Source code for simvx.core.ui.gamepad_overlay

"""GamepadOverlay: on-screen virtual gamepad for touch / mobile play.

Promoted from ``games/dungeon_explorer/scripts/virtual_controls_overlay.py``.
Renders a left-side analog stick, a primary action button (right-bottom), a
secondary button (back / dodge), four ability buttons in an arc, and two
optional rectangle menu buttons + a hotbar strip. Touch input is multi-touch
aware (each finger tracks one element independently); desktop falls back to a
single-pointer mouse path.

Inputs are emitted through :func:`simvx.core.Input.inject_key` so existing
keyboard-driven game logic works unchanged: no signals to wire up.

Unlike the lower-level :class:`VirtualJoystick` / :class:`VirtualButton`
widgets (which are signal-based Controls), ``GamepadOverlay`` is a plain
object you instantiate, configure, then drive from ``on_process`` /
``on_draw`` callbacks of your root node. Multi-touch tracking, joystick →
WASD nav, and context-sensitive button labels all live here.

Example::

    from simvx.core.ui import GamepadOverlay
    self.overlay = GamepadOverlay()
    self.overlay.configure(screen_w=1280, screen_h=720, mobile=True)
    # in on_process:
    self.overlay.on_process(dt)
    # in on_draw:
    self.overlay.on_draw(renderer)
"""

from __future__ import annotations

import math
from typing import Iterable

from ..input import Input, Key, MouseButton
from ..math.types import Vec2

__all__ = ["GamepadOverlay"]


