Source code for simvx.core.ui.virtual_controls

"""Virtual on-screen touch controls — joystick, D-pad, and action buttons.

Designed for mobile/touch games or on-screen overlays. All controls are
rendered via the engine's draw API and route through UIInputEvent.

Example:
    joystick = VirtualJoystick()
    joystick.moved.connect(lambda x, y: player.velocity = Vec2(x, y) * speed)

    dpad = VirtualDPad()
    dpad.direction_changed.connect(lambda dx, dy: player.move(dx, dy))

    fire_btn = VirtualButton(label="Fire")
    fire_btn.pressed.connect(lambda: player.shoot())
"""


from __future__ import annotations

import logging
import math

from ..descriptors import Property, Signal
from ..math.types import Vec2
from .core import Colour, Control

log = logging.getLogger(__name__)

__all__ = ["VirtualJoystick", "VirtualDPad", "VirtualButton"]


# ============================================================================
# VirtualJoystick — Circular on-screen joystick
# ============================================================================


[docs] class VirtualJoystick(Control): """Circular on-screen joystick that emits normalised axis values. The thumb circle follows the touch position within the outer ring. Axis values are normalised to [-1, 1] and clamped to the unit circle. Example: joystick = VirtualJoystick(radius=80.0) joystick.moved.connect(lambda x, y: print(f"Axis: {x:.2f}, {y:.2f}")) """ dead_zone = Property(0.15, range=(0.0, 1.0), hint="Dead zone threshold") radius = Property(64.0, range=(8.0, 256.0), hint="Outer ring radius") def __init__(self, **kwargs): super().__init__(**kwargs) self.moved = Signal() self._active = False self._thumb_offset = Vec2(0.0, 0.0) self.size = Vec2(self.radius * 2, self.radius * 2) def _get_centre(self) -> tuple[float, float]: """Screen-space centre of the joystick.""" x, y, w, h = self.get_global_rect() return x + w / 2, y + h / 2 def _on_gui_input(self, event): if event.button == 1: if event.pressed: cx, cy = self._get_centre() px = event.position.x if hasattr(event.position, "x") else event.position[0] py = event.position.y if hasattr(event.position, "y") else event.position[1] dx, dy = px - cx, py - cy dist = math.hypot(dx, dy) if dist <= self.radius: self._active = True self.grab_mouse() self._update_thumb(px, py) elif self._active: self._active = False self._thumb_offset = Vec2(0.0, 0.0) self.release_mouse() self.queue_redraw() self.moved.emit(0.0, 0.0) elif self._active and event.button == 0: # Mouse move while active px = event.position.x if hasattr(event.position, "x") else event.position[0] py = event.position.y if hasattr(event.position, "y") else event.position[1] self._update_thumb(px, py) def _update_thumb(self, px: float, py: float): """Update thumb position and emit normalised axis values.""" cx, cy = self._get_centre() dx, dy = px - cx, py - cy dist = math.hypot(dx, dy) # Clamp to outer ring if dist > self.radius: scale = self.radius / dist dx *= scale dy *= scale self._thumb_offset = Vec2(dx, dy) self.queue_redraw() # Normalise to [-1, 1] nx = dx / self.radius if self.radius > 0 else 0.0 ny = dy / self.radius if self.radius > 0 else 0.0 # Apply dead zone mag = math.hypot(nx, ny) if mag < self.dead_zone: nx, ny = 0.0, 0.0 self.moved.emit(nx, ny)
[docs] def draw(self, renderer): cx, cy = self._get_centre() # Outer ring ring_colour = Colour.rgba(1.0, 1.0, 1.0, 0.3) renderer.draw_circle(cx, cy, radius=self.radius) # Inner thumb thumb_radius = self.radius * 0.35 thumb_colour = Colour.rgba(1.0, 1.0, 1.0, 0.7) if self._active else Colour.rgba(0.8, 0.8, 0.8, 0.5) renderer.draw_filled_circle(cx + self._thumb_offset.x, cy + self._thumb_offset.y, thumb_radius, thumb_colour)
# ============================================================================ # VirtualDPad — Directional pad with 4 arrow regions # ============================================================================
[docs] class VirtualDPad(Control): """Directional pad with up/down/left/right regions. Emits integer direction vectors (-1, 0, or 1) per axis. Example: dpad = VirtualDPad() dpad.direction_changed.connect(lambda dx, dy: player.move(dx, dy)) """ def __init__(self, **kwargs): super().__init__(**kwargs) self.direction_changed = Signal() self._dx = 0 self._dy = 0 self._active = False if "size_x" not in kwargs and "size_y" not in kwargs: self.size = Vec2(128, 128) def _get_direction(self, px: float, py: float) -> tuple[int, int]: """Determine direction from touch position relative to centre.""" x, y, w, h = self.get_global_rect() cx, cy = x + w / 2, y + h / 2 dx, dy = px - cx, py - cy if abs(dx) < 1.0 and abs(dy) < 1.0: return 0, 0 # Determine quadrant by comparing absolute offsets if abs(dx) > abs(dy): return (1 if dx > 0 else -1), 0 else: return 0, (1 if dy > 0 else -1) def _on_gui_input(self, event): if event.button == 1: if event.pressed and self.is_point_inside(event.position): self._active = True self.grab_mouse() px = event.position.x if hasattr(event.position, "x") else event.position[0] py = event.position.y if hasattr(event.position, "y") else event.position[1] self._dx, self._dy = self._get_direction(px, py) self.queue_redraw() self.direction_changed.emit(self._dx, self._dy) elif not event.pressed and self._active: self._active = False self._dx, self._dy = 0, 0 self.release_mouse() self.queue_redraw() self.direction_changed.emit(0, 0) elif self._active and event.button == 0: px = event.position.x if hasattr(event.position, "x") else event.position[0] py = event.position.y if hasattr(event.position, "y") else event.position[1] new_dx, new_dy = self._get_direction(px, py) if new_dx != self._dx or new_dy != self._dy: self._dx, self._dy = new_dx, new_dy self.queue_redraw() self.direction_changed.emit(self._dx, self._dy)
[docs] def draw(self, renderer): x, y, w, h = self.get_global_rect() cx, cy = x + w / 2, y + h / 2 arm_w = w * 0.3 arm_h = h * 0.3 base_colour = Colour.rgba(1.0, 1.0, 1.0, 0.25) highlight_colour = Colour.rgba(1.0, 1.0, 1.0, 0.6) # Up arm colour = highlight_colour if (self._dy == -1) else base_colour renderer.draw_filled_rect(cx - arm_w / 2, y, arm_w, h / 2 - arm_h / 2, colour) # Down arm colour = highlight_colour if (self._dy == 1) else base_colour renderer.draw_filled_rect(cx - arm_w / 2, cy + arm_h / 2, arm_w, h / 2 - arm_h / 2, colour) # Left arm colour = highlight_colour if (self._dx == -1) else base_colour renderer.draw_filled_rect(x, cy - arm_h / 2, w / 2 - arm_w / 2, arm_h, colour) # Right arm colour = highlight_colour if (self._dx == 1) else base_colour renderer.draw_filled_rect(cx + arm_w / 2, cy - arm_h / 2, w / 2 - arm_w / 2, arm_h, colour) # Centre square renderer.draw_filled_rect(cx - arm_w / 2, cy - arm_h / 2, arm_w, arm_h, base_colour)
# ============================================================================ # VirtualButton — Round touch action button # ============================================================================
[docs] class VirtualButton(Control): """Round on-screen action button with optional text label. Example: jump = VirtualButton(label="Jump", button_radius=40.0) jump.pressed.connect(lambda: player.jump()) """ label = Property("", hint="Button label text") button_radius = Property(32.0, range=(8.0, 128.0), hint="Button radius") def __init__(self, **kwargs): super().__init__(**kwargs) self.pressed = Signal() self.released = Signal() self._is_pressed = False self.size = Vec2(self.button_radius * 2, self.button_radius * 2) def _get_centre(self) -> tuple[float, float]: x, y, w, h = self.get_global_rect() return x + w / 2, y + h / 2 def _on_gui_input(self, event): if event.button == 1: if event.pressed: cx, cy = self._get_centre() px = event.position.x if hasattr(event.position, "x") else event.position[0] py = event.position.y if hasattr(event.position, "y") else event.position[1] dist = math.hypot(px - cx, py - cy) if dist <= self.button_radius: self._is_pressed = True self.queue_redraw() self.pressed.emit() elif self._is_pressed: self._is_pressed = False self.queue_redraw() self.released.emit()
[docs] def draw(self, renderer): cx, cy = self._get_centre() if self._is_pressed: colour = Colour.rgba(1.0, 1.0, 1.0, 0.7) else: colour = Colour.rgba(1.0, 1.0, 1.0, 0.4) renderer.draw_filled_circle(cx, cy, self.button_radius, colour) if self.label: scale = 14.0 / 14.0 text_w = len(self.label) * 8.0 * scale # approximate text_x = cx - text_w / 2 text_y = cy - 7.0 renderer.draw_text_coloured(self.label, text_x, text_y, scale, Colour.WHITE)