"""Input: input state tracker. Instance-based with module-level default."""
import contextvars
import logging
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from typing import cast
from ..math.types import Vec2
from . import gamepad as _gamepad
from .enums import JoyAxis, JoyButton, Key, MouseButton, MouseCaptureMode, _name_for_code
from .events import InputBinding
from .map import InputMap as _default_map
from .map import _InputMap
log = logging.getLogger(__name__)
# Axis names already reported once as out of range, so a backend that lies
# every frame says so once rather than sixty times a second.
_warned_axis_names: set[str] = set()
# The standard-gamepad names every backend reports, keyed to the typed codes
# the binding resolver reads. The names and their ranges are the convention in
# ``simvx.core.input.gamepad``; this is only the bridge to the typed codes.
# Exactly one name per code: the bridge in ``_update_gamepad`` diffs held-state
# per name, so a second spelling of the same code would read as released and
# fight the first.
_JOY_BUTTON_CODES: dict[str, int] = {
"a": int(JoyButton.A),
"b": int(JoyButton.B),
"x": int(JoyButton.X),
"y": int(JoyButton.Y),
"lb": int(JoyButton.LEFT_BUMPER),
"rb": int(JoyButton.RIGHT_BUMPER),
"back": int(JoyButton.BACK),
"start": int(JoyButton.START),
"guide": int(JoyButton.GUIDE),
"l3": int(JoyButton.LEFT_THUMB),
"r3": int(JoyButton.RIGHT_THUMB),
"dpad_up": int(JoyButton.DPAD_UP),
"dpad_right": int(JoyButton.DPAD_RIGHT),
"dpad_down": int(JoyButton.DPAD_DOWN),
"dpad_left": int(JoyButton.DPAD_LEFT),
}
_JOY_AXIS_CODES: dict[str, int] = {
"left_x": int(JoyAxis.LEFT_X),
"left_y": int(JoyAxis.LEFT_Y),
"right_x": int(JoyAxis.RIGHT_X),
"right_y": int(JoyAxis.RIGHT_Y),
"lt": int(JoyAxis.LEFT_TRIGGER),
"rt": int(JoyAxis.RIGHT_TRIGGER),
}
class _Input:
"""Input state tracker. Create new instances for per-tree isolation.
Tracks keyboard, mouse, and gamepad state.
Actions are registered via an InputMap; query with is_action_pressed() etc.
Direct key/button queries use is_key_pressed(Key.X) / is_mouse_button_pressed(MouseButton.LEFT).
"""
def __init__(self, input_map: _InputMap | None = None):
self.input_map = input_map if input_map is not None else _default_map
# --- Touch emulation (mouse <-> touch) ---
self._emulate_touch_from_mouse: bool = False
self._emulate_mouse_from_touch: bool = True
# --- String-keyed state (written by platform adapters, read by UI system) ---
# Kept alongside the typed-set state below because several layers
# still query it by name (cheaper than keycode lookup for the cases
# below): touching the keycode path would balloon those into
# multi-step enum conversions. The readers are:
# * ui_input.py builds combo_keys ("ctrl+s") from these mods on
# every keyboard event.
# * multiline.py / editor panels check ctrl/shift/alt for
# modifier-aware gestures.
# * play_mode.py snapshots them so the editor can pause/resume
# without leaking key state across the boundary.
# * The "mouse_1"/"mouse_2"/... entries let game code poll the
# mouse without remembering enum codes.
# The typed ``_keys_pressed: set[int]`` (below) drives the public
# ``Input.is_key_pressed(Key.X)`` and binding-resolver paths.
self._keys: dict[str, bool] = {}
self._keys_just_pressed: dict[str, bool] = {}
self._keys_just_released: dict[str, bool] = {}
self._mouse_pos: tuple[float, float] = (0.0, 0.0)
self._mouse_delta: tuple[float, float] = (0.0, 0.0)
# When True, the next motion event updates the position baseline but
# contributes no delta. Set across a discontinuity (capture mode change,
# focus regain, cursor warp) so the one-off jump in cursor position is
# not mistaken for real motion. Consumed by the next motion event.
self._discard_next_mouse_delta: bool = False
self._scroll_delta: tuple[float, float] = (0.0, 0.0)
self._gamepad_buttons: dict[int, dict[str, bool]] = {}
self._gamepad_axes: dict[int, dict[str, float]] = {}
# Where each pad's snapshot came from. A poll transaction prunes the
# pads a real device backend stopped reporting and leaves the ones a
# test injected alone, so a simulated pad behaves the same headless and
# under a window.
self._gamepad_sources: dict[int, str] = {}
# --- Typed state ---
self._keys_pressed: set[int] = set()
self._keys_just_pressed_typed: set[int] = set()
self._keys_just_released_typed: set[int] = set()
self._mouse_buttons_pressed: set[int] = set()
self._mouse_buttons_just_pressed: set[int] = set()
self._mouse_buttons_just_released: set[int] = set()
self._joy_axes: dict[int, float] = {}
# Value an axis held before the first write of the current frame, for
# the axes written this frame only. Populated by ``_on_joy_axis`` and
# cleared at the frame boundary, exactly as the just-pressed sets are,
# so an axis nobody moved reports no edge without needing a snapshot of
# every axis every frame.
self._joy_axes_prev: dict[int, float] = {}
self._joy_buttons_pressed: set[int] = set()
self._joy_buttons_just_pressed: set[int] = set()
self._joy_buttons_just_released: set[int] = set()
self._capture_mode: MouseCaptureMode = MouseCaptureMode.VISIBLE
self._capture_mode_callback: object = None # Platform sets this to apply capture
# --- Touch input ---
self._touches: dict[int, tuple[float, float, float]] = {}
self._touches_just_pressed: dict[int, tuple[float, float, float]] = {}
self._touches_just_released: set[int] = set()
# ----------------------------------------------------------------
# Action queries (via InputMap only)
# ----------------------------------------------------------------
def is_action_pressed(self, action: str) -> bool:
"""Check if any input mapped to the action is currently held."""
for b in self.input_map.get_bindings(action):
if self._binding_pressed(b):
return True
return False
def is_action_just_pressed(self, action: str) -> bool:
"""Check if any input mapped to the action was pressed this frame."""
for b in self.input_map.get_bindings(action):
if self._binding_just_pressed(b):
return True
return False
def is_action_just_released(self, action: str) -> bool:
"""Check if any input mapped to the action was released this frame."""
for b in self.input_map.get_bindings(action):
if self._binding_just_released(b):
return True
return False
def get_action_strength(self, action: str) -> float:
"""Return action strength: 1.0 for digital press, analog value for axes."""
strength = 0.0
for b in self.input_map.get_bindings(action):
strength = max(strength, self._binding_strength(b))
return strength
# Alias
get_strength = get_action_strength
def get_axis(self, negative_action: str, positive_action: str) -> float:
"""Return axis value from two opposing actions. Range [-1, 1]."""
return self.get_action_strength(positive_action) - self.get_action_strength(negative_action)
def get_vector(self, neg_x: str, pos_x: str, neg_y: str, pos_y: str) -> Vec2:
"""Return a normalized 2D direction vector from four input actions.
Handles diagonal normalization so magnitude never exceeds 1.0.
"""
x = self.get_action_strength(pos_x) - self.get_action_strength(neg_x)
y = self.get_action_strength(pos_y) - self.get_action_strength(neg_y)
v = Vec2(x, y)
ln = v.length()
return v / ln if ln > 1.0 else v
# ----------------------------------------------------------------
# Typed key query API
# ----------------------------------------------------------------
def is_key_pressed(self, key: Key) -> bool:
"""Check if a specific key is currently held down."""
return int(key) in self._keys_pressed
def is_key_just_pressed(self, key: Key) -> bool:
"""Check if a specific key was pressed this frame (not held from previous)."""
return int(key) in self._keys_just_pressed_typed
def is_key_just_released(self, key: Key) -> bool:
"""Check if a specific key was released this frame."""
return int(key) in self._keys_just_released_typed
# ----------------------------------------------------------------
# Mouse
# ----------------------------------------------------------------
def is_mouse_button_pressed(self, button: MouseButton) -> bool:
"""Check if a mouse button is currently held."""
return int(button) in self._mouse_buttons_pressed
def is_mouse_button_just_pressed(self, button: MouseButton) -> bool:
"""Check if a mouse button was pressed this frame."""
return int(button) in self._mouse_buttons_just_pressed
def is_mouse_button_just_released(self, button: MouseButton) -> bool:
"""Check if a mouse button was released this frame."""
return int(button) in self._mouse_buttons_just_released
@property
def mouse_position(self) -> Vec2:
"""Current mouse position in screen coordinates."""
return Vec2(self._mouse_pos[0], self._mouse_pos[1])
@property
def mouse_delta(self) -> Vec2:
"""Mouse movement delta this frame."""
return Vec2(self._mouse_delta[0], self._mouse_delta[1])
@property
def scroll_delta(self) -> tuple[float, float]:
"""Scroll wheel delta this frame (x, y)."""
return self._scroll_delta
@property
def mouse_wheel_y(self) -> float:
"""Vertical scroll-wheel delta this frame.
Scalar shortcut for the common case: most consumers only want
``Input.scroll_delta[1]`` (PirateMaker measured 90% of scroll-delta
uses), and ``Input.mouse_wheel_y`` reads cleaner than ``[1]``
indexing.
"""
return self._scroll_delta[1]
@property
def mouse_wheel_x(self) -> float:
"""Horizontal scroll-wheel delta this frame. Companion to ``mouse_wheel_y``."""
return self._scroll_delta[0]
def set_touch_emulation(self, enabled: bool = True):
"""Enable mouse-to-touch emulation (useful for testing touch on desktop).
When enabled, left mouse button presses/releases and mouse moves also
generate touch events with finger_id=0. This lets GestureRecognizer and
other touch-consuming code work with a mouse.
"""
self._emulate_touch_from_mouse = enabled
def set_mouse_from_touch_emulation(self, enabled: bool = True):
"""Control whether the primary touch finger fires synthetic mouse events.
Default is True: a primary-finger tap fires ``MouseButton.LEFT`` press
and release events, so InputMap actions bound to the left mouse button
work on touch devices without per-demo changes. Set to False when the
application needs to distinguish touch from mouse input (raw touch is
still delivered via ``touches`` / ``touches_just_pressed``).
"""
self._emulate_mouse_from_touch = enabled
def set_mouse_capture_mode(self, mode: MouseCaptureMode):
"""Set mouse cursor capture mode. Platform adapter applies the change."""
if mode != self._capture_mode:
# Toggling capture warps / hides / recentres the cursor, so the next
# motion event carries a one-off jump that must not drive the camera.
self._discard_next_mouse_delta = True
self._capture_mode = mode
if self._capture_mode_callback:
self._capture_mode_callback(mode)
def skip_next_mouse_delta(self):
"""Drop the next motion event's delta (re-baseline only).
Call across any cursor discontinuity the input layer can't infer on its
own, e.g. a manual cursor warp or a window focus regain under capture.
"""
self._discard_next_mouse_delta = True
def get_mouse_capture_mode(self) -> MouseCaptureMode:
"""Get the current mouse capture mode."""
return self._capture_mode
def is_mouse_captured(self) -> bool:
"""Whether the cursor is currently grabbed / locked to the window.
Returns ``True`` when the active capture mode is anything other than
:attr:`MouseCaptureMode.VISIBLE`: that is, ``HIDDEN``, ``CAPTURED``,
or ``CONFINED``. FPS-style scenes flip this on at start of gameplay
and off when a menu opens; verification harnesses (Q1K3) read it to
confirm pointer-lock state without mode-by-mode comparisons.
"""
return self._capture_mode != MouseCaptureMode.VISIBLE
# ----------------------------------------------------------------
# Gamepad
# ----------------------------------------------------------------
def get_gamepad_axis(self, pad_id: int = 0, axis: str | JoyAxis = "left_x") -> float:
"""Get one gamepad axis, in the engine's convention on every backend.
A stick axis (``"left_x"``, ``"left_y"``, ``"right_x"``, ``"right_y"``)
runs ``-1.0`` to ``1.0`` and rests at ``0.0``, with ``+y`` down the
screen. A trigger (``"lt"``, ``"rt"``) runs ``0.0`` released to ``1.0``
fully pulled. Every window backend maps its own library's range onto
this before the value arrives here.
Accepts either a string name (legacy) or JoyAxis enum.
"""
if isinstance(axis, JoyAxis):
return self._joy_axes.get(int(axis), 0.0)
return self._gamepad_axes.get(pad_id, {}).get(axis, 0.0)
def is_gamepad_pressed(self, pad_id: int = 0, button: str | JoyButton = "a") -> bool:
"""Check if gamepad button is pressed.
Accepts either a string name (legacy) or JoyButton enum.
"""
if isinstance(button, JoyButton):
return int(button) in self._joy_buttons_pressed
return self._gamepad_buttons.get(pad_id, {}).get(button, False)
def get_connected_gamepads(self) -> list[int]:
"""Ids of the gamepads readable right now, in ascending order.
A pad appears here from the first poll that reports it and disappears
on the first poll that does not, so unplugging a controller empties the
list and a game can pause rather than run on the last reading forever.
Ids are the backend's own and need not be contiguous.
A pad published by ``InputSimulator.set_gamepad`` counts as connected,
so a headless test sees what a windowed run sees. A backend with no
gamepad support at all reports nothing and prunes nothing: the list
stays empty rather than claiming every pad just left.
"""
return sorted(self._gamepad_sources)
def get_gamepad_vector(self, pad_id: int = 0, stick: str = "left") -> Vec2:
"""Get stick as Vec2 with deadzone applied."""
x = self.get_gamepad_axis(pad_id, f"{stick}_x")
y = self.get_gamepad_axis(pad_id, f"{stick}_y")
v = Vec2(x, y)
if v.length() < 0.15:
return Vec2(0, 0)
return v
# ----------------------------------------------------------------
# Public injection (for testing / virtual controls)
# ----------------------------------------------------------------
def inject_key(self, key: int | Key, pressed: bool) -> None:
"""Inject a synthetic key event. Same path as platform adapters."""
self._on_key(int(key), pressed)
def inject_mouse_button(self, button: int | MouseButton, pressed: bool) -> None:
"""Inject a synthetic mouse button event. Same path as platform adapters."""
self._on_mouse_button(int(button), pressed)
# ----------------------------------------------------------------
# Internal: called by platform adapters
# ----------------------------------------------------------------
def _on_key(self, key: int, pressed: bool):
"""Called by platform adapter (and ``InputSimulator``) for typed key events.
Mirrors the press into the string-keyed ``_keys`` state as well, so the
two key-tracking conventions stay in lock-step at this single
synchronisation point. Platform adapters already write both; this makes
synthetic events from ``InputSimulator``, which only reach this typed
path: drive the name-based consumers too (the shortcut listener,
``ui_input`` combo-key builder, modifier-aware editor gestures). The
string name comes from the same key-name map the adapters use, so
the redundant write from the platform path is idempotent.
"""
name = _name_for_code(key)
if pressed:
if key not in self._keys_pressed:
self._keys_just_pressed_typed.add(key)
self._keys_pressed.add(key)
if not self._keys.get(name):
self._keys_just_pressed[name] = True
self._keys[name] = True
else:
self._keys_pressed.discard(key)
self._keys_just_released_typed.add(key)
self._keys[name] = False
self._keys_just_released[name] = True
def _on_mouse_button(self, button: int, pressed: bool):
"""Called by platform adapter for typed mouse button events."""
if pressed:
if button not in self._mouse_buttons_pressed:
self._mouse_buttons_just_pressed.add(button)
self._mouse_buttons_pressed.add(button)
else:
self._mouse_buttons_pressed.discard(button)
self._mouse_buttons_just_released.add(button)
# Mouse->touch emulation: left button (0) maps to finger 0
if self._emulate_touch_from_mouse and button == 0:
x, y = self._mouse_pos
self._update_touch(0, 0 if pressed else 1, x, y, 1.0 if pressed else 0.0)
def _on_mouse_move(self, x: float, y: float):
"""Called by platform adapter for mouse movement."""
old = self._mouse_pos
self._mouse_pos = (x, y)
if self._discard_next_mouse_delta:
# Discontinuity (warp / capture toggle): re-baseline, no delta.
self._discard_next_mouse_delta = False
else:
# Accumulate within the frame (cleared by _end_frame); multiple
# motion events per frame must sum to the true per-frame delta
# rather than leaving only the last segment. See cursor_pos_callback.
self._mouse_delta = (self._mouse_delta[0] + (x - old[0]), self._mouse_delta[1] + (y - old[1]))
# Mouse->touch emulation: emit move only when finger 0 is "down" (left button held)
if self._emulate_touch_from_mouse and 0 in self._touches:
self._update_touch(0, 2, x, y, 1.0)
def _on_joy_button(self, button: int, pressed: bool):
"""Called by platform adapter for gamepad button events."""
if pressed:
if button not in self._joy_buttons_pressed:
self._joy_buttons_just_pressed.add(button)
self._joy_buttons_pressed.add(button)
else:
self._joy_buttons_pressed.discard(button)
self._joy_buttons_just_released.add(button)
def _on_joy_axis(self, axis: int, value: float):
"""Called by platform adapter for gamepad axis changes.
Remembers the value the axis held before this frame's first write, so
an axis binding can report a just-pressed / just-released edge against
its own threshold the way a button binding does.
"""
if axis not in self._joy_axes_prev:
self._joy_axes_prev[axis] = self._joy_axes.get(axis, 0.0)
self._joy_axes[axis] = value
def _update_gamepad(
self,
pad_id: int,
buttons: dict[str, bool],
axes: dict[str, float],
*,
source: str = "device",
):
"""Called by platform adapter to update one pad's state each frame.
Beyond the per-pad string dicts (the ``is_gamepad_pressed(0, "a")``
polling API), this bridges into the typed ``JoyButton``/``JoyAxis``
state the ``InputMap`` binding resolver reads.
Args:
pad_id: Which pad this snapshot belongs to.
buttons: Held state per name, in the standard set.
axes: Axis values per name, already in the engine's convention
(see :mod:`simvx.core.input.gamepad`).
source: ``"device"`` for a window backend's poll, ``"simulated"``
for a pad a test injected. A poll transaction prunes only
device pads, so an injected pad survives a real poll that does
not know about it.
"""
if __debug__:
for name in _gamepad.out_of_range_axes(axes):
if name not in _warned_axis_names:
_warned_axis_names.add(name)
log.warning(
"gamepad axis %r arrived at %r from source %r, outside the engine's range; "
"the backend should map through simvx.core.input.gamepad.normalise_axes",
name,
axes[name],
source,
)
self._gamepad_buttons[pad_id] = buttons
self._gamepad_axes[pad_id] = axes
self._gamepad_sources[pad_id] = source
self._rebuild_typed_gamepad_state()
def _rebuild_typed_gamepad_state(self) -> None:
"""Collapse every known pad into the typed state the binding resolver reads.
The typed state has no pad dimension, so it is a union: any pad's press
counts, and the largest-magnitude axis value wins, matching what the
enum branch of ``is_gamepad_pressed`` already assumes. Button edges go
through ``_on_joy_button`` so the just-pressed/just-released sets are
computed in one place, which is also what makes a pruned pad's held
button release properly rather than vanishing.
"""
all_buttons = self._gamepad_buttons.values()
for name, code in _JOY_BUTTON_CODES.items():
held = any(pad.get(name, False) for pad in all_buttons)
if held != (code in self._joy_buttons_pressed):
self._on_joy_button(code, held)
for name, code in _JOY_AXIS_CODES.items():
value = 0.0
for pad in self._gamepad_axes.values():
v = pad.get(name, 0.0)
if abs(v) > abs(value):
value = v
self._on_joy_axis(code, value)
@contextmanager
def _gamepad_poll(self) -> Iterator[Callable[[int, dict[str, bool], dict[str, float]], None]]:
"""One frame's gamepad poll, as a transaction rather than an add-only write.
Yields a ``report(pad_id, buttons, axes)`` the adapter calls once per
pad it found. On leaving the block every device pad that was *not*
reported is dropped and the typed state rebuilt, so a controller
unplugged mid-game releases its held buttons through the ordinary edge
logic instead of staying pressed until the process exits.
Reporting and closing the transaction are the same object, so an
adapter cannot record a pad without also declaring the poll complete.
A poll that raises part-way does not prune: half a frame's pads is not
evidence that the rest went away.
Only a backend that genuinely enumerates pads may open this. A backend
with no gamepad support must not, since an empty poll from it would
read as "every pad just left".
"""
seen: set[int] = set()
def report(pad_id: int, buttons: dict[str, bool], axes: dict[str, float]) -> None:
seen.add(pad_id)
self._update_gamepad(pad_id, buttons, axes)
yield report
stale = [pad for pad, src in self._gamepad_sources.items() if src == "device" and pad not in seen]
for pad in stale:
self._gamepad_buttons.pop(pad, None)
self._gamepad_axes.pop(pad, None)
del self._gamepad_sources[pad]
if stale:
self._rebuild_typed_gamepad_state()
# ----------------------------------------------------------------
# Touch input
# ----------------------------------------------------------------
def _update_touch(self, finger_id: int, action: int, x: float, y: float, pressure: float):
"""Called by platform adapter for touch events. action: 0=down, 1=up, 2=move."""
if action == 0: # down
self._touches[finger_id] = (x, y, pressure)
self._touches_just_pressed[finger_id] = (x, y, pressure)
elif action == 1: # up
self._touches.pop(finger_id, None)
self._touches_just_released.add(finger_id)
elif action == 2: # move
self._touches[finger_id] = (x, y, pressure)
@property
def touches(self) -> dict[int, tuple[float, float, float]]:
"""Active touches: ``{finger_id: (x, y, pressure)}``."""
return dict(self._touches)
@property
def touches_just_pressed(self) -> dict[int, tuple[float, float, float]]:
"""Touches that started this frame."""
return dict(self._touches_just_pressed)
@property
def touches_just_released(self) -> set[int]:
"""Finger IDs that were lifted this frame."""
return set(self._touches_just_released)
@property
def touch_positions(self) -> list[tuple[int, float, float, float]]:
"""Active touches as a list of ``(finger_id, x, y, pressure)``."""
return [(fid, x, y, p) for fid, (x, y, p) in self._touches.items()]
def is_touch_pressed(self, finger_id: int = 0) -> bool:
"""Whether finger_id is currently touching."""
return finger_id in self._touches
@property
def touch_count(self) -> int:
"""Number of active touch points."""
return len(self._touches)
def _new_frame(self):
"""Called by engine at frame start. Clears per-frame state."""
self._keys_just_pressed_typed.clear()
self._keys_just_released_typed.clear()
self._mouse_buttons_just_pressed.clear()
self._mouse_buttons_just_released.clear()
self._joy_buttons_just_pressed.clear()
self._joy_buttons_just_released.clear()
self._joy_axes_prev.clear()
def _end_frame(self):
"""Called by engine at frame end. Clears all per-frame state."""
self._keys_just_pressed.clear()
self._keys_just_released.clear()
self._keys_just_pressed_typed.clear()
self._keys_just_released_typed.clear()
self._mouse_buttons_just_pressed.clear()
self._mouse_buttons_just_released.clear()
self._joy_buttons_just_pressed.clear()
self._joy_buttons_just_released.clear()
self._joy_axes_prev.clear()
self._mouse_delta = (0.0, 0.0)
self._scroll_delta = (0.0, 0.0)
self._touches_just_pressed.clear()
self._touches_just_released.clear()
def _reset(self):
"""Reset all input state. Useful for testing."""
self._keys.clear()
self._keys_just_pressed.clear()
self._keys_just_released.clear()
self._mouse_pos = (0.0, 0.0)
self._mouse_delta = (0.0, 0.0)
self._discard_next_mouse_delta = False
self._scroll_delta = (0.0, 0.0)
self._gamepad_buttons.clear()
self._gamepad_axes.clear()
self._gamepad_sources.clear()
self._keys_pressed.clear()
self._keys_just_pressed_typed.clear()
self._keys_just_released_typed.clear()
self._mouse_buttons_pressed.clear()
self._mouse_buttons_just_pressed.clear()
self._mouse_buttons_just_released.clear()
self._joy_axes.clear()
self._joy_axes_prev.clear()
self._joy_buttons_pressed.clear()
self._joy_buttons_just_pressed.clear()
self._joy_buttons_just_released.clear()
self._capture_mode = MouseCaptureMode.VISIBLE
self._emulate_touch_from_mouse = False
self._touches.clear()
self._touches_just_pressed.clear()
self._touches_just_released.clear()
self.input_map.clear()
# ----------------------------------------------------------------
# Internal: binding resolution helpers
# ----------------------------------------------------------------
def _binding_mods_held(self, b: InputBinding) -> bool:
"""True when every modifier the binding REQUIRES is currently down.
A binding that declares no modifier accepts any modifier state, so an
action bound to plain Space keeps firing while the player holds Shift.
"""
for held, keys in (
(b.ctrl, (Key.LEFT_CONTROL, Key.RIGHT_CONTROL)),
(b.shift, (Key.LEFT_SHIFT, Key.RIGHT_SHIFT)),
(b.alt, (Key.LEFT_ALT, Key.RIGHT_ALT)),
):
if held and not any(int(k) in self._keys_pressed for k in keys):
return False
return True
@staticmethod
def _axis_beyond_deadzone(b: InputBinding, value: float) -> bool:
"""Whether *value* counts as a press for this axis binding's half of travel."""
if b.joy_axis_positive:
return value > b.deadzone
return value < -b.deadzone
def _axis_was_beyond_deadzone(self, b: InputBinding) -> bool:
"""The same test against the value the axis held before this frame.
An axis nobody moved this frame has no entry in ``_joy_axes_prev``, so
its previous value is its current one and neither edge fires.
Only an axis binding reaches here: both callers ask this inside their own
``joy_axis is not None`` branch.
"""
assert b.joy_axis is not None
code = int(b.joy_axis)
previous = self._joy_axes_prev.get(code, self._joy_axes.get(code, 0.0))
return self._axis_beyond_deadzone(b, previous)
def _binding_pressed(self, b: InputBinding) -> bool:
"""Check if a typed binding is currently pressed."""
if b.key is not None:
return int(b.key) in self._keys_pressed and self._binding_mods_held(b)
if b.mouse_button is not None:
return int(b.mouse_button) in self._mouse_buttons_pressed
if b.joy_button is not None:
return int(b.joy_button) in self._joy_buttons_pressed
if b.joy_axis is not None:
return self._axis_beyond_deadzone(b, self._joy_axes.get(int(b.joy_axis), 0.0))
return False
def _binding_just_pressed(self, b: InputBinding) -> bool:
"""Check if a typed binding was just pressed this frame."""
if b.key is not None:
return int(b.key) in self._keys_just_pressed_typed and self._binding_mods_held(b)
if b.mouse_button is not None:
return int(b.mouse_button) in self._mouse_buttons_just_pressed
if b.joy_button is not None:
return int(b.joy_button) in self._joy_buttons_just_pressed
if b.joy_axis is not None:
# An axis crosses its deadzone the way a button closes: the edge is
# the crossing, measured against the value the axis held before this
# frame's poll.
return self._axis_beyond_deadzone(
b, self._joy_axes.get(int(b.joy_axis), 0.0)
) and not self._axis_was_beyond_deadzone(b)
return False
def _binding_just_released(self, b: InputBinding) -> bool:
"""Check if a typed binding was just released this frame."""
if b.key is not None:
return int(b.key) in self._keys_just_released_typed and self._binding_mods_held(b)
if b.mouse_button is not None:
return int(b.mouse_button) in self._mouse_buttons_just_released
if b.joy_button is not None:
return int(b.joy_button) in self._joy_buttons_just_released
if b.joy_axis is not None:
return self._axis_was_beyond_deadzone(b) and not self._axis_beyond_deadzone(
b, self._joy_axes.get(int(b.joy_axis), 0.0)
)
return False
def _binding_strength(self, b: InputBinding) -> float:
"""Return analog strength [0, 1] for a binding."""
if b.key is not None:
return 1.0 if int(b.key) in self._keys_pressed and self._binding_mods_held(b) else 0.0
if b.mouse_button is not None:
return 1.0 if int(b.mouse_button) in self._mouse_buttons_pressed else 0.0
if b.joy_button is not None:
return 1.0 if int(b.joy_button) in self._joy_buttons_pressed else 0.0
if b.joy_axis is not None:
val = self._joy_axes.get(int(b.joy_axis), 0.0)
if b.joy_axis_positive:
return max(0.0, val) if val > b.deadzone else 0.0
return max(0.0, -val) if val < -b.deadzone else 0.0
return 0.0
_default_input = _Input()
_active_input: contextvars.ContextVar[_Input] = contextvars.ContextVar("_active_input", default=_default_input)
class _InputProxy:
"""Proxy that delegates all access to the active _Input for the current context.
Existing code using ``from simvx.core import Input; Input.is_action_pressed(...)``
continues to work: the proxy transparently routes to whichever _Input instance is
active in the current context (defaulting to the module-level default).
"""
__slots__ = ()
#: The class this proxy stands in for. ``tools/api_surface.py`` reads it and
#: freezes that class's public methods under the exported name: without it,
#: the package root publishes an opaque object and every method reachable
#: through ``Input`` is outside the guard that exists to catch a rename.
_api_surface_class = _Input
def __getattr__(self, name: str):
return getattr(_active_input.get(), name)
def __setattr__(self, name: str, value):
setattr(_active_input.get(), name, value)
def __delattr__(self, name: str):
delattr(_active_input.get(), name)
def __repr__(self) -> str:
return repr(_active_input.get())
# Declared as the class it stands in for. A type checker cannot see through
# ``__getattr__``, so without this every consumer of the ambient ``Input`` holds
# an opaque object: passing it where an ``_Input`` is wanted is an error, and
# reading a member off it is unchecked. Naming the class once here is the same
# claim ``_api_surface_class`` already makes, and it is the claim the proxy is
# built to keep -- it forwards every attribute, including the private ones the
# router and the UI seam read.
Input: _Input = cast(_Input, _InputProxy())