Rally
Claude haiku 5.5 · 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
Task text captured before this run. Effective runtime system instructions were not captured.
Generation prompt · 03be86bd82
Create a fun, fully functional 3D rally racing game using HTML5 Canvas, Three.js, vanilla JavaScript, and CSS. No external image, audio, or model files are allowed—everything must be generated procedurally or synthesized at runtime. Three.js may be loaded from a CDN via a single `<script>` tag; no other external libraries are allowed. ## Core Gameplay - The game is a 3D behind-the-car rally racer. - The player drives a rally car on a winding track through hills, forests, and open terrain. - Goal: complete 3 laps as fast as possible while staying mostly on the track. - Include a start countdown (3-2-1-GO!), a live timer, lap times, and a best-lap display. ## 3D Renderer (Three.js) - Use Three.js to render the scene, camera, lighting, geometry, and materials. - Render the game in 3D perspective from a chase camera behind the car. - Include a sky/horizon, ground plane, track surface, road shoulders, and 3D scenery (trees, rocks, signs, simple crowd/banners). - Use distance fog so distant objects fade in cleanly. - Use appropriate lighting (directional sun + ambient) and simple shadows. - Build a procedural low-poly rally car visible from behind (body, wheels, spoiler, headlights, taillights). - Keep performance smooth on modern browsers targeting 60fps. ## Camera - Chase camera follows the car from behind and slightly above. - Camera should lag slightly on acceleration/turning for a dynamic feel. - Optional: allow toggling between chase cam and a higher cinematic cam with the C key. ## Track Design - Use a pre-defined looped 3D track stored as a list of control points with width and elevation data (or generate one procedurally). - The track must have curves, hills, and dips. - Include invisible checkpoint gates around the track to validate lap progression and prevent shortcut cheating. - Track surface types: asphalt/dirt (fast), grass/gravel (slow), and mud/shallow water (very slow). - Place 3D trees, rocks, signs, and barriers around the track; hitting obstacles slows or bounces the player. ## Car Physics (make it feel fun) - Accelerate, brake, and reverse with keyboard or touch controls. - Speed-dependent steering (tight at low speed, stable at high speed). - Drifting/slide when turning at high speed, especially on dirt or grass. - Off-track surfaces reduce grip and top speed. - Collisions with obstacles and track boundaries bounce the car and kill momentum. - Add skid marks, dust clouds, and sparks when drifting or off-road. ## Controls - Keyboard: Arrow keys or WASD to drive; Space to handbrake; R to restart; P to pause; C to toggle camera view (optional). - Touch (mobile): on-screen directional pad and gas/brake/handbrake buttons. ## Visuals - Full-screen canvas that scales responsively while preserving aspect ratio. - 3D scenery with simple shapes: low-poly trees, rocks, billboards, guard rails, banners. - Particle effects: dust clouds, skid marks, sparks on collision (use Three.js sprites/points or simple meshes). - Minimap/HUD overlay in a corner showing the full track layout and the player’s position. ## UI / HUD - Show speedometer, current gear/RPM (simulated), current lap, total laps, current time, best lap. - Countdown overlay at race start. - Pause menu with Resume and Restart. - Finish screen showing final time, best lap, and a Restart button. ## Audio (synthesized with Web Audio API only) - Engine pitch and volume change with speed and gear. - Short beeps for the countdown and a finishing tone. - Crash/thud sound on collisions. - Skid/gravel sound when drifting or driving off-road. ## Code Quality - Keep game state, input handling, physics, camera, rendering, and UI cleanly separated. - Avoid global namespace pollution; use a single main object/module pattern where possible. - Add comments explaining key sections. - Ensure the game loads Three.js from a CDN and works in any modern browser with an internet connection. No placeholders.
Inspect original source 65,765 bytes · SHA-256 a4406595c98a
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>Rally Stage 3D — 3 Laps</title>
<style>
html,body{margin:0;height:100%;background:#000;overflow:hidden;font-family:"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:#fff;user-select:none;-webkit-user-select:none;touch-action:none}
#gl{position:fixed;inset:0;width:100vw;height:100vh;display:block}
.panel{position:absolute;background:rgba(8,12,20,.55);border:1px solid rgba(255,255,255,.12);border-radius:10px;backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px)}
#tl{left:14px;top:12px;padding:10px 14px;min-width:200px}
#tl .big{font-size:20px;font-weight:700;letter-spacing:.5px}
.row{display:flex;justify-content:space-between;gap:14px;font-size:14px;line-height:1.55;font-variant-numeric:tabular-nums}
.row span:first-child{opacity:.65;text-transform:uppercase;font-size:11px;letter-spacing:1px;align-self:center}
#mini{position:absolute;right:14px;top:14px;width:190px;height:190px;border-radius:10px;background:rgba(8,12,20,.5);border:1px solid rgba(255,255,255,.15)}
#dash{right:14px;bottom:14px;padding:10px 14px 12px;width:230px}
#speed{font-size:46px;font-weight:800;line-height:1;font-variant-numeric:tabular-nums;text-align:right}
#speed small{font-size:13px;font-weight:600;opacity:.7;margin-left:4px}
#gearRow{display:flex;justify-content:space-between;align-items:center;margin-top:8px;font-size:13px}
#gear{font-size:26px;font-weight:800;width:38px;text-align:center;background:rgba(255,255,255,.1);border-radius:6px}
#rpmBar{flex:1;height:10px;margin-left:12px;background:rgba(255,255,255,.12);border-radius:5px;overflow:hidden}
#rpmFill{height:100%;width:0;background:linear-gradient(90deg,#4ade80,#facc15 70%,#ef4444);border-radius:5px}
#hint{left:14px;bottom:14px;padding:7px 11px;font-size:12px;opacity:.75;line-height:1.5}
#toast{position:absolute;left:50%;top:18%;transform:translateX(-50%);font-size:22px;font-weight:800;letter-spacing:2px;text-shadow:0 2px 8px #000;opacity:0;transition:opacity .25s;pointer-events:none;white-space:nowrap}
.overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.35)}
.card{background:rgba(10,14,24,.88);border:1px solid rgba(255,255,255,.15);border-radius:14px;padding:28px 34px;text-align:center;min-width:300px;box-shadow:0 10px 40px rgba(0,0,0,.5)}
.card h1{margin:0 0 6px;font-size:34px;letter-spacing:2px;font-weight:900;color:#facc15}
.card h2{margin:0 0 14px;font-size:22px;letter-spacing:1px}
.card p{margin:8px 0;opacity:.8;font-size:14px;line-height:1.5}
.stat{display:flex;justify-content:space-between;gap:30px;font-size:17px;padding:5px 0;border-bottom:1px solid rgba(255,255,255,.08);font-variant-numeric:tabular-nums}
.btn{display:inline-block;margin:8px 6px 0;padding:11px 22px;font-size:16px;font-weight:700;color:#111;background:#facc15;border:0;border-radius:8px;cursor:pointer;letter-spacing:.5px;pointer-events:auto}
.btn:hover{filter:brightness(1.08)}
.btn.alt{background:#e5e7eb}
#countdown{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;pointer-events:none}
#cdText{font-size:130px;font-weight:900;text-shadow:0 6px 24px #000,0 0 40px rgba(250,204,21,.6);font-style:italic}
#touch{position:absolute;left:0;right:0;bottom:0;top:0;pointer-events:none}
.tbtn{position:absolute;pointer-events:auto;width:82px;height:82px;border-radius:50%;background:rgba(255,255,255,.14);border:2px solid rgba(255,255,255,.35);color:#fff;font-size:13px;font-weight:700;display:flex;align-items:center;justify-content:center;letter-spacing:.5px;-webkit-tap-highlight-color:transparent;touch-action:none}
.tbtn.on{background:rgba(250,204,21,.5)}
.hidden{display:none!important}
@media (max-width:700px){
#tl{min-width:0;padding:7px 10px;font-size:12px}
#tl .big{font-size:16px}
#mini{width:120px;height:120px;top:auto;bottom:150px;right:10px}
#dash{width:170px;right:10px;bottom:10px}
#speed{font-size:36px}
#hint{display:none}
.card{min-width:0;width:82vw;padding:18px}
.card h1{font-size:26px}
#cdText{font-size:90px}
}
</style>
</head>
<body>
<canvas id="gl"></canvas>
<div id="tl" class="panel">
<div class="row"><span>Lap</span><span class="big" id="lapTxt">1 / 3</span></div>
<div class="row"><span>Time</span><span id="timeTxt">00:00.00</span></div>
<div class="row"><span>Lap time</span><span id="curLapTxt">--:--.--</span></div>
<div class="row"><span>Best</span><span id="bestTxt" style="color:#facc15">--:--.--</span></div>
<div class="row"><span>Last</span><span id="lastTxt">--:--.--</span></div>
</div>
<canvas id="mini" width="380" height="380"></canvas>
<div id="dash" class="panel">
<div id="speed">0<small>km/h</small></div>
<div id="gearRow"><span>RPM</span><div id="rpmBar"><div id="rpmFill"></div></div><div id="gear">N</div></div>
</div>
<div id="hint" class="panel">WASD / Arrows drive · Space handbrake · C camera · P pause · R restart</div>
<div id="toast"></div>
<div id="countdown" class="hidden"><div id="cdText">3</div></div>
<div id="titleOv" class="overlay">
<div class="card">
<h1>RALLY STAGE 3D</h1>
<h2>Three laps. Stay on the road.</h2>
<p>Drive the procedural rally car through hills, forests and mud.<br>Invisible gates validate every lap — no shortcuts.</p>
<button class="btn" id="startBtn">START RACE</button>
</div>
</div>
<div id="pauseOv" class="overlay hidden">
<div class="card">
<h2>PAUSED</h2>
<button class="btn" id="resumeBtn">Resume</button>
<button class="btn alt" id="restartBtn1">Restart</button>
</div>
</div>
<div id="finishOv" class="overlay hidden">
<div class="card">
<h1>FINISH!</h1>
<h2>Stage complete</h2>
<div class="stat"><span>Final time</span><span id="finalTime">--</span></div>
<div class="stat"><span>Best lap</span><span id="finalBest">--</span></div>
<div class="stat"><span>Lap 1</span><span id="fl1">--</span></div>
<div class="stat"><span>Lap 2</span><span id="fl2">--</span></div>
<div class="stat"><span>Lap 3</span><span id="fl3">--</span></div>
<button class="btn" id="restartBtn2">Restart</button>
</div>
</div>
<div id="touch" class="hidden">
<button class="tbtn" data-k="left" style="left:18px;bottom:150px">◀</button>
<button class="tbtn" data-k="right" style="left:118px;bottom:150px">▶</button>
<button class="tbtn" data-k="hb" style="right:132px;bottom:118px;width:70px;height:70px">HAND</button>
<button class="tbtn" data-k="brake" style="right:150px;bottom:24px;width:90px;height:90px">BRAKE</button>
<button class="tbtn" data-k="gas" style="right:18px;bottom:24px;width:100px;height:100px">GAS</button>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
/* =====================================================================
RALLY STAGE 3D — single-file procedural rally racer.
Everything (track, terrain, scenery, car, particles, sounds) is generated
at runtime. Only external dependency: Three.js from the CDN.
Sections:
0. config & utilities 1. track generation & terrain queries
2. 3D scene building 3. car model, particles, skid marks
4. Web Audio synthesis 5. input (keyboard + touch)
6. physics & race logic 7. camera, HUD, minimap, main loop
===================================================================== */
(function () {
'use strict';
const THREE = window.THREE;
if (!THREE) {
document.body.insertAdjacentHTML('beforeend',
'<div class="overlay"><div class="card"><h2>Three.js failed to load</h2><p>Check your internet connection and reload.</p></div></div>');
return;
}
/* ---------- 0. CONFIG & UTILITIES ---------- */
const CFG = {
NCP: 28, // control points of the closed spline
SUB: 50, // spline samples per control segment (=> 1400 samples)
HW: 6.5, // road half-width
RAIL: 11.5, // guard-rail offset from centre line (hard boundary)
LAPS: 3,
VMAX: 62, // m/s top speed on asphalt
ENGINE: 17, // m/s^2 acceleration at low speed
BRAKE: 34,
REV_MAX: 14,
CAR_R: 1.1, // collision radius of the car
GRID_BACK: 12, // grid position in samples behind the finish line
GATES_PER_LAP: 6, // 5 invisible checkpoints + finish line
};
const SURF = {
asphalt: { name: 'ASPHALT', spd: 1.00, grip: 1.00, col: 0x4a4d54 },
dirt: { name: 'DIRT', spd: 0.92, grip: 0.75, col: 0x9c7a52 },
mud: { name: 'MUD', spd: 0.55, grip: 0.50, col: 0x5e4630 },
grass: { name: 'GRASS', spd: 0.70, grip: 0.62, col: 0x6a9a3a },
};
const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
const lerp = (a, b, t) => a + (b - a) * t;
const smooth = (a, b, x) => { const t = clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); };
function rng(seed) { // deterministic PRNG (mulberry32)
let s = seed >>> 0;
return () => { s = (s + 0x6D2B79F5) >>> 0; let t = s; t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
}
/* ---------- 1. TRACK GENERATION & TERRAIN QUERIES ---------- */
// Track data, filled by buildTrack(). Arrays are per centreline sample.
const TR = {};
// Procedural control point k on a star-shaped loop with hills and dips.
function cpAt(k) {
const n = CFG.NCP, th = 2 * Math.PI * k / n;
const r = 300 + 55 * Math.sin(2 * th + 0.7) + 35 * Math.sin(3 * th + 1.9) - 18 * Math.cos(5 * th);
return [Math.cos(th) * r, 10 * Math.sin(3 * th + 0.4) + 6 * Math.cos(5 * th + 1.0), Math.sin(th) * r * 0.8];
}
// Surface type by fraction of the lap
function surfaceAt(f) {
if (f > 0.10 && f < 0.20) return 'dirt';
if (f > 0.36 && f < 0.44) return 'mud';
if (f > 0.58 && f < 0.68) return 'dirt';
if (f > 0.80 && f < 0.86) return 'mud';
return 'asphalt';
}
// Builds the centreline by uniform Catmull-Rom interpolation of the closed loop.
function buildTrack() {
const n = CFG.NCP, M = CFG.SUB, N = n * M;
const cp = []; for (let k = 0; k < n; k++) cp.push(cpAt(k));
const SX = new Float32Array(N), SY = new Float32Array(N), SZ = new Float32Array(N);
const cr = (a, b, c, d, t) => {
const t2 = t * t, t3 = t2 * t;
return 0.5 * ((2 * b) + (-a + c) * t + (2 * a - 5 * b + 4 * c - d) * t2 + (-a + 3 * b - 3 * c + d) * t3);
};
for (let i = 0; i < n; i++) {
const P0 = cp[(i - 1 + n) % n], P1 = cp[i], P2 = cp[(i + 1) % n], P3 = cp[(i + 2) % n];
for (let j = 0; j < M; j++) {
const t = j / M, s = i * M + j;
SX[s] = cr(P0[0], P1[0], P2[0], P3[0], t);
SY[s] = cr(P0[1], P1[1], P2[1], P3[1], t);
SZ[s] = cr(P0[2], P1[2], P2[2], P3[2], t);
}
}
const TX = new Float32Array(N), TZ = new Float32Array(N), TURN = new Float32Array(N), DIST = new Float32Array(N);
const SUR = new Array(N);
for (let s = 0; s < N; s++) {
const a = (s - 1 + N) % N, b = (s + 1) % N;
const dx = SX[b] - SX[a], dz = SZ[b] - SZ[a], l = Math.hypot(dx, dz) || 1;
TX[s] = dx / l; TZ[s] = dz / l;
SUR[s] = surfaceAt(s / N);
}
let acc = 0;
for (let s = 0; s < N; s++) {
DIST[s] = acc; const b = (s + 1) % N;
acc += Math.hypot(SX[b] - SX[s], SZ[b] - SZ[s]);
}
// Turn direction (>0 means the road bends toward +perp), used for turn-warning signs
for (let s = 0; s < N; s++) {
const a = (s - 10 + N) % N, b = (s + 10) % N;
TURN[s] = (TX[b] - TX[a]) * (-TZ[s]) + (TZ[b] - TZ[a]) * TX[s];
}
Object.assign(TR, { N, SX, SY, SZ, TX, TZ, TURN, DIST, SUR, LEN: acc });
}
// Point on the road offset: ld > 0 is the +perpendicular side (-TZ, TX)
function ptAt(s, ld) {
return [TR.SX[s] - ld * TR.TZ[s], TR.SZ[s] + ld * TR.TX[s]];
}
// Nearest centreline sample. With a hint, searches a window (fast, used by the car);
// falls back to a full search if the window result is far away.
function nearestIdx(x, z, hint) {
const { N, SX, SZ } = TR;
if (hint !== undefined && hint !== null) {
let best = Infinity, bi = hint;
for (let k = -90; k <= 90; k++) {
const i = ((hint + k) % N + N) % N;
const dx = SX[i] - x, dz = SZ[i] - z, d = dx * dx + dz * dz;
if (d < best) { best = d; bi = i; }
}
if (best < 400) return bi;
}
let best = Infinity, bi = 0;
for (let i = 0; i < N; i++) {
const dx = SX[i] - x, dz = SZ[i] - z, d = dx * dx + dz * dz;
if (d < best) { best = d; bi = i; }
}
return bi;
}
// Signed lateral offset from the centreline (positive = left of forward direction)
function lateralOf(x, z, i) {
return (x - TR.SX[i]) * (-TR.TZ[i]) + (z - TR.SZ[i]) * TR.TX[i];
}
// Base rolling terrain (hills & valleys)
function baseH(x, z) {
return 9 * Math.sin(x * 0.011 + 1.3) * Math.cos(z * 0.013)
+ 6 * Math.sin(x * 0.027 - z * 0.02)
+ 4 * Math.cos(z * 0.05 + x * 0.03)
+ 3 * Math.sin(x * 0.08 + z * 0.07);
}
// Ground height: flattened to the road near the track, blending into hills outside
function groundAt(x, z, i, ld) {
const k = smooth(CFG.HW + 3.5, CFG.HW + 30, Math.abs(ld));
return TR.SY[i] * (1 - k) + baseH(x, z) * k;
}
// Full query for a world point. Returns a shared object (read it before the next call).
const _q = { i: 0, ld: 0, y: 0, surf: null, d: 0 };
function queryPoint(x, z, hint) {
const i = nearestIdx(x, z, hint);
const ld = lateralOf(x, z, i);
const d = Math.abs(ld);
let surf;
if (d <= CFG.HW) surf = SURF[TR.SUR[i]];
else if (d < CFG.RAIL) surf = TR.SUR[i] === 'mud' ? SURF.mud : SURF.grass;
else surf = SURF.grass;
_q.i = i; _q.ld = ld; _q.d = d; _q.surf = surf; _q.y = groundAt(x, z, i, ld);
return _q;
}
/* ---------- 2. 3D SCENE BUILDING ---------- */
let renderer, scene, camera, sun, hemi, skyMesh;
const WORLD = { posts: null, crowd: null };
const OBST = []; // collidable circular obstacles {x,z,r,key}
function initRenderer() {
const canvas = document.getElementById('gl');
renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(window.innerWidth, window.innerHeight, false);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
scene = new THREE.Scene();
scene.fog = new THREE.Fog(0xbfd9ea, 170, 900);
camera = new THREE.PerspectiveCamera(62, window.innerWidth / window.innerHeight, 0.5, 2500);
// Lighting: hemisphere ambient + directional sun with shadows
hemi = new THREE.HemisphereLight(0xd6ecff, 0x4a5a33, 0.85);
scene.add(hemi);
sun = new THREE.DirectionalLight(0xfff1d6, 1.05);
sun.position.set(120, 220, 80);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
const sc = sun.shadow.camera;
sc.left = -110; sc.right = 110; sc.top = 110; sc.bottom = -110; sc.near = 10; sc.far = 700;
sun.shadow.bias = -0.0008;
scene.add(sun);
scene.add(sun.target);
buildSky();
}
// Gradient sky dome (vertex coloured, not affected by fog)
function buildSky() {
const geo = new THREE.SphereGeometry(2400, 32, 16);
const cols = [], p = geo.attributes.position;
const top = new THREE.Color(0x2f6fb5), mid = new THREE.Color(0x8cc4e6), hor = new THREE.Color(0xd9ecf5);
const c = new THREE.Color();
for (let i = 0; i < p.count; i++) {
const h = clamp(p.getY(i) / 2400, 0, 1);
if (h < 0.25) c.copy(hor).lerp(mid, h / 0.25);
else c.copy(mid).lerp(top, clamp((h - 0.25) / 0.5, 0, 1));
cols.push(c.r, c.g, c.b);
}
geo.setAttribute('color', new THREE.Float32BufferAttribute(cols, 3));
skyMesh = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.BackSide, fog: false }));
scene.add(skyMesh);
// Soft sun glow sprite
const cv = document.createElement('canvas'); cv.width = cv.height = 128;
const g = cv.getContext('2d'), gr = g.createRadialGradient(64, 64, 0, 64, 64, 64);
gr.addColorStop(0, 'rgba(255,250,220,1)'); gr.addColorStop(0.3, 'rgba(255,240,190,.5)'); gr.addColorStop(1, 'rgba(255,240,190,0)');
g.fillStyle = gr; g.fillRect(0, 0, 128, 128);
const glow = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(cv), fog: false, depthWrite: false }));
glow.position.set(900, 900, 500); glow.scale.set(420, 420, 1);
scene.add(glow);
}
// Ribbon strip along the centreline between lateral offsets ldA(s) and ldB(s).
// colFn(s, side, color) fills per-vertex colours (may be a no-op when unused).
function makeStrip(ldA, ldB, lift, colFn, mat) {
const { N } = TR;
const pos = new Float32Array(N * 6), col = new Float32Array(N * 6), idx = [];
const c = new THREE.Color();
for (let s = 0; s < N; s++) {
for (let side = 0; side < 2; side++) {
const ld = side ? ldB(s) : ldA(s);
const [x, z] = ptAt(s, ld);
const v = (s * 2 + side) * 3;
pos[v] = x; pos[v + 1] = groundAt(x, z, s, ld) + lift; pos[v + 2] = z;
colFn(s, side, c);
col[v] = c.r; col[v + 1] = c.g; col[v + 2] = c.b;
}
}
for (let s = 0; s < N; s++) {
const a = s * 2, b = ((s + 1) % N) * 2;
idx.push(a, a + 1, b, a + 1, b + 1, b);
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
g.setAttribute('color', new THREE.BufferAttribute(col, 3));
g.setIndex(idx);
g.computeVertexNormals();
return new THREE.Mesh(g, mat);
}
// Terrain: displaced ground plane with vertex colours (forest, meadow, dry hilltops)
function buildTerrain() {
const SIZE = 2000, SEG = 170;
const geo = new THREE.PlaneGeometry(SIZE, SIZE, SEG, SEG);
geo.rotateX(-Math.PI / 2);
const p = geo.attributes.position, cols = new Float32Array(p.count * 3);
const c = new THREE.Color(), dark = new THREE.Color(0x3d6b2c), light = new THREE.Color(0x7aa84a),
dry = new THREE.Color(0x8e8a4e), verge = new THREE.Color(0x5a6e3a);
for (let i = 0; i < p.count; i++) {
const x = p.getX(i), z = p.getZ(i);
const q = queryPoint(x, z);
p.setY(i, q.y);
const n = 0.5 + 0.5 * Math.sin(x * 0.05) * Math.cos(z * 0.043);
c.copy(dark).lerp(light, n);
if (q.y > 12) c.lerp(dry, clamp((q.y - 12) / 10, 0, 0.6));
if (q.d < CFG.RAIL + 8) c.lerp(verge, 0.35);
cols[i * 3] = c.r; cols[i * 3 + 1] = c.g; cols[i * 3 + 2] = c.b;
}
geo.setAttribute('color', new THREE.BufferAttribute(cols, 3));
geo.computeVertexNormals();
const m = new THREE.Mesh(geo, new THREE.MeshLambertMaterial({ vertexColors: true }));
m.receiveShadow = true;
scene.add(m);
}
// Road surface (coloured per surface type), shoulders, edge lines and start/finish band
function buildRoad() {
const { SUR } = TR;
const roadMat = new THREE.MeshLambertMaterial({ vertexColors: true, side: THREE.DoubleSide });
const road = makeStrip(() => -CFG.HW, () => CFG.HW, 0.06,
(s, side, c) => c.setHex(SURF[SUR[s]].col), roadMat);
road.receiveShadow = true;
scene.add(road);
const shoulderMat = new THREE.MeshLambertMaterial({ vertexColors: true, side: THREE.DoubleSide });
const shCol = (s, side, c) => c.setHex(SUR[s] === 'mud' ? 0x4d3a24 : 0x7a8a48);
const shL = makeStrip(() => -CFG.RAIL + 0.2, () => -CFG.HW, 0.05, shCol, shoulderMat);
const shR = makeStrip(() => CFG.HW, () => CFG.RAIL - 0.2, 0.05, shCol, shoulderMat);
shL.receiveShadow = shR.receiveShadow = true;
scene.add(shL, shR);
const lineMat = new THREE.MeshLambertMaterial({ color: 0xf2f2f2, side: THREE.DoubleSide });
const white = (s, side, c) => c.setHex(0xf2f2f2);
scene.add(makeStrip(() => CFG.HW - 0.5, () => CFG.HW, 0.09, white, lineMat));
scene.add(makeStrip(() => -CFG.HW, () => -CFG.HW + 0.5, 0.09, white, lineMat));
// Checkered start/finish band: a canvas texture on a tiny strip window around sample 0
const cv = document.createElement('canvas'); cv.width = cv.height = 64;
const g = cv.getContext('2d');
for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) {
g.fillStyle = (x + y) % 2 ? '#fff' : '#111'; g.fillRect(x * 8, y * 8, 8, 8);
}
const tex = new THREE.CanvasTexture(cv);
const finish = makeStrip(() => -CFG.HW, () => CFG.HW, 0.11, () => {},
new THREE.MeshBasicMaterial({ map: tex, side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: -4 }));
// Keep only a narrow window of the strip (generated index buffer)
const N = TR.N, win = 2, fidx = [];
for (let s = 0; s < N; s++) {
if (!(s <= win || s >= N - win)) continue;
const a = s * 2, b = ((s + 1) % N) * 2;
fidx.push(a, a + 1, b, a + 1, b + 1, b);
}
finish.geometry.setIndex(fidx);
scene.add(finish);
}
// Guard rails (hard boundary) and instanced posts
function buildRails() {
const N = TR.N;
const railMat = new THREE.MeshLambertMaterial({ color: 0xdfe4ea, side: THREE.DoubleSide });
const noCol = () => {};
[makeStrip(() => -CFG.RAIL - 0.12, () => -CFG.RAIL + 0.12, 0.85, noCol, railMat),
makeStrip(() => CFG.RAIL - 0.12, () => CFG.RAIL + 0.12, 0.85, noCol, railMat)]
.forEach(b => { b.castShadow = true; scene.add(b); });
const posts = [];
for (let s = 0; s < N; s += 6) {
for (const side of [-1, 1]) {
const ld = side * CFG.RAIL;
const [x, z] = ptAt(s, ld);
posts.push({ x, z, y: groundAt(x, z, s, ld) });
}
}
const pm = new THREE.InstancedMesh(new THREE.BoxGeometry(0.28, 1.1, 0.28),
new THREE.MeshLambertMaterial({ color: 0xd9352a }), posts.length);
pm.castShadow = true;
const m4 = new THREE.Matrix4();
posts.forEach((p, k) => { m4.makeTranslation(p.x, p.y + 0.55, p.z); pm.setMatrixAt(k, m4); });
scene.add(pm);
WORLD.posts = pm;
}
// Procedural scenery: forest, rocks, boulders & stumps (collidable), turn signs, banners, grandstand crowd
function buildScenery() {
const R = rng(4242);
const N = TR.N;
const trees = [], rocks = [];
// Forest scattered beyond the rails (denser farther out)
for (let k = 0; k < 1100; k++) {
const i = Math.floor(R() * N);
const side = R() < 0.5 ? -1 : 1;
const ld = side * (CFG.RAIL + 3 + Math.pow(R(), 0.7) * 90);
const [x, z] = ptAt(i, ld);
const jx = x + (R() - 0.5) * 5, jz = z + (R() - 0.5) * 5;
const y = groundAt(jx, jz, i, ld);
if (y < -8) continue;
trees.push({ x: jx, z: jz, y, s: 0.8 + R() * 1.1, kind: R() < 0.7 ? 0 : 1 });
}
// Rocks beyond rails (scenery only)
for (let k = 0; k < 150; k++) {
const i = Math.floor(R() * N);
const ld = (R() < 0.5 ? -1 : 1) * (CFG.RAIL + 2 + R() * 60);
const [x, z] = ptAt(i, ld);
rocks.push({ x, z, y: groundAt(x, z, i, ld), s: 0.6 + R() * 1.6, rot: R() * 6 });
}
// Collidable boulders on the shoulders (between road edge and rail), not near the grid
const gridS = N - CFG.GRID_BACK;
for (let k = 0; k < 60; k++) {
const i = Math.floor(R() * N);
if (fwdDistLocal(gridS, i, N) < 60 || i < 25) continue;
const ld = (R() < 0.5 ? -1 : 1) * (CFG.HW + 1.8 + R() * 3.2);
const [x, z] = ptAt(i, ld);
const s = 0.7 + R() * 0.8;
rocks.push({ x, z, y: groundAt(x, z, i, ld), s, rot: R() * 6 });
OBST.push({ x, z, r: 1.1 * s, key: 'b' + k });
}
// Stumps on the shoulder (collidable)
for (let k = 0; k < 30; k++) {
const i = Math.floor(R() * N);
if (fwdDistLocal(gridS, i, N) < 60 || i < 25) continue;
const ld = (R() < 0.5 ? -1 : 1) * (CFG.HW + 1.4 + R() * 2.4);
const [x, z] = ptAt(i, ld);
trees.push({ x, z, y: groundAt(x, z, i, ld), s: 0.5, kind: 2 });
OBST.push({ x, z, r: 0.9, key: 's' + k });
}
// Trees: trunk + crown instanced meshes (two crown styles)
const trunkG = new THREE.CylinderGeometry(0.35, 0.5, 4, 6); trunkG.translate(0, 2, 0);
const crownG = new THREE.ConeGeometry(2.6, 6, 7); crownG.translate(0, 6.5, 0);
const crown2G = new THREE.IcosahedronGeometry(2.4, 0); crown2G.translate(0, 5.5, 0);
const stumpG = new THREE.CylinderGeometry(0.6, 0.7, 0.9, 7); stumpG.translate(0, 0.45, 0);
const trunkM = new THREE.MeshLambertMaterial({ color: 0x6b4a2b });
const cM = new THREE.MeshLambertMaterial({ color: 0x2f6b34 });
const c2M = new THREE.MeshLambertMaterial({ color: 0x3d8a3a });
const stM = new THREE.MeshLambertMaterial({ color: 0x7d5a35 });
const trunkList = trees.filter(t => t.kind !== 2);
const stumps = trees.filter(t => t.kind === 2);
const nT = trunkList.length;
const tTrunk = new THREE.InstancedMesh(trunkG, trunkM, nT);
const tCrown = new THREE.InstancedMesh(crownG, cM, nT);
const tCrown2 = new THREE.InstancedMesh(crown2G, c2M, nT);
const tStump = new THREE.InstancedMesh(stumpG, stM, Math.max(1, stumps.length));
const m4 = new THREE.Matrix4(), q4 = new THREE.Quaternion(), sc = new THREE.Vector3(), pp = new THREE.Vector3();
const up = new THREE.Vector3(0, 1, 0);
let a = 0, b = 0;
trunkList.forEach((t, k) => {
q4.setFromAxisAngle(up, R() * 6.28);
pp.set(t.x, t.y, t.z); sc.set(t.s, t.s, t.s);
m4.compose(pp, q4, sc);
tTrunk.setMatrixAt(k, m4);
if (t.kind === 0) tCrown.setMatrixAt(a++, m4); else tCrown2.setMatrixAt(b++, m4);
});
tCrown.count = a; tCrown2.count = b;
stumps.forEach((t, k) => {
pp.set(t.x, t.y, t.z); sc.set(1, 1, 1); q4.identity();
m4.compose(pp, q4, sc); tStump.setMatrixAt(k, m4);
});
tStump.count = stumps.length;
[tTrunk, tCrown, tCrown2, tStump].forEach(m => {
m.castShadow = true; m.instanceMatrix.needsUpdate = true; scene.add(m);
});
// Rocks (flat-shaded dodecahedra)
const rockMesh = new THREE.InstancedMesh(new THREE.DodecahedronGeometry(1.3, 0),
new THREE.MeshLambertMaterial({ color: 0x7c7d80 }), rocks.length);
rockMesh.castShadow = true;
rocks.forEach((r, k) => {
q4.setFromEuler(new THREE.Euler(r.rot * 0.7, r.rot, r.rot * 0.3));
pp.set(r.x, r.y + 0.2 * r.s, r.z); sc.set(r.s, r.s * 0.8, r.s * 1.1);
m4.compose(pp, q4, sc); rockMesh.setMatrixAt(k, m4);
});
scene.add(rockMesh);
buildSignsBannersCrowd();
}
// Forward distance in samples from a to b (wrapping)
function fwdDistLocal(a, b, N) { return ((b - a) % N + N) % N; }
// Canvas-generated chevron turn signs, sponsor banners and grandstand crowd
function buildSignsBannersCrowd() {
const N = TR.N;
const mkSignTex = (dir) => {
const cv = document.createElement('canvas'); cv.width = 128; cv.height = 128;
const g = cv.getContext('2d');
g.fillStyle = '#facc15'; g.fillRect(0, 0, 128, 128);
g.strokeStyle = '#111'; g.lineWidth = 8; g.strokeRect(4, 4, 120, 120);
g.fillStyle = '#111'; g.beginPath();
if (dir > 0) { g.moveTo(96, 64); g.lineTo(40, 26); g.lineTo(40, 48); g.lineTo(20, 48); g.lineTo(20, 80); g.lineTo(40, 80); g.lineTo(40, 102); }
else { g.moveTo(32, 64); g.lineTo(88, 26); g.lineTo(88, 48); g.lineTo(108, 48); g.lineTo(108, 80); g.lineTo(88, 80); g.lineTo(88, 102); }
g.closePath(); g.fill();
return new THREE.CanvasTexture(cv);
};
const signL = mkSignTex(-1), signR = mkSignTex(1);
const postG = new THREE.CylinderGeometry(0.12, 0.12, 3, 5); postG.translate(0, 1.5, 0);
const postM = new THREE.MeshLambertMaterial({ color: 0x888c92 });
const signG = new THREE.PlaneGeometry(2.2, 2.2);
for (let i = 40; i < N; i += 95) {
const dir = TR.TURN[i] > 0 ? 1 : -1;
const ld = CFG.RAIL + 2.2;
const [x, z] = ptAt(i, ld);
const grp = new THREE.Group();
const post = new THREE.Mesh(postG, postM); post.castShadow = true; grp.add(post);
const face = new THREE.Mesh(signG, new THREE.MeshLambertMaterial({ map: dir > 0 ? signR : signL, side: THREE.DoubleSide }));
face.position.y = 3.2; face.castShadow = true; grp.add(face);
grp.position.set(x, groundAt(x, z, i, ld), z);
grp.rotation.y = Math.atan2(TR.TX[i], TR.TZ[i]) + Math.PI / 2;
scene.add(grp);
}
// Sponsor banners on poles beside the road
const bannerCols = ['#e11d48', '#2563eb', '#16a34a', '#f59e0b', '#7c3aed'];
const words = ['RALLY 3D', 'FULL SEND', 'GRAVEL KINGS', 'TURBO', 'MUD & GLORY', 'GO GO GO', 'DRIFT!'];
for (let k = 0; k < 14; k++) {
const i = Math.floor((k + 0.5) * N / 14);
const ld = (k % 2 ? 1 : -1) * (CFG.RAIL + 4);
const [x, z] = ptAt(i, ld);
const cv = document.createElement('canvas'); cv.width = 512; cv.height = 128;
const g = cv.getContext('2d');
g.fillStyle = bannerCols[k % bannerCols.length]; g.fillRect(0, 0, 512, 128);
g.fillStyle = '#fff'; g.font = 'bold 64px Arial, sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle';
g.fillText(words[k % words.length], 256, 66);
const board = new THREE.Mesh(new THREE.PlaneGeometry(14, 3.5),
new THREE.MeshLambertMaterial({ map: new THREE.CanvasTexture(cv), side: THREE.DoubleSide }));
const pole = new THREE.Mesh(new THREE.BoxGeometry(0.35, 6, 0.35), postM);
board.position.y = 6.5; pole.position.y = 3; board.castShadow = true; pole.castShadow = true;
const grp = new THREE.Group();
grp.add(board, pole);
grp.position.set(x, groundAt(x, z, i, ld), z);
grp.rotation.y = Math.atan2(TR.TX[i], TR.TZ[i]) + Math.PI / 2;
scene.add(grp);
}
// Grandstand beside the start/finish straight with a simple instanced crowd
const gi = 0, gld = CFG.RAIL + 12;
const [gx, gz] = ptAt(gi, gld);
const gy = groundAt(gx, gz, gi, gld);
const hdg = Math.atan2(TR.TX[gi], TR.TZ[gi]);
const stand = new THREE.Mesh(new THREE.BoxGeometry(34, 6, 8), new THREE.MeshLambertMaterial({ color: 0x8a8f99 }));
stand.position.set(gx, gy + 3, gz);
stand.rotation.y = hdg; stand.castShadow = true; stand.receiveShadow = true;
scene.add(stand);
const CR = 120;
const crowd = new THREE.InstancedMesh(new THREE.BoxGeometry(0.6, 1.4, 0.6),
new THREE.MeshLambertMaterial(), CR);
const crowdCols = [0xf43f5e, 0x3b82f6, 0xfacc15, 0x22c55e, 0xffffff, 0xf97316];
const m4 = new THREE.Matrix4(), q4 = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), hdg);
const sc = new THREE.Vector3(1, 1, 1), p = new THREE.Vector3(), c = new THREE.Color();
const rightX = TR.TZ[gi], rightZ = -TR.TX[gi]; // stand's lateral axis (sign only matters for layout)
for (let k = 0; k < CR; k++) {
const row = Math.floor(k / 20), col = k % 20;
const along = (col - 10) * 1.6; // along the straight
const rowOff = -2.5 + row * 1.0; // back to front on the stand
p.set(gx + TR.TX[gi] * along + rightX * rowOff, gy + 6.8 + row * 1.1, gz + TR.TZ[gi] * along + rightZ * rowOff);
m4.compose(p, q4, sc); crowd.setMatrixAt(k, m4);
crowd.setColorAt(k, c.setHex(crowdCols[k % crowdCols.length]));
}
crowd.instanceMatrix.needsUpdate = true;
if (crowd.instanceColor) crowd.instanceColor.needsUpdate = true;
crowd.castShadow = true;
scene.add(crowd);
WORLD.crowd = crowd;
}
/* ---------- 3. CAR MODEL, PARTICLES & SKID MARKS ---------- */
// Car rig: root group, wheel pivots (front pivots steer), brake light materials
const carRig = { root: null, wheels: [], brakeMats: [] };
// Procedural low-poly rally car. Nose points along local +Z, right side is +X.
function buildCar() {
const root = new THREE.Group();
const paint = new THREE.MeshLambertMaterial({ color: 0xe8432a });
const dark = new THREE.MeshLambertMaterial({ color: 0x1c1f26 });
const glass = new THREE.MeshLambertMaterial({ color: 0x1d3a52 });
const white = new THREE.MeshLambertMaterial({ color: 0xf5f5f5 });
const chrome = new THREE.MeshLambertMaterial({ color: 0xb8bec6 });
const hl = new THREE.MeshBasicMaterial({ color: 0xfff3b0 });
const mk = (geo, mat, x, y, z) => {
const m = new THREE.Mesh(geo, mat); m.position.set(x, y, z); m.castShadow = true; root.add(m); return m;
};
// Body: lower chassis, tapered nose, glasshouse, roof
mk(new THREE.BoxGeometry(1.75, 0.55, 3.9), paint, 0, 0.62, 0);
const nose = mk(new THREE.BoxGeometry(1.5, 0.42, 0.9), paint, 0, 0.55, 2.2); nose.rotation.x = 0.12;
mk(new THREE.BoxGeometry(1.45, 0.5, 1.9), glass, 0, 1.27, -0.2);
mk(new THREE.BoxGeometry(1.4, 0.06, 1.7), paint, 0, 1.55, -0.2);
mk(new THREE.BoxGeometry(1.8, 0.14, 0.6), dark, 0, 0.36, 1.85); // front bumper
mk(new THREE.BoxGeometry(1.8, 0.14, 0.6), dark, 0, 0.36, -1.95); // rear bumper
// Racing stripes
mk(new THREE.BoxGeometry(0.22, 0.02, 3.4), white, 0.32, 1.1, -0.2).castShadow = false;
mk(new THREE.BoxGeometry(0.22, 0.02, 3.4), white, -0.32, 1.1, -0.2).castShadow = false;
// Rear spoiler: posts and wing
mk(new THREE.BoxGeometry(0.08, 0.45, 0.08), dark, 0.6, 1.1, -1.7);
mk(new THREE.BoxGeometry(0.08, 0.45, 0.08), dark, -0.6, 1.1, -1.7);
mk(new THREE.BoxGeometry(2.0, 0.08, 0.5), dark, 0, 1.35, -1.75);
// Roof light bar
mk(new THREE.BoxGeometry(1.1, 0.12, 0.18), dark, 0, 1.62, 0.55);
for (const x of [-0.45, -0.15, 0.15, 0.45]) {
mk(new THREE.CylinderGeometry(0.1, 0.1, 0.08, 8).rotateX(Math.PI / 2), hl, x, 1.62, 0.66).castShadow = false;
}
// Headlights and tail lights (tail lights brighten under braking)
mk(new THREE.BoxGeometry(0.35, 0.18, 0.08), hl, 0.6, 0.75, 2.42).castShadow = false;
mk(new THREE.BoxGeometry(0.35, 0.18, 0.08), hl, -0.6, 0.75, 2.42).castShadow = false;
for (const x of [0.6, -0.6]) {
const mat = new THREE.MeshBasicMaterial({ color: 0x7a0c05 });
mk(new THREE.BoxGeometry(0.4, 0.2, 0.08), mat, x, 0.78, -2.0).castShadow = false;
carRig.brakeMats.push(mat);
}
// Exhaust
mk(new THREE.CylinderGeometry(0.07, 0.07, 0.3, 6).rotateX(Math.PI / 2), chrome, 0.5, 0.3, -2.0);
// Wheels: pivot group (front pivots steer) containing the spinning tyre + rim
const tyreG = new THREE.CylinderGeometry(0.44, 0.44, 0.36, 10); tyreG.rotateZ(Math.PI / 2);
const rimG = new THREE.CylinderGeometry(0.26, 0.26, 0.38, 6); rimG.rotateZ(Math.PI / 2);
const tyreM = new THREE.MeshLambertMaterial({ color: 0x17181b });
const rimM = new THREE.MeshLambertMaterial({ color: 0xc9ced6 });
const wheelPos = [[0.9, 0.44, 1.35, true], [-0.9, 0.44, 1.35, true], [0.9, 0.44, -1.3, false], [-0.9, 0.44, -1.3, false]];
for (const [x, y, z, front] of wheelPos) {
const pivot = new THREE.Group(); pivot.position.set(x, y, z);
const spin = new THREE.Group();
const t = new THREE.Mesh(tyreG, tyreM), r = new THREE.Mesh(rimG, rimM);
t.castShadow = true; spin.add(t, r); pivot.add(spin);
root.add(pivot);
carRig.wheels.push({ spin, pivot, front });
}
carRig.root = root;
scene.add(root);
}
// Particle pools: Points with per-vertex colour, ring-buffered
const FX = {};
function buildParticles() {
// Soft circle sprite generated on a canvas
const cv = document.createElement('canvas'); cv.width = cv.height = 64;
const g = cv.getContext('2d'), gr = g.createRadialGradient(32, 32, 0, 32, 32, 32);
gr.addColorStop(0, 'rgba(255,255,255,1)'); gr.addColorStop(0.5, 'rgba(255,255,255,.6)'); gr.addColorStop(1, 'rgba(255,255,255,0)');
g.fillStyle = gr; g.fillRect(0, 0, 64, 64);
const tex = new THREE.CanvasTexture(cv);
const mkPool = (count, opts) => {
const geo = new THREE.BufferGeometry();
const pos = new Float32Array(count * 3), col = new Float32Array(count * 3);
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3).setUsage(THREE.DynamicDrawUsage));
geo.setAttribute('color', new THREE.BufferAttribute(col, 3).setUsage(THREE.DynamicDrawUsage));
const mat = new THREE.PointsMaterial(Object.assign({
size: 2, map: tex, vertexColors: true, transparent: true, depthWrite: false, sizeAttenuation: true,
}, opts));
const pts = new THREE.Points(geo, mat);
pts.frustumCulled = false;
scene.add(pts);
const P = [];
for (let i = 0; i < count; i++) P.push({ x: 0, y: -999, z: 0, vx: 0, vy: 0, vz: 0, life: 0, max: 1, r: 1, g: 1, b: 1, grav: 0, drag: 0 });
return { geo, pos, col, P, next: 0, count };
};
FX.dust = mkPool(420, { size: 3.4 });
FX.spark = mkPool(160, { size: 0.45, blending: THREE.AdditiveBlending });
// Skid marks: ring buffer of quads (4 verts each), updated incrementally
const SK = 900;
const sgeo = new THREE.BufferGeometry();
const spos = new Float32Array(SK * 4 * 3).fill(-999);
const sidx = [];
for (let q = 0; q < SK; q++) { const b = q * 4; sidx.push(b, b + 1, b + 2, b + 2, b + 1, b + 3); }
sgeo.setAttribute('position', new THREE.BufferAttribute(spos, 3).setUsage(THREE.DynamicDrawUsage));
sgeo.setIndex(sidx);
const smesh = new THREE.Mesh(sgeo, new THREE.MeshBasicMaterial({
color: 0x0b0b0c, transparent: true, opacity: 0.55, depthWrite: false,
polygonOffset: true, polygonOffsetFactor: -2,
}));
smesh.frustumCulled = false;
scene.add(smesh);
FX.skid = { geo: sgeo, pos: spos, next: 0, last: [null, null], SK };
}
// Dust puff emitted behind the rear wheels on dirt/grass/mud or while sliding
function spawnDust(x, y, z, vx, vz, rgb) {
const D = FX.dust;
const p = D.P[D.next]; D.next = (D.next + 1) % D.count;
p.x = x + (Math.random() - 0.5) * 1.2; p.y = y + 0.2; p.z = z + (Math.random() - 0.5) * 1.2;
p.vx = vx * 0.25 + (Math.random() - 0.5) * 3; p.vz = vz * 0.25 + (Math.random() - 0.5) * 3;
p.vy = 1.5 + Math.random() * 2.5;
p.life = p.max = 0.9 + Math.random() * 0.8;
p.r = rgb[0]; p.g = rgb[1]; p.b = rgb[2]; p.grav = -1.2; p.drag = 1.6;
}
// Sparks on collisions (additive, gravity)
function spawnSparks(x, y, z, n) {
const S = FX.spark;
for (let k = 0; k < n; k++) {
const p = S.P[S.next]; S.next = (S.next + 1) % S.count;
p.x = x; p.y = y; p.z = z;
const a = Math.random() * Math.PI * 2, s = 4 + Math.random() * 9;
p.vx = Math.cos(a) * s; p.vz = Math.sin(a) * s; p.vy = 2 + Math.random() * 6;
p.life = p.max = 0.35 + Math.random() * 0.35;
p.r = 1; p.g = 0.75 + Math.random() * 0.25; p.b = 0.2; p.grav = -18; p.drag = 0.5;
}
}
function updateParticles(dt) {
for (const pool of [FX.dust, FX.spark]) {
const { P, pos, col } = pool;
const isDust = pool === FX.dust;
for (let i = 0; i < P.length; i++) {
const p = P[i];
if (p.life > 0) {
p.life -= dt;
const dk = Math.exp(-p.drag * dt);
p.vx *= dk; p.vz *= dk; p.vy = p.vy * dk + p.grav * dt;
p.x += p.vx * dt; p.y += p.vy * dt; p.z += p.vz * dt;
if (!isDust && p.y < 0.05) { p.y = 0.05; p.vy *= -0.3; }
if (p.life <= 0) p.y = -999;
}
pos[i * 3] = p.x; pos[i * 3 + 1] = p.y; pos[i * 3 + 2] = p.z;
const f = p.life > 0 ? clamp(p.life / p.max, 0, 1) : 0;
// Dust dims toward its base colour; sparks fade out
const k = isDust ? (0.6 + 0.4 * f) : f;
col[i * 3] = p.r * k; col[i * 3 + 1] = p.g * k; col[i * 3 + 2] = p.b * k;
}
pool.geo.attributes.position.needsUpdate = true;
pool.geo.attributes.color.needsUpdate = true;
}
}
// Adds a skid quad between the previous stored point and the current one for a wheel side
function addSkid(side, x, y, z, rx, rz) {
const S = FX.skid;
const last = S.last[side];
if (last) {
const q = S.next; S.next = (S.next + 1) % S.SK;
const w = 0.22, p = S.pos, b = q * 12;
const yy = 0.09;
p[b] = last[0] - rx * w; p[b + 1] = last[2] + yy; p[b + 2] = last[1] - rz * w;
p[b + 3] = last[0] + rx * w; p[b + 4] = last[2] + yy; p[b + 5] = last[1] + rz * w;
p[b + 6] = x - rx * w; p[b + 7] = y + yy; p[b + 8] = z - rz * w;
p[b + 9] = x + rx * w; p[b + 10] = y + yy; p[b + 11] = z + rz * w;
S.geo.attributes.position.needsUpdate = true;
}
S.last[side] = [x, z, y];
}
function breakSkid(side) { FX.skid.last[side] = null; }
/* ---------- 4. WEB AUDIO SYNTHESIS (no files) ---------- */
const AUD = { ctx: null, master: null, engOsc: null, engOsc2: null, engGain: null, engFilt: null,
noiseBuf: null, skidGain: null, skidFilt: null };
// Created lazily on the first user gesture (browser autoplay policy)
function initAudio() {
if (AUD.ctx) { if (AUD.ctx.state === 'suspended') AUD.ctx.resume(); return; }
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
const ctx = new AC(); AUD.ctx = ctx;
AUD.master = ctx.createGain(); AUD.master.gain.value = 0.55; AUD.master.connect(ctx.destination);
// Engine: sawtooth + square through a lowpass, gain follows load
AUD.engFilt = ctx.createBiquadFilter(); AUD.engFilt.type = 'lowpass'; AUD.engFilt.frequency.value = 900;
AUD.engGain = ctx.createGain(); AUD.engGain.gain.value = 0;
AUD.engOsc = ctx.createOscillator(); AUD.engOsc.type = 'sawtooth';
AUD.engOsc2 = ctx.createOscillator(); AUD.engOsc2.type = 'square';
const g2 = ctx.createGain(); g2.gain.value = 0.35;
AUD.engOsc.connect(AUD.engFilt); AUD.engOsc2.connect(g2); g2.connect(AUD.engFilt);
AUD.engFilt.connect(AUD.engGain); AUD.engGain.connect(AUD.master);
AUD.engOsc.start(); AUD.engOsc2.start();
// Shared white-noise buffer for skids and crashes
const nb = ctx.createBuffer(1, ctx.sampleRate * 2, ctx.sampleRate), nd = nb.getChannelData(0);
for (let i = 0; i < nd.length; i++) nd[i] = Math.random() * 2 - 1;
AUD.noiseBuf = nb;
// Continuous gravel/skid loop
const src = ctx.createBufferSource(); src.buffer = nb; src.loop = true;
AUD.skidFilt = ctx.createBiquadFilter(); AUD.skidFilt.type = 'bandpass'; AUD.skidFilt.frequency.value = 1400; AUD.skidFilt.Q.value = 0.8;
AUD.skidGain = ctx.createGain(); AUD.skidGain.gain.value = 0;
src.connect(AUD.skidFilt); AUD.skidFilt.connect(AUD.skidGain); AUD.skidGain.connect(AUD.master);
src.start();
}
// One enveloped oscillator blip
function tone(freq, dur, type, vol, when = 0, slideTo = null) {
if (!AUD.ctx) return;
const c = AUD.ctx, t = c.currentTime + when;
const o = c.createOscillator(), g = c.createGain();
o.type = type; o.frequency.setValueAtTime(freq, t);
if (slideTo) o.frequency.exponentialRampToValueAtTime(slideTo, t + dur);
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(vol, t + 0.012);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
o.connect(g); g.connect(AUD.master);
o.start(t); o.stop(t + dur + 0.05);
}
function sfxBeep(final) { final ? tone(1046, 0.5, 'square', 0.18) : tone(660, 0.16, 'square', 0.18); }
function sfxGo() { tone(1320, 0.6, 'square', 0.2); tone(880, 0.6, 'triangle', 0.12); }
function sfxFinish() {
[523, 659, 784, 1046].forEach((f, k) => tone(f, 0.28, 'triangle', 0.22, k * 0.14));
tone(1568, 0.9, 'sine', 0.2, 0.6);
}
// Crash/thud: filtered noise burst plus a falling sine
function sfxCrash(strength) {
if (!AUD.ctx) return;
const c = AUD.ctx, t = c.currentTime;
const s = c.createBufferSource(); s.buffer = AUD.noiseBuf;
const f = c.createBiquadFilter(); f.type = 'lowpass';
f.frequency.setValueAtTime(900, t); f.frequency.exponentialRampToValueAtTime(120, t + 0.35);
const g = c.createGain(); const v = clamp(strength, 0.1, 1) * 0.9;
g.gain.setValueAtTime(v, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.4);
s.connect(f); f.connect(g); g.connect(AUD.master);
s.start(t, Math.random(), 0.45);
tone(90, 0.3, 'sine', 0.35 * v, 0, 40);
}
// Engine: pitch follows simulated RPM, timbre follows throttle
function updateEngine(rpm01, throttle, active) {
if (!AUD.ctx) return;
const t = AUD.ctx.currentTime;
const f = 42 + rpm01 * 150;
AUD.engOsc.frequency.setTargetAtTime(f, t, 0.05);
AUD.engOsc2.frequency.setTargetAtTime(f * 0.5, t, 0.05);
AUD.engFilt.frequency.setTargetAtTime(500 + rpm01 * 1800 + throttle * 400, t, 0.1);
AUD.engGain.gain.setTargetAtTime(active ? 0.05 + 0.1 * (0.4 * throttle + 0.6 * rpm01) : 0, t, 0.08);
}
// Gravel/skid loop volume
function updateSkid(amount) {
if (!AUD.ctx) return;
AUD.skidGain.gain.setTargetAtTime(clamp(amount, 0, 1) * 0.35, AUD.ctx.currentTime, 0.05);
}
/* ---------- 5. INPUT (keyboard + on-screen touch) ---------- */
const INPUT = { keys: {}, touch: {}, camToggle: false, pauseToggle: false, restart: false };
const GAME_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space'];
window.addEventListener('keydown', (e) => {
if (GAME_KEYS.includes(e.code)) e.preventDefault();
if (e.repeat) return;
INPUT.keys[e.code] = true;
if (e.code === 'KeyC') INPUT.camToggle = true;
if (e.code === 'KeyP' || e.code === 'Escape') INPUT.pauseToggle = true;
if (e.code === 'KeyR') INPUT.restart = true;
});
window.addEventListener('keyup', (e) => { INPUT.keys[e.code] = false; });
window.addEventListener('blur', () => { INPUT.keys = {}; INPUT.touch = {}; });
// Touch pad: each button's data-k sets a virtual key while held
function setupTouch() {
const isTouch = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
if (!isTouch) return;
const root = document.getElementById('touch');
root.classList.remove('hidden');
root.querySelectorAll('.tbtn').forEach(btn => {
const k = btn.dataset.k;
const on = (e) => { e.preventDefault(); INPUT.touch[k] = true; btn.classList.add('on'); };
const off = (e) => { e.preventDefault(); INPUT.touch[k] = false; btn.classList.remove('on'); };
btn.addEventListener('pointerdown', on);
btn.addEventListener('pointerup', off);
btn.addEventListener('pointerleave', off);
btn.addEventListener('pointercancel', off);
});
}
// Normalised driver intent consumed by the physics step
function readControls() {
const k = INPUT.keys, t = INPUT.touch;
const left = k.ArrowLeft || k.KeyA || t.left;
const right = k.ArrowRight || k.KeyD || t.right;
return {
throttle: (k.ArrowUp || k.KeyW || t.gas) ? 1 : 0,
brake: (k.ArrowDown || k.KeyS || t.brake) ? 1 : 0,
steer: (left ? -1 : 0) + (right ? 1 : 0), // -1 left, +1 right
handbrake: !!(k.Space || t.hb),
};
}
/* ---------- 6. PHYSICS & RACE LOGIC ---------- */
// Car state. Heading h: forward = (sin h, cos h); right = (cos h, -sin h).
const CAR = {
x: 0, z: 0, h: 0, vx: 0, vz: 0, y: 0, idx: 0,
vf: 0, vs: 0, steer: 0, wheelSteer: 0,
pitch: 0, roll: 0, throttle: 0, brake: 0, hb: false,
surf: SURF.asphalt, onRoad: true, slide: 0, colCool: 0,
};
const OBST_COOL = new Map(); // per-obstacle cooldown so one hit = one crash event
const RAIL_LIM = CFG.RAIL - 0.5; // car centre may not pass this lateral offset
// Race state: 'title' | 'countdown' | 'running' | 'paused' | 'finished'
// Race clock (ms) only advances while running, so pausing needs no bookkeeping.
// Gate thresholds: unwrapped index Q; threshold j sits at Q = N + j*N/6.
// j = 0 is the start/finish line (lap start), j = 6, 12, 18 complete laps 1, 2, 3.
const RACE = { state: 'title', time: 0, cdElapsed: 0, cdLast: -1, Q: 0, nextJ: 0, lastIdx: 0,
lapStartTime: 0, lapsDone: 0, laps: [], best: Infinity, lastLap: null, finalTime: 0, toastT: 0 };
// Places the car on the start grid behind the finish line
function placeOnGrid() {
const N = TR.N, s0 = N - CFG.GRID_BACK;
CAR.x = TR.SX[s0]; CAR.z = TR.SZ[s0];
CAR.h = Math.atan2(TR.TX[s0], TR.TZ[s0]);
CAR.vx = CAR.vz = 0; CAR.vf = 0; CAR.vs = 0; CAR.steer = 0; CAR.wheelSteer = 0;
CAR.idx = s0; CAR.y = TR.SY[s0];
RACE.Q = N - CFG.GRID_BACK; RACE.lastIdx = s0; RACE.nextJ = 0;
RACE.lapsDone = 0; RACE.laps = []; RACE.lastLap = null; RACE.best = Infinity;
RACE.time = 0; RACE.lapStartTime = 0;
OBST_COOL.clear();
breakSkid(0); breakSkid(1);
FX.skid.pos.fill(-999); FX.skid.geo.attributes.position.needsUpdate = true;
}
// One physics integration step (dt seconds). ctl = { throttle, brake, steer, handbrake }
function stepCar(dt, ctl) {
const N = TR.N;
let q = queryPoint(CAR.x, CAR.z, CAR.idx);
const surf = q.surf;
CAR.surf = surf; CAR.onRoad = q.d <= CFG.HW;
// 1) Steering: smoothed input; yaw rate scales with speed (tight when slow, stable when fast)
CAR.steer += (ctl.steer - CAR.steer) * Math.min(1, dt * 9);
const sp = clamp(Math.hypot(CAR.vx, CAR.vz) / CFG.VMAX, 0, 1);
const maxYaw = lerp(2.9, 1.25, sp);
const authority = clamp(Math.abs(CAR.vf) / 4, 0, 1); // no spinning on the spot
const dirSign = CAR.vf >= -0.2 ? 1 : -1; // reversing flips steering
CAR.h += CAR.steer * maxYaw * authority * dirSign * dt;
CAR.wheelSteer = CAR.steer * 0.5;
// 2) Decompose velocity in the (new) car frame
const fx = Math.sin(CAR.h), fz = Math.cos(CAR.h), rx = Math.cos(CAR.h), rz = -Math.sin(CAR.h);
let vf = CAR.vx * fx + CAR.vz * fz;
let vs = CAR.vx * rx + CAR.vz * rz;
// 3) Longitudinal: engine, brakes, reverse, drag and off-road resistance
const vmaxEff = CFG.VMAX * surf.spd;
const racing = RACE.state === 'running';
if (racing && ctl.throttle) {
const k = clamp(1 - Math.max(vf, 0) / vmaxEff, 0, 1); // torque falls off near top speed
vf += CFG.ENGINE * surf.spd * k * dt;
}
if (racing && ctl.brake) {
if (vf > 0.8) vf -= CFG.BRAKE * surf.grip * dt;
else vf = Math.max(vf - CFG.ENGINE * 0.55 * dt, -CFG.REV_MAX);
}
vf *= Math.exp(-0.06 * dt); // aero/rolling drag
vf *= Math.exp(-(1 - surf.spd) * 1.2 * dt); // off-road drag
// 4) Lateral grip: asphalt grips hard; dirt/grass/mud and handbrake allow slides
const hb = ctl.handbrake && Math.abs(vf) > 2;
if (hb) vf -= Math.sign(vf) * 6 * dt; // handbrake scrubs speed
let lat = 9.0 * surf.grip;
if (hb) lat = 1.1 * surf.grip + 0.5; // rear locks: long drift
else if (Math.abs(vs) > 3) lat *= 0.85; // tyres break loose at high slip
vs *= Math.exp(-lat * dt);
CAR.hb = hb;
CAR.vx = fx * vf + rx * vs; CAR.vz = fz * vf + rz * vs;
CAR.vf = vf; CAR.vs = vs; CAR.slide = Math.abs(vs);
// 5) Integrate position
CAR.x += CAR.vx * dt; CAR.z += CAR.vz * dt;
// 6) Guard rails: hard walls that bounce the car and kill momentum
q = queryPoint(CAR.x, CAR.z, CAR.idx);
if (Math.abs(q.ld) > RAIL_LIM) {
const sgn = Math.sign(q.ld);
const nX = -TR.TZ[q.i] * sgn, nZ = TR.TX[q.i] * sgn; // outward normal
const [px, pz] = ptAt(q.i, sgn * RAIL_LIM);
CAR.x = px; CAR.z = pz;
const vn = CAR.vx * nX + CAR.vz * nZ;
if (vn > 0) {
CAR.vx -= 1.35 * vn * nX; CAR.vz -= 1.35 * vn * nZ; // restitution ~0.35
CAR.vx *= 0.7; CAR.vz *= 0.7; // momentum loss
CAR.h += (Math.random() - 0.5) * 0.12;
if (vn > 4 && CAR.colCool <= 0) onCrash(clamp(vn / 22, 0.2, 1), nX, nZ);
}
}
// 7) Static obstacles (boulders, stumps): circle collisions
for (const o of OBST) {
const dx = CAR.x - o.x, dz = CAR.z - o.z, d2 = dx * dx + dz * dz;
const rr = o.r + CFG.CAR_R;
if (d2 < rr * rr && d2 > 1e-6) {
const d = Math.sqrt(d2), nx = dx / d, nz = dz / d;
CAR.x = o.x + nx * rr; CAR.z = o.z + nz * rr;
const vn = CAR.vx * nx + CAR.vz * nz;
if (vn < 0) {
CAR.vx -= 1.3 * vn * nx; CAR.vz -= 1.3 * vn * nz;
CAR.vx *= 0.55; CAR.vz *= 0.55;
CAR.h += (Math.random() - 0.5) * 0.35;
const cool = OBST_COOL.get(o.key) || 0;
if (-vn > 3 && cool <= 0) { OBST_COOL.set(o.key, 0.4); onCrash(clamp(-vn / 20, 0.25, 1), nx, nz); }
}
}
}
CAR.colCool -= dt;
for (const [k, v] of OBST_COOL) OBST_COOL.set(k, v - dt);
// 8) Progress: unwrapped track index from per-step deltas, then gate checks
const idx = nearestIdx(CAR.x, CAR.z, CAR.idx);
let d = idx - RACE.lastIdx;
if (d > N / 2) d -= N; else if (d < -N / 2) d += N;
RACE.Q += d; RACE.lastIdx = idx; CAR.idx = idx;
if (RACE.state === 'running') checkGates();
// 9) Ground height & attitude from nearby ground samples
const gC = groundAt(CAR.x, CAR.z, idx, q.ld);
const fq = queryPoint(CAR.x + fx * 1.6, CAR.z + fz * 1.6, idx);
const bq = queryPoint(CAR.x - fx * 1.6, CAR.z - fz * 1.6, idx);
const pq = queryPoint(CAR.x + rx * 1.0, CAR.z + rz * 1.0, idx);
const mq = queryPoint(CAR.x - rx * 1.0, CAR.z - rz * 1.0, idx);
CAR.y = lerp(CAR.y, gC, clamp(dt * 30, 0, 1));
CAR.pitch = Math.atan2(bq.y - fq.y, 3.2) * 0.9;
CAR.roll = Math.atan2(mq.y - pq.y, 2.0) * 0.9 - CAR.steer * clamp(Math.abs(vf) / CFG.VMAX, 0, 1) * 0.07;
}
// Ordered gate crossing. Thresholds advance and retract as Q moves forward/backward.
function checkGates() {
const N = TR.N, STEP = N / 6;
while (RACE.Q >= N + RACE.nextJ * STEP) {
const j = RACE.nextJ++;
if (j > 0 && j % 6 === 0) onLapComplete(j / 6);
}
while (RACE.nextJ > 0 && RACE.Q < N + (RACE.nextJ - 1) * STEP) RACE.nextJ--;
}
function onLapComplete(lapNo) {
const lapMs = RACE.time - RACE.lapStartTime;
RACE.lapsDone = lapNo;
RACE.laps.push(lapMs);
RACE.lastLap = lapMs;
RACE.lapStartTime = RACE.time;
if (lapMs < RACE.best) { RACE.best = lapMs; flashToast('NEW BEST LAP!'); }
else flashToast('LAP ' + lapNo + ' ' + fmtTime(lapMs));
if (lapNo >= CFG.LAPS) finishRace();
}
function finishRace() {
RACE.state = 'finished';
RACE.finalTime = RACE.time;
sfxFinish();
document.getElementById('finalTime').textContent = fmtTime(RACE.finalTime);
document.getElementById('finalBest').textContent = fmtTime(RACE.best);
for (let k = 0; k < 3; k++) {
document.getElementById('fl' + (k + 1)).textContent = RACE.laps[k] != null ? fmtTime(RACE.laps[k]) : '--';
}
setTimeout(() => { if (RACE.state === 'finished') showOverlay('finishOv'); }, 900);
}
// Collision feedback: sound, sparks, camera shake
const SHAKE = { amt: 0 };
function onCrash(strength, nx, nz) {
sfxCrash(strength);
spawnSparks(CAR.x + nx * 0.8, CAR.y + 0.5, CAR.z + nz * 0.8, Math.round(10 + strength * 22));
CAR.colCool = 0.25;
SHAKE.amt = Math.max(SHAKE.amt, strength * 0.6);
}
// Particle and skid emission from surface and slide state
function emitEffects(dt) {
const spd = Math.hypot(CAR.vx, CAR.vz);
const off = CAR.surf !== SURF.asphalt;
const sliding = CAR.slide > 3 || (CAR.hb && spd > 6);
const fx = Math.sin(CAR.h), fz = Math.cos(CAR.h), rx = Math.cos(CAR.h), rz = -Math.sin(CAR.h);
// Rear wheel contact points
const bx = CAR.x - fx * 1.3, bz = CAR.z - fz * 1.3, by = CAR.y + 0.02;
if (sliding && spd > 4) {
addSkid(0, bx + rx * 0.9, by, bz + rz * 0.9, rx, rz);
addSkid(1, bx - rx * 0.9, by, bz - rz * 0.9, rx, rz);
} else { breakSkid(0); breakSkid(1); }
if ((off || sliding) && spd > 5) {
CAR.dustAcc = (CAR.dustAcc || 0) + dt * spd * 3.0;
while (CAR.dustAcc > 1) {
CAR.dustAcc -= 1;
const col = CAR.surf === SURF.mud ? [0.36, 0.27, 0.16] : (CAR.surf === SURF.grass ? [0.5, 0.56, 0.42] : [0.72, 0.64, 0.5]);
const side = Math.random() < 0.5 ? -1 : 1;
spawnDust(bx + rx * side * 0.9, by, bz + rz * side * 0.9, CAR.vx, CAR.vz, col);
}
}
// Gravel/skid audio: loudest when sliding, gravel hiss when off-road
const gravel = off ? clamp(spd / 25, 0, 0.6) : 0;
const slideAmt = sliding ? clamp(CAR.slide / 8, 0.2, 1) : 0;
updateSkid(Math.max(gravel, slideAmt) * (spd > 3 ? 1 : 0));
}
/* ---------- 7. CAMERA, HUD, MINIMAP, COUNTDOWN, MAIN LOOP ---------- */
// Chase camera trails the car with heading lag; 'cine' is a higher wide shot. Toggle with C.
const CAMS = { mode: 'chase', h: 0, pos: new THREE.Vector3(), look: new THREE.Vector3(), init: false };
function updateCamera(dt) {
const sp = clamp(Math.hypot(CAR.vx, CAR.vz) / CFG.VMAX, 0, 1);
// Camera heading lags the car heading, so turns feel dynamic
let dh = CAR.h - CAMS.h; dh = Math.atan2(Math.sin(dh), Math.cos(dh));
CAMS.h += dh * (1 - Math.exp(-dt * 3.5));
const cfx = Math.sin(CAMS.h), cfz = Math.cos(CAMS.h);
const fx = Math.sin(CAR.h), fz = Math.cos(CAR.h);
let target, look;
if (CAMS.mode === 'chase') {
const back = 7.4 + sp * 2.6 + (CAR.throttle ? 0.4 : 0);
const up = 2.9 + sp * 0.5;
// Slide pushes the camera sideways a little
const side = -CAR.slide * 0.04 * Math.sign(CAR.steer || 0);
target = new THREE.Vector3(CAR.x - cfx * back + cfz * side, CAR.y + up, CAR.z - cfz * back - cfx * side);
look = new THREE.Vector3(CAR.x + fx * 6, CAR.y + 1.2, CAR.z + fz * 6);
} else {
target = new THREE.Vector3(CAR.x - fx * 14 + fz * 9, CAR.y + 8.5, CAR.z - fz * 14 - fx * 9);
look = new THREE.Vector3(CAR.x + fx * 4, CAR.y + 1.0, CAR.z + fz * 4);
}
if (!CAMS.init) { CAMS.pos.copy(target); CAMS.look.copy(look); CAMS.init = true; }
const k = 1 - Math.exp(-dt * (CAMS.mode === 'chase' ? 7 : 2.2));
CAMS.pos.lerp(target, k);
CAMS.look.lerp(look, 1 - Math.exp(-dt * 9));
camera.position.copy(CAMS.pos);
if (SHAKE.amt > 0) {
camera.position.x += (Math.random() - 0.5) * SHAKE.amt * 0.6;
camera.position.y += (Math.random() - 0.5) * SHAKE.amt * 0.4;
SHAKE.amt = Math.max(0, SHAKE.amt - dt * 2.5);
}
camera.lookAt(CAMS.look);
const fovT = CAMS.mode === 'chase' ? 60 + sp * 14 : 52;
camera.fov += (fovT - camera.fov) * Math.min(1, dt * 4);
camera.updateProjectionMatrix();
}
// Static minimap path; world (x,z) is mapped into the 380px canvas once
let miniPath = null, miniXf = null;
function buildMinimap() {
const { N, SX, SZ } = TR;
let x0 = Infinity, x1 = -Infinity, z0 = Infinity, z1 = -Infinity;
for (let i = 0; i < N; i++) {
x0 = Math.min(x0, SX[i]); x1 = Math.max(x1, SX[i]);
z0 = Math.min(z0, SZ[i]); z1 = Math.max(z1, SZ[i]);
}
const W = 380, pad = 26, span = W - pad * 2;
const sc = span / Math.max(x1 - x0, z1 - z0);
miniXf = { x0, z0, sc, offX: pad + (span - (x1 - x0) * sc) / 2, offZ: pad + (span - (z1 - z0) * sc) / 2 };
miniPath = new Path2D();
for (let i = 0; i < N; i++) {
const [px, py] = miniToXY(SX[i], SZ[i]);
if (i === 0) miniPath.moveTo(px, py); else miniPath.lineTo(px, py);
}
miniPath.closePath();
}
function miniToXY(x, z) {
return [miniXf.offX + (x - miniXf.x0) * miniXf.sc, miniXf.offZ + (z - miniXf.z0) * miniXf.sc];
}
function drawMinimap() {
const cv = document.getElementById('mini'), g = cv.getContext('2d');
g.clearRect(0, 0, cv.width, cv.height);
g.lineJoin = 'round';
g.strokeStyle = 'rgba(255,255,255,0.22)'; g.lineWidth = 16; g.stroke(miniPath);
g.strokeStyle = 'rgba(40,40,46,0.95)'; g.lineWidth = 11; g.stroke(miniPath);
// Start/finish bar
const [sx, sy] = miniToXY(TR.SX[0], TR.SZ[0]);
g.fillStyle = '#fff'; g.fillRect(sx - 3, sy - 9, 6, 18);
// Invisible gate positions shown as small markers
for (let j = 1; j < 6; j++) {
const i = Math.floor(j * TR.N / 6);
const [px, py] = miniToXY(TR.SX[i], TR.SZ[i]);
g.fillStyle = 'rgba(250,204,21,.9)'; g.beginPath(); g.arc(px, py, 4, 0, Math.PI * 2); g.fill();
}
// Player dot and heading tick
const [px, py] = miniToXY(CAR.x, CAR.z);
const hx = Math.sin(CAR.h), hz = Math.cos(CAR.h);
g.strokeStyle = '#fff'; g.lineWidth = 3; g.beginPath();
g.moveTo(px, py); g.lineTo(px + hx * 20, py + hz * -20); g.stroke();
g.fillStyle = '#ef4444'; g.beginPath(); g.arc(px, py, 8, 0, Math.PI * 2); g.fill();
}
function fmtTime(ms) {
if (ms == null || !isFinite(ms)) return '--:--.--';
const m = Math.floor(ms / 60000), s = Math.floor(ms / 1000) % 60, c = Math.floor(ms / 10) % 100;
return String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0') + '.' + String(c).padStart(2, '0');
}
let toastTimer = 0;
function flashToast(msg) {
const t = document.getElementById('toast');
t.textContent = msg; t.style.opacity = 1;
clearTimeout(toastTimer); toastTimer = setTimeout(() => { t.style.opacity = 0; }, 1600);
}
function showOverlay(id) { document.getElementById(id).classList.remove('hidden'); }
function hideOverlay(id) { document.getElementById(id).classList.add('hidden'); }
// Speedometer, simulated gear (speed bands) and RPM bar, plus timers
const GEAR_BANDS = [0, 35, 70, 110, 150, 190, 240];
function updateHUD() {
const spd = Math.hypot(CAR.vx, CAR.vz) * 3.6;
document.getElementById('speed').innerHTML = Math.round(spd) + '<small>km/h</small>';
let gear = 1;
for (let g = 1; g < GEAR_BANDS.length; g++) if (spd >= GEAR_BANDS[g - 1]) gear = g;
const lo = GEAR_BANDS[gear - 1], hi = GEAR_BANDS[gear] || 260;
const rpm01 = clamp(0.18 + 0.82 * (spd - lo) / (hi - lo), 0.12, 1);
document.getElementById('rpmFill').style.width = (rpm01 * 100).toFixed(0) + '%';
let gearTxt = gear;
if (CAR.vf < -0.5 && spd < 5) gearTxt = 'R';
if (RACE.state === 'title' || (RACE.state === 'countdown' && RACE.cdElapsed < 0.5)) gearTxt = 'N';
document.getElementById('gear').textContent = gearTxt;
const shown = RACE.state === 'title' || RACE.state === 'countdown' ? 0 : RACE.time;
document.getElementById('timeTxt').textContent = fmtTime(shown);
document.getElementById('lapTxt').textContent = Math.min(CFG.LAPS, RACE.lapsDone + 1) + ' / ' + CFG.LAPS;
document.getElementById('curLapTxt').textContent =
RACE.state === 'running' || RACE.state === 'paused' ? fmtTime(RACE.time - RACE.lapStartTime) : '--:--.--';
document.getElementById('bestTxt').textContent = fmtTime(RACE.best);
document.getElementById('lastTxt').textContent = fmtTime(RACE.lastLap);
return rpm01;
}
// Countdown 3-2-1-GO! with beeps; the race clock starts at GO.
function updateCountdown(dt) {
RACE.cdElapsed += dt;
const stage = Math.floor(RACE.cdElapsed); // 0,1,2 => 3,2,1 ; 3 => GO
const cd = document.getElementById('cdText');
if (stage !== RACE.cdLast) {
RACE.cdLast = stage;
if (stage <= 2) { cd.textContent = String(3 - stage); sfxBeep(false); }
else if (stage === 3) { cd.textContent = 'GO!'; sfxGo(); }
}
if (RACE.cdElapsed >= 3) {
RACE.state = 'running';
RACE.lapStartTime = 0;
setTimeout(() => { if (RACE.state === 'running') document.getElementById('countdown').classList.add('hidden'); }, 700);
}
}
// Starts (or restarts) a race: reset car to the grid, then the countdown.
function startRace() {
initAudio();
placeOnGrid();
RACE.state = 'countdown'; RACE.cdElapsed = 0; RACE.cdLast = -1;
document.getElementById('countdown').classList.remove('hidden');
document.getElementById('cdText').textContent = '3';
hideOverlay('titleOv'); hideOverlay('finishOv'); hideOverlay('pauseOv');
}
// Pause toggles only during countdown/running. Clock is frozen while paused.
function togglePause() {
if (RACE.state === 'running' || RACE.state === 'countdown') {
RACE.prePause = RACE.state;
RACE.state = 'paused';
showOverlay('pauseOv');
} else if (RACE.state === 'paused') {
RACE.state = RACE.prePause || 'running';
hideOverlay('pauseOv');
}
}
/* ---------- MAIN LOOP ---------- */
let lastT = performance.now();
function frame(now) {
requestAnimationFrame(frame);
const dt = Math.min(0.033, Math.max(0, (now - lastT) / 1000)); lastT = now;
// One-shot hotkeys
if (INPUT.camToggle) {
INPUT.camToggle = false;
CAMS.mode = CAMS.mode === 'chase' ? 'cine' : 'chase';
flashToast(CAMS.mode === 'chase' ? 'CHASE CAM' : 'CINEMATIC CAM');
}
if (INPUT.pauseToggle) {
INPUT.pauseToggle = false;
if (RACE.state === 'running' || RACE.state === 'countdown' || RACE.state === 'paused') togglePause();
}
if (INPUT.restart) {
INPUT.restart = false;
if (RACE.state !== 'title') startRace();
}
const ctl = readControls();
CAR.throttle = ctl.throttle; CAR.brake = ctl.brake;
let rpm01 = 0.12;
if (RACE.state === 'countdown') {
updateCountdown(dt);
// Car is held on the grid with the handbrake during the countdown
stepCar(dt, { throttle: 0, brake: 0, steer: 0, handbrake: true });
} else if (RACE.state === 'running') {
RACE.time += dt * 1000;
// Sub-stepping keeps the collision response stable at 60fps and on frame hitches
const sub = 3, h = dt / sub;
for (let k = 0; k < sub; k++) stepCar(h, ctl);
emitEffects(dt);
} else if (RACE.state === 'finished') {
// Car coasts to a stop after the finish line
stepCar(dt, { throttle: 0, brake: 1, steer: 0, handbrake: false });
}
if (RACE.state === 'paused') updateSkid(0);
rpm01 = updateHUD();
const engineOn = RACE.state !== 'title';
updateEngine(rpm01, CAR.throttle, engineOn);
syncCarVisual(dt);
updateParticles(dt);
updateCamera(dt);
drawMinimap();
renderer.render(scene, camera);
}
// Applies physics state to the car mesh: pose, wheel spin and steer, brake lights
function syncCarVisual(dt) {
const r = carRig.root;
r.position.set(CAR.x, CAR.y + 0.02, CAR.z);
r.rotation.order = 'YXZ';
r.rotation.set(CAR.pitch, CAR.h, CAR.roll);
for (const w of carRig.wheels) {
w.spin.rotation.x += (CAR.vf / 0.44) * dt;
if (w.front) w.pivot.rotation.y = CAR.wheelSteer;
}
const braking = (CAR.brake && CAR.vf > 1) || CAR.hb;
for (const m of carRig.brakeMats) m.color.setHex(braking ? 0xff5a4a : 0x7a0c05);
}
// Window resize keeps the canvas full-screen and the camera aspect correct
function onResize() {
const w = window.innerWidth, h = window.innerHeight;
renderer.setSize(w, h, false);
camera.aspect = w / h; camera.updateProjectionMatrix();
}
/* ---------- BOOTSTRAP ---------- */
function init() {
buildTrack();
initRenderer();
buildTerrain();
buildRoad();
buildRails();
buildScenery();
buildCar();
buildParticles();
buildMinimap();
placeOnGrid();
CAMS.h = CAR.h;
setupTouch();
window.addEventListener('resize', onResize);
document.getElementById('startBtn').addEventListener('click', startRace);
document.getElementById('resumeBtn').addEventListener('click', () => { if (RACE.state === 'paused') togglePause(); });
const restart = () => { hideOverlay('pauseOv'); hideOverlay('finishOv'); startRace(); };
document.getElementById('restartBtn1').addEventListener('click', restart);
document.getElementById('restartBtn2').addEventListener('click', restart);
showOverlay('titleOv');
updateCamera(0.016);
requestAnimationFrame(frame);
}
init();
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"anthropic/claude-haiku-5.5","provider":"openrouter","persona":"full","sessionId":"0ed8eb45-a916-4896-a3dd-42c551bcc746","tokensIn":1270255,"tokensOut":200405,"tokensTotal":1470660,"cost":0.14013224000000002,"turns":23,"toolCalls":27,"failedToolCalls":0,"timestamp":"2026-10-08T01:04:32.661Z"} -->