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
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
|
@ -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** (5–6 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 1–40 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 1–2 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 1–40 are byte-identical to before; 41–60 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 3–5 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 1–60 byte-identical; 61–80 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 1–40 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 | 1–15 | sleepers, ice |
|
||||
| 2 | Kitchen Counter | 16–30 | slime, hypno |
|
||||
| 3 | The Pantry | 31–45 | springs, tunnels |
|
||||
| 4 | Dining Room | 46–60 | buttons+bricks, powerlifters+crates |
|
||||
| 5 | Midnight Feast | 61–75 | 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)` (0–3) 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 1–2 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 3–5 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)` (0–3), `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).
|
||||
|
|
@ -77,6 +77,11 @@ export const MANIFEST = {
|
|||
image('catan-pirate', 'assets/images/catan-pirate.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')],
|
||||
bejeweled: [image('bg-bejeweled', 'assets/images/background-bejeweledblitz.png')],
|
||||
dominion: [
|
||||
|
|
@ -122,9 +127,18 @@ export const MANIFEST = {
|
|||
// Card art: frame 0 = Chance, frame 1 = Community Chest, at 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,
|
||||
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: [
|
||||
image('spireclimb-act1', 'assets/images/spireclimb-act1.png'),
|
||||
image('spireclimb-act2', 'assets/images/spireclimb-act2.png'),
|
||||
|
|
|
|||
|
|
@ -160,13 +160,10 @@ export default class BloxorzGame extends Phaser.Scene {
|
|||
this.clearLayer();
|
||||
const cx = GAME_WIDTH / 2;
|
||||
|
||||
const title = this.add.text(cx, 84, 'BLOXORZ', {
|
||||
fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5);
|
||||
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.textures.exists('bloxorz-bg-menu')) {
|
||||
this.layer.add(this.add.image(cx, GAME_HEIGHT / 2, 'bloxorz-bg-menu')
|
||||
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT));
|
||||
}
|
||||
|
||||
if (!this.bank.length) {
|
||||
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 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,
|
||||
}).setOrigin(0.5);
|
||||
this.layer.add(prog);
|
||||
|
|
@ -189,7 +186,7 @@ export default class BloxorzGame extends Phaser.Scene {
|
|||
const GAP = 14;
|
||||
const gridW = COLS * SIZE + (COLS - 1) * GAP;
|
||||
const left = cx - gridW / 2 + SIZE / 2;
|
||||
const top = 268;
|
||||
const top = 390;
|
||||
|
||||
this.bank.forEach((p, i) => {
|
||||
const col = i % COLS;
|
||||
|
|
@ -284,6 +281,7 @@ export default class BloxorzGame extends Phaser.Scene {
|
|||
|
||||
this.clearLayer();
|
||||
this._computeProjection();
|
||||
this._drawBackground(level);
|
||||
this.drawHud();
|
||||
this.tileGfx = this.add.graphics().setDepth(D.board);
|
||||
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) {
|
||||
const { sx, sy } = project(p, this.proj);
|
||||
return [sx, sy];
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -2,12 +2,13 @@ import * as Phaser from 'phaser';
|
|||
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||||
import { Button } from '../../ui/Button.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 {
|
||||
LAYOUTS, LAYOUT_ORDER, layoutBounds,
|
||||
newGame, isFree, canMatch, removePair, findMoves, reshuffleRemaining,
|
||||
} from './MahjongLogic.js';
|
||||
import MahjongFx, { JUICE, tintForFace } from './MahjongFx.js';
|
||||
|
||||
// Deep-green felt with ivory tiles — the classic mahjong table look.
|
||||
const FELT = 0x0e2a1c;
|
||||
|
|
@ -27,7 +28,29 @@ const PREVIEW_Z = [0x9c8f6e, 0xb3a47e, 0xcab98e, 0xe0cf9f, 0xf6e6b0];
|
|||
const LABEL_W = 128;
|
||||
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 {
|
||||
constructor() { super('MahjongMatchGame'); }
|
||||
|
|
@ -48,6 +71,12 @@ export default class MahjongMatchGame extends Phaser.Scene {
|
|||
this.tilesText = null;
|
||||
this.movesText = 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() {
|
||||
|
|
@ -57,7 +86,8 @@ export default class MahjongMatchGame extends Phaser.Scene {
|
|||
} catch (_) { /* optional */ }
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
@ -66,6 +96,9 @@ export default class MahjongMatchGame extends Phaser.Scene {
|
|||
if (this.hintTimer) { this.hintTimer.remove(false); this.hintTimer = null; }
|
||||
if (this.overlay) { this.overlay.destroy(true); this.overlay = null; }
|
||||
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.selected = null;
|
||||
this.hintPair = null;
|
||||
|
|
@ -82,18 +115,19 @@ export default class MahjongMatchGame extends Phaser.Scene {
|
|||
this.clearLayer();
|
||||
const cx = GAME_WIDTH / 2;
|
||||
|
||||
const title = this.add.text(cx, 100, 'MAHJONG MATCH', {
|
||||
fontFamily: 'Righteous', fontSize: '78px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5);
|
||||
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.', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5);
|
||||
this.layer.add([title, sub]);
|
||||
// The artwork carries the title, so nothing is drawn over the top third.
|
||||
if (this.textures.exists('bg-mahjongmatch-menu')) {
|
||||
this.layer.add(this.add.image(cx, GAME_HEIGHT / 2, 'bg-mahjongmatch-menu')
|
||||
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT));
|
||||
}
|
||||
|
||||
// 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_H = 310;
|
||||
const CARD_H = 250;
|
||||
const GAP_X = 60;
|
||||
const ROW_Y = [420, 770];
|
||||
const ROW_Y = [525, 825];
|
||||
const totalW = 3 * CARD_W + 2 * GAP_X;
|
||||
const left = cx - totalW / 2 + CARD_W / 2;
|
||||
|
||||
|
|
@ -107,17 +141,17 @@ export default class MahjongMatchGame extends Phaser.Scene {
|
|||
this.layer.add(card);
|
||||
|
||||
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);
|
||||
|
||||
const name = this.add.text(x, y + 52, layout.name, {
|
||||
fontFamily: 'Righteous', fontSize: '38px', color: COLORS.textHex,
|
||||
const name = this.add.text(x, y + 40, layout.name, {
|
||||
fontFamily: 'Righteous', fontSize: '36px', color: COLORS.textHex,
|
||||
}).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,
|
||||
}).setOrigin(0.5);
|
||||
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,
|
||||
}).setOrigin(0.5);
|
||||
this.layer.add([name, info, bestLbl]);
|
||||
|
|
@ -176,12 +210,23 @@ export default class MahjongMatchGame extends Phaser.Scene {
|
|||
this.overlayUp = false;
|
||||
|
||||
this.clearLayer();
|
||||
this._drawBackground();
|
||||
this._computeLayout();
|
||||
this._buildTiles();
|
||||
this._drawHud();
|
||||
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.
|
||||
_computeLayout() {
|
||||
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;
|
||||
this.originX = LEFT + (availW - visW) / 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) {
|
||||
|
|
@ -213,6 +261,14 @@ export default class MahjongMatchGame extends Phaser.Scene {
|
|||
|
||||
_buildTiles() {
|
||||
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
|
||||
.map((_, i) => i)
|
||||
.filter((i) => this.g.alive[i])
|
||||
|
|
@ -230,8 +286,7 @@ export default class MahjongMatchGame extends Phaser.Scene {
|
|||
let label = null;
|
||||
const face = this.g.faces[i];
|
||||
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(scale);
|
||||
label = this.add.image(0, 0, face.label).setScale(this.labelScale);
|
||||
container.add(label);
|
||||
}
|
||||
|
||||
|
|
@ -297,24 +352,28 @@ export default class MahjongMatchGame extends Phaser.Scene {
|
|||
const cx = GAME_WIDTH / 2;
|
||||
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', {
|
||||
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, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0, 0.5).setDepth(D.ui);
|
||||
this.layer.add([title, diff]);
|
||||
}).setOrigin(0, 0.5);
|
||||
this.hudLayer.add([title, diff]);
|
||||
|
||||
this.tilesText = this.add.text(cx, 56, '', {
|
||||
fontFamily: 'Righteous', fontSize: '38px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(D.ui);
|
||||
}).setOrigin(0.5);
|
||||
this.movesText = this.add.text(cx, 100, '', {
|
||||
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, '', {
|
||||
fontFamily: 'Righteous', fontSize: '34px', color: COLORS.textHex,
|
||||
}).setOrigin(1, 0.5).setDepth(D.ui);
|
||||
this.layer.add([this.tilesText, this.movesText, this.timerText]);
|
||||
}).setOrigin(1, 0.5);
|
||||
this.hudLayer.add([this.tilesText, this.movesText, this.timerText]);
|
||||
|
||||
const stripCx = 150;
|
||||
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;
|
||||
const layouts = new Button(this, stripCx, btnY, 'Layouts', () => this.showLayoutSelect(),
|
||||
{ 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();
|
||||
}
|
||||
|
|
@ -372,16 +431,23 @@ export default class MahjongMatchGame extends Phaser.Scene {
|
|||
const a = this.selected;
|
||||
this.selected = null;
|
||||
this._clearHint();
|
||||
const face = this.g.faces[a];
|
||||
const freeBefore = this._freeSet();
|
||||
if (!removePair(this.g, a, i)) return;
|
||||
playSound(this, SFX.CARD_PLACE);
|
||||
this._animateRemoval(a);
|
||||
this._animateRemoval(i);
|
||||
this._celebrateMatch(a, i, face, freeBefore);
|
||||
this._refreshTiles();
|
||||
this._updateHud();
|
||||
if (this.g.state === 'won') {
|
||||
this._onWin();
|
||||
} else if (findMoves(this.g).length === 0) {
|
||||
this._onStuck(true);
|
||||
|
||||
// Let the last pair actually break before the overlay covers it. The
|
||||
// clock stops now, though, so the delay can't cost the player a second.
|
||||
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;
|
||||
}
|
||||
|
|
@ -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];
|
||||
if (!o) return;
|
||||
this.tweens.add({
|
||||
targets: o.container,
|
||||
alpha: 0, y: o.container.y - 26, scaleX: 0.7, scaleY: 0.7,
|
||||
duration: 230, ease: 'Quad.easeIn',
|
||||
onComplete: () => o.container.setVisible(false),
|
||||
const { x, y } = o.container;
|
||||
o.container.setVisible(false);
|
||||
this.fx.shatter(x, y, this.g.faces[i].label, this.labelScale, tint);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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;
|
||||
|
||||
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() {
|
||||
if (this.timerEvent) { this.timerEvent.remove(false); this.timerEvent = null; }
|
||||
playSound(this, SFX.VICTORY_SHORT);
|
||||
playSoundEx(this, SFX.FIREWORK, { volume: 0.7 });
|
||||
|
||||
const lsKey = `mahjongmatch-best-${this.layoutKey}`;
|
||||
const prev = this._bestFor(this.layoutKey);
|
||||
|
|
|
|||
|
|
@ -13,10 +13,51 @@
|
|||
// SPIKES are equally deadly: if a blob's slide path crosses a spike it dies
|
||||
// (the run fails and the level must be restarted).
|
||||
//
|
||||
// A blob: { cells: [[x,y,m], ...] } (rigid; moves as a unit). The third
|
||||
// element m is the index of the original monster occupying that cell; it rides
|
||||
// along through slides and merges so the renderer can keep each monster's
|
||||
// colour and face inside a merged blob. All rules ignore it.
|
||||
// A blob: { cells: [[x,y,m], ...], asleep?, green?, hypno?, lifter? } (rigid;
|
||||
// moves as a unit). The third element m is the index of the original monster
|
||||
// occupying that cell; it rides along through slides and merges so the renderer
|
||||
// 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 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]);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const occ = new Map();
|
||||
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();
|
||||
state.blobs.forEach((b, i) => {
|
||||
const r = find(i);
|
||||
if (!groups.has(r)) groups.set(r, []);
|
||||
groups.get(r).push(...b.cells);
|
||||
if (!groups.has(r)) groups.set(r, { cells: [], asleep: true, green: false, hypno: false, lifter: false });
|
||||
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) {
|
||||
for (const [sx, sy] of state.stars) {
|
||||
if (!state.collected.has(key(sx, sy)) && occupiedAt(state, sx, sy)) {
|
||||
state.collected.add(key(sx, sy));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function starsCollected(state) {
|
||||
return state.collected.size;
|
||||
// Blobs carry flags only when set, so a plain monster stays a bare { cells }.
|
||||
function makeBlob({ cells, asleep, green, hypno, lifter }) {
|
||||
const b = { cells };
|
||||
if (asleep) b.asleep = true;
|
||||
if (green) b.green = true;
|
||||
if (hypno) b.hypno = true;
|
||||
if (lifter) b.lifter = true;
|
||||
return b;
|
||||
}
|
||||
|
||||
// How many of the given target cells are currently covered by a monster.
|
||||
|
|
@ -91,45 +194,365 @@ export function targetsCovered(state, targets) {
|
|||
return n;
|
||||
}
|
||||
|
||||
// How far blob `idx` can slide in `dir`, and whether the slide is fatal.
|
||||
// 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
|
||||
// mutate). { maxSteps, deathStep, deathCause } — deathStep>0 means fatal,
|
||||
// deathCause is 'spike' | 'edge' | null.
|
||||
export function computeSlide(state, idx, dir) {
|
||||
const [dx, dy] = DIRS[dir];
|
||||
// ── Sliding ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Walk blob `idx` one direction until it stops or dies. PURE with respect to
|
||||
// `state`: board changes the walk causes (ice shattered, slime laid, bricks
|
||||
// toggled, crates pushed) are written into a COPY, returned as `board`, and only
|
||||
// installed by applyPlan(). Levels with no elements never allocate that copy.
|
||||
//
|
||||
// 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 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();
|
||||
state.blobs.forEach((b, i) => {
|
||||
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
|
||||
// nothing (walls and blobs only exist on the board), so an unblocked blob
|
||||
// runs clean off the table — the death scan below catches that.
|
||||
let maxSteps = 0;
|
||||
const limit = state.cols + state.rows;
|
||||
for (let s = 1; s <= limit; s++) {
|
||||
let blocked = false;
|
||||
for (const [x, y] of blob.cells) {
|
||||
const k = key(x + dx * s, y + dy * s);
|
||||
if (state.walls.has(k) || other.has(k)) { blocked = true; break; }
|
||||
}
|
||||
if (blocked) break;
|
||||
maxSteps = s;
|
||||
}
|
||||
const { springs, tunnels, buttons, brickAt } = state;
|
||||
const inBoard = (x, y) => x >= 0 && y >= 0 && x < state.cols && y < state.rows;
|
||||
const brickUp = (k) => {
|
||||
const g = brickAt?.get(k);
|
||||
return g !== undefined && board.bricksUp?.has(g);
|
||||
};
|
||||
// Everything that a blob can never occupy (used for tunnel destinations).
|
||||
const solid = (k) => state.walls.has(k) || other.has(k) || board.ice?.has(k)
|
||||
|| brickUp(k) || board.crates?.has(k) || springs?.has(k);
|
||||
|
||||
let [dx, dy] = DIRS[dir];
|
||||
let ox = 0, oy = 0;
|
||||
let maxSteps = 0;
|
||||
let deathStep = 0;
|
||||
let deathCause = null;
|
||||
for (let s = 1; s <= maxSteps && deathStep === 0; s++) {
|
||||
for (const [x, y] of blob.cells) {
|
||||
const nx = x + dx * s, ny = y + dy * s;
|
||||
if (nx < 0 || ny < 0 || nx >= state.cols || ny >= state.rows) { deathStep = s; deathCause = 'edge'; break; }
|
||||
if (state.spikes.has(key(nx, ny))) { deathStep = s; deathCause = 'spike'; break; }
|
||||
let stopReason = 'blocked';
|
||||
const hitIce = [];
|
||||
const path = wantPath ? [] : null;
|
||||
const trail = green ? base.map(([x, y]) => key(x, y)) : null;
|
||||
|
||||
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:
|
||||
|
|
@ -137,72 +560,113 @@ export function computeSlide(state, idx, dir) {
|
|||
// { moved:true, dead:true, deathStep, deathCause } — hit a spike / fell off
|
||||
// { moved:true, dead:false, merged, steps } — slid and (maybe) merged
|
||||
export function slide(state, idx, dir) {
|
||||
const { maxSteps, deathStep, deathCause } = computeSlide(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 };
|
||||
return applyPlan(state, planFlick(state, idx, dir));
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const moves = [];
|
||||
let hypnoDirs = null;
|
||||
state.blobs.forEach((blob, idx) => {
|
||||
if (isAsleep(blob)) return;
|
||||
for (const dir of DIR_LIST) {
|
||||
const { maxSteps, deathStep } = computeSlide(state, idx, dir);
|
||||
if (maxSteps > 0 && deathStep === 0) moves.push({ idx, dir, cell: repCell(blob) });
|
||||
if (blob.hypno) {
|
||||
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;
|
||||
}
|
||||
|
||||
// Canonical key: cells sorted within each blob, blobs sorted, joined. Walls and
|
||||
// spikes are fixed for a level, so this fully identifies a configuration.
|
||||
// Canonical key: cells sorted within each blob (plus its flags), blobs sorted,
|
||||
// then the mutable board layers. Immutable terrain is fixed for a level so it is
|
||||
// deliberately left out.
|
||||
export function stateKey(state) {
|
||||
return state.blobs
|
||||
.map((b) => b.cells.map(([x, y]) => key(x, y)).sort().join(';'))
|
||||
const blobs = state.blobs
|
||||
.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()
|
||||
.join('|');
|
||||
const board = boardKey(state.board);
|
||||
return board ? `${blobs}!${board}` : blobs;
|
||||
}
|
||||
|
||||
export function cloneState(state) {
|
||||
return {
|
||||
cols: state.cols,
|
||||
rows: state.rows,
|
||||
walls: state.walls, // immutable during play — shared
|
||||
spikes: state.spikes, // immutable during play — shared
|
||||
stars: state.stars, // immutable during play — shared
|
||||
blobs: state.blobs.map((b) => ({ cells: b.cells.map((cell) => cell.slice()) })),
|
||||
collected: new Set(state.collected),
|
||||
walls: state.walls, // immutable during play — all shared
|
||||
spikes: state.spikes,
|
||||
springs: state.springs,
|
||||
tunnels: state.tunnels,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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 = {
|
||||
cols: level.cols,
|
||||
rows: level.rows,
|
||||
walls: new Set((level.walls ?? []).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]]),
|
||||
blobs: (level.monsters ?? []).map(([x, y], m) => ({ cells: [[x, y, m]] })),
|
||||
collected: new Set(),
|
||||
springs: new Set((level.springs ?? []).map(([x, y]) => key(x, y))),
|
||||
tunnels,
|
||||
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',
|
||||
};
|
||||
mergeBlobs(state); // merge any monsters that start touching
|
||||
pickUpStars(state);
|
||||
state.state = state.blobs.length === 1 ? 'won' : 'playing';
|
||||
return state;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,6 +99,13 @@ export function playSound(scene, key) {
|
|||
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).
|
||||
export function playRandomSound(scene, keys) {
|
||||
playSound(scene, keys[Math.floor(Math.random() * keys.length)]);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// monsters (the board edge is open — only walls and monsters stop a slide, so
|
||||
// every tier carries walls), runs the BFS solver to (a) reject
|
||||
// unsolvable/trivial layouts and
|
||||
// (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
|
||||
// levels to data/puddingmonsters.json. Stars at play time require BOTH:
|
||||
// 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.
|
||||
// Wave 3 replaced the old flat tier ramp with a CURRICULUM: five named chapters
|
||||
// of fifteen levels each, built from blocks that introduce one mechanic at a
|
||||
// time, drill it, then combine it with what came before. See
|
||||
// docs/puddingmonsters-mechanics-plan.md.
|
||||
//
|
||||
// Every candidate is random-filled, solved with the BFS solver for its `par`,
|
||||
// and then has to survive three gates:
|
||||
//
|
||||
// 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:
|
||||
// node server/scripts/genPuddingMonsters.js [seed] [outFile]
|
||||
// node tools/genPuddingMonsters.js [seed] [outFile]
|
||||
//
|
||||
// Deterministic: same seed -> same bank. Re-run after changing the curve.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
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 OUT_FILE = process.argv[3]
|
||||
|
|
@ -40,31 +50,149 @@ function makeRng(seed) {
|
|||
const rng = makeRng(SEED);
|
||||
const randInt = (n) => Math.floor(rng() * n);
|
||||
|
||||
// Difficulty curve. Ordered tiers ramp grid size, monsters and obstacles; each
|
||||
// keeps `count` levels whose par falls in [minPar, maxPar]. The board edge is
|
||||
// OPEN (sliding off is fatal), so walls — the only terrain that stops a slide —
|
||||
// appear from tier 1, like the original game. Spikes arrive in later tiers.
|
||||
// Levels are numbered tier-by-tier.
|
||||
const TIERS = [
|
||||
{ count: 8, cols: 5, rows: 5, monsters: 3, walls: 3, spikes: 0, minPar: 2, maxPar: 3 },
|
||||
{ count: 8, cols: 6, rows: 6, monsters: 3, walls: 4, spikes: 0, minPar: 3, maxPar: 5 },
|
||||
{ count: 8, cols: 6, rows: 6, monsters: 4, walls: 5, spikes: 0, minPar: 4, maxPar: 7 },
|
||||
{ count: 8, cols: 7, rows: 7, monsters: 4, walls: 6, spikes: 1, minPar: 5, maxPar: 9 },
|
||||
{ count: 8, cols: 7, rows: 7, monsters: 5, walls: 7, spikes: 2, minPar: 7, maxPar: 14 },
|
||||
// ── Element catalogue ────────────────────────────────────────────────────────
|
||||
// `typed` elements mark a subset of the monsters; the rest place extra terrain.
|
||||
// `needs` pulls in the terrain a monster type is useless without.
|
||||
const ELEMENTS = {
|
||||
sleepers: { typed: 1, tip: 'Sleepyheads can\'t be flicked — bump one awake to move it.' },
|
||||
green: { typed: 1, tip: 'Green monsters leave slime. Anything sliding onto it stops dead.' },
|
||||
hypno: { typed: 2, tip: 'Hypno goos share one mind — flick one and they all go.' },
|
||||
powerlifters: { typed: 1, terrain: { crates: 2 }, tip: 'Powerlifters shove crates around. Everyone else just stops.' },
|
||||
ice: { terrain: { ice: 3 }, tip: 'Ice stops a slide once, then shatters. Spend it wisely.' },
|
||||
springs: { terrain: { springs: 2 }, tip: 'Springs bounce you straight back — and you keep going.' },
|
||||
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 keyOf = (x, y) => `${x},${y}`;
|
||||
|
||||
// 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 = [];
|
||||
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) {
|
||||
tries++;
|
||||
const x = randInt(cols), y = randInt(rows);
|
||||
const x = lo + randInt(wx), y = lo + randInt(wy);
|
||||
const k = keyOf(x, y);
|
||||
if (taken.has(k)) continue;
|
||||
taken.add(k);
|
||||
|
|
@ -73,110 +201,257 @@ function placeCells(n, cols, rows, taken) {
|
|||
return out.length === n ? out : null;
|
||||
}
|
||||
|
||||
// Build one random candidate level for a tier (or null on failure).
|
||||
function randomLevel(tier) {
|
||||
// Teaching levels promise you cannot lose on move one. Random walls almost
|
||||
// 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 walls = placeCells(tier.walls, tier.cols, tier.rows, taken);
|
||||
|
||||
const walls = placeCells(spec.walls, spec.cols, spec.rows, taken);
|
||||
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;
|
||||
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;
|
||||
return {
|
||||
cols: tier.cols, rows: tier.rows, walls, spikes, monsters,
|
||||
};
|
||||
lvl.monsters = 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) {
|
||||
const s = (arr) => arr.map(([x, y]) => keyOf(x, y)).sort().join(' ');
|
||||
return `${lvl.cols}x${lvl.rows}|M:${s(lvl.monsters)}|W:${s(lvl.walls)}|X:${s(lvl.spikes)}`;
|
||||
const s = (arr) => (arr ?? []).map((c) => c.join(',')).sort().join(' ');
|
||||
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.
|
||||
// The footprint always has >= 3 cells (>= 3 monsters), so this yields 3 distinct.
|
||||
function chooseTargets(footprint) {
|
||||
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])];
|
||||
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}…`);
|
||||
const target = TIERS.reduce((t, x) => t + x.count, 0);
|
||||
const buckets = TIERS.map(() => []);
|
||||
const seen = new Set();
|
||||
let attempts = 0;
|
||||
let solved = 0;
|
||||
const startedAt = Date.now();
|
||||
const tiersFull = () => buckets.every((b, i) => b.length >= TIERS[i].count);
|
||||
const bank = [];
|
||||
let totalTries = 0;
|
||||
|
||||
while (attempts < MAX_ATTEMPTS && !tiersFull()) {
|
||||
// Round-robin the tiers that still need levels so every tier gets airtime.
|
||||
for (let ti = 0; ti < TIERS.length; ti++) {
|
||||
if (buckets[ti].length >= TIERS[ti].count) continue;
|
||||
attempts++;
|
||||
if ((attempts & 0x1ff) === 0 && (Date.now() - startedAt) / 1000 > MAX_SECONDS) {
|
||||
console.log('\n[pudding] time budget reached, stopping early');
|
||||
break;
|
||||
for (const chapter of CHAPTERS) {
|
||||
const produced = [];
|
||||
for (const block of chapter.blocks) {
|
||||
const spec = { ...chapter.base, ...block };
|
||||
const elements = spec.elements ?? (spec.element ? [spec.element] : []);
|
||||
const kept = [];
|
||||
let tries = 0;
|
||||
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 (seen.has(ck)) continue;
|
||||
seen.add(ck);
|
||||
|
||||
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} `);
|
||||
if (kept.length < block.count) {
|
||||
console.error(`[pudding] WARNING ch${chapter.id} ${block.kind}`
|
||||
+ `(${elements.join('+') || 'basic'}): only ${kept.length}/${block.count} after ${tries} tries`);
|
||||
}
|
||||
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) ──────
|
||||
const chosen = [];
|
||||
buckets.forEach((b) => {
|
||||
b.sort((p, q) => p.par - q.par);
|
||||
chosen.push(...b);
|
||||
// ── Assemble ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const levels = bank.map((lvl, i) => {
|
||||
const out = {
|
||||
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) => ({
|
||||
level: i + 1,
|
||||
cols: lvl.cols,
|
||||
rows: lvl.rows,
|
||||
walls: lvl.walls,
|
||||
spikes: lvl.spikes,
|
||||
targets: lvl.targets,
|
||||
monsters: lvl.monsters,
|
||||
par: lvl.par,
|
||||
}));
|
||||
// Names run in chapter order, so a teaching level keeps the name written for it.
|
||||
const chapters = [];
|
||||
let cursor = 0;
|
||||
for (const chapter of CHAPTERS) {
|
||||
const n = chapter.produced.length;
|
||||
if (!n) continue;
|
||||
for (let i = 0; i < n; i++) {
|
||||
levels[cursor + i].name = chapter.names[i] ?? `${chapter.name} ${i + 1}`;
|
||||
}
|
||||
chapters.push({
|
||||
id: chapter.id,
|
||||
name: chapter.name,
|
||||
blurb: chapter.blurb,
|
||||
from: cursor + 1,
|
||||
to: cursor + n,
|
||||
});
|
||||
cursor += n;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
seed: SEED,
|
||||
count: levels.length,
|
||||
chapters,
|
||||
levels,
|
||||
};
|
||||
|
||||
fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true });
|
||||
fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2));
|
||||
|
||||
const perTier = buckets.map((b, i) => `${b.length}/${TIERS[i].count}`).join(' ');
|
||||
console.log(`[pudding] attempts=${attempts} solvable=${solved}`);
|
||||
console.log(`[pudding] tiers filled: ${perTier}`);
|
||||
console.log(`[pudding] wrote ${levels.length} levels (par ${levels[0]?.par}..${levels[levels.length - 1]?.par}) -> ${OUT_FILE}`);
|
||||
console.log(`[pudding] ${totalTries} candidates tried`);
|
||||
for (const c of chapters) console.log(`[pudding] ch${c.id} ${c.name}: levels ${c.from}-${c.to}`);
|
||||
console.log(`[pudding] wrote ${levels.length} levels (par ${Math.min(...levels.map((l) => l.par))}`
|
||||
+ `..${Math.max(...levels.map((l) => l.par))}) -> ${OUT_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);
|
||||