Files
m3te/board_view.sx
swipelab 8402f49503 P16.1: organic legal swap — overshoot/settle (ease_out_back)
Replace the legal swap's flat ease_out_cubic decelerating slide with the
P15.1 ease_out_back overshoot curve: the two swapped gems shoot ~10% PAST
their target cells, then settle exactly onto them. Purely visual — the curve
pins f(0)=0 / f(1)=1, so t==0 is the rest pose and t==1 lands byte-on-cell;
the committed move and final board are unchanged. SWAP_ANIM_DUR (0.16 s) is
untouched, so the cascade-cue timing snapshots do not churn.

Only the legal branch of render_swap changes; the illegal ping-back is left
as-is for P16.2. Model/logic untouched (FFI is the only non-sx surface).

Golden goldens/p16_swap.png: M3TE_FX=3 (top-row swap (5,0)<->(6,0)) pinned at
M3TE_ANIM_TIME=0.10 (swap-phase t~0.625, near the overshoot peak). Measured
against calibrated cell centers: the red lands ~8% left of col-5 and the green
~12% right of col-6 (both PAST target), while every unswapped gem stays
centered; at M3TE_ANIM_TIME=0 the same gems sit dead-on their pre-swap cells.

Gate: `sx build --target ios-sim main.sx` links clean; `tools/run_tests.sh`
22/22 (anim_plan, easing, cascade_rounds, cascade_cue all green).
2026-06-06 10:32:42 +03:00

804 lines
37 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";
#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
// 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: 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.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: 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);
}
}
}
}
// 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;
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 {
// 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 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.
// 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 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();
// 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);
}
// 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));
}
}
// 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);
}