529 lines
20 KiB
JavaScript
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`);
|