Apply the ship/vessel and terminology rename mappings throughout the live app (index.html, css/style.css, js/*.js) and reference docs, per Ship_IP_Changes.md and Theme_Terminology_IP_Changes.md, including: - Ship/theme names and generic terminology (Starfleet, LCARS, Warp Core, TARDIS, etc.) in both code strings and visible UI text, while leaving internal code identifiers (theme keys, CSS classes, preset IDs) untouched. - Made "Species 8675309" canonical and fixed the dotted T.A.R.D.I.X. acronym on the Whataverse theme. - Replaced the per-preset era/pulseShape franchise tag in the preset list with the existing safe universe category name, since those codes are also used functionally by the audio and observation engines and weren't covered by either mapping document. Also rewrite README.md to lead with end-user ambience usage (how to run it, universe/vessel overview, mixer channels, sleep timer, observation mode, hotkeys) with the developer/build notes moved into a collapsed section. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Jt872nc9Fi2WMaJhAKGyDq
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 4D 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: LCARD 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 Starflight 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') {
|
|
// Starflight 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') {
|
|
// Whataverse TARDIX 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 LCARD 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%',
|
|
'FABRICATOR SYSTEMS ON STANDBY',
|
|
'SENSOR SWEEP: NO ANOMALIES DETECTED'
|
|
],
|
|
whoniverse: [
|
|
'TEMPORAL GRACE PERIOD: ACTIVE',
|
|
'CLOISTER CHIME: 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;
|
|
|