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