// Generates the Peggle campaign's second wave: levels 26-85, 5 per friend. // node tools/genPeggleLevels.js // Writes assets/gamedata/peggle/level-026..085.json and levels.json. // // Levels 1-25 (Ethel/Kona/Zanthor/Fireball/Victor) were generated once and // then HAND-TUNED in the editor (index.html?peggle-editor=1) — this script // never rewrites their files and carries their levels.json entries over // verbatim. The original 1-25 builders live in git history. // // Blocks: 26-30 Smasher (bodyslam), 31-35 Maurice (beaverdam), 36-40 Schooner // (tailwind), 41-45 Terry (meteor), 46-50 Gerome (extremeball), 51-55 // Blackwind (cannonball), 56-60 Steve (beamup), 61-65 Nicole (overclock), // 66-70 DV-8-2303 (lasergrid), 71-75 Kage (shadowslash), 76-80 Aiko // (cosmicbloom), 81-85 Nadia (rewind). // // Layouts are starting points meant to be hand-tuned later in the editor; // pegs violating spacing are dropped, so shapes may thin slightly where // strokes intersect. Every generated level is validated against the same // constraints tools/verifyPeggle.js lints (bounds, overlap, brick clearance, // orange-eligible counts, reachability) and the script fails if any level // breaks one. import { writeFileSync, readFileSync, mkdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { TUNING, tessellateCurves, createRound, scoreShot } 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; const D = Math.PI / 180; // layouts below think in degrees // ── 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); } // Full ring starting at `startDeg` (for interleaving concentric rings). function ringAt(cx, cy, radius, n, startDeg, opts = {}) { return arc(cx, cy, radius, startDeg * D, (startDeg + 360 * (1 - 1 / n)) * D, n, opts); } function ellipseRing(cx, cy, rx, ry, n, opts = {}) { const pegs = []; for (let i = 0; i < n; i++) { const a = (i * Math.PI * 2) / n - Math.PI / 2; pegs.push({ x: cx + rx * Math.cos(a), y: cy + ry * Math.sin(a), ...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 = {}) { const curves = over.curves ?? []; let placed = pegs; if (curves.length) { // Cull circle pegs that would collide with the brick curves. const bricks = tessellateCurves(curves); placed = pegs.filter((p) => { const min = p.r + TUNING.BRICK_WID / 2 + GAP + 4; return !bricks.some((b) => (p.x - b.x) ** 2 + (p.y - b.y) ** 2 < min * min); }); } return { version: 1, board: { width: TUNING.BOARD_W, height: TUNING.BOARD_H }, orangeCount: 25, greenCount: 2, pegs: placed, walls: [], moving: [], ...over, curves, }; } // Brick-curve shorthand: anchors + per-segment bends, orange-eligible. const wave = (anchors, bends, orangeEligible = true) => ({ anchors, bends, orangeEligible }); const seg = (x0, y0, x1, y1, bend = 0) => wave([{ x: x0, y: y0 }, { x: x1, y: y1 }], [bend]); const el = { orangeEligible: true }; // ── The Smasher (26-30): the wrestling arena ───────────────────────────────── function smasherLevel(v) { const pegs = []; const curves = []; switch (v) { case 0: // Squared Circle — the ring from above: ropes, posts, mat pegs.push( ...line(350, 260, 850, 260, 11, el), ...line(350, 640, 850, 640, 11, el), ...line(350, 315, 350, 585, 6, el), ...line(850, 315, 850, 585, 6, el), { x: 295, y: 210 }, { x: 905, y: 210 }, { x: 295, y: 690 }, { x: 905, y: 690 }, ); curves.push(seg(280, 180, 920, 180, 35), seg(280, 725, 920, 725, -35)); break; case 1: // Top Rope — three sagging ropes over a roaring crowd pegs.push( ...line(150, 680, 1050, 680, 14, el), ...line(185, 745, 1015, 745, 13, el), ...line(150, 240, 150, 520, 5, el), ...line(1050, 240, 1050, 520, 5, el), ); curves.push( seg(220, 250, 980, 250, 55), seg(220, 380, 980, 380, 55), seg(220, 510, 980, 510, 55), ); break; case 2: // Turnbuckle — crossed slam trajectories through a center ring pegs.push( ...line(210, 195, 990, 760, 13, el), ...line(990, 195, 210, 760, 13, el), ...circle(600, 470, 100, 8, el), { x: 160, y: 230 }, { x: 1040, y: 230 }, { x: 160, y: 720 }, { x: 1040, y: 720 }, ); curves.push(seg(430, 800, 770, 800, 0)); break; case 3: // Championship Belt — the big gold buckle pegs.push( ...circle(600, 480, 115, 12, el), ...circle(600, 480, 58, 6, el), { x: 600, y: 480, ...el }, ...arc(600, 480, 205, 200 * D, 340 * D, 9, el), ...arc(600, 480, 205, 25 * D, 155 * D, 7, el), ); curves.push(seg(140, 480, 420, 480, 25), seg(780, 480, 1060, 480, 25)); break; default: // Steel Cage — the mesh comes down pegs.push( ...grid(260, 220, 8, 3, 100, 200, el), ...grid(320, 320, 8, 3, 100, 200, el), ); curves.push(seg(190, 195, 190, 760, 0), seg(1010, 195, 1010, 760, 0)); break; } return levelFile(settle(pegs), { curves }); } // ── Maurice (31-35): the riverside ─────────────────────────────────────────── function mauriceLevel(v) { const pegs = []; const curves = []; switch (v) { case 0: // Timber! — felled logs mid-topple pegs.push( ...line(170, 250, 520, 350, 7, el), ...line(690, 200, 1040, 320, 7, el), ...line(150, 530, 500, 450, 7, el), ...line(660, 500, 1050, 580, 8, el), ...line(300, 690, 420, 690, 3), ...line(750, 715, 870, 715, 3), ); curves.push(seg(200, 790, 1000, 790, 0)); break; case 1: // Lazy River — the water winds between grassy banks pegs.push( ...line(150, 210, 1050, 210, 10, el), ...line(150, 680, 1050, 680, 10, el), ...circle(300, 580, 45, 5, el), ...circle(880, 605, 45, 5, el), ); curves.push(wave( [{ x: 110, y: 320 }, { x: 420, y: 460 }, { x: 800, y: 370 }, { x: 1090, y: 500 }], [100, -100, 100], )); break; case 2: // The Lodge — the stick dome on the water pegs.push( ...arc(600, 560, 225, 180 * D, 360 * D, 12, el), ...arc(600, 560, 152, 180 * D, 360 * D, 8, el), ...arc(600, 560, 82, 180 * D, 360 * D, 5, el), ...line(330, 590, 870, 590, 8, el), ); curves.push(wave([{ x: 230, y: 705 }, { x: 600, y: 705 }, { x: 970, y: 705 }], [45, -45])); break; case 3: // Log Jam — timber stacked every which way pegs.push( ...line(150, 250, 400, 250, 5, el), ...line(500, 225, 820, 225, 6, el), ...line(890, 290, 1075, 290, 4, el), ...line(200, 420, 520, 420, 6, el), ...line(620, 455, 1000, 455, 7, el), ...line(150, 610, 450, 610, 6, el), ...line(560, 645, 900, 645, 7, el), ...line(300, 780, 900, 780, 7), ); curves.push(seg(985, 615, 1080, 735, 0)); break; default: // Grand Dam — Maurice's masterpiece holds back the flood pegs.push( ...arc(600, 430, 135, 195 * D, 345 * D, 8, el), ...arc(600, 430, 210, 200 * D, 340 * D, 10, el), ...line(250, 700, 950, 700, 8, el), ...line(300, 765, 900, 765, 7, el), ); curves.push(wave([{ x: 150, y: 565 }, { x: 600, y: 460 }, { x: 1050, y: 565 }], [30, 30])); break; } return levelFile(settle(pegs), { curves }); } // ── Schooner (36-40): the high seas ────────────────────────────────────────── function schoonerLevel(v) { const pegs = []; const curves = []; switch (v) { case 0: // Ahoy! — the anchor drops pegs.push( ...circle(600, 245, 50, 6, el), ...line(600, 335, 600, 555, 4, el), ...line(480, 335, 720, 335, 5, el), ...arc(600, 470, 165, 25 * D, 155 * D, 8, el), { x: 415, y: 505, ...el }, { x: 785, y: 505, ...el }, ); curves.push(seg(140, 660, 460, 660, 40), seg(740, 660, 1060, 660, 40)); break; case 1: // Full Sail — canvas stretched before the wind pegs.push( ...line(600, 200, 600, 700, 8, el), ...line(650, 240, 950, 590, 7, el), ...line(660, 630, 940, 630, 5, el), ...line(550, 300, 310, 590, 6, el), ...line(320, 630, 540, 630, 4, el), ); curves.push(seg(300, 725, 900, 725, 45)); break; case 2: // Crow's Nest — rigging up to the lookout pegs.push( ...circle(600, 265, 72, 8, el), ...line(600, 380, 600, 700, 5, el), ...line(552, 345, 210, 700, 7, el), ...line(648, 345, 990, 700, 7, el), ...line(410, 520, 530, 520, 3, el), ...line(670, 520, 790, 520, 3, el), ...line(320, 630, 520, 630, 3, el), ...line(680, 630, 880, 630, 3, el), ); curves.push(seg(200, 780, 1000, 780, 30)); break; case 3: // Rolling Swells — wave after wave after wave pegs.push( ...arc(280, 245, 45, 200 * D, 340 * D, 3, el), ...arc(560, 220, 45, 200 * D, 340 * D, 3, el), ...arc(840, 250, 45, 200 * D, 340 * D, 3, el), { x: 1055, y: 210, ...el }, ); curves.push( wave([{ x: 120, y: 360 }, { x: 600, y: 360 }, { x: 1080, y: 360 }], [70, -70]), wave([{ x: 120, y: 530 }, { x: 600, y: 530 }, { x: 1080, y: 530 }], [-70, 70]), wave([{ x: 120, y: 700 }, { x: 600, y: 700 }, { x: 1080, y: 700 }], [70, -70]), ); break; default: // Maiden Voyage — the good ship herself pegs.push( ...line(300, 465, 900, 465, 8, el), ...line(450, 200, 450, 415, 4, el), ...line(750, 200, 750, 415, 4, el), ...arc(450, 315, 85, -80 * D, 80 * D, 5, el), ...arc(750, 315, 85, -80 * D, 80 * D, 5, el), { x: 280, y: 430, ...el }, { x: 930, y: 430, ...el }, ); curves.push( seg(250, 520, 950, 520, 120), seg(180, 720, 480, 720, 35), seg(720, 720, 1020, 720, 35), ); break; } return levelFile(settle(pegs), { curves }); } // ── Terry (41-45): the Mesozoic ────────────────────────────────────────────── const footprint = (x, y) => [ { x, y, ...el }, { x: x - 46, y: y - 48, ...el }, { x, y: y - 60, ...el }, { x: x + 46, y: y - 48, ...el }, ]; function terryLevel(v) { const pegs = []; const curves = []; switch (v) { case 0: // Footprints — something big walked through here pegs.push( ...footprint(250, 720), ...footprint(450, 595), ...footprint(330, 465), ...footprint(510, 335), ...footprint(390, 255), ...line(850, 280, 850, 700, 5, el), ...line(950, 230, 950, 740, 6, el), ...line(1050, 330, 1050, 650, 4, el), ); curves.push(seg(640, 775, 1060, 775, 20)); break; case 1: // Tar Pits — bubbling and hungry pegs.push( ...circle(390, 640, 112, 9, el), ...circle(390, 640, 55, 4, el), ...circle(860, 590, 100, 8, el), { x: 860, y: 590, ...el }, { x: 350, y: 450, ...el }, { x: 430, y: 380, ...el }, { x: 300, y: 315, ...el }, { x: 520, y: 300, ...el }, { x: 830, y: 430, ...el }, { x: 910, y: 350, ...el }, { x: 770, y: 265, ...el }, { x: 600, y: 200, ...el }, { x: 600, y: 330, ...el }, ); curves.push(seg(150, 800, 1050, 800, 0)); break; case 2: // Bone Yard — ribs, femurs, and one grinning skull pegs.push( ...line(280, 250, 880, 250, 8, el), ...arc(320, 260, 160, 75 * D, 105 * D, 3, el), ...arc(460, 260, 160, 75 * D, 105 * D, 3, el), ...arc(600, 260, 160, 75 * D, 105 * D, 3, el), ...arc(740, 260, 160, 75 * D, 105 * D, 3, el), ...arc(880, 260, 160, 75 * D, 105 * D, 3, el), ...circle(960, 620, 72, 7, el), { x: 935, y: 600 }, { x: 985, y: 600 }, ...line(150, 650, 400, 755, 5, el), { x: 120, y: 610, ...el }, { x: 440, y: 785, ...el }, ); curves.push(wave([{ x: 140, y: 330 }, { x: 260, y: 215 }], [-25])); break; case 3: // Volcano Day — duck and cover pegs.push( ...line(350, 760, 560, 420, 6, el), ...line(850, 760, 640, 420, 6, el), ...arc(600, 395, 120, 205 * D, 335 * D, 7, el), ...arc(600, 395, 190, 210 * D, 330 * D, 9, el), ...line(150, 780, 1050, 780, 11, el), ); curves.push(seg(480, 170, 720, 170, -20)); break; default: // Extinction Event — the sky is falling pegs.push( ...circle(300, 620, 58, 6, el), { x: 300, y: 620, ...el }, ...line(420, 530, 1040, 200, 8, el), ...line(520, 655, 1080, 320, 7, el), ...arc(300, 620, 190, -60 * D, 35 * D, 6, el), ); curves.push(seg(150, 785, 1050, 785, 0)); break; } return levelFile(settle(pegs), { curves }); } // ── Gerome (46-50): the skate park ─────────────────────────────────────────── function geromeLevel(v) { const pegs = []; const curves = []; switch (v) { case 0: // Kickflip — board's up, wheels spinning pegs.push( ...circle(430, 590, 46, 6, el), ...circle(770, 590, 46, 6, el), ...circle(600, 300, 95, 8, el), { x: 600, y: 300, ...el }, ...line(200, 745, 1000, 745, 9, el), ); curves.push(wave([{ x: 290, y: 490 }, { x: 600, y: 465 }, { x: 910, y: 490 }], [-22, -22])); break; case 1: // Half-Pipe — drop in! pegs.push( ...arc(180, 285, 90, -60 * D, 15 * D, 4, el), ...arc(1020, 285, 90, 165 * D, 240 * D, 4, el), ...circle(600, 335, 85, 7, el), ...line(450, 780, 750, 780, 4, el), ); curves.push( wave([{ x: 180, y: 275 }, { x: 600, y: 620 }, { x: 1020, y: 275 }], [85, -85]), wave([{ x: 265, y: 300 }, { x: 600, y: 540 }, { x: 935, y: 300 }], [60, -60]), ); break; case 2: // Slalom — thread the gates pegs.push( ...line(200, 200, 500, 320, 6, el), ...line(500, 320, 250, 450, 5, el), ...line(250, 450, 550, 570, 6, el), ...line(550, 570, 300, 700, 5, el), ...line(1000, 200, 700, 320, 6, el), ...line(700, 320, 950, 450, 5, el), ...line(950, 450, 650, 570, 6, el), ...line(650, 570, 900, 700, 5, el), ); curves.push(seg(440, 775, 760, 775, 0)); break; case 3: // Big Air — launch, soar, land pegs.push( ...arc(600, 520, 175, 215 * D, 325 * D, 7, el), ...circle(600, 260, 62, 6, el), { x: 600, y: 400, ...el }, ...line(480, 765, 720, 765, 4, el), ...line(220, 260, 220, 400, 3, el), ...line(980, 260, 980, 400, 3, el), ); curves.push( wave([{ x: 140, y: 700 }, { x: 440, y: 460 }], [-55]), wave([{ x: 760, y: 460 }, { x: 1060, y: 700 }], [-55]), ); break; default: // Mega Ramp — the one they write about pegs.push( ...line(800, 300, 1080, 560, 5, el), ...line(1080, 300, 800, 560, 5, el), ...arc(660, 520, 155, 245 * D, 325 * D, 5, el), ...line(200, 770, 1000, 770, 9, el), { x: 940, y: 200, ...el }, { x: 760, y: 210, ...el }, ); curves.push(wave([{ x: 130, y: 190 }, { x: 460, y: 590 }, { x: 720, y: 680 }], [-75, -30])); break; } return levelFile(settle(pegs), { curves }); } // ── Blackwind (51-55): the pirate cove ─────────────────────────────────────── function blackwindLevel(v) { const pegs = []; const curves = []; switch (v) { case 0: // Jolly Roger — hoist the colors pegs.push( ...circle(600, 330, 128, 11, el), { x: 552, y: 310 }, { x: 648, y: 310 }, ...arc(600, 370, 55, 55 * D, 125 * D, 2, el), ...line(380, 545, 820, 705, 6, el), ...line(820, 545, 380, 705, 6, el), { x: 330, y: 520, ...el }, { x: 870, y: 520, ...el }, { x: 330, y: 730, ...el }, { x: 870, y: 730, ...el }, ); curves.push(seg(300, 178, 900, 178, -22)); break; case 1: // Broadside — cannons roar across the water pegs.push( ...line(370, 330, 830, 330, 6, el), ...line(370, 480, 830, 480, 6, el), ...line(370, 630, 830, 630, 6, el), ...line(400, 720, 800, 275, 5, el), { x: 210, y: 210, ...el }, { x: 990, y: 210, ...el }, ); curves.push(seg(250, 290, 250, 700, 45), seg(950, 290, 950, 700, -45)); break; case 2: // Treasure Hold — X marks the peg pegs.push( ...line(400, 700, 800, 700, 6, el), ...line(440, 640, 760, 640, 5, el), ...line(480, 580, 720, 580, 4, el), ...line(520, 520, 680, 520, 3, el), ...line(560, 460, 640, 460, 2, el), { x: 600, y: 405, ...el }, ...arc(600, 430, 175, 190 * D, 350 * D, 8, el), ); curves.push(seg(210, 280, 210, 700, 0), seg(990, 280, 990, 700, 0)); break; case 3: // Ship's Wheel — steady as she goes pegs.push( ...circle(600, 450, 45, 6, el), ...ringAt(600, 450, 190, 14, -90, el), ...ringAt(600, 450, 100, 8, -67.5, el), ...ringAt(600, 450, 145, 8, -67.5, el), ...ringAt(600, 450, 238, 8, -67.5, el), ); curves.push(seg(250, 762, 950, 762, 30)); break; default: // Davy Jones — beware what waits below pegs.push( ...arc(600, 340, 140, 180 * D, 360 * D, 9, el), { x: 548, y: 335 }, { x: 652, y: 335 }, { x: 190, y: 250, ...el }, { x: 255, y: 195, ...el }, { x: 160, y: 400, ...el }, { x: 1010, y: 250, ...el }, { x: 945, y: 195, ...el }, { x: 1040, y: 400, ...el }, ...line(150, 785, 1050, 785, 10, el), ); curves.push( wave([{ x: 480, y: 425 }, { x: 420, y: 585 }, { x: 470, y: 725 }], [55, -55]), wave([{ x: 560, y: 445 }, { x: 560, y: 610 }, { x: 520, y: 740 }], [-45, 45]), wave([{ x: 640, y: 445 }, { x: 640, y: 610 }, { x: 680, y: 740 }], [45, -45]), wave([{ x: 720, y: 425 }, { x: 780, y: 585 }, { x: 730, y: 725 }], [-55, 55]), ); break; } return levelFile(settle(pegs), { curves }); } // ── Steve (56-60): low Earth orbit ─────────────────────────────────────────── function steveLevel(v) { const pegs = []; const curves = []; switch (v) { case 0: // Crop Circles — the wheat field tells a story pegs.push( ...circle(350, 320, 112, 9, el), { x: 350, y: 320, ...el }, ...circle(830, 285, 82, 7, el), ...circle(600, 610, 150, 11, el), ...circle(600, 610, 76, 6, el), { x: 600, y: 610, ...el }, ); curves.push(seg(150, 795, 1050, 795, 0)); break; case 1: // Little Green Pegs — they came in peace(?) pegs.push( ...grid(250, 220, 6, 3, 140, 100, el), ...grid(320, 270, 5, 2, 140, 100, el), ...arc(300, 690, 72, 180 * D, 360 * D, 5, el), ...arc(600, 690, 72, 180 * D, 360 * D, 5, el), ...arc(900, 690, 72, 180 * D, 360 * D, 5, el), { x: 600, y: 775 }, ); break; case 2: // Tractor Beam — hold still, Earth creature pegs.push( ...arc(600, 300, 100, 180 * D, 360 * D, 7, el), ...line(380, 330, 820, 330, 7, el), ...line(520, 390, 380, 760, 6, el), ...line(680, 390, 820, 760, 6, el), { x: 600, y: 500, ...el }, { x: 555, y: 600, ...el }, { x: 645, y: 650, ...el }, { x: 600, y: 730, ...el }, { x: 180, y: 220, ...el }, { x: 1020, y: 220, ...el }, { x: 150, y: 450, ...el }, { x: 1050, y: 450, ...el }, ); curves.push(seg(430, 800, 770, 800, 0)); break; case 3: // Mothership — now THAT'S a saucer pegs.push( ...arc(600, 450, 225, 195 * D, 345 * D, 10, el), ...line(250, 450, 950, 450, 8, el), ...arc(600, 430, 122, 190 * D, 350 * D, 6, el), ...line(350, 505, 850, 505, 6, el), ); curves.push( seg(400, 580, 355, 765, 20), seg(600, 585, 600, 785, 0), seg(800, 580, 845, 765, -20), ); break; default: // Close Encounter — a ring of lights over the field pegs.push( ...circle(600, 450, 232, 16, el), { x: 520, y: 380, ...el }, { x: 655, y: 345, ...el }, { x: 700, y: 470, ...el }, { x: 560, y: 525, ...el }, { x: 478, y: 462, ...el }, { x: 200, y: 195, ...el }, { x: 1000, y: 195, ...el }, { x: 150, y: 660, ...el }, { x: 1050, y: 660, ...el }, { x: 320, y: 760, ...el }, { x: 880, y: 760, ...el }, ); curves.push(wave([{ x: 150, y: 275 }, { x: 450, y: 175 }], [-20])); break; } return levelFile(settle(pegs), { curves }); } // ── Nicole (61-65): inside the mainframe ───────────────────────────────────── function nicoleLevel(v) { const pegs = []; const curves = []; switch (v) { case 0: // Access Granted — the login screen falls pegs.push( ...line(230, 200, 590, 200, 5, el), ...line(230, 280, 790, 280, 8, el), ...line(230, 360, 500, 360, 4, el), ...line(230, 440, 710, 440, 7, el), ...line(230, 520, 590, 520, 5, el), ...line(230, 600, 870, 600, 9, el), { x: 950, y: 600, ...el }, ...line(300, 720, 900, 720, 7, el), ); curves.push(seg(160, 190, 160, 640, 0)); break; case 1: // Firewall — burn through it pegs.push( { x: 250, y: 235, ...el }, { x: 450, y: 220, ...el }, { x: 650, y: 235, ...el }, { x: 850, y: 215, ...el }, { x: 350, y: 285, ...el }, { x: 550, y: 290, ...el }, { x: 750, y: 285, ...el }, { x: 950, y: 275, ...el }, ...line(250, 680, 950, 680, 8, el), ...line(300, 755, 900, 755, 7, el), ); curves.push( seg(200, 380, 430, 380, 0), seg(510, 380, 740, 380, 0), seg(820, 380, 1050, 380, 0), seg(160, 480, 390, 480, 0), seg(470, 480, 700, 480, 0), seg(780, 480, 1010, 480, 0), seg(200, 580, 430, 580, 0), seg(510, 580, 740, 580, 0), seg(820, 580, 1050, 580, 0), ); break; case 2: { // Binary Rain — ones and zeroes all the way down const cols = [5, 3, 6, 4, 7, 4, 6, 3, 5]; cols.forEach((count, c) => { const x = 200 + c * 100; const y0 = c % 2 ? 240 : 195; for (let i = 0; i < count; i++) pegs.push({ x, y: y0 + i * 88, ...el }); }); curves.push(seg(150, 795, 1050, 795, 0)); break; } case 3: // Motherboard — don't touch the CPU pegs.push( ...line(460, 295, 740, 295, 5, el), ...line(460, 605, 740, 605, 5, el), ...line(395, 360, 395, 540, 4, el), ...line(805, 360, 805, 540, 4, el), { x: 520, y: 420, ...el }, { x: 680, y: 420, ...el }, { x: 520, y: 480, ...el }, { x: 680, y: 480, ...el }, ...line(330, 235, 180, 235, 3, el), ...line(870, 235, 1020, 235, 3, el), ...line(330, 665, 180, 665, 3, el), ...line(870, 665, 1020, 665, 3, el), { x: 180, y: 300, ...el }, { x: 1020, y: 300, ...el }, { x: 180, y: 730, ...el }, { x: 1020, y: 730, ...el }, ); curves.push( seg(450, 350, 750, 350, 0), seg(750, 350, 750, 550, 0), seg(450, 550, 750, 550, 0), seg(450, 350, 450, 550, 0), ); break; default: // Root Access — follow the tree to its root pegs.push( { x: 600, y: 195, ...el }, ...arc(600, 195, 62, 35 * D, 145 * D, 3, el), { x: 400, y: 300, ...el }, { x: 800, y: 300, ...el }, { x: 500, y: 245, ...el }, { x: 700, y: 245, ...el }, { x: 280, y: 440, ...el }, { x: 520, y: 440, ...el }, { x: 680, y: 440, ...el }, { x: 920, y: 440, ...el }, { x: 340, y: 370, ...el }, { x: 460, y: 370, ...el }, { x: 740, y: 370, ...el }, { x: 860, y: 370, ...el }, ...line(200, 600, 480, 600, 3, el), ...line(720, 600, 1000, 600, 3, el), ...line(250, 740, 950, 740, 6, el), { x: 150, y: 300, ...el }, { x: 1050, y: 300, ...el }, { x: 150, y: 480, ...el }, { x: 1050, y: 480, ...el }, ); curves.push(seg(250, 800, 950, 800, 0)); break; } return levelFile(settle(pegs), { curves }); } // ── DV-8-2303 (66-70): the robot works ─────────────────────────────────────── function dvLevel(v) { const pegs = []; const curves = []; switch (v) { case 0: // Assembly Line — units roll off the belt pegs.push( ...line(250, 370, 950, 370, 8, el), ...line(300, 570, 1000, 570, 8, el), ...line(350, 195, 350, 300, 3, el), ...line(750, 195, 750, 300, 3, el), ...circle(600, 250, 55, 5, el), ...line(300, 740, 900, 740, 6, el), ); curves.push(seg(150, 425, 1050, 425, 0), seg(150, 625, 1050, 625, 0)); break; case 1: // Gear Works — teeth within teeth pegs.push( ...circle(400, 350, 120, 10, el), ...circle(400, 350, 52, 5, el), ...ringAt(400, 350, 165, 8, -67.5, el), ...circle(800, 560, 100, 8, el), { x: 800, y: 560, ...el }, ...ringAt(800, 560, 145, 7, -65, el), ); curves.push(seg(230, 730, 1010, 730, 25)); break; case 2: // Servo Loop — round and round the circuit pegs.push( ...ellipseRing(600, 480, 380, 250, 18, el), ...ellipseRing(600, 480, 245, 140, 12, el), ...circle(600, 480, 62, 5, el), ); curves.push(seg(460, 158, 740, 158, 0)); break; case 3: // Laser Lattice — mind the beams pegs.push( ...line(200, 195, 840, 780, 9, el), ...line(450, 195, 1050, 745, 8, el), ...line(1000, 195, 360, 780, 9, el), ...line(750, 195, 150, 745, 8, el), ); break; default: // Defeat Protocol — face the machine pegs.push( ...circle(510, 355, 46, 5, el), ...circle(690, 355, 46, 5, el), { x: 600, y: 450, ...el }, ...line(480, 535, 720, 535, 4, el), ...line(480, 580, 720, 580, 4, el), { x: 325, y: 300, ...el }, { x: 325, y: 430, ...el }, { x: 325, y: 560, ...el }, { x: 875, y: 300, ...el }, { x: 875, y: 430, ...el }, { x: 875, y: 560, ...el }, ...line(300, 750, 900, 750, 6, el), ); curves.push( seg(415, 215, 785, 215, 0), seg(415, 655, 785, 655, 0), seg(390, 240, 390, 630, 0), seg(810, 240, 810, 630, 0), ); break; } return levelFile(settle(pegs), { curves }); } // ── Kage (71-75): the moonlit dojo ─────────────────────────────────────────── function kageLevel(v) { const pegs = []; const curves = []; switch (v) { case 0: { // Bamboo Grove — stalks in the mist const cols = [[250, 195, 6], [400, 240, 6], [550, 195, 7], [700, 240, 6], [850, 195, 7], [1000, 240, 6]]; for (const [x, y0, n] of cols) for (let i = 0; i < n; i++) pegs.push({ x, y: y0 + i * 92, ...el }); curves.push(seg(150, 800, 1050, 800, 0)); break; } case 1: // Shuriken — thrown true pegs.push( ...circle(600, 470, 62, 6, el), // four blades: two edge pegs per side + a tip { x: 560, y: 335, ...el }, { x: 545, y: 245, ...el }, { x: 640, y: 335, ...el }, { x: 655, y: 245, ...el }, { x: 600, y: 195, ...el }, { x: 735, y: 430, ...el }, { x: 825, y: 415, ...el }, { x: 735, y: 510, ...el }, { x: 825, y: 525, ...el }, { x: 875, y: 470, ...el }, { x: 560, y: 605, ...el }, { x: 545, y: 695, ...el }, { x: 640, y: 605, ...el }, { x: 655, y: 695, ...el }, { x: 600, y: 745, ...el }, { x: 465, y: 430, ...el }, { x: 375, y: 415, ...el }, { x: 465, y: 510, ...el }, { x: 375, y: 525, ...el }, { x: 325, y: 470, ...el }, { x: 770, y: 300, ...el }, { x: 770, y: 640, ...el }, { x: 430, y: 300, ...el }, { x: 430, y: 640, ...el }, ); curves.push(wave([{ x: 150, y: 215 }, { x: 340, y: 170 }], [-18])); break; case 2: // Torii Gate — pass beneath with honor pegs.push( ...line(400, 400, 400, 760, 5, el), ...line(800, 400, 800, 760, 5, el), ...circle(600, 560, 92, 8, el), { x: 200, y: 660, ...el }, { x: 200, y: 730, ...el }, { x: 1000, y: 660, ...el }, { x: 1000, y: 730, ...el }, ); curves.push(seg(280, 255, 920, 255, -40), seg(345, 345, 855, 345, 0)); break; case 3: // Twin Katanas — crossed steel pegs.push( ...line(250, 700, 950, 220, 9, el), ...line(250, 220, 950, 700, 9, el), { x: 168, y: 757, ...el }, { x: 1032, y: 757, ...el }, ...arc(600, 255, 60, 180 * D, 360 * D, 4, el), { x: 380, y: 195, ...el }, { x: 820, y: 195, ...el }, { x: 150, y: 460, ...el }, { x: 1050, y: 460, ...el }, { x: 600, y: 780, ...el }, ); break; default: { // Silent Strike — one clean cut through the dark // A dense field with a diagonal slash of empty space through it. const ax = 150; const ay = 150; const bx = 1050; const by = 820; for (let r = 0; r < 8; r++) { const y = 200 + r * 80; const off = r % 2 ? 60 : 0; for (let c = 0; c < 7; c++) { const x = 220 + off + c * 120; const t = ((x - ax) * (bx - ax) + (y - ay) * (by - ay)) / ((bx - ax) ** 2 + (by - ay) ** 2); const px = ax + t * (bx - ax); const py = ay + t * (by - ay); if (Math.hypot(x - px, y - py) > 82) pegs.push({ x, y, ...el }); } } curves.push(seg(255, 245, 955, 745, 0)); break; } } return levelFile(settle(pegs), { curves }); } // ── Aiko (76-80): a garden between galaxies ────────────────────────────────── const triad = (x, y) => [ { x, y: y - 32, ...el }, { x: x - 30, y: y + 22, ...el }, { x: x + 30, y: y + 22, ...el }, ]; function aikoLevel(v) { const pegs = []; const curves = []; switch (v) { case 0: // First Contact — two worlds, fingertips apart pegs.push( ...arc(340, 450, 152, -55 * D, 55 * D, 6, el), ...arc(860, 450, 152, 125 * D, 235 * D, 6, el), ...circle(600, 450, 48, 5, el), { x: 600, y: 450, ...el }, { x: 230, y: 210, ...el }, { x: 970, y: 210, ...el }, { x: 180, y: 660, ...el }, { x: 1020, y: 660, ...el }, { x: 430, y: 195, ...el }, { x: 770, y: 195, ...el }, ); curves.push(seg(320, 720, 880, 720, -35)); break; case 1: // Spore Drift — seeds on the solar wind pegs.push( ...triad(250, 260), ...triad(520, 225), ...triad(800, 265), ...triad(1010, 420), ...triad(300, 480), ...triad(645, 450), ...triad(900, 630), ...triad(400, 700), ...triad(690, 725), ...triad(160, 600), ); curves.push( wave([{ x: 130, y: 345 }, { x: 480, y: 320 }, { x: 880, y: 375 }], [45, -45]), wave([{ x: 260, y: 590 }, { x: 620, y: 565 }, { x: 1040, y: 735 }], [-45, 45]), ); break; case 2: { // Spiral Galaxy — arms of stars pegs.push(...circle(600, 470, 46, 5, el)); for (const phase of [0, 180]) { for (let k = 1; k <= 12; k++) { const a = (phase + k * 40) * D; const r = 60 + k * 23; pegs.push({ x: 600 + r * Math.cos(a), y: 470 + r * Math.sin(a) * 0.82, ...el }); } } curves.push(wave([{ x: 150, y: 200 }, { x: 400, y: 260 }], [22])); break; } case 3: { // Double Helix — life, but not as we know it for (let i = 0; i <= 10; i++) { const y = 195 + i * 56; const s = Math.sin(i * 0.63); pegs.push({ x: 600 + 150 * s, y, ...el }); pegs.push({ x: 600 - 150 * s, y, ...el }); if (Math.abs(s) > 0.85) pegs.push({ x: 600, y, ...el }); } pegs.push( { x: 200, y: 250, ...el }, { x: 1000, y: 250, ...el }, { x: 200, y: 550, ...el }, { x: 1000, y: 550, ...el }, ); curves.push(seg(300, 800, 900, 800, 0)); break; } default: { // Full Bloom — the garden opens all at once pegs.push(...circle(600, 440, 56, 6, el), { x: 600, y: 440, ...el }); for (let k = 0; k < 6; k++) { const a = -90 + k * 60; const cx = 600 + 152 * Math.cos(a * D); const cy = 440 + 152 * Math.sin(a * D); pegs.push(...arc(cx, cy, 46, (a - 80) * D, (a + 80) * D, 4, el)); } pegs.push( { x: 210, y: 220, ...el }, { x: 255, y: 250, ...el }, { x: 945, y: 220, ...el }, { x: 990, y: 250, ...el }, ); curves.push(seg(615, 700, 570, 800, 18)); break; } } return levelFile(settle(pegs), { curves }); } // ── Nadia (81-85): the end of time ─────────────────────────────────────────── function nadiaLevel(v) { const pegs = []; const curves = []; switch (v) { case 0: // Hourglass — the sand runs out pegs.push( ...line(400, 210, 800, 210, 6, el), ...line(410, 260, 578, 428, 4, el), ...line(790, 260, 622, 428, 4, el), ...line(578, 492, 410, 660, 4, el), ...line(622, 492, 790, 660, 4, el), ...line(400, 710, 800, 710, 6, el), { x: 600, y: 470, ...el }, { x: 600, y: 545, ...el }, { x: 600, y: 620, ...el }, { x: 555, y: 655, ...el }, { x: 645, y: 655, ...el }, { x: 230, y: 400, ...el }, { x: 970, y: 400, ...el }, { x: 230, y: 550, ...el }, { x: 970, y: 550, ...el }, ); curves.push(seg(370, 165, 830, 165, 0), seg(370, 762, 830, 762, 0)); break; case 1: { // Pendulum — tick... tock... pegs.push({ x: 600, y: 195, ...el }); for (const deg of [-40, 0, 40]) { const s = Math.sin(deg * D); const c = Math.cos(deg * D); for (const r of [120, 240, 360]) pegs.push({ x: 600 + r * s, y: 195 + r * c, ...el }); pegs.push(...circle(600 + 455 * s, 195 + 455 * c, 55, 6, el)); } pegs.push(...arc(600, 195, 560, 52 * D, 128 * D, 7, el)); curves.push(seg(350, 150, 850, 150, 0)); break; } case 2: { // Time Spiral — it all comes around again pegs.push({ x: 600, y: 470, ...el }, ...circle(600, 470, 46, 5, el)); for (let k = 0; k <= 15; k++) { const a = k * 35 * D; const r = 330 - k * 16; pegs.push({ x: 600 + r * Math.cos(a), y: 470 + r * Math.sin(a) * 0.95, ...el }); } pegs.push( { x: 200, y: 200, ...el }, { x: 1000, y: 200, ...el }, { x: 200, y: 740, ...el }, { x: 1000, y: 740, ...el }, ); curves.push(wave([{ x: 890, y: 175 }, { x: 1050, y: 265 }], [20])); break; } case 3: // Paradox — two truths, one board pegs.push( ...circle(450, 420, 160, 12, el), ...circle(750, 520, 160, 12, el), { x: 200, y: 200, ...el }, { x: 240, y: 300, ...el }, { x: 180, y: 400, ...el }, { x: 230, y: 500, ...el }, { x: 170, y: 600, ...el }, { x: 1000, y: 200, ...el }, { x: 960, y: 300, ...el }, { x: 1020, y: 400, ...el }, { x: 970, y: 500, ...el }, { x: 1030, y: 600, ...el }, ); curves.push(seg(370, 745, 830, 745, 35)); break; default: { // End of Time — the great clock strikes pegs.push(...circle(600, 470, 258, 18, el), { x: 600, y: 470, ...el }); for (let h = 0; h < 12; h++) { const a = (h * 30 - 90) * D; pegs.push({ x: 600 + 208 * Math.cos(a), y: 470 + 208 * Math.sin(a), ...el }); } pegs.push( { x: 600, y: 410, ...el }, { x: 600, y: 350, ...el }, // minute hand → XII { x: 652, y: 500, ...el }, { x: 704, y: 530, ...el }, // hour hand → IV { x: 170, y: 195, ...el }, { x: 1030, y: 195, ...el }, { x: 170, y: 760, ...el }, { x: 1030, y: 760, ...el }, ); curves.push( wave([{ x: 200, y: 215 }, { x: 335, y: 168 }], [-15]), wave([{ x: 865, y: 168 }, { x: 1000, y: 215 }], [-15]), ); break; } } return levelFile(settle(pegs), { curves }); } // ── Validation (mirrors the verifyPeggle.js data lint) ─────────────────────── function validate(lvl, tag) { const errs = []; const T = TUNING; const bricks = tessellateCurves(lvl.curves ?? []); const boundR = Math.hypot(T.BRICK_LEN / 2, T.BRICK_WID / 2); for (const b of bricks) { if (b.x < boundR || b.x > T.BOARD_W - boundR || b.y < 140 || b.y > 820) { errs.push(`brick out of bounds at ${Math.round(b.x)},${Math.round(b.y)}`); } } for (const c of lvl.curves ?? []) { if ((c.anchors?.length ?? 0) < 2 || (c.bends?.length ?? -1) !== c.anchors.length - 1) errs.push('bad curve schema'); } for (const p of lvl.pegs) { if (p.x < p.r || p.x > T.BOARD_W - p.r || p.y < 140 || p.y > 820) errs.push(`peg out of bounds at ${p.x},${p.y}`); for (const b of bricks) { if ((p.x - b.x) ** 2 + (p.y - b.y) ** 2 < (p.r + T.BRICK_WID / 2) ** 2) errs.push(`peg ${p.x},${p.y} clashes a brick`); } } for (let i = 0; i < lvl.pegs.length; i++) { for (let j = i + 1; j < lvl.pegs.length; j++) { const a = lvl.pegs[i]; const b = lvl.pegs[j]; if ((a.x - b.x) ** 2 + (a.y - b.y) ** 2 < (a.r + b.r + 6) ** 2) errs.push(`pegs overlap at ${a.x},${a.y} / ${b.x},${b.y}`); } } const eligible = lvl.pegs.filter((p) => p.orangeEligible).length + bricks.filter((b) => b.orangeEligible).length; if (eligible < lvl.orangeCount) errs.push(`only ${eligible} orange-eligible pegs+bricks (< ${lvl.orangeCount})`); if (lvl.pegs.length + bricks.length < lvl.orangeCount + lvl.greenCount + 1) errs.push('too few pegs overall'); // Playability: a straight-ish shot must reach at least one peg. const st = createRound(lvl, { seed: 1 }); let anyHit = false; for (const ang of [-0.5, -0.25, 0, 0.25, 0.5]) { if (scoreShot(st, ang) > 0) { anyHit = true; break; } } if (!anyHit) errs.push('no straight-ish shot reaches a peg'); if (errs.length) { throw new Error(`${tag}: ${errs.slice(0, 4).join('; ')}${errs.length > 4 ? ` (+${errs.length - 4} more)` : ''}`); } return { bricks: bricks.length, eligible }; } // ── Manifest + write ───────────────────────────────────────────────────────── const BLOCKS = [ { masterId: 'smasher', powerId: 'bodyslam', background: 'arena', build: smasherLevel, names: ['Squared Circle', 'Top Rope', 'Turnbuckle', 'Championship Belt', 'Steel Cage'], }, { masterId: 'maurice', powerId: 'beaverdam', background: 'river', build: mauriceLevel, names: ['Timber!', 'Lazy River', 'The Lodge', 'Log Jam', 'Grand Dam'], }, { masterId: 'schooner', powerId: 'tailwind', background: 'harbor', build: schoonerLevel, names: ['Ahoy!', 'Full Sail', "Crow's Nest", 'Rolling Swells', 'Maiden Voyage'], }, { masterId: 'terry', powerId: 'meteor', background: 'jurassic', build: terryLevel, names: ['Footprints', 'Tar Pits', 'Bone Yard', 'Volcano Day', 'Extinction Event'], }, { masterId: 'gerome', powerId: 'extremeball', background: 'xtreme', build: geromeLevel, names: ['Kickflip', 'Half-Pipe', 'Slalom', 'Big Air', 'Mega Ramp'], }, { masterId: 'blackwind', powerId: 'cannonball', background: 'cove', build: blackwindLevel, names: ['Jolly Roger', 'Broadside', 'Treasure Hold', "Ship's Wheel", 'Davy Jones'], }, { masterId: 'steve', powerId: 'beamup', background: 'orbit', build: steveLevel, names: ['Crop Circles', 'Little Green Pegs', 'Tractor Beam', 'Mothership', 'Close Encounter'], }, { masterId: 'nicole', powerId: 'overclock', background: 'mainframe', build: nicoleLevel, names: ['Access Granted', 'Firewall', 'Binary Rain', 'Motherboard', 'Root Access'], }, { masterId: 'dv-8-2303', powerId: 'lasergrid', background: 'factory', build: dvLevel, names: ['Assembly Line', 'Gear Works', 'Servo Loop', 'Laser Lattice', 'Defeat Protocol'], }, { masterId: 'kage', powerId: 'shadowslash', background: 'dojo', build: kageLevel, names: ['Bamboo Grove', 'Shuriken', 'Torii Gate', 'Twin Katanas', 'Silent Strike'], }, { masterId: 'aiko', powerId: 'cosmicbloom', background: 'nebula', build: aikoLevel, names: ['First Contact', 'Spore Drift', 'Spiral Galaxy', 'Double Helix', 'Full Bloom'], }, { masterId: 'nadia', powerId: 'rewind', background: 'chrono', build: nadiaLevel, names: ['Hourglass', 'Pendulum', 'Time Spiral', 'Paradox', 'End of Time'], }, ]; const FIRST_NEW_LEVEL = 26; // Carry the hand-tuned first wave (1-25) over verbatim from the live manifest. const existing = JSON.parse(readFileSync(join(OUT_DIR, 'levels.json'), 'utf8')); const carried = existing.levels.filter((l) => l.level < FIRST_NEW_LEVEL); if (carried.length !== FIRST_NEW_LEVEL - 1) { throw new Error(`expected ${FIRST_NEW_LEVEL - 1} existing levels in levels.json, found ${carried.length}`); } mkdirSync(OUT_DIR, { recursive: true }); const manifestLevels = [...carried]; let levelNo = FIRST_NEW_LEVEL - 1; 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 info = validate(data, `${file} (${block.names[v]})`); writeFileSync(join(OUT_DIR, file), `${JSON.stringify(data, null, 2)}\n`); console.log(`${file}: ${block.names[v]} [${block.masterId}] — ${data.pegs.length} pegs + ${info.bricks} bricks (${info.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 (1-${FIRST_NEW_LEVEL - 1} carried over untouched)`);