P6.3: per-gem idle/select/land/clear animations (sx, iOS sim)

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.
This commit is contained in:
swipelab
2026-06-05 07:59:16 +03:00
parent 70562bd5a9
commit d35fa8a5a6
10 changed files with 431 additions and 10 deletions

View File

@@ -57,3 +57,30 @@ The screenshot should match `goldens/p0_quad.png` (a centered orange quad over a
blue clear), modulo the status-bar clock — pixel-exact equality is not required.
A tap on the quad flips its color (orange ↔ green); see
`goldens/p0_input_before.png` / `goldens/p0_input_after.png`.
### Deterministic animation capture (P6.3)
The per-gem idle loop (`gem_anim.sx`) is always-on, so a plain screenshot is
time-dependent. Two environment variables pin the visual state so the board can
be captured reproducibly. The simulator forwards any `SIMCTL_CHILD_*` variable to
the launched app, so prefix them on the `simctl launch`:
- `M3TE_ANIM_TIME=<seconds>` freezes the animation clock at that phase. **`t=0`
is the resting board** — every gem sits at its static pose, so the pre-P6.3
goldens reproduce unchanged. A larger `t` (e.g. `1.0`) shows the mid-breath
idle deformation. The select/land reactions read this same pinned phase.
- `M3TE_SELECT=<cellIndex 0..63>` (= `row*8 + col`) force-selects a cell at
startup, so the selection highlight + pop can be captured without a tap.
```bash
# Resting board (idle at rest): goldens/p6_idle_t0.png
SIMCTL_CHILD_M3TE_ANIM_TIME=0 xcrun simctl launch booted co.swipelab.m3te
# Mid-breath idle: goldens/p6_idle_mid.png
SIMCTL_CHILD_M3TE_ANIM_TIME=1.0 xcrun simctl launch booted co.swipelab.m3te
# Selection pop on cell (3,3): goldens/p6_select.png
env SIMCTL_CHILD_M3TE_ANIM_TIME=0.17 SIMCTL_CHILD_M3TE_SELECT=27 \
xcrun simctl launch booted co.swipelab.m3te
```
With no variable set the game runs fully live (the clock advances by
`delta_time`). `tests/gem_pose.sx` locks the `t==0`-rest invariant headlessly.

View File

