harness.pyΒΆ
Part of Balatro Feel.
1"""Scripted-input harness: exercises the menu, hover, select, drag, and play.
2
3Drives the real input pipeline through ``InputSimulator`` (the same state writes and
4event propagation the platform adapters perform) and saves one screenshot per stage:
5
6 01_menu.png landing screen, before Play is pressed
7 02_idle.png rest pose, full hand fanned out
8 03_hover.png hovering the middle card: scale-up + parallax tilt
9 04_select.png that card clicked: lifts, punches, rises above its neighbours
10 05_drag.png leftmost card dragged right, slots swapping as it crosses
11 06_dropped.png dropped into its new slot, hand re-fanned
12 07_play.png two cards selected and played: staged pulse
13
14Run:
15 uv run python examples/ports/balatro_feel/harness.py
16"""
17
18from __future__ import annotations
19
20import sys
21from collections.abc import Callable
22from pathlib import Path
23
24_PORT_DIR = Path(__file__).resolve().parent
25if str(_PORT_DIR) not in sys.path:
26 sys.path.insert(0, str(_PORT_DIR))
27
28from main import HEIGHT, WIDTH, BalatroFeelRoot, CardTable # noqa: E402
29from nodes.card import SELECTION_LIFT # noqa: E402
30
31from simvx.core import Key, MouseButton, Vec2 # noqa: E402
32from simvx.core.testing.input_sim import InputSimulator # noqa: E402
33from simvx.graphics import App, save_png # noqa: E402
34
35OUT = _PORT_DIR / "screenshots"
36
37
38def capture_sequence() -> None:
39 OUT.mkdir(exist_ok=True)
40 app = App(width=WIDTH, height=HEIGHT, title="Balatro Feel Harness", visible=False)
41 sim = InputSimulator()
42 root = BalatroFeelRoot()
43
44 def hand():
45 """The live HorizontalHandHolder, or None while the menu is up."""
46 screen = root.screen
47 return screen.hand if isinstance(screen, CardTable) else None
48
49 def card_centre(slot: int) -> Vec2:
50 """Screen position of the card currently in `slot`, matching Card.contains."""
51 holder = hand()
52 card = next(c for c in holder.cards if c.slot_index == slot)
53 return card.position + Vec2(0, -SELECTION_LIFT if card.selected else 0)
54
55 def hover(slot: int) -> Callable[[], None]:
56 return lambda: sim.move_mouse(*card_centre(slot))
57
58 def press(slot: int) -> Callable[[], None]:
59 return lambda: sim.press_mouse(MouseButton.LEFT, tuple(card_centre(slot)))
60
61 def release() -> Callable[[], None]:
62 return lambda: sim.release_mouse(MouseButton.LEFT)
63
64 def drag(from_slot: int, to_slot: int, progress: float) -> Callable[[], None]:
65 def _go() -> None:
66 start, end = card_centre(from_slot), card_centre(to_slot)
67 sim.move_mouse(start.x + (end.x - start.x) * progress, start.y - 18)
68
69 return _go
70
71 actions: dict[int, Callable[[], None]] = {}
72 captures: dict[int, str] = {}
73
74 # Menu: the Play button is anchored dead centre, so its own rect is the target.
75 captures[20] = "01_menu.png"
76
77 def press_play() -> None:
78 x, y, w, h = root.screen.play_button.get_global_rect()
79 sim.click((x + w * 0.5, y + h * 0.5))
80
81 actions[30] = press_play
82 captures[70] = "02_idle.png"
83
84 # Hover the middle card, then click it to select.
85 actions[80] = hover(3)
86 captures[110] = "03_hover.png"
87 actions[120] = press(3)
88 actions[125] = release()
89 captures[150] = "04_select.png"
90
91 # Press the leftmost card and walk it right across to slot 4.
92 actions[170] = press(0)
93 drag_start, drag_end = 175, 210
94 for frame in range(drag_start, drag_end):
95 actions[frame] = drag(0, 4, (frame - drag_start + 1) / (drag_end - drag_start))
96 captures[205] = "05_drag.png"
97 actions[220] = release()
98 captures[245] = "06_dropped.png"
99
100 # Select two more cards and play the hand with the keyboard shortcut.
101 actions[260] = press(2)
102 actions[265] = release()
103 actions[280] = press(5)
104 actions[285] = release()
105 actions[310] = lambda: sim.press_key(Key.SPACE)
106 actions[315] = lambda: sim.release_key(Key.SPACE)
107 captures[345] = "07_play.png"
108
109 def on_frame(idx: int, t: float) -> None:
110 action = actions.get(idx)
111 if action is not None:
112 action()
113
114 frames = app.run_headless(
115 root,
116 frames=380,
117 on_frame=on_frame,
118 capture_fn=lambda idx: idx in captures,
119 )
120
121 for frame_index, img in zip(sorted(captures), frames, strict=False):
122 out = OUT / captures[frame_index]
123 save_png(img, out)
124 print(f"saved {out}")
125 print(f"{len(frames)} screenshots written")
126
127
128if __name__ == "__main__":
129 capture_sequence()