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 ## T-1088 click-to-move (Live-session feedback item 2): the mouse-over path ## preview (Node3D under WorldRoot) and the path follower (RefCounted, ticked ## per frame). Installed in _ready(); the RMB gesture handling is in _unhandled_input. var _path_preview: PathPreview = null var _path_follower: PathFollower = null ## T-1088 follow-facing: layered head/torso look-at (active during follows) and ## its shared aim-target node (WorldRoot-local, positioned at the mouse ground ## point each frame). var _head_look: SandboxHeadLook = null var _head_aim_target: Node3D = null ## Time (ms) a non-double RMB press began, -1 = none pending. Resolves click vs ## long-press on RELEASE (RMB_LONG_PRESS_MS threshold). var _rmb_press_msec: int = -1 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) # (3d) Follow-facing look-at (T-1088): aim target under WorldRoot (its local # space IS the sim-aligned space ground_hit_local returns) + the modifier # pair on the freshly-built skeleton. _head_aim_target = Node3D.new() _head_aim_target.name = "HeadAimTarget" world_root.add_child(_head_aim_target) _head_look = SandboxHeadLook.new() _head_look.setup(character_visual.get_skeleton(), _head_aim_target) # (4) InputMapper facing provider (design §9). _install_facing_provider() # (4b) T-1088 click-to-move (Live-session feedback item 2): hover path preview # + follower. See _install_click_to_move. _install_click_to_move() # (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() # T-1088: a teleport invalidates any in-flight click-to-move and stale preview # (design item 2 cancel condition — the planned route no longer applies). if _path_follower != null: _path_follower.cancel() if _path_preview != null: _path_preview.clear() # --------------------------------------------------------------------------- # 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") ## Click-to-move seam (T-1088 Live-session feedback item 2): the mouse-over path ## preview — a Node3D under WorldRoot so it inherits the 45° map rotation for free — ## and the path follower (RefCounted, ticked from _per_frame_update). Both plan over ## the greybox KNOWN store via _is_floor and honour the information boundary (fog is ## unpathable). The RMB commit is handled in _unhandled_input; nothing crosses a new ## wire — the follower streams ordinary Move* steps through InputMapper's throttle. func _install_click_to_move() -> void: _path_preview = PathPreview.new() _path_preview.name = "PathPreview" world_root.add_child(_path_preview) _path_preview.setup(camera_rig.get_camera(), world_root) _path_follower = PathFollower.new() ## RMB gesture vocabulary for click-to-move (design item 2 + sprint/crouch additions). ## RMB, not LMB: no mouse button is bound in the input map (movement is WASD, interact ## is E), so both are free — RMB is the tactical-genre move-here idiom and leaves LMB ## open for the future click-to-interact/select verb. Read raw here (no project.godot ## action) so the binding stays sandbox-local and the shared input map is untouched. ## Single click (press, quick release) → walk the path at the current stance. ## Double click (double_click press) → sprint-there: UPGRADE the in-flight ## single-click follow in place (Godot delivers the double's first press as an ## ordinary click that already started it), or start a fresh sprint follow. ## Long press (held ≥ RMB_LONG_PRESS_MS, committed on RELEASE) → go there, then ## Crouch on arrival ("take cover" input-vocabulary prototype only — real cover ## mechanics are future combat design). ## Click vs long-press can only be told apart on release, so the single click commits ## on RELEASE (a few ms later — imperceptible); the double still commits on its press. ## The path used is always the preview's current path (the hover tile at that instant). func _unhandled_input(event: InputEvent) -> void: if not (event is InputEventMouseButton): return var mb := event as InputEventMouseButton if mb.button_index != MOUSE_BUTTON_RIGHT: return # Any state where a commit is invalid drops the pending press — a gesture # interrupted by pause / dialogue / free-camera is abandoned. var can_commit := ( _first_snapshot_seen and not _gameplay_paused and not _input_suppressed() and _path_preview != null and _path_follower != null ) if not can_commit: _rmb_press_msec = -1 return if mb.pressed: if mb.double_click: _rmb_press_msec = -1 # this gesture resolved as a double — no click/long-press if _path_follower.is_active(): _path_follower.upgrade_to_sprint(GameState.player_stance) else: _path_follower.start_sprint( _path_preview.get_current_path(), GameState.player_stance ) else: _rmb_press_msec = Time.get_ticks_msec() # defer: decide on release else: if _rmb_press_msec < 0: return # release of a double's press (or nothing pending) — ignore var held := Time.get_ticks_msec() - _rmb_press_msec _rmb_press_msec = -1 if held >= SandboxConstants.RMB_LONG_PRESS_MS: _path_follower.start_crouch(_path_preview.get_current_path()) else: _path_follower.start(_path_preview.get_current_path()) get_viewport().set_input_as_handled() ## Greybox known-floor lookup (the PathFinder / follower / preview substrate): ## true only for tiles the store KNOWS to be floor (doors collapse to FLOOR on the ## wire). Unknown / never-seen / wall are impassable — fog is unpathable (D-010). func _is_floor(tile: Vector3i) -> bool: return greybox.store.has_tile(tile) and greybox.store.kind_of(tile) == GreyboxWorld.Store.Kind.FLOOR ## 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 # Corridor reach: how far camera-side a WALL_H wall can still occlude the # character in ortho at the current (smoothed) pitch. Clamped: the -5 deg # frontal preset would otherwise ask for a ~29 m corridor. var pitch_abs: float = maxf(absf(camera_rig.pitch_deg()), 5.0) var reach: float = clampf( SandboxConstants.WALL_H / tan(deg_to_rad(pitch_abs)), 0.5, 16.0 ) greybox.set_cutaway_char_pos(player_rig.global_position, reach) _anim.update(delta) # T-1088 click-to-move (design item 2): hover preview + path follower. Both plan # from the server-confirmed character tile so a plan starts from ground truth, # not an optimistic guess. Under input suppression (dialogue / free camera) the # preview hides and any walk is cancelled — the same condition that freezes idle # facing. Recompute inside the preview is edge-triggered, so this is cheap. var char_tile := SandboxSpace.wire_to_tile(GameState.player_position.x, GameState.player_position.y) var is_floor := Callable(self, "_is_floor") if _input_suppressed(): _path_preview.clear() _path_follower.cancel() else: _path_preview.update(char_tile, is_floor, greybox.store.size()) _path_follower.tick(char_tile, is_floor) # D-252 view/movement split: the body always walks the leg direction (rig); # the mouse is the VIEW — server cone via SetFacing, and the layered # head/torso look-at whenever the character is in motion (WASD or follow). var looking := (_path_follower.is_active() or player_rig.is_moving) _head_look.set_active(looking and not _input_suppressed()) _update_head_aim_target() _head_look.update(delta) ## Position the shared look-at target at the mouse's ground point (WorldRoot- ## local — the same sim-aligned space the aim provider returns) at eye height. func _update_head_aim_target() -> void: var cam := get_viewport().get_camera_3d() if cam == null or _head_aim_target == null: return var mouse := get_viewport().get_mouse_position() var hit: Variant = SandboxMouseAimProvider.ground_hit_local( cam.project_ray_origin(mouse), cam.project_ray_normal(mouse), world_root.global_transform ) if hit != null: var p: Vector3 = hit p.y = SandboxConstants.HEAD_LOOK_EYE_HEIGHT_M _head_aim_target.position = p ## 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