nodes/tile.pyΒΆ

Part of Claustrowordia.

  1"""Tile node: a single letter tile.
  2
  3Each Tile owns a Sprite2D for the face, an MSDF Text2D for the letter, and
  4tween state for placement, shake, and matched colourisation. The colour swap
  5on a successful word match reassigns ``Sprite2D.texture`` to a differently
  6palettised face; the property's change hook re-resolves the GPU texture.
  7"""
  8
  9from __future__ import annotations
 10
 11import math
 12import random
 13
 14from simvx.core import Node2D, Signal, Sprite2D, Text2D
 15from simvx.core.coroutines import wait
 16from simvx.core.math.types import Vec2
 17
 18from .textures import TILE_SIZE, get_tile, glyph_colour
 19
 20# Letter overlay: font scale tuned so the glyph fills the tile like the old baked
 21# version (~0.62 * TILE_SIZE), and a small vertical nudge to optically centre it.
 22_LETTER_SCALE = TILE_SIZE * 0.62 / 16.0
 23_LETTER_Y = TILE_SIZE * 0.34
 24
 25
 26def _decaying_sine(t: float, oscillations: float = 4.0) -> float:
 27    """Damped sine in [0, 1]; 0 outside the range."""
 28    if t >= 1.0 or t <= 0.0:
 29        return 0.0
 30    return math.sin(t * math.pi * 2.0 * oscillations) * (1.0 - t) ** 2
 31
 32
 33class Tile(Node2D):
 34    """A single letter tile. Renders as a Sprite2D + tween offsets."""
 35
 36    placed = Signal()  # ()
 37    matched_changed = Signal()  # (matched: bool)
 38
 39    def __init__(self, letter: str, locked: bool = False) -> None:
 40        super().__init__(name=f"Tile({letter})")
 41        self.letter = letter.upper()
 42        self.locked = locked  # True for the four pre-placed centre tiles
 43        self.matched = False
 44        self._target_position = Vec2(0, 0)
 45        self._punch = Vec2(0, 0)
 46        self._shake = 0.0
 47        self._shake_offset = Vec2(0, 0)
 48        self._spring = Vec2(0, 0)  # current display position
 49        self._has_target = False
 50        self._sprite: Sprite2D | None = None
 51        self._label: Text2D | None = None
 52        self._scale_target = 1.0
 53        self._scale_current = 1.0
 54
 55    # ------------------------------------------------------------------
 56    def on_ready(self) -> None:
 57        # on_ready fires again on every re-add to the tree (documented engine
 58        # behaviour), and this tile is reparented on pickup/drop. Build the face +
 59        # letter once; rebuilding would stack duplicate sprites and glyphs.
 60        if self._sprite is not None:
 61            return
 62        palette = "locked" if self.locked else "normal"
 63        self._sprite = self.add_child(
 64            Sprite2D(
 65                texture=get_tile(self.letter, palette),
 66                width=TILE_SIZE,
 67                height=TILE_SIZE,
 68                name="Face",
 69            )
 70        )
 71        # Letter drawn as an MSDF Text2D overlay (works on web; baked freetype
 72        # glyphs do not). Its position is local to the tile, so the tile's own
 73        # transform carries the letter along.
 74        self._label = self.add_child(
 75            Text2D(
 76                text=self.letter,
 77                position=(0.0, -_LETTER_Y),
 78                font_scale=_LETTER_SCALE,
 79                align="centre",
 80                colour=glyph_colour(palette),
 81            )
 82        )
 83        self._spring = Vec2(self.position.x, self.position.y)
 84
 85    # ------------------------------------------------------------------
 86    def set_target(self, pos: Vec2, snap: bool = False) -> None:
 87        self._target_position = pos
 88        self._has_target = True
 89        if snap:
 90            self._spring = pos
 91            self.position = pos
 92
 93    def set_position_immediate(self, pos: Vec2) -> None:
 94        self._target_position = pos
 95        self._spring = pos
 96        self.position = pos
 97        self._has_target = True
 98
 99    def colourise_matched(self) -> None:
100        """Swap the face to the green "matched" palette."""
101        if self.matched or self._sprite is None:
102            return
103        self.matched = True
104        self.matched_changed(True)
105        self._sprite.texture = get_tile(self.letter, "matched")
106        if self._label is not None:
107            self._label.colour = glyph_colour("matched")
108
109    def reset_palette(self) -> None:
110        if not self.matched or self._sprite is None:
111            return
112        self.matched = False
113        self.matched_changed(False)
114        key = "locked" if self.locked else "normal"
115        self._sprite.texture = get_tile(self.letter, key)
116        if self._label is not None:
117            self._label.colour = glyph_colour(key)
118
119    def shake(self, amount: float = 0.45) -> None:
120        self._shake = max(self._shake, amount)
121
122    def punch_in(self) -> None:
123        """Damped-sine drop animation when the tile is placed."""
124        self.start_coroutine(self._punch_anim(Vec2(0, -28), 0.55, oscillations=2.5))
125        self._scale_target = 1.18
126        self.start_coroutine(self._scale_recover())
127
128    def punch_score(self, idx: int) -> None:
129        """Damped-sine pulse when the tile contributes to a scored word."""
130        self.start_coroutine(self._punch_anim(Vec2(0, -22 * abs(math.sin(idx * 0.6))), 0.45, oscillations=3.0))
131        self.start_coroutine(self._scale_pulse())
132
133    def _scale_recover(self):
134        yield from wait(0.10)
135        self._scale_target = 1.0
136
137    def _scale_pulse(self):
138        prev = self._scale_target
139        self._scale_target = 1.22
140        yield from wait(0.10)
141        self._scale_target = prev
142
143    def _punch_anim(self, amplitude: Vec2, duration: float, oscillations: float = 4.0):
144        elapsed = 0.0
145        while elapsed < duration:
146            t = elapsed / duration
147            k = _decaying_sine(t, oscillations)
148            self._punch = Vec2(amplitude.x * k, amplitude.y * k)
149            dt = yield
150            elapsed += dt or 0.0
151        self._punch = Vec2(0, 0)
152
153    # ------------------------------------------------------------------
154    def on_update(self, dt: float) -> None:
155        # Spring toward target slot
156        if self._has_target:
157            a = min(1.0, 18.0 * dt)
158            self._spring = Vec2(
159                self._spring.x + (self._target_position.x - self._spring.x) * a,
160                self._spring.y + (self._target_position.y - self._spring.y) * a,
161            )
162        # Shake decay
163        if self._shake > 0:
164            self._shake_offset = Vec2(
165                random.uniform(-1, 1) * self._shake * 8.0,
166                random.uniform(-1, 1) * self._shake * 8.0,
167            )
168            self._shake = max(0.0, self._shake - dt * 4.0)
169        else:
170            self._shake_offset = Vec2(0, 0)
171        # Scale spring
172        s = min(1.0, 18.0 * dt)
173        self._scale_current += (self._scale_target - self._scale_current) * s
174        # Apply
175        self.position = self._spring + self._punch + self._shake_offset
176        self.scale = Vec2(self._scale_current, self._scale_current)
177
178
179__all__ = ["Tile"]