// ═══════════════════════════════════════════════════════ // GAME — main entry. Wires together the three.js scene, // drone physics, gate track, HUD, sound, and input. // Run-loop uses a fixed-timestep physics update with // accumulator so frame-rate variance doesn't desync the // simulation. // ═══════════════════════════════════════════════════════ import * as THREE from 'three'; import { TOTAL_LAPS, FOV_FPV, FOV_CHASE, CAMERA_TILT, CHASE_DIST, CHASE_HEIGHT, OFF_COURSE_RADIUS, RESET_HOVER_Y, HOVER_THROTTLE, clamp, smoothDamp, } from './config.js'; import { createDrone } from './drone-physics.js'; import { createDroneMesh } from './drone-mesh.js'; import { createGateTrack } from './gate-track.js'; import { createEnvironment } from './environment.js'; import { createHUD } from './hud.js'; import { createDroneSound } from './sound.js'; // ── Scene setup ───────────────────────────────────────── const canvas = document.getElementById('game'); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance', }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(window.innerWidth, window.innerHeight, false); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.25; const scene = new THREE.Scene(); // Start with chase FOV since chase is the default camera now. const camera = new THREE.PerspectiveCamera( FOV_CHASE, window.innerWidth / window.innerHeight, 0.1, 2000 ); // ── World + drone + track ─────────────────────────────── const env = createEnvironment(scene, renderer); const droneMesh = createDroneMesh(); scene.add(droneMesh.group); // Spawn matches gate-track's SPAWN_POS so orientation math agrees. // Heading = 0 means drone faces -Z (toward the first gate). const spawnPos = new THREE.Vector3(0, 8, 60); const drone = createDrone(spawnPos, 0, HOVER_THROTTLE); const track = createGateTrack(scene); // Park the camera behind the drone at startup so the very // first frame (before the loop runs updateCamera) composes // a sensible shot instead of a random origin view. camera.position.set(spawnPos.x, spawnPos.y + CHASE_HEIGHT, spawnPos.z + CHASE_DIST); camera.lookAt(spawnPos.x, spawnPos.y + 0.5, spawnPos.z - 6); // ── HUD + sound ───────────────────────────────────────── const hud = createHUD(); const sound = createDroneSound(); // ── Game state ────────────────────────────────────────── const gameState = { phase: 'countdown', // 'countdown' | 'racing' | 'finished' | 'paused' countdown: 3.2, lap: 1, raceStartTime: 0, currentLapStartTime: 0, bestLapTime: null, lastLapTime: null, lapSplits: [], currentSplits: [], finishTime: null, // Chase camera is the default — FPV is disorienting for new players. cameraMode: 'chase', // 'chase' | 'fpv' offCourseFlash: 0, }; // ── Input: keyboard ───────────────────────────────────── const keys = Object.create(null); window.addEventListener('keydown', (e) => { keys[e.code] = true; if (e.code === 'KeyP' || e.code === 'Escape') togglePause(); if (e.code === 'KeyR') resetRace(); if (e.code === 'Tab') { e.preventDefault(); gameState.cameraMode = gameState.cameraMode === 'chase' ? 'fpv' : 'chase'; camera.fov = gameState.cameraMode === 'fpv' ? FOV_FPV : FOV_CHASE; camera.updateProjectionMatrix(); } if (e.code === 'KeyF') { if (!document.fullscreenElement) document.documentElement.requestFullscreen(); else document.exitFullscreen(); } // Resume AudioContext on first real key. sound.start(); }); window.addEventListener('keyup', (e) => { keys[e.code] = false; }); // ── Input: touch virtual sticks (mobile) ──────────────── const touchInput = { lx: 0, ly: 0, rx: 0, ry: 0 }; function setupTouchSticks() { if (!('ontouchstart' in window) && !navigator.maxTouchPoints) return; const style = document.createElement('style'); style.textContent = ` .tstick { position: fixed; bottom: 24px; width: 140px; height: 140px; border-radius: 50%; background: rgba(255,255,255,0.08); border: 2px solid rgba(255,255,255,0.25); z-index: 200; backdrop-filter: blur(4px); touch-action: none; } .tstick.left { left: 20px; } .tstick.right { right: 20px; } .tstick .knob { position: absolute; left: 50%; top: 50%; width: 56px; height: 56px; margin-left: -28px; margin-top: -28px; border-radius: 50%; background: rgba(255,255,255,0.85); box-shadow: 0 0 16px rgba(255,255,255,0.5); pointer-events: none; } .tstick .label { position: absolute; top: -22px; left: 50%; transform: translateX(-50%); font: 700 10px Orbitron, sans-serif; color: #fff; letter-spacing: 2px; opacity: 0.55; } @media (hover: hover) and (pointer: fine) { .tstick { display: none; } } `; document.head.appendChild(style); function makeStick(side, label, onMove) { const el = document.createElement('div'); el.className = `tstick ${side}`; el.innerHTML = `
${label}
`; document.body.appendChild(el); const knob = el.querySelector('.knob'); let activeId = null; const r = 50; function onStart(e) { const t = e.changedTouches[0]; activeId = t.identifier; sound.start(); } function onMoveEv(e) { for (const t of e.changedTouches) { if (t.identifier !== activeId) continue; const rect = el.getBoundingClientRect(); const cx = rect.left + rect.width / 2; const cy = rect.top + rect.height / 2; let dx = t.clientX - cx; let dy = t.clientY - cy; const len = Math.hypot(dx, dy); if (len > r) { dx = dx * r / len; dy = dy * r / len; } knob.style.transform = `translate(${dx}px, ${dy}px)`; onMove(dx / r, dy / r); } } function onEnd(e) { for (const t of e.changedTouches) { if (t.identifier !== activeId) continue; activeId = null; knob.style.transform = ''; onMove(0, 0); } } el.addEventListener('touchstart', onStart, { passive: true }); el.addEventListener('touchmove', onMoveEv, { passive: true }); el.addEventListener('touchend', onEnd, { passive: true }); el.addEventListener('touchcancel', onEnd, { passive: true }); } // Left: throttle (y) + yaw (x). Right: pitch (y) + roll (x). makeStick('left', 'THR / YAW', (x, y) => { touchInput.lx = x; touchInput.ly = -y; }); makeStick('right', 'PITCH / ROLL', (x, y) => { touchInput.rx = x; touchInput.ry = -y; }); } setupTouchSticks(); // ── Input sampling ────────────────────────────────────── function sampleInput() { // Keyboard: W/S pitch, A/D yaw, Q/E roll, Space/Shift throttle let pitch = 0, yaw = 0, roll = 0, throttleDelta = 0; if (keys['KeyW']) pitch -= 1; if (keys['KeyS']) pitch += 1; if (keys['KeyA']) yaw += 1; if (keys['KeyD']) yaw -= 1; if (keys['KeyQ']) roll += 1; if (keys['KeyE']) roll -= 1; if (keys['Space']) throttleDelta += 1; if (keys['ShiftLeft'] || keys['ShiftRight']) throttleDelta -= 1; // Touch sticks add on top of keyboard. pitch += touchInput.ry; yaw += touchInput.lx * -1; roll += touchInput.rx; throttleDelta += touchInput.ly; return { pitch: clamp(pitch, -1, 1), yaw: clamp(yaw, -1, 1), roll: clamp(roll, -1, 1), throttleDelta: clamp(throttleDelta, -1, 1), }; } // ── Camera follow ─────────────────────────────────────── // Both FPV and chase modes derive a *yaw-only* heading from // the drone so the camera horizon stays stable regardless of // how hard the drone is rolling. This is the single biggest // "FPV comfort" fix — raw quaternion coupling makes the world // spin when the drone banks. const _camFwd = new THREE.Vector3(); const _camDesired = new THREE.Vector3(); const _camLookAt = new THREE.Vector3(); const _camUp = new THREE.Vector3(0, 1, 0); function getDroneYawForward() { // Project the drone's local -Z onto the XZ plane and renormalize. _camFwd.set(0, 0, -1).applyQuaternion(drone.state.quaternion); _camFwd.y = 0; if (_camFwd.lengthSq() < 0.001) _camFwd.set(0, 0, -1); return _camFwd.normalize(); } function updateCamera(dt) { const fwd = getDroneYawForward(); if (gameState.cameraMode === 'chase') { // Trail behind the drone on a horizontal plane with a // fixed height offset. Smoothly damped so the cam lags // the drone gracefully instead of snapping. _camDesired.copy(drone.state.position) .addScaledVector(fwd, -CHASE_DIST); _camDesired.y += CHASE_HEIGHT; const tau = 0.18; camera.position.x = smoothDamp(camera.position.x, _camDesired.x, tau, dt); camera.position.y = smoothDamp(camera.position.y, _camDesired.y, tau, dt); camera.position.z = smoothDamp(camera.position.z, _camDesired.z, tau, dt); // Look slightly ahead of the drone (gives a sense of speed). _camLookAt.copy(drone.state.position) .addScaledVector(fwd, 6) .add(new THREE.Vector3(0, 0.5, 0)); camera.up.copy(_camUp); camera.lookAt(_camLookAt); } else { // Stable FPV — camera sits at drone position + tiny offset, // looks along the drone's *yaw-only* forward vector with a // slight downward pitch from the pitch component. No roll. camera.position.copy(drone.state.position); camera.position.y += 0.35; // Extract pitch from the full quaternion so the camera still // pitches up/down with the drone (useful for climbing/diving). const fullFwd = new THREE.Vector3(0, 0, -1).applyQuaternion(drone.state.quaternion); const pitch = Math.asin(clamp(fullFwd.y, -1, 1)); const pitchFwd = new THREE.Vector3( fwd.x * Math.cos(pitch * 0.6), Math.sin(pitch * 0.6) - CAMERA_TILT, fwd.z * Math.cos(pitch * 0.6) ).normalize(); _camLookAt.copy(drone.state.position).addScaledVector(pitchFwd, 10); camera.up.copy(_camUp); camera.lookAt(_camLookAt); } } // ── Race logic ────────────────────────────────────────── function resetRace() { drone.resetToHover(spawnPos, 0, HOVER_THROTTLE); track.resetForLap(); gameState.phase = 'countdown'; gameState.countdown = 3.2; gameState.lap = 1; gameState.currentSplits = []; gameState.finishTime = null; gameState.offCourseFlash = 0; hideResults(); } function resetToCheckpoint() { // Snap back to 14m *in front* of the next gate (i.e. against the // travel direction) so the drone re-approaches it head-on. const next = track.getNextGate(); if (!next) return; const back = next.forward.clone().multiplyScalar(-14); const pos = next.position.clone().add(back); pos.y = Math.max(pos.y, RESET_HOVER_Y); // Heading such that drone faces along the gate's travel direction. // forward = (-sin h, 0, -cos h) => h = atan2(-forward.x, -forward.z) const heading = Math.atan2(-next.forward.x, -next.forward.z); drone.resetToHover(pos, heading, HOVER_THROTTLE); gameState.offCourseFlash = 1.5; } function togglePause() { if (gameState.phase === 'racing') { gameState.phase = 'paused'; sound.pause(); } else if (gameState.phase === 'paused') { gameState.phase = 'racing'; sound.resume(); } } // ── Pass-through & lap completion handling ────────────── function onGatePassed(gate) { const now = performance.now() / 1000; const lapTime = now - gameState.currentLapStartTime; gameState.currentSplits.push(lapTime); // Last gate of the lap: close the lap. if (track.getNextGateIndex() >= track.getGateCount()) { gameState.lastLapTime = lapTime; // New best? if (gameState.bestLapTime === null || lapTime < gameState.bestLapTime) { gameState.bestLapTime = lapTime; gameState.lapSplits = gameState.currentSplits.slice(); showGoldToast('NEW BEST LAP!'); } gameState.currentSplits = []; gameState.currentLapStartTime = now; if (gameState.lap >= TOTAL_LAPS) { gameState.phase = 'finished'; gameState.finishTime = now - gameState.raceStartTime; showResults(); } else { gameState.lap++; track.resetForLap(); env.advanceSun(gameState.lap - 1); } } } // ── Toast & result shell ──────────────────────────────── function showGoldToast(text) { const el = document.createElement('div'); el.textContent = text; el.style.cssText = ` position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); font: 900 44px Orbitron, sans-serif; color: #ffd700; text-shadow: 0 0 30px #ffd700, 0 4px 18px rgba(0,0,0,0.8); letter-spacing: 6px; pointer-events: none; z-index: 400; animation: toastGoldIn 2.0s ease-out forwards; `; document.body.appendChild(el); setTimeout(() => el.remove(), 2100); } let resultsEl = null; function showResults() { if (resultsEl) return; resultsEl = document.createElement('div'); resultsEl.style.cssText = ` position: fixed; inset: 0; z-index: 500; display: flex; flex-direction: column; align-items: center; justify-content: center; background: radial-gradient(ellipse at center, rgba(30,8,40,0.92), rgba(5,5,18,0.96)); font-family: Orbitron, sans-serif; color: #fff; backdrop-filter: blur(6px); `; const t = gameState.finishTime; const best = gameState.bestLapTime; resultsEl.innerHTML = `
FINISHED
TOTAL TIME
${formatTime(t)}
BEST LAP
★ ${formatTime(best)}
PRESS R TO RETRY
`; document.body.appendChild(resultsEl); resultsEl.querySelector('#retry-btn').addEventListener('click', resetRace); } function hideResults() { if (resultsEl) { resultsEl.remove(); resultsEl = null; } } function formatTime(s) { if (s === null || !isFinite(s)) return '--:--.---'; const m = Math.floor(s / 60); const sec = s - m * 60; const whole = Math.floor(sec); const ms = Math.round((sec - whole) * 1000); return `${m}:${String(whole).padStart(2, '0')}.${String(ms).padStart(3, '0')}`; } // ── Countdown overlay ─────────────────────────────────── const countdownEl = document.createElement('div'); countdownEl.style.cssText = ` position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); font: 900 180px Orbitron, sans-serif; color: #fff; text-shadow: 0 0 60px rgba(255,106,61,0.9), 0 10px 40px rgba(0,0,0,0.9); pointer-events: none; z-index: 250; opacity: 0; `; document.body.appendChild(countdownEl); let lastCountdownDigit = -1; function updateCountdown() { if (gameState.phase !== 'countdown') { countdownEl.style.opacity = '0'; return; } const c = gameState.countdown; let text = ''; let digit = -1; if (c > 2) { text = '3'; digit = 3; } else if (c > 1) { text = '2'; digit = 2; } else if (c > 0) { text = '1'; digit = 1; } else { text = 'GO'; digit = 0; } if (digit !== lastCountdownDigit) { lastCountdownDigit = digit; countdownEl.textContent = text; countdownEl.style.opacity = '1'; countdownEl.style.animation = 'none'; void countdownEl.offsetWidth; countdownEl.style.animation = 'countdownPop 0.5s ease-out'; } } // ── Resize ────────────────────────────────────────────── window.addEventListener('resize', () => { renderer.setSize(window.innerWidth, window.innerHeight, false); camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); }); // ── Main loop ─────────────────────────────────────────── const FIXED_DT = 1 / 120; let accumulator = 0; let lastTime = performance.now() / 1000; const prevPos = new THREE.Vector3(); function loop() { requestAnimationFrame(loop); const now = performance.now() / 1000; let dt = now - lastTime; lastTime = now; if (dt > 0.1) dt = 0.1; // cap after tab-switch accumulator += dt; // Countdown phase — physics paused, but we still need to // sync the visual mesh + drive the camera so the player // sees the drone sitting on the line instead of an empty // origin shot. if (gameState.phase === 'countdown') { gameState.countdown -= dt; updateCountdown(); if (gameState.countdown <= -0.5) { gameState.phase = 'racing'; gameState.raceStartTime = now; gameState.currentLapStartTime = now; sound.start(); } droneMesh.group.position.copy(drone.state.position); droneMesh.group.quaternion.copy(drone.state.quaternion); droneMesh.spinProps(drone.state.propPhase += dt * 12); track.update(drone.state.position, drone.state.position, dt); updateCamera(dt); renderer.render(scene, camera); return; } // Pause phase: still drive the camera so it doesn't freeze oddly. if (gameState.phase === 'paused') { updateCamera(dt); renderer.render(scene, camera); return; } // Fixed-step physics prevPos.copy(drone.state.position); if (gameState.phase === 'racing') { const input = sampleInput(); while (accumulator >= FIXED_DT) { drone.step(input, FIXED_DT); accumulator -= FIXED_DT; } } else if (gameState.phase === 'finished') { // Idle drift — zero input, gravity still wins but throttle hold keeps it aloft. const idle = { pitch: 0, yaw: 0, roll: 0, throttleDelta: 0 }; while (accumulator >= FIXED_DT) { drone.step(idle, FIXED_DT); accumulator -= FIXED_DT; } } // Sync visual mesh droneMesh.group.position.copy(drone.state.position); droneMesh.group.quaternion.copy(drone.state.quaternion); droneMesh.spinProps(drone.state.propPhase); // Gate pass detection (only while racing) if (gameState.phase === 'racing') { const passed = track.update(prevPos, drone.state.position, dt); if (passed) onGatePassed(passed); // Off-course detection: are we crazy far from the next gate? const next = track.getNextGate(); if (next) { const d2 = drone.state.position.distanceToSquared(next.position); if (d2 > OFF_COURSE_RADIUS * OFF_COURSE_RADIUS) resetToCheckpoint(); } } else { track.update(prevPos, drone.state.position, dt); // still breathe-animate } // Camera updateCamera(dt); // Sound const speed = drone.state.velocity.length(); sound.update(drone.state.throttle, speed, dt); // HUD — decompose drone orientation into pitch/roll. Use // forward+right vectors instead of Euler extraction which // has gimbal-lock artefacts at high pitch angles. const fwdVec = new THREE.Vector3(0, 0, -1).applyQuaternion(drone.state.quaternion); const rightVec = new THREE.Vector3(1, 0, 0).applyQuaternion(drone.state.quaternion); const pitchRad = Math.asin(clamp(fwdVec.y, -1, 1)); const rollRad = Math.asin(clamp(rightVec.y, -1, 1)); const altitude = drone.state.position.y; const curLapTime = gameState.phase === 'racing' ? now - gameState.currentLapStartTime : 0; // Real-time delta vs best. let delta = null; if (gameState.bestLapTime !== null && gameState.phase === 'racing') { const idx = track.getNextGateIndex(); if (idx > 0 && gameState.lapSplits[idx - 1] !== undefined) { delta = gameState.currentSplits[idx - 1] - gameState.lapSplits[idx - 1]; } } if (gameState.offCourseFlash > 0) gameState.offCourseFlash -= dt; // Next-gate direction arrow: compute the 2D angle from the // drone's yaw-forward to the vector pointing at the next gate. let gateArrowAngle = null; // radians, 0 = dead ahead, + = right let gateDistance = null; const nextGate = track.getNextGate(); if (nextGate) { const dx = nextGate.position.x - drone.state.position.x; const dz = nextGate.position.z - drone.state.position.z; gateDistance = Math.hypot(dx, dz); // Drone yaw-forward = fwdVec flattened to XZ plane. const fx = fwdVec.x, fz = fwdVec.z; const flen = Math.hypot(fx, fz) || 1; const fnx = fx / flen, fnz = fz / flen; // Angle between drone-forward and gate-direction in world XZ. // Cross-product sign tells us left/right. const dot = (fnx * dx + fnz * dz) / (gateDistance || 1); const cross = (fnx * dz - fnz * dx); // sign only gateArrowAngle = Math.atan2(cross, dot); } hud.draw({ speed, throttle: drone.state.throttle, altitude, pitch: pitchRad, roll: rollRad, lap: gameState.lap, totalLaps: TOTAL_LAPS, gateIdx: track.getNextGateIndex(), totalGates: track.getGateCount(), currentLapTime: curLapTime, bestLapTime: gameState.bestLapTime, deltaVsBest: delta, offCourseFlash: gameState.offCourseFlash > 0, gateArrowAngle, gateDistance, }); renderer.render(scene, camera); } // ── Kick off ──────────────────────────────────────────── env.advanceSun(0); requestAnimationFrame(() => { const veil = document.getElementById('loading-veil'); if (veil) { veil.classList.add('hidden'); setTimeout(() => veil.remove(), 800); } loop(); });