P7.2: goal HUD + win/lose banner + restart button (sx, iOS sim)

Extend the HUD to show the per-level goal (SCORE x / target) alongside
moves. When the model's level_status (P7.1) is won/lost, draw a centered
overlay banner ("YOU WIN!" / "OUT OF MOVES") with a "PLAY AGAIN" restart
button over the dimmed board; the banner appears once any winning/losing
cascade animation settles. Status is read from the model, never recomputed
in the view.

A finished level freezes board-cell input; only the restart button is live.
Its rect is derived from the shared BoardLayout grid (new BannerLayout), so
the hit-test lands exactly on the drawn button. A tap reseeds the same
starting level through board.restart and clears the transient view layers,
returning to a clean in_progress board.

Banner is text + rects only (honours colour/alpha; no draw-time image tint,
issue 0002). New env capture hooks (M3TE_TARGET / M3TE_MOVE_LIMIT /
M3TE_RESTART) force a terminal status / restart for deterministic goldens.

Tests: tests/banner_layout.sx locks the restart button rect <-> hit-test
round-trip headlessly. Goldens p7_win / p7_lose / p7_restart captured on the
iOS simulator.
This commit is contained in:
swipelab
2026-06-05 14:57:27 +03:00
parent bf38c7a100
commit 5be379f180
10 changed files with 284 additions and 7 deletions

81
tests/banner_layout.sx Normal file
View File

@@ -0,0 +1,81 @@
// Banner / restart hit-test golden (P7.2): lock the win/lose banner geometry and
// the restart-button hit-test that `BoardView.handle_event` relies on. The button
// rect is derived from the SAME grid layout the gems use, and a finished level
// freezes every board-cell tap, so the button is the only live target — if its
// rect drifted from what `render_banner` draws, a tap on the visible button would
// miss and the level could never be restarted. This test checks, headlessly:
//
// * the button is centered over the grid and sits fully inside the panel,
// * the button centre hit-tests INTO the button (tap → restart),
// * an off-button tap (a board corner) hit-tests OUT (frozen, no restart).
//
// Imports BoardLayout (no GL/stb), not BoardView, so it links headless — same
// shape and rationale as tests/hit_test.sx. Failure is signalled via a non-zero
// exit code (the runner checks exit code AND stdout).
#import "modules/std.sx";
#import "board.sx";
#import "board_layout.sx";
irect :: (f: Frame) -> string {
format("({},{},{},{})",
cast(s64) f.origin.x, cast(s64) f.origin.y,
cast(s64) f.size.width, cast(s64) f.size.height)
}
main :: () -> s32 {
// 800×600, no safe inset → 600px square grid, cell 75, origin (100,0): the
// same layout tests/hit_test.sx pins, so the numbers are checkable by hand.
lay : BoardLayout = ---;
lay.compute(Frame.make(0.0, 0.0, 800.0, 600.0), EdgeInsets.zero());
grid := lay.grid_frame();
bl := lay.banner();
print("grid {}\n", irect(grid));
print("panel {}\n", irect(bl.panel));
print("title {}\n", irect(bl.title));
print("button {}\n", irect(bl.button));
fails : s64 = 0;
// The button is horizontally centered on the grid (centred banner).
bcx := bl.button.mid_x();
if cast(s64) bcx != cast(s64) grid.mid_x() { fails += 1; }
print("button mid_x {} grid mid_x {}\n", cast(s64) bcx, cast(s64) grid.mid_x());
// The whole button sits inside the panel — its four corners are contained,
// so it can never spill outside the drawn card.
bx0 := bl.button.origin.x; by0 := bl.button.origin.y;
bx1 := bl.button.max_x(); by1 := bl.button.max_y();
corners_in : s64 = 0;
if bl.panel.contains(Point.{ x = bx0, y = by0 }) { corners_in += 1; }
if bl.panel.contains(Point.{ x = bx1, y = by0 }) { corners_in += 1; }
if bl.panel.contains(Point.{ x = bx0, y = by1 }) { corners_in += 1; }
if bl.panel.contains(Point.{ x = bx1, y = by1 }) { corners_in += 1; }
if corners_in != 4 { fails += 1; }
print("button corners inside panel: {}/4\n", corners_in);
// A tap on the button centre restarts (hit-test true); the panel itself is
// also contained, so the centre is unambiguously on the button.
center := Point.{ x = bl.button.mid_x(), y = bl.button.mid_y() };
hit_center := bl.button.contains(center);
if !hit_center { fails += 1; }
print("button center hit: {}\n", hit_center);
// Off-button taps that a finished level must NOT treat as restart: the grid's
// top-left corner cell centre, and a point just outside the panel. Neither is
// in the button, so each leaves the level frozen.
corner_cell := Point.{ x = grid.origin.x + lay.cell_size * 0.5, y = grid.origin.y + lay.cell_size * 0.5 };
outside := Point.{ x = bl.panel.origin.x - 5.0, y = bl.panel.mid_y() };
off_hits : s64 = 0;
if bl.button.contains(corner_cell) { off_hits += 1; }
if bl.button.contains(outside) { off_hits += 1; }
if off_hits != 0 { fails += 1; }
print("off-button taps that hit the button: {}\n", off_hits);
if fails == 0 {
print("ok: restart button hit-test matches the drawn banner\n");
return 0;
}
print("FAIL: {} banner hit-test checks failed\n", fails);
return 1;
}

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,9 @@
grid (100,0,600,600)
panel (148,168,503,264)
title (148,215,503,79)
button (248,326,302,63)
button mid_x 400 grid mid_x 400
button corners inside panel: 4/4
button center hit: true
off-button taps that hit the button: 0
ok: restart button hit-test matches the drawn banner