fertig-classic-games/src/games/superkart/SuperKartTrack.js

455 lines
16 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.

// Super Kart headless track model. Pure ESM with no Phaser or canvas imports so
// tools/verifySuperKart.js and tools/genSuperKartTracks.js can drive it in Node.
//
// A track JSON (assets/gamedata/superkart/track-NNN.json) is a closed
// Catmull-Rom centerline with a per-control-point half-width. Everything that
// belongs to the road (start line, item rows, boost pads) is addressed by
// arc-length `s` plus a lane offset (-1..1 across the width) so spline edits
// carry them along. buildTrackModel() turns that JSON into the one structure
// the rasterizer, the physics sim, the AI, and the minimap all share.
export const SURFACE = {
OOB: 0, // outside the world
OFFROAD: 1, // theme terrain — slow
ROAD: 2,
CURB: 3, // striped edge band — drivable, slight grip change only visually
BOOST: 4, // boost pad on the road
DEEP: 5, // pit/lava/deep sand — crawl + rescue
WATER: 6, // water — rescue
WALL: 7, // impassable
};
export const CURB_WIDTH = 8; // world units, each road edge
export const STRIPE_LEN = 32; // world units per white/red curb stripe
export const SAMPLE_STEP = 16; // centerline resample spacing
export const CHECKPOINT_STEP = 64;
export const GRID_CELLS = 256; // surfaceGrid is GRID_CELLS × GRID_CELLS
const TAU = Math.PI * 2;
// ── Catmull-Rom (closed, centripetal-ish uniform) ───────────────────────────
function catmull(p0, p1, p2, p3, t) {
const t2 = t * t;
const t3 = t2 * t;
return 0.5 * ((2 * p1) + (-p0 + p2) * t
+ (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2
+ (-p0 + 3 * p1 - 3 * p2 + p3) * t3);
}
// Evaluates the closed loop through pts at segment i, local t in [0,1).
function evalSpline(pts, i, t) {
const n = pts.length;
const a = pts[(i - 1 + n) % n];
const b = pts[i % n];
const c = pts[(i + 1) % n];
const d = pts[(i + 2) % n];
return {
x: catmull(a.x, b.x, c.x, d.x, t),
y: catmull(a.y, b.y, c.y, d.y, t),
w: b.w + (c.w - b.w) * t,
};
}
// ── Model construction ──────────────────────────────────────────────────────
// Resamples the control loop into evenly spaced samples {x, y, tx, ty, w, s}.
function resample(pts) {
const SUBDIV = 32;
const fine = [];
const ctrlS = new Array(pts.length); // arc-length at each control point
let acc = 0;
let prev = null;
for (let i = 0; i < pts.length; i += 1) {
ctrlS[i] = acc;
for (let k = 0; k < SUBDIV; k += 1) {
const p = evalSpline(pts, i, k / SUBDIV);
if (prev) acc += Math.hypot(p.x - prev.x, p.y - prev.y);
p.s = acc;
fine.push(p);
prev = p;
}
}
const total = acc + Math.hypot(fine[0].x - prev.x, fine[0].y - prev.y);
const count = Math.max(32, Math.round(total / SAMPLE_STEP));
const step = total / count;
const samples = new Array(count);
let fi = 0;
for (let i = 0; i < count; i += 1) {
const target = i * step;
while (fi < fine.length - 1 && fine[fi + 1].s < target) fi += 1;
const a = fine[fi];
const b = fine[(fi + 1) % fine.length];
const span = Math.max(1e-6, (fi === fine.length - 1 ? total : b.s) - a.s);
const t = Math.min(1, Math.max(0, (target - a.s) / span));
samples[i] = {
x: a.x + (b.x - a.x) * t,
y: a.y + (b.y - a.y) * t,
w: a.w + (b.w - a.w) * t,
s: target,
tx: 0, ty: 0,
};
}
// Central-difference tangents around the loop.
for (let i = 0; i < count; i += 1) {
const p = samples[(i - 1 + count) % count];
const q = samples[(i + 1) % count];
const dx = q.x - p.x;
const dy = q.y - p.y;
const len = Math.hypot(dx, dy) || 1;
samples[i].tx = dx / len;
samples[i].ty = dy / len;
}
return { samples, totalLength: count * step, step, ctrlS };
}
// Spatial hash of sample indices for nearest-centerline queries.
function buildHash(samples, world) {
const cell = 64;
const cols = Math.ceil(world / cell);
const hash = new Map();
for (let i = 0; i < samples.length; i += 1) {
const cx = Math.min(cols - 1, Math.max(0, Math.floor(samples[i].x / cell)));
const cy = Math.min(cols - 1, Math.max(0, Math.floor(samples[i].y / cell)));
const key = cy * cols + cx;
if (!hash.has(key)) hash.set(key, []);
hash.get(key).push(i);
}
return { hash, cell, cols };
}
function nearbySampleIndices(model, x, y, ring) {
const { hash, cell, cols } = model._hash;
const cx = Math.floor(x / cell);
const cy = Math.floor(y / cell);
const out = [];
for (let dy = -ring; dy <= ring; dy += 1) {
for (let dx = -ring; dx <= ring; dx += 1) {
const gx = cx + dx;
const gy = cy + dy;
if (gx < 0 || gy < 0 || gx >= cols || gy >= cols) continue;
const bucket = hash.get(gy * cols + gx);
if (bucket) out.push(...bucket);
}
}
return out;
}
// Distance from (x,y) to the segment samples[i] → samples[i+1].
function segDist(samples, i, x, y) {
const a = samples[i];
const b = samples[(i + 1) % samples.length];
const abx = b.x - a.x;
const aby = b.y - a.y;
const len2 = abx * abx + aby * aby || 1e-6;
let t = ((x - a.x) * abx + (y - a.y) * aby) / len2;
t = Math.max(0, Math.min(1, t));
const px = a.x + abx * t;
const py = a.y + aby * t;
return { d: Math.hypot(x - px, y - py), t, px, py };
}
function pointInPoly(poly, x, y) {
let inside = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i, i += 1) {
const [xi, yi] = poly[i];
const [xj, yj] = poly[j];
if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
}
// Interpolated centerline point at arc-length s (wraps).
export function sampleAt(model, s) {
const { samples, totalLength, step } = model;
let ss = s % totalLength;
if (ss < 0) ss += totalLength;
const i = Math.floor(ss / step) % samples.length;
const a = samples[i];
const b = samples[(i + 1) % samples.length];
const t = (ss - i * step) / step;
return {
x: a.x + (b.x - a.x) * t,
y: a.y + (b.y - a.y) * t,
tx: a.tx + (b.tx - a.tx) * t,
ty: a.ty + (b.ty - a.ty) * t,
w: a.w + (b.w - a.w) * t,
s: ss,
};
}
// World position for an (s, lane) road address. lane -1..1 spans the width.
export function roadPoint(model, s, lane = 0) {
const p = sampleAt(model, s);
return {
x: p.x + -p.ty * lane * p.w,
y: p.y + p.tx * lane * p.w,
tx: p.tx, ty: p.ty, w: p.w,
};
}
export function surfaceAt(model, x, y) {
if (x < 0 || y < 0 || x >= model.world || y >= model.world) return SURFACE.OOB;
const cs = model.world / GRID_CELLS;
const gx = Math.floor(x / cs);
const gy = Math.floor(y / cs);
return model.surfaceGrid[gy * GRID_CELLS + gx];
}
// Continuity-aware projection: searches only a window of samples around a
// known previous arc-length. Where two track sections pinch close together
// (pretzel lobes, hairpins) the global nearest-point search can flip to the
// wrong branch — karts track their own splineS and use this instead.
export function projectToSplineNear(model, x, y, prevS, windowSamples = 40) {
const { samples } = model;
const n = samples.length;
const i0 = Math.round((((prevS % model.totalLength) + model.totalLength) % model.totalLength) / model.step) % n;
let best = null;
for (let di = -windowSamples; di <= windowSamples; di += 1) {
const i = (i0 + di + n) % n;
const r = segDist(samples, i, x, y);
if (!best || r.d < best.d) best = { ...r, i };
}
// Way off the window (post-rescue teleports, etc.): fall back to global.
if (!best || best.d > 500) return projectToSpline(model, x, y);
const a = samples[best.i];
const cross = a.tx * (y - best.py) - a.ty * (x - best.px);
const s = (a.s + best.t * model.step) % model.totalLength;
const w = Math.max(1, a.w);
return { s, d: best.d, laneT: Math.sign(cross) * (best.d / w) };
}
// Nearest point on the centerline: {s, d, laneT} where laneT is the signed
// lateral offset in units of the local half-width (negative = left of travel).
export function projectToSpline(model, x, y) {
const { samples } = model;
let ring = 1;
let cands = nearbySampleIndices(model, x, y, ring);
while (cands.length === 0 && ring < 40) {
ring += ring; // expand until something is in range (far off-track queries)
cands = nearbySampleIndices(model, x, y, ring);
}
let best = null;
for (const i of cands) {
const r = segDist(samples, i, x, y);
if (!best || r.d < best.d) best = { ...r, i };
}
if (!best) return { s: 0, d: Infinity, laneT: 0 };
const a = samples[best.i];
const cross = a.tx * (y - best.py) - a.ty * (x - best.px);
const s = (a.s + best.t * model.step) % model.totalLength;
const w = Math.max(1, a.w);
return { s, d: best.d, laneT: Math.sign(cross) * (best.d / w) };
}
function buildSurfaceGrid(model, json) {
const grid = new Uint8Array(GRID_CELLS * GRID_CELLS).fill(SURFACE.OFFROAD);
const cs = model.world / GRID_CELLS;
const maxW = model.samples.reduce((m, p) => Math.max(m, p.w), 0) + CURB_WIDTH;
const reach = Math.ceil((maxW + cs) / 64) + 1;
for (let gy = 0; gy < GRID_CELLS; gy += 1) {
for (let gx = 0; gx < GRID_CELLS; gx += 1) {
const x = (gx + 0.5) * cs;
const y = (gy + 0.5) * cs;
let best = null;
for (const i of nearbySampleIndices(model, x, y, reach)) {
const r = segDist(model.samples, i, x, y);
if (!best || r.d < best.d) best = { ...r, i };
}
if (!best) continue;
const a = model.samples[best.i];
const b = model.samples[(best.i + 1) % model.samples.length];
const w = a.w + (b.w - a.w) * best.t;
if (best.d < w) grid[gy * GRID_CELLS + gx] = SURFACE.ROAD;
else if (best.d < w + CURB_WIDTH) grid[gy * GRID_CELLS + gx] = SURFACE.CURB;
}
}
// Painted patches only override terrain — the road stays drivable.
const paint = (test, code) => {
for (let gy = 0; gy < GRID_CELLS; gy += 1) {
for (let gx = 0; gx < GRID_CELLS; gx += 1) {
const idx = gy * GRID_CELLS + gx;
if (grid[idx] !== SURFACE.OFFROAD) continue;
if (test((gx + 0.5) * cs, (gy + 0.5) * cs)) grid[idx] = code;
}
}
};
for (const surf of json.surfaces ?? []) {
const code = surf.type === 'water' ? SURFACE.WATER : SURFACE.DEEP;
if (surf.poly) paint((x, y) => pointInPoly(surf.poly, x, y), code);
else if (surf.circle) {
const [cx, cy, r] = surf.circle;
paint((x, y) => (x - cx) * (x - cx) + (y - cy) * (y - cy) < r * r, code);
}
}
// Boost pads stamp BOOST onto road cells around their road point.
for (const boost of model.boosts) {
const r = Math.ceil(24 / cs);
const gx0 = Math.floor(boost.x / cs);
const gy0 = Math.floor(boost.y / cs);
for (let gy = gy0 - r; gy <= gy0 + r; gy += 1) {
for (let gx = gx0 - r; gx <= gx0 + r; gx += 1) {
if (gx < 0 || gy < 0 || gx >= GRID_CELLS || gy >= GRID_CELLS) continue;
const idx = gy * GRID_CELLS + gx;
if (grid[idx] === SURFACE.ROAD) grid[idx] = SURFACE.BOOST;
}
}
}
// Wall polylines rasterize as thick impassable bands (override everything).
for (const wall of json.walls ?? []) {
const pts = wall.pts ?? [];
const end = wall.closed ? pts.length : pts.length - 1;
for (let i = 0; i < end; i += 1) {
const [ax, ay] = pts[i];
const [bx, by] = pts[(i + 1) % pts.length];
const len = Math.hypot(bx - ax, by - ay);
const steps = Math.max(1, Math.ceil(len / (cs * 0.5)));
for (let k = 0; k <= steps; k += 1) {
const x = ax + ((bx - ax) * k) / steps;
const y = ay + ((by - ay) * k) / steps;
const gx = Math.floor(x / cs);
const gy = Math.floor(y / cs);
if (gx < 0 || gy < 0 || gx >= GRID_CELLS || gy >= GRID_CELLS) continue;
grid[gy * GRID_CELLS + gx] = SURFACE.WALL;
}
}
}
return grid;
}
export function buildTrackModel(json) {
const world = json.world ?? 4096;
const pts = json.spline.map((p) => ({ x: p.x, y: p.y, w: p.w }));
const { samples, totalLength, step, ctrlS } = resample(pts);
const model = {
id: json.id,
name: json.name ?? json.id,
theme: json.theme ?? 'speedway',
laps: json.laps ?? 5,
world,
samples,
totalLength,
step,
json,
};
model._hash = buildHash(samples, world);
// Start line + grid. startIndex picks the control point the line sits at.
const startS = ctrlS[Math.min(json.startIndex ?? 0, pts.length - 1)] % totalLength;
const sp = sampleAt(model, startS);
model.startS = startS;
model.startPose = { x: sp.x, y: sp.y, heading: Math.atan2(sp.ty, sp.tx) };
model.gridSlots = [];
for (let i = 0; i < 9; i += 1) {
const row = Math.floor(i / 2);
const lane = i % 2 === 0 ? -0.4 : 0.4;
const s = ((startS - 70 - row * 46) % totalLength + totalLength) % totalLength;
const p = roadPoint(model, s, lane);
model.gridSlots.push({ x: p.x, y: p.y, heading: Math.atan2(p.ty, p.tx), s });
}
// Checkpoints: ordered gates the sim uses for lap validation, positions,
// and AI targets. Index 0 sits on the start line.
const cpCount = Math.max(8, Math.round(totalLength / CHECKPOINT_STEP));
model.checkpoints = [];
for (let i = 0; i < cpCount; i += 1) {
const s = (startS + (i * totalLength) / cpCount) % totalLength;
const p = sampleAt(model, s);
model.checkpoints.push({ x: p.x, y: p.y, tx: p.tx, ty: p.ty, w: p.w, s });
}
// Item boxes from itemRows {s, count}: a row of boxes across the road.
model.itemBoxes = [];
for (const row of json.itemRows ?? []) {
const count = Math.max(1, row.count ?? 4);
for (let j = 0; j < count; j += 1) {
const lane = count === 1 ? 0 : -0.6 + (1.2 * j) / (count - 1);
const p = roadPoint(model, row.s, lane);
model.itemBoxes.push({ x: p.x, y: p.y, s: row.s, lane });
}
}
// Boost pads {s, lane} → world points (surface stamping happens below).
model.boosts = (json.boosts ?? []).map((bp) => {
const p = roadPoint(model, bp.s, bp.lane ?? 0);
return { x: p.x, y: p.y, angle: Math.atan2(p.ty, p.tx), s: bp.s, lane: bp.lane ?? 0 };
});
model.coins = (json.coins ?? []).map((c) => ({ x: c.x, y: c.y }));
model.hazards = (json.hazards ?? []).map((hz) => ({ ...hz }));
model.decor = (json.decor ?? []).map((d) => ({ ...d }));
model.surfaceGrid = buildSurfaceGrid(model, json);
return model;
}
// ── Validation (shared by the editor strip, the generator, and verify) ──────
function segsIntersect(a, b, c, d) {
const orient = (p, q, r) => Math.sign((q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x));
return orient(a, b, c) !== orient(a, b, d) && orient(c, d, a) !== orient(c, d, b)
&& orient(a, b, c) !== 0 && orient(c, d, a) !== 0;
}
export function validateTrack(model) {
const issues = [];
const { samples } = model;
const n = samples.length;
if (model.json.spline.length < 4) issues.push('spline needs at least 4 control points');
// Self-intersection: non-adjacent centerline segments must not cross.
outer:
for (let i = 0; i < n; i += 1) {
for (let j = i + 2; j < n; j += 1) {
if (i === 0 && j === n - 1) continue; // loop closure adjacency
if (segsIntersect(samples[i], samples[(i + 1) % n],
samples[j], samples[(j + 1) % n])) {
issues.push(`centerline crosses itself near s=${Math.round(samples[i].s)}`);
break outer;
}
}
}
// Width sanity + turn radius vs width: a hairpin tighter than the road is
// wide folds the ribbon over itself.
for (let i = 0; i < n; i += 1) {
const p = samples[i];
if (p.w < 40) { issues.push(`road too narrow (${Math.round(p.w)}) at s=${Math.round(p.s)}`); break; }
}
for (let i = 0; i < n; i += 1) {
const a = samples[(i - 1 + n) % n];
const b = samples[i];
const c = samples[(i + 1) % n];
const angle = Math.abs(normAngle(Math.atan2(c.y - b.y, c.x - b.x) - Math.atan2(b.y - a.y, b.x - a.x)));
const radius = angle > 1e-4 ? model.step / angle : Infinity;
if (radius < b.w * 0.8) {
issues.push(`turn tighter than road width at s=${Math.round(b.s)} (radius ${Math.round(radius)} vs width ${Math.round(b.w)})`);
break;
}
}
// Track must fit inside the world with margin for curbs.
for (const p of samples) {
const m = p.w + CURB_WIDTH + 16;
if (p.x < m || p.y < m || p.x > model.world - m || p.y > model.world - m) {
issues.push(`track leaves the world near s=${Math.round(p.s)}`);
break;
}
}
return issues;
}
export function normAngle(a) {
let r = a % TAU;
if (r > Math.PI) r -= TAU;
if (r < -Math.PI) r += TAU;
return r;
}