New gem_anim.sx adds a purely-visual per-gem pose set driven by a single animation clock: a calm always-on idle breath (scale-pulse + bob, per-gem phase, ramped in from rest), a selection pop, a landing squash-bounce, and a clear pop. BoardView draws every settled gem through gem_pose_at / gem_pose_frame; the move timeline (P6.1) and FX (P6.2) are untouched and the input-lock semantics are unchanged (idle never locks input). Determinism: the idle is always-on, so main reads M3TE_ANIM_TIME=<seconds> to freeze the clock at a chosen phase (t==0 == the resting board, so the pre-P6.3 goldens reproduce) and M3TE_SELECT=<cellIndex> to force a selection for capture. tests/gem_pose.sx locks the t==0-rest invariant and the reaction envelopes headlessly (fails if the idle ramp is dropped). Goldens (deterministic capture): p6_idle_t0 (resting), p6_idle_mid (pinned mid-breath), p6_select (selection pop on cell 3,3). Purely visual: no change to model/score/moves/hit-testing.
106 lines
4.4 KiB
Plaintext
106 lines
4.4 KiB
Plaintext
// Per-gem animation determinism guard (P6.3): prove the idle/select/land/clear
|
|
// poses are PURELY VISUAL pure functions of the animation clock, and — most
|
|
// importantly — that at clock t==0 EVERY gem's idle pose is EXACTLY the resting
|
|
// sprite. That t==0-rest invariant is what lets the pre-P6.3 goldens (p4_board,
|
|
// p4_hud, …) reproduce under the deterministic capture (M3TE_ANIM_TIME=0): if the
|
|
// idle ramp were dropped, gems would already be deformed at t=0 and every prior
|
|
// golden would churn. It also pins the reactions to rest at their window ends and
|
|
// bounds the idle amplitude so the polish stays tasteful.
|
|
// No rendering — pure math over gem_anim.sx. Failure is a non-zero exit code.
|
|
#import "modules/std.sx";
|
|
#import "modules/math";
|
|
#import "board.sx";
|
|
#import "gem_anim.sx";
|
|
|
|
// Local f32 abs with explicit 0.0 literals: the stdlib generic `abs` mis-types
|
|
// its untyped `0` literals under f32 and returns 0.0 for every f32 input. The
|
|
// shipped game never calls abs; only this test needs it, so it rolls its own.
|
|
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. t==0 idle pose is EXACTLY rest for every cell (the determinism invariant).
|
|
print("== idle t=0 is rest for all cells ==\n");
|
|
rest_ok := true;
|
|
for 0..BOARD_ROWS: (row) {
|
|
for 0..BOARD_COLS: (col) {
|
|
p := idle_pose(0.0, col, row);
|
|
if !(p.scale_x == 1.0 and p.scale_y == 1.0 and p.dx == 0.0 and p.dy == 0.0) {
|
|
rest_ok = false;
|
|
}
|
|
}
|
|
}
|
|
print("idle_t0_rest {}\n", rest_ok);
|
|
if !rest_ok { fails += 1; }
|
|
|
|
// 2. Past the ramp the idle actually moves, but stays tasteful (bounded).
|
|
print("== idle mid-phase deforms, bounded ==\n");
|
|
moved := false;
|
|
bounded := true;
|
|
for 0..BOARD_ROWS: (row) {
|
|
for 0..BOARD_COLS: (col) {
|
|
p := idle_pose(0.6, col, row);
|
|
if fabs(p.scale_x - 1.0) > 0.0005 { moved = true; }
|
|
if fabs(p.scale_x - 1.0) > 0.05 { bounded = false; }
|
|
if fabs(p.dy) > 0.05 { bounded = false; }
|
|
}
|
|
}
|
|
print("idle_mid_moves {} idle_bounded {}\n", moved, bounded);
|
|
if !moved { fails += 1; }
|
|
if !bounded { fails += 1; }
|
|
|
|
// 3. Select pop: rest at both window ends, a pop in the middle.
|
|
print("== select pop envelope ==\n");
|
|
s_start := approx(select_pop_scale(0.0), 1.0);
|
|
s_end := approx(select_pop_scale(SELECT_DUR), 1.0);
|
|
s_mid := select_pop_scale(SELECT_DUR * 0.5) > 1.05;
|
|
print("select_start_rest {} select_end_rest {} select_mid_pops {}\n", s_start, s_end, s_mid);
|
|
if !s_start { fails += 1; }
|
|
if !s_end { fails += 1; }
|
|
if !s_mid { fails += 1; }
|
|
|
|
// 4. Land squash: rest at both window ends, a wobble just after impact.
|
|
print("== land squash envelope ==\n");
|
|
l_start := approx(land_squash(0.0), 0.0);
|
|
l_end := approx(land_squash(LAND_DUR), 0.0);
|
|
l_mid := fabs(land_squash(LAND_DUR * 0.12)) > 0.01;
|
|
print("land_start_rest {} land_end_rest {} land_mid_wobbles {}\n", l_start, l_end, l_mid);
|
|
if !l_start { fails += 1; }
|
|
if !l_end { fails += 1; }
|
|
if !l_mid { fails += 1; }
|
|
|
|
// 5. Clear pop: full at t=0, gone at t=1, overshoots above 1 in between.
|
|
print("== clear pop envelope ==\n");
|
|
c_start := approx(clear_pop_scale(0.0), 1.0);
|
|
c_end := approx(clear_pop_scale(1.0), 0.0);
|
|
c_peak := clear_pop_scale(0.30) > 1.1;
|
|
print("clear_start_full {} clear_end_gone {} clear_overshoots {}\n", c_start, c_end, c_peak);
|
|
if !c_start { fails += 1; }
|
|
if !c_end { fails += 1; }
|
|
if !c_peak { fails += 1; }
|
|
|
|
// 6. GemMotion land bookkeeping: fresh state is unpinned at t=0, a never-
|
|
// landed cell rests, and a freshly-stamped land reads age 0.
|
|
print("== gem motion land bookkeeping ==\n");
|
|
m : GemMotion = ---;
|
|
m.init();
|
|
init_ok := m.clock == 0.0 and !m.pinned;
|
|
no_land := land_squash(m.land_local(0)) == 0.0;
|
|
m.clock = 2.0;
|
|
m.stamp_land(10);
|
|
fresh_land := approx(m.land_local(10), 0.0);
|
|
print("motion_init {} motion_no_land {} motion_fresh_land {}\n", init_ok, no_land, fresh_land);
|
|
if !init_ok { fails += 1; }
|
|
if !no_land { fails += 1; }
|
|
if !fresh_land { fails += 1; }
|
|
|
|
if fails == 0 {
|
|
print("ok: per-gem animation rests at t=0 and stays bounded\n");
|
|
return 0;
|
|
}
|
|
print("FAIL: {} gem-anim checks failed\n", fails);
|
|
return 1;
|
|
}
|