Files
m3te/gem_anim.sx
swipelab 00b126d44c P17.3: organic fall — per-round landing squash-&-settle (sx, iOS sim)
Give each landing gem a wide-and-short squash-&-settle bounce as it touches
its destination, applied WITHIN the fall so EVERY cascade round bounces
(staggered per column), not only the final whole-move settle.

One envelope, one bounce: land_squash is now LAND_SQUASH_A * squash_envelope
(P15.1) over its normalized window, so the per-round fall bounce and the
settle bounce are the exact same shape. render_fall/render_clear age a
per-column bounce from each column's touch-down instant (fall_landing_frac *
FALL_ANIM_DUR) via the shared rest_squash + delivering_round helpers, so a gem
still in the air draws unsquashed and only a landed gem flattens; the squash
carries across the fall->clear seam.

Double-bounce reconciliation (approach a): drive the bounce from the per-round
fall and DROP the old whole-move "stamp at age 0" settle. The settle stamp is
now BACK-DATED per column (clock - (total - round_land_time)) so render_gems
resumes land_squash exactly where render_fall left off at the render_anim ->
render_gems seam — one continuous bounce, no double-pop.

Amplitude tuned 0.13 -> 0.18 (~13% peak) so the bounce reads while staying
tasteful; durations unchanged, so the cascade-cue snapshots don't churn.
M3TE_ANIM_TIME=0 still reproduces goldens/p6_idle_t0.png (a resting board
carries no landing stamp). New goldens/p17_land.png pins a staggered landing
mid-pour (M3TE_FX=11 ANIM_TIME=1.94). tests/easing.sx gains a landing-instant
section pinning fall_landing_frac / round_land_time; tests/gem_pose.sx stays
green (land_squash values are identical).
2026-06-06 12:29:11 +03:00

137 lines
5.9 KiB
Plaintext

