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:
195
board_view.sx
195
board_view.sx
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user