Files
settled-reach/spikes/quaternius-aesthetic/scripts/spike/showcase_main.gd
T
jpmschweitzerandClaude Opus 4.6 496953f69c feat(assets): add Quaternius Source tier bodies and fitted outfits
Source tier purchase provides 3 body types × 2 genders with .blend
source files: Regular (→Average), Superhero (→Muscular), Teen (→Thin).
Only Heavy body type still needs hand-authoring.

Surface Deform pipeline validated: clothing authored for Regular body
successfully fitted to Superhero and Teen bodies with zero bind
failures across all 12 outfit × body combinations.

Adds source-bodies/, outfits-fitted-source/, outfits-fitted-source-female/,
and Blender fitting scripts for both genders. Showcase updated with
8 body type entries and per-body-type fitted outfit scanning.

Resolves Q-062 (Source tier contents: Regular + Teen confirmed).
Partially resolves Q-060 (Surface Deform quality: binds succeed,
visual clipping is a compositor concern, not pipeline failure).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 14:22:26 +01:00

403 lines
13 KiB
GDScript

extends Node3D
## Quaternius Aesthetic Validation Spike -- showcase harness.
## All character visual logic lives in CharacterVisual.
## This script handles camera, HUD, and input only.
@onready var camera: Camera3D = $Camera3D
@onready var body_select: OptionButton = $HUD/Panel/VBox/BodySelect
@onready var skin_tone_select: OptionButton = $HUD/Panel/VBox/SkinToneSelect
@onready var btn_outline: Button = $HUD/Panel/VBox/BtnOutline
@onready var btn_outfit: Button = $HUD/Panel/VBox/BtnOutfit
@onready var btn_anim: Button = $HUD/Panel/VBox/BtnAnim
@onready var btn_trellis: Button = $HUD/Panel/VBox/BtnTrellis
@onready var btn_tilt: Button = $HUD/Panel/VBox/BtnTilt
# --- Camera ---
var camera_pivot := Vector3(0.0, 0.9, 0.0)
var camera_size_target: float = 5.0
var camera_angle: int = 1 # start at dramatic
var camera_target_rot := Vector3.ZERO
var camera_current_rot := Vector3.ZERO
var camera_orbit_y: float = 45.0
const PIVOT_Y_ZOOMED_OUT: float = 0.9 # full body -- center mass
const PIVOT_Y_ZOOMED_IN: float = 1.5 # face -- upper head
const ZOOM_MIN: float = 1.5
const ZOOM_MAX: float = 30.0
const ZOOM_SPEED: float = 0.8
const PAN_SPEED: float = 8.0
const TILT_SPEED: float = 3.0
const ORBIT_STEP: float = 90.0
const CAM_DIST: float = 20.0
const ANGLE_PRESETS := [
Vector3(-90.0, 0.0, 0.0), # top-down
Vector3(-30.0, 45.0, 0.0), # dramatic
Vector3(-5.0, 45.0, 0.0), # frontal
]
const ANGLE_NAMES := ["top-down", "dramatic", "frontal"]
# --- Character ---
var character: CharacterVisual
var outfit_paths: Array[String] = []
var current_outfit_index: int = -1
var current_anim_index: int = 0
var anim_names: Array[String] = []
## Body entries: each is a dict with either "segmented" (dir path) or "path" (single GLB).
var body_entries: Array[Dictionary] = []
# --- Constants ---
const CHARACTER_Y_OFFSET: float = 0.04
const CHARACTER_Y_ROTATION: float = 45.0
const ANIM_DIR := "res://models/quaternius/animations/"
# --- Lifecycle ---
func _ready() -> void:
camera.projection = Camera3D.PROJECTION_ORTHOGONAL
camera.size = camera_size_target
camera.near = 0.1
camera.far = 100.0
camera_target_rot = ANGLE_PRESETS[camera_angle]
camera_target_rot.y = camera_orbit_y
camera_current_rot = camera_target_rot
_apply_camera()
_build_floor()
_scan_bodies()
# Load the first body (segmented male by default)
var first := body_entries[0] if body_entries.size() > 0 else {}
_load_character(first)
_scan_outfits()
body_select.item_selected.connect(_on_body_selected)
_populate_skin_tones()
skin_tone_select.item_selected.connect(_on_skin_tone_selected)
btn_outline.pressed.connect(_toggle_outline)
btn_outfit.pressed.connect(_cycle_outfit)
btn_anim.pressed.connect(_toggle_animation)
btn_trellis.pressed.connect(_toggle_trellis_head)
btn_tilt.pressed.connect(_toggle_tilt)
_update_hud()
print("Quaternius Aesthetic Spike ready!")
# --- Body scanning ---
func _scan_bodies() -> void:
# Segmented bodies (validated compositor architecture)
body_entries.append({
"name": "Male Muscular (segmented)",
"segmented": "res://models/quaternius/segmented-male/",
})
body_entries.append({
"name": "Female Muscular (segmented)",
"segmented": "res://models/quaternius/segmented-female/",
})
# Source tier bodies with fitted outfits
body_entries.append({
"name": "Male Regular (source)",
"path": "res://models/quaternius/source-bodies/Regular_Male_FullBody.gltf",
"fitted_outfits": "res://models/quaternius/outfits-fitted-source/regular/",
})
body_entries.append({
"name": "Male Superhero (source)",
"path": "res://models/quaternius/source-bodies/Superhero_Male_FullBody.gltf",
"fitted_outfits": "res://models/quaternius/outfits-fitted-source/superhero/",
})
body_entries.append({
"name": "Male Teen (source)",
"path": "res://models/quaternius/source-bodies/Teen_Male_FullBody.gltf",
"fitted_outfits": "res://models/quaternius/outfits-fitted-source/teen/",
})
body_entries.append({
"name": "Female Regular (source)",
"path": "res://models/quaternius/source-bodies/Regular_Female_FullBody.gltf",
"fitted_outfits": "res://models/quaternius/outfits-fitted-source-female/regular/",
})
body_entries.append({
"name": "Female Superhero (source)",
"path": "res://models/quaternius/source-bodies/Superhero_Female_FullBody.gltf",
"fitted_outfits": "res://models/quaternius/outfits-fitted-source-female/superhero/",
})
body_entries.append({
"name": "Female Teen (source)",
"path": "res://models/quaternius/source-bodies/Teen_Female_FullBody.gltf",
"fitted_outfits": "res://models/quaternius/outfits-fitted-source-female/teen/",
})
body_select.clear()
for entry in body_entries:
body_select.add_item(entry["name"])
print("Body types: %d" % body_entries.size())
func _on_body_selected(index: int) -> void:
if index < 0 or index >= body_entries.size():
return
var entry: Dictionary = body_entries[index]
print("Switching body: %s" % entry["name"])
character.queue_free()
_load_character(entry)
_scan_outfits()
_update_hud()
# --- Skin tone ---
func _populate_skin_tones() -> void:
skin_tone_select.clear()
for i in range(CharacterVisual.SKIN_TONES.size()):
var tone: Dictionary = CharacterVisual.SKIN_TONES[i]
var swatch := _create_color_swatch(tone["lit"])
skin_tone_select.add_icon_item(swatch, "")
skin_tone_select.selected = character.skin_tone_index
func _on_skin_tone_selected(index: int) -> void:
character.skin_tone_index = index
_update_hud()
func _create_color_swatch(color: Color) -> ImageTexture:
var img := Image.create(16, 16, false, Image.FORMAT_RGBA8)
img.fill(color)
return ImageTexture.create_from_image(img)
# --- Character setup ---
func _load_character(entry: Dictionary) -> void:
## Create a new CharacterVisual from a body entry dictionary.
## Handles both segmented and unsegmented body paths.
character = CharacterVisual.new()
character.set_shaders(
preload("res://shaders/spike/toon.gdshader"),
preload("res://shaders/spike/toon_masked.gdshader"),
preload("res://shaders/spike/outline.gdshader"),
)
character.position = Vector3(0.0, CHARACTER_Y_OFFSET, 0.0)
character.rotation_degrees.y = CHARACTER_Y_ROTATION
add_child(character)
if entry.has("segmented"):
character.load_body(entry["segmented"])
else:
character.load_unsegmented_body(entry.get("path", ""))
_load_all_animations()
var mesh_count := character.get_content_mesh_count()
print("Character loaded: %d bones, %d meshes" % [character.get_bone_count(), mesh_count])
func _load_all_animations() -> void:
## Scan the animations directory and load all GLB animation libraries.
## Builds the anim_names list for cycling and plays the first idle.
anim_names.clear()
current_anim_index = 0
var dir := DirAccess.open(ANIM_DIR)
if dir == null:
return
var lib_idx := 0
dir.list_dir_begin()
var file_name := dir.get_next()
while file_name != "":
if file_name.ends_with(".glb"):
var lib_name := "ual_%d" % lib_idx
var anims := character.load_animation_library(ANIM_DIR + file_name, lib_name)
for anim_name in anims:
anim_names.append("%s/%s" % [lib_name, anim_name])
lib_idx += 1
file_name = dir.get_next()
dir.list_dir_end()
# Play first idle
for i in range(anim_names.size()):
if "Idle" in anim_names[i]:
current_anim_index = i
character.get_animation_player().play(anim_names[i])
break
# --- Camera ---
func _apply_camera() -> void:
camera.rotation_degrees = camera_current_rot
var basis := Basis.from_euler(camera_current_rot * (PI / 180.0))
camera.position = camera_pivot + basis * Vector3(0, 0, CAM_DIST)
func _process(delta: float) -> void:
camera.size = lerp(camera.size, camera_size_target, 8.0 * delta)
camera_current_rot = camera_current_rot.lerp(camera_target_rot, TILT_SPEED * delta)
# Shift pivot toward face as zoom increases
var zoom_t := 1.0 - (camera.size - ZOOM_MIN) / (ZOOM_MAX - ZOOM_MIN)
camera_pivot.y = lerp(PIVOT_Y_ZOOMED_OUT, PIVOT_Y_ZOOMED_IN, clamp(zoom_t, 0.0, 1.0))
_apply_camera()
camera_target_rot.y = camera_orbit_y
# WASD panning
var input_dir := Vector2.ZERO
if Input.is_key_pressed(KEY_W): input_dir.y += 1.0
if Input.is_key_pressed(KEY_S): input_dir.y -= 1.0
if Input.is_key_pressed(KEY_A): input_dir.x -= 1.0
if Input.is_key_pressed(KEY_D): input_dir.x += 1.0
if input_dir.length() > 0:
input_dir = input_dir.normalized()
var cam_right := camera.global_basis.x
var cam_up := camera.global_basis.y
var pan_right := Vector3(cam_right.x, 0, cam_right.z).normalized()
var pan_up := Vector3(cam_up.x, 0, cam_up.z).normalized()
camera_pivot += (pan_right * input_dir.x + pan_up * input_dir.y) * PAN_SPEED * delta
_apply_camera()
# --- Input ---
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_WHEEL_UP and event.pressed:
camera_size_target = max(ZOOM_MIN, camera_size_target - ZOOM_SPEED)
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN and event.pressed:
camera_size_target = min(ZOOM_MAX, camera_size_target + ZOOM_SPEED)
if event is InputEventKey and event.pressed and not event.echo:
match event.keycode:
KEY_T: _toggle_tilt()
KEY_Q: camera_orbit_y -= ORBIT_STEP
KEY_E: camera_orbit_y += ORBIT_STEP
KEY_2: _toggle_outline()
KEY_3: _cycle_outfit()
KEY_4: _toggle_animation()
KEY_5: _cycle_animation()
KEY_6: _toggle_trellis_head()
KEY_F12: _take_screenshot()
func _toggle_tilt() -> void:
camera_angle = (camera_angle + 1) % ANGLE_PRESETS.size()
camera_target_rot = ANGLE_PRESETS[camera_angle]
camera_target_rot.y = camera_orbit_y
_update_hud()
func _toggle_outline() -> void:
character.outline_enabled = not character.outline_enabled
_update_hud()
func _toggle_animation() -> void:
if character.is_playing():
character.pause_animation()
else:
character.resume_animation()
_update_hud()
func _cycle_animation() -> void:
if anim_names.is_empty():
return
current_anim_index = (current_anim_index + 1) % anim_names.size()
var anim_path := anim_names[current_anim_index]
character.get_animation_player().play(anim_path)
_update_hud()
var _trellis_head_on := false
func _toggle_trellis_head() -> void:
if _trellis_head_on:
character.detach_all_bone_attachments()
_trellis_head_on = false
print("Trellis head: OFF")
else:
character.attach_to_bone("res://models/generated/head.glb", "Head")
_trellis_head_on = true
print("Trellis head: ON")
func _cycle_outfit() -> void:
character.detach_all_outfits()
character.set_body_visible(true)
current_outfit_index += 1
if current_outfit_index >= outfit_paths.size():
current_outfit_index = -1
else:
character.attach_outfit(outfit_paths[current_outfit_index])
_update_hud()
# --- HUD ---
var _screenshot_count: int = 0
func _take_screenshot() -> void:
var img := get_viewport().get_texture().get_image()
var outfit_name := "bare"
if current_outfit_index >= 0 and current_outfit_index < outfit_paths.size():
outfit_name = outfit_paths[current_outfit_index].get_file().get_basename()
var filename := "screenshots/%s_%s_%02d.png" % [ANGLE_NAMES[camera_angle], outfit_name, _screenshot_count]
img.save_png("res://" + filename)
_screenshot_count += 1
print("Screenshot saved: %s" % filename)
func _update_hud() -> void:
btn_outline.text = "[2] Outline: %s" % ("ON" if character.outline_enabled else "OFF")
var outfit_name := "none"
if current_outfit_index >= 0 and current_outfit_index < outfit_paths.size():
outfit_name = outfit_paths[current_outfit_index].get_file().get_basename()
btn_outfit.text = "[3] Outfit: %s" % outfit_name
btn_anim.text = "[4] Pause/Play | [5] %s" % _current_anim_short_name()
btn_trellis.text = "[6] Trellis head: %s" % ("ON" if _trellis_head_on else "OFF")
btn_tilt.text = "[T] Camera: %s" % ANGLE_NAMES[camera_angle]
func _current_anim_short_name() -> String:
if anim_names.is_empty():
return "no anims"
var full := anim_names[current_anim_index]
# Strip library prefix (e.g. "ual_0/Idle_FoldArms" → "Idle_FoldArms")
var slash := full.find("/")
return full.substr(slash + 1) if slash >= 0 else full
# --- Scene setup ---
func _build_floor() -> void:
const FLOOR_HALF_SIZE := 4
for x in range(-FLOOR_HALF_SIZE, FLOOR_HALF_SIZE + 1):
for z in range(-FLOOR_HALF_SIZE, FLOOR_HALF_SIZE + 1):
var mi := MeshInstance3D.new()
var quad := PlaneMesh.new()
quad.size = Vector2(0.95, 0.95)
mi.mesh = quad
mi.position = Vector3(float(x), 0.0, float(z))
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
if (abs(x) + abs(z)) % 2 == 0:
mat.albedo_color = Color(0.35, 0.38, 0.42)
else:
mat.albedo_color = Color(0.28, 0.30, 0.34)
mi.material_override = mat
add_child(mi)
func _scan_outfits() -> void:
## Scan for outfits matching the current body. Uses fitted outfits if available,
## falls back to original Fantasy pack.
outfit_paths.clear()
var idx := body_select.selected if body_select.selected >= 0 else 0
var entry: Dictionary = body_entries[idx] if idx < body_entries.size() else {}
var scan_dir := ""
var extension := ".glb"
if entry.has("fitted_outfits"):
scan_dir = entry["fitted_outfits"]
else:
scan_dir = "res://models/quaternius/outfits-fantasy/full/"
extension = ".gltf"
var dir := DirAccess.open(scan_dir)
if dir:
dir.list_dir_begin()
var file_name := dir.get_next()
while file_name != "":
if file_name.ends_with(extension) and (file_name.begins_with("Male_") or file_name.begins_with("Female_")):
outfit_paths.append(scan_dir + file_name)
file_name = dir.get_next()
dir.list_dir_end()
outfit_paths.sort()
current_outfit_index = -1
print("Available outfits (%s): %d" % [scan_dir.get_base_dir().get_file(), outfit_paths.size()])