fix(client): add pause toggle, hold-to-move, snapshot event carry-forward

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 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 23:26:07 +01:00
co-authored by Claude Opus 4.6
parent 2dd1e9545d
commit ee3ac75578
2 changed files with 80 additions and 31 deletions
+66 -25
View File
@@ -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