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