Rally
Deepseek v4.1 Flash · 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 84,010 bytes · SHA-256 54f4e1714265
<!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,viewport-fit=cover">
<title>RALLY RUSH — 3D Rally Racer</title>
<style>
:root{
--panel: rgba(9,13,19,.60);
--panel-solid: rgba(9,13,19,.92);
--stroke: rgba(255,255,255,.16);
--accent: #ffcf3f;
--accent2: #ff4d3d;
--text: #eef3f8;
--mono: ui-monospace, "SF Mono", "Roboto Mono", Menlo, Consolas, monospace;
}
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{
width:100%;height:100%;overflow:hidden;background:#10151c;color:var(--text);
font-family:"Segoe UI",system-ui,-apple-system,Roboto,Helvetica,Arial,sans-serif;
user-select:none;-webkit-user-select:none;touch-action:none;
}
#viewport{display:block;width:100%;height:100%;position:absolute;inset:0;background:
linear-gradient(#2d6bb0 0%, #7fb0d8 48%, #cfe1ec 52%, #6f7f5e 55%, #3d4a2c 100%);}
#hud{position:absolute;inset:0;pointer-events:none;z-index:5}
.panel{
position:absolute;background:var(--panel);border:1px solid var(--stroke);
border-radius:12px;backdrop-filter:blur(9px);-webkit-backdrop-filter:blur(9px);
box-shadow:0 8px 30px rgba(0,0,0,.42);
}
/* ---------- top-left timing panel ---------- */
#lapPanel{top:14px;left:14px;padding:10px 14px 11px;min-width:196px}
#lapPanel .row{display:flex;justify-content:space-between;align-items:baseline;gap:14px;line-height:1.5}
#lapPanel .label{font-size:10px;letter-spacing:.16em;font-weight:700;color:#9fb2c6}
#lapPanel .value{font-family:var(--mono);font-size:16px;font-weight:700;letter-spacing:.02em}
#lapPanel .row.big .value{font-size:22px;color:var(--accent)}
#lapPanel .value.best{color:#8ef0a8}
#lapPanel .value.last{color:#ffd9a0}
.sep{height:1px;background:var(--stroke);margin:6px 0}
/* ---------- minimap ---------- */
#minimapWrap{position:absolute;top:14px;right:14px;width:172px;height:172px;
background:var(--panel);border:1px solid var(--stroke);border-radius:12px;
backdrop-filter:blur(9px);-webkit-backdrop-filter:blur(9px);
box-shadow:0 8px 30px rgba(0,0,0,.42);overflow:hidden}
#minimap{display:block;width:100%;height:100%}
#minimapWrap .mmLabel{position:absolute;left:9px;bottom:6px;font-size:9px;letter-spacing:.18em;
color:#9fb2c6;font-weight:700}
/* ---------- speedo ---------- */
#speedo{position:absolute;right:14px;bottom:14px;width:250px;height:152px}
#gauge{display:block;width:100%;height:100%;filter:drop-shadow(0 8px 24px rgba(0,0,0,.5))}
/* ---------- countdown / toast ---------- */
#countdown{
position:absolute;left:50%;top:42%;transform:translate(-50%,-50%);
font-size:clamp(70px,15vw,190px);font-weight:900;letter-spacing:.04em;
color:#fff;text-shadow:0 0 26px rgba(255,207,63,.85),0 10px 40px rgba(0,0,0,.7);
opacity:0;
}
#countdown.show{animation:pop .85s cubic-bezier(.2,.9,.3,1) forwards}
#countdown.go{color:#7dffa1;text-shadow:0 0 34px rgba(125,255,161,.9),0 10px 40px rgba(0,0,0,.7)}
@keyframes pop{
0%{opacity:0;transform:translate(-50%,-50%) scale(1.7)}
22%{opacity:1;transform:translate(-50%,-50%) scale(1)}
75%{opacity:1;transform:translate(-50%,-50%) scale(1)}
100%{opacity:0;transform:translate(-50%,-50%) scale(.86)}
}
#toast{position:absolute;left:50%;top:16%;transform:translateX(-50%);
font-family:var(--mono);font-size:20px;font-weight:800;letter-spacing:.06em;
padding:8px 20px;border-radius:10px;background:var(--panel-solid);border:1px solid var(--stroke);
opacity:0;transition:opacity .3s;white-space:nowrap}
#toast.show{opacity:1}
#toast.fast{color:#8ef0a8;border-color:rgba(142,240,168,.5)}
/* ---------- overlays ---------- */
.overlay{
position:absolute;inset:0;display:flex;align-items:center;justify-content:center;z-index:20;
background:radial-gradient(circle at 50% 40%, rgba(12,20,30,.55), rgba(4,7,11,.88));
backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);
}
.overlay.hidden{display:none}
.card{
width:min(560px,92vw);max-height:92vh;overflow:auto;background:var(--panel-solid);
border:1px solid var(--stroke);border-radius:18px;padding:26px 28px 24px;
box-shadow:0 24px 80px rgba(0,0,0,.7);text-align:center;
}
.card h1{
font-size:clamp(30px,6vw,50px);letter-spacing:.06em;font-weight:900;line-height:1;
background:linear-gradient(180deg,#fff,#ffcf3f);-webkit-background-clip:text;background-clip:text;
-webkit-text-fill-color:transparent;margin-bottom:6px;
}
.card h2{font-size:26px;letter-spacing:.08em;margin-bottom:8px}
.card .sub{font-size:12px;letter-spacing:.22em;color:#9fb2c6;font-weight:700;margin-bottom:20px}
.keys{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px 18px;
text-align:left;margin:0 auto 20px;max-width:430px}
.keys div{font-size:12.5px;color:#c6d5e4;display:flex;align-items:center;gap:8px}
kbd{font-family:var(--mono);font-size:11px;background:#1b2430;border:1px solid rgba(255,255,255,.2);
border-bottom-width:2px;border-radius:5px;padding:2px 6px;color:#fff;min-width:22px;display:inline-block;text-align:center}
.btn{
pointer-events:auto;cursor:pointer;border:none;border-radius:12px;padding:14px 30px;
font-family:inherit;font-weight:900;font-size:16px;letter-spacing:.12em;color:#181205;
background:linear-gradient(180deg,#ffe07a,#ffb703);box-shadow:0 6px 0 #a06f00,0 12px 30px rgba(0,0,0,.5);
transition:transform .08s,box-shadow .08s;
}
.btn:active{transform:translateY(3px);box-shadow:0 3px 0 #a06f00,0 8px 20px rgba(0,0,0,.5)}
.btn.ghost{background:transparent;border:1px solid var(--stroke);color:#dfe9f3;
box-shadow:none;font-size:13px;padding:12px 22px}
.btn.ghost:active{transform:translateY(2px);box-shadow:none}
.btnRow{display:flex;gap:12px;justify-content:center;flex-wrap:wrap}
.stats{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:0 auto 18px;max-width:400px}
.stat{background:#111823;border:1px solid var(--stroke);border-radius:10px;padding:10px 12px;text-align:left}
.stat .k{font-size:10px;letter-spacing:.16em;color:#9fb2c6;font-weight:700}
.stat .v{font-family:var(--mono);font-size:19px;font-weight:800;margin-top:2px}
.stat .v.best{color:#8ef0a8}
#lapList{font-family:var(--mono);font-size:13px;color:#c6d5e4;margin:0 auto 18px;max-width:400px;
display:flex;flex-direction:column;gap:4px}
#lapList .li{display:flex;justify-content:space-between;border-bottom:1px dashed rgba(255,255,255,.12);padding:3px 2px}
#bootErr{color:#ffb4ac;font-size:13px;margin-top:16px;line-height:1.5}
/* ---------- touch controls ---------- */
#touch{position:absolute;inset:0;z-index:10;pointer-events:none;display:none}
#touch.on{display:block}
.tbtn{
position:absolute;pointer-events:auto;display:flex;align-items:center;justify-content:center;
background:rgba(16,24,34,.55);border:1.5px solid var(--stroke);border-radius:50%;
color:#e8f0f8;font-weight:900;font-size:15px;letter-spacing:.06em;
backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);
transition:background .08s,transform .08s;user-select:none;
}
.tbtn.active{background:rgba(255,207,63,.55);transform:scale(.94)}
#tLeft{left:18px;bottom:22px;width:74px;height:74px}
#tRight{left:112px;bottom:22px;width:74px;height:74px}
#tGas{right:18px;bottom:26px;width:104px;height:104px;font-size:17px}
#tBrake{right:132px;bottom:30px;width:76px;height:76px}
#tDrift{right:38px;bottom:146px;width:76px;height:76px;font-size:12px}
@media (max-width:820px){
#minimapWrap{width:118px;height:118px}
#speedo{width:186px;height:114px}
#lapPanel{min-width:0;padding:8px 11px}
#lapPanel .value{font-size:13px}
#lapPanel .row.big .value{font-size:17px}
.keys{display:none}
}
</style>
</head>
<body>
<canvas id="viewport"></canvas>
<div id="hud">
<div class="panel" id="lapPanel">
<div class="row big"><span class="label">LAP</span><span class="value" id="lapValue">1 / 3</span></div>
<div class="sep"></div>
<div class="row"><span class="label">TIME</span><span class="value" id="timeValue">0:00.00</span></div>
<div class="row"><span class="label">LAST</span><span class="value last" id="lastValue">--:--.--</span></div>
<div class="row"><span class="label">BEST</span><span class="value best" id="bestValue">--:--.--</span></div>
</div>
<div id="minimapWrap"><canvas id="minimap" width="360" height="360"></canvas><span class="mmLabel">STAGE MAP</span></div>
<div id="speedo"><canvas id="gauge" width="500" height="304"></canvas></div>
<div id="countdown"></div>
<div id="toast"></div>
</div>
<div id="touch">
<div class="tbtn" id="tLeft">◀</div>
<div class="tbtn" id="tRight">▶</div>
<div class="tbtn" id="tGas">GAS</div>
<div class="tbtn" id="tBrake">BRAKE</div>
<div class="tbtn" id="tDrift">DRIFT</div>
</div>
<div class="overlay" id="overlayStart">
<div class="card">
<h1>RALLY RUSH</h1>
<div class="sub">3 LAPS · POINT TO POINT STAGE</div>
<div class="keys">
<div><kbd>W</kbd><kbd>↑</kbd> Throttle</div>
<div><kbd>S</kbd><kbd>↓</kbd> Brake / Reverse</div>
<div><kbd>A</kbd><kbd>D</kbd> Steer</div>
<div><kbd>Space</kbd> Handbrake drift</div>
<div><kbd>C</kbd> Camera view</div>
<div><kbd>P</kbd> Pause</div>
<div><kbd>R</kbd> Restart stage</div>
<div><kbd>M</kbd> Mute audio</div>
</div>
<button class="btn" id="btnStart">START ENGINE</button>
<div id="bootErr"></div>
</div>
</div>
<div class="overlay hidden" id="overlayPause">
<div class="card">
<h2>PAUSED</h2>
<div class="sub">STAGE HALTED</div>
<div class="btnRow">
<button class="btn" id="btnResume">RESUME</button>
<button class="btn ghost" id="btnRestartPause">RESTART STAGE</button>
</div>
</div>
</div>
<div class="overlay hidden" id="overlayFinish">
<div class="card">
<h1 id="finishTitle">FINISH</h1>
<div class="sub" id="finishSub">STAGE COMPLETE</div>
<div class="stats">
<div class="stat"><div class="k">TOTAL TIME</div><div class="v" id="finTotal">0:00.00</div></div>
<div class="stat"><div class="k">BEST LAP</div><div class="v best" id="finBest">0:00.00</div></div>
</div>
<div id="lapList"></div>
<button class="btn" id="btnRestartFin">RESTART STAGE</button>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
(function(){
'use strict';
/* =========================================================================
RALLY RUSH — 3D rally racer
Single-file artifact. Three.js (CDN) + procedural everything else.
Modules: Utils, Config, Audio, Input, Track, World, Car, FX, CameraRig,
HUD, Game
========================================================================= */
/* ------------------------------------------------------------------ Utils */
const U = {
clamp:(v,a,b)=>v<a?a:(v>b?b:v),
lerp:(a,b,t)=>a+(b-a)*t,
damp:(a,b,l,dt)=>U.lerp(a,b,1-Math.exp(-l*dt)),
smooth:(e0,e1,x)=>{const t=U.clamp((x-e0)/(e1-e0),0,1);return t*t*(3-2*t);},
rand:(a,b)=>a+Math.random()*(b-a),
randInt:(a,b)=>Math.floor(a+Math.random()*(b-a+1)),
pick:arr=>arr[Math.floor(Math.random()*arr.length)],
angleLerp:(a,b,t)=>{let d=((b-a+Math.PI)%(Math.PI*2)+Math.PI*2)%(Math.PI*2)-Math.PI;return a+d*t;},
fmt(ms){
if(ms==null||!isFinite(ms)) return "--:--.--";
const t=Math.max(0,ms), m=Math.floor(t/60000), s=Math.floor(t%60000/1000), c=Math.floor(t%1000/10);
return m+":"+(s<10?"0":"")+s+"."+(c<10?"0":"")+c;
},
fmtShort(ms){
if(ms==null||!isFinite(ms)) return "--.--";
const t=Math.max(0,ms), s=Math.floor(t/1000), c=Math.floor(t%1000/10);
return s+"."+(c<10?"0":"")+c;
}
};
window.Rally = { Utils:U };
/* ----------------------------------------------------------------- Config */
const CFG = {
LAPS:3,
SAMPLES:900,
HALF_WIDTH:6.0, // asphalt/dirt ribbon half width (m)
SHOULDER:3.4, // gravel shoulder width each side (m)
CAR_HALF:1.5, // collision radius of the car (m)
GATES:[0,.25,.5,.75], // normalized lap positions of checkpoint gates
SEED:1337
};
window.Rally.Config = CFG;
/* small deterministic PRNG so scenery is stable between restarts */
function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296;};}
const RNG = mulberry32(CFG.SEED);
/* ---- surface table: grip/rolling/max-speed per ground type ---- */
const SURFACES = {
asphalt:{name:"ASPHALT", grip:9.0, roll:0.30, top:60, accel:1.00, color:[0.24,0.25,0.28]},
dirt: {name:"DIRT", grip:6.2, roll:0.55, top:50, accel:0.92, color:[0.45,0.34,0.21]},
gravel: {name:"GRAVEL", grip:6.3, roll:0.70, top:43, accel:0.86, color:[0.55,0.49,0.39]},
mud: {name:"MUD", grip:4.2, roll:1.35, top:26, accel:0.60, color:[0.30,0.23,0.16]},
water: {name:"WATER", grip:4.6, roll:1.60, top:20, accel:0.52, color:[0.18,0.30,0.42]},
grass: {name:"GRASS", grip:3.4, roll:1.10, top:32, accel:0.62, color:[0.27,0.42,0.19]}
};
/* ================================================================== AUDIO
Everything synthesised with the Web Audio API: engine drone, tyre noise,
countdown beeps, crash thuds, finish jingle.
======================================================================== */
const Audio = {
ctx:null, ok:false, muted:false, ready:false,
init(){
if(this.ready) return;
const AC = window.AudioContext || window.webkitAudioContext;
if(!AC){ this.ok=false; return; }
try{ this.ctx = new AC(); }catch(e){ this.ok=false; return; }
const ctx = this.ctx;
this.master = ctx.createGain(); this.master.gain.value = 0.9; this.master.connect(ctx.destination);
/* ---- engine: two saw-ish oscillators through a moving lowpass ---- */
this.engGain = ctx.createGain(); this.engGain.gain.value = 0.0001;
this.engFilter = ctx.createBiquadFilter(); this.engFilter.type="lowpass";
this.engFilter.frequency.value = 700; this.engFilter.Q.value = 1.2;
this.engGain.connect(this.engFilter); this.engFilter.connect(this.master);
this.oscA = ctx.createOscillator(); this.oscA.type="sawtooth";
this.oscB = ctx.createOscillator(); this.oscB.type="square";
this.oscC = ctx.createOscillator(); this.oscC.type="sawtooth";
const gA = ctx.createGain(); gA.gain.value=0.6;
const gB = ctx.createGain(); gB.gain.value=0.22;
const gC = ctx.createGain(); gC.gain.value=0.35;
this.oscA.connect(gA); this.oscB.connect(gB); this.oscC.connect(gC);
gA.connect(this.engGain); gB.connect(this.engGain); gC.connect(this.engGain);
this.oscA.start(); this.oscB.start(); this.oscC.start();
/* ---- looping white-noise source for tyres / gravel / wind ---- */
const len = ctx.sampleRate*2;
const buf = ctx.createBuffer(1,len,ctx.sampleRate);
const d = buf.getChannelData(0);
for(let i=0;i<len;i++) d[i] = Math.random()*2-1;
this.noiseSrc = ctx.createBufferSource(); this.noiseSrc.buffer=buf; this.noiseSrc.loop=true;
this.noiseFilter = ctx.createBiquadFilter(); this.noiseFilter.type="bandpass";
this.noiseFilter.frequency.value=1400; this.noiseFilter.Q.value=0.7;
this.noiseGain = ctx.createGain(); this.noiseGain.gain.value=0.0001;
this.noiseSrc.connect(this.noiseFilter); this.noiseFilter.connect(this.noiseGain);
this.noiseGain.connect(this.master); this.noiseSrc.start();
this.ready = true; this.ok = true;
},
resume(){ if(this.ctx && this.ctx.state==="suspended") this.ctx.resume(); },
setMuted(m){ this.muted=m; if(this.master) this.master.gain.setTargetAtTime(m?0:0.9, this.ctx.currentTime, 0.05); },
/* rpm: 0..1 (normalised), load: 0..1, speed: m/s */
engine(rpmN, load, speed){
if(!this.ok) return;
const t = this.ctx.currentTime;
const f = 34 + rpmN*186; // idle rumble -> screaming
this.oscA.frequency.setTargetAtTime(f, t, 0.05);
this.oscB.frequency.setTargetAtTime(f*0.5, t, 0.05);
this.oscC.frequency.setTargetAtTime(f*2.02, t, 0.05);
this.engFilter.frequency.setTargetAtTime(360 + rpmN*2600, t, 0.06);
const g = 0.035 + load*0.075 + rpmN*0.03;
this.engGain.gain.setTargetAtTime(g, t, 0.08);
},
tyres(amount, kind, speed){
if(!this.ok) return;
const t=this.ctx.currentTime;
this.noiseGain.gain.setTargetAtTime(amount*0.30, t, 0.06);
const base = kind==="water"?700: kind==="gravel"?1900: 2600;
this.noiseFilter.frequency.setTargetAtTime(base + speed*14, t, 0.08);
this.noiseFilter.Q.value = kind==="skid"?3.2:0.8;
},
quiet(){ if(!this.ok) return;
this.engGain.gain.setTargetAtTime(0.0001,this.ctx.currentTime,0.1);
this.noiseGain.gain.setTargetAtTime(0.0001,this.ctx.currentTime,0.1);
},
beep(freq, dur, vol, type){
if(!this.ok) return;
const ctx=this.ctx, t=ctx.currentTime;
const o=ctx.createOscillator(), g=ctx.createGain();
o.type=type||"square"; o.frequency.value=freq;
g.gain.setValueAtTime(0,t);
g.gain.linearRampToValueAtTime(vol||0.22, t+0.012);
g.gain.exponentialRampToValueAtTime(0.0001, t+dur);
o.connect(g); g.connect(this.master); o.start(t); o.stop(t+dur+0.05);
},
thud(power){
if(!this.ok) return;
const ctx=this.ctx, t=ctx.currentTime, p=U.clamp(power,0.15,1);
const len=Math.floor(ctx.sampleRate*0.35);
const buf=ctx.createBuffer(1,len,ctx.sampleRate);
const d=buf.getChannelData(0);
for(let i=0;i<len;i++){ const k=i/len; d[i]=(Math.random()*2-1)*Math.pow(1-k,3); }
const s=ctx.createBufferSource(); s.buffer=buf;
const f=ctx.createBiquadFilter(); f.type="lowpass"; f.frequency.value=900;
const g=ctx.createGain(); g.gain.value=0.55*p;
s.connect(f); f.connect(g); g.connect(this.master); s.start(t);
const o=ctx.createOscillator(); o.type="sine";
o.frequency.setValueAtTime(190*p+60,t);
o.frequency.exponentialRampToValueAtTime(40,t+0.25);
const og=ctx.createGain(); og.gain.setValueAtTime(0.5*p,t);
og.gain.exponentialRampToValueAtTime(0.0001,t+0.32);
o.connect(og); og.connect(this.master); o.start(t); o.stop(t+0.34);
},
finishJingle(){
if(!this.ok) return;
const seq=[[523,0],[659,0.13],[784,0.26],[1046,0.40],[1318,0.56]];
seq.forEach(([f,dt])=>setTimeout(()=>this.beep(f,0.34,0.22,"triangle"), dt*1000));
}
};
window.Rally.Audio = Audio;
/* ================================================================== INPUT */
const Input = {
keys:Object.create(null),
touch:{left:false,right:false,gas:false,brake:false,drift:false},
onAction:null,
init(){
addEventListener("keydown",e=>{
const k=e.key.toLowerCase();
if([" ","arrowup","arrowdown","arrowleft","arrowright"].includes(k)) e.preventDefault();
if(this.keys[k]) return;
this.keys[k]=true;
if(this.onAction) this.onAction(k);
},{passive:false});
addEventListener("keyup",e=>{ this.keys[e.key.toLowerCase()]=false; });
addEventListener("blur",()=>{ this.keys=Object.create(null); });
this.bindTouch();
},
bindTouch(){
const map={tLeft:"left",tRight:"right",tGas:"gas",tBrake:"brake",tDrift:"drift"};
const el=document.getElementById("touch");
if(("ontouchstart" in window) || navigator.maxTouchPoints>0) el.classList.add("on");
Object.keys(map).forEach(id=>{
const node=document.getElementById(id), prop=map[id];
const on=e=>{e.preventDefault(); this.touch[prop]=true; node.classList.add("active");};
const off=e=>{e.preventDefault(); this.touch[prop]=false; node.classList.remove("active");};
node.addEventListener("pointerdown",on);
node.addEventListener("pointerup",off);
node.addEventListener("pointercancel",off);
node.addEventListener("pointerleave",off);
node.addEventListener("contextmenu",e=>e.preventDefault());
});
},
/* normalised controls */
read(){
const k=this.keys;
const up=k["arrowup"]||k["w"], dn=k["arrowdown"]||k["s"];
const lf=k["arrowleft"]||k["a"], rt=k["arrowright"]||k["d"];
return {
throttle:(up||this.touch.gas)?1:0,
brake:(dn||this.touch.brake)?1:0,
steer:((rt||this.touch.right)?1:0)-((lf||this.touch.left)?1:0),
handbrake:!!(k[" "]||this.touch.drift)
};
}
};
window.Rally.Input = Input;
/* ================================================================== TRACK
Procedure:
1. Ring of control points on polar coordinates (radius + hill height
harmonics) -> closed Catmull-Rom spline -> N spaced samples.
2. Each sample stores position, tangent, side vector, surface type,
arc length. A uniform grid allows O(1) nearest-sample lookups used
for terrain flattening, surface queries and lap progress.
3. Meshes: road ribbon (vertex coloured), gravel shoulders, dashed
centre line, water ribbons, plus barriers/signs/banners.
======================================================================== */
const UP = {x:0,y:1,z:0};
const Track = {
samples:[], length:0, group:null,
grid:{cell:34, min:-360, n:22, buckets:null},
gates:[],
build(){
const n=24, pts=[];
for(let i=0;i<n;i++){
const a=i/n*Math.PI*2;
const r = 118 + 26*Math.sin(3*a+0.30) + 9*Math.sin(5*a+1.10);
const y = 6.2*Math.sin(2*a+0.60) + 4.1*Math.sin(3*a+2.30) + 2.2*Math.sin(5*a+1.00);
pts.push(new THREE.Vector3(Math.cos(a)*r, y, Math.sin(a)*r));
}
const curve = new THREE.CatmullRomCurve3(pts, true, "catmullrom", 0.5);
const raw = curve.getSpacedPoints(CFG.SAMPLES); // returns N+1, last==first
const N = CFG.SAMPLES;
// ---- sample data ----
this.samples = [];
for(let i=0;i<N;i++){
const p = raw[i].clone();
this.samples.push({p, t:new THREE.Vector3(), s:new THREE.Vector3(), arc:0, surf:"asphalt"});
}
let arc=0;
for(let i=0;i<N;i++){
const prev=this.samples[(i-1+N)%N].p, next=this.samples[(i+1)%N].p;
const t=new THREE.Vector3().subVectors(next,prev).normalize();
const s=new THREE.Vector3().crossVectors(t,new THREE.Vector3(0,1,0)).normalize();
this.samples[i].t.copy(t); this.samples[i].s.copy(s);
this.samples[i].arc = arc;
arc += this.samples[i].p.distanceTo(next);
}
this.length = arc;
// ---- surface segmentation (fast -> slow variety) ----
const SEG=[[0,.13,"asphalt"],[.13,.25,"dirt"],[.25,.30,"mud"],[.30,.47,"asphalt"],
[.47,.60,"dirt"],[.60,.655,"water"],[.655,.80,"asphalt"],[.80,.90,"gravel"],
[.90,.945,"water"],[.945,1.0,"asphalt"]];
for(let i=0;i<N;i++){
const f=i/N; let s="asphalt";
for(const [a,b,name] of SEG){ if(f>=a&&f<b){ s=name; break; } }
this.samples[i].surf=s;
}
for(let i=0;i<N;i++) this.samples[i].y = this.samples[i].p.y;
this.buildGrid();
this.gates = CFG.GATES.map(f=>({idx:Math.round(f*N)%N, hit:false, f}));
this.buildMeshes();
},
buildGrid(){
const g=this.grid; g.buckets=new Map();
const key=(cx,cz)=>cx*1000+cz;
for(let i=0;i<this.samples.length;i++){
const p=this.samples[i].p;
const cx=Math.floor((p.x-g.min)/g.cell), cz=Math.floor((p.z-g.min)/g.cell);
const k=key(cx,cz);
let b=g.buckets.get(k); if(!b){b=[];g.buckets.set(k,b);}
b.push(i);
}
},
/* nearest centre-line sample within `rad` grid cells of (x,z) */
nearest(x,z,rad){
const g=this.grid, key=(cx,cz)=>cx*1000+cz;
const cx=Math.floor((x-g.min)/g.cell), cz=Math.floor((z-g.min)/g.cell);
let best=-1, bd=Infinity;
for(let i=cx-rad;i<=cx+rad;i++)for(let j=cz-rad;j<=cz+rad;j++){
const b=g.buckets.get(key(i,j)); if(!b) continue;
for(let q=0;q<b.length;q++){
const p=this.samples[b[q]].p;
const dx=p.x-x, dz=p.z-z, d=dx*dx+dz*dz;
if(d<bd){ bd=d; best=b[q]; }
}
}
return best<0?null:{idx:best, d:Math.sqrt(bd)};
},
/* nearest sample using a window around `hint` — used for lap progress so
the car can never "jump" to a far part of the track that folds close by */
nearestWindow(x,z,hint,win){
const N=this.samples.length; let best=-1,bd=Infinity;
for(let k=-win;k<=win;k++){
const i=((hint+k)%N+N)%N;
const p=this.samples[i].p, dx=p.x-x, dz=p.z-z, d=dx*dx+dz*dz;
if(d<bd){bd=d;best=i;}
}
return {idx:best, d:Math.sqrt(bd)};
},
sampleAt(x,z,hint){
let res, idx;
if(hint==null){ res=this.nearest(x,z,2); if(!res) return {idx:0,d:1e9,lat:1e9,surf:"grass",y:0}; idx=res.idx; }
else { res=this.nearestWindow(x,z,hint,42); idx=res.idx; }
const s=this.samples[idx];
// signed lateral distance from the centre line
const dx=x-s.p.x, dz=z-s.p.z;
const lat=dx*s.s.x+dz*s.s.z;
const a=Math.abs(lat);
let surf;
if(a<CFG.HALF_WIDTH) surf=s.surf;
else if(a<CFG.HALF_WIDTH+CFG.SHOULDER) surf="gravel";
else surf="grass";
return {idx, d:res.d, lat, surf, y:s.p.y};
},
surfaceOf(s){ return SURFACES[s]||SURFACES.grass; },
/* ---------- geometry builders ---------- */
buildMeshes(){
const g=new THREE.Group(); this.group=g;
const N=this.samples.length, S=this.samples;
const HW=CFG.HALF_WIDTH, SH=CFG.SHOULDER, LIFT=0.30;
const ribbon=(offA,offB,yOff,colorFn,mat)=>{
const cols=2, verts=N+1;
const pos=new Float32Array(verts*cols*3), col=new Float32Array(verts*cols*3);
const idx=[];
for(let i=0;i<=N;i++){
const s=S[i%N];
for(let c=0;c<cols;c++){
const off=(c===0?offA:offB);
const v=(i*cols+c);
pos[v*3 ]=s.p.x+s.s.x*off;
pos[v*3+1]=(s.p.y+LIFT)+yOff;
pos[v*3+2]=s.p.z+s.s.z*off;
const rgb=colorFn(s,i,c);
col[v*3]=rgb[0]; col[v*3+1]=rgb[1]; col[v*3+2]=rgb[2];
}
}
for(let i=0;i<N;i++){
const a=i*cols, b=(i+1)*cols, c=a+1, d=b+1;
idx.push(a,b,c, c,b,d);
}
const geo=new THREE.BufferGeometry();
geo.setAttribute("position",new THREE.BufferAttribute(pos,3));
geo.setAttribute("color",new THREE.BufferAttribute(col,3));
geo.setIndex(idx); geo.computeVertexNormals();
const m=new THREE.Mesh(geo,mat); m.receiveShadow=true;
return m;
};
const roadMat=new THREE.MeshLambertMaterial({vertexColors:true});
const dirtTint=(s,i)=>{
const base=SURFACES[s.surf].color;
const j=0.86+0.14*Math.sin(i*0.7);
// subtle dappling so the surface doesn't look flat
const n=(Math.sin(i*0.31)*0.5+0.5)*0.10-0.05;
return [base[0]*j+n, base[1]*j+n, base[2]*j+n];
};
// Left half and right half separately so the centre joint stays hidden
g.add(ribbon( HW,0,0, dirtTint, roadMat));
g.add(ribbon(0,-HW,0, dirtTint, roadMat));
// gravel shoulders
const shMat=new THREE.MeshLambertMaterial({vertexColors:true});
const shTint=(s,i,c)=>{
const b=SURFACES.gravel.color, j=(c===0?0.78:1.0)*(0.9+0.2*(0.5+0.5*Math.sin(i*0.53)));
return [b[0]*j,b[1]*j,b[2]*j];
};
g.add(ribbon( HW+SH, HW, -0.06, shTint, shMat));
g.add(ribbon(-HW, -(HW+SH), -0.06, shTint, shMat));
// dashed white centre line
{
const pos=[], idx=[]; let v=0;
for(let i=0;i<N;i++){
if(i%13>=7) continue;
const s=S[i], s2=S[(i+1)%N], w=0.16;
const y=s.p.y+LIFT+0.02, y2=s2.p.y+LIFT+0.02;
pos.push(s.p.x-s.s.x*w,y,s.p.z-s.s.z*w, s.p.x+s.s.x*w,y,s.p.z+s.s.z*w,
s2.p.x-s.s.x*w,y2,s2.p.z-s.s.z*w, s2.p.x+s.s.x*w,y2,s2.p.z+s.s.z*w);
idx.push(v,v+2,v+1, v+1,v+2,v+3); v+=4;
}
const geo=new THREE.BufferGeometry();
geo.setAttribute("position",new THREE.Float32BufferAttribute(pos,3));
geo.setIndex(idx); geo.computeVertexNormals();
const m=new THREE.Mesh(geo,new THREE.MeshBasicMaterial({color:0xdfe6ea,transparent:true,opacity:0.55,
polygonOffset:true,polygonOffsetFactor:-2,polygonOffsetUnits:-2}));
g.add(m);
}
// water / mud ribbons (shallow water on the track)
{
const pos=[], idx=[]; const waterMat=new THREE.MeshPhongMaterial({color:0x4a7d9e,transparent:true,
opacity:0.58,shininess:130,specular:0xcfeaff,side:THREE.DoubleSide,
polygonOffset:true,polygonOffsetFactor:-3,polygonOffsetUnits:-3});
let v=0;
for(let i=0;i<N;i++){
if(S[i].surf!=="water") continue;
const s=S[i], n=S[(i+1)%N];
if(n.surf!=="water") continue;
const y=s.p.y+LIFT+0.04, y2=n.p.y+LIFT+0.04, w=HW+1.1;
pos.push(s.p.x-s.s.x*w,y,s.p.z-s.s.z*w, s.p.x+s.s.x*w,y,s.p.z+s.s.z*w,
n.p.x-n.s.x*w,y2,n.p.z-n.s.z*w, n.p.x+n.s.x*w,y2,n.p.z+n.s.z*w);
idx.push(v,v+2,v+1, v+1,v+2,v+3); v+=4;
}
const geo=new THREE.BufferGeometry();
geo.setAttribute("position",new THREE.Float32BufferAttribute(pos,3));
geo.setIndex(idx); geo.computeVertexNormals();
g.add(new THREE.Mesh(geo,waterMat));
}
}
};
window.Rally.Track = Track;
/* ================================================================== WORLD
Terrain, sky, lights, scenery (trees/rocks/crowd/signs/gantry) and the
obstacle collision index.
======================================================================== */
const World = {
group:null, obstacles:[], obsGrid:new Map(), CELL:14,
trees:null, sun:null,
/* --- rolling base terrain (unrelated to the track spline) --- */
baseNoise(x,z){
return 7.2*Math.sin(x*0.0075)*Math.cos(z*0.0091)
+ 3.4*Math.sin(x*0.019+1.3)*Math.sin(z*0.0163+0.7)
+ 1.7*Math.cos(x*0.031+z*0.026)
+ 0.9*Math.sin(x*0.061)*Math.cos(z*0.055);
},
/* --- ground height: blended to the track bed near the ribbon --- */
terrainY(x,z){
const n=this.baseNoise(x,z);
const hit=Track.nearest(x,z,2);
if(!hit) return n;
const ry=Track.samples[hit.idx].p.y+0.18;
const t=U.smooth(CFG.HALF_WIDTH+CFG.SHOULDER, 30, hit.d);
return U.lerp(ry,n,t);
},
build(scene){
const g=new THREE.Group(); this.group=g; scene.add(g);
/* ---------- sky dome ---------- */
const sky=new THREE.Mesh(
new THREE.SphereGeometry(900,24,14),
new THREE.ShaderMaterial({
side:THREE.BackSide, depthWrite:false, fog:false,
uniforms:{ cTop:{value:new THREE.Color(0x2a6ab4)}, cMid:{value:new THREE.Color(0xbdd8ea)}, cBot:{value:new THREE.Color(0x8b9a80)} },
vertexShader:"varying vec3 vP; void main(){ vP=position; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);} ",
fragmentShader:"uniform vec3 cTop; uniform vec3 cMid; uniform vec3 cBot; varying vec3 vP;"+
"void main(){ float h=normalize(vP).y; vec3 c = h>0.0 ? mix(cMid,cTop,pow(h,0.60)) : mix(cMid,cBot,pow(-h,0.45)); gl_FragColor=vec4(c,1.0);} "
})
);
sky.frustumCulled=false;
this.sky=sky; scene.add(sky);
/* ---------- lights ---------- */
scene.add(new THREE.HemisphereLight(0xcfe4ff,0x4a5b34,0.62));
scene.add(new THREE.AmbientLight(0xffffff,0.20));
const sun=new THREE.DirectionalLight(0xfff2d6,1.05);
sun.position.set(70,110,40);
sun.castShadow=true;
sun.shadow.mapSize.set(2048,2048);
const sc=sun.shadow.camera;
sc.near=10; sc.far=340; sc.left=-75; sc.right=75; sc.top=75; sc.bottom=-75;
sun.shadow.bias=-0.0009; sun.shadow.normalBias=0.6;
scene.add(sun); scene.add(sun.target);
this.sun=sun;
/* ---------- terrain mesh ---------- */
const SIZE=760, SEG=190;
const geo=new THREE.PlaneGeometry(SIZE,SIZE,SEG,SEG);
geo.rotateX(-Math.PI/2);
const pos=geo.attributes.position, col=new Float32Array(pos.count*3);
for(let i=0;i<pos.count;i++){
const x=pos.getX(i), z=pos.getZ(i);
const y=this.terrainY(x,z);
pos.setY(i,y);
// grass tone, drier on the ridges, darker in the hollows
const v=0.5+0.5*Math.sin(x*0.043)*Math.cos(z*0.037);
const h=U.clamp((y+12)/26,0,1);
const r=0.20+0.13*v+0.10*h, gg=0.34+0.16*v+0.06*h, b=0.13+0.09*v;
col[i*3]=r; col[i*3+1]=gg; col[i*3+2]=b;
}
geo.setAttribute("color",new THREE.BufferAttribute(col,3));
geo.computeVertexNormals();
const ground=new THREE.Mesh(geo,new THREE.MeshLambertMaterial({vertexColors:true}));
ground.receiveShadow=true;
g.add(ground);
this.buildScenery(g);
this.buildTrackFurniture(g);
this.buildObstacleGrid();
},
/* ---------- trees, rocks, crowds ---------- */
buildScenery(g){
const placed=[]; // {x,z,s}
const tryPlace=(minD,maxD,clear)=>{
for(let k=0;k<50;k++){
const a=RNG()*Math.PI*2, r=minD+Math.sqrt(RNG())*(maxD-minD);
const x=Math.cos(a)*r, z=Math.sin(a)*r;
const hit=Track.nearest(x,z,1);
if(hit && hit.d<clear) continue;
let bad=false;
for(const p of placed){ if((p.x-x)**2+(p.z-z)**2 < 30) { bad=true; break; } }
if(bad) continue;
placed.push({x,z});
return {x,z,y:this.terrainY(x,z)};
}
return null;
};
/* -- pine trees (trunk + cone), instanced -- */
const TREES=520;
const trunkGeo=new THREE.CylinderGeometry(0.20,0.34,3.0,5); trunkGeo.translate(0,1.5,0);
const foliGeo=new THREE.ConeGeometry(2.0,5.4,7); foliGeo.translate(0,5.2,0);
const trunkMat=new THREE.MeshPhongMaterial({color:0x4a3423,flatShading:true,shininess:0});
const foliMat=new THREE.MeshPhongMaterial({color:0xffffff,flatShading:true,shininess:0});
const trunks=new THREE.InstancedMesh(trunkGeo,trunkMat,TREES);
const foliage=new THREE.InstancedMesh(foliGeo,foliMat,TREES);
trunks.castShadow=foliage.castShadow=true; foliage.receiveShadow=true;
const mtx=new THREE.Matrix4(), q=new THREE.Quaternion(), vv=new THREE.Vector3(), sv=new THREE.Vector3();
const cgreens=[0x2f5d2a,0x3b6f31,0x274d24,0x486b2c,0x355c3a];
let n=0;
for(let i=0;i<TREES;i++){
const p=tryPlace(30,340,15.5);
if(!p) continue;
const s=0.72+RNG()*1.05;
vv.set(p.x,p.y-0.2,p.z);
q.setFromAxisAngle(new THREE.Vector3(0,1,0),RNG()*6.28);
sv.set(s,s*(0.85+RNG()*0.5),s);
mtx.compose(vv,q,sv);
trunks.setMatrixAt(n,mtx); foliage.setMatrixAt(n,mtx);
const c=new THREE.Color(cgreens[Math.floor(RNG()*cgreens.length)]);
c.offsetHSL((RNG()-0.5)*0.03,0,(RNG()-0.5)*0.08);
foliage.setColorAt(n,c);
this.obstacles.push({x:p.x,z:p.z,r:0.55*s+0.5,type:"tree"});
n++;
}
trunks.count=foliage.count=n;
trunks.instanceMatrix.needsUpdate=foliage.instanceMatrix.needsUpdate=true;
if(foliage.instanceColor) foliage.instanceColor.needsUpdate=true;
g.add(trunks); g.add(foliage); this.trees=foliage;
/* -- rocks -- */
const ROCKS=110;
const rockGeo=new THREE.DodecahedronGeometry(1.15,0);
const rockMat=new THREE.MeshPhongMaterial({color:0xffffff,flatShading:true,shininess:0});
const rocks=new THREE.InstancedMesh(rockGeo,rockMat,ROCKS);
rocks.castShadow=rocks.receiveShadow=true;
let rn=0;
for(let i=0;i<ROCKS;i++){
const p=tryPlace(26,330,13);
if(!p) continue;
const s=0.45+RNG()*1.35;
vv.set(p.x,p.y+s*0.35,p.z);
q.setFromEuler(new THREE.Euler(RNG()*3,RNG()*6.28,RNG()*3));
sv.set(s,s*(0.6+RNG()*0.8),s*(0.8+RNG()*0.5));
mtx.compose(vv,q,sv);
rocks.setMatrixAt(rn,mtx);
const c=new THREE.Color(0x7d7f82); c.offsetHSL(0,0,(RNG()-0.5)*0.16);
rocks.setColorAt(rn,c);
this.obstacles.push({x:p.x,z:p.z,r:0.9*s+0.35,type:"rock"});
rn++;
}
rocks.count=rn; rocks.instanceMatrix.needsUpdate=true;
if(rocks.instanceColor) rocks.instanceColor.needsUpdate=true;
g.add(rocks);
/* -- crowd (cylinders + heads) around the start line + two spots -- */
const spots=[0,0.19,0.72].map(f=>Track.samples[Math.floor(f*CFG.SAMPLES)]);
const people=[];
for(const s of spots){
for(let i=0;i<44;i++){
const side=i%2?1:-1;
const off=(CFG.HALF_WIDTH+CFG.SHOULDER+2.4+RNG()*4.5)*side;
const along=(RNG()-0.5)*36;
const x=s.p.x+s.s.x*off+s.t.x*along, z=s.p.z+s.s.z*off+s.t.z*along;
people.push({x,z,s:0.9+RNG()*0.25});
}
}
const bodyGeo=new THREE.CylinderGeometry(0.30,0.36,1.15,6); bodyGeo.translate(0,0.58,0);
const headGeo=new THREE.SphereGeometry(0.24,7,6); headGeo.translate(0,1.34,0);
const bodyMat=new THREE.MeshPhongMaterial({color:0xffffff,flatShading:true,shininess:0});
const headMat=new THREE.MeshPhongMaterial({color:0xd9b48c,flatShading:true,shininess:0});
const bodies=new THREE.InstancedMesh(bodyGeo,bodyMat,people.length);
const heads=new THREE.InstancedMesh(headGeo,headMat,people.length);
bodies.castShadow=true;
const shades=[0xe0483a,0x2f6ec4,0xe6c34a,0xdedede,0x2c2f38,0x3fa06a,0xc2609b];
people.forEach((p,i)=>{
const y=this.terrainY(p.x,p.z);
vv.set(p.x,y,p.z); q.setFromAxisAngle(new THREE.Vector3(0,1,0),RNG()*6.28);
sv.set(p.s,p.s,p.s); mtx.compose(vv,q,sv);
bodies.setMatrixAt(i,mtx); heads.setMatrixAt(i,mtx);
bodies.setColorAt(i,new THREE.Color(U.pick(shades)));
});
bodies.instanceMatrix.needsUpdate=heads.instanceMatrix.needsUpdate=true;
if(bodies.instanceColor) bodies.instanceColor.needsUpdate=true;
g.add(bodies); g.add(heads);
},
/* ---------- barriers, signs, gate banners, start gantry, flags ---------- */
buildTrackFurniture(g){
const S=Track.samples, N=S.length;
const HW=CFG.HALF_WIDTH, SH=CFG.SHOULDER;
/* -- guard rails on the outside of the fastest corners -- */
const posts=[];
for(let i=0;i<N;i++){
const t1=S[i].t, t2=S[(i+2)%N].t;
const curv=Math.abs(t1.x*t2.z-t1.z*t2.x);
const cross=Math.sign(t1.x*t2.z-t1.z*t2.x)||1;
if(curv>0.010 && S[i].surf!=="water"){
const off=-(cross)*(HW+SH+1.0);
posts.push({i,off});
}
}
const bGeo=new THREE.BoxGeometry(0.28,0.6,2.9);
const bMat=new THREE.MeshPhongMaterial({color:0xffffff,flatShading:true,shininess:0});
const rail=new THREE.InstancedMesh(bGeo,bMat,posts.length+8);
rail.castShadow=rail.receiveShadow=true;
const mtx=new THREE.Matrix4(), q=new THREE.Quaternion(), vv=new THREE.Vector3(), sv=new THREE.Vector3(1,1,1);
posts.forEach((p,k)=>{
const s=S[p.i];
const x=s.p.x+s.s.x*p.off, z=s.p.z+s.s.z*p.off;
vv.set(x,s.p.y+0.72,z);
q.setFromAxisAngle(new THREE.Vector3(0,1,0),Math.atan2(s.t.x,s.t.z));
mtx.compose(vv,q,sv);
rail.setMatrixAt(k,mtx);
rail.setColorAt(k,new THREE.Color(k%2?0xe9e9ec:0xc0392b));
this.obstacles.push({x,z,r:1.5,type:"barrier"});
});
rail.count=posts.length;
rail.instanceMatrix.needsUpdate=true;
if(rail.instanceColor) rail.instanceColor.needsUpdate=true;
g.add(rail);
/* -- warning signs on the approach to tight corners -- */
const signs=[];
for(let i=6;i<N;i+=1){
const t1=S[i].t, t2=S[(i+16)%N].t;
const curv=Math.abs(t1.x*t2.z-t1.z*t2.x);
const cross=Math.sign(t1.x*t2.z-t1.z*t2.x)||1;
if(curv>0.055 && (!signs.length || i-signs[signs.length-1].i>45)) signs.push({i,cross});
}
const signTex=[this.texture(c=>this.drawSign(c,"left")),this.texture(c=>this.drawSign(c,"right")),
this.texture(c=>this.drawSign(c,"warn")),this.texture(c=>this.drawSign(c,"info"))];
const postMat=new THREE.MeshPhongMaterial({color:0xbfc4c9,flatShading:true,shininess:0});
signs.forEach((sg,k)=>{
const s=S[sg.i];
const off=-(sg.cross)*(HW+SH+2.0);
const x=s.p.x+s.s.x*off, z=s.p.z+s.s.z*off, y=s.p.y;
const post=new THREE.Mesh(new THREE.CylinderGeometry(0.09,0.09,2.6,5),postMat);
post.position.set(x,y+1.3,z); post.castShadow=true; g.add(post);
const tex=sg.cross>0?signTex[0]:signTex[1];
const board=new THREE.Mesh(new THREE.PlaneGeometry(1.7,1.25),
new THREE.MeshLambertMaterial({map:tex,transparent:true,side:THREE.DoubleSide}));
board.position.set(x,y+2.75,z);
board.rotation.y=Math.atan2(s.t.x,s.t.z)+Math.PI;
board.castShadow=true; g.add(board);
this.obstacles.push({x,z,r:0.7,type:"sign"});
});
/* -- checkpoint banners + start gantry -- */
const gateNames=["FINISH","CHECKPOINT 1","CHECKPOINT 2","CHECKPOINT 3"];
CFG.GATES.forEach((f,k)=>{
const idx=Math.round(f*N)%N, s=S[idx];
const w=(HW+SH+1.6)*2;
const yaw=Math.atan2(s.t.x,s.t.z);
const gx=new THREE.Group();
gx.position.copy(s.p); gx.rotation.y=yaw;
const text = k===0? gateNames[0] : gateNames[k];
const tex=this.texture(c=>this.drawBanner(c,512,64,text,k===0),512,64);
const banner=new THREE.Mesh(new THREE.PlaneGeometry(w,2.1),
new THREE.MeshBasicMaterial({map:tex,side:THREE.DoubleSide}));
banner.position.set(0,5.6,0); banner.rotation.y=Math.PI; banner.castShadow=true;
gx.add(banner);
const legMat=new THREE.MeshPhongMaterial({color:k===0?0x2b3340:0x9aa2ac,flatShading:true,shininess:0});
[-w/2,w/2].forEach(ox=>{
const leg=new THREE.Mesh(new THREE.CylinderGeometry(0.20,0.26,6.6,6),legMat);
leg.position.set(ox,3.3,0); leg.castShadow=true; gx.add(leg);
this.obstacles.push({x:s.p.x+s.s.x*Math.sign(ox)*(HW+SH+1.7),z:s.p.z+s.s.z*Math.sign(ox)*(HW+SH+1.7),r:0.6,type:"gate"});
});
g.add(gx);
});
/* start line checkered strip */
{
const s=S[0], w=(HW+SH)*2;
const c=document.createElement("canvas"); c.width=32;c.height=8;
const cx=c.getContext("2d");
for(let i=0;i<8;i++)for(let j=0;j<2;j++){ cx.fillStyle=((i+j)%2)?"#f2f2f2":"#1b1b1b"; cx.fillRect(i*4,j*4,4,4); }
const tex=new THREE.CanvasTexture(c);
tex.wrapS=tex.wrapT=THREE.RepeatWrapping; tex.repeat.set(6,1);
const strip=new THREE.Mesh(new THREE.PlaneGeometry(w,1.6),
new THREE.MeshLambertMaterial({map:tex}));
strip.rotation.x=-Math.PI/2; strip.rotation.z=-Math.atan2(s.t.x,s.t.z);
strip.position.set(s.p.x,s.p.y+0.33,s.p.z);
g.add(strip);
}
/* -- roadside flags along the stage -- */
{
const flagN=26, flagGeo=new THREE.PlaneGeometry(1.1,0.7);
for(let i=0;i<flagN;i++){
const idx=Math.floor(i/flagN*N);
const s=S[idx];
const side=(i%2)?1:-1;
const off=side*(HW+SH+3.4);
const x=s.p.x+s.s.x*off, z=s.p.z+s.s.z*off, y=this.terrainY(x,z);
const pole=new THREE.Mesh(new THREE.CylinderGeometry(0.05,0.05,2.4,4),
new THREE.MeshPhongMaterial({color:0xdddddd}));
pole.position.set(x,y+1.2,z); g.add(pole);
const col=[0xffcf3f,0xff4d3d,0x4dc3ff,0x7dffa1][i%4];
const flag=new THREE.Mesh(flagGeo,new THREE.MeshPhongMaterial({color:col,side:THREE.DoubleSide}));
flag.position.set(x+0.55,y+2.1,z); flag.rotation.y=Math.atan2(s.t.x,s.t.z)+1.5708;
flag.userData.wave=(i%7)*0.9;
g.add(flag);
}
}
},
/* canvas texture helper */
texture(draw,w,h){
const c=document.createElement("canvas"); c.width=w||256; c.height=h||256;
const ctx=c.getContext("2d"); draw(ctx,c.width,c.height);
const t=new THREE.CanvasTexture(c);
t.anisotropy=4;
return t;
},
drawSign(ctx,w,h,kind){
ctx.clearRect(0,0,w,h);
if(kind==="left"||kind==="right"||kind==="warn"){
const tri=()=>{ctx.beginPath();ctx.moveTo(w*0.5,16);ctx.lineTo(w-18,h-24);ctx.lineTo(18,h-24);ctx.closePath();};
tri(); ctx.fillStyle="#ffd23f"; ctx.fill();
ctx.lineWidth=11; ctx.strokeStyle="#20242b"; ctx.stroke();
ctx.fillStyle="#20242b";
if(kind==="warn"){
ctx.font="900 108px Arial"; ctx.textAlign="center"; ctx.textBaseline="middle";
ctx.fillText("!",w*0.5,h*0.63);
} else {
ctx.save(); ctx.translate(w*0.5,h*0.62); ctx.scale(kind==="left"?-1:1,1);
ctx.beginPath(); ctx.moveTo(0,-52); ctx.lineTo(30,10); ctx.lineTo(10,10); ctx.lineTo(10,48);
ctx.lineTo(-10,48); ctx.lineTo(-10,10); ctx.lineTo(-30,10); ctx.closePath(); ctx.fill();
ctx.restore();
}
} else {
ctx.fillStyle="#1d3a63"; ctx.fillRect(14,14,w-28,h-28);
ctx.strokeStyle="#ffffff"; ctx.lineWidth=8; ctx.strokeRect(14,14,w-28,h-28);
ctx.fillStyle="#fff"; ctx.font="900 44px Arial"; ctx.textAlign="center";
ctx.fillText("STAGE",w*0.5,h*0.42); ctx.fillText("AHEAD",w*0.5,h*0.62);
}
},
drawBanner(ctx,w,h,text,isFinish){
ctx.clearRect(0,0,w,h);
ctx.fillStyle=isFinish?"#15191f":"#173a68"; ctx.fillRect(0,0,w,h);
ctx.fillStyle=isFinish?"#ffcf3f":"#4dc3ff";
ctx.fillRect(0,0,w,6); ctx.fillRect(0,h-6,w,6);
/* checkered band on the finish banner */
if(isFinish){
for(let i=0;i<w/8;i++)for(let j=0;j<2;j++){
ctx.fillStyle=((i+j)%2)?"#f2f2f2":"#141414";
ctx.fillRect(i*8,6+j*8,8,8); ctx.fillRect(i*8,h-22+j*8,8,8);
}
}
ctx.fillStyle="#ffffff"; ctx.textAlign="center"; ctx.textBaseline="middle";
ctx.font="900 30px Arial, sans-serif";
ctx.fillText(text,w*0.5,h*0.52);
},
buildObstacleGrid(){
this.obsGrid=new Map();
for(const o of this.obstacles){
const cx=Math.floor(o.x/this.CELL), cz=Math.floor(o.z/this.CELL);
const k=cx*10000+cz;
let b=this.obsGrid.get(k); if(!b){b=[];this.obsGrid.set(k,b);}
b.push(o);
}
},
/* obstacles near (x,z) inside `rad` units */
nearby(x,z,rad){
const out=[], c=this.CELL;
const cx=Math.floor(x/c), cz=Math.floor(z/c);
for(let i=cx-1;i<=cx+1;i++)for(let j=cz-1;j<=cz+1;j++){
const b=this.obsGrid.get(i*10000+j); if(!b) continue;
for(const o of b) if(Math.abs(o.x-x)<rad && Math.abs(o.z-z)<rad) out.push(o);
}
return out;
}
};
window.Rally.World = World;
/* ===================================================================== FX
GPU particle pools (Points + ShaderMaterial) for dust/smoke/sparks and a
pooled quad mesh for skid marks. Textures are generated on a canvas.
======================================================================== */
const FX = {
scene:null, pixelRatio:1,
softTex:null, systems:[],
init(scene, pixelRatio){
this.scene=scene; this.pixelRatio=pixelRatio;
/* soft radial sprite */
const c=document.createElement("canvas"); c.width=c.height=64;
const ctx=c.getContext("2d");
const grd=ctx.createRadialGradient(32,32,0,32,32,32);
grd.addColorStop(0,"rgba(255,255,255,1)");
grd.addColorStop(0.45,"rgba(255,255,255,0.55)");
grd.addColorStop(1,"rgba(255,255,255,0)");
ctx.fillStyle=grd; ctx.fillRect(0,0,64,64);
this.softTex=new THREE.CanvasTexture(c);
this.dust=this.makePoints(scene,820,this.softTex,THREE.NormalBlending);
this.sparks=this.makePoints(scene,420,this.softTex,THREE.AdditiveBlending);
this.buildSkids(scene);
},
makePoints(scene,max,map,blending){
const pos=new Float32Array(max*3), col=new Float32Array(max*3),
sz=new Float32Array(max), al=new Float32Array(max);
for(let i=0;i<max;i++){ pos[i*3+1]=-999; }
const geo=new THREE.BufferGeometry();
geo.setAttribute("position",new THREE.BufferAttribute(pos,3));
geo.setAttribute("aColor",new THREE.BufferAttribute(col,3));
geo.setAttribute("aSize",new THREE.BufferAttribute(sz,1));
geo.setAttribute("aAlpha",new THREE.BufferAttribute(al,1));
const mat=new THREE.ShaderMaterial({
uniforms:{ map:{value:map}, uPR:{value:this.pixelRatio} },
transparent:true, depthWrite:false, blending:blending,
vertexShader:
"attribute float aSize; attribute float aAlpha; attribute vec3 aColor;"+
"uniform float uPR; varying float vA; varying vec3 vC;"+
"void main(){ vA=aAlpha; vC=aColor;"+
" vec4 mv=modelViewMatrix*vec4(position,1.0);"+
" gl_PointSize=min(aSize*uPR*230.0/max(1.0,-mv.z), 130.0*uPR);"+
" gl_Position=projectionMatrix*mv; }",
fragmentShader:
"uniform sampler2D map; varying float vA; varying vec3 vC;"+
"void main(){ float a=texture2D(map,gl_PointCoord).a*vA;"+
" if(a<0.01) discard; gl_FragColor=vec4(vC,a); }"
});
const pts=new THREE.Points(geo,mat);
pts.frustumCulled=false;
scene.add(pts);
const sys={pts,geo,max,next:0,
life:new Float32Array(max), maxLife:new Float32Array(max),
vel:new Float32Array(max*3), grow:new Float32Array(max), base:new Float32Array(max)};
this.systems.push(sys);
return sys;
},
spawn(sys,x,y,z,vx,vy,vz,size,life,color,grow,alpha){
const i=sys.next; sys.next=(sys.next+1)%sys.max;
const p=sys.geo.attributes.position.array;
p[i*3]=x; p[i*3+1]=y; p[i*3+2]=z;
sys.vel[i*3]=vx; sys.vel[i*3+1]=vy; sys.vel[i*3+2]=vz;
sys.life[i]=life; sys.maxLife[i]=life; sys.base[i]=size; sys.grow[i]=grow;
const c=sys.geo.attributes.aColor.array;
c[i*3]=color[0]; c[i*3+1]=color[1]; c[i*3+2]=color[2];
sys.geo.attributes.aSize.array[i]=size;
sys.geo.attributes.aAlpha.array[i]=alpha==null?1:alpha;
sys.geo.attributes.aColor.needsUpdate=true;
},
updateParticles(sys,dt,gravity,drag){
const p=sys.geo.attributes.position.array,
a=sys.geo.attributes.aAlpha.array,
s=sys.geo.attributes.aSize.array;
for(let i=0;i<sys.max;i++){
if(sys.life[i]<=0){ if(a[i]!==0) a[i]=0; continue; }
sys.life[i]-=dt;
if(sys.life[i]<=0){ a[i]=0; p[i*3+1]=-999; continue; }
const k=sys.life[i]/sys.maxLife[i]; // 1 -> 0
sys.vel[i*3+1]-=gravity*dt;
const d=1-Math.min(1,drag*dt);
sys.vel[i*3]*=d; sys.vel[i*3+1]*=d; sys.vel[i*3+2]*=d;
p[i*3]+=sys.vel[i*3]*dt; p[i*3+1]+=sys.vel[i*3+1]*dt; p[i*3+2]+=sys.vel[i*3+2]*dt;
s[i]=sys.base[i]*(1+sys.grow[i]*(1-k));
a[i]=k<0.25? k*4 : Math.min(1,(1-k)*4)*0.85;
}
sys.geo.attributes.position.needsUpdate=true;
sys.geo.attributes.aAlpha.needsUpdate=true;
sys.geo.attributes.aSize.needsUpdate=true;
},
/* ---------------- skid marks ---------------- */
buildSkids(scene){
const max=340;
const pos=new Float32Array(max*4*3), al=new Float32Array(max*4);
const idx=new Uint16Array(max*6);
for(let i=0;i<max;i++){
const v=i*4, o=i*6;
idx[o]=v; idx[o+1]=v+1; idx[o+2]=v+2; idx[o+3]=v+1; idx[o+4]=v+3; idx[o+5]=v+2;
}
const geo=new THREE.BufferGeometry();
geo.setAttribute("position",new THREE.BufferAttribute(pos,3));
geo.setAttribute("aAlpha",new THREE.BufferAttribute(al,1));
geo.setIndex(new THREE.BufferAttribute(idx,1));
const mat=new THREE.ShaderMaterial({
transparent:true, depthWrite:false,
polygonOffset:true, polygonOffsetFactor:-4, polygonOffsetUnits:-4,
vertexShader:"attribute float aAlpha; varying float vA; uniform float uY;"+
"void main(){ vA=aAlpha; vec4 mv=modelViewMatrix*vec4(position,1.0); gl_Position=projectionMatrix*mv; }",
fragmentShader:"varying float vA; void main(){ if(vA<0.01) discard; gl_FragColor=vec4(0.055,0.05,0.045,vA); }"
});
const mesh=new THREE.Mesh(geo,mat);
mesh.frustumCulled=false; mesh.renderOrder=1;
scene.add(mesh);
this.skid={mesh,geo,max,next:0,life:new Float32Array(max),cut:new Float32Array(max),
fresh:new Uint8Array(max)};
},
addSkid(x,y,z,dx,dz,w){
const s=this.skid, i=s.next; s.next=(s.next+1)%s.max;
const len=1.5, hx=dx*len*0.5, hz=dz*len*0.5, px=-dz*w*0.5, pz=dx*w*0.5;
const p=s.geo.attributes.position.array, o=i*12;
p[o+0]=x-hx-px; p[o+1]=y; p[o+2]=z-hz-pz;
p[o+3]=x-hx+px; p[o+4]=y; p[o+5]=z-hz+pz;
p[o+6]=x+hx-px; p[o+7]=y; p[o+8]=z+hz-pz;
p[o+9]=x+hx+px; p[o+10]=y; p[o+11]=z+hz+pz;
s.life[i]=9.0; s.cut[i]=4.0; s.fresh[i]=1;
this.skid.geo.attributes.position.needsUpdate=true;
},
updateSkids(dt){
const s=this.skid, a=s.geo.attributes.aAlpha.array;
let dirty=false;
for(let i=0;i<s.max;i++){
if(s.life[i]<=0){ if(a[i*4]!==0){ for(let q=0;q<4;q++) a[i*4+q]=0; dirty=true; } continue; }
s.life[i]-=dt;
const k=Math.max(0,s.life[i]/9.0);
const v=k*k*0.62;
for(let q=0;q<4;q++) a[i*4+q]=v;
dirty=true;
}
if(dirty) s.geo.attributes.aAlpha.needsUpdate=true;
},
update(dt){
this.updateParticles(this.dust,dt,2.4,0.9);
this.updateParticles(this.sparks,dt,16,0.4);
this.updateSkids(dt);
},
/* convenience emitters */
dustPuff(x,y,z,n,col,spd,size,life){
for(let i=0;i<n;i++){
const a=Math.random()*6.283, r=Math.random()*spd;
this.spawn(this.dust,x,y,z,Math.cos(a)*r,Math.random()*spd*0.7+0.3,Math.sin(a)*r,
size*(0.7+Math.random()*0.7),life*(0.7+Math.random()*0.6),col,1.6,0.75);
}
},
sparkBurst(x,y,z,n,power){
for(let i=0;i<n;i++){
const a=Math.random()*6.283, r=Math.random();
this.spawn(this.sparks,x,y,z,Math.cos(a)*r*7*power,Math.random()*7*power,(Math.sin(a))*r*7*power,
0.22+Math.random()*0.2,0.35+Math.random()*0.4,[1,0.72+Math.random()*0.25,0.3],-0.5,1);
}
},
splash(x,y,z,n){
for(let i=0;i<n;i++){
const a=Math.random()*6.283, r=Math.random();
this.spawn(this.dust,x,y+0.1,z,Math.cos(a)*r*3.4,2.6+Math.random()*3,Math.sin(a)*r*3.4,
0.34+Math.random()*0.34,0.7,[0.85,0.93,1.0],1.4,0.8);
}
}
};
window.Rally.FX = FX;
/* ==================================================================== CAR
Low-poly rally car + arcade-sim hybrid physics with slip, surface grip,
handbrake drifting and obstacle bounces.
======================================================================== */
const Car = {
root:null, chassis:null, wheels:[], headlights:[],
x:0,y:0,z:0, yaw:0, vx:0,vz:0, steerAngle:0, wheelSpin:0,
surf:"asphalt", onTrack:true, drift:0, slip:0, rpm:0.15, gear:1,
impact:0, airTime:0, lastSkid:0, surfTime:0, hitFlash:0,
WHEELBASE:2.7, ENGINE:23.5, BRAKE:30,
build(scene){
const g=new THREE.Group();
const paint=new THREE.MeshPhongMaterial({color:0xe8352a,flatShading:true,shininess:26});
const paint2=new THREE.MeshPhongMaterial({color:0xf5f7fa,flatShading:true,shininess:18});
const glass=new THREE.MeshPhongMaterial({color:0x141a22,flatShading:true,shininess:70});
const dark=new THREE.MeshPhongMaterial({color:0x1a1c20,flatShading:true,shininess:12});
const rubber=new THREE.MeshPhongMaterial({color:0x141618,flatShading:true,shininess:4});
const chrome=new THREE.MeshPhongMaterial({color:0xa8afb8,flatShading:true,shininess:60});
const add=(geo,mat,x,y,z,parent)=>{
const m=new THREE.Mesh(geo,mat); m.position.set(x,y,z);
m.castShadow=true; (parent||g).add(m); return m;
};
const chassis=new THREE.Group(); g.add(chassis); this.chassis=chassis;
/* body shell */
add(new THREE.BoxGeometry(1.82,0.44,4.05),paint,0,0.62,0,chassis);
add(new THREE.BoxGeometry(1.68,0.30,1.35),paint,0,0.90,1.28,chassis); // hood
add(new THREE.BoxGeometry(1.66,0.50,1.95),glass,0,1.04,-0.42,chassis); // cabin
add(new THREE.BoxGeometry(0.42,0.11,1.30),paint2,0,1.30,-0.55,chassis); // roof stripe
add(new THREE.BoxGeometry(1.90,0.26,0.34),dark,0,0.52,2.02,chassis); // front bumper
add(new THREE.BoxGeometry(1.90,0.24,0.30),dark,0,0.54,-2.02,chassis); // rear bumper
add(new THREE.BoxGeometry(0.30,0.34,3.9),dark,0.98,0.60,0,chassis); // sills
add(new THREE.BoxGeometry(0.30,0.34,3.9),dark,-0.98,0.60,0,chassis);
/* spoiler */
add(new THREE.BoxGeometry(1.84,0.08,0.46),dark,0,1.30,-2.00,chassis);
add(new THREE.BoxGeometry(0.10,0.34,0.10),dark,0.72,1.12,-1.96,chassis);
add(new THREE.BoxGeometry(0.10,0.34,0.10),dark,-0.72,1.12,-1.96,chassis);
/* roof vents + mud flaps */
add(new THREE.BoxGeometry(0.80,0.10,0.36),dark,0,1.38,0.55,chassis);
/* lights */
const head=new THREE.MeshBasicMaterial({color:0xfff6d0});
const tail=new THREE.MeshBasicMaterial({color:0xff2b1a});
[[0.62],[ -0.62]].forEach(([ox])=>{
const h=add(new THREE.BoxGeometry(0.44,0.16,0.08),head,ox,0.80,2.06,chassis);
this.headlights.push(h);
});
[[0.60],[-0.60]].forEach(([ox])=>{
add(new THREE.BoxGeometry(0.40,0.14,0.07),tail,ox,0.84,-2.06,chassis);
});
/* number plates */
add(new THREE.BoxGeometry(0.70,0.26,0.05),paint2,0,0.66,2.20,chassis);
/* wheels: pivot (steer) -> spin mesh */
const wheelGeo=new THREE.CylinderGeometry(0.44,0.44,0.34,12);
wheelGeo.rotateZ(Math.PI/2);
const rimGeo=new THREE.CylinderGeometry(0.26,0.26,0.36,8);
rimGeo.rotateZ(Math.PI/2);
const positions=[[1.00,1.35],[-1.00,1.35],[1.00,-1.42],[-1.00,-1.42]];
positions.forEach(([ox,oz],i)=>{
const pivot=new THREE.Group(); pivot.position.set(ox,0.46,oz); g.add(pivot);
const tyre=new THREE.Mesh(wheelGeo,rubber); tyre.castShadow=true; pivot.add(tyre);
const rim=new THREE.Mesh(rimGeo,chrome); pivot.add(rim);
this.wheels.push({pivot,tyre,rim,front:i<2,side:Math.sign(ox)});
});
scene.add(g);
this.root=g;
},
reset(){
this.x=0;this.z=0;this.vx=0;this.vz=0;this.yaw=0;this.steerAngle=0;this.wheelSpin=0;
this.drift=0;this.slip=0;this.rpm=0.15;this.gear=1;this.impact=0;this.hitFlash=0;
const s=Track.samples[6]; // a little behind the start line
this.x=s.p.x; this.z=s.p.z; this.y=s.p.y+0.18;
this.yaw=Math.atan2(s.t.x,s.t.z);
this.trackIdx=6;
this.updateTransform(0);
},
basis(yaw){ return {fx:Math.sin(yaw), fz:Math.cos(yaw), rx:Math.cos(yaw), rz:-Math.sin(yaw)}; },
update(dt, inp, paused){
const b0=this.basis(this.yaw);
let vf=this.vx*b0.fx+this.vz*b0.fz;
let vl=this.vx*b0.rx+this.vz*b0.rz;
const speed=Math.hypot(this.vx,this.vz);
const surf=SURFACES[this.surf];
/* ---- steering: tight at low speed, calmer at high speed ---- */
const steerMax=0.66*U.clamp(1-speed/70,0.30,1);
const target=inp.steer*steerMax;
this.steerAngle=U.damp(this.steerAngle,target,inp.steer?10:14,dt);
/* ---- yaw rate, limited by the lateral grip available ---- */
let yawRate=vf*this.steerAngle*0.11;
const gripF=U.clamp(surf.grip/9,0.35,1.10);
const maxYaw=(16*gripF/Math.max(Math.abs(vf),3.5))*(inp.handbrake?1.6:1);
yawRate=U.clamp(yawRate,-maxYaw,maxYaw);
yawRate+=U.clamp(vl*-0.02,-0.35,0.35); // counter-steer feel
this.yaw+=yawRate*dt;
/* ---- slip: re-project world velocity into the new heading ---- */
const b1=this.basis(this.yaw);
vf=this.vx*b1.fx+this.vz*b1.fz;
vl=this.vx*b1.rx+this.vz*b1.rz;
/* ---- longitudinal forces ---- */
const top=surf.top*(this.onTrack?1:0.94);
if(!paused){
if(inp.throttle>0 && !inp.handbrake){
const f=this.ENGINE*surf.accel*(1-Math.pow(U.clamp(vf/top,0,1),1.35));
vf+=f*dt;
}
if(inp.brake>0){
if(vf>0.6) vf-=this.BRAKE*surf.accel*dt;
else vf-=7.5*surf.accel*dt; // reverse
}
if(inp.handbrake) vf-=18*dt;
}
const roll=surf.roll*0.42 + 0.012*Math.abs(vf);
vf-=vf*roll*dt;
if(vf>top) vf=U.damp(vf,top,3,dt);
/* ---- lateral grip / drift ---- */
let grip=surf.grip*(inp.handbrake?0.30:1);
if(this.onTrack && this.surf==="asphalt") grip*=1.0;
vl*=Math.exp(-grip*dt);
vl-=vl*Math.sign(vl)*0.3*dt;
/* ---- rebuild world velocity ---- */
this.vx=b1.fx*vf+b1.rx*vl;
this.vz=b1.fz*vf+b1.rz*vl;
/* ---- integrate ---- */
this.x+=this.vx*dt; this.z+=this.vz*dt;
/* ---- ground query ---- */
const info=Track.sampleAt(this.x,this.z,this.trackIdx);
this.trackIdx=info.idx;
const prevSurf=this.surf;
this.surf=info.surf;
this.onTrack=info.surf!=="grass";
/* surfY = actual visible ground beneath the wheels (road ribbon height when
on the ribbon, blended terrain otherwise) — used for FX placement too */
const onRibbon=Math.abs(info.lat)<CFG.HALF_WIDTH+CFG.SHOULDER;
this.surfY = onRibbon ? info.y+0.315 : World.terrainY(this.x,this.z);
this.y=U.damp(this.y,this.surfY+0.055,16,dt);
/* ---- collisions ---- */
const speedBefore=Math.hypot(this.vx,this.vz);
const obs=World.nearby(this.x,this.z,4.2);
for(let i=0;i<obs.length;i++){
const o=obs[i];
const dx=this.x-o.x, dz=this.z-o.z;
const d=Math.hypot(dx,dz), rr=o.r+CFG.CAR_HALF;
if(d<rr && d>1e-4){
const nx=dx/d, nz=dz/d;
this.x=o.x+nx*rr; this.z=o.z+nz*rr;
const vn=this.vx*nx+this.vz*nz;
if(vn<0){
this.vx-=1.5*vn*nx; this.vz-=1.5*vn*nz;
const power=Math.min(1,-vn/26);
this.vx*=0.66; this.vz*=0.66;
if(power>0.12){
this.impact=Math.max(this.impact,power);
this.hitFlash=1;
FX.sparkBurst(this.x+nx*1.0,this.y+0.6,this.z+nz*1.0,18+Math.floor(power*26),0.6+power);
Audio.thud(power);
}
}
}
}
/* ---- lateral acceleration & attitude for visuals ---- */
const speedNow=Math.hypot(this.vx,this.vz);
this.drift=U.clamp(Math.abs(vl)/(Math.abs(vf)+2.5),0,1);
this.slip=Math.abs(vl);
/* ---- surface transition puff ---- */
this.surfTime+=dt;
if(prevSurf!==this.surf && this.surfTime>0.3){
this.surfTime=0;
const col=this.dustColor(prevSurf);
FX.dustPuff(this.x,this.y+0.1,this.z,7,col,2.4,0.7,0.55);
}
/* ---- gearbox simulation for HUD/audio ---- */
const spd=Math.max(speedNow,Math.abs(vf));
let gear, ratio;
if(vf<-0.6){ gear=0; ratio=Math.min(1,Math.abs(vf)/12); } // reverse
else if(spd<0.6){ gear=1; ratio=0.12; }
else {
const gears=[0,13,25,38,50,64];
gear=1; for(let i=1;i<=5;i++) if(spd>gears[i-1]) gear=i;
const lo=gears[gear-1], hi=gears[gear];
ratio=0.22+0.78*U.clamp((spd-lo)/(hi-lo),0,1);
}
this.gear=gear;
const targetRpm=gear===0? U.clamp(Math.abs(vf)/12,0,1)*0.6 : ratio;
this.rpm=U.damp(this.rpm,targetRpm,gear===0?5:9,dt);
if(inp.throttle>0 && gear>0) this.rpm=Math.max(this.rpm,U.clamp(this.rpm+0.03,0,1));
this.updateTransform(dt, yawRate, speedNow);
},
dustColor(surf){
if(surf==="water") return [0.85,0.92,1.0];
if(surf==="mud") return [0.32,0.24,0.16];
if(surf==="grass") return [0.30,0.42,0.20];
if(surf==="gravel") return [0.62,0.56,0.44];
if(surf==="dirt") return [0.55,0.44,0.30];
return [0.62,0.62,0.64];
},
updateTransform(dt, yawRate, speed){
const g=this.root;
if(!g) return;
g.position.set(this.x,this.y,this.z);
g.rotation.y=this.yaw;
/* body roll & pitch from current dynamics (visual only) */
const yawRateNow = yawRate||0;
const lat = yawRateNow*(speed||0)*0.5;
const rollT=U.clamp(-lat*0.012,-0.14,0.14);
const pitchT=this.drift*0.05;
this.chassis.rotation.z=U.damp(this.chassis.rotation.z,rollT,8,dt||0.016);
this.chassis.rotation.x=U.damp(this.chassis.rotation.x,pitchT,8,dt||0.016);
/* wheels: spin + steer */
const spin=(this.vx*Math.sin(this.yaw)+this.vz*Math.cos(this.yaw))/0.44;
this.wheelSpin+=spin*(dt||0);
for(const w of this.wheels){
if(w.front) w.pivot.rotation.y=this.steerAngle*0.9;
w.tyre.rotation.x=this.wheelSpin;
w.rim.rotation.x=this.wheelSpin;
}
/* brake lights when braking */
if(this.hitFlash>0) this.hitFlash=Math.max(0,this.hitFlash-(dt||0.016)*3);
},
speedKmh(){ return Math.hypot(this.vx,this.vz)*3.6; },
forward(){ return {x:Math.sin(this.yaw), z:Math.cos(this.yaw)}; },
worldWheel(i){ const w=this.wheels[i]; const b=this.basis(this.yaw);
return {x:this.x+b.fx*w.pivot.position.z+b.rx*w.pivot.position.x,
y:this.y+0.1,
z:this.z+b.fz*w.pivot.position.z+b.rz*w.pivot.position.x}; }
};
window.Rally.Car = Car;
/* =================================================================== HUD */
const HUD = {
els:{}, gctx:null, mctx:null, mmBase:null, mmScale:1, mmOff:{x:0,y:0}, toastT:0,
init(){
const $=id=>document.getElementById(id);
this.els={lap:$("lapValue"),time:$("timeValue"),last:$("lastValue"),best:$("bestValue"),
countdown:$("countdown"),toast:$("toast")};
this.gctx=this.setup($("gauge"),250,152);
this.mctx=this.setup($("minimap"),180,180);
this.buildMinimap();
},
setup(canvas,w,h){
const ctx=canvas.getContext("2d");
ctx.scale(canvas.width/w, canvas.height/h);
return ctx;
},
setTiming(lap,laps,time,last,best){
this.els.lap.textContent=Math.min(lap,laps)+" / "+laps;
this.els.time.textContent=U.fmt(time);
this.els.last.textContent=U.fmtShort(last);
this.els.best.textContent=U.fmtShort(best);
},
toast(msg,cls,dur){
const t=this.els.toast;
t.textContent=msg;
t.className="show"+(cls?" "+cls:"");
this.toastT=dur||1.8;
},
updateToast(dt){
if(this.toastT>0){ this.toastT-=dt; if(this.toastT<=0) this.els.toast.className=""; }
},
showCountdown(text,isGo){
const c=this.els.countdown;
c.textContent=text;
c.className="";
void c.offsetWidth; // restart the CSS animation
c.className="show"+(isGo?" go":"");
},
hideCountdown(){ this.els.countdown.className=""; },
/* ------------- analogue speedo + rpm ring + gear ------------- */
drawGauge(kmh,rpmN,gear,surfName,offTrack){
const ctx=this.gctx, W=250,H=152;
const cx=125, cy=141, R=110;
const a0=Math.PI, a1=Math.PI*2, MAXK=240;
ctx.clearRect(0,0,W,H);
/* dial plate — half disc */
ctx.beginPath();
ctx.moveTo(cx-R-13,cy);
ctx.arc(cx,cy,R+13,Math.PI,0);
ctx.closePath();
ctx.fillStyle="rgba(9,13,19,0.62)"; ctx.fill();
ctx.strokeStyle="rgba(255,255,255,0.16)"; ctx.lineWidth=1.2; ctx.stroke();
/* rpm ring */
ctx.beginPath(); ctx.arc(cx,cy,R,a0,a1);
ctx.lineWidth=9; ctx.strokeStyle="rgba(255,255,255,0.13)"; ctx.lineCap="round"; ctx.stroke();
const grad=ctx.createLinearGradient(cx-R,0,cx+R,0);
grad.addColorStop(0,"#6ee7a0"); grad.addColorStop(.55,"#ffd23f"); grad.addColorStop(1,"#ff4d3d");
ctx.beginPath(); ctx.arc(cx,cy,R,a0,a0+(a1-a0)*U.clamp(rpmN,0,1));
ctx.lineWidth=9; ctx.strokeStyle=rpmN>0.92?"#ff4d3d":grad; ctx.stroke();
ctx.lineCap="butt";
/* ticks + numbering */
ctx.textAlign="center"; ctx.textBaseline="middle";
for(let k=0;k<=MAXK;k+=20){
const a=a0+(a1-a0)*(k/MAXK), c1=Math.cos(a), s1=Math.sin(a);
const big=(k%40===0);
ctx.beginPath();
ctx.moveTo(cx+c1*(R-13),cy+s1*(R-13));
ctx.lineTo(cx+c1*(R-(big?27:20)),cy+s1*(R-(big?27:20)));
ctx.lineWidth=big?2:1;
ctx.strokeStyle=big?"rgba(230,240,250,0.85)":"rgba(230,240,250,0.30)";
ctx.stroke();
if(big && k<MAXK){
ctx.fillStyle="rgba(200,215,230,0.85)";
ctx.font="600 10px ui-monospace,monospace";
ctx.fillText(k, cx+c1*(R-34), cy+s1*(R-34));
}
}
/* needle */
const na=a0+(a1-a0)*U.clamp(kmh/MAXK,0,1);
ctx.save();
ctx.translate(cx,cy); ctx.rotate(na);
ctx.beginPath();
ctx.moveTo(-8,-3.2); ctx.lineTo(R-30,-1.2); ctx.lineTo(R-30,1.2); ctx.lineTo(-8,3.2);
ctx.closePath();
ctx.fillStyle="#ff4d3d"; ctx.shadowColor="rgba(255,77,61,0.9)"; ctx.shadowBlur=9; ctx.fill();
ctx.restore();
ctx.shadowBlur=0;
ctx.beginPath(); ctx.arc(cx,cy,7,0,6.283); ctx.fillStyle="#0f151d"; ctx.fill();
ctx.strokeStyle="rgba(255,255,255,0.3)"; ctx.lineWidth=1.5; ctx.stroke();
/* digital plate (also hides the needle where the digits sit) */
ctx.fillStyle="rgba(8,12,18,0.88)";
this.roundRect(ctx,cx-45,cy-68,90,48,10); ctx.fill();
ctx.strokeStyle="rgba(255,255,255,0.12)"; ctx.lineWidth=1; ctx.stroke();
ctx.fillStyle="#ffffff"; ctx.font="800 31px ui-monospace,monospace";
ctx.fillText(String(Math.round(kmh)), cx, cy-48);
ctx.fillStyle="rgba(170,190,210,0.95)"; ctx.font="700 9px system-ui,sans-serif";
ctx.fillText("KM / H", cx, cy-28);
/* surface readout */
ctx.fillStyle=offTrack?"#ff9d8a":"rgba(165,185,205,0.95)";
ctx.font="700 9.5px system-ui,sans-serif";
ctx.fillText(surfName, cx, cy-15);
/* gear */
const gx=cx+76, gy=cy-25;
ctx.fillStyle="rgba(12,18,26,0.95)"; this.roundRect(ctx,gx-19,gy-22,38,44,9); ctx.fill();
ctx.strokeStyle="rgba(255,255,255,0.22)"; ctx.lineWidth=1.2; ctx.stroke();
ctx.fillStyle=gear===0?"#ff9d3f":"#ffd23f"; ctx.font="900 26px ui-monospace,monospace";
ctx.fillText(gear===0?"R":String(gear), gx, gy+1);
},
roundRect(ctx,x,y,w,h,r){
ctx.beginPath();
ctx.moveTo(x+r,y); ctx.arcTo(x+w,y,x+w,y+h,r); ctx.arcTo(x+w,y+h,x,y+h,r);
ctx.arcTo(x,y+h,x,y,r); ctx.arcTo(x,y,x+w,y,r); ctx.closePath();
},
/* ------------- minimap ------------- */
buildMinimap(){
const S=Track.samples;
let minX=1e9,maxX=-1e9,minZ=1e9,maxZ=-1e9;
for(const s of S){
minX=Math.min(minX,s.p.x); maxX=Math.max(maxX,s.p.x);
minZ=Math.min(minZ,s.p.z); maxZ=Math.max(maxZ,s.p.z);
}
const pad=16, W=180,H=180;
const sc=Math.min((W-pad*2)/(maxX-minX),(H-pad*2)/(maxZ-minZ));
this.mmScale=sc;
this.mmOff={x:(W-(maxX+minX)*sc)/2, y:(H-(maxZ+minZ)*sc)/2};
const c=document.createElement("canvas"); c.width=360; c.height=360;
const ctx=c.getContext("2d"); ctx.scale(2,2);
/* track ribbon */
ctx.beginPath();
S.forEach((s,i)=>{
const x=s.p.x*sc+this.mmOff.x, y=s.p.z*sc+this.mmOff.y;
if(i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
});
ctx.closePath();
ctx.lineJoin="round"; ctx.lineCap="round";
ctx.strokeStyle="rgba(255,255,255,0.10)"; ctx.lineWidth=11; ctx.stroke();
ctx.strokeStyle="#4a5a6c"; ctx.lineWidth=8; ctx.stroke();
ctx.strokeStyle="#cdd8e4"; ctx.lineWidth=3.2; ctx.stroke();
/* checkpoints */
Track.gates.forEach((g,i)=>{
const s=S[g.idx];
const x=s.p.x*sc+this.mmOff.x, y=s.p.z*sc+this.mmOff.y;
ctx.fillStyle=i===0?"#ffd23f":"#5ad1ff";
ctx.beginPath(); ctx.arc(x,y,i===0?4.5:3,0,6.283); ctx.fill();
});
this.mmBase=c;
},
drawMinimap(car){
const ctx=this.mctx;
ctx.clearRect(0,0,180,180);
ctx.fillStyle="rgba(8,13,20,0.55)";
this.roundRect(ctx,0,0,180,180,10); ctx.fill();
ctx.drawImage(this.mmBase,0,0,180,180);
/* car marker */
const x=car.x*this.mmScale+this.mmOff.x, y=car.z*this.mmScale+this.mmOff.y;
ctx.save();
ctx.translate(x,y);
ctx.rotate(-car.yaw+Math.PI);
ctx.beginPath(); ctx.moveTo(0,-7); ctx.lineTo(5,5); ctx.lineTo(0,2.5); ctx.lineTo(-5,5); ctx.closePath();
ctx.fillStyle="#ff4d3d"; ctx.fill();
ctx.strokeStyle="rgba(255,255,255,0.9)"; ctx.lineWidth=1.2; ctx.stroke();
ctx.restore();
}
};
window.Rally.HUD = HUD;
/* =============================================================== CAMERA */
const CameraRig = {
cam:null, pos:new THREE.Vector3(), look:new THREE.Vector3(), camYaw:0,
mode:0, shake:0, baseFov:62, wantPos:new THREE.Vector3(), wantLook:new THREE.Vector3(),
init(cam){ this.cam=cam; this.pos.set(0,8,-14); this.look.set(0,1,0); },
toggle(){ this.mode=(this.mode+1)%2; },
addShake(v){ this.shake=Math.min(1.2,this.shake+v); },
update(dt, car){
const speed=Math.hypot(car.vx,car.vz);
if(this.mode===0){
/* chase: yaw lags behind the car -> drift reads on screen */
this.camYaw=U.angleLerp(this.camYaw, car.yaw, 1-Math.exp(-(3.6+speed*0.045)*dt));
const back=7.0+speed*0.055, up=2.55+speed*0.014;
const fx=Math.sin(this.camYaw), fz=Math.cos(this.camYaw);
this.wantPos.set(car.x-fx*back, car.y+up, car.z-fz*back);
this.wantLook.set(car.x+fx*(6+speed*0.12), car.y+1.15, car.z+fz*(6+speed*0.12));
} else {
/* cinematic: slow high orbit */
const t=performance.now()*0.00022;
const R=20;
this.wantPos.set(car.x+Math.sin(t)*R, car.y+13+Math.sin(t*1.7)*3, car.z+Math.cos(t)*R);
this.wantLook.set(car.x,car.y+1.2,car.z);
}
const pRate=this.mode===0?9:3.2, lRate=this.mode===0?12:4;
this.pos.x=U.damp(this.pos.x,this.wantPos.x,pRate,dt);
this.pos.y=U.damp(this.pos.y,this.wantPos.y,pRate*1.1,dt);
this.pos.z=U.damp(this.pos.z,this.wantPos.z,pRate,dt);
this.look.x=U.damp(this.look.x,this.wantLook.x,lRate,dt);
this.look.y=U.damp(this.look.y,this.wantLook.y,lRate,dt);
this.look.z=U.damp(this.look.z,this.wantLook.z,lRate,dt);
/* impact shake */
this.shake=Math.max(0,this.shake-dt*1.9);
const sh=this.shake*this.shake;
this.cam.position.copy(this.pos);
if(sh>0.0001){
this.cam.position.x+=(Math.random()-0.5)*sh*1.5;
this.cam.position.y+=(Math.random()-0.5)*sh*1.1;
this.cam.position.z+=(Math.random()-0.5)*sh*1.5;
}
/* keep the car in frame, slightly under the horizon */
this.cam.lookAt(this.look.x,this.look.y+(sh*(Math.random()-0.5)*1.2),this.look.z);
const fov=this.mode===0? this.baseFov+U.clamp(speed*0.26,0,14) : 52;
this.cam.fov=U.damp(this.cam.fov,fov,3,dt);
this.cam.updateProjectionMatrix();
}
};
window.Rally.CameraRig = CameraRig;
/* =================================================================== GAME
State machine, race/lap logic, the main loop and event wiring.
======================================================================== */
const Game = {
renderer:null, scene:null, camera:null,
state:"menu", prelim:null,
prevIdx:6, skidTimer:0, dustTimer:0, cdShown:null, cdT:0,
race:null, started:false,
init(){
const canvas=document.getElementById("viewport");
if(typeof THREE==="undefined"){
document.getElementById("bootErr").textContent =
"Three.js failed to load. This game streams the library from a CDN — connect to the internet and reload.";
document.getElementById("btnStart").disabled=true;
document.getElementById("btnStart").style.opacity=.4;
return;
}
const r=new THREE.WebGLRenderer({canvas,antialias:true,powerPreference:"high-performance"});
r.setPixelRatio(Math.min(window.devicePixelRatio||1,2));
r.setSize(window.innerWidth,window.innerHeight,false);
r.shadowMap.enabled=true;
r.shadowMap.type=THREE.PCFSoftShadowMap;
this.renderer=r;
const scene=new THREE.Scene();
scene.fog=new THREE.Fog(0xbdd8ea,150,680);
this.scene=scene;
this.camera=new THREE.PerspectiveCamera(62,window.innerWidth/window.innerHeight,0.35,1800);
this.camera.position.set(0,6,-14);
/* build the world */
Track.build();
scene.add(Track.group);
World.build(scene);
Car.build(scene);
FX.init(scene,r.getPixelRatio());
HUD.init();
CameraRig.init(this.camera);
Input.init();
Input.onAction=k=>this.onKey(k);
addEventListener("resize",()=>this.resize());
document.addEventListener("visibilitychange",()=>{ if(document.hidden&&this.state==="race") this.pause(); });
/* UI wiring */
const $=id=>document.getElementById(id);
$("btnStart").addEventListener("click",()=>this.startRace());
$("btnResume").addEventListener("click",()=>this.resume());
$("btnRestartPause").addEventListener("click",()=>this.startRace());
$("btnRestartFin").addEventListener("click",()=>this.startRace());
Car.reset();
this.race=this.freshRace();
CameraRig.camYaw=Car.yaw;
CameraRig.pos.set(Car.x-Math.sin(Car.yaw)*8,Car.y+3,Car.z-Math.cos(Car.yaw)*8);
CameraRig.look.set(Car.x,Car.y+1,Car.z);
this.started=true;
this.lastT=performance.now();
this.loop();
},
freshRace(){
return {lap:1,laps:CFG.LAPS,time:0,lapStart:0,last:null,best:null,times:[],finished:false};
},
resetGates(){ Track.gates.forEach(g=>g.hit=false); },
/* ---------------- state transitions ---------------- */
startRace(){
Audio.init(); Audio.resume();
Car.reset();
this.race=this.freshRace();
this.resetGates();
this.prevIdx=Car.trackIdx;
this.state="countdown"; this.cdT=3.35; this.cdShown=null;
this.skidTimer=0; this.dustTimer=0;
CameraRig.camYaw=Car.yaw;
CameraRig.pos.set(Car.x-Math.sin(Car.yaw)*8,Car.y+3,Car.z-Math.cos(Car.yaw)*8);
CameraRig.look.set(Car.x,Car.y+1,Car.z);
CameraRig.shake=0;
HUD.hideCountdown();
document.getElementById("overlayStart").classList.add("hidden");
document.getElementById("overlayFinish").classList.add("hidden");
document.getElementById("overlayPause").classList.add("hidden");
},
pause(){
if(this.state!=="race"&&this.state!=="countdown") return;
this.prelim=this.state; this.state="paused";
document.getElementById("overlayPause").classList.remove("hidden");
Audio.quiet();
},
resume(){
if(this.state!=="paused") return;
this.state=this.prelim||"race";
document.getElementById("overlayPause").classList.add("hidden");
},
finishRace(){
this.state="finish";
const r=this.race; r.finished=true;
Audio.quiet(); Audio.finishJingle();
document.getElementById("finTotal").textContent=U.fmt(r.time);
document.getElementById("finBest").textContent=U.fmt(r.best);
document.getElementById("finishSub").textContent="TOTAL RACE TIME · "+r.times.length+" LAPS";
const list=document.getElementById("lapList");
list.innerHTML="";
r.times.forEach((t,i)=>{
const d=document.createElement("div"); d.className="li";
const isBest=(t===r.best);
d.innerHTML="<span>LAP "+(i+1)+"</span><span style='color:"+(isBest?"#8ef0a8":"#dfe9f3")+"'>"+
U.fmt(t)+(isBest?" ★":"")+"</span>";
list.appendChild(d);
});
document.getElementById("overlayFinish").classList.remove("hidden");
},
onKey(k){
if(k==="r"){ if(this.state!=="menu") this.startRace(); }
else if(k==="p"||k==="escape"){ if(this.state==="paused") this.resume(); else this.pause(); }
else if(k==="c"){ CameraRig.toggle(); }
else if(k==="m"){ Audio.init(); Audio.setMuted(!Audio.muted); HUD.toast(Audio.muted?"AUDIO MUTED":"AUDIO ON"); }
},
resize(){
if(!this.renderer) return;
const w=window.innerWidth,h=window.innerHeight;
this.camera.aspect=w/h; this.camera.updateProjectionMatrix();
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio||1,2));
this.renderer.setSize(w,h,false);
FX.systems.forEach(s=>{ s.pts.material.uniforms.uPR.value=this.renderer.getPixelRatio(); });
},
/* ---------------- lap / checkpoint logic ---------------- */
checkProgress(dt){
const N=Track.samples.length, idx=Car.trackIdx, r=this.race;
for(let k=1;k<Track.gates.length;k++){
const g=Track.gates[k];
let dd=Math.abs(idx-g.idx); dd=Math.min(dd,N-dd);
if(dd<13) g.hit=true;
}
const prev=this.prevIdx;
if(prev>N*0.72 && idx<N*0.28){
const allPassed=Track.gates.slice(1).every(g=>g.hit);
if(allPassed && !r.finished && r.time>3000){
const lapTime=r.time-r.lapStart;
r.lapStart=r.time; r.last=lapTime; r.times.push(lapTime);
let isBest=false;
if(r.best==null||lapTime<r.best){ r.best=lapTime; isBest=true; }
if(r.lap>=r.laps){ this.finishRace(); }
else{
HUD.toast("LAP "+r.lap+" · "+U.fmt(lapTime)+(isBest?" ★ BEST":""),isBest?"fast":"");
r.lap++; this.resetGates();
}
}
}
this.prevIdx=idx;
},
/* ---------------- particles / skid marks ---------------- */
updateEffects(dt){
const car=Car, speed=Math.hypot(car.vx,car.vz);
if(speed<2.5) return;
const slipping=Math.abs(car.slip)>3.0 || (car.inp&&car.inp.handbrake&&speed>5);
const offRoad=!car.onTrack;
const onWater=car.surf==="water";
this.skidTimer-=dt; this.dustTimer-=dt;
if((slipping||offRoad) && this.skidTimer<=0 && !onWater){
this.skidTimer=0.026;
const f=car.forward();
for(const i of [2,3]){
const w=car.worldWheel(i);
const gy=car.surfY+0.035;
FX.addSkid(w.x,gy,w.z,f.x,f.z,onWater?0.3:0.34);
}
}
if((slipping||offRoad) && this.dustTimer<=0){
this.dustTimer=0.05;
const col=car.dustColor(car.surf);
const spread=offRoad?1.5:0.9;
for(const i of [2,3]){
const w=car.worldWheel(i);
const f=car.forward();
FX.spawn(FX.dust,w.x,car.surfY+0.25,w.z,
-f.x*speed*0.15+(Math.random()-0.5)*spread, 0.6+Math.random()*0.8,
-f.z*speed*0.15+(Math.random()-0.5)*spread,
(0.40+car.drift*0.65+speed*0.008), 0.9+Math.random()*0.7, col, 1.5, 0.6);
}
}
if(onWater && speed>6 && this.dustTimer<=0){
FX.splash(car.x,car.surfY,car.z,4);
}
},
/* ---------------- main loop ---------------- */
loop(){
requestAnimationFrame(()=>this.loop());
if(!this.started) return;
const now=performance.now();
let dt=(now-this.lastT)/1000;
this.lastT=now;
if(!isFinite(dt)||dt<0) dt=0;
dt=Math.min(dt,0.05);
this.update(dt);
this.render();
},
update(dt){
const st=this.state;
/* --- input --- */
const inp=Input.read();
if(st==="countdown"){
inp.throttle=0; inp.brake=0; inp.handbrake=false;
} else if(st!=="race"){
inp.throttle=0; inp.brake=0; inp.steer=0; inp.handbrake=false;
}
Car.inp=inp;
/* --- countdown --- */
if(st==="countdown"){
this.cdT-=dt;
const n=Math.max(0,Math.min(3,Math.ceil(this.cdT)));
if(this.cdShown!==n){
this.cdShown=n;
if(n>0){ HUD.showCountdown(String(n),false); Audio.beep(660,0.16,0.24,"square"); }
else { HUD.showCountdown("GO!",true); Audio.beep(1040,0.5,0.26,"triangle"); }
}
if(this.cdT<=0){
this.state="race"; this.race.time=0; this.race.lapStart=0; this.prevIdx=Car.trackIdx;
}
Audio.engine(0.35,0.6,0);
}
/* --- physics (fixed-ish sub steps) --- */
if(st==="race"){
let remaining=dt;
let guard=0;
while(remaining>0.0001 && guard<4){
const step=Math.min(remaining,1/60);
Car.update(step,inp,false);
remaining-=step; guard++;
}
this.race.time+=dt*1000; // race clock kept in milliseconds
this.checkProgress(dt);
this.updateEffects(dt);
} else if(st==="countdown"){
Car.update(dt,{throttle:0,brake:0,steer:inp.steer,handbrake:false},true);
} else {
Car.updateTransform(0.016,null,0);
}
FX.update(dt);
/* --- camera --- */
CameraRig.update(dt,Car);
if(Car.impact>0){ CameraRig.addShake(Car.impact*0.85); Car.impact=0; }
/* --- audio --- */
if(st==="race"){
const load=(inp.throttle>0?1:0)*0.8;
Audio.engine(Car.rpm,load,Math.hypot(Car.vx,Car.vz));
const spd=Math.hypot(Car.vx,Car.vz);
const slipAmt=U.clamp(Math.abs(Car.slip)/14,0,1);
const offAmt=!Car.onTrack?U.clamp(spd/26,0,1):0;
let kind = !Car.onTrack? Car.surf : (slipAmt>0.25? "skid":"none");
if(Car.surf==="water") kind="water";
Audio.tyres(Math.max(slipAmt*0.8,offAmt),kind,spd);
}
/* --- world follows --- */
const car=Car;
World.sky.position.copy(this.camera.position);
World.sun.position.set(car.x+62,car.y+108,car.z+38);
World.sun.target.position.set(car.x,car.y,car.z);
World.sun.target.updateMatrixWorld();
/* --- HUD --- */
const r=this.race;
HUD.setTiming(r.lap,r.laps,
st==="race"? r.time-r.lapStart : (st==="finish"? r.best : 0),
r.last,r.best);
HUD.drawGauge(car.speedKmh(),car.rpm,car.gear,
(car.onTrack?"":"OFF TRACK · ")+SURFACES[car.surf].name, !car.onTrack);
HUD.drawMinimap(car);
HUD.updateToast(dt);
},
render(){
this.renderer.render(this.scene,this.camera);
}
};
window.Rally.Game = Game;
/* boot once the first paint has happened so the menu shows instantly */
requestAnimationFrame(()=>Game.init());
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"deepseek/deepseek-v4.1-flash","provider":"openrouter","persona":"full","sessionId":"36b7d832-671d-4188-9e34-b0791122602e","tokensIn":13809635,"tokensOut":108809,"tokensTotal":13918444,"cost":0.144981786,"turns":84,"toolCalls":83,"failedToolCalls":2,"timestamp":"2026-09-11T00:09:51.485Z"} -->