Rally
GPT 5.6 Sol · legacy

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 31,191 bytes · SHA-256 d5289d29d559
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>GRITLINE — 3D Rally</title>
<style>
:root{--acid:#e8ff36;--orange:#ff5b24;--ink:#10130f;--cream:#f4f0df;--glass:rgba(12,16,12,.74)}
*{box-sizing:border-box}html,body{width:100%;height:100%;margin:0;overflow:hidden;background:#8eb6c4;color:var(--cream);font-family:"Trebuchet MS","Arial Narrow",sans-serif;touch-action:none;user-select:none}
canvas#game{display:block;width:100%;height:100%}.grain{position:fixed;inset:0;pointer-events:none;opacity:.11;z-index:4;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.5'/%3E%3C/svg%3E")}
#hud{position:fixed;inset:0;z-index:5;pointer-events:none;text-shadow:0 2px 0 #0008}.topbar{position:absolute;top:18px;left:20px;right:20px;display:flex;align-items:flex-start;justify-content:space-between}.brand{font:900 italic 24px Impact,"Arial Black",sans-serif;letter-spacing:2px;transform:skew(-7deg);color:var(--acid)}.brand small{display:block;color:white;font:700 9px "Trebuchet MS";letter-spacing:5px;margin-left:8px}.timing{display:grid;grid-template-columns:auto auto;gap:1px 20px;padding:10px 15px;background:var(--glass);border-top:3px solid var(--acid);clip-path:polygon(5% 0,100% 0,96% 100%,0 100%);font-size:11px;letter-spacing:1.4px}.timing b{font:700 17px "Courier New",monospace;color:white}.timing .best{color:var(--acid)}
#mapWrap{position:absolute;right:20px;top:105px;width:190px;height:145px;background:var(--glass);border:1px solid #fff3;clip-path:polygon(0 8%,8% 0,100% 0,100% 92%,92% 100%,0 100%)}#map{width:100%;height:100%}.mapLabel{position:absolute;left:12px;top:9px;font:900 9px Impact;letter-spacing:2px;color:var(--acid)}
.bottom{position:absolute;left:24px;right:24px;bottom:20px;display:flex;align-items:flex-end;justify-content:space-between}.lapbox{font:900 14px Impact;letter-spacing:2px;border-left:5px solid var(--orange);padding:8px 12px;background:var(--glass)}.lapbox strong{display:block;font-size:40px;line-height:.9;color:white}.speed{width:170px;height:100px;position:relative;text-align:right}.speed .num{font:italic 900 64px/55px Impact;color:white}.speed .unit{font:900 10px Impact;letter-spacing:3px;color:var(--acid)}.gear{position:absolute;left:0;bottom:0;font:900 30px Impact;color:var(--orange)}.rpm{height:5px;background:#ffffff30;margin-top:7px;transform:skew(-18deg);overflow:hidden}.rpm i{display:block;height:100%;width:0;background:linear-gradient(90deg,var(--acid),var(--orange));transition:width .1s}
#surface{position:absolute;bottom:116px;right:25px;font:900 10px Impact;letter-spacing:2px;padding:5px 8px;background:#111b;color:var(--acid);border-right:3px solid currentColor}.message{position:absolute;left:50%;top:42%;transform:translate(-50%,-50%) rotate(-3deg);font:italic 900 clamp(72px,13vw,170px)/.8 Impact,"Arial Black",sans-serif;color:white;-webkit-text-stroke:3px #111;text-shadow:8px 8px 0 var(--orange);opacity:0;transition:opacity .15s,transform .15s}.message.show{opacity:1;transform:translate(-50%,-50%) rotate(-3deg) scale(1)}
.panel{position:fixed;inset:0;z-index:20;display:none;place-items:center;background:#0b100bd9;backdrop-filter:blur(8px)}.panel.show{display:grid}.card{min-width:min(440px,88vw);padding:35px 38px 30px;background:#191d17;border:1px solid #ffffff25;border-top:7px solid var(--acid);clip-path:polygon(0 0,100% 0,100% 92%,92% 100%,0 100%);box-shadow:18px 18px 0 #0005}.card h1{margin:0 0 6px;font:italic 900 52px Impact;letter-spacing:2px}.card .eyebrow{color:var(--acid);font:900 11px Impact;letter-spacing:4px}.stats{margin:25px 0;display:grid;grid-template-columns:1fr auto;gap:10px;border-top:1px solid #fff2;padding-top:15px;font-size:12px;letter-spacing:2px}.stats b{font:700 17px "Courier New";color:white}button{pointer-events:auto;border:0;padding:13px 22px;margin-right:8px;background:var(--acid);color:#111;font:900 13px Impact;letter-spacing:2px;transform:skew(-6deg);cursor:pointer;box-shadow:5px 5px 0 #000}button.alt{background:#fff;color:#111}button:hover{filter:brightness(1.15);transform:skew(-6deg) translateY(-2px)}
#help{position:fixed;z-index:7;left:50%;bottom:16px;transform:translateX(-50%);font:700 10px "Trebuchet MS";letter-spacing:1px;color:#fff9;background:#1119;padding:6px 10px;pointer-events:none}.key{color:var(--acid)}
#touch{display:none;position:fixed;z-index:10;inset:0;pointer-events:none}.touchCluster{position:absolute;bottom:28px;display:grid;gap:9px;pointer-events:auto}.touchCluster.left{left:22px;grid-template-columns:64px 64px}.touchCluster.right{right:22px;grid-template-columns:70px 70px}.touchBtn{height:62px;border-radius:50%;display:grid;place-items:center;background:#10151099;border:2px solid #ffffff55;color:#fff;font:900 20px Impact;box-shadow:inset 0 0 0 4px #0003}.touchBtn.active{background:var(--acid);color:#111;transform:scale(.94)}.touchBtn.wide{grid-column:1/3;border-radius:14px;height:44px;font-size:11px;letter-spacing:2px}
@media(pointer:coarse),(max-width:800px){#touch{display:block}#help{display:none}.topbar{top:10px;left:10px;right:10px}.brand{font-size:18px}.timing{font-size:8px;gap:0 10px;padding:7px 10px}.timing b{font-size:12px}#mapWrap{right:10px;top:75px;width:135px;height:105px}.bottom{bottom:104px;left:12px;right:12px}.speed{transform:scale(.76);transform-origin:right bottom}.lapbox{transform:scale(.8);transform-origin:left bottom}#surface{bottom:184px;right:14px}.card{padding:28px 25px}.card h1{font-size:42px}}
</style>
</head>
<body>
<canvas id="game"></canvas><div class="grain"></div>
<div id="hud">
<div class="topbar"><div class="brand">GRITLINE<small>RALLY STAGE</small></div><div class="timing"><span>STAGE</span><b id="time">00:00.000</b><span>LAST</span><b id="last">--:--.---</b><span>BEST</span><b id="best" class="best">--:--.---</b></div></div>
<div id="mapWrap"><canvas id="map" width="380" height="290"></canvas><span class="mapLabel">STAGE MAP</span></div>
<div id="surface">ASPHALT</div><div id="msg" class="message">3</div>
<div class="bottom"><div class="lapbox">LAP / 3<strong><span id="lap">1</span> / 3</strong></div><div class="speed"><span class="gear" id="gear">N</span><div class="num" id="speed">000</div><div class="unit">KM/H</div><div class="rpm"><i id="rpm"></i></div></div></div>
</div>
<div id="pause" class="panel"><div class="card"><div class="eyebrow">STAGE CONTROL</div><h1>PAUSED</h1><p>Dust settles. The clock is stopped.</p><button id="resume">RESUME</button><button id="restart1" class="alt">RESTART</button></div></div>
<div id="finish" class="panel"><div class="card"><div class="eyebrow">OFFICIAL RESULT</div><h1>FINISH!</h1><div class="stats"><span>FINAL TIME</span><b id="finalTime">--</b><span>BEST LAP</span><b id="finalBest">--</b></div><button id="restart2">RACE AGAIN</button></div></div>
<div id="help"><span class="key">WASD / ARROWS</span> DRIVE <span class="key">SPACE</span> HANDBRAKE <span class="key">C</span> CAMERA <span class="key">P</span> PAUSE <span class="key">R</span> RESTART</div>
<div id="touch"><div class="touchCluster left"><div class="touchBtn" data-key="left">◀</div><div class="touchBtn" data-key="right">▶</div><div class="touchBtn wide" data-key="hand">HANDBRAKE</div></div><div class="touchCluster right"><div class="touchBtn" data-key="brake">▼</div><div class="touchBtn" data-key="gas">▲</div></div></div>
<script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
<script>
(()=>{'use strict';
const App={
cfg:{samples:600,roadWidth:10,totalLaps:3}, scene:null,camera:null,renderer:null,clock:new THREE.Clock(),
state:{mode:'countdown',count:3.9,raceTime:0,lapStart:0,lap:1,lastLap:null,bestLap:null,paused:false,cam:0,nextCP:1,surface:'ASPHALT'},
input:{gas:false,brake:false,left:false,right:false,hand:false}, track:[],obstacles:[],particles:[],skids:[],
car:{pos:new THREE.Vector3(),vel:new THREE.Vector3(),heading:0,speed:0,trackI:0,lateral:0,mesh:null,steer:0}, audio:null,
ui:{},
init(){
this.cacheUI(); this.setupRenderer(); this.makeWorld(); this.makeTrack(); this.makeScenery(); this.makeCar(); this.bind(); this.reset(); this.animate();
},
cacheUI(){for(const id of ['time','last','best','lap','gear','speed','rpm','surface','msg','pause','finish','finalTime','finalBest','map'])this.ui[id]=document.getElementById(id)},
setupRenderer(){
this.renderer=new THREE.WebGLRenderer({canvas:document.getElementById('game'),antialias:true,powerPreference:'high-performance'});
this.renderer.setPixelRatio(Math.min(devicePixelRatio,1.75)); this.renderer.setSize(innerWidth,innerHeight); this.renderer.shadowMap.enabled=true; this.renderer.shadowMap.type=THREE.PCFSoftShadowMap; this.renderer.outputColorSpace=THREE.SRGBColorSpace; this.renderer.toneMapping=THREE.ACESFilmicToneMapping; this.renderer.toneMappingExposure=1.05;
this.scene=new THREE.Scene(); this.scene.background=new THREE.Color(0x91bdcc); this.scene.fog=new THREE.Fog(0x91bdcc,110,390);
this.camera=new THREE.PerspectiveCamera(65,innerWidth/innerHeight,.1,700);
addEventListener('resize',()=>{this.camera.aspect=innerWidth/innerHeight;this.camera.updateProjectionMatrix();this.renderer.setSize(innerWidth,innerHeight)});
},
makeWorld(){
const hemi=new THREE.HemisphereLight(0xdaf4ff,0x657047,1.65);this.scene.add(hemi);
const sun=new THREE.DirectionalLight(0xfff1c7,2.4);sun.position.set(-90,130,-50);sun.castShadow=true;sun.shadow.mapSize.set(1024,1024);sun.shadow.camera.left=-120;sun.shadow.camera.right=120;sun.shadow.camera.top=120;sun.shadow.camera.bottom=-120;sun.shadow.camera.far=350;this.scene.add(sun);
const ground=new THREE.Mesh(new THREE.PlaneGeometry(700,700,1,1),new THREE.MeshLambertMaterial({color:0x627842}));ground.rotation.x=-Math.PI/2;ground.position.y=-2.3;ground.receiveShadow=true;this.scene.add(ground);
// Distant low-poly hills
const hillMat=new THREE.MeshLambertMaterial({color:0x5a7049,flatShading:true});
for(let i=0;i<24;i++){const a=i/24*Math.PI*2,r=260+Math.random()*60;const h=35+Math.random()*55;const hill=new THREE.Mesh(new THREE.ConeGeometry(45+Math.random()*35,h,5),hillMat);hill.position.set(Math.cos(a)*r,h/2-5,Math.sin(a)*r);hill.rotation.y=Math.random()*Math.PI;this.scene.add(hill)}
},
makeTrack(){
const raw=[[-5,1,-84],[45,4,-78],[88,9,-48],[105,4,0],[92,-2,48],[55,7,82],[5,12,97],[-48,7,88],[-93,0,58],[-112,-4,12],[-99,2,-35],[-63,8,-68]];
const curve=new THREE.CatmullRomCurve3(raw.map(p=>new THREE.Vector3(...p)),true,'catmullrom',.35);
for(let i=0;i<this.cfg.samples;i++){const u=i/this.cfg.samples,p=curve.getPointAt(u),t=curve.getTangentAt(u).normalize(),side=new THREE.Vector3(t.z,0,-t.x).normalize();this.track.push({p,t,side,u})}
const positions=[],uvs=[],indices=[]; const w=this.cfg.roadWidth;
for(let i=0;i<=this.cfg.samples;i++){const q=this.track[i%this.cfg.samples];for(const s of [-1,1]){positions.push(q.p.x+q.side.x*w*s,q.p.y+.06,q.p.z+q.side.z*w*s);uvs.push(i/8,(s+1)/2)}}
for(let i=0;i<this.cfg.samples;i++){const a=i*2;indices.push(a,a+2,a+1,a+2,a+3,a+1)}
const geo=new THREE.BufferGeometry();geo.setAttribute('position',new THREE.Float32BufferAttribute(positions,3));geo.setAttribute('uv',new THREE.Float32BufferAttribute(uvs,2));geo.setIndex(indices);geo.computeVertexNormals();
const tex=this.roadTexture();const road=new THREE.Mesh(geo,new THREE.MeshStandardMaterial({map:tex,roughness:.94,color:0xb09d7d}));road.receiveShadow=true;this.scene.add(road);
// Pale shoulders as segmented edge stones
const postMat=new THREE.MeshLambertMaterial({color:0xe7dfc8});
for(let i=0;i<this.cfg.samples;i+=9){const q=this.track[i];for(const s of [-1,1]){const m=new THREE.Mesh(new THREE.BoxGeometry(2.2,.18,.55),postMat);m.position.copy(q.p).addScaledVector(q.side,s*(w+.55));m.position.y+=.05;m.rotation.y=Math.atan2(q.t.x,q.t.z);this.scene.add(m)}}
// Mud/water sectors
const mudMat=new THREE.MeshStandardMaterial({color:0x563c27,roughness:.6,metalness:.05});
[[115,145],[390,420]].forEach(([a,b])=>{for(let i=a;i<b;i+=3){const q=this.track[i];const puddle=new THREE.Mesh(new THREE.CircleGeometry(4+Math.random()*2,12),mudMat);puddle.rotation.x=-Math.PI/2;puddle.position.copy(q.p);puddle.position.y+=.1;puddle.scale.x=1.8;this.scene.add(puddle)}});
this.makeStartGate();
},
roadTexture(){
const c=document.createElement('canvas');c.width=256;c.height=256;const x=c.getContext('2d');x.fillStyle='#9f8768';x.fillRect(0,0,256,256);for(let i=0;i<1100;i++){const v=90+Math.random()*80;x.fillStyle=`rgba(${v},${v-10},${v-25},${Math.random()*.18})`;x.fillRect(Math.random()*256,Math.random()*256,Math.random()*3+1,Math.random()*3+1)}x.fillStyle='#d6cba9';x.globalAlpha=.38;x.fillRect(7,0,3,256);x.fillRect(246,0,3,256);const t=new THREE.CanvasTexture(c);t.wrapS=t.wrapT=THREE.RepeatWrapping;t.repeat.set(1,18);t.colorSpace=THREE.SRGBColorSpace;return t;
},
makeStartGate(){
const q=this.track[0],g=new THREE.Group(),white=new THREE.MeshLambertMaterial({color:0xf2e8d1}),dark=new THREE.MeshLambertMaterial({color:0x171a14}),acid=new THREE.MeshLambertMaterial({color:0xe8ff36});
for(const s of [-1,1]){const p=new THREE.Mesh(new THREE.BoxGeometry(.65,8,.65),dark);p.position.set(s*12,4,0);g.add(p)}const beam=new THREE.Mesh(new THREE.BoxGeometry(25,.9,.9),acid);beam.position.y=8;g.add(beam);
for(let i=0;i<8;i++){const tile=new THREE.Mesh(new THREE.BoxGeometry(3,.12,.8),i%2?white:dark);tile.position.set(-10.5+i*3,0.13,0);g.add(tile)}
g.position.copy(q.p);g.rotation.y=Math.atan2(q.t.x,q.t.z);this.scene.add(g);
},
makeScenery(){
const trunkMat=new THREE.MeshLambertMaterial({color:0x513b25}),leafMats=[0x254c2d,0x315b31,0x416b39].map(c=>new THREE.MeshLambertMaterial({color:c,flatShading:true}));
const treeGeo1=new THREE.ConeGeometry(2.4,7,6),treeGeo2=new THREE.ConeGeometry(1.9,6,6),trunkGeo=new THREE.CylinderGeometry(.38,.55,3,6);
for(let i=0;i<165;i++){const idx=(i*37+17)%this.cfg.samples,q=this.track[idx],s=i%2?1:-1,dist=17+Math.random()*44;const group=new THREE.Group();const trunk=new THREE.Mesh(trunkGeo,trunkMat);trunk.position.y=1.5;group.add(trunk);const l1=new THREE.Mesh(treeGeo1,leafMats[i%3]);l1.position.y=6;group.add(l1);const l2=new THREE.Mesh(treeGeo2,leafMats[(i+1)%3]);l2.position.y=9;group.add(l2);group.position.copy(q.p).addScaledVector(q.side,s*dist);group.position.y=q.p.y-1.3;group.rotation.y=Math.random()*6;group.scale.setScalar(.75+Math.random()*.7);this.scene.add(group);if(dist<23)this.obstacles.push({pos:group.position.clone(),r:1.4,type:'tree'})}
const rockMat=new THREE.MeshLambertMaterial({color:0x777569,flatShading:true});
for(let i=0;i<34;i++){const idx=(i*53+31)%this.cfg.samples,q=this.track[idx],s=i%2?1:-1;const rock=new THREE.Mesh(new THREE.DodecahedronGeometry(1.2+Math.random()*1.2,0),rockMat);rock.position.copy(q.p).addScaledVector(q.side,s*(13+Math.random()*8));rock.position.y+=.3;rock.scale.y=.65;rock.rotation.set(Math.random(),Math.random()*6,Math.random());rock.castShadow=true;this.scene.add(rock);this.obstacles.push({pos:rock.position.clone(),r:1.6,type:'rock'})}
this.makeSigns();
},
makeSigns(){
const yellow=new THREE.MeshLambertMaterial({color:0xe8ff36}),black=new THREE.MeshLambertMaterial({color:0x151812}),red=new THREE.MeshLambertMaterial({color:0xff5728});
for(let k=0;k<10;k++){const idx=(k*61+45)%this.cfg.samples,q=this.track[idx],s=k%2?1:-1,g=new THREE.Group();const pole=new THREE.Mesh(new THREE.BoxGeometry(.18,2.8,.18),black);pole.position.y=1.4;g.add(pole);const board=new THREE.Mesh(new THREE.BoxGeometry(2.8,1.35,.15),k%3?yellow:red);board.position.y=3;g.add(board);const arrow=new THREE.Mesh(new THREE.BoxGeometry(1.5,.25,.18),black);arrow.position.set(0,3,.12);arrow.rotation.z=k%2?-.35:.35;g.add(arrow);g.position.copy(q.p).addScaledVector(q.side,s*13);g.rotation.y=Math.atan2(q.t.x,q.t.z);this.scene.add(g);this.obstacles.push({pos:g.position.clone(),r:1,type:'sign'})}
// Crowd blocks and banner on a safe outside section
const q=this.track[285];for(let i=0;i<18;i++){const person=new THREE.Group(),body=new THREE.Mesh(new THREE.BoxGeometry(.45,1.15,.35),new THREE.MeshLambertMaterial({color:[0xe85d2a,0xf0c33c,0x297d8a,0xd8d4c3][i%4]})),head=new THREE.Mesh(new THREE.SphereGeometry(.22,6,5),new THREE.MeshLambertMaterial({color:0xd39b72}));body.position.y=.7;head.position.y=1.48;person.add(body,head);person.position.copy(q.p).addScaledVector(q.side,17+(i%3)*1.3).addScaledVector(q.t,(i-9)*1.2);this.scene.add(person)}
},
makeCar(){
const g=new THREE.Group(),red=new THREE.MeshStandardMaterial({color:0xeb351d,roughness:.48,metalness:.12}),dark=new THREE.MeshStandardMaterial({color:0x121515,roughness:.7}),glass=new THREE.MeshStandardMaterial({color:0x19343a,roughness:.22,metalness:.3}),white=new THREE.MeshBasicMaterial({color:0xfff8cf}),tail=new THREE.MeshBasicMaterial({color:0xff1f0f});
const body=new THREE.Mesh(new THREE.BoxGeometry(3.5,1.05,6.3),red);body.position.y=1.15;body.castShadow=true;g.add(body);const hood=new THREE.Mesh(new THREE.BoxGeometry(3.25,.5,2.1),red);hood.position.set(0,1.7,1.7);g.add(hood);const cabin=new THREE.Mesh(new THREE.BoxGeometry(2.8,1.25,2.8),glass);cabin.position.set(0,2.05,-.3);cabin.geometry.translate(0,0,.15);g.add(cabin);
for(const x of [-1.75,1.75])for(const z of [-1.9,1.9]){const wheel=new THREE.Mesh(new THREE.CylinderGeometry(.64,.64,.48,12),dark);wheel.rotation.z=Math.PI/2;wheel.position.set(x,.72,z);wheel.castShadow=true;g.add(wheel)}
for(const x of [-1.1,1.1]){const h=new THREE.Mesh(new THREE.BoxGeometry(.72,.3,.08),white);h.position.set(x,1.35,3.18);g.add(h);const t=new THREE.Mesh(new THREE.BoxGeometry(.7,.32,.08),tail);t.position.set(x,1.35,-3.18);g.add(t)}
const wing=new THREE.Mesh(new THREE.BoxGeometry(3.5,.18,.72),dark);wing.position.set(0,2.15,-3);g.add(wing);for(const x of [-1.1,1.1]){const strut=new THREE.Mesh(new THREE.BoxGeometry(.12,.6,.12),dark);strut.position.set(x,1.85,-3);g.add(strut)}
const bumper=new THREE.Mesh(new THREE.BoxGeometry(3.7,.35,.3),dark);bumper.position.set(0,.78,-3.1);g.add(bumper);this.car.mesh=g;this.scene.add(g);
},
bind(){
const map={ArrowUp:'gas',KeyW:'gas',ArrowDown:'brake',KeyS:'brake',ArrowLeft:'left',KeyA:'left',ArrowRight:'right',KeyD:'right',Space:'hand'};
addEventListener('keydown',e=>{if(map[e.code]){this.input[map[e.code]]=true;e.preventDefault();this.initAudio()}if(e.code==='KeyR')this.reset();if(e.code==='KeyP')this.togglePause();if(e.code==='KeyC')this.state.cam=1-this.state.cam});
addEventListener('keyup',e=>{if(map[e.code]){this.input[map[e.code]]=false;e.preventDefault()}});
document.querySelectorAll('.touchBtn').forEach(b=>{const key=b.dataset.key;const on=e=>{e.preventDefault();this.input[key]=true;b.classList.add('active');this.initAudio()},off=e=>{e.preventDefault();this.input[key]=false;b.classList.remove('active')};b.addEventListener('pointerdown',on);b.addEventListener('pointerup',off);b.addEventListener('pointercancel',off);b.addEventListener('pointerleave',off)});
document.getElementById('resume').onclick=()=>this.togglePause();document.getElementById('restart1').onclick=()=>this.reset();document.getElementById('restart2').onclick=()=>this.reset();
addEventListener('blur',()=>{if(this.state.mode==='race'&&!this.state.paused)this.togglePause()});
},
reset(){
const s=this.state;s.mode='countdown';s.count=3.9;s.raceTime=0;s.lapStart=0;s.lap=1;s.lastLap=null;s.bestLap=null;s.paused=false;s.nextCP=1;s.surface='ASPHALT';s.cam=0;this.ui.pause.classList.remove('show');this.ui.finish.classList.remove('show');
const q=this.track[4],c=this.car;c.pos.copy(q.p);c.pos.y+=.7;c.heading=Math.atan2(q.t.x,q.t.z);c.vel.set(0,0,0);c.speed=0;c.trackI=4;c.lateral=0;c.mesh.position.copy(c.pos);c.mesh.rotation.y=c.heading;this.clearEffects();this.updateHUD();
},
togglePause(){if(this.state.mode==='finish')return;this.state.paused=!this.state.paused;this.ui.pause.classList.toggle('show',this.state.paused);if(this.audio)this.audio.master.gain.setTargetAtTime(this.state.paused?0:.14,this.audio.ctx.currentTime,.1)},
nearestTrack(){let best=1e9,bi=0;const c=this.car;for(let i=0;i<this.cfg.samples;i++){const d=this.track[i].p.distanceToSquared(c.pos);if(d<best){best=d;bi=i}}c.trackI=bi;const q=this.track[bi],delta=c.pos.clone().sub(q.p);c.lateral=delta.dot(q.side);return Math.sqrt(best)},
update(dt){
const s=this.state;if(s.paused)return;
if(s.mode==='countdown'){s.count-=dt;const n=Math.max(1,Math.ceil(s.count-.9));this.showMessage(s.count>.9?n:'GO!');if(s.count<=0){s.mode='race';s.raceTime=0;s.lapStart=0;this.showMessage('GO!',.7);this.beep(820,.18)}else if(s.count>.9&&n!==s._beep){s._beep=n;this.beep(420,.08)}}
if(s.mode==='race')s.raceTime+=dt;
this.physics(dt);this.cameraUpdate(dt);this.updateParticles(dt);this.updateHUD();this.drawMap();this.updateAudio();
},
physics(dt){
const c=this.car,s=this.state,q0=this.track[c.trackI],fwd=new THREE.Vector3(Math.sin(c.heading),0,Math.cos(c.heading));let v=c.vel.dot(fwd);const distance=this.nearestTrack(),absLat=Math.abs(c.lateral);let surface=absLat<this.cfg.roadWidth?'ASPHALT':absLat<16?'GRAVEL':'GRASS';if((c.trackI>=115&&c.trackI<=145)||(c.trackI>=390&&c.trackI<=420))if(absLat<this.cfg.roadWidth)surface='MUD';s.surface=surface;
const active=s.mode==='race';const top={ASPHALT:54,GRAVEL:37,GRASS:27,MUD:20}[surface],grip={ASPHALT:7.8,GRAVEL:4.3,GRASS:2.7,MUD:2}[surface];
if(active&&this.input.gas)v+=24*dt;if(active&&this.input.brake)v-=v>1?38*dt:14*dt;v-=Math.sign(v)*Math.min(Math.abs(v),(.7+Math.abs(v)*.018)*dt);v=THREE.MathUtils.clamp(v,-12,top);
const steerInput=(this.input.left?1:0)-(this.input.right?1:0),steerPower=THREE.MathUtils.lerp(1.75,.55,Math.min(Math.abs(v)/50,1));c.steer=THREE.MathUtils.lerp(c.steer,steerInput,.12);if(Math.abs(v)>.4)c.heading+=c.steer*steerPower*dt*Math.sign(v)*(this.input.hand?1.7:1);
fwd.set(Math.sin(c.heading),0,Math.cos(c.heading));const target=fwd.multiplyScalar(v),slideGrip=this.input.hand?1.15:grip;c.vel.lerp(target,Math.min(1,slideGrip*dt));c.speed=c.vel.length()*Math.sign(v);c.pos.addScaledVector(c.vel,dt);
const q=this.track[c.trackI];c.pos.y=THREE.MathUtils.lerp(c.pos.y,q.p.y+.72,Math.min(1,8*dt));
// Soft road-edge bounce and solid scenery collisions
if(absLat>25){c.pos.addScaledVector(q.side,-Math.sign(c.lateral)*8*dt);c.vel.multiplyScalar(.94)}
let hit=false;for(const o of this.obstacles){const dx=c.pos.x-o.pos.x,dz=c.pos.z-o.pos.z,rr=o.r+1.35;if(dx*dx+dz*dz<rr*rr){const d=Math.max(.01,Math.hypot(dx,dz)),nx=dx/d,nz=dz/d;c.pos.x=o.pos.x+nx*rr;c.pos.z=o.pos.z+nz*rr;c.vel.reflect(new THREE.Vector3(nx,0,nz)).multiplyScalar(.28);hit=true;this.spawnSparks(c.pos)}}if(hit){this.crash();c.speed*=.3}
c.mesh.position.copy(c.pos);c.mesh.rotation.y=c.heading;c.mesh.rotation.z=THREE.MathUtils.lerp(c.mesh.rotation.z,-c.steer*Math.min(Math.abs(v)/40,1)*.12,.15);c.mesh.rotation.x=THREE.MathUtils.lerp(c.mesh.rotation.x,(this.input.brake?-.04:this.input.gas?.025:0),.1);
const sliding=Math.abs(c.vel.clone().normalize().dot(new THREE.Vector3(Math.sin(c.heading),0,Math.cos(c.heading))))<.94&&Math.abs(v)>12;if((sliding||surface!=='ASPHALT')&&Math.abs(v)>9&&Math.random()<dt*25)this.spawnDust(c.pos,surface);if(sliding&&Math.random()<dt*18)this.addSkid();
if(active)this.checkProgress();
},
checkProgress(){
const s=this.state,i=this.car.trackI,cps=[0,150,300,450];if(s.nextCP<4&&Math.abs(i-cps[s.nextCP])<7)s.nextCP++;
if(s.nextCP===4&&(i<8||i>592)){const lapTime=s.raceTime-s.lapStart;if(s.raceTime>4){s.lastLap=lapTime;s.bestLap=s.bestLap===null?lapTime:Math.min(s.bestLap,lapTime);s.lapStart=s.raceTime;if(s.lap>=this.cfg.totalLaps){this.finishRace()}else{s.lap++;s.nextCP=1;this.showMessage('LAP '+s.lap,1.2);this.beep(650,.1)}}}
},
finishRace(){const s=this.state;s.mode='finish';this.ui.finalTime.textContent=this.fmt(s.raceTime);this.ui.finalBest.textContent=this.fmt(s.bestLap);this.ui.finish.classList.add('show');this.finishTone()},
cameraUpdate(dt){
const c=this.car,f=new THREE.Vector3(Math.sin(c.heading),0,Math.cos(c.heading)),side=new THREE.Vector3(f.z,0,-f.x),speed=Math.abs(c.speed);let desired,look;
if(!this.state.cam){desired=c.pos.clone().addScaledVector(f,-11-speed*.055).add(new THREE.Vector3(0,5.4+speed*.025,0)).addScaledVector(side,-c.steer*.8);look=c.pos.clone().addScaledVector(f,8).add(new THREE.Vector3(0,1.3,0))}else{desired=c.pos.clone().addScaledVector(f,-19).add(new THREE.Vector3(0,15,0));look=c.pos.clone().addScaledVector(f,6)}
this.camera.position.lerp(desired,1-Math.exp(-dt*(this.state.cam?2.2:4.2)));this.camera.lookAt(look);this.camera.fov=THREE.MathUtils.lerp(this.camera.fov,65+Math.min(speed*.18,9),.05);this.camera.updateProjectionMatrix();
},
spawnDust(pos,surface){const color=surface==='MUD'?0x5b3b26:surface==='GRASS'?0x9a9d69:0xc1a47b;const m=new THREE.Mesh(new THREE.IcosahedronGeometry(.22,1),new THREE.MeshBasicMaterial({color,transparent:true,opacity:.55,depthWrite:false}));m.position.copy(pos).add(new THREE.Vector3((Math.random()-.5)*2,.3,(Math.random()-.5)*2));m.userData={life:1,vel:new THREE.Vector3((Math.random()-.5)*2,1+Math.random(),(Math.random()-.5)*2),grow:2};this.scene.add(m);this.particles.push(m)},
spawnSparks(pos){for(let i=0;i<9;i++){const m=new THREE.Mesh(new THREE.BoxGeometry(.05,.05,.28),new THREE.MeshBasicMaterial({color:0xffb226}));m.position.copy(pos);m.userData={life:.45,vel:new THREE.Vector3((Math.random()-.5)*10,2+Math.random()*5,(Math.random()-.5)*10),grow:0};this.scene.add(m);this.particles.push(m)}},
updateParticles(dt){for(let i=this.particles.length-1;i>=0;i--){const p=this.particles[i],d=p.userData;d.life-=dt;p.position.addScaledVector(d.vel,dt);d.vel.y-=3*dt;p.scale.addScalar(d.grow*dt);p.material.opacity=Math.max(0,d.life*.55);if(d.life<=0){this.scene.remove(p);p.geometry.dispose();p.material.dispose();this.particles.splice(i,1)}}},
addSkid(){const c=this.car,f=new THREE.Vector3(Math.sin(c.heading),0,Math.cos(c.heading)),side=new THREE.Vector3(f.z,0,-f.x);for(const s of [-1,1]){const a=c.pos.clone().addScaledVector(side,s*1.5);a.y-=.66;const b=a.clone().addScaledVector(c.vel.clone().normalize(),-1.2);const geo=new THREE.BufferGeometry().setFromPoints([a,b]);const line=new THREE.Line(geo,new THREE.LineBasicMaterial({color:0x221d16,transparent:true,opacity:.45}));line.userData.life=8;this.scene.add(line);this.skids.push(line)}if(this.skids.length>220){const x=this.skids.shift();this.scene.remove(x);x.geometry.dispose();x.material.dispose()}},
clearEffects(){for(const p of [...this.particles,...this.skids]){this.scene.remove(p);p.geometry.dispose();p.material.dispose()}this.particles=[];this.skids=[]},
updateHUD(){const s=this.state,c=this.car,kph=Math.round(Math.abs(c.speed)*3.6);this.ui.time.textContent=this.fmt(s.raceTime);this.ui.last.textContent=s.lastLap?this.fmt(s.lastLap):'--:--.---';this.ui.best.textContent=s.bestLap?this.fmt(s.bestLap):'--:--.---';this.ui.lap.textContent=Math.min(s.lap,3);this.ui.speed.textContent=String(Math.min(kph,999)).padStart(3,'0');const gear=c.speed<-.5?'R':kph<3?'N':Math.min(6,Math.floor(kph/36)+1);this.ui.gear.textContent=gear;const rpm=gear==='N'?8:gear==='R'?50:25+(kph%36)/36*72;this.ui.rpm.style.width=rpm+'%';this.ui.surface.textContent=s.surface;this.ui.surface.style.color={ASPHALT:'#e8ff36',GRAVEL:'#ffd060',GRASS:'#a9ef78',MUD:'#ff8a50'}[s.surface]},
drawMap(){const c=this.ui.map,x=c.getContext('2d'),w=c.width,h=c.height;x.clearRect(0,0,w,h);const pts=this.track,minX=-125,maxX=115,minZ=-95,maxZ=105,tx=v=>32+(v-minX)/(maxX-minX)*(w-64),tz=v=>35+(v-minZ)/(maxZ-minZ)*(h-62);x.lineCap='round';x.lineJoin='round';x.beginPath();pts.forEach((q,i)=>i?x.lineTo(tx(q.p.x),tz(q.p.z)):x.moveTo(tx(q.p.x),tz(q.p.z)));x.closePath();x.strokeStyle='#0008';x.lineWidth=15;x.stroke();x.strokeStyle='#eee7d2';x.lineWidth=7;x.stroke();const p=this.car.pos;x.beginPath();x.arc(tx(p.x),tz(p.z),8,0,Math.PI*2);x.fillStyle='#e8ff36';x.shadowBlur=12;x.shadowColor='#e8ff36';x.fill();x.shadowBlur=0;x.strokeStyle='#111';x.lineWidth=3;x.stroke()},
showMessage(text,duration=0){this.ui.msg.textContent=text;this.ui.msg.classList.toggle('show',!!text);if(duration){clearTimeout(this.msgTimer);this.msgTimer=setTimeout(()=>this.ui.msg.classList.remove('show'),duration*1000)}},
fmt(t){if(t==null)return'--:--.---';const m=Math.floor(t/60),s=Math.floor(t%60),ms=Math.floor(t*1000)%1000;return`${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}.${String(ms).padStart(3,'0')}`},
initAudio(){if(this.audio)return;const ctx=new (window.AudioContext||window.webkitAudioContext)(),master=ctx.createGain();master.gain.value=.14;master.connect(ctx.destination);const osc=ctx.createOscillator(),gain=ctx.createGain(),filter=ctx.createBiquadFilter();osc.type='sawtooth';osc.frequency.value=55;filter.type='lowpass';filter.frequency.value=340;gain.gain.value=.001;osc.connect(filter).connect(gain).connect(master);osc.start();const noise=ctx.createBuffer(1,ctx.sampleRate*2,ctx.sampleRate);const d=noise.getChannelData(0);for(let i=0;i<d.length;i++)d[i]=Math.random()*2-1;const ns=ctx.createBufferSource(),ng=ctx.createGain(),nf=ctx.createBiquadFilter();ns.buffer=noise;ns.loop=true;nf.type='bandpass';nf.frequency.value=900;ng.gain.value=.001;ns.connect(nf).connect(ng).connect(master);ns.start();this.audio={ctx,master,osc,gain,ng}},
updateAudio(){if(!this.audio)return;const a=this.audio,t=a.ctx.currentTime,sp=Math.abs(this.car.speed),rough=this.state.surface!=='ASPHALT'||Math.abs(this.car.steer)*sp>19;a.osc.frequency.setTargetAtTime(48+sp*4.2,t,.04);a.gain.gain.setTargetAtTime(this.state.mode==='race'?.08+sp*.003:.018,t,.05);a.ng.gain.setTargetAtTime(rough?Math.min(.2,.025+sp*.004):.002,t,.08)},
beep(freq,dur){if(!this.audio)return;const {ctx,master}=this.audio,o=ctx.createOscillator(),g=ctx.createGain();o.frequency.value=freq;o.type='square';g.gain.setValueAtTime(.22,ctx.currentTime);g.gain.exponentialRampToValueAtTime(.001,ctx.currentTime+dur);o.connect(g).connect(master);o.start();o.stop(ctx.currentTime+dur)},
crash(){if(!this.audio)return;const {ctx,master}=this.audio,o=ctx.createOscillator(),g=ctx.createGain();o.type='sawtooth';o.frequency.setValueAtTime(100,ctx.currentTime);o.frequency.exponentialRampToValueAtTime(35,ctx.currentTime+.2);g.gain.setValueAtTime(.35,ctx.currentTime);g.gain.exponentialRampToValueAtTime(.001,ctx.currentTime+.24);o.connect(g).connect(master);o.start();o.stop(ctx.currentTime+.25)},
finishTone(){if(!this.audio)return;[523,659,784,1047].forEach((n,i)=>setTimeout(()=>this.beep(n,.32),i*150))},
animate(){requestAnimationFrame(()=>this.animate());const dt=Math.min(this.clock.getDelta(),.04);this.update(dt);this.renderer.render(this.scene,this.camera)}
};
App.init();
})();
</script>
</body>
</html>