Rally
thinkingcap qwen3.6 27b · full
◈Preview unavailable — try playing the result
Browser check: Preview unavailable or timed out. This is a technical check, not a quality review.
Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 62,118 bytes · SHA-256 ea26056c5260
<!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 Racer 3D</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; width: 100%; height: 100%; background: #000; font-family: 'Segoe UI', Arial, sans-serif; touch-action: none; }
canvas { display: block; }
/* HUD Overlay */
#hud { position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; }
#speedometer { position: absolute; bottom: 20px; right: 20px; text-align: center; color: #fff; }
#speed-value { font-size: 48px; font-weight: bold; text-shadow: 2px 2px 4px rgba(0,0,0,0.8); }
#speed-unit { font-size: 16px; opacity: 0.7; }
#gear-display { font-size: 28px; color: #ffcc00; margin-top: 4px; text-shadow: 1px 1px 3px rgba(0,0,0,0.8); }
#rpm-bar-container { width: 160px; height: 10px; background: rgba(0,0,0,0.5); border-radius: 5px; margin-top: 4px; overflow: hidden; }
#rpm-bar { height: 100%; width: 0%; background: linear-gradient(90deg, #0f0, #ff0, #f00); transition: width 0.05s; border-radius: 5px; }
/* Lap info */
#lap-info { position: absolute; top: 20px; left: 20px; color: #fff; text-shadow: 1px 1px 3px rgba(0,0,0,0.8); }
#lap-count { font-size: 24px; font-weight: bold; }
#current-time { font-size: 20px; margin-top: 4px; }
#best-lap { font-size: 16px; color: #ffcc00; margin-top: 2px; }
/* Minimap */
#minimap-container { position: absolute; bottom: 20px; left: 20px; width: 180px; height: 180px; background: rgba(0,0,0,0.5); border-radius: 10px; border: 2px solid rgba(255,255,255,0.3); }
#minimap { width: 100%; height: 100%; }
/* Countdown */
#countdown-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; z-index: 20; pointer-events: none; }
#countdown-text { font-size: 120px; font-weight: bold; color: #fff; text-shadow: 4px 4px 8px rgba(0,0,0,0.8); opacity: 0; transition: opacity 0.3s; }
/* Pause menu */
#pause-menu { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); display: none; align-items: center; justify-content: center; z-index: 30; pointer-events: auto; }
#pause-menu.active { display: flex; }
.pause-box { text-align: center; color: #fff; }
.pause-box h2 { font-size: 48px; margin-bottom: 30px; }
.pause-btn { padding: 15px 40px; font-size: 20px; border: none; border-radius: 8px; cursor: pointer; margin: 10px; background: #ff6600; color: #fff; font-weight: bold; }
.pause-btn:hover { background: #ff8833; }
/* Finish screen */
#finish-screen { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); display: none; align-items: center; justify-content: center; z-index: 30; pointer-events: auto; }
#finish-screen.active { display: flex; }
.finish-box { text-align: center; color: #fff; }
.finish-box h2 { font-size: 48px; margin-bottom: 10px; color: #ffcc00; }
.finish-stat { font-size: 22px; margin: 8px 0; }
/* Touch controls */
#touch-controls { position: fixed; bottom: 0; left: 0; width: 100%; height: 200px; display: none; z-index: 15; pointer-events: auto; }
.touch-btn { position: absolute; 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; user-select: none; -webkit-user-select: none; }
.touch-btn:active { background: rgba(255,255,255,0.5); }
#touch-left { left: 20px; bottom: 80px; width: 70px; height: 70px; }
#touch-right { left: 110px; bottom: 80px; width: 70px; height: 70px; }
#touch-gas { right: 20px; bottom: 80px; width: 80px; height: 80px; background: rgba(0,255,0,0.3); border-color: rgba(0,255,0,0.6); }
#touch-brake { right: 120px; bottom: 80px; width: 70px; height: 70px; background: rgba(255,0,0,0.3); border-color: rgba(255,0,0,0.6); }
#touch-handbrake { right: 70px; bottom: 10px; width: 80px; height: 40px; border-radius: 20px; background: rgba(255,165,0,0.3); border-color: rgba(255,165,0,0.6); font-size: 14px; }
#touch-reverse { left: 70px; bottom: 10px; width: 70px; height: 40px; border-radius: 20px; background: rgba(200,200,200,0.3); border-color: rgba(200,200,200,0.6); font-size: 14px; }
/* Surface indicator */
#surface-indicator { position: absolute; bottom: 80px; right: 200px; color: #fff; font-size: 14px; text-shadow: 1px 1px 3px rgba(0,0,0,0.8); opacity: 0.7; }
/* Start screen */
#start-screen { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: linear-gradient(135deg, #1a1a2e, #16213e); display: flex; align-items: center; justify-content: center; z-index: 40; pointer-events: auto; }
.start-box { text-align: center; color: #fff; }
.start-box h1 { font-size: 64px; margin-bottom: 10px; background: linear-gradient(90deg, #ff6600, #ffcc00); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
.start-box p { font-size: 18px; opacity: 0.7; margin-bottom: 30px; }
.start-btn { padding: 20px 60px; font-size: 24px; border: none; border-radius: 10px; cursor: pointer; background: linear-gradient(90deg, #ff6600, #ff8833); color: #fff; font-weight: bold; box-shadow: 0 4px 15px rgba(255,102,0,0.4); }
.start-btn:hover { transform: scale(1.05); }
.controls-info { margin-top: 30px; font-size: 14px; opacity: 0.6; line-height: 1.8; }
/* Warning flash */
#warning-flash { position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 5; opacity: 0; background: radial-gradient(ellipse at center, rgba(255,0,0,0.3), transparent); transition: opacity 0.1s; }
</style>
</head>
<body>
<!-- Start Screen -->
<div id="start-screen">
<div class="start-box">
<h1>RALLY RACER 3D</h1>
<p>Complete 3 laps as fast as possible!</p>
<button class="start-btn" onclick="RallyGame.startRace()">START RACE</button>
<div class="controls-info">
WASD / Arrow Keys — Drive | Space — Handbrake<br>
P — Pause | C — Camera Toggle | R — Restart
</div>
</div>
</div>
<!-- HUD -->
<div id="hud">
<div id="lap-info">
<div id="lap-count">LAP 1 / 3</div>
<div id="current-time">00:00.000</div>
<div id="best-lap">Best: --:--.---</div>
</div>
<div id="speedometer">
<div id="speed-value">0</div>
<div id="speed-unit">km/h</div>
<div id="gear-display">N</div>
<div id="rpm-bar-container"><div id="rpm-bar"></div></div>
</div>
<div id="surface-indicator">ASPHALT</div>
<div id="minimap-container"><canvas id="minimap" width="180" height="180"></canvas></div>
</div>
<!-- Countdown -->
<div id="countdown-overlay"><div id="countdown-text"></div></div>
<!-- Pause Menu -->
<div id="pause-menu">
<div class="pause-box">
<h2>PAUSED</h2>
<button class="pause-btn" onclick="RallyGame.resume()">RESUME</button><br>
<button class="pause-btn" onclick="RallyGame.restart()">RESTART</button>
</div>
</div>
<!-- Finish Screen -->
<div id="finish-screen">
<div class="finish-box">
<h2>RACE COMPLETE!</h2>
<div class="finish-stat" id="final-time"></div>
<div class="finish-stat" id="final-best-lap"></div>
<button class="pause-btn" onclick="RallyGame.restart()">RACE AGAIN</button>
</div>
</div>
<!-- Touch Controls -->
<div id="touch-controls">
<div class="touch-btn" id="touch-left">◀</div>
<div class="touch-btn" id="touch-right">▶</div>
<div class="touch-btn" id="touch-gas">▲</div>
<div class="touch-btn" id="touch-brake">▼</div>
<div class="touch-btn" id="touch-handbrake">HBK</div>
<div class="touch-btn" id="touch-reverse">REV</div>
</div>
<!-- Warning Flash -->
<div id="warning-flash"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
// ============================================================
// RALLY GAME - Single-file 3D Rally Racing Game
// ============================================================
const RallyGame = (function() {
'use strict';
// ==================== CONFIGURATION ====================
const CFG = {
totalLaps: 3,
trackWidth: 14,
trackSegments: 500,
carMaxSpeed: 85, // m/s (~306 km/h)
carAccel: 35,
carBrakeForce: 50,
carReverseMax: 20,
carSteerSpeed: 2.8,
driftFactor: 0.92, // lower = more drift
offRoadSlowdown: 0.4,
mudSlowdown: 0.15,
gravity: 20,
cameraHeight: 6,
cameraDistance: 14,
cameraLag: 0.06,
fogNear: 80,
fogFar: 350,
checkpointCount: 12,
treeCount: 400,
rockCount: 100,
};
// ==================== GAME STATE ====================
let state = {
phase: 'menu', // menu | countdown | racing | paused | finished
lap: 1,
currentCheckpoint: 0,
raceTime: 0,
lapStartTime: 0,
bestLap: Infinity,
lapTimes: [],
};
// ==================== INPUT ====================
const keys = {};
let touchInput = { left: false, right: false, gas: false, brake: false, handbrake: false, reverse: false };
function onKey(e, down) {
keys[e.code] = !!down;
if (down && e.code === 'KeyP' && state.phase === 'racing') pause();
else if (down && e.code === 'KeyP' && state.phase === 'paused') resume();
if (down && e.code === 'KeyR') restart();
if (down && e.code === 'KeyC') toggleCamera();
}
document.addEventListener('keydown', e => onKey(e, true));
document.addEventListener('keyup', e => onKey(e, false));
// Touch setup
function setupTouch() {
const isMobile = /Mobi|Android/i.test(navigator.userAgent) || ('ontouchstart' in window);
if (!isMobile) return;
document.getElementById('touch-controls').style.display = 'block';
const bindBtn = (id, prop) => {
const el = document.getElementById(id);
el.addEventListener('touchstart', e => { e.preventDefault(); touchInput[prop] = true; });
el.addEventListener('touchend', e => { e.preventDefault(); touchInput[prop] = false; });
el.addEventListener('touchcancel', e => { touchInput[prop] = false; });
};
bindBtn('touch-left', 'left');
bindBtn('touch-right', 'right');
bindBtn('touch-gas', 'gas');
bindBtn('touch-brake', 'brake');
bindBtn('touch-handbrake', 'handbrake');
bindBtn('touch-reverse', 'reverse');
}
function input(action) {
const t = touchInput;
switch (action) {
case 'accel': return keys['ArrowUp'] || keys['KeyW'] || t.gas;
case 'brake': return keys['ArrowDown'] || keys['KeyS'] || t.brake;
case 'left': return keys['ArrowLeft'] || keys['KeyA'] || t.left;
case 'right': return keys['ArrowRight'] || keys['KeyD'] || t.right;
case 'handbrake': return keys['Space'] || t.handbrake;
case 'reverse': return t.reverse;
}
}
// ==================== THREE.JS SETUP ====================
let scene, camera, renderer, clock;
let carGroup, carBody, wheels = [];
let trackCurve, checkpoints = [], checkpointMeshes = [];
let sceneryObjects = [];
let particles = { dust: null, sparks: null };
let skidMarks = [];
function initThree() {
scene = new THREE.Scene();
scene.background = new THREE.Color(0x87CEEB);
scene.fog = new THREE.FogExp2(0xC4D8E8, 0.003);
camera = new THREE.PerspectiveCamera(65, window.innerWidth / window.innerHeight, 0.5, 1000);
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);
clock = new THREE.Clock();
// Lighting
const ambientLight = new THREE.AmbientLight(0x6688aa, 0.6);
scene.add(ambientLight);
const sunLight = new THREE.DirectionalLight(0xffeedd, 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 = 300;
sunLight.shadow.camera.left = -80;
sunLight.shadow.camera.right = 80;
sunLight.shadow.camera.top = 80;
sunLight.shadow.camera.bottom = -80;
scene.add(sunLight);
const hemiLight = new THREE.HemisphereLight(0x87CEEB, 0x556B2F, 0.4);
scene.add(hemiLight);
window.addEventListener('resize', onResize);
}
function onResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
// ==================== TRACK GENERATION ====================
let trackPoints = [];
let trackNormals = [];
let trackSurfaces = [];
let trackLength = 0;
function generateTrack() {
// Define control points for a winding rally track
const ctrlPts = [
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(40, 2, -60),
new THREE.Vector3(80, 5, -100),
new THREE.Vector3(120, 3, -80),
new THREE.Vector3(150, 8, -120),
new THREE.Vector3(160, 4, -170),
new THREE.Vector3(130, 6, -220),
new THREE.Vector3(80, 10, -250),
new THREE.Vector3(30, 7, -230),
new THREE.Vector3(-20, 4, -260),
new THREE.Vector3(-60, 9, -220),
new THREE.Vector3(-80, 5, -170),
new THREE.Vector3(-100, 3, -120),
new THREE.Vector3(-120, 7, -60),
new THREE.Vector3(-100, 4, -10),
new THREE.Vector3(-80, 2, 40),
new THREE.Vector3(-50, 5, 80),
new THREE.Vector3(-20, 3, 100),
new THREE.Vector3(20, 6, 90),
];
trackCurve = new THREE.CatmullRomCurve3(ctrlPts, true, 'catmullrom', 0.5);
// Sample points along the curve
for (let i = 0; i < CFG.trackSegments; i++) {
const t = i / CFG.trackSegments;
trackPoints.push(trackCurve.getPointAt(t));
}
// Compute normals and tangents
for (let i = 0; i < trackPoints.length; i++) {
const t = i / trackPoints.length;
const nextT = ((i + 1) % trackPoints.length) / trackPoints.length;
const prevT = ((i - 1 + trackPoints.length) % trackPoints.length) / trackPoints.length;
const tangent = new THREE.Vector3().subVectors(
trackCurve.getPointAt(nextT),
trackCurve.getPointAt(prevT)
).normalize();
const up = new THREE.Vector3(0, 1, 0);
const normal = new THREE.Vector3().crossVectors(up, tangent).normalize();
trackNormals.push(normal);
// Surface type: mostly asphalt, some dirt/gravel sections
if (t > 0.25 && t < 0.35) trackSurfaces.push('dirt');
else if (t > 0.6 && t < 0.7) trackSurfaces.push('mud');
else if (t > 0.85 && t < 0.92) trackSurfaces.push('gravel');
else trackSurfaces.push('asphalt');
}
// Compute total track length
for (let i = 1; i <= trackPoints.length; i++) {
const a = trackPoints[(i - 1) % trackPoints.length];
const b = trackPoints[i % trackPoints.length];
trackLength += a.distanceTo(b);
}
// Checkpoints evenly spaced around the track
for (let i = 0; i < CFG.checkpointCount; i++) {
const t = i / CFG.checkpointCount;
checkpoints.push({
position: trackCurve.getPointAt(t),
passed: false,
});
}
}
function buildTrackMesh() {
// Road surface
const roadGeo = new THREE.BufferGeometry();
const vertices = [];
const colors = [];
const uvs = [];
const indices = [];
for (let i = 0; i < trackPoints.length; i++) {
const p = trackPoints[i];
const n = trackNormals[i];
const hw = CFG.trackWidth / 2;
// Left and right edge of road
const left = new THREE.Vector3().copy(p).addScaledVector(n, -hw);
const right = new THREE.Vector3().copy(p).addScaledVector(n, hw);
vertices.push(left.x, left.y, left.z);
vertices.push(right.x, right.y, right.z);
// Color based on surface type
let r, g, b;
switch (trackSurfaces[i]) {
case 'dirt': r = 0.65; g = 0.45; b = 0.25; break;
case 'mud': r = 0.35; g = 0.28; b = 0.18; break;
case 'gravel': r = 0.55; g = 0.52; b = 0.48; break;
default: r = 0.3; g = 0.3; b = 0.32; // asphalt
}
colors.push(r, g, b);
colors.push(r, g, b);
uvs.push(0, i * 0.5);
uvs.push(1, i * 0.5);
const base = i * 2;
if (i < trackPoints.length - 1) {
indices.push(base, base + 1, base + 2);
indices.push(base + 1, base + 3, base + 2);
} else {
// Close the loop
const lastBase = (trackPoints.length - 1) * 2;
indices.push(lastBase, lastBase + 1, 0);
indices.push(lastBase + 1, 1, 0);
}
}
roadGeo.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
roadGeo.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
roadGeo.setIndex(indices);
roadGeo.computeVertexNormals();
const roadMat = new THREE.MeshLambertMaterial({ vertexColors: true });
const roadMesh = new THREE.Mesh(roadGeo, roadMat);
roadMesh.receiveShadow = true;
scene.add(roadMesh);
// Road shoulders (slightly wider, grass color)
const shoulderGeo = new THREE.BufferGeometry();
const sVerts = [];
const sColors = [];
const sIndices = [];
for (let i = 0; i < trackPoints.length; i++) {
const p = trackPoints[i];
const n = trackNormals[i];
const hw = CFG.trackWidth / 2;
const sw = hw + 4; // shoulder width
const innerLeft = new THREE.Vector3().copy(p).addScaledVector(n, -hw);
const outerLeft = new THREE.Vector3().copy(p).addScaledVector(n, -sw);
const innerRight = new THREE.Vector3().copy(p).addScaledVector(n, hw);
const outerRight = new THREE.Vector3().copy(p).addScaledVector(n, sw);
// Left shoulder
sVerts.push(innerLeft.x, innerLeft.y + 0.05, innerLeft.z);
sVerts.push(outerLeft.x, outerLeft.y + 0.05, outerLeft.z);
sColors.push(0.25, 0.45, 0.15);
sColors.push(0.25, 0.45, 0.15);
// Right shoulder
sVerts.push(innerRight.x, innerRight.y + 0.05, innerRight.z);
sVerts.push(outerRight.x, outerRight.y + 0.05, outerRight.z);
sColors.push(0.25, 0.45, 0.15);
sColors.push(0.25, 0.45, 0.15);
const base = i * 4;
if (i < trackPoints.length - 1) {
// Left shoulder strip
sIndices.push(base, base + 1, base + 4);
sIndices.push(base + 1, base + 5, base + 4);
// Right shoulder strip
sIndices.push(base + 2, base + 3, base + 6);
sIndices.push(base + 3, base + 7, base + 6);
} else {
const lastBase = (trackPoints.length - 1) * 4;
sIndices.push(lastBase, lastBase + 1, 0);
sIndices.push(lastBase + 1, 1, 0);
sIndices.push(lastBase + 2, lastBase + 3, 2);
sIndices.push(lastBase + 3, 3, 2);
}
}
shoulderGeo.setAttribute('position', new THREE.Float32BufferAttribute(sVerts, 3));
shoulderGeo.setAttribute('color', new THREE.Float32BufferAttribute(sColors, 3));
shoulderGeo.setIndex(sIndices);
shoulderGeo.computeVertexNormals();
const shoulderMat = new THREE.MeshLambertMaterial({ vertexColors: true });
const shoulderMesh = new THREE.Mesh(shoulderGeo, shoulderMat);
shoulderMesh.receiveShadow = true;
scene.add(shoulderMesh);
// Road edge lines (white dashed)
for (let side = -1; side <= 1; side += 2) {
const lineVerts = [];
for (let i = 0; i < trackPoints.length; i++) {
if (i % 4 < 2) continue; // Dashed effect
const p = trackPoints[i];
const n = trackNormals[i];
const edge = new THREE.Vector3().copy(p).addScaledVector(n, side * CFG.trackWidth / 2);
lineVerts.push(edge.x, edge.y + 0.1, edge.z);
}
const lineGeo = new THREE.BufferGeometry();
lineGeo.setAttribute('position', new THREE.Float32BufferAttribute(lineVerts, 3));
const lineMat = new THREE.PointsMaterial({ color: 0xffffff, size: 0.5 });
scene.add(new THREE.Points(lineGeo, lineMat));
}
// Start/finish line
const startP = trackPoints[0];
const startN = trackNormals[0];
const startLineGeo = new THREE.PlaneGeometry(CFG.trackWidth, 1.5);
const startLineMat = new THREE.MeshBasicMaterial({ color: 0xffffff });
const startLine = new THREE.Mesh(startLineGeo, startLineMat);
startLine.position.copy(startP).add(new THREE.Vector3(0, 0.2, 0));
startLine.lookAt(startP.clone().add(new THREE.Vector3(0, 1, 0)));
scene.add(startLine);
// Checkpoint markers (invisible gates)
for (let i = 0; i < checkpoints.length; i++) {
const cp = checkpoints[i];
const idx = Math.floor(i * trackPoints.length / CFG.checkpointCount) % trackPoints.length;
const n = trackNormals[idx];
// Visual marker (small arch)
const archGeo = new THREE.BoxGeometry(CFG.trackWidth + 2, 4, 0.5);
const archMat = new THREE.MeshBasicMaterial({ color: i === 0 ? 0x00ff00 : 0xffaa00, transparent: true, opacity: 0.3 });
const arch = new THREE.Mesh(archGeo, archMat);
arch.position.copy(cp.position).add(new THREE.Vector3(0, 2, 0));
arch.lookAt(cp.position.clone().add(n));
scene.add(arch);
}
}
// ==================== GROUND PLANE ====================
function buildGround() {
const groundGeo = new THREE.PlaneGeometry(1500, 1500, 20, 20);
// Add some vertex displacement for hills
const pos = groundGeo.attributes.position;
for (let i = 0; i < pos.count; i++) {
const x = pos.getX(i);
const y = pos.getY(i);
const distFromCenter = Math.sqrt(x * x + y * y);
if (distFromCenter > 200) {
pos.setZ(i, (Math.sin(x * 0.01) * Math.cos(y * 0.01)) * 8 + Math.random() * 2);
}
}
groundGeo.computeVertexNormals();
const groundMat = new THREE.MeshLambertMaterial({ color: 0x4a7c3f });
const ground = new THREE.Mesh(groundGeo, groundMat);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -1;
ground.receiveShadow = true;
scene.add(ground);
}
// ==================== SCENERY ====================
function buildScenery() {
const treeGeo = new THREE.ConeGeometry(2, 8, 6);
const trunkGeo = new THREE.CylinderGeometry(0.4, 0.5, 3, 6);
const rockGeo = new THREE.DodecahedronGeometry(1.5, 0);
const treeMat = new THREE.MeshLambertMaterial({ color: 0x2d5a1e });
const trunkMat = new THREE.MeshLambertMaterial({ color: 0x8B4513 });
const rockMat = new THREE.MeshLambertMaterial({ color: 0x777777 });
for (let i = 0; i < CFG.treeCount; i++) {
const t = Math.random();
const idx = Math.floor(t * trackPoints.length) % trackPoints.length;
const p = trackPoints[idx];
const n = trackNormals[idx];
// Place trees off the road
const offset = (Math.random() > 0.5 ? 1 : -1) * (CFG.trackWidth / 2 + 8 + Math.random() * 40);
const treePos = new THREE.Vector3().copy(p).addScaledVector(n, offset);
treePos.y += Math.random() * 2;
// Tree group
const treeGroup = new THREE.Group();
const trunk = new THREE.Mesh(trunkGeo, trunkMat);
trunk.position.y = 1.5;
trunk.castShadow = true;
treeGroup.add(trunk);
const foliage = new THREE.Mesh(treeGeo, treeMat);
foliage.position.y = 6 + Math.random() * 2;
foliage.scale.set(0.8 + Math.random() * 0.5, 0.7 + Math.random() * 0.6, 0.8 + Math.random() * 0.5);
foliage.castShadow = true;
treeGroup.add(foliage);
// Sometimes add a second cone for fuller trees
if (Math.random() > 0.4) {
const f2 = new THREE.Mesh(treeGeo, treeMat);
f2.position.y = 8 + Math.random();
f2.scale.set(0.6, 0.5, 0.6);
f2.castShadow = true;
treeGroup.add(f2);
}
treeGroup.position.copy(treePos);
scene.add(treeGroup);
sceneryObjects.push({ mesh: treeGroup, position: treePos.clone(), radius: 1.5 });
}
// Rocks
for (let i = 0; i < CFG.rockCount; i++) {
const t = Math.random();
const idx = Math.floor(t * trackPoints.length) % trackPoints.length;
const p = trackPoints[idx];
const n = trackNormals[idx];
const offset = (Math.random() > 0.5 ? 1 : -1) * (CFG.trackWidth / 2 + 3 + Math.random() * 25);
const rockPos = new THREE.Vector3().copy(p).addScaledVector(n, offset);
rockPos.y += 0.5;
const rock = new THREE.Mesh(rockGeo, rockMat);
rock.position.copy(rockPos);
rock.scale.set(0.5 + Math.random(), 0.4 + Math.random() * 0.6, 0.5 + Math.random());
rock.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, 0);
rock.castShadow = true;
scene.add(rock);
sceneryObjects.push({ mesh: rock, position: rockPos.clone(), radius: 1 });
}
// Guard rails along track edges (every few segments)
const railGeo = new THREE.CylinderGeometry(0.15, 0.15, 2, 4);
const railMat = new THREE.MeshLambertMaterial({ color: 0xcccccc });
for (let i = 0; i < trackPoints.length; i += 8) {
for (let side = -1; side <= 1; side += 2) {
if (Math.random() > 0.6) continue; // Not everywhere
const p = trackPoints[i];
const n = trackNormals[i];
const railPos = new THREE.Vector3().copy(p).addScaledVector(n, side * (CFG.trackWidth / 2 + 1));
railPos.y += 1;
const post = new THREE.Mesh(railGeo, railMat);
post.position.copy(railPos);
scene.add(post);
}
}
// Banners/flags at checkpoints
for (let i = 0; i < CFG.checkpointCount; i++) {
const cp = checkpoints[i];
const idx = Math.floor(i * trackPoints.length / CFG.checkpointCount) % trackPoints.length;
const n = trackNormals[idx];
// Banner pole on each side
for (let side = -1; side <= 1; side += 2) {
const bannerPos = new THREE.Vector3().copy(cp.position).addScaledVector(n, side * (CFG.trackWidth / 2 + 0.5));
bannerPos.y += 4;
const poleGeo = new THREE.CylinderGeometry(0.1, 0.1, 8, 4);
const poleMat = new THREE.MeshLambertMaterial({ color: i % 2 === 0 ? 0xff3333 : 0x3333ff });
const pole = new THREE.Mesh(poleGeo, poleMat);
pole.position.copy(bannerPos);
scene.add(pole);
// Flag
const flagGeo = new THREE.PlaneGeometry(2, 1.5);
const flagMat = new THREE.MeshBasicMaterial({ color: i % 2 === 0 ? 0xff3333 : 0x3333ff, side: THREE.DoubleSide });
const flag = new THREE.Mesh(flagGeo, flagMat);
flag.position.copy(bannerPos).add(new THREE.Vector3(1, -2, 0));
scene.add(flag);
}
}
// Distant mountains (simple cones)
for (let i = 0; i < 20; i++) {
const angle = (i / 20) * Math.PI * 2;
const dist = 300 + Math.random() * 150;
const mtnGeo = new THREE.ConeGeometry(30 + Math.random() * 40, 60 + Math.random() * 80, 5);
const mtnMat = new THREE.MeshLambertMaterial({ color: 0x6B7B8D });
const mountain = new THREE.Mesh(mtnGeo, mtnMat);
mountain.position.set(Math.cos(angle) * dist, -10, Math.sin(angle) * dist);
scene.add(mountain);
}
}
// ==================== CAR MODEL ====================
function buildCar() {
carGroup = new THREE.Group();
// Car body (main chassis)
const bodyGeo = new THREE.BoxGeometry(2, 0.8, 4.5);
const bodyMat = new THREE.MeshLambertMaterial({ color: 0xdd2200 });
carBody = new THREE.Mesh(bodyGeo, bodyMat);
carBody.position.y = 0.6;
carBody.castShadow = true;
carGroup.add(carBody);
// Cabin/roof
const cabinGeo = new THREE.BoxGeometry(1.7, 0.7, 2);
const cabinMat = new THREE.MeshLambertMaterial({ color: 0x333344 });
const cabin = new THREE.Mesh(cabinGeo, cabinMat);
cabin.position.set(0, 1.25, -0.3);
cabin.castShadow = true;
carGroup.add(cabin);
// Windshield (slanted)
const windshieldGeo = new THREE.PlaneGeometry(1.6, 0.7);
const windshieldMat = new THREE.MeshLambertMaterial({ color: 0x88bbff, transparent: true, opacity: 0.5 });
const windshield = new THREE.Mesh(windshieldGeo, windshieldMat);
windshield.position.set(0, 1.2, 0.7);
windshield.rotation.x = -0.4;
carGroup.add(windshield);
// Spoiler
const spoilerGeo = new THREE.BoxGeometry(2.2, 0.1, 0.5);
const spoilerMat = new THREE.MeshLambertMaterial({ color: 0x222222 });
const spoiler = new THREE.Mesh(spoilerGeo, spoilerMat);
spoiler.position.set(0, 1.4, -2.1);
carGroup.add(spoiler);
// Spoiler supports
for (let x = -0.7; x <= 0.7; x += 1.4) {
const supportGeo = new THREE.BoxGeometry(0.1, 0.5, 0.1);
const support = new THREE.Mesh(supportGeo, spoilerMat);
support.position.set(x, 1.15, -2.1);
carGroup.add(support);
}
// Headlights
for (let x = -0.7; x <= 0.7; x += 1.4) {
const hlGeo = new THREE.SphereGeometry(0.15, 6, 6);
const hlMat = new THREE.MeshBasicMaterial({ color: 0xffffcc });
const hl = new THREE.Mesh(hlGeo, hlMat);
hl.position.set(x, 0.6, 2.3);
carGroup.add(hl);
}
// Taillights
for (let x = -0.7; x <= 0.7; x += 1.4) {
const tlGeo = new THREE.BoxGeometry(0.3, 0.2, 0.05);
const tlMat = new THREE.MeshBasicMaterial({ color: 0xff0000 });
const tl = new THREE.Mesh(tlGeo, tlMat);
tl.position.set(x, 0.6, -2.3);
carGroup.add(tl);
}
// Wheels
const wheelGeo = new THREE.CylinderGeometry(0.4, 0.4, 0.3, 12);
const wheelMat = new THREE.MeshLambertMaterial({ color: 0x111111 });
const wheelPositions = [
{ x: -1.1, y: 0.4, z: 1.5 },
{ x: 1.1, y: 0.4, z: 1.5 },
{ x: -1.1, y: 0.4, z: -1.5 },
{ x: 1.1, y: 0.4, z: -1.5 },
];
wheelPositions.forEach(pos => {
const wheel = new THREE.Mesh(wheelGeo, wheelMat);
wheel.rotation.z = Math.PI / 2;
wheel.position.set(pos.x, pos.y, pos.z);
wheel.castShadow = true;
carGroup.add(wheel);
wheels.push(wheel);
});
// Number decal on side (simple plane)
const numGeo = new THREE.PlaneGeometry(0.8, 0.6);
const numCanvas = document.createElement('canvas');
numCanvas.width = 128;
numCanvas.height = 96;
const ctx = numCanvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, 128, 96);
ctx.fillStyle = '#dd2200';
ctx.font = 'bold 72px Arial';
ctx.textAlign = 'center';
ctx.fillText('7', 64, 75);
const numTex = new THREE.CanvasTexture(numCanvas);
const numMat = new THREE.MeshBasicMaterial({ map: numTex });
for (let side = -1; side <= 1; side += 2) {
const numMesh = new THREE.Mesh(numGeo, numMat);
numMesh.position.set(side * 1.01, 0.7, 0);
numMesh.rotation.y = side > 0 ? Math.PI / 2 : -Math.PI / 2;
carGroup.add(numMesh);
}
scene.add(carGroup);
}
// ==================== CAR PHYSICS ====================
const car = {
position: new THREE.Vector3(),
velocity: new THREE.Vector3(),
heading: 0, // radians, direction car faces
speed: 0, // forward speed (positive = forward)
lateralSpeed: 0, // sideways drift component
gear: 1,
rpm: 0,
onTrack: true,
currentSurface: 'asphalt',
drifting: false,
collisionCooldown: 0,
};
const gears = [
{ min: 0, max: 25, ratio: 3.5 },
{ min: 20, max: 45, ratio: 2.8 },
{ min: 40, max: 60, ratio: 2.1 },
{ min: 55, max: 75, ratio: 1.6 },
{ min: 70, max: 90, ratio: 1.2 },
];
function resetCar() {
const startP = trackPoints[0];
car.position.copy(startP);
car.position.y += 1;
car.velocity.set(0, 0, 0);
car.heading = Math.atan2(
trackCurve.getTangentAt(0).x,
trackCurve.getTangentAt(0).z
);
car.speed = 0;
car.lateralSpeed = 0;
car.gear = 1;
car.rpm = 0;
}
function getClosestTrackPoint(pos) {
let minDist = Infinity;
let closestIdx = 0;
// Optimization: only check nearby points based on last known position
const searchRange = Math.floor(trackPoints.length * 0.1);
const startIdx = Math.max(0, state.currentCheckpoint - searchRange);
const endIdx = Math.min(trackPoints.length, state.currentCheckpoint + searchRange);
for (let i = startIdx; i < endIdx; i++) {
const d = pos.distanceToSquared(trackPoints[i]);
if (d < minDist) {
minDist = d;
closestIdx = i;
}
}
// Also check wrap-around
for (let i = 0; i < Math.min(searchRange, trackPoints.length); i++) {
const d = pos.distanceToSquared(trackPoints[i]);
if (d < minDist) {
minDist = d;
closestIdx = i;
}
}
return { index: closestIdx, distance: Math.sqrt(minDist) };
}
function updateCarPhysics(dt) {
dt = Math.min(dt, 0.033); // Cap delta time
const accelInput = input('accel') ? 1 : (input('brake') || input('reverse') ? -1 : 0);
const steerInput = (input('left') ? 1 : 0) - (input('right') ? 1 : 0);
const handbrake = input('handbrake');
// Determine surface and track proximity
const closest = getClosestTrackPoint(car.position);
car.onTrack = closest.distance < CFG.trackWidth / 2 + 2;
car.currentSurface = car.onTrack ? trackSurfaces[closest.index] : 'grass';
let maxSpeedMult = 1.0;
let gripFactor = 1.0;
switch (car.currentSurface) {
case 'asphalt': maxSpeedMult = 1.0; gripFactor = 1.0; break;
case 'dirt': maxSpeedMult = 0.75; gripFactor = 0.6; break;
case 'gravel': maxSpeedMult = 0.65; gripFactor = 0.5; break;
case 'mud': maxSpeedMult = CFG.mudSlowdown; gripFactor = 0.3; break;
default: maxSpeedMult = CFG.offRoadSlowdown; gripFactor = 0.4; // grass/off-road
}
if (handbrake) {
gripFactor *= 0.2;
car.drifting = true;
} else {
car.drifting = false;
}
const maxSpeed = CFG.carMaxSpeed * maxSpeedMult;
// Acceleration / braking
if (accelInput > 0) {
car.speed += CFG.carAccel * dt;
} else if (accelInput < 0) {
if (car.speed > 1) {
car.speed -= CFG.carBrakeForce * dt;
} else {
car.speed -= CFG.carAccel * 0.4 * dt; // Reverse
}
} else {
// Rolling resistance
car.speed *= (1 - 1.5 * dt);
}
car.speed = Math.max(-CFG.carReverseMax, Math.min(maxSpeed, car.speed));
// Steering
const speedFactor = Math.abs(car.speed) / maxSpeed;
const steerAmount = CFG.carSteerSpeed * (1 - speedFactor * 0.5) * dt;
if (steerInput !== 0 && Math.abs(car.speed) > 0.5) {
car.heading += steerInput * steerAmount * (car.speed > 0 ? 1 : -1);
}
// Lateral drift physics
const forwardDir = new THREE.Vector3(Math.sin(car.heading), 0, Math.cos(car.heading));
const rightDir = new THREE.Vector3(-Math.cos(car.heading), 0, Math.sin(car.heading));
// Decompose velocity into forward and lateral components
const velForward = car.velocity.dot(forwardDir);
const velLateral = car.velocity.dot(rightDir);
// Blend toward desired speed with grip factor
const targetSpeed = handbrake ? car.speed * 0.95 : car.speed;
car.speed += (targetSpeed - car.speed) * dt * 10;
// Lateral velocity decays based on grip
car.lateralSpeed *= Math.pow(CFG.driftFactor, dt * 60);
if (!car.onTrack || handbrake) {
car.lateralSpeed += velLateral * (1 - gripFactor) * dt * 5;
}
// Apply velocity
const totalForward = car.speed + car.lateralSpeed * 0.3;
car.velocity.copy(forwardDir).multiplyScalar(totalForward);
car.velocity.add(rightDir.clone().multiplyScalar(car.lateralSpeed));
// Gravity effect on hills (simple)
if (car.onTrack && closest.index < trackPoints.length - 1) {
const nextP = trackPoints[(closest.index + 1) % trackPoints.length];
const currP = trackPoints[closest.index];
const slopeY = nextP.y - currP.y;
car.speed -= slopeY * dt * 5; // Gravity on slopes
}
// Update position
car.position.addScaledVector(car.velocity, dt);
// Keep car at appropriate height (track elevation + offset)
if (car.onTrack) {
const targetY = trackPoints[closest.index].y + 0.8;
car.position.y += (targetY - car.position.y) * dt * 10;
} else {
// Gentle return to ground level when off-track
car.position.y += (2 - car.position.y) * dt * 3;
}
// Gear calculation
const absSpeed = Math.abs(car.speed);
for (let i = gears.length - 1; i >= 0; i--) {
if (absSpeed > gears[i].min || i === 0) {
car.gear = i + 1;
break;
}
}
if (Math.abs(car.speed) < 2) car.gear = 0; // Neutral
// RPM calculation
const g = gears[car.gear - 1] || gears[0];
const gearRange = g.max - g.min;
const gearPos = Math.max(0, Math.min(1, (absSpeed - g.min) / gearRange));
car.rpm = 2000 + gearPos * 6500;
// Collision cooldown
if (car.collisionCooldown > 0) car.collisionCooldown -= dt;
// Check obstacle collisions
checkObstacleCollisions();
// Update checkpoint progress
updateCheckpoints();
// Spawn particles
spawnParticles(dt);
}
function checkObstacleCollisions() {
if (car.collisionCooldown > 0) return;
for (const obj of sceneryObjects) {
const dx = car.position.x - obj.position.x;
const dz = car.position.z - obj.position.z;
const dist = Math.sqrt(dx * dx + dz * dz);
if (dist < obj.radius + 1.5) {
// Collision!
car.speed *= 0.3;
car.collisionCooldown = 0.5;
// Bounce away
const pushDir = new THREE.Vector2(dx, dz).normalize();
car.position.x += pushDir.x * 0.5;
car.position.z += pushDir.y * 0.5;
spawnSparks(car.position);
playCrashSound();
flashWarning();
break;
}
}
}
function updateCheckpoints() {
const cp = checkpoints[state.currentCheckpoint];
if (!cp) return;
const distToCP = car.position.distanceTo(cp.position);
if (distToCP < CFG.trackWidth + 5 && !cp.passed) {
cp.passed = true;
state.currentCheckpoint++;
// Check for lap completion
if (state.currentCheckpoint >= checkpoints.length) {
state.currentCheckpoint = 0;
completeLap();
}
// Reset all checkpoint passed flags when starting new lap
if (state.currentCheckpoint === 0 && state.lap > 1) {
for (const c of checkpoints) c.passed = false;
}
}
}
function completeLap() {
const lapTime = state.raceTime - state.lapStartTime;
state.lapTimes.push(lapTime);
if (lapTime < state.bestLap) {
state.bestLap = lapTime;
}
state.lap++;
state.lapStartTime = state.raceTime;
// Reset checkpoints for new lap
for (const c of checkpoints) c.passed = false;
if (state.lap > CFG.totalLaps) {
finishRace();
}
}
function updateCarVisuals() {
carGroup.position.copy(car.position);
carGroup.rotation.y = -car.heading + Math.PI / 2; // Three.js rotation convention
// Wheel spin animation
const wheelSpin = car.speed * 0.15;
wheels.forEach(w => {
w.rotation.x += wheelSpin;
});
// Front wheel steering visual
if (wheels.length >= 2) {
const steerInput = (input('left') ? -1 : 0) + (input('right') ? 1 : 0);
const steerAngle = steerInput * 0.3;
wheels[0].rotation.y = steerAngle;
wheels[1].rotation.y = steerAngle;
}
// Body lean during turns
if (carBody) {
const leanAmount = ((input('left') ? -1 : 0) + (input('right') ? 1 : 0)) * Math.abs(car.speed) / CFG.carMaxSpeed * 0.08;
carBody.rotation.z = leanAmount;
}
}
// ==================== CAMERA ====================
let cameraMode = 'chase'; // chase | cinematic
const camTargetPos = new THREE.Vector3();
const camCurrentPos = new THREE.Vector3();
function toggleCamera() {
cameraMode = cameraMode === 'chase' ? 'cinematic' : 'chase';
}
function updateCamera(dt) {
let dist, height;
if (cameraMode === 'chase') {
dist = CFG.cameraDistance;
height = CFG.cameraHeight;
} else {
dist = 30;
height = 20;
}
const forwardDir = new THREE.Vector3(Math.sin(car.heading), 0, Math.cos(car.heading));
const desiredPos = car.position.clone()
.sub(forwardDir.multiplyScalar(dist))
.add(new THREE.Vector3(0, height, 0));
// Smooth camera follow with lag
camCurrentPos.lerp(desiredPos, CFG.cameraLag * dt * 60);
camera.position.copy(camCurrentPos);
// Look slightly ahead of car
const lookTarget = car.position.clone()
.add(forwardDir.multiplyScalar(10))
.add(new THREE.Vector3(0, 2, 0));
camera.lookAt(lookTarget);
}
// ==================== PARTICLES ====================
let dustParticles = [];
let sparkParticles = [];
let skidMarkMeshes = [];
function spawnParticles(dt) {
const absSpeed = Math.abs(car.speed);
// Dust when off-road or drifting
if ((car.drifting || !car.onTrack) && absSpeed > 5) {
for (let i = 0; i < 2; i++) {
const dustPos = car.position.clone();
dustPos.x += (Math.random() - 0.5) * 2;
dustPos.z += (Math.random() - 0.5) * 2;
dustPos.y += Math.random() * 0.5;
const size = 0.3 + Math.random() * 0.5;
const geo = new THREE.SphereGeometry(size, 4, 4);
const mat = new THREE.MeshBasicMaterial({
color: car.onTrack ? 0xccaa77 : 0x88aa66,
transparent: true,
opacity: 0.6,
});
const dust = new THREE.Mesh(geo, mat);
dust.position.copy(dustPos);
scene.add(dust);
dustParticles.push({
mesh: dust,
velocity: new THREE.Vector3((Math.random() - 0.5) * 2, Math.random() * 2 + 1, (Math.random() - 0.5) * 2),
life: 1.0,
});
if (!car.onTrack && absSpeed > 10) {
playGravelSound();
}
}
}
// Skid marks when drifting on asphalt
if (car.drifting && car.onTrack && trackSurfaces[getClosestTrackPoint(car.position).index] === 'asphalt' && absSpeed > 15) {
const skidGeo = new THREE.PlaneGeometry(0.3, 0.8);
const skidMat = new THREE.MeshBasicMaterial({ color: 0x222222, transparent: true, opacity: 0.4 });
for (let side = -1; side <= 1; side += 2) {
const rightDir = new THREE.Vector3(-Math.cos(car.heading), 0, Math.sin(car.heading));
const skidPos = car.position.clone()
.add(rightDir.multiplyScalar(side * 1))
.sub(new THREE.Vector3(Math.sin(car.heading), 0, Math.cos(car.heading)).multiplyScalar(1.5));
skidPos.y += 0.05;
const skid = new THREE.Mesh(skidGeo, skidMat);
skid.position.copy(skidPos);
skid.rotation.x = -Math.PI / 2;
skid.rotation.z = car.heading;
scene.add(skid);
skidMarkMeshes.push({ mesh: skid, life: 5 });
}
}
// Update dust particles
for (let i = dustParticles.length - 1; i >= 0; i--) {
const p = dustParticles[i];
p.life -= dt * 1.5;
p.mesh.position.addScaledVector(p.velocity, dt);
p.mesh.material.opacity = Math.max(0, p.life * 0.6);
p.mesh.scale.multiplyScalar(1 + dt * 2);
if (p.life <= 0) {
scene.remove(p.mesh);
p.mesh.geometry.dispose();
p.mesh.material.dispose();
dustParticles.splice(i, 1);
}
}
// Update spark particles
for (let i = sparkParticles.length - 1; i >= 0; i--) {
const p = sparkParticles[i];
p.life -= dt * 3;
p.mesh.position.addScaledVector(p.velocity, dt);
p.mesh.material.opacity = Math.max(0, p.life);
if (p.life <= 0) {
scene.remove(p.mesh);
p.mesh.geometry.dispose();
p.mesh.material.dispose();
sparkParticles.splice(i, 1);
}
}
// Fade skid marks
for (let i = skidMarkMeshes.length - 1; i >= 0; i--) {
const s = skidMarkMeshes[i];
s.life -= dt;
if (s.life <= 0) {
scene.remove(s.mesh);
s.mesh.geometry.dispose();
s.mesh.material.dispose();
skidMarkMeshes.splice(i, 1);
}
}
// Limit particle counts for performance
while (dustParticles.length > 200) {
const p = dustParticles.shift();
scene.remove(p.mesh);
p.mesh.geometry.dispose();
p.mesh.material.dispose();
}
}
function spawnSparks(pos) {
for (let i = 0; i < 15; i++) {
const geo = new THREE.SphereGeometry(0.1, 4, 4);
const mat = new THREE.MeshBasicMaterial({ color: 0xffaa33, transparent: true, opacity: 1 });
const spark = new THREE.Mesh(geo, mat);
spark.position.copy(pos).add(new THREE.Vector3((Math.random() - 0.5) * 2, Math.random(), (Math.random() - 0.5) * 2));
scene.add(spark);
sparkParticles.push({
mesh: spark,
velocity: new THREE.Vector3(
(Math.random() - 0.5) * 10,
Math.random() * 8 + 2,
(Math.random() - 0.5) * 10
),
life: 1.0,
});
}
}
// ==================== AUDIO SYSTEM ====================
let audioCtx = null;
let engineOsc = null;
let engineGain = null;
let gravelNode = null;
let gravelGain = null;
let gravelPlaying = false;
function initAudio() {
try {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
// Engine sound: oscillator with gain
engineOsc = audioCtx.createOscillator();
engineGain = audioCtx.createGain();
const engineFilter = audioCtx.createBiquadFilter();
engineFilter.type = 'lowpass';
engineFilter.frequency.value = 800;
engineOsc.type = 'sawtooth';
engineOsc.frequency.value = 60;
engineGain.gain.value = 0.05;
engineOsc.connect(engineFilter);
engineFilter.connect(engineGain);
engineGain.connect(audioCtx.destination);
engineOsc.start();
// Gravel noise buffer
const bufferSize = audioCtx.sampleRate * 2;
const gravelBuffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
const data = gravelBuffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * 0.3;
}
gravelNode = audioCtx.createBufferSource();
gravelNode.buffer = gravelBuffer;
gravelNode.loop = true;
gravelGain = audioCtx.createGain();
gravelGain.gain.value = 0;
const gravelFilter = audioCtx.createBiquadFilter();
gravelFilter.type = 'bandpass';
gravelFilter.frequency.value = 2000;
gravelFilter.Q.value = 1;
gravelNode.connect(gravelFilter);
gravelFilter.connect(gravelGain);
gravelGain.connect(audioCtx.destination);
gravelNode.start();
} catch (e) {
console.warn('Audio init failed:', e);
}
}
function updateEngineSound() {
if (!engineOsc || !audioCtx) return;
const rpm = car.rpm / 1000;
engineOsc.frequency.value = 40 + rpm * 8;
engineGain.gain.value = Math.min(0.12, 0.02 + (Math.abs(car.speed) / CFG.carMaxSpeed) * 0.1);
// Gravel sound
if ((car.drifting || !car.onTrack) && Math.abs(car.speed) > 5) {
gravelGain.gain.value = Math.min(0.08, gravelGain.gain.value + 0.02);
} else {
gravelGain.gain.value = Math.max(0, gravelGain.gain.value - 0.03);
}
}
function playBeep(freq, duration) {
if (!audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
gain.gain.value = 0.3;
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + duration);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + duration);
}
function playCrashSound() {
if (!audioCtx) return;
const bufferSize = audioCtx.sampleRate * 0.3;
const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (audioCtx.sampleRate * 0.05));
}
const source = audioCtx.createBufferSource();
source.buffer = buffer;
const gain = audioCtx.createGain();
gain.gain.value = 0.3;
source.connect(gain);
gain.connect(audioCtx.destination);
source.start();
}
function playGravelSound() {
// Handled by continuous gravel noise in updateEngineSound
}
function playFinishTone() {
if (!audioCtx) return;
const notes = [523, 659, 784, 1047];
notes.forEach((freq, i) => {
setTimeout(() => playBeep(freq, 0.3), i * 200);
});
}
// ==================== HUD / UI ====================
function updateHUD() {
const speedKmh = Math.abs(Math.round(car.speed * 3.6));
document.getElementById('speed-value').textContent = speedKmh;
document.getElementById('lap-count').textContent = `LAP ${Math.min(state.lap, CFG.totalLaps)} / ${CFG.totalLaps}`;
const timeStr = formatTime(state.raceTime);
document.getElementById('current-time').textContent = timeStr;
if (state.bestLap < Infinity) {
document.getElementById('best-lap').textContent = `Best: ${formatTime(state.bestLap)}`;
}
// Gear display
const gearText = car.gear === 0 ? 'N' : car.gear.toString();
document.getElementById('gear-display').textContent = gearText;
// RPM bar
const rpmPercent = Math.min(100, ((car.rpm - 2000) / 6500) * 100);
document.getElementById('rpm-bar').style.width = rpmPercent + '%';
// Surface indicator
const surfaceNames = { asphalt: 'ASPHALT', dirt: 'DIRT', gravel: 'GRAVEL', mud: 'MUD', grass: 'OFF-ROAD' };
document.getElementById('surface-indicator').textContent = surfaceNames[car.currentSurface] || car.currentSurface.toUpperCase();
// Minimap
drawMinimap();
}
function formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toFixed(3).padStart(6, '0')}`;
}
function drawMinimap() {
const canvas = document.getElementById('minimap');
const ctx = canvas.getContext('2d');
const w = canvas.width;
const h = canvas.height;
ctx.clearRect(0, 0, w, h);
// Find track bounds
let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
for (const p of trackPoints) {
if (p.x < minX) minX = p.x;
if (p.x > maxX) maxX = p.x;
if (p.z < minZ) minZ = p.z;
if (p.z > maxZ) maxZ = p.z;
}
const padding = 20;
const scaleX = (w - padding * 2) / (maxX - minX || 1);
const scaleZ = (h - padding * 2) / (maxZ - minZ || 1);
const scale = Math.min(scaleX, scaleZ);
const offsetX = (w - (maxX - minX) * scale) / 2;
const offsetZ = (h - (maxZ - minZ) * scale) / 2;
function mapPoint(p) {
return {
x: (p.x - minX) * scale + offsetX,
y: (p.z - minZ) * scale + offsetZ,
};
}
// Draw track outline
ctx.strokeStyle = '#555';
ctx.lineWidth = 4;
ctx.beginPath();
for (let i = 0; i < trackPoints.length; i += 3) {
const mp = mapPoint(trackPoints[i]);
if (i === 0) ctx.moveTo(mp.x, mp.y);
else ctx.lineTo(mp.x, mp.y);
}
ctx.closePath();
ctx.stroke();
// Draw checkpoints
for (let i = 0; i < checkpoints.length; i++) {
const cp = checkpoints[i];
const mp = mapPoint(cp.position);
ctx.fillStyle = cp.passed ? '#44ff44' : '#ffaa00';
ctx.beginPath();
ctx.arc(mp.x, mp.y, 2, 0, Math.PI * 2);
ctx.fill();
}
// Draw car position with heading indicator
const carMP = mapPoint(car.position);
ctx.fillStyle = '#ff3300';
ctx.beginPath();
ctx.arc(carMP.x, carMP.y, 4, 0, Math.PI * 2);
ctx.fill();
// Heading arrow
const arrowLen = 8;
ctx.strokeStyle = '#ff3300';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(carMP.x, carMP.y);
ctx.lineTo(
carMP.x + Math.sin(-car.heading) * arrowLen,
carMP.y + Math.cos(-car.heading) * arrowLen
);
ctx.stroke();
}
// ==================== GAME FLOW ====================
function startRace() {
document.getElementById('start-screen').style.display = 'none';
state.phase = 'countdown';
state.lap = 1;
state.currentCheckpoint = 0;
state.raceTime = 0;
state.bestLap = Infinity;
state.lapTimes = [];
for (const c of checkpoints) c.passed = false;
resetCar();
initAudio();
// Countdown sequence
doCountdown(3);
}
function doCountdown(n) {
const el = document.getElementById('countdown-text');
if (n > 0) {
el.textContent = n.toString();
el.style.opacity = '1';
playBeep(440, 0.2);
setTimeout(() => { el.style.opacity = '0'; }, 800);
setTimeout(() => doCountdown(n - 1), 1000);
} else {
el.textContent = 'GO!';
el.style.color = '#44ff44';
el.style.opacity = '1';
playBeep(880, 0.3);
setTimeout(() => {
el.style.opacity = '0';
el.style.color = '#fff';
state.phase = 'racing';
state.lapStartTime = 0;
}, 600);
}
}
function pause() {
if (state.phase !== 'racing') return;
state.phase = 'paused';
document.getElementById('pause-menu').classList.add('active');
}
function resume() {
if (state.phase !== 'paused') return;
state.phase = 'racing';
document.getElementById('pause-menu').classList.remove('active');
}
function restart() {
// Clean up particles and skid marks
dustParticles.forEach(p => { scene.remove(p.mesh); p.mesh.geometry.dispose(); p.mesh.material.dispose(); });
sparkParticles.forEach(p => { scene.remove(p.mesh); p.mesh.geometry.dispose(); p.mesh.material.dispose(); });
skidMarkMeshes.forEach(s => { scene.remove(s.mesh); s.mesh.geometry.dispose(); s.mesh.material.dispose(); });
dustParticles = [];
sparkParticles = [];
skidMarkMeshes = [];
document.getElementById('pause-menu').classList.remove('active');
document.getElementById('finish-screen').classList.remove('active');
startRace();
}
function finishRace() {
state.phase = 'finished';
playFinishTone();
document.getElementById('final-time').textContent = `Total Time: ${formatTime(state.raceTime)}`;
document.getElementById('final-best-lap').textContent = `Best Lap: ${state.bestLap < Infinity ? formatTime(state.bestLap) : 'N/A'}`;
document.getElementById('finish-screen').classList.add('active');
}
function flashWarning() {
const el = document.getElementById('warning-flash');
el.style.opacity = '1';
setTimeout(() => { el.style.opacity = '0'; }, 200);
}
// ==================== MAIN LOOP ====================
let lastTime = 0;
function gameLoop() {
requestAnimationFrame(gameLoop);
const dt = Math.min(clock.getDelta(), 0.05);
if (state.phase === 'racing') {
state.raceTime += dt;
updateCarPhysics(dt);
} else if (state.phase === 'countdown' || state.phase === 'paused' || state.phase === 'finished') {
// Still render but don't update physics
}
updateCarVisuals();
updateCamera(dt);
updateEngineSound();
updateHUD();
renderer.render(scene, camera);
}
// ==================== INITIALIZATION ====================
function init() {
setupTouch();
initThree();
generateTrack();
buildTrackMesh();
buildGround();
buildScenery();
buildCar();
resetCar();
camCurrentPos.copy(car.position).add(new THREE.Vector3(0, CFG.cameraHeight, CFG.cameraDistance));
gameLoop();
}
// Public API
return {
init,
startRace,
pause,
resume,
restart,
};
})();
// Start the game when page loads
window.addEventListener('load', () => RallyGame.init());
</script>
</body>
</html>
<!-- agent-meta {"model":"thinkingcap-qwen3.6-27b","provider":"lmstudio","persona":"full","sessionId":"1ee324d0-cac9-482f-9d0a-04c1e217340c","tokensIn":591335,"tokensOut":21359,"tokensTotal":612694,"turns":17,"toolCalls":16,"failedToolCalls":0,"timestamp":"2026-07-18T21:43:16.102Z"} -->