"""The engine's gamepad convention, and the mapping every backend feeds.
A window backend reports whatever its own library reports. GLFW hands back a
resting trigger as ``-1.0``, SDL3 hands back a ``0..32767`` integer, and a
browser hands back ``0.0``. This module is the single place those conventions
become the engine's, so the same physical trigger reads the same number
whichever backend the game booted with.
The convention, and it is what ``Input.get_gamepad_axis`` documents:
* Sticks run ``-1.0`` to ``1.0`` per axis, centred at ``0.0``, with ``+y``
pointing down the screen.
* Triggers run ``0.0`` when released to ``1.0`` when fully pulled.
* A pad reports exactly :data:`BUTTON_NAMES` and :data:`AXIS_NAMES`: no more,
so a backend cannot smuggle in a name nothing else has; no fewer, so a name
a game polls is never quietly absent on one backend.
A backend declares its source ranges and calls :func:`normalise_axes`; it does
not do the arithmetic itself. That is what stops a fourth backend re-deriving
the divergence this module exists to remove.
"""
from collections.abc import Mapping
__all__ = [
"AXIS_NAMES",
"BUTTON_NAMES",
"STICK_NAMES",
"TRIGGER_NAMES",
"normalise_axes",
"normalise_buttons",
"out_of_range_axes",
]
#: The standard-gamepad buttons, in the order a readout draws them.
BUTTON_NAMES: tuple[str, ...] = (
"a", "b", "x", "y", "lb", "rb", "back", "start", "guide", "l3", "r3",
"dpad_up", "dpad_right", "dpad_down", "dpad_left",
) # fmt: skip
#: Stick axes, each ``-1.0`` to ``1.0``, ``+y`` down.
STICK_NAMES: tuple[str, ...] = ("left_x", "left_y", "right_x", "right_y")
#: Trigger axes, each ``0.0`` released to ``1.0`` fully pulled.
TRIGGER_NAMES: tuple[str, ...] = ("lt", "rt")
#: Every axis a pad reports.
AXIS_NAMES: tuple[str, ...] = STICK_NAMES + TRIGGER_NAMES
def _check_names(raw: Mapping[str, object], expected: tuple[str, ...], kind: str) -> None:
missing = [name for name in expected if name not in raw]
unknown = sorted(set(raw) - set(expected))
if missing or unknown:
detail = []
if missing:
detail.append(f"missing {missing}")
if unknown:
detail.append(f"unknown {unknown}")
raise ValueError(f"gamepad {kind} names: {', '.join(detail)}; expected exactly {list(expected)}")
[docs]
def normalise_axes(
raw: Mapping[str, float],
*,
stick_scale: float,
trigger_range: tuple[float, float],
) -> dict[str, float]:
"""Map one pad's raw axis readings onto the engine's convention.
Args:
raw: The backend's readings, keyed by :data:`AXIS_NAMES`.
stick_scale: The magnitude a fully deflected stick reports. ``1.0`` for
a library that already reports floats, ``32767.0`` for one that
reports signed 16-bit. A stick divides by this and is clamped, so a
library whose negative extreme is ``-32768`` cannot overshoot the
documented ``[-1, 1]``, and centre stays exactly ``0.0``.
trigger_range: ``(released, fully_pulled)`` in the backend's own units:
``(-1.0, 1.0)`` for GLFW, ``(0.0, 32767.0)`` for SDL3, ``(0.0,
1.0)`` for a browser. A trigger maps linearly from this onto
``[0, 1]``.
Raises:
ValueError: If a name is missing or unknown, or if either range is
degenerate.
"""
_check_names(raw, AXIS_NAMES, "axis")
if stick_scale <= 0.0:
raise ValueError(f"stick_scale must be positive, got {stick_scale}")
released, pulled = trigger_range
span = pulled - released
if span == 0.0:
raise ValueError(f"trigger_range must span a non-zero interval, got {trigger_range}")
axes = {name: min(1.0, max(-1.0, float(raw[name]) / stick_scale)) for name in STICK_NAMES}
for name in TRIGGER_NAMES:
axes[name] = min(1.0, max(0.0, (float(raw[name]) - released) / span))
return axes
[docs]
def out_of_range_axes(axes: Mapping[str, float]) -> list[str]:
"""Names whose value breaks the convention, for a caller that wants to warn.
This is the guard on the seam rather than the fix: a value that arrives
here already out of range has lost the provenance needed to correct it, so
the caller can only report which backend is lying.
"""
bad = [name for name in STICK_NAMES if name in axes and not -1.0 <= axes[name] <= 1.0]
bad += [name for name in TRIGGER_NAMES if name in axes and not 0.0 <= axes[name] <= 1.0]
return bad