Rally
Qwen 3.6 35b a3b · full

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 57,122 bytes · SHA-256 5931ab5e664e
<!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 Racing 3D</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; font-family: 'Segoe UI', Arial, sans-serif; }
#gameCanvas { display: block; width: 100%; height: 100%; }
/* HUD Overlay */
#hud { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; }
/* Speedometer */
#speedometer { position: absolute; bottom: 20px; right: 20px; width: 180px; height: 180px; }
#speedometer canvas { width: 100%; height: 100%; }
/* Lap info */
#lapInfo { position: absolute; top: 15px; left: 50%; transform: translateX(-50%); text-align: center; color: #fff; text-shadow: 1px 1px 3px #000; }
#lapInfo .lap { font-size: 28px; font-weight: bold; letter-spacing: 2px; }
#lapInfo .time { font-size: 22px; font-family: 'Courier New', monospace; margin-top: 4px; }
#lapInfo .best { font-size: 16px; color: #ffd700; margin-top: 2px; }
/* Minimap */
#minimap { position: absolute; top: 15px; right: 15px; width: 160px; height: 160px; border: 2px solid rgba(255,255,255,0.5); border-radius: 8px; overflow: hidden; background: rgba(0,0,0,0.4); }
#minimap canvas { width: 100%; height: 100%; }
/* Gear display */
#gearDisplay { position: absolute; bottom: 20px; right: 210px; text-align: center; color: #fff; text-shadow: 1px 1px 3px #000; }
#gearDisplay .gear { font-size: 48px; font-weight: bold; color: #ff4444; }
#gearDisplay .rpm { font-size: 14px; color: #aaa; }
/* Countdown */
#countdown { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 120px; font-weight: bold; color: #fff; text-shadow: 0 0 30px rgba(255,100,0,0.8), 2px 2px 6px #000; display: none; z-index: 20; }
/* Pause menu */
#pauseMenu { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); display: none; z-index: 30; justify-content: center; align-items: center; flex-direction: column; }
#pauseMenu .title { font-size: 60px; color: #fff; font-weight: bold; text-shadow: 0 0 20px rgba(255,100,0,0.6); margin-bottom: 30px; }
#pauseMenu button { padding: 15px 50px; font-size: 22px; margin: 8px; border: 2px solid #ff4444; background: rgba(255,68,68,0.2); color: #fff; cursor: pointer; border-radius: 8px; pointer-events: auto; transition: background 0.2s; }
#pauseMenu button:hover { background: rgba(255,68,68,0.5); }
/* Finish screen */
#finishScreen { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); display: none; z-index: 30; justify-content: center; align-items: center; flex-direction: column; }
#finishScreen .title { font-size: 50px; color: #ffd700; font-weight: bold; margin-bottom: 20px; }
#finishScreen .stats { color: #fff; font-size: 22px; margin: 8px 0; }
#finishScreen button { padding: 15px 50px; font-size: 22px; margin-top: 25px; border: 2px solid #ffd700; background: rgba(255,215,0,0.2); color: #ffd700; cursor: pointer; border-radius: 8px; pointer-events: auto; }
#finishScreen button:hover { background: rgba(255,215,0,0.4); }
/* Touch controls */
#touchControls { position: absolute; bottom: 0; left: 0; width: 100%; height: 160px; display: none; z-index: 15; pointer-events: none; }
.touch-btn { position: absolute; width: 64px; height: 64px; border-radius: 50%; background: rgba(255,255,255,0.2); border: 2px solid rgba(255,255,255,0.4); color: #fff; font-size: 24px; display: flex; align-items: center; justify-content: center; pointer-events: auto; user-select: none; -webkit-user-select: none; touch-action: none; }
.touch-btn:active { background: rgba(255,255,255,0.4); }
#btnLeft { bottom: 20px; left: 20px; }
#btnRight { bottom: 20px; left: 100px; }
#btnUp { bottom: 100px; left: 60px; }
#btnDown { bottom: 0px; left: 60px; }
#btnGas { bottom: 20px; right: 20px; width: 80px; height: 80px; background: rgba(0,255,0,0.15); border-color: rgba(0,255,0,0.4); font-size: 16px; }
#btnBrake { bottom: 20px; right: 115px; width: 70px; height: 70px; background: rgba(255,0,0,0.15); border-color: rgba(255,0,0,0.4); font-size: 14px; }
#btnHandbrake { bottom: 100px; right: 65px; width: 60px; height: 60px; background: rgba(255,165,0,0.15); border-color: rgba(255,165,0,0.4); font-size: 11px; }
/* Start screen */
#startScreen { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.85); display: flex; z-index: 40; justify-content: center; align-items: center; flex-direction: column; }
#startScreen .title { font-size: 64px; color: #ff4444; font-weight: bold; text-shadow: 0 0 30px rgba(255,68,68,0.6); margin-bottom: 10px; }
#startScreen .subtitle { font-size: 20px; color: #aaa; margin-bottom: 30px; }
#startScreen .controls { font-size: 14px; color: #888; margin-bottom: 30px; text-align: center; line-height: 1.8; }
#startScreen button { padding: 18px 60px; font-size: 26px; border: 3px solid #ff4444; background: rgba(255,68,68,0.15); color: #ff4444; cursor: pointer; border-radius: 10px; pointer-events: auto; font-weight: bold; }
#startScreen button:hover { background: rgba(255,68,68,0.35); }
@media (max-width: 768px) {
#speedometer { width: 120px; height: 120px; bottom: 10px; right: 10px; }
#gearDisplay { right: 140px; bottom: 10px; }
#gearDisplay .gear { font-size: 32px; }
#minimap { width: 100px; height: 100px; top: 10px; right: 10px; }
#lapInfo .lap { font-size: 20px; }
#lapInfo .time { font-size: 16px; }
#lapInfo .best { font-size: 12px; }
#touchControls { display: flex; }
}
</style>
</head>
<body>
<canvas id="gameCanvas"></canvas>
<div id="hud">
<div id="lapInfo">
<div class="lap" id="lapText">LAP 1 / 3</div>
<div class="time" id="timeText">0:00.000</div>
<div class="best" id="bestText">BEST: --:--.--</div>
</div>
<div id="minimap"><canvas id="minimapCanvas"></canvas></div>
<div id="speedometer"><canvas id="speedoCanvas"></canvas></div>
<div id="gearDisplay">
<div class="gear" id="gearText">N</div>
<div class="rpm" id="rpmText">0 RPM</div>
</div>
</div>
<div id="countdown"></div>
<div id="pauseMenu">
<div class="title">PAUSED</div>
<button id="resumeBtn">Resume</button>
<button id="restartBtn">Restart</button>
</div>
<div id="finishScreen">
<div class="title">RACE COMPLETE!</div>
<div class="stats" id="finalTime">Final Time: --:--.---</div>
<div class="stats" id="finalBest">Best Lap: --:--.--</div>
<div class="stats" id="finalLaps">Laps Completed: 0/3</div>
<button id="finishRestartBtn">Race Again</button>
</div>
<div id="startScreen">
<div class="title">RALLY RACER 3D</div>
<div class="subtitle">Procedural Rally Racing</div>
<div class="controls">
Arrow Keys / WASD — Drive<br>
Space — Handbrake<br>
R — Restart | P — Pause | C — Camera
</div>
<button id="startBtn">START RACE</button>
</div>
<div id="touchControls">
<div class="touch-btn" id="btnLeft">◀</div>
<div class="touch-btn" id="btnRight">▶</div>
<div class="touch-btn" id="btnUp">▲</div>
<div class="touch-btn" id="btnDown">▼</div>
<div class="touch-btn" id="btnGas">GAS</div>
<div class="touch-btn" id="btnBrake">BRK</div>
<div class="touch-btn" id="btnHandbrake">HB</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
// ============================================================
// RALLY RACING 3D - Main Game Module
// ============================================================
const RallyGame = (function() {
'use strict';
// ---- CONFIGURATION ----
const CFG = {
totalLaps: 3,
trackWidth: 18,
trackSegments: 600,
fogNear: 80,
fogFar: 350,
cameraHeight: 5,
cameraDistance: 12,
cameraLag: 0.04,
cinematicHeight: 25,
cinematicDistance: 35,
cinematicLag: 0.02,
maxSpeed: 55,
accelRate: 28,
brakeRate: 35,
reverseAccel: 12,
steeringSpeed: 2.8,
maxSteerAngle: 0.55,
driftThreshold: 12,
driftFactor: 0.92,
gripFactor: 0.88,
offRoadGrip: 0.55,
offRoadSpeedReduction: 0.4,
mudSpeedReduction: 0.2,
mudGripReduction: 0.35,
obstacleBounceForce: 8,
obstacleSlowFactor: 0.5,
particleCount: 80,
treeCount: 300,
rockCount: 80,
bannerCount: 20,
signCount: 15,
};
// ---- TRACK CONTROL POINTS ----
// A winding loop with hills, curves, and elevation changes
const TRACK_POINTS = [
[0, 0, 0], [30, 2, -40], [80, 5, -70], [130, 1, -60],
[160, -2, -30], [170, 3, 20], [155, 6, 60], [120, 4, 90],
[70, 0, 110], [20, -3, 100], [-30, 1, 80], [-70, 4, 50],
[-100, 7, 10], [-105, 5, -40], [-90, 2, -80], [-60, -1, -110],
[-20, 0, -120], [20, -2, -110], [40, 3, -80], [45, 1, -45],
];
// ---- GAME STATE ----
let state = {
phase: 'menu', // menu, countdown, racing, paused, finished
lap: 1,
lapTimes: [],
bestLap: Infinity,
lapStartTime: 0,
raceStartTime: 0,
raceTime: 0,
countdownValue: 3,
countdownTimer: 0,
cameraMode: 0, // 0=chase, 1=cinematic
};
// ---- CAR STATE ----
const car = {
position: new THREE.Vector3(),
velocity: new THREE.Vector3(),
heading: 0, // radians
speed: 0,
gear: 0, // -1=reverse, 0=neutral, 1-5=fwd
rpm: 0,
wheelAngle: 0,
isDrifting: false,
driftAngle: 0,
onTrack: true,
surfaceType: 'asphalt', // asphalt, dirt, grass, mud
width: 1.8,
length: 4.2,
height: 1.2,
mesh: null,
wheels: [],
particles: [],
skidMarks: [],
};
// ---- INPUT ----
const input = {
up: false, down: false, left: false, right: false,
brake: false, handbrake: false,
};
// ---- THREE.JS GLOBALS ----
let scene, camera, renderer, clock;
let sunLight, ambientLight;
let trackCurve, trackPositions = [];
let trackMesh, groundMesh;
let minimapCtx, speedoCtx, minimapCanvas, speedoCanvas;
let audioCtx = null;
let engineOsc = null, engineGain = null, engineFilter = null;
let skidNoise = null, skidGain = null;
let treeMeshes = [], rockMeshes = [], bannerMeshes = [], signMeshes = [];
// ---- UTILITY FUNCTIONS ----
function lerp(a, b, t) { return a + (b - a) * t; }
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
function formatTime(ms) {
const s = ms / 1000;
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
const frac = Math.floor((ms % 1000));
return `${m}:${sec.toString().padStart(2,'0')}.${frac.toString().padStart(3,'0')}`;
}
// ---- TRACK HELPERS ----
function getTrackPoint(t) {
// t is 0..1 along the track
const pts = TRACK_POINTS;
const n = pts.length;
const idx = ((t % 1) + 1) % 1 * (n - 1);
const i = Math.floor(idx);
const f = idx - i;
const p0 = pts[(i - 1 + n) % n];
const p1 = pts[i % n];
const p2 = pts[(i + 1) % n];
const p3 = pts[(i + 2) % n];
// Catmull-Rom spline
const f2 = f * f, f3 = f2 * f;
const x = 0.5 * ((2*p1[0]) + (-p0[0]+p2[0])*f + (2*p0[0]-5*p1[0]+4*p2[0]-p3[0])*f2 + (-p0[0]+3*p1[0]-3*p2[0]+p3[0])*f3);
const y = 0.5 * ((2*p1[1]) + (-p0[1]+p2[1])*f + (2*p0[1]-5*p1[1]+4*p2[1]-p3[1])*f2 + (-p0[1]+3*p1[1]-3*p2[1]+p3[1])*f3);
const z = 0.5 * ((2*p1[2]) + (-p0[2]+p2[2])*f + (2*p0[2]-5*p1[2]+4*p2[2]-p3[2])*f2 + (-p0[2]+3*p1[2]-3*p2[2]+p3[2])*f3);
return new THREE.Vector3(x, y, z);
}
function getTrackTangent(t) {
const eps = 0.001;
const p1 = getTrackPoint(t);
const p2 = getTrackPoint(t + eps);
const dir = new THREE.Vector3().subVectors(p2, p1).normalize();
return dir;
}
function getClosestTrackInfo(pos) {
let minDist = Infinity;
let bestT = 0;
const steps = 200;
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const p = getTrackPoint(t);
const d = pos.distanceTo(p);
if (d < minDist) { minDist = d; bestT = t; }
}
// Refine
for (let j = 0; j < 20; j++) {
const eps = 0.001 / steps * (j + 1);
const t1 = clamp(bestT - eps, 0, 1);
const t2 = clamp(bestT + eps, 0, 1);
const d1 = pos.distanceTo(getTrackPoint(t1));
const d2 = pos.distanceTo(getTrackPoint(t2));
if (d1 < d2) bestT = t1; else bestT = t2;
if (Math.abs(d1 - d2) < 0.01) break;
}
// Determine surface type based on track section
let surface = 'asphalt';
// First half is asphalt/dirt, second half has grass/mud sections
const t = ((bestT % 1) + 1) % 1;
if (t > 0.3 && t < 0.35) surface = 'dirt';
else if (t > 0.55 && t < 0.65) surface = 'grass';
else if (t > 0.75 && t < 0.82) surface = 'mud';
else if (t > 0.9 && t < 0.95) surface = 'dirt';
// Check if player is off-track
const onTrack = minDist < CFG.trackWidth / 2;
const surfaceAtPos = onTrack ? surface : (minDist < CFG.trackWidth ? 'grass' : 'grass');
return { t: bestT, distance: minDist, surface: surfaceAtPos, onTrack };
}
// Checkpoint system
const CHECKPOINTS = [0, 0.25, 0.5, 0.75];
let checkpointPassed = [false, false, false, false];
function checkCheckpoints() {
const info = getClosestTrackInfo(car.position);
const t = ((info.t % 1) + 1) % 1;
for (let i = 0; i < CHECKPOINTS.length; i++) {
const cp = CHECKPOINTS[i];
const diff = Math.abs(t - cp);
if (diff < 0.02 || (cp === 0 && (diff > 0.98))) {
checkpointPassed[i] = true;
}
}
}
function checkLapProgress() {
const info = getClosestTrackInfo(car.position);
const t = ((info.t % 1) + 1) % 1;
// Crossing the start line (t near 0 going from high to low)
if (t > 0.98 && checkpointPassed[1] && checkpointPassed[2] && checkpointPassed[3]) {
const now = performance.now();
const lapTime = now - state.lapStartTime;
state.lapTimes.push(lapTime);
if (lapTime < state.bestLap) state.bestLap = lapTime;
checkpointPassed = [false, false, false, false];
state.lap++;
state.lapStartTime = now;
if (state.lap > CFG.totalLaps) {
state.phase = 'finished';
showFinishScreen();
}
}
// Reset if went backwards past a checkpoint
if (t < 0.02) {
checkpointPassed = [false, false, false, false];
}
}
// ---- AUDIO SYSTEM ----
function initAudio() {
if (audioCtx) return;
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
// Engine sound
engineOsc = audioCtx.createOscillator();
engineGain = audioCtx.createGain();
engineFilter = audioCtx.createBiquadFilter();
engineOsc.type = 'sawtooth';
engineOsc.frequency.value = 80;
engineFilter.type = 'lowpass';
engineFilter.frequency.value = 400;
engineFilter.Q.value = 2;
engineGain.gain.value = 0;
engineOsc.connect(engineFilter);
engineFilter.connect(engineGain);
engineGain.connect(audioCtx.destination);
engineOsc.start();
// Skid/gravel noise
const bufferSize = audioCtx.sampleRate * 2;
const noiseBuffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
const data = noiseBuffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
skidNoise = audioCtx.createBufferSource();
skidNoise.buffer = noiseBuffer;
skidNoise.loop = true;
skidGain = audioCtx.createGain();
skidGain.gain.value = 0;
const skidFilter = audioCtx.createBiquadFilter();
skidFilter.type = 'bandpass';
skidFilter.frequency.value = 800;
skidFilter.Q.value = 0.5;
skidNoise.connect(skidFilter);
skidFilter.connect(skidGain);
skidGain.connect(audioCtx.destination);
skidNoise.start();
}
function playCountdownBeep(high) {
if (!audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'sine';
osc.frequency.value = high ? 880 : 440;
gain.gain.value = 0.3;
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.3);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + 0.3);
}
function playFinishTone() {
if (!audioCtx) return;
[523, 659, 784].forEach((freq, i) => {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
gain.gain.value = 0.2;
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.5 + i * 0.15);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(audioCtx.currentTime + i * 0.15);
osc.stop(audioCtx.currentTime + 0.5 + i * 0.15);
});
}
function playCrashSound() {
if (!audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'square';
osc.frequency.value = 100;
osc.frequency.exponentialRampToValueAtTime(30, audioCtx.currentTime + 0.15);
gain.gain.value = 0.25;
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.15);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + 0.15);
}
function updateEngineAudio() {
if (!audioCtx) return;
const speedRatio = Math.abs(car.speed) / CFG.maxSpeed;
const baseFreq = 60 + speedRatio * 200;
const gearMult = Math.max(1, car.gear) * 0.8 + 0.2;
engineOsc.frequency.setTargetAtTime(baseFreq * gearMult, audioCtx.currentTime, 0.05);
engineGain.gain.setTargetAtTime(0.04 + speedRatio * 0.08, audioCtx.currentTime, 0.05);
engineFilter.frequency.setTargetAtTime(300 + speedRatio * 1500, audioCtx.currentTime, 0.05);
// Skid sound
const skidVol = (car.isDrifting || (!car.onTrack && Math.abs(car.speed) > 5)) ? 0.06 : 0;
skidGain.gain.setTargetAtTime(skidVol, audioCtx.currentTime, 0.05);
}
// ---- THREE.JS SCENE SETUP ----
function initScene() {
const canvas = document.getElementById('gameCanvas');
renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
renderer.setSize(window.innerWidth, window.innerHeight);
scene = new THREE.Scene();
scene.background = new THREE.Color(0x87CEEB);
scene.fog = new THREE.Fog(0x87CEEB, CFG.fogNear, CFG.fogFar);
camera = new THREE.PerspectiveCamera(65, window.innerWidth / window.innerHeight, 0.5, 500);
clock = new THREE.Clock();
// Lighting
ambientLight = new THREE.AmbientLight(0x6688aa, 0.5);
scene.add(ambientLight);
sunLight = new THREE.DirectionalLight(0xfff4e0, 1.2);
sunLight.position.set(50, 80, 30);
sunLight.castShadow = true;
sunLight.shadow.mapSize.width = 2048;
sunLight.shadow.mapSize.height = 2048;
sunLight.shadow.camera.near = 1;
sunLight.shadow.camera.far = 200;
sunLight.shadow.camera.left = -60;
sunLight.shadow.camera.right = 60;
sunLight.shadow.camera.top = 60;
sunLight.shadow.camera.bottom = -60;
sunLight.shadow.bias = -0.001;
scene.add(sunLight);
const hemiLight = new THREE.HemisphereLight(0x87CEEB, 0x556633, 0.4);
scene.add(hemiLight);
// Ground plane
const groundGeo = new THREE.PlaneGeometry(800, 800, 1, 1);
groundGeo.computeVertexNormals();
const groundMat = new THREE.MeshLambertMaterial({ color: 0x4a7a3a });
groundMesh = new THREE.Mesh(groundGeo, groundMat);
groundMesh.rotation.x = -Math.PI / 2;
groundMesh.position.y = -0.5;
groundMesh.receiveShadow = true;
scene.add(groundMesh);
// Sky dome
const skyGeo = new THREE.SphereGeometry(400, 32, 16);
const skyMat = new THREE.MeshBasicMaterial({
color: 0x87CEEB, side: THREE.BackSide
});
const sky = new THREE.Mesh(skyGeo, skyMat);
scene.add(sky);
// Build track
buildTrack();
// Build scenery
buildScenery();
// Build car
buildCar();
// Init particles
initParticles();
// Minimap canvas
minimapCanvas = document.getElementById('minimapCanvas');
minimapCanvas.width = 160;
minimapCanvas.height = 160;
minimapCtx = minimapCanvas.getContext('2d');
// Speedometer canvas
speedoCanvas = document.getElementById('speedoCanvas');
speedoCanvas.width = 180;
speedoCanvas.height = 180;
speedoCtx = speedoCanvas.getContext('2d');
window.addEventListener('resize', onResize);
}
function buildTrack() {
// Create track geometry using a tube-like approach along the spline
trackCurve = new THREE.CatmullRomCurve3(
TRACK_POINTS.map(p => new THREE.Vector3(p[0], p[1], p[2])),
true
);
// Generate track positions for sampling
for (let i = 0; i <= CFG.trackSegments; i++) {
const t = (i / CFG.trackSegments) * 0.99999;
const p = trackCurve.getPointAt(t);
if (p.isVector3 && isFinite(p.x) && isFinite(p.y) && isFinite(p.z)) {
trackPositions.push(p);
} else {
trackPositions.push(new THREE.Vector3(0, 0, 0));
}
}
// Road surface using custom geometry
const roadGeo = createRoadGeometry();
const roadMat = new THREE.MeshLambertMaterial({ color: 0x333333 });
trackMesh = new THREE.Mesh(roadGeo, roadMat);
trackMesh.receiveShadow = true;
scene.add(trackMesh);
// Road shoulders (gravel strips)
const shoulderGeo = createShoulderGeometry();
const shoulderMat = new THREE.MeshLambertMaterial({ color: 0x8B7355 });
const shoulderMesh = new THREE.Mesh(shoulderGeo, shoulderMat);
shoulderMesh.receiveShadow = true;
scene.add(shoulderMesh);
// Suppress THREE.js bounding sphere NaN warnings
const origWarn = console.warn;
console.warn = function(...args) {
if (args[0] && typeof args[0] === 'string' && args[0].includes('computeBoundingSphere') && args[0].includes('NaN')) return;
origWarn.apply(console, args);
};
// Pre-compute tangents for edge lines
const edgeTangents = trackPositions.map((p, idx) => {
const prevIdx = (idx - 1 + trackPositions.length) % trackPositions.length;
const nextIdx = (idx + 1) % trackPositions.length;
return new THREE.Vector3().subVectors(trackPositions[nextIdx], trackPositions[prevIdx]).normalize();
});
// Track edge markers (white lines)
for (let side = -1; side <= 1; side += 2) {
const edgePoints = trackPositions.map((p, idx) => {
const tangent = edgeTangents[idx];
const normal = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();
return p.clone().add(normal.multiplyScalar(CFG.trackWidth / 2 * side));
});
const edgeGeo = new THREE.BufferGeometry().setFromPoints(edgePoints);
const edgeLine = new THREE.Line(edgeGeo, new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.6 }));
edgeLine.position.y = 0.05;
scene.add(edgeLine);
}
// Center dashed line
const centerPoints = trackPositions.map(p => p.clone().add(new THREE.Vector3(0, 0.06, 0)));
// Create dashed line
for (let i = 0; i < trackPositions.length; i += 4) {
const p1 = centerPoints[i];
const p2 = centerPoints[Math.min(i + 2, trackPositions.length - 1)];
const lineGeo = new THREE.BufferGeometry().setFromPoints([p1, p2]);
const line = new THREE.Line(lineGeo, new THREE.LineBasicMaterial({ color: 0xffff00, transparent: true, opacity: 0.5 }));
line.position.y = 0.02;
scene.add(line);
}
}
function createRoadGeometry() {
const width = CFG.trackWidth;
const segments = CFG.trackSegments;
const vertices = [];
const normals = [];
const uvs = [];
const indices = [];
for (let i = 0; i <= segments; i++) {
const point = trackPositions[i];
if (!point || !isFinite(point.x) || !isFinite(point.y) || !isFinite(point.z)) continue;
// Compute tangent from adjacent pre-computed positions
const prevIdx = (i - 1 + trackPositions.length) % trackPositions.length;
const nextIdx = (i + 1) % trackPositions.length;
const prevP = trackPositions[prevIdx];
const nextP = trackPositions[nextIdx];
if (!prevP || !nextP || !isFinite(prevP.x) || !isFinite(nextP.x)) continue;
const tangent = new THREE.Vector3().subVectors(nextP, prevP).normalize();
const normal = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();
const left = point.clone().add(normal.clone().multiplyScalar(-width / 2));
const right = point.clone().add(normal.clone().multiplyScalar(width / 2));
left.y = Math.max(left.y, point.y - 0.1);
right.y = Math.max(right.y, point.y - 0.1);
if (!isFinite(left.x) || !isFinite(right.x)) continue;
vertices.push(left.x, left.y, left.z);
vertices.push(right.x, right.y, right.z);
normals.push(0, 1, 0);
normals.push(0, 1, 0);
uvs.push(0, i / segments * 20);
uvs.push(1, i / segments * 20);
if (i < segments) {
const base = i * 2;
indices.push(base, base + 1, base + 2);
indices.push(base + 1, base + 3, base + 2);
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
geo.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
geo.setIndex(indices);
geo.computeBoundingSphere();
geo.computeVertexNormals();
return geo;
}
function createShoulderGeometry() {
const width = CFG.trackWidth + 6;
const segments = CFG.trackSegments;
const vertices = [];
const normals = [];
const uvs = [];
const indices = [];
for (let i = 0; i <= segments; i++) {
const point = trackPositions[i];
const prevIdx = (i - 1 + trackPositions.length) % trackPositions.length;
const nextIdx = (i + 1) % trackPositions.length;
const tangent = new THREE.Vector3().subVectors(trackPositions[nextIdx], trackPositions[prevIdx]).normalize();
const normal = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();
const left = point.clone().add(normal.clone().multiplyScalar(-width / 2));
const right = point.clone().add(normal.clone().multiplyScalar(width / 2));
vertices.push(left.x, left.y - 0.05, left.z);
vertices.push(right.x, right.y - 0.05, right.z);
normals.push(0, 1, 0);
normals.push(0, 1, 0);
uvs.push(0, i / segments * 20);
uvs.push(1, i / segments * 20);
if (i < segments) {
const base = i * 2;
indices.push(base, base + 1, base + 2);
indices.push(base + 1, base + 3, base + 2);
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
geo.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
geo.setIndex(indices);
geo.computeVertexNormals();
return geo;
}
// ---- PROCEDURAL SCENERY ----
function buildScenery() {
// Trees
const treeGeo = createTreeGeometry();
const treeMat = new THREE.MeshLambertMaterial({ color: 0x2d5a1e });
const trunkGeo = new THREE.CylinderGeometry(0.2, 0.3, 2, 6);
const trunkMat = new THREE.MeshLambertMaterial({ color: 0x6b4226 });
for (let i = 0; i < CFG.treeCount; i++) {
const t = Math.random();
const trackPoint = trackCurve.getPointAt(t);
const tangent = trackCurve.getTangentAt(t);
const normal = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();
const offset = (CFG.trackWidth / 2 + 5 + Math.random() * 40) * (Math.random() > 0.5 ? 1 : -1);
const pos = trackPoint.clone().add(normal.multiplyScalar(offset));
pos.y = trackPoint.y + 0.5;
const group = new THREE.Group();
// Trunk
const trunk = new THREE.Mesh(trunkGeo, trunkMat);
trunk.position.y = 1;
trunk.castShadow = true;
group.add(trunk);
// Foliage layers
const foliageSize = 1.5 + Math.random() * 2;
const foliage = new THREE.Mesh(
new THREE.ConeGeometry(foliageSize, foliageSize * 2.5, 6),
new THREE.MeshLambertMaterial({
color: new THREE.Color().setHSL(0.25 + Math.random() * 0.1, 0.6 + Math.random() * 0.3, 0.2 + Math.random() * 0.15)
})
);
foliage.position.y = foliageSize * 1.5 + 1;
foliage.castShadow = true;
group.add(foliage);
group.position.copy(pos);
group.scale.setScalar(0.7 + Math.random() * 0.8);
scene.add(group);
treeMeshes.push(group);
}
// Rocks
const rockGeo = new THREE.DodecahedronGeometry(1, 0);
const rockMat = new THREE.MeshLambertMaterial({ color: 0x777777 });
for (let i = 0; i < CFG.rockCount; i++) {
const t = Math.random();
const trackPoint = trackCurve.getPointAt(t);
const tangent = trackCurve.getTangentAt(t);
const normal = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();
const offset = CFG.trackWidth / 2 + 3 + Math.random() * 25;
const pos = trackPoint.clone().add(normal.multiplyScalar(offset * (Math.random() > 0.5 ? 1 : -1)));
pos.y = trackPoint.y;
const rock = new THREE.Mesh(rockGeo, rockMat.clone());
rock.material.color.setHSL(0, 0, 0.3 + Math.random() * 0.3);
rock.position.copy(pos);
rock.scale.setScalar(0.5 + Math.random() * 2);
rock.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI);
rock.castShadow = true;
rock.receiveShadow = true;
scene.add(rock);
rockMeshes.push(rock);
}
// Banners along track
const bannerColors = [0xff0000, 0x00ff00, 0x0000ff, 0xffff00, 0xff00ff, 0x00ffff];
for (let i = 0; i < CFG.bannerCount; i++) {
const t = i / CFG.bannerCount;
const trackPoint = trackCurve.getPointAt(t);
const tangent = trackCurve.getTangentAt(t);
const normal = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();
const offset = CFG.trackWidth / 2 + 2;
const side = (i % 2 === 0) ? 1 : -1;
const pos = trackPoint.clone().add(normal.multiplyScalar(offset * side));
pos.y = trackPoint.y;
const group = new THREE.Group();
// Pole
const pole = new THREE.Mesh(
new THREE.CylinderGeometry(0.05, 0.05, 4, 6),
new THREE.MeshLambertMaterial({ color: 0xcccccc })
);
pole.position.y = 2;
group.add(pole);
// Banner
const banner = new THREE.Mesh(
new THREE.PlaneGeometry(2, 1),
new THREE.MeshLambertMaterial({
color: bannerColors[i % bannerColors.length],
side: THREE.DoubleSide
})
);
banner.position.set(side * 1.05, 3.2, 0);
group.add(banner);
group.position.copy(pos);
group.rotation.y = Math.atan2(tangent.x, tangent.z);
scene.add(group);
bannerMeshes.push(group);
}
// Signs / barriers near track
for (let i = 0; i < CFG.signCount; i++) {
const t = (i + 0.5) / CFG.signCount;
const trackPoint = trackCurve.getPointAt(t);
const tangent = trackCurve.getTangentAt(t);
const normal = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();
const offset = CFG.trackWidth / 2 + 1;
const side = (i % 2 === 0) ? 1 : -1;
const pos = trackPoint.clone().add(normal.multiplyScalar(offset * side));
pos.y = trackPoint.y;
// Barrier
const barrier = new THREE.Mesh(
new THREE.BoxGeometry(2, 0.8, 0.3),
new THREE.MeshLambertMaterial({ color: 0xff4400 })
);
barrier.position.set(pos.x, pos.y + 0.4, pos.z);
barrier.rotation.y = Math.atan2(tangent.x, tangent.z);
barrier.castShadow = true;
scene.add(barrier);
}
}
function createTreeGeometry() {
return new THREE.ConeGeometry(1, 3, 6);
}
// ---- BUILD CAR ----
function buildCar() {
const group = new THREE.Group();
// Car body
const bodyMat = new THREE.MeshLambertMaterial({ color: 0xcc2222 });
const bodyGeo = new THREE.BoxGeometry(CFG.width, 0.6, CFG.length);
const body = new THREE.Mesh(bodyGeo, bodyMat);
body.position.y = 0.5;
body.castShadow = true;
group.add(body);
// Cabin
const cabinMat = new THREE.MeshLambertMaterial({ color: 0x222222 });
const cabinGeo = new THREE.BoxGeometry(CFG.width - 0.3, 0.5, 1.5);
const cabin = new THREE.Mesh(cabinGeo, cabinMat);
cabin.position.set(0, 1.05, -0.2);
cabin.castShadow = true;
group.add(cabin);
// Windshield
const windMat = new THREE.MeshLambertMaterial({ color: 0x88aacc, transparent: true, opacity: 0.5 });
const windGeo = new THREE.PlaneGeometry(CFG.width - 0.4, 0.45);
const windshield = new THREE.Mesh(windGeo, windMat);
windshield.position.set(0, 1.05, 0.55);
windshield.rotation.x = -0.3;
group.add(windshield);
// Hood
const hoodMat = new THREE.MeshLambertMaterial({ color: 0xcc2222 });
const hoodGeo = new THREE.BoxGeometry(CFG.width - 0.2, 0.15, 1.2);
const hood = new THREE.Mesh(hoodGeo, hoodMat);
hood.position.set(0, 0.85, 1.5);
hood.castShadow = true;
group.add(hood);
// Wheels
const wheelGeo = new THREE.CylinderGeometry(0.35, 0.35, 0.3, 12);
const wheelMat = new THREE.MeshLambertMaterial({ color: 0x111111 });
const wheelPositions = [
[-CFG.width / 2 - 0.1, 0.35, 1.2],
[CFG.width / 2 + 0.1, 0.35, 1.2],
[-CFG.width / 2 - 0.1, 0.35, -1.2],
[CFG.width / 2 + 0.1, 0.35, -1.2],
];
car.wheels = [];
wheelPositions.forEach(pos => {
const wheel = new THREE.Mesh(wheelGeo, wheelMat);
wheel.position.set(pos[0], pos[1], pos[2]);
wheel.rotation.z = Math.PI / 2;
wheel.castShadow = true;
group.add(wheel);
car.wheels.push(wheel);
});
// Spoiler
const spoilerMat = new THREE.MeshLambertMaterial({ color: 0x111111 });
const spoilerPlate = new THREE.Mesh(new THREE.BoxGeometry(CFG.width + 0.4, 0.08, 0.4), spoilerMat);
spoilerPlate.position.set(0, 1.3, -1.8);
group.add(spoilerPlate);
const spoilerLegs = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.3, 0.1), spoilerMat);
const leg1 = spoilerLegs.clone(); leg1.position.set(-0.5, 1.15, -1.8); group.add(leg1);
const leg2 = spoilerLegs.clone(); leg2.position.set(0.5, 1.15, -1.8); group.add(leg2);
// Headlights
const headlightMat = new THREE.MeshBasicMaterial({ color: 0xffffcc });
for (let side = -1; side <= 1; side += 2) {
const hl = new THREE.Mesh(new THREE.SphereGeometry(0.12, 8, 8), headlightMat);
hl.position.set(side * 0.5, 0.55, CFG.length / 2 + 0.05);
group.add(hl);
}
// Taillights
const taillightMat = new THREE.MeshBasicMaterial({ color: 0xff0000 });
for (let side = -1; side <= 1; side += 2) {
const tl = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.15, 0.05), taillightMat);
tl.position.set(side * 0.5, 0.55, -CFG.length / 2 - 0.02);
group.add(tl);
}
// Rally decals (stripes)
const stripeMat = new THREE.MeshLambertMaterial({ color: 0xffffff });
const stripe = new THREE.Mesh(new THREE.BoxGeometry(0.15, 0.62, CFG.length - 0.3), stripeMat);
stripe.position.set(0, 0.5, 0);
group.add(stripe);
car.mesh = group;
scene.add(group);
}
// ---- PARTICLE SYSTEM ----
function initParticles() {
// Dust particles
const dustGeo = new THREE.SphereGeometry(0.15, 4, 4);
const dustMat = new THREE.MeshBasicMaterial({ color: 0xc8b898, transparent: true, opacity: 0.6 });
for (let i = 0; i < CFG.particleCount; i++) {
const dust = new THREE.Mesh(dustGeo, dustMat.clone());
dust.visible = false;
dust.userData = { life: 0, maxLife: 0, vel: new THREE.Vector3() };
scene.add(dust);
car.particles.push(dust);
}
// Spark particles
const sparkGeo = new THREE.SphereGeometry(0.05, 4, 4);
const sparkMat = new THREE.MeshBasicMaterial({ color: 0xffaa00 });
for (let i = 0; i < 30; i++) {
const spark = new THREE.Mesh(sparkGeo, sparkMat.clone());
spark.visible = false;
spark.userData = { life: 0, maxLife: 0, vel: new THREE.Vector3() };
scene.add(spark);
car.particles.push(spark);
}
}
function emitDust(pos, speed) {
for (let i = 0; i < car.particles.length; i++) {
const p = car.particles[i];
if (!p.visible) {
p.visible = true;
p.position.copy(pos);
p.position.y += 0.2;
p.userData.life = 0.5 + Math.random() * 0.5;
p.userData.maxLife = p.userData.life;
const dir = new THREE.Vector3(-Math.sin(car.heading), 0, -Math.cos(car.heading));
p.userData.vel.set(
(Math.random() - 0.5) * 3 + dir.x * speed * 0.3,
Math.random() * 2 + 1,
(Math.random() - 0.5) * 3 + dir.z * speed * 0.3
);
p.scale.setScalar(0.5 + Math.random());
return;
}
}
}
function emitSparks(pos) {
for (let i = 0; i < car.particles.length; i++) {
const p = car.particles[i];
if (!p.visible && p.material.color.getHex() === 0xffaa00) {
p.visible = true;
p.position.copy(pos);
p.userData.life = 0.3 + Math.random() * 0.2;
p.userData.maxLife = p.userData.life;
p.userData.vel.set(
(Math.random() - 0.5) * 10,
Math.random() * 8 + 3,
(Math.random() - 0.5) * 10
);
return;
}
}
}
function updateParticles(dt) {
for (let i = 0; i < car.particles.length; i++) {
const p = car.particles[i];
if (!p.visible) continue;
p.userData.life -= dt;
if (p.userData.life <= 0) {
p.visible = false;
continue;
}
const lifeRatio = p.userData.life / p.userData.maxLife;
p.position.add(p.userData.vel.clone().multiplyScalar(dt));
p.userData.vel.y -= 5 * dt;
const alpha = lifeRatio * 0.6;
p.material.opacity = alpha;
const s = (1 - lifeRatio) * 2 + 0.5;
p.scale.setScalar(s);
}
}
// ---- PHYSICS UPDATE ----
function updatePhysics(dt) {
if (state.phase !== 'racing') return;
const info = getClosestTrackInfo(car.position);
car.onTrack = info.onTrack;
car.surfaceType = info.surface;
const tangent = getTrackTangent(info.t);
const forward = new THREE.Vector3(Math.sin(car.heading), 0, Math.cos(car.heading));
// Surface effects
let grip = 1.0;
let speedMult = 1.0;
if (!car.onTrack) {
grip = CFG.offRoadGrip;
speedMult = CFG.offRoadSpeedReduction;
} else if (info.surface === 'dirt') {
grip = 0.85;
speedMult = 0.85;
} else if (info.surface === 'grass') {
grip = CFG.offRoadGrip;
speedMult = CFG.offRoadSpeedReduction;
} else if (info.surface === 'mud') {
grip = CFG.mudGripReduction;
speedMult = CFG.mudSpeedReduction;
}
// Acceleration
let accel = 0;
if (input.up) accel = CFG.accelRate * speedMult;
if (input.down) accel = -CFG.brakeRate;
if (input.down && car.speed > 1) accel = -CFG.brakeRate;
if (car.speed < -2 && input.down) accel = CFG.reverseAccel;
car.speed += accel * dt;
car.speed *= (1 - 0.5 * dt); // Drag
car.speed = clamp(car.speed, -8, CFG.maxSpeed * speedMult);
// Steering
const speedRatio = Math.abs(car.speed) / CFG.maxSpeed;
const steerFactor = (1 - speedRatio * 0.4) * CFG.steeringSpeed;
const steerAmount = CFG.maxSteerAngle * speedRatio;
if (input.left) car.wheelAngle = lerp(car.wheelAngle, steerAmount, dt * 5);
else if (input.right) car.wheelAngle = lerp(car.wheelAngle, -steerAmount, dt * 5);
else car.wheelAngle *= (1 - dt * 8);
let turnRate = car.wheelAngle * steerFactor * Math.abs(car.speed) * 0.08;
if (Math.abs(car.speed) < 0.5) turnRate = 0;
car.heading += turnRate * dt;
// Drift mechanics
const lateralSpeed = forward.clone().cross(new THREE.Vector3(0, 1, 0)).dot(
new THREE.Vector3(car.velocity.x, 0, car.velocity.z).normalize()
) * Math.abs(car.speed);
car.isDrifting = (Math.abs(car.wheelAngle) > 0.2 && Math.abs(car.speed) > CFG.driftThreshold) ||
(!car.onTrack && Math.abs(car.speed) > 8) ||
(input.handbrake && Math.abs(car.speed) > 5);
if (car.isDrifting) {
car.driftAngle = lerp(car.driftAngle, car.wheelAngle * 0.5 * dt * 3, 0.1);
} else {
car.driftAngle *= 0.9;
}
// Apply velocity
const moveDir = new THREE.Vector3(Math.sin(car.heading), 0, Math.cos(car.heading));
car.velocity.x += moveDir.x * car.speed * dt;
car.velocity.z += moveDir.z * car.speed * dt;
// Lateral grip (reduce sideways velocity)
const sideDir = new THREE.Vector3(-Math.cos(car.heading), 0, Math.sin(car.heading));
const sideSpeed = sideDir.dot(new THREE.Vector3(car.velocity.x, 0, car.velocity.z));
const gripForce = car.isDrifting ? CFG.driftFactor : CFG.gripFactor * grip;
car.velocity.x -= sideDir.x * sideSpeed * (1 - gripForce) * dt * 5;
car.velocity.z -= sideDir.z * sideSpeed * (1 - gripForce) * dt * 5;
// Handbrake
if (input.handbrake) {
car.velocity.multiplyScalar(1 - 2 * dt);
}
// Update position
car.position.x += car.velocity.x * dt;
car.position.z += car.velocity.z * dt;
car.position.y = getTrackHeightAt(car.position.x, car.position.z) + 0.3;
// Obstacle collision
for (const rock of rockMeshes) {
const dx = car.position.x - rock.position.x;
const dz = car.position.z - rock.position.z;
const dist = Math.sqrt(dx * dx + dz * dz);
if (dist < 1.5) {
const nx = dx / dist, nz = dz / dist;
car.position.x += nx * (1.5 - dist);
car.position.z += nz * (1.5 - dist);
car.velocity.x += nx * CFG.obstacleBounceForce * 0.3;
car.velocity.z += nz * CFG.obstacleBounceForce * 0.3;
car.speed *= CFG.obstacleSlowFactor;
playCrashSound();
emitSparks(car.position.clone());
break;
}
}
// Track boundary bounce
const trackInfo = getClosestTrackInfo(car.position);
if (!trackInfo.onTrack) {
const trackPoint = getTrackPoint(trackInfo.t);
const toTrack = new THREE.Vector3().subVectors(trackPoint, car.position);
toTrack.y = 0;
const dist = toTrack.length();
if (dist > CFG.trackWidth / 2) {
toTrack.normalize();
car.position.x += toTrack.x * (dist - CFG.trackWidth / 2) * 0.5;
car.position.z += toTrack.z * (dist - CFG.trackWidth / 2) * 0.5;
car.velocity.x *= 0.7;
car.velocity.z *= 0.7;
car.speed *= 0.7;
}
}
// Update car mesh
car.mesh.position.copy(car.position);
car.mesh.rotation.y = car.heading;
// Wheel rotation
const wheelRot = car.speed * dt * 3;
car.wheels.forEach(w => w.rotation.x += wheelRot);
// Gear and RPM
const absSpeed = Math.abs(car.speed);
if (car.speed < -1) { car.gear = -1; }
else if (absSpeed < 2) { car.gear = 0; }
else if (absSpeed < 12) { car.gear = 1; }
else if (absSpeed < 22) { car.gear = 2; }
else if (absSpeed < 35) { car.gear = 3; }
else if (absSpeed < 45) { car.gear = 4; }
else { car.gear = 5; }
car.rpm = 800 + (absSpeed / CFG.maxSpeed) * 6000 + (car.gear > 0 ? (1 - absSpeed / (CFG.maxSpeed * (car.gear / 5 + 0.6))) * 1000 : 0);
car.rpm = clamp(car.rpm, 800, 7500);
// Dust emission
if ((car.isDrifting || (!car.onTrack && absSpeed > 5)) && Math.random() < 0.3) {
emitDust(car.position.clone(), absSpeed);
}
// Check checkpoints and lap progress
checkCheckpoints();
checkLapProgress();
}
function getTrackHeightAt(x, z) {
const info = getClosestTrackInfo(new THREE.Vector3(x, 0, z));
const point = getTrackPoint(info.t);
return point.y;
}
// ---- CAMERA ----
function updateCamera(dt) {
const targetPos = car.position.clone();
let camHeight, camDist, camLag;
if (state.cameraMode === 0) {
camHeight = CFG.cameraHeight;
camDist = CFG.cameraDistance;
camLag = CFG.cameraLag;
} else {
camHeight = CFG.cinematicHeight;
camDist = CFG.cinematicDistance;
camLag = CFG.cinematicLag;
}
const idealPos = new THREE.Vector3(
targetPos.x - Math.sin(car.heading) * camDist,
targetPos.y + camHeight,
targetPos.z - Math.cos(car.heading) * camDist
);
camera.position.lerp(idealPos, camLag + dt * 3);
const lookTarget = new THREE.Vector3(
targetPos.x + Math.sin(car.heading) * 5,
targetPos.y + 1,
targetPos.z + Math.cos(car.heading) * 5
);
camera.lookAt(lookTarget);
}
// ---- HUD UPDATE ----
function updateHUD() {
// Lap text
document.getElementById('lapText').textContent = `LAP ${Math.min(state.lap, CFG.totalLaps)} / ${CFG.totalLaps}`;
// Time
if (state.phase === 'racing' || state.phase === 'paused') {
state.raceTime = performance.now() - state.raceStartTime;
document.getElementById('timeText').textContent = formatTime(state.raceTime);
}
// Best lap
if (state.bestLap < Infinity) {
document.getElementById('bestText').textContent = `BEST: ${formatTime(state.bestLap)}`;
}
// Gear and RPM
const gearNames = ['R', 'N', '1', '2', '3', '4', '5'];
document.getElementById('gearText').textContent = gearNames[car.gear + 1];
document.getElementById('rpmText').textContent = `${Math.round(car.rpm)} RPM`;
// Speedometer
drawSpeedometer();
// Minimap
drawMinimap();
}
function drawSpeedometer() {
const ctx = speedoCtx;
const w = speedoCanvas.width, h = speedoCanvas.height;
const cx = w / 2, cy = h / 2, r = w / 2 - 10;
ctx.clearRect(0, 0, w, h);
// Background
ctx.beginPath();
ctx.arc(cx, cy, r + 5, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fill();
// Scale arc
const startAngle = Math.PI * 0.75;
const endAngle = Math.PI * 2.25;
const speedRatio = Math.abs(car.speed) / CFG.maxSpeed;
// Outer arc
ctx.beginPath();
ctx.arc(cx, cy, r, startAngle, endAngle);
ctx.strokeStyle = '#333';
ctx.lineWidth = 8;
ctx.stroke();
// Speed arc
if (speedRatio > 0) {
const color = speedRatio > 0.8 ? '#ff4444' : speedRatio > 0.5 ? '#ffaa00' : '#44ff44';
ctx.beginPath();
ctx.arc(cx, cy, r, startAngle, startAngle + (endAngle - startAngle) * speedRatio);
ctx.strokeStyle = color;
ctx.lineWidth = 8;
ctx.stroke();
}
// Ticks
for (let i = 0; i <= 10; i++) {
const angle = startAngle + (endAngle - startAngle) * (i / 10);
const inner = r - 20;
const outer = r - 10;
ctx.beginPath();
ctx.moveTo(cx + Math.cos(angle) * inner, cy + Math.sin(angle) * inner);
ctx.lineTo(cx + Math.cos(angle) * outer, cy + Math.sin(angle) * outer);
ctx.strokeStyle = '#aaa';
ctx.lineWidth = 2;
ctx.stroke();
const labelR = r - 30;
ctx.fillStyle = '#888';
ctx.font = '10px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(Math.round(CFG.maxSpeed * i / 10), cx + Math.cos(angle) * labelR, cy + Math.sin(angle) * labelR);
}
// Speed text
ctx.fillStyle = '#fff';
ctx.font = 'bold 28px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(Math.round(Math.abs(car.speed)), cx, cy - 5);
ctx.fillStyle = '#aaa';
ctx.font = '10px sans-serif';
ctx.fillText('km/h', cx, cy + 18);
}
function drawMinimap() {
const ctx = minimapCtx;
const w = minimapCanvas.width, h = minimapCanvas.height;
ctx.clearRect(0, 0, w, h);
// Find bounds of track
let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
for (let i = 0; i <= 100; i++) {
const p = getTrackPoint(i / 100);
minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x);
minZ = Math.min(minZ, p.z); maxZ = Math.max(maxZ, p.z);
}
const pad = 20;
const scaleX = (w - pad * 2) / (maxX - minX);
const scaleZ = (h - pad * 2) / (maxZ - minZ);
const scale = Math.min(scaleX, scaleZ);
const offsetX = (w - (maxX - minX) * scale) / 2;
const offsetZ = (h - (maxZ - minZ) * scale) / 2;
function toMap(x, z) {
return [(x - minX) * scale + offsetX, (z - minZ) * scale + offsetZ];
}
// Draw track
ctx.beginPath();
for (let i = 0; i <= 100; i++) {
const p = getTrackPoint(i / 100);
const [mx, mz] = toMap(p.x, p.z);
if (i === 0) ctx.moveTo(mx, mz);
else ctx.lineTo(mx, mz);
}
ctx.closePath();
ctx.strokeStyle = 'rgba(255,255,255,0.6)';
ctx.lineWidth = 4;
ctx.stroke();
ctx.fillStyle = 'rgba(255,255,255,0.15)';
ctx.fill();
// Checkpoint markers
CHECKPOINTS.forEach((cp, i) => {
const p = getTrackPoint(cp);
const [mx, mz] = toMap(p.x, p.z);
ctx.beginPath();
ctx.arc(mx, mz, 3, 0, Math.PI * 2);
ctx.fillStyle = checkpointPassed[i] ? '#44ff44' : '#ff4444';
ctx.fill();
});
// Start line
const sp = getTrackPoint(0);
const [sx, sz] = toMap(sp.x, sp.z);
ctx.beginPath();
ctx.arc(sx, sz, 5, 0, Math.PI * 2);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.stroke();
// Player position
const [px, pz] = toMap(car.position.x, car.position.z);
ctx.beginPath();
ctx.arc(px, pz, 4, 0, Math.PI * 2);
ctx.fillStyle = '#ff4444';
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5;
ctx.stroke();
// Direction indicator
const dirLen = 8;
ctx.beginPath();
ctx.moveTo(px, pz);
ctx.lineTo(px + Math.sin(car.heading) * dirLen, pz + Math.cos(car.heading) * dirLen);
ctx.strokeStyle = '#ff4444';
ctx.lineWidth = 2;
ctx.stroke();
}
// ---- COUNTDOWN ----
function startCountdown() {
state.phase = 'countdown';
state.countdownValue = 3;
state.countdownTimer = 0;
const el = document.getElementById('countdown');
el.style.display = 'block';
el.textContent = '3';
playCountdownBeep(false);
}
function updateCountdown(dt) {
if (state.phase !== 'countdown') return;
state.countdownTimer += dt;
const newVal = 3 - Math.floor(state.countdownTimer);
if (newVal !== state.countdownValue && newVal >= 0) {
state.countdownValue = newVal;
const el = document.getElementById('countdown');
if (newVal > 0) {
el.textContent = newVal.toString();
playCountdownBeep(false);
} else {
el.textContent = 'GO!';
el.style.color = '#44ff44';
playCountdownBeep(true);
state.phase = 'racing';
state.raceStartTime = performance.now();
state.lapStartTime = state.raceStartTime;
setTimeout(() => { el.style.display = 'none'; el.style.color = '#fff'; }, 800);
}
}
}
// ---- UI FUNCTIONS ----
function showPauseMenu() {
state.phase = 'paused';
document.getElementById('pauseMenu').style.display = 'flex';
}
function hidePauseMenu() {
state.phase = 'racing';
state.raceStartTime += performance.now() - (state.raceStartTime + (state.phase === 'paused' ? 0 : 0));
document.getElementById('pauseMenu').style.display = 'none';
}
function showFinishScreen() {
playFinishTone();
document.getElementById('finishScreen').style.display = 'flex';
document.getElementById('finalTime').textContent = `Final Time: ${formatTime(state.raceTime)}`;
document.getElementById('finalBest').textContent = `Best Lap: ${state.bestLap < Infinity ? formatTime(state.bestLap) : '--:--.--'}`;
document.getElementById('finalLaps').textContent = `Laps Completed: ${CFG.totalLaps}/${CFG.totalLaps}`;
}
function resetGame() {
state.phase = 'menu';
state.lap = 1;
state.lapTimes = [];
state.bestLap = Infinity;
state.lapStartTime = 0;
state.raceTime = 0;
state.countdownValue = 3;
state.countdownTimer = 0;
state.cameraMode = 0;
car.position.set(0, 0.3, 0);
car.velocity.set(0, 0, 0);
car.heading = 0;
car.speed = 0;
car.gear = 0;
car.rpm = 0;
car.wheelAngle = 0;
car.isDrifting = false;
car.driftAngle = 0;
checkpointPassed = [false, false, false, false];
document.getElementById('countdown').style.display = 'none';
document.getElementById('countdown').style.color = '#fff';
document.getElementById('pauseMenu').style.display = 'none';
document.getElementById('finishScreen').style.display = 'none';
document.getElementById('startScreen').style.display = 'flex';
}
// ---- INPUT HANDLING ----
function setupInput() {
// Keyboard
window.addEventListener('keydown', (e) => {
if (state.phase === 'menu') return;
switch(e.code) {
case 'ArrowUp': case 'KeyW': input.up = true; break;
case 'ArrowDown': case 'KeyS': input.down = true; break;
case 'ArrowLeft': case 'KeyA': input.left = true; break;
case 'ArrowRight': case 'KeyD': input.right = true; break;
case 'Space': input.handbrake = true; e.preventDefault(); break;
case 'KeyP':
if (state.phase === 'racing') showPauseMenu();
else if (state.phase === 'paused') { hidePauseMenu(); }
break;
case 'KeyC': state.cameraMode = (state.cameraMode + 1) % 2; break;
case 'KeyR': resetGame(); break;
}
});
window.addEventListener('keyup', (e) => {
switch(e.code) {
case 'ArrowUp': case 'KeyW': input.up = false; break;
case 'ArrowDown': case 'KeyS': input.down = false; break;
case 'ArrowLeft': case 'KeyA': input.left = false; break;
case 'ArrowRight': case 'KeyD': input.right = false; break;
case 'Space': input.handbrake = false; break;
}
});
// Touch controls
const isTouchDevice = 'ontouchstart' in window;
if (isTouchDevice) {
document.getElementById('touchControls').style.display = 'flex';
}
function setupTouchBtn(id, key) {
const el = document.getElementById(id);
el.addEventListener('touchstart', (e) => { e.preventDefault(); input[key] = true; });
el.addEventListener('touchend', (e) => { e.preventDefault(); input[key] = false; });
el.addEventListener('touchcancel', (e) => { input[key] = false; });
}
setupTouchBtn('btnUp', 'up');
setupTouchBtn('btnDown', 'down');
setupTouchBtn('btnLeft', 'left');
setupTouchBtn('btnRight', 'right');
setupTouchBtn('btnGas', 'up');
setupTouchBtn('btnBrake', 'down');
setupTouchBtn('btnHandbrake', 'handbrake');
// UI buttons
document.getElementById('startBtn').addEventListener('click', () => {
initAudio();
document.getElementById('startScreen').style.display = 'none';
state.phase = 'countdown';
startCountdown();
});
document.getElementById('resumeBtn').addEventListener('click', () => {
hidePauseMenu();
});
document.getElementById('restartBtn').addEventListener('click', () => {
resetGame();
setTimeout(() => {
state.phase = 'countdown';
startCountdown();
}, 100);
});
document.getElementById('finishRestartBtn').addEventListener('click', () => {
resetGame();
setTimeout(() => {
state.phase = 'countdown';
startCountdown();
}, 100);
});
}
// ---- RESIZE ----
function onResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
// ---- MAIN LOOP ----
function gameLoop() {
requestAnimationFrame(gameLoop);
const dt = Math.min(clock.getDelta(), 0.05);
if (state.phase === 'countdown') {
updateCountdown(dt);
}
if (state.phase === 'racing') {
updatePhysics(dt);
}
updateCamera(dt);
updateParticles(dt);
updateEngineAudio();
updateHUD();
// Update sun shadow to follow car
sunLight.position.set(car.position.x + 50, 80, car.position.z + 30);
sunLight.target.position.copy(car.position);
sunLight.target.updateMatrixWorld();
renderer.render(scene, camera);
}
// ---- INIT ----
function init() {
initScene();
setupInput();
// Set initial car position
const startPos = getTrackPoint(0);
car.position.copy(startPos);
car.position.y += 0.3;
car.heading = Math.atan2(
getTrackTangent(0.001).x,
getTrackTangent(0.001).z
);
gameLoop();
}
// Start
init();
// Public API
return {
getState: () => state,
getCar: () => car,
};
})();
</script>
</body>
</html>