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()])