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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user