Rally
Grok 4.5 · legacy
◈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 68,978 bytes · SHA-256 fe670d7d1a16
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>Rally Rush 3D</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%; height: 100%; overflow: hidden;
background: #0a0e14; font-family: 'Segoe UI', system-ui, sans-serif;
touch-action: none; user-select: none; -webkit-user-select: none;
}
#gameCanvas { display: block; width: 100%; height: 100%; }
#hud {
position: fixed; inset: 0; pointer-events: none;
color: #fff; text-shadow: 0 1px 3px rgba(0,0,0,.8);
}
#hud .panel {
position: absolute; background: rgba(0,0,0,.45);
border: 1px solid rgba(255,255,255,.15); border-radius: 10px;
padding: 10px 14px; backdrop-filter: blur(6px);
}
#speedPanel { bottom: 20px; left: 20px; min-width: 140px; }
#speedVal { font-size: 42px; font-weight: 700; letter-spacing: 1px; line-height: 1; }
#speedUnit { font-size: 14px; opacity: .7; margin-left: 4px; }
#gearRpm { font-size: 13px; opacity: .85; margin-top: 4px; }
#timePanel { top: 16px; left: 50%; transform: translateX(-50%); text-align: center; min-width: 200px; }
#timer { font-size: 28px; font-weight: 700; font-variant-numeric: tabular-nums; }
#lapInfo { font-size: 13px; opacity: .9; margin-top: 2px; }
#bestLap { font-size: 12px; color: #7dffb3; margin-top: 2px; }
#minimapWrap {
position: absolute; top: 16px; right: 16px;
width: 160px; height: 160px; border-radius: 12px;
background: rgba(0,0,0,.5); border: 1px solid rgba(255,255,255,.2);
overflow: hidden;
}
#minimap { width: 100%; height: 100%; display: block; }
#surfaceHint {
position: absolute; bottom: 100px; left: 20px;
font-size: 12px; opacity: .7; text-transform: uppercase; letter-spacing: 1px;
}
#countdown {
position: fixed; inset: 0; display: flex; align-items: center; justify-content: center;
font-size: 120px; font-weight: 900; color: #fff;
text-shadow: 0 0 40px rgba(255,120,0,.8), 0 4px 12px rgba(0,0,0,.9);
pointer-events: none; opacity: 0; transition: opacity .15s;
z-index: 20;
}
#countdown.show { opacity: 1; }
#countdown.go { color: #5eff8a; font-size: 100px; }
#overlay {
position: fixed; inset: 0; background: rgba(0,0,0,.72);
display: none; align-items: center; justify-content: center;
z-index: 30; pointer-events: auto;
}
#overlay.visible { display: flex; }
.menu-box {
background: linear-gradient(160deg, #1a2332 0%, #0d141f 100%);
border: 1px solid rgba(255,255,255,.12); border-radius: 16px;
padding: 36px 48px; text-align: center; max-width: 90vw;
box-shadow: 0 20px 60px rgba(0,0,0,.5);
}
.menu-box h1 {
font-size: 36px; margin-bottom: 8px;
background: linear-gradient(90deg, #ff6b2c, #ffd166);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
background-clip: text;
}
.menu-box p { opacity: .8; margin-bottom: 20px; line-height: 1.5; font-size: 14px; }
.menu-box .stats { margin: 16px 0 24px; font-size: 16px; line-height: 1.8; }
.menu-box .stats strong { color: #ffd166; }
.btn {
display: inline-block; margin: 6px; padding: 12px 28px;
background: linear-gradient(180deg, #ff7a3d, #e04e12);
color: #fff; border: none; border-radius: 8px; font-size: 16px;
font-weight: 700; cursor: pointer; pointer-events: auto;
box-shadow: 0 4px 0 #a0340a, 0 6px 16px rgba(0,0,0,.3);
transition: transform .1s, box-shadow .1s;
}
.btn:hover { transform: translateY(-1px); }
.btn:active { transform: translateY(2px); box-shadow: 0 2px 0 #a0340a; }
.btn.secondary {
background: linear-gradient(180deg, #3a4a5c, #2a3544);
box-shadow: 0 4px 0 #1a2230, 0 6px 16px rgba(0,0,0,.3);
}
#titleBoot {
position: fixed; inset: 0; background: radial-gradient(ellipse at center, #1a2838 0%, #060a10 70%);
display: flex; flex-direction: column; align-items: center; justify-content: center;
z-index: 50; color: #fff;
}
#titleBoot h1 {
font-size: clamp(36px, 8vw, 64px); margin-bottom: 12px;
background: linear-gradient(90deg, #ff6b2c, #ffd166, #ff6b2c);
background-size: 200% auto;
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
background-clip: text; animation: shimmer 3s linear infinite;
}
@keyframes shimmer { to { background-position: 200% center; } }
#titleBoot .sub { opacity: .7; margin-bottom: 28px; font-size: 15px; }
#titleBoot .keys {
font-size: 13px; opacity: .55; margin-top: 18px; line-height: 1.7; text-align: center;
}
</style>
</head>
<body>
<canvas id="gameCanvas"></canvas>
<div id="hud">
<div id="speedPanel" class="panel">
<div><span id="speedVal">0</span><span id="speedUnit">km/h</span></div>
<div id="gearRpm">N · 800 RPM</div>
</div>
<div id="timePanel" class="panel">
<div id="timer">00:00.000</div>
<div id="lapInfo">Lap 1 / 3</div>
<div id="bestLap">Best: --:--.---</div>
</div>
<div id="minimapWrap"><canvas id="minimap" width="160" height="160"></canvas></div>
<div id="surfaceHint">ASPHALT</div>
</div>
<div id="countdown"></div>
<div id="overlay">
<div class="menu-box" id="menuBox"></div>
</div>
<div id="titleBoot">
<h1>RALLY RUSH 3D</h1>
<p class="sub">3 Laps · Winding Hills · Pure Speed</p>
<button class="btn" id="startBtn">START RACE</button>
<div class="keys">
WASD / Arrows — Drive · Space — Handbrake<br>
C — Camera · P — Pause · R — Restart
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
(function () {
'use strict';
// ═══════════════════════════════════════════════════════════
// CONFIG
// ═══════════════════════════════════════════════════════════
const CFG = {
TOTAL_LAPS: 3,
GRAVITY: 28,
CAR: {
ACCEL: 42,
BRAKE: 55,
REVERSE_ACCEL: 24,
MAX_SPEED: 54,
MAX_REVERSE: 14,
DRAG: 0.5,
ROLLING: 1.1,
STEER_RATE: 2.6, // max yaw rate influence
STEER_RESP: 12, // how quickly wheels respond
STEER_SPEED_FALL: 0.55, // high-speed stability
GRIP: 11,
DRIFT_GRIP: 3.5,
HANDBRAKE_GRIP: 1.6,
WHEEL_BASE: 2.4,
COLLISION_RADIUS: 1.5
},
SURFACE: {
asphalt: { grip: 1.0, drag: 1.0, topMul: 1.0, name: 'ASPHALT' },
dirt: { grip: 0.72, drag: 1.15, topMul: 0.9, name: 'DIRT' },
grass: { grip: 0.48, drag: 1.5, topMul: 0.7, name: 'GRASS' },
mud: { grip: 0.32, drag: 2.2, topMul: 0.45, name: 'MUD' }
},
TRACK_HALF_WIDTH: 7.5,
SHOULDER: 3.5
};
// ═══════════════════════════════════════════════════════════
// UTIL
// ═══════════════════════════════════════════════════════════
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const lerp = (a, b, t) => a + (b - a) * t;
const fmtTime = (s) => {
if (!isFinite(s) || s < 0) return '--:--.---';
const m = Math.floor(s / 60);
const sec = s - m * 60;
return String(m).padStart(2, '0') + ':' + sec.toFixed(3).padStart(6, '0');
};
// ═══════════════════════════════════════════════════════════
// AUDIO (Web Audio API only)
// ═══════════════════════════════════════════════════════════
const AudioSys = {
ctx: null, master: null,
engineOsc: null, engineOsc2: null, engineGain: null, engineFilter: null,
skidOsc: null, skidGain: null, noiseNode: null, noiseGain: null,
started: false,
init() {
if (this.started) return;
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
this.ctx = new AC();
this.master = this.ctx.createGain();
this.master.gain.value = 0.35;
this.master.connect(this.ctx.destination);
this.engineOsc = this.ctx.createOscillator();
this.engineOsc.type = 'sawtooth';
this.engineOsc.frequency.value = 60;
this.engineOsc2 = this.ctx.createOscillator();
this.engineOsc2.type = 'square';
this.engineOsc2.frequency.value = 90;
this.engineFilter = this.ctx.createBiquadFilter();
this.engineFilter.type = 'lowpass';
this.engineFilter.frequency.value = 400;
this.engineFilter.Q.value = 4;
this.engineGain = this.ctx.createGain();
this.engineGain.gain.value = 0;
const eg2 = this.ctx.createGain();
eg2.gain.value = 0.3;
this.engineOsc.connect(this.engineFilter);
this.engineOsc2.connect(eg2);
eg2.connect(this.engineFilter);
this.engineFilter.connect(this.engineGain);
this.engineGain.connect(this.master);
this.engineOsc.start();
this.engineOsc2.start();
const bufLen = this.ctx.sampleRate * 2;
const noiseBuf = this.ctx.createBuffer(1, bufLen, this.ctx.sampleRate);
const data = noiseBuf.getChannelData(0);
for (let i = 0; i < bufLen; i++) data[i] = Math.random() * 2 - 1;
this.noiseNode = this.ctx.createBufferSource();
this.noiseNode.buffer = noiseBuf;
this.noiseNode.loop = true;
const nFilter = this.ctx.createBiquadFilter();
nFilter.type = 'bandpass';
nFilter.frequency.value = 900;
nFilter.Q.value = 0.8;
this.noiseGain = this.ctx.createGain();
this.noiseGain.gain.value = 0;
this.noiseNode.connect(nFilter);
nFilter.connect(this.noiseGain);
this.noiseGain.connect(this.master);
this.noiseNode.start();
this.skidOsc = this.ctx.createOscillator();
this.skidOsc.type = 'sawtooth';
this.skidOsc.frequency.value = 180;
this.skidGain = this.ctx.createGain();
this.skidGain.gain.value = 0;
const sf = this.ctx.createBiquadFilter();
sf.type = 'highpass';
sf.frequency.value = 400;
this.skidOsc.connect(sf);
sf.connect(this.skidGain);
this.skidGain.connect(this.master);
this.skidOsc.start();
this.started = true;
},
resume() {
if (this.ctx && this.ctx.state === 'suspended') this.ctx.resume();
},
updateEngine(speed, throttle, gear, rpm) {
if (!this.started) return;
const sp = Math.abs(speed);
const base = 55 + rpm * 0.045 + sp * 1.8;
this.engineOsc.frequency.setTargetAtTime(base, this.ctx.currentTime, 0.05);
this.engineOsc2.frequency.setTargetAtTime(base * 1.48 + gear * 8, this.ctx.currentTime, 0.05);
this.engineFilter.frequency.setTargetAtTime(300 + sp * 12 + throttle * 400, this.ctx.currentTime, 0.08);
const vol = 0.04 + Math.min(0.22, sp * 0.004) + throttle * 0.12;
this.engineGain.gain.setTargetAtTime(vol, this.ctx.currentTime, 0.08);
},
updateSkid(amount, offroad) {
if (!this.started) return;
const a = clamp(amount, 0, 1);
this.skidGain.gain.setTargetAtTime(a * 0.08, this.ctx.currentTime, 0.06);
this.noiseGain.gain.setTargetAtTime(a * 0.1 + (offroad ? 0.05 : 0) * Math.min(1, a + 0.3), this.ctx.currentTime, 0.08);
this.skidOsc.frequency.setTargetAtTime(140 + a * 220, this.ctx.currentTime, 0.05);
},
beep(freq, dur, type) {
if (!this.started) return;
const t = this.ctx.currentTime;
const o = this.ctx.createOscillator();
const g = this.ctx.createGain();
o.type = type || 'square';
o.frequency.value = freq;
g.gain.setValueAtTime(0.25, t);
g.gain.exponentialRampToValueAtTime(0.001, t + dur);
o.connect(g); g.connect(this.master);
o.start(t); o.stop(t + dur + 0.02);
},
countdownBeep(n) {
if (n > 0) this.beep(440, 0.15, 'square');
else this.beep(880, 0.4, 'square');
},
finishTone() {
if (!this.started) return;
[523, 659, 784, 1046].forEach((f, i) => setTimeout(() => this.beep(f, 0.25, 'triangle'), i * 120));
},
crash() {
if (!this.started) return;
const t = this.ctx.currentTime;
const o = this.ctx.createOscillator();
const g = this.ctx.createGain();
o.type = 'sine';
o.frequency.setValueAtTime(120, t);
o.frequency.exponentialRampToValueAtTime(30, t + 0.3);
g.gain.setValueAtTime(0.5, t);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.35);
o.connect(g); g.connect(this.master);
o.start(t); o.stop(t + 0.4);
const bufLen = Math.floor(this.ctx.sampleRate * 0.25);
const buf = this.ctx.createBuffer(1, bufLen, this.ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < bufLen; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / bufLen);
const src = this.ctx.createBufferSource();
src.buffer = buf;
const ng = this.ctx.createGain();
ng.gain.value = 0.3;
const f = this.ctx.createBiquadFilter();
f.type = 'lowpass'; f.frequency.value = 800;
src.connect(f); f.connect(ng); ng.connect(this.master);
src.start(t);
}
};
// ═══════════════════════════════════════════════════════════
// INPUT (keyboard only)
// ═══════════════════════════════════════════════════════════
const Input = {
keys: {},
steer: 0,
throttle: 0,
brake: 0,
handbrake: false,
init() {
window.addEventListener('keydown', (e) => {
this.keys[e.code] = true;
if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space'].includes(e.code)) {
e.preventDefault();
}
});
window.addEventListener('keyup', (e) => {
this.keys[e.code] = false;
});
// Avoid stuck keys when tabbing away
window.addEventListener('blur', () => { this.keys = {}; });
},
poll() {
let s = 0, th = 0, br = 0;
if (this.keys['ArrowLeft'] || this.keys['KeyA']) s -= 1;
if (this.keys['ArrowRight'] || this.keys['KeyD']) s += 1;
if (this.keys['ArrowUp'] || this.keys['KeyW']) th = 1;
if (this.keys['ArrowDown'] || this.keys['KeyS']) br = 1;
this.steer = s;
this.throttle = th;
this.brake = br;
this.handbrake = !!this.keys['Space'];
}
};
// ═══════════════════════════════════════════════════════════
// TRACK
// ═══════════════════════════════════════════════════════════
const Track = {
points: [],
samples: [],
totalLength: 0,
checkpoints: [],
treePositions: [],
rockPositions: [],
barrierPositions: [],
signPositions: [],
bannerPositions: [],
build() {
// Wider, smoother rally loop — control points [x, y, z, width]
const cp = [
[ 0, 0.0, 0, 15],
[ 50, 0.4, -25, 15],
[100, 1.0, -20, 14],
[145, 2.2, 25, 14],
[165, 4.0, 80, 13],
[145, 5.5, 140, 13],
[ 90, 5.0, 175, 14],
[ 25, 3.5, 175, 15],
[-35, 2.5, 145, 15],
[-80, 2.0, 105, 14],
[-110, 3.5, 55, 13],
[-105, 5.5, 5, 13],
[-75, 7.0, -45, 14],
[-25, 4.5, -80, 15],
[ 30, 2.0, -95, 15],
[ 70, 1.0, -70, 15],
[ 55, 0.5, -35, 15],
[ 20, 0.2, -10, 15]
];
this.points = cp.map(p => ({ x: p[0], y: p[1], z: p[2], width: p[3] }));
const pts = this.points;
const n = pts.length;
const samples = [];
const segs = 28;
let totalLen = 0;
let prev = null;
for (let i = 0; i < n; i++) {
const p0 = pts[(i - 1 + n) % n];
const p1 = pts[i];
const p2 = pts[(i + 1) % n];
const p3 = pts[(i + 2) % n];
for (let j = 0; j < segs; j++) {
const t = j / segs;
const t2 = t * t, t3 = t2 * t;
const x = 0.5 * ((2*p1.x) + (-p0.x+p2.x)*t + (2*p0.x-5*p1.x+4*p2.x-p3.x)*t2 + (-p0.x+3*p1.x-3*p2.x+p3.x)*t3);
const y = 0.5 * ((2*p1.y) + (-p0.y+p2.y)*t + (2*p0.y-5*p1.y+4*p2.y-p3.y)*t2 + (-p0.y+3*p1.y-3*p2.y+p3.y)*t3);
const z = 0.5 * ((2*p1.z) + (-p0.z+p2.z)*t + (2*p0.z-5*p1.z+4*p2.z-p3.z)*t2 + (-p0.z+3*p1.z-3*p2.z+p3.z)*t3);
const w = lerp(p1.width, p2.width, t);
const s = { x, y, z, width: w, dist: 0 };
if (prev) {
totalLen += Math.hypot(x - prev.x, y - prev.y, z - prev.z);
s.dist = totalLen;
}
samples.push(s);
prev = s;
}
}
this.samples = samples;
this.totalLength = totalLen;
for (let i = 0; i < samples.length; i++) {
const a = samples[i];
const b = samples[(i + 1) % samples.length];
const dx = b.x - a.x, dz = b.z - a.z;
const len = Math.hypot(dx, dz) || 1;
a.tx = dx / len;
a.tz = dz / len;
a.nx = -a.tz; // left normal in XZ
a.nz = a.tx;
const dy = b.y - a.y;
a.ty = dy / (Math.hypot(dx, dy, dz) || 1);
}
// Checkpoints for lap validation
this.checkpoints = [];
const cpCount = 12;
for (let i = 0; i < cpCount; i++) {
const dist = (i / cpCount) * this.totalLength;
const s = this.sampleAtDistance(dist);
this.checkpoints.push({
dist, x: s.x, y: s.y, z: s.z,
tx: s.tx, tz: s.tz, index: i,
halfW: s.width * 0.55
});
}
this.scatterScenery();
},
sampleAtDistance(d) {
const samples = this.samples;
const L = this.totalLength;
d = ((d % L) + L) % L;
let lo = 0, hi = samples.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (samples[mid].dist < d) lo = mid + 1;
else hi = mid;
}
const i1 = lo % samples.length;
const i0 = (i1 - 1 + samples.length) % samples.length;
const a = samples[i0], b = samples[i1];
let segLen = b.dist - a.dist;
if (segLen <= 0) segLen = 1;
let t = (d - a.dist) / segLen;
if (t < 0 || t > 1) t = 0;
return {
x: lerp(a.x, b.x, t), y: lerp(a.y, b.y, t), z: lerp(a.z, b.z, t),
tx: lerp(a.tx, b.tx, t), tz: lerp(a.tz, b.tz, t),
nx: lerp(a.nx, b.nx, t), nz: lerp(a.nz, b.nz, t),
width: lerp(a.width, b.width, t),
ty: lerp(a.ty || 0, b.ty || 0, t),
dist: d
};
},
query(px, pz) {
let best = 0, bestD = Infinity;
const samples = this.samples;
const step = 4;
for (let i = 0; i < samples.length; i += step) {
const s = samples[i];
const d = (s.x - px) * (s.x - px) + (s.z - pz) * (s.z - pz);
if (d < bestD) { bestD = d; best = i; }
}
const r0 = Math.max(0, best - step);
const r1 = Math.min(samples.length - 1, best + step);
for (let i = r0; i <= r1; i++) {
const s = samples[i];
const d = (s.x - px) * (s.x - px) + (s.z - pz) * (s.z - pz);
if (d < bestD) { bestD = d; best = i; }
}
const s = samples[best];
const lx = px - s.x, lz = pz - s.z;
const lateral = lx * s.nx + lz * s.nz;
const halfW = s.width * 0.5;
return {
sample: s, lateral, halfW, elev: s.y, dist: s.dist,
onTrack: Math.abs(lateral) < halfW,
onShoulder: Math.abs(lateral) < halfW + CFG.SHOULDER && Math.abs(lateral) >= halfW
};
},
surfaceAt(px, pz) {
const q = this.query(px, pz);
const absL = Math.abs(q.lateral);
if (absL < q.halfW * 0.85) return { type: 'asphalt', q };
if (absL < q.halfW) return { type: 'dirt', q };
if (absL < q.halfW + CFG.SHOULDER * 0.6) return { type: 'grass', q };
const h = Math.sin(px * 0.17) * Math.cos(pz * 0.13);
if (h > 0.55) return { type: 'mud', q };
return { type: 'grass', q };
},
scatterScenery() {
const rng = (seed) => {
let s = seed;
return () => { s = (s * 16807) % 2147483647; return (s - 1) / 2147483646; };
};
const r = rng(42);
this.treePositions = [];
this.rockPositions = [];
this.barrierPositions = [];
this.signPositions = [];
this.bannerPositions = [];
for (let i = 0; i < this.samples.length; i += 2) {
const s = this.samples[i];
const halfW = s.width * 0.5;
// Trees well clear of the road
for (let side = -1; side <= 1; side += 2) {
if (r() > 0.42) continue;
const off = halfW + 7 + r() * 22;
this.treePositions.push({
x: s.x + s.nx * side * off + (r() - 0.5) * 3,
y: s.y,
z: s.z + s.nz * side * off + (r() - 0.5) * 3,
scale: 0.85 + r() * 1.3,
type: r() > 0.35 ? 0 : 1
});
}
// Rocks near but not on the road
if (r() > 0.82) {
const side = r() > 0.5 ? 1 : -1;
const off = halfW + 3.5 + r() * 5;
this.rockPositions.push({
x: s.x + s.nx * side * off,
y: s.y,
z: s.z + s.nz * side * off,
scale: 0.5 + r() * 1.0
});
}
// Guard rails only on outer edge of sharp turns
if (i % 6 === 0) {
const next = this.samples[(i + 3) % this.samples.length];
const cross = s.tx * next.tz - s.tz * next.tx;
const turn = Math.abs(cross);
if (turn > 0.06) {
// Place on the outside of the corner
const side = cross > 0 ? -1 : 1;
for (let k = 0; k < 3; k++) {
const ss = this.samples[(i + k) % this.samples.length];
this.barrierPositions.push({
x: ss.x + ss.nx * side * (ss.width * 0.5 + 1.0),
y: ss.y,
z: ss.z + ss.nz * side * (ss.width * 0.5 + 1.0),
// rail runs along the track direction
angle: Math.atan2(ss.tx, ss.tz)
});
}
}
}
}
// Rally signs (clear of roadway)
for (let i = 0; i < 12; i++) {
const dist = ((i + 0.3) / 12) * this.totalLength;
const s = this.sampleAtDistance(dist);
const side = i % 2 === 0 ? 1 : -1;
this.signPositions.push({
x: s.x + s.nx * side * (s.width * 0.5 + 3.2),
y: s.y,
z: s.z + s.nz * side * (s.width * 0.5 + 3.2),
angle: Math.atan2(s.tx, s.tz),
kind: i % 3
});
}
// Overhead banners / gates (span the road, tall enough clearance)
for (let i = 0; i < 5; i++) {
const dist = ((i + 0.5) / 5) * this.totalLength;
// Skip very start so we don't double with the finish arch
if (dist < 20 || dist > this.totalLength - 20) continue;
const s = this.sampleAtDistance(dist);
this.bannerPositions.push({
x: s.x, y: s.y, z: s.z,
angle: Math.atan2(s.tx, s.tz),
width: s.width + 3
});
}
}
};
// ═══════════════════════════════════════════════════════════
// PARTICLES
// ═══════════════════════════════════════════════════════════
const Particles = {
list: [], mesh: null, max: 400, geo: null, positions: null, colors: null,
init(scene) {
this.positions = new Float32Array(this.max * 3);
this.colors = new Float32Array(this.max * 3);
this.geo = new THREE.BufferGeometry();
this.geo.setAttribute('position', new THREE.BufferAttribute(this.positions, 3));
this.geo.setAttribute('color', new THREE.BufferAttribute(this.colors, 3));
const mat = new THREE.PointsMaterial({
size: 0.55, vertexColors: true, transparent: true,
opacity: 0.85, depthWrite: false, depthWrite: false, sizeAttenuation: true
});
this.mesh = new THREE.Points(this.geo, mat);
this.mesh.frustumCulled = false;
scene.add(this.mesh);
this.list = [];
},
spawn(x, y, z, vx, vy, vz, life, r, g, b) {
if (this.list.length >= this.max) this.list.shift();
this.list.push({ x, y, z, vx, vy, vz, life, maxLife: life, r, g, b });
},
dust(x, y, z, speed, amt) {
for (let i = 0; i < amt; i++) {
const a = Math.random() * Math.PI * 2;
const sp = 1 + Math.random() * 3;
this.spawn(
x + (Math.random() - 0.5) * 1.2, y + 0.1, z + (Math.random() - 0.5) * 1.2,
Math.cos(a) * sp * 0.3 - speed * 0.12,
1 + Math.random() * 2,
Math.sin(a) * sp * 0.3,
0.4 + Math.random() * 0.6,
0.55 + Math.random() * 0.2, 0.45 + Math.random() * 0.15, 0.3 + Math.random() * 0.15
);
}
},
sparks(x, y, z, n) {
for (let i = 0; i < n; i++) {
this.spawn(x, y + 0.3, z,
(Math.random() - 0.5) * 12, 2 + Math.random() * 8, (Math.random() - 0.5) * 12,
0.25 + Math.random() * 0.3, 1, 0.7 + Math.random() * 0.3, 0.2);
}
},
skid(x, y, z) {
this.spawn(x, y + 0.05, z, (Math.random()-0.5)*0.5, 0, (Math.random()-0.5)*0.5, 1.2, 0.12, 0.1, 0.08);
},
update(dt) {
let i = 0;
while (i < this.list.length) {
const p = this.list[i];
p.life -= dt;
if (p.life <= 0) { this.list.splice(i, 1); continue; }
p.x += p.vx * dt; p.y += p.vy * dt; p.z += p.vz * dt;
p.vy -= 6 * dt;
p.vx *= (1 - 1.5 * dt); p.vz *= (1 - 1.5 * dt);
i++;
}
for (let j = 0; j < this.max; j++) {
if (j < this.list.length) {
const p = this.list[j];
const t = p.life / p.maxLife;
this.positions[j*3] = p.x; this.positions[j*3+1] = p.y; this.positions[j*3+2] = p.z;
this.colors[j*3] = p.r * t; this.colors[j*3+1] = p.g * t; this.colors[j*3+2] = p.b * t;
} else {
this.positions[j*3+1] = -999;
}
}
this.geo.attributes.position.needsUpdate = true;
this.geo.attributes.color.needsUpdate = true;
}
};
// ═══════════════════════════════════════════════════════════
// CAR
// ═══════════════════════════════════════════════════════════
const Car = {
x: 0, y: 0, z: 0,
yaw: 0, pitch: 0, roll: 0,
speed: 0, vx: 0, vz: 0,
steerAngle: 0,
gear: 1, rpm: 800,
sliding: 0,
mesh: null, wheels: [], headlights: [], taillights: [],
lastSurface: 'asphalt',
collisionCool: 0,
skidTimer: 0,
buildMesh(scene) {
const g = new THREE.Group();
const bodyMat = new THREE.MeshStandardMaterial({
color: 0xd63031, roughness: 0.45, metalness: 0.35, flatShading: true
});
const cabinMat = new THREE.MeshStandardMaterial({
color: 0x1e272e, roughness: 0.3, metalness: 0.5, flatShading: true
});
const glassMat = new THREE.MeshStandardMaterial({
color: 0x74b9ff, roughness: 0.2, metalness: 0.8, transparent: true, opacity: 0.55, flatShading: true
});
const bumperMat = new THREE.MeshStandardMaterial({ color: 0x2d3436, flatShading: true });
// Body elongated along local +Z (forward)
const body = new THREE.Mesh(new THREE.BoxGeometry(1.8, 0.55, 3.6), bodyMat);
body.position.y = 0.55;
body.castShadow = true;
g.add(body);
const cabin = new THREE.Mesh(new THREE.BoxGeometry(1.5, 0.5, 1.6), cabinMat);
cabin.position.set(0, 1.0, -0.15);
cabin.castShadow = true;
g.add(cabin);
const windshield = new THREE.Mesh(new THREE.BoxGeometry(1.35, 0.35, 0.08), glassMat);
windshield.position.set(0, 1.05, 0.7);
windshield.rotation.x = -0.25;
g.add(windshield);
const scoop = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.12, 0.7), bodyMat);
scoop.position.set(0, 0.88, 1.0);
g.add(scoop);
// Rear spoiler
const spoilerPoleL = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.35, 0.08), cabinMat);
spoilerPoleL.position.set(-0.6, 0.95, -1.65);
const spoilerPoleR = spoilerPoleL.clone(); spoilerPoleR.position.x = 0.6;
const spoiler = new THREE.Mesh(new THREE.BoxGeometry(1.7, 0.08, 0.4), bodyMat);
spoiler.position.set(0, 1.15, -1.65);
g.add(spoilerPoleL, spoilerPoleR, spoiler);
const fBump = new THREE.Mesh(new THREE.BoxGeometry(1.85, 0.25, 0.3), bumperMat);
fBump.position.set(0, 0.35, 1.9);
const rBump = new THREE.Mesh(new THREE.BoxGeometry(1.85, 0.25, 0.25), bumperMat);
rBump.position.set(0, 0.35, -1.9);
g.add(fBump, rBump);
const hlMat = new THREE.MeshStandardMaterial({
color: 0xfff3c4, emissive: 0xffeeaa, emissiveIntensity: 0.9, flatShading: true
});
const hlL = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.18, 0.1), hlMat);
hlL.position.set(-0.55, 0.55, 1.95);
const hlR = hlL.clone(); hlR.position.x = 0.55;
g.add(hlL, hlR);
this.headlights = [hlL, hlR];
const tlMat = new THREE.MeshStandardMaterial({
color: 0xff0000, emissive: 0xff0000, emissiveIntensity: 0.6, flatShading: true
});
const tlL = new THREE.Mesh(new THREE.BoxGeometry(0.35, 0.14, 0.08), tlMat);
tlL.position.set(-0.55, 0.55, -1.95);
const tlR = tlL.clone(); tlR.position.x = 0.55;
g.add(tlL, tlR);
this.taillights = [tlL, tlR];
const wheelMat = new THREE.MeshStandardMaterial({ color: 0x111111, flatShading: true, roughness: 0.9 });
const rimMat = new THREE.MeshStandardMaterial({ color: 0xb2bec3, metalness: 0.7, roughness: 0.3, flatShading: true });
this.wheels = [];
const wheelPos = [
[-0.85, 0.32, 1.15],
[ 0.85, 0.32, 1.15],
[-0.85, 0.32, -1.15],
[ 0.85, 0.32, -1.15]
];
wheelPos.forEach((p, idx) => {
const wg = new THREE.Group();
const tire = new THREE.Mesh(new THREE.CylinderGeometry(0.35, 0.35, 0.28, 10), wheelMat);
tire.rotation.z = Math.PI / 2;
tire.castShadow = true;
const rim = new THREE.Mesh(new THREE.CylinderGeometry(0.18, 0.18, 0.3, 8), rimMat);
rim.rotation.z = Math.PI / 2;
wg.add(tire, rim);
wg.position.set(p[0], p[1], p[2]);
g.add(wg);
this.wheels.push({ mesh: wg, front: idx < 2 });
});
const shadow = new THREE.Mesh(
new THREE.CircleGeometry(1.3, 16),
new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.35, depthWrite: false })
);
shadow.rotation.x = -Math.PI / 2;
shadow.position.y = 0.05;
g.add(shadow);
this.mesh = g;
scene.add(g);
},
reset() {
// Start a little past the finish line so the arch is in front of the car
const start = Track.sampleAtDistance(8);
this.x = start.x;
this.y = start.y;
this.z = start.z;
// Face along track forward (+Z comes from sin/cos of yaw)
this.yaw = Math.atan2(start.tx, start.tz);
this.pitch = 0;
this.roll = 0;
this.speed = 0;
this.vx = 0;
this.vz = 0;
this.steerAngle = 0;
this.gear = 1;
this.rpm = 800;
this.sliding = 0;
this.collisionCool = 0;
if (this.mesh) {
this.mesh.position.set(this.x, this.y, this.z);
this.mesh.rotation.order = 'YXZ';
this.mesh.rotation.y = this.yaw;
this.mesh.rotation.x = 0;
this.mesh.rotation.z = 0;
}
},
update(dt) {
Input.poll();
const c = CFG.CAR;
const surf = Track.surfaceAt(this.x, this.z);
this.lastSurface = surf.type;
const S = CFG.SURFACE[surf.type];
const q = surf.q;
// --- Steering (works while moving; gentle kick even crawling) ---
const spAbs = Math.abs(this.speed);
const speedFactor = 1 - clamp(spAbs / c.MAX_SPEED, 0, 1) * c.STEER_SPEED_FALL;
const targetSteer = Input.steer * c.STEER_RATE * speedFactor;
this.steerAngle = lerp(this.steerAngle, targetSteer, 1 - Math.exp(-c.STEER_RESP * dt));
// --- Longitudinal force ---
let accel = 0;
if (Input.throttle > 0) {
const top = c.MAX_SPEED * S.topMul;
const power = 1 - clamp(this.speed / Math.max(top, 0.01), 0, 1);
accel += c.ACCEL * Input.throttle * power * (0.55 + 0.45 * S.grip);
}
if (Input.brake > 0) {
if (this.speed > 0.5) accel -= c.BRAKE * Input.brake;
else accel -= c.REVERSE_ACCEL * Input.brake;
}
const drag = (c.DRAG * S.drag) * this.speed * Math.abs(this.speed) * 0.015;
const roll = c.ROLLING * S.drag * Math.sign(this.speed) * (spAbs > 0.1 ? 1 : 0);
accel -= drag + roll;
this.speed += accel * dt;
this.speed = clamp(this.speed, -c.MAX_REVERSE, c.MAX_SPEED * S.topMul * 1.05);
if (Input.handbrake && this.speed > 3) {
this.speed *= (1 - 1.9 * dt);
}
// --- Apply yaw from steering FIRST so velocity follows new heading ---
// Bicycle-style: turn rate ~ steer * speed / wheelbase
if (spAbs > 0.15 || Math.abs(Input.steer) > 0) {
const dir = this.speed >= -0.5 ? 1 : -1;
// Minimum effective for low-speed turning
const steerScale = clamp(spAbs / 6, 0.35, 1.0);
const hbBoost = Input.handbrake ? 1.45 : 1;
this.yaw += this.steerAngle * steerScale * dir * hbBoost * dt;
}
const forwardX = Math.sin(this.yaw);
const forwardZ = Math.cos(this.yaw);
const rightX = Math.cos(this.yaw);
const rightZ = -Math.sin(this.yaw);
// Desired velocity purely along forward axis
const targetFx = forwardX * this.speed;
const targetFz = forwardZ * this.speed;
// Grip blend — lower grip keeps more of previous velocity (slide/drift)
let grip = c.GRIP * S.grip;
if (Input.handbrake) grip = c.HANDBRAKE_GRIP * S.grip;
if (spAbs > 18 && Math.abs(this.steerAngle) > 0.5 && !Input.handbrake) {
grip = lerp(grip, c.DRIFT_GRIP * S.grip, clamp((spAbs - 18) / 28, 0, 1));
}
const blend = 1 - Math.exp(-grip * dt);
this.vx = lerp(this.vx, targetFx, blend);
this.vz = lerp(this.vz, targetFz, blend);
const fVel = this.vx * forwardX + this.vz * forwardZ;
const lVel = this.vx * rightX + this.vz * rightZ;
this.sliding = Math.abs(lVel);
// Soft pull signed speed toward forward velocity component
if (Math.abs(fVel) > 0.01) {
this.speed = lerp(this.speed, fVel, 0.2);
}
// Drift continuation yaw
if (this.sliding > 2.5) {
this.yaw += lVel * 0.025 * dt;
}
this.x += this.vx * dt;
this.z += this.vz * dt;
// Follow elevation
const targetY = q.elev;
this.y = lerp(this.y, targetY, 1 - Math.exp(-14 * dt));
const targetPitch = -Math.atan2(q.sample.ty || 0, 1) * 0.9;
this.pitch = lerp(this.pitch, targetPitch, 1 - Math.exp(-6 * dt));
const targetRoll = -this.steerAngle * 0.2 - lVel * 0.018;
this.roll = lerp(this.roll, clamp(targetRoll, -0.35, 0.35), 1 - Math.exp(-5 * dt));
// Soft outer boundary
const absL = Math.abs(q.lateral);
const limit = q.halfW + CFG.SHOULDER + 3;
if (absL > limit) {
const pushDir = q.lateral > 0 ? -1 : 1;
const pen = absL - limit;
this.x += q.sample.nx * pushDir * pen;
this.z += q.sample.nz * pushDir * pen;
const into = this.vx * q.sample.nx + this.vz * q.sample.nz;
if ((q.lateral > 0 && into > 0) || (q.lateral < 0 && into < 0)) {
this.vx -= q.sample.nx * into * 1.5;
this.vz -= q.sample.nz * into * 1.5;
this.speed *= 0.72;
if (this.collisionCool <= 0 && spAbs > 8) {
AudioSys.crash();
Particles.sparks(this.x, this.y + 0.5, this.z, 12);
this.collisionCool = 0.5;
}
}
}
this.collisionCool -= dt;
this.collideObstacles(spAbs);
this.simGear(dt);
this.syncMesh(dt);
// FX
if (this.sliding > 4 || (Input.handbrake && spAbs > 8)) {
this.skidTimer -= dt;
if (this.skidTimer <= 0) {
Particles.skid(this.x - forwardX * 1.1 + rightX * 0.7, this.y, this.z - forwardZ * 1.1 + rightZ * 0.7);
Particles.skid(this.x - forwardX * 1.1 - rightX * 0.7, this.y, this.z - forwardZ * 1.1 - rightZ * 0.7);
this.skidTimer = 0.04;
}
Particles.dust(this.x - forwardX * 1.5, this.y, this.z - forwardZ * 1.5, spAbs, 2);
}
if (surf.type !== 'asphalt' && spAbs > 5) {
Particles.dust(this.x, this.y, this.z, spAbs * 0.5, surf.type === 'mud' ? 3 : 1);
}
const driftAmt = clamp(this.sliding / 12, 0, 1) + (Input.handbrake && spAbs > 6 ? 0.5 : 0);
AudioSys.updateEngine(this.speed, Input.throttle, this.gear, this.rpm);
AudioSys.updateSkid(
driftAmt + (surf.type !== 'asphalt' ? 0.25 * clamp(spAbs / 30, 0, 1) : 0),
surf.type !== 'asphalt'
);
},
collideObstacles(spAbs) {
const r = CFG.CAR.COLLISION_RADIUS;
const bounce = (ox, oz, rad) => {
const dx = this.x - ox, dz = this.z - oz;
const d2 = dx * dx + dz * dz;
const minD = r + rad;
if (d2 < minD * minD && d2 > 0.0001) {
const d = Math.sqrt(d2);
const nx = dx / d, nz = dz / d;
const pen = minD - d;
this.x += nx * pen;
this.z += nz * pen;
const vn = this.vx * nx + this.vz * nz;
if (vn < 0) {
this.vx -= nx * vn * 1.6;
this.vz -= nz * vn * 1.6;
}
this.speed *= 0.5;
if (this.collisionCool <= 0 && spAbs > 5) {
AudioSys.crash();
Particles.sparks(this.x, this.y + 0.4, this.z, 14);
this.collisionCool = 0.45;
}
return true;
}
return false;
};
for (let i = 0; i < Track.rockPositions.length; i++) {
const o = Track.rockPositions[i];
bounce(o.x, o.z, 0.9 * (o.scale || 1));
}
for (let i = 0; i < Track.barrierPositions.length; i++) {
const o = Track.barrierPositions[i];
bounce(o.x, o.z, 0.7);
}
for (let i = 0; i < Track.treePositions.length; i++) {
const o = Track.treePositions[i];
bounce(o.x, o.z, 0.55);
}
},
simGear(dt) {
const sp = Math.abs(this.speed);
const ratios = [0, 10, 20, 30, 40, 50];
let g = 1;
for (let i = 1; i < ratios.length; i++) {
if (sp > ratios[i] * 0.92) g = i;
}
if (sp < 1) g = 0;
this.gear = g;
const lo = g <= 1 ? 0 : ratios[g - 1];
const hi = g === 0 ? 10 : (ratios[g] || 55);
const t = clamp((sp - lo) / (hi - lo + 0.01), 0, 1);
const targetRpm = g === 0 ? 800 : 900 + t * 5500 + Input.throttle * 400;
this.rpm = lerp(this.rpm, targetRpm, 1 - Math.exp(-4 * dt));
},
syncMesh(dt) {
if (!this.mesh) return;
this.mesh.position.set(this.x, this.y, this.z);
this.mesh.rotation.order = 'YXZ';
this.mesh.rotation.y = this.yaw;
this.mesh.rotation.x = this.pitch;
this.mesh.rotation.z = this.roll;
const spin = (this.speed * dt) / 0.35;
this.wheels.forEach((w) => {
w.mesh.rotation.x += spin;
if (w.front) w.mesh.rotation.y = this.steerAngle * 0.5;
});
const brakeOn = Input.brake > 0 || Input.handbrake;
this.taillights.forEach((tl) => {
tl.material.emissiveIntensity = brakeOn ? 1.4 : 0.5;
});
},
speedKmh() {
return Math.abs(this.speed) * 3.6 * 1.8;
}
};
// ═══════════════════════════════════════════════════════════
// WORLD / RENDER
// ═══════════════════════════════════════════════════════════
const World = {
scene: null, camera: null, renderer: null, sun: null,
camMode: 0,
camPos: new THREE.Vector3(),
camLook: new THREE.Vector3(),
init() {
const canvas = document.getElementById('gameCanvas');
this.renderer = new THREE.WebGLRenderer({
canvas, antialias: true, powerPreference: 'high-performance'
});
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
this.renderer.setClearColor(0x87b5d9);
this.scene = new THREE.Scene();
this.scene.fog = new THREE.Fog(0x9ec8e6, 70, 300);
this.camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.3, 500);
this.camPos.set(0, 8, 12);
this.camLook.set(0, 0, 0);
this.scene.add(new THREE.AmbientLight(0x9bb4c8, 0.55));
this.sun = new THREE.DirectionalLight(0xfff0d5, 0.95);
this.sun.position.set(60, 100, 40);
this.sun.castShadow = true;
this.sun.shadow.mapSize.set(2048, 2048);
this.sun.shadow.camera.near = 10;
this.sun.shadow.camera.far = 300;
this.sun.shadow.camera.left = -90;
this.sun.shadow.camera.right = 90;
this.sun.shadow.camera.top = 90;
this.sun.shadow.camera.bottom = -90;
this.sun.shadow.bias = -0.0005;
this.scene.add(this.sun);
this.scene.add(this.sun.target);
this.scene.add(new THREE.HemisphereLight(0x87ceeb, 0x3d5c2e, 0.35));
// Sky dome
const skyGeo = new THREE.SphereGeometry(420, 24, 16);
const skyMat = new THREE.ShaderMaterial({
side: THREE.BackSide,
depthWrite: false,
uniforms: {
topColor: { value: new THREE.Color(0x4a90c8) },
bottomColor: { value: new THREE.Color(0xc8dff0) },
offset: { value: 20 },
exponent: { value: 0.5 }
},
vertexShader: `
varying vec3 vWorldPos;
void main() {
vec4 wp = modelMatrix * vec4(position, 1.0);
vWorldPos = wp.xyz;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 topColor;
uniform vec3 bottomColor;
uniform float offset;
uniform float exponent;
varying vec3 vWorldPos;
void main() {
float h = normalize(vWorldPos + vec3(0.0, offset, 0.0)).y;
float t = max(pow(max(h, 0.0), exponent), 0.0);
gl_FragColor = vec4(mix(bottomColor, topColor, t), 1.0);
}
`
});
this.scene.add(new THREE.Mesh(skyGeo, skyMat));
// Grass ground tree surfaces
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(700, 700, 48, 48),
new THREE.MeshStandardMaterial({ color: 0x3d6b2e, roughness: 1, flatShading: true })
);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -0.6;
ground.receiveShadow = true;
const gpos = ground.geometry.attributes.position;
for (let i = 0; i < gpos.count; i++) {
const x = gpos.getX(i), y = gpos.getY(i);
gpos.setZ(i, Math.sin(x * 0.04) * Math.cos(y * 0.035) * 1.8);
}
gpos.needsUpdate = true;
ground.geometry.computeVertexNormals();
this.scene.add(ground);
this.buildTrackMesh();
this.buildScenery();
window.addEventListener('resize', () => this.onResize());
},
// Build a ribbon ribbon mesh where edges are left/right of centerline
makeRibbon(innerMul, outerMul, yOff, color, roughness) {
const samples = Track.samples;
const n = samples.length;
const positions = [];
const indices = [];
for (let i = 0; i < n; i++) {
const s = samples[i];
const hw = s.width * 0.5;
// Vertex 0 = left edge, 1 = right edge
positions.push(
s.x + s.nx * hw * innerMul, s.y + yOff, s.z + s.nz * hw * innerMul,
s.x - s.nx * hw * outerMul, s.y + yOff, s.z - s.nz * hw * outerMul
);
}
for (let i = 0; i < n; i++) {
const a = i * 2;
const b = a + 1;
const c = ((i + 1) % n) * 2;
const d = c + 1;
indices.push(a, c, b, b, c, d);
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geo.setIndex(indices);
geo.computeVertexNormals();
const mat = new THREE.MeshStandardMaterial({
color, roughness: roughness || 0.85, metalness: 0.05
});
const mesh = new THREE.Mesh(geo, mat);
mesh.receiveShadow = true;
return mesh;
},
buildTrackMesh() {
// Asphalt (main road)
this.scene.add(this.makeRibbon(1, 1, 0.06, 0x3a3a40, 0.9));
// Dirt shoulder — left strip (beyond left edge)
this.scene.add(this.buildShoulderStrip(1.0, 1.35, 0.03, 0x6b5b3a, true));
// Dirt shoulder — right strip
this.scene.add(this.buildShoulderStrip(1.0, 1.35, 0.03, 0x6b5b3a, false));
// Center line dashed
const lineMat = new THREE.MeshBasicMaterial({ color: 0xf0e68c });
const edgeMat = new THREE.MeshBasicMaterial({ color: 0xffffff });
const samples = Track.samples;
for (let i = 0; i < samples.length; i += 4) {
const s = samples[i];
const s2 = samples[(i + 1) % samples.length];
const len = Math.min(Math.hypot(s2.x - s.x, s2.z - s.z) * 1.3, 2.0);
const line = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.04, len), lineMat);
line.position.set(s.x, s.y + 0.09, s.z);
line.rotation.y = Math.atan2(s.tx, s.tz);
this.scene.add(line);
}
// Edge dashes
for (let i = 0; i < samples.length; i += 3) {
const s = samples[i];
const hw = s.width * 0.5;
for (const side of [-1, 1]) {
const e = new THREE.Mesh(new THREE.BoxGeometry(0.28, 0.05, 1.4), edgeMat);
e.position.set(
s.x + s.nx * side * (hw - 0.15),
s.y + 0.1,
s.z + s.nz * side * (hw - 0.15)
);
e.rotation.y = Math.atan2(s.tx, s.tz);
this.scene.add(e);
}
}
},
buildShoulderStrip(fromMul, toMul, yOff, color, leftSide) {
// Strip between fromMul and toMul of half-width on one side
const samples = Track.samples;
const n = samples.length;
const positions = [];
const indices = [];
for (let i = 0; i < n; i++) {
const s = samples[i];
const hw = s.width * 0.5;
const sign = leftSide ? 1 : -1;
const nxl = s.nx * sign, nzl = s.nz * sign;
positions.push(
s.x + nxl * hw * fromMul, s.y + yOff, s.z + nzl * hw * fromMul,
s.x + nxl * hw * toMul, s.y + yOff, s.z + nzl * hw * toMul
);
}
for (let i = 0; i < n; i++) {
const a = i * 2, b = a + 1;
const c = ((i + 1) % n) * 2, d = c + 1;
indices.push(a, c, b, b, c, d);
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geo.setIndex(indices);
geo.computeVertexNormals();
const mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({
color, roughness: 1, metalness: 0
}));
mesh.receiveShadow = true;
return mesh;
},
buildScenery() {
// Trees
Track.treePositions.forEach((t) => {
const g = new THREE.Group();
const trunk = new THREE.Mesh(
new THREE.CylinderGeometry(0.15 * t.scale, 0.22 * t.scale, 1.2 * t.scale, 5),
new THREE.MeshStandardMaterial({ color: 0x5d4037, flatShading: true })
);
trunk.position.y = 0.6 * t.scale;
trunk.castShadow = true;
g.add(trunk);
const leafColor = t.type === 0 ? 0x2d6a2e : 0x1b5e20;
const leaves = new THREE.Mesh(
new THREE.ConeGeometry(1.1 * t.scale, 2.2 * t.scale, 6),
new THREE.MeshStandardMaterial({ color: leafColor, flatShading: true })
);
leaves.position.y = 1.9 * t.scale;
leaves.castShadow = true;
g.add(leaves);
if (t.type === 0) {
const leaves2 = new THREE.Mesh(
new THREE.ConeGeometry(0.85 * t.scale, 1.5 * t.scale, 6),
new THREE.MeshStandardMaterial({ color: 0x388e3c, flatShading: true })
);
leaves2.position.y = 2.8 * t.scale;
leaves2.castShadow = true;
g.add(leaves2);
}
g.position.set(t.x, t.y, t.z);
this.scene.add(g);
});
// Rocks
const rockMat = new THREE.MeshStandardMaterial({ color: 0x6d6d6d, flatShading: true, roughness: 1 });
Track.rockPositions.forEach((r) => {
const mesh = new THREE.Mesh(new THREE.DodecahedronGeometry(0.55 * r.scale, 0), rockMat);
mesh.position.set(r.x, r.y + 0.28 * r.scale, r.z);
mesh.rotation.set(r.x * 0.3, r.z * 0.2, 0.4);
mesh.scale.set(1, 0.7 + (r.scale % 0.5), 1);
mesh.castShadow = true;
mesh.receiveShadow = true;
this.scene.add(mesh);
});
// Guard rails — oriented along the road
const barMat = new THREE.MeshStandardMaterial({ color: 0xb0b0b0, metalness: 0.6, roughness: 0.4, flatShading: true });
const postMat = new THREE.MeshStandardMaterial({ color: 0x555555, flatShading: true });
Track.barrierPositions.forEach((b) => {
const g = new THREE.Group();
const rail = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.32, 2.2), barMat);
rail.position.y = 0.55;
rail.castShadow = true;
g.add(rail);
for (let i = -1; i <= 1; i++) {
const post = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.7, 0.1), postMat);
post.position.set(0, 0.35, i * 0.85);
g.add(post);
}
g.position.set(b.x, b.y, b.z);
g.rotation.y = b.angle; // runs along track tangent
this.scene.add(g);
});
// Signs
Track.signPositions.forEach((s) => {
const g = new THREE.Group();
const pole = new THREE.Mesh(
new THREE.CylinderGeometry(0.05, 0.05, 2.0, 5),
new THREE.MeshStandardMaterial({ color: 0x444444, flatShading: true })
);
pole.position.y = 1.0;
g.add(pole);
const colors = [0xff6b00, 0xffd166, 0xe74c3c];
const board = new THREE.Mesh(
new THREE.BoxGeometry(1.1, 0.7, 0.08),
new THREE.MeshStandardMaterial({ color: colors[s.kind], flatShading: true })
);
board.position.y = 1.85;
g.add(board);
g.position.set(s.x, s.y, s.z);
// Face toward the track center (approx opposite side wall)
g.rotation.y = s.angle;
this.scene.add(g);
});
// Overhead banners — poles on shoulders, cloth high enough to clear the car
Track.bannerPositions.forEach((b) => {
const g = new THREE.Group();
const poleMat = new THREE.MeshStandardMaterial({ color: 0x333333, flatShading: true });
const half = b.width * 0.5;
const poleH = 6.5;
const pL = new THREE.Mesh(new THREE.CylinderGeometry(0.1, 0.12, poleH, 6), poleMat);
pL.position.set(-half, poleH * 0.5, 0);
const pR = pL.clone();
pR.position.x = half;
g.add(pL, pR);
// Crossbeam + banner fabric
const beam = new THREE.Mesh(
new THREE.BoxGeometry(b.width + 0.2, 0.18, 0.18),
poleMat
);
beam.position.y = poleH - 0.2;
g.add(beam);
const banner = new THREE.Mesh(
new THREE.BoxGeometry(b.width - 0.4, 1.0, 0.06),
new THREE.MeshStandardMaterial({ color: 0xe67e22, flatShading: true, side: THREE.DoubleSide })
);
banner.position.y = poleH - 0.85;
g.add(banner);
const stripe = new THREE.Mesh(
new THREE.BoxGeometry(b.width - 0.4, 0.18, 0.07),
new THREE.MeshBasicMaterial({ color: 0xffffff })
);
stripe.position.y = poleH - 0.85;
g.add(stripe);
g.position.set(b.x, b.y, b.z);
// Rotate so poles sit left/right of track (span the width on X after rotation)
g.rotation.y = b.angle;
this.scene.add(g);
});
// Start / finish arch — tall, spans full width, at dist=0
const start = Track.sampleAtDistance(0);
const arch = new THREE.Group();
const aMat = new THREE.MeshStandardMaterial({ color: 0x2c3e50, flatShading: true });
const aW = start.width + 1.5;
const archH = 6.0;
const colL = new THREE.Mesh(new THREE.BoxGeometry(0.45, archH, 0.45), aMat);
colL.position.set(-aW / 2, archH / 2, 0);
const colR = colL.clone();
colR.position.x = aW / 2;
const top = new THREE.Mesh(new THREE.BoxGeometry(aW + 0.45, 0.4, 0.5), aMat);
top.position.y = archH + 0.1;
arch.add(colL, colR, top);
// Chequered banner under crossbeam
const checkH = 0.9;
const checkY = archH - 0.55;
const checkBg = new THREE.Mesh(
new THREE.BoxGeometry(aW, checkH, 0.1),
new THREE.MeshBasicMaterial({ color: 0xffffff })
);
checkBg.position.y = checkY;
arch.add(checkBg);
const sqW = aW / 10;
for (let row = 0; row < 2; row++) {
for (let col = 0; col < 10; col++) {
if ((row + col) % 2 === 0) continue;
const sq = new THREE.Mesh(
new THREE.BoxGeometry(sqW, checkH / 2, 0.12),
new THREE.MeshBasicMaterial({ color: 0x111111 })
);
sq.position.set(-aW / 2 + sqW / 2 + col * sqW, checkY + (row === 0 ? checkH / 4 : -checkH / 4), 0);
arch.add(sq);
}
}
arch.position.set(start.x, start.y, start.z);
// Span left–right across track
arch.rotation.y = Math.atan2(start.tx, start.tz);
this.scene.add(arch);
// Spectators (clustered off the road, not floating mid-track)
for (let i = 0; i < 28; i++) {
const dist = (0.08 + Math.random() * 0.84) * Track.totalLength;
const s = Track.sampleAtDistance(dist);
const side = Math.random() > 0.5 ? 1 : -1;
const off = s.width * 0.5 + 9 + Math.random() * 5;
const person = new THREE.Group();
const bodyC = new THREE.Mesh(
new THREE.BoxGeometry(0.35, 0.55, 0.25),
new THREE.MeshStandardMaterial({
color: new THREE.Color().setHSL(Math.random(), 0.55, 0.48),
flatShading: true
})
);
bodyC.position.y = 0.85;
const head = new THREE.Mesh(
new THREE.BoxGeometry(0.24, 0.24, 0.24),
new THREE.MeshStandardMaterial({ color: 0xffccaa, flatShading: true })
);
head.position.y = 1.28;
person.add(bodyC, head);
person.position.set(s.x + s.nx * side * off, s.y, s.z + s.nz * side * off);
this.scene.add(person);
}
},
updateCamera(dt) {
const fx = Math.sin(Car.yaw);
const fz = Math.cos(Car.yaw);
let back, up, lookAhead;
if (this.camMode === 0) {
back = 8.5; up = 3.2; lookAhead = 12;
} else {
back = 16; up = 10; lookAhead = 6;
}
const sp = Math.abs(Car.speed);
back += clamp(sp * 0.08, 0, 4);
const ideal = new THREE.Vector3(
Car.x - fx * back,
Car.y + up,
Car.z - fz * back
);
const lag = this.camMode === 0 ? 5.5 : 2.5;
this.camPos.lerp(ideal, 1 - Math.exp(-lag * dt));
const look = new THREE.Vector3(
Car.x + fx * lookAhead,
Car.y + 1.2,
Car.z + fz * lookAhead
);
this.camLook.lerp(look, 1 - Math.exp(-7 * dt));
this.camera.position.copy(this.camPos);
this.camera.lookAt(this.camLook);
const targetFov = 58 + clamp(sp * 0.35, 0, 14);
this.camera.fov = lerp(this.camera.fov, targetFov, 1 - Math.exp(-3 * dt));
this.camera.updateProjectionMatrix();
this.sun.position.set(Car.x + 60, 100, Car.z + 40);
this.sun.target.position.set(Car.x, Car.y, Car.z);
this.sun.target.updateMatrixWorld();
},
onResize() {
const w = window.innerWidth, h = window.innerHeight;
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
this.renderer.setSize(w, h);
},
render() {
this.renderer.render(this.scene, this.camera);
}
};
// ═══════════════════════════════════════════════════════════
// RACE LOGIC
// ═══════════════════════════════════════════════════════════
const Race = {
state: 'title',
lap: 1,
checkpoint: 0,
raceTime: 0,
lapTime: 0,
bestLap: Infinity,
lapTimes: [],
countdown: 3,
countdownTimer: 0,
start() {
Car.reset();
this.lap = 1;
this.checkpoint = 0;
this.raceTime = 0;
this.lapTime = 0;
this.bestLap = Infinity;
this.lapTimes = [];
this.countdown = 3;
this.countdownTimer = 1;
this.state = 'countdown';
HUD.hideOverlay();
HUD.showCountdown('3');
AudioSys.countdownBeep(3);
},
update(dt) {
if (this.state === 'countdown') {
this.countdownTimer -= dt;
if (this.countdownTimer <= 0) {
this.countdown--;
if (this.countdown > 0) {
this.countdownTimer = 1;
HUD.showCountdown(String(this.countdown));
AudioSys.countdownBeep(this.countdown);
} else if (this.countdown === 0) {
this.countdownTimer = 0.8;
HUD.showCountdown('GO!', true);
AudioSys.countdownBeep(0);
this.state = 'racing';
}
}
Car.syncMesh(dt);
return;
}
if (this.state === 'racing') {
Car.update(dt);
this.raceTime += dt;
this.lapTime += dt;
this.checkGates();
if (this.countdownTimer > 0) {
this.countdownTimer -= dt;
if (this.countdownTimer <= 0) HUD.hideCountdown();
}
} else if (this.state === 'finished') {
Car.speed *= (1 - 2 * dt);
Car.vx *= (1 - 2 * dt);
Car.vz *= (1 - 2 * dt);
Car.syncMesh(dt);
}
},
checkGates() {
const cps = Track.checkpoints;
const next = cps[this.checkpoint];
const dx = Car.x - next.x;
const dz = Car.z - next.z;
const along = dx * next.tx + dz * next.tz;
const lateral = Math.abs(-dx * next.tz + dz * next.tx);
if (lateral < next.halfW + 5 && along > -4 && along < 10) {
const fwd = Math.sin(Car.yaw) * next.tx + Math.cos(Car.yaw) * next.tz;
if (fwd > -0.3 || Math.abs(Car.speed) < 2) {
this.checkpoint++;
if (this.checkpoint >= cps.length) {
this.checkpoint = 0;
this.onLapComplete();
}
}
}
},
onLapComplete() {
this.lapTimes.push(this.lapTime);
if (this.lapTime < this.bestLap) this.bestLap = this.lapTime;
this.lapTime = 0;
if (this.lap >= CFG.TOTAL_LAPS) {
this.state = 'finished';
HUD.hideCountdown();
AudioSys.finishTone();
HUD.showFinish(this.raceTime, this.bestLap, this.lapTimes);
} else {
this.lap++;
}
},
togglePause() {
if (this.state === 'racing') {
this.state = 'paused';
HUD.showPause();
} else if (this.state === 'paused') {
this.state = 'racing';
HUD.hideOverlay();
}
}
};
// ═══════════════════════════════════════════════════════════
// HUD / MINIMAP
// ═══════════════════════════════════════════════════════════
const HUD = {
speedVal: null, gearRpm: null, timer: null, lapInfo: null, bestLap: null,
surfaceHint: null, countdownEl: null, overlay: null, menuBox: null,
mm: null, mmCtx: null, mmPts: [], mmMap: null,
init() {
this.speedVal = document.getElementById('speedVal');
this.gearRpm = document.getElementById('gearRpm');
this.timer = document.getElementById('timer');
this.lapInfo = document.getElementById('lapInfo');
this.bestLap = document.getElementById('bestLap');
this.surfaceHint = document.getElementById('surfaceHint');
this.countdownEl = document.getElementById('countdown');
this.overlay = document.getElementById('overlay');
this.menuBox = document.getElementById('menuBox');
this.mm = document.getElementById('minimap');
this.mmCtx = this.mm.getContext('2d');
this.prepMinimap();
},
prepMinimap() {
let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
Track.samples.forEach((s) => {
minX = Math.min(minX, s.x); maxX = Math.max(maxX, s.x);
minZ = Math.min(minZ, s.z); maxZ = Math.max(maxZ, s.z);
});
const pad = 12;
const w = 160 - pad * 2, h = 160 - pad * 2;
const sc = Math.min(w / (maxX - minX || 1), h / (maxZ - minZ || 1));
this.mmMap = {
minX, minZ, sc, pad,
offX: (w - (maxX - minX) * sc) / 2,
offZ: (h - (maxZ - minZ) * sc) / 2
};
this.mmPts = Track.samples.map((s) => ({
x: pad + this.mmMap.offX + (s.x - minX) * sc,
y: pad + this.mmMap.offZ + (s.z - minZ) * sc
}));
},
worldToMm(x, z) {
const m = this.mmMap;
return {
x: m.pad + m.offX + (x - m.minX) * m.sc,
y: m.pad + m.offZ + (z - m.minZ) * m.sc
};
},
update() {
this.speedVal.textContent = String(Math.round(Car.speedKmh()));
const gearLabel = Car.gear === 0 ? 'N' : String(Car.gear);
this.gearRpm.textContent = gearLabel + ' · ' + Math.round(Car.rpm) + ' RPM';
this.timer.textContent = fmtTime(Race.raceTime);
this.lapInfo.textContent = 'Lap ' + Race.lap + ' / ' + CFG.TOTAL_LAPS;
this.bestLap.textContent = 'Best: ' + fmtTime(Race.bestLap === Infinity ? -1 : Race.bestLap);
this.surfaceHint.textContent = CFG.SURFACE[Car.lastSurface].name;
this.drawMinimap();
},
drawMinimap() {
const ctx = this.mmCtx;
ctx.clearRect(0, 0, 160, 160);
ctx.fillStyle = 'rgba(10,20,10,0.5)';
ctx.fillRect(0, 0, 160, 160);
ctx.beginPath();
this.mmPts.forEach((p, i) => { if (i === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y); });
ctx.closePath();
ctx.strokeStyle = 'rgba(255,255,255,0.25)';
ctx.lineWidth = 6;
ctx.stroke();
ctx.strokeStyle = '#888';
ctx.lineWidth = 2.5;
ctx.stroke();
const st = this.mmPts[0];
ctx.fillStyle = '#fff';
ctx.fillRect(st.x - 2, st.y - 2, 4, 4);
Track.checkpoints.forEach((cp, i) => {
const p = this.worldToMm(cp.x, cp.z);
ctx.fillStyle = i === Race.checkpoint ? '#ffd166' : 'rgba(255,255,255,0.2)';
ctx.beginPath();
ctx.arc(p.x, p.y, i === Race.checkpoint ? 3 : 1.5, 0, Math.PI * 2);
ctx.fill();
});
const pp = this.worldToMm(Car.x, Car.z);
ctx.save();
ctx.translate(pp.x, pp.y);
ctx.rotate(Car.yaw);
ctx.fillStyle = '#ff4d4d';
ctx.beginPath();
ctx.moveTo(0, -5);
ctx.lineTo(3.5, 4);
ctx.lineTo(-3.5, 4);
ctx.closePath();
ctx.fill();
ctx.restore();
},
showCountdown(text, isGo) {
this.countdownEl.textContent = text;
this.countdownEl.classList.add('show');
this.countdownEl.classList.toggle('go', !!isGo);
},
hideCountdown() { this.countdownEl.classList.remove('show'); },
showPause() {
this.menuBox.innerHTML = `
<h1>PAUSED</h1>
<p>Take a breath — the stage is waiting.</p>
<button class="btn" id="btnResume">RESUME</button>
<button class="btn secondary" id="btnRestart">RESTART</button>
`;
this.overlay.classList.add('visible');
document.getElementById('btnResume').onclick = () => Race.togglePause();
document.getElementById('btnRestart').onclick = () => Race.start();
},
showFinish(total, best, laps) {
const rows = laps.map((t, i) => `Lap ${i + 1}: <strong>${fmtTime(t)}</strong>`).join('<br>');
this.menuBox.innerHTML = `
<h1>FINISH!</h1>
<div class="stats">
Total: <strong>${fmtTime(total)}</strong><br>
Best lap: <strong>${fmtTime(best)}</strong><br>
${rows}
</div>
<button class="btn" id="btnAgain">RACE AGAIN</button>
`;
this.overlay.classList.add('visible');
document.getElementById('btnAgain').onclick = () => Race.start();
},
hideOverlay() { this.overlay.classList.remove('visible'); }
};
// ═══════════════════════════════════════════════════════════
// MAIN LOOP
// ═══════════════════════════════════════════════════════════
const Game = {
last: 0,
running: false,
init() {
Track.build();
World.init();
Car.buildMesh(World.scene);
Particles.init(World.scene);
Input.init();
HUD.init();
Car.reset();
document.getElementById('startBtn').addEventListener('click', () => {
document.getElementById('titleBoot').style.display = 'none';
AudioSys.init();
AudioSys.resume();
Race.start();
this.running = true;
this.last = performance.now();
requestAnimationFrame((t) => this.loop(t));
});
window.addEventListener('keydown', (e) => {
if (e.code === 'KeyP') {
if (Race.state === 'racing' || Race.state === 'paused') Race.togglePause();
}
if (e.code === 'KeyR') {
if (Race.state !== 'title') Race.start();
}
if (e.code === 'KeyC') {
World.camMode = World.camMode === 0 ? 1 : 0;
}
AudioSys.resume();
});
World.updateCamera(0.016);
World.render();
HUD.update();
this.idleTitle();
},
idleTitle() {
if (this.running) return;
const t = performance.now() * 0.00015;
const s = Track.sampleAtDistance((t * 40) % Track.totalLength);
World.camera.position.set(
s.x - s.tx * 14 + s.nx * 7,
s.y + 7,
s.z - s.tz * 14 + s.nz * 7
);
World.camera.lookAt(s.x, s.y + 1, s.z);
World.render();
requestAnimationFrame(() => this.idleTitle());
},
loop(now) {
if (!this.running) return;
let dt = (now - this.last) / 1000;
this.last = now;
dt = Math.min(dt, 0.05);
if (Race.state !== 'paused' && Race.state !== 'title') {
Race.update(dt);
Particles.update(dt);
World.updateCamera(dt);
}
HUD.update();
World.render();
requestAnimationFrame((t) => this.loop(t));
}
};
if (typeof THREE === 'undefined') {
document.body.innerHTML = '<p style="color:#fff;padding:40px;font-family:sans-serif">Failed to load Three.js from CDN. Check your internet connection.</p>';
} else {
Game.init();
}
})();
</script>
</body>
</html>