Files
m3te/board_layout.sx
swipelab 5be379f180 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.
2026-06-05 14:57:27 +03:00

96 lines
3.9 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Pure geometry of the on-screen board: where the centered 8×8 grid sits inside
// a frame, and the two-way mapping between cells and screen points. Owns no
// rendering and pulls in NO GL/stb imports, so the touch→cell mapping is
// unit-testable headless. BoardView composes this for layout + hit-testing, and
// P5's swap input reuses `point_to_cell` to resolve a tap to a swap endpoint.
#import "modules/std.sx";
#import "modules/math";
#import "modules/ui/types.sx";
#import "board.sx";
BoardLayout :: struct {
cell_size: f32;
origin: Point;
// Center a square 8×8 grid inside the safe-area-inset region of `frame`.
compute :: (self: *BoardLayout, frame: Frame, safe: EdgeInsets) {
avail := frame.inset(safe);
cols : f32 = xx BOARD_COLS;
board_dim := min(avail.size.width, avail.size.height);
self.cell_size = board_dim / cols;
total := self.cell_size * cols;
self.origin = Point.{
x = avail.origin.x + (avail.size.width - total) * 0.5,
y = avail.origin.y + (avail.size.height - total) * 0.5
};
}
cell_frame :: (self: *BoardLayout, col: s64, row: s64) -> Frame {
Frame.make(
self.origin.x + xx col * self.cell_size,
self.origin.y + xx row * self.cell_size,
self.cell_size,
self.cell_size
)
}
// Inverse of `cell_frame`: map a view-local point to the grid cell under it,
// or null when the point falls outside the 8×8 grid. The `< 0.0` guards run
// BEFORE the truncating cast, since casting a small negative float rounds
// toward zero into a valid index. Uses the SAME origin / cell_size `compute`
// produced, so a tap resolves to exactly the cell drawn under the finger.
point_to_cell :: (self: *BoardLayout, p: Point) -> ?Cell {
if self.cell_size <= 0.0 { return null; }
fx := (p.x - self.origin.x) / self.cell_size;
fy := (p.y - self.origin.y) / self.cell_size;
if fx < 0.0 or fy < 0.0 { return null; }
col : s64 = xx fx;
row : s64 = xx fy;
if col >= BOARD_COLS or row >= BOARD_ROWS { return null; }
Cell.{ col = col, row = row }
}
// Frame of the whole 8×8 grid (origin + cols/rows × cell_size). The banner
// and its dimming overlay are sized off this so they cover exactly the board.
grid_frame :: (self: *BoardLayout) -> Frame {
Frame.make(
self.origin.x, self.origin.y,
self.cell_size * cast(f32) BOARD_COLS,
self.cell_size * cast(f32) BOARD_ROWS
)
}
// Win/lose banner geometry (P7.2): an overlay panel centered over the board
// grid, with the title band and the restart button inside it. Derived purely
// from the SAME grid layout the gems use, so the restart hit-test in
// BoardView.handle_event lands on exactly the button BoardView draws. The
// headless banner_layout test locks the button-rect ↔ hit-test round-trip.
banner :: (self: *BoardLayout) -> BannerLayout {
grid := self.grid_frame();
cx := grid.mid_x();
cy := grid.mid_y();
panel_w := grid.size.width * 0.84;
panel_h := grid.size.height * 0.44;
panel := Frame.make(cx - panel_w * 0.5, cy - panel_h * 0.5, panel_w, panel_h);
title := Frame.make(panel.origin.x, panel.origin.y + panel_h * 0.18, panel_w, panel_h * 0.30);
btn_w := panel_w * 0.60;
btn_h := panel_h * 0.24;
btn_y := panel.origin.y + panel_h - btn_h - panel_h * 0.16;
button := Frame.make(cx - btn_w * 0.5, btn_y, btn_w, btn_h);
BannerLayout.{ panel = panel, title = title, button = button }
}
}
// Resolved rectangles of the win/lose banner: the centered `panel`, the `title`
// band where the win/lose headline is centered, and the restart `button` rect
// (also the hit-test target). All in the same view-local space as BoardLayout.
BannerLayout :: struct {
panel: Frame;
title: Frame;
button: Frame;
}