harness.py¶
Part of PirateMaker.
1"""Headless `--test` mode: capture screenshots covering the title menu, an empty
2editor, placing tiles, entering play mode, mid-play, and coming back.
3
4Implemented as a single `app.run_headless()` with `on_frame` callbacks that
5mutate the editor at scheduled frames. Direct API calls (open_editor,
6place_tile_at, start_play, return_to_editor) are used so frames are
7deterministic; the harness is for screenshot capture, not input fuzzing.
8"""
9
10from __future__ import annotations
11
12from pathlib import Path
13
14import numpy as np
15from main import PirateMakerRoot
16from settings import WINDOW_HEIGHT, WINDOW_WIDTH
17
18from simvx.graphics import App, save_png
19
20OUT_DIR = Path(__file__).resolve().parent / "screenshots"
21OUT_DIR.mkdir(exist_ok=True)
22
23
24# Schedule:
25# (frame_at_which_to_act, action_callable, screenshot_filename_or_None)
26# Frame numbers are 0-based as seen by `on_frame`. capture_frames lists the
27# *last frame index of each cohort*, i.e. the frame whose render is the one
28# we want saved.
29SCREENSHOTS: list[tuple[int, str]] = [
30 (3, "01_title_menu.png"),
31 (9, "02_editor_empty.png"),
32 (15, "03_editor_with_tiles.png"),
33 (22, "04_palette_coin.png"),
34 (28, "05_play_boot.png"),
35 (88, "06_play_mid.png"),
36 (130, "07_play_walking.png"),
37 (138, "08_back_to_editor.png"),
38]
39
40
41def run_test() -> None:
42 from settings import SKY_COLOUR
43
44 root = PirateMakerRoot()
45 app = App(width=WINDOW_WIDTH, height=WINDOW_HEIGHT, title="PirateMaker (test)", visible=False, bg_colour=SKY_COLOUR)
46
47 def setup_level(editor):
48 # Origin defaults to (512, 360); rows 0..3 are within viewport.
49 # Place a row of terrain at row=2 (world Y ≈ 488)
50 for col in range(-3, 9):
51 editor.place_tile_at(col, 2, tile_id=2)
52 # A coin and a tooth above the row
53 editor.place_tile_at(0, 1, tile_id=4)
54 editor.place_tile_at(2, 1, tile_id=8)
55 # A water column to the right
56 for row in range(2, 5):
57 editor.place_tile_at(10, row, tile_id=3)
58 editor.place_tile_at(11, row, tile_id=3)
59 editor.recheck_all_neighbours()
60
61 def on_frame(idx: int, t: float):
62 # Pre-frame mutations
63 if idx == 4:
64 root.open_editor()
65 if idx == 10:
66 setup_level(root.editor)
67 if idx == 16:
68 root.editor.selection_index = 4
69 if idx == 23:
70 root.start_play()
71 if idx == 89 and root.level is not None:
72 root.level.player.direction.x = 1.0
73 root.level.player.orientation = "right"
74 if idx == 131:
75 root.return_to_editor()
76 return None
77
78 capture_frames = [f for f, _ in SCREENSHOTS]
79 total = max(capture_frames) + 2
80
81 pixels = app.run_headless(root, frames=total, on_frame=on_frame, capture_frames=capture_frames)
82
83 # Composite alpha against the sky background so transparent textured-quad
84 # pixels (caused by the engine's `dstAlphaBlendFactor=ZERO` blend state
85 # punching alpha=0 through the framebuffer) display correctly when viewed
86 # in standard PNG viewers.
87 sky = np.array([221, 198, 161], dtype=np.float32)
88 for (_, name), arr in zip(SCREENSHOTS, pixels, strict=False):
89 if arr.shape[-1] == 4:
90 alpha = arr[:, :, 3:4].astype(np.float32) / 255.0
91 rgb = arr[:, :, :3].astype(np.float32)
92 composited = (rgb * alpha + sky * (1.0 - alpha)).astype(np.uint8)
93 arr = np.dstack([composited, np.full_like(arr[:, :, 3], 255)])
94 path = OUT_DIR / name
95 save_png(arr, path)
96 print(f"saved {name}")
97
98 print(f"OK: {len(pixels)} screenshots in {OUT_DIR}")