Source code for simvx.core.ui.touch_controller

"""The on-screen controller, which is a controller.

``TouchController`` draws a stick and buttons on the glass and publishes what the
player does with them as a real pad. A game that binds ``Controller.A`` and
``Controller.LEFT_STICK`` gets touch play by adding one node
and writing nothing else, because nothing downstream knows the pad is drawn::

    def on_ready(self):
        self.add_child(TouchController())

**It is a pad in every way a game can observe.** While it is visible it appears
in ``Input.controllers`` and fires ``controller_connected`` like any controller, so a
lobby enumerating pads sees it; its :attr:`~simvx.core.input.enums.
ControllerSource` is ``VIRTUAL``, which is what a lobby filters on when it wants
only physical ones. It leaves the mapping the moment it is hidden.

**Both pads at once is not a special case.** With a controller plugged in and
this on screen, ``Input.controllers`` holds two entries with different sources,
and a binding that names no unit resolves against every pad at once:
any pad's press counts and the largest-magnitude axis wins. So a player who
picks up the controller mid-game keeps playing, two thumbs and a stick do not
fight, and a game that genuinely wants one pad pins a binding to it with
``Controller.A.on(pad)`` or pairs it to a player.

**It never switches the input method.** A device-sourced pad does that; this
one does not, precisely so it cannot hide itself under the player's thumb. It
does go away when a real controller is used, which is the other half of the
same rule.

**It is a canvas layer, not a control.** An on-screen pad that scrolled with
the world would be a defect rather than a choice, so the screen pinning is
built in rather than being a wrapper the game has to remember; ``layer`` is the
ordinary :class:`~simvx.core.nodes_2d.canvas.CanvasLayer` knob for putting it
above or below the rest of the HUD.
"""

from __future__ import annotations

from enum import StrEnum
from typing import TypeVar

from ..descriptors import Property
from ..input.controls import Controller, DeviceButton, DeviceType, Pointer
from ..input.devices import ControllerDevice
from ..input.devices import store as _devices
from ..input.enums import ControllerFamily, ControllerSource, InputMethod, PointerPhase, PointerType
from ..input.events import PointerEvent
from ..input.prompts import button_label
from ..math.types import Vec2
from ..nodes_2d.canvas import CanvasLayer
from ..signals import Signal
from .core import Colour, Control
from .enums import Anchor, FocusMode
from .virtual_controls import VirtualButton, VirtualJoystick, _MultiTouchControl

__all__ = ["TouchController", "TouchControllerLayout"]

#: The backend id the on-screen controller publishes under. Far above anything a
#: backend hands out, so a drawn pad and a plugged-in one never collide. A game
#: reaches the pad through :attr:`TouchController.controller`, or finds it in
#: ``Input.controllers`` by its ``ControllerSource.VIRTUAL`` source.
_VIRTUAL_BACKEND_ID = 1000

#: What the pad calls itself in a device list.
VIRTUAL_CONTROLLER_NAME = "On-screen controller"

#: Its stable device id. A drawn pad is the same pad on every machine, so it
#: has a real guid rather than the empty string a backend reports when it
#: cannot supply one, and a rebind made on it persists.
VIRTUAL_CONTROLLER_GUID = "simvx:touch-controller"

_ControlT = TypeVar("_ControlT", bound=Control)

#: The snapshot name each drawable button reports under, which is the engine's
#: standard set (see :mod:`simvx.core.input.controller`). Written out rather than
#: indexed off that tuple by button code, so a reordering there cannot silently
#: move a drawn button onto a different code.
_BUTTON_KEYS: dict[DeviceButton, str] = {
    Controller.A: "a",
    Controller.B: "b",
    Controller.X: "x",
    Controller.Y: "y",
    Controller.LEFT_BUMPER: "lb",
    Controller.RIGHT_BUMPER: "rb",
    Controller.BACK: "back",
    Controller.START: "start",
}

#: Where each face button sits in the cluster, in units of the button's own
#: radius from the cluster centre, with +y down: the arrangement every
#: controller uses, so a player who has held one knows where A is.
_FACE_OFFSETS: dict[DeviceButton, tuple[float, float]] = {
    Controller.A: (0.0, 1.9),
    Controller.B: (1.9, 0.0),
    Controller.X: (-1.9, 0.0),
    Controller.Y: (0.0, -1.9),
}

#: How far the face cluster reaches from its centre, in button radii.
_FACE_REACH = 2.9

