From ee3ac755782b5266c752eccb0462d6368b4e5b44 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 16 Feb 2026 23:26:07 +0100 Subject: [PATCH] fix(client): add pause toggle, hold-to-move, snapshot event carry-forward MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three client input/bridge fixes: 1. Pause toggle: add UNPAUSE action and toggle logic based on GameState.game_time.tick_rate. Wire UNPAUSE in sim_bridge.gd. 2. Hold-to-move: replace press-event movement with polled _process() direction sampling. Composite diagonals via simultaneous keys (W+D → northeast). Client-side throttle per stance (D-053): Sprint=200ms, Walk=400ms, Careful=600ms, Crouch=800ms. 3. Snapshot carry-forward: when a newer snapshot overwrites an unconsumed one, carry forward current_monologue and current_dialogue so one-shot events aren't silently dropped. Fixes bugs #5 (monologue lost on overwrite) and #6 (overwrite spam). Co-Authored-By: Claude Opus 4.6 --- client/scripts/autoloads/input_mapper.gd | 91 +++++++++++++++++------- client/scripts/autoloads/sim_bridge.gd | 20 ++++-- 2 files changed, 80 insertions(+), 31 deletions(-) diff --git a/client/scripts/autoloads/input_mapper.gd b/client/scripts/autoloads/input_mapper.gd index c02e0e19e..df077aa4f 100644 --- a/client/scripts/autoloads/input_mapper.gd +++ b/client/scripts/autoloads/input_mapper.gd @@ -1,60 +1,101 @@ extends Node # Semantic actions — NO raw key codes cross the bridge -# Diagonal directions registered in project.godot with empty event arrays (intentional). -# Keybindings deferred until input design is finalized — likely numpad or composite WASD. +# Movement uses hold-to-move (polled each frame in _process). +# Discrete actions (interact, stance, etc.) use press events (_unhandled_input). +# Composite diagonals: holding W+D simultaneously → northeast. +# +# 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. 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, + INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE, UNPAUSE, TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN, } var input_queue: Array[Dictionary] = [] +# 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 _last_move_msec: int = 0 + + +# Hold-to-move: poll held direction keys each frame, throttled by stance. +# Server-side cooldown (D-053) is authoritative; this prevents client flooding. +func _process(_delta: float) -> void: + var dir := Vector2i.ZERO + if Input.is_action_pressed("move_north"): + dir.y -= 1 + if Input.is_action_pressed("move_south"): + dir.y += 1 + if Input.is_action_pressed("move_east"): + dir.x += 1 + if Input.is_action_pressed("move_west"): + dir.x -= 1 + + if 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 + var action: Action = _dir_to_action(dir) + input_queue.append({ + "action": action, + "timestamp_msec": now, + }) + + +# Discrete actions: fire once on key press (not held). func _unhandled_input(event: InputEvent) -> void: var action: Action = -1 - # Map input actions to semantic Action enum - # is_action_pressed handles press detection for all input types (key, gamepad, etc.) - if event.is_action_pressed("move_north"): - action = Action.MOVE_NORTH - elif event.is_action_pressed("move_northeast"): - action = Action.MOVE_NORTHEAST - elif event.is_action_pressed("move_east"): - action = Action.MOVE_EAST - elif event.is_action_pressed("move_southeast"): - action = Action.MOVE_SOUTHEAST - elif event.is_action_pressed("move_south"): - action = Action.MOVE_SOUTH - elif event.is_action_pressed("move_southwest"): - action = Action.MOVE_SOUTHWEST - elif event.is_action_pressed("move_west"): - action = Action.MOVE_WEST - elif event.is_action_pressed("move_northwest"): - action = Action.MOVE_NORTHWEST - elif event.is_action_pressed("interact"): + 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("open_menu"): action = Action.OPEN_MENU elif event.is_action_pressed("pause"): - action = Action.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 - # Queue the action if valid if action != -1: input_queue.append({ "action": action, - "timestamp_msec": Time.get_ticks_msec() + "timestamp_msec": Time.get_ticks_msec(), }) get_viewport().set_input_as_handled() + func flush_queue() -> Array[Dictionary]: var queue = input_queue.duplicate() input_queue.clear() return queue + + +# 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 diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 672084efa..7e8be7af4 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -214,14 +214,21 @@ func poll_snapshot() -> Variant: return null # Called by transport layer when raw bytes arrive from the server. -# Latest-wins semantics: newer snapshots replace unconsumed ones. This is correct -# for real-time rendering (stale frames are worthless). Upgrade to queue if needed. +# Latest-wins for positional state (stale frames are worthless), but one-shot +# events (monologue, dialogue) are carried forward from overwritten snapshots +# so they aren't silently dropped when server ticks faster than client consumes. func receive_bytes(bytes: PackedByteArray) -> void: var snapshot = Protocol.decode_snapshot(bytes) - if snapshot != null: - if _last_snapshot != null: - push_warning("SimBridge: overwriting unconsumed snapshot (tick %s replaced by %s)" % [_last_snapshot.tick, snapshot.tick]) - _last_snapshot = snapshot + if snapshot == null: + push_warning("SimBridge: decode_snapshot returned null for %d bytes" % bytes.size()) + return + if _last_snapshot != null: + # Carry forward one-shot events the client hasn't consumed yet. + if snapshot.get("current_monologue") == null and _last_snapshot.get("current_monologue") != null: + snapshot["current_monologue"] = _last_snapshot["current_monologue"] + if snapshot.get("current_dialogue") == null and _last_snapshot.get("current_dialogue") != null: + snapshot["current_dialogue"] = _last_snapshot["current_dialogue"] + _last_snapshot = snapshot # Drain the outbound buffer. Returns raw input entries for batch encoding. func drain_outbound() -> Array[Dictionary]: @@ -244,6 +251,7 @@ static func _action_enum_to_wire(action: int) -> String: InputMapper.Action.INTERACT: return "Interact" InputMapper.Action.USE_PERCEPTION_MODE: return "UsePerceptionMode" InputMapper.Action.PAUSE: return "Pause" + InputMapper.Action.UNPAUSE: return "Unpause" InputMapper.Action.TOGGLE_STANCE_UP: return "ToggleStanceUp" InputMapper.Action.TOGGLE_STANCE_DOWN: return "ToggleStanceDown" InputMapper.Action.OPEN_MENU: