From 18ee89d066ade57bcc12c815ce5d7d7190796de1 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 22 Mar 2026 18:33:36 +0100 Subject: [PATCH] feat(client): add animation support to character compositor Load UAL animation library onto the skeleton and play idle animation after character assembly. Adds play_animation() and stop_animation() public API. Searches all animation libraries for matching animation name with common idle variant fallbacks. Co-Authored-By: Claude Opus 4.6 --- client/scripts/rendering/character_visual.gd | 79 ++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/client/scripts/rendering/character_visual.gd b/client/scripts/rendering/character_visual.gd index 57ed2e96c..8c75f5b3f 100644 --- a/client/scripts/rendering/character_visual.gd +++ b/client/scripts/rendering/character_visual.gd @@ -29,6 +29,7 @@ extends Node3D 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 TOON_SHADER_PATH := BASE_PATH + "shaders/toon.gdshader" const TOON_MASKED_SHADER_PATH := BASE_PATH + "shaders/toon_masked.gdshader" @@ -92,6 +93,7 @@ const OUTLINE_SIZE_THRESHOLD: float = 0.1 # skip tiny meshes (eyes, eyebrows) var _skeleton: Skeleton3D = null var _body_root: Node3D = null +var _anim_player: AnimationPlayer = null var _body_meshes: Array[MeshInstance3D] = [] var _clothing_meshes: Array[MeshInstance3D] = [] @@ -140,6 +142,7 @@ func load_descriptor(descriptor: CharacterVisualDescriptor) -> void: _load_clothing(descriptor) _load_accessories(descriptor) _rebuild_outlines() + _load_animations() ## Set character facing from a 2D direction vector or a named string direction. @@ -239,6 +242,13 @@ func _clear() -> void: _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() @@ -589,6 +599,75 @@ func _rebuild_outlines() -> void: _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 + # Create our own AnimationPlayer on the skeleton + _anim_player = AnimationPlayer.new() + _anim_player.name = "AnimPlayer" + _skeleton.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 # =============================================================================