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) ## ## 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. func play_animation(anim_name: String) -> 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 _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) 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() # ============================================================================= # 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