Merge branch 'm3te-plan'

This commit is contained in:
swipelab
2026-06-06 15:18:13 +03:00
145 changed files with 54591 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
.DS_Store
.sx-cache
.sx-tmp/
sx-out/
# iOS/macOS .app bundles land inside the ignored sx-out/ dir.
*.app/
# Built binaries from `sx build main.sx` at repo root.
/main
# Flow scaffolding (nested working copy).
current/

684
README.md
View File

@@ -7,3 +7,687 @@ A match-3 game written entirely in the **sx** language, targeting **iOS** first.
- 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 27 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 24 landed and flattened with columns 57 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 02) 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 02) 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 P16P18 (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.

0
assets/.gitkeep Normal file
View File

78
assets/audio/LICENSE.txt Normal file
View File

@@ -0,0 +1,78 @@
m3te sound effects
==================
The nine shipped cues (swap, match, combo1..5, win, lose) are best-fit
selections from the user-provided "Triple Treat SFX" pack
(Triple_Treat_SFX.zip, ~30 MB, 280 files, Unity-style with .meta sidecars),
chosen per game event and converted for the candy match-3 feel (P10.8).
>>> The full pack is NOT committed — only the selected, converted cues live
>>> here. The pack was supplied directly by the user for this game; its source
>>> archive is Triple_Treat_SFX.zip (kept outside the repo, in ~/Downloads).
All files are delivered in exactly the format iOS System Sound Services loads
directly via audio.sx: WAVE / mono / 44100 Hz / signed-16-bit PCM. The pack's
sources are 24-bit / 48 kHz / stereo; each was down-mixed to mono, trimmed to
its punchy window, peak-normalized, and re-wrapped to the canonical container.
The bank is deliberately GENTLE (the user rejected aggressive SFX twice): every
cue is peak-normalized to a quiet, consistent -15 dBFS, eases in with a short
fade to tame the attack transient, and rounds out with a cosine fade-out tail.
The pack's candy character is preserved — the cues are not re-synthesized.
Provenance: the user-provided "Triple Treat SFX" pack
-----------------------------------------------------
Each cue below names the EXACT source file within the pack it was selected from
(paths relative to the pack's "Triple Treat SFX/" root). Because the pack came
from the user for this game, no external license was hunted; provenance is the
supplied Triple_Treat_SFX.zip.
swap.wav — soft, light SWIPE matching the swap gesture (fires on every swipe,
so kept subtle). Distinct timbre from the candy pops.
Source: Transition SFX/Swipe FX 1-RCM.wav
Trimmed to the swipe body (~0.44 s), faded, peak-normalized -15 dBFS.
match.wav — bright, juicy candy POP (the satisfying first-clear reward).
Source: Pop:Bubble SFX/Pop FX 5-RCM.wav
Trimmed to the single pop (~0.44 s), faded, peak-normalized -15 dBFS.
combo1..5.wav — the candy-cascade ladder: FIVE distinct REAL Match FX cues from
the pack, ORDERED to convey cascade escalation. The Match FX set
does not cleanly pitch-ascend, so the cues are ordered by spectral
brightness / high-frequency energy instead — combo1 the dullest /
most subdued, combo5 the brightest / sparkliest, so deeper cascades
read as more exciting.
combo1 — Match SFX/Match FX 2-RCM.wav (dark/full, centroid ~1.7 kHz)
combo2 — Match SFX/Match FX 4-RCM.wav (warm mid, centroid ~2.1 kHz)
combo3 — Match SFX/Match FX 6-RCM.wav (bright mid, centroid ~3.2 kHz)
combo4 — Match SFX/Match FX 7-RCM.wav (rich+bright, centroid ~4.7 kHz)
combo5 — Match SFX/Match FX 3-RCM.wav (sparkly, centroid ~6.8 kHz)
Each down-mixed to mono, trimmed to a 0.50 s onset window, eased in
(~6 ms) and rounded out with a 150 ms cosine fade-out, peak-
normalized -15 dBFS. Brightness (spectral centroid) ascends
monotonically, conveying escalation:
combo1 1.68 < combo2 2.09 < combo3 3.18 < combo4 4.70 < combo5 6.77 kHz.
win.wav — short, triumphant POWER-UP cue (level won).
Source: Success:Power-Up SFX/Power Up FX 1-RCM.wav
Trimmed to the front-loaded body (~0.60 s), faded, peak-normalized
-15 dBFS.
lose.wav — soft, gentle tonal FAIL (level lost) — not boomy or harsh.
Source: Fail SFX/Fail FX 2-RCM.wav
Trimmed to the body (~0.58 s) with a long cosine fade-out, peak-
normalized -15 dBFS.
clear.wav — "confirmation_001" from Kenney "Interface Sounds" (CC0).
https://kenney.nl/assets/interface-sounds
NOT loaded by the shipped game; kept as a CC0 reference clip
(tools/measure_pitch.py uses its ~784 Hz fundamental as a baseline).
Processing
----------
Each chosen pack cue was decoded, down-mixed to mono, trimmed to its transient
window, optionally pitch-shifted with real resample DSP (combos only), eased in
with a short fade and rounded out with a cosine fade-out, peak-normalized to
-15 dBFS, and re-wrapped to the canonical WAVE/LEI16/44100/mono container with
afconvert. The shipped game never runs any build tool; it only loads the
finished WAVs. The 30 MB pack and its .meta / __MACOSX cruft are not committed.

BIN
assets/audio/clear.wav Normal file

Binary file not shown.

BIN
assets/audio/combo1.wav Normal file

Binary file not shown.

BIN
assets/audio/combo2.wav Normal file

Binary file not shown.

BIN
assets/audio/combo3.wav Normal file

Binary file not shown.

BIN
assets/audio/combo4.wav Normal file

Binary file not shown.

BIN
assets/audio/combo5.wav Normal file

Binary file not shown.

BIN
assets/audio/lose.wav Normal file

Binary file not shown.

BIN
assets/audio/match.wav Normal file

Binary file not shown.

BIN
assets/audio/swap.wav Normal file

Binary file not shown.

BIN
assets/audio/win.wav Normal file

Binary file not shown.

BIN
assets/board/background.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
assets/board/cell.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

93
assets/fonts/OFL.txt Normal file
View File

@@ -0,0 +1,93 @@
Copyright (c) 2010-2014 by tyPoland Lukasz Dziedzic (team@latofonts.com) with Reserved Font Name "Lato"
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

BIN
assets/fonts/default.ttf Normal file

Binary file not shown.

BIN
assets/fx/particle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
assets/gems/gems.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

174
audio.sx Normal file
View File

@@ -0,0 +1,174 @@
// iOS sound-effect bank via AudioToolbox System Sound Services (P10.2).
//
// A purely additive layer: it never reads or mutates score / board / move
// state — board_view tells it an event happened and it plays one short clip.
//
// System Sound Services is plain C, so it is reached with sx's `#foreign` FFI
// exactly as uikit.sx reaches UIApplicationMain / dlsym / CACurrentMediaTime —
// no sx-library change. The AudioToolbox + CoreFoundation frameworks are linked
// per-target in build.sx. Every call is guarded by `inline if OS == .ios`, so
// other targets never reference these symbols nor need the frameworks.
#import "modules/std.sx";
#import "modules/std/objc.sx";
#import "modules/compiler.sx";
// AudioToolbox — System Sound Services. SystemSoundID is a UInt32; OSStatus a
// SInt32 (0 == noErr); the clip's file is passed as a CFURLRef (opaque ptr).
AudioServicesCreateSystemSoundID :: (url: *void, out_id: *u32) -> s32 #foreign;
AudioServicesPlaySystemSound :: (sound_id: u32) #foreign;
// CoreFoundation — build a file CFURL from an absolute path. `len` is a CFIndex
// (long); `is_dir` a Boolean (unsigned char); a NULL allocator = default.
CFURLCreateFromFileSystemRepresentation :: (allocator: *void, buffer: *u8, len: s64, is_dir: s8) -> *void #foreign;
CFRelease :: (cf: *void) #foreign;
// libc — getcwd to absolutize the bundle-relative asset path. The platform
// chdir's to the bundle's resource dir at boot, so CWD is the .app and the
// game's other relative `assets/...` loads already resolve against it.
getcwd :: (buf: *u8, size: usize) -> *u8 #foreign;
c_strlen :: (s: *u8) -> usize #foreign "strlen";
// The cascade clips combo1..combo5, in ascending pitch. `combo_ids` is indexed
// by `cascade_cue_index`.
COMBO_CLIPS :: 5;
// The whole bank, each cue loaded once at startup into its own SystemSoundID;
// every `play_*` is then a single C call. `loaded` is true only when every cue
// loaded, so a partial/failed bank mutes rather than playing a 0 id.
GameAudio :: struct {
swap_id: u32;
match_id: u32;
combo_ids: [COMBO_CLIPS]u32;
win_id: u32;
lose_id: u32;
loaded: bool;
init :: (self: *GameAudio) {
self.loaded = false;
inline if OS != .ios { return; }
self.swap_id = load_cue("swap.wav", "[sx] audio: loaded swap", "[sx] audio: load failed swap");
self.match_id = load_cue("match.wav", "[sx] audio: loaded match", "[sx] audio: load failed match");
self.combo_ids[0] = load_cue("combo1.wav", "[sx] audio: loaded combo1", "[sx] audio: load failed combo1");
self.combo_ids[1] = load_cue("combo2.wav", "[sx] audio: loaded combo2", "[sx] audio: load failed combo2");
self.combo_ids[2] = load_cue("combo3.wav", "[sx] audio: loaded combo3", "[sx] audio: load failed combo3");
self.combo_ids[3] = load_cue("combo4.wav", "[sx] audio: loaded combo4", "[sx] audio: load failed combo4");
self.combo_ids[4] = load_cue("combo5.wav", "[sx] audio: loaded combo5", "[sx] audio: load failed combo5");
self.win_id = load_cue("win.wav", "[sx] audio: loaded win", "[sx] audio: load failed win");
self.lose_id = load_cue("lose.wav", "[sx] audio: loaded lose", "[sx] audio: load failed lose");
self.loaded = self.swap_id != 0 and self.match_id != 0
and self.combo_ids[0] != 0 and self.combo_ids[1] != 0 and self.combo_ids[2] != 0
and self.combo_ids[3] != 0 and self.combo_ids[4] != 0
and self.win_id != 0 and self.lose_id != 0;
}
play_swap :: (self: *GameAudio) {
inline if OS != .ios { return; }
if !self.loaded { return; }
NSLog(xx "[sx] audio: cue swap");
AudioServicesPlaySystemSound(self.swap_id);
}
play_match :: (self: *GameAudio) {
inline if OS != .ios { return; }
if !self.loaded { return; }
NSLog(xx "[sx] audio: cue match");
AudioServicesPlaySystemSound(self.match_id);
}
// Pick the ascending cascade clip by clamping the cascade depth into the
// combo1..combo5 range (see `cascade_cue_index`).
play_cascade :: (self: *GameAudio, depth: s64) {
inline if OS != .ios { return; }
if !self.loaded { return; }
idx := cascade_cue_index(depth);
NSLog(xx cascade_cue_name(idx));
AudioServicesPlaySystemSound(self.combo_ids[idx]);
}
play_win :: (self: *GameAudio) {
inline if OS != .ios { return; }
if !self.loaded { return; }
NSLog(xx "[sx] audio: cue win");
AudioServicesPlaySystemSound(self.win_id);
}
play_lose :: (self: *GameAudio) {
inline if OS != .ios { return; }
if !self.loaded { return; }
NSLog(xx "[sx] audio: cue lose");
AudioServicesPlaySystemSound(self.lose_id);
}
}
// The cascade cue's log line, one stable literal per combo clip so the play-time
// `log show` shows the clip stepping up with cascade depth. Literals only — the
// string→NSString bridge needs NUL-terminated bytes (a formatted string may not
// be). `idx` is a clamped `cascade_cue_index`, so it is always 0..COMBO_CLIPS-1.
cascade_cue_name :: (idx: s64) -> string {
if idx <= 0 { return "[sx] audio: cue combo1"; }
if idx == 1 { return "[sx] audio: cue combo2"; }
if idx == 2 { return "[sx] audio: cue combo3"; }
if idx == 3 { return "[sx] audio: cue combo4"; }
"[sx] audio: cue combo5"
}
// Cascade depth (number of cleared rounds) → combo clip index 0..COMBO_CLIPS-1
// (combo1..combo5). Clamps: depth <= 1 → 0, depth >= 5 → 4. Pure arithmetic and
// OS-agnostic so it can be snapshot-tested headlessly (P10.4).
cascade_cue_index :: (depth: s64) -> s64 {
if depth <= 1 { return 0; }
if depth >= COMBO_CLIPS { return COMBO_CLIPS - 1; }
depth - 1
}
// Load one cue into a SystemSoundID and log the outcome. `loaded_msg` /
// `failed_msg` are string literals (NUL-terminated, so safe to bridge to
// NSString) naming the cue. Returns 0 on any failure, which the play methods
// treat as "skip" via the `loaded` gate.
load_cue :: (name: string, loaded_msg: string, failed_msg: string) -> u32 {
inline if OS != .ios { return 0; }
id := load_system_sound(name);
if id != 0 { NSLog(xx loaded_msg); }
else { NSLog(xx failed_msg); }
return id;
}
// Create a SystemSoundID for `assets/audio/<name>` (relative to the bundle).
// Returns 0 on any failure.
load_system_sound :: (name: string) -> u32 {
inline if OS != .ios { return 0; }
cwd_buf : [1024]u8 = ---;
if getcwd(@cwd_buf[0], 1024) == null { return 0; }
cwd : string = ---;
cwd.ptr = @cwd_buf[0];
cwd.len = cast(s64) c_strlen(@cwd_buf[0]);
// CFURLCreateFromFileSystemRepresentation takes an explicit byte length, so
// the formatted path needs no NUL terminator.
path := format("{}/assets/audio/{}", cwd, name);
url := CFURLCreateFromFileSystemRepresentation(null, path.ptr, path.len, 0);
if url == null { return 0; }
sound_id : u32 = 0;
status := AudioServicesCreateSystemSoundID(url, @sound_id);
CFRelease(url);
if status != 0 { return 0; }
sound_id
}
// The process-wide instance. main() allocates + inits it; board_view triggers
// the swap/match/cascade cues through the `sfx_*` shims on a committed gesture,
// and main's frame loop fires the win/lose stinger edge-triggered. Null until
// init, so every shim is a safe no-op before then.
g_audio : *GameAudio = null;
sfx_swap :: () { if g_audio != null { g_audio.play_swap(); } }
sfx_match :: () { if g_audio != null { g_audio.play_match(); } }
sfx_cascade :: (depth: s64) { if g_audio != null { g_audio.play_cascade(depth); } }
sfx_win :: () { if g_audio != null { g_audio.play_win(); } }
sfx_lose :: () { if g_audio != null { g_audio.play_lose(); } }

940
board.sx Normal file
View File

@@ -0,0 +1,940 @@
// m3te core model — pure, headless match-3 board (Phase 1).
//
// Everything here is deterministic and rendering-free: a fixed seed always
// produces the same board. Later phases build on these primitives —
// P1.2 (match detection), P1.3 (swap legality), P2 (clear/cascade/refill) —
// so the layout favours plain index access (`at` / `idx`) over anything
// rendering-specific.
#import "modules/std.sx";
// ── Gem ──────────────────────────────────────────────────────────────────
// Six distinct gem types plus an `empty` hole sentinel. The ordinal of a real
// gem (0..5) IS its gem index, so it casts cleanly to/from the integers the RNG
// and the textual dump work in; `empty` (ordinal 6) sits outside that range and
// is never drawn by the RNG.
GEM_COUNT :: 6;
Gem :: enum {
red;
orange;
yellow;
green;
blue;
purple;
// A hole: a cell with no gem, left behind when a match is cleared (P2.1).
// Distinct from all six gem types and never produced by init/pick_gem
// (which only draw ordinals 0..GEM_COUNT), so gravity/refill (P2.2/P2.3) can
// test a cell for `== .empty` to find holes. Outside GEM_CHARS, so it dumps
// via the dedicated EMPTY_CHAR rather than the gem alphabet.
empty;
}
// One stable character per gem type, indexed by ordinal — the alphabet the
// board dump (and its golden) is written in.
GEM_CHARS :: "ROYGBP";
// Hole glyph for the board dump: an empty cell renders as this instead of a gem
// character. Distinct from every gem in GEM_CHARS.
EMPTY_CHAR :: 46; // '.'
gem_char :: (g: Gem) -> u8 {
if g == .empty { return EMPTY_CHAR; }
GEM_CHARS[cast(s64) g]
}
// ── Deterministic RNG ─────────────────────────────────────────────────────
// A 32-bit linear congruential generator (Numerical Recipes constants),
// carried in an s64 and masked back to 32 bits after every step so the
// stream is identical regardless of host integer width. The state*MUL+ADD
// product stays well under s64 range, so no intermediate overflow. Any seed
// (including 0) yields a valid stream — an LCG has no forbidden state.
RNG_MASK32 :: 0xFFFFFFFF;
RNG_MUL :: 1664525;
RNG_ADD :: 1013904223;
Rng :: struct {
state: s64;
// Advance and return the next 32-bit value.
next_u32 :: (self: *Rng) -> s64 {
self.state = (self.state * RNG_MUL + RNG_ADD) & RNG_MASK32;
self.state
}
// Uniform-ish value in [0, n). Uses the high bits, whose period is far
// longer than the low bits of an LCG.
next_range :: (self: *Rng, n: s64) -> s64 {
(self.next_u32() >> 16) % n
}
}
rng_seeded :: (seed: s64) -> Rng {
Rng.{ state = seed & RNG_MASK32 }
}
// ── Board ─────────────────────────────────────────────────────────────────
BOARD_COLS :: 8;
BOARD_ROWS :: 8;
BOARD_CELLS :: BOARD_COLS * BOARD_ROWS;
// Default per-game move budget (P3.3). `init` seeds `Board.move_limit` with
// this; `moves_remaining` counts down from it as committed swaps spend moves.
// The turn/goal loop (P7) owns enforcing the budget — the model only TRACKS it,
// so `moves_remaining` may legitimately reach 0 (or below, if a caller keeps
// committing) without `commit_swap` refusing.
DEFAULT_MOVE_LIMIT :: 30;
// Default per-level score goal (P7.1). `init` seeds `Board.target_score` with
// this; `level_status` wins the moment `score` reaches it. Sized to be reachable
// within the DEFAULT_MOVE_LIMIT budget with focused play but not by idle
// swapping — a single legal swap pays tens of points, a deep cascade a couple
// hundred. A level may override `target_score` for a harder or easier goal.
DEFAULT_TARGET_SCORE :: 1500;
Board :: struct {
// Row-major: cell (col, row) lives at row*BOARD_COLS + col.
cells: [BOARD_CELLS]Gem;
// The board's own deterministic RNG. `init` seeds it, then every later draw
// — refill (P2.3) and the cascade beyond — advances THIS state, so the whole
// gem stream for a seed is reproducible and successive refills continue the
// sequence instead of reseeding. A hand-built board (one made without `init`)
// must seed this before any draw.
rng: Rng;
// Running score total. `init` zeroes it; `add_round_score` adds a single
// round's base points (see `score_round`), and `resolve` adds each cascade
// round's base scaled by `combo_multiplier` (P3.2). The HUD (P4.4) reads this
// field. A hand-built board must zero this before accumulating.
score: s64;
// Turn accounting (P3.3). `moves_made` counts the swaps actually COMMITTED —
// only a legal swap (one that resolved into >=1 match) via `commit_swap`
// increments it; an illegal, reverted swap does not. `move_limit` is the
// game's move budget (`init` sets it to DEFAULT_MOVE_LIMIT); `moves_remaining`
// is derived from the two, so there is a single source of truth and the
// counters can never drift apart. A hand-built board must set both before
// committing swaps.
moves_made: s64;
move_limit: s64;
// Per-level score goal (P7.1). `init` sets it to DEFAULT_TARGET_SCORE;
// `level_status` reads it to decide a win (`score >= target_score`). A
// hand-built board must set this before its status is read.
target_score: s64;
idx :: (col: s64, row: s64) -> s64 {
row * BOARD_COLS + col
}
// Moves still available against the budget: `move_limit - moves_made`. Goes
// to 0 when the budget is spent (and below it only if a caller keeps
// committing past the budget — see DEFAULT_MOVE_LIMIT). The turn/goal loop
// (P7) reads this to decide when the game ends.
moves_remaining :: (self: *Board) -> s64 {
self.move_limit - self.moves_made
}
at :: (self: *Board, col: s64, row: s64) -> Gem {
self.cells[Board.idx(col, row)]
}
set :: (self: *Board, col: s64, row: s64, g: Gem) {
self.cells[Board.idx(col, row)] = g;
}
// Fill every cell from `seed` so that NO horizontal or vertical run of
// three same-type gems exists. Cells are placed in row-major order; when
// placing one, any gem type that would complete a 3-in-a-row with the two
// already-placed cells to its left or above is excluded, and the gem is
// drawn from the remaining allowed types. At most two types are ever
// excluded, so a choice always remains.
init :: (self: *Board, seed: s64) {
self.rng = rng_seeded(seed);
self.score = 0;
self.moves_made = 0;
self.move_limit = DEFAULT_MOVE_LIMIT;
self.target_score = DEFAULT_TARGET_SCORE;
for 0..BOARD_ROWS: (row) {
for 0..BOARD_COLS: (col) {
self.set(col, row, pick_gem(self, @self.rng, col, row));
}
}
}
}
// Choose a gem for (col, row) that can't extend an existing run leftward or
// upward. Pure given the board's already-placed prefix and the RNG state.
pick_gem :: (board: *Board, rng: *Rng, col: s64, row: s64) -> Gem {
forbidden : [GEM_COUNT]bool = ---;
for 0..GEM_COUNT: (t) { forbidden[t] = false; }
// Two same gems immediately to the left → a third of that type matches.
if col >= 2 {
left := board.at(col - 1, row);
if left == board.at(col - 2, row) {
forbidden[cast(s64) left] = true;
}
}
// Two same gems immediately above → a third of that type matches.
if row >= 2 {
up := board.at(col, row - 1);
if up == board.at(col, row - 2) {
forbidden[cast(s64) up] = true;
}
}
allowed := 0;
for 0..GEM_COUNT: (t) { if !forbidden[t] { allowed += 1; } }
// Pick the k-th still-allowed type; single RNG draw, always terminates.
k := rng.next_range(allowed);
for 0..GEM_COUNT: (t) {
if !forbidden[t] {
if k == 0 { return cast(Gem) t; }
k -= 1;
}
}
.red // unreachable: `allowed` >= GEM_COUNT-2 >= 4, so k is always consumed
}
// Deterministic textual dump: one row per line, top (row 0) to bottom, a
// single gem character per cell. Suitable for snapshotting.
board_dump :: (self: *Board) -> string {
line_w := BOARD_COLS + 1; // 8 gem chars + newline
buf := cstring(BOARD_ROWS * line_w);
for 0..BOARD_ROWS: (row) {
base := row * line_w;
for 0..BOARD_COLS: (col) {
buf[base + col] = gem_char(self.at(col, row));
}
buf[base + BOARD_COLS] = 10; // '\n'
}
buf
}
// ── Match detection ────────────────────────────────────────────────────────
// Per-cell membership over the board: cell (col, row) is `true` iff it takes
// part in some horizontal or vertical run of three or more same-type gems.
// This mask IS the matched-cell SET — overlapping shapes (an L or a T where a
// horizontal and a vertical run share a cell) collapse to a single `true`, so
// the union is automatic. The layout mirrors Board.cells exactly so the
// clear/cascade phase can consume it without translation.
MatchMask :: struct {
cells: [BOARD_CELLS]bool;
at :: (self: *MatchMask, col: s64, row: s64) -> bool {
self.cells[Board.idx(col, row)]
}
count :: (self: *MatchMask) -> s64 {
n : s64 = 0;
for 0..BOARD_CELLS: (i) { if self.cells[i] { n += 1; } }
n
}
}
// Mark a closed span of cells along one axis. `vertical` picks the axis; `fixed`
// is the constant coordinate (the row for a horizontal span, the column for a
// vertical one) and the span covers `start..end` of the moving coordinate.
mark_run :: (m: *MatchMask, vertical: bool, fixed: s64, start: s64, end: s64) {
for start..end: (i) {
if vertical {
m.cells[Board.idx(fixed, i)] = true;
} else {
m.cells[Board.idx(i, fixed)] = true;
}
}
}
// Detect every maximal horizontal and vertical run of length >= 3 and mark all
// participating cells. Each row and column is scanned once, extending a run
// while the gem type holds; a maximal run of length >= 3 marks its whole span,
// so length-4 / length-5 runs are simply longer spans of the same walk. A cell
// shared by an intersecting horizontal and vertical run is marked once per
// axis into the same slot — idempotent, so the union counts it once.
//
// Only runs of an actual gem match: `.empty` holes are never matchable, so a
// line of 3+ holes (left behind by a prior clear) is not a match. Holes also
// break runs of real gems, since a hole differs from every gem type.
find_matches :: (b: *Board) -> MatchMask {
m : MatchMask = ---;
for 0..BOARD_CELLS: (i) { m.cells[i] = false; }
// Horizontal: walk each row left-to-right in maximal same-type spans.
for 0..BOARD_ROWS: (row) {
col := 0;
while col < BOARD_COLS {
g := b.at(col, row);
run_end := col + 1;
while run_end < BOARD_COLS and b.at(run_end, row) == g {
run_end += 1;
}
if g != .empty and run_end - col >= 3 { mark_run(@m, false, row, col, run_end); }
col = run_end;
}
}
// Vertical: walk each column top-to-bottom in maximal same-type spans.
for 0..BOARD_COLS: (col) {
row := 0;
while row < BOARD_ROWS {
g := b.at(col, row);
run_end := row + 1;
while run_end < BOARD_ROWS and b.at(col, run_end) == g {
run_end += 1;
}
if g != .empty and run_end - row >= 3 { mark_run(@m, true, col, row, run_end); }
row = run_end;
}
}
m
}
// Deterministic textual dump of a matched-cell SET, in the same row-major grid
// shape as `board_dump`: a matched cell shows its gem character, an unmatched
// cell shows '.'. A board with no matches dumps as an all-'.' grid, which reads
// unambiguously as the empty set. Suitable for snapshotting.
dump_matches :: (b: *Board, m: *MatchMask) -> string {
line_w := BOARD_COLS + 1; // 8 cells + newline
buf := cstring(BOARD_ROWS * line_w);
for 0..BOARD_ROWS: (row) {
base := row * line_w;
for 0..BOARD_COLS: (col) {
if m.at(col, row) {
buf[base + col] = gem_char(b.at(col, row));
} else {
buf[base + col] = 46; // '.'
}
}
buf[base + BOARD_COLS] = 10; // '\n'
}
buf
}
// ── Swap & legality ──────────────────────────────────────────────────────────
// A board cell address. Kept separate from the row-major index so swap callers
// and the move enumeration speak in (col, row) like the rest of the model.
Cell :: struct {
col: s64;
row: s64;
}
// Exchange the gems of two cells, in place. `swap` is its own inverse: calling
// it again with the same two cells restores the board, so a caller can trial a
// swap, inspect the result, then swap back to revert.
swap :: (board: *Board, a: Cell, b: Cell) {
ai := Board.idx(a.col, a.row);
bi := Board.idx(b.col, b.row);
tmp := board.cells[ai];
board.cells[ai] = board.cells[bi];
board.cells[bi] = tmp;
}
// Two cells are orthogonally adjacent iff they differ by exactly one step along
// a single axis. The same cell, a diagonal, or any longer gap is not adjacent.
adjacent :: (a: Cell, b: Cell) -> bool {
if a.row == b.row { return a.col == b.col + 1 or a.col == b.col - 1; }
if a.col == b.col { return a.row == b.row + 1 or a.row == b.row - 1; }
false
}
// Legality of swapping two cells: legal iff they are orthogonally adjacent AND,
// after the swap, at least one of the two swapped cells takes part in a 3+ match
// (via `find_matches`). A swap that only completes a run for the OTHER moved gem
// still counts — either swapped position participating is enough. Non-adjacent
// or diagonal pairs are rejected outright, before any match check. The board is
// left UNCHANGED: the trial swap is reverted before returning.
swap_legal :: (board: *Board, a: Cell, b: Cell) -> bool {
if !adjacent(a, b) { return false; }
swap(board, a, b);
m := find_matches(board);
legal := m.at(a.col, a.row) or m.at(b.col, b.row);
swap(board, a, b); // revert the trial swap
legal
}
// One legal move: an unordered pair of adjacent cells. By construction `a` is
// the top-left cell of the pair and `b` is its right (same row) or down (same
// col) neighbour, so each adjacency is represented once — never as both (a, b)
// and (b, a).
Swap :: struct {
a: Cell;
b: Cell;
}
// Enumerate every currently-legal swap in a stable order: row-major over the
// top-left cell of each pair, and for each cell its right neighbour before its
// down neighbour. This visits each orthogonal adjacency exactly once. The order
// is fixed (independent of board contents), so later hint / no-moves logic and
// the snapshot can depend on it.
legal_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
}
// Deterministic textual dump of an enumerated swap list, in list order: a count
// header, then one swap per line as its unordered cell pair `(col,row)-(col,row)`
// with the canonical top-left cell first. An empty list (no legal moves) dumps
// as just "0 legal swaps", which reads unambiguously. Suitable for snapshotting.
dump_swaps :: (swaps: *List(Swap)) -> string {
result := format("{} legal swaps\n", swaps.len);
for 0..swaps.len: (i) {
s := swaps.items[i];
result = concat(result, format("({},{})-({},{})\n", s.a.col, s.a.row, s.b.col, s.b.row));
}
result
}
// ── Clear (P2.1) ─────────────────────────────────────────────────────────────
// First step of the resolution pipeline: turn matched cells into holes. No
// gravity or refill here (P2.2 / P2.3) — clearing only writes `.empty` into the
// matched cells and leaves every other cell exactly as it was.
// Set every cell flagged in `mask` to a hole, leaving all unflagged cells
// unchanged. Returns the number of cells cleared. `mask` is the matched-cell SET
// from find_matches, so overlapping L/T shapes (already unioned into a single
// `true` per shared cell) clear as one set with no double-counting.
clear_cells :: (board: *Board, mask: *MatchMask) -> s64 {
cleared : s64 = 0;
for 0..BOARD_CELLS: (i) {
if mask.cells[i] {
board.cells[i] = .empty;
cleared += 1;
}
}
cleared
}
// Detect matches on `board` and clear them in one step. Returns the number of
// cells cleared — 0 when there are no matches, in which case the board is left
// unchanged. The count drives later cascade/scoring (P2.2+): a non-zero result
// means the board changed and the resolution loop should continue.
clear_matches :: (board: *Board) -> s64 {
m := find_matches(board);
clear_cells(board, @m)
}
// ── Gravity / collapse (P2.2) ─────────────────────────────────────────────────
// Second step of the resolution pipeline: let the gems fall into the holes a
// clear left behind. Within EACH column independently, every gem slides straight
// down past any holes below it, and the holes bubble to the TOP of the column
// (the smaller row index, since row 0 is the top of the dump). Columns never
// exchange gems — there is no horizontal movement. The surviving gems keep their
// original top-to-bottom order, now packed contiguously at the bottom with all
// holes contiguous above them. Refilling the freed top holes with fresh gems is
// P2.3; this step only moves what is already on the board.
//
// Returns true iff at least one gem changed row (i.e. some hole had a gem above
// it). A column that is already settled — or all holes, or all gems — moves
// nothing, so a fully-settled board returns false; the cascade loop (P2.4) reads
// this to know when gravity has stopped.
collapse :: (board: *Board) -> bool {
moved := false;
for 0..BOARD_COLS: (col) {
// Pack this column's gems toward the bottom: scan it bottom-to-top and
// write each gem at the falling cursor `w`, which also descends from the
// bottom. A gem whose source row differs from `w` actually fell. `w`
// never overtakes the read cursor, so writes only land on rows already
// read — safe to pack in place.
w := BOARD_ROWS - 1;
r := BOARD_ROWS - 1;
while r >= 0 {
g := board.at(col, r);
if g != .empty {
if r != w { moved = true; }
board.set(col, w, g);
w -= 1;
}
r -= 1;
}
// Every row above the packed gems is now a hole.
fill := 0;
while fill <= w {
board.set(col, fill, .empty);
fill += 1;
}
}
moved
}
// ── Refill (P2.3) ──────────────────────────────────────────────────────────────
// Final step of the resolution pipeline: drop a fresh gem into every hole. Each
// `.empty` cell is replaced by a gem drawn from the board's OWN seeded RNG, so a
// given seed always produces the same refill and successive refills continue the
// stream rather than repeating — the state threads through `init`, clears and
// prior refills, never reseeding. Holes are filled wherever they sit, in
// row-major order, so refill does not assume `collapse` ran first.
//
// Unlike `init`, refill makes NO attempt to avoid matches: a refilled gem may
// complete a new run, which is exactly what drives the P2.4 cascade. `next_range`
// only ever yields ordinals 0..GEM_COUNT, so a hole is never refilled with
// `.empty`; afterwards the board has no holes left. Returns the number of cells
// filled (0 on a board that had none).
refill :: (board: *Board) -> s64 {
rng := @board.rng;
filled : s64 = 0;
for 0..BOARD_ROWS: (row) {
for 0..BOARD_COLS: (col) {
if board.at(col, row) == .empty {
board.set(col, row, cast(Gem) rng.next_range(GEM_COUNT));
filled += 1;
}
}
}
filled
}
// ── Cascade resolution (P2.4) ──────────────────────────────────────────────
// The settle loop a swap triggers: keep resolving matches until the board is
// stable. One round is detect → clear → collapse → refill; the loop repeats
// while a round still finds a match. Gravity can align falling survivors into a
// fresh run and a seeded refill can complete one, so a single clear chains into
// more — the cascade. Termination is reached the first round that detects no
// match; for a fixed seed the whole sequence is deterministic.
// Outcome of resolving a board to a stable state. `depth` is the number of
// rounds that found and cleared at least one match (0 for an already-stable
// board). `cleared` holds those rounds' cleared-cell counts in round order, so
// `cleared.len == depth`. `awarded` is the total points this settle added to
// `Board.score`: the sum over rounds of `score_round * combo_multiplier(round)`
// (P3.2), so the HUD (P4.4) and turn accounting (P3.3) can read a swap's payout
// without re-deriving it. A depth-0 (already-stable) board awards 0.
//
// `len4` / `len5_plus` tally the special-match runs cleared across the WHOLE
// settle (summed over rounds): the number of maximal runs of length exactly 4,
// and of length 5 or more (P3.3 special-match flagging). They are a HOOK for
// future special gems — nothing here creates or alters a gem; the tallies only
// make "did this settle clear a 4 / 5+ run" observable. `had_len4` /
// `had_len5_plus` are the boolean view of the same counts.
Cascade :: struct {
depth: s64;
cleared: List(s64);
awarded: s64;
len4: s64;
len5_plus: s64;
had_len4 :: (self: *Cascade) -> bool {
self.len4 > 0
}
had_len5_plus :: (self: *Cascade) -> bool {
self.len5_plus > 0
}
}
// One resolution round: detect matches and, if any, clear them, collapse under
// gravity, then refill the holes from the board's seeded RNG. Returns the
// number of cells cleared this round — 0 iff the board was already stable, in
// which case nothing moves and no gem is drawn. `resolve` repeats this until it
// returns 0.
resolve_step :: (board: *Board) -> s64 {
cleared := clear_matches(board);
if cleared == 0 { return 0; }
collapse(board);
refill(board);
cleared
}
// Resolve the board to a stable state, running rounds until one finds no match,
// scoring each round with the cascade combo multiplier (P3.2). Returns the
// cascade: its depth, per-round cleared-cell counts, and total `awarded` points.
// Each round adds `score_round * combo_multiplier(round)` (round 1-based) to
// `Board.score`; an already-stable board returns depth 0, awards 0, untouched.
resolve :: (board: *Board) -> Cascade {
result := Cascade.{ depth = 0, cleared = List(s64).{}, awarded = 0, len4 = 0, len5_plus = 0 };
while true {
// Read the round's base points AND its special-match tally while the runs
// are still on the board: `resolve_step` clears them, so both have to be
// taken first. A no-match round scores 0 and tallies nothing, then breaks.
base := score_round(board);
sp := count_specials(board);
n := resolve_step(board);
if n == 0 { break; }
result.depth += 1;
points := base * combo_multiplier(result.depth);
board.score += points;
result.awarded += points;
result.cleared.append(n);
result.len4 += sp.len4;
result.len5_plus += sp.len5_plus;
}
result
}
// ── Scoring (P3.1) ───────────────────────────────────────────────────────────
// Base match scoring: value a round's clears purely by RUN LENGTH — longer runs
// are worth more. The scheme is fixed and documented by these named constants:
// a maximal run of length 3 → 30, length 4 → 60, length 5 or more → 100.
//
// Scoring needs each maximal run's LENGTH, not just the unioned matched-cell set
// (`find_matches`/`MatchMask`, which collapses overlaps to a single `true`). So
// this is a separate enumeration path — `find_matches` and every clear/cascade
// caller are untouched. L/T rule: each maximal run is scored INDEPENDENTLY by
// its own length, so an L (a horizontal run meeting a vertical run at a shared
// corner) scores horizontal + vertical — the corner counts toward both runs'
// lengths, unlike the cleared-cell set which unions it once.
//
// One round only: the cross-round combo MULTIPLIER is `combo_multiplier` (P3.2),
// applied by `resolve`; this base scheme is unscaled.
SCORE_RUN_3 :: 30;
SCORE_RUN_4 :: 60;
SCORE_RUN_5_PLUS :: 100;
// One maximal same-type run of length >= 3. `vertical` picks the axis; `fixed`
// is the constant coordinate (the row for a horizontal run, the column for a
// vertical one) and the run covers `start..start+len` of the moving coordinate.
Run :: struct {
vertical: bool;
fixed: s64;
start: s64;
len: s64;
}
// Base points for a single maximal run, by length. Runs are always length >= 3
// (shorter spans are not enumerated), so 3 is the floor; 5 and longer all score
// the top tier.
run_score :: (len: s64) -> s64 {
if len <= 3 { return SCORE_RUN_3; }
if len == 4 { return SCORE_RUN_4; }
SCORE_RUN_5_PLUS
}
// Enumerate every maximal horizontal and vertical run of length >= 3 with its
// length, in a stable order: all horizontal runs row-major (top-to-bottom, each
// row left-to-right), then all vertical runs column-major. The scan mirrors
// `find_matches` exactly — same maximal-span walk, same `.empty` exclusion (holes
// never run) — but records each run's length instead of marking a shared mask, so
// an intersecting L/T yields the horizontal run AND the vertical run as two
// separate entries rather than one unioned cell set.
find_runs :: (b: *Board) -> List(Run) {
runs := List(Run).{};
for 0..BOARD_ROWS: (row) {
col := 0;
while col < BOARD_COLS {
g := b.at(col, row);
run_end := col + 1;
while run_end < BOARD_COLS and b.at(run_end, row) == g {
run_end += 1;
}
if g != .empty and run_end - col >= 3 {
runs.append(Run.{ vertical = false, fixed = row, start = col, len = run_end - col });
}
col = run_end;
}
}
for 0..BOARD_COLS: (col) {
row := 0;
while row < BOARD_ROWS {
g := b.at(col, row);
run_end := row + 1;
while run_end < BOARD_ROWS and b.at(col, run_end) == g {
run_end += 1;
}
if g != .empty and run_end - row >= 3 {
runs.append(Run.{ vertical = true, fixed = col, start = row, len = run_end - row });
}
row = run_end;
}
}
runs
}
// Base points for clearing the board's currently-matched runs THIS round: the
// sum of `run_score` over every maximal run from `find_runs`. Pure and
// read-only — it inspects the board but changes nothing, so it must be called
// BEFORE the round's clear, while the runs are still on the board. A board with
// no run scores 0.
score_round :: (board: *Board) -> s64 {
runs := find_runs(board);
total : s64 = 0;
for 0..runs.len: (i) {
total += run_score(runs.items[i].len);
}
total
}
// Add this round's base points (×1, no combo multiplier) to the board's running
// `score` total and return them. The single-round accumulation primitive; the
// cascade loop (`resolve`) instead scales each round by `combo_multiplier`
// (P3.2). Neither path changes `score_round`.
add_round_score :: (board: *Board) -> s64 {
points := score_round(board);
board.score += points;
points
}
// ── Combo multiplier (P3.2) ────────────────────────────────────────────────
// Across one swap's cascade, each resolution round's base points (`score_round`)
// are scaled by a multiplier that grows with chain depth, so deeper chains pay
// out more. The scheme: the 1-based round index IS the multiplier — round 1 ×1,
// round 2 ×2, round 3 ×3, … A single-round settle (depth 1) therefore scores
// exactly its base (×1, no bonus); every round past the first is amplified, so a
// multi-round chain strictly beats the same clears scored flat. `resolve`
// accumulates `score_round * combo_multiplier(round)` per round into `Board.score`
// and reports the sum as `Cascade.awarded`.
combo_multiplier :: (round: s64) -> s64 {
round
}
// ── Special-match flagging (P3.3) ──────────────────────────────────────────
// A HOOK for future special gems: surface, per detection round, how many of the
// board's maximal runs are long enough to (later) spawn one — a run of length
// exactly 4, and a run of length 5 or more. This is detection ONLY: nothing here
// creates, marks, or alters a gem; later work reads these counts to decide what
// special gem (if any) a clear produces. Length 3 runs are ordinary and counted
// by neither tier.
// Per-round tally of special-length runs: `len4` is the number of maximal runs
// of length exactly 4, `len5_plus` the number of length 5 or more. Boolean
// "did any occur" lives on `Cascade` (`had_len4` / `had_len5_plus`) for the
// whole settle; a single round reads these counts directly.
SpecialCounts :: struct {
len4: s64;
len5_plus: s64;
}
// Count the board's currently-matched runs that hit a special length, by the
// same `find_runs` enumeration scoring uses (so an L/T's horizontal and vertical
// runs are counted independently by their own lengths). Pure and read-only — it
// inspects the board but changes nothing, so it must be called BEFORE the round's
// clear, while the runs are still present. A board with no run (or only length-3
// runs) tallies zero in both tiers.
count_specials :: (board: *Board) -> SpecialCounts {
runs := find_runs(board);
counts := SpecialCounts.{ len4 = 0, len5_plus = 0 };
for 0..runs.len: (i) {
len := runs.items[i].len;
if len == 4 {
counts.len4 += 1;
} else if len >= 5 {
counts.len5_plus += 1;
}
}
counts
}
// Deterministic textual dump of an enumerated run list, in `find_runs` order: a
// count header, then one run per line as `<axis> len <n> at fixed <f> start <s>`
// where axis is H (horizontal) or V (vertical). An empty list dumps as just
// "0 runs". Suitable for snapshotting.
dump_runs :: (runs: *List(Run)) -> string {
result := format("{} runs\n", runs.len);
for 0..runs.len: (i) {
r := runs.items[i];
axis := if r.vertical then "V" else "H";
result = concat(result, format("{} len {} at fixed {} start {}\n", axis, r.len, r.fixed, r.start));
}
result
}
// ── Player move (P3.3) ─────────────────────────────────────────────────────
// The single model-level entry point a player's swap goes through — what the
// swipe input (P5) and turn/goal loop (P7) will call. It ties legality, the
// swap, the cascade settle, and turn accounting together so callers don't
// re-implement the sequence.
// Outcome of attempting one player swap via `commit_swap`. `legal` says whether
// the swap resolved into at least one match and was therefore COMMITTED: when
// false the board is untouched, no move was spent, and `cascade` is the empty
// (depth-0) settle; when true the swap was applied, the board resolved (scoring
// accrued into `Board.score`) and exactly one move was spent. `cascade` carries
// the settle's full outcome — depth, per-round cleared counts, awarded points,
// and the special-match (len4 / len5+) tallies. `moves_remaining` snapshots the
// board's remaining budget AFTER the move, so a caller has it without re-reading.
PlayerMove :: struct {
legal: bool;
cascade: Cascade;
moves_remaining: s64;
}
// Attempt the player's intended swap of two adjacent cells. If the swap is legal
// (`swap_legal`: adjacent AND it forms a match), apply it, `resolve` the cascade
// — which accrues score into `Board.score` and reports the awarded points and
// special-match flags — then spend one move (`moves_made += 1`). If it is illegal
// (non-adjacent, or forms no match) the board is left exactly as it was — no swap,
// no resolve, no move spent — and an empty depth-0 cascade is returned. Move
// accounting only TRACKS the budget; it does not refuse a swap when the budget is
// spent (that is the P7 turn-loop's call) — see DEFAULT_MOVE_LIMIT.
commit_swap :: (board: *Board, a: Cell, b: Cell) -> PlayerMove {
if !swap_legal(board, a, b) {
empty := Cascade.{ depth = 0, cleared = List(s64).{}, awarded = 0, len4 = 0, len5_plus = 0 };
return PlayerMove.{ legal = false, cascade = empty, moves_remaining = board.moves_remaining() };
}
swap(board, a, b);
cascade := resolve(board);
board.moves_made += 1;
PlayerMove.{ legal = true, cascade = cascade, moves_remaining = board.moves_remaining() }
}
// ── Turn / goal loop (P7.1) ────────────────────────────────────────────────
// A thin, pure level loop over the model: a per-level score GOAL
// (`Board.target_score`) to reach within the move budget (`Board.move_limit`),
// the win / lose / in-progress STATUS derived from the two, a deadlock
// RESHUFFLE so the player is never stuck, and a RESTART that reseeds a fresh
// level. All deterministic and rendering-free; P7.2 reads `level_status` to draw
// the goal HUD, the win/lose banner and the restart button.
// Where a level stands. Derived purely from `Board.score`, `Board.target_score`
// and the move budget — there is no stored status to keep in sync, so it can
// never drift from the model. `won` is tested before `lost`, so meeting the goal
// on the final move wins even though no moves remain.
Status :: enum {
in_progress;
won;
lost;
}
// One stable name per status, for snapshots and the HUD.
status_name :: (s: Status) -> string {
if s == .won { return "won"; }
if s == .lost { return "lost"; }
"in_progress"
}
// The level's current standing. WON as soon as `score` reaches `target_score`
// (even with the budget exhausted); otherwise LOST once the move budget is spent
// (`moves_remaining() <= 0`) short of the goal; otherwise still in progress.
// `<= 0` (not `== 0`) so a board pushed past its budget still reads lost.
level_status :: (board: *Board) -> Status {
if board.score >= board.target_score { return .won; }
if board.moves_remaining() <= 0 { return .lost; }
.in_progress
}
// Whether the board has at least one legal swap — the cheap deadlock probe.
// Same enumeration as `legal_swaps`, but it stops at the first legal pair and
// allocates nothing, so the reshuffle loop and P7.2's deadlock check don't build
// a throwaway list each call. The trial swaps inside `swap_legal` are reverted,
// so the board is left unchanged.
has_legal_swap :: (board: *Board) -> bool {
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) { return true; }
}
if row + 1 < BOARD_ROWS {
down := Cell.{ col = col, row = row + 1 };
if swap_legal(board, here, down) { return true; }
}
}
}
false
}
// Upper bound on shuffle attempts before `reshuffle` gives up. A gather-and-
// permute lands on a no-immediate-match, has-a-legal-move arrangement within a
// handful of tries for a real (multi-colour) board, so this cap only guarantees
// termination; exhausting it (astronomically unlikely) leaves the board in its
// last shuffled state and returns false, which the turn loop treats as an
// unbreakable deadlock.
MAX_RESHUFFLE_TRIES :: 200;
// Re-arrange the board's existing gems in place until the player has a move
// again: no cell is part of an immediate match AND at least one legal swap
// exists. The board's own seeded RNG drives a FisherYates permutation, so a
// given state always reshuffles the same way; each invalid arrangement is simply
// re-permuted (the RNG keeps advancing) until one is valid or the attempt cap is
// hit. A reshuffle is NOT a move: `score`, `moves_made` and `move_limit` are
// untouched. Returns true once a valid arrangement is reached, false if the cap
// was exhausted.
reshuffle :: (board: *Board) -> bool {
rng := @board.rng;
tries := 0;
while tries < MAX_RESHUFFLE_TRIES {
// FisherYates over all 64 cells, in place. Short loops — in-body locals
// here are fine (issue 0001 only bites loops of ~1M+ iterations).
i := BOARD_CELLS - 1;
while i > 0 {
j := rng.next_range(i + 1);
tmp := board.cells[i];
board.cells[i] = board.cells[j];
board.cells[j] = tmp;
i -= 1;
}
m := find_matches(board);
if m.count() == 0 and has_legal_swap(board) { return true; }
tries += 1;
}
false
}
// After a committed move's cascade has settled, recover a deadlocked board so the
// player is never stranded: if the level is still in progress yet no legal swap
// remains, `reshuffle` the gems in place. A reshuffle is NOT a move and never runs
// on a finished (won/lost) level, so win/lose and turn accounting are untouched.
// Returns whether a reshuffle ran. BOTH the headless turn loop (`play_turn`) and
// the animated UI commit (`plan_and_commit`) call this, so the rendered game obeys
// the identical no-moves rule — neither path can leave the board stuck.
reshuffle_if_deadlocked :: (board: *Board) -> bool {
if level_status(board) == .in_progress and !has_legal_swap(board) {
return reshuffle(board);
}
false
}
// Reset to a fresh, reproducible level: `init(seed)` reseeds the board (same
// seed → identical starting layout), zeroes `score` and `moves_made`, and
// restores the default move budget and score goal, so `level_status` reads
// `in_progress` again. The entry point P7.2's restart button calls.
restart :: (board: *Board, seed: s64) {
board.init(seed);
}
// Outcome of one turn through the goal loop: whether the turn was `accepted`
// (false only when a finished level rejected the move), the underlying
// `PlayerMove`, the level `status` AFTER it, and whether a deadlock `reshuffle`
// ran (so P7.2 can flash a "shuffled" note). When `accepted` is false the move
// is a no-op (illegal, depth-0 cascade) and `status` is the terminal status that
// caused the rejection. The status is recomputed from the model, never stored.
TurnResult :: struct {
accepted: bool;
move: PlayerMove;
status: Status;
reshuffled: bool;
}
// Play one turn. A FINISHED level is frozen: once `level_status` is won or lost
// the move is REJECTED (`accepted = false`) — no swap, no move spent, no score
// change, status unchanged — until `restart` reseeds a fresh level. P7.2 reads
// `accepted` to tell the player the input was ignored because the level is over.
// While in progress the swap is attempted via `commit_swap` (an illegal swap
// changes nothing and spends no move); then — only if still in progress — the
// board reshuffles if it has deadlocked (no legal swaps left), so the player is
// never stranded. A reshuffle costs no move. A winning or losing move skips the
// reshuffle: the level is over. Returns whether the turn was accepted, the move
// outcome, the resulting status, and whether a reshuffle ran.
play_turn :: (board: *Board, a: Cell, b: Cell) -> TurnResult {
status := level_status(board);
if status != .in_progress {
empty := Cascade.{ depth = 0, cleared = List(s64).{}, awarded = 0, len4 = 0, len5_plus = 0 };
frozen := PlayerMove.{ legal = false, cascade = empty, moves_remaining = board.moves_remaining() };
return TurnResult.{ accepted = false, move = frozen, status = status, reshuffled = false };
}
move := commit_swap(board, a, b);
reshuffled := reshuffle_if_deadlocked(board);
TurnResult.{ accepted = true, move = move, status = level_status(board), reshuffled = reshuffled }
}

