nodes/touch.pyΒΆ
Part of Clear Code Zelda.
1"""On-screen thumb-stick and action buttons, so the port plays with a pointer.
2
3The web export bridges a touch to a left mouse button press at the touch
4position, so polling :class:`simvx.core.Input` for the left button covers
5both mouse and touch. Buttons report through signals; the stick publishes a
6unit-clamped :attr:`direction` the level feeds to the player each frame.
7"""
8
9from __future__ import annotations
10
11from simvx.core import Input, MouseButton, Node2D, Signal, Vec2
12
13#: Radius of the stick ring, and how far the knob travels from its origin.
14STICK_RADIUS = 68.0
15#: Movement below this many pixels of travel reads as "no input".
16STICK_DEADZONE = 10.0
17
18RING_COLOUR = (1.0, 1.0, 1.0, 0.16)
19KNOB_COLOUR = (1.0, 1.0, 1.0, 0.34)
20BUTTON_COLOUR = (0.13, 0.13, 0.13, 0.62)
21BUTTON_COLOUR_HELD = (1.0, 0.85, 0.10, 0.75)
22LABEL_COLOUR = (0.95, 0.95, 0.95, 0.95)
23
24
25class TouchControls(Node2D):
26 """Pointer-driven movement stick plus the five action buttons."""
27
28 # Re-collect every frame: the stick and the button highlights are drawn
29 # from pointer state, which is not a Property.
30 dynamic = True
31
32 attack_pressed = Signal()
33 magic_pressed = Signal()
34 weapon_swap_pressed = Signal()
35 magic_swap_pressed = Signal()
36 upgrade_pressed = Signal()
37
38 def __init__(self, **kwargs):
39 super().__init__(name="TouchControls", **kwargs)
40 self.z_index = 1200
41 #: Unit-clamped steering vector, zero when the stick is not held.
42 self.direction = Vec2(0.0, 0.0)
43 self._stick_origin: Vec2 | None = None
44 self._knob = Vec2(0.0, 0.0)
45 self._held_button: str | None = None
46
47 # -- layout -------------------------------------------------------------
48
49 def _screen_size(self) -> tuple[float, float]:
50 return self.tree.screen_size if self.tree else (1280.0, 720.0)
51
52 def _buttons(self, sw: float, sh: float) -> list[tuple[str, float, float, float, str]]:
53 """(action, centre x, centre y, radius, label), bottom-right cluster."""
54 return [
55 ("attack", sw - 100, sh - 130, 48, "ATK"),
56 ("magic", sw - 196, sh - 92, 34, "MAG"),
57 ("weapon", sw - 200, sh - 188, 28, "Q"),
58 ("spell", sw - 124, sh - 224, 28, "E"),
59 ("menu", sw - 44, sh - 224, 28, "M"),
60 ]
61
62 def _stick_home(self, sh: float) -> Vec2:
63 """Where the stick rests when nothing is touching it."""
64 return Vec2(120.0, sh - 130.0)
65
66 def _is_stick_zone(self, pos, sw: float, sh: float) -> bool:
67 """The stick floats: a press anywhere in the lower-left quadrant grabs it."""
68 return pos.x < sw * 0.5 and pos.y > sh * 0.42
69
70 # -- per-frame ----------------------------------------------------------
71
72 def on_update(self, dt: float):
73 if not self.visible:
74 self._release()
75 return
76 sw, sh = self._screen_size()
77 down = Input.is_mouse_button_pressed(MouseButton.LEFT)
78 pos = Input.mouse_position
79
80 # The press edge comes from Input rather than a held-state diff, so a tap
81 # that starts and ends inside one frame still registers.
82 if Input.is_mouse_button_just_pressed(MouseButton.LEFT):
83 self._on_press(pos, sw, sh)
84 elif not down:
85 self._release()
86
87 if self._stick_origin is not None and down:
88 self._track_stick(pos)
89
90 def _on_press(self, pos, sw: float, sh: float) -> None:
91 for action, cx, cy, radius, _label in self._buttons(sw, sh):
92 if (pos.x - cx) ** 2 + (pos.y - cy) ** 2 <= radius * radius:
93 self._held_button = action
94 self._emit(action)
95 return
96 if self._is_stick_zone(pos, sw, sh):
97 self._stick_origin = Vec2(pos.x, pos.y)
98 self._knob = Vec2(pos.x, pos.y)
99
100 def _emit(self, action: str) -> None:
101 {
102 "attack": self.attack_pressed,
103 "magic": self.magic_pressed,
104 "weapon": self.weapon_swap_pressed,
105 "spell": self.magic_swap_pressed,
106 "menu": self.upgrade_pressed,
107 }[action]()
108
109 def _track_stick(self, pos) -> None:
110 offset = Vec2(pos.x - self._stick_origin.x, pos.y - self._stick_origin.y)
111 travel = offset.length()
112 if travel <= STICK_DEADZONE:
113 self.direction = Vec2(0.0, 0.0)
114 self._knob = Vec2(self._stick_origin.x, self._stick_origin.y)
115 return
116 unit = offset / travel
117 clamped = min(travel, STICK_RADIUS)
118 self.direction = unit * (clamped / STICK_RADIUS)
119 self._knob = Vec2(self._stick_origin.x + unit.x * clamped, self._stick_origin.y + unit.y * clamped)
120
121 def _release(self) -> None:
122 self._stick_origin = None
123 self._held_button = None
124 self.direction = Vec2(0.0, 0.0)
125
126 # -- drawing ------------------------------------------------------------
127
128 def on_draw(self, renderer):
129 sw, sh = self._screen_size()
130 origin = self._stick_origin if self._stick_origin is not None else self._stick_home(sh)
131 knob = self._knob if self._stick_origin is not None else origin
132
133 renderer.draw_circle((origin.x, origin.y), STICK_RADIUS, colour=RING_COLOUR, filled=True)
134 renderer.draw_circle((knob.x, knob.y), 26, colour=KNOB_COLOUR, filled=True)
135
136 for action, cx, cy, radius, label in self._buttons(sw, sh):
137 held = action == self._held_button
138 renderer.draw_circle(
139 (cx, cy),
140 radius,
141 colour=BUTTON_COLOUR_HELD if held else BUTTON_COLOUR,
142 filled=True,
143 )
144 renderer.draw_circle((cx, cy), radius, colour=RING_COLOUR, filled=False)
145 scale = 1.6 if radius >= 40 else 1.2
146 lw = renderer.text_width(label, scale)
147 renderer.draw_text(label, (cx - lw / 2, cy - 7 * scale), colour=LABEL_COLOUR, scale=scale)