Merge branch 'm3te-plan'
10
.gitignore
vendored
Normal 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
@@ -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 2–7 by one
|
||||
cell each (its fall window `[1.74, 1.96)` s); at `1.91` the leading column has just
|
||||
landed while the trailing column is still ~⅔ cell high, so the top row reads as a
|
||||
left-to-right staircase instead of a flat band:
|
||||
|
||||
```bash
|
||||
# Refilled row pouring in as a staggered cascade: goldens/p17_stagger.png
|
||||
env SIMCTL_CHILD_M3TE_FX=11 SIMCTL_CHILD_M3TE_ANIM_TIME=1.91 \
|
||||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||||
# Same cascade past the timeline — every gem landed exactly on the model board:
|
||||
env SIMCTL_CHILD_M3TE_FX=11 SIMCTL_CHILD_M3TE_ANIM_TIME=3.0 \
|
||||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||||
```
|
||||
|
||||
The change is render-only — no `board.sx` model change, and normal play is
|
||||
byte-identical apart from the fall's per-column timing.
|
||||
|
||||
### Organic fall — landing squash-&-settle (P17.3)
|
||||
|
||||
Each landing gem now flattens **wide-and-short on impact then wobbles back to rest**
|
||||
(P15.1's `squash_envelope`), applied WITHIN the fall so EVERY cascade round bounces,
|
||||
staggered per column — not only the final whole-move settle as before. `render_fall`
|
||||
ages a per-column bounce from each column's touch-down instant (`fall_landing_frac` ·
|
||||
`FALL_ANIM_DUR`), so a gem still in the air is drawn unsquashed and only a gem that
|
||||
has reached its cell flattens; the squash carries across the fall→clear seam
|
||||
(`render_clear` continues the previous round's bounce) and across the final
|
||||
render_anim → render_gems seam (the settle stamp is **back-dated** per column so
|
||||
`land_squash` resumes exactly where the fall left it — ONE bounce, no double-pop).
|
||||
`land_squash` is now `LAND_SQUASH_A · squash_envelope(tl/LAND_DUR)`, so the per-round
|
||||
fall bounce and the settle bounce are the same single envelope; amplitude is the
|
||||
tasteful ~13 % peak (`LAND_SQUASH_A = 0.18`). Durations are unchanged, so the
|
||||
cascade-cue snapshots don't churn; `M3TE_ANIM_TIME=0` still reproduces
|
||||
`goldens/p6_idle_t0.png` exactly (a resting board carries no landing stamp).
|
||||
|
||||
The visual tell: pin a round's fall just before it ends and the leading columns sit
|
||||
landed-and-squashed (wide-short) while the trailing columns are still airborne — a
|
||||
staggered squash wave. On seed 1337, `M3TE_FX=11` **round 4** (the refill round) at
|
||||
`1.94` shows columns 2–4 landed and flattened with columns 5–7 still pouring in
|
||||
(every round behaves identically — round 2 `[1.02,1.24)` at `1.21` and round 3
|
||||
`[1.38,1.60)` near `1.58` bounce the same way, so the bounce is NOT limited to the
|
||||
last settle):
|
||||
|
||||
```bash
|
||||
# Staggered landing squash mid-pour (leading cols flattened, trailing airborne):
|
||||
# goldens/p17_land.png
|
||||
env SIMCTL_CHILD_M3TE_FX=11 SIMCTL_CHILD_M3TE_ANIM_TIME=1.94 \
|
||||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||||
# Same cascade past the timeline — fully settled, bounce decayed to rest (no golden):
|
||||
env SIMCTL_CHILD_M3TE_FX=11 SIMCTL_CHILD_M3TE_ANIM_TIME=3.0 \
|
||||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||||
```
|
||||
|
||||
All three fall goldens were re-captured in P19.2 to the final, fully-merged fall
|
||||
motion (gravity accel + per-column stagger + landing squash), each pinned to
|
||||
foreground a different tell of it: `goldens/p17_fall.png` (round 3 at `1.51`) catches
|
||||
the columns bunched HIGH under the gravity accel before any has landed (so it carries
|
||||
no squash); `goldens/p17_stagger.png` (round 4 at `1.91`) catches the left-to-right
|
||||
staircase as the leading column just touches down; and this golden (round 4 at `1.94`)
|
||||
catches the squash wave once the leading columns have landed-and-flattened while the
|
||||
trailing ones still pour in. The change is render-only — no `board.sx` model change,
|
||||
and a resting board is untouched.
|
||||
|
||||
### Organic combine — anticipation pop on clear (P18.1)
|
||||
|
||||
Matched gems no longer just pop-then-shrink flatly: `clear_pop_scale` now shapes the
|
||||
clear as a candy pop in three beats over its local `0..1` — a tiny anticipation
|
||||
squash dip (a "gather" ~8 % below rest), a snappy overshoot up to ~1.40× via P15.1's
|
||||
`ease_out_back`, then an accelerating collapse to nothing (`ease_in_quad`). The
|
||||
endpoints stay LOCKED — `t==0 → 1.0` (rest) and `t==1 → 0.0` (gone) — so the seam to
|
||||
the model board is clean and `M3TE_ANIM_TIME=0` still reproduces the resting board;
|
||||
the soft particle burst / `+points` popup (`board_fx.sx`) compose on top.
|
||||
`tests/gem_pose.sx` pins the new envelope (locked rest endpoints, the anticipation
|
||||
dip below rest, the overshoot above 1, and the strictly monotonic post-peak
|
||||
collapse). `CLEAR_ANIM_DUR` (0.14 s) is unchanged, so the per-round cascade-cue
|
||||
timing snapshots (`tests/cascade_rounds.sx` / `cascade_cue.sx`) don't churn.
|
||||
|
||||
The pop peaks at clear-phase local `t ≈ 0.37`; for `M3TE_FX=3` (the seed-1337
|
||||
vertical red 3-match in column 5, rows 0–2) the clear window is `[0.16, 0.30)` s.
|
||||
Because P18.2 staggers each matched gem's pop START (see below), the `0.21` capture
|
||||
no longer catches the three gems together at this shared peak — it catches them at
|
||||
DIFFERENT points on this same curve, a ripple: the top gem collapsing, the middle
|
||||
rising toward its overshoot, and the bottom still at rest (full size), all composed
|
||||
with the burst and "+30" popup:
|
||||
|
||||
```bash
|
||||
# Per-gem candy-pop shape, staggered across the match by P18.2 (composed w/ burst):
|
||||
# goldens/p18_pop.png
|
||||
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0.21 \
|
||||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||||
# Same match at exact rest (t=0) — board sits at its resting pose, no pop:
|
||||
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0 \
|
||||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||||
```
|
||||
|
||||
`goldens/p6_fx_match.png` (the P11.1 burst+popup reference, pinned at `0.22`) was
|
||||
refreshed for the new pop shape (and re-refreshed in P18.2 for the stagger — pinned
|
||||
mid-clear, the committed frame now shows the ripple, not a uniform pop). This step is
|
||||
the per-gem pop SHAPE only; the per-gem STAGGER of the explosions follows in P18.2.
|
||||
The change is render-only — no `board.sx` model change, and normal play is
|
||||
byte-identical apart from the clear's pop curve.
|
||||
|
||||
### Organic combine — staggered clear ripple (P18.2)
|
||||
|
||||
The matched gems no longer all explode at once: within a clearing round each gem's
|
||||
pop (and its burst) START is offset by a small BOUNDED delay so the cells detonate
|
||||
as a RIPPLE. `clear_ripple_t(t, u)` mirrors `fall_stagger_t`'s `(t-delay)/window`:
|
||||
a gem's normalized rank `u ∈ [0,1]` (its diagonal `col+row` position within the
|
||||
round's matched cells, lowest = `0`) delays its pop START by `CLEAR_STAGGER_MAX·u`
|
||||
(`0.45` of the clear window), then plays the P18.1 `clear_pop_scale` curve over the
|
||||
remaining `1 − CLEAR_STAGGER_MAX`. The rank is normalized PER ROUND (not across the
|
||||
board) so even a 3-match ripples across the full stagger budget. It is BOUNDED:
|
||||
every matched gem still reaches local `1` (scale `0`, fully cleared) by the clear
|
||||
segment's end — the last-to-start gem (`u=1`) lands exactly at `t==1` — so no gem is
|
||||
left mid-pop at the seam to the fall. The bursts (`board_fx.sx`) carry the SAME
|
||||
per-gem delay so they ripple in lockstep with the pops. `tests/easing.sx` pins the
|
||||
envelope (locked `f(0,·)=0` / `f(1,·)=1` endpoints, bounded completion by `t==1`,
|
||||
monotonicity, and the rank ordering).
|
||||
|
||||
INTRA-ROUND VISUAL ONLY: the per-round cascade audio (P10.10) is untouched — one
|
||||
ascending cue per round at the round's clear, NOT per gem — and the model is
|
||||
unchanged (same cells cleared, same final board). `CLEAR_ANIM_DUR` (`0.14` s) is
|
||||
unchanged, so the cascade-cue snapshots (`tests/cascade_rounds.sx` / `cascade_cue.sx`)
|
||||
don't churn and `M3TE_ANIM_TIME=0` still reproduces the resting board.
|
||||
|
||||
For `M3TE_FX=3` (the seed-1337 vertical red 3-match in column 5, rows 0–2) the clear
|
||||
window is `[0.16, 0.30)` s; at `M3TE_ANIM_TIME=0.22` the ripple is at its clearest —
|
||||
the TOP gem is collapsing, the MIDDLE is mid-burst, and the BOTTOM is still full-size
|
||||
(not yet started):
|
||||
|
||||
```bash
|
||||
# Staggered clear ripple (top collapsing / middle bursting / bottom not yet):
|
||||
# goldens/p18_stagger.png
|
||||
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0.22 \
|
||||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||||
# Past the timeline — all three cells cleared per the model, board continues:
|
||||
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=2.0 \
|
||||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||||
```
|
||||
|
||||
`goldens/p18_pop.png` (`M3TE_FX=3` at `0.21`), `goldens/p6_fx_match.png` (`M3TE_FX=3`
|
||||
at `0.22`), and `goldens/p11_combo_deep.png` (`M3TE_FX=11` at `0.22`) were refreshed
|
||||
— each was pinned mid-clear, so each now shows the staggered ripple instead of the
|
||||
prior simultaneous pop.
|
||||
|
||||
### FPS counter — dev overlay (P20.1)
|
||||
|
||||
A small FPS readout for gauging frame cost while tuning the animations. It is a
|
||||
**dev overlay, OFF by default**: only the `M3TE_FPS` env pin turns it on, so default
|
||||
play and every committed golden stay byte-identical (with `M3TE_FPS` unset the
|
||||
rendered scene is unchanged — `goldens/p6_idle_t0.png` reproduces exactly).
|
||||
|
||||
- `M3TE_FPS=<non-zero>` renders the FPS counter in the **top-left corner** (inside
|
||||
the safe area, clear of the centered notch / Dynamic Island and the centered HUD).
|
||||
The rate is computed from the per-frame `delta_time` as an exponential moving
|
||||
average (`FPS_DT_SMOOTH = 0.9`) so the digits don't jitter. Read once at startup
|
||||
like every other `M3TE_*` pin; `=0` or unset leaves it off. Purely a render
|
||||
overlay — no board / score / move / animation state changes, and it never gates
|
||||
input. `delta_time` is real wall-clock even when `M3TE_ANIM_TIME` pins the
|
||||
animation, so the counter stays live while the rest of the scene is frozen.
|
||||
|
||||
```bash
|
||||
# FPS counter over the resting board: goldens/p20_fps.png
|
||||
# (the FPS digits are DYNAMIC — only the FPS text varies run-to-run; the rest of the
|
||||
# scene is pinned at M3TE_ANIM_TIME=0, byte-identical to goldens/p6_idle_t0.png)
|
||||
env SIMCTL_CHILD_M3TE_FPS=1 SIMCTL_CHILD_M3TE_ANIM_TIME=0 \
|
||||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||||
# On device, pass the same flag through devicectl's environment instead:
|
||||
# xcrun devicectl device process launch --environment M3TE_FPS=1 ... co.swipelab.m3te
|
||||
```
|
||||
|
||||
`goldens/p20_fps.png` is the only golden that captures this overlay; because the FPS
|
||||
digits are dynamic, compare the FPS text's PRESENCE in the top-left corner, not its
|
||||
exact value. Every other golden is captured with `M3TE_FPS` unset and is unaffected.
|
||||
|
||||
### Move-timeline frame goldens (P6.1 / P6.2)
|
||||
|
||||
The board-motion timeline (`board_anim.sx`: swap slide → matched-gem clear →
|
||||
collapse/refill fall) and the match-FX layer (`board_fx.sx`) were first locked by a
|
||||
set of frame goldens captured off ONE committed move. They use the same `M3TE_FX`
|
||||
hook as the FX captures above — `M3TE_FX=3` is the seed-1337 vertical red 3-match (a
|
||||
single round) — each pinned with `M3TE_ANIM_TIME` to a phase of the swap→clear→fall→
|
||||
settled timeline. Re-captured in P19.2 so each now shows the **organic** motion merged
|
||||
in P16–P18 (swap overshoot, anticipation-pop clear ripple, gravity-accel fall); the
|
||||
canonical per-feature organic goldens live in the P16/P17/P18 sections above, and
|
||||
these are the generic timeline frames at their segment midpoints.
|
||||
|
||||
```bash
|
||||
# Swap segment midpoint — the swapped gems caught PAST target (ease_out_back
|
||||
# overshoot): goldens/p6_anim_swap.png
|
||||
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0.08 \
|
||||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||||
# Clear segment midpoint — matched gems mid pop-ripple, composed with the burst and
|
||||
# "+30" popup. The SAME committed-move frame backs all three of:
|
||||
# goldens/p6_anim_clear.png == goldens/p6_fx.png == goldens/p6_inputlock_board.png
|
||||
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0.23 \
|
||||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||||
# Fall segment midpoint — the refilled column caught bunched high under gravity
|
||||
# accel: goldens/p6_anim_fall.png
|
||||
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=0.41 \
|
||||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||||
# Settled board past the timeline — FX fully pruned, the move's final board. The
|
||||
# SAME settled frame backs all three of:
|
||||
# goldens/p6_anim_after.png == goldens/p5_swap_after.png == goldens/p6_fx_after.png
|
||||
env SIMCTL_CHILD_M3TE_FX=3 SIMCTL_CHILD_M3TE_ANIM_TIME=2.0 \
|
||||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||||
```
|
||||
|
||||
`p6_anim_clear` / `p6_fx` / `p6_inputlock_board` are one and the same frame — the
|
||||
timeline's clear segment, the burst/popup FX, and the input-locked in-flight board
|
||||
are all the same committed-move moment — as are the three settled `*_after` goldens.
|
||||
|
||||
The resting-board goldens — `goldens/p4_board.png`, `goldens/p4_hud.png`,
|
||||
`goldens/p9_polish.png`, and `goldens/p5_swap_before.png` (the swap's start pose) —
|
||||
are the seed-1337 board at rest, captured with no move committed:
|
||||
|
||||
```bash
|
||||
# Resting candy board / HUD (no move): p4_board.png, p4_hud.png, p9_polish.png,
|
||||
# p5_swap_before.png
|
||||
SIMCTL_CHILD_M3TE_ANIM_TIME=0 \
|
||||
xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||||
```
|
||||
|
||||
They match `goldens/p6_idle_t0.png` over the board+HUD region (the `t==0` rest
|
||||
invariant, `tests/gem_pose.sx`); only the status-bar clock and the bottom
|
||||
home-indicator chrome vary per grab, never the board or HUD.
|
||||
|
||||
## Audio bank (P10) — final model
|
||||
|
||||
The SFX bank (`audio.sx`) is a purely additive layer over iOS **System Sound
|
||||
Services** (AudioToolbox, reached by `#foreign` FFI exactly as the platform reaches
|
||||
UIKit/Metal). Each cue is loaded once at startup into its own `SystemSoundID`;
|
||||
playback is a single C call. The bank only plays when *every* cue loaded (`loaded`
|
||||
gate), so a partial/failed bank mutes rather than playing a stale id. It never
|
||||
reads or mutates score / board / move state — `board_view` and the frame loop just
|
||||
tell it an event happened.
|
||||
|
||||
### Provenance — the user-provided "Triple Treat SFX" pack
|
||||
|
||||
The nine shipped cues are best-fit selections from the user-supplied **Triple Treat
|
||||
SFX** pack (`Triple_Treat_SFX.zip`, ~30 MB, 280 Unity-style files). The pack itself
|
||||
is **not committed** — only the selected, converted cues live in `assets/audio/`;
|
||||
the source archive is kept outside the repo (in `~/Downloads`). Full per-cue notes
|
||||
are in `assets/audio/LICENSE.txt`. The shipped game never re-synthesizes anything;
|
||||
it only loads the finished WAVs.
|
||||
|
||||
| cue | game event | pack source (under `Triple Treat SFX/`) |
|
||||
|--------------|------------------------------------|------------------------------------------|
|
||||
| `swap.wav` | every committed swipe (subtle) | `Transition SFX/Swipe FX 1-RCM.wav` |
|
||||
| `match.wav` | first clear of a legal move (pop) | `Pop:Bubble SFX/Pop FX 5-RCM.wav` |
|
||||
| `combo1.wav` | cascade round 1 (dullest) | `Match SFX/Match FX 2-RCM.wav` (~1.7 kHz)|
|
||||
| `combo2.wav` | cascade round 2 | `Match SFX/Match FX 4-RCM.wav` (~2.1 kHz)|
|
||||
| `combo3.wav` | cascade round 3 | `Match SFX/Match FX 6-RCM.wav` (~3.2 kHz)|
|
||||
| `combo4.wav` | cascade round 4 | `Match SFX/Match FX 7-RCM.wav` (~4.7 kHz)|
|
||||
| `combo5.wav` | cascade round ≥5 (brightest) | `Match SFX/Match FX 3-RCM.wav` (~6.8 kHz)|
|
||||
| `win.wav` | level won (stinger) | `Success:Power-Up SFX/Power Up FX 1-RCM.wav` |
|
||||
| `lose.wav` | level lost (stinger) | `Fail SFX/Fail FX 2-RCM.wav` |
|
||||
|
||||
The Match-FX set does not cleanly pitch-ascend, so `combo1..5` are ordered by
|
||||
ascending **spectral brightness** (centroid 1.68 < 2.09 < 3.18 < 4.70 < 6.77 kHz)
|
||||
so a deeper cascade reads as more exciting.
|
||||
|
||||
### Per-round ascending cascade
|
||||
|
||||
The cascade plays **one ascending cue per cascade round** — round 1 → `combo1`,
|
||||
round 2 → `combo2`, … clamped at `combo5` — *not* a single end-of-move sound. Each
|
||||
cue fires on the move's animation timeline, edge-triggered as that round's clear
|
||||
begins: the frame loop (`main.sx`) diffs `cascade_rounds_started(elapsed, rounds)`
|
||||
against `BoardAnim.cascade_fired` and plays the next combo cue per newly-cleared
|
||||
round (`sfx_cascade`). The depth→index clamp (`cascade_cue_index`: depth ≤ 1 → 0,
|
||||
depth ≥ 5 → 4) is pure + headless, snapshot-tested by `tests/cascade_cue.sx` and
|
||||
`tests/cascade_rounds.sx`. A single match (one round) plays only the `match` pop —
|
||||
no combo cue. The `swap` cue plays for any committed gesture (legal or the reverted
|
||||
ping-back); a legal move adds the `match` pop on its first clearing round; the
|
||||
`win`/`lose` stinger fires once, edge-triggered, as the banner comes up.
|
||||
|
||||
### Format / level spec
|
||||
|
||||
Every cue is delivered in exactly the form System Sound Services loads directly:
|
||||
**WAVE / mono / 44100 Hz / signed-16-bit PCM**. The pack sources are 24-bit /
|
||||
48 kHz / stereo; each was down-mixed to mono, trimmed to its punchy window, eased
|
||||
in with a short fade, rounded out with a cosine fade-out tail, and **peak-normalized
|
||||
to a gentle −15 dBFS** (the user rejected aggressive SFX twice). The candy character
|
||||
of the pack is preserved — the cues are not re-synthesized.
|
||||
|
||||
### Capturing the cue ordering
|
||||
|
||||
Every play is `NSLog`'d, so a playthrough's cue order is readable from the device
|
||||
log. The deep cascade (`M3TE_FX=11`, depth-5 on seed 1337) must be launched **live**
|
||||
(no `M3TE_ANIM_TIME` pin) so the timeline advances and the per-round cues fire in
|
||||
sequence:
|
||||
|
||||
```bash
|
||||
# Deep cascade, live — fires combo1..combo5 one per round on the timeline:
|
||||
SIMCTL_CHILD_M3TE_FX=11 xcrun simctl launch --terminate-running-process booted co.swipelab.m3te
|
||||
xcrun simctl spawn booted log show --last 20s \
|
||||
--predicate 'eventMessage CONTAINS "[sx] audio"' --style compact
|
||||
# Win / lose stingers (also live so the banner edge-triggers the cue). Each pinned
|
||||
# launch passes --terminate-running-process: the M3TE_* pins are read only at
|
||||
# startup, so relaunching a still-running copy reuses its PID and the new pin is
|
||||
# ignored — without it the lose launch reuses the win process and only `cue win`
|
||||
# ever prints. A changed PID between the two launches confirms each pin took:
|
||||
SIMCTL_CHILD_M3TE_TARGET=0 xcrun simctl launch --terminate-running-process booted co.swipelab.m3te # cue win
|
||||
SIMCTL_CHILD_M3TE_MOVE_LIMIT=0 xcrun simctl launch --terminate-running-process booted co.swipelab.m3te # cue lose
|
||||
```
|
||||
|
||||
## Asset regeneration
|
||||
|
||||
The shipped game only loads finished assets; the steps below rebuild them and are
|
||||
never run at play time. Refresh any affected `goldens/` after an art change.
|
||||
|
||||
### Audio (the SFX bank)
|
||||
|
||||
The shipped cues are **not synthesized** — each is a real **Triple Treat SFX** pack
|
||||
clip selected per game event (per-cue source in `assets/audio/LICENSE.txt`). To
|
||||
re-derive a cue from the pack, take its source file and apply the same DSP the
|
||||
shipped bank used: down-mix to mono, trim to the punchy onset window (≤ ~600 ms),
|
||||
ease in with a short fade, round out with a cosine fade-out tail, peak-normalize to
|
||||
a gentle **−15 dBFS**, then re-wrap to the canonical container with `afconvert`
|
||||
(`afconvert -f WAVE -d LEI16@44100 -c 1 in.wav out.wav` → WAVE / `LEI16` / 44100 /
|
||||
mono). The combo ladder is five real **Match FX** cues ordered by ascending spectral
|
||||
brightness (P10.9, `combo1`→`combo5`); the cascade plays one cue per round on the
|
||||
animation timeline (P10.10). Verify the result read-only with `tools/measure_pitch.py`
|
||||
(it never writes a WAV) — it prints each clip's dominant frequency, and `clear.wav`'s
|
||||
~784 Hz CC0 reference is the documented baseline. The 30 MB pack and its `.meta` /
|
||||
`__MACOSX` cruft are not committed.
|
||||
|
||||
> There is intentionally **no procedural-synthesis regeneration script**: the cues
|
||||
> are the curated pack clips, so the original P10.1 note-frequency synthesizer
|
||||
> (`tools/synth_audio.py`) was removed — running it would have clobbered the curated
|
||||
> WAVs with synthetic audio.
|
||||
|
||||
### Image art
|
||||
|
||||
Real art is produced with codex's built-in **`imagegen`** tool through `codex exec`,
|
||||
then `sips`-normalized to each asset's exact dimensions and source format:
|
||||
|
||||
| asset | dims (px) | format | role |
|
||||
|-----------------------------|-----------|--------|-----------------------------------|
|
||||
| `assets/board/background.png` | 863×1822 | PNG | full-view candy gradient backdrop |
|
||||
| `assets/board/cell.png` | 128×128 | PNG | one grid cell tile |
|
||||
| `assets/gems/gems.png` | 768×128 | PNG | 6 gem columns (a gem's UV column = its index) |
|
||||
| `assets/fx/particle.png` | 256×256 | PNG | soft match-burst sprite (tinted per gem) |
|
||||
|
||||
```bash
|
||||
# 1. Generate (codex's imagegen tool, driven non-interactively):
|
||||
codex exec "use the imagegen tool to render <prompt for the asset>"
|
||||
# 2. Normalize to the exact per-asset dims + format, e.g. the gem sheet:
|
||||
sips -z 128 768 --setProperty format png <generated>.png --out assets/gems/gems.png
|
||||
```
|
||||
|
||||
After any art change, re-capture the affected goldens with the deterministic hooks
|
||||
above (`M3TE_ANIM_TIME` / `M3TE_SELECT` / `M3TE_FX` / `M3TE_BADSWAP` / `M3TE_TARGET` /
|
||||
`M3TE_MOVE_LIMIT` / `M3TE_RESTART` / `M3TE_FPS`) and state per golden whether it was refreshed,
|
||||
left unchanged, or removed.
|
||||
|
||||
0
assets/.gitkeep
Normal file
78
assets/audio/LICENSE.txt
Normal 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
BIN
assets/audio/combo1.wav
Normal file
BIN
assets/audio/combo2.wav
Normal file
BIN
assets/audio/combo3.wav
Normal file
BIN
assets/audio/combo4.wav
Normal file
BIN
assets/audio/combo5.wav
Normal file
BIN
assets/audio/lose.wav
Normal file
BIN
assets/audio/match.wav
Normal file
BIN
assets/audio/swap.wav
Normal file
BIN
assets/audio/win.wav
Normal file
BIN
assets/board/background.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
assets/board/cell.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
93
assets/fonts/OFL.txt
Normal 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
BIN
assets/fx/particle.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
assets/gems/gems.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
174
audio.sx
Normal 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
@@ -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 Fisher–Yates 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 {
|
||||
// Fisher–Yates 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
BIN
goldens/p11_combo_deep.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
goldens/p16_badswap.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
goldens/p16_swap.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
goldens/p17_fall.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
goldens/p17_land.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
goldens/p17_stagger.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
goldens/p18_pop.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
goldens/p18_stagger.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
goldens/p20_fps.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
goldens/p4_board.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
goldens/p4_hud.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
goldens/p5_swap_after.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
goldens/p5_swap_before.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
goldens/p6_anim_after.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
goldens/p6_anim_clear.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
goldens/p6_anim_fall.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
goldens/p6_anim_swap.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
goldens/p6_fx.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
goldens/p6_fx_after.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
goldens/p6_fx_match.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
goldens/p6_idle_mid.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
goldens/p6_idle_t0.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
goldens/p6_inputlock_board.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
goldens/p6_select.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
goldens/p7_lose.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
goldens/p7_restart.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
goldens/p7_win.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
goldens/p9_polish.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
486
main.sx
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
1
tests/expected/anim_plan.exit
Normal file
@@ -0,0 +1 @@
|
||||
0
|
||||
20
tests/expected/anim_plan.stdout
Normal 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
|
||||
1
tests/expected/arith.exit
Normal file
@@ -0,0 +1 @@
|
||||
0
|
||||
1
tests/expected/arith.stdout
Normal file
@@ -0,0 +1 @@
|
||||
ok: arithmetic
|
||||
1
tests/expected/banner_layout.exit
Normal file
@@ -0,0 +1 @@
|
||||
0
|
||||
9
tests/expected/banner_layout.stdout
Normal 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
|
||||
1
tests/expected/board_init.exit
Normal file
@@ -0,0 +1 @@
|
||||
0
|
||||
9
tests/expected/board_init.stdout
Normal file
@@ -0,0 +1,9 @@
|
||||
RRPPOGRG
|
||||
PGPOPRRO
|
||||
YYBBYRYB
|
||||
GBYYRGGP
|
||||
OGBRRORY
|
||||
BYRRPRBG
|
||||
YOYYROBB
|
||||
OROBPPRB
|
||||
ok: board_init no-match invariant holds
|
||||
1
tests/expected/cascade.exit
Normal file
@@ -0,0 +1 @@
|
||||
0
|
||||
30
tests/expected/cascade.stdout
Normal 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
|
||||
1
tests/expected/cascade_cue.exit
Normal file
@@ -0,0 +1 @@
|
||||
0
|
||||
12
tests/expected/cascade_cue.stdout
Normal 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
|
||||
1
tests/expected/cascade_rounds.exit
Normal file
@@ -0,0 +1 @@
|
||||
0
|
||||
29
tests/expected/cascade_rounds.stdout
Normal 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
|
||||
1
tests/expected/clear.exit
Normal file
@@ -0,0 +1 @@
|
||||
0
|
||||
116
tests/expected/clear.stdout
Normal 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
|
||||
1
tests/expected/collapse.exit
Normal file
@@ -0,0 +1 @@
|
||||
0
|
||||
78
tests/expected/collapse.stdout
Normal 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
|
||||
1
tests/expected/combo.exit
Normal file
@@ -0,0 +1 @@
|
||||
0
|
||||
41
tests/expected/combo.stdout
Normal 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
|
||||
1
tests/expected/easing.exit
Normal file
@@ -0,0 +1 @@
|
||||
0
|
||||
17
tests/expected/easing.stdout
Normal 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
|
||||
1
tests/expected/fx_combo.exit
Normal file
@@ -0,0 +1 @@
|
||||
0
|
||||
12
tests/expected/fx_combo.stdout
Normal 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
|
||||
1
tests/expected/gem_pose.exit
Normal file
@@ -0,0 +1 @@
|
||||
0
|
||||
15
tests/expected/gem_pose.stdout
Normal 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
|
||||
1
tests/expected/hit_test.exit
Normal file
@@ -0,0 +1 @@
|
||||
0
|
||||
5
tests/expected/hit_test.stdout
Normal 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
|
||||
1
tests/expected/level.exit
Normal file
@@ -0,0 +1 @@
|
||||
0
|
||||