harness.pyΒΆ
Part of Claustrowordia.
1"""Scripted-input harness: exercises the title screen, pickup, drop, and scoring.
2
3Captures frames at key moments to demonstrate the gameplay loop:
4
5 01_idle.png starting board (4 pre-placed centre tiles + 7 hand tiles)
6 02_pickup.png picked up the leftmost hand tile
7 03_hover_cell.png tile follows cursor; drop-preview snaps to grid cell
8 04_placed.png tile dropped on the board (place punch + pop sound)
9 05_word_scored.png word formed: letters flash green, score increments
10 06_full_board.png several more tiles placed, score climbing
11
12Run:
13 uv run python examples/ports/claustrowordia/harness.py
14"""
15
16from __future__ import annotations
17
18import sys
19from collections.abc import Callable
20from pathlib import Path
21
22_PORT_DIR = Path(__file__).parent
23if str(_PORT_DIR) not in sys.path:
24 sys.path.insert(0, str(_PORT_DIR))
25
26from main import HEIGHT, WIDTH, ClaustrowordiaRoot # noqa: E402
27from nodes import dictionary as dict_mod # noqa: E402
28from nodes.grid import GRID_H, GRID_W # noqa: E402
29from nodes.textures import TILE_SIZE # noqa: E402
30
31from simvx.core.input.enums import MouseButton # noqa: E402
32from simvx.core.testing import InputSimulator # noqa: E402
33from simvx.graphics import App, save_png # noqa: E402
34
35OUT = _PORT_DIR / "screenshots"
36
37# Pre-place letters so the harness reliably forms a real word. Game deals the
38# four centre tiles in the order (2,2), (4,2), (2,4), (4,4) and then draws the
39# seven hand tiles, all off the front of the pool. Cells (2,2) and (2,4) share
40# column 2, so seeding C and T there and dealing A as the first hand tile makes
41# a drop at (2,3) spell "CAT" vertically. The other two corners are fillers.
42HARNESS_POOL_PREFIX = [
43 "C",
44 "Q",
45 "T",
46 "Q", # centre tiles at (2,2)=C, (4,2)=Q, (2,4)=T, (4,4)=Q
47 "A",
48 "B",
49 "D",
50 "E",
51 "F",
52 "G",
53 "H", # hand tiles 0..6
54]
55
56
57def cell_to_world_xy(viewport_w: int, viewport_h: int, gx: int, gy: int) -> tuple[float, float]:
58 """Mirror Game.grid.cell_to_world(); used to compute click positions."""
59 cx = viewport_w / 2
60 grid_centre_y = viewport_h * 0.46
61 cell_spacing = TILE_SIZE + 8
62 x = cx + (gx - (GRID_W - 1) / 2) * cell_spacing
63 y = grid_centre_y + (gy - (GRID_H - 1) / 2) * cell_spacing
64 return float(x), float(y)
65
66
67def hand_tile_xy(viewport_w: int, viewport_h: int, slot: int, total: int = 7) -> tuple[float, float]:
68 """Mirror Hand.layout(): returns the world-space centre of slot `slot`."""
69 cx = viewport_w / 2
70 hand_y = viewport_h - 92
71 spacing = TILE_SIZE + 14
72 x = cx + (slot - (total - 1) / 2) * spacing
73 return float(x), float(hand_y)
74
75
76def capture_sequence() -> None:
77 OUT.mkdir(exist_ok=True)
78 # Seed dictionary RNG (this also clears the pool) + prime the letters.
79 dict_mod.seed(42)
80 dict_mod.push_front(HARNESS_POOL_PREFIX)
81
82 app = App(width=WIDTH, height=HEIGHT, title="Claustrowordia Harness", visible=False)
83 sim = InputSimulator()
84
85 captures: dict[int, str] = {}
86 actions: dict[int, Callable[[], None]] = {}
87
88 def move(x: float, y: float) -> Callable[[], None]:
89 return lambda: sim.move_mouse(x, y)
90
91 def click(x: float, y: float) -> Callable[[], None]:
92 # Press only: the game reads the press edge, and holding the button
93 # across a few frames matches how a player actually drags a tile.
94 return lambda: sim.press_mouse(MouseButton.LEFT, (x, y))
95
96 def release_lmb() -> Callable[[], None]:
97 return lambda: sim.release_mouse(MouseButton.LEFT)
98
99 # Hand slot 0 should be "A" (first letter after the four centre tiles).
100 h0_x, h0_y = hand_tile_xy(WIDTH, HEIGHT, 0)
101 cell_2_3_x, cell_2_3_y = cell_to_world_xy(WIDTH, HEIGHT, 2, 3)
102
103 # Frames 5..10: dismiss the title screen so the board is dealt.
104 actions[5] = click(WIDTH / 2, HEIGHT / 2)
105 actions[10] = release_lmb()
106
107 # Frame 30: idle starting state
108 captures[30] = "01_idle.png"
109
110 # Frame 50: pick up hand tile 0 ("A"); then move the cursor up so the
111 # tile follows it visibly (otherwise the screenshot just looks like the
112 # tile is still in the hand).
113 actions[45] = click(h0_x, h0_y)
114 actions[50] = release_lmb()
115 actions[55] = move(WIDTH * 0.3, HEIGHT * 0.55)
116 captures[70] = "02_pickup.png"
117
118 # Frame 80: hover the cell (2, 3), between C at (2,2) and T at (2,4).
119 # Capture *before* the drop click so the shot shows the held tile snapped
120 # to the drop preview rather than the scoring flash that follows.
121 actions[80] = move(cell_2_3_x, cell_2_3_y)
122 captures[90] = "03_hover_cell.png"
123
124 # Frame 105: click to drop on the cell: forms vertical "CAT"
125 actions[105] = click(cell_2_3_x, cell_2_3_y)
126 actions[110] = release_lmb()
127 captures[125] = "04_placed.png"
128
129 # Frame 160: scoring animation has fired: capture the green flashed tiles
130 captures[160] = "05_word_scored.png"
131
132 # Frames 200..360: place several more tiles to demonstrate accumulation.
133 plan = [
134 # (frame_pickup, frame_drop, hand_slot, target_cell)
135 # After dropping A, hand re-fills from the random pool, so the next
136 # tiles aren't deterministic, so we pick targets that are likely empty
137 # and don't require a specific letter to score.
138 (210, 230, 0, (3, 2)),
139 (250, 270, 0, (3, 4)),
140 (290, 310, 0, (1, 3)),
141 (330, 350, 0, (5, 3)),
142 ]
143 for fp, fd, slot, (gx, gy) in plan:
144 hx, hy = hand_tile_xy(WIDTH, HEIGHT, slot)
145 tx, ty = cell_to_world_xy(WIDTH, HEIGHT, gx, gy)
146 actions[fp] = click(hx, hy)
147 actions[fp + 5] = release_lmb()
148 actions[fp + 10] = move(tx, ty)
149 actions[fd] = click(tx, ty)
150 actions[fd + 5] = release_lmb()
151
152 captures[370] = "06_full_board.png"
153
154 total_frames = 400
155
156 def on_frame(idx, _t):
157 fn = actions.get(idx)
158 if fn is not None:
159 fn()
160
161 def capture_fn(idx):
162 return idx in captures
163
164 frames = app.run_headless(
165 ClaustrowordiaRoot(),
166 frames=total_frames,
167 on_frame=on_frame,
168 capture_fn=capture_fn,
169 )
170
171 capture_indices = sorted(captures.keys())
172 for fi, img in zip(capture_indices, frames, strict=False):
173 out = OUT / captures[fi]
174 save_png(img, out)
175 print(f"saved {out}")
176 print(f"{len(capture_indices)} screenshots written")
177
178
179if __name__ == "__main__":
180 capture_sequence()