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:
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));
|
||||
|
||||
Reference in New Issue
Block a user