Add a purely-visual animation timeline so the board no longer snaps on a move. board_anim.sx records, on a value-copy of the pre-move board, the swap and each cascade round's matched cells + per-column fall provenance, then BoardView plays it over delta_time: the two swapped gems SLIDE between cells (and ping out-and-back on an illegal swap), matched gems SCALE OUT, and survivors FALL into place while refills drop in from above the grid. The model stays authoritative: plan_and_commit still calls commit_swap on the real board exactly as before, and the recording replays the identical primitives from the identical cells + RNG state, so the timeline ends ON the model's settled board. tests/anim_plan.sx is the determinism guard — it asserts the committed board, score, moves, and the timeline's final state all equal an independent commit_swap of the same move, that the rounds are contiguous, and that an illegal swap records nothing and leaves the board untouched. All pre-existing logic/cascade goldens stay green. Evidence (sx-test-metal, iOS 26.0, time-sampled with temporarily-lengthened durations; committed durations are the short production values): goldens/p6_anim_swap.png gems sliding between (5,4)/(6,4) goldens/p6_anim_clear.png matched reds scaling out in row 4 goldens/p6_anim_fall.png gems mid-fall with gaps + refill dropping in goldens/p6_anim_after.png settled board == model (SCORE 30, MOVES 29/30)
456 lines
18 KiB
Plaintext
456 lines
18 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 "swipe.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;
|
||
|
||
// 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 };
|
||
|
||
// 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;
|
||
|
||
init :: (self: *BoardSelection) {
|
||
self.active = false;
|
||
self.cell = Cell.{ col = 0, row = 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;
|
||
safe: EdgeInsets;
|
||
|
||
// 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.safe);
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// Settled-board gems: one sprite per non-empty cell at its cell frame. Used
|
||
// whenever no move is animating.
|
||
render_gems :: (self: *BoardView, ctx: *RenderContext, inset: f32, dim: f32) {
|
||
for 0..BOARD_ROWS: (row) {
|
||
for 0..BOARD_COLS: (col) {
|
||
g := self.board.at(col, row);
|
||
if g != .empty {
|
||
gf := self.gem_frame(cast(f32) col, cast(f32) row, inset, dim);
|
||
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 shrink toward nothing; the rest hold position.
|
||
render_clear :: (self: *BoardView, ctx: *RenderContext, rd: *AnimRound, inset: f32, dim: f32, t: f32) {
|
||
shrink := 1.0 - ease_in_quad(t);
|
||
if shrink < 0.0 { shrink = 0.0; }
|
||
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, shrink);
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
}
|
||
}
|
||
|
||
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_inset, 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);
|
||
}
|
||
|
||
// 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);
|
||
if event.* == {
|
||
case .mouse_down: (d) {
|
||
self.drag.begin(d.position);
|
||
return true;
|
||
}
|
||
case .mouse_up: (d) {
|
||
if !self.drag.active { return false; }
|
||
start := self.drag.start;
|
||
self.drag.clear();
|
||
// Ignore swipes while a move is still animating so two timelines
|
||
// never overlap; the model is already settled by then either way.
|
||
if self.anim != null and self.anim.active { return true; }
|
||
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); }
|
||
self.sel.clear();
|
||
} else {
|
||
if hit := self.layout.point_to_cell(start) {
|
||
self.sel.toggle(hit);
|
||
} else {
|
||
self.sel.clear();
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
}
|
||
false
|
||
}
|
||
}
|
||
|
||
// Draw the HUD card — current score and 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, so it tracks score/moves as the
|
||
// game progresses. 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);
|
||
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);
|
||
}
|