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"], } 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) 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, [])