Rally
Deepseek v4 Flash Vision Exp · full
◈Preview unavailable — try playing the result
Browser check: Preview unavailable or timed out. This is a technical check, not a quality review.
Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 62,087 bytes · SHA-256 3e34215c7ff9
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Rally Rush 3D</title>
<style>
:root{
--accent:#ff5a1f;
--accent2:#ffd21f;
--dark:rgba(10,14,22,.72);
--panel:rgba(12,18,28,.82);
}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
html,body{margin:0;padding:0;height:100%;overflow:hidden;background:#0a0e16;
font-family:'Segoe UI',system-ui,-apple-system,Helvetica,Arial,sans-serif;
user-select:none;-webkit-user-select:none;touch-action:none;}
#gameCanvas{position:fixed;inset:0;display:block;width:100%;height:100%;}
/* ---------- HUD ---------- */
#hud{position:fixed;inset:0;pointer-events:none;z-index:5;color:#eef3fa;
text-shadow:0 2px 6px rgba(0,0,0,.55);}
.hud-card{background:var(--panel);border:1px solid rgba(255,255,255,.08);
border-radius:14px;backdrop-filter:blur(6px);box-shadow:0 6px 22px rgba(0,0,0,.35);}
#raceInfo{position:absolute;top:14px;left:14px;padding:10px 14px;min-width:178px;}
#raceInfo .row{display:flex;justify-content:space-between;align-items:baseline;gap:14px;}
#raceInfo .label{font-size:11px;letter-spacing:1.2px;color:#9fb1c8;text-transform:uppercase;}
#raceInfo .val{font-weight:700;font-variant-numeric:tabular-nums;}
#lapVal{font-size:15px;}
#timeVal{font-size:19px;color:#fff;}
#bestVal{font-size:15px;color:var(--accent2);}
#speedPanel{position:absolute;right:16px;bottom:16px;width:190px;padding:12px 14px;text-align:center;}
#speedometer{height:64px;width:100%;}
#speedVal{font-size:30px;font-weight:800;line-height:1;margin-top:2px;font-variant-numeric:tabular-nums;}
#speedUnit{font-size:11px;letter-spacing:2px;color:#9fb1c8;margin-top:2px;}
#gearBox{position:absolute;right:16px;top:14px;padding:8px 14px;text-align:center;min-width:86px;}
#gearVal{font-size:30px;font-weight:900;color:var(--accent2);}
#gearLbl{font-size:10px;letter-spacing:2px;color:#9fb1c8;}
#rpmBar{height:8px;width:100%;margin-top:5px;border-radius:6px;background:rgba(255,255,255,.12);overflow:hidden;}
#rpmFill{height:100%;width:0%;border-radius:6px;background:linear-gradient(90deg,#3ad26f,#ffd21f 65%,#ff4d3d);
transition:width .06s linear;}
#lapMsg{position:absolute;top:26%;left:50%;transform:translateX(-50%);font-size:38px;
font-weight:900;color:var(--accent2);opacity:0;transition:opacity .4s,transform .4s;letter-spacing:2px;}
#lapMsg.show{opacity:1;transform:translateX(-50%) scale(1.08);}
/* ---------- minimap ---------- */
#minimapWrap{position:absolute;top:14px;right:16px;text-align:right;pointer-events:none;}
#minimapTitle{font-size:10px;letter-spacing:2px;color:#9fb1c8;margin-bottom:4px;text-transform:uppercase;}
#minimap{border-radius:10px;border:1px solid rgba(255,255,255,.12);background:rgba(8,12,20,.5);}
/* ---------- overlays ---------- */
.overlay{position:fixed;inset:0;z-index:20;display:flex;align-items:center;justify-content:center;
background:rgba(3,6,12,.66);backdrop-filter:blur(5px);flex-direction:column;
opacity:0;pointer-events:none;transition:opacity .3s;}
.overlay.show{opacity:1;pointer-events:auto;}
#countdown .cd{font-size:clamp(70px,22vw,200px);font-weight:900;color:#fff;letter-spacing:4px;
text-shadow:0 0 40px rgba(255,255,255,.5);transform:scale(.6);opacity:0;}
#countdown .cd.tick{animation:cdPop .9s ease forwards;}
@keyframes cdPop{0%{transform:scale(.6);opacity:0;}18%{transform:scale(1.12);opacity:1;}
70%{transform:scale(1);opacity:1;}100%{transform:scale(.85);opacity:0;}}
#countdown .sub{font-size:clamp(16px,4vw,32px);font-weight:800;color:var(--accent);letter-spacing:3px;margin-top:6px;}
#pauseMenu{flex-direction:column;gap:16px;}
.menuTitle{font-size:clamp(30px,7vw,56px);font-weight:900;color:#fff;letter-spacing:3px;margin-bottom:6px;}
.btn{pointer-events:auto;cursor:pointer;border:none;outline:none;font-weight:800;font-size:18px;
padding:14px 34px;border-radius:12px;color:#fff;background:linear-gradient(135deg,var(--accent),#ff8a2e);
box-shadow:0 8px 24px rgba(255,90,31,.4);transition:transform .12s,box-shadow .12s;min-width:220px;
font-family:inherit;letter-spacing:1px;}
.btn:hover{transform:translateY(-2px);box-shadow:0 12px 30px rgba(255,90,31,.55);}
.btn:active{transform:translateY(1px);}
.btn.ghost{background:rgba(255,255,255,.1);box-shadow:none;border:1px solid rgba(255,255,255,.25);}
.btn.ghost:hover{background:rgba(255,255,255,.2);}
#finishScreen .finTitle{font-size:clamp(34px,8vw,68px);font-weight:900;color:var(--accent2);
text-shadow:0 0 34px rgba(255,210,31,.5);letter-spacing:2px;margin-bottom:4px;}
#finishScreen .finSub{font-size:clamp(15px,3vw,22px);color:#cdd9ea;margin-bottom:24px;}
#finishStats{display:flex;gap:26px;margin-bottom:30px;flex-wrap:wrap;justify-content:center;}
#finishStats .stat{text-align:center;background:var(--panel);padding:16px 24px;border-radius:14px;
border:1px solid rgba(255,255,255,.1);min-width:150px;}
#finishStats .stat .lbl{font-size:11px;letter-spacing:2px;color:#9fb1c8;text-transform:uppercase;}
#finishStats .stat .num{font-size:30px;font-weight:900;color:#fff;font-variant-numeric:tabular-nums;margin-top:6px;}
#finishStats .stat .num.gold{color:var(--accent2);}
/* ---------- touch controls ---------- */
#touch{position:fixed;inset:0;z-index:10;pointer-events:none;display:none;}
@media (pointer:coarse),(max-width:820px){
#touch{display:block;}
#speedPanel,#gearBox,#raceInfo{transform:scale(.86);transform-origin:top right;}
}
.tbtn{position:absolute;pointer-events:auto;display:flex;align-items:center;justify-content:center;
border-radius:50%;background:rgba(20,28,42,.55);border:2px solid rgba(255,255,255,.25);
color:#fff;font-weight:900;font-size:22px;backdrop-filter:blur(4px);touch-action:none;
box-shadow:0 6px 16px rgba(0,0,0,.35);}
.tbtn:active{background:rgba(255,90,31,.65);}
#steerLeft{left:16px;bottom:78px;width:86px;height:86px;}
#steerRight{left:118px;bottom:78px;width:86px;height:86px;}
#btnGas{right:16px;bottom:96px;width:96px;height:96px;background:rgba(70,170,60,.45);}
#btnGas.active{background:rgba(90,200,70,.7);}
#btnBrake{right:122px;bottom:70px;width:78px;height:78px;background:rgba(200,60,60,.45);}
#btnHand{right:16px;bottom:210px;width:70px;height:70px;background:rgba(255,170,40,.4);font-size:15px;}
#btnGas span,#btnBrake span,#btnHand span{font-size:12px;letter-spacing:1px;}
#touch .txtHint{position:absolute;left:50%;bottom:16px;transform:translateX(-50%);color:#fff;
opacity:.0;font-size:13px;letter-spacing:2px;text-align:center;pointer-events:none;line-height:1.5;}
</style>
</head>
<body>
<canvas id="gameCanvas"></canvas>
<!-- Touch controls -->
<div id="touch">
<div class="tbtn" id="steerLeft">◀</div>
<div class="tbtn" id="steerRight">▶</div>
<div class="tbtn" id="btnGas"><span>GAS</span></div>
<div class="tbtn" id="btnBrake"><span>BRAKE</span></div>
<div class="tbtn" id="btnHand"><span>DRIFT</span></div>
<div class="txtHint">◀ ▶ steer • GAS / BRAKE / DRIFT</div>
</div>
<!-- HUD -->
<div id="hud">
<div id="raceInfo" class="hud-card">
<div class="row"><span class="label">Lap</span><span class="val" id="lapVal">1 / 3</span></div>
<div class="row"><span class="label">Time</span><span class="val" id="timeVal">0:00.00</span></div>
<div class="row"><span class="label">Best</span><span class="val" id="bestVal">--:--.--</span></div>
<div class="row"><span class="label">Check</span><span class="val" id="checkVal">0 / 6</span></div>
</div>
<div id="gearBox" class="hud-card">
<div id="gearVal">N</div>
<div id="gearLbl">GEAR</div>
<div id="rpmBar"><div id="rpmFill"></div></div>
</div>
<div id="speedPanel" class="hud-card">
<canvas id="speedometer" width="160" height="64"></canvas>
<div id="speedVal">0</div>
<div id="speedUnit">KM/H</div>
</div>
<div id="minimapWrap">
<div id="minimapTitle">Course</div>
<canvas id="minimap" width="150" height="150"></canvas>
</div>
<div id="lapMsg"></div>
</div>
<!-- Countdown -->
<div id="countdown" class="overlay show">
<div class="cd" id="cdText">3</div>
<div class="sub" id="cdSub">GET READY</div>
</div>
<!-- Pause menu -->
<div id="pauseMenu" class="overlay">
<div class="menuTitle">PAUSED</div>
<button class="btn" id="btnResume">Resume</button>
<button class="btn ghost" id="btnRestart2">Restart</button>
</div>
<!-- Finish screen -->
<div id="finishScreen" class="overlay">
<div class="finTitle">FINISH!</div>
<div class="finSub">Great run, driver.</div>
<div id="finishStats">
<div class="stat"><div class="lbl">Total Time</div><div class="num" id="finTotal">0:00.00</div></div>
<div class="stat"><div class="lbl">Best Lap</div><div class="num gold" id="finBest">0:00.00</div></div>
</div>
<button class="btn" id="btnRestartF">Restart Race</button>
</div>
<!-- Three.js from CDN -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
/* ============================================================
RALLY RUSH 3D — a single-file procedural rally racing game
Structure: Config -> Utils -> Audio -> Track -> Scene ->
Car -> Particles -> Input -> Camera -> HUD -> Main loop
============================================================ */
(function(){
'use strict';
/* ------------------------------------------------------------
CONFIG / TUNING
------------------------------------------------------------ */
const CFG = {
trackSample: 720, // sample points around the loop
trackHalf: 6.5, // half width of asphalt
shoulder: 1.9, // extra shoulder width (gravel)
boundaryMargin: 34, // lateral distance before hard bounce wall
laps: 3,
gates: 6, // checkpoint gates (start also acts as a gate)
cars: { // per-surface physics
tarmac:{ grip:1.00, accel:13.5, maxSpeed:27.0, drag:0.9 },
dirt: { grip:0.70, accel:11.0, maxSpeed:22.0, drag:0.9 },
grass: { grip:0.40, accel: 6.5, maxSpeed:13.5, drag:1.6 },
mud: { grip:0.22, accel: 3.5, maxSpeed: 8.5, drag:3.2 }
},
reverseMax: 8,
carRadius: 1.5,
wheelBase: 2.6,
};
/* ------------------------------------------------------------
SHORT UTILS
------------------------------------------------------------ */
const U = {
clamp:(v,a,b)=>v<a?a:(v>b?b:v),
lerp:(a,b,t)=>a+(b-a)*t,
rand:(a,b)=>a+Math.random()*(b-a),
randInt:(a,b)=>Math.floor(U.rand(a,b+1)),
// signed distance from point to segment, returns t in [0,1]
nearestParam(px,pz,ax,az,bx,bz){
const dx=bx-ax, dz=bz-az, L2=dx*dx+dz*dz;
let t = L2>1e-9 ? ((px-ax)*dx+(pz-az)*dz)/L2 : 0;
t=U.clamp(t,0,1);
const cx=ax+dx*t, cz=az+dz*t;
return { t, d2:(px-cx)*(px-cx)+(pz-cz)*(pz-cz), cx, cz };
},
fmtTime(s){
if(s==null||!isFinite(s)) return '--:--.--';
s=Math.max(0,s);
const m=Math.floor(s/60), sec=s-m*60;
const secStr=sec<10?'0':'';
return m+':'+secStr+sec.toFixed(2);
}
};
/* DOM shorthand */
const $=id=>document.getElementById(id);
const canvasEl=$('gameCanvas');
const touchEl=$('touch');
/* ------------------------------------------------------------
AUDIO — fully synthesized with Web Audio API
------------------------------------------------------------ */
const AudioSys = {
ctx:null, master:null, engineGain:null, engineOsc1:null, engineOsc2:null,
engineFilter:null, skidGain:null, skidNoise:null, started:false, muted:false,
init(){
if(this.ctx) return;
const AC = window.AudioContext||window.webkitAudioContext;
if(!AC) return;
this.ctx = new AC();
this.master = this.ctx.createGain();
this.master.gain.value = 0.85;
this.master.connect(this.ctx.destination);
// engine: two detuned saws -> lowpass -> gain
this.engineGain = this.ctx.createGain(); this.engineGain.gain.value=0.0;
this.engineFilter = this.ctx.createBiquadFilter(); this.engineFilter.type='lowpass';
this.engineFilter.frequency.value=520; this.engineFilter.Q.value=1.2;
this.engineOsc1=this.ctx.createOscillator(); this.engineOsc1.type='sawtooth';
this.engineOsc2=this.ctx.createOscillator(); this.engineOsc2.type='square';
this.engineOsc2.detune.value=8;
this.engineOsc1.connect(this.engineFilter); this.engineOsc2.connect(this.engineFilter);
this.engineFilter.connect(this.engineGain); this.engineGain.connect(this.master);
this.engineOsc1.start(); this.engineOsc2.start();
// skid / gravel loop (filtered noise)
const len=this.ctx.sampleRate*1.0;
const buf=this.ctx.createBuffer(1,len,this.ctx.sampleRate);
const d=buf.getChannelData(0);
for(let i=0;i<len;i++) d[i]=Math.random()*2-1;
const src=this.ctx.createBufferSource(); src.buffer=buf; src.loop=true;
const bp=this.ctx.createBiquadFilter(); bp.type='bandpass'; bp.frequency.value=700; bp.Q.value=0.7;
this.skidGain=this.ctx.createGain(); this.skidGain.gain.value=0.0;
src.connect(bp); bp.connect(this.skidGain); this.skidGain.connect(this.master);
src.start();
},
resume(){ if(this.ctx&&this.ctx.state==='suspended') this.ctx.resume(); },
engine(speed,gearPitch,throttle){
if(!this.ctx) return;
const rpm=U.clamp(gearPitch,0,1);
const freq=40 + rpm*180 + speed*2.2;
this.engineOsc1.frequency.setTargetAtTime(freq,this.ctx.currentTime,0.05);
this.engineOsc2.frequency.setTargetAtTime(freq*0.5,this.ctx.currentTime,0.05);
this.engineFilter.frequency.setTargetAtTime(300+rpm*1400,this.ctx.currentTime,0.05);
const vol=(0.12+0.28*throttle+0.18*(speed>2?1:0))* (this.started?1:0);
this.engineGain.gain.setTargetAtTime(vol,this.ctx.currentTime,0.06);
},
setSkid(level){
if(!this.ctx) return;
this.skidGain.gain.setTargetAtTime(level*0.5,this.ctx.currentTime,0.05);
},
beep(freq,dur,type='square',vol=0.3,when=0){
if(!this.ctx) return;
const t=this.ctx.currentTime+(when||0);
const o=this.ctx.createOscillator(); o.type=type; o.frequency.value=freq;
const g=this.ctx.createGain();
g.gain.setValueAtTime(0,t); g.gain.linearRampToValueAtTime(vol,t+0.01);
g.gain.exponentialRampToValueAtTime(0.0001,t+dur);
o.connect(g); g.connect(this.master); o.start(t); o.stop(t+dur+0.02);
},
toggle(on){ this.muted=!on; if(this.master)this.master.gain.value=on?0.85:0.0; }
};
/* ------------------------------------------------------------
TRACK — procedural closed loop w/ hills, curves, dips
Builds a dense centreline, per-point surface type, gates
------------------------------------------------------------ */
const Track = {
pts:[], // dense sample points {x,y,z, tanX,tanZ, leftX,leftZ, half, surf, dist}
length:0,
gates:[{frac:0}], // fractions (first is start/finish)
// control points (x,z,y)
ctrl:[
[ 0, 90, 4],[ 42, 70, 8],[ 70, 30,10],[ 70,-30, 8],[ 40,-70, 5],
[ 0,-90, 4],[-40,-70, 6],[-70,-28, 9],[-70, 28, 8],[-42, 70, 6]
],
build(){
const c=this.ctrl.map(p=>new THREE.Vector3(p[0],p[2],p[1]));
const curve=new THREE.CatmullRomCurve3(c,true,'catmullrom',0.5);
const N=CFG.trackSample;
this.pts.length=0;
let dist=0;
for(let i=0;i<N;i++){
const t=i/N;
const p=curve.getPointAt(t);
const tan=curve.getTangentAt(t);
// elevation: gentle rolling hills via signed distance from centre
const elev=p.y;
// per-point surface — make two "dirt" stretches, rest asphalt
const surf=this.surfaceAt(i/N);
// half width slightly variable
const half=CFG.trackHalf + Math.sin(t*Math.PI*4+1.3)*0.8 + 0.6;
const tx=tan.x, tz=tan.z;
const len=Math.hypot(tx,tz)||1;
const tnx=tx/len, tnz=tz/len;
this.pts.push({
x:p.x, y:elev, z:p.z,
tanX:tnx, tanZ:tnz,
leftX:tnz, leftZ:-tnx, // left normal
half, surf,
dist:dist
});
if(i>0){ const q=this.pts[i-1]; dist+=Math.hypot(p.x-q.x,p.z-q.z); }
this.pts[i].dist=dist;
}
this.length=dist;
// fix cumulative dist interpolation continuity
let run=0;
for(let i=0;i<N;i++){ if(i>0){ const q=this.pts[i-1]; run+=Math.hypot(this.pts[i].x-q.x,this.pts[i].z-q.z);} this.pts[i].dist=run; }
this.length=run;
// checkpoint gates spread around loop (index 0 is start)
this.gates=[];
for(let g=0;g<CFG.gates;g++) this.gates.push({frac:g/CFG.gates});
},
surfaceAt(frac){
// two dirt sections, otherwise asphalt — smooth-ish switches
const a= Math.sin(frac*Math.PI*2);
return a>0.35 ? (a>0.75?'dirt':'tarmac') : (frac<0.5?'tarmac':'tarmac');
},
// centre height at a sample
sampleY(i){ return this.pts[i % this.pts.length].y; },
// find nearest sample to a world pos, returns {idx, lat, progress, surf}
locate(x,z){
const P=this.pts, N=P.length;
let best=0, bestD=Infinity;
for(let i=0;i<N;i++){
const dx=x-P[i].x, dz=z-P[i].z;
const d=dx*dx+dz*dz;
if(d<bestD){ bestD=d; best=i; }
}
const p=P[best];
const dx=x-p.x, dz=z-p.z;
const lat=dx*p.leftX + dz*p.leftZ; // signed lateral offset
const along=dx*p.tanX + dz*p.tanZ; // along-track component
const progress=(p.dist+along)/this.length;
const pr=(progress%1+1)%1;
return { idx:best, lat, absLat:Math.abs(lat), progress:pr, surf:p.surf };
},
// lateral surface: tarmac on, shoulder gravel just off, grass further
surfaceFor(loc){
const half=this.pts[loc.idx].half;
if(loc.absLat<=half) return loc.surf; // on asphalt/dirt
if(loc.absLat<=half+CFG.shoulder) return 'dirt'; // shoulder gravel
return 'grass';
},
// world-space mud pools (very slow water)
mudPools:[
{x:-14, z:0, r:10},
{x: 30, z:34, r:8 }
],
inMud(x,z,margin){
for(const m of this.mudPools){
const dx=x-m.x, dz=z-m.z;
if(dx*dx+dz*dz < (m.r+margin)*(m.r+margin)) return true;
}
return false;
}
};
/* ------------------------------------------------------------
RENDERER / WORLD — renderer, lights, sky, terrain, track,
scenery, and distant decorations
------------------------------------------------------------ */
const World = {
renderer:null, scene:null, camera:null,
sun:null, hemi:null, sky:null,
trackMesh:null,
obstacles:[], // {x,z,r,type} for collisions
trees:null, rocks:null, // InstancedMesh
bound:120,
init(){
const rnd=this.renderer=new THREE.WebGLRenderer({canvas:canvasEl,antialias:true,powerPreference:'high-performance'});
rnd.setPixelRatio(Math.min(window.devicePixelRatio||1,2));
rnd.setSize(window.innerWidth,window.innerHeight);
rnd.shadowMap.enabled=true;
rnd.shadowMap.type=THREE.PCFSoftShadowMap;
rnd.outputEncoding=THREE.sRGBEncoding;
this.scene=new THREE.Scene();
this.scene.background=new THREE.Color(0x9fd7ff);
this.scene.fog=new THREE.Fog(0xa9d8ef,120,360);
this.camera=new THREE.PerspectiveCamera(62,window.innerWidth/window.innerHeight,0.1,900);
this.camera.position.set(0,30,140);
this._lights();
this._sky();
this._buildTrack();
this._buildTerrain();
this._buildScenery();
},
_lights(){
const hemi=new THREE.HemisphereLight(0xcfe8ff,0x5a6a4a,0.75);
this.scene.add(hemi);
const sun=new THREE.DirectionalLight(0xfff0d8,1.15);
sun.position.set(120,160,80);
sun.castShadow=true;
sun.shadow.mapSize.set(2048,2048);
sun.shadow.camera.near=10; sun.shadow.camera.far=520;
const S=70; sun.shadow.camera.left=-S; sun.shadow.camera.right=S;
sun.shadow.camera.top=S; sun.shadow.camera.bottom=-S;
sun.shadow.bias=-0.0004;
this.scene.add(sun); this.scene.add(sun.target);
this.sun=sun; this.hemi=hemi;
this._amb=new THREE.AmbientLight(0x304050,0.35);
this.scene.add(this._amb);
},
_sky(){
const geo=new THREE.SphereGeometry(560,24,16);
const cols=[]; const pos=geo.attributes.position;
const top=new THREE.Color(0x3b79c4), mid=new THREE.Color(0x9fd7ff), bot=new THREE.Color(0xf3e6c8);
for(let i=0;i<pos.count;i++){
const y=pos.getY(i)/560;
const c=new THREE.Color();
if(y>0){ c.copy(mid).lerp(top,Math.min(1,y*1.3)); }
else c.copy(mid).lerp(bot,Math.min(1,-y*2.2));
cols.push(c.r,c.g,c.b);
}
geo.setAttribute('color',new THREE.Float32BufferAttribute(cols,3));
const mat=new THREE.MeshBasicMaterial({vertexColors:true,side:THREE.BackSide,fog:false});
this.sky=new THREE.Mesh(geo,mat); this.scene.add(this.sky);
// a few cartoon clouds
const cg=new THREE.Group();
const cm=new THREE.MeshBasicMaterial({color:0xffffff,transparent:true,opacity:.9,fog:false});
for(let i=0;i<9;i++){
const cl=new THREE.Group();
for(let j=0;j<4;j++){
const m=new THREE.Mesh(new THREE.SphereGeometry(U.rand(4,9),8,6),cm);
m.position.set(j*7-U.rand(2,8),U.rand(-1,2),U.rand(-2,2));
m.scale.y=0.55; cl.add(m);
}
cl.position.set(U.rand(-260,260),U.rand(70,130),U.rand(-260,260));
cg.add(cl);
}
this.scene.add(cg);
},
/* --- track ribbon with vertex colours + shoulders --- */
_buildTrack(){
const P=Track.pts, N=P.length;
// helper to build a ribbon given half-width function
const buildRibbon=(halfFn, extraY, colorFn)=>{
const posA=[], colA=[], uvA=[], idxA=[];
for(let i=0;i<N;i++){
const p=P[i];
const half=halfFn(p,i);
const y=p.y+extraY;
const lx=p.x+p.leftX*half, lz=p.z+p.leftZ*half;
const rx=p.x-p.leftX*half, rz=p.z-p.leftZ*half;
posA.push(lx,y,lz, rx,y,rz);
const cl=colorFn(p,i,1), cr=colorFn(p,i,-1);
colA.push(cl.r,cl.g,cl.b, cr.r,cr.g,cr.b);
uvA.push(0,i/6, 1,i/6);
}
for(let i=0;i<N;i++){
const a=i*2, b=i*2+1, c=((i+1)%N)*2, d=((i+1)%N)*2+1;
idxA.push(a,b,c, b,d,c);
}
const g=new THREE.BufferGeometry();
g.setIndex(idxA);
g.setAttribute('position',new THREE.Float32BufferAttribute(posA,3));
g.setAttribute('color',new THREE.Float32BufferAttribute(colA,3));
g.setAttribute('uv',new THREE.Float32BufferAttribute(uvA,2));
const n=[]; for(let i=0;i<posA.length/3;i++) n.push(0,1,0);
g.setAttribute('normal',new THREE.Float32BufferAttribute(n,3));
return g;
};
// shoulder (gravel) strip below
const shoulderG=buildRibbon((p)=>p.half+CFG.shoulder, -0.12, ()=>new THREE.Color(0x8d6b47));
const shoulderM=new THREE.Mesh(shoulderG,new THREE.MeshStandardMaterial({vertexColors:true,roughness:1,metalness:0}));
shoulderM.receiveShadow=true; this.scene.add(shoulderM);
// asphalt/dirt surface with per-surface colour
const trackG=buildRibbon((p)=>p.half+0.05, 0, (p)=>{
if(p.surf==='dirt') return new THREE.Color(0x9a6f43);
return new THREE.Color(0x3c3f45);
});
const trackM=new THREE.Mesh(trackG,new THREE.MeshStandardMaterial({vertexColors:true,roughness:0.85,metalness:0}));
trackM.receiveShadow=true; this.scene.add(trackM);
this.trackMesh=trackM;
// dashed centre line
this._buildCentreLine();
},
_buildCentreLine(){
const P=Track.pts, N=P.length;
const cmat=new THREE.MeshBasicMaterial({color:0xffe28a});
const cg=new THREE.BufferGeometry();
const pos=[], idx=[];
let vi=0;
for(let i=0;i<N;i+=4){
const p0=P[i], p1=P[(i+2)%N];
const y=U.lerp(p0.y,p1.y,0.5)+0.06;
pos.push(p0.x,y,p0.z, p1.x,y,p1.z);
idx.push(vi*2,vi*2+1); vi++;
}
const line=new THREE.LineSegments(cg.setAttribute('position',new THREE.Float32BufferAttribute(pos,3)),cmat);
line.geometry.setIndex(idx);
this.scene.add(line);
},
/* --- rolling terrain carved under the track --- */
_buildTerrain(){
const size=1000, seg=140;
const g=new THREE.PlaneGeometry(size,size,seg,seg);
g.rotateX(-Math.PI/2);
const pos=g.attributes.position;
const cols=[];
const grassA=new THREE.Color(0x5f8f3f), grassB=new THREE.Color(0x79a852), grassC=new THREE.Color(0x4a7a34);
for(let i=0;i<pos.count;i++){
const x=pos.getX(i), z=pos.getZ(i);
// nearest track sample (coarse)
let best=0,bd=Infinity;
const P=Track.pts;
for(let j=0;j<P.length;j++){ const dx=x-P[j].x,dz=z-P[j].z,d=dx*dx+dz*dz; if(d<bd){bd=d;best=j;} }
const p=P[best];
const lat=Math.hypot(x-p.x,z-p.z);
const roadDist=lat-(p.half+1.2);
let hill=1.8+2.0*Math.sin(x*0.02+1.0)*Math.sin(z*0.021)+1.2*Math.cos((x+z)*0.012);
let y;
if(roadDist<0){ y=p.y-0.35; }
else{ const fade=U.clamp(roadDist/26,0,1); y=U.lerp(p.y-0.35,hill,fade); }
pos.setY(i,y);
const n=0.5+0.5*Math.sin(x*0.05)*Math.cos(z*0.05);
const c=grassA.clone().lerp(grassB,n).lerp(grassC,U.clamp((y-2)/8,0,1));
cols.push(c.r,c.g,c.b);
}
g.setAttribute('color',new THREE.Float32BufferAttribute(cols,3));
g.computeVertexNormals();
const m=new THREE.Mesh(g,new THREE.MeshStandardMaterial({vertexColors:true,roughness:1,metalness:0}));
m.receiveShadow=true; this.scene.add(m);
this.terrain=m;
},
/* --- trees, rocks, signs, barriers, banners (instanced + meshes) --- */
_buildScenery(){
const N=Track.pts.length;
// collision obstacles + instanced decorations
const trees=[], rocks=[];
// helpers to push a world point at given lateral offset from a sample
const pointAt=(i,lat)=>{
const p=Track.pts[((i%N)+N)%N];
return {x:p.x+p.leftX*lat, z:p.z+p.leftZ*lat, y:p.y};
};
// trees: every few samples on both sides, at reachable & far offsets
for(let i=0;i<N;i+=6){
const side=(i%2?1:-1);
const lat=side*U.rand(CFG.trackHalf+8,CFG.boundaryMargin+8);
const pt=pointAt(i,lat);
const h=U.rand(5,11);
trees.push({x:pt.x,z:pt.z,y:pt.y,h,seed:U.rand(0,1)});
}
// extra far forest
for(let k=0;k<60;k++){
const i=U.randInt(0,N-1), lat=(k%2?1:-1)*U.rand(CFG.boundaryMargin+12,70);
const pt=pointAt(i,lat);
trees.push({x:pt.x,z:pt.z,y:pt.y,h:U.rand(6,12),seed:U.rand(0,1)});
}
// trees within reachable band become collision obstacles
for(const t of trees){
const l=Track.locate(t.x,t.z);
if(l.absLat<CFG.boundaryMargin-2) this.obstacles.push({x:t.x,z:t.z,r:1.1,type:'tree',h:t.h});
}
// rocks
for(let i=0;i<N;i+=11){
const side=(i%2?1:-1);
const lat=side*U.rand(CFG.trackHalf+4,CFG.boundaryMargin+4);
const pt=pointAt(i,lat);
rocks.push({x:pt.x,z:pt.z,y:pt.y,r:U.rand(0.8,1.9),seed:U.rand(0,1)});
}
for(const r of rocks){
const l=Track.locate(r.x,r.z);
if(l.absLat<CFG.boundaryMargin-2) this.obstacles.push({x:r.x,z:r.z,r:r.r,type:'rock'});
}
this._makeTrees(trees);
this._makeRocks(rocks);
this._makeSignsAndBarriers();
this._makeBanners();
},
_makeTrees(trees){
const geom=new THREE.ConeGeometry(1,1,6);
const mat=new THREE.MeshStandardMaterial({color:0x2f6b34,roughness:1});
const trunkG=new THREE.CylinderGeometry(0.22,0.3,1,5);
const trunkM=new THREE.MeshStandardMaterial({color:0x6b4a2a,roughness:1});
const ins=new THREE.InstancedMesh(geom,mat,trees.length);
const tr=new THREE.InstancedMesh(trunkG,trunkM,trees.length);
const m=new THREE.Matrix4(), q=new THREE.Quaternion(), s=new THREE.Vector3();
// darker / lighter foliage via vertex colour not easy on instanced; vary scale & some via material clone
for(let i=0;i<trees.length;i++){
const t=trees[i];
const h=t.h;
q.setFromAxisAngle(new THREE.Vector3(0,1,0),t.seed*6.28);
s.set(h*0.42,h*0.85,h*0.42);
m.compose(new THREE.Vector3(t.x,t.y+h*0.42+h*0.42,t.z),q,s);
ins.setMatrixAt(i,m);
s.set(1,h*0.42,1);
m.compose(new THREE.Vector3(t.x,t.y+h*0.21,t.z),q,s);
tr.setMatrixAt(i,m);
}
ins.castShadow=true; tr.castShadow=true;
ins.instanceMatrix.needsUpdate=true; tr.instanceMatrix.needsUpdate=true;
this.scene.add(ins); this.scene.add(tr);
this.trees=ins;
},
_makeRocks(rocks){
const geom=new THREE.IcosahedronGeometry(1,0);
const mat=new THREE.MeshStandardMaterial({color:0x8a8f96,roughness:1,flatShading:true});
const ins=new THREE.InstancedMesh(geom,mat,rocks.length);
const m=new THREE.Matrix4();
for(let i=0;i<rocks.length;i++){
const r=rocks[i];
const q=new THREE.Quaternion().setFromEuler(new THREE.Euler(U.rand(-.4,.4),U.rand(0,6.28),U.rand(-.4,.4)));
m.compose(new THREE.Vector3(r.x,r.y+r.r*0.4,r.z),q,new THREE.Vector3(r.r,r.r*0.8,r.r));
ins.setMatrixAt(i,m);
}
ins.castShadow=true; ins.receiveShadow=true;
ins.instanceMatrix.needsUpdate=true;
this.scene.add(ins); this.rocks=ins;
},
_makeSignsAndBarriers(){
const N=Track.pts.length;
// billboard / warning signs near sharp corners (high curvature zones)
const signMat=new THREE.MeshStandardMaterial({color:0xdd3333,roughness:.7});
const boardCols=[0xffd21f,0x2f6bff,0xffffff,0x33c46b];
for(let i=0;i<N;i+=26){
const p=Track.pts[i];
const side=(i%2?1:-1);
const lat=side*(p.half+2.6);
const x=p.x+p.leftX*lat, z=p.z+p.leftZ*lat, y=p.y;
const dirIdx=(i+3)%N; const d=Track.pts[dirIdx];
const ang=Math.atan2(d.x-p.x,d.z-p.z);
const grp=new THREE.Group();
const pole=new THREE.Mesh(new THREE.CylinderGeometry(0.12,0.12,2.6,5),new THREE.MeshStandardMaterial({color:0x999999,roughness:.6}));
pole.position.y=1.3; grp.add(pole);
const col=new THREE.Color(boardCols[i%boardCols.length]);
const bd=new THREE.Mesh(new THREE.PlaneGeometry(2.8,1.4),new THREE.MeshStandardMaterial({color:col,roughness:.6,side:THREE.DoubleSide}));
bd.position.y=2.4; grp.add(bd);
// black stripe
const st=new THREE.Mesh(new THREE.PlaneGeometry(2.8,0.35),new THREE.MeshStandardMaterial({color:0x111111,side:THREE.DoubleSide}));
st.position.set(0,2.4,0.02); grp.add(st);
grp.position.set(x,y,z); grp.rotation.y=ang+Math.PI/2;
grp.traverse(o=>{if(o.isMesh){o.castShadow=true;}});
this.scene.add(grp);
this.obstacles.push({x,z,r:0.5,type:'sign'});
}
// guard rails / barriers on the outside of a few high-curve stretches
const rails=[];
for(let r=0;r<5;r++){
const start=Math.floor(U.rand(0,N));
const len=14+U.randInt(0,20);
const side=(r%2?1:-1);
for(let k=0;k<len;k++){
const p=Track.pts[(start+k)%N];
const lat=side*(p.half+0.7);
rails.push({x:p.x+p.leftX*lat,z:p.z+p.leftZ*lat,y:p.y});
}
// index-k barrier collision approximated by thin obstacle row
}
// barrier meshes: repurpose as a few long low walls along chosen arcs
this._makeGuardRail();
// barriers as chunky blocks
for(let r=0;r<4;r++){
const i=Math.floor(U.rand(0,N)), side=(r%2?1:-1);
const p=Track.pts[i], lat=side*(p.half+1.4);
this.obstacles.push({x:p.x+p.leftX*lat,z:p.z+p.leftZ*lat,r:0.9,type:'barrier'});
}
},
_makeGuardRail(){
// a few rows of simple rail posts+beam along outer edge in fixed arcs
const mats=new THREE.MeshStandardMaterial({color:0xc8ccd2,roughness:.4,metalness:.3});
const rails=[{a:0.10,b:0.20},{a:0.42,b:0.55},{a:0.75,b:0.86}];
for(const seg of rails){
for(let k=Math.floor(seg.a*Track.pts.length);k<Math.floor(seg.b*Track.pts.length);k+=3){
const p=Track.pts[k];
const pole=new THREE.Mesh(new THREE.CylinderGeometry(0.1,0.1,1.4,4),mats);
pole.position.set(p.x+p.leftX*(p.half+0.5),p.y+0.7,p.z+p.leftZ*(p.half+0.5));
pole.castShadow=true; this.scene.add(pole);
}
// beam
const bg=new THREE.BufferGeometry(); const pos=[];
for(let k=Math.floor(seg.a*Track.pts.length);k<=Math.floor(seg.b*Track.pts.length);k+=4){
const p=Track.pts[k%Track.pts.length];
pos.push(p.x+p.leftX*(p.half+0.5),p.y+1.25,p.z+p.leftZ*(p.half+0.5));
}
bg.setAttribute('position',new THREE.Float32BufferAttribute(pos,3));
const line=new THREE.Line(bg,mats); this.scene.add(line);
}
},
_makeBanners(){
// overhead finish gantry at the start gate (thin, non-blocking)
const p=Track.pts[0]; const ahead=Track.pts[10];
const ang=Math.atan2(ahead.x-p.x,ahead.z-p.z);
const grp=new THREE.Group();
const side=new THREE.Vector3(Math.cos(ang),0,-Math.sin(ang));
const half=p.half+1.6;
const postM=new THREE.MeshStandardMaterial({color:0xeeeded,roughness:.5});
for(const s of [-1,1]){
const post=new THREE.Mesh(new THREE.CylinderGeometry(0.22,0.28,7.5,6),postM);
post.position.copy(side.clone().multiplyScalar(s*half)).y+=3.75;
post.castShadow=true; grp.add(post);
}
// top gantry beam
const beam=new THREE.Mesh(new THREE.BoxGeometry(half*2,0.8,0.8),
new THREE.MeshStandardMaterial({color:0xffffff,roughness:.6}));
beam.position.y=7.2; beam.castShadow=true; grp.add(beam);
// FINISH text as a transparent overlay (no solid fill)
const txtC=document.createElement('canvas'); txtC.width=1024; txtC.height=128;
const cx=txtC.getContext('2d');
cx.clearRect(0,0,1024,128);
cx.fillStyle='#ff5a1f'; cx.font='900 96px sans-serif'; cx.textAlign='center'; cx.textBaseline='middle';
cx.lineWidth=10; cx.strokeStyle='#ffffff'; cx.strokeText('FINISH',512,64); cx.fillText('FINISH',512,64);
const tex=new THREE.CanvasTexture(txtC);
const txt=new THREE.Mesh(new THREE.PlaneGeometry(half*1.8,1.4),
new THREE.MeshBasicMaterial({map:tex,transparent:true,side:THREE.DoubleSide}));
txt.position.y=6.7; grp.add(txt);
// small checkered strip under the text
const st=new THREE.Mesh(new THREE.PlaneGeometry(half*1.8,0.4),
new THREE.MeshStandardMaterial({color:0xcaa23a,roughness:.5,side:THREE.DoubleSide}));
st.position.y=5.9; grp.add(st);
grp.position.set(p.x,p.y,p.z); grp.rotation.y=Math.PI/2+ang;
this.scene.add(grp);
},
/* per-frame: update shadow + sky follow the car */
follow(carPos){
this.sun.position.set(carPos.x+90,carPos.y+140,carPos.z+60);
this.sun.target.position.copy(carPos);
this.sun.target.updateMatrixWorld();
this.sky.position.copy(carPos);
},
resize(w,h){ this.camera.aspect=w/h; this.camera.updateProjectionMatrix();
this.renderer.setSize(w,h); }
};
/* ------------------------------------------------------------
CAR — procedural low-poly body + physics state
------------------------------------------------------------ */
const Car = {
group:null, wheels:[],
pos:new THREE.Vector3(), heading:0, vel:new THREE.Vector2(0,0),
steer:0, speed:0, gear:1, rpm:0,
throttle:0, brake:0, handbrake:false,
surface:'tarmac', grounded:true,
wheelSpin:0,
init(){
const g=new THREE.Group();
const bodyM=new THREE.MeshStandardMaterial({color:0xff5a1f,roughness:.45,metalness:.25});
const darkM=new THREE.MeshStandardMaterial({color:0x1c1f26,roughness:.6,metalness:.3});
const glassM=new THREE.MeshStandardMaterial({color:0x152030,roughness:.2,metalness:.4});
// main body
const body=new THREE.Mesh(new THREE.BoxGeometry(1.9,0.62,4.0),bodyM);
body.position.y=0.62; body.castShadow=true; g.add(body);
// cabin
const cab=new THREE.Mesh(new THREE.BoxGeometry(1.5,0.6,1.8),bodyM);
cab.position.set(0,1.05,-0.35); cab.castShadow=true; g.add(cab);
// windshield / windows
const win=new THREE.Mesh(new THREE.BoxGeometry(1.42,0.5,1.0),glassM);
win.position.set(0,1.05,0.35); g.add(win);
// front hood slope
const hood=new THREE.Mesh(new THREE.BoxGeometry(1.7,0.2,1.2),bodyM);
hood.position.set(0,0.72,1.25); g.add(hood);
// spoiler
const sp=new THREE.Group();
const wing=new THREE.Mesh(new THREE.BoxGeometry(1.9,0.12,0.5),darkM);
wing.position.y=1.35; wing.castShadow=true; sp.add(wing);
const strut=new THREE.Mesh(new THREE.BoxGeometry(0.12,0.5,0.2),darkM);
strut.position.set(0.5,1.1,-1.95); sp.add(strut);
const strut2=strut.clone(); strut2.position.x=-0.5; sp.add(strut2);
sp.position.z=-1.85; g.add(sp);
// headlights (front, +Z)
const hlM=new THREE.MeshStandardMaterial({color:0xffffff,emissive:0xfff2c0,emissiveIntensity:1});
for(const s of [-1,1]){
const hl=new THREE.Mesh(new THREE.BoxGeometry(0.34,0.24,0.1),hlM);
hl.position.set(s*0.55,0.58,2.02); g.add(hl);
}
// taillights
const tlM=new THREE.MeshStandardMaterial({color:0x330000,emissive:0xff2200,emissiveIntensity:1.6});
for(const s of [-1,1]){
const tl=new THREE.Mesh(new THREE.BoxGeometry(0.4,0.22,0.1),tlM);
tl.position.set(s*0.6,0.66,-2.02); g.add(tl);
}
// bumper / skirts
const skirt=new THREE.Mesh(new THREE.BoxGeometry(2.0,0.3,3.9),darkM);
skirt.position.y=0.34; g.add(skirt);
// wheels
const wheelG=new THREE.CylinderGeometry(0.42,0.42,0.34,14); wheelG.rotateZ(Math.PI/2);
const wheelM=new THREE.MeshStandardMaterial({color:0x14161a,roughness:.8});
const hubM=new THREE.MeshStandardMaterial({color:0x9aa0a8,roughness:.3,metalness:.6});
const wpos=[[-0.95,0.42,1.35],[0.95,0.42,1.35],[-0.95,0.42,-1.35],[0.95,0.42,-1.35]];
for(const w of wpos){
const wg=new THREE.Group();
const tire=new THREE.Mesh(wheelG,wheelM); tire.castShadow=true;
const hub=new THREE.Mesh(new THREE.CylinderGeometry(0.18,0.18,0.36,8),hubM); hub.rotation.z=Math.PI/2;
wg.add(tire); wg.add(hub); wg.position.set(w[0],w[1],w[2]);
g.add(wg); this.wheels.push(wg);
}
this.group=g;
this.scene=World.scene;
World.scene.add(g);
// spawn on start line
const p=Track.pts[0];
this.pos.set(p.x,p.y,p.z);
this.heading=Math.atan2(p.tanX,p.tanZ);
// face along forwards tangent
this.group.position.copy(this.pos);
this.group.rotation.y=this.heading;
},
surfaceCar(){
// decide surface + grip for current location
const loc=Track.locate(this.pos.x,this.pos.z);
let surf=Track.surfaceFor(loc);
if(Track.inMud(this.pos.x,this.pos.z,0)) surf='mud';
return surf;
},
update(dt,input){
const st=CFG.cars[this.surface]||CFG.cars.tarmac;
const th=input.throttle?1:0;
const brk=input.brake?1:0;
const hand=!!input.handbrake;
this.throttle=th; this.brake=brk; this.handbrake=hand;
// heading / steering
const f=Math.abs(this.vel.length());
const spd=this.vel.x*Math.sin(this.heading)+this.vel.y*Math.cos(this.heading);
const maxSp=st.maxSpeed;
const steerMax=U.lerp(0.62,0.16,U.clamp(f/maxSp,0,1));
const steerTarget=input.steer*steerMax;
this.steer+= (steerTarget-this.steer)*Math.min(1,dt*8);
// yaw rate depends on speed
const spdFactor=U.clamp(Math.abs(spd)/7,0,1)*(1-0.55*U.clamp(Math.abs(spd)/maxSp,0,1));
const steerRate=2.2;
this.heading += this.steer*steerRate*spdFactor*dt*(spd<0?-1:1);
// forward/right basis
const fx=Math.sin(this.heading), fz=Math.cos(this.heading);
const rx=fz, rz=-fx;
let vF=this.vel.x*fx+this.vel.y*fz;
let vL=this.vel.x*rx+this.vel.y*rz;
// throttle & brake
if(th>0){
vF += st.accel*(1-0.45*U.clamp(vF/maxSp,0,1))*dt;
}
// drag / rolling resistance
vF -= st.drag*vF*dt;
// apply resist as fraction
if(brk){
const decel=18*dt;
if(vF>0.1) vF=Math.max(0,vF-decel);
else if(vF>-0.1) vF-=Math.min(decel,5)*dt*0.5, vF=Math.max(vF,-CFG.reverseMax);
else vF=Math.max(vF-6*dt,-CFG.reverseMax);
}
// cap reverse & forward
vF=U.clamp(vF,-CFG.reverseMax,maxSp);
// grip on lateral velocity
const lean=U.clamp(1.1-0.5*(Math.abs(vF)/maxSp),0.3,1);
let grip=st.grip*lean;
if(hand) grip*=0.32;
vL*= Math.max(0,1-grip*dt*3.4);
// recompose velocity
this.vel.x=fx*vF+rx*vL;
this.vel.y=fz*vF+rz*vL;
this.pos.x+=this.vel.x*dt;
this.pos.z+=this.vel.y*dt;
// ground height from nearest track sample
const loc=Track.locate(this.pos.x,this.pos.z);
const gy=Track.pts[loc.idx].y;
const carY=U.lerp(this.pos.y,gy,Math.min(1,dt*10));
this.pos.y=carY;
this.speed=vF;
// gear & rpm simulation
this._gear();
// wheel spin
this.wheelSpin+=(vF/0.42)*dt;
for(const w of this.wheels){ w.rotation.x=this.wheelSpin; }
this.group.position.copy(this.pos);
this.group.rotation.y=this.heading;
// slight body lean on steer / drift
this.group.rotation.z=U.lerp(this.group.rotation.z,-this.steer*0.10-Math.sign(this.steer)*Math.min(0.14,Math.abs(vL)*0.012),0.2);
this.group.rotation.x=U.lerp(this.group.rotation.x,0.02+ (vF<0?0.03:0)-Math.abs(this.steer)*0.03,0.2);
return { loc, surf:this.surface, spd:Math.abs(spd) };
},
_gear(){
// gear bands by speed (m/s)
const bands=[0,4,9,15,21,27,34];
let g=1; for(let i=1;i<bands.length;i++){ if(this.speed>=bands[i]) g=i+1; else break; }
this.gear=g;
const lo=bands[g-1], hi=bands[Math.min(g,bands.length-1)]||lo+5;
this.rpm=U.clamp((Math.abs(this.speed)-lo)/Math.max(1,hi-lo),0.12,1.0);
},
};
/* ------------------------------------------------------------
PARTICLE SYSTEM — dust, sparks, skid marks (pooled meshes)
------------------------------------------------------------ */
const Particles = {
pool:[], sparks:[], skids:[], max:220,
dustGeo:null, dustMat:null, sparkGeo:null, sparkMat:null,
skidGeo:null, skidMat:null, skidCount:0,
init(){
this.dustGeo=new THREE.IcosahedronGeometry(1,0);
this.dustMat=new THREE.MeshBasicMaterial({color:0xcbb693,transparent:true,opacity:0.55,fog:true});
this.sparkGeo=new THREE.BoxGeometry(0.22,0.22,0.22);
this.sparkMat=new THREE.MeshBasicMaterial({color:0xffcc33});
this.skidGeo=new THREE.PlaneGeometry(0.5,0.9);
this.skidGeo.rotateX(-Math.PI/2);
for(let i=0;i<this.max;i++){
const m=new THREE.Mesh(this.dustGeo,this.dustMat.clone());
m.visible=false; m.fog=true;
World.scene.add(m);
this.pool.push({mesh:m,life:0,max:1,vx:0,vy:0,vz:0,scale:0});
}
},
emitDust(x,y,z,vx,vz,big){
const p=this.pool.find(p=>p.life<=0);
if(!p) return;
p.life=p.max=U.rand(0.5,0.9);
p.vx=vx*0.3+U.rand(-0.6,0.6); p.vz=vz*0.3+U.rand(-0.6,0.6); p.vy=U.rand(0.6,1.2);
p.scale=big?U.rand(0.8,1.3):U.rand(0.5,0.9);
p.mesh.position.set(x,y,z);
p.mesh.scale.setScalar(p.scale*0.4);
p.mesh.material.opacity=0.5;
p.mesh.visible=true;
p.wobble=U.rand(0,6.28);
},
emitSpark(x,y,z,dir){
if(this.sparks.length>40) return;
const m=new THREE.Mesh(this.sparkGeo,this.sparkMat.clone());
m.position.set(x,y,z); World.scene.add(m);
this.sparks.push({mesh:m,life:0.45,life0:0.45,
vx:dir*U.rand(-1,1)+U.rand(-3,3),vy:U.rand(2,5),vz:U.rand(-3,3)});
},
skid(x,z,y){
const m=new THREE.Mesh(this.skidGeo,this.skidMat||(this.skidMat=new THREE.MeshBasicMaterial({color:0x222222,transparent:true,opacity:0.5})));
if(this.skids.length>=90){ const old=this.skids.shift(); World.scene.remove(old.mesh); }
m.position.set(x,y+0.05,z);
m.rotation.y=this._lastAng||0;
World.scene.add(m);
this.skids.push({mesh:m,life:2.5,life0:2.5});
},
update(dt){
for(const p of this.pool){
if(p.life>0){
p.life-=dt;
p.mesh.position.x+=p.vx*dt; p.mesh.position.y+=p.vy*dt; p.mesh.position.z+=p.vz*dt;
p.vy+=0.8*dt; p.vx*=0.96; p.vz*=0.96;
const t=1-p.life/p.max;
p.mesh.scale.setScalar(p.scale*(0.3+t*1.4));
p.mesh.material.opacity=0.5*(1-t);
if(p.life<=0) p.mesh.visible=false;
}
}
for(let i=this.sparks.length-1;i>=0;i--){
const s=this.sparks[i]; s.life-=dt;
s.mesh.position.x+=s.vx*dt; s.mesh.position.y+=s.vy*dt; s.mesh.position.z+=s.vz*dt;
s.vy-=18*dt; s.mesh.scale.setScalar(Math.max(0.01,s.life/s.life0));
if(s.life<=0){ World.scene.remove(s.mesh); this.sparks.splice(i,1); }
}
for(let i=this.skids.length-1;i>=0;i--){
const s=this.skids[i]; s.life-=dt;
s.mesh.material.opacity=0.5*(s.life/s.life0);
if(s.life<=0){ World.scene.remove(s.mesh); this.skids.splice(i,1); }
}
}
};
/* ------------------------------------------------------------
INPUT — keyboard + touch / on-screen pads
------------------------------------------------------------ */
const Input = {
steer:0, throttle:false, brake:false, handbrake:false,
cb:{},
bind(cb){ this.cb=cb;
window.addEventListener('keydown',e=>this._key(e,true));
window.addEventListener('keyup',e=>this._key(e,false));
this._touch();
window.addEventListener('blur',()=>{ this.steer=0; this.throttle=this.brake=this.handbrake=false; });
// start on first gesture
const start=()=>{ AudioSys.init(); AudioSys.resume(); if(this.cb.onStart)this.cb.onStart();
window.removeEventListener('keydown',start); window.removeEventListener('pointerdown',start); };
window.addEventListener('keydown',start); window.addEventListener('pointerdown',start);
},
_key(e,down){
const k=e.key;
const map={ArrowUp:'u',ArrowDown:'d',ArrowLeft:'l',ArrowRight:'r',w:'u',s:'d',a:'l',d:'r',
W:'u',S:'d',A:'l',D:'r'};
if(k===' '){ this.handbrake=down; e.preventDefault(); }
if(map[k]){
if(map[k]==='u')this.throttle=down;
else if(map[k]==='d')this.brake=down;
else if(map[k]==='l'){ if(down)this.steer=-1; else if(this.steer<0)this.steer=0; }
else if(map[k]==='r'){ if(down)this.steer=1; else if(this.steer>0)this.steer=0; }
}
// commands (on keydown only)
if(down){
if(k.toLowerCase()==='r'){ if(this.cb.onRestart)this.cb.onRestart(); }
if(k.toLowerCase()==='p'){ if(this.cb.onPause)this.cb.onPause(); }
if(k.toLowerCase()==='c'){ if(this.cb.onCamera)this.cb.onCamera(); }
if(k==='Escape'){ if(this.cb.onPause)this.cb.onPause(); }
}
if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(k)) e.preventDefault();
},
_touch(){
const set=(sel,prop)=>{
const el=$(sel);
if(!el) return;
el.addEventListener('pointerdown',e=>{ e.preventDefault(); el.setPointerCapture(e.pointerId); this[prop]=true; el.classList.add('active'); });
const off=e=>{ this[prop]=false; el.classList.remove('active'); };
el.addEventListener('pointerup',off); el.addEventListener('pointercancel',off);
el.addEventListener('lostpointercapture',off);
};
set('btnGas','throttle'); set('btnBrake','brake'); set('btnHand','handbrake');
const steer=(sel,dir)=>{
const el=$(sel); if(!el)return;
const on=e=>{ e.preventDefault(); this.steer=dir; };
const off=()=>{ if((dir<0&&this.steer<0)||(dir>0&&this.steer>0))this.steer=0; };
el.addEventListener('pointerdown',on); el.addEventListener('pointerup',off);
el.addEventListener('pointercancel',off); el.addEventListener('pointerleave',off);
};
steer('steerLeft',-1); steer('steerRight',1);
}
};
/* ------------------------------------------------------------
CAMERA — chase cam with lag + optional cinematic cam
------------------------------------------------------------ */
const CameraSys = {
mode:'chase',
pos:new THREE.Vector3(), look:new THREE.Vector3(),
shake:0,
update(dt){
const car=Car;
const fx=Math.sin(car.heading), fz=Math.cos(car.heading);
let dist=8.4, height=4.0, ahead=5.2;
if(this.mode==='cine'){ dist=16.5; height=12.5; ahead=2.0; }
// desired chase position behind the car
const dx=car.pos.x - fx*dist, dz=car.pos.z - fz*dist;
const dy=car.pos.y + height;
const desired=new THREE.Vector3(dx,dy,dz);
const lag=this.mode==='chase'?1-Math.pow(0.0006,dt):1-Math.pow(0.002,dt);
this.pos.lerp(desired,lag);
const lookDes=new THREE.Vector3(car.pos.x+fx*ahead, car.pos.y+1.4, car.pos.z+fz*ahead);
this.look.lerp(lookDes,1-Math.pow(0.0002,dt));
// shake
if(this.shake>0){ this.shake-=dt*4;
this.pos.x+=U.rand(-1,1)*this.shake; this.pos.y+=U.rand(-1,1)*this.shake; this.pos.z+=U.rand(-1,1)*this.shake; }
World.camera.position.copy(this.pos);
World.camera.lookAt(this.look);
},
reset(){
const car=Car;
const fx=Math.sin(car.heading), fz=Math.cos(car.heading);
this.pos.set(car.pos.x-fx*9.5, car.pos.y+4.6, car.pos.z-fz*9.5);
this.look.set(car.pos.x+fx*4.5,car.pos.y+1.4,car.pos.z+fz*4.5);
}
};
/* ------------------------------------------------------------
HUD — speedometer gauge, minimap, lap/time text
------------------------------------------------------------ */
const HUD = {
spCtx:null, miniCtx:null, trackPts2d:[], miniBounds:null,
lastLap:null,
init(){
this.spCtx=$('speedometer').getContext('2d');
this.miniCtx=$('minimap').getContext('2d');
this._prepMinimap();
},
_prepMinimap(){
const W=$('minimap').width, H=$('minimap').height;
let minx=1e9,maxx=-1e9,minz=1e9,maxz=-1e9;
for(const p of Track.pts){ minx=Math.min(minx,p.x);maxx=Math.max(maxx,p.x);minz=Math.min(minz,p.z);maxz=Math.max(maxz,p.z); }
const pad=10, span=Math.max(maxx-minx,maxz-minz);
const sc=Math.min((W-pad*2)/span,(H-pad*2)/span);
const cx=(minx+maxx)/2, cz=(minz+maxz)/2;
this.miniBounds={minx,maxx,minz,maxz,sc,cx,cz};
this.trackPts2d=Track.pts.map(p=>{
return { x:(p.x-cx)*sc+W/2, y:(p.z-cz)*sc+H/2 };
});
},
drawSpeedo(){
const ctx=this.spCtx, W=160,H=64;
ctx.clearRect(0,0,W,H);
const cx=80, cy=58, r=48;
const a0=Math.PI*1.02, a1=-0.02; // 180deg-ish arc
const maxSpeed=0.95; // m/s -> frac
const s=U.clamp(Math.abs(Car.speed)/CFG.cars.tarmac.maxSpeed,0,1);
// track arc
ctx.lineWidth=11; ctx.lineCap='round';
ctx.strokeStyle='rgba(255,255,255,.12)';
ctx.beginPath(); ctx.arc(cx,cy,r,a0,a1); ctx.stroke();
// value arc
const ang=a0+(a1-a0)*s;
const grad=ctx.createLinearGradient(0,0,W,0);
grad.addColorStop(0,'#3ad26f'); grad.addColorStop(.6,'#ffd21f'); grad.addColorStop(1,'#ff4d3d');
ctx.strokeStyle=grad;
ctx.beginPath(); ctx.arc(cx,cy,r,a0,ang); ctx.stroke();
// needle
const na=a0+(a1-a0)*s;
ctx.strokeStyle='#fff'; ctx.lineWidth=3;
ctx.beginPath(); ctx.moveTo(cx,cy);
ctx.lineTo(cx+Math.cos(na)*(r+2), cy+Math.sin(na)*2); ctx.stroke();
},
drawMinimap(){
const ctx=this.miniCtx, W=$('minimap').width, H=$('minimap').height;
ctx.clearRect(0,0,W,H);
// track path
ctx.strokeStyle='rgba(255,255,255,.55)'; ctx.lineWidth=3; ctx.lineJoin='round';
ctx.beginPath();
const pts=this.trackPts2d;
for(let i=0;i<pts.length;i++){ const p=pts[i]; if(i===0)ctx.moveTo(p.x,p.y); else ctx.lineTo(p.x,p.y); }
ctx.closePath(); ctx.stroke();
// gates
ctx.fillStyle='#ffd21f';
for(const g of Track.gates){
const i=Math.floor(g.frac*Track.pts.length)%Track.pts.length;
const p=pts[i]; ctx.beginPath(); ctx.arc(p.x,p.y,2.6,0,6.28); ctx.fill();
}
// start marker
const sp=pts[0]; ctx.strokeStyle='#fff'; ctx.lineWidth=2;
ctx.beginPath(); ctx.moveTo(sp.x-5,sp.y); ctx.lineTo(sp.x+5,sp.y); ctx.stroke();
// player
const loc=Track.locate(Car.pos.x,Car.pos.z);
const i=Math.floor(loc.progress*Track.pts.length);
const cp=pts[i];
const jx=U.clamp((Car.pos.x-this.miniBounds.cx)*this.miniBounds.sc+W/2,4,W-4);
const jy=U.clamp((Car.pos.z-this.miniBounds.cz)*this.miniBounds.sc+H/2,4,H-4);
ctx.fillStyle='#ff5a1f';
ctx.beginPath(); ctx.arc(jx,jy,4.2,0,6.28); ctx.fill();
ctx.strokeStyle='#fff'; ctx.lineWidth=1.5; ctx.beginPath(); ctx.arc(jx,jy,4.2,0,6.28); ctx.stroke();
},
update(){
const now=Game.raceTime();
$('timeVal').textContent=U.fmtTime(Game.state==='racing'?now:0);
$('lapVal').textContent=Game.lap+' / '+CFG.laps;
$('bestVal').textContent=U.fmtTime(Game.bestLap);
$('checkVal').textContent=Game.checksPassed+' / '+(CFG.gates-1);
// speed
const kmh=Math.abs(Car.speed)*3.6;
$('speedVal').textContent=Math.round(kmh);
$('gearVal').textContent=Car.gameLocked?'N':(Car.speed<-0.5?'R':' '+Car.gear);
$('rpmFill').style.width=(Car.rpm*100)+'%';
this.drawSpeedo();
this.drawMinimap();
},
flashLap(msg){
const el=$('lapMsg'); el.textContent=msg; el.classList.add('show');
clearTimeout(this._lt); this._lt=setTimeout(()=>el.classList.remove('show'),1800);
}
};
/* ------------------------------------------------------------
GAME — state machine, lap logic, collisions, main loop
------------------------------------------------------------ */
const Game = {
state:'idle', // idle | countdown | racing | paused | finished
locked:true,
lap:1, bestLap:null, lastLap:null,
lapStart:0, raceStart:0, now:0,
expectedGate:1, checksPassed:0,
countBegin:0, cdPhase:-1, lastHit:0, spdForAudio:0, drifting:0,
carStand:null,
init(){
Track.build();
World.init();
Car.init();
Particles.init();
HUD.init();
Input.bind({onStart:()=>this.beginCountdown(), onRestart:()=>this.restart(),
onPause:()=>this.togglePause(), onCamera:()=>this.toggleCamera()});
$('btnResume').addEventListener('click',()=>this.togglePause());
$('btnRestart2').addEventListener('click',()=>this.restart());
$('btnRestartF').addEventListener('click',()=>this.restart());
window.addEventListener('resize',()=>{ World.resize(window.innerWidth,window.innerHeight); });
this.showIdle();
this.lastT=performance.now();
requestAnimationFrame(()=>this.loop(performance.now()));
},
showIdle(){
$('countdown').classList.add('show');
$('cdText').textContent='▶';
$('cdText').classList.remove('tick');
$('cdSub').textContent='TAP OR PRESS ANY KEY TO START';
},
beginCountdown(){
if(this.state!=='idle') return;
this.state='countdown';
this.countBegin=performance.now(); this.cdPhase=-1;
$('cdText').textContent='3'; $('cdText').classList.add('tick');
$('cdSub').textContent='GET READY';
},
raceTime(){ return this.state==='racing'?(this.now-this.raceStart)/1000:0; },
restart(){
// reset car
const p=Track.pts[0];
Car.pos.set(p.x,p.y,p.z);
Car.heading=Math.atan2(p.tanX,p.tanZ);
Car.vel.set(0,0); Car.speed=0; Car.steer=0; Car.gear=1; Car.rpm=0;
Car.group.position.copy(Car.pos); Car.group.rotation.set(0,Car.heading,0);
this.lap=1; this.bestLap=this.bestLap; // keep best across restarts? reset best
this.bestLap=null; this.lastLap=null;
this.expectedGate=1; this.checksPassed=0;
this.state='idle'; this.locked=true;
$('finishScreen').classList.remove('show');
$('pauseMenu').classList.remove('show');
$('countdown').classList.add('show');
$('cdText').textContent='▶'; $('cdText').classList.remove('tick');
$('cdSub').textContent='TAP OR PRESS ANY KEY TO START';
CameraSys.reset();
HUD.flashLap('RESTART');
},
togglePause(){
if(this.state==='racing'){ this.state='paused'; $('pauseMenu').classList.add('show'); }
else if(this.state==='paused'){ this.state='racing'; $('pauseMenu').classList.remove('show'); this.lastT=performance.now(); }
},
toggleCamera(){
CameraSys.mode=CameraSys.mode==='chase'?'cine':'chase';
HUD.flashLap(CameraSys.mode==='chase'?'CHASE CAM':'CINE CAM');
},
/* lap / checkpoint detection */
_gates(loc){
const G=Track.gates.length;
const p=Track.pts[loc.idx];
const onCourse=loc.absLat<p.half+2.0;
const gate=Track.gates[this.expectedGate];
if(!gate) return;
// circular frac distance
let d=Math.abs(loc.progress-gate.frac); d=Math.min(d,1-d);
if(d<0.022 && onCourse && !this.locked){
if(this.expectedGate===0){
// completed a lap
const lapT=(this.now-this.lapStart)/1000;
this.lastLap=lapT;
if(this.bestLap==null||lapT<this.bestLap) this.bestLap=lapT;
HUD.flashLap('LAP '+this.lap+' — '+U.fmtTime(lapT));
if(this.lap>=CFG.laps){ this.finish(); return; }
this.lap++; this.lapStart=this.now; this.checksPassed=0;
this.expectedGate=1;
} else {
this.checksPassed++;
this.expectedGate++;
if(this.expectedGate>=G) this.expectedGate=0;
AudioSys.beep(880,0.12,'sine',0.12);
}
}
},
_collisions(loc){
const car=Car; const now=performance.now();
const cr=CFG.carRadius;
const obs=World.obstacles;
for(const o of obs){
const dx=car.pos.x-o.x, dz=car.pos.z-o.z;
const rr=o.r+cr;
if(dx*dx+dz*dz<rr*rr){
const dist=Math.sqrt(dx*dx+dz*dz)||0.001;
const nx=dx/dist, nz=dz/dist;
// push car out
const push=rr-dist;
car.pos.x+=nx*push; car.pos.z+=nz*push;
// reflect velocity along normal (bounce) + kill momentum
const vn=car.vel.x*nx+car.vel.y*nz;
if(vn<0){ car.vel.x-=(1.4)*vn*nx; car.vel.y-=(1.4)*vn*nz; }
car.vel.multiplyScalar(0.55);
if(now-this.lastHit>200){
this.lastHit=now;
CameraSys.shake=1.4;
AudioSys.beep(180,0.18,'sawtooth',0.5); // thud
Car.pos.y+=Math.min(2,Math.abs(vn)*0.5);
}
const dir=Math.sign(car.vel.x*Math.cos(car.heading)-car.vel.y*Math.sin(car.heading))||1;
Particles.emitSpark(car.pos.x,car.pos.y+0.6,car.pos.z,dir);
}
}
// hard boundary (invisible wall) to keep car near course
const wall=CFG.boundaryMargin + Track.pts[loc.idx].half;
if(Math.abs(loc.lat)>wall){
const side=Math.sign(loc.lat);
const p=Track.pts[loc.idx];
const nx=p.leftX*side, nz=p.leftZ*side;
// snap to just inside the boundary
car.pos.x=p.x+nx*wall;
car.pos.z=p.z+nz*wall;
const vn=car.vel.x*nx+car.vel.y*nz;
if(vn>0){ car.vel.x-=1.4*vn*nx; car.vel.y-=1.4*vn*nz; }
car.vel.multiplyScalar(0.6);
CameraSys.shake=Math.max(CameraSys.shake,0.6);
}
},
finish(){
this.state='finished'; this.locked=true;
const total=this.raceTime();
$('finTotal').textContent=U.fmtTime(total);
$('finBest').textContent=U.fmtTime(this.bestLap);
$('finishScreen').classList.add('show');
AudioSys.beep(660,0.2,'sine',0.35);
AudioSys.beep(880,0.2,'sine',0.35,0.22);
AudioSys.beep(1100,0.4,'sine',0.4,0.44);
},
/* surfaces + particle emission + audio per frame */
_effects(loc,res,dt){
const car=Car;
const surf=res.surf;
this.surfaceDetected=surf;
const spd=Math.abs(car.speed);
const offTrack=loc.absLat>Track.pts[loc.idx].half;
const drifting=Math.abs(car.vel.x*Math.cos(car.heading)-car.vel.y*Math.sin(car.heading))>1.6;
this.drifting=drifting;
// dust
const dustRate = (offTrack||drifting||surf!=='tarmac') ? 1:0;
if(dustRate){
if(Math.random()<Math.min(1,dt*40)){
const fx=Math.sin(car.heading), fz=Math.cos(car.heading);
Particles.emitDust(car.pos.x-fx*1.5,car.pos.y+0.3,car.pos.z-fz*1.5,-car.vel.x,-car.vel.y, surf==='mud'||offTrack);
}
}
// skid marks
if((drifting&&spd>6)||(surf==='mud'&&spd>2)){
this._skidAcc=(this._skidAcc||0)+dt;
if(this._skidAcc>0.06){ this._skidAcc=0;
const fx=Math.sin(car.heading), fz=Math.cos(car.heading);
Particles.skid(car.pos.x-fx*1.2,car.pos.z-fz*1.2,car.pos.y);
Particles.skid(car.pos.x-fx*1.2+Math.cos(car.heading)*0.9,car.pos.z-fz*1.2-Math.sin(car.heading)*0.9,car.pos.y);
}
}
// audio
const surfAudio = (offTrack||surf==='mud'||drifting)?1:0;
AudioSys.setSkid(surfAudio*U.clamp(spd/16,0,1));
AudioSys.engine(spd,Car.rpm,this.locked?0:car.throttle);
},
loop(now){
requestAnimationFrame(()=>this.loop(performance.now()));
const dt=Math.min(0.05,(now-this.lastT)/1000); this.lastT=now; this.now=now;
if(this.state==='countdown'){
const el=(now-this.countBegin)/1000;
let phase = el<1?3:el<2?2:el<3?1:0; // 0 => GO
if(phase!==this.cdPhase){
this.cdPhase=phase;
if(phase===3||phase===2||phase===1){
$('cdText').textContent=String(phase); $('cdText').classList.remove('tick');
void $('cdText').offsetWidth; $('cdText').classList.add('tick');
AudioSys.beep(660,0.22,'square',0.3);
} else {
$('cdText').textContent='GO!'; $('cdText').classList.remove('tick');
void $('cdText').offsetWidth; $('cdText').classList.add('tick');
$('cdSub').textContent='RACE!';
AudioSys.beep(920,0.6,'square',0.35);
this.state='racing'; this.locked=false;
this.raceStart=now; this.lapStart=now;
setTimeout(()=>{ $('countdown').classList.remove('show'); },650);
}
}
}
else if(this.state==='racing'){
// car physics with locked guard
const inp=this.locked? {steer:0,throttle:false,brake:false,handbrake:false} :
{steer:Input.steer,throttle:Input.throttle,brake:Input.brake,handbrake:Input.handbrake};
const res=Car.update(dt,inp);
// surface recompute for effects/audio
const surf=Car.surfaceCar();
res.surf=surf;
Car.surface=surf;
const loc=Track.locate(Car.pos.x,Car.pos.z);
this._gates(loc);
this._collisions(loc);
this._effects(loc,res,dt);
}
else if(this.state==='paused'){
AudioSys.engine(0,0,0); AudioSys.setSkid(0);
}
else if(this.state==='idle'){
AudioSys.engine(0,0,0);
}
// always update camera & render
World.follow(Car.pos);
CameraSys.update(dt);
Particles.update(dt);
World.renderer.render(World.scene,World.camera);
HUD.update();
}
};
/* boot */
Game.init();
/* single exposed handle for quick testing / debugging */
window.RallyGame={Car,Input,Game,Track,World,CameraSys,Particles,AudioSys};
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"deepseek/deepseek-v4-flash-vision-exp","provider":"openrouter","persona":"full","sessionId":"942e2552-0c96-4df4-9eab-09017b3ce2c0","tokensIn":3830612,"tokensOut":66791,"tokensTotal":3897403,"cost":0.16783832799999995,"turns":52,"toolCalls":51,"failedToolCalls":0,"timestamp":"2026-08-27T03:18:06.427Z"} -->