D-252 (new record, amends D-054/D-249, resolves Q-084's walk-vs-aim split): Facing is view-only; movement no longer writes it. Client side: the rig's moving-body yaw now always derives from leg velocity (the wire octant is the VIEW and must never rotate the body — the follow-only commit flag generalizes and disappears); the layered head/torso look-at runs during any movement, WASD included; the ~100ms post-step re-assert mitigation is removed as dead (server-side facing_from_delta removal lands separately). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
572 lines
24 KiB
GDScript
572 lines
24 KiB
GDScript
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
|