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:
112
board_view.sx
112
board_view.sx
@@ -40,6 +40,19 @@ HUD_LINE_GAP :f32: 6.0;
|
||||
HUD_TEXT :: Color.{ r = 255, g = 255, b = 255, a = 255 };
|
||||
HUD_PANEL :: Color.{ r = 12, g = 14, b = 22, a = 185 };
|
||||
|
||||
// Win/lose banner (P7.2): a dim over the board, an opaque panel, the win/lose
|
||||
// headline, and a restart button. Built from text + rects only — the engine's
|
||||
// image path can't tint/fade at draw time (issue 0002), but rects and text DO
|
||||
// honour colour + alpha, so the whole overlay is drawn with them.
|
||||
BANNER_DIM :: Color.{ r = 6, g = 8, b = 14, a = 188 };
|
||||
BANNER_PANEL :: Color.{ r = 20, g = 24, b = 38, a = 240 };
|
||||
BANNER_WIN_TEXT :: Color.{ r = 120, g = 240, b = 150, a = 255 };
|
||||
BANNER_LOSE_TEXT :: Color.{ r = 255, g = 120, b = 110, a = 255 };
|
||||
BANNER_BTN :: Color.{ r = 64, g = 132, b = 224, a = 255 };
|
||||
BANNER_BTN_TEXT :: Color.{ r = 255, g = 255, b = 255, a = 255 };
|
||||
BANNER_TITLE_FONT :f32: 52.0;
|
||||
BANNER_BTN_FONT :f32: 30.0;
|
||||
|
||||
// UV sub-rect of one gem column, spanning the sheet's full height.
|
||||
GemUV :: struct {
|
||||
uv_min: Point;
|
||||
@@ -181,6 +194,9 @@ BoardView :: struct {
|
||||
fxassets: *BoardFxAssets;
|
||||
motion: *GemMotion;
|
||||
safe: EdgeInsets;
|
||||
// Seed for `restart`: the same fixed seed main seeded the board with, so the
|
||||
// restart button reproduces the identical starting level.
|
||||
seed: s64;
|
||||
|
||||
// Where the grid sits + the touch↔cell mapping. Recomputed each render /
|
||||
// event from the current frame so the hit-test matches what was drawn.
|
||||
@@ -430,6 +446,65 @@ BoardView :: struct {
|
||||
self.draw_gem(ctx, gf, cast(s64) g);
|
||||
}
|
||||
}
|
||||
|
||||
// Whether the win/lose banner is up: the level is over AND any in-flight move
|
||||
// animation has settled, so a winning/losing cascade plays to completion
|
||||
// before the banner covers the board. Board input stays frozen the whole time
|
||||
// the level is terminal (see handle_event), independent of this.
|
||||
banner_up :: (self: *BoardView) -> bool {
|
||||
if level_status(self.board) == .in_progress { return false; }
|
||||
self.anim == null or !self.anim.active
|
||||
}
|
||||
|
||||
// Win/lose overlay (P7.2): dim the board, draw the centered panel, the
|
||||
// win/lose headline, and the restart button — all text + rects so colour and
|
||||
// alpha are honoured. The button rect comes from the shared BannerLayout, so
|
||||
// it sits exactly where handle_event hit-tests the restart tap.
|
||||
render_banner :: (self: *BoardView, ctx: *RenderContext, status: Status) {
|
||||
ctx.add_rect(self.layout.grid_frame(), BANNER_DIM);
|
||||
|
||||
bl := self.layout.banner();
|
||||
ctx.add_rounded_rect(bl.panel, BANNER_PANEL, 18.0);
|
||||
|
||||
title := if status == .won then "YOU WIN!" else "OUT OF MOVES";
|
||||
tcol := if status == .won then BANNER_WIN_TEXT else BANNER_LOSE_TEXT;
|
||||
tfont := fit_font(title, BANNER_TITLE_FONT, bl.title.size.width);
|
||||
tsz := measure_text(title, tfont);
|
||||
ctx.add_text(
|
||||
Frame.make(bl.title.mid_x() - tsz.width * 0.5, bl.title.mid_y() - tsz.height * 0.5, tsz.width, tsz.height),
|
||||
title, tfont, tcol
|
||||
);
|
||||
|
||||
ctx.add_rounded_rect(bl.button, BANNER_BTN, 12.0);
|
||||
btxt := "PLAY AGAIN";
|
||||
bfont := fit_font(btxt, BANNER_BTN_FONT, bl.button.size.width * 0.86);
|
||||
bsz := measure_text(btxt, bfont);
|
||||
ctx.add_text(
|
||||
Frame.make(bl.button.mid_x() - bsz.width * 0.5, bl.button.mid_y() - bsz.height * 0.5, bsz.width, bsz.height),
|
||||
btxt, bfont, BANNER_BTN_TEXT
|
||||
);
|
||||
}
|
||||
|
||||
// Restart action behind the banner's button: reseed the SAME starting level
|
||||
// through the model (board.restart) and drop every transient view layer
|
||||
// (selection, in-flight drag, move animation, FX) so the board returns to a
|
||||
// clean in_progress state.
|
||||
do_restart :: (self: *BoardView) {
|
||||
self.board.restart(self.seed);
|
||||
self.sel.clear();
|
||||
self.drag.clear();
|
||||
if self.anim != null { self.anim.init(); }
|
||||
if self.fx != null { self.fx.clear(); }
|
||||
}
|
||||
}
|
||||
|
||||
// Scale `base` font size down so `text` fits within `max_w` (measure_text scales
|
||||
// linearly with font size, so one division lands it). Never scales up — a short
|
||||
// headline keeps its size; only an over-wide one shrinks to fit the panel.
|
||||
fit_font :: (text: string, base: f32, max_w: f32) -> f32 {
|
||||
sz := measure_text(text, base);
|
||||
if sz.width <= max_w or sz.width <= 0.0 { return base; }
|
||||
base * max_w / sz.width
|
||||
}
|
||||
|
||||
impl View for BoardView {
|
||||
@@ -489,6 +564,13 @@ impl View for BoardView {
|
||||
// self-pruning, so they vanish once the move settles.
|
||||
self.render_fx_particles(ctx);
|
||||
self.render_fx_popups(ctx);
|
||||
|
||||
// 6. Win/lose banner over everything, once the level is over and the
|
||||
// final cascade has settled. Status comes from the model (P7.1); the
|
||||
// view never recomputes win/lose.
|
||||
if self.banner_up() {
|
||||
self.render_banner(ctx, level_status(self.board));
|
||||
}
|
||||
}
|
||||
|
||||
// Touch input. A press records the drag start; the release resolves the
|
||||
@@ -500,6 +582,24 @@ impl View for BoardView {
|
||||
// behaviour: toggle the selection on the pressed cell, or clear it off-board.
|
||||
handle_event :: (self: *BoardView, event: *Event, frame: Frame) -> bool {
|
||||
self.compute_layout(frame);
|
||||
|
||||
// A finished level (won/lost) freezes board input: swipes/taps on cells
|
||||
// are ignored. Status comes from the model (P7.1) — never recomputed
|
||||
// here. Once the banner is up its restart button is the only live target;
|
||||
// a tap inside it reseeds a fresh level through board.restart.
|
||||
if level_status(self.board) != .in_progress {
|
||||
if event.* == {
|
||||
case .mouse_down: (d) { return true; }
|
||||
case .mouse_up: (d) {
|
||||
if self.banner_up() and self.layout.banner().button.contains(d.position) {
|
||||
self.do_restart();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if event.* == {
|
||||
case .mouse_down: (d) {
|
||||
// Gate input at gesture START: while a move animation is in
|
||||
@@ -536,13 +636,13 @@ impl View for BoardView {
|
||||
}
|
||||
}
|
||||
|
||||
// Draw the HUD card — current score and remaining moves (out of the move limit)
|
||||
// — centered horizontally in the top of `avail`, the safe-area-inset region the
|
||||
// grid is centered in. Reads live model state, so it tracks score/moves as the
|
||||
// game progresses. A translucent panel sits behind the text for legibility over
|
||||
// the board art.
|
||||
// Draw the HUD card — current score against the per-level goal and the remaining
|
||||
// moves (out of the move limit) — centered horizontally in the top of `avail`,
|
||||
// the safe-area-inset region the grid is centered in. Reads live model state
|
||||
// (score, target_score, moves), so it tracks the goal progress as the game runs.
|
||||
// A translucent panel sits behind the text for legibility over the board art.
|
||||
render_hud :: (ctx: *RenderContext, board: *Board, avail: Frame) {
|
||||
score_str := format("SCORE {}", board.score);
|
||||
score_str := format("SCORE {} / {}", board.score, board.target_score);
|
||||
moves_str := format("MOVES {}/{}", board.moves_remaining(), board.move_limit);
|
||||
|
||||
score_sz := measure_text(score_str, HUD_FONT);
|
||||
|
||||
Reference in New Issue
Block a user