284 lines
11 KiB
JavaScript
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);
|