// Generates the Peggle level bank + manifest: 25 levels, 5 per friend. // node tools/genPeggleLevels.js // Writes assets/gamedata/peggle/level-001..025.json and levels.json. // // Blocks: 1-5 Ethel (superguide), 6-10 Kona (multiball), 11-15 Zanthor // (spaceblast), 16-20 Fireball (fireball), 21-25 Victor (zenball). Each block // is one shape family with 5 variants ramping in density/difficulty. // // 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); } // Pentagram stroke centered at (cx, cy), point-up, `per` pegs per stroke. function pentagram(cx, cy, radius, per, opts = {}) { const verts = []; for (let i = 0; i < 5; i++) { const a = -Math.PI / 2 + (i * 2 * Math.PI) / 5; verts.push([cx + radius * Math.cos(a), cy + radius * Math.sin(a)]); } const order = [0, 2, 4, 1, 3, 0]; 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, per, opts)); } return pegs; } // 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 }; // ── Ethel (1-5): parlor arcs & chandeliers ─────────────────────────────────── function ethelLevel(v) { const pegs = []; switch (v) { case 0: // Tea Time — gentle nested arcs + bottom row pegs.push( ...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), ); break; case 1: // Fine China — a saucer stack: opposing arc pairs pegs.push( ...arc(600, 40, 500, Math.PI * 0.22, Math.PI * 0.78, 12, el), ...arc(600, 940, 500, -Math.PI * 0.76, -Math.PI * 0.24, 12, el), ...arc(600, 470, 210, 0, Math.PI * 2 * (1 - 1 / 12), 12, el), ...line(150, 480, 330, 480, 3), ...line(870, 480, 1050, 480, 3), ...line(380, 740, 820, 740, 5), ); break; case 2: // Doily Drop — lace fan of alternating arcs with side tassels pegs.push( ...arc(600, 130, 350, Math.PI * 0.1, Math.PI * 0.9, 12, el), ...arc(600, 130, 470, Math.PI * 0.14, Math.PI * 0.86, 13, el), ...arc(600, 130, 590, Math.PI * 0.18, Math.PI * 0.82, 14, el), ...line(180, 260, 180, 560, 4), ...line(1020, 260, 1020, 560, 4), ...line(420, 760, 780, 760, 4), ); break; case 3: // Lace & Grace — S-curve of two big arcs + hanging drops pegs.push( ...arc(320, 300, 240, -Math.PI * 0.5, Math.PI * 0.5, 12, el), ...arc(880, 560, 240, Math.PI * 0.5, Math.PI * 1.5, 12, el), ...line(300, 640, 300, 780, 3, el), ...line(600, 620, 600, 780, 3, el), ...line(160, 210, 420, 210, 4), ...line(780, 210, 1040, 210, 4), ...grid(480, 340, 3, 2, 120, 100, el), ); break; default: // Grandma's Attic — chandelier: arcs + hanging strands pegs.push( ...arc(600, 90, 380, Math.PI * 0.15, Math.PI * 0.85, 12, el), ...arc(600, 90, 520, Math.PI * 0.2, Math.PI * 0.8, 13, el), ...line(280, 420, 280, 720, 5, el), ...line(440, 470, 440, 770, 5, el), ...line(600, 500, 600, 780, 5, el), ...line(760, 470, 760, 770, 5, el), ...line(920, 420, 920, 720, 5, el), ...line(200, 250, 1000, 250, 6), ); break; } return levelFile(settle(pegs)); } // ── Kona (6-10): bones, paws & toys ────────────────────────────────────────── function konaLevel(v) { const pegs = []; switch (v) { case 0: // Fetch! — the dog bone pegs.push( ...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), ...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), ); break; case 1: { // Paw Prints — big pad + four toes, twice const paw = (cx, cy, s) => [ ...circle(cx, cy, 110 * s, 10, el), ...circle(cx - 130 * s, cy - 150 * s, 40 * s, 5, el), ...circle(cx - 45 * s, cy - 190 * s, 40 * s, 5, el), ...circle(cx + 45 * s, cy - 190 * s, 40 * s, 5, el), ...circle(cx + 130 * s, cy - 150 * s, 40 * s, 5, el), ]; pegs.push( ...paw(390, 520, 1), ...paw(880, 620, 0.8), ...line(700, 240, 1050, 240, 4), ...line(150, 740, 350, 740, 3), ); break; } case 2: // Buried Bones — two crossed bones + dirt scatter pegs.push( ...circle(280, 300, 60, 6, el), ...circle(920, 640, 60, 6, el), ...line(350, 340, 850, 600, 8, el), ...circle(920, 300, 60, 6, el), ...circle(280, 640, 60, 6, el), ...line(850, 340, 350, 600, 8, el), ...grid(240, 760, 8, 1, 105, 0), ...line(560, 210, 640, 210, 2), ); break; case 3: // Squeaky Toy — ball on a rope with small toys pegs.push( ...circle(600, 430, 150, 13, el), ...circle(600, 430, 70, 7, el), ...line(600, 190, 600, 340, 3, el), ...circle(220, 640, 65, 7, el), ...circle(980, 640, 65, 7, el), ...line(160, 300, 360, 300, 3), ...line(840, 300, 1040, 300, 3), ...line(430, 740, 770, 740, 4), ); break; default: // Best in Show — kennel house + trophy rows pegs.push( ...line(600, 200, 320, 380, 5, el), ...line(600, 200, 880, 380, 5, el), ...line(320, 380, 320, 700, 5, el), ...line(880, 380, 880, 700, 5, el), ...line(320, 700, 880, 700, 8, el), ...grid(460, 460, 3, 3, 140, 110, el), ...line(140, 500, 240, 500, 2), ...line(960, 500, 1060, 500, 2), ...line(430, 780, 770, 780, 4), ); break; } return levelFile(settle(pegs)); } // ── Zanthor (11-15): stars, moons & sigils ─────────────────────────────────── function zanthorLevel(v) { const pegs = []; switch (v) { case 0: // Hocus Pocus — pentagram inside an arc pegs.push( ...pentagram(600, 480, 230, 8, el), ...arc(600, 430, 400, Math.PI * 0.12, Math.PI * 0.88, 15, el), ...line(240, 240, 460, 240, 3), ...line(740, 240, 960, 240, 3), ); break; case 1: // Crescent Moon — offset arcs + scattered stars pegs.push( ...arc(560, 460, 260, -Math.PI * 0.42, Math.PI * 0.42, 13, el), ...arc(660, 460, 200, -Math.PI * 0.40, Math.PI * 0.40, 10, el), ...pentagram(280, 300, 80, 3, el), ...pentagram(920, 620, 80, 3, el), ...line(180, 660, 420, 760, 4), ...line(760, 220, 1000, 220, 4), ); break; case 2: // Crystal Ball — orb on a stand with facet lattice pegs.push( ...circle(600, 420, 200, 15, el), ...grid(490, 340, 3, 3, 110, 90, el), ...line(420, 680, 780, 680, 5, el), ...line(470, 760, 730, 760, 4, el), ...line(160, 300, 320, 300, 3), ...line(880, 300, 1040, 300, 3), ); break; case 3: // Twin Stars — two pentagrams joined by a beam pegs.push( ...pentagram(330, 400, 170, 6, el), ...pentagram(870, 560, 170, 6, el), ...line(430, 470, 750, 500, 4, el), ...line(200, 740, 480, 740, 4), ...line(650, 220, 1000, 220, 4), ); break; default: // Grand Sigil — double circle, cross & diagonals pegs.push( ...circle(600, 470, 260, 18, el), ...circle(600, 470, 130, 10, el), ...line(600, 230, 600, 340, 2, el), ...line(600, 610, 600, 720, 2, el), ...line(350, 470, 460, 470, 2, el), ...line(740, 470, 850, 470, 2, el), ...line(300, 240, 420, 330, 2), ...line(900, 240, 780, 330, 2), ...line(300, 720, 420, 620, 2), ...line(900, 720, 780, 620, 2), ...line(480, 780, 720, 780, 3), ); break; } return levelFile(settle(pegs)); } // ── Fireball (16-20): rings, rays & bursts ─────────────────────────────────── function fireballLevel(v) { const pegs = []; switch (v) { case 0: // X-Ray Vision — concentric rings, orange core pegs.push( ...circle(600, 470, 90, 8, el), ...circle(600, 470, 175, 14, el), ...circle(600, 470, 260, 20, el), ...arc(600, 470, 345, Math.PI * -0.35, Math.PI * 1.35, 22), { x: 600, y: 470, ...el }, ...line(160, 740, 400, 740, 3), ...line(800, 740, 1040, 740, 3), ); break; case 1: { // Solar Flare — core + radial rays pegs.push(...circle(600, 470, 110, 9, el), { x: 600, y: 470, ...el }); for (let i = 0; i < 8; i++) { const a = (i * Math.PI * 2) / 8 + Math.PI / 8; pegs.push(...line( 600 + 180 * Math.cos(a), 470 + 180 * Math.sin(a), 600 + 400 * Math.cos(a), 470 + 400 * Math.sin(a), 4, el, )); } break; } case 2: // Atomic Age — three overlapping orbit rings + nucleus pegs.push( ...circle(600, 470, 60, 6, el), ...circle(440, 470, 190, 13, el), ...circle(760, 470, 190, 13, el), ...circle(600, 320, 190, 13, el), ...line(200, 760, 440, 760, 3), ...line(760, 760, 1000, 760, 3), ); break; case 3: { // Meteor Shower — diagonal streaks with cores for (let i = 0; i < 5; i++) { const x0 = 140 + i * 210; pegs.push(...line(x0, 230, x0 + 150, 720, 7, el)); pegs.push({ x: x0 + 160, y: 760, ...el }); } pegs.push(...line(180, 200, 1020, 200, 6)); break; } default: // Supernova — rings + rays + corner shields pegs.push( ...circle(600, 470, 90, 8, el), ...circle(600, 470, 210, 16, el), ); for (let i = 0; i < 6; i++) { const a = (i * Math.PI * 2) / 6; pegs.push(...line( 600 + 260 * Math.cos(a), 470 + 260 * Math.sin(a), 600 + 420 * Math.cos(a), 470 + 420 * Math.sin(a), 3, el, )); } pegs.push( ...arc(140, 210, 150, 0, Math.PI * 0.5, 4), ...arc(1060, 210, 150, Math.PI * 0.5, Math.PI, 4), ...line(430, 780, 770, 780, 4), ); break; } return levelFile(settle(pegs)); } // ── Victor (21-25): boards & lattices ──────────────────────────────────────── function victorLevel(v) { const pegs = []; switch (v) { case 0: // Opening Move — light checker rows pegs.push( ...grid(220, 300, 6, 3, 150, 150, el), ...grid(295, 375, 6, 3, 150, 150, el), ...line(160, 210, 1040, 210, 6), ); break; case 1: { // Knight's Tour — L-shaped clusters const L = (x, y) => [ ...line(x, y, x, y + 180, 3, el), ...line(x, y + 180, x + 190, y + 180, 3, el), ]; pegs.push( ...L(240, 260), ...L(520, 220), ...L(800, 260), ...L(380, 520), ...L(660, 480), ...L(940, 520), ...line(160, 760, 460, 760, 4), ...line(740, 760, 1040, 760, 4), ); break; } case 2: // Double Gambit — chevrons for (let i = 0; i < 4; i++) { const y = 260 + i * 140; pegs.push( ...line(240, y, 600, y + 90, 6, el), ...line(600, y + 90, 960, y, 6, el), ); } break; case 3: // Fortress — walls, towers & courtyard pegs.push( ...line(220, 260, 980, 260, 9, el), ...line(220, 700, 980, 700, 9, el), ...line(220, 330, 220, 630, 4, el), ...line(475, 330, 475, 630, 4, el), ...line(725, 330, 725, 630, 4, el), ...line(980, 330, 980, 630, 4, el), ...grid(330, 400, 2, 2, 100, 130, el), ...grid(580, 400, 2, 2, 100, 130, el), ...grid(830, 400, 2, 2, 100, 130, el), ); break; default: // Checkmate — the dense diagonal lattice pegs.push( ...grid(180, 250, 8, 5, 120, 120, el), ...grid(240, 310, 8, 5, 120, 120, el), ...line(160, 210, 1040, 210, 8), ); break; } return levelFile(settle(pegs)); } // ── Manifest + write ───────────────────────────────────────────────────────── const BLOCKS = [ { masterId: 'ethel', powerId: 'superguide', background: 'parlor', build: ethelLevel, names: ['Tea Time', 'Fine China', 'Doily Drop', 'Lace & Grace', "Grandma's Attic"], }, { masterId: 'kona', powerId: 'multiball', background: 'park', build: konaLevel, names: ['Fetch!', 'Paw Prints', 'Buried Bones', 'Squeaky Toy', 'Best in Show'], }, { masterId: 'zanthor', powerId: 'spaceblast', background: 'arcana', build: zanthorLevel, names: ['Hocus Pocus', 'Crescent Moon', 'Crystal Ball', 'Twin Stars', 'Grand Sigil'], }, { masterId: 'fireball', powerId: 'fireball', background: 'lab', build: fireballLevel, names: ['X-Ray Vision', 'Solar Flare', 'Atomic Age', 'Meteor Shower', 'Supernova'], }, { masterId: 'victor', powerId: 'zenball', background: 'study', build: victorLevel, names: ['Opening Move', "Knight's Tour", 'Double Gambit', 'Fortress', 'Checkmate'], }, ]; mkdirSync(OUT_DIR, { recursive: true }); const manifestLevels = []; let levelNo = 0; for (const block of BLOCKS) { for (let v = 0; v < 5; v++) { levelNo++; const file = `level-${String(levelNo).padStart(3, '0')}.json`; const data = block.build(v); const eligible = data.pegs.filter((p) => p.orangeEligible).length; if (eligible < data.orangeCount) { throw new Error(`${file} (${block.names[v]}): only ${eligible} orange-eligible pegs (< ${data.orangeCount})`); } writeFileSync(join(OUT_DIR, file), `${JSON.stringify(data, null, 2)}\n`); console.log(`${file}: ${block.names[v]} [${block.masterId}] — ${data.pegs.length} pegs (${eligible} orange-eligible)`); manifestLevels.push({ level: levelNo, name: block.names[v], masterId: block.masterId, powerId: block.powerId, file, background: block.background, }); } } const manifest = { version: 1, levels: manifestLevels }; writeFileSync(join(OUT_DIR, 'levels.json'), `${JSON.stringify(manifest, null, 2)}\n`); console.log(`levels.json: ${manifest.levels.length} levels`);