← Rally results

Rally

longcat 2.0 · composer

◈Preview unavailable — try playing the result

Browser check: Preview unavailable or timed out. This is a technical check, not a quality review.

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Imported historical artifact. Its exact original instructions and lineage are not known.

Generation prompt · exact text unavailable

See the current prompt for reference. It may differ from the instructions used to generate this artifact.

Inspect original source 47,901 bytes · SHA-256 40f54c7b808f
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Rally Racer 3D</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<style>
/* ── css: gameUI ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
body { overflow: hidden; background: #000; font-family: 'Segoe UI', Arial, sans-serif; user-select: none; }
#gameCanvas { display: block; width: 100vw; height: 100vh; }
#app { position: relative; width: 100vw; height: 100vh; }
#hud { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; }
#speedo { position: absolute; bottom: 20px; right: 20px; color: #fff; font-size: 42px; font-weight: bold; text-align: right; text-shadow: 2px 2px 4px rgba(0,0,0,0.8); }
#speedo small { font-size: 16px; display: block; }
#gear { position: absolute; bottom: 90px; right: 20px; color: #ffcc00; font-size: 28px; font-weight: bold; text-shadow: 2px 2px 4px rgba(0,0,0,0.8); }
#rpm { position: absolute; bottom: 15px; right: 20px; width: 180px; height: 8px; background: rgba(0,0,0,0.5); border-radius: 4px; }
#rpmBar { height: 100%; width: 0%; background: linear-gradient(90deg, #0f0, #ff0, #f00); border-radius: 4px; transition: width 0.1s; }
#lapInfo, #timeInfo, #bestInfo, #lastInfo { position: absolute; color: #fff; font-size: 18px; text-shadow: 1px 1px 3px rgba(0,0,0,0.9); }
#lapInfo { top: 15px; left: 15px; font-size: 22px; }
#timeInfo { top: 45px; left: 15px; }
#bestInfo { top: 72px; left: 15px; color: #aaf; }
#lastInfo { top: 99px; left: 15px; color: #8f8; }
#checkpointInfo { position: absolute; top: 130px; left: 15px; color: #ff0; font-size: 14px; }
#countdown { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 120px; font-weight: bold; color: #fff; text-shadow: 0 0 30px rgba(255,100,0,0.8); z-index: 20; display: none; transition: all 0.3s; }
#minimap { position: absolute; top: 15px; right: 15px; border: 2px solid rgba(255,255,255,0.5); border-radius: 8px; overflow: hidden; z-index: 10; }
#minimapCanvas { display: block; background: rgba(0,0,0,0.5); }
.overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.75); display: flex; align-items: center; justify-content: center; z-index: 100; }
.overlay.display-none { display: none; }
.menuBox { background: linear-gradient(135deg, #1a1a2e, #16213e); border: 2px solid #e94560; border-radius: 16px; padding: 40px; text-align: center; color: #fff; max-width: 500px; }
.menuBox h1 { font-size: 48px; color: #e94560; margin-bottom: 15px; }
.menuBox h2 { font-size: 36px; margin-bottom: 20px; }
.menuBox p { margin: 8px 0; font-size: 16px; color: #ccc; }
.menuBox button { margin: 10px 8px; padding: 12px 32px; font-size: 18px; background: #e94560; color: #fff; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; transition: transform 0.1s, background 0.2s; }
.menuBox button:hover { transform: scale(1.05); background: #ff6b6b; }
.controlsInfo { margin: 20px 0; padding: 15px; background: rgba(255,255,255,0.1); border-radius: 8px; }
#menu { display: flex; }
#pauseMenu, #finishMenu { display: none; }
#touchControls { position: absolute; bottom: 20px; left: 0; width: 100%; pointer-events: none; z-index: 15; display: none; }
#dpad { position: absolute; bottom: 20px; left: 20px; pointer-events: auto; }
#actionBtns { position: absolute; bottom: 20px; right: 20px; pointer-events: auto; }
.tbtn { width: 55px; height: 55px; background: rgba(255,255,255,0.3); border: 2px solid rgba(255,255,255,0.5); border-radius: 12px; color: #fff; font-size: 20px; margin: 3px; }
.tbtn:active { background: rgba(255,255,255,0.6); }
.abtn { width: 65px; height: 65px; font-size: 16px; }
@media (max-width: 768px) {
  #touchControls { display: block; }
  #minimap { top: auto; bottom: 120px; right: 10px; }
  #minimapCanvas { width: 120px; height: 120px; }
}
</style>
</head>
<body>
<div id="app"><canvas id="gameCanvas"></canvas>
<div id="hud">
  <div id="speedo"><span id="speedVal">0</span><small>km/h</small></div>
  <div id="gear"><span id="gearVal">N</span></div>
  <div id="rpm"><div id="rpmBar"></div></div>
  <div id="lapInfo">LAP <span id="lapVal">1</span>/3</div>
  <div id="timeInfo">TIME <span id="timeVal">0:00.0</span></div>
  <div id="bestInfo">BEST <span id="bestVal">--:--.-</span></div>
  <div id="lastInfo">LAST <span id="lastVal">--:--.-</span></div>
  <div id="checkpointInfo"></div>
</div>
<div id="countdown"></div>
<div id="minimap"><canvas id="minimapCanvas" width="200" height="200"></canvas></div>
<div id="menu" class="overlay"><div class="menuBox"><h1>RALLY RACER</h1><p>Complete 3 laps as fast as possible!</p><div class="controlsInfo"><p>WASD / Arrow Keys - Drive</p><p>Space - Handbrake</p><p>R - Restart | C - Camera | P - Pause</p></div><button id="startBtn">START RACE</button></div></div>
<div id="pauseMenu" class="overlay"><div class="menuBox"><h2>PAUSED</h2><button id="resumeBtn">RESUME</button><button id="restartBtn">RESTART</button></div></div>
<div id="finishMenu" class="overlay"><div class="menuBox"><h1>FINISHED!</h1><p>Final Time: <span id="finalTime">--:--.-</span></p><p>Best Lap: <span id="finalBest">--:--.-</span></p><button id="finishRestartBtn">RACE AGAIN</button></div></div>
<div id="touchControls">
  <div id="dpad">
    <button class="tbtn" id="tUp">▲</button>
    <button class="tbtn" id="tLeft">◄</button>
    <button class="tbtn" id="tRight">►</button>
    <button class="tbtn" id="tDown">▼</button>
  </div>
  <div id="actionBtns">
    <button class="tbtn abtn" id="tGas">GAS</button>
    <button class="tbtn abtn" id="tBrake">BRK</button>
    <button class="tbtn abtn" id="tHB">HB</button>
  </div>
</div>
</div>
<script>
'use strict';

// ── const: CONFIG ──
// Game configuration constants
const CONFIG = {
  MAX_SPEED: 180,
  ACCELERATION: 45,
  BRAKE_FORCE: 60,
  REVERSE_SPEED: 30,
  FRICTION: 0.98,
  TURN_SPEED: 2.8,
  HANDBRAKE_FACTOR: 0.92,
  GRAVITY: 20,
  TRACK_HALF_WIDTH: 12,
  TOTAL_LAPS: 3,
  CHECKPOINT_COUNT: 20,
  CAR_LENGTH: 4,
  CAR_WIDTH: 2,
  CAR_HEIGHT: 1.2,
};

// ── const: TRACK_POINTS ──
// Track curve control points - a winding loop
const TRACK_POINTS = [
  {x:0,y:0,z:0}, {x:60,y:2,z:40}, {x:120,y:5,z:80}, {x:160,y:3,z:140},
  {x:180,y:8,z:210}, {x:160,y:6,z:280}, {x:120,y:4,z:330}, {x:60,y:7,z:360},
  {x:0,y:5,z:370}, {x:-60,y:3,z:350}, {x:-120,y:6,z:310}, {x:-160,y:8,z:260},
  {x:-180,y:5,z:190}, {x:-170,y:4,z:120}, {x:-140,y:6,z:60}, {x:-80,y:3,z:20},
];

// ── function: catmullRom ──
// Catmull-Rom spline interpolation for smooth track
function catmullRom(p0, p1, p2, p3, t) {
  const t2 = t * t, t3 = t2 * t;
  return {
    x: 0.5 * ((2*p1.x) + (-p0.x+p2.x)*t + (2*p0.x-5*p1.x+4*p2.x-p3.x)*t2 + (-p0.x+3*p1.x-3*p2.x+p3.x)*t3),
    y: 0.5 * ((2*p1.y) + (-p0.y+p2.y)*t + (2*p0.y-5*p1.y+4*p2.y-p3.y)*t2 + (-p0.y+3*p1.y-3*p2.y+p3.y)*t3),
    z: 0.5 * ((2*p1.z) + (-p0.z+p2.z)*t + (2*p0.z-5*p1.z+4*p2.z-p3.z)*t2 + (-p0.z+3*p1.z-3*p2.z+p3.z)*t3)
  };
}

// ── function: generateTrackPath ──
// Generate smooth track path from control points
function generateTrackPath(points, segmentsPerCurve) {
  const path = [];
  const n = points.length;
  for (let i = 0; i < n; i++) {
    const p0 = points[(i-1+n)%n];
    const p1 = points[i];
    const p2 = points[(i+1)%n];
    const p3 = points[(i+2)%n];
    for (let j = 0; j < segmentsPerCurve; j++) {
      path.push(catmullRom(p0, p1, p2, p3, j/segmentsPerCurve));
    }
  }
  return path;
}

// ── function: getTrackPointAtDistance ──
// Get track point at distance along path
function getTrackPointAtDistance(path, dist) {
  const totalLen = path.length;
  const idx = ((dist % totalLen) + totalLen) % totalLen;
  const i = Math.floor(idx);
  const t = idx - i;
  const a = path[i % totalLen];
  const b = path[(i+1) % totalLen];
  return {
    x: a.x + (b.x - a.x) * t,
    y: a.y + (b.y - a.y) * t,
    z: a.z + (b.z - a.z) * t
  };
}

// ── function: getTrackTangent ──
// Get track tangent at distance
function getTrackTangent(path, dist) {
  const totalLen = path.length;
  const idx = ((dist % totalLen) + totalLen) % totalLen;
  const i = Math.floor(idx);
  const a = path[i % totalLen];
  const b = path[(i+1) % totalLen];
  const dx = b.x - a.x, dz = b.z - a.z;
  const len = Math.sqrt(dx*dx + dz*dz) || 1;
  return { x: dx/len, z: dz/len };
}

// ── function: findClosestTrackDist ──
// Find closest point on track path to a given position
function findClosestTrackDist(path, pos) {
  let minDist = Infinity, bestIdx = 0;
  for (let i = 0; i < path.length; i++) {
    const dx = path[i].x - pos.x, dz = path[i].z - pos.z;
    const d = dx*dx + dz*dz;
    if (d < minDist) { minDist = d; bestIdx = i; }
  }
  return bestIdx;
}

// ── class: AudioEngine ──
// Procedural Audio Engine using Web Audio API
class AudioEngine {
  constructor() {
    this.ctx = null;
    this.engineOsc = null;
    this.engineGain = null;
    this.masterGain = null;
    this.initialized = false;
  }
  init() {
    if (this.initialized) return;
    this.ctx = new (window.AudioContext || window.webkitAudioContext)();
    this.masterGain = this.ctx.createGain();
    this.masterGain.gain.value = 0.4;
    this.masterGain.connect(this.ctx.destination);
    // Engine sound - sawtooth with filter
    this.engineOsc = this.ctx.createOscillator();
    this.engineOsc.type = 'sawtooth';
    this.engineOsc.frequency.value = 80;
    this.engineFilter = this.ctx.createBiquadFilter();
    this.engineFilter.type = 'lowpass';
    this.engineFilter.frequency.value = 400;
    this.engineGain = this.ctx.createGain();
    this.engineGain.gain.value = 0.0;
    this.engineOsc.connect(this.engineFilter);
    this.engineFilter.connect(this.engineGain);
    this.engineGain.connect(this.masterGain);
    this.engineOsc.start();
    this.initialized = true;
  }
  updateEngine(speed, maxSpeed) {
    if (!this.initialized) return;
    const ratio = Math.abs(speed) / maxSpeed;
    this.engineOsc.frequency.value = 60 + ratio * 250;
    this.engineFilter.frequency.value = 300 + ratio * 1500;
    this.engineGain.gain.value = 0.05 + ratio * 0.25;
  }
  beep(freq, duration, vol) {
    if (!this.initialized) return;
    const osc = this.ctx.createOscillator();
    const gain = this.ctx.createGain();
    osc.type = 'sine';
    osc.frequency.value = freq;
    gain.gain.value = vol || 0.3;
    gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + (duration || 0.2));
    osc.connect(gain);
    gain.connect(this.masterGain);
    osc.start();
    osc.stop(this.ctx.currentTime + (duration || 0.2));
  }
  countdown() { this.beep(440, 0.15, 0.4); }
  go() { this.beep(880, 0.3, 0.5); }
  crash() {
    if (!this.initialized) return;
    const bufferSize = this.ctx.sampleRate * 0.15;
    const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
    const data = buffer.getChannelData(0);
    for (let i = 0; i < bufferSize; i++) data[i] = (Math.random()*2-1) * Math.exp(-i/bufferSize*5);
    const src = this.ctx.createBufferSource();
    src.buffer = buffer;
    const gain = this.ctx.createGain();
    gain.gain.value = 0.4;
    src.connect(gain);
    gain.connect(this.masterGain);
    src.start();
  }
  skidOn() {
    if (!this.initialized || this.skidGain) return;
    const bufferSize = this.ctx.sampleRate * 2;
    const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
    const data = buffer.getChannelData(0);
    for (let i = 0; i < bufferSize; i++) data[i] = (Math.random()*2-1);
    this.skidSrc = this.ctx.createBufferSource();
    this.skidSrc.buffer = buffer;
    this.skidSrc.loop = true;
    this.skidFilter = this.ctx.createBiquadFilter();
    this.skidFilter.type = 'bandpass';
    this.skidFilter.frequency.value = 800;
    this.skidGain = this.ctx.createGain();
    this.skidGain.gain.value = 0.1;
    this.skidSrc.connect(this.skidFilter);
    this.skidFilter.connect(this.skidGain);
    this.skidGain.connect(this.masterGain);
    this.skidSrc.start();
  }
  skidOff() {
    if (!this.skidGain) return;
    this.skidGain.gain.value = 0;
    try { this.skidSrc.stop(); } catch(e) {}
    this.skidGain = null;
    this.skidSrc = null;
  }
}

// ── class: InputHandler ──
// Input handler - keyboard and touch
class InputHandler {
  constructor() {
    this.keys = {};
    this.touch = { up:false, down:false, left:false, right:false, gas:false, brake:false, handbrake:false };
    this._setupKeyboard();
    this._setupTouch();
  }
  _setupKeyboard() {
    const self = this;
    window.addEventListener('keydown', function(e) {
      self.keys[e.code] = true;
      if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space'].includes(e.code)) e.preventDefault();
    });
    window.addEventListener('keyup', function(e) { self.keys[e.code] = false; });
  }
  _setupTouch() {
    const bind = function(id, prop) {
      const el = document.getElementById(id);
      if (!el) return;
      el.addEventListener('touchstart', function(e) { e.preventDefault(); this.touch[prop] = true; });
      el.addEventListener('touchend', function(e) { e.preventDefault(); this.touch[prop] = false; });
      el.addEventListener('mousedown', function(e) { e.preventDefault(); this.touch[prop] = true; });
      el.addEventListener('mouseup', function(e) { e.preventDefault(); this.touch[prop] = false; });
    };
    bind('tUp','up'); bind('tDown','down'); bind('tLeft','left'); bind('tRight','right');
    bind('tGas','gas'); bind('tBrake','brake'); bind('tHB','handbrake');
  }
  get forward() { return this.keys['KeyW'] || this.keys['ArrowUp'] || this.touch.gas; }
  get backward() { return this.keys['KeyS'] || this.keys['ArrowDown'] || this.touch.brake; }
  get left() { return this.keys['KeyA'] || this.keys['ArrowLeft'] || this.touch.left; }
  get right() { return this.keys['KeyD'] || this.keys['ArrowRight'] || this.touch.right; }
  get handbrake() { return this.keys['Space'] || this.touch.handbrake; }
  get accelerate() { return this.keys['KeyW'] || this.keys['ArrowUp'] || this.touch.up; }
  get brake() { return this.keys['KeyS'] || this.keys['ArrowDown'] || this.touch.down; }
}

// ── class: Car ──
// Car physics and mesh creation
class Car {
  constructor(scene) {
    this.scene = scene;
    this.group = new THREE.Group();
    this.pos = new THREE.Vector3(0, 0.5, 0);
    this.vel = new THREE.Vector3(0, 0, 0);
    this.speed = 0;
    this.angle = 0;
    this.angularVel = 0;
    this.steering = 0;
    this.onGrass = false;
    this.onMud = false;
    this.drifting = false;
    this.rpm = 0;
    this.gear = 0;
    this._buildCar();
    scene.add(this.group);
  }
  _buildCar() {
    const bodyGeom = new THREE.BoxGeometry(2, 0.8, 4.2);
    const bodyMat = new THREE.MeshPhongMaterial({ color: 0xe63946, shininess: 80 });
    const body = new THREE.Mesh(bodyGeom, bodyMat);
    body.position.y = 0.6;
    body.castShadow = true;
    this.group.add(body);
    const cabinGeom = new THREE.BoxGeometry(1.7, 0.7, 2.2);
    const cabinMat = new THREE.MeshPhongMaterial({ color: 0x1d3557, shininess: 60 });
    const cabin = new THREE.Mesh(cabinGeom, cabinMat);
    cabin.position.set(0, 1.35, -0.2);
    cabin.castShadow = true;
    this.group.add(cabin);
    const hoodGeom = new THREE.BoxGeometry(1.8, 0.3, 1.4);
    const hood = new THREE.Mesh(hoodGeom, bodyMat);
    hood.position.set(0, 1.05, 1.3);
    this.group.add(hood);
    const spoilerGeom = new THREE.BoxGeometry(2, 0.1, 0.4);
    const spoilerMat = new THREE.MeshPhongMaterial({ color: 0x333333 });
    const spoiler = new THREE.Mesh(spoilerGeom, spoilerMat);
    spoiler.position.set(0, 1.5, -2);
    this.group.add(spoiler);
    const supGeom = new THREE.BoxGeometry(0.1, 0.4, 0.1);
    const sup1 = new THREE.Mesh(supGeom, spoilerMat);
    sup1.position.set(-0.7, 1.3, -2);
    this.group.add(sup1);
    const sup2 = new THREE.Mesh(supGeom, spoilerMat);
    sup2.position.set(0.7, 1.3, -2);
    this.group.add(sup2);
    const hlGeom = new THREE.SphereGeometry(0.2, 8, 8);
    const hlMat = new THREE.MeshPhongMaterial({ color: 0xffffcc, emissive: 0xffff88, emissiveIntensity: 0.8 });
    const hl1 = new THREE.Mesh(hlGeom, hlMat);
    hl1.position.set(-0.6, 0.7, 2.1);
    this.group.add(hl1);
    const hl2 = new THREE.Mesh(hlGeom, hlMat);
    hl2.position.set(0.6, 0.7, 2.1);
    this.group.add(hl2);
    const tlMat = new THREE.MeshPhongMaterial({ color: 0xff0000, emissive: 0xff0000, emissiveIntensity: 0.5 });
    const tl1 = new THREE.Mesh(hlGeom, tlMat);
    tl1.position.set(-0.7, 0.7, -2.1);
    this.group.add(tl1);
    const tl2 = new THREE.Mesh(hlGeom, tlMat);
    tl2.position.set(0.7, 0.7, -2.1);
    this.group.add(tl2);
    this.wheels = [];
    const wheelGeom = new THREE.CylinderGeometry(0.4, 0.4, 0.3, 12);
    const wheelMat = new THREE.MeshPhongMaterial({ color: 0x222222 });
    const wheelPositions = [[-1.1,0.4,1.3],[1.1,0.4,1.3],[-1.1,0.4,-1.3],[1.1,0.4,-1.3]];
    for (const [x,y,z] of wheelPositions) {
      const wheel = new THREE.Mesh(wheelGeom, wheelMat);
      wheel.rotation.z = Math.PI/2;
      wheel.position.set(x, y, z);
      wheel.castShadow = true;
      this.group.add(wheel);
      this.wheels.push(wheel);
    }
  }
  getWorldPos() { return this.pos.clone(); }
}

// ── class: ParticleSystem ──
// Particle system for dust, skid marks, sparks
class ParticleSystem {
  constructor(scene) {
    this.scene = scene;
    this.particles = [];
    this.skidMarks = [];
    this.maxParticles = 300;
    this.maxSkidMarks = 200;
    // Dust cloud sprite texture
    const canvas = document.createElement('canvas');
    canvas.width = 32; canvas.height = 32;
    const ctx = canvas.getContext('2d');
    const grad = ctx.createRadialGradient(16,16,0,16,16,16);
    grad.addColorStop(0, 'rgba(200,180,140,0.8)');
    grad.addColorStop(1, 'rgba(200,180,140,0)');
    ctx.fillStyle = grad;
    ctx.fillRect(0,0,32,32);
    this.dustTexture = new THREE.CanvasTexture(canvas);
    // Spark texture
    const sc = document.createElement('canvas');
    sc.width = 16; sc.height = 16;
    const sctx = sc.getContext('2d');
    const sgrad = sctx.createRadialGradient(8,8,0,8,8,8);
    sgrad.addColorStop(0, 'rgba(255,255,100,1)');
    sgrad.addColorStop(1, 'rgba(255,100,0,0)');
    sctx.fillStyle = sgrad;
    sctx.fillRect(0,0,16,16);
    this.sparkTexture = new THREE.CanvasTexture(sc);
    // Skid mark material
    const skidCanvas = document.createElement('canvas');
    skidCanvas.width = 8; skidCanvas.height = 8;
    const skctx = skidCanvas.getContext('2d');
    skctx.fillStyle = 'rgba(30,30,30,0.6)';
    skctx.beginPath();
    skctx.arc(4,4,3,0,Math.PI*2);
    skctx.fill();
    this.skidTexture = new THREE.CanvasTexture(skidCanvas);
  }
  emitDust(x, y, z, count) {
    for (let i = 0; i < count && this.particles.length < this.maxParticles; i++) {
      const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
        map: this.dustTexture, transparent: true, opacity: 0.6,
        depthWrite: false, blending: THREE.NormalBlending
      }));
      sprite.position.set(x, y, z);
      const s = 0.3 + Math.random() * 0.5;
      sprite.scale.set(s, s, s);
      this.scene.add(sprite);
      this.particles.push({
        sprite, life: 0.8 + Math.random()*0.4, maxLife: 1.2,
        vx: (Math.random()-0.5)*2, vy: 1+Math.random()*2, vz: (Math.random()-0.5)*2,
        type: 'dust'
      });
    }
  }
  emitSparks(x, y, z, count) {
    for (let i = 0; i < count && this.particles.length < this.maxParticles; i++) {
      const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
        map: this.sparkTexture, transparent: true, opacity: 1,
        depthWrite: false, blending: THREE.AdditiveBlending
      }));
      sprite.position.set(x, y, z);
      const s = 0.1 + Math.random() * 0.2;
      sprite.scale.set(s, s, s);
      this.scene.add(sprite);
      this.particles.push({
        sprite, life: 0.3 + Math.random()*0.3, maxLife: 0.6,
        vx: (Math.random()-0.5)*8, vy: 2+Math.random()*4, vz: (Math.random()-0.5)*8,
        type: 'spark'
      });
    }
  }
  addSkidMark(x, y, z, angle) {
    if (this.skidMarks.length >= this.maxSkidMarks) {
      const old = this.skidMarks.shift();
      this.scene.remove(old);
      old.material.dispose();
    }
    const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
      map: this.skidTexture, transparent: true, opacity: 0.4, depthWrite: false
    }));
    sprite.position.set(x, y + 0.02, z);
    sprite.scale.set(0.5, 0.5, 0.5);
    this.scene.add(sprite);
    this.skidMarks.push(sprite);
  }
  update(dt) {
    for (let i = this.particles.length - 1; i >= 0; i--) {
      const p = this.particles[i];
      p.life -= dt;
      if (p.life <= 0) {
        this.scene.remove(p.sprite);
        p.sprite.material.dispose();
        this.particles.splice(i, 1);
        continue;
      }
      p.sprite.position.x += p.vx * dt;
      p.sprite.position.y += p.vy * dt;
      p.sprite.position.z += p.vz * dt;
      if (p.type === 'dust') {
        p.vy -= 2 * dt;
        p.sprite.material.opacity = (p.life / p.maxLife) * 0.5;
      } else {
        p.vy -= 12 * dt;
        p.sprite.material.opacity = p.life / p.maxLife;
      }
    }
  }
}

// ── class: SceneBuilder ──
// Scene builder - creates 3D environment
class SceneBuilder {
  constructor(scene, trackPath) {
    this.scene = scene;
    this.trackPath = trackPath;
    this._addLights();
    this._addSky();
    this._addGround();
    this._buildTrack();
    this._addStartLine();
  }
  _addLights() {
    const ambient = new THREE.AmbientLight(0x6688aa, 0.6);
    this.scene.add(ambient);
    const sun = new THREE.DirectionalLight(0xffffee, 1.0);
    sun.position.set(80, 100, 50);
    sun.castShadow = true;
    sun.shadow.mapSize.width = 2048;
    sun.shadow.mapSize.height = 2048;
    sun.shadow.camera.near = 0.5;
    sun.shadow.camera.far = 400;
    sun.shadow.camera.left = -150;
    sun.shadow.camera.right = 150;
    sun.shadow.camera.top = 150;
    sun.shadow.camera.bottom = -150;
    this.scene.add(sun);
    this.sun = sun;
    const hemi = new THREE.HemisphereLight(0x87ceeb, 0x3d5c3d, 0.4);
    this.scene.add(hemi);
  }
  _addSky() {
    const skyGeom = new THREE.SphereGeometry(500, 16, 16);
    const skyMat = new THREE.MeshBasicMaterial({ color: 0x87ceeb, side: THREE.BackSide });
    const sky = new THREE.Mesh(skyGeom, skyMat);
    this.scene.add(sky);
    this.scene.fog = new THREE.Fog(0x87ceeb, 100, 450);
  }
  _addGround() {
    const groundGeom = new THREE.PlaneGeometry(800, 800, 40, 40);
    const groundMat = new THREE.MeshPhongMaterial({ color: 0x4a7c3f, flatShading: true });
    const pos = groundGeom.attributes.position;
    for (let i = 0; i < pos.count; i++) {
      pos.setZ(i, (Math.random()-0.5) * 0.8);
    }
    groundGeom.computeVertexNormals();
    const ground = new THREE.Mesh(groundGeom, groundMat);
    ground.rotation.x = -Math.PI/2;
    ground.receiveShadow = true;
    this.scene.add(ground);
  }
  _buildTrack() {
    const points = this.trackPath;
    const width = CONFIG.TRACK_HALF_WIDTH;
    const vertices = [];
    const uvs = [];
    const indices = [];
    for (let i = 0; i < points.length; i++) {
      const curr = points[i];
      const next = points[(i+1) % points.length];
      const dx = next.x - curr.x, dz = next.z - curr.z;
      const len = Math.sqrt(dx*dx + dz*dz) || 1;
      const nx = -dz/len, nz = dx/len;
      vertices.push(curr.x + nx*width, curr.y, curr.z + nz*width);
      vertices.push(curr.x - nx*width, curr.y, curr.z - nz*width);
      uvs.push(0, i*0.5, 1, i*0.5);
    }
    for (let i = 0; i < points.length; i++) {
      const i0 = i*2, i1 = i*2+1, i2 = ((i+1)%points.length)*2, i3 = ((i+1)%points.length)*2+1;
      indices.push(i0, i2, i1, i1, i2, i3);
    }
    const trackGeom = new THREE.BufferGeometry();
    trackGeom.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
    trackGeom.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
    trackGeom.setIndex(indices);
    trackGeom.computeVertexNormals();
    const trackMat = new THREE.MeshPhongMaterial({ color: 0x555555, flatShading: true });
    const track = new THREE.Mesh(trackGeom, trackMat);
    track.receiveShadow = true;
    this.scene.add(track);
    this.trackMesh = track;
    this._addShoulders(points, width);
  }
  _addShoulders(points, width) {
    const sw = 3;
    const vertices = [];
    const indices = [];
    for (let i = 0; i < points.length; i++) {
      const curr = points[i];
      const next = points[(i+1) % points.length];
      const dx = next.x - curr.x, dz = next.z - curr.z;
      const len = Math.sqrt(dx*dx + dz*dz) || 1;
      const nx = -dz/len, nz = dx/len;
      vertices.push(curr.x + nx*(width+sw), curr.y+0.01, curr.z + nz*(width+sw));
      vertices.push(curr.x + nx*width, curr.y+0.01, curr.z + nz*width);
      vertices.push(curr.x - nx*width, curr.y+0.01, curr.z - nz*width);
      vertices.push(curr.x - nx*(width+sw), curr.y+0.01, curr.z - nz*(width+sw));
    }
    for (let i = 0; i < points.length; i++) {
      const b = i*4;
      const nb = ((i+1)%points.length)*4;
      indices.push(b, nb, b+1, b+1, nb, nb+1);
      indices.push(b+2, nb+2, b+3, b+3, nb+2, nb+3);
    }
    const geom = new THREE.BufferGeometry();
    geom.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
    geom.setIndex(indices);
    geom.computeVertexNormals();
    const mat = new THREE.MeshPhongMaterial({ color: 0x8B7355, flatShading: true });
    const shoulders = new THREE.Mesh(geom, mat);
    shoulders.receiveShadow = true;
    this.scene.add(shoulders);
  }
  _addStartLine() {
    const startP = this.trackPath[0];
    const next = this.trackPath[1];
    const dx = next.x - startP.x, dz = next.z - startP.z;
    const len = Math.sqrt(dx*dx + dz*dz) || 1;
    const lineGeom = new THREE.PlaneGeometry(CONFIG.TRACK_HALF_WIDTH*2, 1);
    const lineMat = new THREE.MeshBasicMaterial({ color: 0xffffff });
    const line = new THREE.Mesh(lineGeom, lineMat);
    line.rotation.x = -Math.PI/2;
    line.position.set(startP.x, startP.y + 0.05, startP.z);
    line.rotation.z = Math.atan2(dx, dz);
    this.scene.add(line);
    // Checkpoint arch
    const archGeom = new THREE.TorusGeometry(8, 0.4, 8, 20, Math.PI);
    const archMat = new THREE.MeshPhongMaterial({ color: 0xffcc00 });
    const arch = new THREE.Mesh(archGeom, archMat);
    arch.position.set(startP.x, 5, startP.z);
    arch.rotation.y = Math.atan2(dx, dz);
    this.scene.add(arch);
  }
}

// ── class: SceneryBuilder ──
// Scenery builder - trees, rocks, signs etc.
class SceneryBuilder {
  constructor(scene, trackPath) {
    this.scene = scene;
    this.trackPath = trackPath;
    this.obstacles = [];
    this._addTrees();
    this._addRocks();
    this._addSigns();
    this._addBanners();
  }
  _addTrees() {
    const treeMat = new THREE.MeshPhongMaterial({ color: 0x2d5a27, flatShading: true });
    const trunkMat = new THREE.MeshPhongMaterial({ color: 0x5c3a1e });
    const treeGeom = new THREE.ConeGeometry(2, 5, 6);
    const trunkGeom = new THREE.CylinderGeometry(0.3, 0.4, 2, 6);
    const treeInst = new THREE.InstancedMesh(treeGeom, treeMat, 150);
    const trunkInst = new THREE.InstancedMesh(trunkGeom, trunkMat, 150);
    treeInst.castShadow = true;
    trunkInst.castShadow = true;
    const dummy = new THREE.Object3D();
    let count = 0;
    const points = this.trackPath;
    const width = CONFIG.TRACK_HALF_WIDTH;
    for (let i = 5; i < points.length - 5 && count < 150; i += 2) {
      const curr = points[i];
      const next = points[(i+1) % points.length];
      const dx = next.x - curr.x, dz = next.z - curr.z;
      const len = Math.sqrt(dx*dx + dz*dz) || 1;
      const nx = -dz/len, nz = dx/len;
      for (let side = -1; side <= 1 && count < 150; side += 2) {
        for (let d = width + 10; d < width + 50 && count < 150; d += 15 + Math.random()*15) {
          const tx = curr.x + nx*d*side + (Math.random()-0.5)*10;
          const tz = curr.z + nz*d*side + (Math.random()-0.5)*10;
          const s = 0.8 + Math.random()*0.5;
          dummy.position.set(tx, 2.5*s, tz);
          dummy.scale.set(s, s, s);
          dummy.rotation.y = Math.random()*6.28;
          dummy.updateMatrix();
          treeInst.setMatrixAt(count, dummy.matrix);
          dummy.position.y = s;
          dummy.updateMatrix();
          trunkInst.setMatrixAt(count, dummy.matrix);
          this.obstacles.push({x: tx, z: tz, r: 1.2});
          count++;
        }
      }
    }
    treeInst.count = count; trunkInst.count = count;
    treeInst.instanceMatrix.needsUpdate = true;
    trunkInst.instanceMatrix.needsUpdate = true;
    this.scene.add(treeInst);
    this.scene.add(trunkInst);
  }
  _addRocks() {
    const rockMat = new THREE.MeshPhongMaterial({ color: 0x888888, flatShading: true });
    const rockGeom = new THREE.DodecahedronGeometry(1.5, 0);
    const rockInst = new THREE.InstancedMesh(rockGeom, rockMat, 30);
    rockInst.castShadow = true;
    const dummy = new THREE.Object3D();
    let count = 0;
    const points = this.trackPath;
    const width = CONFIG.TRACK_HALF_WIDTH;
    for (let i = 8; i < points.length - 5 && count < 30; i += 6) {
      const curr = points[i];
      const next = points[(i+1) % points.length];
      const dx = next.x - curr.x, dz = next.z - curr.z;
      const len = Math.sqrt(dx*dx + dz*dz) || 1;
      const nx = -dz/len, nz = dx/len;
      const side = Math.random() > 0.5 ? 1 : -1;
      const d = width + 8 + Math.random()*20;
      const rx = curr.x + nx*d*side;
      const rz = curr.z + nz*d*side;
      const rs = 0.5 + Math.random()*1.0;
      dummy.position.set(rx, rs*0.4, rz);
      dummy.scale.set(rs, rs*0.6, rs);
      dummy.rotation.set(Math.random(), Math.random(), Math.random());
      dummy.updateMatrix();
      rockInst.setMatrixAt(count, dummy.matrix);
      this.obstacles.push({x: rx, z: rz, r: rs*1.2});
      count++;
    }
    rockInst.count = count;
    rockInst.instanceMatrix.needsUpdate = true;
    this.scene.add(rockInst);
  }
  _addSigns() {
    const signMat = new THREE.MeshPhongMaterial({ color: 0xff6600 });
    const postMat = new THREE.MeshPhongMaterial({ color: 0x888888 });
    const points = this.trackPath;
    const width = CONFIG.TRACK_HALF_WIDTH;
    for (let i = 12; i < points.length - 5; i += 20) {
      const curr = points[i];
      const next = points[(i+1) % points.length];
      const dx = next.x - curr.x, dz = next.z - curr.z;
      const len = Math.sqrt(dx*dx + dz*dz) || 1;
      const nx = -dz/len, nz = dx/len;
      const side = Math.random() > 0.5 ? 1 : -1;
      const postGeom = new THREE.CylinderGeometry(0.15, 0.15, 4, 6);
      const post = new THREE.Mesh(postGeom, postMat);
      post.position.set(curr.x + nx*(width+3)*side, 2, curr.z + nz*(width+3)*side);
      this.scene.add(post);
      const boardGeom = new THREE.BoxGeometry(2, 1, 0.1);
      const board = new THREE.Mesh(boardGeom, signMat);
      board.position.set(curr.x + nx*(width+3)*side, 3.8, curr.z + nz*(width+3)*side);
      this.scene.add(board);
    }
  }
  _addBanners() {
    const colors = [0xff0066, 0x0066ff, 0x00ff66, 0xffcc00, 0xff3300];
    const points = this.trackPath;
    const width = CONFIG.TRACK_HALF_WIDTH;
    for (let i = 0; i < 5; i++) {
      const bannerGeom = new THREE.PlaneGeometry(1.5, 5);
      const bannerMat = new THREE.MeshPhongMaterial({ color: colors[i%colors.length], side: THREE.DoubleSide });
      const banner = new THREE.Mesh(bannerGeom, bannerMat);
      const curr = points[Math.floor(points.length * 0.1) + i*3];
      const next = points[Math.floor(points.length * 0.1) + i*3 + 1];
      const dx = next.x - curr.x, dz = next.z - curr.z;
      const len = Math.sqrt(dx*dx + dz*dz) || 1;
      const nx = -dz/len, nz = dx/len;
      const side = i < 3 ? 1 : -1;
      const d = width + 6 + (i%3)*3;
      banner.position.set(curr.x + nx*d*side, 5.5, curr.z + nz*d*side);
      banner.lookAt(0, 5.5, 0);
      this.scene.add(banner);
    }
  }
}

// ── class: Game ──
// Main Game class - orchestrates everything
class Game {
  constructor() {
    this.canvas = document.getElementById('gameCanvas');
    this.renderer = new THREE.WebGLRenderer({ canvas: this.canvas, antialias: true });
    this.renderer.setSize(window.innerWidth, window.innerHeight);
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    this.renderer.shadowMap.enabled = true;
    this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
    this.renderer.setClearColor(0x87ceeb);
    this.scene = new THREE.Scene();
    this.camera = new THREE.PerspectiveCamera(65, window.innerWidth/window.innerHeight, 0.1, 600);
    this.trackPath = generateTrackPath(TRACK_POINTS, 12);
    this.checkpoints = this._generateCheckpoints();
    this.sceneBuilder = new SceneBuilder(this.scene, this.trackPath);
    this.scenery = new SceneryBuilder(this.scene, this.trackPath);
    this.car = new Car(this.scene);
    // Place car at start, facing along track
    const startPt = this.trackPath[0];
    const nextPt = this.trackPath[1];
    const dx = nextPt.x - startPt.x, dz = nextPt.z - startPt.z;
    this.car.pos.set(startPt.x, startPt.y + 0.5, startPt.z);
    this.car.angle = Math.atan2(dx, dz);
    this.particles = new ParticleSystem(this.scene);
    this.audio = new AudioEngine();
    this.input = new InputHandler();
    this.state = 'menu';
    this.countdownTime = 0;
    this.raceTime = 0;
    this.lapTimes = [];
    this.currentLap = 1;
    this.bestLap = Infinity;
    this.lastLap = 0;
    this.cpIndex = 0;
    this.lapValid = true;
    this.cameraMode = 0;
    this.camAngle = 0;
    this.camLookAt = new THREE.Vector3();
    this.lastTime = performance.now();
    this.ui = {
      speedVal: document.getElementById('speedVal'),
      gearVal: document.getElementById('gearVal'),
      rpmBar: document.getElementById('rpmBar'),
      lapVal: document.getElementById('lapVal'),
      timeVal: document.getElementById('timeVal'),
      bestVal: document.getElementById('bestVal'),
      lastVal: document.getElementById('lastVal'),
      countdown: document.getElementById('countdown'),
      menu: document.getElementById('menu'),
      pauseMenu: document.getElementById('pauseMenu'),
      finishMenu: document.getElementById('finishMenu'),
      finalTime: document.getElementById('finalTime'),
      finalBest: document.getElementById('finalBest'),
      minimap: document.getElementById('minimapCanvas'),
    };
    this._setupUI();
    window.addEventListener('resize', () => this._handleResize());
    gameLoopHandler(this, performance.now());
  }
  _setupUI() {
    document.getElementById('startBtn').onclick = () => this.startRace();
    const rb = document.getElementById('restartBtn');
    if (rb) rb.onclick = () => this.restart();
    document.getElementById('resumeBtn').onclick = () => this.resume();
    document.getElementById('finishRestartBtn').onclick = () => this.restart();
  }
  _generateCheckpoints() {
    const cps = [];
    const n = this.trackPath.length;
    const spacing = Math.floor(n / CONFIG.CHECKPOINT_COUNT);
    for (let i = 0; i < CONFIG.CHECKPOINT_COUNT; i++) {
      cps.push(this.trackPath[i * spacing]);
    }
    return cps;
  }
  startRace() {
    this.audio.init();
    this.state = 'countdown';
    this.countdownTime = 3.99;
    this.raceTime = 0;
    this.lapTimes = [];
    this.currentLap = 1;
    this.bestLap = Infinity;
    this.cpIndex = 0;
    this.ui.menu.style.display = 'none';
    this.audio.beep(440, 0.5, 0.3);
  }
  restart() {
    this.state = 'countdown';
    this.countdownTime = 3.99;
    this.raceTime = 0;
    this.lapTimes = [];
    this.currentLap = 1;
    this.bestLap = Infinity;
    this.lastLap = 0;
    this.cpIndex = 0;
    const startPt = this.trackPath[0];
    const nextPt = this.trackPath[1];
    const dx = nextPt.x - startPt.x, dz = nextPt.z - startPt.z;
    this.car.pos.set(startPt.x, startPt.y + 0.5, startPt.z);
    this.car.angle = Math.atan2(dx, dz);
    this.car.vel.set(0,0,0);
    this.car.speed = 0;
    this.car.angularVel = 0;
    this.ui.menu.style.display = 'none';
    this.ui.pauseMenu.style.display = 'none';
    this.ui.finishMenu.style.display = 'none';
    this.particles.skidMarks.forEach(s => this.scene.remove(s));
    this.particles.skidMarks = [];
    this.particles.particles.forEach(p => this.scene.remove(p.sprite));
    this.particles.particles = [];
  }
  pause() {
    if (this.state === 'racing') {
      this.state = 'paused';
      this.ui.pauseMenu.style.display = 'flex';
    }
  }
  resume() {
    if (this.state === 'paused') {
      this.state = 'racing';
      this.ui.pauseMenu.style.display = 'none';
      this.lastTime = performance.now();
    }
  }
  finishRace() {
    this.state = 'finished';
    this.ui.finishMenu.style.display = 'flex';
    this.audio.beep(880, 0.5, 0.5);
    setTimeout(() => this.audio.beep(1100, 0.5, 0.4), 300);
    setTimeout(() => this.audio.beep(1320, 0.8, 0.3), 600);
  }
  _handleResize() {
    this.camera.aspect = window.innerWidth / window.innerHeight;
    this.camera.updateProjectionMatrix();
    this.renderer.setSize(window.innerWidth, window.innerHeight);
  }
  _updateCarVisual() {
    this.car.group.position.copy(this.car.pos);
    this.car.group.rotation.y = this.car.angle;
    const wheelRot = this.car.speed * 0.02;
    for (const w of this.car.wheels) {
      w.rotation.x += wheelRot;
    }
  }
}

// ── function: gameLoopHandler ──
// Game loop update methods (called from Game class)
function gameLoopHandler(game, time) {
  requestAnimationFrame((t) => gameLoopHandler(game, t));
  const dt = Math.min((time - game.lastTime) / 1000, 0.05);
  game.lastTime = time;
  // Global hotkeys
  if (game.input.keys['KeyP']) { game.input.keys['KeyP'] = false; game.pause(); }
  if (game.input.keys['KeyR']) { game.input.keys['KeyR'] = false; game.restart(); }
  if (game.input.keys['KeyC']) { game.input.keys['KeyC'] = false; game.cameraMode = 1 - game.cameraMode; }
  if (game.state === 'countdown') updateCountdown(game, dt);
  if (game.state === 'racing') updateRace(game, dt);
  game._updateCarVisual();
  updateCamera(game, dt);
  game.particles.update(dt);
  updateHUD(game);
  updateMinimap(game);
  game.renderer.render(game.scene, game.camera);
}

// ── function: updateCountdown ──
function updateCountdown(game, dt) {
  game.countdownTime -= dt;
  const num = Math.ceil(game.countdownTime);
  if (game.countdownTime <= 0) {
    game.state = 'racing';
    game.ui.countdown.textContent = 'GO!';
    game.ui.countdown.style.display = 'block';
    game.audio.go();
    setTimeout(() => { game.ui.countdown.style.display = 'none'; }, 800);
  } else {
    game.ui.countdown.style.display = 'block';
    game.ui.countdown.textContent = num;
    if (num > 0 && Math.abs(game.countdownTime - num) < dt) {
      game.audio.countdown();
    }
  }
}

// ── function: checkSurface ──
function checkSurface(game) {
  const pos = game.car.pos;
  const trackWidth = CONFIG.TRACK_HALF_WIDTH;
  const trackDist = findClosestTrackDist(game.trackPath, pos);
  const closest = game.trackPath[trackDist];
  const dx = pos.x - closest.x, dz = pos.z - closest.z;
  const dist = Math.sqrt(dx*dx + dz*dz);
  if (dist < trackWidth) return 'asphalt';
  if (dist < trackWidth + 4) return 'gravel';
  // Check if in mud zones
  const angle = Math.atan2(pos.z, pos.x);
  if (Math.sin(angle * 3) > 0.6 && dist < trackWidth + 20) return 'mud';
  return 'grass';
}

// ── function: updateRace ──
function updateRace(game, dt) {
  game.raceTime += dt;
  const car = game.car;
  const input = game.input;
  // Determine surface
  const surface = checkSurface(game);
  car.onGrass = (surface === 'grass');
  car.onMud = (surface === 'mud');
  let maxSpeed = CONFIG.MAX_SPEED;
  let grip = 1.0;
  if (surface === 'gravel') { maxSpeed *= 0.75; grip = 0.7; }
  if (surface === 'grass') { maxSpeed *= 0.5; grip = 0.4; }
  if (surface === 'mud') { maxSpeed *= 0.3; grip = 0.25; }
  // Acceleration
  if (input.accelerate) {
    car.speed += CONFIG.ACCELERATION * dt;
  } else if (input.brake) {
    car.speed -= CONFIG.BRAKE_FORCE * dt;
    if (car.speed < -CONFIG.REVERSE_SPEED) car.speed = -CONFIG.REVERSE_SPEED;
  } else {
    car.speed *= CONFIG.FRICTION;
    if (Math.abs(car.speed) < 0.5) car.speed = 0;
  }
  car.speed = Math.max(-CONFIG.REVERSE_SPEED, Math.min(maxSpeed, car.speed));
  // Steering
  const speedFactor = Math.min(Math.abs(car.speed) / 30, 1);
  const turnRate = CONFIG.TURN_SPEED * speedFactor * (car.speed >= 0 ? 1 : -1);
  let targetSteer = 0;
  if (input.left) targetSteer = 1;
  if (input.right) targetSteer = -1;
  car.steering += (targetSteer - car.steering) * 5 * dt;
  // Drift/handbrake
  const hb = input.handbrake;
  if (hb) {
    grip *= CONFIG.HANDBRAKE_FACTOR;
    car.drifting = Math.abs(car.speed) > 20;
  }
  car.drifting = car.drifting || (Math.abs(car.speed) > 60 && Math.abs(car.steering) > 0.3);
  // Apply angular velocity
  car.angularVel = car.steering * turnRate * (hb ? 1.8 : 1.0) * dt;
  if (car.drifting) car.angularVel *= 1.5;
  car.angle += car.angularVel;
  // Move car
  const moveX = Math.sin(car.angle) * car.speed * dt;
  const moveZ = Math.cos(car.angle) * car.speed * dt;
  car.pos.x += moveX;
  car.pos.z += moveZ;
  // Track height
  const trackDist = findClosestTrackDist(game.trackPath, car.pos);
  const closest = game.trackPath[trackDist];
  car.pos.y = closest.y + 0.5;
  // Obstacle collisions
  const obstacles = game.scenery.obstacles;
  for (const obs of obstacles) {
    const odx = car.pos.x - obs.x, odz = car.pos.z - obs.z;
    const od = Math.sqrt(odx*odx + odz*odz);
    const minD = obs.r + 1.5;
    if (od < minD && od > 0.01) {
      const nx = odx/od, nz = odz/od;
      car.pos.x = obs.x + nx * minD;
      car.pos.z = obs.z + nz * minD;
      car.speed *= 0.5;
      if (Math.abs(car.speed) > 10) game.audio.crash();
      game.particles.emitSparks(car.pos.x, 0.5, car.pos.z, 5);
    }
  }
  // Checkpoint detection
  const cp = game.checkpoints[game.cpIndex];
  const cdx = car.pos.x - cp.x, cdz = car.pos.z - cp.z;
  if (Math.sqrt(cdx*cdx + cdz*cdz) < CONFIG.TRACK_HALF_WIDTH) {
    game.cpIndex++;
    if (game.cpIndex >= game.checkpoints.length) {
      game.cpIndex = 0;
      const lapTime = game.raceTime - game.lapTimes.reduce((a,b) => a+b, 0);
      game.lapTimes.push(lapTime);
      game.lastLap = lapTime;
      if (lapTime < game.bestLap) game.bestLap = lapTime;
      game.currentLap++;
      if (game.currentLap > CONFIG.TOTAL_LAPS) {
        game.finishRace();
      }
    }
  }
  // Skid marks and dust
  const isDrifting = car.drifting && Math.abs(car.speed) > 15;
  if (isDrifting) {
    game.particles.addSkidMark(car.pos.x, car.pos.y - 0.4, car.pos.z, car.angle);
    game.particles.addSkidMark(car.pos.x - Math.sin(car.angle)*2, car.pos.y - 0.4, car.pos.z - Math.cos(car.angle)*2, car.angle);
    if (Math.random() < 0.3) game.particles.emitDust(car.pos.x, 0.2, car.pos.z, 1);
    game.audio.skidOn();
  } else if (car.onGrass || car.onMud) {
    if (Math.random() < 0.5) game.particles.emitDust(car.pos.x, 0.3, car.pos.z, 2);
    game.audio.skidOn();
  } else {
    game.audio.skidOff();
  }
  // Update audio engine
  game.audio.updateEngine(car.speed, CONFIG.MAX_SPEED);
  const absSpeed = Math.abs(car.speed);
  car.rpm = (absSpeed / CONFIG.MAX_SPEED) * 8000;
  car.gear = Math.min(6, Math.floor(car.rpm / 1400) + 1);
}

// ── function: updateCamera ──
function updateCamera(game, dt) {
  const car = game.car;
  const offset = game.cameraMode === 0 ? 12 : 20;
  const height = game.cameraMode === 0 ? 5 : 10;
  const smoothing = game.cameraMode === 0 ? 4 : 2;
  const targetX = car.pos.x - Math.sin(car.angle) * offset;
  const targetZ = car.pos.z - Math.cos(car.angle) * offset;
  const targetY = car.pos.y + height;
  game.camera.position.x += (targetX - game.camera.position.x) * smoothing * dt;
  game.camera.position.y += (targetY - game.camera.position.y) * smoothing * dt;
  game.camera.position.z += (targetZ - game.camera.position.z) * smoothing * dt;
  game.camLookAt.x += (car.pos.x - game.camLookAt.x) * 5 * dt;
  game.camLookAt.y += (car.pos.y + 1 - game.camLookAt.y) * 5 * dt;
  game.camLookAt.z += (car.pos.z - game.camLookAt.z) * 5 * dt;
  game.camera.lookAt(game.camLookAt);
}

// ── function: updateHUD ──
function updateHUD(game) {
  const car = game.car;
  game.ui.speedVal.textContent = Math.abs(Math.round(car.speed));
  game.ui.gearVal.textContent = car.speed < -1 ? 'R' : (car.speed < 1 ? 'N' : car.gear);
  game.ui.rpmBar.style.width = ((car.rpm / 8000) * 100) + '%';
  game.ui.lapVal.textContent = Math.min(game.currentLap, CONFIG.TOTAL_LAPS);
  game.ui.timeVal.textContent = formatTime(game.raceTime);
  game.ui.bestVal.textContent = game.bestLap < Infinity ? formatTime(game.bestLap) : '--:--.-';
  game.ui.lastVal.textContent = game.lastLap > 0 ? formatTime(game.lastLap) : '--:--.-';
}

// ── function: updateMinimap ──
function updateMinimap(game) {
  const canvas = game.ui.minimap;
  if (!canvas) return;
  const ctx = canvas.getContext('2d');
  const w = canvas.width, h = canvas.height;
  ctx.clearRect(0, 0, w, h);
  ctx.fillStyle = 'rgba(0,0,0,0.3)';
  ctx.fillRect(0, 0, w, h);
  let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
  for (const p of game.trackPath) {
    if (p.x < minX) minX = p.x; if (p.x > maxX) maxX = p.x;
    if (p.z < minZ) minZ = p.z; if (p.z > maxZ) maxZ = p.z;
  }
  const pad = 15;
  const scaleX = (w - pad*2) / (maxX - minX || 1);
  const scaleZ = (h - pad*2) / (maxZ - minZ || 1);
  const scale = Math.min(scaleX, scaleZ);
  const toMap = (x, z) => [(x - minX) * scale + pad, (z - minZ) * scale + pad];
  ctx.strokeStyle = '#666';
  ctx.lineWidth = 4;
  ctx.beginPath();
  for (let i = 0; i <= game.trackPath.length; i++) {
    const p = game.trackPath[i % game.trackPath.length];
    const [mx, mz] = toMap(p.x, p.z);
    if (i === 0) ctx.moveTo(mx, mz);
    else ctx.lineTo(mx, mz);
  }
  ctx.stroke();
  ctx.fillStyle = '#ff0';
  for (let i = 0; i < game.checkpoints.length; i++) {
    const cp = game.checkpoints[i];
    const [mx, mz] = toMap(cp.x, cp.z);
    ctx.beginPath();
    ctx.arc(mx, mz, 2, 0, Math.PI*2);
    ctx.fill();
  }
  const [cx, cz] = toMap(game.car.pos.x, game.car.pos.z);
  ctx.fillStyle = '#f00';
  ctx.beginPath();
  ctx.arc(cx, cz, 4, 0, Math.PI*2);
  ctx.fill();
  ctx.strokeStyle = '#fff';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.moveTo(cx, cz);
  ctx.lineTo(cx + Math.sin(game.car.angle)*7, cz + Math.cos(game.car.angle)*7);
  ctx.stroke();
}

// ── function: formatTime ──
function formatTime(seconds) {
  const m = Math.floor(seconds / 60);
  const s = Math.floor(seconds % 60);
  const ms = Math.floor((seconds % 1) * 10);
  return m + ':' + (s<10?'0':'') + s + '.' + ms;
}

// ── main ──
// Main entry point - DOM is already parsed since script is at end of body
const game = new Game();
window.gameInstance = game;
// Touch detection for mobile
const isTouchDevice = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
if (isTouchDevice) {
  document.getElementById('touchControls').style.display = 'block';
}
// Ensure overlays are hidden at start
document.getElementById('pauseMenu').style.display = 'none';
document.getElementById('finishMenu').style.display = 'none';
</script>
</body>
</html>
<!-- agent-meta {"model":"meituan/longcat-2.0","provider":"openrouter","persona":"composer","sessionId":"5c16d966-b470-4bd3-a49c-2a8529c5e707","tokensIn":4150822,"tokensOut":78742,"tokensTotal":4229564,"turns":77,"toolCalls":78,"failedToolCalls":4,"timestamp":"2026-07-21T04:23:28.510Z"} -->