403
board_anim.sx Normal file
View File

@@ -0,0 +1,403 @@
// Board motion animation (P6.1) — a PURELY VISUAL timeline the view plays over
// one player move. The logical model (commit_swap / resolve) stays authoritative:
// `plan_and_commit` commits the move on the real board (and, like the headless
// turn loop, reshuffles a deadlocked board afterwards), then replays the SAME
// commit operations on a value-copy of the pre-move board to record the per-step
// geometry (the swap, each cascade round's matched cells, and each round's
// per-column fall provenance). Because the copy starts from the identical cells
// AND RNG state and runs the identical primitives, its recorded `final` board
// equals the move's settled (pre-reshuffle) board gem-for-gem — the animation only
// ever ends ON the already-decided cascade result, never changes it.
//
// Per-gem idle/select/clear gem animations (P6.3) and score popups / particle FX
// (P6.2) are NOT here; this step animates board MOTION only: swap slide, matched
// scale-out, and collapse/refill fall.
#import "modules/std.sx";
#import "modules/math";
#import "modules/ui/types.sx";
#import "board.sx";
#import "board_layout.sx";
// Short, frame-timed durations (seconds) for each timeline segment. Driven by
// the frame loop's delta_time, so they are wall-clock, framerate-independent.
SWAP_ANIM_DUR :f32: 0.16;
CLEAR_ANIM_DUR :f32: 0.14;
FALL_ANIM_DUR :f32: 0.22;
// Two base easing helpers, each with locked endpoints f(0)=0 and f(1)=1:
// ease_out_cubic decelerates into its endpoint, ease_in_quad accelerates from rest.
ease_out_cubic :: (t: f32) -> f32 { u := t - 1.0; u * u * u + 1.0 }
ease_in_quad :: (t: f32) -> f32 { t * t }
// --- Extended easing toolkit (P15.1) -----------------------------------------
// Pure, headless curves of t in [0,1] for the organic-animation pass (swap/fall/
// combine juice). Each has LOCKED endpoints and bounded, tasteful amplitude; NO
// render code calls these yet — the transition steps (P16/P17/P18) wire them in
// and tune feel. Companions to the two easing helpers above; the math module has
// no exp/pow, so the decaying curves use a polynomial envelope that reaches
// exactly 0 at t==1, which pins f(1) precisely instead of merely approaching it.
// `tests/easing.sx` pins every endpoint, overshoot bound, and monotonicity here.
// Accelerate from rest: slow start, fast finish. Monotonic 0->1. Cubic companion
// to ease_in_quad and the mirror of ease_out_cubic.
ease_in_cubic :: (t: f32) -> f32 { t * t * t }
// Smooth accelerate-then-decelerate, symmetric about (0.5, 0.5). Monotonic 0->1.
ease_in_out_cubic :: (t: f32) -> f32 {
if t < 0.5 { return 4.0 * t * t * t; }
u := -2.0 * t + 2.0;
1.0 - u * u * u * 0.5
}
// Overshoot ("back"): shoots ~10% past 1 then settles to EXACTLY 1, never dipping
// below 0. Non-monotonic by design — the overshoot is the whole point.
BACK_S :f32: 1.70158;
ease_out_back :: (t: f32) -> f32 {
u := t - 1.0;
1.0 + (BACK_S + 1.0) * u * u * u + BACK_S * u * u
}
// Damped spring: rises to 1, overshoots (~18%), then a small decaying wobble back
// to EXACTLY 1. The (1-t)^3 envelope is 0 at t==1, so f(1) is locked.
SPRING_OSC :f32: 1.0;
spring :: (t: f32) -> f32 {
if t <= 0.0 { return 0.0; }
if t >= 1.0 { return 1.0; }
d := 1.0 - t;
1.0 - d * d * d * cos(TAU * SPRING_OSC * t)
}
// Squash-&-stretch landing envelope: a signed, unit-ish shape that is 0 (rest) at
// both ends, squashes on impact, then wobbles out with decay. Downstream applies
// it as e.g. scale_x = 1 + A*s, scale_y = 1 - A*s for a tasteful amplitude A.
SQUASH_OSC :f32: 1.5;
squash_envelope :: (t: f32) -> f32 {
if t <= 0.0 or t >= 1.0 { return 0.0; }
d := 1.0 - t;
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))
}
// Per-column fall stagger (P17.2): within the fall window, each column starts its
// drop at a small BOUNDED delay so a refilled/collapsed row pours in as a cascade
// instead of every gem snapping down in one flat lockstep row. Column `col` waits
// FALL_STAGGER_MAX * col/(BOARD_COLS-1) of the window, then falls over the
// remaining `1 - FALL_STAGGER_MAX`, so the LAST column lands EXACTLY at t==1 and
// every earlier column lands strictly before it — no gem is ever left mid-air when
// the segment ends (the seam to the next round / settled board stays invisible).
// Returns the column's LOCAL 0..1 progress; render_fall feeds it through
// ease_in_cubic so each column still accelerates under gravity within its window.
// `tests/easing.sx` pins f(0)=0, f(1)=1, monotonicity, and the cascade ordering.
FALL_STAGGER_MAX :f32: 0.30;
fall_stagger_t :: (t: f32, col: s64) -> f32 {
delay := FALL_STAGGER_MAX * (cast(f32) col / cast(f32) (BOARD_COLS - 1));
window := 1.0 - FALL_STAGGER_MAX;
lt := (t - delay) / window;
if lt <= 0.0 { return 0.0; }
if lt >= 1.0 { return 1.0; }
lt
}
// The LOCAL fall-progress fraction at which column `col` finishes its drop — the
// instant `fall_stagger_t(.,col)` reaches 1 (delay + window). Column 0 lands first
// at `1 - FALL_STAGGER_MAX`; the last column lands exactly at 1.0. The landing
// squash-bounce (P17.3) ages from this instant per column, so the squash begins
// the moment a gem touches its cell rather than at a flat whole-row settle.
fall_landing_frac :: (col: s64) -> f32 {
(1.0 - FALL_STAGGER_MAX) + FALL_STAGGER_MAX * (cast(f32) col / cast(f32) (BOARD_COLS - 1))
}
// Absolute time (s) on the swap→(clear,fall)* timeline at which round `k` finishes
// dropping column `col`'s gem onto its destination cell — the landing instant the
// per-round bounce ages from. Round k's fall starts after the swap, k clear+fall
// pairs, and that round's own clear; column `col` then lands `fall_landing_frac`
// of the fall window into it. Pure + headless, mirrors `phase`'s segment walk.
round_land_time :: (k: s64, col: s64) -> f32 {
SWAP_ANIM_DUR + cast(f32) k * (CLEAR_ANIM_DUR + FALL_ANIM_DUR) + CLEAR_ANIM_DUR
+ fall_landing_frac(col) * FALL_ANIM_DUR
}
// Per-gem clear ripple (P18.2): within a clearing round the matched gems pop as a
// RIPPLE, not all at once. Each gem gets a normalized rank `u` in [0,1] (its
// diagonal position within the round's matched cells, lowest-diagonal = 0), and
// this offsets that gem's pop START by a BOUNDED delay so rank 0 pops first and
// rank 1 last, yet EVERY gem still reaches local 1 (clear_pop_scale → scale 0,
// fully cleared) by the clear segment's end — no gem is left mid-pop at the seam
// to the fall. Returns the gem's LOCAL 0..1 progress, fed through clear_pop_scale
// (whose locked endpoints keep the seam to the model board). Mirrors
// fall_stagger_t's (t-delay)/window. `tests/easing.sx` pins f(0,.)/f(1,.), the
// bounded completion by t==1, monotonicity, and the rank ordering.
CLEAR_STAGGER_MAX :f32: 0.45;
clear_ripple_t :: (t: f32, u: f32) -> f32 {
delay := CLEAR_STAGGER_MAX * u;
window := 1.0 - CLEAR_STAGGER_MAX;
lt := (t - delay) / window;
if lt <= 0.0 { return 0.0; }
if lt >= 1.0 { return 1.0; }
lt
}
// The diagonal (col+row) extent of a round's matched cells — the span the ripple
// ranks each matched gem across. `hi < lo` only if the mask is empty.
ClearDiag :: struct { lo: s64; hi: s64; }
clear_diag_span :: (m: *MatchMask) -> ClearDiag {
lo : s64 = (BOARD_COLS - 1) + (BOARD_ROWS - 1) + 1;
hi : s64 = -1;
for 0..BOARD_CELLS: (i) {
if m.cells[i] {
d := (i % BOARD_COLS) + (i / BOARD_COLS);
if d < lo { lo = d; }
if d > hi { hi = d; }
}
}
ClearDiag.{ lo = lo, hi = hi }
}
// Normalized rank (0..1) of cell (col,row) within a round's matched diagonal span
// — 0 for the earliest-popping (lowest-diagonal) gem, 1 for the last. Normalizing
// PER ROUND (not across the board) lets even a small 3-match ripple across the
// full stagger budget. A degenerate span (every matched cell on one diagonal)
// ranks all gems 0, so they pop together rather than dividing by zero.
clear_rank :: (span: ClearDiag, col: s64, row: s64) -> f32 {
if span.hi <= span.lo { return 0.0; }
cast(f32) ((col + row) - span.lo) / cast(f32) (span.hi - span.lo)
}
// 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`
// maps each destination cell to the SOURCE ROW its gem falls from within the same
// column: a non-negative row for a surviving gem that slides down, or a NEGATIVE
// row (above the board) for a freshly-refilled gem dropping in from the top.
// `after` is the board once this round has cleared, collapsed, and refilled.
AnimRound :: struct {
before: [BOARD_CELLS]Gem;
matched: MatchMask;
src: [BOARD_CELLS]s64;
after: [BOARD_CELLS]Gem;
}
// The full recorded timeline of one move. `legal` mirrors the model's decision:
// a legal swap has >=1 round and `final` is the settled board; an illegal swap
// has zero rounds, `pre == final`, and the view plays a slide-and-return. `a`/`b`
// are the swapped cells; `pre` is the board before the swap (the slide's start).
// `awarded` carries the model's own payout for this move (cascade.awarded) so the
// score-popup FX (P6.2) shows the real number without re-deriving any scoring.
AnimMove :: struct {
legal: bool;
a: Cell;
b: Cell;
pre: [BOARD_CELLS]Gem;
rounds: List(AnimRound);
final: [BOARD_CELLS]Gem;
awarded: s64;
}
// The most recent round at or before `kmax` that dropped a MOVED gem onto
// destination cell `i` (a slide-down survivor or a top refill — `src != row`), or
// -1 if the gem now resting at `i` never moved over those rounds. The gem at `i`
// landed in that round, so its squash-bounce ages from `round_land_time(round,
// col)`; scanning newest-first means a cell cleared and refilled across rounds
// ages from its LATEST arrival, never a stale earlier one. Pure + headless: the
// per-round bounce (render_fall/clear) and the final-settle stamp share this so
// one envelope plays continuously across every seam.
delivering_round :: (mv: *AnimMove, i: s64, kmax: s64) -> s64 {
row := i / BOARD_COLS;
k := kmax;
while k >= 0 {
if mv.rounds.items[k].src[i] != row { return k; }
k -= 1;
}
-1
}
// Commit the player's swap authoritatively AND record its visual timeline. The
// real board is mutated by `commit_swap`, then — exactly like the headless
// `play_turn` — `reshuffle_if_deadlocked` recovers a stranded board so the rendered
// game obeys the same no-moves rule. The recording runs on a value-copy taken
// BEFORE the commit, so it replays the identical cells + RNG stream; the recorded
// `final` is the SETTLED board the animation ends on. It equals the live board
// unless a deadlock reshuffle then re-arranged it: that reshuffle is a model step,
// not part of this move's timeline, so it renders on the next settled frame.
plan_and_commit :: (board: *Board, a: Cell, b: Cell) -> AnimMove {
move : AnimMove = ---;
move.a = a;
move.b = b;
move.rounds = List(AnimRound).{};
move.pre = board.cells;
move.awarded = 0;
// Snapshot the entire model state (cells + RNG + score + moves) before the
// commit so the replay below is bit-identical to what commit_swap does.
scratch : Board = board.*;
mv := commit_swap(board, a, b);
move.legal = mv.legal;
move.awarded = mv.cascade.awarded;
if !mv.legal {
move.final = board.cells;
reshuffle_if_deadlocked(board);
return move;
}
swap(@scratch, a, b);
while true {
m := find_matches(@scratch);
if m.count() == 0 { break; }
round : AnimRound = ---;
round.before = scratch.cells;
round.matched = m;
clear_cells(@scratch, @m);
// Fall provenance, read off the just-cleared (holed) board — mirrors
// `collapse`'s packing exactly: scanning a column bottom-to-top, each
// surviving gem lands at the descending write cursor `w`, so dest row `w`
// came from source row `r`. The rows left above the survivors (0..w) are
// refilled, so they drop in from above: a dest row `j` there starts at
// `j - n_refill`, i.e. stacked just off the top edge.
for 0..BOARD_COLS: (col) {
w := BOARD_ROWS - 1;
r := BOARD_ROWS - 1;
while r >= 0 {
if scratch.at(col, r) != .empty {
round.src[Board.idx(col, w)] = r;
w -= 1;
}
r -= 1;
}
n_refill := w + 1;
j := 0;
while j <= w {
round.src[Board.idx(col, j)] = j - n_refill;
j += 1;
}
}
collapse(@scratch);
refill(@scratch);
round.after = scratch.cells;
move.rounds.append(round);
}
move.final = scratch.cells;
reshuffle_if_deadlocked(board);
move
}
// Which segment of the timeline is playing, and the local 0..1 progress within
// it. `round` indexes `AnimMove.rounds` for clear/fall.
AnimPhaseKind :: enum { swap; clear; fall; done; }
AnimPhase :: struct {
kind: AnimPhaseKind;
round: s64;
t: f32;
}
// Live timeline state for the in-flight move. Heap-allocated (like BoardSelection
// / DragInput) so it survives BoardView's per-frame rebuild; `tick` advances it
// by the frame's delta_time and the view reads `phase` to render the right slice.
BoardAnim :: struct {
active: bool;
elapsed: f32;
move: AnimMove;
// Highest 1-based cascade round whose ascending combo cue has already played,
// so the frame loop's per-round SFX is edge-triggered: a round's cue fires once,
// when its clear begins, never re-fired every frame. Reset whenever a move
// (re)starts; advanced by the frame loop as rounds clear.
cascade_fired: s64;
init :: (self: *BoardAnim) {
self.active = false;
self.elapsed = 0.0;
self.move.legal = false;
self.move.rounds = List(AnimRound).{};
self.cascade_fired = 0;
}
begin :: (self: *BoardAnim, m: AnimMove) {
self.move = m;
self.elapsed = 0.0;
self.active = true;
self.cascade_fired = 0;
}
// Total wall-clock length: the swap segment plus a clear+fall pair per round.
total :: (self: *BoardAnim) -> f32 {
SWAP_ANIM_DUR + cast(f32) self.move.rounds.len * (CLEAR_ANIM_DUR + FALL_ANIM_DUR)
}
tick :: (self: *BoardAnim, dt: f32) {
if !self.active { return; }
self.elapsed += dt;
if self.elapsed >= self.total() { self.active = false; }
}
// Resolve `elapsed` to the active segment by walking swap → (clear, fall)*.
phase :: (self: *BoardAnim) -> AnimPhase {
e := self.elapsed;
if e < SWAP_ANIM_DUR {
return AnimPhase.{ kind = .swap, round = 0, t = e / SWAP_ANIM_DUR };
}
e -= SWAP_ANIM_DUR;
for 0..self.move.rounds.len: (k) {
if e < CLEAR_ANIM_DUR {
return AnimPhase.{ kind = .clear, round = k, t = e / CLEAR_ANIM_DUR };
}
e -= CLEAR_ANIM_DUR;
if e < FALL_ANIM_DUR {
return AnimPhase.{ kind = .fall, round = k, t = e / FALL_ANIM_DUR };
}
e -= FALL_ANIM_DUR;
}
AnimPhase.{ kind = .done, round = 0, t = 1.0 }
}
}
// Per-round cascade-cue timing (P10.10): how many cascade rounds have BEGUN their
// clear (pop) by `elapsed`, on the SAME swap→(clear,fall)* timeline `phase` walks.
// Round k (0-based) starts clearing at SWAP_ANIM_DUR + k*(CLEAR_ANIM_DUR +
// FALL_ANIM_DUR), so this is the count of rounds whose ascending combo cue should
// have fired by now (clamped to the move's round count). The frame loop diffs it
// against `BoardAnim.cascade_fired` to play one cue per newly-cleared round. Pure +
// headless so the per-round playback is snapshot-testable without audio.
cascade_rounds_started :: (elapsed: f32, num_rounds: s64) -> s64 {
if num_rounds <= 0 { return 0; }
if elapsed < SWAP_ANIM_DUR { return 0; }
seg := CLEAR_ANIM_DUR + FALL_ANIM_DUR;
started := cast(s64) ((elapsed - SWAP_ANIM_DUR) / seg) + 1;
if started > num_rounds { return num_rounds; }
started
}
// Input gate: the board accepts a new swipe/tap gesture only when no move
// animation is in flight. The view checks this at gesture START (mouse_down),
// not at commit (mouse_up), so a gesture begun while a timeline is playing never
// latches a drag and so cannot commit when the animation later settles. Input
// resumes once `tick` clears `active` at the end of the timeline. A null anim
// (no animation layer wired) always accepts.
accepts_input :: (anim: *BoardAnim) -> bool {
anim == null or !anim.active
}

