diff --git a/.pql/changelog/ticket_history/2026-07.sql b/.pql/changelog/ticket_history/2026-07.sql index da2f4f6a6..83b066263 100644 --- a/.pql/changelog/ticket_history/2026-07.sql +++ b/.pql/changelog/ticket_history/2026-07.sql @@ -839,3 +839,5 @@ RMB gesture vocabulary (user, live session 2026-07-06, added to the path-preview Follow-facing refinement (user, live session 2026-07-06): during an RMB path-follow the BODY commits to the leg/movement direction (mouse no longer swings body yaw); the mouse instead drives a LAYERED LOOK-AT — Head bone (~70deg limit) plus torso twist via a spine bone (~30deg) for looking far lateral/behind; beyond their sum the character physically cannot look further without turning (correct). Pure client presentation on the D-249 model; SetFacing continues to ride the wire during follows, so the SERVER vision cone still follows the mouse (diegetic: the character looks where the player points while walking a committed path). Implementation: Godot LookAtModifier3D pair under the CharacterVisual skeleton, influence-faded in/out on follow start/end; rig gains a commit_body_to_motion flag (yaw from leg velocity while set).', NULL, '2026-07-06 18:29:55', '2026-07-06 18:29:55.012', '2026-07-06 18:29:55.012', NULL, '890daaabcde4a85a222f9d206dcda2d0', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKHFRCP5R0VBA9BKR97RRBJ0', 'status', 'backlog', 'in_progress', NULL, '2026-07-06 18:55:29', '2026-07-06 18:55:29.038', '2026-07-06 18:55:29.038', NULL, 'da64cb6b48dd4311c5b257cbd70d891b', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKHFRCP5R0VBA9BKR97RRBJ0', 'status', 'in_progress', 'done', NULL, '2026-07-06 19:18:30', '2026-07-06 19:18:30.658', '2026-07-06 19:18:30.658', NULL, '5bcddf023648706d42815d7aaf08255a', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKE0EW2617K9VE4QHSYSR9HW', 'status', 'backlog', 'in_progress', NULL, '2026-07-06 19:25:35', '2026-07-06 19:25:35.248', '2026-07-06 19:25:35.248', NULL, '8fa2e4eba023cb3ae1577a5f16c9cfd2', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-07.sql b/.pql/changelog/tickets/2026-07.sql index 136e27fee..f69345da5 100644 --- a/.pql/changelog/tickets/2026-07.sql +++ b/.pql/changelog/tickets/2026-07.sql @@ -710,3 +710,5 @@ RMB gesture vocabulary (user, live session 2026-07-06, added to the path-preview Follow-facing refinement (user, live session 2026-07-06): during an RMB path-follow the BODY commits to the leg/movement direction (mouse no longer swings body yaw); the mouse instead drives a LAYERED LOOK-AT — Head bone (~70deg limit) plus torso twist via a spine bone (~30deg) for looking far lateral/behind; beyond their sum the character physically cannot look further without turning (correct). Pure client presentation on the D-249 model; SetFacing continues to ride the wire during follows, so the SERVER vision cone still follows the mouse (diegetic: the character looks where the player points while walking a committed path). Implementation: Godot LookAtModifier3D pair under the CharacterVisual skeleton, influence-faded in/out on follow start/end; rig gains a commit_body_to_motion flag (yaw from leg velocity while set).', 'backlog', 'high', NULL, 'client', NULL, '2026-07-06 07:01:06.475', '2026-07-06 18:29:55.012', NULL, 'c45dd9ede3dbadcc20b4ce12b90a94f5', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKHFRCP5R0VBA9BKR97RRBJ0', 'task', '06FB0TNSRXX29H5X0EB0F0GBW4', 'Server aim-lock: explicit SetFacing should survive accepted moves (vision-cone flap during path-follows)', 'Found live in the T-1088 sandbox: during a client path-follow the vision cone flaps to path-forward on every accepted step because movement.rs facing_from_delta overwrites Facing on each move, and D-054''s change-gated SetFacing never re-asserts an unchanged octant. Client mitigation shipped (InputMapper.reassert_facing ~2 ticks after each emitted step) shrinks the flap to ~100ms per step but cannot eliminate it — movement wins within a tick. Proper fix is server-side facing decoupling (e.g. an explicit-aim flag/timestamp on Facing that accepted moves do not overwrite while fresh), which is exactly Q-084''s parked walk-vs-aim split now with play evidence. Needs a D-record before wire semantics change; touch nothing until the sidequest''s presentation-layer constraint lifts.', 'backlog', 'medium', NULL, 'server', NULL, '2026-07-06 18:44:32.561', '2026-07-06 18:44:32.561', NULL, 'c83a2bdbf3c41b678f04db6e5077b78a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKHFRCP5R0VBA9BKR97RRBJ0', 'task', '06FB0TNSRXX29H5X0EB0F0GBW4', 'Server aim-lock: explicit SetFacing should survive accepted moves (vision-cone flap during path-follows)', 'Found live in the T-1088 sandbox: during a client path-follow the vision cone flaps to path-forward on every accepted step because movement.rs facing_from_delta overwrites Facing on each move, and D-054''s change-gated SetFacing never re-asserts an unchanged octant. Client mitigation shipped (InputMapper.reassert_facing ~2 ticks after each emitted step) shrinks the flap to ~100ms per step but cannot eliminate it — movement wins within a tick. Proper fix is server-side facing decoupling (e.g. an explicit-aim flag/timestamp on Facing that accepted moves do not overwrite while fresh), which is exactly Q-084''s parked walk-vs-aim split now with play evidence. Needs a D-record before wire semantics change; touch nothing until the sidequest''s presentation-layer constraint lifts.', 'in_progress', 'medium', NULL, 'server', NULL, '2026-07-06 18:44:32.561', '2026-07-06 18:55:29.037', NULL, '01cc8d20f141986d0d4fdfe4d6cb1f22', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKHFRCP5R0VBA9BKR97RRBJ0', 'task', '06FB0TNSRXX29H5X0EB0F0GBW4', 'Server aim-lock: explicit SetFacing should survive accepted moves (vision-cone flap during path-follows)', 'Found live in the T-1088 sandbox: during a client path-follow the vision cone flaps to path-forward on every accepted step because movement.rs facing_from_delta overwrites Facing on each move, and D-054''s change-gated SetFacing never re-asserts an unchanged octant. Client mitigation shipped (InputMapper.reassert_facing ~2 ticks after each emitted step) shrinks the flap to ~100ms per step but cannot eliminate it — movement wins within a tick. Proper fix is server-side facing decoupling (e.g. an explicit-aim flag/timestamp on Facing that accepted moves do not overwrite while fresh), which is exactly Q-084''s parked walk-vs-aim split now with play evidence. Needs a D-record before wire semantics change; touch nothing until the sidequest''s presentation-layer constraint lifts.', 'done', 'medium', NULL, 'server', NULL, '2026-07-06 18:44:32.561', '2026-07-06 19:18:30.657', NULL, 'a71401dfc41971383da8d314361e15bb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKE0EW2617K9VE4QHSYSR9HW', 'bug', '06FB0TNSRXX29H5X0EB0F0GBW4', '5 of 11 body types misrender bare in CharacterVisual (thin_f/m, heavy_f/m, child)', 'Found by the T-1089 Synty intake spike (2026-07-06), body-only controls with NO garment attached: thin_f/m, heavy_f/m, child misrender in CharacterVisual (exploded/spider arms, detached head/hair; child 303mm max warp, 87 collapsed edges; f-bodies bust-through). These are the newer high-poly bodies (no seg_hips, different segmentation) — exactly the 5 with no production peasant-clothing variants. Upstream of ANY clothing route; blocks T-1089''s 11-of-11 acceptance criterion and resolves part of Q-060. Evidence: spikes/synty-intake/out/bodies_fit_log.json + out/qa_bodies/ (44 screenshots) + out/bodies_recon.json.', 'in_progress', 'high', NULL, 'client', NULL, '2026-07-06 10:38:16.593', '2026-07-06 19:25:35.248', NULL, 'ec4a20d81cb872c936cd825d4ebca616', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/client/assets/characters/animations/ual1.glb b/client/assets/characters/animations/ual1.glb new file mode 100644 index 000000000..df3d91e3e Binary files /dev/null and b/client/assets/characters/animations/ual1.glb differ diff --git a/client/assets/characters/animations/ual2.glb b/client/assets/characters/animations/ual2.glb new file mode 100644 index 000000000..bb3d392eb Binary files /dev/null and b/client/assets/characters/animations/ual2.glb differ diff --git a/client/assets/characters/animations/ual_extended.glb b/client/assets/characters/animations/ual_extended.glb deleted file mode 100644 index dc684c2a6..000000000 Binary files a/client/assets/characters/animations/ual_extended.glb and /dev/null differ diff --git a/client/assets/characters/animations/ual_standard.glb b/client/assets/characters/animations/ual_standard.glb deleted file mode 100644 index 410b30c61..000000000 Binary files a/client/assets/characters/animations/ual_standard.glb and /dev/null differ diff --git a/client/scripts/rendering/character_visual.gd b/client/scripts/rendering/character_visual.gd index 116ad6a7d..c59ee0cac 100644 --- a/client/scripts/rendering/character_visual.gd +++ b/client/scripts/rendering/character_visual.gd @@ -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: diff --git a/client/scripts/sandbox/locomotion_anim.gd b/client/scripts/sandbox/locomotion_anim.gd index 9289647d2..8cc1b3a17 100644 --- a/client/scripts/sandbox/locomotion_anim.gd +++ b/client/scripts/sandbox/locomotion_anim.gd @@ -5,6 +5,20 @@ extends RefCounted ## velocity (never input), transitions are edge-triggered crossfades, and playback ## rate is cadence-synced so foot fall matches actual ground speed (§6.2). ## +## Two one-shot overlays sit on top of the gait loop (T-1088, UAL Enter/Exit + Turn +## clips): +## - STANCE TRANSITIONS: on a stance change with a Sprint/Crouch Enter/Exit clip, +## play it once (loop=0) then blend into the target loop. The rig position channel +## is independent, so the character keeps gliding — movement never stalls. +## - TURN-IN-PLACE: when IDLE and the yaw TARGET jumps >= TURN_TRIGGER_DEG (a fast +## mouse reface), play Turn90/Turn180 L/R once while the rig's lerp_angle does the +## actual yaw work, then fall back to the idle clip. Retrigger is guarded by the +## active-one-shot window. +## Both overlays use a clip-length timer (decremented in update(delta)) rather than the +## AnimationPlayer.animation_finished signal: it fits the existing pure-polling drive, +## needs no signal lifecycle on a RefCounted across load_descriptor() player rebuilds, +## and is directly headless-testable by advancing update() calls. +## ## Wiring (sandbox root, design §14 group C): ## var anim := LocomotionAnim.new() ## anim.setup(player_rig, character_visual) # after both exist in-tree @@ -32,10 +46,18 @@ signal gait_changed(clip: StringName) ## acceptably (tune-by-eye; record the outcome on T-1088 per design §11.9). var skip_phase_seek: bool = false -var _rig = null # duck-typed rig (locomotion_rig.gd): stance / is_moving / current_speed +var _rig = null # duck-typed rig (locomotion_rig.gd): stance / is_moving / current_speed / yaw_target var _visual = null # duck-typed CharacterVisual: play_animation() / get_animation_player() var _current_clip: StringName = &"" # empty = nothing played yet (first play hard-cuts) var _current_moving: bool = false +var _current_is_oneshot: bool = false # true while _current_clip is an Enter/Exit/Turn one-shot + +# One-shot overlay state (stance transitions + turn-in-place). +var _last_stance: String = "" # stance-change edge detector (empty = not yet seeded) +var _turn_ref_yaw: float = 0.0 # yaw-target reference for idle turn-jump detection (rad) +var _oneshot_clip: StringName = &"" # active one-shot clip (empty = none) +var _oneshot_remaining_s: float = 0.0 # seconds left on the active one-shot (<= 0 = none) +var _oneshot_is_turn: bool = false # true if the active one-shot is a turn (idle-only) ## Pure gait table lookup (§6.1). Unknown stances warn and fall back to the Walk row @@ -48,13 +70,22 @@ static func gait(stance: String, is_moving: bool) -> StringName: return StringName(row["moving"]) if is_moving else StringName(row["idle"]) +## Clip basename — the part after "lib/", or the whole name when unqualified. Lets the +## name-based logic (crouch detection, NATIVE_MPS lookup) stay addressing-agnostic now +## that gait clips carry explicit "ual1/..."-style library prefixes (D-248). +static func bare(clip: StringName) -> String: + var s := String(clip) + var slash := s.rfind("/") + return s.substr(slash + 1) if slash >= 0 else s + + ## Pure blend-table selection (§6.3). Any transition into or out of a Crouch_* clip ## takes the crouch blend; otherwise the idle/gait edge decides. static func blend_for( from_clip: StringName, to_clip: StringName, from_moving: bool, to_moving: bool ) -> float: var blends: Dictionary = SandboxConstants.BLEND - if String(from_clip).begins_with("Crouch") or String(to_clip).begins_with("Crouch"): + if bare(from_clip).begins_with("Crouch") or bare(to_clip).begins_with("Crouch"): return float(blends["crouch"]) if not from_moving and to_moving: return float(blends["idle_to_gait"]) @@ -63,6 +94,37 @@ static func blend_for( return float(blends["gait_to_gait"]) +## Signed shortest arc from one yaw to another, in [-PI, PI). Local copy so the machine +## stays free of any rig-class dependency (it is duck-typed against the rig). +static func shortest_arc(from_yaw: float, to_yaw: float) -> float: + return wrapf(to_yaw - from_yaw, -PI, PI) + + +## Stance-transition one-shot for a stance change, or &"" if none applies (§ T-1088). +## Destination Enter wins over source Exit — entering the new stance is the salient +## action; the only case both apply is Crouch<->Sprint. Same stance in/out -> no clip. +static func transition_clip_for(from_stance: String, to_stance: String) -> StringName: + if from_stance == to_stance: + return &"" + if SandboxConstants.STANCE_ENTER_CLIP.has(to_stance): + return StringName(SandboxConstants.STANCE_ENTER_CLIP[to_stance]) + if SandboxConstants.STANCE_EXIT_CLIP.has(from_stance): + return StringName(SandboxConstants.STANCE_EXIT_CLIP[from_stance]) + return &"" + + +## Turn-in-place one-shot for a signed idle yaw-target jump (rad), or &"" if the jump is +## below TURN_TRIGGER_DEG. >= TURN_180_DEG selects the 180 clip (UAL2); the sign picks +## L (positive) vs R (negative) — see TURN_CLIP for the live-verify handedness note. +static func turn_clip_for(delta_rad: float) -> StringName: + var mag := absf(delta_rad) + if mag < deg_to_rad(SandboxConstants.TURN_TRIGGER_DEG): + return &"" + var arc := "180" if mag >= deg_to_rad(SandboxConstants.TURN_180_DEG) else "90" + var hand := "L" if delta_rad > 0.0 else "R" + return StringName(SandboxConstants.TURN_CLIP["%s_%s" % [arc, hand]]) + + ## The clip currently driven (empty StringName until the first play) — surfaced by ## the sandbox root for the DebugHud "clip" readout. func get_current_clip() -> StringName: @@ -85,10 +147,101 @@ func _on_rig_teleported(_pos_m: Vector3) -> void: ## Per-frame drive — call after the rig's own interpolation for the frame. -func update(_delta: float) -> void: +## Order: (1) first-frame seed, (2) drive an active one-shot to completion, (3) detect a +## stance change or an idle yaw-jump and start a new one-shot, (4) otherwise normal gait. +func update(delta: float) -> void: if _rig == null or _visual == null: return - _apply_state(_rig.stance, _rig.is_moving, _rig.current_speed) + var stance: String = _rig.stance + var moving: bool = _rig.is_moving + var speed: float = _rig.current_speed + + # (1) First-ever frame: hard-cut to the current gait and seed the edge baselines. + if _current_clip == &"": + _apply_state(stance, moving, speed) + _last_stance = stance + _turn_ref_yaw = _read_yaw_target() + return + + # (2) An active stance-transition / turn one-shot owns the clip until it elapses. + if _oneshot_remaining_s > 0.0: + _oneshot_remaining_s -= delta + # Keep the turn reference fresh so a mouse sweep DURING the one-shot doesn't + # burst into a turn the instant it ends. + _turn_ref_yaw = _read_yaw_target() + # A turn is idle-only: if the rig started moving, abandon it so the leg gait + # shows without stalling. + if _oneshot_is_turn and moving: + _oneshot_remaining_s = 0.0 + if _oneshot_remaining_s > 0.0: + return + # Just elapsed — clear and fall through so _apply_state blends into the loop. + _oneshot_clip = &"" + _oneshot_is_turn = false + + # (3a) Stance change -> maybe a transition one-shot (Sprint/Crouch Enter/Exit). + if stance != _last_stance: + var trans := transition_clip_for(_last_stance, stance) + _last_stance = stance + if not String(trans).is_empty(): + _play_oneshot(trans, SandboxConstants.STANCE_TRANSITION_BLEND, false, moving) + return + + # (3b) Idle yaw-target jump -> maybe a turn-in-place one-shot. + var yaw_target := _read_yaw_target() + if not moving: + var d := shortest_arc(_turn_ref_yaw, yaw_target) + _turn_ref_yaw = yaw_target + if absf(d) >= deg_to_rad(SandboxConstants.TURN_TRIGGER_DEG): + var turn := turn_clip_for(d) + if not String(turn).is_empty(): + _play_oneshot(turn, SandboxConstants.TURN_BLEND, true, false) + return + else: + # Moving: body yaw follows the legs (D-252); keep the reference current so a + # post-stop reface measures only the jump that happens AFTER arrival. + _turn_ref_yaw = yaw_target + + # (4) Normal gait selection. + _apply_state(stance, moving, speed) + + +# Rig yaw target (rad, WorldRoot-local) for idle turn-jump detection, duck-typed so a +# rig/stub without a yaw_target simply never triggers turns (delta stays 0). +func _read_yaw_target() -> float: + if _rig != null and "yaw_target" in _rig: + return float(_rig.yaw_target) + return _turn_ref_yaw + + +# Play a one-shot overlay (stance transition or turn): crossfade in, hold for the clip's +# (speed-scaled) duration, then let update() blend into the target loop. Turns play at +# TURN_SPEED_SCALE so the deliberate UAL turn clip doesn't drag far past the rig's snappy +# idle yaw ease; transitions play at native rate. +func _play_oneshot(clip: StringName, blend: float, is_turn: bool, moving: bool) -> void: + var scale := SandboxConstants.TURN_SPEED_SCALE if is_turn else 1.0 + _visual.play_animation(clip, blend) + var player = _visual.get_animation_player() + if player != null: + player.speed_scale = scale + _oneshot_clip = clip + _oneshot_is_turn = is_turn + _oneshot_remaining_s = _clip_length(clip) / maxf(scale, 0.01) + _current_clip = clip + _current_moving = moving + _current_is_oneshot = true + gait_changed.emit(clip) + + +# Clip length (s) from the real (or stubbed) AnimationPlayer, 0.0 when unavailable. +func _clip_length(clip: StringName) -> float: + var player = _visual.get_animation_player() + if player == null: + return 0.0 + var anim: Animation = player.get_animation(clip) + if anim == null: + return 0.0 + return anim.length ## Teleport hard reset (§6.3): a cross-map jump must not smear — replay the correct @@ -96,6 +249,12 @@ func update(_delta: float) -> void: func notify_teleport() -> void: if _rig == null or _visual == null: return + # A teleport aborts any in-flight one-shot overlay — the destination pose is a hard + # cut, not a continuation of an Enter/Exit/Turn. + _oneshot_clip = &"" + _oneshot_remaining_s = 0.0 + _oneshot_is_turn = false + _current_is_oneshot = false var moving: bool = _rig.is_moving var clip := gait(_rig.stance, moving) var changed := clip != _current_clip @@ -106,6 +265,10 @@ func notify_teleport() -> void: player.seek(0.0, false) _current_clip = clip _current_moving = moving + # Re-seed the edge baselines so the post-teleport state does not fire a spurious + # stance transition or turn on the next frame. + _last_stance = _rig.stance + _turn_ref_yaw = _read_yaw_target() _update_speed_scale(clip, moving, _rig.current_speed) if changed: gait_changed.emit(clip) @@ -128,15 +291,17 @@ func _transition_to(clip: StringName, moving: bool) -> void: if not first_play: blend = blend_for(_current_clip, clip, _current_moving, moving) # gait<->gait phase preservation (§6.3): capture the leg beat before switching, - # then re-seek into the new clip so feet keep their rhythm across the blend. + # then re-seek into the new clip so feet keep their rhythm across the blend. Skipped + # when leaving a one-shot (Enter/Exit/Turn) — its phase does not map to a gait loop. var phase := -1.0 - if not first_play and _current_moving and moving and not skip_phase_seek: + if not first_play and _current_moving and moving and not skip_phase_seek and not _current_is_oneshot: phase = _capture_phase() _visual.play_animation(clip, blend) if phase >= 0.0: _seek_phase(clip, phase) _current_clip = clip _current_moving = moving + _current_is_oneshot = false gait_changed.emit(clip) @@ -151,7 +316,7 @@ func _update_speed_scale(clip: StringName, moving: bool, speed: float) -> void: if not moving: player.speed_scale = 1.0 return - var native: float = SandboxConstants.NATIVE_MPS.get(String(clip), 0.0) + var native: float = SandboxConstants.NATIVE_MPS.get(bare(clip), 0.0) if native <= 0.0: player.speed_scale = 1.0 # moving clip missing from NATIVE_MPS — table drift return diff --git a/client/scripts/sandbox/sandbox_constants.gd b/client/scripts/sandbox/sandbox_constants.gd index f16a454c6..39e77500f 100644 --- a/client/scripts/sandbox/sandbox_constants.gd +++ b/client/scripts/sandbox/sandbox_constants.gd @@ -56,20 +56,23 @@ const TURN_BUDGET_DEG := { # -- Animation (design §6) ------------------------------------------------------- -## Gait table (§6.1) — the ONLY place animation clip strings live. -## Clip names are the imported bare names: library "", "_Loop" stripped by the glTF -## importer, case-sensitive (design-input §2.2). Careful = Walk_Formal (D-053 stance -## readability at ortho distance; fallback if it reads "parade march": Walk at 0.6x — -## one table cell). Jog_Fwd is the reserve if Sprint reads too aggressive at 2.5 m/s. +## Gait table (§6.1) — the ONLY place gait clip strings live. Explicit "lib/Clip" +## addressing (D-248 / T-1088): every locomotion loop lives in the UAL1 tier. Names +## are the verified imported names (dumped 2026-07-06 from ual1.glb): the glTF importer +## strips the "_Loop" suffix and sets loop mode, so "Walk_Loop" -> "Walk" (loop=1). +## Careful = Walk_Formal (D-053 stance readability at ortho distance; fallback if it +## reads "parade march": Walk at 0.6x — one table cell). Jog_Fwd (ual1) is the reserve +## if Sprint reads too aggressive at 2.5 m/s. const GAIT_CLIP := { - "Sprint": {"idle": &"Idle", "moving": &"Sprint"}, - "Walk": {"idle": &"Idle", "moving": &"Walk"}, - "Careful": {"idle": &"Idle", "moving": &"Walk_Formal"}, - "Crouch": {"idle": &"Crouch_Idle", "moving": &"Crouch_Fwd"}, + "Sprint": {"idle": &"ual1/Idle", "moving": &"ual1/Sprint"}, + "Walk": {"idle": &"ual1/Idle", "moving": &"ual1/Walk"}, + "Careful": {"idle": &"ual1/Idle", "moving": &"ual1/Walk_Formal"}, + "Crouch": {"idle": &"ual1/Crouch_Idle", "moving": &"ual1/Crouch_Fwd"}, } ## Native clip ground speed (m/s) for cadence sync (§6.2): ## speed_scale = clamp(rig_speed / NATIVE_MPS[clip], SPEED_SCALE_CLAMP.x, .y). +## Keyed by clip BASENAME (the part after "lib/") so it is addressing-agnostic. ## Initial guesses — tuned live against the DebugHud readout. const NATIVE_MPS := {"Walk": 1.4, "Walk_Formal": 1.2, "Sprint": 3.2, "Crouch_Fwd": 0.9} const SPEED_SCALE_CLAMP := Vector2(0.6, 1.8) @@ -84,6 +87,58 @@ const BLEND := { "teleport": 0.0, } +# -- Stance-transition one-shots (T-1088; UAL1 Enter/Exit clips) ---------------------- +# +# On a stance change, if the destination stance has an Enter clip OR the source stance +# has an Exit clip, play that one-shot (loop=0) and blend into the target loop when it +# elapses. The rig position channel is independent, so the character keeps gliding while +# the transition plays (movement never stalls). Only Sprint and Crouch have Enter/Exit +# clips in UAL1 (verified 2026-07-06); Walk/Careful transition with a plain gait blend. +# Priority when both apply (only Crouch<->Sprint): destination Enter wins over source +# Exit — "entering the new stance" is the salient action. Live-tune the edge if it reads +# wrong. Names are verified imported names (all loop=0 one-shots). +const STANCE_ENTER_CLIP := { + "Sprint": &"ual1/Sprint_Enter", + "Crouch": &"ual1/Crouch_Enter", +} +const STANCE_EXIT_CLIP := { + "Sprint": &"ual1/Sprint_Exit", + "Crouch": &"ual1/Crouch_Exit", +} +## Cross-fade (s) INTO a stance-transition one-shot. The blend back OUT to the target +## loop reuses the BLEND table (crouch clips take BLEND.crouch). +const STANCE_TRANSITION_BLEND := 0.12 + +# -- Turn-in-place one-shots (T-1088; UAL1 Turn90, UAL2 Turn180) ---------------------- +# +# When IDLE and the yaw TARGET jumps (mouse-driven octant snap) by >= TURN_TRIGGER_DEG, +# play a turn one-shot synced to the rig's yaw ease, then fall back to the stance idle +# clip. L/R is chosen from the sign of the shortest-arc delta; >= TURN_180_DEG uses the +# 180 clip (UAL2). Retrigger is guarded — a fresh turn cannot start until the current +# one elapses. Octants snap at 45 deg, so effective triggers are 90 deg -> Turn90 and +# 135/180 deg -> Turn180. Names are verified imported names (all loop=0 one-shots). +## Minimum yaw-target jump (deg) that triggers a turn-in-place. Below this, the rig's +## normal yaw ease handles the reface with no clip. +const TURN_TRIGGER_DEG := 60.0 +## At/above this jump (deg), use the Turn180 clip instead of Turn90. +const TURN_180_DEG := 135.0 +## Turn one-shot clips keyed by "_". Turn90 is UAL1, Turn180 is UAL2. +## Hand: positive shortest-arc yaw delta -> "_L", negative -> "_R" (VERIFY handedness +## live — a swap is a one-line fix if the model turns the wrong way). +const TURN_CLIP := { + "90_L": &"ual1/Turn90_L", + "90_R": &"ual1/Turn90_R", + "180_L": &"ual2/Turn180_L", + "180_R": &"ual2/Turn180_R", +} +## Cross-fade (s) into and out of a turn one-shot. +const TURN_BLEND := 0.15 +## Playback rate for turn one-shots. The UAL turn clips are deliberate (~1.7-2.0 s); the +## rig's idle yaw ease is snappy (~0.3 s for 180 deg), so at 1.0x the turn shuffle drags +## long past the settled body. 2.0x is an initial compromise — S9 live-tunes it against +## the on-screen yaw-ease duration (raise to shorten the turn, lower for weightier turns). +const TURN_SPEED_SCALE := 2.0 + # -- Camera (design §7; D-148, D-015, D-158) ------------------------------------- ## Pitch presets in degrees from horizontal — D-148 preset list is authoritative; diff --git a/client/tests/unit/test_gait_table.gd b/client/tests/unit/test_gait_table.gd index 35225debb..5d10f5d5e 100644 --- a/client/tests/unit/test_gait_table.gd +++ b/client/tests/unit/test_gait_table.gd @@ -25,15 +25,22 @@ class StubRig: var stance: String = "Walk" var is_moving: bool = false var current_speed: float = 0.0 + var yaw_target: float = 0.0 # idle turn-jump detector input (rad) class StubAnimPlayer: extends RefCounted - ## Clip lengths mirror the verified import dump (design-input §2.2) — values only - ## matter for phase arithmetic, not for clip existence (layer 1 covers that). + ## Clip lengths mirror the verified import dump (2026-07-06), keyed by the explicit + ## "lib/Clip" address (D-248). Values only matter for phase/one-shot-timer arithmetic, + ## not for clip existence (layer 1 covers that against the real GLB). const LENGTHS := { - "Idle": 2.5, "Walk": 1.33, "Walk_Formal": 1.33, - "Sprint": 0.67, "Crouch_Idle": 2.93, "Crouch_Fwd": 2.0, + "ual1/Idle": 2.5, "ual1/Walk": 1.33, "ual1/Walk_Formal": 1.33, + "ual1/Sprint": 0.67, "ual1/Crouch_Idle": 2.93, "ual1/Crouch_Fwd": 2.0, + # stance-transition + turn one-shots + "ual1/Sprint_Enter": 0.87, "ual1/Sprint_Exit": 1.67, + "ual1/Crouch_Enter": 0.83, "ual1/Crouch_Exit": 0.83, + "ual1/Turn90_L": 2.0, "ual1/Turn90_R": 2.0, + "ual2/Turn180_L": 1.67, "ual2/Turn180_R": 1.67, } var speed_scale: float = 1.0 var current_animation: String = "" @@ -95,15 +102,20 @@ func test_every_gait_cell_exists_in_imported_animation_list() -> void: var player: AnimationPlayer = visual.get_animation_player() assert_object(player).override_failure_message( "get_animation_player() must return the AnimPlayer after load_descriptor()" - + " — null means the skeleton or ual_standard.glb failed to load" + + " — null means the skeleton or the ual1/ual2 GLBs failed to load" ).is_not_null() if player == null: return var clips := player.get_animation_list() assert_bool(clips.is_empty()).override_failure_message( - "imported animation list is empty — ual_standard.glb library copy failed" + "imported animation list is empty — ual1.glb/ual2.glb library copy failed" ).is_false() + # Both explicit libraries must have registered (D-248 lib/Clip addressing). + var libs := player.get_animation_library_list() + assert_bool(libs.has(&"ual1") and libs.has(&"ual2")).override_failure_message( + "expected explicit libraries 'ual1' and 'ual2' — got %s" % [libs] + ).is_true() for stance: String in SandboxConstants.GAIT_CLIP: var row: Dictionary = SandboxConstants.GAIT_CLIP[stance] for cell: String in row: @@ -111,12 +123,34 @@ func test_every_gait_cell_exists_in_imported_animation_list() -> void: assert_bool(clips.has(clip)).override_failure_message( ( "GAIT_CLIP[%s][%s] = '%s' not in the imported animation list —" - + " play_animation() would miss SILENTLY (case-sensitive; library" - + " '' bare names, '_Loop' stripped by the importer). List: %s" + + " play_animation() would miss SILENTLY (case-sensitive; explicit" + + " 'lib/Clip' addressing, '_Loop' stripped by the importer). List: %s" ) % [stance, cell, clip, clips] ).is_true() +func test_transition_and_turn_clips_exist_in_imported_animation_list() -> void: + # The stance-transition (Sprint/Crouch Enter/Exit) and turn-in-place (Turn90/Turn180 + # L/R) name tables must resolve in the real headless CharacterVisual — same silent-miss + # tripwire as the gait table, for the one-shot overlays (T-1088). + var visual: CharacterVisual = auto_free(CharacterVisual.new()) + add_child(visual) + visual.load_descriptor(CharacterVisualDescriptor.new()) + var player: AnimationPlayer = visual.get_animation_player() + assert_object(player).is_not_null() + if player == null: + return + var clips := player.get_animation_list() + var expected: Array = [] + expected.append_array(SandboxConstants.STANCE_ENTER_CLIP.values()) + expected.append_array(SandboxConstants.STANCE_EXIT_CLIP.values()) + expected.append_array(SandboxConstants.TURN_CLIP.values()) + for clip: StringName in expected: + assert_bool(clips.has(String(clip))).override_failure_message( + "one-shot clip '%s' not in the imported animation list — %s" % [clip, clips] + ).is_true() + + func test_gait_table_covers_all_four_stances() -> void: # The wire stance enum (D-053/D-055): every variant must have both cells. for stance: String in ["Sprint", "Walk", "Careful", "Crouch"]: @@ -135,18 +169,57 @@ func test_gait_table_covers_all_four_stances() -> void: func test_gait_returns_table_cells() -> void: - assert_str(String(LocomotionAnim.gait("Walk", false))).is_equal("Idle") - assert_str(String(LocomotionAnim.gait("Walk", true))).is_equal("Walk") - assert_str(String(LocomotionAnim.gait("Careful", true))).is_equal("Walk_Formal") - assert_str(String(LocomotionAnim.gait("Sprint", true))).is_equal("Sprint") - assert_str(String(LocomotionAnim.gait("Crouch", false))).is_equal("Crouch_Idle") - assert_str(String(LocomotionAnim.gait("Crouch", true))).is_equal("Crouch_Fwd") + assert_str(String(LocomotionAnim.gait("Walk", false))).is_equal("ual1/Idle") + assert_str(String(LocomotionAnim.gait("Walk", true))).is_equal("ual1/Walk") + assert_str(String(LocomotionAnim.gait("Careful", true))).is_equal("ual1/Walk_Formal") + assert_str(String(LocomotionAnim.gait("Sprint", true))).is_equal("ual1/Sprint") + assert_str(String(LocomotionAnim.gait("Crouch", false))).is_equal("ual1/Crouch_Idle") + assert_str(String(LocomotionAnim.gait("Crouch", true))).is_equal("ual1/Crouch_Fwd") func test_gait_unknown_stance_falls_back_to_walk_row() -> void: # Mirrors the wire default (player_stance serde-defaults to Walk). - assert_str(String(LocomotionAnim.gait("Prone", true))).is_equal("Walk") - assert_str(String(LocomotionAnim.gait("Prone", false))).is_equal("Idle") + assert_str(String(LocomotionAnim.gait("Prone", true))).is_equal("ual1/Walk") + assert_str(String(LocomotionAnim.gait("Prone", false))).is_equal("ual1/Idle") + + +# ============================================================================= +# 2b. bare() basename helper + one-shot name tables (§ T-1088) +# ============================================================================= + + +func test_bare_strips_library_prefix() -> void: + assert_str(LocomotionAnim.bare(&"ual1/Walk")).is_equal("Walk") + assert_str(LocomotionAnim.bare(&"ual2/Turn180_L")).is_equal("Turn180_L") + # Unqualified names pass through unchanged (back-compat). + assert_str(LocomotionAnim.bare(&"Crouch_Fwd")).is_equal("Crouch_Fwd") + + +func test_transition_clip_table() -> void: + # Destination Enter / source Exit, verified imported names (UAL1). + assert_str(String(LocomotionAnim.transition_clip_for("Walk", "Sprint"))).is_equal("ual1/Sprint_Enter") + assert_str(String(LocomotionAnim.transition_clip_for("Sprint", "Walk"))).is_equal("ual1/Sprint_Exit") + assert_str(String(LocomotionAnim.transition_clip_for("Walk", "Crouch"))).is_equal("ual1/Crouch_Enter") + assert_str(String(LocomotionAnim.transition_clip_for("Crouch", "Walk"))).is_equal("ual1/Crouch_Exit") + # Walk<->Careful have no Enter/Exit clips — plain gait blend, no one-shot. + assert_str(String(LocomotionAnim.transition_clip_for("Walk", "Careful"))).is_equal("") + assert_str(String(LocomotionAnim.transition_clip_for("Careful", "Walk"))).is_equal("") + # Same stance -> no transition. + assert_str(String(LocomotionAnim.transition_clip_for("Sprint", "Sprint"))).is_equal("") + # Both apply (Crouch<->Sprint) -> destination Enter wins. + assert_str(String(LocomotionAnim.transition_clip_for("Crouch", "Sprint"))).is_equal("ual1/Sprint_Enter") + assert_str(String(LocomotionAnim.transition_clip_for("Sprint", "Crouch"))).is_equal("ual1/Crouch_Enter") + + +func test_turn_clip_table() -> void: + # Below TURN_TRIGGER_DEG -> no clip (the rig's yaw ease handles it). + assert_str(String(LocomotionAnim.turn_clip_for(deg_to_rad(45.0)))).is_equal("") + # 90 deg: sign picks L (positive) / R (negative), Turn90 lives in UAL1. + assert_str(String(LocomotionAnim.turn_clip_for(deg_to_rad(90.0)))).is_equal("ual1/Turn90_L") + assert_str(String(LocomotionAnim.turn_clip_for(deg_to_rad(-90.0)))).is_equal("ual1/Turn90_R") + # >= TURN_180_DEG: Turn180 lives in UAL2. + assert_str(String(LocomotionAnim.turn_clip_for(deg_to_rad(180.0)))).is_equal("ual2/Turn180_L") + assert_str(String(LocomotionAnim.turn_clip_for(deg_to_rad(-135.0)))).is_equal("ual2/Turn180_R") # ============================================================================= @@ -171,16 +244,18 @@ func test_blend_gait_to_gait() -> void: func test_blend_crouch_overrides_all_edges() -> void: # "<->Crouch_*" takes the crouch blend regardless of the idle/gait edge kind. + # Uses explicit "lib/Clip" names to prove the crouch detection is addressing-agnostic + # (blend_for() strips the library prefix via bare()). var crouch: float = SandboxConstants.BLEND["crouch"] - assert_float(LocomotionAnim.blend_for(&"Walk", &"Crouch_Fwd", true, true)) \ + assert_float(LocomotionAnim.blend_for(&"ual1/Walk", &"ual1/Crouch_Fwd", true, true)) \ .is_equal_approx(crouch, EPS) - assert_float(LocomotionAnim.blend_for(&"Crouch_Fwd", &"Walk", true, true)) \ + assert_float(LocomotionAnim.blend_for(&"ual1/Crouch_Fwd", &"ual1/Walk", true, true)) \ .is_equal_approx(crouch, EPS) - assert_float(LocomotionAnim.blend_for(&"Idle", &"Crouch_Idle", false, false)) \ + assert_float(LocomotionAnim.blend_for(&"ual1/Idle", &"ual1/Crouch_Idle", false, false)) \ .is_equal_approx(crouch, EPS) - assert_float(LocomotionAnim.blend_for(&"Crouch_Idle", &"Crouch_Fwd", false, true)) \ + assert_float(LocomotionAnim.blend_for(&"ual1/Crouch_Idle", &"ual1/Crouch_Fwd", false, true)) \ .is_equal_approx(crouch, EPS) - assert_float(LocomotionAnim.blend_for(&"Crouch_Fwd", &"Crouch_Idle", true, false)) \ + assert_float(LocomotionAnim.blend_for(&"ual1/Crouch_Fwd", &"ual1/Crouch_Idle", true, false)) \ .is_equal_approx(crouch, EPS) @@ -195,7 +270,7 @@ func test_first_update_plays_idle_with_hard_cut() -> void: var visual: StubVisual = m[2] anim.update(0.016) assert_int(visual.plays.size()).is_equal(1) - assert_str(_last_play(visual)["name"]).is_equal("Idle") + assert_str(_last_play(visual)["name"]).is_equal("ual1/Idle") # First-ever play: -1.0 rides play_animation's default hard-cut path. assert_float(_last_play(visual)["blend"]).is_equal_approx(-1.0, EPS) @@ -219,7 +294,7 @@ func test_idle_to_walk_uses_idle_to_gait_blend() -> void: rig.is_moving = true rig.current_speed = 1.25 anim.update(0.016) - assert_str(_last_play(visual)["name"]).is_equal("Walk") + assert_str(_last_play(visual)["name"]).is_equal("ual1/Walk") assert_float(_last_play(visual)["blend"]) \ .is_equal_approx(SandboxConstants.BLEND["idle_to_gait"], EPS) @@ -235,20 +310,21 @@ func test_walk_to_idle_uses_gait_to_idle_blend() -> void: rig.is_moving = false rig.current_speed = 0.0 anim.update(0.016) - assert_str(_last_play(visual)["name"]).is_equal("Idle") + assert_str(_last_play(visual)["name"]).is_equal("ual1/Idle") assert_float(_last_play(visual)["blend"]) \ .is_equal_approx(SandboxConstants.BLEND["gait_to_idle"], EPS) -func test_stance_toggle_while_idle_keeps_shared_idle_clip() -> void: - # Walk-idle and Sprint-idle share the Idle cell — the clip identity is the edge, - # so no re-play and no gait_changed re-emit on the stance flip. +func test_stance_toggle_between_transitionless_stances_keeps_shared_idle_clip() -> void: + # Walk-idle and Careful-idle share the Idle cell AND have no Enter/Exit clip — the + # clip identity is the edge, so no re-play and no gait_changed re-emit on the flip. + # (Sprint/Crouch now DO fire an Enter/Exit one-shot — see the transition tests.) var m := _make_machine() var anim: LocomotionAnim = m[0] var rig: StubRig = m[1] var visual: StubVisual = m[2] anim.update(0.016) - rig.stance = "Sprint" + rig.stance = "Careful" anim.update(0.016) assert_int(visual.plays.size()).is_equal(1) @@ -266,7 +342,7 @@ func test_gait_changed_emitted_once_per_edge() -> void: rig.current_speed = 1.25 anim.update(0.016) # -> Walk anim.update(0.016) # no edge - assert_array(emitted).is_equal(["Idle", "Walk"]) + assert_array(emitted).is_equal(["ual1/Idle", "ual1/Walk"]) # ============================================================================= @@ -331,6 +407,8 @@ func test_idle_runs_at_native_rate() -> void: func test_gait_to_gait_preserves_phase() -> void: + # Walk -> Careful (Walk_Formal) is the transitionless gait<->gait pair (no Enter/Exit + # clip intervenes); Walk -> Sprint now routes through Sprint_Enter (see transitions). var m := _make_machine() var anim: LocomotionAnim = m[0] var rig: StubRig = m[1] @@ -340,14 +418,14 @@ func test_gait_to_gait_preserves_phase() -> void: anim.update(0.016) # -> Walk # Mid-stride: half way through the Walk loop. visual.player.current_animation_position = 0.5 * visual.player.current_animation_length - rig.stance = "Sprint" - rig.current_speed = 2.5 - anim.update(0.016) # -> Sprint, gait<->gait - assert_str(_last_play(visual)["name"]).is_equal("Sprint") + rig.stance = "Careful" + rig.current_speed = 1.2 + anim.update(0.016) # -> Walk_Formal, gait<->gait + assert_str(_last_play(visual)["name"]).is_equal("ual1/Walk_Formal") assert_int(visual.player.seeks.size()).is_equal(1) - # phase 0.5 into Sprint's 0.67 s loop; update=false keeps the crossfade pose. + # phase 0.5 into Walk_Formal's 1.33 s loop; update=false keeps the crossfade pose. assert_float(visual.player.seeks[0][0]) \ - .is_equal_approx(0.5 * StubAnimPlayer.LENGTHS["Sprint"], EPS) + .is_equal_approx(0.5 * StubAnimPlayer.LENGTHS["ual1/Walk_Formal"], EPS) assert_bool(visual.player.seeks[0][1]).is_false() @@ -363,10 +441,10 @@ func test_skip_phase_seek_flag_disables_the_seek() -> void: rig.current_speed = 1.25 anim.update(0.016) visual.player.current_animation_position = 0.5 * visual.player.current_animation_length - rig.stance = "Sprint" - rig.current_speed = 2.5 + rig.stance = "Careful" + rig.current_speed = 1.2 anim.update(0.016) - assert_str(_last_play(visual)["name"]).is_equal("Sprint") + assert_str(_last_play(visual)["name"]).is_equal("ual1/Walk_Formal") assert_int(visual.player.seeks.size()).is_equal(0) @@ -401,7 +479,7 @@ func test_teleport_replays_with_zero_blend_and_rewinds() -> void: rig.is_moving = false # rig snapped at the teleport target rig.current_speed = 0.0 anim.notify_teleport() - assert_str(_last_play(visual)["name"]).is_equal("Idle") + assert_str(_last_play(visual)["name"]).is_equal("ual1/Idle") assert_float(_last_play(visual)["blend"]) \ .is_equal_approx(SandboxConstants.BLEND["teleport"], EPS) # play() on an already-current clip does not rewind — the reset must seek 0. @@ -423,14 +501,152 @@ func test_setup_connects_rig_teleported_signal_when_present() -> void: rig.is_moving = false rig.current_speed = 0.0 rig.teleported.emit(Vector3(25.25, 0.0, 29.25)) - assert_str(_last_play(visual)["name"]).is_equal("Idle") + assert_str(_last_play(visual)["name"]).is_equal("ual1/Idle") assert_float(_last_play(visual)["blend"]) \ .is_equal_approx(SandboxConstants.BLEND["teleport"], EPS) +# ============================================================================= +# 8. Stance-transition one-shots (T-1088) +# ============================================================================= + + +func test_stance_change_triggers_transition_oneshot() -> void: + # Idle Walk -> Crouch fires the Crouch_Enter one-shot (loop=0) with the transition + # blend, while the rig keeps interpolating independently. + var m := _make_machine() + var anim: LocomotionAnim = m[0] + var rig: StubRig = m[1] + var visual: StubVisual = m[2] + anim.update(0.016) # seed baselines, idle + rig.stance = "Crouch" + anim.update(0.016) + assert_str(_last_play(visual)["name"]).is_equal("ual1/Crouch_Enter") + assert_float(_last_play(visual)["blend"]) \ + .is_equal_approx(SandboxConstants.STANCE_TRANSITION_BLEND, EPS) + # One-shots play at native rate (no cadence scaling). + assert_float(visual.player.speed_scale).is_equal_approx(1.0, EPS) + + +func test_transition_holds_then_blends_into_target_loop() -> void: + # The one-shot owns the clip for its length (Crouch_Enter = 0.83 s @ 1.0x), then the + # next frame blends into the destination loop (idle -> Crouch_Idle). + var m := _make_machine() + var anim: LocomotionAnim = m[0] + var rig: StubRig = m[1] + var visual: StubVisual = m[2] + anim.update(0.016) # seed + rig.stance = "Crouch" + anim.update(0.016) # -> Crouch_Enter + var plays_at_trigger := visual.plays.size() + # Within the 0.83 s window: the clip is held, no new play. + anim.update(0.2) + assert_int(visual.plays.size()).is_equal(plays_at_trigger) + # Advance past the window: elapses and blends into Crouch_Idle the same frame. + anim.update(0.8) + assert_str(_last_play(visual)["name"]).is_equal("ual1/Crouch_Idle") + + +func test_transition_does_not_stall_movement_flag() -> void: + # Movement independence: even mid-transition the machine never blocks the rig — it + # only owns the CLIP. If movement is underway when the transition ends, it lands on + # the moving loop. + var m := _make_machine() + var anim: LocomotionAnim = m[0] + var rig: StubRig = m[1] + var visual: StubVisual = m[2] + rig.is_moving = true + rig.current_speed = 1.25 + anim.update(0.016) # seed -> Walk + rig.stance = "Sprint" + rig.current_speed = 3.0 + anim.update(0.016) # -> Sprint_Enter one-shot + assert_str(_last_play(visual)["name"]).is_equal("ual1/Sprint_Enter") + anim.update(1.0) # elapse (Sprint_Enter 0.87 s) -> Sprint moving loop + assert_str(_last_play(visual)["name"]).is_equal("ual1/Sprint") + + +# ============================================================================= +# 9. Turn-in-place one-shots (T-1088) +# ============================================================================= + + +func test_idle_yaw_jump_triggers_turn() -> void: + # A >= 60 deg idle yaw-target jump plays a Turn90 one-shot; +delta -> L. + var m := _make_machine() + var anim: LocomotionAnim = m[0] + var rig: StubRig = m[1] + var visual: StubVisual = m[2] + anim.update(0.016) # seed, yaw_target 0 + rig.yaw_target = deg_to_rad(90.0) + anim.update(0.016) + assert_str(_last_play(visual)["name"]).is_equal("ual1/Turn90_L") + assert_float(_last_play(visual)["blend"]).is_equal_approx(SandboxConstants.TURN_BLEND, EPS) + assert_float(visual.player.speed_scale) \ + .is_equal_approx(SandboxConstants.TURN_SPEED_SCALE, EPS) + + +func test_small_idle_yaw_change_does_not_turn() -> void: + # A 45 deg reface stays under TURN_TRIGGER_DEG — the rig's yaw ease handles it, no clip. + var m := _make_machine() + var anim: LocomotionAnim = m[0] + var rig: StubRig = m[1] + var visual: StubVisual = m[2] + anim.update(0.016) # seed -> Idle + rig.yaw_target = deg_to_rad(45.0) + anim.update(0.016) + assert_int(visual.plays.size()).is_equal(1) # only the seed idle play + + +func test_moving_yaw_change_does_not_turn() -> void: + # Turns are idle-only — while moving, the body yaw follows the legs (D-252). + var m := _make_machine() + var anim: LocomotionAnim = m[0] + var rig: StubRig = m[1] + var visual: StubVisual = m[2] + rig.is_moving = true + rig.current_speed = 1.25 + anim.update(0.016) # seed -> Walk + rig.yaw_target = deg_to_rad(120.0) + anim.update(0.016) + assert_str(_last_play(visual)["name"]).is_equal("ual1/Walk") # no turn clip + + +func test_turn_retrigger_guarded_while_active() -> void: + # A second jump during an active turn does not start a new turn (retrigger guard). + var m := _make_machine() + var anim: LocomotionAnim = m[0] + var rig: StubRig = m[1] + var visual: StubVisual = m[2] + anim.update(0.016) # seed + rig.yaw_target = deg_to_rad(90.0) + anim.update(0.016) # -> Turn90_L (2.0 s / 2.0x = 1.0 s window) + var plays_at_turn := visual.plays.size() + rig.yaw_target = deg_to_rad(180.0) # another jump mid-turn + anim.update(0.1) # still within the window + assert_int(visual.plays.size()).is_equal(plays_at_turn) # guarded, no new play + + +func test_turn_abandoned_when_movement_starts() -> void: + # A turn is idle-only: if the rig starts moving mid-turn, abandon it and show the + # leg gait immediately (movement never stalls). + var m := _make_machine() + var anim: LocomotionAnim = m[0] + var rig: StubRig = m[1] + var visual: StubVisual = m[2] + anim.update(0.016) # seed idle + rig.yaw_target = deg_to_rad(90.0) + anim.update(0.016) # -> Turn90_L active + rig.is_moving = true + rig.current_speed = 1.25 + anim.update(0.016) # movement -> abandon turn -> Walk + assert_str(_last_play(visual)["name"]).is_equal("ual1/Walk") + + class SignallingStubRig: extends RefCounted signal teleported(pos_m: Vector3) var stance: String = "Walk" var is_moving: bool = false var current_speed: float = 0.0 + var yaw_target: float = 0.0