nodes/touch.pyΒΆ

Part of Q1K3.

  1"""On-screen pointer controls, so the port is playable without a keyboard.
  2
  3Mouse-look needs a captured pointer and WASD needs keys, neither of which a
  4touch device has. This overlay maps pointers (fingers on the web build, where
  5touch also arrives as ``Mouse.LEFT``) onto the same intents the keyboard
  6path produces, using the engine's :class:`VirtualJoystick` and
  7:class:`VirtualButton` widgets plus a drag surface for looking:
  8
  9- the bottom-left stick steers movement
 10- FIRE / JUMP hold their action while held; WPN cycles weapons on each tap
 11- Menu presses the game's menu key, Esc, and says so
 12- a drag anywhere else looks around
 13
 14**Every one of these takes its own finger.** Each widget accepts every pointer
 15and captures the one that pressed it, so moving, looking and firing at the same
 16time is three fingers doing three things, which for an FPS is the difference
 17between playable and not.
 18
 19:class:`Player` reads :attr:`move`, :meth:`take_look`, :attr:`firing` and
 20:attr:`jumping` whenever the game is running in pointer mode.
 21"""
 22
 23from __future__ import annotations
 24
 25from simvx.core import (
 26    AcceptsPointers,
 27    Anchor,
 28    Control,
 29    Pointer,
 30    PointerEvent,
 31    PointerPhase,
 32    Property,
 33    Vec2,
 34)
 35from simvx.core.ui import Button, VirtualButton, VirtualJoystick
 36
 37STICK_RADIUS = 90.0
 38FIRE_RADIUS = 48.0
 39JUMP_RADIUS = 36.0
 40WEAPON_RADIUS = 30.0
 41
 42
 43class LookPad(Control):
 44    """Full-screen drag surface that accumulates pointer motion as a look delta.
 45
 46    The engine ships a joystick and buttons but no look surface, so this is the
 47    one piece the port supplies itself. It sits below the stick and buttons in
 48    the overlay, so those get first claim on a press, and it tracks each drag
 49    by pointer id so the finger that is looking is not the finger that is
 50    steering.
 51    """
 52
 53    # Redeclared rather than assigned: a class-level value would shadow the
 54    # descriptor the engine reads through.
 55    accepts_pointers = Property(AcceptsPointers.ALL, hint="Which pointers this control takes")
 56
 57    def __init__(self) -> None:
 58        super().__init__()
 59        self.anchor = Anchor.FULL_RECT
 60        # Where each dragging pointer was last seen, keyed by its id.
 61        self._dragging: dict[int, Vec2] = {}
 62        self._delta_x = 0.0
 63        self._delta_y = 0.0
 64
 65    def on_gui_input(self, event) -> bool | None:
 66        if not isinstance(event, PointerEvent):
 67            return None
 68        phase = event.phase
 69        if phase is PointerPhase.DOWN and event.button in Pointer.PRIMARY:
 70            self._dragging[event.pointer_id] = Vec2(event.position)
 71            self.capture_pointer(event.pointer_id)
 72            return True
 73        last = self._dragging.get(event.pointer_id)
 74        if last is None:
 75            return None
 76        if phase is PointerPhase.MOVE:
 77            x, y = event.position
 78            self._delta_x += x - last.x
 79            self._delta_y += y - last.y
 80            self._dragging[event.pointer_id] = Vec2(event.position)
 81            return True
 82        if phase is PointerPhase.UP or phase is PointerPhase.CANCEL:
 83            del self._dragging[event.pointer_id]
 84            self.release_pointer(event.pointer_id)
 85            return True
 86        return None
 87
 88    def on_exit_tree(self) -> None:
 89        self._dragging.clear()
 90
 91    def take_delta(self) -> tuple[float, float]:
 92        """Return the drag accumulated since the last call, then reset it."""
 93        delta = (self._delta_x, self._delta_y)
 94        self._delta_x = 0.0
 95        self._delta_y = 0.0
 96        return delta
 97
 98
 99class TouchControls(Control):
100    """Overlay publishing move / look / fire / jump intent from a pointer."""
101
102    def __init__(self) -> None:
103        super().__init__()
104        self.anchor = Anchor.FULL_RECT
105
106        # Added first, so the stick and buttons are hit-tested ahead of it.
107        self._look = self.add_child(LookPad())
108
109        # Each virtual control is sized by its radius, so an anchor and an inset place it.
110        self._stick = VirtualJoystick(radius=STICK_RADIUS, anchor=Anchor.BOTTOM_LEFT, inset=32)
111        self._stick.moved.connect(self._on_stick_moved)
112        self.add_child(self._stick)
113
114        self._fire = VirtualButton(label="FIRE", button_radius=FIRE_RADIUS, anchor=Anchor.BOTTOM_RIGHT, inset=32)
115        self._jump = VirtualButton(label="JUMP", button_radius=JUMP_RADIUS, anchor=Anchor.BOTTOM_RIGHT, inset=(160, 52))
116        self._weapon = VirtualButton(
117            label="WPN", button_radius=WEAPON_RADIUS, anchor=Anchor.BOTTOM_RIGHT, inset=(46, 148)
118        )
119        self._weapon.pressed.connect(self._on_weapon_pressed)
120
121        for button in (self._fire, self._jump, self._weapon):
122            self.add_child(button)
123
124        # Menu between the stick and the buttons: it shows the key it stands
125        # for and presses it, so the game hears Esc from a finger.
126        self._menu = Button(
127            "Menu", action="menu", name="Menu", anchor=Anchor.CENTER_BOTTOM, size=(120, 40), inset=(0, 32)
128        )
129        self.add_child(self._menu)
130
131        self.move: tuple[float, float] = (0.0, 0.0)
132        self._cycle_weapon = False
133
134    # ------------------------------------------------------------------
135
136    @property
137    def firing(self) -> bool:
138        """Whether a finger is on FIRE right now."""
139        return self._fire.is_held
140
141    @property
142    def jumping(self) -> bool:
143        """Whether a finger is on JUMP right now."""
144        return self._jump.is_held
145
146    def take_look(self) -> tuple[float, float]:
147        """Return the look delta accumulated since the last call."""
148        return self._look.take_delta()
149
150    def take_weapon_cycle(self) -> bool:
151        """Return True once per tap of the weapon button."""
152        cycled = self._cycle_weapon
153        self._cycle_weapon = False
154        return cycled
155
156    # ------------------------------------------------------------------
157
158    def _on_stick_moved(self, x: float, y: float) -> None:
159        # Screen +Y is down and forward is -Y; left is positive to match the
160        # sign convention the keyboard path uses in Player.on_update.
161        self.move = (-float(x), -float(y))
162
163    def _on_weapon_pressed(self) -> None:
164        self._cycle_weapon = True