Canyon Run
Qwen 3.6 27b MTP · composer

Reviews & generation details
Reviews
Human review · Jul 17, 2026 · rubric 1
Historical path-based review. Association with these exact bytes is unverified.
- Visual design
- 4 / 5
- UX / feel
- 4 / 5
- Works
- 3 / 5
- Prompt spirit
- 5 / 5
- Delight
- 4 / 5
Gorgeous, feels great; but terrain runs out after a few seconds
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 75,345 bytes · SHA-256 59cc201a5db4
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Canyon Racer — High-Speed Procedural Arcade</title>
<script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
<style>
/* ── css: layout ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #0a0015; font-family: 'Courier New', monospace; }
#game-container { position: relative; width: 100vw; height: 100vh; overflow: hidden; }
#game-canvas { display: block; width: 100%; height: 100%; }
.hidden { display: none !important; }
/* Vignette */
#vignette {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none; z-index: 5;
background: radial-gradient(ellipse at center, transparent 50%, rgba(10,0,21,0.7) 100%);
}
/* HUD */
#hud { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; }
/* Speed Bar (left side) */
#speed-bar-container {
position: absolute; left: 30px; top: 50%; transform: translateY(-50%);
display: flex; align-items: center; gap: 12px;
}
#speed-label { font-size: 10px; color: rgba(255,140,50,0.6); letter-spacing: 3px; writing-mode: vertical-rl; text-orientation: mixed; }
#speed-track { width: 8px; height: 200px; background: rgba(255,255,255,0.05); border-radius: 4px; overflow: hidden; position: relative; }
#speed-fill { position: absolute; bottom: 0; width: 100%; height: 30%; background: linear-gradient(to top, #ff6a00, #ee0979); border-radius: 4px; transition: height 0.1s ease-out; }
#speed-value { font-size: 28px; color: #ff8c32; font-weight: bold; min-width: 50px; text-shadow: 0 0 10px rgba(255,140,50,0.5); }
/* Multiplier */
#multiplier-container { position: absolute; top: 60px; left: 50%; transform: translateX(-50%); text-align: center; }
#multiplier-label { font-size: 10px; color: rgba(255,255,255,0.3); letter-spacing: 4px; margin-bottom: 4px; }
#multiplier-value { font-size: 48px; color: #ff6a00; font-weight: bold; text-shadow: 0 0 20px rgba(255,106,0,0.6); transition: transform 0.1s; }
#multiplier-value.pulse { animation: multPulse 0.3s ease-out; }
/* Score */
#score-container { position: absolute; top: 40px; right: 40px; text-align: right; }
#score-value { font-size: 36px; color: rgba(255,255,255,0.8); font-weight: bold; letter-spacing: 2px; text-shadow: 0 0 15px rgba(255,255,255,0.3); }
/* Energy Bar */
#energy-bar-container { position: absolute; bottom: 40px; left: 50%; transform: translateX(-50%); width: 300px; text-align: center; }
#energy-label { font-size: 10px; color: rgba(255,255,255,0.3); letter-spacing: 4px; margin-bottom: 6px; }
#energy-track { width: 100%; height: 6px; background: rgba(255,255,255,0.08); border-radius: 3px; overflow: hidden; }
#energy-fill { width: 100%; height: 100%; background: linear-gradient(to right, #ee0979, #ff6a00); border-radius: 3px; transition: width 0.15s ease-out; box-shadow: 0 0 8px rgba(255,106,0,0.4); }
#energy-fill.low { background: linear-gradient(to right, #ff0000, #ee0979); animation: energyPulse 0.5s infinite; }
/* Turbo Meter */
#turbo-container { position: absolute; bottom: 30px; right: 40px; text-align: right; width: 120px; }
#turbo-label { font-size: 10px; color: rgba(0,255,200,0.4); letter-spacing: 3px; margin-bottom: 6px; }
#turbo-track { width: 100%; height: 4px; background: rgba(255,255,255,0.08); border-radius: 2px; overflow: hidden; }
#turbo-fill { width: 0%; height: 100%; background: linear-gradient(to right, #00ffc8, #00e5ff); border-radius: 2px; transition: width 0.1s ease-out; box-shadow: 0 0 6px rgba(0,255,200,0.4); }
/* Near Miss Flash */
#near-miss-flash {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none; opacity: 0;
background: radial-gradient(ellipse at center, rgba(255,140,50,0.3), transparent 70%);
transition: opacity 0.1s;
}
/* Start Screen */
#start-screen { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; z-index: 20; background: rgba(10,0,21,0.9); }
#start-content { text-align: center; color: white; }
#start-content h1 { font-size: 64px; letter-spacing: 12px; margin-bottom: 8px; background: linear-gradient(to bottom, #ff8c32, #ee0979); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
.subtitle { font-size: 14px; color: rgba(255,255,255,0.4); letter-spacing: 6px; margin-bottom: 40px; }
#controls-info { margin-bottom: 40px; }
#controls-info p { font-size: 13px; color: rgba(255,255,255,0.5); margin: 8px 0; letter-spacing: 1px; }
.key { display: inline-block; padding: 2px 8px; background: rgba(255,255,255,0.1); border-radius: 3px; color: #ff8c32; font-size: 11px; margin: 0 2px; }
/* Buttons */
button { pointer-events: all; background: linear-gradient(135deg, #ff6a00, #ee0979); border: none; color: white; padding: 14px 48px; font-size: 16px; letter-spacing: 4px; cursor: pointer; font-family: 'Courier New', monospace; text-transform: uppercase; transition: transform 0.2s, box-shadow 0.2s; }
button:hover { transform: scale(1.05); box-shadow: 0 0 30px rgba(255,106,0,0.5); }
/* Game Over */
#game-over { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; z-index: 20; background: rgba(10,0,21,0.85); }
#game-over-content { text-align: center; color: white; }
#go-title { font-size: 56px; letter-spacing: 10px; margin-bottom: 30px; color: #ee0979; text-shadow: 0 0 30px rgba(238,9,121,0.5); }
#go-stats { margin-bottom: 40px; }
.stat-row { display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.08); min-width: 300px; margin: 0 auto; }
.stat-label { font-size: 12px; color: rgba(255,255,255,0.4); letter-spacing: 3px; }
.stat-value { font-size: 28px; color: #ff8c32; font-weight: bold; text-shadow: 0 0 10px rgba(255,140,50,0.4); }
/* Animations */
@keyframes multPulse { 0% { transform: scale(1); } 50% { transform: scale(1.3); } 100% { transform: scale(1); } }
@keyframes energyPulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
</style>
</head>
<body>
<div id="game-container">
<canvas id="game-canvas"></canvas>
<!-- HUD Overlay -->
<div id="hud" class="hidden">
<!-- Speed Bar (left side) -->
<div id="speed-bar-container">
<div id="speed-label">SPEED</div>
<div id="speed-track">
<div id="speed-fill"></div>
</div>
<div id="speed-value">0</div>
</div>
<!-- Multiplier (center top) -->
<div id="multiplier-container">
<div id="multiplier-label">MULTIPLIER</div>
<div id="multiplier-value">x1.0</div>
</div>
<!-- Score (top center-right) -->
<div id="score-container">
<div id="score-value">0</div>
</div>
<!-- Energy Bar (bottom center) -->
<div id="energy-bar-container">
<div id="energy-label">ENERGY</div>
<div id="energy-track">
<div id="energy-fill"></div>
</div>
</div>
<!-- Turbo Meter (bottom right) -->
<div id="turbo-container">
<div id="turbo-label">TURBO</div>
<div id="turbo-track">
<div id="turbo-fill"></div>
</div>
</div>
<!-- Near Miss Flash -->
<div id="near-miss-flash"></div>
</div>
<!-- Game Over Screen -->
<div id="game-over" class="hidden">
<div id="game-over-content">
<h1 id="go-title">GAME OVER</h1>
<div id="go-stats">
<div class="stat-row"><span class="stat-label">FINAL SCORE</span><span class="stat-value" id="go-score">0</span></div>
<div class="stat-row"><span class="stat-label">MAX SPEED</span><span class="stat-value" id="go-maxspeed">0</span></div>
<div class="stat-row"><span class="stat-label">DISTANCE</span><span class="stat-value" id="go-distance">0</span></div>
</div>
<button id="restart-btn">RESTART</button>
</div>
</div>
<!-- Start Screen -->
<div id="start-screen">
<div id="start-content">
<h1>CANYON RACER</h1>
<p class="subtitle">HIGH-SPEED PROCEDURAL ARCADE</p>
<div id="controls-info">
<p><span class="key">W/S</span> or <span class="key">↑/↓</span> — Pitch Up / Down</p>
<p><span class="key">A/D</span> or <span class="key">←/→</span> — Roll Left / Right</p>
<p><span class="key">SPACE</span> — Boost (when Turbo is charged)</p>
</div>
<button id="start-btn">LAUNCH</button>
</div>
</div>
<!-- Vignette overlay -->
<div id="vignette"></div>
</div>
<script>
'use strict';
// ── const: CONFIG ──
const CONFIG = {
// Player physics
BASE_SPEED: 0.8,
MAX_SPEED: 2.5,
BOOST_SPEED: 4.0,
ACCELERATION: 0.015,
DECELERATION: 0.008,
LATERAL_SPEED: 0.35,
VERTICAL_SPEED: 0.25,
BANKING_RATE: 0.06,
MOMENTUM_DAMPING: 0.94,
// Energy system
ENERGY_MAX: 100,
ENERGY_DECAY: 0.035,
ENERGY_RECHARGE: 25,
// Turbo system
TURBO_MAX: 100,
TURBO_BUILD_RATE: 0.15,
TURBO_DRAIN_RATE: 0.6,
TURBO_THRESHOLD: 80,
// Scoring
NEAR_MISS_DISTANCE: 4.0,
NEAR_MISS_MULTIPLIER_BOOST: 0.5,
MULTIPLIER_DECAY: 0.01,
BASE_SCORE_RATE: 10,
// Chunk generation
CHUNK_LENGTH: 80,
CHUNKS_AHEAD: 6,
CHUNKS_BEHIND: 2,
CANYON_WIDTH_BASE: 30,
CANYON_HEIGHT_BASE: 40,
// Difficulty scaling
DIFFICULTY_SCALE_RATE: 0.0001,
MAX_DIFFICULTY: 3.0,
// Visual
FOV_NORMAL: 75,
FOV_BOOST: 95,
CAMERA_DISTANCE: 12,
CAMERA_HEIGHT: 4,
};
// ── const: COLORS ──
const COLORS = {
skyTop: new THREE.Color(0x1a0033),
skyBottom: new THREE.Color(0xff6b35),
canyonWall: new THREE.Color(0x8b4513),
canyonWallDark: new THREE.Color(0x5c2d0e),
monolith: new THREE.Color(0x9b59b6),
monolithEdge: new THREE.Color(0xff6a00),
orb: new THREE.Color(0x00ffc8),
playerBody: new THREE.Color(0xc0c0c0),
playerAccent: new THREE.Color(0xff6a00),
engineGlow: new THREE.Color(0xff4500),
sunColor: new THREE.Color(0xffaa33),
};
// ── class: AudioSystem ──
class AudioSystem {
constructor() {
this.ctx = null;
this.engineOsc = null;
this.engineGain = null;
this.masterGain = null;
this.initialized = false;
}
init() {
if (this.initialized) return;
try {
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
this.masterGain = this.ctx.createGain();
this.masterGain.gain.value = 0.3;
this.masterGain.connect(this.ctx.destination);
// Engine hum - low frequency oscillator with modulation
this.engineOsc = this.ctx.createOscillator();
this.engineOsc.type = 'sawtooth';
this.engineOsc.frequency.value = 60;
const engineFilter = this.ctx.createBiquadFilter();
engineFilter.type = 'lowpass';
engineFilter.frequency.value = 200;
engineFilter.Q.value = 5;
this.engineGain = this.ctx.createGain();
this.engineGain.gain.value = 0.15;
// LFO for engine modulation
const lfo = this.ctx.createOscillator();
lfo.type = 'sine';
lfo.frequency.value = 8;
const lfoGain = this.ctx.createGain();
lfoGain.gain.value = 15;
lfo.connect(lfoGain);
lfoGain.connect(this.engineOsc.frequency);
lfo.start();
this.engineOsc.connect(engineFilter);
engineFilter.connect(this.engineGain);
this.engineGain.connect(this.masterGain);
this.engineOsc.start();
// Background drone
const drone = this.ctx.createOscillator();
drone.type = 'sine';
drone.frequency.value = 40;
const droneGain = this.ctx.createGain();
droneGain.gain.value = 0.06;
drone.connect(droneGain);
droneGain.connect(this.masterGain);
drone.start();
this.initialized = true;
} catch(e) { console.warn('Audio init failed:', e); }
}
updateEngine(speedRatio) {
if (!this.initialized || !this.engineOsc) return;
const freq = 50 + speedRatio * 120;
this.engineOsc.frequency.setTargetAtTime(freq, this.ctx.currentTime, 0.1);
this.engineGain.gain.setTargetAtTime(0.1 + speedRatio * 0.15, this.ctx.currentTime, 0.1);
}
playCollect() {
if (!this.initialized) return;
const osc = this.ctx.createOscillator();
osc.type = 'sine';
osc.frequency.setValueAtTime(800, this.ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(1600, this.ctx.currentTime + 0.15);
const gain = this.ctx.createGain();
gain.gain.setValueAtTime(0.2, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.3);
osc.connect(gain);
gain.connect(this.masterGain);
osc.start();
osc.stop(this.ctx.currentTime + 0.3);
}
playNearMiss() {
if (!this.initialized) return;
// Whoosh sound - filtered noise burst
const bufferSize = this.ctx.sampleRate * 0.2;
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (bufferSize * 0.3));
}
const source = this.ctx.createBufferSource();
source.buffer = buffer;
const filter = this.ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.setValueAtTime(2000, this.ctx.currentTime);
filter.frequency.exponentialRampToValueAtTime(500, this.ctx.currentTime + 0.15);
filter.Q.value = 3;
const gain = this.ctx.createGain();
gain.gain.setValueAtTime(0.25, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.2);
source.connect(filter);
filter.connect(gain);
gain.connect(this.masterGain);
source.start();
}
playBoost() {
if (!this.initialized) return;
const osc = this.ctx.createOscillator();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(200, this.ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(800, this.ctx.currentTime + 0.3);
const gain = this.ctx.createGain();
gain.gain.setValueAtTime(0.15, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.4);
osc.connect(gain);
gain.connect(this.masterGain);
osc.start();
osc.stop(this.ctx.currentTime + 0.4);
}
playCrash() {
if (!this.initialized) return;
const bufferSize = this.ctx.sampleRate * 0.5;
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (bufferSize * 0.15));
}
const source = this.ctx.createBufferSource();
source.buffer = buffer;
const filter = this.ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.setValueAtTime(3000, this.ctx.currentTime);
filter.frequency.exponentialRampToValueAtTime(100, this.ctx.currentTime + 0.5);
const gain = this.ctx.createGain();
gain.gain.setValueAtTime(0.4, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.6);
source.connect(filter);
filter.connect(gain);
gain.connect(this.masterGain);
source.start();
// Low boom
const osc = this.ctx.createOscillator();
osc.type = 'sine';
osc.frequency.setValueAtTime(100, this.ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(20, this.ctx.currentTime + 0.4);
const boomGain = this.ctx.createGain();
boomGain.gain.setValueAtTime(0.3, this.ctx.currentTime);
boomGain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.5);
osc.connect(boomGain);
boomGain.connect(this.masterGain);
osc.start();
osc.stop(this.ctx.currentTime + 0.5);
}
stop() {
if (this.engineOsc) { try { this.engineOsc.stop(); } catch(e){} }
if (this.ctx) { try { this.ctx.close(); } catch(e){} }
this.initialized = false;
}
}
// ── class: Player ──
class Player {
constructor() {
this.mesh = null;
this.position = new THREE.Vector3(0, 0, 0);
this.velocity = new THREE.Vector3(0, 0, 0);
this.targetBankAngle = 0;
this.currentBankAngle = 0;
this.targetPitchAngle = 0;
this.currentPitchAngle = 0;
this.speed = CONFIG.BASE_SPEED;
this.boundaries = { minX: -12, maxX: 12, minY: -8, maxY: 15 };
this.engineGlowMesh = null;
this.trailPositions = [];
this.createCraft();
}
createCraft() {
const group = new THREE.Group();
// Main body - sleek arrowhead shape
const bodyGeo = new THREE.ConeGeometry(0.4, 2.5, 6);
bodyGeo.rotateX(Math.PI / 2);
const bodyMat = new THREE.MeshLambertMaterial({ color: COLORS.playerBody, flatShading: true });
const body = new THREE.Mesh(bodyGeo, bodyMat);
group.add(body);
// Wings - swept back triangles
const wingShape = new THREE.Shape();
wingShape.moveTo(0, 0);
wingShape.lineTo(-2.5, -1.5);
wingShape.lineTo(-2.0, -0.3);
wingShape.lineTo(0, 0.3);
const wingExtrudeSettings = { depth: 0.08, bevelEnabled: false };
const wingGeo = new THREE.ExtrudeGeometry(wingShape, wingExtrudeSettings);
const leftWingMat = new THREE.MeshLambertMaterial({ color: COLORS.playerAccent, flatShading: true });
const rightWingMat = new THREE.MeshLambertMaterial({ color: COLORS.playerAccent, flatShading: true });
const leftWing = new THREE.Mesh(wingGeo, leftWingMat);
leftWing.position.set(0.15, -0.04, 0.3);
group.add(leftWing);
const rightWingShape = new THREE.Shape();
rightWingShape.moveTo(0, 0);
rightWingShape.lineTo(2.5, -1.5);
rightWingShape.lineTo(2.0, -0.3);
rightWingShape.lineTo(0, 0.3);
const rightWingGeo = new THREE.ExtrudeGeometry(rightWingShape, wingExtrudeSettings);
const rightWing = new THREE.Mesh(rightWingGeo, rightWingMat);
rightWing.position.set(-0.15, -0.04, 0.3);
group.add(rightWing);
// Engine glow (back of craft)
const engineGeo = new THREE.SphereGeometry(0.2, 6, 4);
const engineMat = new THREE.MeshBasicMaterial({ color: COLORS.engineGlow });
this.engineGlowMesh = new THREE.Mesh(engineGeo, engineMat);
this.engineGlowMesh.position.set(0, 0, -1.3);
group.add(this.engineGlowMesh);
// Engine light
const engineLight = new THREE.PointLight(COLORS.engineGlow, 2, 8);
engineLight.position.set(0, 0, -1.5);
group.add(engineLight);
this.mesh = group;
}
update(input, dt) {
// Apply input with momentum
const lateralAccel = CONFIG.LATERAL_SPEED * dt * 60;
const verticalAccel = CONFIG.VERTICAL_SPEED * dt * 60;
if (input.left) this.velocity.x -= lateralAccel;
if (input.right) this.velocity.x += lateralAccel;
if (input.up) this.velocity.y += verticalAccel;
if (input.down) this.velocity.y -= verticalAccel;
// Apply damping
this.velocity.multiplyScalar(Math.pow(CONFIG.MOMENTUM_DAMPING, dt * 60));
// Clamp velocity
const maxLatVel = CONFIG.LATERAL_SPEED * 2;
const maxVertVel = CONFIG.VERTICAL_SPEED * 2;
this.velocity.x = Math.max(-maxLatVel, Math.min(maxLatVel, this.velocity.x));
this.velocity.y = Math.max(-maxVertVel, Math.min(maxVertVel, this.velocity.y));
// Update position
this.position.x += this.velocity.x * dt * 60;
this.position.y += this.velocity.y * dt * 60;
// Clamp to boundaries (with some forgiveness)
const b = this.boundaries;
this.position.x = Math.max(b.minX - 2, Math.min(b.maxX + 2, this.position.x));
this.position.y = Math.max(b.minY - 1, Math.min(b.maxY + 1, this.position.y));
// Banking animation based on lateral velocity
this.targetBankAngle = -this.velocity.x * 0.8;
this.currentBankAngle += (this.targetBankAngle - this.currentBankAngle) * CONFIG.BANKING_RATE * dt * 60;
// Pitch animation based on vertical velocity
this.targetPitchAngle = -this.velocity.y * 0.5;
this.currentPitchAngle += (this.targetPitchAngle - this.currentPitchAngle) * CONFIG.BANKING_RATE * dt * 60;
// Apply rotation to mesh
if (this.mesh) {
this.mesh.position.copy(this.position);
this.mesh.rotation.z = this.currentBankAngle;
this.mesh.rotation.x = this.currentPitchAngle;
// Engine glow pulse
const speedRatio = (this.speed - CONFIG.BASE_SPEED) / (CONFIG.BOOST_SPEED - CONFIG.BASE_SPEED);
if (this.engineGlowMesh) {
const scale = 1 + speedRatio * 0.8 + Math.sin(Date.now() * 0.01) * 0.1;
this.engineGlowMesh.scale.set(scale, scale, scale * 2);
}
}
// Record trail positions
if (this.trailPositions.length === 0 ||
this.position.distanceTo(this.trailPositions[this.trailPositions.length - 1]) > 0.5) {
this.trailPositions.push(this.position.clone());
if (this.trailPositions.length > 40) this.trailPositions.shift();
}
return this;
}
getSpeedRatio() {
return Math.max(0, Math.min(1, (this.speed - CONFIG.BASE_SPEED) / (CONFIG.BOOST_SPEED - CONFIG.BASE_SPEED)));
}
}
// ── class: ParticleSystem ──
class ParticleSystem {
constructor(scene) {
this.scene = scene;
this.trailParticles = [];
this.debrisParticles = [];
this.explosionParticles = [];
// Trail particle pool
this.createTrailPool();
this.createDebrisPool();
}
createTrailPool() {
const geometry = new THREE.BufferGeometry();
const count = 200;
const positions = new Float32Array(count * 3);
const colors = new Float32Array(count * 4); // rgba
const sizes = new Float32Array(count);
for (let i = 0; i < count; i++) {
positions[i * 3] = 0;
positions[i * 3 + 1] = 0;
positions[i * 3 + 2] = 0;
colors[i * 4] = 1; // r
colors[i * 4 + 1] = 0.4; // g
colors[i * 4 + 2] = 0; // b
colors[i * 4 + 3] = 0; // a (hidden initially)
sizes[i] = 0.3;
}
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 4));
geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
const material = new THREE.PointsMaterial({
size: 0.5,
vertexColors: true,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
sizeAttenuation: true,
});
this.trailPoints = new THREE.Points(geometry, material);
this.scene.add(this.trailPoints);
// Initialize particle data
for (let i = 0; i < count; i++) {
this.trailParticles.push({
active: false, life: 0, maxLife: 1, velocity: new THREE.Vector3()
});
}
}
createDebrisPool() {
const geometry = new THREE.BufferGeometry();
const count = 150;
const positions = new Float32Array(count * 3);
const colors = new Float32Array(count * 4);
for (let i = 0; i < count; i++) {
positions[i * 3] = 0;
positions[i * 3 + 1] = 0;
positions[i * 3 + 2] = 0;
colors[i * 4] = 0.5;
colors[i * 4 + 1] = 0.3;
colors[i * 4 + 2] = 0.1;
colors[i * 4 + 3] = 0;
}
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 4));
const material = new THREE.PointsMaterial({
size: 0.2,
vertexColors: true,
transparent: true,
depthWrite: false,
sizeAttenuation: true,
});
this.debrisPoints = new THREE.Points(geometry, material);
this.scene.add(this.debrisPoints);
for (let i = 0; i < count; i++) {
this.debrisParticles.push({
active: false, life: 0, maxLife: 1, velocity: new THREE.Vector3()
});
}
}
emitTrail(position, speedRatio) {
// Find inactive particle
for (let p of this.trailParticles) {
if (!p.active) {
p.active = true;
p.life = 0;
p.maxLife = 0.5 + Math.random() * 0.3;
p.velocity.set(
(Math.random() - 0.5) * 0.1,
(Math.random() - 0.5) * 0.1,
-(speedRatio * 2 + 0.5)
);
return position.clone();
}
}
return null;
}
emitDebris(position, speed) {
for (let p of this.debrisParticles) {
if (!p.active) {
p.active = true;
p.life = 0;
p.maxLife = 0.3 + Math.random() * 0.5;
p.velocity.set(
(Math.random() - 0.5) * speed * 2,
(Math.random() - 0.5) * speed * 2,
-(speed * 3 + Math.random())
);
return position.clone();
}
}
return null;
}
emitExplosion(position) {
for (let i = 0; i < 30; i++) {
for (let p of this.trailParticles) {
if (!p.active) {
p.active = true;
p.life = 0;
p.maxLife = 0.5 + Math.random() * 1.0;
const angle = Math.random() * Math.PI * 2;
const upAngle = (Math.random() - 0.5) * Math.PI;
const speed = 2 + Math.random() * 4;
p.velocity.set(
Math.cos(angle) * Math.cos(upAngle) * speed,
Math.sin(upAngle) * speed,
Math.sin(angle) * Math.cos(upAngle) * speed
);
break;
}
}
}
}
update(dt, playerPos, speedRatio) {
// Update trail particles
const tPositions = this.trailPoints.geometry.attributes.position.array;
const tColors = this.trailPoints.geometry.attributes.color.array;
for (let i = 0; i < this.trailParticles.length; i++) {
const p = this.trailParticles[i];
if (!p.active) {
tColors[i * 4 + 3] = 0;
continue;
}
p.life += dt;
if (p.life >= p.maxLife) {
p.active = false;
tColors[i * 4 + 3] = 0;
continue;
}
const lifeRatio = 1 - p.life / p.maxLife;
// Move particle relative to player movement
tPositions[i * 3] += p.velocity.x * dt * 60;
tPositions[i * 3 + 1] += p.velocity.y * dt * 60;
tPositions[i * 3 + 2] += p.velocity.z * dt * 60;
// Color based on speed ratio (orange to red)
const r = 1.0;
const g = 0.4 - lifeRatio * 0.3;
const b = lifeRatio * 0.2;
tColors[i * 4] = r;
tColors[i * 4 + 1] = Math.max(0, g);
tColors[i * 4 + 2] = b;
tColors[i * 4 + 3] = lifeRatio * (0.6 + speedRatio * 0.4);
}
this.trailPoints.geometry.attributes.position.needsUpdate = true;
this.trailPoints.geometry.attributes.color.needsUpdate = true;
// Update debris particles
const dPositions = this.debrisPoints.geometry.attributes.position.array;
const dColors = this.debrisPoints.geometry.attributes.color.array;
for (let i = 0; i < this.debrisParticles.length; i++) {
const p = this.debrisParticles[i];
if (!p.active) {
dColors[i * 4 + 3] = 0;
continue;
}
p.life += dt;
if (p.life >= p.maxLife) {
p.active = false;
dColors[i * 4 + 3] = 0;
continue;
}
const lifeRatio = 1 - p.life / p.maxLife;
dPositions[i * 3] += p.velocity.x * dt * 60;
dPositions[i * 3 + 1] += p.velocity.y * dt * 60;
dPositions[i * 3 + 2] += p.velocity.z * dt * 60;
// Dust color - warm brownish
const brightness = 0.4 + lifeRatio * 0.3;
dColors[i * 4] = brightness;
dColors[i * 4 + 1] = brightness * 0.5;
dColors[i * 4 + 2] = brightness * 0.2;
dColors[i * 4 + 3] = lifeRatio * 0.6;
}
this.debrisPoints.geometry.attributes.position.needsUpdate = true;
this.debrisPoints.geometry.attributes.color.needsUpdate = true;
// Emit new trail particles based on speed
if (Math.random() < 0.3 + speedRatio * 0.7) {
const offset = new THREE.Vector3(
(Math.random() - 0.5) * 0.5,
(Math.random() - 0.5) * 0.5,
-1.5
);
this.emitTrail(playerPos.clone().add(offset), speedRatio);
}
// Emit debris particles based on speed
if (Math.random() < 0.1 + speedRatio * 0.4) {
const offset = new THREE.Vector3(
(Math.random() - 0.5) * 20,
(Math.random() - 0.5) * 15,
-CONFIG.CHUNK_LENGTH * 0.5 + Math.random() * CONFIG.CHUNK_LENGTH
);
this.emitDebris(playerPos.clone().add(offset), speedRatio);
}
}
dispose() {
if (this.trailPoints) {
this.scene.remove(this.trailPoints);
this.trailPoints.geometry.dispose();
this.trailPoints.material.dispose();
}
if (this.debrisPoints) {
this.scene.remove(this.debrisPoints);
this.debrisPoints.geometry.dispose();
this.debrisPoints.material.dispose();
}
}
}
// ── class: CollisionDetector ──
class CollisionDetector {
constructor() {
this.playerRadius = 0.8;
this.nearMissThreshold = CONFIG.NEAR_MISS_DISTANCE;
}
checkCollision(playerPos, collisionBoxes) {
const px = playerPos.x;
const py = playerPos.y;
const pz = playerPos.z;
let collided = false;
let closestDist = Infinity;
let nearMissSide = null;
for (const box of collisionBoxes) {
// Only check boxes that are close in Z
if (Math.abs(box.minZ - pz) > 10 || Math.abs(box.maxZ - pz) > 10) continue;
// Find closest point on AABB to player position
const closestX = Math.max(box.minX, Math.min(px, box.maxX));
const closestY = Math.max(box.minY, Math.min(py, box.maxY));
const closestZ = Math.max(box.minZ, Math.min(pz, box.maxZ));
// Distance from player to closest point on box
const dx = px - closestX;
const dy = py - closestY;
const dz = pz - closestZ;
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (dist < this.playerRadius) {
collided = true;
}
if (dist < closestDist) {
closestDist = dist;
// Determine which side is closest for near-miss feedback
const absDx = Math.abs(dx);
const absDy = Math.abs(dy);
if (absDx > absDy && absDx > Math.abs(dz)) {
nearMissSide = dx > 0 ? 'right' : 'left';
} else if (absDy > Math.abs(dz)) {
nearMissSide = dy > 0 ? 'above' : 'below';
} else {
nearMissSide = dz > 0 ? 'behind' : 'front';
}
}
}
return { collided, closestDist, nearMissSide };
}
checkOrbCollection(playerPos, orbs) {
const collected = [];
for (const orb of orbs) {
const dist = playerPos.distanceTo(orb.worldPos);
if (dist < 2.0) {
collected.push(orb);
}
}
return collected;
}
}
// ── class: CameraController ──
class CameraController {
constructor(camera) {
this.camera = camera;
this.shakeIntensity = 0;
this.targetShake = 0;
this.shakeDecay = 0.92;
this.baseFOV = CONFIG.FOV_NORMAL;
this.currentFOV = CONFIG.FOV_NORMAL;
this.targetFOV = CONFIG.FOV_NORMAL;
// Camera offset from player
this.offset = new THREE.Vector3(0, CONFIG.CAMERA_HEIGHT, CONFIG.CAMERA_DISTANCE);
}
update(playerPos, speedRatio, dt) {
// Smooth camera follow with slight lag for dynamic feel
const targetX = playerPos.x * 0.3; // Slight lateral follow
const targetY = playerPos.y * 0.5 + CONFIG.CAMERA_HEIGHT;
this.camera.position.x += (targetX - this.camera.position.x) * 0.08;
this.camera.position.y += (targetY - this.camera.position.y) * 0.06;
this.camera.position.z = playerPos.z + CONFIG.CAMERA_DISTANCE;
// Look at point ahead of player
const lookTarget = new THREE.Vector3(
playerPos.x * 0.5,
playerPos.y * 0.7,
playerPos.z - 20
);
this.camera.lookAt(lookTarget);
// FOV changes based on speed
this.targetFOV = CONFIG.FOV_NORMAL + speedRatio * (CONFIG.FOV_BOOST - CONFIG.FOV_NORMAL);
this.currentFOV += (this.targetFOV - this.currentFOV) * 0.1;
this.camera.fov = this.currentFOV;
this.camera.updateProjectionMatrix();
// Apply camera shake
if (this.shakeIntensity > 0.01) {
const shakeX = (Math.random() - 0.5) * this.shakeIntensity;
const shakeY = (Math.random() - 0.5) * this.shakeIntensity;
const shakeZ = (Math.random() - 0.5) * this.shakeIntensity * 0.5;
this.camera.position.x += shakeX;
this.camera.position.y += shakeY;
this.camera.position.z += shakeZ;
this.shakeIntensity *= Math.pow(this.shakeDecay, dt * 60);
} else {
this.shakeIntensity = 0;
}
}
addShake(intensity) {
this.targetShake = Math.min(2.0, this.targetShake + intensity);
this.shakeIntensity = Math.max(this.shakeIntensity, this.targetShake);
this.targetShake *= 0.9; // Decay target shake
}
setFOV(fov) {
this.targetFOV = fov;
}
}
// ── class: GameState ──
class GameState {
constructor() {
this.reset();
}
reset() {
this.score = 0;
this.energy = CONFIG.ENERGY_MAX;
this.multiplier = 1.0;
this.turbo = 0;
this.maxSpeed = CONFIG.BASE_SPEED;
this.distance = 0;
this.isRunning = false;
this.isGameOver = false;
this.nearMissTimer = 0;
this.lastNearMissTime = 0;
}
update(dt, speed, collisionResult) {
if (!this.isRunning || this.isGameOver) return;
// Distance tracking
this.distance += speed * dt * 60;
// Max speed tracking
if (speed > this.maxSpeed) this.maxSpeed = speed;
// Energy decay
this.energy -= CONFIG.ENERGY_DECAY * dt * 60;
this.energy = Math.max(0, this.energy);
// Turbo building from high speed straightaways
const speedRatio = (speed - CONFIG.BASE_SPEED) / (CONFIG.BOOST_SPEED - CONFIG.BASE_SPEED);
if (speedRatio > 0.5 && !this.isBoosting()) {
this.turbo += CONFIG.TURBO_BUILD_RATE * dt * 60;
this.turbo = Math.min(CONFIG.TURBO_MAX, this.turbo);
}
// Near-miss scoring
if (collisionResult.closestDist < CONFIG.NEAR_MISS_DISTANCE &&
collisionResult.closestDist > 0.5) {
const now = performance.now();
if (now - this.lastNearMissTime > 200) { // Debounce near misses
this.multiplier += CONFIG.NEAR_MISS_MULTIPLIER_BOOST;
this.score += Math.floor(50 * this.multiplier);
this.nearMissTimer = 0.3;
this.lastNearMissTime = now;
// Turbo boost from near miss
this.turbo = Math.min(CONFIG.TURBO_MAX, this.turbo + 10);
}
}
// Multiplier decay
if (this.nearMissTimer > 0) {
this.nearMissTimer -= dt;
} else {
this.multiplier = Math.max(1.0, this.multiplier - CONFIG.MULTIPLIER_DECAY * dt * 60);
}
// Base score from distance and speed
this.score += Math.floor(CONFIG.BASE_SCORE_RATE * this.multiplier * speedRatio * dt * 60);
// Check game over conditions
if (this.energy <= 0) {
this.triggerGameOver();
}
}
isBoosting() {
return this.turbo >= CONFIG.TURBO_THRESHOLD;
}
activateBoost() {
if (!this.isBoosting()) return false;
this._boostActive = true;
return true;
}
updateBoost(dt) {
if (this._boostActive) {
this.turbo -= CONFIG.TURBO_DRAIN_RATE * dt * 60;
if (this.turbo < CONFIG.TURBO_THRESHOLD) {
this._boostActive = false;
}
}
return this._boostActive || false;
}
rechargeEnergy(amount) {
this.energy = Math.min(CONFIG.ENERGY_MAX, this.energy + amount);
this.score += Math.floor(25 * this.multiplier);
}
triggerGameOver() {
this.isGameOver = true;
this.isRunning = false;
}
getSpeedRatio(speed) {
return Math.max(0, Math.min(1, (speed - CONFIG.BASE_SPEED) / (CONFIG.BOOST_SPEED - CONFIG.BASE_SPEED)));
}
}
// ── class: HUDManager ──
class HUDManager {
constructor() {
this.speedFill = document.getElementById('speed-fill');
this.speedValue = document.getElementById('speed-value');
this.multiplierValue = document.getElementById('multiplier-value');
this.scoreValue = document.getElementById('score-value');
this.energyFill = document.getElementById('energy-fill');
this.turboFill = document.getElementById('turbo-fill');
this.nearMissFlash = document.getElementById('near-miss-flash');
this.hud = document.getElementById('hud');
this.gameOverScreen = document.getElementById('game-over');
this.startScreen = document.getElementById('start-screen');
this.goScore = document.getElementById('go-score');
this.goMaxSpeed = document.getElementById('go-maxspeed');
this.goDistance = document.getElementById('go-distance');
}
showHUD() {
this.hud.classList.remove('hidden');
this.startScreen.classList.add('hidden');
this.gameOverScreen.classList.add('hidden');
}
hideHUD() {
this.hud.classList.add('hidden');
}
showStartScreen() {
this.startScreen.classList.remove('hidden');
this.hud.classList.add('hidden');
this.gameOverScreen.classList.add('hidden');
}
showGameOver(gameState) {
this.goScore.textContent = Math.floor(gameState.score).toLocaleString();
this.goMaxSpeed.textContent = Math.floor(gameState.maxSpeed * 100);
this.goDistance.textContent = Math.floor(gameState.distance).toLocaleString() + 'm';
this.gameOverScreen.classList.remove('hidden');
this.hud.classList.add('hidden');
}
update(gameState, speedRatio) {
// Speed bar
const speedPercent = Math.min(100, speedRatio * 100);
this.speedFill.style.height = speedPercent + '%';
this.speedValue.textContent = Math.floor(speedRatio * 300);
// Multiplier
this.multiplierValue.textContent = 'x' + gameState.multiplier.toFixed(1);
if (gameState.nearMissTimer > 0) {
this.multiplierValue.classList.add('pulse');
setTimeout(() => this.multiplierValue.classList.remove('pulse'), 300);
// Near miss flash
this.nearMissFlash.style.opacity = gameState.nearMissTimer * 2;
} else {
this.nearMissFlash.style.opacity = '0';
}
// Score
this.scoreValue.textContent = Math.floor(gameState.score).toLocaleString();
// Energy bar
const energyPercent = (gameState.energy / CONFIG.ENERGY_MAX) * 100;
this.energyFill.style.width = energyPercent + '%';
if (energyPercent < 25) {
this.energyFill.classList.add('low');
} else {
this.energyFill.classList.remove('low');
}
// Turbo meter
const turboPercent = (gameState.turbo / CONFIG.TURBO_MAX) * 100;
this.turboFill.style.width = turboPercent + '%';
if (gameState.isBoosting()) {
this.turboFill.style.background = 'linear-gradient(to right, #ff6a00, #ee0979)';
} else {
this.turboFill.style.background = '';
}
}
}
// ── class: InputHandler ──
class InputHandler {
constructor() {
this.keys = {};
this.left = false;
this.right = false;
this.up = false;
this.down = false;
this.boost = false;
window.addEventListener('keydown', (e) => {
this.keys[e.code] = true;
this.updateState();
// Prevent scrolling with game keys
if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space'].includes(e.code)) {
e.preventDefault();
}
});
window.addEventListener('keyup', (e) => {
this.keys[e.code] = false;
this.updateState();
});
}
updateState() {
this.left = !!(this.keys['ArrowLeft'] || this.keys['KeyA']);
this.right = !!(this.keys['ArrowRight'] || this.keys['KeyD']);
this.up = !!(this.keys['ArrowUp'] || this.keys['KeyW']);
this.down = !!(this.keys['ArrowDown'] || this.keys['KeyS']);
this.boost = !!this.keys['Space'];
}
}
// ── class: BloomManager ──
class BloomManager {
constructor(scene) {
this.scene = scene;
this.glowSprites = [];
}
createGlowSprite(color, size, position) {
const canvas = document.createElement('canvas');
canvas.width = 64;
canvas.height = 64;
const ctx = canvas.getContext('2d');
const r = (color >> 16) & 0xff;
const g = (color >> 8) & 0xff;
const b = color & 0xff;
const gradient = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
gradient.addColorStop(0, `rgba(${r},${g},${b},0.8)`);
gradient.addColorStop(0.3, `rgba(${r},${g},${b},0.4)`);
gradient.addColorStop(0.7, `rgba(${r},${g},${b},0.1)`);
gradient.addColorStop(1, `rgba(${r},${g},${b},0)`);
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 64, 64);
const texture = new THREE.CanvasTexture(canvas);
const material = new THREE.SpriteMaterial({
map: texture, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false
});
const sprite = new THREE.Sprite(material);
sprite.scale.set(size, size, 1);
sprite.position.copy(position);
this.scene.add(sprite);
this.glowSprites.push({ sprite, baseSize: size, pulsePhase: Math.random() * Math.PI * 2 });
return sprite;
}
update(time) {
for (const gs of this.glowSprites) {
const pulse = 1 + Math.sin(time * 3 + gs.pulsePhase) * 0.15;
gs.sprite.scale.set(gs.baseSize * pulse, gs.baseSize * pulse, 1);
}
}
dispose() {
for (const gs of this.glowSprites) {
this.scene.remove(gs.sprite);
if (gs.sprite.material.map) gs.sprite.material.map.dispose();
gs.sprite.material.dispose();
}
this.glowSprites = [];
}
}
// ── class: SpeedLines ──
class SpeedLines {
constructor(scene) {
this.scene = scene;
this.active = false;
const count = 100;
const geometry = new THREE.BufferGeometry();
const positions = new Float32Array(count * 6); // 2 vertices per line, 3 coords each
const colors = new Float32Array(count * 8); // rgba per vertex
for (let i = 0; i < count; i++) {
positions[i * 12] = 0; // start x
positions[i * 12 + 1] = 0; // start y
positions[i * 12 + 2] = 0; // start z
positions[i * 12 + 3] = 0; // end x
positions[i * 12 + 4] = 0; // end y
positions[i * 12 + 5] = 0; // end z
colors[i * 8] = 1; // start r
colors[i * 8 + 1] = 0.6; // start g
colors[i * 8 + 2] = 0.3; // start b
colors[i * 8 + 3] = 0; // start a (hidden)
colors[i * 8 + 4] = 1; // end r
colors[i * 8 + 5] = 0.6; // end g
colors[i * 8 + 6] = 0.3; // end b
colors[i * 8 + 7] = 0; // end a (hidden)
}
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 4));
const material = new THREE.LineBasicMaterial({
vertexColors: true, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false
});
this.lines = new THREE.LineSegments(geometry, material);
this.scene.add(this.lines);
this.lineData = [];
for (let i = 0; i < count; i++) {
this.lineData.push({ active: false, life: 0, maxLife: 0.5 });
}
}
emit(playerPos, speedRatio) {
if (!this.active && speedRatio < 0.3) return;
const positions = this.lines.geometry.attributes.position.array;
const colors = this.lines.geometry.attributes.color.array;
for (let i = 0; i < this.lineData.length; i++) {
const ld = this.lineData[i];
if (!ld.active && Math.random() < speedRatio * 0.3) {
ld.active = true;
ld.life = 0;
ld.maxLife = 0.2 + Math.random() * 0.4;
// Random position around player, ahead in the distance
const angle = Math.random() * Math.PI * 2;
const dist = 5 + Math.random() * 30;
const x = playerPos.x + Math.cos(angle) * dist;
const y = playerPos.y + (Math.random() - 0.5) * 20;
const z = playerPos.z - CONFIG.CHUNK_LENGTH * 0.5 - Math.random() * CONFIG.CHUNK_LENGTH;
positions[i * 12] = x;
positions[i * 12 + 1] = y;
positions[i * 12 + 2] = z;
positions[i * 12 + 3] = x + (Math.random() - 0.5) * 0.5;
positions[i * 12 + 4] = y - 1 - Math.random() * 2;
positions[i * 12 + 5] = z;
colors[i * 8 + 3] = 0.6;
colors[i * 8 + 7] = 0.2;
} else if (ld.active) {
ld.life += 0.016; // approximate dt
if (ld.life >= ld.maxLife) {
ld.active = false;
colors[i * 8 + 3] = 0;
colors[i * 8 + 7] = 0;
} else {
const lifeRatio = 1 - ld.life / ld.maxLife;
// Move lines toward player (simulating speed)
positions[i * 12 + 2] += speedRatio * 5;
positions[i * 12 + 5] += speedRatio * 5;
colors[i * 8 + 3] = lifeRatio * 0.6;
colors[i * 8 + 7] = lifeRatio * 0.2;
}
}
}
this.lines.geometry.attributes.position.needsUpdate = true;
this.lines.geometry.attributes.color.needsUpdate = true;
}
dispose() {
this.scene.remove(this.lines);
this.lines.geometry.dispose();
this.lines.material.dispose();
}
}
// ── class: ChunkManager ──
class ChunkManager {
constructor(scene) {
this.scene = scene;
this.chunks = new Map();
this.chunkIndex = 0;
this.difficulty = 1.0;
this.rng = this.createRNG(42);
// Shared materials for performance (reuse across chunks)
this.wallMaterials = [
new THREE.MeshLambertMaterial({ color: 0x8b5e3c, flatShading: true }),
new THREE.MeshLambertMaterial({ color: 0x7a4f2e, flatShading: true }),
new THREE.MeshLambertMaterial({ color: 0x6d4528, flatShading: true }),
];
this.floorMat = new THREE.MeshLambertMaterial({ color: 0x3d1f0a, flatShading: true });
this.monolithMats = [
new THREE.MeshLambertMaterial({ color: 0x9b59b6, flatShading: true }),
new THREE.MeshLambertMaterial({ color: 0x8e44ad, flatShading: true }),
new THREE.MeshLambertMaterial({ color: 0xa569bd, flatShading: true }),
];
this.edgeMat = new THREE.LineBasicMaterial({ color: 0xff6a00, transparent: true, opacity: 0.7 });
// Set of all shared material references for quick lookup during disposal
this.sharedMaterials = new Set([
...this.wallMaterials, this.floorMat,
...this.monolithMats, this.edgeMat
]);
}
createRNG(seed) {
let s = seed;
return () => {
s = (s * 16807 + 0) % 2147483647;
return (s - 1) / 2147483646;
};
}
getChunkZ(index) { return index * CONFIG.CHUNK_LENGTH; }
generateChunk(index) {
const z = this.getChunkZ(index);
const chunkGroup = new THREE.Group();
chunkGroup.position.z = -z;
let canyonWidth = (CONFIG.CANYON_WIDTH_BASE - index * 0.1) / this.difficulty;
canyonWidth += Math.sin(index * 0.5) * 8;
canyonWidth = Math.max(14, Math.min(35, canyonWidth));
const canyonHeight = CONFIG.CANYON_HEIGHT_BASE + Math.cos(index * 0.4) * 10;
const centerOffsetX = Math.sin(index * 0.25) * 6;
const centerOffsetY = Math.cos(index * 0.35) * 4;
const terrainType = this.getTerrainType(index);
this.generateCanyonBoundaries(chunkGroup, canyonWidth, canyonHeight, centerOffsetX, centerOffsetY, index);
switch (terrainType) {
case 'walls': this.generateWallObstacles(chunkGroup, canyonWidth, canyonHeight, centerOffsetX, centerOffsetY, index); break;
case 'monoliths': this.generateMonolithField(chunkGroup, canyonWidth, canyonHeight, centerOffsetX, centerOffsetY, index); break;
case 'squeeze': this.generateSqueezeZone(chunkGroup, canyonWidth * 0.55, canyonHeight, centerOffsetX, centerOffsetY, index); break;
}
this.generateOrbs(chunkGroup, canyonWidth, canyonHeight, centerOffsetX, centerOffsetY, terrainType, index);
this.scene.add(chunkGroup);
this.chunks.set(index, { group: chunkGroup, z: -z, type: terrainType });
}
getTerrainType(index) {
const r = this.rng();
if (index % 6 === 0 && this.difficulty > 1.3) return 'squeeze';
if (r < 0.45) return 'walls';
if (r < 0.75) return 'monoliths';
return 'walls';
}
generateCanyonBoundaries(group, width, height, offsetX, offsetY, index) {
const wallHeight = height * 1.8;
const halfWidth = width / 2 + Math.abs(offsetX);
this.createCanyonWall(group, -halfWidth - 3 + offsetX, wallHeight, offsetY, index, 'left');
this.createCanyonWall(group, halfWidth + 3 + offsetX, wallHeight, offsetY, index, 'right');
// Floor
const floorGeo = new THREE.PlaneGeometry(width * 2, CONFIG.CHUNK_LENGTH, 6, 4);
const fPos = floorGeo.attributes.position;
for (let i = 0; i < fPos.count; i++) fPos.setZ(i, (this.rng() - 0.5) * 1.2);
floorGeo.computeVertexNormals();
const floor = new THREE.Mesh(floorGeo, this.floorMat);
floor.rotation.x = -Math.PI / 2;
floor.position.set(offsetX, offsetY - height * 0.35, 0);
group.add(floor);
// Ceiling (occasionally)
if (this.rng() > 0.5 && this.difficulty > 1.2) {
const ceilGeo = new THREE.PlaneGeometry(width * 1.8, CONFIG.CHUNK_LENGTH, 4, 3);
const cPos = ceilGeo.attributes.position;
for (let i = 0; i < cPos.count; i++) cPos.setZ(i, (this.rng() - 0.5) * 2);
ceilGeo.computeVertexNormals();
const ceiling = new THREE.Mesh(ceilGeo, this.wallMaterials[index % 3]);
ceiling.rotation.x = Math.PI / 2;
ceiling.position.set(offsetX, height + offsetY - 2, 0);
group.add(ceiling);
}
group.userData.wallBounds = {
minX: -halfWidth + offsetX, maxX: halfWidth + offsetX,
minY: offsetY - height * 0.35, maxY: wallHeight + offsetY
};
}
createCanyonWall(group, xPos, wallHeight, yOffset, index, side) {
const segments = 8;
const segLength = CONFIG.CHUNK_LENGTH / segments;
for (let i = 0; i < segments; i++) {
const zPos = -CONFIG.CHUNK_LENGTH * 0.5 + i * segLength;
const segHeight = wallHeight * (0.6 + this.rng() * 0.8);
const segWidth = 3 + this.rng() * 4;
const lean = (this.rng() - 0.5) * 2;
const geo = new THREE.BoxGeometry(segWidth, segHeight, segLength + 1);
const posArr = geo.attributes.position;
for (let j = 0; j < posArr.count; j++) {
const y = posArr.getY(j);
const topFactor = Math.max(0, y / (segHeight * 0.5));
posArr.setX(j, posArr.getX(j) + lean * topFactor + (this.rng() - 0.5) * segWidth * 0.15);
posArr.setZ(j, posArr.getZ(j) + (this.rng() - 0.5) * segLength * 0.2);
}
geo.computeVertexNormals();
const mat = this.wallMaterials[(index + i) % 3];
const mesh = new THREE.Mesh(geo, mat);
// FIX: use wallHeight not height
const yPos = yOffset + segHeight / 2 - wallHeight * 0.1;
mesh.position.set(xPos, yPos, zPos);
if (this.rng() > 0.4) {
const edgeGeo = new THREE.EdgesGeometry(geo, 30);
const edges = new THREE.LineSegments(edgeGeo, this.edgeMat.clone());
edges.material.opacity = 0.2 + this.rng() * 0.3;
mesh.add(edges);
}
mesh.userData.collisionBox = {
minX: xPos - segWidth/2 - Math.abs(lean), maxX: xPos + segWidth/2,
minY: yPos - segHeight/2, maxY: yPos + segHeight/2,
minZ: zPos - segLength/2, maxZ: zPos + segLength/2
};
group.add(mesh);
}
}
generateWallObstacles(group, width, height, offsetX, offsetY, index) {
const numObs = Math.floor(2 + this.difficulty * 1.5);
for (let i = 0; i < numObs; i++) {
const zPos = -CONFIG.CHUNK_LENGTH * 0.7 + (i / numObs) * CONFIG.CHUNK_LENGTH * 1.4;
const side = this.rng() > 0.5 ? 1 : -1;
const halfWidth = width / 2;
const protrudeAmount = (this.rng() * 0.35 + 0.08) * halfWidth;
const wallW = 2 + this.rng() * 4;
const wallH = height * (0.3 + this.rng() * 0.5);
const wallD = CONFIG.CHUNK_LENGTH * (0.15 + this.rng() * 0.15);
const geo = new THREE.BoxGeometry(wallW, wallH, wallD, 2, 2, 2);
const posArr = geo.attributes.position;
for (let j = 0; j < posArr.count; j++) {
posArr.setX(j, posArr.getX(j) + (this.rng() - 0.5) * wallW * 0.15);
posArr.setY(j, posArr.getY(j) + (this.rng() - 0.5) * wallH * 0.12);
}
geo.computeVertexNormals();
const mat = this.wallMaterials[index % 3];
const mesh = new THREE.Mesh(geo, mat);
const xPos = side * (halfWidth - protrudeAmount + offsetX);
const yPos = offsetY + wallH / 2;
mesh.position.set(xPos, yPos, zPos);
if (this.rng() > 0.3) {
const edgeGeo = new THREE.EdgesGeometry(geo, 25);
const edges = new THREE.LineSegments(edgeGeo, this.edgeMat.clone());
mesh.add(edges);
}
mesh.userData.collisionBox = {
minX: xPos - wallW/2 - 0.3, maxX: xPos + wallW/2 + 0.3,
minY: yPos - wallH/2, maxY: yPos + wallH/2,
minZ: zPos - wallD/2, maxZ: zPos + wallD/2
};
group.add(mesh);
}
}
generateMonolithField(group, width, height, offsetX, offsetY, index) {
const numM = Math.floor(3 + this.difficulty * 2);
for (let i = 0; i < numM; i++) {
const zPos = -CONFIG.CHUNK_LENGTH * 0.6 + (i / numM) * CONFIG.CHUNK_LENGTH * 1.2;
const mType = Math.floor(this.rng() * 3);
let geo;
switch(mType) {
case 0: geo = new THREE.BoxGeometry(2+this.rng()*2, height*(0.4+this.rng()*0.5), 2+this.rng()*2); break;
case 1: { const s=1.5+this.rng()*2; geo=new THREE.OctahedronGeometry(s,0); geo.scale(1,height/(s*2)*(0.4+this.rng()*0.3),1); } break;
case 2: { const ds=1+this.rng()*1.5; geo=new THREE.DodecahedronGeometry(ds,0); geo.scale(1,height/(ds*2)*(0.3+this.rng()*0.4),1); } break;
}
const mat = this.monolithMats[index % 3];
const mesh = new THREE.Mesh(geo, mat);
const xPos = (this.rng() - 0.5) * width * 0.6 + offsetX;
const yPos = offsetY + height * 0.15 + this.rng() * height * 0.35;
mesh.position.set(xPos, yPos, zPos);
mesh.rotation.y = this.rng() * Math.PI;
mesh.rotation.z = (this.rng() - 0.5) * 0.15;
const edgeGeo = new THREE.EdgesGeometry(geo, 20);
const edges = new THREE.LineSegments(edgeGeo, this.edgeMat.clone());
mesh.add(edges);
if (this.rng() > 0.5) {
const pLight = new THREE.PointLight(0xff6a00, 0.3, 8);
mesh.add(pLight);
}
let halfW = 2;
try { geo.computeBoundingSphere(); halfW = Math.max(geo.boundingSphere.radius, 1.5) + 0.5; } catch(e) {}
const halfH = (height * 0.4) / 2 + 0.5;
mesh.userData.collisionBox = {
minX: xPos - halfW, maxX: xPos + halfW,
minY: yPos - halfH, maxY: yPos + halfH,
minZ: zPos - halfW * 0.7, maxZ: zPos + halfW * 0.7
};
group.add(mesh);
}
}
generateSqueezeZone(group, width, height, offsetX, offsetY, index) {
const squeezeCenter = -CONFIG.CHUNK_LENGTH * 0.3;
for (let side = -1; side <= 1; side += 2) {
const segments = 10;
for (let i = 0; i < segments; i++) {
const t = i / segments;
const zPos = squeezeCenter + (t - 0.5) * CONFIG.CHUNK_LENGTH * 0.7;
const convergence = Math.sin(t * Math.PI);
const wallX = side * (width / 2 - convergence * width * 0.35 + offsetX);
const blockW = 1.5 + this.rng();
const blockH = height * (0.6 + this.rng() * 0.4);
const blockD = CONFIG.CHUNK_LENGTH * 0.7 / segments + 1;
const geo = new THREE.BoxGeometry(blockW, blockH, blockD, 2, 3, 2);
const posArr = geo.attributes.position;
for (let j = 0; j < posArr.count; j++) {
posArr.setX(j, posArr.getX(j) + (this.rng() - 0.5) * blockW * 0.1);
posArr.setY(j, posArr.getY(j) + (this.rng() - 0.5) * blockH * 0.08);
}
geo.computeVertexNormals();
const mat = this.wallMaterials[(index + i) % 3];
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(wallX, offsetY + blockH / 2 - height * 0.1, zPos);
const edgeGeo = new THREE.EdgesGeometry(geo, 25);
const edges = new THREE.LineSegments(edgeGeo, this.edgeMat.clone());
edges.material.opacity = 0.4 + convergence * 0.4;
mesh.add(edges);
mesh.userData.collisionBox = {
minX: wallX - blockW/2, maxX: wallX + blockW/2,
minY: offsetY - height * 0.1, maxY: offsetY + blockH - height * 0.1,
minZ: zPos - blockD/2, maxZ: zPos + blockD/2
};
group.add(mesh);
}
}
// Tight ceiling at squeeze point
const ceilGeo = new THREE.BoxGeometry(width * 0.5, 1.5, CONFIG.CHUNK_LENGTH * 0.35);
const ceilMat = this.wallMaterials[index % 3];
const ceiling = new THREE.Mesh(ceilGeo, ceilMat);
ceiling.position.set(offsetX, offsetY + height * 0.65, squeezeCenter);
const cEdgeGeo = new THREE.EdgesGeometry(ceilGeo, 20);
const cEdges = new THREE.LineSegments(cEdgeGeo, this.edgeMat.clone());
cEdges.material.opacity = 0.5;
ceiling.add(cEdges);
ceiling.userData.collisionBox = {
minX: offsetX - width * 0.25, maxX: offsetX + width * 0.25,
minY: offsetY + height * 0.6, maxY: offsetY + height * 0.75,
minZ: squeezeCenter - CONFIG.CHUNK_LENGTH * 0.18, maxZ: squeezeCenter + CONFIG.CHUNK_LENGTH * 0.18
};
group.add(ceiling);
// Warning glow at entrance
const warnGeo = new THREE.RingGeometry(2, 3, 6);
const warnMat = new THREE.MeshBasicMaterial({ color: 0xff4500, transparent: true, opacity: 0.3, side: THREE.DoubleSide });
const warning = new THREE.Mesh(warnGeo, warnMat);
warning.position.set(offsetX, offsetY + height * 0.3, squeezeCenter + CONFIG.CHUNK_LENGTH * 0.4);
group.add(warning);
}
generateOrbs(group, width, height, offsetX, offsetY, terrainType, index) {
const numOrbs = terrainType === 'squeeze' ? 4 : Math.floor(2 + this.rng() * 3);
for (let i = 0; i < numOrbs; i++) {
const zPos = -CONFIG.CHUNK_LENGTH * 0.5 + (i / numOrbs) * CONFIG.CHUNK_LENGTH;
let xPos, yPos;
if (this.rng() > 0.4 && terrainType === 'squeeze') {
xPos = offsetX + (this.rng() - 0.5) * width * 0.85;
} else {
xPos = (this.rng() - 0.5) * width * 0.6 + offsetX;
}
yPos = offsetY + height * 0.2 + this.rng() * height * 0.4;
const orbGeo = new THREE.IcosahedronGeometry(0.6, 0);
const orbMat = new THREE.MeshBasicMaterial({ color: COLORS.orb, transparent: true, opacity: 0.9 });
const orb = new THREE.Mesh(orbGeo, orbMat);
orb.position.set(xPos, yPos, zPos);
// Glow sprite
const gc = document.createElement('canvas');
gc.width = 64; gc.height = 64;
const gctx = gc.getContext('2d');
const grd = gctx.createRadialGradient(32, 32, 0, 32, 32, 32);
grd.addColorStop(0, 'rgba(0,255,200,0.8)');
grd.addColorStop(0.4, 'rgba(0,255,200,0.3)');
grd.addColorStop(1, 'rgba(0,255,200,0)');
gctx.fillStyle = grd;
gctx.fillRect(0, 0, 64, 64);
const glowTex = new THREE.CanvasTexture(gc);
const glowMat = new THREE.SpriteMaterial({ map: glowTex, transparent: true, blending: THREE.AdditiveBlending });
const glowSprite = new THREE.Sprite(glowMat);
glowSprite.scale.set(3.5, 3.5, 1);
orb.add(glowSprite);
const orbLight = new THREE.PointLight(COLORS.orb, 0.6, 8);
orb.add(orbLight);
orb.userData.isOrb = true;
orb.userData.collected = false;
orb.userData.baseY = yPos;
group.add(orb);
}
}
update(playerZ) {
const currentChunkIndex = Math.floor((playerZ + CONFIG.CHUNK_LENGTH * 2) / CONFIG.CHUNK_LENGTH);
for (let i = this.chunkIndex; i <= currentChunkIndex + CONFIG.CHUNKS_AHEAD; i++) {
if (!this.chunks.has(i)) this.generateChunk(i);
}
const removeThreshold = currentChunkIndex - CONFIG.CHUNKS_BEHIND;
for (const [index, chunk] of this.chunks) {
if (index < removeThreshold) {
this.scene.remove(chunk.group);
// Dispose geometries and non-shared materials
chunk.group.traverse((child) => {
if (child.geometry) child.geometry.dispose();
if (child.material && !this.sharedMaterials.has(child.material)) {
if (Array.isArray(child.material)) {
child.material.forEach(m => m.dispose());
} else {
child.material.dispose();
}
}
});
this.chunks.delete(index);
}
}
this.chunkIndex = Math.max(this.chunkIndex, currentChunkIndex + CONFIG.CHUNKS_AHEAD);
}
getCollisionBoxes(playerZ) {
const boxes = [];
for (const [index, chunk] of this.chunks) {
if (Math.abs(chunk.z - playerZ) > CONFIG.CHUNK_LENGTH * 3) continue;
chunk.group.traverse((child) => {
if (child.userData && child.userData.collisionBox) {
boxes.push({
minX: child.userData.collisionBox.minX,
maxX: child.userData.collisionBox.maxX,
minY: child.userData.collisionBox.minY,
maxY: child.userData.collisionBox.maxY,
minZ: child.userData.collisionBox.minZ + chunk.z,
maxZ: child.userData.collisionBox.maxZ + chunk.z
});
}
});
}
return boxes;
}
getOrbs(playerZ) {
const orbs = [];
for (const [index, chunk] of this.chunks) {
if (Math.abs(chunk.z - playerZ) > CONFIG.CHUNK_LENGTH * 3) continue;
chunk.group.traverse((child) => {
if (child.userData && child.userData.isOrb && !child.userData.collected) {
orbs.push({
mesh: child,
worldPos: new THREE.Vector3(child.position.x, child.position.y, child.position.z + chunk.z)
});
}
});
}
return orbs;
}
collectOrb(orbData) {
if (orbData.mesh.userData.collected) return false;
orbData.mesh.userData.collected = true;
const mesh = orbData.mesh;
let scale = 1;
const animateCollection = () => {
scale -= 0.15;
if (scale <= 0) { mesh.visible = false; return; }
mesh.scale.set(scale, scale, scale);
requestAnimationFrame(animateCollection);
};
animateCollection();
return true;
}
updateDifficulty(score) {
this.difficulty = Math.min(CONFIG.MAX_DIFFICULTY, 1.0 + score * CONFIG.DIFFICULTY_SCALE_RATE);
}
}
// ── class: Game ──
class Game {
constructor() {
this.canvas = document.getElementById('game-canvas');
this.renderer = null;
this.scene = null;
this.camera = null;
this.player = null;
this.chunkManager = null;
this.particleSystem = null;
this.bloomManager = null;
this.speedLines = null;
this.audioSystem = new AudioSystem();
this.gameState = new GameState();
this.hudManager = new HUDManager();
this.inputHandler = new InputHandler();
this.collisionDetector = new CollisionDetector();
this.cameraController = null;
this.clock = new THREE.Clock();
this.isRunning = false;
this.sunLight = null;
this.initThreeJS();
this.setupScene();
this.hudManager.showStartScreen();
}
initThreeJS() {
this.renderer = new THREE.WebGLRenderer({
canvas: this.canvas, antialias: true, alpha: false, powerPreference: 'high-performance'
});
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
this.renderer.toneMappingExposure = 1.3;
this.camera = new THREE.PerspectiveCamera(
CONFIG.FOV_NORMAL, window.innerWidth / window.innerHeight, 0.1, 500
);
this.scene = new THREE.Scene();
this.scene.fog = new THREE.FogExp2(0x1a0033, 0.006);
window.addEventListener('resize', () => this.onResize());
}
setupScene() {
// Sky gradient sphere
const skyGeo = new THREE.SphereGeometry(250, 16, 12);
const skyMat = new THREE.ShaderMaterial({
uniforms: {
topColor: { value: COLORS.skyTop },
bottomColor: { value: COLORS.skyBottom },
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
});
this.scene.add(new THREE.Mesh(skyGeo, skyMat));
// Sun visual
const sunGeo = new THREE.CircleGeometry(15, 16);
const sunMat = new THREE.MeshBasicMaterial({ color: COLORS.sunColor, transparent: true, opacity: 0.8 });
this.sunMesh = new THREE.Mesh(sunGeo, sunMat);
this.sunMesh.position.set(0, 30, -200);
this.scene.add(this.sunMesh);
// Sun glow
const gc = document.createElement('canvas');
gc.width = 128; gc.height = 128;
const gctx = gc.getContext('2d');
const grd = gctx.createRadialGradient(64, 64, 0, 64, 64, 64);
grd.addColorStop(0, 'rgba(255,170,50,0.6)');
grd.addColorStop(0.3, 'rgba(255,100,30,0.2)');
grd.addColorStop(1, 'rgba(255,50,0,0)');
gctx.fillStyle = grd;
gctx.fillRect(0, 0, 128, 128);
const glowTex = new THREE.CanvasTexture(gc);
this.sunGlow = new THREE.Sprite(new THREE.SpriteMaterial({
map: glowTex, transparent: true, blending: THREE.AdditiveBlending
}));
this.sunGlow.scale.set(60, 60, 1);
this.sunMesh.add(this.sunGlow);
// Lighting setup
const ambient = new THREE.AmbientLight(0x4a2080, 0.5);
this.scene.add(ambient);
this.sunLight = new THREE.DirectionalLight(0xffaa33, 1.5);
this.sunLight.position.set(10, 40, -50);
this.sunLight.castShadow = true;
this.sunLight.shadow.mapSize.width = 2048;
this.sunLight.shadow.mapSize.height = 2048;
this.sunLight.shadow.camera.near = 0.5;
this.sunLight.shadow.camera.far = 300;
this.sunLight.shadow.camera.left = -60;
this.sunLight.shadow.camera.right = 60;
this.sunLight.shadow.camera.top = 40;
this.sunLight.shadow.camera.bottom = -20;
this.scene.add(this.sunLight);
const rimLight = new THREE.DirectionalLight(0x9b59b6, 0.3);
rimLight.position.set(-10, 20, 30);
this.scene.add(rimLight);
const hemiLight = new THREE.HemisphereLight(0xff8c32, 0x4a2080, 0.3);
this.scene.add(hemiLight);
// Player craft
this.player = new Player();
this.scene.add(this.player.mesh);
// Chunk manager for procedural generation
this.chunkManager = new ChunkManager(this.scene);
// Particle systems
this.particleSystem = new ParticleSystem(this.scene);
// Bloom/glow effects
this.bloomManager = new BloomManager(this.scene);
// Speed lines effect
this.speedLines = new SpeedLines(this.scene);
// Camera controller
this.cameraController = new CameraController(this.camera);
}
start() {
this.audioSystem.init();
this.gameState.reset();
this.gameState.isRunning = true;
// Start player well behind the first obstacle chunks
const startZ = -CONFIG.CHUNK_LENGTH * 3;
this.player.position.set(0, 5, startZ);
this.player.velocity.set(0, 0, 0);
this.player.speed = CONFIG.BASE_SPEED;
this.player.currentBankAngle = 0;
this.player.currentPitchAngle = 0;
// Clear old chunks
for (const [index, chunk] of this.chunkManager.chunks) {
this.scene.remove(chunk.group);
chunk.group.traverse((child) => {
if (child.geometry) child.geometry.dispose();
if (child.material && !this.chunkManager.sharedMaterials.has(child.material)) {
if (Array.isArray(child.material)) child.material.forEach(m => m.dispose());
else child.material.dispose();
}
});
}
this.chunkManager.chunks.clear();
this.chunkManager.chunkIndex = 0;
// Generate initial chunks - start from chunk that's ahead of player
const firstChunkIdx = Math.floor((startZ + CONFIG.CHUNK_LENGTH * 2) / CONFIG.CHUNK_LENGTH);
for (let i = firstChunkIdx - 1; i <= firstChunkIdx + CONFIG.CHUNKS_AHEAD + 2; i++) {
this.chunkManager.generateChunk(i);
}
// Position camera behind player
this.camera.position.set(0, CONFIG.CAMERA_HEIGHT + 5, startZ + CONFIG.CAMERA_DISTANCE);
this.hudManager.showHUD();
this.isRunning = true;
if (!this._loopStarted) {
this._loopStarted = true;
this.gameLoop();
}
}
gameLoop() {
requestAnimationFrame(() => this.gameLoop());
const dt = Math.min(this.clock.getDelta(), 0.05);
if (this.gameState.isRunning && !this.gameState.isGameOver) {
this.update(dt);
}
this.render();
}
update(dt) {
const gs = this.gameState;
const input = this.inputHandler;
const time = performance.now() * 0.001;
// Handle boost
let boosting = false;
if (input.boost && gs.isBoosting()) {
if (!gs._boostActive) {
this.audioSystem.playBoost();
this.cameraController.addShake(0.5);
}
gs.activateBoost();
boosting = true;
}
boosting = gs.updateBoost(dt) || boosting;
// Update player speed with difficulty scaling
const diffSpeedBonus = (this.chunkManager.difficulty - 1) * 0.3;
const targetSpeed = boosting ? CONFIG.BOOST_SPEED :
Math.min(CONFIG.MAX_SPEED, CONFIG.BASE_SPEED + gs.distance * 0.0001 + diffSpeedBonus);
this.player.speed += (targetSpeed - this.player.speed) * dt * (boosting ? 4 : 2);
// Move player forward
const moveZ = this.player.speed * dt * 60;
this.player.position.z -= moveZ;
// Update player physics
this.player.update(input, dt);
// Procedural generation
this.chunkManager.update(this.player.position.z);
this.chunkManager.updateDifficulty(gs.score);
// Collision detection
const collisionBoxes = this.chunkManager.getCollisionBoxes(this.player.position.z);
const collisionResult = this.collisionDetector.checkCollision(
this.player.position, collisionBoxes
);
// Actual collision → game over
if (collisionResult.collided) {
gs.triggerGameOver();
this.audioSystem.playCrash();
this.cameraController.addShake(2.0);
this.particleSystem.emitExplosion(this.player.position.clone());
setTimeout(() => this.hudManager.showGameOver(gs), 500);
}
// Near-miss effects
if (collisionResult.closestDist < CONFIG.NEAR_MISS_DISTANCE && collisionResult.closestDist > 1.0) {
const now = performance.now();
if (now - (this._lastNearMissAudio || 0) > 300) {
this.audioSystem.playNearMiss();
this.cameraController.addShake(0.3);
this._lastNearMissAudio = now;
}
}
// Orb collection
const orbs = this.chunkManager.getOrbs(this.player.position.z);
const collectedOrbs = this.collisionDetector.checkOrbCollection(this.player.position, orbs);
for (const orb of collectedOrbs) {
if (this.chunkManager.collectOrb(orb)) {
gs.rechargeEnergy(CONFIG.ENERGY_RECHARGE);
this.audioSystem.playCollect();
this.cameraController.addShake(0.15);
// Add bloom flash at collection point
this.bloomManager.createGlowSprite(0x00ffc8, 4, orb.worldPos.clone());
}
}
// Update game state
gs.update(dt, this.player.speed, collisionResult);
// Audio engine sound
const speedRatio = this.player.getSpeedRatio();
this.audioSystem.updateEngine(speedRatio + (boosting ? 0.3 : 0));
// Visual effects updates
this.particleSystem.update(dt, this.player.position, speedRatio);
this.speedLines.emit(this.player.position, speedRatio);
this.bloomManager.update(time);
// Animate orbs
for (const [index, chunk] of this.chunkManager.chunks) {
chunk.group.traverse((child) => {
if (child.userData && child.userData.isOrb && !child.userData.collected) {
child.rotation.y = time * 2;
child.position.y = child.userData.baseY + Math.sin(time * 3 + index) * 0.5;
}
});
}
// Camera follow
this.cameraController.update(this.player.position, speedRatio, dt);
// Move sun and shadow light with player
this.sunMesh.position.z = this.player.position.z - 200;
this.sunLight.position.z = this.player.position.z - 50;
this.sunLight.target.position.copy(this.player.position);
this.sunLight.target.updateMatrixWorld();
// Update HUD
this.hudManager.update(gs, speedRatio);
}
render() {
this.renderer.render(this.scene, this.camera);
}
onResize() {
const w = window.innerWidth;
const h = window.innerHeight;
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
this.renderer.setSize(w, h);
}
dispose() {
this.isRunning = false;
this.audioSystem.stop();
this.particleSystem.dispose();
if (this.bloomManager) this.bloomManager.dispose();
if (this.speedLines) this.speedLines.dispose();
for (const [index, chunk] of this.chunkManager.chunks) {
this.scene.remove(chunk.group);
chunk.group.traverse((child) => {
if (child.geometry) child.geometry.dispose();
if (child.material && !this.chunkManager.sharedMaterials.has(child.material)) {
if (Array.isArray(child.material)) child.material.forEach(m => m.dispose());
else child.material.dispose();
}
});
}
this.renderer.dispose();
}
}
// ── main ──
(function() {
let game = null;
function init() {
game = new Game();
// Start button handler
document.getElementById('start-btn').addEventListener('click', () => {
game.start();
});
// Restart button handler
document.getElementById('restart-btn').addEventListener('click', () => {
if (game) {
game.dispose();
}
init();
});
// Keyboard shortcut to start/restart
window.addEventListener('keydown', (e) => {
if (e.code === 'Enter') {
const startScreen = document.getElementById('start-screen');
const gameOverScreen = document.getElementById('game-over');
if (!startScreen.classList.contains('hidden')) {
game.start();
} else if (!gameOverScreen.classList.contains('hidden')) {
if (game) game.dispose();
init();
}
}
});
}
// Wait for Three.js to load then initialize
function waitForThree() {
if (typeof THREE !== 'undefined') {
init();
} else {
setTimeout(waitForThree, 100);
}
}
waitForThree();
})();
</script>
</body>
</html>