Rally
thinkingcap qwen3.6 27b · middle

Browser check: Artifact runtime error. 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 64,061 bytes · SHA-256 75509c30562b
<!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; }
canvas { display: block; width: 100%; height: 100%; }
/* HUD Overlay */
#hud { position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; }
#speedometer { position: absolute; bottom: 20px; right: 20px; text-align: center; color: #fff; }
#speed-val { font-size: 48px; font-weight: bold; text-shadow: 0 0 10px rgba(0,0,0,0.8); }
#speed-unit { font-size: 16px; opacity: 0.7; }
#gear-display { font-size: 24px; color: #ffcc00; margin-top: 4px; }
#rpm-bar-container { width: 150px; height: 8px; background: rgba(0,0,0,0.5); border-radius: 4px; margin-top: 4px; overflow: hidden; }
#rpm-bar { height: 100%; width: 0%; background: linear-gradient(to right, #0f0, #ff0, #f00); transition: width 0.05s; }
/* Lap info */
#lap-info { position: absolute; top: 20px; left: 20px; color: #fff; text-shadow: 0 0 8px rgba(0,0,0,0.9); }
#lap-info div { margin-bottom: 4px; font-size: 16px; }
#lap-count { font-size: 28px; font-weight: bold; color: #ffcc00; }
/* Timer */
#timer-display { position: absolute; top: 20px; right: 20px; color: #fff; text-align: right; text-shadow: 0 0 8px rgba(0,0,0,0.9); font-size: 16px; }
#current-time { font-size: 32px; font-weight: bold; }
/* Minimap */
#minimap-container { position: absolute; bottom: 20px; left: 20px; width: 180px; height: 180px; background: rgba(0,0,0,0.6); border-radius: 50%; overflow: hidden; border: 2px solid rgba(255,255,255,0.3); }
#minimap { width: 100%; height: 100%; }
/* Countdown */
#countdown-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; z-index: 100; background: rgba(0,0,0,0.5); }
#countdown-text { font-size: 120px; font-weight: bold; color: #fff; text-shadow: 0 0 30px rgba(255,200,0,0.8); animation: pulse 0.5s ease-in-out infinite alternate; }
@keyframes pulse { from { transform: scale(1); } to { transform: scale(1.1); } }
/* Pause menu */
#pause-menu { position: fixed; top: 0; left: 0; width: 100%; height: 100%; display: none; align-items: center; justify-content: center; z-index: 200; background: rgba(0,0,0,0.7); }
#pause-menu.active { display: flex; }
.pause-box { text-align: center; color: #fff; }
.pause-box h1 { font-size: 48px; margin-bottom: 30px; }
.pause-btn { pointer-events: all; padding: 12px 40px; font-size: 20px; border: none; border-radius: 8px; cursor: pointer; margin: 8px; background: #ffcc00; color: #333; font-weight: bold; }
.pause-btn:hover { background: #ffe066; }
/* Finish screen */
#finish-screen { position: fixed; top: 0; left: 0; width: 100%; height: 100%; display: none; align-items: center; justify-content: center; z-index: 200; background: rgba(0,0,0,0.8); }
#finish-screen.active { display: flex; }
.finish-box { text-align: center; color: #fff; padding: 40px; }
.finish-box h1 { font-size: 56px; color: #ffcc00; margin-bottom: 20px; }
.finish-stat { font-size: 24px; margin: 8px 0; }
/* Touch controls */
#touch-controls { position: fixed; bottom: 10px; left: 0; width: 100%; display: none; z-index: 50; pointer-events: all; }
.touch-row { display: flex; justify-content: space-between; padding: 0 20px; margin-bottom: 8px; }
.touch-btn { width: 70px; height: 70px; border-radius: 50%; background: rgba(255,255,255,0.2); border: 2px solid rgba(255,255,255,0.4); color: #fff; font-size: 28px; display: flex; align-items: center; justify-content: center; user-select: none; -webkit-user-select: none; touch-action: manipulation; }
.touch-btn:active { background: rgba(255,255,255,0.5); }
.touch-btn.gas { width: 90px; height: 90px; background: rgba(0,200,0,0.3); border-color: rgba(0,200,0,0.6); }
.touch-btn.brake { width: 70px; height: 70px; background: rgba(200,0,0,0.3); border-color: rgba(200,0,0,0.6); }
.touch-btn.handbrake { width: 60px; height: 60px; background: rgba(255,165,0,0.3); border-color: rgba(255,165,0,0.6); font-size: 14px; }
/* Surface indicator */
#surface-indicator { position: absolute; bottom: 80px; right: 20px; color: #fff; font-size: 14px; text-shadow: 0 0 5px rgba(0,0,0,0.9); opacity: 0.7; }
/* Controls hint */
#controls-hint { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #fff; text-align: center; font-size: 14px; opacity: 0.6; pointer-events: none; }
</style>
</head>
<body>
<div id="hud">
<div id="lap-info">
<div id="lap-count">LAP 1 / 3</div>
<div id="best-lap">Best Lap: --:--.---</div>
</div>
<div id="timer-display">
<div id="current-time">00:00.000</div>
</div>
<div id="speedometer">
<div id="speed-val">0</div>
<div id="speed-unit">km/h</div>
<div id="gear-display">N</div>
<div id="rpm-bar-container"><div id="rpm-bar"></div></div>
</div>
<div id="surface-indicator">ASPHALT</div>
<canvas id="minimap" width="180" height="180"></canvas>
<div id="controls-hint">Arrow Keys / WASD to drive · Space handbrake · P pause · C camera</div>
</div>
<div id="countdown-overlay"><div id="countdown-text"></div></div>
<div id="pause-menu">
<div class="pause-box">
<h1>PAUSED</h1>
<button class="pause-btn" onclick="RallyGame.resume()">Resume</button><br>
<button class="pause-btn" onclick="RallyGame.restart()">Restart</button>
</div>
</div>
<div id="finish-screen">
<div class="finish-box">
<h1>RACE COMPLETE!</h1>
<div class="finish-stat" id="final-time"></div>
<div class="finish-stat" id="final-best-lap"></div>
<br><button class="pause-btn" onclick="RallyGame.restart()">Race Again</button>
</div>
</div>
<div id="touch-controls">
<div class="touch-row">
<div style="display:flex;gap:8px;">
<div class="touch-btn" data-key="left">◀</div>
<div class="touch-btn" data-key="right">▶</div>
</div>
<div style="display:flex;gap:8px;">
<div class="touch-btn handbrake" data-key="handbrake">HB</div>
<div class="touch-btn brake" data-key="down">▼</div>
<div class="touch-btn gas" data-key="up">▲</div>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
// ============================================================
// RALLY RACER 3D - Complete Single-File Rally Racing Game
// ============================================================
const RallyGame = (function() {
'use strict';
// ==================== CONFIGURATION ====================
const CFG = {
totalLaps: 3,
trackWidth: 14,
trackSegments: 200,
carLength: 4.5,
carWidth: 2.0,
maxSpeed: 85, // m/s (~306 km/h) on asphalt
acceleration: 35, // m/s²
brakingForce: 50,
reverseMaxSpeed: -15,
steeringSpeed: 2.8, // rad/s at low speed
highSpeedSteering: 1.2,
driftFactor: 0.92, // lower = more drift
offRoadSlowdown: 0.4,
mudSlowdown: 0.25,
gravity: 20,
jumpForce: 15,
cameraLag: 0.06, // higher = more lag
fogNear: 80,
fogFar: 350,
treeCount: 400,
rockCount: 80,
signCount: 20,
};
// ==================== GAME STATE ====================
let state = {
phase: 'countdown', // countdown | racing | paused | finished
speed: 0,
heading: 0, // car direction in radians
lateralVel: 0,
position: new THREE.Vector3(),
velocity: new THREE.Vector3(),
angularVel: 0,
gear: 1,
rpm: 0,
lap: 1,
currentLapTime: 0,
totalTime: 0,
bestLap: Infinity,
checkpointsPassed: [],
lastCheckpointIdx: -1,
surfaceType: 'asphalt',
isDrifting: false,
handbrake: false,
countdownValue: 3,
countdownTimer: 0,
};
// ==================== THREE.JS GLOBALS ====================
let scene, camera, renderer, clock;
let carGroup, carBody, wheels = [];
let trackCurve, trackPoints = [], trackNormals = [];
let obstacles = [], sceneryObjects = [];
let particles = { dust: null, sparks: null, skidMarks: [] };
let audioCtx, engineOsc, engineGain;
// ==================== INPUT ====================
const keys = {};
const touchState = {};
// ==================== TRACK CONTROL POINTS ====================
function generateTrackPoints() {
const pts = [];
const numPts = 24;
for (let i = 0; i < numPts; i++) {
const angle = (i / numPts) * Math.PI * 2;
// Varying radius for interesting track shape
const r = 180 + Math.sin(angle * 3) * 60 + Math.cos(angle * 2) * 40 + Math.sin(angle * 5) * 20;
const x = Math.cos(angle) * r;
const z = Math.sin(angle) * r;
// Elevation changes
const y = Math.sin(angle * 2) * 12 + Math.cos(angle * 3) * 8 + Math.sin(angle * 5) * 4;
pts.push(new THREE.Vector3(x, y, z));
}
return pts;
}
// ==================== INITIALIZATION ====================
function init() {
// Scene setup
scene = new THREE.Scene();
scene.background = new THREE.Color(0x87CEEB);
scene.fog = new THREE.Fog(0x9DC4E0, CFG.fogNear, CFG.fogFar);
// Camera
camera = new THREE.PerspectiveCamera(65, window.innerWidth / window.innerHeight, 0.5, 600);
// Renderer
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.body.firstChild);
clock = new THREE.Clock();
// Lighting
setupLighting();
// Ground plane
createGround();
// Track
const controlPts = generateTrackPoints();
trackCurve = new THREE.CatmullRomCurve3(controlPts, true, 'catmullrom', 0.5);
buildTrack(trackCurve);
// Car
createCar();
// Scenery
createScenery();
// Particles
setupParticles();
// Audio
initAudio();
// Input
setupInput();
// Position car at start
resetCarPosition();
// Start countdown
startCountdown();
// Resize handler
window.addEventListener('resize', onResize);
// Game loop
animate();
}
function setupLighting() {
const ambient = new THREE.AmbientLight(0x6688aa, 0.6);
scene.add(ambient);
const hemi = new THREE.HemisphereLight(0x87CEEB, 0x445522, 0.4);
scene.add(hemi);
const sun = new THREE.DirectionalLight(0xffeedd, 1.0);
sun.position.set(100, 150, 80);
sun.castShadow = true;
sun.shadow.mapSize.width = 2048;
sun.shadow.mapSize.height = 2048;
sun.shadow.camera.near = 1;
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;
scene.add(sun);
}
function createGround() {
const groundGeo = new THREE.PlaneGeometry(800, 800, 20, 20);
// Add some vertex displacement for terrain feel
const posAttr = groundGeo.attributes.position;
for (let i = 0; i < posAttr.count; i++) {
const x = posAttr.getX(i);
const y = posAttr.getY(i);
const dist = Math.sqrt(x*x + y*y);
if (dist > 250) {
posAttr.setZ(i, (Math.random() - 0.5) * 3 + Math.sin(x*0.02)*2 + Math.cos(y*0.02)*2);
}
}
groundGeo.computeVertexNormals();
const groundMat = new THREE.MeshLambertMaterial({ color: 0x4a7c3f });
const ground = new THREE.Mesh(groundGeo, groundMat);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -0.5;
ground.receiveShadow = true;
scene.add(ground);
// Sky dome
const skyGeo = new THREE.SphereGeometry(400, 16, 8);
const skyMat = new THREE.MeshBasicMaterial({ color: 0x87CEEB, side: THREE.BackSide });
const sky = new THREE.Mesh(skyGeo, skyMat);
scene.add(sky);
// Sun sphere
const sunGeo = new THREE.SphereGeometry(15, 8, 8);
const sunMat = new THREE.MeshBasicMaterial({ color: 0xffffcc });
const sunMesh = new THREE.Mesh(sunGeo, sunMat);
sunMesh.position.set(200, 200, -100);
scene.add(sunMesh);
// Clouds
for (let i = 0; i < 30; 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(5 + Math.random()*8, 6, 4);
const puffMat = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.7 });
const puff = new THREE.Mesh(puffGeo, puffMat);
puff.position.set(j*6 - numPuffs*3, Math.random()*2, Math.random()*4);
cloudGroup.add(puff);
}
cloudGroup.position.set(
(Math.random()-0.5)*700,
80 + Math.random()*40,
(Math.random()-0.5)*700
);
scene.add(cloudGroup);
}
}
function buildTrack(curve) {
const segs = CFG.trackSegments;
trackPoints = [];
for (let i = 0; i <= segs; i++) {
const t = i / segs;
trackPoints.push(curve.getPointAt(t));
}
// Build road mesh using extruded shape along curve
const roadGeo = new THREE.BufferGeometry();
const vertices = [];
const uvs = [];
const indices = [];
const colors = [];
for (let i = 0; i <= segs; i++) {
const t = i / segs;
const point = curve.getPointAt(t);
const tangent = curve.getTangentAt(t).normalize();
const normal = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();
const left = point.clone().add(normal.clone().multiplyScalar(CFG.trackWidth * 0.5));
const right = point.clone().add(normal.clone().multiplyScalar(-CFG.trackWidth * 0.5));
// Road surface
vertices.push(left.x, point.y + 0.1, left.z);
vertices.push(right.x, point.y + 0.1, right.z);
// Shoulders (wider)
const shoulderW = CFG.trackWidth * 0.7;
const lShoulder = point.clone().add(normal.clone().multiplyScalar(shoulderW));
const rShoulder = point.clone().add(normal.clone().multiplyScalar(-shoulderW));
vertices.push(lShoulder.x, point.y + 0.05, lShoulder.z);
vertices.push(rShoulder.x, point.y + 0.05, rShoulder.z);
uvs.push(0, t * 20);
uvs.push(1, t * 20);
uvs.push(0, t * 20);
uvs.push(1, t * 20);
if (i < segs) {
const base = i * 4;
// Road surface quad
indices.push(base, base+1, base+2);
indices.push(base+1, base+3, base+2);
// Left shoulder
indices.push(base+2, base+1, base+5);
indices.push(base+2, base+5, base+6);
// Right shoulder
indices.push(base+3, base+4, base+7);
indices.push(base+3, base+7, base+1);
}
// Color: road is gray, shoulders are brownish dirt
for (let c = 0; c < 2; c++) colors.push(0.35, 0.35, 0.38);
for (let c = 0; c < 2; c++) colors.push(0.45, 0.35, 0.25);
}
roadGeo.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
roadGeo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
roadGeo.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
roadGeo.setIndex(indices);
roadGeo.computeVertexNormals();
const roadMat = new THREE.MeshLambertMaterial({ vertexColors: true });
const roadMesh = new THREE.Mesh(roadGeo, roadMat);
roadMesh.receiveShadow = true;
scene.add(roadMesh);
// Track edge markers (cones/posts)
for (let i = 0; i < segs; i += 4) {
const t = i / segs;
const point = curve.getPointAt(t);
const tangent = curve.getTangentAt(t).normalize();
const normal = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();
// Left post
createPost(point.clone().add(normal.clone().multiplyScalar(CFG.trackWidth * 0.52)), i % 8 === 0 ? 0xff0000 : 0xffffff);
// Right post
createPost(point.clone().add(normal.clone().multiplyScalar(-CFG.trackWidth * 0.52)), i % 8 === 0 ? 0xff0000 : 0xffffff);
}
// Start/finish line
const startPt = curve.getPointAt(0);
const startTan = curve.getTangentAt(0).normalize();
const startNorm = new THREE.Vector3(-startTan.z, 0, startTan.x).normalize();
const lineGeo = new THREE.PlaneGeometry(CFG.trackWidth * 1.2, 2);
// Checkerboard pattern via canvas texture
const checkerCanvas = document.createElement('canvas');
checkerCanvas.width = 64; checkerCanvas.height = 8;
const ctx = checkerCanvas.getContext('2d');
for (let x = 0; x < 16; x++) {
for (let y = 0; y < 2; y++) {
ctx.fillStyle = (x + y) % 2 === 0 ? '#fff' : '#000';
ctx.fillRect(x * 4, y * 4, 4, 4);
}
}
const checkerTex = new THREE.CanvasTexture(checkerCanvas);
const lineMat = new THREE.MeshBasicMaterial({ map: checkerTex });
const startLine = new THREE.Mesh(lineGeo, lineMat);
startLine.position.copy(startPt).add(new THREE.Vector3(0, 0.2, 0));
startLine.lookAt(startPt.clone().add(startTan)).rotateX(-Math.PI/2);
scene.add(startLine);
// Start arch
createStartArch(startPt, startNorm, startTan);
}
function createPost(position, color) {
const geo = new THREE.CylinderGeometry(0.15, 0.15, 1.2, 4);
const mat = new THREE.MeshLambertMaterial({ color: color });
const post = new THREE.Mesh(geo, mat);
post.position.copy(position);
post.position.y += 0.6;
scene.add(post);
// Top sphere
const topGeo = new THREE.SphereGeometry(0.2, 4, 4);
const topMat = new THREE.MeshLambertMaterial({ color: color });
const top = new THREE.Mesh(topGeo, topMat);
top.position.copy(position);
top.position.y += 1.3;
scene.add(top);
}
function createStartArch(pos, norm, tan) {
const archGroup = new THREE.Group();
// Two pillars
for (let side of [-1, 1]) {
const pillarGeo = new THREE.BoxGeometry(0.5, 6, 0.5);
const pillarMat = new THREE.MeshLambertMaterial({ color: 0xffffff });
const pillar = new THREE.Mesh(pillarGeo, pillarMat);
pillar.position.copy(pos).add(norm.clone().multiplyScalar(side * CFG.trackWidth * 0.5));
pillar.position.y += 3;
archGroup.add(pillar);
}
// Banner
const bannerGeo = new THREE.BoxGeometry(CFG.trackWidth, 1.5, 0.2);
const bannerCanvas = document.createElement('canvas');
bannerCanvas.width = 256; bannerCanvas.height = 48;
const bctx = bannerCanvas.getContext('2d');
bctx.fillStyle = '#cc0000';
bctx.fillRect(0, 0, 256, 48);
bctx.fillStyle = '#fff';
bctx.font = 'bold 32px Arial';
bctx.textAlign = 'center';
bctx.fillText('START / FINISH', 128, 36);
const bannerTex = new THREE.CanvasTexture(bannerCanvas);
const bannerMat = new THREE.MeshBasicMaterial({ map: bannerTex });
const banner = new THREE.Mesh(bannerGeo, bannerMat);
banner.position.copy(pos).add(new THREE.Vector3(0, 5.5, 0));
archGroup.add(banner);
scene.add(archGroup);
}
// ==================== CAR MODEL ====================
function createCar() {
carGroup = new THREE.Group();
// Main body
const bodyGeo = new THREE.BoxGeometry(CFG.carWidth, 0.8, CFG.carLength);
const bodyMat = new THREE.MeshLambertMaterial({ color: 0xdd2200 });
carBody = new THREE.Mesh(bodyGeo, bodyMat);
carBody.position.y = 0.6;
carBody.castShadow = true;
carGroup.add(carBody);
// Cabin (windshield area)
const cabinGeo = new THREE.BoxGeometry(CFG.carWidth * 0.85, 0.6, CFG.carLength * 0.45);
const cabinMat = new THREE.MeshLambertMaterial({ color: 0x333344 });
const cabin = new THREE.Mesh(cabinGeo, cabinMat);
cabin.position.set(0, 1.2, -0.2);
carGroup.add(cabin);
// Hood scoop
const hoodGeo = new THREE.BoxGeometry(CFG.carWidth * 0.4, 0.3, 0.8);
const hoodMat = new THREE.MeshLambertMaterial({ color: 0xbb1100 });
const hood = new THREE.Mesh(hoodGeo, hoodMat);
hood.position.set(0, 1.15, 1.2);
carGroup.add(hood);
// Spoiler
const spoilerArmGeo = new THREE.BoxGeometry(0.1, 0.4, 0.1);
const spoilerMat = new THREE.MeshLambertMaterial({ color: 0x222222 });
for (let side of [-1, 1]) {
const arm = new THREE.Mesh(spoilerArmGeo, spoilerMat);
arm.position.set(side * CFG.carWidth * 0.35, 1.4, -CFG.carLength * 0.4);
carGroup.add(arm);
}
const spoilerWingGeo = new THREE.BoxGeometry(CFG.carWidth * 1.1, 0.08, 0.5);
const spoilerWing = new THREE.Mesh(spoilerWingGeo, spoilerMat);
spoilerWing.position.set(0, 1.6, -CFG.carLength * 0.4);
carGroup.add(spoilerWing);
// Headlights
for (let side of [-1, 1]) {
const hlGeo = new THREE.BoxGeometry(0.35, 0.2, 0.1);
const hlMat = new THREE.MeshBasicMaterial({ color: 0xffffcc });
const hl = new THREE.Mesh(hlGeo, hlMat);
hl.position.set(side * CFG.carWidth * 0.3, 0.65, CFG.carLength * 0.5);
carGroup.add(hl);
// Headlight beam (spot light)
const spotLight = new THREE.SpotLight(0xffffcc, 0.5, 40, Math.PI/6, 0.5);
spotLight.position.set(side * CFG.carWidth * 0.3, 0.65, CFG.carLength * 0.52);
spotLight.target.position.set(side * CFG.carWidth * 0.3, 0, CFG.carLength * 2);
carGroup.add(spotLight);
carGroup.add(spotLight.target);
}
// Taillights
for (let side of [-1, 1]) {
const tlGeo = new THREE.BoxGeometry(0.3, 0.15, 0.08);
const tlMat = new THREE.MeshBasicMaterial({ color: 0xff0000 });
const tl = new THREE.Mesh(tlGeo, tlMat);
tl.position.set(side * CFG.carWidth * 0.35, 0.65, -CFG.carLength * 0.5);
carGroup.add(tl);
}
// Wheels
const wheelGeo = new THREE.CylinderGeometry(0.35, 0.35, 0.25, 8);
const wheelMat = new THREE.MeshLambertMaterial({ color: 0x111111 });
const wheelPositions = [
{ x: -CFG.carWidth * 0.55, y: 0.35, z: CFG.carLength * 0.3 },
{ x: CFG.carWidth * 0.55, y: 0.35, z: CFG.carLength * 0.3 },
{ x: -CFG.carWidth * 0.55, y: 0.35, z: -CFG.carLength * 0.3 },
{ x: CFG.carWidth * 0.55, y: 0.35, z: -CFG.carLength * 0.3 },
];
wheelPositions.forEach((wp) => {
const wheel = new THREE.Mesh(wheelGeo, wheelMat);
wheel.rotation.z = Math.PI / 2;
wheel.position.set(wp.x, wp.y, wp.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(wp.x, wp.y, wp.z);
carGroup.add(hub);
});
// Number on side
const numCanvas = document.createElement('canvas');
numCanvas.width = 64; numCanvas.height = 64;
const nctx = numCanvas.getContext('2d');
nctx.fillStyle = '#ffffff';
nctx.fillRect(0, 0, 64, 64);
nctx.fillStyle = '#dd2200';
nctx.font = 'bold 48px Arial';
nctx.textAlign = 'center';
nctx.fillText('7', 32, 50);
const numTex = new THREE.CanvasTexture(numCanvas);
for (let side of [-1, 1]) {
const numGeo = new THREE.PlaneGeometry(0.8, 0.8);
const numMat = new THREE.MeshBasicMaterial({ map: numTex });
const numMesh = new THREE.Mesh(numGeo, numMat);
numMesh.position.set(side * (CFG.carWidth/2 + 0.01), 0.7, -0.3);
numMesh.rotation.y = side > 0 ? Math.PI/2 : -Math.PI/2;
carGroup.add(numMesh);
}
scene.add(carGroup);
}
// ==================== SCENERY ====================
function createScenery() {
const segs = CFG.trackSegments;
for (let i = 0; i < CFG.treeCount; i++) {
const t = Math.random();
const point = trackCurve.getPointAt(t);
const tangent = trackCurve.getTangentAt(t).normalize();
const normal = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();
const side = (Math.random() > 0.5 ? 1 : -1) * (CFG.trackWidth * 0.6 + Math.random() * 80);
const dist = CFG.trackWidth * 0.7 + Math.random() * 60;
const pos = point.clone().add(normal.clone().multiplyScalar(side));
createTree(pos.x, point.y, pos.z);
}
for (let i = 0; i < CFG.rockCount; i++) {
const t = Math.random();
const point = trackCurve.getPointAt(t);
const tangent = trackCurve.getTangentAt(t).normalize();
const normal = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();
const side = (Math.random() > 0.5 ? 1 : -1) * (CFG.trackWidth * 0.6 + Math.random() * 40);
const pos = point.clone().add(normal.clone().multiplyScalar(side));
createRock(pos.x, point.y, pos.z);
}
for (let i = 0; i < CFG.signCount; i++) {
const t = i / CFG.signCount;
const point = trackCurve.getPointAt(t);
const tangent = curveTangentSafe(trackCurve, t).normalize();
const normal = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();
const side = (Math.random() > 0.5 ? 1 : -1) * CFG.trackWidth * 0.8;
const pos = point.clone().add(normal.clone().multiplyScalar(side));
createSign(pos.x, point.y, pos.z);
}
// Guard rails on sharp curves
for (let i = 0; i < segs; i++) {
const t1 = Math.max(0, (i - 2) / segs);
const t2 = (i + 2) / segs;
const p1 = trackCurve.getPointAt(t1);
const p3 = trackCurve.getPointAt(t2);
const curvature = p1.distanceTo(p3);
if (curvature < CFG.trackWidth * 0.8 && i % 5 === 0) {
const point = trackCurve.getPointAt(i / segs);
const tangent = curveTangentSafe(trackCurve, i / segs).normalize();
const normal = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();
for (let side of [-1, 1]) {
createGuardRail(point.clone().add(normal.clone().multiplyScalar(side * CFG.trackWidth * 0.55)), tangent);
}
}
}
}
function curveTangentSafe(curve, t) {
return curve.getTangentAt(Math.max(0, Math.min(1, t)));
}
function createTree(x, y, z) {
const group = new THREE.Group();
// Trunk
const trunkGeo = new THREE.CylinderGeometry(0.2, 0.35, 3 + Math.random() * 2, 5);
const trunkMat = new THREE.MeshLambertMaterial({ color: 0x8B4513 });
const trunk = new THREE.Mesh(trunkGeo, trunkMat);
trunk.position.y = 1.5;
trunk.castShadow = true;
group.add(trunk);
// Foliage layers
const foliageColors = [0x2d6b1e, 0x3a8c2a, 0x4a9e35];
for (let i = 0; i < 3; i++) {
const size = 2.5 - i * 0.6 + Math.random() * 0.5;
const fGeo = new THREE.ConeGeometry(size, 2.5, 6);
const fMat = new THREE.MeshLambertMaterial({ color: foliageColors[i % 3] });
const foliage = new THREE.Mesh(fGeo, fMat);
foliage.position.y = 3 + i * 1.5;
foliage.castShadow = true;
group.add(foliage);
}
group.position.set(x, y, z);
scene.add(group);
sceneryObjects.push({ mesh: group, type: 'tree', radius: 0.8 });
}
function createRock(x, y, z) {
const size = 0.5 + Math.random() * 1.5;
const geo = new THREE.DodecahedronGeometry(size, 0);
// Randomize vertices for organic look
const posAttr = geo.attributes.position;
for (let i = 0; i < posAttr.count; i++) {
posAttr.setX(i, posAttr.getX(i) + (Math.random() - 0.5) * size * 0.3);
posAttr.setY(i, posAttr.getY(i) + (Math.random() - 0.5) * size * 0.3);
posAttr.setZ(i, posAttr.getZ(i) + (Math.random() - 0.5) * size * 0.3);
}
geo.computeVertexNormals();
const mat = new THREE.MeshLambertMaterial({ color: 0x777777 });
const rock = new THREE.Mesh(geo, mat);
rock.position.set(x, y + size * 0.4, z);
rock.rotation.set(Math.random(), Math.random(), Math.random());
rock.castShadow = true;
scene.add(rock);
sceneryObjects.push({ mesh: rock, type: 'rock', radius: size });
}
function createSign(x, y, z) {
const group = new THREE.Group();
// Pole
const poleGeo = new THREE.CylinderGeometry(0.08, 0.08, 3, 4);
const poleMat = new THREE.MeshLambertMaterial({ color: 0x666666 });
const pole = new THREE.Mesh(poleGeo, poleMat);
pole.position.y = 1.5;
group.add(pole);
// Sign board
const signs = ['SLOW', 'CURVE', 'RALLY', 'DANGER', 'TRACK'];
const signText = signs[Math.floor(Math.random() * signs.length)];
const sCanvas = document.createElement('canvas');
sCanvas.width = 128; sCanvas.height = 64;
const sctx = sCanvas.getContext('2d');
sctx.fillStyle = '#ffcc00';
sctx.fillRect(0, 0, 128, 64);
sctx.strokeStyle = '#333';
sctx.lineWidth = 4;
sctx.strokeRect(2, 2, 124, 60);
sctx.fillStyle = '#333';
sctx.font = 'bold 28px Arial';
sctx.textAlign = 'center';
sctx.fillText(signText, 64, 42);
const signTex = new THREE.CanvasTexture(sCanvas);
const boardGeo = new THREE.PlaneGeometry(1.5, 0.75);
const boardMat = new THREE.MeshBasicMaterial({ map: signTex });
const board = new THREE.Mesh(boardGeo, boardMat);
board.position.y = 3;
group.add(board);
group.position.set(x, y, z);
scene.add(group);
}
function createGuardRail(pos, tangent) {
const geo = new THREE.BoxGeometry(0.15, 0.4, 2);
const mat = new THREE.MeshLambertMaterial({ color: 0x888888 });
const rail = new THREE.Mesh(geo, mat);
rail.position.copy(pos);
rail.position.y += 0.3;
rail.lookAt(pos.clone().add(tangent));
scene.add(rail);
// Support post
const postGeo = new THREE.CylinderGeometry(0.05, 0.05, 0.6, 4);
const post = new THREE.Mesh(postGeo, mat);
post.position.copy(pos);
post.position.y += 0;
scene.add(post);
sceneryObjects.push({ mesh: rail, type: 'barrier', radius: 0.3 });
}
// ==================== PARTICLES ====================
function setupParticles() {
// Dust particles
const dustGeo = new THREE.BufferGeometry();
const dustCount = 500;
const dustPositions = new Float32Array(dustCount * 3);
const dustSizes = new Float32Array(dustCount);
for (let i = 0; i < dustCount; i++) {
dustPositions[i*3] = 0; dustPositions[i*3+1] = -100; dustPositions[i*3+2] = 0;
dustSizes[i] = Math.random() * 3 + 1;
}
dustGeo.setAttribute('position', new THREE.Float32BufferAttribute(dustPositions, 3));
dustGeo.setAttribute('size', new THREE.Float32BufferAttribute(dustSizes, 1));
// Create dust texture procedurally
const dCanvas = document.createElement('canvas');
dCanvas.width = 64; dCanvas.height = 64;
const dctx = dCanvas.getContext('2d');
const grad = dctx.createRadialGradient(32, 32, 0, 32, 32, 32);
grad.addColorStop(0, 'rgba(180,160,140,1)');
grad.addColorStop(0.5, 'rgba(160,140,120,0.5)');
grad.addColorStop(1, 'rgba(140,120,100,0)');
dctx.fillStyle = grad;
dctx.fillRect(0, 0, 64, 64);
const dustMat = new THREE.PointsMaterial({
map: new THREE.CanvasTexture(dCanvas),
size: 3,
transparent: true,
opacity: 0.6,
depthWrite: false,
blending: THREE.NormalBlending,
});
particles.dust = new THREE.Points(dustGeo, dustMat);
scene.add(particles.dust);
// Spark particles
const sparkGeo = new THREE.BufferGeometry();
const sparkCount = 200;
const sparkPositions = new Float32Array(sparkCount * 3);
for (let i = 0; i < sparkCount; i++) {
sparkPositions[i*3] = 0; sparkPositions[i*3+1] = -100; sparkPositions[i*3+2] = 0;
}
sparkGeo.setAttribute('position', new THREE.Float32BufferAttribute(sparkPositions, 3));
const sparkMat = new THREE.PointsMaterial({
color: 0xffaa00,
size: 0.5,
transparent: true,
opacity: 0.9,
depthWrite: false,
});
particles.sparks = new THREE.Points(sparkGeo, sparkMat);
scene.add(particles.sparks);
// Particle data arrays
particles.dustData = [];
particles.sparkData = [];
}
function emitDust(pos, count) {
const geo = particles.dust.geometry;
for (let i = 0; i < count && particles.dustData.length < 500; i++) {
particles.dustData.push({
x: pos.x + (Math.random()-0.5)*2,
y: pos.y + Math.random()*0.5,
z: pos.z + (Math.random()-0.5)*2,
vx: (Math.random()-0.5)*3,
vy: Math.random()*2 + 1,
vz: (Math.random()-0.5)*3,
life: 1.0,
});
}
}
function emitSparks(pos, count) {
for (let i = 0; i < count && particles.sparkData.length < 200; i++) {
particles.sparkData.push({
x: pos.x + (Math.random()-0.5)*1,
y: pos.y + Math.random()*0.5,
z: pos.z + (Math.random()-0.5)*1,
vx: (Math.random()-0.5)*8,
vy: Math.random()*6 + 2,
vz: (Math.random()-0.5)*8,
life: 1.0,
});
}
}
function updateParticles(dt) {
// Dust
const dPos = particles.dust.geometry.attributes.position;
for (let i = particles.dustData.length - 1; i >= 0; i--) {
const p = particles.dustData[i];
p.x += p.vx * dt;
p.y += p.vy * dt;
p.z += p.vz * dt;
p.vy -= 2 * dt; // gravity on dust
p.life -= dt * 1.5;
if (p.life <= 0) {
particles.dustData.splice(i, 1);
} else {
dPos.setXYZ(i, p.x, p.y, p.z);
}
}
// Hide unused particles
for (let i = particles.dustData.length; i < dPos.count; i++) {
dPos.setY(i, -100);
}
dPos.needsUpdate = true;
// Sparks
const sPos = particles.sparks.geometry.attributes.position;
for (let i = particles.sparkData.length - 1; i >= 0; i--) {
const p = particles.sparkData[i];
p.x += p.vx * dt;
p.y += p.vy * dt;
p.z += p.vz * dt;
p.vy -= 10 * dt;
p.life -= dt * 3;
if (p.life <= 0) {
particles.sparkData.splice(i, 1);
} else {
sPos.setXYZ(i, p.x, p.y, p.z);
}
}
for (let i = particles.sparkData.length; i < sPos.count; i++) {
sPos.setY(i, -100);
}
sPos.needsUpdate = true;
// Skid marks fade
for (let i = particles.skidMarks.length - 1; i >= 0; i--) {
particles.skidMarks[i].life -= dt * 0.3;
if (particles.skidMarks[i].life <= 0) {
scene.remove(particles.skidMarks[i].mesh);
particles.skidMarks.splice(i, 1);
} else {
particles.skidMarks[i].mesh.material.opacity = particles.skidMarks[i].life * 0.5;
}
}
}
function addSkidMark(pos) {
const geo = new THREE.PlaneGeometry(0.3, 1.2);
const mat = new THREE.MeshBasicMaterial({ color: 0x222222, transparent: true, opacity: 0.5 });
const mark = new THREE.Mesh(geo, mat);
mark.position.copy(pos);
mark.position.y += 0.15;
mark.rotation.x = -Math.PI / 2;
scene.add(mark);
particles.skidMarks.push({ mesh: mark, life: 1.0 });
}
// ==================== AUDIO ====================
function initAudio() {
try {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
// Engine sound - oscillator with gain
engineOsc = audioCtx.createOscillator();
engineGain = audioCtx.createGain();
const filter = audioCtx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = 800;
engineOsc.type = 'sawtooth';
engineOsc.frequency.value = 60;
engineGain.gain.value = 0;
engineOsc.connect(filter);
filter.connect(engineGain);
engineGain.connect(audioCtx.destination);
engineOsc.start();
} catch(e) {
console.warn('Audio not available:', e);
}
}
function updateEngineSound() {
if (!audioCtx || !engineOsc) return;
const speedRatio = Math.abs(state.speed) / CFG.maxSpeed;
const rpmNorm = state.rpm / 8000;
engineOsc.frequency.value = 55 + rpmNorm * 200;
engineGain.gain.value = state.phase === 'racing' ? (0.03 + speedRatio * 0.06) : 0;
}
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.15;
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + duration);
osc.stop(audioCtx.currentTime + duration);
}
function playCrashSound() {
if (!audioCtx) return;
const bufferSize = audioCtx.sampleRate * 0.2;
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 / (bufferSize * 0.3));
}
const source = audioCtx.createBufferSource();
source.buffer = buffer;
const gain = audioCtx.createGain();
gain.gain.value = 0.15;
source.connect(gain);
gain.connect(audioCtx.destination);
source.start();
}
function playSkidSound() {
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 / (bufferSize * 0.5)) * 0.3;
}
const source = audioCtx.createBufferSource();
source.buffer = buffer;
const gain = audioCtx.createGain();
gain.gain.value = 0.08;
source.connect(gain);
gain.connect(audioCtx.destination);
source.start();
}
function playFinishTone() {
if (!audioCtx) return;
[523, 659, 784, 1047].forEach((freq, i) => {
setTimeout(() => playBeep(freq, 0.3), i * 150);
});
}
// ==================== INPUT HANDLING ====================
function setupInput() {
document.addEventListener('keydown', (e) => {
keys[e.code] = true;
if (e.code === 'KeyP' && state.phase === 'racing') pauseGame();
else if (e.code === 'KeyP' && state.phase === 'paused') resume();
if (e.code === 'KeyR') restart();
if (e.code === 'KeyC') toggleCamera();
});
document.addEventListener('keyup', (e) => { keys[e.code] = false; });
// Touch controls
const isMobile = /Android|iPhone|iPad|iPod|webOS/i.test(navigator.userAgent);
if (isMobile || window.innerWidth < 800) {
document.getElementById('touch-controls').style.display = 'block';
}
const touchBtns = document.querySelectorAll('.touch-btn');
touchBtns.forEach(btn => {
btn.addEventListener('touchstart', (e) => {
e.preventDefault();
const key = btn.dataset.key;
keys[key] = true;
// Resume audio context on first touch
if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
});
btn.addEventListener('touchend', (e) => {
e.preventDefault();
keys[btn.dataset.key] = false;
});
});
// Resume audio on first keypress
document.addEventListener('keydown', () => {
if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
}, { once: true });
}
function isKey(action) {
switch(action) {
case 'up': return keys['ArrowUp'] || keys['KeyW'] || keys['up'];
case 'down': return keys['ArrowDown'] || keys['KeyS'] || keys['down'];
case 'left': return keys['ArrowLeft'] || keys['KeyA'] || keys['left'];
case 'right': return keys['ArrowRight'] || keys['KeyD'] || keys['right'];
case 'handbrake': return keys['Space'] || keys['handbrake'];
}
}
// ==================== PHYSICS ====================
function resetCarPosition() {
const startPt = trackCurve.getPointAt(0);
const startTan = curveTangentSafe(trackCurve, 0).normalize();
state.position.copy(startPt);
state.heading = Math.atan2(startTan.x, startTan.z);
state.speed = 0;
state.lateralVel = 0;
state.angularVel = 0;
}
function getTrackInfoAtPos(pos) {
let minDist = Infinity;
let closestT = 0;
const segs = CFG.trackSegments;
// Search nearby checkpoints for efficiency
if (state.lastCheckpointIdx >= 0) {
const searchRange = 20;
const startI = Math.max(0, state.lastCheckpointIdx - searchRange);
const endI = Math.min(segs, state.lastCheckpointIdx + searchRange);
for (let i = startI; i <= endI; i++) {
const t = i / segs;
const tp = trackCurve.getPointAt(t);
const dx = pos.x - tp.x;
const dz = pos.z - tp.z;
const dist = Math.sqrt(dx*dx + dz*dz);
if (dist < minDist) {
minDist = dist;
closestT = t;
}
}
} else {
// Full search on first frame
for (let i = 0; i <= segs; i += 2) {
const t = i / segs;
const tp = trackCurve.getPointAt(t);
const dx = pos.x - tp.x;
const dz = pos.z - tp.z;
const dist = Math.sqrt(dx*dx + dz*dz);
if (dist < minDist) {
minDist = dist;
closestT = t;
}
}
}
// Store checkpoint index for next frame optimization
state.lastCheckpointIdx = Math.round(closestT * segs);
const trackPoint = trackCurve.getPointAt(closestT);
const tangent = curveTangentSafe(trackCurve, closestT).normalize();
const normal = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();
// Lateral offset from center
const toCar = pos.clone().sub(trackPoint);
const lateralOffset = Math.abs(toCar.dot(normal));
let surfaceType = 'asphalt';
if (lateralOffset > CFG.trackWidth * 0.5 && lateralOffset <= CFG.trackWidth * 0.7) {
surfaceType = 'gravel';
} else if (lateralOffset > CFG.trackWidth * 0.7) {
surfaceType = Math.random() < 0.3 ? 'mud' : 'grass';
}
return { closestT, lateralOffset, trackPoint, tangent, normal, surfaceType };
}
function updatePhysics(dt) {
if (state.phase !== 'racing') return;
dt = Math.min(dt, 0.033); // Cap delta time
const trackInfo = getTrackInfoAtPos(state.position);
state.surfaceType = trackInfo.surfaceType;
// Surface modifiers
let speedMult = 1.0;
let gripMult = 1.0;
switch(trackInfo.surfaceType) {
case 'asphalt': speedMult = 1.0; gripMult = 1.0; break;
case 'gravel': speedMult = 0.75; gripMult = 0.6; break;
case 'grass': speedMult = CFG.offRoadSlowdown; gripMult = 0.35; break;
case 'mud': speedMult = CFG.mudSlowdown; gripMult = 0.2; break;
}
const maxSpd = CFG.maxSpeed * speedMult;
const accelForce = CFG.acceleration * speedMult;
// Acceleration / braking
if (isKey('up')) {
state.speed += accelForce * dt;
} else if (isKey('down')) {
if (state.speed > 0.5) {
state.speed -= CFG.brakingForce * dt;
} else {
state.speed -= accelForce * 0.4 * dt; // reverse
}
} else {
// Rolling resistance
state.speed *= (1 - 1.5 * dt);
}
// Handbrake
state.handbrake = isKey('handbrake');
if (state.handbrake) {
state.speed *= (1 - 3 * dt);
gripMult *= 0.3;
}
// Clamp speed
state.speed = Math.max(CFG.reverseMaxSpeed, Math.min(maxSpd, state.speed));
// Steering
const steerFactor = state.speed > 5 ?
CFG.highSpeedSteering + (CFG.steeringSpeed - CFG.highSpeedSteering) * Math.exp(-state.speed / 30) :
CFG.steeringSpeed;
let turnAmount = 0;
if (isKey('left')) turnAmount += steerFactor;
if (isKey('right')) turnAmount -= steerFactor;
// Speed-dependent steering reduction at very high speeds
const speedSteerMod = Math.max(0.3, 1 - state.speed / (CFG.maxSpeed * 2));
turnAmount *= speedSteerMod;
// Apply turning with drift physics
const effectiveGrip = gripMult * CFG.driftFactor;
if (!state.handbrake) {
state.heading += turnAmount * dt * Math.sign(state.speed);
} else {
// Handbrake: more rotation, less forward tracking
state.heading += turnAmount * dt * 1.5 * Math.sign(state.speed || 0.1);
}
// Drift detection
const driftAngle = Math.abs(turnAmount) * (state.speed / CFG.maxSpeed);
state.isDrifting = driftAngle > 0.3 && state.speed > 20;
// Calculate forward direction vector
const fwdX = Math.sin(state.heading);
const fwdZ = Math.cos(state.heading);
// Lateral velocity (drift component)
if (state.isDrifting || trackInfo.surfaceType !== 'asphalt') {
state.lateralVel += turnAmount * state.speed * dt * 0.5;
state.lateralVel *= effectiveGrip;
} else {
state.lateralVel *= 0.85; // Grip pulls lateral vel to zero
}
// Move car
const moveDist = state.speed * dt;
state.position.x += fwdX * moveDist + Math.cos(state.heading) * state.lateralVel * dt;
state.position.z += fwdZ * moveDist - Math.sin(state.heading) * state.lateralVel * dt;
// Elevation: snap to track height with some smoothing
const targetY = trackInfo.trackPoint.y + 0.5;
state.position.y += (targetY - state.position.y) * 0.15;
// Obstacle collision
checkObstacleCollisions();
// Track boundary bounce
if (trackInfo.lateralOffset > CFG.trackWidth * 1.2) {
const pushBack = trackInfo.normal.clone().multiplyScalar(
(trackInfo.lateralOffset - CFG.trackWidth * 1.2) * 0.5
);
state.position.add(pushBack);
state.speed *= 0.7;
}
// Lap checking
checkLapProgress(trackInfo.closestT);
// Particles
if (state.isDrifting && Math.abs(state.speed) > 10) {
emitDust(state.position.clone().add(new THREE.Vector3(0, -0.2, 0)), 2);
addSkidMark(state.position.clone());
if (Math.random() < 0.1) playSkidSound();
} else if (trackInfo.surfaceType !== 'asphalt' && Math.abs(state.speed) > 5) {
emitDust(state.position.clone().add(new THREE.Vector3(0, -0.2, 0)), 1);
}
// Gear simulation
const absSpeed = Math.abs(state.speed);
if (absSpeed < 1) state.gear = 0; // N
else if (absSpeed < 15) state.gear = 1;
else if (absSpeed < 30) state.gear = 2;
else if (absSpeed < 48) state.gear = 3;
else if (absSpeed < 65) state.gear = 4;
else state.gear = 5;
// RPM simulation
const gearRanges = [0, 15, 30, 48, 65, CFG.maxSpeed];
const gearMin = gearRanges[Math.max(0, state.gear - 1)] || 0;
const gearMax = gearRanges[state.gear] || CFG.maxSpeed;
state.rpm = 1000 + (absSpeed - gearMin) / (gearMax - gearMin) * 7000;
// Timers
state.currentLapTime += dt;
state.totalTime += dt;
}
function checkObstacleCollisions() {
for (const obj of sceneryObjects) {
const dx = state.position.x - obj.mesh.position.x;
const dz = state.position.z - obj.mesh.position.z;
const dist = Math.sqrt(dx*dx + dz*dz);
if (dist < obj.radius + 1.2) {
// Collision!
state.speed *= 0.3;
// Push car away
const pushDir = new THREE.Vector3(dx, 0, dz).normalize();
state.position.add(pushDir.clone().multiplyScalar(0.5));
emitSparks(state.position.clone(), 10);
playCrashSound();
}
}
}
function checkLapProgress(closestT) {
const checkpointCount = 8;
const currentCheckpoint = Math.floor(closestT * checkpointCount) % checkpointCount;
// Check if we passed a new checkpoint in sequence
if (currentCheckpoint !== state.lastCheckpointIdx % checkpointCount ||
(state.checkpointsPassed.length > 0 &&
currentCheckpoint === (state.checkpointsPassed[state.checkpointsPassed.length - 1] + 1) % checkpointCount)) {
// Validate sequential progression
const expectedNext = (state.checkpointsPassed.length > 0 ?
state.checkpointsPassed[state.checkpointsPassed.length - 1] : -1) + 1;
if (currentCheckpoint === expectedNext % checkpointCount && !state.checkpointsPassed.includes(currentCheckpoint)) {
state.checkpointsPassed.push(currentCheckpoint);
}
// Lap completion: all checkpoints passed and crossing start line area
if (state.checkpointsPassed.length >= checkpointCount && closestT < 0.1) {
completeLap();
}
}
}
function completeLap() {
if (state.currentLapTime < state.bestLap) {
state.bestLap = state.currentLapTime;
}
if (state.lap >= CFG.totalLaps) {
finishRace();
} else {
state.lap++;
state.checkpointsPassed = [];
state.currentLapTime = 0;
updateHUD();
}
}
// ==================== CAMERA ====================
let cameraMode = 'chase'; // chase | cinematic
function toggleCamera() {
cameraMode = cameraMode === 'chase' ? 'cinematic' : 'chase';
}
function updateCamera(dt) {
const carPos = state.position;
const fwdX = Math.sin(state.heading);
const fwdZ = Math.cos(state.heading);
let targetCamPos, lookAtPos;
if (cameraMode === 'chase') {
// Chase camera behind and above car
const camDist = 10 + Math.abs(state.speed) * 0.05;
const camHeight = 4 + Math.abs(state.speed) * 0.02;
targetCamPos = new THREE.Vector3(
carPos.x - fwdX * camDist,
carPos.y + camHeight,
carPos.z - fwdZ * camDist
);
lookAtPos = new THREE.Vector3(
carPos.x + fwdX * 5,
carPos.y + 1.5,
carPos.z + fwdZ * 5
);
} else {
// Cinematic - higher and wider
targetCamPos = new THREE.Vector3(
carPos.x - fwdX * 20,
carPos.y + 18,
carPos.z - fwdZ * 20
);
lookAtPos = carPos.clone().add(new THREE.Vector3(0, 1, 0));
}
// Smooth camera follow with lag
const lerpFactor = 1 - Math.pow(1 - CFG.cameraLag, dt * 60);
camera.position.lerp(targetCamPos, lerpFactor * 3 + 0.5);
const currentLookAt = new THREE.Vector3();
camera.getWorldDirection(currentLookAt);
camera.lookAt(lookAtPos);
// Update shadow camera to follow car
scene.traverse((child) => {
if (child.isDirectionalLight && child.castShadow) {
child.position.set(carPos.x + 50, carPos.y + 100, carPos.z + 30);
child.target.position.copy(carPos);
child.target.updateMatrixWorld();
}
});
}
// ==================== CAR VISUAL UPDATE ====================
function updateCarVisual() {
if (!carGroup) return;
carGroup.position.copy(state.position);
carGroup.rotation.y = -state.heading;
// Wheel rotation based on speed
const wheelRot = state.speed * 0.15;
wheels.forEach(w => w.rotation.x += wheelRot);
// Front wheel steering visual
if (wheels.length >= 2) {
let steerAngle = 0;
if (isKey('left')) steerAngle = 0.3;
else if (isKey('right')) steerAngle = -0.3;
wheels[0].rotation.y = steerAngle;
wheels[1].rotation.y = steerAngle;
}
// Body tilt during turns
const turnTilt = state.lateralVel * 0.02;
carBody.rotation.z = Math.max(-0.15, Math.min(0.15, -turnTilt));
}
// ==================== HUD ====================
function updateHUD() {
document.getElementById('lap-count').textContent = `LAP ${state.lap} / ${CFG.totalLaps}`;
document.getElementById('best-lap').textContent = state.bestLap < Infinity ?
`Best Lap: ${formatTime(state.bestLap)}` : 'Best Lap: --:--.---';
document.getElementById('current-time').textContent = formatTime(state.totalTime);
const displaySpeed = Math.abs(Math.round(state.speed * 3.6)); // m/s to km/h
document.getElementById('speed-val').textContent = displaySpeed;
const gearNames = ['N', '1', '2', '3', '4', '5'];
document.getElementById('gear-display').textContent = `Gear ${gearNames[state.gear]}`;
document.getElementById('rpm-bar').style.width = `${(state.rpm / 8000) * 100}%`;
const surfaceColors = { asphalt: '#aaa', gravel: '#c9a54e', grass: '#4a7c3f', mud: '#6b4226' };
const si = document.getElementById('surface-indicator');
si.textContent = state.surfaceType.toUpperCase();
si.style.color = surfaceColors[state.surfaceType] || '#fff';
// Minimap
drawMinimap();
}
function formatTime(seconds) {
if (!isFinite(seconds)) return '--:--.---';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${String(mins).padStart(2, '0')}:${secs.toFixed(3).padStart(6, '0')}`;
}
function drawMinimap() {
const canvas = document.getElementById('minimap');
const ctx = canvas.getContext('2d');
const w = canvas.width, h = canvas.height;
const cx = w / 2, cy = h / 2;
ctx.clearRect(0, 0, w, h);
// Background circle
ctx.fillStyle = 'rgba(30,50,30,0.8)';
ctx.beginPath();
ctx.arc(cx, cy, cx - 4, 0, Math.PI * 2);
ctx.fill();
// Find track bounds for scaling
let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
const segs = CFG.trackSegments;
for (let i = 0; i <= segs; i += 5) {
const p = trackCurve.getPointAt(i / segs);
minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x);
minZ = Math.min(minZ, p.z); maxZ = Math.max(maxZ, p.z);
}
const rangeX = maxX - minX || 1;
const rangeZ = maxZ - minZ || 1;
const scale = (Math.min(w, h) * 0.42) / Math.max(rangeX, rangeZ);
const offX = cx - ((minX + maxX) / 2) * scale;
const offY = cy - ((minZ + maxZ) / 2) * scale;
// Draw track
ctx.strokeStyle = '#888';
ctx.lineWidth = 3;
ctx.beginPath();
for (let i = 0; i <= segs; i += 2) {
const p = trackCurve.getPointAt(i / segs);
const sx = p.x * scale + offX;
const sy = p.z * scale + offY;
if (i === 0) ctx.moveTo(sx, sy);
else ctx.lineTo(sx, sy);
}
ctx.closePath();
ctx.stroke();
// Draw checkpoints passed
for (const cp of state.checkpointsPassed) {
const t = (cp + 0.5) / 8;
const p = trackCurve.getPointAt(t);
ctx.fillStyle = '#0f0';
ctx.beginPath();
ctx.arc(p.x * scale + offX, p.z * scale + offY, 3, 0, Math.PI * 2);
ctx.fill();
}
// Draw car position
const carSx = state.position.x * scale + offX;
const carSy = state.position.z * scale + offY;
ctx.fillStyle = '#ff3300';
ctx.beginPath();
ctx.arc(carSx, carSy, 5, 0, Math.PI * 2);
ctx.fill();
// Car direction indicator
const dirX = Math.sin(state.heading) * 8;
const dirY = Math.cos(state.heading) * 8;
ctx.strokeStyle = '#ff3300';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(carSx, carSy);
ctx.lineTo(carSx + dirX, carSy + dirY);
ctx.stroke();
// Border
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(cx, cy, cx - 4, 0, Math.PI * 2);
ctx.stroke();
}
// ==================== GAME FLOW ====================
function startCountdown() {
state.phase = 'countdown';
state.countdownValue = 3;
const overlay = document.getElementById('countdown-overlay');
const text = document.getElementById('countdown-text');
overlay.style.display = 'flex';
let count = 3;
function tick() {
if (count > 0) {
text.textContent = count;
playBeep(440, 0.2);
count--;
setTimeout(tick, 1000);
} else {
text.textContent = 'GO!';
text.style.color = '#00ff00';
playBeep(880, 0.3);
setTimeout(() => {
overlay.style.display = 'none';
text.style.color = '#fff';
state.phase = 'racing';
document.getElementById('controls-hint').style.opacity = '0';
}, 600);
}
}
tick();
}
function pauseGame() {
state.phase = 'paused';
document.getElementById('pause-menu').classList.add('active');
}
function resume() {
state.phase = 'racing';
document.getElementById('pause-menu').classList.remove('active');
}
function finishRace() {
state.phase = 'finished';
playFinishTone();
document.getElementById('final-time').textContent = `Total Time: ${formatTime(state.totalTime)}`;
document.getElementById('final-best-lap').textContent = `Best Lap: ${formatTime(state.bestLap)}`;
document.getElementById('finish-screen').classList.add('active');
}
function restart() {
// Reset state
state.speed = 0;
state.heading = 0;
state.lateralVel = 0;
state.angularVel = 0;
state.gear = 1;
state.rpm = 0;
state.lap = 1;
state.currentLapTime = 0;
state.totalTime = 0;
state.bestLap = Infinity;
state.checkpointsPassed = [];
state.lastCheckpointIdx = -1;
state.surfaceType = 'asphalt';
state.isDrifting = false;
// Clear particles and skid marks
particles.dustData = [];
particles.sparkData = [];
for (const sm of particles.skidMarks) {
scene.remove(sm.mesh);
}
particles.skidMarks = [];
document.getElementById('pause-menu').classList.remove('active');
document.getElementById('finish-screen').classList.remove('active');
resetCarPosition();
startCountdown();
}
// ==================== MAIN LOOP ====================
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05);
updatePhysics(dt);
updateCamera(dt);
updateCarVisual();
updateParticles(dt);
updateEngineSound();
if (state.phase === 'racing') {
updateHUD();
}
renderer.render(scene, camera);
}
function onResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
// ==================== PUBLIC API ====================
return {
init,
resume,
restart,
};
})();
// Start the game when page loads
window.addEventListener('load', () => RallyGame.init());
</script>
</body>
</html>
<!-- agent-meta {"model":"thinkingcap-qwen3.6-27b","provider":"lmstudio","persona":"middle","sessionId":"ca5369d6-8188-462e-a75b-77f4ed17b2a3","tokensIn":44806,"tokensOut":19689,"tokensTotal":64495,"turns":3,"toolCalls":2,"failedToolCalls":0,"timestamp":"2026-07-18T23:44:11.428Z"} -->