// ============ povui.js — on-ride first-person camera ============ import { sampleTrack } from '../game/coaster.js'; import { sfx } from '../core/audio.js'; let povRAF = null; export function stopPOV() { const ov = document.getElementById('pov-overlay'); if (ov) ov.remove(); if (povRAF) { cancelAnimationFrame(povRAF); povRAF = null; } } export function startPOV(ride) { if (!ride?.track?.length && !ride.def) return; // custom coasters use track; prebuilt rides get a synthesized scenic track let track = ride.track; if (!track || !track.length) { track = synthTrackFor(ride); ride.cycleDur = ride.def.rideTime; } stopPOV(); sfx.whoosh(); const ov = document.createElement('div'); ov.id = 'pov-overlay'; ov.innerHTML = `
🎢 ${ride.name} 💨 0 km/h ⚡ ${ride.excite.toFixed ? ride.excite.toFixed(1) : ride.excite}
`; document.body.appendChild(ov); const cvs = document.getElementById('pov-canvas'); const ctx = cvs.getContext('2d'); document.getElementById('pov-exit').addEventListener('click', stopPOV); let prog = 0, lastT = performance.now(); const dur = Math.max(8, ride.cycleDur); const maxSpeed = ride.stats?.maxSpeed || Math.round((ride.def?.excite || 4) * 12); const isNight = () => { const st = window.__getState?.(); return st ? (st.time.hour >= 20 || st.time.hour < 6) : false; }; function resize() { cvs.width = innerWidth; cvs.height = innerHeight; } resize(); window.addEventListener('resize', resize); function frame(now) { const dt = Math.min(0.05, (now - lastT) / 1000); lastT = now; prog += dt / dur; const s = sampleTrack(track, prog % 0.9999); drawFrame(ctx, cvs.width, cvs.height, s, prog, maxSpeed, isNight(), track, prog); const sp = document.getElementById('pov-speed'); if (sp) sp.textContent = Math.round(maxSpeed * (0.55 + Math.abs(s.slope ?? 0) * 0.18)); const tm = document.getElementById('pov-time'); if (tm) tm.textContent = `⏱ ${Math.round((prog % 1) * 100)}%`; if (!document.getElementById('pov-overlay')) return; // exited povRAF = requestAnimationFrame(frame); } povRAF = requestAnimationFrame(frame); } function synthTrackFor(ride) { // simple oval circuit with a hill const pts = []; const w = ride.w || 3, h = ride.h || 3; const cx = ride.x + w / 2, cy = ride.y + h / 2; const N = 28; for (let i = 0; i < N; i++) { const a = i / N * Math.PI * 2; pts.push({ x: cx + Math.cos(a) * (w / 2 + 1.5), y: cy + Math.sin(a) * (h / 2 + 1.5), z: Math.round(Math.abs(Math.sin(a * 2)) * 3), type: 'straight', dir: Math.floor(a / (Math.PI / 2)) % 4, lift: false, }); } return pts; } function drawFrame(ctx, W, H, s, prog, maxSpeed, night, track, rawProg) { // sky const g = ctx.createLinearGradient(0, 0, 0, H); if (night) { g.addColorStop(0, '#0a0e2a'); g.addColorStop(1, '#232a55'); } else { g.addColorStop(0, '#69aef0'); g.addColorStop(1, '#cfe6f7'); } ctx.fillStyle = g; ctx.fillRect(0, 0, W, H); // loop rotation flips world const loopRot = s.loop ? (prog % 0.9999 > 0.45 && prog % 0.9999 < 0.62 ? Math.PI : 0) : 0; const slopeTilt = clampN((s.slope ?? 0) * -26, -160, 160); const steerShift = Math.sin(rawProg * Math.PI * 8) * 40; ctx.save(); ctx.translate(W / 2 + steerShift, H / 2 + slopeTilt * 0.4); ctx.rotate(loopRot); // ground const horizonY = 60 + slopeTilt; const gg = ctx.createLinearGradient(0, horizonY, 0, H * 2); gg.addColorStop(0, '#7fb069'); gg.addColorStop(1, '#3d6b35'); ctx.fillStyle = gg; ctx.fillRect(-W, horizonY, W * 2, H * 2); // water strip far away ctx.fillStyle = night ? '#16204d' : '#3a7cc9'; ctx.fillRect(-W, horizonY - 14, W * 2, 10); // scrolling ground stripes (speed feel) const speedF = 0.5 + maxSpeed / 60; const scroll = (rawProg * 900 * speedF) % 120; ctx.strokeStyle = 'rgba(255,255,255,.10)'; ctx.lineWidth = 3; for (let i = -3; i < 22; i++) { const y = horizonY + ((i * 120 - scroll) ** 1.35) / 40; if (y > H * 1.6) break; ctx.beginPath(); ctx.moveTo(-W, y); ctx.lineTo(W, y); ctx.stroke(); } // track rails ahead (perspective V) ctx.strokeStyle = '#ffd166'; ctx.lineWidth = 6; ctx.beginPath(); ctx.moveTo(-70, H * 0.75); ctx.quadraticCurveTo(-30, horizonY + 140, -16, horizonY + 34); ctx.stroke(); ctx.beginPath(); ctx.moveTo(70, H * 0.75); ctx.quadraticCurveTo(30, horizonY + 140, 16, horizonY + 34); ctx.stroke(); // passing posts const postScroll = (rawProg * 40) % 3; ctx.fillStyle = 'rgba(60,64,80,.85)'; for (let i = 0; i < 6; i++) { const k = i - postScroll; if (k < -1) continue; const px = (k - 2) * 260; const ph = 90 + (i % 3) * 30; ctx.fillRect(px, horizonY + 60, 10, ph + 200); } // trees silhouettes occasionally for (let i = 0; i < 4; i++) { const k = i - ((rawProg * 17) % 1) * 1; const tx = ((i * 397 + Math.floor(rawProg * 17) * 131) % (W * 2)) - W / 2; ctx.fillStyle = night ? '#101530' : '#2e6b34'; ctx.beginPath(); ctx.arc(tx, horizonY + 46, 34, 0, Math.PI * 2); ctx.fill(); } ctx.restore(); // wind speed lines at high speed if (maxSpeed > 45) { ctx.strokeStyle = 'rgba(255,255,255,.25)'; for (let i = 0; i < 8; i++) { const y = Math.random() * H; const x = Math.random() * W; ctx.lineWidth = Math.random() * 2; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + 60 + Math.random() * 90, y + (Math.random() - .5) * 10); ctx.stroke(); } } // vignette const vg = ctx.createRadialGradient(W / 2, H / 2, H * 0.35, W / 2, H / 2, H); vg.addColorStop(0, 'rgba(0,0,0,0)'); vg.addColorStop(1, 'rgba(0,0,10,.42)'); ctx.fillStyle = vg; ctx.fillRect(0, 0, W, H); // coaster car front ctx.fillStyle = '#e05b5b'; ctx.beginPath(); ctx.moveTo(W / 2 - 130, H + 40); ctx.quadraticCurveTo(W / 2, H - 150, W / 2 + 130, H + 40); ctx.closePath(); ctx.fill(); } function clampN(v, a, b) { return v < a ? a : v > b ? b : v; }