Screenshotting immediately after 'simctl launch' grabs the blank white launch screen, not the rendered board. Add an explicit 'sleep 2' between launch and screenshot in the main capture recipe, and state a once-stated render-readiness rule (near the first recipe) that every pinned-launch section below references, so a clean-checkout reader reproduces the board. Docs-only: no .sx/asset/golden change.
694 lines
39 KiB
Markdown
694 lines
39 KiB
Markdown
# m3te
|
||
|
||
A match-3 game written entirely in the **sx** language, targeting **iOS** first.
|
||
|
||
- Game logic, rendering, input, and UI are all authored in sx.
|
||
- Art (palettes, sprite sheets) is produced as real assets.
|
||
- Verification gate: sx logic tests pass AND the iOS app builds & launches in the Simulator.
|
||
|
||
Development is driven by the multi-agent `flow` (Product Owner → Worker → Reviewer → Observer).
|
||
|
||
## Verification gate
|
||
|
||
The gate has two halves. Both must pass. The sx compiler used below lives at
|
||
`/Users/agra/projects/sx/zig-out/bin/sx` (override the runner's binary with the
|
||
`SX` env var). Run everything from the repo root.
|
||
|
||
### 1. Logic tests
|
||
|
||
Pure-sx logic tests run under `sx` and have their stdout + exit code diffed
|
||
against committed snapshots in `tests/expected/`. A failed assertion exits the
|
||
process non-zero, so it fails the runner (and the gate).
|
||
|
||
```bash
|
||
bash tools/run_tests.sh
|
||
```
|
||
|
||
- A test is any `tests/<name>.sx` that has a `tests/expected/<name>.exit`
|
||
marker; `tests/test.sx` (the `expect` assert helper) has no marker, so it is
|
||
not itself run.
|
||
- Regenerate snapshots after an intentional change: `bash tools/run_tests.sh --update`.
|
||
|
||
### 2. iOS Simulator build + launch
|
||
|
||
Build the app for the simulator, then install/launch it on an available device
|
||
and screenshot the rendered scene — the brighter **candy** match-3 board: a soft
|
||
pink→lavender→blue gradient background, an 8×8 grid of glossy candy gems on cream
|
||
cell tiles, and a grape candy HUD card (score / moves) above the grid.
|
||
|
||
```bash
|
||
# Build the .app bundle (sx-out/ios/M3te.app):
|
||
/Users/agra/projects/sx/zig-out/bin/sx build --target ios-sim main.sx
|
||
|
||
# Discover an available simulator — do NOT hardcode a udid:
|
||
xcrun simctl list devices available
|
||
# e.g. capture the first available device's UDID into $udid:
|
||
udid=$(xcrun simctl list devices available | grep -Eo '[0-9A-Fa-f-]{36}' | head -1)
|
||
|
||
# Boot it (skip if already "Booted") and bring the Simulator window up:
|
||
xcrun simctl boot "$udid" || true
|
||
open -a Simulator
|
||
|
||
# Install, launch (bundle id co.swipelab.m3te), wait for the board to render, then
|
||
# screenshot. The wait is REQUIRED: screenshotting immediately after `launch` grabs
|
||
# the blank white launch screen, before the scene has drawn. ~2 s is ample; poll the
|
||
# screenshot if you want it tighter.
|
||
xcrun simctl install booted sx-out/ios/M3te.app
|
||
xcrun simctl launch booted co.swipelab.m3te
|
||
sleep 2
|
||
xcrun simctl io booted screenshot /tmp/m3te.png
|
||
```
|
||
|
||
> **Render-readiness wait — applies to every sim capture recipe in this README.**
|
||
> Each deterministic section below shows only the pinned `launch` command (it swaps a
|
||
> different scene in for the `launch` line above); capture it exactly as here — after
|
||
> the launch, **wait for the first rendered frame** (`sleep 2`, or poll the
|
||
> screenshot) **before** `xcrun simctl io booted screenshot`. Skipping the wait
|
||
> captures the white launch screen, not the board.
|
||
|
||
The screenshot should match `goldens/p6_idle_t0.png` (the resting candy board),
|
||
modulo the status-bar clock — pixel-exact equality is not required; compare the
|
||
board + HUD region, not the top status strip. A tap selects a cell; a swipe
|
||
between two adjacent gems commits a swap (legal swaps cascade + score, illegal
|
||
ones ping back). The deterministic capture hooks below pin a chosen scene so each
|
||
golden reproduces from a clean checkout.
|
||
|
||
> The earlier `p0_*` goldens (a pre-board orange quad over a blue clear, from the
|
||
> P0 foundation slice) were removed in P13.1: the app no longer renders that
|
||
> scene, so they could only ever mislead.
|
||
|
||
### Deterministic animation capture (P6.3)
|
||
|
||
The per-gem idle loop (`gem_anim.sx`) is always-on, so a plain screenshot is
|
||
time-dependent. Two environment variables pin the visual state so the board can
|
||
be captured reproducibly. The simulator forwards any `SIMCTL_CHILD_*` variable to
|
||
the launched app, so prefix them on the `simctl launch`:
|
||
|
||
- `M3TE_ANIM_TIME=<seconds>` freezes the animation clock at that phase. **`t=0`
|
||
is the resting board** — every gem sits at its static pose, so the pre-P6.3
|
||
goldens reproduce unchanged. A larger `t` (e.g. `1.0`) shows the mid-breath
|
||
idle deformation. The select/land reactions read this same pinned phase.
|
||
- `M3TE_SELECT=<cellIndex 0..63>` (= `row*8 + col`) force-selects a cell at
|
||
startup, so the selection highlight + pop can be captured without a tap.
|
||
|
||
> **Every `M3TE_*` pin is read once, at app startup.** A `simctl launch` against
|
||
> an already-running copy just foregrounds the existing process (the launch prints
|
||
> the *same* PID) and the new `SIMCTL_CHILD_*` value is ignored. So every
|
||
> multi-launch recipe below passes `--terminate-running-process` on each pinned
|
||
> launch — it kills the running copy first, so the fresh process re-reads the new
|
||
> pin. (`xcrun simctl terminate booted co.swipelab.m3te` before each relaunch does
|
||
> the same.) A *changed* PID between launches confirms the new pin took.
|
||
|
||
```bash
|
||
# Resting board (idle at rest): goldens/p6_idle_t0.png
|
||
SIMCTL_CHILD_M3TE_ANIM_TIME=0 xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
# Mid-breath idle: goldens/p6_idle_mid.png
|
||
SIMCTL_CHILD_M3TE_ANIM_TIME=1.0 xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
# Selection pop on cell (3,3): goldens/p6_select.png
|
||
env SIMCTL_CHILD_M3TE_ANIM_TIME=0.17 SIMCTL_CHILD_M3TE_SELECT=27 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
```
|
||
|
||
With no variable set the game runs fully live (the clock advances by
|
||
`delta_time`). `tests/gem_pose.sx` locks the `t==0`-rest invariant headlessly.
|
||
|
||
### Level-state capture (P7.2)
|
||
|
||
The win/lose banner and restart button are driven by the model's `level_status`
|
||
(score vs. goal vs. move budget). Three more env hooks force a terminal status
|
||
(or a restart) so the banner / restart states can be screenshot deterministically
|
||
without scripting a winning swipe — combine them with `M3TE_ANIM_TIME` to pin the
|
||
idle clock:
|
||
|
||
- `M3TE_TARGET=<n>` overrides the per-level score goal. `0` makes the fresh board
|
||
read **won** immediately (`score 0 ≥ goal 0`).
|
||
- `M3TE_MOVE_LIMIT=<n>` overrides the move budget. `0` makes it read **lost**
|
||
(budget spent below the goal).
|
||
- `M3TE_RESTART=<non-zero>` runs `board.restart` after the overrides, capturing
|
||
the fresh `in_progress` board the restart button produces.
|
||
|
||
```bash
|
||
# Win banner + restart over the board: goldens/p7_win.png
|
||
env SIMCTL_CHILD_M3TE_TARGET=0 SIMCTL_CHILD_M3TE_ANIM_TIME=0 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
# Lose banner ("OUT OF MOVES") + restart: goldens/p7_lose.png
|
||
env SIMCTL_CHILD_M3TE_MOVE_LIMIT=0 SIMCTL_CHILD_M3TE_ANIM_TIME=0 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
# Fresh in_progress board after restart: goldens/p7_restart.png
|
||
env SIMCTL_CHILD_M3TE_TARGET=0 SIMCTL_CHILD_M3TE_RESTART=1 SIMCTL_CHILD_M3TE_ANIM_TIME=0 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
```
|
||
|
||
While a banner is up the board freezes (only the restart button is live, per
|
||
P7.1's finished-level rule); `tests/banner_layout.sx` locks the restart button's
|
||
rect ↔ hit-test round-trip headlessly.
|
||
|
||
### Match-FX capture (P11.1)
|
||
|
||
The match bursts + score popup (`board_fx.sx`) only spawn off a *committed* move,
|
||
which the simulator can't script (there is no public touch injection). One more
|
||
env hook forces a representative match at startup so the FX can be screenshot
|
||
deterministically — combine it with `M3TE_ANIM_TIME` to freeze the phase:
|
||
|
||
- `M3TE_FX=<n>` commits the **n-th currently-legal swap** (1-based, clamped; `=1`
|
||
is the first) through the normal `plan_and_commit` path, then begins the move
|
||
timeline + its burst/popup FX. While `M3TE_ANIM_TIME` is set the move/FX
|
||
timelines are pinned at that phase (the frame loop holds them frozen), so the
|
||
burst and floating `+points` render identically every run. A larger
|
||
`M3TE_ANIM_TIME` lands past the timeline, capturing the settled board with the
|
||
FX fully pruned. Startup-only and guarded by the var, so normal play is
|
||
untouched.
|
||
|
||
The legal-swap order is the fixed enumeration in `tests/expected/swap_legality.stdout`
|
||
(row-major, right-before-down). For seed 1337, `M3TE_FX=3` is the vertical red
|
||
3-match used by the golden, and `M3TE_FX=11` is a **depth-5 cascade** (the deepest
|
||
on this seed) used to capture the escalated combo emphasis (next section).
|
||
|
||
```bash
|
||
# Punchy match burst + "+30" popup, pinned mid-clear: goldens/p6_fx_match.png
|
||
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0.22 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
# Same match, later phase — FX fully gone over the settled board (no golden):
|
||
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=2.0 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
```
|
||
|
||
### Escalating combo emphasis (P11.2)
|
||
|
||
The combo FX escalates with cascade depth (`mv.rounds.len`), the SAME depth the
|
||
cascade SFX (`play_cascade`) steps up on: a deeper cascade gets a bigger, hotter-
|
||
gold `+points` popup topped by a `COMBO xN` label, and bursts that grow from the
|
||
first round. The depth→emphasis clamp (`fx_combo_level`) mirrors the cascade cue's
|
||
`cascade_cue_index` exactly (depth ≤ 1 → floor, depth ≥ 5 → ceiling); the
|
||
equivalence is locked headlessly by `tests/fx_combo.sx`.
|
||
|
||
Capture it with the same `M3TE_FX` hook — `M3TE_FX=11` is a depth-5 cascade on
|
||
seed 1337, contrasted against the depth-1 single clear `M3TE_FX=3`:
|
||
|
||
```bash
|
||
# Escalated COMBO x5 + gold "+1050" + bigger burst: goldens/p11_combo_deep.png
|
||
env SIMCTL_CHILD_M3TE_FX=11 SIMCTL_CHILD_M3TE_ANIM_TIME=0.22 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
# Single clear for contrast — plain white "+30", no COMBO label (goldens/p6_fx_match.png):
|
||
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0.22 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
# Deep cascade at a later phase — all combo FX gone over the settled board (no golden):
|
||
env SIMCTL_CHILD_M3TE_FX=11 SIMCTL_CHILD_M3TE_ANIM_TIME=3.0 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
```
|
||
|
||
The combo emphasis is purely visual and self-pruning: it never gates input
|
||
(`BoardAnim.active` owns gating) and never touches board / score / move state.
|
||
|
||
### Glossier gem & selection feel (P11.3)
|
||
|
||
The selection highlight (`board_view.sx` `render_selection`) is a candy-glossier
|
||
overlay: two concentric stroked rings fake a soft outward glow, a warm wash tints
|
||
the cell, a bright rim is doubled by a thin inner highlight for a glassy edge, and
|
||
a wet sheen rides the selected gem's live pose. The engine can't tint a texture at
|
||
draw time (issue 0002), so every layer is a rect/overlay — never a gem-texture
|
||
tint. The selection-pop motion still comes from `gem_anim`, so the **t==0 idle
|
||
pose is byte-identical to the static sprite** (locked by `tests/gem_pose.sx`); the
|
||
gloss is selection-only, so the resting board (no selection) is unchanged.
|
||
|
||
Capture it with the same P6.3 hooks — no new env var:
|
||
|
||
```bash
|
||
# Glossy candy selection on cell (3,3), pinned mid-pop: goldens/p6_select.png
|
||
env SIMCTL_CHILD_M3TE_ANIM_TIME=0.17 SIMCTL_CHILD_M3TE_SELECT=27 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
# Same selection at exact rest (no pop) — isolates the overlay:
|
||
env SIMCTL_CHILD_M3TE_ANIM_TIME=0 SIMCTL_CHILD_M3TE_SELECT=27 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
```
|
||
|
||
The selection gloss is purely visual: it never gates input (`BoardAnim.active`
|
||
owns gating) and never touches board / score / move state.
|
||
|
||
### Organic swap motion (P16.1)
|
||
|
||
A legal swap no longer slides flatly into place: `render_swap` drives the two
|
||
gems with `ease_out_back` (the P15.1 overshoot curve), so they shoot a touch PAST
|
||
their target cells then settle exactly onto them. The curve pins `f(0)=0` and
|
||
`f(1)=1`, so the swap stays purely visual — `t==0` is the rest pose and `t==1`
|
||
lands byte-on-cell; the committed move and final board are unchanged. The reused
|
||
`SWAP_ANIM_DUR` (0.16 s) is untouched, so the cascade-cue timing snapshots
|
||
(`tests/cascade_rounds.sx` / `cascade_cue.sx`) don't churn.
|
||
|
||
Capture the overshoot with the same `M3TE_FX` hook, pinned near the peak
|
||
(swap-phase `t ≈ 0.625`, where the gems are ~10 % past target):
|
||
|
||
```bash
|
||
# Swapped gems caught PAST their target cells (overshoot): goldens/p16_swap.png
|
||
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0.10 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
# Same swap at exact rest (t=0) — gems sit dead-on their pre-swap cells:
|
||
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
```
|
||
|
||
`M3TE_FX=3` is the top-row swap `(5,0)↔(6,0)` that completes the vertical red
|
||
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
|
||
```
|
||
|
||
### Organic fall — gravity accel (P17.1)
|
||
|
||
Falling gems no longer *decelerate* into place: `render_fall` now drives the
|
||
per-round drop with `ease_in_cubic` (the P15.1 accelerate-from-rest curve) instead
|
||
of `ease_out_cubic`, so gems start slow and accelerate down like gravity. The curve
|
||
pins `f(0)=0` and `f(1)=1`, so each gem still lands EXACTLY on its destination cell
|
||
at the segment's end — the seam to the next round / settled board stays invisible
|
||
and `move.final` is untouched (`tests/anim_plan.sx` contiguity stays green).
|
||
`FALL_ANIM_DUR` (0.22 s) is unchanged, so the per-round cascade-cue timing
|
||
snapshots (`tests/cascade_rounds.sx` / `cascade_cue.sx`) don't churn.
|
||
|
||
The visual tell of the accel: pinned mid-fall, gems are bunched HIGH near their
|
||
sources (little distance covered) rather than spread out near landing. Capture it
|
||
with the `M3TE_FX` hook; `M3TE_FX=11` is the depth-5 cascade on seed 1337, pinned
|
||
inside **round 3's fall window** `[1.38, 1.60)` s — at `1.51` we are ~59 % through
|
||
that segment in time, yet `ease_in_cubic(0.59) ≈ 0.20`, so the gems have covered
|
||
only ~20 % of their drop and hang caught in the upper rows over the filled lower
|
||
board (the old `ease_out_cubic(0.59) ≈ 0.93` would have them ~93 % down, all but
|
||
landed):
|
||
|
||
```bash
|
||
# Gems caught bunched-high mid-fall under gravity accel: goldens/p17_fall.png
|
||
env SIMCTL_CHILD_M3TE_FX=11 SIMCTL_CHILD_M3TE_ANIM_TIME=1.51 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
# Same cascade past the timeline — fully settled board (no golden):
|
||
env SIMCTL_CHILD_M3TE_FX=11 SIMCTL_CHILD_M3TE_ANIM_TIME=3.0 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
```
|
||
|
||
The round-k fall window is `[0.30 + 0.36·k, 0.52 + 0.36·k)` s on the
|
||
swap→(clear,fall)\* timeline (swap 0.16, then clear 0.14 + fall 0.22 per round), so
|
||
`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 2–7 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:
|
||
|
||
```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.
|
||
|
||
### Organic fall — landing squash-&-settle (P17.3)
|
||
|
||
Each landing gem now flattens **wide-and-short on impact then wobbles back to rest**
|
||
(P15.1's `squash_envelope`), applied WITHIN the fall so EVERY cascade round bounces,
|
||
staggered per column — not only the final whole-move settle as before. `render_fall`
|
||
ages a per-column bounce from each column's touch-down instant (`fall_landing_frac` ·
|
||
`FALL_ANIM_DUR`), so a gem still in the air is drawn unsquashed and only a gem that
|
||
has reached its cell flattens; the squash carries across the fall→clear seam
|
||
(`render_clear` continues the previous round's bounce) and across the final
|
||
render_anim → render_gems seam (the settle stamp is **back-dated** per column so
|
||
`land_squash` resumes exactly where the fall left it — ONE bounce, no double-pop).
|
||
`land_squash` is now `LAND_SQUASH_A · squash_envelope(tl/LAND_DUR)`, so the per-round
|
||
fall bounce and the settle bounce are the same single envelope; amplitude is the
|
||
tasteful ~13 % peak (`LAND_SQUASH_A = 0.18`). Durations are unchanged, so the
|
||
cascade-cue snapshots don't churn; `M3TE_ANIM_TIME=0` still reproduces
|
||
`goldens/p6_idle_t0.png` exactly (a resting board carries no landing stamp).
|
||
|
||
The visual tell: pin a round's fall just before it ends and the leading columns sit
|
||
landed-and-squashed (wide-short) while the trailing columns are still airborne — a
|
||
staggered squash wave. On seed 1337, `M3TE_FX=11` **round 4** (the refill round) at
|
||
`1.94` shows columns 2–4 landed and flattened with columns 5–7 still pouring in
|
||
(every round behaves identically — round 2 `[1.02,1.24)` at `1.21` and round 3
|
||
`[1.38,1.60)` near `1.58` bounce the same way, so the bounce is NOT limited to the
|
||
last settle):
|
||
|
||
```bash
|
||
# Staggered landing squash mid-pour (leading cols flattened, trailing airborne):
|
||
# goldens/p17_land.png
|
||
env SIMCTL_CHILD_M3TE_FX=11 SIMCTL_CHILD_M3TE_ANIM_TIME=1.94 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
# Same cascade past the timeline — fully settled, bounce decayed to rest (no golden):
|
||
env SIMCTL_CHILD_M3TE_FX=11 SIMCTL_CHILD_M3TE_ANIM_TIME=3.0 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
```
|
||
|
||
All three fall goldens were re-captured in P19.2 to the final, fully-merged fall
|
||
motion (gravity accel + per-column stagger + landing squash), each pinned to
|
||
foreground a different tell of it: `goldens/p17_fall.png` (round 3 at `1.51`) catches
|
||
the columns bunched HIGH under the gravity accel before any has landed (so it carries
|
||
no squash); `goldens/p17_stagger.png` (round 4 at `1.91`) catches the left-to-right
|
||
staircase as the leading column just touches down; and this golden (round 4 at `1.94`)
|
||
catches the squash wave once the leading columns have landed-and-flattened while the
|
||
trailing ones still pour in. The change is render-only — no `board.sx` model change,
|
||
and a resting board is untouched.
|
||
|
||
### Organic combine — anticipation pop on clear (P18.1)
|
||
|
||
Matched gems no longer just pop-then-shrink flatly: `clear_pop_scale` now shapes the
|
||
clear as a candy pop in three beats over its local `0..1` — a tiny anticipation
|
||
squash dip (a "gather" ~8 % below rest), a snappy overshoot up to ~1.40× via P15.1's
|
||
`ease_out_back`, then an accelerating collapse to nothing (`ease_in_quad`). The
|
||
endpoints stay LOCKED — `t==0 → 1.0` (rest) and `t==1 → 0.0` (gone) — so the seam to
|
||
the model board is clean and `M3TE_ANIM_TIME=0` still reproduces the resting board;
|
||
the soft particle burst / `+points` popup (`board_fx.sx`) compose on top.
|
||
`tests/gem_pose.sx` pins the new envelope (locked rest endpoints, the anticipation
|
||
dip below rest, the overshoot above 1, and the strictly monotonic post-peak
|
||
collapse). `CLEAR_ANIM_DUR` (0.14 s) is unchanged, so the per-round cascade-cue
|
||
timing snapshots (`tests/cascade_rounds.sx` / `cascade_cue.sx`) don't churn.
|
||
|
||
The pop peaks at clear-phase local `t ≈ 0.37`; for `M3TE_FX=3` (the seed-1337
|
||
vertical red 3-match in column 5, rows 0–2) the clear window is `[0.16, 0.30)` s.
|
||
Because P18.2 staggers each matched gem's pop START (see below), the `0.21` capture
|
||
no longer catches the three gems together at this shared peak — it catches them at
|
||
DIFFERENT points on this same curve, a ripple: the top gem collapsing, the middle
|
||
rising toward its overshoot, and the bottom still at rest (full size), all composed
|
||
with the burst and "+30" popup:
|
||
|
||
```bash
|
||
# Per-gem candy-pop shape, staggered across the match by P18.2 (composed w/ burst):
|
||
# goldens/p18_pop.png
|
||
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0.21 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
# Same match at exact rest (t=0) — board sits at its resting pose, no pop:
|
||
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
```
|
||
|
||
`goldens/p6_fx_match.png` (the P11.1 burst+popup reference, pinned at `0.22`) was
|
||
refreshed for the new pop shape (and re-refreshed in P18.2 for the stagger — pinned
|
||
mid-clear, the committed frame now shows the ripple, not a uniform pop). This step is
|
||
the per-gem pop SHAPE only; the per-gem STAGGER of the explosions follows in P18.2.
|
||
The change is render-only — no `board.sx` model change, and normal play is
|
||
byte-identical apart from the clear's pop curve.
|
||
|
||
### Organic combine — staggered clear ripple (P18.2)
|
||
|
||
The matched gems no longer all explode at once: within a clearing round each gem's
|
||
pop (and its burst) START is offset by a small BOUNDED delay so the cells detonate
|
||
as a RIPPLE. `clear_ripple_t(t, u)` mirrors `fall_stagger_t`'s `(t-delay)/window`:
|
||
a gem's normalized rank `u ∈ [0,1]` (its diagonal `col+row` position within the
|
||
round's matched cells, lowest = `0`) delays its pop START by `CLEAR_STAGGER_MAX·u`
|
||
(`0.45` of the clear window), then plays the P18.1 `clear_pop_scale` curve over the
|
||
remaining `1 − CLEAR_STAGGER_MAX`. The rank is normalized PER ROUND (not across the
|
||
board) so even a 3-match ripples across the full stagger budget. It is BOUNDED:
|
||
every matched gem still reaches local `1` (scale `0`, fully cleared) by the clear
|
||
segment's end — the last-to-start gem (`u=1`) lands exactly at `t==1` — so no gem is
|
||
left mid-pop at the seam to the fall. The bursts (`board_fx.sx`) carry the SAME
|
||
per-gem delay so they ripple in lockstep with the pops. `tests/easing.sx` pins the
|
||
envelope (locked `f(0,·)=0` / `f(1,·)=1` endpoints, bounded completion by `t==1`,
|
||
monotonicity, and the rank ordering).
|
||
|
||
INTRA-ROUND VISUAL ONLY: the per-round cascade audio (P10.10) is untouched — one
|
||
ascending cue per round at the round's clear, NOT per gem — and the model is
|
||
unchanged (same cells cleared, same final board). `CLEAR_ANIM_DUR` (`0.14` s) is
|
||
unchanged, so the cascade-cue snapshots (`tests/cascade_rounds.sx` / `cascade_cue.sx`)
|
||
don't churn and `M3TE_ANIM_TIME=0` still reproduces the resting board.
|
||
|
||
For `M3TE_FX=3` (the seed-1337 vertical red 3-match in column 5, rows 0–2) the clear
|
||
window is `[0.16, 0.30)` s; at `M3TE_ANIM_TIME=0.22` the ripple is at its clearest —
|
||
the TOP gem is collapsing, the MIDDLE is mid-burst, and the BOTTOM is still full-size
|
||
(not yet started):
|
||
|
||
```bash
|
||
# Staggered clear ripple (top collapsing / middle bursting / bottom not yet):
|
||
# goldens/p18_stagger.png
|
||
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0.22 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
# Past the timeline — all three cells cleared per the model, board continues:
|
||
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=2.0 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
```
|
||
|
||
`goldens/p18_pop.png` (`M3TE_FX=3` at `0.21`), `goldens/p6_fx_match.png` (`M3TE_FX=3`
|
||
at `0.22`), and `goldens/p11_combo_deep.png` (`M3TE_FX=11` at `0.22`) were refreshed
|
||
— each was pinned mid-clear, so each now shows the staggered ripple instead of the
|
||
prior simultaneous pop.
|
||
|
||
### FPS counter — dev overlay (P20.1)
|
||
|
||
A small FPS readout for gauging frame cost while tuning the animations. It is a
|
||
**dev overlay, OFF by default**: only the `M3TE_FPS` env pin turns it on, so default
|
||
play and every committed golden stay byte-identical (with `M3TE_FPS` unset the
|
||
rendered scene is unchanged — `goldens/p6_idle_t0.png` reproduces exactly).
|
||
|
||
- `M3TE_FPS=<non-zero>` renders the FPS counter in the **top-left corner** (inside
|
||
the safe area, clear of the centered notch / Dynamic Island and the centered HUD).
|
||
The rate is computed from the per-frame `delta_time` as an exponential moving
|
||
average (`FPS_DT_SMOOTH = 0.9`) so the digits don't jitter. Read once at startup
|
||
like every other `M3TE_*` pin; `=0` or unset leaves it off. Purely a render
|
||
overlay — no board / score / move / animation state changes, and it never gates
|
||
input. `delta_time` is real wall-clock even when `M3TE_ANIM_TIME` pins the
|
||
animation, so the counter stays live while the rest of the scene is frozen.
|
||
|
||
```bash
|
||
# FPS counter over the resting board: goldens/p20_fps.png
|
||
# (the FPS digits are DYNAMIC — only the FPS text varies run-to-run; the rest of the
|
||
# scene is pinned at M3TE_ANIM_TIME=0, byte-identical to goldens/p6_idle_t0.png)
|
||
env SIMCTL_CHILD_M3TE_FPS=1 SIMCTL_CHILD_M3TE_ANIM_TIME=0 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
# On device, pass the same flag through devicectl's environment instead:
|
||
# xcrun devicectl device process launch --environment M3TE_FPS=1 ... co.swipelab.m3te
|
||
```
|
||
|
||
`goldens/p20_fps.png` is the only golden that captures this overlay; because the FPS
|
||
digits are dynamic, compare the FPS text's PRESENCE in the top-left corner, not its
|
||
exact value. Every other golden is captured with `M3TE_FPS` unset and is unaffected.
|
||
|
||
### Move-timeline frame goldens (P6.1 / P6.2)
|
||
|
||
The board-motion timeline (`board_anim.sx`: swap slide → matched-gem clear →
|
||
collapse/refill fall) and the match-FX layer (`board_fx.sx`) were first locked by a
|
||
set of frame goldens captured off ONE committed move. They use the same `M3TE_FX`
|
||
hook as the FX captures above — `M3TE_FX=3` is the seed-1337 vertical red 3-match (a
|
||
single round) — each pinned with `M3TE_ANIM_TIME` to a phase of the swap→clear→fall→
|
||
settled timeline. Re-captured in P19.2 so each now shows the **organic** motion merged
|
||
in P16–P18 (swap overshoot, anticipation-pop clear ripple, gravity-accel fall); the
|
||
canonical per-feature organic goldens live in the P16/P17/P18 sections above, and
|
||
these are the generic timeline frames at their segment midpoints.
|
||
|
||
```bash
|
||
# Swap segment midpoint — the swapped gems caught PAST target (ease_out_back
|
||
# overshoot): goldens/p6_anim_swap.png
|
||
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0.08 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
# Clear segment midpoint — matched gems mid pop-ripple, composed with the burst and
|
||
# "+30" popup. The SAME committed-move frame backs all three of:
|
||
# goldens/p6_anim_clear.png == goldens/p6_fx.png == goldens/p6_inputlock_board.png
|
||
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0.23 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
# Fall segment midpoint — the refilled column caught bunched high under gravity
|
||
# accel: goldens/p6_anim_fall.png
|
||
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0.41 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
# Settled board past the timeline — FX fully pruned, the move's final board. The
|
||
# SAME settled frame backs all three of:
|
||
# goldens/p6_anim_after.png == goldens/p5_swap_after.png == goldens/p6_fx_after.png
|
||
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=2.0 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
```
|
||
|
||
`p6_anim_clear` / `p6_fx` / `p6_inputlock_board` are one and the same frame — the
|
||
timeline's clear segment, the burst/popup FX, and the input-locked in-flight board
|
||
are all the same committed-move moment — as are the three settled `*_after` goldens.
|
||
|
||
The resting-board goldens — `goldens/p4_board.png`, `goldens/p4_hud.png`,
|
||
`goldens/p9_polish.png`, and `goldens/p5_swap_before.png` (the swap's start pose) —
|
||
are the seed-1337 board at rest, captured with no move committed:
|
||
|
||
```bash
|
||
# Resting candy board / HUD (no move): p4_board.png, p4_hud.png, p9_polish.png,
|
||
# p5_swap_before.png
|
||
SIMCTL_CHILD_M3TE_ANIM_TIME=0 \
|
||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
```
|
||
|
||
They match `goldens/p6_idle_t0.png` over the board+HUD region (the `t==0` rest
|
||
invariant, `tests/gem_pose.sx`); only the status-bar clock and the bottom
|
||
home-indicator chrome vary per grab, never the board or HUD.
|
||
|
||
## Audio bank (P10) — final model
|
||
|
||
The SFX bank (`audio.sx`) is a purely additive layer over iOS **System Sound
|
||
Services** (AudioToolbox, reached by `#foreign` FFI exactly as the platform reaches
|
||
UIKit/Metal). Each cue is loaded once at startup into its own `SystemSoundID`;
|
||
playback is a single C call. The bank only plays when *every* cue loaded (`loaded`
|
||
gate), so a partial/failed bank mutes rather than playing a stale id. It never
|
||
reads or mutates score / board / move state — `board_view` and the frame loop just
|
||
tell it an event happened.
|
||
|
||
### Provenance — the user-provided "Triple Treat SFX" pack
|
||
|
||
The nine shipped cues are best-fit selections from the user-supplied **Triple Treat
|
||
SFX** pack (`Triple_Treat_SFX.zip`, ~30 MB, 280 Unity-style files). The pack itself
|
||
is **not committed** — only the selected, converted cues live in `assets/audio/`;
|
||
the source archive is kept outside the repo (in `~/Downloads`). Full per-cue notes
|
||
are in `assets/audio/LICENSE.txt`. The shipped game never re-synthesizes anything;
|
||
it only loads the finished WAVs.
|
||
|
||
| cue | game event | pack source (under `Triple Treat SFX/`) |
|
||
|--------------|------------------------------------|------------------------------------------|
|
||
| `swap.wav` | every committed swipe (subtle) | `Transition SFX/Swipe FX 1-RCM.wav` |
|
||
| `match.wav` | first clear of a legal move (pop) | `Pop:Bubble SFX/Pop FX 5-RCM.wav` |
|
||
| `combo1.wav` | cascade round 1 (dullest) | `Match SFX/Match FX 2-RCM.wav` (~1.7 kHz)|
|
||
| `combo2.wav` | cascade round 2 | `Match SFX/Match FX 4-RCM.wav` (~2.1 kHz)|
|
||
| `combo3.wav` | cascade round 3 | `Match SFX/Match FX 6-RCM.wav` (~3.2 kHz)|
|
||
| `combo4.wav` | cascade round 4 | `Match SFX/Match FX 7-RCM.wav` (~4.7 kHz)|
|
||
| `combo5.wav` | cascade round ≥5 (brightest) | `Match SFX/Match FX 3-RCM.wav` (~6.8 kHz)|
|
||
| `win.wav` | level won (stinger) | `Success:Power-Up SFX/Power Up FX 1-RCM.wav` |
|
||
| `lose.wav` | level lost (stinger) | `Fail SFX/Fail FX 2-RCM.wav` |
|
||
|
||
The Match-FX set does not cleanly pitch-ascend, so `combo1..5` are ordered by
|
||
ascending **spectral brightness** (centroid 1.68 < 2.09 < 3.18 < 4.70 < 6.77 kHz)
|
||
so a deeper cascade reads as more exciting.
|
||
|
||
### Per-round ascending cascade
|
||
|
||
The cascade plays **one ascending cue per cascade round** — round 1 → `combo1`,
|
||
round 2 → `combo2`, … clamped at `combo5` — *not* a single end-of-move sound. Each
|
||
cue fires on the move's animation timeline, edge-triggered as that round's clear
|
||
begins: the frame loop (`main.sx`) diffs `cascade_rounds_started(elapsed, rounds)`
|
||
against `BoardAnim.cascade_fired` and plays the next combo cue per newly-cleared
|
||
round (`sfx_cascade`). The depth→index clamp (`cascade_cue_index`: depth ≤ 1 → 0,
|
||
depth ≥ 5 → 4) is pure + headless, snapshot-tested by `tests/cascade_cue.sx` and
|
||
`tests/cascade_rounds.sx`. A single match (one round) plays only the `match` pop —
|
||
no combo cue. The `swap` cue plays for any committed gesture (legal or the reverted
|
||
ping-back); a legal move adds the `match` pop on its first clearing round; the
|
||
`win`/`lose` stinger fires once, edge-triggered, as the banner comes up.
|
||
|
||
### Format / level spec
|
||
|
||
Every cue is delivered in exactly the form System Sound Services loads directly:
|
||
**WAVE / mono / 44100 Hz / signed-16-bit PCM**. The pack sources are 24-bit /
|
||
48 kHz / stereo; each was down-mixed to mono, trimmed to its punchy window, eased
|
||
in with a short fade, rounded out with a cosine fade-out tail, and **peak-normalized
|
||
to a gentle −15 dBFS** (the user rejected aggressive SFX twice). The candy character
|
||
of the pack is preserved — the cues are not re-synthesized.
|
||
|
||
### Capturing the cue ordering
|
||
|
||
Every play is `NSLog`'d, so a playthrough's cue order is readable from the device
|
||
log. The deep cascade (`M3TE_FX=11`, depth-5 on seed 1337) must be launched **live**
|
||
(no `M3TE_ANIM_TIME` pin) so the timeline advances and the per-round cues fire in
|
||
sequence:
|
||
|
||
```bash
|
||
# Deep cascade, live — fires combo1..combo5 one per round on the timeline:
|
||
SIMCTL_CHILD_M3TE_FX=11 xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||
xcrun simctl spawn booted log show --last 20s \
|
||
--predicate 'eventMessage CONTAINS "[sx] audio"' --style compact
|
||
# Win / lose stingers (also live so the banner edge-triggers the cue). Each pinned
|
||
# launch passes --terminate-running-process: the M3TE_* pins are read only at
|
||
# startup, so relaunching a still-running copy reuses its PID and the new pin is
|
||
# ignored — without it the lose launch reuses the win process and only `cue win`
|
||
# ever prints. A changed PID between the two launches confirms each pin took:
|
||
SIMCTL_CHILD_M3TE_TARGET=0 xcrun simctl launch --terminate-running-process booted co.swipelab.m3te # cue win
|
||
SIMCTL_CHILD_M3TE_MOVE_LIMIT=0 xcrun simctl launch --terminate-running-process booted co.swipelab.m3te # cue lose
|
||
```
|
||
|
||
## Asset regeneration
|
||
|
||
The shipped game only loads finished assets; the steps below rebuild them and are
|
||
never run at play time. Refresh any affected `goldens/` after an art change.
|
||
|
||
### Audio (the SFX bank)
|
||
|
||
The shipped cues are **not synthesized** — each is a real **Triple Treat SFX** pack
|
||
clip selected per game event (per-cue source in `assets/audio/LICENSE.txt`). To
|
||
re-derive a cue from the pack, take its source file and apply the same DSP the
|
||
shipped bank used: down-mix to mono, trim to the punchy onset window (≤ ~600 ms),
|
||
ease in with a short fade, round out with a cosine fade-out tail, peak-normalize to
|
||
a gentle **−15 dBFS**, then re-wrap to the canonical container with `afconvert`
|
||
(`afconvert -f WAVE -d LEI16@44100 -c 1 in.wav out.wav` → WAVE / `LEI16` / 44100 /
|
||
mono). The combo ladder is five real **Match FX** cues ordered by ascending spectral
|
||
brightness (P10.9, `combo1`→`combo5`); the cascade plays one cue per round on the
|
||
animation timeline (P10.10). Verify the result read-only with `tools/measure_pitch.py`
|
||
(it never writes a WAV) — it prints each clip's dominant frequency, and `clear.wav`'s
|
||
~784 Hz CC0 reference is the documented baseline. The 30 MB pack and its `.meta` /
|
||
`__MACOSX` cruft are not committed.
|
||
|
||
> There is intentionally **no procedural-synthesis regeneration script**: the cues
|
||
> are the curated pack clips, so the original P10.1 note-frequency synthesizer
|
||
> (`tools/synth_audio.py`) was removed — running it would have clobbered the curated
|
||
> WAVs with synthetic audio.
|
||
|
||
### Image art
|
||
|
||
Real art is produced with codex's built-in **`imagegen`** tool through `codex exec`,
|
||
then `sips`-normalized to each asset's exact dimensions and source format:
|
||
|
||
| asset | dims (px) | format | role |
|
||
|-----------------------------|-----------|--------|-----------------------------------|
|
||
| `assets/board/background.png` | 863×1822 | PNG | full-view candy gradient backdrop |
|
||
| `assets/board/cell.png` | 128×128 | PNG | one grid cell tile |
|
||
| `assets/gems/gems.png` | 768×128 | PNG | 6 gem columns (a gem's UV column = its index) |
|
||
| `assets/fx/particle.png` | 256×256 | PNG | soft match-burst sprite (tinted per gem) |
|
||
|
||
```bash
|
||
# 1. Generate (codex's imagegen tool, driven non-interactively):
|
||
codex exec "use the imagegen tool to render <prompt for the asset>"
|
||
# 2. Normalize to the exact per-asset dims + format, e.g. the gem sheet:
|
||
sips -z 128 768 --setProperty format png <generated>.png --out assets/gems/gems.png
|
||
```
|
||
|
||
After any art change, re-capture the affected goldens with the deterministic hooks
|
||
above (`M3TE_ANIM_TIME` / `M3TE_SELECT` / `M3TE_FX` / `M3TE_BADSWAP` / `M3TE_TARGET` /
|
||
`M3TE_MOVE_LIMIT` / `M3TE_RESTART` / `M3TE_FPS`) and state per golden whether it was refreshed,
|
||
left unchanged, or removed.
|