feat: add Jell-o Monsters (Pudding Monsters) slide-and-merge puzzle game
Introduce a new single-player logic puzzle game where players flick jelly blobs across a grid to slide and merge them into a single monster. - PuddingMonstersLogic.js: pure slide-and-merge model with BFS solver, no Phaser/DOM dependencies; shared by client and generator - PuddingMonstersGame.js: Phaser scene with drag-to-flick, arrow keys, undo/reset/hint, par tracking, star collection, death on spikes - genPuddingMonsters.js: offline level generator using seeded RNG and BFS solver; produces 40 levels across 5 difficulty tiers - puddingmonsters.json: generated level data (40 levels, 5–7 grid sizes) - Update main.js, GameRoomScene.js, PreloadScene.js, registry.js to wire the new game into the app - Update game-icons assets
This commit is contained in:
parent
147ef4b89b
commit
ea44758f7d
Binary file not shown.
|
Before Width: | Height: | Size: 194 KiB After Width: | Height: | Size: 200 KiB |
Binary file not shown.
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,662 @@
|
||||||
|
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 { api } from '../../services/api.js';
|
||||||
|
import {
|
||||||
|
DIRS, newState, cloneState, slide, computeSlide, solve, blobAt, repCell, starsCollected,
|
||||||
|
} from './PuddingMonstersLogic.js';
|
||||||
|
|
||||||
|
const BG = 0x161226;
|
||||||
|
const FRAME = 0x241c3a;
|
||||||
|
const FLOOR = 0x2b2347;
|
||||||
|
const FLOOR_LN = 0x372c59;
|
||||||
|
const WALL = 0x0e0a1a;
|
||||||
|
const WALL_HI = 0x3a2f56;
|
||||||
|
const SPIKE_BG = 0x3a1320;
|
||||||
|
const SPIKE = 0xe0506b;
|
||||||
|
const STAR_OFF = 0x6b5a8a;
|
||||||
|
const STAR_ON = 0xffd54a;
|
||||||
|
|
||||||
|
const PALETTE = [0xff7eb6, 0x7ed957, 0x5bc0ff, 0xffd166, 0xc792ff, 0xff9e64, 0x4ecdc4, 0xf6e58d];
|
||||||
|
|
||||||
|
const D = { frame: -1, floor: 0, deco: 1, star: 2, monster: 10, anim: 12, ui: 30, overlay: 60, overlayUI: 62 };
|
||||||
|
|
||||||
|
export default class PuddingMonstersGame extends Phaser.Scene {
|
||||||
|
constructor() { super('PuddingMonstersGame'); }
|
||||||
|
|
||||||
|
init(data) {
|
||||||
|
this.gameDef = data.game ?? { slug: 'puddingmonsters', name: 'Jell-o Monsters' };
|
||||||
|
this.bank = [];
|
||||||
|
this.levelsCompleted = 0;
|
||||||
|
this.canPersist = true;
|
||||||
|
this.view = 'select';
|
||||||
|
|
||||||
|
this.level = 0;
|
||||||
|
this.levelDef = null;
|
||||||
|
this.state = null;
|
||||||
|
this.undoStack = [];
|
||||||
|
this.moves = 0;
|
||||||
|
this.par = 0;
|
||||||
|
this.overlayUp = false;
|
||||||
|
this.busy = false;
|
||||||
|
this.drag = null;
|
||||||
|
this.selectedCell = null;
|
||||||
|
this.animGfx = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create() {
|
||||||
|
try {
|
||||||
|
const music = this.cache.json.get('music');
|
||||||
|
if (music?.tracks) new MusicPlayer(this, music.tracks);
|
||||||
|
} catch (_) { /* optional */ }
|
||||||
|
|
||||||
|
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, BG).setDepth(-2);
|
||||||
|
|
||||||
|
const raw = this.cache.json.get('puddingmonsters');
|
||||||
|
this.bank = (raw?.levels ?? []).slice().sort((a, b) => a.level - b.level);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api.get('/puzzles/puddingmonsters/progress');
|
||||||
|
this.levelsCompleted = res?.levelsCompleted ?? 0;
|
||||||
|
} catch (_) {
|
||||||
|
this.canPersist = false;
|
||||||
|
this.levelsCompleted = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.layer = this.add.container(0, 0);
|
||||||
|
this.bindInput();
|
||||||
|
this.showLevelSelect();
|
||||||
|
}
|
||||||
|
|
||||||
|
bindInput() {
|
||||||
|
this.input.on('pointerdown', (p) => this.onPointerDown(p));
|
||||||
|
this.input.on('pointerup', (p) => this.onPointerUp(p));
|
||||||
|
this.input.keyboard.on('keydown', (e) => this.onKey(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
clearLayer() {
|
||||||
|
this.layer.removeAll(true);
|
||||||
|
this.monsterGfx = null;
|
||||||
|
this.starGfx = null;
|
||||||
|
this.animGfx = null;
|
||||||
|
this.undoBtn = null;
|
||||||
|
this.movesText = null;
|
||||||
|
this.starsText = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
bestStars(level) {
|
||||||
|
try { return Number(localStorage.getItem(`pm-stars-${level}`)) || 0; } catch (_) { return 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
saveStars(level, stars) {
|
||||||
|
try {
|
||||||
|
if (stars > this.bestStars(level)) localStorage.setItem(`pm-stars-${level}`, String(stars));
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Level select ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
showLevelSelect() {
|
||||||
|
this.view = 'select';
|
||||||
|
this.overlayUp = false;
|
||||||
|
this.busy = false;
|
||||||
|
this.drag = null;
|
||||||
|
this.clearLayer();
|
||||||
|
const cx = GAME_WIDTH / 2;
|
||||||
|
|
||||||
|
const title = this.add.text(cx, 84, 'JELL-O MONSTERS', {
|
||||||
|
fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex,
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
const sub = this.add.text(cx, 140, 'Flick the jellies so they slide and stick — merge them all into one. Grab the 3 stars!', {
|
||||||
|
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
this.layer.add([title, sub]);
|
||||||
|
|
||||||
|
if (!this.bank.length) {
|
||||||
|
const msg = this.add.text(cx, 520, 'No levels found.\nRun: node server/scripts/genPuddingMonsters.js', {
|
||||||
|
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.dangerHex, align: 'center',
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
this.layer.add(msg);
|
||||||
|
const back = new Button(this, cx, GAME_HEIGHT - 90, 'Back', () => this.scene.start('GameMenu'), { variant: 'ghost' });
|
||||||
|
this.layer.add(back);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextLevel = Math.min(this.levelsCompleted + 1, this.bank.length);
|
||||||
|
const prog = this.add.text(cx, 184, `Completed ${this.levelsCompleted} / ${this.bank.length}`, {
|
||||||
|
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex,
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
this.layer.add(prog);
|
||||||
|
|
||||||
|
const COLS = 10;
|
||||||
|
const SIZE = 120;
|
||||||
|
const GAP = 16;
|
||||||
|
const gridW = COLS * SIZE + (COLS - 1) * GAP;
|
||||||
|
const left = cx - gridW / 2 + SIZE / 2;
|
||||||
|
const top = 280;
|
||||||
|
|
||||||
|
this.bank.forEach((p, i) => {
|
||||||
|
const col = i % COLS;
|
||||||
|
const row = Math.floor(i / COLS);
|
||||||
|
const x = left + col * (SIZE + GAP);
|
||||||
|
const y = top + row * (SIZE + GAP);
|
||||||
|
const level = p.level;
|
||||||
|
const cleared = level <= this.levelsCompleted;
|
||||||
|
const playable = level <= nextLevel;
|
||||||
|
|
||||||
|
const fill = cleared ? 0x35265a : playable ? 0x271f44 : 0x191427;
|
||||||
|
const stroke = cleared ? 0xc792ff : playable ? COLORS.gold : 0x2a2440;
|
||||||
|
const tile = this.add.rectangle(x, y, SIZE, SIZE, fill).setStrokeStyle(playable || cleared ? 3 : 2, stroke, 1);
|
||||||
|
const num = this.add.text(x, y - 18, String(level), {
|
||||||
|
fontFamily: 'Righteous', fontSize: '40px',
|
||||||
|
color: playable || cleared ? COLORS.textHex : '#564f6b',
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
this.layer.add([tile, num]);
|
||||||
|
|
||||||
|
if (playable || cleared) {
|
||||||
|
const earned = this.bestStars(level);
|
||||||
|
const stars = this.add.text(x, y + 30, '★★★'.slice(0, earned) + '☆☆☆'.slice(0, 3 - earned), {
|
||||||
|
fontFamily: 'serif', fontSize: '22px', color: earned ? '#ffd54a' : '#6b5a8a',
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
const par = this.add.text(x, y + 52, `par ${p.par}`, {
|
||||||
|
fontFamily: '"Julius Sans One"', fontSize: '13px', color: COLORS.mutedHex,
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
this.layer.add([stars, par]);
|
||||||
|
} else {
|
||||||
|
const lock = this.add.text(x, y + 36, 'locked', {
|
||||||
|
fontFamily: '"Julius Sans One"', fontSize: '14px', color: '#564f6b',
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
this.layer.add(lock);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playable) {
|
||||||
|
tile.setInteractive({ useHandCursor: true });
|
||||||
|
tile.on('pointerover', () => tile.setStrokeStyle(4, COLORS.gold, 1));
|
||||||
|
tile.on('pointerout', () => tile.setStrokeStyle(3, stroke, 1));
|
||||||
|
tile.on('pointerup', () => this.playLevel(level));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const resume = new Button(this, cx - 160, GAME_HEIGHT - 76, `Play Level ${nextLevel}`, () => this.playLevel(nextLevel),
|
||||||
|
{ width: 300, height: 58, fontSize: 24 });
|
||||||
|
const back = new Button(this, cx + 170, GAME_HEIGHT - 76, 'Back', () => this.scene.start('GameMenu'),
|
||||||
|
{ variant: 'ghost', width: 180, height: 58, fontSize: 24 });
|
||||||
|
this.layer.add([resume, back]);
|
||||||
|
|
||||||
|
if (!this.canPersist) {
|
||||||
|
const note = this.add.text(cx, GAME_HEIGHT - 26, 'Sign in to save your progress across devices.', {
|
||||||
|
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
this.layer.add(note);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Play a level ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
playLevel(level) {
|
||||||
|
const def = this.bank.find((p) => p.level === level);
|
||||||
|
if (!def) return;
|
||||||
|
this.view = 'play';
|
||||||
|
this.level = level;
|
||||||
|
this.levelDef = def;
|
||||||
|
this.par = def.par;
|
||||||
|
this.state = newState(def);
|
||||||
|
this.undoStack = [];
|
||||||
|
this.moves = 0;
|
||||||
|
this.overlayUp = false;
|
||||||
|
this.busy = false;
|
||||||
|
this.drag = null;
|
||||||
|
this.selectedCell = null;
|
||||||
|
|
||||||
|
this.clearLayer();
|
||||||
|
this.computeLayout();
|
||||||
|
this.buildBoard();
|
||||||
|
this.starGfx = this.add.graphics().setDepth(D.star); this.layer.add(this.starGfx);
|
||||||
|
this.monsterGfx = this.add.graphics().setDepth(D.monster); this.layer.add(this.monsterGfx);
|
||||||
|
this.drawStars();
|
||||||
|
this.drawAllMonsters();
|
||||||
|
this.drawHud();
|
||||||
|
this.updateHud();
|
||||||
|
}
|
||||||
|
|
||||||
|
computeLayout() {
|
||||||
|
const { cols, rows } = this.state;
|
||||||
|
const LEFT_STRIP = 300;
|
||||||
|
const TOP = 170;
|
||||||
|
const BOTTOM = GAME_HEIGHT - 50;
|
||||||
|
const RIGHT = GAME_WIDTH - 50;
|
||||||
|
this.cell = Math.min((RIGHT - LEFT_STRIP) / cols, (BOTTOM - TOP) / rows, 132);
|
||||||
|
const boardW = cols * this.cell;
|
||||||
|
const boardH = rows * this.cell;
|
||||||
|
this.originX = LEFT_STRIP + (RIGHT - LEFT_STRIP - boardW) / 2;
|
||||||
|
this.originY = TOP + (BOTTOM - TOP - boardH) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
cellLeft(x) { return this.originX + x * this.cell; }
|
||||||
|
cellTop(y) { return this.originY + y * this.cell; }
|
||||||
|
cellCx(x) { return this.originX + (x + 0.5) * this.cell; }
|
||||||
|
cellCy(y) { return this.originY + (y + 0.5) * this.cell; }
|
||||||
|
|
||||||
|
cellFromPx(px, py) {
|
||||||
|
const x = Math.floor((px - this.originX) / this.cell);
|
||||||
|
const y = Math.floor((py - this.originY) / this.cell);
|
||||||
|
if (x < 0 || y < 0 || x >= this.state.cols || y >= this.state.rows) return null;
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
buildBoard() {
|
||||||
|
const { cols, rows } = this.state;
|
||||||
|
const boardW = cols * this.cell;
|
||||||
|
const boardH = rows * this.cell;
|
||||||
|
|
||||||
|
const frame = this.add.graphics().setDepth(D.frame);
|
||||||
|
frame.fillStyle(FRAME, 1);
|
||||||
|
frame.fillRoundedRect(this.originX - 18, this.originY - 18, boardW + 36, boardH + 36, 22);
|
||||||
|
this.layer.add(frame);
|
||||||
|
|
||||||
|
const floor = this.add.graphics().setDepth(D.floor);
|
||||||
|
for (let y = 0; y < rows; y++) {
|
||||||
|
for (let x = 0; x < cols; x++) {
|
||||||
|
const k = `${x},${y}`;
|
||||||
|
if (this.state.walls.has(k)) continue;
|
||||||
|
floor.fillStyle(FLOOR, 1);
|
||||||
|
floor.fillRoundedRect(this.cellLeft(x) + 2, this.cellTop(y) + 2, this.cell - 4, this.cell - 4, 8);
|
||||||
|
floor.lineStyle(1, FLOOR_LN, 1);
|
||||||
|
floor.strokeRoundedRect(this.cellLeft(x) + 2, this.cellTop(y) + 2, this.cell - 4, this.cell - 4, 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.layer.add(floor);
|
||||||
|
|
||||||
|
const deco = this.add.graphics().setDepth(D.deco);
|
||||||
|
for (const k of this.state.walls) {
|
||||||
|
const [x, y] = k.split(',').map(Number);
|
||||||
|
deco.fillStyle(WALL, 1);
|
||||||
|
deco.fillRoundedRect(this.cellLeft(x) + 2, this.cellTop(y) + 2, this.cell - 4, this.cell - 4, 8);
|
||||||
|
deco.fillStyle(WALL_HI, 1);
|
||||||
|
deco.fillRoundedRect(this.cellLeft(x) + 6, this.cellTop(y) + 6, this.cell - 12, this.cell * 0.22, 6);
|
||||||
|
}
|
||||||
|
for (const k of this.state.spikes) {
|
||||||
|
const [x, y] = k.split(',').map(Number);
|
||||||
|
const L = this.cellLeft(x), T = this.cellTop(y), c = this.cell;
|
||||||
|
deco.fillStyle(SPIKE_BG, 1);
|
||||||
|
deco.fillRoundedRect(L + 2, T + 2, c - 4, c - 4, 8);
|
||||||
|
deco.fillStyle(SPIKE, 1);
|
||||||
|
const baseY = T + c - c * 0.18;
|
||||||
|
const tipY = T + c * 0.24;
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const bx = L + c * (0.2 + i * 0.3);
|
||||||
|
deco.fillTriangle(bx, baseY, bx + c * 0.2, baseY, bx + c * 0.1, tipY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.layer.add(deco);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawStars() {
|
||||||
|
const g = this.starGfx;
|
||||||
|
g.clear();
|
||||||
|
for (const [sx, sy] of this.state.stars) {
|
||||||
|
const on = this.state.collected.has(`${sx},${sy}`);
|
||||||
|
const cx = this.cellCx(sx), cy = this.cellCy(sy);
|
||||||
|
if (on) {
|
||||||
|
g.fillStyle(STAR_ON, 0.22);
|
||||||
|
g.fillCircle(cx, cy, this.cell * 0.42);
|
||||||
|
}
|
||||||
|
this.drawStarShape(g, cx, cy, this.cell * 0.26, on);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawStarShape(g, cx, cy, r, filled) {
|
||||||
|
const pts = [];
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const ang = (Math.PI / 180) * (-90 + i * 36);
|
||||||
|
const rad = i % 2 === 0 ? r : r * 0.45;
|
||||||
|
pts.push(cx + rad * Math.cos(ang), cy + rad * Math.sin(ang));
|
||||||
|
}
|
||||||
|
if (filled) {
|
||||||
|
g.fillStyle(STAR_ON, 1);
|
||||||
|
g.beginPath(); g.moveTo(pts[0], pts[1]);
|
||||||
|
for (let i = 2; i < pts.length; i += 2) g.lineTo(pts[i], pts[i + 1]);
|
||||||
|
g.closePath(); g.fillPath();
|
||||||
|
}
|
||||||
|
g.lineStyle(3, filled ? 0xffec99 : STAR_OFF, 1);
|
||||||
|
g.beginPath(); g.moveTo(pts[0], pts[1]);
|
||||||
|
for (let i = 2; i < pts.length; i += 2) g.lineTo(pts[i], pts[i + 1]);
|
||||||
|
g.closePath(); g.strokePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
colorFor(blob) {
|
||||||
|
const [x, y] = repCell(blob);
|
||||||
|
return PALETTE[(x * 7 + y * 13) % PALETTE.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
drawAllMonsters(excludeIdx = -1) {
|
||||||
|
const g = this.monsterGfx;
|
||||||
|
g.clear();
|
||||||
|
this.state.blobs.forEach((blob, i) => {
|
||||||
|
if (i === excludeIdx) return;
|
||||||
|
const sel = this.selectedCell && blobAt(this.state, this.selectedCell.x, this.selectedCell.y) === i;
|
||||||
|
this.drawBlobInto(g, blob.cells, this.colorFor(blob), 0, 0, sel);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
drawBlobInto(g, cells, color, ox, oy, selected = false) {
|
||||||
|
const c = this.cell;
|
||||||
|
const inset = c * 0.10;
|
||||||
|
const r = c * 0.30;
|
||||||
|
const set = new Set(cells.map(([x, y]) => `${x},${y}`));
|
||||||
|
|
||||||
|
g.fillStyle(color, 1);
|
||||||
|
for (const [x, y] of cells) {
|
||||||
|
g.fillRoundedRect(this.cellLeft(x) + ox + inset, this.cellTop(y) + oy + inset, c - 2 * inset, c - 2 * inset, r);
|
||||||
|
}
|
||||||
|
for (const [x, y] of cells) {
|
||||||
|
const L = this.cellLeft(x) + ox, T = this.cellTop(y) + oy;
|
||||||
|
if (set.has(`${x + 1},${y}`)) g.fillRect(L + c - inset - 1, T + inset, 2 * inset + 2, c - 2 * inset);
|
||||||
|
if (set.has(`${x},${y + 1}`)) g.fillRect(L + inset, T + c - inset - 1, c - 2 * inset, 2 * inset + 2);
|
||||||
|
}
|
||||||
|
// glossy highlight
|
||||||
|
g.fillStyle(0xffffff, 0.18);
|
||||||
|
for (const [x, y] of cells) {
|
||||||
|
const L = this.cellLeft(x) + ox, T = this.cellTop(y) + oy;
|
||||||
|
g.fillRoundedRect(L + inset + c * 0.12, T + inset + c * 0.10, c * 0.30, c * 0.14, c * 0.07);
|
||||||
|
}
|
||||||
|
if (selected) {
|
||||||
|
g.lineStyle(3, 0xffffff, 0.85);
|
||||||
|
for (const [x, y] of cells) {
|
||||||
|
g.strokeRoundedRect(this.cellLeft(x) + ox + inset, this.cellTop(y) + oy + inset, c - 2 * inset, c - 2 * inset, r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eyes at centroid
|
||||||
|
let ex = 0, ey = 0;
|
||||||
|
for (const [x, y] of cells) { ex += this.cellCx(x) + ox; ey += this.cellCy(y) + oy; }
|
||||||
|
ex /= cells.length; ey /= cells.length;
|
||||||
|
const er = c * 0.12, sp = c * 0.17;
|
||||||
|
g.fillStyle(0xffffff, 1);
|
||||||
|
g.fillCircle(ex - sp, ey - er * 0.6, er); g.fillCircle(ex + sp, ey - er * 0.6, er);
|
||||||
|
g.fillStyle(0x1a1a1a, 1);
|
||||||
|
g.fillCircle(ex - sp + er * 0.2, ey - er * 0.45, er * 0.5); g.fillCircle(ex + sp + er * 0.2, ey - er * 0.45, er * 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawHud() {
|
||||||
|
const title = this.add.text(40, 80, 'JELL-O\nMONSTERS', {
|
||||||
|
fontFamily: 'Righteous', fontSize: '34px', color: COLORS.goldHex, lineSpacing: 2,
|
||||||
|
}).setOrigin(0, 0.5).setDepth(D.ui);
|
||||||
|
this.layer.add(title);
|
||||||
|
|
||||||
|
this.movesText = this.add.text(GAME_WIDTH - 50, 80, '', {
|
||||||
|
fontFamily: 'Righteous', fontSize: '30px', color: COLORS.textHex,
|
||||||
|
}).setOrigin(1, 0.5).setDepth(D.ui);
|
||||||
|
this.starsText = this.add.text(GAME_WIDTH - 50, 124, '', {
|
||||||
|
fontFamily: 'serif', fontSize: '30px', color: '#ffd54a',
|
||||||
|
}).setOrigin(1, 0.5).setDepth(D.ui);
|
||||||
|
this.layer.add([this.movesText, this.starsText]);
|
||||||
|
|
||||||
|
const BTN_W = 200;
|
||||||
|
const BTN_H = 56;
|
||||||
|
const BTN_GAP = 14;
|
||||||
|
const BTN_X = 150;
|
||||||
|
const totalH = 4 * BTN_H + 3 * BTN_GAP;
|
||||||
|
let y = GAME_HEIGHT / 2 - totalH / 2;
|
||||||
|
|
||||||
|
const undo = new Button(this, BTN_X, y, 'Undo', () => this.undo(), { width: BTN_W, height: BTN_H, fontSize: 22 });
|
||||||
|
y += BTN_H + BTN_GAP;
|
||||||
|
const reset = new Button(this, BTN_X, y, 'Reset', () => this.resetLevel(), { width: BTN_W, height: BTN_H, fontSize: 22, variant: 'ghost' });
|
||||||
|
y += BTN_H + BTN_GAP;
|
||||||
|
const hint = new Button(this, BTN_X, y, 'Hint', () => this.showHint(), { width: BTN_W, height: BTN_H, fontSize: 22, variant: 'ghost' });
|
||||||
|
y += BTN_H + BTN_GAP;
|
||||||
|
const levels = new Button(this, BTN_X, y, 'Levels', () => this.showLevelSelect(), { width: BTN_W, height: BTN_H, fontSize: 22, variant: 'ghost' });
|
||||||
|
this.undoBtn = undo;
|
||||||
|
this.layer.add([undo, reset, hint, levels]);
|
||||||
|
|
||||||
|
const tip = this.add.text(BTN_X, y + BTN_H + 26, 'Drag a monster to\nslide it. Arrow keys\nflick the selected one.', {
|
||||||
|
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex, align: 'center',
|
||||||
|
}).setOrigin(0.5, 0).setDepth(D.ui);
|
||||||
|
this.layer.add(tip);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateHud() {
|
||||||
|
if (this.movesText) this.movesText.setText(`Moves: ${this.moves} Par: ${this.par}`);
|
||||||
|
if (this.starsText) this.starsText.setText(`★ ${starsCollected(this.state)}/3`);
|
||||||
|
if (this.undoBtn) this.undoBtn.setEnabled(this.undoStack.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Input handling ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
onPointerDown(p) {
|
||||||
|
if (this.view !== 'play' || this.overlayUp || this.busy) return;
|
||||||
|
const cell = this.cellFromPx(p.x, p.y);
|
||||||
|
if (cell && blobAt(this.state, cell.x, cell.y) >= 0) {
|
||||||
|
this.drag = { cell, sx: p.x, sy: p.y };
|
||||||
|
this.selectedCell = cell;
|
||||||
|
this.drawAllMonsters();
|
||||||
|
} else {
|
||||||
|
this.drag = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerUp(p) {
|
||||||
|
if (this.view !== 'play' || this.overlayUp || this.busy || !this.drag) return;
|
||||||
|
const dx = p.x - this.drag.sx;
|
||||||
|
const dy = p.y - this.drag.sy;
|
||||||
|
const cell = this.drag.cell;
|
||||||
|
this.drag = null;
|
||||||
|
if (Math.max(Math.abs(dx), Math.abs(dy)) < this.cell * 0.28) return; // tap, not a flick
|
||||||
|
const dir = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'right' : 'left') : (dy > 0 ? 'down' : 'up');
|
||||||
|
this.doFlick(cell, dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
onKey(e) {
|
||||||
|
if (this.view !== 'play' || this.overlayUp || this.busy || !this.selectedCell) return;
|
||||||
|
const map = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right' };
|
||||||
|
const dir = map[e.key];
|
||||||
|
if (dir) { e.preventDefault?.(); this.doFlick(this.selectedCell, dir); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Flick / move ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
doFlick(cell, dir) {
|
||||||
|
const idx = blobAt(this.state, cell.x, cell.y);
|
||||||
|
if (idx < 0) return;
|
||||||
|
const { maxSteps, deathStep } = computeSlide(this.state, idx, dir);
|
||||||
|
if (maxSteps === 0) { this.nudgeInvalid(idx); return; }
|
||||||
|
|
||||||
|
this.busy = true;
|
||||||
|
this.undoStack.push(cloneState(this.state));
|
||||||
|
this.moves++;
|
||||||
|
this.updateHud();
|
||||||
|
|
||||||
|
const movingCells = this.state.blobs[idx].cells.map((c) => [c[0], c[1]]);
|
||||||
|
const color = this.colorFor(this.state.blobs[idx]);
|
||||||
|
const [dx, dy] = DIRS[dir];
|
||||||
|
const steps = deathStep > 0 ? deathStep : maxSteps;
|
||||||
|
|
||||||
|
this.drawAllMonsters(idx);
|
||||||
|
this.animGfx = this.add.graphics().setDepth(D.anim);
|
||||||
|
this.layer.add(this.animGfx);
|
||||||
|
const proxy = { v: 0 };
|
||||||
|
const tgtX = dx * steps * this.cell;
|
||||||
|
const tgtY = dy * steps * this.cell;
|
||||||
|
const drawAt = (t) => { this.animGfx.clear(); this.drawBlobInto(this.animGfx, movingCells, color, tgtX * t, tgtY * t); };
|
||||||
|
drawAt(0);
|
||||||
|
playSound(this, SFX.PIECE_CLICK);
|
||||||
|
|
||||||
|
this.tweens.add({
|
||||||
|
targets: proxy, v: 1,
|
||||||
|
duration: Math.min(420, 110 + steps * 55),
|
||||||
|
ease: deathStep > 0 ? 'Quad.easeIn' : 'Back.easeOut',
|
||||||
|
onUpdate: () => drawAt(proxy.v),
|
||||||
|
onComplete: () => {
|
||||||
|
if (deathStep > 0) this.splatAndFail(idx, dir);
|
||||||
|
else this.commitMove(idx, dir, movingCells, dx, dy, steps);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
commitMove(idx, dir, movingCells, dx, dy, steps) {
|
||||||
|
const res = slide(this.state, idx, dir);
|
||||||
|
if (this.animGfx) { this.animGfx.destroy(); this.animGfx = null; }
|
||||||
|
// keep the moved blob selected at its new resting position
|
||||||
|
this.selectedCell = { x: movingCells[0][0] + dx * steps, y: movingCells[0][1] + dy * steps };
|
||||||
|
this.drawAllMonsters();
|
||||||
|
this.drawStars();
|
||||||
|
this.updateHud();
|
||||||
|
if (res.merged) playSound(this, SFX.CARD_PLACE);
|
||||||
|
this.busy = false;
|
||||||
|
if (this.state.state === 'won') this.onSolved();
|
||||||
|
}
|
||||||
|
|
||||||
|
splatAndFail(idx, dir) {
|
||||||
|
slide(this.state, idx, dir); // marks state 'dead' (positions unchanged)
|
||||||
|
playSound(this, SFX.SCIFI_EXPLODE);
|
||||||
|
const g = this.animGfx;
|
||||||
|
if (g) {
|
||||||
|
this.tweens.add({
|
||||||
|
targets: g, alpha: 0, duration: 260, ease: 'Quad.easeIn',
|
||||||
|
onComplete: () => { g.destroy(); if (this.animGfx === g) this.animGfx = null; this.onDead(); },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.onDead();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nudgeInvalid(idx) {
|
||||||
|
// brief wobble of the whole monster layer to signal "can't move"
|
||||||
|
const blob = this.state.blobs[idx];
|
||||||
|
const cx = this.cellCx(repCell(blob)[0]);
|
||||||
|
void cx;
|
||||||
|
this.tweens.add({ targets: this.monsterGfx, x: 6, duration: 50, yoyo: true, repeat: 1, onComplete: () => { this.monsterGfx.x = 0; } });
|
||||||
|
}
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
if (!this.undoStack.length || this.busy || this.overlayUp) return;
|
||||||
|
this.state = this.undoStack.pop();
|
||||||
|
this.moves++; // an undo still counts as a move (matches Rush Hour)
|
||||||
|
this.selectedCell = null;
|
||||||
|
this.drawAllMonsters();
|
||||||
|
this.drawStars();
|
||||||
|
this.updateHud();
|
||||||
|
playSound(this, SFX.PIECE_CLICK);
|
||||||
|
}
|
||||||
|
|
||||||
|
resetLevel() {
|
||||||
|
if (this.busy || this.overlayUp) return;
|
||||||
|
this.state = newState(this.levelDef);
|
||||||
|
this.undoStack = [];
|
||||||
|
this.moves = 0;
|
||||||
|
this.selectedCell = null;
|
||||||
|
this.drawAllMonsters();
|
||||||
|
this.drawStars();
|
||||||
|
this.updateHud();
|
||||||
|
playSound(this, SFX.CARD_SHUFFLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
showHint() {
|
||||||
|
if (this.busy || this.overlayUp) return;
|
||||||
|
const { path } = solve(this.state, { maxStates: 200000 });
|
||||||
|
if (!path || !path.length) return;
|
||||||
|
const mv = path[0];
|
||||||
|
const idx = blobAt(this.state, mv.cell[0], mv.cell[1]);
|
||||||
|
if (idx < 0) return;
|
||||||
|
this.selectedCell = { x: mv.cell[0], y: mv.cell[1] };
|
||||||
|
this.drawAllMonsters();
|
||||||
|
const [dx, dy] = DIRS[mv.dir];
|
||||||
|
const cx = this.cellCx(mv.cell[0]);
|
||||||
|
const cy = this.cellCy(mv.cell[1]);
|
||||||
|
const arrow = this.add.text(cx + dx * this.cell * 0.5, cy + dy * this.cell * 0.5,
|
||||||
|
({ up: '↑', down: '↓', left: '←', right: '→' })[mv.dir], {
|
||||||
|
fontFamily: 'Righteous', fontSize: `${Math.round(this.cell * 0.6)}px`, color: '#ffffff',
|
||||||
|
}).setOrigin(0.5).setDepth(D.anim);
|
||||||
|
this.layer.add(arrow);
|
||||||
|
this.tweens.add({ targets: arrow, alpha: 0, scale: 1.4, duration: 900, ease: 'Quad.easeOut', onComplete: () => arrow.destroy() });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── End states ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
onDead() {
|
||||||
|
this.overlayUp = true;
|
||||||
|
const cx = GAME_WIDTH / 2;
|
||||||
|
const cy = GAME_HEIGHT / 2;
|
||||||
|
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6).setDepth(D.overlay).setInteractive();
|
||||||
|
this.layer.add(dim);
|
||||||
|
|
||||||
|
const panel = this.add.graphics().setDepth(D.overlay);
|
||||||
|
panel.fillStyle(COLORS.panel, 0.98);
|
||||||
|
panel.fillRoundedRect(cx - 300, cy - 170, 600, 340, 20);
|
||||||
|
panel.lineStyle(3, COLORS.danger, 1);
|
||||||
|
panel.strokeRoundedRect(cx - 300, cy - 170, 600, 340, 20);
|
||||||
|
this.layer.add(panel);
|
||||||
|
|
||||||
|
const title = this.add.text(cx, cy - 90, 'Splat!', {
|
||||||
|
fontFamily: 'Righteous', fontSize: '64px', color: COLORS.dangerHex,
|
||||||
|
}).setOrigin(0.5).setDepth(D.overlayUI);
|
||||||
|
const msg = this.add.text(cx, cy - 14, 'A monster slid onto the spikes. Try again!', {
|
||||||
|
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex,
|
||||||
|
}).setOrigin(0.5).setDepth(D.overlayUI);
|
||||||
|
this.layer.add([title, msg]);
|
||||||
|
|
||||||
|
const retry = new Button(this, cx - 150, cy + 90, 'Retry', () => this.playLevel(this.level),
|
||||||
|
{ width: 250, height: 58, fontSize: 24 }).setDepth(D.overlayUI);
|
||||||
|
const levels = new Button(this, cx + 150, cy + 90, 'Levels', () => this.showLevelSelect(),
|
||||||
|
{ width: 250, height: 58, fontSize: 24, variant: 'ghost' }).setDepth(D.overlayUI);
|
||||||
|
this.layer.add([retry, levels]);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSolved() {
|
||||||
|
this.overlayUp = true;
|
||||||
|
const stars = starsCollected(this.state);
|
||||||
|
this.saveStars(this.level, stars);
|
||||||
|
|
||||||
|
if (this.level > this.levelsCompleted) this.levelsCompleted = this.level;
|
||||||
|
api.post('/puzzles/puddingmonsters/complete', { level: this.level })
|
||||||
|
.then((res) => { if (res?.levelsCompleted != null) this.levelsCompleted = Math.max(this.levelsCompleted, res.levelsCompleted); })
|
||||||
|
.catch(() => { /* best effort */ });
|
||||||
|
api.post('/history/single-player', {
|
||||||
|
slug: 'puddingmonsters', score: this.moves, opponentScores: [], result: 'win',
|
||||||
|
}).catch(() => { /* best effort */ });
|
||||||
|
playSound(this, SFX.VICTORY_SHORT);
|
||||||
|
|
||||||
|
const cx = GAME_WIDTH / 2;
|
||||||
|
const cy = GAME_HEIGHT / 2;
|
||||||
|
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62).setDepth(D.overlay).setInteractive();
|
||||||
|
this.layer.add(dim);
|
||||||
|
|
||||||
|
const panel = this.add.graphics().setDepth(D.overlay);
|
||||||
|
panel.fillStyle(COLORS.panel, 0.98);
|
||||||
|
panel.fillRoundedRect(cx - 320, cy - 210, 640, 420, 20);
|
||||||
|
panel.lineStyle(3, 0xc792ff, 1);
|
||||||
|
panel.strokeRoundedRect(cx - 320, cy - 210, 640, 420, 20);
|
||||||
|
this.layer.add(panel);
|
||||||
|
|
||||||
|
const title = this.add.text(cx, cy - 140, 'Solved!', {
|
||||||
|
fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex,
|
||||||
|
}).setOrigin(0.5).setDepth(D.overlayUI);
|
||||||
|
const starRow = this.add.text(cx, cy - 64, '★★★'.slice(0, stars) + '☆☆☆'.slice(0, 3 - stars), {
|
||||||
|
fontFamily: 'serif', fontSize: '56px', color: '#ffd54a',
|
||||||
|
}).setOrigin(0.5).setDepth(D.overlayUI);
|
||||||
|
const beatPar = this.moves <= this.par;
|
||||||
|
const stat = this.add.text(cx, cy - 4,
|
||||||
|
`Level ${this.level} cleared in ${this.moves} moves (par ${this.par})${beatPar ? ' ★ par or better!' : ''}`, {
|
||||||
|
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex,
|
||||||
|
}).setOrigin(0.5).setDepth(D.overlayUI);
|
||||||
|
this.layer.add([title, starRow, stat]);
|
||||||
|
|
||||||
|
const hasNext = this.level < this.bank.length;
|
||||||
|
const btns = [];
|
||||||
|
if (hasNext) {
|
||||||
|
btns.push(new Button(this, cx, cy + 70, `Next Level (${this.level + 1})`, () => this.playLevel(this.level + 1),
|
||||||
|
{ width: 340, height: 60, fontSize: 26 }).setDepth(D.overlayUI));
|
||||||
|
} else {
|
||||||
|
btns.push(this.add.text(cx, cy + 62, 'You cleared every level. Sweet!', {
|
||||||
|
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.goldHex,
|
||||||
|
}).setOrigin(0.5).setDepth(D.overlayUI));
|
||||||
|
}
|
||||||
|
const replay = new Button(this, cx - 120, cy + 150, 'Replay', () => this.playLevel(this.level),
|
||||||
|
{ width: 210, height: 54, fontSize: 22, variant: 'ghost' }).setDepth(D.overlayUI);
|
||||||
|
const levels = new Button(this, cx + 120, cy + 150, 'Levels', () => this.showLevelSelect(),
|
||||||
|
{ width: 210, height: 54, fontSize: 22, variant: 'ghost' }).setDepth(D.overlayUI);
|
||||||
|
btns.push(replay, levels);
|
||||||
|
this.layer.add(btns);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,232 @@
|
||||||
|
// Pudding Monsters — pure slide-and-merge model + BFS solver. No Phaser, no DOM.
|
||||||
|
// Shared by the client scene and the offline level generator (both ESM).
|
||||||
|
//
|
||||||
|
// A level is a grid of cols x rows. Monsters are jelly blobs (each starts as one
|
||||||
|
// cell). Flicking a blob slides it in a direction until any of its cells would
|
||||||
|
// leave the board, hit a WALL, or hit another blob — classic "ice slide". When a
|
||||||
|
// blob comes to rest orthogonally adjacent to another blob they STICK into one
|
||||||
|
// rigid blob. The level is solved when every monster has merged into a single
|
||||||
|
// connected blob.
|
||||||
|
//
|
||||||
|
// SPIKES are deadly: if a blob's slide path crosses a spike it dies (the run
|
||||||
|
// fails and the level must be restarted). STARS are bonus floor tiles collected
|
||||||
|
// when a monster covers them.
|
||||||
|
//
|
||||||
|
// A blob: { cells: [[x,y], ...] } (rigid; moves as a unit)
|
||||||
|
|
||||||
|
export const DIRS = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] };
|
||||||
|
export const DIR_LIST = ['up', 'down', 'left', 'right'];
|
||||||
|
|
||||||
|
const ADJ = [[1, 0], [-1, 0], [0, 1], [0, -1]];
|
||||||
|
const key = (x, y) => `${x},${y}`;
|
||||||
|
|
||||||
|
function occupiedAt(state, x, y) {
|
||||||
|
for (const b of state.blobs) {
|
||||||
|
for (const [cx, cy] of b.cells) if (cx === x && cy === y) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blobAt(state, x, y) {
|
||||||
|
for (let i = 0; i < state.blobs.length; i++) {
|
||||||
|
for (const [cx, cy] of state.blobs[i].cells) if (cx === x && cy === y) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lowest cell (top-left in row-major order) — a stable representative for a blob.
|
||||||
|
export function repCell(blob) {
|
||||||
|
return blob.cells.reduce((best, c) =>
|
||||||
|
(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.
|
||||||
|
function mergeBlobs(state) {
|
||||||
|
const occ = new Map();
|
||||||
|
state.blobs.forEach((b, i) => b.cells.forEach(([x, y]) => occ.set(key(x, y), i)));
|
||||||
|
|
||||||
|
const parent = state.blobs.map((_, i) => i);
|
||||||
|
const find = (a) => { while (parent[a] !== a) { parent[a] = parent[parent[a]]; a = parent[a]; } return a; };
|
||||||
|
const union = (a, b) => { const ra = find(a), rb = find(b); if (ra !== rb) parent[ra] = rb; };
|
||||||
|
|
||||||
|
state.blobs.forEach((b, i) => {
|
||||||
|
for (const [x, y] of b.cells) {
|
||||||
|
for (const [dx, dy] of ADJ) {
|
||||||
|
const j = occ.get(key(x + dx, y + dy));
|
||||||
|
if (j !== undefined && j !== i) union(i, j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
state.blobs = [...groups.values()].map((cells) => ({ cells }));
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// How far blob `idx` can slide in `dir`, and whether that path crosses a spike.
|
||||||
|
// Pure (does not mutate). { maxSteps, deathStep } — deathStep>0 means fatal.
|
||||||
|
export function computeSlide(state, idx, dir) {
|
||||||
|
const [dx, dy] = DIRS[dir];
|
||||||
|
const blob = state.blobs[idx];
|
||||||
|
|
||||||
|
const other = new Set();
|
||||||
|
state.blobs.forEach((b, i) => {
|
||||||
|
if (i !== idx) b.cells.forEach(([x, y]) => other.add(key(x, y)));
|
||||||
|
});
|
||||||
|
|
||||||
|
let maxSteps = 0;
|
||||||
|
const limit = state.cols + state.rows;
|
||||||
|
for (let s = 1; s <= limit; s++) {
|
||||||
|
let ok = true;
|
||||||
|
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) { ok = false; break; }
|
||||||
|
if (state.walls.has(key(nx, ny)) || other.has(key(nx, ny))) { ok = false; break; }
|
||||||
|
}
|
||||||
|
if (!ok) break;
|
||||||
|
maxSteps = s;
|
||||||
|
}
|
||||||
|
|
||||||
|
let deathStep = 0;
|
||||||
|
for (let s = 1; s <= maxSteps && deathStep === 0; s++) {
|
||||||
|
for (const [x, y] of blob.cells) {
|
||||||
|
if (state.spikes.has(key(x + dx * s, y + dy * s))) { deathStep = s; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { maxSteps, deathStep };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flick blob `idx` in `dir`. Mutates state. Returns:
|
||||||
|
// { moved:false } — couldn't move (no-op)
|
||||||
|
// { moved:true, dead:true, deathStep } — slid onto a spike (run fails)
|
||||||
|
// { moved:true, dead:false, merged, steps } — slid and (maybe) merged
|
||||||
|
export function slide(state, idx, dir) {
|
||||||
|
const { maxSteps, deathStep } = 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = state.blobs[idx];
|
||||||
|
blob.cells = blob.cells.map(([x, y]) => [x + dx * maxSteps, y + dy * maxSteps]);
|
||||||
|
const before = state.blobs.length;
|
||||||
|
mergeBlobs(state);
|
||||||
|
pickUpStars(state);
|
||||||
|
state.state = state.blobs.length === 1 ? 'won' : 'playing';
|
||||||
|
return { moved: true, dead: false, merged: state.blobs.length < before, steps: maxSteps };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every non-fatal flick available. Each entry carries the blob's representative
|
||||||
|
// cell so a move stays identifiable after merges renumber the blobs.
|
||||||
|
export function legalMoves(state) {
|
||||||
|
const moves = [];
|
||||||
|
state.blobs.forEach((blob, idx) => {
|
||||||
|
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) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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.
|
||||||
|
export function stateKey(state) {
|
||||||
|
return state.blobs
|
||||||
|
.map((b) => b.cells.map(([x, y]) => key(x, y)).sort().join(';'))
|
||||||
|
.sort()
|
||||||
|
.join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
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(([x, y]) => [x, y]) })),
|
||||||
|
collected: new Set(state.collected),
|
||||||
|
state: state.state,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newState(level) {
|
||||||
|
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]) => ({ cells: [[x, y]] })),
|
||||||
|
collected: new Set(),
|
||||||
|
state: 'playing',
|
||||||
|
};
|
||||||
|
mergeBlobs(state); // merge any monsters that start touching
|
||||||
|
pickUpStars(state);
|
||||||
|
state.state = state.blobs.length === 1 ? 'won' : 'playing';
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Breadth-first shortest solution (fewest flicks to merge everything into one
|
||||||
|
// blob). Returns { moves, path, footprint }:
|
||||||
|
// moves — minimum flicks (0 if already solved, -1 if none within maxStates)
|
||||||
|
// path — [{ cell:[x,y], dir }] optimal flicks (null if unsolvable)
|
||||||
|
// footprint — cells of the final single blob (null if unsolvable); the
|
||||||
|
// generator marks 3 of these as stars to guarantee a 3-star clear.
|
||||||
|
export function solve(state, { maxStates = 200000 } = {}) {
|
||||||
|
const start = cloneState(state);
|
||||||
|
if (start.blobs.length === 1) return { moves: 0, path: [], footprint: start.blobs[0].cells };
|
||||||
|
|
||||||
|
const startKey = stateKey(start);
|
||||||
|
const meta = new Map([[startKey, null]]);
|
||||||
|
const stateByKey = new Map([[startKey, start]]);
|
||||||
|
let frontier = [startKey];
|
||||||
|
let depth = 0;
|
||||||
|
|
||||||
|
while (frontier.length) {
|
||||||
|
depth++;
|
||||||
|
const next = [];
|
||||||
|
for (const k of frontier) {
|
||||||
|
const cur = stateByKey.get(k);
|
||||||
|
for (const mv of legalMoves(cur)) {
|
||||||
|
const ns = cloneState(cur);
|
||||||
|
slide(ns, mv.idx, mv.dir);
|
||||||
|
const nk = stateKey(ns);
|
||||||
|
if (meta.has(nk)) continue;
|
||||||
|
meta.set(nk, { parentKey: k, move: { cell: mv.cell, dir: mv.dir } });
|
||||||
|
if (ns.blobs.length === 1) {
|
||||||
|
const path = [];
|
||||||
|
let c = nk;
|
||||||
|
while (meta.get(c)) { const e = meta.get(c); path.unshift(e.move); c = e.parentKey; }
|
||||||
|
return { moves: depth, path, footprint: ns.blobs[0].cells };
|
||||||
|
}
|
||||||
|
stateByKey.set(nk, ns);
|
||||||
|
next.push(nk);
|
||||||
|
}
|
||||||
|
if (meta.size > maxStates) return { moves: -1, path: null, footprint: null };
|
||||||
|
}
|
||||||
|
frontier = next;
|
||||||
|
if (depth > 60) break;
|
||||||
|
}
|
||||||
|
return { moves: -1, path: null, footprint: null };
|
||||||
|
}
|
||||||
|
|
@ -63,6 +63,7 @@ import TriominoesGame from './games/triominoes/TriominoesGame.js';
|
||||||
import FreecellGame from './games/freecell/FreecellGame.js';
|
import FreecellGame from './games/freecell/FreecellGame.js';
|
||||||
import RushHourGame from './games/rushhour/RushHourGame.js';
|
import RushHourGame from './games/rushhour/RushHourGame.js';
|
||||||
import HexsweeperGame from './games/hexsweeper/HexsweeperGame.js';
|
import HexsweeperGame from './games/hexsweeper/HexsweeperGame.js';
|
||||||
|
import PuddingMonstersGame from './games/puddingmonsters/PuddingMonstersGame.js';
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
type: Phaser.AUTO,
|
type: Phaser.AUTO,
|
||||||
|
|
@ -139,6 +140,7 @@ const config = {
|
||||||
FreecellGame,
|
FreecellGame,
|
||||||
RushHourGame,
|
RushHourGame,
|
||||||
HexsweeperGame,
|
HexsweeperGame,
|
||||||
|
PuddingMonstersGame,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
create() {
|
create() {
|
||||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame' };
|
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame' };
|
||||||
if (slugDispatch[this.game.slug]) {
|
if (slugDispatch[this.game.slug]) {
|
||||||
this.scene.start(slugDispatch[this.game.slug], {
|
this.scene.start(slugDispatch[this.game.slug], {
|
||||||
game: this.game,
|
game: this.game,
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ export default class PreloadScene extends Phaser.Scene {
|
||||||
this.load.json('card-backs', '/data/card-backs.json');
|
this.load.json('card-backs', '/data/card-backs.json');
|
||||||
this.load.json('music', '/data/music.json');
|
this.load.json('music', '/data/music.json');
|
||||||
this.load.json('rushhour', '/data/rushhour.json');
|
this.load.json('rushhour', '/data/rushhour.json');
|
||||||
|
this.load.json('puddingmonsters', '/data/puddingmonsters.json');
|
||||||
|
|
||||||
this.load.audio('sfx-water-splash', '/assets/fx/water-splash.mp3');
|
this.load.audio('sfx-water-splash', '/assets/fx/water-splash.mp3');
|
||||||
this.load.audio('sfx-water-sink', '/assets/fx/water-sink.mp3');
|
this.load.audio('sfx-water-sink', '/assets/fx/water-sink.mp3');
|
||||||
|
|
|
||||||
|
|
@ -78,3 +78,4 @@ registerGame({ slug: 'triominoes', name: 'Tri-Ominoes', category:
|
||||||
registerGame({ slug: 'freecell', name: 'Freecell', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 50 });
|
registerGame({ slug: 'freecell', name: 'Freecell', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 50 });
|
||||||
registerGame({ slug: 'rushhour', name: 'Rush Hour', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 51 });
|
registerGame({ slug: 'rushhour', name: 'Rush Hour', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 51 });
|
||||||
registerGame({ slug: 'hexsweeper', name: 'Hexsweeper', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 52 });
|
registerGame({ slug: 'hexsweeper', name: 'Hexsweeper', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 52 });
|
||||||
|
registerGame({ slug: 'puddingmonsters', name: 'Jell-o Monsters', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 53 });
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,178 @@
|
||||||
|
// Offline generator for Pudding Monsters levels.
|
||||||
|
//
|
||||||
|
// For each difficulty tier it random-fills a grid with walls, spikes and K
|
||||||
|
// monsters, runs the BFS solver to (a) reject unsolvable/trivial layouts and
|
||||||
|
// (b) label each survivor with its minimum flick count (par), then places 3
|
||||||
|
// guaranteed-collectable stars on cells of that solution's final footprint and
|
||||||
|
// writes ordered levels to public/data/puddingmonsters.json.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// node server/scripts/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 '../../public/src/games/puddingmonsters/PuddingMonstersLogic.js';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const OUT_FILE = process.argv[3]
|
||||||
|
? path.resolve(process.argv[3])
|
||||||
|
: path.join(__dirname, '../../public/data/puddingmonsters.json');
|
||||||
|
|
||||||
|
const SEED = process.argv[2] ? Number(process.argv[2]) >>> 0 : 0x5eed1234;
|
||||||
|
|
||||||
|
// ── Seeded RNG (mulberry32) ──────────────────────────────────────────────────
|
||||||
|
function makeRng(seed) {
|
||||||
|
let a = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
a |= 0; a = (a + 0x6d2b79f5) | 0;
|
||||||
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
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]. Spikes appear only
|
||||||
|
// in later tiers (yield + difficulty). Levels are numbered tier-by-tier.
|
||||||
|
const TIERS = [
|
||||||
|
{ count: 8, cols: 5, rows: 5, monsters: 3, walls: 0, spikes: 0, minPar: 2, maxPar: 3 },
|
||||||
|
{ count: 8, cols: 6, rows: 6, monsters: 3, walls: 2, spikes: 0, minPar: 3, maxPar: 5 },
|
||||||
|
{ count: 8, cols: 6, rows: 6, monsters: 4, walls: 3, spikes: 0, minPar: 4, maxPar: 7 },
|
||||||
|
{ count: 8, cols: 7, rows: 7, monsters: 4, walls: 4, spikes: 1, minPar: 5, maxPar: 9 },
|
||||||
|
{ count: 8, cols: 7, rows: 7, monsters: 5, walls: 5, spikes: 2, minPar: 7, maxPar: 14 },
|
||||||
|
];
|
||||||
|
const MAX_ATTEMPTS = 6000000;
|
||||||
|
const MAX_SECONDS = 200;
|
||||||
|
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) {
|
||||||
|
const out = [];
|
||||||
|
let tries = 0;
|
||||||
|
while (out.length < n && tries < 400) {
|
||||||
|
tries++;
|
||||||
|
const x = randInt(cols), y = randInt(rows);
|
||||||
|
const k = keyOf(x, y);
|
||||||
|
if (taken.has(k)) continue;
|
||||||
|
taken.add(k);
|
||||||
|
out.push([x, y]);
|
||||||
|
}
|
||||||
|
return out.length === n ? out : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build one random candidate level for a tier (or null on failure).
|
||||||
|
function randomLevel(tier) {
|
||||||
|
const taken = new Set();
|
||||||
|
const walls = placeCells(tier.walls, tier.cols, tier.rows, taken);
|
||||||
|
if (!walls) return null;
|
||||||
|
const spikes = placeCells(tier.spikes, tier.cols, tier.rows, taken);
|
||||||
|
if (!spikes) return null;
|
||||||
|
const monsters = placeCells(tier.monsters, tier.cols, tier.rows, taken);
|
||||||
|
if (!monsters) return null;
|
||||||
|
return {
|
||||||
|
cols: tier.cols, rows: tier.rows, walls, spikes, monsters, stars: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick 3 spread-out, ideally non-starting footprint cells -> guaranteed stars.
|
||||||
|
function chooseStars(footprint, monsters) {
|
||||||
|
const startSet = new Set(monsters.map(([x, y]) => keyOf(x, y)));
|
||||||
|
const nonStart = footprint.filter(([x, y]) => !startSet.has(keyOf(x, y)));
|
||||||
|
const pool = nonStart.length >= 3 ? nonStart : footprint;
|
||||||
|
const sorted = pool.slice().sort((a, b) => (a[1] - b[1]) || (a[0] - b[0]));
|
||||||
|
const idx = [0, Math.floor(sorted.length / 2), sorted.length - 1];
|
||||||
|
return [...new Set(idx)].map((i) => [sorted[i][0], sorted[i][1]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Generate pool ────────────────────────────────────────────────────────────
|
||||||
|
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);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
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.stars = chooseStars(res.footprint, lvl.monsters);
|
||||||
|
if (lvl.stars.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 ((Date.now() - startedAt) / 1000 > MAX_SECONDS) break;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
const levels = chosen.map((lvl, i) => ({
|
||||||
|
level: i + 1,
|
||||||
|
cols: lvl.cols,
|
||||||
|
rows: lvl.rows,
|
||||||
|
walls: lvl.walls,
|
||||||
|
spikes: lvl.spikes,
|
||||||
|
stars: lvl.stars,
|
||||||
|
monsters: lvl.monsters,
|
||||||
|
par: lvl.par,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
seed: SEED,
|
||||||
|
count: levels.length,
|
||||||
|
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}`);
|
||||||
Loading…
Reference in New Issue