"""
Action-map based input bindings for scene systems.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import (
TYPE_CHECKING,
Any,
Callable,
Generic,
Mapping,
Protocol,
TypeVar,
)
from mini_arcade_core.backend.keys import Key
from mini_arcade_core.runtime.input_frame import ButtonState, InputFrame
from mini_arcade_core.scenes.systems.base_system import BaseSystem
from mini_arcade_core.scenes.systems.phases import SystemPhase
if TYPE_CHECKING:
from mini_arcade_core.scenes.sim_scene import BaseIntent
else:
BaseIntent = object
# pylint: disable=invalid-name
TContext = TypeVar("TContext")
TIntent = TypeVar("TIntent", bound=BaseIntent)
# pylint: enable=invalid-name
[docs]
@dataclass(frozen=True)
class ActionState:
"""
Normalized per-action state.
"""
value: float = 0.0
down: bool = False
pressed: bool = False
released: bool = False
[docs]
@dataclass(frozen=True)
class ActionSnapshot:
"""
Per-frame snapshot for all mapped actions.
"""
_states: Mapping[str, ActionState] = field(default_factory=dict)
[docs]
def state(self, action: str) -> ActionState:
"""
Get the ActionState for the given action, or a default if not found.
:param action: The name of the action to get the state for.
:type action: str
:return: The ActionState for the given action, or a default if not found.
:rtype: ActionState
"""
return self._states.get(action, ActionState())
[docs]
def value(self, action: str, default: float = 0.0) -> float:
"""
Get the normalized value of the action, or a default if not found.
:param action: The name of the action to get the value for.
:type action: str
:param default: The default value to return if the action is not found (default 0.0).
:type default: float
:return: The normalized value of the action, or the default if not found.
:rtype: float
"""
return self._states.get(action, ActionState(value=default)).value
[docs]
def down(self, action: str) -> bool:
"""
Check if the action is currently held down.
:param action: The name of the action to check.
:type action: str
:return: True if the action is currently held down, False otherwise.
:rtype: bool
"""
return self.state(action).down
[docs]
def pressed(self, action: str) -> bool:
"""
Check if the action was pressed this frame.
:param action: The name of the action to check.
:type action: str
:return: True if the action was pressed this frame, False otherwise.
:rtype: bool
"""
return self.state(action).pressed
[docs]
def released(self, action: str) -> bool:
"""
Check if the action was released this frame.
:param action: The name of the action to check.
:type action: str
:return: True if the action was released this frame, False otherwise.
:rtype: bool
"""
return self.state(action).released
[docs]
class ActionBinding(Protocol):
"""
Strategy contract for one logical action binding.
"""
[docs]
def read(self, frame: InputFrame) -> ActionState:
"""
Read the current state of this action from the input frame.
:param frame: The input frame containing raw input states.
:type frame: InputFrame
:return: The current ActionState for this binding.
:rtype: ActionState
"""
def _button_state(frame: InputFrame, name: str) -> ButtonState:
return frame.buttons.get(name, ButtonState(False, False, False))
[docs]
@dataclass(frozen=True)
class DigitalActionBinding(ActionBinding):
"""
Digital action sourced from keyboard and/or named buttons.
"""
keys: tuple[Key, ...] = ()
buttons: tuple[str, ...] = ()
[docs]
def read(self, frame: InputFrame) -> ActionState:
key_down = any(k in frame.keys_down for k in self.keys)
key_pressed = any(k in frame.keys_pressed for k in self.keys)
key_released = any(k in frame.keys_released for k in self.keys)
btn_down = any(_button_state(frame, b).down for b in self.buttons)
btn_pressed = any(
_button_state(frame, b).pressed for b in self.buttons
)
btn_released = any(
_button_state(frame, b).released for b in self.buttons
)
down = key_down or btn_down
return ActionState(
value=1.0 if down else 0.0,
down=down,
pressed=key_pressed or btn_pressed,
released=key_released or btn_released,
)
[docs]
@dataclass(frozen=True)
class AxisActionBinding(ActionBinding):
"""
Axis action sourced from analog axes and optional digital fallbacks.
"""
axes: tuple[str, ...] = ()
positive_keys: tuple[Key, ...] = ()
negative_keys: tuple[Key, ...] = ()
positive_buttons: tuple[str, ...] = ()
negative_buttons: tuple[str, ...] = ()
deadzone: float = 0.15
scale: float = 1.0
[docs]
def read(self, frame: InputFrame) -> ActionState:
analog = 0.0
for axis in self.axes:
analog += float(frame.axes.get(axis, 0.0))
pos_down = any(
k in frame.keys_down for k in self.positive_keys
) or any(_button_state(frame, b).down for b in self.positive_buttons)
neg_down = any(
k in frame.keys_down for k in self.negative_keys
) or any(_button_state(frame, b).down for b in self.negative_buttons)
digital = (1.0 if pos_down else 0.0) - (1.0 if neg_down else 0.0)
value = (analog + digital) * self.scale
value = max(-1.0, min(1.0, value))
pressed = (
any(k in frame.keys_pressed for k in self.positive_keys)
or any(k in frame.keys_pressed for k in self.negative_keys)
or any(
_button_state(frame, b).pressed for b in self.positive_buttons
)
or any(
_button_state(frame, b).pressed for b in self.negative_buttons
)
)
released = (
any(k in frame.keys_released for k in self.positive_keys)
or any(k in frame.keys_released for k in self.negative_keys)
or any(
_button_state(frame, b).released for b in self.positive_buttons
)
or any(
_button_state(frame, b).released for b in self.negative_buttons
)
)
down = abs(value) > self.deadzone or pos_down or neg_down
return ActionState(
value=value,
down=down,
pressed=pressed,
released=released,
)
[docs]
@dataclass(frozen=True)
class ActionMap:
"""
Mapping of action IDs to concrete binding strategies.
"""
bindings: Mapping[str, ActionBinding] = field(default_factory=dict)
[docs]
def read(self, frame: InputFrame) -> ActionSnapshot:
"""
Read the current state of all actions from the input frame.
:param frame: The input frame containing raw input states.
:type frame: InputFrame
:return: An ActionSnapshot containing the state of all actions.
:rtype: ActionSnapshot
"""
states = {
name: binding.read(frame)
for name, binding in self.bindings.items()
}
return ActionSnapshot(states)
def _to_str_seq(value: object) -> tuple[str, ...]:
if not isinstance(value, (list, tuple)):
return ()
return tuple(str(item) for item in value if isinstance(item, str))
def _to_key_seq(value: object) -> tuple[Key, ...]:
keys: list[Key] = []
for name in _to_str_seq(value):
normalized = name.strip().upper()
if not normalized:
continue
try:
keys.append(Key[normalized])
except KeyError:
continue
return tuple(keys)
[docs]
def action_map_from_bindings_config(
bindings: Mapping[str, Any] | None,
) -> ActionMap:
"""
Build an ActionMap from YAML-friendly binding dictionaries.
Supported entry formats per action:
- digital:
type: digital
keys: [ESCAPE]
buttons: [pad_start]
- axis:
type: axis
axes: [left_y]
positive_keys: [S]
negative_keys: [W]
positive_buttons: [pad_down]
negative_buttons: [pad_up]
deadzone: 0.15
scale: 1.0
"""
if not isinstance(bindings, Mapping):
return ActionMap()
parsed: dict[str, ActionBinding] = {}
for action_name, raw_cfg in bindings.items():
if not isinstance(action_name, str) or not isinstance(
raw_cfg, Mapping
):
continue
kind = str(raw_cfg.get("type", "")).strip().lower()
has_axis_shape = any(
key in raw_cfg
for key in (
"axes",
"positive_keys",
"negative_keys",
"positive_buttons",
"negative_buttons",
)
)
if not kind:
kind = "axis" if has_axis_shape else "digital"
if kind == "axis":
parsed[action_name] = AxisActionBinding(
axes=_to_str_seq(raw_cfg.get("axes")),
positive_keys=_to_key_seq(raw_cfg.get("positive_keys")),
negative_keys=_to_key_seq(raw_cfg.get("negative_keys")),
positive_buttons=_to_str_seq(raw_cfg.get("positive_buttons")),
negative_buttons=_to_str_seq(raw_cfg.get("negative_buttons")),
deadzone=float(raw_cfg.get("deadzone", 0.15)),
scale=float(raw_cfg.get("scale", 1.0)),
)
continue
parsed[action_name] = DigitalActionBinding(
keys=_to_key_seq(raw_cfg.get("keys")),
buttons=_to_str_seq(raw_cfg.get("buttons")),
)
return ActionMap(bindings=parsed)
[docs]
def action_map_from_controls_config(
controls_cfg: Mapping[str, Any] | None,
*,
scene_key: str,
default_action_map: ActionMap,
) -> ActionMap:
"""
Resolve one scene ActionMap from gameplay.controls config.
Expected layout:
gameplay:
controls:
<scene_key>:
bindings: {...}
"""
if not isinstance(controls_cfg, Mapping):
return default_action_map
scene_cfg = controls_cfg.get(scene_key)
if not isinstance(scene_cfg, Mapping):
return default_action_map
parsed = action_map_from_bindings_config(scene_cfg.get("bindings"))
if parsed.bindings:
return parsed
return default_action_map
[docs]
@dataclass
class ActionIntentSystem(BaseSystem[TContext], Generic[TContext, TIntent]):
"""
Input system that converts an ActionMap snapshot into scene intent.
"""
action_map: ActionMap
intent_factory: Callable[[ActionSnapshot, TContext], TIntent]
name: str = "action_intent"
phase: int = SystemPhase.INPUT
order: int = 10
channel: str | None = None
write_to_ctx_intent: bool = True
[docs]
def step(self, ctx: TContext) -> None:
frame = getattr(ctx, "input_frame", None)
if frame is None:
return
snapshot = self.action_map.read(frame)
intent = self.intent_factory(snapshot, ctx)
if self.channel:
channels = getattr(ctx, "intent_channels", None)
if channels is None:
channels = {}
setattr(ctx, "intent_channels", channels)
channels[self.channel] = intent
if self.write_to_ctx_intent:
setattr(ctx, "intent", intent)