New SR_LIVE sandbox scene: CharacterVisual composited in a 3D greybox world derived from server snapshots. Per-leg constant-velocity interpolation keyed to the stance throttle, 'server feet / client eyes' facing (wire octant while moving, client aim octant idle), cadence-synced gait state machine on AnimationPlayer custom blends, D-148 orthographic follow camera (-30deg default, T-cycle presets), sim-space grid shader, camera-side wall cutaway, accumulating never-evict tile store with four-state visibility tint. Additive seams only: InputMapper.facing_angle_provider (2D path unchanged), CharacterVisual.play_animation blend_time param + get_animation_player(). Visual harness gains per-scenario scene field + SR_AUTOPILOT input scripting. 210 new gdUnit assertions across five suites; verified live (230/230 total, clean smoke, screenshot at .cache/screenshots/locomotion_idle_live.png). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
852 lines
31 KiB
GDScript
852 lines
31 KiB
GDScript
class_name CharacterVisual
|
||
extends Node3D
|
||
|
||
## Runtime character visual compositor.
|
||
## Accepts a CharacterVisualDescriptor and assembles the full 3D character from
|
||
## pre-authored asset files. No runtime deformation — selects and attaches meshes.
|
||
##
|
||
## All asset paths derived programmatically from BASE_PATH + descriptor fields:
|
||
## bodies/{body_type}/seg_{name}.glb (D-160: 18 segments per body type)
|
||
## heads/templates/{head_id}.glb (D-161: head as BoneAttachment3D)
|
||
## hair/{hair_id}.glb (BoneAttachment3D to Head bone)
|
||
## clothing/{item_id}/{body_type}.glb (D-162: pre-fitted per body type)
|
||
## accessories/{accessory_id}.glb (BoneAttachment3D per slot→bone map)
|
||
##
|
||
## Public API:
|
||
## load_descriptor(descriptor) — rebuild full character from descriptor
|
||
## set_facing(direction: Vector2|String) — rotate model to match facing direction
|
||
## get_skeleton() — returns the Skeleton3D for external use
|
||
## is_segment_visible(seg_name) — query body segment visibility
|
||
## get_active_coverage(item_id) — return coverage dict for a clothing item
|
||
## get_active_torso_variant() — "full" or "upper"
|
||
## has_slot_node(slot) — whether a named slot (hair, facial_hair) was loaded
|
||
## get_clothing_node_count() — number of clothing MeshInstance3D nodes
|
||
## get_accessory_node_count() — number of accessory BoneAttachment3D nodes
|
||
## get_skin_tone_texture_name(index) — skin tone texture filename key for index
|
||
## get_overhead_anchor() — Marker3D above Head bone for floating UI (#712)
|
||
## get_animation_player() — AnimationPlayer for external anim drivers (T-1088)
|
||
##
|
||
## D-159 (11 body types), D-160 (18 segments), D-161 (head separate),
|
||
## D-162 (clothing pre-fitted), D-163 (heads via BoneAttachment3D), D-164 (skeleton fork)
|
||
|
||
const BASE_PATH := "res://assets/characters/"
|
||
const SKELETON_PATH := BASE_PATH + "skeleton/armature.glb"
|
||
const ANIM_LIBRARY_PATH := BASE_PATH + "animations/ual_standard.glb"
|
||
const SKIN_TONE_DIR := BASE_PATH + "skin_tones/"
|
||
const EYE_IRIS_MASK_PATH := BASE_PATH + "bodies/eye_iris_mask.png"
|
||
const TOON_SHADER_PATH := BASE_PATH + "shaders/toon.gdshader"
|
||
const TOON_MASKED_SHADER_PATH := BASE_PATH + "shaders/toon_masked.gdshader"
|
||
const OUTLINE_SHADER_PATH := BASE_PATH + "shaders/outline.gdshader"
|
||
|
||
## All body segment names in assembly order. D-160: 14 base + 2 swappable torso + 2 face = 18.
|
||
## torso_upper is loaded and hidden by default; clothing coverage reveals it.
|
||
const ALL_SEGMENTS: Array[String] = [
|
||
"head",
|
||
"neck",
|
||
"torso_upper",
|
||
"torso",
|
||
"hips",
|
||
"arm_upper_l",
|
||
"arm_upper_r",
|
||
"arm_lower_l",
|
||
"arm_lower_r",
|
||
"hand_l",
|
||
"hand_r",
|
||
"leg_upper_l",
|
||
"leg_upper_r",
|
||
"leg_lower_l",
|
||
"leg_lower_r",
|
||
"foot_l",
|
||
"foot_r",
|
||
"eyes",
|
||
"eyebrows",
|
||
]
|
||
|
||
## Segments that do NOT receive the skin tone shader — preserve original embedded material.
|
||
const NON_SKIN_SEGMENTS: Array[String] = ["eyes", "eyebrows"]
|
||
|
||
## Segments hidden by default; shown only when a clothing coverage rule requests it.
|
||
const HIDDEN_BY_DEFAULT: Array[String] = [] # all segments visible by default — no swappable variants
|
||
|
||
## Accessory slot → Quaternius bone name (Section 3 of character-asset-organization.md).
|
||
## Bone names verified against armature.glb — Quaternius rig uses "Head" (capital H),
|
||
## lowercase for everything else. NOT Mixamo names (no "Hips", "LeftHand", "Spine2").
|
||
## 12 slots total: head-attached (hat/goggles/mask/earring_l/earring_r/necklace),
|
||
## spine-attached (backpack/belt), wrist-attached, hand-held.
|
||
const SLOT_TO_BONE: Dictionary = {
|
||
"hat": "Head",
|
||
"goggles": "Head",
|
||
"mask": "Head",
|
||
"earring_l": "Head",
|
||
"earring_r": "Head",
|
||
"necklace": "Head",
|
||
"backpack": "spine_03",
|
||
"belt": "pelvis",
|
||
"wrist_l": "lowerarm_l",
|
||
"wrist_r": "lowerarm_r",
|
||
"held_l": "hand_l",
|
||
"held_r": "hand_r",
|
||
}
|
||
|
||
## Skin tone palette: 9 tones, index 0–8 matching skin_tones/ texture filenames.
|
||
const SKIN_TONES: Array[Dictionary] = [
|
||
{"lit": Color("f5e8e0"), "shadow": Color("c8b4b0"), "tex": "pale_cool"},
|
||
{"lit": Color("f0c8a0"), "shadow": Color("c89870"), "tex": "pale_warm"},
|
||
{"lit": Color("d4a878"), "shadow": Color("a87850"), "tex": "light_olive"},
|
||
{"lit": Color("b87840"), "shadow": Color("885520"), "tex": "medium_golden"},
|
||
{"lit": Color("c09060"), "shadow": Color("906040"), "tex": "olive_warm"},
|
||
{"lit": Color("8b5a2b"), "shadow": Color("5a3010"), "tex": "medium_brown"},
|
||
{"lit": Color("5c3317"), "shadow": Color("3a1a08"), "tex": "deep_brown"},
|
||
{"lit": Color("3d2010"), "shadow": Color("200e04"), "tex": "very_deep_warm"},
|
||
{"lit": Color("2d1a12"), "shadow": Color("180a06"), "tex": "very_deep_cool"},
|
||
]
|
||
|
||
const OUTLINE_WIDTH: float = 0.006
|
||
const OUTLINE_COLOR: Color = Color(0.08, 0.08, 0.12)
|
||
const OUTLINE_SIZE_THRESHOLD: float = 0.1 # skip tiny meshes (eyes, eyebrows)
|
||
|
||
# --- Internal state ---
|
||
|
||
var _skeleton: Skeleton3D = null
|
||
var _body_root: Node3D = null
|
||
var _anim_player: AnimationPlayer = null
|
||
|
||
var _body_meshes: Array[MeshInstance3D] = []
|
||
var _clothing_meshes: Array[MeshInstance3D] = []
|
||
var _bone_attachments: Array[BoneAttachment3D] = []
|
||
var _outline_nodes: Array[MeshInstance3D] = []
|
||
var _accessory_attachments: Array[BoneAttachment3D] = []
|
||
var _overhead_anchor: Marker3D = null
|
||
var _overhead_attachment: BoneAttachment3D = null
|
||
|
||
# Inspectable state for tests
|
||
var _active_torso_variant: String = "full"
|
||
var _active_coverages: Dictionary = {} # item_id -> coverage dict
|
||
var _loaded_slots: Array[String] = [] # "hair", "facial_hair", etc.
|
||
|
||
var _toon_shader: Shader = null
|
||
var _toon_masked_shader: Shader = null
|
||
var _outline_shader: Shader = null
|
||
var _white_mask: ImageTexture = null # 1x1 white pixel — forces full-body tinting
|
||
var _iris_mask: Texture2D = null # iris-only mask from T_Eye_Split.png
|
||
|
||
|
||
func _ready() -> void:
|
||
_load_shaders()
|
||
|
||
|
||
func _load_shaders() -> void:
|
||
_toon_shader = load(TOON_SHADER_PATH) as Shader
|
||
_toon_masked_shader = load(TOON_MASKED_SHADER_PATH) as Shader
|
||
_outline_shader = load(OUTLINE_SHADER_PATH) as Shader
|
||
if not _toon_shader or not _toon_masked_shader or not _outline_shader:
|
||
push_error("CharacterVisual: shader load failed — check %s" % BASE_PATH)
|
||
# 1x1 white pixel texture — used as recolor_mask to enable full-body skin tinting
|
||
var img := Image.create(1, 1, false, Image.FORMAT_R8)
|
||
img.set_pixel(0, 0, Color.WHITE)
|
||
_white_mask = ImageTexture.create_from_image(img)
|
||
# Iris mask — generated from T_Eye_Split.png green channel
|
||
if ResourceLoader.exists(EYE_IRIS_MASK_PATH):
|
||
_iris_mask = load(EYE_IRIS_MASK_PATH) as Texture2D
|
||
|
||
|
||
# =============================================================================
|
||
# Public API
|
||
# =============================================================================
|
||
|
||
|
||
## Rebuild the full character from a CharacterVisualDescriptor.
|
||
func load_descriptor(descriptor: CharacterVisualDescriptor) -> void:
|
||
_clear()
|
||
_load_skeleton()
|
||
if _skeleton == null:
|
||
push_error("CharacterVisual: skeleton failed to load — aborting load_descriptor")
|
||
return
|
||
_load_body_segments(descriptor)
|
||
_load_head(descriptor)
|
||
_load_hair(descriptor)
|
||
_load_facial_hair(descriptor)
|
||
_load_eyebrows(descriptor)
|
||
_load_clothing(descriptor)
|
||
_load_accessories(descriptor)
|
||
_rebuild_outlines()
|
||
_load_animations()
|
||
|
||
|
||
## Set character facing from a 2D direction vector or a named string direction.
|
||
## Vector2(0, 1) = south (toward camera), Vector2(1, 0) = east, etc.
|
||
## String values: "south", "north", "east", "west", "southwest", "northeast", etc.
|
||
func set_facing(direction: Variant) -> void:
|
||
if direction is Vector2:
|
||
# Convert 2D game-space direction to 3D Y rotation.
|
||
# atan2(x, y): Vector2(0,1)→0°, (1,0)→-90°, (0,-1)→180°, (-1,0)→90°
|
||
rotation.y = -atan2((direction as Vector2).x, (direction as Vector2).y)
|
||
elif direction is String:
|
||
var angles: Dictionary = {
|
||
"south": 0.0,
|
||
"southwest": 45.0,
|
||
"west": 90.0,
|
||
"northwest": 135.0,
|
||
"north": 180.0,
|
||
"northeast": -135.0,
|
||
"east": -90.0,
|
||
"southeast": -45.0,
|
||
}
|
||
rotation_degrees.y = angles.get((direction as String).to_lower(), 0.0)
|
||
|
||
|
||
## Alias kept for EntityRenderer compatibility.
|
||
func update_facing(direction: String) -> void:
|
||
set_facing(direction)
|
||
|
||
|
||
func get_skeleton() -> Skeleton3D:
|
||
return _skeleton
|
||
|
||
|
||
## Return whether a named body segment is currently visible.
|
||
func is_segment_visible(seg_name: String) -> bool:
|
||
for mi in _body_meshes:
|
||
if mi.get_meta("segment", "") == seg_name:
|
||
return mi.visible
|
||
return false
|
||
|
||
|
||
## Return the coverage.json data for a clothing item_id, or empty dict if unavailable.
|
||
func get_active_coverage(item_id: String) -> Dictionary:
|
||
return _active_coverages.get(item_id, {})
|
||
|
||
|
||
## Return the active torso variant: "full" or "upper".
|
||
func get_active_torso_variant() -> String:
|
||
return _active_torso_variant
|
||
|
||
|
||
## Return whether a named slot (hair, facial_hair, eyebrow) produced a scene node.
|
||
func has_slot_node(slot: String) -> bool:
|
||
return slot in _loaded_slots
|
||
|
||
|
||
## Return count of clothing MeshInstance3D nodes currently attached.
|
||
func get_clothing_node_count() -> int:
|
||
return _clothing_meshes.size()
|
||
|
||
|
||
## Return count of accessory BoneAttachment3D nodes currently attached.
|
||
func get_accessory_node_count() -> int:
|
||
return _accessory_attachments.size()
|
||
|
||
|
||
## Return the skin tone texture filename key for a given index (without .png extension).
|
||
func get_skin_tone_texture_name(index: int) -> String:
|
||
return SKIN_TONES[clampi(index, 0, SKIN_TONES.size() - 1)]["tex"]
|
||
|
||
|
||
## Return the overhead anchor Marker3D (#712). Null if skeleton not loaded.
|
||
## Anchor point for floating UI elements: status indicators, thought bubbles,
|
||
## alert markers, speech icons. Positioned ~0.3m above the Head bone.
|
||
func get_overhead_anchor() -> Marker3D:
|
||
return _overhead_anchor
|
||
|
||
|
||
# =============================================================================
|
||
# Internal — teardown
|
||
# =============================================================================
|
||
|
||
|
||
func _clear() -> void:
|
||
# Outline nodes are duplicates of body/clothing meshes — must be freed FIRST,
|
||
# before the source meshes are removed. _rebuild_outlines() is not called here
|
||
# because we are tearing down, not rebuilding; the rebuild happens at the end
|
||
# of load_descriptor() once the new character is assembled.
|
||
for node in _outline_nodes:
|
||
if is_instance_valid(node):
|
||
if node.get_parent():
|
||
node.get_parent().remove_child(node)
|
||
node.free()
|
||
_outline_nodes.clear()
|
||
|
||
# #712: free overhead anchor before general bone attachments
|
||
if is_instance_valid(_overhead_attachment) and _overhead_attachment.get_parent():
|
||
_overhead_attachment.get_parent().remove_child(_overhead_attachment)
|
||
_overhead_attachment.free()
|
||
_overhead_attachment = null
|
||
_overhead_anchor = null
|
||
|
||
for att in _bone_attachments:
|
||
if is_instance_valid(att) and att.get_parent():
|
||
att.get_parent().remove_child(att)
|
||
att.free()
|
||
_bone_attachments.clear()
|
||
_accessory_attachments.clear()
|
||
_loaded_slots.clear()
|
||
|
||
for mi in _clothing_meshes:
|
||
if is_instance_valid(mi) and mi.get_parent():
|
||
mi.get_parent().remove_child(mi)
|
||
mi.free()
|
||
_clothing_meshes.clear()
|
||
|
||
_body_meshes.clear()
|
||
_active_coverages.clear()
|
||
_active_torso_variant = "full"
|
||
|
||
if _anim_player and is_instance_valid(_anim_player):
|
||
_anim_player.stop()
|
||
if _anim_player.get_parent():
|
||
_anim_player.get_parent().remove_child(_anim_player)
|
||
_anim_player.free()
|
||
_anim_player = null
|
||
|
||
if is_instance_valid(_body_root) and _body_root.get_parent():
|
||
_body_root.get_parent().remove_child(_body_root)
|
||
_body_root.queue_free()
|
||
_body_root = null
|
||
_skeleton = null
|
||
|
||
|
||
# =============================================================================
|
||
# Internal — skeleton
|
||
# =============================================================================
|
||
|
||
|
||
func _load_skeleton() -> void:
|
||
if not ResourceLoader.exists(SKELETON_PATH):
|
||
push_error("CharacterVisual: skeleton not found at %s" % SKELETON_PATH)
|
||
return
|
||
var scene := load(SKELETON_PATH) as PackedScene
|
||
if scene == null:
|
||
push_error("CharacterVisual: skeleton PackedScene is null")
|
||
return
|
||
_body_root = scene.instantiate()
|
||
add_child(_body_root)
|
||
_skeleton = _find_skeleton(_body_root)
|
||
if _skeleton == null:
|
||
push_error("CharacterVisual: no Skeleton3D node in %s" % SKELETON_PATH)
|
||
return
|
||
# Log any meshes that came with the armature import
|
||
var armature_meshes := _collect_meshes(_body_root)
|
||
if not armature_meshes.is_empty():
|
||
print("--- Armature import contains ", armature_meshes.size(), " meshes ---")
|
||
for m in armature_meshes:
|
||
print(" armature mesh: ", m.name, " visible=", m.visible)
|
||
_validate_slot_bones()
|
||
_create_overhead_anchor()
|
||
|
||
|
||
## Warn on any SLOT_TO_BONE entry that doesn't exist in the loaded skeleton.
|
||
## Catches bone name mismatches early rather than silently failing on accessory attach.
|
||
func _validate_slot_bones() -> void:
|
||
for slot: String in SLOT_TO_BONE:
|
||
var bone_name: String = SLOT_TO_BONE[slot]
|
||
if _skeleton.find_bone(bone_name) == -1:
|
||
push_warning(
|
||
(
|
||
"CharacterVisual: SLOT_TO_BONE['%s'] = '%s' — bone not found in skeleton"
|
||
% [slot, bone_name]
|
||
)
|
||
)
|
||
|
||
|
||
## #712: Create a Marker3D anchored ~0.3m above the Head bone via BoneAttachment3D.
|
||
## Anchor point for floating UI elements (status indicators, thought bubbles, etc.).
|
||
func _create_overhead_anchor() -> void:
|
||
if _skeleton == null:
|
||
return
|
||
var bone_idx := _skeleton.find_bone("Head")
|
||
if bone_idx == -1:
|
||
push_warning("CharacterVisual: Head bone not found — overhead anchor not created")
|
||
return
|
||
_overhead_attachment = BoneAttachment3D.new()
|
||
_overhead_attachment.bone_name = "Head"
|
||
_overhead_attachment.name = "OverheadAttachment"
|
||
_skeleton.add_child(_overhead_attachment)
|
||
|
||
_overhead_anchor = Marker3D.new()
|
||
_overhead_anchor.name = "OverheadAnchor"
|
||
_overhead_anchor.position = Vector3(0, 0.3, 0)
|
||
_overhead_attachment.add_child(_overhead_anchor)
|
||
|
||
|
||
# =============================================================================
|
||
# Internal — body segments (D-160)
|
||
# =============================================================================
|
||
|
||
|
||
func _load_body_segments(desc: CharacterVisualDescriptor) -> void:
|
||
var body_dir := BASE_PATH + "bodies/%s/" % desc.body_type_key()
|
||
var tone := SKIN_TONES[clampi(desc.skin_tone, 0, SKIN_TONES.size() - 1)]
|
||
|
||
for seg_name in ALL_SEGMENTS:
|
||
var seg_path := body_dir + "seg_%s.glb" % seg_name
|
||
if not ResourceLoader.exists(seg_path):
|
||
continue # segment not yet authored — skip gracefully
|
||
var seg_scene := load(seg_path) as PackedScene
|
||
if seg_scene == null:
|
||
continue
|
||
var inst := seg_scene.instantiate()
|
||
for mi in _collect_meshes(inst):
|
||
mi.get_parent().remove_child(mi)
|
||
mi.owner = null
|
||
mi.set_meta("segment", seg_name)
|
||
mi.visible = seg_name not in HIDDEN_BY_DEFAULT
|
||
if seg_name == "eyebrows":
|
||
_apply_tinted_shader(mi, desc.eyebrow_tint, _white_mask)
|
||
elif seg_name == "eyes":
|
||
# Iris-only tinting using mask derived from T_Eye_Split.png green channel
|
||
var eye_mask: Texture2D = _iris_mask if _iris_mask else null
|
||
_apply_tinted_shader(mi, desc.eye_color, eye_mask)
|
||
else:
|
||
_apply_body_shader(mi, seg_name, tone)
|
||
_skeleton.add_child(mi)
|
||
_body_meshes.append(mi)
|
||
inst.queue_free()
|
||
|
||
|
||
func _apply_body_shader(mi: MeshInstance3D, seg_name: String, tone: Dictionary) -> void:
|
||
if mi.mesh == null:
|
||
return
|
||
var is_skin := seg_name not in NON_SKIN_SEGMENTS
|
||
for surf in range(mi.mesh.get_surface_count()):
|
||
if not is_skin:
|
||
# Eyes/eyebrows: preserve the original embedded texture from the GLB.
|
||
var orig: Material = mi.mesh.surface_get_material(surf)
|
||
if orig != null:
|
||
mi.set_surface_override_material(surf, orig)
|
||
continue
|
||
# Skin body segments: use embedded texture for per-body-type detail,
|
||
# with skin tone tint applied via full white recolor mask.
|
||
var orig_mat: Material = mi.mesh.surface_get_material(surf)
|
||
var orig_tex: Texture2D = null
|
||
if orig_mat is StandardMaterial3D:
|
||
orig_tex = (orig_mat as StandardMaterial3D).albedo_texture
|
||
elif orig_mat is BaseMaterial3D:
|
||
orig_tex = (orig_mat as BaseMaterial3D).albedo_texture
|
||
# Fallback to generic skin tone texture if no embedded texture found
|
||
if orig_tex == null:
|
||
var tex_path := SKIN_TONE_DIR + "%s.png" % tone["tex"]
|
||
orig_tex = load(tex_path) as Texture2D
|
||
var mat := ShaderMaterial.new()
|
||
if orig_tex and _toon_masked_shader:
|
||
mat.shader = _toon_masked_shader
|
||
mat.set_shader_parameter("albedo_tex", orig_tex)
|
||
mat.set_shader_parameter("recolor_mask", _white_mask)
|
||
mat.set_shader_parameter("tint_color", tone["lit"])
|
||
mat.set_shader_parameter("shadow_strength", 0.25)
|
||
elif _toon_shader:
|
||
mat.shader = _toon_shader
|
||
mat.set_shader_parameter("base_color", tone["lit"])
|
||
mat.set_shader_parameter("shadow_color", tone["shadow"])
|
||
mat.set_shader_parameter("shadow_threshold", 0.4)
|
||
# Use material_override to fully replace ALL surface materials.
|
||
# set_surface_override_material can leave the original StandardMaterial3D
|
||
# partially active, which may prevent visible=false from working.
|
||
mi.material_override = mat
|
||
return # material_override applies to all surfaces — no need to loop
|
||
|
||
|
||
# =============================================================================
|
||
# Internal — head, hair, facial hair, eyebrows (BoneAttachment3D) (D-161)
|
||
# =============================================================================
|
||
|
||
|
||
func _load_head(desc: CharacterVisualDescriptor) -> void:
|
||
if desc.head_id.is_empty():
|
||
return
|
||
var path := BASE_PATH + "heads/templates/%s.glb" % desc.head_id
|
||
if _attach_to_bone(path, "Head") != null:
|
||
_loaded_slots.append("head")
|
||
|
||
|
||
func _load_hair(desc: CharacterVisualDescriptor) -> void:
|
||
if desc.hair_id.is_empty() or desc.hair_id == "bald":
|
||
return
|
||
var path := BASE_PATH + "hair/%s.glb" % desc.hair_id
|
||
if _attach_to_bone(path, "Head", desc.hair_tint) != null:
|
||
_loaded_slots.append("hair")
|
||
|
||
|
||
func _load_facial_hair(desc: CharacterVisualDescriptor) -> void:
|
||
if desc.facial_hair_id.is_empty():
|
||
return
|
||
var path := BASE_PATH + "facial_hair/%s.glb" % desc.facial_hair_id
|
||
if _attach_to_bone(path, "Head", desc.facial_hair_tint) != null:
|
||
_loaded_slots.append("facial_hair")
|
||
|
||
|
||
func _load_eyebrows(_desc: CharacterVisualDescriptor) -> void:
|
||
# Eyebrows come from the body segment (seg_eyebrows) — not loaded separately.
|
||
# The seg_eyebrows mesh is part of the body and matches the body type's head shape.
|
||
# Loading separate eyebrow GLBs caused z-fighting and size mismatches across body types.
|
||
pass
|
||
|
||
|
||
func _attach_to_bone(
|
||
path: String, bone_name: String, tint: Color = Color.WHITE, _render_priority: int = 0
|
||
) -> BoneAttachment3D:
|
||
if _skeleton == null:
|
||
return null
|
||
if not ResourceLoader.exists(path):
|
||
push_warning("CharacterVisual: asset not found (expected): %s" % path)
|
||
return null
|
||
var scene := load(path) as PackedScene
|
||
if scene == null:
|
||
return null
|
||
var bone_idx := _skeleton.find_bone(bone_name)
|
||
if bone_idx == -1:
|
||
push_error("CharacterVisual: bone '%s' not found in skeleton" % bone_name)
|
||
return null
|
||
var attachment := BoneAttachment3D.new()
|
||
attachment.bone_name = bone_name
|
||
attachment.bone_idx = bone_idx
|
||
_skeleton.add_child(attachment)
|
||
_bone_attachments.append(attachment)
|
||
|
||
# Load recolor mask sidecar: {asset_name}_mask.png alongside the GLB.
|
||
# Without this, toon_masked recolor_mask defaults to black → tint_color ignored.
|
||
var mask_path := path.get_basename() + "_mask.png"
|
||
var mask_tex: Texture2D = null
|
||
if ResourceLoader.exists(mask_path):
|
||
mask_tex = load(mask_path) as Texture2D
|
||
|
||
var inst := scene.instantiate()
|
||
var meshes := _collect_meshes(inst)
|
||
|
||
# Hair/eyebrow GLBs are skinned meshes with their own armature.
|
||
# Reparent the mesh onto OUR skeleton so the shared bone names drive it.
|
||
# This avoids the BoneAttachment3D double-offset problem.
|
||
var has_skin := false
|
||
for mi in meshes:
|
||
if mi.skin != null:
|
||
has_skin = true
|
||
break
|
||
|
||
if has_skin:
|
||
# Skinned mesh (hair, eyebrows) — same structure as body segments.
|
||
# Add mesh directly to our skeleton; matching bone names drive it.
|
||
_skeleton.remove_child(attachment)
|
||
attachment.free()
|
||
_bone_attachments.pop_back()
|
||
for mi in meshes:
|
||
mi.get_parent().remove_child(mi)
|
||
mi.owner = null
|
||
_skeleton.add_child(mi)
|
||
_apply_tinted_shader(mi, tint, mask_tex)
|
||
inst.queue_free()
|
||
return null
|
||
# Unskinned — rigid attachment via BoneAttachment3D
|
||
for mi in meshes:
|
||
mi.get_parent().remove_child(mi)
|
||
mi.owner = null
|
||
attachment.add_child(mi)
|
||
_apply_tinted_shader(mi, tint, mask_tex)
|
||
inst.queue_free()
|
||
return attachment
|
||
|
||
|
||
# =============================================================================
|
||
# Internal — clothing (D-162): coverage.json → segment hiding + torso variant
|
||
# =============================================================================
|
||
|
||
|
||
func _load_clothing(desc: CharacterVisualDescriptor) -> void:
|
||
if _skeleton == null or desc.clothing_slots.is_empty():
|
||
return
|
||
|
||
var hidden_segments: Array[String] = []
|
||
_active_torso_variant = "full"
|
||
|
||
for slot: String in desc.clothing_slots:
|
||
var item_id: String = desc.clothing_slots[slot]
|
||
var item_dir := BASE_PATH + "clothing/%s/" % item_id
|
||
var coverage := _read_coverage(item_dir + "coverage.json")
|
||
_active_coverages[item_id] = coverage
|
||
|
||
var glb_path := item_dir + "%s.glb" % desc.body_type_key()
|
||
|
||
# Accumulate segment coverage across all equipped clothing
|
||
if coverage.has("hides"):
|
||
for seg_name in coverage["hides"]:
|
||
if (seg_name as String) not in hidden_segments:
|
||
hidden_segments.append(seg_name)
|
||
# Only upgrade to "upper" — never downgrade back to "full" if a later item
|
||
# uses "full". Most-revealing torso variant wins across all equipped clothing.
|
||
if coverage.get("torso_variant") == "upper":
|
||
_active_torso_variant = "upper"
|
||
|
||
# Load recolor mask: shared across all body-type variants (same UV layout after Surface Deform).
|
||
var mask_path := item_dir + "reference_mask.png"
|
||
var mask_tex: Texture2D = null
|
||
if ResourceLoader.exists(mask_path):
|
||
mask_tex = load(mask_path) as Texture2D
|
||
|
||
# Load the body-type-fitted clothing GLB
|
||
if ResourceLoader.exists(glb_path):
|
||
var scene := load(glb_path) as PackedScene
|
||
if scene:
|
||
var inst := scene.instantiate()
|
||
var tints: Array = desc.clothing_tints.get(item_id, [])
|
||
for mi in _collect_meshes(inst):
|
||
mi.get_parent().remove_child(mi)
|
||
mi.owner = null
|
||
_apply_clothing_shader(mi, tints, mask_tex)
|
||
_skeleton.add_child(mi)
|
||
_clothing_meshes.append(mi)
|
||
inst.queue_free()
|
||
|
||
# Segment hiding disabled — solidified clothing sits on top of body.
|
||
# Hiding is reserved for amputation/prosthetics via the debug tab or game logic.
|
||
|
||
|
||
func _read_coverage(path: String) -> Dictionary:
|
||
if not FileAccess.file_exists(path):
|
||
return {}
|
||
var file := FileAccess.open(path, FileAccess.READ)
|
||
if file == null:
|
||
return {}
|
||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||
file.close()
|
||
if parsed is Dictionary:
|
||
return parsed
|
||
push_warning("CharacterVisual: coverage.json malformed at %s" % path)
|
||
return {}
|
||
|
||
|
||
func _apply_clothing_shader(mi: MeshInstance3D, tints: Array, mask_tex: Texture2D = null) -> void:
|
||
if mi.mesh == null:
|
||
return
|
||
var tint: Color = tints[0] if not tints.is_empty() else Color(0.7, 0.65, 0.6)
|
||
for surf in range(mi.mesh.get_surface_count()):
|
||
var orig: Material = mi.mesh.surface_get_material(surf)
|
||
var tex := _get_albedo_texture(orig)
|
||
var mat := ShaderMaterial.new()
|
||
if tex and _toon_masked_shader:
|
||
mat.shader = _toon_masked_shader
|
||
mat.set_shader_parameter("albedo_tex", tex)
|
||
mat.set_shader_parameter("tint_color", tint)
|
||
mat.set_shader_parameter("shadow_strength", 0.2)
|
||
if mask_tex:
|
||
mat.set_shader_parameter("recolor_mask", mask_tex)
|
||
elif _toon_shader:
|
||
mat.shader = _toon_shader
|
||
mat.set_shader_parameter("base_color", tint)
|
||
mat.set_shader_parameter("shadow_color", tint.darkened(0.4))
|
||
mat.set_shader_parameter("shadow_threshold", 0.4)
|
||
mi.set_surface_override_material(surf, mat)
|
||
|
||
|
||
# =============================================================================
|
||
# Internal — accessories (BoneAttachment3D per slot→bone)
|
||
# =============================================================================
|
||
|
||
|
||
func _load_accessories(desc: CharacterVisualDescriptor) -> void:
|
||
for slot: String in desc.accessory_slots:
|
||
var item_id: String = desc.accessory_slots[slot]
|
||
var bone_name: String = SLOT_TO_BONE.get(slot, "")
|
||
if bone_name.is_empty():
|
||
push_warning("CharacterVisual: unknown accessory slot '%s'" % slot)
|
||
continue
|
||
var path := BASE_PATH + "accessories/%s.glb" % item_id
|
||
# accessory_tints is now Array[Color] (Primary + Secondary). Pass Primary ([0]).
|
||
var tints: Array = desc.accessory_tints.get(item_id, [])
|
||
var tint: Color = tints[0] if not tints.is_empty() else Color.WHITE
|
||
var att := _attach_to_bone(path, bone_name, tint)
|
||
if att != null:
|
||
# Track accessory attachments separately for get_accessory_node_count()
|
||
_accessory_attachments.append(att)
|
||
|
||
|
||
# =============================================================================
|
||
# Internal — tinting (hair, accessories, bone-attached assets with tint)
|
||
# =============================================================================
|
||
|
||
|
||
func _apply_tinted_shader(
|
||
mi: MeshInstance3D, tint: Color, mask_tex: Texture2D = null, render_priority: int = 0
|
||
) -> void:
|
||
if mi.mesh == null:
|
||
return
|
||
for surf in range(mi.mesh.get_surface_count()):
|
||
var orig: Material = mi.mesh.surface_get_material(surf)
|
||
var tex := _get_albedo_texture(orig)
|
||
var mat := ShaderMaterial.new()
|
||
if tex and _toon_masked_shader:
|
||
mat.shader = _toon_masked_shader
|
||
mat.set_shader_parameter("albedo_tex", tex)
|
||
mat.set_shader_parameter("tint_color", tint)
|
||
mat.set_shader_parameter("shadow_strength", 0.2)
|
||
if mask_tex:
|
||
mat.set_shader_parameter("recolor_mask", mask_tex)
|
||
elif _toon_shader:
|
||
mat.shader = _toon_shader
|
||
mat.set_shader_parameter("base_color", tint)
|
||
mat.set_shader_parameter("shadow_color", tint.darkened(0.4))
|
||
mat.set_shader_parameter("shadow_threshold", 0.4)
|
||
mat.render_priority = render_priority
|
||
mi.set_surface_override_material(surf, mat)
|
||
|
||
|
||
# =============================================================================
|
||
# Internal — outline pass (inverted hull, cull_front)
|
||
# =============================================================================
|
||
|
||
|
||
func _rebuild_outlines() -> void:
|
||
for node in _outline_nodes:
|
||
if is_instance_valid(node):
|
||
if node.get_parent():
|
||
node.get_parent().remove_child(node)
|
||
node.free()
|
||
_outline_nodes.clear()
|
||
|
||
if _outline_shader == null:
|
||
return
|
||
|
||
var outline_mat := ShaderMaterial.new()
|
||
outline_mat.shader = _outline_shader
|
||
outline_mat.set_shader_parameter("outline_color", OUTLINE_COLOR)
|
||
outline_mat.set_shader_parameter("outline_width", OUTLINE_WIDTH)
|
||
|
||
var all_meshes: Array[MeshInstance3D] = []
|
||
all_meshes.append_array(_body_meshes)
|
||
all_meshes.append_array(_clothing_meshes)
|
||
|
||
for mi in all_meshes:
|
||
if mi.mesh == null or not mi.visible:
|
||
continue
|
||
if mi.mesh.get_aabb().size.y < OUTLINE_SIZE_THRESHOLD:
|
||
continue
|
||
var outline_mi := mi.duplicate() as MeshInstance3D
|
||
outline_mi.name = "_outline_%s" % mi.name
|
||
outline_mi.material_override = outline_mat
|
||
mi.get_parent().add_child(outline_mi)
|
||
_outline_nodes.append(outline_mi)
|
||
|
||
|
||
# =============================================================================
|
||
# Animation
|
||
# =============================================================================
|
||
|
||
|
||
func _load_animations() -> void:
|
||
if _skeleton == null:
|
||
return
|
||
var anim_scene: PackedScene = load(ANIM_LIBRARY_PATH) as PackedScene
|
||
if anim_scene == null:
|
||
push_warning("CharacterVisual: animation library not found at %s" % ANIM_LIBRARY_PATH)
|
||
return
|
||
var anim_root: Node = anim_scene.instantiate()
|
||
# Find the AnimationPlayer in the imported GLB scene
|
||
var source_player: AnimationPlayer = null
|
||
for child in anim_root.get_children():
|
||
if child is AnimationPlayer:
|
||
source_player = child as AnimationPlayer
|
||
break
|
||
if source_player == null:
|
||
# Try deeper — some GLB imports nest the player
|
||
for child in anim_root.get_children():
|
||
for grandchild in child.get_children():
|
||
if grandchild is AnimationPlayer:
|
||
source_player = grandchild as AnimationPlayer
|
||
break
|
||
if source_player:
|
||
break
|
||
if source_player == null:
|
||
push_warning("CharacterVisual: no AnimationPlayer found in animation library")
|
||
anim_root.queue_free()
|
||
return
|
||
# Parent AnimationPlayer to _body_root (the imported scene root) so that
|
||
# animation track paths like "Armature/Skeleton3D:bone_name" resolve correctly.
|
||
# The imported GLB has structure: root > Armature > Skeleton3D.
|
||
_anim_player = AnimationPlayer.new()
|
||
_anim_player.name = "AnimPlayer"
|
||
_body_root.add_child(_anim_player)
|
||
# Copy animation libraries from the source player
|
||
for lib_name in source_player.get_animation_library_list():
|
||
var lib: AnimationLibrary = source_player.get_animation_library(lib_name)
|
||
_anim_player.add_animation_library(lib_name, lib.duplicate())
|
||
anim_root.queue_free()
|
||
# Play idle if available
|
||
play_animation("idle")
|
||
|
||
|
||
## Play a named animation. Searches all libraries for a matching name.
|
||
## blend_time >= 0.0 is passed to AnimationPlayer.play() as custom_blend (crossfade
|
||
## seconds); the -1.0 default preserves the original hard-cut behavior for existing
|
||
## callers (T-1088 design §6.3 — the locomotion gait machine is the first blend user).
|
||
func play_animation(anim_name: String, blend_time: float = -1.0) -> void:
|
||
if _anim_player == null:
|
||
return
|
||
# Search across all libraries for the animation
|
||
for lib_name in _anim_player.get_animation_library_list():
|
||
var lib: AnimationLibrary = _anim_player.get_animation_library(lib_name)
|
||
if lib.has_animation(anim_name):
|
||
var full_name: String = lib_name + "/" + anim_name if lib_name != "" else anim_name
|
||
if blend_time >= 0.0:
|
||
_anim_player.play(full_name, blend_time)
|
||
else:
|
||
_anim_player.play(full_name)
|
||
return
|
||
# Try common idle variants
|
||
for variant in ["Idle", "idle_01", "Idle_01", "breathing_idle", "Breathing_Idle"]:
|
||
if anim_name == "idle" and variant != anim_name:
|
||
play_animation(variant, blend_time)
|
||
return
|
||
push_warning("CharacterVisual: animation '%s' not found in any library" % anim_name)
|
||
|
||
|
||
## Stop all animations and return to rest pose.
|
||
func stop_animation() -> void:
|
||
if _anim_player:
|
||
_anim_player.stop()
|
||
|
||
|
||
## Return the internal AnimationPlayer ("AnimPlayer") — speed_scale and clip-phase
|
||
## access for external animation drivers (T-1088 gait machine; Q-063 footstep seam).
|
||
## Null until load_descriptor() has run; every load_descriptor() destroys and
|
||
## recreates the player, so callers must re-fetch — never cache across reloads.
|
||
func get_animation_player() -> AnimationPlayer:
|
||
return _anim_player
|
||
|
||
|
||
# =============================================================================
|
||
# Static helpers
|
||
# =============================================================================
|
||
|
||
|
||
static func _find_skeleton(root: Node) -> Skeleton3D:
|
||
if root is Skeleton3D:
|
||
return root as Skeleton3D
|
||
for child in root.get_children():
|
||
var found := _find_skeleton(child)
|
||
if found:
|
||
return found
|
||
return null
|
||
|
||
|
||
static func _collect_meshes(root: Node) -> Array[MeshInstance3D]:
|
||
var result: Array[MeshInstance3D] = []
|
||
if root is MeshInstance3D:
|
||
result.append(root as MeshInstance3D)
|
||
for child in root.get_children():
|
||
result.append_array(_collect_meshes(child))
|
||
return result
|
||
|
||
|
||
static func _get_albedo_texture(mat: Material) -> Texture2D:
|
||
if mat == null:
|
||
return null
|
||
if mat is StandardMaterial3D:
|
||
return (mat as StandardMaterial3D).albedo_texture
|
||
if mat is BaseMaterial3D:
|
||
return (mat as BaseMaterial3D).albedo_texture
|
||
if mat is ShaderMaterial:
|
||
for p: String in [
|
||
"albedo_tex", "albedo_texture", "texture_albedo", "Hair_Texture", "BaseColor"
|
||
]:
|
||
var val: Variant = (mat as ShaderMaterial).get_shader_parameter(p)
|
||
if val is Texture2D:
|
||
return val as Texture2D
|
||
return null
|