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:
27
README.md
27
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=<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.
|
||||
|
||||
@@ -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
116
gem_anim.sx
Normal 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
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
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
BIN
goldens/p6_select.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 MiB |
119
main.sx
119
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=<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));
|
||||
|
||||
1
tests/expected/gem_pose.exit
Normal file
1
tests/expected/gem_pose.exit
Normal file
@@ -0,0 +1 @@
|
||||
0
|
||||
13
tests/expected/gem_pose.stdout
Normal file
13
tests/expected/gem_pose.stdout
Normal 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
105
tests/gem_pose.sx
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user