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()