Rally
muse spark 1.1 · composer

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 44,355 bytes · SHA-256 0a38ca750fb3
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Rally Rush — 3D Procedural Racer</title>
<script src="https://unpkg.com/three@0.160.0/build/three.min.js"></script>
<style>
/* ── css: layout ── */
@import url('https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@600;700&family=JetBrains+Mono:wght@600&display=swap');
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;overflow:hidden;background:#12161c;font-family:'Chakra Petch',system-ui,sans-serif}
#app{position:relative;width:100vw;height:100vh;overflow:hidden}
#gameCanvas{display:block;width:100%;height:100%}
#hud{position:absolute;inset:0;pointer-events:none}
#topbar{display:flex;gap:10px;padding:14px 18px;flex-wrap:wrap}
.hud-block{background:rgba(0,0,0,.55);backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,.12);padding:8px 14px;border-radius:12px;min-width:110px;display:flex;flex-direction:column}
.hud-block .label{font-size:9px;letter-spacing:.18em;color:#9aa0a8}
.hud-block span:last-child{font-family:'JetBrains Mono',monospace;font-size:16px;color:#fff;line-height:1.1;margin-top:2px}
#gearBox{position:absolute;left:18px;bottom:18px;background:rgba(0,0,0,.55);border:1px solid rgba(255,255,255,.12);backdrop-filter:blur(10px);padding:10px 14px;border-radius:14px;display:flex;align-items:center;gap:10px;min-width:150px}
#gearTxt{font-family:'JetBrains Mono';font-size:28px;font-weight:800;color:#ffec44;width:36px;text-align:center;text-shadow:0 0 12px #ffec44}
#rpmBar{width:86px;height:8px;background:rgba(255,255,255,.15);border-radius:99px;overflow:hidden}
#rpmFill{height:100%;width:10%;background:linear-gradient(90deg,#4cf5a7,#ffec44 60%,#ff3b30);transition:width .05s linear}
#minimap{position:absolute;right:18px;bottom:18px;width:200px;height:200px;border-radius:50%;border:2px solid rgba(255,255,255,.15);background:radial-gradient(circle at center,#1e2a33 0%,#0f151a 70%);box-shadow:0 8px 30px rgba(0,0,0,.6), inset 0 0 0 4px rgba(0,0,0,.4)}
#surfaceFlag{position:absolute;right:18px;bottom:230px;padding:6px 10px;border-radius:999px;font-size:11px;letter-spacing:.12em;background:#ff3b30;color:#fff;opacity:0;transform:translateY(6px);transition:.2s}
#surfaceFlag.show{opacity:1;transform:translateY(0)}
#countdown{position:absolute;left:50%;top:38%;transform:translate(-50%,-50%);font-size:16vw;font-weight:800;color:#fff;line-height:1;text-shadow:0 8px 30px rgba(0,0,0,.8),0 0 30px rgba(255,236,68,.6);pointer-events:none;opacity:0;transition:.2s}
#countdown.pop{opacity:1;transform:translate(-50%,-50%) scale(1.08)}
#overlay{position:absolute;inset:0;background:radial-gradient(120% 120% at 30% 10%,rgba(76,245,167,.18),transparent 40%),radial-gradient(120% 120% at 90% 90%,rgba(255,236,68,.18),transparent 40%),rgba(8,10,12,.78);backdrop-filter:blur(18px);display:flex;align-items:center;justify-content:center;transition:.35s}
#overlay.hidden{opacity:0;pointer-events:none}
#overlayCard{background:linear-gradient(180deg,rgba(255,255,255,.08),rgba(255,255,255,.02));border:1px solid rgba(255,255,255,.14);border-radius:24px;padding:28px 30px;max-width:460px;width:92%;text-align:center;box-shadow:0 20px 80px rgba(0,0,0,.6)}
#overlayTitle{font-size:44px;letter-spacing:-.02em;color:#fff;line-height:.9}
#overlayMsg{color:#b8c0c8;margin:12px 0 18px;font-size:14px;line-height:1.5}
#resultStats{font-family:'JetBrains Mono';color:#d8e0e8;font-size:13px;line-height:1.6;margin-bottom:18px;white-space:pre-line}
.btnRow{display:flex;gap:10px;justify-content:center;flex-wrap:wrap}
button.primary,button#btnResume{appearance:none;border:0;border-radius:999px;padding:12px 22px;font-weight:700;letter-spacing:.04em;cursor:pointer;transition:.18s}
button.primary{background:#ffec44;color:#111;box-shadow:0 8px 24px rgba(255,236,68,.35)}
button.primary:hover{transform:translateY(-1px) scale(1.02);box-shadow:0 12px 28px rgba(255,236,68,.5)}
button#btnResume{background:rgba(255,255,255,.12);color:#fff;border:1px solid rgba(255,255,255,.18)}
.hint{margin-top:18px;color:#7f8a93;font-size:11px;line-height:1.5;letter-spacing:.02em}
.hidden{display:none !important}
#touchControls{position:absolute;inset:0;pointer-events:none;display:none}
@media (max-width:900px){#touchControls{display:block}#topbar{gap:6px;padding:10px}#hud-block{min-width:86px}#minimap{width:140px;height:140px;right:10px;bottom:10px}#gearBox{left:10px;bottom:10px}}
#joyArea{position:absolute;left:14px;bottom:14px;width:140px;height:140px;border-radius:50%;background:rgba(0,0,0,.25);border:1px solid rgba(255,255,255,.12);pointer-events:auto;touch-action:none}
#joyStick{position:absolute;left:50%;top:50%;width:56px;height:56px;margin:-28px 0 0 -28px;border-radius:50%;background:radial-gradient(circle at 35% 30%,#fff,#c8d0d8);box-shadow:0 6px 18px rgba(0,0,0,.5)}
#actionArea{position:absolute;right:10px;bottom:14px;display:flex;flex-direction:column;gap:10px;pointer-events:auto}
.tBtn{width:92px;height:54px;border-radius:14px;border:1px solid rgba(255,255,255,.18);background:rgba(0,0,0,.42);color:#fff;font-weight:800;letter-spacing:.08em;backdrop-filter:blur(8px)}
.tBtn:active{transform:scale(.97);background:rgba(255,236,68,.9);color:#111}
</style>
</head>
<body>
<div id="app">
<canvas id="gameCanvas"></canvas>
<div id="hud">
<div id="topbar">
<div class="hud-block"><span class="label">LAP</span><span id="lapTxt">1 / 3</span></div>
<div class="hud-block"><span class="label">TIME</span><span id="timeTxt">00:00.000</span></div>
<div class="hud-block"><span class="label">BEST</span><span id="bestTxt">--:--.---</span></div>
<div class="hud-block"><span class="label">SPEED</span><span id="speedTxt">0 km/h</span></div>
</div>
<div id="gearBox"><span id="gearTxt">N</span><div id="rpmBar"><div id="rpmFill"></div></div></div>
<canvas id="minimap" width="200" height="200"></canvas>
<div id="surfaceFlag"></div>
</div>
<div id="countdown"></div>
<div id="overlay" class="hidden">
<div id="overlayCard">
<h1 id="overlayTitle">RALLY RUSH</h1>
<p id="overlayMsg">Complete 3 laps. Stay on track, drift through mud.</p>
<div id="resultStats"></div>
<div class="btnRow">
<button id="btnGo" class="primary">START RACE</button>
<button id="btnResume" class="hidden">RESUME [P]</button>
</div>
<div class="hint">WASD / Arrows • SPACE handbrake • C camera • R restart • P pause<br>Touch: left pad steer, right buttons gas/brake/drift</div>
</div>
</div>
<div id="touchControls">
<div id="joyArea"><div id="joyStick"></div></div>
<div id="actionArea">
<button class="tBtn" id="tBrake">BRAKE</button>
<button class="tBtn" id="tGas">GAS</button>
<button class="tBtn" id="tHand">DRIFT</button>
</div>
</div>
</div>
<script>
'use strict';
// ── const: CONFIG ──
const CONFIG = {
laps: 3,
trackSegments: 200,
trackWidth: 12,
offWidth: 28,
maxSpeed: 62, // m/s ~ 223 km/h
gravity: -18,
fogColor: 0xcfe4f0,
worldSize: 600,
checkpoints: 16
};
// ── const: SURFACE ──
const SURFACE = { ASPHALT:0, GRASS:1, MUD:2, WATER:3 };
// ── const: SURFACE_CFG ──
const SURFACE_CFG = {
[SURFACE.ASPHALT]: { grip:1.0, drag:0.02, maxF:1.0, label:'', color:'#ffcc00' },
[SURFACE.GRASS]: { grip:0.55, drag:0.10, maxF:0.62, label:'GRASS', color:'#4cf5a7' },
[SURFACE.MUD]: { grip:0.32, drag:0.18, maxF:0.45, label:'MUD', color:'#8a5a2b' },
[SURFACE.WATER]: { grip:0.22, drag:0.28, maxF:0.35, label:'WATER', color:'#3aa0ff' }
};
// ── function: lerp ──
function lerp(a,b,t){return a+(b-a)*t}
// ── function: clamp ──
function clamp(v,a,b){return Math.max(a,Math.min(b,v))}
// ── function: smoothstep ──
function smoothstep(t){return t*t*(3-2*t)}
// ── function: fmtTime ──
function fmtTime(ms){
if(!isFinite(ms)||ms<=0) return '--:--.---';
let m=Math.floor(ms/60000), s=Math.floor((ms%60000)/1000), mm=Math.floor(ms%1000);
return `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}.${String(mm).padStart(3,'0')}`;
}
// ── class: Track ──
class Track {
constructor(){
this.points=[];
this.length=0;
this.total=0;
this.checkGates=[];
this.generate();
}
generate(){
const N=CONFIG.trackSegments;
const controls=[];
const R=120;
for(let i=0;i<8;i++){
let ang = (i/8)*Math.PI*2 + Math.sin(i*1.3)*0.35;
let rad = R * (0.8 + Math.sin(i*2.1)*0.25 + (i%2?0.15:0));
let x = Math.cos(ang)*rad + Math.sin(ang*2)*18;
let z = Math.sin(ang)*rad*0.9 + Math.cos(ang*1.7)*22;
let y = Math.sin(i*1.8)*12 + Math.cos(i*0.9)*6;
controls.push(new THREE.Vector3(x,y,z));
}
const pts=[];
const get = (i)=> controls[(i+controls.length)%controls.length];
for(let i=0;i<controls.length;i++){
for(let j=0;j<N/controls.length;j++){
let t=j/(N/controls.length);
let p0=get(i-1),p1=get(i),p2=get(i+1),p3=get(i+2);
let tt=t*t, ttt=tt*t;
let x=0.5*((2*p1.x)+(-p0.x+p2.x)*t+(2*p0.x-5*p1.x+4*p2.x-p3.x)*tt+(-p0.x+3*p1.x-3*p2.x+p3.x)*ttt);
let y=0.5*((2*p1.y)+(-p0.y+p2.y)*t+(2*p0.y-5*p1.y+4*p2.y-p3.y)*tt+(-p0.y+3*p1.y-3*p2.y+p3.y)*ttt);
let z=0.5*((2*p1.z)+(-p0.z+p2.z)*t+(2*p0.z-5*p1.z+4*p2.z-p3.z)*tt+(-p0.z+3*p1.z-3*p2.z+p3.z)*ttt);
y = y + Math.sin(t*6 + i)*1.2 + Math.sin((i+t)*0.8)*4;
pts.push(new THREE.Vector3(x,y,z));
}
}
let acc=0;
const arr=[];
for(let i=0;i<pts.length;i++){
let cur=pts[i], nxt=pts[(i+1)%pts.length];
let dir=new THREE.Vector3().subVectors(nxt,cur);
let seg=dir.length(); dir.normalize();
let right=new THREE.Vector3(-dir.z,0,dir.x).normalize();
acc+=seg;
arr.push({pos:cur.clone(), dir:dir, right:right, seg:seg, accum:acc, width:CONFIG.trackWidth*(0.9+0.18*Math.sin(i*0.17))});
}
this.points=arr; this.total=acc;
for(let i=0;i<CONFIG.checkpoints;i++){
let idx=Math.floor((i/CONFIG.checkpoints)*arr.length);
this.checkGates.push({idx, accum:arr[idx].accum});
}
}
closestParam(worldPos){
let best=0,bestD=1e9;
for(let i=0;i<this.points.length;i++){
let d=this.points[i].pos.distanceToSquared(worldPos);
if(d<bestD){bestD=d;best=i;}
}
let idx=best;
let a=this.points[idx].pos, b=this.points[(idx+1)%this.points.length].pos;
let ab=new THREE.Vector3().subVectors(b,a);
let ap=new THREE.Vector3().subVectors(worldPos,a);
let t=clamp(ap.dot(ab)/ab.lengthSq(),0,1);
let proj=new THREE.Vector3().addVectors(a, ab.clone().multiplyScalar(t));
let lateral=new THREE.Vector3().subVectors(worldPos, proj);
let right=this.points[idx].right;
let lat = lateral.dot(right);
let accum = this.points[idx].accum + t*this.points[idx].seg;
let off = Math.abs(lat);
let surf=SURFACE.ASPHALT;
if(off>this.points[idx].width*0.5+1) surf=SURFACE.GRASS;
if(off>this.points[idx].width*0.5+8) surf=SURFACE.MUD;
if(off>this.points[idx].width*0.5+18) surf=SURFACE.WATER;
return {idx, t, proj, lat, accum: accum % this.total, accumRaw: this.points[idx].accum + t*this.points[idx].seg, surf, dist: Math.sqrt(bestD), right, seg:this.points[idx]};
}
buildMeshes(scene){
const N=this.points.length;
const verts=[], uvs=[], indices=[];
for(let i=0;i<N;i++){
let p=this.points[i];
let half=p.width*0.5;
let left=new THREE.Vector3().addVectors(p.pos, p.right.clone().multiplyScalar(-half));
let right=new THREE.Vector3().addVectors(p.pos, p.right.clone().multiplyScalar(half));
left.y+=0.02; right.y+=0.02;
verts.push(left.x,left.y,left.z, right.x,right.y,right.z);
uvs.push(0,i/N*10, 1,i/N*10);
}
for(let i=0;i<N;i++){
let i0=i*2, i1=i*2+1, j0=((i+1)%N)*2, j1=((i+1)%N)*2+1;
indices.push(i0,j0,i1, i1,j0,j1);
}
const geo=new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(verts,3));
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs,2));
geo.setIndex(indices);
geo.computeVertexNormals();
const stripeCanvas=document.createElement('canvas'); stripeCanvas.width=256; stripeCanvas.height=32;
const sctx=stripeCanvas.getContext('2d');
sctx.fillStyle='#2e3338'; sctx.fillRect(0,0,256,32);
sctx.fillStyle='#444a50'; sctx.fillRect(0,0,64,32);
const tex=new THREE.CanvasTexture(stripeCanvas); tex.wrapS=THREE.RepeatWrapping; tex.wrapT=THREE.RepeatWrapping; tex.repeat.set(1,10);
const mat=new THREE.MeshStandardMaterial({map:tex, roughness:0.9, metalness:0.02});
const mesh=new THREE.Mesh(geo,mat); mesh.receiveShadow=true; scene.add(mesh);
const shoulderVerts=[], sIndices=[];
for(let i=0;i<N;i++){
let p=this.points[i];
let innerHalf=p.width*0.5, outerHalf=p.width*0.5+3.5;
let l1=new THREE.Vector3().addVectors(p.pos, p.right.clone().multiplyScalar(-innerHalf));
let l2=new THREE.Vector3().addVectors(p.pos, p.right.clone().multiplyScalar(-outerHalf));
let r1=new THREE.Vector3().addVectors(p.pos, p.right.clone().multiplyScalar(innerHalf));
let r2=new THREE.Vector3().addVectors(p.pos, p.right.clone().multiplyScalar(outerHalf));
[l1,l2,r1,r2].forEach(v=>v.y+=0.005);
shoulderVerts.push(l1.x,l1.y,l1.z, l2.x,l2.y,l2.z, r1.x,r1.y,r1.z, r2.x,r2.y,r2.z);
}
for(let i=0;i<N;i++){
let b=i*4, nb=((i+1)%N)*4;
sIndices.push(b, nb, b+1, b+1, nb, nb+1);
sIndices.push(b+2, b+3, nb+2, b+3, nb+3, nb+2);
}
const sGeo=new THREE.BufferGeometry();
sGeo.setAttribute('position', new THREE.Float32BufferAttribute(shoulderVerts,3));
sGeo.setIndex(sIndices); sGeo.computeVertexNormals();
const sMat=new THREE.MeshStandardMaterial({color:0x8a6040, roughness:1});
const sMesh=new THREE.Mesh(sGeo,sMat); sMesh.receiveShadow=true; scene.add(sMesh);
const ground=new THREE.Mesh(new THREE.PlaneGeometry(800,800,20,20), new THREE.MeshStandardMaterial({color:0x4a7a3f, roughness:1}));
ground.rotation.x=-Math.PI/2; ground.position.y=-1.5; ground.receiveShadow=true; scene.add(ground);
const gateGroup=new THREE.Group();
for(let g=0; g<this.checkGates.length; g++){
let idx=this.checkGates[g].idx;
let p=this.points[idx];
let arch = new THREE.Mesh(new THREE.BoxGeometry(p.width+1,4,0.4), new THREE.MeshStandardMaterial({color: g===0?0xffec44:0xffffff, transparent:true, opacity: g===0?0.22:0.06}));
arch.position.copy(p.pos); arch.position.y+=2;
let look = this.points[(idx+2)%N].pos;
arch.lookAt(look); gateGroup.add(arch);
}
scene.add(gateGroup);
return {road:mesh, shoulder:sMesh, ground};
}
}
// ── class: Car ──
class Car {
constructor(){
this.pos=new THREE.Vector3(0,0,0);
this.vel=new THREE.Vector3(0,0,0);
this.yaw=0; this.pitch=0;
this.speed=0;
this.steer=0; this.steerTarget=0;
this.rpm=0; this.gear=1;
this.slip=0;
this.onGround=true;
this.surface=SURFACE.ASPHALT;
this.wheelSpin=0;
this.angularVel=0;
this.mesh=null;
this.shadow=null;
}
buildModel(){
const g=new THREE.Group();
// body low-poly
const bodyMat=new THREE.MeshStandardMaterial({color:0xff3b30, roughness:0.4, metalness:0.2});
const body=new THREE.Mesh(new THREE.BoxGeometry(1.9,0.7,4.2), bodyMat); body.position.y=0.7; body.castShadow=true; g.add(body);
const roof=new THREE.Mesh(new THREE.BoxGeometry(1.7,0.55,2.2), new THREE.MeshStandardMaterial({color:0xffffff, roughness:0.5})); roof.position.set(0,1.25, -0.2); roof.castShadow=true; g.add(roof);
const spoiler=new THREE.Mesh(new THREE.BoxGeometry(1.8,0.1,0.45), new THREE.MeshStandardMaterial({color:0x111111})); spoiler.position.set(0,1.1, -2.0); g.add(spoiler);
const spoiler2=new THREE.Mesh(new THREE.BoxGeometry(1.8,0.45,0.1), new THREE.MeshStandardMaterial({color:0x111111})); spoiler2.position.set(0,1.35, -2.0); g.add(spoiler2);
// lights
const headL=new THREE.Mesh(new THREE.BoxGeometry(0.35,0.18,0.06), new THREE.MeshStandardMaterial({color:0xffffcc, emissive:0xffffaa, emissiveIntensity:1.5})); headL.position.set(0.7,0.7,2.11); g.add(headL);
const headR=headL.clone(); headR.position.x=-0.7; g.add(headR);
const tailL=new THREE.Mesh(new THREE.BoxGeometry(0.35,0.18,0.06), new THREE.MeshStandardMaterial({color:0xff1111, emissive:0xff1111, emissiveIntensity:2})); tailL.position.set(0.7,0.7,-2.11); g.add(tailL);
const tailR=tailL.clone(); tailR.position.x=-0.7; g.add(tailR);
// wheels
const whGeo=new THREE.CylinderGeometry(0.38,0.38,0.42,14); whGeo.rotateZ(Math.PI/2);
const whMat=new THREE.MeshStandardMaterial({color:0x1a1a1a, roughness:0.9});
for(let x of [-1,1]) for(let z of [-1.2,1.2]){
let w=new THREE.Mesh(whGeo,whMat); w.position.set(x*1.05,0.38, z*1.1); w.castShadow=true; g.add(w);
}
// number plate
const plate=new THREE.Mesh(new THREE.PlaneGeometry(0.7,0.28), new THREE.MeshStandardMaterial({color:0xffec44})); plate.position.set(0,0.72,-2.12); plate.rotation.y=Math.PI; g.add(plate);
this.mesh=g;
return g;
}
reset(track){
let start=track.points[0];
let next=track.points[4];
let dir=new THREE.Vector3().subVectors(next.pos, start.pos).normalize();
this.yaw=Math.atan2(dir.x, dir.z);
this.pos.copy(start.pos); this.pos.y+=0.4;
this.vel.set(0,0,0); this.speed=0; this.steer=0; this.slip=0; this.rpm=0.1; this.gear=1;
}
update(dt, input, trackInfo){
const cfg=SURFACE_CFG[trackInfo.surf];
this.surface=trackInfo.surf;
// input: accel [-1,1], steer [-1,1], handbrake bool
let accel = input.accel;
let steerIn = input.steer;
// steering target with speed dependency
let speed01 = clamp(this.speed/CONFIG.maxSpeed,0,1);
let steerGrip = lerp(1.0,0.35,speed01);
this.steerTarget = steerIn * (0.9*steerGrip + (input.handbrake?0.6:0));
this.steer = lerp(this.steer, this.steerTarget, dt*6);
// engine force
let forward = new THREE.Vector3(Math.sin(this.yaw),0,Math.cos(this.yaw));
let engine = accel * (input.handbrake?0.2:1) * (580 * cfg.maxF);
// special mud drag
let drag = cfg.drag + speed01*0.06;
// lateral slip
let lateralVel = this.vel.clone().sub(forward.clone().multiplyScalar(this.vel.dot(forward)));
let latSpeed = lateralVel.length();
let grip = cfg.grip * (input.handbrake?0.35:1);
// drift condition
let wantSlip = Math.abs(this.steer)*speed01*1.5 + (input.handbrake?0.8:0) + latSpeed*0.02;
this.slip = lerp(this.slip, clamp(wantSlip,0,1.4), dt* (wantSlip>this.slip?8:4));
let effectiveGrip = grip * lerp(1, 0.45, this.slip*0.6);
// apply forces
let accelVec = forward.clone().multiplyScalar(engine*dt);
// remove some lateral velocity via grip
let lateralCorr = lateralVel.clone().multiplyScalar(-effectiveGrip*dt*4.5);
this.vel.add(accelVec).add(lateralCorr);
// drag
this.vel.multiplyScalar(1 - drag*dt);
// gravity / ground clamp
this.vel.y += CONFIG.gravity*dt*0.35;
if(this.pos.y < trackInfo.proj.y+0.38){
this.pos.y = trackInfo.proj.y+0.38;
if(this.vel.y<0) this.vel.y*= -0.1;
}
// move
this.pos.add(this.vel.clone().multiplyScalar(dt));
this.speed = this.vel.length();
// yaw from steer and slip
let yawRate = this.steer * lerp(2.2,1.0,speed01) * (1+this.slip*0.7) * (this.speed>2?1:0);
this.yaw += yawRate*dt + this.angularVel*dt;
this.angularVel = lerp(this.angularVel, -latSpeed*0.06*Math.sign(this.steer), dt*5);
this.angularVel *= (1 - dt*2);
// rpm simulation
let targetRpm = 0.15 + speed01*0.85 + Math.abs(this.slip)*0.15;
targetRpm = clamp(targetRpm + (accel>0?0.05: -0.05),0,1);
this.rpm = lerp(this.rpm, targetRpm, dt* (targetRpm>this.rpm?10:4));
// gear
if(this.rpm>0.82 && this.gear<6){ this.gear++; this.rpm=0.45; }
if(this.rpm<0.28 && this.gear>1 && accel<0.2){ this.gear--; this.rpm=0.6; }
if(this.speed<0.5) this.gear=1;
this.wheelSpin+= this.speed*dt*6;
// collision with track sides (bounce)
let off = trackInfo.lat;
let half = trackInfo.seg.width*0.5+2.5;
if(Math.abs(off) > half){
// wall
let push = Math.sign(off)* (Math.abs(off)-half);
let right = trackInfo.right;
this.pos.add(right.clone().multiplyScalar(-push*0.9));
this.vel.add(right.clone().multiplyScalar(-push*8));
this.speed*=0.85;
this.slip=Math.max(this.slip,0.6);
return true; // hit
}
return false;
}
syncMesh(){
if(!this.mesh) return;
this.mesh.position.copy(this.pos);
this.mesh.rotation.set(this.pitch*0.3, this.yaw, Math.sin(this.yaw*2)*0 + this.steer*0.3 + this.angularVel*0.25);
// slight roll on steer
this.mesh.rotation.z = -this.steer*0.45 - this.slip*0.25* Math.sign(this.steer||1);
}
}
// ── class: AudioSynth ──
class AudioSynth {
constructor(){
this.ctx=null; this.engOsc=null; this.engGain=null; this.mudGain=null;
this.enabled=false; this.time=0;
}
init(){
try{
const AC = window.AudioContext||window.webkitAudioContext;
this.ctx=new AC(); this.enabled=true;
// engine chain
this.engGain=this.ctx.createGain(); this.engGain.gain.value=0;
this.engFilter=this.ctx.createBiquadFilter(); this.engFilter.type='lowpass'; this.engFilter.frequency.value=800;
this.engOsc=this.ctx.createOscillator(); this.engOsc.type='sawtooth'; this.engOsc.frequency.value=60;
this.engOsc.connect(this.engFilter); this.engFilter.connect(this.engGain); this.engGain.connect(this.ctx.destination);
this.engOsc.start();
// extra noise for gravel buzz
this.noiseGain=this.ctx.createGain(); this.noiseGain.gain.value=0; this.noiseGain.connect(this.ctx.destination);
// buffer noise
let len=this.ctx.sampleRate*1; let buf=this.ctx.createBuffer(1,len,this.ctx.sampleRate);
let ch=buf.getChannelData(0); for(let i=0;i<len;i++) ch[i]=(Math.random()*2-1)*0.4;
this.noiseSrc=this.ctx.createBufferSource(); this.noiseSrc.buffer=buf; this.noiseSrc.loop=true;
this.noiseFilter=this.ctx.createBiquadFilter(); this.noiseFilter.type='bandpass'; this.noiseFilter.frequency.value=1200;
this.noiseSrc.connect(this.noiseFilter); this.noiseFilter.connect(this.noiseGain); this.noiseSrc.start();
}catch(e){this.enabled=false}
}
resume(){ if(this.ctx && this.ctx.state==='suspended') this.ctx.resume(); }
setEngine(rpm,speed,slip,surf){
if(!this.enabled) return;
let freq = 40 + rpm*280 + slip*90 + speed*1.2;
if(this.engOsc) this.engOsc.frequency.setTargetAtTime(freq, this.ctx.currentTime, 0.06);
if(this.engGain) this.engGain.gain.setTargetAtTime(0.12 + rpm*0.18 + speed*0.002, this.ctx.currentTime, 0.1);
if(this.engFilter) this.engFilter.frequency.setTargetAtTime(400 + rpm*1200, this.ctx.currentTime, 0.08);
// gravel rumble
if(this.noiseGain){
let g=(surf!==0?0.08+slip*0.12: slip*0.05);
this.noiseGain.gain.setTargetAtTime(g, this.ctx.currentTime, 0.12);
}
}
beep(count){
if(!this.enabled) return;
let o=this.ctx.createOscillator(), g=this.ctx.createGain();
o.type='sine'; o.frequency.value= count<=0? 900: 480;
g.gain.value=0.3; o.connect(g); g.connect(this.ctx.destination);
o.start(); g.gain.exponentialRampToValueAtTime(0.0001, this.ctx.currentTime+0.25); o.stop(this.ctx.currentTime+0.3);
}
crash(intensity){
if(!this.enabled) return;
let o=this.ctx.createOscillator(), g=this.ctx.createGain(), f=this.ctx.createBiquadFilter();
o.type='square'; o.frequency.value= 120+ intensity*200;
f.type='lowpass'; f.frequency.value= 1800;
g.gain.value= clamp(intensity,0,1)*0.5;
o.connect(f); f.connect(g); g.connect(this.ctx.destination);
o.start(); g.gain.exponentialRampToValueAtTime(0.0001, this.ctx.currentTime+0.3); o.stop(this.ctx.currentTime+0.35);
}
finish(){
if(!this.enabled) return;
for(let i=0;i<3;i++){
let o=this.ctx.createOscillator(), g=this.ctx.createGain();
o.type='sine'; o.frequency.value= 400 + i*250;
g.gain.value=0.2; o.connect(g); g.connect(this.ctx.destination);
o.start(this.ctx.currentTime+i*0.18); g.gain.exponentialRampToValueAtTime(0.0001, this.ctx.currentTime+i*0.18+0.4);
o.stop(this.ctx.currentTime+i*0.18+0.45);
}
}
}
// ── class: Particles ──
class Particles {
constructor(scene){
this.scene=scene;
this.pool=[]; this.active=[];
this.skidCanvas=document.createElement('canvas'); this.skidCanvas.width=1024; this.skidCanvas.height=1024;
this.skidCtx=this.skidCanvas.getContext('2d');
this.skidTex=new THREE.CanvasTexture(this.skidCanvas); this.skidTex.minFilter=THREE.LinearFilter;
this.skidMesh=null;
this.initGroundDecal();
}
initGroundDecal(){
// large plane for skid marks using canvas texture projected onto ground shader? simplified: use plane near ground with texture.
// We'll add small quads for particles instead.
}
spawnDust(pos, vel, count, color){
for(let i=0;i<count;i++){
let m=new THREE.Mesh(new THREE.SphereGeometry(0.12+Math.random()*0.22,5,5), new THREE.MeshBasicMaterial({color: color||0xaa9977, transparent:true, opacity:0.55}));
m.position.copy(pos).add(new THREE.Vector3((Math.random()-0.5)*0.6, Math.random()*0.5, (Math.random()-0.5)*0.6));
m.userData={vel:new THREE.Vector3((Math.random()-0.5)*2+vel.x*0.2, Math.random()*1.5, (Math.random()-0.5)*2+vel.z*0.2), life:0.8+Math.random()*0.6, age:0};
this.scene.add(m); this.active.push(m);
}
}
spawnSkidMark(p1,p2){
// draw line on skid canvas? Needs world to UV mapping — approximate by drawing near center with jitter
// Instead spawn small thin boxes for instant visual
let mid=new THREE.Vector3().addVectors(p1,p2).multiplyScalar(0.5);
let dir=new THREE.Vector3().subVectors(p2,p1); let len=dir.length();
if(len<0.05) return;
dir.normalize();
const geo=new THREE.BoxGeometry(0.12,len,0.02);
const mat=new THREE.MeshBasicMaterial({color:0x111111, transparent:true, opacity:0.5});
const mesh=new THREE.Mesh(geo,mat);
mesh.position.copy(mid); mesh.position.y+=0.03;
mesh.rotation.y=Math.atan2(dir.x,dir.z);
this.scene.add(mesh);
mesh.userData={life:7, age:0, fade:true};
this.active.push(mesh);
}
spawnSparks(pos){
for(let i=0;i<6;i++){
let m=new THREE.Mesh(new THREE.SphereGeometry(0.05,4,4), new THREE.MeshBasicMaterial({color:0xffec44}));
m.position.copy(pos); m.position.y+=0.3;
m.userData={vel:new THREE.Vector3((Math.random()-0.5)*6, Math.random()*3, (Math.random()-0.5)*6), life:0.35, age:0, sparks:true};
this.scene.add(m); this.active.push(m);
}
}
update(dt){
for(let i=this.active.length-1;i>=0;i--){
let m=this.active[i]; let d=m.userData;
d.age+=dt;
if(d.vel){ m.position.add(d.vel.clone().multiplyScalar(dt)); d.vel.y-=9*dt; d.vel.multiplyScalar(1-dt*1.2); }
if(d.fade){ /* stay */ } else if(m.material.opacity!==undefined){ m.material.opacity = 1 - d.age/d.life; m.scale.multiplyScalar(1+dt*0.6); }
if(d.age> d.life){ this.scene.remove(m); this.active.splice(i,1); }
}
}
}
// ── function: buildScenery ──
function buildScenery(track, scene){
const group=new THREE.Group();
// trees
const trunkGeo=new THREE.CylinderGeometry(0.18,0.26,2.2,6);
const foliageGeo=new THREE.ConeGeometry(1.2,3.5,7);
const trunkMat=new THREE.MeshStandardMaterial({color:0x5a3a22});
const foliageMats=[new THREE.MeshStandardMaterial({color:0x2e7d32}), new THREE.MeshStandardMaterial({color:0x3d8b3d}), new THREE.MeshStandardMaterial({color:0x1e5a28})];
const rockGeo=new THREE.DodecahedronGeometry(1,0);
const rockMat=new THREE.MeshStandardMaterial({color:0x7a7a7a, roughness:0.9});
// place along track sides
for(let i=0;i<track.points.length;i+=2){
let p=track.points[i];
// skip some if too close to crowd?
let r = (Math.random()*2+1);
for(let side of [-1,1]){
if(Math.random()<0.72){
let off = p.width*0.5 + 4 + Math.random()*28;
let pos=new THREE.Vector3().addVectors(p.pos, p.right.clone().multiplyScalar(side*off));
pos.y = p.pos.y -0.5;
// tree
if(Math.random()<0.82){
let trunk=new THREE.Mesh(trunkGeo,trunkMat); trunk.position.copy(pos); trunk.position.y+=1.1; trunk.castShadow=true; trunk.receiveShadow=true;
group.add(trunk);
let foliage=new THREE.Mesh(foliageGeo, foliageMats[Math.floor(Math.random()*3)]); foliage.position.copy(pos); foliage.position.y+=2.2+1.7; foliage.castShadow=true;
foliage.rotation.y=Math.random()*Math.PI; group.add(foliage);
} else {
let rock=new THREE.Mesh(rockGeo, rockMat); rock.position.copy(pos); rock.position.y+=0.5; rock.scale.setScalar(0.6+Math.random()*1.2); rock.rotation.set(Math.random()*Math.PI,Math.random()*Math.PI,0); rock.castShadow=true; group.add(rock);
}
}
}
}
// banners / guard rail posts
const postGeo=new THREE.BoxGeometry(0.18,1.2,0.18);
const railGeo=new THREE.BoxGeometry(0.12,0.12,4);
const postMat=new THREE.MeshStandardMaterial({color:0xdddddd});
const railMat=new THREE.MeshStandardMaterial({color:0xbbbbbb});
for(let i=0;i<track.points.length;i+=8){
let p=track.points[i];
for(let side of [-1,1]){
if(Math.random()<0.25) continue;
let off=p.width*0.5+0.6;
let base=new THREE.Vector3().addVectors(p.pos, p.right.clone().multiplyScalar(side*off));
let post=new THREE.Mesh(postGeo,postMat); post.position.copy(base); post.position.y+=0.6; group.add(post);
// rail
if(i%16===0){
let rail=new THREE.Mesh(railGeo,railMat); rail.position.copy(base); rail.position.y+=0.9;
let nxt=track.points[(i+3)%track.points.length]; rail.lookAt(nxt.pos); group.add(rail);
}
}
}
// start/finish banner
{
let s=track.points[0];
let bannerG=new THREE.Group();
const pole=new THREE.Mesh(new THREE.BoxGeometry(0.3,5,0.3), new THREE.MeshStandardMaterial({color:0xeeeeee}));
for(let side of [-1,1]){
let pos=new THREE.Vector3().addVectors(s.pos, s.right.clone().multiplyScalar(side*(s.width*0.5+0.4)));
let pl=pole.clone(); pl.position.copy(pos); pl.position.y+=2.5; bannerG.add(pl);
}
const topBar=new THREE.Mesh(new THREE.BoxGeometry(s.width+2,0.5,0.35), new THREE.MeshStandardMaterial({color:0xffec44}));
topBar.position.copy(s.pos); topBar.position.y+=4.6; topBar.lookAt(track.points[2].pos); bannerG.add(topBar);
group.add(bannerG);
}
scene.add(group);
return group;
}
// ── class: Input ──
class Input {
constructor(){
this.keys={}; this.accel=0; this.brake=0; this.steer=0; this.hand=false;
this.joyX=0;
this.bind();
}
bind(){
window.addEventListener('keydown', e=>{
this.keys[e.code]=true;
if(['ArrowUp','ArrowDown','Space'].includes(e.code)) e.preventDefault();
});
window.addEventListener('keyup', e=>{ this.keys[e.code]=false; });
// touch joystick
const joyArea=document.getElementById('joyArea'), stick=document.getElementById('joyStick');
let joyActive=false, startX=0;
const handle=(x)=>{
let dx=x-startX; let max=50; let nx=clamp(dx/max,-1,1);
this.joyX=nx; stick.style.transform=`translate(${nx*40}px,0)`;
};
joyArea.addEventListener('pointerdown', e=>{
joyActive=true; startX=e.clientX; joyArea.setPointerCapture(e.pointerId);
});
joyArea.addEventListener('pointermove', e=>{ if(joyActive) handle(e.clientX); });
const end=()=>{
joyActive=false; this.joyX=0; stick.style.transform=`translate(0,0)`;
};
joyArea.addEventListener('pointerup', end); joyArea.addEventListener('pointercancel', end);
// action buttons
const gas=document.getElementById('tGas'), brake=document.getElementById('tBrake'), hand=document.getElementById('tHand');
const setHold=(el, cb)=>{
let on = (e)=>{ e.preventDefault(); cb(true); }; let off=()=>cb(false);
el.addEventListener('pointerdown', on); el.addEventListener('pointerup', off); el.addEventListener('pointerleave', off);
el.addEventListener('touchstart', on, {passive:false}); el.addEventListener('touchend', off);
};
setHold(gas, v=> this.keys['TouchGas']=v);
setHold(brake, v=> this.keys['TouchBrake']=v);
setHold(hand, v=> this.keys['TouchHand']=v);
}
read(){
let accel=0, brake=0, steer=0;
if(this.keys['KeyW']||this.keys['ArrowUp']||this.keys['TouchGas']) accel+=1;
if(this.keys['KeyS']||this.keys['ArrowDown']||this.keys['TouchBrake']) brake+=1;
if(this.keys['KeyA']||this.keys['ArrowLeft']) steer-=1;
if(this.keys['KeyD']||this.keys['ArrowRight']) steer+=1;
if(Math.abs(this.joyX)>0.08) steer+=this.joyX;
let hand = this.keys['Space']||this.keys['TouchHand'];
// combine accel/brake
let a = accel - brake*0.7;
if(brake && !accel) a = -0.7;
return {accel: clamp(a,-1,1), steer: clamp(steer,-1,1), handbrake: hand};
}
}
// ── class: Game ──
class Game {
constructor(){
this.canvas=document.getElementById('gameCanvas');
this.scene=new THREE.Scene();
this.scene.background=new THREE.Color(CONFIG.fogColor);
this.renderer=new THREE.WebGLRenderer({canvas:this.canvas, antialias:true});
this.renderer.setPixelRatio(Math.min(devicePixelRatio,2));
this.renderer.shadowMap.enabled=true; this.renderer.shadowMap.type=THREE.PCFSoftShadowMap;
this.camera=new THREE.PerspectiveCamera(68, innerWidth/innerHeight, 0.1, 900);
this.track=new Track();
this.car=new Car();
this.input=new Input();
this.audio=new AudioSynth();
this.particles=new Particles(this.scene);
this.time=0; this.raceTime=0; this.lapTimes=[]; this.best=Infinity; this.currentLap=1; this.raceStarted=false; this.raceOver=false;
this.checkIdx=1; this.lapStartAccum=0; this.progress=0;
this.camMode=0;
this.camPos=new THREE.Vector3(); this.camTarget=new THREE.Vector3();
this.countdownState=0;
this.lapFlash=0;
this.minimap=document.getElementById('minimap'); this.mmCtx=this.minimap.getContext('2d');
this.raf=null; this.paused=false;
this.lastSkidPos=null;
this.lastRawAccum=0;
}
init(){
const scene=this.scene;
scene.fog=new THREE.Fog(CONFIG.fogColor, 70, 320);
const amb=new THREE.AmbientLight(0xffffff,0.55); scene.add(amb);
const sun=new THREE.DirectionalLight(0xfff6e0,1.2); sun.position.set(120,160,60); sun.castShadow=true;
sun.shadow.mapSize.set(2048,2048);
sun.shadow.camera.left=-150; sun.shadow.camera.right=150; sun.shadow.camera.top=150; sun.shadow.camera.bottom=-150;
sun.shadow.camera.near=10; sun.shadow.camera.far=400;
scene.add(sun);
this.track.buildMeshes(scene);
buildScenery(this.track, scene);
scene.add(this.car.buildModel());
this.car.reset(this.track);
this.camPos.copy(this.car.pos).add(new THREE.Vector3(0,4,-10));
this.camTarget.copy(this.car.pos);
this.resize(); window.addEventListener('resize', ()=>this.resize());
document.getElementById('btnGo').addEventListener('click', ()=>{ if(!this.audio.enabled) this.audio.init(); this.startCountdown(); });
document.getElementById('btnResume').addEventListener('click', ()=> this.togglePause(false));
window.addEventListener('keydown', e=>{
if(e.code==='KeyR') this.restart();
if(e.code==='KeyP') this.togglePause();
if(e.code==='KeyC'){ this.camMode=1-this.camMode; }
if(e.code==='Enter' && this.raceOver) this.restart();
});
document.getElementById('overlay').classList.remove('hidden');
this.loop(0);
this.drawMinimap();
}
resize(){
this.renderer.setSize(innerWidth, innerHeight, false);
this.camera.aspect=innerWidth/innerHeight; this.camera.updateProjectionMatrix();
}
startCountdown(){
this.audio.resume();
this.car.reset(this.track);
this.raceTime=0; this.currentLap=1; this.lapTimes=[]; this.best=Infinity;
this.raceStarted=false; this.raceOver=false; this.checkIdx=1; this.lapFlash=0;
this.countdownState=3; this.paused=false; this.lastRawAccum=this.track.points[0].accum;
document.getElementById('overlay').classList.add('hidden');
document.getElementById('btnResume').classList.add('hidden');
this.doCount();
}
doCount(){
const el=document.getElementById('countdown');
if(this.countdownState<0){
el.textContent=''; el.classList.remove('pop');
this.raceStarted=true; this.raceTime=0; this.audio.beep(0);
return;
}
let txt = this.countdownState===0? 'GO!': String(this.countdownState);
el.textContent=txt; el.classList.remove('pop'); void el.offsetWidth; el.classList.add('pop');
this.audio.beep(this.countdownState);
this.countdownState--;
setTimeout(()=>this.doCount(), this.countdownState===0? 650: 900);
}
restart(){
document.getElementById('overlay').classList.add('hidden');
this.startCountdown();
}
togglePause(force){
if(this.raceOver) return;
let to = typeof force==='boolean'? force: !this.paused;
if(!this.raceStarted && to) return;
this.paused=to;
let ov=document.getElementById('overlay');
if(this.paused){ ov.classList.remove('hidden'); document.getElementById('overlayTitle').textContent='PAUSED'; document.getElementById('overlayMsg').textContent='Take a breath. The mud isn\'t going anywhere.'; document.getElementById('btnGo').textContent='RESTART'; document.getElementById('btnResume').classList.remove('hidden'); }
else { ov.classList.add('hidden'); }
}
update(dt){
if(this.paused || !this.raceStarted || this.raceOver) return;
let inp=this.input.read();
let info=this.track.closestParam(this.car.pos);
this.progress=info.accumRaw;
let curRaw = info.accumRaw;
// handle wrap detection for lap validation
let deltaRaw = curRaw - this.lastRawAccum;
if(deltaRaw < -this.track.total*0.5) curRaw+= this.track.total; // crossed start line forward
// checkpoint advancing using circular forward diff
let gate = this.track.checkGates[this.checkIdx % this.track.checkGates.length];
let diff = (info.accum - gate.accum + this.track.total) % this.track.total;
// if we are just after gate (small diff), pass it
if(diff < 40){
this.checkIdx++;
if(this.checkIdx>=this.track.checkGates.length){
let lapT = this.raceTime;
this.lapTimes.push(lapT);
if(lapT<this.best) this.best=lapT;
if(this.currentLap>=CONFIG.laps){
this.finish();
}else{
this.currentLap++;
this.lapFlash=1;
this.raceTime=0;
this.checkIdx=1;
}
}
}
this.lastRawAccum = info.accumRaw;
let hit=this.car.update(dt, {accel: inp.accel, steer: inp.steer, handbrake: inp.handbrake}, info);
this.car.syncMesh();
this.audio.setEngine(this.car.rpm, this.car.speed, this.car.slip, this.car.surface);
if(hit){ this.audio.crash(clamp(this.car.speed/25,0,1)); this.particles.spawnSparks(this.car.pos); this.particles.spawnDust(this.car.pos, this.car.vel, 10, 0xcccccc); }
if(this.car.slip>0.55 || this.car.surface!==SURFACE.ASPHALT){
let c = this.car.surface===SURFACE.MUD? 2: this.car.surface===SURFACE.WATER? 3: Math.floor(this.car.slip*3);
if(c>0) this.particles.spawnDust(this.car.pos, this.car.vel, c, this.car.surface===SURFACE.MUD?0x6b4a30: this.car.surface===SURFACE.GRASS?0x5a7a4a:0xb8a88a);
}
if(Math.abs(this.car.steer)>0.2 && this.car.speed>6){
let rear = this.car.pos.clone().add(new THREE.Vector3(Math.sin(this.car.yaw+Math.PI)*1.6,0, Math.cos(this.car.yaw+Math.PI)*1.6));
if(this.lastSkidPos && rear.distanceTo(this.lastSkidPos)<3) this.particles.spawnSkidMark(this.lastSkidPos, rear);
this.lastSkidPos=rear;
} else { this.lastSkidPos=null; }
this.raceTime+=dt*1000;
this.updateCamera(dt);
this.particles.update(dt);
}
updateCamera(dt){
let car=this.car;
if(this.camMode===0){
let back = new THREE.Vector3(Math.sin(car.yaw),0,Math.cos(car.yaw)).multiplyScalar(-9 - car.speed*0.06 - car.slip*1.2);
let up = new THREE.Vector3(0, 2.8 + car.speed*0.015, 0);
let desired = car.pos.clone().add(back).add(up);
let target = car.pos.clone().add(new THREE.Vector3(0,0.8,0)).add(new THREE.Vector3(Math.sin(car.yaw),0,Math.cos(car.yaw)).multiplyScalar(2 + car.speed*0.02));
this.camTarget.lerp(target, dt*5);
let lag = 1 - clamp(car.speed/CONFIG.maxSpeed,0,1)*0.25;
this.camPos.lerp(desired, dt*(4*lag));
} else {
let high = car.pos.clone().add(new THREE.Vector3(0,22, -18));
this.camPos.lerp(high, dt*3);
this.camTarget.lerp(car.pos, dt*3);
}
this.camera.position.copy(this.camPos);
this.camera.lookAt(this.camTarget);
}
drawMinimap(){
let ctx=this.mmCtx, W=200,H=200;
ctx.clearRect(0,0,W,H);
ctx.save(); ctx.translate(W/2,H/2); ctx.scale(0.62,0.62);
ctx.beginPath();
for(let i=0;i<this.track.points.length;i++){
let p=this.track.points[i].pos;
let x=p.x, y=p.z;
if(i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
}
ctx.closePath(); ctx.strokeStyle='rgba(255,255,255,.18)'; ctx.lineWidth=12; ctx.stroke();
ctx.strokeStyle='#2a323a'; ctx.lineWidth=8; ctx.stroke();
// gates dots
for(let g of this.track.checkGates){
let p=this.track.points[g.idx].pos;
ctx.fillStyle= g.idx===0? '#ffec44' : 'rgba(255,255,255,.3)';
ctx.beginPath(); ctx.arc(p.x,p.z,1.2,0,Math.PI*2); ctx.fill();
}
let cp=this.car.pos;
ctx.fillStyle='#ffec44'; ctx.shadowColor='#ffec44'; ctx.shadowBlur=12;
ctx.beginPath(); ctx.arc(cp.x, cp.z, 3.2,0,Math.PI*2); ctx.fill();
ctx.restore();
}
updateHUD(){
document.getElementById('lapTxt').textContent=`${this.currentLap} / ${CONFIG.laps}`;
document.getElementById('timeTxt').textContent=fmtTime(this.raceStarted? this.raceTime:0);
document.getElementById('bestTxt').textContent=fmtTime(this.best);
let kmh=this.car.speed*3.6; document.getElementById('speedTxt').textContent=`${Math.round(kmh)} km/h`;
let gearTxt=document.getElementById('gearTxt');
gearTxt.textContent = this.car.speed<2? 'N': this.car.gear;
document.getElementById('rpmFill').style.width=`${this.car.rpm*100}%`;
let flag=document.getElementById('surfaceFlag');
let lab=SURFACE_CFG[this.car.surface].label;
if(lab){ flag.textContent=lab; flag.classList.add('show'); flag.style.background=SURFACE_CFG[this.car.surface].color; }
else flag.classList.remove('show');
if(this.lapFlash>0){
this.lapFlash-=0.02; document.getElementById('lapTxt').style.color=`rgba(255,236,68,${0.5+this.lapFlash*0.5})`;
} else {
document.getElementById('lapTxt').style.color='';
}
}
finish(){
this.raceOver=true;
this.audio.finish();
const ov=document.getElementById('overlay');
ov.classList.remove('hidden');
document.getElementById('overlayTitle').textContent='FINISHED!';
document.getElementById('overlayMsg').textContent='Great run. The track is chewing that time.';
let total=this.lapTimes.reduce((a,b)=>a+b,0);
let best=Math.min(...this.lapTimes);
document.getElementById('resultStats').textContent=`Total: ${fmtTime(total)}\nLaps: ${this.lapTimes.map((t,i)=>`L${i+1} ${fmtTime(t)}`).join(' ')}\nBest: ${fmtTime(best)}`;
document.getElementById('btnGo').textContent='RACE AGAIN';
document.getElementById('btnResume').classList.add('hidden');
}
loop(t){
requestAnimationFrame((nt)=>this.loop(nt));
let dt=Math.min(0.033, (t-(this._last||t))/1000); this._last=t;
if(!this._last) dt=0.016;
this.time+=dt;
if(!this.paused) this.update(dt);
this.updateHUD();
this.drawMinimap();
this.renderer.render(this.scene, this.camera);
}
}
// ── main ──
const game = new Game();
game.init();
</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.1","provider":"openrouter","persona":"composer","sessionId":"98bbe548-9ba7-4a36-b3b5-222e7bd14d1e","tokensIn":316676,"tokensOut":35118,"tokensTotal":351794,"turns":18,"toolCalls":18,"failedToolCalls":0,"timestamp":"2026-07-17T04:35:26.947Z"} -->