// Per-gem animation set (P6.3) — a PURELY VISUAL pose each gem sprite is drawn
// with: a calm always-on idle breath, a pop on selection, and a squash-bounce on
// landing. Everything here is a pure function of an animation clock and the cell;
// it never reads or writes the model, so a gem's idle bob/scale cannot change its
// logical cell or break hit-testing (which stays on the grid in board_layout.sx).
//
// Determinism: the idle is always-on, so a live screenshot would be time-
// dependent. `GemMotion.clock` is the single animation time; capture mode
// (M3TE_ANIM_TIME, read in main) freezes it at a chosen phase so the board can be
// screenshotted reproducibly. Every effect is built so that at clock t==0 the pose
// is exactly the resting sprite — so the pre-P6.3 goldens reproduce at t==0.
#import "modules/std.sx";
#import "modules/math";
#import "board.sx";
#import "board_anim.sx";
// A gem's draw transform about its cell centre. scale_x/scale_y scale the sprite
// (1.0 == the normal cell-fill size) and dx/dy nudge it in CELL units. The resting
// pose is all-ones / all-zeros, which draws identically to the static sprite.
GemPose :: struct {
scale_x: f32;
scale_y: f32;
dx: f32;
dy: f32;
}
gem_pose_rest :: () -> GemPose {
GemPose.{ scale_x = 1.0, scale_y = 1.0, dx = 0.0, dy = 0.0 }
}
// --- Idle breath -------------------------------------------------------------
// A gentle ~1s pulse + vertical bob, ramped in from rest so a freshly-shown board
// (and the t==0 capture) starts on the resting pose. A per-gem phase offset keeps
// the board from pulsing in lockstep without ever desyncing the t==0 rest.
IDLE_PERIOD :f32: 1.05; // seconds per breath
IDLE_SCALE_A :f32: 0.035; // +/- uniform scale amplitude
IDLE_BOB_A :f32: 0.024; // vertical bob amplitude (cell units)
IDLE_RAMP :f32: 0.45; // seconds to ease the idle up from full rest
// Smooth per-cell phase: a diagonal gradient wrapped into one breath period.
gem_idle_phase :: (col: s64, row: s64) -> f32 {
cast(f32) ((col * 2 + row * 3) % 8) / 8.0 * TAU
}
idle_pose :: (t: f32, col: s64, row: s64) -> GemPose {
ramp := clamp(t / IDLE_RAMP, 0.0, 1.0);
w := t / IDLE_PERIOD * TAU + gem_idle_phase(col, row);
s := IDLE_SCALE_A * sin(w) * ramp;
bob := IDLE_BOB_A * cos(w) * ramp;
GemPose.{ scale_x = 1.0 + s, scale_y = 1.0 + s, dx = 0.0, dy = bob }
}
// --- Selection pop -----------------------------------------------------------
// A quick scale-up that settles back: a single hump over the window so the tapped
// gem "pops" then relaxes (the highlight overlay still draws on top of this).
SELECT_DUR :f32: 0.34;
SELECT_POP_A :f32: 0.15;
select_pop_scale :: (ts: f32) -> f32 {
if ts <= 0.0 or ts >= SELECT_DUR { return 1.0; }
1.0 + SELECT_POP_A * sin(PI * ts / SELECT_DUR)
}
// --- Landing squash-bounce ---------------------------------------------------
// A damped wobble on settle: the gem flattens wide-and-short on impact, then a
// couple of decaying overshoots. 0 at tl==0 and again past the window (rest).
// The shape IS P15.1's `squash_envelope` (board_anim) over the normalized window
// tl/LAND_DUR, scaled to a tasteful amplitude — so the per-round fall bounce and
// this settle bounce are the EXACT same envelope (P17.3 drives both through here).
LAND_DUR :f32: 0.42;
LAND_SQUASH_A :f32: 0.18; // peak ~13% wide-and-short — reads on landing, still tasteful
land_squash :: (tl: f32) -> f32 {
if tl <= 0.0 or tl >= LAND_DUR { return 0.0; }
LAND_SQUASH_A * squash_envelope(tl / LAND_DUR)
}
// --- Clear pop ---------------------------------------------------------------
// The matched-gem clear: a snappy outward pop then a collapse to nothing over its
// local 0..1, so the clear reads as a satisfying candy pop rather than a plain
// shrink. A fast rise to a bigger overshoot makes the snap read; the soft
// particle burst / score popup (board_fx.sx) compose on top.
CLEAR_POP_A :f32: 0.34; // overshoot height above resting scale
CLEAR_POP_RISE :f32: 0.18; // fraction of the window spent rising to the peak
clear_pop_scale :: (t: f32) -> f32 {
if t <= 0.0 { return 1.0; }
if t >= 1.0 { return 0.0; }
if t < CLEAR_POP_RISE {
return 1.0 + CLEAR_POP_A * (t / CLEAR_POP_RISE);
}
peak := 1.0 + CLEAR_POP_A;
u := (t - CLEAR_POP_RISE) / (1.0 - CLEAR_POP_RISE);
peak * (1.0 - u * u)
}
// Live per-gem animation state, heap-allocated (like BoardAnim/BoardFx) so it
// survives BoardView's per-frame rebuild. `clock` is the single animation time:
// the frame loop advances it by delta_time, or capture mode pins it. `land_at`
// records, per cell, the clock value when that cell last received a gem so only
// the cells that actually moved bounce.
GemMotion :: struct {
clock: f32;
pinned: bool;
land_at: [BOARD_CELLS]f32;
init :: (self: *GemMotion) {
self.clock = 0.0;
self.pinned = false;
self.reset_landings();
}
// Drop every landing stamp back to the never-landed sentinel so no cell
// carries a squash-bounce. `restart` calls this so a reseeded board starts at
// its resting pose instead of replaying the prior move's landing wobble; the
// idle clock keeps running, so the always-on idle simply resumes from rest.
reset_landings :: (self: *GemMotion) {
for 0..BOARD_CELLS: (i) { self.land_at[i] = -1000.0; }
}
stamp_land :: (self: *GemMotion, i: s64) {
self.stamp_land_at(i, self.clock);
}
// Record cell `i`'s landing at an explicit clock value, so the settle hand-off
// can BACK-DATE the stamp to when the gem actually touched down mid-fall (each
// column lands at a staggered instant): land_squash then resumes the per-round
// bounce exactly where render_fall left it, with no double-pop at the seam.
stamp_land_at :: (self: *GemMotion, i: s64, at: f32) {
self.land_at[i] = at;
}
land_local :: (self: *GemMotion, i: s64) -> f32 {
self.clock - self.land_at[i]
}
}