150 lines
5.7 KiB
Python
150 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"],
|
|
"pickup": ["gp_left_thumb"],
|
|
}
|
|
|
|
|
|
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, [])
|