P15.1: add extended easing toolkit + determinism snapshot (sx)

Pure, headless easing curves of t in [0,1] for the organic-animation pass
(swap/fall/combine juice), placed alongside the existing ease_out_cubic /
ease_in_quad in board_anim.sx: ease_in_cubic, ease_in_out_cubic, ease_out_back
(bounded overshoot, settles to exactly 1), spring (damped wobble to exactly 1),
and squash_envelope (signed squash-&-stretch landing shape). The math module has
no exp/pow, so the decaying curves use a (1-t)^n polynomial envelope that hits 0
at t==1, pinning f(1) precisely.

Additive only: no render code calls the new curves yet. tests/easing.sx locks,
per curve, the endpoints, overshoot/undershoot bounds, and monotonicity-where-
required (booleans only, so the snapshot is platform-stable), structured so P16.2
can append illegal-swap bounce-back assertions. Test count 21 -> 22.
This commit is contained in:
swipelab
2026-06-06 10:16:02 +03:00
parent a62ddcf0b9
commit 1a8360ec1d
4 changed files with 180 additions and 0 deletions

View File

@@ -28,6 +28,54 @@ FALL_ANIM_DUR :f32: 0.22;
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
}
// 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`

122
tests/easing.sx Normal file
View File

@@ -0,0 +1,122 @@
// Easing-toolkit math foundation (P15.1): pin the pure, headless easing curves in
// board_anim.sx that the organic-animation pass (P16/P17/P18) builds on. NO render
// code calls these yet, so this test is the only consumer — it locks, for each
// curve: the endpoints f(0)/f(1) (and f(0.5) where it's a fixed point), the
// overshoot/undershoot range (bounded + tasteful), and monotonicity where the
// curve must not reverse. Mirrors how tests/gem_pose.sx pins the gem poses; prints
// only booleans (no raw floats) so the snapshot is platform-stable.
//
// P16.2 will APPEND illegal-swap bounce-back assertions here: add a new numbered
// section above the final fails check, in the same `print(...); if !x { fails += 1; }`
// shape. No rendering — pure math over board_anim.sx. Failure is a non-zero exit.
#import "modules/std.sx";
#import "board.sx";
#import "board_anim.sx";
// Local f32 abs (the stdlib generic `abs` mis-types its untyped `0` literal under
// f32; the shipped game never calls abs, so the tests roll their own — matches
// tests/gem_pose.sx).
fabs :: (x: f32) -> f32 { if x < 0.0 then 0.0 - x else x }
approx :: (a: f32, b: f32) -> bool { fabs(a - b) < 0.0001 }
main :: () -> s32 {
fails : s64 = 0;
// 1. Endpoints are locked: every curve starts/ends exactly on its rest value
// (the in/out curves at 1, the spring at 1, the squash envelope at 0).
print("== endpoints locked ==\n");
e_in := ease_in_cubic(0.0) == 0.0 and ease_in_cubic(1.0) == 1.0;
e_io := ease_in_out_cubic(0.0) == 0.0 and ease_in_out_cubic(1.0) == 1.0
and ease_in_out_cubic(0.5) == 0.5;
e_back := ease_out_back(0.0) == 0.0 and ease_out_back(1.0) == 1.0;
e_spring := spring(0.0) == 0.0 and spring(1.0) == 1.0;
e_squash := squash_envelope(0.0) == 0.0 and squash_envelope(1.0) == 0.0;
e_exist := ease_out_cubic(0.0) == 0.0 and ease_out_cubic(1.0) == 1.0
and ease_in_quad(0.0) == 0.0 and ease_in_quad(1.0) == 1.0;
print("ease_in {} ease_in_out {} back {} spring {} squash {} existing {}\n",
e_in, e_io, e_back, e_spring, e_squash, e_exist);
if !e_in { fails += 1; }
if !e_io { fails += 1; }
if !e_back { fails += 1; }
if !e_spring { fails += 1; }
if !e_squash { fails += 1; }
if !e_exist { fails += 1; }
// 2. Monotonicity where required: the four plain eases never reverse over a
// fine sweep of [0,1] (the overshoot/spring/squash curves are exempt — they
// are meant to reverse).
print("== monotonic where required ==\n");
mono_in := true; mono_io := true; mono_oc := true; mono_iq := true;
p_in := ease_in_cubic(0.0);
p_io := ease_in_out_cubic(0.0);
p_oc := ease_out_cubic(0.0);
p_iq := ease_in_quad(0.0);
for 1..21: (i) {
t := cast(f32) i / 20.0;
v_in := ease_in_cubic(t); if v_in < p_in - 0.000001 { mono_in = false; } p_in = v_in;
v_io := ease_in_out_cubic(t); if v_io < p_io - 0.000001 { mono_io = false; } p_io = v_io;
v_oc := ease_out_cubic(t); if v_oc < p_oc - 0.000001 { mono_oc = false; } p_oc = v_oc;
v_iq := ease_in_quad(t); if v_iq < p_iq - 0.000001 { mono_iq = false; } p_iq = v_iq;
}
print("ease_in {} ease_in_out {} ease_out_cubic {} ease_in_quad {}\n",
mono_in, mono_io, mono_oc, mono_iq);
if !mono_in { fails += 1; }
if !mono_io { fails += 1; }
if !mono_oc { fails += 1; }
if !mono_iq { fails += 1; }
// 3. Back/overshoot + spring: each shoots above 1 then settles to exactly 1,
// with a BOUNDED peak (tasteful) and no dip below 0. The spring additionally
// wobbles back below 1 after its overshoot (the damped decay).
print("== overshoot bounded + settles ==\n");
back_mx := ease_out_back(0.0); back_mn := ease_out_back(0.0);
spr_mx := spring(0.0); spr_mn := spring(0.0);
spr_wobble := false;
for 1..21: (i) {
t := cast(f32) i / 20.0;
b := ease_out_back(t);
if b > back_mx { back_mx = b; }
if b < back_mn { back_mn = b; }
s := spring(t);
if s > spr_mx { spr_mx = s; }
if s < spr_mn { spr_mn = s; }
if t > 0.6 and s < 1.0 { spr_wobble = true; }
}
back_overshoots := back_mx > 1.0;
back_bounded := back_mx < 1.15 and back_mn >= -0.0001;
spr_overshoots := spr_mx > 1.0;
spr_bounded := spr_mx < 1.25 and spr_mn >= -0.0001;
print("back_overshoots {} back_bounded {} spring_overshoots {} spring_bounded {} spring_wobbles {}\n",
back_overshoots, back_bounded, spr_overshoots, spr_bounded, spr_wobble);
if !back_overshoots { fails += 1; }
if !back_bounded { fails += 1; }
if !spr_overshoots { fails += 1; }
if !spr_bounded { fails += 1; }
if !spr_wobble { fails += 1; }
// 4. Squash envelope: rests at both ends, actually moves in between, has both a
// squash (positive) and a stretch (negative) lobe, and stays bounded.
print("== squash envelope bounded ==\n");
sq_mx : f32 = 0.0; sq_mn : f32 = 0.0; sq_moves := false;
for 0..21: (i) {
t := cast(f32) i / 20.0;
s := squash_envelope(t);
if s > sq_mx { sq_mx = s; }
if s < sq_mn { sq_mn = s; }
if fabs(s) > 0.01 { sq_moves = true; }
}
sq_two_sided := sq_mx > 0.0 and sq_mn < 0.0;
sq_bounded := sq_mx < 0.75 and sq_mn > -0.75;
print("squash_moves {} squash_two_sided {} squash_bounded {}\n",
sq_moves, sq_two_sided, sq_bounded);
if !sq_moves { fails += 1; }
if !sq_two_sided { fails += 1; }
if !sq_bounded { fails += 1; }
if fails == 0 {
print("ok: easing toolkit endpoints locked + amplitudes bounded\n");
return 0;
}
print("FAIL: {} easing checks failed\n", fails);
return 1;
}

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,9 @@
== endpoints locked ==
ease_in true ease_in_out true back true spring true squash true existing true
== monotonic where required ==
ease_in true ease_in_out true ease_out_cubic true ease_in_quad true
== overshoot bounded + settles ==
back_overshoots true back_bounded true spring_overshoots true spring_bounded true spring_wobbles true
== squash envelope bounded ==
squash_moves true squash_two_sided true squash_bounded true
ok: easing toolkit endpoints locked + amplitudes bounded