Polish pass before final acceptance. The 8x8 grid was rendering flush to the left/right screen edges (gems ~4pt from the bezel on iPhone 17). Add a content margin (BOARD_INSET_X = 16pt) layered on top of the platform safe-area insets so the grid is framed by the background, while the HUD keeps using the bare safe insets so it still hugs the top below the Dynamic Island. The grid is width-constrained in portrait, so this inset is what sizes it; vertical centering inside the safe area is unchanged, and the win/lose banner (derived from the grid) stays centered over the framed board. Safe-area verified on a current iPhone simulator (iPhone 17, iOS 26): HUD below the Dynamic Island, board far above the home indicator, forced win/lose banners centered and unclipped. The headless geometry tests (hit_test, banner_layout) call compute() with a zero inset directly, so they are unaffected; full logic gate stays green (18/18). Goldens: add p9_polish.png (resting board, M3TE_ANIM_TIME=0) as the canonical polished layout. Re-capture the README-documented deterministic goldens whose board position shifts by the 16pt margin (p4_board, p4_hud, p6_idle_t0, p6_idle_mid, p6_select, p7_win, p7_lose, p7_restart). The in-flight move-timeline goldens (p5_swap_*, p6_anim_*, p6_fx_*, p6_inputlock_board) and the p0 quad goldens are not reproducible via the documented env pins (which pin only the idle clock + level state), so they are left as-is.
691 lines
29 KiB
Plaintext
691 lines
29 KiB
Plaintext
// 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: 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.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);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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); }
|
||
// SFX (P8.1). Additive only — plays a short cue when a swap
|
||
// actually clears a match; reads no score/board state and
|
||
// writes none. A legal move has >=1 cascade round.
|
||
if mv.legal and mv.rounds.len > 0 { sfx_clear(); }
|
||
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);
|
||
}
|