nodes/grid.py

Part of Claustrowordia.

  1"""Grid: the 7×7 board.
  2
  3The grid keeps an array of `Tile | None` cells and renders cell outlines
  4beneath them via on_draw. World position of cell (gx, gy) is computed by
  5`cell_to_world` so Tile / Hand / drag-preview can all snap to the same
  6coordinates.
  7"""
  8
  9from __future__ import annotations
 10
 11from collections.abc import Iterable
 12
 13from simvx.core import Node2D, Sprite2D
 14from simvx.core.math.types import Vec2
 15
 16from .textures import TILE_SIZE, get_grid_cell
 17
 18GRID_W = 7
 19GRID_H = 7
 20CELL_SPACING = TILE_SIZE + 8
 21
 22
 23class Grid(Node2D):
 24    """The 7×7 game board. `centre` is the world-space middle of the grid."""
 25
 26    def __init__(self, centre: Vec2) -> None:
 27        super().__init__(name="Grid")
 28        self._centre = centre
 29        self.cells: list[list[object | None]] = [[None for _ in range(GRID_W)] for _ in range(GRID_H)]
 30
 31    # ------------------------------------------------------------------
 32    def on_ready(self) -> None:
 33        # One Sprite2D per cell as a cheap background; these are static
 34        # and the engine batches them through the texture cache.
 35        for gy in range(GRID_H):
 36            for gx in range(GRID_W):
 37                pos = self.cell_to_world(gx, gy)
 38                self.add_child(
 39                    Sprite2D(
 40                        texture=get_grid_cell(),
 41                        width=TILE_SIZE,
 42                        height=TILE_SIZE,
 43                        position=pos,
 44                        name=f"Cell({gx},{gy})",
 45                    )
 46                )
 47
 48    # ------------------------------------------------------------------
 49    # Coordinate helpers
 50    # ------------------------------------------------------------------
 51    def cell_to_world(self, gx: int, gy: int) -> Vec2:
 52        cx = self._centre.x + (gx - (GRID_W - 1) / 2) * CELL_SPACING
 53        cy = self._centre.y + (gy - (GRID_H - 1) / 2) * CELL_SPACING
 54        return Vec2(cx, cy)
 55
 56    def world_to_cell(self, pos: Vec2) -> tuple[int, int] | None:
 57        gx = int(round((pos.x - self._centre.x) / CELL_SPACING + (GRID_W - 1) / 2))
 58        gy = int(round((pos.y - self._centre.y) / CELL_SPACING + (GRID_H - 1) / 2))
 59        if 0 <= gx < GRID_W and 0 <= gy < GRID_H:
 60            return gx, gy
 61        return None
 62
 63    def in_bounds(self, gx: int, gy: int) -> bool:
 64        return 0 <= gx < GRID_W and 0 <= gy < GRID_H
 65
 66    # ------------------------------------------------------------------
 67    # State
 68    # ------------------------------------------------------------------
 69    def get(self, gx: int, gy: int):
 70        if not self.in_bounds(gx, gy):
 71            return None
 72        return self.cells[gy][gx]
 73
 74    def set(self, gx: int, gy: int, value) -> None:
 75        if self.in_bounds(gx, gy):
 76            self.cells[gy][gx] = value
 77
 78    def is_empty(self, gx: int, gy: int) -> bool:
 79        return self.get(gx, gy) is None
 80
 81    def neighbours(self, gx: int, gy: int) -> Iterable:
 82        for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)):
 83            t = self.get(gx + dx, gy + dy)
 84            if t is not None:
 85                yield t
 86
 87    def has_any_neighbour(self, gx: int, gy: int) -> bool:
 88        return any(True for _ in self.neighbours(gx, gy))
 89
 90    def row_letters(self, gy: int) -> str:
 91        return "".join((self.cells[gy][gx].letter if self.cells[gy][gx] else " ") for gx in range(GRID_W))
 92
 93    def col_letters(self, gx: int) -> str:
 94        return "".join((self.cells[gy][gx].letter if self.cells[gy][gx] else " ") for gy in range(GRID_H))
 95
 96    def row_tiles(self, gy: int) -> list:
 97        return [self.cells[gy][gx] for gx in range(GRID_W)]
 98
 99    def col_tiles(self, gx: int) -> list:
100        return [self.cells[gy][gx] for gy in range(GRID_H)]
101
102    def all_tiles(self) -> list:
103        out = []
104        for row in self.cells:
105            for t in row:
106                if t is not None:
107                    out.append(t)
108        return out
109
110    def count_filled(self) -> int:
111        return sum(1 for row in self.cells for t in row if t is not None)
112
113    def is_full(self) -> bool:
114        return self.count_filled() >= GRID_W * GRID_H
115
116
117__all__ = ["Grid", "GRID_W", "GRID_H", "CELL_SPACING"]