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