nodes/game.py¶
Part of Claustrowordia.
1"""Game: the top-level scene wiring grid, hand, scoring, and audio."""
2
3from __future__ import annotations
4
5from simvx.core import AudioPlayer, Node2D, Signal, Sprite2D, Text2D
6from simvx.core.coroutines import wait
7from simvx.core.input.state import Input
8from simvx.core.math.types import Vec2
9
10from . import audio as audio_mod
11from . import dictionary as dict_mod
12from .grid import GRID_H, GRID_W, Grid
13from .hand import HAND_SIZE, Hand
14from .textures import TILE_SIZE, get_drop_preview
15from .tile import Tile
16
17
18class _ModalPanel(Node2D):
19 """Dimmed backdrop plus a bordered box, used by the title and game-over screens.
20
21 Drawn above every board node via z_index (Node2D sorts siblings by
22 absolute_z_index); the accompanying text sits one layer higher.
23 """
24
25 def __init__(self, viewport: Vec2, size: Vec2, centre_y: float, name: str) -> None:
26 super().__init__(name=name)
27 self._vp = viewport
28 self._size = size
29 self._centre_y = centre_y
30 self.z_index = 500
31 self.visible = False
32
33 def box_rect(self) -> tuple[float, float, float, float]:
34 bx = (self._vp.x - self._size.x) / 2
35 by = self._centre_y - self._size.y / 2
36 return bx, by, self._size.x, self._size.y
37
38 def on_draw(self, renderer) -> None:
39 w, h = self._vp.x, self._vp.y
40 renderer.draw_rect((0, 0), (w, h), colour=(0.0, 0.0, 0.0, 0.72), filled=True)
41 bx, by, bw, bh = self.box_rect()
42 renderer.draw_rect((bx - 5, by - 5), (bw + 10, bh + 10), colour=(1.0, 0.55, 0.20, 1.0), filled=True)
43 renderer.draw_rect((bx, by), (bw, bh), colour=(0.12, 0.10, 0.16, 1.0), filled=True)
44
45
46class Game(Node2D):
47 """Root scene: 7×7 board + 7-tile hand + score HUD + audio."""
48
49 score_changed = Signal() # (new_score: int)
50 game_over_changed = Signal() # (over: bool)
51
52 def __init__(self, viewport_size: Vec2) -> None:
53 super().__init__(name="Game")
54 self._viewport = viewport_size
55 self.score = 0
56 self.move_count = 0
57 self.held: Tile | None = None
58 self._held_origin_in_hand = True
59 self.in_menu = True
60 self.is_game_over = False
61 self._scoring_lock = 0 # >0 while a check coroutine is running
62 # Audio streams (built once)
63 self._sfx_pop = audio_mod.make_pop()
64 self._sfx_pickup = audio_mod.make_pickup()
65 self._sfx_invalid = audio_mod.make_invalid()
66 self._sfx_chime = audio_mod.make_word_chime()
67 self._sfx_game_over = audio_mod.make_game_over()
68 self._sfx_notes = [audio_mod.make_note(i) for i in range(10)]
69
70 # ------------------------------------------------------------------
71 def on_ready(self) -> None:
72 # Audio players (added once; we restart them on each play() call).
73 self._pop = self.add_child(AudioPlayer(stream=self._sfx_pop, bus="SFX", name="PopSfx"))
74 self._pickup = self.add_child(AudioPlayer(stream=self._sfx_pickup, bus="SFX", name="PickupSfx"))
75 self._invalid = self.add_child(AudioPlayer(stream=self._sfx_invalid, bus="SFX", name="InvalidSfx"))
76 self._chime = self.add_child(AudioPlayer(stream=self._sfx_chime, bus="SFX", name="ChimeSfx"))
77 self._gover = self.add_child(AudioPlayer(stream=self._sfx_game_over, bus="SFX", name="GameOverSfx"))
78 self._note_players = [
79 self.add_child(AudioPlayer(stream=s, bus="SFX", name=f"Note{i}")) for i, s in enumerate(self._sfx_notes)
80 ]
81
82 # Centred grid; hand below.
83 cx = self._viewport.x / 2
84 # Title takes ~80px at the top and the hand ~140px at the bottom. The grid
85 # spans 7 × 80 = 560px, so centring it at 0.46 leaves a comfortable margin.
86 grid_centre_y = self._viewport.y * 0.46
87 self.grid = self.add_child(Grid(centre=Vec2(cx, grid_centre_y)))
88 # Hand sits in the bottom band, below the grid.
89 hand_y = self._viewport.y - 92
90 self.hand = self.add_child(Hand(centre=Vec2(cx, hand_y)))
91
92 # Drop preview (visible only while a tile is held + hovering a valid cell).
93 self.preview = self.add_child(
94 Sprite2D(
95 texture=get_drop_preview(),
96 width=TILE_SIZE,
97 height=TILE_SIZE,
98 name="DropPreview",
99 )
100 )
101 self.preview.visible = False
102
103 # HUD: title to the upper left, score to the upper right. Text2D z-orders
104 # inline with the rest of the 2D scene, so the HUD declares a layer above
105 # the modal dim backdrop (z=500) to stay legible over it.
106 _HUD_Z = 550
107 self.title_text = self.add_child(
108 Text2D(
109 text="CLAUSTROWORDIA",
110 position=(32, 42),
111 font_scale=1.5,
112 colour=(0.95, 0.93, 0.82, 1.0),
113 )
114 )
115 self.title_text.z_index = _HUD_Z
116 self.score_text = self.add_child(
117 Text2D(
118 text="Score: 0",
119 position=(self._viewport.x - 220, 42),
120 font_scale=1.5,
121 colour=(1.0, 0.85, 0.30, 1.0),
122 )
123 )
124 self.score_text.z_index = _HUD_Z
125
126 # Controls: vertical stack on the upper-left, below the title. They live
127 # here rather than in a bottom strip because the hand of tiles occupies
128 # the bottom band of the window.
129 controls_lines = [
130 "Click hand tile",
131 "Click empty cell to place",
132 "Right-click cancels",
133 "R restart",
134 "Esc quit",
135 ]
136 line_height = 36
137 controls_top = 84 # below the title (y=42 + font row)
138 self.controls_text_lines: list[Text2D] = []
139 for i, line in enumerate(controls_lines):
140 txt = self.add_child(
141 Text2D(
142 text=line,
143 position=(20, controls_top + i * line_height),
144 font_scale=1.56,
145 colour=(0.95, 0.93, 0.82, 1.0),
146 )
147 )
148 txt.z_index = _HUD_Z # above the modal dim backdrop
149 self.controls_text_lines.append(txt)
150
151 # Game-over popup: a modal box (drawn above the board) with centred text
152 # one layer higher, so it's clearly legible instead of low-contrast.
153 self.gameover_panel = self.add_child(
154 _ModalPanel(
155 self._viewport,
156 Vec2(560, 150),
157 self._viewport.y * 0.40 + 21,
158 name="GameOverPanel",
159 )
160 )
161 self.gameover_text = self.add_child(
162 Text2D(
163 text="",
164 position=(cx, self._viewport.y * 0.40 - 28),
165 font_scale=2.2,
166 align="centre",
167 colour=(1.0, 0.62, 0.25, 1.0),
168 )
169 )
170 self.gameover_text.z_index = 600
171 self.gameover_text.visible = False
172 self.gameover_sub = self.add_child(
173 Text2D(
174 text="",
175 position=(cx, self._viewport.y * 0.40 + 34),
176 font_scale=1.0,
177 align="centre",
178 colour=(0.97, 0.95, 0.86, 1.0),
179 )
180 )
181 self.gameover_sub.z_index = 600
182 self.gameover_sub.visible = False
183
184 self._build_menu(cx)
185 self._show_menu()
186
187 # ------------------------------------------------------------------
188 def _build_menu(self, cx: float) -> None:
189 """Title screen: the same modal box as game-over, with the rules on it."""
190 self.menu_panel = self.add_child(
191 _ModalPanel(
192 self._viewport,
193 Vec2(760, 420),
194 self._viewport.y * 0.5,
195 name="MenuPanel",
196 )
197 )
198 top = self._viewport.y * 0.5 - 210
199 self.menu_nodes: list[Text2D] = []
200
201 def _line(text: str, dy: float, scale: float, colour: tuple[float, float, float, float]) -> None:
202 txt = self.add_child(
203 Text2D(
204 text=text,
205 position=(cx, top + dy),
206 font_scale=scale,
207 align="centre",
208 colour=colour,
209 )
210 )
211 txt.z_index = 600
212 self.menu_nodes.append(txt)
213
214 _line("CLAUSTROWORDIA", 42, 2.6, (1.0, 0.62, 0.25, 1.0))
215 _line("A word puzzle after Antti Haavikko's Ludum Dare 50 winner", 102, 1.0, (0.85, 0.83, 0.74, 1.0))
216 rules = [
217 "Click a tile in your hand to pick it up",
218 "Click an empty cell to place it",
219 "Words along any row or column score",
220 "Right-click cancels a pickup",
221 "R restarts, Esc quits",
222 ]
223 for i, rule in enumerate(rules):
224 _line(rule, 150 + i * 34, 1.15, (0.95, 0.93, 0.82, 1.0))
225 _line("Click or press Enter to play", 344, 1.3, (1.0, 0.85, 0.30, 1.0))
226
227 def _show_menu(self) -> None:
228 self.in_menu = True
229 self.menu_panel.visible = True
230 for node in self.menu_nodes:
231 node.visible = True
232 self._set_hud_visible(False)
233 self.grid.visible = False
234 self.hand.visible = False
235
236 def _start_game(self) -> None:
237 self.in_menu = False
238 self.menu_panel.visible = False
239 for node in self.menu_nodes:
240 node.visible = False
241 self._set_hud_visible(True)
242 self.grid.visible = True
243 self.hand.visible = True
244 # Deal the four pre-placed centre tiles + a hand of 7; they tween in.
245 self._setup_initial_state()
246
247 def _set_hud_visible(self, visible: bool) -> None:
248 self.title_text.visible = visible
249 self.score_text.visible = visible
250 for txt in self.controls_text_lines:
251 txt.visible = visible
252
253 # ------------------------------------------------------------------
254 def _setup_initial_state(self) -> None:
255 # The letter pool is deliberately left as-is: the harness primes it with a
256 # deterministic prefix before the game starts, and restart() clears it itself.
257 # Four centre tiles at offset (-1,-1),(1,-1),(-1,1),(1,1) from board centre,
258 # exactly mirroring the upstream layout.
259 cx = (GRID_W - 1) // 2
260 cy = (GRID_H - 1) // 2
261 for dx, dy in ((-1, -1), (1, -1), (-1, 1), (1, 1)):
262 letter = dict_mod.draw_letter()
263 tile = Tile(letter=letter, locked=True)
264 # Parent to the grid (like placed tiles) so grid visibility + restart's
265 # grid.remove_child cover these centre tiles too. Grid sits at origin,
266 # so cell_to_world (absolute) positions are unaffected.
267 self.grid.add_child(tile)
268 tile.set_position_immediate(self.grid.cell_to_world(cx + dx, cy + dy))
269 self.grid.set(cx + dx, cy + dy, tile)
270 # Deal 7 hand tiles
271 for _ in range(HAND_SIZE):
272 t = Tile(letter=dict_mod.draw_letter())
273 self.hand.add_tile(t)
274
275 # ------------------------------------------------------------------
276 def on_update(self, dt: float) -> None:
277 if self.in_menu:
278 # Click / tap starts too, so the game is playable on touch.
279 if Input.is_action_just_pressed("primary") or Input.is_action_just_pressed("start"):
280 self._start_game()
281 elif Input.is_action_just_pressed("quit"):
282 self.app.quit()
283 return
284
285 if self.is_game_over:
286 if Input.is_action_just_pressed("restart") or Input.is_action_just_pressed("primary"):
287 self.restart()
288 elif Input.is_action_just_pressed("quit"):
289 self.app.quit()
290 return
291
292 if Input.is_action_just_pressed("quit"):
293 self.app.quit()
294 return
295
296 mp = Input.mouse_position
297 # Held tile follows the cursor; preview snaps to grid cell under cursor.
298 if self.held is not None:
299 self.held.set_target(Vec2(mp.x, mp.y), snap=False)
300 cell = self.grid.world_to_cell(Vec2(mp.x, mp.y))
301 if cell is not None and self.grid.is_empty(*cell):
302 self.preview.position = self.grid.cell_to_world(*cell)
303 self.preview.visible = True
304 else:
305 self.preview.visible = False
306
307 # Click handling
308 if Input.is_action_just_pressed("primary") and self._scoring_lock == 0:
309 self._handle_lmb(Vec2(mp.x, mp.y))
310 if Input.is_action_just_pressed("secondary") and self.held is not None:
311 self._cancel_pickup()
312
313 # ------------------------------------------------------------------
314 def _handle_lmb(self, mp: Vec2) -> None:
315 if self.held is None:
316 # Try to pick a tile from the hand
317 tile = self.hand.tile_at(mp)
318 if tile is not None:
319 self._pickup_from_hand(tile)
320 return
321 else:
322 # Try to drop on a grid cell
323 cell = self.grid.world_to_cell(mp)
324 if cell is not None and self.grid.is_empty(*cell):
325 self._drop_on_grid(cell)
326 else:
327 self._invalid.play()
328
329 def _pickup_from_hand(self, tile: Tile) -> None:
330 self.held = tile
331 self._held_origin_in_hand = True
332 # Detach from hand: keep the node parented to Game so it sits above grid in draw order.
333 self.hand.tiles.remove(tile)
334 self.hand.remove_child(tile)
335 self.add_child(tile)
336 self.hand.layout()
337 self._pickup.play()
338
339 def _cancel_pickup(self) -> None:
340 if self.held is None:
341 return
342 # Re-attach to hand
343 self.remove_child(self.held)
344 self.hand.add_tile(self.held)
345 self.held = None
346 self.preview.visible = False
347
348 def _drop_on_grid(self, cell: tuple[int, int]) -> None:
349 gx, gy = cell
350 tile = self.held
351 assert tile is not None
352 # Re-parent visually under grid so it sits with the cells
353 self.remove_child(tile)
354 self.grid.add_child(tile)
355 target = self.grid.cell_to_world(gx, gy)
356 tile.set_target(target, snap=False)
357 tile.punch_in()
358 self.grid.set(gx, gy, tile)
359 self.held = None
360 self.preview.visible = False
361 self._pop.play()
362 self.move_count += 1
363 self.start_coroutine(self._check_words(gx, gy))
364
365 # ------------------------------------------------------------------
366 # Word checking: mirrors upstream Field.Check / CheckString.
367 # ------------------------------------------------------------------
368 def _check_words(self, gx: int, gy: int):
369 self._scoring_lock += 1
370 try:
371 row_letters = self.grid.row_letters(gy)
372 col_letters = self.grid.col_letters(gx)
373 row_tiles = self.grid.row_tiles(gy)
374 col_tiles = self.grid.col_tiles(gx)
375
376 words: list[tuple[str, list[Tile], bool]] = []
377 words.extend(self._collect_words(row_letters, gx, row_tiles, reverse=False))
378 words.extend(self._collect_words(col_letters, gy, col_tiles, reverse=False))
379 words.extend(
380 self._collect_words(row_letters[::-1], (GRID_W - 1) - gx, list(reversed(row_tiles)), reverse=True)
381 )
382 words.extend(
383 self._collect_words(col_letters[::-1], (GRID_H - 1) - gy, list(reversed(col_tiles)), reverse=True)
384 )
385
386 # De-dup: the same word over the same tiles in the same direction is one
387 # find, but the same word read down a column as well as across a row counts twice.
388 seen = set()
389 unique = []
390 for word, tiles, rev in words:
391 key = (word, tuple(id(t) for t in tiles), rev)
392 if key in seen:
393 continue
394 seen.add(key)
395 unique.append((word, tiles, rev))
396
397 multi = 1
398 unique.sort(key=lambda w: len(w[0]))
399 for word, tiles, _rev in unique:
400 yield from self._announce_word(word, tiles, multi)
401 multi += 1
402 yield from wait(0.35)
403
404 # Deal a fresh tile (matches upstream's "deal-after-drop" behaviour)
405 if not self.grid.is_full() and not self.is_game_over:
406 t = Tile(letter=dict_mod.draw_letter())
407 self.hand.add_tile(t)
408
409 # Lose check
410 if self.grid.is_full():
411 self._trigger_game_over()
412 finally:
413 self._scoring_lock -= 1
414
415 def _collect_words(
416 self,
417 text: str,
418 must_include: int,
419 tiles: list,
420 reverse: bool,
421 ) -> list[tuple[str, list[Tile], bool]]:
422 """Find dictionary words in `text` that span the dropped index."""
423 out: list[tuple[str, list[Tile], bool]] = []
424 if len(text) < 3:
425 return out
426 for length in range(len(text), 2, -1):
427 for start in range(0, len(text) - length + 1):
428 w = text[start : start + length]
429 if " " in w:
430 continue
431 # Must cover the drop column/row
432 if not (start <= must_include < start + length):
433 continue
434 if dict_mod.is_word(w):
435 word_tiles = [t for t in tiles[start : start + length] if t is not None]
436 if len(word_tiles) == length:
437 out.append((w.lower(), word_tiles, reverse))
438 return out
439
440 def _announce_word(self, word: str, tiles: list[Tile], multi: int):
441 # Score: length² × multiplier (full-match-bonus 10× per upstream)
442 base = len(word) ** 2
443 all_tiles = self.grid.all_tiles()
444 is_full_match = all(t.matched or t in tiles for t in all_tiles)
445 if is_full_match:
446 multi *= 10
447 score = base * multi
448 # Per-letter pulse + ascending notes
449 for i, t in enumerate(tiles):
450 t.colourise_matched()
451 t.shake(0.45)
452 t.punch_score(i)
453 self._note_players[min(i, len(self._note_players) - 1)].play()
454 yield from wait(0.075)
455 self._chime.play()
456 self.score += score
457 self.score_text.text = f"Score: {self.score}"
458 self.score_changed(self.score)
459
460 # ------------------------------------------------------------------
461 def _trigger_game_over(self) -> None:
462 self.is_game_over = True
463 # Hide the board: the tile letters are Text2D children of the tiles, and
464 # they would read as noise through the dimmed game-over popup.
465 self.grid.visible = False
466 self.hand.visible = False
467 self.preview.visible = False
468 if self.held is not None:
469 self.held.visible = False
470 self.gameover_panel.visible = True
471 self.gameover_text.text = "GAME OVER"
472 self.gameover_text.visible = True
473 self.gameover_sub.text = f"Final score: {self.score} Click or press R to restart"
474 self.gameover_sub.visible = True
475 self.game_over_changed(True)
476 self._gover.play()
477
478 def restart(self) -> None:
479 # Wipe everything and rebuild
480 for row in self.grid.cells:
481 for t in row:
482 if t is not None:
483 self.grid.remove_child(t)
484 self.grid.cells = [[None for _ in range(GRID_W)] for _ in range(GRID_H)]
485 self.hand.clear()
486 self.score = 0
487 self.move_count = 0
488 self.held = None
489 self.is_game_over = False
490 self.grid.visible = True
491 self.hand.visible = True
492 self.gameover_panel.visible = False
493 self.gameover_text.visible = False
494 self.gameover_sub.visible = False
495 self.score_text.text = "Score: 0"
496 self.score_changed(0)
497 self.game_over_changed(False)
498 dict_mod.reset()
499 self._setup_initial_state()
500
501
502__all__ = ["Game"]