diff --git a/README.md b/README.md index aa3f4bd..7f2e351 100644 --- a/README.md +++ b/README.md @@ -115,3 +115,32 @@ env SIMCTL_CHILD_M3TE_TARGET=0 SIMCTL_CHILD_M3TE_RESTART=1 SIMCTL_CHILD_M3TE_ANI While a banner is up the board freezes (only the restart button is live, per P7.1's finished-level rule); `tests/banner_layout.sx` locks the restart button's rect ↔ hit-test round-trip headlessly. + +### Match-FX capture (P11.1) + +The match bursts + score popup (`board_fx.sx`) only spawn off a *committed* move, +which the simulator can't script (there is no public touch injection). One more +env hook forces a representative match at startup so the FX can be screenshot +deterministically — combine it with `M3TE_ANIM_TIME` to freeze the phase: + +- `M3TE_FX=` commits the **n-th currently-legal swap** (1-based, clamped; `=1` + is the first) through the normal `plan_and_commit` path, then begins the move + timeline + its burst/popup FX. While `M3TE_ANIM_TIME` is set the move/FX + timelines are pinned at that phase (the frame loop holds them frozen), so the + burst and floating `+points` render identically every run. A larger + `M3TE_ANIM_TIME` lands past the timeline, capturing the settled board with the + FX fully pruned. Startup-only and guarded by the var, so normal play is + untouched. + +The legal-swap order is the fixed enumeration in `tests/expected/swap_legality.stdout` +(row-major, right-before-down). For seed 1337, `M3TE_FX=3` is the vertical red +3-match used by the golden. + +```bash +# Punchy match burst + "+30" popup, pinned mid-clear: goldens/p6_fx_match.png +env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0.22 \ + xcrun simctl launch booted co.swipelab.m3te +# Same match, later phase — FX fully gone over the settled board (no golden): +env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=2.0 \ + xcrun simctl launch booted co.swipelab.m3te +``` diff --git a/board_fx.sx b/board_fx.sx index ec48f82..d95455e 100644 --- a/board_fx.sx +++ b/board_fx.sx @@ -25,8 +25,8 @@ // lingers a touch into the fall; combos (cascade rounds past the first) burst // bigger. Sizes are in CELL units (1.0 == one grid cell). FX_BURST_LIFE :f32: 0.70; -FX_BURST_BASE :f32: 1.95; -FX_BURST_COMBO :f32: 0.55; // extra peak size per cascade depth (capped) +FX_BURST_BASE :f32: 2.50; +FX_BURST_COMBO :f32: 0.72; // extra peak size per cascade depth (capped) // Popup timing/motion. Rises ~1.2 cells over its life and fades out; a combo // (depth > 1) popup is larger and gold. @@ -35,15 +35,17 @@ FX_POPUP_RISE :f32: 1.2; FX_POPUP_FONT :f32: 34.0; FX_POPUP_COMBO_FONT :f32: 48.0; -// Bright, slightly pastel tints so a soft glow reads over the dark board, in gem -// order (red, orange, yellow, green, blue, purple). +// Vivid candy tints so a soft glow reads brightly over the dark board, in gem +// order (red, orange, yellow, green, blue, purple). Saturated a touch past the +// pastel — the low channel is trimmed while the dominant/mid channel is lifted — +// so every burst pops as a punchier colour without losing luminance. fx_tint :: (i: s64) -> Color { - if i == 0 { return Color.{ r = 255, g = 86, b = 86, a = 255 }; } - if i == 1 { return Color.{ r = 255, g = 158, b = 64, a = 255 }; } - if i == 2 { return Color.{ r = 255, g = 234, b = 96, a = 255 }; } - if i == 3 { return Color.{ r = 120, g = 240, b = 120, a = 255 }; } - if i == 4 { return Color.{ r = 110, g = 184, b = 255, a = 255 }; } - Color.{ r = 206, g = 132, b = 255, a = 255 } + if i == 0 { return Color.{ r = 255, g = 92, b = 62, a = 255 }; } + if i == 1 { return Color.{ r = 255, g = 164, b = 44, a = 255 }; } + if i == 2 { return Color.{ r = 255, g = 240, b = 72, a = 255 }; } + if i == 3 { return Color.{ r = 112, g = 250, b = 112, a = 255 }; } + if i == 4 { return Color.{ r = 96, g = 192, b = 255, a = 255 }; } + Color.{ r = 224, g = 124, b = 255, a = 255 } } FX_POPUP_COLOR :: Color.{ r = 255, g = 255, b = 255, a = 255 }; diff --git a/gem_anim.sx b/gem_anim.sx index 6cdead0..230eae8 100644 --- a/gem_anim.sx +++ b/gem_anim.sx @@ -74,19 +74,21 @@ land_squash :: (tl: f32) -> f32 { } // --- 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; +// The matched-gem clear: a snappy outward pop then a collapse to nothing over its +// local 0..1, so the clear reads as a satisfying candy pop rather than a plain +// shrink. A fast rise to a bigger overshoot makes the snap read; the soft +// particle burst / score popup (board_fx.sx) compose on top. +CLEAR_POP_A :f32: 0.34; // overshoot height above resting scale +CLEAR_POP_RISE :f32: 0.18; // fraction of the window spent rising to the peak 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); + if t < CLEAR_POP_RISE { + return 1.0 + CLEAR_POP_A * (t / CLEAR_POP_RISE); } peak := 1.0 + CLEAR_POP_A; - u := (t - 0.30) / 0.70; + u := (t - CLEAR_POP_RISE) / (1.0 - CLEAR_POP_RISE); peak * (1.0 - u * u) } diff --git a/goldens/p6_fx_match.png b/goldens/p6_fx_match.png index bf323d6..460d5d7 100644 Binary files a/goldens/p6_fx_match.png and b/goldens/p6_fx_match.png differ diff --git a/main.sx b/main.sx index 7143d8a..ea1e11d 100644 --- a/main.sx +++ b/main.sx @@ -180,10 +180,15 @@ frame :: () { g_pipeline.dispatch_event(ev); } - // Advance the in-flight move animation by this frame's delta before rendering, - // so the board view draws the timeline slice for the current wall-clock time. - if g_anim != null { g_anim.tick(g_delta_time); } - if g_fx != null { g_fx.tick(g_delta_time); } + // Advance the in-flight move animation + its match FX by this frame's delta + // before rendering, so the board view draws the timeline slice for the current + // wall-clock time. Capture mode pins the animation clock (M3TE_ANIM_TIME); + // while pinned the move/FX timelines stay frozen at the phase the startup + // hooks set, so the FX-match scene (M3TE_FX) screenshots identically each run. + if g_motion == null or !g_motion.pinned { + 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 @@ -326,6 +331,30 @@ main :: () -> void { } } + // Match-FX capture hook (P11.1). The bursts/popups spawn off a committed move, + // which the sim can't script (no public touch injection), so M3TE_FX forces a + // representative match at startup the same way a swipe would: it commits the + // n-th currently-legal swap (1-based, clamped; =1 is the first) via the normal + // plan_and_commit path, then begins the move timeline + its FX. M3TE_ANIM_TIME + // pins the phase — advancing both to that time, after which the frozen frame + // loop holds them there — so the burst + "+points" popup screenshot identically + // every run. A larger M3TE_ANIM_TIME lands past the timeline, capturing the + // settled board with the FX fully pruned. Startup-only and unset → fully live. + if fx := read_env("M3TE_FX") { + swaps := legal_swaps(g_board); + if swaps.len > 0 { + n := parse_s64(fx); + if n < 1 { n = 1; } + if n > swaps.len { n = swaps.len; } + sw := swaps.items[n - 1]; + mv := plan_and_commit(g_board, sw.a, sw.b); + g_anim.begin(mv); + g_fx.begin(@mv); + g_anim.tick(g_motion.clock); + g_fx.tick(g_motion.clock); + } + } + // Level-state capture hooks (P7.2): override the goal / move budget so a // terminal status can be screenshot without scripting a swipe. M3TE_TARGET=0 // makes the fresh board read WON immediately (score 0 ≥ goal 0);