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.controls import Mouse # 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
84 captures: dict[int, str] = {}
85 actions: dict[int, Callable[[], None]] = {}
86
87 def move(x: float, y: float) -> Callable[[], None]:
88 return lambda: sim.move_mouse(x, y)
89
90 def click(x: float, y: float) -> Callable[[], None]:
91 # Press only: the game reads the press edge, and holding the button
92 # across a few frames matches how a player actually drags a tile.
93 return lambda: sim.press_mouse(Mouse.LEFT, (x, y))
94
95 def release_lmb() -> Callable[[], None]:
96 return lambda: sim.release_mouse(Mouse.LEFT)
97
98 # Hand slot 0 should be "A" (first letter after the four centre tiles).
99 h0_x, h0_y = hand_tile_xy(WIDTH, HEIGHT, 0)
100 cell_2_3_x, cell_2_3_y = cell_to_world_xy(WIDTH, HEIGHT, 2, 3)
101
102 # Frames 5..10: dismiss the title screen so the board is dealt.
103 actions[5] = click(WIDTH / 2, HEIGHT / 2)
104 actions[10] = release_lmb()
105
106 # Frame 30: idle starting state
107 captures[30] = "01_idle.png"
108
109 # Frame 50: pick up hand tile 0 ("A"); then move the cursor up so the
110 # tile follows it visibly (otherwise the screenshot just looks like the
111 # tile is still in the hand).
112 actions[45] = click(h0_x, h0_y)
113 actions[50] = release_lmb()
114 actions[55] = move(WIDTH * 0.3, HEIGHT * 0.55)
115 captures[70] = "02_pickup.png"
116
117 # Frame 80: hover the cell (2, 3), between C at (2,2) and T at (2,4).
118 # Capture *before* the drop click so the shot shows the held tile snapped
119 # to the drop preview rather than the scoring flash that follows.
120 actions[80] = move(cell_2_3_x, cell_2_3_y)
121 captures[90] = "03_hover_cell.png"
122
123 # Frame 105: click to drop on the cell: forms vertical "CAT"
124 actions[105] = click(cell_2_3_x, cell_2_3_y)
125 actions[110] = release_lmb()
126 captures[125] = "04_placed.png"
127
128 # Frame 160: scoring animation has fired: capture the green flashed tiles
129 captures[160] = "05_word_scored.png"
130
131 # Frames 200..360: place several more tiles to demonstrate accumulation.
132 plan = [
133 # (frame_pickup, frame_drop, hand_slot, target_cell)
134 # After dropping A, hand re-fills from the random pool, so the next
135 # tiles aren't deterministic, so we pick targets that are likely empty
136 # and don't require a specific letter to score.
137 (210, 230, 0, (3, 2)),
138 (250, 270, 0, (3, 4)),
139 (290, 310, 0, (1, 3)),
140 (330, 350, 0, (5, 3)),
141 ]
142 for fp, fd, slot, (gx, gy) in plan:
143 hx, hy = hand_tile_xy(WIDTH, HEIGHT, slot)
144 tx, ty = cell_to_world_xy(WIDTH, HEIGHT, gx, gy)
145 actions[fp] = click(hx, hy)
146 actions[fp + 5] = release_lmb()
147 actions[fp + 10] = move(tx, ty)
148 actions[fd] = click(tx, ty)
149 actions[fd + 5] = release_lmb()
150
151 captures[370] = "06_full_board.png"
152
153 total_frames = 400
154
155 def on_frame(idx, _t):
156 fn = actions.get(idx)
157 if fn is not None:
158 fn()
159
160 def capture_fn(idx):
161 return idx in captures
162
163 root = ClaustrowordiaRoot()
164 sim = InputSimulator(root)
165 frames = app.run_headless(
166 root,
167 frames=total_frames,
168 on_frame=on_frame,
169 capture_fn=capture_fn,
170 )
171
172 capture_indices = sorted(captures.keys())
173 for fi, img in zip(capture_indices, frames, strict=False):
174 out = OUT / captures[fi]
175 save_png(img, out)
176 print(f"saved {out}")
177 print(f"{len(capture_indices)} screenshots written")
178
179
180if __name__ == "__main__":
181 capture_sequence()