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 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 18:33:36 +01:00
co-authored by Claude Opus 4.6
parent 86acb87771
commit 18ee89d066
@@ -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
# =============================================================================