P17.2: organic fall — per-column stagger (cascade pour)

render_fall now offsets each COLUMN's drop START by a small bounded delay
(fall_stagger_t) so a refilled/collapsed row pours in as a left-to-right cascade
instead of every gem snapping down in one flat lockstep row. Column col waits
FALL_STAGGER_MAX (0.30) * col/7 of the fall window, then falls over the remaining
1 - 0.30, with that local progress fed through ease_in_cubic so each column still
accelerates under gravity within its own window.

Bounded by construction: the last column lands EXACTLY at t=1 and every earlier
column strictly before it, so no gem is ever left mid-air at the segment end — the
seam to the next round / settled board stays invisible and move.final is untouched.

FALL_ANIM_DUR (0.22s) and the timeline helpers (phase/total/cascade_rounds_started)
are unchanged, so the per-round cascade-cue timing snapshots don't churn and live
per-round audio is unaffected. Render-only — no board.sx model change.

tests/easing.sx pins fall_stagger_t: f(0)=0, f(1)=1 across all columns (no gem
unlanded), per-column monotonicity, and the mid-fall cascade ordering (each later
column strictly behind the one before). tests/anim_plan.sx (final==model,
contiguity) stays green.

Golden goldens/p17_stagger.png: M3TE_FX=11 (depth-5 cascade, seed 1337) pinned at
M3TE_ANIM_TIME=1.91 — round 4 refills columns 2-7 by one cell each, so the top row
reads as a left-to-right staircase (vs the pre-stagger flat row in p17_fall.png).
This commit is contained in:
swipelab
2026-06-06 11:35:36 +03:00
parent 8d4e7acd2b
commit 02d856275c
6 changed files with 98 additions and 3 deletions

View File

