class_name CharacterVisual extends Node3D ## Manages the visual representation of a single character: ## segmented body mesh, outfit meshes, toon shader, outline pass, and animation. ## ## Architecture (validated in Sprint 28): ## The body is segmented into 15 bone-group regions (head, neck, torso, arms, ## hands, legs, feet) plus eyes/eyebrows. Each segment is a separate skinned ## GLB sharing the same 65-bone skeleton. Segments can be individually hidden ## when clothing covers them. ## ## Static meshes (Trellis-generated heads, hairstyles, hats) attach to bones ## via BoneAttachment3D. Outfits are skinned GLBs that share the skeleton. ## ## Usage: ## var cv = CharacterVisual.new() ## parent.add_child(cv) ## cv.load_body("res://models/quaternius/segmented/", ALL_SEGMENTS) ## cv.load_animation_library("res://models/quaternius/animations/UAL2_Standard.glb") ## cv.play_animation("Idle_FoldArms") ## cv.attach_outfit("res://models/quaternius/outfits-fantasy/parts/Male_Ranger_Body.gltf") ## ## The class owns the scene subtree rooted at the imported body. All shader and ## outline state is managed internally. The caller never touches materials directly. signal outfit_changed(outfit_name: String) signal shader_mode_changed(mode: int) signal outline_changed(enabled: bool) enum ShaderMode { TEXTURED, FLAT_TOON } ## Camera angle presets for use with this character system. ## Frontal (0 deg tilt) is the default for mugshot / character editor UI. ## Gameplay uses dramatic (-30 deg) or isometric (-45 deg). const CAMERA_ANGLE_FRONTAL := Vector3(-5.0, 45.0, 0.0) const CAMERA_ANGLE_DRAMATIC := Vector3(-30.0, 45.0, 0.0) const CAMERA_ANGLE_TOPDOWN := Vector3(-90.0, 0.0, 0.0) ## All body segment names in loading order. const ALL_SEGMENTS: Array[String] = [ "head", "neck", "torso", "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", "eyebrows", "eyes", ] # --- Public state (read via getters, write via setters) --- var shader_mode: int = ShaderMode.TEXTURED: set(value): if shader_mode == value: return shader_mode = value _refresh_visuals() shader_mode_changed.emit(shader_mode) var outline_enabled: bool = true: set(value): if outline_enabled == value: return outline_enabled = value _rebuild_outlines() outline_changed.emit(outline_enabled) var outline_width: float = 0.006 var outline_color: Color = Color(0.08, 0.08, 0.12) ## Skin tone palette — 9 tones from pale to deep, each with lit and shadow color. const SKIN_TONES := [ {"name": "Pale cool", "lit": Color("f5e8e0"), "shadow": Color("c8b4b0"), "tex": "pale_cool"}, {"name": "Pale warm", "lit": Color("f0c8a0"), "shadow": Color("c89870"), "tex": "pale_warm"}, {"name": "Light olive", "lit": Color("d4a878"), "shadow": Color("a87850"), "tex": "light_olive"}, {"name": "Medium golden", "lit": Color("b87840"), "shadow": Color("885520"), "tex": "medium_golden"}, {"name": "Olive warm", "lit": Color("c09060"), "shadow": Color("906040"), "tex": "olive_warm"}, {"name": "Medium brown", "lit": Color("8b5a2b"), "shadow": Color("5a3010"), "tex": "medium_brown"}, {"name": "Deep brown", "lit": Color("5c3317"), "shadow": Color("3a1a08"), "tex": "deep_brown"}, {"name": "Very deep warm", "lit": Color("3d2010"), "shadow": Color("200e04"), "tex": "very_deep_warm"}, {"name": "Very deep cool", "lit": Color("2d1a12"), "shadow": Color("180a06"), "tex": "very_deep_cool"}, ] var skin_tone_index: int = 2: # default: Light olive set(value): skin_tone_index = value % SKIN_TONES.size() # Only reapply toon shaders, don't touch outlines for mi in _content_meshes: _apply_toon(mi) # --- Internal state --- var _body_root: Node3D = null var _skeleton: Skeleton3D = null var _anim_player: AnimationPlayer = null var _content_meshes: Array[MeshInstance3D] = [] var _body_meshes: Array[MeshInstance3D] = [] var _outline_nodes: Array[Node3D] = [] var _outfit_meshes: Array[MeshInstance3D] = [] # Shaders -- set these before loading the body, or call set_shaders(). var _toon_shader: Shader var _toon_masked_shader: Shader var _outline_shader: Shader # --- Initialization --- func set_shaders(toon: Shader, toon_masked: Shader, outline: Shader) -> void: _toon_shader = toon _toon_masked_shader = toon_masked _outline_shader = outline # --- Body loading --- func load_body(segment_dir: String, segment_names: Array[String] = ALL_SEGMENTS.duplicate()) -> bool: ## Load a body from individual segment GLBs. Each segment is a separate skinned ## mesh that attaches to the shared skeleton. ## ## segment_dir: directory path like "res://models/quaternius/segmented/" ## segment_names: list of segment names to load (default: all 17 segments) ## ## The directory must contain an armature.glb (skeleton-only) and seg_.glb ## files for each segment. # Load the armature first (provides the skeleton) var armature_scene: PackedScene = load(segment_dir.path_join("armature.glb")) if armature_scene == null: # Fall back: load skeleton from first segment var first := load(segment_dir.path_join("seg_%s.glb" % segment_names[0])) if first == null: push_error("CharacterVisual: no armature or segments found in '%s'" % segment_dir) return false _body_root = first.instantiate() add_child(_body_root) _skeleton = _find_typed(_body_root, "Skeleton3D") as Skeleton3D for mi in _collect_meshes(_body_root): _register_mesh(mi) _body_meshes.append(mi) mi.set_meta("segment", segment_names[0]) segment_names = segment_names.slice(1) else: _body_root = armature_scene.instantiate() add_child(_body_root) _skeleton = _find_typed(_body_root, "Skeleton3D") as Skeleton3D if _skeleton == null: push_error("CharacterVisual: no skeleton found") return false # Load each segment and attach its meshes to our skeleton for seg_name in segment_names: var seg_path := segment_dir.path_join("seg_%s.glb" % seg_name) var seg_scene: PackedScene = load(seg_path) if seg_scene == null: print(" Segment not found: %s" % seg_path) continue var seg_inst := seg_scene.instantiate() var seg_meshes := _collect_meshes(seg_inst) for mi in seg_meshes: mi.get_parent().remove_child(mi) mi.owner = null mi.set_meta("segment", seg_name) # Apply shader + fallback material to ALL surfaces BEFORE adding to tree # to prevent null material errors from the renderer _register_mesh(mi) _ensure_all_materials(mi) _skeleton.add_child(mi) _body_meshes.append(mi) seg_inst.queue_free() _refresh_visuals() return true func load_unsegmented_body(gltf_path: String) -> bool: ## Load a single full-body GLB (unsegmented). Used for comparison with the ## original Quaternius base characters. Not the primary architecture. var scene: PackedScene = load(gltf_path) if scene == null: push_error("CharacterVisual: could not load body '%s'" % gltf_path) return false _body_root = scene.instantiate() add_child(_body_root) _skeleton = _find_typed(_body_root, "Skeleton3D") as Skeleton3D for mi in _collect_meshes(_body_root): _register_mesh(mi) _body_meshes.append(mi) _refresh_visuals() return true func set_segment_visible(segment_name: String, visible: bool) -> void: ## Show/hide a specific body segment by name. for mi in _body_meshes: if mi.has_meta("segment") and mi.get_meta("segment") == segment_name: mi.visible = visible _rebuild_outlines() func set_body_visible(show: bool) -> void: ## Hide/show all body meshes. When hiding, keeps small meshes (eyes, eyebrows) ## visible so the face remains present when outfits do not include a head. ## On segmented bodies, delegates to per-segment visibility. const SMALL_MESH_THRESHOLD := 0.1 # AABB height below which meshes are kept visible for mi in _body_meshes: if not show and mi.mesh and mi.mesh.get_aabb().size.y < SMALL_MESH_THRESHOLD: mi.visible = true # keep eyes/eyebrows else: mi.visible = show _rebuild_outlines() func get_skeleton() -> Skeleton3D: return _skeleton func get_bone_count() -> int: return _skeleton.get_bone_count() if _skeleton else 0 func get_content_mesh_count() -> int: return _content_meshes.size() func get_animation_player() -> AnimationPlayer: return _anim_player func has_bone_attachments() -> bool: return not _bone_attachments.is_empty() # --- Animation --- func load_animation_library(glb_path: String, library_name: String = "ual") -> PackedStringArray: ## Load animations from a GLB and attach them to this character. ## Returns the list of animation names available. var scene: PackedScene = load(glb_path) if scene == null: push_error("CharacterVisual: could not load animation library '%s'" % glb_path) return PackedStringArray() var anim_instance := scene.instantiate() var source_player := _find_typed(anim_instance, "AnimationPlayer") as AnimationPlayer if source_player == null: anim_instance.queue_free() push_error("CharacterVisual: no AnimationPlayer in '%s'" % glb_path) return PackedStringArray() # Ensure we have an AnimationPlayer on our character _anim_player = _find_typed(_body_root, "AnimationPlayer") as AnimationPlayer if _anim_player == null: _anim_player = AnimationPlayer.new() _anim_player.name = "AnimationPlayer" _body_root.add_child(_anim_player) # Remap and copy all animations var lib := AnimationLibrary.new() var names := PackedStringArray() for anim_name in source_player.get_animation_list(): var source_anim := source_player.get_animation(anim_name) var remapped := _remap_animation(source_anim, anim_instance, _body_root) lib.add_animation(anim_name, remapped) names.append(anim_name) _anim_player.add_animation_library(library_name, lib) anim_instance.queue_free() return names func play_animation(anim_name: String, library_name: String = "ual") -> void: if _anim_player: _anim_player.play("%s/%s" % [library_name, anim_name]) func pause_animation() -> void: if _anim_player: _anim_player.pause() func resume_animation() -> void: if _anim_player: _anim_player.play() func is_playing() -> bool: return _anim_player.is_playing() if _anim_player else false func get_current_animation() -> String: return _anim_player.current_animation if _anim_player else "" # --- Bone attachments (heads, hair, accessories) --- var _bone_attachments: Array[Node3D] = [] func attach_to_bone(gltf_path: String, bone_name: String, offset := Vector3.ZERO) -> bool: ## Load a static mesh and parent it to a bone via BoneAttachment3D. ## No skinning needed -- the mesh rides the bone transform. ## Used for Trellis-generated heads, hairstyles, hats, held items. if _skeleton == null: push_error("CharacterVisual: no skeleton for bone attachment") return false var bone_idx := _skeleton.find_bone(bone_name) if bone_idx == -1: push_error("CharacterVisual: bone '%s' not found" % bone_name) return false var scene: PackedScene = load(gltf_path) if scene == null: push_error("CharacterVisual: could not load '%s'" % gltf_path) return false var instance := scene.instantiate() var attachment := BoneAttachment3D.new() attachment.bone_name = bone_name attachment.bone_idx = bone_idx _skeleton.add_child(attachment) instance.position = offset attachment.add_child(instance) # Apply toon shader to all meshes in the attachment for mi in _collect_meshes(instance): _save_originals(mi) _apply_toon(mi) _content_meshes.append(mi) _bone_attachments.append(attachment) _rebuild_outlines() return true func detach_all_bone_attachments() -> void: for att in _bone_attachments: for mi in _collect_meshes(att): _content_meshes.erase(mi) if att.get_parent(): att.get_parent().remove_child(att) att.free() _bone_attachments.clear() _rebuild_outlines() # --- Outfits --- func attach_outfit(gltf_path: String) -> bool: ## Load an outfit GLB/GLTF and attach its meshes to this character's skeleton. ## Returns true if at least one mesh was attached. if _skeleton == null: push_error("CharacterVisual: no skeleton to attach outfit to") return false var scene: PackedScene = load(gltf_path) if scene == null: push_error("CharacterVisual: could not load outfit '%s'" % gltf_path) return false var outfit_instance := scene.instantiate() var meshes := _collect_meshes(outfit_instance) if meshes.is_empty(): outfit_instance.queue_free() return false for mi in meshes: mi.get_parent().remove_child(mi) mi.owner = null _register_mesh(mi) _ensure_all_materials(mi) _skeleton.add_child(mi) _outfit_meshes.append(mi) outfit_instance.queue_free() _rebuild_outlines() outfit_changed.emit(gltf_path.get_file().get_basename()) return true func detach_all_outfits() -> void: for mi in _outfit_meshes: _unregister_mesh(mi) if mi.get_parent(): mi.get_parent().remove_child(mi) mi.free() _outfit_meshes.clear() _rebuild_outlines() outfit_changed.emit("none") func get_outfit_count() -> int: return _outfit_meshes.size() # --- Visuals core --- func _refresh_visuals() -> void: ## Reapply toon shader to all content meshes. Does NOT rebuild outlines — ## outline material is independent of toon shader state. Call _rebuild_outlines() ## separately when outline state changes or meshes are added/removed. for mi in _content_meshes: _apply_toon(mi) func _register_mesh(mi: MeshInstance3D) -> void: _save_originals(mi) _apply_toon(mi) _content_meshes.append(mi) func _unregister_mesh(mi: MeshInstance3D) -> void: _content_meshes.erase(mi) # --- Toon shader --- func _save_originals(mi: MeshInstance3D) -> void: if mi.mesh == null or mi.has_meta("_orig_mats"): return var mats: Array[Material] = [] for s in range(mi.mesh.get_surface_count()): var mat := mi.get_active_material(s) if mat == null: mat = mi.mesh.surface_get_material(s) mats.append(mat) mi.set_meta("_orig_mats", mats) func _apply_toon(mi: MeshInstance3D) -> void: if mi.mesh == null: return # Body segments that show skin get skin tone textures. # Eyes, eyebrows, and the icosphere (light probe) keep their original materials. const NON_SKIN_SEGMENTS := ["eyes", "eyebrows", "icosphere"] var segment_name: String = mi.get_meta("segment", "") as String var is_skin := segment_name != "" and segment_name not in NON_SKIN_SEGMENTS var tone: Dictionary = SKIN_TONES[skin_tone_index] var originals: Array = mi.get_meta("_orig_mats", []) as Array for surf_idx in range(mi.mesh.get_surface_count()): var original: Material = originals[surf_idx] if surf_idx < originals.size() else null if is_skin: # Body segments: load skin tone texture variant var skin_tex_path := "res://models/quaternius/segmented-male/T_Skin_%s.png" % tone["tex"] var skin_tex: Texture2D = load(skin_tex_path) as Texture2D if skin_tex: var mat := ShaderMaterial.new() 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) mi.set_surface_override_material(surf_idx, mat) else: # Fallback: flat toon if texture not found var mat := ShaderMaterial.new() 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_idx, mat) else: # Non-body meshes (outfits, accessories) var tex := _get_albedo_texture(original) var col := _get_albedo_color(original) if tex: var mat := ShaderMaterial.new() mat.shader = _toon_masked_shader mat.set_shader_parameter("albedo_tex", tex) mat.set_shader_parameter("tint_color", Color(0.8, 0.7, 0.6)) mat.set_shader_parameter("shadow_strength", 0.2) mi.set_surface_override_material(surf_idx, mat) else: var base := col if col != Color.BLACK else Color(0.6, 0.5, 0.45) var mat := ShaderMaterial.new() mat.shader = _toon_shader mat.set_shader_parameter("base_color", base) mat.set_shader_parameter("shadow_color", base.darkened(0.4)) mat.set_shader_parameter("shadow_threshold", 0.4) mi.set_surface_override_material(surf_idx, mat) # --- Outline --- func _rebuild_outlines() -> void: # Remove existing outline nodes immediately (not deferred) to prevent # the renderer from touching them with stale/null materials 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 not outline_enabled: 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) const OUTLINE_SIZE_THRESHOLD := 0.1 # skip tiny meshes (eyes/eyebrows) for mi in _content_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 _apply_material_recursive(outline_mi, outline_mat) _ensure_all_materials(outline_mi) mi.get_parent().add_child(outline_mi) _outline_nodes.append(outline_mi) # --- Helpers --- static func _apply_material_recursive(node: Node, mat: Material) -> void: if node is MeshInstance3D: (node as MeshInstance3D).material_override = mat for child in node.get_children(): _apply_material_recursive(child, mat) static func _get_albedo_color(mat: Material) -> Color: if mat is StandardMaterial3D: return (mat as StandardMaterial3D).albedo_color if mat is BaseMaterial3D: return (mat as BaseMaterial3D).albedo_color return Color.BLACK 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 in ["albedo_tex", "albedo_texture", "texture_albedo", "base_color_texture"]: var val = (mat as ShaderMaterial).get_shader_parameter(p) if val is Texture2D: return val as Texture2D return null static func _find_typed(root: Node, type_name: String) -> Node: if root.get_class() == type_name: return root for child in root.get_children(): var found := _find_typed(child, type_name) 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 func _ensure_all_materials(node: Node) -> void: ## Ensure every MeshInstance3D in the subtree has a material on every surface. ## Prevents "material is null" errors from the GL renderer. if node is MeshInstance3D: var mi := node as MeshInstance3D if mi.mesh: for surf_idx in range(mi.mesh.get_surface_count()): if mi.get_active_material(surf_idx) == null: if mi.material_override == null: var fallback := StandardMaterial3D.new() fallback.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED fallback.albedo_color = Color(0.5, 0.5, 0.5) mi.material_override = fallback break # material_override covers all surfaces for child in node.get_children(): _ensure_all_materials(child) func _remap_animation(source: Animation, source_root: Node, target_root: Node) -> Animation: var anim := source.duplicate() as Animation var src_skel := _find_typed(source_root, "Skeleton3D") var tgt_skel := _find_typed(target_root, "Skeleton3D") if src_skel == null or tgt_skel == null: return anim var src_path := str(source_root.get_path_to(src_skel)) var tgt_path := str(target_root.get_path_to(tgt_skel)) for i in range(anim.get_track_count()): var track := str(anim.track_get_path(i)) anim.track_set_path(i, NodePath(track.replace(src_path, tgt_path))) return anim