Rally
thinkingcap qwen3.6 27b · 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 61,186 bytes · SHA-256 5c751b73ba0f
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Rally Racer 3D</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<style>
/* ── css: 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: 100vw; height: 100vh; }
#gameCanvas { display: block; width: 100%; height: 100%; }
/* HUD */
#hud { position: absolute; top: 0; left: 0; right: 0; bottom: 0; pointer-events: none; z-index: 10; }
#speedometer { position: absolute; bottom: 20px; right: 20px; color: #fff; font-size: 36px; font-weight: bold; text-shadow: 2px 2px 4px rgba(0,0,0,0.8); }
#gearDisplay { position: absolute; bottom: 65px; right: 20px; color: #ffcc00; font-size: 18px; font-weight: bold; text-shadow: 1px 1px 3px rgba(0,0,0,0.8); }
#rpmBar { position: absolute; bottom: 95px; right: 20px; width: 160px; height: 14px; background: rgba(0,0,0,0.6); border-radius: 7px; overflow: hidden; border: 1px solid #555; }
#rpmFill { height: 100%; width: 0%; background: linear-gradient(to right, #0f0, #ff0, #f00); transition: width 0.1s; }
#lapInfo { position: absolute; top: 20px; left: 50%; transform: translateX(-50%); color: #fff; font-size: 24px; font-weight: bold; text-shadow: 2px 2px 4px rgba(0,0,0,0.8); }
#timerDisplay { position: absolute; top: 50px; left: 50%; transform: translateX(-50%); color: #fff; font-size: 20px; text-shadow: 1px 1px 3px rgba(0,0,0,0.8); }
#bestLapDisplay { position: absolute; top: 75px; left: 50%; transform: translateX(-50%); color: #ffcc00; font-size: 16px; text-shadow: 1px 1px 3px rgba(0,0,0,0.8); }
#lapTimeDisplay { position: absolute; top: 95px; left: 50%; transform: translateX(-50%); color: #aaa; font-size: 14px; text-shadow: 1px 1px 3px rgba(0,0,0,0.8); }
#minimap { position: absolute; bottom: 20px; left: 20px; border-radius: 8px; border: 2px solid rgba(255,255,255,0.4); background: rgba(0,0,0,0.5); }
/* Countdown */
#countdownOverlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; display: flex; align-items: center; justify-content: center; z-index: 20; background: rgba(0,0,0,0.5); }
#countdownText { font-size: 120px; color: #fff; font-weight: bold; text-shadow: 4px 4px 8px rgba(0,0,0,0.9); animation: pulse 0.5s ease-in-out infinite alternate; }
@keyframes pulse { from { transform: scale(1); } to { transform: scale(1.1); } }
/* Pause Menu */
#pauseMenu { position: absolute; top: 0; left: 0; right: 0; bottom: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 30; background: rgba(0,0,0,0.7); }
#pauseMenu h2 { color: #fff; font-size: 48px; margin-bottom: 30px; text-shadow: 2px 2px 6px rgba(0,0,0,0.9); }
#pauseMenu button, #finishScreen button { pointer-events: auto; padding: 15px 40px; font-size: 20px; margin: 8px; border: none; border-radius: 8px; cursor: pointer; background: linear-gradient(to bottom, #ff6b35, #e84118); color: #fff; font-weight: bold; box-shadow: 0 4px 8px rgba(0,0,0,0.4); }
#pauseMenu button:hover, #finishScreen button:hover { background: linear-gradient(to bottom, #ff8c5a, #f05530); }
/* Finish Screen */
#finishScreen { position: absolute; top: 0; left: 0; right: 0; bottom: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 30; background: rgba(0,0,0,0.8); }
#finishScreen h2 { color: #ffcc00; font-size: 48px; margin-bottom: 20px; text-shadow: 2px 2px 6px rgba(0,0,0,0.9); }
#finishScreen p { color: #fff; font-size: 24px; margin: 5px; }
/* Touch Controls */
#touchControls { position: absolute; bottom: 10px; left: 0; right: 0; z-index: 15; display: flex; justify-content: space-between; padding: 0 20px; pointer-events: none; }
#dpad { display: grid; grid-template-columns: 60px 60px 60px; grid-template-rows: 60px 60px; gap: 4px; pointer-events: auto; }
#actionBtns { display: flex; flex-direction: column; gap: 8px; pointer-events: auto; }
#touchControls button { width: 60px; height: 60px; border-radius: 12px; border: 2px solid rgba(255,255,255,0.4); background: rgba(255,255,255,0.15); color: #fff; font-size: 20px; font-weight: bold; touch-action: none; }
#btnUp { grid-column: 2; grid-row: 1; }
#btnLeft { grid-column: 1; grid-row: 2; }
#btnRight { grid-column: 3; grid-row: 2; }
#btnDown { grid-column: 2; grid-row: 2; }
</style>
</head>
<body>
<canvas id="gameCanvas"></canvas>
<div id="hud" style="display:none;">
<div id="speedometer"><span id="speedVal">0</span> km/h</div>
<div id="gearDisplay">Gear: <span id="gearVal">N</span></div>
<div id="rpmBar"><div id="rpmFill"></div></div>
<div id="lapInfo">Lap <span id="currentLap">1</span>/3</div>
<div id="timerDisplay">00:00.000</div>
<div id="bestLapDisplay">Best Lap: --:--.--</div>
<div id="lapTimeDisplay">Lap Time: 00:00.000</div>
<canvas id="minimap" width="160" height="160"></canvas>
</div>
<div id="countdownOverlay"><span id="countdownText">3</span></div>
<div id="pauseMenu" style="display:none;">
<h2>PAUSED</h2>
<button id="resumeBtn">Resume</button>
<button id="restartPauseBtn">Restart</button>
</div>
<div id="finishScreen" style="display:none;">
<h2>RACE COMPLETE!</h2>
<p>Final Time: <span id="finalTime"></span></p>
<p>Best Lap: <span id="bestLapFinish"></span></p>
<button id="restartBtn">Race Again</button>
</div>
<div id="touchControls" style="display:none;">
<div id="dpad">
<button id="btnUp">▲</button>
<button id="btnLeft">◄</button>
<button id="btnRight">►</button>
<button id="btnDown">▼</button>
</div>
<div id="actionBtns">
<button id="btnGas">GAS</button>
<button id="btnBrake">BRAKE</button>
<button id="btnHandbrake">HB</button>
</div>
</div>
<script>
'use strict';
// ── const: CONFIG ──
// ============================================================
// Rally Racer 3D - Constants & Configuration
// ============================================================
const CONFIG = {
TOTAL_LAPS: 3,
TRACK_POINTS: 200,
TRACK_WIDTH: 14,
CAR_ACCEL: 18,
CAR_BRAKE: 25,
CAR_MAX_SPEED: 65,
CAR_STEER_RATE: 2.8,
DRIFT_FACTOR: 0.92,
OFF_TRACK_SLOWDOWN: 0.4,
CHECKPOINT_COUNT: 12,
CAMERA_DISTANCE: 12,
CAMERA_HEIGHT: 5,
FOG_NEAR: 60,
FOG_FAR: 300,
};
// ── const: TRACK_CONTROL_POINTS ──
// Track control points (hand-crafted winding rally track)
const TRACK_CONTROL_POINTS = [
{ x: 0, z: 0, y: 0 },
{ x: 40, z: -20, y: 1 },
{ x: 80, z: -30, y: 2 },
{ x: 120, z: -10, y: 3 },
{ x: 150, z: 30, y: 4 },
{ x: 160, z: 80, y: 2 },
{ x: 140, z: 130, y: 1 },
{ x: 100, z: 160, y: 5 },
{ x: 50, z: 170, y: 6 },
{ x: 0, z: 150, y: 4 },
{ x: -40, z: 120, y: 3 },
{ x: -70, z: 80, y: 2 },
{ x: -90, z: 30, y: 5 },
{ x: -80, z: -20, y: 4 },
{ x: -60, z: -60, y: 3 },
{ x: -30, z: -80, y: 1 },
];
// ── function: catmullRom ──
// ============================================================
// Track Generation - Catmull-Rom spline interpolation
// ============================================================
function catmullRom(p0, p1, p2, p3, t) {
const t2 = t * t;
const t3 = t2 * t;
return {
x: 0.5 * ((2*p1.x) + (-p0.x+p2.x)*t + (2*p0.x-5*p1.x+4*p2.x-p3.x)*t2 + (-p0.x+3*p1.x-3*p2.x+p3.x)*t3),
y: 0.5 * ((2*p1.y) + (-p0.y+p2.y)*t + (2*p0.y-5*p1.y+4*p2.y-p3.y)*t2 + (-p0.y+3*p1.y-3*p2.y+p3.y)*t3),
z: 0.5 * ((2*p1.z) + (-p0.z+p2.z)*t + (2*p0.z-5*p1.z+4*p2.z-p3.z)*t2 + (-p0.z+3*p1.z-3*p2.z+p3.z)*t3),
};
}
// ── function: generateTrack ──
function generateTrack() {
const pts = TRACK_CONTROL_POINTS;
const n = pts.length;
const trackPoints = [];
for (let i = 0; i < n; i++) {
const p0 = pts[(i - 1 + n) % n];
const p1 = pts[i];
const p2 = pts[(i + 1) % n];
const p3 = pts[(i + 2) % n];
for (let t = 0; t < 1; t += 1 / CONFIG.TRACK_POINTS * n) {
trackPoints.push(catmullRom(p0, p1, p2, p3, t));
}
}
return trackPoints.slice(0, CONFIG.TRACK_POINTS);
}
// ── function: getTrackPoint ──
function getTrackPoint(track, index) {
const i = Math.floor(index) % track.length;
const frac = index - Math.floor(index);
const p1 = track[(i + track.length) % track.length];
const p2 = track[(i + 1) % track.length];
return {
x: p1.x + (p2.x - p1.x) * frac,
y: p1.y + (p2.y - p1.y) * frac,
z: p1.z + (p2.z - p1.z) * frac,
};
}
// ── function: getTrackDirection ──
function getTrackDirection(track, index) {
const p1 = getTrackPoint(track, index);
const p2 = getTrackPoint(track, index + 5);
return { x: p2.x - p1.x, z: p2.z - p1.z };
}
// ── function: nearestTrackIndex ──
function nearestTrackIndex(track, px, pz) {
let minDist = Infinity;
let minIdx = 0;
for (let i = 0; i < track.length; i += 3) {
const dx = px - track[i].x;
const dz = pz - track[i].z;
const d = dx*dx + dz*dz;
if (d < minDist) { minDist = d; minIdx = i; }
}
// Refine search around best index
for (let i = Math.max(0, minIdx-15); i <= Math.min(track.length-1, minIdx+15); i++) {
const dx = px - track[i].x;
const dz = pz - track[i].z;
const d = dx*dx + dz*dz;
if (d < minDist) { minDist = d; minIdx = i; }
}
return minIdx;
}
// ── function: getDistanceFromTrack ──
function getDistanceFromTrack(track, px, pz) {
let minDist = Infinity;
for (let i = 0; i < track.length; i += 2) {
const dx = px - track[i].x;
const dz = pz - track[i].z;
const d = Math.sqrt(dx*dx + dz*dz);
if (d < minDist) minDist = d;
}
return minDist;
}
// ── function: createCarState ──
// ============================================================
// Car Physics State & Update
// ============================================================
function createCarState() {
return {
x: 0, y: 0, z: 0,
heading: 0, // radians, facing +z direction initially
speed: 0,
steerAngle: 0,
gear: 1,
rpm: 800,
driftFactor: 0,
onTrack: true,
surfaceType: 'asphalt',
skidding: false,
};
}
// ── function: updateCarPhysics ──
function updateCarPhysics(car, input, track, dt) {
// Clamp dt to avoid physics explosions
dt = Math.min(dt, 0.05);
const distFromTrack = getDistanceFromTrack(track, car.x, car.z);
car.onTrack = distFromTrack < CONFIG.TRACK_WIDTH * 0.6;
if (distFromTrack > CONFIG.TRACK_WIDTH * 1.2) {
car.surfaceType = 'mud';
} else if (!car.onTrack) {
car.surfaceType = 'grass';
} else {
car.surfaceType = 'asphalt';
}
// Surface modifiers
let speedMult = 1;
let gripMult = 1;
switch (car.surfaceType) {
case 'grass': speedMult = 0.65; gripMult = 0.7; break;
case 'mud': speedMult = 0.35; gripMult = 0.4; break;
default: speedMult = 1; gripMult = 1;
}
const maxSpeed = CONFIG.CAR_MAX_SPEED * speedMult;
// Acceleration / braking
if (input.gas) {
car.speed += CONFIG.CAR_ACCEL * dt;
} else if (input.brake || input.reverse) {
if (car.speed > 0.5) {
car.speed -= CONFIG.CAR_BRAKE * dt;
} else {
car.speed -= CONFIG.CAR_ACCEL * 0.4 * dt; // reverse slowly
}
} else {
// Rolling resistance
car.speed *= (1 - 1.5 * dt);
}
// Handbrake
if (input.handbrake) {
car.speed *= (1 - 3 * dt);
gripMult *= 0.3;
car.skidding = true;
} else {
car.skidding = false;
}
car.speed = Math.max(-maxSpeed * 0.25, Math.min(maxSpeed, car.speed));
// Steering (speed-dependent)
const steerFactor = Math.max(0.3, 1 - Math.abs(car.speed) / maxSpeed * 0.6);
if (input.left) {
car.steerAngle += CONFIG.CAR_STEER_RATE * steerFactor * dt;
} else if (input.right) {
car.steerAngle -= CONFIG.CAR_STEER_RATE * steerFactor * dt;
} else {
car.steerAngle *= (1 - 5 * dt); // auto-center
}
car.steerAngle = Math.max(-0.8, Math.min(0.8, car.steerAngle));
// Turning with drift physics
const turnRate = car.steerAngle * (Math.abs(car.speed) / maxSpeed) * gripMult;
car.heading += turnRate * dt * Math.sign(car.speed);
// Drift factor for visual effects
if (car.skidding || !car.onTrack) {
car.driftFactor = Math.min(1, car.driftFactor + dt * 3);
} else {
car.driftFactor = Math.max(0, car.driftFactor - dt * 4);
}
// Move car forward
const moveSpeed = car.speed * dt;
car.x += Math.sin(car.heading) * moveSpeed;
car.z -= Math.cos(car.heading) * moveSpeed;
// Elevation from track
const nearestIdx = nearestTrackIndex(track, car.x, car.z);
const trackPt = getTrackPoint(track, nearestIdx);
const distToCenter = Math.sqrt((car.x - trackPt.x)**2 + (car.z - trackPt.z)**2);
// Smooth elevation transition
if (distToCenter < CONFIG.TRACK_WIDTH * 1.5) {
car.y += (trackPt.y - car.y) * dt * 8;
} else {
car.y += (0 - car.y) * dt * 3;
}
// Gear simulation
const absSpeed = Math.abs(car.speed);
if (absSpeed < 1) { car.gear = car.speed >= 0 ? 1 : 'R'; car.rpm = 800 + absSpeed * 50; }
else if (absSpeed < maxSpeed * 0.2) { car.gear = 1; car.rpm = 800 + absSpeed * 60; }
else if (absSpeed < maxSpeed * 0.4) { car.gear = 2; car.rpm = 800 + (absSpeed - maxSpeed*0.2) * 50; }
else if (absSpeed < maxSpeed * 0.6) { car.gear = 3; car.rpm = 800 + (absSpeed - maxSpeed*0.4) * 50; }
else if (absSpeed < maxSpeed * 0.8) { car.gear = 4; car.rpm = 800 + (absSpeed - maxSpeed*0.6) * 50; }
else { car.gear = 5; car.rpm = 800 + (absSpeed - maxSpeed*0.8) * 50; }
if (car.speed < 0 && car.gear !== 'R') { car.gear = 'R'; car.rpm = 800 + absSpeed * 60; }
car.rpm = Math.min(7500, Math.max(800, car.rpm));
}
// ── function: buildCarMesh ──
// ============================================================
// Build Rally Car Mesh (low-poly)
// ============================================================
function buildCarMesh() {
const group = new THREE.Group();
// Main body
const bodyGeo = new THREE.BoxGeometry(2.2, 1, 4.5);
const bodyMat = new THREE.MeshLambertMaterial({ color: 0xe83020 });
const body = new THREE.Mesh(bodyGeo, bodyMat);
body.position.y = 0.7;
group.add(body);
// Cabin / roof
const cabinGeo = new THREE.BoxGeometry(1.9, 0.7, 2);
const cabinMat = new THREE.MeshLambertMaterial({ color: 0xcccccc });
const cabin = new THREE.Mesh(cabinGeo, cabinMat);
cabin.position.set(0, 1.45, -0.3);
group.add(cabin);
// Windshield (front)
const windGeo = new THREE.BoxGeometry(1.8, 0.6, 0.1);
const windMat = new THREE.MeshLambertMaterial({ color: 0x4488cc });
const windshield = new THREE.Mesh(windGeo, windMat);
windshield.position.set(0, 1.35, -1.25);
group.add(windshield);
// Rear window
const rearWind = new THREE.Mesh(new THREE.BoxGeometry(1.8, 0.6, 0.1), windMat);
rearWind.position.set(0, 1.35, 0.65);
group.add(rearWind);
// Spoiler
const spoilerGeo = new THREE.BoxGeometry(2.4, 0.1, 0.5);
const spoilerMat = new THREE.MeshLambertMaterial({ color: 0x333333 });
const spoiler = new THREE.Mesh(spoilerGeo, spoilerMat);
spoiler.position.set(0, 1.6, 2);
group.add(spoiler);
// Spoiler supports
for (let side of [-0.8, 0.8]) {
const supGeo = new THREE.BoxGeometry(0.1, 0.4, 0.1);
const sup = new THREE.Mesh(supGeo, spoilerMat);
sup.position.set(side, 1.4, 2);
group.add(sup);
}
// Headlights (front)
for (let side of [-0.7, 0.7]) {
const hlGeo = new THREE.BoxGeometry(0.4, 0.25, 0.1);
const hlMat = new THREE.MeshLambertMaterial({ color: 0xffffcc, emissive: 0xffffaa });
const hl = new THREE.Mesh(hlGeo, hlMat);
hl.position.set(side, 0.7, -2.3);
group.add(hl);
}
// Taillights (rear)
for (let side of [-0.8, 0.8]) {
const tlGeo = new THREE.BoxGeometry(0.4, 0.25, 0.1);
const tlMat = new THREE.MeshLambertMaterial({ color: 0xff0000, emissive: 0xff0000 });
const tl = new THREE.Mesh(tlGeo, tlMat);
tl.position.set(side, 0.7, 2.3);
group.add(tl);
}
// Wheels
const wheelGeo = new THREE.CylinderGeometry(0.4, 0.4, 0.3, 8);
const wheelMat = new THREE.MeshLambertMaterial({ color: 0x222222 });
const wheelPositions = [
[-1.1, 0.4, -1.5], [1.1, 0.4, -1.5],
[-1.1, 0.4, 1.5], [1.1, 0.4, 1.5]
];
const wheels = [];
for (const pos of wheelPositions) {
const wheel = new THREE.Mesh(wheelGeo, wheelMat);
wheel.rotation.z = Math.PI / 2;
wheel.position.set(...pos);
group.add(wheel);
wheels.push(wheel);
}
// Number plate on hood
const numGeo = new THREE.BoxGeometry(0.8, 0.5, 0.05);
const numMat = new THREE.MeshLambertMaterial({ color: 0xffffff });
const numPlate = new THREE.Mesh(numGeo, numMat);
numPlate.position.set(0, 1.2, -2.3);
group.add(numPlate);
return { mesh: group, wheels };
}
// ── function: buildTrackMesh ──
// ============================================================
// Scene Building - Track Mesh, Ground, Scenery
// ============================================================
function buildTrackMesh(track) {
const shape = new THREE.Shape();
// Build track as a series of connected segments
const vertices = [];
const indices = [];
const colors = [];
for (let i = 0; i < track.length; i++) {
const p1 = track[i];
const p2 = track[(i + 1) % track.length];
// Direction vector
let dx = p2.x - p1.x;
let dz = p2.z - p1.z;
const len = Math.sqrt(dx*dx + dz*dz);
if (len === 0) { dx = 0; dz = 1; } else { dx /= len; dz /= len; }
// Perpendicular vector for track width
const px = -dz * CONFIG.TRACK_WIDTH / 2;
const pz = dx * CONFIG.TRACK_WIDTH / 2;
// Left and right vertices
const leftIdx = vertices.length / 3;
const rightIdx = leftIdx + 1;
vertices.push(p1.x - px, p1.y, p1.z - pz);
vertices.push(p1.x + px, p1.y, p1.z + pz);
colors.push(0.25, 0.25, 0.28);
colors.push(0.25, 0.25, 0.28);
if (i < track.length - 1) {
indices.push(leftIdx, rightIdx, leftIdx + 2);
indices.push(rightIdx, rightIdx + 1, leftIdx + 2);
} else {
// Close the loop
const firstLeft = 0;
const firstRight = 1;
indices.push(leftIdx, rightIdx, firstLeft);
indices.push(rightIdx, firstRight, firstLeft);
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
geo.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
geo.setIndex(indices);
geo.computeVertexNormals();
const mat = new THREE.MeshLambertMaterial({ vertexColors: true });
return new THREE.Mesh(geo, mat);
}
// ── function: buildTrackShoulders ──
function buildTrackShoulders(track) {
const group = new THREE.Group();
for (let i = 0; i < track.length; i += 4) {
const p1 = track[i];
const p2 = track[(i + 1) % track.length];
let dx = p2.x - p1.x;
let dz = p2.z - p1.z;
const len = Math.sqrt(dx*dx + dz*dz);
if (len === 0) { dx = 0; dz = 1; } else { dx /= len; dz /= len; }
const px = -dz * CONFIG.TRACK_WIDTH / 2;
const pz = dx * CONFIG.TRACK_WIDTH / 2;
// Gravel shoulders on both sides
for (let side of [-1, 1]) {
const shoulderGeo = new THREE.BoxGeometry(3, 0.15, len + 0.5);
const shoulderMat = new THREE.MeshLambertMaterial({ color: 0x8B7D6B });
const shoulder = new THREE.Mesh(shoulderGeo, shoulderMat);
shoulder.position.set(
p1.x + px * side * 1.3,
p1.y - 0.05,
p1.z + pz * side * 1.3
);
const angle = Math.atan2(dx, dz);
shoulder.rotation.y = angle;
group.add(shoulder);
}
}
return group;
}
// ── function: buildGround ──
function buildGround() {
// Large ground plane with grass color
const geo = new THREE.PlaneGeometry(800, 800, 20, 20);
const mat = new THREE.MeshLambertMaterial({ color: 0x3a7d32 });
// Add some vertex displacement for terrain variation
const pos = geo.attributes.position;
for (let i = 0; i < pos.count; i++) {
const x = pos.getX(i);
const z = pos.getY(i);
pos.setZ(i, Math.sin(x * 0.02) * Math.cos(z * 0.02) * 1.5 + Math.random() * 0.3);
}
const ground = new THREE.Mesh(geo, mat);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -0.5;
return ground;
}
// ── function: buildScenery ──
function buildScenery(track) {
const group = new THREE.Group();
// Tree geometry templates (reuse for performance)
const trunkGeo = new THREE.CylinderGeometry(0.3, 0.4, 2, 6);
const trunkMat = new THREE.MeshLambertMaterial({ color: 0x8B5A2B });
const leavesGeo = new THREE.ConeGeometry(1.8, 4, 6);
const leavesMat = new THREE.MeshLambertMaterial({ color: 0x2d7a2d });
const rockGeo = new THREE.DodecahedronGeometry(0.8, 0);
const rockMat = new THREE.MeshLambertMaterial({ color: 0x777777 });
// Place trees and rocks along the track
for (let i = 0; i < track.length; i += 3) {
const p = track[i];
const nextP = track[(i + 1) % track.length];
let dx = nextP.x - p.x;
let dz = nextP.z - p.z;
const len = Math.sqrt(dx*dx + dz*dz);
if (len === 0) { dx = 0; dz = 1; } else { dx /= len; dz /= len; }
// Trees on both sides at varying distances
for (let side of [-1, 1]) {
const dist = CONFIG.TRACK_WIDTH * 0.8 + Math.random() * 25;
const treeX = p.x + (-dz) * dist * side;
const treeZ = p.z + dx * dist * side;
// Trunk
const trunk = new THREE.Mesh(trunkGeo, trunkMat);
trunk.position.set(treeX, 1, treeZ);
group.add(trunk);
// Leaves (varying height)
const leavesH = 3 + Math.random() * 2;
const leaves = new THREE.Mesh(
new THREE.ConeGeometry(1.5 + Math.random(), leavesH, 6),
leavesMat
);
leaves.position.set(treeX, 1 + leavesH/2, treeZ);
group.add(leaves);
}
// Occasional rocks
if (Math.random() < 0.3) {
for (let side of [-1, 1]) {
const dist = CONFIG.TRACK_WIDTH * 0.7 + Math.random() * 5;
const rock = new THREE.Mesh(rockGeo, rockMat);
rock.position.set(
p.x + (-dz) * dist * side,
0.3,
p.z + dx * dist * side
);
rock.scale.setScalar(0.5 + Math.random() * 1);
rock.rotation.y = Math.random() * Math.PI;
group.add(rock);
}
}
// Occasional rally banners/signs
if (i % 30 === 0) {
const bannerSide = (i / 30) % 2 === 0 ? -1 : 1;
const dist = CONFIG.TRACK_WIDTH * 0.75;
// Pole
const poleGeo = new THREE.CylinderGeometry(0.08, 0.08, 4, 4);
const poleMat = new THREE.MeshLambertMaterial({ color: 0xaaaaaa });
const pole = new THREE.Mesh(poleGeo, poleMat);
pole.position.set(
p.x + (-dz) * dist * bannerSide,
2,
p.z + dx * dist * bannerSide
);
group.add(pole);
// Banner cloth
const bannerGeo = new THREE.BoxGeometry(3, 1.5, 0.05);
const bannerColors = [0xff4444, 0x4444ff, 0xffff44, 0x44ff44];
const bannerMat = new THREE.MeshLambertMaterial({
color: bannerColors[Math.floor(Math.random() * bannerColors.length)]
});
const banner = new THREE.Mesh(bannerGeo, bannerMat);
banner.position.set(
p.x + (-dz) * dist * bannerSide,
3.5,
p.z + dx * dist * bannerSide
);
group.add(banner);
}
// Guard rails on sharp curves (every ~10 points)
if (i % 20 === 0) {
for (let side of [-1, 1]) {
const dist = CONFIG.TRACK_WIDTH * 0.55;
const railGeo = new THREE.BoxGeometry(0.15, 0.6, len + 1);
const railMat = new THREE.MeshLambertMaterial({ color: 0xcccccc });
const rail = new THREE.Mesh(railGeo, railMat);
rail.position.set(
p.x + (-dz) * dist * side,
0.3,
p.z + dx * dist * side
);
const angle = Math.atan2(dx, dz);
rail.rotation.y = angle;
group.add(rail);
// Rail post
for (let j = -1; j <= 1; j += 2) {
const postGeo = new THREE.CylinderGeometry(0.06, 0.06, 0.8, 4);
const post = new THREE.Mesh(postGeo, railMat);
post.position.set(
p.x + (-dz) * dist * side + dx * j * len/2,
0.4,
p.z + dx * dist * side - dz * j * len/2
);
group.add(post);
}
}
}
}
return group;
}
// ── function: buildCheckpoints ──
// ============================================================
// Checkpoint Gates (invisible)
// ============================================================
function buildCheckpoints(track) {
const checkpoints = [];
const count = CONFIG.CHECKPOINT_COUNT;
for (let i = 0; i < count; i++) {
const idx = Math.floor(i * track.length / count);
const p = track[idx];
const nextP = track[(idx + 1) % track.length];
let dx = nextP.x - p.x;
let dz = nextP.z - p.z;
const len = Math.sqrt(dx*dx + dz*dz);
if (len === 0) { dx = 0; dz = 1; } else { dx /= len; dz /= len; }
checkpoints.push({
index: idx,
x: p.x, z: p.z, y: p.y,
nx: -dz, nz: dx, // normal direction for gate plane
passed: false,
});
}
return checkpoints;
}
// ── function: checkCheckpointPass ──
function checkCheckpointPass(car, checkpoints) {
if (checkpoints.length === 0) return;
const next = checkpoints[car.nextCheckpoint % checkpoints.length];
const dx = car.x - next.x;
const dz = car.z - next.z;
const dist = Math.sqrt(dx*dx + dz*dz);
if (dist < CONFIG.TRACK_WIDTH * 1.5) {
// Check if we crossed the gate plane in the right direction
const dot = dx * next.nx + dz * next.nz;
if (dot > 0 || !next.passed) {
next.passed = true;
car.nextCheckpoint++;
// Lap completion check
if (car.nextCheckpoint >= checkpoints.length) {
car.lapCompleted = true;
car.nextCheckpoint = 0;
checkpoints.forEach(cp => cp.passed = false);
}
}
}
}
// ── function: createCamera ──
// ============================================================
// Camera System - Chase Camera with Lag
// ============================================================
function createCamera() {
return {
position: new THREE.Vector3(0, CONFIG.CAMERA_HEIGHT, CONFIG.CAMERA_DISTANCE),
target: new THREE.Vector3(0, 1, 0),
mode: 'chase', // or 'cinematic'
smoothPos: new THREE.Vector3(),
smoothTarget: new THREE.Vector3(),
};
}
// ── function: updateCamera ──
function updateCamera(cam, carMesh, dt) {
const carPos = carMesh.position;
const carHeading = carMesh.rotation.y;
let desiredDist, desiredHeight;
if (cam.mode === 'chase') {
desiredDist = CONFIG.CAMERA_DISTANCE;
desiredHeight = CONFIG.CAMERA_HEIGHT;
} else {
desiredDist = 25;
desiredHeight = 18;
}
// Desired camera position behind car
const dx = Math.sin(carHeading) * desiredDist;
const dz = -Math.cos(carHeading) * desiredDist;
const targetPos = new THREE.Vector3(
carPos.x + dx,
carPos.y + desiredHeight,
carPos.z + dz
);
// Smooth interpolation (lag effect)
const lerpFactor = 1 - Math.pow(0.05, dt);
cam.smoothPos.lerp(targetPos, lerpFactor);
// Look slightly ahead of the car
const lookAhead = new THREE.Vector3(
carPos.x + Math.sin(carHeading) * 5,
carPos.y + 1.5,
carPos.z - Math.cos(carHeading) * 5
);
cam.smoothTarget.lerp(lookAhead, lerpFactor);
// Apply to camera
cam.position.copy(cam.smoothPos);
cam.target.copy(cam.smoothTarget);
}
// ── function: createParticleSystem ──
// ============================================================
// Particle Effects - Dust, Sparks, Skid Marks
// ============================================================
function createParticleSystem(maxParticles) {
const positions = new Float32Array(maxParticles * 3);
const colors = new Float32Array(maxParticles * 3);
const sizes = new Float32Array(maxParticles);
for (let i = 0; i < maxParticles; i++) {
positions[i*3] = 0;
positions[i*3+1] = -100; // hide initially
positions[i*3+2] = 0;
sizes[i] = 0;
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geo.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
geo.setAttribute('size', new THREE.Float32BufferAttribute(sizes, 1));
const mat = new THREE.PointsMaterial({
size: 0.5,
vertexColors: true,
transparent: true,
opacity: 0.8,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const points = new THREE.Points(geo, mat);
return {
mesh: points,
particles: [],
maxParticles,
positions,
colors,
sizes,
};
}
// ── function: emitParticle ──
function emitParticle(system, x, y, z, r, g, b, life, size) {
if (system.particles.length >= system.maxParticles) return;
system.particles.push({
x, y, z,
vx: (Math.random() - 0.5) * 3,
vy: Math.random() * 2 + 1,
vz: (Math.random() - 0.5) * 3,
r, g, b,
life,
maxLife: life,
size,
});
}
// ── function: updateParticles ──
function updateParticles(system, dt) {
const pos = system.mesh.geometry.attributes.position;
const col = system.mesh.geometry.attributes.color;
// Update existing particles
for (let i = system.particles.length - 1; i >= 0; i--) {
const p = system.particles[i];
p.life -= dt;
if (p.life <= 0) {
system.particles.splice(i, 1);
continue;
}
p.x += p.vx * dt;
p.y += p.vy * dt;
p.z += p.vz * dt;
p.vy -= 2 * dt; // gravity
const alpha = p.life / p.maxLife;
pos.setXYZ(i, p.x, p.y, p.z);
col.setRGB(i, p.r * alpha, p.g * alpha, p.b * alpha);
}
// Hide unused particles
for (let i = system.particles.length; i < system.maxParticles; i++) {
pos.setY(i, -100);
}
pos.needsUpdate = true;
col.needsUpdate = true;
}
// ── function: emitDust ──
function emitDust(system, x, y, z) {
const count = 2 + Math.floor(Math.random() * 3);
for (let i = 0; i < count; i++) {
emitParticle(system,
x + (Math.random()-0.5)*1.5, y + Math.random()*0.3, z + (Math.random()-0.5)*1.5,
0.7, 0.6, 0.4, // brownish dust color
0.8 + Math.random() * 0.5,
0.5 + Math.random() * 0.5
);
}
}
// ── function: emitSparks ──
function emitSparks(system, x, y, z) {
const count = 3 + Math.floor(Math.random() * 4);
for (let i = 0; i < count; i++) {
emitParticle(system,
x + (Math.random()-0.5)*1, y + Math.random()*0.5, z + (Math.random()-0.5)*1,
1, 0.8, 0.2, // orange sparks
0.3 + Math.random() * 0.3,
0.2 + Math.random() * 0.3
);
}
}
// ── function: createSkidMarkSystem ──
// ============================================================
// Skid Mark System (persistent on ground)
// ============================================================
function createSkidMarkSystem(maxMarks) {
const positions = [];
const geo = new THREE.PlaneGeometry(1, 2);
for (let i = 0; i < maxMarks; i++) {
const mat = new THREE.MeshBasicMaterial({
color: 0x222222, transparent: true, opacity: 0.5, depthWrite: false
});
const mark = new THREE.Mesh(geo, mat);
mark.rotation.x = -Math.PI / 2;
mark.position.y = 0.01;
mark.visible = false;
positions.push({ mesh: mark, life: 0 });
}
return { marks: positions, nextIndex: 0 };
}
// ── function: addSkidMark ──
function addSkidMark(system, x, z, heading) {
const idx = system.nextIndex % system.marks.length;
const mark = system.marks[idx];
mark.position.set(x, 0.01, z);
mark.rotation.z = -heading;
mark.visible = true;
mark.material.opacity = 0.5;
mark.life = 8; // seconds before fading
system.nextIndex++;
}
// ── function: updateSkidMarks ──
function updateSkidMarks(system, dt) {
for (const mark of system.marks) {
if (mark.life > 0) {
mark.life -= dt;
mark.material.opacity = Math.max(0, mark.life / 8 * 0.5);
if (mark.life <= 0) mark.visible = false;
}
}
}
// ── function: createAudioSystem ──
// ============================================================
// Audio System - Web Audio API Synthesis (fixed init)
// ============================================================
function createAudioSystem() {
let ctx = null;
let engineOsc = null;
let engineGain = null;
let skidNode = null;
let skidGain = null;
let initialized = false;
function tryInit() {
if (initialized) return true;
try {
ctx = new (window.AudioContext || window.webkitAudioContext)();
// Engine sound: oscillator + gain
engineOsc = ctx.createOscillator();
engineGain = ctx.createGain();
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = 800;
engineOsc.type = 'sawtooth';
engineOsc.frequency.value = 60;
engineGain.gain.value = 0.05;
engineOsc.connect(filter);
filter.connect(engineGain);
engineGain.connect(ctx.destination);
engineOsc.start();
initialized = true;
return true;
} catch(e) {
console.warn('Audio init failed:', e);
return false;
}
}
function updateEngine(car) {
if (!engineOsc || !ctx) return;
const absSpeed = Math.abs(car.speed);
const maxSpeed = CONFIG.CAR_MAX_SPEED;
const speedRatio = absSpeed / maxSpeed;
// Engine pitch based on RPM
engineOsc.frequency.value = 50 + car.rpm * 0.8;
// Volume based on speed and gear changes
engineGain.gain.value = 0.02 + speedRatio * 0.06;
}
function playBeep(freq, duration) {
if (!ctx) return;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
gain.gain.setValueAtTime(0.15, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + duration);
}
function playCrash() {
if (!ctx) return;
const bufferSize = ctx.sampleRate * 0.2;
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (bufferSize * 0.1));
}
const source = ctx.createBufferSource();
source.buffer = buffer;
const gain = ctx.createGain();
gain.gain.value = 0.15;
source.connect(gain);
gain.connect(ctx.destination);
source.start();
}
function playFinishTone() {
if (!ctx) return;
const notes = [523, 659, 784, 1047]; // C E G C
notes.forEach((freq, i) => {
setTimeout(() => playBeep(freq, 0.3), i * 150);
});
}
function startSkidSound() {
if (!ctx || skidNode) return;
const bufferSize = ctx.sampleRate * 2;
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * 0.3;
}
skidNode = ctx.createBufferSource();
skidNode.buffer = buffer;
skidNode.loop = true;
skidGain = ctx.createGain();
skidGain.gain.value = 0.04;
const filter = ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.value = 2000;
skidNode.connect(filter);
filter.connect(skidGain);
skidGain.connect(ctx.destination);
skidNode.start();
}
function stopSkidSound() {
if (skidNode) {
try { skidNode.stop(); } catch(e) {}
skidNode = null;
skidGain = null;
}
}
return {
init: tryInit,
updateEngine,
playBeep,
playCrash,
playFinishTone,
startSkidSound,
stopSkidSound,
get isReady() { return initialized; }
};
}
// ── function: createInputHandler ──
// ============================================================
// Input Handling - Keyboard & Touch
// ============================================================
function createInputHandler() {
const keys = {};
const input = { gas: false, brake: false, left: false, right: false, handbrake: false, reverse: false };
function onKeyDown(e) {
keys[e.code] = true;
updateInput();
// Prevent scrolling on space/arrows
if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) {
e.preventDefault();
}
}
function onKeyUp(e) {
keys[e.code] = false;
updateInput();
}
function updateInput() {
input.gas = !!(keys['KeyW'] || keys['ArrowUp']);
input.brake = !!(keys['KeyS'] || keys['ArrowDown']);
input.left = !!(keys['KeyA'] || keys['ArrowLeft']);
input.right = !!(keys['KeyD'] || keys['ArrowRight']);
input.handbrake = !!keys['Space'];
input.reverse = input.brake && !input.gas;
}
function setTouchInput(type, active) {
switch(type) {
case 'gas': input.gas = active; break;
case 'brake': input.brake = active; break;
case 'left': input.left = active; break;
case 'right': input.right = active; break;
case 'handbrake': input.handbrake = active; break;
}
}
return { onKeyDown, onKeyUp, setTouchInput, get input() { return input; } };
}
// ── function: formatTime ──
// ============================================================
// HUD Update Functions
// ============================================================
function formatTime(ms) {
const totalSec = ms / 1000;
const min = Math.floor(totalSec / 60);
const sec = Math.floor(totalSec % 60);
const frac = Math.floor((totalSec * 1000) % 1000);
return `${String(min).padStart(2,'0')}:${String(sec).padStart(2,'0')}.${String(frac).padStart(3,'0')}`;
}
// ── function: updateHUD ──
function updateHUD(car, gameState) {
const speedKmh = Math.abs(Math.round(car.speed * 3.6)); // m/s to km/h
document.getElementById('speedVal').textContent = speedKmh;
document.getElementById('gearVal').textContent = car.gear;
document.getElementById('currentLap').textContent = gameState.currentLap;
const rpmPercent = ((car.rpm - 800) / (7500 - 800)) * 100;
document.getElementById('rpmFill').style.width = Math.min(100, rpmPercent) + '%';
// Timer display
if (gameState.racing) {
const elapsed = performance.now() - gameState.startTime;
document.getElementById('timerDisplay').textContent = formatTime(elapsed);
const lapElapsed = performance.now() - gameState.lapStartTime;
document.getElementById('lapTimeDisplay').textContent = 'Lap Time: ' + formatTime(lapElapsed);
}
if (gameState.bestLap > 0) {
document.getElementById('bestLapDisplay').textContent = 'Best Lap: ' + formatTime(gameState.bestLap);
}
}
// ── function: drawMinimap ──
// ============================================================
// Minimap Drawing
// ============================================================
function drawMinimap(track, carPos) {
const canvas = document.getElementById('minimap');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = canvas.width;
const h = canvas.height;
// Clear
ctx.fillStyle = 'rgba(0,0,0,0.6)';
ctx.fillRect(0, 0, w, h);
// Find track bounds
let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
for (const p of track) {
if (p.x < minX) minX = p.x;
if (p.x > maxX) maxX = p.x;
if (p.z < minZ) minZ = p.z;
if (p.z > maxZ) maxZ = p.z;
}
const padding = 20;
const scaleX = (w - padding*2) / (maxX - minX || 1);
const scaleZ = (h - padding*2) / (maxZ - minZ || 1);
const scale = Math.min(scaleX, scaleZ);
const offsetX = (w - (maxX - minX) * scale) / 2;
const offsetZ = (h - (maxZ - minZ) * scale) / 2;
function mapX(x) { return (x - minX) * scale + offsetX; }
function mapZ(z) { return (z - minZ) * scale + offsetZ; }
// Draw track outline
ctx.strokeStyle = '#666';
ctx.lineWidth = 4;
ctx.beginPath();
for (let i = 0; i < track.length; i += 2) {
const p = track[i];
if (i === 0) ctx.moveTo(mapX(p.x), mapZ(p.z));
else ctx.lineTo(mapX(p.x), mapZ(p.z));
}
ctx.closePath();
ctx.stroke();
// Draw track center line
ctx.strokeStyle = '#999';
ctx.lineWidth = 2;
ctx.beginPath();
for (let i = 0; i < track.length; i += 3) {
const p = track[i];
if (i === 0) ctx.moveTo(mapX(p.x), mapZ(p.z));
else ctx.lineTo(mapX(p.x), mapZ(p.z));
}
ctx.closePath();
ctx.stroke();
// Draw car position with heading indicator
const cx = mapX(carPos.x);
const cy = mapZ(carPos.z);
ctx.fillStyle = '#ff3300';
ctx.beginPath();
ctx.arc(cx, cy, 4, 0, Math.PI * 2);
ctx.fill();
// Heading arrow
ctx.strokeStyle = '#ffcc00';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(
cx + Math.sin(carPos.heading) * 8,
cy - Math.cos(carPos.heading) * 8
);
ctx.stroke();
}
// ── function: checkObstacleCollision ──
// ============================================================
// Obstacle Collision Detection
// ============================================================
function checkObstacleCollision(car, sceneryGroup) {
const carRadius = 1.5;
let collided = false;
// Check against guard rails and rocks (simplified - check bounding boxes of children)
for (const child of sceneryGroup.children) {
if (!child.geometry || !child.position) continue;
const dx = car.x - child.position.x;
const dz = car.z - child.position.z;
const dist = Math.sqrt(dx*dx + dz*dz);
// Approximate collision radius based on geometry type
let objRadius = 0.5;
if (child.geometry.type === 'BoxGeometry') {
objRadius = Math.max(child.geometry.parameters.width, child.geometry.parameters.depth) * 0.5 || 1;
} else if (child.geometry.type === 'DodecahedronGeometry') {
objRadius = child.geometry.parameters.radius * (child.scale.x || 1);
}
// Skip trees (trunks are thin, leaves are high)
if (child.geometry.type === 'ConeGeometry' && child.position.y > 2) continue;
if (child.geometry.type === 'CylinderGeometry' && child.position.y < 1.5 && objRadius < 0.3) continue; // tree trunks
if (dist < carRadius + objRadius) {
collided = true;
// Bounce: push car away from obstacle
const nx = dx / dist;
const nz = dz / dist;
car.x += nx * 0.5;
car.z += nz * 0.5;
// Kill momentum
car.speed *= 0.3;
}
}
return collided;
}
// ── function: buildTrackMarkings ──
// ============================================================
// Track Center Line & Road Markings
// ============================================================
function buildTrackMarkings(track) {
const group = new THREE.Group();
// Dashed center line
for (let i = 0; i < track.length; i += 4) {
if (i % 8 === 0) {
const p1 = track[i];
const p2 = track[(i + 1) % track.length];
let dx = p2.x - p1.x;
let dz = p2.z - p1.z;
const len = Math.sqrt(dx*dx + dz*dz);
// White center line dashes
const dashGeo = new THREE.BoxGeometry(0.15, 0.02, len * 0.6);
const dashMat = new THREE.MeshLambertMaterial({ color: 0xffffff });
const dash = new THREE.Mesh(dashGeo, dashMat);
dash.position.set((p1.x + p2.x)/2, p1.y + 0.02, (p1.z + p2.z)/2);
if (len > 0) {
const angle = Math.atan2(dx, dz);
dash.rotation.y = angle;
}
group.add(dash);
}
// Edge lines (solid white)
for (let side of [-1, 1]) {
if (i % 4 === 0) {
const p1 = track[i];
const p2 = track[(i + 1) % track.length];
let dx = p2.x - p1.x;
let dz = p2.z - p1.z;
const len = Math.sqrt(dx*dx + dz*dz);
if (len === 0) continue;
const px = (-dz / len) * CONFIG.TRACK_WIDTH * 0.45;
const pz = (dx / len) * CONFIG.TRACK_WIDTH * 0.45;
const edgeGeo = new THREE.BoxGeometry(0.1, 0.02, len);
const edgeMat = new THREE.MeshLambertMaterial({ color: 0xffffff });
const edge = new THREE.Mesh(edgeGeo, edgeMat);
edge.position.set(p1.x + px * side, p1.y + 0.02, p1.z + pz * side);
edge.rotation.y = Math.atan2(dx, dz);
group.add(edge);
}
}
}
// Start/finish line
const startP = track[0];
const nextP = track[1];
let sdx = nextP.x - startP.x;
let sdz = nextP.z - startP.z;
const slen = Math.sqrt(sdx*sdx + sdz*sdz);
// Checkered pattern for start line
const checkSize = CONFIG.TRACK_WIDTH / 8;
for (let row = 0; row < 2; row++) {
for (let col = 0; col < 8; col++) {
if ((row + col) % 2 === 0) {
const checkGeo = new THREE.BoxGeometry(checkSize, 0.03, checkSize);
const checkMat = new THREE.MeshLambertMaterial({ color: 0xffffff });
const check = new THREE.Mesh(checkGeo, checkMat);
const px = (-sdz / (slen || 1)) * (col - 3.5) * checkSize;
const pz = (sdx / (slen || 1)) * (col - 3.5) * checkSize;
check.position.set(
startP.x + px,
startP.y + 0.03,
startP.z + pz + row * checkSize * 0.5
);
group.add(check);
}
}
}
return group;
}
// ── function: buildSkyDome ──
// ============================================================
// Sky Dome with Gradient
// ============================================================
function buildSkyDome() {
const skyGeo = new THREE.SphereGeometry(350, 16, 12);
const skyMat = new THREE.ShaderMaterial({
uniforms: {
topColor: { value: new THREE.Color(0x0077ff) },
bottomColor: { value: new THREE.Color(0xaaddff) },
offset: { value: 20 },
exponent: { value: 0.4 }
},
vertexShader: `
varying vec3 vWorldPosition;
void main() {
vec4 worldPos = modelMatrix * vec4(position, 1.0);
vWorldPosition = worldPos.xyz;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 topColor;
uniform vec3 bottomColor;
uniform float offset;
uniform float exponent;
varying vec3 vWorldPosition;
void main() {
float h = normalize(vWorldPosition + offset).y;
gl_FragColor = vec4(mix(bottomColor, topColor, max(pow(max(h, 0.0), exponent), 0.0)), 1.0);
}
`,
side: THREE.BackSide,
depthWrite: false,
});
return new THREE.Mesh(skyGeo, skyMat);
}
// ── function: buildClouds ──
// ============================================================
// Simple Cloud Meshes
// ============================================================
function buildClouds() {
const group = new THREE.Group();
const cloudMat = new THREE.MeshLambertMaterial({
color: 0xffffff, transparent: true, opacity: 0.7
});
for (let i = 0; i < 25; i++) {
const cloudGroup = new THREE.Group();
// Each cloud is several overlapping spheres
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, 5);
const puff = new THREE.Mesh(puffGeo, cloudMat);
puff.position.set(
(Math.random() - 0.5) * 15,
(Math.random() - 0.5) * 3,
(Math.random() - 0.5) * 8
);
puff.scale.y = 0.4;
cloudGroup.add(puff);
}
cloudGroup.position.set(
(Math.random() - 0.5) * 600,
60 + Math.random() * 40,
(Math.random() - 0.5) * 600
);
group.add(cloudGroup);
}
return group;
}
// ── main ──
// ============================================================
// Rally Racer 3D - Main Game Loop & Initialization
// ============================================================
(function() {
// --- Scene Setup ---
const scene = new THREE.Scene();
scene.fog = new THREE.Fog(0x9dc4e0, CONFIG.FOG_NEAR, CONFIG.FOG_FAR);
const camera = new THREE.PerspectiveCamera(65, window.innerWidth / window.innerHeight, 0.1, 500);
const renderer = new THREE.WebGLRenderer({ canvas: document.getElementById('gameCanvas'), antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
// --- Sky Dome & Clouds ---
scene.add(buildSkyDome());
const clouds = buildClouds();
scene.add(clouds);
// --- Lighting ---
const ambientLight = new THREE.AmbientLight(0x6688aa, 0.5);
scene.add(ambientLight);
const sunLight = new THREE.DirectionalLight(0xffeedd, 1.2);
sunLight.position.set(50, 80, 30);
sunLight.castShadow = true;
sunLight.shadow.mapSize.width = 1024;
sunLight.shadow.mapSize.height = 1024;
sunLight.shadow.camera.near = 1;
sunLight.shadow.camera.far = 200;
sunLight.shadow.camera.left = -60;
sunLight.shadow.camera.right = 60;
sunLight.shadow.camera.top = 60;
sunLight.shadow.camera.bottom = -60;
scene.add(sunLight);
const hemiLight = new THREE.HemisphereLight(0x87CEEB, 0x3a7d32, 0.4);
scene.add(hemiLight);
// --- Generate Track ---
const track = generateTrack();
// --- Build Scene Objects ---
const trackMesh = buildTrackMesh(track);
trackMesh.receiveShadow = true;
scene.add(trackMesh);
const shoulders = buildTrackShoulders(track);
scene.add(shoulders);
const markings = buildTrackMarkings(track);
scene.add(markings);
const ground = buildGround();
ground.receiveShadow = true;
scene.add(ground);
const sceneryGroup = buildScenery(track);
scene.add(sceneryGroup);
// --- Build Car ---
const carData = buildCarMesh();
const carMesh = carData.mesh;
carMesh.castShadow = true;
scene.add(carMesh);
// Position car at start of track
const startPos = track[0];
const dir1 = getTrackDirection(track, 0);
const startHeading = Math.atan2(dir1.x, dir1.z);
carMesh.position.set(startPos.x, startPos.y + 0.7, startPos.z);
carMesh.rotation.y = startHeading;
// --- Game State ---
const carState = createCarState();
carState.heading = startHeading;
carState.nextCheckpoint = 1;
carState.lapCompleted = false;
const checkpoints = buildCheckpoints(track);
const gameState = {
phase: 'countdown', // countdown, racing, paused, finished
currentLap: 1,
startTime: 0,
lapStartTime: 0,
bestLap: 0,
lapTimes: [],
countdownValue: 3,
countdownTimer: 0,
};
// --- Camera ---
const cam = createCamera();
cam.smoothPos.copy(camera.position);
cam.smoothTarget.copy(new THREE.Vector3(0, 1, 0));
// --- Particles ---
const particleSystem = createParticleSystem(500);
scene.add(particleSystem.mesh);
const skidMarkSystem = createSkidMarkSystem(200);
for (const mark of skidMarkSystem.marks) {
scene.add(mark.mesh);
}
// --- Audio ---
const audio = createAudioSystem();
let audioInitialized = false;
function initAudioIfNeeded() {
if (!audioInitialized && !audio.isReady) {
audio.init();
audioInitialized = true;
}
}
// --- Input ---
const inputHandler = createInputHandler();
window.addEventListener('keydown', function mainKeydown(e) {
initAudioIfNeeded();
inputHandler.onKeyDown(e);
});
window.addEventListener('keyup', inputHandler.onKeyUp);
// Touch controls setup
if ('ontouchstart' in window) {
document.getElementById('touchControls').style.display = 'flex';
function addTouchBtn(id, type) {
const btn = document.getElementById(id);
if (!btn) return;
btn.addEventListener('touchstart', (e) => {
e.preventDefault();
initAudioIfNeeded();
inputHandler.setTouchInput(type, true);
});
btn.addEventListener('touchend', (e) => { e.preventDefault(); inputHandler.setTouchInput(type, false); });
}
addTouchBtn('btnUp', 'gas');
addTouchBtn('btnDown', 'brake');
addTouchBtn('btnLeft', 'left');
addTouchBtn('btnRight', 'right');
addTouchBtn('btnGas', 'gas');
addTouchBtn('btnBrake', 'brake');
addTouchBtn('btnHandbrake', 'handbrake');
}
// --- UI Buttons ---
document.getElementById('resumeBtn').addEventListener('click', () => {
initAudioIfNeeded();
gameState.phase = 'racing';
document.getElementById('pauseMenu').style.display = 'none';
});
function restartGame() {
carState.x = startPos.x;
carState.y = startPos.y + 0.7;
carState.z = startPos.z;
carState.heading = startHeading;
carState.speed = 0;
carState.steerAngle = 0;
carState.gear = 1;
carState.rpm = 800;
carMesh.position.set(startPos.x, startPos.y + 0.7, startPos.z);
carMesh.rotation.y = startHeading;
gameState.currentLap = 1;
gameState.bestLap = 0;
gameState.lapTimes = [];
gameState.phase = 'countdown';
gameState.countdownValue = 3;
gameState.countdownTimer = 0;
checkpoints.forEach(cp => cp.passed = false);
carState.nextCheckpoint = 1;
carState.lapCompleted = false;
document.getElementById('finishScreen').style.display = 'none';
document.getElementById('pauseMenu').style.display = 'none';
document.getElementById('hud').style.display = 'block';
document.getElementById('countdownOverlay').style.display = 'flex';
}
document.getElementById('restartBtn').addEventListener('click', restartGame);
document.getElementById('restartPauseBtn').addEventListener('click', restartGame);
// --- Resize Handler ---
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// --- Pause/Camera/Restart key handlers (separate from input) ---
window.addEventListener('keydown', function controlHandler(e) {
if (e.code === 'KeyP' && gameState.phase === 'racing') {
gameState.phase = 'paused';
document.getElementById('pauseMenu').style.display = 'flex';
audio.stopSkidSound();
} else if (e.code === 'KeyP' && gameState.phase === 'paused') {
gameState.phase = 'racing';
document.getElementById('pauseMenu').style.display = 'none';
}
if (e.code === 'KeyC') {
cam.mode = cam.mode === 'chase' ? 'cinematic' : 'chase';
}
if (e.code === 'KeyR' && gameState.phase !== 'countdown') {
restartGame();
}
});
// --- Show HUD & Start Countdown ---
document.getElementById('hud').style.display = 'block';
document.getElementById('countdownOverlay').style.display = 'flex';
// --- Game Loop Variables ---
let lastTime = performance.now();
let dustTimer = 0;
let minimapTimer = 0;
// ============================================================
// MAIN GAME LOOP
// ============================================================
function gameLoop(now) {
requestAnimationFrame(gameLoop);
const dt = Math.min((now - lastTime) / 1000, 0.1);
lastTime = now;
// --- Countdown Phase ---
if (gameState.phase === 'countdown') {
gameState.countdownTimer += dt;
if (gameState.countdownTimer >= 1) {
gameState.countdownTimer -= 1;
gameState.countdownValue--;
const countdownText = document.getElementById('countdownText');
if (gameState.countdownValue > 0) {
countdownText.textContent = gameState.countdownValue;
if (audio.isReady) audio.playBeep(440, 0.2);
} else {
countdownText.textContent = 'GO!';
countdownText.style.color = '#00ff00';
if (audio.isReady) audio.playBeep(880, 0.3);
setTimeout(() => {
document.getElementById('countdownOverlay').style.display = 'none';
}, 500);
gameState.phase = 'racing';
gameState.startTime = now;
gameState.lapStartTime = now;
}
}
updateCamera(cam, carMesh, dt);
camera.position.copy(cam.position);
camera.lookAt(cam.target);
renderer.render(scene, camera);
return;
}
// --- Paused Phase ---
if (gameState.phase === 'paused') {
updateCamera(cam, carMesh, dt);
camera.position.copy(cam.position);
camera.lookAt(cam.target);
renderer.render(scene, camera);
return;
}
// --- Finished Phase ---
if (gameState.phase === 'finished') {
renderer.render(scene, camera);
return;
}
// --- Racing Phase: Update Physics ---
updateCarPhysics(carState, inputHandler.input, track, dt);
// Sync car mesh with physics state
carMesh.position.set(carState.x, carState.y + 0.7, carState.z);
carMesh.rotation.y = carState.heading;
// Wheel rotation animation
const wheelRotSpeed = carState.speed * dt * 2;
for (const wheel of carData.wheels) {
wheel.rotation.x += wheelRotSpeed;
}
// Front wheels steer angle
if (carData.wheels.length >= 2) {
carData.wheels[0].rotation.z = Math.PI/2 + carState.steerAngle * 0.5;
carData.wheels[1].rotation.z = Math.PI/2 + carState.steerAngle * 0.5;
}
// --- Obstacle Collision ---
const collided = checkObstacleCollision(carState, sceneryGroup);
if (collided) {
audio.playCrash();
emitSparks(particleSystem, carState.x, carState.y + 1, carState.z);
}
// --- Checkpoint Progression ---
checkCheckpointPass(carState, checkpoints);
if (carState.lapCompleted) {
const lapTime = now - gameState.lapStartTime;
if (gameState.bestLap === 0 || lapTime < gameState.bestLap) {
gameState.bestLap = lapTime;
}
gameState.lapTimes.push(lapTime);
carState.lapCompleted = false;
gameState.currentLap++;
gameState.lapStartTime = now;
if (gameState.currentLap > CONFIG.TOTAL_LAPS) {
// Race complete!
gameState.phase = 'finished';
const totalTime = now - gameState.startTime;
document.getElementById('finalTime').textContent = formatTime(totalTime);
document.getElementById('bestLapFinish').textContent = formatTime(gameState.bestLap);
document.getElementById('finishScreen').style.display = 'flex';
audio.playFinishTone();
}
}
// --- Particle Effects ---
dustTimer += dt;
if (dustTimer > 0.05) {
dustTimer = 0;
const absSpeed = Math.abs(carState.speed);
// Dust when off-road or drifting
if (!carState.onTrack && absSpeed > 3) {
emitDust(particleSystem, carState.x, carState.y + 0.2, carState.z);
} else if (carState.skidding && absSpeed > 5) {
emitDust(particleSystem, carState.x, carState.y + 0.2, carState.z);
}
// Skid marks when drifting
if ((carState.skidding || !carState.onTrack) && absSpeed > 8) {
addSkidMark(skidMarkSystem,
carState.x - Math.sin(carState.heading) * 1.5,
carState.z + Math.cos(carState.heading) * 1.5,
carState.heading
);
}
}
updateParticles(particleSystem, dt);
updateSkidMarks(skidMarkSystem, dt);
// --- Audio Updates ---
if (audio.isReady) {
audio.updateEngine(carState);
if (carState.skidding || !carState.onTrack) {
if (!audio._skidPlaying) {
audio.startSkidSound();
audio._skidPlaying = true;
}
} else {
if (audio._skidPlaying) {
audio.stopSkidSound();
audio._skidPlaying = false;
}
}
}
// --- Camera Update ---
updateCamera(cam, carMesh, dt);
camera.position.copy(cam.position);
camera.lookAt(cam.target);
// Move sun shadow to follow car
sunLight.position.set(carState.x + 50, 80, carState.z + 30);
sunLight.target.position.set(carState.x, carState.y, carState.z);
sunLight.target.updateMatrixWorld();
// --- HUD Update ---
updateHUD(carState, gameState);
minimapTimer += dt;
if (minimapTimer > 0.1) {
minimapTimer = 0;
drawMinimap(track, carState);
}
// --- Render ---
renderer.render(scene, camera);
}
requestAnimationFrame(gameLoop);
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"thinkingcap-qwen3.6-27b","provider":"lmstudio","persona":"composer","sessionId":"ee567ad6-654d-4b20-857e-703df70a1e51","tokensIn":787671,"tokensOut":32600,"tokensTotal":820271,"turns":31,"toolCalls":30,"failedToolCalls":1,"timestamp":"2026-07-18T15:48:20.854Z"} -->