902 lines
34 KiB
JavaScript
902 lines
34 KiB
JavaScript
// Super Kart headless race simulation. Pure ESM with no Phaser imports so
|
|
// tools/verifySuperKart.js can soak-test full races in Node. All tuning comes
|
|
// from data/superkart-rules.json; all track geometry from SuperKartTrack.js.
|
|
//
|
|
// The scene drives it at a fixed 60Hz:
|
|
// const state = createRace({ trackModel, rules, engineClass, racers, playerIndex, mode, seed });
|
|
// step(state, playerInputs); // playerInputs from kartInputsNeutral()
|
|
// ...then reads state.karts / state.events for rendering.
|
|
|
|
import {
|
|
SURFACE, surfaceAt, projectToSplineNear, normAngle,
|
|
} from './SuperKartTrack.js';
|
|
|
|
const TAU = Math.PI * 2;
|
|
export const STEP_MS = 1000 / 60;
|
|
export const COUNTDOWN_MS = 3400;
|
|
// Once the player finishes, the field is given this long (sim time) to finish
|
|
// too before the race is forced to end — a safety net against a stuck AI,
|
|
// not the normal path (state.karts.every(finished) ends it sooner).
|
|
const POST_FINISH_SAFETY_MS = 90000;
|
|
|
|
export function mulberry32(seed) {
|
|
let a = seed >>> 0;
|
|
return function rng() {
|
|
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;
|
|
};
|
|
}
|
|
|
|
export function kartInputsNeutral() {
|
|
return { steer: 0, accel: false, brake: false, hop: false, item: false };
|
|
}
|
|
|
|
// ── Stat formulas (exported for the verify script and select-screen bars) ───
|
|
|
|
export function topSpeedOf(stats, physics, classMult = 1) {
|
|
return (physics.baseTopSpeed + (stats.topSpeed - 0.5) * physics.topSpeedSpread) * classMult;
|
|
}
|
|
|
|
export function accelOf(stats, physics) {
|
|
return physics.baseAccel + (stats.accel - 0.5) * physics.accelSpread;
|
|
}
|
|
|
|
export function turnRateOf(stats, physics, speedFrac) {
|
|
const base = physics.turnRateBase + (stats.handling - 0.5) * physics.turnRateSpread;
|
|
return base * (1 - physics.speedTurnPenalty * Math.min(1, Math.max(0, speedFrac)));
|
|
}
|
|
|
|
export function offroadMultOf(stats, physics) {
|
|
return physics.offroadMultMin + (physics.offroadMultMax - physics.offroadMultMin) * stats.offroad;
|
|
}
|
|
|
|
export function surfaceMult(code, stats, physics) {
|
|
if (code === SURFACE.OFFROAD) return offroadMultOf(stats, physics);
|
|
if (code === SURFACE.DEEP) return physics.deepMult;
|
|
return 1; // road, curb, and boost pads are all full speed
|
|
}
|
|
|
|
export function pointsFor(position, rules) {
|
|
return rules.pointsTable[Math.min(position - 1, rules.pointsTable.length - 1)] ?? 0;
|
|
}
|
|
|
|
// Position-weighted item roulette. position is 1-based.
|
|
export function rollItem(rng, position, rules) {
|
|
const col = Math.max(0, Math.min(8, position - 1));
|
|
const table = rules.itemWeights.byPosition;
|
|
const ids = rules.items.map((it) => it.id).filter((id) => table[id]);
|
|
let total = 0;
|
|
for (const id of ids) total += table[id][col];
|
|
let roll = rng() * total;
|
|
for (const id of ids) {
|
|
roll -= table[id][col];
|
|
if (roll <= 0) return id;
|
|
}
|
|
return ids[ids.length - 1];
|
|
}
|
|
|
|
// ── Race construction ───────────────────────────────────────────────────────
|
|
|
|
function makeKart(index, racer, isPlayer, slot, physics) {
|
|
return {
|
|
index,
|
|
racer,
|
|
isPlayer,
|
|
aiSkill: racer.aiSkill ?? 0.5,
|
|
x: slot.x,
|
|
y: slot.y,
|
|
heading: slot.heading,
|
|
velAngle: slot.heading, // velocity direction chases heading (drift feel)
|
|
speed: 0,
|
|
hopMs: 0,
|
|
hopCooldown: 0,
|
|
airborne: false,
|
|
drifting: false,
|
|
driftDir: 0,
|
|
driftChargeMs: 0, // mini-turbo charge accrued while committed to the slide
|
|
prevHop: false,
|
|
prevItem: false,
|
|
surface: SURFACE.ROAD,
|
|
offroadMs: 0,
|
|
spinMs: 0,
|
|
spinSpin: 0, // visual rotation accumulator while spinning
|
|
squashMs: 0,
|
|
invulnMs: 0,
|
|
boostMs: 0,
|
|
boostMult: 1,
|
|
starMs: 0,
|
|
empMs: 0,
|
|
wallHitCooldown: 0,
|
|
coins: 0,
|
|
item: null,
|
|
rouletteMs: 0,
|
|
lap: 1,
|
|
cpIndex: 0,
|
|
splineS: slot.s, // continuity anchor for windowed spline projection
|
|
sRel: 0,
|
|
progress: 0,
|
|
position: index + 1,
|
|
finished: false,
|
|
finishTimeMs: 0,
|
|
lapStartMs: 0,
|
|
bestLapMs: 0,
|
|
rescueMs: 0,
|
|
cpMissMs: 0, // blink/banner window after a missed-checkpoint correction
|
|
stuckMs: 0,
|
|
reverseMs: 0,
|
|
deepMs: 0,
|
|
aiLane: 0,
|
|
aiLaneLap: 0,
|
|
aiItemDelay: 0,
|
|
radius: physics.kartRadius,
|
|
};
|
|
}
|
|
|
|
// racers: array of racer defs (from superkart-racers.json), one per grid slot,
|
|
// in grid order. playerIndex marks which of them the human drives (-1 = none,
|
|
// e.g. verify soaks). mode 'tt' skips AI karts entirely.
|
|
export function createRace({ trackModel, rules, engineClass, racers, playerIndex = 0, mode = 'gp', seed = 1 }) {
|
|
const rng = mulberry32(seed);
|
|
const physics = rules.physics;
|
|
const roster = mode === 'tt' ? [racers[playerIndex]] : racers;
|
|
const pIdx = mode === 'tt' ? 0 : playerIndex;
|
|
|
|
const state = {
|
|
mode,
|
|
rules,
|
|
physics,
|
|
engineClass,
|
|
model: trackModel,
|
|
laps: trackModel.laps,
|
|
rng,
|
|
seed,
|
|
tick: 0,
|
|
timeMs: 0,
|
|
phase: 'countdown',
|
|
countdownMs: COUNTDOWN_MS,
|
|
playerIndex: pIdx,
|
|
karts: roster.map((r, i) => makeKart(i, r, i === pIdx, trackModel.gridSlots[i], physics)),
|
|
itemBoxes: trackModel.itemBoxes.map((b) => ({ x: b.x, y: b.y, respawnAt: 0 })),
|
|
coins: trackModel.coins.map((c) => ({ x: c.x, y: c.y, taken: false })),
|
|
hazardsDropped: [],
|
|
projectiles: [],
|
|
finishOrder: [],
|
|
events: [],
|
|
};
|
|
for (const k of state.karts) {
|
|
k.aiLane = (rng() * 2 - 1) * 0.5;
|
|
k.aiItemDelay = 400 + rng() * 1200;
|
|
}
|
|
return state;
|
|
}
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
function emit(state, type, data) {
|
|
state.events.push({ type, ...data });
|
|
}
|
|
|
|
function classSpeedMult(state) { return state.engineClass?.speedMult ?? 1; }
|
|
|
|
// Gap-to-player rubber-banding: AI karts trailing the player get a top-speed
|
|
// boost, karts ahead get held back, scaled to 0 at the player's own
|
|
// progress and saturating at +/-rubberBandRange world units of gap. Engine
|
|
// class scales the whole effect (rubberBandMult) — Cruiser leans on this
|
|
// hard for forgiving rivals, Turbo barely uses it to stay ruthless.
|
|
function rubberBandMultOf(state, kart) {
|
|
if (kart.isPlayer) return 1;
|
|
const player = state.karts[state.playerIndex];
|
|
if (!player?.isPlayer) return 1;
|
|
const { physics } = state;
|
|
const gain = physics.rubberBandGain * (state.engineClass?.rubberBandMult ?? 1);
|
|
if (gain <= 0) return 1;
|
|
const gap = player.progress - kart.progress; // + = kart trails the player
|
|
const t = Math.max(-1, Math.min(1, gap / physics.rubberBandRange));
|
|
return 1 + gain * t;
|
|
}
|
|
|
|
function effTopSpeed(state, kart) {
|
|
const { physics } = state;
|
|
let mult = classSpeedMult(state) * rubberBandMultOf(state, kart);
|
|
mult *= 1 + kart.coins * physics.coinTopSpeedBonus;
|
|
if (kart.starMs > 0) mult *= itemParams(state, 'overdrive').mult ?? 1.2;
|
|
if (kart.empMs > 0) mult *= itemParams(state, 'emp').mult ?? 0.6;
|
|
if (kart.squashMs > 0) mult *= physics.squashSpeedMult;
|
|
if (!kart.airborne && kart.starMs <= 0) {
|
|
mult *= surfaceMult(kart.surface, kart.racer.stats, physics);
|
|
}
|
|
if (kart.boostMs > 0) mult *= kart.boostMult;
|
|
return topSpeedOf(kart.racer.stats, physics, mult);
|
|
}
|
|
|
|
function itemParams(state, id) {
|
|
return state.rules.items.find((it) => it.id === id)?.params ?? {};
|
|
}
|
|
|
|
function respawnAtCheckpoint(state, kart) {
|
|
const cps = state.model.checkpoints;
|
|
const cp = cps[kart.cpIndex % cps.length];
|
|
kart.x = cp.x;
|
|
kart.y = cp.y;
|
|
kart.heading = Math.atan2(cp.ty, cp.tx);
|
|
kart.velAngle = kart.heading;
|
|
kart.speed = 0;
|
|
kart.drifting = false;
|
|
kart.driftChargeMs = 0;
|
|
kart.airborne = false;
|
|
kart.hopMs = 0;
|
|
kart.offroadMs = 0;
|
|
kart.deepMs = 0;
|
|
kart.splineS = cp.s;
|
|
kart.invulnMs = state.physics.invulnMs;
|
|
}
|
|
|
|
function startSpin(state, kart, cause) {
|
|
if (kart.invulnMs > 0 || kart.starMs > 0 || kart.spinMs > 0) return false;
|
|
kart.spinMs = state.physics.spinMs;
|
|
kart.spinSpin = 0;
|
|
kart.drifting = false;
|
|
kart.driftChargeMs = 0;
|
|
const dropped = Math.min(kart.coins, state.physics.coinsLostOnHit);
|
|
kart.coins -= dropped;
|
|
emit(state, 'spin', { kart: kart.index, cause });
|
|
return true;
|
|
}
|
|
|
|
// ── AI ──────────────────────────────────────────────────────────────────────
|
|
|
|
function aiSkillOf(state, kart) {
|
|
return Math.min(1, kart.aiSkill * (state.engineClass?.aiSkillMult ?? 1));
|
|
}
|
|
|
|
function computeAiInputs(state, kart) {
|
|
const inputs = kartInputsNeutral();
|
|
// Finished karts (including the autopiloted player) keep actively driving
|
|
// laps through the results/standings screens rather than coasting to a
|
|
// stop — the scene only stops stepping this state once it tears the race
|
|
// down (next race start, or the cup-final podium).
|
|
const { model, physics } = state;
|
|
const skill = aiSkillOf(state, kart);
|
|
|
|
const idx = Math.round(kart.splineS / model.step) % model.samples.length;
|
|
const top = effTopSpeed(state, kart);
|
|
const speedFrac = top > 0 ? kart.speed / top : 0;
|
|
const lookahead = Math.round(
|
|
physics.aiLookaheadMin + (physics.aiLookaheadMax - physics.aiLookaheadMin)
|
|
* Math.min(1, speedFrac) * (0.7 + 0.3 * skill),
|
|
);
|
|
const target = model.samples[(idx + Math.max(2, lookahead)) % model.samples.length];
|
|
const tx = target.x + -target.ty * kart.aiLane * target.w * 0.8;
|
|
const ty = target.y + target.tx * kart.aiLane * target.w * 0.8;
|
|
|
|
let want = Math.atan2(ty - kart.y, tx - kart.x);
|
|
if (kart.reverseMs > 0) {
|
|
// Backing out of a stuck spot: reverse while steering toward the target.
|
|
inputs.brake = true;
|
|
inputs.steer = Math.max(-1, Math.min(1, -normAngle(want - kart.heading)));
|
|
return inputs;
|
|
}
|
|
|
|
// Steering noise shrinks with skill.
|
|
want += (state.rng() * 2 - 1) * 0.06 * (1 - skill);
|
|
inputs.steer = Math.max(-1, Math.min(1, physics.aiSteerGain * normAngle(want - kart.heading)));
|
|
|
|
// Brake when the curvature over the lookahead window outruns our speed.
|
|
let curv = 0;
|
|
for (let i = 0; i < lookahead; i += 1) {
|
|
const a = model.samples[(idx + i) % model.samples.length];
|
|
const b = model.samples[(idx + i + 1) % model.samples.length];
|
|
curv += Math.abs(normAngle(Math.atan2(b.ty, b.tx) - Math.atan2(a.ty, a.tx)));
|
|
}
|
|
const curvPerUnit = curv / Math.max(1, lookahead * model.step);
|
|
const limit = physics.aiBrakeCurvature * (0.8 + 0.6 * skill);
|
|
inputs.accel = true;
|
|
if (curvPerUnit * kart.speed > limit) {
|
|
inputs.accel = false;
|
|
if (curvPerUnit * kart.speed > limit * 1.6) inputs.brake = true;
|
|
}
|
|
|
|
// Item use.
|
|
if (kart.item && kart.rouletteMs <= 0) {
|
|
kart.aiItemDelay -= STEP_MS;
|
|
if (kart.aiItemDelay <= 0) {
|
|
inputs.item = aiWantsItem(state, kart);
|
|
if (inputs.item) kart.aiItemDelay = 600 + state.rng() * 1400;
|
|
}
|
|
}
|
|
return inputs;
|
|
}
|
|
|
|
function aiWantsItem(state, kart) {
|
|
const straight = isOnStraight(state, kart);
|
|
switch (kart.item) {
|
|
case 'bolt':
|
|
case 'seeker': {
|
|
const target = nextKartAhead(state, kart);
|
|
if (!target) return kart.item === 'bolt' && state.rng() < 0.002;
|
|
const dist = Math.hypot(target.x - kart.x, target.y - kart.y);
|
|
return dist < 700;
|
|
}
|
|
case 'oil': return straight && state.rng() < 0.02;
|
|
case 'turbo': return straight && kart.boostMs <= 0;
|
|
case 'overdrive': return true;
|
|
case 'emp': return kart.position > 3;
|
|
case 'coins': return true;
|
|
default: return false;
|
|
}
|
|
}
|
|
|
|
function isOnStraight(state, kart) {
|
|
const { model } = state;
|
|
const idx = Math.round(kart.splineS / model.step) % model.samples.length;
|
|
let curv = 0;
|
|
for (let i = 0; i < 6; i += 1) {
|
|
const a = model.samples[(idx + i) % model.samples.length];
|
|
const b = model.samples[(idx + i + 1) % model.samples.length];
|
|
curv += Math.abs(normAngle(Math.atan2(b.ty, b.tx) - Math.atan2(a.ty, a.tx)));
|
|
}
|
|
return curv < 0.25;
|
|
}
|
|
|
|
function nextKartAhead(state, kart) {
|
|
let best = null;
|
|
for (const other of state.karts) {
|
|
if (other === kart || other.finished) continue;
|
|
if (other.progress <= kart.progress) continue;
|
|
if (!best || other.progress < best.progress) best = other;
|
|
}
|
|
return best;
|
|
}
|
|
|
|
// ── Per-kart physics step ───────────────────────────────────────────────────
|
|
|
|
function stepKart(state, kart, inputs, dt) {
|
|
const { physics, model } = state;
|
|
|
|
for (const key of ['spinMs', 'squashMs', 'invulnMs', 'boostMs', 'starMs', 'empMs', 'hopCooldown', 'wallHitCooldown', 'cpMissMs']) {
|
|
if (kart[key] > 0) kart[key] = Math.max(0, kart[key] - STEP_MS);
|
|
}
|
|
|
|
if (kart.rescueMs > 0) {
|
|
kart.rescueMs -= STEP_MS;
|
|
if (kart.rescueMs <= 0) respawnAtCheckpoint(state, kart);
|
|
return;
|
|
}
|
|
|
|
const locked = state.phase === 'countdown' || kart.spinMs > 0;
|
|
const steer = locked ? 0 : Math.max(-1, Math.min(1, inputs.steer || 0));
|
|
const accel = locked ? false : !!inputs.accel;
|
|
const brake = locked ? false : !!inputs.brake;
|
|
|
|
// Hop / powerslide.
|
|
if (!locked && inputs.hop && !kart.prevHop && kart.hopMs <= 0 && kart.hopCooldown <= 0 && !kart.airborne) {
|
|
kart.hopMs = physics.hopMs;
|
|
kart.airborne = true;
|
|
kart.hopCooldown = physics.hopMs + physics.hopCooldownMs;
|
|
if (kart.drifting) kart.drifting = false;
|
|
emit(state, 'hop', { kart: kart.index });
|
|
}
|
|
kart.prevHop = !!inputs.hop;
|
|
if (kart.airborne) {
|
|
kart.hopMs -= STEP_MS;
|
|
if (kart.hopMs <= 0) {
|
|
kart.airborne = false;
|
|
// Landing with hop held and a direction committed starts the powerslide.
|
|
if (inputs.hop && steer !== 0 && kart.speed > effTopSpeed(state, kart) * 0.3) {
|
|
kart.drifting = true;
|
|
kart.driftDir = Math.sign(steer);
|
|
kart.driftChargeMs = 0;
|
|
emit(state, 'drift-start', { kart: kart.index });
|
|
}
|
|
}
|
|
}
|
|
if (kart.drifting) {
|
|
kart.driftChargeMs += STEP_MS;
|
|
const top = effTopSpeed(state, kart);
|
|
const speedDropped = kart.speed < top * 0.25;
|
|
const steerReversed = steer !== 0 && Math.sign(steer) !== kart.driftDir;
|
|
if (!inputs.hop || speedDropped || steerReversed) {
|
|
kart.drifting = false;
|
|
// Mini-turbo: only a clean release (hop let go, not a stall/reversal
|
|
// cutting the slide short) with enough committed charge pays out —
|
|
// stronger the longer the slide, capped at miniTurboMaxMs.
|
|
if (!speedDropped && !steerReversed && kart.driftChargeMs >= physics.miniTurboMinMs) {
|
|
const span = Math.max(1, physics.miniTurboMaxMs - physics.miniTurboMinMs);
|
|
const t = Math.min(1, (kart.driftChargeMs - physics.miniTurboMinMs) / span);
|
|
kart.boostMs = physics.miniTurboBoostMs;
|
|
kart.boostMult = physics.miniTurboBoostMultMin
|
|
+ (physics.miniTurboBoostMultMax - physics.miniTurboBoostMultMin) * t;
|
|
emit(state, 'boost', { kart: kart.index, pad: false });
|
|
}
|
|
kart.driftChargeMs = 0;
|
|
emit(state, 'drift-end', { kart: kart.index });
|
|
}
|
|
}
|
|
|
|
// Longitudinal speed.
|
|
const top = effTopSpeed(state, kart);
|
|
if (kart.spinMs > 0) {
|
|
kart.speed = Math.max(0, kart.speed - physics.brakeDecel * 0.8 * dt);
|
|
kart.spinSpin += dt * 9;
|
|
} else if (accel && !brake) {
|
|
kart.speed += accelOf(kart.racer.stats, physics) * classSpeedMult(state) * dt;
|
|
} else if (brake) {
|
|
kart.speed -= physics.brakeDecel * dt;
|
|
if (kart.speed < 0) kart.speed = Math.max(kart.speed, -physics.reverseTopSpeed);
|
|
} else {
|
|
kart.speed -= kart.speed * physics.coastDrag * dt;
|
|
}
|
|
if (kart.speed > top) {
|
|
// Ease down toward the cap (boost expiry / entering grass) instead of snapping.
|
|
kart.speed = Math.max(top, kart.speed - physics.brakeDecel * 1.5 * dt);
|
|
}
|
|
if (kart.speed < -physics.reverseTopSpeed) kart.speed = -physics.reverseTopSpeed;
|
|
|
|
// Steering. Drifting multiplies the turn rate in the committed direction.
|
|
if (kart.spinMs <= 0 && Math.abs(kart.speed) > 1) {
|
|
const baseTop = topSpeedOf(kart.racer.stats, physics, classSpeedMult(state));
|
|
let rate = turnRateOf(kart.racer.stats, physics, Math.abs(kart.speed) / baseTop);
|
|
if (kart.drifting && Math.sign(steer || kart.driftDir) === kart.driftDir) {
|
|
rate *= physics.driftTurnMult;
|
|
}
|
|
const dir = kart.speed >= 0 ? 1 : -1;
|
|
kart.heading = normAngle(kart.heading + steer * rate * dir * dt);
|
|
}
|
|
|
|
// Velocity direction chases the heading; grip is what separates a clean
|
|
// line from a powerslide. High drift stat = tighter, more controlled slides.
|
|
const grip = kart.drifting
|
|
? 2.2 + 3.2 * kart.racer.stats.drift
|
|
: physics.slipDecay + 5.5;
|
|
kart.velAngle = normAngle(kart.velAngle + normAngle(kart.heading - kart.velAngle) * Math.min(1, grip * dt));
|
|
|
|
// Integrate + wall handling (axis-separated slide).
|
|
const vx = Math.cos(kart.velAngle) * kart.speed * dt;
|
|
const vy = Math.sin(kart.velAngle) * kart.speed * dt;
|
|
const blocked = (x, y) => {
|
|
const c = surfaceAt(model, x, y);
|
|
return c === SURFACE.WALL || c === SURFACE.OOB;
|
|
};
|
|
if (!blocked(kart.x + vx, kart.y + vy)) {
|
|
kart.x += vx;
|
|
kart.y += vy;
|
|
} else if (!blocked(kart.x + vx, kart.y)) {
|
|
kart.x += vx;
|
|
hitWall(state, kart);
|
|
} else if (!blocked(kart.x, kart.y + vy)) {
|
|
kart.y += vy;
|
|
hitWall(state, kart);
|
|
} else {
|
|
hitWall(state, kart);
|
|
kart.speed = 0;
|
|
}
|
|
|
|
// Surface effects.
|
|
kart.surface = surfaceAt(model, kart.x, kart.y);
|
|
if (!kart.airborne) {
|
|
if (kart.surface === SURFACE.BOOST && kart.boostMs <= 0) {
|
|
kart.boostMs = physics.boostPadMs;
|
|
kart.boostMult = physics.boostPadMult;
|
|
emit(state, 'boost', { kart: kart.index, pad: true });
|
|
}
|
|
if (kart.surface === SURFACE.WATER) {
|
|
if (kart.rescueMs <= 0) {
|
|
kart.rescueMs = physics.respawnMs;
|
|
emit(state, 'splash', { kart: kart.index });
|
|
}
|
|
} else if (kart.surface === SURFACE.DEEP) {
|
|
kart.deepMs += STEP_MS;
|
|
if (kart.deepMs > 700 && kart.rescueMs <= 0) {
|
|
kart.deepMs = 0;
|
|
kart.rescueMs = physics.respawnMs;
|
|
emit(state, 'sink', { kart: kart.index });
|
|
}
|
|
} else {
|
|
kart.deepMs = 0;
|
|
}
|
|
if (kart.surface === SURFACE.OFFROAD) kart.offroadMs += STEP_MS;
|
|
else kart.offroadMs = 0;
|
|
if (kart.offroadMs > physics.offroadRescueMs) {
|
|
kart.offroadMs = 0;
|
|
kart.rescueMs = physics.respawnMs;
|
|
emit(state, 'rescue', { kart: kart.index });
|
|
}
|
|
}
|
|
|
|
// Stuck detection feeds the AI reverse-out behavior.
|
|
if (!kart.isPlayer) {
|
|
if (Math.abs(kart.speed) < 25 && state.phase === 'racing' && kart.spinMs <= 0 && !kart.finished) {
|
|
kart.stuckMs += STEP_MS;
|
|
} else kart.stuckMs = 0;
|
|
if (kart.reverseMs > 0) kart.reverseMs -= STEP_MS;
|
|
if (kart.stuckMs > physics.aiStuckMs && kart.reverseMs <= 0) {
|
|
kart.stuckMs = 0;
|
|
kart.reverseMs = physics.aiReverseMs;
|
|
kart.reverseCount = (kart.reverseCount ?? 0) + 1;
|
|
if (kart.reverseCount % 3 === 0) {
|
|
// Three failed reverse-outs: give up and take the rescue.
|
|
kart.rescueMs = physics.respawnMs;
|
|
emit(state, 'rescue', { kart: kart.index });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Lap / checkpoint / progress tracking (windowed projection keeps pinched
|
|
// track sections from flipping a kart onto the wrong branch).
|
|
const proj = projectToSplineNear(model, kart.x, kart.y, kart.splineS);
|
|
kart.splineS = proj.s;
|
|
const L = model.totalLength;
|
|
let sRel = ((proj.s - model.startS) % L + L) % L;
|
|
kart.sRel = sRel;
|
|
const cps = model.checkpoints.length;
|
|
const cpSize = L / cps;
|
|
const cp = Math.floor(sRel / cpSize) % cps;
|
|
// How far ahead of the last validated gate we are, going forward round the
|
|
// ring. 0 = same gate, 1 = the normal advance. A big forward jump means the
|
|
// kart shortcut across geometry and the spline projection snapped branches;
|
|
// anything past the halfway mark is the kart *behind* its own cpIndex
|
|
// (driving backwards, or sitting on the pre-start grid) and is left alone.
|
|
const delta = (cp - kart.cpIndex + cps) % cps;
|
|
if (delta >= physics.checkpointMissSkip && delta < cps / 2) {
|
|
// Missed checkpoint: stop the player dead and put them back on the last
|
|
// gate they legitimately crossed — i.e. just behind the one they skipped —
|
|
// instead of leaving the lap chain silently stalled until they guess which
|
|
// gate it was and drive backwards to it. cpIndex is deliberately untouched,
|
|
// so the next step reads delta 0 and this can't retrigger in a loop; a miss
|
|
// straddling the start line still owes the player the line crossing.
|
|
if (kart.isPlayer && state.phase === 'racing' && !kart.finished
|
|
&& kart.cpMissMs <= 0 && kart.rescueMs <= 0) {
|
|
const missed = (kart.cpIndex + 1) % cps;
|
|
respawnAtCheckpoint(state, kart);
|
|
kart.cpMissMs = physics.checkpointMissMs;
|
|
// Re-derive arc-length from the teleported anchor so this frame's
|
|
// progress reflects where the kart actually is, not the skipped-to spot.
|
|
sRel = ((kart.splineS - model.startS) % L + L) % L;
|
|
kart.sRel = sRel;
|
|
emit(state, 'checkpoint-miss', { kart: kart.index, checkpoint: missed });
|
|
}
|
|
} else if (delta === 1) {
|
|
kart.cpIndex = cp;
|
|
if (cp === 0) {
|
|
kart.lap += 1;
|
|
const lapMs = state.timeMs - kart.lapStartMs;
|
|
if (kart.bestLapMs === 0 || lapMs < kart.bestLapMs) kart.bestLapMs = lapMs;
|
|
kart.lapStartMs = state.timeMs;
|
|
if (kart.lap > state.laps && !kart.finished) {
|
|
kart.finished = true;
|
|
kart.finishTimeMs = state.timeMs;
|
|
state.finishOrder.push(kart.index);
|
|
emit(state, 'finish', { kart: kart.index, place: state.finishOrder.length });
|
|
} else if (!kart.finished) {
|
|
emit(state, 'lap', { kart: kart.index, lap: kart.lap });
|
|
}
|
|
}
|
|
}
|
|
const within = Math.max(0, Math.min(sRel - kart.cpIndex * cpSize, 2 * cpSize));
|
|
kart.progress = (kart.lap - 1) * L + kart.cpIndex * cpSize + within;
|
|
|
|
// Item pickup + roulette.
|
|
if (kart.rouletteMs > 0) {
|
|
kart.rouletteMs -= STEP_MS;
|
|
if (kart.rouletteMs <= 0) {
|
|
kart.item = rollItem(state.rng, kart.position, state.rules);
|
|
emit(state, 'item-get', { kart: kart.index, item: kart.item });
|
|
}
|
|
} else if (!kart.item && state.mode !== 'tt') {
|
|
for (const box of state.itemBoxes) {
|
|
if (box.respawnAt > state.timeMs) continue;
|
|
const dx = box.x - kart.x;
|
|
const dy = box.y - kart.y;
|
|
const r = physics.itemBoxRadius + kart.radius;
|
|
if (dx * dx + dy * dy < r * r) {
|
|
box.respawnAt = state.timeMs + physics.itemBoxRespawnMs;
|
|
kart.rouletteMs = physics.rouletteMs;
|
|
emit(state, 'item-box', { kart: kart.index });
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Coins.
|
|
for (const coin of state.coins) {
|
|
if (coin.taken || kart.coins >= physics.maxCoins) continue;
|
|
const dx = coin.x - kart.x;
|
|
const dy = coin.y - kart.y;
|
|
const r = physics.coinPickupRadius;
|
|
if (dx * dx + dy * dy < r * r) {
|
|
coin.taken = true;
|
|
kart.coins += 1;
|
|
emit(state, 'coin', { kart: kart.index });
|
|
}
|
|
}
|
|
|
|
// Dropped hazards (oil slicks).
|
|
for (const hz of state.hazardsDropped) {
|
|
if (hz.dead) continue;
|
|
if (hz.owner === kart.index && hz.armMs > 0) continue; // just dropped it
|
|
const dx = hz.x - kart.x;
|
|
const dy = hz.y - kart.y;
|
|
const r = hz.radius + kart.radius;
|
|
if (dx * dx + dy * dy < r * r && startSpin(state, kart, 'oil')) hz.dead = true;
|
|
}
|
|
|
|
// Item use (edge-triggered).
|
|
if (inputs.item && !kart.prevItem && kart.item && kart.rouletteMs <= 0 && !locked) {
|
|
useItem(state, kart);
|
|
}
|
|
kart.prevItem = !!inputs.item;
|
|
}
|
|
|
|
function hitWall(state, kart) {
|
|
if (kart.wallHitCooldown > 0) return;
|
|
kart.wallHitCooldown = 250;
|
|
kart.speed *= 1 - state.physics.wallBumpSpeedLoss;
|
|
emit(state, 'wallhit', { kart: kart.index });
|
|
}
|
|
|
|
// ── Items ───────────────────────────────────────────────────────────────────
|
|
|
|
function useItem(state, kart) {
|
|
const id = kart.item;
|
|
const params = itemParams(state, id);
|
|
kart.item = null;
|
|
emit(state, 'item-use', { kart: kart.index, item: id });
|
|
switch (id) {
|
|
case 'bolt':
|
|
case 'seeker': {
|
|
const target = id === 'seeker' ? nextKartAhead(state, kart) : null;
|
|
state.projectiles.push({
|
|
id,
|
|
owner: kart.index,
|
|
x: kart.x + Math.cos(kart.heading) * (kart.radius + 12),
|
|
y: kart.y + Math.sin(kart.heading) * (kart.radius + 12),
|
|
heading: kart.heading,
|
|
speed: params.speed ?? 850,
|
|
bounces: params.bounces ?? 0,
|
|
homing: !!params.homing,
|
|
target: target ? target.index : -1,
|
|
lifeMs: params.lifeMs ?? 7000,
|
|
radius: params.radius ?? 10,
|
|
graceMs: 350, // can't hit its own thrower immediately
|
|
dead: false,
|
|
});
|
|
break;
|
|
}
|
|
case 'oil':
|
|
state.hazardsDropped.push({
|
|
id: 'oil',
|
|
owner: kart.index,
|
|
x: kart.x - Math.cos(kart.heading) * (kart.radius + 16),
|
|
y: kart.y - Math.sin(kart.heading) * (kart.radius + 16),
|
|
radius: params.radius ?? 18,
|
|
armMs: 600,
|
|
dead: false,
|
|
});
|
|
break;
|
|
case 'turbo':
|
|
kart.boostMs = params.ms ?? 1400;
|
|
kart.boostMult = params.mult ?? 1.5;
|
|
emit(state, 'boost', { kart: kart.index, pad: false });
|
|
break;
|
|
case 'overdrive':
|
|
kart.starMs = params.ms ?? 7000;
|
|
emit(state, 'star', { kart: kart.index });
|
|
break;
|
|
case 'emp': {
|
|
const targets = [];
|
|
for (const other of state.karts) {
|
|
if (other === kart || other.finished) continue;
|
|
if (other.starMs > 0 || other.invulnMs > 0) continue;
|
|
other.empMs = (params.ms ?? 4200) + other.racer.stats.weight * (params.weightExtraMs ?? 1600);
|
|
other.drifting = false;
|
|
targets.push(other.index);
|
|
}
|
|
emit(state, 'emp', { kart: kart.index, targets });
|
|
break;
|
|
}
|
|
case 'coins':
|
|
kart.coins = Math.min(state.physics.maxCoins, kart.coins + (params.coins ?? 2));
|
|
emit(state, 'coin', { kart: kart.index, pack: true });
|
|
break;
|
|
default: break;
|
|
}
|
|
}
|
|
|
|
function stepProjectiles(state, dt) {
|
|
const { model } = state;
|
|
for (const p of state.projectiles) {
|
|
if (p.dead) continue;
|
|
p.lifeMs -= STEP_MS;
|
|
if (p.graceMs > 0) p.graceMs -= STEP_MS;
|
|
if (p.lifeMs <= 0) { p.dead = true; continue; }
|
|
|
|
if (p.homing && p.target >= 0) {
|
|
const t = state.karts[p.target];
|
|
if (t && !t.finished) {
|
|
const want = Math.atan2(t.y - p.y, t.x - p.x);
|
|
const rate = itemParams(state, 'seeker').homingTurnRate ?? 3.2;
|
|
const diff = normAngle(want - p.heading);
|
|
p.heading = normAngle(p.heading + Math.max(-rate * dt, Math.min(rate * dt, diff)));
|
|
}
|
|
}
|
|
const nx = p.x + Math.cos(p.heading) * p.speed * dt;
|
|
const ny = p.y + Math.sin(p.heading) * p.speed * dt;
|
|
const code = surfaceAt(model, nx, ny);
|
|
if (code === SURFACE.WALL || code === SURFACE.OOB) {
|
|
if (p.bounces > 0) {
|
|
p.bounces -= 1;
|
|
// Reflect off whichever axis is blocked (cheap but effective on the grid).
|
|
const xBlocked = [SURFACE.WALL, SURFACE.OOB].includes(surfaceAt(model, nx, p.y));
|
|
const yBlocked = [SURFACE.WALL, SURFACE.OOB].includes(surfaceAt(model, p.x, ny));
|
|
if (xBlocked) p.heading = normAngle(Math.PI - p.heading);
|
|
if (yBlocked || (!xBlocked && !yBlocked)) p.heading = normAngle(-p.heading);
|
|
emit(state, 'bounce', { x: p.x, y: p.y });
|
|
} else {
|
|
p.dead = true;
|
|
emit(state, 'shatter', { x: p.x, y: p.y });
|
|
continue;
|
|
}
|
|
} else {
|
|
p.x = nx;
|
|
p.y = ny;
|
|
}
|
|
|
|
for (const kart of state.karts) {
|
|
if (kart.finished) continue;
|
|
if (p.graceMs > 0 && kart.index === p.owner) continue;
|
|
const dx = kart.x - p.x;
|
|
const dy = kart.y - p.y;
|
|
const r = kart.radius + p.radius;
|
|
if (dx * dx + dy * dy < r * r) {
|
|
p.dead = true;
|
|
if (kart.starMs > 0 || kart.invulnMs > 0) emit(state, 'shatter', { x: p.x, y: p.y });
|
|
else startSpin(state, kart, p.id);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (state.tick % 60 === 0) {
|
|
state.projectiles = state.projectiles.filter((p) => !p.dead);
|
|
state.hazardsDropped = state.hazardsDropped.filter((h) => !h.dead);
|
|
}
|
|
for (const hz of state.hazardsDropped) if (hz.armMs > 0) hz.armMs -= STEP_MS;
|
|
}
|
|
|
|
// Kart-vs-kart bumping: separation shared by inverse weight, plus a shove.
|
|
function stepCollisions(state) {
|
|
const { karts, physics } = state;
|
|
for (let i = 0; i < karts.length; i += 1) {
|
|
for (let j = i + 1; j < karts.length; j += 1) {
|
|
const a = karts[i];
|
|
const b = karts[j];
|
|
if (a.finished || b.finished || a.rescueMs > 0 || b.rescueMs > 0) continue;
|
|
const dx = b.x - a.x;
|
|
const dy = b.y - a.y;
|
|
const minDist = a.radius + b.radius;
|
|
const d2 = dx * dx + dy * dy;
|
|
if (d2 >= minDist * minDist || d2 === 0) continue;
|
|
const d = Math.sqrt(d2);
|
|
const nx = dx / d;
|
|
const ny = dy / d;
|
|
const overlap = minDist - d;
|
|
const wa = 0.2 + a.racer.stats.weight;
|
|
const wb = 0.2 + b.racer.stats.weight;
|
|
const shareA = wb / (wa + wb);
|
|
const shareB = wa / (wa + wb);
|
|
a.x -= nx * overlap * shareA;
|
|
a.y -= ny * overlap * shareA;
|
|
b.x += nx * overlap * shareB;
|
|
b.y += ny * overlap * shareB;
|
|
// The lighter kart's velocity direction gets knocked away from the
|
|
// contact normal (a → b); the heavier one barely notices.
|
|
const kick = physics.bumpImpulse / 600;
|
|
const away = (kart, sign) => {
|
|
const cross = Math.cos(kart.velAngle) * ny * sign - Math.sin(kart.velAngle) * nx * sign;
|
|
return Math.sign(cross || 1);
|
|
};
|
|
a.velAngle = normAngle(a.velAngle + away(a, -1) * kick * shareA);
|
|
b.velAngle = normAngle(b.velAngle + away(b, 1) * kick * shareB);
|
|
// Star karts flatten whoever they touch.
|
|
if (a.starMs > 0 && b.starMs <= 0) startSpin(state, b, 'star');
|
|
else if (b.starMs > 0 && a.starMs <= 0) startSpin(state, a, 'star');
|
|
// A heavy kart running over an EMP-shrunk one squashes it.
|
|
if (a.empMs > 0 && b.empMs <= 0 && b.squashMs <= 0 && a.squashMs <= 0) {
|
|
a.squashMs = physics.squashMs;
|
|
emit(state, 'squash', { kart: a.index });
|
|
} else if (b.empMs > 0 && a.empMs <= 0 && a.squashMs <= 0 && b.squashMs <= 0) {
|
|
b.squashMs = physics.squashMs;
|
|
emit(state, 'squash', { kart: b.index });
|
|
}
|
|
emit(state, 'bump', { a: a.index, b: b.index });
|
|
}
|
|
}
|
|
}
|
|
|
|
function updatePositions(state) {
|
|
const order = [...state.karts].sort((a, b) => {
|
|
const af = a.finished ? state.finishOrder.indexOf(a.index) : Infinity;
|
|
const bf = b.finished ? state.finishOrder.indexOf(b.index) : Infinity;
|
|
if (af !== bf) return af - bf;
|
|
return b.progress - a.progress;
|
|
});
|
|
const prevPlayerPos = state.karts[state.playerIndex]?.position;
|
|
order.forEach((kart, i) => { kart.position = i + 1; });
|
|
const nowPlayerPos = state.karts[state.playerIndex]?.position;
|
|
if (prevPlayerPos !== undefined && nowPlayerPos !== prevPlayerPos && state.phase === 'racing') {
|
|
emit(state, 'player-position', { from: prevPlayerPos, to: nowPlayerPos });
|
|
}
|
|
}
|
|
|
|
// ── Public step ─────────────────────────────────────────────────────────────
|
|
|
|
export function step(state, playerInputs = kartInputsNeutral()) {
|
|
state.events = [];
|
|
const dt = STEP_MS / 1000;
|
|
state.tick += 1;
|
|
state.timeMs += STEP_MS;
|
|
|
|
if (state.phase === 'countdown') {
|
|
state.countdownMs -= STEP_MS;
|
|
if (state.countdownMs <= 0) {
|
|
state.phase = 'racing';
|
|
for (const k of state.karts) k.lapStartMs = state.timeMs;
|
|
emit(state, 'go', {});
|
|
}
|
|
}
|
|
|
|
for (const kart of state.karts) {
|
|
// Fresh racing line each lap keeps the AI field from single-filing.
|
|
// (Also applies once the player's kart is finished and autopiloted, so
|
|
// it doesn't robotically hug the centerline during the victory lap.)
|
|
if ((!kart.isPlayer || kart.finished) && kart.lap !== kart.aiLaneLap) {
|
|
kart.aiLaneLap = kart.lap;
|
|
kart.aiLane = (state.rng() * 2 - 1) * 0.5;
|
|
}
|
|
// A finished player kart is handed off to the AI driver for the post-race
|
|
// vignette — computeAiInputs already works for any kart unmodified.
|
|
const inputs = (kart.isPlayer && !kart.finished) ? playerInputs : computeAiInputs(state, kart);
|
|
stepKart(state, kart, inputs, dt);
|
|
}
|
|
stepCollisions(state);
|
|
stepProjectiles(state, dt);
|
|
updatePositions(state);
|
|
|
|
if (state.phase === 'racing') {
|
|
const player = state.karts[state.playerIndex];
|
|
if (state.mode === 'tt') {
|
|
if (player?.finished) state.phase = 'finished';
|
|
} else if (state.karts.every((k) => k.finished)) {
|
|
state.phase = 'finished';
|
|
} else if (player?.finished) {
|
|
// Player done: wait for the rest of the field to finish too (the
|
|
// victory-lap vignette plays during this window), with a generous
|
|
// safety cap in case an AI kart never gets home.
|
|
state.postFinishMs = (state.postFinishMs ?? 0) + STEP_MS;
|
|
if (state.postFinishMs > POST_FINISH_SAFETY_MS) state.phase = 'finished';
|
|
}
|
|
}
|
|
return state;
|
|
}
|
|
|
|
// Final standings. Unfinished karts are ranked by progress and given
|
|
// extrapolated finish times so the results table always fills in.
|
|
export function finalizeRace(state) {
|
|
const ranked = [...state.karts].sort((a, b) => {
|
|
const af = a.finished ? state.finishOrder.indexOf(a.index) : Infinity;
|
|
const bf = b.finished ? state.finishOrder.indexOf(b.index) : Infinity;
|
|
if (af !== bf) return af - bf;
|
|
return b.progress - a.progress;
|
|
});
|
|
return ranked.map((kart, i) => ({
|
|
kartIndex: kart.index,
|
|
racerId: kart.racer.id,
|
|
position: i + 1,
|
|
finished: kart.finished,
|
|
timeMs: kart.finished ? kart.finishTimeMs : Math.round(state.timeMs + (i * 800)),
|
|
bestLapMs: kart.bestLapMs,
|
|
points: pointsFor(i + 1, state.rules),
|
|
}));
|
|
}
|