Files
settled-reach/client/scripts/sandbox/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

101 lines
4.2 KiB
GDScript

class_name SandboxSpace
extends RefCounted
## THE conversion authority between sim tile space and sandbox world metres
## (T-1088 design §2). Every coordinate/angle conversion in the locomotion sandbox
## goes through this file — nothing else converts.
##
## THE CONVENTION (D-066/D-222):
## - One sim TilePosition step = one 0.5 m subtile. All sandbox positions/speeds
## are metres.
## - Sim +x (East) -> local +X; sim +y (South, Y-down) -> local +Z; sim z-level
## -> Y (Gauntlet is single-level z=0 -> Y=0; no metre scale exists for z-levels
## yet — no stair mechanic).
## - Tile index -> world: Vector3((t.x + 0.5) * 0.5, 0, (t.y + 0.5) * 0.5).
## - Sim facing angle theta (0=East, +PI/2=South, Y-down radians — server
## vision_cone.rs and InputMapper.facing_angle) -> ModelRoot yaw = PI/2 - theta.
## - D-243 "voxel = 1 m" is generation-cascade vocabulary and appears nowhere here;
## the 1 m visual tile (D-066) exists only as major grid lines in the floor shader.
##
## Wire trap: live mode sends tile-center floats (N + 0.5); TestHarness sends
## integers (N). Always floori() the wire float to recover the tile index, then
## recompute the center — byte-identical in both modes. NEVER round().
##
## Facing trap: CharacterVisual.set_facing() is never called from sandbox code.
## Its internal table (west=+90, east=-90 — character_visual.gd:184-193) assumes a
## mirrored axis mapping (East -> -X) incompatible with this convention; using it
## makes the model face west while walking east. The rig owns ModelRoot.rotation.y
## exclusively, always through octant_to_yaw() below.
## Sim facing octant -> sim angle theta (Y-down radians: 0 = East, +PI/2 = South).
const OCTANT_TO_SIM_ANGLE := {
"East": 0.0,
"Southeast": PI / 4.0,
"South": PI / 2.0,
"Southwest": 3.0 * PI / 4.0,
"West": PI,
"Northwest": -3.0 * PI / 4.0,
"North": -PI / 2.0,
"Northeast": -PI / 4.0,
}
## Tile index -> world metres at the subtile center (+0.25 m on each axis).
static func tile_to_world(tile: Vector3i) -> Vector3:
return Vector3(
(float(tile.x) + 0.5) * SandboxConstants.SUBTILE_M,
0.0, # Gauntlet is single-level z=0; z-level -> Y has no metre scale yet
(float(tile.y) + 0.5) * SandboxConstants.SUBTILE_M
)
## Recover the integer tile index from wire floats. Live wire sends tile centers
## (N + 0.5); TestHarness sends integers (N) — floori handles both identically.
## NEVER round(): round(N + 0.5) lands on the wrong tile.
static func wire_to_tile(wire_x: float, wire_y: float, z: int = 0) -> Vector3i:
return Vector3i(floori(wire_x), floori(wire_y), z)
## Wire floats -> world metres: floori to the tile index, then recompute the center.
static func wire_to_world(wire_x: float, wire_y: float, z: int = 0) -> Vector3:
return tile_to_world(wire_to_tile(wire_x, wire_y, z))
## World metres -> containing tile index (inverse of tile_to_world for any point
## inside the tile, not just the center).
static func world_to_tile(world: Vector3, z: int = 0) -> Vector3i:
return Vector3i(
floori(world.x / SandboxConstants.SUBTILE_M),
floori(world.z / SandboxConstants.SUBTILE_M),
z
)
## Octant string -> sim angle theta. Unknown octants warn and default to North
## (the GameState.player_facing default).
static func octant_to_sim_angle(octant: String) -> float:
if not OCTANT_TO_SIM_ANGLE.has(octant):
push_warning("SandboxSpace: unknown octant '%s' — defaulting to North" % octant)
return -PI / 2.0
return float(OCTANT_TO_SIM_ANGLE[octant])
## Sim angle theta -> ModelRoot yaw in WorldRoot-local space: yaw = PI/2 - theta,
## wrapped to (-PI, PI] so North stays +PI. Verified against Godot +yaw semantics
## (+90 deg yaw turns a +Z-facing model to +X): theta=PI/2 (South) -> 0 -> +Z;
## theta=0 (East) -> +PI/2 -> +X; theta=PI (West) -> -PI/2 -> -X;
## theta=-PI/2 (North) -> PI -> -Z.
static func sim_angle_to_yaw(theta: float) -> float:
var yaw := PI / 2.0 - theta
while yaw > PI:
yaw -= TAU
while yaw <= -PI:
yaw += TAU
return yaw
## Octant string -> ModelRoot yaw radians. Golden table (T-1088 design §2, verified):
## South 0, Southeast 45, East +90, Northeast 135, North 180, Northwest -135,
## West -90, Southwest -45 (degrees).
static func octant_to_yaw(octant: String) -> float:
return sim_angle_to_yaw(octant_to_sim_angle(octant))