Mi2dRPGamEng/engine/gamepad.py

149 lines
5.7 KiB
Python

from __future__ import annotations
import configparser
from dataclasses import dataclass, field
from pathlib import Path
# Standard GLFW gamepad button indices (glfwGetGamepadState order) mapped to config-friendly
# names, so the .conf format matches KeyBindings' "action = token, token" style exactly.
GAMEPAD_BUTTON_NAMES: dict[int, str] = {
0: "gp_a",
1: "gp_b",
2: "gp_x",
3: "gp_y",
4: "gp_left_bumper",
5: "gp_right_bumper",
6: "gp_back",
7: "gp_start",
8: "gp_guide",
9: "gp_left_thumb",
10: "gp_right_thumb",
11: "gp_dpad_up",
12: "gp_dpad_right",
13: "gp_dpad_down",
14: "gp_dpad_left",
}
# Standard GLFW gamepad axis indices. The triggers are analog axes (not buttons) in GLFW's
# standard mapping - GamepadState.digital_buttons() turns them into gp_left_trigger/
# gp_right_trigger pseudo-buttons via TRIGGER_PRESS_THRESHOLD, so bindings only ever deal with
# named buttons, never raw axis indices.
AXIS_LEFT_X = 0
AXIS_LEFT_Y = 1
AXIS_RIGHT_X = 2
AXIS_RIGHT_Y = 3
AXIS_LEFT_TRIGGER = 4
AXIS_RIGHT_TRIGGER = 5
TRIGGER_PRESS_THRESHOLD = 0.5
DEFAULT_STICK_DEADZONE = 0.2
DEFAULT_GAMEPAD_BINDINGS: dict[str, list[str]] = {
"leap": ["gp_a"],
"block": ["gp_left_bumper"],
"activate_right_hand": ["gp_right_bumper"],
"activate_left_hand": ["gp_x"],
"activate_right_hand_2": ["gp_right_trigger"],
"activate_left_hand_2": ["gp_left_trigger"],
"activate_selected_ability": ["gp_b"],
}
def apply_deadzone(value: float, deadzone: float = DEFAULT_STICK_DEADZONE) -> float:
return 0.0 if abs(value) < deadzone else value
@dataclass
class GamepadState:
"""One frame's snapshot of a gamepad, decoupled from any actual hardware/glfw call so the
binding/mapping logic is fully unit-testable. main.py is responsible for the one-line glue
that turns a real glfw.get_gamepad_state(...) result into this shape each frame - see
GamepadState.from_glfw.
"""
buttons: dict[str, bool] = field(default_factory=dict) # gp_* name -> pressed
axes: list[float] = field(default_factory=lambda: [0.0] * 6)
def axis(self, index: int) -> float:
return self.axes[index] if index < len(self.axes) else 0.0
def digital_buttons(self) -> set[str]:
"""Every currently-pressed gp_* name, including the triggers thresholded into
pseudo-buttons (gp_left_trigger/gp_right_trigger) alongside the real digital buttons.
"""
pressed = {name for name, is_down in self.buttons.items() if is_down}
if self.axis(AXIS_LEFT_TRIGGER) > TRIGGER_PRESS_THRESHOLD:
pressed.add("gp_left_trigger")
if self.axis(AXIS_RIGHT_TRIGGER) > TRIGGER_PRESS_THRESHOLD:
pressed.add("gp_right_trigger")
return pressed
@classmethod
def from_glfw(cls, raw) -> "GamepadState":
"""Wraps a glfw.get_gamepad_state(...) result (a _GLFWgamepadstate with .buttons/.axes
sequences indexed exactly like GAMEPAD_BUTTON_NAMES/AXIS_*) into this hardware-agnostic
shape. Never exercised by tests (no hardware in CI) - kept intentionally thin so the
only untested code is "read the raw struct", not any actual logic.
"""
buttons = {name: bool(raw.buttons[index]) for index, name in GAMEPAD_BUTTON_NAMES.items()}
axes = list(raw.axes)
return cls(buttons=buttons, axes=axes)
@dataclass
class GamepadButtonEdgeTracker:
"""Frame-to-frame "was this raw gp_* button just pressed?" detection, independent of any
action binding - used for menu navigation (main.py), which needs raw button edges (dpad/A/B)
rather than InputState's game-action dispatch (see apply_gamepad_state), since a menu isn't
a bindable game action and shouldn't go through GamepadBindings/KeyBindings at all.
"""
_prev_pressed: set[str] = field(default_factory=set)
def newly_pressed(self, state: GamepadState) -> set[str]:
pressed = state.digital_buttons()
edges = pressed - self._prev_pressed
self._prev_pressed = pressed
return edges
def reset(self) -> None:
"""Drops the remembered button set - call whenever something other than this tracker
may have changed context out from under it (mirrors InputState.clear_held_state), so a
button held continuously across that boundary is treated as freshly pressed afterward
rather than silently missing its next edge.
"""
self._prev_pressed = set()
class GamepadBindings:
"""Action -> list of gp_* button names, loaded from an editable .conf file - same array-
per-action format and fallback behavior as engine.config.KeyBindings, just for gamepad
buttons instead of keys/mouse buttons.
"""
def __init__(self, bindings: dict[str, list[str]]):
self.bindings = bindings
self._action_by_button: dict[str, str] = {}
for action, buttons in bindings.items():
for button in buttons:
self._action_by_button[button] = action
@classmethod
def load(cls, path: Path) -> "GamepadBindings":
bindings = {action: list(buttons) for action, buttons in DEFAULT_GAMEPAD_BINDINGS.items()}
if path.exists():
parser = configparser.ConfigParser()
parser.read(path)
for section in parser.sections():
for action, raw in parser.items(section):
buttons = [b.strip() for b in raw.split(",") if b.strip()]
if buttons:
bindings[action] = buttons
return cls(bindings)
def action_for_button(self, button: str) -> str | None:
return self._action_by_button.get(button)
def buttons_for_action(self, action: str) -> list[str]:
return self.bindings.get(action, [])