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

@@ -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`