fertig-classic-games/tools/genPeggleLevels.js

529 lines
20 KiB
JavaScript

// 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, tessellateCurves } 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 = {}) {
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 el = { orangeEligible: true };
// ── Ethel (1-5): parlor arcs & chandeliers ───────────────────────────────────
function ethelLevel(v) {
const pegs = [];
const curves = [];
switch (v) {
case 0: // Tea Time — nested arcs, a steam-wisp brick curve up top
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),
);
curves.push(wave([{ x: 350, y: 300 }, { x: 600, y: 270 }, { x: 850, y: 300 }], [-45, -45]));
break;
case 1: // Fine China — arc pairs with a brick saucer bowl
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),
);
curves.push(wave([{ x: 380, y: 600 }, { x: 600, y: 600 }, { x: 820, y: 600 }], [55, 55]));
break;
case 2: // Doily Drop — lace fan with hanging brick swags
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),
);
curves.push(wave(
[{ x: 180, y: 640 }, { x: 460, y: 660 }, { x: 740, y: 660 }, { x: 1020, y: 640 }],
[70, 70, 70],
));
break;
case 3: // Lace & Grace — S arcs with a ribbon wave along the bottom
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),
...grid(480, 340, 3, 2, 120, 100, el),
);
curves.push(wave(
[{ x: 200, y: 690 }, { x: 490, y: 710 }, { x: 780, y: 710 }, { x: 1050, y: 690 }],
[-55, 55, -55],
));
break;
default: // Grandma's Attic — chandelier strands under a cobweb swag
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),
);
curves.push(wave([{ x: 280, y: 330 }, { x: 600, y: 330 }, { x: 920, y: 330 }], [50, 50]));
break;
}
return levelFile(settle(pegs), { curves });
}
// ── Kona (6-10): bones, paws & toys ──────────────────────────────────────────
function konaLevel(v) {
const pegs = [];
const curves = [];
switch (v) {
case 0: // Fetch! — the dog bone under a thrown-ball arc
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(300, 620, 5, 2, 150, 90),
...line(140, 320, 220, 560, 3),
...line(1060, 320, 980, 560, 3),
);
curves.push(wave([{ x: 180, y: 300 }, { x: 490, y: 260 }, { x: 800, y: 300 }], [-55, -45]));
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),
);
curves.push(wave([{ x: 200, y: 720 }, { x: 620, y: 730 }, { x: 1040, y: 720 }], [35, -35]));
break;
}
case 2: // Buried Bones — crossed bones over brick dirt mounds
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),
...line(560, 210, 640, 210, 2),
);
curves.push(wave([{ x: 240, y: 700 }, { x: 600, y: 720 }, { x: 960, y: 700 }], [-55, -55]));
break;
case 3: // Squeaky Toy — bouncing-path brick wave under the ball
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),
);
curves.push(wave(
[{ x: 150, y: 580 }, { x: 450, y: 600 }, { x: 750, y: 600 }, { x: 1020, y: 580 }],
[55, -55, 55],
));
break;
default: // Best in Show — kennel house with bunting swags
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(430, 780, 770, 780, 4),
);
curves.push(
wave([{ x: 120, y: 330 }, { x: 300, y: 330 }], [45]),
wave([{ x: 900, y: 330 }, { x: 1080, y: 330 }], [45]),
);
break;
}
return levelFile(settle(pegs), { curves });
}
// ── Zanthor (11-15): stars, moons & sigils ───────────────────────────────────
function zanthorLevel(v) {
const pegs = [];
const curves = [];
switch (v) {
case 0: // Hocus Pocus — pentagram between two wand-swish curves
pegs.push(
...pentagram(600, 480, 230, 8, el),
...arc(600, 430, 400, Math.PI * 0.12, Math.PI * 0.88, 15, el),
);
curves.push(
wave([{ x: 240, y: 240 }, { x: 600, y: 205 }, { x: 960, y: 240 }], [-30, -30]),
wave([{ x: 250, y: 720 }, { x: 600, y: 740 }, { x: 950, y: 720 }], [45, 45]),
);
break;
case 1: // Crescent Moon — moon with a comet-tail brick curve
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(760, 220, 1000, 220, 4),
);
curves.push(wave([{ x: 150, y: 640 }, { x: 450, y: 690 }, { x: 750, y: 670 }], [45, -40]));
break;
case 2: // Crystal Ball — orb under a curl of mystic mist
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),
);
curves.push(wave([{ x: 200, y: 250 }, { x: 500, y: 230 }, { x: 800, y: 250 }], [-40, -40]));
break;
case 3: // Twin Stars — pentagrams over a shooting-star trail
pegs.push(
...pentagram(330, 400, 170, 6, el),
...pentagram(870, 560, 170, 6, el),
...line(430, 470, 750, 500, 4, el),
...line(650, 220, 1000, 220, 4),
);
curves.push(wave([{ x: 180, y: 710 }, { x: 600, y: 740 }, { x: 1020, y: 710 }], [45, 45]));
break;
default: // Grand Sigil — circles & cross flanked by runic rails
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(480, 780, 720, 780, 3),
);
curves.push(
wave([{ x: 160, y: 380 }, { x: 160, y: 700 }], [-65]),
wave([{ x: 1040, y: 380 }, { x: 1040, y: 700 }], [65]),
);
break;
}
return levelFile(settle(pegs), { curves });
}
// ── Fireball (16-20): rings, rays & bursts ───────────────────────────────────
function fireballLevel(v) {
const pegs = [];
const curves = [];
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 },
);
curves.push(wave([{ x: 160, y: 720 }, { x: 600, y: 750 }, { x: 1040, y: 720 }], [45, 45]));
break;
case 1: { // Solar Flare — core + rays under a corona curve
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,
));
}
curves.push(wave([{ x: 350, y: 250 }, { x: 600, y: 215 }, { x: 850, y: 250 }], [-35, -35]));
break;
}
case 2: // Atomic Age — orbit rings over an electron-path curve
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),
);
curves.push(wave([{ x: 200, y: 680 }, { x: 600, y: 720 }, { x: 1000, y: 680 }], [50, 50]));
break;
case 3: { // Meteor Shower — streaks under an atmosphere curve
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 });
}
curves.push(wave([{ x: 180, y: 230 }, { x: 600, y: 200 }, { x: 1020, y: 230 }], [-25, -25]));
break;
}
default: // Supernova — rings + rays over a shockwave curve
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),
);
curves.push(wave([{ x: 300, y: 720 }, { x: 600, y: 750 }, { x: 900, y: 720 }], [35, 35]));
break;
}
return levelFile(settle(pegs), { curves });
}
// ── Victor (21-25): boards & lattices ────────────────────────────────────────
function victorLevel(v) {
const pegs = [];
const curves = [];
switch (v) {
case 0: // Opening Move — checker rows between two brick waves
pegs.push(
...grid(220, 300, 6, 3, 150, 150, el),
...grid(295, 375, 6, 3, 150, 150, el),
);
curves.push(
wave([{ x: 160, y: 240 }, { x: 600, y: 205 }, { x: 1040, y: 240 }], [-30, -30]),
wave([{ x: 160, y: 700 }, { x: 600, y: 730 }, { x: 1040, y: 700 }], [45, -45]),
);
break;
case 1: { // Knight's Tour — L clusters over a zigzag rail
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),
);
curves.push(wave(
[{ x: 160, y: 710 }, { x: 450, y: 730 }, { x: 740, y: 730 }, { x: 1030, y: 710 }],
[40, -40, 40],
));
break;
}
case 2: // Double Gambit — chevrons between curved side rails
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),
);
}
curves.push(
wave([{ x: 170, y: 300 }, { x: 170, y: 620 }], [-60]),
wave([{ x: 1030, y: 300 }, { x: 1030, y: 620 }], [60]),
);
break;
case 3: // Fortress — walls & towers ringed by a brick moat
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(830, 400, 2, 2, 100, 130, el),
);
curves.push(wave([{ x: 220, y: 760 }, { x: 980, y: 760 }], [25]));
break;
default: // Checkmate — lattice woven with two long waves
pegs.push(
...grid(180, 250, 8, 5, 120, 120, el),
...line(160, 210, 1040, 210, 8),
);
curves.push(
wave([{ x: 160, y: 350 }, { x: 600, y: 410 }, { x: 1040, y: 350 }], [50, 50]),
wave([{ x: 160, y: 610 }, { x: 600, y: 550 }, { x: 1040, y: 610 }], [-50, -50]),
);
break;
}
return levelFile(settle(pegs), { curves });
}
// ── 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 bricks = tessellateCurves(data.curves ?? []);
const eligible = data.pegs.filter((p) => p.orangeEligible).length
+ bricks.filter((b) => b.orangeEligible).length;
if (eligible < data.orangeCount) {
throw new Error(`${file} (${block.names[v]}): only ${eligible} orange-eligible pegs+bricks (< ${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 + ${bricks.length} bricks (${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`);