// ═══════════════════════════════════════════════════════
// GAME — entry point: scene, camera, renderer, input, game loop
// ═══════════════════════════════════════════════════════
import * as THREE from 'three';
import { MAX_SPEED, ACCEL, BRAKE, DRAG, TURN_RATE, GRIP_TRACK, GRIP_GRASS, TRACK_WIDTH } from './config.js';
import { frame, nearestTrackT, trackLen } from './track.js';
import { createSky, createLights, createGround } from './environment.js';
import { createRoadSurface, createDirtStrips, createCurbs, createCenterDashes, createStartFinish, createSectorMarkers, createBarriers } from './track-meshes.js';
import { placeScenery } from './scenery.js';
import { createCar } from './cars.js';
import { createAICars, updateAI } from './ai.js';
import { createSmokeSystem, createDustSystem, createSpeedLineSystem } from './particles.js';
import { createTireMarkSystem } from './tire-marks.js';
import { createMinimap } from './minimap.js';
import { createHUD } from './hud.js';
import { TelemetrySession, scheduleSave, loadBestLap, saveBestLap, loadGhost, saveGhost } from './telemetry.js';
import { createSoundEngine } from './sound.js';
import { createMusic } from './music.js';
import { createAISound } from './ai-sound.js';
// ══ GLOBALS ══
const G = {
player: { x: 0, z: 0, heading: 0, velHeading: 0, speed: 0, lap: 0, onTrack: true },
keys: {},
lapMarker: 0,
};
window.G = G;
window.keys = G.keys;
window.trackCurve = (await import('./track.js')).trackCurve;
window.trackLen = trackLen;
window.frameFn = frame;
window.nearestTrackT = nearestTrackT;
// ══ RENDERER & SCENE ══
const canvas = document.getElementById('game');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.3;
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0xc8ddf0, 0.0022);
const camera = new THREE.PerspectiveCamera(65, window.innerWidth / window.innerHeight, 0.5, 600);
// ══ BUILD WORLD ══
createSky(scene);
const { sun } = createLights(scene);
createGround(scene, renderer);
createRoadSurface(scene, renderer);
createDirtStrips(scene, renderer);
createCurbs(scene);
createCenterDashes(scene);
createStartFinish(scene);
createSectorMarkers(scene);
createBarriers(scene);
placeScenery(scene);
const playerCar = createCar(0xff2200);
scene.add(playerCar);
const aiCars = createAICars(scene);
window.aiCars = aiCars;
window.scene = scene;
window.__playerFinished = () => playerFinished;
window.__raceResults = () => raceResults;
window.__raceEndTimer = () => raceEndTimer;
window.__raceState = () => raceState;
window.__resultsShown = () => resultsShown;
const smoke = createSmokeSystem(scene);
const dust = createDustSystem(scene);
const speedLines = createSpeedLineSystem(scene);
const tireMarks = createTireMarkSystem(scene);
const minimap = createMinimap(aiCars);
minimap.setFrame(frame);
const hud = createHUD();
const telemetry = new TelemetrySession();
let sound = null;
let music = null;
let aiSound = null;
// ══ BEST LAP & GHOST CAR ══
let bestLapRecord = loadBestLap(); // { time, lapNumber, ... } or null
let bestLapTime = bestLapRecord ? bestLapRecord.time : null;
let currentLapStart = 0; // elapsed time when current lap started
let currentLapTime = 0; // elapsed - currentLapStart
let lastLapTime = null; // most recently completed lap
let currentLapSamples = []; // [{t, x, z, heading}, ...] for potential ghost save
let ghostData = loadGhost(); // saved best-lap replay
let ghostCar = null; // THREE.Group for ghost visualization
let ghostEnabled = true; // user toggle (G key)
// ══ NITRO ══
let nitroCharge = 1.0; // 0..1
let nitroActive = false;
const NITRO_DURATION = 2.5; // seconds of boost
const NITRO_RECHARGE = 12; // seconds to full recharge
const NITRO_MULT = 1.35; // top-speed multiplier while active
const NITRO_ACCEL_MULT = 1.6; // acceleration multiplier
let nitroTimer = 0; // remaining boost time
// ══ CAMERA MODES ══
const CAM_THIRD = 0;
const CAM_COCKPIT = 1;
const CAM_TOP = 2;
const CAM_NAMES = ['THIRD-PERSON', 'COCKPIT', 'TOP-DOWN'];
let cameraMode = CAM_THIRD;
// ══ PAUSE ══
let paused = false;
// ══ TOUCH CONTROLS (mobile) ══
const touchState = { accel: false, brake: false, left: false, right: false, drift: false, nitro: false };
// ══ GHOST CAR (translucent replay of best lap) ══
function createGhostCar() {
const g = new THREE.Group();
const mat = new THREE.MeshBasicMaterial({ color: 0x00eaff, transparent: true, opacity: 0.35 });
const body = new THREE.Mesh(new THREE.BoxGeometry(2, 0.7, 4), mat);
body.position.y = 0.6;
g.add(body);
const cabin = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.5, 2), mat);
cabin.position.set(0, 1.15, -0.3);
g.add(cabin);
g.visible = false;
return g;
}
ghostCar = createGhostCar();
scene.add(ghostCar);
// ══ INPUT ══
window.addEventListener('keydown', (e) => {
// Start audio on first interaction
if (!sound) {
sound = createSoundEngine();
aiSound = createAISound(sound.ctx, sound.master, sound.noiseBuf);
for (const ai of aiCars) ai.soundIdx = aiSound.addCar();
music = createMusic(); music.start();
}
// Pause/resume (Escape or P) — don't record as game input
if (e.code === 'Escape' || e.code === 'KeyP') {
if (raceState === 'racing' || raceState === 'countdown' || paused) {
togglePause();
e.preventDefault();
return;
}
}
// Fullscreen toggle (F) — don't record as game input
if (e.code === 'KeyF') {
toggleFullscreen();
e.preventDefault();
return;
}
// Camera mode (C)
if (e.code === 'KeyC') {
cameraMode = (cameraMode + 1) % 3;
showToast(`CAMERA: ${CAM_NAMES[cameraMode]}`);
e.preventDefault();
return;
}
// Ghost toggle (G)
if (e.code === 'KeyG') {
ghostEnabled = !ghostEnabled;
ghostCar.visible = ghostEnabled && ghostData !== null && raceState === 'racing';
showToast(`GHOST: ${ghostEnabled ? 'ON' : 'OFF'}`);
e.preventDefault();
return;
}
G.keys[e.code] = true;
});
window.addEventListener('keyup', (e) => { G.keys[e.code] = false; });
// ══ RACE STATE ══
const TOTAL_LAPS = 3;
let raceState = 'attract'; // attract → grid → countdown → racing → finished
let countdownStartTime = 0;
let raceStartTime = 0;
let raceResults = [];
let playerFinished = false;
let playerFinishTime = 0;
let playerHasPassedHalf = false;
let raceEndTimer = -1;
let resultsShown = false;
let gridTimer = 1.5;
let attractAIIdx = 0; // which AI car to follow in attract mode
let attractT = 0; // track position for attract AI
// Hide player car during attract mode
playerCar.visible = false;
// ══ POSITION ALL CARS ON GRID ══
function positionOnGrid() {
const { point: startPt, tangent: startTan, side: startSide } = frame(0);
const startHeading = Math.atan2(startTan.x, startTan.z);
// 2-wide grid: AI in front, player back-left (like real racing)
const gridSlots = [
{ row: 0, col: -1, type: 'ai', idx: 0 }, // BLUE - pole left
{ row: 0, col: 1, type: 'ai', idx: 1 }, // GOLD - pole right
{ row: 1, col: -1, type: 'ai', idx: 2 }, // JADE - 2nd row left
{ row: 1, col: 1, type: 'ai', idx: 3 }, // BLAZE - 2nd row right
{ row: 2, col: -1, type: 'player' }, // YOU - back left
];
const ROW_SPACING = 12 / trackLen; // ~12 world units between rows (track-t units)
const COL_OFFSET = 4;
for (const slot of gridSlots) {
// Place each row at a different track-t so cars follow the curve
const rowT = ((0 - slot.row * ROW_SPACING) % 1 + 1) % 1;
const { point: rowPt, tangent: rowTan, side: rowSide } = frame(rowT);
const rowHeading = Math.atan2(rowTan.x, rowTan.z);
const pos = rowPt.clone().add(rowSide.clone().multiplyScalar(slot.col * COL_OFFSET));
if (slot.type === 'player') {
G.player.x = pos.x;
G.player.z = pos.z;
G.player.heading = rowHeading;
G.player.velHeading = rowHeading;
G.player.lap = 0;
playerCar.position.set(pos.x, 0.05, pos.z);
playerCar.rotation.set(0, rowHeading, 0);
} else {
const ai = aiCars[slot.idx];
ai.x = pos.x;
ai.z = pos.z;
ai.heading = rowHeading;
ai.velHeading = rowHeading;
ai.speed = 0;
ai.impulseX = 0;
ai.impulseZ = 0;
ai.lap = 0;
ai.prevTrackT = 0;
ai.finished = false;
ai.finishTime = 0;
ai.hasPassedHalf = false;
ai.mesh.position.set(pos.x, 0.05, pos.z);
ai.mesh.rotation.set(0, rowHeading, 0);
}
}
}
// Place AI cars around track for attract mode
function positionForAttract() {
for (let i = 0; i < aiCars.length; i++) {
const ai = aiCars[i];
const t = (0.1 + i * 0.22) % 1; // spread around the track
const lateral = (i % 2 === 0 ? 0.3 : -0.3); // alternate sides
const { point, tangent, side } = frame(t);
const heading = Math.atan2(tangent.x, tangent.z);
ai.x = point.x + side.x * lateral * 3;
ai.z = point.z + side.z * lateral * 3;
ai.heading = heading;
ai.velHeading = heading;
ai.speed = 30 + Math.random() * 10; // start with some speed
ai.lap = 0;
ai.prevTrackT = t;
ai._trackT = t;
ai.finished = false;
ai.finishTime = 0;
ai.hasPassedHalf = false;
ai.impulseX = 0;
ai.impulseZ = 0;
ai.mesh.position.set(ai.x, 0.05, ai.z);
ai.mesh.rotation.set(0, heading, 0);
}
}
positionForAttract();
// ══ PAUSE OVERLAY ══
const pauseEl = document.createElement('div');
pauseEl.id = 'pause-overlay';
pauseEl.style.cssText = `
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.72);
display: none; align-items: center; justify-content: center;
z-index: 400; pointer-events: auto;
font-family: 'Orbitron', sans-serif;
backdrop-filter: blur(10px) saturate(120%);
-webkit-backdrop-filter: blur(10px) saturate(120%);
`;
pauseEl.innerHTML = `
PAUSED
WASD/ARROWS · SPACE DRIFT · SHIFT NITRO · P/ESC PAUSE
`;
const pauseStyle = document.createElement('style');
pauseStyle.textContent = `
.pause-btn {
font-family: Orbitron, sans-serif;
font-weight: 900; font-size: 18px;
background: rgba(255,255,255,0.08);
color: white;
border: 2px solid rgba(255,255,255,0.3);
padding: 12px 36px;
letter-spacing: 2px;
cursor: pointer;
min-width: 280px;
text-transform: uppercase;
outline: none;
transition: background 0.15s, border-color 0.15s, transform 0.1s, box-shadow 0.15s;
}
.pause-btn:hover {
background: rgba(255,34,0,0.3);
border-color: #ff4422;
transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(255,34,0,0.25);
}
.pause-btn:active {
background: rgba(255,34,0,0.55);
border-color: #ff6644;
transform: translateY(0);
box-shadow: 0 2px 8px rgba(255,34,0,0.3);
}
.pause-btn:focus-visible {
border-color: #00eaff;
box-shadow: 0 0 0 3px rgba(0,234,255,0.25);
}
`;
document.head.appendChild(pauseStyle);
document.body.appendChild(pauseEl);
// Pause button handlers (added after function definitions)
setTimeout(() => {
document.getElementById('pause-resume').onclick = () => togglePause();
document.getElementById('pause-restart').onclick = () => { togglePause(); restartRace(); };
document.getElementById('pause-camera').onclick = () => {
cameraMode = (cameraMode + 1) % 3;
showToast(`CAMERA: ${CAM_NAMES[cameraMode]}`);
};
document.getElementById('pause-ghost').onclick = () => {
ghostEnabled = !ghostEnabled;
ghostCar.visible = ghostEnabled && ghostData !== null && raceState === 'racing';
showToast(`GHOST: ${ghostEnabled ? 'ON' : 'OFF'}`);
};
document.getElementById('pause-fullscreen').onclick = () => toggleFullscreen();
}, 0);
function togglePause() {
paused = !paused;
pauseEl.style.display = paused ? 'flex' : 'none';
if (paused) {
if (music && music.pause) music.pause();
} else {
if (music && music.resume) music.resume();
clock.getDelta(); // reset delta so dt doesn't spike
}
}
// ══ FULLSCREEN ══
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(() => {});
} else {
document.exitFullscreen().catch(() => {});
}
}
// ══ TOUCH CONTROLS (mobile) ══
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
if (isTouchDevice) {
const touchStyle = document.createElement('style');
touchStyle.textContent = `
.touch-btn {
position: fixed;
font-family: Orbitron, sans-serif;
font-weight: 900;
font-size: 26px;
background: rgba(0,0,0,0.38);
color: #fff;
border: 2px solid rgba(255,255,255,0.45);
border-radius: 50%;
width: 72px; height: 72px;
display: flex; align-items: center; justify-content: center;
z-index: 150;
touch-action: manipulation;
user-select: none; -webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
text-shadow: 0 2px 6px rgba(0,0,0,0.7);
box-shadow: 0 4px 14px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.1);
transition: transform 0.08s ease-out, background 0.08s ease-out, border-color 0.12s ease-out;
}
.touch-btn.wide { width: 96px; border-radius: 14px; font-size: 16px; letter-spacing: 2px; }
.touch-btn:active { background: rgba(255,34,0,0.5); transform: scale(0.94); }
.touch-btn.pressed {
background: rgba(255,34,0,0.65);
border-color: #ffb0a0;
transform: scale(0.92);
box-shadow: 0 0 22px rgba(255,68,34,0.6), inset 0 1px 0 rgba(255,255,255,0.15);
}
`;
document.head.appendChild(touchStyle);
function makeBtn(label, style, key) {
const btn = document.createElement('div');
btn.className = 'touch-btn' + (style.wide ? ' wide' : '');
btn.textContent = label;
Object.assign(btn.style, style.css || {});
btn.addEventListener('touchstart', (e) => { touchState[key] = true; btn.classList.add('pressed'); e.preventDefault(); }, { passive: false });
btn.addEventListener('touchend', (e) => { touchState[key] = false; btn.classList.remove('pressed'); e.preventDefault(); }, { passive: false });
btn.addEventListener('touchcancel',(e) => { touchState[key] = false; btn.classList.remove('pressed'); }, { passive: true });
document.body.appendChild(btn);
return btn;
}
// Left cluster — steering
makeBtn('◀', { css: { left: '24px', bottom: '100px' } }, 'left');
makeBtn('▶', { css: { left: '116px', bottom: '100px' } }, 'right');
// Right cluster — pedals
makeBtn('▲', { css: { right: '24px', bottom: '180px' } }, 'accel');
makeBtn('▼', { css: { right: '24px', bottom: '100px' } }, 'brake');
// Drift
makeBtn('DRIFT', { wide: true, css: { right: '116px', bottom: '100px' } }, 'drift');
// Nitro
makeBtn('NITRO', { wide: true, css: { right: '116px', bottom: '180px' } }, 'nitro');
}
// ══ TOAST (transient notification) ══
const toastEl = document.createElement('div');
toastEl.style.cssText = `
position: fixed; top: 50%; left: 50%;
transform: translate(-50%, -50%);
font-family: Orbitron, sans-serif;
font-weight: 900; font-size: 28px;
color: #fff;
text-shadow: 0 0 20px rgba(255,255,255,0.6), 0 2px 8px rgba(0,0,0,0.9);
z-index: 350; pointer-events: none;
opacity: 0; transition: opacity 0.2s;
`;
document.body.appendChild(toastEl);
let toastTimer = 0;
function showToast(msg, variant) {
toastEl.textContent = msg;
toastEl.style.opacity = '1';
// Reset any gold animation/class left over from a previous best-lap toast
toastEl.classList.remove('toast-gold');
// offset reflow so re-adding the class restarts the keyframes
void toastEl.offsetWidth;
if (variant === 'gold') {
toastEl.style.color = '#ffd700';
toastEl.style.fontSize = '42px';
toastEl.style.letterSpacing = '3px';
toastEl.style.textShadow = '0 0 36px rgba(255,215,0,0.9), 0 4px 14px rgba(0,0,0,0.9)';
toastEl.style.animation = 'toastGoldIn 2s ease-out forwards';
toastTimer = 2;
} else {
toastEl.style.color = '#fff';
toastEl.style.fontSize = '28px';
toastEl.style.letterSpacing = '0';
toastEl.style.textShadow = '0 0 20px rgba(255,255,255,0.6), 0 2px 8px rgba(0,0,0,0.9)';
toastEl.style.animation = 'none';
toastTimer = 1.2;
}
}
// ══ COUNTDOWN OVERLAY ══
const countdownEl = document.createElement('div');
countdownEl.style.cssText = `
position: fixed; top: 25%; left: 50%;
transform: translate(-50%, -50%);
font-family: 'Orbitron', sans-serif;
font-weight: 900; font-size: 200px;
color: white;
text-shadow: 0 0 80px rgba(255,255,255,0.6), 0 4px 20px rgba(0,0,0,0.8);
z-index: 200; pointer-events: none;
opacity: 0; transition: opacity 0.15s;
`;
document.body.appendChild(countdownEl);
let _lastCountdownText = '';
function setCountdown(text, color) {
countdownEl.style.opacity = '1';
countdownEl.style.color = color;
if (_lastCountdownText === text) return; // same digit — don't retrigger
_lastCountdownText = text;
countdownEl.textContent = text;
// Restart the pop keyframe by toggling the animation property.
countdownEl.style.animation = 'none';
void countdownEl.offsetWidth; // force reflow
countdownEl.style.animation = 'countdownPop 0.55s cubic-bezier(0.2, 1.2, 0.4, 1) forwards';
}
// ══ RESULTS OVERLAY ══
const resultsEl = document.createElement('div');
resultsEl.id = 'results-overlay';
resultsEl.style.cssText = `
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.0);
display: flex; align-items: center; justify-content: center;
z-index: 300; pointer-events: none;
font-family: 'Orbitron', sans-serif;
transition: background 1s;
`;
document.body.appendChild(resultsEl);
// ══ LAP NOTIFICATION ══
const lapNotifyEl = document.createElement('div');
lapNotifyEl.style.cssText = `
position: fixed; top: 35%; left: 50%;
transform: translate(-50%, -50%);
font-family: 'Orbitron', sans-serif;
font-weight: 900; font-size: 48px;
color: #ffd700;
text-shadow: 0 0 30px rgba(255,215,0,0.6), 0 2px 10px rgba(0,0,0,0.8);
z-index: 200; pointer-events: none;
opacity: 0; transition: opacity 0.3s;
`;
document.body.appendChild(lapNotifyEl);
let lapNotifyTimer = 0;
// ══ ATTRACT MODE OVERLAY ══
const attractEl = document.createElement('div');
attractEl.style.cssText = `
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center;
z-index: 250; pointer-events: none;
font-family: 'Orbitron', sans-serif;
`;
const attractTitle = document.createElement('div');
attractTitle.style.cssText = `
font-weight: 900; font-size: clamp(32px, 10vw, 72px); color: #ffffff;
text-shadow: 0 0 60px rgba(255,60,30,0.8), 0 4px 20px rgba(0,0,0,0.9);
margin-bottom: 30px; letter-spacing: clamp(2px, 0.8vw, 6px);
text-align: center; word-break: break-word; max-width: 90vw;
`;
attractTitle.textContent = 'SUNSET RACING';
const attractSub = document.createElement('div');
attractSub.style.cssText = `
font-weight: 700; font-size: clamp(14px, 3.5vw, 24px); color: rgba(255,255,255,0.9);
text-shadow: 0 0 20px rgba(255,255,255,0.4), 0 2px 8px rgba(0,0,0,0.8);
text-align: center; max-width: 90vw;
animation: attractPulse 1.2s ease-in-out infinite;
`;
attractSub.textContent = 'PRESS SPACE TO RACE';
// Add pulse animation
const attractStyle = document.createElement('style');
attractStyle.textContent = `
@keyframes attractPulse {
0%, 100% { opacity: 0.5; transform: scale(1); }
50% { opacity: 1; transform: scale(1.05); }
}
`;
document.head.appendChild(attractStyle);
attractEl.appendChild(attractTitle);
attractEl.appendChild(attractSub);
document.body.appendChild(attractEl);
function formatRaceTime(seconds) {
if (!isFinite(seconds)) return 'DNF';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
const wholeSecs = Math.floor(secs);
const ms = Math.floor((secs - wholeSecs) * 1000);
return `${mins}:${String(wholeSecs).padStart(2, '0')}.${String(ms).padStart(3, '0')}`;
}
function showResults() {
raceResults.sort((a, b) => {
if (a.dnf && !b.dnf) return 1;
if (!a.dnf && b.dnf) return -1;
return a.time - b.time;
});
const posLabels = ['1ST', '2ND', '3RD', '4TH', '5TH'];
const posColors = ['#ffd700', '#e0e0e0', '#cd7f32', '#cccccc', '#aaaaaa'];
let html = '';
html += '
🏁
';
const playerResult = raceResults.find(r => r.name === 'YOU');
const playerPos = raceResults.indexOf(playerResult) + 1;
if (playerPos === 1) {
html += '
YOU WIN!
';
} else {
html += `
${posLabels[playerPos-1]} PLACE
`;
}
for (let i = 0; i < raceResults.length; i++) {
const r = raceResults[i];
const isPlayer = r.name === 'YOU';
const bg = isPlayer ? 'background:rgba(255,34,0,0.3); border:2px solid #ff2200;' : 'background:rgba(255,255,255,0.05); border:1px solid rgba(255,255,255,0.1);';
const carColors = { 'YOU': '#ff2200', 'BLUE': '#3366ff', 'GOLD': '#ffcc00', 'JADE': '#00cc66', 'BLAZE': '#ff6600' };
html += `
`;
html += `${posLabels[i]}`;
html += ``;
html += `${r.name}`;
html += `${r.dnf ? 'DNF' : formatRaceTime(r.time)}`;
html += '
';
}
// Best lap for this race + all-time
if (lastLapTime !== null) {
html += '
';
html += `
LAST LAP: ${formatRaceTime(lastLapTime)}
`;
if (bestLapTime !== null) {
html += `
★ BEST: ${formatRaceTime(bestLapTime)}
`;
}
html += '
';
}
html += '
PRESS R TO RESTART · ESC TO PAUSE · C CAMERA · G GHOST
';
html += '
';
resultsEl.innerHTML = html;
resultsEl.style.background = 'rgba(0,0,0,0.85)';
resultsEl.style.pointerEvents = 'auto';
}
function startRace() {
raceState = 'grid';
gridTimer = 1.5;
raceResults = [];
playerFinished = false;
playerFinishTime = 0;
playerHasPassedHalf = false;
raceEndTimer = -1;
resultsShown = false;
prevTrackT = 0;
lapNotifyTimer = 0;
// Lap timing reset
currentLapStart = 0;
currentLapTime = 0;
lastLapTime = null;
currentLapSamples = [];
// Nitro reset
nitroCharge = 1.0;
nitroActive = false;
nitroTimer = 0;
G.player.speed = 0;
G.player.impulseX = 0;
G.player.impulseZ = 0;
for (const ai of aiCars) {
ai.speed = 0;
ai.impulseX = 0;
ai.impulseZ = 0;
}
// Reset sound engines to idle
if (sound) sound.reset();
if (aiSound) aiSound.reset();
positionOnGrid();
playerCar.visible = true;
// Hide attract overlay
attractEl.style.display = 'none';
resultsEl.innerHTML = '';
resultsEl.style.background = 'rgba(0,0,0,0.0)';
resultsEl.style.pointerEvents = 'none';
countdownEl.style.opacity = '0';
lapNotifyEl.style.opacity = '0';
clock.start();
}
function restartRace() {
raceState = 'grid';
gridTimer = 1.5;
raceResults = [];
playerFinished = false;
playerFinishTime = 0;
playerHasPassedHalf = false;
raceEndTimer = -1;
resultsShown = false;
prevTrackT = 0;
lapNotifyTimer = 0;
// Lap timing reset
currentLapStart = 0;
currentLapTime = 0;
lastLapTime = null;
currentLapSamples = [];
// Nitro reset
nitroCharge = 1.0;
nitroActive = false;
nitroTimer = 0;
G.player.speed = 0;
G.player.impulseX = 0;
G.player.impulseZ = 0;
for (const ai of aiCars) {
ai.speed = 0;
ai.impulseX = 0;
ai.impulseZ = 0;
}
// Reset sound engines to idle
if (sound) sound.reset();
if (aiSound) aiSound.reset();
positionOnGrid();
playerCar.visible = true;
resultsEl.innerHTML = '';
resultsEl.style.background = 'rgba(0,0,0,0.0)';
resultsEl.style.pointerEvents = 'none';
countdownEl.style.opacity = '0';
lapNotifyEl.style.opacity = '0';
clock.start();
}
// R key to restart
window.addEventListener('keydown', (e) => {
// Space to start from attract, or R to restart
if (e.code === 'Space' && raceState === 'attract') {
startRace();
}
if (e.code === 'KeyR' && (raceState === 'finished' || raceState === 'racing' || raceState === 'countdown' || raceState === 'grid')) {
restartRace();
}
});
// ══ GAME LOOP ══
const clock = new THREE.Clock();
let prevTrackT = 0;
// ── Check if car crosses finish line ──
function checkLapCrossing(prevT, currentT, speed) {
// Forward lap: prev was near end of lap (>0.9), now near start (<0.1)
if (prevT > 0.9 && currentT < 0.1 && speed > 0) return 1;
// Backward lap: prev near start, now near end, going backward
if (prevT < 0.1 && currentT > 0.9 && speed < 0) return -1;
return 0;
}
// ── Check if car has passed halfway point (anti-cheat) ──
function updateHalfPassed(currentT, car) {
if (currentT > 0.4 && currentT < 0.6) {
car.hasPassedHalf = true;
}
}
// ── Calculate race position (1st = most progress) ──
function getPosition(playerT) {
// Total progress = completed laps + current track position
const playerProgress = G.player.lap + playerT;
let pos = 1;
for (const ai of aiCars) {
const aiProgress = ai.lap + ai._trackT;
if (aiProgress > playerProgress) pos++;
}
return pos;
}
// ── Player physics (extracted so we can skip it during grid/countdown) ──
function updatePlayerPhysics(dt) {
const p = G.player;
const nearest = nearestTrackT(p.x, p.z);
const onRoad = nearest.dist < TRACK_WIDTH / 2;
p.onTrack = onRoad;
// Combine keyboard + touch input
const keyAccel = G.keys['KeyW'] || G.keys['ArrowUp'] || touchState.accel;
const keyBrake = G.keys['KeyS'] || G.keys['ArrowDown'] || touchState.brake;
const keyLeft = G.keys['KeyA'] || G.keys['ArrowLeft'] || touchState.left;
const keyRight = G.keys['KeyD'] || G.keys['ArrowRight'] || touchState.right;
const handbrake = G.keys['Space'] || touchState.drift || false;
const nitroKey = G.keys['ShiftLeft'] || G.keys['ShiftRight'] || touchState.nitro;
// ── Nitro: activate if pressed and have charge ──
if (nitroKey && !nitroActive && nitroCharge >= 0.25) {
nitroActive = true;
nitroTimer = NITRO_DURATION * nitroCharge; // proportional to charge
}
if (nitroActive) {
nitroTimer -= dt;
nitroCharge = Math.max(0, nitroCharge - dt / NITRO_DURATION);
if (nitroTimer <= 0 || nitroCharge <= 0) {
nitroActive = false;
nitroTimer = 0;
}
} else {
nitroCharge = Math.min(1, nitroCharge + dt / NITRO_RECHARGE);
}
const accelNow = nitroActive ? ACCEL * NITRO_ACCEL_MULT : ACCEL;
const maxNow = nitroActive ? MAX_SPEED * NITRO_MULT : MAX_SPEED;
if (keyAccel) p.speed += accelNow * dt;
else if (keyBrake) p.speed -= BRAKE * dt;
else {
if (p.speed > 0) p.speed = Math.max(0, p.speed - DRAG * dt);
else p.speed = Math.min(0, p.speed + DRAG * dt);
}
if (handbrake && Math.abs(p.speed) > 2) {
const hbDrag = 18;
if (p.speed > 0) p.speed = Math.max(0, p.speed - hbDrag * dt);
else p.speed = Math.min(0, p.speed + hbDrag * dt);
}
if (!onRoad) {
const grassDrag = 25;
p.speed *= Math.max(0, 1 - grassDrag * dt / Math.max(Math.abs(p.speed), 8));
}
p.speed = THREE.MathUtils.clamp(p.speed, -MAX_SPEED * 0.3, maxNow);
const absSpeed = Math.abs(p.speed);
const steerInput = keyLeft ? 1 : (keyRight ? -1 : 0);
const turnAuthority = Math.min(absSpeed / 12, 1) * Math.max(0.45, 1 - (absSpeed / MAX_SPEED) * 0.55);
const isBraking = (G.keys['KeyS'] || G.keys['ArrowDown']) && p.speed > 2;
const brakeTurnBonus = isBraking ? 1.45 : 1.0;
const handbrakeTurnBonus = handbrake ? 1.8 : 1.0;
const turnDelta = steerInput * TURN_RATE * turnAuthority * brakeTurnBonus * handbrakeTurnBonus * dt;
p.heading += turnDelta;
let grip = onRoad ? GRIP_TRACK : GRIP_GRASS;
if (handbrake) grip *= 0.35;
let velHeadingDiff = p.heading - p.velHeading;
while (velHeadingDiff > Math.PI) velHeadingDiff -= 2 * Math.PI;
while (velHeadingDiff < -Math.PI) velHeadingDiff += 2 * Math.PI;
p.velHeading += velHeadingDiff * grip * 5 * dt;
while (p.velHeading > Math.PI) p.velHeading -= 2 * Math.PI;
while (p.velHeading < -Math.PI) p.velHeading += 2 * Math.PI;
const moveDir = new THREE.Vector3(Math.sin(p.velHeading), 0, Math.cos(p.velHeading));
p.x += moveDir.x * p.speed * dt;
p.z += moveDir.z * p.speed * dt;
const surfaceFrame = frame(nearest.t);
const surfaceY = surfaceFrame.point.y + 0.05;
playerCar.position.set(p.x, surfaceY, p.z);
playerCar.rotation.y = p.heading;
// Front wheel visual
const frontWheels = playerCar.userData.frontWheels;
if (frontWheels) {
const maxSteerAngle = 0.44 * Math.max(0.35, 1 - (absSpeed / MAX_SPEED) * 0.5);
const targetSteerAngle = steerInput * maxSteerAngle;
const currentSteer = frontWheels[0].rotation.y;
const newSteer = THREE.MathUtils.lerp(currentSteer, targetSteerAngle, 8 * dt);
for (const fw of frontWheels) fw.rotation.y = newSteer;
}
// Drift visuals
const driftAngle = (() => {
let d = p.heading - p.velHeading;
while (d > Math.PI) d -= 2 * Math.PI;
while (d < -Math.PI) d += 2 * Math.PI;
return d;
})();
const targetRoll = driftAngle * 0.4;
playerCar.rotation.z = THREE.MathUtils.lerp(playerCar.rotation.z, targetRoll, 5 * dt);
const targetPitch = (G.keys['KeyW'] || G.keys['ArrowUp']) ? -0.03 :
(G.keys['KeyS'] || G.keys['ArrowDown']) ? 0.04 : 0;
playerCar.rotation.x = THREE.MathUtils.lerp(playerCar.rotation.x, targetPitch * (p.speed / MAX_SPEED), 5 * dt);
// Brake lights
const isBrakingOrReversing = (G.keys['KeyS'] || G.keys['ArrowDown']) || handbrake || p.speed < -1;
const tlm = playerCar.userData.tailLightMat;
if (tlm) {
tlm.emissive.setHex(isBrakingOrReversing ? 0xff0000 : 0x000000);
tlm.emissiveIntensity = isBrakingOrReversing ? 1.5 : 0;
}
// Tire marks
const absDrift = Math.abs(driftAngle);
const isDrifting = absDrift > 0.15 && absSpeed > 3;
const isHardBraking = handbrake && absSpeed > 3;
const isAggressiveTurn = absDrift > 0.08 && absSpeed > 10 && onRoad;
if (isDrifting || isHardBraking || isAggressiveTurn) {
const intensity = Math.min(1, (absSpeed / MAX_SPEED) * (absDrift / 0.5 + 0.3));
const rearOffset = -1.3;
const rearX = p.x + moveDir.x * rearOffset;
const rearZ = p.z + moveDir.z * rearOffset;
const rightX = Math.cos(p.heading);
const rightZ = -Math.sin(p.heading);
const sideDist = 1.1;
tireMarks.addMark(rearX - rightX * sideDist, rearZ - rightZ * sideDist, p.heading, intensity, -1, 'player');
tireMarks.addMark(rearX + rightX * sideDist, rearZ + rightZ * sideDist, p.heading, intensity, 1, 'player');
} else {
tireMarks.breakChain('player');
}
// Particles
if ((absSpeed > 15 && absDrift > 0.2) || (absSpeed > 25 && absDrift > 0.1)) {
const behindOffset = moveDir.clone().multiplyScalar(-2);
for (const side of [-1.2, 1.2]) {
const sideVec = new THREE.Vector3(moveDir.z, 0, -moveDir.x).multiplyScalar(side);
smoke.emit(
p.x + behindOffset.x + sideVec.x, 0.2, p.z + behindOffset.z + sideVec.z,
(Math.random() - 0.5) * 3, 1.5 + Math.random(), (Math.random() - 0.5) * 3
);
}
}
if (!onRoad && Math.abs(p.speed) > 5 && Math.random() < 0.4) {
const behindOffset = moveDir.clone().multiplyScalar(-1.5);
dust.emit(p.x + behindOffset.x + (Math.random() - 0.5), 0.3, p.z + behindOffset.z + (Math.random() - 0.5));
}
return { nearest, absSpeed, moveDir, driftAngle };
}
function update() {
// Pause: render only, no physics
if (paused) {
clock.getDelta(); // drain to prevent dt spike on resume
renderer.render(scene, camera);
return;
}
const dt = Math.min(clock.getDelta(), 0.05);
const p = G.player;
const elapsed = clock.elapsedTime;
// Toast fade
if (toastTimer > 0) {
toastTimer -= dt;
if (toastTimer <= 0) toastEl.style.opacity = '0';
}
// ══════════════════════════════════════════
// STATE: ATTRACT — AI car racing, helicopter cam
// ══════════════════════════════════════════
if (raceState === 'attract') {
// Run AI cars around the track
const fakePlayer = { x: 99999, z: 99999, heading: 0, speed: 0, impulseX: 0, impulseZ: 0 };
try { updateAI(aiCars, fakePlayer, dt, 'racing'); } catch(e) {}
// Update AI sound
if (aiSound) {
const leadAI = aiCars[attractAIIdx];
const px = leadAI.x;
const py = 0;
const pz = leadAI.z;
for (const ai of aiCars) {
if (ai.soundIdx !== undefined) {
aiSound.updateCar(ai.soundIdx,
ai.mesh.position.x, ai.mesh.position.y, ai.mesh.position.z,
ai.speed, px, py, pz, leadAI.heading, dt);
}
}
}
if (sound) sound.update(0, 0, false, dt);
// Follow lead AI car with helicopter camera
const leadAI = aiCars[attractAIIdx];
const leadPos = leadAI.mesh.position;
const leadDir = new THREE.Vector3(Math.sin(leadAI.heading), 0, Math.cos(leadAI.heading));
// Helicopter cam: high above and behind, slightly to the side for cinematic feel
const heliHeight = 35;
const heliBehind = 30;
const heliSide = 15;
const targetCamPos = leadPos.clone()
.add(leadDir.clone().multiplyScalar(-heliBehind))
.add(new THREE.Vector3(0, heliHeight, 0))
.add(new THREE.Vector3(leadDir.z, 0, -leadDir.x).multiplyScalar(heliSide));
camera.position.lerp(targetCamPos, 1.5 * dt);
const lookTarget = leadPos.clone();
lookTarget.y += 2;
camera.lookAt(lookTarget);
sun.position.copy(leadPos).add(new THREE.Vector3(60, 80, 40));
sun.target.position.copy(leadPos);
minimap.draw(leadAI);
hud.draw(Math.abs(Math.round(leadAI.speed * 4)), true, 0, 1, TOTAL_LAPS, aiCars.length + 1, elapsed);
smoke.update(dt);
dust.update(dt);
speedLines.update(dt, leadAI);
return;
}
// ══════════════════════════════════════════
// STATE: GRID — cars staged, brief pause
// ══════════════════════════════════════════
if (raceState === 'grid') {
gridTimer -= dt;
if (gridTimer <= 0) {
raceState = 'countdown';
countdownStartTime = elapsed;
}
// Camera behind player on grid
const moveDir = new THREE.Vector3(Math.sin(p.heading), 0, Math.cos(p.heading));
const camBehind = moveDir.clone().multiplyScalar(-14);
const camUp = new THREE.Vector3(0, 7, 0);
const targetCamPos = playerCar.position.clone().add(camBehind).add(camUp);
camera.position.lerp(targetCamPos, 3 * dt);
const lookTarget = playerCar.position.clone();
lookTarget.y += 1;
camera.lookAt(lookTarget);
sun.position.copy(playerCar.position).add(new THREE.Vector3(60, 80, 40));
sun.target.position.copy(playerCar.position);
minimap.draw(p);
hud.draw(0, true, 0, 5, TOTAL_LAPS, aiCars.length + 1, elapsed);
smoke.update(dt);
dust.update(dt);
speedLines.update(dt, p);
return;
}
// ══════════════════════════════════════════
// STATE: COUNTDOWN — 3, 2, 1, GO!
// ══════════════════════════════════════════
if (raceState === 'countdown') {
const countdownElapsed = elapsed - countdownStartTime;
// Show 3, 2, 1, GO (each digit pops in via countdownPop keyframe)
if (countdownElapsed < 1) {
setCountdown('3', '#ff3333');
} else if (countdownElapsed < 2) {
setCountdown('2', '#ffaa00');
} else if (countdownElapsed < 3) {
setCountdown('1', '#00ff66');
} else if (countdownElapsed < 3.8) {
setCountdown('GO!', '#ffffff');
} else {
countdownEl.style.opacity = '0';
}
// Transition to racing 0.4s after GO!
if (countdownElapsed >= 3.4) {
countdownEl.style.opacity = '0';
if (raceState !== 'racing') {
raceState = 'racing';
raceStartTime = elapsed;
prevTrackT = 0;
for (const ai of aiCars) {
ai.prevTrackT = 0;
}
}
}
// Camera behind player
const moveDir = new THREE.Vector3(Math.sin(p.heading), 0, Math.cos(p.heading));
const camBehind = moveDir.clone().multiplyScalar(-14);
const camUp = new THREE.Vector3(0, 7, 0);
const targetCamPos = playerCar.position.clone().add(camBehind).add(camUp);
camera.position.lerp(targetCamPos, 3 * dt);
const lookTarget = playerCar.position.clone();
lookTarget.y += 1;
camera.lookAt(lookTarget);
sun.position.copy(playerCar.position).add(new THREE.Vector3(60, 80, 40));
sun.target.position.copy(playerCar.position);
minimap.draw(p);
hud.draw(0, true, 0, 5, TOTAL_LAPS, aiCars.length + 1, elapsed);
smoke.update(dt);
dust.update(dt);
speedLines.update(dt, p);
return;
}
// ══════════════════════════════════════════
// STATE: FINISHED — show results, slow cars
// ══════════════════════════════════════════
if (raceState === 'finished') {
// Show results on first frame of finished state
if (!resultsShown) {
// DNF any car that hasn't finished
if (!playerFinished) {
raceResults.push({ name: 'YOU', time: 0, dnf: true });
}
for (const ai of aiCars) {
if (!ai.finished) {
raceResults.push({ name: ai.name, time: 0, dnf: true });
}
}
showResults();
resultsShown = true;
countdownEl.style.opacity = '0';
}
// Gradually slow player
p.speed *= Math.max(0, 1 - 3 * dt);
const moveDir = new THREE.Vector3(Math.sin(p.velHeading), 0, Math.cos(p.velHeading));
p.x += moveDir.x * p.speed * dt;
p.z += moveDir.z * p.speed * dt;
const nearest = nearestTrackT(p.x, p.z);
const surfaceFrame = frame(nearest.t);
playerCar.position.set(p.x, surfaceFrame.point.y + 0.05, p.z);
// Update AI positions (they're already decelerating in updateAI)
G.player._trackT = nearest.t;
try { updateAI(aiCars, G.player, dt, 'finished'); } catch(e) {}
// Camera
const camBehind = moveDir.clone().multiplyScalar(-14);
const camUp = new THREE.Vector3(0, 7, 0);
const targetCamPos = playerCar.position.clone().add(camBehind).add(camUp);
camera.position.lerp(targetCamPos, 3 * dt);
const lookTarget = playerCar.position.clone();
lookTarget.y += 1;
camera.lookAt(lookTarget);
sun.position.copy(playerCar.position).add(new THREE.Vector3(60, 80, 40));
sun.target.position.copy(playerCar.position);
smoke.update(dt);
dust.update(dt);
minimap.draw(p);
hud.draw(Math.abs(Math.round(p.speed * 4)), p.onTrack, TOTAL_LAPS, getPosition(nearest.t), TOTAL_LAPS, aiCars.length + 1, elapsed);
return;
}
// ══════════════════════════════════════════
// STATE: RACING — full gameplay
// ══════════════════════════════════════════
// ── Player physics ──
const { nearest, absSpeed, moveDir } = updatePlayerPhysics(dt);
const currentTrackT = nearest.t;
// ── Player lap tracking (anti-cheat: must pass halfway) ──
currentLapTime = elapsed - currentLapStart;
// Record ghost samples every ~0.1s during current lap (if still possibly best)
if (!playerFinished) {
// Limit sample count to avoid memory blowup (max ~600 samples @ 0.1s = 60s lap)
if (currentLapSamples.length < 1000) {
const lastSample = currentLapSamples[currentLapSamples.length - 1];
if (!lastSample || currentLapTime - lastSample.t >= 0.1) {
currentLapSamples.push({
t: currentLapTime,
x: p.x,
z: p.z,
heading: p.heading,
});
}
}
}
if (!playerFinished) {
// Only set hasPassedHalf when ACTUALLY near the halfway point
if (currentTrackT > 0.4 && currentTrackT < 0.6) {
playerHasPassedHalf = true;
}
const lapCross = checkLapCrossing(prevTrackT, currentTrackT, p.speed);
if (lapCross === 1 && playerHasPassedHalf) {
p.lap++;
playerHasPassedHalf = false; // reset for next lap
// ── Capture lap time ──
lastLapTime = currentLapTime;
const isNewBest = bestLapTime === null || lastLapTime < bestLapTime;
if (isNewBest) {
bestLapTime = lastLapTime;
saveBestLap(lastLapTime, p.lap, telemetry.id);
// Save samples as ghost data
if (currentLapSamples.length > 10) {
const saved = saveGhost(currentLapSamples, lastLapTime);
if (saved) ghostData = loadGhost();
}
showToast(`NEW BEST LAP! ${formatRaceTime(lastLapTime)}`, 'gold');
}
// Reset for next lap
currentLapStart = elapsed;
currentLapSamples = [];
// Show lap notification
if (p.lap < TOTAL_LAPS) {
const lapMsg = isNewBest
? `LAP ${p.lap + 1}/${TOTAL_LAPS} ⭐ ${formatRaceTime(lastLapTime)}`
: `LAP ${p.lap + 1}/${TOTAL_LAPS} ${formatRaceTime(lastLapTime)}`;
lapNotifyEl.textContent = lapMsg;
lapNotifyEl.style.opacity = '1';
lapNotifyTimer = 2;
}
// Check finish
if (p.lap >= TOTAL_LAPS) {
playerFinished = true;
playerFinishTime = elapsed - raceStartTime;
raceResults.push({ name: 'YOU', time: playerFinishTime, dnf: false });
lapNotifyEl.textContent = '🏁 FINISHED!';
lapNotifyEl.style.opacity = '1';
lapNotifyTimer = 3;
}
}
}
prevTrackT = currentTrackT;
// ── AI lap tracking ──
for (const ai of aiCars) {
if (ai.finished) continue;
// Anti-cheat: must actually pass halfway point
if (ai._trackT > 0.4 && ai._trackT < 0.6) {
ai.hasPassedHalf = true;
}
const aiLapCross = checkLapCrossing(ai.prevTrackT, ai._trackT, ai.speed);
if (aiLapCross === 1 && ai.hasPassedHalf) {
ai.lap++;
ai.hasPassedHalf = false;
if (ai.lap >= TOTAL_LAPS) {
ai.finished = true;
ai.finishTime = elapsed - raceStartTime;
raceResults.push({ name: ai.name, time: ai.finishTime, dnf: false });
}
}
ai.prevTrackT = ai._trackT;
}
// ── AI update ──
G.player._trackT = currentTrackT;
try {
updateAI(aiCars, G.player, dt, 'racing');
} catch(e) {
console.error('AI error:', e.message, e.stack);
}
// ── AI tire marks & particles ──
for (const ai of aiCars) {
const aiAbsSpeed = Math.abs(ai.speed);
let aiDrift = ai.heading - ai.velHeading;
while (aiDrift > Math.PI) aiDrift -= 2 * Math.PI;
while (aiDrift < -Math.PI) aiDrift += 2 * Math.PI;
const aiAbsDrift = Math.abs(aiDrift);
if ((aiAbsDrift > 0.15 && aiAbsSpeed > 3) || ai.handbrake && aiAbsSpeed > 3) {
const aiMoveX = Math.sin(ai.velHeading);
const aiMoveZ = Math.cos(ai.velHeading);
const rearX = ai.x + aiMoveX * -1.3;
const rearZ = ai.z + aiMoveZ * -1.3;
const rightX = Math.cos(ai.heading);
const rightZ = -Math.sin(ai.heading);
tireMarks.addMark(rearX - rightX * 1.1, rearZ - rightZ * 1.1, ai.heading, 0.5, -1, 'ai_' + aiCars.indexOf(ai));
tireMarks.addMark(rearX + rightX * 1.1, rearZ + rightZ * 1.1, ai.heading, 0.5, 1, 'ai_' + aiCars.indexOf(ai));
} else {
tireMarks.breakChain('ai_' + aiCars.indexOf(ai));
}
if (aiAbsSpeed > 15 && aiAbsDrift > 0.2) {
const behindX = -Math.sin(ai.velHeading) * 2;
const behindZ = -Math.cos(ai.velHeading) * 2;
for (const side of [-1.2, 1.2]) {
smoke.emit(
ai.x + behindX + Math.cos(ai.heading) * side,
0.2,
ai.z + behindZ - Math.sin(ai.heading) * side,
(Math.random() - 0.5) * 3, 1.5 + Math.random(), (Math.random() - 0.5) * 3
);
}
}
}
// ── Particle updates ──
smoke.update(dt);
dust.update(dt);
speedLines.update(dt, p);
// ── Lap notification fade ──
if (lapNotifyTimer > 0) {
lapNotifyTimer -= dt;
if (lapNotifyTimer <= 0) {
lapNotifyEl.style.opacity = '0';
}
}
// ── Telemetry ──
telemetry.update(p, currentTrackT, dt, elapsed);
if (Math.floor(elapsed) % 5 === 0 && Math.floor(elapsed) !== telemetry._lastSave) {
telemetry._lastSave = Math.floor(elapsed);
scheduleSave(telemetry);
}
// ── Camera follow (3 modes) ──
let targetCamPos, lookTarget;
if (cameraMode === CAM_THIRD) {
const camBehind = moveDir.clone().multiplyScalar(-14);
const camUp = new THREE.Vector3(0, 7, 0);
targetCamPos = playerCar.position.clone().add(camBehind).add(camUp);
lookTarget = playerCar.position.clone();
lookTarget.y += 1;
camera.position.lerp(targetCamPos, 5 * dt);
} else if (cameraMode === CAM_COCKPIT) {
// Cockpit: just above/behind the driver's seat
const forward = moveDir.clone().multiplyScalar(-0.2);
const up = new THREE.Vector3(0, 1.2, 0);
targetCamPos = playerCar.position.clone().add(forward).add(up);
const aheadVec = moveDir.clone().multiplyScalar(10);
lookTarget = playerCar.position.clone().add(aheadVec);
lookTarget.y += 0.6;
camera.position.copy(targetCamPos); // snap, not lerp
} else if (cameraMode === CAM_TOP) {
targetCamPos = playerCar.position.clone().add(new THREE.Vector3(0, 40, 0));
lookTarget = playerCar.position.clone();
camera.position.lerp(targetCamPos, 8 * dt);
}
camera.lookAt(lookTarget);
const targetFov = 65 + (Math.abs(p.speed) / MAX_SPEED) * 15 + (nitroActive ? 8 : 0);
camera.fov = THREE.MathUtils.lerp(camera.fov, targetFov, 3 * dt);
camera.updateProjectionMatrix();
sun.position.copy(playerCar.position).add(new THREE.Vector3(60, 80, 40));
sun.target.position.copy(playerCar.position);
// ── Ghost car update (replay of best lap) ──
if (ghostEnabled && ghostData && ghostData.samples && ghostData.samples.length > 0) {
ghostCar.visible = true;
// Loop the ghost in sync with current lap time
const lapDur = ghostData.lapTime;
const t = currentLapTime % lapDur;
// Find closest sample (binary search would be faster, but linear is fine for 600 samples)
let sample = ghostData.samples[0];
for (let i = 1; i < ghostData.samples.length; i++) {
if (ghostData.samples[i].t > t) break;
sample = ghostData.samples[i];
}
ghostCar.position.set(sample.x, 0.05, sample.z);
ghostCar.rotation.y = sample.heading;
} else {
ghostCar.visible = false;
}
// ── AI car sounds ──
if (aiSound) {
const px = p.x, pz = p.z, py = playerCar.position.y;
for (const ai of aiCars) {
if (ai.soundIdx !== undefined) {
aiSound.updateCar(ai.soundIdx,
ai.mesh.position.x, ai.mesh.position.y, ai.mesh.position.z,
ai.speed, px, py, pz, p.heading, dt);
}
}
}
// ── HUD ──
const displaySpeed = Math.abs(Math.round(p.speed * 4));
const pos = getPosition(currentTrackT);
const playerLap = Math.min(p.lap + 1, TOTAL_LAPS);
// ── Compute AI gaps (ahead/behind player) ──
const playerProgressTotal = p.lap + currentTrackT;
let aheadGap = null, aheadName = null, behindGap = null, behindName = null;
let closestAheadProgress = Infinity;
let closestBehindProgress = -Infinity;
// Player speed in world units/sec (avg); approximate gap in seconds as distance/speed
const avgSpeed = Math.max(Math.abs(p.speed), 10); // avoid divide-by-zero
for (const ai of aiCars) {
const aiProg = ai.lap + ai._trackT;
const delta = aiProg - playerProgressTotal;
if (delta > 0 && aiProg < closestAheadProgress) {
closestAheadProgress = aiProg;
// Convert track-t delta to world distance (approximate)
aheadGap = (delta * trackLen) / avgSpeed;
aheadName = ai.name || 'AI';
} else if (delta < 0 && aiProg > closestBehindProgress) {
closestBehindProgress = aiProg;
behindGap = (-delta * trackLen) / avgSpeed;
behindName = ai.name || 'AI';
}
}
// ── Compute ghost delta (seconds ahead/behind ghost) ──
let ghostDelta = null;
if (ghostData && ghostData.samples && ghostData.samples.length > 0) {
// Find ghost's track position at current lap time
const lapDur = ghostData.lapTime;
const t = currentLapTime % lapDur;
let ghostSample = ghostData.samples[0];
for (let i = 1; i < ghostData.samples.length; i++) {
if (ghostData.samples[i].t > t) break;
ghostSample = ghostData.samples[i];
}
// Find ghost track-t and compare with player
const ghostNearest = nearestTrackT(ghostSample.x, ghostSample.z);
const ghostProgress = ghostNearest.t;
const playerProgress = currentTrackT;
// Approx delta: if player is ahead on track, negative = faster than ghost
let delta = (ghostProgress - playerProgress) * trackLen / avgSpeed;
// wrap
if (delta > trackLen / (2 * avgSpeed)) delta -= trackLen / avgSpeed;
if (delta < -trackLen / (2 * avgSpeed)) delta += trackLen / avgSpeed;
ghostDelta = delta;
}
hud.draw(displaySpeed, p.onTrack, playerLap, pos, TOTAL_LAPS, aiCars.length + 1, elapsed, {
currentLapTime: currentLapTime,
bestLapTime: bestLapTime,
lastLapTime: lastLapTime,
gapAhead: aheadGap,
gapBehind: behindGap,
aheadName: aheadName,
behindName: behindName,
nitroCharge: nitroCharge,
ghostDelta: ghostDelta,
});
// ── Minimap ──
minimap.draw(p);
// ── Sound engine ──
if (sound) {
const isThrottle = G.keys['KeyW'] || G.keys['ArrowUp'];
sound.update(p.speed, absSpeed, isThrottle, dt);
}
// ── Check if race is finished ──
const allCarsFinished = playerFinished && aiCars.every(ai => ai.finished);
const anyCarFinished = playerFinished || aiCars.some(ai => ai.finished);
if (anyCarFinished && raceEndTimer < 0) {
raceEndTimer = 30;
}
if (raceEndTimer >= 0) {
raceEndTimer -= dt;
}
// Transition to finished state
if (allCarsFinished || (raceEndTimer >= 0 && raceEndTimer <= 0.01)) {
raceState = 'finished';
}
}
let loadingVeilHidden = false;
function hideLoadingVeil() {
if (loadingVeilHidden) return;
const veil = document.getElementById('loading-veil');
if (!veil) { loadingVeilHidden = true; return; }
veil.classList.add('hidden');
// Remove from DOM after the fade-out transition so it can't steal clicks.
setTimeout(() => { veil.remove(); }, 700);
loadingVeilHidden = true;
}
function animate() {
requestAnimationFrame(animate);
update();
renderer.render(scene, camera);
if (!loadingVeilHidden) hideLoadingVeil();
}
animate();
// ══ RESIZE ══
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});