199 lines
6.9 KiB
JavaScript
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`);
|