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