P18.2: organic combine — staggered clear ripple (sx, iOS sim)
Within a clearing round the matched gems no longer all explode at once: each gem's pop (and its burst) START is offset by a bounded per-gem delay so the cells detonate as a ripple. - board_anim.sx: clear_ripple_t(t,u) mirrors fall_stagger_t's (t-delay)/window, delaying a gem's pop START by CLEAR_STAGGER_MAX*u (0.45 of the clear window). Bounded: every gem still reaches local 1 (scale 0) by t==1, so none is left mid-pop at the seam to the fall. clear_diag_span/clear_rank rank each matched gem 0..1 by diagonal (col+row) PER ROUND, so even a 3-match ripples across the full budget. - board_view.sx render_clear: feed each matched gem's ranked, staggered local t through the P18.1 clear_pop_scale (locked endpoints unchanged). - board_fx.sx: bursts carry the same per-gem delay so they ripple in lockstep with the pops. Per-round audio cue (P10.10) still fires once at t0, not per gem. - Model untouched (same cells cleared, same final board); CLEAR_ANIM_DUR fixed, so cascade-cue snapshots don't churn and M3TE_ANIM_TIME=0 still rests. - tests/easing.sx: pin clear_ripple_t endpoints, bounded completion by t==1, monotonicity, ripple ordering, and the diagonal rank. - goldens: add p18_stagger (M3TE_FX=3 @ 0.22); refresh p18_pop, p6_fx_match, p11_combo_deep (all pinned mid-clear, now showing the ripple).
50
README.md
@@ -413,10 +413,52 @@ env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0 \
|
||||
```
|
||||
|
||||
`goldens/p6_fx_match.png` (the P11.1 burst+popup reference, pinned at `0.22`) was
|
||||
refreshed for the new pop shape. The per-gem STAGGER of the explosions is the next
|
||||
step (P18.2); this step is the per-gem pop SHAPE only. The change is render-only —
|
||||
no `board.sx` model change, and normal play is byte-identical apart from the clear's
|
||||
pop curve.
|
||||
refreshed for the new pop shape. This step is the per-gem pop SHAPE only; the per-gem
|
||||
STAGGER of the explosions follows in P18.2. The change is render-only — no `board.sx`
|
||||
model change, and normal play is byte-identical apart from the clear's pop curve.
|
||||
|
||||
### Organic combine — staggered clear ripple (P18.2)
|
||||
|
||||
The matched gems no longer all explode at once: within a clearing round each gem's
|
||||
pop (and its burst) START is offset by a small BOUNDED delay so the cells detonate
|
||||
as a RIPPLE. `clear_ripple_t(t, u)` mirrors `fall_stagger_t`'s `(t-delay)/window`:
|
||||
a gem's normalized rank `u ∈ [0,1]` (its diagonal `col+row` position within the
|
||||
round's matched cells, lowest = `0`) delays its pop START by `CLEAR_STAGGER_MAX·u`
|
||||
(`0.45` of the clear window), then plays the P18.1 `clear_pop_scale` curve over the
|
||||
remaining `1 − CLEAR_STAGGER_MAX`. The rank is normalized PER ROUND (not across the
|
||||
board) so even a 3-match ripples across the full stagger budget. It is BOUNDED:
|
||||
every matched gem still reaches local `1` (scale `0`, fully cleared) by the clear
|
||||
segment's end — the last-to-start gem (`u=1`) lands exactly at `t==1` — so no gem is
|
||||
left mid-pop at the seam to the fall. The bursts (`board_fx.sx`) carry the SAME
|
||||
per-gem delay so they ripple in lockstep with the pops. `tests/easing.sx` pins the
|
||||
envelope (locked `f(0,·)=0` / `f(1,·)=1` endpoints, bounded completion by `t==1`,
|
||||
monotonicity, and the rank ordering).
|
||||
|
||||
INTRA-ROUND VISUAL ONLY: the per-round cascade audio (P10.10) is untouched — one
|
||||
ascending cue per round at the round's clear, NOT per gem — and the model is
|
||||
unchanged (same cells cleared, same final board). `CLEAR_ANIM_DUR` (`0.14` s) is
|
||||
unchanged, so the cascade-cue snapshots (`tests/cascade_rounds.sx` / `cascade_cue.sx`)
|
||||
don't churn and `M3TE_ANIM_TIME=0` still reproduces the resting board.
|
||||
|
||||
For `M3TE_FX=3` (the seed-1337 vertical red 3-match in column 5, rows 0–2) the clear
|
||||
window is `[0.16, 0.30)` s; at `M3TE_ANIM_TIME=0.22` the ripple is at its clearest —
|
||||
the TOP gem is collapsing, the MIDDLE is mid-burst, and the BOTTOM is still full-size
|
||||
(not yet started):
|
||||
|
||||
```bash
|
||||
# Staggered clear ripple (top collapsing / middle bursting / bottom not yet):
|
||||
# goldens/p18_stagger.png
|
||||
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0.22 \
|
||||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||||
# Past the timeline — all three cells cleared per the model, board continues:
|
||||
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=2.0 \
|
||||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||||
```
|
||||
|
||||
`goldens/p18_pop.png` (`M3TE_FX=3` at `0.21`), `goldens/p6_fx_match.png` (`M3TE_FX=3`
|
||||
at `0.22`), and `goldens/p11_combo_deep.png` (`M3TE_FX=11` at `0.22`) were refreshed
|
||||
— each was pinned mid-clear, so each now shows the staggered ripple instead of the
|
||||
prior simultaneous pop.
|
||||
|
||||
### FPS counter — dev overlay (P20.1)
|
||||
|
||||
|
||||
@@ -136,6 +136,52 @@ round_land_time :: (k: s64, col: s64) -> f32 {
|
||||
+ fall_landing_frac(col) * FALL_ANIM_DUR
|
||||
}
|
||||
|
||||
// Per-gem clear ripple (P18.2): within a clearing round the matched gems pop as a
|
||||
// RIPPLE, not all at once. Each gem gets a normalized rank `u` in [0,1] (its
|
||||
// diagonal position within the round's matched cells, lowest-diagonal = 0), and
|
||||
// this offsets that gem's pop START by a BOUNDED delay so rank 0 pops first and
|
||||
// rank 1 last, yet EVERY gem still reaches local 1 (clear_pop_scale → scale 0,
|
||||
// fully cleared) by the clear segment's end — no gem is left mid-pop at the seam
|
||||
// to the fall. Returns the gem's LOCAL 0..1 progress, fed through clear_pop_scale
|
||||
// (whose locked endpoints keep the seam to the model board). Mirrors
|
||||
// fall_stagger_t's (t-delay)/window. `tests/easing.sx` pins f(0,.)/f(1,.), the
|
||||
// bounded completion by t==1, monotonicity, and the rank ordering.
|
||||
CLEAR_STAGGER_MAX :f32: 0.45;
|
||||
clear_ripple_t :: (t: f32, u: f32) -> f32 {
|
||||
delay := CLEAR_STAGGER_MAX * u;
|
||||
window := 1.0 - CLEAR_STAGGER_MAX;
|
||||
lt := (t - delay) / window;
|
||||
if lt <= 0.0 { return 0.0; }
|
||||
if lt >= 1.0 { return 1.0; }
|
||||
lt
|
||||
}
|
||||
|
||||
// The diagonal (col+row) extent of a round's matched cells — the span the ripple
|
||||
// ranks each matched gem across. `hi < lo` only if the mask is empty.
|
||||
ClearDiag :: struct { lo: s64; hi: s64; }
|
||||
clear_diag_span :: (m: *MatchMask) -> ClearDiag {
|
||||
lo : s64 = (BOARD_COLS - 1) + (BOARD_ROWS - 1) + 1;
|
||||
hi : s64 = -1;
|
||||
for 0..BOARD_CELLS: (i) {
|
||||
if m.cells[i] {
|
||||
d := (i % BOARD_COLS) + (i / BOARD_COLS);
|
||||
if d < lo { lo = d; }
|
||||
if d > hi { hi = d; }
|
||||
}
|
||||
}
|
||||
ClearDiag.{ lo = lo, hi = hi }
|
||||
}
|
||||
|
||||
// Normalized rank (0..1) of cell (col,row) within a round's matched diagonal span
|
||||
// — 0 for the earliest-popping (lowest-diagonal) gem, 1 for the last. Normalizing
|
||||
// PER ROUND (not across the board) lets even a small 3-match ripple across the
|
||||
// full stagger budget. A degenerate span (every matched cell on one diagonal)
|
||||
// ranks all gems 0, so they pop together rather than dividing by zero.
|
||||
clear_rank :: (span: ClearDiag, col: s64, row: s64) -> f32 {
|
||||
if span.hi <= span.lo { return 0.0; }
|
||||
cast(f32) ((col + row) - span.lo) / cast(f32) (span.hi - span.lo)
|
||||
}
|
||||
|
||||
// One recorded cascade round. `before` is the board at the round's start (the
|
||||
// swapped board for round 0, the previous round's `after` otherwise — never has
|
||||
// holes). `matched` flags the cells cleared this round (they scale out). `src`
|
||||
|
||||
13
board_fx.sx
@@ -223,15 +223,22 @@ BoardFx :: struct {
|
||||
rd := @mv.rounds.items[k];
|
||||
t0 := SWAP_ANIM_DUR + cast(f32) k * (CLEAR_ANIM_DUR + FALL_ANIM_DUR);
|
||||
extra := depth_boost + FX_BURST_COMBO * cast(f32) min(k, 2);
|
||||
// Stagger each burst's START by its gem's clear-ripple rank so the
|
||||
// bursts ripple in lockstep with the staggered pops (P18.2) instead of
|
||||
// one simultaneous flash. The round's audio cue still fires once at t0.
|
||||
span := clear_diag_span(@rd.matched);
|
||||
for 0..BOARD_CELLS: (idx) {
|
||||
if rd.matched.cells[idx] {
|
||||
g := rd.before[idx];
|
||||
if g != .empty {
|
||||
col := idx % BOARD_COLS;
|
||||
row := idx / BOARD_COLS;
|
||||
rdelay := CLEAR_STAGGER_MAX * clear_rank(span, col, row) * CLEAR_ANIM_DUR;
|
||||
self.particles.append(FxParticle.{
|
||||
col = cast(f32) (idx % BOARD_COLS) + 0.5,
|
||||
row = cast(f32) (idx / BOARD_COLS) + 0.5,
|
||||
col = cast(f32) col + 0.5,
|
||||
row = cast(f32) row + 0.5,
|
||||
tint = cast(s64) g,
|
||||
delay = t0,
|
||||
delay = t0 + rdelay,
|
||||
age = 0.0,
|
||||
life = FX_BURST_LIFE,
|
||||
peak = FX_BURST_BASE + extra,
|
||||
|
||||
@@ -494,13 +494,18 @@ BoardView :: struct {
|
||||
// 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, k: s64, e: f32, dim: f32, t: f32) {
|
||||
pop := clear_pop_scale(t);
|
||||
span := clear_diag_span(@rd.matched);
|
||||
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] {
|
||||
// Ripple: each matched gem's pop START is offset by its diagonal
|
||||
// rank within the round (clear_ripple_t), so the matched cells
|
||||
// explode as a wave instead of simultaneously; every gem still
|
||||
// reaches scale 0 by t==1, keeping the seam to the fall clean.
|
||||
pop := clear_pop_scale(clear_ripple_t(t, clear_rank(span, col, row)));
|
||||
gf := self.gem_frame_scaled(col, row, dim, pop);
|
||||
self.draw_gem(ctx, gf, cast(s64) g);
|
||||
} else {
|
||||
|
||||
|
Before Width: | Height: | Size: 3.3 MiB After Width: | Height: | Size: 3.2 MiB |
|
Before Width: | Height: | Size: 3.2 MiB After Width: | Height: | Size: 3.2 MiB |
BIN
goldens/p18_stagger.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
|
Before Width: | Height: | Size: 3.2 MiB After Width: | Height: | Size: 3.2 MiB |
@@ -210,6 +210,57 @@ main :: () -> s32 {
|
||||
if !rlt_col_mono { fails += 1; }
|
||||
if !rlt_round_after { fails += 1; }
|
||||
|
||||
// 8. Per-gem clear ripple (P18.2): within a clearing round each matched gem's
|
||||
// pop START is offset by a BOUNDED delay (its rank u in [0,1] across the
|
||||
// round's matched cells) so the matched cells explode as a ripple, yet EVERY
|
||||
// gem still completes its pop by the segment end. Lock: at t==0 no rank has
|
||||
// started; at t==1 EVERY rank has reached local 1 (clear_pop_scale → scale 0,
|
||||
// fully cleared — no gem left mid-pop at the seam to the fall); per-rank local
|
||||
// progress is monotonic in t; and MID-clear a HIGHER rank has made STRICTLY
|
||||
// LESS progress than a lower one (its pop starts later) — the ripple, the
|
||||
// opposite of a flat simultaneous clear. `clear_rank` then ranks each matched
|
||||
// gem 0..1 by diagonal across the round (lowest-diagonal = 0, the first to pop).
|
||||
print("== clear ripple bounded ==\n");
|
||||
rip_t0 := true; rip_t1 := true;
|
||||
for 0..6: (j) {
|
||||
u := cast(f32) j / 5.0;
|
||||
if clear_ripple_t(0.0, u) != 0.0 { rip_t0 = false; }
|
||||
if clear_ripple_t(1.0, u) != 1.0 { rip_t1 = false; }
|
||||
}
|
||||
rip_ripple := true;
|
||||
for 1..6: (j) {
|
||||
u := cast(f32) j / 5.0;
|
||||
up := cast(f32) (j - 1) / 5.0;
|
||||
if !(clear_ripple_t(0.5, u) < clear_ripple_t(0.5, up)) { rip_ripple = false; }
|
||||
}
|
||||
rip_mono := true;
|
||||
for 0..6: (j) {
|
||||
u := cast(f32) j / 5.0;
|
||||
pp := clear_ripple_t(0.0, u);
|
||||
for 1..21: (i) {
|
||||
tt := cast(f32) i / 20.0;
|
||||
vv := clear_ripple_t(tt, u);
|
||||
if vv < pp - 0.000001 { rip_mono = false; }
|
||||
pp = vv;
|
||||
}
|
||||
}
|
||||
mm : MatchMask = ---;
|
||||
for 0..BOARD_CELLS: (i) { mm.cells[i] = false; }
|
||||
mm.cells[Board.idx(5, 0)] = true; // diagonal 5 — first to pop
|
||||
mm.cells[Board.idx(5, 1)] = true; // diagonal 6
|
||||
mm.cells[Board.idx(5, 2)] = true; // diagonal 7 — last to pop
|
||||
sp := clear_diag_span(@mm);
|
||||
rip_rank := approx(clear_rank(sp, 5, 0), 0.0)
|
||||
and approx(clear_rank(sp, 5, 1), 0.5)
|
||||
and approx(clear_rank(sp, 5, 2), 1.0);
|
||||
print("ripple_t0 {} ripple_t1 {} ripple_cascade {} ripple_mono {} ripple_rank {}\n",
|
||||
rip_t0, rip_t1, rip_ripple, rip_mono, rip_rank);
|
||||
if !rip_t0 { fails += 1; }
|
||||
if !rip_t1 { fails += 1; }
|
||||
if !rip_ripple { fails += 1; }
|
||||
if !rip_mono { fails += 1; }
|
||||
if !rip_rank { fails += 1; }
|
||||
|
||||
if fails == 0 {
|
||||
print("ok: easing toolkit endpoints locked + amplitudes bounded\n");
|
||||
return 0;
|
||||
|
||||
@@ -12,4 +12,6 @@ bounce_ends true peak_amp true peak_loc true overshoots true overshoot_bounded t
|
||||
stagger_t0 true stagger_t1 true stagger_cascade true stagger_mono true
|
||||
== landing instant ==
|
||||
landing_first true landing_last true landing_mono true landing_seam true landtime_col_mono true landtime_round_after true
|
||||
== clear ripple bounded ==
|
||||
ripple_t0 true ripple_t1 true ripple_cascade true ripple_mono true ripple_rank true
|
||||
ok: easing toolkit endpoints locked + amplitudes bounded
|
||||
|
||||