feat: pudding monsters waves 1-3, mahjong match FX, background art

Pudding Monsters:
- Rewrite level bank to 75 levels across 5 named chapters with a teach →
  drill → mix curriculum progression
- Implement 8 mechanics: sleepers, ice, slime trails, hypno goos, springs,
  tunnels, buttons+bricks, powerlifters+crates
- Split scoring into stars (target coverage) and crowns (par solves)
- Introduce planFlick/applyPlan so scene animation never disagrees with
  engine state
- Add mutable board layers (state.board) with generic cloning/hashing
- Level select: chapter rows, mechanic pips, tooltips, chapter-complete panels
- Monster type rendering: green/hypno/lifter colors, sleeper shut-eyes+Z

Mahjong Match:
- Add MahjongFx.js: shatter, burst, flash, ring, floatText, recoil,
  freed-tile chimes

Bloxorz:
- Add background images for menu and levels

Infrastructure:
- docs/puddingmonsters-mechanics-plan.md: living mechanics build-out plan
- tools/verifyPuddingMonsters.js: 1295 regression checks
- genPuddingMonsters.js: curriculum generator with load-bearing gates
- Add playSoundEx() for pitched sound cues
- Update asset manifest for all three games
This commit is contained in:
Brian Fertig 2026-07-26 01:27:36 -06:00
parent 4813c17cce
commit cb3c152d10
23 changed files with 8859 additions and 1925 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,523 @@
# Jell-o Monsters (Pudding Monsters) — mechanics build-out plan
Living plan doc. Survives context clears: **read this file first**, pick the next unchecked
item, update the checkboxes and the "Status" line as work lands.
**Status:** **ALL WAVES COMPLETE (2026-07-25).** Eight mechanics, a 75-level curriculum in 5 named
chapters, stars and crowns as separate awards, **1295 checks green**. What is left is optional
polish (§3.4 achievements) and the parked mechanics in §2.5.
**Not browser-verified at any point** — Brian playtests.
---
## Where things stand
Files that make up the game:
| File | Role |
|---|---|
| `src/games/puddingmonsters/PuddingMonstersLogic.js` | Pure model + BFS solver. No Phaser. |
| `src/games/puddingmonsters/PuddingMonstersGame.js` | Phaser scene: level select + board + HUD. |
| `tools/verifyPuddingMonsters.js` | Regression harness, **1295 checks**. |
| `tools/genPuddingMonsters.js` | Curriculum generator → `data/puddingmonsters.json`. |
| `data/puddingmonsters.json` | **75 levels in 5 chapters**, plus a `chapters` block. |
| `assets/images/jello-items.png` | 528×132 sheet = **4 frames @132px**: `0` spikes, `1-3` wall variants. |
| `src/data/assetManifest.js:125` | Lazy-load entry (`sheet('jello-items', …, 132, 132)`). |
`tools/verifyPuddingMonsters.js` is the regression harness — run it after every change. Each new
mechanic changes the solver, and without it a subtly wrong `par` is indistinguishable from a right
one.
Implemented mechanics: slide-until-blocked, stick/merge on contact, **open edges** (leaving the
board is fatal), walls, spikes (our invention — not in the original), 3 target squares judged at
the final configuration, par medal — plus **sleepers, ice, slime trails, hypno goos, springs,
tunnels, buttons+bricks and powerlifters+crates**. Level schema:
`{level, cols, rows, walls, spikes, targets, monsters, par}` with optional `ice`, `slime`,
`sleepers`, `green`, `hypno`, `springs`, `tunnels`, `bricks`, `buttons`, `bricksDown`, `crates`,
`powerlifters`, `tip`, `element`.
Scoring is the original's: **stars** = how many of the 3 yellow squares the final blob covers,
**crown** = solved in par or fewer. They are independent awards.
Still missing from the original: tunnel **cloning**, magnets, splitters, conveyor belts and the
mustache monster — all parked for want of documented behaviour, see §2.5 — and the achievement
list (§3.4).
---
## Recommendation on sequencing (asked for explicitly)
**Yes — complete Wave 1 and Wave 2 mechanics before overhauling the level set, and treat the
level curriculum as a distinct Wave 3.** But do not build two waves of engine work with nothing
playable: each wave ends with a small hand-authored **sandbox tier** (56 levels per new element)
appended to the existing bank, purely so the mechanic can be played and judged. That is throwaway
content, explicitly not the final curriculum.
Reasoning:
1. **Progression design is a function of the complete element set.** A difficulty curve that
introduces sleeping monsters at level 9 and ice at level 18 is worthless once springs, tunnels
and powerlifters need slots too. Designing the ramp before the elements exist means designing
it twice, and the second pass is the expensive one (hand-authored teaching levels, par bands,
chapter boundaries).
2. **Levels are cheap; the curve is not.** `genPuddingMonsters.js` fills the whole 40-level bank
in seconds. Regenerating after each wave costs nothing. Re-deciding the pedagogy costs a
session each time.
3. **Wave 0's state-key refactor invalidates assumptions, not data.** Existing levels contain no
new elements so their `par` stays correct, but every generator tier definition gets rewritten
in Wave 3 anyway.
The real risk of deferring is finding out late that a mechanic is unfun or that the generator
can't produce good levels with it. The per-wave sandbox tier is the mitigation — it makes each
mechanic playable within its own wave, without committing to where it sits in the final game.
---
## Wave 0 — prerequisites — ✅ DONE 2026-07-25
The current solver assumes the board is immutable: `stateKey()`
(`PuddingMonstersLogic.js:173`) hashes **only blob positions**, because walls/spikes/targets never
change. Slime trails, shattered ice, toggled bricks, awake flags, pushed crates and clone counts
are all mutable board state. If any of them lands without entering the state key, BFS will treat
two different positions as identical, return a too-short "optimal" path, and silently write wrong
`par` values into the bank — which then corrupts the star medal for every affected level.
- [x] `state.board` holds every mutable layer. A layer may be a Set of cell keys, a Map, an array
or a scalar; `boardKey()` / `cloneBoard()` handle all four **generically**, so adding a layer
needs no changes to hashing or cloning. Immutable `walls`/`spikes` still share by reference.
- [x] `stateKey()` folds the board in (`blobs!board`). Empty layers contribute nothing, so a level
with no elements hashes exactly as before — that is why the existing bank is unaffected.
- [x] `cloneState()` deep-copies board layers (verified: mutating a child's layer does not write
through to its parent — the exact bug that would corrupt a BFS search).
- [x] `computeSlide()` rewritten as one ordered per-step walk with the resolution order documented
inline as numbered steps: **1 BLOCK → 2 ENTER → 3 DEATH → 4 STOP-ON → 5 REDIRECT**. Steps 4
and 5 are empty hooks with comments naming the wave that fills them. Note the BLOCK/STOP-ON
distinction: walls stop a blob *before* the cell, slime stops it *after* entering.
- [x] `computeSlide()` now also returns `offset` (final displacement) and, under `{ trace: true }`,
`path` (offset after each step). **Use `offset`, not `dir * maxSteps`** — once springs and
tunnels redirect mid-slide those stop being the same thing. `trace` is what Wave 1 needs to
lay slime and shatter ice along the walk; `slide()` has the comment marking where.
- [x] `tools/verifyPuddingMonsters.js` written (house style, `check()` + exit 1). **246 checks**:
bank schema, all 40 levels re-solved with par matched and the optimal path replayed to a
3-star win, engine primitives, solver minimality (brute-force check that no shorter path
exists), and a whole section on board-layer / asleep-flag state identity.
- [x] Baseline green before any mechanic: **246 passed, 0 failed**.
Also landed while in here:
- [x] **`asleep` flag plumbed through the state model** (Wave 1.1's foundation, since it is a
state-*identity* concern): levels may declare `sleepers: [[x,y]]`, `newState` flags those
blobs, `mergeBlobs` wakes a blob unless *every* part was asleep, `stateKey` distinguishes
asleep from awake, `cloneState` preserves it. **Not yet gated** — a sleeper can still be
flicked; that is Wave 1.1's remaining work (`legalMoves` + scene + generator).
- [x] Deleted the dead legacy star-collection machinery (`pickUpStars`, `state.collected`,
`starsCollected`, `state.stars`, `level.stars`) — unused since targets replaced collectibles,
and it cost a `Set` allocation on every `cloneState`, i.e. on every node the solver expands.
**Behaviour-preservation proof (re-run this after any future engine refactor):**
```
node tools/verifyPuddingMonsters.js # 246 passed, 0 failed
node tools/genPuddingMonsters.js 1592594996 /tmp/regen.json
# levels array is byte-identical to data/puddingmonsters.json (same 462 attempts)
```
Still holds after Wave 1: the sandbox tier draws from the RNG **after** the main bank, so
`node tools/genPuddingMonsters.js 1592594996` still reproduces levels 140 byte-identically (the
verifier and this check were both re-run after Wave 1). Anything that changes the main `TIERS` or
the draw order inside them breaks it — re-baseline then.
**Solver perf baseline:** all 40 levels solved in **208 ms** total, worst level 50 ms
(`maxStates: 200000`). Compare against this when slime and tunnels expand the state space.
---
## Wave 1 — the chapter 12 mechanics — ✅ DONE 2026-07-25
**Landed:** all four mechanics in the engine, the scene, the generator and the verifier.
- Level JSON gained five optional fields: `ice: [[x,y]]` (board cells) and `sleepers` / `green` /
`hypno` / `slime` (the first three list *monster start cells*, typing those monsters). Plus
`tip` (one-line lesson shown above the board) and `element` (which mechanic the level teaches).
- **`planFlick()` / `applyPlan()`** replaced the scene's use of `computeSlide` + `slide`. One plan
drives both the animation and the move, so what the player watches can never disagree with what
the engine did — a hypno flick returns one `part` per hive member, and the verifier asserts
`plan + apply === slide()` for every direction.
- Scene: green and hypno monsters get their own body colour, sleepers get shut eyes and a "z",
hypno eyes get spiral rings; slime is a bridged ooze layer under the monsters; ice is a frosted
slab that pops when struck. **Ice art is optional** — frame 4 of `jello-items.png` is used if
it exists, otherwise the slab is drawn procedurally, so nothing is blocked on art.
- Undo/Reset resync ice and slime (`syncElements()`), since both are now mutable board state.
- Level-select grid now scales its tile size and column count with the bank (it overflowed the
buttons at 60 levels; it will hold ~125 for Wave 3), and element levels show a coloured pip.
- **Bank: 60 levels.** Levels 140 are byte-identical to before; 4160 are the sandbox tier.
**Deviation from the plan, deliberate:** the sandbox tier is *generated and gated* rather than
hand-authored. `genPuddingMonsters.js` grew `SANDBOX_TIERS` (5 levels per mechanic) and an
`isLoadBearing()` gate — a candidate is rejected unless removing its element changes the par or
makes the level unsolvable. That is the Wave 3 anti-decorative gate, built early, and it produced
better levels than hand-authoring would have. `verifyPuddingMonsters.js` re-checks load-bearing
independently rather than trusting generation. Hand-authored *teaching* levels are still Wave 3
work — a gated random level proves the mechanic matters, it does not teach it gently.
**Verification:** `node tools/verifyPuddingMonsters.js`**605 passed, 0 failed** (was 246).
Solver: all 60 levels in 237 ms, worst 45 ms, so the in-game Hint stays instant.
Rules decided while building (all covered by checks):
- A flick that **only shatters ice** — the blob is already touching it — is a legal move that
travels 0 cells. Without that, ice you are up against could never be broken.
- Ice shatters even when a wall blocks the same step, and a wide blob striking two ice blocks
breaks both.
- A green blob is **never stopped by its own fresh trail** (slime is laid when the move is
applied, so the walk only sees pre-existing slime) but **is** stopped by older slime.
- The hive resolves **furthest-along-the-direction first**, so a leader clears the way and the
follower can close up and stick. Merging happens once, after every member has moved.
- A sleeping hypno does not move with the hive until something wakes it.
- If any hive member would die, the whole flick is fatal and `legalMoves` excludes it.
### Original plan (kept for reference)
Chosen because they fit the rigid-blob model, need no change to monster count or board topology,
and cover the original's first two chapters. Order within the wave is deliberate: cheapest and
most independent first.
**Where the seams are** (all in `src/games/puddingmonsters/PuddingMonstersLogic.js`, added in
Wave 0 — search for the wave number in comments):
| Seam | What plugs in |
|---|---|
| `initialBoard(level)` | Seed a layer from the level def (`board.ice = new Set(...)`). Two example lines are already in the comment. |
| `computeSlide()` step 1 BLOCK | Solid things: intact ice, raised bricks, crates. |
| `computeSlide()` step 4 STOP-ON | Slime — halts the blob *after* it enters the cell. |
| `computeSlide()` step 5 REDIRECT | Springs, tunnels (Wave 2). `dir`/`dx`/`dy`/offset are loop-local for this. |
| `slide()` | Re-walk with `{ trace: true }` and apply board mutations along the path. |
| `legalMoves()` | Skip blobs that can't be flicked (asleep). |
| `stateKey()` / `cloneBoard()` | Nothing to do — new layers are picked up automatically. |
Every new element needs (a) a rule in the walk, (b) a generator tier that can place it, (c) unit
checks in `verifyPuddingMonsters.js` §3, and (d) scene rendering.
### 1.1 Sleeping monsters ("Sleepyheads", original 1-6 / 1-9)
Asleep monsters **cannot be flicked**. They wake only when another blob slides into them and
merges. A merged blob is awake if any member is awake.
- [x] Logic: level JSON carries a parallel `sleepers: [[x,y]]` array (chosen over a per-monster
tuple flag — it keeps the existing `monsters` shape and the generator simpler). *Done in
Wave 0.*
- [x] Merge wakes the whole blob; a blob is asleep only if every part was. Wake state is in
`stateKey`. *Done in Wave 0.*
- [x] `legalMoves()` skips asleep blobs; `slide()` rejects them. **← the actual gate, still open**
- [x] Decide: can a sleeping blob be *pushed* by an arriving blob, or does the arriving blob simply
stop against it and stick? (Original: it sticks and the merged blob is awake — so no push.)
- [x] Scene: closed-eye + "Z" rendering, wake animation (eyes pop open, quick jiggle — reuse
`wobbleBlob`). No sheet art needed, faces are drawn.
- [x] Solver check: a level where the only solution requires waking in a specific order.
**Art:** none (procedural).
### 1.2 Ice blocks ("Breaking the Ice", 1-18 / 1-19 / 1-25)
Acts exactly like a wall, but **shatters when struck once** and the cell becomes empty. The
striking blob still stops at it that move (it does not continue through on the same flick).
- [x] Logic: `ice:Set` in mutable board state; blocks a slide like a wall, then is removed on
impact. Enters `stateKey`.
- [x] Decide and document: does a blob that stops against ice merge with anything behind the ice?
(No — ice is gone but the blob has already come to rest.)
- [x] Scene: shatter animation, cracked intermediate frame optional.
**Art:** frame `4` = intact ice block, frame `5` = shattered/cracked (optional but nice).
### 1.3 Slime trails (green monsters, "Why So Green?", 1-12)
A green monster leaves slime on **every cell it passes through**, including its start cell. Any
monster (green or not) that slides onto a slimed cell **stops there**. Slime is permanent.
- [x] Logic: `slime:Set` in board state; laid down during a green blob's slide; a slimed cell is a
*stop-on* tile, not a *block-before* tile (the blob enters the cell, then halts) — this is
the key rules distinction versus walls.
- [x] A merged blob containing a green monster is green (matches the original: the trail follows
the whole blob).
- [x] Slime enters `stateKey`. Branching factor rises but BFS depth stays small; watch
`SOLVE_MAX_STATES` (currently 80 000 in the generator).
- [x] Scene: slime tile rendering under monsters (depth between floor and monsters), green
monster body colour + drip detail.
**Art:** frame `6` = slime tile (or draw procedurally — decide when rendering; procedural is
probably better since it needs to tile seamlessly across a trail).
### 1.4 Hypno / synchronous goos ("Running Together" achievement)
All monsters of the hypno type share a hive mind: flicking any one of them slides **every** hypno
blob in that direction, in the same move.
- [x] Logic: group id on monsters; `slide()` resolves a group move. **Resolution order matters**
they can block each other. Resolve iteratively: repeatedly slide whichever group member can
still move furthest until no member moves, or define a strict order (furthest-along-the-
direction first) and document the choice.
- [x] Death: if *any* member dies (edge/spike), the run fails.
- [x] Merging a hypno with a normal monster — decide whether the merged blob stays hypno
(original suggests yes) and document.
- [x] Scene: spiral/hypno eyes, simultaneous slide animation.
- [x] Solver: no key change (positions already capture it), but `legalMoves` must emit one move
per group, not per blob.
**Art:** none (procedural eyes).
### Wave 1 exit criteria
- [x] `verifyPuddingMonsters.js` covers all four mechanics with unit-level rule checks.
- [x] Sandbox tier: ~6 hand-authored levels per mechanic appended to the bank (throwaway).
- [x] Played in-browser by Brian; each mechanic reads clearly without explanation.
---
## Wave 2 — the chapter 35 mechanics — ✅ DONE 2026-07-25
**Landed:** springs, tunnels, buttons+bricks, powerlifters+crates — in the engine, the scene, the
generator and the verifier. Bank grew to 80 levels (levels 160 byte-identical; 6180 are the new
sandbox blocks). **1228 checks green**; solver does all 80 levels in 464 ms, worst 82 ms.
The engine walk was restructured so that **the walk is the single authority for board mutation**.
It now works copy-on-write: `computeSlide` clones the board only if the flick actually changes it,
returns it as `board`, and `applyPlan` installs it. Levels using no elements still allocate
nothing, which is why levels 140 are untouched and the solver stayed fast.
Rules decided while building (the original's behaviour is undocumented for most of these — these
are our rules, and every one is covered by checks):
- **Springs** are omnidirectional bumpers: a blob that would enter one is thrown back the way it
came and *keeps sliding*, so the route bends. Each spring bounces **once per flick** — hit a
spent one and it just blocks. That is what guarantees the walk terminates: two springs facing
each other cannot ping-pong forever. Chosen over a fixed-distance launch because it needs no
rotation, reads instantly, and cannot deadlock.
- **Tunnels** are linked pairs. A blob whose cell enters a mouth is translated so that cell lands
on the partner, then keeps sliding — so a whole multi-cell blob goes through rigidly, which is
exactly what the original's "Tunnel Master — move a 3-eyed monster through a tunnel" describes.
One teleport per flick; a teleport that would land on anything solid simply does not happen.
- **Buttons** toggle their brick group when a monster slides over them, once per button per flick,
and it happens *mid-slide* — a button can raise a brick in time to stop the very slide that
pressed it. A brick that would rise through a monster or crate **jams** instead of crushing it.
- **Powerlifters** push crates; everyone else is stopped dead by them. No crate trains — a crate
backed by anything solid will not budge. A crate shoved past the rim is gone (and the pusher
usually follows it off, which is the player's problem).
- `maxSteps` now means **cells actually travelled**, not loop iterations — a spring bounce costs an
iteration but no step. This was wrong when springs landed and is now asserted.
- `computeSlide` returns `path` (per-step offsets, with a flag marking teleports) and
`crateMoves`. The scene animates each blob **along the polyline** and slides pushed crates with
it; a teleport is snapped, never tweened through. A check asserts every path ends exactly on its
part's offset, because the scene no longer slices the path by step count.
**Scope call — tunnel cloning was NOT implemented.** Sources conflict: the "Tunnel Master"
achievement describes moving a blob *through* a tunnel (teleport), while a review describes tunnels
*reproducing* monsters, and the "Reproduction" achievement counts clones. Teleport is the
better-attested reading, it keeps the monster count fixed, and it keeps the BFS state space
bounded — cloning makes the number of blobs variable, which is the one change that could make the
generator's `solve()` blow up. Cloning is parked in §2.5 with magnets and splitters until there is
footage to copy.
### Original plan (kept for reference)
These change monster count or board topology, so they need more solver care.
### 2.1 Buttons + retractable bricks
Sliding a monster over a round button raises/lowers a set of brick walls.
- [x] Logic: `buttons:[{cell, group}]`, `bricks:[{cell, group, up:bool}]`. Triggered when a blob
**passes over or stops on** the button (decide: original appears to trigger on pass-over).
- [x] Toggle state enters `stateKey`.
- [x] Edge case: bricks rising under a resting blob — forbid at generation time rather than
inventing a rule.
**Art:** frame `7` button up, `8` button down, `9` brick raised, `10` brick lowered/flush.
### 2.2 Springs / bumpers ("Spring Pusher": *bump 150 monsters with a spring*)
A spring bumps a monster back instead of stopping it.
- [x] Nail the rule from gameplay footage before coding: does it reverse the blob, launch it a
fixed distance, or launch it until the next obstacle? Written source doesn't say.
- [x] Logic: redirect inside the per-step scan built in Wave 0. Must be loop-safe (spring facing
spring) — cap total steps.
**Art:** frame `11` spring (4 rotations, or one frame rotated in-scene — prefer rotation).
### 2.3 Tunnels + cloning ("Tunnel Master", "Reproduction": *create 30 monster clones on a single level*)
Monsters entering a tunnel emerge elsewhere, and tunnels **clone** monsters — so the monster
count is not fixed.
- [x] This is the biggest solver change in the plan: the win condition ("all blobs merged into
one") is currently `state.blobs.length === 1`, which still holds, but the state space grows
and `stateKey` must handle a variable number of blobs (it already does — it sorts blobs).
- [x] Watch for infinite clone loops; cap clones per level and per state.
- [x] Decide whether clones can themselves clone.
**Art:** frame `12` tunnel mouth (rotatable), `13` tunnel exit if visually distinct.
### 2.4 Powerlifters + crates ("Powerlifter": *move the same object 10 times*)
Powerlifter monsters **push objects** instead of sticking to them.
- [x] Logic: `crates` as movable non-monster occupants. A powerlifter blob pushes a crate along
its slide; crate stops on wall/blob/edge — decide whether a pushed crate can fall off the
table (probably yes, and probably not fatal).
- [x] Crate positions enter `stateKey`.
**Art:** frame `14` crate.
### 2.5 Deferred / needs research
Not scheduled — insufficient information to specify, revisit with gameplay footage:
- **Tunnel cloning** ("Reproduction": *create 30 monster clones on a single level*) — tunnels are
implemented as teleports (see the Wave 2 notes). Cloning would make the number of blobs variable,
which is the one change that can blow up the BFS state space the generator depends on. Needs
footage plus a hard per-level clone cap before it is worth attempting.
- **Magnet monster** ("Positive Attraction": *use a magnet to attract a plus-shaped monster*).
- **Splitters** ("Separation": *split a 6-eyed monster into 3 equal parts*) — something cuts blobs
apart; lowest confidence item on the whole list.
- **Conveyor belts** and the **mustache monster** — named by the fan wiki, no documented behaviour.
### Wave 2 exit criteria
- [x] Verify covers every element; generator produces valid levels using each in isolation
(5 per mechanic, all independently re-checked as load-bearing).
- [x] Sandbox tier extended — 80 levels total.
- [x] Solver performance still fine: **464 ms for all 80 levels, worst 82 ms** (was 237 ms / 45 ms
at 60 levels). `SOLVE_MAX_STATES` untouched. In-game Hint stays instant.
---
## Wave 3 — level overhaul, progression, and meta — ✅ DONE 2026-07-25
**Landed:** the whole bank was thrown away and regenerated as a curriculum. `genPuddingMonsters.js`
was rewritten around `CHAPTERS`: five named chapters of fifteen levels, each built from **blocks**
`basic` (no elements), `teach` (one gentle introduction), `drill` (reinforcement), `mix`
(combinations). Every level is named. Regenerate with
`node tools/genPuddingMonsters.js 20260725` (~4 minutes; the gates are expensive).
| Ch | Name | Levels | Introduces |
|---|---|---|---|
| 1 | Cold Storage | 115 | sleepers, ice |
| 2 | Kitchen Counter | 1630 | slime, hypno |
| 3 | The Pantry | 3145 | springs, tunnels |
| 4 | Dining Room | 4660 | buttons+bricks, powerlifters+crates |
| 5 | Midnight Feast | 6175 | nothing new — every combination |
Three gates decide whether a candidate level survives, and the verifier re-checks all three rather
than trusting generation:
1. **Par band** — per block.
2. **Load-bearing** — strip an element and the par must change or the level must break. On a
combination level *every* element is checked on its own (57 levels, 88 element checks).
3. **Gentle** (teaching levels only) — **no opening flick may be fatal**. You cannot lose a lesson
on move one.
That third gate could not be met by random walls — on an open-edged table nearly every layout has
some suicidal flick, and 400 000 candidates produced zero survivors. So teaching levels are
*repaired* into gentleness: monsters are placed off the rim, then `addGuardRails()` drops a wall on
the last cell of any ray that would run a monster off the table. The gate then passes by
construction, and is still verified independently.
**Crown vs stars are now separate**, as in the original: `stars = targetsCovered(final)` (03) and
`crown = moves <= par`. The HUD shows both live ("★ 2/3 squares" and "crown: on track / lost"), the
solved overlay shows both with a reason for whichever you missed, and the level select shows stars
under the number with a crown in the corner. Simulated perfect play scores **225/225 stars and
75/75 crowns**, so the two awards are always simultaneously achievable.
**Level select rebuilt** as one row per chapter: heading, blurb, per-chapter cleared count, and 15
tiles. Tiles carry a mechanic pip, a crown, a green NEW badge on teaching levels, and a hover
tooltip with the level name, par and lesson (via the shared `src/ui/Tooltip.js`). Finishing a
chapter's last level shows a **chapter-complete panel** with its star and crown tally, and the
Next button reads "Start The Pantry".
**localStorage keys are namespaced `pm3-`** (`pm3-stars-<n>`, `pm3-crown-<n>`). Wave 3 renumbered
every level, so the old `pm-stars-<n>` medals described puzzles that no longer exist; Reset
Progress clears both generations.
### Deviations, deliberate
- **75 levels, not the original's 125.** Five chapters of fifteen keeps every level earning its
place and the level select readable on one screen. The curriculum is data — raising a block's
`count` and adding names is the only work needed to grow it.
- **Achievements (§3.4) not built.** They need a subsystem this game does not have, and none of
the rest depends on them. Left open below.
- Per-chapter completion screens landed as part of the solved overlay rather than as a separate
scene.
### Original plan (kept for reference)
Only start once Waves 12 are playable. This is where the game becomes *fun* rather than
*featureful*.
### 3.1 Chapter structure
Original: **5 chapters × 25 levels = 125**, named — *Escape the Fridge*, *Room Invaders*,
*The Neighborhood*, *City Tour*, and a fifth. We have 40 flat levels.
- [x] Decide our chapter count and size (125 is a lot to hand-tune; 5 × 15 = 75 is defensible).
- [x] Chapter metadata in `data/puddingmonsters.json`; level-select grouped by chapter with
per-chapter unlock.
- [x] Level names. The original names every level (*"Sleepyheads"*, *"Breaking the Ice"*,
*"Third Slime's a Charm"*) — that is how it telegraphs a new mechanic. Cheap, high value.
### 3.2 Teaching curve
- [x] Each new element gets a hand-authored **teaching level**: minimal board, one idea, hard to
fail. Generated levels alone will never teach.
- [x] Then 35 generated levels reinforcing it, then combinations with earlier elements.
- [x] Rewrite `TIERS` in `genPuddingMonsters.js` as a per-chapter element budget (which elements
are legal, how many, par band) rather than the current flat 5-tier ramp.
- [x] Generator must **reject levels where an element is decorative***built early in Wave 1*:
`isLoadBearing(lvl, element, par)` in `genPuddingMonsters.js` strips the element, re-solves,
and requires the par to change or the level to become unsolvable. `verifyPuddingMonsters.js`
re-checks it independently for every level tagged with an `element`. Reuse both for the
Wave 2 elements; they take the element name, so no new machinery is needed.
### 3.3 Crown vs stars (scoring fidelity)
The original awards these **separately**: 3 stars for the final blob covering the 3 star tiles,
and a **crown** for solving in the minimum number of moves ("Pudding King — crown 100 levels").
We currently fold both into `min(coverage, parMedal)` in `liveStars()`
(`PuddingMonstersGame.js:472`), so a player who covers all three tiles but takes an extra move
just sees a lower star count and can't tell which half they missed.
- [x] Split: `stars = targetsCovered(final)` (03), `crown = moves <= par`.
- [x] HUD shows both; level-select shows stars + crown badge.
- [x] localStorage key change (`pm-stars-<level>` → add `pm-crown-<level>`); handle existing
saved progress gracefully.
### 3.4 Nice-to-have meta — STILL OPEN
- [ ] Achievements (the original has 26; shape-based ones like *Donut Monster* / *Hot Dog
Monster* are charming and nearly free once blob shape is inspectable).
- [x] Per-chapter completion screens — done, folded into the solved overlay.
---
## Art requests (sheet is append-only)
`assets/images/jello-items.png`, 132×132 frames, currently 4 frames (528×132). Appending frame
`N` means widening the sheet to `132 × (N+1)`; existing frames must not move.
| Frame | Wave | Content |
|---|---|---|
| 4 | 1 | Ice block, intact (frosted, slightly translucent). **Optional**`makeIce()` uses frame 4 the moment it exists, and draws a procedural slab until then. Nothing is blocked. |
| ~~5~~ | 1 | Cracked ice — dropped; the shatter is a pop-and-fade tween, no second frame needed. |
| ~~6~~ | 1 | Slime tile — dropped; drawn procedurally so a trail bridges between cells. |
| ~~7 / 8~~ | 2 | Button — drawn procedurally (a red pad inlaid in the floor). Not needed. |
| ~~9 / 10~~ | 2 | Bricks — drawn procedurally, raised vs flush-in-floor. Not needed. |
| ~~11~~ | 2 | Spring — drawn procedurally (omnidirectional, so no rotation needed). |
| ~~12 / 13~~ | 2 | Tunnel mouths — drawn procedurally, colour-coded per pair. |
| ~~14~~ | 2 | Crate — drawn procedurally (banded wooden box). |
**Every Wave 2 element ended up procedural**, so frame 4 (ice) is the only art request still open —
and even that is optional, since `makeIce()` falls back to a drawn slab. Monster types (sleeping,
green, hypno, powerlifter) are drawn in code too: type shows in body colour plus a face detail
(shut eyes and a "z", spiral eye-rings, heavy brows), so none of them depends on hue alone.
---
## Sources for original-game behaviour
- [Wikipedia](https://en.wikipedia.org/wiki/Pudding_Monsters) — core slide/fuse mechanic.
- [Jay is Games review](https://jayisgames.com/review/pudding-monsters.php) — ice blocks, buttons
and bricks, star-tile rule.
- [PS4Blog Switch review](https://www.ps4blog.net/2022/03/nintendo-switch-pudding-monsters-review/)
— sleeping monsters, green slime trails.
- [LadiesGamers review](https://ladiesgamers.com/pudding-monsters-review/) — tunnels/cloning,
star tiles, 125 levels.
- [Pocket Gamer chapter 1 guide](https://www.pocketgamer.com/pudding-monsters/pudding-monsters-3-star-walkthrough-guides-for-chapter-1-escape-the-fridge/)
— level names, which mechanic arrives when.
- [VGTimes achievement list](https://vgtimes.com/games/pudding-monsters/achievements-and-trophies/)
— the definitive element roster (springs, magnets, tunnels, powerlifters, synchronous monsters,
separation, crowns).

View File

@ -77,6 +77,11 @@ export const MANIFEST = {
image('catan-pirate', 'assets/images/catan-pirate.png'), image('catan-pirate', 'assets/images/catan-pirate.png'),
], ],
risk: [image('risk-board', 'assets/images/risk-board.png')], risk: [image('risk-board', 'assets/images/risk-board.png')],
bloxorz: [
image('bloxorz-bg-menu', 'assets/images/bloxorz/background-menu.png'),
...Array.from({ length: 6 }, (_, i) =>
image(`bloxorz-bg-${i + 1}`, `assets/images/bloxorz/background-${i + 1}.png`)),
],
jewelquest: [image('bg-jewelquest-battle', 'assets/images/background-jewelquest.png')], jewelquest: [image('bg-jewelquest-battle', 'assets/images/background-jewelquest.png')],
bejeweled: [image('bg-bejeweled', 'assets/images/background-bejeweledblitz.png')], bejeweled: [image('bg-bejeweled', 'assets/images/background-bejeweledblitz.png')],
dominion: [ dominion: [
@ -122,9 +127,18 @@ export const MANIFEST = {
// Card art: frame 0 = Chance, frame 1 = Community Chest, at 200×300. // Card art: frame 0 = Chance, frame 1 = Community Chest, at 200×300.
sheet('monopoly-cards', 'assets/images/monopoly-cards.png', 200, 300), sheet('monopoly-cards', 'assets/images/monopoly-cards.png', 200, 300),
], ],
puddingmonsters: [sheet('jello-items', 'assets/images/jello-items.png', 132, 132)], puddingmonsters: [
sheet('jello-items', 'assets/images/jello-items.png', 132, 132),
image('bg-puddingmonsters-menu', 'assets/images/background-jello.png'),
],
mahjong: MAHJONG_TILES, mahjong: MAHJONG_TILES,
mahjongmatch: MAHJONG_TILES, mahjongmatch: [
...MAHJONG_TILES,
// Carries its own title, so the layout-select screen draws no heading.
image('bg-mahjongmatch-menu', 'assets/images/background-mahjongmatch.png'),
// In-play backdrops — one is picked at random per game.
...['01', '02', '03'].map((n) => image(`bg-mm-${n}`, `assets/images/background-mm-${n}.png`)),
],
spireclimb: [ spireclimb: [
image('spireclimb-act1', 'assets/images/spireclimb-act1.png'), image('spireclimb-act1', 'assets/images/spireclimb-act1.png'),
image('spireclimb-act2', 'assets/images/spireclimb-act2.png'), image('spireclimb-act2', 'assets/images/spireclimb-act2.png'),

View File

@ -160,13 +160,10 @@ export default class BloxorzGame extends Phaser.Scene {
this.clearLayer(); this.clearLayer();
const cx = GAME_WIDTH / 2; const cx = GAME_WIDTH / 2;
const title = this.add.text(cx, 84, 'BLOXORZ', { if (this.textures.exists('bloxorz-bg-menu')) {
fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex, this.layer.add(this.add.image(cx, GAME_HEIGHT / 2, 'bloxorz-bg-menu')
}).setOrigin(0.5); .setDisplaySize(GAME_WIDTH, GAME_HEIGHT));
const sub = this.add.text(cx, 138, 'Roll the block and drop it standing into the hole. Clear each level to unlock the next.', { }
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
}).setOrigin(0.5);
this.layer.add([title, sub]);
if (!this.bank.length) { if (!this.bank.length) {
const msg = this.add.text(cx, 520, 'No levels found.\nRun: node tools/genBloxorz.js', { const msg = this.add.text(cx, 520, 'No levels found.\nRun: node tools/genBloxorz.js', {
@ -179,7 +176,7 @@ export default class BloxorzGame extends Phaser.Scene {
} }
const nextLevel = Math.min(this.levelsCompleted + 1, this.bank.length); const nextLevel = Math.min(this.levelsCompleted + 1, this.bank.length);
const prog = this.add.text(cx, 182, `Completed ${this.levelsCompleted} / ${this.bank.length}`, { const prog = this.add.text(cx, 300, `Completed ${this.levelsCompleted} / ${this.bank.length}`, {
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex, fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex,
}).setOrigin(0.5); }).setOrigin(0.5);
this.layer.add(prog); this.layer.add(prog);
@ -189,7 +186,7 @@ export default class BloxorzGame extends Phaser.Scene {
const GAP = 14; const GAP = 14;
const gridW = COLS * SIZE + (COLS - 1) * GAP; const gridW = COLS * SIZE + (COLS - 1) * GAP;
const left = cx - gridW / 2 + SIZE / 2; const left = cx - gridW / 2 + SIZE / 2;
const top = 268; const top = 390;
this.bank.forEach((p, i) => { this.bank.forEach((p, i) => {
const col = i % COLS; const col = i % COLS;
@ -284,6 +281,7 @@ export default class BloxorzGame extends Phaser.Scene {
this.clearLayer(); this.clearLayer();
this._computeProjection(); this._computeProjection();
this._drawBackground(level);
this.drawHud(); this.drawHud();
this.tileGfx = this.add.graphics().setDepth(D.board); this.tileGfx = this.add.graphics().setDepth(D.board);
this.blockGfx = this.add.graphics().setDepth(D.block); this.blockGfx = this.add.graphics().setDepth(D.block);
@ -312,6 +310,16 @@ export default class BloxorzGame extends Phaser.Scene {
}; };
} }
// Levels are grouped into blocks of 6, each block using one background image
// (bloxorz-bg-1 for levels 1-6, bloxorz-bg-2 for 7-12, ...).
_drawBackground(level) {
const key = `bloxorz-bg-${Math.ceil(level / 6)}`;
if (!this.textures.exists(key)) return;
const bg = this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, key)
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(-1);
this.layer.add(bg);
}
project3(p) { project3(p) {
const { sx, sy } = project(p, this.proj); const { sx, sy } = project(p, this.proj);
return [sx, sy]; return [sx, sy];

View File

@ -0,0 +1,284 @@
// Match feedback for Mahjong Match — shattering tiles, sparks, flashes and
// floating callouts. Mirrors the shape of SlotsFx / TAFx: a small class that
// owns its generated textures and cleans up after itself, so the scene stays
// about the game.
//
// Every effect is drawn from procedurally generated textures (the Bejeweled
// pattern) — no new art. The one size-dependent texture is `mj-face`, the
// ivory tile front the shards are cut from; tile size varies per layout, so
// the scene calls `ensureFaceTexture()` whenever it recomputes the board.
import * as Phaser from 'phaser';
const FACE_KEY = 'mj-face';
// Label art is 128×178, centred on the tile face (see MahjongMatchGame).
const LABEL_W = 128;
const LABEL_H = 178;
// Matches the board's FACE / FACE_EDGE so shards look like the tile they came from.
const FACE_FILL = 0xf6efdb;
const FACE_EDGE = 0x8d7c52;
// One dial for the whole effect. Turn things down here after playtesting
// rather than editing the effect bodies.
export const JUICE = {
snapMs: 90, // pair punches together before it breaks
snapScale: 1.16,
snapPull: 0.22, // fraction of the gap each tile travels toward the other
shardLife: 620,
shardSpeed: [110, 300],
shardLift: -170, // initial upward velocity, px/s
shardGravity: 1250, // px/s²
shardSpin: [-5, 5],
maxShards: 120, // hard cap on concurrent shards
sparks: 14,
sparkSpeed: [90, 340],
sparkLife: 520,
stars: 5,
ringScale: 2.6,
ringMs: 380,
flashScale: 1.7,
flashMs: 300,
shakeMs: 90,
shakeAmp: 0.0035,
recoilRadius: 2.2, // in tile widths
recoilPush: 9, // px at the epicentre
recoilMs: 110,
freedPulseMs: 260,
freedStagger: 60,
};
// Suit families get their own spark colour, so a burst reads as "that tile".
const TINTS = {
bamboo: 0x3fbf6f,
circle: 0x4a9be8,
char: 0xd94f4f,
wind: 0x9b7fd4,
dragon: 0xffc94a,
bonus: 0xff7fb0,
};
// `face` is a MahjongLogic face record: { id, group, label, copies }.
export function tintForFace(face) {
if (!face) return TINTS.bonus;
if (face.group === 'flower' || face.group === 'season') return TINTS.bonus;
if (face.id.startsWith('bamboo')) return TINTS.bamboo;
if (face.id.startsWith('circle')) return TINTS.circle;
if (face.id.startsWith('char')) return TINTS.char;
if (face.id.startsWith('wind')) return TINTS.wind;
if (face.id.startsWith('dragon')) return TINTS.dragon;
return TINTS.bonus;
}
export default class MahjongFx {
// `layer` is a container at the FX depth — above the tiles, below the HUD.
constructor(scene, layer) {
this.scene = scene;
this.layer = layer;
this.shards = 0;
this._buildTextures();
}
setLayer(layer) { this.layer = layer; }
_buildTextures() {
const s = this.scene;
if (s.textures.exists('mj-dot')) return;
// Soft radial glow, additive-blended for flashes and halos.
let g = s.add.graphics();
for (let i = 16; i >= 1; i--) {
const t = i / 16;
g.fillStyle(0xffffff, 0.022 + 0.085 * (1 - t));
g.fillCircle(64, 64, 64 * t);
}
g.generateTexture('mj-glow', 128, 128);
g.destroy();
g = s.add.graphics();
g.fillStyle(0xffffff, 1);
g.fillCircle(4, 4, 4);
g.generateTexture('mj-dot', 8, 8);
g.destroy();
// Four-point star for the brighter sparks.
g = s.add.graphics();
g.fillStyle(0xffffff, 1);
g.fillPoints([
{ x: 16, y: 0 }, { x: 19, y: 13 }, { x: 32, y: 16 }, { x: 19, y: 19 },
{ x: 16, y: 32 }, { x: 13, y: 19 }, { x: 0, y: 16 }, { x: 13, y: 13 },
], true);
g.generateTexture('mj-spark', 32, 32);
g.destroy();
g = s.add.graphics();
g.lineStyle(5, 0xffffff, 1);
g.strokeCircle(32, 32, 27);
g.generateTexture('mj-ring', 64, 64);
g.destroy();
}
// The shard source texture has to match the current tile size, which the
// layout picks. Cheap to rebuild — it only changes when the board does.
ensureFaceTexture(tileW, tileH, thick) {
const w = Math.round(tileW);
const h = Math.round(tileH);
if (this._faceW === w && this._faceH === h) return;
const s = this.scene;
if (s.textures.exists(FACE_KEY)) s.textures.remove(FACE_KEY);
const r = Math.max(4, thick);
const g = s.add.graphics();
g.fillStyle(FACE_FILL, 1);
g.fillRoundedRect(0, 0, w, h, r);
g.lineStyle(2, FACE_EDGE, 1);
g.strokeRoundedRect(1, 1, w - 2, h - 2, r);
g.generateTexture(FACE_KEY, w, h);
g.destroy();
this._faceW = w;
this._faceH = h;
}
// Breaks a tile face into four tumbling quadrants.
//
// Each shard is a container holding cropped copies of the face (and the
// label art, when the face has any). The children sit at -quadrantCentre and
// the container at tileCentre + quadrantCentre, so the piece starts exactly
// where it was drawn but rotates about its own centre rather than swinging
// around the middle of the tile.
shatter(x, y, labelKey, labelScale, tint) {
if (!this._faceW) return;
const s = this.scene;
const W = this._faceW;
const H = this._faceH;
for (let qy = 0; qy < 2; qy++) {
for (let qx = 0; qx < 2; qx++) {
if (this.shards >= JUICE.maxShards) return;
const ox = (qx === 0 ? -1 : 1) * W / 4;
const oy = (qy === 0 ? -1 : 1) * H / 4;
const face = s.add.image(-ox, -oy, FACE_KEY)
.setCrop(qx * W / 2, qy * H / 2, W / 2, H / 2);
const parts = [face];
if (labelKey && s.textures.exists(labelKey)) {
const label = s.add.image(-ox, -oy, labelKey)
.setScale(labelScale)
.setCrop(qx * LABEL_W / 2, qy * LABEL_H / 2, LABEL_W / 2, LABEL_H / 2);
parts.push(label);
}
const shard = s.add.container(x + ox, y + oy, parts);
this.layer.add(shard);
this.shards++;
const life = JUICE.shardLife;
const secs = life / 1000;
const dir = Math.sign(ox) || 1;
const vx = dir * Phaser.Math.Between(...JUICE.shardSpeed);
const vy0 = JUICE.shardLift + Phaser.Math.Between(-60, 60);
const sx = shard.x;
const sy = shard.y;
s.tweens.add({
targets: shard,
x: sx + vx * secs,
rotation: Phaser.Math.FloatBetween(...JUICE.shardSpin),
duration: life,
ease: 'Linear',
onUpdate: (tw) => {
const t = tw.progress * secs;
shard.y = sy + vy0 * t + 0.5 * JUICE.shardGravity * t * t;
shard.alpha = tw.progress < 0.6 ? 1 : 1 - (tw.progress - 0.6) / 0.4;
},
onComplete: () => { shard.destroy(true); this.shards--; },
});
}
}
this.burst(x, y, JUICE.sparks, tint);
}
burst(x, y, count = JUICE.sparks, tint = 0xffffff) {
const s = this.scene;
const dots = s.add.particles(x, y, 'mj-dot', {
speed: { min: JUICE.sparkSpeed[0], max: JUICE.sparkSpeed[1] },
lifespan: JUICE.sparkLife,
scale: { start: 1.1, end: 0 },
alpha: { start: 1, end: 0 },
tint: [tint, 0xffffff],
blendMode: 'ADD',
emitting: false,
});
this.layer.add(dots);
dots.explode(count);
const stars = s.add.particles(x, y, 'mj-spark', {
speed: { min: 60, max: 190 },
lifespan: JUICE.sparkLife + 120,
scale: { start: 0.7, end: 0 },
rotate: { start: 0, end: 180 },
alpha: { start: 1, end: 0 },
tint,
blendMode: 'ADD',
emitting: false,
});
this.layer.add(stars);
stars.explode(JUICE.stars);
s.time.delayedCall(JUICE.sparkLife + 220, () => { dots.destroy(); stars.destroy(); });
}
flash(x, y, tint = 0xffffff) {
const img = this.scene.add.image(x, y, 'mj-glow')
.setTint(tint).setBlendMode(Phaser.BlendModes.ADD).setScale(0.3).setAlpha(0.95);
this.layer.add(img);
this.scene.tweens.add({
targets: img,
scale: JUICE.flashScale, alpha: 0,
duration: JUICE.flashMs, ease: 'Quad.easeOut',
onComplete: () => img.destroy(),
});
}
ring(x, y, tint = 0xffffff) {
const img = this.scene.add.image(x, y, 'mj-ring')
.setTint(tint).setBlendMode(Phaser.BlendModes.ADD).setScale(0.2).setAlpha(0.9);
this.layer.add(img);
this.scene.tweens.add({
targets: img,
scale: JUICE.ringScale, alpha: 0,
duration: JUICE.ringMs, ease: 'Cubic.easeOut',
onComplete: () => img.destroy(),
});
}
floatText(x, y, str, color, { size = 34, rise = 70, dur = 800, delay = 0 } = {}) {
const txt = this.scene.add.text(x, y, str, {
fontFamily: 'Righteous', fontSize: `${size}px`, color,
stroke: '#000000', strokeThickness: 5,
}).setOrigin(0.5).setScale(0.4).setAlpha(0);
this.layer.add(txt);
this.scene.tweens.add({
targets: txt, scale: 1, alpha: 1,
duration: 150, delay, ease: 'Back.easeOut',
});
this.scene.tweens.add({
targets: txt, y: y - rise, alpha: 0,
duration: dur, delay: delay + 180, ease: 'Quad.easeOut',
onComplete: () => txt.destroy(),
});
}
}

View File

@ -2,12 +2,13 @@ import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js'; import { Button } from '../../ui/Button.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js'; import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { playSound, SFX } from '../../ui/Sounds.js'; import { playSound, playSoundEx, SFX } from '../../ui/Sounds.js';
import { api } from '../../services/api.js'; import { api } from '../../services/api.js';
import { import {
LAYOUTS, LAYOUT_ORDER, layoutBounds, LAYOUTS, LAYOUT_ORDER, layoutBounds,
newGame, isFree, canMatch, removePair, findMoves, reshuffleRemaining, newGame, isFree, canMatch, removePair, findMoves, reshuffleRemaining,
} from './MahjongLogic.js'; } from './MahjongLogic.js';
import MahjongFx, { JUICE, tintForFace } from './MahjongFx.js';
// Deep-green felt with ivory tiles — the classic mahjong table look. // Deep-green felt with ivory tiles — the classic mahjong table look.
const FELT = 0x0e2a1c; const FELT = 0x0e2a1c;
@ -27,7 +28,29 @@ const PREVIEW_Z = [0x9c8f6e, 0xb3a47e, 0xcab98e, 0xe0cf9f, 0xf6e6b0];
const LABEL_W = 128; const LABEL_W = 128;
const LABEL_H = 178; const LABEL_H = 178;
const D = { bg: -2, ui: 30 }; // A Phaser Container renders its children in insertion order and ignores their
// depth, so the board / FX / HUD are separate ROOT containers — only the scene
// display list actually depth-sorts.
const D = { bg: -2, board: 0, fx: 20, ui: 30, overlay: 40 };
// The match cue: a clack, then one of the gem tones with a little rate jitter
// so 72 matches in a row don't sound like a metronome.
const GEM_KEYS = [SFX.GEM_MATCH_1, SFX.GEM_MATCH_2, SFX.GEM_MATCH_4, SFX.GEM_MATCH_5];
// Ascending chime for tiles the match unblocked — roughly a major scale, so a
// big opening arpeggiates instead of just getting louder.
const CHIME_RATES = [1, 1.122, 1.26, 1.335, 1.5, 1.682, 1.888, 2];
const MAX_FREED_CUES = CHIME_RATES.length;
const CALLOUTS = ['MATCH!', 'NICE!', 'SNAP!', 'CLEAN!', 'GOT IT!', 'SHARP!'];
const FREED_GOLD = 0xffd15c;
// In-play backdrops, one picked per game. The art is detailed and two of the
// three are brightly lit, so a dark scrim sits over it to keep the ivory tiles
// and the HUD readable — drop SCRIM_ALPHA toward 0 for more of the artwork.
const BG_KEYS = ['bg-mm-01', 'bg-mm-02', 'bg-mm-03'];
const SCRIM = 0x06140d;
const SCRIM_ALPHA = 0.45;
export default class MahjongMatchGame extends Phaser.Scene { export default class MahjongMatchGame extends Phaser.Scene {
constructor() { super('MahjongMatchGame'); } constructor() { super('MahjongMatchGame'); }
@ -48,6 +71,12 @@ export default class MahjongMatchGame extends Phaser.Scene {
this.tilesText = null; this.tilesText = null;
this.movesText = null; this.movesText = null;
this.timerText = null; this.timerText = null;
this.fxLayer = null;
this.hudLayer = null;
this.labelScale = 1;
// Bumped whenever the tiles are rebuilt. Deferred match animations carry a
// copy and bail if it moved on, so they can't act on a different board.
this.boardEpoch = 0;
} }
create() { create() {
@ -57,7 +86,8 @@ export default class MahjongMatchGame extends Phaser.Scene {
} catch (_) { /* optional */ } } catch (_) { /* optional */ }
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, FELT).setDepth(D.bg); this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, FELT).setDepth(D.bg);
this.layer = this.add.container(0, 0); this.layer = this.add.container(0, 0).setDepth(D.board);
this.fx = new MahjongFx(this, this.layer);
this.showLayoutSelect(); this.showLayoutSelect();
} }
@ -66,6 +96,9 @@ export default class MahjongMatchGame extends Phaser.Scene {
if (this.hintTimer) { this.hintTimer.remove(false); this.hintTimer = null; } if (this.hintTimer) { this.hintTimer.remove(false); this.hintTimer = null; }
if (this.overlay) { this.overlay.destroy(true); this.overlay = null; } if (this.overlay) { this.overlay.destroy(true); this.overlay = null; }
this.layer.removeAll(true); this.layer.removeAll(true);
if (this.fxLayer) { this.fxLayer.destroy(true); this.fxLayer = null; }
if (this.hudLayer) { this.hudLayer.destroy(true); this.hudLayer = null; }
this.fx.setLayer(this.layer);
this.tileObjs = []; this.tileObjs = [];
this.selected = null; this.selected = null;
this.hintPair = null; this.hintPair = null;
@ -82,18 +115,19 @@ export default class MahjongMatchGame extends Phaser.Scene {
this.clearLayer(); this.clearLayer();
const cx = GAME_WIDTH / 2; const cx = GAME_WIDTH / 2;
const title = this.add.text(cx, 100, 'MAHJONG MATCH', { // The artwork carries the title, so nothing is drawn over the top third.
fontFamily: 'Righteous', fontSize: '78px', color: COLORS.goldHex, if (this.textures.exists('bg-mahjongmatch-menu')) {
}).setOrigin(0.5); this.layer.add(this.add.image(cx, GAME_HEIGHT / 2, 'bg-mahjongmatch-menu')
const sub = this.add.text(cx, 178, 'Clear the board by matching free pairs. A tile is free when nothing rests on it and a side is open.', { .setDisplaySize(GAME_WIDTH, GAME_HEIGHT));
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.mutedHex, }
}).setOrigin(0.5);
this.layer.add([title, sub]);
// Cards sit below the wordmark and its brush flourish (which ends ~y=390)
// and clear the Back button at the bottom, so they're a little shorter
// than the two rows would otherwise allow.
const CARD_W = 480; const CARD_W = 480;
const CARD_H = 310; const CARD_H = 250;
const GAP_X = 60; const GAP_X = 60;
const ROW_Y = [420, 770]; const ROW_Y = [525, 825];
const totalW = 3 * CARD_W + 2 * GAP_X; const totalW = 3 * CARD_W + 2 * GAP_X;
const left = cx - totalW / 2 + CARD_W / 2; const left = cx - totalW / 2 + CARD_W / 2;
@ -107,17 +141,17 @@ export default class MahjongMatchGame extends Phaser.Scene {
this.layer.add(card); this.layer.add(card);
const preview = this.add.graphics(); const preview = this.add.graphics();
this._drawLayoutPreview(preview, layout, x, y - 60, 320, 150); this._drawLayoutPreview(preview, layout, x, y - 46, 300, 118);
this.layer.add(preview); this.layer.add(preview);
const name = this.add.text(x, y + 52, layout.name, { const name = this.add.text(x, y + 40, layout.name, {
fontFamily: 'Righteous', fontSize: '38px', color: COLORS.textHex, fontFamily: 'Righteous', fontSize: '36px', color: COLORS.textHex,
}).setOrigin(0.5); }).setOrigin(0.5);
const info = this.add.text(x, y + 96, `${layout.positions.length} tiles · ${layout.desc}`, { const info = this.add.text(x, y + 74, `${layout.positions.length} tiles · ${layout.desc}`, {
fontFamily: '"Julius Sans One"', fontSize: '21px', color: COLORS.mutedHex, fontFamily: '"Julius Sans One"', fontSize: '21px', color: COLORS.mutedHex,
}).setOrigin(0.5); }).setOrigin(0.5);
const best = this._bestFor(key); const best = this._bestFor(key);
const bestLbl = this.add.text(x, y + 130, best !== null ? `Best: ${this._fmtTime(best)}` : 'Not cleared yet', { const bestLbl = this.add.text(x, y + 102, best !== null ? `Best: ${this._fmtTime(best)}` : 'Not cleared yet', {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.goldHex, fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.goldHex,
}).setOrigin(0.5); }).setOrigin(0.5);
this.layer.add([name, info, bestLbl]); this.layer.add([name, info, bestLbl]);
@ -176,12 +210,23 @@ export default class MahjongMatchGame extends Phaser.Scene {
this.overlayUp = false; this.overlayUp = false;
this.clearLayer(); this.clearLayer();
this._drawBackground();
this._computeLayout(); this._computeLayout();
this._buildTiles(); this._buildTiles();
this._drawHud(); this._drawHud();
this._startTimer(); this._startTimer();
} }
// Added to `this.layer` before the tiles, so insertion order keeps it behind
// them. If the texture hasn't loaded, the felt rectangle underneath shows.
_drawBackground() {
const key = BG_KEYS[Math.floor(Math.random() * BG_KEYS.length)];
if (!this.textures.exists(key)) return;
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
this.layer.add(this.add.image(cx, cy, key).setDisplaySize(GAME_WIDTH, GAME_HEIGHT));
this.layer.add(this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, SCRIM, SCRIM_ALPHA));
}
// Fit the layout into the area right of the button strip. // Fit the layout into the area right of the button strip.
_computeLayout() { _computeLayout() {
const { spanX, spanY, maxZ } = layoutBounds(this.g.positions); const { spanX, spanY, maxZ } = layoutBounds(this.g.positions);
@ -201,6 +246,9 @@ export default class MahjongMatchGame extends Phaser.Scene {
const visH = spanY * this.halfH + (maxZ + 1) * this.thick; const visH = spanY * this.halfH + (maxZ + 1) * this.thick;
this.originX = LEFT + (availW - visW) / 2 + maxZ * this.thick; this.originX = LEFT + (availW - visW) / 2 + maxZ * this.thick;
this.originY = TOP + (availH - visH) / 2 + maxZ * this.thick; this.originY = TOP + (availH - visH) / 2 + maxZ * this.thick;
// Shards are cut from a face texture sized to these tiles.
this.fx.ensureFaceTexture(this.tileW, this.tileH, this.thick);
} }
_tileScreenPos(i) { _tileScreenPos(i) {
@ -213,6 +261,14 @@ export default class MahjongMatchGame extends Phaser.Scene {
_buildTiles() { _buildTiles() {
this.tileObjs = []; this.tileObjs = [];
this.boardEpoch++;
this.labelScale = Math.min((this.tileW * 0.80) / LABEL_W, (this.tileH * 0.82) / LABEL_H);
// Root-level, above the board container — NOT a child of it.
if (this.fxLayer) this.fxLayer.destroy(true);
this.fxLayer = this.add.container(0, 0).setDepth(D.fx);
this.fx.setLayer(this.fxLayer);
const order = this.g.positions const order = this.g.positions
.map((_, i) => i) .map((_, i) => i)
.filter((i) => this.g.alive[i]) .filter((i) => this.g.alive[i])
@ -230,8 +286,7 @@ export default class MahjongMatchGame extends Phaser.Scene {
let label = null; let label = null;
const face = this.g.faces[i]; const face = this.g.faces[i];
if (face.label && this.textures.exists(face.label)) { if (face.label && this.textures.exists(face.label)) {
const scale = Math.min((this.tileW * 0.80) / LABEL_W, (this.tileH * 0.82) / LABEL_H); label = this.add.image(0, 0, face.label).setScale(this.labelScale);
label = this.add.image(0, 0, face.label).setScale(scale);
container.add(label); container.add(label);
} }
@ -297,24 +352,28 @@ export default class MahjongMatchGame extends Phaser.Scene {
const cx = GAME_WIDTH / 2; const cx = GAME_WIDTH / 2;
const layout = LAYOUTS[this.layoutKey]; const layout = LAYOUTS[this.layoutKey];
// Root-level, above both the board and the effects.
if (this.hudLayer) this.hudLayer.destroy(true);
this.hudLayer = this.add.container(0, 0).setDepth(D.ui);
const title = this.add.text(40, 64, 'MAHJONG MATCH', { const title = this.add.text(40, 64, 'MAHJONG MATCH', {
fontFamily: 'Righteous', fontSize: '40px', color: COLORS.goldHex, fontFamily: 'Righteous', fontSize: '40px', color: COLORS.goldHex,
}).setOrigin(0, 0.5).setDepth(D.ui); }).setOrigin(0, 0.5);
const diff = this.add.text(40, 106, layout.name, { const diff = this.add.text(40, 106, layout.name, {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.mutedHex, fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.mutedHex,
}).setOrigin(0, 0.5).setDepth(D.ui); }).setOrigin(0, 0.5);
this.layer.add([title, diff]); this.hudLayer.add([title, diff]);
this.tilesText = this.add.text(cx, 56, '', { this.tilesText = this.add.text(cx, 56, '', {
fontFamily: 'Righteous', fontSize: '38px', color: COLORS.textHex, fontFamily: 'Righteous', fontSize: '38px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.ui); }).setOrigin(0.5);
this.movesText = this.add.text(cx, 100, '', { this.movesText = this.add.text(cx, 100, '', {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.mutedHex, fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.ui); }).setOrigin(0.5);
this.timerText = this.add.text(GAME_WIDTH - 50, 155, '', { this.timerText = this.add.text(GAME_WIDTH - 50, 155, '', {
fontFamily: 'Righteous', fontSize: '34px', color: COLORS.textHex, fontFamily: 'Righteous', fontSize: '34px', color: COLORS.textHex,
}).setOrigin(1, 0.5).setDepth(D.ui); }).setOrigin(1, 0.5);
this.layer.add([this.tilesText, this.movesText, this.timerText]); this.hudLayer.add([this.tilesText, this.movesText, this.timerText]);
const stripCx = 150; const stripCx = 150;
const BTN_W = 220, BTN_H = 58, BTN_GAP = 16; const BTN_W = 220, BTN_H = 58, BTN_GAP = 16;
@ -331,7 +390,7 @@ export default class MahjongMatchGame extends Phaser.Scene {
btnY += BTN_H + BTN_GAP; btnY += BTN_H + BTN_GAP;
const layouts = new Button(this, stripCx, btnY, 'Layouts', () => this.showLayoutSelect(), const layouts = new Button(this, stripCx, btnY, 'Layouts', () => this.showLayoutSelect(),
{ width: BTN_W, height: BTN_H, fontSize: 22, variant: 'ghost' }); { width: BTN_W, height: BTN_H, fontSize: 22, variant: 'ghost' });
this.layer.add([hint, shuffleB, restart, layouts]); this.hudLayer.add([hint, shuffleB, restart, layouts]);
this._updateHud(); this._updateHud();
} }
@ -372,16 +431,23 @@ export default class MahjongMatchGame extends Phaser.Scene {
const a = this.selected; const a = this.selected;
this.selected = null; this.selected = null;
this._clearHint(); this._clearHint();
const face = this.g.faces[a];
const freeBefore = this._freeSet();
if (!removePair(this.g, a, i)) return; if (!removePair(this.g, a, i)) return;
playSound(this, SFX.CARD_PLACE); this._celebrateMatch(a, i, face, freeBefore);
this._animateRemoval(a);
this._animateRemoval(i);
this._refreshTiles(); this._refreshTiles();
this._updateHud(); this._updateHud();
if (this.g.state === 'won') {
this._onWin(); // Let the last pair actually break before the overlay covers it. The
} else if (findMoves(this.g).length === 0) { // clock stops now, though, so the delay can't cost the player a second.
this._onStuck(true); const won = this.g.state === 'won';
if (won || findMoves(this.g).length === 0) {
if (won && this.timerEvent) { this.timerEvent.remove(false); this.timerEvent = null; }
const epoch = this.boardEpoch;
this.time.delayedCall(JUICE.snapMs + 300, () => {
if (this.view !== 'play' || this.boardEpoch !== epoch) return;
if (won) this._onWin(); else this._onStuck(true);
});
} }
return; return;
} }
@ -403,14 +469,122 @@ export default class MahjongMatchGame extends Phaser.Scene {
}); });
} }
_animateRemoval(i) { // ── Match feedback ────────────────────────────────────────────────────────────
_freeSet() {
const set = new Set();
for (let i = 0; i < this.g.positions.length; i++) {
if (this.g.alive[i] && isFree(this.g, i)) set.add(i);
}
return set;
}
// The pair punches together, then breaks. `face` and `freeBefore` are
// captured before the removal, since both are gone from the model after it.
_celebrateMatch(a, b, face, freeBefore) {
const pa = this._tileScreenPos(a);
const pb = this._tileScreenPos(b);
const mx = (pa.x + pb.x) / 2;
const my = (pa.y + pb.y) / 2;
const tint = tintForFace(face);
playSound(this, SFX.UI_PLACE);
for (const [idx, p] of [[a, pa], [b, pb]]) {
const o = this.tileObjs[idx];
if (!o) continue;
this.tweens.add({
targets: o.container,
x: p.x + (mx - p.x) * JUICE.snapPull,
y: p.y + (my - p.y) * JUICE.snapPull,
scaleX: JUICE.snapScale, scaleY: JUICE.snapScale,
duration: JUICE.snapMs, ease: 'Back.easeOut',
});
}
const epoch = this.boardEpoch;
this.time.delayedCall(JUICE.snapMs, () => this._breakPair(a, b, mx, my, tint, freeBefore, epoch));
}
_breakPair(a, b, mx, my, tint, freeBefore, epoch) {
// The board was rebuilt (new game or shuffle) while the snap was playing —
// these indices now mean different tiles.
if (this.view !== 'play' || !this.fxLayer || this.boardEpoch !== epoch) return;
this._shatterTile(a, tint);
this._shatterTile(b, tint);
this.fx.flash(mx, my, tint);
this.fx.ring(mx, my, tint);
this.cameras.main.shake(JUICE.shakeMs, JUICE.shakeAmp);
const gem = GEM_KEYS[Math.floor(Math.random() * GEM_KEYS.length)];
playSoundEx(this, gem, { rate: Phaser.Math.FloatBetween(0.94, 1.08), volume: 0.9 });
playSoundEx(this, SFX.GEM_DROP, { volume: 0.35 });
this._recoilFrom(mx, my);
const freed = [];
for (let i = 0; i < this.g.positions.length; i++) {
if (this.g.alive[i] && isFree(this.g, i) && !freeBefore.has(i)) freed.push(i);
}
this._celebrateFreed(freed, epoch);
const word = CALLOUTS[Math.floor(Math.random() * CALLOUTS.length)];
this.fx.floatText(mx, my - 20, word, COLORS.goldHex, { size: 40 });
if (freed.length) {
this.fx.floatText(mx, my + 26, `+${freed.length} OPENED`, '#7fe3a0',
{ size: 28, delay: 140, rise: 56 });
}
}
// Hides the tile and hands its face to the shard effect. The container is
// kept (invisible) so `_rebuildTiles` / `clearLayer` still own its lifetime.
_shatterTile(i, tint) {
const o = this.tileObjs[i]; const o = this.tileObjs[i];
if (!o) return; if (!o) return;
this.tweens.add({ const { x, y } = o.container;
targets: o.container, o.container.setVisible(false);
alpha: 0, y: o.container.y - 26, scaleX: 0.7, scaleY: 0.7, this.fx.shatter(x, y, this.g.faces[i].label, this.labelScale, tint);
duration: 230, ease: 'Quad.easeIn', }
onComplete: () => o.container.setVisible(false),
// Nearby tiles get shoved away from the break and spring back — the board
// reacting is what sells the hit as physical.
_recoilFrom(x, y) {
const radius = JUICE.recoilRadius * this.tileW;
for (let i = 0; i < this.g.positions.length; i++) {
if (!this.g.alive[i]) continue;
const o = this.tileObjs[i];
if (!o) continue;
const base = this._tileScreenPos(i);
const dx = base.x - x, dy = base.y - y;
const d = Math.hypot(dx, dy);
if (d < 1 || d > radius) continue;
const push = JUICE.recoilPush * (1 - d / radius);
this.tweens.add({
targets: o.container,
x: base.x + (dx / d) * push,
y: base.y + (dy / d) * push,
duration: JUICE.recoilMs, yoyo: true, ease: 'Quad.easeOut',
onComplete: () => { if (o.container.active) o.container.setPosition(base.x, base.y); },
});
}
}
// Tiles the match just unblocked flash gold and ring out an ascending chime.
_celebrateFreed(freed, epoch) {
freed.slice(0, MAX_FREED_CUES).forEach((idx, n) => {
this.time.delayedCall(n * JUICE.freedStagger, () => {
const o = this.tileObjs[idx];
if (this.view !== 'play' || this.boardEpoch !== epoch || !o || !this.g.alive[idx]) return;
const p = this._tileScreenPos(idx);
this.fx.ring(p.x, p.y, FREED_GOLD);
this.tweens.add({
targets: o.container, scaleX: 1.1, scaleY: 1.1,
duration: JUICE.freedPulseMs / 2, yoyo: true, ease: 'Quad.easeOut',
onComplete: () => { if (o.container.active) o.container.setScale(1); },
});
playSoundEx(this, SFX.UI_CHIME, { rate: CHIME_RATES[n], volume: 0.5 });
});
}); });
} }
@ -455,7 +629,7 @@ export default class MahjongMatchGame extends Phaser.Scene {
_makeOverlayPanel(strokeColor) { _makeOverlayPanel(strokeColor) {
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
this.overlay = this.add.container(0, 0); this.overlay = this.add.container(0, 0).setDepth(D.overlay);
this.overlayUp = true; this.overlayUp = true;
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62).setInteractive(); const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62).setInteractive();
@ -471,6 +645,7 @@ export default class MahjongMatchGame extends Phaser.Scene {
_onWin() { _onWin() {
if (this.timerEvent) { this.timerEvent.remove(false); this.timerEvent = null; } if (this.timerEvent) { this.timerEvent.remove(false); this.timerEvent = null; }
playSound(this, SFX.VICTORY_SHORT); playSound(this, SFX.VICTORY_SHORT);
playSoundEx(this, SFX.FIREWORK, { volume: 0.7 });
const lsKey = `mahjongmatch-best-${this.layoutKey}`; const lsKey = `mahjongmatch-best-${this.layoutKey}`;
const prev = this._bestFor(this.layoutKey); const prev = this._bestFor(this.layoutKey);

File diff suppressed because it is too large Load Diff

View File

@ -13,10 +13,51 @@
// SPIKES are equally deadly: if a blob's slide path crosses a spike it dies // SPIKES are equally deadly: if a blob's slide path crosses a spike it dies
// (the run fails and the level must be restarted). // (the run fails and the level must be restarted).
// //
// A blob: { cells: [[x,y,m], ...] } (rigid; moves as a unit). The third // A blob: { cells: [[x,y,m], ...], asleep?, green?, hypno?, lifter? } (rigid;
// element m is the index of the original monster occupying that cell; it rides // moves as a unit). The third element m is the index of the original monster
// along through slides and merges so the renderer can keep each monster's // occupying that cell; it rides along through slides and merges so the renderer
// colour and face inside a merged blob. All rules ignore it. // can keep each monster's colour and face inside a merged blob. The movement
// rules ignore m.
//
// ── MONSTER TYPES ───────────────────────────────────────────────────────────
// asleep — cannot be flicked at all. Wakes when an awake blob slides into it
// and merges. A merged blob is asleep only if EVERY part was.
// green — leaves a slime trail on every cell it travels through. Any blob
// that later slides onto slime stops ON that cell. A merged blob is
// green if ANY part was (the trail follows the whole blob).
// hypno — hive mind: flicking one hypno blob slides EVERY hypno blob the same
// direction, in one move. Merged blob is hypno if ANY part was.
// lifter — powerlifter: PUSHES crates instead of being stopped by them.
// Merged blob is a lifter if ANY part was.
//
// ── BOARD ELEMENTS ──────────────────────────────────────────────────────────
// ice — blocks a slide exactly like a wall, then SHATTERS. The blob still
// comes to rest in front of it. A flick that only shatters ice (the
// blob is already touching it) is a legal move.
// spring — bounces a blob back the way it came instead of stopping it, and
// keeps it sliding. Each spring bounces once per flick; hit a spent
// spring and it just blocks, which is what guarantees a walk ends.
// button — pressing one (sliding a monster over it) toggles its brick group
// brick — raised = solid like a wall, lowered = plain floor. Bricks cannot
// rise through a monster or crate: that toggle jams instead.
// tunnel — linked pair of cells. A blob whose cell enters one mouth is
// teleported so that cell lands on the other, then keeps sliding.
// One teleport per flick; a teleport that would land the blob on
// anything solid simply does not happen.
// crate — solid to everyone except a lifter, who pushes it along. A crate
// pushed off the table is gone (not fatal). No crate trains: a
// crate backed by anything solid cannot be pushed.
//
// ── STATE SHAPE (see docs/puddingmonsters-mechanics-plan.md) ────────────────
// Immutable terrain — `walls`, `spikes`, `springs`, `tunnels`, `buttons`,
// `brickAt`, `targets` — is fixed for a level and shared by reference through
// cloneState(). Everything that CHANGES during play lives in `state.board`:
// `ice`, `slime`, `bricksUp`, `crates`.
//
// EVERY mutable layer MUST be folded into stateKey(). boardKey() does that
// generically, so a new layer is free — but if one ever bypasses it, the BFS
// solver will treat two genuinely different positions as the same one, return a
// short "optimal" path, and write a too-low `par` into the level bank.
export const DIRS = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }; export const DIRS = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] };
export const DIR_LIST = ['up', 'down', 'left', 'right']; export const DIR_LIST = ['up', 'down', 'left', 'right'];
@ -44,7 +85,66 @@ export function repCell(blob) {
(c[1] < best[1] || (c[1] === best[1] && c[0] < best[0])) ? c : best, blob.cells[0]); (c[1] < best[1] || (c[1] === best[1] && c[0] < best[0])) ? c : best, blob.cells[0]);
} }
// Union any blobs that are orthogonally adjacent, transitively, into single blobs. // ── Mutable board layers ─────────────────────────────────────────────────────
// A layer is a Set of cell keys, a Map of cell key -> value, an array, or a
// scalar. Add new ones in initialBoard() and they are automatically cloned and
// hashed; nothing else needs to change.
function layerKey(v) {
if (v instanceof Set) return [...v].sort().join(',');
if (v instanceof Map) return [...v.entries()].map(([k, val]) => `${k}=${val}`).sort().join(',');
if (Array.isArray(v)) return v.slice().sort().join(',');
return String(v);
}
function cloneLayer(v) {
if (v instanceof Set) return new Set(v);
if (v instanceof Map) return new Map(v);
if (Array.isArray(v)) return v.slice();
return v;
}
// Canonical string for the mutable half of the board. Empty layers contribute
// nothing, so a level using no elements hashes exactly as it did before the
// board existed (and costs nothing in the solver's hot loop).
export function boardKey(board) {
let out = '';
for (const name of Object.keys(board ?? {}).sort()) {
const k = layerKey(board[name]);
if (k) out += `${out ? '&' : ''}${name}:${k}`;
}
return out;
}
export function cloneBoard(board) {
const out = {};
for (const name of Object.keys(board ?? {})) out[name] = cloneLayer(board[name]);
return out;
}
// Mutable layers a level starts with.
function initialBoard(level) {
const board = {};
if (level.ice?.length) board.ice = new Set(level.ice.map(([x, y]) => key(x, y)));
if (level.slime?.length) board.slime = new Set(level.slime.map(([x, y]) => key(x, y)));
if (level.crates?.length) board.crates = new Set(level.crates.map(([x, y]) => key(x, y)));
if (level.bricks?.length) {
// Bricks start raised unless the level lowers their group to begin with.
const down = new Set(level.bricksDown ?? []);
const up = new Set();
for (const [, , g] of level.bricks) if (!down.has(g)) up.add(g);
board.bricksUp = up;
}
return board;
}
// ── Merging ──────────────────────────────────────────────────────────────────
// Union any blobs that are orthogonally adjacent, transitively, into single
// blobs. Flags combine by type: asleep is AND (sliding an awake blob into a
// sleeper is what wakes it); green, hypno and lifter are OR (one green monster
// slimes for the whole blob, one hypno puts it on the hive mind, one
// powerlifter lets the whole blob shove crates).
function mergeBlobs(state) { function mergeBlobs(state) {
const occ = new Map(); const occ = new Map();
state.blobs.forEach((b, i) => b.cells.forEach(([x, y]) => occ.set(key(x, y), i))); state.blobs.forEach((b, i) => b.cells.forEach(([x, y]) => occ.set(key(x, y), i)));
@ -65,22 +165,25 @@ function mergeBlobs(state) {
const groups = new Map(); const groups = new Map();
state.blobs.forEach((b, i) => { state.blobs.forEach((b, i) => {
const r = find(i); const r = find(i);
if (!groups.has(r)) groups.set(r, []); if (!groups.has(r)) groups.set(r, { cells: [], asleep: true, green: false, hypno: false, lifter: false });
groups.get(r).push(...b.cells); const g = groups.get(r);
g.cells.push(...b.cells);
if (!b.asleep) g.asleep = false;
if (b.green) g.green = true;
if (b.hypno) g.hypno = true;
if (b.lifter) g.lifter = true;
}); });
state.blobs = [...groups.values()].map((cells) => ({ cells })); state.blobs = [...groups.values()].map(makeBlob);
} }
function pickUpStars(state) { // Blobs carry flags only when set, so a plain monster stays a bare { cells }.
for (const [sx, sy] of state.stars) { function makeBlob({ cells, asleep, green, hypno, lifter }) {
if (!state.collected.has(key(sx, sy)) && occupiedAt(state, sx, sy)) { const b = { cells };
state.collected.add(key(sx, sy)); if (asleep) b.asleep = true;
} if (green) b.green = true;
} if (hypno) b.hypno = true;
} if (lifter) b.lifter = true;
return b;
export function starsCollected(state) {
return state.collected.size;
} }
// How many of the given target cells are currently covered by a monster. // How many of the given target cells are currently covered by a monster.
@ -91,45 +194,365 @@ export function targetsCovered(state, targets) {
return n; return n;
} }
// How far blob `idx` can slide in `dir`, and whether the slide is fatal. // ── Sliding ──────────────────────────────────────────────────────────────────
// Only walls and other monsters stop a slide — the board edge is open, so a
// blob whose path crosses a spike or leaves the board dies. Pure (does not // Walk blob `idx` one direction until it stops or dies. PURE with respect to
// mutate). { maxSteps, deathStep, deathCause } — deathStep>0 means fatal, // `state`: board changes the walk causes (ice shattered, slime laid, bricks
// deathCause is 'spike' | 'edge' | null. // toggled, crates pushed) are written into a COPY, returned as `board`, and only
export function computeSlide(state, idx, dir) { // installed by applyPlan(). Levels with no elements never allocate that copy.
const [dx, dy] = DIRS[dir]; //
// Per step, elements resolve in a fixed priority order:
//
// 1. BLOCK — a cell the blob would enter is solid (wall / another blob /
// intact ice / raised brick / crate it cannot push / spent
// spring): the blob stops BEFORE entering. Ice struck this way
// shatters.
// 2. BOUNCE — else, a fresh spring in the way reverses the direction without
// advancing. Each spring bounces once per flick, which is what
// makes the walk terminate.
// 3. ENTER — pushed crates advance, then the blob advances one cell.
// 4. DEATH — a cell just entered is off the table ('edge') or a spike
// ('spike'): fatal, the walk ends there.
// 5. BUTTONS — buttons under the blob toggle their brick group (once per
// button per flick; a brick that would rise through a monster or
// crate jams instead).
// 6. STOP-ON — a cell just entered is slimed, which halts the blob AFTER
// entering. Note the contrast with BLOCK.
// 7. TUNNEL — a mouth just entered teleports the blob (once per flick), then
// sliding continues; slime is re-checked at the destination.
//
// Returns { maxSteps, deathStep, deathCause, stopReason, offset, hitIce, path,
// board, boardChanged }:
// maxSteps — cells actually travelled (0 = the blob does not move). A
// spring bounce costs a loop iteration but no step, so this can
// be lower than the number of iterations the walk took.
// deathStep — >0 means fatal, at that step; deathCause 'spike' | 'edge'
// stopReason — 'blocked' | 'dead' | 'stop-on'
// offset — [ox, oy] final displacement. ALWAYS use this rather than
// dir*maxSteps: springs and tunnels make them different.
// path — offset after each step as [ox, oy, jump] (jump=1 for a
// teleport, which the scene must snap rather than tween)
// hitIce — ice cells shattered, for the scene's pop animation
export function computeSlide(state, idx, dir, { trace = false } = {}) {
const blob = state.blobs[idx]; const blob = state.blobs[idx];
const base = blob.cells;
const lifter = blob.lifter === true;
const green = blob.green === true;
const wantPath = trace || green;
// Copy-on-write: the walk only clones the board if it actually changes it.
let board = state.board;
let owned = false;
const own = () => { if (!owned) { board = cloneBoard(board); owned = true; } return board; };
const other = new Set(); const other = new Set();
state.blobs.forEach((b, i) => { state.blobs.forEach((b, i) => {
if (i !== idx) b.cells.forEach(([x, y]) => other.add(key(x, y))); if (i !== idx) b.cells.forEach(([x, y]) => other.add(key(x, y)));
}); });
// Slide until a wall or another blob blocks. Out-of-board cells block const { springs, tunnels, buttons, brickAt } = state;
// nothing (walls and blobs only exist on the board), so an unblocked blob const inBoard = (x, y) => x >= 0 && y >= 0 && x < state.cols && y < state.rows;
// runs clean off the table — the death scan below catches that. const brickUp = (k) => {
let maxSteps = 0; const g = brickAt?.get(k);
const limit = state.cols + state.rows; return g !== undefined && board.bricksUp?.has(g);
for (let s = 1; s <= limit; s++) { };
let blocked = false; // Everything that a blob can never occupy (used for tunnel destinations).
for (const [x, y] of blob.cells) { const solid = (k) => state.walls.has(k) || other.has(k) || board.ice?.has(k)
const k = key(x + dx * s, y + dy * s); || brickUp(k) || board.crates?.has(k) || springs?.has(k);
if (state.walls.has(k) || other.has(k)) { blocked = true; break; }
}
if (blocked) break;
maxSteps = s;
}
let [dx, dy] = DIRS[dir];
let ox = 0, oy = 0;
let maxSteps = 0;
let deathStep = 0; let deathStep = 0;
let deathCause = null; let deathCause = null;
for (let s = 1; s <= maxSteps && deathStep === 0; s++) { let stopReason = 'blocked';
for (const [x, y] of blob.cells) { const hitIce = [];
const nx = x + dx * s, ny = y + dy * s; const path = wantPath ? [] : null;
if (nx < 0 || ny < 0 || nx >= state.cols || ny >= state.rows) { deathStep = s; deathCause = 'edge'; break; } const trail = green ? base.map(([x, y]) => key(x, y)) : null;
if (state.spikes.has(key(nx, ny))) { deathStep = s; deathCause = 'spike'; break; }
const usedSprings = new Set();
const pressed = new Set();
let usedTunnel = false;
// Where each pushed crate started and ended up, so the scene can slide it
// instead of popping it. `null` means it went over the edge.
const crateMoves = new Map();
const crateOrigin = new Map();
// Springs can bounce the blob back and forth, so the straight-line bound is
// not enough; each spring is spent after one bounce, which caps the total.
const limit = (state.cols + state.rows) * ((springs?.size ?? 0) + 2) + 8;
const slimedNow = () => {
if (!board.slime?.size) return false;
for (const [x, y] of base) if (board.slime.has(key(x + ox, y + oy))) return true;
return false;
};
for (let s = 1; s <= limit; s++) {
const nx = ox + dx, ny = oy + dy;
// 1/2. Classify every cell we are about to enter.
let blocked = false;
let bounce = false;
const iceNow = [];
const pushes = [];
for (const [x, y] of base) {
const tk = key(x + nx, y + ny);
if (state.walls.has(tk) || other.has(tk) || brickUp(tk)) blocked = true;
if (board.ice?.has(tk)) { blocked = true; iceNow.push(tk); }
if (springs?.has(tk)) {
if (usedSprings.has(tk)) blocked = true;
else bounce = true;
}
if (board.crates?.has(tk)) {
if (!lifter) { blocked = true; continue; }
const bx = x + nx + dx, by = y + ny + dy;
const bk = key(bx, by);
if (!inBoard(bx, by)) { pushes.push([tk, null]); continue; } // shoved off the table
if (solid(bk)) blocked = true; // no crate trains
else pushes.push([tk, bk]);
}
}
if (blocked) {
if (iceNow.length) {
const b = own();
for (const k of iceNow) { b.ice.delete(k); hitIce.push(k); }
if (!b.ice.size) delete b.ice;
}
stopReason = 'blocked';
break;
}
if (bounce) {
for (const [x, y] of base) {
const tk = key(x + nx, y + ny);
if (springs?.has(tk)) usedSprings.add(tk);
}
dx = -dx; dy = -dy;
continue; // the spring throws it back without advancing
}
// 3. ENTER — crates first so the blob never overlaps one mid-step.
if (pushes.length) {
const b = own();
for (const [from] of pushes) b.crates.delete(from);
for (const [, to] of pushes) if (to) b.crates.add(to);
if (!b.crates.size) delete b.crates;
for (const [from, to] of pushes) {
const origin = crateOrigin.get(from) ?? from;
crateOrigin.delete(from);
if (to) crateOrigin.set(to, origin);
crateMoves.set(origin, to);
}
}
ox = nx; oy = ny; maxSteps += 1; // cells travelled, NOT loop iterations:
if (path) path.push([ox, oy, 0]); // a spring bounce burns a turn of the
// loop without moving the blob at all
if (trail) for (const [x, y] of base) trail.push(key(x + ox, y + oy));
// 4. DEATH.
let died = false;
for (const [x, y] of base) {
const cx = x + ox, cy = y + oy;
if (!inBoard(cx, cy)) { deathStep = s; deathCause = 'edge'; died = true; break; }
if (state.spikes.has(key(cx, cy))) { deathStep = s; deathCause = 'spike'; died = true; break; }
}
if (died) { stopReason = 'dead'; break; }
// 5. BUTTONS.
if (buttons?.size) {
for (const [x, y] of base) {
const bk = key(x + ox, y + oy);
const g = buttons.get(bk);
if (g === undefined || pressed.has(bk)) continue;
pressed.add(bk);
toggleBrickGroup(g);
}
}
// 6. STOP-ON — slime halts the blob on the cell it just entered. A green
// blob's own fresh trail is only laid once the walk finishes, so it never
// stops itself.
if (slimedNow()) { stopReason = 'stop-on'; break; }
// 7. TUNNEL.
if (!usedTunnel && tunnels?.size) {
for (const [x, y] of base) {
const exit = tunnels.get(key(x + ox, y + oy));
if (!exit) continue;
const jx = exit[0] - (x + ox), jy = exit[1] - (y + oy);
let ok = true;
for (const [bx, by] of base) {
const cx = bx + ox + jx, cy = by + oy + jy;
if (!inBoard(cx, cy) || solid(key(cx, cy)) || state.spikes.has(key(cx, cy))) { ok = false; break; }
}
if (!ok) continue;
ox += jx; oy += jy;
usedTunnel = true;
if (path) path.push([ox, oy, 1]);
if (trail) for (const [bx, by] of base) trail.push(key(bx + ox, by + oy));
break;
}
if (usedTunnel && slimedNow()) { stopReason = 'stop-on'; break; }
} }
} }
return { maxSteps, deathStep, deathCause };
// A green blob's trail is laid once the walk is known to have happened.
if (trail && maxSteps > 0 && deathStep === 0) {
const b = own();
const sl = b.slime ?? (b.slime = new Set());
for (const k of trail) sl.add(k);
}
return {
maxSteps, deathStep, deathCause, stopReason,
offset: [ox, oy], hitIce, path, crateMoves, board, boardChanged: owned,
};
// Lowering a brick group always works; raising jams if anything is standing
// on it. Nothing is written unless the group actually changes.
function toggleBrickGroup(g) {
const up = board.bricksUp;
if (!up) return;
if (up.has(g)) { own().bricksUp.delete(g); return; }
for (const [ck, cg] of brickAt) {
if (cg !== g) continue;
if (other.has(ck) || board.crates?.has(ck)) return; // jammed
for (const [x, y] of base) if (key(x + ox, y + oy) === ck) return;
}
own().bricksUp.add(g);
}
}
const isAsleep = (blob) => blob?.asleep === true;
// Plan a flick without touching the state. PURE. Returns:
// { legal, dead, deathCause, deathStep, parts, board, hitIce }
// parts — [{ idx, cells, offset, steps, deathStep, path }] per blob that
// moves; `cells` is the blob's cells BEFORE the move and `path` the
// per-step offsets, so the scene can animate the real route (which
// bends at springs and jumps at tunnels). A hypno flick has one part
// per hive member.
// board — the board state after the flick; applyPlan installs it.
// The scene animates a plan and then applies it; slide() does both at once.
// Planning and applying share this one function so they can never disagree.
export function planFlick(state, idx, dir) {
const blob = state.blobs[idx];
const none = { legal: false, dead: false, parts: [], board: state.board, hitIce: [], crateMoves: new Map() };
if (!blob || isAsleep(blob)) return none;
if (!blob.hypno) return planSingle(state, idx, dir);
// Hive mind: every hypno blob slides. They can block each other, so resolve
// them one at a time, furthest along the direction first — the leader clears
// the way for whoever is behind it. Merging waits until the whole group has
// moved, which also keeps blob indices stable throughout.
const [dx, dy] = DIRS[dir];
const order = state.blobs
.map((b, i) => [b, i])
.filter(([b]) => b.hypno && !isAsleep(b)) // a sleeping hypno stays put until woken
.map(([b, i]) => [i, Math.max(...b.cells.map(([x, y]) => x * dx + y * dy))])
.sort((a, b) => b[1] - a[1])
.map(([i]) => i);
const scratch = {
...state,
board: cloneBoard(state.board),
blobs: state.blobs.map((b) => ({ ...b, cells: b.cells.map((c) => c.slice()) })),
};
const parts = [];
const hitIce = [];
const crateMoves = new Map();
let boardChanged = false;
for (const i of order) {
const before = scratch.blobs[i].cells.map((c) => c.slice());
const r = computeSlide(scratch, i, dir, { trace: true });
// One member dying fails the whole run, and nothing is applied.
if (r.deathStep > 0) {
return {
legal: true, dead: true, deathCause: r.deathCause, deathStep: r.deathStep,
parts: [{ idx: i, cells: before, offset: r.offset, steps: r.maxSteps, deathStep: r.deathStep, path: r.path }],
board: state.board, hitIce: [], crateMoves: new Map(),
};
}
scratch.board = r.board;
if (r.boardChanged) boardChanged = true;
hitIce.push(...r.hitIce);
// A crate a later hive member shoves on again keeps its ORIGINAL start cell.
for (const [from, to] of r.crateMoves) {
let origin = from;
for (const [o, cur] of crateMoves) if (cur === from) { origin = o; break; }
crateMoves.set(origin, to);
}
if (r.maxSteps > 0) {
const [ox, oy] = r.offset;
scratch.blobs[i].cells = scratch.blobs[i].cells.map(([x, y, m]) => [x + ox, y + oy, m]);
parts.push({ idx: i, cells: before, offset: r.offset, steps: r.maxSteps, deathStep: 0, path: r.path });
}
}
// scratch.board is always a fresh clone, so identity cannot decide this —
// only a walk that reported a real change makes a still hive a legal move.
const legal = parts.length > 0 || boardChanged;
return { legal, dead: false, parts, board: scratch.board, hitIce, crateMoves };
}
function planSingle(state, idx, dir) {
const blob = state.blobs[idx];
const r = computeSlide(state, idx, dir, { trace: true });
const cells = blob.cells.map((c) => c.slice());
if (r.deathStep > 0) {
return {
legal: true, dead: true, deathCause: r.deathCause, deathStep: r.deathStep,
parts: [{ idx, cells, offset: r.offset, steps: r.maxSteps, deathStep: r.deathStep, path: r.path }],
board: state.board, hitIce: [], crateMoves: new Map(),
};
}
// A flick that moves nothing but still changes the board — shattering ice you
// are already touching, or pressing a button you are standing next to — is a
// real move. Otherwise ice you are up against could never be broken.
if (r.maxSteps === 0 && !r.boardChanged) {
return { legal: false, dead: false, parts: [], board: state.board, hitIce: [], crateMoves: new Map() };
}
return {
legal: true,
dead: false,
parts: r.maxSteps > 0
? [{ idx, cells, offset: r.offset, steps: r.maxSteps, deathStep: 0, path: r.path }]
: [],
board: r.board,
hitIce: r.hitIce,
crateMoves: r.crateMoves,
};
}
// Apply a plan produced by planFlick() for the same state. Mutates.
export function applyPlan(state, plan) {
if (!plan.legal) return { moved: false };
if (plan.dead) {
state.state = 'dead';
return { moved: true, dead: true, deathStep: plan.deathStep, deathCause: plan.deathCause };
}
for (const part of plan.parts) {
const [ox, oy] = part.offset;
const blob = state.blobs[part.idx];
blob.cells = blob.cells.map(([x, y, m]) => [x + ox, y + oy, m]);
}
state.board = plan.board; // the walk already resolved ice/slime/bricks/crates
const before = state.blobs.length;
mergeBlobs(state);
state.state = state.blobs.length === 1 ? 'won' : 'playing';
return {
moved: true,
dead: false,
merged: state.blobs.length < before,
steps: plan.parts[0]?.steps ?? 0,
shattered: plan.hitIce.length,
};
} }
// Flick blob `idx` in `dir`. Mutates state. Returns: // Flick blob `idx` in `dir`. Mutates state. Returns:
@ -137,72 +560,113 @@ export function computeSlide(state, idx, dir) {
// { moved:true, dead:true, deathStep, deathCause } — hit a spike / fell off // { moved:true, dead:true, deathStep, deathCause } — hit a spike / fell off
// { moved:true, dead:false, merged, steps } — slid and (maybe) merged // { moved:true, dead:false, merged, steps } — slid and (maybe) merged
export function slide(state, idx, dir) { export function slide(state, idx, dir) {
const { maxSteps, deathStep, deathCause } = computeSlide(state, idx, dir); return applyPlan(state, planFlick(state, idx, dir));
if (maxSteps === 0) return { moved: false };
const [dx, dy] = DIRS[dir];
if (deathStep > 0) {
state.state = 'dead';
return { moved: true, dead: true, deathStep, deathCause };
}
const blob = state.blobs[idx];
blob.cells = blob.cells.map(([x, y, m]) => [x + dx * maxSteps, y + dy * maxSteps, m]);
const before = state.blobs.length;
mergeBlobs(state);
pickUpStars(state);
state.state = state.blobs.length === 1 ? 'won' : 'playing';
return { moved: true, dead: false, merged: state.blobs.length < before, steps: maxSteps };
} }
// Every non-fatal flick available. Each entry carries the blob's representative // Every non-fatal flick available. Each entry carries the blob's representative
// cell so a move stays identifiable after merges renumber the blobs. // cell so a move stays identifiable after merges renumber the blobs. Asleep
// blobs offer nothing, and the hypno hive mind offers one move per DIRECTION,
// not one per blob.
export function legalMoves(state) { export function legalMoves(state) {
const moves = []; const moves = [];
let hypnoDirs = null;
state.blobs.forEach((blob, idx) => { state.blobs.forEach((blob, idx) => {
if (isAsleep(blob)) return;
for (const dir of DIR_LIST) { for (const dir of DIR_LIST) {
const { maxSteps, deathStep } = computeSlide(state, idx, dir); if (blob.hypno) {
if (maxSteps > 0 && deathStep === 0) moves.push({ idx, dir, cell: repCell(blob) }); if (hypnoDirs?.has(dir)) continue;
(hypnoDirs ??= new Set()).add(dir);
const plan = planFlick(state, idx, dir);
if (plan.legal && !plan.dead) moves.push({ idx, dir, cell: repCell(blob) });
continue;
}
// Fast path: a lone blob's legality is just its own walk.
const r = computeSlide(state, idx, dir);
if (r.deathStep === 0 && (r.maxSteps > 0 || r.boardChanged)) {
moves.push({ idx, dir, cell: repCell(blob) });
}
} }
}); });
return moves; return moves;
} }
// Canonical key: cells sorted within each blob, blobs sorted, joined. Walls and // Canonical key: cells sorted within each blob (plus its flags), blobs sorted,
// spikes are fixed for a level, so this fully identifies a configuration. // then the mutable board layers. Immutable terrain is fixed for a level so it is
// deliberately left out.
export function stateKey(state) { export function stateKey(state) {
return state.blobs const blobs = state.blobs
.map((b) => b.cells.map(([x, y]) => key(x, y)).sort().join(';')) .map((b) => {
const cells = b.cells.map(([x, y]) => key(x, y)).sort().join(';');
let flags = '';
if (b.asleep) flags += 'z';
if (b.green) flags += 'g';
if (b.hypno) flags += 'h';
if (b.lifter) flags += 'p';
return flags ? `${cells}#${flags}` : cells;
})
.sort() .sort()
.join('|'); .join('|');
const board = boardKey(state.board);
return board ? `${blobs}!${board}` : blobs;
} }
export function cloneState(state) { export function cloneState(state) {
return { return {
cols: state.cols, cols: state.cols,
rows: state.rows, rows: state.rows,
walls: state.walls, // immutable during play — shared walls: state.walls, // immutable during play — all shared
spikes: state.spikes, // immutable during play — shared spikes: state.spikes,
stars: state.stars, // immutable during play — shared springs: state.springs,
blobs: state.blobs.map((b) => ({ cells: b.cells.map((cell) => cell.slice()) })), tunnels: state.tunnels,
collected: new Set(state.collected), buttons: state.buttons,
brickAt: state.brickAt,
board: cloneBoard(state.board),
blobs: state.blobs.map((b) => ({ ...b, cells: b.cells.map((cell) => cell.slice()) })),
state: state.state, state: state.state,
}; };
} }
export function newState(level) { export function newState(level) {
const flagSet = (list) => new Set((list ?? []).map(([x, y]) => key(x, y)));
const asleep = flagSet(level.sleepers);
const green = flagSet(level.green);
const hypno = flagSet(level.hypno);
const lifter = flagSet(level.powerlifters);
const tunnels = new Map();
for (const [ax, ay, bx, by] of (level.tunnels ?? [])) {
tunnels.set(key(ax, ay), [bx, by]);
tunnels.set(key(bx, by), [ax, ay]);
}
const cellGroupMap = (list) => {
const m = new Map();
for (const [x, y, g] of (list ?? [])) m.set(key(x, y), g);
return m;
};
const state = { const state = {
cols: level.cols, cols: level.cols,
rows: level.rows, rows: level.rows,
walls: new Set((level.walls ?? []).map(([x, y]) => key(x, y))), walls: new Set((level.walls ?? []).map(([x, y]) => key(x, y))),
spikes: new Set((level.spikes ?? []).map(([x, y]) => key(x, y))), spikes: new Set((level.spikes ?? []).map(([x, y]) => key(x, y))),
stars: (level.stars ?? []).map((c) => [c[0], c[1]]), springs: new Set((level.springs ?? []).map(([x, y]) => key(x, y))),
blobs: (level.monsters ?? []).map(([x, y], m) => ({ cells: [[x, y, m]] })), tunnels,
collected: new Set(), buttons: cellGroupMap(level.buttons),
brickAt: cellGroupMap(level.bricks),
board: initialBoard(level),
blobs: (level.monsters ?? []).map(([x, y], m) => {
const k = key(x, y);
return makeBlob({
cells: [[x, y, m]],
asleep: asleep.has(k),
green: green.has(k),
hypno: hypno.has(k),
lifter: lifter.has(k),
});
}),
state: 'playing', state: 'playing',
}; };
mergeBlobs(state); // merge any monsters that start touching mergeBlobs(state); // merge any monsters that start touching
pickUpStars(state);
state.state = state.blobs.length === 1 ? 'won' : 'playing'; state.state = state.blobs.length === 1 ? 'won' : 'playing';
return state; return state;
} }

View File

@ -99,6 +99,13 @@ export function playSound(scene, key) {
try { scene.sound.play(key); } catch (_) {} try { scene.sound.play(key); } catch (_) {}
} }
// Same, but with a Phaser sound config — `{ rate, volume, detune }`. Use this
// for pitch-shifted cues rather than `scene.sound.add(...)`, which leaks a
// Sound object per call.
export function playSoundEx(scene, key, config) {
try { scene.sound.play(key, config); } catch (_) {}
}
// Picks and plays one of several keys at random (e.g. variations of the same cue). // Picks and plays one of several keys at random (e.g. variations of the same cue).
export function playRandomSound(scene, keys) { export function playRandomSound(scene, keys) {
playSound(scene, keys[Math.floor(Math.random() * keys.length)]); playSound(scene, keys[Math.floor(Math.random() * keys.length)]);

View File

@ -1,24 +1,34 @@
// Offline generator for Pudding Monsters levels. // Offline generator for Pudding Monsters / "Jell-o Monsters" levels.
// //
// For each difficulty tier it random-fills a grid with walls, spikes and K // Wave 3 replaced the old flat tier ramp with a CURRICULUM: five named chapters
// monsters (the board edge is open — only walls and monsters stop a slide, so // of fifteen levels each, built from blocks that introduce one mechanic at a
// every tier carries walls), runs the BFS solver to (a) reject // time, drill it, then combine it with what came before. See
// unsolvable/trivial layouts and // docs/puddingmonsters-mechanics-plan.md.
// (b) label each survivor with its minimum flick count (par), then marks 3 cells //
// of that solution's final footprint as yellow target squares and writes ordered // Every candidate is random-filled, solved with the BFS solver for its `par`,
// levels to data/puddingmonsters.json. Stars at play time require BOTH: // and then has to survive three gates:
// the final merged blob covering the targets AND solving in par (min of the two //
// medals) — so the on-par solution lands on all 3 targets and scores 3 stars. // 1. PAR BAND — the block says how hard its levels should be.
// 2. LOAD-BEARING — strip an element out and the level must solve in a
// different number of moves, or stop being solvable. This
// is what stops a "slime level" from being an ordinary
// level with a green monster standing in it. Every element
// in a combination level must pass this on its own.
// 3. GENTLE (teaching levels only) — NO first flick may be fatal. You cannot
// lose a teaching level on move one.
//
// Targets (the 3 yellow squares) are 3 cells of the optimal solution's final
// footprint, so the on-par solution is always a 3-star, crowned clear.
// //
// Usage: // Usage:
// node server/scripts/genPuddingMonsters.js [seed] [outFile] // node tools/genPuddingMonsters.js [seed] [outFile]
// //
// Deterministic: same seed -> same bank. Re-run after changing the curve. // Deterministic: same seed -> same bank. Re-run after changing the curve.
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { newState, solve } from '../src/games/puddingmonsters/PuddingMonstersLogic.js'; import { newState, solve, planFlick, DIR_LIST } from '../src/games/puddingmonsters/PuddingMonstersLogic.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const OUT_FILE = process.argv[3] const OUT_FILE = process.argv[3]
@ -40,31 +50,149 @@ function makeRng(seed) {
const rng = makeRng(SEED); const rng = makeRng(SEED);
const randInt = (n) => Math.floor(rng() * n); const randInt = (n) => Math.floor(rng() * n);
// Difficulty curve. Ordered tiers ramp grid size, monsters and obstacles; each // ── Element catalogue ────────────────────────────────────────────────────────
// keeps `count` levels whose par falls in [minPar, maxPar]. The board edge is // `typed` elements mark a subset of the monsters; the rest place extra terrain.
// OPEN (sliding off is fatal), so walls — the only terrain that stops a slide — // `needs` pulls in the terrain a monster type is useless without.
// appear from tier 1, like the original game. Spikes arrive in later tiers. const ELEMENTS = {
// Levels are numbered tier-by-tier. sleepers: { typed: 1, tip: 'Sleepyheads can\'t be flicked — bump one awake to move it.' },
const TIERS = [ green: { typed: 1, tip: 'Green monsters leave slime. Anything sliding onto it stops dead.' },
{ count: 8, cols: 5, rows: 5, monsters: 3, walls: 3, spikes: 0, minPar: 2, maxPar: 3 }, hypno: { typed: 2, tip: 'Hypno goos share one mind — flick one and they all go.' },
{ count: 8, cols: 6, rows: 6, monsters: 3, walls: 4, spikes: 0, minPar: 3, maxPar: 5 }, powerlifters: { typed: 1, terrain: { crates: 2 }, tip: 'Powerlifters shove crates around. Everyone else just stops.' },
{ count: 8, cols: 6, rows: 6, monsters: 4, walls: 5, spikes: 0, minPar: 4, maxPar: 7 }, ice: { terrain: { ice: 3 }, tip: 'Ice stops a slide once, then shatters. Spend it wisely.' },
{ count: 8, cols: 7, rows: 7, monsters: 4, walls: 6, spikes: 1, minPar: 5, maxPar: 9 }, springs: { terrain: { springs: 2 }, tip: 'Springs bounce you straight back — and you keep going.' },
{ count: 8, cols: 7, rows: 7, monsters: 5, walls: 7, spikes: 2, minPar: 7, maxPar: 14 }, tunnels: { terrain: { tunnels: 1 }, tip: 'Tunnels carry a monster across the table, still sliding.' },
buttons: { terrain: { bricks: 2, buttons: 1 }, tip: 'Roll over the button to raise or lower the bricks.' },
};
// ── The curriculum ───────────────────────────────────────────────────────────
// Each chapter is 15 levels of blocks. `teach` = one gentle introduction,
// `drill` = reinforcement of the same element, `mix` = combinations, `basic` =
// no elements at all. Chapter defaults are merged into every block.
const CHAPTERS = [
{
id: 1,
name: 'Cold Storage',
blurb: 'Wake up. Get out of the fridge.',
base: { cols: 5, rows: 5, monsters: 3, walls: 4, spikes: 0 },
blocks: [
{ kind: 'basic', count: 3, minPar: 2, maxPar: 3 },
{ kind: 'teach', element: 'sleepers', count: 1, minPar: 2, maxPar: 3 },
{ kind: 'drill', element: 'sleepers', count: 3, minPar: 3, maxPar: 5 },
{ kind: 'teach', element: 'ice', count: 1, minPar: 2, maxPar: 3 },
{ kind: 'drill', element: 'ice', count: 3, minPar: 3, maxPar: 5 },
{ kind: 'mix', elements: ['sleepers', 'ice'], count: 4, cols: 6, rows: 6, monsters: 4, walls: 5, minPar: 4, maxPar: 8 },
],
names: [
'First Wobble', 'Two of a Kind', 'Shelf Life',
'Rise and Shine', 'Snooze Button', 'Heavy Sleeper', 'Dream Team',
'Breaking the Ice', 'Cold Snap', 'Thin Ice', 'Freezer Burn',
'Chill Out', 'Frost and Friends', 'Deep Freeze', 'Door Ajar',
],
},
{
id: 2,
name: 'Kitchen Counter',
blurb: 'Mind the mess. Some of it is yours.',
base: { cols: 6, rows: 6, monsters: 3, walls: 4, spikes: 0 },
blocks: [
{ kind: 'basic', count: 2, minPar: 3, maxPar: 5 },
{ kind: 'teach', element: 'green', count: 1, cols: 5, rows: 5, minPar: 2, maxPar: 3 },
{ kind: 'drill', element: 'green', count: 4, minPar: 3, maxPar: 6 },
{ kind: 'teach', element: 'hypno', count: 1, cols: 5, rows: 5, minPar: 2, maxPar: 3 },
{ kind: 'drill', element: 'hypno', count: 4, monsters: 4, walls: 5, minPar: 3, maxPar: 6 },
{ kind: 'mix', elements: ['green', 'hypno'], count: 3, monsters: 4, walls: 5, minPar: 4, maxPar: 9 },
],
names: [
'Countertop Caper', 'Spilled Milk',
'Sticky Business', 'Green Streak', 'Trail Mix', 'Slime Time', 'Gooey Detour',
'Group Think', 'All Together Now', 'Hive Mind', 'Synchronised Slipping', 'Follow the Leader',
'Slime and Punishment', 'One Mind, Many Blobs', 'Crumb Trail',
],
},
{
id: 3,
name: 'The Pantry',
blurb: 'Shortcuts, springs, and a long way down.',
base: { cols: 6, rows: 6, monsters: 3, walls: 4, spikes: 0 },
blocks: [
{ kind: 'basic', count: 1, minPar: 3, maxPar: 5 },
{ kind: 'teach', element: 'springs', count: 1, cols: 5, rows: 5, minPar: 2, maxPar: 3 },
{ kind: 'drill', element: 'springs', count: 4, minPar: 3, maxPar: 6 },
{ kind: 'teach', element: 'tunnels', count: 1, cols: 5, rows: 5, minPar: 2, maxPar: 3 },
{ kind: 'drill', element: 'tunnels', count: 4, minPar: 3, maxPar: 6 },
{ kind: 'mix', elements: ['springs', 'tunnels'], count: 2, monsters: 4, walls: 5, minPar: 4, maxPar: 9 },
{ kind: 'mix', elements: ['springs', 'ice'], count: 1, monsters: 4, walls: 5, minPar: 4, maxPar: 9 },
{ kind: 'mix', elements: ['tunnels', 'green'], count: 1, monsters: 4, walls: 5, minPar: 4, maxPar: 9 },
],
names: [
'Shelf Assembly',
'Boing', 'Bounce Back', 'Double Trouble', 'Springboard', 'Return to Sender',
'Down the Hatch', 'Wormhole', 'Pantry Portal', 'In One End', 'Shortcut',
'Bounce and Beyond', 'Jar Jam', 'Spice Rack', 'Pantry Panic',
],
},
{
id: 4,
name: 'Dining Room',
blurb: 'Push the furniture. Nobody is watching.',
base: { cols: 6, rows: 6, monsters: 3, walls: 4, spikes: 0 },
blocks: [
{ kind: 'basic', count: 1, minPar: 3, maxPar: 5 },
{ kind: 'teach', element: 'buttons', count: 1, cols: 5, rows: 5, minPar: 2, maxPar: 3 },
{ kind: 'drill', element: 'buttons', count: 4, minPar: 3, maxPar: 6 },
{ kind: 'teach', element: 'powerlifters', count: 1, cols: 5, rows: 5, minPar: 2, maxPar: 3 },
{ kind: 'drill', element: 'powerlifters', count: 4, minPar: 3, maxPar: 6 },
{ kind: 'mix', elements: ['buttons', 'powerlifters'], count: 2, monsters: 4, walls: 5, minPar: 4, maxPar: 9 },
{ kind: 'mix', elements: ['powerlifters', 'springs'], count: 1, monsters: 4, walls: 5, minPar: 4, maxPar: 9 },
{ kind: 'mix', elements: ['buttons', 'sleepers'], count: 1, monsters: 4, walls: 5, minPar: 4, maxPar: 9 },
],
names: [
'Table Manners',
'Press Here', 'Trapdoor', 'Now You Don\'t', 'Push and Pull', 'Brickwork',
'Heavy Lifting', 'Crate Expectations', 'Muscle Memory', 'Shove Off', 'Furniture Shuffle',
'Dinner Service', 'Table for One', 'Centrepiece', 'Clean Plate',
],
},
{
id: 5,
name: 'Midnight Feast',
blurb: 'Everything you have learned, all at once.',
base: { cols: 7, rows: 7, monsters: 4, walls: 6, spikes: 1 },
blocks: [
{ kind: 'mix', elements: ['sleepers', 'springs'], count: 2, minPar: 4, maxPar: 10 },
{ kind: 'mix', elements: ['green', 'tunnels'], count: 2, minPar: 4, maxPar: 10 },
{ kind: 'mix', elements: ['ice', 'buttons'], count: 2, minPar: 4, maxPar: 10 },
{ kind: 'mix', elements: ['hypno', 'powerlifters'], count: 2, minPar: 4, maxPar: 10 },
{ kind: 'mix', elements: ['springs', 'tunnels', 'sleepers'], count: 2, monsters: 5, walls: 7, minPar: 5, maxPar: 12 },
{ kind: 'mix', elements: ['green', 'ice', 'buttons'], count: 2, monsters: 5, walls: 7, minPar: 5, maxPar: 12 },
{ kind: 'mix', elements: ['powerlifters', 'tunnels', 'hypno'], count: 3, monsters: 5, walls: 7, minPar: 5, maxPar: 14 },
],
names: [
'Night Shift', 'Leftovers', 'Midnight Snack', 'Everything Bagel', 'Kitchen Sink',
'The Long Way Round', 'Sleepwalking', 'Slippery Slope', 'Spring Cleaning', 'Tunnel Vision',
'Button Masher', 'Crate Escape', 'Full Fridge', 'Last Supper', 'Jell-o Monster',
],
},
]; ];
const MAX_ATTEMPTS = 6000000;
const MAX_SECONDS = 200; const MAX_TRIES_PER_LEVEL = 400000;
const SECONDS_PER_BLOCK = 300;
const SOLVE_MAX_STATES = 80000; const SOLVE_MAX_STATES = 80000;
const keyOf = (x, y) => `${x},${y}`; const keyOf = (x, y) => `${x},${y}`;
// Place `n` distinct random cells avoiding `taken`; returns null if it can't. // Place `n` distinct random cells avoiding `taken`; returns null if it can't.
function placeCells(n, cols, rows, taken) { // `interior` keeps cells off the rim, which is what makes guard rails possible.
function placeCells(n, cols, rows, taken, interior = false) {
const out = []; const out = [];
let tries = 0; let tries = 0;
const lo = interior ? 1 : 0;
const wx = interior ? cols - 2 : cols;
const wy = interior ? rows - 2 : rows;
if (wx <= 0 || wy <= 0) return null;
while (out.length < n && tries < 400) { while (out.length < n && tries < 400) {
tries++; tries++;
const x = randInt(cols), y = randInt(rows); const x = lo + randInt(wx), y = lo + randInt(wy);
const k = keyOf(x, y); const k = keyOf(x, y);
if (taken.has(k)) continue; if (taken.has(k)) continue;
taken.add(k); taken.add(k);
@ -73,110 +201,257 @@ function placeCells(n, cols, rows, taken) {
return out.length === n ? out : null; return out.length === n ? out : null;
} }
// Build one random candidate level for a tier (or null on failure). // Teaching levels promise you cannot lose on move one. Random walls almost
function randomLevel(tier) { // never deliver that on an open-edged table, so build it in: for every monster
// and every direction whose ray runs clean off the board, drop a wall on the
// last cell of that ray. The monster then stops one short of the rim instead of
// sailing over it. Monsters must already be off the rim themselves — a monster
// standing on the edge facing out cannot be saved by any wall.
function addGuardRails(lvl, taken) {
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
for (const [mx, my] of lvl.monsters) {
for (const [dx, dy] of dirs) {
let blocked = false;
let last = null;
for (let s = 1; ; s++) {
const x = mx + dx * s, y = my + dy * s;
if (x < 0 || y < 0 || x >= lvl.cols || y >= lvl.rows) break;
if (taken.has(keyOf(x, y))) { blocked = true; break; }
last = [x, y];
}
if (blocked) continue;
if (!last) return false; // monster is on the rim facing out
taken.add(keyOf(last[0], last[1]));
lvl.walls.push(last);
}
}
return true;
}
// Build one random candidate for a block (or null if the board is too crowded).
function randomLevel(spec) {
const elements = spec.elements ?? (spec.element ? [spec.element] : []);
const taken = new Set(); const taken = new Set();
const walls = placeCells(tier.walls, tier.cols, tier.rows, taken);
const walls = placeCells(spec.walls, spec.cols, spec.rows, taken);
if (!walls) return null; if (!walls) return null;
const spikes = placeCells(tier.spikes, tier.cols, tier.rows, taken); const spikes = placeCells(spec.spikes ?? 0, spec.cols, spec.rows, taken);
if (!spikes) return null; if (!spikes) return null;
const monsters = placeCells(tier.monsters, tier.cols, tier.rows, taken);
const lvl = { cols: spec.cols, rows: spec.rows, walls, spikes };
// Terrain each element needs.
for (const el of elements) {
const terrain = ELEMENTS[el].terrain ?? {};
for (const [field, count] of Object.entries(terrain)) {
const n = spec.counts?.[field] ?? count;
if (field === 'tunnels') {
const mouths = placeCells(n * 2, spec.cols, spec.rows, taken);
if (!mouths) return null;
lvl.tunnels = [];
for (let i = 0; i < mouths.length; i += 2) {
lvl.tunnels.push([mouths[i][0], mouths[i][1], mouths[i + 1][0], mouths[i + 1][1]]);
}
} else if (field === 'bricks' || field === 'buttons') {
const cells = placeCells(n, spec.cols, spec.rows, taken);
if (!cells) return null;
lvl[field] = cells.map(([x, y]) => [x, y, 1]); // one group keeps cause and effect readable
} else {
const cells = placeCells(n, spec.cols, spec.rows, taken);
if (!cells) return null;
lvl[field] = cells;
}
}
}
const monsters = placeCells(spec.monsters, spec.cols, spec.rows, taken, spec.kind === 'teach');
if (!monsters) return null; if (!monsters) return null;
return { lvl.monsters = monsters;
cols: tier.cols, rows: tier.rows, walls, spikes, monsters,
}; // Teaching levels get a rail on any line that would drop a monster off the
// table, so the opening position is survivable whatever the player tries.
if (spec.kind === 'teach' && !addGuardRails(lvl, taken)) return null;
// Typed monsters are a random subset of the monsters already placed. Never
// type every monster: something has to be flickable.
const pool = monsters.slice();
for (const el of elements) {
const n = spec.counts?.[el] ?? ELEMENTS[el].typed ?? 0;
if (!n) continue;
const picked = [];
while (picked.length < n && pool.length > 1) picked.push(pool.splice(randInt(pool.length), 1)[0]);
if (picked.length !== n) return null;
lvl[el] = picked;
}
return lvl;
}
// The element has to matter: with it removed the level must solve in a
// different number of moves, or stop being solvable at all.
function isLoadBearing(lvl, element, par) {
const stripped = { ...lvl };
delete stripped[element];
delete stripped.targets;
const state = newState(stripped);
if (state.blobs.length === 1) return true; // the element was holding them apart
return solve(state, { maxStates: SOLVE_MAX_STATES }).moves !== par;
}
// How many opening flicks kill you. Teaching levels must have none.
function fatalFirstMoves(state) {
let n = 0;
state.blobs.forEach((blob, idx) => {
if (blob.asleep) return;
for (const dir of DIR_LIST) {
const plan = planFlick(state, idx, dir);
if (plan.legal && plan.dead) n += 1;
}
});
return n;
} }
function canonKey(lvl) { function canonKey(lvl) {
const s = (arr) => arr.map(([x, y]) => keyOf(x, y)).sort().join(' '); const s = (arr) => (arr ?? []).map((c) => c.join(',')).sort().join(' ');
return `${lvl.cols}x${lvl.rows}|M:${s(lvl.monsters)}|W:${s(lvl.walls)}|X:${s(lvl.spikes)}`; return [lvl.cols, lvl.rows, s(lvl.monsters), s(lvl.walls), s(lvl.spikes), s(lvl.ice),
s(lvl.sleepers), s(lvl.green), s(lvl.hypno), s(lvl.springs), s(lvl.tunnels),
s(lvl.bricks), s(lvl.buttons), s(lvl.crates), s(lvl.powerlifters)].join('|');
} }
// 3 spread-out cells of the solution's final footprint -> yellow target squares. // 3 spread-out cells of the solution's final footprint -> yellow target squares.
// The footprint always has >= 3 cells (>= 3 monsters), so this yields 3 distinct.
function chooseTargets(footprint) { function chooseTargets(footprint) {
const sorted = footprint.slice().sort((a, b) => (a[1] - b[1]) || (a[0] - b[0])); const sorted = footprint.slice().sort((a, b) => (a[1] - b[1]) || (a[0] - b[0]));
const idx = [...new Set([0, Math.floor(sorted.length / 2), sorted.length - 1])]; const idx = [...new Set([0, Math.floor(sorted.length / 2), sorted.length - 1])];
return idx.map((i) => [sorted[i][0], sorted[i][1]]); return idx.map((i) => [sorted[i][0], sorted[i][1]]);
} }
// ── Generate pool ──────────────────────────────────────────────────────────── // ── Build the bank chapter by chapter ────────────────────────────────────────
console.log(`[pudding] generating with seed ${SEED}`); console.log(`[pudding] generating with seed ${SEED}`);
const target = TIERS.reduce((t, x) => t + x.count, 0);
const buckets = TIERS.map(() => []);
const seen = new Set(); const seen = new Set();
let attempts = 0; const bank = [];
let solved = 0; let totalTries = 0;
const startedAt = Date.now();
const tiersFull = () => buckets.every((b, i) => b.length >= TIERS[i].count);
while (attempts < MAX_ATTEMPTS && !tiersFull()) { for (const chapter of CHAPTERS) {
// Round-robin the tiers that still need levels so every tier gets airtime. const produced = [];
for (let ti = 0; ti < TIERS.length; ti++) { for (const block of chapter.blocks) {
if (buckets[ti].length >= TIERS[ti].count) continue; const spec = { ...chapter.base, ...block };
attempts++; const elements = spec.elements ?? (spec.element ? [spec.element] : []);
if ((attempts & 0x1ff) === 0 && (Date.now() - startedAt) / 1000 > MAX_SECONDS) { const kept = [];
console.log('\n[pudding] time budget reached, stopping early'); let tries = 0;
break; const started = Date.now();
while (kept.length < block.count
&& tries < MAX_TRIES_PER_LEVEL
&& (Date.now() - started) / 1000 < SECONDS_PER_BLOCK) {
tries++; totalTries++;
const lvl = randomLevel(spec);
if (!lvl) continue;
const ck = canonKey(lvl);
if (seen.has(ck)) continue;
seen.add(ck);
const state = newState(lvl);
if (state.blobs.length !== spec.monsters) continue; // started adjacent
if (block.kind === 'teach' && fatalFirstMoves(state) > 0) continue;
const res = solve(state, { maxStates: SOLVE_MAX_STATES });
if (res.moves < Math.max(2, spec.minPar) || res.moves > spec.maxPar) continue;
if (!elements.every((el) => isLoadBearing(lvl, el, res.moves))) continue;
lvl.targets = chooseTargets(res.footprint);
if (lvl.targets.length !== 3) continue;
lvl.par = res.moves;
lvl.chapter = chapter.id;
lvl.kind = block.kind;
if (elements.length) lvl.elements = elements.slice();
if (block.kind === 'teach') {
lvl.element = block.element;
lvl.tip = ELEMENTS[block.element].tip;
lvl.teaching = true;
} else if (elements.length) {
lvl.element = elements[0];
}
kept.push(lvl);
} }
const tier = TIERS[ti];
const lvl = randomLevel(tier);
if (!lvl) continue;
const ck = canonKey(lvl); if (kept.length < block.count) {
if (seen.has(ck)) continue; console.error(`[pudding] WARNING ch${chapter.id} ${block.kind}`
seen.add(ck); + `(${elements.join('+') || 'basic'}): only ${kept.length}/${block.count} after ${tries} tries`);
const state = newState(lvl);
if (state.blobs.length !== tier.monsters) continue; // started adjacent -> skip
const res = solve(state, { maxStates: SOLVE_MAX_STATES });
if (res.moves < Math.max(2, tier.minPar) || res.moves > tier.maxPar) continue;
solved++;
lvl.targets = chooseTargets(res.footprint);
if (lvl.targets.length !== 3) continue;
lvl.par = res.moves;
buckets[ti].push(lvl);
if (solved % 200 === 0) {
const kept = buckets.reduce((t, b) => t + b.length, 0);
process.stdout.write(`\r[pudding] attempts=${attempts} kept=${kept}/${target} `);
} }
kept.sort((p, q) => p.par - q.par);
produced.push(...kept);
} }
if ((Date.now() - startedAt) / 1000 > MAX_SECONDS) break; // Difficulty rises across the chapter without breaking the teach-then-drill
// order: blocks stay in curriculum order and sort by par inside themselves.
// The combination blocks all sit at the end, so sort that whole run together
// rather than letting each block restart the ramp.
let firstMix = produced.length;
while (firstMix > 0 && produced[firstMix - 1].kind === 'mix') firstMix -= 1;
const tail = produced.slice(firstMix).sort((p, q) => p.par - q.par);
produced.length = firstMix;
produced.push(...tail);
chapter.produced = produced;
bank.push(...produced);
console.log(`[pudding] chapter ${chapter.id} "${chapter.name}": ${produced.length}/15 levels`);
} }
process.stdout.write('\n');
// ── Assemble ordered levels (tier order, then par ascending within tier) ────── // ── Assemble ─────────────────────────────────────────────────────────────────
const chosen = [];
buckets.forEach((b) => { const levels = bank.map((lvl, i) => {
b.sort((p, q) => p.par - q.par); const out = {
chosen.push(...b); level: i + 1,
chapter: lvl.chapter,
name: '',
cols: lvl.cols,
rows: lvl.rows,
walls: lvl.walls,
spikes: lvl.spikes,
targets: lvl.targets,
monsters: lvl.monsters,
par: lvl.par,
};
for (const f of ['ice', 'slime', 'sleepers', 'green', 'hypno',
'springs', 'tunnels', 'bricks', 'buttons', 'bricksDown', 'crates', 'powerlifters']) {
if (lvl[f]?.length) out[f] = lvl[f];
}
if (lvl.elements) out.elements = lvl.elements;
if (lvl.element) out.element = lvl.element;
if (lvl.tip) out.tip = lvl.tip;
if (lvl.teaching) out.teaching = true;
return out;
}); });
const levels = chosen.map((lvl, i) => ({ // Names run in chapter order, so a teaching level keeps the name written for it.
level: i + 1, const chapters = [];
cols: lvl.cols, let cursor = 0;
rows: lvl.rows, for (const chapter of CHAPTERS) {
walls: lvl.walls, const n = chapter.produced.length;
spikes: lvl.spikes, if (!n) continue;
targets: lvl.targets, for (let i = 0; i < n; i++) {
monsters: lvl.monsters, levels[cursor + i].name = chapter.names[i] ?? `${chapter.name} ${i + 1}`;
par: lvl.par, }
})); chapters.push({
id: chapter.id,
name: chapter.name,
blurb: chapter.blurb,
from: cursor + 1,
to: cursor + n,
});
cursor += n;
}
const payload = { const payload = {
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
seed: SEED, seed: SEED,
count: levels.length, count: levels.length,
chapters,
levels, levels,
}; };
fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true }); fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true });
fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2)); fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2));
const perTier = buckets.map((b, i) => `${b.length}/${TIERS[i].count}`).join(' '); console.log(`[pudding] ${totalTries} candidates tried`);
console.log(`[pudding] attempts=${attempts} solvable=${solved}`); for (const c of chapters) console.log(`[pudding] ch${c.id} ${c.name}: levels ${c.from}-${c.to}`);
console.log(`[pudding] tiers filled: ${perTier}`); console.log(`[pudding] wrote ${levels.length} levels (par ${Math.min(...levels.map((l) => l.par))}`
console.log(`[pudding] wrote ${levels.length} levels (par ${levels[0]?.par}..${levels[levels.length - 1]?.par}) -> ${OUT_FILE}`); + `..${Math.max(...levels.map((l) => l.par))}) -> ${OUT_FILE}`);

View File

@ -0,0 +1,846 @@
// Verifier for Pudding Monsters / "Jell-o Monsters" (Node only — no browser).
//
// 1. Schema-lints data/puddingmonsters.json (bounds, overlaps, target legality).
// 2. Re-solves every level fresh from the JSON (independent of whatever
// genPuddingMonsters.js asserted at generation time), checks `par`, and
// replays the optimal path to confirm it really wins with 3 stars.
// 3. Unit-tests the engine primitives against small synthetic levels: slide
// stopping, edge/spike death, merging, no-ops, and the solver.
// 4. Exercises the mutable-board plumbing (state.board layers, per-blob asleep
// flags) that Waves 1-2 hang their elements off — see
// docs/puddingmonsters-mechanics-plan.md. These checks are what stop a new
// element from silently escaping stateKey() and corrupting `par`.
//
// Usage: node tools/verifyPuddingMonsters.js
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
DIRS, DIR_LIST, newState, cloneState, slide, computeSlide, legalMoves, solve,
planFlick, applyPlan,
stateKey, boardKey, cloneBoard, blobAt, repCell, targetsCovered,
} from '../src/games/puddingmonsters/PuddingMonstersLogic.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FILE = path.join(__dirname, '../data/puddingmonsters.json');
let passes = 0;
let failures = 0;
function check(name, cond, detail = '') {
if (cond) { passes += 1; console.log(` ok ${name}`); }
else { failures += 1; console.error(`FAIL ${name}${detail ? `${detail}` : ''}`); }
}
const keyOf = (x, y) => `${x},${y}`;
// ── 1. Bank schema ───────────────────────────────────────────────────────────
const raw = JSON.parse(fs.readFileSync(FILE, 'utf8'));
const levels = raw.levels ?? [];
console.log(`[verify] ${FILE}`);
console.log(`[verify] ${levels.length} levels`);
check('bank is non-empty', levels.length > 0, `found ${levels.length}`);
check('bank count matches the levels array', raw.count === levels.length);
check('levels are numbered 1..N in order',
levels.every((l, i) => l.level === i + 1));
for (const def of levels) {
const inBounds = ([x, y]) => x >= 0 && y >= 0 && x < def.cols && y < def.rows;
const walls = def.walls ?? [];
const spikes = def.spikes ?? [];
const targets = def.targets ?? [];
const monsters = def.monsters ?? [];
const all = [...walls, ...spikes, ...targets, ...monsters];
check(`L${def.level}: every cell is in bounds`, all.every(inBounds));
const wallKeys = new Set(walls.map(([x, y]) => keyOf(x, y)));
const spikeKeys = new Set(spikes.map(([x, y]) => keyOf(x, y)));
const monsterKeys = new Set(monsters.map(([x, y]) => keyOf(x, y)));
check(`L${def.level}: walls, spikes and monsters do not overlap`,
wallKeys.size === walls.length
&& spikeKeys.size === spikes.length
&& monsterKeys.size === monsters.length
&& [...wallKeys].every((k) => !spikeKeys.has(k) && !monsterKeys.has(k))
&& [...spikeKeys].every((k) => !monsterKeys.has(k)));
check(`L${def.level}: has 3 distinct target squares`,
targets.length === 3 && new Set(targets.map(([x, y]) => keyOf(x, y))).size === 3);
check(`L${def.level}: no target sits on a wall or spike`,
targets.every(([x, y]) => !wallKeys.has(keyOf(x, y)) && !spikeKeys.has(keyOf(x, y))));
check(`L${def.level}: par is a sane move count`,
Number.isInteger(def.par) && def.par >= 2, `par=${def.par}`);
// Wave 1 elements are optional per level.
const ice = def.ice ?? [];
check(`L${def.level}: ice is in bounds and clear of other terrain`,
ice.every(inBounds)
&& ice.every(([x, y]) => !wallKeys.has(keyOf(x, y)) && !spikeKeys.has(keyOf(x, y)) && !monsterKeys.has(keyOf(x, y))));
for (const kind of ['sleepers', 'green', 'hypno']) {
const list = def[kind] ?? [];
if (!list.length) continue;
check(`L${def.level}: every ${kind} entry is one of the level's monsters`,
list.every(([x, y]) => monsterKeys.has(keyOf(x, y))));
check(`L${def.level}: ${kind} entries are distinct`,
new Set(list.map(([x, y]) => keyOf(x, y))).size === list.length);
}
check(`L${def.level}: not every monster is asleep`,
(def.sleepers ?? []).length < monsters.length);
check(`L${def.level}: a hive needs at least two members`,
!def.hypno || def.hypno.length >= 2);
// Wave 2 elements.
const occupied = new Set([...wallKeys, ...spikeKeys, ...monsterKeys, ...ice.map(([x, y]) => keyOf(x, y))]);
const clearCells = (list, name) => {
check(`L${def.level}: ${name} is in bounds and clear of other terrain`,
list.every(inBounds) && list.every(([x, y]) => !occupied.has(keyOf(x, y))));
for (const [x, y] of list) occupied.add(keyOf(x, y));
};
clearCells(def.springs ?? [], 'springs');
clearCells((def.bricks ?? []).map(([x, y]) => [x, y]), 'bricks');
clearCells((def.buttons ?? []).map(([x, y]) => [x, y]), 'buttons');
clearCells(def.crates ?? [], 'crates');
if (def.tunnels?.length) {
check(`L${def.level}: every tunnel links two distinct in-bounds cells`,
def.tunnels.every(([ax, ay, bx, by]) =>
inBounds([ax, ay]) && inBounds([bx, by]) && keyOf(ax, ay) !== keyOf(bx, by)));
const mouths = def.tunnels.flatMap(([ax, ay, bx, by]) => [[ax, ay], [bx, by]]);
check(`L${def.level}: tunnel mouths are distinct and clear of other terrain`,
new Set(mouths.map(([x, y]) => keyOf(x, y))).size === mouths.length
&& mouths.every(([x, y]) => !occupied.has(keyOf(x, y))));
}
if (def.bricks?.length) {
const groups = new Set(def.bricks.map(([, , g]) => g));
check(`L${def.level}: every brick group has a button`,
[...groups].every((g) => (def.buttons ?? []).some(([, , bg]) => bg === g)));
check(`L${def.level}: brick and button groups are defined`,
def.bricks.every(([, , g]) => g !== undefined)
&& (def.buttons ?? []).every(([, , g]) => g !== undefined));
}
check(`L${def.level}: buttons without bricks would do nothing`,
!(def.buttons ?? []).length || (def.bricks ?? []).length > 0);
if (def.powerlifters?.length) {
check(`L${def.level}: every powerlifter is one of the level's monsters`,
def.powerlifters.every(([x, y]) => monsterKeys.has(keyOf(x, y))));
check(`L${def.level}: crates without a powerlifter are just walls`,
(def.crates ?? []).length > 0);
}
}
// ── 2. Every level re-solved and replayed ────────────────────────────────────
let parOk = 0;
let winOk = 0;
let starsOk = 0;
for (const def of levels) {
const state = newState(def);
if (state.blobs.length !== (def.monsters ?? []).length) {
check(`L${def.level}: monsters do not start already merged`, false);
continue;
}
const res = solve(state, { maxStates: 200000 });
if (res.moves === def.par) parOk += 1;
else check(`L${def.level}: solver par matches the bank`, false, `solver=${res.moves} bank=${def.par}`);
// Replay the optimal path move-by-move on a fresh state.
const play = newState(def);
let replayed = true;
for (const mv of res.path ?? []) {
const idx = blobAt(play, mv.cell[0], mv.cell[1]);
if (idx < 0) { replayed = false; break; }
const out = slide(play, idx, mv.dir);
if (!out.moved || out.dead) { replayed = false; break; }
}
if (replayed && play.state === 'won' && play.blobs.length === 1) winOk += 1;
else check(`L${def.level}: the optimal path replays to a win`, false);
if (targetsCovered(play, def.targets) === 3) starsOk += 1;
else check(`L${def.level}: the optimal solution covers all 3 targets`, false,
`covered=${targetsCovered(play, def.targets)}`);
}
check(`all ${levels.length} levels: solver par matches the bank`, parOk === levels.length, `${parOk}/${levels.length}`);
check(`all ${levels.length} levels: optimal path replays to a win`, winOk === levels.length, `${winOk}/${levels.length}`);
check(`all ${levels.length} levels: optimal solution is a 3-star clear`, starsOk === levels.length, `${starsOk}/${levels.length}`);
// Levels that advertise a mechanic must actually need it: strip the element and
// the level has to solve in a different number of moves, or not at all. Every
// element of a combination level is checked on its own. Re-checked here rather
// than trusted from generation time.
{
const tagged = levels.filter((l) => l.elements?.length);
let bearing = 0;
let pairs = 0;
for (const def of tagged) {
let allOk = true;
for (const el of def.elements) {
pairs += 1;
const stripped = { ...def };
delete stripped[el];
const st = newState(stripped);
const res = st.blobs.length === 1 ? { moves: 0 } : solve(st, { maxStates: 200000 });
if (res.moves === def.par) {
allOk = false;
check(`L${def.level} "${def.name}": its ${el} is load-bearing`, false, `same par (${def.par}) without it`);
}
}
if (allOk) bearing += 1;
}
check(`all ${tagged.length} element levels: every mechanic in them is load-bearing`,
bearing === tagged.length, `${bearing}/${tagged.length} levels, ${pairs} element checks`);
check('the bank exercises every mechanic',
['sleepers', 'ice', 'green', 'hypno', 'springs', 'tunnels', 'buttons', 'powerlifters']
.every((e) => tagged.some((l) => l.elements.includes(e))));
}
// ── 2b. The curriculum ───────────────────────────────────────────────────────
{
const chapters = raw.chapters ?? [];
check('the bank declares chapters', chapters.length > 0);
check('chapters cover every level exactly once, in order', (() => {
let expect = 1;
for (const c of chapters) {
if (c.from !== expect) return false;
if (c.to < c.from) return false;
expect = c.to + 1;
}
return expect === levels.length + 1;
})());
check('every chapter has a name and a blurb',
chapters.every((c) => typeof c.name === 'string' && c.name && typeof c.blurb === 'string' && c.blurb));
check('every level knows which chapter it is in',
levels.every((l) => chapters.some((c) => c.id === l.chapter && l.level >= c.from && l.level <= c.to)));
check('every level is named', levels.every((l) => typeof l.name === 'string' && l.name.length > 0));
check('level names are unique', new Set(levels.map((l) => l.name)).size === levels.length);
// Every mechanic gets exactly one teaching level, and it comes before any
// other level that uses that mechanic.
const taught = levels.filter((l) => l.teaching);
check('every mechanic has exactly one teaching level', (() => {
const seenEls = taught.map((l) => l.element);
return new Set(seenEls).size === seenEls.length
&& ['sleepers', 'ice', 'green', 'hypno', 'springs', 'tunnels', 'buttons', 'powerlifters']
.every((e) => seenEls.includes(e));
})(), taught.map((l) => l.element).join(','));
for (const t of taught) {
const firstUse = levels.find((l) => l.elements?.includes(t.element));
check(`the ${t.element} lesson (L${t.level} "${t.name}") comes before any level using it`,
firstUse.level === t.level, `first use is L${firstUse.level}`);
check(`L${t.level} "${t.name}": a lesson is short (par <= 3)`, t.par <= 3, `par=${t.par}`);
check(`L${t.level} "${t.name}": a lesson explains itself`, typeof t.tip === 'string' && t.tip.length > 0);
}
// The promise of a teaching level: you cannot lose it on move one.
let gentle = 0;
for (const t of taught) {
const st = newState(t);
let fatal = 0;
st.blobs.forEach((blob, idx) => {
if (blob.asleep) return;
for (const dir of DIR_LIST) {
const plan = planFlick(st, idx, dir);
if (plan.legal && plan.dead) fatal += 1;
}
});
if (fatal === 0) gentle += 1;
else check(`L${t.level} "${t.name}": no opening flick is fatal`, false, `${fatal} fatal openings`);
}
check(`all ${taught.length} teaching levels: no opening flick is fatal`, gentle === taught.length);
// Difficulty should trend upward across the game, even if not monotonically.
const avgPar = (from, to) => {
const inRange = levels.filter((l) => l.level >= from && l.level <= to);
return inRange.reduce((t, l) => t + l.par, 0) / inRange.length;
};
const firstChapter = chapters[0];
const lastChapter = chapters[chapters.length - 1];
check('the last chapter is harder than the first',
avgPar(lastChapter.from, lastChapter.to) > avgPar(firstChapter.from, firstChapter.to),
`${avgPar(firstChapter.from, firstChapter.to).toFixed(1)} -> ${avgPar(lastChapter.from, lastChapter.to).toFixed(1)}`);
check('the game opens gently', avgPar(1, 5) <= 3.5, `${avgPar(1, 5).toFixed(1)}`);
}
// ── 3. Engine primitives ─────────────────────────────────────────────────────
// Helper: a level literal, monsters listed as [x,y].
const L = (o) => ({ cols: 5, rows: 5, walls: [], spikes: [], targets: [], monsters: [], ...o });
{
// . . . . . A slides right until the wall at (3,0) stops it at (2,0).
const s = newState(L({ monsters: [[0, 0]], walls: [[3, 0]] }));
const r = computeSlide(s, 0, 'right');
check('a slide stops in front of a wall', r.maxSteps === 2 && r.deathStep === 0,
`maxSteps=${r.maxSteps}`);
check('offset agrees with the step count on a straight slide',
r.offset[0] === 2 && r.offset[1] === 0);
slide(s, 0, 'right');
check('slide() moves the blob to the computed rest cell',
s.blobs[0].cells[0][0] === 2 && s.blobs[0].cells[0][1] === 0);
}
{
// Nothing in the way -> the blob runs off the open edge and dies.
const s = newState(L({ monsters: [[0, 0], [4, 4]] }));
const r = computeSlide(s, 0, 'left');
check('sliding off the open edge is fatal', r.deathStep === 1 && r.deathCause === 'edge');
check('a fatal walk reports stopReason "dead"', r.stopReason === 'dead');
const out = slide(s, 0, 'left');
check('a fatal slide marks the run dead and leaves positions untouched',
out.dead === true && s.state === 'dead'
&& s.blobs[0].cells[0][0] === 0 && s.blobs[0].cells[0][1] === 0);
}
{
// Spike two cells right of the monster, wall beyond it.
const s = newState(L({ monsters: [[0, 0]], spikes: [[2, 0]], walls: [[4, 0]] }));
const r = computeSlide(s, 0, 'right');
check('a slide across a spike is fatal at the spike',
r.deathStep === 2 && r.deathCause === 'spike', `deathStep=${r.deathStep} cause=${r.deathCause}`);
}
{
// A wall immediately to the left -> the flick is a no-op, not a death.
const s = newState(L({ monsters: [[1, 0], [4, 4]], walls: [[0, 0]] }));
const r = computeSlide(s, 0, 'left');
check('a blocked flick is a no-op', r.maxSteps === 0 && r.deathStep === 0);
check('slide() reports a no-op without mutating', slide(s, 0, 'left').moved === false);
check('a no-op flick is not offered as a legal move',
!legalMoves(s).some((m) => m.idx === 0 && m.dir === 'left'));
check('a fatal flick is not offered as a legal move',
!legalMoves(s).some((m) => m.idx === 0 && m.dir === 'up'));
}
{
// Two monsters in a row; A slides into B and sticks.
const s = newState(L({ monsters: [[0, 0], [3, 0]] }));
const out = slide(s, 0, 'right');
check('a blob stops against another blob and merges',
out.merged === true && s.blobs.length === 1 && s.blobs[0].cells.length === 2);
check('merged cells keep their original monster index',
new Set(s.blobs[0].cells.map((c) => c[2])).size === 2);
check('merging every monster wins the level', s.state === 'won');
}
{
// Transitive merge: C is already adjacent to B, A arrives -> one blob of 3.
const s = newState(L({ monsters: [[0, 0], [3, 0], [3, 1]] }));
check('monsters that start adjacent merge at newState', s.blobs.length === 2);
slide(s, blobAt(s, 0, 0), 'right');
check('merges are transitive', s.blobs.length === 1 && s.blobs[0].cells.length === 3);
}
{
// A merged blob moves rigidly: both cells travel the same distance.
const s = newState(L({ monsters: [[1, 1], [2, 1]], walls: [[1, 4]] }));
const idx = blobAt(s, 1, 1);
slide(s, idx, 'down');
const ys = s.blobs[0].cells.map((c) => c[1]);
check('a merged blob slides as one rigid piece',
ys.every((y) => y === 3) && s.blobs[0].cells.length === 2, `ys=${ys}`);
}
{
const s = newState(L({ monsters: [[2, 2]] }));
check('a level that starts merged is already won', s.state === 'won' && s.blobs.length === 1);
check('solve() returns 0 moves for an already-won level', solve(s).moves === 0);
}
{
// repCell is the top-left-most cell, stable regardless of cell order.
const blob = { cells: [[3, 1, 0], [2, 1, 1], [2, 0, 2]] };
const rc = repCell(blob);
check('repCell picks the top-left-most cell', rc[0] === 2 && rc[1] === 0);
}
// ── 4. State identity: the mutable board and blob flags ──────────────────────
//
// Waves 1-2 add slime, ice, bricks and crates as layers on state.board, and an
// `asleep` flag on blobs. Both are mutable, so both MUST change stateKey — if
// they do not, the BFS solver merges genuinely different positions and reports
// a par lower than the level can actually be solved in.
{
const s = newState(L({ monsters: [[0, 0], [4, 4]] }));
check('a level with no elements has an empty board key', boardKey(s.board) === '');
const before = stateKey(s);
const withIce = cloneState(s);
withIce.board.ice = new Set(['2,2']);
check('a new board layer changes the state key', stateKey(withIce) !== before);
const sameIce = cloneState(s);
sameIce.board.ice = new Set(['2,2']);
check('equal board layers hash identically', stateKey(sameIce) === stateKey(withIce));
const otherIce = cloneState(s);
otherIce.board.ice = new Set(['3,3']);
check('different board layers hash differently', stateKey(otherIce) !== stateKey(withIce));
const orderA = cloneState(s); orderA.board.slime = new Set(['1,1', '2,2']);
const orderB = cloneState(s); orderB.board.slime = new Set(['2,2', '1,1']);
check('board layers hash independently of insertion order', stateKey(orderA) === stateKey(orderB));
const emptied = cloneState(s);
emptied.board.slime = new Set();
check('an empty layer leaves the key unchanged', stateKey(emptied) === before);
const mapLayer = cloneState(s);
mapLayer.board.crates = new Map([['1,1', 'a']]);
const mapLayer2 = cloneState(s);
mapLayer2.board.crates = new Map([['1,1', 'b']]);
check('Map layers hash by entry, not identity', stateKey(mapLayer) !== stateKey(mapLayer2));
// Layers must be COPIED by cloneState, or the solver's search would write
// through every state it has already visited.
const parent = cloneState(s);
parent.board.slime = new Set(['1,1']);
const child = cloneState(parent);
child.board.slime.add('2,2');
check('cloneState deep-copies board layers',
parent.board.slime.size === 1 && child.board.slime.size === 2);
const copied = cloneBoard(parent.board);
check('cloneBoard copies Sets rather than sharing them', copied.slime !== parent.board.slime);
}
{
// Per-blob asleep flags: identity + merge semantics (waking is Wave 1's
// gating, but the state model has to carry it correctly first).
const s = newState(L({ monsters: [[0, 0], [3, 0]], sleepers: [[3, 0]] }));
const sleeperIdx = blobAt(s, 3, 0);
check('a level can declare a sleeping monster', s.blobs[sleeperIdx].asleep === true);
check('other monsters stay awake', s.blobs[blobAt(s, 0, 0)].asleep !== true);
const awakeCopy = cloneState(s);
delete awakeCopy.blobs[sleeperIdx].asleep;
check('an asleep blob and an awake one hash differently', stateKey(awakeCopy) !== stateKey(s));
check('cloneState preserves the asleep flag', cloneState(s).blobs[sleeperIdx].asleep === true);
slide(s, blobAt(s, 0, 0), 'right');
check('merging an awake blob into a sleeper wakes the whole blob',
s.blobs.length === 1 && s.blobs[0].asleep !== true);
const both = newState(L({ monsters: [[0, 0], [1, 0]], sleepers: [[0, 0], [1, 0]] }));
check('a blob merged from sleepers only stays asleep', both.blobs[0].asleep === true);
}
// ── 4b. Wave 1 mechanics ─────────────────────────────────────────────────────
// Sleeping monsters: cannot be flicked, wake by being merged into.
{
const s = newState(L({ monsters: [[0, 0], [3, 0]], sleepers: [[0, 0]] }));
const sleeper = blobAt(s, 0, 0);
const awake = blobAt(s, 3, 0);
check('a sleeper offers no legal moves', !legalMoves(s).some((m) => m.idx === sleeper));
check('the awake monster still has moves', legalMoves(s).some((m) => m.idx === awake));
check('flicking a sleeper is a no-op', slide(s, sleeper, 'right').moved === false);
check('a refused flick leaves the sleeper where it was',
s.blobs[sleeper].cells[0][0] === 0 && s.blobs[sleeper].cells[0][1] === 0);
slide(s, awake, 'left');
check('sliding into a sleeper sticks and wakes the merged blob',
s.blobs.length === 1 && s.blobs[0].asleep !== true && s.blobs[0].cells.length === 2);
}
{
const s = newState(L({ monsters: [[0, 0], [3, 0]], sleepers: [[0, 0], [3, 0]] }));
check('a board of only sleepers has no legal moves', legalMoves(s).length === 0);
check('a board of only sleepers is unsolvable', solve(s, { maxStates: 5000 }).moves === -1);
}
// Ice blocks: stop a slide like a wall, then shatter.
{
const s = newState(L({ monsters: [[0, 0], [4, 4]], ice: [[3, 0]] }));
const r = computeSlide(s, 0, 'right');
check('ice blocks a slide like a wall', r.maxSteps === 2 && r.deathStep === 0);
check('the struck ice is reported', r.hitIce.length === 1 && r.hitIce[0] === '3,0');
slide(s, 0, 'right');
check('the blob comes to rest in front of the ice it broke',
s.blobs[blobAt(s, 2, 0)].cells[0][0] === 2);
check('struck ice is gone from the board', !(s.board.ice?.has('3,0')));
// With the ice gone the very same flick now runs off the open edge.
const again = computeSlide(s, blobAt(s, 2, 0), 'right');
check('ice is a one-use shield — the next flick runs off the edge',
again.deathStep > 0 && again.deathCause === 'edge');
}
{
// Ice you are already touching: the flick shatters it without moving.
const s = newState(L({ monsters: [[0, 0], [4, 4]], ice: [[1, 0]] }));
const r = computeSlide(s, 0, 'right');
check('a blob against ice cannot move', r.maxSteps === 0);
const plan = planFlick(s, 0, 'right');
check('a shatter-only flick is still a legal move', plan.legal === true && plan.parts.length === 0);
check('a shatter-only flick is offered by legalMoves',
legalMoves(s).some((m) => m.idx === 0 && m.dir === 'right'));
const out = applyPlan(s, plan);
check('applying a shatter-only flick counts as a move that travels 0 cells',
out.moved === true && out.steps === 0);
check('the touched ice shattered', !(s.board.ice?.has('1,0')));
check('the blob did not move', s.blobs[blobAt(s, 0, 0)] !== undefined);
}
{
// A 2-cell blob striking two ice blocks at once breaks both.
const s = newState(L({ monsters: [[0, 0], [0, 1], [4, 4]], ice: [[2, 0], [2, 1]] }));
const idx = blobAt(s, 0, 0);
check('the two starting monsters merged into one 2-cell blob', s.blobs[idx].cells.length === 2);
const r = computeSlide(s, idx, 'right');
check('a wide blob stops one cell short of the ice wall', r.maxSteps === 1);
check('both struck ice blocks are reported', new Set(r.hitIce).size === 2);
slide(s, idx, 'right');
check('both struck ice blocks shattered', !s.board.ice || s.board.ice.size === 0);
}
// Slime trails: green monsters lay them, everyone stops ON them.
{
const s = newState(L({ monsters: [[0, 0], [4, 4]], green: [[0, 0]], walls: [[3, 0]] }));
check('a green monster is flagged green', s.blobs[blobAt(s, 0, 0)].green === true);
const out = slide(s, blobAt(s, 0, 0), 'right');
check('a green blob slides normally over clean floor', out.steps === 2);
check('the trail covers the start cell and every cell travelled',
s.board.slime?.size === 3 && ['0,0', '1,0', '2,0'].every((k) => s.board.slime.has(k)));
check('a green blob is not stopped by its own fresh trail', blobAt(s, 2, 0) >= 0);
}
{
// Slime laid last move stops a blob that would otherwise fall off the table.
const s = newState(L({ monsters: [[0, 0], [1, 4]], green: [[0, 0]], walls: [[3, 0]] }));
slide(s, blobAt(s, 0, 0), 'right'); // slimes (0,0) (1,0) (2,0)
const other = blobAt(s, 1, 4);
const bare = computeSlide(s, other, 'up');
check('a blob stops ON the slimed cell, not before it',
bare.maxSteps === 4 && bare.stopReason === 'stop-on', `steps=${bare.maxSteps} reason=${bare.stopReason}`);
check('stopping on slime is not fatal', bare.deathStep === 0);
slide(s, other, 'up');
check('the slimed blob rests on the slime', blobAt(s, 1, 0) >= 0);
}
{
// Pre-existing slime (declared by the level) stops a green blob too.
const s = newState(L({ monsters: [[0, 0], [4, 4]], green: [[0, 0]], slime: [[2, 0]], walls: [[4, 0]] }));
const r = computeSlide(s, blobAt(s, 0, 0), 'right');
check('slime stops the green monster that did not lay it',
r.maxSteps === 2 && r.stopReason === 'stop-on');
slide(s, blobAt(s, 0, 0), 'right');
check('a green blob extends the trail it stopped on', s.board.slime.size === 3);
}
{
const s = newState(L({ monsters: [[0, 0], [3, 0]], green: [[0, 0]] }));
slide(s, blobAt(s, 0, 0), 'right');
check('a blob merged with a green monster is green', s.blobs[0].green === true);
}
// Hypno goos: one flick moves the whole hive.
{
const s = newState(L({ monsters: [[0, 0], [0, 2]], hypno: [[0, 0], [0, 2]], walls: [[3, 0], [3, 2]] }));
slide(s, blobAt(s, 0, 0), 'right');
check('flicking one hypno slides every hypno the same way',
blobAt(s, 2, 0) >= 0 && blobAt(s, 2, 2) >= 0);
}
{
// The hive resolves leader-first, so the follower can close the gap and stick.
const s = newState(L({ monsters: [[0, 0], [2, 0]], hypno: [[0, 0], [2, 0]], walls: [[4, 0]] }));
slide(s, blobAt(s, 0, 0), 'right');
check('hypno blobs resolve furthest-first and merge behind the leader',
s.blobs.length === 1 && s.blobs[0].cells.length === 2
&& blobAt(s, 2, 0) === 0 && blobAt(s, 3, 0) === 0);
check('a blob merged from hypno parts stays hypno', s.blobs[0].hypno === true);
}
{
const s = newState(L({ monsters: [[0, 0], [0, 2]], hypno: [[0, 0]], walls: [[3, 0], [3, 2]] }));
slide(s, blobAt(s, 0, 0), 'right');
check('a hypno flick leaves ordinary monsters alone',
blobAt(s, 2, 0) >= 0 && blobAt(s, 0, 2) >= 0);
const s2 = newState(L({ monsters: [[0, 0], [0, 2]], hypno: [[0, 0]], walls: [[3, 0], [3, 2]] }));
slide(s2, blobAt(s2, 0, 2), 'right');
check('flicking an ordinary monster leaves the hive alone',
blobAt(s2, 2, 2) >= 0 && blobAt(s2, 0, 0) >= 0);
}
{
// Only 'right' is survivable here: every other direction runs a hive member
// off the table, and the hive is offered once per direction, not per blob.
const s = newState(L({ monsters: [[0, 0], [0, 2]], hypno: [[0, 0], [0, 2]], walls: [[3, 0], [3, 2]] }));
const moves = legalMoves(s);
check('the hive offers one move per direction, fatal ones excluded',
moves.length === 1 && moves[0].dir === 'right', `moves=${moves.map((m) => m.dir).join(',')}`);
check('a hive move that would kill a member is fatal when forced',
planFlick(s, 0, 'left').dead === true);
}
// ── 4c. Wave 2 mechanics ─────────────────────────────────────────────────────
// Springs: bounce the blob back the way it came and keep it sliding.
{
// (2,0) wall · blob starts (2,2) · spring (2,4)
// Down into the spring, back up past the start, stopped by the wall at (2,0).
const s = newState(L({ monsters: [[2, 2], [0, 0]], springs: [[2, 4]], walls: [[2, 0]] }));
const r = computeSlide(s, blobAt(s, 2, 2), 'down', { trace: true });
check('a spring reverses the slide instead of stopping it',
r.offset[0] === 0 && r.offset[1] === -1, `offset=${r.offset}`);
check('the bounce counts only the cells actually travelled', r.maxSteps === 3, `steps=${r.maxSteps}`);
check('the path bends rather than running straight', (r.path ?? []).length === 3);
slide(s, blobAt(s, 2, 2), 'down');
check('the blob comes to rest past where it started', blobAt(s, 2, 1) >= 0);
}
{
// Two springs facing each other: the first is spent by the time the blob
// comes back to it, so it blocks — which is what makes the walk terminate.
const s = newState(L({ monsters: [[2, 2], [0, 0]], springs: [[2, 0], [2, 4]] }));
const r = computeSlide(s, blobAt(s, 2, 2), 'down');
check('a spent spring blocks instead of bouncing again',
r.offset[1] === 1 && r.stopReason === 'blocked', `offset=${r.offset} reason=${r.stopReason}`);
check('the walk between two springs still terminates', r.maxSteps < 12);
}
{
// A spring can just as easily bounce you off the far edge.
const s = newState(L({ monsters: [[2, 2], [0, 0]], springs: [[2, 4]] }));
const r = computeSlide(s, blobAt(s, 2, 2), 'down');
check('a bounce can throw a blob off the open edge', r.deathStep > 0 && r.deathCause === 'edge');
}
// Tunnels: teleport the blob, then keep it sliding.
{
const s = newState(L({
cols: 6, rows: 6, monsters: [[0, 0], [3, 3]], tunnels: [[1, 0, 4, 0]], walls: [[5, 0]],
}));
const idx = blobAt(s, 0, 0);
const r = computeSlide(s, idx, 'right', { trace: true });
check('a blob entering a tunnel comes out of its partner',
r.offset[0] === 4 && r.offset[1] === 0, `offset=${r.offset}`);
check('the path marks the teleport as a jump', (r.path ?? []).some((p) => p[2] === 1));
slide(s, idx, 'right');
check('the blob rests where the far mouth led it', blobAt(s, 4, 0) >= 0);
}
{
// A whole multi-cell blob goes through rigidly ("Tunnel Master").
const s = newState(L({
cols: 6, rows: 6, monsters: [[0, 0], [0, 1], [3, 3]], tunnels: [[1, 0, 4, 0]], walls: [[5, 0], [5, 1]],
}));
const idx = blobAt(s, 0, 0);
check('the two monsters merged before the trip', s.blobs[idx].cells.length === 2);
slide(s, idx, 'right');
check('a 2-cell blob teleports rigidly and stays whole',
blobAt(s, 4, 0) >= 0 && blobAt(s, 4, 1) === blobAt(s, 4, 0)
&& s.blobs[blobAt(s, 4, 0)].cells.length === 2);
}
{
// Blocked exit: the tunnel is inert and the blob slides straight past it.
const s = newState(L({
cols: 6, rows: 6, monsters: [[0, 0], [3, 3]], tunnels: [[1, 0, 4, 0]], walls: [[4, 0]],
}));
slide(s, blobAt(s, 0, 0), 'right');
check('a teleport onto something solid simply does not happen', blobAt(s, 3, 0) >= 0);
}
// Buttons and retractable bricks.
{
const s = newState(L({
cols: 6, rows: 6, monsters: [[0, 0], [0, 2], [5, 5]],
bricks: [[3, 0, 1]], buttons: [[1, 2, 1]], walls: [[4, 2], [5, 0]],
}));
check('bricks start raised', s.board.bricksUp.has(1));
slide(s, blobAt(s, 0, 0), 'right');
check('a raised brick blocks like a wall', blobAt(s, 2, 0) >= 0);
slide(s, blobAt(s, 0, 2), 'right');
check('sliding over a button lowers its brick group', !s.board.bricksUp.has(1));
slide(s, blobAt(s, 2, 0), 'right');
check('with the bricks down the way is open', blobAt(s, 4, 0) >= 0);
}
{
// The button is pressed mid-slide, so the brick it raises can stop the very
// same slide.
const s = newState(L({
cols: 6, rows: 6, monsters: [[0, 2], [5, 5]],
bricks: [[3, 2, 1]], bricksDown: [1], buttons: [[1, 2, 1]],
}));
check('a level can start with its bricks lowered', !s.board.bricksUp.has(1));
slide(s, blobAt(s, 0, 2), 'right');
check('a button pressed mid-slide raises bricks in time to stop that slide',
s.board.bricksUp.has(1) && blobAt(s, 2, 2) >= 0);
}
{
// Bricks must not rise through a monster: that toggle jams.
const s = newState(L({
cols: 6, rows: 6, monsters: [[0, 2], [3, 2]],
bricks: [[3, 2, 1]], bricksDown: [1], buttons: [[1, 2, 1]],
}));
slide(s, blobAt(s, 0, 2), 'right');
check('a brick cannot rise through a monster — the toggle jams',
!s.board.bricksUp.has(1));
}
// Powerlifters and crates.
{
const s = newState(L({
cols: 6, rows: 6, monsters: [[0, 0], [5, 5]], powerlifters: [[0, 0]],
crates: [[2, 0]], walls: [[5, 0]],
}));
const idx = blobAt(s, 0, 0);
check('a powerlifter is flagged', s.blobs[idx].lifter === true);
slide(s, idx, 'right');
check('a powerlifter shoves the crate along ahead of it',
s.board.crates.has('4,0') && !s.board.crates.has('2,0'), `crates=${[...(s.board.crates ?? [])]}`);
check('the pusher stops when the crate can go no further', blobAt(s, 3, 0) >= 0);
}
{
// The same level without the powerlifter flag: the crate is just a wall.
const s = newState(L({
cols: 6, rows: 6, monsters: [[0, 0], [5, 5]], crates: [[2, 0]], walls: [[5, 0]],
}));
slide(s, blobAt(s, 0, 0), 'right');
check('an ordinary monster is stopped dead by a crate',
blobAt(s, 1, 0) >= 0 && s.board.crates.has('2,0'));
}
{
// No crate trains: a crate backed by another crate cannot move.
const s = newState(L({
cols: 6, rows: 6, monsters: [[0, 0], [5, 5]], powerlifters: [[0, 0]], crates: [[2, 0], [3, 0]],
}));
slide(s, blobAt(s, 0, 0), 'right');
check('a crate backed by another crate will not budge',
blobAt(s, 1, 0) >= 0 && s.board.crates.has('2,0') && s.board.crates.has('3,0'));
}
{
// A crate shoved past the rim is gone. (The pusher usually follows it off —
// that is the walk's business, this checks only the crate bookkeeping.)
const s = newState(L({
cols: 6, rows: 6, monsters: [[0, 0], [3, 3]], powerlifters: [[0, 0]], crates: [[5, 0]],
}));
const r = computeSlide(s, blobAt(s, 0, 0), 'right');
check('a crate pushed off the table is removed from the board',
!r.board.crates?.has('5,0') && !r.board.crates?.has('6,0'));
}
// The scene animates each part along its `path` and never slices it by step
// count (a teleport adds a point without advancing a step). So every path must
// end exactly on the part's offset, or the blob would visibly land in the wrong
// cell before snapping.
{
const fixtures = [
['plain slide', L({ monsters: [[0, 0], [3, 0]] }), 'right'],
['spring bounce', L({ monsters: [[2, 2], [0, 0]], springs: [[2, 4]], walls: [[2, 0]] }), 'down'],
['double bounce', L({ monsters: [[2, 2], [0, 0]], springs: [[2, 0], [2, 4]] }), 'down'],
['teleport', L({ cols: 6, rows: 6, monsters: [[0, 0], [3, 3]], tunnels: [[1, 0, 4, 0]], walls: [[5, 0]] }), 'right'],
['crate push', L({ cols: 6, rows: 6, monsters: [[0, 0], [5, 5]], powerlifters: [[0, 0]], crates: [[2, 0]], walls: [[5, 0]] }), 'right'],
['fatal edge', L({ monsters: [[2, 2], [0, 0]] }), 'down'],
];
let ok = 0;
for (const [name, def, dir] of fixtures) {
const st = newState(def);
const plan = planFlick(st, blobAt(st, def.monsters[0][0], def.monsters[0][1]), dir);
const part = plan.parts[0];
if (!part) { check(`${name}: the fixture produces a moving part`, false); continue; }
const last = part.path?.[part.path.length - 1];
if (last && last[0] === part.offset[0] && last[1] === part.offset[1]) ok += 1;
else check(`${name}: path ends on the part's offset`, false, `path end=${last} offset=${part.offset}`);
check(`${name}: the path is at least as long as the steps taken`,
(part.path?.length ?? 0) >= part.steps);
}
check('every route ends where the walk says it does', ok === fixtures.length, `${ok}/${fixtures.length}`);
}
// planFlick / applyPlan: the scene animates a plan, so it must never disagree
// with what slide() would have done, and planning must not mutate.
{
const def = L({ monsters: [[0, 0], [3, 0], [0, 3]], green: [[0, 0]], ice: [[3, 3]], walls: [[4, 0]] });
for (const dir of DIR_LIST) {
const a = newState(def);
const b = newState(def);
const before = stateKey(a);
const plan = planFlick(a, 0, dir);
check(`planFlick(${dir}) does not mutate the state`, stateKey(a) === before);
const viaPlan = applyPlan(a, plan);
const viaSlide = slide(b, 0, dir);
check(`plan+apply matches slide() for ${dir}`,
stateKey(a) === stateKey(b) && viaPlan.moved === viaSlide.moved && viaPlan.dead === viaSlide.dead);
}
}
// ── 5. Solver ────────────────────────────────────────────────────────────────
{
// One flick away from a win.
const s = newState(L({ monsters: [[0, 0], [3, 0]] }));
const res = solve(s);
check('solver finds the one-move win', res.moves === 1 && res.path.length === 1);
check('solver returns the final footprint', res.footprint?.length === 2);
}
{
// Unsolvable: two monsters alone on an open board — any flick falls off.
const s = newState(L({ monsters: [[0, 0], [4, 4]] }));
check('solver reports unsolvable boards', solve(s, { maxStates: 20000 }).moves === -1);
}
{
// The solver's answer is a true minimum: no shorter path exists by brute
// force over the same move set.
const def = L({ cols: 5, rows: 5, monsters: [[0, 0], [4, 0], [0, 4]], walls: [[2, 2], [4, 4], [1, 3]] });
const res = solve(newState(def));
if (res.moves > 0) {
const shorter = (() => {
const seen = new Set();
let frontier = [newState(def)];
for (let d = 1; d < res.moves; d++) {
const next = [];
for (const st of frontier) {
for (const mv of legalMoves(st)) {
const ns = cloneState(st);
slide(ns, mv.idx, mv.dir);
if (ns.blobs.length === 1) return true;
const k = stateKey(ns);
if (seen.has(k)) continue;
seen.add(k);
next.push(ns);
}
}
frontier = next;
}
return false;
})();
check('solver par is a true minimum (no shorter path exists)', !shorter, `par=${res.moves}`);
} else {
check('the minimality fixture is solvable', false, `moves=${res.moves}`);
}
}
{
// DIRS / DIR_LIST agree, and every direction is reachable from the map.
check('DIR_LIST covers exactly the four directions',
DIR_LIST.length === 4 && DIR_LIST.every((d) => Array.isArray(DIRS[d])));
}
// ── Summary ──────────────────────────────────────────────────────────────────
console.log(`[verify] ${passes} passed, ${failures} failed`);
if (failures > 0) process.exit(1);