"""How device inputs compose into an action, and what type that action has.
A game declares what an action *is* rather than assembling it at every call
site::
input_context = {
"jump": [Key.SPACE, Controller.A], # bool
"throttle": [Axis1D(low=Key.LEFT_CONTROL, high=Key.SPACE),
Controller.RIGHT_TRIGGER], # float
"move": [Axis2D.WASD, Controller.LEFT_STICK], # Vec2
}
The type follows from the bindings: buttons alone give ``bool``, an
:class:`Axis1D` or a single axis gives ``float``, an :class:`Axis2D` or a stick
gives ``Vec2``. ``Input.value(action)`` returns that type and
``Input.pressed(action)`` still answers on all three, meaning "past the
deadzone" for a continuous one.
**Each entry in the list is one source**, and a source produces a whole value of
the action's type on its own. That is what makes "largest magnitude wins"
well defined: the keys give one vector, the stick gives another, and the action
takes whichever is further from rest rather than summing them.
A full axis is its two halves, and a stick is its two axes, so there is one
composite for a scalar and one for a vector rather than a type per spelling:
a bare axis such as ``Controller.LEFT_X`` or a tablet's ring normalises to an
:class:`Axis1D` over its negative and positive halves, and a stick such as
``Controller.LEFT_STICK`` to an :class:`Axis2D` over the halves of its two axes.
**A string is another entry's name.** Actions and device aliases are entries of
one map, so ``"pause": ["start", Key.ESCAPE]`` takes whatever ``"start"``
resolves to, and ``Axis2D(up="forward", ...)`` takes the one control
``"forward"`` names. The input map expands the names once whenever it changes.
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field, replace
from enum import StrEnum
from typing import ClassVar, cast
from .controls import DeviceAxis, DeviceButton, DeviceControl, DeviceStick, DeviceType, Key
from .events import InputBinding
__all__ = ["ActionKind", "Axis1D", "Axis2D", "BindingSpec", "SourceSpec"]
#: Anything accepted where one device input is wanted: a binding, a key, a
#: button, an axis half, or another entry's name.
type BindingSpec = InputBinding | DeviceButton | DeviceAxis | str
#: One part of a composite as declared: a binding, or the name of an entry
#: that resolves to exactly one.
type Part = InputBinding | str
#: Anything accepted as one source of an action's value.
type SourceSpec = BindingSpec | DeviceStick | Axis1D | Axis2D
[docs]
class ActionKind(StrEnum):
"""What type an action's value has, decided by the bindings it was given."""
#: Buttons only. ``Input.value`` gives ``bool``.
BUTTON = "button"
#: An :class:`Axis1D` or a single axis is present. ``Input.value`` gives ``float``.
AXIS_1D = "axis_1d"
#: An :class:`Axis2D` or a stick is present. ``Input.value`` gives ``Vec2``.
AXIS_2D = "axis_2d"
def _scaled(parts: tuple[Part, ...], change: Callable[[InputBinding], InputBinding]) -> list[InputBinding]:
"""*parts* each put through *change*; an entry's name has no value of its own to scale, and is refused."""
out: list[InputBinding] = []
for part in parts:
if not isinstance(part, InputBinding):
raise TypeError(
f"{part!r} names another entry, whose value is that entry's own: scale or rate the controls "
"where the entry is declared"
)
out.append(change(part))
return out
def _as_binding(spec: BindingSpec, *, role: str) -> Part:
"""Coerce one composite component to an :class:`InputBinding`, or keep an entry's name.
A control is a binding of its own; a string names another entry, which
the input map resolves to the one control it stands for.
"""
if isinstance(spec, InputBinding):
return spec
if isinstance(spec, DeviceButton | DeviceAxis):
return InputBinding(spec)
if isinstance(spec, str):
if not spec:
raise ValueError(f"{role}: an entry's name cannot be empty")
return spec
raise TypeError(f"{role}: expected a control, a binding or an entry's name, got {spec!r}")
[docs]
@dataclass(frozen=True, slots=True)
class Axis1D:
"""Two opposed inputs composed into one signed value, -1.0 to 1.0.
``Axis1D(low=Key.LEFT_CONTROL, high=Key.SPACE)`` reads -1.0 while Control is
held, 1.0 while Space is, and 0.0 with neither or both. Either side may be
an axis half, which is how a trigger or a stick direction joins the same
action: a bare axis in an action's binding list becomes this over its two
halves.
Attributes:
low: What drives the value negative.
high: What drives it positive.
deadzone: How far from rest the composed value must be to count. A
component that is an axis half applies it; a button never sits
inside it.
"""
low: BindingSpec
high: BindingSpec
deadzone: float = 0.2
[docs]
def __post_init__(self) -> None:
object.__setattr__(self, "low", _as_binding(self.low, role="Axis1D(low=...)"))
object.__setattr__(self, "high", _as_binding(self.high, role="Axis1D(high=...)"))
[docs]
@property
def parts(self) -> tuple[Part, Part]:
"""This source's components as declared, ``(low, high)``: bindings, or entries' names."""
return (cast(Part, self.low), cast(Part, self.high))
[docs]
@property
def bindings(self) -> tuple[InputBinding, InputBinding]:
"""This source's components, ``(low, high)``, once the map has resolved every name in it."""
return (cast(InputBinding, self.low), cast(InputBinding, self.high))
[docs]
def __mul__(self, factor: float) -> Axis1D:
"""Both halves multiplied into the action's unit: ``Axis1D(low=Key.Q, high=Key.E) * 2``."""
if not isinstance(factor, int | float) or isinstance(factor, bool):
return NotImplemented
low, high = _scaled(self.parts, lambda b: b * factor)
return replace(self, low=low, high=high)
__rmul__ = __mul__
[docs]
def per_second(self, rate: float) -> Axis1D:
"""Both halves as motion: the held value times *rate* each second."""
low, high = _scaled(self.parts, lambda b: b.per_second(rate))
return replace(self, low=low, high=high)
[docs]
@classmethod
def for_device_axis(cls, axis: DeviceAxis, *, deadzone: float = 0.0) -> Axis1D:
"""One whole axis, a tablet's ring or strip, as a signed value: its two halves.
No deadzone by default: a dial reads exactly where it was turned, and a
delta of a fraction of a turn is a real motion.
"""
return cls(
low=InputBinding(axis.negative, deadzone=0.0),
high=InputBinding(axis.positive, deadzone=0.0),
deadzone=deadzone,
)
[docs]
@dataclass(frozen=True, slots=True, kw_only=True)
class Axis2D:
"""Four inputs composed into a direction, with a magnitude of at most 1.0.
``Axis2D(up=Key.W, down=Key.S, left=Key.A, right=Key.D)`` is the keyboard
half of a movement action, ready-made as :attr:`WASD` and :attr:`ARROWS`;
``Controller.LEFT_STICK`` in the same list is the stick half, and the action
takes whichever is further from rest. ``+y`` is down the screen, as
everywhere else in 2D. Keyword-only, because an order is a convention
nothing checks and a wrong one inverts movement silently.
The deadzone is radial rather than per axis, so a stick pushed diagonally is
not clipped into a square: the whole vector is discarded when it is shorter
than *deadzone* and passed through when it is not. A diagonal held on the
keyboard is normalised to length 1.0, so walking diagonally is not faster.
Attributes:
up, down, left, right: What drives each direction.
deadzone: How far from centre the composed vector must reach to count.
"""
up: BindingSpec
down: BindingSpec
left: BindingSpec
right: BindingSpec
deadzone: float = 0.2
#: W, A, S and D. A player rebinds one direction of it like any other.
WASD: ClassVar[Axis2D]
#: The four arrow keys.
ARROWS: ClassVar[Axis2D]
[docs]
def __post_init__(self) -> None:
for role in ("up", "down", "left", "right"):
object.__setattr__(self, role, _as_binding(getattr(self, role), role=f"Axis2D({role}=...)"))
[docs]
@property
def parts(self) -> tuple[Part, Part, Part, Part]:
"""This source's components as declared, ``(up, down, left, right)``: bindings, or names."""
return (cast(Part, self.up), cast(Part, self.down), cast(Part, self.left), cast(Part, self.right))
[docs]
@property
def bindings(self) -> tuple[InputBinding, InputBinding, InputBinding, InputBinding]:
"""This source's components, ``(up, down, left, right)``, once the map has resolved every name."""
return (
cast(InputBinding, self.up),
cast(InputBinding, self.down),
cast(InputBinding, self.left),
cast(InputBinding, self.right),
)
[docs]
def __mul__(self, factor: float) -> Axis2D:
"""Every direction multiplied into the action's unit: ``Touch.PAN * 0.01`` reads the screen's width as 1."""
if not isinstance(factor, int | float) or isinstance(factor, bool):
return NotImplemented
up, down, left, right = _scaled(self.parts, lambda b: b * factor)
return replace(self, up=up, down=down, left=left, right=right)
__rmul__ = __mul__
[docs]
def per_second(self, rate: float) -> Axis2D:
"""Every direction as motion: the held value times *rate* each second."""
up, down, left, right = _scaled(self.parts, lambda b: b.per_second(rate))
return replace(self, up=up, down=down, left=left, right=right)
[docs]
@classmethod
def for_stick(cls, stick: DeviceStick, *, deadzone: float = 0.2) -> Axis2D:
"""One whole stick as a vector: the halves of its two axes, on the stick's unit."""
return cls(
up=InputBinding(stick.up, deadzone=0.0),
down=InputBinding(stick.down, deadzone=0.0),
left=InputBinding(stick.left, deadzone=0.0),
right=InputBinding(stick.right, deadzone=0.0),
deadzone=deadzone,
)
Axis2D.WASD = Axis2D(up=Key.W, down=Key.S, left=Key.A, right=Key.D)
Axis2D.ARROWS = Axis2D(up=Key.UP, down=Key.DOWN, left=Key.LEFT, right=Key.RIGHT)
#: One source of an entry's value: a binding, a composite, or another entry's name.
type Source = InputBinding | Axis1D | Axis2D | str
_KIND_OF: dict[type, ActionKind] = {
InputBinding: ActionKind.BUTTON,
Axis1D: ActionKind.AXIS_1D,
Axis2D: ActionKind.AXIS_2D,
}
_WIDTH = {ActionKind.BUTTON: 0, ActionKind.AXIS_1D: 1, ActionKind.AXIS_2D: 2}
[docs]
def to_source(spec: SourceSpec) -> Source:
"""Normalise one entry of an action's binding list into a source.
A bare axis becomes the :class:`Axis1D` over its two halves and a bare stick
the :class:`Axis2D` over its two axes, so everything below this point deals
in three source types rather than in every spelling of them.
"""
if isinstance(spec, (Axis1D, Axis2D, InputBinding)):
return spec
if isinstance(spec, str):
# Another entry's name, which the map expands.
if not spec:
raise ValueError("an entry's name cannot be empty")
return spec
if isinstance(spec, DeviceAxis) and not spec.half:
return Axis1D.for_device_axis(spec, deadzone=0.0 if spec.device is not DeviceType.CONTROLLER else 0.2)
if isinstance(spec, DeviceStick):
# A pad's stick rests near centre and wobbles; a gesture's motion does not.
return Axis2D.for_stick(spec, deadzone=0.2 if spec.device is DeviceType.CONTROLLER else 0.0)
if isinstance(spec, DeviceButton | DeviceAxis):
return InputBinding(spec)
raise TypeError(f"an input source is a control, a binding, a composite or an entry's name, got {spec!r}")
[docs]
def kind_for(name: str, sources: list[Source]) -> ActionKind:
"""The type an action has, given its sources, refusing a list that has none.
The widest source decides, and a narrower one has to have an unambiguous
reading inside it: a button in a float action pushes it positive, while a
button or a scalar in a 2D action names no direction at all and is refused
here rather than resolving to something the game did not mean.
"""
kind = ActionKind.BUTTON
sources = [source for source in sources if not isinstance(source, str)]
for source in sources:
candidate = _KIND_OF[type(source)]
if isinstance(source, InputBinding) and (source.scale != 1.0 or source.rate is not None):
# A value put into the action's units is a quantity, not a press.
candidate = ActionKind.AXIS_1D
if _WIDTH[candidate] > _WIDTH[kind]:
kind = candidate
if kind is ActionKind.AXIS_2D:
for source in sources:
if not isinstance(source, Axis2D):
raise TypeError(
f"input action {name!r} mixes a 2D source with {source!r}, which names no direction. "
"Every source of a 2D action must be one: wrap the buttons in "
"Axis2D(up=..., down=..., left=..., right=...), or give them their own action."
)
return kind
[docs]
@dataclass(slots=True)
class Action:
"""One entry of the input map: what it is called, what type it has, what drives it.
An action and a device alias are both entries. ``sources`` each produce a
whole value on their own and may name other entries; once the map has
expanded them, ``bindings`` is the same thing flattened to device bindings,
which is what a prompt and the event resolver read. An entry keyed by a
control rather than a name is a game's redefinition of an engine entry,
``Controller.A``, and has exactly one source.
"""
name: str | DeviceControl
kind: ActionKind = ActionKind.BUTTON
sources: list[Source] = field(default_factory=list)
bindings: list[InputBinding] = field(default_factory=list)
[docs]
def refresh(self) -> None:
"""Recompute the kind and the flattened bindings from the sources.
A name in the sources contributes nothing here: the map expands it,
and the expanded entry is the one the resolver reads.
"""
self.kind = kind_for(str(self.name), self.sources)
flat: list[InputBinding] = []
for source in self.sources:
if isinstance(source, InputBinding):
flat.append(source)
elif not isinstance(source, str):
flat.extend(part for part in source.parts if isinstance(part, InputBinding))
self.bindings = flat
[docs]
@property
def names(self) -> tuple[str, ...]:
"""Every entry this one names, in its sources and in its composites' parts, in order."""
found: list[str] = []
for source in self.sources:
if isinstance(source, str):
found.append(source)
elif not isinstance(source, InputBinding):
found.extend(part for part in source.parts if isinstance(part, str))
return tuple(found)