"""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
@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))