Files
SciFi-XZBT/js/visualizer.js
T

674 lines
25 KiB
JavaScript

class StarshipVisualizer {
constructor(audioManager, warpSynth) {
this.am = audioManager;
this.warpSynth = warpSynth;
this.spectrumCanvas = null;
this.spectrumCtx = null;
this.warpCoreCanvas = null;
this.warpCoreCtx = null;
this.animationFrameId = null;
this.pulseEnergy = 0.2;
this.warpParticles = [];
this.mode = 'warp-core'; // 'warp-core' or 'time-rotor'
this.rotorPhase = 0;
this.coreTime = 0;
this.coreLoad = 0.6;
this.lastFrameTime = null;
// Hook into warp core pulse callback
if (this.warpSynth) {
this.warpSynth.onPulse = (phase, duration) => {
this.pulseEnergy = 1.0;
this.spawnWarpPulses();
};
}
}
setMode(mode) {
this.mode = mode || 'warp-core';
}
init(spectrumCanvasId, warpCoreCanvasId) {
this.spectrumCanvas = document.getElementById(spectrumCanvasId);
if (this.spectrumCanvas) {
this.spectrumCtx = this.spectrumCanvas.getContext('2d');
}
this.warpCoreCanvas = document.getElementById(warpCoreCanvasId);
if (this.warpCoreCanvas) {
this.warpCoreCtx = this.warpCoreCanvas.getContext('2d');
this.initWarpParticles();
}
window.addEventListener('resize', () => this.resizeCanvases());
this.resizeCanvases();
this.startRenderLoop();
}
resizeCanvases() {
if (this.spectrumCanvas) {
const rect = this.spectrumCanvas.parentElement.getBoundingClientRect();
this.spectrumCanvas.width = rect.width * window.devicePixelRatio;
this.spectrumCanvas.height = (rect.height || 160) * window.devicePixelRatio;
this.spectrumCtx.scale(window.devicePixelRatio, window.devicePixelRatio);
}
if (this.warpCoreCanvas) {
const rect = this.warpCoreCanvas.parentElement.getBoundingClientRect();
this.warpCoreCanvas.width = rect.width * window.devicePixelRatio;
this.warpCoreCanvas.height = (rect.height || 260) * window.devicePixelRatio;
this.warpCoreCtx.scale(window.devicePixelRatio, window.devicePixelRatio);
}
}
initWarpParticles() {
this.warpParticles = [];
for (let i = 0; i < 36; i++) {
this.warpParticles.push({
y: Math.random(),
speed: (Math.random() * 0.008 + 0.004) * (Math.random() > 0.5 ? 1 : -1),
size: Math.random() * 4 + 2,
opacity: Math.random() * 0.7 + 0.3
});
}
}
spawnWarpPulses() {
// The new machines render their own flow; only legacy particle modes need a pool.
if (this.mode !== 'warp-core' && this.mode !== 'time-rotor') return;
for (let i = 0; i < 6; i++) {
this.warpParticles.push({
y: 0.5, // Center matter/antimatter reaction plane
speed: (Math.random() * 0.018 + 0.01) * (i % 2 === 0 ? 1 : -1),
size: Math.random() * 6 + 3,
opacity: 1.0
});
}
}
startRenderLoop() {
this.lastFrameTime = null;
const render = (now) => {
const elapsed = this.lastFrameTime === null ? 0 : Math.min((now - this.lastFrameTime) / 1000, 0.05);
this.lastFrameTime = now;
const volume = this.warpSynth && !this.warpSynth.isMuted ? this.warpSynth.params.volume : 0;
const targetLoad = Math.max(0, Math.min(1, volume * (0.65 + this.pulseEnergy * 0.35)));
this.coreLoad += (targetLoad - this.coreLoad) * (1 - Math.exp(-elapsed * 6));
this.coreTime += elapsed * (this.mode === 'tactical-flywheel' ? 0.6 + this.coreLoad * 0.8 : 1);
this.renderSpectrum();
if (window.CoreAnimations && CoreAnimations.has(this.mode)) {
if (this.warpCoreCanvas && this.warpCoreCtx) {
CoreAnimations.render(this.warpCoreCtx, this.mode,
this.warpCoreCanvas.width / window.devicePixelRatio,
this.warpCoreCanvas.height / window.devicePixelRatio,
this.coreTime, this.coreLoad);
}
} else {
switch (this.mode) {
case 'time-rotor':
this.renderTimeRotor();
break;
case 'industrial-reactor':
this.renderIndustrialReactor();
break;
case 'bio-heart':
this.renderBioHeart();
break;
case 'retro-oscilloscope':
this.renderRetroOscilloscope();
break;
case 'singularity-core':
this.renderSingularityCore();
break;
case 'warp-core':
default:
this.renderWarpCore();
break;
}
}
// Decay pulse energy smoothly
this.pulseEnergy = Math.max(0.15, this.pulseEnergy * Math.pow(0.94, elapsed * 60));
this.animationFrameId = requestAnimationFrame(render);
};
if (this.animationFrameId) cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = requestAnimationFrame(render);
}
renderSpectrum() {
if (!this.spectrumCanvas || !this.spectrumCtx || !this.am.analyser) return;
const ctx = this.spectrumCtx;
const w = this.spectrumCanvas.width / window.devicePixelRatio;
const h = this.spectrumCanvas.height / window.devicePixelRatio;
const bufferLength = this.am.analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
this.am.analyser.getByteFrequencyData(dataArray);
ctx.clearRect(0, 0, w, h);
// Dynamic grid color per visualizer mode
let gridCol = 'rgba(255, 153, 0, 0.12)';
if (this.mode === 'time-rotor' || this.mode === 'singularity-core') gridCol = 'rgba(0, 229, 255, 0.12)';
else if (this.mode === 'bio-heart') gridCol = 'rgba(16, 185, 129, 0.12)';
else if (this.mode === 'retro-oscilloscope') gridCol = 'rgba(34, 197, 94, 0.15)';
else if (this.mode === 'industrial-reactor' || this.mode === 'compression-furnace' || this.mode === 'station-hub') gridCol = 'rgba(245, 158, 11, 0.15)';
ctx.strokeStyle = gridCol;
ctx.lineWidth = 1;
for (let y = 20; y < h; y += 30) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.stroke();
}
// Draw segmented frequency bars
const numBars = 32;
const barWidth = (w / numBars) - 3;
for (let i = 0; i < numBars; i++) {
const binIdx = Math.floor(Math.pow(i / numBars, 1.8) * (bufferLength * 0.6));
const val = dataArray[binIdx] || 0;
const barHeight = Math.max(4, (val / 255) * (h - 20));
const x = i * (barWidth + 3);
const y = h - barHeight;
let col;
if (this.mode === 'time-rotor') {
if (i < 8) col = '#00e5ff';
else if (i < 20) col = '#38bdf8';
else if (i < 28) col = '#d4af37';
else col = '#ffffff';
} else if (this.mode === 'bio-heart') {
if (i < 8) col = '#10b981';
else if (i < 20) col = '#34d399';
else if (i < 28) col = '#a855f7';
else col = '#c084fc';
} else if (this.mode === 'retro-oscilloscope') {
col = i < 28 ? '#22c55e' : '#86efac';
} else if (this.mode === 'industrial-reactor' || this.mode === 'compression-furnace' || this.mode === 'station-hub') {
if (i < 8) col = '#d97706';
else if (i < 20) col = '#f59e0b';
else if (i < 28) col = '#fbbf24';
else col = '#fef08a';
} else if (this.mode === 'singularity-core') {
if (i < 8) col = '#4f46e5';
else if (i < 20) col = '#6366f1';
else if (i < 28) col = '#38bdf8';
else col = '#ffffff';
} else {
// Starfleet / Classic LCARS
if (i < 8) col = '#ff6600';
else if (i < 20) col = '#ff9933';
else if (i < 28) col = '#cc99cc';
else col = '#99ccff';
}
ctx.fillStyle = col;
ctx.shadowColor = col;
ctx.shadowBlur = val > 120 ? 8 : 0;
ctx.fillRect(x, y, barWidth, barHeight);
ctx.fillStyle = '#ffffff';
ctx.fillRect(x, y - 2, barWidth, 2);
}
ctx.shadowBlur = 0;
}
renderWarpCore() {
if (!this.warpCoreCanvas || !this.warpCoreCtx) return;
const ctx = this.warpCoreCtx;
const w = this.warpCoreCanvas.width / window.devicePixelRatio;
const h = this.warpCoreCanvas.height / window.devicePixelRatio;
ctx.clearRect(0, 0, w, h);
const centerX = w / 2;
const chamberWidth = Math.min(70, w * 0.4);
// 1. Draw outer intermix chamber housing
ctx.fillStyle = '#111625';
ctx.fillRect(centerX - chamberWidth / 2 - 8, 0, chamberWidth + 16, h);
// Chamber glass gradient
const glassGrad = ctx.createLinearGradient(centerX - chamberWidth / 2, 0, centerX + chamberWidth / 2, 0);
glassGrad.addColorStop(0, 'rgba(0, 50, 100, 0.4)');
glassGrad.addColorStop(0.5, 'rgba(0, 180, 255, 0.15)');
glassGrad.addColorStop(1, 'rgba(0, 50, 100, 0.4)');
ctx.fillStyle = glassGrad;
ctx.fillRect(centerX - chamberWidth / 2, 0, chamberWidth, h);
// 2. Matter / Antimatter injectors (Top and Bottom)
ctx.fillStyle = '#ff9900';
ctx.fillRect(centerX - chamberWidth / 2 - 4, 0, chamberWidth + 8, 12);
ctx.fillRect(centerX - chamberWidth / 2 - 4, h - 12, chamberWidth + 8, 12);
// 3. Central Reaction Intermix Chamber (Center glowing disc)
const centerY = h / 2;
const glowRadius = 24 + this.pulseEnergy * 28;
const coreGlow = ctx.createRadialGradient(centerX, centerY, 2, centerX, centerY, glowRadius);
coreGlow.addColorStop(0, '#ffffff');
coreGlow.addColorStop(0.3, `rgba(0, 210, 255, ${0.7 + this.pulseEnergy * 0.3})`);
coreGlow.addColorStop(0.7, `rgba(0, 100, 255, ${0.4 + this.pulseEnergy * 0.4})`);
coreGlow.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = coreGlow;
ctx.beginPath();
ctx.arc(centerX, centerY, glowRadius, 0, Math.PI * 2);
ctx.fill();
// 4. Segmented Magnetic Constriction Coils (horizontal pulsing rings)
const numCoils = 14;
for (let i = 0; i < numCoils; i++) {
const coilY = (i / (numCoils - 1)) * (h - 30) + 15;
const distFromCenter = Math.abs(coilY - centerY) / (h / 2);
const coilIntensity = Math.max(0.2, (1.0 - distFromCenter * 0.6) * (0.4 + this.pulseEnergy * 0.6));
ctx.fillStyle = `rgba(0, 230, 255, ${coilIntensity})`;
ctx.shadowColor = '#00e6ff';
ctx.shadowBlur = this.pulseEnergy > 0.6 ? 12 : 3;
// Draw coil bar
ctx.fillRect(centerX - chamberWidth / 2 + 4, coilY - 2, chamberWidth - 8, 4);
}
ctx.shadowBlur = 0;
// 5. Plasma stream particles
for (let i = this.warpParticles.length - 1; i >= 0; i--) {
const p = this.warpParticles[i];
p.y += p.speed;
if (p.y < 0 || p.y > 1) {
if (this.warpParticles.length > 36) {
this.warpParticles.splice(i, 1);
continue;
} else {
p.y = p.speed > 0 ? 0 : 1;
}
}
const py = p.y * h;
const px = centerX + (Math.sin(p.y * 12) * (chamberWidth * 0.25));
ctx.fillStyle = `rgba(180, 240, 255, ${p.opacity * (0.4 + this.pulseEnergy * 0.6)})`;
ctx.beginPath();
ctx.arc(px, py, p.size * (0.8 + this.pulseEnergy * 0.4), 0, Math.PI * 2);
ctx.fill();
}
}
/**
* Renders the canonical TARDIS Central Time Rotor
* A glass cylinder containing an interior mechanical column physically rising and falling
* in sync with the pulse cycle, illuminated with glowing Gallifreyan cyan/emerald light.
*/
renderTimeRotor() {
if (!this.warpCoreCanvas || !this.warpCoreCtx) return;
const ctx = this.warpCoreCtx;
const w = this.warpCoreCanvas.width / window.devicePixelRatio;
const h = this.warpCoreCanvas.height / window.devicePixelRatio;
ctx.clearRect(0, 0, w, h);
const centerX = w / 2;
const columnWidth = Math.min(84, w * 0.45);
// 1. TARDIS Console Plinth & Ceiling Collar (Victorian Brass / Gallifreyan Bronze)
const collarGrad = ctx.createLinearGradient(centerX - columnWidth / 2, 0, centerX + columnWidth / 2, 0);
collarGrad.addColorStop(0, '#593e10');
collarGrad.addColorStop(0.3, '#d4af37');
collarGrad.addColorStop(0.7, '#fef08a');
collarGrad.addColorStop(1, '#593e10');
ctx.fillStyle = collarGrad;
ctx.fillRect(centerX - columnWidth / 2 - 8, 0, columnWidth + 16, 14);
ctx.fillRect(centerX - columnWidth / 2 - 8, h - 14, columnWidth + 16, 14);
// 2. Outer Glass Column Tube
const glassGrad = ctx.createLinearGradient(centerX - columnWidth / 2, 0, centerX + columnWidth / 2, 0);
glassGrad.addColorStop(0, 'rgba(0, 40, 80, 0.45)');
glassGrad.addColorStop(0.15, 'rgba(0, 229, 255, 0.25)');
glassGrad.addColorStop(0.5, 'rgba(255, 255, 255, 0.12)');
glassGrad.addColorStop(0.85, 'rgba(0, 229, 255, 0.25)');
glassGrad.addColorStop(1, 'rgba(0, 40, 80, 0.45)');
ctx.fillStyle = glassGrad;
ctx.fillRect(centerX - columnWidth / 2, 14, columnWidth, h - 28);
// Glass edge highlights
ctx.strokeStyle = 'rgba(0, 229, 255, 0.6)';
ctx.lineWidth = 1.5;
ctx.strokeRect(centerX - columnWidth / 2, 14, columnWidth, h - 28);
// 3. Central Bobbing Time Rotor Column
// Physical oscillation: rises and falls smoothly
this.rotorPhase += 0.038;
const maxTravel = (h - 90) * 0.35;
const rotorOffset = Math.sin(this.rotorPhase) * maxTravel;
const rotorCenterY = (h / 2) + rotorOffset;
const rotorHeight = (h - 28) * 0.48;
// Moving Inner Rotor Rod & Glass Tubes
const innerWidth = columnWidth * 0.58;
// Glowing core glow
const coreGlow = ctx.createRadialGradient(centerX, rotorCenterY, 4, centerX, rotorCenterY, 36 + this.pulseEnergy * 30);
coreGlow.addColorStop(0, '#ffffff');
coreGlow.addColorStop(0.4, `rgba(0, 229, 255, ${0.7 + this.pulseEnergy * 0.3})`);
coreGlow.addColorStop(0.8, `rgba(0, 100, 200, ${0.3 + this.pulseEnergy * 0.4})`);
coreGlow.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = coreGlow;
ctx.beginPath();
ctx.arc(centerX, rotorCenterY, 36 + this.pulseEnergy * 30, 0, Math.PI * 2);
ctx.fill();
// Inner mechanical tubes
ctx.fillStyle = '#00e5ff';
ctx.shadowColor = '#00e5ff';
ctx.shadowBlur = 10 + this.pulseEnergy * 10;
ctx.fillRect(centerX - 4, rotorCenterY - rotorHeight / 2, 8, rotorHeight);
// Left and right secondary crystal tubes
ctx.fillStyle = 'rgba(180, 240, 255, 0.85)';
ctx.fillRect(centerX - innerWidth / 2 + 2, rotorCenterY - rotorHeight / 2 + 10, 5, rotorHeight - 20);
ctx.fillRect(centerX + innerWidth / 2 - 7, rotorCenterY - rotorHeight / 2 + 10, 5, rotorHeight - 20);
// Gallifreyan Circular Rotor Rings
for (let r = 0; r < 4; r++) {
const ringY = rotorCenterY - rotorHeight / 2 + (r * (rotorHeight / 3));
ctx.strokeStyle = '#d4af37';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.ellipse(centerX, ringY, innerWidth / 2 + 2, 5, 0, 0, Math.PI * 2);
ctx.stroke();
}
ctx.shadowBlur = 0;
// 4. Sparkling Vortex Time Energy Particles
for (let i = this.warpParticles.length - 1; i >= 0; i--) {
const p = this.warpParticles[i];
p.y += p.speed * 0.8;
if (p.y < 0.05 || p.y > 0.95) {
if (this.warpParticles.length > 36) {
this.warpParticles.splice(i, 1);
continue;
} else {
p.y = p.speed > 0 ? 0.05 : 0.95;
}
}
const py = p.y * h;
const px = centerX + (Math.sin(p.y * 16 + this.rotorPhase) * (columnWidth * 0.32));
ctx.fillStyle = `rgba(0, 229, 255, ${p.opacity * (0.5 + this.pulseEnergy * 0.5)})`;
ctx.shadowColor = '#00e5ff';
ctx.shadowBlur = 6;
ctx.beginPath();
ctx.arc(px, py, p.size * (0.7 + this.pulseEnergy * 0.5), 0, Math.PI * 2);
ctx.fill();
}
ctx.shadowBlur = 0;
}
/**
* Industrial Fusion Reactor (Nostromo, Serenity, Rocinante)
* Heavy containment walls, incandescent glowing amber plasma core, heat radiating coils
*/
renderIndustrialReactor() {
if (!this.warpCoreCanvas || !this.warpCoreCtx) return;
const ctx = this.warpCoreCtx;
const w = this.warpCoreCanvas.width / window.devicePixelRatio;
const h = this.warpCoreCanvas.height / window.devicePixelRatio;
ctx.clearRect(0, 0, w, h);
const centerX = w / 2;
const centerY = h / 2;
const chamberW = Math.min(80, w * 0.42);
// Cast iron frame
ctx.fillStyle = '#1c150c';
ctx.fillRect(centerX - chamberW / 2 - 10, 0, chamberW + 20, h);
// Hazard warning bands at top and bottom
for (let x = centerX - chamberW / 2 - 10; x < centerX + chamberW / 2 + 10; x += 12) {
ctx.fillStyle = (x % 24 === 0) ? '#d97706' : '#1a1106';
ctx.fillRect(x, 0, 12, 10);
ctx.fillRect(x, h - 10, 12, 10);
}
// Incandescent molten amber core
const radius = 22 + this.pulseEnergy * 32;
const glow = ctx.createRadialGradient(centerX, centerY, 2, centerX, centerY, radius);
glow.addColorStop(0, '#ffffff');
glow.addColorStop(0.2, '#fef08a');
glow.addColorStop(0.5, `rgba(245, 158, 11, ${0.7 + this.pulseEnergy * 0.3})`);
glow.addColorStop(1, 'rgba(180, 83, 9, 0)');
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
ctx.fill();
// Heat induction coil clamps
for (let i = 0; i < 9; i++) {
const cy = 20 + i * ((h - 40) / 8);
ctx.fillStyle = (i % 2 === 0) ? '#f59e0b' : '#78350f';
ctx.shadowColor = '#f59e0b';
ctx.shadowBlur = this.pulseEnergy > 0.6 ? 10 : 2;
ctx.fillRect(centerX - chamberW / 2, cy - 3, chamberW, 6);
}
ctx.shadowBlur = 0;
}
/**
* Living Leviathan Bio-Heart (Moya, Lexx, Species 8472)
* Pulsing vascular heart sac with bioluminescent emerald/violet energy and neural veins
*/
renderBioHeart() {
if (!this.warpCoreCanvas || !this.warpCoreCtx) return;
const ctx = this.warpCoreCtx;
const w = this.warpCoreCanvas.width / window.devicePixelRatio;
const h = this.warpCoreCanvas.height / window.devicePixelRatio;
ctx.clearRect(0, 0, w, h);
const centerX = w / 2;
const centerY = h / 2;
// Organic vascular expansion
const bioScale = 1.0 + Math.sin(this.rotorPhase * 1.2) * 0.12 + this.pulseEnergy * 0.18;
const baseR = 35 * bioScale;
// Outer bioluminescent aura
const aura = ctx.createRadialGradient(centerX, centerY, 4, centerX, centerY, baseR * 1.8);
aura.addColorStop(0, '#a7f3d0');
aura.addColorStop(0.3, `rgba(16, 185, 129, ${0.7 + this.pulseEnergy * 0.3})`);
aura.addColorStop(0.7, `rgba(139, 92, 246, ${0.3 + this.pulseEnergy * 0.3})`);
aura.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = aura;
ctx.beginPath();
ctx.arc(centerX, centerY, baseR * 1.8, 0, Math.PI * 2);
ctx.fill();
// Pulsing neural veins
ctx.strokeStyle = '#34d399';
ctx.lineWidth = 2.5;
ctx.shadowColor = '#10b981';
ctx.shadowBlur = 8;
for (let v = 0; v < 6; v++) {
const angle = (v / 6) * Math.PI * 2 + this.rotorPhase * 0.2;
ctx.beginPath();
ctx.moveTo(centerX, centerY);
const cpX = centerX + Math.cos(angle + 0.5) * (baseR * 0.8);
const cpY = centerY + Math.sin(angle + 0.5) * (baseR * 0.8);
const endX = centerX + Math.cos(angle) * (baseR * 1.5);
const endY = centerY + Math.sin(angle) * (baseR * 1.5);
ctx.quadraticCurveTo(cpX, cpY, endX, endY);
ctx.stroke();
}
ctx.shadowBlur = 0;
}
/**
* Retro Oscilloscope & Analog Astrogator (Jupiter 2, Discovery One)
* 1950s/60s green phosphor CRT screen with glowing Lissajous audio wave rings
*/
renderRetroOscilloscope() {
if (!this.warpCoreCanvas || !this.warpCoreCtx) return;
const ctx = this.warpCoreCtx;
const w = this.warpCoreCanvas.width / window.devicePixelRatio;
const h = this.warpCoreCanvas.height / window.devicePixelRatio;
ctx.clearRect(0, 0, w, h);
const centerX = w / 2;
const centerY = h / 2;
const crtRadius = Math.min(w, h) * 0.42;
// Circular CRT bezel
ctx.fillStyle = '#052e16';
ctx.beginPath();
ctx.arc(centerX, centerY, crtRadius, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#22c55e';
ctx.lineWidth = 2;
ctx.stroke();
// Crosshairs
ctx.strokeStyle = 'rgba(34, 197, 94, 0.25)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(centerX - crtRadius, centerY);
ctx.lineTo(centerX + crtRadius, centerY);
ctx.moveTo(centerX, centerY - crtRadius);
ctx.lineTo(centerX, centerY + crtRadius);
ctx.stroke();
// Draw a neutral trace before audio initialization, replacing the previous theme.
const analyser = this.am.analyser;
const bufferLength = analyser ? analyser.fftSize : 128;
const dataArray = new Uint8Array(bufferLength);
if (analyser) analyser.getByteTimeDomainData(dataArray);
else dataArray.fill(128);
ctx.strokeStyle = '#86efac';
ctx.shadowColor = '#22c55e';
ctx.shadowBlur = 8;
ctx.lineWidth = 2;
ctx.beginPath();
const points = 48;
for (let i = 0; i < points; i++) {
const idx = Math.floor((i / points) * (bufferLength / 2));
const v = (dataArray[idx] / 128.0) - 1.0;
const angle = (i / points) * Math.PI * 2 + this.coreTime;
const r = (crtRadius * 0.65) + (v * 28 * (0.8 + this.pulseEnergy));
const x = centerX + Math.cos(angle) * r;
const y = centerY + Math.sin(angle) * r;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.closePath();
ctx.stroke();
// A sweep marker makes rotation legible even when the audio trace is silent.
const sweepRadius = (crtRadius * 0.65) + ((dataArray[0] / 128.0) - 1.0) * 28 * (0.8 + this.pulseEnergy);
ctx.fillStyle = '#d1fae5';
ctx.beginPath();
ctx.arc(centerX + Math.cos(this.coreTime) * sweepRadius,
centerY + Math.sin(this.coreTime) * sweepRadius, 2.5, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
}
/**
* Gravity Singularity Core (Event Horizon, Deep Space)
* Black hole event horizon with warping gravitational accretion disk
*/
renderSingularityCore() {
if (!this.warpCoreCanvas || !this.warpCoreCtx) return;
const ctx = this.warpCoreCtx;
const w = this.warpCoreCanvas.width / window.devicePixelRatio;
const h = this.warpCoreCanvas.height / window.devicePixelRatio;
ctx.clearRect(0, 0, w, h);
const centerX = w / 2;
const centerY = h / 2;
const diskR = Math.min(w, h) * 0.44;
// Glowing gravitational accretion disk
ctx.save();
ctx.translate(centerX, centerY);
ctx.rotate(this.rotorPhase * 0.6);
const grad = ctx.createRadialGradient(0, 0, 12, 0, 0, diskR);
grad.addColorStop(0, '#000000');
grad.addColorStop(0.35, '#000000');
grad.addColorStop(0.45, `rgba(99, 102, 241, ${0.8 + this.pulseEnergy * 0.2})`);
grad.addColorStop(0.7, `rgba(56, 189, 248, ${0.4 + this.pulseEnergy * 0.3})`);
grad.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.ellipse(0, 0, diskR, diskR * 0.35, 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
// Pure black event horizon sphere at center
ctx.fillStyle = '#000000';
ctx.strokeStyle = 'rgba(99, 102, 241, 0.8)';
ctx.lineWidth = 2;
ctx.shadowColor = '#6366f1';
ctx.shadowBlur = 12 + this.pulseEnergy * 10;
ctx.beginPath();
ctx.arc(centerX, centerY, 18, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.shadowBlur = 0;
}
}
window.StarshipVisualizer = StarshipVisualizer;
// Helper: Hex color to RGBA
function hexToRgba(hex, alpha = 1) {
if (!hex || hex.charAt(0) !== '#') return `rgba(56, 189, 248, ${alpha})`;
let c = hex.substring(1);
if (c.length === 3) c = c.split('').map(x => x + x).join('');
const num = parseInt(c, 16);
return `rgba(${(num >> 16) & 255}, ${(num >> 8) & 255}, ${num & 255}, ${alpha})`;
}
/**
* ============================================================================
* CINEMATIC OBSERVATION LOUNGE ENGINE (v9g)
* ============================================================================
* Features:
* - 60fps DPI-Aware Deep Celestial Canvas (Parallax 3D Starfield & Warp Tunnel)
* - Relativistic Warp Flight vs. Orbital Cruise Impulse Modes
* - Procedural Celestial Bodies (Class-M Planet with Atmospheric Glow, Time Vortex, Gas Giants)
* - Living Traffic & Encounters (Shuttles, Cruisers, Decloaking Klingon BOP, TARDIS, Freighters)
* - Viewport Window Framing Architecture per Universe (Starfleet, Industrial, Station, Whoniverse, Military)
* - Emergency Alert Synchronization (Red/Yellow Alert Klaxon Strobes & Shield Grids)
* - Subspace Audio Harmonics Waveform Sill
* - Auto-Hiding Interactive Glass Control Dock
*/
// =========================================================================
// OBSERVATION CANVAS MANIFEST (v3co)
// =========================================================================
// Each universe declares exactly which canvas layers it draws. Default-deny:
// anything not listed is OFF. A universe missing from this table gets no canvas at all.
// This table is the contract that keeps each theme's OBSERVATION its own experience --
// do not add a layer here to "fill space"; give the theme its own bespoke content instead.
//
// `starfield` entries may carry a per-universe profile so that two universes drawing stars
// are still drawing THEIR OWN stars (density, palette, scale), not one shared layer.