Source code for simvx.core.ui.virtual_controls

"""On-screen stick and button controls, each usable by several fingers at once.

These are the drawn pieces an on-screen pad is built from, and they are here on
their own for a game that wants one of them wired to its own signal rather than
to a pad. A game that wants a controller on the glass adds a
:class:`~simvx.core.ui.touch_controller.TouchController`, which is built from these
and publishes a real controller.

Every control here takes :attr:`~simvx.core.ui.enums.AcceptsPointers.ALL`
pointers and keys what it is holding by pointer id, so a finger on the stick and
a finger on a button are two independent presses. A press is followed through
the pointer that opened it: the control captures that pointer on the way down,
which is what keeps a thumb that slides off the edge of the stick still steering
it, and what makes the release arrive here rather than wherever the thumb
happened to be.

Example:
    stick = VirtualJoystick()
    stick.moved.connect(lambda x, y: player.walk(Vec2(x, y)))

    fire = VirtualButton(label="Fire")
    fire.pressed.connect(player.shoot)
"""

import logging
import math

from ..descriptors import Property
from ..input.controls import Pointer
from ..input.enums import PointerPhase
from ..input.events import PointerEvent
from ..math.types import Vec2
from ..signals import Signal
from .core import Colour, Control
from .enums import AcceptsPointers, PointerFilter

log = logging.getLogger(__name__)

__all__ = ["VirtualButton", "VirtualJoystick"]


class _MultiTouchControl(Control):
    """A control that tracks the pointers pressing it rather than one press.

    A pad is used with two thumbs, so a stick and a button must be pressable at
    the same instant by different fingers, and two thumbs on one button are one
    press of it: the set of pointer ids holding it is what says when the first
    arrived and the last left.
    """

    pointer_filter = Property(PointerFilter.STOP, hint="How this control takes part in the pointer hit test")

    accepts_pointers = Property(AcceptsPointers.ALL, hint="Which pointers this control takes")

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        # Pointer ids currently pressing this control. Empty means at rest.
        self._holders: set[int] = set()

    @property
    def is_held(self) -> bool:
        """Whether any pointer is pressing this control right now."""
        return bool(self._holders)

    def _grab(self, pointer_id: int) -> bool:
        """Take *pointer_id*, returning True when it is the first one down."""
        first = not self._holders
        self._holders.add(pointer_id)
        self.capture_pointer(pointer_id)
        return first

    def _drop(self, pointer_id: int) -> bool:
        """Give *pointer_id* back, returning True when the last one has gone."""
        if pointer_id not in self._holders:
            return False
        self._holders.discard(pointer_id)
        self.release_pointer(pointer_id)
        return not self._holders

    def on_exit_tree(self) -> None:
        # A control the tree no longer holds receives nothing, so whatever it
        # was reporting has to stop; the captures go with the node itself.
        self._holders.clear()


