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:
37
README.md
37
README.md
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
BIN
goldens/p16_badswap.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.2 MiB |
50
main.sx
50
main.sx
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user