fertig-classic-games/server/scripts/genZuma.js

208 lines
8.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Offline generator for Zuma levels.
//
// Six hand-designed path shapes (parametric control-point emitters in 1920x1080
// canvas space) crossed with a hand-written 20-row difficulty table. Each level
// is validated against the same geometry rules verifyZuma.js lints: path long
// enough for its ball quota, samples in bounds past the off-screen lead-in,
// curvature wide enough for the marbles, and the frog clear of the path.
// Writes ordered levels to public/data/zuma.json.
//
// Usage:
// node server/scripts/genZuma.js [outFile]
//
// Deterministic: shapes and the table are static. Re-run after changing either.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildPath, TUNING } from '../../public/src/games/zuma/ZumaLogic.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const OUT_FILE = process.argv[2]
? path.resolve(process.argv[2])
: path.join(__dirname, '../../public/data/zuma.json');
const rad = (deg) => (deg * Math.PI) / 180;
const rp = (pts) => pts.map(([x, y]) => [Math.round(x), Math.round(y)]);
// ── Shapes: { points, frog } — first point is the off-screen spawn lead-in,
// the last is the skull hole ─────────────────────────────────────────────
function sCurve() {
return {
points: [
[-80, 300], [240, 220], [560, 300], [860, 460], [1120, 640],
[1400, 760], [1660, 700], [1790, 520], [1700, 330], [1500, 260],
],
frog: [960, 920],
};
}
function horseshoe() {
return {
points: [
[-80, 1000], [160, 900], [170, 650], [300, 380], [560, 190], [960, 130],
[1360, 190], [1620, 380], [1750, 650], [1700, 900], [1520, 990],
],
frog: [960, 620],
};
}
function spiral() {
const cx = 960, cy = 580, rx = 820, ry = 430, turns = 2.1, fEnd = 0.36;
const points = [[-80, cy]];
const steps = Math.round(turns * 16); // a control point every 22.5°
for (let i = 0; i <= steps; i++) {
const u = i / steps;
const th = Math.PI + u * turns * 2 * Math.PI;
const f = 1 - u * (1 - fEnd);
points.push([cx + rx * f * Math.cos(th), cy + ry * f * Math.sin(th)]);
}
return { points: rp(points), frog: [cx, cy] };
}
function zigzag() {
return {
points: [
[-80, 190], [300, 190], [800, 190], [1300, 190], [1560, 190],
[1720, 235], [1785, 355], [1720, 475], [1560, 520],
[1100, 520], [600, 520], [360, 520],
[200, 565], [135, 685], [200, 805], [360, 850],
[900, 850], [1400, 850], [1640, 880], [1750, 960],
],
frog: [960, 685],
};
}
function doubleLoop() {
const A = { x: 540, y: 560, rx: 360, ry: 330 };
const B = { x: 1380, y: 560, rx: 360, ry: 330 };
const points = [[-80, 180], [200, 255]];
for (let d = -90; d <= 200; d += 24) {
points.push([A.x + A.rx * Math.cos(rad(d)), A.y + A.ry * Math.sin(rad(d))]);
}
points.push([300, 210], [700, 120]); // arc over loop A to loop B's top
for (let d = -90; d <= 200; d += 24) {
points.push([B.x + B.rx * Math.cos(rad(d)), B.y + B.ry * Math.sin(rad(d))]);
}
// hole hook: continue the ring's exit direction, then curl into the center
points.push([1090, 330], [1200, 260], [1330, 300], [1390, 420], [1330, 520]);
return { points: rp(points), frog: [A.x, A.y] };
}
function figureEight() {
// 1:2 Lissajous traced once: enters mid-left, crosses itself at center,
// ends in the lower-left lobe.
const cx = 960, cy = 560, ax = 820, ay = 430;
const t0 = 1.5 * Math.PI;
const t1 = t0 + 2 * Math.PI - 0.55;
// the left tip has a vertical tangent, so the lead-in climbs from below
const points = [[-80, 940], [60, 750]];
const n = 44;
for (let i = 0; i <= n; i++) {
const t = t0 + ((t1 - t0) * i) / n;
points.push([cx + ax * Math.sin(t), cy + ay * Math.sin(2 * t)]);
}
return { points: rp(points), frog: [550, 560] };
}
const SHAPES = { sCurve, horseshoe, spiral, zigzag, doubleLoop, figureEight };
// ── Difficulty table ─────────────────────────────────────────────────────────
const TABLE = [
// level, name, shape, colors, quota, intro, push, powerUpRate
[1, 'Riverbend', 'sCurve', 4, 28, 10, 22, 0.07],
[2, 'Temple Gate', 'horseshoe', 4, 32, 10, 24, 0.07],
[3, 'Twin Pools', 'doubleLoop', 4, 36, 10, 26, 0.065],
[4, 'Switchbacks', 'zigzag', 4, 40, 12, 26, 0.065],
[5, 'Serpent Coil', 'spiral', 4, 46, 12, 28, 0.06],
[6, 'Crossroads', 'figureEight', 4, 42, 12, 28, 0.06],
[7, 'Rapids', 'sCurve', 4, 30, 10, 34, 0.06],
[8, 'Sun Court', 'horseshoe', 5, 36, 10, 30, 0.055],
[9, 'Thunder Steps', 'zigzag', 5, 44, 12, 30, 0.055],
[10, 'Twin Serpents', 'doubleLoop', 5, 42, 12, 32, 0.055],
[11, 'Deep Coil', 'spiral', 5, 52, 14, 32, 0.05],
[12, 'Tangled Path', 'figureEight', 5, 46, 12, 34, 0.05],
[13, 'Lightning Run', 'zigzag', 5, 50, 14, 36, 0.05],
[14, 'Whirlpool', 'spiral', 5, 58, 14, 36, 0.05],
[15, 'Obsidian Gate', 'horseshoe', 6, 38, 10, 38, 0.05],
[16, 'Twin Tempests', 'doubleLoop', 6, 46, 12, 40, 0.05],
[17, 'Stormsteps', 'zigzag', 6, 54, 14, 42, 0.045],
[18, 'Maelstrom Cross', 'figureEight', 6, 50, 12, 44, 0.045],
[19, 'Abyss Coil', 'spiral', 6, 62, 14, 46, 0.045],
[20, 'The Final Coil', 'spiral', 6, 66, 16, 48, 0.045],
];
// Calibrated against a headless aimbot (accurate shot every 450ms scores
// ~quota×(28 + push×0.5)): ★★★ demands chain/combo play beyond plain matching.
function starScores(quota, pushSpeed) {
const top = Math.round((quota * (28 + pushSpeed * 0.5)) / 10) * 10;
return [Math.round((top * 0.5) / 10) * 10, Math.round((top * 0.75) / 10) * 10, top];
}
// ── Validation (mirrors verifyZuma.js bank lint) ─────────────────────────────
function validate(level) {
const errs = [];
const p = buildPath(level.points);
if (p.length < level.quota * TUNING.BALL_SPACING * 1.6) {
errs.push(`path ${p.length.toFixed(0)}px too short for quota ${level.quota}`);
}
let minFrog = Infinity, minRadius = Infinity, minRadiusS = 0;
for (let i = 0; i < p.samples.length; i++) {
const s = p.samples[i];
minFrog = Math.min(minFrog, Math.hypot(s.x - level.frog[0], s.y - level.frog[1]));
if (s.s > 200 && (s.x < 40 || s.x > 1880 || s.y < 40 || s.y > 1040)) {
errs.push(`sample out of bounds at s=${s.s.toFixed(0)} (${s.x.toFixed(0)},${s.y.toFixed(0)})`);
break;
}
if (i > 0 && i < p.samples.length - 1 && s.s > 200) {
const a = p.samples[i - 1], c = p.samples[i + 1];
const v1x = s.x - a.x, v1y = s.y - a.y, v2x = c.x - s.x, v2y = c.y - s.y;
const l1 = Math.hypot(v1x, v1y), l2 = Math.hypot(v2x, v2y);
if (l1 > 0.01 && l2 > 0.01) {
const cos = Math.max(-1, Math.min(1, (v1x * v2x + v1y * v2y) / (l1 * l2)));
const theta = Math.acos(cos);
if (theta > 1e-4 && l1 / theta < minRadius) { minRadius = l1 / theta; minRadiusS = s.s; }
}
}
}
if (minFrog < 140) errs.push(`frog only ${minFrog.toFixed(0)}px from path`);
if (minRadius < TUNING.BALL_RADIUS * 1.7) errs.push(`min curve radius ${minRadius.toFixed(0)}px at s=${minRadiusS.toFixed(0)} of ${p.length.toFixed(0)}`);
return { errs, length: p.length, minFrog, minRadius };
}
// ── Build & write ────────────────────────────────────────────────────────────
const levels = [];
let bad = 0;
for (const [level, name, shape, colors, quota, introBalls, pushSpeed, powerUpRate] of TABLE) {
const { points, frog } = SHAPES[shape]();
const def = {
level, name, shape, points, frog, colors, quota, introBalls, pushSpeed, powerUpRate,
seed: 1000 + level * 7919,
starScores: starScores(quota, pushSpeed),
};
const { errs, length, minFrog, minRadius } = validate(def);
if (errs.length) {
bad++;
console.error(`L${String(level).padStart(2)} ${name.padEnd(16)} ${shape.padEnd(12)} INVALID: ${errs.join('; ')}`);
} else {
console.log(`L${String(level).padStart(2)} ${name.padEnd(16)} ${shape.padEnd(12)} len=${length.toFixed(0).padStart(5)} quota=${quota} frogClear=${minFrog.toFixed(0)} minR=${minRadius.toFixed(0)}`);
}
levels.push(def);
}
if (bad) {
console.error(`\n${bad} invalid level(s) — not writing ${OUT_FILE}`);
process.exit(1);
}
fs.writeFileSync(OUT_FILE, JSON.stringify({
generatedAt: new Date().toISOString(),
count: levels.length,
levels,
}, null, 1));
console.log(`\nWrote ${levels.length} levels to ${OUT_FILE}`);