feat(client): 3D locomotion sandbox — character walks the live Gauntlet (T-1088)
New SR_LIVE sandbox scene: CharacterVisual composited in a 3D greybox world derived from server snapshots. Per-leg constant-velocity interpolation keyed to the stance throttle, 'server feet / client eyes' facing (wire octant while moving, client aim octant idle), cadence-synced gait state machine on AnimationPlayer custom blends, D-148 orthographic follow camera (-30deg default, T-cycle presets), sim-space grid shader, camera-side wall cutaway, accumulating never-evict tile store with four-state visibility tint. Additive seams only: InputMapper.facing_angle_provider (2D path unchanged), CharacterVisual.play_animation blend_time param + get_animation_player(). Visual harness gains per-scenario scene field + SR_AUTOPILOT input scripting. 210 new gdUnit assertions across five suites; verified live (230/230 total, clean smoke, screenshot at .cache/screenshots/locomotion_idle_live.png). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,315 @@
|
||||
class_name LocomotionRig
|
||||
extends Node3D
|
||||
## T-1088 locomotion rig (design §4, §5) — the position and yaw channels of the
|
||||
## feel thesis (§0): each channel gets exactly ONE smoothing layer, crisp -> soft.
|
||||
## Thin Node3D wrapper (this node = PlayerRig; local position = interpolated sim
|
||||
## position in WorldRoot-local metres) over a pure static math core, so every
|
||||
## rule is headless-testable (test_locomotion_math.gd, §10.4). Proto-production:
|
||||
## an NPC adapter later feeds set_wire_target() from VisibleEntity rows with no
|
||||
## rig changes.
|
||||
##
|
||||
## Position (§4.1): per-leg constant velocity sized to the step — on a changed
|
||||
## target, cover the distance in exactly one stance interval (dist/interval),
|
||||
## clamped to [base, CATCHUP_MAX_FACTOR * base]. No easing on the character
|
||||
## itself: easing a repeating 0.4 s step reads as scooting; the camera (S5) is
|
||||
## deliberately the only soft position layer.
|
||||
##
|
||||
## Yaw (§5, "server feet, client eyes"): moving (incl. the idle-hysteresis
|
||||
## window) follows the wire octant — the server sets Facing from the move delta,
|
||||
## so it IS the motion direction; idle follows idle_facing_provider (the same
|
||||
## snapped octant that rides the SetFacing wire); frozen while suppressed
|
||||
## (dialogue / free camera). Shortest-arc lerp_angle ease (TURN_SHARPNESS) under
|
||||
## per-stance deg/s budgets (TURN_BUDGET_DEG), written to ModelRoot.rotation.y in
|
||||
## WorldRoot-LOCAL space so octant->yaw composes with the D-148 map rotation
|
||||
## exactly once.
|
||||
##
|
||||
## TRAP (design §2, §5): NEVER call CharacterVisual.set_facing() from this rig
|
||||
## or anything near it. Its internal table (west=+90, east=-90 —
|
||||
## character_visual.gd:184-193) assumes a mirrored axis mapping (East -> -X)
|
||||
## incompatible with THE convention (SandboxSpace: East -> +X); using it makes
|
||||
## the model face west while walking east. This rig owns ModelRoot.rotation.y
|
||||
## exclusively, always through SandboxSpace.octant_to_yaw(); CharacterVisual's
|
||||
## own rotation.y stays 0.
|
||||
##
|
||||
## The rig never reads GameState, InputMapper, or any autoload itself (§4.0) —
|
||||
## the sandbox root adapts snapshot fields into set_wire_target() and installs
|
||||
## the provider Callables below. NPC adapters leave the providers unset.
|
||||
|
||||
## Emitted when a wire target lands beyond SNAP_DIST_M (Home-key / cross-map
|
||||
## teleport, §4.1): all rig channels have already snapped; the camera (S5)
|
||||
## hard-sets its pivot on this, the anim machine (S6) hard-cuts (BLEND.teleport
|
||||
## = 0.0). NOT emitted for the first-ever snapshot (the root's latch owns that).
|
||||
signal teleported(pos_m: Vector3)
|
||||
|
||||
## set_wire_target() outcome — a pure decision, exposed for tests (§10.4).
|
||||
enum TargetAction { SNAP_FIRST, IGNORE, TELEPORT, STEP }
|
||||
|
||||
## Fallback step window when step_window_ms_provider is unset (headless tests,
|
||||
## future NPC adapters before MovementSpeed wiring). Mirrors the Walk default
|
||||
## stance only — the player adapter MUST install a provider reading
|
||||
## InputMapper.MOVE_INTERVAL_MS at runtime: that table is the single source and
|
||||
## is deliberately never copied (design §13.8).
|
||||
const DEFAULT_STEP_WINDOW_MS := 400.0
|
||||
|
||||
## (stance: String) -> float|int step window in ms for one confirmed step.
|
||||
## Player adapter: func(stance): return InputMapper.MOVE_INTERVAL_MS.get(stance, 400).
|
||||
## Called once per accepted step (per-leg recompute, §4.1) with the stance that
|
||||
## arrived in the same wire target.
|
||||
var step_window_ms_provider: Callable = Callable()
|
||||
## () -> octant String for idle facing. The player adapter returns
|
||||
## InputMapper.facing_octant — the SAME snapped octant that rides the SetFacing
|
||||
## wire, so the model never shows an octant the server wasn't told (§5). Leave
|
||||
## unset (NPCs) to collapse the facing split to pure wire facing — single code
|
||||
## path by construction.
|
||||
var idle_facing_provider: Callable = Callable()
|
||||
## () -> bool, true while input is suppressed. The player adapter returns
|
||||
## GameState.dialogue_active or GameState.free_camera_mode — the exact condition
|
||||
## under which InputMapper computes but does not send octants (input_mapper.gd:71).
|
||||
## While idle + suppressed the yaw target freezes (last target held, §5). Leave
|
||||
## unset for never-suppressed (NPCs).
|
||||
var suppression_provider: Callable = Callable()
|
||||
|
||||
## Latest wire facing octant (snapshot player_facing) — the moving yaw source.
|
||||
## Updates on EVERY wire target, including ignored ones: a blocked move (Q-020)
|
||||
## still changes Facing server-side, so bump-to-turn falls out for free (§4.3).
|
||||
var wire_octant: String = "North"
|
||||
## Latest wire stance — selects the step window and the moving turn budget.
|
||||
var stance: String = "Walk"
|
||||
## Tick of the latest consumed wire target (DebugHud "snapshot age" input).
|
||||
var last_wire_tick: int = -1
|
||||
|
||||
## True until at-target for IDLE_ENTER_DELAY_S (§4.2 hysteresis — kills the
|
||||
## walk<->idle flicker from snapshot jitter). Gait selection keys off this and
|
||||
## current_speed, never off input (§0).
|
||||
var is_moving: bool = false
|
||||
## Constant per-leg ground speed (m/s), zero on arrival — cadence-sync input
|
||||
## (§6.2: speed_scale = current_speed / NATIVE_MPS, constant per leg).
|
||||
var current_speed: float = 0.0
|
||||
## Render velocity (m/s), zero on arrival — gait-selection input (§0).
|
||||
var velocity: Vector3 = Vector3.ZERO
|
||||
## Current yaw target (rad, WorldRoot-local) — held while frozen. DebugHud pairs
|
||||
## it with model_root.rotation.y as "yaw target / actual".
|
||||
var yaw_target: float = 0.0
|
||||
|
||||
## Yaw channel output node — resolved from the ModelRoot scene child in _ready();
|
||||
## injectable beforehand for headless tests.
|
||||
var model_root: Node3D = null
|
||||
|
||||
var _has_target: bool = false
|
||||
var _target_pos: Vector3 = Vector3.ZERO
|
||||
var _leg_speed: float = 0.0
|
||||
var _idle_timer_s: float = 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if model_root == null:
|
||||
model_root = get_node_or_null("ModelRoot") as Node3D
|
||||
|
||||
|
||||
## The single rig input (§4.0): the sandbox root (or a future NPC adapter) calls
|
||||
## this once per consumed snapshot with the wire position converted to
|
||||
## WorldRoot-local metres (SandboxSpace.wire_to_world), the wire facing octant,
|
||||
## stance, and tick.
|
||||
func set_wire_target(pos_m: Vector3, facing_octant: String, new_stance: String, tick: int) -> void:
|
||||
wire_octant = facing_octant
|
||||
stance = new_stance
|
||||
last_wire_tick = tick
|
||||
match classify_target(_has_target, position, _target_pos, pos_m):
|
||||
TargetAction.SNAP_FIRST:
|
||||
# First-ever snapshot snaps, no signal (2D precedent
|
||||
# entity_renderer.gd:133-140; the root's first-snapshot latch
|
||||
# handles the camera).
|
||||
_snap_all_channels(pos_m)
|
||||
TargetAction.IGNORE:
|
||||
# Paused-tick identical frames and blocked moves (Q-020) land here:
|
||||
# target-chasing is idempotent, nothing re-triggers. Facing already
|
||||
# updated above — bump-to-turn is the visible blocked-move behavior.
|
||||
pass
|
||||
TargetAction.TELEPORT:
|
||||
_snap_all_channels(pos_m)
|
||||
teleported.emit(pos_m)
|
||||
TargetAction.STEP:
|
||||
_target_pos = pos_m
|
||||
_leg_speed = derive_leg_speed(position.distance_to(pos_m), _step_interval_s())
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if not _has_target:
|
||||
return
|
||||
# Position channel: constant-velocity chase, exact arrival (§4.1).
|
||||
position = step_position(position, _target_pos, _leg_speed, delta)
|
||||
var at_target := position.is_equal_approx(_target_pos)
|
||||
if at_target:
|
||||
position = _target_pos # kill sub-epsilon residue
|
||||
velocity = Vector3.ZERO
|
||||
current_speed = 0.0
|
||||
else:
|
||||
velocity = (_target_pos - position).normalized() * _leg_speed
|
||||
current_speed = _leg_speed
|
||||
_idle_timer_s = advance_idle_timer(_idle_timer_s, at_target, delta)
|
||||
is_moving = is_moving_state(at_target, _idle_timer_s)
|
||||
# Yaw channel (§5).
|
||||
_update_facing(delta)
|
||||
|
||||
|
||||
## Remaining distance (m) on the current leg — DebugHud "leg distance" readout.
|
||||
func leg_remaining_m() -> float:
|
||||
return position.distance_to(_target_pos)
|
||||
|
||||
|
||||
## DebugHud duck contract (sandbox_debug_hud.gd): render speed in m/s — constant
|
||||
## per leg, zero on arrival.
|
||||
func get_render_speed() -> float:
|
||||
return current_speed
|
||||
|
||||
|
||||
## DebugHud duck contract (sandbox_debug_hud.gd): current yaw target (rad,
|
||||
## WorldRoot-local), paired on screen with ModelRoot.rotation.y as "target/actual".
|
||||
func get_yaw_target_rad() -> float:
|
||||
return yaw_target
|
||||
|
||||
|
||||
## Snap every rig channel to the target (first snapshot / teleport, §4.1):
|
||||
## position, velocity, yaw target AND actual (no ease across a jump), and the
|
||||
## idle hysteresis (pre-expired so the rig is instantly idle).
|
||||
func _snap_all_channels(pos_m: Vector3) -> void:
|
||||
_has_target = true
|
||||
position = pos_m
|
||||
_target_pos = pos_m
|
||||
_leg_speed = 0.0
|
||||
velocity = Vector3.ZERO
|
||||
current_speed = 0.0
|
||||
_idle_timer_s = SandboxConstants.IDLE_ENTER_DELAY_S
|
||||
is_moving = false
|
||||
yaw_target = SandboxSpace.octant_to_yaw(wire_octant)
|
||||
if model_root != null:
|
||||
model_root.rotation.y = yaw_target
|
||||
|
||||
|
||||
func _step_interval_s() -> float:
|
||||
var window_ms := DEFAULT_STEP_WINDOW_MS
|
||||
if step_window_ms_provider.is_valid():
|
||||
window_ms = float(step_window_ms_provider.call(stance))
|
||||
return window_ms / 1000.0
|
||||
|
||||
|
||||
func _update_facing(delta: float) -> void:
|
||||
var suppressed := false
|
||||
if suppression_provider.is_valid():
|
||||
suppressed = bool(suppression_provider.call())
|
||||
var idle_octant := ""
|
||||
if idle_facing_provider.is_valid():
|
||||
idle_octant = str(idle_facing_provider.call())
|
||||
yaw_target = select_yaw_target(is_moving, wire_octant, idle_octant, suppressed, yaw_target)
|
||||
if model_root == null:
|
||||
return
|
||||
var budget_key := stance if is_moving else "Idle"
|
||||
model_root.rotation.y = step_yaw(model_root.rotation.y, yaw_target, budget_key, delta)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure static core — all locomotion math lives below, stateless and
|
||||
# headless-testable (§10.4). The wrapper above only shuttles state.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
## Classify an incoming wire target (§4.1): the first-ever snapshot snaps; an
|
||||
## unchanged target is ignored — paused-tick frames (§4.3) and blocked moves
|
||||
## (Q-020) re-trigger nothing by construction, and a mid-leg repeat keeps the
|
||||
## current leg speed (no tail-slowdown re-derivation); beyond SNAP_DIST_M
|
||||
## (5 subtiles — matches the 2D TELEPORT_DISTANCE_THRESHOLD) is a teleport;
|
||||
## otherwise a normal step.
|
||||
static func classify_target(
|
||||
has_target: bool, render_pos: Vector3, current_target: Vector3, new_target: Vector3
|
||||
) -> TargetAction:
|
||||
if not has_target:
|
||||
return TargetAction.SNAP_FIRST
|
||||
if new_target.is_equal_approx(current_target):
|
||||
return TargetAction.IGNORE
|
||||
if render_pos.distance_to(new_target) > SandboxConstants.SNAP_DIST_M:
|
||||
return TargetAction.TELEPORT
|
||||
return TargetAction.STEP
|
||||
|
||||
|
||||
## Per-leg constant velocity (§4.1): cover dist in exactly one stance interval,
|
||||
## clamped to [base, CATCHUP_MAX_FACTOR * base] where base is the cardinal
|
||||
## one-subtile speed (SUBTILE_M / interval). One rule handles everything:
|
||||
## cardinal Walk 0.5/0.4 = 1.25 m/s; diagonal 0.707/0.4 = 1.77 m/s — arrives
|
||||
## exactly on time (no sqrt(2) on the wire, D-053; a fixed speed would lag on
|
||||
## held diagonals); multi-tile latest-wins deltas close within ~one interval
|
||||
## with feet speeding up to match (§6.2).
|
||||
static func derive_leg_speed(dist_m: float, interval_s: float) -> float:
|
||||
var safe_interval := maxf(interval_s, 0.001)
|
||||
var base := SandboxConstants.SUBTILE_M / safe_interval
|
||||
return clampf(dist_m / safe_interval, base, SandboxConstants.CATCHUP_MAX_FACTOR * base)
|
||||
|
||||
|
||||
## One frame of constant-velocity chase — move_toward clamps at the target, so
|
||||
## arrival is exact and velocity is a clean square wave, never a sawtooth (§4.1).
|
||||
static func step_position(
|
||||
render_pos: Vector3, target: Vector3, leg_speed: float, delta: float
|
||||
) -> Vector3:
|
||||
return render_pos.move_toward(target, leg_speed * delta)
|
||||
|
||||
|
||||
## Idle-hysteresis timer (§4.2): accumulates while at-target, resets the moment
|
||||
## a new leg starts.
|
||||
static func advance_idle_timer(timer_s: float, at_target: bool, delta: float) -> float:
|
||||
return timer_s + delta if at_target else 0.0
|
||||
|
||||
|
||||
## is_moving with hysteresis (§4.2): snapshot arrival jitters +-1-2 ticks against
|
||||
## the client throttle, so the rig frequently arrives a few frames early —
|
||||
## staying "moving" until at-target for IDLE_ENTER_DELAY_S kills the walk<->idle
|
||||
## flicker; a real stop still reaches Idle inside the human ~0.3 s settle window.
|
||||
static func is_moving_state(at_target: bool, timer_s: float) -> bool:
|
||||
if not at_target:
|
||||
return true
|
||||
return timer_s < SandboxConstants.IDLE_ENTER_DELAY_S
|
||||
|
||||
|
||||
## Yaw target source per the §5 authority table ("server feet, client eyes"):
|
||||
## moving -> wire octant (server Facing IS the motion direction); idle +
|
||||
## suppressed -> held target frozen; idle with a provider -> the client-local
|
||||
## aim octant; idle without one (NPCs) -> pure wire facing. Empty idle_octant
|
||||
## means "no provider installed".
|
||||
static func select_yaw_target(
|
||||
moving: bool, wire_oct: String, idle_octant: String, suppressed: bool, held_target: float
|
||||
) -> float:
|
||||
if moving:
|
||||
return SandboxSpace.octant_to_yaw(wire_oct)
|
||||
if suppressed:
|
||||
return held_target
|
||||
if idle_octant.is_empty():
|
||||
return SandboxSpace.octant_to_yaw(wire_oct)
|
||||
return SandboxSpace.octant_to_yaw(idle_octant)
|
||||
|
||||
|
||||
## Signed shortest arc from one yaw to another, in [-PI, PI).
|
||||
static func shortest_arc(from_yaw: float, to_yaw: float) -> float:
|
||||
return wrapf(to_yaw - from_yaw, -PI, PI)
|
||||
|
||||
|
||||
## Wrap a yaw into (-PI, PI] — same convention as SandboxSpace (North stays +PI).
|
||||
static func wrap_yaw(yaw: float) -> float:
|
||||
var wrapped := yaw
|
||||
while wrapped > PI:
|
||||
wrapped -= TAU
|
||||
while wrapped <= -PI:
|
||||
wrapped += TAU
|
||||
return wrapped
|
||||
|
||||
|
||||
## One frame of yaw smoothing (§5): shortest-arc lerp_angle ease-out at
|
||||
## TURN_SHARPNESS, with the per-frame change clamped to the per-stance
|
||||
## TURN_BUDGET_DEG (budget_key = stance while moving, "Idle" otherwise; unknown
|
||||
## keys fall back to Idle). A 180 deg reversal at Walk completes in ~0.25 s
|
||||
## (about half a step); a 45 deg corner resolves in ~60 ms.
|
||||
static func step_yaw(current_yaw: float, target_yaw: float, budget_key: String, delta: float) -> float:
|
||||
var eased := lerp_angle(current_yaw, target_yaw, 1.0 - exp(-SandboxConstants.TURN_SHARPNESS * delta))
|
||||
var budget_deg: float = SandboxConstants.TURN_BUDGET_DEG.get(
|
||||
budget_key, SandboxConstants.TURN_BUDGET_DEG["Idle"]
|
||||
)
|
||||
var max_step := deg_to_rad(budget_deg) * delta
|
||||
var step := clampf(shortest_arc(current_yaw, eased), -max_step, max_step)
|
||||
return wrap_yaw(current_yaw + step)
|
||||
@@ -0,0 +1,415 @@
|
||||
extends Node3D
|
||||
## T-1088 3D locomotion sandbox root — boot, connect, poll/pump, local descriptor,
|
||||
## first-snapshot latch, component wiring (design §1.3). Sandbox-only file.
|
||||
##
|
||||
## The session-driver boilerplate below is a deliberate ~40-line copy from main.gd:
|
||||
## D-166 freezes main.gd until Phase 5, so each copied block carries a "Pattern:"
|
||||
## comment naming its source for the T-962 shared-driver extraction.
|
||||
##
|
||||
## Launch recipe (design §10.1):
|
||||
## T1: cd server && cargo run --bin settled-reach-server -- --test-mode
|
||||
## T2: SR_LIVE=1 ~/bin/godot4 --path client res://scenes/locomotion_sandbox.tscn
|
||||
|
||||
const MANIFEST_PATH := "res://assets/characters/manifest.json"
|
||||
|
||||
## Autopilot (design §10.2): sim-space heading per movement token (0 = East,
|
||||
## +PI/2 = South — the InputMapper.facing_angle convention, Y-down radians).
|
||||
const AUTOPILOT_HEADINGS := {
|
||||
"east": 0.0,
|
||||
"south": PI / 2.0,
|
||||
"west": PI,
|
||||
"north": -PI / 2.0,
|
||||
}
|
||||
## Hold time for pulsed discrete actions (stance_up/stance_down) — long enough
|
||||
## for the buffered InputEventAction press to reach InputMapper._unhandled_input.
|
||||
const AUTOPILOT_PULSE_S := 0.1
|
||||
|
||||
@onready var world_root: Node3D = $WorldRoot
|
||||
@onready var greybox: GreyboxWorld = $WorldRoot/Greybox
|
||||
@onready var player_rig: LocomotionRig = $WorldRoot/PlayerRig
|
||||
@onready var model_root: Node3D = $WorldRoot/PlayerRig/ModelRoot
|
||||
@onready var camera_rig: FollowCamera3D = $CameraRig
|
||||
|
||||
var character_visual: CharacterVisual = null
|
||||
## Gait state machine (design §6) — RefCounted, owned and driven by this root.
|
||||
var _anim: LocomotionAnim = null
|
||||
## Mouse-aim facing provider (design §9) — the installed Callable keeps it alive;
|
||||
## referenced here too so the seam's owner is greppable. Cleared in _exit_tree().
|
||||
var _aim_provider: SandboxMouseAimProvider = null
|
||||
var _first_snapshot_seen: bool = false
|
||||
var _gameplay_paused: bool = false # D-170: implant fullscreen occludes gameplay
|
||||
var _autopilot_spec: String = "" # design §10.2: raw SR_AUTOPILOT string (kept for debugging)
|
||||
var _autopilot_steps: Array[Dictionary] = [] # parsed segments, consumed FIFO by _autopilot_tick
|
||||
var _autopilot_active: bool = false # a segment is running (its timer is live)
|
||||
var _autopilot_timer: float = 0.0 # seconds left in the active segment (fixed-fps deltas)
|
||||
var _autopilot_action: String = "" # Input action held/pulsed by the active segment
|
||||
var _autopilot_kind: String = "" # "move" | "pulse" | "wait"
|
||||
var _autopilot_heading: float = NAN # scripted sim-space facing angle (NAN until first move)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# (1) SR_PORT honor — pattern: visual_capture.gd:79-88. A plain boot dials the
|
||||
# default 9876; the live harness starts the server with --port 0 and passes the
|
||||
# parsed port through SR_PORT.
|
||||
var port_env := OS.get_environment("SR_PORT")
|
||||
if not port_env.is_empty():
|
||||
SimBridge.server_port = int(port_env)
|
||||
# Guarded connect — pattern: main.gd:61-62. Handshake (D-192) + StartupMessage
|
||||
# are automatic in SimBridge's state machine.
|
||||
if SimBridge.state == SimBridge.ConnectionState.DISCONNECTED:
|
||||
SimBridge.connect_to_sim()
|
||||
|
||||
# (2)+(3) Local descriptor (GameState.character_visual_descriptor is never
|
||||
# populated — the sandbox builds its own from manifest.json), then CharacterVisual
|
||||
# IN-TREE BEFORE load_descriptor: its _ready() loads the toon/outline shaders;
|
||||
# out-of-tree the materials get null shaders. Pattern: character_creation.gd:386-388.
|
||||
character_visual = CharacterVisual.new()
|
||||
character_visual.name = "CharacterVisual"
|
||||
model_root.add_child(character_visual)
|
||||
character_visual.load_descriptor(_build_descriptor())
|
||||
# TRAP (design §2): never call character_visual.set_facing() — its octant table
|
||||
# assumes the 2D renderer's mirrored axis mapping (East -> -X). ModelRoot.rotation.y,
|
||||
# always via SandboxSpace.octant_to_yaw(), is the only facing authority here;
|
||||
# CharacterVisual's own rotation.y stays 0. The rig enforces this (locomotion_rig.gd).
|
||||
|
||||
# PlayerRig stays hidden (.tscn visible=false) until the first-snapshot latch.
|
||||
|
||||
# (3b) Rig provider adapters (design §4.0, §5): the rig reads zero autoloads —
|
||||
# these Callables are its only view of InputMapper/GameState. NPC adapters later
|
||||
# leave all three unset.
|
||||
player_rig.step_window_ms_provider = _step_window_ms
|
||||
player_rig.idle_facing_provider = _idle_facing_octant
|
||||
player_rig.suppression_provider = _input_suppressed
|
||||
# Teleport fan-out (design §4.1): camera hard-snaps its pivot; the anim machine
|
||||
# connects itself in setup() below (0.0-blend hard cut).
|
||||
player_rig.teleported.connect(_on_rig_teleported)
|
||||
|
||||
# (3c) Gait machine (design §6): slaved to the rig's motion channel, driving
|
||||
# CharacterVisual via the additive play_animation(name, blend) API.
|
||||
_anim = LocomotionAnim.new()
|
||||
_anim.setup(player_rig, character_visual)
|
||||
|
||||
# (4) InputMapper facing provider (design §9).
|
||||
_install_facing_provider()
|
||||
|
||||
# (5) D-170: the occlusion *mechanism* is CanvasItem-only, the signal is not —
|
||||
# a 3D scene connects it directly to a pause flag.
|
||||
HudGroups.gameplay_occluded.connect(_on_gameplay_occluded)
|
||||
|
||||
# (6) SR_AUTOPILOT (design §10.2) — deterministic capture input. Parsed here,
|
||||
# ticked per-frame after the first snapshot. While a script is loaded, a scripted
|
||||
# heading replaces the mouse-aim provider ON THE SAME SEAM (the mouse position is
|
||||
# nondeterministic under xvfb), so the genuine InputMapper octant-snap / SetFacing /
|
||||
# mouse-relative-WASD / throttle path still runs end-to-end.
|
||||
_autopilot_spec = OS.get_environment("SR_AUTOPILOT")
|
||||
_autopilot_steps = _parse_autopilot(_autopilot_spec)
|
||||
if not _autopilot_steps.is_empty():
|
||||
InputMapper.facing_angle_provider = Callable(self, "_autopilot_facing_angle")
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
# Clear the §9 seam so the 2D canvas-transform path resumes for any scene loaded
|
||||
# after this one; the provider's own guards make a stale install safe (NAN).
|
||||
InputMapper.facing_angle_provider = Callable()
|
||||
# Release any autopilot-held action so pressed state never leaks past the scene.
|
||||
if _autopilot_active:
|
||||
_autopilot_end_segment()
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
# Snapshot poll — pattern: main.gd:251-268; extract to a shared session driver in
|
||||
# Phase 5 (T-962). The first snapshot arrives here, never in _ready. Dispatch runs
|
||||
# BEFORE the latch so the rig's SNAP_FIRST has placed the player when the camera snaps.
|
||||
var snapshot: Variant = SimBridge.poll_snapshot()
|
||||
if snapshot != null:
|
||||
GameState.apply_snapshot(snapshot)
|
||||
_dispatch_snapshot()
|
||||
if not _first_snapshot_seen:
|
||||
_first_snapshot_latch()
|
||||
|
||||
# Input pump — pattern: main.gd:295-341. The only pump in the codebase lives in
|
||||
# main.gd; without this the sandbox connects but never moves (design §1.3).
|
||||
for entry in InputMapper.flush_queue():
|
||||
var action: int = entry.get("action", -1)
|
||||
if (
|
||||
action == InputMapper.Action.BUG_REPORT
|
||||
or action == InputMapper.Action.OPEN_JOURNAL
|
||||
or action == InputMapper.Action.OPEN_MENU
|
||||
):
|
||||
continue # client-only actions — no wire mapping (sim_bridge.gd:536-539)
|
||||
SimBridge.send_input(entry)
|
||||
|
||||
if _gameplay_paused:
|
||||
return # D-170: skip per-frame visual work while an implant app occludes
|
||||
|
||||
_per_frame_update(delta)
|
||||
_autopilot_tick(delta)
|
||||
|
||||
|
||||
# First-snapshot latch (design §1.3 step 3): the rig's SNAP_FIRST already placed
|
||||
# position + yaw (dispatch precedes the latch, and the rig snaps on its first
|
||||
# set_wire_target — entity_renderer.gd:133-140 first-appearance precedent); the
|
||||
# latch owns visibility and the camera snap.
|
||||
func _first_snapshot_latch() -> void:
|
||||
_first_snapshot_seen = true
|
||||
player_rig.visible = true
|
||||
_snap_camera_to_player()
|
||||
|
||||
|
||||
# Snapshot fan-out — direct consumer calls, no SnapshotEventRouter (design §13.7:
|
||||
# the router's registration lives in main.gd; two consumers don't justify touching it).
|
||||
func _dispatch_snapshot() -> void:
|
||||
greybox.on_snapshot()
|
||||
var pos := GameState.player_position
|
||||
player_rig.set_wire_target(
|
||||
SandboxSpace.wire_to_world(pos.x, pos.y),
|
||||
GameState.player_facing,
|
||||
GameState.player_stance,
|
||||
GameState.current_tick
|
||||
)
|
||||
|
||||
|
||||
# Local CharacterVisualDescriptor from the first valid manifest.json id per category
|
||||
# — no hardcoded id strings (design §1.3 step 2). Body type and skin tone keep
|
||||
# descriptor defaults; facial hair stays empty (empty string = none is a valid value,
|
||||
# not a manifest id).
|
||||
func _build_descriptor() -> CharacterVisualDescriptor:
|
||||
var manifest := _load_manifest()
|
||||
var descriptor := CharacterVisualDescriptor.new()
|
||||
var heads: Array = manifest.get("heads", [])
|
||||
if not heads.is_empty():
|
||||
descriptor.head_id = str(heads[0])
|
||||
var hair: Array = manifest.get("hair", [])
|
||||
if not hair.is_empty():
|
||||
descriptor.hair_id = str(hair[0])
|
||||
# Clothing manifest maps item_id -> {slot}; wear the first item declared per slot.
|
||||
var clothing: Variant = manifest.get("clothing", {})
|
||||
if clothing is Dictionary:
|
||||
for item_id: String in clothing:
|
||||
var slot := str((clothing[item_id] as Dictionary).get("slot", ""))
|
||||
if not slot.is_empty() and not descriptor.clothing_slots.has(slot):
|
||||
descriptor.clothing_slots[slot] = item_id
|
||||
return descriptor
|
||||
|
||||
|
||||
# Pattern: character_creation.gd:324-338 (manifest load).
|
||||
func _load_manifest() -> Dictionary:
|
||||
var file := FileAccess.open(MANIFEST_PATH, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("locomotion_sandbox: manifest not found at %s" % MANIFEST_PATH)
|
||||
return {}
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if parsed is Dictionary:
|
||||
return parsed as Dictionary
|
||||
push_warning("locomotion_sandbox: manifest parse failed — using empty defaults")
|
||||
return {}
|
||||
|
||||
|
||||
func _on_gameplay_occluded(occluded: bool) -> void:
|
||||
_gameplay_paused = occluded
|
||||
|
||||
|
||||
## True while an implant app occludes gameplay (D-170) — components consult this
|
||||
## instead of connecting to HudGroups themselves.
|
||||
func is_gameplay_paused() -> bool:
|
||||
return _gameplay_paused
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rig provider adapters (design §4.0, §5) — the player-side Callables installed
|
||||
# in _ready(). Each is the exact adapter shape the rig's doc comments name.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Step window for one confirmed step, ms — reads InputMapper.MOVE_INTERVAL_MS at
|
||||
# runtime (single source, never copied — design §13.8). 400 = the Walk default,
|
||||
# matching the rig's own fallback.
|
||||
func _step_window_ms(for_stance: String) -> int:
|
||||
return int(InputMapper.MOVE_INTERVAL_MS.get(for_stance, 400))
|
||||
|
||||
|
||||
# Idle facing (§5): the SAME snapped octant that rides the SetFacing wire, so the
|
||||
# model never shows an octant the server wasn't told.
|
||||
func _idle_facing_octant() -> String:
|
||||
return InputMapper.facing_octant
|
||||
|
||||
|
||||
# Suppression (§5): the exact condition under which InputMapper computes but does
|
||||
# not send octants (input_mapper.gd:78) — idle yaw freezes on it.
|
||||
func _input_suppressed() -> bool:
|
||||
return GameState.dialogue_active or GameState.free_camera_mode
|
||||
|
||||
|
||||
# Teleport (design §4.1/§7): hard camera-pivot snap, no glide. The anim machine's
|
||||
# 0.0-blend reset rides its own connection (LocomotionAnim.setup).
|
||||
func _on_rig_teleported(_pos_m: Vector3) -> void:
|
||||
camera_rig.snap_to_target()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wiring (design §14 groups B/C/D) — one function per attach point.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
## Input seam (design §9): install InputMapper.facing_angle_provider — unprojects
|
||||
## the mouse onto the y=0 plane, WorldRoot.to_local() (undoing the 45 degree map
|
||||
## rotation), delta from the rig's local position, atan2(delta.z, delta.x) sim
|
||||
## radians; NAN inside MOUSE_AIM_DEADZONE_M. Without it, 2D-canvas mouse math
|
||||
## makes facing garbage in a 3D scene and WASD unsteerable. CameraRig is a child,
|
||||
## so its _ready() (which adopts the Camera3D) has already run.
|
||||
func _install_facing_provider() -> void:
|
||||
_aim_provider = SandboxMouseAimProvider.new(camera_rig.get_camera(), world_root, player_rig)
|
||||
InputMapper.facing_angle_provider = Callable(_aim_provider, "get_facing_angle")
|
||||
|
||||
|
||||
## Per-frame cross-component work owned by the root, gated on the first snapshot
|
||||
## and the D-170 pause flag: the cutaway anchor (design §8 — the rig's INTERPOLATED
|
||||
## world XZ, so the cut zone glides and spatial smoothstep becomes temporal
|
||||
## smoothness) and the gait machine (after the rig's own _process interpolation).
|
||||
func _per_frame_update(delta: float) -> void:
|
||||
if not _first_snapshot_seen:
|
||||
return
|
||||
greybox.set_cutaway_char_pos(player_rig.global_position)
|
||||
_anim.update(delta)
|
||||
|
||||
|
||||
## Hard camera-pivot snap (design §7) — first-snapshot latch + teleport fan-out.
|
||||
func _snap_camera_to_player() -> void:
|
||||
camera_rig.snap_to_target()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Autopilot (S8, design §10.2) — deterministic capture input. SR_AUTOPILOT
|
||||
# (e.g. "east:2.0,south:1.5,stance_up,east:1.0") drives timed presses of the
|
||||
# REAL input actions — no SimBridge bypass, no test-only paths in the rig.
|
||||
# Movement tokens aim the token's heading through the facing seam and hold
|
||||
# "move_north" (W = toward aim under D-054 mouse-relative WASD); stance tokens
|
||||
# pulse an InputEventAction through the normal event pipeline.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Segment machine, ticked from _process. Gated on the first snapshot so
|
||||
# capture-time connection jitter never eats the schedule. Durations count
|
||||
# process deltas — deterministic frame counts under --fixed-fps.
|
||||
func _autopilot_tick(delta: float) -> void:
|
||||
if not _first_snapshot_seen:
|
||||
return
|
||||
if _autopilot_active:
|
||||
_autopilot_timer -= delta
|
||||
if _autopilot_timer > 0.0:
|
||||
return
|
||||
_autopilot_end_segment()
|
||||
if not _autopilot_steps.is_empty():
|
||||
_autopilot_begin_segment(_autopilot_steps.pop_front())
|
||||
|
||||
|
||||
# Parse "east:2.0,south:1.5,stance_up,east:1.0" into segment dicts. Movement
|
||||
# tokens (north/east/south/west:SECONDS) walk that sim direction; stance_up/
|
||||
# stance_down pulse once (optional :SECONDS hold); "wait:SECONDS" idles.
|
||||
# Unknown tokens warn and are skipped.
|
||||
func _parse_autopilot(spec: String) -> Array[Dictionary]:
|
||||
var steps: Array[Dictionary] = []
|
||||
for raw_token in spec.split(",", false):
|
||||
var parts := raw_token.strip_edges().split(":")
|
||||
var token := parts[0].strip_edges()
|
||||
var duration := parts[1].to_float() if parts.size() > 1 else 0.0
|
||||
if AUTOPILOT_HEADINGS.has(token):
|
||||
(
|
||||
steps
|
||||
. append(
|
||||
{
|
||||
"kind": "move",
|
||||
"action": "move_north", # W = forward = toward the scripted aim (D-054)
|
||||
"duration": maxf(duration, 0.0),
|
||||
"heading": AUTOPILOT_HEADINGS[token],
|
||||
}
|
||||
)
|
||||
)
|
||||
elif token == "stance_up" or token == "stance_down":
|
||||
(
|
||||
steps
|
||||
. append(
|
||||
{
|
||||
"kind": "pulse",
|
||||
"action": token,
|
||||
"duration": maxf(duration, AUTOPILOT_PULSE_S),
|
||||
"heading": NAN,
|
||||
}
|
||||
)
|
||||
)
|
||||
elif token == "wait":
|
||||
steps.append(
|
||||
{"kind": "wait", "action": "", "duration": maxf(duration, 0.0), "heading": NAN}
|
||||
)
|
||||
elif not token.is_empty():
|
||||
push_warning("locomotion_sandbox: unknown SR_AUTOPILOT token '%s'" % token)
|
||||
return steps
|
||||
|
||||
|
||||
func _autopilot_begin_segment(step: Dictionary) -> void:
|
||||
_autopilot_active = true
|
||||
_autopilot_timer = step["duration"]
|
||||
_autopilot_action = step["action"]
|
||||
_autopilot_kind = step["kind"]
|
||||
var heading: float = step["heading"]
|
||||
if is_finite(heading):
|
||||
_autopilot_heading = heading
|
||||
match _autopilot_kind:
|
||||
"move":
|
||||
# Held action state — InputMapper polls Input.is_action_pressed each frame.
|
||||
Input.action_press(_autopilot_action)
|
||||
"pulse":
|
||||
_autopilot_send_event(_autopilot_action, true)
|
||||
|
||||
|
||||
func _autopilot_end_segment() -> void:
|
||||
match _autopilot_kind:
|
||||
"move":
|
||||
Input.action_release(_autopilot_action)
|
||||
"pulse":
|
||||
_autopilot_send_event(_autopilot_action, false)
|
||||
_autopilot_active = false
|
||||
_autopilot_action = ""
|
||||
_autopilot_kind = ""
|
||||
|
||||
|
||||
# InputEventAction through the buffered input pipeline — reaches InputMapper's
|
||||
# _unhandled_input exactly like a keypress (Input.action_press only feeds the
|
||||
# polled is_action_pressed path, which discrete actions never read).
|
||||
func _autopilot_send_event(action_name: String, pressed: bool) -> void:
|
||||
var event := InputEventAction.new()
|
||||
event.action = action_name
|
||||
event.pressed = pressed
|
||||
Input.parse_input_event(event)
|
||||
|
||||
|
||||
# Scripted facing (installed on InputMapper.facing_angle_provider while an
|
||||
# autopilot script is loaded): the heading of the current/last movement segment,
|
||||
# NAN before the first one (facing keeps InputMapper's default). Everything
|
||||
# downstream — octant snap, SetFacing-on-change, WASD rotation — is unchanged.
|
||||
func _autopilot_facing_angle() -> float:
|
||||
return _autopilot_heading
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DebugHud duck contract (sandbox_debug_hud.gd) — the HUD probes this root for
|
||||
# the anim readouts; rig and greybox answer their own.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
## Clip currently driven by the gait machine; null until one plays (HUD shows "—").
|
||||
func get_current_clip() -> Variant:
|
||||
if _anim == null:
|
||||
return null
|
||||
var clip: StringName = _anim.get_current_clip()
|
||||
return null if clip == &"" else clip
|
||||
|
||||
|
||||
## Live AnimationPlayer speed_scale (cadence sync, design §6.2); null until the
|
||||
## CharacterVisual exists. Re-fetched every call — load_descriptor() recreates the player.
|
||||
func get_anim_speed_scale() -> Variant:
|
||||
if character_visual == null:
|
||||
return null
|
||||
var player := character_visual.get_animation_player()
|
||||
return null if player == null else player.speed_scale
|
||||
@@ -0,0 +1,98 @@
|
||||
class_name SandboxMouseAimProvider
|
||||
extends RefCounted
|
||||
## T-1088 (design §9) — mouse-aim facing provider for the 3D locomotion sandbox.
|
||||
## Installed by the sandbox root as InputMapper.facing_angle_provider:
|
||||
##
|
||||
## var provider := SandboxMouseAimProvider.new(camera, world_root, player_rig)
|
||||
## InputMapper.facing_angle_provider = Callable(provider, "get_facing_angle")
|
||||
##
|
||||
## Returns sim-space radians (0 = East, +PI/2 = South — the InputMapper.facing_angle
|
||||
## convention), or NAN for "no update" (deadzone / degenerate ray / freed nodes).
|
||||
## Everything downstream in InputMapper — octant snap, SetFacing-on-change,
|
||||
## mouse-relative WASD, dialogue suppression — runs unchanged; D-054 semantics
|
||||
## preserved exactly (only the octant ever crosses the wire).
|
||||
##
|
||||
## RefCounted, not Node: InputMapper pulls the value through the Callable each
|
||||
## frame, so the provider needs no tree presence or lifecycle of its own, and the
|
||||
## pure static core stays headless-testable (design §10.4). The installed Callable
|
||||
## keeps this RefCounted alive; the installer clears the seam back to Callable()
|
||||
## on scene exit, and the is_instance_valid guards below return NAN if the scene
|
||||
## nodes are freed first.
|
||||
##
|
||||
## Math (design §9): unproject the mouse to a ray, intersect the y=0 ground plane
|
||||
## in world space, convert through WorldRoot.to_local() (undoing the single D-148
|
||||
## 45° map rotation — WorldRoot-local space IS sim-aligned space, SandboxSpace §2),
|
||||
## take the delta from the rig's WorldRoot-local position, atan2(delta.z, delta.x).
|
||||
## A screen-space mouse angle is NOT a sim-space angle in the 3D scene (constant
|
||||
## ~45° skew from the map rotation plus up to ~19° ortho-pitch warp) — the ray
|
||||
## unprojection is the only correct D-054 path.
|
||||
|
||||
## Guard against a ray running (near-)parallel to the ground plane.
|
||||
const _RAY_PARALLEL_EPS := 0.000001
|
||||
|
||||
var _camera: Camera3D = null
|
||||
var _world_root: Node3D = null
|
||||
var _rig: Node3D = null
|
||||
|
||||
|
||||
func _init(camera: Camera3D, world_root: Node3D, rig: Node3D) -> void:
|
||||
_camera = camera
|
||||
_world_root = world_root
|
||||
_rig = rig
|
||||
|
||||
|
||||
## Callable target for InputMapper.facing_angle_provider. Gathers the live scene
|
||||
## state (mouse, camera ray, transforms) and defers to the pure static core.
|
||||
func get_facing_angle() -> float:
|
||||
if (
|
||||
not is_instance_valid(_camera)
|
||||
or not is_instance_valid(_world_root)
|
||||
or not is_instance_valid(_rig)
|
||||
):
|
||||
return NAN
|
||||
if not _camera.is_inside_tree() or not _world_root.is_inside_tree() or not _rig.is_inside_tree():
|
||||
return NAN
|
||||
var viewport := _camera.get_viewport()
|
||||
if viewport == null:
|
||||
return NAN
|
||||
var mouse_screen := viewport.get_mouse_position()
|
||||
return compute_facing_angle(
|
||||
_camera.project_ray_origin(mouse_screen),
|
||||
_camera.project_ray_normal(mouse_screen),
|
||||
_world_root.global_transform,
|
||||
_world_root.to_local(_rig.global_position),
|
||||
SandboxConstants.MOUSE_AIM_DEADZONE_M
|
||||
)
|
||||
|
||||
|
||||
## Pure math core (headless-testable, design §10.4): world-space mouse ray ->
|
||||
## y=0 ground-plane hit -> WorldRoot-local delta from the rig -> sim radians.
|
||||
## world_root_transform is WorldRoot's GLOBAL transform (local -> world);
|
||||
## rig_local_pos is the rig's position in WorldRoot-local metres.
|
||||
## Returns NAN when there is no meaningful aim point: ray parallel to the ground,
|
||||
## ground plane behind the ray, or hit inside the deadzone (mirrors the 2D
|
||||
## jitter guard in InputMapper._update_facing_from_mouse).
|
||||
static func compute_facing_angle(
|
||||
ray_origin: Vector3,
|
||||
ray_dir: Vector3,
|
||||
world_root_transform: Transform3D,
|
||||
rig_local_pos: Vector3,
|
||||
deadzone_m: float
|
||||
) -> float:
|
||||
# Ray -> y=0 ground plane, world space.
|
||||
if absf(ray_dir.y) < _RAY_PARALLEL_EPS:
|
||||
return NAN
|
||||
var t := -ray_origin.y / ray_dir.y
|
||||
if t < 0.0:
|
||||
return NAN
|
||||
var hit_world := ray_origin + ray_dir * t
|
||||
# Undo the 45° map rotation: WorldRoot-local axes are sim axes (SandboxSpace §2:
|
||||
# local +X = sim East, local +Z = sim South).
|
||||
var hit_local := world_root_transform.affine_inverse() * hit_world
|
||||
var delta := hit_local - rig_local_pos
|
||||
var planar := Vector2(delta.x, delta.z)
|
||||
if planar.length_squared() < deadzone_m * deadzone_m:
|
||||
return NAN
|
||||
# atan2(z, x) IS the sim convention (0 = East, +PI/2 = South) because local
|
||||
# +Z = sim +y (South, Y-down radians — server vision_cone.rs).
|
||||
return atan2(delta.z, delta.x)
|
||||
@@ -0,0 +1,117 @@
|
||||
class_name SandboxConstants
|
||||
extends RefCounted
|
||||
## T-1088 locomotion sandbox — every feel/tuning knob in one file.
|
||||
## § references point at the T-1088 final design (see ticket). Proto-production:
|
||||
## values migrate to Phase-5 homes at the T-962 gate.
|
||||
##
|
||||
## Stance move intervals are deliberately NOT duplicated here — read
|
||||
## InputMapper.MOVE_INTERVAL_MS at runtime (single source, no drift; design §13.8).
|
||||
|
||||
# -- Coordinate & scale (design §2) -------------------------------------------
|
||||
|
||||
## D-066/D-222 — THE convention: one sim TilePosition step = one 0.5 m subtile.
|
||||
## All sandbox positions and speeds are metres. See SandboxSpace for conversions.
|
||||
const SUBTILE_M := 0.5
|
||||
|
||||
# -- Greybox walls + cutaway (design §3, §8) -----------------------------------
|
||||
|
||||
## Wall height in metres — walls shorter than the ~1.7 m character read wrong.
|
||||
const WALL_H := 2.5
|
||||
## Cutaway stub height (m) camera-side walls drop to.
|
||||
const CUT_HEIGHT := 0.75
|
||||
## Cutaway half-disc radius (m) around the character.
|
||||
const CUT_RADIUS := 5.0
|
||||
## Cutaway smoothstep falloff band (m) at the disc edge.
|
||||
const CUT_BAND := 1.5
|
||||
|
||||
# -- Locomotion interpolation (design §4) --------------------------------------
|
||||
|
||||
# Stance intervals: NOT duplicated — read InputMapper.MOVE_INTERVAL_MS at runtime.
|
||||
|
||||
## Leg-speed clamp for multi-tile latest-wins deltas: close within ~one interval.
|
||||
const CATCHUP_MAX_FACTOR := 3.0
|
||||
## 5 subtiles — matches the 2D TELEPORT_DISTANCE_THRESHOLD (main.gd:3). Beyond this,
|
||||
## snap all channels (position, yaw, camera, 0.0-blend anim reset).
|
||||
const SNAP_DIST_M := 2.5
|
||||
## Walk↔idle flicker hysteresis (§4.2): is_moving stays true until at-target this long.
|
||||
const IDLE_ENTER_DELAY_S := 0.18
|
||||
|
||||
# -- Facing / turn smoothing (design §5, D-151) ---------------------------------
|
||||
|
||||
## Yaw ease rate (1/s): yaw = lerp_angle(yaw, target, 1 - exp(-TURN_SHARPNESS * delta)).
|
||||
const TURN_SHARPNESS := 14.0
|
||||
## Per-stance yaw budget clamp, degrees/second.
|
||||
const TURN_BUDGET_DEG := {
|
||||
"Sprint": 1080.0,
|
||||
"Walk": 720.0,
|
||||
"Careful": 540.0,
|
||||
"Crouch": 420.0,
|
||||
"Idle": 600.0,
|
||||
}
|
||||
|
||||
# -- Animation (design §6) -------------------------------------------------------
|
||||
|
||||
## Gait table (§6.1) — the ONLY place animation clip strings live.
|
||||
## Clip names are the imported bare names: library "", "_Loop" stripped by the glTF
|
||||
## importer, case-sensitive (design-input §2.2). Careful = Walk_Formal (D-053 stance
|
||||
## readability at ortho distance; fallback if it reads "parade march": Walk at 0.6x —
|
||||
## one table cell). Jog_Fwd is the reserve if Sprint reads too aggressive at 2.5 m/s.
|
||||
const GAIT_CLIP := {
|
||||
"Sprint": {"idle": &"Idle", "moving": &"Sprint"},
|
||||
"Walk": {"idle": &"Idle", "moving": &"Walk"},
|
||||
"Careful": {"idle": &"Idle", "moving": &"Walk_Formal"},
|
||||
"Crouch": {"idle": &"Crouch_Idle", "moving": &"Crouch_Fwd"},
|
||||
}
|
||||
|
||||
## Native clip ground speed (m/s) for cadence sync (§6.2):
|
||||
## speed_scale = clamp(rig_speed / NATIVE_MPS[clip], SPEED_SCALE_CLAMP.x, .y).
|
||||
## Initial guesses — tuned live against the DebugHud readout.
|
||||
const NATIVE_MPS := {"Walk": 1.4, "Walk_Formal": 1.2, "Sprint": 3.2, "Crouch_Fwd": 0.9}
|
||||
const SPEED_SCALE_CLAMP := Vector2(0.6, 1.8)
|
||||
|
||||
## Cross-fade seconds by transition kind (§6.3); teleport is a hard cut — a
|
||||
## cross-map jump must not smear.
|
||||
const BLEND := {
|
||||
"idle_to_gait": 0.12,
|
||||
"gait_to_idle": 0.20,
|
||||
"gait_to_gait": 0.15,
|
||||
"crouch": 0.25,
|
||||
"teleport": 0.0,
|
||||
}
|
||||
|
||||
# -- Camera (design §7; D-148, D-015, D-158) -------------------------------------
|
||||
|
||||
## Pitch presets in degrees from horizontal — D-148 preset list is authoritative;
|
||||
## the spike's -45 iso preset is struck. T-key cycles (sanctioned dev affordance).
|
||||
const CAM_PITCH_PRESETS := [-30.0, -5.0, -80.0]
|
||||
const CAM_DIST := 30.0
|
||||
const CAM_ORTHO_SIZE := 9.0
|
||||
## Exponential pivot-follow rate (1/s) — deliberately the ONLY soft layer (§0).
|
||||
const CAM_FOLLOW_RATE := 6.0
|
||||
## Preset tilt lerp rate (1/s).
|
||||
const CAM_TILT_RATE := 3.0
|
||||
## Follow lookahead knob for the tuning pass (seconds of velocity lead).
|
||||
const LOOKAHEAD_S := 0.0
|
||||
|
||||
# -- Scene (design §1.2) -----------------------------------------------------------
|
||||
|
||||
## WorldRoot yaw — the single static D-148 map-rotation Transform3D. Set once in
|
||||
## locomotion_sandbox.tscn; sim coords stay axis-aligned inside WorldRoot and the
|
||||
## camera never rotates.
|
||||
const MAP_ROTATION_DEG := 45.0
|
||||
|
||||
# -- Greybox tints (design §3.2) -----------------------------------------------------
|
||||
|
||||
## Four-state visibility tint: Color values are used as instance color directly;
|
||||
## float values multiply albedo value (85% / 70%).
|
||||
const TINTS := {
|
||||
"forward": Color.WHITE,
|
||||
"peripheral": 0.85,
|
||||
"boundary": 0.70,
|
||||
"remembered": Color(0.42, 0.45, 0.52),
|
||||
}
|
||||
|
||||
# -- Input (design §9) ---------------------------------------------------------------
|
||||
|
||||
## Ground-plane deadzone (m) for the mouse facing provider — mirrors the 2D jitter guard.
|
||||
const MOUSE_AIM_DEADZONE_M := 0.1
|
||||
@@ -0,0 +1,98 @@
|
||||
extends CanvasLayer
|
||||
## T-1088 numeric locomotion instrumentation HUD (design §10.1): tick, tick_rate,
|
||||
## stance, player tile, render speed, clip + speed_scale, snapshot age, yaw
|
||||
## target/actual, store size. Sandbox-only. Cadence sync is verifiable numerically
|
||||
## on screen, not just by eye — tuning NATIVE_MPS is a read-the-HUD job.
|
||||
##
|
||||
## All component reads go through has_method guards so the HUD works from the S1
|
||||
## skeleton and lights up ("—" -> numbers) as later slices land. Duck-typed contract:
|
||||
## PlayerRig (S4): get_render_speed() -> float (m/s),
|
||||
## get_yaw_target_rad() -> float
|
||||
## PlayerRig or root (S6): get_current_clip() -> StringName,
|
||||
## get_anim_speed_scale() -> float
|
||||
## Greybox (S3): get_store_size() -> int
|
||||
|
||||
const FONT_SIZE := 13
|
||||
|
||||
@onready var _rig: Node = get_node_or_null("../WorldRoot/PlayerRig")
|
||||
@onready var _model_root: Node3D = get_node_or_null("../WorldRoot/PlayerRig/ModelRoot")
|
||||
@onready var _greybox: Node = get_node_or_null("../WorldRoot/Greybox")
|
||||
|
||||
var _label: Label = null
|
||||
var _last_tick: int = -1
|
||||
var _last_snapshot_msec: int = -1
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
layer = 10
|
||||
_label = Label.new()
|
||||
_label.name = "Readout"
|
||||
_label.position = Vector2(8, 8)
|
||||
_label.add_theme_font_size_override("font_size", FONT_SIZE)
|
||||
_label.add_theme_color_override("font_color", Color(0.85, 0.92, 1.0))
|
||||
_label.add_theme_color_override("font_outline_color", Color(0.0, 0.0, 0.0, 0.9))
|
||||
_label.add_theme_constant_override("outline_size", 4)
|
||||
add_child(_label)
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
# Snapshot age: a consumed-tick change is the arrival signal (world_renderer.gd:33-36
|
||||
# tick-gate pattern). SimBridge delivery is latest-wins, so consumption time is the
|
||||
# honest age reference for interpolation debugging.
|
||||
if GameState.current_tick != _last_tick:
|
||||
_last_tick = GameState.current_tick
|
||||
_last_snapshot_msec = Time.get_ticks_msec()
|
||||
_label.text = _build_text()
|
||||
|
||||
|
||||
func _build_text() -> String:
|
||||
var lines := PackedStringArray()
|
||||
var rate := str(GameState.game_time.get("tick_rate", "—"))
|
||||
lines.append("tick %d rate %s snap age %s" % [GameState.current_tick, rate, _age_text()])
|
||||
|
||||
var tile := SandboxSpace.wire_to_tile(GameState.player_position.x, GameState.player_position.y)
|
||||
lines.append("stance %s tile (%d, %d)" % [GameState.player_stance, tile.x, tile.y])
|
||||
|
||||
var anim_sources: Array = [_rig, get_parent()]
|
||||
lines.append(
|
||||
"speed %s m/s clip %s scale %s"
|
||||
% [
|
||||
_fmt(_call_first([_rig], "get_render_speed"), "%.3f"),
|
||||
_fmt(_call_first(anim_sources, "get_current_clip"), "%s"),
|
||||
_fmt(_call_first(anim_sources, "get_anim_speed_scale"), "%.2f"),
|
||||
]
|
||||
)
|
||||
|
||||
var yaw_target: Variant = _call_first([_rig], "get_yaw_target_rad")
|
||||
if yaw_target != null:
|
||||
yaw_target = rad_to_deg(float(yaw_target))
|
||||
var yaw_actual: Variant = null
|
||||
if _model_root != null:
|
||||
yaw_actual = rad_to_deg(_model_root.rotation.y)
|
||||
lines.append(
|
||||
"yaw target %s actual %s" % [_fmt(yaw_target, "%.1f deg"), _fmt(yaw_actual, "%.1f deg")]
|
||||
)
|
||||
|
||||
lines.append("store %s tiles" % _fmt(_call_first([_greybox], "get_store_size"), "%d"))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
func _age_text() -> String:
|
||||
if _last_snapshot_msec < 0:
|
||||
return "—"
|
||||
return "%.3f s" % (float(Time.get_ticks_msec() - _last_snapshot_msec) / 1000.0)
|
||||
|
||||
|
||||
# First answer from the candidate nodes implementing `method`, else null.
|
||||
static func _call_first(nodes: Array, method: String) -> Variant:
|
||||
for node in nodes:
|
||||
if node != null and (node as Object).has_method(method):
|
||||
return (node as Object).call(method)
|
||||
return null
|
||||
|
||||
|
||||
# Format a duck-typed value; "—" until the providing component lands.
|
||||
static func _fmt(value: Variant, format: String) -> String:
|
||||
if value == null:
|
||||
return "—"
|
||||
return format % value
|
||||
@@ -0,0 +1,100 @@
|
||||
class_name SandboxSpace
|
||||
extends RefCounted
|
||||
## THE conversion authority between sim tile space and sandbox world metres
|
||||
## (T-1088 design §2). Every coordinate/angle conversion in the locomotion sandbox
|
||||
## goes through this file — nothing else converts.
|
||||
##
|
||||
## THE CONVENTION (D-066/D-222):
|
||||
## - One sim TilePosition step = one 0.5 m subtile. All sandbox positions/speeds
|
||||
## are metres.
|
||||
## - Sim +x (East) -> local +X; sim +y (South, Y-down) -> local +Z; sim z-level
|
||||
## -> Y (Gauntlet is single-level z=0 -> Y=0; no metre scale exists for z-levels
|
||||
## yet — no stair mechanic).
|
||||
## - Tile index -> world: Vector3((t.x + 0.5) * 0.5, 0, (t.y + 0.5) * 0.5).
|
||||
## - Sim facing angle theta (0=East, +PI/2=South, Y-down radians — server
|
||||
## vision_cone.rs and InputMapper.facing_angle) -> ModelRoot yaw = PI/2 - theta.
|
||||
## - D-243 "voxel = 1 m" is generation-cascade vocabulary and appears nowhere here;
|
||||
## the 1 m visual tile (D-066) exists only as major grid lines in the floor shader.
|
||||
##
|
||||
## Wire trap: live mode sends tile-center floats (N + 0.5); TestHarness sends
|
||||
## integers (N). Always floori() the wire float to recover the tile index, then
|
||||
## recompute the center — byte-identical in both modes. NEVER round().
|
||||
##
|
||||
## Facing trap: CharacterVisual.set_facing() is never called from sandbox code.
|
||||
## Its internal table (west=+90, east=-90 — character_visual.gd:184-193) assumes a
|
||||
## mirrored axis mapping (East -> -X) incompatible with this convention; using it
|
||||
## makes the model face west while walking east. The rig owns ModelRoot.rotation.y
|
||||
## exclusively, always through octant_to_yaw() below.
|
||||
|
||||
## Sim facing octant -> sim angle theta (Y-down radians: 0 = East, +PI/2 = South).
|
||||
const OCTANT_TO_SIM_ANGLE := {
|
||||
"East": 0.0,
|
||||
"Southeast": PI / 4.0,
|
||||
"South": PI / 2.0,
|
||||
"Southwest": 3.0 * PI / 4.0,
|
||||
"West": PI,
|
||||
"Northwest": -3.0 * PI / 4.0,
|
||||
"North": -PI / 2.0,
|
||||
"Northeast": -PI / 4.0,
|
||||
}
|
||||
|
||||
|
||||
## Tile index -> world metres at the subtile center (+0.25 m on each axis).
|
||||
static func tile_to_world(tile: Vector3i) -> Vector3:
|
||||
return Vector3(
|
||||
(float(tile.x) + 0.5) * SandboxConstants.SUBTILE_M,
|
||||
0.0, # Gauntlet is single-level z=0; z-level -> Y has no metre scale yet
|
||||
(float(tile.y) + 0.5) * SandboxConstants.SUBTILE_M
|
||||
)
|
||||
|
||||
|
||||
## Recover the integer tile index from wire floats. Live wire sends tile centers
|
||||
## (N + 0.5); TestHarness sends integers (N) — floori handles both identically.
|
||||
## NEVER round(): round(N + 0.5) lands on the wrong tile.
|
||||
static func wire_to_tile(wire_x: float, wire_y: float, z: int = 0) -> Vector3i:
|
||||
return Vector3i(floori(wire_x), floori(wire_y), z)
|
||||
|
||||
|
||||
## Wire floats -> world metres: floori to the tile index, then recompute the center.
|
||||
static func wire_to_world(wire_x: float, wire_y: float, z: int = 0) -> Vector3:
|
||||
return tile_to_world(wire_to_tile(wire_x, wire_y, z))
|
||||
|
||||
|
||||
## World metres -> containing tile index (inverse of tile_to_world for any point
|
||||
## inside the tile, not just the center).
|
||||
static func world_to_tile(world: Vector3, z: int = 0) -> Vector3i:
|
||||
return Vector3i(
|
||||
floori(world.x / SandboxConstants.SUBTILE_M),
|
||||
floori(world.z / SandboxConstants.SUBTILE_M),
|
||||
z
|
||||
)
|
||||
|
||||
|
||||
## Octant string -> sim angle theta. Unknown octants warn and default to North
|
||||
## (the GameState.player_facing default).
|
||||
static func octant_to_sim_angle(octant: String) -> float:
|
||||
if not OCTANT_TO_SIM_ANGLE.has(octant):
|
||||
push_warning("SandboxSpace: unknown octant '%s' — defaulting to North" % octant)
|
||||
return -PI / 2.0
|
||||
return float(OCTANT_TO_SIM_ANGLE[octant])
|
||||
|
||||
|
||||
## Sim angle theta -> ModelRoot yaw in WorldRoot-local space: yaw = PI/2 - theta,
|
||||
## wrapped to (-PI, PI] so North stays +PI. Verified against Godot +yaw semantics
|
||||
## (+90 deg yaw turns a +Z-facing model to +X): theta=PI/2 (South) -> 0 -> +Z;
|
||||
## theta=0 (East) -> +PI/2 -> +X; theta=PI (West) -> -PI/2 -> -X;
|
||||
## theta=-PI/2 (North) -> PI -> -Z.
|
||||
static func sim_angle_to_yaw(theta: float) -> float:
|
||||
var yaw := PI / 2.0 - theta
|
||||
while yaw > PI:
|
||||
yaw -= TAU
|
||||
while yaw <= -PI:
|
||||
yaw += TAU
|
||||
return yaw
|
||||
|
||||
|
||||
## Octant string -> ModelRoot yaw radians. Golden table (T-1088 design §2, verified):
|
||||
## South 0, Southeast 45, East +90, Northeast 135, North 180, Northwest -135,
|
||||
## West -90, Southwest -45 (degrees).
|
||||
static func octant_to_yaw(octant: String) -> float:
|
||||
return sim_angle_to_yaw(octant_to_sim_angle(octant))
|
||||
@@ -0,0 +1,55 @@
|
||||
shader_type spatial;
|
||||
render_mode diffuse_lambert, specular_disabled;
|
||||
|
||||
// T-1088 greybox floor (design §3.3) — sandbox-only.
|
||||
//
|
||||
// Per-instance visibility tint x world-space grid lines. The MultiMesh instance
|
||||
// color (use_colors, GreyboxWorld) arrives as the COLOR built-in and multiplies
|
||||
// albedo: white = Forward, 85% = Peripheral, 70% = BoundaryWall, dim cool grey =
|
||||
// Remembered (SandboxConstants.TINTS — §3.2).
|
||||
//
|
||||
// Grid: data is per-0.5 m subtile (D-066/D-222) but the floor must READ as 1 m
|
||||
// visual tiles (2x2 subtiles) — minor lines every 0.5 m (alpha 0.15), major
|
||||
// lines every 1.0 m (alpha 0.45). Lines are sampled in SIM space (u_sim_from_world
|
||||
// undoes the 45 deg WorldRoot rotation) so they land on tile edges; sampling raw
|
||||
// world space would draw the grid rotated 45 deg against the tiles. Continuous
|
||||
// across instances because sim space is shared. D-243's "voxel = 1 m" is
|
||||
// generation-cascade vocabulary — the 1 m visual tile exists ONLY as the major
|
||||
// lines here (§2).
|
||||
|
||||
uniform vec3 u_base_albedo : source_color = vec3(0.52, 0.54, 0.58);
|
||||
uniform vec3 u_line_albedo : source_color = vec3(0.10, 0.11, 0.14);
|
||||
uniform float u_minor_spacing_m = 0.5;
|
||||
uniform float u_major_spacing_m = 1.0;
|
||||
uniform float u_minor_alpha = 0.15;
|
||||
uniform float u_major_alpha = 0.45;
|
||||
uniform float u_line_half_width_m = 0.02;
|
||||
// World -> sim-space transform; set by GreyboxWorld from its own inverse global
|
||||
// transform so the grid rotates WITH the tiles under the WorldRoot 45 deg.
|
||||
uniform mat4 u_sim_from_world = mat4(1.0);
|
||||
|
||||
varying vec2 v_world_xz;
|
||||
|
||||
void vertex() {
|
||||
// MODEL_MATRIX folds in the per-instance MultiMesh transform (incl. WorldRoot
|
||||
// rotation); u_sim_from_world undoes the scene rotation back to sim axes.
|
||||
v_world_xz = (u_sim_from_world * MODEL_MATRIX * vec4(VERTEX, 1.0)).xz;
|
||||
}
|
||||
|
||||
// 1.0 on a grid line of the given spacing, 0.0 off it, one-fwidth AA edge.
|
||||
float line_mask(vec2 p, float spacing, float half_width) {
|
||||
vec2 dxy = abs(fract(p / spacing + 0.5) - 0.5) * spacing; // metres to nearest line
|
||||
float d = min(dxy.x, dxy.y);
|
||||
float aa = fwidth(d);
|
||||
return 1.0 - smoothstep(half_width - aa, half_width + aa, d);
|
||||
}
|
||||
|
||||
void fragment() {
|
||||
float minor = line_mask(v_world_xz, u_minor_spacing_m, u_line_half_width_m) * u_minor_alpha;
|
||||
float major = line_mask(v_world_xz, u_major_spacing_m, u_line_half_width_m) * u_major_alpha;
|
||||
float line_a = max(minor, major); // major wins where the grids coincide
|
||||
vec3 tinted = u_base_albedo * COLOR.rgb;
|
||||
// Line color is tinted too, so remembered/peripheral tiles keep dim lines.
|
||||
ALBEDO = mix(tinted, u_line_albedo * COLOR.rgb, line_a);
|
||||
ROUGHNESS = 1.0;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
shader_type spatial;
|
||||
render_mode diffuse_lambert, specular_disabled;
|
||||
|
||||
// T-1088 greybox wall + camera-side cutaway (design §8) — technique
|
||||
// proto-production.
|
||||
//
|
||||
// COLOR-multiplied albedo (same four-state tint contract as the floor shader)
|
||||
// plus a smoothstep height cut: a half-disc of camera-side wall around the
|
||||
// character drops to a CUT_HEIGHT stub with smooth radial falloff — the
|
||||
// Xenonauts read, minus per-building logic the greybox doesn't have. The
|
||||
// cutaway cuts RENDER HEIGHT; the tint gates KNOWLEDGE — independent factors,
|
||||
// so remembered walls can be cut too (the player's camera doesn't care what the
|
||||
// character currently sees). Open box tops are acceptable greybox fidelity;
|
||||
// ghost band / dither / capping are named polish on this same shader.
|
||||
//
|
||||
// u_char_pos_xz is the rig's INTERPOLATED world XZ, pushed once per frame
|
||||
// (GreyboxWorld.set_cutaway_char_pos) — the cut zone glides with the character,
|
||||
// so spatial smoothstep becomes temporal smoothness. Parked far away by default:
|
||||
// no cut until the first push.
|
||||
//
|
||||
// Camera-side predicate: the camera has yaw 0 and looks world -Z (the diamond
|
||||
// view is WorldRoot's 45 deg rotation, never the camera's — D-148), so camera
|
||||
// ground-forward is the constant vec2(0, -1) in world space. A wall fragment
|
||||
// sits between camera and character when to_wall.y > 0; the test runs in world
|
||||
// space, so it stays correct under the WorldRoot rotation.
|
||||
//
|
||||
// Geometry uniforms default to the SandboxConstants values; GreyboxWorld pushes
|
||||
// them at material build so SandboxConstants stays the single source (§12).
|
||||
|
||||
uniform vec2 u_char_pos_xz = vec2(1000000.0, 1000000.0);
|
||||
uniform float u_wall_h = 2.5; // SandboxConstants.WALL_H
|
||||
uniform float u_cut_height = 0.75; // SandboxConstants.CUT_HEIGHT — the stub
|
||||
uniform float u_cut_radius = 5.0; // SandboxConstants.CUT_RADIUS — half-disc radius
|
||||
uniform float u_cut_band = 1.5; // SandboxConstants.CUT_BAND — radial falloff band
|
||||
uniform vec3 u_base_albedo : source_color = vec3(0.66, 0.64, 0.60);
|
||||
|
||||
varying vec3 v_world_pos;
|
||||
|
||||
void vertex() {
|
||||
// MODEL_MATRIX folds in the per-instance MultiMesh transform.
|
||||
v_world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
|
||||
}
|
||||
|
||||
void fragment() {
|
||||
vec2 to_wall = v_world_pos.xz - u_char_pos_xz;
|
||||
// Soft camera-side gate: fully cut from 0.75 m on the camera side of the
|
||||
// character, fully solid from 0.25 m on the far side — no hard seam.
|
||||
float side_f = smoothstep(-0.25, 0.75, to_wall.y);
|
||||
// Radial falloff: full cut inside (radius - band), fading to none at radius.
|
||||
float rad_f = 1.0 - smoothstep(u_cut_radius - u_cut_band, u_cut_radius, length(to_wall));
|
||||
float allowed = mix(u_wall_h, u_cut_height, side_f * rad_f);
|
||||
if (v_world_pos.y > allowed) {
|
||||
discard;
|
||||
}
|
||||
ALBEDO = u_base_albedo * COLOR.rgb;
|
||||
ROUGHNESS = 1.0;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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/).
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user