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.
895 lines
43 KiB
Plaintext
895 lines
43 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/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);
|
||
}
|