P16.2: organic illegal swap — springy bounce-back (+ M3TE_BADSWAP hook)

render_swap's rejected-swap branch now drives the two gems with a P15.1
spring-based bounce-back (bad_swap_bounce): a quick lunge toward the
neighbour, then a damped spring home that overshoots rest by a bounded
amount and settles to exactly 0. f(0)=f(1)=0, so the move stays purely
visual — board byte-identical to pre-swap, no score/move spent.

- board_anim.sx: add bad_swap_bounce envelope (lunge via ease_out_cubic,
  settle via 1 - spring(u)); BADSWAP_LUNGE_T/AMP constants.
- board_view.sx: replace the linear ping-out illegal branch with the bounce.
- main.sx: add illegal_swaps (complement of legal_swaps, same row-major
  order) + the startup-only M3TE_BADSWAP=n capture hook; mirrors M3TE_FX.
- tests/easing.sx: append bounce-envelope assertions (endpoints, single
  lunge peak + location, damped settle); regenerate expected snapshot.
- README.md: document the M3TE_BADSWAP recipe + goldens/p16_badswap.png.

Gate green: ios-sim build links, 22 logic snapshots pass (anim_plan model
invariants unchanged; SWAP_ANIM_DUR untouched so cascade-cue snapshots do
not churn).
This commit is contained in:
swipelab
2026-06-06 11:01:45 +03:00
parent f9feecc8e2
commit 4d06097b08
7 changed files with 144 additions and 5 deletions

View File

