// 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); }