[docs] class VirtualJoystick(_MultiTouchControl): """Circular on-screen stick that reports a normalised vector. The thumb follows the pointer inside the outer ring and the reading is clamped to the unit circle, so a diagonal push is never longer than a straight one. ``moved`` carries ``(x, y)`` in -1 to 1 with +y down the screen, and fires with ``(0, 0)`` when the last finger lifts. Example: stick = VirtualJoystick(radius=80.0) stick.moved.connect(lambda x, y: print(f"{x:.2f}, {y:.2f}")) """ dead_zone = Property(0.15, range=(0.0, 1.0), hint="Reading below this magnitude reports centre") radius = Property(64.0, range=(8.0, 256.0), hint="Outer ring radius") def __init__(self, **kwargs): super().__init__(**kwargs) self.moved = Signal() self._thumb_offset = Vec2(0.0, 0.0) self._value = Vec2(0.0, 0.0) if "size" not in kwargs and "size_x" not in kwargs and "size_y" not in kwargs: self.size = Vec2(self.radius * 2, self.radius * 2)
[docs] @property def value(self) -> Vec2: """The current reading, ``(0, 0)`` at rest.""" return self._value
def _centre(self) -> tuple[float, float]: x, y, w, h = self._local_rect() return x + w / 2, y + h / 2
[docs] def on_gui_input(self, event) -> bool | None: if not isinstance(event, PointerEvent): return None phase = event.phase if phase is PointerPhase.DOWN and event.button in Pointer.PRIMARY: cx, cy = self._centre() px, py = self.to_local(event.position) if math.hypot(px - cx, py - cy) > self.radius: return None self._grab(event.pointer_id) self._aim(px, py) return True if event.pointer_id not in self._holders: return None if phase is PointerPhase.MOVE: self._aim(*self.to_local(event.position)) return True if phase is PointerPhase.UP or phase is PointerPhase.CANCEL: # A cancel recentres exactly as a lift does: the finger is gone # either way, and a direction left held walks the player into a wall. if self._drop(event.pointer_id): self._recentre() return True return None
def _aim(self, px: float, py: float) -> None: """Point the stick at a local position, and report where it now reads.""" cx, cy = self._centre() dx, dy = px - cx, py - cy distance = math.hypot(dx, dy) if distance > self.radius: scale = self.radius / distance dx *= scale dy *= scale self._thumb_offset = Vec2(dx, dy) self.queue_redraw() nx = dx / self.radius if self.radius > 0 else 0.0 ny = dy / self.radius if self.radius > 0 else 0.0 if math.hypot(nx, ny) < self.dead_zone: nx, ny = 0.0, 0.0 self._value = Vec2(nx, ny) self.moved.emit(nx, ny) def _recentre(self) -> None: self._thumb_offset = Vec2(0.0, 0.0) self._value = Vec2(0.0, 0.0) self.queue_redraw() self.moved.emit(0.0, 0.0)
[docs] def on_exit_tree(self) -> None: super().on_exit_tree() self._thumb_offset = Vec2(0.0, 0.0) self._value = Vec2(0.0, 0.0)
[docs] def on_draw(self, renderer): cx, cy = self._centre() renderer.circle((cx, cy), self.radius, stroke=Colour.rgba(1.0, 1.0, 1.0, 0.3)) thumb = Colour.rgba(1.0, 1.0, 1.0, 0.7) if self.is_held else Colour.rgba(0.8, 0.8, 0.8, 0.5) renderer.circle((cx + self._thumb_offset.x, cy + self._thumb_offset.y), self.radius * 0.35, fill=thumb)
[docs] class VirtualButton(_MultiTouchControl): """Round on-screen action button with an optional label. ``pressed`` fires when the first finger lands on it and ``released`` when the last one leaves, so two thumbs on one button are one press. Example: jump = VirtualButton(label="Jump", button_radius=40.0) jump.pressed.connect(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() if "size" not in kwargs and "size_x" not in kwargs and "size_y" not in kwargs: self.size = Vec2(self.button_radius * 2, self.button_radius * 2) def _centre(self) -> tuple[float, float]: x, y, w, h = self._local_rect() return x + w / 2, y + h / 2
[docs] def on_gui_input(self, event) -> bool | None: if not isinstance(event, PointerEvent): return None phase = event.phase if phase is PointerPhase.DOWN and event.button in Pointer.PRIMARY: cx, cy = self._centre() px, py = self.to_local(event.position) if math.hypot(px - cx, py - cy) > self.button_radius: return None if self._grab(event.pointer_id): self.queue_redraw() self.pressed.emit() return True if event.pointer_id not in self._holders: return None if phase is PointerPhase.UP or phase is PointerPhase.CANCEL: # A cancelled press releases too: the button must not stay drawn # down for the rest of the session because the system took the # finger away. if self._drop(event.pointer_id): self.queue_redraw() self.released.emit() return True if phase is PointerPhase.MOVE: # The pointer is captured, so a thumb that slides off the button # keeps it held: lifting is what ends a press. return True return None
[docs] def on_exit_tree(self) -> None: was_held = self.is_held super().on_exit_tree() if was_held: self.released.emit()
[docs] def on_draw(self, renderer): cx, cy = self._centre() fill = Colour.rgba(1.0, 1.0, 1.0, 0.7) if self.is_held else Colour.rgba(1.0, 1.0, 1.0, 0.4) renderer.circle((cx, cy), self.button_radius, fill=fill) if self.label: scale = 14.0 / 16.0 # Measured, not guessed at a fixed advance per character: the label # is centred on the button, so a width that is not the drawn width # puts it visibly off-centre. A label wider than the button shrinks # to fit rather than bleeding over the edge, because the radius # follows the window and the word does not. text_w = renderer.text_width(self.label, scale) room = self.button_radius * 1.5 if text_w > room > 0.0: scale *= room / text_w text_w = room # The held fill is bright, so the label darkens with it: white on # near-white is a button whose label vanishes exactly while the # player is looking at it. ink = Colour.rgba(0.05, 0.06, 0.08, 1.0) if self.is_held else Colour.WHITE renderer.text(self.label, (cx - text_w / 2, cy - 8.0 * scale), fill=ink, scale=scale)