320
board_fx.sx Normal file
View File

@@ -0,0 +1,320 @@
// Match FX & score popups (P6.2) — a PURELY VISUAL, transient layer played over
// a committed move. It never touches the model: it reads the recorded AnimMove
// (per-round matched cells + the model's own awarded points) and spawns short-
// lived particle bursts at the cleared cells plus one floating "+points" popup,
// all driven by the frame loop's delta_time. Everything is gone shortly after
// the move settles, and none of it gates input (that stays on BoardAnim.active).
//
// The provided art (assets/fx/particle.png) is a WHITE soft-glow sparkle; the
// engine's image path can't tint or fade a texture at draw time (it samples
// texture*white), so the white sprite is tinted per gem/combo colour HERE at
// load time into one texture per colour, and a burst animates by SCALE (grow →
// shrink to nothing) rather than alpha — the soft texture edges carry the fade.
#import "modules/std.sx";
#import "modules/math";
#import "modules/opengl.sx";
#import "modules/stb.sx";
#import "modules/gpu/types.sx";
#import "modules/gpu/api.sx";
#import "modules/ui/types.sx";
#import "board.sx";
#import "board_layout.sx";
#import "board_anim.sx";
// Burst timing/size. A burst fires when its round's gems start clearing and
// lingers a touch into the fall, growing in two ways so deeper cascades read as
// more exciting: a per-move DEPTH boost lifts every burst of a deep cascade from
// its first round (`FX_BURST_DEPTH` per combo level, escalating in lockstep with
// the cascade SFX), and a per-round bump grows the later rounds within a move
// (`FX_BURST_COMBO`, capped). Sizes are in CELL units (1.0 == one grid cell).
FX_BURST_LIFE :f32: 0.70;
FX_BURST_BASE :f32: 2.50;
FX_BURST_COMBO :f32: 0.72; // extra peak size per round index within a move (capped)
FX_BURST_DEPTH :f32: 0.45; // extra peak size per cascade combo level (whole move)
// Popup timing/motion. Rises ~1.2 cells over its life and fades out. A combo
// (depth > 1) popup is gold and grows one step per combo level, topped by a
// `COMBO xN` label naming the cascade depth; both escalate in lockstep with the
// cascade SFX cue (see `fx_combo_level`).
FX_POPUP_LIFE :f32: 1.40;
FX_POPUP_RISE :f32: 1.2;
FX_POPUP_FONT :f32: 34.0;
FX_POPUP_COMBO_FONT :f32: 48.0;
FX_POPUP_COMBO_STEP :f32: 8.0; // extra popup font per combo level past the base
FX_COMBO_LABEL_RATIO :f32: 0.55; // `COMBO xN` label font as a fraction of the +points font
FX_COMBO_LABEL_GAP :f32: 0.12; // gap (cell units) between the label and +points
// Vivid candy tints so a soft glow reads brightly over the dark board, in gem
// order (red, orange, yellow, green, blue, purple). Saturated a touch past the
// pastel — the low channel is trimmed while the dominant/mid channel is lifted —
// so every burst pops as a punchier colour without losing luminance.
fx_tint :: (i: s64) -> Color {
if i == 0 { return Color.{ r = 255, g = 92, b = 62, a = 255 }; }
if i == 1 { return Color.{ r = 255, g = 164, b = 44, a = 255 }; }
if i == 2 { return Color.{ r = 255, g = 240, b = 72, a = 255 }; }
if i == 3 { return Color.{ r = 112, g = 250, b = 112, a = 255 }; }
if i == 4 { return Color.{ r = 96, g = 192, b = 255, a = 255 }; }
Color.{ r = 224, g = 124, b = 255, a = 255 }
}
FX_POPUP_COLOR :: Color.{ r = 255, g = 255, b = 255, a = 255 };
FX_POPUP_COMBO_COLOR :: Color.{ r = 255, g = 222, b = 130, a = 255 }; // base gold (depth 2)
FX_POPUP_COMBO_HOT :: Color.{ r = 255, g = 248, b = 214, a = 255 }; // hot near-white gold (deepest)
// Cascade depth (`mv.rounds.len`) -> combo emphasis level. Mirrors audio.sx's
// `cascade_cue_index` clamp EXACTLY (depth <= 1 -> 0, depth >= 5 -> the max), so
// the on-screen combo emphasis (popup size/colour + burst boost) steps up in
// lockstep with the cascade SFX cue. Pure arithmetic, OS-agnostic, and the
// equivalence to `cascade_cue_index` is locked headlessly (tests/fx_combo.sx).
FX_COMBO_MAX_LEVEL :: 4; // == audio.sx COMBO_CLIPS - 1
fx_combo_level :: (depth: s64) -> s64 {
if depth <= 1 { return 0; }
if depth >= FX_COMBO_MAX_LEVEL + 1 { return FX_COMBO_MAX_LEVEL; }
depth - 1
}
// Popup font size for a cascade `depth` rounds deep: a single clear (depth <= 1)
// uses the plain size; a combo starts at the base combo size and grows one step
// per combo level past the first, clamped at the deepest level.
fx_popup_font :: (depth: s64) -> f32 {
if depth <= 1 { return FX_POPUP_FONT; }
FX_POPUP_COMBO_FONT + FX_POPUP_COMBO_STEP * cast(f32) (fx_combo_level(depth) - 1)
}
// Popup colour for a cascade `depth` rounds deep: white for a single clear, else
// the gold lerped toward a hot near-white as the cascade deepens.
fx_popup_color :: (depth: s64) -> Color {
if depth <= 1 { return FX_POPUP_COLOR; }
t := cast(f32) (fx_combo_level(depth) - 1) / cast(f32) (FX_COMBO_MAX_LEVEL - 1);
Color.{
r = fx_lerp_u8(FX_POPUP_COMBO_COLOR.r, FX_POPUP_COMBO_HOT.r, t),
g = fx_lerp_u8(FX_POPUP_COMBO_COLOR.g, FX_POPUP_COMBO_HOT.g, t),
b = fx_lerp_u8(FX_POPUP_COMBO_COLOR.b, FX_POPUP_COMBO_HOT.b, t),
a = 255,
}
}
fx_lerp_u8 :: (lo: u8, hi: u8, t: f32) -> u8 {
cast(u8) (cast(f32) lo + (cast(f32) hi - cast(f32) lo) * t)
}
// Upload an RGBA buffer as a texture, returning its handle. Mirrors
// board_view.load_texture's upload half but takes an in-memory buffer (the
// per-colour tinted particle) instead of a file path.
upload_rgba :: (pixels: [*]u8, w: s32, h: s32, gpu: ?GPU) -> u32 {
if gpu != null {
return xx gpu.create_texture(w, h, .rgba8, xx pixels);
}
tex : u32 = 0;
glGenTextures(1, @tex);
glBindTexture(GL_TEXTURE_2D, tex);
glTexImage2D(GL_TEXTURE_2D, 0, xx GL_RGBA, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, xx GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, xx GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, xx GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, xx GL_CLAMP_TO_EDGE);
tex
}
// Loads the white particle once and bakes one tinted copy per colour. The white
// source's RGB is uniform, so a tint is just (tint.rgb, source.alpha) per pixel.
BoardFxAssets :: struct {
tex: [GEM_COUNT]u32;
loaded: bool;
init :: (self: *BoardFxAssets) {
for 0..GEM_COUNT: (t) { self.tex[t] = 0; }
self.loaded = false;
}
load :: (self: *BoardFxAssets, gpu: ?GPU) {
w : s32 = 0;
h : s32 = 0;
ch : s32 = 0;
src : [*]u8 = xx stbi_load("assets/fx/particle.png", @w, @h, @ch, 4);
if xx src == 0 {
out("WARNING: could not load assets/fx/particle.png\n");
self.loaded = false;
return;
}
n := cast(s64) w * cast(s64) h;
buf : [*]u8 = xx context.allocator.alloc(n * 4);
// Loop locals are hoisted: a block-scoped local declared inside a body
// that runs hundreds of thousands of times grows the stack per iteration
// (sx codegen), so the per-pixel tint loop only ASSIGNS pre-declared vars.
i : s64 = 0;
o : s64 = 0;
for 0..GEM_COUNT: (t) {
col := fx_tint(t);
i = 0;
while i < n {
o = i * 4;
buf[o] = col.r;
buf[o+1] = col.g;
buf[o+2] = col.b;
buf[o+3] = src[o+3];
i += 1;
}
self.tex[t] = upload_rgba(buf, w, h, gpu);
}
stbi_image_free(xx src);
self.loaded = true;
}
}
// A live burst: a soft glow centred on a board cell that grows then shrinks to
// nothing. `tint` indexes BoardFxAssets.tex; `delay` holds it invisible until
// its round's clear begins; `peak` is the peak size in cell units.
FxParticle :: struct {
col: f32;
row: f32;
tint: s64;
delay: f32;
age: f32;
life: f32;
peak: f32;
}
// A floating "+points" popup anchored at the initial clear's centroid, rising
// and fading over its life. `depth` is the cascade depth (`mv.rounds.len`): it
// drives the combo styling at render time (gold size step + `COMBO xN` label for
// depth > 1). Stores the raw points (not a formatted string): the label is built
// at render time in the frame's arena, so nothing allocated here has to outlive
// the spawning event.
FxPopup :: struct {
col: f32;
row: f32;
points: s64;
depth: s64;
delay: f32;
age: f32;
life: f32;
}
// Live FX state for the in-flight move. Heap-allocated (like BoardAnim) so it
// survives BoardView's per-frame rebuild; `tick` ages the FX and prunes the
// dead, and BoardView draws what is live.
BoardFx :: struct {
particles: List(FxParticle);
popups: List(FxPopup);
init :: (self: *BoardFx) {
self.particles = List(FxParticle).{};
self.popups = List(FxPopup).{};
}
clear :: (self: *BoardFx) {
self.particles.len = 0;
self.popups.len = 0;
}
// Spawn the FX for a committed legal move: a coloured burst at every cleared
// cell of every cascade round (timed to its clear), plus one popup showing
// the model's awarded points at the first round's centroid. Illegal moves
// (no clears, no award) spawn nothing.
begin :: (self: *BoardFx, mv: *AnimMove) {
self.clear();
if !mv.legal or mv.rounds.len == 0 { return; }
// Whole-move depth boost: a deeper cascade makes every burst bigger from
// its first round, escalating in lockstep with the cascade SFX cue.
depth_boost := FX_BURST_DEPTH * cast(f32) fx_combo_level(mv.rounds.len);
for 0..mv.rounds.len: (k) {
rd := @mv.rounds.items[k];
t0 := SWAP_ANIM_DUR + cast(f32) k * (CLEAR_ANIM_DUR + FALL_ANIM_DUR);
extra := depth_boost + FX_BURST_COMBO * cast(f32) min(k, 2);
// Stagger each burst's START by its gem's clear-ripple rank so the
// bursts ripple in lockstep with the staggered pops (P18.2) instead of
// one simultaneous flash. The round's audio cue still fires once at t0.
span := clear_diag_span(@rd.matched);
for 0..BOARD_CELLS: (idx) {
if rd.matched.cells[idx] {
g := rd.before[idx];
if g != .empty {
col := idx % BOARD_COLS;
row := idx / BOARD_COLS;
rdelay := CLEAR_STAGGER_MAX * clear_rank(span, col, row) * CLEAR_ANIM_DUR;
self.particles.append(FxParticle.{
col = cast(f32) col + 0.5,
row = cast(f32) row + 0.5,
tint = cast(s64) g,
delay = t0 + rdelay,
age = 0.0,
life = FX_BURST_LIFE,
peak = FX_BURST_BASE + extra,
});
}
}
}
}
// One popup for the whole move at the first clear's centroid.
rd0 := @mv.rounds.items[0];
sc : s64 = 0;
sr : s64 = 0;
cnt : s64 = 0;
for 0..BOARD_CELLS: (idx) {
if rd0.matched.cells[idx] {
sc += idx % BOARD_COLS;
sr += idx / BOARD_COLS;
cnt += 1;
}
}
if cnt == 0 { return; }
self.popups.append(FxPopup.{
col = cast(f32) sc / cast(f32) cnt + 0.5,
row = cast(f32) sr / cast(f32) cnt + 0.5,
points = mv.awarded,
depth = mv.rounds.len,
delay = SWAP_ANIM_DUR,
age = 0.0,
life = FX_POPUP_LIFE,
});
}
// Advance every live FX by `dt` and drop those past their lifetime. Kept
// simple: compact each list in place by overwriting dead entries.
tick :: (self: *BoardFx, dt: f32) {
w : s64 = 0;
i : s64 = 0;
while i < self.particles.len {
p := self.particles.items[i];
p.age += dt;
if p.age < p.delay + p.life {
self.particles.items[w] = p;
w += 1;
}
i += 1;
}
self.particles.len = w;
w = 0;
i = 0;
while i < self.popups.len {
q := self.popups.items[i];
q.age += dt;
if q.age < q.delay + q.life {
self.popups.items[w] = q;
w += 1;
}
i += 1;
}
self.popups.len = w;
}
}
// Burst size envelope over local progress 0..1: a fast rise to a peak then a
// fade back to zero, so a burst pops in and shrinks out (no alpha needed). 0
// outside [0,1].
fx_pop_env :: (t: f32) -> f32 {
if t <= 0.0 or t >= 1.0 { return 0.0; }
sin(PI * sqrt(t))
}
// Popup fade over local progress 0..1: full then ease-out to transparent.
fx_popup_fade :: (t: f32) -> f32 {
if t <= 0.0 { return 1.0; }
if t >= 1.0 { return 0.0; }
u := 1.0 - t;
u * u
}

95
board_layout.sx Normal file
View File

@@ -0,0 +1,95 @@
// Pure geometry of the on-screen board: where the centered 8×8 grid sits inside
// a frame, and the two-way mapping between cells and screen points. Owns no
// rendering and pulls in NO GL/stb imports, so the touch→cell mapping is
// unit-testable headless. BoardView composes this for layout + hit-testing, and
// P5's swap input reuses `point_to_cell` to resolve a tap to a swap endpoint.
#import "modules/std.sx";
#import "modules/math";
#import "modules/ui/types.sx";
#import "board.sx";
BoardLayout :: struct {
cell_size: f32;
origin: Point;
// Center a square 8×8 grid inside the safe-area-inset region of `frame`.
compute :: (self: *BoardLayout, frame: Frame, safe: EdgeInsets) {
avail := frame.inset(safe);
cols : f32 = xx BOARD_COLS;
board_dim := min(avail.size.width, avail.size.height);
self.cell_size = board_dim / cols;
total := self.cell_size * cols;
self.origin = Point.{
x = avail.origin.x + (avail.size.width - total) * 0.5,
y = avail.origin.y + (avail.size.height - total) * 0.5
};
}
cell_frame :: (self: *BoardLayout, col: s64, row: s64) -> Frame {
Frame.make(
self.origin.x + xx col * self.cell_size,
self.origin.y + xx row * self.cell_size,
self.cell_size,
self.cell_size
)
}
// Inverse of `cell_frame`: map a view-local point to the grid cell under it,
// or null when the point falls outside the 8×8 grid. The `< 0.0` guards run
// BEFORE the truncating cast, since casting a small negative float rounds
// toward zero into a valid index. Uses the SAME origin / cell_size `compute`
// produced, so a tap resolves to exactly the cell drawn under the finger.
point_to_cell :: (self: *BoardLayout, p: Point) -> ?Cell {
if self.cell_size <= 0.0 { return null; }
fx := (p.x - self.origin.x) / self.cell_size;
fy := (p.y - self.origin.y) / self.cell_size;
if fx < 0.0 or fy < 0.0 { return null; }
col : s64 = xx fx;
row : s64 = xx fy;
if col >= BOARD_COLS or row >= BOARD_ROWS { return null; }
Cell.{ col = col, row = row }
}
// Frame of the whole 8×8 grid (origin + cols/rows × cell_size). The banner
// and its dimming overlay are sized off this so they cover exactly the board.
grid_frame :: (self: *BoardLayout) -> Frame {
Frame.make(
self.origin.x, self.origin.y,
self.cell_size * cast(f32) BOARD_COLS,
self.cell_size * cast(f32) BOARD_ROWS
)
}
// Win/lose banner geometry (P7.2): an overlay panel centered over the board
// grid, with the title band and the restart button inside it. Derived purely
// from the SAME grid layout the gems use, so the restart hit-test in
// BoardView.handle_event lands on exactly the button BoardView draws. The
// headless banner_layout test locks the button-rect ↔ hit-test round-trip.
banner :: (self: *BoardLayout) -> BannerLayout {
grid := self.grid_frame();
cx := grid.mid_x();
cy := grid.mid_y();
panel_w := grid.size.width * 0.84;
panel_h := grid.size.height * 0.44;
panel := Frame.make(cx - panel_w * 0.5, cy - panel_h * 0.5, panel_w, panel_h);
title := Frame.make(panel.origin.x, panel.origin.y + panel_h * 0.18, panel_w, panel_h * 0.30);
btn_w := panel_w * 0.60;
btn_h := panel_h * 0.24;
btn_y := panel.origin.y + panel_h - btn_h - panel_h * 0.16;
button := Frame.make(cx - btn_w * 0.5, btn_y, btn_w, btn_h);
BannerLayout.{ panel = panel, title = title, button = button }
}
}
// Resolved rectangles of the win/lose banner: the centered `panel`, the `title`
// band where the win/lose headline is centered, and the restart `button` rect
// (also the hit-test target). All in the same view-local space as BoardLayout.
BannerLayout :: struct {
panel: Frame;
title: Frame;
button: Frame;
}

894
board_view.sx Normal file
View File

