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