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.
96 lines
3.9 KiB
Plaintext
96 lines
3.9 KiB
Plaintext
// 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;
|
||
}
|