@@ -0,0 +1,894 @@
// BoardView (P4.3) — render the seeded match-3 board with real gem sprites.
//
// Modeled on game/chess/board_view.sx: a `View` that lays out an 8×8 grid and
// draws tiles/sprites through RenderContext.add_image / add_image_uv, sampling
// the gem sprite sheet by UV column. The background image fills the whole view;
// the grid is a centered square inside the safe-area inset.
#import "modules/std.sx";
#import "modules/math";
#import "modules/opengl.sx";
#import "modules/stb.sx";
#import "modules/gpu/types.sx";
#import "modules/gpu/api.sx";
#import "modules/ui/types.sx";
#import "modules/ui/render.sx";
#import "modules/ui/events.sx";
#import "modules/ui/view.sx";
#import "modules/ui/font.sx";
#import "board.sx";
#import "board_layout.sx";
#import "board_anim.sx";
#import "board_fx.sx";
#import "gem_anim.sx";
#import "swipe.sx";
#import "audio.sx";
// Fraction of a cell each gem occupies; the remainder is margin so a gem sits
// inside its cell tile rather than touching the tile's edges.
GEM_FILL_FRAC :f32: 0.84;
// Content margin layered on top of the platform safe-area insets: frames the
// grid off the left/right screen bezel so the gems aren't flush to the edge.
// The grid is width-constrained on a portrait phone, so this is the inset that
// actually sizes it; vertical centering inside the safe area is unchanged.
BOARD_INSET_X :f32: 16.0;
// Selection overlay (P11.3): a soft candy "glow" halo, a warm wash over the cell,
// a bright rim topped by a glossy inner highlight, and a wet sheen on the chosen
// gem. `add_stroked_rect` paints the border band in its FILL colour (the shader
// ignores the separate stroke colour), so each ring colour is passed as the fill.
// The engine can't tint/fade a texture at draw time (issue 0002), so every layer
// here is a rect/overlay — never a gem-texture tint.
SELECT_GLOW_OUT :: Color.{ r = 255, g = 232, b = 140, a = 30 }; // wide faint outer bloom
SELECT_GLOW_IN :: Color.{ r = 255, g = 238, b = 150, a = 70 }; // brighter near-edge halo
SELECT_FILL :: Color.{ r = 255, g = 244, b = 150, a = 80 }; // warm wash over the gem
SELECT_RIM :: Color.{ r = 255, g = 234, b = 92, a = 255 }; // bright candy rim
SELECT_RIM_HI :: Color.{ r = 255, g = 255, b = 232, a = 220 }; // glossy inner highlight ring
SELECT_GLOSS :: Color.{ r = 255, g = 255, b = 255, a = 96 }; // wet sheen on the selected gem
// HUD (P12.2): a glossy candy card with the score and remaining moves, in the
// loaded Lato font. Placed in the empty band above the centered grid (inside the
// safe area). The fill is a bright grape candy, lifted by a translucent top sheen
// and a bright rounded rim; cream text rides a soft purple shadow for punch.
HUD_FONT :f32: 34.0;
HUD_PAD :f32: 14.0;
HUD_LINE_GAP :f32: 6.0;
HUD_RADIUS :f32: 20.0;
HUD_TEXT :: Color.{ r = 255, g = 252, b = 245, a = 255 }; // warm cream text
HUD_TEXT_SH :: Color.{ r = 56, g = 18, b = 80, a = 150 }; // soft purple text shadow
HUD_PANEL :: Color.{ r = 92, g = 46, b = 150, a = 224 }; // bright grape candy fill
HUD_PANEL_HI :: Color.{ r = 196, g = 138, b = 240, a = 92 }; // glossy top sheen
HUD_PANEL_RIM:: Color.{ r = 236, g = 204, b = 255, a = 150 }; // bright candy rim
// FPS dev overlay (P20.1): a small corner readout, OFF unless M3TE_FPS pins it on
// (so default play + every golden are unchanged). Pinned to the top-left of the
// safe area — clear of the centered notch / Dynamic Island and the centered HUD.
// Dark grape text over a bright halo keeps it legible on the light lavender art.
FPS_FONT :f32: 22.0;
FPS_PAD :f32: 8.0;
FPS_TEXT :: Color.{ r = 40, g = 16, b = 64, a = 235 }; // dark grape, readable on lavender
FPS_TEXT_SH:: Color.{ r = 255, g = 255, b = 255, a = 170 }; // bright halo for contrast
// Win/lose banner (P12.2): a warm dim over the board, a glossy candy panel, the
// win/lose headline, and a playful restart button. Built from text + rects only —
// the engine's image path can't tint/fade at draw time (issue 0002), but rects and
// text DO honour colour + alpha, so the whole overlay is drawn with them. Each
// candy surface is a fill + a top sheen + a bright rounded rim; titles and the
// button label ride a tinted drop shadow so they pop off the panel.
BANNER_DIM :: Color.{ r = 26, g = 10, b = 44, a = 184 }; // warm purple dim
BANNER_PANEL :: Color.{ r = 96, g = 50, b = 156, a = 244 }; // grape candy panel
BANNER_PANEL_HI :: Color.{ r = 198, g = 140, b = 242, a = 110 }; // glossy panel sheen
BANNER_PANEL_RIM :: Color.{ r = 240, g = 208, b = 255, a = 168 }; // bright panel rim
BANNER_WIN_TEXT :: Color.{ r = 255, g = 220, b = 96, a = 255 }; // celebratory candy gold
BANNER_WIN_SH :: Color.{ r = 120, g = 56, b = 8, a = 220 }; // warm amber shadow
BANNER_LOSE_TEXT :: Color.{ r = 255, g = 104, b = 104, a = 255 }; // punchy candy coral
BANNER_LOSE_SH :: Color.{ r = 92, g = 14, b = 32, a = 220 }; // deep berry shadow
BANNER_BTN :: Color.{ r = 255, g = 120, b = 178, a = 255 }; // bubblegum candy CTA
BANNER_BTN_HI :: Color.{ r = 255, g = 198, b = 222, a = 150 }; // glossy button sheen
BANNER_BTN_RIM :: Color.{ r = 255, g = 226, b = 240, a = 184 }; // bright button rim
BANNER_BTN_SHADE :: Color.{ r = 198, g = 52, b = 120, a = 210 }; // darker bevel lip (3D)
BANNER_BTN_TEXT :: Color.{ r = 255, g = 255, b = 255, a = 255 };
BANNER_BTN_TEXT_SH:: Color.{ r = 120, g = 20, b = 64, a = 200 }; // button label shadow
BANNER_PANEL_RADIUS :f32: 24.0;
BANNER_BTN_RADIUS :f32: 16.0;
BANNER_TITLE_FONT :f32: 52.0;
BANNER_BTN_FONT :f32: 30.0;
// UV sub-rect of one gem column, spanning the sheet's full height.
GemUV :: struct {
uv_min: Point;
uv_max: Point;
}
// Loads and holds the three board textures (background, cell tile, gem sheet)
// and maps a gem index to its column UV. Modeled on chess's ChessPieces.
BoardAssets :: struct {
bg_tex: u32;
cell_tex: u32;
gems_tex: u32;
cell_u: f32;
loaded: bool;
init :: (self: *BoardAssets) {
self.bg_tex = 0;
self.cell_tex = 0;
self.gems_tex = 0;
// gems.png is GEM_COUNT columns wide and one row tall, so a gem's UV
// column IS its gem index (0=red … 5=purple); cell_u is one column wide.
self.cell_u = 1.0 / cast(f32) GEM_COUNT;
self.loaded = false;
}
load :: (self: *BoardAssets, gpu: ?GPU) {
self.bg_tex = load_texture("assets/board/background.png", gpu);
self.cell_tex = load_texture("assets/board/cell.png", gpu);
self.gems_tex = load_texture("assets/gems/gems.png", gpu);
self.loaded = self.bg_tex != 0 and self.cell_tex != 0 and self.gems_tex != 0;
}
gem_uv :: (self: *BoardAssets, index: s64) -> GemUV {
u0 : f32 = xx index * self.cell_u;
GemUV.{
uv_min = Point.{ x = u0, y = 0.0 },
uv_max = Point.{ x = u0 + self.cell_u, y = 1.0 }
}
}
}
// Decode an RGBA image and upload it as a texture, returning the handle (0 on
// failure). When a GPU backend is bound (iOS Metal) it owns the upload; the
// desktop GL path falls back to a plain GL_TEXTURE_2D.
load_texture :: (path: [:0]u8, gpu: ?GPU) -> u32 {
w : s32 = 0;
h : s32 = 0;
ch : s32 = 0;
pixels := stbi_load(path, @w, @h, @ch, 4);
if pixels == null {
out("WARNING: could not load texture: ");
out(path);
out("\n");
return 0;
}
tex : u32 = 0;
if gpu != null {
tex = gpu.create_texture(w, h, .rgba8, xx pixels);
} else {
glGenTextures(1, @tex);
glBindTexture(GL_TEXTURE_2D, tex);
glTexImage2D(GL_TEXTURE_2D, 0, xx GL_RGBA, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, xx GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, xx GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, xx GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, xx GL_CLAMP_TO_EDGE);
}
stbi_image_free(pixels);
return tex;
}
// Which board cell the player has currently selected, if any. Lives behind a
// pointer (heap-allocated in main) because BoardView is a value rebuilt every
// frame from `build_ui`, so the view itself cannot carry state across frames.
// A tap toggles this highlight; a swipe commits a swap (see DragInput) and
// clears it.
BoardSelection :: struct {
active: bool;
cell: Cell;
// Animation clock value when this selection last became active, so the
// selection-pop reaction (gem_anim) can age from the moment of the tap.
since: f32;
init :: (self: *BoardSelection) {
self.active = false;
self.cell = Cell.{ col = 0, row = 0 };
self.since = 0.0;
}
clear :: (self: *BoardSelection) {
self.active = false;
}
// Tapping a cell selects it; tapping the cell already selected clears the
// selection, so a tap toggles its own cell and moves it to any other.
toggle :: (self: *BoardSelection, c: Cell) {
if self.active and self.cell.col == c.col and self.cell.row == c.row {
self.active = false;
} else {
self.active = true;
self.cell = c;
}
}
}
// Tracks an in-progress touch drag between its press and release so a swipe can
// be resolved on lift: the press records the start point, and release maps
// start→end through `swipe_intent` to an adjacent-swap intent. Heap-allocated
// (like BoardSelection) so it survives BoardView's per-frame rebuild between the
// down (touchesBegan → mouse_down) and up (touchesEnded → mouse_up) events.
DragInput :: struct {
active: bool;
start: Point;
init :: (self: *DragInput) {
self.active = false;
self.start = Point.{ x = 0.0, y = 0.0 };
}
begin :: (self: *DragInput, p: Point) {
self.active = true;
self.start = p;
}
clear :: (self: *DragInput) {
self.active = false;
}
}
BoardView :: struct {
board: *Board;
assets: *BoardAssets;
sel: *BoardSelection;
drag: *DragInput;
anim: *BoardAnim;
fx: *BoardFx;
fxassets: *BoardFxAssets;
motion: *GemMotion;
safe: EdgeInsets;
// Seed for `restart`: the same fixed seed main seeded the board with, so the
// restart button reproduces the identical starting level.
seed: s64;
// FPS dev overlay (P20.1). `fps_on` gates the corner readout (off by default,
// set only by the M3TE_FPS env pin); `fps` is the smoothed reciprocal frame
// rate computed in the frame loop. Purely a render overlay.
fps_on: bool;
fps: f32;
// Where the grid sits + the touch↔cell mapping. Recomputed each render /
// event from the current frame so the hit-test matches what was drawn.
layout: BoardLayout;
compute_layout :: (self: *BoardView, frame: Frame) {
self.layout.compute(frame, self.content_insets());
}
// Platform safe-area insets widened by the content margin, so the grid (and
// the hit-test / banner geometry derived from it) is framed off the screen
// bezel. The HUD keeps using the bare safe insets, so it still hugs the top
// below the notch / Dynamic Island rather than shifting in with the board.
content_insets :: (self: *BoardView) -> EdgeInsets {
EdgeInsets.{
top = self.safe.top,
left = self.safe.left + BOARD_INSET_X,
bottom = self.safe.bottom,
right = self.safe.right + BOARD_INSET_X,
}
}
// Draw gem `gem_index`'s sprite-sheet column into `gf`.
draw_gem :: (self: *BoardView, ctx: *RenderContext, gf: Frame, gem_index: s64) {
uv := self.assets.gem_uv(gem_index);
ctx.add_image_uv(gf, self.assets.gems_tex, uv.uv_min, uv.uv_max);
}
// Frame for a gem at a (possibly fractional) board position, inset inside its
// cell. Fractional col/row is how the swap-slide and fall animations place a
// gem partway between cells.
gem_frame :: (self: *BoardView, fcol: f32, frow: f32, inset: f32, dim: f32) -> Frame {
Frame.make(
self.layout.origin.x + fcol * self.layout.cell_size + inset,
self.layout.origin.y + frow * self.layout.cell_size + inset,
dim,
dim
)
}
// Frame for a gem shrunk by `scale` about its cell centre — the clear
// scale-out. At scale 0 the gem is a zero-size frame (gone).
gem_frame_scaled :: (self: *BoardView, col: s64, row: s64, dim: f32, scale: f32) -> Frame {
cs := self.layout.cell_size;
cx := self.layout.origin.x + cast(f32) col * cs + cs * 0.5;
cy := self.layout.origin.y + cast(f32) row * cs + cs * 0.5;
d := dim * scale;
Frame.make(cx - d * 0.5, cy - d * 0.5, d, d)
}
// Frame for a gem at cell (col,row) drawn with a per-gem animation pose: the
// sprite is scaled about its cell centre and nudged by the pose offset (both
// in cell units). A resting pose reproduces gem_frame exactly, so the t==0
// idle pose draws identically to the static sprite.
gem_pose_frame :: (self: *BoardView, col: s64, row: s64, dim: f32, pose: GemPose) -> Frame {
cs := self.layout.cell_size;
cx := self.layout.origin.x + (cast(f32) col + 0.5) * cs + pose.dx * cs;
cy := self.layout.origin.y + (cast(f32) row + 0.5) * cs + pose.dy * cs;
w := dim * pose.scale_x;
h := dim * pose.scale_y;
Frame.make(cx - w * 0.5, cy - h * 0.5, w, h)
}
// Frame for a gem at a (possibly fractional) row in column `col`, squashed by
// `sq` about its cell centre: scale_x = 1+sq (wider), scale_y = 1-sq (shorter)
// — the wide-and-short landing impact. sq==0 reproduces gem_frame's centred
// placement EXACTLY, so a gem still mid-fall (or one that never moved) draws
// byte-identically to the plain fall; only a landed gem flattens.
gem_squash_frame :: (self: *BoardView, col: s64, frow: f32, dim: f32, sq: f32) -> Frame {
cs := self.layout.cell_size;
cx := self.layout.origin.x + (cast(f32) col + 0.5) * cs;
cy := self.layout.origin.y + (frow + 0.5) * cs;
w := dim * (1.0 + sq);
h := dim * (1.0 - sq);
Frame.make(cx - w * 0.5, cy - h * 0.5, w, h)
}
// The per-gem animation pose for a settled cell: the always-on idle breath,
// plus a squash-bounce if the cell landed recently, plus a pop if it is the
// selected cell. Purely visual — composed from gem_anim's pure functions.
gem_pose_at :: (self: *BoardView, col: s64, row: s64) -> GemPose {
pose := idle_pose(self.motion.clock, col, row);
sq := land_squash(self.motion.land_local(Board.idx(col, row)));
pose.scale_x += sq;
pose.scale_y -= sq;
if self.sel != null and self.sel.active
and self.sel.cell.col == col and self.sel.cell.row == row {
ts := if self.motion.pinned then self.motion.clock else self.motion.clock - self.sel.since;
sp := select_pop_scale(ts);
pose.scale_x *= sp;
pose.scale_y *= sp;
}
pose
}
// Per-round landing squash for the gem resting at cell `i` at move-timeline
// time `elapsed`, considering rounds up to `kmax`. The gem landed in its
// `delivering_round`; the bounce ages from that round's landing instant through
// the shared `land_squash` envelope. A gem still mid-fall reads a NEGATIVE age
// (land_squash → 0, so it draws unsquashed) and one that never moved reads 0.
// render_fall passes the current round; render_clear the previous (its board is
// that round's `after`), so the one bounce plays on across the fall→clear seam.
rest_squash :: (self: *BoardView, i: s64, kmax: s64, elapsed: f32) -> f32 {
m := delivering_round(@self.anim.move, i, kmax);
if m < 0 { return 0.0; }
col := i % BOARD_COLS;
land_squash(elapsed - round_land_time(m, col))
}
// Settled-board gems: one sprite per non-empty cell, drawn with its live
// per-gem animation pose. Used whenever no move is animating.
render_gems :: (self: *BoardView, ctx: *RenderContext, dim: f32) {
for 0..BOARD_ROWS: (row) {
for 0..BOARD_COLS: (col) {
g := self.board.at(col, row);
if g != .empty {
pose := self.gem_pose_at(col, row);
gf := self.gem_pose_frame(col, row, dim, pose);
self.draw_gem(ctx, gf, cast(s64) g);
}
}
}
}
// Selection emphasis (P11.3): a glossier candy highlight on the chosen cell.
// Two concentric stroked rings fake a soft outward glow (the renderer has no
// blur), 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. All rect/overlay layers (issue 0002 forbids a draw-time gem tint); the
// selection-pop motion still comes from gem_anim, so the t==0 idle pose is
// untouched.
render_selection :: (self: *BoardView, ctx: *RenderContext, dim: f32) {
cs := self.layout.cell_size;
cf := self.layout.cell_frame(self.sel.cell.col, self.sel.cell.row);
// Glow halo: rings just outside the cell edge, brighter nearer the rim, so
// the falloff reads as a soft bloom without tinting the gem interior.
ctx.add_stroked_rect(cf.expand(cs * 0.16), SELECT_GLOW_OUT, SELECT_GLOW_OUT, cs * 0.16, cs * 0.30);
ctx.add_stroked_rect(cf.expand(cs * 0.07), SELECT_GLOW_IN, SELECT_GLOW_IN, cs * 0.08, cs * 0.21);
// Warm wash + bright rim + a thin glossy highlight ring just inside the rim.
ctx.add_rounded_rect(cf, SELECT_FILL, cs * 0.14);
rim_w := max(2.0, cs * 0.06);
ctx.add_stroked_rect(cf, SELECT_RIM, SELECT_RIM, rim_w, cs * 0.14);
hi_w := max(1.0, cs * 0.022);
ctx.add_stroked_rect(cf.expand(0.0 - rim_w), SELECT_RIM_HI, SELECT_RIM_HI, hi_w, cs * 0.11);
// Wet sheen on the selected gem: a bright pill in its upper third, sized to
// the gem's live pose so it tracks the selection pop.
pose := self.gem_pose_at(self.sel.cell.col, self.sel.cell.row);
gf := self.gem_pose_frame(self.sel.cell.col, self.sel.cell.row, dim, pose);
gw := gf.size.width;
gh := gf.size.height;
gloss := Frame.make(gf.origin.x + gw * 0.22, gf.origin.y + gh * 0.13, gw * 0.40, gh * 0.22);
ctx.add_rounded_rect(gloss, SELECT_GLOSS, gh * 0.12);
}
// Play the active slice of the move timeline. Gem motion is clipped to the
// grid so refilled gems slide in from behind the top edge rather than
// overlapping the HUD band above the board.
render_anim :: (self: *BoardView, ctx: *RenderContext, inset: f32, dim: f32) {
ph := self.anim.phase();
cs := self.layout.cell_size;
grid := Frame.make(
self.layout.origin.x, self.layout.origin.y,
cs * cast(f32) BOARD_COLS, cs * cast(f32) BOARD_ROWS
);
ctx.push_clip(grid);
mv := @self.anim.move;
e := self.anim.elapsed;
if ph.kind == .swap {
self.render_swap(ctx, mv, inset, dim, ph.t);
} else if ph.kind == .clear {
rd := @mv.rounds.items[ph.round];
self.render_clear(ctx, rd, ph.round, e, dim, ph.t);
} else if ph.kind == .fall {
rd := @mv.rounds.items[ph.round];
self.render_fall(ctx, rd, ph.round, e, dim, ph.t);
} else {
// Settled tail of the timeline — draw the final (model) board, still
// carrying the final round's landing bounce so this rare safety-net
// frame matches both the fall it follows and the render_gems hand-off
// (which resumes the same back-dated stamp). tick() normally clears
// `active` before this is reached.
last := mv.rounds.len - 1;
for 0..BOARD_CELLS: (i) {
g := mv.final[i];
if g != .empty {
sq := self.rest_squash(i, last, e);
gf := self.gem_squash_frame(i % BOARD_COLS, cast(f32) (i / BOARD_COLS), dim, sq);
self.draw_gem(ctx, gf, cast(s64) g);
}
}
}
ctx.pop_clip();
}
// 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 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);
for 0..BOARD_CELLS: (i) {
if i == ai or i == bi { continue; }
g := mv.pre[i];
if g != .empty {
gf := self.gem_frame(cast(f32) (i % BOARD_COLS), cast(f32) (i / BOARD_COLS), inset, dim);
self.draw_gem(ctx, gf, cast(s64) g);
}
}
p : f32 = ---;
if mv.legal {
// Overshoot-and-settle: the two gems shoot a touch PAST their target
// cells, then settle exactly onto them, instead of decelerating flatly
// 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 {
// 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;
bfc := cast(f32) mv.b.col; bfr := cast(f32) mv.b.row;
ga := mv.pre[ai];
if ga != .empty {
gf := self.gem_frame(afc + (bfc - afc) * p, afr + (bfr - afr) * p, inset, dim);
self.draw_gem(ctx, gf, cast(s64) ga);
}
gb := mv.pre[bi];
if gb != .empty {
gf := self.gem_frame(bfc + (afc - bfc) * p, bfr + (afr - bfr) * p, inset, dim);
self.draw_gem(ctx, gf, cast(s64) gb);
}
}
// Clear segment: matched gems pop outward then collapse to nothing (a
// satisfying pop, composing with the particle burst); the rest hold position.
render_clear :: (self: *BoardView, ctx: *RenderContext, rd: *AnimRound, k: s64, e: f32, dim: f32, t: f32) {
span := clear_diag_span(@rd.matched);
for 0..BOARD_CELLS: (i) {
g := rd.before[i];
if g == .empty { continue; }
col := i % BOARD_COLS;
row := i / BOARD_COLS;
if rd.matched.cells[i] {
// Ripple: each matched gem's pop START is offset by its diagonal
// rank within the round (clear_ripple_t), so the matched cells
// explode as a wave instead of simultaneously; every gem still
// reaches scale 0 by t==1, keeping the seam to the fall clean.
pop := clear_pop_scale(clear_ripple_t(t, clear_rank(span, col, row)));
gf := self.gem_frame_scaled(col, row, dim, pop);
self.draw_gem(ctx, gf, cast(s64) g);
} else {
// before[k] is round k-1's settled board, so a survivor here still
// carries the bounce from the round that dropped it in — continue it
// across the fall→clear seam (kmax = k-1). sq==0 for round 0's clear
// (nothing has fallen yet), keeping that frame byte-identical.
sq := self.rest_squash(i, k - 1, e);
gf := self.gem_squash_frame(col, cast(f32) row, dim, sq);
self.draw_gem(ctx, gf, cast(s64) g);
}
}
}
// Transient match FX (P6.2): coloured glow bursts at the cleared cells,
// clipped to the grid so a burst's glow never bleeds over the HUD. Each
// burst grows then shrinks to nothing; the soft texture carries the fade.
render_fx_particles :: (self: *BoardView, ctx: *RenderContext) {
if self.fx == null or self.fxassets == null or !self.fxassets.loaded { return; }
if self.fx.particles.len == 0 { return; }
cs := self.layout.cell_size;
grid := Frame.make(
self.layout.origin.x, self.layout.origin.y,
cs * cast(f32) BOARD_COLS, cs * cast(f32) BOARD_ROWS
);
ctx.push_clip(grid);
for 0..self.fx.particles.len: (i) {
p := self.fx.particles.items[i];
lt := (p.age - p.delay) / p.life;
env := fx_pop_env(lt);
if env > 0.0 {
size := env * p.peak * cs;
cx := self.layout.origin.x + p.col * cs;
cy := self.layout.origin.y + p.row * cs;
gf := Frame.make(cx - size * 0.5, cy - size * 0.5, size, size);
ctx.add_image(gf, self.fxassets.tex[p.tint]);
}
}
ctx.pop_clip();
}
// Floating "+points" popups: rise and fade above the initial clear. Drawn
// unclipped (over everything) so the number stays legible as it lifts off
// the grid. The text path honours the colour's alpha, so these truly fade.
// A combo (depth > 1) escalates with cascade depth: gold and larger, topped
// by a `COMBO xN` label naming the depth — the same depth the cascade SFX
// escalates on — so deeper cascades read as more exciting.
render_fx_popups :: (self: *BoardView, ctx: *RenderContext) {
if self.fx == null or self.fx.popups.len == 0 { return; }
cs := self.layout.cell_size;
for 0..self.fx.popups.len: (i) {
q := self.fx.popups.items[i];
lt := (q.age - q.delay) / q.life;
if lt >= 0.0 {
fade := fx_popup_fade(lt);
font := fx_popup_font(q.depth);
base := fx_popup_color(q.depth);
col := Color.{ r = base.r, g = base.g, b = base.b, a = cast(u8) (fade * 255.0) };
txt := format("+{}", q.points);
sz := measure_text(txt, font);
cx := self.layout.origin.x + q.col * cs;
cy := self.layout.origin.y + (q.row - lt * FX_POPUP_RISE) * cs;
ctx.add_text(
Frame.make(cx - sz.width * 0.5, cy - sz.height * 0.5, sz.width, sz.height),
txt, font, col
);
if q.depth > 1 {
lfont := font * FX_COMBO_LABEL_RATIO;
ltxt := format("COMBO x{}", q.depth);
lsz := measure_text(ltxt, lfont);
lcy := cy - sz.height * 0.5 - cs * FX_COMBO_LABEL_GAP - lsz.height * 0.5;
ctx.add_text(
Frame.make(cx - lsz.width * 0.5, lcy - lsz.height * 0.5, lsz.width, lsz.height),
ltxt, lfont, col
);
}
}
}
}
// Fall segment: every gem of the round's settled board accelerates under
// gravity from its source row (above the board for refills) down to its
// destination cell. Each COLUMN's drop starts at a small staggered delay
// (fall_stagger_t) so a refilled row pours in as a cascade rather than a flat
// lockstep row; ease_in_cubic pins each column's f(1)=1, and fall_stagger_t
// guarantees every column reaches 1 by t==1, so each gem lands exactly on its
// cell and the seam to the next round / settled board stays invisible.
render_fall :: (self: *BoardView, ctx: *RenderContext, rd: *AnimRound, k: s64, e: f32, dim: f32, t: f32) {
for 0..BOARD_CELLS: (i) {
g := rd.after[i];
if g == .empty { continue; }
col := i % BOARD_COLS;
drow := i / BOARD_COLS;
src := rd.src[i];
te := ease_in_cubic(fall_stagger_t(t, col));
cur_row := cast(f32) src + (cast(f32) drow - cast(f32) src) * te;
// Squash on landing: rest_squash ages the bounce from this column's
// touch-down (kmax = k). A gem still falling reads a negative age → 0, so
// in-flight gems stay byte-identical to the plain fall; only a gem that
// has reached its cell flattens wide-and-short, then wobbles out.
sq := self.rest_squash(i, k, e);
gf := self.gem_squash_frame(col, cur_row, dim, sq);
self.draw_gem(ctx, gf, cast(s64) g);
}
}
// Whether the win/lose banner is up: the level is over AND any in-flight move
// animation has settled, so a winning/losing cascade plays to completion
// before the banner covers the board. Board input stays frozen the whole time
// the level is terminal (see handle_event), independent of this.
banner_up :: (self: *BoardView) -> bool {
if level_status(self.board) == .in_progress { return false; }
self.anim == null or !self.anim.active
}
// Win/lose overlay (P7.2): dim the board, draw the centered panel, the
// win/lose headline, and the restart button — all text + rects so colour and
// alpha are honoured. The button rect comes from the shared BannerLayout, so
// it sits exactly where handle_event hit-tests the restart tap.
render_banner :: (self: *BoardView, ctx: *RenderContext, status: Status) {
ctx.add_rect(self.layout.grid_frame(), BANNER_DIM);
bl := self.layout.banner();
// Candy panel: grape fill under a glossy top sheen and a bright rounded rim.
// Geometry is the shared bl.panel — only colour / rounding / gloss change.
ctx.add_rounded_rect(bl.panel, BANNER_PANEL, BANNER_PANEL_RADIUS);
ctx.add_rounded_rect(top_sheen(bl.panel, 0.42, BANNER_PANEL_RADIUS * 0.6), BANNER_PANEL_HI, BANNER_PANEL_RADIUS * 0.8);
prim := max(2.0, BANNER_PANEL_RADIUS * 0.12);
ctx.add_stroked_rect(bl.panel, BANNER_PANEL_RIM, BANNER_PANEL_RIM, prim, BANNER_PANEL_RADIUS);
title := if status == .won then "YOU WIN!" else "OUT OF MOVES";
tcol := if status == .won then BANNER_WIN_TEXT else BANNER_LOSE_TEXT;
tsh := if status == .won then BANNER_WIN_SH else BANNER_LOSE_SH;
tfont := fit_font(title, BANNER_TITLE_FONT, bl.title.size.width);
tsz := measure_text(title, tfont);
tfr := Frame.make(bl.title.mid_x() - tsz.width * 0.5, bl.title.mid_y() - tsz.height * 0.5, tsz.width, tsz.height);
ctx.add_text(Frame.make(tfr.origin.x + 2.0, tfr.origin.y + 3.0, tfr.size.width, tfr.size.height), title, tfont, tsh);
ctx.add_text(tfr, title, tfont, tcol);
// Candy button: a darker bevel lip peeks under the bubblegum fill for a 3D
// candy edge, lifted by a glossy sheen and a bright rim. The fill / hit rect
// is the shared bl.button, so the restart hit-test is byte-for-byte unchanged.
ctx.add_rounded_rect(Frame.make(bl.button.origin.x, bl.button.origin.y + 3.0, bl.button.size.width, bl.button.size.height), BANNER_BTN_SHADE, BANNER_BTN_RADIUS);
ctx.add_rounded_rect(bl.button, BANNER_BTN, BANNER_BTN_RADIUS);
ctx.add_rounded_rect(top_sheen(bl.button, 0.46, BANNER_BTN_RADIUS * 0.5), BANNER_BTN_HI, BANNER_BTN_RADIUS * 0.8);
brim := max(2.0, BANNER_BTN_RADIUS * 0.14);
ctx.add_stroked_rect(bl.button, BANNER_BTN_RIM, BANNER_BTN_RIM, brim, BANNER_BTN_RADIUS);
btxt := "PLAY AGAIN";
bfont := fit_font(btxt, BANNER_BTN_FONT, bl.button.size.width * 0.86);
bsz := measure_text(btxt, bfont);
bfr := Frame.make(bl.button.mid_x() - bsz.width * 0.5, bl.button.mid_y() - bsz.height * 0.5, bsz.width, bsz.height);
ctx.add_text(Frame.make(bfr.origin.x + 1.5, bfr.origin.y + 2.0, bfr.size.width, bfr.size.height), btxt, bfont, BANNER_BTN_TEXT_SH);
ctx.add_text(bfr, btxt, bfont, BANNER_BTN_TEXT);
}
// FPS dev overlay (P20.1): a small "FPS n" readout pinned to the top-left of
// the safe area, on top of everything. Drawn only when fps_on (the M3TE_FPS
// pin) is set, so the unset render path is byte-identical. A bright halo under
// the dark text keeps the digits legible over the light background art.
render_fps_overlay :: (self: *BoardView, ctx: *RenderContext, frame: Frame) {
n := cast(s64) (self.fps + 0.5);
txt := format("FPS {}", n);
sz := measure_text(txt, FPS_FONT);
x := frame.origin.x + self.safe.left + FPS_PAD;
y := frame.origin.y + self.safe.top + FPS_PAD;
f := Frame.make(x, y, sz.width, sz.height);
ctx.add_text(Frame.make(f.origin.x + 1.0, f.origin.y + 1.5, f.size.width, f.size.height), txt, FPS_FONT, FPS_TEXT_SH);
ctx.add_text(f, txt, FPS_FONT, FPS_TEXT);
}
// Restart action behind the banner's button: reseed the SAME starting level
// through the model (board.restart) and drop every transient view layer
// (selection, in-flight drag, move animation, FX, and the per-gem landing
// bounce) so the board returns to a clean, resting in_progress state. Without
// the motion reset a restart fired right after a terminal cascade would carry
// that move's landing squash onto the freshly seeded board.
do_restart :: (self: *BoardView) {
self.board.restart(self.seed);
self.sel.clear();
self.drag.clear();
if self.anim != null { self.anim.init(); }
if self.fx != null { self.fx.clear(); }
self.motion.reset_landings();
}
}
// Scale `base` font size down so `text` fits within `max_w` (measure_text scales
// linearly with font size, so one division lands it). Never scales up — a short
// headline keeps its size; only an over-wide one shrinks to fit the panel.
fit_font :: (text: string, base: f32, max_w: f32) -> f32 {
sz := measure_text(text, base);
if sz.width <= max_w or sz.width <= 0.0 { return base; }
base * max_w / sz.width
}
// A rounded rect covering the top `frac` of `f`, inset by `pad` on the sides and
// top — the glossy candy sheen sat over a panel/button fill. The renderer has no
// gradient, so a single brighter translucent cap fakes the gloss.
top_sheen :: (f: Frame, frac: f32, pad: f32) -> Frame {
Frame.make(f.origin.x + pad, f.origin.y + pad, f.size.width - pad * 2.0, f.size.height * frac)
}
impl View for BoardView {
size_that_fits :: (self: *BoardView, proposal: ProposedSize) -> Size {
Size.{ width = proposal.width ?? 0.0, height = proposal.height ?? 0.0 }
}
layout :: (self: *BoardView, bounds: Frame) {
self.compute_layout(bounds);
}
render :: (self: *BoardView, ctx: *RenderContext, frame: Frame) {
self.compute_layout(frame);
// 1. Background image fills the whole view, behind the grid.
if self.assets.bg_tex != 0 {
ctx.add_image(frame, self.assets.bg_tex);
}
// 2. One cell tile per board cell — the static grid, never animated.
gem_inset := self.layout.cell_size * (1.0 - GEM_FILL_FRAC) * 0.5;
gem_dim := self.layout.cell_size * GEM_FILL_FRAC;
if self.assets.cell_tex != 0 {
for 0..BOARD_ROWS: (row) {
for 0..BOARD_COLS: (col) {
ctx.add_image(self.layout.cell_frame(col, row), self.assets.cell_tex);
}
}
}
// 2b. Gems: while a move is animating, play its swap/clear/fall timeline;
// otherwise draw the settled model board. The timeline ends exactly on
// the model state, so the seam back to the static path is invisible.
if self.assets.gems_tex != 0 {
if self.anim != null and self.anim.active {
self.render_anim(ctx, gem_inset, gem_dim);
} else {
self.render_gems(ctx, gem_dim);
}
}
// 3. Selection emphasis on the chosen cell: a soft candy glow halo under a
// warm wash, a bright glossy rim, and a wet sheen on the popped gem.
if self.sel != null and self.sel.active {
self.render_selection(ctx, gem_dim);
}
// 4. HUD card with score + remaining moves, in the band above the grid.
avail := frame.inset(self.safe);
render_hud(ctx, self.board, avail);
// 5. Transient match FX over the board: coloured bursts at the cleared
// cells, then the floating "+points" popup on top. Purely visual and
// self-pruning, so they vanish once the move settles.
self.render_fx_particles(ctx);
self.render_fx_popups(ctx);
// 6. Win/lose banner over everything, once the level is over and the
// final cascade has settled. Status comes from the model (P7.1); the
// view never recomputes win/lose.
if self.banner_up() {
self.render_banner(ctx, level_status(self.board));
}
// 7. FPS dev overlay (P20.1), on top of everything. Off by default; only
// renders when M3TE_FPS pinned it on, so the unset path is unchanged.
if self.fps_on {
self.render_fps_overlay(ctx, frame);
}
}
// Touch input. A press records the drag start; the release resolves the
// gesture against the SAME layout it was drawn with. A swipe (start→end maps
// to an adjacent-swap intent) is fed straight into `commit_swap`: a legal
// swap applies, cascades, scores and spends a move, an illegal one reverts —
// either way the next frame re-renders the board + HUD from the model. A
// sub-threshold / off-board drag carries no intent and falls back to the tap
// behaviour: toggle the selection on the pressed cell, or clear it off-board.
handle_event :: (self: *BoardView, event: *Event, frame: Frame) -> bool {
self.compute_layout(frame);
// A finished level (won/lost) freezes board input: swipes/taps on cells
// are ignored. Status comes from the model (P7.1) — never recomputed
// here. Once the banner is up its restart button is the only live target;
// a tap inside it reseeds a fresh level through board.restart.
if level_status(self.board) != .in_progress {
if event.* == {
case .mouse_down: (d) { return true; }
case .mouse_up: (d) {
if self.banner_up() and self.layout.banner().button.contains(d.position) {
self.do_restart();
}
return true;
}
}
return false;
}
if event.* == {
case .mouse_down: (d) {
// Gate input at gesture START: while a move animation is in
// flight the board ignores new gestures for the WHOLE in-flight
// window, so a press begun mid-animation never latches a drag and
// so can't commit when the animation later ends. The press is
// still consumed; input resumes once the timeline settles.
if !accepts_input(self.anim) { return true; }
self.drag.begin(d.position);
return true;
}
case .mouse_up: (d) {
if !self.drag.active { return false; }
start := self.drag.start;
self.drag.clear();
if intent := swipe_intent(@self.layout, start, d.position) {
mv := plan_and_commit(self.board, intent.a, intent.b);
if self.anim != null { self.anim.begin(mv); }
if self.fx != null { self.fx.begin(@mv); }
// SFX: additive cues for the committed gesture — never reads
// or writes board/score/move state. The swap slide cue plays
// for any committed gesture (legal or the reverted ping-back);
// a legal move adds the match pop on its first clearing round.
// A multi-round chain's ascending combo cues are NOT fired here:
// the frame loop plays one per round, edge-triggered as each
// round visually clears (combo1, combo2, …), so the cascade
// reads as an audible ascending run instead of one cue at commit.
sfx_swap();
if mv.legal {
sfx_match();
}
self.sel.clear();
} else {
if hit := self.layout.point_to_cell(start) {
self.sel.toggle(hit);
// Re-arm the selection-pop reaction from this tap's moment.
if self.sel.active { self.sel.since = self.motion.clock; }
} else {
self.sel.clear();
}
}
return true;
}
}
false
}
}
// Draw the HUD card — current score against the per-level goal and the remaining
// moves (out of the move limit) — centered horizontally in the top of `avail`,
// the safe-area-inset region the grid is centered in. Reads live model state
// (score, target_score, moves), so it tracks the goal progress as the game runs.
// A translucent panel sits behind the text for legibility over the board art.
render_hud :: (ctx: *RenderContext, board: *Board, avail: Frame) {
score_str := format("SCORE {} / {}", board.score, board.target_score);
moves_str := format("MOVES {}/{}", board.moves_remaining(), board.move_limit);
score_sz := measure_text(score_str, HUD_FONT);
moves_sz := measure_text(moves_str, HUD_FONT);
text_w := max(score_sz.width, moves_sz.width);
panel_w := text_w + HUD_PAD * 2.0;
panel_h := score_sz.height + HUD_LINE_GAP + moves_sz.height + HUD_PAD * 2.0;
panel_x := avail.origin.x + (avail.size.width - panel_w) * 0.5;
panel_y := avail.origin.y + HUD_PAD;
panel := Frame.make(panel_x, panel_y, panel_w, panel_h);
// Candy card: grape fill, a glossy top sheen, then a bright rounded rim.
ctx.add_rounded_rect(panel, HUD_PANEL, HUD_RADIUS);
ctx.add_rounded_rect(top_sheen(panel, 0.46, HUD_RADIUS * 0.5), HUD_PANEL_HI, HUD_RADIUS * 0.8);
rim := max(2.0, HUD_RADIUS * 0.12);
ctx.add_stroked_rect(panel, HUD_PANEL_RIM, HUD_PANEL_RIM, rim, HUD_RADIUS);
tx := panel_x + HUD_PAD;
ty := panel_y + HUD_PAD;
hud_line(ctx, Frame.make(tx, ty, score_sz.width, score_sz.height), score_str);
ty += score_sz.height + HUD_LINE_GAP;
hud_line(ctx, Frame.make(tx, ty, moves_sz.width, moves_sz.height), moves_str);
}
// One HUD text row: a soft purple shadow under the warm cream text, so the line
// stays legible over the grape card. Geometry is the caller's row frame.
hud_line :: (ctx: *RenderContext, f: Frame, text: string) {
ctx.add_text(Frame.make(f.origin.x + 1.5, f.origin.y + 2.0, f.size.width, f.size.height), text, HUD_FONT, HUD_TEXT_SH);
ctx.add_text(f, text, HUD_FONT, HUD_TEXT);
}

