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

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));