#: The two buttons a pad puts on its shoulders, drawn along the top edge.
_SHOULDERS = (Controller.LEFT_BUMPER, Controller.RIGHT_BUMPER)


def _pad_button(spec: object) -> DeviceButton:
    """A pad button from its control or its name (``"a"``, ``"left_bumper"``)."""
    if isinstance(spec, DeviceButton):
        return spec
    found = getattr(Controller, str(spec).upper(), None)
    if not isinstance(found, DeviceButton):
        raise ValueError(f"the pad has no button called {spec!r}")
    return found


def _node_name(button: DeviceButton) -> str:
    """``Controller.LEFT_BUMPER`` -> ``"LeftBumperButton"``."""
    return "".join(part.capitalize() for part in str(button.name).split("_")) + "Button"


[docs] class TouchControllerLayout(StrEnum): """Which controls a :class:`TouchController` puts on the glass. A layout is a choice of controls, never a set of coordinates: the geometry follows the window and the touch-target floor, so a game says what it needs a player to be able to do and nothing about where. What differs between them is only how the player *moves*, and whether there is a second stick to aim with. A, B and Start are on all three, because a real pad has face buttons whatever its stick arrangement and a touch player must always be able to reach the pause menu. Members: STICK_AND_BUTTONS: An analogue left stick. The default, and what most games with a character and an action want. TWIN_STICK: A right stick as well, for a game that aims with one. DPAD_AND_BUTTONS: A four-way D-pad in place of the left stick, for a grid or platform game where an analogue reading is noise. """ STICK_AND_BUTTONS = "stick_and_buttons" TWIN_STICK = "twin_stick" DPAD_AND_BUTTONS = "dpad_and_buttons"
class _DPad(_MultiTouchControl): """A four-way pad that presses the D-pad buttons of the virtual controller. One finger, sliding: pressing up and dragging to the corner presses up and right together, which is how a thumb plays a platform game. It is a control rather than four buttons for exactly that: four buttons cannot report the diagonal between them. """ def __init__(self, **kwargs): super().__init__(**kwargs) self.direction_changed = Signal() self._direction = (0, 0) if "size" not in kwargs and "size_x" not in kwargs and "size_y" not in kwargs: self.size = Vec2(128.0, 128.0) @property def direction(self) -> tuple[int, int]: """The held direction as ``(dx, dy)``, each -1, 0 or 1, +y down.""" return self._direction def _direction_at(self, px: float, py: float) -> tuple[int, int]: """Which arms a local position presses, diagonals included.""" x, y, w, h = self._local_rect() dx = px - (x + w / 2) dy = py - (y + h / 2) # A dead square in the middle, then an arm per axis the finger has # cleared: both cleared is the diagonal. slack = min(w, h) * 0.18 return ( 0 if abs(dx) < slack else (1 if dx > 0 else -1), 0 if abs(dy) < slack else (1 if dy > 0 else -1), ) def _aim(self, px: float, py: float) -> None: direction = self._direction_at(px, py) if direction != self._direction: self._direction = direction self.queue_redraw() self.direction_changed(*direction) 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: if not self.is_point_inside(event.position): return None self._grab(event.pointer_id) self._aim(*self.to_local(event.position)) 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: if self._drop(event.pointer_id) and self._direction != (0, 0): self._direction = (0, 0) self.queue_redraw() self.direction_changed(0, 0) return True return None def on_draw(self, renderer): x, y, w, h = self._local_rect() cx, cy = x + w / 2, y + h / 2 arm_w, arm_h = w * 0.3, h * 0.3 base = Colour.rgba(1.0, 1.0, 1.0, 0.25) lit = Colour.rgba(1.0, 1.0, 1.0, 0.6) dx, dy = self._direction renderer.rect((cx - arm_w / 2, y), (arm_w, h / 2 - arm_h / 2), fill=lit if dy < 0 else base) renderer.rect((cx - arm_w / 2, cy + arm_h / 2), (arm_w, h / 2 - arm_h / 2), fill=lit if dy > 0 else base) renderer.rect((x, cy - arm_h / 2), (w / 2 - arm_w / 2, arm_h), fill=lit if dx < 0 else base) renderer.rect((cx + arm_w / 2, cy - arm_h / 2), (w / 2 - arm_w / 2, arm_h), fill=lit if dx > 0 else base) renderer.rect((cx - arm_w / 2, cy - arm_h / 2), (arm_w, arm_h), fill=base) def _place(control: Control, anchor: Anchor, inset, diameter: float) -> None: """Anchor *control* at *anchor*, *inset* in from the screen's edges, as a square *diameter* across.""" control.anchor = anchor control.inset = inset control.size = (diameter, diameter) #: What the drawn controls answer unless a game widens it. _FINGERS = frozenset({PointerType.TOUCH})
[docs] class TouchController(CanvasLayer): """An on-screen controller, published as a controller. Add one and a game that already reads ``Controller`` buttons and sticks bindings is playable with two thumbs; there is nothing else to wire up. :attr:`layout` says how the player moves and :attr:`buttons` which buttons they get, and neither takes a coordinate. Example: def on_ready(self): self.add_child(TouchController()) # or, for a twin-stick game with four face buttons: self.add_child(TouchController( layout="twin_stick", buttons=(Controller.A, Controller.B, Controller.X, Controller.Y), )) """ layout = Property( TouchControllerLayout.STICK_AND_BUTTONS, hint="Which controls to put on the glass", on_change="_rebuild", ) #: Which buttons besides Start to draw, as a tuple of #: ``Controller`` buttons. A and B are what a game with #: one verb and one modifier needs; a game with more says so, and the pad #: arranges them the way a controller does rather than taking coordinates. #: Only the face buttons (A, B, X, Y), the shoulders and Back can be drawn: #: a stick click or the guide button has no sensible shape on glass. buttons = Property( (Controller.A, Controller.B), coerce=lambda spec: tuple(_pad_button(b) for b in spec), hint="Which buttons to draw, besides Start", on_change="_rebuild", ) #: Above ordinary HUD content by default: a pad the player cannot see is a #: pad they cannot press. layer = Property( int(CanvasLayer.Band.OVERLAY), hint="Draw order (higher = on top)", on_change="_on_layer_change", ) auto_hide = Property( True, coerce=bool, hint="Leave the screen while the player is using a real controller", ) visible = Property( True, coerce=bool, hint="Whether this node and its subtree are drawn and picked", on_change="_on_visible_changed", ) def __init__(self, **kwargs): self._elements: list[Control] = [] self._stick: VirtualJoystick | None = None self._stick_right: VirtualJoystick | None = None self._dpad: _DPad | None = None self._face: list[tuple[DeviceButton, VirtualButton]] = [] self._shoulders: list[tuple[DeviceButton, VirtualButton]] = [] self._start: VirtualButton | None = None self._held: dict[str, bool] = {} self._axis_values: dict[str, float] = {} self._connected = False self._router = None super().__init__(**kwargs) # A ``layout=`` kwarg has already run the change hook by now, which # built the controls; only an unstated layout still needs building. if not self._elements: self._rebuild() # -- the pad -----------------------------------------------------------
[docs] @property def controller(self) -> ControllerDevice | None: """This pad as a :class:`~simvx.core.input.devices.ControllerDevice`, or None. None while the pad is not on screen, when it is not in ``Input.controllers`` either. """ engine_id = _devices.device_ids.get((DeviceType.CONTROLLER, _VIRTUAL_BACKEND_ID)) return _devices.objects.get(engine_id) if engine_id is not None else None
def _state(self): """The input state this pad writes, which is its tree's own.""" tree = self.tree if tree is None: return None return tree.input def _connect_pad(self) -> None: """Announce the pad, at rest. It is in ``Input.controllers`` from here.""" state = self._state() if state is None or self._connected: return self._connected = True self._publish() def _disconnect_pad(self) -> None: """Take the pad away, releasing whatever it was holding with real edges.""" state = self._state() if not self._connected: return self._connected = False self._held.clear() self._axis_values.clear() if state is not None: pad = self.controller if pad is not None: state._disconnect_pad(pad.id) self._flush() def _publish(self) -> None: """Report the whole pad, the way a backend reports a polled one. A snapshot rather than a change, because that is what a pad is: a name the layout does not drive is that input at rest, and the state layer turns the difference into edges and the events behind them. """ state = self._state() if state is None or not self._connected: return state._update_controller( _VIRTUAL_BACKEND_ID, self._held, self._axis_values, source=ControllerSource.VIRTUAL, name=VIRTUAL_CONTROLLER_NAME, guid=VIRTUAL_CONTROLLER_GUID, recognised=True, ) self._flush() def _flush(self) -> None: """Turn what the snapshot changed into events, now rather than next frame. A pad has no callback, so the changes a poll found are published by whoever polled. This pad is polled by a thumb, and the thumb's own pointer event is being dispatched as this runs: the publish is queued behind it and drained the moment it finishes, so the press and the button it produced reach the frame in that order. """ if self._router is None: return self._router.flush_joy_events() def _set_button(self, name: str, pressed: bool) -> None: if self._held.get(name, False) == pressed: return self._held[name] = pressed self._publish() def _set_axes(self, x_name: str, y_name: str, x: float, y: float) -> None: if self._axis_values.get(x_name, 0.0) == x and self._axis_values.get(y_name, 0.0) == y: return self._axis_values[x_name] = x self._axis_values[y_name] = y self._publish() def _set_dpad(self, dx: int, dy: int) -> None: self._held["dpad_left"] = dx < 0 self._held["dpad_right"] = dx > 0 self._held["dpad_up"] = dy < 0 self._held["dpad_down"] = dy > 0 self._publish() # -- the controls ------------------------------------------------------ def _rebuild(self) -> None: """Replace the drawn controls with the ones this layout calls for.""" for element in self._elements: # Immediate rather than deferred: a control the layout no longer # has must not be drawn or pressable for the rest of the frame. self.remove_child(element) element.destroy() self._elements = [] self._stick = self._stick_right = self._dpad = None chosen = TouchControllerLayout(self.layout) if chosen is TouchControllerLayout.DPAD_AND_BUTTONS: self._dpad = self._add(_DPad(name="DPad")) self._dpad.direction_changed.connect(self._set_dpad) else: self._stick = self._add(VirtualJoystick(name="LeftStick")) self._stick.moved.connect(lambda x, y: self._set_axes("left_x", "left_y", x, y)) if chosen is TouchControllerLayout.TWIN_STICK: self._stick_right = self._add(VirtualJoystick(name="RightStick")) self._stick_right.moved.connect(lambda x, y: self._set_axes("right_x", "right_y", x, y)) self._face = [] self._shoulders = [] for spec in self.buttons: button = _pad_button(spec) drawn = self._add_button(button) (self._shoulders if button in _SHOULDERS else self._face).append((button, drawn)) self._start = self._add_button(Controller.START) self._layout_elements() @property def pointer_types(self) -> frozenset[PointerType] | None: """The kinds of pointer the drawn controls answer, fingers alone by default. A mouse click in the pad's area passes through to the game, so a desktop game can shoot where the stick is drawn. A game tested with a mouse, or played with a pen, widens it: ``pad.pointer_types = frozenset({PointerType.TOUCH, PointerType.MOUSE})``. None answers every pointer. """ return getattr(self, "_pointer_types", _FINGERS)
[docs] @pointer_types.setter def pointer_types(self, kinds: frozenset[PointerType] | None) -> None: self._pointer_types = None if kinds is None else frozenset(PointerType(k) for k in kinds) for control in self._elements: control.pointer_types = self._pointer_types
def _add(self, control: _ControlT) -> _ControlT: # Fingers only, by default: a desktop click in the stick's area is # the game's, and the drawn pad never takes keyboard focus, so Tab # still reaches a game action while nothing else is focused. control.pointer_types = self.pointer_types control.focus_mode = FocusMode.NONE self._elements.append(control) self.add_child(control) return control def _add_button(self, button: DeviceButton) -> VirtualButton: """One drawable button, labelled with what this pad prints on it. The label comes from the prompt table rather than being written here, so the drawn button says the same thing a rebind screen says about the code it publishes. """ key = _BUTTON_KEYS.get(button) if key is None: raise ValueError( f"TouchController cannot draw {button!r}: a stick click or the guide button has no " f"shape on glass. Drawable buttons are {sorted(str(b.name) for b in _BUTTON_KEYS)}." ) drawn = self._add(VirtualButton(label=button_label(button, ControllerFamily.GENERIC), name=_node_name(button))) drawn.pressed.connect(lambda k=key: self._set_button(k, True)) drawn.released.connect(lambda k=key: self._set_button(k, False)) return drawn def _layout_elements(self, *_changed) -> None: """Place the controls for the current window. Sizes come off the smaller screen dimension so a phone in portrait and a desktop window both get thumb-sized targets, with a floor that keeps a small window usable and a ceiling that stops a large one being swallowed by its own controls. The screen is measured in UI units, the space the controls are laid out in, so the pad grows with the tree's ``ui_scale`` like the rest of the interface. Connected to both the resize and the scale signal, whose argument it does not need. """ tree = self.tree if tree is None: return w, h = tree.screen_size if tree.ui_scale != 1.0: w, h = w / tree.ui_scale, h / tree.ui_scale unit = min(float(w), float(h)) inset = max(16.0, unit * 0.045) stick_r = min(104.0, max(52.0, unit * 0.135)) button_r = min(56.0, max(32.0, unit * 0.085)) gap = button_r * 0.55 if self._stick is not None: self._stick.radius = stick_r _place(self._stick, Anchor.BOTTOM_LEFT, inset, stick_r * 2) if self._dpad is not None: _place(self._dpad, Anchor.BOTTOM_LEFT, inset, stick_r * 2) if self._stick_right is not None: self._stick_right.radius = stick_r _place(self._stick_right, Anchor.BOTTOM_RIGHT, inset, stick_r * 2) # The face buttons keep a controller's own arrangement around a cluster # centre under the right thumb, so a player who has held a pad knows # where A is without reading the label. A right stick takes the corner # the cluster would sit in, so the cluster moves up above it. reach = button_r * _FACE_REACH centre_y = -(inset + reach) if self._stick_right is not None: centre_y -= stick_r * 2 + gap centre_x = -(inset + reach) for button, drawn in self._face: drawn.button_radius = button_r off_x, off_y = _FACE_OFFSETS[button] right = -(centre_x + off_x * button_r) - button_r bottom = -(centre_y + off_y * button_r) - button_r _place(drawn, Anchor.BOTTOM_RIGHT, (0.0, 0.0, right, bottom), button_r * 2) # A shoulder sits just above its own side's thumb, which is where a pad # puts it relative to the hand. Deliberately not the top corners: the # whole pad stays inside the bottom band and leaves the rest of the # screen to the game's own HUD, which is where a health bar and a score # invariably are. for button, drawn in self._shoulders: r = button_r * 0.7 drawn.button_radius = r if button is Controller.LEFT_BUMPER: _place(drawn, Anchor.BOTTOM_LEFT, (inset, 0.0, 0.0, inset + stick_r * 2 + gap), r * 2) else: bottom = -(centre_y - reach - gap) _place(drawn, Anchor.BOTTOM_RIGHT, (0.0, 0.0, inset, bottom), r * 2) # Start is small and sits bottom-centre, between the two thumbs: a # real pad puts it in the middle, no thumb rests there, and it is the # one part of the screen a game's own HUD reliably leaves empty. start_r = button_r * 0.55 start = self._start if start is None: return start.button_radius = start_r _place(start, Anchor.CENTER_BOTTOM, (0.0, 0.0, 0.0, inset), start_r * 2) # -- lifecycle ---------------------------------------------------------
[docs] def on_enter_tree(self) -> None: from ..input.router import InputRouter # The pad is its own input source, so it has its own router: a router # points at one tree and publishes into it, which is exactly what a # device does. self._router = InputRouter(self.tree) self._layout_elements() self.tree.screen_resized.connect(self._layout_elements) self.tree.ui_scale_changed.connect(self._layout_elements) self.tree.input.input_method_changed.connect(self._on_input_method_changed) if self.visible: self._connect_pad() self._apply_auto_hide(self.tree.input.input_method)
[docs] def on_exit_tree(self) -> None: self._disconnect_pad() self.tree.screen_resized.disconnect(self._layout_elements) self.tree.ui_scale_changed.disconnect(self._layout_elements) self.tree.input.input_method_changed.disconnect(self._on_input_method_changed) self._router = None
def _on_visible_changed(self) -> None: # The pad exists exactly while it is on screen: a control the player # cannot see is one they cannot press, and a lobby must not list it. if self.visible: self._connect_pad() else: self._disconnect_pad() super()._on_visible_changed() def _on_input_method_changed(self, method: InputMethod) -> None: self._apply_auto_hide(method) def _apply_auto_hide(self, method: InputMethod) -> None: """Leave for a real controller, come back for a finger. Only those two: the mouse passes through the drawn pad unless a game widened :attr:`pointer_types`, and a pad that hid whenever the player touched the mouse would be gone the moment a laptop player reached for the trackpad. """ if not self.auto_hide: return if method is InputMethod.CONTROLLER: self.visible = False elif method is InputMethod.TOUCH: self.visible = True