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:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user