Source code for core.input

import pygame
import math
import time


# ---------------------------------------------------------------------------
# Touch data classes
# ---------------------------------------------------------------------------

[docs] class TouchPoint: """Represents a single touch point.""" __slots__ = ("finger_id", "x", "y", "dx", "dy", "pressure") def __init__(self, finger_id: int, x: float, y: float, dx: float = 0.0, dy: float = 0.0, pressure: float = 1.0): self.finger_id = finger_id self.x = x self.y = y self.dx = dx self.dy = dy self.pressure = pressure
[docs] class TouchGesture: """Holds recognized gesture data for the current frame.""" __slots__ = ("tap", "double_tap", "long_press", "swipe", "swipe_direction", "swipe_velocity", "pinch", "pinch_scale", "pinch_center", "rotate", "rotate_angle", "rotate_center") def __init__(self): self.tap = False self.double_tap = False self.long_press = False self.swipe = False self.swipe_direction = (0.0, 0.0) # normalized (dx, dy) self.swipe_velocity = 0.0 # pixels / second self.pinch = False self.pinch_scale = 1.0 # >1 = spread, <1 = pinch self.pinch_center = (0.0, 0.0) self.rotate = False self.rotate_angle = 0.0 # delta radians this frame self.rotate_center = (0.0, 0.0)
# --------------------------------------------------------------------------- # Input manager # ---------------------------------------------------------------------------
[docs] class Input: # --- Provider override for testing --- _provider = None # When set, delegates update/get_key/get_mouse_button etc. # --- Keyboard & Mouse (existing) --- _keys = {} _mouse_buttons = {} _mouse_pos = (0, 0) _game_mouse_pos = (0, 0) _mouse_mapper = None _events = [] # --- Joystick / Gamepad --- _joysticks: dict[int, pygame.joystick.JoystickType] = {} # instance_id -> Joystick _joy_buttons: dict[int, dict[int, bool]] = {} # instance_id -> {btn: pressed} _joy_buttons_prev: dict[int, dict[int, bool]] = {} _joy_axes: dict[int, dict[int, float]] = {} # instance_id -> {axis: value} _joy_hats: dict[int, dict[int, tuple]] = {} # instance_id -> {hat: (x,y)} _joy_deadzone: float = 0.15 # --- Touch --- _touches: dict[int, TouchPoint] = {} # finger_id -> TouchPoint (active) _touches_started: list[TouchPoint] = [] # began this frame _touches_moved: list[TouchPoint] = [] # moved this frame _touches_ended: list[TouchPoint] = [] # ended this frame # --- Gesture recognition state (internal) --- _gesture = TouchGesture() _gesture_tap_start: dict[int, tuple] = {} # finger_id -> (x, y, time) _gesture_last_tap_time: float = 0.0 _gesture_last_tap_pos: tuple = (0.0, 0.0) _gesture_prev_pinch_dist: float = 0.0 _gesture_prev_pinch_angle: float = 0.0 _gesture_long_press_threshold: float = 0.5 # seconds _gesture_tap_radius: float = 20.0 # max movement for a tap (px) _gesture_double_tap_time: float = 0.35 # seconds between taps _gesture_swipe_min_dist: float = 40.0 # min px for a swipe # ----------------------------------------------------------------------- # Provider injection for testing # -----------------------------------------------------------------------
[docs] @classmethod def set_provider(cls, provider): """Inject a provider object that overrides Input behaviour. The provider may implement any subset of: update(), get_key(key_code), get_mouse_button(index), get_mouse_position(), get_game_mouse_position(), get_events(), get_axis(name). Missing methods fall through to the default pygame implementation. Pass ``None`` to restore the default behaviour. """ cls._provider = provider
[docs] @classmethod def clear_provider(cls): """Remove any injected provider, restoring default pygame input.""" cls._provider = None
# ----------------------------------------------------------------------- # Core update # -----------------------------------------------------------------------
[docs] @classmethod def update(cls): if cls._provider and hasattr(cls._provider, "update"): cls._provider.update() return # Keyboard & mouse cls._keys = pygame.key.get_pressed() cls._mouse_buttons = pygame.mouse.get_pressed() cls._mouse_pos = pygame.mouse.get_pos() cls._events = pygame.event.get() # Map window coords to game/design coords if cls._mouse_mapper: mapped = cls._mouse_mapper(cls._mouse_pos[0], cls._mouse_pos[1]) cls._game_mouse_pos = mapped if mapped is not None else (-99999, -99999) else: cls._game_mouse_pos = cls._mouse_pos # Reset per-frame touch lists cls._touches_started.clear() cls._touches_moved.clear() cls._touches_ended.clear() # Reset gesture cls._gesture = TouchGesture() # Store previous button states for just-pressed / just-released cls._joy_buttons_prev = { jid: dict(btns) for jid, btns in cls._joy_buttons.items() } # Process events for event in cls._events: cls._process_joystick_event(event) cls._process_touch_event(event) # Update joystick continuous state cls._update_joystick_state() # Recognize gestures from touch data cls._recognize_gestures()
# ----------------------------------------------------------------------- # Mouse mapper # -----------------------------------------------------------------------
[docs] @classmethod def set_mouse_mapper(cls, mapper_fn): """Set a function (window_x, window_y) -> (game_x, game_y) or None.""" cls._mouse_mapper = mapper_fn
# ----------------------------------------------------------------------- # Events # -----------------------------------------------------------------------
[docs] @classmethod def get_events(cls): if cls._provider and hasattr(cls._provider, "get_events"): return cls._provider.get_events() return cls._events
# ----------------------------------------------------------------------- # Keyboard # -----------------------------------------------------------------------
[docs] @classmethod def get_key(cls, key_code): if cls._provider and hasattr(cls._provider, "get_key"): return cls._provider.get_key(key_code) return cls._keys[key_code] if cls._keys else False
# ----------------------------------------------------------------------- # Mouse # -----------------------------------------------------------------------
[docs] @classmethod def get_mouse_button(cls, button_index): if cls._provider and hasattr(cls._provider, "get_mouse_button"): return cls._provider.get_mouse_button(button_index) # 0: left, 1: middle, 2: right return cls._mouse_buttons[button_index] if cls._mouse_buttons else False
[docs] @classmethod def get_mouse_position(cls): if cls._provider and hasattr(cls._provider, "get_mouse_position"): return cls._provider.get_mouse_position() return cls._mouse_pos
[docs] @classmethod def get_game_mouse_position(cls): """Return mouse position mapped to game/design coordinates.""" if cls._provider and hasattr(cls._provider, "get_game_mouse_position"): return cls._provider.get_game_mouse_position() return cls._game_mouse_pos
# ----------------------------------------------------------------------- # Axes (keyboard + joystick merged) # -----------------------------------------------------------------------
[docs] @classmethod def get_axis(cls, axis_name: str) -> float: if cls._provider and hasattr(cls._provider, "get_axis"): return cls._provider.get_axis(axis_name) val = 0.0 if axis_name == "Horizontal": if cls.get_key(pygame.K_RIGHT) or cls.get_key(pygame.K_d): val += 1.0 if cls.get_key(pygame.K_LEFT) or cls.get_key(pygame.K_a): val -= 1.0 # Merge first joystick left stick X (axis 0) joy_val = cls.get_joy_axis(axis=0) if abs(joy_val) > abs(val): val = joy_val elif axis_name == "Vertical": if cls.get_key(pygame.K_UP) or cls.get_key(pygame.K_w): val += 1.0 if cls.get_key(pygame.K_DOWN) or cls.get_key(pygame.K_s): val -= 1.0 # Merge first joystick left stick Y (axis 1), invert so up = +1 joy_val = -cls.get_joy_axis(axis=1) if abs(joy_val) > abs(val): val = joy_val return max(-1.0, min(1.0, val))
# ----------------------------------------------------------------------- # Joystick / Gamepad # -----------------------------------------------------------------------
[docs] @classmethod def set_joystick_deadzone(cls, deadzone: float): """Set the deadzone threshold for joystick axes (default 0.15).""" cls._joy_deadzone = max(0.0, min(1.0, deadzone))
[docs] @classmethod def get_joystick_count(cls) -> int: """Return number of connected joysticks.""" return len(cls._joysticks)
[docs] @classmethod def get_joystick_ids(cls) -> list[int]: """Return list of connected joystick instance IDs.""" return list(cls._joysticks.keys())
[docs] @classmethod def get_joystick_name(cls, instance_id: int = -1) -> str: """Return joystick name. If instance_id=-1, use first connected.""" joy = cls._get_joystick(instance_id) return joy.get_name() if joy else ""
[docs] @classmethod def get_joy_button(cls, button: int, instance_id: int = -1) -> bool: """Return True if button is currently pressed.""" jid = cls._resolve_joy_id(instance_id) if jid is None: return False return cls._joy_buttons.get(jid, {}).get(button, False)
[docs] @classmethod def get_joy_button_down(cls, button: int, instance_id: int = -1) -> bool: """Return True if button was just pressed this frame.""" jid = cls._resolve_joy_id(instance_id) if jid is None: return False curr = cls._joy_buttons.get(jid, {}).get(button, False) prev = cls._joy_buttons_prev.get(jid, {}).get(button, False) return curr and not prev
[docs] @classmethod def get_joy_button_up(cls, button: int, instance_id: int = -1) -> bool: """Return True if button was just released this frame.""" jid = cls._resolve_joy_id(instance_id) if jid is None: return False curr = cls._joy_buttons.get(jid, {}).get(button, False) prev = cls._joy_buttons_prev.get(jid, {}).get(button, False) return not curr and prev
[docs] @classmethod def get_joy_axis(cls, axis: int, instance_id: int = -1) -> float: """Return axis value (-1..1) with deadzone applied.""" jid = cls._resolve_joy_id(instance_id) if jid is None: return 0.0 raw = cls._joy_axes.get(jid, {}).get(axis, 0.0) if abs(raw) < cls._joy_deadzone: return 0.0 # Remap from [deadzone..1] to [0..1] preserving sign sign = 1.0 if raw > 0 else -1.0 return sign * (abs(raw) - cls._joy_deadzone) / (1.0 - cls._joy_deadzone)
[docs] @classmethod def get_joy_hat(cls, hat: int = 0, instance_id: int = -1) -> tuple: """Return D-pad / hat value as (x, y) where x,y are -1, 0, or 1.""" jid = cls._resolve_joy_id(instance_id) if jid is None: return (0, 0) return cls._joy_hats.get(jid, {}).get(hat, (0, 0))
# -- Internal joystick helpers -- @classmethod def _resolve_joy_id(cls, instance_id: int): if instance_id == -1: if cls._joysticks: return next(iter(cls._joysticks)) return None return instance_id if instance_id in cls._joysticks else None @classmethod def _get_joystick(cls, instance_id: int): jid = cls._resolve_joy_id(instance_id) return cls._joysticks.get(jid) if jid is not None else None @classmethod def _process_joystick_event(cls, event): if event.type == pygame.JOYDEVICEADDED: joy = pygame.joystick.Joystick(event.device_index) joy.init() jid = joy.get_instance_id() cls._joysticks[jid] = joy cls._joy_buttons[jid] = {} cls._joy_axes[jid] = {} cls._joy_hats[jid] = {} elif event.type == pygame.JOYDEVICEREMOVED: jid = event.instance_id cls._joysticks.pop(jid, None) cls._joy_buttons.pop(jid, None) cls._joy_buttons_prev.pop(jid, None) cls._joy_axes.pop(jid, None) cls._joy_hats.pop(jid, None) elif event.type == pygame.JOYBUTTONDOWN: cls._joy_buttons.setdefault(event.instance_id, {})[event.button] = True elif event.type == pygame.JOYBUTTONUP: cls._joy_buttons.setdefault(event.instance_id, {})[event.button] = False elif event.type == pygame.JOYAXISMOTION: cls._joy_axes.setdefault(event.instance_id, {})[event.axis] = event.value elif event.type == pygame.JOYHATMOTION: cls._joy_hats.setdefault(event.instance_id, {})[event.hat] = event.value @classmethod def _update_joystick_state(cls): """Refresh continuous joystick state from SDL.""" for jid, joy in cls._joysticks.items(): axes = cls._joy_axes.setdefault(jid, {}) for i in range(joy.get_numaxes()): axes[i] = joy.get_axis(i) btns = cls._joy_buttons.setdefault(jid, {}) for i in range(joy.get_numbuttons()): btns[i] = joy.get_button(i) hats = cls._joy_hats.setdefault(jid, {}) for i in range(joy.get_numhats()): hats[i] = joy.get_hat(i) # ----------------------------------------------------------------------- # Touch # -----------------------------------------------------------------------
[docs] @classmethod def get_touches(cls) -> dict[int, TouchPoint]: """Return dict of all currently active touch points {finger_id: TouchPoint}.""" return cls._touches
[docs] @classmethod def get_touch_count(cls) -> int: """Return number of currently active touch points.""" return len(cls._touches)
[docs] @classmethod def get_touches_started(cls) -> list[TouchPoint]: """Return list of touches that began this frame.""" return cls._touches_started
[docs] @classmethod def get_touches_moved(cls) -> list[TouchPoint]: """Return list of touches that moved this frame.""" return cls._touches_moved
[docs] @classmethod def get_touches_ended(cls) -> list[TouchPoint]: """Return list of touches that ended this frame.""" return cls._touches_ended
[docs] @classmethod def is_touching(cls) -> bool: """Return True if any finger is currently touching.""" return len(cls._touches) > 0
# -- Internal touch helpers -- @classmethod def _process_touch_event(cls, event): if event.type == pygame.FINGERDOWN: # SDL touch coords are normalized 0..1, convert to window pixels w, h = pygame.display.get_surface().get_size() if pygame.display.get_surface() else (1, 1) tp = TouchPoint( finger_id=event.finger_id, x=event.x * w, y=event.y * h, dx=event.dx * w, dy=event.dy * h, pressure=getattr(event, "pressure", 1.0) ) cls._touches[event.finger_id] = tp cls._touches_started.append(tp) # Gesture: record start cls._gesture_tap_start[event.finger_id] = (tp.x, tp.y, time.time()) elif event.type == pygame.FINGERMOTION: w, h = pygame.display.get_surface().get_size() if pygame.display.get_surface() else (1, 1) tp = TouchPoint( finger_id=event.finger_id, x=event.x * w, y=event.y * h, dx=event.dx * w, dy=event.dy * h, pressure=getattr(event, "pressure", 1.0) ) cls._touches[event.finger_id] = tp cls._touches_moved.append(tp) elif event.type == pygame.FINGERUP: w, h = pygame.display.get_surface().get_size() if pygame.display.get_surface() else (1, 1) tp = TouchPoint( finger_id=event.finger_id, x=event.x * w, y=event.y * h, dx=event.dx * w, dy=event.dy * h, pressure=0.0 ) cls._touches.pop(event.finger_id, None) cls._touches_ended.append(tp) # ----------------------------------------------------------------------- # Gesture recognition # -----------------------------------------------------------------------
[docs] @classmethod def get_gesture(cls) -> TouchGesture: """Return the recognized gesture data for the current frame.""" return cls._gesture
@classmethod def _recognize_gestures(cls): now = time.time() g = cls._gesture # --- Tap / Double-tap / Swipe (on finger up) --- for tp in cls._touches_ended: start = cls._gesture_tap_start.pop(tp.finger_id, None) if start is None: continue sx, sy, st = start dist = math.hypot(tp.x - sx, tp.y - sy) duration = now - st if dist <= cls._gesture_tap_radius: if duration < cls._gesture_long_press_threshold: # It's a tap g.tap = True # Check double-tap if (now - cls._gesture_last_tap_time < cls._gesture_double_tap_time and math.hypot(tp.x - cls._gesture_last_tap_pos[0], tp.y - cls._gesture_last_tap_pos[1]) < cls._gesture_tap_radius * 2): g.double_tap = True cls._gesture_last_tap_time = now cls._gesture_last_tap_pos = (tp.x, tp.y) elif dist >= cls._gesture_swipe_min_dist and duration < 0.5: # It's a swipe g.swipe = True dx = tp.x - sx dy = tp.y - sy length = math.hypot(dx, dy) g.swipe_direction = (dx / length, dy / length) if length > 0 else (0.0, 0.0) g.swipe_velocity = length / max(duration, 0.001) # --- Long press detection (finger still held) --- if len(cls._touches) == 1: fid = next(iter(cls._touches)) start = cls._gesture_tap_start.get(fid) if start: sx, sy, st = start tp = cls._touches[fid] dist = math.hypot(tp.x - sx, tp.y - sy) if dist <= cls._gesture_tap_radius and (now - st) >= cls._gesture_long_press_threshold: g.long_press = True # --- Pinch & Rotate (two fingers) --- if len(cls._touches) == 2: fingers = list(cls._touches.values()) f1, f2 = fingers[0], fingers[1] cx = (f1.x + f2.x) / 2.0 cy = (f1.y + f2.y) / 2.0 dist = math.hypot(f2.x - f1.x, f2.y - f1.y) angle = math.atan2(f2.y - f1.y, f2.x - f1.x) if cls._gesture_prev_pinch_dist > 0: # Pinch if dist > 0: g.pinch = True g.pinch_scale = dist / cls._gesture_prev_pinch_dist g.pinch_center = (cx, cy) # Rotate delta_angle = angle - cls._gesture_prev_pinch_angle # Normalize to [-pi, pi] while delta_angle > math.pi: delta_angle -= 2 * math.pi while delta_angle < -math.pi: delta_angle += 2 * math.pi if abs(delta_angle) > 0.001: g.rotate = True g.rotate_angle = delta_angle g.rotate_center = (cx, cy) cls._gesture_prev_pinch_dist = dist cls._gesture_prev_pinch_angle = angle else: cls._gesture_prev_pinch_dist = 0.0 cls._gesture_prev_pinch_angle = 0.0 # Clean up stale gesture starts for fingers no longer present active_ids = set(cls._touches.keys()) stale = [fid for fid in cls._gesture_tap_start if fid not in active_ids] for fid in stale: cls._gesture_tap_start.pop(fid, None) # ----------------------------------------------------------------------- # Joystick convenience constants (common gamepad mapping) # ----------------------------------------------------------------------- # Buttons (Xbox-style layout, SDL default) JOY_A = 0 JOY_B = 1 JOY_X = 2 JOY_Y = 3 JOY_LB = 4 # Left bumper JOY_RB = 5 # Right bumper JOY_BACK = 6 JOY_START = 7 JOY_L3 = 8 # Left stick press JOY_R3 = 9 # Right stick press # Axes JOY_LEFT_X = 0 JOY_LEFT_Y = 1 JOY_RIGHT_X = 2 JOY_RIGHT_Y = 3 JOY_LT = 4 # Left trigger JOY_RT = 5 # Right trigger