Mechanical sweep of all .sx sources, plan docs, and tests/expected snapshots for the sx language rename (s8/s16/s32/s64 -> i8/i16/i32/i64). Verified: tools/run_tests.sh 23/23. Note: the ios-sim build has 2 pre-existing 'restart' dot-call errors from the sx opt-in UFCS change (sx a47ea14) — independent of this rename (present pre-sweep); migrated in the follow-up commit.
404 lines
18 KiB
Plaintext
404 lines
18 KiB
Plaintext
// 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 (and, like the headless
|
|
// turn loop, reshuffles a deadlocked board afterwards), then replays the SAME
|
|
// commit 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 move's settled (pre-reshuffle) board gem-for-gem — the animation only
|
|
// ever ends ON the already-decided cascade 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;
|
|
|
|
// Two base easing helpers, each with locked endpoints f(0)=0 and f(1)=1:
|
|
// ease_out_cubic decelerates into its endpoint, ease_in_quad accelerates from rest.
|
|
ease_out_cubic :: (t: f32) -> f32 { u := t - 1.0; u * u * u + 1.0 }
|
|
ease_in_quad :: (t: f32) -> f32 { t * t }
|
|
|
|
// --- Extended easing toolkit (P15.1) -----------------------------------------
|
|
// Pure, headless curves of t in [0,1] for the organic-animation pass (swap/fall/
|
|
// combine juice). Each has LOCKED endpoints and bounded, tasteful amplitude; NO
|
|
// render code calls these yet — the transition steps (P16/P17/P18) wire them in
|
|
// and tune feel. Companions to the two easing helpers above; the math module has
|
|
// no exp/pow, so the decaying curves use a polynomial envelope that reaches
|
|
// exactly 0 at t==1, which pins f(1) precisely instead of merely approaching it.
|
|
// `tests/easing.sx` pins every endpoint, overshoot bound, and monotonicity here.
|
|
|
|
// Accelerate from rest: slow start, fast finish. Monotonic 0->1. Cubic companion
|
|
// to ease_in_quad and the mirror of ease_out_cubic.
|
|
ease_in_cubic :: (t: f32) -> f32 { t * t * t }
|
|
|
|
// Smooth accelerate-then-decelerate, symmetric about (0.5, 0.5). Monotonic 0->1.
|
|
ease_in_out_cubic :: (t: f32) -> f32 {
|
|
if t < 0.5 { return 4.0 * t * t * t; }
|
|
u := -2.0 * t + 2.0;
|
|
1.0 - u * u * u * 0.5
|
|
}
|
|
|
|
// Overshoot ("back"): shoots ~10% past 1 then settles to EXACTLY 1, never dipping
|
|
// below 0. Non-monotonic by design — the overshoot is the whole point.
|
|
BACK_S :f32: 1.70158;
|
|
ease_out_back :: (t: f32) -> f32 {
|
|
u := t - 1.0;
|
|
1.0 + (BACK_S + 1.0) * u * u * u + BACK_S * u * u
|
|
}
|
|
|
|
// Damped spring: rises to 1, overshoots (~18%), then a small decaying wobble back
|
|
// to EXACTLY 1. The (1-t)^3 envelope is 0 at t==1, so f(1) is locked.
|
|
SPRING_OSC :f32: 1.0;
|
|
spring :: (t: f32) -> f32 {
|
|
if t <= 0.0 { return 0.0; }
|
|
if t >= 1.0 { return 1.0; }
|
|
d := 1.0 - t;
|
|
1.0 - d * d * d * cos(TAU * SPRING_OSC * t)
|
|
}
|
|
|
|
// Squash-&-stretch landing envelope: a signed, unit-ish shape that is 0 (rest) at
|
|
// both ends, squashes on impact, then wobbles out with decay. Downstream applies
|
|
// it as e.g. scale_x = 1 + A*s, scale_y = 1 - A*s for a tasteful amplitude A.
|
|
SQUASH_OSC :f32: 1.5;
|
|
squash_envelope :: (t: f32) -> f32 {
|
|
if t <= 0.0 or t >= 1.0 { return 0.0; }
|
|
d := 1.0 - t;
|
|
sin(TAU * SQUASH_OSC * t) * d * d
|
|
}
|
|
|
|
// Illegal-swap bounce-back envelope (P16.2): the displacement FRACTION the two
|
|
// swapped gems travel toward the rejected neighbour over the swap segment. A quick
|
|
// lunge OUT to BADSWAP_LUNGE_AMP (the single peak, at t==BADSWAP_LUNGE_T), then a
|
|
// damped spring HOME that slightly overshoots past rest and settles to EXACTLY 0.
|
|
// f(0)=0 and f(1)=0, so the swap stays purely visual — t==0 and t==1 are both the
|
|
// rest pose. The settle reuses P15.1's `spring`: `1 - spring(u)` is the spring's
|
|
// own (1-u)^3·cos envelope, which carries the value from the peak down through 0,
|
|
// a bounded dip below rest, and back to exactly 0 — so the wobble matches the rest
|
|
// of the organic pass and f(1) is pinned, not merely approached.
|
|
BADSWAP_LUNGE_T :f32: 0.36; // where the lunge reaches its peak
|
|
BADSWAP_LUNGE_AMP :f32: 0.42; // how far toward the neighbour (cell fraction)
|
|
bad_swap_bounce :: (t: f32) -> f32 {
|
|
if t <= 0.0 { return 0.0; }
|
|
if t >= 1.0 { return 0.0; }
|
|
if t < BADSWAP_LUNGE_T {
|
|
return BADSWAP_LUNGE_AMP * ease_out_cubic(t / BADSWAP_LUNGE_T);
|
|
}
|
|
u := (t - BADSWAP_LUNGE_T) / (1.0 - BADSWAP_LUNGE_T);
|
|
BADSWAP_LUNGE_AMP * (1.0 - spring(u))
|
|
}
|
|
|
|
// Per-column fall stagger (P17.2): within the fall window, each column starts its
|
|
// drop at a small BOUNDED delay so a refilled/collapsed row pours in as a cascade
|
|
// instead of every gem snapping down in one flat lockstep row. Column `col` waits
|
|
// FALL_STAGGER_MAX * col/(BOARD_COLS-1) of the window, then falls over the
|
|
// remaining `1 - FALL_STAGGER_MAX`, so the LAST column lands EXACTLY at t==1 and
|
|
// every earlier column lands strictly before it — no gem is ever left mid-air when
|
|
// the segment ends (the seam to the next round / settled board stays invisible).
|
|
// Returns the column's LOCAL 0..1 progress; render_fall feeds it through
|
|
// ease_in_cubic so each column still accelerates under gravity within its window.
|
|
// `tests/easing.sx` pins f(0)=0, f(1)=1, monotonicity, and the cascade ordering.
|
|
FALL_STAGGER_MAX :f32: 0.30;
|
|
fall_stagger_t :: (t: f32, col: i64) -> f32 {
|
|
delay := FALL_STAGGER_MAX * (cast(f32) col / cast(f32) (BOARD_COLS - 1));
|
|
window := 1.0 - FALL_STAGGER_MAX;
|
|
lt := (t - delay) / window;
|
|
if lt <= 0.0 { return 0.0; }
|
|
if lt >= 1.0 { return 1.0; }
|
|
lt
|
|
}
|
|
|
|
// The LOCAL fall-progress fraction at which column `col` finishes its drop — the
|
|
// instant `fall_stagger_t(.,col)` reaches 1 (delay + window). Column 0 lands first
|
|
// at `1 - FALL_STAGGER_MAX`; the last column lands exactly at 1.0. The landing
|
|
// squash-bounce (P17.3) ages from this instant per column, so the squash begins
|
|
// the moment a gem touches its cell rather than at a flat whole-row settle.
|
|
fall_landing_frac :: (col: i64) -> f32 {
|
|
(1.0 - FALL_STAGGER_MAX) + FALL_STAGGER_MAX * (cast(f32) col / cast(f32) (BOARD_COLS - 1))
|
|
}
|
|
|
|
// Absolute time (s) on the swap→(clear,fall)* timeline at which round `k` finishes
|
|
// dropping column `col`'s gem onto its destination cell — the landing instant the
|
|
// per-round bounce ages from. Round k's fall starts after the swap, k clear+fall
|
|
// pairs, and that round's own clear; column `col` then lands `fall_landing_frac`
|
|
// of the fall window into it. Pure + headless, mirrors `phase`'s segment walk.
|
|
round_land_time :: (k: i64, col: i64) -> f32 {
|
|
SWAP_ANIM_DUR + cast(f32) k * (CLEAR_ANIM_DUR + FALL_ANIM_DUR) + CLEAR_ANIM_DUR
|
|
+ fall_landing_frac(col) * FALL_ANIM_DUR
|
|
}
|
|
|
|
// Per-gem clear ripple (P18.2): within a clearing round the matched gems pop as a
|
|
// RIPPLE, not all at once. Each gem gets a normalized rank `u` in [0,1] (its
|
|
// diagonal position within the round's matched cells, lowest-diagonal = 0), and
|
|
// this offsets that gem's pop START by a BOUNDED delay so rank 0 pops first and
|
|
// rank 1 last, yet EVERY gem still reaches local 1 (clear_pop_scale → scale 0,
|
|
// fully cleared) by the clear segment's end — no gem is left mid-pop at the seam
|
|
// to the fall. Returns the gem's LOCAL 0..1 progress, fed through clear_pop_scale
|
|
// (whose locked endpoints keep the seam to the model board). Mirrors
|
|
// fall_stagger_t's (t-delay)/window. `tests/easing.sx` pins f(0,.)/f(1,.), the
|
|
// bounded completion by t==1, monotonicity, and the rank ordering.
|
|
CLEAR_STAGGER_MAX :f32: 0.45;
|
|
clear_ripple_t :: (t: f32, u: f32) -> f32 {
|
|
delay := CLEAR_STAGGER_MAX * u;
|
|
window := 1.0 - CLEAR_STAGGER_MAX;
|
|
lt := (t - delay) / window;
|
|
if lt <= 0.0 { return 0.0; }
|
|
if lt >= 1.0 { return 1.0; }
|
|
lt
|
|
}
|
|
|
|
// The diagonal (col+row) extent of a round's matched cells — the span the ripple
|
|
// ranks each matched gem across. `hi < lo` only if the mask is empty.
|
|
ClearDiag :: struct { lo: i64; hi: i64; }
|
|
clear_diag_span :: (m: *MatchMask) -> ClearDiag {
|
|
lo : i64 = (BOARD_COLS - 1) + (BOARD_ROWS - 1) + 1;
|
|
hi : i64 = -1;
|
|
for 0..BOARD_CELLS (i) {
|
|
if m.cells[i] {
|
|
d := (i % BOARD_COLS) + (i / BOARD_COLS);
|
|
if d < lo { lo = d; }
|
|
if d > hi { hi = d; }
|
|
}
|
|
}
|
|
ClearDiag.{ lo = lo, hi = hi }
|
|
}
|
|
|
|
// Normalized rank (0..1) of cell (col,row) within a round's matched diagonal span
|
|
// — 0 for the earliest-popping (lowest-diagonal) gem, 1 for the last. Normalizing
|
|
// PER ROUND (not across the board) lets even a small 3-match ripple across the
|
|
// full stagger budget. A degenerate span (every matched cell on one diagonal)
|
|
// ranks all gems 0, so they pop together rather than dividing by zero.
|
|
clear_rank :: (span: ClearDiag, col: i64, row: i64) -> f32 {
|
|
if span.hi <= span.lo { return 0.0; }
|
|
cast(f32) ((col + row) - span.lo) / cast(f32) (span.hi - span.lo)
|
|
}
|
|
|
|
// 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]i64;
|
|
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).
|
|
// `awarded` carries the model's own payout for this move (cascade.awarded) so the
|
|
// score-popup FX (P6.2) shows the real number without re-deriving any scoring.
|
|
AnimMove :: struct {
|
|
legal: bool;
|
|
a: Cell;
|
|
b: Cell;
|
|
pre: [BOARD_CELLS]Gem;
|
|
rounds: List(AnimRound);
|
|
final: [BOARD_CELLS]Gem;
|
|
awarded: i64;
|
|
}
|
|
|
|
// The most recent round at or before `kmax` that dropped a MOVED gem onto
|
|
// destination cell `i` (a slide-down survivor or a top refill — `src != row`), or
|
|
// -1 if the gem now resting at `i` never moved over those rounds. The gem at `i`
|
|
// landed in that round, so its squash-bounce ages from `round_land_time(round,
|
|
// col)`; scanning newest-first means a cell cleared and refilled across rounds
|
|
// ages from its LATEST arrival, never a stale earlier one. Pure + headless: the
|
|
// per-round bounce (render_fall/clear) and the final-settle stamp share this so
|
|
// one envelope plays continuously across every seam.
|
|
delivering_round :: (mv: *AnimMove, i: i64, kmax: i64) -> i64 {
|
|
row := i / BOARD_COLS;
|
|
k := kmax;
|
|
while k >= 0 {
|
|
if mv.rounds.items[k].src[i] != row { return k; }
|
|
k -= 1;
|
|
}
|
|
-1
|
|
}
|
|
|
|
// Commit the player's swap authoritatively AND record its visual timeline. The
|
|
// real board is mutated by `commit_swap`, then — exactly like the headless
|
|
// `play_turn` — `reshuffle_if_deadlocked` recovers a stranded board so the rendered
|
|
// game obeys the same no-moves rule. The recording runs on a value-copy taken
|
|
// BEFORE the commit, so it replays the identical cells + RNG stream; the recorded
|
|
// `final` is the SETTLED board the animation ends on. It equals the live board
|
|
// unless a deadlock reshuffle then re-arranged it: that reshuffle is a model step,
|
|
// not part of this move's timeline, so it renders on the next settled frame.
|
|
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;
|
|
move.awarded = 0;
|
|
|
|
// 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;
|
|
move.awarded = mv.cascade.awarded;
|
|
if !mv.legal {
|
|
move.final = board.cells;
|
|
reshuffle_if_deadlocked(board);
|
|
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;
|
|
reshuffle_if_deadlocked(board);
|
|
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: i64;
|
|
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;
|
|
// Highest 1-based cascade round whose ascending combo cue has already played,
|
|
// so the frame loop's per-round SFX is edge-triggered: a round's cue fires once,
|
|
// when its clear begins, never re-fired every frame. Reset whenever a move
|
|
// (re)starts; advanced by the frame loop as rounds clear.
|
|
cascade_fired: i64;
|
|
|
|
init :: (self: *BoardAnim) {
|
|
self.active = false;
|
|
self.elapsed = 0.0;
|
|
self.move.legal = false;
|
|
self.move.rounds = List(AnimRound).{};
|
|
self.cascade_fired = 0;
|
|
}
|
|
|
|
begin :: (self: *BoardAnim, m: AnimMove) {
|
|
self.move = m;
|
|
self.elapsed = 0.0;
|
|
self.active = true;
|
|
self.cascade_fired = 0;
|
|
}
|
|
|
|
// 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 }
|
|
}
|
|
}
|
|
|
|
// Per-round cascade-cue timing (P10.10): how many cascade rounds have BEGUN their
|
|
// clear (pop) by `elapsed`, on the SAME swap→(clear,fall)* timeline `phase` walks.
|
|
// Round k (0-based) starts clearing at SWAP_ANIM_DUR + k*(CLEAR_ANIM_DUR +
|
|
// FALL_ANIM_DUR), so this is the count of rounds whose ascending combo cue should
|
|
// have fired by now (clamped to the move's round count). The frame loop diffs it
|
|
// against `BoardAnim.cascade_fired` to play one cue per newly-cleared round. Pure +
|
|
// headless so the per-round playback is snapshot-testable without audio.
|
|
cascade_rounds_started :: (elapsed: f32, num_rounds: i64) -> i64 {
|
|
if num_rounds <= 0 { return 0; }
|
|
if elapsed < SWAP_ANIM_DUR { return 0; }
|
|
seg := CLEAR_ANIM_DUR + FALL_ANIM_DUR;
|
|
started := cast(i64) ((elapsed - SWAP_ANIM_DUR) / seg) + 1;
|
|
if started > num_rounds { return num_rounds; }
|
|
started
|
|
}
|
|
|
|
// Input gate: the board accepts a new swipe/tap gesture only when no move
|
|
// animation is in flight. The view checks this at gesture START (mouse_down),
|
|
// not at commit (mouse_up), so a gesture begun while a timeline is playing never
|
|
// latches a drag and so cannot commit when the animation later settles. Input
|
|
// resumes once `tick` clears `active` at the end of the timeline. A null anim
|
|
// (no animation layer wired) always accepts.
|
|
accepts_input :: (anim: *BoardAnim) -> bool {
|
|
anim == null or !anim.active
|
|
}
|