2784 lines
104 KiB
JavaScript
2784 lines
104 KiB
JavaScript
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;
|
|
|