Files
m3te/board_view.sx
swipelab 0f84b09f7b P7.2 fix: reset per-gem landing state on restart
The restart button (BoardView.do_restart) reseeded the model and dropped
selection/drag/anim/FX, but left GemMotion.land_at carrying the prior move's
landing stamps. A restart fired right after a terminal cascade therefore
replayed that move's squash-bounce on the freshly seeded board instead of
showing a clean resting pose.

Factor the landing reset into GemMotion.reset_landings (init now delegates to
it) and call it from do_restart, so a restart returns every cell to its
resting idle pose. The idle clock keeps running, so the always-on idle simply
resumes from rest.

Regression: tests/gem_pose.sx section 7 stamps a cell mid-squash, asserts it
is squashing, then asserts reset_landings returns every cell to rest while
leaving the clock untouched. Fails on the pre-fix (no-op reset) behaviour,
passes after. Gate green: ios-sim build + 18/18 logic tests.
2026-06-05 15:17:37 +03:00

667 lines
28 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.
// BoardView (P4.3) — render the seeded match-3 board with real gem sprites.
//
// Modeled on game/chess/board_view.sx: a `View` that lays out an 8×8 grid and
// draws tiles/sprites through RenderContext.add_image / add_image_uv, sampling
// the gem sprite sheet by UV column. The background image fills the whole view;
// the grid is a centered square inside the safe-area inset.
#import "modules/std.sx";
#import "modules/math";
#import "modules/opengl.sx";
#import "modules/stb.sx";
#import "modules/gpu/types.sx";
#import "modules/gpu/api.sx";
#import "modules/ui/types.sx";
#import "modules/ui/render.sx";
#import "modules/ui/events.sx";
#import "modules/ui/view.sx";
#import "modules/ui/font.sx";
#import "board.sx";
#import "board_layout.sx";
#import "board_anim.sx";
#import "board_fx.sx";
#import "gem_anim.sx";
#import "swipe.sx";
// Fraction of a cell each gem occupies; the remainder is margin so a gem sits
// inside its cell tile rather than touching the tile's edges.
GEM_FILL_FRAC :f32: 0.84;
// Selection overlay: a translucent warm fill plus a bright opaque rim around the
// chosen cell. `add_stroked_rect` draws the rim in its FILL color (the renderer
// ignores the separate stroke color), so SELECT_RIM is passed as the fill.
SELECT_FILL :: Color.{ r = 255, g = 240, b = 120, a = 70 };
SELECT_RIM :: Color.{ r = 255, g = 228, b = 60, a = 255 };
// HUD: a translucent card with the score and remaining moves, in the loaded Lato
// font. Placed in the empty band above the centered grid (inside the safe area).
HUD_FONT :f32: 34.0;
HUD_PAD :f32: 14.0;
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;
uv_max: Point;
}
// Loads and holds the three board textures (background, cell tile, gem sheet)
// and maps a gem index to its column UV. Modeled on chess's ChessPieces.
BoardAssets :: struct {
bg_tex: u32;
cell_tex: u32;
gems_tex: u32;
cell_u: f32;
loaded: bool;
init :: (self: *BoardAssets) {
self.bg_tex = 0;
self.cell_tex = 0;
self.gems_tex = 0;
// gems.png is GEM_COUNT columns wide and one row tall, so a gem's UV
// column IS its gem index (0=red … 5=purple); cell_u is one column wide.
self.cell_u = 1.0 / cast(f32) GEM_COUNT;
self.loaded = false;
}
load :: (self: *BoardAssets, gpu: ?GPU) {
self.bg_tex = load_texture("assets/board/background.png", gpu);
self.cell_tex = load_texture("assets/board/cell.png", gpu);
self.gems_tex = load_texture("assets/gems/gems.png", gpu);
self.loaded = self.bg_tex != 0 and self.cell_tex != 0 and self.gems_tex != 0;
}
gem_uv :: (self: *BoardAssets, index: s64) -> GemUV {
u0 : f32 = xx index * self.cell_u;
GemUV.{
uv_min = Point.{ x = u0, y = 0.0 },
uv_max = Point.{ x = u0 + self.cell_u, y = 1.0 }
}
}
}
// Decode an RGBA image and upload it as a texture, returning the handle (0 on
// failure). When a GPU backend is bound (iOS Metal) it owns the upload; the
// desktop GL path falls back to a plain GL_TEXTURE_2D.
load_texture :: (path: [:0]u8, gpu: ?GPU) -> u32 {
w : s32 = 0;
h : s32 = 0;
ch : s32 = 0;
pixels := stbi_load(path, @w, @h, @ch, 4);
if pixels == null {
out("WARNING: could not load texture: ");
out(path);
out("\n");
return 0;
}
tex : u32 = 0;
if gpu != null {
tex = gpu.create_texture(w, h, .rgba8, xx pixels);
} else {
glGenTextures(1, @tex);
glBindTexture(GL_TEXTURE_2D, tex);
glTexImage2D(GL_TEXTURE_2D, 0, xx GL_RGBA, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, xx GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, xx GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, xx GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, xx GL_CLAMP_TO_EDGE);
}
stbi_image_free(pixels);
return tex;
}
// Which board cell the player has currently selected, if any. Lives behind a
// pointer (heap-allocated in main) because BoardView is a value rebuilt every
// frame from `build_ui`, so the view itself cannot carry state across frames.
// A tap toggles this highlight; a swipe commits a swap (see DragInput) and
// clears it.
BoardSelection :: struct {
active: bool;
cell: Cell;
// Animation clock value when this selection last became active, so the
// selection-pop reaction (gem_anim) can age from the moment of the tap.
since: f32;
init :: (self: *BoardSelection) {
self.active = false;
self.cell = Cell.{ col = 0, row = 0 };
self.since = 0.0;
}
clear :: (self: *BoardSelection) {
self.active = false;
}
// Tapping a cell selects it; tapping the cell already selected clears the
// selection, so a tap toggles its own cell and moves it to any other.
toggle :: (self: *BoardSelection, c: Cell) {
if self.active and self.cell.col == c.col and self.cell.row == c.row {
self.active = false;
} else {
self.active = true;
self.cell = c;
}
}
}
// Tracks an in-progress touch drag between its press and release so a swipe can
// be resolved on lift: the press records the start point, and release maps
// start→end through `swipe_intent` to an adjacent-swap intent. Heap-allocated
// (like BoardSelection) so it survives BoardView's per-frame rebuild between the
// down (touchesBegan → mouse_down) and up (touchesEnded → mouse_up) events.
DragInput :: struct {
active: bool;
start: Point;
init :: (self: *DragInput) {
self.active = false;
self.start = Point.{ x = 0.0, y = 0.0 };
}
begin :: (self: *DragInput, p: Point) {
self.active = true;
self.start = p;
}
clear :: (self: *DragInput) {
self.active = false;
}
}
BoardView :: struct {
board: *Board;
assets: *BoardAssets;
sel: *BoardSelection;
drag: *DragInput;
anim: *BoardAnim;
fx: *BoardFx;
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.
layout: BoardLayout;
compute_layout :: (self: *BoardView, frame: Frame) {
self.layout.compute(frame, self.safe);
}
// Draw gem `gem_index`'s sprite-sheet column into `gf`.
draw_gem :: (self: *BoardView, ctx: *RenderContext, gf: Frame, gem_index: s64) {
uv := self.assets.gem_uv(gem_index);
ctx.add_image_uv(gf, self.assets.gems_tex, uv.uv_min, uv.uv_max);
}
// Frame for a gem at a (possibly fractional) board position, inset inside its
// cell. Fractional col/row is how the swap-slide and fall animations place a
// gem partway between cells.
gem_frame :: (self: *BoardView, fcol: f32, frow: f32, inset: f32, dim: f32) -> Frame {
Frame.make(
self.layout.origin.x + fcol * self.layout.cell_size + inset,
self.layout.origin.y + frow * self.layout.cell_size + inset,
dim,
dim
)
}
// Frame for a gem shrunk by `scale` about its cell centre — the clear
// scale-out. At scale 0 the gem is a zero-size frame (gone).
gem_frame_scaled :: (self: *BoardView, col: s64, row: s64, dim: f32, scale: f32) -> Frame {
cs := self.layout.cell_size;
cx := self.layout.origin.x + cast(f32) col * cs + cs * 0.5;
cy := self.layout.origin.y + cast(f32) row * cs + cs * 0.5;
d := dim * scale;
Frame.make(cx - d * 0.5, cy - d * 0.5, d, d)
}
// Frame for a gem at cell (col,row) drawn with a per-gem animation pose: the
// sprite is scaled about its cell centre and nudged by the pose offset (both
// in cell units). A resting pose reproduces gem_frame exactly, so the t==0
// idle pose draws identically to the static sprite.
gem_pose_frame :: (self: *BoardView, col: s64, row: s64, dim: f32, pose: GemPose) -> Frame {
cs := self.layout.cell_size;
cx := self.layout.origin.x + (cast(f32) col + 0.5) * cs + pose.dx * cs;
cy := self.layout.origin.y + (cast(f32) row + 0.5) * cs + pose.dy * cs;
w := dim * pose.scale_x;
h := dim * pose.scale_y;
Frame.make(cx - w * 0.5, cy - h * 0.5, w, h)
}
// The per-gem animation pose for a settled cell: the always-on idle breath,
// plus a squash-bounce if the cell landed recently, plus a pop if it is the
// selected cell. Purely visual — composed from gem_anim's pure functions.
gem_pose_at :: (self: *BoardView, col: s64, row: s64) -> GemPose {
pose := idle_pose(self.motion.clock, col, row);
sq := land_squash(self.motion.land_local(Board.idx(col, row)));
pose.scale_x += sq;
pose.scale_y -= sq;
if self.sel != null and self.sel.active
and self.sel.cell.col == col and self.sel.cell.row == row {
ts := if self.motion.pinned then self.motion.clock else self.motion.clock - self.sel.since;
sp := select_pop_scale(ts);
pose.scale_x *= sp;
pose.scale_y *= sp;
}
pose
}
// Settled-board gems: one sprite per non-empty cell, drawn with its live
// per-gem animation pose. Used whenever no move is animating.
render_gems :: (self: *BoardView, ctx: *RenderContext, dim: f32) {
for 0..BOARD_ROWS: (row) {
for 0..BOARD_COLS: (col) {
g := self.board.at(col, row);
if g != .empty {
pose := self.gem_pose_at(col, row);
gf := self.gem_pose_frame(col, row, dim, pose);
self.draw_gem(ctx, gf, cast(s64) g);
}
}
}
}
// Play the active slice of the move timeline. Gem motion is clipped to the
// grid so refilled gems slide in from behind the top edge rather than
// overlapping the HUD band above the board.
render_anim :: (self: *BoardView, ctx: *RenderContext, inset: f32, dim: f32) {
ph := self.anim.phase();
cs := self.layout.cell_size;
grid := Frame.make(
self.layout.origin.x, self.layout.origin.y,
cs * cast(f32) BOARD_COLS, cs * cast(f32) BOARD_ROWS
);
ctx.push_clip(grid);
mv := @self.anim.move;
if ph.kind == .swap {
self.render_swap(ctx, mv, inset, dim, ph.t);
} else if ph.kind == .clear {
rd := @mv.rounds.items[ph.round];
self.render_clear(ctx, rd, inset, dim, ph.t);
} else if ph.kind == .fall {
rd := @mv.rounds.items[ph.round];
self.render_fall(ctx, rd, inset, dim, ph.t);
} else {
// Settled tail of the timeline — draw the final (model) board. tick()
// normally clears `active` before this is reached, so it is the seam
// safety net rather than a frame the player typically sees.
for 0..BOARD_CELLS: (i) {
g := mv.final[i];
if g != .empty {
gf := self.gem_frame(cast(f32) (i % BOARD_COLS), cast(f32) (i / BOARD_COLS), inset, dim);
self.draw_gem(ctx, gf, cast(s64) g);
}
}
}
ctx.pop_clip();
}
// Swap segment: the board sits still (pre-swap) except the two swapped gems,
// which slide between their cells. A legal swap slides fully (a→b, b→a); an
// illegal one pings out to the neighbour and back, ending where it started.
render_swap :: (self: *BoardView, ctx: *RenderContext, mv: *AnimMove, inset: f32, dim: f32, t: f32) {
ai := Board.idx(mv.a.col, mv.a.row);
bi := Board.idx(mv.b.col, mv.b.row);
for 0..BOARD_CELLS: (i) {
if i == ai or i == bi { continue; }
g := mv.pre[i];
if g != .empty {
gf := self.gem_frame(cast(f32) (i % BOARD_COLS), cast(f32) (i / BOARD_COLS), inset, dim);
self.draw_gem(ctx, gf, cast(s64) g);
}
}
p : f32 = ---;
if mv.legal {
p = ease_out_cubic(t);
} else if t < 0.5 {
p = ease_out_cubic(t * 2.0);
} else {
p = ease_out_cubic((1.0 - t) * 2.0);
}
afc := cast(f32) mv.a.col; afr := cast(f32) mv.a.row;
bfc := cast(f32) mv.b.col; bfr := cast(f32) mv.b.row;
ga := mv.pre[ai];
if ga != .empty {
gf := self.gem_frame(afc + (bfc - afc) * p, afr + (bfr - afr) * p, inset, dim);
self.draw_gem(ctx, gf, cast(s64) ga);
}
gb := mv.pre[bi];
if gb != .empty {
gf := self.gem_frame(bfc + (afc - bfc) * p, bfr + (afr - bfr) * p, inset, dim);
self.draw_gem(ctx, gf, cast(s64) gb);
}
}
// Clear segment: matched gems pop outward then collapse to nothing (a
// satisfying pop, composing with the particle burst); the rest hold position.
render_clear :: (self: *BoardView, ctx: *RenderContext, rd: *AnimRound, inset: f32, dim: f32, t: f32) {
pop := clear_pop_scale(t);
for 0..BOARD_CELLS: (i) {
g := rd.before[i];
if g == .empty { continue; }
col := i % BOARD_COLS;
row := i / BOARD_COLS;
if rd.matched.cells[i] {
gf := self.gem_frame_scaled(col, row, dim, pop);
self.draw_gem(ctx, gf, cast(s64) g);
} else {
gf := self.gem_frame(cast(f32) col, cast(f32) row, inset, dim);
self.draw_gem(ctx, gf, cast(s64) g);
}
}
}
// Transient match FX (P6.2): coloured glow bursts at the cleared cells,
// clipped to the grid so a burst's glow never bleeds over the HUD. Each
// burst grows then shrinks to nothing; the soft texture carries the fade.
render_fx_particles :: (self: *BoardView, ctx: *RenderContext) {
if self.fx == null or self.fxassets == null or !self.fxassets.loaded { return; }
if self.fx.particles.len == 0 { return; }
cs := self.layout.cell_size;
grid := Frame.make(
self.layout.origin.x, self.layout.origin.y,
cs * cast(f32) BOARD_COLS, cs * cast(f32) BOARD_ROWS
);
ctx.push_clip(grid);
for 0..self.fx.particles.len: (i) {
p := self.fx.particles.items[i];
lt := (p.age - p.delay) / p.life;
env := fx_pop_env(lt);
if env > 0.0 {
size := env * p.peak * cs;
cx := self.layout.origin.x + p.col * cs;
cy := self.layout.origin.y + p.row * cs;
gf := Frame.make(cx - size * 0.5, cy - size * 0.5, size, size);
ctx.add_image(gf, self.fxassets.tex[p.tint]);
}
}
ctx.pop_clip();
}
// Floating "+points" popups: rise and fade above the initial clear. Drawn
// unclipped (over everything) so the number stays legible as it lifts off
// the grid. The text path honours the colour's alpha, so these truly fade.
render_fx_popups :: (self: *BoardView, ctx: *RenderContext) {
if self.fx == null or self.fx.popups.len == 0 { return; }
cs := self.layout.cell_size;
for 0..self.fx.popups.len: (i) {
q := self.fx.popups.items[i];
lt := (q.age - q.delay) / q.life;
if lt >= 0.0 {
fade := fx_popup_fade(lt);
font := if q.combo then FX_POPUP_COMBO_FONT else FX_POPUP_FONT;
base := if q.combo then FX_POPUP_COMBO_COLOR else FX_POPUP_COLOR;
col := Color.{ r = base.r, g = base.g, b = base.b, a = cast(u8) (fade * 255.0) };
txt := format("+{}", q.points);
sz := measure_text(txt, font);
cx := self.layout.origin.x + q.col * cs;
cy := self.layout.origin.y + (q.row - lt * FX_POPUP_RISE) * cs;
ctx.add_text(
Frame.make(cx - sz.width * 0.5, cy - sz.height * 0.5, sz.width, sz.height),
txt, font, col
);
}
}
}
// Fall segment: every gem of the round's settled board slides from its source
// row (above the board for refills) down to its destination cell.
render_fall :: (self: *BoardView, ctx: *RenderContext, rd: *AnimRound, inset: f32, dim: f32, t: f32) {
te := ease_out_cubic(t);
for 0..BOARD_CELLS: (i) {
g := rd.after[i];
if g == .empty { continue; }
col := i % BOARD_COLS;
drow := i / BOARD_COLS;
src := rd.src[i];
cur_row := cast(f32) src + (cast(f32) drow - cast(f32) src) * te;
gf := self.gem_frame(cast(f32) col, cur_row, inset, dim);
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, and the per-gem landing
// bounce) so the board returns to a clean, resting in_progress state. Without
// the motion reset a restart fired right after a terminal cascade would carry
// that move's landing squash onto the freshly seeded board.
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(); }
self.motion.reset_landings();
}
}
// 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 {
size_that_fits :: (self: *BoardView, proposal: ProposedSize) -> Size {
Size.{ width = proposal.width ?? 0.0, height = proposal.height ?? 0.0 }
}
layout :: (self: *BoardView, bounds: Frame) {
self.compute_layout(bounds);
}
render :: (self: *BoardView, ctx: *RenderContext, frame: Frame) {
self.compute_layout(frame);
// 1. Background image fills the whole view, behind the grid.
if self.assets.bg_tex != 0 {
ctx.add_image(frame, self.assets.bg_tex);
}
// 2. One cell tile per board cell — the static grid, never animated.
gem_inset := self.layout.cell_size * (1.0 - GEM_FILL_FRAC) * 0.5;
gem_dim := self.layout.cell_size * GEM_FILL_FRAC;
if self.assets.cell_tex != 0 {
for 0..BOARD_ROWS: (row) {
for 0..BOARD_COLS: (col) {
ctx.add_image(self.layout.cell_frame(col, row), self.assets.cell_tex);
}
}
}
// 2b. Gems: while a move is animating, play its swap/clear/fall timeline;
// otherwise draw the settled model board. The timeline ends exactly on
// the model state, so the seam back to the static path is invisible.
if self.assets.gems_tex != 0 {
if self.anim != null and self.anim.active {
self.render_anim(ctx, gem_inset, gem_dim);
} else {
self.render_gems(ctx, gem_dim);
}
}
// 3. Selection overlay on the chosen cell: a translucent fill under a
// bright rim, drawn over the whole grid so it reads as a highlight.
if self.sel != null and self.sel.active {
cf := self.layout.cell_frame(self.sel.cell.col, self.sel.cell.row);
ctx.add_rect(cf, SELECT_FILL);
rim_w := max(2.0, self.layout.cell_size * 0.06);
ctx.add_stroked_rect(cf, SELECT_RIM, SELECT_RIM, rim_w, self.layout.cell_size * 0.14);
}
// 4. HUD card with score + remaining moves, in the band above the grid.
avail := frame.inset(self.safe);
render_hud(ctx, self.board, avail);
// 5. Transient match FX over the board: coloured bursts at the cleared
// cells, then the floating "+points" popup on top. Purely visual and
// 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
// gesture against the SAME layout it was drawn with. A swipe (start→end maps
// to an adjacent-swap intent) is fed straight into `commit_swap`: a legal
// swap applies, cascades, scores and spends a move, an illegal one reverts —
// either way the next frame re-renders the board + HUD from the model. A
// sub-threshold / off-board drag carries no intent and falls back to the tap
// 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
// flight the board ignores new gestures for the WHOLE in-flight
// window, so a press begun mid-animation never latches a drag and
// so can't commit when the animation later ends. The press is
// still consumed; input resumes once the timeline settles.
if !accepts_input(self.anim) { return true; }
self.drag.begin(d.position);
return true;
}
case .mouse_up: (d) {
if !self.drag.active { return false; }
start := self.drag.start;
self.drag.clear();
if intent := swipe_intent(@self.layout, start, d.position) {
mv := plan_and_commit(self.board, intent.a, intent.b);
if self.anim != null { self.anim.begin(mv); }
if self.fx != null { self.fx.begin(@mv); }
self.sel.clear();
} else {
if hit := self.layout.point_to_cell(start) {
self.sel.toggle(hit);
// Re-arm the selection-pop reaction from this tap's moment.
if self.sel.active { self.sel.since = self.motion.clock; }
} else {
self.sel.clear();
}
}
return true;
}
}
false
}
}
// 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, board.target_score);
moves_str := format("MOVES {}/{}", board.moves_remaining(), board.move_limit);
score_sz := measure_text(score_str, HUD_FONT);
moves_sz := measure_text(moves_str, HUD_FONT);
text_w := max(score_sz.width, moves_sz.width);
panel_w := text_w + HUD_PAD * 2.0;
panel_h := score_sz.height + HUD_LINE_GAP + moves_sz.height + HUD_PAD * 2.0;
panel_x := avail.origin.x + (avail.size.width - panel_w) * 0.5;
panel_y := avail.origin.y + HUD_PAD;
ctx.add_rounded_rect(Frame.make(panel_x, panel_y, panel_w, panel_h), HUD_PANEL, 12.0);
tx := panel_x + HUD_PAD;
ty := panel_y + HUD_PAD;
ctx.add_text(Frame.make(tx, ty, score_sz.width, score_sz.height), score_str, HUD_FONT, HUD_TEXT);
ty += score_sz.height + HUD_LINE_GAP;
ctx.add_text(Frame.make(tx, ty, moves_sz.width, moves_sz.height), moves_str, HUD_FONT, HUD_TEXT);
}