[docs] class GamepadOverlay: """On-screen virtual gamepad: joystick + 6 buttons + optional hotbar.""" # Menu-mode ability key overrides (mirror the dungeon-explorer convention). _MENU_ABILITY_KEYS = (Key.TAB, Key.U, Key.KEY_3, Key.KEY_4) def __init__( self, *, ability_keys: Iterable[Key] = (Key.KEY_1, Key.KEY_2, Key.KEY_3, Key.KEY_4), primary_keys: Iterable[Key] = (Key.SPACE, Key.E), secondary_key: Key = Key.LEFT_SHIFT, menu_key: Key = Key.ESCAPE, inventory_key: Key = Key.I, hotbar_keys: Iterable[Key] = (Key.KEY_1, Key.KEY_2, Key.KEY_3, Key.KEY_4), primary_label: str = "ATK", secondary_label: str = "DODGE", menu_label: str = "MENU", inventory_label: str = "ITEMS", ): self._visible = False # Layout: populated by configure() self._joystick_pos = Vec2(120, 0) self._joystick_radius = 126.0 self._joystick_active = False self._joystick_thumb = Vec2() self._joystick_value = Vec2() self._joystick_touch_id: int | None = None self._atk_pos = Vec2() self._atk_radius = 56.0 self._blk_pos = Vec2() self._blk_radius = 48.0 self._ability_positions: list[Vec2] = [] self._ability_radius = 34.0 self._pause_rect = (0.0, 0.0, 0.0, 0.0) self._inv_rect = (0.0, 0.0, 0.0, 0.0) self._hotbar_rects: list[tuple[float, float, float, float]] = [] # State self._atk_pressed = False self._blk_pressed = False self._ability_pressed = [False, False, False, False] self._pause_pressed = False self._inv_pressed = False self._joy_keys_held: set[Key] = set() self._joy_tap_cooldown = 0.0 self._screen_w = 1280 self._screen_h = 720 self._ui_scale = 1.0 # Context labels: overridable per frame via set_context(). self._has_shield = False self._near_interactable = False self._in_menu = False self._atk_label = primary_label self._blk_label = secondary_label # Static config self._ability_keys = tuple(ability_keys) self._primary_keys = tuple(primary_keys) self._secondary_key = secondary_key self._menu_key = menu_key self._inventory_key = inventory_key self._hotbar_keys = tuple(hotbar_keys) self._primary_label = primary_label self._secondary_label = secondary_label self._menu_label = menu_label self._inventory_label = inventory_label # ------------------------------------------------------------------ layout
[docs] def configure(self, screen_w: int, screen_h: int, mobile: bool = False) -> None: """Recompute positions for the given viewport. Dropping any in-flight touch / press state, so a viewport rotation doesn't leave a button or joystick stuck to a stale coordinate. """ if (screen_w, screen_h) != (self._screen_w, self._screen_h): self._release_all() self._screen_w = screen_w self._screen_h = screen_h if mobile: js = 1.6 bs = 2.0 js = min(js, min(screen_w, screen_h) / 4 / 126.0) else: js = 1.0 bs = 1.0 self._ui_scale = bs self._joystick_radius = 126.0 * js self._atk_radius = 56.0 * bs self._blk_radius = 48.0 * bs self._ability_radius = 34.0 * bs jm = 160 * js self._joystick_pos = Vec2(jm, screen_h - jm) mid_x = screen_w / 2 base_x = screen_w - 130 * bs base_y = screen_h - 120 * bs self._atk_pos = Vec2(base_x, base_y) self._blk_pos = Vec2(base_x - 100 * bs, base_y - 70 * bs) self._ability_positions = [ Vec2(base_x - 150 * bs, base_y - 140 * bs), Vec2(base_x - 70 * bs, base_y - 165 * bs), Vec2(base_x + 15 * bs, base_y - 155 * bs), Vec2(base_x + 70 * bs, base_y - 100 * bs), ] hb_slot_size = 40 hb_pad = 5 hb_total_w = 4 * (hb_slot_size + hb_pad) hb_sx = mid_x - hb_total_w / 2 hb_sy = screen_h - hb_slot_size - 12 self._hotbar_rects = [ (hb_sx + i * (hb_slot_size + hb_pad), hb_sy, hb_slot_size, hb_slot_size) for i in range(4) ] btn_w, btn_h = 80 * bs, 36 * bs gap = 10 menu_y = hb_sy - btn_h - 8 self._pause_rect = (mid_x - btn_w - gap / 2, menu_y, btn_w, btn_h) self._inv_rect = (mid_x + gap / 2, menu_y, btn_w, btn_h)
@property def visible(self) -> bool: return self._visible
[docs] @visible.setter def visible(self, val: bool): was = self._visible self._visible = val if was and not val: self._release_all()
[docs] def set_context(self, *, has_shield: bool = False, near_interactable: bool = False, in_menu: bool = False) -> None: """Update context-sensitive labels (interact prompt, block vs dodge, menu mode).""" self._has_shield = has_shield self._near_interactable = near_interactable self._in_menu = in_menu if in_menu: self._atk_label = "OK" self._blk_label = "BACK" else: self._atk_label = "E" if near_interactable else self._primary_label self._blk_label = "BLK" if has_shield else self._secondary_label
[docs] @property def joystick_value(self) -> Vec2: """Normalised joystick output ``(-1..1, -1..1)``. Drives player input.""" return self._joystick_value
# ------------------------------------------------------------------ tick
[docs] def on_process(self, dt: float) -> None: if not self._visible: return self._process_mouse_input() # Joystick → directional key injection for menu/dialog navigation. self._joy_tap_cooldown = max(0.0, self._joy_tap_cooldown - dt) jx, jy = self._joystick_value.x, self._joystick_value.y want: set[Key] = set() if jy < -0.5: want.add(Key.W) elif jy > 0.5: want.add(Key.S) if jx < -0.5: want.add(Key.A) elif jx > 0.5: want.add(Key.D) for k in self._joy_keys_held - want: Input.inject_key(k, False) for k in want - self._joy_keys_held: if self._joy_tap_cooldown <= 0: Input.inject_key(k, True) self._joy_tap_cooldown = 0.22 self._joy_keys_held = want
def _process_mouse_input(self) -> None: touches = Input.touches just_pressed = Input.touches_just_pressed just_released = Input.touches_just_released if touches or just_pressed or just_released: self._process_touches(touches, just_pressed, just_released) else: self._process_mouse_fallback() def _process_touches(self, touches: dict, just_pressed: dict, just_released: set) -> None: consumed = False for fid, (fx, fy, _pressure) in just_pressed.items(): dx = fx - self._joystick_pos.x dy = fy - self._joystick_pos.y if math.sqrt(dx * dx + dy * dy) <= self._joystick_radius: self._joystick_active = True self._joystick_touch_id = fid self._update_joystick(fx, fy) consumed = True continue if self._check_button_hit(fx, fy, self._atk_pos, self._atk_radius): self._atk_pressed = True for k in self._primary_keys: Input.inject_key(k, True) consumed = True continue if self._check_button_hit(fx, fy, self._blk_pos, self._blk_radius): self._blk_pressed = True if self._in_menu: Input.inject_key(self._menu_key, True) else: Input.inject_key(self._secondary_key, True) consumed = True continue hit_ability = False for i, pos in enumerate(self._ability_positions): if self._check_button_hit(fx, fy, pos, self._ability_radius): self._ability_pressed[i] = True Input.inject_key(self._ability_key(i), True) hit_ability = True break if hit_ability: consumed = True continue if self._rect_hit(fx, fy, self._pause_rect): self._pause_pressed = True Input.inject_key(self._menu_key, True) consumed = True elif self._rect_hit(fx, fy, self._inv_rect): self._inv_pressed = True Input.inject_key(self._inventory_key, True) consumed = True else: for hi, hr in enumerate(self._hotbar_rects): if self._rect_hit(fx, fy, hr) and hi < len(self._hotbar_keys): Input.inject_key(self._hotbar_keys[hi], True) consumed = True break if consumed: Input._mouse_buttons_just_pressed.discard(MouseButton.LEFT) if self._joystick_active and self._joystick_touch_id is not None: if self._joystick_touch_id in touches: fx, fy, _ = touches[self._joystick_touch_id] self._update_joystick(fx, fy) elif self._joystick_touch_id in just_released: self._joystick_active = False self._joystick_touch_id = None self._joystick_thumb = Vec2() self._joystick_value = Vec2() if just_released: self._release_button_keys() def _process_mouse_fallback(self) -> None: mx, my = Input.mouse_position if Input.is_mouse_button_just_pressed(MouseButton.LEFT): dx = mx - self._joystick_pos.x dy = my - self._joystick_pos.y if math.sqrt(dx * dx + dy * dy) <= self._joystick_radius: self._joystick_active = True self._update_joystick(mx, my) return if self._check_button_hit(mx, my, self._atk_pos, self._atk_radius): self._atk_pressed = True for k in self._primary_keys: Input.inject_key(k, True) return if self._check_button_hit(mx, my, self._blk_pos, self._blk_radius): self._blk_pressed = True if self._in_menu: Input.inject_key(self._menu_key, True) else: Input.inject_key(self._secondary_key, True) return for i, pos in enumerate(self._ability_positions): if self._check_button_hit(mx, my, pos, self._ability_radius): self._ability_pressed[i] = True Input.inject_key(self._ability_key(i), True) return if self._rect_hit(mx, my, self._pause_rect): self._pause_pressed = True Input.inject_key(self._menu_key, True) return if self._rect_hit(mx, my, self._inv_rect): self._inv_pressed = True Input.inject_key(self._inventory_key, True) return for hi, hr in enumerate(self._hotbar_rects): if self._rect_hit(mx, my, hr) and hi < len(self._hotbar_keys): Input.inject_key(self._hotbar_keys[hi], True) return if self._joystick_active and Input.is_mouse_button_pressed(MouseButton.LEFT): self._update_joystick(mx, my) elif self._joystick_active: self._joystick_active = False self._joystick_thumb = Vec2() self._joystick_value = Vec2() if not Input.is_mouse_button_pressed(MouseButton.LEFT): self._release_button_keys() # ------------------------------------------------------------------ helpers def _ability_key(self, index: int) -> Key: if self._in_menu and index < len(self._MENU_ABILITY_KEYS): return self._MENU_ABILITY_KEYS[index] if index < len(self._ability_keys): return self._ability_keys[index] return Key.KEY_1 def _ability_label(self, index: int) -> str: if self._in_menu: return ("TAB", "USE", "3", "4")[index] return str(index + 1) @staticmethod def _check_button_hit(mx: float, my: float, pos: Vec2, radius: float) -> bool: dx = mx - pos.x dy = my - pos.y return math.sqrt(dx * dx + dy * dy) <= radius @staticmethod def _rect_hit(mx: float, my: float, rect: tuple) -> bool: rx, ry, rw, rh = rect return rx <= mx <= rx + rw and ry <= my <= ry + rh def _update_joystick(self, mx: float, my: float) -> None: dx = mx - self._joystick_pos.x dy = my - self._joystick_pos.y dist = math.sqrt(dx * dx + dy * dy) if dist > self._joystick_radius: scale = self._joystick_radius / dist dx *= scale dy *= scale self._joystick_thumb = Vec2(dx, dy) nx = dx / self._joystick_radius if self._joystick_radius > 0 else 0.0 ny = dy / self._joystick_radius if self._joystick_radius > 0 else 0.0 if math.sqrt(nx * nx + ny * ny) < 0.25: nx, ny = 0.0, 0.0 self._joystick_value = Vec2(nx, ny) def _release_button_keys(self) -> None: if self._atk_pressed: self._atk_pressed = False for k in self._primary_keys: Input.inject_key(k, False) if self._blk_pressed: self._blk_pressed = False Input.inject_key(self._secondary_key, False) Input.inject_key(self._menu_key, False) for i in range(4): if self._ability_pressed[i]: self._ability_pressed[i] = False Input.inject_key(self._ability_key(i), False) if self._pause_pressed: self._pause_pressed = False Input.inject_key(self._menu_key, False) if self._inv_pressed: self._inv_pressed = False Input.inject_key(self._inventory_key, False) def _release_all(self) -> None: self._joystick_active = False self._joystick_touch_id = None self._joystick_thumb = Vec2() self._joystick_value = Vec2() self._release_button_keys() for k in (Key.W, Key.S, Key.A, Key.D): Input.inject_key(k, False) self._joy_keys_held.clear() # ------------------------------------------------------------------ draw
[docs] def on_draw(self, renderer) -> None: if not self._visible: return s = self._ui_scale ds = 2 * s jx, jy = self._joystick_pos.x, self._joystick_pos.y renderer.draw_circle((jx, jy), self._joystick_radius, colour=(0.3, 0.3, 0.35, 0.35), filled=True) for a in range(0, 360, 10): rad = math.radians(a) px = jx + math.cos(rad) * self._joystick_radius py = jy + math.sin(rad) * self._joystick_radius renderer.draw_rect((px - ds, py - ds), (ds * 2, ds * 2), colour=(1.0, 1.0, 1.0, 0.5), filled=True) thumb_r = self._joystick_radius * 0.35 thumb_a = 0.8 if self._joystick_active else 0.5 renderer.draw_circle((jx + self._joystick_thumb.x, jy + self._joystick_thumb.y), thumb_r, colour=(1.0, 1.0, 1.0, thumb_a), filled=True) atk_a = 0.8 if self._atk_pressed else 0.5 renderer.draw_circle((self._atk_pos.x, self._atk_pos.y), self._atk_radius, colour=(0.9, 0.3, 0.2, atk_a), filled=True) renderer.draw_text(self._atk_label, (self._atk_pos.x - len(self._atk_label) * 6 * s, self._atk_pos.y - 8 * s), scale=1.6 * s, colour=(1.0, 1.0, 1.0, 0.9)) blk_a = 0.8 if self._blk_pressed else 0.5 renderer.draw_circle((self._blk_pos.x, self._blk_pos.y), self._blk_radius, colour=(0.2, 0.5, 0.9, blk_a), filled=True) renderer.draw_text(self._blk_label, (self._blk_pos.x - len(self._blk_label) * 5 * s, self._blk_pos.y - 7 * s), scale=1.3 * s, colour=(1.0, 1.0, 1.0, 0.9)) for i, pos in enumerate(self._ability_positions): pressed = self._ability_pressed[i] a = 0.7 if pressed else 0.4 renderer.draw_circle((pos.x, pos.y), self._ability_radius, colour=(0.6, 0.4, 0.8, a), filled=True) lbl = self._ability_label(i) renderer.draw_text(lbl, (pos.x - len(lbl) * 4 * s, pos.y - 8 * s), scale=1.1 * s, colour=(1.0, 1.0, 1.0, 0.85)) for rect, label, pressed in ( (self._pause_rect, self._menu_label, self._pause_pressed), (self._inv_rect, self._inventory_label, self._inv_pressed), ): rx, ry, rw, rh = rect a = 0.65 if pressed else 0.35 renderer.draw_rect((rx, ry), (rw, rh), colour=(0.3, 0.3, 0.35, a), filled=True) renderer.draw_rect((rx, ry), (rw, max(1, 2 * s)), colour=(0.5, 0.5, 0.6, a), filled=True) renderer.draw_text(label, (rx + rw / 2 - len(label) * 4 * s, ry + rh / 2 - 7 * s), scale=1.1 * s, colour=(1.0, 1.0, 1.0, 0.8 if pressed else 0.6))