fertig-classic-games/tools/genPeggleLevels.js

484 lines
17 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 } 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`);