fertig-classic-games/tools/genSuperKartTracks.js

284 lines
11 KiB
JavaScript

// Generates the 12 launch tracks + cups.json for Super Kart into
// assets/gamedata/superkart/. Run: node tools/genSuperKartTracks.js [--force]
//
// Contract (same as genPeggleLevels.js): tracks are generated ONCE and then
// hand-tuned in the editor (?superkart-editor=1). Without --force, existing
// track files are left untouched so tuning is never clobbered.
//
// Layouts are star-shaped polar curves r(θ) = base + Σ aᵢ·sin(iθ+φᵢ): any
// curve monotone in θ around the center cannot self-intersect, so every
// family is safe by construction and only width/turn-radius interplay needs
// validation (buildTrackModel + validateTrack, the same checks the editor
// and verifier run).
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import {
buildTrackModel, validateTrack, projectToSpline, SURFACE, surfaceAt, normAngle,
} from '../src/games/superkart/SuperKartTrack.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const OUT = join(ROOT, 'assets', 'gamedata', 'superkart');
const FORCE = process.argv.includes('--force');
const WORLD = 4096;
const CENTER = WORLD / 2;
const TAU = Math.PI * 2;
const rules = JSON.parse(readFileSync(join(ROOT, 'data', 'superkart-rules.json'), 'utf8'));
function mulberry32(seed) {
let a = seed >>> 0;
return () => {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ── Layout families ─────────────────────────────────────────────────────────
// Each returns { spline, laps } — control points around the world center.
function polarSpline(rng, { points, base, harmonics, width, widthJitter = 0, narrowAtCurves = 0 }) {
const phases = harmonics.map(() => rng() * TAU);
const rAt = (theta) => {
let r = base;
harmonics.forEach(([freq, amp], i) => { r += Math.sin(theta * freq + phases[i]) * amp; });
return Math.max(560, Math.min(r, CENTER - 420));
};
const spline = [];
for (let i = 0; i < points; i += 1) {
const theta = (i / points) * TAU;
const r = rAt(theta);
spline.push({
x: Math.round(CENTER + Math.cos(theta) * r),
y: Math.round(CENTER + Math.sin(theta) * r),
w: 0,
});
}
// Width: base ± jitter, narrowed where the local polyline turns hard.
for (let i = 0; i < points; i += 1) {
const a = spline[(i - 1 + points) % points];
const b = spline[i];
const c = spline[(i + 1) % points];
const turn = Math.abs(normAngle(
Math.atan2(c.y - b.y, c.x - b.x) - Math.atan2(b.y - a.y, b.x - a.x),
));
let w = width + (rng() * 2 - 1) * widthJitter;
w -= turn * narrowAtCurves;
spline[i].w = Math.round(Math.max(72, Math.min(180, w)));
}
return spline;
}
const FAMILIES = {
oval: (rng) => polarSpline(rng, {
points: 12, base: 1280, width: 132, widthJitter: 10,
harmonics: [[2, 220 + rng() * 120]],
}),
kidney: (rng) => polarSpline(rng, {
points: 14, base: 1260, width: 120, widthJitter: 14, narrowAtCurves: 20,
harmonics: [[2, 180 + rng() * 90], [3, 150 + rng() * 110], [4, 60 + rng() * 60]],
}),
hairpin: (rng) => polarSpline(rng, {
points: 16, base: 1240, width: 108, widthJitter: 12, narrowAtCurves: 34,
harmonics: [[3, 260 + rng() * 120], [5, 130 + rng() * 90]],
}),
pretzel: (rng) => polarSpline(rng, {
points: 18, base: 1200, width: 102, widthJitter: 10, narrowAtCurves: 30,
harmonics: [[2, 340 + rng() * 130], [5, 150 + rng() * 80], [7, 60 + rng() * 40]],
}),
speedway: (rng) => polarSpline(rng, {
points: 12, base: 1330, width: 150, widthJitter: 6,
harmonics: [[2, 160 + rng() * 80], [3, 70 + rng() * 50]],
}),
};
// ── Furniture: item rows, coins, boosts, decor, patches ─────────────────────
function curvatureAt(model, idx, span = 4) {
const n = model.samples.length;
const a = model.samples[(idx - span + n) % n];
const b = model.samples[idx];
const c = model.samples[(idx + span) % n];
const turn = Math.abs(normAngle(
Math.atan2(c.y - b.y, c.x - b.x) - Math.atan2(b.y - a.y, b.x - a.x),
));
return turn / (2 * span * model.step);
}
function furnish(track, model, rng, theme) {
const L = model.totalLength;
const n = model.samples.length;
// Item rows at thirds of the lap, measured from the start line.
track.itemRows = [0.28, 0.58, 0.86].map((f) => ({
s: Math.round((model.startS + f * L) % L),
count: 4,
}));
// Curvature thresholds adapt to each layout: "straight" is the gentlest
// 30% of the lap, "hard corner" the sharpest 10%.
const curvs = [];
for (let i = 0; i < n; i += 1) curvs.push(curvatureAt(model, i));
const sorted = [...curvs].sort((a, b) => a - b);
const straightThresh = sorted[Math.floor(n * 0.30)];
const hardThresh = sorted[Math.floor(n * 0.90)];
// Coins on straights: runs of low curvature get a small arc of coins.
track.coins = [];
let run = 0;
for (let i = 0; i < n && track.coins.length < 24; i += 1) {
if (curvs[i] <= straightThresh) run += 1;
else run = 0;
if (run >= 10 && i % 4 === 0) {
const p = model.samples[i];
const lane = Math.sin(i * 0.7) * 0.45;
track.coins.push({
x: Math.round(p.x + -p.ty * lane * p.w),
y: Math.round(p.y + p.tx * lane * p.w),
});
}
}
// Boost pads on corner exits: hard corner followed by an easing section.
track.boosts = [];
for (let i = 0; i < n && track.boosts.length < 3; i += 1) {
const hard = curvs[i] >= hardThresh;
const clearAhead = curvs[(i + 12) % n] <= straightThresh * 1.5;
if (hard && clearAhead) {
const p = model.samples[(i + 14) % n];
track.boosts.push({ s: Math.round(p.s), lane: Math.round((rng() * 2 - 1) * 4) / 10 });
i += 60; // one pad per corner complex
}
}
// Roadside decor: offroad points near the track edge.
track.decor = [];
const decorList = theme.decor ?? ['tree'];
let guard = 0;
while (track.decor.length < 16 && guard < 400) {
guard += 1;
const p = model.samples[Math.floor(rng() * n)];
const side = rng() < 0.5 ? -1 : 1;
const dist = p.w + 60 + rng() * 240;
const x = Math.round(p.x + -p.ty * side * dist);
const y = Math.round(p.y + p.tx * side * dist);
if (surfaceAt(model, x, y) !== SURFACE.OFFROAD) continue;
track.decor.push({ sprite: decorList[track.decor.length % decorList.length], x, y });
}
// One or two scenic hazard patches well clear of the racing line.
track.surfaces = [];
const patchType = ['beach', 'swamp', 'nightcity'].includes(track.theme) ? 'water' : 'deep';
guard = 0;
while (track.surfaces.length < 2 && guard < 300) {
guard += 1;
const x = 500 + rng() * (WORLD - 1000);
const y = 500 + rng() * (WORLD - 1000);
const proj = projectToSpline(model, x, y);
const maxW = 200;
if (proj.d < maxW + 320) continue;
track.surfaces.push({ type: patchType, circle: [Math.round(x), Math.round(y), Math.round(140 + rng() * 140)] });
}
track.hazards = [];
track.walls = [];
}
// ── Track plan ──────────────────────────────────────────────────────────────
const PLAN = [
// Sprocket Cup — wide, forgiving.
{ id: 'track-001', cup: 0, name: 'Gerome Speedway', theme: 'speedway', family: 'oval', laps: 5 },
{ id: 'track-002', cup: 0, name: 'Kona Cove', theme: 'beach', family: 'kidney', laps: 5 },
{ id: 'track-003', cup: 0, name: 'Bayou Bend', theme: 'swamp', family: 'kidney', laps: 5 },
{ id: 'track-004', cup: 0, name: 'Scrap Run', theme: 'scrapyard', family: 'oval', laps: 5 },
// Flywheel Cup — quicker corners.
{ id: 'track-005', cup: 1, name: 'Sunwash Shore', theme: 'beach', family: 'hairpin', laps: 5 },
{ id: 'track-006', cup: 1, name: 'Cinder Peaks', theme: 'volcano', family: 'kidney', laps: 5 },
{ id: 'track-007', cup: 1, name: 'Neon Nights', theme: 'nightcity', family: 'pretzel', laps: 5 },
{ id: 'track-008', cup: 1, name: 'Speedway Classic', theme: 'speedway', family: 'speedway', laps: 5 },
// Nitro Cup — narrow and technical.
{ id: 'track-009', cup: 2, name: "Croc's Crossing", theme: 'swamp', family: 'hairpin', laps: 5 },
{ id: 'track-010', cup: 2, name: "Smasher's Junction", theme: 'scrapyard', family: 'pretzel', laps: 5 },
{ id: 'track-011', cup: 2, name: 'Fireball Furnace', theme: 'volcano', family: 'hairpin', laps: 5 },
{ id: 'track-012', cup: 2, name: 'Blackwind Boulevard', theme: 'nightcity', family: 'pretzel', laps: 5 },
];
const CUPS = [
{ id: 'cup-1', name: 'Sprocket Cup' },
{ id: 'cup-2', name: 'Flywheel Cup' },
{ id: 'cup-3', name: 'Nitro Cup' },
];
// ── Main ────────────────────────────────────────────────────────────────────
mkdirSync(OUT, { recursive: true });
let written = 0;
let skipped = 0;
let failed = 0;
PLAN.forEach((plan, ti) => {
const file = join(OUT, `${plan.id}.json`);
if (existsSync(file) && !FORCE) {
console.log(`skip ${plan.id} (exists — hand-tuned files are never rewritten; use --force)`);
skipped += 1;
return;
}
const theme = rules.themes[plan.theme];
let track = null;
for (let attempt = 0; attempt < 40; attempt += 1) {
const rng = mulberry32(0xC0FFEE + ti * 977 + attempt * 131071);
const spline = FAMILIES[plan.family](rng);
const candidate = {
version: 1,
id: plan.id,
name: plan.name,
theme: plan.theme,
laps: plan.laps,
world: WORLD,
spline,
startIndex: 0,
surfaces: [], walls: [], boosts: [], itemRows: [], hazards: [], decor: [], coins: [],
};
let model;
try {
model = buildTrackModel(candidate);
} catch (_) { continue; }
if (validateTrack(model).length) continue;
furnish(candidate, model, rng, theme);
// Furniture can't break geometry, but re-validate with everything in place.
const finalModel = buildTrackModel(candidate);
const issues = validateTrack(finalModel);
if (issues.length) continue;
track = candidate;
console.log(`write ${plan.id} ${plan.name.padEnd(20)} ${plan.family.padEnd(9)} lap ${Math.round(finalModel.totalLength)}u, ${candidate.coins.length} coins, ${candidate.boosts.length} boosts (attempt ${attempt + 1})`);
break;
}
if (!track) {
console.error(`FAIL ${plan.id} — no valid layout found`);
failed += 1;
return;
}
writeFileSync(file, `${JSON.stringify(track, null, 2)}\n`);
written += 1;
});
const cupsJson = {
version: 1,
cups: CUPS.map((cup, i) => ({
...cup,
tracks: PLAN.filter((p) => p.cup === i).map((p) => p.id),
})),
tracks: PLAN.map((p) => ({ id: p.id, name: p.name, theme: p.theme, file: `${p.id}.json` })),
};
const cupsFile = join(OUT, 'cups.json');
if (!existsSync(cupsFile) || FORCE || written > 0) {
writeFileSync(cupsFile, `${JSON.stringify(cupsJson, null, 2)}\n`);
console.log('write cups.json');
}
console.log(`\ndone: ${written} written, ${skipped} skipped, ${failed} failed`);
process.exit(failed ? 1 : 0);