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.