@@ -309,6 +309,41 @@ swap→(clear,fall)\* timeline (swap 0.16, then clear 0.14 + fall 0.22 per round
`1.51` lands squarely in round 3's fall. The change is render-only — no `board.sx`
model change, and normal play is byte-identical apart from the fall's motion curve.
### Organic fall — per-column stagger (P17.2)
The fall no longer drops a whole row in lockstep: `render_fall` offsets each
COLUMN's drop START by a small bounded delay (`fall_stagger_t`), so a refilled row
pours in as a left-to-right cascade. Column `col` waits `FALL_STAGGER_MAX·col/7`
(`FALL_STAGGER_MAX = 0.30`) of the fall window, then falls over the remaining
`1 - 0.30`, feeding that local progress through `ease_in_cubic` so each column still
accelerates under gravity within its own window. The LAST column lands EXACTLY at
`t=1` and every earlier column strictly before it, so NO gem is ever left mid-air at
the segment end — the seam to the next round / settled board stays invisible and
`move.final` is untouched (`tests/anim_plan.sx` contiguity stays green;
`tests/easing.sx` pins `fall_stagger_t`'s `f(0)=0`, `f(1)=1`, monotonicity, and the
mid-fall cascade ordering). `FALL_ANIM_DUR` (0.22 s) is unchanged, so the per-round
cascade-cue snapshots don't churn.
The cleanest tell is a round where several adjacent columns refill the SAME
distance: in lockstep their gems share one height (a flat row); staggered, they
form a diagonal. On seed 1337, `M3TE_FX=11` **round 4** refills columns 27 by one
cell each (its fall window `[1.74, 1.96)` s); at `1.91` the leading column has just
landed while the trailing column is still ~⅔ cell high, so the top row reads as a
left-to-right staircase instead of a flat band (contrast `goldens/p17_fall.png`,
which is pre-stagger lockstep):
```bash
# Refilled row pouring in as a staggered cascade: goldens/p17_stagger.png
env SIMCTL_CHILD_M3TE_FX=11 SIMCTL_CHILD_M3TE_ANIM_TIME=1.91 \
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
# Same cascade past the timeline — every gem landed exactly on the model board:
env SIMCTL_CHILD_M3TE_FX=11 SIMCTL_CHILD_M3TE_ANIM_TIME=3.0 \
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
```
The change is render-only — no `board.sx` model change, and normal play is
byte-identical apart from the fall's per-column timing.
## Audio bank (P10) — final model
The SFX bank (`audio.sx`) is a purely additive layer over iOS **System Sound

View File

@@ -97,6 +97,26 @@ bad_swap_bounce :: (t: f32) -> f32 {
BADSWAP_LUNGE_AMP * (1.0 - spring(u))
}
// Per-column fall stagger (P17.2): within the fall window, each column starts its
// drop at a small BOUNDED delay so a refilled/collapsed row pours in as a cascade
// instead of every gem snapping down in one flat lockstep row. Column `col` waits
// FALL_STAGGER_MAX * col/(BOARD_COLS-1) of the window, then falls over the
// remaining `1 - FALL_STAGGER_MAX`, so the LAST column lands EXACTLY at t==1 and
// every earlier column lands strictly before it — no gem is ever left mid-air when
// the segment ends (the seam to the next round / settled board stays invisible).
// Returns the column's LOCAL 0..1 progress; render_fall feeds it through
// ease_in_cubic so each column still accelerates under gravity within its window.
// `tests/easing.sx` pins f(0)=0, f(1)=1, monotonicity, and the cascade ordering.
FALL_STAGGER_MAX :f32: 0.30;
fall_stagger_t :: (t: f32, col: s64) -> f32 {
delay := FALL_STAGGER_MAX * (cast(f32) col / cast(f32) (BOARD_COLS - 1));
window := 1.0 - FALL_STAGGER_MAX;
lt := (t - delay) / window;
if lt <= 0.0 { return 0.0; }
if lt >= 1.0 { return 1.0; }
lt
}
// 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`

View File

@@ -531,16 +531,19 @@ BoardView :: struct {
// Fall segment: every gem of the round's settled board accelerates under
// gravity from its source row (above the board for refills) down to its
// destination cell. ease_in_cubic pins f(1)=1, so each gem lands exactly on
// its cell and the seam to the next round / settled board stays invisible.
// destination cell. Each COLUMN's drop starts at a small staggered delay
// (fall_stagger_t) so a refilled row pours in as a cascade rather than a flat
// lockstep row; ease_in_cubic pins each column's f(1)=1, and fall_stagger_t
// guarantees every column reaches 1 by t==1, so each gem lands exactly on its
// cell and the seam to the next round / settled board stays invisible.
render_fall :: (self: *BoardView, ctx: *RenderContext, rd: *AnimRound, inset: f32, dim: f32, t: f32) {
te := ease_in_cubic(t);
for 0..BOARD_CELLS: (i) {
g := rd.after[i];
if g == .empty { continue; }
col := i % BOARD_COLS;
drow := i / BOARD_COLS;
src := rd.src[i];
te := ease_in_cubic(fall_stagger_t(t, col));
cur_row := cast(f32) src + (cast(f32) drow - cast(f32) src) * te;
gf := self.gem_frame(cast(f32) col, cur_row, inset, dim);
self.draw_gem(ctx, gf, cast(s64) g);

BIN
goldens/p17_stagger.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

View File

@@ -141,6 +141,41 @@ main :: () -> s32 {
if !bb_overshoot_bounded { fails += 1; }
if !bb_settles { fails += 1; }
// 6. Per-column fall stagger (P17.2): the fall window offsets each column's drop
// START by a BOUNDED delay so a refilled row pours in as a cascade, yet EVERY
// column still lands EXACTLY on its cell by the segment end. Lock: at t==0 no
// column has moved; at t==1 EVERY column has reached local progress 1 (no gem
// left mid-air — the seam to the next round stays invisible); per-column local
// progress is monotonic in t; and MID-fall the columns form a cascade — each
// later column has made STRICTLY LESS progress than the one before (its drop
// starts later), the opposite of a flat lockstep row sharing one progress.
print("== fall stagger bounded ==\n");
stg_t0 := true; stg_t1 := true;
for 0..BOARD_COLS: (c) {
if fall_stagger_t(0.0, c) != 0.0 { stg_t0 = false; }
if fall_stagger_t(1.0, c) != 1.0 { stg_t1 = false; }
}
stg_cascade := true;
for 1..BOARD_COLS: (c) {
if !(fall_stagger_t(0.5, c) < fall_stagger_t(0.5, c - 1)) { stg_cascade = false; }
}
stg_mono := true;
for 0..BOARD_COLS: (c) {
pp := fall_stagger_t(0.0, c);
for 1..21: (i) {
tt := cast(f32) i / 20.0;
vv := fall_stagger_t(tt, c);
if vv < pp - 0.000001 { stg_mono = false; }
pp = vv;
}
}
print("stagger_t0 {} stagger_t1 {} stagger_cascade {} stagger_mono {}\n",
stg_t0, stg_t1, stg_cascade, stg_mono);
if !stg_t0 { fails += 1; }
if !stg_t1 { fails += 1; }
if !stg_cascade { fails += 1; }
if !stg_mono { fails += 1; }
if fails == 0 {
print("ok: easing toolkit endpoints locked + amplitudes bounded\n");
return 0;

View File

@@ -8,4 +8,6 @@ back_overshoots true back_bounded true spring_overshoots true spring_bounded tru
squash_moves true squash_two_sided true squash_bounded true
== illegal-swap bounce ==
bounce_ends true peak_amp true peak_loc true overshoots true overshoot_bounded true settles true
== fall stagger bounded ==
stagger_t0 true stagger_t1 true stagger_cascade true stagger_mono true
ok: easing toolkit endpoints locked + amplitudes bounded