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
674 lines
25 KiB
JavaScript
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 worp 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 {
|
|
// Starflight / Classic LCARD
|
|
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 TARDIX 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. TARDIX 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 Leviathon Bio-Heart (Moya, Lexx, Species 8675309)
|
|
* 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, TARDIX, Freighters)
|
|
* - Viewport Window Framing Architecture per Universe (Starflight, Industrial, Station, Whataverse, 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.
|