455 lines
16 KiB
JavaScript
455 lines
16 KiB
JavaScript
// 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;
|
||
}
|