Canyon Run
ornith 1.0 9b · composer

Reviews & generation details
Reviews
Human review · Jul 17, 2026 · rubric 1
Historical path-based review. Association with these exact bytes is unverified.
The reviewer marked this result as broken. It remains in the archive.
- Visual design
- Unscored
- UX / feel
- Unscored
- Works
- Unscored
- Prompt spirit
- Unscored
- Delight
- Unscored
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 56,520 bytes · SHA-256 4ffd65d7b8a5
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Canyon Racer</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<style>
/* ── css: game-layout ── */
html, body { margin: 0; padding: 0; overflow: hidden; background: #0a0a1a; }
canvas#game-canvas { display: block; width: 100vw; height: 100vh; }
#ui-layer { position: absolute; top: 0; left: 0; right: 0; bottom: 0; pointer-events: none; }
#hud-top { position: absolute; top: 24px; left: 32px; display: flex; gap: 16px; align-items: flex-start; }
#score-display { font-family: 'Courier New', monospace; font-size: 20px; color: #ffdd57; text-shadow: 0 0 8px rgba(255,200,80,0.6); letter-spacing: 2px; }
#multiplier-display { font-family: 'Courier New', monospace; font-size: 18px; color: #ff4488; text-shadow: 0 0 8px rgba(255,70,136,0.6); letter-spacing: 2px; transition: transform 0.1s ease; }
#multiplier-display.pulse { transform: scale(1.2); }
#hud-speedometer { position: absolute; bottom: 32px; left: 50%; transform: translateX(-50%); pointer-events: none; opacity: 0.85; }
#energy-bar-container { position: absolute; bottom: 64px; right: 32px; width: 120px; height: 8px; pointer-events: none; }
.energy-track { background: rgba(255,255,255,0.08); border-radius: 4px; overflow: hidden; border: 1px solid rgba(255,255,255,0.15); }
#energy-fill { width: 100%; height: 100%; background: linear-gradient(90deg, #ff6600, #ffdd44); border-radius: 4px; box-shadow: 0 0 8px rgba(255,180,50,0.5); transition: width 0.3s ease; }
#energy-fill.low { background: linear-gradient(90deg, #ff2200, #ff6600); box-shadow: 0 0 12px rgba(255,50,40,0.7); }
#game-over-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.85); display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 10; pointer-events: auto; }
#go-title { font-size: 48px; color: #ffdd44; text-shadow: 0 0 20px rgba(255,200,68,0.7); margin-bottom: 16px; letter-spacing: 4px; }
#go-score { font-size: 24px; color: #aaa; margin: 8px 0; }
#go-speed { font-size: 24px; color: #aaa; margin: 8px 0; }
#restart-btn { margin-top: 32px; padding: 12px 48px; background: rgba(255,220,60,0.15); border: 2px solid #ffdd44; color: #ffdd44; font-family: 'Courier New', monospace; font-size: 16px; letter-spacing: 3px; cursor: pointer; transition: all 0.2s; text-transform: uppercase; }
#restart-btn:hover { background: rgba(255,220,60,0.3); box-shadow: 0 0 24px rgba(255,200,68,0.4); transform: scale(1.05); }
#start-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 10; pointer-events: auto; }
#start-overlay h1 { font-size: 64px; color: #ffdd44; text-shadow: 0 0 30px rgba(255,200,68,0.7); letter-spacing: 8px; margin-bottom: 24px; }
.sub, .sub2 { font-family: 'Courier New', monospace; color: #aaa; font-size: 14px; text-align: center; line-height: 1.8; }
.hidden { display: none !important; }
</style>
</head>
<body>
<div id="game-container">
<canvas id="game-canvas" width="1280" height="720"></canvas>
<div id="ui-layer">
<div id="hud-top" class="hidden">
<div id="score-display"><span id="score-val">0</span></div>
<div id="multiplier-display"><span id="mult-val">1.0x</span></div>
</div>
<div id="hud-speedometer" class="hidden">
<svg viewBox="0 0 200 80" xmlns="http://www.w3.org/2000/svg">
<path d="M15,60 Q100,5 185,60" fill="none" stroke="#ff4444" stroke-width="2" opacity="0.5"></path>
<circle id="speed-marker" cx="100" cy="78" r="6" fill="#fff"></circle>
</svg>
</div>
<div id="hud-energy-bar" class="hidden">
<div class="energy-track">
<div id="energy-fill"></div>
</div>
</div>
</div>
<div id="game-over-overlay" class="hidden">
<h1 id="go-title">GAME OVER</h1>
<p id="go-score">Final Score: 0</p>
<p id="go-speed">Max Speed: 0</p>
<button id="restart-btn">RESTART</button>
</div>
<div id="start-overlay" class="hidden">
<h1>RACER</h1>
<p class="sub">MOUSE to steer | SCROLL/Wheel for speed | SHIFT boost</p>
<p class="sub2">ESC to pause</p>
</div>
</div>
<script>
'use strict';
// ── const: CONFIG ──
const CONFIG = {
WORLD: { width: 200, chunkLength: 120, horizonDist: 400, renderDist: 350 },
PLAYER: { mass: 8, maxSpeed: 3.2, boostMaxSpeed: 6.5, accel: 0.08, friction: 0.96, turnRate: 0.12, bankSensitivity: 4, pitchSens: 0.06 },
ENERGY: { decayRate: 0.035, orbValue: 25, boostCostPerFrame: 0.008 },
SCORING: { baseScorePerUnit: 1, nearMissBonus: 5, maxMultiplier: 12, minNearDist: 6, farNearDist: 14 },
GENERATION: { chunkCount: 3, obstacleDensityBase: 0.25, difficultyScale: 0.0008, wallSegmentsPerChunk: 4 },
VISUAL: { trailLength: 60, dustRate: 0.3, boostFOVZoom: 0.35, boostFOVBase: 55, boostFOVMax: 90, shakeNearMissIntensity: 8, shakeBoostIntensity: 4 },
};
// ── class: SoundSynth ──
class SoundSynth {
constructor() {
this.ctx = null;
this.masterGain = null;
this.engineOsc = null;
this._running = false;
}
init() {
if (!this.ctx) {
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
this.masterGain = this.ctx.createGain();
this.masterGain.gain.value = 0.25;
this.masterGain.connect(this.ctx.destination);
}
if (this.ctx.state === 'suspended') {
this.ctx.resume();
}
}
_engage() {
if (!this._running && this.ctx) {
this._running = true;
const baseFreq = 45 + Math.sin(Date.now() * 0.002) * 3;
try {
this.engineOsc = this.ctx.createOscillator();
this.engineOsc.type = 'sawtooth';
this.engineOsc.frequency.value = baseFreq;
const env = this.ctx.createGain();
env.gain.setValueAtTime(0.3, this.ctx.currentTime);
this.engineOsc.connect(env);
env.connect(this.masterGain);
this.engineOsc.start();
} catch(e) {}
}
}
_dissolve() {
if (this.engineOsc && this._running) {
try { this.engineOsc.stop(); } catch(e) {}
this._running = false;
}
}
engine(speedRatio, boostLevel) {
if (!this.ctx || !this.masterGain) return;
this._engage();
const freq = 45 + speedRatio * 80;
try {
if (this.engineOsc) {
this.engineOsc.frequency.setTargetAtTime(freq, this.ctx.currentTime, 0.1);
}
} catch(e) {}
}
ping() {
if (!this.ctx || !this.masterGain) return;
try {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(1200, this.ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(800, this.ctx.currentTime + 0.15);
gain.gain.setValueAtTime(0.4, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.2);
osc.connect(gain);
gain.connect(this.masterGain);
osc.start();
osc.stop(this.ctx.currentTime + 0.25);
} catch(e) {}
}
whoosh() {
if (!this.ctx || !this.masterGain) return;
try {
const bufferSize = this.ctx.sampleRate * 0.15;
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (this.ctx.sampleRate * 0.05));
}
const src = this.ctx.createBufferSource();
src.buffer = buffer;
const gain = this.ctx.createGain();
gain.gain.setValueAtTime(0.2, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.15);
src.connect(gain);
gain.connect(this.masterGain);
src.start();
} catch(e) {}
}
collect() {
if (!this.ctx || !this.masterGain) return;
try {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(800, this.ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(1600, this.ctx.currentTime + 0.12);
gain.gain.setValueAtTime(0.35, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.2);
osc.connect(gain);
gain.connect(this.masterGain);
osc.start();
osc.stop(this.ctx.currentTime + 0.25);
} catch(e) {}
}
crash() {
if (!this.ctx || !this.masterGain) return;
try {
const bufferSize = this.ctx.sampleRate * 0.3;
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (this.ctx.sampleRate * 0.03));
}
const src = this.ctx.createBufferSource();
src.buffer = buffer;
const gain = this.ctx.createGain();
gain.gain.setValueAtTime(0.4, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.35);
src.connect(gain);
gain.connect(this.masterGain);
src.start();
} catch(e) {}
}
turbo() {
if (!this.ctx || !this.masterGain) return;
try {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'square';
osc.frequency.value = 80 + Math.random() * 40;
gain.gain.setValueAtTime(0.15, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.2);
osc.connect(gain);
gain.connect(this.masterGain);
osc.start();
osc.stop(this.ctx.currentTime + 0.25);
} catch(e) {}
}
stop() { this._dissolve(); }
}
// ── class: CanyonChunk ──
class CanyonChunk {
constructor(zStart, zEnd) {
this.zStart = zStart;
this.zEnd = zEnd;
this.centerZ = (zStart + zEnd) / 2;
this.walls = [];
this.obstacles = [];
this.orbs = [];
this._meshGroup = null;
this.generate();
}
generate() {
const width = CONFIG.WORLD.width;
const segCount = Math.max(4, Math.floor(CONFIG.GENERATION.wallSegmentsPerChunk + (this.zStart / 1000) * 2));
for (let i = 0; i < segCount; i++) {
this.walls.push({
z: this.centerZ + (i - segCount/2) * (width / segCount),
leftX: -width/2 + Math.sin(i * 1.3 + this.zStart * 0.015) * width * 0.4,
rightX: width/2 + Math.cos(i * 1.7 + this.centerZ * 0.012) * width * 0.4,
height: 30 + Math.random() * 80,
});
}
const density = CONFIG.GENERATION.obstacleDensityBase + (this.zStart / 500) * 0.1;
for (let i = 0; i < Math.floor(density * width); i++) {
this._placeObstacle(i / density, width);
}
// Orbs scattered throughout the chunk
const orbCount = Math.max(2, Math.min(8, Math.floor(width / CONFIG.WORLD.chunkLength)));
for (let i = 0; i < orbCount; i++) {
this.orbs.push({
z: this.centerZ + (i - orbCount/2) * (width / orbCount),
x: (Math.random() - 0.5) * width * 0.8,
y: -10 + Math.random() * 35,
});
}
// Extra orbs for longer chunks
if (this.zEnd - this.zStart > CONFIG.WORLD.chunkLength * 0.6) {
for (let i = 0; i < 4; i++) {
this.orbs.push({
z: this.centerZ + (Math.random() - 0.5) * width,
x: (Math.random() - 0.5) * width * 0.7,
y: -8 + Math.random() * 45,
});
}
}
}
_placeObstacle(offset, width) {
if (offset < 0.1 || offset > 0.95) return;
const typeRand = Math.random();
let type, x, w, h;
if (typeRand < 0.35) {
// Floating Monolith
type = 'monolith';
x = (Math.random() - 0.5) * width * 1.2;
const tiltZ = Math.sin(offset * 2.7 + this.centerZ * 0.01);
w = 12 + Math.random() * 10;
h = 22 + Math.random() * 30;
} else if (typeRand < 0.68) {
// Tight Squeeze
type = 'squeeze';
x = (Math.random() - 0.5) * width * 1.4;
w = 2 + Math.random() * 3;
h = 38 + Math.random() * 60;
} else {
// Wall Jutting Out
type = 'wall';
x = (Math.random() - 0.5) * width * 1.3;
w = 5 + Math.random() * 12;
h = 18 + Math.random() * 45;
}
this.obstacles.push({ type, z: this.centerZ + x, y: -12 + Math.random() * 30, width: w, height: h });
}
rebuild(scene) {
if (this._meshGroup) scene.remove(this._meshGroup);
const group = new THREE.Group();
this._meshGroup = group;
// Walls
for (const w of this.walls) {
const geo = new THREE.BoxGeometry(0.8, w.height * 1.2, 0.6);
const mat = this._getWallMaterial(w.z, w.leftX, w.rightX);
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set((w.leftX + w.rightX)/2 - this.centerZ, -5 + Math.random()*3, 0);
mesh.castShadow = true;
group.add(mesh);
// Top edge highlight
const lineGeo = new THREE.BoxGeometry(w.height * 1.05, 0.1, 0.62);
const lineMat = new THREE.MeshLambertMaterial({ color: 0xffaa33 });
const lineMesh = new THREE.Mesh(lineGeo, lineMat);
lineMesh.position.set((w.leftX + w.rightX)/2 - this.centerZ, w.height * 1.25 - 5 + Math.random()*3, 0);
group.add(lineMesh);
}
// Obstacles
for (const obs of this.obstacles) {
const geo = new THREE.BoxGeometry(obs.width, obs.height * 1.2, 0.8);
let mat;
if (obs.type === 'monolith') mat = new THREE.MeshLambertMaterial({ color: 0x4a7f6a });
else if (obs.type === 'squeeze') mat = new THREE.MeshLambertMaterial({ color: 0x5a5a8a });
else mat = new THREE.MeshLambertMaterial({ color: 0x3a4a6a });
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(obs.width/2 - this.centerZ, obs.y - 5 + Math.random()*3, 0);
mesh.castShadow = true;
group.add(mesh);
}
// Orbs (created as separate groups)
for (let i = 0; i < this.orbs.length; i++) {
const orbMesh = createOrb(this.orbs[i].z, this.orbs[i].x - this.centerZ, this.orbs[i].y);
group.add(orbMesh);
}
scene.add(group);
}
_getWallMaterial(z, leftX, rightX) {
const hue = ((z / CONFIG.WORLD.horizonDist + leftX / (CONFIG.WORLD.width * 2)) % 1 + 1) % 1;
return new THREE.MeshLambertMaterial({
color: new THREE.Color().setHSL(hue, 0.4, 0.18),
flatShading: true
});
}
// Collision detection helpers
checkCollision(playerX, playerY) {
const halfR = 0.5;
for (const w of this.walls) {
if (Math.abs(playerZ - this.centerZ) > CONFIG.WORLD.chunkLength * 0.4) continue;
// Check if player is between wall segment left and right edges
if (playerX >= w.leftX && playerX <= w.rightX) return true;
}
for (const obs of this.obstacles) {
const dx = Math.abs(playerX - obs.width/2);
const dy = Math.abs((playerY + 10) - obs.height * 0.3);
if (dx < halfR && dy < halfR) return true;
}
// Near-miss distance calculation for scoring
let minDist = Infinity;
for (const w of this.walls) {
const dz = Math.abs(playerZ - this.centerZ);
if (dz > CONFIG.WORLD.chunkLength * 0.45) continue;
const dx1 = Math.abs(playerX - w.leftX);
const dx2 = Math.abs(playerX - w.rightX);
minDist = Math.min(minDist, dx1, dx2);
}
for (const obs of this.obstacles) {
const dz = Math.abs(playerZ - obs.z);
if (dz > CONFIG.WORLD.chunkLength * 0.45) continue;
const dx = Math.abs(playerX - obs.width/2);
minDist = Math.min(minDist, dx);
}
return this._nearMissDistance(playerZ, playerX, playerY);
}
_nearMissDistance(pz, px, py) {
let minDist = Infinity;
for (const w of this.walls) {
if (Math.abs(pz - this.centerZ) > CONFIG.WORLD.chunkLength * 0.4) continue;
const dx1 = Math.abs(px - w.leftX);
const dx2 = Math.abs(px - w.rightX);
minDist = Math.min(minDist, dx1, dx2);
}
for (const obs of this.obstacles) {
if (Math.abs(pz - obs.z) > CONFIG.WORLD.chunkLength * 0.4) continue;
const dx = Math.abs(px - obs.width/2);
minDist = Math.min(minDist, dx);
}
return Math.max(1, minDist);
}
}
// ── class: CanyonManager ──
class CanyonManager {
constructor(scene) {
this.scene = scene;
this.chunks = [];
this.activeChunk = null; // The chunk centered on player's Z
this.spawnZ = -CONFIG.WORLD.horizonDist;
this._recycleZ = CONFIG.WORLD.renderDist + 200;
}
update(playerZ, score) {
const halfLen = CONFIG.WORLD.chunkLength / 2;
const targetChunkZ = playerZ - (this.spawnZ % CONFIG.WORLD.chunkLength);
// Find or create the right chunk
let found = false;
for (let c of this.chunks) {
if (!c.isOverlapping(playerZ)) continue;
if (Math.abs(c.centerZ - targetChunkZ) < halfLen) {
this.activeChunk = c;
found = true;
break;
}
}
if (!found && !this.chunks.length) {
const z0 = Math.floor(this.spawnZ / CONFIG.WORLD.chunkLength) * CONFIG.WORLD.chunkLength - halfLen;
this.chunks.push(new CanyonChunk(z0, z0 + CONFIG.WORLD.chunkLength));
}
// Spawn new chunks ahead
while (this.spawnZ < targetChunkZ - halfLen) {
const nextZ = Math.floor(this.spawnZ / CONFIG.WORLD.chunkLength) * CONFIG.WORLD.chunkLength;
this.chunks.push(new CanyonChunk(nextZ, nextZ + CONFIG.WORLD.chunkLength));
this.spawnZ += CONFIG.WORLD.chunkLength;
}
// Remove old chunks
const removeZ = playerZ - CONFIG.WORLD.renderDist * 1.5;
for (let i = this.chunks.length - 1; i >= 0; i--) {
if (this.chunks[i].zEnd < removeZ) {
this.scene.remove(this.chunks[i]._meshGroup);
this._disposeObject(this.chunks[i]);
this.chunks.splice(i, 1);
}
}
// Cleanup orphaned meshes from removed chunks
const remainingChunkSet = new Set(this.chunks.map(c => c));
for (let i = this.scene.children.length - 1; i >= 0; i--) {
if (this.scene.children[i] === undefined) continue;
const child = this.scene.children[i];
// Check if it's an orphaned mesh group from a removed chunk
if (child.userData && child.userData.parentChunk && !remainingChunkSet.has(child.userData.parentChunk)) {
scene.remove(child);
}
}
return this.activeChunk;
}
_disposeObject(chunk) {
for (const wall of chunk.walls) {
if (wall.mesh) scene.remove(wall.mesh);
wall.mesh = null;
}
for (const obs of chunk.obstacles) {
if (obs.mesh) scene.remove(obs.mesh);
obs.mesh = null;
}
for (const orb of chunk.orbs) {
if (orb.mesh) scene.remove(orb.mesh);
orb.mesh = null;
}
}
rebuildChunk(chunk) {
this._disposeObject(chunk);
// Rebuild walls
const wallGeo = new THREE.BoxGeometry(0.8, 1, 0.5);
for (let i = 0; i < chunk.walls.length; i++) {
const w = chunk.walls[i];
const mesh = new THREE.Mesh(wallGeo, this._getWallMaterial(i));
mesh.position.set(w.leftX + w.rightX)/2, -10 + Math.random()*3, w.z - playerZ;
// Actually we store relative to chunk center - let me use a different approach
}
}
_getWallMaterial(index) {
const h = CONFIG.WORLD.chunkLength / (index === this.walls.length-1 ? this.walls.length : 1);
return new THREE.MeshLambertMaterial({ color: 0x6a3d2a });
}
}
// ── function: createOrb ──
function createOrb(z, x, y) {
// Create a glowing orb with inner glow using emissive material
const group = new THREE.Group();
const coreGeo = new THREE.OctahedronGeometry(0.5, 0);
const coreMat = new THREE.MeshBasicMaterial({
color: 0xffdd44,
transparent: true,
opacity: 0.9
});
const core = new THREE.Mesh(coreGeo, coreMat);
group.add(core);
const glowGeo = new THREE.OctahedronGeometry(1.2, 0);
const glowMat = new THREE.MeshBasicMaterial({
color: 0xffaa33,
transparent: true,
opacity: 0.35
});
const glow = new THREE.Mesh(glowGeo, glowMat);
group.add(glow);
// Outer halo ring
const ringGeo = new THREE.RingGeometry(1.8, 2.2, 16);
const ringMat = new THREE.MeshBasicMaterial({
color: 0xff44aa,
transparent: true,
opacity: 0.25,
side: THREE.DoubleSide
});
const ring = new THREE.Mesh(ringGeo, ringMat);
ring.rotation.x = Math.PI / 2;
group.add(ring);
group.userData.orbZ = z + CONFIG.WORLD.chunkLength / 2; // offset for relative positioning
group.position.set(x, y, -CONFIG.WORLD.chunkLength/2);
return group;
}
// ── class: Player ──
class Player {
constructor(scene) {
this.scene = scene;
// Physics state
this.pos = new THREE.Vector3(0, -15, -CONFIG.WORLD.chunkLength * 2);
this.vel = new THREE.Vector3(0.8, 0, 0);
this.acc = CONFIG.PLAYER.accel;
this.friction = CONFIG.PLAYER.friction;
this.maxSpeed = CONFIG.PLAYER.maxSpeed;
this.boostMaxSpeed = CONFIG.PLAYER.boostMaxSpeed;
this.turnRate = CONFIG.PLAYER.turnRate;
// Angular state (banking/pitching)
this.roll = 0, this.rollTarget = 0;
this.pitch = 0, this.pitchTarget = 0;
this.angularVel = new THREE.Vector2(0, 0);
// Energy & boost
this.energy = CONFIG.ENERGY.orbValue * 4;
this.maxEnergy = CONFIG.ENERGY.orbValue * 4;
this.boostActive = false;
this.boostLevel = 0;
this.turboTimer = 0;
// Scoring
this.score = 0;
this.multiplier = 1.0;
this.nearMissDist = Infinity;
this.lastScoreZ = this.pos.z;
this.totalDistance = 0;
// Visual state
this.mesh = null;
this.engineTrail = [];
this._createMesh();
}
_createMesh() {
const group = new THREE.Group();
// Main craft body - sleek angular shape
const bodyGeo = new THREE.ConeGeometry(0.8, 3, 4);
bodyGeo.rotateX(Math.PI / 2);
bodyGeo.translate(0, 1.5, 0);
const bodyMat = new THREE.MeshLambertMaterial({
color: 0xaaaaaa, flatShading: true, transparent: true, opacity: 0.9
});
this.meshBody = new THREE.Mesh(bodyGeo, bodyMat);
group.add(this.meshBody);
// Wing fins (left and right)
const wingGeo = new THREE.BoxGeometry(2.5, 0.1, 1.2);
const wingLeft = new THREE.Mesh(wingGeo, bodyMat.clone());
wingLeft.position.set(-1.8, 0, 0.4);
group.add(wingLeft);
const wingRight = new THREE.Mesh(wingGeo, bodyMat.clone());
wingRight.position.set(1.8, 0, 0.4);
group.add(wingRight);
// Nose cone - brighter
const noseGeo = new THREE.ConeGeometry(0.5, 1.2, 6);
noseGeo.rotateX(Math.PI / 2);
noseGeo.translate(0, 1.8, 0);
const noseMat = new THREE.MeshBasicMaterial({
color: 0xffdd44, transparent: true, opacity: 0.7
});
this.meshNose = new THREE.Mesh(noseGeo, noseMat);
group.add(this.meshNose);
// Engine glow at rear
const engineGeo = new THREE.SphereGeometry(0.6, 8, 8);
const engineMat = new THREE.MeshBasicMaterial({
color: 0xff2244, transparent: true, opacity: 0.8
});
this.meshEngine = new THREE.Mesh(engineGeo, engineMat);
this.meshEngine.position.z = -1.5;
group.add(this.meshEngine);
// Engine glow particles container
this._engineGlowParticles = [];
for (let i = 0; i < 8; i++) {
const pGeo = new THREE.SphereGeometry(0.2 + Math.random()*0.3, 6, 6);
const pMat = new THREE.MeshBasicMaterial({
color: new THREE.Color().setHSL(0.1 + Math.random()*0.05, 0.9, 0.4),
transparent: true, opacity: 0.6
});
const pMesh = new THREE.Mesh(pGeo, pMat);
this._engineGlowParticles.push({ mesh: pMesh, offset: i / 8 });
group.add(pMesh);
}
// Wing lights (cyan accents)
const lightGeo = new THREE.BoxGeometry(0.2, 0.2, 1.5);
const lightMat = new THREE.MeshBasicMaterial({ color: 0x44ffaa });
for (let side of [-1, 1]) {
const l1 = new THREE.Mesh(lightGeo, lightMat.clone());
l1.position.set(side * 2.2, 0.6, 0.8);
group.add(l1);
const l2 = new THREE.Mesh(lightGeo, lightMat.clone());
l2.position.set(side * 2.2, -0.6, 0.8);
group.add(l2);
}
// Wireframe outline for style
const wireGeo = new THREE.OctahedronGeometry(1.5, 0);
wireGeo.rotateX(Math.PI/2).translate(0, 2, 0);
const wireMat = new THREE.MeshBasicMaterial({
color: 0xff44aa, wireframe: true, transparent: true, opacity: 0.3
});
this.meshWireframe = new THREE.Mesh(wireGeo, wireMat);
group.add(this.meshWireframe);
// Position at player location (offset by y since we're below ground)
const offsetZ = -CONFIG.WORLD.chunkLength / 2;
group.position.set(0, 0, offsetZ);
this.mesh = group;
scene.add(group);
}
update(inputState, dt) {
// Pitch input (vertical control via mouse scroll or space/shift)
if (inputState.pitch > 0) {
this.vel.z += CONFIG.PLAYER.pitchSens * inputState.pitch * this.maxSpeed;
} else if (inputState.pitch < 0) {
this.vel.z -= Math.abs(CONFIG.PLAYER.pitchSens) * Math.min(inputState.pitch, -1);
}
// Horizontal steering target based on input
const steerTarget = inputState.steer || 0;
const currentSteerDir = (this.vel.x > 0 ? 1 : this.vel.x < 0 ? -1 : 0);
const desiredVelX = steerTarget * this.maxSpeed * currentSteerDir;
// Apply momentum with damping
this.vel.x *= Math.max(0.9, 1 - dt * CONFIG.PLAYER.turnRate * Math.abs(this.vel.z));
this.vel.y *= this.friction;
// Banking during turns
const steerAmount = desiredVelX / this.maxSpeed;
if (steerAmount !== 0) {
this.rollTarget -= steerAmount * CONFIG.PLAYER.bankSensitivity * this.turnRate;
this.pitchTarget += Math.abs(steerAmount) * 0.3;
} else {
this.rollTarget = THREE.MathUtils.lerp(this.rollTarget, 0, dt * 2);
if (this.meshPitch !== undefined) {
this.meshPitch = THREE.MathUtils.lerp(this.meshPitch, 0, dt * 2);
}
}
// Energy decay + boost mechanics
const energyDecay = CONFIG.ENERGY.decayRate + Math.abs(this.vel.z / this.maxSpeed) * 0.01;
if (this.boostActive && this.energy > 0) {
this.turboTimer -= dt;
const costPerFrame = CONFIG.ENERGY.boostCostPerFrame;
this.energy -= costPerFrame;
// Turbo boosts horizontal velocity dramatically
this.vel.x *= 1.15;
} else if (this.vel.x > 0 || this.vel.x < 0) {
// Natural deceleration on x-axis when not boosting
this.vel.x = Math.max(-this.friction, Math.min(this.friction, this.vel.x));
}
// Apply energy decay to base
this.energy = THREE.MathUtils.clamp(this.energy - energyDecay * dt, 0, this.maxEnergy);
// Boost activation
if (inputState.boost && this.energy >= CONFIG.ENERGY.orbValue) {
this.boostActive = true;
this.boostLevel = Math.min(1, this.energy / CONFIG.ENERGY.orbValue * 3);
this.turboTimer = 0.4;
} else if (this.turboTimer > 0) {
// Boost fading out
const fadeAmount = 1 - this.turboTimer / 0.4;
if (fadeAmount < 1) {
this.boostLevel *= fadeAmount;
if (this.boostLevel <= 0.05) {
this.boostActive = false;
this.boostLevel = 0;
}
}
} else {
// Slow down under boost
const slowFactor = (1 - this.boostLevel) * 0.96;
if (this.vel.x > 2 || this.vel.x < -2) {
this.vel.x *= Math.max(0.5, slowFactor);
}
}
// Cap speed
const targetSpeed = this.boostActive ? this.boostMaxSpeed * this.boostLevel : this.maxSpeed;
if (this.vel.length() > targetSpeed) {
const scale = targetSpeed / this.vel.length();
this.vel.multiplyScalar(scale);
}
// Move player forward
this.pos.x += this.vel.x * dt;
this.pos.y += this.vel.y * dt;
this.pos.z += this.vel.z * dt;
// Track score and distance
const distMoved = Math.abs(this.pos.z - this.lastScoreZ);
if (distMoved > 0.1) {
const speedBonus = Math.min(2, CONFIG.PLAYER.maxSpeed / distMoved * 2);
this.score += distMoved * CONFIG.SCORING.baseScorePerUnit + speedBonus;
this.totalDistance += distMoved;
this.lastScoreZ = this.pos.z;
}
// Update angular state (interpolate to target)
this.roll = THREE.MathUtils.lerp(this.roll, this.rollTarget, dt * 3);
if (this.meshPitch !== undefined) {
this.meshPitch = THREE.MathUtils.lerp(this.meshPitch, this.pitchTarget, dt * 2.5);
}
// Update mesh position relative to player pos
const worldPos = new THREE.Vector3(0, -15 + this.pos.y, this.pos.z);
if (this.mesh.position) {
this.mesh.position.copy(worldPos);
}
// Update engine glow particles
for (const p of this._engineGlowParticles) {
const speed = Math.abs(this.vel.z);
p.mesh.position.z -= (speed + 0.5) * dt;
if (p.mesh.position.z < -4) {
p.mesh.visible = false;
} else {
p.mesh.visible = true;
// Flash on boost
p.mesh.material.opacity = 0.6 + Math.sin(Date.now() * 0.015 * this.boostLevel) * 0.3;
if (this.boostActive && this.boostLevel > 0.8) {
p.mesh.material.color.setHSL(0.05, 1, 0.7); // white-hot on turbo
} else {
p.mesh.material.color.setHSL(0.05 + Math.random()*0.03, 0.9, 0.4);
}
}
}
// Engine trail particles
if (Math.abs(this.vel.z) > 1) {
const newTrail = new TrailParticle();
newTrail.life = 1;
newTrail.size = 0.3 + Math.random() * 0.5;
newTrail.color = this.boostActive && this.boostLevel > 0.6
? new THREE.Color(0xff4422)
: new THREE.Color().setHSL(0.1, 0.8, 0.3);
// Position behind player
const offset = (-speed * dt) + (Math.random() - 0.5) * 0.5;
newTrail.mesh.position.set(
this.pos.x + (Math.random() - 0.5) * 2,
this.pos.y - 10 + Math.random() * 8,
this.pos.z + offset
);
particleSystem.add(newTrail);
}
// Update wireframe to match bank/pitch
if (this.meshWireframe && this.meshBody) {
const bankRot = new THREE.Euler(this.roll, 0, this.meshPitch, 'XYZ');
this.meshBody.rotation.set(
Math.sin(this.pitch * 0.3) * 0.15,
-bankRot.x,
bankRot.z
);
}
// Near-miss detection (handled by CanyonManager)
this.nearMissDist = Infinity; // Reset, will be set by CanyonManager
return { scoreDelta: distMoved * CONFIG.SCORING.baseScorePerUnit };
}
collectOrb() {
if (!this.boostActive || this.energy >= this.maxEnergy) return false;
const cost = Math.min(CONFIG.ENERGY.orbValue, (1 - this.energy / this.maxEnergy) * CONFIG.ENERGY.orbValue);
this.energy += cost;
return true;
}
die() {
soundSynth.crash();
if (this.mesh) scene.remove(this.mesh);
}
restart() {
this.score = 0;
this.multiplier = 1.0;
this.totalDistance = 0;
this.nearMissDist = Infinity;
// Reset physics
this.pos.set(0, -15, -CONFIG.WORLD.chunkLength * 2);
this.vel.set(0.8, 0, 0);
this.roll = 0;
this.pitchTarget = 0;
this.energy = CONFIG.ENERGY.orbValue * 4;
this.boostActive = false;
this.turboTimer = 0;
this.boostLevel = 0;
// Reset engine glow
for (const p of this._engineGlowParticles) {
p.mesh.position.z = -1.5;
p.mesh.material.opacity = 0.6;
p.mesh.visible = true;
}
if (this.mesh) scene.remove(this.mesh);
this._createMesh();
scene.add(this.mesh);
}
}
// ── class: TrailParticle ──
class TrailParticle {
constructor() {
this.life = 1;
this.maxLife = 1;
this.size = 0.3 + Math.random() * 0.5;
this.color = new THREE.Color(0xff44aa);
const geo = new THREE.SphereGeometry(this.size, 6, 6);
const mat = new THREE.MeshBasicMaterial({
color: this.color, transparent: true, opacity: 1
});
this.mesh = new THREE.Mesh(geo, mat);
}
update(dt) {
this.life -= dt * 2;
if (this.life <= 0) return false; // expired
const t = this.life / this.maxLife;
this.mesh.material.opacity = t * 0.6;
this.size *= (1 - t);
return true; // still alive
}
dispose() {
if (this.mesh) scene.remove(this.mesh);
}
}
// ── class: ParticleSystem ──
class ParticleSystem {
constructor(scene) {
this.scene = scene;
this.particles = [];
this._geoPool = null;
// Pre-allocate geometry buffer for performance
const geom = new THREE.BufferGeometry();
const positions = new Float32Array(3 * CONFIG.VISUAL.trailLength);
geom.setAttribute('position', new THREE.BufferAttribute(positions, 3));
this._geoPool = geom;
const mat = new THREE.PointsMaterial({
color: 0xffdd44,
size: 2,
transparent: true,
opacity: 0.6,
blending: THREE.AdditiveBlending,
depthWrite: false
});
this._systemGeom = geom.clone();
this._systemMat = new THREE.PointsMaterial({
color: new THREE.Color(0xffaa33),
size: 1.5,
transparent: true,
opacity: 0.7,
blending: THREE.AdditiveBlending,
depthWrite: false
});
this._system = new THREE.Points(this._systemGeom, this._systemMat);
scene.add(this._system);
}
add(debris) {
const count = CONFIG.VISUAL.dustRate;
for (let i = 0; i < count; i++) {
this.particles.push({
mesh: null, // We'll use a simple approach - just track positions
life: 1,
maxLife: 0.5 + Math.random() * 0.5,
size: 0.3 + Math.random() * 0.8,
vx: (Math.random() - 0.5) * 4,
vy: (Math.random() - 1) * 2,
vz: 2 + Math.random() * 6,
x: debris.x + (Math.random()-0.5)*3,
y: debris.y + (Math.random()-0.5)*2,
z: debris.z + (Math.random()-1)*8,
});
}
// Add to buffer if we have enough
this.particles.push({
mesh: null,
life: 1,
maxLife: 0.3,
size: 0.5 + Math.random() * 1.0,
vx: (Math.random()-0.5)*2,
vy: -1 + Math.random()*4,
vz: 1 + Math.random()*4,
x: debris.x + (Math.random()-0.5),
y: debris.y + Math.random()*8,
z: debris.z + Math.random()*6,
});
// If particles exceed buffer size, reset oldest ones
if (this.particles.length > CONFIG.VISUAL.trailLength) {
const toRemove = this.particles.splice(0, this.particles.length - CONFIG.VISUAL.trailLength);
for (const p of toRemove) {
// Could dispose meshes here but we're using buffer approach
}
}
}
update(dt) {
const positions = this._systemGeom.attributes.position.array;
let idx = 0;
for (let i = 0; i < CONFIG.VISUAL.trailLength && idx < positions.length; i++) {
if (i >= this.particles.length) break;
const p = this.particles[i];
if (!p || p.life <= 0) continue;
// Update position based on velocity
p.x += p.vx * dt;
p.y += p.vy * dt;
p.z += p.vz * dt;
// Decrease life (moves toward horizon = dies faster as it goes further back)
const fadeSpeed = 2 + Math.abs(p.vz / 10);
p.life -= dt * fadeSpeed;
if (p.life <= 0) continue;
positions[idx++] = p.x;
positions[idx++] = p.y;
positions[idx++] = p.z;
}
// Fill remaining with zeroed positions (invisible)
while (idx < positions.length) {
positions[idx++] = 0;
positions[idx++] = 0;
positions[idx++] = 0;
}
this._systemGeom.attributes.position.needsUpdate = true;
}
dispose() {
if (this._system) scene.remove(this._system);
}
}
// ── class: HUDController ──
class HUDController {
constructor() {
this.scoreEl = document.getElementById('score-val');
this.multEl = document.getElementById('mult-val');
this.speedMarker = document.getElementById('speed-marker');
this.energyFill = document.getElementById('energy-fill');
this.multiplierPulse = false;
// Speed calibration: 0-100% arc for speed range
this.maxSpeedDisplay = CONFIG.PLAYER.boostMaxSpeed * 2.5; // Scale up for visual effect
this.baseSpeed = CONFIG.PLAYER.maxSpeed;
this.boostRatio = this.maxSpeedDisplay / (this.baseSpeed + CONFIG.PLAYER.boostMaxSpeed);
// Initial state
this.update({ score: 0, multiplier: 1, speed: 0, energy: 1, boostActive: false });
}
update(state) {
if (!state || !this.scoreEl) return;
const s = state;
// Score display with pulsing effect on multiplier change
this.scoreEl.textContent = Math.floor(s.score).toLocaleString();
if (s.multiplier > 1 && s.lastMultiplier !== undefined && s.lastMultiplier < 2) {
this._triggerPulse();
}
s.lastMultiplier = s.multiplier;
// Multiplier display with pulse animation class
this.multEl.textContent = s.multiplier.toFixed(1) + 'x';
if (s.nearMiss && !this.multiplierPulse) {
this._triggerPulse();
}
// Speedometer arc - position marker along the curve
const speedRatio = Math.min(s.speed / this.maxSpeedDisplay, 0.95);
const totalArcLen = 280; // matches dasharray
const offset = totalArcLen * (1 - speedRatio) * 0.6; // scaled range
if (this.speedMarker) {
this.speedMarker.setAttribute('stroke-dashoffset', offset.toFixed(1));
}
// Color the marker based on speed zone
if (s.boostActive && s.boostLevel > 0.7) {
this.speedMarker.style.fill = '#fff';
} else if (speedRatio > 0.85) {
this.speedMarker.style.fill = '#ff4422';
} else if (speedRatio > 0.6) {
this.speedMarker.style.fill = '#ffaa00';
} else {
this.speedMarker.style.fill = '#00ffaa';
}
// Energy bar
const energyPct = s.energy / s.maxEnergy;
this.energyFill.style.width = (energyPct * 100).toFixed(1) + '%';
if (energyPct < 0.3) {
this.energyFill.classList.add('low');
} else {
this.energyFill.classList.remove('low');
}
// Update visibility
const isPlaying = s && s.totalDistance > 1;
document.getElementById('hud-speedometer').classList.toggle('hidden', !isPlaying);
document.getElementById('hud-energy-bar').classList.toggle('hidden', !isPlaying);
document.getElementById('multiplier-display').classList.toggle('hidden', !isPlaying);
// Near-miss pulse class
this.multiplierPulse = s.nearMiss || false;
if (this.multEl) {
this.multEl.classList.toggle('pulse', this.multiplierPulse);
}
}
_triggerPulse() {
if (!this.multEl) return;
// Use a quick CSS transition via JS manipulation
const pulseCount = Math.max(1, Math.min(3, Math.floor(this.score / 50)));
let i = 0;
this._pulseInterval = setInterval(() => {
if (this.multEl) this.multEl.classList.toggle('pulse');
i++;
if (i >= pulseCount * 4) clearInterval(this._pulseInterval);
}, 150);
}
gameOver(score, maxSpeed) {
document.getElementById('go-score').textContent = `Final Score: ${Math.floor(score).toLocaleString()}`;
document.getElementById('go-speed').textContent = `Max Speed: ${(maxSpeed / CONFIG.PLAYER.boostMaxSpeed * 100).toFixed(0)}%`;
document.getElementById('game-over-overlay').classList.remove('hidden');
}
}
// ── class: CameraController ──
class CameraController {
constructor(scene) {
this.scene = scene;
this.camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, CONFIG.WORLD.horizonDist * 2);
// Camera state
this.targetPos = new THREE.Vector3();
this.position = new THREE.Vector3();
this.targetFOV = CONFIG.PLAYER.boostFOVBase;
this.currentFOV = CONFIG.PLAYER.boostFOVBase;
this.shakeIntensity = 0;
this.shakeAccum = 0;
this.fovZoomFactor = CONFIG.VISUAL.boostFOVZoom;
scene.add(this.camera);
// Add ambient and directional lighting
const ambLight = new THREE.AmbientLight(0x4a3520, 0.6);
scene.add(ambLight);
this.dirLight = new THREE.DirectionalLight(0xffaa33, 1.2);
this.dirLight.position.set(50, 80, -30);
this.dirLight.castShadow = true;
this.dirLight.shadow.camera.left = -100;
this.dirLight.shadow.camera.right = 100;
this.dirLight.shadow.camera.top = 100;
this.dirLight.shadow.camera.bottom = -100;
this.dirLight.shadow.camera.near = 1;
this.dirLight.shadow.camera.far = CONFIG.WORLD.horizonDist * 2;
this.dirLight.shadow.mapSize.width = 2048;
this.dirLight.shadow.mapSize.height = 2048;
scene.add(this.dirLight);
// Secondary warm light from below (underground feel)
const underLight = new THREE.PointLight(0xff6633, 0.5, 100);
underLight.position.set(0, -20, 0);
scene.add(underLight);
// Purple accent light
const purpleLight = new THREE.PointLight(0x8844ff, 0.3, 80);
purpleLight.position.set(-60, 10, 20);
scene.add(purpleLight);
}
update(playerPos, state) {
// Target position: offset behind and below player
const offset = new THREE.Vector3(
-playerPos.x * 0.3,
-18 + playerPos.y * 0.5,
-65
);
this.targetPos.set(playerPos.x + offset.x, playerPos.y + offset.y, playerPos.z + offset.z);
// Add some forward lean based on pitch
const pitchLean = (state?.pitch || 0) * 2;
this.targetPos.y += pitchLean;
// Smooth camera position following target
this.position.x += (this.targetPos.x - this.position.x) * 0.15;
this.position.y += (this.targetPos.y - this.position.y) * 0.12;
this.position.z += (this.targetPos.z + this.shakeAccum * 0.3 - this.position.z) * 0.12;
// Apply shake to Z position
if (this.shakeIntensity > 0 && Math.random() < 0.5) {
const shakeScale = this.shakeIntensity / CONFIG.VISUAL.shakeNearMissIntensity;
this.shakeAccum += (Math.random() - 0.5) * 2 * shakeScale;
} else if (this.shakeAccum > 0) {
this.shakeAccum *= 0.85;
}
// FOV zoom on boost
const speedRatio = state?.speed / CONFIG.PLAYER.boostMaxSpeed || 0;
this.targetFOV = CONFIG.PLAYER.boostFOVBase + speedRatio * CONFIG.VISUAL.boostFOVZoom;
this.currentFOV += (this.targetFOV - this.currentFOV) * 0.15;
// Apply camera
if (state?.nearMiss || this.shakeIntensity > 0) {
const shakeScale = state?.nearMiss ? CONFIG.VISUAL.shakeNearMissIntensity : this.shakeIntensity;
// Camera shake vector
if (this.position.x === undefined) {
// First frame
this.position.set(this.targetPos.clone());
}
this.camera.position.copy(this.position);
} else {
this.camera.position.copy(this.position);
}
// Look slightly ahead of player position
const lookTarget = new THREE.Vector3(
this.targetPos.x + state?.steer || 0,
this.targetPos.y - (state?.pitch || 0) * 1.5,
this.targetPos.z + 40
);
// Add FOV zoom offset to look target
const fovZoomOffset = state?.boostLevel ? speedRatio * CONFIG.VISUAL.boostFOVZoom : 0;
lookTarget.y += fovZoomOffset;
this.camera.lookAt(lookTarget);
this.camera.fov = this.currentFOV;
this.camera.updateProjectionMatrix();
}
setShake(intensity) {
this.shakeIntensity = intensity || 0;
}
}
// ── class: PostProcessor ──
class PostProcessor {
constructor(scene, renderer) {
this.scene = scene;
this.renderer = renderer;
this.composer = null;
if (THREE.EffectComposer && THREE.UnrealBloomPass) {
// Three.js includes these in the main build for r128+
// Create a copy of camera with bloom pass target resolution
const bloomCamera = new THREE.Camera();
bloomCamera.copy(renderer.camera);
bloomCamera.position.set(0, 0, 0);
this.composer = new THREE.EffectComposer(renderer);
this.composer.addPass(new THREE.RenderPass(scene, renderer.camera));
// Bloom pass
const bloomRes = Math.max(128, renderer.domElement.width * 0.5),
bloomHeight = Math.max(64, renderer.domElement.height * 0.5);
this.composer.addPass(new THREE.UnrealBloomPass(
new THREE.Vector2(bloomRes, bloomHeight),
1.2, // strength - makes glowing elements bright
0.4, // radius - blur spread
0.8 // threshold - minimum brightness to bloom
));
// Chromatic aberration for speed feel
this.composer.addPass(new THREE.ChromaticAberrationPass(
new THREE.Vector2(renderer.domElement.width, renderer.domElement.height),
0.002 + (renderer.camera.fov / 90) * 0.0015
));
// Vignette pass for depth
this.composer.addPass(new THREE.VignettePass(
new THREE.Vector2(renderer.domElement.width, renderer.domElement.height),
0.6, // darkness
0.8, // offset
1.5 // roundness
));
// Color grading - warm golden hour tint
this.composer.addPass(new THREE.ShaderPass(THREE.UniformsLib[null] || {}, 'vertexShader', 'fragmentShader'), {
resolution: new THREE.Vector2(renderer.domElement.width, renderer.domElement.height),
exposure: 1.3,
contrast: 0.95,
saturation: 1.15,
});
} else {
// Fallback - use simple post-processing with tone mapping
this.composer = null;
}
}
render(scene, camera) {
if (this.composer) {
this.composer.render(scene, camera);
} else {
this.renderer.render(scene, camera);
}
}
}
// ── class: GameLoop ──
class GameLoop {
constructor() {
this.scene = new THREE.Scene();
this.renderer = new THREE.WebGLRenderer({
antialias: true, alpha: false, powerPreference: 'high-performance'
});
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.outputEncoding = THREE.sRGBEncoding;
// Post processor (Three.js r128 bundles EffectComposer + bloom)
if (THREE.EffectComposer && THREE.UnrealBloomPass) {
const bloomCamera = new THREE.Camera();
bloomCamera.copy(this.camera || this.renderer.domElement);
this.postProcessor = new PostProcessor(this.scene, this.renderer);
} else {
this.postProcessor = null;
}
// Sound synth instance - created here to ensure availability
this.soundSynth = new SoundSynth();
this.state = { running: false };
this.inputState = { steer: 0, pitch: 0, boost: false };
this._keyBoost = false;
this._keyPitch = 0;
this._keyState = {};
// Game objects (initialized in main)
this.player = null;
this.canyonManager = null;
this.particleSystem = null;
this.hud = new HUDController();
this._gameTime = 0;
window.addEventListener('resize', () => this.onResize());
document.addEventListener('keydown', e => this._onKeyDown(e));
document.addEventListener('keyup', e => this._onKeyUp(e));
this.renderer.domElement.style.cursor = 'none';
}
start() {
if (this.state.running) return;
// Init audio on user interaction
if (!this.soundSynth.ctx || this.soundSynth.ctx.state === 'suspended') {
this.soundSynth.init();
}
this.player = new Player(this.scene);
this.canyonManager = new CanyonManager(this.scene);
this.particleSystem = new ParticleSystem(this.scene);
// Generate initial canyon chunks
const halfChunk = CONFIG.WORLD.chunkLength / 2;
for (let i = -1; i < 4; i++) {
const zStart = Math.floor(i * CONFIG.WORLD.chunkLength) - halfChunk;
this.canyonManager.chunks.push(new CanyonChunk(zStart, zStart + CONFIG.WORLD.chunkLength));
}
// Set initial player position matching first chunk
const firstZ = Math.floor(-CONFIG.WORLD.chunkLength * 2);
this.player.pos.set(0, -15, firstZ);
document.getElementById('start-overlay').classList.add('hidden');
this.state.running = true;
// Kick off game loop
const loop = () => {
if (!this.state.running) return;
requestAnimationFrame(loop);
this._tick();
};
loop();
}
_getKeySteer() {
let dir = 0;
for (const k of ['ArrowLeft', 'a', 'A']) { if (this._keyState[k]) dir--; }
for (const k of ['ArrowRight', 'd', 'D']) { if (this._keyState[k]) dir++; }
return dir; // -1, 0, or 1
}
_onKeyDown(e) {
const key = e.key.toLowerCase();
switch(key) {
case 'arrowleft': case 'a': this.inputState.steer -= 1; break;
case 'arrowright': case 'd': this.inputState.steer += 1; break;
case 'w': case 'arrowup':
if (!this._keyPitch) this._keyPitch = 0;
this._keyPitch = Math.max(this._keyPitch || 0, 2); break;
case 's': case 'arrowdown':
this._keyPitch -= 1.5; break;
case 'shift': case ' ':
if (this.state.running) {
this._keyBoost = true;
this.inputState.boost = true;
} else if (!this.soundSynth.ctx) {
this.soundSynth.init();
} break;
case 'escape':
if (!this.state.running) {
this.start();
} else {
document.exitPointerLock?.();
this.soundSynth.stop();
} break;
}
if (key !== 'escape') {
this.inputState.steer = Math.max(-1, Math.min(1, this.inputState.steer));
if (!['shift',' ','w','s','arrowup','arrowdown'].includes(key)) {
this._keyPitch *= 0.95;
}
}
}
_onKeyUp(e) {
const key = e.key.toLowerCase();
if (key === 'shift' || key === ' ') {
this._keyBoost = false;
this.inputState.boost = false;
} else if (['arrowleft','a','arrowright','d'].includes(key)) {
// Steer decays naturally via player physics friction
}
}
onResize() {
const w = window.innerWidth, h = window.innerHeight;
this.renderer.setSize(w, h);
if (this.camera) {
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
}
}
gameOver() {
this.state.running = false;
this.soundSynth.stop();
document.exitPointerLock?.();
const maxSpeed = Math.max(
CONFIG.PLAYER.boostMaxSpeed * 2.5,
1 + this.player.score * 0.01);
this.hud.gameOver(this.player.score, maxSpeed);
}
restart() {
// Clean up all scene objects except lights and camera
const toRemove = [];
for (const child of this.scene.children) {
if (child !== this.camera && !['DirectionalLight', 'PointLight'].includes(child.constructor.name)) {
toRemove.push(child);
}
}
for (let i = toRemove.length - 1; i >= 0; i--) {
this.scene.remove(toRemove[i]);
}
// Rebuild canyon chunks with fresh instances
const halfChunk = CONFIG.WORLD.chunkLength / 2;
this.canyonManager.chunks = [];
for (let i = -1; i < 4; i++) {
const zStart = Math.floor(i * CONFIG.WORLD.chunkLength) - halfChunk;
this.canyonManager.chunks.push(new CanyonChunk(zStart, zStart + CONFIG.WORLD.chunkLength));
}
// Reset player
this.player.restart();
document.getElementById('game-over-overlay').classList.add('hidden');
this.hud.update({ score: 0, multiplier: 1, speed: 0, energy: 1, boostActive: false });
this.state.running = true;
}
dispose() {
if (this.particleSystem) this.particleSystem.dispose();
if (this.postProcessor?.composer) this.postProcessor.composer.dispose();
this.soundSynth.stop();
}
}
// ── const: main ──
// Entry point - creates GameLoop and wires up UI/events
const main = function() {
const game = new GameLoop();
window._gameInstance = game;
// Mouse controls for steering (active when pointer locked)
document.addEventListener('mousemove', function(e) {
if (!document.pointerLockElement || !window._gameInstance?.state.running) return;
const steer = e.movementX * 0.025;
game.inputState.steer += steer;
game.inputState.steer = Math.max(-1, Math.min(1, game.inputState.steer));
if (e.movementY && game.inputState.pitch === 0) {
game.inputState.pitch += e.movementY * 0.05;
} else if (e.movementY) {
game.inputState.pitch = Math.max(-3, Math.min(3,
(game.inputState.pitch || 0) + e.movementY * 0.02));
}
lastMouseX = e.clientX;
});
document.addEventListener('wheel', function(e) {
if (!document.pointerLockElement || !window._gameInstance?.state.running) return;
const delta = Math.sign(e.deltaY); // -1 forward, +1 backward
game.inputState.pitch += delta * 2;
game.inputState.pitch = Math.max(-3, Math.min(3, game.inputState.pitch));
e.preventDefault();
}, { passive: false });
const canvas = document.getElementById('game-canvas');
canvas.addEventListener('click', function(e) {
e.preventDefault();
if (!document.pointerLockElement) {
canvas.requestPointerLock();
}
});
// Pointer lock change handler - manages start/stop state
document.addEventListener('pointerlockchange', function() {
const g = window._gameInstance;
if (g && !document.pointerLockElement && g.state.running) {
g.state.running = false;
g.soundSynth.stop();
} else if (!document.pointerLockElement) {
// Not locked - show start overlay if needed
const startOverlay = document.getElementById('start-overlay');
if (startOverlay && !startOverlay.classList.contains('hidden')) return;
if (!g?.soundSynth.ctx) g.soundSynth.init();
startOverlay?.classList.remove('hidden');
}
});
// Keyboard controls (fallback when mouse isn't available)
document.addEventListener('keydown', function(e) {
const key = e.key.toLowerCase();
switch(key) {
case 'arrowleft': case 'a': game.inputState.steer -= 1; break;
case 'arrowright': case 'd': game.inputState.steer += 1; break;
case 'w': case 'arrowup':
if (!game.inputState.pitch) game.inputState.pitch = 0;
game.inputState.pitch = Math.max(game.inputState.pitch || 0, 2); break;
case 's': case 'arrowdown':
game.inputState.pitch -= 1.5; break;
case 'shift': case ' ':
if (game.state.running) {
game._keyBoost = true;
game.inputState.boost = true;
} else if (!window._gameInstance?.state.running) {
game.soundSynth.init();
} break;
case 'escape':
if (!window._gameInstance?.state.running) {
game.start();
} else if (window._gameInstance?.state.running) {
document.exitPointerLock();
game.soundSynth.stop();
} break;
}
// Clamp steer from keyboard
if (key !== 'escape' && window._gameInstance) {
game.inputState.steer = Math.max(-1, Math.min(1, game.inputState.steer));
// Reset pitch toward zero with decay when no keys held
if (!['shift',' ','w','s','arrowup','arrowdown'].includes(key)) {
game.inputState.pitch *= 0.95;
}
}
});
document.addEventListener('keyup', function(e) {
const key = e.key.toLowerCase();
if (key === 'shift' || key === ' ') {
game._keyBoost = false;
game.inputState.boost = false;
} else if (['arrowleft','a','arrowright','d'].includes(key)) {
// Steer decays naturally via player physics friction
}
});
// Hide HUD until game starts
const hudEls = document.querySelectorAll('#hud-top, #hud-speedometer, #hud-energy-bar');
for (const el of hudEls) el.classList.add('hidden');
// Start the game on load after a brief delay to show start screen
setTimeout(function() {
if (!window._gameInstance) return;
const canvas = document.getElementById('game-canvas');
canvas.addEventListener('click', function(e) {
e.preventDefault();
// If not locked, lock it
if (!document.pointerLockElement) {
canvas.requestPointerLock();
} else {
// Already locked - just start the game
}
window._gameInstance.start();
for (const el of hudEls) el.classList.remove('hidden');
document.getElementById('start-overlay').classList.add('hidden');
}, true);
}, 500);
return game;
};
// ── const: soundSynth ──
// Global instance - created before main runs
const soundSynth = new SoundSynth();
// ── main ──
// Main entry point - called automatically after all units are defined
document.addEventListener('DOMContentLoaded', function() { main(); });
</script>
</body>
</html>