diff --git a/client/scenes/locomotion_sandbox.tscn b/client/scenes/locomotion_sandbox.tscn new file mode 100644 index 000000000..1c722a3af --- /dev/null +++ b/client/scenes/locomotion_sandbox.tscn @@ -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") diff --git a/client/scripts/autoloads/input_mapper.gd b/client/scripts/autoloads/input_mapper.gd index 5163386fa..9efcb3ab1 100644 --- a/client/scripts/autoloads/input_mapper.gd +++ b/client/scripts/autoloads/input_mapper.gd @@ -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 diff --git a/client/scripts/rendering/character_visual.gd b/client/scripts/rendering/character_visual.gd index 11135cc2d..116ad6a7d 100644 --- a/client/scripts/rendering/character_visual.gd +++ b/client/scripts/rendering/character_visual.gd @@ -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 # ============================================================================= diff --git a/client/scripts/sandbox/follow_camera_3d.gd b/client/scripts/sandbox/follow_camera_3d.gd new file mode 100644 index 000000000..3578f492b --- /dev/null +++ b/client/scripts/sandbox/follow_camera_3d.gd @@ -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) diff --git a/client/scripts/sandbox/greybox_world.gd b/client/scripts/sandbox/greybox_world.gd new file mode 100644 index 000000000..c1a904fa4 --- /dev/null +++ b/client/scripts/sandbox/greybox_world.gd @@ -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) diff --git a/client/scripts/sandbox/locomotion_anim.gd b/client/scripts/sandbox/locomotion_anim.gd new file mode 100644 index 000000000..9289647d2 --- /dev/null +++ b/client/scripts/sandbox/locomotion_anim.gd @@ -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) diff --git a/client/scripts/sandbox/locomotion_rig.gd b/client/scripts/sandbox/locomotion_rig.gd new file mode 100644 index 000000000..d31a34d8e --- /dev/null +++ b/client/scripts/sandbox/locomotion_rig.gd @@ -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) diff --git a/client/scripts/sandbox/locomotion_sandbox.gd b/client/scripts/sandbox/locomotion_sandbox.gd new file mode 100644 index 000000000..6d8b4b1bf --- /dev/null +++ b/client/scripts/sandbox/locomotion_sandbox.gd @@ -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 diff --git a/client/scripts/sandbox/mouse_aim_provider.gd b/client/scripts/sandbox/mouse_aim_provider.gd new file mode 100644 index 000000000..dbbb4da4b --- /dev/null +++ b/client/scripts/sandbox/mouse_aim_provider.gd @@ -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) diff --git a/client/scripts/sandbox/sandbox_constants.gd b/client/scripts/sandbox/sandbox_constants.gd new file mode 100644 index 000000000..889d6df45 --- /dev/null +++ b/client/scripts/sandbox/sandbox_constants.gd @@ -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 diff --git a/client/scripts/sandbox/sandbox_debug_hud.gd b/client/scripts/sandbox/sandbox_debug_hud.gd new file mode 100644 index 000000000..f27dee410 --- /dev/null +++ b/client/scripts/sandbox/sandbox_debug_hud.gd @@ -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 diff --git a/client/scripts/sandbox/sandbox_space.gd b/client/scripts/sandbox/sandbox_space.gd new file mode 100644 index 000000000..be5ef100a --- /dev/null +++ b/client/scripts/sandbox/sandbox_space.gd @@ -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)) diff --git a/client/shaders/sandbox/greybox_tile.gdshader b/client/shaders/sandbox/greybox_tile.gdshader new file mode 100644 index 000000000..1f260ae26 --- /dev/null +++ b/client/shaders/sandbox/greybox_tile.gdshader @@ -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; +} diff --git a/client/shaders/sandbox/greybox_wall_cutaway.gdshader b/client/shaders/sandbox/greybox_wall_cutaway.gdshader new file mode 100644 index 000000000..a2aa73561 --- /dev/null +++ b/client/shaders/sandbox/greybox_wall_cutaway.gdshader @@ -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; +} diff --git a/client/tests/unit/test_gait_table.gd b/client/tests/unit/test_gait_table.gd new file mode 100644 index 000000000..35225debb --- /dev/null +++ b/client/tests/unit/test_gait_table.gd @@ -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 diff --git a/client/tests/unit/test_greybox_store.gd b/client/tests/unit/test_greybox_store.gd new file mode 100644 index 000000000..fe1150263 --- /dev/null +++ b/client/tests/unit/test_greybox_store.gd @@ -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 diff --git a/client/tests/unit/test_locomotion_input.gd b/client/tests/unit/test_locomotion_input.gd new file mode 100644 index 000000000..9831b594c --- /dev/null +++ b/client/tests/unit/test_locomotion_input.gd @@ -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) diff --git a/client/tests/unit/test_locomotion_math.gd b/client/tests/unit/test_locomotion_math.gd new file mode 100644 index 000000000..4396807b9 --- /dev/null +++ b/client/tests/unit/test_locomotion_math.gd @@ -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) diff --git a/client/tests/unit/test_sandbox_space.gd b/client/tests/unit/test_sandbox_space.gd new file mode 100644 index 000000000..d80c55969 --- /dev/null +++ b/client/tests/unit/test_sandbox_space.gd @@ -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) diff --git a/client/tests/visual_capture.gd b/client/tests/visual_capture.gd index 67b238d89..0322aa611 100644 --- a/client/tests/visual_capture.gd +++ b/client/tests/visual_capture.gd @@ -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/). diff --git a/tests/visual.json b/tests/visual.json index af274c1a9..721a4e5f1 100644 --- a/tests/visual.json +++ b/tests/visual.json @@ -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": {