feat(client): purchased UAL tiers wired — lib/Clip addressing, stance enter/exits, turn-in-place (T-1088)

ual1.glb (120 clips) + ual2.glb (134) replace the free-tier subsets;
CharacterVisual loads both under explicit library names with lib/Clip exact
addressing (bare names keep cross-library search). Gait table re-pointed;
two new one-shot overlays in the gait machine: stance transitions
(Sprint/Crouch Enter/Exit, destination-Enter priority, clip-length timer,
movement never stalls) and turn-in-place (Turn90/180 L/R from shortest-arc
sign, >=60/135 deg idle yaw jumps, retrigger-guarded). All clip names
verified by dumping the imported GLBs. 70 gait tests + live smoke green
(both libraries load with exact counts).

S9 live-tune list: turn handedness (unverified headless — one-line swap),
TURN_SPEED_SCALE=2.0 compromise vs the snappy yaw ease, turn-on-stop feel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 21:53:35 +02:00
co-authored by Claude Fable 5
parent e46aa404fc
commit c82ea3ec92
10 changed files with 580 additions and 94 deletions
+84 -38
View File
@@ -31,7 +31,16 @@ 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"
## Purchased UAL tiers (D-248 / T-1088): each GLB's imported default library ("") is
## registered under an explicit name so "lib/Clip" addressing resolves exactly and the
## two GLBs' identically-named "" libraries do not collide. Bare-name play_animation()
## still works via a cross-library search (back-compat). UAL1 = base locomotion +
## Turn90 + Sprint/Crouch Enter/Exit; UAL2 = Turn180 + 8-dir walk sets + life-sim idles.
## The importer strips the "_Loop" suffix and sets loop mode (verified 2026-07-06).
const ANIM_LIBRARIES := {
"ual1": BASE_PATH + "animations/ual1.glb",
"ual2": BASE_PATH + "animations/ual2.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"
@@ -732,61 +741,89 @@ func _rebuild_outlines() -> void:
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()
# Load every purchased UAL tier, registering each GLB's imported default ("")
# library under its explicit name (D-248 / T-1088) — "ual1", "ual2".
var loaded := 0
for lib_name: String in ANIM_LIBRARIES:
loaded += _load_animation_library(lib_name, ANIM_LIBRARIES[lib_name])
if loaded == 0:
push_warning("CharacterVisual: no animation libraries loaded")
return
# Play idle if available
play_animation("idle")
## Play a named animation. Searches all libraries for a matching name.
## Load one UAL GLB and register its libraries. The imported default library ("")
## carries the clips; it is re-registered under the explicit `lib_name` so
## "lib/Clip" addressing resolves and a second GLB's "" library cannot collide.
## Returns the number of libraries added (0 on failure).
func _load_animation_library(lib_name: String, path: String) -> int:
var anim_scene: PackedScene = load(path) as PackedScene
if anim_scene == null:
push_warning("CharacterVisual: animation library not found at %s" % path)
return 0
var anim_root: Node = anim_scene.instantiate()
var source_player := _find_anim_player(anim_root)
if source_player == null:
push_warning("CharacterVisual: no AnimationPlayer found in %s" % path)
anim_root.queue_free()
return 0
var added := 0
for src_lib_name in source_player.get_animation_library_list():
# Default "" library -> the explicit tier name; any pre-named source library
# is kept but namespaced under the tier so tiers never collide.
var target_name: String = lib_name if src_lib_name == "" else lib_name + "_" + str(src_lib_name)
if _anim_player.has_animation_library(target_name):
continue
var lib: AnimationLibrary = source_player.get_animation_library(src_lib_name)
_anim_player.add_animation_library(target_name, lib.duplicate())
added += 1
anim_root.queue_free()
return added
## Recursively find the first AnimationPlayer under `root` (GLB imports sometimes
## nest it below the scene root).
static func _find_anim_player(root: Node) -> AnimationPlayer:
if root is AnimationPlayer:
return root as AnimationPlayer
for child in root.get_children():
var found := _find_anim_player(child)
if found:
return found
return null
## Play a named animation. Two addressing forms (T-1088 / D-248):
## - Explicit "lib/Clip" (name contains "/") resolves to that library's clip
## exactly — no cross-library search, so tier placement is unambiguous.
## - A bare name searches every library and plays the first match (back-compat
## for existing callers and the "idle" convenience alias below).
## blend_time >= 0.0 is passed to AnimationPlayer.play() as custom_blend (crossfade
## seconds); the -1.0 default preserves the original hard-cut behavior for existing
## callers (T-1088 design §6.3 — the locomotion gait machine is the first blend user).
## seconds); the -1.0 default preserves the original hard-cut behavior (design §6.3 —
## the locomotion gait machine is the first blend user).
func play_animation(anim_name: String, blend_time: float = -1.0) -> void:
if _anim_player == null:
return
# Search across all libraries for the animation
# Explicit "lib/Clip" addressing — resolve exactly.
if anim_name.contains("/"):
if _anim_player.has_animation(anim_name):
_play_resolved(anim_name, blend_time)
return
push_warning("CharacterVisual: animation '%s' not found (explicit lib/Clip)" % anim_name)
return
# Bare name — search across all libraries for a matching clip.
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
if blend_time >= 0.0:
_anim_player.play(full_name, blend_time)
else:
_anim_player.play(full_name)
_play_resolved(full_name, blend_time)
return
# Try common idle variants
for variant in ["Idle", "idle_01", "Idle_01", "breathing_idle", "Breathing_Idle"]:
@@ -796,6 +833,15 @@ func play_animation(anim_name: String, blend_time: float = -1.0) -> void:
push_warning("CharacterVisual: animation '%s' not found in any library" % anim_name)
# Play a fully-qualified animation name ("lib/Clip" or a default-library bare name),
# honoring the custom-blend contract: blend_time >= 0.0 crossfades, -1.0 hard-cuts.
func _play_resolved(full_name: String, blend_time: float) -> void:
if blend_time >= 0.0:
_anim_player.play(full_name, blend_time)
else:
_anim_player.play(full_name)
## Stop all animations and return to rest pose.
func stop_animation() -> void:
if _anim_player: