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 a pointer (a finger on the web build,
  5where touch arrives as ``MouseButton.LEFT``) onto the same intents the
  6keyboard path 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- a drag anywhere else looks around
 12
 13:class:`Player` reads :attr:`move`, :meth:`take_look`, :attr:`firing` and
 14:attr:`jumping` whenever the game is running in pointer mode.
 15"""
 16
 17from __future__ import annotations
 18
 19from simvx.core import AnchorPreset, Control, Input, MouseButton, Vec2
 20from simvx.core.ui import VirtualButton, VirtualJoystick
 21
 22STICK_RADIUS = 90.0
 23FIRE_RADIUS = 48.0
 24JUMP_RADIUS = 36.0
 25WEAPON_RADIUS = 30.0
 26
 27
 28def _place(control: Control, preset: AnchorPreset, left: float, top: float, right: float, bottom: float) -> None:
 29    control.set_anchor_preset(preset)
 30    control.margin_left = left
 31    control.margin_top = top
 32    control.margin_right = right
 33    control.margin_bottom = bottom
 34
 35
 36class LookPad(Control):
 37    """Full-screen drag surface that accumulates pointer motion as a look delta.
 38
 39    The engine ships a joystick and buttons but no look surface, so this is the
 40    one piece the port supplies itself. It sits below the stick and buttons in
 41    the overlay, so those get first claim on a press.
 42    """
 43
 44    def __init__(self) -> None:
 45        super().__init__()
 46        self.set_anchor_preset(AnchorPreset.FULL_RECT)
 47        self._dragging = False
 48        self._last = Vec2(0.0, 0.0)
 49        self._delta_x = 0.0
 50        self._delta_y = 0.0
 51
 52    def _on_gui_input(self, event) -> None:
 53        if event.button == MouseButton.LEFT:
 54            if event.pressed:
 55                self._dragging = True
 56                self._last = Vec2(event.position.x, event.position.y)
 57                self.grab_mouse()
 58                event.handled = True
 59            elif self._dragging:
 60                self._dragging = False
 61                self.release_mouse()
 62                event.handled = True
 63        elif self._dragging and event.button is None:
 64            self._delta_x += float(event.position.x) - float(self._last.x)
 65            self._delta_y += float(event.position.y) - float(self._last.y)
 66            self._last = Vec2(event.position.x, event.position.y)
 67
 68    def take_delta(self) -> tuple[float, float]:
 69        """Return the drag accumulated since the last call, then reset it."""
 70        delta = (self._delta_x, self._delta_y)
 71        self._delta_x = 0.0
 72        self._delta_y = 0.0
 73        return delta
 74
 75
 76class TouchControls(Control):
 77    """Overlay publishing move / look / fire / jump intent from a pointer."""
 78
 79    def __init__(self) -> None:
 80        super().__init__()
 81        self.set_anchor_preset(AnchorPreset.FULL_RECT)
 82
 83        # Added first, so the stick and buttons are hit-tested ahead of it.
 84        self._look = self.add_child(LookPad())
 85
 86        self._stick = VirtualJoystick(radius=STICK_RADIUS)
 87        _place(self._stick, AnchorPreset.BOTTOM_LEFT, 32, -(32 + STICK_RADIUS * 2), 32 + STICK_RADIUS * 2, -32)
 88        self._stick.moved.connect(self._on_stick_moved)
 89        self.add_child(self._stick)
 90
 91        self._fire = VirtualButton(label="FIRE", button_radius=FIRE_RADIUS)
 92        _place(self._fire, AnchorPreset.BOTTOM_RIGHT, -128, -128, -32, -32)
 93        self._fire.pressed.connect(self._on_fire_pressed)
 94
 95        self._jump = VirtualButton(label="JUMP", button_radius=JUMP_RADIUS)
 96        _place(self._jump, AnchorPreset.BOTTOM_RIGHT, -232, -124, -160, -52)
 97        self._jump.pressed.connect(self._on_jump_pressed)
 98
 99        self._weapon = VirtualButton(label="WPN", button_radius=WEAPON_RADIUS)
100        _place(self._weapon, AnchorPreset.BOTTOM_RIGHT, -106, -208, -46, -148)
101        self._weapon.pressed.connect(self._on_weapon_pressed)
102
103        for button in (self._fire, self._jump, self._weapon):
104            self.add_child(button)
105
106        self.move: tuple[float, float] = (0.0, 0.0)
107        self.firing = False
108        self.jumping = False
109        self._cycle_weapon = False
110
111    # ------------------------------------------------------------------
112
113    def take_look(self) -> tuple[float, float]:
114        """Return the look delta accumulated since the last call."""
115        return self._look.take_delta()
116
117    def take_weapon_cycle(self) -> bool:
118        """Return True once per tap of the weapon button."""
119        cycled = self._cycle_weapon
120        self._cycle_weapon = False
121        return cycled
122
123    def on_update(self, dt: float) -> None:
124        # A finger that slides off a button never delivers its release to that
125        # button, so clear the held actions whenever the pointer itself is up.
126        if not Input.is_mouse_button_pressed(MouseButton.LEFT):
127            self.firing = False
128            self.jumping = False
129
130    # ------------------------------------------------------------------
131
132    def _on_stick_moved(self, x: float, y: float) -> None:
133        # Screen +Y is down and forward is -Y; left is positive to match the
134        # sign convention the keyboard path uses in Player.on_update.
135        self.move = (-float(x), -float(y))
136
137    def _on_fire_pressed(self) -> None:
138        self.firing = True
139
140    def _on_jump_pressed(self) -> None:
141        self.jumping = True
142
143    def _on_weapon_pressed(self) -> None:
144        self._cycle_weapon = True