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