← Rally results

Rally

muse spark 1.2 contributor · full

Initial view of Rally

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 42,714 bytes · SHA-256 701f618fe4c4
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>Rally Rush — Procedural 3D Rally</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;overflow:hidden;background:#87c4f0;font-family:'Segoe UI',system-ui,sans-serif;color:#fff}
#canvas{position:fixed;inset:0;width:100%;height:100%;display:block}
#hud{position:fixed;inset:0;pointer-events:none;z-index:10}
.top-bar{position:absolute;top:0;left:0;right:0;display:flex;justify-content:space-between;align-items:flex-start;padding:12px 14px;gap:12px}
.hud-card{background:linear-gradient(135deg,rgba(10,20,34,.92),rgba(16,30,48,.88));border:1px solid rgba(255,255,255,.14);backdrop-filter:blur(10px);border-radius:14px;padding:10px 14px;box-shadow:0 8px 24px rgba(0,0,0,.42)}
.lap-box{display:flex;gap:16px;align-items:center}
.lap-big{font-size:26px;font-weight:900;line-height:1;letter-spacing:-1px}
.lap-big span{font-size:12px;font-weight:600;opacity:.6;letter-spacing:1px}
.time-col{font-variant-numeric:tabular-nums}
.time-col .t{font-size:20px;font-weight:800;letter-spacing:-.5px}
.time-col .sub{font-size:10px;opacity:.6;letter-spacing:.8px;text-transform:uppercase}
.best{font-size:11px;opacity:.8;margin-top:2px}
.speed-box{text-align:right;min-width:150px}
.speed-val{font-size:38px;font-weight:900;line-height:1;letter-spacing:-2px}
.speed-val small{font-size:11px;letter-spacing:2px;opacity:.6;font-weight:700}
.gear{font-size:11px;font-weight:800;letter-spacing:2px;background:#ff3b30;color:#fff;padding:2px 8px;border-radius:6px;display:inline-block;margin-top:4px}
.rpm-bar{width:100%;height:5px;background:rgba(255,255,255,.15);border-radius:99px;margin-top:6px;overflow:hidden}
.rpm-fill{height:100%;background:linear-gradient(90deg,#2ecc71,#f1c40f,#e74c3c);width:30%;transition:width .08s}
.bottom-hud{position:absolute;bottom:0;left:0;right:0;display:flex;justify-content:space-between;align-items:flex-end;padding:12px 14px;gap:14px}
#minimap{width:168px;height:168px;border-radius:14px;border:1px solid rgba(255,255,255,.16);background:rgba(8,16,28,.93);box-shadow:0 8px 24px rgba(0,0,0,.5)}
.controls-hint{font-size:10px;letter-spacing:.7px;opacity:.65;text-transform:uppercase;line-height:1.6;text-align:right;text-shadow:0 1px 8px rgba(0,0,0,.9)}
.controls-hint b{color:#fff}
#countdown{position:fixed;inset:0;display:flex;align-items:flex-start;justify-content:center;padding-top:28vh;z-index:20;pointer-events:none}
#countdown span{font-size:110px;font-weight:900;letter-spacing:-4px;color:#fff;text-shadow:0 4px 20px rgba(0,0,0,.85),0 0 24px rgba(0,0,0,.6);transform:scale(.7);opacity:0;transition:all .38s cubic-bezier(.34,1.56,.64,1);display:flex;align-items:center;justify-content:center}
#countdown.show span{transform:scale(1);opacity:1}
#countdown.go span{color:#2ecc71;text-shadow:0 0 28px #2ecc71, 0 4px 20px rgba(0,0,0,.8)}
#countdown.show span{transform:scale(1);opacity:1}
#countdown.go span{color:#2ecc71;border-color:rgba(46,204,113,.6);text-shadow:0 0 28px #2ecc71;background:rgba(46,204,113,.28)}
.overlay{position:fixed;inset:0;background:rgba(4,10,18,.80);backdrop-filter:blur(12px);display:flex;align-items:center;justify-content:center;z-index:30;opacity:0;pointer-events:none;transition:.3s}
.overlay.on{opacity:1;pointer-events:auto}
.modal{background:linear-gradient(180deg,#12233a,#0c1628);border:1px solid rgba(255,255,255,.13);border-radius:20px;padding:28px 26px;min-width:320px;max-width:420px;text-align:center;box-shadow:0 24px 60px rgba(0,0,0,.6)}
.modal h2{font-size:28px;letter-spacing:-1px;margin-bottom:6px}
.modal p{opacity:.7;font-size:13px;line-height:1.5;margin-bottom:16px}
.btn{appearance:none;border:0;background:#ff3b30;color:#fff;font-weight:800;letter-spacing:.8px;padding:12px 22px;border-radius:12px;cursor:pointer;font-size:14px;transition:.15s;pointer-events:auto}
.btn:hover{transform:translateY(-1px);background:#ff4d42;box-shadow:0 8px 20px rgba(255,59,48,.4)}
.btn.secondary{background:rgba(255,255,255,.11);color:#fff}
.btn-row{display:flex;gap:10px;justify-content:center;flex-wrap:wrap}
.badge{display:inline-block;font-size:10px;letter-spacing:1.2px;font-weight:800;background:rgba(255,255,255,.11);padding:4px 8px;border-radius:99px;margin-bottom:10px}
.finish-time{font-size:44px;font-weight:900;letter-spacing:-2px;margin:8px 0}
.finish-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:14px 0;text-align:left}
.fg{background:rgba(255,255,255,.06);border-radius:12px;padding:10px 12px}
.fg label{font-size:10px;letter-spacing:1px;opacity:.6;text-transform:uppercase}
.fg div{font-size:18px;font-weight:800;margin-top:2px}
#touch{position:fixed;inset:0;pointer-events:none;z-index:15;display:none}
@media(max-width:800px){#touch{display:block}.controls-hint{display:none}.speed-val{font-size:32px}#minimap{width:132px;height:132px}#countdown span{width:140px;height:140px;font-size:90px}}
.touch-group{position:absolute;pointer-events:auto}
.t-left{left:12px;bottom:18px;display:grid;grid-template-columns:52px 52px 52px;grid-template-rows:52px 52px;gap:6px}
.t-right{right:12px;bottom:18px;display:flex;flex-direction:column;gap:8px;align-items:center}
.tbtn{width:52px;height:52px;border-radius:14px;background:rgba(255,255,255,.16);border:1px solid rgba(255,255,255,.26);backdrop-filter:blur(8px);display:flex;align-items:center;justify-content:center;font-size:18px;color:#fff;user-select:none;-webkit-user-select:none;touch-action:none}
.tbtn:active{background:rgba(255,59,48,.75)}
.tbtn.wide{width:110px;height:48px;font-size:13px;font-weight:800;letter-spacing:1px}
.tbtn.hand{width:110px;height:48px;background:rgba(255,193,7,.26);border-color:rgba(255,193,7,.5)}
</style>
<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js"}}</script>
</head>
<body>
<canvas id="canvas"></canvas>
<div id="hud">
  <div class="top-bar">
    <div class="hud-card lap-box">
      <div>
        <div class="lap-big" id="lapTxt">1 <span>/ 3</span></div>
        <div style="font-size:10px;letter-spacing:1px;opacity:.6;text-transform:uppercase;margin-top:2px">Rally Lap</div>
      </div>
      <div style="width:1px;height:42px;background:rgba(255,255,255,.12)"></div>
      <div class="time-col">
        <div class="t" id="timerTxt">00:00.0</div>
        <div class="sub">Current</div>
        <div class="best" id="bestTxt">Best — --:--.-</div>
        <div class="best" id="lastTxt" style="opacity:.5"></div>
      </div>
    </div>
    <div class="hud-card speed-box">
      <div class="speed-val" id="speedTxt">0 <small>KM/H</small></div>
      <div class="gear" id="gearTxt">N</div>
      <div class="rpm-bar"><div class="rpm-fill" id="rpmFill"></div></div>
      <div style="font-size:10px;letter-spacing:1px;opacity:.55;margin-top:4px" id="surfaceTxt">ASPHALT • GRIP 100%</div>
    </div>
  </div>
  <div class="bottom-hud">
    <canvas id="minimap" width="168" height="168"></canvas>
    <div class="controls-hint">
      <b>WASD / Arrows</b> Drive &nbsp; <b>Space</b> Handbrake<br>
      <b>R</b> Restart &nbsp; <b>P</b> Pause &nbsp; <b>C</b> Camera<br>
      Stay on track — checkpoints prevent shortcuts
    </div>
  </div>
</div>
<div id="countdown"><span id="cdTxt">3</span></div>
<div class="overlay" id="pauseOverlay">
  <div class="modal">
    <div class="badge">Paused</div>
    <h2>Rally Paused</h2>
    <p>Take a breath. The clock is stopped.</p>
    <div class="btn-row"><button class="btn" id="resumeBtn">Resume [P]</button><button class="btn secondary" id="restartBtn2">Restart [R]</button></div>
  </div>
</div>
<div class="overlay" id="finishOverlay">
  <div class="modal" style="min-width:360px">
    <div class="badge" style="background:#2ecc71;color:#fff">🏁 Finished</div>
    <h2>Rally Complete!</h2>
    <div class="finish-time" id="finishTotal">00:00.0</div>
    <div class="finish-grid">
      <div class="fg"><label>Best Lap</label><div id="finishBest">--</div></div>
      <div class="fg"><label>Total Laps</label><div>3 / 3</div></div>
      <div class="fg"><label>Lap 1</label><div id="fL1">--</div></div>
      <div class="fg"><label>Lap 2</label><div id="fL2">--</div></div>
      <div class="fg" style="grid-column:span 2;text-align:center"><label>Lap 3</label><div id="fL3">--</div></div>
    </div>
    <div class="btn-row"><button class="btn" id="playAgainBtn">Race Again</button></div>
    <p style="margin-top:12px;opacity:.45">Tip: drift on dirt for faster cornering — but don't cut checkpoints!</p>
  </div>
</div>
<div id="touch">
  <div class="touch-group t-left">
    <div></div><button class="tbtn" data-k="up">▲</button><div></div>
    <button class="tbtn" data-k="left">◀</button><button class="tbtn" data-k="down">▼</button><button class="tbtn" data-k="right">▶</button>
  </div>
  <div class="touch-group t-right">
    <button class="tbtn wide" data-k="gas">GAS</button>
    <button class="tbtn wide" data-k="brake">BRAKE</button>
    <button class="tbtn hand" data-k="hand">HANDBRAKE</button>
  </div>
</div>
<script type="module">
import * as THREE from 'three';

/* ===== Config ===== */
const TOTAL_LAPS = 3;
const TRACK_WIDTH = 13;
const SHOULDER = 3.8;
const CHECKPOINTS = 16;

/* ===== State ===== */
let renderer, scene, camera, sun;
let car, wheels=[], chassis;
let trackCurve, trackPoints=[];
let obstacles=[], barriers=[];
let particles=[], skidDecals=[];
let mctx;
let keys={}, camMode=0;
let gameState='countdown';
let lap=1, nextCheckpoint=1;
let lapStartTime=0, raceStartTime=0, bestLap=Infinity, lapTimes=[];
let speed=0, heading=0, pos=new THREE.Vector2();
let steer=0, handbrake=false;
let gear=1, driftIntensity=0, surface='ASPHALT', grip=1;
let audioCtx, engineOsc, engineGain, masterGain;
let lastTime=0, countdownVal=3, countdownTimer=0;
let skidTimer=0, dustTimer=0;
let camPos=new THREE.Vector3(), camTarget=new THREE.Vector3();

/* ===== Three Setup ===== */
const canvas=document.getElementById('canvas');
renderer=new THREE.WebGLRenderer({canvas, antialias:true, powerPreference:'high-performance'});
renderer.setPixelRatio(Math.min(devicePixelRatio,2));
renderer.shadowMap.enabled=true;
renderer.shadowMap.type=THREE.PCFSoftShadowMap;
renderer.toneMapping=THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure=1.08;
scene=new THREE.Scene();
scene.background=new THREE.Color(0x8ecaf2);
scene.fog=new THREE.Fog(0xa6d6f5, 140, 680);
camera=new THREE.PerspectiveCamera(62, innerWidth/innerHeight, 0.1, 1000);
addEventListener('resize',()=>{
  camera.aspect=innerWidth/innerHeight; camera.updateProjectionMatrix();
  renderer.setSize(innerWidth,innerHeight);
});
renderer.setSize(innerWidth,innerHeight);

// lights — brighter, warmer
scene.add(new THREE.HemisphereLight(0xffffff, 0x3d6b2e, 1.15));
sun=new THREE.DirectionalLight(0xfff4e0, 1.6);
sun.position.set(160,220,90);
sun.castShadow=true;
sun.shadow.mapSize.set(2048,2048);
sun.shadow.camera.near=2; sun.shadow.camera.far=700;
sun.shadow.camera.left=-300; sun.shadow.camera.right=300; sun.shadow.camera.top=300; sun.shadow.camera.bottom=-300;
sun.shadow.bias=-0.0008;
scene.add(sun);

// sky gradient dome
const skyGeo=new THREE.SphereGeometry(850,32,16);
const skyMat=new THREE.ShaderMaterial({
  side:THREE.BackSide, fog:false,
  vertexShader:`varying vec3 vP;void main(){vP=position;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);}`,
  fragmentShader:`varying vec3 vP;void main(){
    float h = normalize(vP).y;
    vec3 top = vec3(0.52,0.78,0.94);
    vec3 bot = vec3(0.72,0.88,0.96);
    vec3 col = mix(bot, top, clamp(h*1.4+0.15,0.,1.));
    // subtle horizon haze
    col = mix(col, vec3(0.92,0.94,0.96), pow(max(0., -h*2.+0.3),2.)*0.5);
    gl_FragColor=vec4(col,1.);
  }`
});
scene.add(new THREE.Mesh(skyGeo, skyMat));

// ground — flat green field at y=0, track sits on top; no hills to cause gaps
const groundGeo=new THREE.PlaneGeometry(3000,3000, 2,2);
const groundMesh=new THREE.Mesh(groundGeo, new THREE.MeshLambertMaterial({color:0x3d8f2d}));
groundMesh.rotation.x=-Math.PI/2;
groundMesh.position.y=-0.02;
groundMesh.receiveShadow=true;
scene.add(groundMesh);

// distant treeline ring
for(let a=0;a<Math.PI*2;a+=0.10){
  const r= 1100 + Math.random()*200;
  const x=Math.cos(a)*r, z=Math.sin(a)*r;
  const s= 2.0 + Math.random()*1.6;
  const g=new THREE.Group();
  const trunk=new THREE.Mesh(new THREE.CylinderGeometry(0.5,0.7,3.5,5), new THREE.MeshLambertMaterial({color:0x4a3418}));
  trunk.position.y=1.8; g.add(trunk);
  const cone=new THREE.Mesh(new THREE.ConeGeometry(2.8,5,6), new THREE.MeshLambertMaterial({color:0x1a5a22}));
  cone.position.y=4.8; g.add(cone);
  g.position.set(x, -0.1, z);
  g.scale.setScalar(s*0.55);
  scene.add(g);
}

/* ===== Track ===== */
const ctrlPoints=[
  {x:0, z:0, y:0.4, w:15},
  {x:74, z:-42, y:4.8, w:14},
  {x:152, z:-22, y:7.8, w:13},
  {x:208, z:44, y:2.8, w:14},
  {x:176, z:128, y:-0.4, w:15},
  {x:86, z:158, y:6.2, w:14},
  {x:-16, z:138, y:9.2, w:13},
  {x:-86, z:76, y:3.8, w:14},
  {x:-112, z:0, y:-1.2, w:15},
  {x:-72, z:-80, y:1.6, w:14},
  {x:16, z:-112, y:6.8, w:14},
  {x:86, z:-92, y:2.2, w:13},
];
function buildTrack(){
  const pts=ctrlPoints.map(p=> new THREE.Vector3(p.x, p.y, p.z));
  pts.push(pts[0].clone());
  trackCurve=new THREE.CatmullRomCurve3(pts, true, 'catmullrom', 0.42);
  const SAMPLES=440;
  trackPoints=[];
  for(let i=0;i<SAMPLES;i++){
    const t=i/SAMPLES;
    trackPoints.push({p:trackCurve.getPoint(t), tan:trackCurve.getTangent(t).normalize(), t});
  }
  const verts=[], uvs=[], indices=[], colors=[];
  const widths=trackPoints.map((_,i)=> ctrlPoints[Math.floor(i/(SAMPLES/ctrlPoints.length))%ctrlPoints.length].w);
  // use ctrlPoints y directly — no groundH offset needed, track defines its own height
  for(let i=0;i<SAMPLES;i++){
    const tp=trackPoints[i];
    const w=widths[i]/2;
    const nx=-tp.tan.z, nz=tp.tan.x; const len=Math.hypot(nx,nz)||1; const lx=nx/len,lz=nz/len;
    const left=new THREE.Vector3(tp.p.x+lx*w, tp.p.y+0.14, tp.p.z+lz*w);
    const right=new THREE.Vector3(tp.p.x-lx*w, tp.p.y+0.14, tp.p.z-lz*w);
    const s=w+SHOULDER;
    const sL=new THREE.Vector3(tp.p.x+lx*s, tp.p.y+0.10, tp.p.z+lz*s);
    const sR=new THREE.Vector3(tp.p.x-lx*s, tp.p.y+0.10, tp.p.z-lz*s);
    verts.push(sL.x,sL.y,sL.z, left.x,left.y,left.z, right.x,right.y,right.z, sR.x,sR.y,sR.z);
    const v=i/SAMPLES; uvs.push(0,v, 0.24,v, 0.76,v, 1,v);
    const seg=Math.floor(i/38)%5;
    const isDirt=seg===2;
    // shoulder gravel, asphalt/dirt, asphalt/dirt, shoulder
    colors.push(0.58,0.52,0.38);
    if(isDirt) colors.push(0.62,0.50,0.30); else colors.push(0.19,0.19,0.21);
    if(isDirt) colors.push(0.62,0.50,0.30); else colors.push(0.19,0.19,0.21);
    colors.push(0.58,0.52,0.38);
  }
  for(let i=0;i<SAMPLES;i++){
    const ni=(i+1)%SAMPLES, a=i*4, b=ni*4;
    indices.push(a,b,a+1, a+1,b,b+1);
    indices.push(a+1,b+1,a+2, a+2,b+1,b+2);
    indices.push(a+2,b+2,a+3, a+3,b+2,b+3);
  }
  const geo=new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.Float32BufferAttribute(verts,3));
  geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs,2));
  geo.setAttribute('color', new THREE.Float32BufferAttribute(colors,3));
  geo.setIndex(indices);
  geo.computeVertexNormals();
  const mesh=new THREE.Mesh(geo, new THREE.MeshLambertMaterial({vertexColors:true}));
  mesh.receiveShadow=true;
  scene.add(mesh);

  // crisp white dashes
  const dashMat=new THREE.MeshLambertMaterial({color:0xffffff});
  for(let i=0;i<SAMPLES;i+=14){
    const p=trackCurve.getPoint(i/SAMPLES);
    const t=trackCurve.getTangent(i/SAMPLES);
    const box=new THREE.Mesh(new THREE.BoxGeometry(0.88,0.06,2.3), dashMat);
    box.position.set(p.x, p.y+0.16, p.z);
    box.rotation.y=Math.atan2(t.x,t.z);
    scene.add(box);
  }
  // red/white curbs — flush with track
  for(let i=0;i<SAMPLES;i+=2){
    const tp=trackPoints[i], nxt=trackPoints[(i+3)%SAMPLES];
    const curv=Math.hypot(nxt.tan.x-tp.tan.x, nxt.tan.z-tp.tan.z);
    if(curv>0.05){
      const nx=-tp.tan.z, nz=tp.tan.x; const len=Math.hypot(nx,nz)||1; const lx=nx/len,lz=nz/len;
      const w=widths[i]/2;
      for(let side of [-1,1]){
        for(let k=0;k<3;k++){
          const off=w+0.32+k*0.50;
          const bx=tp.p.x+lx*off*side, bz=tp.p.z+lz*off*side;
          const curb=new THREE.Mesh(new THREE.BoxGeometry(0.50,0.14,1.6), new THREE.MeshLambertMaterial({color:(k%2===0)?0xe53935:0xffffff}));
          curb.position.set(bx, tp.p.y+0.13, bz);
          curb.rotation.y=Math.atan2(tp.tan.x,tp.tan.z);
          curb.receiveShadow=true; scene.add(curb);
        }
      }
    }
  }
  // start/finish line
  const sp=trackPoints[0];
  const t0=sp.tan; const nx=-t0.z, nz=t0.x; const len=Math.hypot(nx,nz)||1; const lx=nx/len,lz=nz/len;
  const w=widths[0]/2+SHOULDER;
  for(let k=-w;k<w;k+=1.0){
    const isWhite=Math.floor((k+w)/1.0)%2===0;
    const m=new THREE.Mesh(new THREE.BoxGeometry(0.96,0.06,1.3), new THREE.MeshBasicMaterial({color:isWhite?0xffffff:0x111111}));
    m.position.set(sp.p.x+lx*k, sp.p.y+0.20, sp.p.z+lz*k);
    m.rotation.y=Math.atan2(t0.x,t0.z);
    scene.add(m);
  }
  // gantry banner
  const arch=new THREE.Group();
  const poleGeo=new THREE.CylinderGeometry(0.40,0.40,10,8);
  const poleMat=new THREE.MeshLambertMaterial({color:0xe8e8e8});
  const poleL=new THREE.Mesh(poleGeo,poleMat); poleL.position.set(sp.p.x+lx*11, sp.p.y+5.0, sp.p.z+lz*11); poleL.castShadow=true;
  const poleR=new THREE.Mesh(poleGeo,poleMat); poleR.position.set(sp.p.x-lx*11, sp.p.y+5.0, sp.p.z-lz*11); poleR.castShadow=true;
  arch.add(poleL,poleR);
  const cvs=document.createElement('canvas'); cvs.width=512; cvs.height=128;
  const ctx=cvs.getContext('2d');
  ctx.fillStyle='#ff3b30'; ctx.fillRect(0,0,512,128);
  ctx.fillStyle='#fff'; ctx.font='900 44px system-ui,sans-serif'; ctx.textAlign='center'; ctx.textBaseline='middle';
  ctx.fillText('RALLY  RUSH',256,70);
  const tex=new THREE.CanvasTexture(cvs); tex.colorSpace=THREE.SRGBColorSpace;
  const banner=new THREE.Mesh(new THREE.BoxGeometry(18,2.6,0.9), new THREE.MeshLambertMaterial({map:tex}));
  banner.position.set(sp.p.x, sp.p.y+9.2, sp.p.z);
  banner.rotation.y=Math.atan2(t0.x,t0.z);
  arch.add(banner);
  scene.add(arch);
}
buildTrack();

/* ===== Scenery ===== */
function groundH(x,z){ return Math.sin(x*0.005)*9 + Math.cos(z*0.006)*9 + Math.sin(x*0.012+z*0.009)*5 -1.0; }
function addTree(x,z,scale=1){
  const g=new THREE.Group();
  const trunk=new THREE.Mesh(new THREE.CylinderGeometry(0.28*scale,0.44*scale,2.8*scale,6), new THREE.MeshLambertMaterial({color:0x5a3418}));
  trunk.position.y=1.4*scale; trunk.castShadow=true; trunk.receiveShadow=true;
  const coneMat=new THREE.MeshLambertMaterial({color:0x1e6b2a});
  const c1=new THREE.Mesh(new THREE.ConeGeometry(1.95*scale,3.8*scale,7), coneMat); c1.position.y=4.0*scale; c1.castShadow=true;
  const c2=new THREE.Mesh(new THREE.ConeGeometry(1.45*scale,2.9*scale,7), coneMat); c2.position.y=6.0*scale; c2.castShadow=true;
  g.add(trunk,c1,c2); g.position.set(x, groundH(x,z)+0.05, z);
  scene.add(g);
  obstacles.push({x,z,r:1.8*scale});
}
function addRock(x,z,s=1){
  const m=new THREE.Mesh(new THREE.DodecahedronGeometry(1.0*s,0), new THREE.MeshLambertMaterial({color:0x7e7e7e}));
  m.position.set(x, groundH(x,z)+0.5*s, z); m.rotation.set(Math.random()*Math.PI, Math.random()*Math.PI, 0);
  m.castShadow=true; scene.add(m); obstacles.push({x,z,r:1.25*s});
}
function addBush(x,z,s=1){
  const m=new THREE.Mesh(new THREE.SphereGeometry(1.0*s,7,6), new THREE.MeshLambertMaterial({color:0x2e7a2e}));
  m.position.set(x, groundH(x,z)+0.6*s, z); m.scale.y=0.65; m.castShadow=true; scene.add(m);
  obstacles.push({x,z,r:1.0*s});
}
// dense but not too close
for(let i=0;i<trackPoints.length;i+=4){
  const tp=trackPoints[i];
  const nx=-tp.tan.z, nz=tp.tan.x; const len=Math.hypot(nx,nz)||1; const lx=nx/len,lz=nz/len;
  for(let side of [-1,1]){
    if(Math.random()<0.35) continue;
    const dist= TRACK_WIDTH/2+SHOULDER+ 8 + Math.random()*32;
    const tx=tp.p.x+lx*dist*side + (Math.random()-0.5)*4;
    const tz=tp.p.z+lz*dist*side + (Math.random()-0.5)*4;
    const r=Math.random();
    if(r<0.58) addTree(tx,tz, 0.85+Math.random()*0.6);
    else if(r<0.80) addRock(tx,tz, 0.65+Math.random()*0.7);
    else addBush(tx,tz, 0.85+Math.random()*0.45);
  }
}
// guard rails
for(let i=0;i<trackPoints.length;i+=3){
  const tp=trackPoints[i], nxt=trackPoints[(i+3)%trackPoints.length];
  const curv=Math.hypot(nxt.tan.x-tp.tan.x, nxt.tan.z-tp.tan.z);
  if(curv>0.065 && Math.random()<0.65){
    const nx=-tp.tan.z, nz=tp.tan.x; const len=Math.hypot(nx,nz)||1; const lx=nx/len,lz=nz/len;
    const off=TRACK_WIDTH/2+SHOULDER+0.6;
    for(let s of [-1,1]){
      if(Math.random()<0.4) continue;
      const bx=tp.p.x+lx*off*s, bz=tp.p.z+lz*off*s;
      const rail=new THREE.Mesh(new THREE.BoxGeometry(0.24,0.78,3.4), new THREE.MeshLambertMaterial({color:0xdddddd}));
      rail.position.set(bx, tp.p.y+0.58, bz);
      rail.rotation.y=Math.atan2(tp.tan.x,tp.tan.z);
      rail.castShadow=true; scene.add(rail);
      barriers.push({x:bx,z:bz,r:1.6});
    }
  }
}
// sponsor signs
for(let i=0;i<4;i++){
  const t=i/4+0.07; const p=trackCurve.getPoint(t); const tan=trackCurve.getTangent(t);
  const nx=-tan.z, nz=tan.x; const len=Math.hypot(nx,nz)||1; const lx=nx/len,lz=nz/len;
  const sx=p.x+lx*19, sz=p.z+lz*19;
  const sign=new THREE.Mesh(new THREE.BoxGeometry(7,2.8,0.32), new THREE.MeshLambertMaterial({color: i%2?0x2a7fff:0xffc107}));
  sign.position.set(sx, p.y+3.2, sz); sign.rotation.y=Math.atan2(tan.x,tan.z)+Math.PI/2;
  scene.add(sign);
}

/* ===== Car ===== */
function buildCar(){
  car=new THREE.Group(); chassis=new THREE.Group();
  const bodyMat=new THREE.MeshStandardMaterial({color:0xe53935, roughness:0.30, metalness:0.18});
  const body=new THREE.Mesh(new THREE.BoxGeometry(1.90,0.70,3.60), bodyMat); body.position.y=0.80; body.castShadow=true; chassis.add(body);
  const stripe=new THREE.Mesh(new THREE.BoxGeometry(0.50,0.06,3.62), new THREE.MeshStandardMaterial({color:0xffffff, roughness:0.4}));
  stripe.position.set(0,1.06,0); chassis.add(stripe);
  // number decal
  const numCvs=document.createElement('canvas'); numCvs.width=128; numCvs.height=128;
  const nctx=numCvs.getContext('2d'); nctx.fillStyle='#ffffff'; nctx.fillRect(0,0,128,128);
  nctx.fillStyle='#e53935'; nctx.font='900 72px system-ui'; nctx.textAlign='center'; nctx.textBaseline='middle'; nctx.fillText('7',64,72);
  const numTex=new THREE.CanvasTexture(numCvs); numTex.colorSpace=THREE.SRGBColorSpace;
  const numPlane=new THREE.Mesh(new THREE.PlaneGeometry(0.7,0.7), new THREE.MeshBasicMaterial({map:numTex, transparent:true}));
  numPlane.position.set(0.96,0.82, -0.1); numPlane.rotation.y=Math.PI/2; chassis.add(numPlane);
  const num2=numPlane.clone(); num2.position.x=-0.96; num2.rotation.y=-Math.PI/2; chassis.add(num2);
  const cabin=new THREE.Mesh(new THREE.BoxGeometry(1.64,0.60,1.80), new THREE.MeshStandardMaterial({color:0x18202e, roughness:0.5}));
  cabin.position.set(0,1.24,-0.10); cabin.castShadow=true; chassis.add(cabin);
  const win=new THREE.Mesh(new THREE.BoxGeometry(1.66,0.34,1.10), new THREE.MeshStandardMaterial({color:0x9fd8ff, roughness:0.18, metalness:0.12, transparent:true, opacity:0.82}));
  win.position.set(0,1.30,0.04); chassis.add(win);
  const spoiler=new THREE.Mesh(new THREE.BoxGeometry(1.92,0.11,0.38), new THREE.MeshStandardMaterial({color:0x111111})); spoiler.position.set(0,1.09,-1.70); chassis.add(spoiler);
  const legGeo=new THREE.BoxGeometry(0.10,0.44,0.24), legMat=new THREE.MeshStandardMaterial({color:0x111111});
  const l1=new THREE.Mesh(legGeo,legMat); l1.position.set(-0.60,0.88,-1.70); chassis.add(l1);
  const l2=l1.clone(); l2.position.x=0.60; chassis.add(l2);
  const hlMat=new THREE.MeshStandardMaterial({color:0xffffe0, emissive:0xffffcc, emissiveIntensity:0.9});
  const hlL=new THREE.Mesh(new THREE.SphereGeometry(0.19,10,10), hlMat); hlL.position.set(-0.56,0.72,1.80); chassis.add(hlL);
  const hlR=hlL.clone(); hlR.position.x=0.56; chassis.add(hlR);
  // headlight glow
  const glowGeo=new THREE.CircleGeometry(0.32,12);
  const glowMat=new THREE.MeshBasicMaterial({color:0xffffcc, transparent:true, opacity:0.18});
  const gL=new THREE.Mesh(glowGeo, glowMat); gL.position.set(-0.56,0.72,1.92); gL.rotation.y=Math.PI; chassis.add(gL);
  const gR=gL.clone(); gR.position.x=0.56; chassis.add(gR);
  const tlMat=new THREE.MeshStandardMaterial({color:0xff1a1a, emissive:0xff1111, emissiveIntensity:1.0});
  const tlL=new THREE.Mesh(new THREE.SphereGeometry(0.15,8,8), tlMat); tlL.position.set(-0.62,0.73,-1.80); chassis.add(tlL);
  const tlR=tlL.clone(); tlR.position.x=0.62; chassis.add(tlR);
  const wheelGeo=new THREE.CylinderGeometry(0.41,0.41,0.48,14);
  const wheelMat=new THREE.MeshStandardMaterial({color:0x151515, roughness:0.85});
  [[-1.02,0.41,1.06],[1.02,0.41,1.06],[-1.02,0.41,-1.06],[1.02,0.41,-1.06]].forEach(p=>{
    const w=new THREE.Mesh(wheelGeo,wheelMat); w.rotation.z=Math.PI/2; w.position.set(p[0],p[1],p[2]); w.castShadow=true; chassis.add(w); wheels.push(w);
  });
  const shadow=new THREE.Mesh(new THREE.CircleGeometry(1.62,20), new THREE.MeshBasicMaterial({color:0x000000, transparent:true, opacity:0.30}));
  shadow.rotation.x=-Math.PI/2; shadow.position.y=0.04; chassis.add(shadow);
  car.add(chassis); scene.add(car);
  const sp0=trackPoints[0]; pos.set(sp0.p.x, sp0.p.z); heading=Math.atan2(sp0.tan.x, sp0.tan.z);
}
buildCar();

/* ===== Particles ===== */
const dustGeo=new THREE.SphereGeometry(0.19,6,6);
function spawnDust(x,y,z){
  const m=new THREE.Mesh(dustGeo, new THREE.MeshBasicMaterial({color:0xc8b89a, transparent:true, opacity:0.68}));
  m.position.set(x,y,z); m.scale.setScalar(0.6+Math.random()*0.85);
  scene.add(m);
  particles.push({mesh:m, vel:new THREE.Vector3((Math.random()-0.5)*1.1, 0.55+Math.random()*0.7, (Math.random()-0.5)*1.1), life:0.72, decay:0.95+Math.random()*0.55});
}
const sparkGeo=new THREE.SphereGeometry(0.08,4,4);
function spawnSparks(x,y,z){
  for(let i=0;i<6;i++){
    const m=new THREE.Mesh(sparkGeo, new THREE.MeshBasicMaterial({color:0xffcc00}));
    m.position.set(x,y,z); scene.add(m);
    particles.push({mesh:m, vel:new THREE.Vector3((Math.random()-0.5)*4, Math.random()*2.2, (Math.random()-0.5)*4), life:0.34, decay:2.6});
  }
}
function spawnSkid(x,z,angle){
  const g=new THREE.Mesh(new THREE.PlaneGeometry(0.34,1.10), new THREE.MeshBasicMaterial({color:0x111111, transparent:true, opacity:0.52, depthWrite:false}));
  g.rotation.x=-Math.PI/2; g.rotation.z=angle; g.position.set(x,0.11,z);
  scene.add(g); skidDecals.push({mesh:g});
  if(skidDecals.length>140){ const o=skidDecals.shift(); scene.remove(o.mesh); }
}

/* ===== Audio ===== */
function initAudio(){
  if(audioCtx) return;
  audioCtx=new (window.AudioContext||window.webkitAudioContext)();
  masterGain=audioCtx.createGain(); masterGain.gain.value=0.18; masterGain.connect(audioCtx.destination);
  engineOsc=audioCtx.createOscillator(); engineOsc.type='sawtooth'; engineOsc.frequency.value=78;
  engineGain=audioCtx.createGain(); engineGain.gain.value=0;
  const f=audioCtx.createBiquadFilter(); f.type='lowpass'; f.frequency.value=920;
  engineOsc.connect(f); f.connect(engineGain); engineGain.connect(masterGain);
  engineOsc.start();
}
function beep(freq=800,dur=0.12,vol=0.4){
  if(!audioCtx) return;
  const o=audioCtx.createOscillator(), g=audioCtx.createGain();
  o.frequency.value=freq; o.connect(g); g.connect(masterGain); g.gain.value=vol;
  g.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime+dur);
  o.start(); o.stop(audioCtx.currentTime+dur);
}
function thud(){
  if(!audioCtx) return;
  const o=audioCtx.createOscillator(), g=audioCtx.createGain();
  o.frequency.value=120; o.type='square'; g.gain.value=0.5;
  o.connect(g); g.connect(masterGain);
  g.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime+0.2);
  o.start(); o.stop(audioCtx.currentTime+0.2);
}

/* ===== Input ===== */
addEventListener('keydown',e=>{
  const k=e.key.toLowerCase();
  keys[k]=true;
  if(k==='p') togglePause();
  if(k==='r') restartRace();
  if(k==='c'){ camMode=(camMode+1)%2; beep(600,0.08,0.3); }
  if(['arrowup','w','arrowdown','s','arrowleft','a','arrowright','d',' '].includes(k)){
    if(!audioCtx) initAudio(); if(audioCtx && audioCtx.state==='suspended') audioCtx.resume();
  }
});
addEventListener('keyup',e=> keys[e.key.toLowerCase()]=false);
addEventListener('blur',()=> keys={});
document.querySelectorAll('.tbtn').forEach(b=>{
  const k=b.dataset.k;
  const set=v=>{
    if(k==='up') keys['w']=v, keys['arrowup']=v;
    if(k==='down') keys['s']=v, keys['arrowdown']=v;
    if(k==='left') keys['a']=v, keys['arrowleft']=v;
    if(k==='right') keys['d']=v, keys['arrowright']=v;
    if(k==='gas') keys['w']=v;
    if(k==='brake') keys['s']=v;
    if(k==='hand') keys[' ']=v;
  };
  b.addEventListener('touchstart',e=>{e.preventDefault(); set(true); if(!audioCtx) initAudio();},{passive:false});
  b.addEventListener('touchend',e=>{e.preventDefault(); set(false);},{passive:false});
  b.addEventListener('mousedown',()=>set(true));
  b.addEventListener('mouseup',()=>set(false));
  b.addEventListener('mouseleave',()=>set(false));
});

/* ===== Helpers ===== */
function closestTrackInfo(x,z){
  let best=0, bestD=Infinity;
  for(let i=0;i<trackPoints.length;i++){
    const dx=x-trackPoints[i].p.x, dz=z-trackPoints[i].p.z;
    const d=dx*dx+dz*dz;
    if(d<bestD){bestD=d; best=i;}
  }
  const tp=trackPoints[best];
  const w=ctrlPoints[Math.floor(best/(trackPoints.length/ctrlPoints.length))%ctrlPoints.length].w;
  const nx=-tp.tan.z, nz=tp.tan.x; const len=Math.hypot(nx,nz)||1; const lx=nx/len,lz=nz/len;
  const dx=x-tp.p.x, dz=z-tp.p.z;
  const lateral= dx*lx + dz*lz;
  const along= dx*tp.tan.x + dz*tp.tan.z;
  const progress=best/trackPoints.length + (along>0? 0.5/trackPoints.length:0);
  return {idx:best, lateral, dist:Math.sqrt(bestD), width:w, height:tp.p.y, tangent:tp.tan, progress:(progress+1)%1};
}
function formatTime(ms){
  const s=ms/1000, m=Math.floor(s/60).toString().padStart(2,'0');
  return `${m}:${(s%60).toFixed(1).padStart(4,'0')}`;
}

/* ===== Minimap ===== */
const minimap=document.getElementById('minimap'); mctx=minimap.getContext('2d');
function drawMinimap(){
  const w=minimap.width, h=minimap.height;
  mctx.clearRect(0,0,w,h);
  mctx.fillStyle='#0a1a2a'; mctx.fillRect(0,0,w,h);
  let minX=Infinity,maxX=-Infinity,minZ=Infinity,maxZ=-Infinity;
  trackPoints.forEach(tp=>{ minX=Math.min(minX,tp.p.x); maxX=Math.max(maxX,tp.p.x); minZ=Math.min(minZ,tp.p.z); maxZ=Math.max(maxZ,tp.p.z); });
  const pad=16, sx=(w-pad*2)/(maxX-minX), sz=(h-pad*2)/(maxZ-minZ), sc=Math.min(sx,sz);
  const ox=(w-(maxX-minX)*sc)/2 - minX*sc, oz=(h-(maxZ-minZ)*sc)/2 - minZ*sc;
  const tx=x=> x*sc+ox, tz=z=> z*sc+oz;
  mctx.strokeStyle='rgba(255,255,255,.14)'; mctx.lineWidth=18; mctx.lineCap='round'; mctx.lineJoin='round';
  mctx.beginPath(); trackPoints.forEach((tp,i)=>{ if(i===0) mctx.moveTo(tx(tp.p.x),tz(tp.p.z)); else mctx.lineTo(tx(tp.p.x),tz(tp.p.z)); }); mctx.closePath(); mctx.stroke();
  mctx.strokeStyle='#2a2a2e'; mctx.lineWidth=10; mctx.stroke();
  mctx.strokeStyle='rgba(255,255,255,.92)'; mctx.setLineDash([4,6]); mctx.lineWidth=1.15; mctx.stroke(); mctx.setLineDash([]);
  for(let i=0;i<CHECKPOINTS;i++){
    const idx=Math.floor(i/CHECKPOINTS*trackPoints.length), tp=trackPoints[idx];
    const isNext=i===nextCheckpoint%CHECKPOINTS;
    mctx.fillStyle=isNext?'#2ecc71':'rgba(255,255,255,.35)';
    mctx.beginPath(); mctx.arc(tx(tp.p.x),tz(tp.p.z), isNext?4:2.6,0,Math.PI*2); mctx.fill();
  }
  mctx.fillStyle='#ff3b30'; mctx.strokeStyle='#fff'; mctx.lineWidth=1.5;
  mctx.beginPath(); mctx.arc(tx(pos.x),tz(pos.y), 5,0,Math.PI*2); mctx.fill(); mctx.stroke();
  const dirX=Math.sin(heading)*8, dirZ=Math.cos(heading)*8;
  mctx.strokeStyle='#ff3b30'; mctx.lineWidth=2;
  mctx.beginPath(); mctx.moveTo(tx(pos.x),tz(pos.y)); mctx.lineTo(tx(pos.x+dirX),tz(pos.y+dirZ)); mctx.stroke();
}

/* ===== Flow ===== */
const cdEl=document.getElementById('countdown'), cdTxt=document.getElementById('cdTxt');
function startCountdown(){
  gameState='countdown'; countdownVal=3; countdownTimer=0;
  cdEl.style.display='flex'; cdEl.className=''; cdTxt.textContent='3';
  requestAnimationFrame(()=> cdEl.classList.add('show'));
  beep(700,0.18,0.5);
}
function updateCountdown(dt){
  countdownTimer+=dt;
  if(countdownTimer>1){
    countdownTimer=0; countdownVal--;
    if(countdownVal>0){
      cdTxt.textContent=countdownVal; cdEl.classList.remove('show'); void cdEl.offsetWidth; cdEl.classList.add('show');
      beep(700,0.18,0.5);
    } else if(countdownVal===0){
      cdTxt.textContent='GO!'; cdEl.classList.add('go'); cdEl.classList.remove('show'); void cdEl.offsetWidth; cdEl.classList.add('show');
      beep(1100,0.35,0.6);
      gameState='racing'; raceStartTime=performance.now(); lapStartTime=raceStartTime;
      setTimeout(()=>{ cdEl.style.display='none'; cdEl.classList.remove('show','go'); }, 700);
    }
  }
}
function togglePause(){
  if(gameState==='finished') return;
  if(gameState==='paused'){ gameState='racing'; document.getElementById('pauseOverlay').classList.remove('on'); }
  else if(gameState==='racing'){ gameState='paused'; document.getElementById('pauseOverlay').classList.add('on'); }
}
function restartRace(){
  lap=1; nextCheckpoint=1; bestLap=Infinity; lapTimes=[];
  speed=0; driftIntensity=0;
  const sp0=trackPoints[0]; pos.set(sp0.p.x, sp0.p.z); heading=Math.atan2(sp0.tan.x, sp0.tan.z);
  document.getElementById('finishOverlay').classList.remove('on');
  document.getElementById('pauseOverlay').classList.remove('on');
  skidDecals.forEach(d=> scene.remove(d.mesh)); skidDecals=[];
  startCountdown();
}
document.getElementById('resumeBtn').onclick=togglePause;
document.getElementById('restartBtn2').onclick=restartRace;
document.getElementById('playAgainBtn').onclick=restartRace;

/* ===== Physics ===== */
function updatePhysics(dt){
  const info=closestTrackInfo(pos.x, pos.y);
  const absLat=Math.abs(info.lateral);
  if(absLat < info.width/2){ surface='ASPHALT'; grip=1.0; }
  else if(absLat < info.width/2+SHOULDER){ surface='GRAVEL'; grip=0.62; }
  else if(absLat < info.width/2+SHOULDER+6){ surface='GRASS'; grip=0.38; }
  else { surface='MUD'; grip=0.28; }

  const up=keys['w']||keys['arrowup'], down=keys['s']||keys['arrowdown'];
  const left=keys['a']||keys['arrowleft'], right=keys['d']||keys['arrowright'];
  handbrake=!!keys[' '];
  const throttle= up?1:0, brake= down?1:0;
  steer=(left?-1:0)+(right?1:0);

  const kmh=Math.abs(speed)*3.6*18;
  const steerFactor= handbrake?1.08: Math.max(0.36, 1 - kmh/240);
  const targetSteer= steer * 0.046 * steerFactor;
  const speedNorm=Math.min(1, Math.abs(speed)/9);
  const driftBase= handbrake?0.92 : (speedNorm>0.48 && Math.abs(steer)>0.5 ? 0.38 : 0);
  const effectiveGrip= grip * (handbrake?0.42:1);
  driftIntensity= THREE.MathUtils.lerp(driftIntensity, driftBase*(1-effectiveGrip+0.6), 0.13);
  const slide= driftIntensity * speedNorm * 0.92;
  const steerPower=(1 - slide*0.5) * (effectiveGrip*0.7+0.3);
  heading += targetSteer * (speed>0.2? Math.sign(speed):1) * steerPower * (60*dt) * (throttle||brake?1:0.35);
  if(slide>0.1) heading += slide * steer * 0.018 * (60*dt);

  const maxSpeed= 9.6 * (0.55+ grip*0.45);
  const accel= 18.5 * (handbrake?0.5:1) * (grip*0.6+0.4);
  const drag= 0.9 + (1-grip)*0.6;
  if(throttle && !brake){
    speed += accel * dt * (1 - Math.abs(speed)/maxSpeed*0.7);
  } else if(brake){
    if(speed>0) speed -= 22*dt; else speed -= 8*dt;
    speed=Math.max(speed, -3.2);
  } else {
    speed *= Math.pow(0.98, 60*dt);
    if(Math.abs(speed)<0.02) speed=0;
  }
  speed *= Math.pow(1 - 0.015*drag, 60*dt);
  if(speed>maxSpeed) speed=maxSpeed;
  if(speed<-3.2) speed=-3.2;

  const fX=Math.sin(heading), fZ=Math.cos(heading);
  const moveX= fX * speed * (60*dt)*0.6;
  const moveZ= fZ * speed * (60*dt)*0.6;
  const latX= Math.cos(heading)* slide * speed * 0.18;
  const latZ= -Math.sin(heading)* slide * speed * 0.18;
  let newX= pos.x + moveX + latX;
  let newZ= pos.y + moveZ + latZ;

  for(const o of obstacles){
    const dx=newX - o.x, dz=newZ - o.z;
    if(dx*dx+dz*dz < o.r*o.r + 1.2){
      const d=Math.sqrt(dx*dx+dz*dz)||1, nx=dx/d, nz=dz/d;
      newX= o.x + nx*(o.r+1.45); newZ= o.z + nz*(o.r+1.45);
      speed *= 0.36; driftIntensity=0.6; spawnSparks(newX,1.2,newZ); thud(); break;
    }
  }
  for(const b of barriers){
    const dx=newX-b.x, dz=newZ-b.z;
    if(dx*dx+dz*dz < 3.2){
      const d=Math.sqrt(dx*dx+dz*dz)||1;
      newX=b.x + dx/d*3.4; newZ=b.z+dz/d*3.4;
      speed*=0.56; spawnSparks(newX,0.9,newZ); thud();
    }
  }
  const newInfo=closestTrackInfo(newX,newZ);
  if(Math.abs(newInfo.lateral) > newInfo.width/2+SHOULDER+12){
    const nx=-newInfo.tangent.z, nz=newInfo.tangent.x; const len=Math.hypot(nx,nz)||1; const lx=nx/len,lz=nz/len;
    const push=Math.sign(newInfo.lateral);
    newX -= lx*push*1.25; newZ -= lz*push*1.25;
    speed*=0.7; spawnDust(newX,0.6,newZ);
  }
  pos.set(newX,newZ);
  const hInfo=closestTrackInfo(pos.x,pos.y);
  const targetY=hInfo.height+0.45;
  car.position.y += (targetY - car.position.y)*0.2;
  car.position.x=pos.x; car.position.z=pos.y;

  const prog=hInfo.progress;
  const cpProg=(nextCheckpoint%CHECKPOINTS)/CHECKPOINTS;
  let diff=cpProg - prog; if(diff>0.5) diff-=1; if(diff<-0.5) diff+=1;
  const gateDist=Math.abs(diff)*trackPoints.length;
  if(gateDist < 10){
    nextCheckpoint++;
    if(nextCheckpoint>=CHECKPOINTS && prog<0.08){
      const now=performance.now(), lapTime=now - lapStartTime;
      lapTimes.push(lapTime); bestLap=Math.min(bestLap, lapTime);
      document.getElementById('lastTxt').textContent=`Last ${formatTime(lapTime)}`;
      lap++;
      if(lap>TOTAL_LAPS){
        gameState='finished';
        const total=now-raceStartTime;
        document.getElementById('finishTotal').textContent=formatTime(total);
        document.getElementById('finishBest').textContent=isFinite(bestLap)?formatTime(bestLap):'--';
        for(let i=0;i<3;i++) document.getElementById('fL'+(i+1)).textContent=lapTimes[i]?formatTime(lapTimes[i]):'--';
        document.getElementById('finishOverlay').classList.add('on');
        beep(900,0.6,0.6); setTimeout(()=>beep(1200,0.8,0.6),260);
      } else {
        lapStartTime=now; nextCheckpoint=1; beep(1000,0.15,0.5);
      }
    }
  }

  const kmh2=Math.abs(speed)*3.6*18;
  if(gameState==='racing'){
    if((surface!=='ASPHALT' && kmh2>18) || driftIntensity>0.25){
      dustTimer-=dt; if(dustTimer<=0){ dustTimer=0.06; spawnDust(pos.x - fX*1.2 + (Math.random()-0.5)*0.8, 0.35, pos.y - fZ*1.2 + (Math.random()-0.5)*0.8); }
    }
    if(driftIntensity>0.22 && kmh2>30){
      skidTimer-=dt; if(skidTimer<=0){ skidTimer=0.09; spawnSkid(pos.x + (Math.random()-0.5)*0.6, pos.y + (Math.random()-0.5)*0.6, heading); }
    }
  }
  chassis.rotation.y=heading + driftIntensity*steer*0.55;
  chassis.rotation.z= -steer*driftIntensity*0.34 - steer*speedNorm*0.18;
  chassis.rotation.x= (throttle? -0.06*speedNorm:0) + (brake?0.07:0);
  wheels.forEach((w,i)=>{ w.rotation.x+= speed*0.92; if(i<2) w.rotation.y= steer*0.55; });

  document.getElementById('speedTxt').innerHTML=`${Math.round(kmh2)} <small>KM/H</small>`;
  const g=Math.min(6, Math.max(1, Math.floor(kmh2/32)+1));
  gear= throttle? g: gear;
  document.getElementById('gearTxt').textContent= kmh2<4?'N':'G'+gear;
  document.getElementById('rpmFill').style.width=`${20 + (kmh2%32)/32*80}%`;
  document.getElementById('lapTxt').innerHTML=`${Math.min(lap,TOTAL_LAPS)} <span>/ ${TOTAL_LAPS}</span>`;
  document.getElementById('surfaceTxt').textContent=`${surface} • GRIP ${Math.round(grip*100)}%`;
  if(gameState==='racing'){
    document.getElementById('timerTxt').textContent=formatTime(performance.now()-lapStartTime);
    document.getElementById('bestTxt').textContent=isFinite(bestLap)?`Best ${formatTime(bestLap)}`:'Best — --:--.-';
  }
  if(audioCtx && engineGain){
    const targetFreq=72 + kmh2*2.25 + gear*18 + (handbrake?38:0);
    engineOsc.frequency.linearRampToValueAtTime(targetFreq, audioCtx.currentTime+0.08);
    const vol= gameState==='racing'? 0.22 + Math.min(1,Math.abs(speed)/8)*0.22 : 0.06;
    engineGain.gain.linearRampToValueAtTime(vol, audioCtx.currentTime+0.08);
  }
}

/* ===== Camera — elevated chase with horizon visible ===== */
function updateCamera(dt){
  const fX=Math.sin(heading), fZ=Math.cos(heading);
  const sNorm=Math.min(1, Math.abs(speed)/8);
  const dist= 15 + sNorm*1.4;
  const h= 8.0 + sNorm*0.5;
  let tx= pos.x - fX*dist - Math.cos(heading)*steer*0.7;
  let tz= pos.y - fZ*dist + Math.sin(heading)*steer*0.7;
  let ty= car.position.y + h;
  if(camMode===1){ tx= pos.x - fX*26; tz= pos.y - fZ*26; ty= car.position.y + 14; }
  camPos.x += (tx - camPos.x)* Math.min(1, 3.2*dt);
  camPos.y += (ty - camPos.y)* Math.min(1, 3.2*dt);
  camPos.z += (tz - camPos.z)* Math.min(1, 3.2*dt);
  const lookX= pos.x + fX*18*(0.5+sNorm*0.35);
  const lookZ= pos.y + fZ*18*(0.5+sNorm*0.35);
  camTarget.x += (lookX - camTarget.x)* Math.min(1, 5.0*dt);
  camTarget.z += (lookZ - camTarget.z)* Math.min(1, 5.0*dt);
  camTarget.y= car.position.y + 0.6;
  camera.position.set(camPos.x, camPos.y, camPos.z);
  camera.lookAt(camTarget.x, camTarget.y, camTarget.z);
}

/* ===== Loop ===== */
function animate(t){
  requestAnimationFrame(animate);
  const dt=Math.min(0.033, (t-lastTime)/1000 || 0.016); lastTime=t;
  if(gameState==='countdown') updateCountdown(dt);
  if(gameState==='racing') updatePhysics(dt);
  if(gameState!=='paused'){
    for(let i=particles.length-1;i>=0;i--){
      const p=particles[i];
      p.mesh.position.addScaledVector(p.vel, dt*6);
      p.vel.y -= 2.2*dt;
      p.life-= dt*p.decay;
      p.mesh.material.opacity=Math.max(0,p.life);
      p.mesh.scale.multiplyScalar(1+ dt*0.55);
      if(p.life<=0){ scene.remove(p.mesh); particles.splice(i,1); }
    }
    updateCamera(dt);
  }
  drawMinimap();
  renderer.render(scene,camera);
}
camPos.set(pos.x, 10.5, pos.y-18); camTarget.set(pos.x,0,pos.y);
startCountdown();
animate(performance.now());
addEventListener('pointerdown',()=>{ if(!audioCtx) initAudio(); if(audioCtx && audioCtx.state==='suspended') audioCtx.resume(); },{once:true});
</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.2-contributor","provider":"openrouter","persona":"full","sessionId":"2dcc67e4-27fa-459c-bf31-c199a937dc57","tokensIn":6354497,"tokensOut":74628,"tokensTotal":6429125,"cost":0.185029552,"turns":53,"toolCalls":52,"failedToolCalls":0,"timestamp":"2026-08-23T04:08:03.370Z"} -->