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:
2026-07-06 13:23:16 +02:00
co-authored by Claude Fable 5
parent ce1811b5f9
commit a7801a942a
21 changed files with 3555 additions and 8 deletions
+81
View File
@@ -0,0 +1,81 @@
[gd_scene load_steps=7 format=3 uid="uid://c61sb2euagj4o"]
[ext_resource type="Script" path="res://scripts/sandbox/locomotion_sandbox.gd" id="1_sandbox"]
[ext_resource type="Script" path="res://scripts/sandbox/sandbox_debug_hud.gd" id="2_debughud"]
[ext_resource type="Script" path="res://scripts/sandbox/greybox_world.gd" id="3_greybox"]
[ext_resource type="Script" path="res://scripts/sandbox/locomotion_rig.gd" id="4_rig"]
[ext_resource type="Script" path="res://scripts/sandbox/follow_camera_3d.gd" id="5_followcam"]
; T-1088: 3D locomotion sandbox — live-server-driven CharacterVisual walking the
; Gauntlet greybox. Scene tree per the T-1088 design §1.2. Launch:
; T1: cd server && cargo run --bin settled-reach-server -- --test-mode
; T2: SR_LIVE=1 ~/bin/godot4 --path client res://scenes/locomotion_sandbox.tscn
; Lights + environment follow the character_creation.tscn:65-92 recipe (the toon +
; inverted-hull outline shaders need lights and a WorldEnvironment in-scene).
[sub_resource type="Environment" id="Environment_1"]
background_mode = 1
background_color = Color(0.03, 0.03, 0.06, 1)
ambient_light_source = 2
ambient_light_color = Color(0.4, 0.45, 0.55, 1)
ambient_light_energy = 0.5
[node name="LocomotionSandbox" type="Node3D"]
script = ExtResource("1_sandbox")
; D-148: THE single static map rotation — 45° yaw (0.785398 rad), set once here.
; Sim coords stay axis-aligned inside WorldRoot; the camera never rotates.
[node name="WorldRoot" type="Node3D" parent="."]
rotation = Vector3(0, 0.785398, 0)
; greybox_world.gd — accumulating tile store + MultiMesh painter (design §3, §8).
; Builds its FloorMM/WallMM MultiMeshInstance3D children in _ready().
[node name="Greybox" type="Node3D" parent="WorldRoot"]
script = ExtResource("3_greybox")
; locomotion_rig.gd — local position = interpolated sim position (metres).
; Hidden until the first-snapshot latch (locomotion_sandbox.gd); the root installs
; the provider Callables and feeds set_wire_target() per consumed snapshot.
[node name="PlayerRig" type="Node3D" parent="WorldRoot"]
visible = false
script = ExtResource("4_rig")
; ModelRoot.rotation.y = smoothed facing yaw (D-151), in WorldRoot-LOCAL space so
; octant→yaw composes with the map rotation exactly once. CharacterVisual is
; runtime-instantiated under it; its own rotation.y stays 0.
[node name="ModelRoot" type="Node3D" parent="WorldRoot/PlayerRig"]
; follow_camera_3d.gd — D-148 pivot-orbit rig, D-015 locked follow. The Camera3D
; placeholder child is adopted in _ready() and every property is rewritten from
; SandboxConstants, so the values below are editor preview only and cannot drift.
[node name="CameraRig" type="Node3D" parent="." node_paths=PackedStringArray("follow_target")]
script = ExtResource("5_followcam")
follow_target = NodePath("../WorldRoot/PlayerRig")
; Initial pose: gameplay default preset (-30° pitch from horizontal, CAM_DIST 30,
; ortho size 9 — SandboxConstants). Yaw 0, always: the diamond view is WorldRoot's 45°.
[node name="Camera3D" type="Camera3D" parent="CameraRig"]
position = Vector3(0, 15, 25.9808)
rotation = Vector3(-0.523599, 0, 0)
projection = 1
current = true
size = 9.0
near = 0.1
far = 100.0
; Key light: upper-left-front (character_creation.tscn recipe)
[node name="KeyLight" type="DirectionalLight3D" parent="."]
transform = Transform3D(0.866, -0.354, 0.354, 0, 0.707, 0.707, -0.5, -0.612, 0.612, 2, 3, 2)
light_energy = 1.2
shadow_enabled = true
; Fill light: right side, softer, no shadows
[node name="FillLight" type="DirectionalLight3D" parent="."]
transform = Transform3D(0.866, -0.259, -0.428, 0, 0.856, -0.517, 0.5, 0.448, 0.741, -2, 2, -1)
light_energy = 0.4
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
environment = SubResource("Environment_1")
[node name="DebugHud" type="CanvasLayer" parent="."]
script = ExtResource("2_debughud")
+17
View File
@@ -59,6 +59,13 @@ var facing_octant: String = "North" # Derived from facing_angle
var _last_sent_octant: String = "North" # Track to avoid redundant sends
var _last_move_msec: int = 0
# T-1088 (design §9): 3D facing seam. A 3D scene installs a provider returning
# sim-space radians (0=East, +PI/2=South — same convention as facing_angle), or
# NAN = no update (deadzone/degenerate ray). While installed it fully replaces the
# 2D canvas-transform path in _update_facing_from_mouse(); unset (default) leaves
# the 2D path unchanged. Untyped Callable per the autoload parse-order rule.
var facing_angle_provider = Callable()
# Hold-to-move: poll held direction keys each frame, throttled by stance.
# D-054: WASD is now mouse-relative. W = toward cursor, A/D = strafe.
@@ -180,6 +187,16 @@ func reset_facing_state() -> void:
# Intentional coupling: reads GameState.player_position directly — InputMapper is an
# autoload that runs before game loop rendering, so position is always current-tick.
func _update_facing_from_mouse() -> void:
# T-1088 (design §9): provider seam. In a 3D scene the canvas-transform anchor
# below is a far-off-screen point, and the screen-space mouse angle is not a
# sim-space angle under the 45° map rotation + ortho pitch — an installed
# provider (e.g. SandboxMouseAimProvider) replaces this whole path.
if facing_angle_provider.is_valid():
var a: float = facing_angle_provider.call()
if is_finite(a):
facing_angle = a
facing_octant = _angle_to_octant(a)
return
var vp := get_viewport()
if vp == null:
return
+18 -3
View File
@@ -24,6 +24,7 @@ extends Node3D
## get_accessory_node_count() — number of accessory BoneAttachment3D nodes
## get_skin_tone_texture_name(index) — skin tone texture filename key for index
## get_overhead_anchor() — Marker3D above Head bone for floating UI (#712)
## get_animation_player() — AnimationPlayer for external anim drivers (T-1088)
##
## D-159 (11 body types), D-160 (18 segments), D-161 (head separate),
## D-162 (clothing pre-fitted), D-163 (heads via BoneAttachment3D), D-164 (skeleton fork)
@@ -771,7 +772,10 @@ func _load_animations() -> void:
## Play a named animation. Searches all libraries for a matching name.
func play_animation(anim_name: String) -> void:
## blend_time >= 0.0 is passed to AnimationPlayer.play() as custom_blend (crossfade
## seconds); the -1.0 default preserves the original hard-cut behavior for existing
## callers (T-1088 design §6.3 — the locomotion gait machine is the first blend user).
func play_animation(anim_name: String, blend_time: float = -1.0) -> void:
if _anim_player == null:
return
# Search across all libraries for the animation
@@ -779,12 +783,15 @@ func play_animation(anim_name: String) -> void:
var lib: AnimationLibrary = _anim_player.get_animation_library(lib_name)
if lib.has_animation(anim_name):
var full_name: String = lib_name + "/" + anim_name if lib_name != "" else anim_name
_anim_player.play(full_name)
if blend_time >= 0.0:
_anim_player.play(full_name, blend_time)
else:
_anim_player.play(full_name)
return
# Try common idle variants
for variant in ["Idle", "idle_01", "Idle_01", "breathing_idle", "Breathing_Idle"]:
if anim_name == "idle" and variant != anim_name:
play_animation(variant)
play_animation(variant, blend_time)
return
push_warning("CharacterVisual: animation '%s' not found in any library" % anim_name)
@@ -795,6 +802,14 @@ func stop_animation() -> void:
_anim_player.stop()
## Return the internal AnimationPlayer ("AnimPlayer") — speed_scale and clip-phase
## access for external animation drivers (T-1088 gait machine; Q-063 footstep seam).
## Null until load_descriptor() has run; every load_descriptor() destroys and
## recreates the player, so callers must re-fetch — never cache across reloads.
func get_animation_player() -> AnimationPlayer:
return _anim_player
# =============================================================================
# Static helpers
# =============================================================================
+154
View File
@@ -0,0 +1,154 @@
class_name FollowCamera3D
extends Node3D
## T-1088 follow camera (design §7) — the D-148 orthographic rig with D-015 locked
## follow. Proto-production: values/technique migrate at the T-962 gate.
##
## Node contract: attach to a Node3D rig OUTSIDE WorldRoot (the .tscn CameraRig).
## The rig's global position is the smoothed pivot; a Camera3D child (adopted if one
## named "Camera3D" exists, created in code otherwise — every property is rewritten
## from constants in _ready, so .tscn values cannot drift) orbits the pivot via the
## spike math: camera.position = pivot + basis * Vector3(0, 0, CAM_DIST), where
## basis is a pure pitch rotation. Yaw is locked to 0 ALWAYS — this file has no
## rotation input path by design: stepped camera yaw is OUT until a D/Q record
## exists (design-input §3); the diamond view is WorldRoot's 45°, never the camera.
##
## The pivot follow is exponential — deliberately the ONLY soft layer in the stack
## (design §0). Against the rig's constant-velocity legs it converges to a constant
## trailing offset velocity/CAM_FOLLOW_RATE (Walk ~0.21 m, Sprint ~0.42 m): reads
## as speed, zero jitter. The character itself is never eased (locomotion_rig.gd).
##
## Dev affordances (sanctioned, D-148/D-158): T cycles pitch presets
## [-30 gameplay default, -5 frontal, -80 overhead] (the spike's -45 iso preset is
## struck by D-148); scroll wheel zooms ortho size 6-14. No "camera_tilt" action
## exists in project.godot, so T is a direct physical-key check in
## _unhandled_input — UI-safe: events consumed by Control nodes never reach it.
##
## Teleport snaps: connect the locomotion rig's `teleported` signal to
## snap_to_target() (use .unbind(n) if the signal carries arguments); the sandbox
## root also calls snap_to_target() from its first-snapshot latch.
const CAM_NEAR := 0.1
const CAM_FAR := 100.0
# Scroll zoom — sandbox-only dev affordance (design §7: ungoverned and kept that
# way), so its knobs live here rather than in SandboxConstants.
const ZOOM_MIN := 6.0
const ZOOM_MAX := 14.0
const ZOOM_STEP := 1.0
const ZOOM_RATE := 8.0
## The node the pivot chases — the sandbox wires WorldRoot/PlayerRig here, so the
## followed point is the rig's interpolated position with the 45° map rotation
## already applied (design §1.2).
@export var follow_target: Node3D = null
var _camera: Camera3D = null
var _pivot: Vector3 = Vector3.ZERO
var _preset_idx: int = 0 # index into SandboxConstants.CAM_PITCH_PRESETS; 0 = -30 default
var _pitch_deg: float = 0.0
var _target_ortho_size: float = 0.0
# Target velocity estimate, only consumed by the LOOKAHEAD_S knob (0.0 for now —
# design §7 exposes it for the tuning pass).
var _last_target_pos: Vector3 = Vector3.ZERO
var _target_velocity: Vector3 = Vector3.ZERO
var _has_target_history: bool = false
func _ready() -> void:
_camera = get_node_or_null("Camera3D") as Camera3D
if _camera == null:
_camera = Camera3D.new()
_camera.name = "Camera3D"
add_child(_camera)
_camera.projection = Camera3D.PROJECTION_ORTHOGONAL
_camera.size = SandboxConstants.CAM_ORTHO_SIZE
_camera.near = CAM_NEAR
_camera.far = CAM_FAR
_camera.current = true
_pitch_deg = float(SandboxConstants.CAM_PITCH_PRESETS[_preset_idx])
_target_ortho_size = SandboxConstants.CAM_ORTHO_SIZE
_pivot = follow_target.global_position if follow_target != null else global_position
global_position = _pivot
_apply_orbit()
func _process(delta: float) -> void:
if follow_target != null:
var target_pos := follow_target.global_position
if _has_target_history and delta > 0.0:
var frame_delta := target_pos - _last_target_pos
if frame_delta.length() > SandboxConstants.SNAP_DIST_M:
# Teleport-sized jump: don't launch the lookahead; the rig's
# `teleported` signal hard-snaps the pivot (design §7).
_target_velocity = Vector3.ZERO
else:
_target_velocity = frame_delta / delta
_last_target_pos = target_pos
_has_target_history = true
var look_point := target_pos + _target_velocity * SandboxConstants.LOOKAHEAD_S
# Exponential pivot follow — frame-rate independent (1 - exp(-rate * dt)).
_pivot = _pivot.lerp(look_point, 1.0 - exp(-SandboxConstants.CAM_FOLLOW_RATE * delta))
global_position = _pivot
var target_pitch := float(SandboxConstants.CAM_PITCH_PRESETS[_preset_idx])
_pitch_deg = lerpf(_pitch_deg, target_pitch, 1.0 - exp(-SandboxConstants.CAM_TILT_RATE * delta))
_camera.size = lerpf(_camera.size, _target_ortho_size, 1.0 - exp(-ZOOM_RATE * delta))
_apply_orbit()
# Dev affordances only — there is deliberately NO yaw/rotation input path here.
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventKey:
var key := event as InputEventKey
# Direct physical-key check: no "camera_tilt" action exists in project.godot.
# Physical T (84) is unbound in the input map, so no action conflicts.
if key.pressed and not key.echo and key.physical_keycode == KEY_T:
_preset_idx = (_preset_idx + 1) % SandboxConstants.CAM_PITCH_PRESETS.size()
get_viewport().set_input_as_handled()
elif event is InputEventMouseButton:
var mb := event as InputEventMouseButton
if mb.pressed and mb.button_index == MOUSE_BUTTON_WHEEL_UP:
_nudge_zoom(-ZOOM_STEP)
get_viewport().set_input_as_handled()
elif mb.pressed and mb.button_index == MOUSE_BUTTON_WHEEL_DOWN:
_nudge_zoom(ZOOM_STEP)
get_viewport().set_input_as_handled()
## Hard pivot snap onto follow_target — no glide (design §7). Called by the sandbox
## root's first-snapshot latch and by the rig's `teleported` signal. Pitch and zoom
## are deliberately untouched: a cross-map jump must not change the dev view.
func snap_to_target() -> void:
if follow_target == null:
return
_pivot = follow_target.global_position
global_position = _pivot
_last_target_pos = _pivot
_target_velocity = Vector3.ZERO
_has_target_history = true
## The managed Camera3D child — for the DebugHud / visual-capture harness.
func get_camera() -> Camera3D:
return _camera
## Spike pivot-orbit math (design §7): the camera's offset from the pivot for a
## given pitch, camera.position = pivot + basis * Vector3(0, 0, dist). Static and
## pure for golden tests. At -30°/30 m this is (0, 15, 25.98) — the boom rises as
## the pitch steepens while the look-at point stays exactly on the pivot.
static func orbit_offset(pitch_deg: float, dist: float) -> Vector3:
return Basis(Vector3.RIGHT, deg_to_rad(pitch_deg)) * Vector3(0.0, 0.0, dist)
# Position the camera on the orbit and pitch it at the pivot. Yaw stays 0: the
# rig node is never rotated and the camera's rotation is pitch-only by
# construction.
func _apply_orbit() -> void:
var pitch_rad := deg_to_rad(_pitch_deg)
_camera.position = orbit_offset(_pitch_deg, SandboxConstants.CAM_DIST)
_camera.rotation = Vector3(pitch_rad, 0.0, 0.0)
func _nudge_zoom(step: float) -> void:
_target_ortho_size = clampf(_target_ortho_size + step, ZOOM_MIN, ZOOM_MAX)
+324
View File
@@ -0,0 +1,324 @@
class_name GreyboxWorld
extends Node3D
## T-1088 greybox world — accumulating tile store + MultiMesh painter (design §3).
##
## Two halves in one file:
## - Store (inner RefCounted, headless-testable — test_greybox_store.gd):
## accumulates tile knowledge from successive GameState.visible_tiles frames.
## Never evicts; last observation wins — the embryo of the Phase-5
## "destroyed-while-unobserved renders remembered" fog-memory record. Survives
## the Home-key teleport (a feature). Gauntlet worst case 117x125 = 14,625
## tiles — trivial against the 16,384 preallocation.
## - Painter (this Node3D): builds the FloorMM/WallMM MultiMeshInstance3D
## children in _ready() and applies Store diffs ONLY — new tiles get one
## transform + one color write, visibility flips get one set_instance_color()
## each. No full-buffer rebuilds (contrast the 2D TileRenderer
## clear-and-reset, deliberately not copied).
##
## Mesh strategy (§3.3, Q-079 engaged): two MultiMeshes because the greybox
## uniquely needs per-instance state (four-state tint) and per-material shader
## uniforms (cutaway) — GridMap has neither. All real 3D geometry per D-244.
##
## Perception stays server-enforced upstream (D-010): the store only ever sees
## tiles the server disclosed; never-seen tiles render as nothing — the void.
##
## Promotion status (design §1.1): store contract proto-production; meshes
## disposable.
## Preallocated instances per MultiMesh (design §3.1). Resizing a MultiMesh
## resets its buffers, so allocate once; unused instances stay zero-scaled.
const INSTANCE_CAPACITY := 16384
## Zero-scaled transform for unused/freed instances — renders nothing.
const ZERO_XFORM := Transform3D(Vector3.ZERO, Vector3.ZERO, Vector3.ZERO, Vector3.ZERO)
const FLOOR_SHADER := preload("res://shaders/sandbox/greybox_tile.gdshader")
const WALL_SHADER := preload("res://shaders/sandbox/greybox_wall_cutaway.gdshader")
## Accumulating last-observation-wins tile knowledge (design §3.1). Pure data —
## no scene or autoload access — so gdUnit covers it headless.
class Store:
extends RefCounted
## Wire tile_kind is walkability-derived (server query.rs:78-82); doors are
## entities on the wire — Door/Object collapse to FLOOR.
enum Kind { FLOOR, WALL }
## Render states (§3.2). REMEMBERED = in store but not in the current LOS
## set. Never-seen tiles have no state — nothing rendered, the void.
enum Vis { FORWARD, PERIPHERAL, BOUNDARY_WALL, REMEMBERED }
## Vector3i(x, y, z) sim tile coords -> Kind. Accumulates forever.
var _store: Dictionary = {}
## Vector3i -> Vis: current render state of every stored tile.
var _state: Dictionary = {}
## Vector3i -> Vis: the current LOS set, rebuilt each consumed snapshot.
var _visible_now: Dictionary = {}
## Consume one snapshot's GameState.visible_tiles (already merged from both
## wire spellings by snapshot_handler.gd:52-64 — live dicts carry
## {x, y, z, visibility, type}; TestHarness dicts omit "visibility").
## Returns the paint diff — three Array[Vector3i] under fixed keys:
## "added": first-ever observations (slot + transform + color)
## "rekinded": kind changed under last-observation-wins (slot moves MM)
## "recolored": render state flipped only (one set_instance_color)
func ingest(tiles: Array) -> Dictionary:
var added: Array[Vector3i] = []
var rekinded: Array[Vector3i] = []
var recolored: Array[Vector3i] = []
var new_visible: Dictionary = {}
for tile: Variant in tiles:
if not tile is Dictionary or not tile.has("x") or not tile.has("y"):
continue
var key := Vector3i(int(tile["x"]), int(tile["y"]), int(tile.get("z", 0)))
var kind := _parse_kind(tile)
var vis := _parse_vis(tile)
new_visible[key] = vis
if not _store.has(key):
_store[key] = kind
_state[key] = vis
added.append(key)
elif _store[key] != kind:
# Last observation wins — e.g. a wall destroyed while observed.
_store[key] = kind
_state[key] = vis
rekinded.append(key)
elif _state[key] != vis:
_state[key] = vis
recolored.append(key)
# Tiles that just left LOS dim to REMEMBERED. Tiles remembered on an
# earlier snapshot already carry the state — the flip emits exactly once.
for key: Vector3i in _visible_now:
if new_visible.has(key):
continue
if _state[key] != Vis.REMEMBERED:
_state[key] = Vis.REMEMBERED
recolored.append(key)
_visible_now = new_visible
return {"added": added, "rekinded": rekinded, "recolored": recolored}
## Total accumulated tiles (never shrinks — eviction: never, §3.1).
func size() -> int:
return _store.size()
## Tiles in the current LOS set (DebugHud readout).
func visible_count() -> int:
return _visible_now.size()
func has_tile(key: Vector3i) -> bool:
return _store.has(key)
## Kind for a stored tile; -1 for never-seen.
func kind_of(key: Vector3i) -> int:
return _store.get(key, -1)
## Current render state (Vis) for a stored tile; -1 for never-seen — the
## void (perception is upstream, server-enforced).
func state_of(key: Vector3i) -> int:
return _state.get(key, -1)
## Persistence seam (design §0 scope fence — in-memory today): a copy of the
## accumulated knowledge, Vector3i -> Kind. Transient visibility state is
## not knowledge and is not exported.
func to_dict() -> Dictionary:
return _store.duplicate()
static func _parse_kind(tile: Dictionary) -> Kind:
return Kind.WALL if str(tile.get("type", "floor")) == "wall" else Kind.FLOOR
static func _parse_vis(tile: Dictionary) -> Vis:
match str(tile.get("visibility", "Forward")):
"Peripheral":
return Vis.PERIPHERAL
"BoundaryWall":
return Vis.BOUNDARY_WALL
_:
# Forward, plus TestHarness "tiles" dicts (no visibility key —
# design-input §1.5) and unknown future variants.
return Vis.FORWARD
## The accumulating knowledge store — read by the DebugHud (store.size(),
## store.visible_count()) and driven by on_snapshot().
var store := Store.new()
var _floor_mm: MultiMeshInstance3D = null
var _wall_mm: MultiMeshInstance3D = null
var _wall_material: ShaderMaterial = null
## Vector3i -> Vector2i(kind, slot index in that kind's MultiMesh).
var _slots: Dictionary = {}
## Next never-used slot per Kind (high watermark).
var _next_slot: Array[int] = [0, 0]
## Freed slots per Kind (holes left by rekind moves), reused first.
var _free_slots: Array = [[], []]
## Per-Vis instance colors, resolved from SandboxConstants.TINTS in _ready().
var _state_colors: Array[Color] = []
var _last_tick: int = -1
var _capacity_warned := false
func _ready() -> void:
_state_colors = [
_tint_to_color(SandboxConstants.TINTS["forward"]),
_tint_to_color(SandboxConstants.TINTS["peripheral"]),
_tint_to_color(SandboxConstants.TINTS["boundary"]),
_tint_to_color(SandboxConstants.TINTS["remembered"]),
]
# FloorMM: PlaneMesh faces +Y natively — no orientation fix needed (§3.2).
var floor_mesh := PlaneMesh.new()
floor_mesh.size = Vector2(SandboxConstants.SUBTILE_M, SandboxConstants.SUBTILE_M)
var floor_material := ShaderMaterial.new()
floor_material.shader = FLOOR_SHADER
# Grid lines are sampled in sim space: undo this node's global transform
# (i.e. the WorldRoot 45 deg) so lines land on tile edges, not world axes.
# Deferred: global_transform is not final until the tree is ready.
_set_sim_from_world.call_deferred(floor_material)
_floor_mm = _make_layer("FloorMM", floor_mesh, floor_material)
# WallMM: BoxMesh is centered on its origin — the per-instance transform
# lifts it +WALL_H/2 so the base sits at y=0 (§3.2, explicit).
var wall_mesh := BoxMesh.new()
wall_mesh.size = Vector3(
SandboxConstants.SUBTILE_M, SandboxConstants.WALL_H, SandboxConstants.SUBTILE_M
)
_wall_material = ShaderMaterial.new()
_wall_material.shader = WALL_SHADER
# Cutaway geometry knobs come from SandboxConstants (single source, §12);
# the shader defaults mirror them only for standalone/editor use.
_wall_material.set_shader_parameter("u_wall_h", SandboxConstants.WALL_H)
_wall_material.set_shader_parameter("u_cut_height", SandboxConstants.CUT_HEIGHT)
_wall_material.set_shader_parameter("u_cut_radius", SandboxConstants.CUT_RADIUS)
_wall_material.set_shader_parameter("u_cut_band", SandboxConstants.CUT_BAND)
_wall_mm = _make_layer("WallMM", wall_mesh, _wall_material)
## Push the world->sim transform into the floor grid shader (deferred from
## _ready so global_transform reflects the WorldRoot rotation).
func _set_sim_from_world(mat: ShaderMaterial) -> void:
mat.set_shader_parameter("u_sim_from_world", global_transform.affine_inverse())
## Snapshot consumer — called directly by the sandbox root (no
## SnapshotEventRouter, design §13.7). Tick-gated on GameState.current_tick
## change — pattern: world_renderer.gd:33-36. Paused ticks repeat the tick and
## are skipped here for free.
func on_snapshot() -> void:
if GameState.current_tick == _last_tick:
return
_last_tick = GameState.current_tick
_apply_diff(store.ingest(GameState.visible_tiles))
## DebugHud duck contract (sandbox_debug_hud.gd): total accumulated tiles.
func get_store_size() -> int:
return store.size()
## Per-frame cutaway anchor (design §8): pass the rig's INTERPOLATED
## world-space position (PlayerRig.global_position — it already carries the
## 45 deg WorldRoot rotation, matching the shader's world-space test) so the cut
## zone glides with the character and spatial smoothstep becomes temporal
## smoothness. One uniform write per frame — the entire CPU cost.
func set_cutaway_char_pos(world_pos: Vector3) -> void:
_wall_material.set_shader_parameter("u_char_pos_xz", Vector2(world_pos.x, world_pos.z))
func _make_layer(layer_name: String, mesh: Mesh, material: ShaderMaterial) -> MultiMeshInstance3D:
var mm := MultiMesh.new()
# Format flags must be set before instance_count (buffer layout is fixed at
# allocation). use_colors routes set_instance_color() to the shaders' COLOR
# built-in, which multiplies albedo (§3.2).
mm.transform_format = MultiMesh.TRANSFORM_3D
mm.use_colors = true
mm.mesh = mesh
mm.instance_count = INSTANCE_CAPACITY
for i in INSTANCE_CAPACITY:
mm.set_instance_transform(i, ZERO_XFORM)
var instance := MultiMeshInstance3D.new()
instance.name = layer_name
instance.multimesh = mm
instance.material_override = material
add_child(instance)
return instance
func _apply_diff(diff: Dictionary) -> void:
for key: Vector3i in diff["added"]:
_paint_new(key)
for key: Vector3i in diff["rekinded"]:
# Kind flipped — the instance moves between MultiMeshes: zero-scale the
# old slot, allocate in the other mesh.
_release_slot(key)
_paint_new(key)
for key: Vector3i in diff["recolored"]:
_repaint_color(key)
func _paint_new(key: Vector3i) -> void:
var kind := store.kind_of(key)
var slot := _alloc_slot(kind)
if slot < 0:
return # capacity exhausted — the store still knows the tile (warned once)
var mm := _mm_for(kind).multimesh
mm.set_instance_transform(slot, _tile_xform(key, kind))
mm.set_instance_color(slot, _state_colors[store.state_of(key)])
_slots[key] = Vector2i(kind, slot)
func _repaint_color(key: Vector3i) -> void:
var slot: Vector2i = _slots.get(key, Vector2i(-1, -1))
if slot.y < 0:
return # never painted (capacity overflow)
_mm_for(slot.x).multimesh.set_instance_color(slot.y, _state_colors[store.state_of(key)])
func _release_slot(key: Vector3i) -> void:
var slot: Vector2i = _slots.get(key, Vector2i(-1, -1))
if slot.y < 0:
return
_mm_for(slot.x).multimesh.set_instance_transform(slot.y, ZERO_XFORM)
(_free_slots[slot.x] as Array).append(slot.y)
_slots.erase(key)
func _alloc_slot(kind: int) -> int:
var free: Array = _free_slots[kind]
if not free.is_empty():
return free.pop_back()
if _next_slot[kind] >= INSTANCE_CAPACITY:
if not _capacity_warned:
_capacity_warned = true
push_error(
(
"GreyboxWorld: MultiMesh capacity %d exhausted — further tiles are "
% INSTANCE_CAPACITY
)
+ "stored but not rendered (Gauntlet worst case is 14,625; investigate)"
)
return -1
var slot := _next_slot[kind]
_next_slot[kind] += 1
return slot
func _mm_for(kind: int) -> MultiMeshInstance3D:
return _wall_mm if kind == Store.Kind.WALL else _floor_mm
## Floors sit on the ground plane at the subtile center (SandboxSpace, §2);
## walls lift +WALL_H/2 so the centered BoxMesh base sits at y=0.
func _tile_xform(key: Vector3i, kind: int) -> Transform3D:
var origin := SandboxSpace.tile_to_world(key)
if kind == Store.Kind.WALL:
origin.y += SandboxConstants.WALL_H * 0.5
return Transform3D(Basis.IDENTITY, origin)
## SandboxConstants.TINTS values are Color (used directly) or float (a greyscale
## albedo value — 85% / 70%), per §3.2.
func _tint_to_color(tint: Variant) -> Color:
if tint is Color:
return tint
var value := float(tint)
return Color(value, value, value)
+182
View File
@@ -0,0 +1,182 @@
class_name LocomotionAnim
extends RefCounted
## T-1088 gait state machine (design §6) — proto-production.
## Slaves animation to the rig's motion channel: clip selection keys off render
## velocity (never input), transitions are edge-triggered crossfades, and playback
## rate is cadence-synced so foot fall matches actual ground speed (§6.2).
##
## Wiring (sandbox root, design §14 group C):
## var anim := LocomotionAnim.new()
## anim.setup(player_rig, character_visual) # after both exist in-tree
## anim.update(delta) # each frame, after the rig moved
## Teleports: setup() auto-connects the rig's `teleported` signal to notify_teleport()
## when the signal exists; otherwise call notify_teleport() alongside the rig snap.
##
## The rig ref is duck-typed against the §4.0 contract getters — `stance: String`,
## `is_moving: bool`, `current_speed: float` (m/s, constant per leg) — so the machine
## needs no rig class and a test stub drives it headless. The visual ref is duck-typed
## against the §6.3 additive CharacterVisual API: play_animation(name, blend_time)
## and get_animation_player().
##
## Clip strings live ONLY in SandboxConstants.GAIT_CLIP (§6.1) — never here.
## Q-063 seam (footstep audio): emitted once per edge-triggered gait change with the
## clip now playing (the clip identifies stance + motion; Idle clips = no footsteps).
## Clip phase for footstep timing is available via CharacterVisual.get_animation_player().
signal gait_changed(clip: StringName)
## §6.3 pre-flagged caveat (the SKIP_PHASE_SEEK flag): in Godot 4.6, seek() during a
## custom_blend crossfade may cancel the blend (unverified against the live scene).
## If gait<->gait transitions visibly pop instead of crossfading, set this true to
## drop the phase-preserving seek — the 0.15 s blend masks the leg-beat resync
## acceptably (tune-by-eye; record the outcome on T-1088 per design §11.9).
var skip_phase_seek: bool = false
var _rig = null # duck-typed rig (locomotion_rig.gd): stance / is_moving / current_speed
var _visual = null # duck-typed CharacterVisual: play_animation() / get_animation_player()
var _current_clip: StringName = &"" # empty = nothing played yet (first play hard-cuts)
var _current_moving: bool = false
## Pure gait table lookup (§6.1). Unknown stances warn and fall back to the Walk row
## (mirrors GameState.player_stance's wire default).
static func gait(stance: String, is_moving: bool) -> StringName:
if not SandboxConstants.GAIT_CLIP.has(stance):
push_warning("LocomotionAnim: unknown stance '%s' — defaulting to Walk" % stance)
stance = "Walk"
var row: Dictionary = SandboxConstants.GAIT_CLIP[stance]
return StringName(row["moving"]) if is_moving else StringName(row["idle"])
## Pure blend-table selection (§6.3). Any transition into or out of a Crouch_* clip
## takes the crouch blend; otherwise the idle/gait edge decides.
static func blend_for(
from_clip: StringName, to_clip: StringName, from_moving: bool, to_moving: bool
) -> float:
var blends: Dictionary = SandboxConstants.BLEND
if String(from_clip).begins_with("Crouch") or String(to_clip).begins_with("Crouch"):
return float(blends["crouch"])
if not from_moving and to_moving:
return float(blends["idle_to_gait"])
if from_moving and not to_moving:
return float(blends["gait_to_idle"])
return float(blends["gait_to_gait"])
## The clip currently driven (empty StringName until the first play) — surfaced by
## the sandbox root for the DebugHud "clip" readout.
func get_current_clip() -> StringName:
return _current_clip
## Store the rig + visual refs. Auto-connects the rig's `teleported(pos_m)` signal
## (if it exposes one) to the hard reset.
func setup(rig, visual) -> void:
_rig = rig
_visual = visual
if rig != null and rig.has_signal("teleported"):
rig.teleported.connect(_on_rig_teleported)
# Signal adapter — locomotion_rig.gd emits teleported(pos_m: Vector3); the anim
# reset doesn't need the position, only the rig's post-snap state.
func _on_rig_teleported(_pos_m: Vector3) -> void:
notify_teleport()
## Per-frame drive — call after the rig's own interpolation for the frame.
func update(_delta: float) -> void:
if _rig == null or _visual == null:
return
_apply_state(_rig.stance, _rig.is_moving, _rig.current_speed)
## Teleport hard reset (§6.3): a cross-map jump must not smear — replay the correct
## clip for the rig's post-snap state with the 0.0 teleport blend, rewound to phase 0.
func notify_teleport() -> void:
if _rig == null or _visual == null:
return
var moving: bool = _rig.is_moving
var clip := gait(_rig.stance, moving)
var changed := clip != _current_clip
_visual.play_animation(clip, float(SandboxConstants.BLEND["teleport"]))
var player = _visual.get_animation_player()
if player != null:
# play() on the already-current clip does not rewind — force the hard reset.
player.seek(0.0, false)
_current_clip = clip
_current_moving = moving
_update_speed_scale(clip, moving, _rig.current_speed)
if changed:
gait_changed.emit(clip)
# State application, separated from the rig read so tests drive it directly.
func _apply_state(stance: String, moving: bool, speed: float) -> void:
var clip := gait(stance, moving)
if clip != _current_clip:
_transition_to(clip, moving)
_update_speed_scale(clip, moving, speed)
# Edge-triggered transition (§6.3): fires only on clip change, so loops never
# restart mid-cycle (a stance toggle between Walk/Careful/Sprint idle keeps the
# shared Idle clip untouched).
func _transition_to(clip: StringName, moving: bool) -> void:
var first_play := _current_clip == &""
var blend := -1.0 # first-ever play: hard cut (the character just appeared)
if not first_play:
blend = blend_for(_current_clip, clip, _current_moving, moving)
# gait<->gait phase preservation (§6.3): capture the leg beat before switching,
# then re-seek into the new clip so feet keep their rhythm across the blend.
var phase := -1.0
if not first_play and _current_moving and moving and not skip_phase_seek:
phase = _capture_phase()
_visual.play_animation(clip, blend)
if phase >= 0.0:
_seek_phase(clip, phase)
_current_clip = clip
_current_moving = moving
gait_changed.emit(clip)
# Cadence sync (§6.2): while moving, speed_scale = clamp(speed / NATIVE_MPS[clip],
# 0.6, 1.8). Rig speed is constant per leg (dist/interval, §4.1), so the scale is
# constant per leg — no within-step wobble; catch-up bursts push it up so feet chase
# instead of skating. Idle clips always run at 1.0.
func _update_speed_scale(clip: StringName, moving: bool, speed: float) -> void:
var player = _visual.get_animation_player()
if player == null:
return
if not moving:
player.speed_scale = 1.0
return
var native: float = SandboxConstants.NATIVE_MPS.get(String(clip), 0.0)
if native <= 0.0:
player.speed_scale = 1.0 # moving clip missing from NATIVE_MPS — table drift
return
var limits: Vector2 = SandboxConstants.SPEED_SCALE_CLAMP
player.speed_scale = clampf(speed / native, limits.x, limits.y)
# Normalized phase [0,1) of the currently playing clip, or -1.0 when unavailable.
func _capture_phase() -> float:
var player = _visual.get_animation_player()
if player == null or String(player.current_animation).is_empty():
return -1.0
var length: float = player.current_animation_length
if length <= 0.0:
return -1.0
return fposmod(player.current_animation_position, length) / length
# Seek the new clip to the preserved phase without flushing the pose (update=false)
# so the crossfade started by play() keeps blending. See skip_phase_seek caveat.
func _seek_phase(clip: StringName, phase: float) -> void:
var player = _visual.get_animation_player()
if player == null:
return
var anim: Animation = player.get_animation(clip)
if anim == null:
return
player.seek(phase * anim.length, false)
+315
View File
@@ -0,0 +1,315 @@
class_name LocomotionRig
extends Node3D
## T-1088 locomotion rig (design §4, §5) — the position and yaw channels of the
## feel thesis (§0): each channel gets exactly ONE smoothing layer, crisp -> soft.
## Thin Node3D wrapper (this node = PlayerRig; local position = interpolated sim
## position in WorldRoot-local metres) over a pure static math core, so every
## rule is headless-testable (test_locomotion_math.gd, §10.4). Proto-production:
## an NPC adapter later feeds set_wire_target() from VisibleEntity rows with no
## rig changes.
##
## Position (§4.1): per-leg constant velocity sized to the step — on a changed
## target, cover the distance in exactly one stance interval (dist/interval),
## clamped to [base, CATCHUP_MAX_FACTOR * base]. No easing on the character
## itself: easing a repeating 0.4 s step reads as scooting; the camera (S5) is
## deliberately the only soft position layer.
##
## Yaw (§5, "server feet, client eyes"): moving (incl. the idle-hysteresis
## window) follows the wire octant — the server sets Facing from the move delta,
## so it IS the motion direction; idle follows idle_facing_provider (the same
## snapped octant that rides the SetFacing wire); frozen while suppressed
## (dialogue / free camera). Shortest-arc lerp_angle ease (TURN_SHARPNESS) under
## per-stance deg/s budgets (TURN_BUDGET_DEG), written to ModelRoot.rotation.y in
## WorldRoot-LOCAL space so octant->yaw composes with the D-148 map rotation
## exactly once.
##
## TRAP (design §2, §5): NEVER call CharacterVisual.set_facing() from this rig
## or anything near it. Its internal table (west=+90, east=-90 —
## character_visual.gd:184-193) assumes a mirrored axis mapping (East -> -X)
## incompatible with THE convention (SandboxSpace: East -> +X); using it makes
## the model face west while walking east. This rig owns ModelRoot.rotation.y
## exclusively, always through SandboxSpace.octant_to_yaw(); CharacterVisual's
## own rotation.y stays 0.
##
## The rig never reads GameState, InputMapper, or any autoload itself (§4.0) —
## the sandbox root adapts snapshot fields into set_wire_target() and installs
## the provider Callables below. NPC adapters leave the providers unset.
## Emitted when a wire target lands beyond SNAP_DIST_M (Home-key / cross-map
## teleport, §4.1): all rig channels have already snapped; the camera (S5)
## hard-sets its pivot on this, the anim machine (S6) hard-cuts (BLEND.teleport
## = 0.0). NOT emitted for the first-ever snapshot (the root's latch owns that).
signal teleported(pos_m: Vector3)
## set_wire_target() outcome — a pure decision, exposed for tests (§10.4).
enum TargetAction { SNAP_FIRST, IGNORE, TELEPORT, STEP }
## Fallback step window when step_window_ms_provider is unset (headless tests,
## future NPC adapters before MovementSpeed wiring). Mirrors the Walk default
## stance only — the player adapter MUST install a provider reading
## InputMapper.MOVE_INTERVAL_MS at runtime: that table is the single source and
## is deliberately never copied (design §13.8).
const DEFAULT_STEP_WINDOW_MS := 400.0
## (stance: String) -> float|int step window in ms for one confirmed step.
## Player adapter: func(stance): return InputMapper.MOVE_INTERVAL_MS.get(stance, 400).
## Called once per accepted step (per-leg recompute, §4.1) with the stance that
## arrived in the same wire target.
var step_window_ms_provider: Callable = Callable()
## () -> octant String for idle facing. The player adapter returns
## InputMapper.facing_octant — the SAME snapped octant that rides the SetFacing
## wire, so the model never shows an octant the server wasn't told (§5). Leave
## unset (NPCs) to collapse the facing split to pure wire facing — single code
## path by construction.
var idle_facing_provider: Callable = Callable()
## () -> bool, true while input is suppressed. The player adapter returns
## GameState.dialogue_active or GameState.free_camera_mode — the exact condition
## under which InputMapper computes but does not send octants (input_mapper.gd:71).
## While idle + suppressed the yaw target freezes (last target held, §5). Leave
## unset for never-suppressed (NPCs).
var suppression_provider: Callable = Callable()
## Latest wire facing octant (snapshot player_facing) — the moving yaw source.
## Updates on EVERY wire target, including ignored ones: a blocked move (Q-020)
## still changes Facing server-side, so bump-to-turn falls out for free (§4.3).
var wire_octant: String = "North"
## Latest wire stance — selects the step window and the moving turn budget.
var stance: String = "Walk"
## Tick of the latest consumed wire target (DebugHud "snapshot age" input).
var last_wire_tick: int = -1
## True until at-target for IDLE_ENTER_DELAY_S (§4.2 hysteresis — kills the
## walk<->idle flicker from snapshot jitter). Gait selection keys off this and
## current_speed, never off input (§0).
var is_moving: bool = false
## Constant per-leg ground speed (m/s), zero on arrival — cadence-sync input
## (§6.2: speed_scale = current_speed / NATIVE_MPS, constant per leg).
var current_speed: float = 0.0
## Render velocity (m/s), zero on arrival — gait-selection input (§0).
var velocity: Vector3 = Vector3.ZERO
## Current yaw target (rad, WorldRoot-local) — held while frozen. DebugHud pairs
## it with model_root.rotation.y as "yaw target / actual".
var yaw_target: float = 0.0
## Yaw channel output node — resolved from the ModelRoot scene child in _ready();
## injectable beforehand for headless tests.
var model_root: Node3D = null
var _has_target: bool = false
var _target_pos: Vector3 = Vector3.ZERO
var _leg_speed: float = 0.0
var _idle_timer_s: float = 0.0
func _ready() -> void:
if model_root == null:
model_root = get_node_or_null("ModelRoot") as Node3D
## The single rig input (§4.0): the sandbox root (or a future NPC adapter) calls
## this once per consumed snapshot with the wire position converted to
## WorldRoot-local metres (SandboxSpace.wire_to_world), the wire facing octant,
## stance, and tick.
func set_wire_target(pos_m: Vector3, facing_octant: String, new_stance: String, tick: int) -> void:
wire_octant = facing_octant
stance = new_stance
last_wire_tick = tick
match classify_target(_has_target, position, _target_pos, pos_m):
TargetAction.SNAP_FIRST:
# First-ever snapshot snaps, no signal (2D precedent
# entity_renderer.gd:133-140; the root's first-snapshot latch
# handles the camera).
_snap_all_channels(pos_m)
TargetAction.IGNORE:
# Paused-tick identical frames and blocked moves (Q-020) land here:
# target-chasing is idempotent, nothing re-triggers. Facing already
# updated above — bump-to-turn is the visible blocked-move behavior.
pass
TargetAction.TELEPORT:
_snap_all_channels(pos_m)
teleported.emit(pos_m)
TargetAction.STEP:
_target_pos = pos_m
_leg_speed = derive_leg_speed(position.distance_to(pos_m), _step_interval_s())
func _process(delta: float) -> void:
if not _has_target:
return
# Position channel: constant-velocity chase, exact arrival (§4.1).
position = step_position(position, _target_pos, _leg_speed, delta)
var at_target := position.is_equal_approx(_target_pos)
if at_target:
position = _target_pos # kill sub-epsilon residue
velocity = Vector3.ZERO
current_speed = 0.0
else:
velocity = (_target_pos - position).normalized() * _leg_speed
current_speed = _leg_speed
_idle_timer_s = advance_idle_timer(_idle_timer_s, at_target, delta)
is_moving = is_moving_state(at_target, _idle_timer_s)
# Yaw channel (§5).
_update_facing(delta)
## Remaining distance (m) on the current leg — DebugHud "leg distance" readout.
func leg_remaining_m() -> float:
return position.distance_to(_target_pos)
## DebugHud duck contract (sandbox_debug_hud.gd): render speed in m/s — constant
## per leg, zero on arrival.
func get_render_speed() -> float:
return current_speed
## DebugHud duck contract (sandbox_debug_hud.gd): current yaw target (rad,
## WorldRoot-local), paired on screen with ModelRoot.rotation.y as "target/actual".
func get_yaw_target_rad() -> float:
return yaw_target
## Snap every rig channel to the target (first snapshot / teleport, §4.1):
## position, velocity, yaw target AND actual (no ease across a jump), and the
## idle hysteresis (pre-expired so the rig is instantly idle).
func _snap_all_channels(pos_m: Vector3) -> void:
_has_target = true
position = pos_m
_target_pos = pos_m
_leg_speed = 0.0
velocity = Vector3.ZERO
current_speed = 0.0
_idle_timer_s = SandboxConstants.IDLE_ENTER_DELAY_S
is_moving = false
yaw_target = SandboxSpace.octant_to_yaw(wire_octant)
if model_root != null:
model_root.rotation.y = yaw_target
func _step_interval_s() -> float:
var window_ms := DEFAULT_STEP_WINDOW_MS
if step_window_ms_provider.is_valid():
window_ms = float(step_window_ms_provider.call(stance))
return window_ms / 1000.0
func _update_facing(delta: float) -> void:
var suppressed := false
if suppression_provider.is_valid():
suppressed = bool(suppression_provider.call())
var idle_octant := ""
if idle_facing_provider.is_valid():
idle_octant = str(idle_facing_provider.call())
yaw_target = select_yaw_target(is_moving, wire_octant, idle_octant, suppressed, yaw_target)
if model_root == null:
return
var budget_key := stance if is_moving else "Idle"
model_root.rotation.y = step_yaw(model_root.rotation.y, yaw_target, budget_key, delta)
# ---------------------------------------------------------------------------
# Pure static core — all locomotion math lives below, stateless and
# headless-testable (§10.4). The wrapper above only shuttles state.
# ---------------------------------------------------------------------------
## Classify an incoming wire target (§4.1): the first-ever snapshot snaps; an
## unchanged target is ignored — paused-tick frames (§4.3) and blocked moves
## (Q-020) re-trigger nothing by construction, and a mid-leg repeat keeps the
## current leg speed (no tail-slowdown re-derivation); beyond SNAP_DIST_M
## (5 subtiles — matches the 2D TELEPORT_DISTANCE_THRESHOLD) is a teleport;
## otherwise a normal step.
static func classify_target(
has_target: bool, render_pos: Vector3, current_target: Vector3, new_target: Vector3
) -> TargetAction:
if not has_target:
return TargetAction.SNAP_FIRST
if new_target.is_equal_approx(current_target):
return TargetAction.IGNORE
if render_pos.distance_to(new_target) > SandboxConstants.SNAP_DIST_M:
return TargetAction.TELEPORT
return TargetAction.STEP
## Per-leg constant velocity (§4.1): cover dist in exactly one stance interval,
## clamped to [base, CATCHUP_MAX_FACTOR * base] where base is the cardinal
## one-subtile speed (SUBTILE_M / interval). One rule handles everything:
## cardinal Walk 0.5/0.4 = 1.25 m/s; diagonal 0.707/0.4 = 1.77 m/s — arrives
## exactly on time (no sqrt(2) on the wire, D-053; a fixed speed would lag on
## held diagonals); multi-tile latest-wins deltas close within ~one interval
## with feet speeding up to match (§6.2).
static func derive_leg_speed(dist_m: float, interval_s: float) -> float:
var safe_interval := maxf(interval_s, 0.001)
var base := SandboxConstants.SUBTILE_M / safe_interval
return clampf(dist_m / safe_interval, base, SandboxConstants.CATCHUP_MAX_FACTOR * base)
## One frame of constant-velocity chase — move_toward clamps at the target, so
## arrival is exact and velocity is a clean square wave, never a sawtooth (§4.1).
static func step_position(
render_pos: Vector3, target: Vector3, leg_speed: float, delta: float
) -> Vector3:
return render_pos.move_toward(target, leg_speed * delta)
## Idle-hysteresis timer (§4.2): accumulates while at-target, resets the moment
## a new leg starts.
static func advance_idle_timer(timer_s: float, at_target: bool, delta: float) -> float:
return timer_s + delta if at_target else 0.0
## is_moving with hysteresis (§4.2): snapshot arrival jitters +-1-2 ticks against
## the client throttle, so the rig frequently arrives a few frames early —
## staying "moving" until at-target for IDLE_ENTER_DELAY_S kills the walk<->idle
## flicker; a real stop still reaches Idle inside the human ~0.3 s settle window.
static func is_moving_state(at_target: bool, timer_s: float) -> bool:
if not at_target:
return true
return timer_s < SandboxConstants.IDLE_ENTER_DELAY_S
## Yaw target source per the §5 authority table ("server feet, client eyes"):
## moving -> wire octant (server Facing IS the motion direction); idle +
## suppressed -> held target frozen; idle with a provider -> the client-local
## aim octant; idle without one (NPCs) -> pure wire facing. Empty idle_octant
## means "no provider installed".
static func select_yaw_target(
moving: bool, wire_oct: String, idle_octant: String, suppressed: bool, held_target: float
) -> float:
if moving:
return SandboxSpace.octant_to_yaw(wire_oct)
if suppressed:
return held_target
if idle_octant.is_empty():
return SandboxSpace.octant_to_yaw(wire_oct)
return SandboxSpace.octant_to_yaw(idle_octant)
## Signed shortest arc from one yaw to another, in [-PI, PI).
static func shortest_arc(from_yaw: float, to_yaw: float) -> float:
return wrapf(to_yaw - from_yaw, -PI, PI)
## Wrap a yaw into (-PI, PI] — same convention as SandboxSpace (North stays +PI).
static func wrap_yaw(yaw: float) -> float:
var wrapped := yaw
while wrapped > PI:
wrapped -= TAU
while wrapped <= -PI:
wrapped += TAU
return wrapped
## One frame of yaw smoothing (§5): shortest-arc lerp_angle ease-out at
## TURN_SHARPNESS, with the per-frame change clamped to the per-stance
## TURN_BUDGET_DEG (budget_key = stance while moving, "Idle" otherwise; unknown
## keys fall back to Idle). A 180 deg reversal at Walk completes in ~0.25 s
## (about half a step); a 45 deg corner resolves in ~60 ms.
static func step_yaw(current_yaw: float, target_yaw: float, budget_key: String, delta: float) -> float:
var eased := lerp_angle(current_yaw, target_yaw, 1.0 - exp(-SandboxConstants.TURN_SHARPNESS * delta))
var budget_deg: float = SandboxConstants.TURN_BUDGET_DEG.get(
budget_key, SandboxConstants.TURN_BUDGET_DEG["Idle"]
)
var max_step := deg_to_rad(budget_deg) * delta
var step := clampf(shortest_arc(current_yaw, eased), -max_step, max_step)
return wrap_yaw(current_yaw + step)
@@ -0,0 +1,415 @@
extends Node3D
## T-1088 3D locomotion sandbox root — boot, connect, poll/pump, local descriptor,
## first-snapshot latch, component wiring (design §1.3). Sandbox-only file.
##
## The session-driver boilerplate below is a deliberate ~40-line copy from main.gd:
## D-166 freezes main.gd until Phase 5, so each copied block carries a "Pattern:"
## comment naming its source for the T-962 shared-driver extraction.
##
## Launch recipe (design §10.1):
## T1: cd server && cargo run --bin settled-reach-server -- --test-mode
## T2: SR_LIVE=1 ~/bin/godot4 --path client res://scenes/locomotion_sandbox.tscn
const MANIFEST_PATH := "res://assets/characters/manifest.json"
## Autopilot (design §10.2): sim-space heading per movement token (0 = East,
## +PI/2 = South — the InputMapper.facing_angle convention, Y-down radians).
const AUTOPILOT_HEADINGS := {
"east": 0.0,
"south": PI / 2.0,
"west": PI,
"north": -PI / 2.0,
}
## Hold time for pulsed discrete actions (stance_up/stance_down) — long enough
## for the buffered InputEventAction press to reach InputMapper._unhandled_input.
const AUTOPILOT_PULSE_S := 0.1
@onready var world_root: Node3D = $WorldRoot
@onready var greybox: GreyboxWorld = $WorldRoot/Greybox
@onready var player_rig: LocomotionRig = $WorldRoot/PlayerRig
@onready var model_root: Node3D = $WorldRoot/PlayerRig/ModelRoot
@onready var camera_rig: FollowCamera3D = $CameraRig
var character_visual: CharacterVisual = null
## Gait state machine (design §6) — RefCounted, owned and driven by this root.
var _anim: LocomotionAnim = null
## Mouse-aim facing provider (design §9) — the installed Callable keeps it alive;
## referenced here too so the seam's owner is greppable. Cleared in _exit_tree().
var _aim_provider: SandboxMouseAimProvider = null
var _first_snapshot_seen: bool = false
var _gameplay_paused: bool = false # D-170: implant fullscreen occludes gameplay
var _autopilot_spec: String = "" # design §10.2: raw SR_AUTOPILOT string (kept for debugging)
var _autopilot_steps: Array[Dictionary] = [] # parsed segments, consumed FIFO by _autopilot_tick
var _autopilot_active: bool = false # a segment is running (its timer is live)
var _autopilot_timer: float = 0.0 # seconds left in the active segment (fixed-fps deltas)
var _autopilot_action: String = "" # Input action held/pulsed by the active segment
var _autopilot_kind: String = "" # "move" | "pulse" | "wait"
var _autopilot_heading: float = NAN # scripted sim-space facing angle (NAN until first move)
func _ready() -> void:
# (1) SR_PORT honor — pattern: visual_capture.gd:79-88. A plain boot dials the
# default 9876; the live harness starts the server with --port 0 and passes the
# parsed port through SR_PORT.
var port_env := OS.get_environment("SR_PORT")
if not port_env.is_empty():
SimBridge.server_port = int(port_env)
# Guarded connect — pattern: main.gd:61-62. Handshake (D-192) + StartupMessage
# are automatic in SimBridge's state machine.
if SimBridge.state == SimBridge.ConnectionState.DISCONNECTED:
SimBridge.connect_to_sim()
# (2)+(3) Local descriptor (GameState.character_visual_descriptor is never
# populated — the sandbox builds its own from manifest.json), then CharacterVisual
# IN-TREE BEFORE load_descriptor: its _ready() loads the toon/outline shaders;
# out-of-tree the materials get null shaders. Pattern: character_creation.gd:386-388.
character_visual = CharacterVisual.new()
character_visual.name = "CharacterVisual"
model_root.add_child(character_visual)
character_visual.load_descriptor(_build_descriptor())
# TRAP (design §2): never call character_visual.set_facing() — its octant table
# assumes the 2D renderer's mirrored axis mapping (East -> -X). ModelRoot.rotation.y,
# always via SandboxSpace.octant_to_yaw(), is the only facing authority here;
# CharacterVisual's own rotation.y stays 0. The rig enforces this (locomotion_rig.gd).
# PlayerRig stays hidden (.tscn visible=false) until the first-snapshot latch.
# (3b) Rig provider adapters (design §4.0, §5): the rig reads zero autoloads —
# these Callables are its only view of InputMapper/GameState. NPC adapters later
# leave all three unset.
player_rig.step_window_ms_provider = _step_window_ms
player_rig.idle_facing_provider = _idle_facing_octant
player_rig.suppression_provider = _input_suppressed
# Teleport fan-out (design §4.1): camera hard-snaps its pivot; the anim machine
# connects itself in setup() below (0.0-blend hard cut).
player_rig.teleported.connect(_on_rig_teleported)
# (3c) Gait machine (design §6): slaved to the rig's motion channel, driving
# CharacterVisual via the additive play_animation(name, blend) API.
_anim = LocomotionAnim.new()
_anim.setup(player_rig, character_visual)
# (4) InputMapper facing provider (design §9).
_install_facing_provider()
# (5) D-170: the occlusion *mechanism* is CanvasItem-only, the signal is not —
# a 3D scene connects it directly to a pause flag.
HudGroups.gameplay_occluded.connect(_on_gameplay_occluded)
# (6) SR_AUTOPILOT (design §10.2) — deterministic capture input. Parsed here,
# ticked per-frame after the first snapshot. While a script is loaded, a scripted
# heading replaces the mouse-aim provider ON THE SAME SEAM (the mouse position is
# nondeterministic under xvfb), so the genuine InputMapper octant-snap / SetFacing /
# mouse-relative-WASD / throttle path still runs end-to-end.
_autopilot_spec = OS.get_environment("SR_AUTOPILOT")
_autopilot_steps = _parse_autopilot(_autopilot_spec)
if not _autopilot_steps.is_empty():
InputMapper.facing_angle_provider = Callable(self, "_autopilot_facing_angle")
func _exit_tree() -> void:
# Clear the §9 seam so the 2D canvas-transform path resumes for any scene loaded
# after this one; the provider's own guards make a stale install safe (NAN).
InputMapper.facing_angle_provider = Callable()
# Release any autopilot-held action so pressed state never leaks past the scene.
if _autopilot_active:
_autopilot_end_segment()
func _process(delta: float) -> void:
# Snapshot poll — pattern: main.gd:251-268; extract to a shared session driver in
# Phase 5 (T-962). The first snapshot arrives here, never in _ready. Dispatch runs
# BEFORE the latch so the rig's SNAP_FIRST has placed the player when the camera snaps.
var snapshot: Variant = SimBridge.poll_snapshot()
if snapshot != null:
GameState.apply_snapshot(snapshot)
_dispatch_snapshot()
if not _first_snapshot_seen:
_first_snapshot_latch()
# Input pump — pattern: main.gd:295-341. The only pump in the codebase lives in
# main.gd; without this the sandbox connects but never moves (design §1.3).
for entry in InputMapper.flush_queue():
var action: int = entry.get("action", -1)
if (
action == InputMapper.Action.BUG_REPORT
or action == InputMapper.Action.OPEN_JOURNAL
or action == InputMapper.Action.OPEN_MENU
):
continue # client-only actions — no wire mapping (sim_bridge.gd:536-539)
SimBridge.send_input(entry)
if _gameplay_paused:
return # D-170: skip per-frame visual work while an implant app occludes
_per_frame_update(delta)
_autopilot_tick(delta)
# First-snapshot latch (design §1.3 step 3): the rig's SNAP_FIRST already placed
# position + yaw (dispatch precedes the latch, and the rig snaps on its first
# set_wire_target — entity_renderer.gd:133-140 first-appearance precedent); the
# latch owns visibility and the camera snap.
func _first_snapshot_latch() -> void:
_first_snapshot_seen = true
player_rig.visible = true
_snap_camera_to_player()
# Snapshot fan-out — direct consumer calls, no SnapshotEventRouter (design §13.7:
# the router's registration lives in main.gd; two consumers don't justify touching it).
func _dispatch_snapshot() -> void:
greybox.on_snapshot()
var pos := GameState.player_position
player_rig.set_wire_target(
SandboxSpace.wire_to_world(pos.x, pos.y),
GameState.player_facing,
GameState.player_stance,
GameState.current_tick
)
# Local CharacterVisualDescriptor from the first valid manifest.json id per category
# — no hardcoded id strings (design §1.3 step 2). Body type and skin tone keep
# descriptor defaults; facial hair stays empty (empty string = none is a valid value,
# not a manifest id).
func _build_descriptor() -> CharacterVisualDescriptor:
var manifest := _load_manifest()
var descriptor := CharacterVisualDescriptor.new()
var heads: Array = manifest.get("heads", [])
if not heads.is_empty():
descriptor.head_id = str(heads[0])
var hair: Array = manifest.get("hair", [])
if not hair.is_empty():
descriptor.hair_id = str(hair[0])
# Clothing manifest maps item_id -> {slot}; wear the first item declared per slot.
var clothing: Variant = manifest.get("clothing", {})
if clothing is Dictionary:
for item_id: String in clothing:
var slot := str((clothing[item_id] as Dictionary).get("slot", ""))
if not slot.is_empty() and not descriptor.clothing_slots.has(slot):
descriptor.clothing_slots[slot] = item_id
return descriptor
# Pattern: character_creation.gd:324-338 (manifest load).
func _load_manifest() -> Dictionary:
var file := FileAccess.open(MANIFEST_PATH, FileAccess.READ)
if file == null:
push_warning("locomotion_sandbox: manifest not found at %s" % MANIFEST_PATH)
return {}
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
if parsed is Dictionary:
return parsed as Dictionary
push_warning("locomotion_sandbox: manifest parse failed — using empty defaults")
return {}
func _on_gameplay_occluded(occluded: bool) -> void:
_gameplay_paused = occluded
## True while an implant app occludes gameplay (D-170) — components consult this
## instead of connecting to HudGroups themselves.
func is_gameplay_paused() -> bool:
return _gameplay_paused
# ---------------------------------------------------------------------------
# Rig provider adapters (design §4.0, §5) — the player-side Callables installed
# in _ready(). Each is the exact adapter shape the rig's doc comments name.
# ---------------------------------------------------------------------------
# Step window for one confirmed step, ms — reads InputMapper.MOVE_INTERVAL_MS at
# runtime (single source, never copied — design §13.8). 400 = the Walk default,
# matching the rig's own fallback.
func _step_window_ms(for_stance: String) -> int:
return int(InputMapper.MOVE_INTERVAL_MS.get(for_stance, 400))
# Idle facing (§5): the SAME snapped octant that rides the SetFacing wire, so the
# model never shows an octant the server wasn't told.
func _idle_facing_octant() -> String:
return InputMapper.facing_octant
# Suppression (§5): the exact condition under which InputMapper computes but does
# not send octants (input_mapper.gd:78) — idle yaw freezes on it.
func _input_suppressed() -> bool:
return GameState.dialogue_active or GameState.free_camera_mode
# Teleport (design §4.1/§7): hard camera-pivot snap, no glide. The anim machine's
# 0.0-blend reset rides its own connection (LocomotionAnim.setup).
func _on_rig_teleported(_pos_m: Vector3) -> void:
camera_rig.snap_to_target()
# ---------------------------------------------------------------------------
# Wiring (design §14 groups B/C/D) — one function per attach point.
# ---------------------------------------------------------------------------
## Input seam (design §9): install InputMapper.facing_angle_provider — unprojects
## the mouse onto the y=0 plane, WorldRoot.to_local() (undoing the 45 degree map
## rotation), delta from the rig's local position, atan2(delta.z, delta.x) sim
## radians; NAN inside MOUSE_AIM_DEADZONE_M. Without it, 2D-canvas mouse math
## makes facing garbage in a 3D scene and WASD unsteerable. CameraRig is a child,
## so its _ready() (which adopts the Camera3D) has already run.
func _install_facing_provider() -> void:
_aim_provider = SandboxMouseAimProvider.new(camera_rig.get_camera(), world_root, player_rig)
InputMapper.facing_angle_provider = Callable(_aim_provider, "get_facing_angle")
## Per-frame cross-component work owned by the root, gated on the first snapshot
## and the D-170 pause flag: the cutaway anchor (design §8 — the rig's INTERPOLATED
## world XZ, so the cut zone glides and spatial smoothstep becomes temporal
## smoothness) and the gait machine (after the rig's own _process interpolation).
func _per_frame_update(delta: float) -> void:
if not _first_snapshot_seen:
return
greybox.set_cutaway_char_pos(player_rig.global_position)
_anim.update(delta)
## Hard camera-pivot snap (design §7) — first-snapshot latch + teleport fan-out.
func _snap_camera_to_player() -> void:
camera_rig.snap_to_target()
# ---------------------------------------------------------------------------
# Autopilot (S8, design §10.2) — deterministic capture input. SR_AUTOPILOT
# (e.g. "east:2.0,south:1.5,stance_up,east:1.0") drives timed presses of the
# REAL input actions — no SimBridge bypass, no test-only paths in the rig.
# Movement tokens aim the token's heading through the facing seam and hold
# "move_north" (W = toward aim under D-054 mouse-relative WASD); stance tokens
# pulse an InputEventAction through the normal event pipeline.
# ---------------------------------------------------------------------------
# Segment machine, ticked from _process. Gated on the first snapshot so
# capture-time connection jitter never eats the schedule. Durations count
# process deltas — deterministic frame counts under --fixed-fps.
func _autopilot_tick(delta: float) -> void:
if not _first_snapshot_seen:
return
if _autopilot_active:
_autopilot_timer -= delta
if _autopilot_timer > 0.0:
return
_autopilot_end_segment()
if not _autopilot_steps.is_empty():
_autopilot_begin_segment(_autopilot_steps.pop_front())
# Parse "east:2.0,south:1.5,stance_up,east:1.0" into segment dicts. Movement
# tokens (north/east/south/west:SECONDS) walk that sim direction; stance_up/
# stance_down pulse once (optional :SECONDS hold); "wait:SECONDS" idles.
# Unknown tokens warn and are skipped.
func _parse_autopilot(spec: String) -> Array[Dictionary]:
var steps: Array[Dictionary] = []
for raw_token in spec.split(",", false):
var parts := raw_token.strip_edges().split(":")
var token := parts[0].strip_edges()
var duration := parts[1].to_float() if parts.size() > 1 else 0.0
if AUTOPILOT_HEADINGS.has(token):
(
steps
. append(
{
"kind": "move",
"action": "move_north", # W = forward = toward the scripted aim (D-054)
"duration": maxf(duration, 0.0),
"heading": AUTOPILOT_HEADINGS[token],
}
)
)
elif token == "stance_up" or token == "stance_down":
(
steps
. append(
{
"kind": "pulse",
"action": token,
"duration": maxf(duration, AUTOPILOT_PULSE_S),
"heading": NAN,
}
)
)
elif token == "wait":
steps.append(
{"kind": "wait", "action": "", "duration": maxf(duration, 0.0), "heading": NAN}
)
elif not token.is_empty():
push_warning("locomotion_sandbox: unknown SR_AUTOPILOT token '%s'" % token)
return steps
func _autopilot_begin_segment(step: Dictionary) -> void:
_autopilot_active = true
_autopilot_timer = step["duration"]
_autopilot_action = step["action"]
_autopilot_kind = step["kind"]
var heading: float = step["heading"]
if is_finite(heading):
_autopilot_heading = heading
match _autopilot_kind:
"move":
# Held action state — InputMapper polls Input.is_action_pressed each frame.
Input.action_press(_autopilot_action)
"pulse":
_autopilot_send_event(_autopilot_action, true)
func _autopilot_end_segment() -> void:
match _autopilot_kind:
"move":
Input.action_release(_autopilot_action)
"pulse":
_autopilot_send_event(_autopilot_action, false)
_autopilot_active = false
_autopilot_action = ""
_autopilot_kind = ""
# InputEventAction through the buffered input pipeline — reaches InputMapper's
# _unhandled_input exactly like a keypress (Input.action_press only feeds the
# polled is_action_pressed path, which discrete actions never read).
func _autopilot_send_event(action_name: String, pressed: bool) -> void:
var event := InputEventAction.new()
event.action = action_name
event.pressed = pressed
Input.parse_input_event(event)
# Scripted facing (installed on InputMapper.facing_angle_provider while an
# autopilot script is loaded): the heading of the current/last movement segment,
# NAN before the first one (facing keeps InputMapper's default). Everything
# downstream — octant snap, SetFacing-on-change, WASD rotation — is unchanged.
func _autopilot_facing_angle() -> float:
return _autopilot_heading
# ---------------------------------------------------------------------------
# DebugHud duck contract (sandbox_debug_hud.gd) — the HUD probes this root for
# the anim readouts; rig and greybox answer their own.
# ---------------------------------------------------------------------------
## Clip currently driven by the gait machine; null until one plays (HUD shows "—").
func get_current_clip() -> Variant:
if _anim == null:
return null
var clip: StringName = _anim.get_current_clip()
return null if clip == &"" else clip
## Live AnimationPlayer speed_scale (cadence sync, design §6.2); null until the
## CharacterVisual exists. Re-fetched every call — load_descriptor() recreates the player.
func get_anim_speed_scale() -> Variant:
if character_visual == null:
return null
var player := character_visual.get_animation_player()
return null if player == null else player.speed_scale
@@ -0,0 +1,98 @@
class_name SandboxMouseAimProvider
extends RefCounted
## T-1088 (design §9) — mouse-aim facing provider for the 3D locomotion sandbox.
## Installed by the sandbox root as InputMapper.facing_angle_provider:
##
## var provider := SandboxMouseAimProvider.new(camera, world_root, player_rig)
## InputMapper.facing_angle_provider = Callable(provider, "get_facing_angle")
##
## Returns sim-space radians (0 = East, +PI/2 = South — the InputMapper.facing_angle
## convention), or NAN for "no update" (deadzone / degenerate ray / freed nodes).
## Everything downstream in InputMapper — octant snap, SetFacing-on-change,
## mouse-relative WASD, dialogue suppression — runs unchanged; D-054 semantics
## preserved exactly (only the octant ever crosses the wire).
##
## RefCounted, not Node: InputMapper pulls the value through the Callable each
## frame, so the provider needs no tree presence or lifecycle of its own, and the
## pure static core stays headless-testable (design §10.4). The installed Callable
## keeps this RefCounted alive; the installer clears the seam back to Callable()
## on scene exit, and the is_instance_valid guards below return NAN if the scene
## nodes are freed first.
##
## Math (design §9): unproject the mouse to a ray, intersect the y=0 ground plane
## in world space, convert through WorldRoot.to_local() (undoing the single D-148
## 45° map rotation — WorldRoot-local space IS sim-aligned space, SandboxSpace §2),
## take the delta from the rig's WorldRoot-local position, atan2(delta.z, delta.x).
## A screen-space mouse angle is NOT a sim-space angle in the 3D scene (constant
## ~45° skew from the map rotation plus up to ~19° ortho-pitch warp) — the ray
## unprojection is the only correct D-054 path.
## Guard against a ray running (near-)parallel to the ground plane.
const _RAY_PARALLEL_EPS := 0.000001
var _camera: Camera3D = null
var _world_root: Node3D = null
var _rig: Node3D = null
func _init(camera: Camera3D, world_root: Node3D, rig: Node3D) -> void:
_camera = camera
_world_root = world_root
_rig = rig
## Callable target for InputMapper.facing_angle_provider. Gathers the live scene
## state (mouse, camera ray, transforms) and defers to the pure static core.
func get_facing_angle() -> float:
if (
not is_instance_valid(_camera)
or not is_instance_valid(_world_root)
or not is_instance_valid(_rig)
):
return NAN
if not _camera.is_inside_tree() or not _world_root.is_inside_tree() or not _rig.is_inside_tree():
return NAN
var viewport := _camera.get_viewport()
if viewport == null:
return NAN
var mouse_screen := viewport.get_mouse_position()
return compute_facing_angle(
_camera.project_ray_origin(mouse_screen),
_camera.project_ray_normal(mouse_screen),
_world_root.global_transform,
_world_root.to_local(_rig.global_position),
SandboxConstants.MOUSE_AIM_DEADZONE_M
)
## Pure math core (headless-testable, design §10.4): world-space mouse ray ->
## y=0 ground-plane hit -> WorldRoot-local delta from the rig -> sim radians.
## world_root_transform is WorldRoot's GLOBAL transform (local -> world);
## rig_local_pos is the rig's position in WorldRoot-local metres.
## Returns NAN when there is no meaningful aim point: ray parallel to the ground,
## ground plane behind the ray, or hit inside the deadzone (mirrors the 2D
## jitter guard in InputMapper._update_facing_from_mouse).
static func compute_facing_angle(
ray_origin: Vector3,
ray_dir: Vector3,
world_root_transform: Transform3D,
rig_local_pos: Vector3,
deadzone_m: float
) -> float:
# Ray -> y=0 ground plane, world space.
if absf(ray_dir.y) < _RAY_PARALLEL_EPS:
return NAN
var t := -ray_origin.y / ray_dir.y
if t < 0.0:
return NAN
var hit_world := ray_origin + ray_dir * t
# Undo the 45° map rotation: WorldRoot-local axes are sim axes (SandboxSpace §2:
# local +X = sim East, local +Z = sim South).
var hit_local := world_root_transform.affine_inverse() * hit_world
var delta := hit_local - rig_local_pos
var planar := Vector2(delta.x, delta.z)
if planar.length_squared() < deadzone_m * deadzone_m:
return NAN
# atan2(z, x) IS the sim convention (0 = East, +PI/2 = South) because local
# +Z = sim +y (South, Y-down radians — server vision_cone.rs).
return atan2(delta.z, delta.x)
+117
View File
@@ -0,0 +1,117 @@
class_name SandboxConstants
extends RefCounted
## T-1088 locomotion sandbox — every feel/tuning knob in one file.
## § references point at the T-1088 final design (see ticket). Proto-production:
## values migrate to Phase-5 homes at the T-962 gate.
##
## Stance move intervals are deliberately NOT duplicated here — read
## InputMapper.MOVE_INTERVAL_MS at runtime (single source, no drift; design §13.8).
# -- Coordinate & scale (design §2) -------------------------------------------
## D-066/D-222 — THE convention: one sim TilePosition step = one 0.5 m subtile.
## All sandbox positions and speeds are metres. See SandboxSpace for conversions.
const SUBTILE_M := 0.5
# -- Greybox walls + cutaway (design §3, §8) -----------------------------------
## Wall height in metres — walls shorter than the ~1.7 m character read wrong.
const WALL_H := 2.5
## Cutaway stub height (m) camera-side walls drop to.
const CUT_HEIGHT := 0.75
## Cutaway half-disc radius (m) around the character.
const CUT_RADIUS := 5.0
## Cutaway smoothstep falloff band (m) at the disc edge.
const CUT_BAND := 1.5
# -- Locomotion interpolation (design §4) --------------------------------------
# Stance intervals: NOT duplicated — read InputMapper.MOVE_INTERVAL_MS at runtime.
## Leg-speed clamp for multi-tile latest-wins deltas: close within ~one interval.
const CATCHUP_MAX_FACTOR := 3.0
## 5 subtiles — matches the 2D TELEPORT_DISTANCE_THRESHOLD (main.gd:3). Beyond this,
## snap all channels (position, yaw, camera, 0.0-blend anim reset).
const SNAP_DIST_M := 2.5
## Walk↔idle flicker hysteresis (§4.2): is_moving stays true until at-target this long.
const IDLE_ENTER_DELAY_S := 0.18
# -- Facing / turn smoothing (design §5, D-151) ---------------------------------
## Yaw ease rate (1/s): yaw = lerp_angle(yaw, target, 1 - exp(-TURN_SHARPNESS * delta)).
const TURN_SHARPNESS := 14.0
## Per-stance yaw budget clamp, degrees/second.
const TURN_BUDGET_DEG := {
"Sprint": 1080.0,
"Walk": 720.0,
"Careful": 540.0,
"Crouch": 420.0,
"Idle": 600.0,
}
# -- Animation (design §6) -------------------------------------------------------
## Gait table (§6.1) — the ONLY place animation clip strings live.
## Clip names are the imported bare names: library "", "_Loop" stripped by the glTF
## importer, case-sensitive (design-input §2.2). Careful = Walk_Formal (D-053 stance
## readability at ortho distance; fallback if it reads "parade march": Walk at 0.6x —
## one table cell). Jog_Fwd is the reserve if Sprint reads too aggressive at 2.5 m/s.
const GAIT_CLIP := {
"Sprint": {"idle": &"Idle", "moving": &"Sprint"},
"Walk": {"idle": &"Idle", "moving": &"Walk"},
"Careful": {"idle": &"Idle", "moving": &"Walk_Formal"},
"Crouch": {"idle": &"Crouch_Idle", "moving": &"Crouch_Fwd"},
}
## Native clip ground speed (m/s) for cadence sync (§6.2):
## speed_scale = clamp(rig_speed / NATIVE_MPS[clip], SPEED_SCALE_CLAMP.x, .y).
## Initial guesses — tuned live against the DebugHud readout.
const NATIVE_MPS := {"Walk": 1.4, "Walk_Formal": 1.2, "Sprint": 3.2, "Crouch_Fwd": 0.9}
const SPEED_SCALE_CLAMP := Vector2(0.6, 1.8)
## Cross-fade seconds by transition kind (§6.3); teleport is a hard cut — a
## cross-map jump must not smear.
const BLEND := {
"idle_to_gait": 0.12,
"gait_to_idle": 0.20,
"gait_to_gait": 0.15,
"crouch": 0.25,
"teleport": 0.0,
}
# -- Camera (design §7; D-148, D-015, D-158) -------------------------------------
## Pitch presets in degrees from horizontal — D-148 preset list is authoritative;
## the spike's -45 iso preset is struck. T-key cycles (sanctioned dev affordance).
const CAM_PITCH_PRESETS := [-30.0, -5.0, -80.0]
const CAM_DIST := 30.0
const CAM_ORTHO_SIZE := 9.0
## Exponential pivot-follow rate (1/s) — deliberately the ONLY soft layer (§0).
const CAM_FOLLOW_RATE := 6.0
## Preset tilt lerp rate (1/s).
const CAM_TILT_RATE := 3.0
## Follow lookahead knob for the tuning pass (seconds of velocity lead).
const LOOKAHEAD_S := 0.0
# -- Scene (design §1.2) -----------------------------------------------------------
## WorldRoot yaw — the single static D-148 map-rotation Transform3D. Set once in
## locomotion_sandbox.tscn; sim coords stay axis-aligned inside WorldRoot and the
## camera never rotates.
const MAP_ROTATION_DEG := 45.0
# -- Greybox tints (design §3.2) -----------------------------------------------------
## Four-state visibility tint: Color values are used as instance color directly;
## float values multiply albedo value (85% / 70%).
const TINTS := {
"forward": Color.WHITE,
"peripheral": 0.85,
"boundary": 0.70,
"remembered": Color(0.42, 0.45, 0.52),
}
# -- Input (design §9) ---------------------------------------------------------------
## Ground-plane deadzone (m) for the mouse facing provider — mirrors the 2D jitter guard.
const MOUSE_AIM_DEADZONE_M := 0.1
@@ -0,0 +1,98 @@
extends CanvasLayer
## T-1088 numeric locomotion instrumentation HUD (design §10.1): tick, tick_rate,
## stance, player tile, render speed, clip + speed_scale, snapshot age, yaw
## target/actual, store size. Sandbox-only. Cadence sync is verifiable numerically
## on screen, not just by eye — tuning NATIVE_MPS is a read-the-HUD job.
##
## All component reads go through has_method guards so the HUD works from the S1
## skeleton and lights up ("—" -> numbers) as later slices land. Duck-typed contract:
## PlayerRig (S4): get_render_speed() -> float (m/s),
## get_yaw_target_rad() -> float
## PlayerRig or root (S6): get_current_clip() -> StringName,
## get_anim_speed_scale() -> float
## Greybox (S3): get_store_size() -> int
const FONT_SIZE := 13
@onready var _rig: Node = get_node_or_null("../WorldRoot/PlayerRig")
@onready var _model_root: Node3D = get_node_or_null("../WorldRoot/PlayerRig/ModelRoot")
@onready var _greybox: Node = get_node_or_null("../WorldRoot/Greybox")
var _label: Label = null
var _last_tick: int = -1
var _last_snapshot_msec: int = -1
func _ready() -> void:
layer = 10
_label = Label.new()
_label.name = "Readout"
_label.position = Vector2(8, 8)
_label.add_theme_font_size_override("font_size", FONT_SIZE)
_label.add_theme_color_override("font_color", Color(0.85, 0.92, 1.0))
_label.add_theme_color_override("font_outline_color", Color(0.0, 0.0, 0.0, 0.9))
_label.add_theme_constant_override("outline_size", 4)
add_child(_label)
func _process(_delta: float) -> void:
# Snapshot age: a consumed-tick change is the arrival signal (world_renderer.gd:33-36
# tick-gate pattern). SimBridge delivery is latest-wins, so consumption time is the
# honest age reference for interpolation debugging.
if GameState.current_tick != _last_tick:
_last_tick = GameState.current_tick
_last_snapshot_msec = Time.get_ticks_msec()
_label.text = _build_text()
func _build_text() -> String:
var lines := PackedStringArray()
var rate := str(GameState.game_time.get("tick_rate", ""))
lines.append("tick %d rate %s snap age %s" % [GameState.current_tick, rate, _age_text()])
var tile := SandboxSpace.wire_to_tile(GameState.player_position.x, GameState.player_position.y)
lines.append("stance %s tile (%d, %d)" % [GameState.player_stance, tile.x, tile.y])
var anim_sources: Array = [_rig, get_parent()]
lines.append(
"speed %s m/s clip %s scale %s"
% [
_fmt(_call_first([_rig], "get_render_speed"), "%.3f"),
_fmt(_call_first(anim_sources, "get_current_clip"), "%s"),
_fmt(_call_first(anim_sources, "get_anim_speed_scale"), "%.2f"),
]
)
var yaw_target: Variant = _call_first([_rig], "get_yaw_target_rad")
if yaw_target != null:
yaw_target = rad_to_deg(float(yaw_target))
var yaw_actual: Variant = null
if _model_root != null:
yaw_actual = rad_to_deg(_model_root.rotation.y)
lines.append(
"yaw target %s actual %s" % [_fmt(yaw_target, "%.1f deg"), _fmt(yaw_actual, "%.1f deg")]
)
lines.append("store %s tiles" % _fmt(_call_first([_greybox], "get_store_size"), "%d"))
return "\n".join(lines)
func _age_text() -> String:
if _last_snapshot_msec < 0:
return ""
return "%.3f s" % (float(Time.get_ticks_msec() - _last_snapshot_msec) / 1000.0)
# First answer from the candidate nodes implementing `method`, else null.
static func _call_first(nodes: Array, method: String) -> Variant:
for node in nodes:
if node != null and (node as Object).has_method(method):
return (node as Object).call(method)
return null
# Format a duck-typed value; "—" until the providing component lands.
static func _fmt(value: Variant, format: String) -> String:
if value == null:
return ""
return format % value
+100
View File
@@ -0,0 +1,100 @@
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))
@@ -0,0 +1,55 @@
shader_type spatial;
render_mode diffuse_lambert, specular_disabled;
// T-1088 greybox floor (design §3.3) — sandbox-only.
//
// Per-instance visibility tint x world-space grid lines. The MultiMesh instance
// color (use_colors, GreyboxWorld) arrives as the COLOR built-in and multiplies
// albedo: white = Forward, 85% = Peripheral, 70% = BoundaryWall, dim cool grey =
// Remembered (SandboxConstants.TINTS — §3.2).
//
// Grid: data is per-0.5 m subtile (D-066/D-222) but the floor must READ as 1 m
// visual tiles (2x2 subtiles) — minor lines every 0.5 m (alpha 0.15), major
// lines every 1.0 m (alpha 0.45). Lines are sampled in SIM space (u_sim_from_world
// undoes the 45 deg WorldRoot rotation) so they land on tile edges; sampling raw
// world space would draw the grid rotated 45 deg against the tiles. Continuous
// across instances because sim space is shared. D-243's "voxel = 1 m" is
// generation-cascade vocabulary — the 1 m visual tile exists ONLY as the major
// lines here (§2).
uniform vec3 u_base_albedo : source_color = vec3(0.52, 0.54, 0.58);
uniform vec3 u_line_albedo : source_color = vec3(0.10, 0.11, 0.14);
uniform float u_minor_spacing_m = 0.5;
uniform float u_major_spacing_m = 1.0;
uniform float u_minor_alpha = 0.15;
uniform float u_major_alpha = 0.45;
uniform float u_line_half_width_m = 0.02;
// World -> sim-space transform; set by GreyboxWorld from its own inverse global
// transform so the grid rotates WITH the tiles under the WorldRoot 45 deg.
uniform mat4 u_sim_from_world = mat4(1.0);
varying vec2 v_world_xz;
void vertex() {
// MODEL_MATRIX folds in the per-instance MultiMesh transform (incl. WorldRoot
// rotation); u_sim_from_world undoes the scene rotation back to sim axes.
v_world_xz = (u_sim_from_world * MODEL_MATRIX * vec4(VERTEX, 1.0)).xz;
}
// 1.0 on a grid line of the given spacing, 0.0 off it, one-fwidth AA edge.
float line_mask(vec2 p, float spacing, float half_width) {
vec2 dxy = abs(fract(p / spacing + 0.5) - 0.5) * spacing; // metres to nearest line
float d = min(dxy.x, dxy.y);
float aa = fwidth(d);
return 1.0 - smoothstep(half_width - aa, half_width + aa, d);
}
void fragment() {
float minor = line_mask(v_world_xz, u_minor_spacing_m, u_line_half_width_m) * u_minor_alpha;
float major = line_mask(v_world_xz, u_major_spacing_m, u_line_half_width_m) * u_major_alpha;
float line_a = max(minor, major); // major wins where the grids coincide
vec3 tinted = u_base_albedo * COLOR.rgb;
// Line color is tinted too, so remembered/peripheral tiles keep dim lines.
ALBEDO = mix(tinted, u_line_albedo * COLOR.rgb, line_a);
ROUGHNESS = 1.0;
}
@@ -0,0 +1,57 @@
shader_type spatial;
render_mode diffuse_lambert, specular_disabled;
// T-1088 greybox wall + camera-side cutaway (design §8) — technique
// proto-production.
//
// COLOR-multiplied albedo (same four-state tint contract as the floor shader)
// plus a smoothstep height cut: a half-disc of camera-side wall around the
// character drops to a CUT_HEIGHT stub with smooth radial falloff — the
// Xenonauts read, minus per-building logic the greybox doesn't have. The
// cutaway cuts RENDER HEIGHT; the tint gates KNOWLEDGE — independent factors,
// so remembered walls can be cut too (the player's camera doesn't care what the
// character currently sees). Open box tops are acceptable greybox fidelity;
// ghost band / dither / capping are named polish on this same shader.
//
// u_char_pos_xz is the rig's INTERPOLATED world XZ, pushed once per frame
// (GreyboxWorld.set_cutaway_char_pos) — the cut zone glides with the character,
// so spatial smoothstep becomes temporal smoothness. Parked far away by default:
// no cut until the first push.
//
// Camera-side predicate: the camera has yaw 0 and looks world -Z (the diamond
// view is WorldRoot's 45 deg rotation, never the camera's — D-148), so camera
// ground-forward is the constant vec2(0, -1) in world space. A wall fragment
// sits between camera and character when to_wall.y > 0; the test runs in world
// space, so it stays correct under the WorldRoot rotation.
//
// Geometry uniforms default to the SandboxConstants values; GreyboxWorld pushes
// them at material build so SandboxConstants stays the single source (§12).
uniform vec2 u_char_pos_xz = vec2(1000000.0, 1000000.0);
uniform float u_wall_h = 2.5; // SandboxConstants.WALL_H
uniform float u_cut_height = 0.75; // SandboxConstants.CUT_HEIGHT — the stub
uniform float u_cut_radius = 5.0; // SandboxConstants.CUT_RADIUS — half-disc radius
uniform float u_cut_band = 1.5; // SandboxConstants.CUT_BAND — radial falloff band
uniform vec3 u_base_albedo : source_color = vec3(0.66, 0.64, 0.60);
varying vec3 v_world_pos;
void vertex() {
// MODEL_MATRIX folds in the per-instance MultiMesh transform.
v_world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
}
void fragment() {
vec2 to_wall = v_world_pos.xz - u_char_pos_xz;
// Soft camera-side gate: fully cut from 0.75 m on the camera side of the
// character, fully solid from 0.25 m on the far side — no hard seam.
float side_f = smoothstep(-0.25, 0.75, to_wall.y);
// Radial falloff: full cut inside (radius - band), fading to none at radius.
float rad_f = 1.0 - smoothstep(u_cut_radius - u_cut_band, u_cut_radius, length(to_wall));
float allowed = mix(u_wall_h, u_cut_height, side_f * rad_f);
if (v_world_pos.y > allowed) {
discard;
}
ALBEDO = u_base_albedo * COLOR.rgb;
ROUGHNESS = 1.0;
}
+436
View File
@@ -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
+264
View File
@@ -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
+237
View File
@@ -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)
+378
View File
@@ -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)
+148
View File
@@ -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)
+31 -5
View File
@@ -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/).
+30
View File
@@ -76,6 +76,19 @@
"atlas_gen_open": {
"ticks": 240,
"description": "Live: real atlas opener (HudGroups) + GJ71c Layer-1 overlays (#960)"
},
"locomotion_idle_live": {
"ticks": 90,
"live": true,
"scene": "res://scenes/locomotion_sandbox.tscn",
"description": "Live 3D sandbox: character idle at Hub spawn — greybox floor, -30 deg frame (T-1088)"
},
"locomotion_cutaway": {
"ticks": 540,
"live": true,
"scene": "res://scenes/locomotion_sandbox.tscn",
"env": { "SR_AUTOPILOT": "stance_up,south:6.0" },
"description": "Live 3D sandbox: autopilot walks south into a wall — cutaway stub golden (T-1088)"
}
},
"flows": {
@@ -98,6 +111,23 @@
{ "action": "MoveNorth", "label": "near wall" },
{ "action": "MoveSouth", "label": "fog hiding" }
]
},
"locomotion_gaits": {
"interval": 2,
"live": true,
"scene": "res://scenes/locomotion_sandbox.tscn",
"env": {
"SR_AUTOPILOT": "east:2.0,stance_up,east:2.0,stance_up,east:2.0,stance_down,stance_down,stance_down,south:2.0,wait:1.5"
},
"description": "T-1088 stance ladder movie — NOT golden-compared. Movie mode starts no server: run `cd server && cargo run --bin settled-reach-server -- --test-mode` first, then `SR_LIVE=1 SR_PORT=9876 tests/run-visual --movie locomotion_gaits`",
"steps": [
{ "action": "wait", "label": "careful gait east" },
{ "action": "wait", "label": "walk gait east" },
{ "action": "wait", "label": "sprint gait east" },
{ "action": "wait", "label": "crouch gait south" },
{ "action": "wait", "label": "settling to idle" },
{ "action": "wait", "label": "idle rest" }
]
}
},
"crops": {