39
build.sx Normal file
View File

@@ -0,0 +1,39 @@
#import "modules/compiler.sx";
#import "modules/platform/bundle.sx";
configure_build :: () {
opts := build_options();
opts.set_bundle_id("co.swipelab.m3te");
opts.set_post_link_callback(bundle_main);
if OS == {
case .macos: {
opts.set_output_path("sx-out/macos/M3te");
opts.set_bundle_path("sx-out/macos/M3te.app");
// Ad-hoc sign (empty identity) so a local `open` launch isn't
// Gatekeeper-rejected. SDL3 comes from Homebrew.
opts.add_link_flag("-L/opt/homebrew/lib");
opts.add_link_flag("-lSDL3");
opts.add_asset_dir("assets", "assets");
}
case .ios: {
opts.set_output_path("sx-out/ios/M3te");
opts.set_bundle_path("sx-out/ios/M3te.app");
// Device-only: the simulator is ad-hoc signed by the bundler,
// which also grants get-task-allow for lldb. Embedding a device
// identity / profile in a sim build blocks install + launch.
if opts.is_ios_device() {
opts.set_codesign_identity("Apple Development: Alexandru Agrapine (DC8VVHJ9W4)");
opts.set_provisioning_profile("/Users/agra/Downloads/SwipeS32DevProfile.mobileprovision");
}
opts.add_framework("UIKit");
opts.add_framework("OpenGLES");
opts.add_framework("QuartzCore");
opts.add_framework("Metal");
// System Sound Services SFX (audio.sx) — a short clip played when a
// swap clears a match. CoreFoundation supplies the file URL.
opts.add_framework("AudioToolbox");
opts.add_framework("CoreFoundation");
opts.add_asset_dir("assets", "assets");
}
}
}

184
docs/std-gaps.md Normal file
View File

@@ -0,0 +1,184 @@
# sx std-library gaps surfaced by m3te
m3te is authored entirely in sx. While building it, a handful of *general-purpose*
utilities had to be hand-rolled because the sx standard library / library modules
don't provide them. This report enumerates those candidates — the ones that
arguably belong in sx `std` (or a library module) — and, for each, records an
explicit cross-check against the real sx library so the list contains **true gaps
only**.
- **sx library checked:** `/Users/agra/projects/sx/library/modules/`
`std.sx`, `math/` (`math.sx`, `vector2.sx`, `matrix44.sx`), `std/`
(`objc.sx`, `cli.sx`, `hash.sx`, `json.sx`, `uikit.sx`, `objc_block.sx`),
`process.sx`, `fs.sx`, `test.sx`, `ui/` (`types.sx`, `animation.sx`,
`image.sx`, …), `gpu/`, `platform/`.
- **m3te source read:** `*.sx` (`main.sx`, `board.sx`, `board_view.sx`,
`board_anim.sx`, `board_fx.sx`, `board_layout.sx`, `gem_anim.sx`, `audio.sx`,
`swipe.sx`, `build.sx`), `tests/test.sx`, `tools/`.
Highest-value candidates first. Borderline items are flagged. A separate
"Already provided" section lists helpers m3te *also* hand-rolled but which the
library **does** ship (so they are honestly **not** gaps) — included because
the step asks for an honest accounting.
---
## Category: Numerics / parsing
### 1. String → number parsing (`parse_s64`, `parse_f32`)
- **m3te location:** `main.sx:156` (`parse_s64`), `main.sx:122` (`parse_f32`).
- **What it does:** decimal ASCII `string``s64` / `f32` (sign + integer +
optional fractional part).
- **Why general-purpose:** the exact inverse of the formatters everyone already
uses; needed by any program that reads numbers from argv, env, config, or a
text file. Nothing game-specific about it.
- **Suggested home:** `modules/std` (alongside `int_to_string` /
`float_to_string`), or a dedicated `modules/strings`.
- **Not already in sx library (checked):** `std.sx` ships only the *forward*
direction — `int_to_string` (`std.sx:68`), `uint_to_string`,
`float_to_string` (`std.sx:121`), `int_to_hex_string` — and **no** parse.
No `atoi`/`strtol`/`strtod`/`string_to_int` anywhere under
`library/modules` (grepped). `std/json.sx` has a `Parser.parse_number`
(`json.sx:547`) but it is a private method consuming a JSON parser's buffer,
not a reusable `string → number`.
### 2. Seedable deterministic PRNG (`Rng` / LCG)
- **m3te location:** `board.sx:55` (`Rng` struct: `next_u32` `board.sx:59`,
`next_range` `board.sx:66`), `board.sx:71` (`rng_seeded`), constants
`board.sx:51-53`.
- **What it does:** a 32-bit linear-congruential generator carried in `s64`
and masked to 32 bits — `next_u32()`, `next_range(n)` (uniform-ish `[0,n)`),
`rng_seeded(seed)`.
- **Why general-purpose:** a seedable, reproducible RNG is a textbook stdlib
primitive (simulations, procedural generation, shuffles, tests). m3te's copy
is written to be host-width-independent; only its *consumer* (`pick_gem`) is
game-specific — the generator itself is not.
- **Suggested home:** `modules/math` (or a new `modules/random`).
- **Not already in sx library (checked):** no `rand`/`rng`/`lcg`/`xorshift`/
`random`/`seed` generator anywhere under `library/modules` (grepped names and
the classic LCG constants `1664525` / `1013904223`). `std/hash.sx` is SHA-256
only (content addressing, not a PRNG). `math/math.sx` has `sin`/`cos`/`sqrt`/
`clamp`/… but no randomness.
---
## Category: FFI / system ergonomics
### 3. NUL-terminated C string (`*u8`) → sx `string` bridge (`from_cstr`)
- **m3te location:** `main.sx:108-117` (`read_env`, copying form: `strlen` +
`cstring` + `memcpy`); `audio.sx:144-148` (viewing form over a `getcwd`
buffer using `c_strlen`). libc `strlen` is re-bound in both `main.sx:28` and
`audio.sx:30`.
- **What it does:** turn a foreign NUL-terminated byte pointer into an sx
`string` — either by copying (`strlen` + alloc + `memcpy`) or by building a
length-bounded view.
- **Why general-purpose:** *every* FFI surface that returns `char*` needs this
(env vars, `getcwd`, `UTF8String`, libc results, …). It is the single most
repeated FFI chore in m3te and has no game content.
- **Suggested home:** `modules/std` (e.g. `from_cstr(*u8) -> string` plus a
zero-copy `cstr_view`), and a public `strlen` binding so callers stop
re-declaring it.
- **Not already in sx library (checked):** `process.env` (`process.sx:86`)
performs exactly this copy **inline** but does not expose it as a reusable
helper, and re-binds `strlen` privately (`process.sx:28`). `std/objc.sx`
exposes `NSString.UTF8String` returning `[*]u8` (`objc.sx:113`) with **no**
helper to convert it back to a `string`. No public `from_cstr`/`cstr_to_string`
exists.
### 4. `getcwd` binding / `cwd()` helper
- **m3te location:** `audio.sx:29` (`getcwd` `#foreign` binding), used in
`load_system_sound` (`audio.sx:141`) to absolutize a bundle-relative path.
- **What it does:** read the process's current working directory into a string.
- **Why general-purpose:** resolving relative paths to absolute is a routine
filesystem need, unrelated to match-3.
- **Suggested home:** `modules/fs` (next to `basename`/`dirname`) or
`modules/process`.
- **Not already in sx library (checked):** `fs.sx` binds `open`/`read`/`mkdir`/
`rename`/… and ships `basename`/`dirname`/`path_join` but **no** `getcwd`.
`process.sx` binds `getenv`/`popen`/`system` but not `getcwd`. The platform
modules bind only `chdir` (`platform/uikit.sx:20`, `platform/sdl3.sx:14`),
never `getcwd`; `platform/bundle.sx` obtains a cwd by shelling out, not via a
binding. (grepped `getcwd`/`chdir`/`cwd`.)
---
## Category: Graphics (borderline — partly engine-specific)
### 5. Image file / RGBA buffer → GPU texture (`load_texture`, `upload_rgba`)
- **m3te location:** `board_view.sx:132` (`load_texture`: PNG path → texture
handle), `board_fx.sx:104` (`upload_rgba`: in-memory RGBA buffer → texture
handle). Both branch `gpu.create_texture` vs a raw-GL fallback.
- **What it does:** decode an image (via `stbi_load`) or take an RGBA buffer and
upload it as a `.rgba8` texture, returning the handle.
- **Why general-purpose:** "load a PNG into a texture" is needed by any app that
draws sprites; the decode + `create_texture` core is reusable.
- **Suggested home:** `modules/ui/image.sx` (a loader to complement the existing
`ImageView`) or `modules/gpu`.
- **Not already in sx library (checked):** `ui/image.sx` defines `ImageView`
which takes an **already-uploaded** `texture_id` — it does **not** load from
disk. `stbi_load` appears only in m3te, never in `library/modules/ui`
(grepped). The GPU layer exposes `gpu.create_texture` but no file/decode
front-end.
- **Borderline:** the raw-GL fallback branch is engine-specific; only the
"decode → `create_texture`" half is cleanly general. Worth shipping as the
GPU-only path; the GL fallback can stay app-side.
---
## Category: Testing (borderline — a stub exists)
### 6. Failing assertion with source location (`expect`)
- **m3te location:** `tests/test.sx:10` (`expect(cond, msg)` → prints
`FAIL <file>:<line>: <msg>` and exits non-zero via `process.exit`).
- **What it does:** a test assertion that, on failure, reports the caller's
`file:line` and **fails the process** so a harness/CI catches it.
- **Why general-purpose:** every sx test suite needs a failing assertion;
m3te's whole `tests/` gate is built on this one helper.
- **Suggested home:** `modules/test`.
- **Partially exists (checked):** `modules/test.sx` ships only
`assert(condition)` which prints `"assertion failed\n"` and **does not exit**
(`test.sx:3-7`) — useless as a gate. `process.assert(cond, msg)`
(`process.sx:148`) *does* report `file:line` and exit 1, so the capability is
half-present but split awkwardly: the dedicated test module can't fail a run,
and the failing variant lives in `process`. A real `modules/test` `expect`/
`assert` that reports location and fails is the gap.
---
## Already provided by the sx library — NOT gaps (honest accounting)
m3te also re-implemented or could have reused these; the library **does** ship
them, so they are explicitly **not** counted above:
- **`clamp` / `lerp` / `min` / `max` / `abs` / `sign`** — m3te uses these
throughout (e.g. `gem_anim.sx:45`, `board_layout.sx:20`, `swipe.sx:31`) and
they all come from `math/math.sx:11-37`. Not hand-rolled; not a gap.
- **`read_env`** (`main.sx:108`) — duplicates `process.env` (`process.sx:86`)
almost line-for-line. m3te re-declares its own `getenv` (`main.sx:27`) instead
of importing `process`, but the capability exists. Not a gap (it motivates #3,
the *reusable* `from_cstr` underneath it).
- **`ease_out_cubic` / `ease_in_quad`** (`board_anim.sx:28-29`) — identical to
`ui/animation.sx:19` and `ui/animation.sx:13`. Re-implemented only because
`board_anim` imports `ui/types` but not all of `ui/animation`. Not a gap.
*Placement note:* these are pure curves and arguably belong in `modules/math`
too, so non-UI code can reach them without pulling in the UI module.
- **`fx_lerp_u8` / per-channel colour lerp** (`board_fx.sx:97`) — `Color.lerp`
(`ui/types.sx:124`) already lerps all four channels. Not a gap.
- **NSString / `NSLog` bridging** (used in `audio.sx`) — `std/objc.sx` ships
`NSLog` (`objc.sx:75`) and an `Into(*NSString) for string` (`objc.sx:119`),
which m3te uses via `xx`. Not a gap.
---
## Summary (top gaps, ranked)
1. **String → number parsing** (`parse_s64` / `parse_f32`) — clean, universally
needed, the missing inverse of the existing formatters.
2. **Seedable PRNG** (`Rng` LCG) — textbook stdlib primitive, entirely absent.
3. **`from_cstr` C-string bridge** (+ public `strlen`) — the most-repeated FFI
chore; `process.env` inlines it but never exposes it.
4. **`getcwd` / `cwd()`** — only `chdir` is bound; the read side is missing.
5. **Image-file → GPU texture loader** — borderline; the decode-and-upload core
is general, the GL fallback is app-side.
6. **`expect` test assertion** — borderline; capability is split between a
non-failing `test.assert` stub and `process.assert`.

153
gem_anim.sx Normal file
View File

@@ -0,0 +1,153 @@
// Per-gem animation set (P6.3) — a PURELY VISUAL pose each gem sprite is drawn
// with: a calm always-on idle breath, a pop on selection, and a squash-bounce on
// landing. Everything here is a pure function of an animation clock and the cell;
// it never reads or writes the model, so a gem's idle bob/scale cannot change its
// logical cell or break hit-testing (which stays on the grid in board_layout.sx).
//
// Determinism: the idle is always-on, so a live screenshot would be time-
// dependent. `GemMotion.clock` is the single animation time; capture mode
// (M3TE_ANIM_TIME, read in main) freezes it at a chosen phase so the board can be
// screenshotted reproducibly. Every effect is built so that at clock t==0 the pose
// is exactly the resting sprite — so the pre-P6.3 goldens reproduce at t==0.
#import "modules/std.sx";
#import "modules/math";
#import "board.sx";
#import "board_anim.sx";
// A gem's draw transform about its cell centre. scale_x/scale_y scale the sprite
// (1.0 == the normal cell-fill size) and dx/dy nudge it in CELL units. The resting
// pose is all-ones / all-zeros, which draws identically to the static sprite.
GemPose :: struct {
scale_x: f32;
scale_y: f32;
dx: f32;
dy: f32;
}
gem_pose_rest :: () -> GemPose {
GemPose.{ scale_x = 1.0, scale_y = 1.0, dx = 0.0, dy = 0.0 }
}
// --- Idle breath -------------------------------------------------------------
// A gentle ~1s pulse + vertical bob, ramped in from rest so a freshly-shown board
// (and the t==0 capture) starts on the resting pose. A per-gem phase offset keeps
// the board from pulsing in lockstep without ever desyncing the t==0 rest.
IDLE_PERIOD :f32: 1.05; // seconds per breath
IDLE_SCALE_A :f32: 0.035; // +/- uniform scale amplitude
IDLE_BOB_A :f32: 0.024; // vertical bob amplitude (cell units)
IDLE_RAMP :f32: 0.45; // seconds to ease the idle up from full rest
// Smooth per-cell phase: a diagonal gradient wrapped into one breath period.
gem_idle_phase :: (col: s64, row: s64) -> f32 {
cast(f32) ((col * 2 + row * 3) % 8) / 8.0 * TAU
}
idle_pose :: (t: f32, col: s64, row: s64) -> GemPose {
ramp := clamp(t / IDLE_RAMP, 0.0, 1.0);
w := t / IDLE_PERIOD * TAU + gem_idle_phase(col, row);
s := IDLE_SCALE_A * sin(w) * ramp;
bob := IDLE_BOB_A * cos(w) * ramp;
GemPose.{ scale_x = 1.0 + s, scale_y = 1.0 + s, dx = 0.0, dy = bob }
}
// --- Selection pop -----------------------------------------------------------
// A quick scale-up that settles back: a single hump over the window so the tapped
// gem "pops" then relaxes (the highlight overlay still draws on top of this).
SELECT_DUR :f32: 0.34;
SELECT_POP_A :f32: 0.15;
select_pop_scale :: (ts: f32) -> f32 {
if ts <= 0.0 or ts >= SELECT_DUR { return 1.0; }
1.0 + SELECT_POP_A * sin(PI * ts / SELECT_DUR)
}
// --- Landing squash-bounce ---------------------------------------------------
// A damped wobble on settle: the gem flattens wide-and-short on impact, then a
// couple of decaying overshoots. 0 at tl==0 and again past the window (rest).
// The shape IS P15.1's `squash_envelope` (board_anim) over the normalized window
// tl/LAND_DUR, scaled to a tasteful amplitude — so the per-round fall bounce and
// this settle bounce are the EXACT same envelope (P17.3 drives both through here).
LAND_DUR :f32: 0.42;
LAND_SQUASH_A :f32: 0.18; // peak ~13% wide-and-short — reads on landing, still tasteful
land_squash :: (tl: f32) -> f32 {
if tl <= 0.0 or tl >= LAND_DUR { return 0.0; }
LAND_SQUASH_A * squash_envelope(tl / LAND_DUR)
}
// --- Clear pop ---------------------------------------------------------------
// The matched-gem clear, shaped as a candy pop in three beats over its local
// 0..1: a tiny anticipation squash dip (a "gather" just below rest), a snappy
// overshoot up to the peak via P15.1's ease_out_back, then an accelerating
// collapse to nothing. Endpoints are LOCKED — t==0 -> 1.0 (rest) and t==1 -> 0.0
// (gone) — so the seam to the model board stays clean; the soft particle burst /
// score popup (board_fx.sx) compose on top.
CLEAR_DIP_T :f32: 0.16; // fraction of the window spent on the anticipation dip
CLEAR_DIP_A :f32: 0.08; // how far the gem compresses below rest before popping
CLEAR_POP_RISE :f32: 0.52; // window fraction at which the overshoot peak is reached
CLEAR_POP_A :f32: 0.36; // overshoot height above resting scale
clear_pop_scale :: (t: f32) -> f32 {
if t <= 0.0 { return 1.0; }
if t >= 1.0 { return 0.0; }
if t < CLEAR_DIP_T {
// Anticipation gather: sin(PI*u) is 0 at both ends, so t==0 stays exactly
// at rest and the dip hands off to the rise at rest — a brief squash, not
// a step.
u := t / CLEAR_DIP_T;
return 1.0 - CLEAR_DIP_A * sin(PI * u);
}
if t < CLEAR_POP_RISE {
// Snap up to the peak: ease_out_back rises from rest, shoots a touch past
// 1+A, then eases back to exactly 1+A at the seam (its locked f(1)=1), so
// the maximum is a single clean overshoot with no second reversal.
u := (t - CLEAR_DIP_T) / (CLEAR_POP_RISE - CLEAR_DIP_T);
return 1.0 + CLEAR_POP_A * ease_out_back(u);
}
// Collapse to nothing: accelerate the shrink from the peak so the gem vanishes
// as the burst takes over. ease_in_quad pins the seam at the peak and t==1 at 0.
peak := 1.0 + CLEAR_POP_A;
u := (t - CLEAR_POP_RISE) / (1.0 - CLEAR_POP_RISE);
peak * (1.0 - ease_in_quad(u))
}
// Live per-gem animation state, heap-allocated (like BoardAnim/BoardFx) so it
// survives BoardView's per-frame rebuild. `clock` is the single animation time:
// the frame loop advances it by delta_time, or capture mode pins it. `land_at`
// records, per cell, the clock value when that cell last received a gem so only
// the cells that actually moved bounce.
GemMotion :: struct {
clock: f32;
pinned: bool;
land_at: [BOARD_CELLS]f32;
init :: (self: *GemMotion) {
self.clock = 0.0;
self.pinned = false;
self.reset_landings();
}
// Drop every landing stamp back to the never-landed sentinel so no cell
// carries a squash-bounce. `restart` calls this so a reseeded board starts at
// its resting pose instead of replaying the prior move's landing wobble; the
// idle clock keeps running, so the always-on idle simply resumes from rest.
reset_landings :: (self: *GemMotion) {
for 0..BOARD_CELLS: (i) { self.land_at[i] = -1000.0; }
}
stamp_land :: (self: *GemMotion, i: s64) {
self.stamp_land_at(i, self.clock);
}
// Record cell `i`'s landing at an explicit clock value, so the settle hand-off
// can BACK-DATE the stamp to when the gem actually touched down mid-fall (each
// column lands at a staggered instant): land_squash then resumes the per-round
// bounce exactly where render_fall left it, with no double-pop at the seam.
stamp_land_at :: (self: *GemMotion, i: s64, at: f32) {
self.land_at[i] = at;
}
land_local :: (self: *GemMotion, i: s64) -> f32 {
self.clock - self.land_at[i]
}
}

