// Generates the first five Peggle stub layouts + the level manifest. // node tools/genPeggleLevels.js // Writes assets/gamedata/peggle/level-001..005.json and levels.json. // // Layouts are starting points meant to be hand-tuned later in the editor // (index.html?peggle-editor=1); pegs violating spacing are dropped, so shapes // may thin slightly where strokes intersect. import { writeFileSync, mkdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { TUNING } from '../src/games/peggle/PeggleLogic.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const OUT_DIR = join(__dirname, '..', 'assets', 'gamedata', 'peggle'); const W = TUNING.BOARD_W; // 1200 const MIN_Y = 190; // clear of the launcher const MAX_Y = 790; // clear of the bucket lane const GAP = 6; // required clearance between peg rims const R = TUNING.PEG_R; // ── Shape helpers ──────────────────────────────────────────────────────────── function arc(cx, cy, radius, a0, a1, n, opts = {}) { const pegs = []; for (let i = 0; i < n; i++) { const a = a0 + ((a1 - a0) * i) / Math.max(1, n - 1); pegs.push({ x: cx + radius * Math.cos(a), y: cy + radius * Math.sin(a), ...opts }); } return pegs; } function line(x0, y0, x1, y1, n, opts = {}) { const pegs = []; for (let i = 0; i < n; i++) { const t = n === 1 ? 0 : i / (n - 1); pegs.push({ x: x0 + (x1 - x0) * t, y: y0 + (y1 - y0) * t, ...opts }); } return pegs; } function grid(x, y, cols, rows, dx, dy, opts = {}) { const pegs = []; for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { pegs.push({ x: x + c * dx, y: y + r * dy, ...opts }); } } return pegs; } function circle(cx, cy, radius, n, opts = {}) { return arc(cx, cy, radius, 0, Math.PI * 2 * (1 - 1 / n), n, opts); } // Drop pegs out of bounds or too close to an earlier peg. function settle(pegs) { const out = []; for (const p of pegs) { const x = Math.round(p.x); const y = Math.round(p.y); if (x < R + GAP || x > W - R - GAP || y < MIN_Y || y > MAX_Y) continue; const minD = 2 * R + GAP; if (out.some((q) => (q.x - x) ** 2 + (q.y - y) ** 2 < minD * minD)) continue; out.push({ x, y, shape: 'circle', r: R, orangeEligible: !!p.orangeEligible }); } return out; } function levelFile(pegs, over = {}) { return { version: 1, board: { width: TUNING.BOARD_W, height: TUNING.BOARD_H }, orangeCount: 25, greenCount: 2, pegs, walls: [], moving: [], ...over, }; } const el = { orangeEligible: true }; // ── Level 1 — Tea Time (Ethel): gentle nested arcs + bottom row ───────────── function level1() { const pegs = [ ...arc(600, 120, 420, Math.PI * 0.15, Math.PI * 0.85, 14, el), ...arc(600, 60, 560, Math.PI * 0.20, Math.PI * 0.80, 13, el), ...arc(600, 10, 700, Math.PI * 0.25, Math.PI * 0.75, 12, el), ...line(160, 770, 1040, 770, 10), ...line(320, 300, 880, 300, 5), ]; return levelFile(settle(pegs)); } // ── Level 2 — Fetch! (Kona): a dog bone + scattered field ─────────────────── function level2() { const pegs = [ // bone: two knuckle circles joined by a shaft ...circle(310, 430, 95, 9, el), ...circle(890, 430, 95, 9, el), ...line(400, 390, 800, 390, 6, el), ...line(400, 470, 800, 470, 6, el), ...line(430, 430, 770, 430, 5, el), // scattered field above and below ...grid(220, 240, 6, 1, 152, 0), ...grid(300, 620, 5, 2, 150, 90), ...line(140, 320, 220, 560, 3), ...line(1060, 320, 980, 560, 3), ]; return levelFile(settle(pegs)); } // ── Level 3 — Hocus Pocus (Zanthor): five-point star inside an arc ────────── function level3() { const cx = 600; const cy = 480; const outer = 230; // star vertices (point up) const verts = []; for (let i = 0; i < 5; i++) { const a = -Math.PI / 2 + (i * 2 * Math.PI) / 5; verts.push([cx + outer * Math.cos(a), cy + outer * Math.sin(a)]); } const order = [0, 2, 4, 1, 3, 0]; // pentagram stroke const pegs = []; for (let i = 0; i < 5; i++) { const [x0, y0] = verts[order[i]]; const [x1, y1] = verts[order[i + 1]]; pegs.push(...line(x0, y0, x1, y1, 8, el)); } pegs.push(...arc(600, 430, 400, Math.PI * 0.12, Math.PI * 0.88, 15, el)); pegs.push(...line(240, 240, 460, 240, 3)); pegs.push(...line(740, 240, 960, 240, 3)); return levelFile(settle(pegs)); } // ── Level 4 — X-Ray Vision (Fireball): concentric rings, orange core ──────── function level4() { const cx = 600; const cy = 470; const pegs = [ ...circle(cx, cy, 90, 8, el), ...circle(cx, cy, 175, 14, el), ...circle(cx, cy, 260, 20, el), ...arc(cx, cy, 345, Math.PI * -0.35, Math.PI * 1.35, 22), { x: cx, y: cy, ...el }, ...line(160, 740, 400, 740, 3), ...line(800, 740, 1040, 740, 3), ]; return levelFile(settle(pegs)); } // ── Level 5 — Checkmate (Victor): diagonal checkerboard lattice ───────────── function level5() { const pegs = [ ...grid(180, 250, 8, 5, 120, 120, el), ...grid(240, 310, 8, 5, 120, 120, el), ...line(160, 210, 1040, 210, 8), ]; return levelFile(settle(pegs)); } // ── Manifest + write ───────────────────────────────────────────────────────── const LEVELS = [ { level: 1, name: 'Tea Time', masterId: 'ethel', powerId: 'superguide', file: 'level-001.json', background: 'parlor', build: level1 }, { level: 2, name: 'Fetch!', masterId: 'kona', powerId: 'multiball', file: 'level-002.json', background: 'park', build: level2 }, { level: 3, name: 'Hocus Pocus', masterId: 'zanthor', powerId: 'spaceblast', file: 'level-003.json', background: 'arcana', build: level3 }, { level: 4, name: 'X-Ray Vision', masterId: 'fireball', powerId: 'fireball', file: 'level-004.json', background: 'lab', build: level4 }, { level: 5, name: 'Checkmate', masterId: 'victor', powerId: 'zenball', file: 'level-005.json', background: 'study', build: level5 }, ]; mkdirSync(OUT_DIR, { recursive: true }); for (const def of LEVELS) { const data = def.build(); const eligible = data.pegs.filter((p) => p.orangeEligible).length; if (eligible < data.orangeCount) { throw new Error(`${def.file}: only ${eligible} orange-eligible pegs (< ${data.orangeCount})`); } writeFileSync(join(OUT_DIR, def.file), `${JSON.stringify(data, null, 2)}\n`); console.log(`${def.file}: ${data.pegs.length} pegs (${eligible} orange-eligible)`); } const manifest = { version: 1, levels: LEVELS.map(({ build, ...entry }) => entry), }; writeFileSync(join(OUT_DIR, 'levels.json'), `${JSON.stringify(manifest, null, 2)}\n`); console.log(`levels.json: ${manifest.levels.length} levels`);