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

17
main.sx
View File

@@ -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));