P6.1: swap/clear/fall move tweens (sx, iOS sim)

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)
This commit is contained in:
swipelab
2026-06-05 01:06:02 +03:00
parent 1603b8b4bf
commit 0b858f7724
10 changed files with 513 additions and 20 deletions

View File

@@ -17,6 +17,7 @@
#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
@@ -169,6 +170,7 @@ BoardView :: struct {
assets: *BoardAssets;
sel: *BoardSelection;
drag: *DragInput;
anim: *BoardAnim;
safe: EdgeInsets;
// Where the grid sits + the touch↔cell mapping. Recomputed each render /
@@ -178,6 +180,160 @@ BoardView :: struct {
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 {
@@ -197,28 +353,25 @@ impl View for BoardView {
ctx.add_image(frame, self.assets.bg_tex);
}
// 2. One cell tile per board cell, then its gem sampled by index column.
// 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;
for 0..BOARD_ROWS: (row) {
for 0..BOARD_COLS: (col) {
cf := self.layout.cell_frame(col, row);
if self.assets.cell_tex != 0 {
ctx.add_image(cf, self.assets.cell_tex);
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);
}
}
}
g := self.board.at(col, row);
if g != .empty and self.assets.gems_tex != 0 {
uv := self.assets.gem_uv(cast(s64) g);
gf := Frame.make(
cf.origin.x + gem_inset,
cf.origin.y + gem_inset,
gem_dim,
gem_dim
);
ctx.add_image_uv(gf, self.assets.gems_tex, uv.uv_min, uv.uv_max);
}
// 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);
}
}
@@ -254,8 +407,12 @@ impl View for BoardView {
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) {
commit_swap(self.board, intent.a, intent.b);
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) {