Files
settled-reach/client/scripts/rendering/character_visual.gd
T
jpmschweitzerandClaude Opus 4.6 4eb54a6f7a fix(client): resolve all gdlint warnings — zero warnings policy
Fix 354 gdlint warnings across 65 files: 194 class-definitions-order
(reorder declarations), 138 max-line-length (split long lines),
22 code issues (unused args, no-else-return, naming). Update .gdlintrc
to exclude addons/ and raise max-public-methods for test files.
No logic changes — declaration order, whitespace, and naming only.

Ticket: #783

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:59:56 +02:00

799 lines
30 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 08 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