Mi2dRPGamEng/engine/input.py

209 lines
8.6 KiB
Python

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()