Rally
Qwen 3.6 27b MTP · 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 55,357 bytes · SHA-256 1a3c3b7a68fd
<!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 { width: 100%; height: 100%; overflow: hidden; background: #000; font-family: 'Segoe UI', Arial, sans-serif; touch-action: none; user-select: none; -webkit-user-select: none; }
canvas { display: block; }
/* HUD Overlay */
#hud { position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; }
#hud > * { pointer-events: auto; }
/* Speedometer */
#speedo { position: absolute; bottom: 20px; right: 20px; width: 180px; height: 180px; background: radial-gradient(circle, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.4) 100%); border-radius: 50%; border: 3px solid #ff6600; display: flex; align-items: center; justify-content: center; flex-direction: column; }
#speed-val { color: #fff; font-size: 36px; font-weight: bold; text-shadow: 0 0 10px #ff6600; }
#speed-unit { color: #aaa; font-size: 12px; }
#gear-display { position: absolute; bottom: 5px; right: 5px; color: #ff6600; font-size: 24px; font-weight: bold; background: rgba(0,0,0,0.6); padding: 2px 8px; border-radius: 4px; }
#rpm-bar { position: absolute; bottom: 35px; right: 15px; width: 150px; height: 8px; background: rgba(0,0,0,0.5); border-radius: 4px; overflow: hidden; }
#rpm-fill { height: 100%; width: 0%; background: linear-gradient(to right, #0f0, #ff0, #f00); transition: width 0.1s; border-radius: 4px; }
/* Lap Info */
#lap-info { position: absolute; top: 20px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.7); padding: 8px 24px; border-radius: 8px; text-align: center; color: #fff; }
#lap-info .lap-num { font-size: 18px; font-weight: bold; color: #ff6600; }
#lap-info .time { font-size: 14px; margin-top: 2px; }
/* Best Lap */
#best-lap { position: absolute; top: 75px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.6); padding: 4px 16px; border-radius: 6px; color: #0f0; font-size: 13px; }
/* Minimap */
#minimap { position: absolute; top: 20px; left: 20px; width: 150px; height: 150px; background: rgba(0,0,0,0.6); border-radius: 8px; border: 2px solid #ff6600; }
#minimap canvas { width: 100%; height: 100%; border-radius: 6px; }
/* Countdown */
#countdown { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 120px; font-weight: bold; color: #ff6600; text-shadow: 0 0 30px rgba(255,102,0,0.8), 0 0 60px rgba(255,102,0,0.4); z-index: 100; display: none; animation: countPulse 0.5s ease-out; }
@keyframes countPulse { from { transform: translate(-50%, -50%) scale(1.8); opacity: 0; } to { transform: translate(-50%, -50%) scale(1); opacity: 1; } }
/* Start prompt */
#start-prompt { position: fixed; top: 60%; left: 50%; transform: translateX(-50%); color: #fff; font-size: 20px; text-align: center; z-index: 90; animation: blink 1.5s infinite; }
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
/* Lap notification */
#lap-notification { position: fixed; top: 40%; left: 50%; transform: translateX(-50%); color: #ff6600; font-size: 32px; font-weight: bold; text-shadow: 0 0 15px rgba(255,102,0,0.6); z-index: 90; opacity: 0; transition: opacity 0.3s; pointer-events: none; }
/* Pause Menu */
#pause-menu { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 200; display: none; align-items: center; justify-content: center; flex-direction: column; }
#pause-menu h1 { color: #ff6600; font-size: 48px; margin-bottom: 30px; text-shadow: 0 0 20px rgba(255,102,0,0.5); }
.menu-btn { background: linear-gradient(to bottom, #ff6600, #cc5200); color: #fff; border: none; padding: 12px 40px; font-size: 20px; border-radius: 8px; cursor: pointer; margin: 8px; min-width: 200px; text-transform: uppercase; letter-spacing: 2px; }
.menu-btn:hover { background: linear-gradient(to bottom, #ff8533, #e66000); transform: scale(1.05); }
/* Finish Screen */
#finish-screen { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.85); z-index: 200; display: none; align-items: center; justify-content: center; flex-direction: column; }
#finish-screen h1 { color: #ff6600; font-size: 52px; margin-bottom: 10px; text-shadow: 0 0 30px rgba(255,102,0,0.6); }
#finish-stats { color: #fff; font-size: 20px; line-height: 2; text-align: center; margin-bottom: 30px; }
#finish-stats span { color: #ff6600; font-weight: bold; }
/* Touch Controls */
#touch-controls { position: fixed; bottom: 10px; left: 0; width: 100%; height: 140px; z-index: 50; display: none; pointer-events: none; }
.touch-btn { pointer-events: auto; background: rgba(255,255,255,0.15); border: 2px solid rgba(255,255,255,0.3); border-radius: 50%; color: #fff; font-size: 24px; display: flex; align-items: center; justify-content: center; position: absolute; }
.touch-btn:active { background: rgba(255,102,0,0.4); border-color: #ff6600; }
#btn-left { left: 15px; bottom: 30px; width: 70px; height: 70px; }
#btn-right { left: 95px; bottom: 30px; width: 70px; height: 70px; }
#btn-gas { right: 15px; bottom: 60px; width: 80px; height: 80px; background: rgba(0,200,0,0.2); border-color: rgba(0,200,0,0.4); }
#btn-brake { right: 15px; bottom: -10px; width: 70px; height: 70px; background: rgba(200,0,0,0.2); border-color: rgba(200,0,0,0.4); }
#btn-handbrake { right: 110px; bottom: 30px; width: 60px; height: 60px; background: rgba(255,165,0,0.2); border-color: rgba(255,165,0,0.4); font-size: 14px; }
/* Surface indicator */
#surface-indicator { position: absolute; bottom: 210px; right: 30px; color: #fff; font-size: 12px; background: rgba(0,0,0,0.5); padding: 3px 10px; border-radius: 4px; }
</style>
</head>
<body>
<div id="hud">
<div id="lap-info"><span class="lap-num" id="lap-display">LAP 1 / 3</span><br><span class="time" id="timer-display">00:00.000</span></div>
<div id="best-lap">BEST LAP: --:--.---</div>
<div id="minimap"><canvas id="minimap-canvas" width="150" height="150"></canvas></div>
<div id="speedo"><span id="speed-val">0</span><span id="speed-unit">KM/H</span><div id="rpm-bar"><div id="rpm-fill"></div></div></div>
<div id="gear-display">N</div>
<div id="surface-indicator">ASPHALT</div>
</div>
<div id="countdown"></div>
<div id="start-prompt">Click or press any key to start</div>
<div id="lap-notification"></div>
<div id="pause-menu">
<h1>PAUSED</h1>
<button class="menu-btn" onclick="RallyGame.resume()">RESUME</button>
<button class="menu-btn" onclick="RallyGame.restart()">RESTART</button>
</div>
<div id="finish-screen">
<h1>RACE COMPLETE!</h1>
<div id="finish-stats"></div>
<button class="menu-btn" onclick="RallyGame.restart()">RACE AGAIN</button>
</div>
<div id="touch-controls">
<div class="touch-btn" id="btn-left">◀</div>
<div class="touch-btn" id="btn-right">▶</div>
<div class="touch-btn" id="btn-gas">▲</div>
<div class="touch-btn" id="btn-brake">STOP</div>
<div class="touch-btn" id="btn-handbrake">HBK</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
// ============================================================
// RALLY RACER 3D - Single-file rally racing game
// ============================================================
const RallyGame = (function() {
'use strict';
// ==================== CONSTANTS ====================
const TOTAL_LAPS = 3;
const TRACK_WIDTH = 12;
const CAR_LENGTH = 4.5;
const CAR_WIDTH = 2.0;
const MAX_SPEED_ON_TRACK = 65; // m/s (~234 km/h)
const ACCELERATION = 28;
const BRAKE_FORCE = 40;
const REVERSE_ACCEL = 12;
const STEER_RATE = 2.8;
const DRIFT_THRESHOLD = 25;
// ==================== GAME STATE ====================
let state = {
phase: 'countdown', // countdown, racing, paused, finished
lap: 1,
currentCheckpoint: 0,
totalCheckpoints: 0,
startTime: 0,
elapsedTime: 0,
lapTimes: [],
bestLap: Infinity,
countdownValue: 3,
countdownTimer: 0,
};
// ==================== INPUT ====================
const keys = {};
let touchInput = { left: false, right: false, gas: false, brake: false, handbrake: false };
function onKey(e) {
keys[e.code] = e.type === 'keydown';
if (e.type === 'keydown') {
if (e.code === 'KeyP' && state.phase !== 'finished') togglePause();
if (e.code === 'KeyR' && state.phase !== 'countdown') restart();
if (e.code === 'KeyC') toggleCamera();
}
}
function setupTouch() {
const isMobile = /Android|iPhone|iPad|iPod|webOS/i.test(navigator.userAgent) || ('ontouchstart' in window);
if (!isMobile) return;
document.getElementById('touch-controls').style.display = 'block';
const bindBtn = (id, key) => {
const el = document.getElementById(id);
el.addEventListener('touchstart', e => { e.preventDefault(); touchInput[key] = true; });
el.addEventListener('touchend', e => { e.preventDefault(); touchInput[key] = false; });
el.addEventListener('touchcancel', e => { touchInput[key] = false; });
};
bindBtn('btn-left', 'left');
bindBtn('btn-right', 'right');
bindBtn('btn-gas', 'gas');
bindBtn('btn-brake', 'brake');
bindBtn('btn-handbrake', 'handbrake');
}
function input() {
return {
accel: keys['ArrowUp'] || keys['KeyW'] || touchInput.gas,
brake: keys['ArrowDown'] || keys['KeyS'] || touchInput.brake,
left: keys['ArrowLeft'] || keys['KeyA'] || touchInput.left,
right: keys['ArrowRight'] || keys['KeyD'] || touchInput.right,
handbrake: keys['Space'] || touchInput.handbrake,
};
}
// ==================== AUDIO ENGINE (Web Audio API) ====================
let audioCtx = null;
let engineOsc = null, engineGain = null;
let skidNode = null, skidGain = null;
function initAudio() {
if (audioCtx) return;
try {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
// Engine sound - oscillator with noise
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;
engineOsc.connect(engineFilter);
engineFilter.connect(engineGain);
engineGain.connect(audioCtx.destination);
engineOsc.start();
// Skid/gravel noise
const bufferSize = audioCtx.sampleRate * 2;
const skidBuffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
const data = skidBuffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
skidNode = audioCtx.createBufferSource();
skidNode.buffer = skidBuffer;
skidNode.loop = true;
const skidFilter = audioCtx.createBiquadFilter();
skidFilter.type = 'bandpass';
skidFilter.frequency.value = 2000;
skidFilter.Q.value = 0.5;
skidGain = audioCtx.createGain();
skidGain.gain.value = 0;
skidNode.connect(skidFilter);
skidFilter.connect(skidGain);
skidGain.connect(audioCtx.destination);
skidNode.start();
} catch(e) { console.warn('Audio init failed:', e); }
}
function updateEngineSound(speed, gear) {
if (!engineOsc) return;
const rpm = Math.max(60, 60 + Math.abs(speed) * 15 + (gear - 1) * 80);
engineOsc.frequency.setTargetAtTime(rpm, audioCtx.currentTime, 0.05);
const vol = state.phase === 'racing' ? Math.min(0.12, 0.02 + Math.abs(speed) / MAX_SPEED_ON_TRACK * 0.1) : 0;
engineGain.gain.setTargetAtTime(vol, audioCtx.currentTime, 0.05);
}
function updateSkidSound(isDrifting, isOffRoad) {
if (!skidGain) return;
const vol = (isDrifting || isOffRoad) ? 0.06 : 0;
skidGain.gain.setTargetAtTime(vol, audioCtx.currentTime, 0.1);
}
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;
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
gain.gain.setTargetAtTime(0, audioCtx.currentTime + duration * 0.8, duration * 0.1);
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 src = audioCtx.createBufferSource();
src.buffer = buffer;
const gain = audioCtx.createGain();
gain.gain.value = 0.4;
const filter = audioCtx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = 600;
src.connect(filter);
filter.connect(gain);
gain.connect(audioCtx.destination);
src.start();
}
function playFinishTone() {
if (!audioCtx) return;
[523, 659, 784, 1047].forEach((f, i) => {
setTimeout(() => playBeep(f, 0.4), i * 200);
});
}
// ==================== TRACK GENERATION ====================
let trackPoints = []; // Interpolated track points (dense array)
let trackControlPoints = []; // Original control points
let checkpoints = []; // Checkpoint indices for lap validation
let sceneryObjects = []; // Trees, rocks, signs etc.
function generateTrack() {
// Define a winding loop with curves and hills
const cp = [
{ x: 0, z: 0, y: 0 },
{ x: 80, z: -30, y: 2 },
{ x: 160, z: -10, y: 5 },
{ x: 220, z: 40, y: 3 },
{ x: 250, z: 120, y: 8 },
{ x: 220, z: 200, y: 6 },
{ x: 160, z: 260, y: 4 },
{ x: 80, z: 300, y: 7 },
{ x: -20, z: 310, y: 5 },
{ x: -100, z: 280, y: 9 },
{ x: -160, z: 220, y: 6 },
{ x: -200, z: 140, y: 3 },
{ x: -210, z: 50, y: 2 },
{ x: -180, z: -30, y: 4 },
{ x: -100, z: -70, y: 1 },
{ x: -40, z: -50, y: 0 },
];
trackControlPoints = cp;
// Catmull-Rom spline interpolation for smooth track
const numSegments = 800;
trackPoints = [];
function catmullRom(p0, p1, p2, p3, t) {
const t2 = t * t, t3 = t2 * t;
return {
x: 0.5 * ((2*p1.x) + (-p0.x+p2.x)*t + (2*p0.x-5*p1.x+4*p2.x-p3.x)*t2 + (-p0.x+3*p1.x-3*p2.x+p3.x)*t3),
y: 0.5 * ((2*p1.y) + (-p0.y+p2.y)*t + (2*p0.y-5*p1.y+4*p2.y-p3.y)*t2 + (-p0.y+3*p1.y-3*p2.y+p3.y)*t3),
z: 0.5 * ((2*p1.z) + (-p0.z+p2.z)*t + (2*p0.z-5*p1.z+4*p2.z-p3.z)*t2 + (-p0.z+3*p1.z-3*p2.z+p3.z)*t3),
};
}
const n = cp.length;
for (let i = 0; i < numSegments; i++) {
const idx = Math.floor(i / (numSegments / n));
const t = (i % (numSegments / n)) / (numSegments / n);
const p0 = cp[(idx - 1 + n) % n];
const p1 = cp[idx % n];
const p2 = cp[(idx + 1) % n];
const p3 = cp[(idx + 2) % n];
trackPoints.push(catmullRom(p0, p1, p2, p3, t));
}
// Set up checkpoints (8 evenly spaced around the track)
state.totalCheckpoints = 8;
const checkpointInterval = Math.floor(trackPoints.length / state.totalCheckpoints);
for (let i = 0; i < state.totalCheckpoints; i++) {
checkpoints.push(i * checkpointInterval);
}
}
// ==================== THREE.JS SCENE SETUP ====================
let scene, camera, renderer, clock;
let carGroup, carBody, wheels = [];
let trackMesh, groundMesh;
let particles = [];
let skidMarks = [];
let camMode = 0; // 0=chase, 1=cinematic
function initScene() {
scene = new THREE.Scene();
// Sky gradient using a large sphere
const skyGeo = new THREE.SphereGeometry(500, 16, 16);
const skyMat = new THREE.ShaderMaterial({
uniforms: {
topColor: { value: new THREE.Color(0x0044aa) },
bottomColor: { value: new THREE.Color(0xaaddff) },
offset: { value: 20 },
exponent: { value: 0.6 }
},
vertexShader: `
varying vec3 vWorldPosition;
void main() {
vec4 worldPos = modelMatrix * vec4(position, 1.0);
vWorldPosition = worldPos.xyz;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 topColor;
uniform vec3 bottomColor;
uniform float offset;
uniform float exponent;
varying vec3 vWorldPosition;
void main() {
float h = normalize(vWorldPosition + offset).y;
gl_FragColor = vec4(mix(bottomColor, topColor, max(pow(max(h, 0.0), exponent), 0.0)), 1.0);
}
`,
side: THREE.BackSide
});
const skyMesh = new THREE.Mesh(skyGeo, skyMat);
scene.add(skyMesh);
// Clouds
for (let i = 0; i < 25; i++) {
const cloudGroup = new THREE.Group();
const numPuffs = 3 + Math.floor(Math.random() * 4);
for (let j = 0; j < numPuffs; j++) {
const puffGeo = new THREE.SphereGeometry(8 + Math.random() * 12, 7, 5);
const puffMat = new THREE.MeshLambertMaterial({ color: 0xffffff, transparent: true, opacity: 0.8 });
const puff = new THREE.Mesh(puffGeo, puffMat);
puff.position.set(j * 10 - numPuffs * 5 + Math.random() * 5, Math.random() * 3, Math.random() * 6);
puff.scale.y = 0.4;
cloudGroup.add(puff);
}
const angle = (i / 25) * Math.PI * 2;
const radius = 150 + Math.random() * 200;
cloudGroup.position.set(Math.cos(angle) * radius, 60 + Math.random() * 40, Math.sin(angle) * radius);
scene.add(cloudGroup);
}
scene.fog = new THREE.FogExp2(0xaaddff, 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.insertBefore(renderer.domElement, document.getElementById('hud'));
clock = new THREE.Clock();
// Lighting
const ambientLight = new THREE.AmbientLight(0x6688aa, 0.5);
scene.add(ambientLight);
const sunLight = new THREE.DirectionalLight(0xffeedd, 1.2);
sunLight.position.set(100, 80, 50);
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 = -100;
sunLight.shadow.camera.right = 100;
sunLight.shadow.camera.top = 100;
sunLight.shadow.camera.bottom = -100;
scene.add(sunLight);
const hemiLight = new THREE.HemisphereLight(0x87CEEB, 0x445522, 0.3);
scene.add(hemiLight);
}
// ==================== BUILD TRACK MESH ====================
function buildTrackMesh() {
// Create track surface as a ribbon mesh
const vertices = [];
const colors = [];
const indices = [];
for (let i = 0; i < trackPoints.length; i++) {
const p = trackPoints[i];
const nextP = trackPoints[(i + 1) % trackPoints.length];
// Direction vector
const dx = nextP.x - p.x;
const dz = nextP.z - p.z;
const len = Math.sqrt(dx * dx + dz * dz);
const nx = -dz / len;
const nz = dx / len;
const hw = TRACK_WIDTH / 2;
// Left edge (with slight color variation for visual interest)
vertices.push(p.x + nx * hw, p.y + 0.1, p.z + nz * hw);
colors.push(0.35, 0.35, 0.38);
// Right edge
vertices.push(p.x - nx * hw, p.y + 0.1, p.z - nz * hw);
colors.push(0.35, 0.35, 0.38);
// Create quads (exactly 2 vertices per segment)
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);
}
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
geometry.setIndex(indices);
geometry.computeVertexNormals();
const material = new THREE.MeshLambertMaterial({ vertexColors: true });
trackMesh = new THREE.Mesh(geometry, material);
trackMesh.receiveShadow = true;
scene.add(trackMesh);
// Add center line dashes using thin planes along the track
buildCenterLine();
// Add start/finish checkerboard line
buildStartFinishLine();
}
function buildCenterLine() {
const dashLength = 3;
const gapLength = 2;
let totalDist = 0;
// Calculate cumulative distances
const dists = [0];
for (let i = 0; i < trackPoints.length; i++) {
const p = trackPoints[i];
const nextP = trackPoints[(i + 1) % trackPoints.length];
totalDist += Math.sqrt((nextP.x-p.x)**2 + (nextP.z-p.z)**2);
dists.push(totalDist);
}
// Place dashes along the track
let dashStart = 0;
while (dashStart < totalDist) {
const pIdx = findPointAtDistance(dashStart, dists, totalDist);
if (pIdx >= 0) {
const p = trackPoints[pIdx];
const nextP = trackPoints[(pIdx + 1) % trackPoints.length];
const dx = nextP.x - p.x, dz = nextP.z - p.z;
const dirLen = Math.sqrt(dx*dx+dz*dz);
// Small white dash plane
const dashGeo = new THREE.PlaneGeometry(0.15, dashLength);
const dashMat = new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.DoubleSide });
const dash = new THREE.Mesh(dashGeo, dashMat);
dash.position.set(p.x, p.y + 0.12, p.z);
dash.rotation.x = -Math.PI / 2;
dash.rotation.z = -Math.atan2(dx, dz) + Math.PI/2;
scene.add(dash);
}
dashStart += dashLength + gapLength;
}
}
function findPointAtDistance(targetDist, dists, totalDist) {
// Binary search for the segment containing target distance
let lo = 0, hi = dists.length - 1;
while (lo < hi - 1) {
const mid = (lo + hi) >> 1;
if (dists[mid] <= targetDist % totalDist) lo = mid;
else hi = mid;
}
return Math.min(lo, trackPoints.length - 1);
}
function buildStartFinishLine() {
// Checkerboard pattern at start of track
const p0 = trackPoints[0];
const p1 = trackPoints[1];
const dx = p1.x - p0.x, dz = p1.z - p0.z;
const len = Math.sqrt(dx*dx+dz*dz);
const nx = -dz/len, nz = dx/len;
// Create checkerboard using small planes
const tileSize = TRACK_WIDTH / 8;
for (let row = 0; row < 2; row++) {
for (let col = 0; col < 8; col++) {
if ((row + col) % 2 === 0) continue; // Skip every other tile
const geo = new THREE.PlaneGeometry(tileSize, tileSize);
const mat = new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.DoubleSide });
const tile = new THREE.Mesh(geo, mat);
const offsetZ = (row - 0.5) * tileSize;
const offsetX = (col - 3.5) * tileSize;
tile.position.set(
p0.x + nx * offsetX + dx/len * offsetZ,
p0.y + 0.13,
p0.z + nz * offsetX + dz/len * offsetZ
);
tile.rotation.x = -Math.PI / 2;
tile.rotation.z = Math.atan2(dx, dz);
scene.add(tile);
}
}
}
// ==================== GROUND PLANE ====================
function buildGround() {
const groundGeo = new THREE.PlaneGeometry(800, 800, 40, 40);
// Add some terrain variation
const pos = groundGeo.attributes.position;
for (let i = 0; i < pos.count; i++) {
const x = pos.getX(i), y = pos.getY(i);
const dist = Math.sqrt(x*x + y*y);
if (dist > 150) {
pos.setZ(i, (Math.sin(x*0.02)*Math.cos(y*0.03)) * 8 + Math.random() * 2);
}
}
groundGeo.computeVertexNormals();
const groundMat = new THREE.MeshLambertMaterial({ color: 0x4a7c3f });
groundMesh = new THREE.Mesh(groundGeo, groundMat);
groundMesh.rotation.x = -Math.PI / 2;
groundMesh.position.y = -0.5;
groundMesh.receiveShadow = true;
scene.add(groundMesh);
}
// ==================== SCENERY GENERATION ====================
function buildScenery() {
// Trees
for (let i = 0; i < trackPoints.length; i += 8) {
const p = trackPoints[i];
const nextP = trackPoints[(i+1)%trackPoints.length];
const dx = nextP.x - p.x, dz = nextP.z - p.z;
const len = Math.sqrt(dx*dx+dz*dz);
const nx = -dz/len, nz = dx/len;
// Place trees on both sides of track
for (let side = -1; side <= 1; side += 2) {
if (Math.random() > 0.4) continue;
const dist = TRACK_WIDTH/2 + 5 + Math.random() * 30;
const tx = p.x + nx * dist * side + (Math.random()-0.5)*10;
const tz = p.z + nz * dist * side + (Math.random()-0.5)*10;
// Simple tree: trunk + foliage cone
const treeGroup = new THREE.Group();
const trunkGeo = new THREE.CylinderGeometry(0.3, 0.4, 3, 6);
const trunkMat = new THREE.MeshLambertMaterial({ color: 0x8B5E3C });
const trunk = new THREE.Mesh(trunkGeo, trunkMat);
trunk.position.y = 1.5;
trunk.castShadow = true;
treeGroup.add(trunk);
const foliageGeo = new THREE.ConeGeometry(2 + Math.random(), 4 + Math.random()*3, 6);
const greenShade = 0x2d6b1e + Math.floor(Math.random() * 0x1a3a0f);
const foliageMat = new THREE.MeshLambertMaterial({ color: greenShade });
const foliage = new THREE.Mesh(foliageGeo, foliageMat);
foliage.position.y = 5;
foliage.castShadow = true;
treeGroup.add(foliage);
// Second layer for some trees
if (Math.random() > 0.5) {
const f2 = new THREE.Mesh(new THREE.ConeGeometry(1.5, 3, 6), foliageMat);
f2.position.y = 7;
f2.castShadow = true;
treeGroup.add(f2);
}
treeGroup.position.set(tx, p.y - 0.5, tz);
scene.add(treeGroup);
}
}
// Rocks scattered around
for (let i = 0; i < trackPoints.length; i += 15) {
const p = trackPoints[i];
const nextP = trackPoints[(i+1)%trackPoints.length];
const dx = nextP.x - p.x, dz = nextP.z - p.z;
const len = Math.sqrt(dx*dx+dz*dz);
const nx = -dz/len, nz = dx/len;
if (Math.random() > 0.5) continue;
const dist = TRACK_WIDTH/2 + 3 + Math.random() * 15;
const side = Math.random() > 0.5 ? 1 : -1;
const rockGeo = new THREE.DodecahedronGeometry(0.8 + Math.random()*1.5, 1);
// Deform vertices for natural look
const rPos = rockGeo.attributes.position;
for (let j = 0; j < rPos.count; j++) {
rPos.setX(j, rPos.getX(j) * (0.7 + Math.random()*0.6));
rPos.setY(j, rPos.getY(j) * (0.5 + Math.random()*0.4));
rPos.setZ(j, rPos.getZ(j) * (0.7 + Math.random()*0.6));
}
rockGeo.computeVertexNormals();
const rockMat = new THREE.MeshLambertMaterial({ color: 0x888888 });
const rock = new THREE.Mesh(rockGeo, rockMat);
rock.position.set(p.x + nx*dist*side, p.y - 0.2, p.z + nz*dist*side);
rock.rotation.set(Math.random(), Math.random(), Math.random());
rock.castShadow = true;
scene.add(rock);
// Store as obstacle for collision
sceneryObjects.push({ mesh: rock, radius: 1.5 });
}
// Banners/signs at start and checkpoints
const bannerPositions = [0, Math.floor(trackPoints.length/4), Math.floor(trackPoints.length/2), Math.floor(3*trackPoints.length/4)];
bannerPositions.forEach(idx => {
const p = trackPoints[idx];
const nextP = trackPoints[(idx+1)%trackPoints.length];
const dx = nextP.x - p.x, dz = nextP.z - p.z;
const len = Math.sqrt(dx*dx+dz*dz);
const nx = -dz/len, nz = dx/len;
// Banner pole on left side
for (let s = -1; s <= 1; s += 2) {
const poleGeo = new THREE.CylinderGeometry(0.1, 0.1, 6, 4);
const poleMat = new THREE.MeshLambertMaterial({ color: 0xcccccc });
const pole = new THREE.Mesh(poleGeo, poleMat);
pole.position.set(p.x + nx*(TRACK_WIDTH/2+1)*s, p.y+3, p.z + nz*(TRACK_WIDTH/2+1)*s);
scene.add(pole);
}
// Banner between poles
const bannerGeo = new THREE.PlaneGeometry(TRACK_WIDTH+4, 2.5);
const isStart = idx === 0;
const bannerMat = new THREE.MeshLambertMaterial({
color: isStart ? 0xff6600 : (idx % 2 === 0 ? 0x3399ff : 0x33cc33),
side: THREE.DoubleSide
});
const banner = new THREE.Mesh(bannerGeo, bannerMat);
// Position banner above track
banner.position.set(p.x, p.y + 5.5, p.z);
banner.rotation.y = Math.atan2(dx, dz);
scene.add(banner);
});
// Guard rails along some sections
for (let i = 0; i < trackPoints.length; i += 4) {
const p = trackPoints[i];
if (Math.random() > 0.3) continue;
const nextP = trackPoints[(i+1)%trackPoints.length];
const dx = nextP.x - p.x, dz = nextP.z - p.z;
const len = Math.sqrt(dx*dx+dz*dz);
const nx = -dz/len, nz = dx/len;
// Small guardrail post on one side
const side = (i % 8 < 4) ? 1 : -1;
const postGeo = new THREE.BoxGeometry(0.15, 0.8, 0.15);
const postMat = new THREE.MeshLambertMaterial({ color: 0xdddddd });
const post = new THREE.Mesh(postGeo, postMat);
post.position.set(p.x + nx*(TRACK_WIDTH/2+0.3)*side, p.y+0.4, p.z + nz*(TRACK_WIDTH/2+0.3)*side);
scene.add(post);
// Rail between posts (every other)
if (i % 8 === 0) {
const railGeo = new THREE.BoxGeometry(0.1, 0.15, 4);
const railMat = new THREE.MeshLambertMaterial({ color: 0xcccccc });
const rail = new THREE.Mesh(railGeo, railMat);
rail.position.set(p.x + nx*(TRACK_WIDTH/2+0.3)*side, p.y+0.7, p.z + nz*(TRACK_WIDTH/2+0.3)*side);
rail.rotation.y = Math.atan2(dx, dz);
scene.add(rail);
}
}
}
// ==================== BUILD RALLY CAR ====================
function buildCar() {
carGroup = new THREE.Group();
// Main body
const bodyGeo = new THREE.BoxGeometry(CAR_WIDTH, 0.8, CAR_LENGTH * 0.7);
const bodyMat = new THREE.MeshLambertMaterial({ color: 0xcc2200 });
carBody = new THREE.Mesh(bodyGeo, bodyMat);
carBody.position.y = 0.6;
carBody.castShadow = true;
carGroup.add(carBody);
// Cabin/roof
const cabinGeo = new THREE.BoxGeometry(CAR_WIDTH * 0.85, 0.7, CAR_LENGTH * 0.4);
const cabinMat = new THREE.MeshLambertMaterial({ color: 0x222233 });
const cabin = new THREE.Mesh(cabinGeo, cabinMat);
cabin.position.set(0, 1.25, -0.2);
cabin.castShadow = true;
carGroup.add(cabin);
// Windshield (front)
const windshieldGeo = new THREE.PlaneGeometry(CAR_WIDTH * 0.8, 0.6);
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.05);
windshield.rotation.x = -Math.PI * 0.3;
carGroup.add(windshield);
// Spoiler
const spoilerGeo = new THREE.BoxGeometry(CAR_WIDTH + 0.4, 0.08, 0.6);
const spoilerMat = new THREE.MeshLambertMaterial({ color: 0x111111 });
const spoiler = new THREE.Mesh(spoilerGeo, spoilerMat);
spoiler.position.set(0, 1.4, -CAR_LENGTH * 0.35);
carGroup.add(spoiler);
// Spoiler supports
for (let s = -1; s <= 1; s += 2) {
const supportGeo = new THREE.BoxGeometry(0.08, 0.4, 0.08);
const support = new THREE.Mesh(supportGeo, spoilerMat);
support.position.set(s * CAR_WIDTH * 0.35, 1.2, -CAR_LENGTH * 0.35);
carGroup.add(support);
}
// Headlights (front)
for (let s = -1; s <= 1; s += 2) {
const hlGeo = new THREE.SphereGeometry(0.15, 6, 4);
const hlMat = new THREE.MeshLambertMaterial({ color: 0xffffcc, emissive: 0xffffaa });
const hl = new THREE.Mesh(hlGeo, hlMat);
hl.position.set(s * CAR_WIDTH * 0.35, 0.6, CAR_LENGTH * 0.35);
carGroup.add(hl);
}
// Taillights (rear)
for (let s = -1; s <= 1; s += 2) {
const tlGeo = new THREE.BoxGeometry(0.3, 0.15, 0.05);
const tlMat = new THREE.MeshLambertMaterial({ color: 0xff0000, emissive: 0xff0000 });
const tl = new THREE.Mesh(tlGeo, tlMat);
tl.position.set(s * CAR_WIDTH * 0.35, 0.6, -CAR_LENGTH * 0.37);
carGroup.add(tl);
}
// Wheels (4)
const wheelGeo = new THREE.CylinderGeometry(0.35, 0.35, 0.25, 8);
const wheelMat = new THREE.MeshLambertMaterial({ color: 0x1a1a1a });
const wheelPositions = [
{ x: -CAR_WIDTH/2 - 0.1, y: 0.35, z: CAR_LENGTH * 0.25 },
{ x: CAR_WIDTH/2 + 0.1, y: 0.35, z: CAR_LENGTH * 0.25 },
{ x: -CAR_WIDTH/2 - 0.1, y: 0.35, z: -CAR_LENGTH * 0.25 },
{ x: CAR_WIDTH/2 + 0.1, y: 0.35, z: -CAR_LENGTH * 0.25 },
];
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);
// Hubcap
const hubGeo = new THREE.CylinderGeometry(0.15, 0.15, 0.26, 6);
const hubMat = new THREE.MeshLambertMaterial({ color: 0xcccccc });
const hub = new THREE.Mesh(hubGeo, hubMat);
hub.rotation.z = Math.PI / 2;
hub.position.set(pos.x, pos.y, pos.z);
carGroup.add(hub);
});
// Rally number on side
const numCanvas = document.createElement('canvas');
numCanvas.width = 64; numCanvas.height = 64;
const ctx2d = numCanvas.getContext('2d');
ctx2d.fillStyle = '#ffffff';
ctx2d.fillRect(0, 0, 64, 64);
ctx2d.fillStyle = '#cc2200';
ctx2d.font = 'bold 36px Arial';
ctx2d.textAlign = 'center';
ctx2d.fillText('7', 32, 45);
const numTexture = new THREE.CanvasTexture(numCanvas);
const numGeo = new THREE.PlaneGeometry(0.8, 0.8);
const numMat = new THREE.MeshLambertMaterial({ map: numTexture });
for (let s = -1; s <= 1; s += 2) {
const numMesh = new THREE.Mesh(numGeo, numMat);
numMesh.position.set(s * (CAR_WIDTH/2 + 0.06), 0.7, 0.3);
numMesh.rotation.y = s > 0 ? Math.PI / 2 : -Math.PI / 2;
carGroup.add(numMesh);
}
scene.add(carGroup);
}
// ==================== CAR PHYSICS ====================
let carState = {
x: 0, y: 0, z: 0,
heading: 0, // radians, facing +X direction initially
speed: 0,
lateralSpeed: 0,
angularVel: 0,
gear: 1,
rpm: 0,
isDrifting: false,
surfaceType: 'asphalt',
};
function getTrackInfo(x, z) {
// Find closest track point and distance from center
let minDist = Infinity;
let closestIdx = 0;
// Search nearby checkpoints first for efficiency
const searchRange = 50;
const startSearch = Math.max(0, state.currentCheckpoint - searchRange);
const endSearch = Math.min(trackPoints.length, state.currentCheckpoint + searchRange);
for (let i = startSearch; i < endSearch; i++) {
const p = trackPoints[i];
const dx = x - p.x, dz = z - p.z;
const d = dx*dx + dz*dz;
if (d < minDist) {
minDist = d;
closestIdx = i;
}
}
// Also check wrap-around region
for (let i = 0; i < searchRange && i < trackPoints.length - searchRange; i++) {
const p = trackPoints[i];
const dx = x - p.x, dz = z - p.z;
const d = dx*dx + dz*dz;
if (d < minDist) {
minDist = d;
closestIdx = i;
}
}
const dist = Math.sqrt(minDist);
const p = trackPoints[closestIdx];
const nextP = trackPoints[(closestIdx + 1) % trackPoints.length];
// Track direction
const dx = nextP.x - p.x;
const dz = nextP.z - p.z;
const dirLen = Math.sqrt(dx*dx + dz*dz);
const trackDirX = dx / dirLen;
const trackDirZ = dz / dirLen;
// Normal to track (left side)
const normalX = -trackDirZ;
const normalZ = trackDirX;
// Lateral offset from center
const lateralOffset = ((x - p.x) * normalX + (z - p.z) * normalZ);
let surfaceType = 'asphalt';
if (dist > TRACK_WIDTH / 2 + 3) {
surfaceType = 'grass';
} else if (dist > TRACK_WIDTH / 2) {
surfaceType = 'gravel';
}
return {
closestIdx,
distance: dist,
lateralOffset,
trackY: p.y,
trackDirX,
trackDirZ,
normalX,
normalZ,
surfaceType,
onTrack: dist <= TRACK_WIDTH / 2,
};
}
function updateCarPhysics(dt) {
const inp = input();
// Get track info
const trackInfo = getTrackInfo(carState.x, carState.z);
carState.surfaceType = trackInfo.surfaceType;
// Surface properties
let maxSpeed = MAX_SPEED_ON_TRACK;
let accelMult = 1.0;
let gripFactor = 1.0;
let friction = 0.98;
switch (trackInfo.surfaceType) {
case 'asphalt': break;
case 'gravel': maxSpeed *= 0.75; accelMult = 0.7; gripFactor = 0.6; friction = 0.96; break;
case 'grass': maxSpeed *= 0.5; accelMult = 0.4; gripFactor = 0.35; friction = 0.92; break;
}
// Acceleration
if (inp.accel) {
carState.speed += ACCELERATION * accelMult * dt;
} else if (inp.brake) {
if (carState.speed > 1) {
carState.speed -= BRAKE_FORCE * dt;
} else {
carState.speed -= REVERSE_ACCEL * dt;
}
} else {
// Natural deceleration
carState.speed *= Math.pow(friction, dt * 60);
}
// Clamp speed
carState.speed = Math.max(-15, Math.min(maxSpeed, carState.speed));
// Handbrake - reduces grip dramatically
if (inp.handbrake) {
gripFactor *= 0.2;
friction = 0.94;
}
// Steering
const speedRatio = Math.abs(carState.speed) / maxSpeed;
let steerAmount = 0;
if (Math.abs(carState.speed) > 0.5) {
const steerFactor = STEER_RATE * (1 - speedRatio * 0.4); // Less steering at high speed
if (inp.left) steerAmount = steerFactor;
if (inp.right) steerAmount = -steerFactor;
// Reverse steering direction when going backwards
if (carState.speed < 0) steerAmount *= -1;
}
carState.angularVel += steerAmount * dt * Math.min(1, Math.abs(carState.speed) / 5);
carState.angularVel *= 0.85; // Angular damping
// Apply rotation
carState.heading += carState.angularVel * dt;
// Drift detection
const lateralGrip = gripFactor;
const driftAngle = Math.abs(carState.angularVel) * Math.abs(carState.speed);
carState.isDrifting = driftAngle > DRIFT_THRESHOLD && !inp.handbrake || (driftAngle > 15 && inp.handbrake);
if (!carState.isDrifting) {
// Align velocity with heading gradually based on grip
const alignRate = 3 * lateralGrip;
carState.lateralSpeed *= Math.pow(0.9, dt * 60 * alignRate);
} else {
// Build up lateral speed when drifting
carState.lateralSpeed += carState.angularVel * carState.speed * dt * 2;
carState.lateralSpeed = Math.max(-15, Math.min(15, carState.lateralSpeed));
}
// Calculate velocity components in world space
const forwardX = Math.sin(carState.heading);
const forwardZ = Math.cos(carState.heading);
const rightX = Math.cos(carState.heading);
const rightZ = -Math.sin(carState.heading);
// Move car
const moveSpeed = carState.speed * dt;
const lateralMove = carState.lateralSpeed * dt * (1 - gripFactor) * 0.5;
carState.x += forwardX * moveSpeed + rightX * lateralMove;
carState.z += forwardZ * moveSpeed + rightZ * lateralMove;
// Elevation: follow track height with some smoothing
const targetY = trackInfo.trackY;
carState.y += (targetY - carState.y) * Math.min(1, dt * 8);
// Collision with obstacles
for (const obj of sceneryObjects) {
const dx = carState.x - obj.mesh.position.x;
const dz = carState.z - obj.mesh.position.z;
const dist = Math.sqrt(dx*dx + dz*dz);
if (dist < obj.radius + 1.2) {
// Bounce off
const nx = dx / dist, nz = dz / dist;
carState.x += nx * (obj.radius + 1.5 - dist);
carState.z += nz * (obj.radius + 1.5 - dist);
carState.speed *= 0.3;
carState.angularVel += (Math.random() - 0.5) * 2;
playCrashSound();
// Spawn sparks
spawnSparks(carState.x, carState.y + 1, carState.z);
}
}
// Track boundary collision (soft bounce when way off track)
if (!trackInfo.onTrack && Math.abs(trackInfo.lateralOffset) > TRACK_WIDTH/2 + 8) {
const pushBack = (Math.abs(trackInfo.lateralOffset) - TRACK_WIDTH/2 - 8) * dt * 3;
carState.x -= trackInfo.normalX * pushBack * Math.sign(trackInfo.lateralOffset);
carState.z -= trackInfo.normalZ * pushBack * Math.sign(trackInfo.lateralOffset);
carState.speed *= 0.95;
}
// Gear simulation
const absSpeed = Math.abs(carState.speed);
if (absSpeed < 1) {
carState.gear = carState.speed >= 0 ? 1 : -1;
} else {
carState.gear = Math.min(6, Math.floor(absSpeed / 12) + 1);
if (carState.speed < 0) carState.gear = -carState.gear;
}
// RPM simulation
const gearRange = 12;
const speedInGear = absSpeed % gearRange;
carState.rpm = Math.min(8000, 1500 + (speedInGear / gearRange) * 6500);
// Update audio
updateEngineSound(carState.speed, carState.gear);
updateSkidSound(carState.isDrifting, !trackInfo.onTrack);
// Spawn particles when drifting or off-road
if (carState.isDrifting || (!trackInfo.onTrack && absSpeed > 5)) {
spawnDust(carState.x - forwardX * CAR_LENGTH * 0.3, carState.y + 0.2, carState.z - forwardZ * CAR_LENGTH * 0.3);
}
// Checkpoint validation
checkCheckpoints(trackInfo.closestIdx);
}
// ==================== CHECKPOINT & LAP SYSTEM ====================
function checkCheckpoints(idx) {
if (state.phase !== 'racing') return;
const cp = checkpoints[state.currentCheckpoint];
const distToCP = Math.abs(idx - cp);
const wrapDist = Math.min(distToCP, trackPoints.length - distToCP);
if (wrapDist < 30) {
state.currentCheckpoint++;
// Check for lap completion
if (state.currentCheckpoint >= state.totalCheckpoints) {
completeLap();
}
}
}
function completeLap() {
const lapTime = performance.now() - state.startTime;
state.lapTimes.push(lapTime);
if (lapTime < state.bestLap) {
state.bestLap = lapTime;
}
state.currentCheckpoint = 0;
state.startTime = performance.now();
if (state.lapTimes.length >= TOTAL_LAPS) {
finishRace();
} else {
state.lap++;
}
}
function finishRace() {
state.phase = 'finished';
playFinishTone();
const totalTime = state.lapTimes.reduce((a, b) => a + b, 0);
document.getElementById('finish-stats').innerHTML =
`Total Time: <span>${formatTime(totalTime)}</span><br>` +
`Best Lap: <span>${formatTime(state.bestLap)}</span><br>` +
state.lapTimes.map((t, i) => `<small>Lap ${i+1}: ${formatTime(t)}</small>`).join('<br>');
document.getElementById('finish-screen').style.display = 'flex';
}
// ==================== PARTICLE SYSTEM ====================
function spawnDust(x, y, z) {
if (particles.length > 200) return; // Limit particles
const geo = new THREE.SphereGeometry(0.15 + Math.random() * 0.2, 4, 3);
const mat = new THREE.MeshBasicMaterial({
color: carState.surfaceType === 'grass' ? 0x8B7D5B : 0xccbbaa,
transparent: true, opacity: 0.6
});
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(x + (Math.random()-0.5)*1, y, z + (Math.random()-0.5)*1);
scene.add(mesh);
particles.push({
mesh, life: 1.0, decay: 0.8 + Math.random() * 0.5,
vx: (Math.random()-0.5) * 2, vy: 0.5 + Math.random(), vz: (Math.random()-0.5) * 2,
});
}
function spawnSparks(x, y, z) {
for (let i = 0; i < 8; i++) {
const geo = new THREE.SphereGeometry(0.05, 3, 2);
const mat = new THREE.MeshBasicMaterial({ color: 0xffaa00 });
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(x, y, z);
scene.add(mesh);
particles.push({
mesh, life: 1.0, decay: 2 + Math.random() * 2,
vx: (Math.random()-0.5) * 8, vy: 2 + Math.random()*4, vz: (Math.random()-0.5) * 8,
});
}
}
function updateParticles(dt) {
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.life -= p.decay * dt;
if (p.life <= 0) {
scene.remove(p.mesh);
p.mesh.geometry.dispose();
p.mesh.material.dispose();
particles.splice(i, 1);
continue;
}
p.mesh.position.x += p.vx * dt;
p.mesh.position.y += p.vy * dt;
p.mesh.position.z += p.vz * dt;
p.vy -= 2 * dt; // Gravity
if (p.mesh.material.transparent) {
p.mesh.material.opacity = Math.max(0, p.life * 0.6);
}
const scale = 1 + (1 - p.life) * 2;
p.mesh.scale.set(scale, scale, scale);
}
}
// ==================== CAMERA SYSTEM ====================
let camPos = new THREE.Vector3();
let camTarget = new THREE.Vector3();
function updateCamera(dt) {
const forwardX = Math.sin(carState.heading);
const forwardZ = Math.cos(carState.heading);
if (camMode === 0) {
// Chase camera - behind and above car
const idealOffset = new THREE.Vector3(
-forwardX * 12,
5,
-forwardZ * 12
);
const idealPos = new THREE.Vector3(
carState.x + idealOffset.x,
carState.y + idealOffset.y,
carState.z + idealOffset.z
);
// Smooth camera follow with lag
camPos.lerp(idealPos, Math.min(1, dt * 4));
// Look slightly ahead of the car
const lookAhead = new THREE.Vector3(
carState.x + forwardX * 8,
carState.y + 2,
carState.z + forwardZ * 8
);
camTarget.lerp(lookAhead, Math.min(1, dt * 6));
camera.position.copy(camPos);
camera.lookAt(camTarget);
} else {
// Cinematic camera - higher and wider angle
const idealOffset = new THREE.Vector3(
-forwardX * 25,
18,
-forwardZ * 25
);
const idealPos = new THREE.Vector3(
carState.x + idealOffset.x,
carState.y + idealOffset.y,
carState.z + idealOffset.z
);
camPos.lerp(idealPos, Math.min(1, dt * 2));
const lookAhead = new THREE.Vector3(
carState.x + forwardX * 5,
carState.y + 1,
carState.z + forwardZ * 5
);
camTarget.lerp(lookAhead, Math.min(1, dt * 4));
camera.position.copy(camPos);
camera.lookAt(camTarget);
}
// Update shadow camera to follow car
const sunLight = scene.children.find(c => c.isDirectionalLight);
if (sunLight) {
sunLight.target.position.set(carState.x, carState.y, carState.z);
sunLight.position.set(carState.x + 50, 80, carState.z + 30);
}
}
function toggleCamera() {
camMode = (camMode + 1) % 2;
}
// ==================== HUD UPDATE ====================
function updateHUD() {
const speedKmh = Math.abs(Math.round(carState.speed * 3.6));
document.getElementById('speed-val').textContent = speedKmh;
// Gear display
const gearText = carState.gear === -1 ? 'R' : (carState.gear === 0 ? 'N' : String(carState.gear));
document.getElementById('gear-display').textContent = gearText;
// RPM bar
document.getElementById('rpm-fill').style.width = (carState.rpm / 8000 * 100) + '%';
// Lap display
document.getElementById('lap-display').textContent = `LAP ${Math.min(state.lap, TOTAL_LAPS)} / ${TOTAL_LAPS}`;
// Timer
if (state.phase === 'racing') {
const elapsed = performance.now() - state.startTime;
let totalTime = state.lapTimes.reduce((a,b) => a+b, 0) + elapsed;
document.getElementById('timer-display').textContent = formatTime(totalTime);
} else if (state.phase === 'finished') {
const total = state.lapTimes.reduce((a,b) => a+b, 0);
document.getElementById('timer-display').textContent = formatTime(total);
}
// Best lap
if (state.bestLap < Infinity) {
document.getElementById('best-lap').textContent = `BEST LAP: ${formatTime(state.bestLap)}`;
}
// Surface indicator
const surfEl = document.getElementById('surface-indicator');
surfEl.textContent = carState.surfaceType.toUpperCase();
surfEl.style.color = carState.surfaceType === 'asphalt' ? '#aaaaff' :
carState.surfaceType === 'gravel' ? '#ffaa44' : '#88cc44';
}
function formatTime(ms) {
const totalSec = ms / 1000;
const min = Math.floor(totalSec / 60);
const sec = (totalSec % 60).toFixed(3);
return `${String(min).padStart(2,'0')}:${sec.padStart(6,'0')}`;
}
// ==================== MINIMAP ====================
function updateMinimap() {
const canvas = document.getElementById('minimap-canvas');
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, 150, 150);
// 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 = (150 - padding*2) / (maxX - minX);
const scaleZ = (150 - padding*2) / (maxZ - minZ);
const scale = Math.min(scaleX, scaleZ);
const offsetX = (150 - (maxX - minX) * scale) / 2;
const offsetZ = (150 - (maxZ - minZ) * scale) / 2;
// Draw track
ctx.strokeStyle = '#666';
ctx.lineWidth = 4;
ctx.beginPath();
for (let i = 0; i < trackPoints.length; i += 5) {
const p = trackPoints[i];
const mx = (p.x - minX) * scale + offsetX;
const mz = (p.z - minZ) * scale + offsetZ;
if (i === 0) ctx.moveTo(mx, mz);
else ctx.lineTo(mx, mz);
}
ctx.closePath();
ctx.stroke();
// Draw checkpoints
for (let i = 0; i < checkpoints.length; i++) {
const p = trackPoints[checkpoints[i]];
const mx = (p.x - minX) * scale + offsetX;
const mz = (p.z - minZ) * scale + offsetZ;
ctx.fillStyle = i <= state.currentCheckpoint ? '#0f0' : '#555';
ctx.beginPath();
ctx.arc(mx, mz, 3, 0, Math.PI*2);
ctx.fill();
}
// Draw car position
const cx = (carState.x - minX) * scale + offsetX;
const cz = (carState.z - minZ) * scale + offsetZ;
ctx.fillStyle = '#ff6600';
ctx.beginPath();
ctx.arc(cx, cz, 4, 0, Math.PI*2);
ctx.fill();
// Draw heading indicator
const forwardX = Math.sin(carState.heading) * 8;
const forwardZ = Math.cos(carState.heading) * 8;
ctx.strokeStyle = '#ff6600';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(cx, cz);
ctx.lineTo(cx + forwardX, cz + forwardZ);
ctx.stroke();
}
// ==================== GAME FLOW ====================
function startCountdown() {
state.phase = 'countdown';
state.countdownValue = 3;
state.currentCheckpoint = 0;
const cdEl = document.getElementById('countdown');
cdEl.style.display = 'block';
cdEl.textContent = '3';
playBeep(440, 0.2);
let count = 3;
const interval = setInterval(() => {
count--;
if (count > 0) {
cdEl.textContent = String(count);
playBeep(440 + (3-count)*100, 0.2);
} else if (count === 0) {
cdEl.textContent = 'GO!';
cdEl.style.color = '#00ff00';
playBeep(880, 0.4);
setTimeout(() => {
cdEl.style.display = 'none';
cdEl.style.color = '#ff6600';
state.phase = 'racing';
state.startTime = performance.now();
}, 500);
} else {
clearInterval(interval);
}
}, 1000);
}
function togglePause() {
if (state.phase === 'racing') {
state.phase = 'paused';
document.getElementById('pause-menu').style.display = 'flex';
} else if (state.phase === 'paused') {
resume();
}
}
function resume() {
state.phase = 'racing';
state.startTime = performance.now() - (state.lapTimes.reduce((a,b) => a+b, 0));
document.getElementById('pause-menu').style.display = 'none';
}
function restart() {
// Reset car position to start of track
const startPos = trackPoints[0];
const nextPos = trackPoints[1];
carState.x = startPos.x;
carState.y = startPos.y;
carState.z = startPos.z;
carState.heading = Math.atan2(nextPos.x - startPos.x, nextPos.z - startPos.z);
carState.speed = 0;
carState.lateralSpeed = 0;
carState.angularVel = 0;
// Reset state
state.lap = 1;
state.currentCheckpoint = 0;
state.lapTimes = [];
state.bestLap = Infinity;
document.getElementById('pause-menu').style.display = 'none';
document.getElementById('finish-screen').style.display = 'none';
document.getElementById('best-lap').textContent = 'BEST LAP: --:--.---';
// Clear particles
for (const p of particles) {
scene.remove(p.mesh);
p.mesh.geometry.dispose();
p.mesh.material.dispose();
}
particles = [];
startCountdown();
}
// ==================== MAIN GAME LOOP ====================
function gameLoop() {
requestAnimationFrame(gameLoop);
const dt = Math.min(clock.getDelta(), 0.05); // Cap delta time
if (state.phase === 'racing') {
updateCarPhysics(dt);
// Update car visual
carGroup.position.set(carState.x, carState.y, carState.z);
carGroup.rotation.y = -carState.heading;
// Wheel rotation animation
const wheelRotSpeed = carState.speed * dt * 3;
wheels.forEach(w => { w.rotation.x += wheelRotSpeed; });
// Front wheel steering visual
if (wheels.length >= 2) {
const steerAngle = input().left ? 0.3 : (input().right ? -0.3 : 0);
wheels[0].rotation.y = steerAngle;
wheels[1].rotation.y = steerAngle;
}
// Car body tilt during turns
if (carBody) {
carBody.rotation.z = -carState.angularVel * 0.15;
}
} else if (state.phase === 'countdown') {
// Position car at start
const startPos = trackPoints[0];
const nextPos = trackPoints[1];
carState.x = startPos.x;
carState.y = startPos.y;
carState.z = startPos.z;
carState.heading = Math.atan2(nextPos.x - startPos.x, nextPos.z - startPos.z);
carGroup.position.set(carState.x, carState.y, carState.z);
carGroup.rotation.y = -carState.heading;
}
updateCamera(dt);
updateParticles(dt);
updateHUD();
// Update minimap every few frames
if (Math.floor(performance.now() / 100) % 2 === 0) {
updateMinimap();
}
renderer.render(scene, camera);
}
// ==================== INITIALIZATION ====================
function init() {
// Setup input listeners
window.addEventListener('keydown', onKey);
window.addEventListener('keyup', onKey);
setupTouch();
// Generate track
generateTrack();
// Initialize Three.js scene
initScene();
// Build world
buildGround();
buildTrackMesh();
buildScenery();
buildCar();
// Position car at start
const startPos = trackPoints[0];
const nextPos = trackPoints[1];
carState.x = startPos.x;
carState.y = startPos.y;
carState.z = startPos.z - 5; // Start slightly behind the line
carState.heading = Math.atan2(nextPos.x - startPos.x, nextPos.z - startPos.z);
camPos.set(carState.x - Math.sin(carState.heading)*12, carState.y + 5, carState.z - Math.cos(carState.heading)*12);
camTarget.set(carState.x, carState.y + 2, carState.z);
// Handle resize
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// Start countdown on first interaction (for audio context)
const startHandler = () => {
initAudio();
if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
document.removeEventListener('click', startHandler);
document.removeEventListener('keydown', startHandler);
startCountdown();
};
document.addEventListener('click', startHandler);
document.addEventListener('keydown', startHandler);
// Start game loop
gameLoop();
}
// Public API
return { init, resume, restart };
})();
// Start the game when page loads
window.addEventListener('DOMContentLoaded', RallyGame.init);
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen3.6-27b-mtp","provider":"lmstudio","persona":"full","sessionId":"0d15a114-605d-4f22-a806-6d88e2329502","tokensIn":953970,"tokensOut":26090,"tokensTotal":980060,"turns":25,"toolCalls":24,"failedToolCalls":0,"timestamp":"2026-07-29T15:41:47.012Z"} -->