Rally
muse spark 1.2 contributor · full

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 <b>Space</b> Handbrake<br>
<b>R</b> Restart <b>P</b> Pause <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"} -->