Files
m3te/board_anim.sx
swipelab f68ed9a2b3 P17.1: organic fall — accelerate under gravity (ease_in_cubic)
render_fall now drives the per-round drop with ease_in_cubic (P15.1 accel-from-
rest) instead of ease_out_cubic, so falling gems start slow and accelerate into
place like gravity rather than decelerating. f(1)=1 is pinned, so every gem still
lands exactly on its destination cell and move.final is untouched. FALL_ANIM_DUR
(0.22s) is unchanged, so the cascade-cue timing snapshots don't churn.

Golden goldens/p17_fall.png: M3TE_FX=11 (depth-5 cascade, seed 1337) pinned at
M3TE_ANIM_TIME=1.51 (round 3 fall window [1.38,1.60)) — gems caught bunched-high
mid-fall, ~20% down at ~59% of the segment, vs the old curve's ~93%.
2026-06-06 11:15:10 +03:00

294 lines
12 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 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;
// 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))
}
// 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).
// `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: s64;
}
// 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;
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;
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;
// 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: s64;
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: s64) -> s64 {
if num_rounds <= 0 { return 0; }
if elapsed < SWAP_ANIM_DUR { return 0; }
seg := CLEAR_ANIM_DUR + FALL_ANIM_DUR;
started := cast(s64) ((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
}