Rally
longcat 2.0 · composer
◈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 47,901 bytes · SHA-256 40f54c7b808f
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Rally Racer 3D</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<style>
/* ── css: gameUI ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
body { overflow: hidden; background: #000; font-family: 'Segoe UI', Arial, sans-serif; user-select: none; }
#gameCanvas { display: block; width: 100vw; height: 100vh; }
#app { position: relative; width: 100vw; height: 100vh; }
#hud { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; }
#speedo { position: absolute; bottom: 20px; right: 20px; color: #fff; font-size: 42px; font-weight: bold; text-align: right; text-shadow: 2px 2px 4px rgba(0,0,0,0.8); }
#speedo small { font-size: 16px; display: block; }
#gear { position: absolute; bottom: 90px; right: 20px; color: #ffcc00; font-size: 28px; font-weight: bold; text-shadow: 2px 2px 4px rgba(0,0,0,0.8); }
#rpm { position: absolute; bottom: 15px; right: 20px; width: 180px; height: 8px; background: rgba(0,0,0,0.5); border-radius: 4px; }
#rpmBar { height: 100%; width: 0%; background: linear-gradient(90deg, #0f0, #ff0, #f00); border-radius: 4px; transition: width 0.1s; }
#lapInfo, #timeInfo, #bestInfo, #lastInfo { position: absolute; color: #fff; font-size: 18px; text-shadow: 1px 1px 3px rgba(0,0,0,0.9); }
#lapInfo { top: 15px; left: 15px; font-size: 22px; }
#timeInfo { top: 45px; left: 15px; }
#bestInfo { top: 72px; left: 15px; color: #aaf; }
#lastInfo { top: 99px; left: 15px; color: #8f8; }
#checkpointInfo { position: absolute; top: 130px; left: 15px; color: #ff0; font-size: 14px; }
#countdown { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 120px; font-weight: bold; color: #fff; text-shadow: 0 0 30px rgba(255,100,0,0.8); z-index: 20; display: none; transition: all 0.3s; }
#minimap { position: absolute; top: 15px; right: 15px; border: 2px solid rgba(255,255,255,0.5); border-radius: 8px; overflow: hidden; z-index: 10; }
#minimapCanvas { display: block; background: rgba(0,0,0,0.5); }
.overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.75); display: flex; align-items: center; justify-content: center; z-index: 100; }
.overlay.display-none { display: none; }
.menuBox { background: linear-gradient(135deg, #1a1a2e, #16213e); border: 2px solid #e94560; border-radius: 16px; padding: 40px; text-align: center; color: #fff; max-width: 500px; }
.menuBox h1 { font-size: 48px; color: #e94560; margin-bottom: 15px; }
.menuBox h2 { font-size: 36px; margin-bottom: 20px; }
.menuBox p { margin: 8px 0; font-size: 16px; color: #ccc; }
.menuBox button { margin: 10px 8px; padding: 12px 32px; font-size: 18px; background: #e94560; color: #fff; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; transition: transform 0.1s, background 0.2s; }
.menuBox button:hover { transform: scale(1.05); background: #ff6b6b; }
.controlsInfo { margin: 20px 0; padding: 15px; background: rgba(255,255,255,0.1); border-radius: 8px; }
#menu { display: flex; }
#pauseMenu, #finishMenu { display: none; }
#touchControls { position: absolute; bottom: 20px; left: 0; width: 100%; pointer-events: none; z-index: 15; display: none; }
#dpad { position: absolute; bottom: 20px; left: 20px; pointer-events: auto; }
#actionBtns { position: absolute; bottom: 20px; right: 20px; pointer-events: auto; }
.tbtn { width: 55px; height: 55px; background: rgba(255,255,255,0.3); border: 2px solid rgba(255,255,255,0.5); border-radius: 12px; color: #fff; font-size: 20px; margin: 3px; }
.tbtn:active { background: rgba(255,255,255,0.6); }
.abtn { width: 65px; height: 65px; font-size: 16px; }
@media (max-width: 768px) {
#touchControls { display: block; }
#minimap { top: auto; bottom: 120px; right: 10px; }
#minimapCanvas { width: 120px; height: 120px; }
}
</style>
</head>
<body>
<div id="app"><canvas id="gameCanvas"></canvas>
<div id="hud">
<div id="speedo"><span id="speedVal">0</span><small>km/h</small></div>
<div id="gear"><span id="gearVal">N</span></div>
<div id="rpm"><div id="rpmBar"></div></div>
<div id="lapInfo">LAP <span id="lapVal">1</span>/3</div>
<div id="timeInfo">TIME <span id="timeVal">0:00.0</span></div>
<div id="bestInfo">BEST <span id="bestVal">--:--.-</span></div>
<div id="lastInfo">LAST <span id="lastVal">--:--.-</span></div>
<div id="checkpointInfo"></div>
</div>
<div id="countdown"></div>
<div id="minimap"><canvas id="minimapCanvas" width="200" height="200"></canvas></div>
<div id="menu" class="overlay"><div class="menuBox"><h1>RALLY RACER</h1><p>Complete 3 laps as fast as possible!</p><div class="controlsInfo"><p>WASD / Arrow Keys - Drive</p><p>Space - Handbrake</p><p>R - Restart | C - Camera | P - Pause</p></div><button id="startBtn">START RACE</button></div></div>
<div id="pauseMenu" class="overlay"><div class="menuBox"><h2>PAUSED</h2><button id="resumeBtn">RESUME</button><button id="restartBtn">RESTART</button></div></div>
<div id="finishMenu" class="overlay"><div class="menuBox"><h1>FINISHED!</h1><p>Final Time: <span id="finalTime">--:--.-</span></p><p>Best Lap: <span id="finalBest">--:--.-</span></p><button id="finishRestartBtn">RACE AGAIN</button></div></div>
<div id="touchControls">
<div id="dpad">
<button class="tbtn" id="tUp">▲</button>
<button class="tbtn" id="tLeft">◄</button>
<button class="tbtn" id="tRight">►</button>
<button class="tbtn" id="tDown">▼</button>
</div>
<div id="actionBtns">
<button class="tbtn abtn" id="tGas">GAS</button>
<button class="tbtn abtn" id="tBrake">BRK</button>
<button class="tbtn abtn" id="tHB">HB</button>
</div>
</div>
</div>
<script>
'use strict';
// ── const: CONFIG ──
// Game configuration constants
const CONFIG = {
MAX_SPEED: 180,
ACCELERATION: 45,
BRAKE_FORCE: 60,
REVERSE_SPEED: 30,
FRICTION: 0.98,
TURN_SPEED: 2.8,
HANDBRAKE_FACTOR: 0.92,
GRAVITY: 20,
TRACK_HALF_WIDTH: 12,
TOTAL_LAPS: 3,
CHECKPOINT_COUNT: 20,
CAR_LENGTH: 4,
CAR_WIDTH: 2,
CAR_HEIGHT: 1.2,
};
// ── const: TRACK_POINTS ──
// Track curve control points - a winding loop
const TRACK_POINTS = [
{x:0,y:0,z:0}, {x:60,y:2,z:40}, {x:120,y:5,z:80}, {x:160,y:3,z:140},
{x:180,y:8,z:210}, {x:160,y:6,z:280}, {x:120,y:4,z:330}, {x:60,y:7,z:360},
{x:0,y:5,z:370}, {x:-60,y:3,z:350}, {x:-120,y:6,z:310}, {x:-160,y:8,z:260},
{x:-180,y:5,z:190}, {x:-170,y:4,z:120}, {x:-140,y:6,z:60}, {x:-80,y:3,z:20},
];
// ── function: catmullRom ──
// Catmull-Rom spline interpolation for smooth track
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)
};
}
// ── function: generateTrackPath ──
// Generate smooth track path from control points
function generateTrackPath(points, segmentsPerCurve) {
const path = [];
const n = points.length;
for (let i = 0; i < n; i++) {
const p0 = points[(i-1+n)%n];
const p1 = points[i];
const p2 = points[(i+1)%n];
const p3 = points[(i+2)%n];
for (let j = 0; j < segmentsPerCurve; j++) {
path.push(catmullRom(p0, p1, p2, p3, j/segmentsPerCurve));
}
}
return path;
}
// ── function: getTrackPointAtDistance ──
// Get track point at distance along path
function getTrackPointAtDistance(path, dist) {
const totalLen = path.length;
const idx = ((dist % totalLen) + totalLen) % totalLen;
const i = Math.floor(idx);
const t = idx - i;
const a = path[i % totalLen];
const b = path[(i+1) % totalLen];
return {
x: a.x + (b.x - a.x) * t,
y: a.y + (b.y - a.y) * t,
z: a.z + (b.z - a.z) * t
};
}
// ── function: getTrackTangent ──
// Get track tangent at distance
function getTrackTangent(path, dist) {
const totalLen = path.length;
const idx = ((dist % totalLen) + totalLen) % totalLen;
const i = Math.floor(idx);
const a = path[i % totalLen];
const b = path[(i+1) % totalLen];
const dx = b.x - a.x, dz = b.z - a.z;
const len = Math.sqrt(dx*dx + dz*dz) || 1;
return { x: dx/len, z: dz/len };
}
// ── function: findClosestTrackDist ──
// Find closest point on track path to a given position
function findClosestTrackDist(path, pos) {
let minDist = Infinity, bestIdx = 0;
for (let i = 0; i < path.length; i++) {
const dx = path[i].x - pos.x, dz = path[i].z - pos.z;
const d = dx*dx + dz*dz;
if (d < minDist) { minDist = d; bestIdx = i; }
}
return bestIdx;
}
// ── class: AudioEngine ──
// Procedural Audio Engine using Web Audio API
class AudioEngine {
constructor() {
this.ctx = null;
this.engineOsc = null;
this.engineGain = null;
this.masterGain = null;
this.initialized = false;
}
init() {
if (this.initialized) return;
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
this.masterGain = this.ctx.createGain();
this.masterGain.gain.value = 0.4;
this.masterGain.connect(this.ctx.destination);
// Engine sound - sawtooth with filter
this.engineOsc = this.ctx.createOscillator();
this.engineOsc.type = 'sawtooth';
this.engineOsc.frequency.value = 80;
this.engineFilter = this.ctx.createBiquadFilter();
this.engineFilter.type = 'lowpass';
this.engineFilter.frequency.value = 400;
this.engineGain = this.ctx.createGain();
this.engineGain.gain.value = 0.0;
this.engineOsc.connect(this.engineFilter);
this.engineFilter.connect(this.engineGain);
this.engineGain.connect(this.masterGain);
this.engineOsc.start();
this.initialized = true;
}
updateEngine(speed, maxSpeed) {
if (!this.initialized) return;
const ratio = Math.abs(speed) / maxSpeed;
this.engineOsc.frequency.value = 60 + ratio * 250;
this.engineFilter.frequency.value = 300 + ratio * 1500;
this.engineGain.gain.value = 0.05 + ratio * 0.25;
}
beep(freq, duration, vol) {
if (!this.initialized) return;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
gain.gain.value = vol || 0.3;
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + (duration || 0.2));
osc.connect(gain);
gain.connect(this.masterGain);
osc.start();
osc.stop(this.ctx.currentTime + (duration || 0.2));
}
countdown() { this.beep(440, 0.15, 0.4); }
go() { this.beep(880, 0.3, 0.5); }
crash() {
if (!this.initialized) return;
const bufferSize = this.ctx.sampleRate * 0.15;
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) data[i] = (Math.random()*2-1) * Math.exp(-i/bufferSize*5);
const src = this.ctx.createBufferSource();
src.buffer = buffer;
const gain = this.ctx.createGain();
gain.gain.value = 0.4;
src.connect(gain);
gain.connect(this.masterGain);
src.start();
}
skidOn() {
if (!this.initialized || this.skidGain) return;
const bufferSize = this.ctx.sampleRate * 2;
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) data[i] = (Math.random()*2-1);
this.skidSrc = this.ctx.createBufferSource();
this.skidSrc.buffer = buffer;
this.skidSrc.loop = true;
this.skidFilter = this.ctx.createBiquadFilter();
this.skidFilter.type = 'bandpass';
this.skidFilter.frequency.value = 800;
this.skidGain = this.ctx.createGain();
this.skidGain.gain.value = 0.1;
this.skidSrc.connect(this.skidFilter);
this.skidFilter.connect(this.skidGain);
this.skidGain.connect(this.masterGain);
this.skidSrc.start();
}
skidOff() {
if (!this.skidGain) return;
this.skidGain.gain.value = 0;
try { this.skidSrc.stop(); } catch(e) {}
this.skidGain = null;
this.skidSrc = null;
}
}
// ── class: InputHandler ──
// Input handler - keyboard and touch
class InputHandler {
constructor() {
this.keys = {};
this.touch = { up:false, down:false, left:false, right:false, gas:false, brake:false, handbrake:false };
this._setupKeyboard();
this._setupTouch();
}
_setupKeyboard() {
const self = this;
window.addEventListener('keydown', function(e) {
self.keys[e.code] = true;
if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space'].includes(e.code)) e.preventDefault();
});
window.addEventListener('keyup', function(e) { self.keys[e.code] = false; });
}
_setupTouch() {
const bind = function(id, prop) {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener('touchstart', function(e) { e.preventDefault(); this.touch[prop] = true; });
el.addEventListener('touchend', function(e) { e.preventDefault(); this.touch[prop] = false; });
el.addEventListener('mousedown', function(e) { e.preventDefault(); this.touch[prop] = true; });
el.addEventListener('mouseup', function(e) { e.preventDefault(); this.touch[prop] = false; });
};
bind('tUp','up'); bind('tDown','down'); bind('tLeft','left'); bind('tRight','right');
bind('tGas','gas'); bind('tBrake','brake'); bind('tHB','handbrake');
}
get forward() { return this.keys['KeyW'] || this.keys['ArrowUp'] || this.touch.gas; }
get backward() { return this.keys['KeyS'] || this.keys['ArrowDown'] || this.touch.brake; }
get left() { return this.keys['KeyA'] || this.keys['ArrowLeft'] || this.touch.left; }
get right() { return this.keys['KeyD'] || this.keys['ArrowRight'] || this.touch.right; }
get handbrake() { return this.keys['Space'] || this.touch.handbrake; }
get accelerate() { return this.keys['KeyW'] || this.keys['ArrowUp'] || this.touch.up; }
get brake() { return this.keys['KeyS'] || this.keys['ArrowDown'] || this.touch.down; }
}
// ── class: Car ──
// Car physics and mesh creation
class Car {
constructor(scene) {
this.scene = scene;
this.group = new THREE.Group();
this.pos = new THREE.Vector3(0, 0.5, 0);
this.vel = new THREE.Vector3(0, 0, 0);
this.speed = 0;
this.angle = 0;
this.angularVel = 0;
this.steering = 0;
this.onGrass = false;
this.onMud = false;
this.drifting = false;
this.rpm = 0;
this.gear = 0;
this._buildCar();
scene.add(this.group);
}
_buildCar() {
const bodyGeom = new THREE.BoxGeometry(2, 0.8, 4.2);
const bodyMat = new THREE.MeshPhongMaterial({ color: 0xe63946, shininess: 80 });
const body = new THREE.Mesh(bodyGeom, bodyMat);
body.position.y = 0.6;
body.castShadow = true;
this.group.add(body);
const cabinGeom = new THREE.BoxGeometry(1.7, 0.7, 2.2);
const cabinMat = new THREE.MeshPhongMaterial({ color: 0x1d3557, shininess: 60 });
const cabin = new THREE.Mesh(cabinGeom, cabinMat);
cabin.position.set(0, 1.35, -0.2);
cabin.castShadow = true;
this.group.add(cabin);
const hoodGeom = new THREE.BoxGeometry(1.8, 0.3, 1.4);
const hood = new THREE.Mesh(hoodGeom, bodyMat);
hood.position.set(0, 1.05, 1.3);
this.group.add(hood);
const spoilerGeom = new THREE.BoxGeometry(2, 0.1, 0.4);
const spoilerMat = new THREE.MeshPhongMaterial({ color: 0x333333 });
const spoiler = new THREE.Mesh(spoilerGeom, spoilerMat);
spoiler.position.set(0, 1.5, -2);
this.group.add(spoiler);
const supGeom = new THREE.BoxGeometry(0.1, 0.4, 0.1);
const sup1 = new THREE.Mesh(supGeom, spoilerMat);
sup1.position.set(-0.7, 1.3, -2);
this.group.add(sup1);
const sup2 = new THREE.Mesh(supGeom, spoilerMat);
sup2.position.set(0.7, 1.3, -2);
this.group.add(sup2);
const hlGeom = new THREE.SphereGeometry(0.2, 8, 8);
const hlMat = new THREE.MeshPhongMaterial({ color: 0xffffcc, emissive: 0xffff88, emissiveIntensity: 0.8 });
const hl1 = new THREE.Mesh(hlGeom, hlMat);
hl1.position.set(-0.6, 0.7, 2.1);
this.group.add(hl1);
const hl2 = new THREE.Mesh(hlGeom, hlMat);
hl2.position.set(0.6, 0.7, 2.1);
this.group.add(hl2);
const tlMat = new THREE.MeshPhongMaterial({ color: 0xff0000, emissive: 0xff0000, emissiveIntensity: 0.5 });
const tl1 = new THREE.Mesh(hlGeom, tlMat);
tl1.position.set(-0.7, 0.7, -2.1);
this.group.add(tl1);
const tl2 = new THREE.Mesh(hlGeom, tlMat);
tl2.position.set(0.7, 0.7, -2.1);
this.group.add(tl2);
this.wheels = [];
const wheelGeom = new THREE.CylinderGeometry(0.4, 0.4, 0.3, 12);
const wheelMat = new THREE.MeshPhongMaterial({ color: 0x222222 });
const wheelPositions = [[-1.1,0.4,1.3],[1.1,0.4,1.3],[-1.1,0.4,-1.3],[1.1,0.4,-1.3]];
for (const [x,y,z] of wheelPositions) {
const wheel = new THREE.Mesh(wheelGeom, wheelMat);
wheel.rotation.z = Math.PI/2;
wheel.position.set(x, y, z);
wheel.castShadow = true;
this.group.add(wheel);
this.wheels.push(wheel);
}
}
getWorldPos() { return this.pos.clone(); }
}
// ── class: ParticleSystem ──
// Particle system for dust, skid marks, sparks
class ParticleSystem {
constructor(scene) {
this.scene = scene;
this.particles = [];
this.skidMarks = [];
this.maxParticles = 300;
this.maxSkidMarks = 200;
// Dust cloud sprite texture
const canvas = document.createElement('canvas');
canvas.width = 32; canvas.height = 32;
const ctx = canvas.getContext('2d');
const grad = ctx.createRadialGradient(16,16,0,16,16,16);
grad.addColorStop(0, 'rgba(200,180,140,0.8)');
grad.addColorStop(1, 'rgba(200,180,140,0)');
ctx.fillStyle = grad;
ctx.fillRect(0,0,32,32);
this.dustTexture = new THREE.CanvasTexture(canvas);
// Spark texture
const sc = document.createElement('canvas');
sc.width = 16; sc.height = 16;
const sctx = sc.getContext('2d');
const sgrad = sctx.createRadialGradient(8,8,0,8,8,8);
sgrad.addColorStop(0, 'rgba(255,255,100,1)');
sgrad.addColorStop(1, 'rgba(255,100,0,0)');
sctx.fillStyle = sgrad;
sctx.fillRect(0,0,16,16);
this.sparkTexture = new THREE.CanvasTexture(sc);
// Skid mark material
const skidCanvas = document.createElement('canvas');
skidCanvas.width = 8; skidCanvas.height = 8;
const skctx = skidCanvas.getContext('2d');
skctx.fillStyle = 'rgba(30,30,30,0.6)';
skctx.beginPath();
skctx.arc(4,4,3,0,Math.PI*2);
skctx.fill();
this.skidTexture = new THREE.CanvasTexture(skidCanvas);
}
emitDust(x, y, z, count) {
for (let i = 0; i < count && this.particles.length < this.maxParticles; i++) {
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
map: this.dustTexture, transparent: true, opacity: 0.6,
depthWrite: false, blending: THREE.NormalBlending
}));
sprite.position.set(x, y, z);
const s = 0.3 + Math.random() * 0.5;
sprite.scale.set(s, s, s);
this.scene.add(sprite);
this.particles.push({
sprite, life: 0.8 + Math.random()*0.4, maxLife: 1.2,
vx: (Math.random()-0.5)*2, vy: 1+Math.random()*2, vz: (Math.random()-0.5)*2,
type: 'dust'
});
}
}
emitSparks(x, y, z, count) {
for (let i = 0; i < count && this.particles.length < this.maxParticles; i++) {
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
map: this.sparkTexture, transparent: true, opacity: 1,
depthWrite: false, blending: THREE.AdditiveBlending
}));
sprite.position.set(x, y, z);
const s = 0.1 + Math.random() * 0.2;
sprite.scale.set(s, s, s);
this.scene.add(sprite);
this.particles.push({
sprite, life: 0.3 + Math.random()*0.3, maxLife: 0.6,
vx: (Math.random()-0.5)*8, vy: 2+Math.random()*4, vz: (Math.random()-0.5)*8,
type: 'spark'
});
}
}
addSkidMark(x, y, z, angle) {
if (this.skidMarks.length >= this.maxSkidMarks) {
const old = this.skidMarks.shift();
this.scene.remove(old);
old.material.dispose();
}
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
map: this.skidTexture, transparent: true, opacity: 0.4, depthWrite: false
}));
sprite.position.set(x, y + 0.02, z);
sprite.scale.set(0.5, 0.5, 0.5);
this.scene.add(sprite);
this.skidMarks.push(sprite);
}
update(dt) {
for (let i = this.particles.length - 1; i >= 0; i--) {
const p = this.particles[i];
p.life -= dt;
if (p.life <= 0) {
this.scene.remove(p.sprite);
p.sprite.material.dispose();
this.particles.splice(i, 1);
continue;
}
p.sprite.position.x += p.vx * dt;
p.sprite.position.y += p.vy * dt;
p.sprite.position.z += p.vz * dt;
if (p.type === 'dust') {
p.vy -= 2 * dt;
p.sprite.material.opacity = (p.life / p.maxLife) * 0.5;
} else {
p.vy -= 12 * dt;
p.sprite.material.opacity = p.life / p.maxLife;
}
}
}
}
// ── class: SceneBuilder ──
// Scene builder - creates 3D environment
class SceneBuilder {
constructor(scene, trackPath) {
this.scene = scene;
this.trackPath = trackPath;
this._addLights();
this._addSky();
this._addGround();
this._buildTrack();
this._addStartLine();
}
_addLights() {
const ambient = new THREE.AmbientLight(0x6688aa, 0.6);
this.scene.add(ambient);
const sun = new THREE.DirectionalLight(0xffffee, 1.0);
sun.position.set(80, 100, 50);
sun.castShadow = true;
sun.shadow.mapSize.width = 2048;
sun.shadow.mapSize.height = 2048;
sun.shadow.camera.near = 0.5;
sun.shadow.camera.far = 400;
sun.shadow.camera.left = -150;
sun.shadow.camera.right = 150;
sun.shadow.camera.top = 150;
sun.shadow.camera.bottom = -150;
this.scene.add(sun);
this.sun = sun;
const hemi = new THREE.HemisphereLight(0x87ceeb, 0x3d5c3d, 0.4);
this.scene.add(hemi);
}
_addSky() {
const skyGeom = new THREE.SphereGeometry(500, 16, 16);
const skyMat = new THREE.MeshBasicMaterial({ color: 0x87ceeb, side: THREE.BackSide });
const sky = new THREE.Mesh(skyGeom, skyMat);
this.scene.add(sky);
this.scene.fog = new THREE.Fog(0x87ceeb, 100, 450);
}
_addGround() {
const groundGeom = new THREE.PlaneGeometry(800, 800, 40, 40);
const groundMat = new THREE.MeshPhongMaterial({ color: 0x4a7c3f, flatShading: true });
const pos = groundGeom.attributes.position;
for (let i = 0; i < pos.count; i++) {
pos.setZ(i, (Math.random()-0.5) * 0.8);
}
groundGeom.computeVertexNormals();
const ground = new THREE.Mesh(groundGeom, groundMat);
ground.rotation.x = -Math.PI/2;
ground.receiveShadow = true;
this.scene.add(ground);
}
_buildTrack() {
const points = this.trackPath;
const width = CONFIG.TRACK_HALF_WIDTH;
const vertices = [];
const uvs = [];
const indices = [];
for (let i = 0; i < points.length; i++) {
const curr = points[i];
const next = points[(i+1) % points.length];
const dx = next.x - curr.x, dz = next.z - curr.z;
const len = Math.sqrt(dx*dx + dz*dz) || 1;
const nx = -dz/len, nz = dx/len;
vertices.push(curr.x + nx*width, curr.y, curr.z + nz*width);
vertices.push(curr.x - nx*width, curr.y, curr.z - nz*width);
uvs.push(0, i*0.5, 1, i*0.5);
}
for (let i = 0; i < points.length; i++) {
const i0 = i*2, i1 = i*2+1, i2 = ((i+1)%points.length)*2, i3 = ((i+1)%points.length)*2+1;
indices.push(i0, i2, i1, i1, i2, i3);
}
const trackGeom = new THREE.BufferGeometry();
trackGeom.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
trackGeom.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
trackGeom.setIndex(indices);
trackGeom.computeVertexNormals();
const trackMat = new THREE.MeshPhongMaterial({ color: 0x555555, flatShading: true });
const track = new THREE.Mesh(trackGeom, trackMat);
track.receiveShadow = true;
this.scene.add(track);
this.trackMesh = track;
this._addShoulders(points, width);
}
_addShoulders(points, width) {
const sw = 3;
const vertices = [];
const indices = [];
for (let i = 0; i < points.length; i++) {
const curr = points[i];
const next = points[(i+1) % points.length];
const dx = next.x - curr.x, dz = next.z - curr.z;
const len = Math.sqrt(dx*dx + dz*dz) || 1;
const nx = -dz/len, nz = dx/len;
vertices.push(curr.x + nx*(width+sw), curr.y+0.01, curr.z + nz*(width+sw));
vertices.push(curr.x + nx*width, curr.y+0.01, curr.z + nz*width);
vertices.push(curr.x - nx*width, curr.y+0.01, curr.z - nz*width);
vertices.push(curr.x - nx*(width+sw), curr.y+0.01, curr.z - nz*(width+sw));
}
for (let i = 0; i < points.length; i++) {
const b = i*4;
const nb = ((i+1)%points.length)*4;
indices.push(b, nb, b+1, b+1, nb, nb+1);
indices.push(b+2, nb+2, b+3, b+3, nb+2, nb+3);
}
const geom = new THREE.BufferGeometry();
geom.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
geom.setIndex(indices);
geom.computeVertexNormals();
const mat = new THREE.MeshPhongMaterial({ color: 0x8B7355, flatShading: true });
const shoulders = new THREE.Mesh(geom, mat);
shoulders.receiveShadow = true;
this.scene.add(shoulders);
}
_addStartLine() {
const startP = this.trackPath[0];
const next = this.trackPath[1];
const dx = next.x - startP.x, dz = next.z - startP.z;
const len = Math.sqrt(dx*dx + dz*dz) || 1;
const lineGeom = new THREE.PlaneGeometry(CONFIG.TRACK_HALF_WIDTH*2, 1);
const lineMat = new THREE.MeshBasicMaterial({ color: 0xffffff });
const line = new THREE.Mesh(lineGeom, lineMat);
line.rotation.x = -Math.PI/2;
line.position.set(startP.x, startP.y + 0.05, startP.z);
line.rotation.z = Math.atan2(dx, dz);
this.scene.add(line);
// Checkpoint arch
const archGeom = new THREE.TorusGeometry(8, 0.4, 8, 20, Math.PI);
const archMat = new THREE.MeshPhongMaterial({ color: 0xffcc00 });
const arch = new THREE.Mesh(archGeom, archMat);
arch.position.set(startP.x, 5, startP.z);
arch.rotation.y = Math.atan2(dx, dz);
this.scene.add(arch);
}
}
// ── class: SceneryBuilder ──
// Scenery builder - trees, rocks, signs etc.
class SceneryBuilder {
constructor(scene, trackPath) {
this.scene = scene;
this.trackPath = trackPath;
this.obstacles = [];
this._addTrees();
this._addRocks();
this._addSigns();
this._addBanners();
}
_addTrees() {
const treeMat = new THREE.MeshPhongMaterial({ color: 0x2d5a27, flatShading: true });
const trunkMat = new THREE.MeshPhongMaterial({ color: 0x5c3a1e });
const treeGeom = new THREE.ConeGeometry(2, 5, 6);
const trunkGeom = new THREE.CylinderGeometry(0.3, 0.4, 2, 6);
const treeInst = new THREE.InstancedMesh(treeGeom, treeMat, 150);
const trunkInst = new THREE.InstancedMesh(trunkGeom, trunkMat, 150);
treeInst.castShadow = true;
trunkInst.castShadow = true;
const dummy = new THREE.Object3D();
let count = 0;
const points = this.trackPath;
const width = CONFIG.TRACK_HALF_WIDTH;
for (let i = 5; i < points.length - 5 && count < 150; i += 2) {
const curr = points[i];
const next = points[(i+1) % points.length];
const dx = next.x - curr.x, dz = next.z - curr.z;
const len = Math.sqrt(dx*dx + dz*dz) || 1;
const nx = -dz/len, nz = dx/len;
for (let side = -1; side <= 1 && count < 150; side += 2) {
for (let d = width + 10; d < width + 50 && count < 150; d += 15 + Math.random()*15) {
const tx = curr.x + nx*d*side + (Math.random()-0.5)*10;
const tz = curr.z + nz*d*side + (Math.random()-0.5)*10;
const s = 0.8 + Math.random()*0.5;
dummy.position.set(tx, 2.5*s, tz);
dummy.scale.set(s, s, s);
dummy.rotation.y = Math.random()*6.28;
dummy.updateMatrix();
treeInst.setMatrixAt(count, dummy.matrix);
dummy.position.y = s;
dummy.updateMatrix();
trunkInst.setMatrixAt(count, dummy.matrix);
this.obstacles.push({x: tx, z: tz, r: 1.2});
count++;
}
}
}
treeInst.count = count; trunkInst.count = count;
treeInst.instanceMatrix.needsUpdate = true;
trunkInst.instanceMatrix.needsUpdate = true;
this.scene.add(treeInst);
this.scene.add(trunkInst);
}
_addRocks() {
const rockMat = new THREE.MeshPhongMaterial({ color: 0x888888, flatShading: true });
const rockGeom = new THREE.DodecahedronGeometry(1.5, 0);
const rockInst = new THREE.InstancedMesh(rockGeom, rockMat, 30);
rockInst.castShadow = true;
const dummy = new THREE.Object3D();
let count = 0;
const points = this.trackPath;
const width = CONFIG.TRACK_HALF_WIDTH;
for (let i = 8; i < points.length - 5 && count < 30; i += 6) {
const curr = points[i];
const next = points[(i+1) % points.length];
const dx = next.x - curr.x, dz = next.z - curr.z;
const len = Math.sqrt(dx*dx + dz*dz) || 1;
const nx = -dz/len, nz = dx/len;
const side = Math.random() > 0.5 ? 1 : -1;
const d = width + 8 + Math.random()*20;
const rx = curr.x + nx*d*side;
const rz = curr.z + nz*d*side;
const rs = 0.5 + Math.random()*1.0;
dummy.position.set(rx, rs*0.4, rz);
dummy.scale.set(rs, rs*0.6, rs);
dummy.rotation.set(Math.random(), Math.random(), Math.random());
dummy.updateMatrix();
rockInst.setMatrixAt(count, dummy.matrix);
this.obstacles.push({x: rx, z: rz, r: rs*1.2});
count++;
}
rockInst.count = count;
rockInst.instanceMatrix.needsUpdate = true;
this.scene.add(rockInst);
}
_addSigns() {
const signMat = new THREE.MeshPhongMaterial({ color: 0xff6600 });
const postMat = new THREE.MeshPhongMaterial({ color: 0x888888 });
const points = this.trackPath;
const width = CONFIG.TRACK_HALF_WIDTH;
for (let i = 12; i < points.length - 5; i += 20) {
const curr = points[i];
const next = points[(i+1) % points.length];
const dx = next.x - curr.x, dz = next.z - curr.z;
const len = Math.sqrt(dx*dx + dz*dz) || 1;
const nx = -dz/len, nz = dx/len;
const side = Math.random() > 0.5 ? 1 : -1;
const postGeom = new THREE.CylinderGeometry(0.15, 0.15, 4, 6);
const post = new THREE.Mesh(postGeom, postMat);
post.position.set(curr.x + nx*(width+3)*side, 2, curr.z + nz*(width+3)*side);
this.scene.add(post);
const boardGeom = new THREE.BoxGeometry(2, 1, 0.1);
const board = new THREE.Mesh(boardGeom, signMat);
board.position.set(curr.x + nx*(width+3)*side, 3.8, curr.z + nz*(width+3)*side);
this.scene.add(board);
}
}
_addBanners() {
const colors = [0xff0066, 0x0066ff, 0x00ff66, 0xffcc00, 0xff3300];
const points = this.trackPath;
const width = CONFIG.TRACK_HALF_WIDTH;
for (let i = 0; i < 5; i++) {
const bannerGeom = new THREE.PlaneGeometry(1.5, 5);
const bannerMat = new THREE.MeshPhongMaterial({ color: colors[i%colors.length], side: THREE.DoubleSide });
const banner = new THREE.Mesh(bannerGeom, bannerMat);
const curr = points[Math.floor(points.length * 0.1) + i*3];
const next = points[Math.floor(points.length * 0.1) + i*3 + 1];
const dx = next.x - curr.x, dz = next.z - curr.z;
const len = Math.sqrt(dx*dx + dz*dz) || 1;
const nx = -dz/len, nz = dx/len;
const side = i < 3 ? 1 : -1;
const d = width + 6 + (i%3)*3;
banner.position.set(curr.x + nx*d*side, 5.5, curr.z + nz*d*side);
banner.lookAt(0, 5.5, 0);
this.scene.add(banner);
}
}
}
// ── class: Game ──
// Main Game class - orchestrates everything
class Game {
constructor() {
this.canvas = document.getElementById('gameCanvas');
this.renderer = new THREE.WebGLRenderer({ canvas: this.canvas, antialias: true });
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
this.renderer.setClearColor(0x87ceeb);
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(65, window.innerWidth/window.innerHeight, 0.1, 600);
this.trackPath = generateTrackPath(TRACK_POINTS, 12);
this.checkpoints = this._generateCheckpoints();
this.sceneBuilder = new SceneBuilder(this.scene, this.trackPath);
this.scenery = new SceneryBuilder(this.scene, this.trackPath);
this.car = new Car(this.scene);
// Place car at start, facing along track
const startPt = this.trackPath[0];
const nextPt = this.trackPath[1];
const dx = nextPt.x - startPt.x, dz = nextPt.z - startPt.z;
this.car.pos.set(startPt.x, startPt.y + 0.5, startPt.z);
this.car.angle = Math.atan2(dx, dz);
this.particles = new ParticleSystem(this.scene);
this.audio = new AudioEngine();
this.input = new InputHandler();
this.state = 'menu';
this.countdownTime = 0;
this.raceTime = 0;
this.lapTimes = [];
this.currentLap = 1;
this.bestLap = Infinity;
this.lastLap = 0;
this.cpIndex = 0;
this.lapValid = true;
this.cameraMode = 0;
this.camAngle = 0;
this.camLookAt = new THREE.Vector3();
this.lastTime = performance.now();
this.ui = {
speedVal: document.getElementById('speedVal'),
gearVal: document.getElementById('gearVal'),
rpmBar: document.getElementById('rpmBar'),
lapVal: document.getElementById('lapVal'),
timeVal: document.getElementById('timeVal'),
bestVal: document.getElementById('bestVal'),
lastVal: document.getElementById('lastVal'),
countdown: document.getElementById('countdown'),
menu: document.getElementById('menu'),
pauseMenu: document.getElementById('pauseMenu'),
finishMenu: document.getElementById('finishMenu'),
finalTime: document.getElementById('finalTime'),
finalBest: document.getElementById('finalBest'),
minimap: document.getElementById('minimapCanvas'),
};
this._setupUI();
window.addEventListener('resize', () => this._handleResize());
gameLoopHandler(this, performance.now());
}
_setupUI() {
document.getElementById('startBtn').onclick = () => this.startRace();
const rb = document.getElementById('restartBtn');
if (rb) rb.onclick = () => this.restart();
document.getElementById('resumeBtn').onclick = () => this.resume();
document.getElementById('finishRestartBtn').onclick = () => this.restart();
}
_generateCheckpoints() {
const cps = [];
const n = this.trackPath.length;
const spacing = Math.floor(n / CONFIG.CHECKPOINT_COUNT);
for (let i = 0; i < CONFIG.CHECKPOINT_COUNT; i++) {
cps.push(this.trackPath[i * spacing]);
}
return cps;
}
startRace() {
this.audio.init();
this.state = 'countdown';
this.countdownTime = 3.99;
this.raceTime = 0;
this.lapTimes = [];
this.currentLap = 1;
this.bestLap = Infinity;
this.cpIndex = 0;
this.ui.menu.style.display = 'none';
this.audio.beep(440, 0.5, 0.3);
}
restart() {
this.state = 'countdown';
this.countdownTime = 3.99;
this.raceTime = 0;
this.lapTimes = [];
this.currentLap = 1;
this.bestLap = Infinity;
this.lastLap = 0;
this.cpIndex = 0;
const startPt = this.trackPath[0];
const nextPt = this.trackPath[1];
const dx = nextPt.x - startPt.x, dz = nextPt.z - startPt.z;
this.car.pos.set(startPt.x, startPt.y + 0.5, startPt.z);
this.car.angle = Math.atan2(dx, dz);
this.car.vel.set(0,0,0);
this.car.speed = 0;
this.car.angularVel = 0;
this.ui.menu.style.display = 'none';
this.ui.pauseMenu.style.display = 'none';
this.ui.finishMenu.style.display = 'none';
this.particles.skidMarks.forEach(s => this.scene.remove(s));
this.particles.skidMarks = [];
this.particles.particles.forEach(p => this.scene.remove(p.sprite));
this.particles.particles = [];
}
pause() {
if (this.state === 'racing') {
this.state = 'paused';
this.ui.pauseMenu.style.display = 'flex';
}
}
resume() {
if (this.state === 'paused') {
this.state = 'racing';
this.ui.pauseMenu.style.display = 'none';
this.lastTime = performance.now();
}
}
finishRace() {
this.state = 'finished';
this.ui.finishMenu.style.display = 'flex';
this.audio.beep(880, 0.5, 0.5);
setTimeout(() => this.audio.beep(1100, 0.5, 0.4), 300);
setTimeout(() => this.audio.beep(1320, 0.8, 0.3), 600);
}
_handleResize() {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
}
_updateCarVisual() {
this.car.group.position.copy(this.car.pos);
this.car.group.rotation.y = this.car.angle;
const wheelRot = this.car.speed * 0.02;
for (const w of this.car.wheels) {
w.rotation.x += wheelRot;
}
}
}
// ── function: gameLoopHandler ──
// Game loop update methods (called from Game class)
function gameLoopHandler(game, time) {
requestAnimationFrame((t) => gameLoopHandler(game, t));
const dt = Math.min((time - game.lastTime) / 1000, 0.05);
game.lastTime = time;
// Global hotkeys
if (game.input.keys['KeyP']) { game.input.keys['KeyP'] = false; game.pause(); }
if (game.input.keys['KeyR']) { game.input.keys['KeyR'] = false; game.restart(); }
if (game.input.keys['KeyC']) { game.input.keys['KeyC'] = false; game.cameraMode = 1 - game.cameraMode; }
if (game.state === 'countdown') updateCountdown(game, dt);
if (game.state === 'racing') updateRace(game, dt);
game._updateCarVisual();
updateCamera(game, dt);
game.particles.update(dt);
updateHUD(game);
updateMinimap(game);
game.renderer.render(game.scene, game.camera);
}
// ── function: updateCountdown ──
function updateCountdown(game, dt) {
game.countdownTime -= dt;
const num = Math.ceil(game.countdownTime);
if (game.countdownTime <= 0) {
game.state = 'racing';
game.ui.countdown.textContent = 'GO!';
game.ui.countdown.style.display = 'block';
game.audio.go();
setTimeout(() => { game.ui.countdown.style.display = 'none'; }, 800);
} else {
game.ui.countdown.style.display = 'block';
game.ui.countdown.textContent = num;
if (num > 0 && Math.abs(game.countdownTime - num) < dt) {
game.audio.countdown();
}
}
}
// ── function: checkSurface ──
function checkSurface(game) {
const pos = game.car.pos;
const trackWidth = CONFIG.TRACK_HALF_WIDTH;
const trackDist = findClosestTrackDist(game.trackPath, pos);
const closest = game.trackPath[trackDist];
const dx = pos.x - closest.x, dz = pos.z - closest.z;
const dist = Math.sqrt(dx*dx + dz*dz);
if (dist < trackWidth) return 'asphalt';
if (dist < trackWidth + 4) return 'gravel';
// Check if in mud zones
const angle = Math.atan2(pos.z, pos.x);
if (Math.sin(angle * 3) > 0.6 && dist < trackWidth + 20) return 'mud';
return 'grass';
}
// ── function: updateRace ──
function updateRace(game, dt) {
game.raceTime += dt;
const car = game.car;
const input = game.input;
// Determine surface
const surface = checkSurface(game);
car.onGrass = (surface === 'grass');
car.onMud = (surface === 'mud');
let maxSpeed = CONFIG.MAX_SPEED;
let grip = 1.0;
if (surface === 'gravel') { maxSpeed *= 0.75; grip = 0.7; }
if (surface === 'grass') { maxSpeed *= 0.5; grip = 0.4; }
if (surface === 'mud') { maxSpeed *= 0.3; grip = 0.25; }
// Acceleration
if (input.accelerate) {
car.speed += CONFIG.ACCELERATION * dt;
} else if (input.brake) {
car.speed -= CONFIG.BRAKE_FORCE * dt;
if (car.speed < -CONFIG.REVERSE_SPEED) car.speed = -CONFIG.REVERSE_SPEED;
} else {
car.speed *= CONFIG.FRICTION;
if (Math.abs(car.speed) < 0.5) car.speed = 0;
}
car.speed = Math.max(-CONFIG.REVERSE_SPEED, Math.min(maxSpeed, car.speed));
// Steering
const speedFactor = Math.min(Math.abs(car.speed) / 30, 1);
const turnRate = CONFIG.TURN_SPEED * speedFactor * (car.speed >= 0 ? 1 : -1);
let targetSteer = 0;
if (input.left) targetSteer = 1;
if (input.right) targetSteer = -1;
car.steering += (targetSteer - car.steering) * 5 * dt;
// Drift/handbrake
const hb = input.handbrake;
if (hb) {
grip *= CONFIG.HANDBRAKE_FACTOR;
car.drifting = Math.abs(car.speed) > 20;
}
car.drifting = car.drifting || (Math.abs(car.speed) > 60 && Math.abs(car.steering) > 0.3);
// Apply angular velocity
car.angularVel = car.steering * turnRate * (hb ? 1.8 : 1.0) * dt;
if (car.drifting) car.angularVel *= 1.5;
car.angle += car.angularVel;
// Move car
const moveX = Math.sin(car.angle) * car.speed * dt;
const moveZ = Math.cos(car.angle) * car.speed * dt;
car.pos.x += moveX;
car.pos.z += moveZ;
// Track height
const trackDist = findClosestTrackDist(game.trackPath, car.pos);
const closest = game.trackPath[trackDist];
car.pos.y = closest.y + 0.5;
// Obstacle collisions
const obstacles = game.scenery.obstacles;
for (const obs of obstacles) {
const odx = car.pos.x - obs.x, odz = car.pos.z - obs.z;
const od = Math.sqrt(odx*odx + odz*odz);
const minD = obs.r + 1.5;
if (od < minD && od > 0.01) {
const nx = odx/od, nz = odz/od;
car.pos.x = obs.x + nx * minD;
car.pos.z = obs.z + nz * minD;
car.speed *= 0.5;
if (Math.abs(car.speed) > 10) game.audio.crash();
game.particles.emitSparks(car.pos.x, 0.5, car.pos.z, 5);
}
}
// Checkpoint detection
const cp = game.checkpoints[game.cpIndex];
const cdx = car.pos.x - cp.x, cdz = car.pos.z - cp.z;
if (Math.sqrt(cdx*cdx + cdz*cdz) < CONFIG.TRACK_HALF_WIDTH) {
game.cpIndex++;
if (game.cpIndex >= game.checkpoints.length) {
game.cpIndex = 0;
const lapTime = game.raceTime - game.lapTimes.reduce((a,b) => a+b, 0);
game.lapTimes.push(lapTime);
game.lastLap = lapTime;
if (lapTime < game.bestLap) game.bestLap = lapTime;
game.currentLap++;
if (game.currentLap > CONFIG.TOTAL_LAPS) {
game.finishRace();
}
}
}
// Skid marks and dust
const isDrifting = car.drifting && Math.abs(car.speed) > 15;
if (isDrifting) {
game.particles.addSkidMark(car.pos.x, car.pos.y - 0.4, car.pos.z, car.angle);
game.particles.addSkidMark(car.pos.x - Math.sin(car.angle)*2, car.pos.y - 0.4, car.pos.z - Math.cos(car.angle)*2, car.angle);
if (Math.random() < 0.3) game.particles.emitDust(car.pos.x, 0.2, car.pos.z, 1);
game.audio.skidOn();
} else if (car.onGrass || car.onMud) {
if (Math.random() < 0.5) game.particles.emitDust(car.pos.x, 0.3, car.pos.z, 2);
game.audio.skidOn();
} else {
game.audio.skidOff();
}
// Update audio engine
game.audio.updateEngine(car.speed, CONFIG.MAX_SPEED);
const absSpeed = Math.abs(car.speed);
car.rpm = (absSpeed / CONFIG.MAX_SPEED) * 8000;
car.gear = Math.min(6, Math.floor(car.rpm / 1400) + 1);
}
// ── function: updateCamera ──
function updateCamera(game, dt) {
const car = game.car;
const offset = game.cameraMode === 0 ? 12 : 20;
const height = game.cameraMode === 0 ? 5 : 10;
const smoothing = game.cameraMode === 0 ? 4 : 2;
const targetX = car.pos.x - Math.sin(car.angle) * offset;
const targetZ = car.pos.z - Math.cos(car.angle) * offset;
const targetY = car.pos.y + height;
game.camera.position.x += (targetX - game.camera.position.x) * smoothing * dt;
game.camera.position.y += (targetY - game.camera.position.y) * smoothing * dt;
game.camera.position.z += (targetZ - game.camera.position.z) * smoothing * dt;
game.camLookAt.x += (car.pos.x - game.camLookAt.x) * 5 * dt;
game.camLookAt.y += (car.pos.y + 1 - game.camLookAt.y) * 5 * dt;
game.camLookAt.z += (car.pos.z - game.camLookAt.z) * 5 * dt;
game.camera.lookAt(game.camLookAt);
}
// ── function: updateHUD ──
function updateHUD(game) {
const car = game.car;
game.ui.speedVal.textContent = Math.abs(Math.round(car.speed));
game.ui.gearVal.textContent = car.speed < -1 ? 'R' : (car.speed < 1 ? 'N' : car.gear);
game.ui.rpmBar.style.width = ((car.rpm / 8000) * 100) + '%';
game.ui.lapVal.textContent = Math.min(game.currentLap, CONFIG.TOTAL_LAPS);
game.ui.timeVal.textContent = formatTime(game.raceTime);
game.ui.bestVal.textContent = game.bestLap < Infinity ? formatTime(game.bestLap) : '--:--.-';
game.ui.lastVal.textContent = game.lastLap > 0 ? formatTime(game.lastLap) : '--:--.-';
}
// ── function: updateMinimap ──
function updateMinimap(game) {
const canvas = game.ui.minimap;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = canvas.width, h = canvas.height;
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.fillRect(0, 0, w, h);
let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
for (const p of game.trackPath) {
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 pad = 15;
const scaleX = (w - pad*2) / (maxX - minX || 1);
const scaleZ = (h - pad*2) / (maxZ - minZ || 1);
const scale = Math.min(scaleX, scaleZ);
const toMap = (x, z) => [(x - minX) * scale + pad, (z - minZ) * scale + pad];
ctx.strokeStyle = '#666';
ctx.lineWidth = 4;
ctx.beginPath();
for (let i = 0; i <= game.trackPath.length; i++) {
const p = game.trackPath[i % game.trackPath.length];
const [mx, mz] = toMap(p.x, p.z);
if (i === 0) ctx.moveTo(mx, mz);
else ctx.lineTo(mx, mz);
}
ctx.stroke();
ctx.fillStyle = '#ff0';
for (let i = 0; i < game.checkpoints.length; i++) {
const cp = game.checkpoints[i];
const [mx, mz] = toMap(cp.x, cp.z);
ctx.beginPath();
ctx.arc(mx, mz, 2, 0, Math.PI*2);
ctx.fill();
}
const [cx, cz] = toMap(game.car.pos.x, game.car.pos.z);
ctx.fillStyle = '#f00';
ctx.beginPath();
ctx.arc(cx, cz, 4, 0, Math.PI*2);
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(cx, cz);
ctx.lineTo(cx + Math.sin(game.car.angle)*7, cz + Math.cos(game.car.angle)*7);
ctx.stroke();
}
// ── function: formatTime ──
function formatTime(seconds) {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
const ms = Math.floor((seconds % 1) * 10);
return m + ':' + (s<10?'0':'') + s + '.' + ms;
}
// ── main ──
// Main entry point - DOM is already parsed since script is at end of body
const game = new Game();
window.gameInstance = game;
// Touch detection for mobile
const isTouchDevice = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
if (isTouchDevice) {
document.getElementById('touchControls').style.display = 'block';
}
// Ensure overlays are hidden at start
document.getElementById('pauseMenu').style.display = 'none';
document.getElementById('finishMenu').style.display = 'none';
</script>
</body>
</html>
<!-- agent-meta {"model":"meituan/longcat-2.0","provider":"openrouter","persona":"composer","sessionId":"5c16d966-b470-4bd3-a49c-2a8529c5e707","tokensIn":4150822,"tokensOut":78742,"tokensTotal":4229564,"turns":77,"toolCalls":78,"failedToolCalls":4,"timestamp":"2026-07-21T04:23:28.510Z"} -->