@@ -19,6 +19,7 @@
#import "board_layout.sx";
#import "board_anim.sx";
#import "board_fx.sx";
#import "gem_anim.sx";
#import "swipe.sx";
// Fraction of a cell each gem occupies; the remainder is margin so a gem sits
@@ -120,10 +121,14 @@ load_texture :: (path: [:0]u8, gpu: ?GPU) -> u32 {
BoardSelection :: struct {
active: bool;
cell: Cell;
// Animation clock value when this selection last became active, so the
// selection-pop reaction (gem_anim) can age from the moment of the tap.
since: f32;
init :: (self: *BoardSelection) {
self.active = false;
self.cell = Cell.{ col = 0, row = 0 };
self.since = 0.0;
}
clear :: (self: *BoardSelection) {
@@ -174,6 +179,7 @@ BoardView :: struct {
anim: *BoardAnim;
fx: *BoardFx;
fxassets: *BoardFxAssets;
motion: *GemMotion;
safe: EdgeInsets;
// Where the grid sits + the touch↔cell mapping. Recomputed each render /
@@ -212,14 +218,48 @@ BoardView :: struct {
Frame.make(cx - d * 0.5, cy - d * 0.5, d, d)
}
// Settled-board gems: one sprite per non-empty cell at its cell frame. Used
// whenever no move is animating.
render_gems :: (self: *BoardView, ctx: *RenderContext, inset: f32, dim: f32) {
// Frame for a gem at cell (col,row) drawn with a per-gem animation pose: the
// sprite is scaled about its cell centre and nudged by the pose offset (both
// in cell units). A resting pose reproduces gem_frame exactly, so the t==0
// idle pose draws identically to the static sprite.
gem_pose_frame :: (self: *BoardView, col: s64, row: s64, dim: f32, pose: GemPose) -> Frame {
cs := self.layout.cell_size;
cx := self.layout.origin.x + (cast(f32) col + 0.5) * cs + pose.dx * cs;
cy := self.layout.origin.y + (cast(f32) row + 0.5) * cs + pose.dy * cs;
w := dim * pose.scale_x;
h := dim * pose.scale_y;
Frame.make(cx - w * 0.5, cy - h * 0.5, w, h)
}
// The per-gem animation pose for a settled cell: the always-on idle breath,
// plus a squash-bounce if the cell landed recently, plus a pop if it is the
// selected cell. Purely visual — composed from gem_anim's pure functions.
gem_pose_at :: (self: *BoardView, col: s64, row: s64) -> GemPose {
pose := idle_pose(self.motion.clock, col, row);
sq := land_squash(self.motion.land_local(Board.idx(col, row)));
pose.scale_x += sq;
pose.scale_y -= sq;
if self.sel != null and self.sel.active
and self.sel.cell.col == col and self.sel.cell.row == row {
ts := if self.motion.pinned then self.motion.clock else self.motion.clock - self.sel.since;
sp := select_pop_scale(ts);
pose.scale_x *= sp;
pose.scale_y *= sp;
}
pose
}
// Settled-board gems: one sprite per non-empty cell, drawn with its live
// per-gem animation pose. Used whenever no move is animating.
render_gems :: (self: *BoardView, ctx: *RenderContext, dim: f32) {
for 0..BOARD_ROWS: (row) {
for 0..BOARD_COLS: (col) {
g := self.board.at(col, row);
if g != .empty {
gf := self.gem_frame(cast(f32) col, cast(f32) row, inset, dim);
pose := self.gem_pose_at(col, row);
gf := self.gem_pose_frame(col, row, dim, pose);
self.draw_gem(ctx, gf, cast(s64) g);
}
}
@@ -303,17 +343,17 @@ BoardView :: struct {
}
}
// Clear segment: matched gems shrink toward nothing; the rest hold position.
// Clear segment: matched gems pop outward then collapse to nothing (a
// satisfying pop, composing with the particle burst); the rest hold position.
render_clear :: (self: *BoardView, ctx: *RenderContext, rd: *AnimRound, inset: f32, dim: f32, t: f32) {
shrink := 1.0 - ease_in_quad(t);
if shrink < 0.0 { shrink = 0.0; }
pop := clear_pop_scale(t);
for 0..BOARD_CELLS: (i) {
g := rd.before[i];
if g == .empty { continue; }
col := i % BOARD_COLS;
row := i / BOARD_COLS;
if rd.matched.cells[i] {
gf := self.gem_frame_scaled(col, row, dim, shrink);
gf := self.gem_frame_scaled(col, row, dim, pop);
self.draw_gem(ctx, gf, cast(s64) g);
} else {
gf := self.gem_frame(cast(f32) col, cast(f32) row, inset, dim);
@@ -427,7 +467,7 @@ impl View for BoardView {
if self.anim != null and self.anim.active {
self.render_anim(ctx, gem_inset, gem_dim);
} else {
self.render_gems(ctx, gem_inset, gem_dim);
self.render_gems(ctx, gem_dim);
}
}
@@ -483,6 +523,8 @@ impl View for BoardView {
} else {
if hit := self.layout.point_to_cell(start) {
self.sel.toggle(hit);
// Re-arm the selection-pop reaction from this tap's moment.
if self.sel.active { self.sel.since = self.motion.clock; }
} else {
self.sel.clear();
}

116
gem_anim.sx Normal file
View File

@@ -0,0 +1,116 @@
// 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";
// 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).
LAND_DUR :f32: 0.42;
LAND_SQUASH_A :f32: 0.13;
LAND_OSC :f32: 1.5; // oscillations across the window
land_squash :: (tl: f32) -> f32 {
if tl <= 0.0 or tl >= LAND_DUR { return 0.0; }
decay := 1.0 - tl / LAND_DUR;
LAND_SQUASH_A * sin(TAU * LAND_OSC * tl / LAND_DUR) * decay * decay
}
// --- Clear pop ---------------------------------------------------------------
// The matched-gem clear: a brief outward pop then a collapse to nothing over its
// local 0..1, so the clear reads as a satisfying pop rather than a plain shrink.
// Composes with the existing particle burst / score popup (board_fx.sx).
CLEAR_POP_A :f32: 0.22;
clear_pop_scale :: (t: f32) -> f32 {
if t <= 0.0 { return 1.0; }
if t >= 1.0 { return 0.0; }
if t < 0.30 {
return 1.0 + CLEAR_POP_A * (t / 0.30);
}
peak := 1.0 + CLEAR_POP_A;
u := (t - 0.30) / 0.70;
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;
for 0..BOARD_CELLS: (i) { self.land_at[i] = -1000.0; }
}
stamp_land :: (self: *GemMotion, i: s64) {
self.land_at[i] = self.clock;
}
land_local :: (self: *GemMotion, i: s64) -> f32 {
self.clock - self.land_at[i]
}
}

BIN
goldens/p6_idle_mid.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

BIN
goldens/p6_idle_t0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

BIN
goldens/p6_select.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

119
main.sx
View File

@@ -17,9 +17,15 @@
#import "board_view.sx";
#import "board_anim.sx";
#import "board_fx.sx";
#import "gem_anim.sx";
#run configure_build();
// libc is the implicit foreign-library handle the std allocators bind against;
// reused here to read the deterministic-capture environment variables at startup.
getenv :: (name: [:0]u8) -> *u8 #foreign libc "getenv";
strlen :: (s: *u8) -> usize #foreign libc "strlen";
// Fixed seed for the rendered board — the same seed tests/board_init.sx locks
// as a snapshot, so the on-screen layout matches that golden gem-for-gem.
BOARD_SEED :: 1337;
@@ -64,10 +70,85 @@ g_anim : *BoardAnim = null;
g_fx : *BoardFx = null;
g_fxassets : *BoardFxAssets = null;
// Per-gem idle/select/land animation state (P6.3). Heap-allocated like the rest:
// `clock` advances by delta_time each frame (or is pinned by capture mode) and
// drives every per-gem pose. Purely visual; does not gate input.
g_motion : *GemMotion = null;
// Tracks whether the move timeline was active last frame, so the frame loop can
// fire the landing squash-bounce on the exact frame a move settles.
g_anim_prev_active : bool = false;
// Rebuilt each frame inside the pipeline's arena; carries the current safe-area
// insets so the grid stays inside the notch / home-indicator region.
build_ui :: () -> View {
BoardView.{ board = g_board, assets = g_assets, sel = g_sel, drag = g_drag, anim = g_anim, fx = g_fx, fxassets = g_fxassets, safe = g_safe_insets }
BoardView.{ board = g_board, assets = g_assets, sel = g_sel, drag = g_drag, anim = g_anim, fx = g_fx, fxassets = g_fxassets, motion = g_motion, safe = g_safe_insets }
}
// Deterministic capture (P6.3). The idle loop is always-on, so a live screenshot
// would be time-dependent; these env hooks pin the visual state so goldens are
// reproducible. M3TE_ANIM_TIME=<seconds> freezes the animation clock at a chosen
// phase (t==0 is the resting board, identical to the pre-P6.3 goldens). Optional
// M3TE_SELECT=<cellIndex 0..63> forces a selection so the select-pop reaction can
// be captured without injecting a tap. Absent → normal live behaviour.
read_env :: (name: [:0]u8) -> ?string {
p := getenv(name);
addr : s64 = xx p;
if addr == 0 { return null; }
n := cast(s64) strlen(p);
if n == 0 { return ""; }
buf := cstring(n);
memcpy(buf.ptr, xx p, n);
buf
}
// Digit arithmetic runs entirely in s64; the result converts to f32 only once at
// the end. Doing the digit math in f32 would unify the ASCII literals (45/46/48/
// 57) to f32 across the comparisons, which mis-types the byte compares.
parse_f32 :: (s: string) -> f32 {
i : s64 = 0;
neg : bool = false;
if s.len > 0 {
c0 : s64 = xx s[0];
if c0 == 45 { neg = true; i = 1; } // '-'
}
intval : s64 = 0;
while i < s.len {
c : s64 = xx s[i];
if c < 48 or c > 57 { break; }
intval = intval * 10 + (c - 48);
i += 1;
}
fracval : s64 = 0;
fracdiv : s64 = 1;
if i < s.len {
d : s64 = xx s[i];
if d == 46 { // '.'
i += 1;
while i < s.len {
c : s64 = xx s[i];
if c < 48 or c > 57 { break; }
fracval = fracval * 10 + (c - 48);
fracdiv = fracdiv * 10;
i += 1;
}
}
}
v : f32 = cast(f32) intval + cast(f32) fracval / cast(f32) fracdiv;
if neg { v = 0.0 - v; }
v
}
parse_s64 :: (s: string) -> s64 {
i : s64 = 0;
v : s64 = 0;
while i < s.len {
c : s64 = xx s[i];
if c < 48 or c > 57 { break; }
v = v * 10 + (c - 48);
i += 1;
}
v
}
frame :: () {
@@ -97,6 +178,23 @@ frame :: () {
if g_anim != null { g_anim.tick(g_delta_time); }
if g_fx != null { g_fx.tick(g_delta_time); }
// Advance the always-on per-gem animation clock (idle/select/land). Capture
// mode pins the clock, so it only moves when not pinned. On the exact frame a
// move timeline settles, stamp the landing bounce on every cell the move
// changed, so the gems that actually moved squash-bounce on settle.
if g_motion != null {
if !g_motion.pinned { g_motion.clock += g_delta_time; }
if g_anim != null {
if g_anim_prev_active and !g_anim.active {
mv := @g_anim.move;
for 0..BOARD_CELLS: (i) {
if mv.pre[i] != mv.final[i] { g_motion.stamp_land(i); }
}
}
g_anim_prev_active = g_anim.active;
}
}
inline if OS == .ios {
// Lazy-attach Metal once -[SxAppDelegate didFinishLaunching:] has
// installed the SxMetalView and its bounds have been measured; both can
@@ -183,6 +281,25 @@ main :: () -> void {
g_fxassets.init();
g_fxassets.load(g_pipeline.gpu);
g_motion = xx context.allocator.alloc(size_of(GemMotion));
g_motion.init();
// Deterministic-capture hooks: pin the animation clock and/or preselect a
// cell so the always-on idle (and the select reaction) screenshot the same
// way every time. No env set → fully live.
if t := read_env("M3TE_ANIM_TIME") {
g_motion.pinned = true;
g_motion.clock = parse_f32(t);
}
if sc := read_env("M3TE_SELECT") {
idx := parse_s64(sc);
if idx >= 0 and idx < BOARD_CELLS {
g_sel.active = true;
g_sel.cell = Cell.{ col = idx % BOARD_COLS, row = idx / BOARD_COLS };
g_sel.since = g_motion.clock;
}
}
g_pipeline.set_body(closure(build_ui));
g_plat.run_frame_loop(closure(frame));

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,13 @@
== idle t=0 is rest for all cells ==
idle_t0_rest true
== idle mid-phase deforms, bounded ==
idle_mid_moves true idle_bounded true
== select pop envelope ==
select_start_rest true select_end_rest true select_mid_pops true
== land squash envelope ==
land_start_rest true land_end_rest true land_mid_wobbles true
== clear pop envelope ==
clear_start_full true clear_end_gone true clear_overshoots true
== gem motion land bookkeeping ==
motion_init true motion_no_land true motion_fresh_land true
ok: per-gem animation rests at t=0 and stays bounded

105
tests/gem_pose.sx Normal file
View File

@@ -0,0 +1,105 @@
// 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;
}