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"]