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