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
4240 lines
183 KiB
JavaScript
4240 lines
183 KiB
JavaScript
/**
|
|
* Main Application Controller - 10 Sci-Fi Universes Architecture
|
|
* Coordinates Starflight, Whataverse, Industrial, Bioships, Retro Future, Military,
|
|
* Deep Space, Outlaw, Space Stations, and Comedy universes.
|
|
*/
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// 1. Initialize Audio Subsystems
|
|
const audioManager = new AudioManager();
|
|
const hullDrone = new HullDroneSynth(audioManager);
|
|
const warpCore = new WarpCoreSynth(audioManager);
|
|
const lifeSupport = new LifeSupportSynth(audioManager);
|
|
const telemetry = new TelemetrySynth(audioManager);
|
|
const alerts = new AlertSynth(audioManager);
|
|
const whoniverseAudio = new WhataverseAudioSynth(audioManager);
|
|
const expandedAudio = new ExpandedSciFiAudioSynth(audioManager);
|
|
const engineTransitions = new EngineTransitionSynth(audioManager);
|
|
window.expandedAudio = expandedAudio;
|
|
window.whoniverseAudio = whoniverseAudio;
|
|
window.engineTransitions = engineTransitions;
|
|
const visualizer = new StarshipVisualizer(audioManager, warpCore);
|
|
const observationEngine = new ObservationEngine(audioManager, warpCore, alerts, hullDrone, lifeSupport);
|
|
window.observationEngine = observationEngine;
|
|
|
|
let isPlaying = false;
|
|
let activeUniverseId = 'starfleet';
|
|
let activePresetId = 'tng-bridge';
|
|
window.activeUniverseId = activeUniverseId;
|
|
window.activePresetId = activePresetId;
|
|
|
|
// 2. DOM Elements
|
|
const headerUniverseBtn = document.getElementById('header-universe-btn');
|
|
const headerUniverseLabel = document.getElementById('header-universe-label');
|
|
const universeDropdown = document.getElementById('universe-dropdown');
|
|
const headerShipName = document.getElementById('header-ship-name');
|
|
const headerMatrixLabel = document.getElementById('header-matrix-label');
|
|
|
|
const sidebarTitle = document.getElementById('sidebar-title');
|
|
const presetContainer = document.getElementById('preset-container');
|
|
const currentPresetTitle = document.getElementById('current-preset-title');
|
|
const currentPresetDesc = document.getElementById('current-preset-desc');
|
|
const statAudioStatus = document.getElementById('stat-audio-status');
|
|
const statCoreFreq = document.getElementById('stat-core-freq');
|
|
|
|
const btnPlay = document.getElementById('btn-master-play');
|
|
const playIcon = document.getElementById('play-icon');
|
|
const playText = document.getElementById('play-text');
|
|
|
|
// Master Volume Console Elements
|
|
const sliderMasterVol = document.getElementById('slider-master-vol');
|
|
const valMasterVol = document.getElementById('val-master-vol');
|
|
const valMasterDb = document.getElementById('val-master-db');
|
|
const btnMasterMute = document.getElementById('btn-master-mute');
|
|
const btnHeaderMute = document.getElementById('btn-header-mute');
|
|
const headerVolVal = document.getElementById('header-vol-val');
|
|
const btnVolDown = document.getElementById('btn-vol-down');
|
|
const btnVolUp = document.getElementById('btn-vol-up');
|
|
const volMeterLeds = document.getElementById('vol-meter-leds');
|
|
const volStepButtons = document.querySelectorAll('.btn-vol-step');
|
|
|
|
// Channel Sliders
|
|
const sliderHullVol = document.getElementById('slider-hull-vol');
|
|
const valHullVol = document.getElementById('val-hull-vol');
|
|
const sliderHullFreq = document.getElementById('slider-hull-freq');
|
|
const valHullFreq = document.getElementById('val-hull-freq');
|
|
const sliderHullCutoff = document.getElementById('slider-hull-cutoff');
|
|
const valHullCutoff = document.getElementById('val-hull-cutoff');
|
|
|
|
const sliderWarpVol = document.getElementById('slider-warp-vol');
|
|
const valWarpVol = document.getElementById('val-warp-vol');
|
|
const sliderWarpBpm = document.getElementById('slider-warp-bpm');
|
|
const valWarpBpm = document.getElementById('val-warp-bpm');
|
|
const sliderWarpCarrier = document.getElementById('slider-warp-carrier');
|
|
const valWarpCarrier = document.getElementById('val-warp-carrier');
|
|
|
|
const sliderAirVol = document.getElementById('slider-air-vol');
|
|
const valAirVol = document.getElementById('val-air-vol');
|
|
const sliderAirCutoff = document.getElementById('slider-air-cutoff');
|
|
const valAirCutoff = document.getElementById('val-air-cutoff');
|
|
|
|
const sliderTelemetryVol = document.getElementById('slider-telemetry-vol');
|
|
const valTelemetryVol = document.getElementById('val-telemetry-vol');
|
|
const sliderTelemetryDensity = document.getElementById('slider-telemetry-density');
|
|
const valTelemetryDensity = document.getElementById('val-telemetry-density');
|
|
|
|
// Visualizer & Dynamic Containers
|
|
const coreVisualizerTitle = document.getElementById('core-visualizer-title');
|
|
const coreVisualizerLabel = document.getElementById('core-visualizer-label');
|
|
const soundboardTitle = document.getElementById('soundboard-title');
|
|
const universeEventsContainer = document.getElementById('universe-events-container');
|
|
const soundboardContainer = document.getElementById('soundboard-container');
|
|
|
|
// Sleep Timer
|
|
const timerDisplay = document.getElementById('timer-display');
|
|
const timerButtons = document.querySelectorAll('.btn-timer');
|
|
|
|
// 3. Universe Management & Switching
|
|
function initUniverseDropdown() {
|
|
universeDropdown.innerHTML = '';
|
|
Object.values(UniverseRegistry).forEach(u => {
|
|
const btn = document.createElement('button');
|
|
btn.className = `universe-item ${u.id === activeUniverseId ? 'active' : ''}`;
|
|
btn.innerHTML = `<span class="u-icon">${u.icon}</span> <span>${u.name}</span>`;
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
setUniverse(u.id);
|
|
});
|
|
universeDropdown.appendChild(btn);
|
|
});
|
|
|
|
headerUniverseBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
universeDropdown.classList.toggle('show');
|
|
});
|
|
|
|
document.addEventListener('click', () => {
|
|
universeDropdown.classList.remove('show');
|
|
});
|
|
}
|
|
|
|
function setUniverse(universeId) {
|
|
const universe = UniverseRegistry[universeId];
|
|
if (!universe) return;
|
|
|
|
activeUniverseId = universeId;
|
|
universeDropdown.classList.remove('show');
|
|
|
|
// Update Dropdown UI active class
|
|
document.querySelectorAll('.universe-item').forEach(el => {
|
|
el.classList.toggle('active', el.textContent.includes(universe.name));
|
|
});
|
|
|
|
// Update Header and Theme Class
|
|
headerUniverseLabel.textContent = universe.shortCode;
|
|
document.body.className = universe.themeClass;
|
|
headerMatrixLabel.textContent = universe.headerTitle;
|
|
|
|
// Custom titles per universe
|
|
const titlesMap = {
|
|
'starfleet': {
|
|
sidebar: 'STARSHIP PRESETS',
|
|
visTitle: 'INTERMIX CASCADE',
|
|
visLabel: 'INTERMIX CHAMBER // ACTIVE',
|
|
soundboard: 'LCARD KEYPAD'
|
|
},
|
|
'whoniverse': {
|
|
sidebar: 'TARDIX CONSOLES',
|
|
visTitle: 'CENTRAL TIME ROTOR COLUMN',
|
|
visLabel: 'VORTEX ROTOR // OPERATIONAL',
|
|
soundboard: 'TARDIX CONSOLE CONTROLS'
|
|
},
|
|
'industrial': {
|
|
sidebar: 'INDUSTRIAL FREIGHTERS',
|
|
visTitle: 'COMPRESSION FURNACE',
|
|
visLabel: 'CONTAINMENT CORE // ACTIVE',
|
|
soundboard: 'INDUSTRIAL INSTRUMENTATION'
|
|
},
|
|
'bioships': {
|
|
sidebar: 'SENTIENT LEVIATHONS',
|
|
visTitle: 'ORGANIC BIO-HEART SAC',
|
|
visLabel: 'VASCULAR NEXUS // PULSING',
|
|
soundboard: 'NEURAL LINK SENSORS'
|
|
},
|
|
'retrofuture': {
|
|
sidebar: 'GOLDEN AGE EXPLORATION',
|
|
visTitle: 'ASTROGATOR OSCILLOSCOPE',
|
|
visLabel: 'ANALOG BEAM // ROTATING',
|
|
soundboard: 'RETRO CONSOLE SWITCHES'
|
|
},
|
|
'military': {
|
|
sidebar: 'BATTLEFLEET WARSHIPS',
|
|
visTitle: 'TACTICAL FLYWHEEL',
|
|
visLabel: 'CONDITION ONE // ARMED',
|
|
soundboard: 'CIC TACTICAL KEYPAD'
|
|
},
|
|
'deepspace': {
|
|
sidebar: 'DEEP EXPEDITION ARKS',
|
|
visTitle: 'GRAVITY SINGULARITY CORE',
|
|
visLabel: 'EVENT HORIZON // LOCKED',
|
|
soundboard: 'VOID ACOUSTIC CONSOLE'
|
|
},
|
|
'outlaw': {
|
|
sidebar: 'FRONTIER & SALVAGE SHIPS',
|
|
visTitle: 'PATCHWORK IMPULSE DRIVE',
|
|
visLabel: 'KINETIC THRUSTERS // PRIMED',
|
|
soundboard: 'OUTLAW COCKPIT CONTROLS'
|
|
},
|
|
'spacestations': {
|
|
sidebar: 'ORBITAL STATIONS & DOCKS',
|
|
visTitle: 'CENTRIFUGAL DISTRIBUTION HUB',
|
|
visLabel: 'STATION ROTOR // DOCKED',
|
|
soundboard: 'STATION OPS CONSOLE'
|
|
},
|
|
'comedy': {
|
|
sidebar: 'WHIMSICAL & ADVENTURE FLEET',
|
|
visTitle: 'IMPROBABILITY ENGINE',
|
|
visLabel: 'REALITY FIELD // SHIFTING',
|
|
soundboard: 'CHEERFUL CONSOLE KEYPAD'
|
|
}
|
|
};
|
|
|
|
const conf = titlesMap[universeId] || titlesMap['starfleet'];
|
|
sidebarTitle.textContent = conf.sidebar;
|
|
coreVisualizerTitle.textContent = conf.visTitle;
|
|
coreVisualizerLabel.textContent = conf.visLabel;
|
|
soundboardTitle.textContent = conf.soundboard;
|
|
|
|
// Configure Visualizer mode
|
|
visualizer.setMode(universe.visualizer || 'warp-core');
|
|
|
|
// Render Event Buttons & Soundboard
|
|
renderUniverseEvents(universe);
|
|
renderUniverseSoundboard(universe);
|
|
|
|
// Render Presets
|
|
renderUniversePresets(universe);
|
|
|
|
// Select Default Preset
|
|
const firstPresetId = Object.keys(universe.presets)[0];
|
|
if (firstPresetId) {
|
|
selectPreset(firstPresetId);
|
|
}
|
|
|
|
if (observationActive && typeof refreshObservation === 'function') {
|
|
refreshObservation();
|
|
}
|
|
}
|
|
|
|
function renderUniverseEvents(universe) {
|
|
universeEventsContainer.innerHTML = '';
|
|
universe.events.forEach(evt => {
|
|
const btn = document.createElement('button');
|
|
btn.className = 'btn-lcars-pill';
|
|
btn.id = evt.id;
|
|
btn.textContent = evt.label;
|
|
|
|
btn.addEventListener('click', async () => {
|
|
await audioManager.resume();
|
|
handleUniverseEvent(evt.type, btn);
|
|
});
|
|
|
|
universeEventsContainer.appendChild(btn);
|
|
});
|
|
}
|
|
|
|
function renderUniverseSoundboard(universe) {
|
|
soundboardContainer.innerHTML = '';
|
|
universe.soundboard.forEach(key => {
|
|
const btn = document.createElement('button');
|
|
btn.className = 'btn-chirp';
|
|
btn.id = key.id;
|
|
btn.textContent = key.label;
|
|
if (key.span) {
|
|
btn.style.gridColumn = `span ${key.span}`;
|
|
}
|
|
|
|
btn.addEventListener('click', async () => {
|
|
await audioManager.resume();
|
|
handleSoundboardTrigger(key.id);
|
|
});
|
|
|
|
soundboardContainer.appendChild(btn);
|
|
});
|
|
}
|
|
|
|
function renderUniversePresets(universe) {
|
|
presetContainer.innerHTML = '';
|
|
Object.values(universe.presets).forEach(preset => {
|
|
const btn = document.createElement('button');
|
|
btn.className = `preset-btn ${preset.id === activePresetId ? 'active' : ''}`;
|
|
btn.setAttribute('data-preset-id', preset.id);
|
|
|
|
btn.innerHTML = `
|
|
<span>${preset.name}</span>
|
|
<span class="ship-era-tag">${(universe.name || 'SHIP').toUpperCase()}</span>
|
|
`;
|
|
|
|
btn.addEventListener('click', () => {
|
|
selectPreset(preset.id);
|
|
});
|
|
|
|
presetContainer.appendChild(btn);
|
|
});
|
|
}
|
|
|
|
// 4a. Phase 2 Preset Ambience
|
|
// Single funnel that stops every automated loop (legacy + registry) and then
|
|
// re-engages the ambience matching the currently selected preset.
|
|
function startPresetAmbience(presetId) {
|
|
expandedAudio.stopAllLoops();
|
|
whoniverseAudio.stopAmbientLoops();
|
|
|
|
// Legacy Phase 1 loops, kept on their original presets
|
|
if (presetId === 'sta-sevastopol') {
|
|
expandedAudio.startStationSparks(4000, 11000);
|
|
} else if (presetId === 'sta-gateway' || presetId.includes('sickbay') || presetId.includes('med')) {
|
|
expandedAudio.startMedicalMonitor(1.15);
|
|
}
|
|
|
|
// Phase 2 audio_gaps.md rows #25-#45
|
|
if (presetId === 'bio-vorlon') expandedAudio.startVorlanSong(); // #26
|
|
if (presetId === 'bio-wraith') expandedAudio.startHullRegen(); // #28
|
|
if (presetId === 'deep-event-horizon' || presetId === 'deep-icarus-ii') expandedAudio.startHullStrainMoans(); // #30
|
|
// Phase 3 (audio_gaps.md #46-#54)
|
|
if (presetId === 'sta-babylon-5') expandedAudio.startZocoloChatter(); // #46
|
|
if (presetId === 'ind-nostromo') expandedAudio.startTeletypeChatter(); // #51
|
|
if (presetId === 'ret-jupiter-2') expandedAudio.startHeterodyneDrone(); // #54
|
|
if (presetId === 'deep-icarus-ii') expandedAudio.startIcarusBeacon(); // #31
|
|
if (presetId === 'deep-event-horizon' || presetId === 'deep-nightflyer') expandedAudio.startVoidWhispers(); // #32
|
|
if (presetId === 'com-protector') expandedAudio.startBerylliumThrum(); // #33
|
|
if (presetId === 'out-marauder') expandedAudio.startRepulsorliftDrone(); // #35
|
|
if (presetId === 'out-bebop') expandedAudio.startBebopChug(); // #36
|
|
if (presetId === 'mil-agamemnon') expandedAudio.startCarouselGroan(); // #37
|
|
if (presetId === 'mil-viper') expandedAudio.startOxygenRegulator(); // #39
|
|
if (presetId === 'ind-nostromo') expandedAudio.startPipeDrips(); // #40
|
|
if (presetId === 'ind-serenity' || presetId === 'ind-starbug') expandedAudio.startSteamVent(); // #41
|
|
if (presetId === 'ret-discovery-one') expandedAudio.startHalBreathing(); // #43
|
|
if (presetId === 'ret-cygnus') expandedAudio.startCygnusChug(); // #45
|
|
}
|
|
|
|
// 4. Select & Apply Preset
|
|
function selectPreset(presetId) {
|
|
const universe = UniverseRegistry[activeUniverseId];
|
|
if (!universe) return;
|
|
|
|
const preset = universe.presets[presetId];
|
|
if (!preset) return;
|
|
|
|
activePresetId = presetId;
|
|
window.activePresetId = presetId;
|
|
|
|
document.querySelectorAll('.preset-btn').forEach(b => {
|
|
b.classList.toggle('active', b.getAttribute('data-preset-id') === presetId);
|
|
});
|
|
|
|
headerShipName.textContent = preset.name.toUpperCase();
|
|
currentPresetTitle.textContent = preset.name.toUpperCase();
|
|
currentPresetDesc.textContent = preset.description;
|
|
statCoreFreq.textContent = `${preset.warp.carrierFreq.toFixed(1)} Hz`;
|
|
|
|
hullDrone.applyPreset(preset.hull);
|
|
warpCore.applyPreset(preset.warp);
|
|
lifeSupport.applyPreset(preset.lifeSupport);
|
|
telemetry.applyPreset(preset.telemetry);
|
|
|
|
syncSlidersToSynthParams();
|
|
|
|
expandedAudio.stopAllLoops();
|
|
whoniverseAudio.stopAmbientLoops();
|
|
if (isPlaying) {
|
|
startPresetAmbience(presetId);
|
|
if (activeUniverseId === 'whoniverse') whoniverseAudio.synthesizeDematSwitch();
|
|
else if (activeUniverseId === 'industrial') expandedAudio.synthesizeDockingClamp();
|
|
else telemetry.synthesizeLCARDSingleChirp();
|
|
}
|
|
|
|
if (observationActive && typeof refreshObservation === 'function') {
|
|
refreshObservation();
|
|
}
|
|
}
|
|
|
|
function syncSlidersToSynthParams() {
|
|
sliderHullVol.value = hullDrone.params.volume;
|
|
valHullVol.textContent = `${Math.round(hullDrone.params.volume * 100)}%`;
|
|
sliderHullFreq.value = hullDrone.params.baseFreq;
|
|
valHullFreq.textContent = `${hullDrone.params.baseFreq} Hz`;
|
|
sliderHullCutoff.value = hullDrone.params.filterCutoff;
|
|
valHullCutoff.textContent = `${hullDrone.params.filterCutoff} Hz`;
|
|
|
|
sliderWarpVol.value = warpCore.params.volume;
|
|
valWarpVol.textContent = `${Math.round(warpCore.params.volume * 100)}%`;
|
|
sliderWarpBpm.value = warpCore.params.bpm;
|
|
valWarpBpm.textContent = `${warpCore.params.bpm} BPM`;
|
|
sliderWarpCarrier.value = warpCore.params.carrierFreq;
|
|
valWarpCarrier.textContent = `${warpCore.params.carrierFreq} Hz`;
|
|
|
|
sliderAirVol.value = lifeSupport.params.volume;
|
|
valAirVol.textContent = `${Math.round(lifeSupport.params.volume * 100)}%`;
|
|
sliderAirCutoff.value = lifeSupport.params.lowpassFreq;
|
|
valAirCutoff.textContent = `${lifeSupport.params.lowpassFreq} Hz`;
|
|
|
|
sliderTelemetryVol.value = telemetry.params.volume;
|
|
valTelemetryVol.textContent = `${Math.round(telemetry.params.volume * 100)}%`;
|
|
sliderTelemetryDensity.value = telemetry.params.density;
|
|
valTelemetryDensity.textContent = `${Math.round(telemetry.params.density * 100)}%`;
|
|
}
|
|
|
|
// 5. Play / Pause Master Toggle
|
|
let isTransitioning = false;
|
|
const ENGINE_TRANSITION_SECS = 2.5;
|
|
|
|
async function togglePlay() {
|
|
if (isTransitioning) return;
|
|
|
|
const engineProfile = typeof getEngineProfileForPreset === 'function'
|
|
? getEngineProfileForPreset(activePresetId, activeUniverseId)
|
|
: 'galaxy';
|
|
|
|
if (!isPlaying) {
|
|
isTransitioning = true;
|
|
await audioManager.resume();
|
|
|
|
// Flashing state while engaging
|
|
btnPlay.classList.add('engaging');
|
|
btnPlay.classList.remove('playing', 'stopping');
|
|
statAudioStatus.textContent = 'ENGAGING...';
|
|
statAudioStatus.style.color = '#ffcc00';
|
|
|
|
// Layered & Staged Procedural Startup Script for this Profile
|
|
engineTransitions.playStartup(engineProfile, ENGINE_TRANSITION_SECS);
|
|
|
|
// Acoustic chime / chirp feedback
|
|
if (activeUniverseId === 'whoniverse') whoniverseAudio.synthesizeTelepathicChime();
|
|
else if (activeUniverseId === 'bioships') expandedAudio.synthesizeStarburst();
|
|
else telemetry.synthesizeLCARDDoubleChirp();
|
|
|
|
// Start engine sounds with smooth spool-up ramp
|
|
hullDrone.start(ENGINE_TRANSITION_SECS);
|
|
warpCore.start(ENGINE_TRANSITION_SECS);
|
|
lifeSupport.start(ENGINE_TRANSITION_SECS);
|
|
telemetry.start();
|
|
|
|
setTimeout(() => {
|
|
isPlaying = true;
|
|
isTransitioning = false;
|
|
|
|
btnPlay.classList.remove('engaging');
|
|
btnPlay.classList.add('playing');
|
|
playIcon.textContent = '■';
|
|
playText.textContent = 'FULL STOP';
|
|
statAudioStatus.textContent = 'ONLINE';
|
|
statAudioStatus.style.color = '#00ff88';
|
|
|
|
if (activePresetId) {
|
|
startPresetAmbience(activePresetId);
|
|
}
|
|
}, ENGINE_TRANSITION_SECS * 1000);
|
|
|
|
} else {
|
|
isTransitioning = true;
|
|
|
|
// Flashing state while stopping
|
|
btnPlay.classList.remove('playing', 'engaging');
|
|
btnPlay.classList.add('stopping');
|
|
statAudioStatus.textContent = 'STOPPING...';
|
|
statAudioStatus.style.color = '#ff9900';
|
|
|
|
// Layered & Staged Procedural Shutdown Script for this Profile
|
|
engineTransitions.playShutdown(engineProfile, ENGINE_TRANSITION_SECS);
|
|
|
|
if (activeUniverseId === 'whoniverse') whoniverseAudio.synthesizeLeverClunk();
|
|
else telemetry.synthesizeLCARDDoubleChirp();
|
|
|
|
// Stop engine sounds with smooth spool-down fade
|
|
hullDrone.stop(ENGINE_TRANSITION_SECS);
|
|
warpCore.stop(ENGINE_TRANSITION_SECS);
|
|
lifeSupport.stop(ENGINE_TRANSITION_SECS);
|
|
telemetry.stop();
|
|
expandedAudio.stopAllLoops();
|
|
whoniverseAudio.stopAmbientLoops();
|
|
alerts.stopAlert();
|
|
whoniverseAudio.stopCloisterBell();
|
|
resetAlertButtons();
|
|
|
|
setTimeout(() => {
|
|
isPlaying = false;
|
|
isTransitioning = false;
|
|
|
|
btnPlay.classList.remove('stopping');
|
|
playIcon.textContent = '▶';
|
|
playText.textContent = 'ENGAGE';
|
|
statAudioStatus.textContent = 'STANDBY';
|
|
statAudioStatus.style.color = '#ffcc00';
|
|
}, ENGINE_TRANSITION_SECS * 1000);
|
|
}
|
|
}
|
|
|
|
btnPlay.addEventListener('click', togglePlay);
|
|
|
|
// 6. Master Volume Control System
|
|
function updateMasterVolumeUI(val, isMuted = false) {
|
|
sliderMasterVol.value = val;
|
|
const pct = Math.round(val * 100);
|
|
valMasterVol.textContent = isMuted ? 'MUTED' : `${pct}%`;
|
|
headerVolVal.textContent = isMuted ? 'MUTE' : `${pct}%`;
|
|
|
|
if (isMuted || val <= 0.001) {
|
|
valMasterDb.textContent = '-∞ dB';
|
|
} else {
|
|
const db = 20 * Math.log10(val);
|
|
valMasterDb.textContent = `${db >= 0 ? '+' : ''}${db.toFixed(1)} dB`;
|
|
}
|
|
|
|
btnMasterMute.classList.toggle('muted', isMuted);
|
|
btnHeaderMute.classList.toggle('muted', isMuted);
|
|
btnMasterMute.textContent = isMuted ? 'UNMUTE' : 'MUTE';
|
|
btnHeaderMute.textContent = isMuted ? 'MUTED' : 'MUTE';
|
|
|
|
if (volMeterLeds) {
|
|
const pips = volMeterLeds.querySelectorAll('.led-pip');
|
|
const activeCount = isMuted ? 0 : Math.round(val * pips.length);
|
|
pips.forEach((pip, idx) => {
|
|
pip.classList.toggle('active', idx < activeCount);
|
|
});
|
|
}
|
|
|
|
volStepButtons.forEach(btn => {
|
|
const stepVal = parseFloat(btn.getAttribute('data-vol'));
|
|
btn.classList.toggle('active', !isMuted && Math.abs(stepVal - val) < 0.04);
|
|
});
|
|
}
|
|
|
|
function setMasterVolume(val) {
|
|
const clamped = Math.max(0, Math.min(1, val));
|
|
if (audioManager.isMuted) audioManager.setMute(false);
|
|
audioManager.setMasterVolume(clamped);
|
|
updateMasterVolumeUI(clamped, false);
|
|
}
|
|
|
|
function toggleMute() {
|
|
const isMuted = audioManager.toggleMute();
|
|
updateMasterVolumeUI(audioManager.getMasterVolume(), isMuted);
|
|
}
|
|
|
|
sliderMasterVol.addEventListener('input', (e) => setMasterVolume(parseFloat(e.target.value)));
|
|
btnMasterMute.addEventListener('click', toggleMute);
|
|
btnHeaderMute.addEventListener('click', toggleMute);
|
|
btnVolDown.addEventListener('click', () => setMasterVolume(audioManager.getMasterVolume() - 0.05));
|
|
btnVolUp.addEventListener('click', () => setMasterVolume(audioManager.getMasterVolume() + 0.05));
|
|
|
|
volStepButtons.forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const stepVal = parseFloat(btn.getAttribute('data-vol'));
|
|
setMasterVolume(stepVal);
|
|
});
|
|
});
|
|
|
|
// 7. Channel Synthesizer Sliders
|
|
sliderHullVol.addEventListener('input', (e) => {
|
|
const val = parseFloat(e.target.value);
|
|
hullDrone.setVolume(val);
|
|
valHullVol.textContent = `${Math.round(val * 100)}%`;
|
|
});
|
|
sliderHullFreq.addEventListener('input', (e) => {
|
|
const val = parseFloat(e.target.value);
|
|
hullDrone.setBaseFreq(val);
|
|
valHullFreq.textContent = `${val} Hz`;
|
|
});
|
|
sliderHullCutoff.addEventListener('input', (e) => {
|
|
const val = parseFloat(e.target.value);
|
|
hullDrone.setFilterCutoff(val);
|
|
valHullCutoff.textContent = `${val} Hz`;
|
|
});
|
|
|
|
sliderWarpVol.addEventListener('input', (e) => {
|
|
const val = parseFloat(e.target.value);
|
|
warpCore.setVolume(val);
|
|
valWarpVol.textContent = `${Math.round(val * 100)}%`;
|
|
});
|
|
sliderWarpBpm.addEventListener('input', (e) => {
|
|
const val = parseInt(e.target.value);
|
|
warpCore.setBpm(val);
|
|
valWarpBpm.textContent = `${val} BPM`;
|
|
});
|
|
sliderWarpCarrier.addEventListener('input', (e) => {
|
|
const val = parseFloat(e.target.value);
|
|
warpCore.setCarrierFreq(val);
|
|
valWarpCarrier.textContent = `${val} Hz`;
|
|
statCoreFreq.textContent = `${val.toFixed(1)} Hz`;
|
|
});
|
|
|
|
sliderAirVol.addEventListener('input', (e) => {
|
|
const val = parseFloat(e.target.value);
|
|
lifeSupport.setVolume(val);
|
|
valAirVol.textContent = `${Math.round(val * 100)}%`;
|
|
});
|
|
sliderAirCutoff.addEventListener('input', (e) => {
|
|
const val = parseFloat(e.target.value);
|
|
lifeSupport.setFilterCutoff(val);
|
|
valAirCutoff.textContent = `${val} Hz`;
|
|
});
|
|
|
|
sliderTelemetryVol.addEventListener('input', (e) => {
|
|
const val = parseFloat(e.target.value);
|
|
telemetry.setVolume(val);
|
|
valTelemetryVol.textContent = `${Math.round(val * 100)}%`;
|
|
});
|
|
sliderTelemetryDensity.addEventListener('input', (e) => {
|
|
const val = parseFloat(e.target.value);
|
|
telemetry.setDensity(val);
|
|
valTelemetryDensity.textContent = `${Math.round(val * 100)}%`;
|
|
});
|
|
|
|
// 8. Universe Event Handlers
|
|
function resetAlertButtons() {
|
|
document.querySelectorAll('.btn-lcars-pill').forEach(b => b.classList.remove('active-alert'));
|
|
}
|
|
|
|
function handleUniverseEvent(type, btnElement) {
|
|
switch (type) {
|
|
case 'warp':
|
|
case 'quantum':
|
|
alerts.synthesizeWarpJump();
|
|
visualizer.spawnWarpPulses();
|
|
break;
|
|
case 'alert-red':
|
|
case 'action-stations':
|
|
case 'scramble':
|
|
if (alerts.activeAlert === 'red') {
|
|
alerts.stopAlert();
|
|
resetAlertButtons();
|
|
} else {
|
|
resetAlertButtons();
|
|
btnElement.classList.add('active-alert');
|
|
alerts.triggerRedAlert('tng');
|
|
}
|
|
break;
|
|
case 'alert-yellow':
|
|
if (alerts.activeAlert === 'yellow') {
|
|
alerts.stopAlert();
|
|
resetAlertButtons();
|
|
} else {
|
|
resetAlertButtons();
|
|
btnElement.classList.add('active-alert');
|
|
alerts.triggerYellowAlert();
|
|
}
|
|
break;
|
|
case 'demat':
|
|
whoniverseAudio.synthesizeDematCycle(4);
|
|
visualizer.spawnWarpPulses();
|
|
break;
|
|
case 'vortex':
|
|
whoniverseAudio.synthesizeDematCycle(1);
|
|
visualizer.spawnWarpPulses();
|
|
break;
|
|
case 'cloister':
|
|
if (whoniverseAudio.activeCloister) {
|
|
whoniverseAudio.stopCloisterBell();
|
|
resetAlertButtons();
|
|
} else {
|
|
resetAlertButtons();
|
|
btnElement.classList.add('active-alert');
|
|
whoniverseAudio.triggerCloisterBell();
|
|
}
|
|
break;
|
|
case 'epstein':
|
|
case 'subdrive':
|
|
expandedAudio.synthesizeEpsteinBurn();
|
|
visualizer.spawnWarpPulses();
|
|
break;
|
|
case 'purge':
|
|
case 'decompress':
|
|
case 'docking':
|
|
expandedAudio.synthesizeDockingClamp();
|
|
break;
|
|
case 'vent':
|
|
whoniverseAudio.synthesizeFastReturn();
|
|
break;
|
|
case 'starburst':
|
|
case 'biodefense':
|
|
expandedAudio.synthesizeStarburst();
|
|
visualizer.spawnWarpPulses();
|
|
break;
|
|
case 'neural':
|
|
expandedAudio.synthesizeNeuralBondSwell();
|
|
visualizer.spawnWarpPulses();
|
|
break;
|
|
case 'centrifuge':
|
|
case 'astrogator':
|
|
expandedAudio.synthesizeRetroAstrogator();
|
|
break;
|
|
case 'hal':
|
|
expandedAudio.synthesizeHalChime();
|
|
break;
|
|
case 'ftl-jump':
|
|
expandedAudio.synthesizeFtlJump();
|
|
visualizer.spawnWarpPulses();
|
|
break;
|
|
case 'flak':
|
|
expandedAudio.synthesizeFlakBarrageBurst();
|
|
break;
|
|
case 'singularity':
|
|
case 'gravity':
|
|
case 'rotation':
|
|
expandedAudio.synthesizeSingularityEngage();
|
|
visualizer.spawnWarpPulses();
|
|
break;
|
|
case 'solar':
|
|
expandedAudio.synthesizeSolarRoar();
|
|
visualizer.spawnWarpPulses();
|
|
break;
|
|
case 'afterburner':
|
|
expandedAudio.synthesizeAfterburner();
|
|
visualizer.spawnWarpPulses();
|
|
break;
|
|
case 'ludicrous':
|
|
expandedAudio.synthesizeLudicrousSpeed();
|
|
visualizer.spawnWarpPulses();
|
|
break;
|
|
case 'improbability':
|
|
expandedAudio.synthesizeImprobabilityFlip();
|
|
visualizer.spawnWarpPulses();
|
|
break;
|
|
default:
|
|
telemetry.synthesizeLCARDSingleChirp();
|
|
break;
|
|
}
|
|
}
|
|
|
|
function handleSoundboardTrigger(id) {
|
|
const universe = UniverseRegistry[activeUniverseId];
|
|
const preset = universe && universe.presets ? universe.presets[activePresetId] : null;
|
|
const era = preset ? preset.era : 'tng';
|
|
|
|
switch (id) {
|
|
// Star Trek
|
|
case 'btn-comm-badge':
|
|
expandedAudio.synthesizeCommBadge();
|
|
break;
|
|
case 'btn-door-swish':
|
|
expandedAudio.synthesizeDoorSwish(era);
|
|
break;
|
|
case 'btn-bosun-whistle':
|
|
expandedAudio.synthesizeBosunWhistle();
|
|
break;
|
|
case 'btn-medical-monitor':
|
|
expandedAudio.synthesizeMedicalMonitor();
|
|
break;
|
|
case 'btn-chirp-single':
|
|
case 'btn-switch-pop':
|
|
telemetry.synthesizeLCARDSingleChirp();
|
|
break;
|
|
case 'btn-chirp-double':
|
|
telemetry.synthesizeLCARDDoubleChirp();
|
|
break;
|
|
case 'btn-chirp-ack':
|
|
telemetry.synthesizeLCARDSequence();
|
|
break;
|
|
case 'btn-chirp-sweep':
|
|
telemetry.synthesizeSensorSweep();
|
|
break;
|
|
case 'btn-chirp-door':
|
|
telemetry.synthesizeDoorChime();
|
|
break;
|
|
|
|
// Whataverse
|
|
case 'btn-sonic':
|
|
whoniverseAudio.synthesizeSonicScrewdriver();
|
|
break;
|
|
case 'btn-lever-clunk':
|
|
whoniverseAudio.synthesizeLeverClunk();
|
|
break;
|
|
case 'btn-tardis-door':
|
|
whoniverseAudio.synthesizeTARDIXDoor();
|
|
break;
|
|
case 'btn-fast-return':
|
|
whoniverseAudio.synthesizeFastReturn();
|
|
break;
|
|
case 'btn-demat-switch':
|
|
case 'btn-relay-click':
|
|
whoniverseAudio.synthesizeDematSwitch();
|
|
break;
|
|
case 'btn-telepathic':
|
|
case 'btn-telepathic-chime':
|
|
whoniverseAudio.synthesizeTelepathicChime();
|
|
break;
|
|
case 'btn-scanner':
|
|
whoniverseAudio.synthesizeDematSwitch();
|
|
telemetry.synthesizeSensorSweep();
|
|
break;
|
|
|
|
// Space Stations
|
|
case 'btn-air-handler':
|
|
expandedAudio.synthesizeAirHandlerThud();
|
|
break;
|
|
case 'btn-mag-boot':
|
|
expandedAudio.synthesizeMagBootLatch();
|
|
break;
|
|
case 'btn-commlock':
|
|
expandedAudio.synthesizeComlockTone();
|
|
break;
|
|
case 'btn-sevas-spark':
|
|
expandedAudio.synthesizeSevastopolSpark();
|
|
break;
|
|
case 'btn-station-ecg':
|
|
expandedAudio.synthesizeStationMedicalPing();
|
|
break;
|
|
case 'btn-tram-gong':
|
|
whoniverseAudio.synthesizeCloisterStrike();
|
|
break;
|
|
|
|
// Deep Space
|
|
case 'btn-solar-roar':
|
|
expandedAudio.synthesizeSolarRoar();
|
|
break;
|
|
case 'btn-cryo-sigh':
|
|
expandedAudio.synthesizeCryoDepressurize();
|
|
break;
|
|
case 'btn-orion-thump':
|
|
expandedAudio.synthesizeOrionPulseThump();
|
|
break;
|
|
|
|
// Outlaw & Frontier
|
|
case 'btn-tape-clunk':
|
|
expandedAudio.synthesizeCassetteTransport();
|
|
break;
|
|
case 'btn-grappler-servo':
|
|
expandedAudio.synthesizeGrapplerServo();
|
|
break;
|
|
case 'btn-radio-static':
|
|
expandedAudio.synthesizeRadioStatic();
|
|
break;
|
|
case 'btn-afterburner-snd':
|
|
expandedAudio.synthesizeAfterburner();
|
|
break;
|
|
|
|
// Comedy & Adventure
|
|
case 'btn-cheerful-door':
|
|
expandedAudio.synthesizeCheerfulDoor();
|
|
break;
|
|
case 'btn-tea-machine':
|
|
expandedAudio.synthesizeTeaDispenser();
|
|
break;
|
|
case 'btn-kettle-whistle':
|
|
expandedAudio.synthesizeKettleWhistle();
|
|
break;
|
|
case 'btn-plaid-alarm':
|
|
expandedAudio.synthesizePlaidAlarm();
|
|
break;
|
|
case 'btn-beryllium-pulse':
|
|
expandedAudio.synthesizeImprobabilityFlip();
|
|
break;
|
|
|
|
// General / Industrial / Military
|
|
case 'btn-geiger':
|
|
expandedAudio.synthesizeGeigerBurst();
|
|
break;
|
|
case 'btn-dock-clamp':
|
|
case 'btn-pneumatic-door':
|
|
expandedAudio.synthesizeDockingClamp();
|
|
break;
|
|
case 'btn-dradis-ping':
|
|
expandedAudio.synthesizeDRADARPing();
|
|
break;
|
|
case 'btn-hal-chime':
|
|
expandedAudio.synthesizeHalChime();
|
|
break;
|
|
|
|
// Phase 2 (audio_gaps.md #21-#45)
|
|
case 'btn-cardassian-door':
|
|
expandedAudio.synthesizeCardassianDoor();
|
|
break;
|
|
case 'btn-turbolift':
|
|
expandedAudio.synthesizeTurbochuteWhoosh();
|
|
break;
|
|
case 'btn-replicator':
|
|
expandedAudio.synthesizeFabricatorShimmer();
|
|
break;
|
|
case 'btn-scanner-activate':
|
|
whoniverseAudio.synthesizeScannerActivate();
|
|
break;
|
|
case 'btn-type-clatter':
|
|
whoniverseAudio.synthesizeTypeClatter();
|
|
break;
|
|
case 'btn-vorlon-song':
|
|
expandedAudio.synthesizeVorlanSingingSwell();
|
|
break;
|
|
case 'btn-neural-bond':
|
|
expandedAudio.synthesizeNeuralBondSwell();
|
|
break;
|
|
case 'btn-hull-regen':
|
|
expandedAudio.synthesizeHullRegenBurst();
|
|
break;
|
|
case 'btn-belter-telemetry':
|
|
expandedAudio.synthesizeBeltworkerTelemetryJitter();
|
|
break;
|
|
case 'btn-hull-strain':
|
|
expandedAudio.synthesizeHullStrainMoan();
|
|
break;
|
|
case 'btn-icarus-beacon':
|
|
expandedAudio.synthesizeIcarusBeacon();
|
|
break;
|
|
case 'btn-void-whisper':
|
|
expandedAudio.synthesizeVoidWhisper();
|
|
break;
|
|
case 'btn-beryllium-thrum':
|
|
expandedAudio.synthesizeBerylliumThrumSwell();
|
|
break;
|
|
case 'btn-omega-13':
|
|
expandedAudio.synthesizeOmega13Whine();
|
|
break;
|
|
case 'btn-repulsor-drone':
|
|
expandedAudio.synthesizeRepulsorliftDrone();
|
|
break;
|
|
case 'btn-bebop-chug':
|
|
expandedAudio.synthesizeBebopChugStroke();
|
|
break;
|
|
case 'btn-carousel-groan':
|
|
expandedAudio.synthesizeCarouselGroanCycle();
|
|
break;
|
|
case 'btn-slipstream':
|
|
expandedAudio.synthesizeSlipstreamSurge();
|
|
break;
|
|
case 'btn-oxygen-reg':
|
|
expandedAudio.synthesizeOxygenRegulatorCycle();
|
|
break;
|
|
case 'btn-pipe-drips':
|
|
expandedAudio.synthesizePipeDrip();
|
|
break;
|
|
case 'btn-steam-vent':
|
|
expandedAudio.synthesizeSteamVentSwell();
|
|
break;
|
|
case 'btn-crash-couch':
|
|
expandedAudio.synthesizeCrashCouchGimbal();
|
|
break;
|
|
case 'btn-hal-breath':
|
|
expandedAudio.synthesizeHalBreathCycle();
|
|
break;
|
|
case 'btn-death-blossom':
|
|
expandedAudio.synthesizeDeathBlossomSurge();
|
|
break;
|
|
case 'btn-cygnus-chug':
|
|
expandedAudio.synthesizeCygnusPistonChug();
|
|
break;
|
|
|
|
// Phase 3 (audio_gaps.md #48-#53)
|
|
case 'btn-transporter':
|
|
expandedAudio.synthesizeTranslocatorCycle();
|
|
break;
|
|
case 'btn-breech-clank':
|
|
expandedAudio.synthesizeBreechClank();
|
|
break;
|
|
case 'btn-dock-groan':
|
|
expandedAudio.synthesizeDockingGroan();
|
|
break;
|
|
case 'btn-tape-spooler':
|
|
expandedAudio.synthesizeTapeSpooler();
|
|
break;
|
|
case 'btn-teletype':
|
|
expandedAudio.synthesizeTeletypeChatterPhrase();
|
|
break;
|
|
case 'btn-zocalo-chatter':
|
|
if (expandedAudio.zocaloChatterActive) expandedAudio.stopZocoloChatter();
|
|
else expandedAudio.startZocoloChatter();
|
|
break;
|
|
case 'btn-hetero-drone':
|
|
if (expandedAudio.heterodyneDroneActive) expandedAudio.stopHeterodyneDrone();
|
|
else expandedAudio.startHeterodyneDrone();
|
|
break;
|
|
|
|
default:
|
|
telemetry.synthesizeLCARDSingleChirp();
|
|
break;
|
|
}
|
|
}
|
|
|
|
// 9. Sleep Timer
|
|
timerButtons.forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const minutes = parseInt(btn.getAttribute('data-minutes'));
|
|
if (minutes === 0) {
|
|
audioManager.stopSleepTimer();
|
|
timerDisplay.textContent = 'TIMER INACTIVE (CONTINUOUS)';
|
|
} else {
|
|
audioManager.startSleepTimer(
|
|
minutes,
|
|
(secondsLeft) => {
|
|
const m = Math.floor(secondsLeft / 60);
|
|
const s = secondsLeft % 60;
|
|
timerDisplay.textContent = `SLEEP TIMER: ${m}:${s < 10 ? '0' : ''}${s}`;
|
|
},
|
|
() => {
|
|
timerDisplay.textContent = 'SLEEP TIMER COMPLETED';
|
|
if (isPlaying) togglePlay();
|
|
}
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
|
|
// =========================================================================
|
|
// OBSERVATION MODE
|
|
// One passive full-screen view with theme-specific inline SVG displays.
|
|
// Deliberately exits only on an actual click/tap (or Escape).
|
|
// =========================================================================
|
|
const btnObservation = document.getElementById('btn-observation');
|
|
const observationOverlay = document.getElementById('observation-overlay');
|
|
const observationStage = document.getElementById('observation-stage');
|
|
const observationSimLayer = document.getElementById('observation-sim-layer');
|
|
const observationUniverse = document.getElementById('observation-universe');
|
|
const observationProfile = document.getElementById('observation-profile');
|
|
const observationStatus = document.getElementById('observation-status');
|
|
const observationEventLayer = document.getElementById('observation-event-layer');
|
|
const sliderObservationActivity = document.getElementById('slider-observation-activity');
|
|
const valObservationActivity = document.getElementById('val-observation-activity');
|
|
const toggleObservationHud = document.getElementById('toggle-observation-hud');
|
|
const valObservationHud = document.getElementById('val-observation-hud');
|
|
const observationChrome = observationOverlay.querySelector('.observation-chrome');
|
|
const observationReturn = observationOverlay.querySelector('.observation-return');
|
|
|
|
let observationActive = false;
|
|
let observationActivity = 0.60;
|
|
window.observationActivity = observationActivity;
|
|
let observationAmbientTimer = null;
|
|
let observationTransientSerial = 0;
|
|
|
|
function obsClamp(v, lo, hi) {
|
|
return Math.max(lo, Math.min(hi, v));
|
|
}
|
|
|
|
function obsRand(min, max) {
|
|
return min + Math.random() * (max - min);
|
|
}
|
|
|
|
function obsInt(min, max) {
|
|
return Math.floor(obsRand(min, max + 1));
|
|
}
|
|
|
|
function obsPick(items) {
|
|
return items[Math.floor(Math.random() * items.length)];
|
|
}
|
|
|
|
function obsHex(n, width = 3) {
|
|
return Math.floor(n).toString(16).toUpperCase().padStart(width, '0');
|
|
}
|
|
|
|
|
|
// =========================================================================
|
|
// PERSISTENT OBSERVATION SCENE ENGINE
|
|
// Original visual worlds, not franchise reproductions. Persistent entities
|
|
// use simple stateful motion rather than one-shot CSS decorations.
|
|
// =========================================================================
|
|
let observationSceneRaf = null;
|
|
let observationSceneLast = 0;
|
|
let observationSceneEntities = [];
|
|
let observationSceneBoostUntil = 0;
|
|
let observationSplineMorphs = [];
|
|
|
|
function obsEaseSmooth(t) {
|
|
return t * t * (3 - 2 * t);
|
|
}
|
|
|
|
function obsEaseInCubic(t) {
|
|
return t * t * t;
|
|
}
|
|
|
|
function obsEaseOutCubic(t) {
|
|
return 1 - Math.pow(1 - t, 3);
|
|
}
|
|
|
|
function obsBezierPoint(t, p0, p1, p2, p3) {
|
|
const u = 1 - t;
|
|
const tt = t * t;
|
|
const uu = u * u;
|
|
const uuu = uu * u;
|
|
const ttt = tt * t;
|
|
return {
|
|
x: uuu*p0.x + 3*uu*t*p1.x + 3*u*tt*p2.x + ttt*p3.x,
|
|
y: uuu*p0.y + 3*uu*t*p1.y + 3*u*tt*p2.y + ttt*p3.y
|
|
};
|
|
}
|
|
|
|
function obsBezierDerivative(t, p0, p1, p2, p3) {
|
|
const u = 1 - t;
|
|
return {
|
|
x: 3*u*u*(p1.x-p0.x) + 6*u*t*(p2.x-p1.x) + 3*t*t*(p3.x-p2.x),
|
|
y: 3*u*u*(p1.y-p0.y) + 6*u*t*(p2.y-p1.y) + 3*t*t*(p3.y-p2.y)
|
|
};
|
|
}
|
|
|
|
|
|
// =========================================================================
|
|
// LONG-LIVED SPLINE MORPH ENGINE
|
|
// Applies only to persistent pathwork in the main observation stage.
|
|
// The goal is subtle recalculation/breathing, not chaotic wriggling.
|
|
// =========================================================================
|
|
|
|
function obsThemeSplineScale(theme) {
|
|
return ({
|
|
starfleet: 0.60,
|
|
whoniverse: 1.05,
|
|
industrial: 0.48,
|
|
bioships: 1.28,
|
|
retrofuture: 0.72,
|
|
military: 0.44,
|
|
deepspace: 0.34,
|
|
outlaw: 0.86,
|
|
spacestations: 0.50,
|
|
comedy: 0.90
|
|
})[theme] || 0.65;
|
|
}
|
|
|
|
function obsTokenizePath(d) {
|
|
return (d.match(/[A-Za-z]|[-+]?(?:\d*\.\d+|\d+)(?:e[-+]?\d+)?/g) || []);
|
|
}
|
|
|
|
function obsParsePathSegments(d) {
|
|
const tokens = obsTokenizePath(d);
|
|
const segments = [];
|
|
let i = 0;
|
|
while (i < tokens.length) {
|
|
const cmd = tokens[i++];
|
|
if (!/^[A-Za-z]$/.test(cmd)) break;
|
|
const upper = cmd.toUpperCase();
|
|
|
|
let count = 0;
|
|
if (upper === 'M' || upper === 'L' || upper === 'T') count = 2;
|
|
else if (upper === 'Q' || upper === 'S') count = 4;
|
|
else if (upper === 'C') count = 6;
|
|
else if (upper === 'H' || upper === 'V') count = 1;
|
|
else if (upper === 'Z') count = 0;
|
|
else count = 0;
|
|
|
|
if (upper == 'Z') {
|
|
segments.push({ cmd, values: [] });
|
|
continue;
|
|
}
|
|
|
|
const values = [];
|
|
while (i < tokens.length && !/^[A-Za-z]$/.test(tokens[i])) {
|
|
values.push(Number(tokens[i++]));
|
|
}
|
|
if (!values.length) {
|
|
segments.push({ cmd, values: [] });
|
|
continue;
|
|
}
|
|
|
|
if (count == 0) {
|
|
segments.push({ cmd, values });
|
|
continue;
|
|
}
|
|
|
|
// Split repeated coordinate groups into separate segments. For repeated M,
|
|
// SVG treats the subsequent groups as implicit L commands.
|
|
for (let start = 0, segIdx = 0; start < values.length; start += count, segIdx++) {
|
|
const slice = values.slice(start, start + count);
|
|
if (slice.length < count) break;
|
|
const segCmd = (upper === 'M' && segIdx > 0) ? (cmd === upper ? 'L' : 'l') : cmd;
|
|
segments.push({ cmd: segCmd, values: slice });
|
|
}
|
|
}
|
|
return segments;
|
|
}
|
|
|
|
function obsBuildPathFromSegments(segments) {
|
|
return segments.map(seg => {
|
|
if (!seg.values || !seg.values.length) return seg.cmd;
|
|
const nums = seg.values.map(v => {
|
|
const n = Math.abs(v) < 1e-6 ? 0 : v;
|
|
return Number(n.toFixed(2)).toString();
|
|
}).join(' ');
|
|
return `${seg.cmd}${nums}`;
|
|
}).join(' ');
|
|
}
|
|
|
|
function obsCreateSplineMorphState(pathEl) {
|
|
if (!pathEl) return null;
|
|
const d = pathEl.getAttribute('d');
|
|
if (!d || !/[CQST]/i.test(d)) return null;
|
|
|
|
const segments = obsParsePathSegments(d);
|
|
if (!segments.length) return null;
|
|
|
|
const bbox = pathEl.getBBox();
|
|
const width = Math.max(80, bbox.width || 80);
|
|
const height = Math.max(80, bbox.height || 80);
|
|
const routeLike = pathEl.classList.contains('obs-route-wander');
|
|
const themeScale = obsThemeSplineScale(activeUniverseId);
|
|
const kindScale = routeLike ? 1.0 : 0.7;
|
|
|
|
let drawable = segments
|
|
.map((seg, idx) => ({ idx, upper: seg.cmd.toUpperCase(), count: seg.values.length }))
|
|
.filter(item => ['Q', 'T', 'S', 'C'].includes(item.upper));
|
|
const lastDrawableIdx = drawable.length ? drawable[drawable.length - 1].idx : -1;
|
|
|
|
const ampFractions = [];
|
|
segments.forEach((seg, segIdx) => {
|
|
const upper = seg.cmd.toUpperCase();
|
|
let frac = [];
|
|
|
|
if (upper === 'M' || upper === 'L') {
|
|
frac = new Array(seg.values.length).fill(0);
|
|
} else if (upper === 'C') {
|
|
frac = [1, 1, 1, 1, segIdx === lastDrawableIdx ? 0 : 0.18, segIdx === lastDrawableIdx ? 0 : 0.18];
|
|
} else if (upper === 'S') {
|
|
frac = [1, 1, segIdx === lastDrawableIdx ? 0 : 0.18, segIdx === lastDrawableIdx ? 0 : 0.18];
|
|
} else if (upper === 'Q') {
|
|
frac = [0.92, 0.92, segIdx === lastDrawableIdx ? 0 : 0.15, segIdx === lastDrawableIdx ? 0 : 0.15];
|
|
} else if (upper === 'T') {
|
|
frac = [segIdx === lastDrawableIdx ? 0 : 0.14, segIdx === lastDrawableIdx ? 0 : 0.14];
|
|
} else {
|
|
frac = new Array(seg.values.length).fill(0);
|
|
}
|
|
ampFractions.push(frac);
|
|
});
|
|
|
|
const offsets = segments.map(seg => new Array(seg.values.length).fill(0));
|
|
const targets = segments.map(seg => new Array(seg.values.length).fill(0));
|
|
|
|
function amplitudeFor(segIndex, valueIndex) {
|
|
const seg = segments[segIndex];
|
|
const frac = (ampFractions[segIndex] && ampFractions[segIndex][valueIndex]) || 0;
|
|
if (frac <= 0) return 0;
|
|
const upper = seg.cmd.toUpperCase();
|
|
const axis = upper === 'H' ? 'x' : upper === 'V' ? 'y' : (valueIndex % 2 === 0 ? 'x' : 'y');
|
|
const span = axis === 'x' ? width : height;
|
|
const baseFrac = routeLike ? 0.038 : 0.026;
|
|
return span * baseFrac * frac * kindScale * themeScale;
|
|
}
|
|
|
|
return {
|
|
el: pathEl,
|
|
baseD: d,
|
|
segments,
|
|
ampFractions,
|
|
offsets,
|
|
targets,
|
|
nextRetargetAt: performance.now() + obsRand(2200, 7800),
|
|
lastRetargetAt: performance.now(),
|
|
width,
|
|
height,
|
|
routeLike,
|
|
targetMultiplier: 1
|
|
};
|
|
}
|
|
|
|
function initializeObservationSplineMorphs() {
|
|
observationSplineMorphs = [];
|
|
if (!observationStage) return;
|
|
|
|
const paths = observationStage.querySelectorAll('path.obs-dashflow, path.obs-route-wander');
|
|
paths.forEach(pathEl => {
|
|
const state = obsCreateSplineMorphState(pathEl);
|
|
if (state) observationSplineMorphs.push(state);
|
|
});
|
|
}
|
|
|
|
function stopObservationSplineMorphs() {
|
|
observationSplineMorphs.forEach(state => {
|
|
if (state.el && state.el.isConnected) state.el.setAttribute('d', state.baseD);
|
|
});
|
|
observationSplineMorphs = [];
|
|
}
|
|
|
|
function retargetObservationSpline(state, now, forceScale = 1) {
|
|
const activity = observationActivity;
|
|
state.targetMultiplier = forceScale;
|
|
state.lastRetargetAt = now;
|
|
|
|
state.segments.forEach((seg, segIndex) => {
|
|
seg.values.forEach((_, valueIndex) => {
|
|
const amp = (function() {
|
|
const frac = (state.ampFractions[segIndex] && state.ampFractions[segIndex][valueIndex]) || 0;
|
|
if (frac <= 0) return 0;
|
|
const upper = seg.cmd.toUpperCase();
|
|
const axis = upper === 'H' ? 'x' : upper === 'V' ? 'y' : (valueIndex % 2 === 0 ? 'x' : 'y');
|
|
const span = axis === 'x' ? state.width : state.height;
|
|
const baseFrac = state.routeLike ? 0.038 : 0.026;
|
|
return span * baseFrac * frac * (state.routeLike ? 1.0 : 0.7) * obsThemeSplineScale(activeUniverseId) * forceScale;
|
|
})();
|
|
|
|
if (amp <= 0) {
|
|
state.targets[segIndex][valueIndex] = 0;
|
|
return;
|
|
}
|
|
|
|
const bias = state.routeLike ? 1.0 : 0.75;
|
|
state.targets[segIndex][valueIndex] = obsRand(-amp * bias, amp * bias);
|
|
});
|
|
});
|
|
|
|
const slow = state.routeLike ? 32000 : 42000;
|
|
const fast = state.routeLike ? 8500 : 14000;
|
|
const next = slow - (slow - fast) * Math.pow(activity, 0.82);
|
|
state.nextRetargetAt = now + next * obsRand(0.70, 1.35);
|
|
}
|
|
|
|
function updateObservationSplineMorphs(now, speedScale) {
|
|
if (!observationSplineMorphs.length) return;
|
|
|
|
const themeScale = obsThemeSplineScale(activeUniverseId);
|
|
observationSplineMorphs = observationSplineMorphs.filter(state => state.el && state.el.isConnected);
|
|
|
|
observationSplineMorphs.forEach(state => {
|
|
if (now >= state.nextRetargetAt) {
|
|
retargetObservationSpline(state, now, 1);
|
|
}
|
|
|
|
const follow = (0.012 + observationActivity * 0.030 + Math.max(0, speedScale - 0.4) * 0.006) * themeScale;
|
|
|
|
state.segments.forEach((seg, segIndex) => {
|
|
seg.values.forEach((baseValue, valueIndex) => {
|
|
const current = state.offsets[segIndex][valueIndex];
|
|
const target = state.targets[segIndex][valueIndex];
|
|
state.offsets[segIndex][valueIndex] = current + (target - current) * follow;
|
|
});
|
|
});
|
|
|
|
const morphed = state.segments.map((seg, segIndex) => ({
|
|
cmd: seg.cmd,
|
|
values: seg.values.map((baseValue, valueIndex) => baseValue + state.offsets[segIndex][valueIndex])
|
|
}));
|
|
|
|
state.el.setAttribute('d', obsBuildPathFromSegments(morphed));
|
|
});
|
|
}
|
|
|
|
function pulseObservationSplineMorphs() {
|
|
if (!observationActive || !observationSplineMorphs.length) return;
|
|
|
|
const candidates = observationSplineMorphs.filter(state => state.el && state.el.isConnected);
|
|
if (!candidates.length) return;
|
|
|
|
const count = observationActivity > 0.72 && Math.random() < observationActivity ? 2 : 1;
|
|
const shuffled = [...candidates].sort(() => Math.random() - 0.5);
|
|
|
|
for (let i = 0; i < Math.min(count, shuffled.length); i++) {
|
|
retargetObservationSpline(shuffled[i], performance.now(), 1.22 + observationActivity * 0.38);
|
|
}
|
|
}
|
|
|
|
|
|
function obsSceneShip(kind='scout', color='#dbeafe', accent='#38bdf8') {
|
|
if (kind === 'freighter') {
|
|
return `
|
|
<g>
|
|
<path d="M-42 -13H23L43 0L23 13H-42L-55 5V-5Z" fill="${color}" opacity=".82"/>
|
|
<rect x="-26" y="-18" width="28" height="36" rx="5" fill="${accent}" opacity=".22"/>
|
|
<path d="M-50 -5H-63M-50 5H-63" stroke="${accent}" stroke-width="4" opacity=".9"/>
|
|
<circle cx="-66" cy="-5" r="2.5" fill="#f97316"/>
|
|
<circle cx="-66" cy="5" r="2.5" fill="#f97316"/>
|
|
</g>`;
|
|
}
|
|
if (kind === 'shuttle') {
|
|
return `
|
|
<g>
|
|
<path d="M-25 -10L19 -6L31 0L19 6L-25 10L-34 0Z" fill="${color}" opacity=".88"/>
|
|
<path d="M-17 -13L2 -7M-17 13L2 7" stroke="${accent}" stroke-width="3" opacity=".65"/>
|
|
<path d="M-35 0H-48" stroke="${accent}" stroke-width="3" opacity=".7"/>
|
|
</g>`;
|
|
}
|
|
if (kind === 'tug') {
|
|
return `
|
|
<g>
|
|
<rect x="-26" y="-13" width="42" height="26" rx="7" fill="${color}" opacity=".82"/>
|
|
<path d="M16 -8L34 0L16 8Z" fill="${accent}" opacity=".6"/>
|
|
<path d="M-26 -7H-40M-26 7H-40" stroke="#f97316" stroke-width="3"/>
|
|
</g>`;
|
|
}
|
|
return `
|
|
<g>
|
|
<path d="M-30 -10L30 0L-30 10L-17 0Z" fill="${color}" opacity=".9"/>
|
|
<path d="M-10 -13L10 0L-10 13" fill="none" stroke="${accent}" stroke-width="3" opacity=".65"/>
|
|
<path d="M-33 0H-48" stroke="${accent}" stroke-width="3" opacity=".65"/>
|
|
</g>`;
|
|
}
|
|
|
|
function obsSceneDefs() {
|
|
return `
|
|
<defs>
|
|
<filter id="obsSceneGlow">
|
|
<feGaussianBlur stdDeviation="4" result="blur"/>
|
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
|
</filter>
|
|
<clipPath id="obsSceneStationClip">
|
|
<rect x="220" y="140" width="1160" height="540" rx="28"/>
|
|
</clipPath>
|
|
</defs>`;
|
|
}
|
|
|
|
function obsFlightMarkup(id, shipMarkup, cfg) {
|
|
return `
|
|
<g id="${id}" class="obs-scene-entity"
|
|
data-obs-kind="flight"
|
|
data-x0="${cfg.x0}" data-y0="${cfg.y0}"
|
|
data-x1="${cfg.x1}" data-y1="${cfg.y1}"
|
|
data-x2="${cfg.x2}" data-y2="${cfg.y2}"
|
|
data-x3="${cfg.x3}" data-y3="${cfg.y3}"
|
|
data-duration="${cfg.duration}"
|
|
data-delay="${cfg.delay || 0}"
|
|
data-mode="${cfg.mode || 'cruise'}"
|
|
data-scale0="${cfg.scale0 ?? 1}"
|
|
data-scale1="${cfg.scale1 ?? 1}"
|
|
data-minactivity="${cfg.minActivity ?? 0}"
|
|
data-opacity="${cfg.opacity ?? .8}">
|
|
${shipMarkup}
|
|
</g>`;
|
|
}
|
|
|
|
function obsOrbitMarkup(id, body, cfg) {
|
|
return `
|
|
<g id="${id}" class="obs-scene-entity"
|
|
data-obs-kind="orbit"
|
|
data-cx="${cfg.cx}" data-cy="${cfg.cy}"
|
|
data-rx="${cfg.rx}" data-ry="${cfg.ry}"
|
|
data-duration="${cfg.duration}"
|
|
data-phase="${cfg.phase || 0}"
|
|
data-scale0="${cfg.scale0 ?? 1}"
|
|
data-scale1="${cfg.scale1 ?? 1}"
|
|
data-minactivity="${cfg.minActivity ?? 0}"
|
|
data-opacity="${cfg.opacity ?? .7}">
|
|
${body}
|
|
</g>`;
|
|
}
|
|
|
|
function obsFloatMarkup(id, body, cfg) {
|
|
return `
|
|
<g id="${id}" class="obs-scene-entity"
|
|
data-obs-kind="float"
|
|
data-cx="${cfg.cx}" data-cy="${cfg.cy}"
|
|
data-ampx="${cfg.ampX || 0}" data-ampy="${cfg.ampY || 0}"
|
|
data-duration="${cfg.duration}"
|
|
data-phase="${cfg.phase || 0}"
|
|
data-minactivity="${cfg.minActivity ?? 0}"
|
|
data-opacity="${cfg.opacity ?? .7}">
|
|
${body}
|
|
</g>`;
|
|
}
|
|
|
|
function buildObservationSceneMarkup(theme) {
|
|
const defs = obsSceneDefs();
|
|
|
|
if (theme === 'starfleet') {
|
|
return `${defs}
|
|
<g opacity=".82">
|
|
${obsFlightMarkup('sf-flight-1', obsSceneShip('scout','#e0f2fe','#38bdf8'), {
|
|
x0:410,y0:640,x1:520,y1:520,x2:880,y2:245,x3:1110,y3:330,duration:26000,delay:900,mode:'arrival',scale0:.35,scale1:.75,minActivity:.15
|
|
})}
|
|
${obsFlightMarkup('sf-flight-2', obsSceneShip('shuttle','#fde68a','#f97316'), {
|
|
x0:1190,y0:600,x1:1030,y1:500,x2:680,y2:420,x3:510,y3:260,duration:31000,delay:4600,mode:'cruise',scale0:.28,scale1:.55,minActivity:.35
|
|
})}
|
|
${obsOrbitMarkup('sf-orbit-1', `<circle r="7" fill="#7dd3fc"/><circle r="24" fill="none" stroke="#38bdf8" opacity=".36"/>`, {
|
|
cx:800,cy:455,rx:245,ry:155,duration:24000,phase:.2,scale0:.65,scale1:1.05,minActivity:.05
|
|
})}
|
|
${obsOrbitMarkup('sf-orbit-2', `<rect x="-6" y="-6" width="12" height="12" fill="#f97316" transform="rotate(45)"/>`, {
|
|
cx:800,cy:455,rx:165,ry:245,duration:39000,phase:.62,scale0:.5,scale1:.9,minActivity:.5
|
|
})}
|
|
</g>`;
|
|
}
|
|
|
|
if (theme === 'whoniverse') {
|
|
const glyph = (char,color,size) => `<text x="0" y="0" text-anchor="middle" dominant-baseline="middle" fill="${color}" font-size="${size}" font-family="monospace">${char}</text>`;
|
|
return `${defs}
|
|
${obsOrbitMarkup('who-glyph-1', glyph('◎','#00e5ff',38), {cx:800,cy:455,rx:335,ry:160,duration:23000,phase:.1,scale0:.55,scale1:1.2,minActivity:.05})}
|
|
${obsOrbitMarkup('who-glyph-2', glyph('∆','#d4af37',30), {cx:800,cy:455,rx:255,ry:280,duration:31000,phase:.42,scale0:.5,scale1:1,minActivity:.2})}
|
|
${obsOrbitMarkup('who-glyph-3', glyph('∞','#00e5ff',28), {cx:800,cy:455,rx:420,ry:110,duration:39000,phase:.73,scale0:.4,scale1:1.15,minActivity:.45})}
|
|
${obsFloatMarkup('who-fragment-1', `<path d="M-55 0Q0-42 55 0Q0 42-55 0Z" fill="none" stroke="#00e5ff" stroke-width="3" opacity=".45"/>`,
|
|
{cx:480,cy:520,ampX:95,ampY:75,duration:17000,phase:.35,minActivity:.25})}
|
|
${obsFloatMarkup('who-fragment-2', `<path d="M-38-38L38 38M38-38L-38 38" stroke="#d4af37" stroke-width="3" opacity=".42"/>`,
|
|
{cx:1110,cy:330,ampX:70,ampY:110,duration:21000,phase:.8,minActivity:.55})}`;
|
|
}
|
|
|
|
if (theme === 'industrial') {
|
|
// v13co: the sim layer sits ABOVE the stage (z-index 2 vs 1), so anything
|
|
// placed here draws over the camera console. Every entity is therefore
|
|
// parented to a clip matching one feed's picture area. The v12 entities
|
|
// (a wheeled cart, a crane hook, a fan and two steam blobs) were free
|
|
// floating in screen space, which is what made the smoke look detached
|
|
// and put a rolling "cart" across the cargo bay. They are replaced by
|
|
// motion that belongs to a specific feed.
|
|
const feedClip = (id, x, y) =>
|
|
`<clipPath id="${id}"><rect x="${x}" y="${y}" width="485" height="189"/></clipPath>`;
|
|
return `${defs}
|
|
<defs>
|
|
${feedClip('indSimCam1', 290, 259)}
|
|
${feedClip('indSimCam2', 825, 259)}
|
|
${feedClip('indSimCam3', 290, 483)}
|
|
${feedClip('indSimCam4', 825, 483)}
|
|
</defs>
|
|
<g clip-path="url(#indSimCam1)" opacity=".7">
|
|
${obsFloatMarkup('ind-bay-drone', `
|
|
<g>
|
|
<rect x="-9" y="-5" width="18" height="10" rx="3" fill="#2b2118" stroke="#7c4a16" stroke-width="1.2"/>
|
|
<circle cx="0" cy="7" r="3" fill="#ffd08a" class="obs-scene-flicker"/>
|
|
<ellipse cx="0" cy="24" rx="14" ry="17" fill="#ffb347" opacity=".045"/>
|
|
</g>`, {cx:532,cy:420,ampX:186,ampY:11,duration:31000,phase:.15,minActivity:.2,opacity:.66})}
|
|
</g>
|
|
<g clip-path="url(#indSimCam2)" opacity=".62">
|
|
${obsFloatMarkup('ind-ember-1', `<circle r="2.2" fill="#fdba74"/>`,
|
|
{cx:1035,cy:352,ampX:38,ampY:52,duration:13000,phase:.2,minActivity:.25,opacity:.6})}
|
|
${obsFloatMarkup('ind-ember-2', `<circle r="1.6" fill="#fb923c"/>`,
|
|
{cx:1092,cy:320,ampX:46,ampY:44,duration:17000,phase:.62,minActivity:.45,opacity:.5})}
|
|
${obsFloatMarkup('ind-ember-3', `<circle r="2.6" fill="#fed7aa"/>`,
|
|
{cx:1160,cy:372,ampX:30,ampY:40,duration:21000,phase:.85,minActivity:.6,opacity:.42})}
|
|
</g>
|
|
<g clip-path="url(#indSimCam3)" opacity=".5">
|
|
${obsFloatMarkup('ind-lock-tell', `
|
|
<g><rect x="-16" y="-2" width="32" height="4" rx="2" fill="#38bdf8" opacity=".5"/></g>`,
|
|
{cx:532,cy:640,ampX:0,ampY:13,duration:23000,phase:.3,minActivity:.5,opacity:.4})}
|
|
</g>
|
|
<g clip-path="url(#indSimCam4)" opacity=".6">
|
|
${obsFloatMarkup('ind-corridor-lamp', `
|
|
<g><ellipse rx="26" ry="9" fill="#ffb347" opacity=".1"/><circle r="2.4" fill="#ffe0b0"/></g>`,
|
|
{cx:1067,cy:600,ampX:118,ampY:26,duration:27000,phase:.4,minActivity:.3,opacity:.55})}
|
|
</g>`;
|
|
}
|
|
|
|
if (theme === 'bioships') {
|
|
const particle = (color,r=5) => `<circle r="${r}" fill="${color}" class="obs-scene-soft"/>`;
|
|
return `${defs}
|
|
${obsFlightMarkup('bio-flow-1', particle('#a7f3d0',5), {x0:300,y0:520,x1:520,y1:220,x2:760,y2:680,x3:1110,y3:350,duration:15000,delay:0,mode:'cruise',scale0:.7,scale1:1,minActivity:.05,opacity:.7})}
|
|
${obsFlightMarkup('bio-flow-2', particle('#10b981',4), {x0:1240,y0:610,x1:970,y1:430,x2:880,y2:720,x3:480,y3:390,duration:18000,delay:1600,mode:'cruise',scale0:.5,scale1:.9,minActivity:.15,opacity:.65})}
|
|
${obsFlightMarkup('bio-flow-3', particle('#8b5cf6',6), {x0:340,y0:290,x1:620,y1:570,x2:920,y2:180,x3:1300,y3:510,duration:22000,delay:4200,mode:'cruise',scale0:.55,scale1:1.05,minActivity:.35,opacity:.58})}
|
|
${obsOrbitMarkup('bio-node-1', `<circle r="14" fill="#10b981" opacity=".3"/><circle r="5" fill="#a7f3d0"/>`, {cx:800,cy:470,rx:210,ry:125,duration:19000,phase:.3,scale0:.65,scale1:1.2,minActivity:.3})}
|
|
${obsFloatMarkup('bio-membrane', `<path d="M-120 0Q-60-70 0-35Q60-70 120 0Q60 70 0 35Q-60 70-120 0Z" fill="#8b5cf6" opacity=".08" stroke="#10b981" stroke-width="4" class="obs-scene-breathe"/>`,
|
|
{cx:800,cy:470,ampX:26,ampY:18,duration:13000,phase:.1,minActivity:.55,opacity:.6})}`;
|
|
}
|
|
|
|
if (theme === 'retrofuture') {
|
|
const vectorShip = `<path d="M-28-11L30 0L-28 11L-12 0Z" fill="none" stroke="#86efac" stroke-width="3"/><circle r="3" fill="#22c55e"/>`;
|
|
return `${defs}
|
|
${obsFlightMarkup('retro-v1', vectorShip, {x0:350,y0:610,x1:510,y1:430,x2:950,y2:330,x3:1210,y3:500,duration:25000,delay:0,mode:'cruise',scale0:.5,scale1:.8,minActivity:.1,opacity:.65})}
|
|
${obsFlightMarkup('retro-v2', vectorShip, {x0:1180,y0:260,x1:1010,y1:570,x2:610,y2:610,x3:430,y3:330,duration:33000,delay:4300,mode:'cruise',scale0:.35,scale1:.65,minActivity:.45,opacity:.52})}
|
|
${obsOrbitMarkup('retro-blip', `<circle r="5" fill="#86efac"/><circle r="25" fill="none" stroke="#22c55e" opacity=".25"/>`, {cx:800,cy:460,rx:235,ry:235,duration:30000,phase:.15,scale0:.55,scale1:.95,minActivity:.05})}
|
|
${obsFloatMarkup('retro-liss', `<path d="M-100 0C-50-95 50 95 100 0C50-95-50 95-100 0Z" fill="none" stroke="#22c55e" stroke-width="2.5" opacity=".3"/>`,
|
|
{cx:800,cy:460,ampX:45,ampY:25,duration:14000,phase:.42,minActivity:.7,opacity:.5})}`;
|
|
}
|
|
|
|
if (theme === 'military') {
|
|
const tri = `<path d="M0-15L14 14H-14Z" fill="#eab308" opacity=".75"/>`;
|
|
return `${defs}
|
|
${obsFlightMarkup('mil-form', `
|
|
<g>
|
|
${tri}
|
|
<g transform="translate(-58 38) scale(.78)">${tri}</g>
|
|
<g transform="translate(58 38) scale(.78)">${tri}</g>
|
|
<g transform="translate(0 82) scale(.68)">${tri}</g>
|
|
</g>`, {x0:390,y0:620,x1:550,y1:410,x2:900,y2:290,x3:1160,y3:390,duration:28000,delay:800,mode:'cruise',scale0:.4,scale1:.7,minActivity:.15,opacity:.65})}
|
|
${obsFlightMarkup('mil-hostile', `<path d="M-18-14L20 0L-18 14L-8 0Z" fill="#ef4444" opacity=".72"/>`,
|
|
{x0:1190,y0:650,x1:1020,y1:520,x2:660,y2:430,x3:470,y3:240,duration:22000,delay:6200,mode:'arrival',scale0:.35,scale1:.6,minActivity:.45,opacity:.58})}
|
|
${obsOrbitMarkup('mil-cap', `<rect x="-8" y="-8" width="16" height="16" fill="none" stroke="#eab308" stroke-width="3" transform="rotate(45)"/>`,
|
|
{cx:800,cy:470,rx:300,ry:185,duration:41000,phase:.5,scale0:.45,scale1:.75,minActivity:.65,opacity:.52})}`;
|
|
}
|
|
|
|
if (theme === 'deepspace') {
|
|
return `${defs}
|
|
${obsFlightMarkup('deep-ship', obsSceneShip('shuttle','#e0e7ff','#818cf8'), {
|
|
x0:230,y0:570,x1:520,y1:500,x2:900,y2:350,x3:1370,y3:300,duration:52000,delay:0,mode:'cruise',scale0:.12,scale1:.28,minActivity:.2,opacity:.6
|
|
})}
|
|
${obsFlightMarkup('deep-comet', `<g><circle r="6" fill="#fff"/><path d="M-12 0H-170" stroke="#c7d2fe" stroke-width="3" opacity=".6"/></g>`, {
|
|
x0:180,y0:700,x1:480,y1:520,x2:930,y2:230,x3:1490,y3:150,duration:36000,delay:7500,mode:'cruise',scale0:.55,scale1:.9,minActivity:.5,opacity:.52
|
|
})}
|
|
${obsFloatMarkup('deep-planet', `
|
|
<g>
|
|
<circle r="118" fill="#172554" opacity=".5"/>
|
|
<path d="M-110 20A118 118 0 0 1 106-36" fill="none" stroke="#6366f1" stroke-width="4" opacity=".45"/>
|
|
<ellipse rx="160" ry="35" fill="none" stroke="#38bdf8" stroke-width="3" opacity=".18"/>
|
|
</g>`, {cx:1200,cy:590,ampX:80,ampY:24,duration:95000,phase:.22,minActivity:.05,opacity:.55})}`;
|
|
}
|
|
|
|
if (theme === 'outlaw') {
|
|
return `${defs}
|
|
${obsFlightMarkup('out-runner', `
|
|
<g>
|
|
<path d="M-38-12H18L38 0L18 12H-38L-50 4V-4Z" fill="#fbcfe8" opacity=".7"/>
|
|
<path d="M-38-15L-5-9M-38 15L-5 9" stroke="#f59e0b" stroke-width="3"/>
|
|
<path d="M-52-5H-72M-52 5H-72" stroke="#ec4899" stroke-width="4" opacity=".65"/>
|
|
</g>`, {x0:300,y0:610,x1:480,y1:430,x2:940,y2:300,x3:1290,y3:410,duration:31000,delay:1400,mode:'cruise',scale0:.35,scale1:.75,minActivity:.1,opacity:.62})}
|
|
${obsFlightMarkup('out-pursuer', obsSceneShip('scout','#f9a8d4','#8b5cf6'), {
|
|
x0:1280,y0:590,x1:1070,y1:520,x2:730,y2:520,x3:420,y3:310,duration:27000,delay:7900,mode:'arrival',scale0:.25,scale1:.52,minActivity:.5,opacity:.5
|
|
})}
|
|
${obsFloatMarkup('out-needle', `
|
|
<g>
|
|
<path d="M-70 45A85 85 0 0 1 70 45" fill="none" stroke="#f59e0b" stroke-width="5" opacity=".42"/>
|
|
<path d="M0 45L52-4" stroke="#ec4899" stroke-width="4"/>
|
|
<circle cy="45" r="8" fill="#8b5cf6"/>
|
|
</g>`, {cx:1250,cy:535,ampX:8,ampY:4,duration:9000,phase:.2,minActivity:.25,opacity:.58})}`;
|
|
}
|
|
|
|
if (theme === 'spacestations') {
|
|
const clipOpen = `<g clip-path="url(#obsSceneStationClip)">`;
|
|
const clipClose = `</g>`;
|
|
return `${defs}${clipOpen}
|
|
${obsFlightMarkup('st-arrival', obsSceneShip('shuttle','#e0f2fe','#38bdf8'), {
|
|
x0:270,y0:520,x1:500,y1:455,x2:920,y2:380,x3:1195,y3:415,duration:36000,delay:0,mode:'arrival',scale0:.16,scale1:.62,minActivity:.05,opacity:.75
|
|
})}
|
|
${obsFlightMarkup('st-depart', obsSceneShip('tug','#bae6fd','#f97316'), {
|
|
x0:1180,y0:485,x1:1030,y1:430,x2:650,y2:310,x3:260,y3:255,duration:30000,delay:8400,mode:'depart',scale0:.58,scale1:.16,minActivity:.2,opacity:.68
|
|
})}
|
|
${obsFlightMarkup('st-freighter', obsSceneShip('freighter','#cbd5e1','#0ea5e9'), {
|
|
x0:185,y0:255,x1:530,y1:210,x2:920,y2:230,x3:1420,y3:300,duration:52000,delay:3800,mode:'cruise',scale0:.26,scale1:.42,minActivity:.4,opacity:.55
|
|
})}
|
|
${obsOrbitMarkup('st-hold', obsSceneShip('shuttle','#7dd3fc','#fdba74'), {
|
|
cx:800,cy:410,rx:290,ry:145,duration:43000,phase:.18,scale0:.18,scale1:.32,minActivity:.62,opacity:.5
|
|
})}
|
|
${obsFlightMarkup('st-nearpass', obsSceneShip('freighter','#f1f5f9','#38bdf8'), {
|
|
x0:170,y0:650,x1:470,y1:565,x2:950,y2:535,x3:1450,y3:600,duration:42000,delay:14500,mode:'cruise',scale0:.42,scale1:.78,minActivity:.78,opacity:.48
|
|
})}
|
|
${clipClose}`;
|
|
}
|
|
|
|
if (theme === 'comedy') {
|
|
return `${defs}
|
|
${obsFlightMarkup('com-tour', `
|
|
<g>
|
|
<path d="M-36-12L26-8L42 0L26 8L-36 12L-48 0Z" fill="#cffafe" opacity=".75"/>
|
|
<circle cx="-8" cy="0" r="7" fill="#fbbf24" opacity=".7"/>
|
|
<path d="M-50 0H-72" stroke="#f43f5e" stroke-width="4"/>
|
|
</g>`, {x0:260,y0:600,x1:520,y1:255,x2:1030,y2:670,x3:1350,y3:330,duration:36000,delay:1200,mode:'cruise',scale0:.32,scale1:.62,minActivity:.08,opacity:.65})}
|
|
${obsOrbitMarkup('com-orbit', `<circle r="18" fill="#fbbf24" opacity=".25"/><text text-anchor="middle" y="6" fill="#67e8f9" font-size="20" font-family="monospace">?</text>`,
|
|
{cx:800,cy:445,rx:330,ry:160,duration:33000,phase:.35,scale0:.65,scale1:1.15,minActivity:.45,opacity:.58})}
|
|
${obsFloatMarkup('com-cube', `<rect x="-22" y="-22" width="44" height="44" rx="8" fill="none" stroke="#f43f5e" stroke-width="4" transform="rotate(14)"/>`,
|
|
{cx:1120,cy:570,ampX:90,ampY:70,duration:18000,phase:.7,minActivity:.7,opacity:.52})}`;
|
|
}
|
|
|
|
return defs;
|
|
}
|
|
|
|
function registerObservationSceneEntities() {
|
|
observationSceneEntities = [];
|
|
if (!observationSimLayer) return;
|
|
|
|
observationSimLayer.querySelectorAll('[data-obs-kind]').forEach(el => {
|
|
const d = el.dataset;
|
|
const sceneNow = performance.now();
|
|
const common = {
|
|
el,
|
|
kind: d.obsKind,
|
|
minActivity: Number(d.minactivity || 0),
|
|
baseOpacity: Number(d.opacity || .7),
|
|
phase: Number(d.phase || 0),
|
|
startedAt: sceneNow,
|
|
inactiveUntil: sceneNow
|
|
};
|
|
|
|
if (d.obsKind === 'flight') {
|
|
const firstStart = sceneNow + Number(d.delay || 0);
|
|
observationSceneEntities.push({
|
|
...common,
|
|
startedAt: firstStart,
|
|
inactiveUntil: firstStart,
|
|
p0:{x:Number(d.x0),y:Number(d.y0)},
|
|
p1:{x:Number(d.x1),y:Number(d.y1)},
|
|
p2:{x:Number(d.x2),y:Number(d.y2)},
|
|
p3:{x:Number(d.x3),y:Number(d.y3)},
|
|
duration:Number(d.duration || 30000),
|
|
mode:d.mode || 'cruise',
|
|
scale0:Number(d.scale0 || 1),
|
|
scale1:Number(d.scale1 || 1),
|
|
cycle:0
|
|
});
|
|
} else if (d.obsKind === 'orbit') {
|
|
observationSceneEntities.push({
|
|
...common,
|
|
cx:Number(d.cx),cy:Number(d.cy),
|
|
rx:Number(d.rx),ry:Number(d.ry),
|
|
duration:Number(d.duration || 30000),
|
|
scale0:Number(d.scale0 || 1),
|
|
scale1:Number(d.scale1 || 1)
|
|
});
|
|
} else if (d.obsKind === 'float') {
|
|
observationSceneEntities.push({
|
|
...common,
|
|
cx:Number(d.cx),cy:Number(d.cy),
|
|
ampX:Number(d.ampx || 0),ampY:Number(d.ampy || 0),
|
|
duration:Number(d.duration || 20000)
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
function observationSceneResetFlight(entity, now) {
|
|
entity.cycle += 1;
|
|
const restBase = 5500 - observationActivity * 3500;
|
|
entity.inactiveUntil = now + obsRand(restBase * .35, restBase * 1.35);
|
|
entity.startedAt = entity.inactiveUntil;
|
|
}
|
|
|
|
function updateObservationSceneEntity(entity, now, speedScale) {
|
|
const visibleByActivity = observationActivity + .001 >= entity.minActivity;
|
|
if (!visibleByActivity) {
|
|
entity.el.style.opacity = '0';
|
|
return;
|
|
}
|
|
|
|
if (entity.kind === 'flight') {
|
|
if (now < entity.inactiveUntil) {
|
|
entity.el.style.opacity = '0';
|
|
return;
|
|
}
|
|
|
|
const elapsed = (now - entity.startedAt) * speedScale;
|
|
let raw = elapsed / entity.duration;
|
|
|
|
if (raw >= 1) {
|
|
observationSceneResetFlight(entity, now);
|
|
entity.el.style.opacity = '0';
|
|
return;
|
|
}
|
|
|
|
raw = obsClamp(raw, 0, 1);
|
|
let t = raw;
|
|
if (entity.mode === 'arrival') t = obsEaseOutCubic(raw);
|
|
else if (entity.mode === 'depart') t = obsEaseInCubic(raw);
|
|
else t = obsEaseSmooth(raw);
|
|
|
|
const p = obsBezierPoint(t, entity.p0, entity.p1, entity.p2, entity.p3);
|
|
const v = obsBezierDerivative(t, entity.p0, entity.p1, entity.p2, entity.p3);
|
|
const angle = Math.atan2(v.y, v.x) * 180 / Math.PI;
|
|
const scale = entity.scale0 + (entity.scale1 - entity.scale0) * t;
|
|
|
|
const edgeFade = Math.min(1, raw / .08, (1-raw) / .08);
|
|
const opacity = entity.baseOpacity * obsClamp(edgeFade,0,1);
|
|
entity.el.style.opacity = String(opacity);
|
|
entity.el.setAttribute('transform', `translate(${p.x.toFixed(2)} ${p.y.toFixed(2)}) rotate(${angle.toFixed(2)}) scale(${scale.toFixed(3)})`);
|
|
return;
|
|
}
|
|
|
|
if (entity.kind === 'orbit') {
|
|
const cycle = ((now - entity.startedAt) * speedScale / entity.duration + entity.phase) % 1;
|
|
const a = cycle * Math.PI * 2;
|
|
const x = entity.cx + Math.cos(a) * entity.rx;
|
|
const y = entity.cy + Math.sin(a) * entity.ry;
|
|
const depth = (Math.sin(a) + 1) / 2;
|
|
const scale = entity.scale0 + (entity.scale1 - entity.scale0) * depth;
|
|
entity.el.style.opacity = String(entity.baseOpacity * (.55 + depth * .45));
|
|
entity.el.setAttribute('transform', `translate(${x.toFixed(2)} ${y.toFixed(2)}) scale(${scale.toFixed(3)})`);
|
|
return;
|
|
}
|
|
|
|
if (entity.kind === 'float') {
|
|
const cycle = ((now - entity.startedAt) * speedScale / entity.duration + entity.phase) * Math.PI * 2;
|
|
const x = entity.cx + Math.sin(cycle) * entity.ampX;
|
|
const y = entity.cy + Math.cos(cycle * .73) * entity.ampY;
|
|
entity.el.style.opacity = String(entity.baseOpacity);
|
|
entity.el.setAttribute('transform', `translate(${x.toFixed(2)} ${y.toFixed(2)})`);
|
|
}
|
|
}
|
|
|
|
function observationSceneFrame(now) {
|
|
observationSceneRaf = null;
|
|
if (!observationActive || !observationSimLayer) return;
|
|
|
|
const reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
const baseSpeed = reduceMotion ? .08 : (.32 + observationActivity * 1.18);
|
|
const boost = now < observationSceneBoostUntil ? .38 : 0;
|
|
const speedScale = baseSpeed + boost;
|
|
|
|
for (const entity of observationSceneEntities) {
|
|
updateObservationSceneEntity(entity, now, speedScale);
|
|
}
|
|
|
|
updateObservationSplineMorphs(now, speedScale);
|
|
|
|
observationSceneLast = now;
|
|
observationSceneRaf = requestAnimationFrame(observationSceneFrame);
|
|
}
|
|
|
|
function startObservationScene() {
|
|
stopObservationScene();
|
|
if (!observationSimLayer) return;
|
|
observationSimLayer.innerHTML = `<svg viewBox="0 0 1600 900" preserveAspectRatio="xMidYMid slice"
|
|
xmlns="http://www.w3.org/2000/svg" aria-hidden="true">${buildObservationSceneMarkup(activeUniverseId)}</svg>`;
|
|
registerObservationSceneEntities();
|
|
observationSceneLast = performance.now();
|
|
observationSceneRaf = requestAnimationFrame(observationSceneFrame);
|
|
}
|
|
|
|
function stopObservationScene() {
|
|
if (observationSceneRaf) {
|
|
cancelAnimationFrame(observationSceneRaf);
|
|
observationSceneRaf = null;
|
|
}
|
|
observationSceneEntities = [];
|
|
if (observationSimLayer) observationSimLayer.innerHTML = '';
|
|
}
|
|
|
|
function pulseObservationScene() {
|
|
if (!observationActive) return;
|
|
observationSceneBoostUntil = performance.now() + 1200 + observationActivity * 900;
|
|
|
|
const eligible = observationSceneEntities.filter(e => observationActivity + .001 >= e.minActivity);
|
|
if (!eligible.length) return;
|
|
const chosen = obsPick(eligible);
|
|
|
|
// Telemetry can wake a waiting scene object early without assigning the
|
|
// sound a literal fictional meaning.
|
|
if (chosen.kind === 'flight' && performance.now() < chosen.inactiveUntil && Math.random() < .55) {
|
|
const wakeNow = performance.now();
|
|
chosen.inactiveUntil = wakeNow;
|
|
chosen.startedAt = wakeNow;
|
|
}
|
|
}
|
|
|
|
|
|
function transientSvg(body) {
|
|
return `<svg viewBox="0 0 1600 900" preserveAspectRatio="xMidYMid slice"
|
|
xmlns="http://www.w3.org/2000/svg" aria-hidden="true">${body}</svg>`;
|
|
}
|
|
|
|
function spawnObservationTransient(body, lifetime = 4200) {
|
|
if (!observationActive || !observationEventLayer) return;
|
|
const node = document.createElement('div');
|
|
node.className = 'obs-transient';
|
|
node.dataset.serial = String(++observationTransientSerial);
|
|
node.style.setProperty('--obs-life', `${lifetime}ms`);
|
|
node.innerHTML = transientSvg(body);
|
|
observationEventLayer.appendChild(node);
|
|
|
|
const maxTransient = 5 + Math.round(observationActivity * 8);
|
|
while (observationEventLayer.children.length > maxTransient) {
|
|
observationEventLayer.firstElementChild.remove();
|
|
}
|
|
|
|
window.setTimeout(() => node.remove(), lifetime + 300);
|
|
}
|
|
|
|
function obsTextCard(x, y, title, line, color = 'var(--primary-accent)', width = 290) {
|
|
return `
|
|
<g transform="translate(${x} ${y})">
|
|
<g style="transform-box:fill-box;transform-origin:center;animation:obs-transient-pop .7s ease-out both">
|
|
<rect width="${width}" height="82" rx="7" fill="rgba(0,0,0,.72)" stroke="${color}" stroke-width="2"/>
|
|
<rect x="0" y="0" width="7" height="82" fill="${color}" opacity=".85"/>
|
|
<text x="22" y="31" fill="${color}" font-size="15" font-family="monospace">${title}</text>
|
|
<text x="22" y="57" fill="#e8f4ff" opacity=".78" font-size="13" font-family="monospace">${line}</text>
|
|
</g>
|
|
</g>`;
|
|
}
|
|
|
|
function obsExhibitX(width = 290) {
|
|
const leftMargin = 240;
|
|
const rightMargin = 240;
|
|
return obsInt(leftMargin, 1600 - rightMargin - width);
|
|
}
|
|
|
|
function obsExhibitY(height = 82) {
|
|
const topMargin = 220;
|
|
const bottomMargin = 190;
|
|
return obsInt(topMargin, 900 - bottomMargin - height);
|
|
}
|
|
|
|
function obsExhibitCardPosition(width = 290, height = 82) {
|
|
return { x: obsExhibitX(width), y: obsExhibitY(height) };
|
|
}
|
|
|
|
function obsContact(x, y, color = 'var(--primary-accent)', label = 'CONTACT') {
|
|
const size = obsInt(26, 54);
|
|
return `
|
|
<g transform="translate(${x} ${y})" stroke="${color}" fill="none" stroke-width="3">
|
|
<g style="transform-box:fill-box;transform-origin:center;animation:obs-transient-pop .65s ease-out both">
|
|
<path d="M${-size},${-size/2}v${-size/2}h${size/2} M${size},${-size/2}v${-size/2}h${-size/2}
|
|
M${-size},${size/2}v${size/2}h${size/2} M${size},${size/2}v${size/2}h${-size/2}"/>
|
|
<circle r="5" fill="${color}" stroke="none"/>
|
|
<circle r="${size * 1.25}" opacity=".25"/>
|
|
<text x="${size + 18}" y="6" fill="${color}" stroke="none" font-size="14" font-family="monospace">${label}</text>
|
|
</g>
|
|
</g>`;
|
|
}
|
|
|
|
function obsExpandingRing(x, y, color = 'var(--primary-accent)', radius = 70) {
|
|
return `
|
|
<g transform="translate(${x} ${y})">
|
|
<circle r="${radius}" fill="none" stroke="${color}" stroke-width="4"
|
|
style="transform-box:fill-box;transform-origin:center;animation:obs-transient-ring 2.8s ease-out forwards"/>
|
|
<circle r="7" fill="${color}" opacity=".8"/>
|
|
</g>`;
|
|
}
|
|
|
|
function generateStarflightActivity(power) {
|
|
const effect = obsPick(['contact','vector','data','ring','diagnostic','streak']);
|
|
if (effect === 'contact') {
|
|
const x = obsInt(420,1180), y = obsInt(230,690);
|
|
return spawnObservationTransient(
|
|
obsContact(x,y,'var(--primary-accent)',`CONTACT ${obsInt(2,99).toString().padStart(2,'0')}`) +
|
|
`<path d="M800 455L${x} ${y}" stroke="var(--tertiary-accent)" stroke-width="2" opacity=".55" stroke-dasharray="10 9"/>`,
|
|
4300
|
|
);
|
|
}
|
|
if (effect === 'vector') {
|
|
const y1=obsInt(260,680), y2=obsInt(220,690);
|
|
return spawnObservationTransient(`
|
|
<path d="M230 ${y1} C520 ${obsInt(220,690)} 940 ${obsInt(220,690)} 1380 ${y2}"
|
|
fill="none" stroke="var(--tertiary-accent)" stroke-width="${2+power*3}" stroke-dasharray="18 12"
|
|
class="obs-route-wander"/>
|
|
<circle cx="1380" cy="${y2}" r="7" fill="var(--primary-accent)"/>
|
|
`, 5200);
|
|
}
|
|
if (effect === 'data') {
|
|
const pos = obsExhibitCardPosition(290, 82);
|
|
return spawnObservationTransient(obsTextCard(
|
|
pos.x, pos.y,
|
|
obsPick(['SENSOR UPDATE','NAV SOLUTION','SYSTEM QUERY','PASSIVE ARRAY']),
|
|
obsPick([
|
|
`BEARING ${obsInt(0,359).toString().padStart(3,'0')}.${obsInt(0,9)}°`,
|
|
`RANGE ${obsInt(2,980)}.${obsInt(0,9)} Mm`,
|
|
`SUBSPACE VAR ${obsRand(.01,.99).toFixed(3)}`,
|
|
`VECTOR ${obsInt(100,999)}-${obsInt(10,99)}`
|
|
])
|
|
), 4200);
|
|
}
|
|
if (effect === 'diagnostic') {
|
|
const bars = Array.from({length: obsInt(5,9)}, (_,i) =>
|
|
`<rect x="${270+i*44}" y="${obsInt(250,580)}" width="30" height="${obsInt(30,160)}"
|
|
fill="var(--secondary-accent)" opacity="${obsRand(.25,.85).toFixed(2)}"/>`).join('');
|
|
return spawnObservationTransient(`<g class="obs-ambient-glow">${bars}</g>`, 3500);
|
|
}
|
|
if (effect === 'streak') {
|
|
const lines = Array.from({length: obsInt(8,16)}, () => {
|
|
const y=obsInt(160,760), x=obsInt(150,1300), len=obsInt(70,240);
|
|
return `<line x1="${x}" y1="${y}" x2="${x+len}" y2="${y}" stroke="#dbeafe" stroke-width="${obsRand(1,3)}" opacity="${obsRand(.25,.75)}"/>`;
|
|
}).join('');
|
|
return spawnObservationTransient(`<g style="animation:obs-slide-horizontal-wide 2.5s ease-out both">${lines}</g>`, 3000);
|
|
}
|
|
return spawnObservationTransient(obsExpandingRing(obsInt(420,1180),obsInt(250,680),'var(--primary-accent)',obsInt(45,90)), 3200);
|
|
}
|
|
|
|
function generateWhataverseActivity(power) {
|
|
const effect = obsPick(['echo','glyphs','coordinate','warp','rings']);
|
|
if (effect === 'echo') {
|
|
const x=obsInt(430,1170), y=obsInt(270,650);
|
|
return spawnObservationTransient(`
|
|
${[0,1,2].map(i=>`
|
|
<circle cx="${x+i*28}" cy="${y+i*18}" r="${50+i*25}" fill="none" stroke="${i===1?'#d4af37':'#00e5ff'}"
|
|
stroke-width="${3-i*.5}" opacity="${.7-i*.2}"
|
|
style="transform-box:fill-box;transform-origin:center;animation:obs-transient-ring ${2.5+i*.7}s ease-out forwards"/>
|
|
`).join('')}
|
|
`, 4700);
|
|
}
|
|
if (effect === 'glyphs') {
|
|
const glyphs = Array.from({length: obsInt(4,9)}, (_,i) =>
|
|
`<text x="${obsInt(180,1420)}" y="${obsInt(200,720)}" fill="${i%2?'#00e5ff':'#d4af37'}"
|
|
font-family="monospace" font-size="${obsInt(18,42)}" opacity="${obsRand(.3,.8)}"
|
|
style="animation:obs-drift-up ${obsRand(3.5,7).toFixed(1)}s ease-out forwards">${obsPick(['◉','◎','⌁','∆','∴','∞','⊙','⊛'])}</text>`
|
|
).join('');
|
|
return spawnObservationTransient(glyphs, 6500);
|
|
}
|
|
if (effect === 'coordinate') {
|
|
const pos = obsExhibitCardPosition(290, 82);
|
|
return spawnObservationTransient(obsTextCard(
|
|
pos.x, pos.y, 'TEMPORAL COORDINATE',
|
|
`${obsInt(0,99)}:${obsInt(0,99)}:${obsInt(0,99)} ∴ ${obsInt(1,13)}.${obsInt(0,9)}`, '#00e5ff'
|
|
), 4300);
|
|
}
|
|
if (effect === 'warp') {
|
|
return spawnObservationTransient(`
|
|
<g transform="translate(800 450)" opacity=".75">
|
|
<ellipse rx="210" ry="70" fill="none" stroke="#00e5ff" stroke-width="5" class="obs-spin"/>
|
|
<ellipse rx="330" ry="115" fill="none" stroke="#d4af37" stroke-width="3" class="obs-spin-rev"/>
|
|
<circle r="35" fill="#00e5ff" opacity=".28" class="obs-pulse"/>
|
|
</g>
|
|
`, 4400);
|
|
}
|
|
return spawnObservationTransient(obsExpandingRing(800,450,obsPick(['#00e5ff','#d4af37']),obsInt(60,120)), 3500);
|
|
}
|
|
|
|
function generateIndustrialActivity(power) {
|
|
// v13co: every industrial transient is clipped to ONE feed's picture area.
|
|
// In v12 these drew in raw screen space, so steam puffs and signal bars
|
|
// spilled across bezels and the console furniture.
|
|
const feeds = [
|
|
{ix:290, iy:259, id:'CAM 01', where:'CARGO BAY 2'},
|
|
{ix:825, iy:259, id:'CAM 02', where:'REACTOR ACCESS'},
|
|
{ix:290, iy:483, id:'CAM 03', where:'AIRLOCK C'},
|
|
{ix:825, iy:483, id:'CAM 04', where:'MACHINE CORRIDOR'}
|
|
];
|
|
const f = obsPick(feeds);
|
|
const W = 485, H = 189;
|
|
const clipId = `indFx-${Math.random().toString(36).slice(2, 9)}`;
|
|
const inFeed = (body) => `
|
|
<defs>
|
|
<clipPath id="${clipId}"><rect x="${f.ix}" y="${f.iy}" width="${W}" height="${H}"/></clipPath>
|
|
<radialGradient id="indVapour" cx="50%" cy="50%" r="50%">
|
|
<stop offset="0%" stop-color="#efe2cf" stop-opacity=".5"/>
|
|
<stop offset="55%" stop-color="#dcc9ad" stop-opacity=".22"/>
|
|
<stop offset="100%" stop-color="#c9b596" stop-opacity="0"/>
|
|
</radialGradient>
|
|
</defs>
|
|
<g clip-path="url(#${clipId})"><g transform="translate(${f.ix} ${f.iy})">${body}</g></g>`;
|
|
|
|
const canCycle = (f.id === 'CAM 01' || f.id === 'CAM 03');
|
|
const effect = obsPick(['signal', 'dropout', 'vapour', 'motion', 'gain', 'warning']
|
|
.concat(canCycle ? ['door'] : []));
|
|
|
|
// Digital break-up: displaced scan blocks plus a recalibration caption.
|
|
if (effect === 'signal') {
|
|
const blocks = Array.from({length: obsInt(9, 15)}, () => {
|
|
const by = obsInt(0, H - 14);
|
|
return `<rect x="${obsInt(-18, 18)}" y="${by}" width="${W}" height="${obsInt(3, 11)}"
|
|
fill="#ffaa00" opacity="${obsRand(.1, .34).toFixed(2)}"/>`;
|
|
}).join('');
|
|
return spawnObservationTransient(inFeed(`
|
|
<g opacity=".07"><rect width="${W}" height="${H}" fill="#ffffff" class="obs-flicker"/></g>
|
|
${blocks}
|
|
<rect y="${H / 2 - 20}" width="${W}" height="40" fill="#000000" opacity=".5"/>
|
|
<text x="${W / 2}" y="${H / 2 + 7}" text-anchor="middle" fill="#ffaa00"
|
|
font-size="15" font-family="monospace">SIGNAL RECALIBRATION</text>
|
|
`), 3600);
|
|
}
|
|
|
|
// Total feed loss, then reacquire.
|
|
if (effect === 'dropout') {
|
|
return spawnObservationTransient(inFeed(`
|
|
<rect width="${W}" height="${H}" fill="#050505" opacity=".92"/>
|
|
<g class="obs-flicker">
|
|
<text x="${W / 2}" y="${H / 2 - 4}" text-anchor="middle" fill="#ef4444"
|
|
font-size="17" font-family="monospace">NO SIGNAL</text>
|
|
<text x="${W / 2}" y="${H / 2 + 22}" text-anchor="middle" fill="#8a6a42"
|
|
font-size="11" font-family="monospace">${f.id} // REACQUIRING</text>
|
|
</g>
|
|
`), 2600);
|
|
}
|
|
|
|
// A vent release inside the room the camera is actually looking at.
|
|
if (effect === 'vapour') {
|
|
const x = obsInt(90, W - 90), y = H - obsInt(16, 40);
|
|
const puffs = Array.from({length: obsInt(6, 11)}, (_, i) => {
|
|
const dur = obsRand(2.8, 5.2).toFixed(1);
|
|
return `<circle cx="${x + obsInt(-26, 26)}" cy="${y}" r="${obsInt(12, 30)}" fill="url(#indVapour)" opacity="0"
|
|
style="--vx:${obsInt(-30, 30)}px;animation:obs-vapour ${dur}s ease-out forwards;animation-delay:${(i * 0.22).toFixed(2)}s"/>`;
|
|
}).join('');
|
|
return spawnObservationTransient(inFeed(`
|
|
${puffs}
|
|
<rect x="${x - 22}" y="${y - 3}" width="44" height="6" rx="2" fill="#2b2118"/>
|
|
`), 6200);
|
|
}
|
|
|
|
// Motion detection bracket that tracks across the picture.
|
|
if (effect === 'motion') {
|
|
const bx = obsInt(40, W - 190), by = obsInt(46, H - 130);
|
|
return spawnObservationTransient(inFeed(`
|
|
<g style="animation:obs-slide-horizontal 4.2s ease-in-out both">
|
|
<g transform="translate(${bx} ${by})" stroke="#ff3b30" stroke-width="3" fill="none">
|
|
<path d="M0 14V0h14M104 0h14v14M118 56v14h-14M14 70H0V56"/>
|
|
<rect width="118" height="70" opacity=".14" fill="#ff3b30" stroke="none"/>
|
|
</g>
|
|
</g>
|
|
<rect y="${H - 46}" width="188" height="20" fill="#7f1d1d" opacity=".8"/>
|
|
<text x="10" y="${H - 31}" fill="#ffe4e6" font-family="monospace" font-size="11">MOTION // ${f.id}</text>
|
|
`), 4600);
|
|
}
|
|
|
|
// Pressure-door cycle seen head on.
|
|
if (effect === 'door') {
|
|
const mid = W / 2, top = 58, bot = H - 52;
|
|
return spawnObservationTransient(inFeed(`
|
|
<g opacity=".72">
|
|
<rect x="${mid - 84}" y="${top}" width="80" height="${bot - top}" fill="#1d160f" stroke="#7c4a16" stroke-width="2">
|
|
<animate attributeName="width" values="80;10;80" dur="4.2s" repeatCount="1" fill="freeze"/>
|
|
</rect>
|
|
<rect x="${mid + 4}" y="${top}" width="80" height="${bot - top}" fill="#1d160f" stroke="#7c4a16" stroke-width="2">
|
|
<animate attributeName="x" values="${mid + 4};${mid + 74};${mid + 4}" dur="4.2s" repeatCount="1" fill="freeze"/>
|
|
<animate attributeName="width" values="80;10;80" dur="4.2s" repeatCount="1" fill="freeze"/>
|
|
</rect>
|
|
</g>
|
|
<rect x="${mid - 84}" y="${top - 7}" width="168" height="6" fill="#b8860f" opacity=".55"/>
|
|
<text x="${mid}" y="${bot + 19}" text-anchor="middle" fill="#ffaa00"
|
|
font-family="monospace" font-size="11">HATCH CYCLE // ${f.where}</text>
|
|
`), 4800);
|
|
}
|
|
|
|
// Auto-iris hunting after a lighting change.
|
|
if (effect === 'gain') {
|
|
return spawnObservationTransient(inFeed(`
|
|
<rect width="${W}" height="${H}" fill="#ffe0b0"
|
|
style="animation:obs-feed-agc 1.9s ease-out forwards"/>
|
|
<text x="12" y="${H - 30}" fill="#c8964a" font-family="monospace" font-size="11">AGC // IRIS ADJUST</text>
|
|
`), 2400);
|
|
}
|
|
|
|
// Plant readout card — stays on the console rail, not over a feed.
|
|
const pos = obsExhibitCardPosition(290, 82);
|
|
return spawnObservationTransient(obsTextCard(
|
|
pos.x, pos.y,
|
|
obsPick(['PRESSURE TRANSIENT', 'COOLANT LOOP', 'MAINTENANCE BUS', 'LOCAL POWER', 'GANTRY INTERLOCK']),
|
|
obsPick([
|
|
`P-${obsInt(1, 9)} ${obsInt(30, 98)}%`,
|
|
`FLOW ${obsInt(120, 980)} L/M`,
|
|
`BUS ${obsHex(obsInt(100, 999))} NOMINAL`,
|
|
`VALVE ${obsInt(1, 44)} CYCLING`,
|
|
`HOIST ${obsInt(1, 4)} LOAD ${obsRand(.4, 4.2).toFixed(1)} T`
|
|
]),
|
|
'#ffaa00'
|
|
), 4500);
|
|
}
|
|
|
|
function generateBioshipActivity(power) {
|
|
const effect = obsPick(['neural','spores','ripple','tendril','organ','metric']);
|
|
if (effect === 'neural') {
|
|
const y1=obsInt(260,650), y2=obsInt(260,650);
|
|
return spawnObservationTransient(`
|
|
<path d="M210 ${y1} C450 ${obsInt(190,720)} 650 ${obsInt(190,720)} 800 450 S1160 ${obsInt(190,720)} 1400 ${y2}"
|
|
fill="none" stroke="#a7f3d0" stroke-width="${3+power*4}" stroke-linecap="round" class="obs-dashflow"
|
|
opacity=".8"/>
|
|
`, 3300);
|
|
}
|
|
if (effect === 'spores') {
|
|
const spores=Array.from({length:obsInt(10,24)},()=>`
|
|
<circle cx="${obsInt(260,1340)}" cy="${obsInt(260,700)}" r="${obsRand(2,8).toFixed(1)}"
|
|
fill="${obsPick(['#a7f3d0','#10b981','#8b5cf6'])}" opacity="${obsRand(.25,.8).toFixed(2)}"
|
|
style="animation:obs-drift-up ${obsRand(3,7).toFixed(1)}s ease-out forwards"/>`).join('');
|
|
return spawnObservationTransient(spores, 7000);
|
|
}
|
|
if (effect === 'tendril') {
|
|
const sx=obsPick([180,1420]), sy=obsInt(300,660);
|
|
return spawnObservationTransient(`
|
|
<path d="M${sx} ${sy} C${obsInt(350,650)} ${obsInt(200,700)} ${obsInt(950,1250)} ${obsInt(200,700)} 800 450"
|
|
fill="none" stroke="#10b981" stroke-width="${obsInt(5,12)}" opacity=".45"
|
|
stroke-linecap="round" class="obs-route-wander"/>
|
|
`, 5200);
|
|
}
|
|
if (effect === 'organ') {
|
|
return spawnObservationTransient(`
|
|
<g transform="translate(${obsInt(500,1100)} ${obsInt(340,580)})">
|
|
<g style="transform-box:fill-box;transform-origin:center;animation:obs-transient-pop 1.2s ease-out both">
|
|
<ellipse rx="${obsInt(45,95)}" ry="${obsInt(70,150)}" fill="#8b5cf6" opacity=".14" stroke="#10b981" stroke-width="5" class="obs-organic-undulate"/>
|
|
<ellipse rx="${obsInt(18,35)}" ry="${obsInt(35,70)}" fill="#10b981" opacity=".5" class="obs-pulse"/>
|
|
</g>
|
|
</g>
|
|
`, 5000);
|
|
}
|
|
if (effect === 'metric') {
|
|
const pos = obsExhibitCardPosition(290, 82);
|
|
return spawnObservationTransient(obsTextCard(
|
|
pos.x, pos.y, obsPick(['NEURAL RESPONSE','METABOLIC FIELD','SYMBIOTIC VARIANCE','BIOELECTRIC FLUX']),
|
|
`${obsRand(0.1,9.9).toFixed(2)} // ${obsInt(20,99)}%`, '#10b981'
|
|
), 4300);
|
|
}
|
|
return spawnObservationTransient(obsExpandingRing(800,470,obsPick(['#10b981','#8b5cf6','#a7f3d0']),obsInt(55,110)), 3700);
|
|
}
|
|
|
|
function generateRetroActivity(power) {
|
|
const effect = obsPick(['blip','scope','counter','vector','bloom','reel']);
|
|
if (effect === 'blip') {
|
|
return spawnObservationTransient(
|
|
obsContact(obsInt(470,1130),obsInt(260,660),'#86efac',`OBJ ${obsInt(1,99)}`), 4300
|
|
);
|
|
}
|
|
if (effect === 'scope') {
|
|
const y=obsInt(560,720);
|
|
return spawnObservationTransient(`
|
|
<path d="M130 ${y} ${Array.from({length:14},(_,i)=>`L${180+i*95} ${y+obsInt(-70,70)}`).join(' ')}"
|
|
fill="none" stroke="#22c55e" stroke-width="4" opacity=".78" class="obs-dashflow"/>
|
|
`, 4500);
|
|
}
|
|
if (effect === 'counter') {
|
|
const pos = obsExhibitCardPosition(290, 82);
|
|
return spawnObservationTransient(obsTextCard(
|
|
pos.x, pos.y, obsPick(['ASTROGATOR','VECTOR MEMORY','TAPE INDEX','OBJECT FILE']),
|
|
`${obsInt(0,9999).toString().padStart(4,'0')} / ${obsInt(0,9999).toString().padStart(4,'0')}`,'#22c55e'
|
|
),4300);
|
|
}
|
|
if (effect === 'reel') {
|
|
return spawnObservationTransient(`
|
|
<g transform="translate(${obsPick([260,1340])} ${obsInt(350,580)})">
|
|
<circle r="62" fill="none" stroke="#22c55e" stroke-width="6" opacity=".55" class="obs-spin"/>
|
|
<circle r="22" fill="none" stroke="#86efac" stroke-width="5" stroke-dasharray="8 8" class="obs-spin-rev"/>
|
|
</g>`, 4800);
|
|
}
|
|
if (effect === 'bloom') {
|
|
return spawnObservationTransient(obsExpandingRing(obsInt(480,1120),obsInt(270,650),'#86efac',obsInt(35,75)),3000);
|
|
}
|
|
return spawnObservationTransient(`
|
|
<path d="M${obsInt(120,300)} ${obsInt(650,760)} Q800 ${obsInt(180,360)} ${obsInt(1300,1490)} ${obsInt(250,660)}"
|
|
fill="none" stroke="#22c55e" stroke-width="3" opacity=".7" class="obs-route-wander"/>
|
|
`, 5200);
|
|
}
|
|
|
|
function generateMilitaryActivity(power) {
|
|
const effect = obsPick(['contact','intercept','formation','sector','status','sweep']);
|
|
if (effect === 'contact') {
|
|
const pos = obsExhibitCardPosition(320, 82);
|
|
return spawnObservationTransient(
|
|
obsContact(obsInt(430,1170),obsInt(240,680),'#eab308',obsPick(['UNKNOWN','TRACK','CONTACT','RETURN'])) +
|
|
obsTextCard(pos.x,pos.y,'TACTICAL UPDATE',`TRACK ${obsHex(obsInt(1,4095))} // ${obsInt(2,98)}%`,'#eab308',320),
|
|
4600
|
|
);
|
|
}
|
|
if (effect === 'intercept') {
|
|
const x=obsInt(450,1150),y=obsInt(250,650);
|
|
return spawnObservationTransient(`
|
|
<path d="M800 470 Q${obsInt(600,1000)} ${obsInt(180,700)} ${x} ${y}"
|
|
fill="none" stroke="#eab308" stroke-width="4" stroke-dasharray="15 10" class="obs-route-wander"/>
|
|
${obsContact(x,y,'#ef4444','VECTOR')}
|
|
`, 4700);
|
|
}
|
|
if (effect === 'formation') {
|
|
const cx=obsInt(600,1000), cy=obsInt(340,600);
|
|
return spawnObservationTransient(`
|
|
<g transform="translate(${cx} ${cy})" fill="#eab308" opacity=".75">
|
|
<g style="animation:obs-slide-horizontal 5s ease-in-out both">
|
|
<polygon points="0,-14 12,12 -12,12"/>
|
|
<polygon points="-55,30 -43,56 -67,56"/>
|
|
<polygon points="55,30 67,56 43,56"/>
|
|
<polygon points="0,70 12,96 -12,96"/>
|
|
</g>
|
|
</g>`, 5200);
|
|
}
|
|
if (effect === 'sector') {
|
|
return spawnObservationTransient(`
|
|
<path d="M800 470L800 170A300 300 0 0 1 ${obsInt(1020,1110)} ${obsInt(250,390)}Z"
|
|
fill="${obsPick(['#eab308','#ef4444'])}" opacity=".12" class="obs-pulse"/>
|
|
`, 3800);
|
|
}
|
|
if (effect === 'status') {
|
|
const pos = obsExhibitCardPosition(290, 82);
|
|
return spawnObservationTransient(obsTextCard(
|
|
pos.x,pos.y,obsPick(['TACTICAL ARRAY','TRACK CORRELATION','FORMATION UPDATE','IFF QUERY']),
|
|
obsPick([`SECTOR ${obsInt(1,12)} / CLEAR`,`RETURN ${obsHex(obsInt(1,4095))}`,`VECTOR ${obsInt(0,359)}.${obsInt(0,9)}°`,`CONF ${obsInt(34,99)}%`]),'#eab308'
|
|
),4200);
|
|
}
|
|
return spawnObservationTransient(obsExpandingRing(800,470,'#eab308',obsInt(65,130)),3500);
|
|
}
|
|
|
|
function generateDeepSpaceActivity(power) {
|
|
const effect = obsPick(['anomaly','comet','lens','spectral','planet','signal']);
|
|
if (effect === 'anomaly') {
|
|
const x=obsInt(430,1180), y=obsInt(230,650);
|
|
return spawnObservationTransient(`
|
|
<g transform="translate(${x} ${y})">
|
|
<circle r="18" fill="#000"/>
|
|
<circle r="45" fill="none" stroke="#818cf8" stroke-width="4" opacity=".65" class="obs-spin"/>
|
|
<ellipse rx="120" ry="34" fill="none" stroke="#38bdf8" stroke-width="3" opacity=".35" class="obs-spin-rev"/>
|
|
<circle r="145" fill="#6366f1" opacity=".04" class="obs-pulse"/>
|
|
</g>
|
|
`, 6500);
|
|
}
|
|
if (effect === 'comet') {
|
|
return spawnObservationTransient(`
|
|
<g style="animation:obs-comet-drift 5s linear forwards">
|
|
<path d="M0 0L-260 60" stroke="#eef2ff" stroke-width="3" opacity=".6"/>
|
|
<circle r="6" fill="#fff"/>
|
|
</g>`, 5200);
|
|
}
|
|
if (effect === 'lens') {
|
|
return spawnObservationTransient(`
|
|
<g transform="translate(${obsInt(500,1100)} ${obsInt(300,580)})" opacity=".55">
|
|
<ellipse rx="210" ry="70" fill="none" stroke="#6366f1" stroke-width="5" class="obs-spin-slow"/>
|
|
<ellipse rx="130" ry="42" fill="none" stroke="#38bdf8" stroke-width="3" class="obs-spin-rev"/>
|
|
</g>`, 6800);
|
|
}
|
|
if (effect === 'planet') {
|
|
const y=obsInt(260,620), r=obsInt(45,110);
|
|
return spawnObservationTransient(`
|
|
<g transform="translate(0 ${y})">
|
|
<g style="animation:obs-slide-horizontal-wide 10s linear forwards">
|
|
<circle cx="180" cy="0" r="${r}" fill="#312e81" opacity=".45"/>
|
|
<path d="M${180-r} 0A${r} ${r} 0 0 1 ${180+r} -10" fill="none" stroke="#818cf8" stroke-width="3" opacity=".5"/>
|
|
</g>
|
|
</g>`, 10000);
|
|
}
|
|
if (effect === 'spectral') {
|
|
const pos = obsExhibitCardPosition(290, 82);
|
|
return spawnObservationTransient(obsTextCard(
|
|
pos.x,pos.y,obsPick(['SPECTRAL EVENT','GRAVITIC VARIANCE','DEEP FIELD RETURN','OPTICAL TRANSIENT']),
|
|
`${obsRand(.001,9.999).toFixed(3)} / ${obsInt(1,999)} ly`,'#818cf8'
|
|
),4800);
|
|
}
|
|
return spawnObservationTransient(obsExpandingRing(obsInt(420,1180),obsInt(240,660),'#38bdf8',obsInt(45,95)),4000);
|
|
}
|
|
|
|
function generateOutlawActivity(power) {
|
|
const effect = obsPick(['glitch','route','contact','signal','gauge','rear']);
|
|
if (effect === 'glitch') {
|
|
const bars=Array.from({length:obsInt(8,18)},()=>`
|
|
<rect x="${obsInt(80,1300)}" y="${obsInt(160,760)}" width="${obsInt(80,420)}" height="${obsInt(3,18)}"
|
|
fill="${obsPick(['#ec4899','#f59e0b','#8b5cf6'])}" opacity="${obsRand(.08,.45)}"/>`).join('');
|
|
return spawnObservationTransient(`<g class="obs-flicker">${bars}</g>`,3000);
|
|
}
|
|
if (effect === 'route') {
|
|
return spawnObservationTransient(`
|
|
<path d="M120 ${obsInt(600,760)} Q${obsInt(350,620)} ${obsInt(200,690)} 800 ${obsInt(300,620)} T1480 ${obsInt(180,700)}"
|
|
fill="none" stroke="#f59e0b" stroke-width="5" stroke-dasharray="20 12" class="obs-route-wander"/>
|
|
<text x="1240" y="${obsInt(250,650)}" fill="#f472b6" font-size="16" font-family="monospace">ROUTE RECALC...</text>
|
|
`,5600);
|
|
}
|
|
if (effect === 'contact' || effect === 'rear') {
|
|
return spawnObservationTransient(obsContact(
|
|
obsInt(430,1170),obsInt(240,670),obsPick(['#ec4899','#f59e0b']),'UNKNOWN'
|
|
),4200);
|
|
}
|
|
if (effect === 'signal') {
|
|
const pos = obsExhibitCardPosition(290, 82);
|
|
return spawnObservationTransient(obsTextCard(
|
|
pos.x,pos.y,obsPick(['TRANSPONDER HIT','SIGNAL QUALITY','NAV PATCH','RADIO BURST']),
|
|
`${obsInt(9,99)}% // ${obsHex(obsInt(1,4095))}`,'#ec4899'
|
|
),4300);
|
|
}
|
|
return spawnObservationTransient(`
|
|
<g transform="translate(${obsPick([290,1310])} ${obsInt(360,580)})">
|
|
<path d="M-80 70A100 100 0 0 1 80 70" fill="none" stroke="#f59e0b" stroke-width="6" opacity=".6"/>
|
|
<line x1="0" y1="70" x2="${obsInt(-70,70)}" y2="${obsInt(-5,55)}" stroke="#ec4899" stroke-width="5"/>
|
|
</g>`,4500);
|
|
}
|
|
|
|
function generateStationActivity(power) {
|
|
const effect = obsPick(['dock','depart','guidance','traffic','beacon','queue']);
|
|
if (effect === 'dock') {
|
|
const y = obsInt(250, 520);
|
|
return spawnObservationTransient(`
|
|
<path d="M260 ${y} C520 ${y-85} 780 ${y+50} 1160 ${y-22}"
|
|
fill="none" stroke="#38bdf8" stroke-width="3.5" stroke-dasharray="18 12" class="obs-route-wander"/>
|
|
<polygon points="1132,${y-35} 1166,${y-22} 1132,${y-9}" fill="#f97316"/>
|
|
<circle cx="1170" cy="${y-22}" r="6" fill="#fdba74" class="obs-blink"/>
|
|
<text x="972" y="${y-52}" fill="#fdba74" font-size="16" font-family="monospace">DOCK VECTOR ${obsInt(1,18)}</text>
|
|
`, 6200);
|
|
}
|
|
if (effect === 'depart') {
|
|
const y = obsInt(235, 540);
|
|
return spawnObservationTransient(`
|
|
<g transform="translate(0 ${y})">
|
|
<g style="animation:obs-slide-horizontal-wide 8s linear forwards">
|
|
<g transform="translate(260 0)">
|
|
<path d="M0 0L30 10L0 20L8 10Z" fill="#e0f2fe"/>
|
|
<path d="M-25 10H6" stroke="#38bdf8" stroke-width="2"/>
|
|
<circle cx="-32" cy="10" r="4" fill="#f97316" class="obs-blink"/>
|
|
</g>
|
|
</g>
|
|
</g>`, 7000);
|
|
}
|
|
if (effect === 'guidance') {
|
|
const x = obsInt(470, 1100), y = obsInt(230, 570);
|
|
return spawnObservationTransient(`
|
|
<g transform="translate(${x} ${y})">
|
|
<circle r="24" fill="none" stroke="#38bdf8" stroke-width="3" class="obs-pulse"/>
|
|
<circle r="48" fill="none" stroke="#7dd3fc" stroke-width="2" opacity=".55" class="obs-pulse" style="animation-delay:-1.1s"/>
|
|
<path d="M-80 0H80M0-80V80" stroke="#38bdf8" stroke-width="2" opacity=".42"/>
|
|
</g>`, 4600);
|
|
}
|
|
if (effect === 'beacon') {
|
|
const x = obsPick([248, 1352]);
|
|
const y = obsPick([168, 648]);
|
|
return spawnObservationTransient(`
|
|
<g transform="translate(${x} ${y})">
|
|
<circle r="12" fill="#f97316" opacity=".42" class="obs-pulse"/>
|
|
<circle r="34" fill="none" stroke="#fdba74" stroke-width="3"
|
|
style="transform-box:fill-box;transform-origin:center;animation:obs-transient-ring 3.2s ease-out forwards"/>
|
|
</g>`, 3600);
|
|
}
|
|
if (effect === 'queue') {
|
|
const pos = obsExhibitCardPosition(310, 82);
|
|
return spawnObservationTransient(obsTextCard(
|
|
pos.x, pos.y,
|
|
obsPick(['TRAFFIC CONTROL','DOCK STATUS','PORT AUTHORITY','BERTH SCHEDULER']),
|
|
obsPick([
|
|
`QUEUE ${obsInt(2,18)} // CLEARANCE`,
|
|
`BAY ${obsInt(1,24)} // ARRIVAL WINDOW`,
|
|
`HOLD ARC ${obsRand(0.8,9.9).toFixed(1)} // ROUTING`,
|
|
`${obsInt(2,84)} OBJECTS // PORT FLOW`
|
|
]),
|
|
'#38bdf8', 310
|
|
), 4600);
|
|
}
|
|
const x = obsInt(330, 1270), y = obsInt(205, 610);
|
|
return spawnObservationTransient(`
|
|
<path d="M${x-140} ${y+45} C${x-60} ${y-35} ${x+70} ${y+30} ${x+150} ${y-15}"
|
|
fill="none" stroke="#0ea5e9" stroke-width="2.5" stroke-dasharray="10 9" class="obs-route-wander"/>
|
|
<text x="${x-8}" y="${y-22}" fill="#7dd3fc" font-size="15" font-family="monospace">TRAFFIC FLOW ${obsInt(1,9)}</text>
|
|
`, 5200);
|
|
}
|
|
|
|
function generateComedyActivity(power) {
|
|
const effect = obsPick(['route','oddity','planet','status','contact','geometry']);
|
|
if (effect === 'route') {
|
|
return spawnObservationTransient(`
|
|
<path d="M120 690 C360 ${obsInt(160,720)} 640 ${obsInt(160,720)} 800 450 S1190 ${obsInt(160,720)} 1460 ${obsInt(180,690)}"
|
|
fill="none" stroke="#fbbf24" stroke-width="5" stroke-dasharray="18 12" class="obs-route-wander"/>
|
|
`,6000);
|
|
}
|
|
if (effect === 'oddity') {
|
|
const pos = obsExhibitCardPosition(330, 82);
|
|
return spawnObservationTransient(obsTextCard(
|
|
pos.x,pos.y,
|
|
obsPick(['NAVIGATION UPDATE','ENVIRONMENTAL NOTE','SERVICE MESSAGE','SENSOR CONCLUSION']),
|
|
obsPick([
|
|
`ROUTE CONFIDENCE ${obsRand(96,103).toFixed(1)}%`,
|
|
`TEA PROBABILITY ${obsInt(12,99)}%`,
|
|
`MOSTLY NOMINAL`,
|
|
`OBJECT: PROBABLY HARMLESS`,
|
|
`PANIC INDEX ${obsInt(0,3)} / 10`,
|
|
`SCENIC DETOUR +${obsInt(2,48)} MIN`
|
|
]),'#06b6d4',330
|
|
),5000);
|
|
}
|
|
if (effect === 'planet') {
|
|
return spawnObservationTransient(`
|
|
<g transform="translate(0 ${obsInt(300,600)})">
|
|
<g style="animation:obs-slide-horizontal-wide 9s linear forwards">
|
|
<circle cx="180" cy="0" r="${obsInt(35,90)}" fill="${obsPick(['#fbbf24','#06b6d4','#f43f5e'])}" opacity=".32"/>
|
|
<circle cx="180" cy="0" r="${obsInt(18,35)}" fill="#fff" opacity=".08"/>
|
|
</g>
|
|
</g>`,9200);
|
|
}
|
|
if (effect === 'geometry') {
|
|
const sides=obsPick([3,4,5,6]);
|
|
const r=obsInt(55,110), cx=obsInt(500,1100),cy=obsInt(300,620);
|
|
const pts=Array.from({length:sides},(_,i)=>{
|
|
const a=-Math.PI/2+i*Math.PI*2/sides;
|
|
return `${(cx+Math.cos(a)*r).toFixed(1)},${(cy+Math.sin(a)*r).toFixed(1)}`;
|
|
}).join(' ');
|
|
return spawnObservationTransient(`<polygon points="${pts}" fill="none" stroke="#f43f5e" stroke-width="5" class="obs-spin"/>`,5200);
|
|
}
|
|
if (effect === 'contact') {
|
|
return spawnObservationTransient(obsContact(obsInt(450,1150),obsInt(250,650),'#67e8f9',obsPick(['FRIENDLY?','THING','OBJECT','PROBABLY FINE'])),4300);
|
|
}
|
|
return spawnObservationTransient(obsExpandingRing(obsInt(450,1150),obsInt(250,650),obsPick(['#06b6d4','#f43f5e','#fbbf24']),obsInt(45,100)),3500);
|
|
}
|
|
|
|
function triggerObservationActivity(source = 'ambient') {
|
|
if (!observationActive || observationActivity <= 0.001) return;
|
|
|
|
// Telemetry is a synchronization pulse, but the slider controls whether
|
|
// that pulse becomes visible. At maximum every telemetry pulse produces
|
|
// a visible response; at low levels many pass quietly.
|
|
if (source === 'telemetry') {
|
|
const responseChance = 0.18 + observationActivity * 0.82;
|
|
if (Math.random() > responseChance) return;
|
|
}
|
|
|
|
let count = 1;
|
|
if (source === 'telemetry' && observationActivity > .55 && Math.random() < observationActivity) count++;
|
|
if (observationActivity > .82 && Math.random() < (observationActivity - .7)) count++;
|
|
|
|
const generator = {
|
|
starfleet: generateStarflightActivity,
|
|
whoniverse: generateWhataverseActivity,
|
|
industrial: generateIndustrialActivity,
|
|
bioships: generateBioshipActivity,
|
|
retrofuture: generateRetroActivity,
|
|
military: generateMilitaryActivity,
|
|
deepspace: generateDeepSpaceActivity,
|
|
outlaw: generateOutlawActivity,
|
|
spacestations: generateStationActivity,
|
|
comedy: generateComedyActivity
|
|
}[activeUniverseId] || generateStarflightActivity;
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
window.setTimeout(() => generator(observationActivity), i * obsInt(120,380));
|
|
}
|
|
}
|
|
|
|
function scheduleObservationAmbientActivity() {
|
|
if (observationAmbientTimer) {
|
|
clearTimeout(observationAmbientTimer);
|
|
observationAmbientTimer = null;
|
|
}
|
|
if (!observationActive || observationActivity <= .01) return;
|
|
|
|
// Quiet = occasional subtle life. Active = a busy generative display.
|
|
const slow = 12500;
|
|
const fast = 1500;
|
|
const base = slow - (slow - fast) * Math.pow(observationActivity, .82);
|
|
const jitter = base * obsRand(.25,.75);
|
|
|
|
observationAmbientTimer = window.setTimeout(() => {
|
|
triggerObservationActivity('ambient');
|
|
scheduleObservationAmbientActivity();
|
|
}, base + jitter);
|
|
}
|
|
|
|
function setObservationActivity(raw) {
|
|
observationActivity = obsClamp(Number(raw) / 100, 0, 1);
|
|
window.observationActivity = observationActivity;
|
|
observationOverlay.style.setProperty('--obs-activity', String(observationActivity));
|
|
const pct = Math.round(observationActivity * 100);
|
|
valObservationActivity.textContent = `${pct}%`;
|
|
if (observationActive) {
|
|
observationStatus.innerHTML =
|
|
`OBSERVATION ACTIVE<br>PROCEDURAL AUDIO LINK: ${isPlaying ? 'ONLINE' : 'STANDBY'}<br>VISUAL ACTIVITY: ${pct}%`;
|
|
scheduleObservationAmbientActivity();
|
|
}
|
|
}
|
|
|
|
function resetObservationReturnFade() {
|
|
if (!observationReturn) return;
|
|
|
|
observationReturn.style.transition = 'none';
|
|
observationReturn.classList.remove('observation-return-fade');
|
|
void observationReturn.offsetWidth;
|
|
observationReturn.style.transition = '';
|
|
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
if (observationActive) {
|
|
observationReturn.classList.add('observation-return-fade');
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function resetObservationHudFade() {
|
|
if (!observationChrome) return;
|
|
|
|
// Remove transition momentarily so each OBSERVATION session starts fully visible.
|
|
observationChrome.style.transition = 'none';
|
|
observationChrome.classList.remove('observation-hud-fade');
|
|
void observationChrome.offsetWidth;
|
|
observationChrome.style.transition = '';
|
|
|
|
if (!toggleObservationHud.checked) {
|
|
// Start the thirty-second fade on the next frame.
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
if (observationActive && !toggleObservationHud.checked) {
|
|
observationChrome.classList.add('observation-hud-fade');
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
function updateObservationHudMode() {
|
|
const hold = toggleObservationHud.checked;
|
|
valObservationHud.textContent = hold ? 'ON' : 'FADE';
|
|
|
|
if (!observationActive || !observationChrome) return;
|
|
|
|
if (hold) {
|
|
observationChrome.style.transition = 'none';
|
|
observationChrome.classList.remove('observation-hud-fade');
|
|
void observationChrome.offsetWidth;
|
|
observationChrome.style.transition = '';
|
|
} else {
|
|
resetObservationHudFade();
|
|
}
|
|
}
|
|
|
|
toggleObservationHud.addEventListener('change', updateObservationHudMode);
|
|
updateObservationHudMode();
|
|
|
|
sliderObservationActivity.addEventListener('input', () => {
|
|
setObservationActivity(sliderObservationActivity.value);
|
|
if (observationActive) scheduleObservationInstrumentTick();
|
|
});
|
|
|
|
window.addEventListener('scifi-telemetry-activity', (event) => {
|
|
if (!observationActive) return;
|
|
triggerObservationActivity('telemetry');
|
|
nudgeObservationInstruments(event.detail ? event.detail.density : null);
|
|
pulseObservationScene();
|
|
pulseObservationSplineMorphs();
|
|
});
|
|
|
|
setObservationActivity(sliderObservationActivity.value);
|
|
|
|
|
|
// =========================================================================
|
|
// LIVE OBSERVATION INSTRUMENTATION
|
|
// The fixed OBSERVATION chrome is intentionally outside this system.
|
|
// Only fantasy <text> elements inside the main SVG/event layers participate.
|
|
// =========================================================================
|
|
|
|
let observationInstrumentStates = [];
|
|
let observationInstrumentTimer = null;
|
|
let observationTransientObserver = null;
|
|
|
|
const observationTextVocabulary = {
|
|
starfleet: ['NOMINAL','PASSIVE','SCANNING','CORRELATING','TRACKING','VERIFIED','STABLE'],
|
|
whoniverse: ['VORTEX LOCK','CONVERGING','DRIFTING','RECURSIVE','RESOLVING','TEMPORAL HOLD'],
|
|
industrial: ['SECURE FEED','MONITORING','SIGNAL HOLD','CYCLING','LOCAL CHECK','STABLE'],
|
|
bioships: ['QUIESCENT','RESPONDING','ADAPTING','RESONANT','SYNCHRONIZED','PULSING'],
|
|
retrofuture: ['READY','READ','PROCESS','HOLD','TRACK','STABLE'],
|
|
military: ['PASSIVE','TRACKING','CORRELATING','VERIFIED','WATCH','CLEAR'],
|
|
deepspace: ['DEEP FIELD','OBSERVING','SPECTRAL HOLD','PASSIVE','RESOLVING','STABLE'],
|
|
outlaw: ['MANUAL','INTERMITTENT','PATCHED','SEARCHING','HOLDING','DIRTY LOCK'],
|
|
spacestations: ['PASSIVE WATCH','TRAFFIC HOLD','DOCKING','ROUTING','OBSERVING','CLEAR'],
|
|
comedy: ['PROBABLY FINE','MOSTLY NOMINAL','STILL FINE','SCENIC','TEA ADJACENT','UNNECESSARILY PRECISE']
|
|
};
|
|
|
|
const observationPlainTextVariants = {
|
|
comedy: {
|
|
'TEA SOURCE DETECTED': ['TEA SOURCE DETECTED','TEA SOURCE PROBABLE','TEA SOURCE LOST','BEVERAGE VECTOR ACQUIRED'],
|
|
'NO IMMEDIATE CAUSE FOR PANIC': ['NO IMMEDIATE CAUSE FOR PANIC','STILL NO CAUSE FOR PANIC','PANIC NOT CURRENTLY REQUIRED','SITUATION MOSTLY NORMAL'],
|
|
'SCENIC NAVIGATION': ['SCENIC NAVIGATION','SCENIC RECALCULATION','ROUTE APPRECIATION','NAVIGATION, BUT NICER']
|
|
}
|
|
};
|
|
|
|
function getObservationTelemetryDensity() {
|
|
if (typeof telemetry !== 'undefined' && telemetry && telemetry.params) {
|
|
return obsClamp(Number(telemetry.params.density) || 0, 0, 1);
|
|
}
|
|
const slider = document.getElementById('slider-telemetry-density');
|
|
return slider ? obsClamp(Number(slider.value) || 0, 0, 1) : 0.5;
|
|
}
|
|
|
|
function classifyObservationNumber(source, raw, index) {
|
|
const upper = source.toUpperCase();
|
|
const decimals = raw.includes('.') ? raw.split('.')[1].length : 0;
|
|
const unsignedRaw = raw.replace(/^[-+]/, '');
|
|
const integerPart = unsignedRaw.split('.')[0];
|
|
const leadingZero = integerPart.length > 1 && integerPart.startsWith('0');
|
|
const resolution = Math.pow(10, -decimals);
|
|
const base = Number(raw);
|
|
|
|
let kind = 'generic';
|
|
let min = -Infinity;
|
|
let max = Infinity;
|
|
let circular = false;
|
|
let macroScale = Math.max(resolution * 2, Math.abs(base || 1) * 0.025);
|
|
|
|
const after = source.slice(index + raw.length, index + raw.length + 4);
|
|
const before = source.slice(Math.max(0, index - 24), index).toUpperCase();
|
|
|
|
if (after.includes('%') || upper.includes('QUALITY') || upper.includes('CONFIDENCE') || upper.includes('EFFICIENCY')) {
|
|
kind = 'percent';
|
|
min = 0;
|
|
max = upper.includes('CONFIDENCE') ? 103 : 100;
|
|
macroScale = 8;
|
|
} else if (upper.includes('NAV VECTOR') || upper.includes('BEARING') || upper.includes('HEADING') || upper.includes('VECTOR ')) {
|
|
kind = 'heading';
|
|
min = 0;
|
|
max = 360;
|
|
circular = true;
|
|
macroScale = 18;
|
|
} else if (upper.includes('CAM ') || upper.includes('CONTACT ') || upper.includes('TRACK ') ||
|
|
upper.includes('BAY ') || upper.includes('SECTOR ') || upper.includes('OBJECT ') ||
|
|
before.endsWith('CAM ') || before.endsWith('TRACK ')) {
|
|
kind = 'identifier';
|
|
min = 0;
|
|
max = leadingZero ? Math.pow(10, integerPart.length) - 1 : Math.max(99, base + 24);
|
|
macroScale = Math.max(1, Math.min(4, 1 + base * .02));
|
|
} else if (upper.includes('RANGE') || upper.includes('DIST') || upper.includes(' LY') || upper.includes(' MM')) {
|
|
kind = 'range';
|
|
min = 0;
|
|
max = Math.max(base * 3, base + 1000);
|
|
macroScale = Math.max(resolution * 3, Math.abs(base || 1) * .18);
|
|
} else if (upper.includes('TIME') || upper.includes(':')) {
|
|
kind = 'clockish';
|
|
min = 0;
|
|
max = decimals ? 99.9 : 99;
|
|
macroScale = decimals ? 2.5 : 5;
|
|
} else if (base >= 0 && base <= 100) {
|
|
min = 0;
|
|
max = 100;
|
|
macroScale = Math.max(resolution * 3, 4);
|
|
}
|
|
|
|
return {
|
|
raw,
|
|
base,
|
|
current: base,
|
|
target: base,
|
|
decimals,
|
|
integerWidth: integerPart.length,
|
|
leadingZero,
|
|
resolution,
|
|
kind,
|
|
min,
|
|
max,
|
|
circular,
|
|
macroScale,
|
|
flutterOffset: 0,
|
|
nextFlutterAt: performance.now() + obsRand(350, 1400),
|
|
correctionKick: 0,
|
|
correctionUntil: 0
|
|
};
|
|
}
|
|
|
|
function getStatusMutationRule(source) {
|
|
const text = source.trim();
|
|
const upper = text.toUpperCase();
|
|
|
|
// Camera location names are identities, not status fields.
|
|
if (/^CAM\s+\d+\s*\/\//i.test(text)) return null;
|
|
|
|
if (text.includes('//')) {
|
|
const splitAt = text.lastIndexOf('//');
|
|
const prefix = text.slice(0, splitAt + 2).trimEnd();
|
|
const suffix = text.slice(splitAt + 2).trim();
|
|
|
|
// These are display/status phrases and can safely wander.
|
|
if (/(PASSIVE|NOMINAL|ACTIVE|OBSERVATION|WATCH|FEED|SENSORS|SECURE|FIELD|ARRAY|CONTROL)/i.test(suffix) ||
|
|
/(SENSOR|VECTOR|TACTICAL|OPTICAL|BIOLOGICAL|TRAFFIC|LIVE)/i.test(prefix)) {
|
|
return { type: 'suffix', prefix, original: suffix };
|
|
}
|
|
}
|
|
|
|
// A few colon-delimited fantasy statuses.
|
|
const colonMatch = text.match(/^(ROUTE|TRANSPONDER)\s*:\s*(.+)$/i);
|
|
if (colonMatch) {
|
|
return { type: 'colon', prefix: colonMatch[1].toUpperCase(), original: colonMatch[2].trim() };
|
|
}
|
|
|
|
const plain = observationPlainTextVariants[activeUniverseId];
|
|
if (plain && plain[upper]) {
|
|
return { type: 'plain', original: upper, variants: plain[upper] };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function instrumentObservationTextNode(node) {
|
|
if (!node || node.nodeType !== 1 || node.dataset.obsLiveInstrumented === '1') return;
|
|
|
|
const source = (node.textContent || '').trim();
|
|
if (!source) return;
|
|
|
|
const numericMatches = [...source.matchAll(/[-+]?\d+(?:\.\d+)?/g)];
|
|
const numbers = numericMatches.map(m => classifyObservationNumber(source, m[0], m.index));
|
|
|
|
const state = {
|
|
node,
|
|
source,
|
|
numbers,
|
|
numberMatches: numericMatches.map(m => ({ raw: m[0], index: m.index })),
|
|
statusRule: getStatusMutationRule(source),
|
|
statusOverride: null,
|
|
statusUntil: 0,
|
|
nextTextGlitchAt: performance.now() + obsRand(2500, 9000),
|
|
glitchUntil: 0,
|
|
glitchSeed: 0
|
|
};
|
|
|
|
node.dataset.obsLiveInstrumented = '1';
|
|
node.classList.add('obs-live-instrument');
|
|
observationInstrumentStates.push(state);
|
|
}
|
|
|
|
function instrumentObservationTree(root) {
|
|
if (!root || !root.querySelectorAll) return;
|
|
if (root.matches && root.matches('text')) instrumentObservationTextNode(root);
|
|
root.querySelectorAll('text').forEach(instrumentObservationTextNode);
|
|
}
|
|
|
|
function initializeObservationLiveInstrumentation() {
|
|
stopObservationLiveInstrumentation(false);
|
|
observationInstrumentStates = [];
|
|
|
|
instrumentObservationTree(observationStage);
|
|
instrumentObservationTree(observationEventLayer);
|
|
|
|
observationTransientObserver = new MutationObserver((mutations) => {
|
|
for (const mutation of mutations) {
|
|
mutation.addedNodes.forEach(node => {
|
|
if (node.nodeType === 1) instrumentObservationTree(node);
|
|
});
|
|
}
|
|
});
|
|
observationTransientObserver.observe(observationEventLayer, { childList: true, subtree: true });
|
|
|
|
scheduleObservationInstrumentTick();
|
|
}
|
|
|
|
function stopObservationLiveInstrumentation(clearStates = true) {
|
|
if (observationInstrumentTimer) {
|
|
clearTimeout(observationInstrumentTimer);
|
|
observationInstrumentTimer = null;
|
|
}
|
|
if (observationTransientObserver) {
|
|
observationTransientObserver.disconnect();
|
|
observationTransientObserver = null;
|
|
}
|
|
if (clearStates) observationInstrumentStates = [];
|
|
}
|
|
|
|
function formatObservationNumber(value, token) {
|
|
let v = value;
|
|
|
|
if (token.circular) {
|
|
const span = token.max - token.min;
|
|
v = ((v - token.min) % span + span) % span + token.min;
|
|
} else {
|
|
if (Number.isFinite(token.min)) v = Math.max(token.min, v);
|
|
if (Number.isFinite(token.max)) v = Math.min(token.max, v);
|
|
}
|
|
|
|
// Identity-like fields stay integer but can still "flutter" by one count.
|
|
if (token.kind === 'identifier') v = Math.round(v);
|
|
|
|
let result = token.decimals > 0 ? v.toFixed(token.decimals) : String(Math.round(v));
|
|
|
|
if (token.leadingZero) {
|
|
const sign = result.startsWith('-') ? '-' : '';
|
|
const unsigned = result.replace(/^-/, '');
|
|
const parts = unsigned.split('.');
|
|
parts[0] = parts[0].padStart(token.integerWidth, '0');
|
|
result = sign + parts.join('.');
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function shortestCircularDelta(current, target, min, max) {
|
|
const span = max - min;
|
|
let delta = target - current;
|
|
if (delta > span / 2) delta -= span;
|
|
if (delta < -span / 2) delta += span;
|
|
return delta;
|
|
}
|
|
|
|
function rebuildObservationText(state, now) {
|
|
if (!state.node || !state.node.isConnected) return;
|
|
|
|
let cursor = 0;
|
|
let built = '';
|
|
|
|
state.numberMatches.forEach((match, i) => {
|
|
const token = state.numbers[i];
|
|
built += state.source.slice(cursor, match.index);
|
|
|
|
let displayValue = token.current + token.flutterOffset;
|
|
|
|
if (token.correctionUntil > now) {
|
|
const remaining = (token.correctionUntil - now) / 1400;
|
|
displayValue += token.correctionKick * Math.max(0, Math.min(1, remaining));
|
|
}
|
|
|
|
built += formatObservationNumber(displayValue, token);
|
|
cursor = match.index + match.raw.length;
|
|
});
|
|
|
|
built += state.source.slice(cursor);
|
|
|
|
if (state.statusOverride && state.statusUntil > now && state.statusRule) {
|
|
const rule = state.statusRule;
|
|
if (rule.type === 'suffix') {
|
|
const splitAt = built.lastIndexOf('//');
|
|
if (splitAt >= 0) built = `${built.slice(0, splitAt + 2)} ${state.statusOverride}`;
|
|
} else if (rule.type === 'colon') {
|
|
const colonAt = built.indexOf(':');
|
|
if (colonAt >= 0) built = `${built.slice(0, colonAt + 1)} ${state.statusOverride}`;
|
|
} else if (rule.type === 'plain') {
|
|
built = state.statusOverride;
|
|
}
|
|
} else if (state.statusOverride && state.statusUntil <= now) {
|
|
state.statusOverride = null;
|
|
}
|
|
|
|
// Very brief display corruption. This is visual noise only and always recovers.
|
|
if (state.glitchUntil > now && built.length > 3) {
|
|
const chars = [...built];
|
|
const candidates = [];
|
|
for (let i = 1; i < chars.length - 1; i++) {
|
|
if (/[A-Z0-9]/i.test(chars[i])) candidates.push(i);
|
|
}
|
|
if (candidates.length) {
|
|
const idx = candidates[state.glitchSeed % candidates.length];
|
|
chars[idx] = obsPick(['_','?','·','Ξ','0','1']);
|
|
built = chars.join('');
|
|
state.node.classList.add('obs-live-glitch');
|
|
}
|
|
} else {
|
|
state.node.classList.remove('obs-live-glitch');
|
|
}
|
|
|
|
state.node.textContent = built;
|
|
}
|
|
|
|
function updateObservationNumberToken(token, now) {
|
|
const activity = observationActivity;
|
|
|
|
let delta = token.circular
|
|
? shortestCircularDelta(token.current, token.target, token.min, token.max)
|
|
: token.target - token.current;
|
|
|
|
// Activity controls how quickly the displayed instrument tracks its target.
|
|
const trackingFraction = 0.07 + activity * 0.31;
|
|
if (Math.abs(delta) > token.resolution * .15) {
|
|
token.current += delta * trackingFraction;
|
|
} else {
|
|
token.current = token.target;
|
|
}
|
|
|
|
// Micro flutter: the least-significant digit hunts around the real value.
|
|
if (now >= token.nextFlutterAt) {
|
|
const flutterChance = 0.34 + activity * 0.56;
|
|
if (Math.random() < flutterChance) {
|
|
const direction = obsPick([-1, 1]);
|
|
const steps = Math.random() < (.18 + activity * .2) ? 2 : 1;
|
|
const identifierDamping = token.kind === 'identifier' ? .55 : 1;
|
|
token.flutterOffset = direction * token.resolution * steps * identifierDamping;
|
|
} else {
|
|
token.flutterOffset = 0;
|
|
}
|
|
|
|
const slow = 1850;
|
|
const fast = 240;
|
|
token.nextFlutterAt = now + (slow - (slow-fast) * activity) * obsRand(.65, 1.35);
|
|
} else if (Math.random() < .16 + activity * .18) {
|
|
token.flutterOffset *= .45;
|
|
if (Math.abs(token.flutterOffset) < token.resolution * .15) token.flutterOffset = 0;
|
|
}
|
|
|
|
if (token.circular) {
|
|
const span = token.max - token.min;
|
|
token.current = ((token.current - token.min) % span + span) % span + token.min;
|
|
} else {
|
|
if (Number.isFinite(token.min)) token.current = Math.max(token.min, token.current);
|
|
if (Number.isFinite(token.max)) token.current = Math.min(token.max, token.current);
|
|
}
|
|
}
|
|
|
|
function maybeGlitchObservationText(state, now) {
|
|
if (now < state.nextTextGlitchAt) return;
|
|
|
|
const activity = observationActivity;
|
|
const density = getObservationTelemetryDensity();
|
|
const chance = .06 + activity * .18 + density * .05;
|
|
|
|
if (Math.random() < chance) {
|
|
state.glitchSeed = obsInt(0, 100000);
|
|
state.glitchUntil = now + obsRand(90, 260 + activity * 180);
|
|
}
|
|
|
|
const slow = 17000;
|
|
const fast = 2800;
|
|
state.nextTextGlitchAt = now + (slow - (slow-fast) * activity) * obsRand(.6, 1.4);
|
|
}
|
|
|
|
function observationInstrumentTick() {
|
|
observationInstrumentTimer = null;
|
|
if (!observationActive) return;
|
|
|
|
const now = performance.now();
|
|
observationInstrumentStates = observationInstrumentStates.filter(state => state.node && state.node.isConnected);
|
|
|
|
for (const state of observationInstrumentStates) {
|
|
state.numbers.forEach(token => updateObservationNumberToken(token, now));
|
|
maybeGlitchObservationText(state, now);
|
|
rebuildObservationText(state, now);
|
|
}
|
|
|
|
scheduleObservationInstrumentTick();
|
|
}
|
|
|
|
function scheduleObservationInstrumentTick() {
|
|
if (!observationActive) return;
|
|
if (observationInstrumentTimer) clearTimeout(observationInstrumentTimer);
|
|
|
|
// Observation level directly controls the "instrument refresh rate".
|
|
// At quiet settings values settle visibly and slowly; at high settings
|
|
// they look like fast live telemetry.
|
|
const slowMs = 900;
|
|
const fastMs = 105;
|
|
const delay = slowMs - (slowMs - fastMs) * Math.pow(observationActivity, .88);
|
|
observationInstrumentTimer = setTimeout(observationInstrumentTick, delay);
|
|
}
|
|
|
|
function chooseObservationTarget(token, density) {
|
|
const amplitudeFloor = token.resolution;
|
|
const amplitude = Math.max(
|
|
amplitudeFloor,
|
|
token.macroScale * (.06 + Math.pow(density, 1.12))
|
|
);
|
|
|
|
let delta;
|
|
if (token.kind === 'identifier') {
|
|
const maxStep = Math.max(1, Math.round(1 + density * 3));
|
|
delta = obsPick([-1,1]) * obsInt(1, maxStep);
|
|
} else {
|
|
delta = obsRand(-amplitude, amplitude);
|
|
}
|
|
|
|
let target = token.target + delta;
|
|
|
|
if (token.circular) {
|
|
const span = token.max - token.min;
|
|
target = ((target - token.min) % span + span) % span + token.min;
|
|
} else {
|
|
if (Number.isFinite(token.min)) target = Math.max(token.min, target);
|
|
if (Number.isFinite(token.max)) target = Math.min(token.max, target);
|
|
}
|
|
|
|
token.target = target;
|
|
|
|
// Sometimes a display control-loop overshoots and corrects itself.
|
|
if (Math.random() < .28 + observationActivity * .24) {
|
|
const sign = Math.random() < .5 ? -1 : 1;
|
|
const kickSteps = token.kind === 'identifier' ? 1 : obsRand(.6, 2.2 + observationActivity * 1.5);
|
|
token.correctionKick = sign * token.resolution * kickSteps;
|
|
token.correctionUntil = performance.now() + obsRand(550, 1450);
|
|
}
|
|
}
|
|
|
|
function chooseObservationStatusOverride(state, density) {
|
|
if (!state.statusRule) return;
|
|
|
|
let variants;
|
|
const rule = state.statusRule;
|
|
|
|
if (rule.type === 'plain') {
|
|
variants = rule.variants || [];
|
|
} else {
|
|
variants = observationTextVocabulary[activeUniverseId] || observationTextVocabulary.starfleet;
|
|
}
|
|
|
|
if (!variants.length) return;
|
|
|
|
const original = rule.original.toUpperCase();
|
|
const choices = variants.filter(v => v !== original);
|
|
if (!choices.length) return;
|
|
|
|
state.statusOverride = obsPick(choices);
|
|
|
|
// Higher observation activity means state changes read faster and clear sooner.
|
|
const holdSlow = 6200;
|
|
const holdFast = 1700;
|
|
state.statusUntil = performance.now() +
|
|
(holdSlow - (holdSlow-holdFast) * observationActivity) * obsRand(.7, 1.35);
|
|
}
|
|
|
|
function nudgeObservationInstruments(densityOverride = null) {
|
|
if (!observationActive) return;
|
|
|
|
const density = densityOverride == null
|
|
? getObservationTelemetryDensity()
|
|
: obsClamp(Number(densityOverride) || 0, 0, 1);
|
|
|
|
const liveStates = observationInstrumentStates.filter(s => s.node && s.node.isConnected);
|
|
const numericStates = liveStates.filter(s => s.numbers.length);
|
|
const statusStates = liveStates.filter(s => s.statusRule);
|
|
|
|
// Density controls how much of the imaginary system is disturbed by a chirp.
|
|
const desiredNumeric = Math.min(
|
|
numericStates.length,
|
|
Math.max(1, Math.round(1 + density * (2 + numericStates.length * .22)))
|
|
);
|
|
|
|
const shuffledNumbers = [...numericStates].sort(() => Math.random() - .5);
|
|
for (let i = 0; i < desiredNumeric; i++) {
|
|
const state = shuffledNumbers[i];
|
|
state.numbers.forEach(token => {
|
|
// A dense chirp can perturb several values in a compound readout.
|
|
if (Math.random() < .55 + density * .4) chooseObservationTarget(token, density);
|
|
});
|
|
}
|
|
|
|
// Text states are less frequent than numeric movement. They read as system
|
|
// transitions rather than a constantly changing label soup.
|
|
if (statusStates.length && Math.random() < .28 + density * .5) {
|
|
const desiredText = density > .72 && Math.random() < density ? 2 : 1;
|
|
const shuffledText = [...statusStates].sort(() => Math.random() - .5);
|
|
for (let i = 0; i < Math.min(desiredText, shuffledText.length); i++) {
|
|
chooseObservationStatusOverride(shuffledText[i], density);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
function seededUnit(seed) {
|
|
let x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453;
|
|
return x - Math.floor(x);
|
|
}
|
|
|
|
function makeStars(count = 90, width = 1600, height = 900, tint = '#dbeafe', seedOffset = 0) {
|
|
let stars = '';
|
|
for (let i = 0; i < count; i++) {
|
|
const x = Math.floor(seededUnit(i + 1 + seedOffset) * width);
|
|
const y = Math.floor(seededUnit((i + 1 + seedOffset) * 7.31) * height);
|
|
const r = (0.55 + seededUnit((i + seedOffset + 4) * 3.17) * 1.65).toFixed(2);
|
|
const o = (0.25 + seededUnit((i + seedOffset + 9) * 5.19) * 0.72).toFixed(2);
|
|
stars += `<circle cx="${x}" cy="${y}" r="${r}" fill="${tint}" opacity="${o}"/>`;
|
|
}
|
|
return stars;
|
|
}
|
|
|
|
function svgFrame(inner, defs = '') {
|
|
return `
|
|
<svg viewBox="0 0 1600 900" preserveAspectRatio="xMidYMid slice"
|
|
xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Theme observation display">
|
|
<defs>
|
|
<radialGradient id="obsGlow" cx="50%" cy="50%">
|
|
<stop offset="0%" stop-color="var(--primary-accent)" stop-opacity=".32"/>
|
|
<stop offset="65%" stop-color="var(--primary-accent)" stop-opacity=".05"/>
|
|
<stop offset="100%" stop-color="#000" stop-opacity="0"/>
|
|
</radialGradient>
|
|
<linearGradient id="obsFade" x1="0" x2="1">
|
|
<stop offset="0%" stop-color="var(--primary-accent)" stop-opacity=".05"/>
|
|
<stop offset="50%" stop-color="var(--primary-accent)" stop-opacity=".42"/>
|
|
<stop offset="100%" stop-color="var(--primary-accent)" stop-opacity=".05"/>
|
|
</linearGradient>
|
|
<filter id="obsSoftGlow">
|
|
<feGaussianBlur stdDeviation="5" result="blur"/>
|
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
|
</filter>
|
|
${defs}
|
|
</defs>
|
|
${inner}
|
|
</svg>`;
|
|
}
|
|
|
|
function renderObservationStarflight() {
|
|
const starsA = makeStars(115, 1750, 900, '#dbeafe', 2);
|
|
const starsB = makeStars(55, 1750, 900, '#93c5fd', 220);
|
|
return svgFrame(`
|
|
<rect width="1600" height="900" fill="#02050a"/>
|
|
<g style="animation:obs-drift-left 80s linear infinite">${starsA}</g>
|
|
<g opacity=".55" style="animation:obs-drift-right 120s linear infinite">${starsB}</g>
|
|
|
|
<circle cx="800" cy="455" r="285" fill="url(#obsGlow)" opacity=".45"/>
|
|
<g fill="none" stroke="var(--primary-accent)" opacity=".46">
|
|
<circle cx="800" cy="455" r="240" stroke-width="2"/>
|
|
<circle cx="800" cy="455" r="170" stroke-width="1"/>
|
|
<circle cx="800" cy="455" r="100" stroke-width="1"/>
|
|
<path d="M520 455H1080M800 175V735" opacity=".42"/>
|
|
</g>
|
|
<g class="obs-spin-slow" fill="none" stroke="var(--tertiary-accent)" stroke-width="3" opacity=".55">
|
|
<path d="M800 200 A255 255 0 0 1 1055 455"/>
|
|
<path d="M800 710 A255 255 0 0 1 545 455"/>
|
|
</g>
|
|
<g fill="var(--primary-accent)" filter="url(#obsSoftGlow)">
|
|
<circle class="obs-blink" cx="976" cy="355" r="5"/>
|
|
<circle cx="650" cy="535" r="4" opacity=".8"/>
|
|
<circle class="obs-blink" cx="885" cy="610" r="3"/>
|
|
</g>
|
|
<g transform="translate(800 455)" opacity=".52">
|
|
<g class="obs-spin-slow">
|
|
<circle cx="0" cy="-205" r="5" fill="var(--tertiary-accent)"/>
|
|
<circle cx="177" cy="102" r="3" fill="var(--primary-accent)"/>
|
|
<circle cx="-155" cy="135" r="4" fill="var(--secondary-accent)"/>
|
|
</g>
|
|
</g>
|
|
<g opacity=".38" class="obs-ambient-glow">
|
|
<path d="M250 620 C430 550 515 675 690 600 S1040 525 1355 615"
|
|
fill="none" stroke="var(--tertiary-accent)" stroke-width="2" class="obs-dashflow"/>
|
|
</g>
|
|
|
|
<g fill="none" stroke="var(--secondary-accent)" stroke-width="7" opacity=".8">
|
|
<path d="M68 160H360M68 160V310"/>
|
|
<path d="M1532 160H1240M1532 160V310"/>
|
|
<path d="M68 740H360M68 740V600"/>
|
|
<path d="M1532 740H1240M1532 740V600"/>
|
|
</g>
|
|
<g fill="var(--secondary-accent)" opacity=".72">
|
|
<rect x="85" y="198" width="210" height="16" rx="8"/>
|
|
<rect x="85" y="226" width="150" height="11" rx="6"/>
|
|
<rect x="1305" y="198" width="210" height="16" rx="8"/>
|
|
<rect x="1365" y="226" width="150" height="11" rx="6"/>
|
|
</g>
|
|
<text x="94" y="700" fill="var(--tertiary-accent)" opacity=".65" font-size="18" font-family="monospace">LONG RANGE SENSOR ARRAY // PASSIVE</text>
|
|
<text x="1290" y="700" fill="var(--tertiary-accent)" opacity=".65" font-size="18" font-family="monospace" text-anchor="end">NAV VECTOR 034.8 // NOMINAL</text>
|
|
`);
|
|
}
|
|
|
|
function renderObservationWhataverse() {
|
|
return svgFrame(`
|
|
<rect width="1600" height="900" fill="#020817"/>
|
|
<circle cx="800" cy="455" r="420" fill="url(#obsGlow)" opacity=".5"/>
|
|
<g fill="none" stroke="#00e5ff" opacity=".38">
|
|
<ellipse class="obs-spin" cx="800" cy="455" rx="430" ry="170" stroke-width="3"/>
|
|
<g transform="rotate(58 800 455)">
|
|
<ellipse class="obs-spin-rev" cx="800" cy="455" rx="350" ry="105" stroke-width="2"/>
|
|
</g>
|
|
<g transform="rotate(-34 800 455)">
|
|
<ellipse class="obs-spin-slow" cx="800" cy="455" rx="270" ry="70" stroke-width="4"/>
|
|
</g>
|
|
</g>
|
|
<g class="obs-breathe">
|
|
<rect x="760" y="220" width="80" height="470" rx="30" fill="#00e5ff" opacity=".08"/>
|
|
<rect x="782" y="250" width="36" height="410" rx="18" fill="#d4af37" opacity=".34"/>
|
|
<circle cx="800" cy="455" r="64" fill="#00e5ff" opacity=".2" filter="url(#obsSoftGlow)"/>
|
|
</g>
|
|
<g fill="none" stroke="#d4af37" opacity=".5" stroke-width="2">
|
|
<circle class="obs-spin-slow" cx="800" cy="455" r="115" stroke-dasharray="8 16"/>
|
|
<circle class="obs-spin-rev" cx="800" cy="455" r="180" stroke-dasharray="3 28"/>
|
|
</g>
|
|
<path d="M185 700 C420 570 520 770 720 640 S1090 560 1410 705"
|
|
fill="none" stroke="#00e5ff" stroke-width="3" opacity=".34" class="obs-dashflow"/>
|
|
<g opacity=".48" font-family="monospace" fill="#d4af37">
|
|
<text x="325" y="330" font-size="22" class="obs-spin-slow">◉</text>
|
|
<text x="1220" y="520" font-size="28" class="obs-spin-rev">◎</text>
|
|
<text x="420" y="610" font-size="18" class="obs-ambient-wobble">⌁ 37.4 ∴ 9</text>
|
|
<text x="1120" y="290" font-size="18" class="obs-ambient-wobble">∆ 04:11:∞</text>
|
|
</g>
|
|
<text x="800" y="775" text-anchor="middle" fill="#d4af37" font-size="21" font-family="monospace" opacity=".72">
|
|
TEMPORAL DISPLACEMENT // VORTEX OBSERVATION
|
|
</text>
|
|
`);
|
|
}
|
|
|
|
function renderObservationIndustrial() {
|
|
// The industrial viewport frame (getViewportFrameSvg) masks everything
|
|
// outside x 140..1460, y 160..740, so the whole surveillance console is
|
|
// laid out inside that safe area. v12 ran the feeds to x 110..1490 and had
|
|
// its edges eaten by the bulkhead.
|
|
const SAFE_X0 = 156, SAFE_X1 = 1444;
|
|
|
|
// Feeds are authored at 650x270 and drawn through a uniform scale, so the
|
|
// scenes below can keep one readable coordinate space.
|
|
const CW = 650, CH = 270, S = 0.78;
|
|
const IX = 14, IY = 14, IW = 622, IH = 242;
|
|
const CAM_X = [279, 814], CAM_Y = [248, 472];
|
|
|
|
// Identity text (camera names, equipment designations, container IDs) is
|
|
// opted OUT of the live-instrumentation number drift by pre-stamping the
|
|
// flag it checks. Only telemetry should wander; "CAM 02" must not quietly
|
|
// become "CAM 01", which is exactly what v12 did to every label it drew.
|
|
const ID = 'data-obs-live-instrumented="1"';
|
|
|
|
// ---------------------------------------------------------------- parts
|
|
const crate = (x, y, w, h, body, edge, id, idColor) => {
|
|
let ribs = '';
|
|
for (let i = 9; i < w - 5; i += 9) ribs += `M${i} 4V${h - 4}`;
|
|
return `
|
|
<g transform="translate(${x} ${y})">
|
|
<rect width="${w}" height="${h}" rx="1.5" fill="${body}" stroke="#070504" stroke-width="1.5"/>
|
|
<path d="${ribs}" fill="none" stroke="${edge}" stroke-width="1" opacity=".32"/>
|
|
<rect width="${w}" height="5" fill="#ffffff" opacity=".05"/>
|
|
<rect y="${h - 6}" width="${w}" height="6" fill="#000000" opacity=".34"/>
|
|
<g fill="#241a12">
|
|
<rect width="8" height="8"/><rect x="${w - 8}" width="8" height="8"/>
|
|
<rect y="${h - 8}" width="8" height="8"/><rect x="${w - 8}" y="${h - 8}" width="8" height="8"/>
|
|
</g>
|
|
<text x="${w / 2}" y="${h / 2 + 4}" text-anchor="middle" font-family="monospace"
|
|
font-size="11" fill="${idColor}" opacity=".78" ${ID}>${id}</text>
|
|
</g>`;
|
|
};
|
|
|
|
// Vent vapour authored as in-scene geometry so the camera clip contains it.
|
|
// v12 floated these on the sim layer, which is why steam drifted over the
|
|
// bezels and read as detached from any feed.
|
|
const vapour = (x, y, count, seed, scale = 1) => {
|
|
let out = '';
|
|
for (let i = 0; i < count; i++) {
|
|
const dur = (4.5 + seededUnit(seed + i) * 3.6).toFixed(1);
|
|
const delay = (i * (dur / count)).toFixed(1);
|
|
const vx = Math.round(-24 + seededUnit(seed + i * 3.1) * 48);
|
|
const r = ((9 + seededUnit(seed + i * 5.7) * 13) * scale).toFixed(1);
|
|
out += `<circle cx="${x}" cy="${y}" r="${r}" fill="url(#indVapour)" opacity="0"
|
|
style="--vx:${vx}px;animation:obs-vapour ${dur}s ease-out infinite;animation-delay:-${delay}s"/>`;
|
|
}
|
|
return out;
|
|
};
|
|
|
|
const gauge = (x, y, r, label, needleColor, delay) => {
|
|
let ticks = '';
|
|
for (let a = -120; a <= 120; a += 30) {
|
|
const rad = (a - 90) * Math.PI / 180;
|
|
ticks += `M${(Math.cos(rad) * (r - 3)).toFixed(1)} ${(Math.sin(rad) * (r - 3)).toFixed(1)}` +
|
|
`L${(Math.cos(rad) * (r - 8)).toFixed(1)} ${(Math.sin(rad) * (r - 8)).toFixed(1)}`;
|
|
}
|
|
return `
|
|
<g transform="translate(${x} ${y})">
|
|
<circle r="${r}" fill="#0c0a08" stroke="#3d2e1e" stroke-width="2"/>
|
|
<circle r="${r - 2}" fill="none" stroke="#1a1410" stroke-width="1"/>
|
|
<path d="${ticks}" stroke="#6b5537" stroke-width="1.4" fill="none"/>
|
|
<g style="transform-box:fill-box;transform-origin:bottom center;animation:obs-needle-hunt ${(9 + delay).toFixed(1)}s ease-in-out infinite;animation-delay:-${delay}s">
|
|
<rect x="-1" y="${-(r - 6)}" width="2" height="${r - 6}" fill="${needleColor}"/>
|
|
</g>
|
|
<circle r="2.6" fill="#8a5b25"/>
|
|
<text y="${r + 13}" text-anchor="middle" font-family="monospace" font-size="11" fill="#9a7a4c" ${ID}>${label}</text>
|
|
</g>`;
|
|
};
|
|
|
|
// Per-feed video artifacts. This replaces v12's single screen-wide sweep
|
|
// bar: each feed rolls, tears and grains on its own clock, inside its own
|
|
// clip, so the artifacts read as video rather than as a screen overlay.
|
|
const feedFx = (i) => `
|
|
<g clip-path="url(#indCamClip)">
|
|
<rect x="${IX}" y="${IY}" width="${IW}" height="${IH}" fill="url(#indScan)" opacity=".5"/>
|
|
<rect x="${IX}" y="${IY}" width="${IW}" height="${IH}" filter="url(#indGrainF)" class="obs-feed-grain"/>
|
|
<rect x="${IX}" y="${IY}" width="${IW}" height="${IH}" fill="#ffd9a0" class="obs-feed-agc"
|
|
style="animation-delay:-${(i * 2.3).toFixed(1)}s"/>
|
|
<g class="obs-feed-roll" style="animation-delay:-${(i * 1.9).toFixed(1)}s">
|
|
<rect x="${IX}" y="0" width="${IW}" height="60" fill="url(#indRoll)"/>
|
|
</g>
|
|
<g class="obs-feed-tear" style="animation-delay:-${(i * 3.1).toFixed(1)}s">
|
|
<rect x="${IX}" y="0" width="${IW}" height="6" fill="#ffffff" opacity=".22"/>
|
|
<rect x="${IX}" y="6" width="${IW}" height="3" fill="#ffaa00" opacity=".28"/>
|
|
<rect x="${IX}" y="11" width="${IW}" height="2" fill="#ffffff" opacity=".14"/>
|
|
</g>
|
|
<rect x="${IX}" y="${IY}" width="${IW}" height="${IH}" fill="url(#indFeedVig)"/>
|
|
</g>`;
|
|
|
|
const cam = (i, label, code, status, scene) => `
|
|
<g transform="translate(${CAM_X[i % 2]} ${CAM_Y[i < 2 ? 0 : 1]}) scale(${S})">
|
|
<rect x="-6" y="-6" width="${CW + 12}" height="${CH + 12}" rx="9" fill="#0b0907" stroke="#2a1d10" stroke-width="2"/>
|
|
<rect width="${CW}" height="${CH}" rx="5" fill="url(#indBezel)" stroke="#7c4a16" stroke-width="2"/>
|
|
<g fill="#2e2216">
|
|
<circle cx="7" cy="7" r="3"/><circle cx="${CW - 7}" cy="7" r="3"/>
|
|
<circle cx="7" cy="${CH - 7}" r="3"/><circle cx="${CW - 7}" cy="${CH - 7}" r="3"/>
|
|
</g>
|
|
<rect x="${IX}" y="${IY}" width="${IW}" height="${IH}" fill="#07090a"/>
|
|
<g clip-path="url(#indCamClip)">
|
|
<g style="animation:obs-crt-jump ${13 + i * 2}s steps(1,end) infinite">${scene}</g>
|
|
</g>
|
|
${feedFx(i)}
|
|
<g font-family="monospace">
|
|
<rect x="${IX}" y="${IY}" width="${IW}" height="26" fill="#000000" opacity=".45"/>
|
|
<circle cx="${IX + 15}" cy="${IY + 13}" r="4.5" fill="#ef4444" class="obs-blink"/>
|
|
<text x="${IX + 28}" y="${IY + 19}" fill="#ffaa00" font-size="17" ${ID}>${label}</text>
|
|
<text x="${IX + IW - 10}" y="${IY + 19}" text-anchor="end" fill="#a8814d" font-size="14" ${ID}>${code}</text>
|
|
<rect x="${IX}" y="${IY + IH - 24}" width="${IW}" height="24" fill="#000000" opacity=".45"/>
|
|
<text x="${IX + 10}" y="${IY + IH - 7}" fill="#c8964a" font-size="14" ${ID}>${status}</text>
|
|
<text x="${IX + IW - 10}" y="${IY + IH - 7}" text-anchor="end" fill="#7f6238" font-size="13" ${ID}>LIVE // SECURE FEED</text>
|
|
</g>
|
|
<g class="obs-cam-focus" style="animation-delay:-${i * 8}s">
|
|
<rect x="-6" y="-6" width="${CW + 12}" height="${CH + 12}" rx="9" fill="none" stroke="#ffaa00" stroke-width="3"/>
|
|
<rect x="${IX + IW - 112}" y="${IY + IH - 52}" width="102" height="21" rx="2" fill="#7f1d1d" opacity=".88"/>
|
|
<text x="${IX + IW - 61}" y="${IY + IH - 37}" text-anchor="middle" font-family="monospace" font-size="13" fill="#ffe4e6" ${ID}>SELECTED</text>
|
|
</g>
|
|
</g>`;
|
|
|
|
// ------------------------------------------------- procedural geometry
|
|
let bayChev = '';
|
|
for (let i = 0; i < 660; i += 16) bayChev += `M${i} 222L${i + 8} 212H${i + 16}L${i + 8} 222Z`;
|
|
let bayDeck = '';
|
|
for (let i = 20; i < 640; i += 44) bayDeck += `M${i} 214V256`;
|
|
|
|
let coreSeg = '';
|
|
for (let a = 0; a < 360; a += 45) {
|
|
const rad = a * Math.PI / 180;
|
|
coreSeg += `M${(Math.cos(rad) * 62).toFixed(1)} ${(Math.sin(rad) * 62).toFixed(1)}` +
|
|
`L${(Math.cos(rad) * 86).toFixed(1)} ${(Math.sin(rad) * 86).toFixed(1)}`;
|
|
}
|
|
let rods = '';
|
|
for (let i = 0; i < 7; i++) {
|
|
const rx = -60 + i * 20;
|
|
rods += `<g style="animation:obs-rod-travel ${(9 + i * 1.7).toFixed(1)}s ease-in-out infinite;animation-delay:-${i}s">
|
|
<rect x="${rx - 3}" y="0" width="6" height="34" fill="#6b5537"/>
|
|
<rect x="${rx - 4.5}" y="30" width="9" height="8" rx="1" fill="#b45309"/>
|
|
</g>`;
|
|
}
|
|
const trefoil = [0, 120, 240].map(a =>
|
|
`<path transform="rotate(${a})" d="M-4.6 -6.4A8 8 0 0 1 4.6 -6.4L2.5 -2.8A4 4 0 0 0 -2.5 -2.8Z"/>`).join('');
|
|
let plantGrate = '';
|
|
for (let i = 20; i < 640; i += 40) plantGrate += `M${i} 210V256`;
|
|
|
|
let dogs = '';
|
|
for (let a = 0; a < 360; a += 45) {
|
|
const rad = a * Math.PI / 180;
|
|
dogs += `<g transform="translate(${(Math.cos(rad) * 66).toFixed(1)} ${(Math.sin(rad) * 66).toFixed(1)}) rotate(${a})">
|
|
<rect x="-7" y="-6" width="14" height="12" rx="2" fill="#4a565f" stroke="#1b2126" stroke-width="1.4"/>
|
|
<rect x="-2" y="-6" width="4" height="12" fill="#8fa3b0" opacity=".45"/>
|
|
</g>`;
|
|
}
|
|
let hazRing = '';
|
|
for (let a = 0; a < 360; a += 15) {
|
|
if ((a / 15) % 2) continue;
|
|
const r0 = 78, r1 = 90, a0 = a * Math.PI / 180, a1 = (a + 15) * Math.PI / 180;
|
|
hazRing += `M${(Math.cos(a0) * r0).toFixed(1)} ${(Math.sin(a0) * r0).toFixed(1)}` +
|
|
`L${(Math.cos(a0) * r1).toFixed(1)} ${(Math.sin(a0) * r1).toFixed(1)}` +
|
|
`A${r1} ${r1} 0 0 1 ${(Math.cos(a1) * r1).toFixed(1)} ${(Math.sin(a1) * r1).toFixed(1)}` +
|
|
`L${(Math.cos(a1) * r0).toFixed(1)} ${(Math.sin(a1) * r0).toFixed(1)}` +
|
|
`A${r0} ${r0} 0 0 0 ${(Math.cos(a0) * r0).toFixed(1)} ${(Math.sin(a0) * r0).toFixed(1)}Z`;
|
|
}
|
|
const lamp = (y, txt, col, i) => `
|
|
<g transform="translate(0 ${y})">
|
|
<g style="animation:obs-airlock-cycle 16s linear infinite;animation-delay:-${(i * 16 / 3).toFixed(2)}s">
|
|
<circle cx="-34" r="9" fill="${col}" opacity=".22"/>
|
|
<circle cx="-34" r="5" fill="${col}"/>
|
|
</g>
|
|
<circle cx="-34" r="5" fill="none" stroke="#2b343b" stroke-width="1.4"/>
|
|
<text x="-22" y="4" font-family="monospace" font-size="12" fill="#9aa9b4" ${ID}>${txt}</text>
|
|
</g>`;
|
|
const suits = [0, 1, 2].map(i => `
|
|
<g transform="translate(${i * 30} 0)">
|
|
<rect width="26" height="88" rx="2" fill="#0e1215" stroke="#28313a" stroke-width="1.4"/>
|
|
<rect x="4" y="8" width="18" height="54" rx="8" fill="#1b2228"/>
|
|
<circle cx="13" cy="18" r="6" fill="#0a0e11" stroke="#3a454e" stroke-width="1.2"/>
|
|
<rect x="7" y="28" width="12" height="26" rx="3" fill="#2a3138"/>
|
|
<circle cx="13" cy="78" r="3" fill="${i === 1 ? '#f59e0b' : '#22c55e'}"/>
|
|
</g>`).join('');
|
|
let lockFloor = '';
|
|
for (let i = 20; i < 640; i += 40) lockFloor += `M${i} 214V256`;
|
|
|
|
const vpx = 325, vpy = 128;
|
|
let corrFrames = '', corrFloor = '', corrLamps = '';
|
|
for (let i = 0; i < 7; i++) {
|
|
const s = Math.pow(0.76, i);
|
|
const x0 = vpx + (24 - vpx) * s, x1 = vpx + (626 - vpx) * s;
|
|
const y0 = vpy + (22 - vpy) * s, y1 = vpy + (248 - vpy) * s;
|
|
corrFrames += `<rect x="${x0.toFixed(1)}" y="${y0.toFixed(1)}" width="${(x1 - x0).toFixed(1)}" height="${(y1 - y0).toFixed(1)}"
|
|
fill="none" stroke="#2e2418" stroke-width="${(10 * s).toFixed(1)}" opacity="${(0.92 - i * 0.09).toFixed(2)}"/>`;
|
|
corrFloor += `<path d="M${x0.toFixed(1)} ${y1.toFixed(1)}H${x1.toFixed(1)}" stroke="#2a2118" stroke-width="${(2 * s + 0.4).toFixed(1)}"/>`;
|
|
}
|
|
for (let x = 24; x <= 626; x += 60) {
|
|
corrFloor += `<path d="M${x} 248L${vpx} ${vpy}" stroke="#221a12" stroke-width="1" opacity=".45"/>`;
|
|
}
|
|
for (let i = 1; i < 5; i++) {
|
|
const s = Math.pow(0.76, i);
|
|
const y = vpy + (34 - vpy) * s, w = 62 * s;
|
|
corrLamps += `<g${i === 2 ? ' class="obs-lamp-fail"' : ''}>
|
|
<rect x="${(vpx - w / 2).toFixed(1)}" y="${y.toFixed(1)}" width="${w.toFixed(1)}" height="${(5 * s + 1).toFixed(1)}" rx="1" fill="#ffe4b5" opacity=".5"/>
|
|
<ellipse cx="${vpx}" cy="${(y + 18 * s).toFixed(1)}" rx="${(w * 1.5).toFixed(1)}" ry="${(24 * s).toFixed(1)}" fill="#f59e0b" opacity=".05"/>
|
|
</g>`;
|
|
}
|
|
const fanBlades = [0, 72, 144, 216, 288].map(a =>
|
|
`<path transform="rotate(${a})" d="M0 0Q14-6 22-17Q10-23 0-8Z"/>`).join('');
|
|
const drip = (x, y, d) =>
|
|
`<g style="animation:obs-drip ${(6 + d).toFixed(1)}s ease-in infinite;animation-delay:-${d}s">
|
|
<ellipse cx="${x}" cy="${y}" rx="1.6" ry="3.4" fill="#9fb6c4" opacity=".65"/></g>`;
|
|
|
|
// ------------------------------------------------------- CAM 01 :: BAY
|
|
// A working cargo hold: a gantry crane on an overhead rail running a real
|
|
// pick-and-place cycle over stacked containers. v12 had a wheeled cart
|
|
// sliding back and forth, which read as a car and made no sense on a ship.
|
|
const sceneCargo = `
|
|
<rect x="${IX}" y="${IY}" width="${IW}" height="${IH}" fill="#0a0b0c"/>
|
|
<rect x="${IX}" y="${IY}" width="${IW}" height="${IH}" fill="url(#indBayLight)"/>
|
|
|
|
<rect x="46" y="34" width="558" height="180" fill="#131110"/>
|
|
<g stroke="#231b14" stroke-width="1.5" fill="none" opacity=".9">
|
|
<path d="M110 34v180M190 34v180M270 34v180M398 34v180M478 34v180M556 34v180"/>
|
|
<path d="M46 80h558M46 126h558M46 172h558"/>
|
|
</g>
|
|
<g>
|
|
<rect x="286" y="112" width="96" height="102" fill="#0c0b0a" stroke="#3d2e1e" stroke-width="2"/>
|
|
<path d="M334 112v102" stroke="#241c14" stroke-width="2"/>
|
|
<rect x="286" y="112" width="96" height="7" fill="#a8801f" opacity=".45"/>
|
|
<circle cx="334" cy="164" r="10" fill="none" stroke="#7c4a16" stroke-width="2"/>
|
|
<circle cx="334" cy="164" r="3" fill="#5c4529"/>
|
|
<text x="334" y="206" text-anchor="middle" font-family="monospace" font-size="11" fill="#8a6a42" opacity=".8" ${ID}>BAY 2 // AFT</text>
|
|
</g>
|
|
<g fill="none" stroke="#2c2218" stroke-width="6"><path d="M46 52h46v162M604 52h-46v162"/></g>
|
|
<path d="M46 52h46v162" fill="none" stroke="#f59e0b" stroke-width="2" stroke-dasharray="11 9" class="obs-pipe-flow" opacity=".4"/>
|
|
|
|
<rect x="${IX}" y="214" width="${IW}" height="42" fill="#100e0c"/>
|
|
<path d="M14 230h622M14 244h622${bayDeck}" stroke="#241d15" stroke-width="1.2" fill="none" opacity=".85"/>
|
|
<path d="${bayChev}" fill="#b8860f" opacity=".4"/>
|
|
<g fill="#241c14">
|
|
<circle cx="70" cy="236" r="3"/><circle cx="300" cy="236" r="3"/><circle cx="560" cy="236" r="3"/>
|
|
</g>
|
|
|
|
<g class="obs-bay-shudder">
|
|
${crate(90, 174, 88, 40, '#5d4427', '#8a6a42', 'CB-4471', '#e8c88a')}
|
|
<g style="animation:obs-gantry-taken var(--obs-gantry-clock) linear infinite">
|
|
${crate(90, 134, 88, 40, '#6b4d2c', '#96754a', 'CB-4472', '#e8c88a')}
|
|
</g>
|
|
${crate(182, 174, 88, 40, '#4f3a22', '#7d6039', 'HAZ-3/09', '#f0b64f')}
|
|
<g stroke="#3f3a2e" stroke-width="1.4" opacity=".7" fill="none">
|
|
<path d="M182 186h88M182 202h88M206 174v40M248 174v40"/>
|
|
</g>
|
|
${crate(380, 174, 88, 40, '#5a4426', '#886946', 'CB-3310', '#e8c88a')}
|
|
<g style="animation:obs-gantry-placed var(--obs-gantry-clock) linear infinite">
|
|
${crate(380, 134, 88, 40, '#6b4d2c', '#96754a', 'CB-4472', '#e8c88a')}
|
|
</g>
|
|
${crate(472, 174, 88, 40, '#453120', '#6f5535', 'ORE-C', '#d9b070')}
|
|
</g>
|
|
|
|
<g transform="translate(134 0)">
|
|
<g style="animation:obs-gantry-traverse var(--obs-gantry-clock) ease-in-out infinite">
|
|
<g transform="translate(0 62)">
|
|
<g style="animation:obs-gantry-hoist var(--obs-gantry-clock) ease-in-out infinite">
|
|
<rect x="-1.5" y="-300" width="3" height="300" fill="#5f4a33"/>
|
|
<rect x="-2.5" y="-300" width="1" height="300" fill="#8a6f4f" opacity=".55"/>
|
|
<rect x="-50" y="0" width="100" height="9" rx="2" fill="#8a5b25" stroke="#241a10" stroke-width="1.2"/>
|
|
<rect x="-50" y="0" width="100" height="3" fill="#c98a3c" opacity=".45"/>
|
|
<rect x="-46" y="9" width="10" height="8" fill="#3a2b1b"/>
|
|
<rect x="36" y="9" width="10" height="8" fill="#3a2b1b"/>
|
|
<circle cx="0" cy="4.5" r="3" style="animation:obs-gantry-lamp var(--obs-gantry-clock) linear infinite"/>
|
|
<g style="animation:obs-gantry-load var(--obs-gantry-clock) linear infinite">
|
|
${crate(-44, 6, 88, 40, '#6b4d2c', '#96754a', 'CB-4472', '#e8c88a')}
|
|
</g>
|
|
</g>
|
|
</g>
|
|
<g transform="translate(0 46)">
|
|
<rect x="-24" y="0" width="48" height="18" rx="2" fill="#3a2b1b" stroke="#7c4a16" stroke-width="1.4"/>
|
|
<circle cx="-15" cy="1" r="4" fill="#181109"/><circle cx="15" cy="1" r="4" fill="#181109"/>
|
|
<rect x="-9" y="6" width="18" height="5" rx="1" fill="#ffaa00" opacity=".55"/>
|
|
</g>
|
|
</g>
|
|
</g>
|
|
|
|
<rect x="${IX}" y="${IY}" width="${IW}" height="28" fill="#0b0a09"/>
|
|
<g stroke="#241c14" stroke-width="2" fill="none">
|
|
<path d="M60 14v28M200 14v28M340 14v28M480 14v28M600 14v28"/>
|
|
</g>
|
|
<rect x="34" y="42" width="582" height="9" rx="2" fill="#2f2418" stroke="#5c4529" stroke-width="1.2"/>
|
|
<rect x="34" y="42" width="582" height="3" fill="#7c623c" opacity=".5"/>
|
|
|
|
<g transform="translate(586 66)">
|
|
<g class="obs-beacon-sweep">
|
|
<circle r="46" fill="none"/>
|
|
<path d="M0 0L-46 26L-46 -26Z" fill="#f59e0b" opacity=".11"/>
|
|
</g>
|
|
<rect x="-8" y="-11" width="16" height="6" fill="#2a2118"/>
|
|
<circle r="7" fill="#7c1d0d"/>
|
|
<circle r="5" fill="#f59e0b" class="obs-beacon-flash"/>
|
|
</g>
|
|
|
|
${vapour(250, 250, 6, 12.4, .8)}
|
|
|
|
<g stroke="#2f2418" stroke-width="3" fill="none" opacity=".9">
|
|
<path d="M14 248h622M100 240v16M330 240v16M560 240v16"/>
|
|
</g>
|
|
<g font-family="monospace" font-size="13" fill="#8a6a42" opacity=".8">
|
|
<text x="26" y="66" ${ID}>GANTRY 4 // AUTO CYCLE</text>
|
|
<text x="26" y="84">LOAD 2.4 T</text>
|
|
</g>`;
|
|
|
|
// --------------------------------------------------- CAM 02 :: REACTOR
|
|
const sceneReactor = `
|
|
<rect x="${IX}" y="${IY}" width="${IW}" height="${IH}" fill="#0a0806"/>
|
|
<rect x="${IX}" y="${IY}" width="${IW}" height="196" fill="#12100d"/>
|
|
<g stroke="#221a12" stroke-width="1.5" fill="none" opacity=".85">
|
|
<path d="M14 58h622M14 102h622M14 146h622M100 14v196M420 14v196M544 14v196"/>
|
|
</g>
|
|
|
|
<g transform="translate(240 18)">
|
|
<rect x="-74" y="-4" width="148" height="14" rx="2" fill="#1c1611" stroke="#3a2b1b" stroke-width="1.4"/>
|
|
<g transform="translate(0 10)">${rods}</g>
|
|
</g>
|
|
|
|
<g transform="translate(240 128)">
|
|
<circle r="92" fill="#f59e0b" class="obs-core-halo"/>
|
|
<circle r="86" fill="#0b0907"/>
|
|
<circle r="80" fill="none" stroke="#3a2b1b" stroke-width="11"/>
|
|
<path d="${coreSeg}" stroke="#5c4529" stroke-width="2.2" fill="none" opacity=".75"/>
|
|
<circle r="62" fill="none" stroke="#241a10" stroke-width="6"/>
|
|
<g class="obs-pivot" style="animation:obs-rotate 14s linear infinite">
|
|
<circle r="52" fill="none" stroke="#b45309" stroke-width="5" stroke-dasharray="26 16" opacity=".7"/>
|
|
</g>
|
|
<g class="obs-pivot" style="animation:obs-rotate-rev 22s linear infinite">
|
|
<circle r="40" fill="none" stroke="#f59e0b" stroke-width="3" stroke-dasharray="6 12" opacity=".55"/>
|
|
</g>
|
|
<g class="obs-pivot obs-core-load">
|
|
<circle r="30" fill="#fb923c" opacity=".22"/>
|
|
<circle r="18" fill="#fdba74" opacity=".5"/>
|
|
<circle r="9" fill="#fff7ed" opacity=".85"/>
|
|
</g>
|
|
</g>
|
|
|
|
<g fill="none" stroke="#2b2118" stroke-width="9">
|
|
<path d="M14 178h84q14 0 14-14v-44M636 178h-84q-14 0-14-14v-44"/>
|
|
</g>
|
|
<path d="M14 178h84q14 0 14-14v-44" fill="none" stroke="#38bdf8" stroke-width="2.4" stroke-dasharray="12 10" class="obs-pipe-flow" opacity=".45"/>
|
|
<path d="M636 178h-84q-14 0-14-14v-44" fill="none" stroke="#f97316" stroke-width="2.4" stroke-dasharray="12 10" class="obs-pipe-flow" opacity=".42" style="animation-direction:reverse"/>
|
|
|
|
${gauge(424, 74, 26, 'TEMP', '#f59e0b', 0)}
|
|
${gauge(490, 74, 26, 'PRESS', '#38bdf8', 1.4)}
|
|
${gauge(556, 74, 26, 'FLUX', '#22c55e', 2.8)}
|
|
|
|
<g transform="translate(590 152)">
|
|
<rect x="-17" y="-17" width="34" height="34" rx="2" fill="#3d3208" stroke="#ca8a04" stroke-width="1.5"/>
|
|
<g fill="#facc15"><circle r="2.6"/>${trefoil}</g>
|
|
</g>
|
|
|
|
<g class="obs-heat-shimmer"><rect x="150" y="192" width="182" height="20" fill="url(#indHeat)"/></g>
|
|
${vapour(392, 206, 5, 31.7, .85)}
|
|
|
|
<rect x="${IX}" y="210" width="${IW}" height="46" fill="#0d0b09"/>
|
|
<path d="M14 226h622M14 242h622${plantGrate}" stroke="#2a2118" stroke-width="1.2" fill="none" opacity=".8"/>
|
|
<g stroke="#4a3a26" fill="none" opacity=".9">
|
|
<path d="M14 214h622" stroke-width="3"/>
|
|
<path d="M14 230h622" stroke-width="1.6"/>
|
|
<path d="M74 210v46M204 210v46M334 210v46M464 210v46M594 210v46" stroke-width="3"/>
|
|
</g>
|
|
<g font-family="monospace" font-size="13" fill="#8a6a42" opacity=".8">
|
|
<text x="26" y="66" ${ID}>FUSION PLANT 1</text>
|
|
<text x="26" y="84">OUTPUT 68%</text>
|
|
</g>`;
|
|
|
|
// --------------------------------------------------- CAM 03 :: AIRLOCK
|
|
const sceneAirlock = `
|
|
<rect x="${IX}" y="${IY}" width="${IW}" height="${IH}" fill="#08090a"/>
|
|
<rect x="${IX}" y="${IY}" width="${IW}" height="200" fill="#111417"/>
|
|
<g stroke="#1d2429" stroke-width="1.5" fill="none" opacity=".9">
|
|
<path d="M14 56h622M14 100h622M14 144h622M126 14v200M476 14v200"/>
|
|
</g>
|
|
|
|
<g transform="translate(30 62)">${suits}</g>
|
|
|
|
<g transform="translate(300 122)">
|
|
<circle r="96" fill="#0c0f11" stroke="#232a30" stroke-width="4"/>
|
|
<path d="${hazRing}" fill="#b8860f" opacity=".5"/>
|
|
<circle r="72" fill="#141a1f" stroke="#3a454e" stroke-width="5"/>
|
|
<g class="obs-pivot" style="animation:obs-dog-ring 16s ease-in-out infinite">
|
|
<circle r="74" fill="none"/>
|
|
${dogs}
|
|
</g>
|
|
<circle r="52" fill="#0a0d10" stroke="#2b343b" stroke-width="3"/>
|
|
<circle r="34" fill="#04070c"/>
|
|
<g>
|
|
<circle cx="-14" cy="-9" r="1.2" fill="#dbeafe"/>
|
|
<circle cx="8" cy="-18" r=".9" fill="#bfdbfe"/>
|
|
<circle cx="17" cy="6" r="1.4" fill="#e0f2fe"/>
|
|
<circle cx="-6" cy="14" r="1" fill="#93c5fd"/>
|
|
<circle cx="-22" cy="10" r=".8" fill="#dbeafe"/>
|
|
<circle cx="24" cy="-7" r="1" fill="#ffffff"/>
|
|
<circle cx="2" cy="24" r=".9" fill="#bfdbfe"/>
|
|
</g>
|
|
<circle r="34" fill="none" stroke="#3a454e" stroke-width="5"/>
|
|
<path d="M-24 -18A34 34 0 0 1 4 -33" fill="none" stroke="#cbd5e1" stroke-width="3" opacity=".16"/>
|
|
</g>
|
|
|
|
<g transform="translate(300 26)">
|
|
<rect x="-15" y="-7" width="30" height="8" rx="2" fill="#1b2126"/>
|
|
<circle cy="5" r="7" fill="#7f1d1d"/>
|
|
<circle cy="5" r="5" fill="#ef4444" class="obs-beacon-flash"/>
|
|
</g>
|
|
|
|
<g transform="translate(556 54)">
|
|
<rect x="-54" y="-16" width="108" height="96" rx="3" fill="#0d1114" stroke="#28313a" stroke-width="1.5"/>
|
|
<text y="-3" text-anchor="middle" font-family="monospace" font-size="11" fill="#7c8b96" ${ID}>CYCLE STATE</text>
|
|
${lamp(20, 'VACUUM', '#38bdf8', 0)}
|
|
${lamp(44, 'EQUALIZE', '#f59e0b', 1)}
|
|
${lamp(68, 'PRESSURE', '#22c55e', 2)}
|
|
</g>
|
|
|
|
${gauge(556, 172, 28, 'CHAMBER kPa', '#38bdf8', 1.1)}
|
|
${vapour(300, 212, 5, 57.3, .7)}
|
|
|
|
<rect x="${IX}" y="214" width="${IW}" height="42" fill="#0b0e10"/>
|
|
<path d="M14 230h622M14 244h622${lockFloor}" stroke="#1d2429" stroke-width="1.2" fill="none" opacity=".85"/>
|
|
<path d="${bayChev}" fill="#b8860f" opacity=".34"/>
|
|
<g font-family="monospace" font-size="13" fill="#7c8b96" opacity=".8">
|
|
<text x="26" y="66" ${ID}>AIRLOCK C</text>
|
|
<text x="26" y="84" ${ID}>OUTER DOOR SEALED</text>
|
|
</g>`;
|
|
|
|
// -------------------------------------------------- CAM 04 :: CORRIDOR
|
|
const sceneCorridor = `
|
|
<rect x="${IX}" y="${IY}" width="${IW}" height="${IH}" fill="#090a0a"/>
|
|
<g clip-path="url(#indCorridorFloor)">
|
|
<rect x="${IX}" y="${IY}" width="${IW}" height="${IH}" fill="#100e0c"/>
|
|
<g fill="none">${corrFloor}</g>
|
|
</g>
|
|
<g stroke="#241c14" stroke-width="1.4" fill="none" opacity=".55">
|
|
<path d="M24 22L${vpx} ${vpy}M626 22L${vpx} ${vpy}M24 248L${vpx} ${vpy}M626 248L${vpx} ${vpy}"/>
|
|
</g>
|
|
<g stroke="#332617" stroke-width="4" fill="none" opacity=".75">
|
|
<path d="M24 40L${vpx} ${vpy}M626 40L${vpx} ${vpy}"/>
|
|
</g>
|
|
<g stroke="#2b2118" stroke-width="2.6" fill="none" opacity=".7">
|
|
<path d="M24 78L${vpx} ${vpy}M626 78L${vpx} ${vpy}M24 104L${vpx} ${vpy}M626 104L${vpx} ${vpy}"/>
|
|
</g>
|
|
<g fill="none">${corrFrames}</g>
|
|
${corrLamps}
|
|
|
|
<g transform="translate(${vpx} ${vpy})">
|
|
<circle r="31" fill="#f59e0b" opacity=".06"/>
|
|
<circle r="29" fill="#0a0c0d"/>
|
|
<g class="obs-pivot" style="animation:obs-rotate 1.7s linear infinite">
|
|
<circle r="25" fill="none"/>
|
|
<g fill="#4a3a26" opacity=".92">${fanBlades}</g>
|
|
</g>
|
|
<circle r="6" fill="#241a10"/>
|
|
<g stroke="#161009" stroke-width="2.4" opacity=".9" fill="none">
|
|
<path d="M-28-15H28M-28-5H28M-28 5H28M-28 15H28"/>
|
|
</g>
|
|
<circle r="29" fill="none" stroke="#5c4529" stroke-width="2.4"/>
|
|
</g>
|
|
|
|
<g transform="translate(84 176)">
|
|
<rect x="-4" y="18" width="8" height="16" fill="#2b2118"/>
|
|
<circle r="19" fill="none" stroke="#3a2b1b" stroke-width="5"/>
|
|
<g class="obs-pivot" style="animation:obs-rotate 26s linear infinite">
|
|
<circle r="17" fill="none"/>
|
|
<path d="M0-17V17M-17 0H17M-12-12L12 12M12-12L-12 12" stroke="#6b5537" stroke-width="3" fill="none"/>
|
|
</g>
|
|
<circle r="4" fill="#241a10"/>
|
|
</g>
|
|
<g fill="none" stroke="#2c2218" stroke-width="6" opacity=".85">
|
|
<path d="M84 195v53M566 150v98"/>
|
|
</g>
|
|
<path d="M84 195v53" fill="none" stroke="#38bdf8" stroke-width="2" stroke-dasharray="10 9" class="obs-pipe-flow" opacity=".35"/>
|
|
|
|
${drip(196, 96, 1.4)}
|
|
${drip(438, 82, 3.1)}
|
|
${drip(268, 66, 5.2)}
|
|
${vapour(520, 236, 5, 78.1, .75)}
|
|
|
|
<g font-family="monospace" font-size="13" fill="#8a6a42" opacity=".8">
|
|
<text x="26" y="66" ${ID}>FRAME 22 TO 34</text>
|
|
<text x="26" y="84" ${ID}>EXTRACTOR 3 ONLINE</text>
|
|
</g>`;
|
|
|
|
// ------------------------------------------------- console side rails
|
|
let rails = '';
|
|
for (let i = 0; i < 8; i++) {
|
|
const y = 252 + i * 54;
|
|
const fill = 22 + ((i * 41) % 62);
|
|
rails += `<rect x="160" y="${y}" width="94" height="38" rx="2" fill="#1a1309" stroke="#3d2e1e" stroke-width="1.2"/>
|
|
<rect x="160" y="${y}" width="${fill}" height="38" rx="2" fill="#b45309" opacity=".5" class="obs-ambient-glow" style="animation-delay:-${(i * 0.7).toFixed(1)}s"/>
|
|
<rect x="1346" y="${y}" width="94" height="38" rx="2" fill="#1a1309" stroke="#3d2e1e" stroke-width="1.2"/>
|
|
<rect x="${1440 - fill}" y="${y}" width="${fill}" height="38" rx="2" fill="#b45309" opacity=".5" class="obs-ambient-glow" style="animation-delay:-${(i * 0.55).toFixed(1)}s"/>`;
|
|
}
|
|
|
|
const defs = `
|
|
<linearGradient id="indBezel" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stop-color="#241a12"/>
|
|
<stop offset="45%" stop-color="#15100b"/>
|
|
<stop offset="100%" stop-color="#0a0705"/>
|
|
</linearGradient>
|
|
<linearGradient id="indRoll" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
|
|
<stop offset="44%" stop-color="#ffe6bd" stop-opacity=".12"/>
|
|
<stop offset="52%" stop-color="#ffffff" stop-opacity=".2"/>
|
|
<stop offset="60%" stop-color="#ffe6bd" stop-opacity=".09"/>
|
|
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
|
|
</linearGradient>
|
|
<linearGradient id="indHeat" x1="0" y1="1" x2="0" y2="0">
|
|
<stop offset="0%" stop-color="#fb923c" stop-opacity=".5"/>
|
|
<stop offset="100%" stop-color="#fb923c" stop-opacity="0"/>
|
|
</linearGradient>
|
|
<radialGradient id="indFeedVig" cx="50%" cy="50%" r="72%">
|
|
<stop offset="52%" stop-color="#000000" stop-opacity="0"/>
|
|
<stop offset="100%" stop-color="#000000" stop-opacity=".6"/>
|
|
</radialGradient>
|
|
<radialGradient id="indBayLight" cx="50%" cy="26%" r="68%">
|
|
<stop offset="0%" stop-color="#f59e0b" stop-opacity=".09"/>
|
|
<stop offset="100%" stop-color="#f59e0b" stop-opacity="0"/>
|
|
</radialGradient>
|
|
<radialGradient id="indRoomLight" cx="50%" cy="46%" r="60%">
|
|
<stop offset="0%" stop-color="#b45309" stop-opacity=".07"/>
|
|
<stop offset="100%" stop-color="#000000" stop-opacity="0"/>
|
|
</radialGradient>
|
|
<radialGradient id="indVapour" cx="50%" cy="50%" r="50%">
|
|
<stop offset="0%" stop-color="#efe2cf" stop-opacity=".5"/>
|
|
<stop offset="55%" stop-color="#dcc9ad" stop-opacity=".22"/>
|
|
<stop offset="100%" stop-color="#c9b596" stop-opacity="0"/>
|
|
</radialGradient>
|
|
<pattern id="indScan" width="4" height="4" patternUnits="userSpaceOnUse">
|
|
<rect width="4" height="1.7" fill="#000000" opacity=".62"/>
|
|
</pattern>
|
|
<filter id="indGrainF" x="0" y="0" width="100%" height="100%">
|
|
<feTurbulence type="fractalNoise" baseFrequency=".85" numOctaves="3" stitchTiles="stitch"/>
|
|
<feColorMatrix type="saturate" values="0"/>
|
|
</filter>
|
|
<clipPath id="indCamClip"><rect x="${IX}" y="${IY}" width="${IW}" height="${IH}" rx="3"/></clipPath>
|
|
<clipPath id="indCorridorFloor"><polygon points="14,256 636,256 636,248 325,128 14,248"/></clipPath>`;
|
|
|
|
return svgFrame(`
|
|
<rect width="1600" height="900" fill="#070706"/>
|
|
<rect width="1600" height="900" fill="url(#indRoomLight)"/>
|
|
|
|
${cam(0, 'CAM 01 // CARGO BAY 2', 'C-01 4K/IR', 'GANTRY 4 AUTO // 2 CONTACTS', sceneCargo)}
|
|
${cam(1, 'CAM 02 // REACTOR ACCESS', 'C-02 4K/RAD', 'PLANT 1 NOMINAL // LOOP A', sceneReactor)}
|
|
${cam(2, 'CAM 03 // AIRLOCK C', 'C-03 4K/LL', 'OUTER DOOR SEALED // NO EVA', sceneAirlock)}
|
|
${cam(3, 'CAM 04 // MACHINE CORRIDOR', 'C-04 4K/IR', 'FRAME 22-34 // EXTRACTOR ON', sceneCorridor)}
|
|
|
|
<g font-family="monospace">
|
|
<rect x="${SAFE_X0}" y="170" width="500" height="66" rx="4" fill="#100c08" stroke="#3d2e1e" stroke-width="2"/>
|
|
<rect x="${SAFE_X0}" y="170" width="6" height="66" fill="#ffaa00"/>
|
|
<text x="${SAFE_X0 + 22}" y="198" fill="#ffaa00" font-size="22" ${ID}>CSV KESTREL - HEAVY HAULER</text>
|
|
<text x="${SAFE_X0 + 22}" y="222" fill="#8a6a42" font-size="14" ${ID}>REG CB-4471 // DECK 4 SURVEILLANCE RING</text>
|
|
|
|
<rect x="668" y="170" width="330" height="66" rx="4" fill="#100c08" stroke="#3d2e1e" stroke-width="2"/>
|
|
<text x="686" y="192" fill="#8a6a42" font-size="13" ${ID}>SHIP TIME</text>
|
|
<text x="686" y="224" fill="#ffaa00" font-size="26">04:17:33</text>
|
|
<text x="980" y="192" text-anchor="end" fill="#8a6a42" font-size="13" ${ID}>RECORDER</text>
|
|
<text x="980" y="216" text-anchor="end" fill="#c8964a" font-size="17" ${ID}>ARMED</text>
|
|
<circle cx="964" cy="228" r="4.5" fill="#ef4444" class="obs-blink"/>
|
|
|
|
<rect x="1010" y="170" width="434" height="66" rx="4" fill="#100c08" stroke="#3d2e1e" stroke-width="2"/>
|
|
<text x="1028" y="192" fill="#22c55e" font-size="16" ${ID}>4 OF 4 FEEDS LOCKED</text>
|
|
<text x="1028" y="214" fill="#8a6a42" font-size="13">BANDWIDTH 62% // ARCHIVE 71% FULL</text>
|
|
<rect x="1028" y="222" width="398" height="6" rx="3" fill="#1a1309"/>
|
|
<rect x="1028" y="222" width="248" height="6" rx="3" fill="#b45309" opacity=".8"/>
|
|
</g>
|
|
|
|
${rails}
|
|
|
|
<g stroke="#3d2e1e" stroke-width="2" fill="none" opacity=".6">
|
|
<path d="M800 252v90M800 372v86M800 476v90M800 596v86"/>
|
|
</g>
|
|
<g font-family="monospace" font-size="12" fill="#6b5233" opacity=".85">
|
|
<text x="800" y="362" text-anchor="middle" transform="rotate(-90 800 362)" ${ID}>BUS A</text>
|
|
<text x="800" y="586" text-anchor="middle" transform="rotate(-90 800 586)" ${ID}>BUS B</text>
|
|
</g>
|
|
|
|
<g font-family="monospace">
|
|
<rect x="${SAFE_X0}" y="694" width="1288" height="36" rx="4" fill="#100c08" stroke="#3d2e1e" stroke-width="1.5"/>
|
|
<rect x="${SAFE_X0}" y="694" width="6" height="36" fill="#b45309"/>
|
|
<text x="${SAFE_X0 + 22}" y="718" fill="#c8964a" font-size="14" ${ID}>MANIFEST 4471-C // ORE CONCENTRATE + SEALED HAZ-3</text>
|
|
<g font-size="14" fill="#8a6a42">
|
|
<text x="700" y="718">HULL 12%</text>
|
|
<text x="830" y="718">BAY 101.3 KPA</text>
|
|
<text x="1010" y="718">RCTR 68%</text>
|
|
<text x="1130" y="718">O2 20.9%</text>
|
|
<text x="1250" y="718">GRAV 0.94 G</text>
|
|
</g>
|
|
<text x="1426" y="718" text-anchor="end" fill="#ffaa00" font-size="14" ${ID}>NOMINAL</text>
|
|
</g>
|
|
`, defs);
|
|
}
|
|
|
|
function renderObservationBioships() {
|
|
return svgFrame(`
|
|
<rect width="1600" height="900" fill="#010b07"/>
|
|
<circle cx="800" cy="455" r="390" fill="url(#obsGlow)" opacity=".5"/>
|
|
<g fill="none" stroke="#10b981" opacity=".35">
|
|
<path d="M190 470 C370 260 510 690 690 455 S1010 220 1400 470" stroke-width="18"/>
|
|
<path d="M210 575 C420 410 510 750 760 520 S1130 380 1390 590" stroke-width="6"/>
|
|
<path d="M255 315 C470 520 600 180 830 405 S1180 620 1375 320" stroke-width="5"/>
|
|
</g>
|
|
<g class="obs-breathe">
|
|
<path d="M800 238 C920 300 1010 395 985 505 C962 610 882 680 800 716 C718 680 638 610 615 505 C590 395 680 300 800 238Z"
|
|
fill="#10b981" opacity=".11" stroke="#8b5cf6" stroke-width="8"/>
|
|
<ellipse cx="800" cy="470" rx="100" ry="142" fill="#8b5cf6" opacity=".12"/>
|
|
<ellipse cx="800" cy="470" rx="52" ry="92" fill="#10b981" opacity=".33" filter="url(#obsSoftGlow)"/>
|
|
</g>
|
|
<g fill="#a7f3d0" opacity=".8">
|
|
<circle class="obs-pulse" cx="515" cy="345" r="7"/>
|
|
<circle class="obs-pulse" cx="1115" cy="565" r="6" style="animation-delay:-1.7s"/>
|
|
<circle class="obs-pulse" cx="455" cy="600" r="5" style="animation-delay:-2.3s"/>
|
|
<circle class="obs-pulse" cx="1165" cy="320" r="5" style="animation-delay:-.9s"/>
|
|
<circle r="5" fill="#8b5cf6">
|
|
<animateMotion dur="10s" repeatCount="indefinite"
|
|
path="M330,500 C530,220 720,650 920,410 S1210,260 1320,470"/>
|
|
</circle>
|
|
<circle r="3.5" fill="#10b981">
|
|
<animateMotion dur="14s" repeatCount="indefinite"
|
|
path="M1240,650 C1010,480 900,760 690,535 S420,350 270,520"/>
|
|
</circle>
|
|
</g>
|
|
<g stroke="#10b981" stroke-width="3" opacity=".55" fill="none">
|
|
<path d="M515 345 C610 360 660 420 712 450" class="obs-dashflow"/>
|
|
<path d="M1115 565 C1015 560 965 520 892 492" class="obs-dashflow"/>
|
|
</g>
|
|
<text x="800" y="785" text-anchor="middle" fill="#a7f3d0" font-size="20" font-family="monospace" opacity=".65">
|
|
BIOLOGICAL SYSTEMS // PASSIVE NEURAL OBSERVATION
|
|
</text>
|
|
`);
|
|
}
|
|
|
|
function renderObservationRetro() {
|
|
const stars = makeStars(95, 1600, 900, '#86efac', 720);
|
|
return svgFrame(`
|
|
<rect width="1600" height="900" fill="#010903"/>
|
|
<g opacity=".44">${stars}</g>
|
|
<g transform="translate(800 460)" fill="none" stroke="#22c55e">
|
|
<circle r="275" opacity=".55" stroke-width="3"/>
|
|
<circle r="205" opacity=".35"/>
|
|
<circle r="135" opacity=".35"/>
|
|
<circle r="65" opacity=".35"/>
|
|
<path d="M-275 0H275M0-275V275" opacity=".3"/>
|
|
<g class="obs-spin">
|
|
<path d="M0 0L0-275L70-230Z" fill="#22c55e" opacity=".18" stroke="none"/>
|
|
<line x1="0" y1="0" x2="0" y2="-275" stroke="#86efac" stroke-width="4"/>
|
|
</g>
|
|
</g>
|
|
<g fill="#86efac" filter="url(#obsSoftGlow)">
|
|
<circle class="obs-blink" cx="945" cy="360" r="6"/>
|
|
<circle cx="675" cy="570" r="4"/>
|
|
<circle class="obs-blink" cx="1040" cy="520" r="4" style="animation-delay:-1.1s"/>
|
|
</g>
|
|
<path d="M180 730 C250 675 310 785 380 730 S510 675 580 730 S710 785 780 730 S910 675 980 730 S1110 785 1180 730 S1310 675 1420 730"
|
|
fill="none" stroke="#22c55e" stroke-width="3" opacity=".65" class="obs-dashflow"/>
|
|
<g transform="translate(220 500)" opacity=".55">
|
|
<circle r="44" fill="none" stroke="#86efac" stroke-width="3" class="obs-spin"/>
|
|
<circle r="22" fill="none" stroke="#22c55e" stroke-width="5" stroke-dasharray="7 5" class="obs-spin-rev"/>
|
|
</g>
|
|
<g class="obs-crt-jump" opacity=".5">
|
|
<path d="M1160 520h250M1160 540h180M1160 560h220" stroke="#22c55e" stroke-width="3"/>
|
|
</g>
|
|
<g fill="#22c55e" opacity=".72" font-family="monospace">
|
|
<text x="105" y="210" font-size="18">ASTROGATION ARRAY</text>
|
|
<text x="105" y="238" font-size="14">VECTOR MEMORY: LOCKED</text>
|
|
<text x="1490" y="210" font-size="18" text-anchor="end">SCAN 360°</text>
|
|
<text x="1490" y="238" font-size="14" text-anchor="end">ANALOG BEAM: ACTIVE</text>
|
|
</g>
|
|
`);
|
|
}
|
|
|
|
function renderObservationMilitary() {
|
|
const stars = makeStars(110, 1650, 900, '#fef3c7', 960);
|
|
return svgFrame(`
|
|
<rect width="1600" height="900" fill="#050506"/>
|
|
<g opacity=".42">${stars}</g>
|
|
<g transform="translate(800 470)" fill="none" stroke="#eab308">
|
|
<circle r="300" opacity=".25"/>
|
|
<circle r="220" opacity=".25"/>
|
|
<circle r="140" opacity=".25"/>
|
|
<circle r="60" opacity=".25"/>
|
|
<path d="M-330 0H330M0-330V330" opacity=".22"/>
|
|
</g>
|
|
<g stroke="#eab308" fill="none" stroke-width="3">
|
|
<path d="M1040 322 l28 -28 h42 l28 28 v42 l-28 28 h-42 l-28 -28z" class="obs-blink"/>
|
|
<path d="M570 545 l18 -18 h30 l18 18 v30 l-18 18 h-30 l-18 -18z" opacity=".65"/>
|
|
<path d="M930 625 l15 -15 h25 l15 15 v25 l-15 15 h-25 l-15 -15z" opacity=".5"/>
|
|
</g>
|
|
<g stroke="#ca8a04" fill="none" stroke-width="2" opacity=".6">
|
|
<path d="M800 470L1074 343" class="obs-dashflow"/>
|
|
<path d="M800 470L603 560" class="obs-dashflow"/>
|
|
</g>
|
|
<g transform="translate(800 470)" opacity=".28">
|
|
<g class="obs-spin-slow">
|
|
<path d="M0 0L0-300L62-255Z" fill="#eab308" stroke="none"/>
|
|
</g>
|
|
</g>
|
|
<g fill="#eab308" opacity=".52">
|
|
<circle r="4"><animateMotion dur="16s" repeatCount="indefinite" path="M420,650 L1180,295"/></circle>
|
|
<circle r="3"><animateMotion dur="23s" repeatCount="indefinite" path="M1240,610 L530,300"/></circle>
|
|
</g>
|
|
<g fill="#eab308" font-family="monospace">
|
|
<text x="1068" y="285" font-size="15">CONTACT 03 // TRACKING</text>
|
|
<text x="108" y="715" font-size="17" opacity=".72">TACTICAL WATCH // PASSIVE</text>
|
|
<text x="1490" y="715" text-anchor="end" font-size="17" opacity=".72">WEAPONS SAFE // SENSORS ACTIVE</text>
|
|
</g>
|
|
<g opacity=".75">
|
|
<rect x="120" y="250" width="190" height="12" fill="#eab308"/>
|
|
<rect x="120" y="278" width="130" height="8" fill="#71717a"/>
|
|
<rect x="1290" y="250" width="190" height="12" fill="#eab308"/>
|
|
<rect x="1350" y="278" width="130" height="8" fill="#71717a"/>
|
|
</g>
|
|
`);
|
|
}
|
|
|
|
function renderObservationDeepSpace() {
|
|
const starsA = makeStars(155, 1800, 900, '#eef2ff', 1220);
|
|
const starsB = makeStars(65, 1800, 900, '#818cf8', 1430);
|
|
// Planets, nebula clouds, dust lanes, derelicts and anomalies now live in the canvas
|
|
// engine's procedural, session-seeded Deep Space field (see ObservationEngine's
|
|
// regenerateDeepSpaceField / renderDeepSpaceField). This legacy SVG layer is kept
|
|
// intentionally sparse -- just a comet accent and the HUD label -- so it never paints a
|
|
// fixed duplicate planet/nebula on top of that varying scene.
|
|
return svgFrame(`
|
|
<rect width="1600" height="900" fill="#010207"/>
|
|
<g style="animation:obs-drift-left 125s linear infinite">${starsA}</g>
|
|
<g opacity=".48" style="animation:obs-drift-right 180s linear infinite">${starsB}</g>
|
|
<g opacity=".34" style="animation:obs-comet-drift 42s linear infinite">
|
|
<path d="M0 0L-180 38" stroke="#eef2ff" stroke-width="2"/>
|
|
<circle cx="0" cy="0" r="4" fill="#ffffff" filter="url(#obsSoftGlow)"/>
|
|
</g>
|
|
<text x="800" y="790" text-anchor="middle" fill="#818cf8" font-family="monospace" font-size="18" opacity=".55">
|
|
LONG RANGE OPTICAL // DEEP FIELD OBSERVATION
|
|
</text>
|
|
`);
|
|
}
|
|
|
|
function renderObservationOutlaw() {
|
|
const stars = makeStars(115, 1700, 900, '#fbcfe8', 1710);
|
|
return svgFrame(`
|
|
<rect width="1600" height="900" fill="#09040c"/>
|
|
<g style="animation:obs-drift-left 95s linear infinite" opacity=".55">${stars}</g>
|
|
<path d="M120 720 Q510 430 800 465 T1480 230" fill="none" stroke="#f59e0b" stroke-width="4" opacity=".45" class="obs-dashflow"/>
|
|
<g fill="none" stroke="#ec4899" opacity=".55">
|
|
<path d="M630 390h120v-45M970 390H850v-45M630 540h120v45M970 540H850v45" stroke-width="4"/>
|
|
<circle cx="800" cy="465" r="95" opacity=".25"/>
|
|
</g>
|
|
<g transform="translate(120 210)">
|
|
<g class="obs-crt-jump">
|
|
<rect width="290" height="170" rx="8" fill="#130919" stroke="#8b5cf6" stroke-width="3"/>
|
|
<text x="18" y="32" fill="#f472b6" font-size="15" font-family="monospace">REAR FEED // 02</text>
|
|
<path d="M40 130L120 80L180 112L250 55" stroke="#f59e0b" fill="none" stroke-width="4"/>
|
|
<circle cx="225" cy="68" r="5" fill="#ec4899" class="obs-blink"/>
|
|
</g>
|
|
</g>
|
|
<g fill="#f472b6" font-family="monospace" opacity=".72">
|
|
<text x="1190" y="675" font-size="17">ROUTE: MANUAL</text>
|
|
<text x="1190" y="702" font-size="14">TRANSPONDER: INTERMITTENT</text>
|
|
<text x="1190" y="729" font-size="14">SIGNAL QUALITY: 62%</text>
|
|
</g>
|
|
<rect x="490" y="785" width="620" height="4" fill="url(#obsFade)" opacity=".7"/>
|
|
`);
|
|
}
|
|
|
|
function renderObservationStations() {
|
|
const starsNear = makeStars(105, 1160, 540, '#e0f2fe', 1990);
|
|
const starsFar = makeStars(55, 1160, 540, '#7dd3fc', 2310);
|
|
const telemetryModule = (x, y, w, h, title, line1, line2 = '') => `
|
|
<g transform="translate(${x} ${y})">
|
|
<rect width="${w}" height="${h}" rx="10" fill="rgba(6,16,27,.78)" stroke="#0284c7" stroke-width="3"/>
|
|
<rect x="0" y="0" width="8" height="${h}" rx="4" fill="#f97316" opacity=".9"/>
|
|
<text x="22" y="28" fill="#fdba74" font-size="14" font-family="monospace">${title}</text>
|
|
<text x="22" y="55" fill="#bae6fd" font-size="16" font-family="monospace">${line1}</text>
|
|
${line2 ? `<text x="22" y="79" fill="#7dd3fc" opacity=".82" font-size="13" font-family="monospace">${line2}</text>` : ''}
|
|
</g>`;
|
|
|
|
return svgFrame(`
|
|
<defs>
|
|
<clipPath id="stationViewportClip">
|
|
<rect x="220" y="140" width="1160" height="540" rx="28"/>
|
|
</clipPath>
|
|
</defs>
|
|
|
|
<!-- Station Bulkhead Wall with Viewport Window Cutout (Native Station Viewport) -->
|
|
<path class="obs-station-bulkhead" d="M0,0 H1600 V900 H0 Z M220,140 h1160 a28,28 0 0 1 28,28 v484 a28,28 0 0 1 -28,28 h-1160 a28,28 0 0 1 -28,-28 v-484 a28,28 0 0 1 28,-28 Z" fill="#02070d" fill-rule="evenodd"/>
|
|
|
|
<!-- Station Viewport Structural Bezel & Outer Trim -->
|
|
<g class="obs-station-frame" transform="translate(220 140)">
|
|
<rect width="1160" height="540" rx="28" fill="none" stroke="#38bdf8" stroke-width="6"/>
|
|
<rect x="14" y="14" width="1132" height="512" rx="20" fill="none" stroke="#0ea5e9" stroke-width="2" opacity=".75"/>
|
|
</g>
|
|
|
|
<g clip-path="url(#stationViewportClip)">
|
|
|
|
<g transform="translate(220 140)">
|
|
<g style="animation:obs-drift-left 170s linear infinite" opacity=".82">${starsNear}</g>
|
|
<g style="animation:obs-drift-right 260s linear infinite" opacity=".42">${starsFar}</g>
|
|
|
|
<!-- Static sky glow removed: the rotating 360-degree canvas panorama now owns every
|
|
celestial element in this scene, so a fixed nebula haze would sit still in the
|
|
window while the rest of the sky wheels past it. -->
|
|
|
|
|
|
<g opacity=".62">
|
|
<g>
|
|
<path d="M885 540 L1090 408 L1186 438 L1010 572 Z"
|
|
fill="#0f3d57" opacity=".46"/>
|
|
<path d="M895 532 L1088 412" stroke="#38bdf8" stroke-width="3" opacity=".45"/>
|
|
<path d="M940 502 L1120 392" stroke="#7dd3fc" stroke-width="2" opacity=".28"/>
|
|
<circle cx="1114" cy="394" r="4" fill="#f97316" class="obs-blink"/>
|
|
<circle cx="1030" cy="446" r="3" fill="#7dd3fc" opacity=".65"/>
|
|
</g>
|
|
<g>
|
|
<path d="M148 448 L306 338 L372 366 L236 480 Z"
|
|
fill="#123247" opacity=".38"/>
|
|
<path d="M156 442 L304 342" stroke="#38bdf8" stroke-width="2.5" opacity=".36"/>
|
|
<path d="M194 418 L334 328" stroke="#7dd3fc" stroke-width="2" opacity=".22"/>
|
|
<circle cx="320" cy="336" r="3.5" fill="#f97316" class="obs-blink"/>
|
|
</g>
|
|
<g opacity=".34">
|
|
<path d="M0 470 H150" stroke="#164e63" stroke-width="18"/>
|
|
<path d="M1010 34 H1160" stroke="#164e63" stroke-width="14"/>
|
|
</g>
|
|
</g>
|
|
|
|
<g opacity=".65">
|
|
<path d="M160 385 C360 315 520 350 682 280 S945 188 1110 214"
|
|
fill="none" stroke="#38bdf8" stroke-width="2.5" stroke-dasharray="14 10" class="obs-route-wander"/>
|
|
<circle cx="1098" cy="217" r="5" fill="#f97316" class="obs-blink"/>
|
|
</g>
|
|
|
|
<g opacity=".58">
|
|
<path d="M990 430 C1030 382 1080 325 1168 262" fill="none" stroke="#fdba74" stroke-width="2" stroke-dasharray="9 9"/>
|
|
<circle cx="1168" cy="262" r="6" fill="#fdba74"/>
|
|
</g>
|
|
</g>
|
|
</g>
|
|
|
|
<g opacity=".95">
|
|
<path d="M350 140H1250" stroke="#38bdf8" stroke-width="4"/>
|
|
<path d="M350 680H1250" stroke="#38bdf8" stroke-width="4"/>
|
|
<path d="M220 270V550" stroke="#38bdf8" stroke-width="4"/>
|
|
<path d="M1380 270V550" stroke="#38bdf8" stroke-width="4"/>
|
|
|
|
<g stroke="#0ea5e9" stroke-width="5">
|
|
<path d="M220 230H220V140H310"/>
|
|
<path d="M1290 140H1380V230"/>
|
|
<path d="M220 590V680H310"/>
|
|
<path d="M1290 680H1380V590"/>
|
|
</g>
|
|
</g>
|
|
|
|
${telemetryModule(105, 170, 300, 96, 'OBSERVATION DECK', 'RING RATE 01.8 // STABLE', 'STARFIELD DRIFT 004.2')}
|
|
${telemetryModule(1195, 170, 300, 96, 'TRAFFIC CONTROL', 'DOCK VECTOR 118.4 // ROUTING', 'QUEUE 03 // CLEARANCE')}
|
|
${telemetryModule(105, 590, 320, 96, 'PORT STATUS', 'BAY 12 // ARRIVAL WINDOW', 'HOLDING ARC 02.7')}
|
|
${telemetryModule(1175, 590, 320, 96, 'TRANSIT SPINE', 'LOCAL FLOW 07 // NOMINAL', 'HABITAT SPIN 00.9')}
|
|
|
|
<g fill="none" stroke="#38bdf8" opacity=".45">
|
|
<circle cx="800" cy="410" r="300" stroke-width="2"/>
|
|
<circle cx="800" cy="410" r="240" stroke-width="1"/>
|
|
<path d="M510 410H1090M800 140V680" stroke-width="1"/>
|
|
</g>
|
|
|
|
<text x="800" y="760" text-anchor="middle" fill="#fdba74" font-size="20" font-family="monospace" opacity=".72">
|
|
STATION OBSERVATION // ROTATIONAL TRAFFIC WINDOW
|
|
</text>
|
|
`);
|
|
}
|
|
|
|
function renderObservationComedy() {
|
|
const stars = makeStars(120, 1700, 900, '#cffafe', 2260);
|
|
return svgFrame(`
|
|
<rect width="1600" height="900" fill="#07061a"/>
|
|
<g style="animation:obs-drift-left 105s linear infinite" opacity=".58">${stars}</g>
|
|
<path d="M160 700 C420 590 540 330 800 410 S1160 650 1430 250"
|
|
fill="none" stroke="#06b6d4" stroke-width="5" opacity=".5" class="obs-dashflow"/>
|
|
<g transform="translate(800 445)">
|
|
<circle r="170" fill="#06b6d4" opacity=".04"/>
|
|
<circle r="115" fill="none" stroke="#fbbf24" stroke-width="4" opacity=".55" class="obs-spin-slow"/>
|
|
<circle r="62" fill="none" stroke="#f43f5e" stroke-width="7" stroke-dasharray="22 15" class="obs-spin-rev"/>
|
|
<circle r="16" fill="#06b6d4" opacity=".75" class="obs-pulse"/>
|
|
</g>
|
|
<g font-family="monospace">
|
|
<text x="135" y="250" fill="#67e8f9" font-size="18">SCENIC NAVIGATION</text>
|
|
<text x="135" y="280" fill="#fbbf24" font-size="14">ROUTE CONFIDENCE: 99.7% PROBABLY FINE</text>
|
|
<text x="1465" y="650" text-anchor="end" fill="#f472b6" font-size="16" class="obs-blink">TEA SOURCE DETECTED</text>
|
|
<text x="1465" y="680" text-anchor="end" fill="#67e8f9" font-size="14">NO IMMEDIATE CAUSE FOR PANIC</text>
|
|
</g>
|
|
<g style="animation:obs-planet-drift 95s linear infinite" opacity=".62">
|
|
<circle cx="0" cy="335" r="45" fill="#fbbf24" opacity=".12"/>
|
|
<circle cx="0" cy="335" r="26" fill="#fbbf24" opacity=".18"/>
|
|
<circle cx="-54" cy="335" r="4" fill="#67e8f9"/>
|
|
</g>
|
|
`);
|
|
}
|
|
|
|
function getObservationSvg(universeId) {
|
|
switch (universeId) {
|
|
case 'starfleet': return renderObservationStarflight();
|
|
case 'whoniverse': return renderObservationWhataverse();
|
|
case 'industrial': return renderObservationIndustrial();
|
|
case 'bioships': return renderObservationBioships();
|
|
case 'retrofuture': return renderObservationRetro();
|
|
case 'military': return renderObservationMilitary();
|
|
case 'deepspace': return renderObservationDeepSpace();
|
|
case 'outlaw': return renderObservationOutlaw();
|
|
case 'spacestations': return renderObservationStations();
|
|
case 'comedy': return renderObservationComedy();
|
|
default: return renderObservationStarflight();
|
|
}
|
|
}
|
|
|
|
function refreshObservation() {
|
|
const universe = UniverseRegistry[activeUniverseId];
|
|
const preset = universe && universe.presets ? universe.presets[activePresetId] : null;
|
|
if (!universe || !preset) return;
|
|
|
|
observationUniverse.textContent = `${universe.shortCode} // OBSERVATION`;
|
|
observationProfile.textContent = preset.name.toUpperCase();
|
|
observationStatus.innerHTML =
|
|
`OBSERVATION ACTIVE<br>PROCEDURAL AUDIO LINK: ${isPlaying ? 'ONLINE' : 'STANDBY'}<br>VISUAL ACTIVITY: ${Math.round(observationActivity * 100)}%`;
|
|
|
|
observationOverlay.style.setProperty('--obs-activity', String(observationActivity));
|
|
observationStage.innerHTML = getObservationSvg(activeUniverseId);
|
|
initializeObservationSplineMorphs();
|
|
startObservationScene();
|
|
initializeObservationLiveInstrumentation();
|
|
|
|
if (observationEngine) {
|
|
observationEngine.start(activeUniverseId);
|
|
}
|
|
}
|
|
|
|
function enterObservation() {
|
|
if (observationActive) return;
|
|
observationActive = true;
|
|
observationOverlay.classList.add('active');
|
|
observationOverlay.setAttribute('aria-hidden', 'false');
|
|
refreshObservation();
|
|
resetObservationHudFade();
|
|
resetObservationReturnFade();
|
|
scheduleObservationAmbientActivity();
|
|
|
|
// Give the display an immediate but restrained sign of life.
|
|
if (observationActivity > .08) {
|
|
window.setTimeout(() => triggerObservationActivity('ambient'), 700);
|
|
}
|
|
}
|
|
|
|
function exitObservation() {
|
|
if (!observationActive) return;
|
|
observationActive = false;
|
|
|
|
if (observationEngine) {
|
|
observationEngine.stop();
|
|
}
|
|
|
|
if (observationAmbientTimer) {
|
|
clearTimeout(observationAmbientTimer);
|
|
observationAmbientTimer = null;
|
|
}
|
|
|
|
stopObservationLiveInstrumentation();
|
|
stopObservationSplineMorphs();
|
|
stopObservationScene();
|
|
|
|
if (observationChrome) {
|
|
observationChrome.style.transition = 'none';
|
|
observationChrome.classList.remove('observation-hud-fade');
|
|
void observationChrome.offsetWidth;
|
|
observationChrome.style.transition = '';
|
|
}
|
|
|
|
if (observationReturn) {
|
|
observationReturn.style.transition = 'none';
|
|
observationReturn.classList.remove('observation-return-fade');
|
|
void observationReturn.offsetWidth;
|
|
observationReturn.style.transition = '';
|
|
}
|
|
|
|
observationOverlay.classList.remove('active');
|
|
observationOverlay.setAttribute('aria-hidden', 'true');
|
|
observationStage.innerHTML = '';
|
|
observationEventLayer.innerHTML = '';
|
|
}
|
|
|
|
window.exitObservation = exitObservation;
|
|
window.refreshObservation = refreshObservation;
|
|
window.enterObservation = enterObservation;
|
|
window.selectPreset = selectPreset;
|
|
window.setUniverse = setUniverse;
|
|
|
|
btnObservation.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
enterObservation();
|
|
});
|
|
|
|
// Click background to exit, but ignore clicks on the control dock or its buttons
|
|
observationOverlay.addEventListener('click', (e) => {
|
|
if (e.target.closest('#observation-control-dock')) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
exitObservation();
|
|
});
|
|
|
|
const observationReturnPill = document.getElementById('observation-return-pill');
|
|
if (observationReturnPill) {
|
|
observationReturnPill.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
exitObservation();
|
|
});
|
|
}
|
|
|
|
// 10. Keyboard Shortcuts
|
|
window.addEventListener('keydown', (e) => {
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
|
|
|
|
if (e.code === 'Space') {
|
|
e.preventDefault();
|
|
togglePlay();
|
|
} else if (e.code === 'KeyM') {
|
|
toggleMute();
|
|
} else if (e.code === 'KeyW') {
|
|
if (observationActive && observationEngine) {
|
|
observationEngine.toggleWarpFlight();
|
|
return;
|
|
}
|
|
const primaryActionBtn = universeEventsContainer.children[0];
|
|
if (primaryActionBtn) primaryActionBtn.click();
|
|
} else if (e.code === 'KeyF') {
|
|
if (observationActive && observationEngine) {
|
|
observationEngine.toggleViewportFrame();
|
|
return;
|
|
}
|
|
} else if (e.code === 'KeyP') {
|
|
if (observationActive && observationEngine) {
|
|
observationEngine.togglePillars();
|
|
return;
|
|
}
|
|
} else if (e.code === 'KeyR') {
|
|
if (observationActive && observationEngine) {
|
|
observationEngine.toggleAlert();
|
|
return;
|
|
}
|
|
const firstAlertBtn = universeEventsContainer.children[1] || universeEventsContainer.children[0];
|
|
if (firstAlertBtn) firstAlertBtn.click();
|
|
} else if (e.code === 'ArrowUp' || e.code === 'Equal' || e.code === 'NumpadAdd') {
|
|
e.preventDefault();
|
|
setMasterVolume(audioManager.getMasterVolume() + 0.05);
|
|
} else if (e.code === 'ArrowDown' || e.code === 'Minus' || e.code === 'NumpadSubtract') {
|
|
e.preventDefault();
|
|
setMasterVolume(audioManager.getMasterVolume() - 0.05);
|
|
} else if (e.code >= 'Digit1' && e.code <= 'Digit9') {
|
|
const num = parseInt(e.code.replace('Digit', ''));
|
|
setMasterVolume(num * 0.1);
|
|
} else if (e.code === 'Digit0') {
|
|
setMasterVolume(1.0);
|
|
} else if (e.code === 'Escape') {
|
|
if (observationActive) {
|
|
exitObservation();
|
|
return;
|
|
}
|
|
alerts.stopAlert();
|
|
whoniverseAudio.stopCloisterBell();
|
|
resetAlertButtons();
|
|
}
|
|
});
|
|
|
|
// 11. Initial Setup
|
|
initUniverseDropdown();
|
|
setUniverse('starfleet');
|
|
updateMasterVolumeUI(0.75, false);
|
|
visualizer.init('spectrum-canvas', 'warp-core-canvas');
|
|
});
|