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:
186
board_anim.sx
Normal file
186
board_anim.sx
Normal file
@@ -0,0 +1,186 @@
|
||||
// Board motion animation (P6.1) — a PURELY VISUAL timeline the view plays over
|
||||
// one player move. The logical model (commit_swap / resolve) stays authoritative:
|
||||
// `plan_and_commit` commits the move on the real board exactly as before, then
|
||||
// replays the SAME operations on a value-copy of the pre-move board to record the
|
||||
// per-step geometry (the swap, each cascade round's matched cells, and each
|
||||
// round's per-column fall provenance). Because the copy starts from the identical
|
||||
// cells AND RNG state and runs the identical primitives, its recorded `final`
|
||||
// board equals the model's settled board gem-for-gem — the animation only ever
|
||||
// ends ON the already-decided result, never changes it.
|
||||
//
|
||||
// Per-gem idle/select/clear gem animations (P6.3) and score popups / particle FX
|
||||
// (P6.2) are NOT here; this step animates board MOTION only: swap slide, matched
|
||||
// scale-out, and collapse/refill fall.
|
||||
#import "modules/std.sx";
|
||||
#import "modules/math";
|
||||
#import "modules/ui/types.sx";
|
||||
#import "board.sx";
|
||||
#import "board_layout.sx";
|
||||
|
||||
// Short, frame-timed durations (seconds) for each timeline segment. Driven by
|
||||
// the frame loop's delta_time, so they are wall-clock, framerate-independent.
|
||||
SWAP_ANIM_DUR :f32: 0.16;
|
||||
CLEAR_ANIM_DUR :f32: 0.14;
|
||||
FALL_ANIM_DUR :f32: 0.22;
|
||||
|
||||
// Easing helpers. Slide/fall decelerate into place (ease-out cubic); the clear
|
||||
// scale-out accelerates as it shrinks (ease-in quad).
|
||||
ease_out_cubic :: (t: f32) -> f32 { u := t - 1.0; u * u * u + 1.0 }
|
||||
ease_in_quad :: (t: f32) -> f32 { t * t }
|
||||
|
||||
// One recorded cascade round. `before` is the board at the round's start (the
|
||||
// swapped board for round 0, the previous round's `after` otherwise — never has
|
||||
// holes). `matched` flags the cells cleared this round (they scale out). `src`
|
||||
// maps each destination cell to the SOURCE ROW its gem falls from within the same
|
||||
// column: a non-negative row for a surviving gem that slides down, or a NEGATIVE
|
||||
// row (above the board) for a freshly-refilled gem dropping in from the top.
|
||||
// `after` is the board once this round has cleared, collapsed, and refilled.
|
||||
AnimRound :: struct {
|
||||
before: [BOARD_CELLS]Gem;
|
||||
matched: MatchMask;
|
||||
src: [BOARD_CELLS]s64;
|
||||
after: [BOARD_CELLS]Gem;
|
||||
}
|
||||
|
||||
// The full recorded timeline of one move. `legal` mirrors the model's decision:
|
||||
// a legal swap has >=1 round and `final` is the settled board; an illegal swap
|
||||
// has zero rounds, `pre == final`, and the view plays a slide-and-return. `a`/`b`
|
||||
// are the swapped cells; `pre` is the board before the swap (the slide's start).
|
||||
AnimMove :: struct {
|
||||
legal: bool;
|
||||
a: Cell;
|
||||
b: Cell;
|
||||
pre: [BOARD_CELLS]Gem;
|
||||
rounds: List(AnimRound);
|
||||
final: [BOARD_CELLS]Gem;
|
||||
}
|
||||
|
||||
// Commit the player's swap authoritatively AND record its visual timeline. The
|
||||
// real board is mutated by `commit_swap` exactly as the non-animated path did;
|
||||
// the recording runs on a separate value-copy taken BEFORE the commit, so it
|
||||
// replays the identical cells + RNG stream and its `final` equals `board.cells`.
|
||||
plan_and_commit :: (board: *Board, a: Cell, b: Cell) -> AnimMove {
|
||||
move : AnimMove = ---;
|
||||
move.a = a;
|
||||
move.b = b;
|
||||
move.rounds = List(AnimRound).{};
|
||||
move.pre = board.cells;
|
||||
|
||||
// Snapshot the entire model state (cells + RNG + score + moves) before the
|
||||
// commit so the replay below is bit-identical to what commit_swap does.
|
||||
scratch : Board = board.*;
|
||||
|
||||
mv := commit_swap(board, a, b);
|
||||
move.legal = mv.legal;
|
||||
if !mv.legal {
|
||||
move.final = board.cells;
|
||||
return move;
|
||||
}
|
||||
|
||||
swap(@scratch, a, b);
|
||||
while true {
|
||||
m := find_matches(@scratch);
|
||||
if m.count() == 0 { break; }
|
||||
|
||||
round : AnimRound = ---;
|
||||
round.before = scratch.cells;
|
||||
round.matched = m;
|
||||
|
||||
clear_cells(@scratch, @m);
|
||||
|
||||
// Fall provenance, read off the just-cleared (holed) board — mirrors
|
||||
// `collapse`'s packing exactly: scanning a column bottom-to-top, each
|
||||
// surviving gem lands at the descending write cursor `w`, so dest row `w`
|
||||
// came from source row `r`. The rows left above the survivors (0..w) are
|
||||
// refilled, so they drop in from above: a dest row `j` there starts at
|
||||
// `j - n_refill`, i.e. stacked just off the top edge.
|
||||
for 0..BOARD_COLS: (col) {
|
||||
w := BOARD_ROWS - 1;
|
||||
r := BOARD_ROWS - 1;
|
||||
while r >= 0 {
|
||||
if scratch.at(col, r) != .empty {
|
||||
round.src[Board.idx(col, w)] = r;
|
||||
w -= 1;
|
||||
}
|
||||
r -= 1;
|
||||
}
|
||||
n_refill := w + 1;
|
||||
j := 0;
|
||||
while j <= w {
|
||||
round.src[Board.idx(col, j)] = j - n_refill;
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
|
||||
collapse(@scratch);
|
||||
refill(@scratch);
|
||||
round.after = scratch.cells;
|
||||
move.rounds.append(round);
|
||||
}
|
||||
|
||||
move.final = scratch.cells;
|
||||
move
|
||||
}
|
||||
|
||||
// Which segment of the timeline is playing, and the local 0..1 progress within
|
||||
// it. `round` indexes `AnimMove.rounds` for clear/fall.
|
||||
AnimPhaseKind :: enum { swap; clear; fall; done; }
|
||||
|
||||
AnimPhase :: struct {
|
||||
kind: AnimPhaseKind;
|
||||
round: s64;
|
||||
t: f32;
|
||||
}
|
||||
|
||||
// Live timeline state for the in-flight move. Heap-allocated (like BoardSelection
|
||||
// / DragInput) so it survives BoardView's per-frame rebuild; `tick` advances it
|
||||
// by the frame's delta_time and the view reads `phase` to render the right slice.
|
||||
BoardAnim :: struct {
|
||||
active: bool;
|
||||
elapsed: f32;
|
||||
move: AnimMove;
|
||||
|
||||
init :: (self: *BoardAnim) {
|
||||
self.active = false;
|
||||
self.elapsed = 0.0;
|
||||
self.move.legal = false;
|
||||
self.move.rounds = List(AnimRound).{};
|
||||
}
|
||||
|
||||
begin :: (self: *BoardAnim, m: AnimMove) {
|
||||
self.move = m;
|
||||
self.elapsed = 0.0;
|
||||
self.active = true;
|
||||
}
|
||||
|
||||
// Total wall-clock length: the swap segment plus a clear+fall pair per round.
|
||||
total :: (self: *BoardAnim) -> f32 {
|
||||
SWAP_ANIM_DUR + cast(f32) self.move.rounds.len * (CLEAR_ANIM_DUR + FALL_ANIM_DUR)
|
||||
}
|
||||
|
||||
tick :: (self: *BoardAnim, dt: f32) {
|
||||
if !self.active { return; }
|
||||
self.elapsed += dt;
|
||||
if self.elapsed >= self.total() { self.active = false; }
|
||||
}
|
||||
|
||||
// Resolve `elapsed` to the active segment by walking swap → (clear, fall)*.
|
||||
phase :: (self: *BoardAnim) -> AnimPhase {
|
||||
e := self.elapsed;
|
||||
if e < SWAP_ANIM_DUR {
|
||||
return AnimPhase.{ kind = .swap, round = 0, t = e / SWAP_ANIM_DUR };
|
||||
}
|
||||
e -= SWAP_ANIM_DUR;
|
||||
for 0..self.move.rounds.len: (k) {
|
||||
if e < CLEAR_ANIM_DUR {
|
||||
return AnimPhase.{ kind = .clear, round = k, t = e / CLEAR_ANIM_DUR };
|
||||
}
|
||||
e -= CLEAR_ANIM_DUR;
|
||||
if e < FALL_ANIM_DUR {
|
||||
return AnimPhase.{ kind = .fall, round = k, t = e / FALL_ANIM_DUR };
|
||||
}
|
||||
e -= FALL_ANIM_DUR;
|
||||
}
|
||||
AnimPhase.{ kind = .done, round = 0, t = 1.0 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user