@@ -240,6 +240,41 @@ env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0 \
3-match in column 5; at `M3TE_ANIM_TIME=0.10` the red lands ~8 % left of col-5
and the green ~12 % right of col-6 (every unswapped gem stays centered).
### Organic illegal swap (P16.2)
A *rejected* swap no longer pings flatly out and back: `render_swap` now drives the
two gems with `bad_swap_bounce` (a P15.1 `spring`-based envelope), so they lunge
toward each other then spring home, overshooting rest by a bounded amount before
settling. The curve pins `f(0)=0` and `f(1)=0`, so the move stays purely visual —
both `t=0` and `t=1` are the rest pose, the board is byte-identical to pre-swap, no
move or score is spent. The envelope (endpoints, single lunge peak + location,
damped settle) is locked headlessly by `tests/easing.sx`.
The bounce only plays for an *illegal* swap, which the sim can't script, so one more
env hook forces a known rejected pair at startup — combine it with `M3TE_ANIM_TIME`
to freeze the phase:
- `M3TE_BADSWAP=<n>` commits the **n-th currently-ILLEGAL orthogonally-adjacent
pair** — the complement of `legal_swaps`, enumerated in the SAME stable row-major
order (right-before-down), 1-based + clamped — through the normal `plan_and_commit`
path (which reverts the illegal swap), then begins the move timeline so the
swap-segment bounce can be screenshot. No FX begins (a rejected swap clears
nothing). Startup-only and guarded by the var, so normal play is byte-identical
with it unset.
The lunge peak is at swap-phase `t = BADSWAP_LUNGE_T (0.36)`, i.e. animation time
`0.36 × SWAP_ANIM_DUR (0.16) ≈ 0.0576 s`. For seed 1337, `M3TE_BADSWAP=41` is the
mid-board horizontal pair `(2,3)↔(3,3)`:
```bash
# Springy lunge caught at its extreme (gems nudged toward each other): goldens/p16_badswap.png
env SIMCTL_CHILD_M3TE_BADSWAP=41 SIMCTL_CHILD_M3TE_ANIM_TIME=0.0576 \
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
# Same rejected pair at exact rest (t=0) — gems sit dead-on their pre-swap cells:
env SIMCTL_CHILD_M3TE_BADSWAP=41 SIMCTL_CHILD_M3TE_ANIM_TIME=0 \
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
```
## Audio bank (P10) — final model
The SFX bank (`audio.sx`) is a purely additive layer over iOS **System Sound
@@ -365,6 +400,6 @@ sips -z 128 768 --setProperty format png <generated>.png --out assets/gems/gems.
```
After any art change, re-capture the affected goldens with the deterministic hooks
above (`M3TE_ANIM_TIME` / `M3TE_SELECT` / `M3TE_FX` / `M3TE_TARGET` /
above (`M3TE_ANIM_TIME` / `M3TE_SELECT` / `M3TE_FX` / `M3TE_BADSWAP` / `M3TE_TARGET` /
`M3TE_MOVE_LIMIT` / `M3TE_RESTART`) and state per golden whether it was refreshed,
left unchanged, or removed.

View File

@@ -76,6 +76,27 @@ squash_envelope :: (t: f32) -> f32 {
sin(TAU * SQUASH_OSC * t) * d * d
}
// Illegal-swap bounce-back envelope (P16.2): the displacement FRACTION the two
// swapped gems travel toward the rejected neighbour over the swap segment. A quick
// lunge OUT to BADSWAP_LUNGE_AMP (the single peak, at t==BADSWAP_LUNGE_T), then a
// damped spring HOME that slightly overshoots past rest and settles to EXACTLY 0.
// f(0)=0 and f(1)=0, so the swap stays purely visual — t==0 and t==1 are both the
// rest pose. The settle reuses P15.1's `spring`: `1 - spring(u)` is the spring's
// own (1-u)^3·cos envelope, which carries the value from the peak down through 0,
// a bounded dip below rest, and back to exactly 0 — so the wobble matches the rest
// of the organic pass and f(1) is pinned, not merely approached.
BADSWAP_LUNGE_T :f32: 0.36; // where the lunge reaches its peak
BADSWAP_LUNGE_AMP :f32: 0.42; // how far toward the neighbour (cell fraction)
bad_swap_bounce :: (t: f32) -> f32 {
if t <= 0.0 { return 0.0; }
if t >= 1.0 { return 0.0; }
if t < BADSWAP_LUNGE_T {
return BADSWAP_LUNGE_AMP * ease_out_cubic(t / BADSWAP_LUNGE_T);
}
u := (t - BADSWAP_LUNGE_T) / (1.0 - BADSWAP_LUNGE_T);
BADSWAP_LUNGE_AMP * (1.0 - spring(u))
}
// 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

@@ -399,7 +399,8 @@ BoardView :: struct {
// Swap segment: the board sits still (pre-swap) except the two swapped gems,
// which slide between their cells. A legal swap slides fully (a→b, b→a); an
// illegal one pings out to the neighbour and back, ending where it started.
// illegal one lunges toward the neighbour and springs back to rest, ending
// exactly where it started.
render_swap :: (self: *BoardView, ctx: *RenderContext, mv: *AnimMove, inset: f32, dim: f32, t: f32) {
ai := Board.idx(mv.a.col, mv.a.row);
bi := Board.idx(mv.b.col, mv.b.row);
@@ -420,10 +421,12 @@ BoardView :: struct {
// into place. ease_out_back pins f(0)=0 and f(1)=1, so t==0 is the rest
// pose and t==1 lands byte-on-cell — the swap stays purely visual.
p = ease_out_back(t);
} else if t < 0.5 {
p = ease_out_cubic(t * 2.0);
} else {
p = ease_out_cubic((1.0 - t) * 2.0);
// Rejected swap: a springy, slightly-damped bounce-back. The gems lunge
// toward each other then spring home, overshooting rest by a bounded
// amount before settling. bad_swap_bounce pins f(0)=0 and f(1)=0, so the
// move stays purely visual — the board is byte-identical to pre-swap.
p = bad_swap_bounce(t);
}
afc := cast(f32) mv.a.col; afr := cast(f32) mv.a.row;

BIN
goldens/p16_badswap.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

50
main.sx
View File

@@ -165,6 +165,34 @@ parse_s64 :: (s: string) -> s64 {
v
}
// The orthogonally-adjacent pairs that are currently ILLEGAL — the exact
// COMPLEMENT of `legal_swaps`, enumerated in the SAME stable row-major order (each
// cell's right neighbour before its down neighbour, each adjacency visited once).
// Drives the M3TE_BADSWAP capture hook, which needs a KNOWN rejected pair on the
// fixed seed to screenshot the springy bounce-back. Headless and read-only — the
// trial swaps inside `swap_legal` are reverted, so the board is left unchanged.
illegal_swaps :: (board: *Board) -> List(Swap) {
result := List(Swap).{};
for 0..BOARD_ROWS: (row) {
for 0..BOARD_COLS: (col) {
here := Cell.{ col = col, row = row };
if col + 1 < BOARD_COLS {
right := Cell.{ col = col + 1, row = row };
if !swap_legal(board, here, right) {
result.append(Swap.{ a = here, b = right });
}
}
if row + 1 < BOARD_ROWS {
down := Cell.{ col = col, row = row + 1 };
if !swap_legal(board, here, down) {
result.append(Swap.{ a = here, b = down });
}
}
}
}
result
}
frame :: () {
fc := g_plat.begin_frame();
g_delta_time = fc.delta_time;
@@ -376,6 +404,28 @@ main :: () -> void {
}
}
// Illegal-swap bounce capture hook (P16.2). The springy bounce-back plays only
// for a REJECTED swap, which the sim can't script (no public touch injection),
// so M3TE_BADSWAP forces one at startup the way a swipe would. It commits the
// n-th currently-ILLEGAL orthogonally-adjacent pair — the complement of
// legal_swaps, enumerated in the SAME row-major order, 1-based + clamped — via
// the normal plan_and_commit (which reverts an illegal swap: zero rounds, board
// byte-identical, no score/move spent), then begins the move timeline and ticks
// it to M3TE_ANIM_TIME so the swap-segment bounce screenshots identically. No FX
// begins — a rejected swap clears nothing. Startup-only and unset → fully live.
if bs := read_env("M3TE_BADSWAP") {
bad := illegal_swaps(g_board);
if bad.len > 0 {
n := parse_s64(bs);
if n < 1 { n = 1; }
if n > bad.len { n = bad.len; }
sw := bad.items[n - 1];
mv := plan_and_commit(g_board, sw.a, sw.b);
g_anim.begin(mv);
g_anim.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);

View File

@@ -113,6 +113,34 @@ main :: () -> s32 {
if !sq_two_sided { fails += 1; }
if !sq_bounded { fails += 1; }
// 5. Illegal-swap bounce-back (P16.2): the springy lunge-and-settle render_swap
// plays for a REJECTED swap. Lock its envelope end to end — rests at BOTH
// ends (f(0)=f(1)=0, so the move stays purely visual), a SINGLE lunge peak of
// exactly BADSWAP_LUNGE_AMP at BADSWAP_LUNGE_T, then a damped settle that
// overshoots past rest by a BOUNDED amount and leaves NO residual at t=1.
print("== illegal-swap bounce ==\n");
bb_ends := bad_swap_bounce(0.0) == 0.0 and bad_swap_bounce(1.0) == 0.0;
bb_mx : f32 = 0.0; bb_mx_t : f32 = 0.0; bb_mn : f32 = 0.0;
for 0..101: (i) {
t := cast(f32) i / 100.0;
v := bad_swap_bounce(t);
if v > bb_mx { bb_mx = v; bb_mx_t = t; }
if v < bb_mn { bb_mn = v; }
}
bb_peak_amp := approx(bb_mx, BADSWAP_LUNGE_AMP);
bb_peak_loc := fabs(bb_mx_t - BADSWAP_LUNGE_T) < 0.011;
bb_overshoots := bb_mn < -0.01; // springs PAST rest after the peak
bb_overshoot_bounded := bb_mn > -0.12; // but the recoil stays tasteful
bb_settles := approx(bad_swap_bounce(1.0), 0.0); // no residual displacement
print("bounce_ends {} peak_amp {} peak_loc {} overshoots {} overshoot_bounded {} settles {}\n",
bb_ends, bb_peak_amp, bb_peak_loc, bb_overshoots, bb_overshoot_bounded, bb_settles);
if !bb_ends { fails += 1; }
if !bb_peak_amp { fails += 1; }
if !bb_peak_loc { fails += 1; }
if !bb_overshoots { fails += 1; }
if !bb_overshoot_bounded { fails += 1; }
if !bb_settles { fails += 1; }
if fails == 0 {
print("ok: easing toolkit endpoints locked + amplitudes bounded\n");
return 0;

View File

@@ -6,4 +6,6 @@ ease_in true ease_in_out true ease_out_cubic true ease_in_quad true
back_overshoots true back_bounded true spring_overshoots true spring_bounded true spring_wobbles true
== squash envelope bounded ==
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
ok: easing toolkit endpoints locked + amplitudes bounded