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>
101 lines
4.2 KiB
GDScript
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))
|