from __future__ import annotations from engine.config import DEFAULT_KEYBINDINGS, KeyBindings from engine.gamepad import AXIS_LEFT_X, AXIS_LEFT_Y, GamepadBindings, GamepadState, apply_deadzone MOVE_ACTION_VECTORS: dict[str, tuple[int, int, int]] = { "move_up": (0, -1, 0), "move_down": (0, 1, 0), "move_left": (-1, 0, 0), "move_right": (1, 0, 0), } # Mouse-button-driven "which hand acts" scheme: left/right click for the primary pair of # hands, shift+left/right for a second pair (only meaningful for a 4-armed species; wield_item # already refuses to use a hand slot with no corresponding arm, so this degrades safely). HAND_ACTIVATION_ACTIONS: dict[str, str] = { "activate_right_hand": "right_hand", "activate_left_hand": "left_hand", "activate_right_hand_2": "right_hand_2", "activate_left_hand_2": "left_hand_2", } # jupyter_rfb/rendercanvas pointer button numbering (DOM-style): 0 = left, 2 = right. MOUSE_BUTTON_KEYS: dict[int, str] = {0: "mouse_left", 2: "mouse_right"} # Numkey hotkeys for the ability bar (engine/ui.py::AbilityBar) - "0" conventionally maps to # the 10th slot (index 9), matching the usual 1-9,0 hotbar ordering. SELECT_ABILITY_ACTIONS: dict[str, int] = { "select_ability_1": 0, "select_ability_2": 1, "select_ability_3": 2, "select_ability_4": 3, "select_ability_5": 4, "select_ability_6": 5, "select_ability_7": 6, "select_ability_8": 7, "select_ability_9": 8, "select_ability_0": 9, } DEFAULT_FACING: tuple[int, int, int] = (0, 1, 0) class InputState: """Discrete key/mouse-driven movement and actions (one press = one step or state toggle), resolved through a KeyBindings config so any action can be bound to multiple inputs. Also tracks raw pointer position for continuous mouse aiming (see engine/aim.py). """ def __init__(self, keybindings: KeyBindings | None = None): self.keybindings = keybindings or KeyBindings(dict(DEFAULT_KEYBINDINGS)) self.pending_move: tuple[int, int, int] | None = None self.held_move_actions: set[str] = set() self.pending_leap: tuple[int, int, int] | None = None self.pending_swim_down = False self.pending_hand_activation: str | None = None self.pending_ability_select: int | None = None self.pending_activate_ability_bar = False self.is_blocking = False self.facing: tuple[int, int, int] = DEFAULT_FACING self.pointer_pos: tuple[float, float] | None = None self.gamepad_move_vector: tuple[float, float] = (0.0, 0.0) self._prev_gamepad_buttons: set[str] = set() def handle_event(self, event: dict) -> None: event_type = event.get("event_type") if event_type == "pointer_move": self.pointer_pos = (event.get("x", 0.0), event.get("y", 0.0)) return if event_type == "pointer_down": self._dispatch_down(MOUSE_BUTTON_KEYS.get(event.get("button", -1)), event.get("modifiers", ())) return if event_type not in ("key_down", "key_up"): return key = event.get("key", "") action = self._resolve_action(key, event.get("modifiers", ())) if action is None: return if event_type == "key_down": self._apply_down(action) else: self._apply_up(action) def _resolve_action(self, key: str, modifiers) -> str | None: if key and "Shift" in modifiers: action = self.keybindings.action_for_key(f"shift+{key}") if action is not None: return action return self.keybindings.action_for_key(key) def _dispatch_down(self, mouse_key: str | None, modifiers) -> None: if mouse_key is None: return action = self._resolve_action(mouse_key, modifiers) if action is not None: self._apply_down(action) def _apply_down(self, action: str) -> None: if action in MOVE_ACTION_VECTORS: move = MOVE_ACTION_VECTORS[action] self.pending_move = move # one-shot compat, e.g. for a discrete-step caller self.held_move_actions.add(action) self.facing = move elif action == "leap": self.pending_leap = self.facing elif action == "swim_down": self.pending_swim_down = True elif action == "block": self.is_blocking = True elif action in HAND_ACTIVATION_ACTIONS: self.pending_hand_activation = HAND_ACTIVATION_ACTIONS[action] elif action in SELECT_ABILITY_ACTIONS: self.pending_ability_select = SELECT_ABILITY_ACTIONS[action] elif action == "activate_selected_ability": self.pending_activate_ability_bar = True def _apply_up(self, action: str) -> None: if action in MOVE_ACTION_VECTORS: self.held_move_actions.discard(action) elif action == "block": self.is_blocking = False def apply_gamepad_state(self, state: GamepadState, bindings: GamepadBindings) -> None: """Feeds one frame's polled gamepad snapshot through the same action dispatch as keyboard/mouse events. Unlike glfw's edge-triggered key_down/key_up callbacks, a gamepad is polled every frame, so button transitions have to be detected here by diffing against the previous frame's pressed set. """ pressed = state.digital_buttons() for button in pressed - self._prev_gamepad_buttons: action = bindings.action_for_button(button) if action is not None: self._apply_down(action) for button in self._prev_gamepad_buttons - pressed: action = bindings.action_for_button(button) if action is not None: self._apply_up(action) self._prev_gamepad_buttons = pressed self.gamepad_move_vector = ( apply_deadzone(state.axis(AXIS_LEFT_X)), apply_deadzone(state.axis(AXIS_LEFT_Y)), ) def consume_move(self) -> tuple[int, int, int] | None: move, self.pending_move = self.pending_move, None return move def current_move_vector(self) -> tuple[float, float, float]: """The combined direction of all currently-held movement keys plus the left stick's deadzoned analog offset (for continuous movement) - the two sources simply add, so keyboard and gamepad work either standalone or together. Holding both an up/down and left/right key gives a diagonal vector - not normalized here (World.try_move_continuous normalizes it), so callers get the raw combination. """ dx = dy = 0.0 for action in self.held_move_actions: vx, vy, _ = MOVE_ACTION_VECTORS[action] dx += vx dy += vy dx += self.gamepad_move_vector[0] dy += self.gamepad_move_vector[1] return (dx, dy, 0.0) def consume_leap(self) -> tuple[int, int, int] | None: leap, self.pending_leap = self.pending_leap, None return leap def consume_swim_down(self) -> bool: fired, self.pending_swim_down = self.pending_swim_down, False return fired def consume_hand_activation(self) -> str | None: hand, self.pending_hand_activation = self.pending_hand_activation, None return hand def consume_ability_select(self) -> int | None: index, self.pending_ability_select = self.pending_ability_select, None return index def consume_activate_ability_bar(self) -> bool: fired, self.pending_activate_ability_bar = self.pending_activate_ability_bar, False return fired def clear_held_state(self) -> None: """Drops all held-key/blocking state - call whenever leaving PLAYING for a menu, since key_up events (and gamepad polling - see main.py's poll_gamepad) stop reaching this class while a menu owns input, so a key/button released mid-menu would otherwise still read as held once gameplay resumes. Also resets the gamepad edge-tracking set: without this, a button held continuously across the boundary (e.g. still holding block when a pause menu closes) would never see a fresh "down" edge on resume, since apply_gamepad_state's diff would find it already "pressed" in both the stale pre-pause snapshot and the current one. """ self.held_move_actions.clear() self.is_blocking = False self.pending_move = None self.pending_leap = None self.pending_swim_down = False self.pending_hand_activation = None self.pending_activate_ability_bar = False self._prev_gamepad_buttons = set()