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 }
|
||||
}
|
||||
}
|
||||
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) {
|
||||
|
||||
BIN
goldens/p6_anim_after.png
Normal file
BIN
goldens/p6_anim_after.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 MiB |
BIN
goldens/p6_anim_clear.png
Normal file
BIN
goldens/p6_anim_clear.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 MiB |
BIN
goldens/p6_anim_fall.png
Normal file
BIN
goldens/p6_anim_fall.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.9 MiB |
BIN
goldens/p6_anim_swap.png
Normal file
BIN
goldens/p6_anim_swap.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 MiB |
17
main.sx
17
main.sx
@@ -15,6 +15,7 @@
|
||||
#import "modules/platform/uikit.sx";
|
||||
#import "board.sx";
|
||||
#import "board_view.sx";
|
||||
#import "board_anim.sx";
|
||||
|
||||
#run configure_build();
|
||||
|
||||
@@ -24,6 +25,7 @@ BOARD_SEED :: 1337;
|
||||
|
||||
g_plat : Platform = ---;
|
||||
g_pipeline : *UIPipeline = ---;
|
||||
g_delta_time : f32 = 0.016;
|
||||
g_viewport_w : f32 = 800.0;
|
||||
g_viewport_h : f32 = 600.0;
|
||||
g_safe_insets : EdgeInsets = .{};
|
||||
@@ -49,14 +51,20 @@ g_sel : *BoardSelection = null;
|
||||
// so the drag start must persist between them.
|
||||
g_drag : *DragInput = null;
|
||||
|
||||
// In-flight move animation (P6.1). Heap-allocated for the same reason: a swipe
|
||||
// begins the swap/clear/fall timeline, which then plays out over many subsequent
|
||||
// frames, so the timeline state must persist across BoardView's per-frame rebuild.
|
||||
g_anim : *BoardAnim = null;
|
||||
|
||||
// Rebuilt each frame inside the pipeline's arena; carries the current safe-area
|
||||
// insets so the grid stays inside the notch / home-indicator region.
|
||||
build_ui :: () -> View {
|
||||
BoardView.{ board = g_board, assets = g_assets, sel = g_sel, drag = g_drag, safe = g_safe_insets }
|
||||
BoardView.{ board = g_board, assets = g_assets, sel = g_sel, drag = g_drag, anim = g_anim, safe = g_safe_insets }
|
||||
}
|
||||
|
||||
frame :: () {
|
||||
fc := g_plat.begin_frame();
|
||||
g_delta_time = fc.delta_time;
|
||||
g_viewport_w = fc.viewport_w;
|
||||
g_viewport_h = fc.viewport_h;
|
||||
g_safe_insets = g_plat.safe_insets();
|
||||
@@ -76,6 +84,10 @@ frame :: () {
|
||||
g_pipeline.dispatch_event(ev);
|
||||
}
|
||||
|
||||
// Advance the in-flight move animation by this frame's delta before rendering,
|
||||
// so the board view draws the timeline slice for the current wall-clock time.
|
||||
if g_anim != null { g_anim.tick(g_delta_time); }
|
||||
|
||||
inline if OS == .ios {
|
||||
// Lazy-attach Metal once -[SxAppDelegate didFinishLaunching:] has
|
||||
// installed the SxMetalView and its bounds have been measured; both can
|
||||
@@ -152,6 +164,9 @@ main :: () -> void {
|
||||
g_drag = xx context.allocator.alloc(size_of(DragInput));
|
||||
g_drag.init();
|
||||
|
||||
g_anim = xx context.allocator.alloc(size_of(BoardAnim));
|
||||
g_anim.init();
|
||||
|
||||
g_pipeline.set_body(closure(build_ui));
|
||||
|
||||
g_plat.run_frame_loop(closure(frame));
|
||||
|
||||
117
tests/anim_plan.sx
Normal file
117
tests/anim_plan.sx
Normal file
@@ -0,0 +1,117 @@
|
||||
// Animation-layer determinism guard (P6.1): prove the swap/clear/fall animation
|
||||
// timeline is PURELY VISUAL — it never changes the model's result. `plan_and_commit`
|
||||
// commits the move on the real board (authoritative) AND records the visual
|
||||
// timeline on a value-copy; this test asserts, on the SAME seed the app renders
|
||||
// (SEED 1337):
|
||||
// - the board `plan_and_commit` leaves is byte-for-byte identical to an
|
||||
// independent `commit_swap` of the same move, with the same score + moves;
|
||||
// - the recorded timeline ENDS on that exact state: `move.final` equals the
|
||||
// model board, the rounds are contiguous (round 0 starts on the swapped board,
|
||||
// each later round starts on the prior round's settled board), and the last
|
||||
// round's `after` equals `final`;
|
||||
// - an illegal swap records no rounds and leaves the board untouched.
|
||||
// No rendering — it calls exactly what BoardView.handle_event calls. Links headless
|
||||
// like tests/swipe_commit.sx; avoids tests/test.sx (its trace.sx pulls in a second
|
||||
// `Frame` that collides with the UI one). Failure is a non-zero exit code.
|
||||
#import "modules/std.sx";
|
||||
#import "board.sx";
|
||||
#import "board_anim.sx";
|
||||
|
||||
SEED :: 1337;
|
||||
|
||||
boards_equal :: (x: *Board, y: *Board) -> bool {
|
||||
for 0..BOARD_CELLS: (i) {
|
||||
if !(x.cells[i] == y.cells[i]) { return false; }
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
main :: () -> s32 {
|
||||
fails : s64 = 0;
|
||||
|
||||
// ── Legal swap: plan == model, timeline ends on the model ───────────────
|
||||
// (5,4)->(6,4): brings R into (5,4), completing R,R,R across cols 3-5 of row
|
||||
// 4 — the same legal swap tests/swipe_commit.sx commits.
|
||||
print("== legal swap: plan matches model ==\n");
|
||||
a := Cell.{ col = 5, row = 4 };
|
||||
b := Cell.{ col = 6, row = 4 };
|
||||
|
||||
bm : Board = ---;
|
||||
bm.init(SEED);
|
||||
mvm := commit_swap(@bm, a, b);
|
||||
|
||||
ba : Board = ---;
|
||||
ba.init(SEED);
|
||||
move := plan_and_commit(@ba, a, b);
|
||||
|
||||
print("model: legal {} depth {} score {} moves {}\n",
|
||||
mvm.legal, mvm.cascade.depth, bm.score, bm.moves_made);
|
||||
print("plan: legal {} rounds {} score {} moves {}\n",
|
||||
move.legal, move.rounds.len, ba.score, ba.moves_made);
|
||||
|
||||
if !move.legal { fails += 1; }
|
||||
if !boards_equal(@ba, @bm) { fails += 1; } // committed board == model
|
||||
if ba.score != bm.score { fails += 1; }
|
||||
if ba.moves_made != bm.moves_made { fails += 1; }
|
||||
if move.rounds.len != mvm.cascade.depth { fails += 1; }
|
||||
|
||||
// move.final equals the model board.
|
||||
final_eq := true;
|
||||
for 0..BOARD_CELLS: (i) {
|
||||
if !(move.final[i] == bm.cells[i]) { final_eq = false; }
|
||||
}
|
||||
if !final_eq { fails += 1; }
|
||||
print("final==model {}\n", final_eq);
|
||||
|
||||
// Timeline contiguity: round 0 starts on the swapped pre board; each later
|
||||
// round starts on the previous round's settled board; final == last after.
|
||||
contiguous := true;
|
||||
if move.rounds.len > 0 {
|
||||
ai := Board.idx(a.col, a.row);
|
||||
bi := Board.idx(b.col, b.row);
|
||||
r0 := @move.rounds.items[0];
|
||||
for 0..BOARD_CELLS: (i) {
|
||||
expect : Gem = move.pre[i];
|
||||
if i == ai { expect = move.pre[bi]; }
|
||||
else if i == bi { expect = move.pre[ai]; }
|
||||
if !(r0.before[i] == expect) { contiguous = false; }
|
||||
}
|
||||
for 1..move.rounds.len: (k) {
|
||||
prev := @move.rounds.items[k - 1];
|
||||
cur := @move.rounds.items[k];
|
||||
for 0..BOARD_CELLS: (i) {
|
||||
if !(cur.before[i] == prev.after[i]) { contiguous = false; }
|
||||
}
|
||||
}
|
||||
last := @move.rounds.items[move.rounds.len - 1];
|
||||
for 0..BOARD_CELLS: (i) {
|
||||
if !(last.after[i] == move.final[i]) { contiguous = false; }
|
||||
}
|
||||
}
|
||||
if !contiguous { fails += 1; }
|
||||
print("contiguous {}\n", contiguous);
|
||||
out("final board:\n");
|
||||
out(board_dump(@bm));
|
||||
|
||||
// ── Illegal swap: no timeline, board untouched ──────────────────────────
|
||||
// (0,0)->(1,0): two reds → no match. plan_and_commit must leave the board
|
||||
// exactly as it was, spend no move, and record zero rounds.
|
||||
print("== illegal swap: untouched ==\n");
|
||||
bi2 : Board = ---;
|
||||
bi2.init(SEED);
|
||||
pre2 : Board = bi2;
|
||||
mi := plan_and_commit(@bi2, Cell.{ col = 0, row = 0 }, Cell.{ col = 1, row = 0 });
|
||||
print("legal {} rounds {} score {} moves {}\n", mi.legal, mi.rounds.len, bi2.score, bi2.moves_made);
|
||||
if mi.legal { fails += 1; }
|
||||
if mi.rounds.len != 0 { fails += 1; }
|
||||
if !boards_equal(@pre2, @bi2) { fails += 1; }
|
||||
if bi2.score != 0 { fails += 1; }
|
||||
if bi2.moves_made != 0 { fails += 1; }
|
||||
|
||||
if fails == 0 {
|
||||
print("ok: animation layer leaves the model result unchanged\n");
|
||||
return 0;
|
||||
}
|
||||
print("FAIL: {} anim-determinism checks failed\n", fails);
|
||||
return 1;
|
||||
}
|
||||
1
tests/expected/anim_plan.exit
Normal file
1
tests/expected/anim_plan.exit
Normal file
@@ -0,0 +1 @@
|
||||
0
|
||||
17
tests/expected/anim_plan.stdout
Normal file
17
tests/expected/anim_plan.stdout
Normal file
@@ -0,0 +1,17 @@
|
||||
== legal swap: plan matches model ==
|
||||
model: legal true depth 1 score 30 moves 1
|
||||
plan: legal true rounds 1 score 30 moves 1
|
||||
final==model true
|
||||
contiguous true
|
||||
final board:
|
||||
RRPBORRG
|
||||
PGPPOGRO
|
||||
YYBOPRYB
|
||||
GBYBYRGP
|
||||
OGBYRGOY
|
||||
BYRRPRBG
|
||||
YOYYROBB
|
||||
OROBPPRB
|
||||
== illegal swap: untouched ==
|
||||
legal false rounds 0 score 0 moves 0
|
||||
ok: animation layer leaves the model result unchanged
|
||||
Reference in New Issue
Block a user