const OBSERVATION_CANVAS = { starfleet: { canvas: true, layers: { celestial: true, starfield: true }, // Bespoke: sparser and cooler than Deep Space's field -- a calm survey sky // behind the Class-M planet, not a busy multi-hued star rush. starfield: { count: 150, sizeMin: 0.6, sizeRange: 1.5, warpStreaks: true, colors: ['#e0f2fe', '#ffffff', '#bfdbfe', '#93c5fd', '#a5f3fc'] } }, whoniverse: { canvas: true, layers: { celestial: true } }, // Time Vortex on black, deliberately starless deepspace: { canvas: true, layers: { starfield: true, deepSpaceField: true, constellations: true, shootingStars: true, events: true } }, spacestations: { canvas: true, layers: { stationPanorama: true, shootingStars: true, traffic: true, reticles: true, events: true, cinematics: true } }, industrial: { canvas: false, layers: {} }, bioships: { canvas: false, layers: {} }, retrofuture: { canvas: false, layers: {} }, military: { canvas: false, layers: {} }, outlaw: { canvas: false, layers: {} }, comedy: { canvas: false, layers: {} } }; // Default starfield profile (Deep Space and any future universe that declares `starfield` // without its own profile). const DEFAULT_STARFIELD_PROFILE = { count: 340, sizeMin: 0.8, sizeRange: 2.2, warpStreaks: true, colors: ['#e0f2fe', '#ffffff', '#bfdbfe', '#fef08a', '#fca5a5', '#93c5fd', '#fdba74', '#a5f3fc', '#f0abfc', '#fde68a'] }; class ObservationEngine { constructor(audioManager, warpSynth, alertSynth, hullDroneSynth, lifeSupportSynth) { this.am = audioManager; this.warpSynth = warpSynth; this.alerts = alertSynth; this.hullDrone = hullDroneSynth || null; this.lifeSupport = lifeSupportSynth || null; // DOM Elements this.overlay = document.getElementById('observation-overlay'); this.canvas = document.getElementById('observation-canvas'); this.ctx = this.canvas ? this.canvas.getContext('2d') : null; this.frameEl = document.getElementById('observation-viewport-frame'); this.alertWash = document.getElementById('observation-alert-wash'); this.dock = document.getElementById('observation-control-dock'); this.btnWarp = document.getElementById('obs-btn-warp'); this.btnFrame = document.getElementById('obs-btn-frame'); this.btnPillars = document.getElementById('obs-btn-pillars'); this.btnAlert = document.getElementById('obs-btn-alert'); this.btnExit = document.getElementById('obs-btn-exit'); this.selectPreset = document.getElementById('obs-select-preset'); this.mainToggleViewport = document.getElementById('toggle-observation-viewport'); this.mainValViewport = document.getElementById('val-observation-viewport'); this.mainTogglePillars = document.getElementById('toggle-observation-pillars'); this.mainValPillars = document.getElementById('val-observation-pillars'); this.waveformCanvas = document.getElementById('observation-waveform-canvas'); this.waveformCtx = this.waveformCanvas ? this.waveformCanvas.getContext('2d') : null; // State this.running = false; this.animId = null; this.width = 1600; this.height = 900; this.dpr = 1; this.activeUniverse = 'starfleet'; this.flightMode = 'cruise'; // 'cruise' or 'warp' this.warpSpeed = 1.0; this.targetWarpSpeed = 1.0; this.warpPulseEnergy = 0.0; this.showViewport = true; this.showPillars = false; // Default OFF per user request! this.cameraWobble = { x: 0, y: 0 }; this.time = 0; this.lastFrameTime = performance.now(); // 3D Starfield this.stars = []; this.starCount = 340; this.starColors = ['#e0f2fe', '#ffffff', '#bfdbfe', '#fef08a', '#fca5a5', '#93c5fd', '#fdba74', '#a5f3fc', '#f0abfc', '#fde68a']; // Entities & Encounters this.traffic = []; this.events = []; this.nextTrafficTime = 0; this.nextEventTime = 0; this.dockHideTimer = null; // Audio Analysis buffer this.analyserData = new Uint8Array(64); // --- Observation Mode Enhancements: State --- // Audio-visual sync this.audioEnergy = { bass: 0, mid: 0, treble: 0, overall: 0 }; this.viewportVibration = { x: 0, y: 0 }; this.scanlineBreath = 0.14; this.scanlinesEl = this.overlay ? this.overlay.querySelector('.observation-scanlines') : null; // Nebula morphing this.nebulaBlobs = []; // Binary star systems (occasional background variety for select universes) this.binaryStar = null; // Shooting stars this.shootingStars = []; this.nextShootingStarTime = 0; // Constellation lines (self-contained synthetic points, independent of the main starfield) this.constellationPoints = null; this.constellationLines = null; this.constellationAlpha = 0; this.nextConstellationTime = 0; // 25-minute ambient lighting cycle this.lightingCycleTime = 0; this.lightingModifiers = null; // Cinematic event director this.cinematicActive = null; this.cinematicCaption = null; this.nextCinematicTime = 0; // HUD status ticker this.tickerTextEl = document.getElementById('observation-ticker-text'); this.tickerMessages = []; this.tickerIndex = -1; this.nextTickerTime = 0; // Space Stations: 360-degree rotating panorama. // Generated ONCE per page load and retained for the whole session (across entering/leaving // Observation Mode and preset switches). Reloading the page rolls a brand new sky. this.stationRotation = Math.random(); // starting bearing, 0..1 of a full revolution this.stationRevolutionSeconds = 240; // ~4 minutes for a full 360 this.stationFov = 0.22; // visible window covers ~22% of the full circle this.stationPanorama = null; // Deep Space: procedurally generated celestial field (planets, nebula clouds, dust lanes, // derelicts, a drifting asteroid field, and gravitational/radiation anomalies). Generated // ONCE per page load and retained for the whole session -- same contract as the station // panorama above. Reloading the page rolls a brand new sky; it never reshuffles mid-session // even as you enter/leave Observation Mode or flip presets. this.deepSpaceField = null; this.initStars(); this.initNebulaBlobs(); this.regenerateStationPanorama(); this.regenerateDeepSpaceField(); this.initEventListeners(); } initStars() { // v3co: the starfield is built from the active universe's declared profile, so two // universes that both draw stars are still drawing their own sky, not a shared layer. const prof = this.getStarfieldProfile(); this.starProfile = prof; this.starCount = prof.count; this.stars = []; for (let i = 0; i < prof.count; i++) { this.stars.push({ x: (Math.random() - 0.5) * 2600, y: (Math.random() - 0.5) * 1600, z: Math.random() * 1200 + 10, pz: 1200, size: Math.random() * prof.sizeRange + prof.sizeMin, color: prof.colors[Math.floor(Math.random() * prof.colors.length)], twinkleSpeed: Math.random() * 3 + 1, twinklePhase: Math.random() * Math.PI * 2 }); } } resize() { if (!this.canvas) return; this.dpr = window.devicePixelRatio || 1; this.width = window.innerWidth; this.height = window.innerHeight; this.canvas.width = this.width * this.dpr; this.canvas.height = this.height * this.dpr; if (this.ctx) { this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); } if (this.waveformCanvas) { const w = this.waveformCanvas.clientWidth || 360; const h = this.waveformCanvas.clientHeight || 24; this.waveformCanvas.width = w * this.dpr; this.waveformCanvas.height = h * this.dpr; if (this.waveformCtx) { this.waveformCtx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); } } } initEventListeners() { window.addEventListener('resize', () => { if (this.running) this.resize(); }); if (this.btnWarp) { this.btnWarp.addEventListener('click', (e) => { e.stopPropagation(); this.toggleWarpFlight(); }); } if (this.btnFrame) { this.btnFrame.addEventListener('click', (e) => { e.stopPropagation(); this.toggleViewport(); }); } if (this.btnPillars) { this.btnPillars.addEventListener('click', (e) => { e.stopPropagation(); this.togglePillars(); }); } if (this.mainToggleViewport) { this.mainToggleViewport.addEventListener('change', () => { this.setViewport(this.mainToggleViewport.checked); }); } if (this.mainTogglePillars) { this.mainTogglePillars.addEventListener('change', () => { this.setPillars(this.mainTogglePillars.checked); }); } if (this.btnAlert) { this.btnAlert.addEventListener('click', (e) => { e.stopPropagation(); this.toggleAlert(); }); } if (this.btnExit) { this.btnExit.addEventListener('click', (e) => { e.stopPropagation(); if (typeof exitObservation === 'function') exitObservation(); }); } if (this.selectPreset) { this.selectPreset.addEventListener('change', (e) => { e.stopPropagation(); const presetId = this.selectPreset.value; if (presetId && window.selectPreset) { window.selectPreset(presetId); if (typeof refreshObservation === 'function') refreshObservation(); } }); this.selectPreset.addEventListener('click', (e) => e.stopPropagation()); } if (this.dock) { this.dock.addEventListener('click', (e) => e.stopPropagation()); } // Auto-hide control dock on mouse stillness const pingDock = () => { if (!this.running || !this.dock) return; this.dock.classList.remove('dock-autohide'); clearTimeout(this.dockHideTimer); this.dockHideTimer = setTimeout(() => { if (this.running && this.dock) { this.dock.classList.add('dock-autohide'); } }, 3500); }; window.addEventListener('mousemove', pingDock); window.addEventListener('touchstart', pingDock, { passive: true }); } setViewport(enabled) { this.showViewport = !!enabled; if (this.frameEl) { this.frameEl.classList.toggle('frame-hidden', !this.showViewport); } const stage = document.getElementById('observation-stage'); if (stage) { stage.classList.toggle('obs-viewport-hidden', !this.showViewport); } if (this.btnFrame) { this.btnFrame.classList.toggle('active', this.showViewport); this.btnFrame.textContent = `VIEWPORT: ${this.showViewport ? 'ON' : 'OFF'}`; } if (this.mainToggleViewport) { this.mainToggleViewport.checked = this.showViewport; } if (this.mainValViewport) { this.mainValViewport.textContent = this.showViewport ? 'ON' : 'OFF'; } } toggleViewport() { this.setViewport(!this.showViewport); } toggleViewportFrame() { this.toggleViewport(); } setPillars(enabled) { this.showPillars = !!enabled; if (this.btnPillars) { this.btnPillars.classList.toggle('active', this.showPillars); this.btnPillars.textContent = `PILLARS: ${this.showPillars ? 'ON' : 'OFF'}`; } if (this.mainTogglePillars) { this.mainTogglePillars.checked = this.showPillars; } if (this.mainValPillars) { this.mainValPillars.textContent = this.showPillars ? 'ON' : 'OFF'; } this.updateViewportFrame(); } togglePillars() { this.setPillars(!this.showPillars); } start(universeId) { this.activeUniverse = universeId || 'starfleet'; this.running = true; // --- v3co: apply the OBSERVATION_CANVAS manifest for this universe ------------------- // Barrier 2 of 3: the overlay class scopes the background-rect CSS rule; the canvas // visibility is belt-and-braces so a wrong manifest entry still cannot leak pixels. const canvasOn = this.canvasEnabled(); if (this.overlay) this.overlay.classList.toggle('obs-canvas-on', canvasOn); if (this.canvas) this.canvas.style.visibility = canvasOn ? '' : 'hidden'; // Rebuild the starfield from this universe's declared profile. this.initStars(); // Clear any entities left over from a universe that WAS allowed them, so a canvas-off // theme can never inherit another theme's ships, events or shooting stars. if (!this.hasLayer('traffic')) this.traffic = []; if (!this.hasLayer('events')) this.events = []; if (!this.hasLayer('shootingStars')) this.shootingStars = []; if (!this.hasLayer('constellations')) { this.constellationPoints = null; this.constellationLines = null; this.constellationAlpha = 0; } if (!this.hasLayer('cinematics')) { this.cinematicActive = null; this.cinematicCaption = null; } // ------------------------------------------------------------------------------------- this.resize(); this.populatePresetDropdown(); this.initNebulaBlobs(); this.setViewport(this.showViewport); this.setPillars(this.showPillars); this.updateAlertState(); // Hook warp pulse if (this.warpSynth) { const existingPulse = this.warpSynth.onPulse; this.warpSynth.onPulse = (phase, duration) => { if (typeof existingPulse === 'function') existingPulse(phase, duration); this.triggerWarpPulse(); }; } this.lastFrameTime = performance.now(); this.nextTrafficTime = performance.now() + 2500; this.nextEventTime = performance.now() + 5000; // --- Observation Mode Enhancements: reset per-session timers --- this.nextShootingStarTime = performance.now() + 6000 + Math.random() * 6000; this.nextConstellationTime = 0; // triggers an immediate first constellation on next update this.constellationPoints = null; this.constellationLines = null; this.constellationAlpha = 0; this.cinematicActive = null; this.cinematicCaption = null; // Shortened first wait so a cinematic sequence is easy to observe/verify; later ones cool down longer. this.nextCinematicTime = performance.now() + 20000 + Math.random() * 15000; this.tickerMessages = this.getTickerMessages(this.activeUniverse); this.tickerIndex = -1; this.nextTickerTime = 0; if (this.btnWarp) { this.btnWarp.classList.toggle('active', this.flightMode === 'warp'); this.btnWarp.textContent = `WARP: ${this.flightMode === 'warp' ? 'ENGAGED' : 'OFF'}`; } this.loop(); } stop() { this.running = false; if (this.animId) { cancelAnimationFrame(this.animId); this.animId = null; } clearTimeout(this.dockHideTimer); if (this.ctx) { this.ctx.clearRect(0, 0, this.width, this.height); } } triggerWarpPulse() { this.warpPulseEnergy = 1.0; } toggleWarpFlight() { if (this.flightMode === 'warp') { this.flightMode = 'cruise'; this.targetWarpSpeed = 1.0; } else { this.flightMode = 'warp'; // Scale warp streak speed according to warp BPM const bpm = (this.warpSynth && this.warpSynth.params && this.warpSynth.params.bpm) ? this.warpSynth.params.bpm : 48; this.targetWarpSpeed = Math.min(38, Math.max(16, (bpm / 48) * 24)); // Trigger subtle warp flash this.spawnEvent('warp-flash', this.width / 2, this.height / 2); } if (this.btnWarp) { this.btnWarp.classList.toggle('active', this.flightMode === 'warp'); this.btnWarp.textContent = `WARP: ${this.flightMode === 'warp' ? 'ENGAGED' : 'OFF'}`; } } toggleAlert() { if (this.alerts) { if (this.alerts.activeAlert === 'red') { this.alerts.stopAlert(); } else { this.alerts.triggerRedAlert('tng'); } } this.updateAlertState(); } updateAlertState() { const alertType = this.alerts ? this.alerts.activeAlert : null; if (this.alertWash) { this.alertWash.className = 'observation-alert-wash'; if (alertType === 'red') { this.alertWash.classList.add('alert-red'); } else if (alertType === 'yellow') { this.alertWash.classList.add('alert-yellow'); } } if (this.btnAlert) { this.btnAlert.classList.toggle('btn-alert-active', alertType === 'red'); this.btnAlert.textContent = alertType === 'red' ? 'RED ALERT: ON' : (alertType === 'yellow' ? 'YELLOW ALERT' : 'RED ALERT'); } } populatePresetDropdown() { if (!this.selectPreset) return; this.selectPreset.innerHTML = ''; const universe = UniverseRegistry[this.activeUniverse]; if (universe && universe.presets) { for (const [id, preset] of Object.entries(universe.presets)) { const opt = document.createElement('option'); opt.value = id; opt.textContent = preset.name; if (id === (window.activePresetId || '')) opt.selected = true; this.selectPreset.appendChild(opt); } } } loop() { if (!this.running) return; const now = performance.now(); const dt = Math.min((now - this.lastFrameTime) / 1000, 0.1); this.lastFrameTime = now; this.time += dt; this.update(dt, now); this.render(); this.renderWaveform(); this.animId = requestAnimationFrame(() => this.loop()); } update(dt, now) { const stationView = this.isStationView(); // Smoothly interpolate warp speed this.warpSpeed += (this.targetWarpSpeed - this.warpSpeed) * (dt * 4); this.warpPulseEnergy *= Math.max(0, 1 - dt * 2.5); if (stationView) { // A station rotates in place: no forward flight, so no camera sway and no // starfield rush - just the fixed sky turning past the window. this.cameraWobble.x *= 0.9; this.cameraWobble.y *= 0.9; if (this.hasLayer('stationPanorama')) this.updateStationPanorama(dt); } else { // Camera sway during cruise if (this.flightMode === 'cruise') { this.cameraWobble.x = Math.sin(this.time * 0.18) * 14; this.cameraWobble.y = Math.cos(this.time * 0.14) * 8; } else { this.cameraWobble.x *= 0.9; this.cameraWobble.y *= 0.9; } // Update 3D Stars (only for universes that declare the starfield layer) const speed = this.flightMode === 'warp' ? this.warpSpeed * 220 : 25 + this.warpPulseEnergy * 60; const starsLen = this.hasLayer('starfield') ? this.stars.length : 0; for (let i = 0; i < starsLen; i++) { const star = this.stars[i]; star.pz = star.z; star.z -= speed * dt; if (star.z <= 2) { star.z = 1200; star.pz = 1200; star.x = (Math.random() - 0.5) * 2600; star.y = (Math.random() - 0.5) * 1600; } } } // Traffic Spawning -- v3co: governed by the OBSERVATION_CANVAS manifest, which supersedes // the v2cs `activeUniverse !== 'deepspace'` guard. Gating the SPAWNER (not just the renderer) // is what stops ships accumulating invisibly in themes that should have none. const activityFactor = (window.observationActivity !== undefined ? window.observationActivity : 0.6); if (this.hasLayer('traffic') && now > this.nextTrafficTime && this.traffic.length < 3) { this.spawnTraffic(); this.nextTrafficTime = now + (14000 + Math.random() * 18000) / Math.max(0.1, activityFactor); } // Update Traffic for (let i = this.traffic.length - 1; i >= 0; i--) { const ship = this.traffic[i]; ship.progress += dt / ship.duration; ship.x = ship.x0 + (ship.x1 - ship.x0) * ship.progress; ship.y = ship.y0 + (ship.y1 - ship.y0) * ship.progress; // Engine trail particles if (Math.random() < 0.6) { ship.particles.push({ x: ship.x, y: ship.y, vx: -ship.vx * 0.2 + (Math.random() - 0.5) * 4, vy: -ship.vy * 0.2 + (Math.random() - 0.5) * 4, alpha: 0.8, size: Math.random() * 3 + 2, color: ship.trailColor }); } // Update particles for (let p = ship.particles.length - 1; p >= 0; p--) { const pt = ship.particles[p]; pt.x += pt.vx; pt.y += pt.vy; pt.alpha -= dt * 1.5; if (pt.alpha <= 0) ship.particles.splice(p, 1); } // Warp-out departure: flash just before the ship reaches the edge, if it was tagged for one if (ship.warpExit && !ship.warpExitDone && ship.progress > 0.9) { this.spawnEvent('warp-flash', ship.x, ship.y); ship.warpExitDone = true; } if (ship.progress >= 1.0) { this.traffic.splice(i, 1); } } // Dynamic Events Spawning if (this.hasLayer('events') && now > this.nextEventTime && this.events.length < 2) { this.spawnRandomEvent(); this.nextEventTime = now + (20000 + Math.random() * 25000) / Math.max(0.1, activityFactor); } // Update Events for (let i = this.events.length - 1; i >= 0; i--) { const ev = this.events[i]; ev.life += dt; if (ev.life >= ev.maxLife) { this.events.splice(i, 1); } } // --- Observation Mode Enhancements --- this.updateAudioEnergy(); this.updateViewportVibration(dt); this.updateScanlineBreathing(); this.updateLightingCycle(dt); // v3co: all four are manifest-gated. `cinematics` in particular is a crossover vector -- // its sequences call spawnTraffic()/spawnEvent() directly and buildFlybySpectacleSequence() // is appended for every universe, so leaving it ungated would repopulate this.traffic for // themes whose renderers are off. if (this.hasLayer('shootingStars')) this.updateShootingStars(dt, now); if (this.hasLayer('constellations')) this.updateConstellations(dt, now); if (this.hasLayer('cinematics')) this.updateCinematicDirector(dt, now); this.updateStatusTicker(dt, now); // Sync Alert wash state with AlertSynth this.updateAlertState(); } spawnTraffic(emphasis) { const fromLeft = Math.random() > 0.5; let yStart, yEnd; if (this.isStationView()) { // Keep dock traffic inside the station's window opening - the bulkhead hides anything else const rect = this.getStationViewportRect(); yStart = rect.y + rect.h * 0.18 + Math.random() * rect.h * 0.64; yEnd = yStart + (Math.random() - 0.5) * rect.h * 0.3; } else { yStart = 160 + Math.random() * (this.height - 340); yEnd = yStart + (Math.random() - 0.5) * 220; } const x0 = fromLeft ? -100 : this.width + 100; const x1 = fromLeft ? this.width + 100 : -100; let duration = 12 + Math.random() * 14; let shipType = 'shuttle'; let label = 'SHUTTLE // TYPE-9'; let trailColor = '#38bdf8'; let scale = 1.0; if (this.activeUniverse === 'starfleet') { const types = ['shuttle', 'cruiser', 'runabout']; shipType = types[Math.floor(Math.random() * types.length)]; if (shipType === 'cruiser') { label = `USS GIBRALTAR // NCC-${Math.floor(Math.random()*80000+10000)}`; trailColor = '#60a5fa'; } else if (shipType === 'runabout') { label = `RUNABOUT YANGTZE // NCC-72452`; trailColor = '#f97316'; } } else if (this.activeUniverse === 'whoniverse') { shipType = 'tardis'; label = 'TYPE 40 TIME CAPSULE // DRIFT'; trailColor = '#00e5ff'; } else if (this.activeUniverse === 'industrial' || this.activeUniverse === 'outlaw') { shipType = 'freighter'; label = 'HEAVY HAULER // CLASS IV'; trailColor = '#f97316'; } else if (this.activeUniverse === 'military') { const types = ['cruiser', 'fighterwing']; shipType = types[Math.floor(Math.random() * types.length)]; if (shipType === 'fighterwing') { label = `VIPER WING // FLIGHT ${Math.floor(Math.random() * 9) + 1}`; trailColor = '#eab308'; scale = 0.75; } else { label = 'VIPER PATROL // CAP 04'; trailColor = '#eab308'; } } else if (this.activeUniverse === 'bioships') { shipType = 'bioshippod'; label = 'SPAWN POD // DRIFTING'; trailColor = '#34d399'; } else if (this.activeUniverse === 'retrofuture') { shipType = 'retrosaucer'; label = 'ATOMIC CRUISER // SAUCER CLASS'; trailColor = '#4ade80'; } if (emphasis) { // Close, dramatic flyby used by cinematic sequences: bigger, faster, more prominent scale *= 1.6; duration *= 0.55; } // Occasionally have a vessel materialize via warp instead of drifting in from off-screen, // and/or make a warp jump to depart instead of simply exiting the frame. const warpEntry = !emphasis && Math.random() < 0.12; const warpExit = !emphasis && Math.random() < 0.12; const initialProgress = warpEntry ? (0.12 + Math.random() * 0.1) : 0; const initialX = x0 + (x1 - x0) * initialProgress; const initialY = yStart + (yEnd - yStart) * initialProgress; this.traffic.push({ type: shipType, label: label, x0, y0: yStart, x1, y1: yEnd, x: initialX, y: initialY, vx: (x1 - x0) / duration, vy: (yEnd - yStart) / duration, progress: initialProgress, duration: duration, scale: shipType === 'cruiser' ? 0.75 : scale, trailColor: trailColor, particles: [], warpEntry, warpExit, warpExitDone: !warpExit }); if (warpEntry) { this.spawnEvent('warp-flash', initialX, initialY); } } spawnRandomEvent() { const kinds = ['comet', 'warp-flash']; const kind = kinds[Math.floor(Math.random() * kinds.length)]; let x, y; if (this.isStationView()) { // Confine arrivals and comets to the station's window opening const rect = this.getStationViewportRect(); x = rect.x + rect.w * 0.15 + Math.random() * rect.w * 0.7; y = rect.y + rect.h * 0.18 + Math.random() * rect.h * 0.64; } else { x = 200 + Math.random() * (this.width - 400); y = 140 + Math.random() * (this.height - 280); } this.spawnEvent(kind, x, y); } spawnEvent(kind, x, y) { this.events.push({ kind, x, y, life: 0, maxLife: kind === 'warp-flash' ? 1.4 : 5.0, vx: kind === 'comet' ? (Math.random() > 0.5 ? 120 : -120) : 0, vy: kind === 'comet' ? 50 : 0 }); } isStationView() { return this.activeUniverse === 'spacestations'; } // --- OBSERVATION_CANVAS accessors (v3co) ------------------------------- // Single source of truth for "may this theme draw on the canvas at all" and // "may it draw this particular layer". Default-deny in both directions. canvasEnabled() { const cfg = OBSERVATION_CANVAS[this.activeUniverse]; return !!(cfg && cfg.canvas); } hasLayer(name) { const cfg = OBSERVATION_CANVAS[this.activeUniverse]; return !!(cfg && cfg.canvas && cfg.layers && cfg.layers[name]); } getStarfieldProfile() { const cfg = OBSERVATION_CANVAS[this.activeUniverse]; const p = (cfg && cfg.starfield) || {}; return Object.assign({}, DEFAULT_STARFIELD_PROFILE, p); } render() { const ctx = this.ctx; if (!ctx) return; // v3co: a universe not declared canvas-on in OBSERVATION_CANVAS draws nothing at all -- // its OBSERVATION display is its own bespoke SVG art. First of three barriers (the other // two: canvas visibility:hidden in start(), and its own opaque background rect). if (!this.canvasEnabled()) return; const stationView = this.isStationView(); // Center of projection with sway const cx = this.width / 2 + this.cameraWobble.x; const cy = this.height / 2 + this.cameraWobble.y; // Clear canvas if (this.flightMode === 'warp' && !stationView) { // Warp motion blur trail ctx.fillStyle = 'rgba(1, 3, 8, 0.35)'; ctx.fillRect(0, 0, this.width, this.height); } else { // Stations always clear solid: a rotating station never smears into warp streaks ctx.fillStyle = '#010308'; ctx.fillRect(0, 0, this.width, this.height); } // v3co: every layer is drawn only if the active universe declares it in // OBSERVATION_CANVAS. The old isStationView() / activeUniverse === 'deepspace' // branching is replaced by uniform manifest lookups -- there is no implicit // "everyone gets this" layer any more. Draw order is unchanged. // 0. Space Stations: one fixed 360-degree sky wheeling past the window. if (this.hasLayer('stationPanorama')) this.renderStationPanorama(ctx); // 1. Deep Space Nebulae (generic glow) if (this.hasLayer('nebulae')) this.renderNebulae(ctx, cx, cy); // 2. Universe-Specific Celestial Objects (Planets, Time Vortex, Rings) if (this.hasLayer('celestial')) this.renderCelestialObjects(ctx, cx, cy); // 2b. Deep Space: procedural, session-seeded field (planets, dust, derelicts, anomalies) if (this.hasLayer('deepSpaceField')) this.renderDeepSpaceField(ctx, cx, cy); // 3. 3D Parallax Starfield & Warp Streaks (per-universe profile) if (this.hasLayer('starfield')) this.renderStarfield(ctx, cx, cy); // 3b. Procedural Constellation Lines (faint, background layer) if (this.hasLayer('constellations')) this.renderConstellations(ctx, cx, cy); // 3c. Shooting Stars if (this.hasLayer('shootingStars')) this.renderShootingStars(ctx); // 4. Dynamic Encounters & Traffic if (this.hasLayer('traffic') || this.hasLayer('events')) this.renderTrafficAndEvents(ctx); // 5. Holographic Target Reticles if (this.hasLayer('reticles')) this.renderTargetReticles(ctx); } getNebulaPalette() { let g1 = '#3b82f6', g2 = '#6366f1', g3 = '#ec4899'; if (this.activeUniverse === 'whoniverse') { g1 = '#00e5ff'; g2 = '#d4af37'; g3 = '#8b5cf6'; } else if (this.activeUniverse === 'industrial') { g1 = '#f59e0b'; g2 = '#b45309'; g3 = '#3f2c18'; } else if (this.activeUniverse === 'bioships') { g1 = '#10b981'; g2 = '#8b5cf6'; g3 = '#065f46'; } else if (this.activeUniverse === 'retrofuture') { g1 = '#22c55e'; g2 = '#15803d'; g3 = '#14532d'; } else if (this.activeUniverse === 'military') { g1 = '#64748b'; g2 = '#3f3f46'; g3 = '#7f1d1d'; } else if (this.activeUniverse === 'deepspace') { g1 = '#f59e0b'; g2 = '#7c2d12'; g3 = '#1e293b'; } else if (this.activeUniverse === 'outlaw') { g1 = '#f97316'; g2 = '#7c2d12'; g3 = '#4c0519'; } else if (this.activeUniverse === 'spacestations') { g1 = '#38bdf8'; g2 = '#0ea5e9'; g3 = '#1e3a8a'; } else if (this.activeUniverse === 'comedy') { g1 = '#f472b6'; g2 = '#a78bfa'; g3 = '#fde047'; } return [g1, g2, g3]; } initNebulaBlobs() { const palette = this.getNebulaPalette(); this.nebulaBlobs = []; for (let i = 0; i < 4; i++) { this.nebulaBlobs.push({ baseX: (Math.random() - 0.5) * 1.6, baseY: (Math.random() - 0.5) * 1.1, radius: 260 + Math.random() * 340, color: palette[i % palette.length], driftSpeedX: (Math.random() - 0.5) * 0.015, driftSpeedY: (Math.random() - 0.5) * 0.01, pulseSpeed: 0.15 + Math.random() * 0.25, pulsePhase: Math.random() * Math.PI * 2, baseAlpha: 0.05 + Math.random() * 0.05, morphSeed: Math.random() * 1000 }); } } renderNebulae(ctx, cx, cy) { if (!this.nebulaBlobs || !this.nebulaBlobs.length) this.initNebulaBlobs(); const bassBoost = this.warpPulseEnergy * 0.15 + (this.audioEnergy ? this.audioEnergy.bass * 0.12 : 0); const lighting = this.getLightingModifiers(); for (const blob of this.nebulaBlobs) { // Slow procedural drift/morph, looped via sine so blobs never run away off-scene const driftX = Math.sin(this.time * blob.driftSpeedX * 10 + blob.morphSeed) * 220; const driftY = Math.cos(this.time * blob.driftSpeedY * 10 + blob.morphSeed * 1.3) * 160; const bx = cx + blob.baseX * this.width * 0.5 + driftX; const by = cy + blob.baseY * this.height * 0.5 + driftY; const pulse = 1 + Math.sin(this.time * blob.pulseSpeed + blob.pulsePhase) * 0.18; const radius = Math.max(40, blob.radius * pulse * (0.9 + bassBoost * 2)); const alpha = Math.max(0, (blob.baseAlpha + bassBoost) * lighting.nebulaIntensity); const grad = ctx.createRadialGradient(bx, by, radius * 0.08, bx, by, radius); grad.addColorStop(0, hexToRgba(blob.color, alpha)); grad.addColorStop(0.55, hexToRgba(blob.color, alpha * 0.4)); grad.addColorStop(1, 'transparent'); ctx.fillStyle = grad; ctx.fillRect(0, 0, this.width, this.height); } } renderCelestialObjects(ctx, cx, cy) { if (this.activeUniverse === 'whoniverse') { // Procedural 3D Time Vortex ctx.save(); ctx.translate(cx, cy); const ringCount = 14; for (let i = ringCount; i >= 1; i--) { const ringZ = ((this.time * 0.6 + i * 0.45) % ringCount); const rNorm = ringZ / ringCount; const rx = 80 + rNorm * 480; const ry = 40 + rNorm * 220; const rot = this.time * (i % 2 === 0 ? 0.35 : -0.28) + i * 0.3; ctx.save(); ctx.rotate(rot); ctx.beginPath(); ctx.ellipse(0, 0, rx, ry, 0, 0, Math.PI * 2); ctx.strokeStyle = i % 2 === 0 ? `rgba(0, 229, 255, ${0.15 + (1 - rNorm) * 0.45})` : `rgba(212, 175, 55, ${0.12 + (1 - rNorm) * 0.4})`; ctx.lineWidth = 1.5 + (1 - rNorm) * 3; ctx.stroke(); ctx.restore(); } ctx.restore(); return; } if (this.activeUniverse === 'starfleet') { // Class-M Planet with Rayleigh Scattering Atmosphere Halo const px = cx + this.width * 0.26; const py = cy - this.height * 0.06; const pr = Math.min(this.width, this.height) * 0.22; // Outer Atmospheric Glow const atmosGrad = ctx.createRadialGradient(px, py, pr * 0.85, px, py, pr * 1.35); atmosGrad.addColorStop(0, 'rgba(56, 189, 248, 0.45)'); atmosGrad.addColorStop(0.5, 'rgba(59, 130, 246, 0.18)'); atmosGrad.addColorStop(1, 'transparent'); ctx.fillStyle = atmosGrad; ctx.beginPath(); ctx.arc(px, py, pr * 1.35, 0, Math.PI * 2); ctx.fill(); // Planet Body const bodyGrad = ctx.createRadialGradient(px - pr * 0.35, py - pr * 0.35, pr * 0.1, px, py, pr); bodyGrad.addColorStop(0, '#1e3a8a'); bodyGrad.addColorStop(0.4, '#1d4ed8'); bodyGrad.addColorStop(0.7, '#0f172a'); bodyGrad.addColorStop(1, '#020617'); ctx.fillStyle = bodyGrad; ctx.beginPath(); ctx.arc(px, py, pr, 0, Math.PI * 2); ctx.fill(); // Subtle atmospheric rim arc ctx.beginPath(); ctx.arc(px, py, pr, -Math.PI * 0.8, Math.PI * 0.2); ctx.strokeStyle = 'rgba(186, 230, 253, 0.65)'; ctx.lineWidth = 3.5; ctx.stroke(); // Orbiting Moon const moonAngle = this.time * 0.08; const mx = px + Math.cos(moonAngle) * (pr * 1.7); const my = py + Math.sin(moonAngle) * (pr * 0.6); const mr = pr * 0.14; ctx.fillStyle = '#cbd5e1'; ctx.beginPath(); ctx.arc(mx, my, mr, 0, Math.PI * 2); ctx.fill(); // Moon shadow ctx.fillStyle = 'rgba(2, 6, 23, 0.75)'; ctx.beginPath(); ctx.arc(mx + mr * 0.3, my, mr * 0.95, 0, Math.PI * 2); ctx.fill(); return; } // Deep Space's own procedural, session-seeded field (planets, nebulae, dust lanes, // derelicts, anomalies -- each with its own computer callout, no near-passing traffic) // is generated once in the constructor and drawn by renderDeepSpaceField() below, called // separately from render(). Space Stations never reaches this method at all // (isStationView() short-circuits render() first). } regenerateDeepSpaceField() { const rand = (min, max) => min + Math.random() * (max - min); const pick = (arr) => arr[Math.floor(Math.random() * arr.length)]; // Planets, derelicts and anomalies all get a computer callout with a leader line, so two // of them landing close together (or a small object landing inside a large planet) makes // both illegible. This tracks every placed object's normalized position + how much // clearance its callout needs, and biases new placements away from all of them -- // same idea as the even bearing-spacing the Space Station panorama already does, just in // 2D screen-fraction space instead of 1D bearing space. // Distances are measured in actual pixels (converting each candidate's normalized // offset through the canvas's own width/height) rather than raw nx/ny units, since a // canvas is wider than it is tall -- comparing normalized deltas directly would treat a // given horizontal separation as "closer" than the same vertical one. Clearance is also // required from BOTH sides of a pair (the new candidate's own footprint, not just the // already-placed object's), so two large planets can't still end up overlapping just // because each one individually satisfied a one-sided check. const placed = []; const placeAway = (nxRange, nyRange, clearancePx, tries = 14) => { let best = null, bestScore = -Infinity; for (let attempt = 0; attempt < tries; attempt++) { const nx = rand(nxRange[0], nxRange[1]); const ny = rand(nyRange[0], nyRange[1]); let minDist = placed.length ? Infinity : clearancePx; for (const p of placed) { const dPx = Math.hypot((nx - p.nx) * this.width, (ny - p.ny) * this.height); const d = dPx - p.clearance - clearancePx; if (d < minDist) minDist = d; } if (minDist > bestScore) { bestScore = minDist; best = { nx, ny }; } if (minDist >= clearancePx) break; } placed.push({ nx: best.nx, ny: best.ny, clearance: clearancePx }); return best; }; // --- Planets: 2-4, each a distinct world type spread across the sky --- const planetPalettes = [ { body: ['#b45309', '#78350f', '#292524', '#0c0a09'], ring: '#fdba74', rim: '#fed7aa', label: 'GAS GIANT' }, { body: ['#60a5fa', '#1d4ed8', '#1e3a8a', '#0b1220'], ring: '#93c5fd', rim: '#bae6fd', label: 'ICE WORLD // NO ATMOSPHERE' }, { body: ['#a78bfa', '#6d28d9', '#312e81', '#1e1b4b'], ring: '#c4b5fd', rim: '#e9d5ff', label: 'GAS GIANT // ION STORMS' }, { body: ['#f87171', '#991b1b', '#450a0a', '#1c0a0a'], ring: '#fca5a5', rim: '#fecaca', label: 'ROCKY WORLD // SEISMIC ACTIVITY' }, { body: ['#34d399', '#047857', '#022c22', '#020c09'], ring: '#6ee7b7', rim: '#a7f3d0', label: 'ROCKY WORLD // BIOSIGNATURE?' }, { body: ['#e2e8f0', '#94a3b8', '#334155', '#0f172a'], ring: '#f1f5f9', rim: '#f8fafc', label: 'FROZEN WORLD // DORMANT' } ]; const paletteBag = planetPalettes.slice(); const planets = []; const planetCount = 2 + Math.floor(Math.random() * 3); // 2-4 for (let i = 0; i < planetCount; i++) { const pal = paletteBag.splice(Math.floor(Math.random() * paletteBag.length), 1)[0]; const moons = []; const moonCount = Math.floor(Math.random() * 3); // 0-2 for (let m = 0; m < moonCount; m++) { moons.push({ orbitR: rand(1.4, 2.4), squash: rand(0.3, 0.65), speed: rand(0.03, 0.09), phase: Math.random() * Math.PI * 2, size: rand(0.08, 0.16), color: pick(['#cbd5e1', '#94a3b8', '#e2e8f0', '#78716c']) }); } const pRadius = rand(0.07, 0.16); const pPos = placeAway([-0.42, 0.42], [-0.30, 0.32], pRadius * Math.min(this.width, this.height) + 130); planets.push({ // At true interstellar range, a planet's apparent drift across a viewport during any // realistic observation session is imperceptible -- these sit at a fixed screen-relative // bearing, with only a slow independent sway standing in for that motion. nx: pPos.nx, ny: pPos.ny, radius: pRadius, palette: pal, hasRings: Math.random() < 0.5, ringTilt: rand(-0.55, -0.12), lightAngle: rand(-Math.PI, Math.PI), driftSpeedX: rand(-0.006, 0.006), driftSpeedY: rand(-0.004, 0.004), driftPhase: Math.random() * Math.PI * 2, scanPhase: Math.random() * Math.PI * 2, pingPhase: Math.random() * Math.PI * 2, pingInterval: rand(9, 15), moons }); } // --- Nebula clouds: richer and more varied than the generic 4-blob ambient system --- const nebulaPalette = ['#6366f1', '#818cf8', '#38bdf8', '#312e81', '#4338ca', '#0ea5e9', '#a5b4fc']; const nebulae = []; const nebulaCount = 3 + Math.floor(Math.random() * 3); // 3-5 for (let i = 0; i < nebulaCount; i++) { nebulae.push({ nx: rand(-0.6, 0.6), ny: rand(-0.5, 0.5), radius: rand(220, 620), color: pick(nebulaPalette), alpha: rand(0.035, 0.09), driftSpeedX: rand(-0.008, 0.008), driftSpeedY: rand(-0.006, 0.006), pulseSpeed: rand(0.06, 0.18), pulsePhase: Math.random() * Math.PI * 2, morphSeed: Math.random() * 1000 }); } // --- Fine dust lanes: thin streak-like clouds for texture, distinct from the soft blobs --- const dustLanes = []; const laneCount = 4 + Math.floor(Math.random() * 4); // 4-7 for (let i = 0; i < laneCount; i++) { dustLanes.push({ nx: rand(-0.55, 0.55), ny: rand(-0.45, 0.45), length: rand(260, 620), thickness: rand(30, 90), angle: rand(-0.6, 0.6), color: pick(['#4338ca', '#1e1b4b', '#0f172a', '#312e81']), alpha: rand(0.05, 0.12), driftSpeed: rand(0.003, 0.01), phase: Math.random() * Math.PI * 2 }); } // --- Distant derelict / megastructure silhouettes (generation-ship debris, thematic) --- const derelicts = []; if (Math.random() < 0.7) { const derelictCount = 1 + (Math.random() < 0.3 ? 1 : 0); for (let i = 0; i < derelictCount; i++) { const dPos = placeAway([-0.46, 0.46], [-0.4, 0.4], 150); derelicts.push({ nx: dPos.nx, ny: dPos.ny, scale: rand(0.05, 0.13), rotation: rand(-0.3, 0.3), segments: 3 + Math.floor(Math.random() * 4), beaconPhase: Math.random() * Math.PI * 2, beaconSpeed: rand(1.2, 2.8), color: pick(['#334155', '#3f3f46', '#292524', '#1e293b']), label: 'DERELICT // NO SIGNAL', scanPhase: Math.random() * Math.PI * 2, pingPhase: Math.random() * Math.PI * 2, pingInterval: rand(9, 15) }); } } // --- Gravitational / radiation anomalies: rare phenomena gated by the activity slider --- const anomalyCount = 1 + Math.floor(Math.random() * 2); // 1-2 const anomalies = []; const anomalyLabels = { lensing: 'GRAVITATIONAL LENSING', radiation: 'RADIATION SURGE' }; for (let i = 0; i < anomalyCount; i++) { const kind = pick(['lensing', 'radiation']); const aRadius = rand(50, 120); const aPos = placeAway([-0.4, 0.4], [-0.32, 0.32], aRadius + 150); anomalies.push({ nx: aPos.nx, ny: aPos.ny, radius: aRadius, kind, label: anomalyLabels[kind], minActivity: rand(0.15, 0.55), pulseSpeed: rand(0.15, 0.4), pulsePhase: Math.random() * Math.PI * 2, color: pick(['#818cf8', '#38bdf8', '#f43f5e']), scanPhase: Math.random() * Math.PI * 2, pingPhase: Math.random() * Math.PI * 2, pingInterval: rand(9, 15) }); } this.deepSpaceField = { planets, nebulae, dustLanes, derelicts, anomalies }; return this.deepSpaceField; } renderDeepSpaceField(ctx, cx, cy) { if (!this.deepSpaceField) this.regenerateDeepSpaceField(); const field = this.deepSpaceField; const lighting = this.getLightingModifiers(); const activity = (window.observationActivity !== undefined ? window.observationActivity : 0.6); const isWarp = this.flightMode === 'warp'; const warpStretch = isWarp ? 1 + Math.min(2.2, this.warpSpeed * 0.5) : 1; const bass = this.audioEnergy ? this.audioEnergy.bass : 0; const minDim = Math.min(this.width, this.height); // 1. Dust lanes -- furthest back, softest, faint streaked texture for (const lane of field.dustLanes) { const t = this.time * lane.driftSpeed + lane.phase; const bx = cx + lane.nx * this.width + Math.sin(t) * 40; const by = cy + lane.ny * this.height + Math.cos(t * 0.7) * 30; ctx.save(); ctx.translate(bx, by); ctx.rotate(lane.angle); const grad = ctx.createLinearGradient(-lane.length / 2, 0, lane.length / 2, 0); grad.addColorStop(0, 'transparent'); grad.addColorStop(0.5, hexToRgba(lane.color, lane.alpha * lighting.nebulaIntensity)); grad.addColorStop(1, 'transparent'); ctx.fillStyle = grad; ctx.fillRect(-lane.length / 2, -lane.thickness / 2, lane.length, lane.thickness); ctx.restore(); } // 2. Nebula clouds -- stretch into a relativistic streak while under warp for (const neb of field.nebulae) { const driftX = Math.sin(this.time * neb.driftSpeedX * 10 + neb.morphSeed) * 60; const driftY = Math.cos(this.time * neb.driftSpeedY * 10 + neb.morphSeed * 1.3) * 45; const bx = cx + neb.nx * this.width + driftX; const by = cy + neb.ny * this.height + driftY; const pulse = 1 + Math.sin(this.time * neb.pulseSpeed + neb.pulsePhase) * 0.15; const radius = neb.radius * pulse; const alpha = neb.alpha * lighting.nebulaIntensity; ctx.save(); if (isWarp) { ctx.translate(bx, by); ctx.scale(warpStretch, 1); ctx.translate(-bx, -by); } const grad = ctx.createRadialGradient(bx, by, radius * 0.1, bx, by, radius); grad.addColorStop(0, hexToRgba(neb.color, alpha)); grad.addColorStop(0.5, hexToRgba(neb.color, alpha * 0.4)); grad.addColorStop(1, 'transparent'); ctx.fillStyle = grad; ctx.fillRect(0, 0, this.width, this.height); ctx.restore(); } // 3. Derelicts / distant megastructures -- angular hull silhouette with a blinking beacon for (const d of field.derelicts) { const bx = cx + d.nx * this.width; const by = cy + d.ny * this.height; const scale = d.scale * minDim; ctx.save(); ctx.translate(bx, by); ctx.rotate(d.rotation); ctx.globalAlpha = 0.55; ctx.fillStyle = d.color; ctx.beginPath(); ctx.moveTo(-scale, 0); for (let s = 0; s < d.segments; s++) { const ang = (s / d.segments) * Math.PI - Math.PI / 2; ctx.lineTo(Math.cos(ang) * scale, Math.sin(ang) * scale * 0.3); } ctx.lineTo(scale, 0); ctx.closePath(); ctx.fill(); ctx.globalAlpha = 0.3 + Math.max(0, Math.sin(this.time * d.beaconSpeed + d.beaconPhase)) * 0.7; ctx.fillStyle = '#f43f5e'; ctx.beginPath(); ctx.arc(scale * 0.7, 0, Math.max(1.5, scale * 0.04), 0, Math.PI * 2); ctx.fill(); ctx.restore(); this.drawComputerCallout(ctx, bx, by, scale, d.label, '#f43f5e', d.scanPhase, d.pingPhase, d.pingInterval); } // 4. Planets with atmosphere, rings and orbiting moons for (const p of field.planets) { const driftX = Math.sin(this.time * p.driftSpeedX * 10 + p.driftPhase) * 25; const driftY = Math.cos(this.time * p.driftSpeedY * 10 + p.driftPhase * 1.2) * 18; const px = cx + p.nx * this.width + driftX; const py = cy + p.ny * this.height + driftY; const pr = p.radius * minDim; // Rings are split into a back arc and a front arc so they read as an actual ring // encircling a sphere (like Saturn) rather than a flat ellipse laid over a circle -- // the back arc is drawn now, then the opaque planet body occludes its far half, then // the front arc is drawn again afterward so it visibly crosses in front of the sphere. if (p.hasRings) { ctx.save(); ctx.translate(px, py); ctx.rotate(p.ringTilt); for (let r = pr * 1.3; r <= pr * 2.0; r += pr * 0.12) { ctx.beginPath(); ctx.ellipse(0, 0, r, r * 0.26, 0, Math.PI, Math.PI * 2); ctx.strokeStyle = hexToRgba(p.palette.ring, 0.1 + Math.sin(r * 0.15) * 0.06); ctx.lineWidth = pr * 0.05; ctx.stroke(); } ctx.restore(); } const bodyGrad = ctx.createRadialGradient( px - pr * Math.cos(p.lightAngle) * 0.35, py - pr * Math.sin(p.lightAngle) * 0.35, pr * 0.05, px, py, pr ); bodyGrad.addColorStop(0, p.palette.body[0]); bodyGrad.addColorStop(0.5, p.palette.body[1]); bodyGrad.addColorStop(0.85, p.palette.body[2]); bodyGrad.addColorStop(1, p.palette.body[3]); ctx.fillStyle = bodyGrad; ctx.beginPath(); ctx.arc(px, py, pr, 0, Math.PI * 2); ctx.fill(); // Atmospheric rim glow, gently audio-reactive ctx.strokeStyle = hexToRgba(p.palette.rim, 0.4 + bass * 0.15); ctx.lineWidth = pr * 0.06; ctx.beginPath(); ctx.arc(px, py, pr * 1.01, 0, Math.PI * 2); ctx.stroke(); if (p.hasRings) { ctx.save(); ctx.translate(px, py); ctx.rotate(p.ringTilt); for (let r = pr * 1.3; r <= pr * 2.0; r += pr * 0.12) { ctx.beginPath(); ctx.ellipse(0, 0, r, r * 0.26, 0, 0, Math.PI); ctx.strokeStyle = hexToRgba(p.palette.ring, 0.16 + Math.sin(r * 0.15) * 0.08); ctx.lineWidth = pr * 0.05; ctx.stroke(); } ctx.restore(); } for (const moon of p.moons) { const mAng = this.time * moon.speed + moon.phase; const mx = px + Math.cos(mAng) * pr * moon.orbitR; const my = py + Math.sin(mAng) * pr * moon.orbitR * moon.squash; ctx.save(); ctx.globalAlpha = 0.85; ctx.fillStyle = moon.color; ctx.beginPath(); ctx.arc(mx, my, pr * moon.size, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } this.drawComputerCallout(ctx, px, py, pr, p.palette.label, p.palette.rim, p.scanPhase, p.pingPhase, p.pingInterval); } // 5. Gravitational / radiation anomalies -- rare, activity-gated, fade in past their threshold for (const a of field.anomalies) { if (activity < a.minActivity) continue; const ax = cx + a.nx * this.width; const ay = cy + a.ny * this.height; const pulse = 0.5 + Math.sin(this.time * a.pulseSpeed + a.pulsePhase) * 0.5; const visibility = Math.min(1, (activity - a.minActivity) / 0.25); const alpha = pulse * 0.5 * visibility; if (alpha <= 0) continue; if (a.kind === 'lensing') { for (let ring = 0; ring < 3; ring++) { const rr = a.radius * (0.5 + ring * 0.35) * (1 + pulse * 0.15); ctx.beginPath(); ctx.arc(ax, ay, rr, 0, Math.PI * 2); ctx.strokeStyle = hexToRgba(a.color, alpha * (1 - ring * 0.25)); ctx.lineWidth = 2; ctx.stroke(); } } else { const grad = ctx.createRadialGradient(ax, ay, 0, ax, ay, a.radius); grad.addColorStop(0, hexToRgba(a.color, alpha * 0.6)); grad.addColorStop(1, 'transparent'); ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(ax, ay, a.radius, 0, Math.PI * 2); ctx.fill(); } // Computer callout instead of a bare floating label -- also keeps the text off the // anomaly itself via the bracket's leader line, rather than sitting on top of it (or, // as could happen before, on top of an unrelated planet placed nearby). this.drawComputerCallout(ctx, ax, ay, a.radius, a.label, a.color, a.scanPhase, a.pingPhase, a.pingInterval, visibility); } } // Shared "computer console" callout: LCARS corner brackets sized to the object, a leader // line, and a small mono-font tag -- the same drawing technique already used for ship // traffic reticles (renderTargetReticles), extended here to any discrete Deep Space object. // Idles with a slow breathing pulse and flashes briefly on a per-object interval, like a // sensor periodically re-confirming a lock, rather than sitting static on screen. drawComputerCallout(ctx, x, y, radius, label, color, scanPhase, pingPhase, pingInterval, intensityMul = 1) { const breathe = 0.55 + Math.sin(this.time * 0.6 + scanPhase) * 0.2; const cycle = pingInterval || 12; const t = (this.time + pingPhase) % cycle; const ping = t < 0.5 ? (1 - t / 0.5) : 0; const alpha = Math.min(1, breathe + ping * 0.5) * intensityMul; if (alpha <= 0.02) return; const boxSize = Math.max(18, radius * 1.18); const bLen = Math.max(5, boxSize * 0.22); const onRight = x > this.width * 0.55; const dir = onRight ? -1 : 1; ctx.save(); ctx.font = '11px "Share Tech Mono", monospace'; ctx.textAlign = onRight ? 'right' : 'left'; ctx.strokeStyle = hexToRgba(color, alpha); ctx.fillStyle = hexToRgba(color, Math.min(1, alpha + 0.15)); ctx.lineWidth = 1.2; ctx.beginPath(); ctx.moveTo(x - boxSize, y - boxSize + bLen); ctx.lineTo(x - boxSize, y - boxSize); ctx.lineTo(x - boxSize + bLen, y - boxSize); ctx.moveTo(x + boxSize - bLen, y - boxSize); ctx.lineTo(x + boxSize, y - boxSize); ctx.lineTo(x + boxSize, y - boxSize + bLen); ctx.moveTo(x - boxSize, y + boxSize - bLen); ctx.lineTo(x - boxSize, y + boxSize); ctx.lineTo(x - boxSize + bLen, y + boxSize); ctx.moveTo(x + boxSize - bLen, y + boxSize); ctx.lineTo(x + boxSize, y + boxSize); ctx.lineTo(x + boxSize, y + boxSize - bLen); ctx.stroke(); // Route the leader line downward instead of upward when the object sits close enough to // the top edge that an upward line would run into the HUD header text. const routeDown = (y - boxSize - 40) < 70; const vDir = routeDown ? 1 : -1; const cornerX = onRight ? x - boxSize : x + boxSize; const cornerY = routeDown ? y + boxSize : y - boxSize; // Two objects at similar heights can still park their labels at the same offset even // when their brackets are cleanly separated horizontally -- a small deterministic // per-object jitter (derived from its own scanPhase, so it's stable frame to frame) // spreads those cases apart without needing true label-bounding-box collision checks. const kinkJitter = Math.sin(scanPhase * 1.7) * 16; const kinkX = cornerX + dir * 22; const kinkY = cornerY + vDir * 14 + kinkJitter; const endX = cornerX + dir * 130; ctx.beginPath(); ctx.moveTo(cornerX, cornerY); ctx.lineTo(kinkX, kinkY); ctx.lineTo(endX, kinkY); ctx.stroke(); ctx.fillText(label, cornerX + dir * 26, kinkY + (routeDown ? 14 : -4)); ctx.restore(); } renderStarfield(ctx, cx, cy) { const fov = 420; const prof = this.starProfile || DEFAULT_STARFIELD_PROFILE; const isWarp = this.flightMode === 'warp' && prof.warpStreaks !== false; const brightness = this.getLightingModifiers().starBrightness; for (let i = 0; i < this.stars.length; i++) { const star = this.stars[i]; const sx = cx + (star.x / star.z) * fov; const sy = cy + (star.y / star.z) * fov; if (sx < -20 || sx > this.width + 20 || sy < -20 || sy > this.height + 20) { continue; } const normZ = 1 - star.z / 1200; const alpha = Math.max(0.15, Math.min(1, normZ * (isWarp ? 1.0 : (0.7 + Math.sin(this.time * star.twinkleSpeed + star.twinklePhase) * 0.3)) * brightness)); if (isWarp) { // Relativistic Warp Streaks const spx = cx + (star.x / star.pz) * fov; const spy = cy + (star.y / star.pz) * fov; ctx.beginPath(); ctx.moveTo(spx, spy); ctx.lineTo(sx, sy); ctx.strokeStyle = star.color; ctx.lineWidth = star.size * (1 + normZ * 1.5); ctx.globalAlpha = alpha; ctx.stroke(); } else { // Cruise Stars ctx.beginPath(); ctx.arc(sx, sy, star.size * (0.8 + normZ * 0.8), 0, Math.PI * 2); ctx.fillStyle = star.color; ctx.globalAlpha = alpha; ctx.fill(); } } ctx.globalAlpha = 1.0; } renderTrafficAndEvents(ctx) { // 1. Draw traffic engine trails for (const ship of this.traffic) { for (const pt of ship.particles) { ctx.beginPath(); ctx.arc(pt.x, pt.y, pt.size * pt.alpha, 0, Math.PI * 2); ctx.fillStyle = pt.color; ctx.globalAlpha = pt.alpha; ctx.fill(); } ctx.globalAlpha = 1.0; // Draw Ship Silhouette / Vessel Graphic this.drawShipVessel(ctx, ship); } // 2. Draw Dynamic Events (Warp Flash, Comets) for (const ev of this.events) { if (ev.kind === 'warp-flash') { const prog = ev.life / ev.maxLife; const radius = prog * 160; const alpha = Math.max(0, 1 - prog); ctx.save(); ctx.translate(ev.x, ev.y); // Radial starburst const fgrad = ctx.createRadialGradient(0, 0, 0, 0, 0, radius); fgrad.addColorStop(0, '#ffffff'); fgrad.addColorStop(0.3, '#38bdf8'); fgrad.addColorStop(1, 'transparent'); ctx.fillStyle = fgrad; ctx.globalAlpha = alpha; ctx.beginPath(); ctx.arc(0, 0, radius, 0, Math.PI * 2); ctx.fill(); // Anamorphic horizontal streak ctx.strokeStyle = '#93c5fd'; ctx.lineWidth = (1 - prog) * 6; ctx.beginPath(); ctx.moveTo(-radius * 3.5, 0); ctx.lineTo(radius * 3.5, 0); ctx.stroke(); ctx.restore(); } else if (ev.kind === 'comet') { const prog = ev.life / ev.maxLife; const cx = ev.x + ev.vx * ev.life; const cy = ev.y + ev.vy * ev.life; const alpha = Math.sin(prog * Math.PI) * 0.85; ctx.save(); ctx.globalAlpha = alpha; // Tail ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx - ev.vx * 0.7, cy - ev.vy * 0.7); ctx.strokeStyle = 'rgba(186, 230, 253, 0.6)'; ctx.lineWidth = 4; ctx.stroke(); // Head ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.arc(cx, cy, 5, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } } } drawShipVessel(ctx, ship) { ctx.save(); ctx.translate(ship.x, ship.y); const heading = Math.atan2(ship.vy, ship.vx); ctx.rotate(heading); ctx.scale(ship.scale, ship.scale); if (ship.type === 'shuttle') { // Sleek Starfleet Shuttlecraft // Hull ctx.fillStyle = '#e2e8f0'; ctx.beginPath(); ctx.moveTo(28, 0); ctx.lineTo(12, -10); ctx.lineTo(-24, -10); ctx.lineTo(-28, -6); ctx.lineTo(-28, 6); ctx.lineTo(-24, 10); ctx.lineTo(12, 10); ctx.closePath(); ctx.fill(); // Cockpit windshield ctx.fillStyle = '#0f172a'; ctx.beginPath(); ctx.moveTo(22, 0); ctx.lineTo(10, -6); ctx.lineTo(6, -6); ctx.lineTo(6, 6); ctx.lineTo(10, 6); ctx.closePath(); ctx.fill(); // Warp Nacelles with Glowing Blue Field ctx.fillStyle = '#94a3b8'; ctx.fillRect(-22, -16, 26, 4); ctx.fillRect(-22, 12, 26, 4); ctx.fillStyle = '#38bdf8'; ctx.shadowColor = '#38bdf8'; ctx.shadowBlur = 8; ctx.fillRect(-18, -15, 18, 2); ctx.fillRect(-18, 13, 18, 2); // Red Bussard Collectors ctx.fillStyle = '#ef4444'; ctx.shadowColor = '#ef4444'; ctx.beginPath(); ctx.arc(5, -14, 2, 0, Math.PI * 2); ctx.arc(5, 14, 2, 0, Math.PI * 2); ctx.fill(); } else if (ship.type === 'cruiser') { // Starfleet Capital Cruiser Silhouette ctx.fillStyle = '#cbd5e1'; // Primary Saucer ctx.beginPath(); ctx.ellipse(30, 0, 24, 14, 0, 0, Math.PI * 2); ctx.fill(); // Secondary Engineering Hull & Neck ctx.fillStyle = '#94a3b8'; ctx.fillRect(-15, -5, 34, 10); ctx.beginPath(); ctx.moveTo(-15, -4); ctx.lineTo(-45, -3); ctx.lineTo(-45, 3); ctx.lineTo(-15, 4); ctx.closePath(); ctx.fill(); // Dual Nacelle Struts and Nacelles ctx.strokeStyle = '#64748b'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(-10, 0); ctx.lineTo(-25, -20); ctx.moveTo(-10, 0); ctx.lineTo(-25, 20); ctx.stroke(); ctx.fillStyle = '#3b82f6'; ctx.shadowColor = '#60a5fa'; ctx.shadowBlur = 10; ctx.fillRect(-45, -22, 42, 5); ctx.fillRect(-45, 17, 42, 5); // Bussard ctx.fillStyle = '#ef4444'; ctx.beginPath(); ctx.arc(-2, -19.5, 2.5, 0, Math.PI * 2); ctx.arc(-2, 19.5, 2.5, 0, Math.PI * 2); ctx.fill(); } else if (ship.type === 'tardis') { // Whoniverse TARDIS tumbling in vortex ctx.rotate(this.time * 1.5); ctx.fillStyle = '#1e3a8a'; ctx.fillRect(-12, -18, 24, 36); // Panels ctx.fillStyle = '#172554'; ctx.fillRect(-10, -15, 9, 14); ctx.fillRect(1, -15, 9, 14); ctx.fillRect(-10, 1, 9, 14); ctx.fillRect(1, 1, 9, 14); // Flashing amber lantern ctx.fillStyle = Math.sin(this.time * 6) > 0 ? '#fbbf24' : '#78350f'; ctx.shadowColor = '#fbbf24'; ctx.shadowBlur = 8; ctx.beginPath(); ctx.arc(0, -21, 3.5, 0, Math.PI * 2); ctx.fill(); } else if (ship.type === 'fighterwing') { // Military Fighter Wing - tight 3-ship delta formation const drawFighter = (ox, oy) => { ctx.save(); ctx.translate(ox, oy); ctx.fillStyle = '#4b5563'; ctx.beginPath(); ctx.moveTo(16, 0); ctx.lineTo(-10, -9); ctx.lineTo(-6, 0); ctx.lineTo(-10, 9); ctx.closePath(); ctx.fill(); ctx.fillStyle = '#eab308'; ctx.shadowColor = '#eab308'; ctx.shadowBlur = 6; ctx.beginPath(); ctx.arc(-9, 0, 1.6, 0, Math.PI * 2); ctx.fill(); ctx.restore(); }; drawFighter(0, 0); drawFighter(-16, -14); drawFighter(-16, 14); } else if (ship.type === 'bioshippod') { // Organic Bioship Spawn Pod - pulsing membrane sac drifting through space const pulse = 1 + Math.sin(this.time * 2.4) * 0.12; ctx.fillStyle = '#065f46'; ctx.beginPath(); ctx.ellipse(0, 0, 22 * pulse, 13 * pulse, 0, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = 'rgba(52, 211, 153, 0.55)'; ctx.shadowColor = '#34d399'; ctx.shadowBlur = 12; ctx.beginPath(); ctx.ellipse(0, 0, 13 * pulse, 7 * pulse, 0, 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0; ctx.strokeStyle = 'rgba(167, 243, 208, 0.5)'; ctx.lineWidth = 1; for (let v = -1; v <= 1; v++) { ctx.beginPath(); ctx.moveTo(-18, v * 6); ctx.lineTo(18, v * 6); ctx.stroke(); } } else if (ship.type === 'retrosaucer') { // Retro-Future Atomic-Age Flying Saucer with a chasing rim-light pattern ctx.fillStyle = '#d1d5db'; ctx.beginPath(); ctx.ellipse(0, 2, 26, 8, 0, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#9ca3af'; ctx.beginPath(); ctx.ellipse(0, -3, 12, 9, 0, 0, Math.PI * 2); ctx.fill(); const litIndex = Math.floor(this.time * 4) % 6; for (let i = 0; i < 6; i++) { const ang = (i / 6) * Math.PI * 2; const isLit = i === litIndex; ctx.fillStyle = isLit ? '#4ade80' : 'rgba(74, 222, 128, 0.35)'; ctx.shadowColor = '#4ade80'; ctx.shadowBlur = isLit ? 8 : 0; ctx.beginPath(); ctx.arc(Math.cos(ang) * 22, 2 + Math.sin(ang) * 6, 2, 0, Math.PI * 2); ctx.fill(); } } else { // Heavy Industrial Cargo Freighter ctx.fillStyle = '#78350f'; ctx.fillRect(-35, -12, 55, 24); ctx.fillStyle = '#b45309'; ctx.fillRect(12, -8, 16, 16); // Orange Thrusters ctx.fillStyle = '#f97316'; ctx.shadowColor = '#ea580c'; ctx.shadowBlur = 10; ctx.fillRect(-38, -9, 4, 6); ctx.fillRect(-38, 3, 4, 6); } ctx.restore(); } renderTargetReticles(ctx) { ctx.save(); ctx.font = '11px "Share Tech Mono", monospace'; ctx.fillStyle = '#38bdf8'; ctx.strokeStyle = '#38bdf8'; ctx.lineWidth = 1.2; for (const ship of this.traffic) { const boxSize = 34 * ship.scale; const x = ship.x; const y = ship.y; // Draw LCARS corner reticle brackets const bLen = 8; // Top-left ctx.beginPath(); ctx.moveTo(x - boxSize, y - boxSize + bLen); ctx.lineTo(x - boxSize, y - boxSize); ctx.lineTo(x - boxSize + bLen, y - boxSize); // Top-right ctx.moveTo(x + boxSize - bLen, y - boxSize); ctx.lineTo(x + boxSize, y - boxSize); ctx.lineTo(x + boxSize, y - boxSize + bLen); // Bottom-left ctx.moveTo(x - boxSize, y + boxSize - bLen); ctx.lineTo(x - boxSize, y + boxSize); ctx.lineTo(x - boxSize + bLen, y + boxSize); // Bottom-right ctx.moveTo(x + boxSize - bLen, y + boxSize); ctx.lineTo(x + boxSize, y + boxSize); ctx.lineTo(x + boxSize, y + boxSize - bLen); ctx.stroke(); // Leader line and text tag ctx.beginPath(); ctx.moveTo(x + boxSize, y - boxSize); ctx.lineTo(x + boxSize + 22, y - boxSize - 14); ctx.lineTo(x + boxSize + 130, y - boxSize - 14); ctx.stroke(); ctx.fillText(ship.label, x + boxSize + 26, y - boxSize - 18); } ctx.restore(); } renderWaveform() { if (!this.waveformCtx || !this.waveformCanvas) return; const ctx = this.waveformCtx; const w = this.waveformCanvas.width / (this.dpr || 1); const h = this.waveformCanvas.height / (this.dpr || 1); ctx.clearRect(0, 0, w, h); if (this.am && this.am.analyser) { this.am.analyser.getByteFrequencyData(this.analyserData); } const bars = 30; const barW = Math.max(3, (w / bars) - 2); const accent = getComputedStyle(document.body).getPropertyValue('--primary-accent').trim() || '#ff9900'; ctx.fillStyle = accent; for (let i = 0; i < bars; i++) { const val = (this.analyserData[i * 2] || 0) / 255; const barH = Math.max(2, val * (h - 4)); ctx.fillRect(i * (barW + 2), h - barH, barW, barH); } } // ========================================================================= // Space Stations: Procedural 360-Degree Rotating Panorama // // A station rotating in place sees a fixed sky wheel past the window and come back // around again - there is no forward motion, so no pilot-style starfield rush here. // The sky is generated once per page load in normalized azimuth/elevation space, so // it survives window resizes, preset switches and re-entering Observation Mode, and // only changes when the page is reloaded. // ========================================================================= regenerateStationPanorama() { const rand = (min, max) => min + Math.random() * (max - min); const pick = (arr) => arr[Math.floor(Math.random() * arr.length)]; // --- Background stars spread around the full circle --- const stars = []; for (let i = 0; i < 900; i++) { stars.push({ a: Math.random(), e: (Math.random() - 0.5) * 2, size: Math.pow(Math.random(), 2.2) * 2.4 + 0.5, // mostly pinpricks, a few bright ones color: pick(this.starColors), brightness: 0.35 + Math.random() * 0.65, twinkleSpeed: 0.6 + Math.random() * 2.6, twinklePhase: Math.random() * Math.PI * 2 }); } // --- Deep space nebula patches --- const nebulaPalette = ['#38bdf8', '#0ea5e9', '#1e3a8a', '#6366f1', '#8b5cf6', '#0891b2', '#f472b6']; const nebulae = []; const nebulaCount = 3 + Math.floor(Math.random() * 3); for (let i = 0; i < nebulaCount; i++) { nebulae.push({ a: Math.random(), e: rand(-0.7, 0.7), radius: rand(0.35, 1.1), color: pick(nebulaPalette), alpha: rand(0.05, 0.12), pulseSpeed: rand(0.08, 0.22), pulsePhase: Math.random() * Math.PI * 2 }); } // --- Planets, spaced apart so two never stack on the same bearing --- const planetPalettes = [ { c0: '#60a5fa', c1: '#1d4ed8', c2: '#0b1220', rim: '#bae6fd' }, { c0: '#fbbf24', c1: '#b45309', c2: '#1c1917', rim: '#fed7aa' }, { c0: '#4ade80', c1: '#15803d', c2: '#052e16', rim: '#bbf7d0' }, { c0: '#e0f2fe', c1: '#7dd3fc', c2: '#0c4a6e', rim: '#f0f9ff' }, { c0: '#fb923c', c1: '#9a3412', c2: '#1c1917', rim: '#fdba74' }, { c0: '#c084fc', c1: '#6d28d9', c2: '#1e1b4b', rim: '#e9d5ff' } ]; const planets = []; const planetCount = 1 + Math.floor(Math.random() * 2); for (let i = 0; i < planetCount; i++) { const pal = planetPalettes.splice(Math.floor(Math.random() * planetPalettes.length), 1)[0]; const moons = []; const moonCount = Math.floor(Math.random() * 3); for (let m = 0; m < moonCount; m++) { moons.push({ orbit: rand(1.5, 2.6), squash: rand(0.25, 0.6), speed: rand(0.05, 0.15), phase: Math.random() * Math.PI * 2, size: rand(0.09, 0.17) }); } planets.push({ a: 0, // bearings are assigned below, evenly spread around the circle e: rand(-0.35, 0.35), radius: rand(0.18, 0.42), // fraction of the window band height palette: pal, hasRings: Math.random() < 0.45, ringTilt: rand(-0.6, -0.15), bandCount: 2 + Math.floor(Math.random() * 4), lightAngle: rand(-Math.PI, Math.PI), moons }); } // --- A distant sun --- const sun = { a: 0, e: rand(-0.4, 0.4), radius: rand(0.05, 0.1), color: pick(['#fef9c3', '#fed7aa', '#e0f2fe', '#fecaca']) }; // --- Far-off sister stations with blinking beacons --- const structures = []; const structureCount = 1 + Math.floor(Math.random() * 2); for (let i = 0; i < structureCount; i++) { structures.push({ a: 0, e: rand(-0.45, 0.45), scale: rand(0.07, 0.15), hasRing: Math.random() < 0.6, panelCount: 1 + Math.floor(Math.random() * 2), beaconSpeed: rand(1.4, 3.2), beaconPhase: Math.random() * Math.PI * 2 }); } // --- An asteroid cluster --- const rocks = []; const clusterE = rand(-0.5, 0.5); const rockCount = 9 + Math.floor(Math.random() * 10); for (let i = 0; i < rockCount; i++) { const verts = []; const vertCount = 5 + Math.floor(Math.random() * 4); for (let v = 0; v < vertCount; v++) { verts.push({ ang: (v / vertCount) * Math.PI * 2, r: 0.6 + Math.random() * 0.5 }); } rocks.push({ da: rand(-0.035, 0.035), de: rand(-0.35, 0.35), size: rand(0.012, 0.035), verts, rotSpeed: rand(-0.35, 0.35), rotPhase: Math.random() * Math.PI * 2, shade: pick(['#57534e', '#44403c', '#78716c', '#3f3f46']) }); } const asteroids = { a: 0, e: clusterE, rocks }; // --- Spread the major features evenly around the circle --- // Purely random bearings tend to clump: everything piles into one window while the // rest of the revolution is empty sky. Slotting them gives a steady rhythm of one // notable object drifting through every so often, which is the point of a 360 view. const majors = [...planets, sun, ...structures, asteroids]; for (let i = majors.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [majors[i], majors[j]] = [majors[j], majors[i]]; } const slot = 1 / majors.length; const baseBearing = Math.random(); majors.forEach((feature, i) => { const bearing = baseBearing + (i + rand(-0.28, 0.28)) * slot; feature.a = ((bearing % 1) + 1) % 1; }); this.stationPanorama = { stars, nebulae, planets, sun, structures, asteroids }; return this.stationPanorama; } // The station stage SVG uses viewBox "0 0 1600 900" with preserveAspectRatio="xMidYMid slice", // so its window cutout (220,140 -> 1380,680) lands on screen like a CSS `cover` background. // Matching that math keeps celestial objects transiting through the real opening. getStationViewportRect() { if (!this.showViewport) { return { x: 0, y: 0, w: this.width, h: this.height, cx: this.width / 2, cy: this.height / 2 }; } const scale = Math.max(this.width / 1600, this.height / 900); const offX = (this.width - 1600 * scale) / 2; const offY = (this.height - 900 * scale) / 2; const x = offX + 220 * scale; const y = offY + 140 * scale; const w = 1160 * scale; const h = 540 * scale; return { x, y, w, h, cx: x + w / 2, cy: y + h / 2 }; } updateStationPanorama(dt) { if (!this.stationPanorama) this.regenerateStationPanorama(); const period = this.stationRevolutionSeconds || 240; this.stationRotation = (this.stationRotation + dt / period) % 1; } // Maps a panorama bearing to a screen x, wrapping seamlessly through 360 degrees. stationProjectX(a, rect) { let rel = (a - this.stationRotation + 0.5) % 1; if (rel < 0) rel += 1; rel -= 0.5; return rect.cx + (rel / (this.stationFov || 0.22)) * rect.w; } stationProjectY(e, rect) { return rect.cy + e * rect.h * 0.58; } renderStationPanorama(ctx) { if (!this.stationPanorama) this.regenerateStationPanorama(); const pano = this.stationPanorama; const rect = this.getStationViewportRect(); const lighting = this.getLightingModifiers(); const bass = this.audioEnergy ? this.audioEnergy.bass : 0; // --- Nebula patches --- for (const neb of pano.nebulae) { const radius = neb.radius * rect.h * (1 + Math.sin(this.time * neb.pulseSpeed + neb.pulsePhase) * 0.12); const nx = this.stationProjectX(neb.a, rect); if (nx < rect.x - radius * 1.2 || nx > rect.x + rect.w + radius * 1.2) continue; const ny = this.stationProjectY(neb.e, rect); const alpha = Math.max(0, (neb.alpha + bass * 0.06) * lighting.nebulaIntensity); const grad = ctx.createRadialGradient(nx, ny, radius * 0.08, nx, ny, radius); grad.addColorStop(0, hexToRgba(neb.color, alpha)); grad.addColorStop(0.55, hexToRgba(neb.color, alpha * 0.4)); grad.addColorStop(1, 'transparent'); ctx.fillStyle = grad; ctx.fillRect(rect.x - 40, rect.y - 40, rect.w + 80, rect.h + 80); } // --- Background stars --- const brightness = lighting.starBrightness; for (const star of pano.stars) { const sx = this.stationProjectX(star.a, rect); if (sx < rect.x - 20 || sx > rect.x + rect.w + 20) continue; const sy = this.stationProjectY(star.e, rect); const twinkle = 0.72 + Math.sin(this.time * star.twinkleSpeed + star.twinklePhase) * 0.28; ctx.globalAlpha = Math.max(0.05, Math.min(1, star.brightness * twinkle * brightness)); ctx.fillStyle = star.color; ctx.beginPath(); ctx.arc(sx, sy, star.size, 0, Math.PI * 2); ctx.fill(); } ctx.globalAlpha = 1; // --- Distant sun --- this.renderStationSun(ctx, pano.sun, rect, brightness); // --- Asteroid cluster --- this.renderStationAsteroids(ctx, pano.asteroids, rect); // --- Planets --- for (const planet of pano.planets) { this.renderStationPlanet(ctx, planet, rect); } // --- Far-off sister stations --- for (const structure of pano.structures) { this.renderStationStructure(ctx, structure, rect); } } renderStationSun(ctx, sun, rect, brightness) { const sx = this.stationProjectX(sun.a, rect); const r = sun.radius * rect.h; if (sx < rect.x - r * 8 || sx > rect.x + rect.w + r * 8) return; const sy = this.stationProjectY(sun.e, rect); ctx.save(); const glow = ctx.createRadialGradient(sx, sy, r * 0.2, sx, sy, r * 6); glow.addColorStop(0, hexToRgba(sun.color, 0.5 * brightness)); glow.addColorStop(0.25, hexToRgba(sun.color, 0.14 * brightness)); glow.addColorStop(1, 'transparent'); ctx.fillStyle = glow; ctx.beginPath(); ctx.arc(sx, sy, r * 6, 0, Math.PI * 2); ctx.fill(); // Core ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.arc(sx, sy, r, 0, Math.PI * 2); ctx.fill(); // Soft anamorphic streak - gradient stroke so it fades out instead of ending as a hard bar const streak = ctx.createLinearGradient(sx - r * 7, sy, sx + r * 7, sy); streak.addColorStop(0, 'transparent'); streak.addColorStop(0.5, hexToRgba(sun.color, 0.3 * brightness)); streak.addColorStop(1, 'transparent'); ctx.strokeStyle = streak; ctx.lineWidth = r * 0.22; ctx.beginPath(); ctx.moveTo(sx - r * 7, sy); ctx.lineTo(sx + r * 7, sy); ctx.stroke(); ctx.restore(); } renderStationPlanet(ctx, planet, rect) { const pr = planet.radius * rect.h * 0.5; const px = this.stationProjectX(planet.a, rect); const reach = planet.hasRings ? pr * 2.4 : pr * 2.8; if (px < rect.x - reach || px > rect.x + rect.w + reach) return; const py = this.stationProjectY(planet.e, rect); const pal = planet.palette; ctx.save(); // Moons currently behind the planet for (const moon of planet.moons) { const ang = this.time * moon.speed + moon.phase; if (Math.sin(ang) >= 0) continue; this.drawStationMoon(ctx, px, py, pr, moon, ang); } // Back half of the rings if (planet.hasRings) { this.drawStationRings(ctx, px, py, pr, planet, Math.PI, Math.PI * 2); } // Atmospheric halo const halo = ctx.createRadialGradient(px, py, pr * 0.9, px, py, pr * 1.3); halo.addColorStop(0, hexToRgba(pal.rim, 0.28)); halo.addColorStop(1, 'transparent'); ctx.fillStyle = halo; ctx.beginPath(); ctx.arc(px, py, pr * 1.3, 0, Math.PI * 2); ctx.fill(); // Body const lx = px + Math.cos(planet.lightAngle) * pr * 0.4; const ly = py + Math.sin(planet.lightAngle) * pr * 0.4; const body = ctx.createRadialGradient(lx, ly, pr * 0.08, px, py, pr); body.addColorStop(0, pal.c0); body.addColorStop(0.45, pal.c1); body.addColorStop(1, pal.c2); ctx.fillStyle = body; ctx.beginPath(); ctx.arc(px, py, pr, 0, Math.PI * 2); ctx.fill(); // Latitude banding, clipped to the disc ctx.save(); ctx.beginPath(); ctx.arc(px, py, pr, 0, Math.PI * 2); ctx.clip(); ctx.globalAlpha = 0.16; ctx.fillStyle = pal.c2; for (let b = 0; b < planet.bandCount; b++) { const by = py - pr + ((b + 0.5) / planet.bandCount) * pr * 2; const bh = (pr * 2 / planet.bandCount) * 0.42; ctx.fillRect(px - pr, by - bh / 2, pr * 2, bh); } ctx.restore(); // Lit rim arc ctx.globalAlpha = 0.45; ctx.strokeStyle = pal.rim; ctx.lineWidth = Math.max(1.2, pr * 0.035); ctx.beginPath(); ctx.arc(px, py, pr, planet.lightAngle - Math.PI * 0.55, planet.lightAngle + Math.PI * 0.55); ctx.stroke(); ctx.globalAlpha = 1; // Front half of the rings if (planet.hasRings) { this.drawStationRings(ctx, px, py, pr, planet, 0, Math.PI); } // Moons in front of the planet for (const moon of planet.moons) { const ang = this.time * moon.speed + moon.phase; if (Math.sin(ang) < 0) continue; this.drawStationMoon(ctx, px, py, pr, moon, ang); } ctx.restore(); } drawStationRings(ctx, px, py, pr, planet, startAngle, endAngle) { ctx.save(); ctx.translate(px, py); ctx.rotate(planet.ringTilt); const step = Math.max(3, pr * 0.1); for (let r = pr * 1.35; r <= pr * 2.05; r += step) { ctx.beginPath(); ctx.ellipse(0, 0, r, r * 0.3, 0, startAngle, endAngle); ctx.strokeStyle = hexToRgba(planet.palette.rim, 0.1 + Math.sin(r * 0.12) * 0.06); ctx.lineWidth = step * 0.75; ctx.stroke(); } ctx.restore(); } drawStationMoon(ctx, px, py, pr, moon, ang) { const mx = px + Math.cos(ang) * pr * moon.orbit; const my = py + Math.sin(ang) * pr * moon.orbit * moon.squash; const mr = pr * moon.size; ctx.fillStyle = '#cbd5e1'; ctx.beginPath(); ctx.arc(mx, my, mr, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = 'rgba(2, 6, 23, 0.7)'; ctx.beginPath(); ctx.arc(mx + mr * 0.35, my + mr * 0.1, mr * 0.95, 0, Math.PI * 2); ctx.fill(); } renderStationStructure(ctx, structure, rect) { const s = structure.scale * rect.h; const sx = this.stationProjectX(structure.a, rect); if (sx < rect.x - s * 4 || sx > rect.x + rect.w + s * 4) return; const sy = this.stationProjectY(structure.e, rect); ctx.save(); ctx.translate(sx, sy); // Central hub ctx.fillStyle = '#1e293b'; ctx.strokeStyle = '#475569'; ctx.lineWidth = Math.max(1, s * 0.06); ctx.beginPath(); ctx.ellipse(0, 0, s * 0.42, s * 0.3, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); // Habitation ring if (structure.hasRing) { ctx.strokeStyle = '#334155'; ctx.lineWidth = Math.max(1.5, s * 0.1); ctx.beginPath(); ctx.ellipse(0, 0, s, s * 0.34, 0, 0, Math.PI * 2); ctx.stroke(); } // Solar panel wings ctx.fillStyle = '#0f2942'; ctx.strokeStyle = '#1e40af'; ctx.lineWidth = Math.max(0.8, s * 0.03); for (let p = 0; p < structure.panelCount; p++) { const off = (p + 1) * s * 0.55; ctx.fillRect(-off - s * 0.5, -s * 0.16, s * 0.5, s * 0.32); ctx.strokeRect(-off - s * 0.5, -s * 0.16, s * 0.5, s * 0.32); ctx.fillRect(off, -s * 0.16, s * 0.5, s * 0.32); ctx.strokeRect(off, -s * 0.16, s * 0.5, s * 0.32); } // Communications mast ctx.strokeStyle = '#64748b'; ctx.lineWidth = Math.max(1, s * 0.04); ctx.beginPath(); ctx.moveTo(0, -s * 0.3); ctx.lineTo(0, -s * 0.75); ctx.stroke(); // Blinking beacons const lit = Math.sin(this.time * structure.beaconSpeed + structure.beaconPhase) > 0.4; ctx.fillStyle = lit ? '#f97316' : 'rgba(249, 115, 22, 0.25)'; ctx.shadowColor = '#f97316'; ctx.shadowBlur = lit ? s * 0.5 : 0; ctx.beginPath(); ctx.arc(0, -s * 0.78, Math.max(1.2, s * 0.07), 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0; ctx.fillStyle = lit ? 'rgba(125, 211, 252, 0.9)' : 'rgba(125, 211, 252, 0.35)'; ctx.beginPath(); ctx.arc(-s * 0.42, s * 0.1, Math.max(1, s * 0.05), 0, Math.PI * 2); ctx.arc(s * 0.42, s * 0.1, Math.max(1, s * 0.05), 0, Math.PI * 2); ctx.fill(); ctx.restore(); } renderStationAsteroids(ctx, cluster, rect) { for (const rock of cluster.rocks) { const rx = this.stationProjectX(cluster.a + rock.da, rect); const size = rock.size * rect.h; if (rx < rect.x - size * 3 || rx > rect.x + rect.w + size * 3) continue; const ry = this.stationProjectY(cluster.e + rock.de * 0.5, rect); ctx.save(); ctx.translate(rx, ry); ctx.rotate(this.time * rock.rotSpeed + rock.rotPhase); ctx.fillStyle = rock.shade; ctx.beginPath(); rock.verts.forEach((v, i) => { const vx = Math.cos(v.ang) * size * v.r; const vy = Math.sin(v.ang) * size * v.r; if (i === 0) ctx.moveTo(vx, vy); else ctx.lineTo(vx, vy); }); ctx.closePath(); ctx.fill(); ctx.strokeStyle = 'rgba(148, 163, 184, 0.25)'; ctx.lineWidth = 0.8; ctx.stroke(); ctx.restore(); } } // ========================================================================= // Observation Mode Enhancements: Audio-Visual Integration // ========================================================================= updateAudioEnergy() { if (this.am && this.am.analyser) { this.am.analyser.getByteFrequencyData(this.analyserData); } const data = this.analyserData; const n = data.length; if (!n) return; const bassEnd = Math.max(1, Math.floor(n * 0.12)); const midEnd = Math.max(bassEnd + 1, Math.floor(n * 0.5)); let bassSum = 0, bassCount = 0, midSum = 0, midCount = 0, trebleSum = 0, trebleCount = 0; for (let i = 0; i < n; i++) { const v = data[i] / 255; if (i < bassEnd) { bassSum += v; bassCount++; } else if (i < midEnd) { midSum += v; midCount++; } else { trebleSum += v; trebleCount++; } } const bass = bassCount ? bassSum / bassCount : 0; const mid = midCount ? midSum / midCount : 0; const treble = trebleCount ? trebleSum / trebleCount : 0; // Smooth toward the new reading each frame so visuals don't flicker with raw FFT noise. const smooth = 0.15; this.audioEnergy.bass += (bass - this.audioEnergy.bass) * smooth; this.audioEnergy.mid += (mid - this.audioEnergy.mid) * smooth; this.audioEnergy.treble += (treble - this.audioEnergy.treble) * smooth; this.audioEnergy.overall = this.audioEnergy.bass * 0.5 + this.audioEnergy.mid * 0.35 + this.audioEnergy.treble * 0.15; } updateViewportVibration(dt) { // Viewport vibration is intentionally disabled (v11co). // The canvas and the viewport frame do not always share a layer - Space Stations, // for example, draws its window architecture in the SVG stage instead of the frame // element - so translating the canvas made the view slide inside a stationary window. // Hull Drone energy still drives the nebulae; the viewport itself now stays locked. this.viewportVibration.x = 0; this.viewportVibration.y = 0; if (this.canvas) this.canvas.style.transform = ''; if (this.frameEl) this.frameEl.style.transform = ''; } updateScanlineBreathing() { if (!this.scanlinesEl) { this.scanlinesEl = this.overlay ? this.overlay.querySelector('.observation-scanlines') : null; if (!this.scanlinesEl) return; } const speed = (this.lifeSupport && this.lifeSupport.params) ? this.lifeSupport.params.airflowModSpeed : 0.15; const depth = (this.lifeSupport && this.lifeSupport.params) ? this.lifeSupport.params.airflowModDepth : 0.12; const base = 0.14; // matches the .observation-scanlines default CSS opacity const breath = Math.sin(this.time * speed * Math.PI * 2) * depth; this.scanlineBreath = Math.max(0.04, base + breath * base * 2); this.scanlinesEl.style.opacity = this.scanlineBreath.toFixed(3); } // ========================================================================= // Observation Mode Enhancements: 25-Minute Ambient Lighting Cycle // ========================================================================= updateLightingCycle(dt) { this.lightingCycleTime = (this.lightingCycleTime || 0) + dt; const cycleDuration = 1500; // 25 minutes: Deep Space -> Nebula Passage -> Star Approach -> Eclipse const phases = [ { name: 'Deep Space', starBrightness: 0.85, nebulaIntensity: 0.8 }, { name: 'Nebula Passage', starBrightness: 0.7, nebulaIntensity: 1.6 }, { name: 'Star Approach', starBrightness: 1.3, nebulaIntensity: 1.1 }, { name: 'Eclipse', starBrightness: 0.55, nebulaIntensity: 0.9 } ]; const t = (this.lightingCycleTime % cycleDuration) / cycleDuration; const phaseLen = 1 / phases.length; const idx = Math.min(phases.length - 1, Math.floor(t / phaseLen)); const nextIdx = (idx + 1) % phases.length; const localT = (t % phaseLen) / phaseLen; const smoothT = (1 - Math.cos(localT * Math.PI)) / 2; // ease in/out crossfade between phases const a = phases[idx], b = phases[nextIdx]; this.lightingModifiers = { name: smoothT < 0.5 ? a.name : b.name, starBrightness: a.starBrightness + (b.starBrightness - a.starBrightness) * smoothT, nebulaIntensity: a.nebulaIntensity + (b.nebulaIntensity - a.nebulaIntensity) * smoothT }; } getLightingModifiers() { return this.lightingModifiers || { name: 'Deep Space', starBrightness: 1, nebulaIntensity: 1 }; } // ========================================================================= // Observation Mode Enhancements: Shooting Stars // ========================================================================= spawnShootingStar() { const fromTop = Math.random() > 0.5; let x, y; if (this.isStationView()) { // Enter through the top of the station's window rather than off the top of the screen, // where the bulkhead would swallow the whole streak const rect = this.getStationViewportRect(); x = rect.x + Math.random() * rect.w * 0.7; y = rect.y + (fromTop ? 0 : Math.random() * rect.h * 0.4); } else { x = Math.random() * this.width; y = fromTop ? -20 : Math.random() * this.height * 0.4; } const angle = (Math.PI * 0.15) + Math.random() * (Math.PI * 0.2); // downward diagonal streak const speed = 900 + Math.random() * 500; this.shootingStars.push({ x, y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, life: 0, maxLife: 0.7 + Math.random() * 0.4, length: 90 + Math.random() * 90, color: this.starColors[Math.floor(Math.random() * this.starColors.length)] }); } updateShootingStars(dt, now) { if (this.flightMode !== 'warp' && now > this.nextShootingStarTime && this.shootingStars.length < 2) { this.spawnShootingStar(); const activityFactor = (window.observationActivity !== undefined ? window.observationActivity : 0.6); this.nextShootingStarTime = now + (9000 + Math.random() * 16000) / Math.max(0.15, activityFactor); } for (let i = this.shootingStars.length - 1; i >= 0; i--) { const s = this.shootingStars[i]; s.life += dt; s.x += s.vx * dt; s.y += s.vy * dt; if (s.life >= s.maxLife || s.x < -120 || s.x > this.width + 120 || s.y < -120 || s.y > this.height + 120) { this.shootingStars.splice(i, 1); } } } renderShootingStars(ctx) { for (const s of this.shootingStars) { const prog = s.life / s.maxLife; const alpha = Math.sin(Math.min(1, prog) * Math.PI); // fade in then out across its short life const mag = Math.hypot(s.vx, s.vy) || 1; const tailX = s.x - (s.vx / mag) * s.length; const tailY = s.y - (s.vy / mag) * s.length; const grad = ctx.createLinearGradient(s.x, s.y, tailX, tailY); grad.addColorStop(0, hexToRgba(s.color, alpha)); grad.addColorStop(1, 'transparent'); ctx.strokeStyle = grad; ctx.lineWidth = 2.2; ctx.beginPath(); ctx.moveTo(s.x, s.y); ctx.lineTo(tailX, tailY); ctx.stroke(); ctx.fillStyle = `rgba(255,255,255,${alpha})`; ctx.beginPath(); ctx.arc(s.x, s.y, 1.8, 0, Math.PI * 2); ctx.fill(); } } // ========================================================================= // Observation Mode Enhancements: Procedural Constellation Lines // ========================================================================= regenerateConstellation() { const count = 4 + Math.floor(Math.random() * 3); const points = []; for (let i = 0; i < count; i++) { points.push({ x: (Math.random() - 0.5) * 1400, y: (Math.random() - 0.5) * 900, z: 550 + Math.random() * 500 }); } this.constellationPoints = points; this.constellationLines = []; for (let i = 0; i < points.length - 1; i++) { this.constellationLines.push([i, i + 1]); } if (points.length > 3 && Math.random() > 0.5) { this.constellationLines.push([points.length - 1, 0]); // occasionally close the pattern into a loop } } updateConstellations(dt, now) { if (this.flightMode === 'warp' || this.isStationView()) { // Fade out during warp streaks rather than fighting them visually. // Stations use the 360-degree panorama instead and skip this layer entirely. this.constellationAlpha = Math.max(0, (this.constellationAlpha || 0) - dt * 0.6); return; } if (!this.nextConstellationTime || now > this.nextConstellationTime) { this.regenerateConstellation(); this.nextConstellationTime = now + 50000 + Math.random() * 30000; } const speed = 8; // slow independent drift, distinct pacing from the main starfield rush if (this.constellationPoints) { for (const p of this.constellationPoints) { p.z -= speed * dt; } if (this.constellationPoints.some(p => p.z < 80)) { this.regenerateConstellation(); } } const target = (this.constellationPoints && this.constellationPoints.length) ? 0.3 : 0; this.constellationAlpha = (this.constellationAlpha || 0) + (target - (this.constellationAlpha || 0)) * Math.min(1, dt * 0.4); } renderConstellations(ctx, cx, cy) { if (!this.constellationPoints || !this.constellationLines || (this.constellationAlpha || 0) <= 0.005) return; const fov = 420; ctx.save(); ctx.strokeStyle = `rgba(147, 197, 253, ${this.constellationAlpha.toFixed(3)})`; ctx.fillStyle = `rgba(224, 242, 254, ${Math.min(1, this.constellationAlpha * 2.2).toFixed(3)})`; ctx.lineWidth = 1; ctx.beginPath(); for (const [ia, ib] of this.constellationLines) { const a = this.constellationPoints[ia]; const b = this.constellationPoints[ib]; if (!a || !b) continue; const ax = cx + (a.x / a.z) * fov, ay = cy + (a.y / a.z) * fov; const bx = cx + (b.x / b.z) * fov, by = cy + (b.y / b.z) * fov; ctx.moveTo(ax, ay); ctx.lineTo(bx, by); } ctx.stroke(); for (const p of this.constellationPoints) { const px = cx + (p.x / p.z) * fov, py = cy + (p.y / p.z) * fov; ctx.beginPath(); ctx.arc(px, py, 1.6, 0, Math.PI * 2); ctx.fill(); } ctx.restore(); } // ========================================================================= // Observation Mode Enhancements: Cinematic Event Director // ========================================================================= queueCinematicAction(action, params) { if (action === 'flash-status' && params && params.text) { this.cinematicCaption = params.text; } } buildFirstContactSequence() { return { name: 'First Contact', steps: [ { duration: 3.0, run: () => { this.spawnEvent('comet', this.width * 0.5, this.height * 0.3); } }, { duration: 2.5, run: () => { this.queueCinematicAction('flash-status', { text: 'UNKNOWN VESSEL DETECTED' }); this.spawnTraffic(true); } }, { duration: 4.0, run: () => { this.queueCinematicAction('flash-status', { text: 'HAILING FREQUENCIES OPEN' }); } }, { duration: 3.0, run: () => { this.spawnEvent('warp-flash', this.width * 0.5, this.height * 0.5); } } ] }; } buildHullBreachSequence() { return { name: 'Hull Breach', steps: [ { duration: 0.8, run: () => { this.triggerWarpPulse(); this.queueCinematicAction('flash-status', { text: 'HULL STRESS CRITICAL' }); } }, { duration: 1.6, run: () => { if (this.alerts) this.alerts.triggerRedAlert('tng'); this.updateAlertState(); } }, { duration: 3.5, run: () => { this.queueCinematicAction('flash-status', { text: 'DAMAGE CONTROL TEAMS RESPONDING' }); } }, { duration: 3.0, run: () => { if (this.alerts && this.alerts.activeAlert === 'red') { this.alerts.stopAlert(); this.updateAlertState(); } this.queueCinematicAction('flash-status', { text: 'HULL INTEGRITY STABILIZED' }); } } ] }; } buildTemporalAnomalySequence() { return { name: 'Temporal Anomaly', steps: [ { duration: 2.0, run: () => { this.queueCinematicAction('flash-status', { text: 'TEMPORAL FLUX DETECTED' }); } }, { duration: 3.0, run: () => { this.spawnEvent('warp-flash', this.width * Math.random(), this.height * Math.random()); } }, { duration: 3.0, run: () => { this.queueCinematicAction('flash-status', { text: 'VORTEX STABILIZING' }); } } ] }; } buildBioResonanceSequence() { return { name: 'Bio Resonance', steps: [ { duration: 2.5, run: () => { this.queueCinematicAction('flash-status', { text: 'RESONANT PULSE DETECTED' }); } }, { duration: 3.5, run: () => { this.spawnEvent('comet', this.width * 0.6, this.height * 0.4); } }, { duration: 2.5, run: () => { this.queueCinematicAction('flash-status', { text: 'PULSE DISSIPATING' }); } } ] }; } buildFlybySpectacleSequence() { return { name: 'Close Flyby', steps: [ { duration: 0.5, run: () => { this.spawnTraffic(true); } }, { duration: 4.5, run: () => {} } ] }; } getCinematicSequencesFor(universeId) { const sequences = []; if (universeId === 'starfleet' || universeId === 'deepspace' || universeId === 'spacestations') { sequences.push(this.buildFirstContactSequence()); } if (universeId === 'military' || universeId === 'outlaw' || universeId === 'industrial') { sequences.push(this.buildHullBreachSequence()); } if (universeId === 'whoniverse') { sequences.push(this.buildTemporalAnomalySequence()); } if (universeId === 'bioships') { sequences.push(this.buildBioResonanceSequence()); } sequences.push(this.buildFlybySpectacleSequence()); // every universe can get a simple flyby spectacle return sequences; } startCinematicSequence(sequence) { if (!sequence || !sequence.steps || !sequence.steps.length) return; this.cinematicActive = { name: sequence.name, steps: sequence.steps, index: 0, elapsed: 0 }; this.cinematicCaption = null; const first = sequence.steps[0]; if (first && typeof first.run === 'function') first.run(); } updateCinematicDirector(dt, now) { if (this.cinematicActive) { this.cinematicActive.elapsed += dt; const step = this.cinematicActive.steps[this.cinematicActive.index]; if (step && this.cinematicActive.elapsed >= step.duration) { this.cinematicActive.index++; this.cinematicActive.elapsed = 0; const next = this.cinematicActive.steps[this.cinematicActive.index]; if (next) { this.cinematicCaption = null; if (typeof next.run === 'function') next.run(); } else { this.cinematicActive = null; this.cinematicCaption = null; this.nextCinematicTime = now + 90000 + Math.random() * 90000; } } return; } if (!this.nextCinematicTime) { this.nextCinematicTime = now + 45000 + Math.random() * 30000; } if (now > this.nextCinematicTime) { const sequences = this.getCinematicSequencesFor(this.activeUniverse); const chosen = sequences[Math.floor(Math.random() * sequences.length)]; this.startCinematicSequence(chosen); } } // ========================================================================= // Observation Mode Enhancements: HUD Status Ticker // ========================================================================= getTickerMessages(universeId) { const sets = { starfleet: [ 'ALL DECKS REPORTING NOMINAL', 'SUBSPACE ARRAY HOLDING STEADY LOCK', 'STRUCTURAL INTEGRITY FIELD: 100%', 'REPLICATOR SYSTEMS ON STANDBY', 'SENSOR SWEEP: NO ANOMALIES DETECTED' ], whoniverse: [ 'TEMPORAL GRACE PERIOD: ACTIVE', 'CLOISTER BELL: SILENT', 'CHAMELEON CIRCUIT: STUCK (AS USUAL)', 'VORTEX MANIFOLD WITHIN TOLERANCE', 'ARTRON ENERGY LEVELS STABLE' ], industrial: [ 'BULK FREIGHT MANIFEST: ON SCHEDULE', 'GANTRY CRANE 4: OPERATIONAL', 'HULL PLATING STRESS: NOMINAL', 'CARGO BAY PRESSURE HOLDING', 'MAINTENANCE CYCLE: DECK 4 COMPLETE' ], bioships: [ 'BIOMASS RESONANCE: SYNCHRONIZED', 'NEURAL LATTICE: RESPONSIVE', 'MEMBRANE INTEGRITY: HEALTHY', 'SYMBIOTIC LINK STABLE', 'PULSE RHYTHM WITHIN NORMAL RANGE' ], retrofuture: [ 'ATOMIC REACTOR: WITHIN SAFE LIMITS', 'RADAR SWEEP: ALL CLEAR', 'VACUUM TUBE BANK: NOMINAL', 'RETRO-ROCKET FUEL: SUFFICIENT', 'AUTOPILOT: ENGAGED' ], military: [ 'TACTICAL GRID: CLEAR', 'SHIELD HARMONICS NOMINAL', 'WEAPONS SYSTEMS: STANDBY', 'PATROL SECTOR SWEEP COMPLETE', 'THREAT ASSESSMENT: LOW' ], deepspace: [ 'DEEP FIELD SCAN: CONTINUING', 'LONG RANGE SENSORS: NOMINAL', 'STELLAR CARTOGRAPHY UPDATING', 'BACKGROUND RADIATION: BASELINE', 'NAVIGATION LOCK: HOLDING' ], outlaw: [ 'TRANSPONDER: SPOOFED', 'CARGO MANIFEST: REDACTED', 'PATROL CHATTER: MONITORING', 'FUEL RESERVES: RUNNING LEAN', 'NO QUESTIONS, NO PROBLEMS' ], spacestations: [ 'DOCKING RING: CLEAR FOR APPROACH', 'PROMENADE TRAFFIC: NORMAL', 'LIFE SUPPORT: ALL SECTIONS NOMINAL', 'TRANSIT SCHEDULE: ON TIME', 'STATION SPIN: STABLE' ], comedy: [ 'PROBABLY FINE, ACTUALLY', 'TEA SUPPLIES: ADEQUATE', 'PANIC LEVEL: STILL NOT REQUIRED', 'SCENIC ROUTE: ENGAGED', 'MOSTLY HARMLESS' ] }; return sets[universeId] || sets.starfleet; } setTickerText(text) { if (!this.tickerTextEl) { this.tickerTextEl = document.getElementById('observation-ticker-text'); if (!this.tickerTextEl) return; } this.tickerTextEl.textContent = text; // Restart the CSS scroll animation from the left edge whenever the message changes. this.tickerTextEl.style.animation = 'none'; void this.tickerTextEl.offsetWidth; this.tickerTextEl.style.animation = ''; } updateStatusTicker(dt, now) { if (!this.tickerTextEl) { this.tickerTextEl = document.getElementById('observation-ticker-text'); if (!this.tickerTextEl) return; } if (this.cinematicCaption) { if (this.tickerTextEl.textContent !== this.cinematicCaption) { this.setTickerText(this.cinematicCaption); } return; } if (!this.tickerMessages || !this.tickerMessages.length) { this.tickerMessages = this.getTickerMessages(this.activeUniverse); } if (!this.nextTickerTime || now > this.nextTickerTime) { this.tickerIndex = ((this.tickerIndex === undefined ? -1 : this.tickerIndex) + 1) % this.tickerMessages.length; this.setTickerText(this.tickerMessages[this.tickerIndex]); this.nextTickerTime = now + 9000; } } updateViewportFrame() { if (!this.frameEl) return; const presetId = this.selectPreset ? this.selectPreset.value : (window.activePresetId || null); this.frameEl.innerHTML = this.getViewportFrameSvg(this.activeUniverse, presetId); } getViewportFrameSvg(universeId, presetId) { if (window.ObservationBezels && typeof window.ObservationBezels.getViewportFrameSvg === "function") { return window.ObservationBezels.getViewportFrameSvg(universeId, presetId, this); } return ""; } } window.ObservationEngine = ObservationEngine;