Files
settled-reach/client/scripts/rendering/character_visual.gd
T
jpmschweitzerandClaude Opus 4.6 86acb87771 fix(client): explicit Variant type on JSON.parse_string return
GDScript strict mode rejects type inference from Variant-returning
functions. JSON.parse_string() returns Variant — use explicit type
annotation to satisfy the parser.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 18:28:35 +01:00

628 lines
23 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
##
## 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 SKIN_TONE_DIR := BASE_PATH + "skin_tones/"
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", "torso_upper",
"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] = ["torso_upper"]
## 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 _body_meshes: Array[MeshInstance3D] = []
var _clothing_meshes: Array[MeshInstance3D] = []
var _bone_attachments: Array[BoneAttachment3D] = []
var _outline_nodes: Array[MeshInstance3D] = []
var _accessory_attachments: Array[BoneAttachment3D] = []
# 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
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)
# =============================================================================
# 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()
## 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"]
# =============================================================================
# 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()
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 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
_validate_slot_bones()
## 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])
# =============================================================================
# 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
_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: apply skin tone via toon_masked or flat toon fallback.
var tex_path := SKIN_TONE_DIR + "%s.png" % tone["tex"]
var skin_tex := load(tex_path) as Texture2D
var mat := ShaderMaterial.new()
if skin_tex and _toon_masked_shader:
mat.shader = _toon_masked_shader
mat.set_shader_parameter("albedo_tex", skin_tex)
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)
mi.set_surface_override_material(surf, mat)
# =============================================================================
# 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:
if desc.eyebrow_id.is_empty():
return
# Convention: eyebrow_id uses the short visual name (e.g. "regular", "thick", "female"),
# NOT the full filename prefix. Path: eyebrows/{eyebrow_id}.glb → e.g. eyebrows/regular.glb
var path := BASE_PATH + "eyebrows/%s.glb" % desc.eyebrow_id
if _attach_to_bone(path, "Head", desc.eyebrow_tint) != null:
_loaded_slots.append("eyebrow")
func _attach_to_bone(path: String, bone_name: String, tint: Color = Color.WHITE) -> 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()
attachment.add_child(inst)
for mi in _collect_meshes(inst):
_apply_tinted_shader(mi, tint, mask_tex)
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()
# Apply segment hiding from coverage
for mi in _body_meshes:
var seg: String = mi.get_meta("segment", "")
if seg in hidden_segments:
mi.visible = false
# Apply torso variant selection
if _active_torso_variant == "upper":
for mi in _body_meshes:
match mi.get_meta("segment", ""):
"torso": mi.visible = false
"torso_upper": mi.visible = true
else:
for mi in _body_meshes:
if mi.get_meta("segment", "") == "torso_upper":
mi.visible = false
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) -> 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)
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)
# =============================================================================
# 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"]:
var val: Variant = (mat as ShaderMaterial).get_shader_parameter(p)
if val is Texture2D:
return val as Texture2D
return null