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>
403 lines
13 KiB
GDScript
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()])
|