0
goldens/.gitkeep Normal file
View File

BIN
goldens/p11_combo_deep.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
goldens/p16_badswap.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
goldens/p16_swap.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
goldens/p17_fall.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

BIN
goldens/p17_land.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
goldens/p17_stagger.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
goldens/p18_pop.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
goldens/p18_stagger.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
goldens/p20_fps.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
goldens/p4_board.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
goldens/p4_hud.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
goldens/p5_swap_after.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
goldens/p5_swap_before.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
goldens/p6_anim_after.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
goldens/p6_anim_clear.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
goldens/p6_anim_fall.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
goldens/p6_anim_swap.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
goldens/p6_fx.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
goldens/p6_fx_after.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
goldens/p6_fx_match.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
goldens/p6_idle_mid.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
goldens/p6_idle_t0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
goldens/p6_select.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
goldens/p7_lose.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

BIN
goldens/p7_restart.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
goldens/p7_win.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

BIN
goldens/p9_polish.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

486
main.sx Normal file
View File

@@ -0,0 +1,486 @@
#import "modules/std.sx";
#import "build.sx";
#import "modules/compiler.sx";
#import "modules/opengl.sx";
#import "modules/sdl3.sx";
#import "modules/math";
#import "modules/stb.sx";
#import "modules/stb_truetype.sx";
#import "modules/gpu/api.sx";
#import "modules/gpu/types.sx";
#import "modules/gpu/metal.sx";
#import "modules/ui";
#import "modules/platform/api.sx";
#import "modules/platform/sdl3.sx";
#import "modules/platform/uikit.sx";
#import "board.sx";
#import "board_view.sx";
#import "board_anim.sx";
#import "board_fx.sx";
#import "gem_anim.sx";
#import "audio.sx";
#run configure_build();
// libc is the implicit foreign-library handle the std allocators bind against;
// reused here to read the deterministic-capture environment variables at startup.
getenv :: (name: [:0]u8) -> *u8 #foreign libc "getenv";
strlen :: (s: *u8) -> usize #foreign libc "strlen";
// Fixed seed for the rendered board — the same seed tests/board_init.sx locks
// as a snapshot, so the on-screen layout matches that golden gem-for-gem.
BOARD_SEED :: 1337;
// Cleared-surface tone for both GPU paths (Metal on iOS, GL on desktop). Tuned to
// the candy background's mid-gradient lavender so the clear never reads as a dark
// seam past the background art; one source keeps the two paths from diverging.
CLEAR_R :f32: 0.765;
CLEAR_G :f32: 0.733;
CLEAR_B :f32: 0.933;
g_plat : Platform = ---;
g_pipeline : *UIPipeline = ---;
g_delta_time : f32 = 0.016;
g_viewport_w : f32 = 800.0;
g_viewport_h : f32 = 600.0;
g_safe_insets : EdgeInsets = .{};
// FPS dev overlay (P20.1). OFF unless the M3TE_FPS env pin is set, so default
// play and every committed golden stay byte-identical. `g_fps_avg_dt` is an
// exponential moving average of the per-frame delta, smoothed so the readout
// doesn't jitter wildly; the displayed FPS is its reciprocal. Both are only
// touched on the gated path, so the unset path is unchanged.
FPS_DT_SMOOTH :f32: 0.9; // weight on the running average vs. this frame's delta
g_fps_on : bool = false;
g_fps_avg_dt : f32 = 0.016;
// iOS-only concrete handles kept alongside the boxed `g_plat` so the frame loop
// can reach the CAMetalLayer pointer / pixel dims without going through the
// protocol box.
g_uikit_plat : *UIKitPlatform = null;
g_metal_gpu : *MetalGPU = null;
// The pure-sx model (board.sx) and its sprites, seeded once in main() and
// rendered every frame. Heap-allocated so the view holds stable pointers to
// the mutable state across frames.
g_board : *Board = null;
g_assets : *BoardAssets = null;
// Current cell selection (P4.4). Heap-allocated so it survives BoardView's
// per-frame rebuild; a tap hit-tests a cell and toggles this.
g_sel : *BoardSelection = null;
// In-progress touch drag (P5.2). Heap-allocated for the same reason: the press
// and release that bracket a swipe land on different per-frame BoardView values,
// so the drag start must persist between them.
g_drag : *DragInput = null;
// In-flight move animation (P6.1). Heap-allocated for the same reason: a swipe
// begins the swap/clear/fall timeline, which then plays out over many subsequent
// frames, so the timeline state must persist across BoardView's per-frame rebuild.
g_anim : *BoardAnim = null;
// Transient match FX + score popups (P6.2). Heap-allocated like the animation:
// a committed move spawns short-lived bursts/popups that play out (and prune
// themselves) over many later frames. `g_fxassets` holds the per-colour tinted
// particle textures, loaded once. Purely visual; neither gates input.
g_fx : *BoardFx = null;
g_fxassets : *BoardFxAssets = null;
// Per-gem idle/select/land animation state (P6.3). Heap-allocated like the rest:
// `clock` advances by delta_time each frame (or is pinned by capture mode) and
// drives every per-gem pose. Purely visual; does not gate input.
g_motion : *GemMotion = null;
// Tracks whether the move timeline was active last frame, so the frame loop can
// fire the landing squash-bounce on the exact frame a move settles.
g_anim_prev_active : bool = false;
// Tracks whether the win/lose banner was up last frame, so the frame loop fires
// the win/lose stinger (P10.3) EXACTLY ONCE — on the frame the level settles
// terminal and any final cascade has played out — instead of replaying it every
// frame the banner is up. Re-armed when a restart reopens the level.
g_banner_prev_up : bool = false;
// Rebuilt each frame inside the pipeline's arena; carries the current safe-area
// insets so the grid stays inside the notch / home-indicator region.
build_ui :: () -> View {
fps : f32 = if g_fps_avg_dt > 0.0 then 1.0 / g_fps_avg_dt else 0.0;
BoardView.{ board = g_board, assets = g_assets, sel = g_sel, drag = g_drag, anim = g_anim, fx = g_fx, fxassets = g_fxassets, motion = g_motion, safe = g_safe_insets, seed = BOARD_SEED, fps_on = g_fps_on, fps = fps }
}
// Deterministic capture (P6.3). The idle loop is always-on, so a live screenshot
// would be time-dependent; these env hooks pin the visual state so goldens are
// reproducible. M3TE_ANIM_TIME=<seconds> freezes the animation clock at a chosen
// phase (t==0 is the resting board, identical to the pre-P6.3 goldens). Optional
// M3TE_SELECT=<cellIndex 0..63> forces a selection so the select-pop reaction can
// be captured without injecting a tap. Absent → normal live behaviour.
read_env :: (name: [:0]u8) -> ?string {
p := getenv(name);
addr : s64 = xx p;
if addr == 0 { return null; }
n := cast(s64) strlen(p);
if n == 0 { return ""; }
buf := cstring(n);
memcpy(buf.ptr, xx p, n);
buf
}
// Digit arithmetic runs entirely in s64; the result converts to f32 only once at
// the end. Doing the digit math in f32 would unify the ASCII literals (45/46/48/
// 57) to f32 across the comparisons, which mis-types the byte compares.
parse_f32 :: (s: string) -> f32 {
i : s64 = 0;
neg : bool = false;
if s.len > 0 {
c0 : s64 = xx s[0];
if c0 == 45 { neg = true; i = 1; } // '-'
}
intval : s64 = 0;
while i < s.len {
c : s64 = xx s[i];
if c < 48 or c > 57 { break; }
intval = intval * 10 + (c - 48);
i += 1;
}
fracval : s64 = 0;
fracdiv : s64 = 1;
if i < s.len {
d : s64 = xx s[i];
if d == 46 { // '.'
i += 1;
while i < s.len {
c : s64 = xx s[i];
if c < 48 or c > 57 { break; }
fracval = fracval * 10 + (c - 48);
fracdiv = fracdiv * 10;
i += 1;
}
}
}
v : f32 = cast(f32) intval + cast(f32) fracval / cast(f32) fracdiv;
if neg { v = 0.0 - v; }
v
}
parse_s64 :: (s: string) -> s64 {
i : s64 = 0;
v : s64 = 0;
while i < s.len {
c : s64 = xx s[i];
if c < 48 or c > 57 { break; }
v = v * 10 + (c - 48);
i += 1;
}
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;
g_viewport_w = fc.viewport_w;
g_viewport_h = fc.viewport_h;
g_safe_insets = g_plat.safe_insets();
// FPS dev overlay (P20.1): advance the smoothed frame-time average ONLY when
// the env pin enabled it, so an unset run never touches this and renders
// byte-identically. delta_time is real wall-clock even when M3TE_ANIM_TIME
// pins the animation clock, so the readout is live while the scene is frozen.
if g_fps_on and g_delta_time > 0.0 {
g_fps_avg_dt = g_fps_avg_dt * FPS_DT_SMOOTH + g_delta_time * (1.0 - FPS_DT_SMOOTH);
}
if fc.viewport_w != g_pipeline.screen_width or fc.viewport_h != g_pipeline.screen_height {
g_pipeline.resize(fc.viewport_w, fc.viewport_h);
}
for g_plat.poll_events(): (*ev) {
inline if OS != .ios {
if ev == {
case .key_up: (e) {
if e.key == .escape { g_plat.stop(); }
}
}
}
g_pipeline.dispatch_event(ev);
}
// Advance the in-flight move animation + its match FX by this frame's delta
// before rendering, so the board view draws the timeline slice for the current
// wall-clock time. Capture mode pins the animation clock (M3TE_ANIM_TIME);
// while pinned the move/FX timelines stay frozen at the phase the startup
// hooks set, so the FX-match scene (M3TE_FX) screenshots identically each run.
if g_motion == null or !g_motion.pinned {
if g_anim != null { g_anim.tick(g_delta_time); }
if g_fx != null { g_fx.tick(g_delta_time); }
}
// Per-round cascade SFX (P10.10): as each cascade round's clear begins on the
// move timeline, play the NEXT ascending combo cue (round 1 → combo1, round 2
// → combo2, … clamped at combo5). Edge-triggered off `cascade_fired` so each
// round's cue fires exactly once; only a real multi-round chain (rounds >= 2)
// gets the run, so a single match stays the lone match pop. Additive — reads
// only the recorded timeline, never board/score/move state.
if g_anim != null and g_anim.move.rounds.len >= 2 {
started := cascade_rounds_started(g_anim.elapsed, g_anim.move.rounds.len);
while g_anim.cascade_fired < started {
g_anim.cascade_fired += 1;
sfx_cascade(g_anim.cascade_fired);
}
}
// Advance the always-on per-gem animation clock (idle/select/land). Capture
// mode pins the clock, so it only moves when not pinned. On the exact frame a
// move timeline settles, stamp the landing bounce on every cell the move
// changed, so the gems that actually moved squash-bounce on settle.
if g_motion != null {
if !g_motion.pinned { g_motion.clock += g_delta_time; }
if g_anim != null {
if g_anim_prev_active and !g_anim.active {
// On the frame the timeline settles, hand the final round's
// per-column landing bounce to land_squash so render_gems resumes it
// seamlessly. Each gem the LAST round delivered to cell i is
// back-dated to when its column actually touched down — (1 -
// fall_landing_frac)·FALL_ANIM_DUR ago — so the bounce picks up
// exactly where render_fall left it: one bounce, no double-pop at the
// render_anim → render_gems seam. A gem that settled in an earlier
// round already bounced then (its back-dated age exceeds LAND_DUR, so
// land_squash reads rest); a gem that never moved is skipped.
mv := @g_anim.move;
total := g_anim.total();
last := mv.rounds.len - 1;
for 0..BOARD_CELLS: (i) {
m := delivering_round(mv, i, last);
if m >= 0 {
col := i % BOARD_COLS;
g_motion.stamp_land_at(i, g_motion.clock - (total - round_land_time(m, col)));
}
}
}
g_anim_prev_active = g_anim.active;
}
}
// Win/lose stinger (P10.3): edge-trigger on the banner coming up — the level
// has settled won/lost AND any in-flight cascade has finished animating — so
// the stinger plays once as the banner appears, never every frame it is up.
// Status is read-only from the model (mirrors BoardView.banner_up); a restart
// reopens the level, dropping the edge so a fresh win/lose re-fires.
banner_now := level_status(g_board) != .in_progress and (g_anim == null or !g_anim.active);
if banner_now and !g_banner_prev_up {
if level_status(g_board) == .won { sfx_win(); } else { sfx_lose(); }
}
g_banner_prev_up = banner_now;
inline if OS == .ios {
// Lazy-attach Metal once -[SxAppDelegate didFinishLaunching:] has
// installed the SxMetalView and its bounds have been measured; both can
// lag the first CADisplayLink tick, and a zero-sized drawable aborts
// via XPC.
if g_uikit_plat.gl_layer == null { return; }
if g_uikit_plat.pixel_w <= 0 or g_uikit_plat.pixel_h <= 0 { return; }
if g_metal_gpu.layer == null {
g_metal_gpu.init(g_uikit_plat.gl_layer, g_uikit_plat.pixel_w, g_uikit_plat.pixel_h);
} else if g_metal_gpu.pixel_w != g_uikit_plat.pixel_w or g_metal_gpu.pixel_h != g_uikit_plat.pixel_h {
g_metal_gpu.resize(g_uikit_plat.pixel_w, g_uikit_plat.pixel_h);
}
clear : ClearColor = .{ r = CLEAR_R, g = CLEAR_G, b = CLEAR_B, a = 1.0 };
if !g_metal_gpu.begin_frame(clear) { return; }
g_pipeline.tick();
g_metal_gpu.end_frame(fc.target_present_time);
} else {
glViewport(0, 0, fc.pixel_w, fc.pixel_h);
glClearColor(CLEAR_R, CLEAR_G, CLEAR_B, 1.0);
glClear(GL_COLOR_BUFFER_BIT);
g_pipeline.tick();
}
g_plat.end_frame();
}
main :: () -> void {
inline if OS == .ios {
u : *UIKitPlatform = xx context.allocator.alloc(size_of(UIKitPlatform));
u.gpu_mode = .metal;
if !u.init("m3te", 800, 600) { return; }
g_plat = xx u;
g_uikit_plat = u;
// The CAMetalLayer doesn't exist until didFinishLaunching: runs after we
// return into UIApplicationMain, so attach lazily on the first frame.
// init(null, 0, 0) only needs the MTLDevice, which is enough for the
// texture uploads below.
g_metal_gpu = xx context.allocator.alloc(size_of(MetalGPU));
// alloc returns uninitialized memory; struct field defaults are NOT
// applied, so List caps/lens would be garbage without this memset.
memset(xx g_metal_gpu, 0, size_of(MetalGPU));
if !g_metal_gpu.init(null, 0, 0) { return; }
} else {
s : *SdlPlatform = xx context.allocator.alloc(size_of(SdlPlatform));
if !s.init("m3te", 800, 600) { return; }
g_plat = xx s;
}
fc := g_plat.begin_frame();
g_viewport_w = fc.viewport_w;
g_viewport_h = fc.viewport_h;
g_safe_insets = g_plat.safe_insets();
g_pipeline = xx context.allocator.alloc(size_of(UIPipeline));
// Same alloc caveat as above: zero so the optional `gpu` reads as null on
// the desktop path (where set_gpu is not called) and the Lists start empty.
memset(xx g_pipeline, 0, size_of(UIPipeline));
inline if OS == .ios {
g_pipeline.set_gpu(xx g_metal_gpu);
}
g_pipeline.init(fc.viewport_w, fc.viewport_h);
g_pipeline.init_font("assets/fonts/default.ttf", 32.0, fc.dpi_scale);
g_board = xx context.allocator.alloc(size_of(Board));
g_board.init(BOARD_SEED);
g_assets = xx context.allocator.alloc(size_of(BoardAssets));
g_assets.init();
g_assets.load(g_pipeline.gpu);
g_sel = xx context.allocator.alloc(size_of(BoardSelection));
g_sel.init();
g_drag = xx context.allocator.alloc(size_of(DragInput));
g_drag.init();
g_anim = xx context.allocator.alloc(size_of(BoardAnim));
g_anim.init();
g_fx = xx context.allocator.alloc(size_of(BoardFx));
g_fx.init();
g_fxassets = xx context.allocator.alloc(size_of(BoardFxAssets));
g_fxassets.init();
g_fxassets.load(g_pipeline.gpu);
g_motion = xx context.allocator.alloc(size_of(GemMotion));
g_motion.init();
// SFX (P10.2). Loads the System Sound Services cue bank once; board_view
// plays a cue per event. Purely additive — never touches score/board/move
// state. On iOS the platform has already chdir'd to the bundle, so each
// cue's relative path resolves. No-op off iOS.
g_audio = xx context.allocator.alloc(size_of(GameAudio));
memset(xx g_audio, 0, size_of(GameAudio));
g_audio.init();
// Deterministic-capture hooks: pin the animation clock and/or preselect a
// cell so the always-on idle (and the select reaction) screenshot the same
// way every time. No env set → fully live.
if t := read_env("M3TE_ANIM_TIME") {
g_motion.pinned = true;
g_motion.clock = parse_f32(t);
}
if sc := read_env("M3TE_SELECT") {
idx := parse_s64(sc);
if idx >= 0 and idx < BOARD_CELLS {
g_sel.active = true;
g_sel.cell = Cell.{ col = idx % BOARD_COLS, row = idx / BOARD_COLS };
g_sel.since = g_motion.clock;
}
}
// FPS dev-overlay hook (P20.1): a non-zero M3TE_FPS turns on the corner FPS
// readout. Default (unset / =0) leaves it off, so normal play and every
// committed golden stay byte-identical. Purely a render overlay — no board /
// score / move / animation state changes and it never gates input.
if fp := read_env("M3TE_FPS") {
if parse_s64(fp) != 0 { g_fps_on = true; }
}
// Match-FX capture hook (P11.1). The bursts/popups spawn off a committed move,
// which the sim can't script (no public touch injection), so M3TE_FX forces a
// representative match at startup the same way a swipe would: it commits the
// n-th currently-legal swap (1-based, clamped; =1 is the first) via the normal
// plan_and_commit path, then begins the move timeline + its FX. M3TE_ANIM_TIME
// pins the phase — advancing both to that time, after which the frozen frame
// loop holds them there — so the burst + "+points" popup screenshot identically
// every run. A larger M3TE_ANIM_TIME lands past the timeline, capturing the
// settled board with the FX fully pruned. Startup-only and unset → fully live.
if fx := read_env("M3TE_FX") {
swaps := legal_swaps(g_board);
if swaps.len > 0 {
n := parse_s64(fx);
if n < 1 { n = 1; }
if n > swaps.len { n = swaps.len; }
sw := swaps.items[n - 1];
mv := plan_and_commit(g_board, sw.a, sw.b);
g_anim.begin(mv);
g_fx.begin(@mv);
g_anim.tick(g_motion.clock);
g_fx.tick(g_motion.clock);
}
}
// 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);
// M3TE_MOVE_LIMIT=0 makes it read LOST (budget spent below the goal). With
// M3TE_RESTART set non-zero the board is then restart()-ed, capturing the
// fresh in_progress board the restart button produces.
if tg := read_env("M3TE_TARGET") { g_board.target_score = parse_s64(tg); }
if ml := read_env("M3TE_MOVE_LIMIT") { g_board.move_limit = parse_s64(ml); }
if rs := read_env("M3TE_RESTART") {
if parse_s64(rs) != 0 { g_board.restart(BOARD_SEED); }
}
g_pipeline.set_body(closure(build_ui));
g_plat.run_frame_loop(closure(frame));
g_plat.shutdown();
}

52
swipe.sx Normal file
View File

@@ -0,0 +1,52 @@
// Pure drag → adjacent-swap intent mapping (Phase 5 input). Turns a touch drag —
// a down position and an up/move position, both in the same view-local coordinate
// space BoardLayout uses — into an optional swap intent (A, B): A is the cell
// under the drag start, B its orthogonal neighbour along the drag's dominant axis.
// Owns no rendering and mutates no model, so it is unit-testable headless; P5.2
// feeds the resulting Swap straight into `commit_swap`.
#import "modules/std.sx";
#import "modules/math";
#import "modules/ui/types.sx";
#import "board.sx";
#import "board_layout.sx";
// A drag whose dominant-axis travel is below this fraction of a cell is treated
// as a tap, not a swipe, and yields no intent. Scaling to cell_size keeps the
// feel constant across screen sizes, since the layout sizes cells to the device.
SWIPE_THRESHOLD_FRACTION :f32: 0.5;
// Map a drag to the adjacent-swap intent it expresses, or null when the gesture
// is not a board swipe. Returns null when: the start point is off the board; the
// dominant-axis travel is below the swipe threshold (a tap, not a swipe); or the
// resolved neighbour B would fall off the board. A and B are always orthogonally
// adjacent — the intent never spans more than one cell. The dominant axis is the
// larger of |dx|, |dy| (an exact tie resolves horizontal), and its sign picks the
// direction: +x → right, -x → left, +y → down, -y → up (screen y grows downward,
// matching `cell_frame`). Reuses `point_to_cell` so the start resolves to exactly
// the cell drawn under the finger.
swipe_intent :: (layout: *BoardLayout, start: Point, end: Point) -> ?Swap {
if a := layout.point_to_cell(start) {
dx := end.x - start.x;
dy := end.y - start.y;
adx := abs(dx);
ady := abs(dy);
threshold := layout.cell_size * SWIPE_THRESHOLD_FRACTION;
if adx < threshold and ady < threshold { return null; } // a tap, not a swipe
bcol := a.col;
brow := a.row;
if adx >= ady {
if dx > 0.0 { bcol += 1; } else { bcol -= 1; }
} else {
if dy > 0.0 { brow += 1; } else { brow -= 1; }
}
if bcol < 0 or bcol >= BOARD_COLS or brow < 0 or brow >= BOARD_ROWS {
return null; // neighbour off the board
}
return Swap.{ a = a, b = Cell.{ col = bcol, row = brow } };
}
null // start off the board
}

157
tests/anim_plan.sx Normal file
View File

@@ -0,0 +1,157 @@
// Animation-layer determinism guard (P6.1): prove the swap/clear/fall animation
// timeline is PURELY VISUAL — it never changes the model's result. `plan_and_commit`
// commits the move on the real board (authoritative) AND records the visual
// timeline on a value-copy; this test asserts, on the SAME seed the app renders
// (SEED 1337):
// - the board `plan_and_commit` leaves is byte-for-byte identical to an
// independent `commit_swap` of the same move, with the same score + moves;
// - the recorded timeline ENDS on that exact state: `move.final` equals the
// model board, the rounds are contiguous (round 0 starts on the swapped board,
// each later round starts on the prior round's settled board), and the last
// round's `after` equals `final`;
// - an illegal swap records no rounds and leaves the board untouched.
// It also guards the P6.1 input-lock fix: `accepts_input` rejects a gesture for
// the FULL in-flight animation window (mouse_down → settle), so a swipe begun
// while a move animates never latches a drag and never commits — even if it is
// released after the timeline ends.
// No rendering — it calls exactly what BoardView.handle_event calls. Links headless
// like tests/swipe_commit.sx; avoids tests/test.sx (its trace.sx pulls in a second
// `Frame` that collides with the UI one). Failure is a non-zero exit code.
#import "modules/std.sx";
#import "board.sx";
#import "board_anim.sx";
SEED :: 1337;
boards_equal :: (x: *Board, y: *Board) -> bool {
for 0..BOARD_CELLS: (i) {
if !(x.cells[i] == y.cells[i]) { return false; }
}
true
}
main :: () -> s32 {
fails : s64 = 0;
// ── Legal swap: plan == model, timeline ends on the model ───────────────
// (5,4)->(6,4): brings R into (5,4), completing R,R,R across cols 3-5 of row
// 4 — the same legal swap tests/swipe_commit.sx commits.
print("== legal swap: plan matches model ==\n");
a := Cell.{ col = 5, row = 4 };
b := Cell.{ col = 6, row = 4 };
bm : Board = ---;
bm.init(SEED);
mvm := commit_swap(@bm, a, b);
ba : Board = ---;
ba.init(SEED);
move := plan_and_commit(@ba, a, b);
print("model: legal {} depth {} score {} moves {}\n",
mvm.legal, mvm.cascade.depth, bm.score, bm.moves_made);
print("plan: legal {} rounds {} score {} moves {}\n",
move.legal, move.rounds.len, ba.score, ba.moves_made);
if !move.legal { fails += 1; }
if !boards_equal(@ba, @bm) { fails += 1; } // committed board == model
if ba.score != bm.score { fails += 1; }
if ba.moves_made != bm.moves_made { fails += 1; }
if move.rounds.len != mvm.cascade.depth { fails += 1; }
// move.final equals the model board.
final_eq := true;
for 0..BOARD_CELLS: (i) {
if !(move.final[i] == bm.cells[i]) { final_eq = false; }
}
if !final_eq { fails += 1; }
print("final==model {}\n", final_eq);
// Timeline contiguity: round 0 starts on the swapped pre board; each later
// round starts on the previous round's settled board; final == last after.
contiguous := true;
if move.rounds.len > 0 {
ai := Board.idx(a.col, a.row);
bi := Board.idx(b.col, b.row);
r0 := @move.rounds.items[0];
for 0..BOARD_CELLS: (i) {
expect : Gem = move.pre[i];
if i == ai { expect = move.pre[bi]; }
else if i == bi { expect = move.pre[ai]; }
if !(r0.before[i] == expect) { contiguous = false; }
}
for 1..move.rounds.len: (k) {
prev := @move.rounds.items[k - 1];
cur := @move.rounds.items[k];
for 0..BOARD_CELLS: (i) {
if !(cur.before[i] == prev.after[i]) { contiguous = false; }
}
}
last := @move.rounds.items[move.rounds.len - 1];
for 0..BOARD_CELLS: (i) {
if !(last.after[i] == move.final[i]) { contiguous = false; }
}
}
if !contiguous { fails += 1; }
print("contiguous {}\n", contiguous);
out("final board:\n");
out(board_dump(@bm));
// ── Illegal swap: no timeline, board untouched ──────────────────────────
// (0,0)->(1,0): two reds → no match. plan_and_commit must leave the board
// exactly as it was, spend no move, and record zero rounds.
print("== illegal swap: untouched ==\n");
bi2 : Board = ---;
bi2.init(SEED);
pre2 : Board = bi2;
mi := plan_and_commit(@bi2, Cell.{ col = 0, row = 0 }, Cell.{ col = 1, row = 0 });
print("legal {} rounds {} score {} moves {}\n", mi.legal, mi.rounds.len, bi2.score, bi2.moves_made);
if mi.legal { fails += 1; }
if mi.rounds.len != 0 { fails += 1; }
if !boards_equal(@pre2, @bi2) { fails += 1; }
if bi2.score != 0 { fails += 1; }
if bi2.moves_made != 0 { fails += 1; }
// ── Input gate: locked for the FULL in-flight animation ─────────────────
// The view begins a drag on mouse_down only when `accepts_input` is true,
// and commits on mouse_up only if a drag latched. So a gesture that BEGINS
// while a move animates must NEVER commit — even if it is released after the
// animation has fully settled. This guards the P6.1 input-lock fix.
print("== input gate: locked while animating ==\n");
gate : BoardAnim = ---;
gate.init();
idle_ok := accepts_input(@gate); // no move in flight → accept
gate.begin(move); // a legal move's timeline starts
busy_ok := accepts_input(@gate); // mouse_down DURING animation → reject
while gate.active { gate.tick(0.05); } // player holds; timeline plays out
settled_ok := accepts_input(@gate); // animation fully settled → accept
print("accepts idle {} busy {} settled {}\n", idle_ok, busy_ok, settled_ok);
if !idle_ok { fails += 1; }
if busy_ok { fails += 1; } // MUST be locked while animating
if !settled_ok { fails += 1; }
// The board MUST decide a gesture at PRESS, not at RELEASE. Over the exact
// failure scenario — a gesture PRESSED while animating and RELEASED after the
// timeline has settled — the two policies diverge:
// release-gate: commit unless animating AT RELEASE → COMMITS (the timeline
// finished first), letting input slip through mid-transition.
// press-gate: latch only if input accepted AT PRESS → DROPS, because
// input was locked for the whole window the timeline ran.
gate.init();
gate.begin(move);
accept_at_press := accepts_input(@gate); // mouse_down while animating
while gate.active { gate.tick(0.05); }
accept_at_release := accepts_input(@gate); // mouse_up after settle
release_gate_commits := accept_at_release;
press_gate_commits := accept_at_press;
print("release_gate_commits {} press_gate_commits {}\n", release_gate_commits, press_gate_commits);
if !release_gate_commits { fails += 1; } // the scenario release-gating lets through
if press_gate_commits { fails += 1; } // the board press-gates: MUST NOT commit
if fails == 0 {
print("ok: animation layer leaves the model result unchanged\n");
return 0;
}
print("FAIL: {} anim-determinism checks failed\n", fails);
return 1;
}

