generated from Labyricorn/labyricorn-project-template
One commit for the work accumulated in the working tree since Step 6.3, which had never been split into per-step commits: - src/local-surfaces.js + src/surface-url.js (new); src/ui.js, src/validation.js, src/connection.js and public/index.html updated for local-surface hosting and generic surface rendering - tests: local-surfaces (20), scifi-surfaces (24) and postmessage-interop (7) new; connection/museum-gallery/surface-validation suites updated - reference exhibits: shared/contract-core.js defaults to Contract 5.3 (major 5, minor 3, xzbt 5.3); museum-gallery advertises its surface catalog; aquarium/haunted-house/planetarium adapters updated - SciFi-XZBT (Step 6.7A/6.7B): surface-mode.js + surface-bus.js, Observation-surface boot branch, local-change hooks, view.pillars / view.warp-flight targets; fixture byte-identical to G:/.vibe/SciFi-XZBT - SciFi-XZBT contract adapter handshake fix: the inbound bridge filter no longer gates on an exact advisory xzbt value (Contract 5.3 §6.5), only on its presence/type, matching the host's own envelope validation; the adapter now advertises contract minor 3 / version 5.3.0, which it already implemented via the 5.3 surfaces field. Root cause of the five failing postmessage-interop tests (host hello was silently dropped). - docs: architecture 6.4 and 6.7A, reference 6.6 and 6.7; evidence logs; test-fixtures/PROVENANCE.md resync record Test results: NGN 154/154 (was 149/154); postmessage-interop 7/7 (was 2/7); SciFi contract harness 21/21, real-adapter suite 32/32. git diff --check clean for changed files; two pre-existing trailing-whitespace lines remain in test-fixtures/reference-exhibits/scifi/index.html, copied verbatim from the authoritative SciFi source. Step 6.7 live verification (browser Observation, packaged standalone) is still pending and is not claimed here.
4922 lines
218 KiB
JavaScript
4922 lines
218 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', () => {
|
|
// Step 6.7B: boot-mode resolver (Contract 5.3 SS31.4 query-string surface
|
|
// form). 'console' is today's application, byte-for-byte -- the resolver
|
|
// returns 'console' for no query string and for any unrecognized
|
|
// ?surface= value. 'observation' renders only the Observation viewscreen
|
|
// and mirrors an authoritative console instance over an exhibit-internal
|
|
// BroadcastChannel; it never constructs a second contract session, audio
|
|
// graph or generative engine (see the isConsoleMode guards below).
|
|
const xzbtSurface = XZBTSurfaceMode.resolve(window.location);
|
|
const isConsoleMode = xzbtSurface.mode !== 'observation';
|
|
const xzbtInstanceId = isConsoleMode ? XZBTSurfaceMode.newInstanceId() : xzbtSurface.instanceId;
|
|
if (!isConsoleMode) document.body.classList.add('surface-observation');
|
|
let surfaceOwner = null; // console mode: bus owner, feeds attached surfaces
|
|
let surfaceLink = null; // observation mode: bus attachment to the console
|
|
|
|
// 1. Initialize Audio Subsystems
|
|
const audioManager = new AudioManager();
|
|
const fncSystem = new FuturisticNoiceCancellation(audioManager);
|
|
fncSystem.bindUI();
|
|
window.fncSystem = fncSystem;
|
|
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 whataverseAudio = new WhataverseAudioSynth(audioManager);
|
|
const expandedAudio = new ExpandedSciFiAudioSynth(audioManager);
|
|
const engineTransitions = new EngineTransitionSynth(audioManager);
|
|
window.expandedAudio = expandedAudio;
|
|
window.whataverseAudio = whataverseAudio;
|
|
window.engineTransitions = engineTransitions;
|
|
// Step 6.7B: console-only. Its canvases (spectrum, warp core) are
|
|
// console-panel elements; never construct it on the Observation surface.
|
|
const visualizer = isConsoleMode ? new StarshipVisualizer(audioManager, warpCore) : null;
|
|
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');
|
|
|
|
// XZBT Generative Experience subsystem. Deterministic engine remains
|
|
// authoritative. Step 6.7B: console-only -- WebLLM/Kokoro must never be
|
|
// constructed or prepared on the Observation surface (highest-risk guard
|
|
// in this design; see the isConsoleMode guard on prepareExperience() in
|
|
// enterObservation() below).
|
|
let generativeExperience = null;
|
|
if (isConsoleMode) {
|
|
generativeExperience = new XZBTGenerativeExperience({
|
|
getUniverse: () => UniverseRegistry[activeUniverseId],
|
|
getPreset: () => UniverseRegistry[activeUniverseId]?.presets?.[activePresetId] || null,
|
|
isPlaying: () => isPlaying,
|
|
isObservation: () => observationActive,
|
|
audioManager,
|
|
onGenerated: (line, state) => {
|
|
const ticker = document.getElementById('observation-ticker-text');
|
|
const tickerText = line.toUpperCase();
|
|
if (ticker && observationActive) ticker.textContent = tickerText;
|
|
// Mirror the announcement to any attached Observation surface(s):
|
|
// the AI only verbalizes state the authority already decided, so
|
|
// this is presentation-only text, not contract state (report SS6).
|
|
if (surfaceOwner) surfaceOwner.broadcastPresentation('ticker', { tickerText });
|
|
window.dispatchEvent(new CustomEvent('xzbt:generated-content', { detail: { line, state } }));
|
|
}
|
|
});
|
|
generativeExperience.bindUI();
|
|
window.generativeExperience = generativeExperience;
|
|
}
|
|
|
|
// Forward-declare absolute state setters & helpers for Contract Adapter
|
|
let setMasterVolumeDirect, setPlayingDirect, setMutedDirect, setFncEnabledDirect;
|
|
let setObservationDirect, setViewportFrameDirect, setAlertActiveDirect;
|
|
let setUniverseDirect, selectPresetDirect, getContractStateSnapshot;
|
|
// Step 6.7B: console-only. Never constructed on the Observation surface --
|
|
// this is what makes "no second contract session" a structural fact.
|
|
let controlBus = null;
|
|
let contractAdapter = null;
|
|
|
|
// Semantic control bus & XZBT Contract Adapter. Step 6.7B: constructed
|
|
// only in console mode -- see the isConsoleMode guard closing after
|
|
// generativeExperience.onSpeechStateChanged below.
|
|
if (isConsoleMode) {
|
|
controlBus = new XZBTControlBus();
|
|
window.xzbtControlBus = controlBus;
|
|
|
|
contractAdapter = new XZBTContractAdapter({
|
|
product: 'SciFi-XZBT',
|
|
version: '5.3.0',
|
|
surfaces: XZBTSurfaceMode.SURFACES(xzbtInstanceId),
|
|
instanceId: xzbtInstanceId,
|
|
bindings: {
|
|
getUniverse: () => activeUniverseId,
|
|
getPreset: () => activePresetId,
|
|
setUniverse: (uId, src) => setUniverseDirect(uId, src),
|
|
selectPreset: (pId, src) => selectPresetDirect(pId, src),
|
|
getState: () => getContractStateSnapshot(),
|
|
setters: {
|
|
'mix.master': v => setMasterVolumeDirect(v, false),
|
|
'mix.hull.level': v => {
|
|
hullDrone.setVolume(v);
|
|
sliderHullVol.value = v;
|
|
valHullVol.textContent = `${Math.round(v * 100)}%`;
|
|
},
|
|
'mix.hull.frequency': v => {
|
|
hullDrone.setBaseFreq(v);
|
|
sliderHullFreq.value = v;
|
|
valHullFreq.textContent = `${v} Hz`;
|
|
},
|
|
'mix.hull.cutoff': v => {
|
|
hullDrone.setFilterCutoff(v);
|
|
sliderHullCutoff.value = v;
|
|
valHullCutoff.textContent = `${v} Hz`;
|
|
},
|
|
'mix.drive.level': v => {
|
|
warpCore.setVolume(v);
|
|
sliderWarpVol.value = v;
|
|
valWarpVol.textContent = `${Math.round(v * 100)}%`;
|
|
},
|
|
'mix.drive.pulse-rate': v => {
|
|
warpCore.setBpm(v);
|
|
sliderWarpBpm.value = v;
|
|
valWarpBpm.textContent = `${v} BPM`;
|
|
},
|
|
'mix.drive.carrier': v => {
|
|
warpCore.setCarrierFreq(v);
|
|
sliderWarpCarrier.value = v;
|
|
valWarpCarrier.textContent = `${v} Hz`;
|
|
statCoreFreq.textContent = `${Number(v).toFixed(1)} Hz`;
|
|
},
|
|
'mix.environment.level': v => {
|
|
lifeSupport.setVolume(v);
|
|
sliderAirVol.value = v;
|
|
valAirVol.textContent = `${Math.round(v * 100)}%`;
|
|
},
|
|
'mix.environment.cutoff': v => {
|
|
lifeSupport.setFilterCutoff(v);
|
|
sliderAirCutoff.value = v;
|
|
valAirCutoff.textContent = `${v} Hz`;
|
|
},
|
|
'mix.telemetry.level': v => {
|
|
telemetry.setVolume(v);
|
|
sliderTelemetryVol.value = v;
|
|
valTelemetryVol.textContent = `${Math.round(v * 100)}%`;
|
|
},
|
|
'mix.telemetry.density': v => {
|
|
telemetry.setDensity(v);
|
|
sliderTelemetryDensity.value = v;
|
|
valTelemetryDensity.textContent = `${Math.round(v * 100)}%`;
|
|
},
|
|
'speech.robot-amount': v => {
|
|
if (generativeExperience.ui.robot) {
|
|
generativeExperience.ui.robot.value = String(v);
|
|
generativeExperience.syncRobotUI_();
|
|
}
|
|
},
|
|
'fnc.level': v => {
|
|
fncSystem.setLevel(v);
|
|
},
|
|
'view.activity': v => {
|
|
sliderObservationActivity.value = Math.round(v * 100);
|
|
setObservationActivity(v * 100);
|
|
if (observationActive) scheduleObservationInstrumentTick();
|
|
},
|
|
'transport.playing': (v, src) => setPlayingDirect(v, src),
|
|
'transport.muted': (v, src) => setMutedDirect(v, src),
|
|
'fnc.enabled': (v, src) => setFncEnabledDirect(v, src),
|
|
'view.observation': (v, src) => setObservationDirect(v, src),
|
|
'view.viewport-frame': (v, src) => setViewportFrameDirect(v, src),
|
|
'alert.active': (v, src) => setAlertActiveDirect(v, src),
|
|
// Step 6.7B: newly-canonical targets. observation-engine.js keeps
|
|
// setPillars(); warp-flight has no engine-side setter (only
|
|
// toggleWarpFlight()), so the desired boolean is compared against
|
|
// the engine's current flightMode before toggling -- this keeps
|
|
// observation-engine.js's own simulation code untouched.
|
|
'view.pillars': v => {
|
|
if (observationEngine) observationEngine.setPillars(!!v);
|
|
},
|
|
'view.warp-flight': v => {
|
|
if (!observationEngine) return;
|
|
const want = !!v;
|
|
const isWarp = observationEngine.flightMode === 'warp';
|
|
if (want !== isWarp) observationEngine.toggleWarpFlight();
|
|
}
|
|
},
|
|
invokers: {
|
|
'speech.say': async args => {
|
|
if (args && args.text) {
|
|
await generativeExperience.speak(args.text);
|
|
}
|
|
},
|
|
'speech.generate-announcement': () => generativeExperience.generateAmbientMoment('request'),
|
|
'display.ticker': args => {
|
|
const ticker = document.getElementById('observation-ticker-text');
|
|
if (ticker && args && typeof args.text === 'string') ticker.textContent = args.text;
|
|
},
|
|
'event.alert-red': () => {
|
|
alerts.triggerRedAlert('tng');
|
|
fncSystem.hold('alert', true);
|
|
if (observationEngine) observationEngine.updateAlertState();
|
|
}
|
|
},
|
|
handleUniverseEvent: async (evtType, src) => {
|
|
const universe = UniverseRegistry[activeUniverseId];
|
|
const aliases = { 'flak-barrage': 'flak', 'solar-roar': 'solar', 'ludicrous-speed': 'ludicrous', 'improbability-flip': 'improbability' };
|
|
const match = universe && universe.events ? universe.events.find(e => e.type === (aliases[evtType] || evtType) || e.id === `btn-${evtType}`) : null;
|
|
if (!match) return false;
|
|
await audioManager.resume();
|
|
const btn = document.getElementById(match.id);
|
|
handleUniverseEvent(match.type, btn);
|
|
return true;
|
|
},
|
|
handleSoundboardTrigger: async (sfxBtnId, src) => {
|
|
const aliases = { 'btn-console-chirp': 'btn-chirp-single', 'btn-telepathic-chime': 'btn-telepathic', 'btn-comlock': 'btn-commlock', 'btn-afterburner': 'btn-afterburner-snd' };
|
|
sfxBtnId = aliases[sfxBtnId] || sfxBtnId;
|
|
const universe = UniverseRegistry[activeUniverseId];
|
|
// The HAL override event uses this existing chime without a soundboard button.
|
|
if (sfxBtnId === 'btn-hal-chime') {
|
|
if (!universe?.events.some(evt => evt.type === 'hal')) return false;
|
|
await audioManager.resume();
|
|
expandedAudio.synthesizeHalChime();
|
|
return true;
|
|
}
|
|
const match = universe && universe.soundboard ? universe.soundboard.find(s => s.id === sfxBtnId) : null;
|
|
if (!match) return false;
|
|
await audioManager.resume();
|
|
handleSoundboardTrigger(sfxBtnId);
|
|
return true;
|
|
}
|
|
}
|
|
});
|
|
window.xzbtContractAdapter = contractAdapter;
|
|
fncSystem.onMutation = (id, value) => contractAdapter?.applyMutation(id, value, 'ui');
|
|
generativeExperience.onMutation = (id, value) => contractAdapter?.applyMutation(id, value, 'ui');
|
|
generativeExperience.onInvoke = id => contractAdapter?.invokeAction(id, {}, 'ui');
|
|
generativeExperience.onSpeechStateChanged = state => contractAdapter?.updateCapability('speech', state);
|
|
|
|
// Step 6.7B: exhibit-internal surface-attachment bus, owner side. Fed
|
|
// from the adapter's session-independent onLocalChange/onLocalAction
|
|
// hooks (contract-adapter.js SS5.4) rather than contract events, which are
|
|
// gated on an NGN session and would go silent standalone.
|
|
surfaceOwner = XZBTSurfaceBus.createOwner(contractAdapter, {
|
|
channelName: XZBTSurfaceMode.channelName(xzbtInstanceId),
|
|
// A surface attaching/detaching can flip observationAudienceActive();
|
|
// re-evaluate the scheduler so a pane opened with the console overlay
|
|
// off (or the last pane closing with it still off) starts/stops the
|
|
// ambient generator correctly (report SS6, SS8's symmetric detach case).
|
|
onParticipantsChanged: () => scheduleObservationAmbientActivity()
|
|
});
|
|
contractAdapter.onLocalChange = (target, value, stateRevision) => {
|
|
surfaceOwner.broadcastState(target, value, stateRevision);
|
|
};
|
|
contractAdapter.onLocalAction = (target, args) => {
|
|
surfaceOwner.broadcastAction(target, args);
|
|
};
|
|
} else {
|
|
// Step 6.7B: Observation surface (non-authoritative). Never constructs
|
|
// a contract session, control bus, or generative engine; attaches to
|
|
// the console instance named by ?xi= and mirrors its state over the
|
|
// exhibit-internal bus. `applyPresentationState` is defined further
|
|
// down (function declarations hoist; these callbacks only actually run
|
|
// once a message arrives, long after the rest of this script has run).
|
|
surfaceLink = XZBTSurfaceBus.attach({
|
|
channelName: XZBTSurfaceMode.channelName(xzbtInstanceId),
|
|
timeoutMs: 1500,
|
|
onSnapshot: (values, stateRevision, registryRevision, presentation) => {
|
|
Object.keys(values || {}).forEach(target => applyPresentationState(target, values[target]));
|
|
if (presentation && typeof presentation.tickerText === 'string' && presentation.tickerText) {
|
|
const ticker = document.getElementById('observation-ticker-text');
|
|
if (ticker) ticker.textContent = presentation.tickerText;
|
|
}
|
|
// The surface renders the scene unconditionally while open -- no
|
|
// view.observation gating (report SS8: surface existence and the
|
|
// console's own overlay flag are deliberately separate concepts).
|
|
enterObservation();
|
|
},
|
|
onChange: (target, value) => applyPresentationState(target, value),
|
|
onAction: (target, args) => {
|
|
if (target === 'display.ticker' && args && typeof args.text === 'string') {
|
|
const ticker = document.getElementById('observation-ticker-text');
|
|
if (ticker) ticker.textContent = args.text;
|
|
}
|
|
},
|
|
onPresentation: (kind, payload) => {
|
|
if (kind === 'ticker' && typeof payload.tickerText === 'string') {
|
|
const ticker = document.getElementById('observation-ticker-text');
|
|
if (ticker) ticker.textContent = payload.tickerText;
|
|
} else if (kind === 'obs-activity') {
|
|
// Render the authority's decision; never independently generate
|
|
// one (report SS6's "authority decides, surface renders").
|
|
triggerObservationActivity(payload.source || 'ambient');
|
|
}
|
|
},
|
|
onTimeout: () => {
|
|
// No console instance open on this channel (or a stale/unknown
|
|
// ?xi=). Degrade to a clear waiting state rather than inventing
|
|
// local authority (report SS9, Museum's proven behavior).
|
|
console.warn('[SciFi-XZBT] Observation surface: no console instance found for xi=' + xzbtInstanceId);
|
|
}
|
|
});
|
|
|
|
window.__xzbtSurfaceDispose = () => { if (surfaceLink) surfaceLink.detach(); };
|
|
window.addEventListener('beforeunload', () => { if (surfaceLink) surfaceLink.detach(); });
|
|
} // end isConsoleMode / else observation-surface attach (control bus, contract adapter, surface bus)
|
|
|
|
// Step 6.7B: the one dock-interaction chokepoint (already true before this
|
|
// step for every control except PILLARS/WARP, now including them). Console
|
|
// mode routes into the one mutation chokepoint as before; the Observation
|
|
// surface routes the same five interactive controls to the console over the
|
|
// bus instead, and drops view.observation (RETURN) locally -- posting it
|
|
// would toggle the *console's* own overlay, a cross-surface side effect
|
|
// the design explicitly forbids (report SS8). RETURN/exit are additionally
|
|
// hidden by CSS (body.surface-observation) as the primary defense.
|
|
const XZBT_PRESENTATION_ROUTED_TARGETS = new Set([
|
|
'view.warp-flight', 'view.viewport-frame', 'view.pillars', 'alert.active', 'preset.selected'
|
|
]);
|
|
observationEngine.onMutation = isConsoleMode
|
|
? (id, value) => contractAdapter?.applyMutation(id, value, 'ui')
|
|
: (id, value) => {
|
|
if (!XZBT_PRESENTATION_ROUTED_TARGETS.has(id)) return;
|
|
if (surfaceLink) surfaceLink.mutate('set', id, value);
|
|
};
|
|
|
|
// Step 6.7B: console-only (controlBus/contractAdapter don't exist otherwise).
|
|
if (isConsoleMode) {
|
|
// Intercept XZBTControlBus calls to route through contractAdapter
|
|
const origBusSet = controlBus.set.bind(controlBus);
|
|
const origBusTrigger = controlBus.trigger.bind(controlBus);
|
|
controlBus.set = function (id, value, source = 'external') {
|
|
if (contractAdapter?.legacyIdMap.has(id) || contractAdapter?.targets.has(id)) {
|
|
const canonicalId = contractAdapter?.legacyIdMap.get(id) || id;
|
|
const target = contractAdapter?.targets.get(canonicalId);
|
|
let normVal = value;
|
|
if (target && target.kind === 'range') {
|
|
if (contractAdapter?.legacyIdMap.has(id)) {
|
|
const legacy = controlBus.targets.get(id);
|
|
if (legacy?.step) normVal = Math.round(Number(value) / legacy.step) * legacy.step;
|
|
if (canonicalId === 'view.activity') normVal = Number(normVal) / 100;
|
|
}
|
|
}
|
|
contractAdapter?.applyMutation(canonicalId, normVal, source);
|
|
return true;
|
|
}
|
|
return origBusSet(id, value, source);
|
|
};
|
|
controlBus.trigger = function (id, source = 'external') {
|
|
if (contractAdapter?.legacyIdMap.has(id) || contractAdapter?.targets.has(id)) {
|
|
const canonicalId = contractAdapter?.legacyIdMap.get(id) || id;
|
|
const target = contractAdapter?.targets.get(canonicalId);
|
|
if (target && target.kind === 'state') {
|
|
// Toggle state if boolean
|
|
const curr = contractAdapter?.getContractState()[canonicalId];
|
|
contractAdapter?.applyMutation(canonicalId, !curr, source);
|
|
return true;
|
|
}
|
|
contractAdapter?.invokeAction(canonicalId, {}, source);
|
|
return true;
|
|
}
|
|
return origBusTrigger(id, source);
|
|
};
|
|
|
|
// Register internal slider targets onto XZBTControlBus for contract adapter routing
|
|
const rangeTarget = (id, label) => {
|
|
const el = document.getElementById(id);
|
|
if (!el) return;
|
|
controlBus.register(id.replace(/^slider-/, ''), {
|
|
label,
|
|
type: 'range',
|
|
min: Number(el.min || 0),
|
|
max: Number(el.max || 1),
|
|
step: Number(el.step || 0),
|
|
apply: v => {
|
|
el.value = v;
|
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
});
|
|
};
|
|
rangeTarget('slider-master-vol', 'Master Volume');
|
|
rangeTarget('slider-hull-vol', 'Hull Level');
|
|
rangeTarget('slider-hull-freq', 'Hull Base Tone');
|
|
rangeTarget('slider-hull-cutoff', 'Hull Damping Cutoff');
|
|
rangeTarget('slider-warp-vol', 'Core / Drive Level');
|
|
rangeTarget('slider-warp-bpm', 'Core / Drive Pulse Rate');
|
|
rangeTarget('slider-warp-carrier', 'Core / Drive Carrier');
|
|
rangeTarget('slider-air-vol', 'Environmental Level');
|
|
rangeTarget('slider-air-cutoff', 'Environmental Cutoff');
|
|
rangeTarget('slider-telemetry-vol', 'Telemetry Level');
|
|
rangeTarget('slider-telemetry-density', 'Telemetry Density');
|
|
rangeTarget('ai-robot-amount', 'Speech Automation / Robot');
|
|
rangeTarget('slider-fnc-level', 'FNC Level');
|
|
rangeTarget('slider-observation-activity', 'Experience Visual Activity');
|
|
controlBus.register('master-play', { label: 'Engage / Full Stop', type: 'action' });
|
|
controlBus.register('fnc-enable', { label: 'FNC Enable / Bypass', type: 'action' });
|
|
controlBus.register('watch-experience', { label: 'Watch Experience', type: 'action' });
|
|
controlBus.register('observation-viewport', { label: 'Observation Viewport Toggle', type: 'action' });
|
|
controlBus.register('red-alert', { label: 'Red Alert Toggle', type: 'action' });
|
|
controlBus.register('generate-announcement', { label: 'Generate AI Announcement', type: 'action' });
|
|
controlBus.register('mute', { label: 'Master Mute', type: 'action' });
|
|
} // end isConsoleMode (control bus semantic registration)
|
|
|
|
|
|
// TV presentation controls. The final Cast stream quality remains Chrome-controlled.
|
|
const tvAspect = document.getElementById('tv-aspect-lock'),
|
|
tvQuality = document.getElementById('tv-quality'),
|
|
tvQualityValue = document.getElementById('tv-quality-value');
|
|
const applyTV = () => {
|
|
document.body.classList.toggle('xzbt-tv-aspect', !!tvAspect?.checked);
|
|
document.body.classList.toggle('xzbt-tv-letterbox', !!tvAspect?.checked);
|
|
window.xzbtRenderScale = Number(tvQuality?.value || 1);
|
|
if (tvQualityValue) {
|
|
const labels = {
|
|
0.667: '720P TARGET',
|
|
1: '1080P TARGET',
|
|
1.333: '1440P TARGET',
|
|
2: '2160P TARGET'
|
|
};
|
|
tvQualityValue.textContent = labels[tvQuality.value] || '1080P TARGET';
|
|
}
|
|
window.dispatchEvent(new Event('resize'));
|
|
};
|
|
tvAspect?.addEventListener('change', applyTV);
|
|
tvQuality?.addEventListener('change', applyTV);
|
|
applyTV();
|
|
|
|
// 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();
|
|
contractAdapter?.applyMutation('universe.selected', u.id, 'ui');
|
|
});
|
|
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');
|
|
if (window.generativeExperience) window.generativeExperience.onThemeChanged();
|
|
if (window.fncSystem) window.fncSystem.setTheme(universeId);
|
|
|
|
// 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;
|
|
// Assigning className wholesale drops the TV presentation classes, so re-apply them.
|
|
applyTV();
|
|
// ...and the Step 6.7B surface-mode class, for the same reason.
|
|
if (!isConsoleMode) document.body.classList.add('surface-observation');
|
|
headerMatrixLabel.textContent = universe.headerTitle;
|
|
|
|
// Custom titles per universe
|
|
const titlesMap = {
|
|
'starfleet': {
|
|
sidebar: 'STARSHIP PRESETS',
|
|
visTitle: 'INTERMIX CASCADE',
|
|
visLabel: 'INTERMIX CHAMBER // ACTIVE',
|
|
soundboard: 'LCARD KEYPAD'
|
|
},
|
|
'whataverse': {
|
|
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', () => invokeConsoleEvent(evt, btn, 'ui'));
|
|
|
|
universeEventsContainer.appendChild(btn);
|
|
});
|
|
}
|
|
|
|
async function invokeConsoleEvent(evt, btn, source) {
|
|
if (evt.type === 'alert-red' || evt.type === 'alert-yellow') {
|
|
const value = evt.type === 'alert-red' ? 'red' : 'yellow';
|
|
return contractAdapter?.applyMutation('alert.active', alerts.activeAlert === value ? 'none' : value, source);
|
|
}
|
|
const canonical = [...contractAdapter?.targets.values()].find(t => t.kind === 'impulse' && t.id.startsWith('event.') && (t.id === `event.${evt.type}` || `btn-${t.id.slice(6)}` === evt.id));
|
|
if (canonical) return contractAdapter?.invokeAction(canonical.id, {}, source);
|
|
await audioManager.resume();
|
|
handleUniverseEvent(evt.type, 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 () => {
|
|
const aliases = { 'btn-chirp-single': 'sfx.console-chirp', 'btn-telepathic': 'sfx.telepathic-chime', 'btn-commlock': 'sfx.comlock', 'btn-afterburner-snd': 'sfx.afterburner' };
|
|
const canonical = aliases[key.id] || `sfx.${key.id.replace(/^btn-/, '')}`;
|
|
if (contractAdapter?.targets.has(canonical)) return contractAdapter?.invokeAction(canonical, {}, 'ui');
|
|
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', () => {
|
|
contractAdapter?.applyMutation('preset.selected', preset.id, 'ui');
|
|
});
|
|
|
|
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();
|
|
whataverseAudio.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;
|
|
if (window.fncSystem) window.fncSystem.resetLearning('preset change');
|
|
|
|
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();
|
|
whataverseAudio.stopAmbientLoops();
|
|
if (isPlaying) {
|
|
startPresetAmbience(presetId);
|
|
if (activeUniverseId === 'whataverse') whataverseAudio.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();
|
|
fncSystem.ensureGraph();
|
|
if (fncSystem.enabled) fncSystem.start();
|
|
// Prepare the configured local LLM and neural voice in parallel with startup.
|
|
generativeExperience.prepareExperience().catch(err => console.warn('Generative preload failed', err));
|
|
|
|
// 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 === 'whataverse') whataverseAudio.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);
|
|
}
|
|
if (generativeExperience.ui.auto?.checked) generativeExperience.scheduleNext();
|
|
}, 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 === 'whataverse') whataverseAudio.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();
|
|
whataverseAudio.stopAmbientLoops();
|
|
alerts.stopAlert();
|
|
whataverseAudio.stopCloisterBell();
|
|
fncSystem.hold('alert', false);
|
|
resetAlertButtons();
|
|
generativeExperience.stop();
|
|
fncSystem.resetLearning('full stop');
|
|
fncSystem.stop();
|
|
|
|
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', () => {
|
|
contractAdapter?.applyMutation('transport.playing', !isPlaying, 'ui');
|
|
});
|
|
|
|
// 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) {
|
|
contractAdapter?.applyMutation('mix.master', val, 'ui');
|
|
}
|
|
|
|
function toggleMute() {
|
|
contractAdapter?.applyMutation('transport.muted', !audioManager.isMuted, 'ui');
|
|
}
|
|
|
|
sliderMasterVol.addEventListener('input', (e) => {
|
|
contractAdapter?.applyMutation('mix.master', parseFloat(e.target.value), 'ui');
|
|
});
|
|
btnMasterMute.addEventListener('click', toggleMute);
|
|
btnHeaderMute.addEventListener('click', toggleMute);
|
|
btnVolDown.addEventListener('click', () => {
|
|
contractAdapter?.applyMutation('mix.master', Math.max(0, audioManager.getMasterVolume() - 0.05), 'ui');
|
|
});
|
|
btnVolUp.addEventListener('click', () => {
|
|
contractAdapter?.applyMutation('mix.master', Math.min(1, audioManager.getMasterVolume() + 0.05), 'ui');
|
|
});
|
|
|
|
volStepButtons.forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const stepVal = parseFloat(btn.getAttribute('data-vol'));
|
|
contractAdapter?.applyMutation('mix.master', stepVal, 'ui');
|
|
});
|
|
});
|
|
|
|
// 7. Channel Synthesizer Sliders
|
|
sliderHullVol.addEventListener('input', (e) => {
|
|
contractAdapter?.applyMutation('mix.hull.level', parseFloat(e.target.value), 'ui');
|
|
});
|
|
sliderHullFreq.addEventListener('input', (e) => {
|
|
contractAdapter?.applyMutation('mix.hull.frequency', parseFloat(e.target.value), 'ui');
|
|
});
|
|
sliderHullCutoff.addEventListener('input', (e) => {
|
|
contractAdapter?.applyMutation('mix.hull.cutoff', parseFloat(e.target.value), 'ui');
|
|
});
|
|
|
|
sliderWarpVol.addEventListener('input', (e) => {
|
|
contractAdapter?.applyMutation('mix.drive.level', parseFloat(e.target.value), 'ui');
|
|
});
|
|
sliderWarpBpm.addEventListener('input', (e) => {
|
|
contractAdapter?.applyMutation('mix.drive.pulse-rate', parseInt(e.target.value), 'ui');
|
|
});
|
|
sliderWarpCarrier.addEventListener('input', (e) => {
|
|
contractAdapter?.applyMutation('mix.drive.carrier', parseFloat(e.target.value), 'ui');
|
|
});
|
|
|
|
sliderAirVol.addEventListener('input', (e) => {
|
|
contractAdapter?.applyMutation('mix.environment.level', parseFloat(e.target.value), 'ui');
|
|
});
|
|
sliderAirCutoff.addEventListener('input', (e) => {
|
|
contractAdapter?.applyMutation('mix.environment.cutoff', parseFloat(e.target.value), 'ui');
|
|
});
|
|
|
|
sliderTelemetryVol.addEventListener('input', (e) => {
|
|
contractAdapter?.applyMutation('mix.telemetry.level', parseFloat(e.target.value), 'ui');
|
|
});
|
|
sliderTelemetryDensity.addEventListener('input', (e) => {
|
|
contractAdapter?.applyMutation('mix.telemetry.density', parseFloat(e.target.value), 'ui');
|
|
});
|
|
|
|
// 8. Universe Event Handlers
|
|
function resetAlertButtons() {
|
|
document.querySelectorAll('.btn-lcars-pill').forEach(b => b.classList.remove('active-alert'));
|
|
}
|
|
|
|
function handleUniverseEvent(type, btnElement) {
|
|
fncSystem.holdFor(6500, 'event');
|
|
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();
|
|
fncSystem.hold('alert', false);
|
|
resetAlertButtons();
|
|
} else {
|
|
resetAlertButtons();
|
|
btnElement.classList.add('active-alert');
|
|
alerts.triggerRedAlert('tng');
|
|
fncSystem.hold('alert', true);
|
|
}
|
|
break;
|
|
case 'alert-yellow':
|
|
if (alerts.activeAlert === 'yellow') {
|
|
alerts.stopAlert();
|
|
fncSystem.hold('alert', false);
|
|
resetAlertButtons();
|
|
} else {
|
|
resetAlertButtons();
|
|
btnElement.classList.add('active-alert');
|
|
alerts.triggerYellowAlert();
|
|
fncSystem.hold('alert', true);
|
|
}
|
|
break;
|
|
case 'demat':
|
|
whataverseAudio.synthesizeDematCycle(4);
|
|
visualizer?.spawnWarpPulses();
|
|
break;
|
|
case 'vortex':
|
|
whataverseAudio.synthesizeDematCycle(1);
|
|
visualizer?.spawnWarpPulses();
|
|
break;
|
|
case 'cloister':
|
|
if (whataverseAudio.activeCloister) {
|
|
whataverseAudio.stopCloisterBell();
|
|
fncSystem.hold('alert', false);
|
|
resetAlertButtons();
|
|
} else {
|
|
resetAlertButtons();
|
|
btnElement.classList.add('active-alert');
|
|
whataverseAudio.triggerCloisterBell();
|
|
fncSystem.hold('alert', true);
|
|
}
|
|
break;
|
|
case 'epstein':
|
|
case 'subdrive':
|
|
expandedAudio.synthesizeEpsteinBurn();
|
|
visualizer?.spawnWarpPulses();
|
|
break;
|
|
case 'purge':
|
|
case 'decompress':
|
|
case 'docking':
|
|
expandedAudio.synthesizeDockingClamp();
|
|
break;
|
|
case 'vent':
|
|
whataverseAudio.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;
|
|
}
|
|
generativeExperience?.describeExistingEvent(type);
|
|
}
|
|
|
|
function handleSoundboardTrigger(id) {
|
|
fncSystem.holdFor(2800, 'soundboard');
|
|
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':
|
|
whataverseAudio.synthesizeSonicScrewdriver();
|
|
break;
|
|
case 'btn-lever-clunk':
|
|
whataverseAudio.synthesizeLeverClunk();
|
|
break;
|
|
case 'btn-tardix-door':
|
|
whataverseAudio.synthesizeTARDIXDoor();
|
|
break;
|
|
case 'btn-fast-return':
|
|
whataverseAudio.synthesizeFastReturn();
|
|
break;
|
|
case 'btn-demat-switch':
|
|
case 'btn-relay-click':
|
|
whataverseAudio.synthesizeDematSwitch();
|
|
break;
|
|
case 'btn-telepathic':
|
|
case 'btn-telepathic-chime':
|
|
whataverseAudio.synthesizeTelepathicChime();
|
|
break;
|
|
case 'btn-scanner':
|
|
whataverseAudio.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':
|
|
whataverseAudio.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':
|
|
whataverseAudio.synthesizeScannerActivate();
|
|
break;
|
|
case 'btn-type-clatter':
|
|
whataverseAudio.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,
|
|
whataverse: 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 === 'whataverse') {
|
|
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);
|
|
}
|
|
|
|
// Step 6.7B: an "audience" is the console's own overlay OR at least one
|
|
// attached Observation surface. Scheduling/deciding activity uses this;
|
|
// *local rendering* still gates on observationActive alone (report SS6's
|
|
// audience-scheduler correction -- without it, an audience watching only
|
|
// the surface would see a static scene forever).
|
|
const observationAudienceActive = () => observationActive || !!(surfaceOwner && surfaceOwner.attachedCount() > 0);
|
|
|
|
function triggerObservationActivity(source = 'ambient') {
|
|
if (!observationAudienceActive() || observationActivity <= 0.001) return;
|
|
// The authority decides; attached surfaces render the same decision
|
|
// rather than independently rolling their own random event (report SS6,
|
|
// "Transient activity events are mirrored, not regenerated"). Exact
|
|
// visual parameters (which contact, which ring) are NOT byte-identical
|
|
// across documents -- see the accepted-limitations note in README.md /
|
|
// agents.md; only the "an event happens now" decision is synchronized.
|
|
if (isConsoleMode && surfaceOwner) surfaceOwner.broadcastPresentation('obs-activity', { source });
|
|
|
|
// 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,
|
|
whataverse: 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;
|
|
}
|
|
// The Observation surface never runs its own scheduler -- it only
|
|
// renders 'obs-activity' decisions relayed from the authority. Without
|
|
// this guard two documents would each roll independent random timers,
|
|
// violating "no duplicate random event generation" (report SS6/R3).
|
|
if (!isConsoleMode) return;
|
|
if (!observationAudienceActive() || 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', () => {
|
|
contractAdapter?.applyMutation('view.activity', Number(sliderObservationActivity.value) / 100, 'ui');
|
|
});
|
|
|
|
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'],
|
|
whataverse: ['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);
|
|
});
|
|
}
|
|
});
|
|
if (observationEventLayer) 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);
|
|
|
|
const gaugeArc = (r, startDeg = -140, endDeg = -40) => {
|
|
const sa = startDeg * Math.PI / 180;
|
|
const ea = endDeg * Math.PI / 180;
|
|
const sx = (Math.cos(sa) * r).toFixed(1);
|
|
const sy = (Math.sin(sa) * r).toFixed(1);
|
|
const ex = (Math.cos(ea) * r).toFixed(1);
|
|
const ey = (Math.sin(ea) * r).toFixed(1);
|
|
return `M ${sx} ${sy} A ${r} ${r} 0 0 1 ${ex} ${ey}`;
|
|
};
|
|
|
|
const gaugeTicks = (r, color) => {
|
|
const ticks = [-140, -115, -90, -65, -40];
|
|
return ticks.map((deg, idx) => {
|
|
const a = deg * Math.PI / 180;
|
|
const ix = Math.cos(a) * (r - 9);
|
|
const iy = Math.sin(a) * (r - 9);
|
|
const ox = Math.cos(a) * (r + (idx === 2 ? 10 : 5));
|
|
const oy = Math.sin(a) * (r + (idx === 2 ? 10 : 5));
|
|
return `<line x1="${ix.toFixed(1)}" y1="${iy.toFixed(1)}" x2="${ox.toFixed(1)}" y2="${oy.toFixed(1)}" stroke="${color}" stroke-width="${idx === 2 ? 3 : 2}" opacity="${idx === 2 ? '.85' : '.45'}"/>`;
|
|
}).join('');
|
|
};
|
|
|
|
const outlawGauge = ({ x, y, r, label, sublabel, arcColor, nodeColor, pointerColor, values, dur, begin = '0s', opacity = '.95', drift = false }) => {
|
|
const tipR = (r * 0.8).toFixed(1);
|
|
const arcPath = gaugeArc(r);
|
|
const smallArcPath = gaugeArc(r - 10, -132, -48);
|
|
return `
|
|
<g transform="translate(${x} ${y})" opacity="${opacity}">
|
|
<text x="0" y="-${r + 34}" text-anchor="middle" fill="#fde68a" font-size="14" font-family="monospace" letter-spacing="2">${label}</text>
|
|
<text x="0" y="-${r + 16}" text-anchor="middle" fill="#f472b6" opacity=".72" font-size="11" font-family="monospace">${sublabel}</text>
|
|
<path d="${arcPath}" fill="none" stroke="${arcColor}" stroke-width="6" stroke-linecap="round" opacity=".2"/>
|
|
<path d="${smallArcPath}" fill="none" stroke="${arcColor}" stroke-width="2" stroke-linecap="round" opacity=".72" stroke-dasharray="10 12">
|
|
<animate attributeName="stroke-dashoffset" values="0;-44" dur="5.8s" repeatCount="indefinite"/>
|
|
<animate attributeName="opacity" values=".35;.85;.35" dur="4.2s" repeatCount="indefinite"/>
|
|
</path>
|
|
${gaugeTicks(r, '#fbcfe8')}
|
|
<circle cx="0" cy="0" r="9" fill="#1a0b1f" stroke="#8b5cf6" stroke-width="2" opacity=".95"/>
|
|
<path d="M-${(r*0.92).toFixed(1)} ${(r*0.72).toFixed(1)} H ${(r*0.92).toFixed(1)}" stroke="#f472b6" stroke-width="1.2" opacity=".18"/>
|
|
<g${drift ? ' class="obs-crt-jump"' : ''}>
|
|
<g>
|
|
<animateTransform attributeName="transform" type="rotate" values="${values}" dur="${dur}" repeatCount="indefinite" begin="${begin}"/>
|
|
<line x1="0" y1="0" x2="0" y2="-${tipR}" stroke="${pointerColor}" stroke-width="3.2" stroke-linecap="round" opacity=".95"/>
|
|
<circle cx="0" cy="-${tipR}" r="5.5" fill="${nodeColor}" opacity=".95">
|
|
<animate attributeName="r" values="5.2;7;5.2" dur="2.4s" begin="${begin}" repeatCount="indefinite"/>
|
|
<animate attributeName="opacity" values=".9;1;.9" dur="2.4s" begin="${begin}" repeatCount="indefinite"/>
|
|
</circle>
|
|
<circle cx="0" cy="-${tipR}" r="13" fill="none" stroke="${nodeColor}" stroke-width="1.4" opacity=".32">
|
|
<animate attributeName="r" values="9;16;9" dur="2.4s" begin="${begin}" repeatCount="indefinite"/>
|
|
<animate attributeName="opacity" values=".25;.05;.25" dur="2.4s" begin="${begin}" repeatCount="indefinite"/>
|
|
</circle>
|
|
</g>
|
|
</g>
|
|
</g>`;
|
|
};
|
|
|
|
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 opacity=".94">
|
|
<text x="800" y="132" text-anchor="middle" fill="#fde68a" font-size="14" font-family="monospace" letter-spacing="3" opacity=".82">FIELD TUNING ARRAY</text>
|
|
<path d="M350 152H1250" stroke="#f472b6" stroke-width="1.5" opacity=".16" stroke-dasharray="8 10"/>
|
|
|
|
${outlawGauge({ x: 450, y: 250, r: 64, label: 'PORT BUS', sublabel: 'FLOAT', arcColor: '#fb923c', nodeColor: '#fde68a', pointerColor: '#f472b6', values: '-24 0 0; 18 0 0; -12 0 0; 22 0 0; -20 0 0; -24 0 0', dur: '8.4s', begin: '0s' })}
|
|
${outlawGauge({ x: 800, y: 244, r: 76, label: 'JUMP BIAS', sublabel: 'PEG / BOUNCE', arcColor: '#ec4899', nodeColor: '#fb923c', pointerColor: '#fde68a', values: '-6 0 0; 46 0 0; 38 0 0; 44 0 0; 10 0 0; -12 0 0; -6 0 0', dur: '5.8s', begin: '1.2s', drift: true })}
|
|
${outlawGauge({ x: 1150, y: 250, r: 64, label: 'VECTOR', sublabel: 'CENTER RANGE', arcColor: '#8b5cf6', nodeColor: '#fbcfe8', pointerColor: '#fb923c', values: '-38 0 0; 28 0 0; -14 0 0; 12 0 0; -3 0 0; 0 0 0; 7 0 0; 0 0 0', dur: '7.6s', begin: '.6s' })}
|
|
</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 'whataverse': 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();
|
|
}
|
|
}
|
|
|
|
// Step 6.7B: applies one mirrored contract-state value on the
|
|
// Observation surface. Reuses the existing local setters/functions
|
|
// directly -- never contractAdapter.applyMutation (there is none here) --
|
|
// so this is local *rendering*, not a second computation of authority.
|
|
// Targets the renderer doesn't care about (mix.*, fnc.*, transport.muted,
|
|
// speech.*) are intentionally ignored.
|
|
function applyPresentationState(target, value) {
|
|
switch (target) {
|
|
case 'universe.selected':
|
|
if (value && value !== activeUniverseId) setUniverse(value);
|
|
break;
|
|
case 'preset.selected':
|
|
if (value && value !== activePresetId) selectPreset(value);
|
|
break;
|
|
case 'view.viewport-frame':
|
|
if (observationEngine) observationEngine.setViewport(!!value);
|
|
break;
|
|
case 'view.pillars':
|
|
if (observationEngine) observationEngine.setPillars(!!value);
|
|
break;
|
|
case 'view.warp-flight': {
|
|
if (!observationEngine) break;
|
|
const isWarp = observationEngine.flightMode === 'warp';
|
|
if (!!value !== isWarp) observationEngine.toggleWarpFlight();
|
|
break;
|
|
}
|
|
case 'alert.active':
|
|
if (typeof setAlertActiveDirect === 'function') setAlertActiveDirect(value);
|
|
break;
|
|
case 'view.activity':
|
|
setObservationActivity(Number(value) * 100);
|
|
break;
|
|
case 'transport.playing':
|
|
// Cosmetic only (the "PROCEDURAL AUDIO LINK" status line) -- never
|
|
// engages audio on the surface.
|
|
isPlaying = !!value;
|
|
break;
|
|
default:
|
|
break; // view.observation, mix.*, fnc.*, speech.*, transport.muted: not rendered here.
|
|
}
|
|
}
|
|
|
|
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;
|
|
// Step 6.7B / R2: the single most important guard in this design.
|
|
// prepareExperience() lazily loads WebLLM + Kokoro (agents.md's one
|
|
// documented network/import exception); calling it unconditionally
|
|
// here would make every opened Observation surface download a
|
|
// multi-gigabyte model. Console-only.
|
|
if (isConsoleMode) {
|
|
generativeExperience.prepareExperience().catch(err => console.warn('Generative preload failed', err));
|
|
}
|
|
observationActive = true;
|
|
observationOverlay.classList.add('active');
|
|
observationOverlay.setAttribute('aria-hidden', 'false');
|
|
refreshObservation();
|
|
resetObservationHudFade();
|
|
resetObservationReturnFade();
|
|
if (isConsoleMode) {
|
|
// The Observation surface never runs its own scheduler -- it renders
|
|
// activity decisions broadcast by the authority (report SS6).
|
|
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 = '';
|
|
if (observationEventLayer) observationEventLayer.innerHTML = '';
|
|
}
|
|
|
|
window.exitObservation = exitObservation;
|
|
window.refreshObservation = refreshObservation;
|
|
window.enterObservation = enterObservation;
|
|
window.selectPreset = selectPreset;
|
|
window.setUniverse = setUniverse;
|
|
|
|
btnObservation.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
contractAdapter?.applyMutation('view.observation', !observationActive, 'ui');
|
|
});
|
|
|
|
// 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();
|
|
contractAdapter?.applyMutation('view.observation', false, 'ui');
|
|
});
|
|
|
|
const observationReturnPill = document.getElementById('observation-return-pill');
|
|
if (observationReturnPill) {
|
|
observationReturnPill.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
contractAdapter?.applyMutation('view.observation', false, 'ui');
|
|
});
|
|
}
|
|
|
|
// 10. Keyboard Shortcuts. Step 6.7B: console-only -- a keystroke on the
|
|
// Observation surface must not mutate state (report SS7).
|
|
if (isConsoleMode) {
|
|
window.addEventListener('keydown', (e) => {
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
|
|
|
|
if (e.code === 'Space') {
|
|
e.preventDefault();
|
|
contractAdapter?.applyMutation('transport.playing', !isPlaying, 'hotkey');
|
|
} else if (e.code === 'KeyM') {
|
|
contractAdapter?.applyMutation('transport.muted', !audioManager.isMuted, 'hotkey');
|
|
} else if (e.code === 'KeyW') {
|
|
if (observationActive && observationEngine) {
|
|
observationEngine.toggleWarpFlight();
|
|
return;
|
|
}
|
|
const primaryActionBtn = universeEventsContainer.children[0];
|
|
const primaryEvent = UniverseRegistry[activeUniverseId].events[0];
|
|
if (primaryEvent) invokeConsoleEvent(primaryEvent, primaryActionBtn, 'hotkey');
|
|
} else if (e.code === 'KeyF') {
|
|
if (observationActive && observationEngine) {
|
|
contractAdapter?.applyMutation('view.viewport-frame', !observationEngine.showViewport, 'hotkey');
|
|
return;
|
|
}
|
|
} else if (e.code === 'KeyP') {
|
|
if (observationActive && observationEngine) {
|
|
observationEngine.togglePillars();
|
|
return;
|
|
}
|
|
} else if (e.code === 'KeyR') {
|
|
if (observationActive && observationEngine) {
|
|
const nextAlert = alerts.activeAlert === 'red' ? 'none' : 'red';
|
|
contractAdapter?.applyMutation('alert.active', nextAlert, 'hotkey');
|
|
return;
|
|
}
|
|
const firstAlertBtn = universeEventsContainer.children[1] || universeEventsContainer.children[0];
|
|
const events = UniverseRegistry[activeUniverseId].events;
|
|
const alertEvent = events[1] || events[0];
|
|
if (alertEvent) invokeConsoleEvent(alertEvent, firstAlertBtn, 'hotkey');
|
|
} else if (e.code === 'ArrowUp' || e.code === 'Equal' || e.code === 'NumpadAdd') {
|
|
e.preventDefault();
|
|
contractAdapter?.applyMutation('mix.master', Math.min(1, audioManager.getMasterVolume() + 0.05), 'hotkey');
|
|
} else if (e.code === 'ArrowDown' || e.code === 'Minus' || e.code === 'NumpadSubtract') {
|
|
e.preventDefault();
|
|
contractAdapter?.applyMutation('mix.master', Math.max(0, audioManager.getMasterVolume() - 0.05), 'hotkey');
|
|
} else if (e.code >= 'Digit1' && e.code <= 'Digit9') {
|
|
const num = parseInt(e.code.replace('Digit', ''));
|
|
contractAdapter?.applyMutation('mix.master', num * 0.1, 'hotkey');
|
|
} else if (e.code === 'Digit0') {
|
|
contractAdapter?.applyMutation('mix.master', 1.0, 'hotkey');
|
|
} else if (e.code === 'Escape') {
|
|
if (observationActive) {
|
|
contractAdapter?.applyMutation('view.observation', false, 'hotkey');
|
|
return;
|
|
}
|
|
contractAdapter?.applyMutation('alert.active', 'none', 'hotkey');
|
|
}
|
|
});
|
|
} // end isConsoleMode (keyboard shortcuts)
|
|
|
|
// --- Implementation of Absolute Setters and Contract Helpers ---
|
|
setMasterVolumeDirect = function (val, isMuted = false) {
|
|
const clamped = Math.max(0, Math.min(1, Number(val)));
|
|
if (audioManager.isMuted) audioManager.setMute(false);
|
|
audioManager.setMasterVolume(clamped);
|
|
updateMasterVolumeUI(clamped, isMuted);
|
|
};
|
|
|
|
setPlayingDirect = async function (on) {
|
|
const targetOn = !!on;
|
|
if (isPlaying === targetOn && !isTransitioning) return;
|
|
// If a transition is currently in progress, wait for it to complete
|
|
while (isTransitioning) {
|
|
await new Promise(r => setTimeout(r, 100));
|
|
}
|
|
if (isPlaying === targetOn) return;
|
|
await togglePlay();
|
|
// Await transition completion so caller gets deterministic state
|
|
while (isTransitioning) {
|
|
await new Promise(r => setTimeout(r, 100));
|
|
}
|
|
};
|
|
|
|
setMutedDirect = function (on) {
|
|
const targetMuted = !!on;
|
|
if (audioManager.isMuted === targetMuted) return;
|
|
audioManager.setMute(targetMuted);
|
|
updateMasterVolumeUI(audioManager.getMasterVolume(), targetMuted);
|
|
};
|
|
|
|
setFncEnabledDirect = function (on) {
|
|
const targetEnabled = !!on;
|
|
if (fncSystem.enabled === targetEnabled) return;
|
|
fncSystem.setEnabled(targetEnabled);
|
|
};
|
|
|
|
setObservationDirect = function (on) {
|
|
const targetOn = !!on;
|
|
if (observationActive === targetOn) return;
|
|
if (targetOn) {
|
|
enterObservation();
|
|
} else {
|
|
exitObservation();
|
|
}
|
|
};
|
|
|
|
setViewportFrameDirect = function (on) {
|
|
const targetOn = !!on;
|
|
if (observationEngine) {
|
|
observationEngine.setViewport(targetOn);
|
|
}
|
|
};
|
|
|
|
setAlertActiveDirect = function (status) {
|
|
const targetStatus = status || 'none';
|
|
if (alerts.activeAlert === targetStatus || (targetStatus === 'none' && !alerts.activeAlert)) return;
|
|
|
|
if (targetStatus === 'none') {
|
|
alerts.stopAlert();
|
|
whataverseAudio.stopCloisterBell();
|
|
fncSystem.hold('alert', false);
|
|
resetAlertButtons();
|
|
} else if (targetStatus === 'red') {
|
|
resetAlertButtons();
|
|
alerts.triggerRedAlert('tng');
|
|
fncSystem.hold('alert', true);
|
|
document.getElementById('btn-red-alert')?.classList.add('active-alert');
|
|
} else if (targetStatus === 'yellow') {
|
|
resetAlertButtons();
|
|
alerts.triggerYellowAlert();
|
|
fncSystem.hold('alert', true);
|
|
document.getElementById('btn-yellow-alert')?.classList.add('active-alert');
|
|
}
|
|
if (observationEngine) observationEngine.updateAlertState();
|
|
};
|
|
|
|
setUniverseDirect = async function (universeId) {
|
|
setUniverse(universeId);
|
|
};
|
|
|
|
selectPresetDirect = async function (presetId) {
|
|
selectPreset(presetId);
|
|
};
|
|
|
|
getContractStateSnapshot = function () {
|
|
const vol = audioManager ? audioManager.getMasterVolume() : 0.75;
|
|
const isMuted = audioManager ? audioManager.isMuted : false;
|
|
let alertStatus = 'none';
|
|
if (alerts && alerts.activeAlert) alertStatus = alerts.activeAlert;
|
|
|
|
return {
|
|
'transport.playing': isPlaying,
|
|
'transport.muted': isMuted,
|
|
'mix.master': vol,
|
|
'mix.hull.level': hullDrone ? hullDrone.params.volume : 0.5,
|
|
'mix.hull.frequency': hullDrone ? hullDrone.params.baseFreq : 50,
|
|
'mix.hull.cutoff': hullDrone ? hullDrone.params.filterCutoff : 105,
|
|
'mix.drive.level': warpCore ? warpCore.params.volume : 0.5,
|
|
'mix.drive.pulse-rate': warpCore ? warpCore.params.bpm : 48,
|
|
'mix.drive.carrier': warpCore ? warpCore.params.carrierFreq : 58,
|
|
'mix.environment.level': lifeSupport ? lifeSupport.params.volume : 0.5,
|
|
'mix.environment.cutoff': lifeSupport ? lifeSupport.params.lowpassFreq : 1600,
|
|
'mix.telemetry.level': telemetry ? telemetry.params.volume : 0.5,
|
|
'mix.telemetry.density': telemetry ? telemetry.params.density : 0.5,
|
|
'speech.robot-amount': generativeExperience ? generativeExperience.roboticAmount() : 0,
|
|
'fnc.enabled': fncSystem ? fncSystem.enabled : false,
|
|
'fnc.level': fncSystem ? fncSystem.level : 0.5,
|
|
'view.observation': observationActive,
|
|
'view.viewport-frame': observationEngine ? observationEngine.showViewport : true,
|
|
'view.pillars': observationEngine ? observationEngine.showPillars : false,
|
|
'view.warp-flight': observationEngine ? observationEngine.flightMode === 'warp' : false,
|
|
'view.activity': observationActivity,
|
|
'alert.active': alertStatus,
|
|
'universe.selected': activeUniverseId,
|
|
'preset.selected': activePresetId
|
|
};
|
|
};
|
|
|
|
// 11. Initial Setup
|
|
initUniverseDropdown();
|
|
setUniverse('starfleet');
|
|
updateMasterVolumeUI(0.75, false);
|
|
visualizer?.init('spectrum-canvas', 'warp-core-canvas');
|
|
});
|