diff --git a/README.md b/README.md index 6befa29..28df468 100644 --- a/README.md +++ b/README.md @@ -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=` 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=` (= `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. diff --git a/board_view.sx b/board_view.sx index bd7accf..069f75d 100644 --- a/board_view.sx +++ b/board_view.sx @@ -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(); } diff --git a/gem_anim.sx b/gem_anim.sx new file mode 100644 index 0000000..1cfca42 --- /dev/null +++ b/gem_anim.sx @@ -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] + } +} diff --git a/goldens/p6_idle_mid.png b/goldens/p6_idle_mid.png new file mode 100644 index 0000000..8d8d480 Binary files /dev/null and b/goldens/p6_idle_mid.png differ diff --git a/goldens/p6_idle_t0.png b/goldens/p6_idle_t0.png new file mode 100644 index 0000000..14a70e5 Binary files /dev/null and b/goldens/p6_idle_t0.png differ diff --git a/goldens/p6_select.png b/goldens/p6_select.png new file mode 100644 index 0000000..994eff0 Binary files /dev/null and b/goldens/p6_select.png differ diff --git a/main.sx b/main.sx index c087c10..0c7f6b5 100644 --- a/main.sx +++ b/main.sx @@ -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= freezes the animation clock at a chosen +// phase (t==0 is the resting board, identical to the pre-P6.3 goldens). Optional +// M3TE_SELECT= 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)); diff --git a/tests/expected/gem_pose.exit b/tests/expected/gem_pose.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/expected/gem_pose.exit @@ -0,0 +1 @@ +0 diff --git a/tests/expected/gem_pose.stdout b/tests/expected/gem_pose.stdout new file mode 100644 index 0000000..c9af136 --- /dev/null +++ b/tests/expected/gem_pose.stdout @@ -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 diff --git a/tests/gem_pose.sx b/tests/gem_pose.sx new file mode 100644 index 0000000..9a7c480 --- /dev/null +++ b/tests/gem_pose.sx @@ -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; +}