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