Files
m3te/board_view.sx
swipelab 31d1012806 shed local vendors: stb + kb_text_shape + file_utils now ship with sx
The local vendors/ copies existed because the old modules/ffi/stb*.sx
resolved C paths CWD-relative, forcing every consumer to carry
identically-named copies. sx now ships these as proper library vendors
(#import "vendors/<name>/<name>.sx"), so the copies and the retired
ffi module imports both go. Verified: sx build --target ios-sim
bundles M3te.app; tools/run_tests.sh 23/23.
2026-06-12 18:35:12 +03:00

895 lines
43 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/ffi/opengl.sx";
#import "vendors/stb_image/stb_image.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";
#import "audio.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;
// Content margin layered on top of the platform safe-area insets: frames the
// grid off the left/right screen bezel so the gems aren't flush to the edge.
// The grid is width-constrained on a portrait phone, so this is the inset that
// actually sizes it; vertical centering inside the safe area is unchanged.
BOARD_INSET_X :f32: 16.0;
// Selection overlay (P11.3): a soft candy "glow" halo, a warm wash over the cell,
// a bright rim topped by a glossy inner highlight, and a wet sheen on the chosen
// gem. `add_stroked_rect` paints the border band in its FILL colour (the shader
// ignores the separate stroke colour), so each ring colour is passed as the fill.
// The engine can't tint/fade a texture at draw time (issue 0002), so every layer
// here is a rect/overlay — never a gem-texture tint.
SELECT_GLOW_OUT :: Color.{ r = 255, g = 232, b = 140, a = 30 }; // wide faint outer bloom
SELECT_GLOW_IN :: Color.{ r = 255, g = 238, b = 150, a = 70 }; // brighter near-edge halo
SELECT_FILL :: Color.{ r = 255, g = 244, b = 150, a = 80 }; // warm wash over the gem
SELECT_RIM :: Color.{ r = 255, g = 234, b = 92, a = 255 }; // bright candy rim
SELECT_RIM_HI :: Color.{ r = 255, g = 255, b = 232, a = 220 }; // glossy inner highlight ring
SELECT_GLOSS :: Color.{ r = 255, g = 255, b = 255, a = 96 }; // wet sheen on the selected gem
// HUD (P12.2): a glossy candy 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). The fill is a bright grape candy, lifted by a translucent top sheen
// and a bright rounded rim; cream text rides a soft purple shadow for punch.
HUD_FONT :f32: 34.0;
HUD_PAD :f32: 14.0;
HUD_LINE_GAP :f32: 6.0;
HUD_RADIUS :f32: 20.0;
HUD_TEXT :: Color.{ r = 255, g = 252, b = 245, a = 255 }; // warm cream text
HUD_TEXT_SH :: Color.{ r = 56, g = 18, b = 80, a = 150 }; // soft purple text shadow
HUD_PANEL :: Color.{ r = 92, g = 46, b = 150, a = 224 }; // bright grape candy fill
HUD_PANEL_HI :: Color.{ r = 196, g = 138, b = 240, a = 92 }; // glossy top sheen
HUD_PANEL_RIM:: Color.{ r = 236, g = 204, b = 255, a = 150 }; // bright candy rim
// FPS dev overlay (P20.1): a small corner readout, OFF unless M3TE_FPS pins it on
// (so default play + every golden are unchanged). Pinned to the top-left of the
// safe area — clear of the centered notch / Dynamic Island and the centered HUD.
// Dark grape text over a bright halo keeps it legible on the light lavender art.
FPS_FONT :f32: 22.0;
FPS_PAD :f32: 8.0;
FPS_TEXT :: Color.{ r = 40, g = 16, b = 64, a = 235 }; // dark grape, readable on lavender
FPS_TEXT_SH:: Color.{ r = 255, g = 255, b = 255, a = 170 }; // bright halo for contrast
// Win/lose banner (P12.2): a warm dim over the board, a glossy candy panel, the
// win/lose headline, and a playful 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. Each
// candy surface is a fill + a top sheen + a bright rounded rim; titles and the
// button label ride a tinted drop shadow so they pop off the panel.
BANNER_DIM :: Color.{ r = 26, g = 10, b = 44, a = 184 }; // warm purple dim
BANNER_PANEL :: Color.{ r = 96, g = 50, b = 156, a = 244 }; // grape candy panel
BANNER_PANEL_HI :: Color.{ r = 198, g = 140, b = 242, a = 110 }; // glossy panel sheen
BANNER_PANEL_RIM :: Color.{ r = 240, g = 208, b = 255, a = 168 }; // bright panel rim
BANNER_WIN_TEXT :: Color.{ r = 255, g = 220, b = 96, a = 255 }; // celebratory candy gold
BANNER_WIN_SH :: Color.{ r = 120, g = 56, b = 8, a = 220 }; // warm amber shadow
BANNER_LOSE_TEXT :: Color.{ r = 255, g = 104, b = 104, a = 255 }; // punchy candy coral
BANNER_LOSE_SH :: Color.{ r = 92, g = 14, b = 32, a = 220 }; // deep berry shadow
BANNER_BTN :: Color.{ r = 255, g = 120, b = 178, a = 255 }; // bubblegum candy CTA
BANNER_BTN_HI :: Color.{ r = 255, g = 198, b = 222, a = 150 }; // glossy button sheen
BANNER_BTN_RIM :: Color.{ r = 255, g = 226, b = 240, a = 184 }; // bright button rim
BANNER_BTN_SHADE :: Color.{ r = 198, g = 52, b = 120, a = 210 }; // darker bevel lip (3D)
BANNER_BTN_TEXT :: Color.{ r = 255, g = 255, b = 255, a = 255 };
BANNER_BTN_TEXT_SH:: Color.{ r = 120, g = 20, b = 64, a = 200 }; // button label shadow
BANNER_PANEL_RADIUS :f32: 24.0;
BANNER_BTN_RADIUS :f32: 16.0;
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: i64) -> 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 : i32 = 0;
h : i32 = 0;
ch : i32 = 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: i64;
// FPS dev overlay (P20.1). `fps_on` gates the corner readout (off by default,
// set only by the M3TE_FPS env pin); `fps` is the smoothed reciprocal frame
// rate computed in the frame loop. Purely a render overlay.
fps_on: bool;
fps: f32;
// 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.content_insets());
}
// Platform safe-area insets widened by the content margin, so the grid (and
// the hit-test / banner geometry derived from it) is framed off the screen
// bezel. The HUD keeps using the bare safe insets, so it still hugs the top
// below the notch / Dynamic Island rather than shifting in with the board.
content_insets :: (self: *BoardView) -> EdgeInsets {
EdgeInsets.{
top = self.safe.top,
left = self.safe.left + BOARD_INSET_X,
bottom = self.safe.bottom,
right = self.safe.right + BOARD_INSET_X,
}
}
// Draw gem `gem_index`'s sprite-sheet column into `gf`.
draw_gem :: (self: *BoardView, ctx: *RenderContext, gf: Frame, gem_index: i64) {
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: i64, row: i64, 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: i64, row: i64, 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)
}
// Frame for a gem at a (possibly fractional) row in column `col`, squashed by
// `sq` about its cell centre: scale_x = 1+sq (wider), scale_y = 1-sq (shorter)
// — the wide-and-short landing impact. sq==0 reproduces gem_frame's centred
// placement EXACTLY, so a gem still mid-fall (or one that never moved) draws
// byte-identically to the plain fall; only a landed gem flattens.
gem_squash_frame :: (self: *BoardView, col: i64, frow: f32, dim: f32, sq: f32) -> Frame {
cs := self.layout.cell_size;
cx := self.layout.origin.x + (cast(f32) col + 0.5) * cs;
cy := self.layout.origin.y + (frow + 0.5) * cs;
w := dim * (1.0 + sq);
h := dim * (1.0 - sq);
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: i64, row: i64) -> 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
}
// Per-round landing squash for the gem resting at cell `i` at move-timeline
// time `elapsed`, considering rounds up to `kmax`. The gem landed in its
// `delivering_round`; the bounce ages from that round's landing instant through
// the shared `land_squash` envelope. A gem still mid-fall reads a NEGATIVE age
// (land_squash → 0, so it draws unsquashed) and one that never moved reads 0.
// render_fall passes the current round; render_clear the previous (its board is
// that round's `after`), so the one bounce plays on across the fall→clear seam.
rest_squash :: (self: *BoardView, i: i64, kmax: i64, elapsed: f32) -> f32 {
m := delivering_round(@self.anim.move, i, kmax);
if m < 0 { return 0.0; }
col := i % BOARD_COLS;
land_squash(elapsed - round_land_time(m, col))
}
// 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(i64) g);
}
}
}
}
// Selection emphasis (P11.3): a glossier candy highlight on the chosen cell.
// Two concentric stroked rings fake a soft outward glow (the renderer has no
// blur), a warm wash tints the cell, a bright rim is doubled by a thin inner
// highlight for a glassy edge, and a wet sheen rides the selected gem's live
// pose. All rect/overlay layers (issue 0002 forbids a draw-time gem tint); the
// selection-pop motion still comes from gem_anim, so the t==0 idle pose is
// untouched.
render_selection :: (self: *BoardView, ctx: *RenderContext, dim: f32) {
cs := self.layout.cell_size;
cf := self.layout.cell_frame(self.sel.cell.col, self.sel.cell.row);
// Glow halo: rings just outside the cell edge, brighter nearer the rim, so
// the falloff reads as a soft bloom without tinting the gem interior.
ctx.add_stroked_rect(cf.expand(cs * 0.16), SELECT_GLOW_OUT, SELECT_GLOW_OUT, cs * 0.16, cs * 0.30);
ctx.add_stroked_rect(cf.expand(cs * 0.07), SELECT_GLOW_IN, SELECT_GLOW_IN, cs * 0.08, cs * 0.21);
// Warm wash + bright rim + a thin glossy highlight ring just inside the rim.
ctx.add_rounded_rect(cf, SELECT_FILL, cs * 0.14);
rim_w := max(2.0, cs * 0.06);
ctx.add_stroked_rect(cf, SELECT_RIM, SELECT_RIM, rim_w, cs * 0.14);
hi_w := max(1.0, cs * 0.022);
ctx.add_stroked_rect(cf.expand(0.0 - rim_w), SELECT_RIM_HI, SELECT_RIM_HI, hi_w, cs * 0.11);
// Wet sheen on the selected gem: a bright pill in its upper third, sized to
// the gem's live pose so it tracks the selection pop.
pose := self.gem_pose_at(self.sel.cell.col, self.sel.cell.row);
gf := self.gem_pose_frame(self.sel.cell.col, self.sel.cell.row, dim, pose);
gw := gf.size.width;
gh := gf.size.height;
gloss := Frame.make(gf.origin.x + gw * 0.22, gf.origin.y + gh * 0.13, gw * 0.40, gh * 0.22);
ctx.add_rounded_rect(gloss, SELECT_GLOSS, gh * 0.12);
}
// 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;
e := self.anim.elapsed;
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, ph.round, e, dim, ph.t);
} else if ph.kind == .fall {
rd := @mv.rounds.items[ph.round];
self.render_fall(ctx, rd, ph.round, e, dim, ph.t);
} else {
// Settled tail of the timeline — draw the final (model) board, still
// carrying the final round's landing bounce so this rare safety-net
// frame matches both the fall it follows and the render_gems hand-off
// (which resumes the same back-dated stamp). tick() normally clears
// `active` before this is reached.
last := mv.rounds.len - 1;
for 0..BOARD_CELLS (i) {
g := mv.final[i];
if g != .empty {
sq := self.rest_squash(i, last, e);
gf := self.gem_squash_frame(i % BOARD_COLS, cast(f32) (i / BOARD_COLS), dim, sq);
self.draw_gem(ctx, gf, cast(i64) 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 lunges toward the neighbour and springs back to rest, ending
// exactly 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(i64) g);
}
}
p : f32 = ---;
if mv.legal {
// Overshoot-and-settle: the two gems shoot a touch PAST their target
// cells, then settle exactly onto them, instead of decelerating flatly
// into place. ease_out_back pins f(0)=0 and f(1)=1, so t==0 is the rest
// pose and t==1 lands byte-on-cell — the swap stays purely visual.
p = ease_out_back(t);
} else {
// Rejected swap: a springy, slightly-damped bounce-back. The gems lunge
// toward each other then spring home, overshooting rest by a bounded
// amount before settling. bad_swap_bounce pins f(0)=0 and f(1)=0, so the
// move stays purely visual — the board is byte-identical to pre-swap.
p = bad_swap_bounce(t);
}
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(i64) 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(i64) 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, k: i64, e: f32, dim: f32, t: f32) {
span := clear_diag_span(@rd.matched);
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] {
// Ripple: each matched gem's pop START is offset by its diagonal
// rank within the round (clear_ripple_t), so the matched cells
// explode as a wave instead of simultaneously; every gem still
// reaches scale 0 by t==1, keeping the seam to the fall clean.
pop := clear_pop_scale(clear_ripple_t(t, clear_rank(span, col, row)));
gf := self.gem_frame_scaled(col, row, dim, pop);
self.draw_gem(ctx, gf, cast(i64) g);
} else {
// before[k] is round k-1's settled board, so a survivor here still
// carries the bounce from the round that dropped it in — continue it
// across the fall→clear seam (kmax = k-1). sq==0 for round 0's clear
// (nothing has fallen yet), keeping that frame byte-identical.
sq := self.rest_squash(i, k - 1, e);
gf := self.gem_squash_frame(col, cast(f32) row, dim, sq);
self.draw_gem(ctx, gf, cast(i64) 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.
// A combo (depth > 1) escalates with cascade depth: gold and larger, topped
// by a `COMBO xN` label naming the depth — the same depth the cascade SFX
// escalates on — so deeper cascades read as more exciting.
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 := fx_popup_font(q.depth);
base := fx_popup_color(q.depth);
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
);
if q.depth > 1 {
lfont := font * FX_COMBO_LABEL_RATIO;
ltxt := format("COMBO x{}", q.depth);
lsz := measure_text(ltxt, lfont);
lcy := cy - sz.height * 0.5 - cs * FX_COMBO_LABEL_GAP - lsz.height * 0.5;
ctx.add_text(
Frame.make(cx - lsz.width * 0.5, lcy - lsz.height * 0.5, lsz.width, lsz.height),
ltxt, lfont, col
);
}
}
}
}
// Fall segment: every gem of the round's settled board accelerates under
// gravity from its source row (above the board for refills) down to its
// destination cell. Each COLUMN's drop starts at a small staggered delay
// (fall_stagger_t) so a refilled row pours in as a cascade rather than a flat
// lockstep row; ease_in_cubic pins each column's f(1)=1, and fall_stagger_t
// guarantees every column reaches 1 by t==1, so each gem lands exactly on its
// cell and the seam to the next round / settled board stays invisible.
render_fall :: (self: *BoardView, ctx: *RenderContext, rd: *AnimRound, k: i64, e: f32, dim: f32, t: f32) {
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];
te := ease_in_cubic(fall_stagger_t(t, col));
cur_row := cast(f32) src + (cast(f32) drow - cast(f32) src) * te;
// Squash on landing: rest_squash ages the bounce from this column's
// touch-down (kmax = k). A gem still falling reads a negative age → 0, so
// in-flight gems stay byte-identical to the plain fall; only a gem that
// has reached its cell flattens wide-and-short, then wobbles out.
sq := self.rest_squash(i, k, e);
gf := self.gem_squash_frame(col, cur_row, dim, sq);
self.draw_gem(ctx, gf, cast(i64) 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();
// Candy panel: grape fill under a glossy top sheen and a bright rounded rim.
// Geometry is the shared bl.panel — only colour / rounding / gloss change.
ctx.add_rounded_rect(bl.panel, BANNER_PANEL, BANNER_PANEL_RADIUS);
ctx.add_rounded_rect(top_sheen(bl.panel, 0.42, BANNER_PANEL_RADIUS * 0.6), BANNER_PANEL_HI, BANNER_PANEL_RADIUS * 0.8);
prim := max(2.0, BANNER_PANEL_RADIUS * 0.12);
ctx.add_stroked_rect(bl.panel, BANNER_PANEL_RIM, BANNER_PANEL_RIM, prim, BANNER_PANEL_RADIUS);
title := if status == .won then "YOU WIN!" else "OUT OF MOVES";
tcol := if status == .won then BANNER_WIN_TEXT else BANNER_LOSE_TEXT;
tsh := if status == .won then BANNER_WIN_SH else BANNER_LOSE_SH;
tfont := fit_font(title, BANNER_TITLE_FONT, bl.title.size.width);
tsz := measure_text(title, tfont);
tfr := Frame.make(bl.title.mid_x() - tsz.width * 0.5, bl.title.mid_y() - tsz.height * 0.5, tsz.width, tsz.height);
ctx.add_text(Frame.make(tfr.origin.x + 2.0, tfr.origin.y + 3.0, tfr.size.width, tfr.size.height), title, tfont, tsh);
ctx.add_text(tfr, title, tfont, tcol);
// Candy button: a darker bevel lip peeks under the bubblegum fill for a 3D
// candy edge, lifted by a glossy sheen and a bright rim. The fill / hit rect
// is the shared bl.button, so the restart hit-test is byte-for-byte unchanged.
ctx.add_rounded_rect(Frame.make(bl.button.origin.x, bl.button.origin.y + 3.0, bl.button.size.width, bl.button.size.height), BANNER_BTN_SHADE, BANNER_BTN_RADIUS);
ctx.add_rounded_rect(bl.button, BANNER_BTN, BANNER_BTN_RADIUS);
ctx.add_rounded_rect(top_sheen(bl.button, 0.46, BANNER_BTN_RADIUS * 0.5), BANNER_BTN_HI, BANNER_BTN_RADIUS * 0.8);
brim := max(2.0, BANNER_BTN_RADIUS * 0.14);
ctx.add_stroked_rect(bl.button, BANNER_BTN_RIM, BANNER_BTN_RIM, brim, BANNER_BTN_RADIUS);
btxt := "PLAY AGAIN";
bfont := fit_font(btxt, BANNER_BTN_FONT, bl.button.size.width * 0.86);
bsz := measure_text(btxt, bfont);
bfr := Frame.make(bl.button.mid_x() - bsz.width * 0.5, bl.button.mid_y() - bsz.height * 0.5, bsz.width, bsz.height);
ctx.add_text(Frame.make(bfr.origin.x + 1.5, bfr.origin.y + 2.0, bfr.size.width, bfr.size.height), btxt, bfont, BANNER_BTN_TEXT_SH);
ctx.add_text(bfr, btxt, bfont, BANNER_BTN_TEXT);
}
// FPS dev overlay (P20.1): a small "FPS n" readout pinned to the top-left of
// the safe area, on top of everything. Drawn only when fps_on (the M3TE_FPS
// pin) is set, so the unset render path is byte-identical. A bright halo under
// the dark text keeps the digits legible over the light background art.
render_fps_overlay :: (self: *BoardView, ctx: *RenderContext, frame: Frame) {
n := cast(i64) (self.fps + 0.5);
txt := format("FPS {}", n);
sz := measure_text(txt, FPS_FONT);
x := frame.origin.x + self.safe.left + FPS_PAD;
y := frame.origin.y + self.safe.top + FPS_PAD;
f := Frame.make(x, y, sz.width, sz.height);
ctx.add_text(Frame.make(f.origin.x + 1.0, f.origin.y + 1.5, f.size.width, f.size.height), txt, FPS_FONT, FPS_TEXT_SH);
ctx.add_text(f, txt, FPS_FONT, FPS_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
}
// A rounded rect covering the top `frac` of `f`, inset by `pad` on the sides and
// top — the glossy candy sheen sat over a panel/button fill. The renderer has no
// gradient, so a single brighter translucent cap fakes the gloss.
top_sheen :: (f: Frame, frac: f32, pad: f32) -> Frame {
Frame.make(f.origin.x + pad, f.origin.y + pad, f.size.width - pad * 2.0, f.size.height * frac)
}
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 emphasis on the chosen cell: a soft candy glow halo under a
// warm wash, a bright glossy rim, and a wet sheen on the popped gem.
if self.sel != null and self.sel.active {
self.render_selection(ctx, gem_dim);
}
// 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));
}
// 7. FPS dev overlay (P20.1), on top of everything. Off by default; only
// renders when M3TE_FPS pinned it on, so the unset path is unchanged.
if self.fps_on {
self.render_fps_overlay(ctx, frame);
}
}
// 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); }
// SFX: additive cues for the committed gesture — never reads
// or writes board/score/move state. The swap slide cue plays
// for any committed gesture (legal or the reverted ping-back);
// a legal move adds the match pop on its first clearing round.
// A multi-round chain's ascending combo cues are NOT fired here:
// the frame loop plays one per round, edge-triggered as each
// round visually clears (combo1, combo2, …), so the cascade
// reads as an audible ascending run instead of one cue at commit.
sfx_swap();
if mv.legal {
sfx_match();
}
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;
panel := Frame.make(panel_x, panel_y, panel_w, panel_h);
// Candy card: grape fill, a glossy top sheen, then a bright rounded rim.
ctx.add_rounded_rect(panel, HUD_PANEL, HUD_RADIUS);
ctx.add_rounded_rect(top_sheen(panel, 0.46, HUD_RADIUS * 0.5), HUD_PANEL_HI, HUD_RADIUS * 0.8);
rim := max(2.0, HUD_RADIUS * 0.12);
ctx.add_stroked_rect(panel, HUD_PANEL_RIM, HUD_PANEL_RIM, rim, HUD_RADIUS);
tx := panel_x + HUD_PAD;
ty := panel_y + HUD_PAD;
hud_line(ctx, Frame.make(tx, ty, score_sz.width, score_sz.height), score_str);
ty += score_sz.height + HUD_LINE_GAP;
hud_line(ctx, Frame.make(tx, ty, moves_sz.width, moves_sz.height), moves_str);
}
// One HUD text row: a soft purple shadow under the warm cream text, so the line
// stays legible over the grape card. Geometry is the caller's row frame.
hud_line :: (ctx: *RenderContext, f: Frame, text: string) {
ctx.add_text(Frame.make(f.origin.x + 1.5, f.origin.y + 2.0, f.size.width, f.size.height), text, HUD_FONT, HUD_TEXT_SH);
ctx.add_text(f, text, HUD_FONT, HUD_TEXT);
}