"""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
# ============================================================================