Rally
Qwen 3.6 27b MTP · composer

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 82,342 bytes · SHA-256 96fed4a3d022
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Rally Racing 3D</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<style>
/* ── css: layout ── */
* { 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; }
#app { position: relative; width: 100%; height: 100%; }
#gameCanvas { display: block; width: 100%; height: 100%; }
/* HUD */
#hud { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; }
.hud-value { font-size: 42px; font-weight: bold; color: #fff; text-shadow: 2px 2px 4px rgba(0,0,0,0.8); letter-spacing: 1px; }
.hud-label { font-size: 13px; color: #aaa; text-transform: uppercase; letter-spacing: 2px; }
#speedometer { position: absolute; bottom: 20px; right: 20px; text-align: center; background: rgba(0,0,0,0.5); padding: 10px 20px; border-radius: 10px; }
.gear-display { font-size: 28px; color: #ffcc00; font-weight: bold; margin-top: -4px; text-shadow: 1px 1px 3px rgba(0,0,0,0.8); }
#rpmBar { display: block; margin: 6px auto 0; border-radius: 3px; background: #222; }
#lapInfo { position: absolute; top: 15px; left: 15px; background: rgba(0,0,0,0.5); padding: 12px 18px; border-radius: 10px; min-width: 160px; }
.hud-row { display: flex; justify-content: space-between; margin-bottom: 4px; align-items: baseline; }
.hud-row:last-child { margin-bottom: 0; }
#minimap { position: absolute; bottom: 20px; left: 15px; border-radius: 8px; background: rgba(0,0,0,0.4); border: 2px solid rgba(255,255,255,0.2); }
/* Countdown */
#countdownOverlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; z-index: 20; pointer-events: none; }
#countdownText { font-size: 120px; font-weight: bold; color: #fff; text-shadow: 4px 4px 8px rgba(0,0,0,0.9); opacity: 0; transition: opacity 0.2s; }
/* Pause Menu */
#pauseMenu { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; background: rgba(0,0,0,0.7); z-index: 30; }
#pauseMenu h1 { font-size: 64px; color: #fff; margin-bottom: 30px; text-shadow: 3px 3px 6px rgba(0,0,0,0.8); }
/* Finish Screen */
#finishScreen { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; background: rgba(0,0,0,0.8); z-index: 30; }
#finishScreen h1 { font-size: 52px; color: #ffcc00; margin-bottom: 20px; text-shadow: 3px 3px 6px rgba(0,0,0,0.8); }
.stat-row { display: flex; justify-content: space-between; width: 300px; font-size: 24px; color: #fff; margin-bottom: 10px; padding: 5px 0; border-bottom: 1px solid rgba(255,255,255,0.2); }
.stat-row span:first-child { color: #aaa; }
/* Start Screen */
#startScreen { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); z-index: 40; }
#startScreen h1 { font-size: 56px; color: #ffcc00; margin-bottom: 15px; text-shadow: 3px 3px 8px rgba(0,0,0,0.9); letter-spacing: 3px; }
.instructions { color: #ccc; font-size: 16px; margin-bottom: 40px; text-align: center; line-height: 1.8; }
/* Buttons */
button { pointer-events: auto; padding: 12px 36px; font-size: 20px; border: none; border-radius: 8px; cursor: pointer; margin: 6px; transition: transform 0.1s, background 0.2s; }
button:hover { transform: scale(1.05); }
button:active { transform: scale(0.97); }
#startBtn { background: #ffcc00; color: #1a1a2e; font-weight: bold; padding: 16px 48px; font-size: 24px; }
#resumeBtn, #restartPauseBtn, #restartFinishBtn { background: rgba(255,255,255,0.15); color: #fff; border: 2px solid rgba(255,255,255,0.3); }
#resumeBtn:hover, #restartPauseBtn:hover, #restartFinishBtn:hover { background: rgba(255,255,255,0.25); }
/* Touch Controls */
#touchControls { position: absolute; bottom: 0; left: 0; width: 100%; height: 100%; z-index: 15; pointer-events: none; }
#dpad { position: absolute; bottom: 20px; left: 20px; width: 140px; height: 140px; pointer-events: auto; }
.dpad-btn { background: rgba(255,255,255,0.2); color: #fff; border: 2px solid rgba(255,255,255,0.3); font-size: 24px; width: 46px; height: 46px; position: absolute; display: flex; align-items: center; justify-content: center; padding: 0; border-radius: 8px; }
.dpad-up { top: 0; left: 50%; transform: translateX(-50%); }
.dpad-down { bottom: 0; left: 50%; transform: translateX(-50%); }
.dpad-left { top: 50%; left: 0; transform: translateY(-50%); }
.dpad-right { top: 50%; right: 0; transform: translateY(-50%); }
#actionBtns { position: absolute; bottom: 20px; right: 15px; display: flex; flex-direction: column; gap: 8px; pointer-events: auto; }
.action-btn { width: 70px; height: 50px; font-size: 14px; font-weight: bold; padding: 0; border-radius: 8px; }
.gas-btn { background: rgba(0,200,0,0.3); color: #0f0; border: 2px solid rgba(0,255,0,0.4); }
.brake-btn { background: rgba(200,0,0,0.3); color: #f66; border: 2px solid rgba(255,100,100,0.4); }
.handbrake-btn { background: rgba(255,200,0,0.3); color: #fc0; border: 2px solid rgba(255,255,100,0.4); }
@media (max-width: 768px) {
.hud-value { font-size: 28px !important; }
#speedometer { bottom: 10px; right: 10px; padding: 6px 12px; }
#lapInfo { top: 8px; left: 8px; padding: 8px 12px; min-width: 130px; }
.hud-row .hud-label { font-size: 10px !important; }
.hud-row .hud-value { font-size: 16px !important; }
#minimap { width: 120px; height: 120px; bottom: 10px; left: 8px; }
}
</style>
</head>
<body>
<canvas id="gameCanvas"></canvas>
<!-- HUD Overlay -->
<div id="hud" style="display:none;">
<div id="speedometer">
<div class="hud-value" id="speedValue">0</div>
<div class="hud-label">KM/H</div>
<div class="gear-display"><span id="gearValue">N</span></div>
<canvas id="rpmBar" width="200" height="16"></canvas>
</div>
<div id="lapInfo">
<div class="hud-row"><span class="hud-label">LAP</span><span class="hud-value" id="lapValue">1/3</span></div>
<div class="hud-row"><span class="hud-label">TIME</span><span class="hud-value" id="timeValue">0:00.00</span></div>
<div class="hud-row"><span class="hud-label">BEST LAP</span><span class="hud-value" id="bestLapValue">--:--.--</span></div>
</div>
<canvas id="minimap" width="180" height="180"></canvas>
</div>
<!-- Countdown Overlay -->
<div id="countdownOverlay">
<div id="countdownText"></div>
</div>
<!-- Pause Menu -->
<div id="pauseMenu" style="display:none;">
<h1>PAUSED</h1>
<button id="resumeBtn">RESUME</button>
<button id="restartPauseBtn">RESTART</button>
</div>
<!-- Finish Screen -->
<div id="finishScreen" style="display:none;">
<h1>RACE COMPLETE!</h1>
<div class="stat-row"><span>Total Time:</span><span id="finalTime"></span></div>
<div class="stat-row"><span>Best Lap:</span><span id="finalBestLap"></span></div>
<button id="restartFinishBtn">RACE AGAIN</button>
</div>
<!-- Touch Controls -->
<div id="touchControls" style="display:none;">
<div id="dpad">
<button class="dpad-btn dpad-up" data-dir="up">▲</button>
<button class="dpad-btn dpad-left" data-dir="left">◀</button>
<button class="dpad-btn dpad-right" data-dir="right">▶</button>
<button class="dpad-btn dpad-down" data-dir="down">▼</button>
</div>
<div id="actionBtns">
<button class="action-btn gas-btn" data-action="gas">GAS</button>
<button class="action-btn brake-btn" data-action="brake">BRAKE</button>
<button class="action-btn handbrake-btn" data-action="handbrake">HB</button>
</div>
</div>
<!-- Start Screen -->
<div id="startScreen">
<h1>RALLY RACING 3D</h1>
<p class="instructions">Arrow Keys / WASD to drive<br>Space = Handbrake | P = Pause | C = Camera Toggle | R = Restart</p>
<button id="startBtn">START RACE</button>
</div>
<script>
'use strict';
// ── const: RallyGame ──
// ============================================================
// RALLY RACING 3D - Main Game Module
// ============================================================
const RallyGame = {
// === CONSTANTS ===
TOTAL_LAPS: 3,
TRACK_WIDTH: 12,
CAR_ACCEL: 18.0,
CAR_BRAKE: 25.0,
CAR_MAX_SPEED: 45.0, // m/s (~162 km/h) on asphalt
CAR_REVERSE_MAX: 10.0,
STEER_SENSITIVITY: 2.8, // radians per second at full steer
DRAG_COEFFICIENT: 0.3, // air drag factor
SURFACE_FRICTION: {
asphalt: { grip: 1.0, speedMult: 1.0, color: 0x444444 },
dirt: { grip: 0.65, speedMult: 0.75, color: 0x8B7355 },
grass: { grip: 0.45, speedMult: 0.55, color: 0x4a7c3f },
mud: { grip: 0.25, speedMult: 0.35, color: 0x5c4033 }
},
// === GAME STATE ===
state: 'menu', // menu | countdown | racing | paused | finished
scene: null, camera: null, renderer: null, clock: null,
car: {
position: new THREE.Vector3(),
velocity: new THREE.Vector3(),
heading: 0, // radians, 0 = +Z direction
speed: 0, // scalar speed (signed)
angularVel: 0, // yaw rotation rate
gear: 1, // 1-5 + N for neutral/reverse
rpm: 800,
driftAngle: 0,
surfaceType: 'asphalt',
onTrack: true,
handbrake: false
},
input: { up: false, down: false, left: false, right: false, handbrake: false },
race: {
lap: 1,
checkpointsPassed: [],
currentCheckpoint: 0,
startTime: 0,
elapsedTime: 0,
lapStartTime: 0,
bestLapTime: Infinity,
countdownValue: 3,
countdownTimer: 0
},
cameraMode: 'chase', // chase | cinematic
cameraPos: new THREE.Vector3(),
cameraTarget: new THREE.Vector3(),
trackPoints: [], // Array of {x, y, z} control points
trackCurve: null, // THREE.CatmullRomCurve3
checkpoints: [], // positions along curve (0-1)
obstacles: [], // {mesh, position, radius}
particles: [], // active particle systems
skidMarks: [], // accumulated skid mark geometries
audioCtx: null,
engineOsc: null,
engineGain: null,
noiseNode: null,
noiseGain: null,
minimapCtx: null,
// Scene references for cleanup
groundMesh: null,
trackMesh: null,
carGroup: null,
sceneryObjects: [],
checkpointGates: []
};
// ── function: generateTrack ──
// ============================================================
// TRACK GENERATION - Procedural rally circuit
// ============================================================
function generateTrack() {
// Define control points for a winding rally track with hills and dips
const rawPoints = [
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(40, 1, -20),
new THREE.Vector3(80, 3, -15),
new THREE.Vector3(110, 5, 10),
new THREE.Vector3(120, 7, 50),
new THREE.Vector3(100, 6, 90),
new THREE.Vector3(60, 4, 110),
new THREE.Vector3(20, 2, 100),
new THREE.Vector3(-20, 1, 85),
new THREE.Vector3(-50, 3, 60),
new THREE.Vector3(-70, 5, 30),
new THREE.Vector3(-80, 4, -10),
new THREE.Vector3(-70, 2, -45),
new THREE.Vector3(-45, 1, -65),
new THREE.Vector3(-15, 0.5, -70),
new THREE.Vector3(10, 0, -55)
];
const curve = new THREE.CatmullRomCurve3(rawPoints, true, 'catmullrom', 0.5);
// Sample points along the curve for track geometry and checkpoints
const numSamples = 400;
const sampledPoints = [];
for (let i = 0; i < numSamples; i++) {
const t = i / numSamples;
const point = curve.getPointAt(t);
const tangent = curve.getTangentAt(t);
sampledPoints.push({
pos: point.clone(),
tangent: tangent.clone(),
normal: new THREE.Vector3(-tangent.z, 0, tangent.x).normalize(),
t: t
});
}
RallyGame.trackCurve = curve;
RallyGame.trackPoints = sampledPoints;
// Define checkpoint positions (evenly spaced around the track)
const numCheckpoints = 8;
RallyGame.checkpoints = [];
for (let i = 0; i < numCheckpoints; i++) {
RallyGame.checkpoints.push(i / numCheckpoints);
}
// Build track mesh using custom geometry
buildTrackMesh(sampledPoints, curve);
return sampledPoints;
}
// ── function: addTrackEdges ──
function addTrackEdges(points) {
const width = RallyGame.TRACK_WIDTH / 2 + 0.3;
for (let side = -1; side <= 1; side += 2) {
const edgePoints = [];
for (let i = 0; i < points.length; i++) {
const p = points[i];
const n = p.normal.clone().multiplyScalar(width * side);
edgePoints.push(new THREE.Vector3(p.pos.x + n.x, p.pos.y + 0.25, p.pos.z + n.z));
}
const lineGeo = new THREE.BufferGeometry().setFromPoints(edgePoints);
const lineMat = new THREE.LineBasicMaterial({ color: 0xffffff, linewidth: 1 });
const line = new THREE.Line(lineGeo, lineMat);
RallyGame.scene.add(line);
}
// Center dashed line
const centerPoints = [];
for (let i = 0; i < points.length; i += 4) {
const p = points[i];
centerPoints.push(new THREE.Vector3(p.pos.x, p.pos.y + 0.22, p.pos.z));
}
// Add gaps by duplicating some points
const dashedPoints = [];
for (let i = 0; i < centerPoints.length - 1; i++) {
if (i % 3 !== 2) {
dashedPoints.push(centerPoints[i]);
} else {
// Gap: add same point twice to create a break
dashedPoints.push(centerPoints[i].clone());
dashedPoints.push(centerPoints[i + 1].clone());
}
}
const centerGeo = new THREE.BufferGeometry().setFromPoints(dashedPoints);
const centerMat = new THREE.LineBasicMaterial({ color: 0xffff00, linewidth: 2 });
const centerLine = new THREE.Line(centerGeo, centerMat);
RallyGame.scene.add(centerLine);
}
// ── function: updateCarPhysics ──
// ============================================================
// CAR PHYSICS - Rally-style driving dynamics
// ============================================================
function updateCarPhysics(dt) {
const car = RallyGame.car;
const input = RallyGame.input;
// Determine surface properties
const trackInfo = getTrackInfo(car.position);
car.surfaceType = trackInfo.surfaceType;
car.onTrack = trackInfo.distance <= RallyGame.TRACK_WIDTH / 2 + 0.5;
const surface = RallyGame.SURFACE_FRICTION[car.surfaceType];
const gripFactor = surface.grip * (car.handbrake ? 0.3 : 1.0);
const speedLimit = RallyGame.CAR_MAX_SPEED * surface.speedMult;
// === ACCELERATION / BRAKING ===
let accelForce = 0;
if (input.up) {
if (car.speed < speedLimit) {
accelForce = RallyGame.CAR_ACCEL * surface.grip;
} else {
accelForce = -5.0; // slight brake at max speed
}
}
if (input.down) {
if (car.speed > 0.5) {
accelForce = -RallyGame.CAR_BRAKE;
} else {
accelForce = -RallyGame.CAR_ACCEL * 0.4; // reverse
}
}
// Apply acceleration along heading direction
const headingX = Math.sin(car.heading);
const headingZ = Math.cos(car.heading);
car.velocity.x += headingX * accelForce * dt;
car.velocity.z += headingZ * accelForce * dt;
// === STEERING ===
let steerInput = 0;
if (input.left) steerInput -= 1;
if (input.right) steerInput += 1;
// Speed-dependent steering: tighter at low speed, stable at high speed
const absSpeed = Math.abs(car.speed);
const speedFactor = Math.max(0.3, 1.0 - absSpeed / RallyGame.CAR_MAX_SPEED * 0.5);
const steerRate = RallyGame.STEER_SENSITIVITY * steerInput * speedFactor;
// Only steer when moving
if (absSpeed > 0.5) {
car.heading += steerRate * dt * Math.sign(car.speed);
}
// === DRAG & FRICTION ===
const dragForce = RallyGame.DRAG_COEFFICIENT * absSpeed;
const surfaceDrag = car.onTrack ? 1.0 : 2.5; // extra drag off-track
if (car.velocity.lengthSq() > 0.01) {
const velMag = car.velocity.length();
const totalDrag = Math.min(dragForce * surfaceDrag + absSpeed * 0.5, velMag);
const dragX = (car.velocity.x / velMag) * totalDrag;
const dragZ = (car.velocity.z / velMag) * totalDrag;
car.velocity.x -= dragX * dt;
car.velocity.z -= dragZ * dt;
}
// === DRIFT MECHANICS ===
// Calculate lateral velocity component
const forwardVel = car.velocity.x * headingX + car.velocity.z * headingZ;
const rightX = Math.cos(car.heading);
const rightZ = -Math.sin(car.heading);
const lateralVel = car.velocity.x * rightX + car.velocity.z * rightZ;
// Apply grip to reduce lateral velocity (grip pulls car toward heading direction)
const gripForce = gripFactor * 8.0;
car.velocity.x -= rightX * lateralVel * Math.min(gripForce * dt, 1.0);
car.velocity.z -= rightZ * lateralVel * Math.min(gripForce * dt, 1.0);
// Calculate drift angle for visual feedback and particle effects
const velocityHeading = Math.atan2(car.velocity.x, car.velocity.z);
car.driftAngle = Math.abs(normalizeAngle(car.heading - velocityHeading));
// === UPDATE POSITION ===
car.position.x += car.velocity.x * dt;
car.position.z += car.velocity.z * dt;
// Get track elevation at current position
const elevPoint = RallyGame.trackCurve.getPointAt(trackInfo.closestT);
const distFromCenter = trackInfo.distance;
// Height follows track with some falloff for off-track areas
const heightFactor = Math.max(0, 1.0 - distFromCenter / (RallyGame.TRACK_WIDTH * 3));
car.position.y = elevPoint.y + heightFactor * 0.5 + 0.2;
// === SPEED & GEAR CALCULATION ===
car.speed = forwardVel;
const absSpeedKmh = Math.abs(car.speed) * 3.6; // m/s to km/h
// Simulated gear system (5 gears + reverse)
if (car.speed < -0.5) {
car.gear = 'R';
} else if (absSpeed < 1) {
car.gear = 'N';
} else if (absSpeedKmh < 40) {
car.gear = 1;
} else if (absSpeedKmh < 80) {
car.gear = 2;
} else if (absSpeedKmh < 130) {
car.gear = 3;
} else if (absSpeedKmh < 180) {
car.gear = 4;
} else {
car.gear = 5;
}
// RPM simulation based on speed and gear
const gearNum = typeof car.gear === 'number' ? car.gear : (car.gear === 'R' ? -1 : 0);
if (gearNum !== 0) {
const gearRatio = [0, 3.5, 2.2, 1.6, 1.2, 0.9][Math.abs(gearNum)] || 1;
car.rpm = Math.max(800, Math.min(7500, absSpeed * gearRatio * 120 + (input.up ? 1500 : 0)));
} else {
car.rpm = input.up ? 3000 : 900;
}
// === OBSTACLE COLLISIONS ===
checkObstacleCollisions();
// === PARTICLE EFFECTS ===
updateParticles(dt, absSpeedKmh);
// === CHECKPOINT SYSTEM ===
updateCheckpoints(trackInfo.closestT);
}
// ── function: normalizeAngle ──
function normalizeAngle(angle) {
while (angle > Math.PI) angle -= Math.PI * 2;
while (angle < -Math.PI) angle += Math.PI * 2;
return angle;
}
// ── function: checkObstacleCollisions ──
function checkObstacleCollisions() {
const car = RallyGame.car;
const carRadius = 1.5;
for (const obstacle of RallyGame.obstacles) {
const dx = car.position.x - obstacle.position.x;
const dz = car.position.z - obstacle.position.z;
const dist = Math.sqrt(dx * dx + dz * dz);
if (dist < carRadius + obstacle.radius) {
// Collision response: bounce and slow down
const normalX = dx / dist;
const normalZ = dz / dist;
// Reflect velocity
const dot = car.velocity.x * normalX + car.velocity.z * normalZ;
if (dot < 0) { // Only if moving toward obstacle
car.velocity.x -= 2 * dot * normalX * 0.5;
car.velocity.z -= 2 * dot * normalZ * 0.5;
// Kill some momentum
car.velocity.multiplyScalar(0.6);
}
// Push car out of obstacle
const pushDist = (carRadius + obstacle.radius - dist) + 0.1;
car.position.x += normalX * pushDist;
car.position.z += normalZ * pushDist;
// Spawn collision sparks
spawnCollisionSparks(car.position.clone());
// Play crash sound
playCrashSound();
}
}
}
// ── function: updateCheckpoints ──
function updateCheckpoints(trackT) {
const race = RallyGame.race;
if (race.currentCheckpoint >= RallyGame.checkpoints.length) return;
const cpT = RallyGame.checkpoints[race.currentCheckpoint];
let diff = trackT - cpT;
// Handle wrap-around near end of lap
if (diff < -0.5) diff += 1.0;
if (diff > 0.5) diff -= 1.0;
if (Math.abs(diff) < 0.04) {
race.currentCheckpoint++;
// Check for lap completion
if (race.currentCheckpoint >= RallyGame.checkpoints.length) {
completeLap();
}
}
}
// ── function: completeLap ──
function completeLap() {
const race = RallyGame.race;
const now = performance.now();
const lapTime = (now - race.lapStartTime) / 1000;
if (lapTime < race.bestLapTime && race.lap > 0) {
race.bestLapTime = lapTime;
}
race.lap++;
race.currentCheckpoint = 0;
race.lapStartTime = now;
// Play lap beep
playBeep(880, 0.15);
if (race.lap > RallyGame.TOTAL_LAPS) {
finishRace();
}
}
// ── function: finishRace ──
function finishRace() {
RallyGame.state = 'finished';
const totalTime = RallyGame.race.elapsedTime;
const bestLap = RallyGame.race.bestLapTime;
document.getElementById('finalTime').textContent = formatTime(totalTime);
document.getElementById('finalBestLap').textContent =
isFinite(bestLap) ? formatTime(bestLap) : '--:--.--';
document.getElementById('finishScreen').style.display = 'flex';
// Play finish tone
playFinishTone();
}
// ── function: updateCamera ──
// ============================================================
// CAMERA SYSTEM - Chase camera with dynamic lag
// ============================================================
function updateCamera(dt) {
const car = RallyGame.car;
const mode = RallyGame.cameraMode;
let targetPos, lookAt;
if (mode === 'chase') {
// Behind and above the car
const camDist = 8 + Math.abs(car.speed) * 0.05; // Pull back at high speed
const camHeight = 3.5 + Math.abs(car.speed) * 0.02;
targetPos = new THREE.Vector3(
car.position.x - Math.sin(car.heading) * camDist,
car.position.y + camHeight,
car.position.z - Math.cos(car.heading) * camDist
);
lookAt = new THREE.Vector3(
car.position.x + Math.sin(car.heading) * 5,
car.position.y + 1.0,
car.position.z + Math.cos(car.heading) * 5
);
} else {
// Cinematic: higher and wider angle
const camDist = 20;
const camHeight = 12;
targetPos = new THREE.Vector3(
car.position.x - Math.sin(car.heading + 0.3) * camDist,
car.position.y + camHeight,
car.position.z - Math.cos(car.heading + 0.3) * camDist
);
lookAt = new THREE.Vector3(
car.position.x,
car.position.y + 0.5,
car.position.z
);
}
// Smooth camera follow with lag for dynamic feel
const lerpFactor = mode === 'chase' ? Math.min(dt * 4.0, 1) : Math.min(dt * 2.5, 1);
RallyGame.cameraPos.lerp(targetPos, lerpFactor);
RallyGame.cameraTarget.lerp(lookAt, lerpFactor);
// Add slight camera shake at high speed or when drifting
if (Math.abs(car.speed) > 20 || car.driftAngle > 0.3) {
const shakeIntensity = Math.min(Math.abs(car.speed) * 0.001 + car.driftAngle * 0.05, 0.1);
RallyGame.cameraPos.x += (Math.random() - 0.5) * shakeIntensity;
RallyGame.cameraPos.y += (Math.random() - 0.5) * shakeIntensity * 0.5;
}
RallyGame.camera.position.copy(RallyGame.cameraPos);
RallyGame.camera.lookAt(RallyGame.cameraTarget);
}
// ── function: generateScenery ──
// ============================================================
// SCENERY GENERATION - Trees, rocks, signs, barriers
// ============================================================
function generateScenery() {
const scene = RallyGame.scene;
// Ground plane (large terrain)
const groundGeo = new THREE.PlaneGeometry(600, 600);
const groundMat = new THREE.MeshLambertMaterial({ color: 0x3d5c2a });
const ground = new THREE.Mesh(groundGeo, groundMat);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -1;
scene.add(ground);
RallyGame.groundMesh = ground;
// Generate trees along the track
generateTrees();
// Generate rocks scattered around
generateRocks();
// Generate signs and banners
generateSigns();
// Generate guard rails on sharp curves
generateGuardRails();
// Generate checkpoint gates (invisible but visible markers)
generateCheckpointMarkers();
// Add some distant hills/mountains for atmosphere
generateDistantHills();
}
// ── function: generateTrees ──
function generateTrees() {
const scene = RallyGame.scene;
const treeCount = 200;
// Pre-create geometries and materials for reuse
const trunkGeo = new THREE.CylinderGeometry(0.15, 0.25, 2, 6);
const trunkMat = new THREE.MeshLambertMaterial({ color: 0x8B4513 });
// Different tree types
const pineConeGeo = new THREE.ConeGeometry(1.5, 3, 7);
const pineMat = new THREE.MeshLambertMaterial({ color: 0x2d6b1e });
const roundCrownGeo = new THREE.SphereGeometry(1.2, 8, 6);
const roundMat = new THREE.MeshLambertMaterial({ color: 0x3a7d2c });
for (let i = 0; i < treeCount; i++) {
// Place trees along the track but off to the sides
const t = Math.random();
const point = RallyGame.trackCurve.getPointAt(t);
const tangent = RallyGame.trackCurve.getTangentAt(t);
const normal = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();
// Random offset from track center (keep off the road)
const side = Math.random() > 0.5 ? 1 : -1;
const distance = RallyGame.TRACK_WIDTH * 0.8 + Math.random() * 40;
const treeX = point.x + normal.x * distance * side;
const treeZ = point.z + normal.z * distance * side;
const treeY = -0.5 + Math.random() * 2; // slight elevation variation
const treeGroup = new THREE.Group();
// Trunk
const trunk = new THREE.Mesh(trunkGeo, trunkMat);
trunk.position.y = 1;
treeGroup.add(trunk);
// Foliage (random type)
if (Math.random() > 0.4) {
// Pine tree
const cone = new THREE.Mesh(pineConeGeo, pineMat);
cone.position.y = 3 + Math.random();
treeGroup.add(cone);
// Second layer for taller pines
if (Math.random() > 0.5) {
const cone2 = new THREE.Mesh(
new THREE.ConeGeometry(1.2, 2.5, 7),
pineMat
);
cone2.position.y = 4.5 + Math.random();
treeGroup.add(cone2);
}
} else {
// Round deciduous tree
const crown = new THREE.Mesh(roundCrownGeo, roundMat);
crown.position.y = 3;
treeGroup.add(crown);
}
// Random scale variation
const scale = 0.7 + Math.random() * 0.6;
treeGroup.scale.set(scale, scale, scale);
treeGroup.position.set(treeX, treeY, treeZ);
scene.add(treeGroup);
RallyGame.sceneryObjects.push({
mesh: treeGroup,
position: new THREE.Vector3(treeX, treeY, treeZ),
radius: 0.5 * scale
});
}
}
// ── function: generateRocks ──
function generateRocks() {
const scene = RallyGame.scene;
const rockCount = 60;
for (let i = 0; i < rockCount; i++) {
const t = Math.random();
const point = RallyGame.trackCurve.getPointAt(t);
const tangent = RallyGame.trackCurve.getTangentAt(t);
const normal = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();
const side = Math.random() > 0.5 ? 1 : -1;
const distance = RallyGame.TRACK_WIDTH * 0.6 + Math.random() * 25;
// Create irregular rock shape
const rockGeo = new THREE.DodecahedronGeometry(0.4 + Math.random() * 0.8, 1);
// Distort vertices for natural look
const positions = rockGeo.attributes.position;
for (let j = 0; j < positions.count; j++) {
positions.setY(j, positions.getY(j) * (0.5 + Math.random() * 0.5));
}
rockGeo.computeVertexNormals();
const shade = 0.3 + Math.random() * 0.2;
const rockMat = new THREE.MeshLambertMaterial({
color: new THREE.Color(shade, shade - 0.05, shade - 0.1)
});
const rock = new THREE.Mesh(rockGeo, rockMat);
rock.position.set(
point.x + normal.x * distance * side,
point.y - 0.2,
point.z + normal.z * distance * side
);
rock.rotation.set(Math.random(), Math.random(), Math.random());
scene.add(rock);
RallyGame.sceneryObjects.push({
mesh: rock,
position: rock.position.clone(),
radius: 0.5
});
}
}
// ── function: generateSigns ──
function generateSigns() {
const scene = RallyGame.scene;
// Directional arrow signs at curves
const curvePoints = [0.15, 0.35, 0.55, 0.75];
for (const t of curvePoints) {
const point = RallyGame.trackCurve.getPointAt(t);
const tangent = RallyGame.trackCurve.getTangentAt(t);
const normal = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();
// Sign post
const postGeo = new THREE.CylinderGeometry(0.05, 0.05, 2.5, 6);
const postMat = new THREE.MeshLambertMaterial({ color: 0x888888 });
const post = new THREE.Mesh(postGeo, postMat);
// Sign board (arrow)
const boardGeo = new THREE.BoxGeometry(1.2, 0.6, 0.05);
const canvas = document.createElement('canvas');
canvas.width = 128;
canvas.height = 64;
const ctx = canvas.getContext('2d');
// Yellow warning sign
ctx.fillStyle = '#ffcc00';
ctx.fillRect(0, 0, 128, 64);
ctx.strokeStyle = '#333';
ctx.lineWidth = 4;
ctx.strokeRect(4, 4, 120, 56);
// Arrow
ctx.fillStyle = '#333';
ctx.beginPath();
ctx.moveTo(90, 32);
ctx.lineTo(40, 15);
ctx.lineTo(40, 49);
ctx.closePath();
ctx.fill();
const texture = new THREE.CanvasTexture(canvas);
const boardMat = new THREE.MeshBasicMaterial({ map: texture });
const board = new THREE.Mesh(boardGeo, boardMat);
board.position.y = 1.5;
// Place sign on the outside of the curve
const signGroup = new THREE.Group();
signGroup.add(post);
signGroup.add(board);
signGroup.position.set(
point.x + normal.x * (RallyGame.TRACK_WIDTH / 2 + 3),
point.y,
point.z + normal.z * (RallyGame.TRACK_WIDTH / 2 + 3)
);
// Face the car approaching direction
signGroup.lookAt(point.x, point.y, point.z);
scene.add(signGroup);
}
// Start/finish banner
const startBanner = createBanner('START', '#ff0000');
const startPoint = RallyGame.trackCurve.getPointAt(0.01);
const startTangent = RallyGame.trackCurve.getTangentAt(0.01);
startBanner.position.set(startPoint.x, startPoint.y + 5, startPoint.z);
startBanner.lookAt(startPoint.x + startTangent.x * 10, startPoint.y + 5, startPoint.z + startTangent.z * 10);
scene.add(startBanner);
}
// ── function: createBanner ──
function createBanner(text, bgColor) {
const group = new THREE.Group();
// Banner fabric
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 80;
const ctx = canvas.getContext('2d');
ctx.fillStyle = bgColor || '#ffffff';
ctx.fillRect(0, 0, 256, 80);
ctx.strokeStyle = '#333';
ctx.lineWidth = 4;
ctx.strokeRect(4, 4, 248, 72);
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 40px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, 128, 42);
const texture = new THREE.CanvasTexture(canvas);
const bannerGeo = new THREE.PlaneGeometry(6, 2);
const bannerMat = new THREE.MeshBasicMaterial({ map: texture, side: THREE.DoubleSide });
const banner = new THREE.Mesh(bannerGeo, bannerMat);
// Support poles
const poleGeo = new THREE.CylinderGeometry(0.08, 0.08, 5, 6);
const poleMat = new THREE.MeshLambertMaterial({ color: 0xaaaaaa });
const leftPole = new THREE.Mesh(poleGeo, poleMat);
leftPole.position.set(-3, -2.5, 0);
group.add(leftPole);
const rightPole = new THREE.Mesh(poleGeo, poleMat);
rightPole.position.set(3, -2.5, 0);
group.add(rightPole);
group.add(banner);
return group;
}
// ── function: generateGuardRails ──
function generateGuardRails() {
const scene = RallyGame.scene;
// Place guard rails on the outside of sharp curves
const railSegments = [
{ start: 0.1, end: 0.2 },
{ start: 0.3, end: 0.4 },
{ start: 0.55, end: 0.65 },
{ start: 0.78, end: 0.88 }
];
const postGeo = new THREE.CylinderGeometry(0.04, 0.04, 1, 4);
const postMat = new THREE.MeshLambertMaterial({ color: 0xcccccc });
for (const seg of railSegments) {
const steps = Math.floor((seg.end - seg.start) * RallyGame.trackPoints.length);
for (let i = 0; i < steps; i += 3) {
const t = seg.start + (i / RallyGame.trackPoints.length);
if (t > seg.end) break;
const point = RallyGame.trackCurve.getPointAt(t);
const tangent = RallyGame.trackCurve.getTangentAt(t);
const normal = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();
// Post on outside of curve
const post = new THREE.Mesh(postGeo, postMat);
post.position.set(
point.x + normal.x * (RallyGame.TRACK_WIDTH / 2 + 1.5),
point.y + 0.5,
point.z + normal.z * (RallyGame.TRACK_WIDTH / 2 + 1.5)
);
scene.add(post);
}
// Rail bar between posts
const railPoints = [];
for (let i = 0; i <= steps; i += 3) {
const t = seg.start + (i / RallyGame.trackPoints.length);
if (t > seg.end) break;
const point = RallyGame.trackCurve.getPointAt(t);
const tangent = RallyGame.trackCurve.getTangentAt(t);
const normal = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();
railPoints.push(new THREE.Vector3(
point.x + normal.x * (RallyGame.TRACK_WIDTH / 2 + 1.5),
point.y + 0.8,
point.z + normal.z * (RallyGame.TRACK_WIDTH / 2 + 1.5)
));
}
if (railPoints.length > 1) {
const railGeo = new THREE.BufferGeometry().setFromPoints(railPoints);
const railMat = new THREE.LineBasicMaterial({ color: 0xdddddd });
scene.add(new THREE.Line(railGeo, railMat));
}
}
}
// ── function: generateCheckpointMarkers ──
function generateCheckpointMarkers() {
// Visual markers for checkpoints (small flags)
for (let i = 0; i < RallyGame.checkpoints.length; i++) {
const t = RallyGame.checkpoints[i];
const point = RallyGame.trackCurve.getPointAt(t);
const flagGeo = new THREE.PlaneGeometry(1, 1.5);
const canvas = document.createElement('canvas');
canvas.width = 64;
canvas.height = 96;
const ctx = canvas.getContext('2d');
// Checkered pattern
const sqSize = 8;
for (let x = 0; x < 64; x += sqSize) {
for (let y = 0; y < 96; y += sqSize) {
ctx.fillStyle = ((x / sqSize + y / sqSize) % 2 === 0) ? '#fff' : '#333';
ctx.fillRect(x, y, sqSize, sqSize);
}
}
const texture = new THREE.CanvasTexture(canvas);
const flagMat = new THREE.MeshBasicMaterial({ map: texture, side: THREE.DoubleSide });
const flag = new THREE.Mesh(flagGeo, flagMat);
flag.position.set(point.x, point.y + 3, point.z);
RallyGame.scene.add(flag);
}
}
// ── function: generateDistantHills ──
function generateDistantHills() {
// Simple distant mountains for atmosphere
const hillCount = 12;
for (let i = 0; i < hillCount; i++) {
const angle = (i / hillCount) * Math.PI * 2;
const distance = 150 + Math.random() * 80;
const height = 15 + Math.random() * 30;
const radius = 20 + Math.random() * 25;
const hillGeo = new THREE.ConeGeometry(radius, height, 6);
const shade = 0.15 + Math.random() * 0.1;
const hillMat = new THREE.MeshLambertMaterial({
color: new THREE.Color(shade, shade + 0.1, shade - 0.05)
});
const hill = new THREE.Mesh(hillGeo, hillMat);
hill.position.set(
Math.cos(angle) * distance,
height / 2 - 5,
Math.sin(angle) * distance
);
RallyGame.scene.add(hill);
}
}
// ── function: updateParticles ──
// ============================================================
// PARTICLE EFFECTS - Dust, skid marks, sparks
// ============================================================
function updateParticles(dt, speedKmh) {
const car = RallyGame.car;
// Remove expired particles
RallyGame.particles = RallyGame.particles.filter(p => p.life > 0);
for (const particle of RallyGame.particles) {
particle.life -= dt;
if (particle.type === 'dust') {
particle.mesh.position.y += dt * 1.5; // rise
particle.mesh.scale.multiplyScalar(1 + dt * 2); // expand
const alpha = Math.max(0, particle.life / particle.maxLife);
particle.mesh.material.opacity = alpha * 0.4;
} else if (particle.type === 'spark') {
particle.mesh.position.add(particle.velocity.clone().multiplyScalar(dt));
particle.velocity.y -= dt * 15; // gravity
const alpha = Math.max(0, particle.life / particle.maxLife);
particle.mesh.material.opacity = alpha;
} else if (particle.type === 'smoke') {
particle.mesh.position.y += dt * 2;
particle.mesh.scale.multiplyScalar(1 + dt * 3);
const alpha = Math.max(0, particle.life / particle.maxLife);
particle.mesh.material.opacity = alpha * 0.3;
}
}
// Spawn dust when drifting or off-road
if (car.driftAngle > 0.2 || !car.onTrack) {
if (Math.random() < 0.6) {
spawnDustParticle(car.position.clone());
}
}
// Spawn smoke from rear wheels at high speed with handbrake
if (car.handbrake && Math.abs(car.speed) > 10) {
if (Math.random() < 0.8) {
spawnSmokeParticle(car.position.clone());
}
}
}
// ── function: spawnDustParticle ──
function spawnDustParticle(position) {
const size = 0.3 + Math.random() * 0.5;
// Create a simple sprite for dust
const canvas = document.createElement('canvas');
canvas.width = 64;
canvas.height = 64;
const ctx = canvas.getContext('2d');
const gradient = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
gradient.addColorStop(0, 'rgba(180, 160, 130, 0.6)');
gradient.addColorStop(0.5, 'rgba(150, 140, 120, 0.3)');
gradient.addColorStop(1, 'rgba(120, 110, 90, 0)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 64, 64);
const texture = new THREE.CanvasTexture(canvas);
const mat = new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthWrite: false
});
const sprite = new THREE.Sprite(mat);
sprite.position.set(
position.x + (Math.random() - 0.5) * 2,
position.y + 0.3,
position.z + (Math.random() - 0.5) * 2
);
sprite.scale.set(size, size, 1);
RallyGame.scene.add(sprite);
RallyGame.particles.push({
mesh: sprite,
type: 'dust',
life: 1.0 + Math.random(),
maxLife: 2.0
});
}
// ── function: spawnSmokeParticle ──
function spawnSmokeParticle(position) {
const size = 0.5 + Math.random() * 0.3;
const canvas = document.createElement('canvas');
canvas.width = 64;
canvas.height = 64;
const ctx = canvas.getContext('2d');
const gradient = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
gradient.addColorStop(0, 'rgba(100, 100, 100, 0.5)');
gradient.addColorStop(0.7, 'rgba(80, 80, 80, 0.2)');
gradient.addColorStop(1, 'rgba(60, 60, 60, 0)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 64, 64);
const texture = new THREE.CanvasTexture(canvas);
const mat = new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthWrite: false
});
const sprite = new THREE.Sprite(mat);
// Position at rear wheels
const carHeading = RallyGame.car.heading;
const offsetX = Math.cos(carHeading) * (Math.random() - 0.5) * 1.5;
const offsetZ = -Math.sin(carHeading) * (Math.random() - 0.5) * 1.5;
sprite.position.set(
position.x + offsetX,
position.y + 0.2,
position.z + offsetZ
);
sprite.scale.set(size, size, 1);
RallyGame.scene.add(sprite);
RallyGame.particles.push({
mesh: sprite,
type: 'smoke',
life: 1.5 + Math.random(),
maxLife: 2.5
});
}
// ── function: spawnCollisionSparks ──
function spawnCollisionSparks(position) {
const sparkCount = 8;
for (let i = 0; i < sparkCount; i++) {
const canvas = document.createElement('canvas');
canvas.width = 32;
canvas.height = 32;
const ctx = canvas.getContext('2d');
const gradient = ctx.createRadialGradient(16, 16, 0, 16, 16, 16);
gradient.addColorStop(0, 'rgba(255, 255, 100, 1)');
gradient.addColorStop(0.3, 'rgba(255, 200, 50, 0.8)');
gradient.addColorStop(1, 'rgba(255, 100, 0, 0)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 32, 32);
const texture = new THREE.CanvasTexture(canvas);
const mat = new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthWrite: false
});
const sprite = new THREE.Sprite(mat);
sprite.position.copy(position);
sprite.scale.set(0.2, 0.2, 1);
RallyGame.scene.add(sprite);
RallyGame.particles.push({
mesh: sprite,
type: 'spark',
life: 0.3 + Math.random() * 0.4,
maxLife: 0.7,
velocity: new THREE.Vector3(
(Math.random() - 0.5) * 8,
Math.random() * 6 + 2,
(Math.random() - 0.5) * 8
)
});
}
}
// ── function: cleanupParticles ──
// Clean up old particles to prevent memory leaks
function cleanupParticles() {
for (const particle of RallyGame.particles) {
if (particle.life <= 0 && particle.mesh.parent) {
RallyGame.scene.remove(particle.mesh);
if (particle.mesh.material.map) {
particle.mesh.material.map.dispose();
}
particle.mesh.material.dispose();
}
}
}
// ── function: initAudio ──
// ============================================================
// AUDIO SYSTEM - Synthesized with Web Audio API
// ============================================================
function initAudio() {
try {
RallyGame.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
// Engine sound: oscillator + gain for pitch/volume control
RallyGame.engineOsc = RallyGame.audioCtx.createOscillator();
RallyGame.engineGain = RallyGame.audioCtx.createGain();
RallyGame.engineFilter = RallyGame.audioCtx.createBiquadFilter();
RallyGame.engineOsc.type = 'sawtooth';
RallyGame.engineOsc.frequency.value = 80;
RallyGame.engineFilter.type = 'lowpass';
RallyGame.engineFilter.frequency.value = 400;
RallyGame.engineFilter.Q.value = 2;
RallyGame.engineGain.gain.value = 0;
RallyGame.engineOsc.connect(RallyGame.engineFilter);
RallyGame.engineFilter.connect(RallyGame.engineGain);
RallyGame.engineGain.connect(RallyGame.audioCtx.destination);
RallyGame.engineOsc.start();
// Noise for skid/gravel sounds
const bufferSize = RallyGame.audioCtx.sampleRate * 2;
const noiseBuffer = RallyGame.audioCtx.createBuffer(1, bufferSize, RallyGame.audioCtx.sampleRate);
const output = noiseBuffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
output[i] = Math.random() * 2 - 1;
}
RallyGame.noiseSource = RallyGame.audioCtx.createBufferSource();
RallyGame.noiseSource.buffer = noiseBuffer;
RallyGame.noiseSource.loop = true;
RallyGame.noiseFilter = RallyGame.audioCtx.createBiquadFilter();
RallyGame.noiseFilter.type = 'bandpass';
RallyGame.noiseFilter.frequency.value = 800;
RallyGame.noiseFilter.Q.value = 1;
RallyGame.noiseGain = RallyGame.audioCtx.createGain();
RallyGame.noiseGain.gain.value = 0;
RallyGame.noiseSource.connect(RallyGame.noiseFilter);
RallyGame.noiseFilter.connect(RallyGame.noiseGain);
RallyGame.noiseGain.connect(RallyGame.audioCtx.destination);
RallyGame.noiseSource.start();
} catch (e) {
console.warn('Audio initialization failed:', e);
}
}
// ── function: updateEngineSound ──
function updateEngineSound() {
if (!RallyGame.audioCtx || !RallyGame.engineOsc) return;
const car = RallyGame.car;
const absSpeed = Math.abs(car.speed);
// Engine pitch based on RPM
const targetFreq = 60 + (car.rpm / 7500) * 200;
RallyGame.engineOsc.frequency.setTargetAtTime(targetFreq, RallyGame.audioCtx.currentTime, 0.05);
// Volume based on throttle and speed
let volume = 0.03;
if (RallyGame.input.up) {
volume = 0.06 + (absSpeed / RallyGame.CAR_MAX_SPEED) * 0.12;
} else if (car.rpm > 1500) {
volume = 0.04;
}
// Muffle when off-track (engine struggling)
if (!car.onTrack) {
RallyGame.engineFilter.frequency.setTargetAtTime(200, RallyGame.audioCtx.currentTime, 0.1);
} else {
RallyGame.engineFilter.frequency.setTargetAtTime(300 + absSpeed * 5, RallyGame.audioCtx.currentTime, 0.1);
}
RallyGame.engineGain.gain.setTargetAtTime(volume, RallyGame.audioCtx.currentTime, 0.05);
// Skid/gravel noise
const isDrifting = car.driftAngle > 0.2;
const isOffRoad = !car.onTrack;
let noiseVol = 0;
if (isDrifting) {
noiseVol = Math.min(car.driftAngle * 0.15, 0.1);
RallyGame.noiseFilter.frequency.setTargetAtTime(600, RallyGame.audioCtx.currentTime, 0.1);
}
if (isOffRoad && absSpeed > 3) {
noiseVol = Math.min(absSpeed * 0.005, 0.08);
RallyGame.noiseFilter.frequency.setTargetAtTime(400 + absSpeed * 20, RallyGame.audioCtx.currentTime, 0.1);
}
RallyGame.noiseGain.gain.setTargetAtTime(noiseVol, RallyGame.audioCtx.currentTime, 0.05);
}
// ── function: playBeep ──
function playBeep(frequency, duration) {
if (!RallyGame.audioCtx) return;
const osc = RallyGame.audioCtx.createOscillator();
const gain = RallyGame.audioCtx.createGain();
osc.type = 'sine';
osc.frequency.value = frequency || 440;
gain.gain.setValueAtTime(0.3, RallyGame.audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, RallyGame.audioCtx.currentTime + (duration || 0.2));
osc.connect(gain);
gain.connect(RallyGame.audioCtx.destination);
osc.start();
osc.stop(RallyGame.audioCtx.currentTime + (duration || 0.2) + 0.05);
}
// ── function: playCrashSound ──
function playCrashSound() {
if (!RallyGame.audioCtx) return;
// Short noise burst for crash
const bufferSize = RallyGame.audioCtx.sampleRate * 0.3;
const buffer = RallyGame.audioCtx.createBuffer(1, bufferSize, RallyGame.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 = RallyGame.audioCtx.createBufferSource();
source.buffer = buffer;
const filter = RallyGame.audioCtx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = 1500;
const gain = RallyGame.audioCtx.createGain();
gain.gain.value = 0.2;
source.connect(filter);
filter.connect(gain);
gain.connect(RallyGame.audioCtx.destination);
source.start();
}
// ── function: playFinishTone ──
function playFinishTone() {
if (!RallyGame.audioCtx) return;
// Victory arpeggio
const notes = [523, 659, 784, 1047]; // C E G C
notes.forEach((freq, i) => {
setTimeout(() => playBeep(freq, 0.3), i * 150);
});
}
// ── function: resumeAudioContext ──
function resumeAudioContext() {
if (RallyGame.audioCtx && RallyGame.audioCtx.state === 'suspended') {
RallyGame.audioCtx.resume();
}
}
// ── function: setupInput ──
// ============================================================
// INPUT HANDLING - Keyboard + Touch controls
// ============================================================
function setupInput() {
// === KEYBOARD CONTROLS ===
document.addEventListener('keydown', (e) => {
resumeAudioContext();
switch(e.code) {
case 'ArrowUp':
case 'KeyW':
RallyGame.input.up = true;
e.preventDefault();
break;
case 'ArrowDown':
case 'KeyS':
RallyGame.input.down = true;
e.preventDefault();
break;
case 'ArrowLeft':
case 'KeyA':
RallyGame.input.left = true;
e.preventDefault();
break;
case 'ArrowRight':
case 'KeyD':
RallyGame.input.right = true;
e.preventDefault();
break;
case 'Space':
RallyGame.car.handbrake = true;
RallyGame.input.handbrake = true;
e.preventDefault();
break;
case 'KeyP':
togglePause();
e.preventDefault();
break;
case 'KeyC':
toggleCamera();
e.preventDefault();
break;
case 'KeyR':
if (RallyGame.state === 'racing' || RallyGame.state === 'paused') {
restartRace();
}
e.preventDefault();
break;
}
});
document.addEventListener('keyup', (e) => {
switch(e.code) {
case 'ArrowUp':
case 'KeyW':
RallyGame.input.up = false;
break;
case 'ArrowDown':
case 'KeyS':
RallyGame.input.down = false;
break;
case 'ArrowLeft':
case 'KeyA':
RallyGame.input.left = false;
break;
case 'ArrowRight':
case 'KeyD':
RallyGame.input.right = false;
break;
case 'Space':
RallyGame.car.handbrake = false;
RallyGame.input.handbrake = false;
break;
}
});
// === TOUCH CONTROLS (Mobile) ===
setupTouchControls();
// Show touch controls on mobile devices
if ('ontouchstart' in window || navigator.maxTouchPoints > 0) {
document.getElementById('touchControls').style.display = 'block';
}
}
// ── function: setupTouchControls ──
function setupTouchControls() {
const dpadBtns = document.querySelectorAll('.dpad-btn');
dpadBtns.forEach(btn => {
const dir = btn.dataset.dir;
btn.addEventListener('touchstart', (e) => {
e.preventDefault();
resumeAudioContext();
switch(dir) {
case 'up': RallyGame.input.up = true; break;
case 'down': RallyGame.input.down = true; break;
case 'left': RallyGame.input.left = true; break;
case 'right': RallyGame.input.right = true; break;
}
}, { passive: false });
btn.addEventListener('touchend', (e) => {
e.preventDefault();
switch(dir) {
case 'up': RallyGame.input.up = false; break;
case 'down': RallyGame.input.down = false; break;
case 'left': RallyGame.input.left = false; break;
case 'right': RallyGame.input.right = false; break;
}
}, { passive: false });
btn.addEventListener('touchcancel', (e) => {
switch(dir) {
case 'up': RallyGame.input.up = false; break;
case 'down': RallyGame.input.down = false; break;
case 'left': RallyGame.input.left = false; break;
case 'right': RallyGame.input.right = false; break;
}
});
});
const actionBtns = document.querySelectorAll('.action-btn');
actionBtns.forEach(btn => {
const action = btn.dataset.action;
btn.addEventListener('touchstart', (e) => {
e.preventDefault();
resumeAudioContext();
switch(action) {
case 'gas': RallyGame.input.up = true; break;
case 'brake': RallyGame.input.down = true; break;
case 'handbrake':
RallyGame.car.handbrake = true;
RallyGame.input.handbrake = true;
break;
}
}, { passive: false });
btn.addEventListener('touchend', (e) => {
e.preventDefault();
switch(action) {
case 'gas': RallyGame.input.up = false; break;
case 'brake': RallyGame.input.down = false; break;
case 'handbrake':
RallyGame.car.handbrake = false;
RallyGame.input.handbrake = false;
break;
}
}, { passive: false });
});
}
// ── function: togglePause ──
function togglePause() {
if (RallyGame.state === 'racing') {
RallyGame.state = 'paused';
document.getElementById('pauseMenu').style.display = 'flex';
} else if (RallyGame.state === 'paused') {
resumeRace();
}
}
// ── function: toggleCamera ──
function toggleCamera() {
RallyGame.cameraMode = RallyGame.cameraMode === 'chase' ? 'cinematic' : 'chase';
}
// ── function: resetInput ──
function resetInput() {
RallyGame.input = { up: false, down: false, left: false, right: false, handbrake: false };
RallyGame.car.handbrake = false;
}
// ── function: updateHUD ──
// ============================================================
// HUD & UI UPDATES - Speedometer, lap info, minimap
// ============================================================
function updateHUD() {
const car = RallyGame.car;
const race = RallyGame.race;
// Speed (km/h)
const speedKmh = Math.abs(car.speed * 3.6).toFixed(0);
document.getElementById('speedValue').textContent = speedKmh;
// Gear display
document.getElementById('gearValue').textContent = car.gear;
// RPM bar
drawRPMBar();
// Lap info
const currentLap = Math.min(race.lap, RallyGame.TOTAL_LAPS);
document.getElementById('lapValue').textContent = `${currentLap}/${RallyGame.TOTAL_LAPS}`;
// Total time
if (race.startTime > 0) {
race.elapsedTime = (performance.now() - race.startTime) / 1000;
document.getElementById('timeValue').textContent = formatTime(race.elapsedTime);
}
// Best lap
if (isFinite(race.bestLapTime)) {
document.getElementById('bestLapValue').textContent = formatTime(race.bestLapTime);
}
// Draw minimap
drawMinimap();
}
// ── function: drawRPMBar ──
function drawRPMBar() {
const canvas = document.getElementById('rpmBar');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = canvas.width;
const h = canvas.height;
ctx.clearRect(0, 0, w, h);
// Background gradient (green -> yellow -> red)
const rpmRatio = RallyGame.car.rpm / 7500;
const fillWidth = w * Math.min(rpmRatio, 1.0);
// Draw segments
for (let x = 0; x < w; x++) {
const ratio = x / w;
let color;
if (ratio < 0.5) {
color = `rgb(${Math.floor(ratio * 2 * 180)}, ${Math.floor(200 - ratio * 2 * 40)}, 0)`;
} else if (ratio < 0.75) {
const t = (ratio - 0.5) / 0.25;
color = `rgb(${Math.floor(180 + t * 75)}, ${Math.floor(160 - t * 140)}, 0)`;
} else {
const t = (ratio - 0.75) / 0.25;
color = `rgb(${Math.floor(255 - t * 50)}, ${Math.floor(20 + t * 0)}, ${Math.floor(t * 30)})`;
}
if (x < fillWidth) {
ctx.fillStyle = color;
ctx.fillRect(x, 0, 1, h);
} else {
ctx.fillStyle = 'rgba(40,40,40,0.5)';
ctx.fillRect(x, 0, 1, h);
}
}
// Redline zone marker
const redlineX = w * (6500 / 7500);
ctx.fillStyle = 'rgba(255,0,0,0.3)';
ctx.fillRect(redlineX, 0, w - redlineX, h);
}
// ── function: drawMinimap ──
function drawMinimap() {
const canvas = document.getElementById('minimap');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = canvas.width;
const h = canvas.height;
// Clear with semi-transparent background for trail effect
ctx.fillStyle = 'rgba(0, 30, 10, 0.85)';
ctx.fillRect(0, 0, w, h);
if (!RallyGame.trackCurve) return;
// Calculate track bounds
let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
for (let i = 0; i < RallyGame.trackPoints.length; i += 5) {
const p = RallyGame.trackPoints[i].pos;
if (p.x < minX) minX = p.x;
if (p.x > maxX) maxX = p.x;
if (p.z < minZ) minZ = p.z;
if (p.z > maxZ) maxZ = p.z;
}
const padding = 20;
const scaleX = (w - padding * 2) / (maxX - minX || 1);
const scaleZ = (h - padding * 2) / (maxZ - minZ || 1);
const scale = Math.min(scaleX, scaleZ);
const offsetX = (w - (maxX - minX) * scale) / 2;
const offsetZ = (h - (maxZ - minZ) * scale) / 2;
// Draw track outline
ctx.strokeStyle = 'rgba(100, 100, 100, 0.6)';
ctx.lineWidth = RallyGame.TRACK_WIDTH * scale * 0.8;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
for (let i = 0; i <= RallyGame.trackPoints.length; i++) {
const idx = i % RallyGame.trackPoints.length;
const p = RallyGame.trackPoints[idx].pos;
const x = (p.x - minX) * scale + offsetX;
const z = (p.z - minZ) * scale + offsetZ;
if (i === 0) ctx.moveTo(x, z);
else ctx.lineTo(x, z);
}
ctx.stroke();
// Draw track center line
ctx.strokeStyle = 'rgba(180, 180, 180, 0.4)';
ctx.lineWidth = 2;
ctx.beginPath();
for (let i = 0; i <= RallyGame.trackPoints.length; i++) {
const idx = i % RallyGame.trackPoints.length;
const p = RallyGame.trackPoints[idx].pos;
const x = (p.x - minX) * scale + offsetX;
const z = (p.z - minZ) * scale + offsetZ;
if (i === 0) ctx.moveTo(x, z);
else ctx.lineTo(x, z);
}
ctx.stroke();
// Draw start/finish line marker
const startPoint = RallyGame.trackCurve.getPointAt(0.01);
const sx = (startPoint.x - minX) * scale + offsetX;
const sz = (startPoint.z - minZ) * scale + offsetZ;
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(sx - 4, sz);
ctx.lineTo(sx + 4, sz);
ctx.stroke();
// Draw car position (with heading indicator)
const carPos = RallyGame.car.position;
const cx = (carPos.x - minX) * scale + offsetX;
const cz = (carPos.z - minZ) * scale + offsetZ;
// Car dot
ctx.fillStyle = '#ff3333';
ctx.beginPath();
ctx.arc(cx, cz, 4, 0, Math.PI * 2);
ctx.fill();
// Heading indicator line
const headLen = 10;
ctx.strokeStyle = '#ffcc00';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(cx, cz);
ctx.lineTo(
cx + Math.sin(RallyGame.car.heading) * headLen,
cz + Math.cos(RallyGame.car.heading) * headLen
);
ctx.stroke();
// Car glow effect
ctx.fillStyle = 'rgba(255, 50, 50, 0.3)';
ctx.beginPath();
ctx.arc(cx, cz, 8, 0, Math.PI * 2);
ctx.fill();
}
// ── function: formatTime ──
function formatTime(seconds) {
if (!isFinite(seconds)) return '--:--.--';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toFixed(2).padStart(5, '0')}`;
}
// ── function: updateCountdown ──
// Countdown display
function updateCountdown(dt) {
RallyGame.race.countdownTimer += dt;
if (RallyGame.race.countdownTimer >= 1.0) {
RallyGame.race.countdownTimer = 0;
RallyGame.race.countdownValue--;
const textEl = document.getElementById('countdownText');
if (RallyGame.race.countdownValue > 0) {
textEl.textContent = RallyGame.race.countdownValue.toString();
textEl.style.opacity = '1';
textEl.style.color = RallyGame.race.countdownValue === 1 ? '#ffcc00' : '#ffffff';
playBeep(440 + (3 - RallyGame.race.countdownValue) * 220, 0.2);
} else {
textEl.textContent = 'GO!';
textEl.style.opacity = '1';
textEl.style.color = '#00ff00';
playBeep(880, 0.3);
// Start the race after GO!
setTimeout(() => {
RallyGame.state = 'racing';
RallyGame.race.startTime = performance.now();
RallyGame.race.lapStartTime = performance.now();
document.getElementById('countdownText').style.opacity = '0';
document.getElementById('hud').style.display = 'block';
}, 500);
return; // Stop countdown updates
}
// Fade out after a moment
setTimeout(() => {
textEl.style.opacity = '0';
}, 700);
}
}
// ── function: initScene ──
// ============================================================
// SCENE SETUP - Three.js renderer, lighting, fog
// ============================================================
function initScene() {
// Renderer
const canvas = document.getElementById('gameCanvas');
RallyGame.renderer = new THREE.WebGLRenderer({
canvas: canvas,
antialias: true,
alpha: false
});
RallyGame.renderer.setSize(window.innerWidth, window.innerHeight);
RallyGame.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
RallyGame.renderer.shadowMap.enabled = true;
RallyGame.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
RallyGame.renderer.toneMapping = THREE.ACESFilmicToneMapping;
RallyGame.toneMappingExposure = 1.0;
// Scene
RallyGame.scene = new THREE.Scene();
// Sky color (gradient-like via background)
RallyGame.scene.background = new THREE.Color(0x87CEEB);
// Fog for distance fade
RallyGame.scene.fog = new THREE.FogExp2(0xc4d8e8, 0.006);
// Camera
RallyGame.camera = new THREE.PerspectiveCamera(
65,
window.innerWidth / window.innerHeight,
0.1,
500
);
// === LIGHTING ===
// Ambient light (soft fill)
const ambientLight = new THREE.AmbientLight(0x6688aa, 0.5);
RallyGame.scene.add(ambientLight);
// Hemisphere light (sky/ground color blend)
const hemiLight = new THREE.HemisphereLight(0x87CEEB, 0x3d5c2a, 0.4);
RallyGame.scene.add(hemiLight);
// Directional sun light with shadows
const sunLight = new THREE.DirectionalLight(0xffeedd, 1.0);
sunLight.position.set(50, 80, 30);
sunLight.castShadow = true;
// Configure shadow properties
sunLight.shadow.mapSize.width = 2048;
sunLight.shadow.mapSize.height = 2048;
sunLight.shadow.camera.near = 1;
sunLight.shadow.camera.far = 250;
sunLight.shadow.camera.left = -60;
sunLight.shadow.camera.right = 60;
sunLight.shadow.camera.top = 60;
sunLight.shadow.camera.bottom = -60;
sunLight.shadow.bias = -0.001;
RallyGame.scene.add(sunLight);
RallyGame.sunLight = sunLight;
// Secondary fill light from opposite side
const fillLight = new THREE.DirectionalLight(0x8899bb, 0.3);
fillLight.position.set(-30, 40, -20);
RallyGame.scene.add(fillLight);
// === RESIZE HANDLER ===
window.addEventListener('resize', onWindowResize);
// Clock for delta time
RallyGame.clock = new THREE.Clock();
}
// ── function: onWindowResize ──
function onWindowResize() {
if (!RallyGame.camera || !RallyGame.renderer) return;
RallyGame.camera.aspect = window.innerWidth / window.innerHeight;
RallyGame.camera.updateProjectionMatrix();
RallyGame.renderer.setSize(window.innerWidth, window.innerHeight);
}
// ── function: initGame ──
// ============================================================
// GAME INITIALIZATION & RESET
// ============================================================
function initGame() {
// Initialize Three.js scene
initScene();
// Generate track and scenery
generateTrack();
generateScenery();
// Create car model
RallyGame.carGroup = createCarModel();
RallyGame.scene.add(RallyGame.carGroup);
// Position car at start of track
resetCarPosition();
// Setup input handlers
setupInput();
// Initialize audio (will activate on first user interaction)
initAudio();
// Get minimap context
RallyGame.minimapCtx = document.getElementById('minimap').getContext('2d');
// Setup UI button handlers
document.getElementById('startBtn').addEventListener('click', startRace);
document.getElementById('resumeBtn').addEventListener('click', resumeRace);
document.getElementById('restartPauseBtn').addEventListener('click', restartRace);
document.getElementById('restartFinishBtn').addEventListener('click', () => {
document.getElementById('finishScreen').style.display = 'none';
restartRace();
});
// Start render loop
requestAnimationFrame(gameLoop);
}
// ── function: resetCarPosition ──
function resetCarPosition() {
const startPoint = RallyGame.trackCurve.getPointAt(0.01);
const tangent = RallyGame.trackCurve.getTangentAt(0.01);
RallyGame.car.position.copy(startPoint);
RallyGame.car.position.y += 0.5;
RallyGame.car.velocity.set(0, 0, 0);
RallyGame.car.heading = Math.atan2(tangent.x, tangent.z);
RallyGame.car.speed = 0;
RallyGame.car.angularVel = 0;
RallyGame.car.driftAngle = 0;
RallyGame.car.gear = 'N';
RallyGame.car.rpm = 800;
RallyGame.car.handbrake = false;
// Update car mesh position
updateCarMesh();
// Reset camera position to behind car
const camDist = 12;
RallyGame.cameraPos.set(
RallyGame.car.position.x - Math.sin(RallyGame.car.heading) * camDist,
RallyGame.car.position.y + 4,
RallyGame.car.position.z - Math.cos(RallyGame.car.heading) * camDist
);
RallyGame.cameraTarget.copy(RallyGame.car.position);
}
// ── function: updateCarMesh ──
function updateCarMesh() {
if (!RallyGame.carGroup) return;
const car = RallyGame.car;
// Position
RallyGame.carGroup.position.copy(car.position);
// Rotation (heading + slight tilt based on drift/steering)
RallyGame.carGroup.rotation.y = car.heading;
// Body roll during turns
const steerInput = (RallyGame.input.left ? -1 : 0) + (RallyGame.input.right ? 1 : 0);
const speedFactor = Math.abs(car.speed) / RallyGame.CAR_MAX_SPEED;
RallyGame.carGroup.rotation.z = -steerInput * car.driftAngle * 0.3;
// Wheel rotation based on speed
if (RallyGame.wheels) {
const wheelRot = car.speed * 0.5;
RallyGame.wheels.forEach((wheel, i) => {
wheel.children[0].rotation.x += wheelRot * 0.167; // approximate per-frame rotation
// Front wheels steer
if (i < 2) {
const steerAngle = steerInput * 0.35 * Math.min(speedFactor + 0.2, 1);
wheel.rotation.y = -steerAngle;
}
});
}
// Update shadow light to follow car
if (RallyGame.sunLight && RallyGame.sunLight.target) {
RallyGame.sunLight.position.set(
car.position.x + 50,
80,
car.position.z + 30
);
RallyGame.sunLight.target.position.copy(car.position);
RallyGame.sunLight.target.updateMatrixWorld();
}
}
// ── function: startRace ──
function startRace() {
document.getElementById('startScreen').style.display = 'none';
// Reset race state
RallyGame.race.lap = 1;
RallyGame.race.currentCheckpoint = 0;
RallyGame.race.startTime = 0;
RallyGame.race.elapsedTime = 0;
RallyGame.race.bestLapTime = Infinity;
RallyGame.race.countdownValue = 3;
RallyGame.race.countdownTimer = 0;
resetCarPosition();
resetInput();
// Start countdown
RallyGame.state = 'countdown';
const textEl = document.getElementById('countdownText');
textEl.textContent = '3';
textEl.style.opacity = '1';
textEl.style.color = '#ffffff';
}
// ── function: resumeRace ──
function resumeRace() {
RallyGame.state = 'racing';
document.getElementById('pauseMenu').style.display = 'none';
// Resume audio context if needed
resumeAudioContext();
}
// ── function: restartRace ──
function restartRace() {
// Clean up particles
for (const particle of RallyGame.particles) {
if (particle.mesh.parent) {
RallyGame.scene.remove(particle.mesh);
if (particle.mesh.material.map) particle.mesh.material.map.dispose();
particle.mesh.material.dispose();
}
}
RallyGame.particles = [];
document.getElementById('pauseMenu').style.display = 'none';
document.getElementById('finishScreen').style.display = 'none';
startRace();
}
// ── let: lastParticleCleanup ──
// ============================================================
// MAIN GAME LOOP - Render + update cycle
// ============================================================
let lastParticleCleanup = 0;
// ── function: gameLoop ──
function gameLoop() {
requestAnimationFrame(gameLoop);
const dt = Math.min(RallyGame.clock.getDelta(), 0.05); // Cap delta time
if (RallyGame.state === 'countdown') {
updateCountdown(dt);
// Still render the scene during countdown
updateCarMesh();
updateCamera(dt);
RallyGame.renderer.render(RallyGame.scene, RallyGame.camera);
return;
}
if (RallyGame.state === 'paused' || RallyGame.state === 'finished') {
// Just render without updating physics
RallyGame.renderer.render(RallyGame.scene, RallyGame.camera);
return;
}
if (RallyGame.state !== 'racing') return;
// === UPDATE PHYSICS ===
updateCarPhysics(dt);
// === UPDATE CAR MESH ===
updateCarMesh();
// === UPDATE CAMERA ===
updateCamera(dt);
// === UPDATE PARTICLES ===
const speedKmh = Math.abs(RallyGame.car.speed * 3.6);
updateParticles(dt, speedKmh);
// Periodic particle cleanup (every second)
lastParticleCleanup += dt;
if (lastParticleCleanup > 1.0) {
lastParticleCleanup = 0;
cleanupParticles();
}
// === UPDATE AUDIO ===
updateEngineSound();
// === UPDATE HUD ===
updateHUD();
// === RENDER ===
RallyGame.renderer.render(RallyGame.scene, RallyGame.camera);
}
// ── function: buildTrackMesh ──
function buildTrackMesh(points, curve) {
const width = RallyGame.TRACK_WIDTH;
const segments = points.length;
const vertices = [];
const uvs = [];
const indices = [];
const colors = [];
for (let i = 0; i < segments; i++) {
const p = points[i];
const n = p.normal.clone().multiplyScalar(width / 2);
// Left and right edge vertices (track surface)
const left = new THREE.Vector3(p.pos.x - n.x, p.pos.y + 0.15, p.pos.z - n.z);
const right = new THREE.Vector3(p.pos.x + n.x, p.pos.y + 0.15, p.pos.z + n.z);
// Shoulder vertices (wider)
const shoulderWidth = width * 0.8;
const sn = p.normal.clone().multiplyScalar(shoulderWidth / 2);
const leftShoulder = new THREE.Vector3(p.pos.x - sn.x, p.pos.y + 0.05, p.pos.z - sn.z);
const rightShoulder = new THREE.Vector3(p.pos.x + sn.x, p.pos.y + 0.05, p.pos.z + sn.z);
// Main track surface (asphalt)
vertices.push(left.x, left.y, left.z);
vertices.push(right.x, right.y, right.z);
const v = i / segments;
uvs.push(0, v * 20);
uvs.push(1, v * 20);
// Asphalt color with slight variation
const shade = 0.3 + Math.random() * 0.05;
colors.push(shade, shade, shade + 0.02);
colors.push(shade, shade, shade + 0.02);
// Shoulder (dirt/gravel)
vertices.push(leftShoulder.x, leftShoulder.y, leftShoulder.z);
vertices.push(rightShoulder.x, rightShoulder.y, rightShoulder.z);
uvs.push(0, v * 20);
uvs.push(1, v * 20);
colors.push(0.55, 0.45, 0.35);
colors.push(0.55, 0.45, 0.35);
// Indices for track surface quad (vertices 0,1 of current and next)
const base = i * 4;
const nextBase = ((i + 1) % segments) * 4;
// Track surface triangles
indices.push(base, base + 1, nextBase);
indices.push(base + 1, nextBase + 1, nextBase);
// Left shoulder strip (2 triangles)
indices.push(base, nextBase, base + 2);
indices.push(nextBase, nextBase + 2, base + 2);
// Right shoulder strip (2 triangles)
indices.push(base + 1, nextBase + 3, nextBase + 1);
indices.push(nextBase + 1, nextBase + 3, base + 3);
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
geometry.setIndex(indices);
geometry.computeVertexNormals();
const material = new THREE.MeshLambertMaterial({
vertexColors: true,
side: THREE.DoubleSide
});
RallyGame.trackMesh = new THREE.Mesh(geometry, material);
RallyGame.scene.add(RallyGame.trackMesh);
// Add track edge lines (white dashed)
addTrackEdges(points);
}
// ── function: getTrackInfo ──
// Find closest point on track and determine surface type
function getTrackInfo(position) {
const curve = RallyGame.trackCurve;
// Binary search for closest t value using the curve's built-in methods
let bestT = 0;
let bestDistSq = Infinity;
// Coarse search: sample at regular intervals
const coarseSteps = 100;
for (let i = 0; i <= coarseSteps; i++) {
const t = i / coarseSteps;
const point = curve.getPointAt(t);
const dx = position.x - point.x;
const dz = position.z - point.z;
const distSq = dx * dx + dz * dz;
if (distSq < bestDistSq) {
bestDistSq = distSq;
bestT = t;
}
}
// Fine search: refine around the best coarse result
let fineBestT = bestT;
let fineBestDistSq = bestDistSq;
const step = 1 / (coarseSteps * 20); // Much finer resolution
for (let t = Math.max(0, bestT - step * 5); t <= Math.min(1, bestT + step * 5); t += step) {
const point = curve.getPointAt(t);
const dx = position.x - point.x;
const dz = position.z - point.z;
const distSq = dx * dx + dz * dz;
if (distSq < fineBestDistSq) {
fineBestDistSq = distSq;
fineBestT = t;
}
}
const distance = Math.sqrt(fineBestDistSq);
const halfWidth = RallyGame.TRACK_WIDTH / 2;
let surfaceType = 'asphalt';
if (distance > halfWidth && distance <= halfWidth * 1.6) {
surfaceType = 'dirt';
} else if (distance > halfWidth * 1.6 && distance <= halfWidth * 2.5) {
surfaceType = 'grass';
} else if (distance > halfWidth * 2.5) {
surfaceType = 'mud';
}
return { distance, closestT: fineBestT, surfaceType };
}
// ── function: createCarModel ──
// ============================================================
// CAR MODEL - Low-poly rally car built from primitives
// ============================================================
function createCarModel() {
const group = new THREE.Group();
// Main body (lower) - bright rally red
const bodyGeo = new THREE.BoxGeometry(2.0, 0.65, 4.2);
const bodyMat = new THREE.MeshLambertMaterial({ color: 0xdd2222 });
const body = new THREE.Mesh(bodyGeo, bodyMat);
body.position.y = 0.5;
group.add(body);
// Body side stripes (white racing stripe)
const stripeGeo = new THREE.BoxGeometry(2.02, 0.15, 4.0);
const stripeMat = new THREE.MeshLambertMaterial({ color: 0xffffff });
const stripe = new THREE.Mesh(stripeGeo, stripeMat);
stripe.position.y = 0.3;
group.add(stripe);
// Cabin (upper) - dark tinted glass look
const cabinGeo = new THREE.BoxGeometry(1.75, 0.6, 2.1);
const cabinMat = new THREE.MeshLambertMaterial({ color: 0x2a2a3a });
const cabin = new THREE.Mesh(cabinGeo, cabinMat);
cabin.position.set(0, 1.12, -0.25);
group.add(cabin);
// Windshield (front glass)
const windshieldGeo = new THREE.BoxGeometry(1.6, 0.55, 0.08);
const glassMat = new THREE.MeshLambertMaterial({
color: 0x99bbdd, transparent: true, opacity: 0.5
});
const windshield = new THREE.Mesh(windshieldGeo, glassMat);
windshield.position.set(0, 1.1, 0.78);
windshield.rotation.x = -0.3;
group.add(windshield);
// Rear window
const rearWindow = new THREE.Mesh(windshieldGeo.clone(), glassMat);
rearWindow.position.set(0, 1.1, -1.25);
rearWindow.rotation.x = 0.3;
group.add(rearWindow);
// Side windows
const sideWinGeo = new THREE.BoxGeometry(0.08, 0.45, 1.6);
const leftSideWin = new THREE.Mesh(sideWinGeo, glassMat);
leftSideWin.position.set(-0.87, 1.12, -0.25);
group.add(leftSideWin);
const rightSideWin = new THREE.Mesh(sideWinGeo.clone(), glassMat);
rightSideWin.position.set(0.87, 1.12, -0.25);
group.add(rightSideWin);
// Spoiler arms and wing
const spoilerArmGeo = new THREE.BoxGeometry(0.1, 0.45, 0.1);
const spoilerMat = new THREE.MeshLambertMaterial({ color: 0x222222 });
const leftArm = new THREE.Mesh(spoilerArmGeo, spoilerMat);
leftArm.position.set(-0.75, 1.35, -1.85);
group.add(leftArm);
const rightArm = new THREE.Mesh(spoilerArmGeo, spoilerMat);
rightArm.position.set(0.75, 1.35, -1.85);
group.add(rightArm);
const spoilerWingGeo = new THREE.BoxGeometry(2.4, 0.08, 0.45);
const spoilerWingMat = new THREE.MeshLambertMaterial({ color: 0x1a1a1a });
const spoilerWing = new THREE.Mesh(spoilerWingGeo, spoilerWingMat);
spoilerWing.position.set(0, 1.58, -1.85);
group.add(spoilerWing);
// Wheels with detailed rims
const wheelGeo = new THREE.CylinderGeometry(0.38, 0.38, 0.28, 16);
const tireMat = new THREE.MeshLambertMaterial({ color: 0x1a1a1a });
const rimGeo = new THREE.CylinderGeometry(0.2, 0.2, 0.3, 8);
const rimMat = new THREE.MeshLambertMaterial({ color: 0xcccccc });
const wheelPositions = [
{ x: -1.15, y: 0.38, z: 1.4 }, // front left
{ x: 1.15, y: 0.38, z: 1.4 }, // front right
{ x: -1.15, y: 0.38, z: -1.4 }, // rear left
{ x: 1.15, y: 0.38, z: -1.4 } // rear right
];
RallyGame.wheels = [];
wheelPositions.forEach((pos) => {
const wheelGroup = new THREE.Group();
const tire = new THREE.Mesh(wheelGeo, tireMat);
tire.rotation.z = Math.PI / 2;
wheelGroup.add(tire);
const rim = new THREE.Mesh(rimGeo, rimMat);
rim.rotation.z = Math.PI / 2;
wheelGroup.add(rim);
wheelGroup.position.set(pos.x, pos.y, pos.z);
group.add(wheelGroup);
RallyGame.wheels.push(wheelGroup);
});
// Headlights (front) - bright with glow effect
const headlightGeo = new THREE.BoxGeometry(0.4, 0.25, 0.12);
const headlightMat = new THREE.MeshLambertMaterial({
color: 0xffffee, emissive: 0xffffaa, emissiveIntensity: 0.8
});
const leftHeadlight = new THREE.Mesh(headlightGeo, headlightMat);
leftHeadlight.position.set(-0.65, 0.55, 2.14);
group.add(leftHeadlight);
const rightHeadlight = new THREE.Mesh(headlightGeo.clone(), headlightMat);
rightHeadlight.position.set(0.65, 0.55, 2.14);
group.add(rightHeadlight);
// Headlight housing (chrome)
const housingGeo = new THREE.BoxGeometry(0.5, 0.35, 0.08);
const chromeMat = new THREE.MeshLambertMaterial({ color: 0xaaaaaa });
const leftHousing = new THREE.Mesh(housingGeo, chromeMat);
leftHousing.position.set(-0.65, 0.55, 2.18);
group.add(leftHousing);
const rightHousing = new THREE.Mesh(housingGeo.clone(), chromeMat);
rightHousing.position.set(0.65, 0.55, 2.18);
group.add(rightHousing);
// Taillights (rear) - bright red with glow
const taillightGeo = new THREE.BoxGeometry(0.35, 0.2, 0.12);
const taillightMat = new THREE.MeshLambertMaterial({
color: 0xff0000, emissive: 0xff2200, emissiveIntensity: 0.6
});
const leftTaillight = new THREE.Mesh(taillightGeo, taillightMat);
leftTaillight.position.set(-0.7, 0.55, -2.14);
group.add(leftTaillight);
const rightTaillight = new THREE.Mesh(taillightGeo.clone(), taillightMat);
rightTaillight.position.set(0.7, 0.55, -2.14);
group.add(rightTaillight);
// Rally number on sides (both sides)
const numberCanvas = document.createElement('canvas');
numberCanvas.width = 128;
numberCanvas.height = 96;
const nctx = numberCanvas.getContext('2d');
nctx.fillStyle = '#ffffff';
nctx.fillRect(0, 0, 128, 96);
nctx.strokeStyle = '#cc2222';
nctx.lineWidth = 5;
nctx.strokeRect(4, 4, 120, 88);
nctx.fillStyle = '#cc2222';
nctx.font = 'bold 60px Arial';
nctx.textAlign = 'center';
nctx.textBaseline = 'middle';
nctx.fillText('7', 64, 52);
const numberTexture = new THREE.CanvasTexture(numberCanvas);
const numberMat = new THREE.MeshBasicMaterial({ map: numberTexture });
const numberGeo = new THREE.PlaneGeometry(0.9, 0.65);
const leftNumber = new THREE.Mesh(numberGeo, numberMat);
leftNumber.position.set(-1.03, 0.7, -0.25);
leftNumber.rotation.y = -Math.PI / 2;
group.add(leftNumber);
const rightNumber = new THREE.Mesh(numberGeo.clone(), numberMat);
rightNumber.position.set(1.03, 0.7, -0.25);
rightNumber.rotation.y = Math.PI / 2;
group.add(rightNumber);
// Front and rear bumpers (black)
const bumperGeo = new THREE.BoxGeometry(2.15, 0.18, 0.15);
const bumperMat = new THREE.MeshLambertMaterial({ color: 0x333333 });
const frontBumper = new THREE.Mesh(bumperGeo, bumperMat);
frontBumper.position.set(0, 0.28, 2.17);
group.add(frontBumper);
const rearBumper = new THREE.Mesh(bumperGeo.clone(), bumperMat);
rearBumper.position.set(0, 0.28, -2.17);
group.add(rearBumper);
// Roof rack (silver bars)
const roofRackGeo = new THREE.BoxGeometry(1.5, 0.06, 1.7);
const roofRackMat = new THREE.MeshLambertMaterial({ color: 0x888888 });
const roofRack = new THREE.Mesh(roofRackGeo, roofRackMat);
roofRack.position.set(0, 1.45, -0.25);
group.add(roofRack);
// Fire extinguisher on roof rack (red cylinder)
const extGeo = new THREE.CylinderGeometry(0.07, 0.07, 0.55, 8);
const extMat = new THREE.MeshLambertMaterial({ color: 0xff3300 });
const extinguisher = new THREE.Mesh(extGeo, extMat);
extinguisher.position.set(0.45, 1.62, -0.55);
group.add(extinguisher);
// Exhaust pipe (rear)
const exhaustGeo = new THREE.CylinderGeometry(0.06, 0.08, 0.3, 8);
const exhaustMat = new THREE.MeshLambertMaterial({ color: 0x555555 });
const exhaust = new THREE.Mesh(exhaustGeo, exhaustMat);
exhaust.rotation.x = Math.PI / 2;
exhaust.position.set(0.6, 0.3, -2.25);
group.add(exhaust);
// Side mirrors
const mirrorGeo = new THREE.BoxGeometry(0.15, 0.12, 0.08);
const mirrorMat = new THREE.MeshLambertMaterial({ color: 0x333333 });
const leftMirror = new THREE.Mesh(mirrorGeo, mirrorMat);
leftMirror.position.set(-1.05, 1.0, 0.6);
group.add(leftMirror);
const rightMirror = new THREE.Mesh(mirrorGeo.clone(), mirrorMat);
rightMirror.position.set(1.05, 1.0, 0.6);
group.add(rightMirror);
return group;
}
// ── main ──
// ============================================================
// MAIN ENTRY POINT - Initialize and start the game
// ============================================================
(function() {
// Wait for Three.js to be loaded before initializing
if (typeof THREE === 'undefined') {
console.error('Three.js not loaded!');
return;
}
// Start the game
initGame();
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen3.6-27b-mtp","provider":"lmstudio","persona":"composer","sessionId":"55ba0957-6332-4ba6-8e90-11e2fcadc26e","tokensIn":1376407,"tokensOut":38271,"tokensTotal":1414678,"turns":42,"toolCalls":41,"failedToolCalls":1,"timestamp":"2026-07-16T04:05:37.057Z"} -->