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)