1117 lines
44 KiB
JavaScript
1117 lines
44 KiB
JavaScript
// Generates the Peggle campaign's second wave: levels 26-85, 5 per friend.
|
|
// node tools/genPeggleLevels.js
|
|
// Writes assets/gamedata/peggle/level-026..085.json and levels.json.
|
|
//
|
|
// Levels 1-25 (Ethel/Kona/Zanthor/Fireball/Victor) were generated once and
|
|
// then HAND-TUNED in the editor (index.html?peggle-editor=1) — this script
|
|
// never rewrites their files and carries their levels.json entries over
|
|
// verbatim. The original 1-25 builders live in git history.
|
|
//
|
|
// Blocks: 26-30 Smasher (bodyslam), 31-35 Maurice (beaverdam), 36-40 Schooner
|
|
// (tailwind), 41-45 Terry (meteor), 46-50 Gerome (extremeball), 51-55
|
|
// Blackwind (cannonball), 56-60 Steve (beamup), 61-65 Nicole (overclock),
|
|
// 66-70 DV-8-2303 (lasergrid), 71-75 Kage (shadowslash), 76-80 Aiko
|
|
// (cosmicbloom), 81-85 Nadia (rewind).
|
|
//
|
|
// Layouts are starting points meant to be hand-tuned later in the editor;
|
|
// pegs violating spacing are dropped, so shapes may thin slightly where
|
|
// strokes intersect. Every generated level is validated against the same
|
|
// constraints tools/verifyPeggle.js lints (bounds, overlap, brick clearance,
|
|
// orange-eligible counts, reachability) and the script fails if any level
|
|
// breaks one.
|
|
|
|
import { writeFileSync, readFileSync, mkdirSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, join } from 'node:path';
|
|
|
|
import { TUNING, tessellateCurves, createRound, scoreShot } 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;
|
|
const D = Math.PI / 180; // layouts below think in degrees
|
|
|
|
// ── 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);
|
|
}
|
|
|
|
// Full ring starting at `startDeg` (for interleaving concentric rings).
|
|
function ringAt(cx, cy, radius, n, startDeg, opts = {}) {
|
|
return arc(cx, cy, radius, startDeg * D, (startDeg + 360 * (1 - 1 / n)) * D, n, opts);
|
|
}
|
|
|
|
function ellipseRing(cx, cy, rx, ry, n, opts = {}) {
|
|
const pegs = [];
|
|
for (let i = 0; i < n; i++) {
|
|
const a = (i * Math.PI * 2) / n - Math.PI / 2;
|
|
pegs.push({ x: cx + rx * Math.cos(a), y: cy + ry * Math.sin(a), ...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 seg = (x0, y0, x1, y1, bend = 0) => wave([{ x: x0, y: y0 }, { x: x1, y: y1 }], [bend]);
|
|
|
|
const el = { orangeEligible: true };
|
|
|
|
// ── The Smasher (26-30): the wrestling arena ─────────────────────────────────
|
|
|
|
function smasherLevel(v) {
|
|
const pegs = [];
|
|
const curves = [];
|
|
switch (v) {
|
|
case 0: // Squared Circle — the ring from above: ropes, posts, mat
|
|
pegs.push(
|
|
...line(350, 260, 850, 260, 11, el),
|
|
...line(350, 640, 850, 640, 11, el),
|
|
...line(350, 315, 350, 585, 6, el),
|
|
...line(850, 315, 850, 585, 6, el),
|
|
{ x: 295, y: 210 }, { x: 905, y: 210 }, { x: 295, y: 690 }, { x: 905, y: 690 },
|
|
);
|
|
curves.push(seg(280, 180, 920, 180, 35), seg(280, 725, 920, 725, -35));
|
|
break;
|
|
case 1: // Top Rope — three sagging ropes over a roaring crowd
|
|
pegs.push(
|
|
...line(150, 680, 1050, 680, 14, el),
|
|
...line(185, 745, 1015, 745, 13, el),
|
|
...line(150, 240, 150, 520, 5, el),
|
|
...line(1050, 240, 1050, 520, 5, el),
|
|
);
|
|
curves.push(
|
|
seg(220, 250, 980, 250, 55),
|
|
seg(220, 380, 980, 380, 55),
|
|
seg(220, 510, 980, 510, 55),
|
|
);
|
|
break;
|
|
case 2: // Turnbuckle — crossed slam trajectories through a center ring
|
|
pegs.push(
|
|
...line(210, 195, 990, 760, 13, el),
|
|
...line(990, 195, 210, 760, 13, el),
|
|
...circle(600, 470, 100, 8, el),
|
|
{ x: 160, y: 230 }, { x: 1040, y: 230 }, { x: 160, y: 720 }, { x: 1040, y: 720 },
|
|
);
|
|
curves.push(seg(430, 800, 770, 800, 0));
|
|
break;
|
|
case 3: // Championship Belt — the big gold buckle
|
|
pegs.push(
|
|
...circle(600, 480, 115, 12, el),
|
|
...circle(600, 480, 58, 6, el),
|
|
{ x: 600, y: 480, ...el },
|
|
...arc(600, 480, 205, 200 * D, 340 * D, 9, el),
|
|
...arc(600, 480, 205, 25 * D, 155 * D, 7, el),
|
|
);
|
|
curves.push(seg(140, 480, 420, 480, 25), seg(780, 480, 1060, 480, 25));
|
|
break;
|
|
default: // Steel Cage — the mesh comes down
|
|
pegs.push(
|
|
...grid(260, 220, 8, 3, 100, 200, el),
|
|
...grid(320, 320, 8, 3, 100, 200, el),
|
|
);
|
|
curves.push(seg(190, 195, 190, 760, 0), seg(1010, 195, 1010, 760, 0));
|
|
break;
|
|
}
|
|
return levelFile(settle(pegs), { curves });
|
|
}
|
|
|
|
// ── Maurice (31-35): the riverside ───────────────────────────────────────────
|
|
|
|
function mauriceLevel(v) {
|
|
const pegs = [];
|
|
const curves = [];
|
|
switch (v) {
|
|
case 0: // Timber! — felled logs mid-topple
|
|
pegs.push(
|
|
...line(170, 250, 520, 350, 7, el),
|
|
...line(690, 200, 1040, 320, 7, el),
|
|
...line(150, 530, 500, 450, 7, el),
|
|
...line(660, 500, 1050, 580, 8, el),
|
|
...line(300, 690, 420, 690, 3),
|
|
...line(750, 715, 870, 715, 3),
|
|
);
|
|
curves.push(seg(200, 790, 1000, 790, 0));
|
|
break;
|
|
case 1: // Lazy River — the water winds between grassy banks
|
|
pegs.push(
|
|
...line(150, 210, 1050, 210, 10, el),
|
|
...line(150, 680, 1050, 680, 10, el),
|
|
...circle(300, 580, 45, 5, el),
|
|
...circle(880, 605, 45, 5, el),
|
|
);
|
|
curves.push(wave(
|
|
[{ x: 110, y: 320 }, { x: 420, y: 460 }, { x: 800, y: 370 }, { x: 1090, y: 500 }],
|
|
[100, -100, 100],
|
|
));
|
|
break;
|
|
case 2: // The Lodge — the stick dome on the water
|
|
pegs.push(
|
|
...arc(600, 560, 225, 180 * D, 360 * D, 12, el),
|
|
...arc(600, 560, 152, 180 * D, 360 * D, 8, el),
|
|
...arc(600, 560, 82, 180 * D, 360 * D, 5, el),
|
|
...line(330, 590, 870, 590, 8, el),
|
|
);
|
|
curves.push(wave([{ x: 230, y: 705 }, { x: 600, y: 705 }, { x: 970, y: 705 }], [45, -45]));
|
|
break;
|
|
case 3: // Log Jam — timber stacked every which way
|
|
pegs.push(
|
|
...line(150, 250, 400, 250, 5, el),
|
|
...line(500, 225, 820, 225, 6, el),
|
|
...line(890, 290, 1075, 290, 4, el),
|
|
...line(200, 420, 520, 420, 6, el),
|
|
...line(620, 455, 1000, 455, 7, el),
|
|
...line(150, 610, 450, 610, 6, el),
|
|
...line(560, 645, 900, 645, 7, el),
|
|
...line(300, 780, 900, 780, 7),
|
|
);
|
|
curves.push(seg(985, 615, 1080, 735, 0));
|
|
break;
|
|
default: // Grand Dam — Maurice's masterpiece holds back the flood
|
|
pegs.push(
|
|
...arc(600, 430, 135, 195 * D, 345 * D, 8, el),
|
|
...arc(600, 430, 210, 200 * D, 340 * D, 10, el),
|
|
...line(250, 700, 950, 700, 8, el),
|
|
...line(300, 765, 900, 765, 7, el),
|
|
);
|
|
curves.push(wave([{ x: 150, y: 565 }, { x: 600, y: 460 }, { x: 1050, y: 565 }], [30, 30]));
|
|
break;
|
|
}
|
|
return levelFile(settle(pegs), { curves });
|
|
}
|
|
|
|
// ── Schooner (36-40): the high seas ──────────────────────────────────────────
|
|
|
|
function schoonerLevel(v) {
|
|
const pegs = [];
|
|
const curves = [];
|
|
switch (v) {
|
|
case 0: // Ahoy! — the anchor drops
|
|
pegs.push(
|
|
...circle(600, 245, 50, 6, el),
|
|
...line(600, 335, 600, 555, 4, el),
|
|
...line(480, 335, 720, 335, 5, el),
|
|
...arc(600, 470, 165, 25 * D, 155 * D, 8, el),
|
|
{ x: 415, y: 505, ...el }, { x: 785, y: 505, ...el },
|
|
);
|
|
curves.push(seg(140, 660, 460, 660, 40), seg(740, 660, 1060, 660, 40));
|
|
break;
|
|
case 1: // Full Sail — canvas stretched before the wind
|
|
pegs.push(
|
|
...line(600, 200, 600, 700, 8, el),
|
|
...line(650, 240, 950, 590, 7, el),
|
|
...line(660, 630, 940, 630, 5, el),
|
|
...line(550, 300, 310, 590, 6, el),
|
|
...line(320, 630, 540, 630, 4, el),
|
|
);
|
|
curves.push(seg(300, 725, 900, 725, 45));
|
|
break;
|
|
case 2: // Crow's Nest — rigging up to the lookout
|
|
pegs.push(
|
|
...circle(600, 265, 72, 8, el),
|
|
...line(600, 380, 600, 700, 5, el),
|
|
...line(552, 345, 210, 700, 7, el),
|
|
...line(648, 345, 990, 700, 7, el),
|
|
...line(410, 520, 530, 520, 3, el),
|
|
...line(670, 520, 790, 520, 3, el),
|
|
...line(320, 630, 520, 630, 3, el),
|
|
...line(680, 630, 880, 630, 3, el),
|
|
);
|
|
curves.push(seg(200, 780, 1000, 780, 30));
|
|
break;
|
|
case 3: // Rolling Swells — wave after wave after wave
|
|
pegs.push(
|
|
...arc(280, 245, 45, 200 * D, 340 * D, 3, el),
|
|
...arc(560, 220, 45, 200 * D, 340 * D, 3, el),
|
|
...arc(840, 250, 45, 200 * D, 340 * D, 3, el),
|
|
{ x: 1055, y: 210, ...el },
|
|
);
|
|
curves.push(
|
|
wave([{ x: 120, y: 360 }, { x: 600, y: 360 }, { x: 1080, y: 360 }], [70, -70]),
|
|
wave([{ x: 120, y: 530 }, { x: 600, y: 530 }, { x: 1080, y: 530 }], [-70, 70]),
|
|
wave([{ x: 120, y: 700 }, { x: 600, y: 700 }, { x: 1080, y: 700 }], [70, -70]),
|
|
);
|
|
break;
|
|
default: // Maiden Voyage — the good ship herself
|
|
pegs.push(
|
|
...line(300, 465, 900, 465, 8, el),
|
|
...line(450, 200, 450, 415, 4, el),
|
|
...line(750, 200, 750, 415, 4, el),
|
|
...arc(450, 315, 85, -80 * D, 80 * D, 5, el),
|
|
...arc(750, 315, 85, -80 * D, 80 * D, 5, el),
|
|
{ x: 280, y: 430, ...el }, { x: 930, y: 430, ...el },
|
|
);
|
|
curves.push(
|
|
seg(250, 520, 950, 520, 120),
|
|
seg(180, 720, 480, 720, 35),
|
|
seg(720, 720, 1020, 720, 35),
|
|
);
|
|
break;
|
|
}
|
|
return levelFile(settle(pegs), { curves });
|
|
}
|
|
|
|
// ── Terry (41-45): the Mesozoic ──────────────────────────────────────────────
|
|
|
|
const footprint = (x, y) => [
|
|
{ x, y, ...el },
|
|
{ x: x - 46, y: y - 48, ...el },
|
|
{ x, y: y - 60, ...el },
|
|
{ x: x + 46, y: y - 48, ...el },
|
|
];
|
|
|
|
function terryLevel(v) {
|
|
const pegs = [];
|
|
const curves = [];
|
|
switch (v) {
|
|
case 0: // Footprints — something big walked through here
|
|
pegs.push(
|
|
...footprint(250, 720), ...footprint(450, 595), ...footprint(330, 465),
|
|
...footprint(510, 335), ...footprint(390, 255),
|
|
...line(850, 280, 850, 700, 5, el),
|
|
...line(950, 230, 950, 740, 6, el),
|
|
...line(1050, 330, 1050, 650, 4, el),
|
|
);
|
|
curves.push(seg(640, 775, 1060, 775, 20));
|
|
break;
|
|
case 1: // Tar Pits — bubbling and hungry
|
|
pegs.push(
|
|
...circle(390, 640, 112, 9, el),
|
|
...circle(390, 640, 55, 4, el),
|
|
...circle(860, 590, 100, 8, el),
|
|
{ x: 860, y: 590, ...el },
|
|
{ x: 350, y: 450, ...el }, { x: 430, y: 380, ...el }, { x: 300, y: 315, ...el },
|
|
{ x: 520, y: 300, ...el }, { x: 830, y: 430, ...el }, { x: 910, y: 350, ...el },
|
|
{ x: 770, y: 265, ...el }, { x: 600, y: 200, ...el }, { x: 600, y: 330, ...el },
|
|
);
|
|
curves.push(seg(150, 800, 1050, 800, 0));
|
|
break;
|
|
case 2: // Bone Yard — ribs, femurs, and one grinning skull
|
|
pegs.push(
|
|
...line(280, 250, 880, 250, 8, el),
|
|
...arc(320, 260, 160, 75 * D, 105 * D, 3, el),
|
|
...arc(460, 260, 160, 75 * D, 105 * D, 3, el),
|
|
...arc(600, 260, 160, 75 * D, 105 * D, 3, el),
|
|
...arc(740, 260, 160, 75 * D, 105 * D, 3, el),
|
|
...arc(880, 260, 160, 75 * D, 105 * D, 3, el),
|
|
...circle(960, 620, 72, 7, el),
|
|
{ x: 935, y: 600 }, { x: 985, y: 600 },
|
|
...line(150, 650, 400, 755, 5, el),
|
|
{ x: 120, y: 610, ...el }, { x: 440, y: 785, ...el },
|
|
);
|
|
curves.push(wave([{ x: 140, y: 330 }, { x: 260, y: 215 }], [-25]));
|
|
break;
|
|
case 3: // Volcano Day — duck and cover
|
|
pegs.push(
|
|
...line(350, 760, 560, 420, 6, el),
|
|
...line(850, 760, 640, 420, 6, el),
|
|
...arc(600, 395, 120, 205 * D, 335 * D, 7, el),
|
|
...arc(600, 395, 190, 210 * D, 330 * D, 9, el),
|
|
...line(150, 780, 1050, 780, 11, el),
|
|
);
|
|
curves.push(seg(480, 170, 720, 170, -20));
|
|
break;
|
|
default: // Extinction Event — the sky is falling
|
|
pegs.push(
|
|
...circle(300, 620, 58, 6, el),
|
|
{ x: 300, y: 620, ...el },
|
|
...line(420, 530, 1040, 200, 8, el),
|
|
...line(520, 655, 1080, 320, 7, el),
|
|
...arc(300, 620, 190, -60 * D, 35 * D, 6, el),
|
|
);
|
|
curves.push(seg(150, 785, 1050, 785, 0));
|
|
break;
|
|
}
|
|
return levelFile(settle(pegs), { curves });
|
|
}
|
|
|
|
// ── Gerome (46-50): the skate park ───────────────────────────────────────────
|
|
|
|
function geromeLevel(v) {
|
|
const pegs = [];
|
|
const curves = [];
|
|
switch (v) {
|
|
case 0: // Kickflip — board's up, wheels spinning
|
|
pegs.push(
|
|
...circle(430, 590, 46, 6, el),
|
|
...circle(770, 590, 46, 6, el),
|
|
...circle(600, 300, 95, 8, el),
|
|
{ x: 600, y: 300, ...el },
|
|
...line(200, 745, 1000, 745, 9, el),
|
|
);
|
|
curves.push(wave([{ x: 290, y: 490 }, { x: 600, y: 465 }, { x: 910, y: 490 }], [-22, -22]));
|
|
break;
|
|
case 1: // Half-Pipe — drop in!
|
|
pegs.push(
|
|
...arc(180, 285, 90, -60 * D, 15 * D, 4, el),
|
|
...arc(1020, 285, 90, 165 * D, 240 * D, 4, el),
|
|
...circle(600, 335, 85, 7, el),
|
|
...line(450, 780, 750, 780, 4, el),
|
|
);
|
|
curves.push(
|
|
wave([{ x: 180, y: 275 }, { x: 600, y: 620 }, { x: 1020, y: 275 }], [85, -85]),
|
|
wave([{ x: 265, y: 300 }, { x: 600, y: 540 }, { x: 935, y: 300 }], [60, -60]),
|
|
);
|
|
break;
|
|
case 2: // Slalom — thread the gates
|
|
pegs.push(
|
|
...line(200, 200, 500, 320, 6, el),
|
|
...line(500, 320, 250, 450, 5, el),
|
|
...line(250, 450, 550, 570, 6, el),
|
|
...line(550, 570, 300, 700, 5, el),
|
|
...line(1000, 200, 700, 320, 6, el),
|
|
...line(700, 320, 950, 450, 5, el),
|
|
...line(950, 450, 650, 570, 6, el),
|
|
...line(650, 570, 900, 700, 5, el),
|
|
);
|
|
curves.push(seg(440, 775, 760, 775, 0));
|
|
break;
|
|
case 3: // Big Air — launch, soar, land
|
|
pegs.push(
|
|
...arc(600, 520, 175, 215 * D, 325 * D, 7, el),
|
|
...circle(600, 260, 62, 6, el),
|
|
{ x: 600, y: 400, ...el },
|
|
...line(480, 765, 720, 765, 4, el),
|
|
...line(220, 260, 220, 400, 3, el),
|
|
...line(980, 260, 980, 400, 3, el),
|
|
);
|
|
curves.push(
|
|
wave([{ x: 140, y: 700 }, { x: 440, y: 460 }], [-55]),
|
|
wave([{ x: 760, y: 460 }, { x: 1060, y: 700 }], [-55]),
|
|
);
|
|
break;
|
|
default: // Mega Ramp — the one they write about
|
|
pegs.push(
|
|
...line(800, 300, 1080, 560, 5, el),
|
|
...line(1080, 300, 800, 560, 5, el),
|
|
...arc(660, 520, 155, 245 * D, 325 * D, 5, el),
|
|
...line(200, 770, 1000, 770, 9, el),
|
|
{ x: 940, y: 200, ...el }, { x: 760, y: 210, ...el },
|
|
);
|
|
curves.push(wave([{ x: 130, y: 190 }, { x: 460, y: 590 }, { x: 720, y: 680 }], [-75, -30]));
|
|
break;
|
|
}
|
|
return levelFile(settle(pegs), { curves });
|
|
}
|
|
|
|
// ── Blackwind (51-55): the pirate cove ───────────────────────────────────────
|
|
|
|
function blackwindLevel(v) {
|
|
const pegs = [];
|
|
const curves = [];
|
|
switch (v) {
|
|
case 0: // Jolly Roger — hoist the colors
|
|
pegs.push(
|
|
...circle(600, 330, 128, 11, el),
|
|
{ x: 552, y: 310 }, { x: 648, y: 310 },
|
|
...arc(600, 370, 55, 55 * D, 125 * D, 2, el),
|
|
...line(380, 545, 820, 705, 6, el),
|
|
...line(820, 545, 380, 705, 6, el),
|
|
{ x: 330, y: 520, ...el }, { x: 870, y: 520, ...el },
|
|
{ x: 330, y: 730, ...el }, { x: 870, y: 730, ...el },
|
|
);
|
|
curves.push(seg(300, 178, 900, 178, -22));
|
|
break;
|
|
case 1: // Broadside — cannons roar across the water
|
|
pegs.push(
|
|
...line(370, 330, 830, 330, 6, el),
|
|
...line(370, 480, 830, 480, 6, el),
|
|
...line(370, 630, 830, 630, 6, el),
|
|
...line(400, 720, 800, 275, 5, el),
|
|
{ x: 210, y: 210, ...el }, { x: 990, y: 210, ...el },
|
|
);
|
|
curves.push(seg(250, 290, 250, 700, 45), seg(950, 290, 950, 700, -45));
|
|
break;
|
|
case 2: // Treasure Hold — X marks the peg
|
|
pegs.push(
|
|
...line(400, 700, 800, 700, 6, el),
|
|
...line(440, 640, 760, 640, 5, el),
|
|
...line(480, 580, 720, 580, 4, el),
|
|
...line(520, 520, 680, 520, 3, el),
|
|
...line(560, 460, 640, 460, 2, el),
|
|
{ x: 600, y: 405, ...el },
|
|
...arc(600, 430, 175, 190 * D, 350 * D, 8, el),
|
|
);
|
|
curves.push(seg(210, 280, 210, 700, 0), seg(990, 280, 990, 700, 0));
|
|
break;
|
|
case 3: // Ship's Wheel — steady as she goes
|
|
pegs.push(
|
|
...circle(600, 450, 45, 6, el),
|
|
...ringAt(600, 450, 190, 14, -90, el),
|
|
...ringAt(600, 450, 100, 8, -67.5, el),
|
|
...ringAt(600, 450, 145, 8, -67.5, el),
|
|
...ringAt(600, 450, 238, 8, -67.5, el),
|
|
);
|
|
curves.push(seg(250, 762, 950, 762, 30));
|
|
break;
|
|
default: // Davy Jones — beware what waits below
|
|
pegs.push(
|
|
...arc(600, 340, 140, 180 * D, 360 * D, 9, el),
|
|
{ x: 548, y: 335 }, { x: 652, y: 335 },
|
|
{ x: 190, y: 250, ...el }, { x: 255, y: 195, ...el }, { x: 160, y: 400, ...el },
|
|
{ x: 1010, y: 250, ...el }, { x: 945, y: 195, ...el }, { x: 1040, y: 400, ...el },
|
|
...line(150, 785, 1050, 785, 10, el),
|
|
);
|
|
curves.push(
|
|
wave([{ x: 480, y: 425 }, { x: 420, y: 585 }, { x: 470, y: 725 }], [55, -55]),
|
|
wave([{ x: 560, y: 445 }, { x: 560, y: 610 }, { x: 520, y: 740 }], [-45, 45]),
|
|
wave([{ x: 640, y: 445 }, { x: 640, y: 610 }, { x: 680, y: 740 }], [45, -45]),
|
|
wave([{ x: 720, y: 425 }, { x: 780, y: 585 }, { x: 730, y: 725 }], [-55, 55]),
|
|
);
|
|
break;
|
|
}
|
|
return levelFile(settle(pegs), { curves });
|
|
}
|
|
|
|
// ── Steve (56-60): low Earth orbit ───────────────────────────────────────────
|
|
|
|
function steveLevel(v) {
|
|
const pegs = [];
|
|
const curves = [];
|
|
switch (v) {
|
|
case 0: // Crop Circles — the wheat field tells a story
|
|
pegs.push(
|
|
...circle(350, 320, 112, 9, el),
|
|
{ x: 350, y: 320, ...el },
|
|
...circle(830, 285, 82, 7, el),
|
|
...circle(600, 610, 150, 11, el),
|
|
...circle(600, 610, 76, 6, el),
|
|
{ x: 600, y: 610, ...el },
|
|
);
|
|
curves.push(seg(150, 795, 1050, 795, 0));
|
|
break;
|
|
case 1: // Little Green Pegs — they came in peace(?)
|
|
pegs.push(
|
|
...grid(250, 220, 6, 3, 140, 100, el),
|
|
...grid(320, 270, 5, 2, 140, 100, el),
|
|
...arc(300, 690, 72, 180 * D, 360 * D, 5, el),
|
|
...arc(600, 690, 72, 180 * D, 360 * D, 5, el),
|
|
...arc(900, 690, 72, 180 * D, 360 * D, 5, el),
|
|
{ x: 600, y: 775 },
|
|
);
|
|
break;
|
|
case 2: // Tractor Beam — hold still, Earth creature
|
|
pegs.push(
|
|
...arc(600, 300, 100, 180 * D, 360 * D, 7, el),
|
|
...line(380, 330, 820, 330, 7, el),
|
|
...line(520, 390, 380, 760, 6, el),
|
|
...line(680, 390, 820, 760, 6, el),
|
|
{ x: 600, y: 500, ...el }, { x: 555, y: 600, ...el },
|
|
{ x: 645, y: 650, ...el }, { x: 600, y: 730, ...el },
|
|
{ x: 180, y: 220, ...el }, { x: 1020, y: 220, ...el },
|
|
{ x: 150, y: 450, ...el }, { x: 1050, y: 450, ...el },
|
|
);
|
|
curves.push(seg(430, 800, 770, 800, 0));
|
|
break;
|
|
case 3: // Mothership — now THAT'S a saucer
|
|
pegs.push(
|
|
...arc(600, 450, 225, 195 * D, 345 * D, 10, el),
|
|
...line(250, 450, 950, 450, 8, el),
|
|
...arc(600, 430, 122, 190 * D, 350 * D, 6, el),
|
|
...line(350, 505, 850, 505, 6, el),
|
|
);
|
|
curves.push(
|
|
seg(400, 580, 355, 765, 20),
|
|
seg(600, 585, 600, 785, 0),
|
|
seg(800, 580, 845, 765, -20),
|
|
);
|
|
break;
|
|
default: // Close Encounter — a ring of lights over the field
|
|
pegs.push(
|
|
...circle(600, 450, 232, 16, el),
|
|
{ x: 520, y: 380, ...el }, { x: 655, y: 345, ...el }, { x: 700, y: 470, ...el },
|
|
{ x: 560, y: 525, ...el }, { x: 478, y: 462, ...el },
|
|
{ x: 200, y: 195, ...el }, { x: 1000, y: 195, ...el }, { x: 150, y: 660, ...el },
|
|
{ x: 1050, y: 660, ...el }, { x: 320, y: 760, ...el }, { x: 880, y: 760, ...el },
|
|
);
|
|
curves.push(wave([{ x: 150, y: 275 }, { x: 450, y: 175 }], [-20]));
|
|
break;
|
|
}
|
|
return levelFile(settle(pegs), { curves });
|
|
}
|
|
|
|
// ── Nicole (61-65): inside the mainframe ─────────────────────────────────────
|
|
|
|
function nicoleLevel(v) {
|
|
const pegs = [];
|
|
const curves = [];
|
|
switch (v) {
|
|
case 0: // Access Granted — the login screen falls
|
|
pegs.push(
|
|
...line(230, 200, 590, 200, 5, el),
|
|
...line(230, 280, 790, 280, 8, el),
|
|
...line(230, 360, 500, 360, 4, el),
|
|
...line(230, 440, 710, 440, 7, el),
|
|
...line(230, 520, 590, 520, 5, el),
|
|
...line(230, 600, 870, 600, 9, el),
|
|
{ x: 950, y: 600, ...el },
|
|
...line(300, 720, 900, 720, 7, el),
|
|
);
|
|
curves.push(seg(160, 190, 160, 640, 0));
|
|
break;
|
|
case 1: // Firewall — burn through it
|
|
pegs.push(
|
|
{ x: 250, y: 235, ...el }, { x: 450, y: 220, ...el }, { x: 650, y: 235, ...el },
|
|
{ x: 850, y: 215, ...el }, { x: 350, y: 285, ...el }, { x: 550, y: 290, ...el },
|
|
{ x: 750, y: 285, ...el }, { x: 950, y: 275, ...el },
|
|
...line(250, 680, 950, 680, 8, el),
|
|
...line(300, 755, 900, 755, 7, el),
|
|
);
|
|
curves.push(
|
|
seg(200, 380, 430, 380, 0), seg(510, 380, 740, 380, 0), seg(820, 380, 1050, 380, 0),
|
|
seg(160, 480, 390, 480, 0), seg(470, 480, 700, 480, 0), seg(780, 480, 1010, 480, 0),
|
|
seg(200, 580, 430, 580, 0), seg(510, 580, 740, 580, 0), seg(820, 580, 1050, 580, 0),
|
|
);
|
|
break;
|
|
case 2: { // Binary Rain — ones and zeroes all the way down
|
|
const cols = [5, 3, 6, 4, 7, 4, 6, 3, 5];
|
|
cols.forEach((count, c) => {
|
|
const x = 200 + c * 100;
|
|
const y0 = c % 2 ? 240 : 195;
|
|
for (let i = 0; i < count; i++) pegs.push({ x, y: y0 + i * 88, ...el });
|
|
});
|
|
curves.push(seg(150, 795, 1050, 795, 0));
|
|
break;
|
|
}
|
|
case 3: // Motherboard — don't touch the CPU
|
|
pegs.push(
|
|
...line(460, 295, 740, 295, 5, el),
|
|
...line(460, 605, 740, 605, 5, el),
|
|
...line(395, 360, 395, 540, 4, el),
|
|
...line(805, 360, 805, 540, 4, el),
|
|
{ x: 520, y: 420, ...el }, { x: 680, y: 420, ...el },
|
|
{ x: 520, y: 480, ...el }, { x: 680, y: 480, ...el },
|
|
...line(330, 235, 180, 235, 3, el), ...line(870, 235, 1020, 235, 3, el),
|
|
...line(330, 665, 180, 665, 3, el), ...line(870, 665, 1020, 665, 3, el),
|
|
{ x: 180, y: 300, ...el }, { x: 1020, y: 300, ...el },
|
|
{ x: 180, y: 730, ...el }, { x: 1020, y: 730, ...el },
|
|
);
|
|
curves.push(
|
|
seg(450, 350, 750, 350, 0), seg(750, 350, 750, 550, 0),
|
|
seg(450, 550, 750, 550, 0), seg(450, 350, 450, 550, 0),
|
|
);
|
|
break;
|
|
default: // Root Access — follow the tree to its root
|
|
pegs.push(
|
|
{ x: 600, y: 195, ...el },
|
|
...arc(600, 195, 62, 35 * D, 145 * D, 3, el),
|
|
{ x: 400, y: 300, ...el }, { x: 800, y: 300, ...el },
|
|
{ x: 500, y: 245, ...el }, { x: 700, y: 245, ...el },
|
|
{ x: 280, y: 440, ...el }, { x: 520, y: 440, ...el },
|
|
{ x: 680, y: 440, ...el }, { x: 920, y: 440, ...el },
|
|
{ x: 340, y: 370, ...el }, { x: 460, y: 370, ...el },
|
|
{ x: 740, y: 370, ...el }, { x: 860, y: 370, ...el },
|
|
...line(200, 600, 480, 600, 3, el), ...line(720, 600, 1000, 600, 3, el),
|
|
...line(250, 740, 950, 740, 6, el),
|
|
{ x: 150, y: 300, ...el }, { x: 1050, y: 300, ...el },
|
|
{ x: 150, y: 480, ...el }, { x: 1050, y: 480, ...el },
|
|
);
|
|
curves.push(seg(250, 800, 950, 800, 0));
|
|
break;
|
|
}
|
|
return levelFile(settle(pegs), { curves });
|
|
}
|
|
|
|
// ── DV-8-2303 (66-70): the robot works ───────────────────────────────────────
|
|
|
|
function dvLevel(v) {
|
|
const pegs = [];
|
|
const curves = [];
|
|
switch (v) {
|
|
case 0: // Assembly Line — units roll off the belt
|
|
pegs.push(
|
|
...line(250, 370, 950, 370, 8, el),
|
|
...line(300, 570, 1000, 570, 8, el),
|
|
...line(350, 195, 350, 300, 3, el),
|
|
...line(750, 195, 750, 300, 3, el),
|
|
...circle(600, 250, 55, 5, el),
|
|
...line(300, 740, 900, 740, 6, el),
|
|
);
|
|
curves.push(seg(150, 425, 1050, 425, 0), seg(150, 625, 1050, 625, 0));
|
|
break;
|
|
case 1: // Gear Works — teeth within teeth
|
|
pegs.push(
|
|
...circle(400, 350, 120, 10, el),
|
|
...circle(400, 350, 52, 5, el),
|
|
...ringAt(400, 350, 165, 8, -67.5, el),
|
|
...circle(800, 560, 100, 8, el),
|
|
{ x: 800, y: 560, ...el },
|
|
...ringAt(800, 560, 145, 7, -65, el),
|
|
);
|
|
curves.push(seg(230, 730, 1010, 730, 25));
|
|
break;
|
|
case 2: // Servo Loop — round and round the circuit
|
|
pegs.push(
|
|
...ellipseRing(600, 480, 380, 250, 18, el),
|
|
...ellipseRing(600, 480, 245, 140, 12, el),
|
|
...circle(600, 480, 62, 5, el),
|
|
);
|
|
curves.push(seg(460, 158, 740, 158, 0));
|
|
break;
|
|
case 3: // Laser Lattice — mind the beams
|
|
pegs.push(
|
|
...line(200, 195, 840, 780, 9, el),
|
|
...line(450, 195, 1050, 745, 8, el),
|
|
...line(1000, 195, 360, 780, 9, el),
|
|
...line(750, 195, 150, 745, 8, el),
|
|
);
|
|
break;
|
|
default: // Defeat Protocol — face the machine
|
|
pegs.push(
|
|
...circle(510, 355, 46, 5, el),
|
|
...circle(690, 355, 46, 5, el),
|
|
{ x: 600, y: 450, ...el },
|
|
...line(480, 535, 720, 535, 4, el),
|
|
...line(480, 580, 720, 580, 4, el),
|
|
{ x: 325, y: 300, ...el }, { x: 325, y: 430, ...el }, { x: 325, y: 560, ...el },
|
|
{ x: 875, y: 300, ...el }, { x: 875, y: 430, ...el }, { x: 875, y: 560, ...el },
|
|
...line(300, 750, 900, 750, 6, el),
|
|
);
|
|
curves.push(
|
|
seg(415, 215, 785, 215, 0),
|
|
seg(415, 655, 785, 655, 0),
|
|
seg(390, 240, 390, 630, 0),
|
|
seg(810, 240, 810, 630, 0),
|
|
);
|
|
break;
|
|
}
|
|
return levelFile(settle(pegs), { curves });
|
|
}
|
|
|
|
// ── Kage (71-75): the moonlit dojo ───────────────────────────────────────────
|
|
|
|
function kageLevel(v) {
|
|
const pegs = [];
|
|
const curves = [];
|
|
switch (v) {
|
|
case 0: { // Bamboo Grove — stalks in the mist
|
|
const cols = [[250, 195, 6], [400, 240, 6], [550, 195, 7], [700, 240, 6], [850, 195, 7], [1000, 240, 6]];
|
|
for (const [x, y0, n] of cols) for (let i = 0; i < n; i++) pegs.push({ x, y: y0 + i * 92, ...el });
|
|
curves.push(seg(150, 800, 1050, 800, 0));
|
|
break;
|
|
}
|
|
case 1: // Shuriken — thrown true
|
|
pegs.push(
|
|
...circle(600, 470, 62, 6, el),
|
|
// four blades: two edge pegs per side + a tip
|
|
{ x: 560, y: 335, ...el }, { x: 545, y: 245, ...el }, { x: 640, y: 335, ...el },
|
|
{ x: 655, y: 245, ...el }, { x: 600, y: 195, ...el },
|
|
{ x: 735, y: 430, ...el }, { x: 825, y: 415, ...el }, { x: 735, y: 510, ...el },
|
|
{ x: 825, y: 525, ...el }, { x: 875, y: 470, ...el },
|
|
{ x: 560, y: 605, ...el }, { x: 545, y: 695, ...el }, { x: 640, y: 605, ...el },
|
|
{ x: 655, y: 695, ...el }, { x: 600, y: 745, ...el },
|
|
{ x: 465, y: 430, ...el }, { x: 375, y: 415, ...el }, { x: 465, y: 510, ...el },
|
|
{ x: 375, y: 525, ...el }, { x: 325, y: 470, ...el },
|
|
{ x: 770, y: 300, ...el }, { x: 770, y: 640, ...el },
|
|
{ x: 430, y: 300, ...el }, { x: 430, y: 640, ...el },
|
|
);
|
|
curves.push(wave([{ x: 150, y: 215 }, { x: 340, y: 170 }], [-18]));
|
|
break;
|
|
case 2: // Torii Gate — pass beneath with honor
|
|
pegs.push(
|
|
...line(400, 400, 400, 760, 5, el),
|
|
...line(800, 400, 800, 760, 5, el),
|
|
...circle(600, 560, 92, 8, el),
|
|
{ x: 200, y: 660, ...el }, { x: 200, y: 730, ...el },
|
|
{ x: 1000, y: 660, ...el }, { x: 1000, y: 730, ...el },
|
|
);
|
|
curves.push(seg(280, 255, 920, 255, -40), seg(345, 345, 855, 345, 0));
|
|
break;
|
|
case 3: // Twin Katanas — crossed steel
|
|
pegs.push(
|
|
...line(250, 700, 950, 220, 9, el),
|
|
...line(250, 220, 950, 700, 9, el),
|
|
{ x: 168, y: 757, ...el }, { x: 1032, y: 757, ...el },
|
|
...arc(600, 255, 60, 180 * D, 360 * D, 4, el),
|
|
{ x: 380, y: 195, ...el }, { x: 820, y: 195, ...el },
|
|
{ x: 150, y: 460, ...el }, { x: 1050, y: 460, ...el }, { x: 600, y: 780, ...el },
|
|
);
|
|
break;
|
|
default: { // Silent Strike — one clean cut through the dark
|
|
// A dense field with a diagonal slash of empty space through it.
|
|
const ax = 150; const ay = 150; const bx = 1050; const by = 820;
|
|
for (let r = 0; r < 8; r++) {
|
|
const y = 200 + r * 80;
|
|
const off = r % 2 ? 60 : 0;
|
|
for (let c = 0; c < 7; c++) {
|
|
const x = 220 + off + c * 120;
|
|
const t = ((x - ax) * (bx - ax) + (y - ay) * (by - ay)) / ((bx - ax) ** 2 + (by - ay) ** 2);
|
|
const px = ax + t * (bx - ax);
|
|
const py = ay + t * (by - ay);
|
|
if (Math.hypot(x - px, y - py) > 82) pegs.push({ x, y, ...el });
|
|
}
|
|
}
|
|
curves.push(seg(255, 245, 955, 745, 0));
|
|
break;
|
|
}
|
|
}
|
|
return levelFile(settle(pegs), { curves });
|
|
}
|
|
|
|
// ── Aiko (76-80): a garden between galaxies ──────────────────────────────────
|
|
|
|
const triad = (x, y) => [
|
|
{ x, y: y - 32, ...el },
|
|
{ x: x - 30, y: y + 22, ...el },
|
|
{ x: x + 30, y: y + 22, ...el },
|
|
];
|
|
|
|
function aikoLevel(v) {
|
|
const pegs = [];
|
|
const curves = [];
|
|
switch (v) {
|
|
case 0: // First Contact — two worlds, fingertips apart
|
|
pegs.push(
|
|
...arc(340, 450, 152, -55 * D, 55 * D, 6, el),
|
|
...arc(860, 450, 152, 125 * D, 235 * D, 6, el),
|
|
...circle(600, 450, 48, 5, el),
|
|
{ x: 600, y: 450, ...el },
|
|
{ x: 230, y: 210, ...el }, { x: 970, y: 210, ...el }, { x: 180, y: 660, ...el },
|
|
{ x: 1020, y: 660, ...el }, { x: 430, y: 195, ...el }, { x: 770, y: 195, ...el },
|
|
);
|
|
curves.push(seg(320, 720, 880, 720, -35));
|
|
break;
|
|
case 1: // Spore Drift — seeds on the solar wind
|
|
pegs.push(
|
|
...triad(250, 260), ...triad(520, 225), ...triad(800, 265), ...triad(1010, 420),
|
|
...triad(300, 480), ...triad(645, 450), ...triad(900, 630),
|
|
...triad(400, 700), ...triad(690, 725), ...triad(160, 600),
|
|
);
|
|
curves.push(
|
|
wave([{ x: 130, y: 345 }, { x: 480, y: 320 }, { x: 880, y: 375 }], [45, -45]),
|
|
wave([{ x: 260, y: 590 }, { x: 620, y: 565 }, { x: 1040, y: 735 }], [-45, 45]),
|
|
);
|
|
break;
|
|
case 2: { // Spiral Galaxy — arms of stars
|
|
pegs.push(...circle(600, 470, 46, 5, el));
|
|
for (const phase of [0, 180]) {
|
|
for (let k = 1; k <= 12; k++) {
|
|
const a = (phase + k * 40) * D;
|
|
const r = 60 + k * 23;
|
|
pegs.push({ x: 600 + r * Math.cos(a), y: 470 + r * Math.sin(a) * 0.82, ...el });
|
|
}
|
|
}
|
|
curves.push(wave([{ x: 150, y: 200 }, { x: 400, y: 260 }], [22]));
|
|
break;
|
|
}
|
|
case 3: { // Double Helix — life, but not as we know it
|
|
for (let i = 0; i <= 10; i++) {
|
|
const y = 195 + i * 56;
|
|
const s = Math.sin(i * 0.63);
|
|
pegs.push({ x: 600 + 150 * s, y, ...el });
|
|
pegs.push({ x: 600 - 150 * s, y, ...el });
|
|
if (Math.abs(s) > 0.85) pegs.push({ x: 600, y, ...el });
|
|
}
|
|
pegs.push(
|
|
{ x: 200, y: 250, ...el }, { x: 1000, y: 250, ...el },
|
|
{ x: 200, y: 550, ...el }, { x: 1000, y: 550, ...el },
|
|
);
|
|
curves.push(seg(300, 800, 900, 800, 0));
|
|
break;
|
|
}
|
|
default: { // Full Bloom — the garden opens all at once
|
|
pegs.push(...circle(600, 440, 56, 6, el), { x: 600, y: 440, ...el });
|
|
for (let k = 0; k < 6; k++) {
|
|
const a = -90 + k * 60;
|
|
const cx = 600 + 152 * Math.cos(a * D);
|
|
const cy = 440 + 152 * Math.sin(a * D);
|
|
pegs.push(...arc(cx, cy, 46, (a - 80) * D, (a + 80) * D, 4, el));
|
|
}
|
|
pegs.push(
|
|
{ x: 210, y: 220, ...el }, { x: 255, y: 250, ...el },
|
|
{ x: 945, y: 220, ...el }, { x: 990, y: 250, ...el },
|
|
);
|
|
curves.push(seg(615, 700, 570, 800, 18));
|
|
break;
|
|
}
|
|
}
|
|
return levelFile(settle(pegs), { curves });
|
|
}
|
|
|
|
// ── Nadia (81-85): the end of time ───────────────────────────────────────────
|
|
|
|
function nadiaLevel(v) {
|
|
const pegs = [];
|
|
const curves = [];
|
|
switch (v) {
|
|
case 0: // Hourglass — the sand runs out
|
|
pegs.push(
|
|
...line(400, 210, 800, 210, 6, el),
|
|
...line(410, 260, 578, 428, 4, el),
|
|
...line(790, 260, 622, 428, 4, el),
|
|
...line(578, 492, 410, 660, 4, el),
|
|
...line(622, 492, 790, 660, 4, el),
|
|
...line(400, 710, 800, 710, 6, el),
|
|
{ x: 600, y: 470, ...el }, { x: 600, y: 545, ...el }, { x: 600, y: 620, ...el },
|
|
{ x: 555, y: 655, ...el }, { x: 645, y: 655, ...el },
|
|
{ x: 230, y: 400, ...el }, { x: 970, y: 400, ...el },
|
|
{ x: 230, y: 550, ...el }, { x: 970, y: 550, ...el },
|
|
);
|
|
curves.push(seg(370, 165, 830, 165, 0), seg(370, 762, 830, 762, 0));
|
|
break;
|
|
case 1: { // Pendulum — tick... tock...
|
|
pegs.push({ x: 600, y: 195, ...el });
|
|
for (const deg of [-40, 0, 40]) {
|
|
const s = Math.sin(deg * D);
|
|
const c = Math.cos(deg * D);
|
|
for (const r of [120, 240, 360]) pegs.push({ x: 600 + r * s, y: 195 + r * c, ...el });
|
|
pegs.push(...circle(600 + 455 * s, 195 + 455 * c, 55, 6, el));
|
|
}
|
|
pegs.push(...arc(600, 195, 560, 52 * D, 128 * D, 7, el));
|
|
curves.push(seg(350, 150, 850, 150, 0));
|
|
break;
|
|
}
|
|
case 2: { // Time Spiral — it all comes around again
|
|
pegs.push({ x: 600, y: 470, ...el }, ...circle(600, 470, 46, 5, el));
|
|
for (let k = 0; k <= 15; k++) {
|
|
const a = k * 35 * D;
|
|
const r = 330 - k * 16;
|
|
pegs.push({ x: 600 + r * Math.cos(a), y: 470 + r * Math.sin(a) * 0.95, ...el });
|
|
}
|
|
pegs.push(
|
|
{ x: 200, y: 200, ...el }, { x: 1000, y: 200, ...el },
|
|
{ x: 200, y: 740, ...el }, { x: 1000, y: 740, ...el },
|
|
);
|
|
curves.push(wave([{ x: 890, y: 175 }, { x: 1050, y: 265 }], [20]));
|
|
break;
|
|
}
|
|
case 3: // Paradox — two truths, one board
|
|
pegs.push(
|
|
...circle(450, 420, 160, 12, el),
|
|
...circle(750, 520, 160, 12, el),
|
|
{ x: 200, y: 200, ...el }, { x: 240, y: 300, ...el }, { x: 180, y: 400, ...el },
|
|
{ x: 230, y: 500, ...el }, { x: 170, y: 600, ...el },
|
|
{ x: 1000, y: 200, ...el }, { x: 960, y: 300, ...el }, { x: 1020, y: 400, ...el },
|
|
{ x: 970, y: 500, ...el }, { x: 1030, y: 600, ...el },
|
|
);
|
|
curves.push(seg(370, 745, 830, 745, 35));
|
|
break;
|
|
default: { // End of Time — the great clock strikes
|
|
pegs.push(...circle(600, 470, 258, 18, el), { x: 600, y: 470, ...el });
|
|
for (let h = 0; h < 12; h++) {
|
|
const a = (h * 30 - 90) * D;
|
|
pegs.push({ x: 600 + 208 * Math.cos(a), y: 470 + 208 * Math.sin(a), ...el });
|
|
}
|
|
pegs.push(
|
|
{ x: 600, y: 410, ...el }, { x: 600, y: 350, ...el }, // minute hand → XII
|
|
{ x: 652, y: 500, ...el }, { x: 704, y: 530, ...el }, // hour hand → IV
|
|
{ x: 170, y: 195, ...el }, { x: 1030, y: 195, ...el },
|
|
{ x: 170, y: 760, ...el }, { x: 1030, y: 760, ...el },
|
|
);
|
|
curves.push(
|
|
wave([{ x: 200, y: 215 }, { x: 335, y: 168 }], [-15]),
|
|
wave([{ x: 865, y: 168 }, { x: 1000, y: 215 }], [-15]),
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
return levelFile(settle(pegs), { curves });
|
|
}
|
|
|
|
// ── Validation (mirrors the verifyPeggle.js data lint) ───────────────────────
|
|
|
|
function validate(lvl, tag) {
|
|
const errs = [];
|
|
const T = TUNING;
|
|
const bricks = tessellateCurves(lvl.curves ?? []);
|
|
const boundR = Math.hypot(T.BRICK_LEN / 2, T.BRICK_WID / 2);
|
|
for (const b of bricks) {
|
|
if (b.x < boundR || b.x > T.BOARD_W - boundR || b.y < 140 || b.y > 820) {
|
|
errs.push(`brick out of bounds at ${Math.round(b.x)},${Math.round(b.y)}`);
|
|
}
|
|
}
|
|
for (const c of lvl.curves ?? []) {
|
|
if ((c.anchors?.length ?? 0) < 2 || (c.bends?.length ?? -1) !== c.anchors.length - 1) errs.push('bad curve schema');
|
|
}
|
|
for (const p of lvl.pegs) {
|
|
if (p.x < p.r || p.x > T.BOARD_W - p.r || p.y < 140 || p.y > 820) errs.push(`peg out of bounds at ${p.x},${p.y}`);
|
|
for (const b of bricks) {
|
|
if ((p.x - b.x) ** 2 + (p.y - b.y) ** 2 < (p.r + T.BRICK_WID / 2) ** 2) errs.push(`peg ${p.x},${p.y} clashes a brick`);
|
|
}
|
|
}
|
|
for (let i = 0; i < lvl.pegs.length; i++) {
|
|
for (let j = i + 1; j < lvl.pegs.length; j++) {
|
|
const a = lvl.pegs[i];
|
|
const b = lvl.pegs[j];
|
|
if ((a.x - b.x) ** 2 + (a.y - b.y) ** 2 < (a.r + b.r + 6) ** 2) errs.push(`pegs overlap at ${a.x},${a.y} / ${b.x},${b.y}`);
|
|
}
|
|
}
|
|
const eligible = lvl.pegs.filter((p) => p.orangeEligible).length
|
|
+ bricks.filter((b) => b.orangeEligible).length;
|
|
if (eligible < lvl.orangeCount) errs.push(`only ${eligible} orange-eligible pegs+bricks (< ${lvl.orangeCount})`);
|
|
if (lvl.pegs.length + bricks.length < lvl.orangeCount + lvl.greenCount + 1) errs.push('too few pegs overall');
|
|
|
|
// Playability: a straight-ish shot must reach at least one peg.
|
|
const st = createRound(lvl, { seed: 1 });
|
|
let anyHit = false;
|
|
for (const ang of [-0.5, -0.25, 0, 0.25, 0.5]) {
|
|
if (scoreShot(st, ang) > 0) { anyHit = true; break; }
|
|
}
|
|
if (!anyHit) errs.push('no straight-ish shot reaches a peg');
|
|
|
|
if (errs.length) {
|
|
throw new Error(`${tag}: ${errs.slice(0, 4).join('; ')}${errs.length > 4 ? ` (+${errs.length - 4} more)` : ''}`);
|
|
}
|
|
return { bricks: bricks.length, eligible };
|
|
}
|
|
|
|
// ── Manifest + write ─────────────────────────────────────────────────────────
|
|
|
|
const BLOCKS = [
|
|
{
|
|
masterId: 'smasher', powerId: 'bodyslam', background: 'arena', build: smasherLevel,
|
|
names: ['Squared Circle', 'Top Rope', 'Turnbuckle', 'Championship Belt', 'Steel Cage'],
|
|
},
|
|
{
|
|
masterId: 'maurice', powerId: 'beaverdam', background: 'river', build: mauriceLevel,
|
|
names: ['Timber!', 'Lazy River', 'The Lodge', 'Log Jam', 'Grand Dam'],
|
|
},
|
|
{
|
|
masterId: 'schooner', powerId: 'tailwind', background: 'harbor', build: schoonerLevel,
|
|
names: ['Ahoy!', 'Full Sail', "Crow's Nest", 'Rolling Swells', 'Maiden Voyage'],
|
|
},
|
|
{
|
|
masterId: 'terry', powerId: 'meteor', background: 'jurassic', build: terryLevel,
|
|
names: ['Footprints', 'Tar Pits', 'Bone Yard', 'Volcano Day', 'Extinction Event'],
|
|
},
|
|
{
|
|
masterId: 'gerome', powerId: 'extremeball', background: 'xtreme', build: geromeLevel,
|
|
names: ['Kickflip', 'Half-Pipe', 'Slalom', 'Big Air', 'Mega Ramp'],
|
|
},
|
|
{
|
|
masterId: 'blackwind', powerId: 'cannonball', background: 'cove', build: blackwindLevel,
|
|
names: ['Jolly Roger', 'Broadside', 'Treasure Hold', "Ship's Wheel", 'Davy Jones'],
|
|
},
|
|
{
|
|
masterId: 'steve', powerId: 'beamup', background: 'orbit', build: steveLevel,
|
|
names: ['Crop Circles', 'Little Green Pegs', 'Tractor Beam', 'Mothership', 'Close Encounter'],
|
|
},
|
|
{
|
|
masterId: 'nicole', powerId: 'overclock', background: 'mainframe', build: nicoleLevel,
|
|
names: ['Access Granted', 'Firewall', 'Binary Rain', 'Motherboard', 'Root Access'],
|
|
},
|
|
{
|
|
masterId: 'dv-8-2303', powerId: 'lasergrid', background: 'factory', build: dvLevel,
|
|
names: ['Assembly Line', 'Gear Works', 'Servo Loop', 'Laser Lattice', 'Defeat Protocol'],
|
|
},
|
|
{
|
|
masterId: 'kage', powerId: 'shadowslash', background: 'dojo', build: kageLevel,
|
|
names: ['Bamboo Grove', 'Shuriken', 'Torii Gate', 'Twin Katanas', 'Silent Strike'],
|
|
},
|
|
{
|
|
masterId: 'aiko', powerId: 'cosmicbloom', background: 'nebula', build: aikoLevel,
|
|
names: ['First Contact', 'Spore Drift', 'Spiral Galaxy', 'Double Helix', 'Full Bloom'],
|
|
},
|
|
{
|
|
masterId: 'nadia', powerId: 'rewind', background: 'chrono', build: nadiaLevel,
|
|
names: ['Hourglass', 'Pendulum', 'Time Spiral', 'Paradox', 'End of Time'],
|
|
},
|
|
];
|
|
|
|
const FIRST_NEW_LEVEL = 26;
|
|
|
|
// Carry the hand-tuned first wave (1-25) over verbatim from the live manifest.
|
|
const existing = JSON.parse(readFileSync(join(OUT_DIR, 'levels.json'), 'utf8'));
|
|
const carried = existing.levels.filter((l) => l.level < FIRST_NEW_LEVEL);
|
|
if (carried.length !== FIRST_NEW_LEVEL - 1) {
|
|
throw new Error(`expected ${FIRST_NEW_LEVEL - 1} existing levels in levels.json, found ${carried.length}`);
|
|
}
|
|
|
|
mkdirSync(OUT_DIR, { recursive: true });
|
|
const manifestLevels = [...carried];
|
|
let levelNo = FIRST_NEW_LEVEL - 1;
|
|
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);
|
|
if (levelNo >= 33) data.orangeCount -= 7;
|
|
const info = validate(data, `${file} (${block.names[v]})`);
|
|
writeFileSync(join(OUT_DIR, file), `${JSON.stringify(data, null, 2)}\n`);
|
|
console.log(`${file}: ${block.names[v]} [${block.masterId}] — ${data.pegs.length} pegs + ${info.bricks} bricks (${info.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 (1-${FIRST_NEW_LEVEL - 1} carried over untouched)`);
|