Files
m3te/tests/easing.sx
swipelab 6f7d2f4db2 lang migration: rename signed integer types sN -> iN
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.
2026-06-12 09:36:51 +03:00

271 lines
13 KiB
Plaintext

// 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 :: () -> i32 {
fails : i64 = 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; }
// 5. Illegal-swap bounce-back (P16.2): the springy lunge-and-settle render_swap
// plays for a REJECTED swap. Lock its envelope end to end — rests at BOTH
// ends (f(0)=f(1)=0, so the move stays purely visual), a SINGLE lunge peak of
// exactly BADSWAP_LUNGE_AMP at BADSWAP_LUNGE_T, then a damped settle that
// overshoots past rest by a BOUNDED amount and leaves NO residual at t=1.
print("== illegal-swap bounce ==\n");
bb_ends := bad_swap_bounce(0.0) == 0.0 and bad_swap_bounce(1.0) == 0.0;
bb_mx : f32 = 0.0; bb_mx_t : f32 = 0.0; bb_mn : f32 = 0.0;
for 0..101 (i) {
t := cast(f32) i / 100.0;
v := bad_swap_bounce(t);
if v > bb_mx { bb_mx = v; bb_mx_t = t; }
if v < bb_mn { bb_mn = v; }
}
bb_peak_amp := approx(bb_mx, BADSWAP_LUNGE_AMP);
bb_peak_loc := fabs(bb_mx_t - BADSWAP_LUNGE_T) < 0.011;
bb_overshoots := bb_mn < -0.01; // springs PAST rest after the peak
bb_overshoot_bounded := bb_mn > -0.12; // but the recoil stays tasteful
bb_settles := approx(bad_swap_bounce(1.0), 0.0); // no residual displacement
print("bounce_ends {} peak_amp {} peak_loc {} overshoots {} overshoot_bounded {} settles {}\n",
bb_ends, bb_peak_amp, bb_peak_loc, bb_overshoots, bb_overshoot_bounded, bb_settles);
if !bb_ends { fails += 1; }
if !bb_peak_amp { fails += 1; }
if !bb_peak_loc { fails += 1; }
if !bb_overshoots { fails += 1; }
if !bb_overshoot_bounded { fails += 1; }
if !bb_settles { fails += 1; }
// 6. Per-column fall stagger (P17.2): the fall window offsets each column's drop
// START by a BOUNDED delay so a refilled row pours in as a cascade, yet EVERY
// column still lands EXACTLY on its cell by the segment end. Lock: at t==0 no
// column has moved; at t==1 EVERY column has reached local progress 1 (no gem
// left mid-air — the seam to the next round stays invisible); per-column local
// progress is monotonic in t; and MID-fall the columns form a cascade — each
// later column has made STRICTLY LESS progress than the one before (its drop
// starts later), the opposite of a flat lockstep row sharing one progress.
print("== fall stagger bounded ==\n");
stg_t0 := true; stg_t1 := true;
for 0..BOARD_COLS (c) {
if fall_stagger_t(0.0, c) != 0.0 { stg_t0 = false; }
if fall_stagger_t(1.0, c) != 1.0 { stg_t1 = false; }
}
stg_cascade := true;
for 1..BOARD_COLS (c) {
if !(fall_stagger_t(0.5, c) < fall_stagger_t(0.5, c - 1)) { stg_cascade = false; }
}
stg_mono := true;
for 0..BOARD_COLS (c) {
pp := fall_stagger_t(0.0, c);
for 1..21 (i) {
tt := cast(f32) i / 20.0;
vv := fall_stagger_t(tt, c);
if vv < pp - 0.000001 { stg_mono = false; }
pp = vv;
}
}
print("stagger_t0 {} stagger_t1 {} stagger_cascade {} stagger_mono {}\n",
stg_t0, stg_t1, stg_cascade, stg_mono);
if !stg_t0 { fails += 1; }
if !stg_t1 { fails += 1; }
if !stg_cascade { fails += 1; }
if !stg_mono { fails += 1; }
// 7. Per-column landing instant (P17.3): `fall_landing_frac` is the LOCAL fall
// progress at which each column finishes its drop — exactly the seam where
// `fall_stagger_t` reaches 1, the moment the landing squash-bounce begins.
// Lock: column 0 lands first at `1 - FALL_STAGGER_MAX`, the last column at
// 1.0; it rises monotonically across columns; at that instant the column's
// stagger progress IS 1 (landed) while a hair earlier it is still < 1 (in
// air). `round_land_time` then maps it onto the move timeline — later for
// each later column, and round k+1's first landing strictly after round k's
// last — so the per-round bounces never run before their gems touch down.
print("== landing instant ==\n");
lf_first := approx(fall_landing_frac(0), 1.0 - FALL_STAGGER_MAX);
lf_last := approx(fall_landing_frac(BOARD_COLS - 1), 1.0);
lf_mono := true;
lf_seam := true;
for 0..BOARD_COLS (c) {
if c >= 1 and !(fall_landing_frac(c) > fall_landing_frac(c - 1)) { lf_mono = false; }
lf := fall_landing_frac(c);
if !approx(fall_stagger_t(lf, c), 1.0) { lf_seam = false; } // landed at lf
if fall_stagger_t(lf - 0.05, c) >= 1.0 { lf_seam = false; } // still in air just before
}
rlt_col_mono := true;
for 1..BOARD_COLS (c) {
if !(round_land_time(0, c) > round_land_time(0, c - 1)) { rlt_col_mono = false; }
}
rlt_round_after := round_land_time(1, 0) > round_land_time(0, BOARD_COLS - 1);
print("landing_first {} landing_last {} landing_mono {} landing_seam {} landtime_col_mono {} landtime_round_after {}\n",
lf_first, lf_last, lf_mono, lf_seam, rlt_col_mono, rlt_round_after);
if !lf_first { fails += 1; }
if !lf_last { fails += 1; }
if !lf_mono { fails += 1; }
if !lf_seam { fails += 1; }
if !rlt_col_mono { fails += 1; }
if !rlt_round_after { fails += 1; }
// 8. Per-gem clear ripple (P18.2): within a clearing round each matched gem's
// pop START is offset by a BOUNDED delay (its rank u in [0,1] across the
// round's matched cells) so the matched cells explode as a ripple, yet EVERY
// gem still completes its pop by the segment end. Lock: at t==0 no rank has
// started; at t==1 EVERY rank has reached local 1 (clear_pop_scale → scale 0,
// fully cleared — no gem left mid-pop at the seam to the fall); per-rank local
// progress is monotonic in t; and MID-clear a HIGHER rank has made STRICTLY
// LESS progress than a lower one (its pop starts later) — the ripple, the
// opposite of a flat simultaneous clear. `clear_rank` then ranks each matched
// gem 0..1 by diagonal across the round (lowest-diagonal = 0, the first to pop).
print("== clear ripple bounded ==\n");
rip_t0 := true; rip_t1 := true;
for 0..6 (j) {
u := cast(f32) j / 5.0;
if clear_ripple_t(0.0, u) != 0.0 { rip_t0 = false; }
if clear_ripple_t(1.0, u) != 1.0 { rip_t1 = false; }
}
rip_ripple := true;
for 1..6 (j) {
u := cast(f32) j / 5.0;
up := cast(f32) (j - 1) / 5.0;
if !(clear_ripple_t(0.5, u) < clear_ripple_t(0.5, up)) { rip_ripple = false; }
}
rip_mono := true;
for 0..6 (j) {
u := cast(f32) j / 5.0;
pp := clear_ripple_t(0.0, u);
for 1..21 (i) {
tt := cast(f32) i / 20.0;
vv := clear_ripple_t(tt, u);
if vv < pp - 0.000001 { rip_mono = false; }
pp = vv;
}
}
mm : MatchMask = ---;
for 0..BOARD_CELLS (i) { mm.cells[i] = false; }
mm.cells[Board.idx(5, 0)] = true; // diagonal 5 — first to pop
mm.cells[Board.idx(5, 1)] = true; // diagonal 6
mm.cells[Board.idx(5, 2)] = true; // diagonal 7 — last to pop
sp := clear_diag_span(@mm);
rip_rank := approx(clear_rank(sp, 5, 0), 0.0)
and approx(clear_rank(sp, 5, 1), 0.5)
and approx(clear_rank(sp, 5, 2), 1.0);
print("ripple_t0 {} ripple_t1 {} ripple_cascade {} ripple_mono {} ripple_rank {}\n",
rip_t0, rip_t1, rip_ripple, rip_mono, rip_rank);
if !rip_t0 { fails += 1; }
if !rip_t1 { fails += 1; }
if !rip_ripple { fails += 1; }
if !rip_mono { fails += 1; }
if !rip_rank { 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;
}