Files
settled-reach/client/tests/unit/test_sandbox_space.gd
T
jpmschweitzerandClaude Fable 5 a7801a942a 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>
2026-07-06 13:23:16 +02:00

149 lines
6.2 KiB
GDScript

## 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)