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