13
tests/arith.sx Normal file
View File

@@ -0,0 +1,13 @@
// Trivial logic-gate sanity check: exercises the `expect` helper on passing
// assertions and prints a stable summary line for the snapshot runner to diff.
// Real board-state tests arrive in Phase 1.
#import "modules/std.sx";
t :: #import "test.sx";
main :: () -> s32 {
t.expect(2 + 2 == 4, "two plus two is four");
t.expect(7 % 3 == 1, "seven mod three is one");
t.expect(10 - 4 == 6, "ten minus four is six");
print("ok: arithmetic\n");
return 0;
}

81
tests/banner_layout.sx Normal file
View File

@@ -0,0 +1,81 @@
// Banner / restart hit-test golden (P7.2): lock the win/lose banner geometry and
// the restart-button hit-test that `BoardView.handle_event` relies on. The button
// rect is derived from the SAME grid layout the gems use, and a finished level
// freezes every board-cell tap, so the button is the only live target — if its
// rect drifted from what `render_banner` draws, a tap on the visible button would
// miss and the level could never be restarted. This test checks, headlessly:
//
// * the button is centered over the grid and sits fully inside the panel,
// * the button centre hit-tests INTO the button (tap → restart),
// * an off-button tap (a board corner) hit-tests OUT (frozen, no restart).
//
// Imports BoardLayout (no GL/stb), not BoardView, so it links headless — same
// shape and rationale as tests/hit_test.sx. Failure is signalled via a non-zero
// exit code (the runner checks exit code AND stdout).
#import "modules/std.sx";
#import "board.sx";
#import "board_layout.sx";
irect :: (f: Frame) -> string {
format("({},{},{},{})",
cast(s64) f.origin.x, cast(s64) f.origin.y,
cast(s64) f.size.width, cast(s64) f.size.height)
}
main :: () -> s32 {
// 800×600, no safe inset → 600px square grid, cell 75, origin (100,0): the
// same layout tests/hit_test.sx pins, so the numbers are checkable by hand.
lay : BoardLayout = ---;
lay.compute(Frame.make(0.0, 0.0, 800.0, 600.0), EdgeInsets.zero());
grid := lay.grid_frame();
bl := lay.banner();
print("grid {}\n", irect(grid));
print("panel {}\n", irect(bl.panel));
print("title {}\n", irect(bl.title));
print("button {}\n", irect(bl.button));
fails : s64 = 0;
// The button is horizontally centered on the grid (centred banner).
bcx := bl.button.mid_x();
if cast(s64) bcx != cast(s64) grid.mid_x() { fails += 1; }
print("button mid_x {} grid mid_x {}\n", cast(s64) bcx, cast(s64) grid.mid_x());
// The whole button sits inside the panel — its four corners are contained,
// so it can never spill outside the drawn card.
bx0 := bl.button.origin.x; by0 := bl.button.origin.y;
bx1 := bl.button.max_x(); by1 := bl.button.max_y();
corners_in : s64 = 0;
if bl.panel.contains(Point.{ x = bx0, y = by0 }) { corners_in += 1; }
if bl.panel.contains(Point.{ x = bx1, y = by0 }) { corners_in += 1; }
if bl.panel.contains(Point.{ x = bx0, y = by1 }) { corners_in += 1; }
if bl.panel.contains(Point.{ x = bx1, y = by1 }) { corners_in += 1; }
if corners_in != 4 { fails += 1; }
print("button corners inside panel: {}/4\n", corners_in);
// A tap on the button centre restarts (hit-test true); the panel itself is
// also contained, so the centre is unambiguously on the button.
center := Point.{ x = bl.button.mid_x(), y = bl.button.mid_y() };
hit_center := bl.button.contains(center);
if !hit_center { fails += 1; }
print("button center hit: {}\n", hit_center);
// Off-button taps that a finished level must NOT treat as restart: the grid's
// top-left corner cell centre, and a point just outside the panel. Neither is
// in the button, so each leaves the level frozen.
corner_cell := Point.{ x = grid.origin.x + lay.cell_size * 0.5, y = grid.origin.y + lay.cell_size * 0.5 };
outside := Point.{ x = bl.panel.origin.x - 5.0, y = bl.panel.mid_y() };
off_hits : s64 = 0;
if bl.button.contains(corner_cell) { off_hits += 1; }
if bl.button.contains(outside) { off_hits += 1; }
if off_hits != 0 { fails += 1; }
print("off-button taps that hit the button: {}\n", off_hits);
if fails == 0 {
print("ok: restart button hit-test matches the drawn banner\n");
return 0;
}
print("FAIL: {} banner hit-test checks failed\n", fails);
return 1;
}

39
tests/board_init.sx Normal file
View File

@@ -0,0 +1,39 @@
// Board-state golden: seed the board deterministically, dump it, and assert
// the no-pre-existing-match invariant (zero horizontal/vertical 3-in-a-rows).
// The dump is locked as a snapshot so the seeded board state can't drift.
#import "modules/std.sx";
#import "board.sx";
t :: #import "test.sx";
SEED :: 1337;
// Count every horizontal or vertical window of three consecutive same-type
// gems. A correctly initialized board has zero. This walks the finished board
// independently of the placement logic, so it's a real check, not a tautology.
count_three_runs :: (b: *Board) -> s32 {
runs : s32 = 0;
for 0..BOARD_ROWS: (row) {
for 0..(BOARD_COLS - 2): (col) {
g := b.at(col, row);
if g == b.at(col + 1, row) and g == b.at(col + 2, row) { runs += 1; }
}
}
for 0..(BOARD_ROWS - 2): (row) {
for 0..BOARD_COLS: (col) {
g := b.at(col, row);
if g == b.at(col, row + 1) and g == b.at(col, row + 2) { runs += 1; }
}
}
runs
}
main :: () -> s32 {
board : Board = ---;
board.init(SEED);
out(board_dump(@board));
t.expect(count_three_runs(@board) == 0, "seeded board has no 3-in-a-row runs");
print("ok: board_init no-match invariant holds\n");
return 0;
}

141
tests/cascade.sx Normal file
View File

@@ -0,0 +1,141 @@
// Cascade golden: resolve a HAND-CRAFTED seeded board to a stable state and
// snapshot the whole settle loop. The board carries a single horizontal match
// (BBB at row 3, cols 0-2) sitting on the run-free O/G checkerboard, painted so
// that clearing it is NOT the end: col 0 holds R . R R straddling that match's
// cell, so once the B is cleared and gravity packs the column, the three reds
// fall adjacent into a fresh vertical RRR — a SECOND match the first clear set
// up. So the loop runs at least two rounds before it stabilises. The exact
// sequence (per-round boards + final depth) is locked by the snapshot.
//
// Two things are asserted independently of the dump: the final board is stable
// (find_matches empty), and the public `resolve` reproduces the manual loop's
// depth, per-round cleared counts, and final board byte-for-byte. A control
// checkerboard with no initial match resolves at depth 0, untouched.
#import "modules/std.sx";
#import "board.sx";
t :: #import "test.sx";
SEED :: 7;
// Number of rounds the crafted cascade runs. Locked alongside the golden.
EXPECTED_DEPTH :: 2;
// Inverse of `gem_char`: map a board character back to its Gem so the starting
// board can be written as a human-readable grid. The hole glyph maps to `.empty`.
char_to_gem :: (c: u8) -> Gem {
if c == EMPTY_CHAR { return .empty; }
for 0..GEM_COUNT: (i) {
if GEM_CHARS[i] == c { return cast(Gem) i; }
}
.red
}
// Load an 8x8 board from `rows` (top row first, each exactly BOARD_COLS chars).
// The RNG is left unseeded — callers seed it before resolving.
load_board :: (rows: []string) -> Board {
b : Board = ---;
for 0..BOARD_ROWS: (row) {
line := rows[row];
for 0..BOARD_COLS: (col) {
b.set(col, row, char_to_gem(line[col]));
}
}
b
}
boards_equal :: (a: *Board, b: *Board) -> bool {
for 0..BOARD_CELLS: (i) { if a.cells[i] != b.cells[i] { return false; } }
true
}
// The crafted cascade board: checkerboard everywhere except a horizontal BBB at
// row 3 (cols 0-2) and reds salted down col 0 (rows 2,4,5) around that match's
// cell. Clearing BBB punches the col-0 hole between the reds; gravity then packs
// R,R,R adjacent → a vertical match for round 2. Its RNG is seeded from SEED so
// the refill that follows each clear is reproducible.
cascade_board :: () -> Board {
b := load_board(.[
"OGOGOGOG",
"GOGOGOGO",
"RGOGOGOG",
"BBBOGOGO",
"RGOGOGOG",
"ROGOGOGO",
"OGOGOGOG",
"GOGOGOGO",
]);
b.rng = rng_seeded(SEED);
b
}
// A run-free checkerboard with no initial match — the depth-0 control.
checker_board :: () -> Board {
b := load_board(.[
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GOGOGOGO",
]);
b.rng = rng_seeded(SEED);
b
}
main :: () -> s32 {
print("== cascade (resolution loop) ==\n");
// Drive the loop one round at a time so each post-round board is visible in
// the snapshot, recording the per-round cleared counts and the depth.
b := cascade_board();
out("start:\n");
out(board_dump(@b));
depth := 0;
counts := List(s64).{};
while true {
n := resolve_step(@b);
if n == 0 { break; }
depth += 1;
counts.append(n);
print("round {}: cleared {} cells\n", depth, n);
out(board_dump(@b));
}
print("cascade depth {}\n", depth);
// The loop reached a stable board.
fm := find_matches(@b);
t.expect(fm.count() == 0, "cascade: final board has no matches");
// A genuine multi-round chain at the expected depth.
t.expect(depth == EXPECTED_DEPTH, "cascade: depth equals expected");
t.expect(depth >= 2, "cascade: chained at least two rounds");
// The public `resolve` on a fresh identical board reproduces the manual
// loop exactly: same depth, same per-round cleared counts, same final board.
b2 := cascade_board();
c := resolve(@b2);
t.expect(c.depth == depth, "cascade: resolve depth matches manual loop");
same_counts := c.cleared.len == counts.len;
if same_counts {
for 0..counts.len: (i) {
if c.cleared.items[i] != counts.items[i] { same_counts = false; }
}
}
t.expect(same_counts, "cascade: resolve per-round counts match manual loop");
t.expect(boards_equal(@b, @b2), "cascade: resolve final board matches manual loop");
// Control: a checkerboard with no initial match resolves at depth 0 and is
// left untouched (no clear, no collapse, no refill draw).
ctrl := checker_board();
before := ctrl;
cc := resolve(@ctrl);
t.expect(cc.depth == 0, "control: stable board resolves at depth 0");
t.expect(cc.cleared.len == 0, "control: depth 0 yields an empty per-round list");
t.expect(boards_equal(@before, @ctrl), "control: stable board left unchanged");
print("ok: cascade resolves to a stable board\n");
return 0;
}

34
tests/cascade_cue.sx Normal file
View File

@@ -0,0 +1,34 @@
// P10.4 — Cascade-cue selection snapshot: prove the cascade-depth → combo-cue
// mapping is a PURE, headless clamp. It reuses audio.sx's `cascade_cue_index`
// (and `cascade_cue_name`) UNCHANGED — the exact functions `play_cascade` calls
// — so the escalation logic the audio playback path can't gate-cover is covered
// here with no audio. The mapping clamps depth <= 1 to the first cue (combo1)
// and depth >= COMBO_CLIPS to the last (combo5), stepping up monotonically in
// between. The depth→index/name table below is locked by the committed snapshot.
#import "modules/std.sx";
#import "audio.sx";
main :: () -> s32 {
print("== cascade cue selection (depth -> combo cue) ==\n");
// Walk a representative depth range (0..9) so both clamps and the monotonic
// middle are visible: depths 0,1 pin to the first cue; depths >= 5 pin to
// the last; 2,3,4 step up one cue at a time.
prev : s64 = -1;
for 0..10: (depth) {
idx := cascade_cue_index(depth);
print("depth {} -> idx {} ({})\n", depth, idx, cascade_cue_name(idx));
// The mapping must never step down as depth grows.
if idx < prev { print("FAIL: cue index decreased at depth {}\n", depth); return 1; }
prev = idx;
}
// Explicit clamp boundaries, independent of the loop above.
if cascade_cue_index(0) != 0 { print("FAIL: depth 0 not clamped to first cue\n"); return 1; }
if cascade_cue_index(1) != 0 { print("FAIL: depth 1 not clamped to first cue\n"); return 1; }
if cascade_cue_index(COMBO_CLIPS) != COMBO_CLIPS - 1 { print("FAIL: depth COMBO_CLIPS not at last cue\n"); return 1; }
if cascade_cue_index(9) != COMBO_CLIPS - 1 { print("FAIL: deep cascade not clamped to last cue\n"); return 1; }
print("ok: cascade cue mapping clamps into combo1..combo{}\n", COMBO_CLIPS);
return 0;
}

64
tests/cascade_rounds.sx Normal file
View File

@@ -0,0 +1,64 @@
// P10.10 — Per-round cascade-cue timing snapshot: prove the frame loop plays ONE
// ascending combo cue PER cascade round, edge-triggered as each round's clear
// begins — combo1, combo2, … clamped at combo5, an audible ascending run — NOT a
// single combo cue keyed to the final cascade depth. The pure timing helper
// `cascade_rounds_started` (board_anim.sx) reports how many rounds have begun
// clearing by a given `elapsed` on the SAME swap→(clear,fall)* timeline the view
// animates; the round→cue mapping reuses `cascade_cue_index`/`cascade_cue_name`
// (audio.sx), the exact functions `sfx_cascade` plays. Simulating the frame loop
// over a 5-round chain yields the locked combo1..combo5 run. Links headless like
// tests/anim_plan.sx (board_anim pulls no GL) + tests/cascade_cue.sx (audio alone
// links). Failure is a non-zero exit code.
#import "modules/std.sx";
#import "board_anim.sx";
#import "audio.sx";
main :: () -> s32 {
print("== per-round cascade cue timing ==\n");
// `cascade_rounds_started` = how many cascade rounds have BEGUN clearing by
// `elapsed`, on the swap(0.16s)→[clear(0.14s),fall(0.22s)] per-round timeline.
// Round k (0-based) starts clearing at 0.16 + k*0.36; sampled safely INSIDE
// each round window so the integer step is unambiguous. Locked for 5 rounds.
print("-- started-count across a 5-round chain --\n");
rounds : s64 = 5;
print("e=0.00 -> {}\n", cascade_rounds_started(0.00, rounds));
print("e=0.10 -> {}\n", cascade_rounds_started(0.10, rounds));
print("e=0.20 -> {}\n", cascade_rounds_started(0.20, rounds));
print("e=0.50 -> {}\n", cascade_rounds_started(0.50, rounds));
print("e=0.55 -> {}\n", cascade_rounds_started(0.55, rounds));
print("e=0.90 -> {}\n", cascade_rounds_started(0.90, rounds));
print("e=1.30 -> {}\n", cascade_rounds_started(1.30, rounds));
print("e=1.70 -> {}\n", cascade_rounds_started(1.70, rounds));
print("e=5.00 -> {}\n", cascade_rounds_started(5.00, rounds));
// Edge-triggered playback: stepping the clock, each rising edge of the
// started-count plays the NEXT round's cue — combo1..combo5, once each,
// ascending. This IS the loop main's frame loop runs; the emitted run is the
// locked acceptance ordering.
print("-- ascending per-round run --\n");
fired : s64 = 0;
elapsed : f32 = 0.0;
while fired < rounds {
started := cascade_rounds_started(elapsed, rounds);
while fired < started {
fired += 1;
print("round {} -> {}\n", fired, cascade_cue_name(cascade_cue_index(fired)));
}
elapsed += 0.02;
}
// Non-cascade guards: a 1-round (single match) and 0-round (illegal) move. The
// frame loop gates the run on rounds>=2, so neither plays a combo run; the
// helper still reports the lone/zero round so the gate lives in the caller.
print("-- non-cascade --\n");
print("1-round@end {}\n", cascade_rounds_started(5.0, 1));
print("0-round@end {}\n", cascade_rounds_started(5.0, 0));
// Deep chain: the cue tail clamps at combo5 for round >= 5 (cascade_cue_index).
print("-- deep-chain cue clamp --\n");
for 1..8: (r) { print("round {} -> {}\n", r, cascade_cue_name(cascade_cue_index(r))); }
print("ok: one ascending combo cue per cascade round, clamped at combo5\n");
return 0;
}

203
tests/clear.sx Normal file
View File

@@ -0,0 +1,203 @@
// Clear golden: run detect→clear over several HAND-CRAFTED boards and snapshot
// the post-clear board. Each board sits on the run-free O/G checkerboard from
// match_detect (adjacent cells always differ, so it has zero pre-existing
// matches) with only the runs under test painted in — so any hole in the result
// is purely the cleared match's doing. For each scene the before/after boards
// are printed, and three facts are asserted independently of the dump: matched
// cells became holes, non-matched cells are byte-identical, and the cleared
// count is exact. The boards (and their match counts) mirror match_detect.sx.
#import "modules/std.sx";
#import "board.sx";
t :: #import "test.sx";
// Inverse of `gem_char`: map a gem character back to its Gem so each board can
// be written as a human-readable grid. The hole glyph maps to `.empty`, so a
// board can be hand-written with pre-existing holes (cells left by a prior
// clear) for the holes-never-match regression.
char_to_gem :: (c: u8) -> Gem {
if c == EMPTY_CHAR { return .empty; }
for 0..GEM_COUNT: (i) {
if GEM_CHARS[i] == c { return cast(Gem) i; }
}
.red
}
// Load an 8x8 board from `rows` (top row first, each exactly BOARD_COLS gem
// characters).
load_board :: (rows: []string) -> Board {
b : Board = ---;
for 0..BOARD_ROWS: (row) {
line := rows[row];
for 0..BOARD_COLS: (col) {
b.set(col, row, char_to_gem(line[col]));
}
}
b
}
// Detect→clear one scene, snapshot before/after, and assert the three clear
// invariants against the matched-cell set: every flagged cell is now a hole,
// every unflagged cell is unchanged, and the returned count is exact.
scene :: (name: string, rows: []string, want_cleared: s64) {
b := load_board(rows);
orig := load_board(rows); // pristine copy for the unchanged check
m := find_matches(@b);
cleared := clear_cells(@b, @m);
print("== {} ==\n", name);
out("before:\n");
out(board_dump(@orig));
out("after:\n");
out(board_dump(@b));
cleared_holes := true; // every matched cell is now a hole
others_intact := true; // every other cell is byte-identical
for 0..BOARD_CELLS: (i) {
if m.cells[i] {
if !(b.cells[i] == .empty) { cleared_holes = false; }
} else {
if !(b.cells[i] == orig.cells[i]) { others_intact = false; }
}
}
t.expect(cleared_holes, concat(name, ": cleared cells are holes"));
t.expect(others_intact, concat(name, ": non-matched cells unchanged"));
t.expect(cleared == want_cleared, concat(name, ": cleared count exact"));
}
main :: () -> s32 {
print("== clear (detect -> clear) ==\n");
// Single horizontal 3-run (row 3, cols 2-4) → three holes there only.
scene("horizontal-3", .[
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GORRROGO",
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GOGOGOGO",
], 3);
// Single vertical 3-run (col 5, rows 2-4).
scene("vertical-3", .[
"OGOGOGOG",
"GOGOGOGO",
"OGOGOBOG",
"GOGOGBGO",
"OGOGOBOG",
"GOGOGOGO",
"OGOGOGOG",
"GOGOGOGO",
], 3);
// Disjoint runs: horizontal R (row 1), horizontal P (row 5), vertical Y
// (col 6) — three separate hole clusters, 9 cells total.
scene("disjoint-runs", .[
"OGOGOGOG",
"RRROGOGO",
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGYG",
"GOPPPOYO",
"OGOGOGYG",
"GOGOGOGO",
], 9);
// Overlapping L and T: a horizontal run and a vertical run share a cell (the
// L's corner (1,1), the T's stem-top (4,5)). The mask already unions the
// shared cell, so clear removes the whole union as one set — 10 holes, not
// 11 — exercising the overlapping-clear acceptance case.
scene("L-and-T", .[
"OGOGOGOG",
"GRRRGOGO",
"OROGOGOG",
"GRGOGOGO",
"OGOGOGOG",
"GOGYYYGO",
"OGOGYGOG",
"GOGOYOGO",
], 10);
// No matches: the bare checkerboard is left completely unchanged (0 holes),
// so its before/after dumps are identical.
scene("no-matches", .[
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GOGOGOGO",
], 0);
// Holes never match: a checkerboard carrying a horizontal 3-run of holes
// (row 3, cols 2-4) and a vertical 3-run of holes (col 1, rows 5-7), left by
// earlier clears. A line of 3+ holes is NOT a match, so detect finds nothing,
// clear removes nothing, and before/after are identical. Without this, a
// post-clear board would keep re-"matching" its own holes and the P2.4
// cascade would never stabilise.
scene("holes-no-match", .[
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GO...OGO",
"OGOGOGOG",
"G.GOGOGO",
"O.OGOGOG",
"G.GOGOGO",
], 0);
// clear_matches: the one-call detect+clear returns the same cleared count
// and punches the holes itself.
cm := load_board(.[
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GORRROGO",
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GOGOGOGO",
]);
t.expect(clear_matches(@cm) == 3, "clear_matches: detect+clear returns count");
t.expect(cm.at(2, 3) == .empty and cm.at(3, 3) == .empty and cm.at(4, 3) == .empty,
"clear_matches: matched run is now holes");
// Holes are never matchable: a board whose only equal-adjacent runs are
// holes yields an empty match set, and clear_matches reports 0 (no change).
holes := load_board(.[
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GO...OGO",
"OGOGOGOG",
"G.GOGOGO",
"O.OGOGOG",
"G.GOGOGO",
]);
hm := find_matches(@holes);
t.expect(hm.count() == 0, "holes: a line of 3+ holes is not a match");
t.expect(clear_matches(@holes) == 0, "holes: clear_matches returns 0 on a holes-only board");
// Cascade base case: after a real clear punches a 3-in-a-line into holes,
// re-detecting on the cleared board must find nothing — otherwise the P2.4
// cascade loop would re-match its own holes and never terminate.
casc := load_board(.[
"OGOGOGOG",
"GOGOGOGO",
"OGOGOBOG",
"GOGOGBGO",
"OGOGOBOG",
"GOGOGOGO",
"OGOGOGOG",
"GOGOGOGO",
]);
t.expect(clear_matches(@casc) == 3, "cascade: first clear removes the vertical 3-run");
t.expect(clear_matches(@casc) == 0, "cascade: re-clear on the holed board returns 0");
print("ok: clear over hand-crafted boards\n");
return 0;
}

146
tests/collapse.sx Normal file
View File

@@ -0,0 +1,146 @@
// Collapse golden: run gravity over several HAND-CRAFTED boards and snapshot the
// post-collapse board. Unlike the clear/match goldens these boards need not be
// run-free — `collapse` never inspects matches, it only moves gems past holes —
// so each column is painted to exercise a distinct case (holes in the middle, at
// the bottom, a full column of holes, a column with none, a lone gem, an
// alternating stack). Distinct gem letters are stacked vertically so the
// top-to-bottom order is observable in the dump.
//
// For each scene the before/after boards are printed, and two facts are asserted
// independently of the dump: every column ends with its original gems (same
// top-to-bottom order) packed at the BOTTOM and all holes contiguous above, and
// the returned `moved` flag is exact.
#import "modules/std.sx";
#import "board.sx";
t :: #import "test.sx";
// Inverse of `gem_char`: map a board character back to its Gem. The hole glyph
// maps to `.empty`, so a board can be hand-written with holes in any position.
char_to_gem :: (c: u8) -> Gem {
if c == EMPTY_CHAR { return .empty; }
for 0..GEM_COUNT: (i) {
if GEM_CHARS[i] == c { return cast(Gem) i; }
}
.red
}
// Load an 8x8 board from `rows` (top row first, each exactly BOARD_COLS chars).
load_board :: (rows: []string) -> Board {
b : Board = ---;
for 0..BOARD_ROWS: (row) {
line := rows[row];
for 0..BOARD_COLS: (col) {
b.set(col, row, char_to_gem(line[col]));
}
}
b
}
// The collapse invariant, checked column by column against the original board:
// each column's gems (its non-hole cells, in top-to-bottom order) must reappear
// packed contiguously at the BOTTOM in that same order, with every cell above
// them a hole. This single check covers holes-bubble-to-top, gems-settle-to-
// bottom, order-preservation, and the all-holes / no-holes edge columns at once.
check_collapsed :: (orig: *Board, b: *Board) -> bool {
for 0..BOARD_COLS: (col) {
gems : [BOARD_ROWS]Gem = ---;
n := 0;
for 0..BOARD_ROWS: (row) {
g := orig.at(col, row);
if g != .empty { gems[n] = g; n += 1; }
}
boundary := BOARD_ROWS - n; // first row that must hold a gem
for 0..BOARD_ROWS: (row) {
if row < boundary {
if b.at(col, row) != .empty { return false; }
} else {
if b.at(col, row) != gems[row - boundary] { return false; }
}
}
}
true
}
// Collapse one scene, snapshot before/after, and assert the collapse invariant
// plus the exact `moved` flag.
scene :: (name: string, rows: []string, want_moved: bool) {
b := load_board(rows);
orig := load_board(rows); // pristine copy for the invariant check
moved := collapse(@b);
print("== {} ==\n", name);
out("before:\n");
out(board_dump(@orig));
out("after:\n");
out(board_dump(@b));
t.expect(check_collapsed(@orig, @b), concat(name, ": gems packed bottom, holes top, order preserved"));
t.expect(moved == want_moved, concat(name, ": moved flag exact"));
}
main :: () -> s32 {
print("== collapse (gravity) ==\n");
// Eight independent columns, one case each (top-to-bottom):
// col0 holes in the MIDDLE: R O Y G B straddle three holes -> all fall.
// col1 holes at the BOTTOM: R O Y sit on top -> fall to the floor.
// col2 a FULL column of holes -> stays all holes.
// col3 NO holes (eight gems) -> unchanged.
// col4 already settled (holes already at the top) -> unchanged.
// col5 a LONE gem at the very top -> drops to the floor.
// col6 an ALTERNATING gem/hole stack -> gems pack, order preserved.
// col7 three gems already resting on the floor -> unchanged.
scene("varied", .[
"RR.R.RR.",
".O.O....",
".Y.Y..O.",
"O..GR...",
"Y..BO.Y.",
"...RY..R",
"G..OG.GO",
"B..YB..Y",
], true);
// No holes anywhere: gravity has nothing to do, board is left byte-identical.
scene("no-holes", .[
"ROYGBPRO",
"YGBPROYG",
"BPROYGBP",
"ROYGBPRO",
"YGBPROYG",
"BPROYGBP",
"ROYGBPRO",
"YGBPROYG",
], false);
// Every cell a hole: an empty board collapses to itself, nothing moves.
scene("all-holes", .[
"........",
"........",
"........",
"........",
"........",
"........",
"........",
"........",
], false);
// Already settled: every column has its holes contiguous at the top and its
// gems contiguous at the bottom (this IS the post-collapse form of "varied").
// Re-collapsing must move nothing and leave the board unchanged — the
// idempotency the P2.4 cascade relies on to detect that gravity has stopped.
scene("settled", .[
"...R....",
"...O....",
"...Y....",
"R..GR...",
"O..BO.R.",
"YR.RY.OR",
"GO.OG.YO",
"BY.YBRGY",
], false);
print("ok: collapse over hand-crafted boards\n");
return 0;
}

