extends Node # Semantic actions — NO raw key codes cross the bridge # Movement uses hold-to-move (polled each frame in _process). # Discrete actions (interact, stance, etc.) use press events (_unhandled_input). # # D-054: Mouse-relative facing and movement. # Mouse position determines facing direction (client-side float). # WASD is relative to facing: W = toward cursor, S = away, A/D = strafe. # Server receives facing octant only — the full float stays client-side. # # Movement throttle: client-side rate limit per stance (D-053). # Sprint=5/s, Walk=2.5/s, Careful=1.7/s, Crouch=1.25/s. # Server cooldown is authoritative, but the client throttle prevents # flooding and gives correct movement feel in test mode. # # T-1146: implant-occlusion gate. Every gameplay-sim-bound action this file can # produce (movement, SET_FACING, and every _unhandled_input entry EXCEPT # OPEN_MENU/BUG_REPORT/OPEN_JOURNAL, which are client-only and never reach # send_input — see main.gd's dispatch loop) is gated on # HudGroups.gameplay_occluded, at the POLL SITE (here), not the flush site # (main.gd). Poll-site was T-1146's own recommendation and is also the # structurally cleaner one: it slots into the existing dialogue_active / # free_camera_mode early-return this file already has, and it means nothing is # ever enqueued while occluded — a flush-site gate would still enqueue and then # have to discard, which is exactly the kind of latent backlog this ticket is # about. Movement is HELD-STATE polled (Input.is_action_pressed), so an # occlusion-begin transition must be able to interrupt a key already down # mid-press, and an occlusion-end transition must NOT silently resume a key # still down — see _on_gameplay_occluded and _suppress_move_until_release # below. Discrete actions (_unhandled_input) are edge-triggered per-press # events, not held state, so there is no backlog-on-close risk for them: a # gated press this frame simply never queues, and get_viewport() still has # set_input_as_handled() called so the event doesn't fall through to some # other handler either (mirrors the existing dialogue_active early-return # already established below). enum Action { MOVE_NORTH, MOVE_NORTHEAST, MOVE_EAST, MOVE_SOUTHEAST, MOVE_SOUTH, MOVE_SOUTHWEST, MOVE_WEST, MOVE_NORTHWEST, INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE, UNPAUSE, TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN, BUG_REPORT, # #495: F12 WRONG button — client-only, not sent to server OPEN_JOURNAL, # #264: J key — toggle knowledge journal panel, client-only SET_FACING, # D-054: facing octant update (no movement) TELEPORT_HUB, # #501: Home key — Gauntlet dev teleport (not production fast-travel) SAVE_GAME, # #554: F5 quicksave — sends SaveGame to server with save path LOAD_GAME, # #554: F6 quickload — sends LoadGame to server with save path DEBUG_COMMAND, # #581: debug console command dispatch — sends DebugCommandKind to server CHANGE_SETTINGS, # #646: persist a setting to server SQLite — sends {key, value} to server REQUEST_ALL_SETTINGS, # #646: request full settings dump from server after handshake (unit variant) DELETE_SETTING, # #646: delete a setting by key from server SQLite (struct variant) } # Minimum milliseconds between movement commands, per stance. # Tuned so Walk feels like walking, Sprint feels fast but readable. const MOVE_INTERVAL_MS := { "Sprint": 200, # 5/sec — fast but trackable "Walk": 400, # 2.5/sec — comfortable walking pace "Careful": 600, # ~1.7/sec — deliberate, scanning "Crouch": 800, # 1.25/sec — creeping } var input_queue: Array[Dictionary] = [] # D-054: Client-side facing angle (radians). 0=East, -PI/2=North, PI/2=South. # Updated every frame from mouse position. EntityRenderer reads this for indicator. var facing_angle: float = -PI / 2.0 # Default: North var facing_octant: String = "North" # Derived from facing_angle # 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() # gdlint class-definitions-order: all public vars above this line, all # private (underscore-prefixed) vars below — reordered while touching this # file for T-1146 (pre-existing violation on main, unrelated to this ticket: # facing_angle_provider used to sit after _last_sent_octant/_last_move_msec). var _last_sent_octant: String = "North" # Track to avoid redundant sends var _last_move_msec: int = 0 # T-1146: local mirror of HudGroups.gameplay_occluded, updated by the signal # handler below. Deliberately NOT HudGroups.is_implant_active() / is_app_active() # — those are true for BOTH FULLSCREEN and INSERT mode (e.g. main.gd opens # "implant/economics" in INSERT mode, a small overlay alongside still-visible, # still-playable gameplay — D-181/#824), whereas gameplay_occluded (and this # mirror) is only ever true for FULLSCREEN, which is the actual "the player is # not looking at their character" condition this gate exists for. Read directly # in the hot _process() poll instead of calling HudGroups.get_active_mode() # every frame — the signal already tells us exactly when it changes. var _gameplay_occluded: bool = false # T-1146: latch set the instant gameplay becomes occluded WHILE a movement key # is already held. Cleared only once the poll observes ALL movement keys # released (raw_dir == ZERO) at least one frame after occlusion ends — a # player who forgot their finger on W when the map closes must re-press, not # lurch forward on the very next frame. Facing updates are exempt (see # _process) — turning to look is not "movement" and re-syncing facing the # instant the implant closes is expected, not a lurch. var _suppress_move_until_release: bool = false func _ready() -> void: # Autoload-to-autoload signal wiring is fine at _ready (both are autoloads, # so both are already fully constructed by the time either _ready() body # runs) — the parse-order rule that bites autoloads only applies to # referencing a class_name TYPE, not calling a method / connecting a # signal on another autoload singleton. HudGroups.gameplay_occluded.connect(_on_gameplay_occluded) # T-1146: fires on BOTH the open transition (occluded=true) and the close # transition (occluded=false). Open: mirror the flag — the very next # _process() poll already gates on _gameplay_occluded below and simply stops # producing, so a key held at the moment of opening is dropped this frame with # no separate latch needed. Close: mirror the flag AND arm the release-latch so # a key still held at the moment of closing cannot resume movement without an # intervening release — see _suppress_move_until_release's own doc. func _on_gameplay_occluded(occluded: bool) -> void: _gameplay_occluded = occluded if not occluded: _suppress_move_until_release = true # 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. # Server-side cooldown (D-053) is authoritative; this prevents client flooding. # D-064: movement suppressed during dialogue (walk-away handled by dialogue_box). # T-1146: SET_FACING and movement are both gated on gameplay_occluded — an # implant screen open means the player is not looking at their character, so # neither "I turned to face something" nor "I walked" should reach the server. # Facing-angle tracking itself (facing_angle/facing_octant, purely client-side) # still updates every frame regardless — only the SEND to the server is gated, # same as the existing test_mode split in sim_bridge.gd's send_input(). func _process(_delta: float) -> void: # D-054: Update facing angle from mouse position every frame _update_facing_from_mouse() if GameState.dialogue_active or GameState.free_camera_mode: return if _gameplay_occluded: return # D-054: Send facing octant to server when it changes (even without movement) if facing_octant != _last_sent_octant: _last_sent_octant = facing_octant ( input_queue . append( { "action": Action.SET_FACING, "timestamp_msec": Time.get_ticks_msec(), "action_data": {"facing": facing_octant}, } ) ) # Poll held WASD keys var raw_dir := Vector2i.ZERO if Input.is_action_pressed("move_north"): raw_dir.y -= 1 if Input.is_action_pressed("move_south"): raw_dir.y += 1 if Input.is_action_pressed("move_east"): raw_dir.x += 1 if Input.is_action_pressed("move_west"): raw_dir.x -= 1 # T-1146: a key still held at the exact moment the implant closed must not # resume movement on its own — the latch (armed in _on_gameplay_occluded) # stays up until this poll observes every movement key released at least # once, at which point a fresh press is required to move again. if _suppress_move_until_release: if raw_dir == Vector2i.ZERO: _suppress_move_until_release = false return if raw_dir != Vector2i.ZERO: var now := Time.get_ticks_msec() var interval: int = MOVE_INTERVAL_MS.get(GameState.player_stance, 200) if now - _last_move_msec >= interval: _last_move_msec = now # D-054: Transform WASD input relative to mouse facing var world_dir := _wasd_to_world_dir(raw_dir) var action: Action = _dir_to_action(world_dir) ( input_queue . append( { "action": action, "timestamp_msec": now, } ) ) # Discrete actions: fire once on key press (not held). # T-1146: OPEN_MENU/BUG_REPORT/OPEN_JOURNAL are checked and queued BEFORE the # implant-occlusion gate — they are the implant/app-level keys the ticket # explicitly carves out. OPEN_MENU is how the implant CLOSES (Esc/#528's # priority chain in main.gd — HudGroups.close_app() before anything else), so # gating it on occlusion would make the implant impossible to close with Esc. # BUG_REPORT and OPEN_JOURNAL are client-only (main.gd's dispatch loop # `continue`s on both before ever reaching SimBridge.send_input) — neither is # gameplay-sim-bound, so neither belongs behind a gameplay-occlusion gate. # Every other action in this function (INTERACT, USE_PERCEPTION_MODE, # PAUSE/UNPAUSE, TOGGLE_STANCE_*, TELEPORT_HUB, SAVE_GAME, LOAD_GAME) reaches # send_input unconditionally once queued (sim_bridge.gd's action_enum_to_wire # has a wire case for every one of them) and IS gated below. func _unhandled_input(event: InputEvent) -> void: if GameState.dialogue_active or GameState.free_camera_mode: return # Implant/app-level keys — never gated on occlusion, checked first so they # always win regardless of implant state. var action: Action = -1 if event.is_action_pressed("open_menu"): action = Action.OPEN_MENU elif event.is_action_pressed("bug_report"): action = Action.BUG_REPORT elif event.is_action_pressed("open_journal"): action = Action.OPEN_JOURNAL # Gameplay-sim-bound keys — gated: skip the whole block while an implant # screen is occluding gameplay, so none of these can even be recognized, # let alone queued, while the player isn't looking at their character. if action == -1 and not _gameplay_occluded: if event.is_action_pressed("interact"): action = Action.INTERACT elif event.is_action_pressed("perception_mode"): action = Action.USE_PERCEPTION_MODE elif event.is_action_pressed("pause"): var tick_rate = GameState.game_time.get("tick_rate", "Full") action = Action.UNPAUSE if tick_rate == "Paused" else Action.PAUSE elif event.is_action_pressed("stance_up"): action = Action.TOGGLE_STANCE_UP elif event.is_action_pressed("stance_down"): action = Action.TOGGLE_STANCE_DOWN elif event.is_action_pressed("teleport_hub"): if GameState.gauntlet_mode: action = Action.TELEPORT_HUB elif event.is_action_pressed("quicksave"): action = Action.SAVE_GAME elif event.is_action_pressed("quickload"): action = Action.LOAD_GAME if action != -1: var entry := { "action": action, "timestamp_msec": Time.get_ticks_msec(), } # #554: Attach save path for SaveGame/LoadGame actions if action == Action.SAVE_GAME or action == Action.LOAD_GAME: var game_id := GameState.current_game_id if game_id.is_empty(): get_viewport().set_input_as_handled() return # No active session — ignore save/load entry["action_data"] = {"path": "user://saves/" + game_id + "/quicksave.sav"} input_queue.append(entry) get_viewport().set_input_as_handled() func flush_queue() -> Array[Dictionary]: var queue = input_queue.duplicate() input_queue.clear() return queue # T-1088 click-to-move seam (design §9 / D-248): queue ONE 8-direction step from a # sim tile delta (e.g. Vector2i(1, 0) = East, Vector2i(1, 1) = Southeast), gated by # the SAME per-stance throttle held-WASD uses (_last_move_msec + MOVE_INTERVAL_MS). # The sandbox path follower calls this once per frame while walking a client-planned # path; the shared throttle paces it to the exact stance cadence (no duplicated # interval table) and interleaves cleanly with WASD — a held key and a queued step # can never both fire inside one window. Returns true when the step was queued, # false when throttled or input-suppressed. Ordinary Move* actions only: zero # protocol change, no client prediction, the server validates every step (D-010). func queue_move_step(tile_delta: Vector2i) -> bool: # T-1146: same suppression predicate as the _process poll — all three # input_queue producers in this module share it, so the occlusion gate is # self-contained here rather than delegated to seam callers (today the # sandbox freezes the path follower itself, but Phase 5/T-962 lifts # click-to-move into the shared session driver). if GameState.dialogue_active or GameState.free_camera_mode or _gameplay_occluded: return false if tile_delta == Vector2i.ZERO: return false var now := Time.get_ticks_msec() var interval: int = MOVE_INTERVAL_MS.get(GameState.player_stance, 200) if now - _last_move_msec < interval: return false _last_move_msec = now input_queue.append( { "action": _dir_to_action(tile_delta), "timestamp_msec": now, } ) return true # T-1088 double-RMB sprint-there seam: queue ONE ordinary stance toggle (up = toward # Sprint, down = toward Crouch) through the same input_queue movement rides. Discrete, # so no throttle — the server's handle_toggle_stance has no cooldown, so the sandbox # follower can burst the exact ladder distance and trust it (server validates). No-op # while input is suppressed (dialogue / free camera / occlusion), matching movement. func queue_stance_toggle(up: bool) -> void: if GameState.dialogue_active or GameState.free_camera_mode or _gameplay_occluded: return input_queue.append( { "action": Action.TOGGLE_STANCE_UP if up else Action.TOGGLE_STANCE_DOWN, "timestamp_msec": Time.get_ticks_msec(), } ) ## Reset facing state to default (North). Use in tests per D-030 testability. func reset_facing_state() -> void: facing_angle = -PI / 2.0 facing_octant = "North" _last_sent_octant = "North" # D-054: Compute facing angle from mouse position relative to player screen position. # Uses viewport canvas transform to convert world coords to screen coords. # 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 var canvas_xf := vp.get_canvas_transform() var C := load("res://scripts/constants.gd") var player_world_px: Vector2 = GameState.player_position * C.TILE_SIZE var player_screen: Vector2 = canvas_xf * player_world_px var mouse_screen: Vector2 = vp.get_mouse_position() var delta: Vector2 = mouse_screen - player_screen # Only update if mouse is meaningfully distant from player (avoid jitter at center) if delta.length_squared() > 4.0: facing_angle = delta.angle() facing_octant = _angle_to_octant(facing_angle) # D-054: Transform raw WASD input (screen-space) to world direction relative to mouse facing. # W (+Y up in input, mapped to forward), S (backward), A (strafe left), D (strafe right). # Raw input: W=(-Y), S=(+Y), A=(-X), D=(+X) in screen coords. # Forward = facing_angle direction. Output: nearest octant direction vector. func _wasd_to_world_dir(raw_dir: Vector2i) -> Vector2i: # Build a continuous direction vector relative to facing. # raw_dir.y: -1 = W (forward), +1 = S (backward) # raw_dir.x: -1 = A (strafe left), +1 = D (strafe right) var forward := Vector2(cos(facing_angle), sin(facing_angle)) var right := Vector2(-forward.y, forward.x) # 90° clockwise # Combine: forward/back from W/S, strafe from A/D var world_float := forward * float(-raw_dir.y) + right * float(raw_dir.x) # Snap to nearest octant direction return _snap_to_octant_dir(world_float) # Snap a floating-point direction vector to the nearest of 8 cardinal/diagonal directions. static func _snap_to_octant_dir(dir: Vector2) -> Vector2i: if dir.length_squared() < 0.001: return Vector2i.ZERO var angle := dir.angle() # Quantize to nearest 45° (PI/4) var octant := roundi(angle / (PI / 4.0)) match octant: 0: return Vector2i(1, 0) # East 1: return Vector2i(1, 1) # Southeast 2, -6: return Vector2i(0, 1) # South 3, -5: return Vector2i(-1, 1) # Southwest 4, -4: return Vector2i(-1, 0) # West -3, 5: return Vector2i(-1, -1) # Northwest -2: return Vector2i(0, -1) # North -1: return Vector2i(1, -1) # Northeast _: return Vector2i.ZERO # D-054: Convert a facing angle (radians) to the nearest octant name. # Godot 2D: 0=East, PI/2=South, -PI/2=North. static func _angle_to_octant(angle: float) -> String: var octant := roundi(angle / (PI / 4.0)) match octant: 0: return "East" 1: return "Southeast" 2, -6: return "South" 3, -5: return "Southwest" 4, -4: return "West" -3, 5: return "Northwest" -2: return "North" -1: return "Northeast" _: return "East" # Map a direction vector to the corresponding movement Action. # Handles all 8 directions via composite W+D, W+A, etc. static func _dir_to_action(dir: Vector2i) -> Action: match dir: Vector2i(0, -1): return Action.MOVE_NORTH Vector2i(1, -1): return Action.MOVE_NORTHEAST Vector2i(1, 0): return Action.MOVE_EAST Vector2i(1, 1): return Action.MOVE_SOUTHEAST Vector2i(0, 1): return Action.MOVE_SOUTH Vector2i(-1, 1): return Action.MOVE_SOUTHWEST Vector2i(-1, 0): return Action.MOVE_WEST Vector2i(-1, -1): return Action.MOVE_NORTHWEST _: return Action.MOVE_NORTH