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). # 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, 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. # D-064: movement suppressed during dialogue (walk-away handled by dialogue_box). func _process(_delta: float) -> void: if GameState.dialogue_active: return 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 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"): 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 if action != -1: input_queue.append({ "action": action, "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