140
tests/combo.sx Normal file
View File

@@ -0,0 +1,140 @@
// Combo-multiplier golden (P3.2): drive seeded boards through the full cascade
// settle and snapshot the per-round scoring. Each round's base points
// (`score_round`) are scaled by `combo_multiplier(round)` = the 1-based round
// index, so round 1 ×1, round 2 ×2, round 3 ×3, …; `resolve` accumulates the
// multiplied sum into `Board.score` and reports it as `Cascade.awarded`.
//
// Three scenes prove the rule end to end:
// - single-depth1: one clear, depth 1 → base ×1 = base exactly (NO bonus).
// - cascade-depth2: the P2.4 cascade board (seed 7) → a real 2-round chain
// whose multiplied total (90) strictly beats the flat sum (60).
// - chain-depth3: the same crafted board at seed 10 → a 3-round chain,
// 30×1 + 30×2 + 30×3 = 180, well above the flat 90.
//
// For each scene the starting board and every round's (cleared, base, multiplier,
// round points) are printed so the golden is self-explanatory, the flat and
// multiplied totals are printed side by side, and `resolve` on a fresh identical
// board is asserted to award EXACTLY the multiplied total into `Board.score`.
#import "modules/std.sx";
#import "board.sx";
t :: #import "test.sx";
// Inverse of `gem_char`: map a board character back to its Gem so each board can
// be written as a human-readable grid. The hole glyph maps to `.empty`.
char_to_gem :: (c: u8) -> Gem {
if c == EMPTY_CHAR { return .empty; }
for 0..GEM_COUNT: (i) {
if GEM_CHARS[i] == c { return cast(Gem) i; }
}
.red
}
// Load an 8x8 board from `rows` (top row first, each exactly BOARD_COLS chars),
// seeded RNG, running score zeroed so `board.score` ends equal to the payout.
load_board :: (rows: []string, seed: s64) -> Board {
b : Board = ---;
for 0..BOARD_ROWS: (row) {
line := rows[row];
for 0..BOARD_COLS: (col) {
b.set(col, row, char_to_gem(line[col]));
}
}
b.rng = rng_seeded(seed);
b.score = 0;
b
}
// One scene: drive the settle one round at a time so each round is visible in the
// snapshot — score_round BEFORE the clear, multiplier off the 1-based round index,
// mirroring `resolve` exactly. Print per-round (cleared, base, multiplier, round
// points) and the flat-vs-multiplied totals, then assert `resolve` on a fresh
// identical board awards `want_mult` into `Board.score` and reports it as
// `Cascade.awarded` at the same depth. A depth-1 settle must equal the flat sum
// (no bonus); a deeper chain must strictly exceed it.
scene :: (name: string, rows: []string, seed: s64, want_flat: s64, want_mult: s64) {
print("== {} ==\n", name);
b := load_board(rows, seed);
out(board_dump(@b));
flat : s64 = 0;
mult : s64 = 0;
depth : s64 = 0;
while true {
base := score_round(@b);
n := resolve_step(@b);
if n == 0 { break; }
depth += 1;
m := combo_multiplier(depth);
print("round {}: cleared {} base {} x{} = {}\n", depth, n, base, m, base * m);
flat += base;
mult += base * m;
}
print("flat sum {}\n", flat);
print("multiplied total {}\n", mult);
t.expect(flat == want_flat, concat(name, ": flat sum exact"));
t.expect(mult == want_mult, concat(name, ": multiplied total exact"));
if depth >= 2 {
t.expect(mult > flat, concat(name, ": multi-round chain beats flat sum"));
} else {
t.expect(mult == flat, concat(name, ": single round scores flat (no bonus)"));
}
// The public `resolve` on a fresh identical board reproduces the payout:
// accumulates the multiplied total into `Board.score` and reports it as
// `Cascade.awarded`, at the same depth.
b2 := load_board(rows, seed);
c := resolve(@b2);
t.expect(c.depth == depth, concat(name, ": resolve depth matches manual loop"));
t.expect(c.awarded == want_mult, concat(name, ": resolve awarded equals multiplied total"));
t.expect(b2.score == want_mult, concat(name, ": resolve accumulates into board.score"));
}
main :: () -> s32 {
print("== combo (cascade multiplier) ==\n");
// Single-round clear (seed 0): one RRR clears and the refill makes no new
// match, so the settle stops at depth 1 → base 30 ×1 = 30, exactly the flat
// value. Proves there is no combo bonus on a single round.
scene("single-depth1", .[
"RRRGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GOGOGOGO",
"OGOGOGOG",
"GOGOGOGO",
], 0, 30, 30);
// The P2.4 cascade board (seed 7): round 1 clears the horizontal BBB (base 30
// ×1), round 2 the gravity-formed vertical RRR (base 30 ×2) → 30 + 60 = 90,
// strictly above the flat 30 + 30 = 60.
scene("cascade-depth2", .[
"OGOGOGOG",
"GOGOGOGO",
"RGOGOGOG",
"BBBOGOGO",
"RGOGOGOG",
"ROGOGOGO",
"OGOGOGOG",
"GOGOGOGO",
], 7, 60, 90);
// The same crafted board at seed 10: the refill after round 2 sets up a third
// len-3 clear, a controlled 3-round chain → 30×1 + 30×2 + 30×3 = 180, well
// above the flat 90.
scene("chain-depth3", .[
"OGOGOGOG",
"GOGOGOGO",
"RGOGOGOG",
"BBBOGOGO",
"RGOGOGOG",
"ROGOGOGO",
"OGOGOGOG",
"GOGOGOGO",
], 10, 90, 180);
print("ok: combo multiplier scales cascade rounds\n");
return 0;
}

270
tests/easing.sx Normal file
View File

@@ -0,0 +1,270 @@
// Easing-toolkit math foundation (P15.1): pin the pure, headless easing curves in
// board_anim.sx that the organic-animation pass (P16/P17/P18) builds on. NO render
// code calls these yet, so this test is the only consumer — it locks, for each
// curve: the endpoints f(0)/f(1) (and f(0.5) where it's a fixed point), the
// overshoot/undershoot range (bounded + tasteful), and monotonicity where the
// curve must not reverse. Mirrors how tests/gem_pose.sx pins the gem poses; prints
// only booleans (no raw floats) so the snapshot is platform-stable.
//
// P16.2 will APPEND illegal-swap bounce-back assertions here: add a new numbered
// section above the final fails check, in the same `print(...); if !x { fails += 1; }`
// shape. No rendering — pure math over board_anim.sx. Failure is a non-zero exit.
#import "modules/std.sx";
#import "board.sx";
#import "board_anim.sx";
// Local f32 abs (the stdlib generic `abs` mis-types its untyped `0` literal under
// f32; the shipped game never calls abs, so the tests roll their own — matches
// tests/gem_pose.sx).
fabs :: (x: f32) -> f32 { if x < 0.0 then 0.0 - x else x }
approx :: (a: f32, b: f32) -> bool { fabs(a - b) < 0.0001 }
main :: () -> s32 {
fails : s64 = 0;
// 1. Endpoints are locked: every curve starts/ends exactly on its rest value
// (the in/out curves at 1, the spring at 1, the squash envelope at 0).
print("== endpoints locked ==\n");
e_in := ease_in_cubic(0.0) == 0.0 and ease_in_cubic(1.0) == 1.0;
e_io := ease_in_out_cubic(0.0) == 0.0 and ease_in_out_cubic(1.0) == 1.0
and ease_in_out_cubic(0.5) == 0.5;
e_back := ease_out_back(0.0) == 0.0 and ease_out_back(1.0) == 1.0;
e_spring := spring(0.0) == 0.0 and spring(1.0) == 1.0;
e_squash := squash_envelope(0.0) == 0.0 and squash_envelope(1.0) == 0.0;
e_exist := ease_out_cubic(0.0) == 0.0 and ease_out_cubic(1.0) == 1.0
and ease_in_quad(0.0) == 0.0 and ease_in_quad(1.0) == 1.0;
print("ease_in {} ease_in_out {} back {} spring {} squash {} existing {}\n",
e_in, e_io, e_back, e_spring, e_squash, e_exist);
if !e_in { fails += 1; }
if !e_io { fails += 1; }
if !e_back { fails += 1; }
if !e_spring { fails += 1; }
if !e_squash { fails += 1; }
if !e_exist { fails += 1; }
// 2. Monotonicity where required: the four plain eases never reverse over a
// fine sweep of [0,1] (the overshoot/spring/squash curves are exempt — they
// are meant to reverse).
print("== monotonic where required ==\n");
mono_in := true; mono_io := true; mono_oc := true; mono_iq := true;
p_in := ease_in_cubic(0.0);
p_io := ease_in_out_cubic(0.0);
p_oc := ease_out_cubic(0.0);
p_iq := ease_in_quad(0.0);
for 1..21: (i) {
t := cast(f32) i / 20.0;
v_in := ease_in_cubic(t); if v_in < p_in - 0.000001 { mono_in = false; } p_in = v_in;
v_io := ease_in_out_cubic(t); if v_io < p_io - 0.000001 { mono_io = false; } p_io = v_io;
v_oc := ease_out_cubic(t); if v_oc < p_oc - 0.000001 { mono_oc = false; } p_oc = v_oc;
v_iq := ease_in_quad(t); if v_iq < p_iq - 0.000001 { mono_iq = false; } p_iq = v_iq;
}
print("ease_in {} ease_in_out {} ease_out_cubic {} ease_in_quad {}\n",
mono_in, mono_io, mono_oc, mono_iq);
if !mono_in { fails += 1; }
if !mono_io { fails += 1; }
if !mono_oc { fails += 1; }
if !mono_iq { fails += 1; }
// 3. Back/overshoot + spring: each shoots above 1 then settles to exactly 1,
// with a BOUNDED peak (tasteful) and no dip below 0. The spring additionally
// wobbles back below 1 after its overshoot (the damped decay).
print("== overshoot bounded + settles ==\n");
back_mx := ease_out_back(0.0); back_mn := ease_out_back(0.0);
spr_mx := spring(0.0); spr_mn := spring(0.0);
spr_wobble := false;
for 1..21: (i) {
t := cast(f32) i / 20.0;
b := ease_out_back(t);
if b > back_mx { back_mx = b; }
if b < back_mn { back_mn = b; }
s := spring(t);
if s > spr_mx { spr_mx = s; }
if s < spr_mn { spr_mn = s; }
if t > 0.6 and s < 1.0 { spr_wobble = true; }
}
back_overshoots := back_mx > 1.0;
back_bounded := back_mx < 1.15 and back_mn >= -0.0001;
spr_overshoots := spr_mx > 1.0;
spr_bounded := spr_mx < 1.25 and spr_mn >= -0.0001;
print("back_overshoots {} back_bounded {} spring_overshoots {} spring_bounded {} spring_wobbles {}\n",
back_overshoots, back_bounded, spr_overshoots, spr_bounded, spr_wobble);
if !back_overshoots { fails += 1; }
if !back_bounded { fails += 1; }
if !spr_overshoots { fails += 1; }
if !spr_bounded { fails += 1; }
if !spr_wobble { fails += 1; }
// 4. Squash envelope: rests at both ends, actually moves in between, has both a
// squash (positive) and a stretch (negative) lobe, and stays bounded.
print("== squash envelope bounded ==\n");
sq_mx : f32 = 0.0; sq_mn : f32 = 0.0; sq_moves := false;
for 0..21: (i) {
t := cast(f32) i / 20.0;
s := squash_envelope(t);
if s > sq_mx { sq_mx = s; }
if s < sq_mn { sq_mn = s; }
if fabs(s) > 0.01 { sq_moves = true; }
}
sq_two_sided := sq_mx > 0.0 and sq_mn < 0.0;
sq_bounded := sq_mx < 0.75 and sq_mn > -0.75;
print("squash_moves {} squash_two_sided {} squash_bounded {}\n",
sq_moves, sq_two_sided, sq_bounded);
if !sq_moves { fails += 1; }
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; }
// 6. Per-column fall stagger (P17.2): the fall window offsets each column's drop
// START by a BOUNDED delay so a refilled row pours in as a cascade, yet EVERY
// column still lands EXACTLY on its cell by the segment end. Lock: at t==0 no
// column has moved; at t==1 EVERY column has reached local progress 1 (no gem
// left mid-air — the seam to the next round stays invisible); per-column local
// progress is monotonic in t; and MID-fall the columns form a cascade — each
// later column has made STRICTLY LESS progress than the one before (its drop
// starts later), the opposite of a flat lockstep row sharing one progress.
print("== fall stagger bounded ==\n");
stg_t0 := true; stg_t1 := true;
for 0..BOARD_COLS: (c) {
if fall_stagger_t(0.0, c) != 0.0 { stg_t0 = false; }
if fall_stagger_t(1.0, c) != 1.0 { stg_t1 = false; }
}
stg_cascade := true;
for 1..BOARD_COLS: (c) {
if !(fall_stagger_t(0.5, c) < fall_stagger_t(0.5, c - 1)) { stg_cascade = false; }
}
stg_mono := true;
for 0..BOARD_COLS: (c) {
pp := fall_stagger_t(0.0, c);
for 1..21: (i) {
tt := cast(f32) i / 20.0;
vv := fall_stagger_t(tt, c);
if vv < pp - 0.000001 { stg_mono = false; }
pp = vv;
}
}
print("stagger_t0 {} stagger_t1 {} stagger_cascade {} stagger_mono {}\n",
stg_t0, stg_t1, stg_cascade, stg_mono);
if !stg_t0 { fails += 1; }
if !stg_t1 { fails += 1; }
if !stg_cascade { fails += 1; }
if !stg_mono { fails += 1; }
// 7. Per-column landing instant (P17.3): `fall_landing_frac` is the LOCAL fall
// progress at which each column finishes its drop — exactly the seam where
// `fall_stagger_t` reaches 1, the moment the landing squash-bounce begins.
// Lock: column 0 lands first at `1 - FALL_STAGGER_MAX`, the last column at
// 1.0; it rises monotonically across columns; at that instant the column's
// stagger progress IS 1 (landed) while a hair earlier it is still < 1 (in
// air). `round_land_time` then maps it onto the move timeline — later for
// each later column, and round k+1's first landing strictly after round k's
// last — so the per-round bounces never run before their gems touch down.
print("== landing instant ==\n");
lf_first := approx(fall_landing_frac(0), 1.0 - FALL_STAGGER_MAX);
lf_last := approx(fall_landing_frac(BOARD_COLS - 1), 1.0);
lf_mono := true;
lf_seam := true;
for 0..BOARD_COLS: (c) {
if c >= 1 and !(fall_landing_frac(c) > fall_landing_frac(c - 1)) { lf_mono = false; }
lf := fall_landing_frac(c);
if !approx(fall_stagger_t(lf, c), 1.0) { lf_seam = false; } // landed at lf
if fall_stagger_t(lf - 0.05, c) >= 1.0 { lf_seam = false; } // still in air just before
}
rlt_col_mono := true;
for 1..BOARD_COLS: (c) {
if !(round_land_time(0, c) > round_land_time(0, c - 1)) { rlt_col_mono = false; }
}
rlt_round_after := round_land_time(1, 0) > round_land_time(0, BOARD_COLS - 1);
print("landing_first {} landing_last {} landing_mono {} landing_seam {} landtime_col_mono {} landtime_round_after {}\n",
lf_first, lf_last, lf_mono, lf_seam, rlt_col_mono, rlt_round_after);
if !lf_first { fails += 1; }
if !lf_last { fails += 1; }
if !lf_mono { fails += 1; }
if !lf_seam { fails += 1; }
if !rlt_col_mono { fails += 1; }
if !rlt_round_after { fails += 1; }
// 8. Per-gem clear ripple (P18.2): within a clearing round each matched gem's
// pop START is offset by a BOUNDED delay (its rank u in [0,1] across the
// round's matched cells) so the matched cells explode as a ripple, yet EVERY
// gem still completes its pop by the segment end. Lock: at t==0 no rank has
// started; at t==1 EVERY rank has reached local 1 (clear_pop_scale → scale 0,
// fully cleared — no gem left mid-pop at the seam to the fall); per-rank local
// progress is monotonic in t; and MID-clear a HIGHER rank has made STRICTLY
// LESS progress than a lower one (its pop starts later) — the ripple, the
// opposite of a flat simultaneous clear. `clear_rank` then ranks each matched
// gem 0..1 by diagonal across the round (lowest-diagonal = 0, the first to pop).
print("== clear ripple bounded ==\n");
rip_t0 := true; rip_t1 := true;
for 0..6: (j) {
u := cast(f32) j / 5.0;
if clear_ripple_t(0.0, u) != 0.0 { rip_t0 = false; }
if clear_ripple_t(1.0, u) != 1.0 { rip_t1 = false; }
}
rip_ripple := true;
for 1..6: (j) {
u := cast(f32) j / 5.0;
up := cast(f32) (j - 1) / 5.0;
if !(clear_ripple_t(0.5, u) < clear_ripple_t(0.5, up)) { rip_ripple = false; }
}
rip_mono := true;
for 0..6: (j) {
u := cast(f32) j / 5.0;
pp := clear_ripple_t(0.0, u);
for 1..21: (i) {
tt := cast(f32) i / 20.0;
vv := clear_ripple_t(tt, u);
if vv < pp - 0.000001 { rip_mono = false; }
pp = vv;
}
}
mm : MatchMask = ---;
for 0..BOARD_CELLS: (i) { mm.cells[i] = false; }
mm.cells[Board.idx(5, 0)] = true; // diagonal 5 — first to pop
mm.cells[Board.idx(5, 1)] = true; // diagonal 6
mm.cells[Board.idx(5, 2)] = true; // diagonal 7 — last to pop
sp := clear_diag_span(@mm);
rip_rank := approx(clear_rank(sp, 5, 0), 0.0)
and approx(clear_rank(sp, 5, 1), 0.5)
and approx(clear_rank(sp, 5, 2), 1.0);
print("ripple_t0 {} ripple_t1 {} ripple_cascade {} ripple_mono {} ripple_rank {}\n",
rip_t0, rip_t1, rip_ripple, rip_mono, rip_rank);
if !rip_t0 { fails += 1; }
if !rip_t1 { fails += 1; }
if !rip_ripple { fails += 1; }
if !rip_mono { fails += 1; }
if !rip_rank { fails += 1; }
if fails == 0 {
print("ok: easing toolkit endpoints locked + amplitudes bounded\n");
return 0;
}
print("FAIL: {} easing checks failed\n", fails);
return 1;
}

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,20 @@
== legal swap: plan matches model ==
model: legal true depth 1 score 30 moves 1
plan: legal true rounds 1 score 30 moves 1
final==model true
contiguous true
final board:
RRPBORRG
PGPPOGRO
YYBOPRYB
GBYBYRGP
OGBYRGOY
BYRRPRBG
YOYYROBB
OROBPPRB
== illegal swap: untouched ==
legal false rounds 0 score 0 moves 0
== input gate: locked while animating ==
accepts idle true busy false settled true
release_gate_commits true press_gate_commits false
ok: animation layer leaves the model result unchanged

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1 @@
ok: arithmetic

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,9 @@
grid (100,0,600,600)
panel (148,168,503,264)
title (148,215,503,79)
button (248,326,302,63)
button mid_x 400 grid mid_x 400
button corners inside panel: 4/4
button center hit: true
off-button taps that hit the button: 0
ok: restart button hit-test matches the drawn banner

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,9 @@
RRPPOGRG
PGPOPRRO
YYBBYRYB
GBYYRGGP
OGBRRORY
BYRRPRBG
YOYYROBB
OROBPPRB
ok: board_init no-match invariant holds

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,30 @@
== cascade (resolution loop) ==
start:
OGOGOGOG
GOGOGOGO
RGOGOGOG
BBBOGOGO
RGOGOGOG
ROGOGOGO
OGOGOGOG
GOGOGOGO
round 1: cleared 3 cells
RBRGOGOG
OGOOGOGO
GOGGOGOG
RGOOGOGO
RGOGOGOG
ROGOGOGO
OGOGOGOG
GOGOGOGO
round 2: cleared 3 cells
RBRGOGOG
PGOOGOGO
YOGGOGOG
RGOOGOGO
OGOGOGOG
GOGOGOGO
OGOGOGOG
GOGOGOGO
cascade depth 2
ok: cascade resolves to a stable board

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,12 @@
== cascade cue selection (depth -> combo cue) ==
depth 0 -> idx 0 ([sx] audio: cue combo1)
depth 1 -> idx 0 ([sx] audio: cue combo1)
depth 2 -> idx 1 ([sx] audio: cue combo2)
depth 3 -> idx 2 ([sx] audio: cue combo3)
depth 4 -> idx 3 ([sx] audio: cue combo4)
depth 5 -> idx 4 ([sx] audio: cue combo5)
depth 6 -> idx 4 ([sx] audio: cue combo5)
depth 7 -> idx 4 ([sx] audio: cue combo5)
depth 8 -> idx 4 ([sx] audio: cue combo5)
depth 9 -> idx 4 ([sx] audio: cue combo5)
ok: cascade cue mapping clamps into combo1..combo5

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,29 @@
== per-round cascade cue timing ==
-- started-count across a 5-round chain --
e=0.00 -> 0
e=0.10 -> 0
e=0.20 -> 1
e=0.50 -> 1
e=0.55 -> 2
e=0.90 -> 3
e=1.30 -> 4
e=1.70 -> 5
e=5.00 -> 5
-- ascending per-round run --
round 1 -> [sx] audio: cue combo1
round 2 -> [sx] audio: cue combo2
round 3 -> [sx] audio: cue combo3
round 4 -> [sx] audio: cue combo4
round 5 -> [sx] audio: cue combo5
-- non-cascade --
1-round@end 1
0-round@end 0
-- deep-chain cue clamp --
round 1 -> [sx] audio: cue combo1
round 2 -> [sx] audio: cue combo2
round 3 -> [sx] audio: cue combo3
round 4 -> [sx] audio: cue combo4
round 5 -> [sx] audio: cue combo5
round 6 -> [sx] audio: cue combo5
round 7 -> [sx] audio: cue combo5
ok: one ascending combo cue per cascade round, clamped at combo5

View File

@@ -0,0 +1 @@
0

116
tests/expected/clear.stdout Normal file
View File

@@ -0,0 +1,116 @@
== clear (detect -> clear) ==
== horizontal-3 ==
before:
OGOGOGOG
GOGOGOGO
OGOGOGOG
GORRROGO
OGOGOGOG
GOGOGOGO
OGOGOGOG
GOGOGOGO
after:
OGOGOGOG
GOGOGOGO
OGOGOGOG
GO...OGO
OGOGOGOG
GOGOGOGO
OGOGOGOG
GOGOGOGO
== vertical-3 ==
before:
OGOGOGOG
GOGOGOGO
OGOGOBOG
GOGOGBGO
OGOGOBOG
GOGOGOGO
OGOGOGOG
GOGOGOGO
after:
OGOGOGOG
GOGOGOGO
OGOGO.OG
GOGOG.GO
OGOGO.OG
GOGOGOGO
OGOGOGOG
GOGOGOGO
== disjoint-runs ==
before:
OGOGOGOG
RRROGOGO
OGOGOGOG
GOGOGOGO
OGOGOGYG
GOPPPOYO
OGOGOGYG
GOGOGOGO
after:
OGOGOGOG
...OGOGO
OGOGOGOG
GOGOGOGO
OGOGOG.G
GO...O.O
OGOGOG.G
GOGOGOGO
== L-and-T ==
before:
OGOGOGOG
GRRRGOGO
OROGOGOG
GRGOGOGO
OGOGOGOG
GOGYYYGO
OGOGYGOG
GOGOYOGO
after:
OGOGOGOG
G...GOGO
O.OGOGOG
G.GOGOGO
OGOGOGOG
GOG...GO
OGOG.GOG
GOGO.OGO
== no-matches ==
before:
OGOGOGOG
GOGOGOGO
OGOGOGOG
GOGOGOGO
OGOGOGOG
GOGOGOGO
OGOGOGOG
GOGOGOGO
after:
OGOGOGOG
GOGOGOGO
OGOGOGOG
GOGOGOGO
OGOGOGOG
GOGOGOGO
OGOGOGOG
GOGOGOGO
== holes-no-match ==
before:
OGOGOGOG
GOGOGOGO
OGOGOGOG
GO...OGO
OGOGOGOG
G.GOGOGO
O.OGOGOG
G.GOGOGO
after:
OGOGOGOG
GOGOGOGO
OGOGOGOG
GO...OGO
OGOGOGOG
G.GOGOGO
O.OGOGOG
G.GOGOGO
ok: clear over hand-crafted boards

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,78 @@
== collapse (gravity) ==
== varied ==
before:
RR.R.RR.
.O.O....
.Y.Y..O.
O..GR...
Y..BO.Y.
...RY..R
G..OG.GO
B..YB..Y
after:
...R....
...O....
...Y....
R..GR...
O..BO.R.
YR.RY.OR
GO.OG.YO
BY.YBRGY
== no-holes ==
before:
ROYGBPRO
YGBPROYG
BPROYGBP
ROYGBPRO
YGBPROYG
BPROYGBP
ROYGBPRO
YGBPROYG
after:
ROYGBPRO
YGBPROYG
BPROYGBP
ROYGBPRO
YGBPROYG
BPROYGBP
ROYGBPRO
YGBPROYG
== all-holes ==
before:
........
........
........
........
........
........
........
........
after:
........
........
........
........
........
........
........
........
== settled ==
before:
...R....
...O....
...Y....
R..GR...
O..BO.R.
YR.RY.OR
GO.OG.YO
BY.YBRGY
after:
...R....
...O....
...Y....
R..GR...
O..BO.R.
YR.RY.OR
GO.OG.YO
BY.YBRGY
ok: collapse over hand-crafted boards

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,41 @@
== combo (cascade multiplier) ==
== single-depth1 ==
RRRGOGOG
GOGOGOGO
OGOGOGOG
GOGOGOGO
OGOGOGOG
GOGOGOGO
OGOGOGOG
GOGOGOGO
round 1: cleared 3 base 30 x1 = 30
flat sum 30
multiplied total 30
== cascade-depth2 ==
OGOGOGOG
GOGOGOGO
RGOGOGOG
BBBOGOGO
RGOGOGOG
ROGOGOGO
OGOGOGOG
GOGOGOGO
round 1: cleared 3 base 30 x1 = 30
round 2: cleared 3 base 30 x2 = 60
flat sum 60
multiplied total 90
== chain-depth3 ==
OGOGOGOG
GOGOGOGO
RGOGOGOG
BBBOGOGO
RGOGOGOG
ROGOGOGO
OGOGOGOG
GOGOGOGO
round 1: cleared 3 base 30 x1 = 30
round 2: cleared 3 base 30 x2 = 60
round 3: cleared 3 base 30 x3 = 90
flat sum 90
multiplied total 180
ok: combo multiplier scales cascade rounds

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,17 @@
== endpoints locked ==
ease_in true ease_in_out true back true spring true squash true existing true
== monotonic where required ==
ease_in true ease_in_out true ease_out_cubic true ease_in_quad true
== overshoot bounded + settles ==
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
== fall stagger bounded ==
stagger_t0 true stagger_t1 true stagger_cascade true stagger_mono true
== landing instant ==
landing_first true landing_last true landing_mono true landing_seam true landtime_col_mono true landtime_round_after true
== clear ripple bounded ==
ripple_t0 true ripple_t1 true ripple_cascade true ripple_mono true ripple_rank true
ok: easing toolkit endpoints locked + amplitudes bounded

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,12 @@
== combo emphasis selection (depth -> fx level / popup font) ==
depth 0 -> level 0 font 34.000000 combo false
depth 1 -> level 0 font 34.000000 combo false
depth 2 -> level 1 font 48.000000 combo true
depth 3 -> level 2 font 56.000000 combo true
depth 4 -> level 3 font 64.000000 combo true
depth 5 -> level 4 font 72.000000 combo true
depth 6 -> level 4 font 72.000000 combo true
depth 7 -> level 4 font 72.000000 combo true
depth 8 -> level 4 font 72.000000 combo true
depth 9 -> level 4 font 72.000000 combo true
ok: combo emphasis clamps into level 0..4 in lockstep with the cascade cue

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,15 @@
== idle t=0 is rest for all cells ==
idle_t0_rest true
== idle mid-phase deforms, bounded ==
idle_mid_moves true idle_bounded true
== select pop envelope ==
select_start_rest true select_end_rest true select_mid_pops true
== land squash envelope ==
land_start_rest true land_end_rest true land_mid_wobbles true
== clear pop envelope ==
clear_start_full true clear_end_gone true clear_dips true clear_overshoots true clear_collapses true
== gem motion land bookkeeping ==
motion_init true motion_no_land true motion_fresh_land true
== gem motion restart resets landings ==
restart_pre_squashing true restart_post_rest true restart_clock_kept true
ok: per-gem animation rests at t=0 and stays bounded

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,5 @@
grid origin (100,0) cell 75
ok: 64/64 cell centers round-trip
corner maps to (3,5)
ok: 0 off-board taps resolved to a cell
ok: hit-test mapping is the inverse of the layout

View File

@@ -0,0 +1 @@
0

Some files were not shown because too many files have changed in this diff Show More