feat(client): 3D locomotion sandbox — character walks the live Gauntlet (T-1088)
New SR_LIVE sandbox scene: CharacterVisual composited in a 3D greybox world derived from server snapshots. Per-leg constant-velocity interpolation keyed to the stance throttle, 'server feet / client eyes' facing (wire octant while moving, client aim octant idle), cadence-synced gait state machine on AnimationPlayer custom blends, D-148 orthographic follow camera (-30deg default, T-cycle presets), sim-space grid shader, camera-side wall cutaway, accumulating never-evict tile store with four-state visibility tint. Additive seams only: InputMapper.facing_angle_provider (2D path unchanged), CharacterVisual.play_animation blend_time param + get_animation_player(). Visual harness gains per-scenario scene field + SR_AUTOPILOT input scripting. 210 new gdUnit assertions across five suites; verified live (230/230 total, clean smoke, screenshot at .cache/screenshots/locomotion_idle_live.png). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,436 @@
|
||||
## LocomotionAnim gait machine tests (T-1088 design §6, §10.4).
|
||||
##
|
||||
## Two layers:
|
||||
## 1. END-TO-END CLIP GUARD — every SandboxConstants.GAIT_CLIP cell must exist in a
|
||||
## real headless CharacterVisual's get_animation_list(). play_animation() is a
|
||||
## case-sensitive exact-name search that fails with only a warning, so a typo'd
|
||||
## or renamed clip is a SILENT miss in production — this test is the tripwire,
|
||||
## end-to-end against the imported GLB, not against a constant copy.
|
||||
## 2. State-machine logic on stubs — edge-triggered transitions, the §6.3 blend
|
||||
## table, §6.2 cadence sync, phase preservation + skip_phase_seek, teleport
|
||||
## hard reset, and the gait_changed (Q-063) signal.
|
||||
class_name TestGaitTable
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const EPS := 0.000001
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Stubs — duck-typed against the §4.0 rig getters and §6.3 CharacterVisual API
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class StubRig:
|
||||
extends RefCounted
|
||||
var stance: String = "Walk"
|
||||
var is_moving: bool = false
|
||||
var current_speed: float = 0.0
|
||||
|
||||
|
||||
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).
|
||||
const LENGTHS := {
|
||||
"Idle": 2.5, "Walk": 1.33, "Walk_Formal": 1.33,
|
||||
"Sprint": 0.67, "Crouch_Idle": 2.93, "Crouch_Fwd": 2.0,
|
||||
}
|
||||
var speed_scale: float = 1.0
|
||||
var current_animation: String = ""
|
||||
var current_animation_position: float = 0.0
|
||||
var current_animation_length: float = 0.0
|
||||
var seeks: Array = [] # [seconds, update] per seek() call
|
||||
|
||||
func get_animation(clip_name: StringName) -> Animation:
|
||||
var anim := Animation.new()
|
||||
anim.length = float(LENGTHS.get(String(clip_name), 1.0))
|
||||
return anim
|
||||
|
||||
func seek(seconds: float, update: bool = false, _update_only: bool = false) -> void:
|
||||
seeks.append([seconds, update])
|
||||
current_animation_position = seconds
|
||||
|
||||
|
||||
class StubVisual:
|
||||
extends RefCounted
|
||||
var player := StubAnimPlayer.new()
|
||||
var plays: Array = [] # [{"name": String, "blend": float}] per play_animation call
|
||||
|
||||
func play_animation(anim_name: String, blend_time: float = -1.0) -> void:
|
||||
plays.append({"name": anim_name, "blend": blend_time})
|
||||
player.current_animation = anim_name
|
||||
player.current_animation_length = float(StubAnimPlayer.LENGTHS.get(anim_name, 1.0))
|
||||
player.current_animation_position = 0.0
|
||||
|
||||
func get_animation_player() -> StubAnimPlayer:
|
||||
return player
|
||||
|
||||
|
||||
## Machine wired to fresh stubs; returns [anim, rig, visual].
|
||||
func _make_machine() -> Array:
|
||||
var rig := StubRig.new()
|
||||
var visual := StubVisual.new()
|
||||
var anim := LocomotionAnim.new()
|
||||
anim.setup(rig, visual)
|
||||
return [anim, rig, visual]
|
||||
|
||||
|
||||
func _last_play(visual: StubVisual) -> Dictionary:
|
||||
return {} if visual.plays.is_empty() else visual.plays[-1]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 1. End-to-end clip guard — real headless CharacterVisual (§6.1)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_every_gait_cell_exists_in_imported_animation_list() -> void:
|
||||
# §2.1 order is load-bearing: .new() -> add_child() -> load_descriptor().
|
||||
# In-tree first, because _ready() loads the toon/outline shaders. A default
|
||||
# descriptor is enough — only the mandatory skeleton + animation library matter.
|
||||
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).override_failure_message(
|
||||
"get_animation_player() must return the AnimPlayer after load_descriptor()"
|
||||
+ " — null means the skeleton or ual_standard.glb 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"
|
||||
).is_false()
|
||||
for stance: String in SandboxConstants.GAIT_CLIP:
|
||||
var row: Dictionary = SandboxConstants.GAIT_CLIP[stance]
|
||||
for cell: String in row:
|
||||
var clip := String(row[cell])
|
||||
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"
|
||||
) % [stance, cell, 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"]:
|
||||
assert_bool(SandboxConstants.GAIT_CLIP.has(stance)).override_failure_message(
|
||||
"GAIT_CLIP missing wire stance '%s'" % stance
|
||||
).is_true()
|
||||
var row: Dictionary = SandboxConstants.GAIT_CLIP[stance]
|
||||
assert_bool(row.has("idle") and row.has("moving")).override_failure_message(
|
||||
"GAIT_CLIP[%s] must have both 'idle' and 'moving' cells" % stance
|
||||
).is_true()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 2. Pure gait() lookup (§6.1)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 3. Blend table (§6.3) — pure blend_for
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_blend_idle_to_gait() -> void:
|
||||
var b := LocomotionAnim.blend_for(&"Idle", &"Walk", false, true)
|
||||
assert_float(b).is_equal_approx(SandboxConstants.BLEND["idle_to_gait"], EPS)
|
||||
|
||||
|
||||
func test_blend_gait_to_idle() -> void:
|
||||
var b := LocomotionAnim.blend_for(&"Walk", &"Idle", true, false)
|
||||
assert_float(b).is_equal_approx(SandboxConstants.BLEND["gait_to_idle"], EPS)
|
||||
|
||||
|
||||
func test_blend_gait_to_gait() -> void:
|
||||
var b := LocomotionAnim.blend_for(&"Walk", &"Sprint", true, true)
|
||||
assert_float(b).is_equal_approx(SandboxConstants.BLEND["gait_to_gait"], EPS)
|
||||
|
||||
|
||||
func test_blend_crouch_overrides_all_edges() -> void:
|
||||
# "<->Crouch_*" takes the crouch blend regardless of the idle/gait edge kind.
|
||||
var crouch: float = SandboxConstants.BLEND["crouch"]
|
||||
assert_float(LocomotionAnim.blend_for(&"Walk", &"Crouch_Fwd", true, true)) \
|
||||
.is_equal_approx(crouch, EPS)
|
||||
assert_float(LocomotionAnim.blend_for(&"Crouch_Fwd", &"Walk", true, true)) \
|
||||
.is_equal_approx(crouch, EPS)
|
||||
assert_float(LocomotionAnim.blend_for(&"Idle", &"Crouch_Idle", false, false)) \
|
||||
.is_equal_approx(crouch, EPS)
|
||||
assert_float(LocomotionAnim.blend_for(&"Crouch_Idle", &"Crouch_Fwd", false, true)) \
|
||||
.is_equal_approx(crouch, EPS)
|
||||
assert_float(LocomotionAnim.blend_for(&"Crouch_Fwd", &"Crouch_Idle", true, false)) \
|
||||
.is_equal_approx(crouch, EPS)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 4. Edge-triggered transitions on the stubbed player
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_first_update_plays_idle_with_hard_cut() -> void:
|
||||
var m := _make_machine()
|
||||
var anim: LocomotionAnim = m[0]
|
||||
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")
|
||||
# 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)
|
||||
|
||||
|
||||
func test_unchanged_state_never_retriggers_play() -> void:
|
||||
# Loops never restart mid-cycle: same state across frames = exactly one play call.
|
||||
var m := _make_machine()
|
||||
var anim: LocomotionAnim = m[0]
|
||||
var visual: StubVisual = m[2]
|
||||
for i in 5:
|
||||
anim.update(0.016)
|
||||
assert_int(visual.plays.size()).is_equal(1)
|
||||
|
||||
|
||||
func test_idle_to_walk_uses_idle_to_gait_blend() -> void:
|
||||
var m := _make_machine()
|
||||
var anim: LocomotionAnim = m[0]
|
||||
var rig: StubRig = m[1]
|
||||
var visual: StubVisual = m[2]
|
||||
anim.update(0.016) # settle into Idle
|
||||
rig.is_moving = true
|
||||
rig.current_speed = 1.25
|
||||
anim.update(0.016)
|
||||
assert_str(_last_play(visual)["name"]).is_equal("Walk")
|
||||
assert_float(_last_play(visual)["blend"]) \
|
||||
.is_equal_approx(SandboxConstants.BLEND["idle_to_gait"], EPS)
|
||||
|
||||
|
||||
func test_walk_to_idle_uses_gait_to_idle_blend() -> void:
|
||||
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)
|
||||
rig.is_moving = false
|
||||
rig.current_speed = 0.0
|
||||
anim.update(0.016)
|
||||
assert_str(_last_play(visual)["name"]).is_equal("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.
|
||||
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"
|
||||
anim.update(0.016)
|
||||
assert_int(visual.plays.size()).is_equal(1)
|
||||
|
||||
|
||||
func test_gait_changed_emitted_once_per_edge() -> void:
|
||||
# Q-063 seam: one emission per transition, carrying the clip now playing.
|
||||
var m := _make_machine()
|
||||
var anim: LocomotionAnim = m[0]
|
||||
var rig: StubRig = m[1]
|
||||
var emitted: Array = []
|
||||
anim.gait_changed.connect(func(clip: StringName) -> void: emitted.append(String(clip)))
|
||||
anim.update(0.016) # -> Idle
|
||||
anim.update(0.016) # no edge
|
||||
rig.is_moving = true
|
||||
rig.current_speed = 1.25
|
||||
anim.update(0.016) # -> Walk
|
||||
anim.update(0.016) # no edge
|
||||
assert_array(emitted).is_equal(["Idle", "Walk"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 5. Cadence sync (§6.2)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_speed_scale_matches_speed_over_native_mps() -> void:
|
||||
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 # Walk cardinal leg: 0.5 m / 0.4 s
|
||||
anim.update(0.016)
|
||||
assert_float(visual.player.speed_scale) \
|
||||
.is_equal_approx(1.25 / SandboxConstants.NATIVE_MPS["Walk"], EPS)
|
||||
|
||||
|
||||
func test_speed_scale_clamped_at_catchup_burst() -> void:
|
||||
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 = 10.0 # 3x catch-up burst far beyond native
|
||||
anim.update(0.016)
|
||||
assert_float(visual.player.speed_scale) \
|
||||
.is_equal_approx(SandboxConstants.SPEED_SCALE_CLAMP.y, EPS)
|
||||
|
||||
|
||||
func test_speed_scale_clamped_at_floor() -> void:
|
||||
# Hysteresis window: at-target (speed 0) but still "moving" — clamp floor, not 0.
|
||||
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 = 0.0
|
||||
anim.update(0.016)
|
||||
assert_float(visual.player.speed_scale) \
|
||||
.is_equal_approx(SandboxConstants.SPEED_SCALE_CLAMP.x, EPS)
|
||||
|
||||
|
||||
func test_idle_runs_at_native_rate() -> void:
|
||||
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 = 2.5
|
||||
anim.update(0.016)
|
||||
rig.is_moving = false
|
||||
rig.current_speed = 0.0
|
||||
anim.update(0.016)
|
||||
assert_float(visual.player.speed_scale).is_equal_approx(1.0, EPS)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 6. gait<->gait phase preservation (§6.3)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_gait_to_gait_preserves_phase() -> void:
|
||||
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) # -> 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")
|
||||
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.
|
||||
assert_float(visual.player.seeks[0][0]) \
|
||||
.is_equal_approx(0.5 * StubAnimPlayer.LENGTHS["Sprint"], EPS)
|
||||
assert_bool(visual.player.seeks[0][1]).is_false()
|
||||
|
||||
|
||||
func test_skip_phase_seek_flag_disables_the_seek() -> void:
|
||||
# §6.3 caveat fallback: if seek-during-blend cancels the crossfade in the live
|
||||
# scene, the flag drops the seek and the 0.15 s blend masks the resync.
|
||||
var m := _make_machine()
|
||||
var anim: LocomotionAnim = m[0]
|
||||
var rig: StubRig = m[1]
|
||||
var visual: StubVisual = m[2]
|
||||
anim.skip_phase_seek = true
|
||||
rig.is_moving = true
|
||||
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
|
||||
anim.update(0.016)
|
||||
assert_str(_last_play(visual)["name"]).is_equal("Sprint")
|
||||
assert_int(visual.player.seeks.size()).is_equal(0)
|
||||
|
||||
|
||||
func test_idle_transitions_do_not_phase_seek() -> void:
|
||||
# Phase preservation is gait<->gait only — an idle edge starts the clip normally.
|
||||
var m := _make_machine()
|
||||
var anim: LocomotionAnim = m[0]
|
||||
var rig: StubRig = m[1]
|
||||
var visual: StubVisual = m[2]
|
||||
anim.update(0.016) # -> Idle
|
||||
visual.player.current_animation_position = 1.0
|
||||
rig.is_moving = true
|
||||
rig.current_speed = 1.25
|
||||
anim.update(0.016) # Idle -> Walk
|
||||
assert_int(visual.player.seeks.size()).is_equal(0)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 7. Teleport hard reset (§6.3)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func test_teleport_replays_with_zero_blend_and_rewinds() -> void:
|
||||
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) # -> Walk, mid-session
|
||||
visual.player.current_animation_position = 0.7
|
||||
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_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.
|
||||
assert_int(visual.player.seeks.size()).is_equal(1)
|
||||
assert_float(visual.player.seeks[0][0]).is_equal_approx(0.0, EPS)
|
||||
assert_float(visual.player.speed_scale).is_equal_approx(1.0, EPS)
|
||||
|
||||
|
||||
func test_setup_connects_rig_teleported_signal_when_present() -> void:
|
||||
# The rig contract (§4.0/§7) emits `teleported(pos_m)`; setup() auto-wires the
|
||||
# reset. The stub signal signature mirrors locomotion_rig.gd:42.
|
||||
var rig := SignallingStubRig.new()
|
||||
var visual := StubVisual.new()
|
||||
var anim := LocomotionAnim.new()
|
||||
anim.setup(rig, visual)
|
||||
rig.is_moving = true
|
||||
rig.current_speed = 1.25
|
||||
anim.update(0.016)
|
||||
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_float(_last_play(visual)["blend"]) \
|
||||
.is_equal_approx(SandboxConstants.BLEND["teleport"], EPS)
|
||||
|
||||
|
||||
class SignallingStubRig:
|
||||
extends RefCounted
|
||||
signal teleported(pos_m: Vector3)
|
||||
var stance: String = "Walk"
|
||||
var is_moving: bool = false
|
||||
var current_speed: float = 0.0
|
||||
@@ -0,0 +1,264 @@
|
||||
## Greybox tile store contract (T-1088 design §3, §10.4): accumulation across
|
||||
## LOS-filtered snapshots, never-evict / last-observation-wins, and the
|
||||
## four-state visibility flips — on synthetic GameState.visible_tiles dicts,
|
||||
## plus a fixture replay through the full decode pipeline
|
||||
## (tests/fixtures/gauntlet/*.msgpack -> Protocol.decode_snapshot() ->
|
||||
## GameState.apply_snapshot() -> Store), per design-input §1.6.
|
||||
##
|
||||
## Headless by design: only the Store inner class (RefCounted) is exercised —
|
||||
## the GreyboxWorld painter node (MultiMesh writes, shaders) is §10.1/§10.2's
|
||||
## live/visual job.
|
||||
class_name TestGreyboxStore
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const Store := GreyboxWorld.Store
|
||||
|
||||
## Repo-relative fixture dir (outside res:// — resolved via globalize, the
|
||||
## visual_capture.gd:177-179 pattern). Regenerate: make fixtures-gauntlet.
|
||||
const FIXTURE_DIR := "tests/fixtures/gauntlet"
|
||||
|
||||
|
||||
## Live-wire dict shape after the snapshot_handler.gd:52-64 merge:
|
||||
## {x, y, z, visibility, type} with type already lowercased by protocol.gd.
|
||||
func _tile(x: int, y: int, type: String, visibility: String = "Forward") -> Dictionary:
|
||||
return {"x": x, "y": y, "z": 0, "visibility": visibility, "type": type}
|
||||
|
||||
|
||||
## TestHarness "tiles" shape — no visibility key (design-input §1.5).
|
||||
func _harness_tile(x: int, y: int, type: String) -> Dictionary:
|
||||
return {"x": x, "y": y, "z": 0, "type": type}
|
||||
|
||||
|
||||
# -- accumulation + never-evict (§3.1) ------------------------------------------
|
||||
|
||||
|
||||
func test_ingest_accumulates_across_snapshots() -> void:
|
||||
var store := Store.new()
|
||||
store.ingest([_tile(1, 1, "floor"), _tile(2, 1, "wall")])
|
||||
store.ingest([_tile(3, 1, "floor"), _tile(4, 1, "floor")])
|
||||
assert_int(store.size()).is_equal(4)
|
||||
assert_bool(store.has_tile(Vector3i(1, 1, 0))).is_true()
|
||||
assert_bool(store.has_tile(Vector3i(4, 1, 0))).is_true()
|
||||
|
||||
|
||||
func test_added_reported_once_then_diff_stays_empty() -> void:
|
||||
var store := Store.new()
|
||||
var first: Dictionary = store.ingest([_tile(1, 1, "floor")])
|
||||
var second: Dictionary = store.ingest([_tile(1, 1, "floor")])
|
||||
assert_array(first["added"]).contains_exactly([Vector3i(1, 1, 0)])
|
||||
assert_array(second["added"]).is_empty()
|
||||
assert_array(second["rekinded"]).is_empty()
|
||||
assert_array(second["recolored"]).is_empty()
|
||||
|
||||
|
||||
func test_never_evicts() -> void:
|
||||
# Walked away — LOS goes empty for many snapshots; the tile stays known.
|
||||
var store := Store.new()
|
||||
store.ingest([_tile(5, 5, "wall")])
|
||||
for i in 10:
|
||||
store.ingest([])
|
||||
assert_int(store.size()).is_equal(1)
|
||||
assert_bool(store.has_tile(Vector3i(5, 5, 0))).is_true()
|
||||
assert_int(store.kind_of(Vector3i(5, 5, 0))).is_equal(Store.Kind.WALL)
|
||||
|
||||
|
||||
func test_kinds_parse_and_doors_collapse_to_floor() -> void:
|
||||
var store := Store.new()
|
||||
store.ingest(
|
||||
[_tile(1, 0, "floor"), _tile(2, 0, "wall"), _tile(3, 0, "door"), _tile(4, 0, "object")]
|
||||
)
|
||||
assert_int(store.kind_of(Vector3i(1, 0, 0))).is_equal(Store.Kind.FLOOR)
|
||||
assert_int(store.kind_of(Vector3i(2, 0, 0))).is_equal(Store.Kind.WALL)
|
||||
# Wire tile_kind is walkability-derived (query.rs:78-82); doors are entities
|
||||
# on the wire — Door/Object collapse to FLOOR (§3.1).
|
||||
assert_int(store.kind_of(Vector3i(3, 0, 0))).is_equal(Store.Kind.FLOOR)
|
||||
assert_int(store.kind_of(Vector3i(4, 0, 0))).is_equal(Store.Kind.FLOOR)
|
||||
|
||||
|
||||
func test_last_observation_wins_kind_flip() -> void:
|
||||
var store := Store.new()
|
||||
store.ingest([_tile(7, 7, "wall")])
|
||||
var diff: Dictionary = store.ingest([_tile(7, 7, "floor")])
|
||||
assert_int(store.kind_of(Vector3i(7, 7, 0))).is_equal(Store.Kind.FLOOR)
|
||||
assert_array(diff["rekinded"]).contains_exactly([Vector3i(7, 7, 0)])
|
||||
assert_array(diff["added"]).is_empty()
|
||||
assert_int(store.size()).is_equal(1)
|
||||
|
||||
|
||||
func test_z_level_distinguishes_tiles() -> void:
|
||||
var store := Store.new()
|
||||
store.ingest(
|
||||
[
|
||||
{"x": 1, "y": 1, "z": 0, "visibility": "Forward", "type": "floor"},
|
||||
{"x": 1, "y": 1, "z": 1, "visibility": "Forward", "type": "wall"},
|
||||
]
|
||||
)
|
||||
assert_int(store.size()).is_equal(2)
|
||||
assert_int(store.kind_of(Vector3i(1, 1, 0))).is_equal(Store.Kind.FLOOR)
|
||||
assert_int(store.kind_of(Vector3i(1, 1, 1))).is_equal(Store.Kind.WALL)
|
||||
|
||||
|
||||
func test_malformed_entries_are_skipped() -> void:
|
||||
var store := Store.new()
|
||||
store.ingest([42, {"y": 3}, _tile(1, 1, "floor")])
|
||||
assert_int(store.size()).is_equal(1)
|
||||
|
||||
|
||||
# -- four-state visibility (§3.2) -------------------------------------------------
|
||||
|
||||
|
||||
func test_visibility_states_parse() -> void:
|
||||
var store := Store.new()
|
||||
store.ingest(
|
||||
[
|
||||
_tile(1, 0, "floor", "Forward"),
|
||||
_tile(2, 0, "floor", "Peripheral"),
|
||||
_tile(3, 0, "wall", "BoundaryWall"),
|
||||
]
|
||||
)
|
||||
assert_int(store.state_of(Vector3i(1, 0, 0))).is_equal(Store.Vis.FORWARD)
|
||||
assert_int(store.state_of(Vector3i(2, 0, 0))).is_equal(Store.Vis.PERIPHERAL)
|
||||
assert_int(store.state_of(Vector3i(3, 0, 0))).is_equal(Store.Vis.BOUNDARY_WALL)
|
||||
|
||||
|
||||
func test_harness_tiles_without_visibility_read_forward() -> void:
|
||||
var store := Store.new()
|
||||
store.ingest([_harness_tile(1, 1, "floor")])
|
||||
assert_int(store.state_of(Vector3i(1, 1, 0))).is_equal(Store.Vis.FORWARD)
|
||||
|
||||
|
||||
func test_out_of_sight_flips_to_remembered_exactly_once() -> void:
|
||||
var store := Store.new()
|
||||
store.ingest([_tile(1, 1, "floor")])
|
||||
var leave: Dictionary = store.ingest([])
|
||||
assert_int(store.state_of(Vector3i(1, 1, 0))).is_equal(Store.Vis.REMEMBERED)
|
||||
assert_array(leave["recolored"]).contains_exactly([Vector3i(1, 1, 0)])
|
||||
# Still-remembered tiles must not re-emit the flip on later snapshots —
|
||||
# diff-only painting depends on it (§3.1, no full-buffer rebuilds).
|
||||
var later: Dictionary = store.ingest([])
|
||||
assert_array(later["recolored"]).is_empty()
|
||||
|
||||
|
||||
func test_four_state_flip_sequence() -> void:
|
||||
# Forward -> Peripheral -> BoundaryWall -> (absent) Remembered -> Forward,
|
||||
# each flip emitted as exactly one recolor — never a re-add.
|
||||
var store := Store.new()
|
||||
var key := Vector3i(9, 9, 0)
|
||||
store.ingest([_tile(9, 9, "wall", "Forward")])
|
||||
assert_int(store.state_of(key)).is_equal(Store.Vis.FORWARD)
|
||||
var to_peripheral: Dictionary = store.ingest([_tile(9, 9, "wall", "Peripheral")])
|
||||
assert_int(store.state_of(key)).is_equal(Store.Vis.PERIPHERAL)
|
||||
assert_array(to_peripheral["recolored"]).contains_exactly([key])
|
||||
var to_boundary: Dictionary = store.ingest([_tile(9, 9, "wall", "BoundaryWall")])
|
||||
assert_int(store.state_of(key)).is_equal(Store.Vis.BOUNDARY_WALL)
|
||||
assert_array(to_boundary["recolored"]).contains_exactly([key])
|
||||
var to_remembered: Dictionary = store.ingest([])
|
||||
assert_int(store.state_of(key)).is_equal(Store.Vis.REMEMBERED)
|
||||
assert_array(to_remembered["recolored"]).contains_exactly([key])
|
||||
var back: Dictionary = store.ingest([_tile(9, 9, "wall", "Forward")])
|
||||
assert_int(store.state_of(key)).is_equal(Store.Vis.FORWARD)
|
||||
assert_array(back["recolored"]).contains_exactly([key])
|
||||
assert_array(back["added"]).is_empty()
|
||||
assert_int(store.size()).is_equal(1)
|
||||
|
||||
|
||||
func test_never_seen_is_the_void() -> void:
|
||||
# Never-seen tiles have no state — nothing rendered; perception is upstream,
|
||||
# server-enforced (§3.2).
|
||||
var store := Store.new()
|
||||
store.ingest([_tile(1, 1, "floor")])
|
||||
assert_bool(store.has_tile(Vector3i(99, 99, 0))).is_false()
|
||||
assert_int(store.state_of(Vector3i(99, 99, 0))).is_equal(-1)
|
||||
assert_int(store.kind_of(Vector3i(99, 99, 0))).is_equal(-1)
|
||||
|
||||
|
||||
# -- to_dict() persistence seam (design §0 scope fence) ----------------------------
|
||||
|
||||
|
||||
func test_to_dict_exports_knowledge_copy() -> void:
|
||||
var store := Store.new()
|
||||
store.ingest([_tile(1, 1, "floor"), _tile(2, 1, "wall")])
|
||||
var exported: Dictionary = store.to_dict()
|
||||
assert_int(exported.size()).is_equal(2)
|
||||
assert_int(exported[Vector3i(1, 1, 0)]).is_equal(Store.Kind.FLOOR)
|
||||
assert_int(exported[Vector3i(2, 1, 0)]).is_equal(Store.Kind.WALL)
|
||||
# A copy — the seam must not expose live store state.
|
||||
exported.erase(Vector3i(1, 1, 0))
|
||||
assert_int(store.size()).is_equal(2)
|
||||
|
||||
|
||||
# -- fixture replay (design §10.4; full pipeline per design-input §1.6) -------------
|
||||
|
||||
|
||||
func test_fixture_replay_hub_spawn_then_movement() -> void:
|
||||
# Fixture bytes -> Protocol.decode_snapshot() (real wire decode incl. the
|
||||
# tile_kind -> type mapping) -> GameState.apply_snapshot() (wire-spelling
|
||||
# merge) -> Store — the exact path the live sandbox runs. Expectations are
|
||||
# computed from the decoded arrays, not hardcoded, so fixture regeneration
|
||||
# cannot silently skew the assertions.
|
||||
var spawn: Dictionary = _decode_fixture("hub_spawn")
|
||||
var moved: Dictionary = _decode_fixture("hub_after_movement")
|
||||
var spawn_keys := _key_set(spawn["visible_tiles"])
|
||||
var moved_keys := _key_set(moved["visible_tiles"])
|
||||
assert_bool(spawn_keys.is_empty()).is_false()
|
||||
assert_bool(moved_keys.is_empty()).is_false()
|
||||
|
||||
var store := Store.new()
|
||||
GameState.apply_snapshot(spawn)
|
||||
var first: Dictionary = store.ingest(GameState.visible_tiles)
|
||||
assert_int(store.size()).is_equal(spawn_keys.size())
|
||||
assert_int((first["added"] as Array).size()).is_equal(spawn_keys.size())
|
||||
# The Hub spawn frame discloses both kinds — walls fringe the floor cone.
|
||||
assert_bool(_contains_kind(store, spawn_keys, Store.Kind.FLOOR)).is_true()
|
||||
assert_bool(_contains_kind(store, spawn_keys, Store.Kind.WALL)).is_true()
|
||||
# The Hub spawn tile (50, 58) itself is a disclosed floor (design-input §1.6).
|
||||
assert_int(store.kind_of(Vector3i(50, 58, 0))).is_equal(Store.Kind.FLOOR)
|
||||
|
||||
GameState.apply_snapshot(moved)
|
||||
store.ingest(GameState.visible_tiles)
|
||||
# Accumulation: the union of both frames, nothing evicted.
|
||||
var union := spawn_keys.duplicate()
|
||||
union.merge(moved_keys)
|
||||
assert_int(store.size()).is_equal(union.size())
|
||||
# Every tile of the moved frame is visible now (never REMEMBERED)...
|
||||
var visible_marked_remembered := 0
|
||||
for key: Vector3i in moved_keys:
|
||||
if store.state_of(key) == Store.Vis.REMEMBERED:
|
||||
visible_marked_remembered += 1
|
||||
assert_int(visible_marked_remembered).is_equal(0)
|
||||
# ...and every spawn-only tile has dimmed to REMEMBERED.
|
||||
var stale_not_remembered := 0
|
||||
for key: Vector3i in spawn_keys:
|
||||
if not moved_keys.has(key) and store.state_of(key) != Store.Vis.REMEMBERED:
|
||||
stale_not_remembered += 1
|
||||
assert_int(stale_not_remembered).is_equal(0)
|
||||
|
||||
|
||||
func _decode_fixture(fixture_name: String) -> Dictionary:
|
||||
# Fixtures live outside res:// — resolve the repo root from the project dir
|
||||
# (pattern: visual_capture.gd:177-179).
|
||||
var repo_root := ProjectSettings.globalize_path("res://").rstrip("/").get_base_dir()
|
||||
var path := repo_root.path_join(FIXTURE_DIR).path_join(fixture_name + ".msgpack")
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
assert_that(file).is_not_null().override_failure_message(
|
||||
"Gauntlet fixture missing: %s — run 'make fixtures-gauntlet'" % path
|
||||
)
|
||||
var bytes := file.get_buffer(file.get_length())
|
||||
file.close()
|
||||
var snapshot: Variant = Protocol.decode_snapshot(bytes)
|
||||
assert_bool(snapshot is Dictionary).is_true()
|
||||
return snapshot if snapshot is Dictionary else {}
|
||||
|
||||
|
||||
func _key_set(tiles: Array) -> Dictionary:
|
||||
var keys: Dictionary = {}
|
||||
for tile: Dictionary in tiles:
|
||||
keys[Vector3i(int(tile["x"]), int(tile["y"]), int(tile["z"]))] = true
|
||||
return keys
|
||||
|
||||
|
||||
func _contains_kind(store: GreyboxWorld.Store, keys: Dictionary, kind: int) -> bool:
|
||||
for key: Vector3i in keys:
|
||||
if store.kind_of(key) == kind:
|
||||
return true
|
||||
return false
|
||||
@@ -0,0 +1,237 @@
|
||||
## T-1088 (design §9, §10.4): the InputMapper facing_angle_provider seam and the
|
||||
## sandbox mouse-aim provider's pure angle math.
|
||||
##
|
||||
## The provider math is tested through the static
|
||||
## SandboxMouseAimProvider.compute_facing_angle() with synthetic rays/transforms —
|
||||
## no viewport or camera needed headless. The seam tests drive the InputMapper
|
||||
## autoload directly; the legacy 2D canvas-transform path is made deterministic by
|
||||
## positioning GameState.player_position relative to the CURRENT mouse position
|
||||
## (no assumption about where the headless mouse sits).
|
||||
class_name TestLocomotionInput
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const MouseAimProvider := preload("res://scripts/sandbox/mouse_aim_provider.gd")
|
||||
|
||||
const EPS := 0.000001
|
||||
## Deadzone used by the synthetic-math tests (mirrors SandboxConstants.MOUSE_AIM_DEADZONE_M).
|
||||
const DEADZONE := 0.1
|
||||
## Ray pointing straight down at the ground plane.
|
||||
const DOWN := Vector3(0.0, -1.0, 0.0)
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
InputMapper.facing_angle_provider = Callable()
|
||||
InputMapper.reset_facing_state()
|
||||
GameState.player_position = Vector2.ZERO
|
||||
|
||||
|
||||
# -- compute_facing_angle: cardinal/diagonal directions (identity WorldRoot) --------
|
||||
|
||||
|
||||
func test_math_hit_east_of_rig_is_zero() -> void:
|
||||
# Straight-down ray 1 m east (+X) of the rig -> sim angle 0 (East).
|
||||
var a: float = MouseAimProvider.compute_facing_angle(
|
||||
Vector3(1.0, 10.0, 0.0), DOWN, Transform3D.IDENTITY, Vector3.ZERO, DEADZONE
|
||||
)
|
||||
assert_float(a).is_equal_approx(0.0, EPS)
|
||||
|
||||
|
||||
func test_math_hit_south_of_rig_is_plus_half_pi() -> void:
|
||||
# Local +Z = sim South (Y-down radians): hit at +Z -> +PI/2.
|
||||
var a: float = MouseAimProvider.compute_facing_angle(
|
||||
Vector3(0.0, 10.0, 1.0), DOWN, Transform3D.IDENTITY, Vector3.ZERO, DEADZONE
|
||||
)
|
||||
assert_float(a).is_equal_approx(PI / 2.0, EPS)
|
||||
|
||||
|
||||
func test_math_hit_west_of_rig_is_pi() -> void:
|
||||
var a: float = MouseAimProvider.compute_facing_angle(
|
||||
Vector3(-1.0, 10.0, 0.0), DOWN, Transform3D.IDENTITY, Vector3.ZERO, DEADZONE
|
||||
)
|
||||
assert_float(a).is_equal_approx(PI, EPS)
|
||||
|
||||
|
||||
func test_math_hit_north_of_rig_is_minus_half_pi() -> void:
|
||||
var a: float = MouseAimProvider.compute_facing_angle(
|
||||
Vector3(0.0, 10.0, -1.0), DOWN, Transform3D.IDENTITY, Vector3.ZERO, DEADZONE
|
||||
)
|
||||
assert_float(a).is_equal_approx(-PI / 2.0, EPS)
|
||||
|
||||
|
||||
func test_math_hit_southeast_of_rig_is_quarter_pi() -> void:
|
||||
var a: float = MouseAimProvider.compute_facing_angle(
|
||||
Vector3(1.0, 10.0, 1.0), DOWN, Transform3D.IDENTITY, Vector3.ZERO, DEADZONE
|
||||
)
|
||||
assert_float(a).is_equal_approx(PI / 4.0, EPS)
|
||||
|
||||
|
||||
# -- compute_facing_angle: WorldRoot transform is undone -----------------------------
|
||||
|
||||
|
||||
func test_math_world_rotation_undone() -> void:
|
||||
# The D-148 45° map rotation must NOT skew the sim angle. A world-space hit at
|
||||
# the rotated image of local (1,0,0) must still read as East (0.0).
|
||||
var xf := Transform3D(Basis(Vector3.UP, deg_to_rad(45.0)), Vector3.ZERO)
|
||||
var hit_world := xf * Vector3(1.0, 0.0, 0.0)
|
||||
var a: float = MouseAimProvider.compute_facing_angle(
|
||||
hit_world + Vector3(0.0, 10.0, 0.0), DOWN, xf, Vector3.ZERO, DEADZONE
|
||||
)
|
||||
assert_float(a).is_equal_approx(0.0, EPS)
|
||||
|
||||
|
||||
func test_math_world_translation_undone() -> void:
|
||||
# A translated WorldRoot: hit at the world image of local (0,0,1) -> South.
|
||||
var xf := Transform3D(Basis.IDENTITY, Vector3(10.0, 0.0, -3.0))
|
||||
var hit_world := xf * Vector3(0.0, 0.0, 1.0)
|
||||
var a: float = MouseAimProvider.compute_facing_angle(
|
||||
hit_world + Vector3(0.0, 10.0, 0.0), DOWN, xf, Vector3.ZERO, DEADZONE
|
||||
)
|
||||
assert_float(a).is_equal_approx(PI / 2.0, EPS)
|
||||
|
||||
|
||||
func test_math_rig_offset_and_rotation_compose() -> void:
|
||||
# Rotated WorldRoot + rig away from the origin: hit at the world image of the
|
||||
# local point 1 m east of the rig -> East, regardless of either offset.
|
||||
var xf := Transform3D(Basis(Vector3.UP, deg_to_rad(45.0)), Vector3(5.0, 0.0, 7.0))
|
||||
var rig_local := Vector3(2.0, 0.0, 3.0)
|
||||
var hit_world := xf * (rig_local + Vector3(1.0, 0.0, 0.0))
|
||||
var a: float = MouseAimProvider.compute_facing_angle(
|
||||
hit_world + Vector3(0.0, 10.0, 0.0), DOWN, xf, rig_local, DEADZONE
|
||||
)
|
||||
assert_float(a).is_equal_approx(0.0, EPS)
|
||||
|
||||
|
||||
func test_math_oblique_ray_like_ortho_camera() -> void:
|
||||
# A -30°-pitch-style oblique ray (not straight down) still lands on y=0
|
||||
# correctly: origin (0, 5, 8.66), dir (0, -0.5, -0.866) -> hit (0, 0, 0);
|
||||
# rig 1 m west of the hit -> East.
|
||||
var a: float = MouseAimProvider.compute_facing_angle(
|
||||
Vector3(0.0, 5.0, 8.66),
|
||||
Vector3(0.0, -0.5, -0.866),
|
||||
Transform3D.IDENTITY,
|
||||
Vector3(-1.0, 0.0, 0.0),
|
||||
DEADZONE
|
||||
)
|
||||
assert_float(a).is_equal_approx(0.0, EPS)
|
||||
|
||||
|
||||
# -- compute_facing_angle: NAN cases (deadzone + degenerate rays) --------------------
|
||||
|
||||
|
||||
func test_math_inside_deadzone_is_nan() -> void:
|
||||
# 0.05 m from the rig < 0.1 m deadzone -> NAN (no update; the 2D jitter-guard mirror).
|
||||
var a: float = MouseAimProvider.compute_facing_angle(
|
||||
Vector3(0.05, 10.0, 0.0), DOWN, Transform3D.IDENTITY, Vector3.ZERO, DEADZONE
|
||||
)
|
||||
assert_bool(is_nan(a)).is_true()
|
||||
|
||||
|
||||
func test_math_just_outside_deadzone_is_finite() -> void:
|
||||
var a: float = MouseAimProvider.compute_facing_angle(
|
||||
Vector3(0.2, 10.0, 0.0), DOWN, Transform3D.IDENTITY, Vector3.ZERO, DEADZONE
|
||||
)
|
||||
assert_bool(is_finite(a)).is_true()
|
||||
assert_float(a).is_equal_approx(0.0, EPS)
|
||||
|
||||
|
||||
func test_math_ray_parallel_to_ground_is_nan() -> void:
|
||||
var a: float = MouseAimProvider.compute_facing_angle(
|
||||
Vector3(0.0, 10.0, 0.0),
|
||||
Vector3(1.0, 0.0, 0.0),
|
||||
Transform3D.IDENTITY,
|
||||
Vector3.ZERO,
|
||||
DEADZONE
|
||||
)
|
||||
assert_bool(is_nan(a)).is_true()
|
||||
|
||||
|
||||
func test_math_ground_plane_behind_ray_is_nan() -> void:
|
||||
# Origin below the plane, ray pointing further down -> t < 0 -> NAN.
|
||||
var a: float = MouseAimProvider.compute_facing_angle(
|
||||
Vector3(0.0, -5.0, 0.0), DOWN, Transform3D.IDENTITY, Vector3.ZERO, DEADZONE
|
||||
)
|
||||
assert_bool(is_nan(a)).is_true()
|
||||
|
||||
|
||||
func test_math_angle_feeds_octant_snap() -> void:
|
||||
# The provider's output is consumed by InputMapper._angle_to_octant — a
|
||||
# southeast hit must snap to the "Southeast" wire octant.
|
||||
var a: float = MouseAimProvider.compute_facing_angle(
|
||||
Vector3(1.0, 10.0, 1.0), DOWN, Transform3D.IDENTITY, Vector3.ZERO, DEADZONE
|
||||
)
|
||||
assert_str(InputMapper._angle_to_octant(a)).is_equal("Southeast")
|
||||
|
||||
|
||||
# -- provider instance guards (no viewport needed) ------------------------------------
|
||||
|
||||
|
||||
func test_provider_with_null_nodes_returns_nan() -> void:
|
||||
var provider := MouseAimProvider.new(null, null, null)
|
||||
assert_bool(is_nan(provider.get_facing_angle())).is_true()
|
||||
|
||||
|
||||
func test_provider_with_out_of_tree_nodes_returns_nan() -> void:
|
||||
var camera: Camera3D = auto_free(Camera3D.new())
|
||||
var world_root: Node3D = auto_free(Node3D.new())
|
||||
var rig: Node3D = auto_free(Node3D.new())
|
||||
var provider := MouseAimProvider.new(camera, world_root, rig)
|
||||
assert_bool(is_nan(provider.get_facing_angle())).is_true()
|
||||
|
||||
|
||||
# -- InputMapper seam ------------------------------------------------------------------
|
||||
|
||||
|
||||
func test_seam_finite_provider_updates_facing_and_octant() -> void:
|
||||
InputMapper.facing_angle_provider = func() -> float: return PI / 4.0
|
||||
InputMapper._update_facing_from_mouse()
|
||||
assert_float(InputMapper.facing_angle).is_equal_approx(PI / 4.0, EPS)
|
||||
assert_str(InputMapper.facing_octant).is_equal("Southeast")
|
||||
|
||||
|
||||
func test_seam_nan_provider_leaves_facing_and_blocks_2d_path() -> void:
|
||||
# Arrange the 2D path so it WOULD rewrite facing if it ran (player 100 px
|
||||
# away from the mouse on screen), then install a NAN provider: the early
|
||||
# return must both skip the update and block the 2D path entirely.
|
||||
_place_player_at_screen_delta(Vector2(100.0, 100.0))
|
||||
InputMapper.facing_angle = 0.42
|
||||
InputMapper.facing_octant = "East"
|
||||
InputMapper.facing_angle_provider = func() -> float: return NAN
|
||||
InputMapper._update_facing_from_mouse()
|
||||
assert_float(InputMapper.facing_angle).is_equal_approx(0.42, EPS)
|
||||
assert_str(InputMapper.facing_octant).is_equal("East")
|
||||
|
||||
|
||||
func test_seam_unset_provider_falls_through_to_2d_path() -> void:
|
||||
# Provider unset (default Callable()): the new branch must not fire and the
|
||||
# legacy 2D canvas-transform path must run unchanged — with the player placed
|
||||
# 100 px up-left of the mouse, it computes atan2(100, 100) = PI/4 (Southeast).
|
||||
_place_player_at_screen_delta(Vector2(100.0, 100.0))
|
||||
InputMapper.facing_angle_provider = Callable()
|
||||
InputMapper._update_facing_from_mouse()
|
||||
assert_float(InputMapper.facing_angle).is_equal_approx(PI / 4.0, 0.001)
|
||||
assert_str(InputMapper.facing_octant).is_equal("Southeast")
|
||||
|
||||
|
||||
func test_seam_unset_provider_leaves_facing_untouched_inside_2d_jitter_guard() -> void:
|
||||
# Provider unset + player exactly under the mouse: neither the new branch nor
|
||||
# the 2D path (its own <= 2 px jitter guard) may touch facing.
|
||||
_place_player_at_screen_delta(Vector2.ZERO)
|
||||
InputMapper.facing_angle = 0.42
|
||||
InputMapper.facing_octant = "East"
|
||||
InputMapper.facing_angle_provider = Callable()
|
||||
InputMapper._update_facing_from_mouse()
|
||||
assert_float(InputMapper.facing_angle).is_equal_approx(0.42, EPS)
|
||||
assert_str(InputMapper.facing_octant).is_equal("East")
|
||||
|
||||
|
||||
# Position GameState.player_position so that (mouse_screen - player_screen) equals
|
||||
# delta_px EXACTLY, inverting the 2D path's own math (player_position * TILE_SIZE
|
||||
# through the canvas transform). This pins the legacy path's outcome without any
|
||||
# assumption about the headless mouse position or canvas transform.
|
||||
func _place_player_at_screen_delta(delta_px: Vector2) -> void:
|
||||
var vp := InputMapper.get_viewport()
|
||||
var canvas_xf := vp.get_canvas_transform()
|
||||
var C := load("res://scripts/constants.gd")
|
||||
var player_screen := vp.get_mouse_position() - delta_px
|
||||
var player_world_px := canvas_xf.affine_inverse() * player_screen
|
||||
GameState.player_position = player_world_px / float(C.TILE_SIZE)
|
||||
@@ -0,0 +1,378 @@
|
||||
## LocomotionRig math (T-1088 design §4, §5, §10.4): per-leg constant-velocity
|
||||
## derivation (cardinal 1.25 m/s / diagonal 1.77 m/s at Walk 400 ms), the 3x
|
||||
## catch-up clamp, the 2.5 m snap threshold, idle hysteresis timing, paused-tick
|
||||
## idempotence, blocked-move zero-motion (Q-020 bump-to-turn), and shortest-arc
|
||||
## yaw easing under the per-stance TURN_BUDGET_DEG clamps.
|
||||
##
|
||||
## Pure static-core functions are tested directly; wrapper tests instantiate the
|
||||
## rig out-of-tree (never added as a child, so the engine never drives _process —
|
||||
## frames are stepped manually for deterministic timing).
|
||||
class_name TestLocomotionMath
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const EPS := 0.000001
|
||||
|
||||
## 60 fps frame used by the yaw tests.
|
||||
const DT := 1.0 / 60.0
|
||||
|
||||
|
||||
func _make_rig() -> LocomotionRig:
|
||||
var rig: LocomotionRig = auto_free(LocomotionRig.new())
|
||||
rig.model_root = auto_free(Node3D.new())
|
||||
return rig
|
||||
|
||||
|
||||
# -- leg-speed derivation (§4.1: dist / interval, clamped) --------------------------
|
||||
|
||||
|
||||
func test_leg_speed_cardinal_walk() -> void:
|
||||
# One cardinal subtile at Walk: 0.5 m / 0.4 s = 1.25 m/s.
|
||||
assert_float(LocomotionRig.derive_leg_speed(0.5, 0.4)).is_equal_approx(1.25, EPS)
|
||||
|
||||
|
||||
func test_leg_speed_diagonal_walk() -> void:
|
||||
# Diagonal step costs the same interval (no sqrt(2) on the wire, D-053):
|
||||
# 0.5 * sqrt(2) / 0.4 = ~1.77 m/s — arrives exactly when the next step lands.
|
||||
var diag := 0.5 * sqrt(2.0)
|
||||
assert_float(LocomotionRig.derive_leg_speed(diag, 0.4)).is_equal_approx(1.767767, 0.0001)
|
||||
|
||||
|
||||
func test_leg_speed_sprint_cardinal() -> void:
|
||||
# Sprint window 200 ms: 0.5 / 0.2 = 2.5 m/s.
|
||||
assert_float(LocomotionRig.derive_leg_speed(0.5, 0.2)).is_equal_approx(2.5, EPS)
|
||||
|
||||
|
||||
func test_leg_speed_catchup_clamped_at_3x() -> void:
|
||||
# 4-tile latest-wins delta at Walk: raw 2.0/0.4 = 5.0 m/s, clamped to
|
||||
# CATCHUP_MAX_FACTOR (3.0) * base = 3.75 m/s — feet speed up, never blur.
|
||||
assert_float(LocomotionRig.derive_leg_speed(2.0, 0.4)).is_equal_approx(3.75, EPS)
|
||||
|
||||
|
||||
func test_leg_speed_floored_at_base() -> void:
|
||||
# Sub-subtile residue (late arrival) still closes at least at base speed —
|
||||
# the tail of a leg never crawls.
|
||||
assert_float(LocomotionRig.derive_leg_speed(0.1, 0.4)).is_equal_approx(1.25, EPS)
|
||||
|
||||
|
||||
# -- target classification (§4.1: first snap / ignore / teleport / step) -------------
|
||||
|
||||
|
||||
func test_classify_first_snapshot_snaps() -> void:
|
||||
var got := LocomotionRig.classify_target(
|
||||
false, Vector3.ZERO, Vector3.ZERO, Vector3(25.25, 0.0, 29.25)
|
||||
)
|
||||
assert_int(got).is_equal(LocomotionRig.TargetAction.SNAP_FIRST)
|
||||
|
||||
|
||||
func test_classify_same_target_ignored() -> void:
|
||||
# Paused ticks keep delivering identical positions (§4.3) — idempotent.
|
||||
var target := Vector3(1.25, 0.0, 2.25)
|
||||
var got := LocomotionRig.classify_target(true, target, target, target)
|
||||
assert_int(got).is_equal(LocomotionRig.TargetAction.IGNORE)
|
||||
|
||||
|
||||
func test_classify_same_target_ignored_mid_leg() -> void:
|
||||
# A repeat while still chasing keeps the current leg speed (no re-derivation
|
||||
# from the shrinking remaining distance).
|
||||
var target := Vector3(1.75, 0.0, 2.25)
|
||||
var render := Vector3(1.5, 0.0, 2.25)
|
||||
var got := LocomotionRig.classify_target(true, render, target, target)
|
||||
assert_int(got).is_equal(LocomotionRig.TargetAction.IGNORE)
|
||||
|
||||
|
||||
func test_classify_step_within_snap_dist() -> void:
|
||||
var render := Vector3(1.25, 0.0, 2.25)
|
||||
var got := LocomotionRig.classify_target(
|
||||
true, render, render, render + Vector3(0.5, 0.0, 0.0)
|
||||
)
|
||||
assert_int(got).is_equal(LocomotionRig.TargetAction.STEP)
|
||||
|
||||
|
||||
func test_classify_snap_threshold_boundary() -> void:
|
||||
# SNAP_DIST_M is strict: exactly 2.5 m (5 subtiles) still glides — matches
|
||||
# the 2D TELEPORT_DISTANCE_THRESHOLD semantics; beyond it teleports.
|
||||
var render := Vector3.ZERO
|
||||
var at_limit := LocomotionRig.classify_target(
|
||||
true, render, render, Vector3(2.5, 0.0, 0.0)
|
||||
)
|
||||
assert_int(at_limit).is_equal(LocomotionRig.TargetAction.STEP)
|
||||
var beyond := LocomotionRig.classify_target(
|
||||
true, render, render, Vector3(2.51, 0.0, 0.0)
|
||||
)
|
||||
assert_int(beyond).is_equal(LocomotionRig.TargetAction.TELEPORT)
|
||||
|
||||
|
||||
func test_classify_teleport_measured_from_render_pos() -> void:
|
||||
# Teleport distance is render-pos -> new target (§4.1), not target -> target.
|
||||
var render := Vector3.ZERO
|
||||
var old_target := Vector3(2.0, 0.0, 0.0)
|
||||
var got := LocomotionRig.classify_target(
|
||||
true, render, old_target, Vector3(2.0, 0.0, 2.0) # 2.83 m from render
|
||||
)
|
||||
assert_int(got).is_equal(LocomotionRig.TargetAction.TELEPORT)
|
||||
|
||||
|
||||
# -- idle hysteresis (§4.2: IDLE_ENTER_DELAY_S = 0.18) --------------------------------
|
||||
|
||||
|
||||
func test_moving_while_not_at_target() -> void:
|
||||
assert_bool(LocomotionRig.is_moving_state(false, 99.0)).is_true()
|
||||
|
||||
|
||||
func test_hysteresis_holds_moving_within_delay() -> void:
|
||||
# Arrived, but only 0.1 s at target — still "moving" (covers 2-3 ticks of
|
||||
# snapshot jitter at every stance).
|
||||
assert_bool(LocomotionRig.is_moving_state(true, 0.1)).is_true()
|
||||
|
||||
|
||||
func test_hysteresis_enters_idle_at_delay() -> void:
|
||||
assert_bool(LocomotionRig.is_moving_state(true, 0.18)).is_false()
|
||||
assert_bool(LocomotionRig.is_moving_state(true, 0.5)).is_false()
|
||||
|
||||
|
||||
func test_idle_timer_accumulates_at_target() -> void:
|
||||
assert_float(LocomotionRig.advance_idle_timer(0.1, true, 0.05)).is_equal_approx(0.15, EPS)
|
||||
|
||||
|
||||
func test_idle_timer_resets_when_leg_starts() -> void:
|
||||
assert_float(LocomotionRig.advance_idle_timer(0.5, false, 0.05)).is_equal_approx(0.0, EPS)
|
||||
|
||||
|
||||
# -- yaw target selection (§5 authority table) -----------------------------------------
|
||||
|
||||
|
||||
func test_facing_moving_uses_wire_octant() -> void:
|
||||
# Moving: server Facing IS the motion direction — mouse aim is visually ignored.
|
||||
var got := LocomotionRig.select_yaw_target(true, "East", "North", false, 0.0)
|
||||
assert_float(got).is_equal_approx(PI / 2.0, EPS)
|
||||
|
||||
|
||||
func test_facing_moving_ignores_suppression() -> void:
|
||||
var got := LocomotionRig.select_yaw_target(true, "East", "North", true, 0.0)
|
||||
assert_float(got).is_equal_approx(PI / 2.0, EPS)
|
||||
|
||||
|
||||
func test_facing_idle_uses_provider_octant() -> void:
|
||||
var got := LocomotionRig.select_yaw_target(false, "East", "North", false, 0.0)
|
||||
assert_float(got).is_equal_approx(PI, EPS)
|
||||
|
||||
|
||||
func test_facing_idle_without_provider_falls_back_to_wire() -> void:
|
||||
# NPC path: no provider installed -> pure wire facing, single code path.
|
||||
var got := LocomotionRig.select_yaw_target(false, "East", "", false, 0.0)
|
||||
assert_float(got).is_equal_approx(PI / 2.0, EPS)
|
||||
|
||||
|
||||
func test_facing_frozen_under_suppression_when_idle() -> void:
|
||||
# Dialogue / free camera: InputMapper stops sending octants — hold the last
|
||||
# target instead of showing an octant the server was never told.
|
||||
var held := 0.123
|
||||
var got := LocomotionRig.select_yaw_target(false, "East", "North", true, held)
|
||||
assert_float(got).is_equal_approx(held, EPS)
|
||||
|
||||
|
||||
# -- yaw stepping (§5: shortest-arc lerp_angle under TURN_BUDGET_DEG) --------------------
|
||||
|
||||
|
||||
func test_shortest_arc_wraps_through_pi() -> void:
|
||||
# -170 deg -> +170 deg is -20 deg through the seam, never +340 deg.
|
||||
var arc := LocomotionRig.shortest_arc(deg_to_rad(-170.0), deg_to_rad(170.0))
|
||||
assert_float(arc).is_equal_approx(deg_to_rad(-20.0), EPS)
|
||||
|
||||
|
||||
func test_yaw_step_unclamped_matches_ease() -> void:
|
||||
# Small 5 deg correction at Walk: eased step (~1 deg at 60 fps) is far under
|
||||
# the 12 deg/frame budget — pure lerp_angle ease, no clamp.
|
||||
var weight := 1.0 - exp(-SandboxConstants.TURN_SHARPNESS * DT)
|
||||
var expected := weight * deg_to_rad(5.0)
|
||||
var got := LocomotionRig.step_yaw(0.0, deg_to_rad(5.0), "Walk", DT)
|
||||
assert_float(got).is_equal_approx(expected, EPS)
|
||||
|
||||
|
||||
func test_yaw_budget_clamps_walk_reversal() -> void:
|
||||
# Near-180 reversal at Walk: eased step (~37 deg) hits the 720 deg/s budget
|
||||
# -> exactly 12 deg this frame (reversal completes in ~0.25 s, half a step).
|
||||
var got := LocomotionRig.step_yaw(0.0, deg_to_rad(179.0), "Walk", DT)
|
||||
assert_float(got).is_equal_approx(deg_to_rad(720.0) * DT, EPS)
|
||||
|
||||
|
||||
func test_yaw_budget_per_stance() -> void:
|
||||
# Same reversal, different stances: Sprint 1080 -> 18 deg/frame,
|
||||
# Crouch 420 -> 7 deg/frame.
|
||||
var target := deg_to_rad(179.0)
|
||||
var sprint := LocomotionRig.step_yaw(0.0, target, "Sprint", DT)
|
||||
assert_float(sprint).is_equal_approx(deg_to_rad(1080.0) * DT, EPS)
|
||||
var crouch := LocomotionRig.step_yaw(0.0, target, "Crouch", DT)
|
||||
assert_float(crouch).is_equal_approx(deg_to_rad(420.0) * DT, EPS)
|
||||
|
||||
|
||||
func test_yaw_budget_idle_and_unknown_key() -> void:
|
||||
# Idle budget 600 -> 10 deg/frame; unknown stance keys fall back to Idle.
|
||||
var target := deg_to_rad(179.0)
|
||||
var idle := LocomotionRig.step_yaw(0.0, target, "Idle", DT)
|
||||
assert_float(idle).is_equal_approx(deg_to_rad(600.0) * DT, EPS)
|
||||
var unknown := LocomotionRig.step_yaw(0.0, target, "Prone", DT)
|
||||
assert_float(unknown).is_equal_approx(deg_to_rad(600.0) * DT, EPS)
|
||||
|
||||
|
||||
func test_yaw_step_takes_shortest_arc() -> void:
|
||||
# From -170 deg toward +170 deg: the step is negative (through the seam).
|
||||
var start := deg_to_rad(-170.0)
|
||||
var weight := 1.0 - exp(-SandboxConstants.TURN_SHARPNESS * DT)
|
||||
var expected := start + weight * deg_to_rad(-20.0)
|
||||
var got := LocomotionRig.step_yaw(start, deg_to_rad(170.0), "Walk", DT)
|
||||
assert_float(got).is_equal_approx(expected, EPS)
|
||||
|
||||
|
||||
func test_yaw_step_wraps_result_across_seam() -> void:
|
||||
# Budget-clamped turn crossing -PI: -175 deg - 12 deg wraps to +173 deg.
|
||||
var got := LocomotionRig.step_yaw(deg_to_rad(-175.0), deg_to_rad(90.0), "Walk", DT)
|
||||
assert_float(got).is_equal_approx(deg_to_rad(173.0), EPS)
|
||||
|
||||
|
||||
# -- wrapper behavior (out-of-tree rig, manually stepped frames) --------------------------
|
||||
|
||||
|
||||
func test_first_wire_target_snaps_without_teleport_signal() -> void:
|
||||
var rig := _make_rig()
|
||||
var emissions: Array = []
|
||||
rig.teleported.connect(func(p: Vector3) -> void: emissions.append(p))
|
||||
rig.set_wire_target(Vector3(25.25, 0.0, 29.25), "East", "Walk", 100)
|
||||
assert_float(rig.position.x).is_equal_approx(25.25, EPS)
|
||||
assert_float(rig.position.z).is_equal_approx(29.25, EPS)
|
||||
assert_bool(rig.is_moving).is_false()
|
||||
assert_float(rig.current_speed).is_equal_approx(0.0, EPS)
|
||||
# Yaw snapped straight to the wire octant (East = +90 deg), no ease-in.
|
||||
assert_float(rig.model_root.rotation.y).is_equal_approx(PI / 2.0, EPS)
|
||||
assert_int(emissions.size()).is_equal(0)
|
||||
|
||||
|
||||
func test_paused_repeat_of_same_target_is_idempotent() -> void:
|
||||
# Paused server keeps sending identical-position frames (§4.3): no motion
|
||||
# re-trigger, no drift, rig settles idle through normal hysteresis.
|
||||
var rig := _make_rig()
|
||||
var target := Vector3(1.25, 0.0, 2.25)
|
||||
rig.set_wire_target(target, "North", "Walk", 10)
|
||||
for i in 5:
|
||||
rig.set_wire_target(target, "North", "Walk", 10)
|
||||
rig._process(0.05)
|
||||
assert_bool(rig.is_moving).is_false()
|
||||
assert_float(rig.current_speed).is_equal_approx(0.0, EPS)
|
||||
assert_float(rig.velocity.length()).is_equal_approx(0.0, EPS)
|
||||
assert_float(rig.position.distance_to(target)).is_equal_approx(0.0, EPS)
|
||||
|
||||
|
||||
func test_blocked_move_turns_in_place_without_motion() -> void:
|
||||
# Q-020 bump-to-turn (§4.3): the server updates Facing on a blocked move but
|
||||
# not position — the rig turns to face the wall and stands, zero motion.
|
||||
var rig := _make_rig()
|
||||
var target := Vector3(1.25, 0.0, 2.25)
|
||||
rig.set_wire_target(target, "North", "Walk", 1) # snap: yaw = PI (North)
|
||||
rig.set_wire_target(target, "West", "Walk", 2) # blocked: same tile, new facing
|
||||
rig._process(DT)
|
||||
assert_float(rig.position.distance_to(target)).is_equal_approx(0.0, EPS)
|
||||
assert_float(rig.current_speed).is_equal_approx(0.0, EPS)
|
||||
assert_bool(rig.is_moving).is_false()
|
||||
# Idle without a provider -> wire facing (West = -90 deg).
|
||||
assert_float(rig.yaw_target).is_equal_approx(-PI / 2.0, EPS)
|
||||
# One Idle-budget frame (600 deg/s -> 10 deg) from North toward West,
|
||||
# shortest arc through the +PI seam: PI + 10 deg wraps to -PI + 10 deg.
|
||||
assert_float(rig.model_root.rotation.y).is_equal_approx(-PI + deg_to_rad(10.0), EPS)
|
||||
|
||||
|
||||
func test_step_moves_at_constant_leg_speed() -> void:
|
||||
# Equal displacement per frame — the M2 milestone's numeric core.
|
||||
var rig := _make_rig()
|
||||
var start := Vector3(1.25, 0.0, 2.25)
|
||||
rig.set_wire_target(start, "East", "Walk", 1)
|
||||
rig.set_wire_target(start + Vector3(0.5, 0.0, 0.0), "East", "Walk", 2)
|
||||
rig._process(0.1)
|
||||
assert_bool(rig.is_moving).is_true()
|
||||
assert_float(rig.current_speed).is_equal_approx(1.25, 0.001)
|
||||
assert_float(rig.velocity.x).is_equal_approx(1.25, 0.001)
|
||||
assert_float(rig.position.x).is_equal_approx(1.375, 0.001)
|
||||
rig._process(0.1)
|
||||
assert_float(rig.position.x).is_equal_approx(1.5, 0.001)
|
||||
|
||||
|
||||
func test_diagonal_step_velocity_components() -> void:
|
||||
# Diagonal leg at Walk: 1.77 m/s along the diagonal = 1.25 m/s per axis —
|
||||
# both axes arrive exactly when a cardinal step would.
|
||||
var rig := _make_rig()
|
||||
var start := Vector3(1.25, 0.0, 2.25)
|
||||
rig.set_wire_target(start, "Southeast", "Walk", 1)
|
||||
rig.set_wire_target(start + Vector3(0.5, 0.0, 0.5), "Southeast", "Walk", 2)
|
||||
rig._process(0.1)
|
||||
assert_float(rig.current_speed).is_equal_approx(1.767767, 0.0001)
|
||||
assert_float(rig.velocity.x).is_equal_approx(1.25, 0.001)
|
||||
assert_float(rig.velocity.z).is_equal_approx(1.25, 0.001)
|
||||
|
||||
|
||||
func test_arrival_holds_moving_through_hysteresis_window() -> void:
|
||||
# Arrive after 0.4 s, then stay "moving" until 0.18 s at target (§4.2).
|
||||
var rig := _make_rig()
|
||||
var start := Vector3(1.25, 0.0, 2.25)
|
||||
rig.set_wire_target(start, "East", "Walk", 1)
|
||||
rig.set_wire_target(start + Vector3(0.5, 0.0, 0.0), "East", "Walk", 2)
|
||||
for i in 4: # 4 x 0.1 s = exactly one Walk window -> arrival
|
||||
rig._process(0.1)
|
||||
assert_float(rig.position.x).is_equal_approx(1.75, 0.001)
|
||||
assert_float(rig.current_speed).is_equal_approx(0.0, EPS)
|
||||
assert_bool(rig.is_moving).is_true() # at target 0.1 s < 0.18 s — held
|
||||
rig._process(0.1) # at target 0.2 s >= 0.18 s — idle
|
||||
assert_bool(rig.is_moving).is_false()
|
||||
|
||||
|
||||
func test_teleport_snaps_all_channels_and_emits() -> void:
|
||||
var rig := _make_rig()
|
||||
var emissions: Array = []
|
||||
rig.teleported.connect(func(p: Vector3) -> void: emissions.append(p))
|
||||
rig.set_wire_target(Vector3(1.25, 0.0, 1.25), "North", "Walk", 1)
|
||||
var far := Vector3(25.25, 0.0, 29.25) # Home-key Hub return — cross-map jump
|
||||
rig.set_wire_target(far, "South", "Walk", 2)
|
||||
assert_int(emissions.size()).is_equal(1)
|
||||
assert_float((emissions[0] as Vector3).x).is_equal_approx(far.x, EPS)
|
||||
assert_float(rig.position.distance_to(far)).is_equal_approx(0.0, EPS)
|
||||
assert_bool(rig.is_moving).is_false()
|
||||
assert_float(rig.current_speed).is_equal_approx(0.0, EPS)
|
||||
# Yaw hard-snapped to the post-teleport wire octant (South = 0), no glide.
|
||||
assert_float(rig.model_root.rotation.y).is_equal_approx(0.0, EPS)
|
||||
|
||||
|
||||
func test_step_window_provider_overrides_default() -> void:
|
||||
# The player adapter injects InputMapper.MOVE_INTERVAL_MS — the rig itself
|
||||
# never reads the autoload (§4.0).
|
||||
var rig := _make_rig()
|
||||
rig.step_window_ms_provider = func(for_stance: String) -> float:
|
||||
return 200.0 if for_stance == "Sprint" else 400.0
|
||||
var start := Vector3(1.25, 0.0, 1.25)
|
||||
rig.set_wire_target(start, "East", "Sprint", 1)
|
||||
rig.set_wire_target(start + Vector3(0.5, 0.0, 0.0), "East", "Sprint", 2)
|
||||
rig._process(0.1)
|
||||
assert_float(rig.current_speed).is_equal_approx(2.5, 0.001)
|
||||
|
||||
|
||||
func test_idle_facing_provider_steers_idle_yaw() -> void:
|
||||
# Idle aim: the provider's octant (client-local, already sent as SetFacing)
|
||||
# becomes the yaw target — mouse-responsive idle facing without wire lag.
|
||||
var rig := _make_rig()
|
||||
rig.idle_facing_provider = func() -> String: return "East"
|
||||
rig.set_wire_target(Vector3(1.25, 0.0, 2.25), "North", "Walk", 1)
|
||||
rig._process(DT)
|
||||
assert_bool(rig.is_moving).is_false()
|
||||
assert_float(rig.yaw_target).is_equal_approx(PI / 2.0, EPS)
|
||||
|
||||
|
||||
func test_suppression_freezes_idle_yaw_target() -> void:
|
||||
# Dialogue / free camera (§5 row 3): the yaw target holds even though the
|
||||
# idle provider says otherwise.
|
||||
var rig := _make_rig()
|
||||
rig.idle_facing_provider = func() -> String: return "East"
|
||||
rig.suppression_provider = func() -> bool: return true
|
||||
rig.set_wire_target(Vector3(1.25, 0.0, 2.25), "North", "Walk", 1)
|
||||
rig._process(DT)
|
||||
assert_float(rig.yaw_target).is_equal_approx(PI, EPS) # frozen at North
|
||||
# Node3D.rotation is float32: written +PI reads back a hair above double PI,
|
||||
# so wrap_yaw lands on the equivalent -PI. Assert angular identity, not sign.
|
||||
var arc := LocomotionRig.shortest_arc(PI, rig.model_root.rotation.y)
|
||||
assert_float(arc).is_equal_approx(0.0, EPS)
|
||||
@@ -0,0 +1,148 @@
|
||||
## SandboxSpace golden conversions (T-1088 design §2, §10.4): tile -> world metres,
|
||||
## wire-float floori recovery (live tile-centers vs TestHarness integers), and the
|
||||
## verified octant -> yaw table (yaw = PI/2 - theta; South 0, East +90, West -90,
|
||||
## North 180 degrees).
|
||||
##
|
||||
## The East/West cells are the REGRESSION GUARD against character_visual.gd's
|
||||
## mirrored 2D table (east=-90/west=+90, character_visual.gd:184-193) — copying that
|
||||
## table makes the model face west while walking east. Never relax these two tests.
|
||||
class_name TestSandboxSpace
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const EPS := 0.000001
|
||||
|
||||
|
||||
# -- tile_to_world ---------------------------------------------------------------
|
||||
|
||||
|
||||
func test_tile_to_world_origin_center() -> void:
|
||||
# Tile (0,0) center: subtile 0.5 m, center at +0.25 m on each axis.
|
||||
var w := SandboxSpace.tile_to_world(Vector3i(0, 0, 0))
|
||||
assert_float(w.x).is_equal_approx(0.25, EPS)
|
||||
assert_float(w.y).is_equal_approx(0.0, EPS)
|
||||
assert_float(w.z).is_equal_approx(0.25, EPS)
|
||||
|
||||
|
||||
func test_tile_to_world_hub_spawn() -> void:
|
||||
# Gauntlet Hub spawn (50, 58): x = (50 + 0.5) * 0.5 = 25.25, z = (58 + 0.5) * 0.5 = 29.25.
|
||||
var w := SandboxSpace.tile_to_world(Vector3i(50, 58, 0))
|
||||
assert_float(w.x).is_equal_approx(25.25, EPS)
|
||||
assert_float(w.y).is_equal_approx(0.0, EPS)
|
||||
assert_float(w.z).is_equal_approx(29.25, EPS)
|
||||
|
||||
|
||||
func test_tile_to_world_sim_south_is_local_plus_z() -> void:
|
||||
# Sim +y (South, Y-down) maps to local +Z: one southward tile step moves +0.5 m in Z only.
|
||||
var a := SandboxSpace.tile_to_world(Vector3i(10, 20, 0))
|
||||
var b := SandboxSpace.tile_to_world(Vector3i(10, 21, 0))
|
||||
assert_float(b.x - a.x).is_equal_approx(0.0, EPS)
|
||||
assert_float(b.z - a.z).is_equal_approx(0.5, EPS)
|
||||
|
||||
|
||||
func test_tile_to_world_sim_east_is_local_plus_x() -> void:
|
||||
# Sim +x (East) maps to local +X: one eastward tile step moves +0.5 m in X only.
|
||||
var a := SandboxSpace.tile_to_world(Vector3i(10, 20, 0))
|
||||
var b := SandboxSpace.tile_to_world(Vector3i(11, 20, 0))
|
||||
assert_float(b.x - a.x).is_equal_approx(0.5, EPS)
|
||||
assert_float(b.z - a.z).is_equal_approx(0.0, EPS)
|
||||
|
||||
|
||||
# -- wire-float recovery (floori, never round) -------------------------------------
|
||||
|
||||
|
||||
func test_wire_to_tile_live_tile_center_floats() -> void:
|
||||
# Live wire sends tile-center floats (N + 0.5) — floori recovers N.
|
||||
# round(50.5) would land on 51: the half-a-tile-off failure mode.
|
||||
assert_object(SandboxSpace.wire_to_tile(50.5, 58.5)).is_equal(Vector3i(50, 58, 0))
|
||||
|
||||
|
||||
func test_wire_to_tile_harness_integer_floats() -> void:
|
||||
# TestHarness sends integer floats — floori is a no-op, byte-identical both modes.
|
||||
assert_object(SandboxSpace.wire_to_tile(50.0, 58.0)).is_equal(Vector3i(50, 58, 0))
|
||||
|
||||
|
||||
func test_wire_to_world_recenters() -> void:
|
||||
# Wire float -> tile index -> recomputed center: 50.5 -> tile 50 -> 25.25 m.
|
||||
var w := SandboxSpace.wire_to_world(50.5, 58.5)
|
||||
assert_float(w.x).is_equal_approx(25.25, EPS)
|
||||
assert_float(w.z).is_equal_approx(29.25, EPS)
|
||||
|
||||
|
||||
func test_wire_to_world_identical_across_modes() -> void:
|
||||
# The same tile must resolve to the same world point from either wire spelling.
|
||||
var live := SandboxSpace.wire_to_world(50.5, 58.5)
|
||||
var harness := SandboxSpace.wire_to_world(50.0, 58.0)
|
||||
assert_float(live.x).is_equal_approx(harness.x, EPS)
|
||||
assert_float(live.z).is_equal_approx(harness.z, EPS)
|
||||
|
||||
|
||||
func test_world_to_tile_round_trip() -> void:
|
||||
# Gauntlet corners + spawn: world_to_tile inverts tile_to_world exactly.
|
||||
for tile: Vector3i in [
|
||||
Vector3i(0, 0, 0), Vector3i(50, 58, 0), Vector3i(116, 124, 0), Vector3i(3, 7, 0)
|
||||
]:
|
||||
assert_object(SandboxSpace.world_to_tile(SandboxSpace.tile_to_world(tile))).is_equal(tile)
|
||||
|
||||
|
||||
# -- octant -> yaw (all 8; E/W are the mirrored-table regression guard) --------------
|
||||
|
||||
|
||||
func test_octant_yaw_south_is_zero() -> void:
|
||||
assert_float(SandboxSpace.octant_to_yaw("South")).is_equal_approx(0.0, EPS)
|
||||
|
||||
|
||||
func test_octant_yaw_southeast_is_plus_45() -> void:
|
||||
assert_float(SandboxSpace.octant_to_yaw("Southeast")).is_equal_approx(PI / 4.0, EPS)
|
||||
|
||||
|
||||
func test_octant_yaw_east_is_plus_90() -> void:
|
||||
# REGRESSION GUARD: East = +PI/2 (model turns to +X). character_visual.gd's
|
||||
# mirrored table says east = -90 deg — copying it faces the model west while
|
||||
# walking east (the fatal flaw of both rejected design candidates).
|
||||
assert_float(SandboxSpace.octant_to_yaw("East")).is_equal_approx(PI / 2.0, EPS)
|
||||
|
||||
|
||||
func test_octant_yaw_northeast_is_plus_135() -> void:
|
||||
assert_float(SandboxSpace.octant_to_yaw("Northeast")).is_equal_approx(3.0 * PI / 4.0, EPS)
|
||||
|
||||
|
||||
func test_octant_yaw_north_is_180() -> void:
|
||||
assert_float(SandboxSpace.octant_to_yaw("North")).is_equal_approx(PI, EPS)
|
||||
|
||||
|
||||
func test_octant_yaw_northwest_is_minus_135() -> void:
|
||||
assert_float(SandboxSpace.octant_to_yaw("Northwest")).is_equal_approx(-3.0 * PI / 4.0, EPS)
|
||||
|
||||
|
||||
func test_octant_yaw_west_is_minus_90() -> void:
|
||||
# REGRESSION GUARD: West = -PI/2 (model turns to -X) — the mirror of the
|
||||
# character_visual.gd 2D table (west = +90 deg there). See East guard above.
|
||||
assert_float(SandboxSpace.octant_to_yaw("West")).is_equal_approx(-PI / 2.0, EPS)
|
||||
|
||||
|
||||
func test_octant_yaw_southwest_is_minus_45() -> void:
|
||||
assert_float(SandboxSpace.octant_to_yaw("Southwest")).is_equal_approx(-PI / 4.0, EPS)
|
||||
|
||||
|
||||
# -- derivation internals ------------------------------------------------------------
|
||||
|
||||
|
||||
func test_sim_angle_convention_matches_input_mapper() -> void:
|
||||
# theta: 0 = East, +PI/2 = South, -PI/2 = North (Y-down radians — server
|
||||
# vision_cone.rs and InputMapper.facing_angle share this convention).
|
||||
assert_float(SandboxSpace.octant_to_sim_angle("East")).is_equal_approx(0.0, EPS)
|
||||
assert_float(SandboxSpace.octant_to_sim_angle("South")).is_equal_approx(PI / 2.0, EPS)
|
||||
assert_float(SandboxSpace.octant_to_sim_angle("North")).is_equal_approx(-PI / 2.0, EPS)
|
||||
|
||||
|
||||
func test_sim_angle_to_yaw_wraps_into_signed_pi_range() -> void:
|
||||
# Northwest: theta = -3PI/4 -> PI/2 - theta = 5PI/4 -> wrapped to -3PI/4.
|
||||
var nw := SandboxSpace.sim_angle_to_yaw(-3.0 * PI / 4.0)
|
||||
assert_float(nw).is_equal_approx(-3.0 * PI / 4.0, EPS)
|
||||
# North stays +PI (range (-PI, PI]), matching the golden table's 180 deg.
|
||||
assert_float(SandboxSpace.sim_angle_to_yaw(-PI / 2.0)).is_equal_approx(PI, EPS)
|
||||
|
||||
|
||||
func test_unknown_octant_defaults_to_north() -> void:
|
||||
# Defensive default mirrors GameState.player_facing's "North" default.
|
||||
assert_float(SandboxSpace.octant_to_yaw("Sideways")).is_equal_approx(PI, EPS)
|
||||
@@ -1,5 +1,6 @@
|
||||
## Visual test capture engine — boots main.tscn, runs a named scenario or flow,
|
||||
## captures viewport PNGs for golden comparison or ad-hoc inspection.
|
||||
## Visual test capture engine — boots the entry's "scene" (default main.tscn),
|
||||
## runs a named scenario or flow, captures viewport PNGs for golden comparison
|
||||
## or ad-hoc inspection.
|
||||
##
|
||||
## Usage:
|
||||
## godot --rendering-driver opengl3 --fixed-fps 60 --resolution 960x540 \
|
||||
@@ -66,10 +67,22 @@ func _run(): # gdlint:disable=max-returns
|
||||
# Ensure output directory exists
|
||||
DirAccess.make_dir_recursive_absolute(_output_dir)
|
||||
|
||||
# Load main scene (autoloads already initialized from project.godot)
|
||||
var main_scene = load("res://scenes/main.tscn")
|
||||
# Per-entry overrides (T-1088, design §10.2 — both additive; existing entries
|
||||
# without these keys behave byte-identically):
|
||||
# "scene": alternate root scene (default res://scenes/main.tscn)
|
||||
# "env": env vars set before the scene boots (e.g. SR_AUTOPILOT) — OS-level,
|
||||
# so the scene's _ready() reads them exactly like shell-exported vars;
|
||||
# run-visual has no per-scenario env mechanism, this is it.
|
||||
var entry_cfg := _entry_config()
|
||||
var env_vars: Dictionary = entry_cfg.get("env", {})
|
||||
for env_key: String in env_vars:
|
||||
OS.set_environment(env_key, str(env_vars[env_key]))
|
||||
|
||||
# Load root scene (autoloads already initialized from project.godot)
|
||||
var scene_path: String = entry_cfg.get("scene", "res://scenes/main.tscn")
|
||||
var main_scene = load(scene_path)
|
||||
if main_scene == null:
|
||||
push_error("visual_capture: failed to load res://scenes/main.tscn")
|
||||
push_error("visual_capture: failed to load %s" % scene_path)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
@@ -298,6 +311,19 @@ func _capture(path: String) -> void:
|
||||
print("visual_capture: captured -> %s (%dx%d)" % [path, image.get_width(), image.get_height()])
|
||||
|
||||
|
||||
## Config entry for the active scenario or flow ({} when the name is unknown —
|
||||
## the mode runners report that error themselves).
|
||||
func _entry_config() -> Dictionary:
|
||||
var section: Dictionary = {}
|
||||
if not _scenario.is_empty():
|
||||
section = _config.get("scenarios", {})
|
||||
return section.get(_scenario, {})
|
||||
if not _flow.is_empty():
|
||||
section = _config.get("flows", {})
|
||||
return section.get(_flow, {})
|
||||
return {}
|
||||
|
||||
|
||||
func _load_config() -> Dictionary:
|
||||
# Config is at tests/visual.json relative to repo root.
|
||||
# Repo root = parent of Godot project root (client/).
|
||||
|
||||
Reference in New Issue
Block a user