fertig-classic-games/tools/genPeggleLevels.js

199 lines
6.9 KiB
JavaScript

// Generates the first five Peggle stub layouts + the level manifest.
// node tools/genPeggleLevels.js
// Writes assets/gamedata/peggle/level-001..005.json and levels.json.
//
// 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);
}
// 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 };
// ── Level 1 — Tea Time (Ethel): gentle nested arcs + bottom row ─────────────
function level1() {
const pegs = [
...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),
];
return levelFile(settle(pegs));
}
// ── Level 2 — Fetch! (Kona): a dog bone + scattered field ───────────────────
function level2() {
const pegs = [
// bone: two knuckle circles joined by a shaft
...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),
// scattered field above and below
...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),
];
return levelFile(settle(pegs));
}
// ── Level 3 — Hocus Pocus (Zanthor): five-point star inside an arc ──────────
function level3() {
const cx = 600;
const cy = 480;
const outer = 230;
// star vertices (point up)
const verts = [];
for (let i = 0; i < 5; i++) {
const a = -Math.PI / 2 + (i * 2 * Math.PI) / 5;
verts.push([cx + outer * Math.cos(a), cy + outer * Math.sin(a)]);
}
const order = [0, 2, 4, 1, 3, 0]; // pentagram stroke
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, 8, el));
}
pegs.push(...arc(600, 430, 400, Math.PI * 0.12, Math.PI * 0.88, 15, el));
pegs.push(...line(240, 240, 460, 240, 3));
pegs.push(...line(740, 240, 960, 240, 3));
return levelFile(settle(pegs));
}
// ── Level 4 — X-Ray Vision (Fireball): concentric rings, orange core ────────
function level4() {
const cx = 600;
const cy = 470;
const pegs = [
...circle(cx, cy, 90, 8, el),
...circle(cx, cy, 175, 14, el),
...circle(cx, cy, 260, 20, el),
...arc(cx, cy, 345, Math.PI * -0.35, Math.PI * 1.35, 22),
{ x: cx, y: cy, ...el },
...line(160, 740, 400, 740, 3),
...line(800, 740, 1040, 740, 3),
];
return levelFile(settle(pegs));
}
// ── Level 5 — Checkmate (Victor): diagonal checkerboard lattice ─────────────
function level5() {
const pegs = [
...grid(180, 250, 8, 5, 120, 120, el),
...grid(240, 310, 8, 5, 120, 120, el),
...line(160, 210, 1040, 210, 8),
];
return levelFile(settle(pegs));
}
// ── Manifest + write ─────────────────────────────────────────────────────────
const LEVELS = [
{ level: 1, name: 'Tea Time', masterId: 'ethel', powerId: 'superguide', file: 'level-001.json', background: 'parlor', build: level1 },
{ level: 2, name: 'Fetch!', masterId: 'kona', powerId: 'multiball', file: 'level-002.json', background: 'park', build: level2 },
{ level: 3, name: 'Hocus Pocus', masterId: 'zanthor', powerId: 'spaceblast', file: 'level-003.json', background: 'arcana', build: level3 },
{ level: 4, name: 'X-Ray Vision', masterId: 'fireball', powerId: 'fireball', file: 'level-004.json', background: 'lab', build: level4 },
{ level: 5, name: 'Checkmate', masterId: 'victor', powerId: 'zenball', file: 'level-005.json', background: 'study', build: level5 },
];
mkdirSync(OUT_DIR, { recursive: true });
for (const def of LEVELS) {
const data = def.build();
const eligible = data.pegs.filter((p) => p.orangeEligible).length;
if (eligible < data.orangeCount) {
throw new Error(`${def.file}: only ${eligible} orange-eligible pegs (< ${data.orangeCount})`);
}
writeFileSync(join(OUT_DIR, def.file), `${JSON.stringify(data, null, 2)}\n`);
console.log(`${def.file}: ${data.pegs.length} pegs (${eligible} orange-eligible)`);
}
const manifest = {
version: 1,
levels: LEVELS.map(({ build, ...entry }) => entry),
};
writeFileSync(join(OUT_DIR, 'levels.json'), `${JSON.stringify(manifest, null, 2)}\n`);
console.log(`levels.json: ${manifest.levels.length} levels`);