From d04016850c3fbd8701a03b7af621bf81ff33758e Mon Sep 17 00:00:00 2001 From: Labyricorn Date: Thu, 3 Sep 2026 21:08:52 -0700 Subject: [PATCH] Initial commit --- README.md | 40 +- css/style.css | 2317 +++++ dist/SciFiAmbientDisplay_v4.html | 13645 +++++++++++++++++++++++++++++ index.html | 367 + js/app.js | 3961 +++++++++ js/audio.js | 2258 +++++ js/config.js | 1039 +++ js/observation-bezels.js | 270 + js/observation-engine.js | 2783 ++++++ js/visualizer.js | 643 ++ tools/extract.ps1 | 80 + tools/package.ps1 | 77 + 12 files changed, 27479 insertions(+), 1 deletion(-) create mode 100644 css/style.css create mode 100644 dist/SciFiAmbientDisplay_v4.html create mode 100644 index.html create mode 100644 js/app.js create mode 100644 js/audio.js create mode 100644 js/config.js create mode 100644 js/observation-bezels.js create mode 100644 js/observation-engine.js create mode 100644 js/visualizer.js create mode 100644 tools/extract.ps1 create mode 100644 tools/package.ps1 diff --git a/README.md b/README.md index bcb6d75..b088ecb 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,40 @@ -# SciFi-XZBT +# SciFi-XZBT Modular Architecture & Repackaging +## Directory Overview + +```text +SciFi-XZBT/ +├── index.html # Clean entry point (368 lines) +├── css/ +│ └── style.css # LCARS styling, CRT effects, theme colors (2,317 lines) +├── js/ +│ ├── audio.js # Web Audio synthesis, oscillators, sound engines (2,258 lines) +│ ├── config.js # Starship presets, Universe registry & palettes (1,039 lines) +│ ├── visualizer.js # Audio spectrum & Warp Core canvas rendering (643 lines) +│ ├── observation-bezels.js # Procedural SVG Viewport bezels (TOS, Voyager, TNG, etc.) (271 lines) +│ ├── observation-engine.js # Full observation mode canvas and simulation engine (2,784 lines) +│ └── app.js # DOM bindings, hotkeys, UI controllers & loop (3,962 lines) +├── dist/ +│ └── SciFiAmbientDisplay_v4.html # Standalone, single-file offline build +└── tools/ + ├── package.ps1 # 1-click script to build the standalone single-file HTML + └── extract.ps1 # Original extraction script for reference +``` + +--- + +## Development Workflow + +1. **Developing Modularly**: + - Edit the specific CSS or JS files directly. + - For audio or synthesizer logic, focus exclusively on `js/audio.js`. + - For viewscreen bezels and framing, edit `js/observation-bezels.js`. + - For observation celestial mechanics and canvases, edit `js/observation-engine.js`. + - For presets and sound matrix tables, edit `js/config.js`. + +2. **Repackaging to Single-File**: + Run the packaging script from PowerShell: + ```powershell + powershell -ExecutionPolicy Bypass -File .\tools\package.ps1 + ``` + This immediately produces `dist\SciFiAmbientDisplay_v4.html`, combining all CSS and JS back into a self-contained, double-clickable offline file. diff --git a/css/style.css b/css/style.css new file mode 100644 index 0000000..89069c2 --- /dev/null +++ b/css/style.css @@ -0,0 +1,2317 @@ +/** + * Authentic LCARS & Multi-Era Star Trek Styling + * Includes TNG 24th Century, TOS Retro 23rd Century, and NX-01 Industrial themes. + */ + +@import url('https://fonts.googleapis.com/css2?family=Antonio:wght@400;600;700&family=Share+Tech+Mono&display=swap'); + +:root { + /* TNG 24th Century LCARS Palette */ + --lcars-orange: #ff9900; + --lcars-tangerine: #ff6600; + --lcars-amber: #cc6600; + --lcars-sand: #ffcc66; + --lcars-lavender: #cc99cc; + --lcars-violet: #9966cc; + --lcars-ice-blue: #99ccff; + --lcars-blue: #3366cc; + --lcars-red: #cc3333; + --lcars-crimson: #990000; + --lcars-bg: #000000; + --lcars-panel-bg: #0d0f14; + --lcars-font: 'Antonio', 'Arial Narrow', sans-serif; + --mono-font: 'Share Tech Mono', monospace; + + /* Theme variables (Defaults to TNG) */ + --primary-accent: var(--lcars-orange); + --secondary-accent: var(--lcars-lavender); + --tertiary-accent: var(--lcars-ice-blue); + --warning-accent: var(--lcars-red); +} + +/* Voyager Era Palette */ +body.theme-lcars-voyager { + --primary-accent: #3399cc; + --secondary-accent: #9999ff; + --tertiary-accent: #66cccc; + --warning-accent: #cc3333; +} + +/* Defiant Combat Palette */ +body.theme-lcars-defiant { + --primary-accent: #cc5500; + --secondary-accent: #aa3300; + --tertiary-accent: #dd8800; + --warning-accent: #ff0000; +} + +/* Deep Space Nine Palette */ +body.theme-lcars-ds9 { + --primary-accent: #997755; + --secondary-accent: #bb8844; + --tertiary-accent: #667788; + --warning-accent: #bb3322; +} + +/* TOS Retro 23rd Century Palette */ +body.theme-tos-retro { + --primary-accent: #ffcc00; + --secondary-accent: #0099ff; + --tertiary-accent: #cc0000; + --lcars-panel-bg: #15181c; + --lcars-font: 'Share Tech Mono', monospace; +} + +/* NX Industrial Palette */ +body.theme-nx-industrial { + --primary-accent: #33bbee; + --secondary-accent: #88aacc; + --tertiary-accent: #445566; + --lcars-panel-bg: #0e1217; + --lcars-font: 'Share Tech Mono', monospace; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; + user-select: none; +} + +body { + background-color: var(--lcars-bg); + color: #ffffff; + font-family: var(--lcars-font); + letter-spacing: 0.05em; + overflow-x: hidden; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* Header & Frame */ +.lcars-header { + display: flex; + align-items: stretch; + height: 64px; + background: transparent; + padding: 8px 16px 0 16px; + gap: 12px; +} + +.lcars-elbow-left { + width: 180px; + background-color: var(--primary-accent); + border-top-left-radius: 32px; + display: flex; + align-items: center; + justify-content: flex-end; + padding-right: 14px; + font-size: 1.25rem; + font-weight: 700; + color: #000000; + text-transform: uppercase; +} + +.lcars-top-bar { + flex: 1; + background-color: var(--secondary-accent); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + font-size: 1.1rem; + font-weight: 700; + color: #000000; + text-transform: uppercase; +} + +.lcars-pill-end { + width: 70px; + background-color: var(--tertiary-accent); + border-top-right-radius: 20px; + border-bottom-right-radius: 20px; +} + +/* Header Volume Dock */ +.header-volume-dock { + display: flex; + align-items: center; + gap: 8px; + background: rgba(0, 0, 0, 0.25); + padding: 2px 10px; + border-radius: 12px; +} + +.btn-header-mute { + background: #000000; + color: var(--secondary-accent); + border: 1px solid var(--secondary-accent); + border-radius: 8px; + padding: 2px 8px; + font-family: var(--lcars-font); + font-size: 0.8rem; + font-weight: 700; + cursor: pointer; + transition: all 0.15s; +} + +.btn-header-mute:hover { + background: var(--secondary-accent); + color: #000; +} + +.btn-header-mute.muted { + background: #ff0000; + color: #ffffff; + border-color: #ff0000; +} + +.header-vol-val { + font-family: var(--mono-font); + font-size: 0.85rem; + color: #000; + font-weight: 700; + min-width: 32px; +} + +/* Main Layout Grid */ +.lcars-container { + display: grid; + grid-template-columns: 240px 1fr 300px; + gap: 16px; + padding: 16px; + flex: 1; +} + +@media (max-width: 1080px) { + .lcars-container { + grid-template-columns: 220px 1fr; + } + .right-column { + grid-column: span 2; + } +} + +@media (max-width: 768px) { + .lcars-container { + grid-template-columns: 1fr; + } + .lcars-elbow-left { + width: 100px; + font-size: 0.9rem; + } +} + +/* Left Sidebar - Presets & Ship Selector */ +.left-sidebar { + display: flex; + flex-direction: column; + gap: 10px; +} + +.sidebar-heading { + background-color: var(--primary-accent); + color: #000; + padding: 6px 12px; + font-weight: 700; + font-size: 1rem; + text-transform: uppercase; + border-radius: 4px; +} + +.preset-list { + display: flex; + flex-direction: column; + gap: 6px; + max-height: calc(100vh - 220px); + overflow-y: auto; + padding-right: 4px; +} + +.preset-btn { + background-color: #1a1e27; + color: #e0e6ed; + border: 2px solid transparent; + padding: 10px 12px; + text-align: left; + font-family: var(--lcars-font); + font-size: 0.95rem; + font-weight: 600; + text-transform: uppercase; + border-radius: 0 16px 16px 0; + cursor: pointer; + transition: all 0.15s ease; + display: flex; + flex-direction: column; + gap: 2px; +} + +.preset-btn:hover { + background-color: #272d3b; + border-left: 6px solid var(--primary-accent); + padding-left: 8px; +} + +.preset-btn.active { + background-color: var(--primary-accent); + color: #000000; + font-weight: 700; +} + +.preset-btn .ship-era-tag { + font-size: 0.7rem; + opacity: 0.75; + font-family: var(--mono-font); +} + +/* Center Panel - Main Controls & Mixers */ +.center-panel { + display: flex; + flex-direction: column; + gap: 16px; +} + +.panel-card { + background-color: var(--lcars-panel-bg); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + padding: 16px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.panel-header { + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 2px solid var(--primary-accent); + padding-bottom: 6px; +} + +.panel-title { + font-size: 1.25rem; + font-weight: 700; + color: var(--primary-accent); + text-transform: uppercase; +} + +.panel-sub { + font-family: var(--mono-font); + font-size: 0.85rem; + color: #8899aa; +} + +/* Transport Controls Bar */ +.transport-bar { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.btn-lcars-large { + background-color: var(--primary-accent); + color: #000; + font-family: var(--lcars-font); + font-size: 1.3rem; + font-weight: 700; + padding: 12px 28px; + border: none; + border-radius: 24px; + cursor: pointer; + text-transform: uppercase; + transition: transform 0.1s, filter 0.15s; + display: inline-flex; + align-items: center; + gap: 8px; +} + +.btn-lcars-large:hover { + filter: brightness(1.2); +} + +.btn-lcars-large:active { + transform: scale(0.97); +} + +.btn-lcars-large.playing { + background-color: #ff3333; + color: #fff; +} + +/* Action Pill Buttons */ +.pill-button-group { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.btn-lcars-pill { + background-color: var(--secondary-accent); + color: #000; + font-family: var(--lcars-font); + font-size: 0.95rem; + font-weight: 700; + padding: 8px 16px; + border: none; + border-radius: 14px; + cursor: pointer; + text-transform: uppercase; + transition: all 0.15s; +} + +.btn-lcars-pill:hover { + filter: brightness(1.25); +} + +.btn-lcars-pill.active-alert { + background-color: #ff0000 !important; + color: #ffffff !important; + animation: lcars-red-pulse 1s infinite alternate; +} + +@keyframes lcars-red-pulse { + 0% { box-shadow: 0 0 4px #ff0000; } + 100% { box-shadow: 0 0 20px #ff0000; } +} + +/* Dedicated Master Volume Console */ +.master-volume-console { + background: rgba(255, 153, 0, 0.05); + border: 1px solid var(--primary-accent); + border-left: 6px solid var(--primary-accent); + border-radius: 6px; + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.master-vol-top { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.master-vol-title-group { + display: flex; + align-items: baseline; + gap: 10px; +} + +.master-vol-tag { + font-family: var(--lcars-font); + font-size: 1.05rem; + font-weight: 700; + color: var(--primary-accent); + text-transform: uppercase; +} + +.master-vol-readout { + font-family: var(--mono-font); + font-size: 1.4rem; + font-weight: 700; + color: #ffffff; +} + +.master-vol-db { + font-family: var(--mono-font); + font-size: 0.85rem; + color: #00e6ff; +} + +.master-vol-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.btn-mute { + background-color: var(--primary-accent); + color: #000; + font-weight: 700; + min-width: 68px; + text-align: center; +} + +.btn-mute.muted { + background-color: #ff0000 !important; + color: #ffffff !important; + box-shadow: 0 0 10px #ff0000; +} + +.master-vol-presets { + display: flex; + gap: 4px; +} + +.btn-vol-step { + background: #1e2430; + color: #a0b4cc; + border: 1px solid #334455; + border-radius: 4px; + padding: 4px 8px; + font-family: var(--mono-font); + font-size: 0.75rem; + cursor: pointer; + transition: all 0.1s; +} + +.btn-vol-step:hover { + background: var(--primary-accent); + color: #000; + border-color: var(--primary-accent); +} + +.btn-vol-step.active { + background: var(--primary-accent); + color: #000; + font-weight: bold; +} + +/* Master Slider Row & Level Meter */ +.master-slider-row { + display: flex; + align-items: center; + gap: 12px; +} + +.vol-adj-btn { + width: 32px; + height: 32px; + background: #1b212c; + color: var(--primary-accent); + border: 1px solid var(--primary-accent); + border-radius: 50%; + font-family: var(--mono-font); + font-size: 1.2rem; + font-weight: bold; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.1s; + flex-shrink: 0; +} + +.vol-adj-btn:hover { + background: var(--primary-accent); + color: #000; +} + +.vol-adj-btn:active { + transform: scale(0.92); +} + +.slider-track-container { + flex: 1; + display: flex; + flex-direction: column; + gap: 6px; +} + +.slider-master-large { + height: 12px !important; + border-radius: 6px !important; + background: #161b24 !important; +} + +.slider-master-large::-webkit-slider-thumb { + width: 24px !important; + height: 24px !important; + background: var(--primary-accent) !important; + box-shadow: 0 0 10px rgba(255, 153, 0, 0.8) !important; +} + +/* Segmented LED Volume Meter */ +.vol-meter-leds { + display: flex; + gap: 3px; + height: 6px; + width: 100%; +} + +.led-pip { + flex: 1; + background: #18202c; + border-radius: 1px; + transition: background 0.08s; +} + +.led-pip.active:nth-child(-n+6) { + background: #00e6ff; + box-shadow: 0 0 4px #00e6ff; +} + +.led-pip.active:nth-child(n+7):nth-child(-n+8) { + background: #ffcc00; + box-shadow: 0 0 4px #ffcc00; +} + +.led-pip.active:nth-child(n+9) { + background: #ff3333; + box-shadow: 0 0 4px #ff3333; +} + +/* Multi-Channel Synthesizer Mixer */ +.mixer-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 14px; +} + +.channel-strip { + background: rgba(255, 255, 255, 0.03); + border-left: 4px solid var(--primary-accent); + padding: 12px; + border-radius: 0 8px 8px 0; + display: flex; + flex-direction: column; + gap: 10px; +} + +.channel-header { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.95rem; + font-weight: 700; + color: var(--primary-accent); + text-transform: uppercase; +} + +.control-row { + display: flex; + flex-direction: column; + gap: 4px; +} + +.control-label { + display: flex; + justify-content: space-between; + font-family: var(--mono-font); + font-size: 0.75rem; + color: #a0b0c0; +} + +/* Sliders */ +input[type="range"] { + -webkit-appearance: none; + width: 100%; + height: 8px; + background: #202634; + border-radius: 4px; + outline: none; +} + +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--primary-accent); + cursor: pointer; + border: 2px solid #000; + box-shadow: 0 0 6px rgba(255, 153, 0, 0.5); +} + +/* Right Sidebar - Warp Core & Diagnostic Monitor */ +.right-column { + display: flex; + flex-direction: column; + gap: 16px; +} + +.telemetry-triggers { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.btn-chirp { + background-color: #232b3b; + color: var(--tertiary-accent); + border: 1px solid var(--tertiary-accent); + border-radius: 6px; + padding: 8px; + font-family: var(--lcars-font); + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + text-transform: uppercase; + transition: all 0.1s; +} + +.btn-chirp:hover { + background-color: var(--tertiary-accent); + color: #000; +} + +.btn-chirp:active { + transform: scale(0.95); +} + +/* Sleep Timer Display */ +.timer-readout { + font-family: var(--mono-font); + font-size: 1.1rem; + color: #00e6ff; + text-align: center; + background: #131a26; + padding: 6px; + border-radius: 4px; + border: 1px solid rgba(0, 230, 255, 0.3); +} + +/* Footer status bar */ +.lcars-footer { + margin-top: auto; + padding: 8px 16px; + background-color: var(--primary-accent); + color: #000; + display: flex; + justify-content: space-between; + font-weight: 700; + font-size: 0.9rem; + text-transform: uppercase; +} + + +/** + * Canvas Visualizers & Diagnostic Displays CSS + */ + +.visualizer-container { + display: flex; + flex-direction: column; + gap: 8px; +} + +.canvas-wrapper { + position: relative; + width: 100%; + background-color: #080b10; + border: 1px solid rgba(255, 153, 0, 0.25); + border-radius: 6px; + overflow: hidden; +} + +.canvas-wrapper canvas { + display: block; + width: 100%; +} + +.spectrum-wrapper { + height: 140px; +} + +.warp-core-wrapper { + height: 240px; +} + +.canvas-label-overlay { + position: absolute; + top: 6px; + left: 8px; + font-family: var(--mono-font); + font-size: 0.7rem; + color: var(--primary-accent); + letter-spacing: 0.1em; + pointer-events: none; + background: rgba(0, 0, 0, 0.6); + padding: 2px 6px; + border-radius: 3px; +} + +.diagnostic-stats { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; + font-family: var(--mono-font); + font-size: 0.75rem; +} + +.stat-box { + background: rgba(255, 255, 255, 0.04); + padding: 6px 8px; + border-left: 2px solid var(--secondary-accent); + display: flex; + flex-direction: column; +} + +.stat-label { + color: #8899aa; + font-size: 0.65rem; +} + +.stat-value { + color: #00e6ff; + font-weight: bold; +} + + +/** + * Whoniverse (Doctor Who TARDIS) Aesthetic Stylesheet & Universe Switcher Dropdown + */ + +/* Universe Switcher Dropdown */ +.universe-selector-container { + position: relative; + display: inline-block; + cursor: pointer; +} + +.universe-dropdown-menu { + display: none; + position: absolute; + top: 100%; + left: 0; + min-width: 220px; + background-color: #0b111a; + border: 2px solid var(--primary-accent); + border-radius: 0 0 12px 12px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.9); + z-index: 1000; + overflow: hidden; +} + +.universe-dropdown-menu.show { + display: flex; + flex-direction: column; +} + +.universe-item { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + color: #ffffff; + font-family: var(--lcars-font); + font-size: 1rem; + font-weight: 700; + text-transform: uppercase; + background: transparent; + border: none; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + cursor: pointer; + text-align: left; + transition: all 0.15s ease; +} + +.universe-item:hover { + background-color: var(--primary-accent); + color: #000000; +} + +.universe-item.active { + background-color: rgba(255, 255, 255, 0.12); + border-left: 4px solid var(--primary-accent); +} + +.universe-item .u-icon { + font-size: 1.2rem; + width: 24px; + text-align: center; +} + +/* ========================================================================== + WHONIVERSE (TARDIS CONSOLE) THEME + ========================================================================== */ + +body.theme-whoniverse-tardis { + --tardis-blue: #003b6f; + --tardis-deep: #001f3f; + --tardis-cyan: #00e5ff; + --tardis-teal: #008080; + --tardis-amber: #d4af37; + --tardis-brass: #b8860b; + --tardis-roundel: #e0f2fe; + + --primary-accent: var(--tardis-cyan); + --secondary-accent: var(--tardis-blue); + --tertiary-accent: var(--tardis-amber); + --warning-accent: #cc0000; + + --lcars-bg: #030712; + --lcars-panel-bg: #070e1b; + --lcars-font: 'Share Tech Mono', monospace; + background-color: #030712; + background-image: + radial-gradient(circle at 50% 10%, rgba(0, 59, 111, 0.3) 0%, transparent 60%), + radial-gradient(circle at 10% 90%, rgba(0, 229, 255, 0.05) 0%, transparent 40%); +} + +/* Header Styling for TARDIS Police Box */ +body.theme-whoniverse-tardis .lcars-elbow-left { + background: linear-gradient(135deg, #004a8b, #002244); + color: #ffffff; + border: 1px solid var(--tardis-cyan); + border-radius: 4px 4px 0 0; + box-shadow: 0 0 12px rgba(0, 229, 255, 0.3); + letter-spacing: 0.15em; +} + +body.theme-whoniverse-tardis .lcars-top-bar { + background: #001f3f; + color: #ffffff; + border: 1px solid rgba(0, 229, 255, 0.4); + border-radius: 4px; +} + +body.theme-whoniverse-tardis .header-vol-val { + color: var(--tardis-cyan); +} + +body.theme-whoniverse-tardis .lcars-pill-end { + background-color: var(--tardis-amber); + border-radius: 4px; +} + +/* TARDIS Roundel Buttons & Cards */ +body.theme-whoniverse-tardis .sidebar-heading { + background: linear-gradient(90deg, #003b6f, #001f3f); + color: var(--tardis-cyan); + border-left: 4px solid var(--tardis-cyan); + letter-spacing: 0.1em; +} + +body.theme-whoniverse-tardis .preset-btn { + background: rgba(0, 59, 111, 0.2); + border: 1px solid rgba(0, 229, 255, 0.2); + border-radius: 8px; + color: #e2e8f0; +} + +body.theme-whoniverse-tardis .preset-btn:hover { + background: rgba(0, 229, 255, 0.15); + border-color: var(--tardis-cyan); + box-shadow: 0 0 8px rgba(0, 229, 255, 0.3); +} + +body.theme-whoniverse-tardis .preset-btn.active { + background: linear-gradient(135deg, #003b6f, #002244); + border: 1px solid var(--tardis-cyan); + color: #ffffff; + box-shadow: 0 0 15px rgba(0, 229, 255, 0.5); +} + +/* Master Play Button for TARDIS */ +body.theme-whoniverse-tardis .btn-lcars-large { + background: linear-gradient(135deg, #00e5ff, #0088cc); + color: #000000; + border-radius: 8px; + box-shadow: 0 0 16px rgba(0, 229, 255, 0.4); +} + +body.theme-whoniverse-tardis .btn-lcars-large.playing { + background: linear-gradient(135deg, #d4af37, #996515); + color: #ffffff; + box-shadow: 0 0 20px rgba(212, 175, 55, 0.6); +} + +/* Roundel Keypad Buttons */ +body.theme-whoniverse-tardis .btn-chirp { + background: radial-gradient(circle, rgba(0, 229, 255, 0.1) 0%, rgba(0, 31, 63, 0.4) 100%); + border: 1px solid rgba(0, 229, 255, 0.4); + border-radius: 20px; + color: #b0e0e6; + transition: all 0.2s; +} + +body.theme-whoniverse-tardis .btn-chirp:hover { + background: var(--tardis-cyan); + color: #000000; + box-shadow: 0 0 12px var(--tardis-cyan); +} + +/* Cloister Bell Active Siren */ +body.theme-whoniverse-tardis .btn-lcars-pill.active-alert { + background-color: #ff3300 !important; + color: #ffffff !important; + animation: cloister-pulse 1.2s infinite alternate; +} + +@keyframes cloister-pulse { + 0% { box-shadow: 0 0 4px #ff3300; } + 100% { box-shadow: 0 0 25px #ff3300; } +} + +/* TARDIS Footer */ +body.theme-whoniverse-tardis .lcars-footer { + background: linear-gradient(90deg, #001f3f, #003b6f); + color: var(--tardis-cyan); + border-top: 1px solid rgba(0, 229, 255, 0.3); +} + + +/** + * Expanded Sci-Fi Universes Themes Stylesheet + * Custom visual aesthetics for: Industrial, Bioships, Retro Future, Military, + * Deep Space, Outlaw / Frontier, Space Stations, and Comedy / Adventure. + */ + +/* ========================================================================== + 1. INDUSTRIAL SPACE (Weyland-Yutani, Firefly, Expanse, Mining Grit) + ========================================================================== */ +body.theme-industrial { + --primary-accent: #ffaa00; + --secondary-accent: #b45309; + --tertiary-accent: #d97706; + --warning-accent: #dc2626; + + --lcars-bg: #0d0a07; + --lcars-panel-bg: #140f0a; + --lcars-font: 'Share Tech Mono', monospace; + + background-color: #0d0a07; + background-image: repeating-linear-gradient(0deg, rgba(0, 0, 0, 0.25), rgba(0, 0, 0, 0.25) 2px, transparent 2px, transparent 4px); +} + +body.theme-industrial .lcars-elbow-left { + background: linear-gradient(135deg, #b45309, #78350f); + color: #ffaa00; + border: 1px solid #ffaa00; + border-radius: 2px; + box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.8); +} + +body.theme-industrial .lcars-top-bar { + background: #1e150d; + color: #ffaa00; + border-bottom: 2px solid #b45309; +} + +body.theme-industrial .panel-card { + border: 1px solid #451a03; + border-left: 4px solid #b45309; +} + +body.theme-industrial .preset-btn { + background: #1a120b; + border-left: 3px solid #78350f; + color: #fdba74; +} + +body.theme-industrial .preset-btn.active { + background: #b45309; + color: #000000; + font-weight: bold; +} + +/* ========================================================================== + 2. BIOSHIPS (Living Starships, Moya, Lexx, Species 8472) + ========================================================================== */ +body.theme-bioships { + --primary-accent: #10b981; + --secondary-accent: #8b5cf6; + --tertiary-accent: #065f46; + --warning-accent: #ef4444; + + --lcars-bg: #021a12; + --lcars-panel-bg: #04271c; + --lcars-font: 'Antonio', sans-serif; + + background-color: #021a12; + background-image: radial-gradient(circle at 50% 50%, rgba(139, 92, 246, 0.12) 0%, transparent 70%); +} + +body.theme-bioships .lcars-elbow-left { + background: linear-gradient(135deg, #059669, #4c1d95); + color: #ffffff; + border-radius: 28px 0 28px 0; + box-shadow: 0 0 18px rgba(16, 185, 129, 0.4); +} + +body.theme-bioships .lcars-top-bar { + background: #064e3b; + color: #a7f3d0; + border-radius: 0 16px 16px 0; +} + +body.theme-bioships .panel-card { + border: 1px solid rgba(16, 185, 129, 0.25); + border-radius: 14px; +} + +body.theme-bioships .preset-btn { + border-radius: 12px; + background: rgba(16, 185, 129, 0.08); + color: #d1fae5; +} + +body.theme-bioships .preset-btn.active { + background: linear-gradient(90deg, #10b981, #7c3aed); + color: #000000; + font-weight: bold; +} + +/* ========================================================================== + 3. RETRO FUTURE (1950s–1970s Golden Age, 2001, Jupiter 2) + ========================================================================== */ +body.theme-retrofuture { + --primary-accent: #22c55e; + --secondary-accent: #15803d; + --tertiary-accent: #86efac; + --warning-accent: #dc2626; + + --lcars-bg: #03140a; + --lcars-panel-bg: #052212; + --lcars-font: 'Share Tech Mono', monospace; + + background-color: #03140a; +} + +body.theme-retrofuture .lcars-elbow-left { + background: #15803d; + color: #bbf7d0; + border-radius: 8px; + border: 2px solid #22c55e; +} + +body.theme-retrofuture .lcars-top-bar { + background: #052e16; + color: #22c55e; + border: 1px solid #15803d; +} + +body.theme-retrofuture .preset-btn { + background: #052914; + border: 1px solid #14532d; + color: #86efac; +} + +body.theme-retrofuture .preset-btn.active { + background: #22c55e; + color: #000000; + box-shadow: 0 0 12px #22c55e; +} + +/* ========================================================================== + 4. MILITARY SPACE (Battlestar Galactica, Sulaco, Fleet Combat) + ========================================================================== */ +body.theme-military { + --primary-accent: #eab308; + --secondary-accent: #71717a; + --tertiary-accent: #ca8a04; + --warning-accent: #ef4444; + + --lcars-bg: #09090b; + --lcars-panel-bg: #18181b; + --lcars-font: 'Share Tech Mono', monospace; +} + +body.theme-military .lcars-elbow-left { + background: #27272a; + color: #eab308; + border: 2px solid #eab308; + border-radius: 0; + font-weight: 900; +} + +body.theme-military .lcars-top-bar { + background: #18181b; + color: #fef08a; + border-bottom: 2px solid #eab308; +} + +body.theme-military .panel-card { + border: 1px solid #3f3f46; + border-left: 4px solid #eab308; +} + +body.theme-military .preset-btn { + background: #27272a; + border: 1px solid #3f3f46; + color: #e4e4e7; + border-radius: 2px; +} + +body.theme-military .preset-btn.active { + background: #eab308; + color: #000000; + font-weight: bold; +} + +/* ========================================================================== + 5. DEEP SPACE (Generation Ships, Icarus II, Event Horizon) + ========================================================================== */ +body.theme-deepspace { + --primary-accent: #6366f1; + --secondary-accent: #312e81; + --tertiary-accent: #38bdf8; + --warning-accent: #f43f5e; + + --lcars-bg: #030712; + --lcars-panel-bg: #0b0f19; + --lcars-font: 'Antonio', sans-serif; + background-image: radial-gradient(circle at 80% 20%, rgba(99, 102, 241, 0.15) 0%, transparent 60%); +} + +body.theme-deepspace .lcars-elbow-left { + background: linear-gradient(135deg, #4338ca, #1e1b4b); + color: #e0e7ff; + border: 1px solid #6366f1; +} + +body.theme-deepspace .lcars-top-bar { + background: #111827; + color: #818cf8; +} + +body.theme-deepspace .preset-btn { + background: rgba(49, 46, 129, 0.25); + border: 1px solid rgba(99, 102, 241, 0.25); + color: #c7d2fe; +} + +body.theme-deepspace .preset-btn.active { + background: #6366f1; + color: #ffffff; + box-shadow: 0 0 15px rgba(99, 102, 241, 0.6); +} + +/* ========================================================================== + 6. OUTLAW / FRONTIER (Cowboy Bebop, Outlaw Star, Milano, Betty) + ========================================================================== */ +body.theme-outlaw { + --primary-accent: #ec4899; + --secondary-accent: #f59e0b; + --tertiary-accent: #8b5cf6; + --warning-accent: #ef4444; + + --lcars-bg: #0f0714; + --lcars-panel-bg: #1a0c23; + --lcars-font: 'Share Tech Mono', monospace; +} + +body.theme-outlaw .lcars-elbow-left { + background: linear-gradient(135deg, #db2777, #9d174d); + color: #fef08a; + box-shadow: 0 0 14px rgba(236, 72, 153, 0.4); +} + +body.theme-outlaw .lcars-top-bar { + background: #200d2d; + color: #f472b6; + border-bottom: 2px solid #ec4899; +} + +body.theme-outlaw .preset-btn { + background: #261135; + border: 1px solid rgba(236, 72, 153, 0.3); + color: #fbcfe8; +} + +body.theme-outlaw .preset-btn.active { + background: linear-gradient(90deg, #ec4899, #f59e0b); + color: #000000; + font-weight: bold; +} + +/* ========================================================================== + 7. SPACE STATIONS (Babylon 5, Moonbase Alpha, Sevastopol, Tycho) + ========================================================================== */ +body.theme-spacestations { + --primary-accent: #f97316; + --secondary-accent: #0284c7; + --tertiary-accent: #38bdf8; + --warning-accent: #dc2626; + + --lcars-bg: #050b14; + --lcars-panel-bg: #0a1322; + --lcars-font: 'Share Tech Mono', monospace; +} + +body.theme-spacestations .lcars-elbow-left { + background: #ea580c; + color: #000000; + border-radius: 4px; +} + +body.theme-spacestations .lcars-top-bar { + background: #0f1e33; + color: #fdba74; + border-bottom: 2px solid #0284c7; +} + +body.theme-spacestations .preset-btn { + background: #0e1b2e; + border-left: 3px solid #0284c7; + color: #bae6fd; +} + +body.theme-spacestations .preset-btn.active { + background: #f97316; + color: #000000; + font-weight: bold; +} + +/* ========================================================================== + 8. COMEDY / ADVENTURE (The Orville, Heart of Gold, Spaceballs) + ========================================================================== */ +body.theme-comedy { + --primary-accent: #06b6d4; + --secondary-accent: #f43f5e; + --tertiary-accent: #fbbf24; + --warning-accent: #ef4444; + + --lcars-bg: #0c0a21; + --lcars-panel-bg: #151233; + --lcars-font: 'Antonio', sans-serif; + background-image: radial-gradient(circle at 50% 10%, rgba(6, 182, 212, 0.2) 0%, transparent 60%); +} + +body.theme-comedy .lcars-elbow-left { + background: linear-gradient(135deg, #06b6d4, #f43f5e); + color: #ffffff; + border-radius: 20px; + box-shadow: 0 0 16px rgba(6, 182, 212, 0.5); +} + +body.theme-comedy .lcars-top-bar { + background: #1e1b4b; + color: #67e8f9; +} + +body.theme-comedy .preset-btn { + background: rgba(6, 182, 212, 0.1); + border: 1px solid rgba(6, 182, 212, 0.3); + border-radius: 12px; + color: #cffafe; +} + +body.theme-comedy .preset-btn.active { + background: linear-gradient(90deg, #06b6d4, #fbbf24); + color: #000000; + font-weight: bold; +} + + +/* ========================================================================== + OBSERVATION MODE + Passive casting / display view. Click anywhere to return. + ========================================================================== */ + +.btn-observation { + background: transparent !important; + color: var(--primary-accent) !important; + border: 2px solid var(--primary-accent) !important; +} +.btn-observation:hover { + background: var(--primary-accent) !important; + color: #000 !important; +} + +.observation-overlay { + position: fixed; + inset: 0; + z-index: 10000; + display: none; + background: #010308; + overflow: hidden; + cursor: default; +} + +.observation-overlay.active { + display: block; +} + +/* Layer 0 & 1: 60fps Deep Celestial Canvas */ +.observation-canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + z-index: 1; + pointer-events: none; + display: block; +} + +.observation-stage { + position: absolute; + inset: 0; + z-index: 2; + overflow: hidden; + pointer-events: none; +} + +.observation-stage svg { + width: 100%; + height: 100%; + display: block; +} + +/* Allow 60fps dynamic celestial canvas to show through legacy SVG stage while preserving telemetry & HUDs. + NOTE (v2cs): this previously read `rect:first-child`, which never matched -- every renderer's + block is the true first child of the , so the background rect is actually the second + child (or, more robustly, simply the first element of its own tag type). That silently left an + opaque near-black rectangle sitting on z-index 2, directly over the canvas engine at z-index 1, + hiding almost everything the canvas draws for every theme. Fixed by matching on type instead of + position. + + NOTE (v3co): the selector above is correct but was applied GLOBALLY, which uncovered the + shared canvas layer stack in nine themes simultaneously. It is now scoped behind + `.obs-canvas-on`, a class toggled in ObservationEngine.start() from the OBSERVATION_CANVAS + manifest. Themes declared canvas:false keep their opaque background rect -- their + OBSERVATION display is their own bespoke SVG art and nothing else. */ +.observation-overlay.obs-canvas-on .observation-stage svg > rect:first-of-type { + display: none; +} +.observation-stage svg g[style*="obs-drift-left"], +.observation-stage svg g[style*="obs-drift-right"] { + display: none; +} + +/* Space Stations Native Viewport respect & toggle */ +.obs-station-bulkhead { + transition: opacity 0.4s ease; +} +.obs-station-frame { + transition: opacity 0.4s ease; +} +.obs-viewport-hidden .obs-station-bulkhead, +.obs-viewport-hidden .obs-station-frame { + opacity: 0; +} + +/* Layer 2: Architectural Starship Viewport Framing */ +.observation-viewport-frame { + position: absolute; + inset: 0; + z-index: 4; + pointer-events: none; + transition: opacity 0.4s ease; + overflow: hidden; +} + +.observation-viewport-frame svg { + width: 100%; + height: 100%; + display: block; +} + +.observation-viewport-frame.frame-hidden { + opacity: 0; +} + +.observation-glass-sheen { + position: absolute; + inset: 0; + z-index: 5; + pointer-events: none; + background: linear-gradient(135deg, rgba(255,255,255,0.055) 0%, transparent 42%, rgba(255,255,255,0.02) 68%, transparent 100%); +} + +.observation-scanlines { + position: absolute; + inset: 0; + z-index: 6; + pointer-events: none; + opacity: .14; + background: repeating-linear-gradient( + 0deg, + rgba(255,255,255,.04) 0px, + rgba(255,255,255,.04) 1px, + transparent 1px, + transparent 4px + ); +} + +.observation-vignette { + position: absolute; + inset: 0; + z-index: 7; + pointer-events: none; + box-shadow: inset 0 0 16vw rgba(0,0,0,.82); +} + +/* Layer 3: Emergency Alert Lighting Wash */ +.observation-alert-wash { + position: absolute; + inset: 0; + z-index: 8; + pointer-events: none; + opacity: 0; + transition: opacity 0.45s ease; +} + +.observation-alert-wash.alert-red { + opacity: 1; + background: radial-gradient(circle at center, rgba(255, 0, 0, 0.08) 0%, rgba(220, 20, 20, 0.35) 75%, rgba(120, 0, 0, 0.72) 100%); + animation: obsAlertStrobeRed 1.1s ease-in-out infinite alternate; +} + +.observation-alert-wash.alert-yellow { + opacity: 1; + background: radial-gradient(circle at center, rgba(255, 180, 0, 0.06) 0%, rgba(220, 150, 20, 0.28) 75%, rgba(140, 80, 0, 0.6) 100%); + animation: obsAlertStrobeYellow 1.5s ease-in-out infinite alternate; +} + +@keyframes obsAlertStrobeRed { + 0% { opacity: 0.2; } + 100% { opacity: 0.95; } +} + +@keyframes obsAlertStrobeYellow { + 0% { opacity: 0.25; } + 100% { opacity: 0.85; } +} + +/* Layer 4: Holographic LCARS HUD */ +.observation-chrome { + position: absolute; + inset: 0; + z-index: 9; + pointer-events: none; + font-family: var(--mono-font); + color: #e8f4ff; + text-shadow: 0 0 8px rgba(255,255,255,0.18); + opacity: 1; + transition: opacity 0.5s ease; +} + +.observation-topline { + position: absolute; + top: 3.2vh; + left: 3vw; + right: 3vw; + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 2rem; + pointer-events: none; +} + +.observation-mode-label { + font-family: var(--lcars-font); + font-size: clamp(1rem, 1.6vw, 1.7rem); + letter-spacing: 0.22em; + font-weight: 700; + color: var(--primary-accent); +} + +.observation-profile { + margin-top: .45rem; + font-size: clamp(.8rem, 1.05vw, 1.1rem); + letter-spacing: .08em; + color: rgba(235,245,255,.78); +} + +.observation-status { + text-align: right; + font-size: clamp(.7rem, .9vw, .95rem); + letter-spacing: .1em; + line-height: 1.65; + color: rgba(235,245,255,.62); +} + +.observation-waveform-container { + position: absolute; + bottom: 2.2vh; + left: 50%; + transform: translateX(-50%); + z-index: 9; + pointer-events: none; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + opacity: 0.82; +} + +.observation-waveform-label { + font-family: var(--mono-font); + font-size: 0.65rem; + letter-spacing: 0.2em; + color: var(--tertiary-accent); + text-shadow: 0 0 6px rgba(0,0,0,0.9); +} + +#observation-waveform-canvas { + width: clamp(240px, 32vw, 440px); + height: 24px; + border-radius: 4px; +} + +/* Scrolling Universe-Specific Status Ticker */ +.observation-ticker { + position: absolute; + top: 1.05vh; + left: 3vw; + right: 3vw; + z-index: 9; + display: flex; + align-items: center; + gap: 10px; + pointer-events: none; + opacity: 0.85; +} + +.observation-ticker-label { + font-family: var(--lcars-font); + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.18em; + color: #000; + background: var(--tertiary-accent, #99ccff); + padding: 2px 9px; + border-radius: 8px; + flex-shrink: 0; + white-space: nowrap; +} + +.observation-ticker-track { + flex: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; + -webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 6%, #000 94%, transparent 100%); + mask-image: linear-gradient(90deg, transparent 0%, #000 6%, #000 94%, transparent 100%); +} + +.observation-ticker-text { + display: inline-block; + font-family: var(--mono-font); + font-size: 0.7rem; + letter-spacing: 0.13em; + color: rgba(235,245,255,.7); + white-space: nowrap; + padding-left: 100%; + animation: obsTickerScroll 20s linear infinite; +} + +@keyframes obsTickerScroll { + 0% { transform: translateX(0); } + 100% { transform: translateX(-100%); } +} + +/* Auto-hiding in-observation dock */ +.observation-control-dock { + position: absolute; + bottom: 2.5vh; + right: 2.5vw; + z-index: 12; + display: flex; + align-items: center; + gap: 8px; + background: rgba(8, 12, 22, 0.88); + backdrop-filter: blur(14px); + border: 1px solid color-mix(in srgb, var(--primary-accent) 65%, transparent); + box-shadow: 0 4px 28px rgba(0,0,0,0.8), inset 0 0 14px rgba(255,255,255,0.03); + border-radius: 20px; + padding: 6px 12px; + transition: opacity 0.35s ease, transform 0.35s ease; + opacity: 1; +} + +.observation-control-dock.dock-autohide { + opacity: 0; + pointer-events: none; + transform: translateY(8px); +} + +.obs-dock-btn { + background: rgba(0,0,0,0.55); + border: 1px solid color-mix(in srgb, var(--primary-accent) 55%, transparent); + color: #e2e8f0; + font-family: var(--lcars-font); + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.08em; + padding: 5px 12px; + border-radius: 12px; + cursor: pointer; + transition: all 0.16s ease; + white-space: nowrap; + user-select: none; +} + +.obs-dock-btn:hover { + background: var(--primary-accent); + color: #000; + box-shadow: 0 0 10px var(--primary-accent); +} + +.obs-dock-btn.active { + background: var(--primary-accent); + color: #000; + border-color: var(--primary-accent); + box-shadow: 0 0 12px var(--primary-accent); +} + +.obs-dock-btn.btn-alert-active { + background: #dc2626 !important; + color: #fff !important; + border-color: #f87171 !important; + box-shadow: 0 0 14px #dc2626 !important; + animation: obsBtnAlertPulse 0.9s ease-in-out infinite alternate; +} + +.obs-dock-btn.obs-dock-close { + border-color: #f87171; + color: #fca5a5; +} + +.obs-dock-btn.obs-dock-close:hover { + background: #dc2626; + color: #fff; + box-shadow: 0 0 12px #dc2626; +} + +@keyframes obsBtnAlertPulse { + from { opacity: 0.75; } + to { opacity: 1; } +} + +.obs-dock-select { + background: rgba(0,0,0,0.75); + border: 1px solid color-mix(in srgb, var(--tertiary-accent) 60%, transparent); + color: var(--tertiary-accent); + font-family: var(--mono-font); + font-size: 0.78rem; + padding: 5px 8px; + border-radius: 8px; + cursor: pointer; + max-width: 170px; + outline: none; +} + +.obs-dock-select option { + background: #0d1117; + color: #e2e8f0; +} + +.observation-return { + position: absolute; + bottom: 2.8vh; + left: 3vw; + padding: .45rem .9rem; + border: 1px solid color-mix(in srgb, var(--primary-accent) 55%, transparent); + border-radius: 999px; + background: rgba(0,0,0,.45); + backdrop-filter: blur(8px); + color: rgba(255,255,255,.78); + font-size: clamp(.65rem, .78vw, .82rem); + letter-spacing: .16em; + white-space: nowrap; + cursor: pointer; + z-index: 12; + transition: background 0.2s ease, color 0.2s ease, opacity 0.5s ease; +} + +.observation-return:hover { + background: var(--primary-accent); + color: #000; +} + +@keyframes obs-drift-left { + from { transform: translateX(0); } + to { transform: translateX(-160px); } +} +@keyframes obs-drift-right { + from { transform: translateX(-100px); } + to { transform: translateX(80px); } +} +@keyframes obs-pulse { + 0%, 100% { opacity: .35; transform: scale(1); } + 50% { opacity: 1; transform: scale(1.045); } +} +@keyframes obs-rotate { + to { transform: rotate(360deg); } +} +@keyframes obs-rotate-rev { + to { transform: rotate(-360deg); } +} +@keyframes obs-sweep { + 0% { opacity: 0; transform: translateY(-8%); } + 15% { opacity: .65; } + 85% { opacity: .65; } + 100% { opacity: 0; transform: translateY(108%); } +} +@keyframes obs-flicker { + 0%, 95%, 100% { opacity: 1; } + 96% { opacity: .35; } + 97% { opacity: .9; } + 98% { opacity: .2; } +} +@keyframes obs-blink { + 0%, 44%, 100% { opacity: .2; } + 45%, 70% { opacity: 1; } +} +@keyframes obs-breathe { + 0%, 100% { transform: scale(.98); filter: brightness(.86); } + 50% { transform: scale(1.04); filter: brightness(1.15); } +} +@keyframes obs-wave { + 0% { stroke-dashoffset: 0; } + 100% { stroke-dashoffset: -180; } +} + +.obs-spin { + transform-box: fill-box; + transform-origin: center; + animation: obs-rotate 18s linear infinite; +} +.obs-spin-slow { + transform-box: fill-box; + transform-origin: center; + animation: obs-rotate 42s linear infinite; +} +.obs-spin-rev { + transform-box: fill-box; + transform-origin: center; + animation: obs-rotate-rev 28s linear infinite; +} +.obs-pulse { + transform-box: fill-box; + transform-origin: center; + animation: obs-pulse 3.8s ease-in-out infinite; +} +.obs-breathe { + transform-box: fill-box; + transform-origin: center; + animation: obs-breathe 5.5s ease-in-out infinite; +} +.obs-flicker { + animation: obs-flicker 8s steps(1,end) infinite; +} +.obs-blink { + animation: obs-blink 3.5s steps(1,end) infinite; +} +.obs-dashflow { + stroke-dasharray: 20 12; + animation: obs-wave 7s linear infinite; +} + +/* OBSERVATION-specific theme tinting */ +body.theme-whoniverse-tardis .observation-overlay { background: #020817; } +body.theme-industrial .observation-overlay { background: #070706; } +body.theme-bioships .observation-overlay { background: #010b07; } +body.theme-retrofuture .observation-overlay { background: #010903; } +body.theme-military .observation-overlay { background: #050506; } +body.theme-deepspace .observation-overlay { background: #010207; } +body.theme-outlaw .observation-overlay { background: #09040c; } +body.theme-spacestations .observation-overlay { background: #02070d; } +body.theme-comedy .observation-overlay { background: #07061a; } + +@media (prefers-reduced-motion: reduce) { + .observation-overlay * { + animation-duration: 1ms !important; + animation-iteration-count: 1 !important; + } +} + + +/* ========================================================================== + OBSERVATION ACTIVITY ENGINE + Generative transient visuals + richer ambient motion. + ========================================================================== */ + +.observation-activity-control { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: min(330px, 100%); + padding: 6px 10px; + border: 1px solid color-mix(in srgb, var(--primary-accent) 55%, transparent); + border-radius: 14px; + background: rgba(255,255,255,.025); + font-family: var(--mono-font); +} + +.observation-activity-label { + color: var(--primary-accent); + font-size: .72rem; + font-weight: 700; + letter-spacing: .08em; + white-space: nowrap; +} + +.observation-activity-control input[type="range"] { + flex: 1; + min-width: 90px; + height: 6px; +} + +.observation-activity-value { + min-width: 38px; + text-align: right; + color: #dbeafe; + font-size: .75rem; +} + +.observation-event-layer { + position: absolute; + inset: 0; + z-index: 3; + overflow: hidden; + pointer-events: none; +} + +.obs-transient { + position: absolute; + inset: 0; + opacity: 0; + pointer-events: none; + animation: obs-transient-life var(--obs-life, 4.5s) ease-in-out forwards; +} + +.obs-transient svg { + width: 100%; + height: 100%; + display: block; +} + +@keyframes obs-transient-life { + 0% { opacity: 0; } + 8% { opacity: 1; } + 78% { opacity: .92; } + 100% { opacity: 0; } +} + +@keyframes obs-transient-pop { + 0% { transform: scale(.55); opacity: 0; } + 22% { transform: scale(1.08); opacity: 1; } + 100% { transform: scale(1); opacity: .2; } +} + +@keyframes obs-transient-ring { + from { transform: scale(.15); opacity: .95; } + to { transform: scale(2.7); opacity: 0; } +} + +@keyframes obs-slide-horizontal { + from { transform: translateX(-130px); } + to { transform: translateX(130px); } +} + +@keyframes obs-slide-horizontal-wide { + from { transform: translateX(-520px); } + to { transform: translateX(520px); } +} + +@keyframes obs-slide-vertical { + from { transform: translateY(-160px); } + to { transform: translateY(160px); } +} + +@keyframes obs-soft-wobble { + 0%,100% { transform: translate(0,0) rotate(0deg); } + 25% { transform: translate(4px,-2px) rotate(.25deg); } + 60% { transform: translate(-3px,3px) rotate(-.2deg); } +} + +@keyframes obs-glow-cycle { + 0%,100% { opacity: .18; } + 50% { opacity: .88; } +} + +@keyframes obs-drift-up { + from { transform: translateY(70px); opacity: 0; } + 15% { opacity: .7; } + to { transform: translateY(-120px); opacity: 0; } +} + +@keyframes obs-zoom-in { + from { transform: scale(.82); opacity: .15; } + to { transform: scale(1.16); opacity: .8; } +} + +@keyframes obs-route-wander { + 0%,100% { stroke-dashoffset: 0; opacity: .35; } + 50% { stroke-dashoffset: -120; opacity: .9; } +} + +@keyframes obs-planet-drift { + from { transform: translateX(-220px); } + to { transform: translateX(1900px); } +} + +@keyframes obs-comet-drift { + from { transform: translate(-250px, 180px) rotate(-15deg); opacity: 0; } + 10% { opacity: .8; } + 85% { opacity: .65; } + to { transform: translate(1900px, -260px) rotate(-15deg); opacity: 0; } +} + +@keyframes obs-camera-pan { + 0%,100% { transform: translateX(-14px) scale(1.03); } + 50% { transform: translateX(14px) scale(1.07); } +} + +@keyframes obs-organic-undulate { + 0%,100% { transform: scale(1, .97) skewX(-1deg); } + 50% { transform: scale(1.035, 1.045) skewX(1deg); } +} + +@keyframes obs-crt-jump { + 0%,93%,100% { transform: translateY(0); opacity: 1; } + 94% { transform: translateY(9px); opacity: .6; } + 95% { transform: translateY(-5px); opacity: .85; } + 96% { transform: translateY(2px); opacity: .35; } +} + +/* ========================================================================== + INDUSTRIAL OBSERVATION — v13co + Video-feed artifacts and mechanical work cycles. + EVERY effect below is authored to live INSIDE a camera clip-path so it + can never bleed across the console the way the old full-screen sweep and + the free-floating steam blobs did. + ========================================================================== */ + +/* Rolling horizontal sync bar — travels down ONE feed, not the screen. */ +@keyframes obs-feed-roll { + 0% { transform: translateY(-72px); } + 100% { transform: translateY(292px); } +} + +/* Occasional tracking tear: a couple of displaced scan blocks, then gone. */ +@keyframes obs-feed-tear { + 0%, 87%, 100% { opacity: 0; transform: translate(0, 120px); } + 88% { opacity: .55; transform: translate(-11px, 74px); } + 90% { opacity: .32; transform: translate(9px, 158px); } + 92% { opacity: .48; transform: translate(-5px, 44px); } + 94% { opacity: 0; transform: translate(0, 210px); } +} + +/* Analogue grain: churn opacity only so the turbulence filter stays cached. */ +@keyframes obs-feed-grain { + 0%, 100% { opacity: .055; } + 25% { opacity: .10; } + 50% { opacity: .04; } + 75% { opacity: .115; } +} + +/* Automatic gain control hunting for exposure. */ +@keyframes obs-feed-agc { + 0%, 100% { opacity: 0; } + 40% { opacity: .055; } + 70% { opacity: .02; } +} + +/* Active-feed selector; four cameras share this with negative delays. */ +@keyframes obs-cam-focus { + 0%, 21% { opacity: 1; } + 23%, 100% { opacity: 0; } +} + +/* --- CARGO BAY: gantry crane work cycle. Phases interlock on one clock. --- */ +@keyframes obs-gantry-traverse { + 0%, 18% { transform: translateX(0px); } + 38%, 62% { transform: translateX(290px); } + 82%, 100% { transform: translateX(0px); } +} +@keyframes obs-gantry-hoist { + 0% { transform: translateY(0px); } + 5% { transform: translateY(66px); } + 11% { transform: translateY(66px); } + 17% { transform: translateY(0px); } + 40% { transform: translateY(0px); } + 45% { transform: translateY(66px); } + 53% { transform: translateY(66px); } + 60% { transform: translateY(0px); } + 100% { transform: translateY(0px); } +} +@keyframes obs-gantry-load { + 0%, 11% { opacity: 0; } + 13%, 45% { opacity: 1; } + 53%, 100% { opacity: 0; } +} +@keyframes obs-gantry-taken { + 0%, 10% { opacity: 1; } + 13%, 96% { opacity: 0; } + 100% { opacity: 1; } +} +@keyframes obs-gantry-placed { + 0%, 52% { opacity: 0; } + 56%, 96% { opacity: 1; } + 100% { opacity: 0; } +} +@keyframes obs-gantry-lamp { + 0%, 16%, 62%, 100% { fill: #16a34a; } + 19%, 38% { fill: #f59e0b; } + 45%, 59% { fill: #f59e0b; } +} +@keyframes obs-bay-shudder { + 0%, 46%, 52%, 100% { transform: translate(0, 0); } + 47% { transform: translate(.9px, -.7px); } + 49% { transform: translate(-.8px, .6px); } + 51% { transform: translate(.5px, .4px); } +} + +/* Rotating hazard beacon. Pivot comes from a zero-fill bbox anchor circle. */ +@keyframes obs-beacon-sweep { to { transform: rotate(360deg); } } +@keyframes obs-beacon-flash { + 0%, 100% { opacity: .16; } + 50% { opacity: .95; } +} + +/* Steam / vapour: rises from a vent and dissipates. Always inside a clip. */ +@keyframes obs-vapour { + 0% { transform: translate(0, 0) scale(.3); opacity: 0; } + 18% { opacity: .34; } + 60% { opacity: .16; } + 100% { transform: translate(var(--vx, 12px), -94px) scale(1.9); opacity: 0; } +} + +/* --- REACTOR PLANT --- */ +@keyframes obs-core-breathe { + 0%, 100% { transform: scale(.93); opacity: .55; } + 50% { transform: scale(1.08); opacity: 1; } +} +@keyframes obs-rod-travel { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(11px); } +} +@keyframes obs-heat-shimmer { + 0%, 100% { transform: translateY(0) scaleY(1); opacity: .14; } + 50% { transform: translateY(-3px) scaleY(1.07); opacity: .3; } +} +@keyframes obs-needle-hunt { + 0%, 100% { transform: rotate(-33deg); } + 30% { transform: rotate(19deg); } + 55% { transform: rotate(-9deg); } + 78% { transform: rotate(30deg); } +} +@keyframes obs-pipe-flow { to { stroke-dashoffset: -240; } } + +/* --- AIRLOCK --- */ +@keyframes obs-dog-ring { + 0%, 30% { transform: rotate(0deg); } + 46%, 76% { transform: rotate(45deg); } + 92%, 100% { transform: rotate(0deg); } +} +@keyframes obs-airlock-cycle { + 0%, 24% { opacity: 1; } + 26%, 100% { opacity: .1; } +} + +/* --- MACHINE CORRIDOR --- */ +@keyframes obs-lamp-fail { + 0%, 61%, 100% { opacity: .85; } + 62% { opacity: .1; } + 63.5% { opacity: .72; } + 65% { opacity: .08; } + 67% { opacity: .8; } + 70% { opacity: .22; } + 72% { opacity: .82; } +} +@keyframes obs-drip { + 0%, 72% { transform: translateY(0); opacity: 0; } + 76% { opacity: .75; } + 100% { transform: translateY(54px); opacity: 0; } +} + +.obs-feed-roll { animation: obs-feed-roll 7.5s linear infinite; } +.obs-feed-grain { animation: obs-feed-grain .45s steps(2, end) infinite; } +.obs-feed-agc { animation: obs-feed-agc 9s ease-in-out infinite; } +.obs-feed-tear { animation: obs-feed-tear 13s linear infinite; } +.obs-cam-focus { animation: obs-cam-focus 32s linear infinite; } +.obs-beacon-sweep { transform-box: fill-box; transform-origin: center; animation: obs-beacon-sweep 3.4s linear infinite; } +.obs-beacon-flash { animation: obs-beacon-flash 1.7s ease-in-out infinite; } +.obs-pipe-flow { animation: obs-pipe-flow 4s linear infinite; } +.obs-heat-shimmer { transform-box: fill-box; transform-origin: bottom center; animation: obs-heat-shimmer 3.2s ease-in-out infinite; } +.obs-lamp-fail { animation: obs-lamp-fail 9s linear infinite; } +.obs-bay-shudder { animation: obs-bay-shudder 17s ease-in-out infinite; } +.obs-pivot { transform-box: fill-box; transform-origin: center; } + +/* Functional link: the OBSERVATION activity slider drives plant output and + crane pace, so what you see tracks what the console says it is doing. + All six gantry animations share one clock so their phases stay locked. */ +.observation-overlay { + --obs-activity: .6; + --obs-gantry-clock: calc(34s - var(--obs-activity, .6) * 14s); +} +.obs-core-load { + transform-box: fill-box; + transform-origin: center; + animation: obs-core-breathe calc(7.4s - var(--obs-activity, .6) * 4s) ease-in-out infinite; +} +.obs-core-halo { opacity: calc(.03 + var(--obs-activity, .6) * .16); } + +@media (prefers-reduced-motion: reduce) { + .obs-feed-roll, .obs-feed-grain, .obs-feed-tear, .obs-feed-agc, + .obs-beacon-sweep, .obs-bay-shudder, .obs-lamp-fail { animation: none; } +} + +.obs-ambient-wobble { + transform-box: fill-box; + transform-origin: center; + animation: obs-soft-wobble 8s ease-in-out infinite; +} + +.obs-ambient-glow { + animation: obs-glow-cycle 5.5s ease-in-out infinite; +} + +.obs-camera-pan { + transform-box: fill-box; + transform-origin: center; + animation: obs-camera-pan 14s ease-in-out infinite; +} + +.obs-organic-undulate { + transform-box: fill-box; + transform-origin: center; + animation: obs-organic-undulate 7s ease-in-out infinite; +} + +.obs-crt-jump { + animation: obs-crt-jump 11s steps(1,end) infinite; +} + +.obs-route-wander { + animation: obs-route-wander 8s linear infinite; +} + +/* OBSERVATION chrome remains above generative effects. */ +.observation-chrome { z-index: 9; } +.observation-scanlines { z-index: 6; } +.observation-vignette { z-index: 7; } + +@media (max-width: 820px) { + .observation-activity-control { + width: 100%; + } +} + + +/* ========================================================================== + LIVE OBSERVATION INSTRUMENTATION + Applies only to fantasy text inside the SVG observation visualization. + ========================================================================== */ +.observation-stage text.obs-live-instrument, +.observation-event-layer text.obs-live-instrument { + transition: opacity .12s linear; +} +.observation-stage text.obs-live-glitch, +.observation-event-layer text.obs-live-glitch { + opacity: .72; +} + + +/* ========================================================================== + OBSERVATION HUD PERSISTENCE + HUD HOLD on = profile/status/return chrome stays visible. + HUD HOLD off = chrome fades away over 30 seconds after OBSERVATION starts. + ========================================================================== */ + +.observation-hud-control { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border: 1px solid color-mix(in srgb, var(--primary-accent) 55%, transparent); + border-radius: 14px; + background: rgba(255,255,255,.025); + font-family: var(--mono-font); + white-space: nowrap; +} + +.observation-hud-label { + color: var(--primary-accent); + font-size: .72rem; + font-weight: 700; + letter-spacing: .08em; +} + +.observation-hud-control input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--primary-accent); + cursor: pointer; +} + +.observation-hud-state { + min-width: 34px; + color: #dbeafe; + font-size: .75rem; + text-align: right; +} + +.observation-chrome { + opacity: 1; + transition: opacity 30s linear; +} + +.observation-chrome.observation-hud-fade { + opacity: 0; +} + + +/* CLICK ANYWHERE TO RETURN always fades away over 30 seconds, + independently of the HUD HOLD setting. */ +.observation-return { + opacity: 1; + transition: opacity 30s linear; +} + +.observation-return.observation-return-fade { + opacity: 0; +} + + +/* ========================================================================== + OBSERVATION SCENE ENGINE + Persistent generative world layer. Unlike transient alerts, these entities + have lifecycles and move through the scene over time. + ========================================================================== */ +.observation-sim-layer { + position: absolute; + inset: 0; + z-index: 2; + overflow: hidden; + pointer-events: none; +} + +.observation-sim-layer svg { + width: 100%; + height: 100%; + display: block; +} + +.obs-scene-entity { + will-change: transform, opacity; +} + +.obs-scene-soft { + filter: url(#obsSceneGlow); +} + +@keyframes obs-scene-fan-spin { + to { transform: rotate(360deg); } +} + +@keyframes obs-scene-breathe { + 0%, 100% { opacity: .28; transform: scale(.96); } + 50% { opacity: .72; transform: scale(1.06); } +} + +@keyframes obs-scene-flicker { + 0%, 92%, 100% { opacity: .72; } + 93% { opacity: .28; } + 94% { opacity: .86; } + 96% { opacity: .42; } +} + +.obs-scene-fan { + transform-box: fill-box; + transform-origin: center; + animation: obs-scene-fan-spin 5s linear infinite; +} + +.obs-scene-breathe { + transform-box: fill-box; + transform-origin: center; + animation: obs-scene-breathe 6s ease-in-out infinite; +} + +.obs-scene-flicker { + animation: obs-scene-flicker 10s steps(1,end) infinite; +} + diff --git a/dist/SciFiAmbientDisplay_v4.html b/dist/SciFiAmbientDisplay_v4.html new file mode 100644 index 0000000..0a549b0 --- /dev/null +++ b/dist/SciFiAmbientDisplay_v4.html @@ -0,0 +1,13645 @@ + + + + + + + Sci-Fi Ambience Generator - Publish Build v13co + + + + + +
+
+
+ STARFLEET +
+
+ +
+
+ +
+ USS ENTERPRISE // MAIN BRIDGE +
+ + 75% +
+ STARFLEET SOUND MATRIX v2.5 +
+
+
+ + +
+ + + + + +
+ + +
+
+
+
ENTERPRISE-D: MAIN BRIDGE
+
Warm, low-frequency 50Hz hull tone with gentle ventilation and soft LCARS computer chirps.
+
+
+
+ AUDIO ENGINE + STANDBY +
+
+ CORE FREQ + 58.0 Hz +
+
+
+ + +
+ + +
+ +
+ + + +
+ ACTIVITY + + 60% +
+ +
+ + +
+
+
+ MAIN MASTER VOLUME + 75% + -2.5 dB +
+
+ +
+ + + + +
+
+
+ +
+ +
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+ + +
+
+ ACOUSTIC FREQUENCY SPECTRUM + PROCEDURAL HARMONIC ANALYSIS +
+
+
SPECTRUM BIN: 32 CH // REAL-TIME FFT
+ +
+
+ + +
+
+ PROCEDURAL SOUND ENGINE MIXER + LIVE PARAMETER MODULATION +
+ +
+ +
+
+ 1. HULL DRONE + 65% +
+
+
LEVEL
+ +
+
+
BASE TONE50 Hz
+ +
+
+
DAMPING CUTOFF105 Hz
+ +
+
+ + +
+
+ 2. WARP CORE + 35% +
+
+
LEVEL
+ +
+
+
PULSE RATE (BPM)48 BPM
+ +
+
+
REACTOR PITCH58 Hz
+ +
+
+ + +
+
+ 3. LIFE SUPPORT + 55% +
+
+
LEVEL
+ +
+
+
AIRFLOW AIR FILTER1600 Hz
+ +
+
+ + +
+
+ 4. TELEMETRY + 45% +
+
+
LEVEL
+ +
+
+
BACKGROUND CHIRP DENSITY65%
+ +
+
+
+
+ +
+ + + + +
+ + + + + + + + + + + + + + + + + diff --git a/index.html b/index.html new file mode 100644 index 0000000..a643c79 --- /dev/null +++ b/index.html @@ -0,0 +1,367 @@ + + + + + + + Sci-Fi Ambience Generator - Publish Build v13co + + + + + +
+
+
+ STARFLEET +
+
+ +
+
+ +
+ USS ENTERPRISE // MAIN BRIDGE +
+ + 75% +
+ STARFLEET SOUND MATRIX v2.5 +
+
+
+ + +
+ + + + + +
+ + +
+
+
+
ENTERPRISE-D: MAIN BRIDGE
+
Warm, low-frequency 50Hz hull tone with gentle ventilation and soft LCARS computer chirps.
+
+
+
+ AUDIO ENGINE + STANDBY +
+
+ CORE FREQ + 58.0 Hz +
+
+
+ + +
+ + +
+ +
+ + + +
+ ACTIVITY + + 60% +
+ +
+ + +
+
+
+ MAIN MASTER VOLUME + 75% + -2.5 dB +
+
+ +
+ + + + +
+
+
+ +
+ +
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+ + +
+
+ ACOUSTIC FREQUENCY SPECTRUM + PROCEDURAL HARMONIC ANALYSIS +
+
+
SPECTRUM BIN: 32 CH // REAL-TIME FFT
+ +
+
+ + +
+
+ PROCEDURAL SOUND ENGINE MIXER + LIVE PARAMETER MODULATION +
+ +
+ +
+
+ 1. HULL DRONE + 65% +
+
+
LEVEL
+ +
+
+
BASE TONE50 Hz
+ +
+
+
DAMPING CUTOFF105 Hz
+ +
+
+ + +
+
+ 2. WARP CORE + 35% +
+
+
LEVEL
+ +
+
+
PULSE RATE (BPM)48 BPM
+ +
+
+
REACTOR PITCH58 Hz
+ +
+
+ + +
+
+ 3. LIFE SUPPORT + 55% +
+
+
LEVEL
+ +
+
+
AIRFLOW AIR FILTER1600 Hz
+ +
+
+ + +
+
+ 4. TELEMETRY + 45% +
+
+
LEVEL
+ +
+
+
BACKGROUND CHIRP DENSITY65%
+ +
+
+
+
+ +
+ + + + +
+ + + + + + + + + + + + + + + + + diff --git a/js/app.js b/js/app.js new file mode 100644 index 0000000..7420607 --- /dev/null +++ b/js/app.js @@ -0,0 +1,3961 @@ +/** + * Main Application Controller - 10 Sci-Fi Universes Architecture + * Coordinates Starfleet, Whoniverse, Industrial, Bioships, Retro Future, Military, + * Deep Space, Outlaw, Space Stations, and Comedy universes. + */ + +document.addEventListener('DOMContentLoaded', () => { + // 1. Initialize Audio Subsystems + const audioManager = new AudioManager(); + const hullDrone = new HullDroneSynth(audioManager); + const warpCore = new WarpCoreSynth(audioManager); + const lifeSupport = new LifeSupportSynth(audioManager); + const telemetry = new TelemetrySynth(audioManager); + const alerts = new AlertSynth(audioManager); + const whoniverseAudio = new WhoniverseAudioSynth(audioManager); + const expandedAudio = new ExpandedSciFiAudioSynth(audioManager); + const visualizer = new StarshipVisualizer(audioManager, warpCore); + const observationEngine = new ObservationEngine(audioManager, warpCore, alerts, hullDrone, lifeSupport); + window.observationEngine = observationEngine; + + let isPlaying = false; + let activeUniverseId = 'starfleet'; + let activePresetId = 'tng-bridge'; + window.activeUniverseId = activeUniverseId; + window.activePresetId = activePresetId; + + // 2. DOM Elements + const headerUniverseBtn = document.getElementById('header-universe-btn'); + const headerUniverseLabel = document.getElementById('header-universe-label'); + const universeDropdown = document.getElementById('universe-dropdown'); + const headerShipName = document.getElementById('header-ship-name'); + const headerMatrixLabel = document.getElementById('header-matrix-label'); + + const sidebarTitle = document.getElementById('sidebar-title'); + const presetContainer = document.getElementById('preset-container'); + const currentPresetTitle = document.getElementById('current-preset-title'); + const currentPresetDesc = document.getElementById('current-preset-desc'); + const statAudioStatus = document.getElementById('stat-audio-status'); + const statCoreFreq = document.getElementById('stat-core-freq'); + + const btnPlay = document.getElementById('btn-master-play'); + const playIcon = document.getElementById('play-icon'); + const playText = document.getElementById('play-text'); + + // Master Volume Console Elements + const sliderMasterVol = document.getElementById('slider-master-vol'); + const valMasterVol = document.getElementById('val-master-vol'); + const valMasterDb = document.getElementById('val-master-db'); + const btnMasterMute = document.getElementById('btn-master-mute'); + const btnHeaderMute = document.getElementById('btn-header-mute'); + const headerVolVal = document.getElementById('header-vol-val'); + const btnVolDown = document.getElementById('btn-vol-down'); + const btnVolUp = document.getElementById('btn-vol-up'); + const volMeterLeds = document.getElementById('vol-meter-leds'); + const volStepButtons = document.querySelectorAll('.btn-vol-step'); + + // Channel Sliders + const sliderHullVol = document.getElementById('slider-hull-vol'); + const valHullVol = document.getElementById('val-hull-vol'); + const sliderHullFreq = document.getElementById('slider-hull-freq'); + const valHullFreq = document.getElementById('val-hull-freq'); + const sliderHullCutoff = document.getElementById('slider-hull-cutoff'); + const valHullCutoff = document.getElementById('val-hull-cutoff'); + + const sliderWarpVol = document.getElementById('slider-warp-vol'); + const valWarpVol = document.getElementById('val-warp-vol'); + const sliderWarpBpm = document.getElementById('slider-warp-bpm'); + const valWarpBpm = document.getElementById('val-warp-bpm'); + const sliderWarpCarrier = document.getElementById('slider-warp-carrier'); + const valWarpCarrier = document.getElementById('val-warp-carrier'); + + const sliderAirVol = document.getElementById('slider-air-vol'); + const valAirVol = document.getElementById('val-air-vol'); + const sliderAirCutoff = document.getElementById('slider-air-cutoff'); + const valAirCutoff = document.getElementById('val-air-cutoff'); + + const sliderTelemetryVol = document.getElementById('slider-telemetry-vol'); + const valTelemetryVol = document.getElementById('val-telemetry-vol'); + const sliderTelemetryDensity = document.getElementById('slider-telemetry-density'); + const valTelemetryDensity = document.getElementById('val-telemetry-density'); + + // Visualizer & Dynamic Containers + const coreVisualizerTitle = document.getElementById('core-visualizer-title'); + const coreVisualizerLabel = document.getElementById('core-visualizer-label'); + const soundboardTitle = document.getElementById('soundboard-title'); + const universeEventsContainer = document.getElementById('universe-events-container'); + const soundboardContainer = document.getElementById('soundboard-container'); + + // Sleep Timer + const timerDisplay = document.getElementById('timer-display'); + const timerButtons = document.querySelectorAll('.btn-timer'); + + // 3. Universe Management & Switching + function initUniverseDropdown() { + universeDropdown.innerHTML = ''; + Object.values(UniverseRegistry).forEach(u => { + const btn = document.createElement('button'); + btn.className = `universe-item ${u.id === activeUniverseId ? 'active' : ''}`; + btn.innerHTML = `${u.icon} ${u.name}`; + btn.addEventListener('click', (e) => { + e.stopPropagation(); + setUniverse(u.id); + }); + universeDropdown.appendChild(btn); + }); + + headerUniverseBtn.addEventListener('click', (e) => { + e.stopPropagation(); + universeDropdown.classList.toggle('show'); + }); + + document.addEventListener('click', () => { + universeDropdown.classList.remove('show'); + }); + } + + function setUniverse(universeId) { + const universe = UniverseRegistry[universeId]; + if (!universe) return; + + activeUniverseId = universeId; + universeDropdown.classList.remove('show'); + + // Update Dropdown UI active class + document.querySelectorAll('.universe-item').forEach(el => { + el.classList.toggle('active', el.textContent.includes(universe.name)); + }); + + // Update Header and Theme Class + headerUniverseLabel.textContent = universe.shortCode; + document.body.className = universe.themeClass; + headerMatrixLabel.textContent = universe.headerTitle; + + // Custom titles per universe + const titlesMap = { + 'starfleet': { + sidebar: 'STARSHIP PRESETS', + visTitle: 'MATTER / ANTIMATTER CORE', + visLabel: 'INTERMIX CHAMBER // ACTIVE', + soundboard: 'LCARS KEYPAD' + }, + 'whoniverse': { + sidebar: 'TARDIS CONSOLES', + visTitle: 'CENTRAL TIME ROTOR COLUMN', + visLabel: 'VORTEX ROTOR // OPERATIONAL', + soundboard: 'TARDIS CONSOLE CONTROLS' + }, + 'industrial': { + sidebar: 'INDUSTRIAL FREIGHTERS', + visTitle: 'HEAVY FUSION REACTOR', + visLabel: 'CONTAINMENT CORE // ACTIVE', + soundboard: 'INDUSTRIAL INSTRUMENTATION' + }, + 'bioships': { + sidebar: 'SENTIENT LEVIATHANS', + 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: 'DRADIS TACTICAL COMBAT CORE', + 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: 'MODIFIED SUB-LIGHT REACTOR', + visLabel: 'KINETIC THRUSTERS // PRIMED', + soundboard: 'OUTLAW COCKPIT CONTROLS' + }, + 'spacestations': { + sidebar: 'ORBITAL STATIONS & DOCKS', + visTitle: 'CENTRIFUGAL ROTATION CORE', + visLabel: 'STATION ROTOR // DOCKED', + soundboard: 'STATION OPS CONSOLE' + }, + 'comedy': { + sidebar: 'WHIMSICAL & ADVENTURE FLEET', + visTitle: 'IMPROBABILITY CORE', + visLabel: 'REALITY FIELD // SHIFTING', + soundboard: 'CHEERFUL CONSOLE KEYPAD' + } + }; + + const conf = titlesMap[universeId] || titlesMap['starfleet']; + sidebarTitle.textContent = conf.sidebar; + coreVisualizerTitle.textContent = conf.visTitle; + coreVisualizerLabel.textContent = conf.visLabel; + soundboardTitle.textContent = conf.soundboard; + + // Configure Visualizer mode + visualizer.setMode(universe.visualizer || 'warp-core'); + + // Render Event Buttons & Soundboard + renderUniverseEvents(universe); + renderUniverseSoundboard(universe); + + // Render Presets + renderUniversePresets(universe); + + // Select Default Preset + const firstPresetId = Object.keys(universe.presets)[0]; + if (firstPresetId) { + selectPreset(firstPresetId); + } + + if (observationActive && typeof refreshObservation === 'function') { + refreshObservation(); + } + } + + function renderUniverseEvents(universe) { + universeEventsContainer.innerHTML = ''; + universe.events.forEach(evt => { + const btn = document.createElement('button'); + btn.className = 'btn-lcars-pill'; + btn.id = evt.id; + btn.textContent = evt.label; + + btn.addEventListener('click', async () => { + await audioManager.resume(); + handleUniverseEvent(evt.type, btn); + }); + + universeEventsContainer.appendChild(btn); + }); + } + + function renderUniverseSoundboard(universe) { + soundboardContainer.innerHTML = ''; + universe.soundboard.forEach(key => { + const btn = document.createElement('button'); + btn.className = 'btn-chirp'; + btn.id = key.id; + btn.textContent = key.label; + if (key.span) { + btn.style.gridColumn = `span ${key.span}`; + } + + btn.addEventListener('click', async () => { + await audioManager.resume(); + handleSoundboardTrigger(key.id); + }); + + soundboardContainer.appendChild(btn); + }); + } + + function renderUniversePresets(universe) { + presetContainer.innerHTML = ''; + Object.values(universe.presets).forEach(preset => { + const btn = document.createElement('button'); + btn.className = `preset-btn ${preset.id === activePresetId ? 'active' : ''}`; + btn.setAttribute('data-preset-id', preset.id); + + btn.innerHTML = ` + ${preset.name} + ${(preset.era || 'SHIP').toUpperCase()} // ${preset.warp.pulseShape.toUpperCase()} CORE + `; + + btn.addEventListener('click', () => { + selectPreset(preset.id); + }); + + presetContainer.appendChild(btn); + }); + } + + // 4. Select & Apply Preset + function selectPreset(presetId) { + const universe = UniverseRegistry[activeUniverseId]; + if (!universe) return; + + const preset = universe.presets[presetId]; + if (!preset) return; + + activePresetId = presetId; + window.activePresetId = presetId; + + document.querySelectorAll('.preset-btn').forEach(b => { + b.classList.toggle('active', b.getAttribute('data-preset-id') === presetId); + }); + + headerShipName.textContent = preset.name.toUpperCase(); + currentPresetTitle.textContent = preset.name.toUpperCase(); + currentPresetDesc.textContent = preset.description; + statCoreFreq.textContent = `${preset.warp.carrierFreq.toFixed(1)} Hz`; + + hullDrone.applyPreset(preset.hull); + warpCore.applyPreset(preset.warp); + lifeSupport.applyPreset(preset.lifeSupport); + telemetry.applyPreset(preset.telemetry); + + syncSlidersToSynthParams(); + + if (isPlaying) { + if (activeUniverseId === 'whoniverse') whoniverseAudio.synthesizeDematSwitch(); + else if (activeUniverseId === 'industrial') expandedAudio.synthesizeDockingClamp(); + else telemetry.synthesizeLCARSSingleChirp(); + } + + 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 + async function togglePlay() { + if (!isPlaying) { + await audioManager.resume(); + hullDrone.start(); + warpCore.start(); + lifeSupport.start(); + telemetry.start(); + + isPlaying = true; + btnPlay.classList.add('playing'); + playIcon.textContent = '■'; + playText.textContent = 'DISENGAGE'; + statAudioStatus.textContent = 'ONLINE'; + statAudioStatus.style.color = '#00ff88'; + + if (activeUniverseId === 'whoniverse') whoniverseAudio.synthesizeTelepathicChime(); + else if (activeUniverseId === 'bioships') expandedAudio.synthesizeStarburst(); + else telemetry.synthesizeLCARSDoubleChirp(); + } else { + hullDrone.stop(); + warpCore.stop(); + lifeSupport.stop(); + telemetry.stop(); + alerts.stopAlert(); + whoniverseAudio.stopCloisterBell(); + resetAlertButtons(); + + isPlaying = false; + btnPlay.classList.remove('playing'); + playIcon.textContent = '▶'; + playText.textContent = 'ENGAGE AMBIENCE'; + statAudioStatus.textContent = 'STANDBY'; + statAudioStatus.style.color = '#ffcc00'; + } + } + + btnPlay.addEventListener('click', togglePlay); + + // 6. Master Volume Control System + function updateMasterVolumeUI(val, isMuted = false) { + sliderMasterVol.value = val; + const pct = Math.round(val * 100); + valMasterVol.textContent = isMuted ? 'MUTED' : `${pct}%`; + headerVolVal.textContent = isMuted ? 'MUTE' : `${pct}%`; + + if (isMuted || val <= 0.001) { + valMasterDb.textContent = '-∞ dB'; + } else { + const db = 20 * Math.log10(val); + valMasterDb.textContent = `${db >= 0 ? '+' : ''}${db.toFixed(1)} dB`; + } + + btnMasterMute.classList.toggle('muted', isMuted); + btnHeaderMute.classList.toggle('muted', isMuted); + btnMasterMute.textContent = isMuted ? 'UNMUTE' : 'MUTE'; + btnHeaderMute.textContent = isMuted ? 'MUTED' : 'MUTE'; + + if (volMeterLeds) { + const pips = volMeterLeds.querySelectorAll('.led-pip'); + const activeCount = isMuted ? 0 : Math.round(val * pips.length); + pips.forEach((pip, idx) => { + pip.classList.toggle('active', idx < activeCount); + }); + } + + volStepButtons.forEach(btn => { + const stepVal = parseFloat(btn.getAttribute('data-vol')); + btn.classList.toggle('active', !isMuted && Math.abs(stepVal - val) < 0.04); + }); + } + + function setMasterVolume(val) { + const clamped = Math.max(0, Math.min(1, val)); + if (audioManager.isMuted) audioManager.setMute(false); + audioManager.setMasterVolume(clamped); + updateMasterVolumeUI(clamped, false); + } + + function toggleMute() { + const isMuted = audioManager.toggleMute(); + updateMasterVolumeUI(audioManager.getMasterVolume(), isMuted); + } + + sliderMasterVol.addEventListener('input', (e) => setMasterVolume(parseFloat(e.target.value))); + btnMasterMute.addEventListener('click', toggleMute); + btnHeaderMute.addEventListener('click', toggleMute); + btnVolDown.addEventListener('click', () => setMasterVolume(audioManager.getMasterVolume() - 0.05)); + btnVolUp.addEventListener('click', () => setMasterVolume(audioManager.getMasterVolume() + 0.05)); + + volStepButtons.forEach(btn => { + btn.addEventListener('click', () => { + const stepVal = parseFloat(btn.getAttribute('data-vol')); + setMasterVolume(stepVal); + }); + }); + + // 7. Channel Synthesizer Sliders + sliderHullVol.addEventListener('input', (e) => { + const val = parseFloat(e.target.value); + hullDrone.setVolume(val); + valHullVol.textContent = `${Math.round(val * 100)}%`; + }); + sliderHullFreq.addEventListener('input', (e) => { + const val = parseFloat(e.target.value); + hullDrone.setBaseFreq(val); + valHullFreq.textContent = `${val} Hz`; + }); + sliderHullCutoff.addEventListener('input', (e) => { + const val = parseFloat(e.target.value); + hullDrone.setFilterCutoff(val); + valHullCutoff.textContent = `${val} Hz`; + }); + + sliderWarpVol.addEventListener('input', (e) => { + const val = parseFloat(e.target.value); + warpCore.setVolume(val); + valWarpVol.textContent = `${Math.round(val * 100)}%`; + }); + sliderWarpBpm.addEventListener('input', (e) => { + const val = parseInt(e.target.value); + warpCore.setBpm(val); + valWarpBpm.textContent = `${val} BPM`; + }); + sliderWarpCarrier.addEventListener('input', (e) => { + const val = parseFloat(e.target.value); + warpCore.setCarrierFreq(val); + valWarpCarrier.textContent = `${val} Hz`; + statCoreFreq.textContent = `${val.toFixed(1)} Hz`; + }); + + sliderAirVol.addEventListener('input', (e) => { + const val = parseFloat(e.target.value); + lifeSupport.setVolume(val); + valAirVol.textContent = `${Math.round(val * 100)}%`; + }); + sliderAirCutoff.addEventListener('input', (e) => { + const val = parseFloat(e.target.value); + lifeSupport.setFilterCutoff(val); + valAirCutoff.textContent = `${val} Hz`; + }); + + sliderTelemetryVol.addEventListener('input', (e) => { + const val = parseFloat(e.target.value); + telemetry.setVolume(val); + valTelemetryVol.textContent = `${Math.round(val * 100)}%`; + }); + sliderTelemetryDensity.addEventListener('input', (e) => { + const val = parseFloat(e.target.value); + telemetry.setDensity(val); + valTelemetryDensity.textContent = `${Math.round(val * 100)}%`; + }); + + // 8. Universe Event Handlers + function resetAlertButtons() { + document.querySelectorAll('.btn-lcars-pill').forEach(b => b.classList.remove('active-alert')); + } + + function handleUniverseEvent(type, btnElement) { + switch (type) { + case 'warp': + case 'quantum': + alerts.synthesizeWarpJump(); + visualizer.spawnWarpPulses(); + break; + case 'alert-red': + case 'action-stations': + case 'scramble': + if (alerts.activeAlert === 'red') { + alerts.stopAlert(); + resetAlertButtons(); + } else { + resetAlertButtons(); + btnElement.classList.add('active-alert'); + alerts.triggerRedAlert('tng'); + } + break; + case 'alert-yellow': + if (alerts.activeAlert === 'yellow') { + alerts.stopAlert(); + resetAlertButtons(); + } else { + resetAlertButtons(); + btnElement.classList.add('active-alert'); + alerts.triggerYellowAlert(); + } + break; + case 'demat': + whoniverseAudio.synthesizeDematCycle(4); + visualizer.spawnWarpPulses(); + break; + case 'vortex': + whoniverseAudio.synthesizeDematCycle(1); + visualizer.spawnWarpPulses(); + break; + case 'cloister': + if (whoniverseAudio.activeCloister) { + whoniverseAudio.stopCloisterBell(); + resetAlertButtons(); + } else { + resetAlertButtons(); + btnElement.classList.add('active-alert'); + whoniverseAudio.triggerCloisterBell(); + } + break; + case 'epstein': + case 'subdrive': + expandedAudio.synthesizeEpsteinBurn(); + visualizer.spawnWarpPulses(); + break; + case 'purge': + case 'decompress': + case 'docking': + expandedAudio.synthesizeDockingClamp(); + break; + case 'vent': + whoniverseAudio.synthesizeFastReturn(); + break; + case 'starburst': + case 'biodefense': + expandedAudio.synthesizeStarburst(); + visualizer.spawnWarpPulses(); + break; + case 'neural': + whoniverseAudio.synthesizeTelepathicChime(); + break; + case 'centrifuge': + case 'astrogator': + expandedAudio.synthesizeRetroAstrogator(); + break; + case 'hal': + expandedAudio.synthesizeHalChime(); + break; + case 'ftl-jump': + case 'flak': + expandedAudio.synthesizeFtlJump(); + visualizer.spawnWarpPulses(); + break; + case 'singularity': + case 'gravity': + case 'rotation': + expandedAudio.synthesizeSingularityEngage(); + visualizer.spawnWarpPulses(); + break; + case 'solar': + alerts.synthesizeWarpJump(); + break; + case 'afterburner': + expandedAudio.synthesizeAfterburner(); + visualizer.spawnWarpPulses(); + break; + case 'ludicrous': + expandedAudio.synthesizeLudicrousSpeed(); + visualizer.spawnWarpPulses(); + break; + case 'improbability': + expandedAudio.synthesizeImprobabilityFlip(); + visualizer.spawnWarpPulses(); + break; + default: + telemetry.synthesizeLCARSSingleChirp(); + break; + } + } + + function handleSoundboardTrigger(id) { + switch (id) { + case 'btn-chirp-single': + case 'btn-switch-pop': + telemetry.synthesizeLCARSSingleChirp(); + break; + case 'btn-chirp-double': + telemetry.synthesizeLCARSDoubleChirp(); + break; + case 'btn-chirp-ack': + telemetry.synthesizeLCARSSequence(); + break; + case 'btn-chirp-sweep': + telemetry.synthesizeSensorSweep(); + break; + case 'btn-chirp-door': + telemetry.synthesizeDoorChime(); + break; + case 'btn-sonic': + whoniverseAudio.synthesizeSonicScrewdriver(); + break; + case 'btn-fast-return': + whoniverseAudio.synthesizeFastReturn(); + break; + case 'btn-demat-switch': + case 'btn-relay-click': + whoniverseAudio.synthesizeDematSwitch(); + break; + case 'btn-telepathic': + case 'btn-telepathic-chime': + whoniverseAudio.synthesizeTelepathicChime(); + break; + case 'btn-scanner': + whoniverseAudio.synthesizeDematSwitch(); + telemetry.synthesizeSensorSweep(); + break; + case 'btn-geiger': + expandedAudio.synthesizeGeigerBurst(); + break; + case 'btn-dock-clamp': + case 'btn-pneumatic-door': + case 'btn-tape-clunk': + case 'btn-air-handler': + expandedAudio.synthesizeDockingClamp(); + break; + case 'btn-dradis-ping': + expandedAudio.synthesizeDradisPing(); + break; + case 'btn-hal-chime': + expandedAudio.synthesizeHalChime(); + break; + case 'btn-cheerful-door': + expandedAudio.synthesizeCheerfulDoor(); + break; + case 'btn-beryllium-pulse': + case 'btn-tea-machine': + expandedAudio.synthesizeImprobabilityFlip(); + break; + case 'btn-tram-gong': + whoniverseAudio.synthesizeCloisterStrike(); + break; + default: + telemetry.synthesizeLCARSSingleChirp(); + break; + } + } + + // 9. Sleep Timer + timerButtons.forEach(btn => { + btn.addEventListener('click', () => { + const minutes = parseInt(btn.getAttribute('data-minutes')); + if (minutes === 0) { + audioManager.stopSleepTimer(); + timerDisplay.textContent = 'TIMER INACTIVE (CONTINUOUS)'; + } else { + audioManager.startSleepTimer( + minutes, + (secondsLeft) => { + const m = Math.floor(secondsLeft / 60); + const s = secondsLeft % 60; + timerDisplay.textContent = `SLEEP TIMER: ${m}:${s < 10 ? '0' : ''}${s}`; + }, + () => { + timerDisplay.textContent = 'SLEEP TIMER COMPLETED'; + if (isPlaying) togglePlay(); + } + ); + } + }); + }); + + + // ========================================================================= + // OBSERVATION MODE + // One passive full-screen view with theme-specific inline SVG displays. + // Deliberately exits only on an actual click/tap (or Escape). + // ========================================================================= + const btnObservation = document.getElementById('btn-observation'); + const observationOverlay = document.getElementById('observation-overlay'); + const observationStage = document.getElementById('observation-stage'); + const observationSimLayer = document.getElementById('observation-sim-layer'); + const observationUniverse = document.getElementById('observation-universe'); + const observationProfile = document.getElementById('observation-profile'); + const observationStatus = document.getElementById('observation-status'); + const observationEventLayer = document.getElementById('observation-event-layer'); + const sliderObservationActivity = document.getElementById('slider-observation-activity'); + const valObservationActivity = document.getElementById('val-observation-activity'); + const toggleObservationHud = document.getElementById('toggle-observation-hud'); + const valObservationHud = document.getElementById('val-observation-hud'); + const observationChrome = observationOverlay.querySelector('.observation-chrome'); + const observationReturn = observationOverlay.querySelector('.observation-return'); + + let observationActive = false; + let observationActivity = 0.60; + window.observationActivity = observationActivity; + let observationAmbientTimer = null; + let observationTransientSerial = 0; + + function obsClamp(v, lo, hi) { + return Math.max(lo, Math.min(hi, v)); + } + + function obsRand(min, max) { + return min + Math.random() * (max - min); + } + + function obsInt(min, max) { + return Math.floor(obsRand(min, max + 1)); + } + + function obsPick(items) { + return items[Math.floor(Math.random() * items.length)]; + } + + function obsHex(n, width = 3) { + return Math.floor(n).toString(16).toUpperCase().padStart(width, '0'); + } + + + // ========================================================================= + // PERSISTENT OBSERVATION SCENE ENGINE + // Original visual worlds, not franchise reproductions. Persistent entities + // use simple stateful motion rather than one-shot CSS decorations. + // ========================================================================= + let observationSceneRaf = null; + let observationSceneLast = 0; + let observationSceneEntities = []; + let observationSceneBoostUntil = 0; + let observationSplineMorphs = []; + + function obsEaseSmooth(t) { + return t * t * (3 - 2 * t); + } + + function obsEaseInCubic(t) { + return t * t * t; + } + + function obsEaseOutCubic(t) { + return 1 - Math.pow(1 - t, 3); + } + + function obsBezierPoint(t, p0, p1, p2, p3) { + const u = 1 - t; + const tt = t * t; + const uu = u * u; + const uuu = uu * u; + const ttt = tt * t; + return { + x: uuu*p0.x + 3*uu*t*p1.x + 3*u*tt*p2.x + ttt*p3.x, + y: uuu*p0.y + 3*uu*t*p1.y + 3*u*tt*p2.y + ttt*p3.y + }; + } + + function obsBezierDerivative(t, p0, p1, p2, p3) { + const u = 1 - t; + return { + x: 3*u*u*(p1.x-p0.x) + 6*u*t*(p2.x-p1.x) + 3*t*t*(p3.x-p2.x), + y: 3*u*u*(p1.y-p0.y) + 6*u*t*(p2.y-p1.y) + 3*t*t*(p3.y-p2.y) + }; + } + + + // ========================================================================= + // LONG-LIVED SPLINE MORPH ENGINE + // Applies only to persistent pathwork in the main observation stage. + // The goal is subtle recalculation/breathing, not chaotic wriggling. + // ========================================================================= + + function obsThemeSplineScale(theme) { + return ({ + starfleet: 0.60, + whoniverse: 1.05, + industrial: 0.48, + bioships: 1.28, + retrofuture: 0.72, + military: 0.44, + deepspace: 0.34, + outlaw: 0.86, + spacestations: 0.50, + comedy: 0.90 + })[theme] || 0.65; + } + + function obsTokenizePath(d) { + return (d.match(/[A-Za-z]|[-+]?(?:\d*\.\d+|\d+)(?:e[-+]?\d+)?/g) || []); + } + + function obsParsePathSegments(d) { + const tokens = obsTokenizePath(d); + const segments = []; + let i = 0; + while (i < tokens.length) { + const cmd = tokens[i++]; + if (!/^[A-Za-z]$/.test(cmd)) break; + const upper = cmd.toUpperCase(); + + let count = 0; + if (upper === 'M' || upper === 'L' || upper === 'T') count = 2; + else if (upper === 'Q' || upper === 'S') count = 4; + else if (upper === 'C') count = 6; + else if (upper === 'H' || upper === 'V') count = 1; + else if (upper === 'Z') count = 0; + else count = 0; + + if (upper == 'Z') { + segments.push({ cmd, values: [] }); + continue; + } + + const values = []; + while (i < tokens.length && !/^[A-Za-z]$/.test(tokens[i])) { + values.push(Number(tokens[i++])); + } + if (!values.length) { + segments.push({ cmd, values: [] }); + continue; + } + + if (count == 0) { + segments.push({ cmd, values }); + continue; + } + + // Split repeated coordinate groups into separate segments. For repeated M, + // SVG treats the subsequent groups as implicit L commands. + for (let start = 0, segIdx = 0; start < values.length; start += count, segIdx++) { + const slice = values.slice(start, start + count); + if (slice.length < count) break; + const segCmd = (upper === 'M' && segIdx > 0) ? (cmd === upper ? 'L' : 'l') : cmd; + segments.push({ cmd: segCmd, values: slice }); + } + } + return segments; + } + + function obsBuildPathFromSegments(segments) { + return segments.map(seg => { + if (!seg.values || !seg.values.length) return seg.cmd; + const nums = seg.values.map(v => { + const n = Math.abs(v) < 1e-6 ? 0 : v; + return Number(n.toFixed(2)).toString(); + }).join(' '); + return `${seg.cmd}${nums}`; + }).join(' '); + } + + function obsCreateSplineMorphState(pathEl) { + if (!pathEl) return null; + const d = pathEl.getAttribute('d'); + if (!d || !/[CQST]/i.test(d)) return null; + + const segments = obsParsePathSegments(d); + if (!segments.length) return null; + + const bbox = pathEl.getBBox(); + const width = Math.max(80, bbox.width || 80); + const height = Math.max(80, bbox.height || 80); + const routeLike = pathEl.classList.contains('obs-route-wander'); + const themeScale = obsThemeSplineScale(activeUniverseId); + const kindScale = routeLike ? 1.0 : 0.7; + + let drawable = segments + .map((seg, idx) => ({ idx, upper: seg.cmd.toUpperCase(), count: seg.values.length })) + .filter(item => ['Q', 'T', 'S', 'C'].includes(item.upper)); + const lastDrawableIdx = drawable.length ? drawable[drawable.length - 1].idx : -1; + + const ampFractions = []; + segments.forEach((seg, segIdx) => { + const upper = seg.cmd.toUpperCase(); + let frac = []; + + if (upper === 'M' || upper === 'L') { + frac = new Array(seg.values.length).fill(0); + } else if (upper === 'C') { + frac = [1, 1, 1, 1, segIdx === lastDrawableIdx ? 0 : 0.18, segIdx === lastDrawableIdx ? 0 : 0.18]; + } else if (upper === 'S') { + frac = [1, 1, segIdx === lastDrawableIdx ? 0 : 0.18, segIdx === lastDrawableIdx ? 0 : 0.18]; + } else if (upper === 'Q') { + frac = [0.92, 0.92, segIdx === lastDrawableIdx ? 0 : 0.15, segIdx === lastDrawableIdx ? 0 : 0.15]; + } else if (upper === 'T') { + frac = [segIdx === lastDrawableIdx ? 0 : 0.14, segIdx === lastDrawableIdx ? 0 : 0.14]; + } else { + frac = new Array(seg.values.length).fill(0); + } + ampFractions.push(frac); + }); + + const offsets = segments.map(seg => new Array(seg.values.length).fill(0)); + const targets = segments.map(seg => new Array(seg.values.length).fill(0)); + + function amplitudeFor(segIndex, valueIndex) { + const seg = segments[segIndex]; + const frac = (ampFractions[segIndex] && ampFractions[segIndex][valueIndex]) || 0; + if (frac <= 0) return 0; + const upper = seg.cmd.toUpperCase(); + const axis = upper === 'H' ? 'x' : upper === 'V' ? 'y' : (valueIndex % 2 === 0 ? 'x' : 'y'); + const span = axis === 'x' ? width : height; + const baseFrac = routeLike ? 0.038 : 0.026; + return span * baseFrac * frac * kindScale * themeScale; + } + + return { + el: pathEl, + baseD: d, + segments, + ampFractions, + offsets, + targets, + nextRetargetAt: performance.now() + obsRand(2200, 7800), + lastRetargetAt: performance.now(), + width, + height, + routeLike, + targetMultiplier: 1 + }; + } + + function initializeObservationSplineMorphs() { + observationSplineMorphs = []; + if (!observationStage) return; + + const paths = observationStage.querySelectorAll('path.obs-dashflow, path.obs-route-wander'); + paths.forEach(pathEl => { + const state = obsCreateSplineMorphState(pathEl); + if (state) observationSplineMorphs.push(state); + }); + } + + function stopObservationSplineMorphs() { + observationSplineMorphs.forEach(state => { + if (state.el && state.el.isConnected) state.el.setAttribute('d', state.baseD); + }); + observationSplineMorphs = []; + } + + function retargetObservationSpline(state, now, forceScale = 1) { + const activity = observationActivity; + state.targetMultiplier = forceScale; + state.lastRetargetAt = now; + + state.segments.forEach((seg, segIndex) => { + seg.values.forEach((_, valueIndex) => { + const amp = (function() { + const frac = (state.ampFractions[segIndex] && state.ampFractions[segIndex][valueIndex]) || 0; + if (frac <= 0) return 0; + const upper = seg.cmd.toUpperCase(); + const axis = upper === 'H' ? 'x' : upper === 'V' ? 'y' : (valueIndex % 2 === 0 ? 'x' : 'y'); + const span = axis === 'x' ? state.width : state.height; + const baseFrac = state.routeLike ? 0.038 : 0.026; + return span * baseFrac * frac * (state.routeLike ? 1.0 : 0.7) * obsThemeSplineScale(activeUniverseId) * forceScale; + })(); + + if (amp <= 0) { + state.targets[segIndex][valueIndex] = 0; + return; + } + + const bias = state.routeLike ? 1.0 : 0.75; + state.targets[segIndex][valueIndex] = obsRand(-amp * bias, amp * bias); + }); + }); + + const slow = state.routeLike ? 32000 : 42000; + const fast = state.routeLike ? 8500 : 14000; + const next = slow - (slow - fast) * Math.pow(activity, 0.82); + state.nextRetargetAt = now + next * obsRand(0.70, 1.35); + } + + function updateObservationSplineMorphs(now, speedScale) { + if (!observationSplineMorphs.length) return; + + const themeScale = obsThemeSplineScale(activeUniverseId); + observationSplineMorphs = observationSplineMorphs.filter(state => state.el && state.el.isConnected); + + observationSplineMorphs.forEach(state => { + if (now >= state.nextRetargetAt) { + retargetObservationSpline(state, now, 1); + } + + const follow = (0.012 + observationActivity * 0.030 + Math.max(0, speedScale - 0.4) * 0.006) * themeScale; + + state.segments.forEach((seg, segIndex) => { + seg.values.forEach((baseValue, valueIndex) => { + const current = state.offsets[segIndex][valueIndex]; + const target = state.targets[segIndex][valueIndex]; + state.offsets[segIndex][valueIndex] = current + (target - current) * follow; + }); + }); + + const morphed = state.segments.map((seg, segIndex) => ({ + cmd: seg.cmd, + values: seg.values.map((baseValue, valueIndex) => baseValue + state.offsets[segIndex][valueIndex]) + })); + + state.el.setAttribute('d', obsBuildPathFromSegments(morphed)); + }); + } + + function pulseObservationSplineMorphs() { + if (!observationActive || !observationSplineMorphs.length) return; + + const candidates = observationSplineMorphs.filter(state => state.el && state.el.isConnected); + if (!candidates.length) return; + + const count = observationActivity > 0.72 && Math.random() < observationActivity ? 2 : 1; + const shuffled = [...candidates].sort(() => Math.random() - 0.5); + + for (let i = 0; i < Math.min(count, shuffled.length); i++) { + retargetObservationSpline(shuffled[i], performance.now(), 1.22 + observationActivity * 0.38); + } + } + + + function obsSceneShip(kind='scout', color='#dbeafe', accent='#38bdf8') { + if (kind === 'freighter') { + return ` + + + + + + + `; + } + if (kind === 'shuttle') { + return ` + + + + + `; + } + if (kind === 'tug') { + return ` + + + + + `; + } + return ` + + + + + `; + } + + function obsSceneDefs() { + return ` + + + + + + + + + `; + } + + function obsFlightMarkup(id, shipMarkup, cfg) { + return ` + + ${shipMarkup} + `; + } + + function obsOrbitMarkup(id, body, cfg) { + return ` + + ${body} + `; + } + + function obsFloatMarkup(id, body, cfg) { + return ` + + ${body} + `; + } + + function buildObservationSceneMarkup(theme) { + const defs = obsSceneDefs(); + + if (theme === 'starfleet') { + return `${defs} + + ${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', ``, { + cx:800,cy:455,rx:245,ry:155,duration:24000,phase:.2,scale0:.65,scale1:1.05,minActivity:.05 + })} + ${obsOrbitMarkup('sf-orbit-2', ``, { + cx:800,cy:455,rx:165,ry:245,duration:39000,phase:.62,scale0:.5,scale1:.9,minActivity:.5 + })} + `; + } + + if (theme === 'whoniverse') { + const glyph = (char,color,size) => `${char}`; + 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', ``, + {cx:480,cy:520,ampX:95,ampY:75,duration:17000,phase:.35,minActivity:.25})} + ${obsFloatMarkup('who-fragment-2', ``, + {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) => + ``; + return `${defs} + + ${feedClip('indSimCam1', 290, 259)} + ${feedClip('indSimCam2', 825, 259)} + ${feedClip('indSimCam3', 290, 483)} + ${feedClip('indSimCam4', 825, 483)} + + + ${obsFloatMarkup('ind-bay-drone', ` + + + + + `, {cx:532,cy:420,ampX:186,ampY:11,duration:31000,phase:.15,minActivity:.2,opacity:.66})} + + + ${obsFloatMarkup('ind-ember-1', ``, + {cx:1035,cy:352,ampX:38,ampY:52,duration:13000,phase:.2,minActivity:.25,opacity:.6})} + ${obsFloatMarkup('ind-ember-2', ``, + {cx:1092,cy:320,ampX:46,ampY:44,duration:17000,phase:.62,minActivity:.45,opacity:.5})} + ${obsFloatMarkup('ind-ember-3', ``, + {cx:1160,cy:372,ampX:30,ampY:40,duration:21000,phase:.85,minActivity:.6,opacity:.42})} + + + ${obsFloatMarkup('ind-lock-tell', ` + `, + {cx:532,cy:640,ampX:0,ampY:13,duration:23000,phase:.3,minActivity:.5,opacity:.4})} + + + ${obsFloatMarkup('ind-corridor-lamp', ` + `, + {cx:1067,cy:600,ampX:118,ampY:26,duration:27000,phase:.4,minActivity:.3,opacity:.55})} + `; + } + + if (theme === 'bioships') { + const particle = (color,r=5) => ``; + 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', ``, {cx:800,cy:470,rx:210,ry:125,duration:19000,phase:.3,scale0:.65,scale1:1.2,minActivity:.3})} + ${obsFloatMarkup('bio-membrane', ``, + {cx:800,cy:470,ampX:26,ampY:18,duration:13000,phase:.1,minActivity:.55,opacity:.6})}`; + } + + if (theme === 'retrofuture') { + const vectorShip = ``; + 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', ``, {cx:800,cy:460,rx:235,ry:235,duration:30000,phase:.15,scale0:.55,scale1:.95,minActivity:.05})} + ${obsFloatMarkup('retro-liss', ``, + {cx:800,cy:460,ampX:45,ampY:25,duration:14000,phase:.42,minActivity:.7,opacity:.5})}`; + } + + if (theme === 'military') { + const tri = ``; + return `${defs} + ${obsFlightMarkup('mil-form', ` + + ${tri} + ${tri} + ${tri} + ${tri} + `, {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', ``, + {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', ``, + {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', ``, { + 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', ` + + + + + `, {cx:1200,cy:590,ampX:80,ampY:24,duration:95000,phase:.22,minActivity:.05,opacity:.55})}`; + } + + if (theme === 'outlaw') { + return `${defs} + ${obsFlightMarkup('out-runner', ` + + + + + `, {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', ` + + + + + `, {cx:1250,cy:535,ampX:8,ampY:4,duration:9000,phase:.2,minActivity:.25,opacity:.58})}`; + } + + if (theme === 'spacestations') { + const clipOpen = ``; + const clipClose = ``; + 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', ` + + + + + `, {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', `?`, + {cx:800,cy:445,rx:330,ry:160,duration:33000,phase:.35,scale0:.65,scale1:1.15,minActivity:.45,opacity:.58})} + ${obsFloatMarkup('com-cube', ``, + {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 = ``; + 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 ``; + } + + 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 ` + + + + + ${title} + ${line} + + `; + } + + 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 ` + + + + + + ${label} + + `; + } + + function obsExpandingRing(x, y, color = 'var(--primary-accent)', radius = 70) { + return ` + + + + `; + } + + function generateStarfleetActivity(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')}`) + + ``, + 4300 + ); + } + if (effect === 'vector') { + const y1=obsInt(260,680), y2=obsInt(220,690); + return spawnObservationTransient(` + + + `, 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) => + ``).join(''); + return spawnObservationTransient(`${bars}`, 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 ``; + }).join(''); + return spawnObservationTransient(`${lines}`, 3000); + } + return spawnObservationTransient(obsExpandingRing(obsInt(420,1180),obsInt(250,680),'var(--primary-accent)',obsInt(45,90)), 3200); + } + + function generateWhoniverseActivity(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=>` + + `).join('')} + `, 4700); + } + if (effect === 'glyphs') { + const glyphs = Array.from({length: obsInt(4,9)}, (_,i) => + `${obsPick(['◉','◎','⌁','∆','∴','∞','⊙','⊛'])}` + ).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(` + + + + + + `, 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) => ` + + + + + + + + + ${body}`; + + 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 ``; + }).join(''); + return spawnObservationTransient(inFeed(` + + ${blocks} + + SIGNAL RECALIBRATION + `), 3600); + } + + // Total feed loss, then reacquire. + if (effect === 'dropout') { + return spawnObservationTransient(inFeed(` + + + NO SIGNAL + ${f.id} // REACQUIRING + + `), 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 ``; + }).join(''); + return spawnObservationTransient(inFeed(` + ${puffs} + + `), 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(` + + + + + + + + MOTION // ${f.id} + `), 4600); + } + + // Pressure-door cycle seen head on. + if (effect === 'door') { + const mid = W / 2, top = 58, bot = H - 52; + return spawnObservationTransient(inFeed(` + + + + + + + + + + + HATCH CYCLE // ${f.where} + `), 4800); + } + + // Auto-iris hunting after a lighting change. + if (effect === 'gain') { + return spawnObservationTransient(inFeed(` + + AGC // IRIS ADJUST + `), 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(` + + `, 3300); + } + if (effect === 'spores') { + const spores=Array.from({length:obsInt(10,24)},()=>` + `).join(''); + return spawnObservationTransient(spores, 7000); + } + if (effect === 'tendril') { + const sx=obsPick([180,1420]), sy=obsInt(300,660); + return spawnObservationTransient(` + + `, 5200); + } + if (effect === 'organ') { + return spawnObservationTransient(` + + + + + + + `, 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(` + + `, 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(` + + + + `, 4800); + } + if (effect === 'bloom') { + return spawnObservationTransient(obsExpandingRing(obsInt(480,1120),obsInt(270,650),'#86efac',obsInt(35,75)),3000); + } + return spawnObservationTransient(` + + `, 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(` + + ${obsContact(x,y,'#ef4444','VECTOR')} + `, 4700); + } + if (effect === 'formation') { + const cx=obsInt(600,1000), cy=obsInt(340,600); + return spawnObservationTransient(` + + + + + + + + `, 5200); + } + if (effect === 'sector') { + return spawnObservationTransient(` + + `, 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(` + + + + + + + `, 6500); + } + if (effect === 'comet') { + return spawnObservationTransient(` + + + + `, 5200); + } + if (effect === 'lens') { + return spawnObservationTransient(` + + + + `, 6800); + } + if (effect === 'planet') { + const y=obsInt(260,620), r=obsInt(45,110); + return spawnObservationTransient(` + + + + + + `, 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)},()=>` + `).join(''); + return spawnObservationTransient(`${bars}`,3000); + } + if (effect === 'route') { + return spawnObservationTransient(` + + ROUTE RECALC... + `,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(` + + + + `,4500); + } + + function generateStationActivity(power) { + const effect = obsPick(['dock','depart','guidance','traffic','beacon','queue']); + if (effect === 'dock') { + const y = obsInt(250, 520); + return spawnObservationTransient(` + + + + DOCK VECTOR ${obsInt(1,18)} + `, 6200); + } + if (effect === 'depart') { + const y = obsInt(235, 540); + return spawnObservationTransient(` + + + + + + + + + `, 7000); + } + if (effect === 'guidance') { + const x = obsInt(470, 1100), y = obsInt(230, 570); + return spawnObservationTransient(` + + + + + `, 4600); + } + if (effect === 'beacon') { + const x = obsPick([248, 1352]); + const y = obsPick([168, 648]); + return spawnObservationTransient(` + + + + `, 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(` + + TRAFFIC FLOW ${obsInt(1,9)} + `, 5200); + } + + function generateComedyActivity(power) { + const effect = obsPick(['route','oddity','planet','status','contact','geometry']); + if (effect === 'route') { + return spawnObservationTransient(` + + `,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(` + + + + + + `,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(``,5200); + } + if (effect === 'contact') { + return spawnObservationTransient(obsContact(obsInt(450,1150),obsInt(250,650),'#67e8f9',obsPick(['FRIENDLY?','THING','OBJECT','PROBABLY FINE'])),4300); + } + return spawnObservationTransient(obsExpandingRing(obsInt(450,1150),obsInt(250,650),obsPick(['#06b6d4','#f43f5e','#fbbf24']),obsInt(45,100)),3500); + } + + function triggerObservationActivity(source = 'ambient') { + if (!observationActive || observationActivity <= 0.001) return; + + // Telemetry is a synchronization pulse, but the slider controls whether + // that pulse becomes visible. At maximum every telemetry pulse produces + // a visible response; at low levels many pass quietly. + if (source === 'telemetry') { + const responseChance = 0.18 + observationActivity * 0.82; + if (Math.random() > responseChance) return; + } + + let count = 1; + if (source === 'telemetry' && observationActivity > .55 && Math.random() < observationActivity) count++; + if (observationActivity > .82 && Math.random() < (observationActivity - .7)) count++; + + const generator = { + starfleet: generateStarfleetActivity, + whoniverse: generateWhoniverseActivity, + industrial: generateIndustrialActivity, + bioships: generateBioshipActivity, + retrofuture: generateRetroActivity, + military: generateMilitaryActivity, + deepspace: generateDeepSpaceActivity, + outlaw: generateOutlawActivity, + spacestations: generateStationActivity, + comedy: generateComedyActivity + }[activeUniverseId] || generateStarfleetActivity; + + for (let i = 0; i < count; i++) { + window.setTimeout(() => generator(observationActivity), i * obsInt(120,380)); + } + } + + function scheduleObservationAmbientActivity() { + if (observationAmbientTimer) { + clearTimeout(observationAmbientTimer); + observationAmbientTimer = null; + } + if (!observationActive || observationActivity <= .01) return; + + // Quiet = occasional subtle life. Active = a busy generative display. + const slow = 12500; + const fast = 1500; + const base = slow - (slow - fast) * Math.pow(observationActivity, .82); + const jitter = base * obsRand(.25,.75); + + observationAmbientTimer = window.setTimeout(() => { + triggerObservationActivity('ambient'); + scheduleObservationAmbientActivity(); + }, base + jitter); + } + + function setObservationActivity(raw) { + observationActivity = obsClamp(Number(raw) / 100, 0, 1); + window.observationActivity = observationActivity; + observationOverlay.style.setProperty('--obs-activity', String(observationActivity)); + const pct = Math.round(observationActivity * 100); + valObservationActivity.textContent = `${pct}%`; + if (observationActive) { + observationStatus.innerHTML = + `OBSERVATION ACTIVE
PROCEDURAL AUDIO LINK: ${isPlaying ? 'ONLINE' : 'STANDBY'}
VISUAL ACTIVITY: ${pct}%`; + scheduleObservationAmbientActivity(); + } + } + + function resetObservationReturnFade() { + if (!observationReturn) return; + + observationReturn.style.transition = 'none'; + observationReturn.classList.remove('observation-return-fade'); + void observationReturn.offsetWidth; + observationReturn.style.transition = ''; + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (observationActive) { + observationReturn.classList.add('observation-return-fade'); + } + }); + }); + } + + function resetObservationHudFade() { + if (!observationChrome) return; + + // Remove transition momentarily so each OBSERVATION session starts fully visible. + observationChrome.style.transition = 'none'; + observationChrome.classList.remove('observation-hud-fade'); + void observationChrome.offsetWidth; + observationChrome.style.transition = ''; + + if (!toggleObservationHud.checked) { + // Start the thirty-second fade on the next frame. + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (observationActive && !toggleObservationHud.checked) { + observationChrome.classList.add('observation-hud-fade'); + } + }); + }); + } + } + + function updateObservationHudMode() { + const hold = toggleObservationHud.checked; + valObservationHud.textContent = hold ? 'ON' : 'FADE'; + + if (!observationActive || !observationChrome) return; + + if (hold) { + observationChrome.style.transition = 'none'; + observationChrome.classList.remove('observation-hud-fade'); + void observationChrome.offsetWidth; + observationChrome.style.transition = ''; + } else { + resetObservationHudFade(); + } + } + + toggleObservationHud.addEventListener('change', updateObservationHudMode); + updateObservationHudMode(); + + sliderObservationActivity.addEventListener('input', () => { + setObservationActivity(sliderObservationActivity.value); + if (observationActive) scheduleObservationInstrumentTick(); + }); + + window.addEventListener('scifi-telemetry-activity', (event) => { + if (!observationActive) return; + triggerObservationActivity('telemetry'); + nudgeObservationInstruments(event.detail ? event.detail.density : null); + pulseObservationScene(); + pulseObservationSplineMorphs(); + }); + + setObservationActivity(sliderObservationActivity.value); + + + // ========================================================================= + // LIVE OBSERVATION INSTRUMENTATION + // The fixed OBSERVATION chrome is intentionally outside this system. + // Only fantasy elements inside the main SVG/event layers participate. + // ========================================================================= + + let observationInstrumentStates = []; + let observationInstrumentTimer = null; + let observationTransientObserver = null; + + const observationTextVocabulary = { + starfleet: ['NOMINAL','PASSIVE','SCANNING','CORRELATING','TRACKING','VERIFIED','STABLE'], + whoniverse: ['VORTEX LOCK','CONVERGING','DRIFTING','RECURSIVE','RESOLVING','TEMPORAL HOLD'], + industrial: ['SECURE FEED','MONITORING','SIGNAL HOLD','CYCLING','LOCAL CHECK','STABLE'], + bioships: ['QUIESCENT','RESPONDING','ADAPTING','RESONANT','SYNCHRONIZED','PULSING'], + retrofuture: ['READY','READ','PROCESS','HOLD','TRACK','STABLE'], + military: ['PASSIVE','TRACKING','CORRELATING','VERIFIED','WATCH','CLEAR'], + deepspace: ['DEEP FIELD','OBSERVING','SPECTRAL HOLD','PASSIVE','RESOLVING','STABLE'], + outlaw: ['MANUAL','INTERMITTENT','PATCHED','SEARCHING','HOLDING','DIRTY LOCK'], + spacestations: ['PASSIVE WATCH','TRAFFIC HOLD','DOCKING','ROUTING','OBSERVING','CLEAR'], + comedy: ['PROBABLY FINE','MOSTLY NOMINAL','STILL FINE','SCENIC','TEA ADJACENT','UNNECESSARILY PRECISE'] + }; + + const observationPlainTextVariants = { + comedy: { + 'TEA SOURCE DETECTED': ['TEA SOURCE DETECTED','TEA SOURCE PROBABLE','TEA SOURCE LOST','BEVERAGE VECTOR ACQUIRED'], + 'NO IMMEDIATE CAUSE FOR PANIC': ['NO IMMEDIATE CAUSE FOR PANIC','STILL NO CAUSE FOR PANIC','PANIC NOT CURRENTLY REQUIRED','SITUATION MOSTLY NORMAL'], + 'SCENIC NAVIGATION': ['SCENIC NAVIGATION','SCENIC RECALCULATION','ROUTE APPRECIATION','NAVIGATION, BUT NICER'] + } + }; + + function getObservationTelemetryDensity() { + if (typeof telemetry !== 'undefined' && telemetry && telemetry.params) { + return obsClamp(Number(telemetry.params.density) || 0, 0, 1); + } + const slider = document.getElementById('slider-telemetry-density'); + return slider ? obsClamp(Number(slider.value) || 0, 0, 1) : 0.5; + } + + function classifyObservationNumber(source, raw, index) { + const upper = source.toUpperCase(); + const decimals = raw.includes('.') ? raw.split('.')[1].length : 0; + const unsignedRaw = raw.replace(/^[-+]/, ''); + const integerPart = unsignedRaw.split('.')[0]; + const leadingZero = integerPart.length > 1 && integerPart.startsWith('0'); + const resolution = Math.pow(10, -decimals); + const base = Number(raw); + + let kind = 'generic'; + let min = -Infinity; + let max = Infinity; + let circular = false; + let macroScale = Math.max(resolution * 2, Math.abs(base || 1) * 0.025); + + const after = source.slice(index + raw.length, index + raw.length + 4); + const before = source.slice(Math.max(0, index - 24), index).toUpperCase(); + + if (after.includes('%') || upper.includes('QUALITY') || upper.includes('CONFIDENCE') || upper.includes('EFFICIENCY')) { + kind = 'percent'; + min = 0; + max = upper.includes('CONFIDENCE') ? 103 : 100; + macroScale = 8; + } else if (upper.includes('NAV VECTOR') || upper.includes('BEARING') || upper.includes('HEADING') || upper.includes('VECTOR ')) { + kind = 'heading'; + min = 0; + max = 360; + circular = true; + macroScale = 18; + } else if (upper.includes('CAM ') || upper.includes('CONTACT ') || upper.includes('TRACK ') || + upper.includes('BAY ') || upper.includes('SECTOR ') || upper.includes('OBJECT ') || + before.endsWith('CAM ') || before.endsWith('TRACK ')) { + kind = 'identifier'; + min = 0; + max = leadingZero ? Math.pow(10, integerPart.length) - 1 : Math.max(99, base + 24); + macroScale = Math.max(1, Math.min(4, 1 + base * .02)); + } else if (upper.includes('RANGE') || upper.includes('DIST') || upper.includes(' LY') || upper.includes(' MM')) { + kind = 'range'; + min = 0; + max = Math.max(base * 3, base + 1000); + macroScale = Math.max(resolution * 3, Math.abs(base || 1) * .18); + } else if (upper.includes('TIME') || upper.includes(':')) { + kind = 'clockish'; + min = 0; + max = decimals ? 99.9 : 99; + macroScale = decimals ? 2.5 : 5; + } else if (base >= 0 && base <= 100) { + min = 0; + max = 100; + macroScale = Math.max(resolution * 3, 4); + } + + return { + raw, + base, + current: base, + target: base, + decimals, + integerWidth: integerPart.length, + leadingZero, + resolution, + kind, + min, + max, + circular, + macroScale, + flutterOffset: 0, + nextFlutterAt: performance.now() + obsRand(350, 1400), + correctionKick: 0, + correctionUntil: 0 + }; + } + + function getStatusMutationRule(source) { + const text = source.trim(); + const upper = text.toUpperCase(); + + // Camera location names are identities, not status fields. + if (/^CAM\s+\d+\s*\/\//i.test(text)) return null; + + if (text.includes('//')) { + const splitAt = text.lastIndexOf('//'); + const prefix = text.slice(0, splitAt + 2).trimEnd(); + const suffix = text.slice(splitAt + 2).trim(); + + // These are display/status phrases and can safely wander. + if (/(PASSIVE|NOMINAL|ACTIVE|OBSERVATION|WATCH|FEED|SENSORS|SECURE|FIELD|ARRAY|CONTROL)/i.test(suffix) || + /(SENSOR|VECTOR|TACTICAL|OPTICAL|BIOLOGICAL|TRAFFIC|LIVE)/i.test(prefix)) { + return { type: 'suffix', prefix, original: suffix }; + } + } + + // A few colon-delimited fantasy statuses. + const colonMatch = text.match(/^(ROUTE|TRANSPONDER)\s*:\s*(.+)$/i); + if (colonMatch) { + return { type: 'colon', prefix: colonMatch[1].toUpperCase(), original: colonMatch[2].trim() }; + } + + const plain = observationPlainTextVariants[activeUniverseId]; + if (plain && plain[upper]) { + return { type: 'plain', original: upper, variants: plain[upper] }; + } + + return null; + } + + function instrumentObservationTextNode(node) { + if (!node || node.nodeType !== 1 || node.dataset.obsLiveInstrumented === '1') return; + + const source = (node.textContent || '').trim(); + if (!source) return; + + const numericMatches = [...source.matchAll(/[-+]?\d+(?:\.\d+)?/g)]; + const numbers = numericMatches.map(m => classifyObservationNumber(source, m[0], m.index)); + + const state = { + node, + source, + numbers, + numberMatches: numericMatches.map(m => ({ raw: m[0], index: m.index })), + statusRule: getStatusMutationRule(source), + statusOverride: null, + statusUntil: 0, + nextTextGlitchAt: performance.now() + obsRand(2500, 9000), + glitchUntil: 0, + glitchSeed: 0 + }; + + node.dataset.obsLiveInstrumented = '1'; + node.classList.add('obs-live-instrument'); + observationInstrumentStates.push(state); + } + + function instrumentObservationTree(root) { + if (!root || !root.querySelectorAll) return; + if (root.matches && root.matches('text')) instrumentObservationTextNode(root); + root.querySelectorAll('text').forEach(instrumentObservationTextNode); + } + + function initializeObservationLiveInstrumentation() { + stopObservationLiveInstrumentation(false); + observationInstrumentStates = []; + + instrumentObservationTree(observationStage); + instrumentObservationTree(observationEventLayer); + + observationTransientObserver = new MutationObserver((mutations) => { + for (const mutation of mutations) { + mutation.addedNodes.forEach(node => { + if (node.nodeType === 1) instrumentObservationTree(node); + }); + } + }); + observationTransientObserver.observe(observationEventLayer, { childList: true, subtree: true }); + + scheduleObservationInstrumentTick(); + } + + function stopObservationLiveInstrumentation(clearStates = true) { + if (observationInstrumentTimer) { + clearTimeout(observationInstrumentTimer); + observationInstrumentTimer = null; + } + if (observationTransientObserver) { + observationTransientObserver.disconnect(); + observationTransientObserver = null; + } + if (clearStates) observationInstrumentStates = []; + } + + function formatObservationNumber(value, token) { + let v = value; + + if (token.circular) { + const span = token.max - token.min; + v = ((v - token.min) % span + span) % span + token.min; + } else { + if (Number.isFinite(token.min)) v = Math.max(token.min, v); + if (Number.isFinite(token.max)) v = Math.min(token.max, v); + } + + // Identity-like fields stay integer but can still "flutter" by one count. + if (token.kind === 'identifier') v = Math.round(v); + + let result = token.decimals > 0 ? v.toFixed(token.decimals) : String(Math.round(v)); + + if (token.leadingZero) { + const sign = result.startsWith('-') ? '-' : ''; + const unsigned = result.replace(/^-/, ''); + const parts = unsigned.split('.'); + parts[0] = parts[0].padStart(token.integerWidth, '0'); + result = sign + parts.join('.'); + } + return result; + } + + function shortestCircularDelta(current, target, min, max) { + const span = max - min; + let delta = target - current; + if (delta > span / 2) delta -= span; + if (delta < -span / 2) delta += span; + return delta; + } + + function rebuildObservationText(state, now) { + if (!state.node || !state.node.isConnected) return; + + let cursor = 0; + let built = ''; + + state.numberMatches.forEach((match, i) => { + const token = state.numbers[i]; + built += state.source.slice(cursor, match.index); + + let displayValue = token.current + token.flutterOffset; + + if (token.correctionUntil > now) { + const remaining = (token.correctionUntil - now) / 1400; + displayValue += token.correctionKick * Math.max(0, Math.min(1, remaining)); + } + + built += formatObservationNumber(displayValue, token); + cursor = match.index + match.raw.length; + }); + + built += state.source.slice(cursor); + + if (state.statusOverride && state.statusUntil > now && state.statusRule) { + const rule = state.statusRule; + if (rule.type === 'suffix') { + const splitAt = built.lastIndexOf('//'); + if (splitAt >= 0) built = `${built.slice(0, splitAt + 2)} ${state.statusOverride}`; + } else if (rule.type === 'colon') { + const colonAt = built.indexOf(':'); + if (colonAt >= 0) built = `${built.slice(0, colonAt + 1)} ${state.statusOverride}`; + } else if (rule.type === 'plain') { + built = state.statusOverride; + } + } else if (state.statusOverride && state.statusUntil <= now) { + state.statusOverride = null; + } + + // Very brief display corruption. This is visual noise only and always recovers. + if (state.glitchUntil > now && built.length > 3) { + const chars = [...built]; + const candidates = []; + for (let i = 1; i < chars.length - 1; i++) { + if (/[A-Z0-9]/i.test(chars[i])) candidates.push(i); + } + if (candidates.length) { + const idx = candidates[state.glitchSeed % candidates.length]; + chars[idx] = obsPick(['_','?','·','Ξ','0','1']); + built = chars.join(''); + state.node.classList.add('obs-live-glitch'); + } + } else { + state.node.classList.remove('obs-live-glitch'); + } + + state.node.textContent = built; + } + + function updateObservationNumberToken(token, now) { + const activity = observationActivity; + + let delta = token.circular + ? shortestCircularDelta(token.current, token.target, token.min, token.max) + : token.target - token.current; + + // Activity controls how quickly the displayed instrument tracks its target. + const trackingFraction = 0.07 + activity * 0.31; + if (Math.abs(delta) > token.resolution * .15) { + token.current += delta * trackingFraction; + } else { + token.current = token.target; + } + + // Micro flutter: the least-significant digit hunts around the real value. + if (now >= token.nextFlutterAt) { + const flutterChance = 0.34 + activity * 0.56; + if (Math.random() < flutterChance) { + const direction = obsPick([-1, 1]); + const steps = Math.random() < (.18 + activity * .2) ? 2 : 1; + const identifierDamping = token.kind === 'identifier' ? .55 : 1; + token.flutterOffset = direction * token.resolution * steps * identifierDamping; + } else { + token.flutterOffset = 0; + } + + const slow = 1850; + const fast = 240; + token.nextFlutterAt = now + (slow - (slow-fast) * activity) * obsRand(.65, 1.35); + } else if (Math.random() < .16 + activity * .18) { + token.flutterOffset *= .45; + if (Math.abs(token.flutterOffset) < token.resolution * .15) token.flutterOffset = 0; + } + + if (token.circular) { + const span = token.max - token.min; + token.current = ((token.current - token.min) % span + span) % span + token.min; + } else { + if (Number.isFinite(token.min)) token.current = Math.max(token.min, token.current); + if (Number.isFinite(token.max)) token.current = Math.min(token.max, token.current); + } + } + + function maybeGlitchObservationText(state, now) { + if (now < state.nextTextGlitchAt) return; + + const activity = observationActivity; + const density = getObservationTelemetryDensity(); + const chance = .06 + activity * .18 + density * .05; + + if (Math.random() < chance) { + state.glitchSeed = obsInt(0, 100000); + state.glitchUntil = now + obsRand(90, 260 + activity * 180); + } + + const slow = 17000; + const fast = 2800; + state.nextTextGlitchAt = now + (slow - (slow-fast) * activity) * obsRand(.6, 1.4); + } + + function observationInstrumentTick() { + observationInstrumentTimer = null; + if (!observationActive) return; + + const now = performance.now(); + observationInstrumentStates = observationInstrumentStates.filter(state => state.node && state.node.isConnected); + + for (const state of observationInstrumentStates) { + state.numbers.forEach(token => updateObservationNumberToken(token, now)); + maybeGlitchObservationText(state, now); + rebuildObservationText(state, now); + } + + scheduleObservationInstrumentTick(); + } + + function scheduleObservationInstrumentTick() { + if (!observationActive) return; + if (observationInstrumentTimer) clearTimeout(observationInstrumentTimer); + + // Observation level directly controls the "instrument refresh rate". + // At quiet settings values settle visibly and slowly; at high settings + // they look like fast live telemetry. + const slowMs = 900; + const fastMs = 105; + const delay = slowMs - (slowMs - fastMs) * Math.pow(observationActivity, .88); + observationInstrumentTimer = setTimeout(observationInstrumentTick, delay); + } + + function chooseObservationTarget(token, density) { + const amplitudeFloor = token.resolution; + const amplitude = Math.max( + amplitudeFloor, + token.macroScale * (.06 + Math.pow(density, 1.12)) + ); + + let delta; + if (token.kind === 'identifier') { + const maxStep = Math.max(1, Math.round(1 + density * 3)); + delta = obsPick([-1,1]) * obsInt(1, maxStep); + } else { + delta = obsRand(-amplitude, amplitude); + } + + let target = token.target + delta; + + if (token.circular) { + const span = token.max - token.min; + target = ((target - token.min) % span + span) % span + token.min; + } else { + if (Number.isFinite(token.min)) target = Math.max(token.min, target); + if (Number.isFinite(token.max)) target = Math.min(token.max, target); + } + + token.target = target; + + // Sometimes a display control-loop overshoots and corrects itself. + if (Math.random() < .28 + observationActivity * .24) { + const sign = Math.random() < .5 ? -1 : 1; + const kickSteps = token.kind === 'identifier' ? 1 : obsRand(.6, 2.2 + observationActivity * 1.5); + token.correctionKick = sign * token.resolution * kickSteps; + token.correctionUntil = performance.now() + obsRand(550, 1450); + } + } + + function chooseObservationStatusOverride(state, density) { + if (!state.statusRule) return; + + let variants; + const rule = state.statusRule; + + if (rule.type === 'plain') { + variants = rule.variants || []; + } else { + variants = observationTextVocabulary[activeUniverseId] || observationTextVocabulary.starfleet; + } + + if (!variants.length) return; + + const original = rule.original.toUpperCase(); + const choices = variants.filter(v => v !== original); + if (!choices.length) return; + + state.statusOverride = obsPick(choices); + + // Higher observation activity means state changes read faster and clear sooner. + const holdSlow = 6200; + const holdFast = 1700; + state.statusUntil = performance.now() + + (holdSlow - (holdSlow-holdFast) * observationActivity) * obsRand(.7, 1.35); + } + + function nudgeObservationInstruments(densityOverride = null) { + if (!observationActive) return; + + const density = densityOverride == null + ? getObservationTelemetryDensity() + : obsClamp(Number(densityOverride) || 0, 0, 1); + + const liveStates = observationInstrumentStates.filter(s => s.node && s.node.isConnected); + const numericStates = liveStates.filter(s => s.numbers.length); + const statusStates = liveStates.filter(s => s.statusRule); + + // Density controls how much of the imaginary system is disturbed by a chirp. + const desiredNumeric = Math.min( + numericStates.length, + Math.max(1, Math.round(1 + density * (2 + numericStates.length * .22))) + ); + + const shuffledNumbers = [...numericStates].sort(() => Math.random() - .5); + for (let i = 0; i < desiredNumeric; i++) { + const state = shuffledNumbers[i]; + state.numbers.forEach(token => { + // A dense chirp can perturb several values in a compound readout. + if (Math.random() < .55 + density * .4) chooseObservationTarget(token, density); + }); + } + + // Text states are less frequent than numeric movement. They read as system + // transitions rather than a constantly changing label soup. + if (statusStates.length && Math.random() < .28 + density * .5) { + const desiredText = density > .72 && Math.random() < density ? 2 : 1; + const shuffledText = [...statusStates].sort(() => Math.random() - .5); + for (let i = 0; i < Math.min(desiredText, shuffledText.length); i++) { + chooseObservationStatusOverride(shuffledText[i], density); + } + } + } + + + function seededUnit(seed) { + let x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453; + return x - Math.floor(x); + } + + function makeStars(count = 90, width = 1600, height = 900, tint = '#dbeafe', seedOffset = 0) { + let stars = ''; + for (let i = 0; i < count; i++) { + const x = Math.floor(seededUnit(i + 1 + seedOffset) * width); + const y = Math.floor(seededUnit((i + 1 + seedOffset) * 7.31) * height); + const r = (0.55 + seededUnit((i + seedOffset + 4) * 3.17) * 1.65).toFixed(2); + const o = (0.25 + seededUnit((i + seedOffset + 9) * 5.19) * 0.72).toFixed(2); + stars += ``; + } + return stars; + } + + function svgFrame(inner, defs = '') { + return ` + + + + + + + + + + + + + + + + + ${defs} + + ${inner} + `; + } + + function renderObservationStarfleet() { + const starsA = makeStars(115, 1750, 900, '#dbeafe', 2); + const starsB = makeStars(55, 1750, 900, '#93c5fd', 220); + return svgFrame(` + + ${starsA} + ${starsB} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + LONG RANGE SENSOR ARRAY // PASSIVE + NAV VECTOR 034.8 // NOMINAL + `); + } + + function renderObservationWhoniverse() { + return svgFrame(` + + + + + + + + + + + + + + + + + + + + + + + + + ⌁ 37.4 ∴ 9 + ∆ 04:11:∞ + + + TEMPORAL DISPLACEMENT // VORTEX OBSERVATION + + `); + } + + 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 ` + + + + + + + + + + ${id} + `; + }; + + // 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 += ``; + } + 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 ` + + + + + + + + + ${label} + `; + }; + + // 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) => ` + + + + + + + + + + + + + + `; + + const cam = (i, label, code, status, scene) => ` + + + + + + + + + + ${scene} + + ${feedFx(i)} + + + + ${label} + ${code} + + ${status} + LIVE // SECURE FEED + + + + + SELECTED + + `; + + // ------------------------------------------------- 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 += ` + + + `; + } + const trefoil = [0, 120, 240].map(a => + ``).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 += ` + + + `; + } + 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) => ` + + + + + + + ${txt} + `; + const suits = [0, 1, 2].map(i => ` + + + + + + + `).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 += ``; + corrFloor += ``; + } + for (let x = 24; x <= 626; x += 60) { + corrFloor += ``; + } + for (let i = 1; i < 5; i++) { + const s = Math.pow(0.76, i); + const y = vpy + (34 - vpy) * s, w = 62 * s; + corrLamps += ` + + + `; + } + const fanBlades = [0, 72, 144, 216, 288].map(a => + ``).join(''); + const drip = (x, y, d) => + ` + `; + + // ------------------------------------------------------- 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 = ` + + + + + + + + + + + + + + + BAY 2 // AFT + + + + + + + + + + + + + ${crate(90, 174, 88, 40, '#5d4427', '#8a6a42', 'CB-4471', '#e8c88a')} + + ${crate(90, 134, 88, 40, '#6b4d2c', '#96754a', 'CB-4472', '#e8c88a')} + + ${crate(182, 174, 88, 40, '#4f3a22', '#7d6039', 'HAZ-3/09', '#f0b64f')} + + + + ${crate(380, 174, 88, 40, '#5a4426', '#886946', 'CB-3310', '#e8c88a')} + + ${crate(380, 134, 88, 40, '#6b4d2c', '#96754a', 'CB-4472', '#e8c88a')} + + ${crate(472, 174, 88, 40, '#453120', '#6f5535', 'ORE-C', '#d9b070')} + + + + + + + + + + + + + + + ${crate(-44, 6, 88, 40, '#6b4d2c', '#96754a', 'CB-4472', '#e8c88a')} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ${vapour(250, 250, 6, 12.4, .8)} + + + + + + GANTRY 4 // AUTO CYCLE + LOAD 2.4 T + `; + + // --------------------------------------------------- CAM 02 :: REACTOR + const sceneReactor = ` + + + + + + + + + ${rods} + + + + + + + + + + + + + + + + + + + + + + + + + + + + ${gauge(424, 74, 26, 'TEMP', '#f59e0b', 0)} + ${gauge(490, 74, 26, 'PRESS', '#38bdf8', 1.4)} + ${gauge(556, 74, 26, 'FLUX', '#22c55e', 2.8)} + + + + ${trefoil} + + + + ${vapour(392, 206, 5, 31.7, .85)} + + + + + + + + + + FUSION PLANT 1 + OUTPUT 68% + `; + + // --------------------------------------------------- CAM 03 :: AIRLOCK + const sceneAirlock = ` + + + + + + + ${suits} + + + + + + + + ${dogs} + + + + + + + + + + + + + + + + + + + + + + + + + CYCLE STATE + ${lamp(20, 'VACUUM', '#38bdf8', 0)} + ${lamp(44, 'EQUALIZE', '#f59e0b', 1)} + ${lamp(68, 'PRESSURE', '#22c55e', 2)} + + + ${gauge(556, 172, 28, 'CHAMBER kPa', '#38bdf8', 1.1)} + ${vapour(300, 212, 5, 57.3, .7)} + + + + + + AIRLOCK C + OUTER DOOR SEALED + `; + + // -------------------------------------------------- CAM 04 :: CORRIDOR + const sceneCorridor = ` + + + + ${corrFloor} + + + + + + + + + + + ${corrFrames} + ${corrLamps} + + + + + + + ${fanBlades} + + + + + + + + + + + + + + + + + + + + + + + ${drip(196, 96, 1.4)} + ${drip(438, 82, 3.1)} + ${drip(268, 66, 5.2)} + ${vapour(520, 236, 5, 78.1, .75)} + + + FRAME 22 TO 34 + EXTRACTOR 3 ONLINE + `; + + // ------------------------------------------------- console side rails + let rails = ''; + for (let i = 0; i < 8; i++) { + const y = 252 + i * 54; + const fill = 22 + ((i * 41) % 62); + rails += ` + + + `; + } + + const defs = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; + + return svgFrame(` + + + + ${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)} + + + + + CSV KESTREL - HEAVY HAULER + REG CB-4471 // DECK 4 SURVEILLANCE RING + + + SHIP TIME + 04:17:33 + RECORDER + ARMED + + + + 4 OF 4 FEEDS LOCKED + BANDWIDTH 62% // ARCHIVE 71% FULL + + + + + ${rails} + + + + + + BUS A + BUS B + + + + + + MANIFEST 4471-C // ORE CONCENTRATE + SEALED HAZ-3 + + HULL 12% + BAY 101.3 KPA + RCTR 68% + O2 20.9% + GRAV 0.94 G + + NOMINAL + + `, defs); + } + + function renderObservationBioships() { + return svgFrame(` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BIOLOGICAL SYSTEMS // PASSIVE NEURAL OBSERVATION + + `); + } + + function renderObservationRetro() { + const stars = makeStars(95, 1600, 900, '#86efac', 720); + return svgFrame(` + + ${stars} + + + + + + + + + + + + + + + + + + + + + + + + + + ASTROGATION ARRAY + VECTOR MEMORY: LOCKED + SCAN 360° + ANALOG BEAM: ACTIVE + + `); + } + + function renderObservationMilitary() { + const stars = makeStars(110, 1650, 900, '#fef3c7', 960); + return svgFrame(` + + ${stars} + + + + + + + + + + + + + + + + + + + + + + + + + + + CONTACT 03 // TRACKING + TACTICAL WATCH // PASSIVE + WEAPONS SAFE // SENSORS ACTIVE + + + + + + + + `); + } + + 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(` + + ${starsA} + ${starsB} + + + + + + LONG RANGE OPTICAL // DEEP FIELD OBSERVATION + + `); + } + + function renderObservationOutlaw() { + const stars = makeStars(115, 1700, 900, '#fbcfe8', 1710); + return svgFrame(` + + ${stars} + + + + + + + + + REAR FEED // 02 + + + + + + ROUTE: MANUAL + TRANSPONDER: INTERMITTENT + SIGNAL QUALITY: 62% + + + `); + } + + 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 = '') => ` + + + + ${title} + ${line1} + ${line2 ? `${line2}` : ''} + `; + + return svgFrame(` + + + + + + + + + + + + + + + + + + + ${starsNear} + ${starsFar} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ${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')} + + + + + + + + + STATION OBSERVATION // ROTATIONAL TRAFFIC WINDOW + + `); + } + + function renderObservationComedy() { + const stars = makeStars(120, 1700, 900, '#cffafe', 2260); + return svgFrame(` + + ${stars} + + + + + + + + + SCENIC NAVIGATION + ROUTE CONFIDENCE: 99.7% PROBABLY FINE + TEA SOURCE DETECTED + NO IMMEDIATE CAUSE FOR PANIC + + + + + + + `); + } + + function getObservationSvg(universeId) { + switch (universeId) { + case 'starfleet': return renderObservationStarfleet(); + case 'whoniverse': return renderObservationWhoniverse(); + 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 renderObservationStarfleet(); + } + } + + 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
PROCEDURAL AUDIO LINK: ${isPlaying ? 'ONLINE' : 'STANDBY'}
VISUAL ACTIVITY: ${Math.round(observationActivity * 100)}%`; + + observationOverlay.style.setProperty('--obs-activity', String(observationActivity)); + observationStage.innerHTML = getObservationSvg(activeUniverseId); + initializeObservationSplineMorphs(); + startObservationScene(); + initializeObservationLiveInstrumentation(); + + if (observationEngine) { + observationEngine.start(activeUniverseId); + } + } + + function enterObservation() { + if (observationActive) return; + observationActive = true; + observationOverlay.classList.add('active'); + observationOverlay.setAttribute('aria-hidden', 'false'); + refreshObservation(); + resetObservationHudFade(); + resetObservationReturnFade(); + scheduleObservationAmbientActivity(); + + // Give the display an immediate but restrained sign of life. + if (observationActivity > .08) { + window.setTimeout(() => triggerObservationActivity('ambient'), 700); + } + } + + function exitObservation() { + if (!observationActive) return; + observationActive = false; + + if (observationEngine) { + observationEngine.stop(); + } + + if (observationAmbientTimer) { + clearTimeout(observationAmbientTimer); + observationAmbientTimer = null; + } + + stopObservationLiveInstrumentation(); + stopObservationSplineMorphs(); + stopObservationScene(); + + if (observationChrome) { + observationChrome.style.transition = 'none'; + observationChrome.classList.remove('observation-hud-fade'); + void observationChrome.offsetWidth; + observationChrome.style.transition = ''; + } + + if (observationReturn) { + observationReturn.style.transition = 'none'; + observationReturn.classList.remove('observation-return-fade'); + void observationReturn.offsetWidth; + observationReturn.style.transition = ''; + } + + observationOverlay.classList.remove('active'); + observationOverlay.setAttribute('aria-hidden', 'true'); + observationStage.innerHTML = ''; + observationEventLayer.innerHTML = ''; + } + + window.exitObservation = exitObservation; + window.refreshObservation = refreshObservation; + window.enterObservation = enterObservation; + window.selectPreset = selectPreset; + window.setUniverse = setUniverse; + + btnObservation.addEventListener('click', (e) => { + e.stopPropagation(); + enterObservation(); + }); + + // Click background to exit, but ignore clicks on the control dock or its buttons + observationOverlay.addEventListener('click', (e) => { + if (e.target.closest('#observation-control-dock')) return; + e.preventDefault(); + e.stopPropagation(); + exitObservation(); + }); + + const observationReturnPill = document.getElementById('observation-return-pill'); + if (observationReturnPill) { + observationReturnPill.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + exitObservation(); + }); + } + + // 10. Keyboard Shortcuts + window.addEventListener('keydown', (e) => { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return; + + if (e.code === 'Space') { + e.preventDefault(); + togglePlay(); + } else if (e.code === 'KeyM') { + toggleMute(); + } else if (e.code === 'KeyW') { + if (observationActive && observationEngine) { + observationEngine.toggleWarpFlight(); + return; + } + const primaryActionBtn = universeEventsContainer.children[0]; + if (primaryActionBtn) primaryActionBtn.click(); + } else if (e.code === 'KeyF') { + if (observationActive && observationEngine) { + observationEngine.toggleViewportFrame(); + return; + } + } else if (e.code === 'KeyP') { + if (observationActive && observationEngine) { + observationEngine.togglePillars(); + return; + } + } else if (e.code === 'KeyR') { + if (observationActive && observationEngine) { + observationEngine.toggleAlert(); + return; + } + const firstAlertBtn = universeEventsContainer.children[1] || universeEventsContainer.children[0]; + if (firstAlertBtn) firstAlertBtn.click(); + } else if (e.code === 'ArrowUp' || e.code === 'Equal' || e.code === 'NumpadAdd') { + e.preventDefault(); + setMasterVolume(audioManager.getMasterVolume() + 0.05); + } else if (e.code === 'ArrowDown' || e.code === 'Minus' || e.code === 'NumpadSubtract') { + e.preventDefault(); + setMasterVolume(audioManager.getMasterVolume() - 0.05); + } else if (e.code >= 'Digit1' && e.code <= 'Digit9') { + const num = parseInt(e.code.replace('Digit', '')); + setMasterVolume(num * 0.1); + } else if (e.code === 'Digit0') { + setMasterVolume(1.0); + } else if (e.code === 'Escape') { + if (observationActive) { + exitObservation(); + return; + } + alerts.stopAlert(); + whoniverseAudio.stopCloisterBell(); + resetAlertButtons(); + } + }); + + // 11. Initial Setup + initUniverseDropdown(); + setUniverse('starfleet'); + updateMasterVolumeUI(0.75, false); + visualizer.init('spectrum-canvas', 'warp-core-canvas'); +}); + diff --git a/js/audio.js b/js/audio.js new file mode 100644 index 0000000..1f9ee03 --- /dev/null +++ b/js/audio.js @@ -0,0 +1,2258 @@ +class AudioManager { + constructor() { + this.ctx = null; + this.isInitialized = false; + this.isPlaying = false; + this.masterGain = null; + this.compressor = null; + this.analyser = null; + + this.currentVolume = 0.75; + this.isMuted = false; + + // Sleep Timer + this.timerId = null; + this.timerRemainingSeconds = 0; + this.onTimerTick = null; + this.onTimerComplete = null; + } + + init() { + if (this.isInitialized) return; + + const AudioContextClass = window.AudioContext || window.webkitAudioContext; + this.ctx = new AudioContextClass(); + + // Master Dynamics Compressor / Limiter for studio-quality mastering & anti-clipping + this.compressor = this.ctx.createDynamicsCompressor(); + this.compressor.threshold.setValueAtTime(-12, this.ctx.currentTime); + this.compressor.knee.setValueAtTime(8, this.ctx.currentTime); + this.compressor.ratio.setValueAtTime(4, this.ctx.currentTime); + this.compressor.attack.setValueAtTime(0.003, this.ctx.currentTime); + this.compressor.release.setValueAtTime(0.25, this.ctx.currentTime); + + // Master Gain + this.masterGain = this.ctx.createGain(); + this.masterGain.gain.setValueAtTime(this.isMuted ? 0 : this.currentVolume, this.ctx.currentTime); + + // Master Analyser Node for Visualizers + this.analyser = this.ctx.createAnalyser(); + this.analyser.fftSize = 512; + this.analyser.smoothingTimeConstant = 0.82; + + // Route: Nodes -> Compressor -> MasterGain -> Analyser -> Destination + this.compressor.connect(this.masterGain); + this.masterGain.connect(this.analyser); + this.analyser.connect(this.ctx.destination); + + this.isInitialized = true; + } + + async resume() { + if (!this.isInitialized) this.init(); + if (this.ctx.state === 'suspended') { + await this.ctx.resume(); + } + } + + setMasterVolume(val, smoothTime = 0.05) { + const clamped = Math.max(0, Math.min(1, val)); + this.currentVolume = clamped; + if (this.masterGain && this.ctx) { + const target = this.isMuted ? 0 : clamped; + const now = this.ctx.currentTime; + this.masterGain.gain.cancelScheduledValues(now); + this.masterGain.gain.linearRampToValueAtTime(target, now + smoothTime); + } + } + + getMasterVolume() { + return this.currentVolume; + } + + toggleMute() { + return this.setMute(!this.isMuted); + } + + setMute(muted) { + this.isMuted = !!muted; + if (this.masterGain && this.ctx) { + const target = this.isMuted ? 0 : this.currentVolume; + const now = this.ctx.currentTime; + this.masterGain.gain.cancelScheduledValues(now); + this.masterGain.gain.linearRampToValueAtTime(target, now + 0.05); + } + return this.isMuted; + } + + // Noise Buffer Helper (White, Pink, Brown) + createNoiseBuffer(type = 'pink', durationSeconds = 5) { + if (!this.ctx) this.init(); + const sampleRate = this.ctx.sampleRate; + const bufferSize = sampleRate * durationSeconds; + const buffer = this.ctx.createBuffer(2, bufferSize, sampleRate); + const left = buffer.getChannelData(0); + const right = buffer.getChannelData(1); + + if (type === 'white') { + for (let i = 0; i < bufferSize; i++) { + left[i] = Math.random() * 2 - 1; + right[i] = Math.random() * 2 - 1; + } + } else if (type === 'pink') { + let b0L = 0, b1L = 0, b2L = 0, b3L = 0, b4L = 0, b5L = 0, b6L = 0; + let b0R = 0, b1R = 0, b2R = 0, b3R = 0, b4R = 0, b5R = 0, b6R = 0; + for (let i = 0; i < bufferSize; i++) { + const whiteL = Math.random() * 2 - 1; + b0L = 0.99886 * b0L + whiteL * 0.0555179; + b1L = 0.99332 * b1L + whiteL * 0.0750759; + b2L = 0.96900 * b2L + whiteL * 0.1538520; + b3L = 0.86650 * b3L + whiteL * 0.3104856; + b4L = 0.55000 * b4L + whiteL * 0.5329522; + b5L = -0.7616 * b5L - whiteL * 0.0168980; + left[i] = (b0L + b1L + b2L + b3L + b4L + b5L + b6L + whiteL * 0.5362) * 0.11; + b6L = whiteL * 0.115926; + + const whiteR = Math.random() * 2 - 1; + b0R = 0.99886 * b0R + whiteR * 0.0555179; + b1R = 0.99332 * b1R + whiteR * 0.0750759; + b2R = 0.96900 * b2R + whiteR * 0.1538520; + b3R = 0.86650 * b3R + whiteR * 0.3104856; + b4R = 0.55000 * b4R + whiteR * 0.5329522; + b5R = -0.7616 * b5R - whiteR * 0.0168980; + right[i] = (b0R + b1R + b2R + b3R + b4R + b5R + b6R + whiteR * 0.5362) * 0.11; + b6R = whiteR * 0.115926; + } + } else if (type === 'brown') { + let lastOutL = 0.0; + let lastOutR = 0.0; + for (let i = 0; i < bufferSize; i++) { + const whiteL = Math.random() * 2 - 1; + lastOutL = (lastOutL + 0.02 * whiteL) / 1.02; + left[i] = lastOutL * 3.5; + + const whiteR = Math.random() * 2 - 1; + lastOutR = (lastOutR + 0.02 * whiteR) / 1.02; + right[i] = lastOutR * 3.5; + } + } + + return buffer; + } + + // Sleep Timer System + startSleepTimer(minutes, onTick, onComplete) { + this.stopSleepTimer(); + this.timerRemainingSeconds = Math.round(minutes * 60); + this.onTimerTick = onTick; + this.onTimerComplete = onComplete; + + if (this.onTimerTick) this.onTimerTick(this.timerRemainingSeconds); + + this.timerId = setInterval(() => { + this.timerRemainingSeconds--; + if (this.onTimerTick) this.onTimerTick(this.timerRemainingSeconds); + + // Begin exponential smooth fadeout during final 30 seconds + if (this.timerRemainingSeconds <= 30 && this.timerRemainingSeconds > 0) { + const factor = this.timerRemainingSeconds / 30; + if (this.masterGain && this.ctx) { + const targetVol = this.getMasterVolume() * factor; + this.masterGain.gain.setValueAtTime(Math.max(0, targetVol), this.ctx.currentTime); + } + } + + if (this.timerRemainingSeconds <= 0) { + this.stopSleepTimer(); + if (this.onTimerComplete) this.onTimerComplete(); + } + }, 1000); + } + + stopSleepTimer() { + if (this.timerId) { + clearInterval(this.timerId); + this.timerId = null; + this.timerRemainingSeconds = 0; + } + } +} + +window.AudioManager = AudioManager; + + +/** + * Hull Drone & Environmental Sub-Bass Synthesizer + * Generates organic, continuous low-frequency starship structural vibration & room tone. + */ + +class HullDroneSynth { + constructor(audioManager) { + this.am = audioManager; + this.nodes = []; + this.gainNode = null; + this.filterNode = null; + this.subOsc1 = null; + this.subOsc2 = null; + this.noiseSource = null; + this.isMuted = false; + + // Default configuration parameters + this.params = { + volume: 0.7, + baseFreq: 50, // Fundamental frequency (e.g. 50Hz for TNG bridge) + filterCutoff: 110, // Lowpass filter cutoff + resonance: 2.5, // Filter Q / resonance peak + noiseMix: 0.45, // Brown noise texture mix + harmonicSpread: 1.02 // Slight frequency detune between sub-oscillators for phasing + }; + } + + start() { + this.stop(); + const ctx = this.am.ctx; + if (!ctx) return; + + // Channel Gain Node + this.gainNode = ctx.createGain(); + this.gainNode.gain.setValueAtTime(this.isMuted ? 0 : this.params.volume, ctx.currentTime); + + // Steep Lowpass Filter (24dB/oct via 2 cascading biquads) + this.filterNode = ctx.createBiquadFilter(); + this.filterNode.type = 'lowpass'; + this.filterNode.frequency.setValueAtTime(this.params.filterCutoff, ctx.currentTime); + this.filterNode.Q.setValueAtTime(this.params.resonance, ctx.currentTime); + + const filterStage2 = ctx.createBiquadFilter(); + filterStage2.type = 'lowpass'; + filterStage2.frequency.setValueAtTime(this.params.filterCutoff * 1.5, ctx.currentTime); + filterStage2.Q.setValueAtTime(1.0, ctx.currentTime); + + // Sub-bass Oscillator 1 (Sine) + this.subOsc1 = ctx.createOscillator(); + this.subOsc1.type = 'sine'; + this.subOsc1.frequency.setValueAtTime(this.params.baseFreq, ctx.currentTime); + + const osc1Gain = ctx.createGain(); + osc1Gain.gain.setValueAtTime(0.5, ctx.currentTime); + this.subOsc1.connect(osc1Gain); + osc1Gain.connect(this.filterNode); + + // Sub-bass Oscillator 2 (Triangle/Sine detuned for slow, natural phase beating) + this.subOsc2 = ctx.createOscillator(); + this.subOsc2.type = 'triangle'; + this.subOsc2.frequency.setValueAtTime(this.params.baseFreq * this.params.harmonicSpread, ctx.currentTime); + + const osc2Gain = ctx.createGain(); + osc2Gain.gain.setValueAtTime(0.3, ctx.currentTime); + this.subOsc2.connect(osc2Gain); + osc2Gain.connect(this.filterNode); + + // Brown Noise Structural Rumble Layer + const brownBuffer = this.am.createNoiseBuffer('brown', 6); + this.noiseSource = ctx.createBufferSource(); + this.noiseSource.buffer = brownBuffer; + this.noiseSource.loop = true; + + const noiseGain = ctx.createGain(); + noiseGain.gain.setValueAtTime(this.params.noiseMix * 0.7, ctx.currentTime); + this.noiseSource.connect(noiseGain); + noiseGain.connect(this.filterNode); + + // Slow LFO for organic drifting movement + const lfo = ctx.createOscillator(); + lfo.type = 'sine'; + lfo.frequency.setValueAtTime(0.1, ctx.currentTime); // 10 second cycle + + const lfoGain = ctx.createGain(); + lfoGain.gain.setValueAtTime(12, ctx.currentTime); // Modulate cutoff by ±12Hz + lfo.connect(lfoGain); + lfoGain.connect(this.filterNode.frequency); + + // Connect Graph + this.filterNode.connect(filterStage2); + filterStage2.connect(this.gainNode); + this.gainNode.connect(this.am.compressor); + + // Start Sources + this.subOsc1.start(); + this.subOsc2.start(); + this.noiseSource.start(); + lfo.start(); + + this.nodes = [this.subOsc1, this.subOsc2, this.noiseSource, lfo, osc1Gain, osc2Gain, noiseGain, lfoGain, this.filterNode, filterStage2, this.gainNode]; + } + + stop() { + if (this.nodes.length > 0) { + try { + if (this.subOsc1) this.subOsc1.stop(); + if (this.subOsc2) this.subOsc2.stop(); + if (this.noiseSource) this.noiseSource.stop(); + } catch (e) { + // Ignore if already stopped + } + this.nodes.forEach(node => { + try { node.disconnect(); } catch (e) {} + }); + this.nodes = []; + } + } + + setVolume(val) { + this.params.volume = Math.max(0, Math.min(1, val)); + if (this.gainNode && this.am.ctx && !this.isMuted) { + const now = this.am.ctx.currentTime; + this.gainNode.gain.cancelScheduledValues(now); + this.gainNode.gain.linearRampToValueAtTime(this.params.volume, now + 0.05); + } + } + + setBaseFreq(freq) { + this.params.baseFreq = freq; + if (this.subOsc1 && this.subOsc2 && this.am.ctx) { + const now = this.am.ctx.currentTime; + this.subOsc1.frequency.linearRampToValueAtTime(freq, now + 0.1); + this.subOsc2.frequency.linearRampToValueAtTime(freq * this.params.harmonicSpread, now + 0.1); + } + } + + setFilterCutoff(cutoff) { + this.params.filterCutoff = cutoff; + if (this.filterNode && this.am.ctx) { + const now = this.am.ctx.currentTime; + this.filterNode.frequency.linearRampToValueAtTime(cutoff, now + 0.1); + } + } + + applyPreset(config) { + if (config.volume !== undefined) this.params.volume = config.volume; + if (config.baseFreq !== undefined) this.setBaseFreq(config.baseFreq); + if (config.filterCutoff !== undefined) this.setFilterCutoff(config.filterCutoff); + if (config.resonance !== undefined) this.params.resonance = config.resonance; + if (config.noiseMix !== undefined) this.params.noiseMix = config.noiseMix; + if (config.harmonicSpread !== undefined) this.params.harmonicSpread = config.harmonicSpread; + this.setVolume(this.params.volume); + } +} + +window.HullDroneSynth = HullDroneSynth; + + +/** + * Warp Core & Reactor Pulse Synthesizer + * Generates the iconic pulsating magnetic intermix thrum of Star Trek warp cores. + */ + +class WarpCoreSynth { + constructor(audioManager) { + this.am = audioManager; + this.nodes = []; + this.gainNode = null; + this.isMuted = false; + + // Pulse parameters + this.params = { + volume: 0.8, + bpm: 48, // Pulse rate (TNG is ~46-52 BPM, Voyager is ~68-75 BPM) + carrierFreq: 58, // Fundamental carrier pitch (Hz) + modFreqRatio: 2.0, // FM modulation frequency multiplier + modIndex: 40, // FM modulation depth + filterCutoff: 180, // Lowpass filter cutoff + pulseShape: 'tng', // 'tng', 'voyager', 'tos', 'defiant', 'nx' + resonance: 3.0, + swirlMix: 0.35 // Stereo phase swirl + }; + + this.pulseInterval = null; + this.pulsePhase = 0; + this.onPulse = null; // Callback for UI visualizer pulse animation! + } + + start() { + this.stop(); + const ctx = this.am.ctx; + if (!ctx) return; + + this.gainNode = ctx.createGain(); + this.gainNode.gain.setValueAtTime(this.isMuted ? 0 : this.params.volume, ctx.currentTime); + + // Filter Node + this.filterNode = ctx.createBiquadFilter(); + this.filterNode.type = 'lowpass'; + this.filterNode.frequency.setValueAtTime(this.params.filterCutoff, ctx.currentTime); + this.filterNode.Q.setValueAtTime(this.params.resonance, ctx.currentTime); + + // Stereo Panner for magnetic swirl + this.panner = ctx.createStereoPanner ? ctx.createStereoPanner() : null; + + // Carrier & Modulator Oscillators (FM Engine) + this.carrier = ctx.createOscillator(); + this.carrier.type = this.params.pulseShape === 'tos' ? 'sawtooth' : (this.params.pulseShape === 'nx' ? 'triangle' : 'sine'); + this.carrier.frequency.setValueAtTime(this.params.carrierFreq, ctx.currentTime); + + // Sub-harmonic oscillator for massive bottom end + this.subOsc = ctx.createOscillator(); + this.subOsc.type = 'sine'; + this.subOsc.frequency.setValueAtTime(this.params.carrierFreq * 0.5, ctx.currentTime); + + const subGain = ctx.createGain(); + subGain.gain.setValueAtTime(0.6, ctx.currentTime); + this.subOsc.connect(subGain); + subGain.connect(this.filterNode); + + // Pulse Envelope Modulator Gain Node + this.pulseGain = ctx.createGain(); + this.pulseGain.gain.setValueAtTime(0.2, ctx.currentTime); + + // Connect Carrier -> PulseGain -> Filter -> Panner -> ChannelGain -> Master + this.carrier.connect(this.pulseGain); + this.pulseGain.connect(this.filterNode); + + if (this.panner) { + this.filterNode.connect(this.panner); + this.panner.connect(this.gainNode); + } else { + this.filterNode.connect(this.gainNode); + } + + this.gainNode.connect(this.am.compressor); + + this.carrier.start(); + this.subOsc.start(); + + this.nodes = [this.carrier, this.subOsc, subGain, this.pulseGain, this.filterNode, this.gainNode]; + if (this.panner) this.nodes.push(this.panner); + + // Start precision pulse scheduler + this.startPulseLoop(); + } + + startPulseLoop() { + if (this.pulseInterval) clearInterval(this.pulseInterval); + + const intervalMs = (60 / this.params.bpm) * 1000; + this.scheduleNextPulse(); + + this.pulseInterval = setInterval(() => { + this.scheduleNextPulse(); + }, intervalMs); + } + + scheduleNextPulse() { + const ctx = this.am.ctx; + if (!ctx || !this.pulseGain || !this.filterNode) return; + + const now = ctx.currentTime; + const pulseDuration = (60 / this.params.bpm); + + this.pulsePhase = (this.pulsePhase + 1) % 4; + + // Trigger visualizer callback + if (this.onPulse) { + this.onPulse(this.pulsePhase, pulseDuration); + } + + // Dynamic envelope shaping based on ship era + if (this.params.pulseShape === 'tng') { + // Iconic 4-stage Galaxy-class magnetic warp pulse + // Soft attack, deep swelling peak, secondary reverberant harmonic bloom, smooth decay + const peakTime = now + pulseDuration * 0.28; + const secondPeak = now + pulseDuration * 0.58; + + this.pulseGain.gain.cancelScheduledValues(now); + this.pulseGain.gain.setValueAtTime(0.18, now); + this.pulseGain.gain.linearRampToValueAtTime(0.95, peakTime); + this.pulseGain.gain.exponentialRampToValueAtTime(0.45, now + pulseDuration * 0.42); + this.pulseGain.gain.linearRampToValueAtTime(0.65, secondPeak); + this.pulseGain.gain.exponentialRampToValueAtTime(0.18, now + pulseDuration * 0.95); + + // Modulate filter cutoff in sync with the pulse + this.filterNode.frequency.cancelScheduledValues(now); + this.filterNode.frequency.setValueAtTime(this.params.filterCutoff * 0.7, now); + this.filterNode.frequency.exponentialRampToValueAtTime(this.params.filterCutoff * 1.6, peakTime); + this.filterNode.frequency.exponentialRampToValueAtTime(this.params.filterCutoff * 0.7, now + pulseDuration * 0.95); + + } else if (this.params.pulseShape === 'voyager') { + // Faster, sharper, higher-resonance Class 9 warp core + const peakTime = now + pulseDuration * 0.2; + this.pulseGain.gain.cancelScheduledValues(now); + this.pulseGain.gain.setValueAtTime(0.25, now); + this.pulseGain.gain.linearRampToValueAtTime(1.0, peakTime); + this.pulseGain.gain.exponentialRampToValueAtTime(0.25, now + pulseDuration * 0.85); + + this.filterNode.frequency.cancelScheduledValues(now); + this.filterNode.frequency.linearRampToValueAtTime(this.params.filterCutoff * 1.8, peakTime); + this.filterNode.frequency.linearRampToValueAtTime(this.params.filterCutoff * 0.8, now + pulseDuration * 0.85); + + } else if (this.params.pulseShape === 'tos') { + // TOS Electromechanical oscillating engine thrum + const halfTime = now + pulseDuration * 0.5; + this.pulseGain.gain.cancelScheduledValues(now); + this.pulseGain.gain.setValueAtTime(0.4, now); + this.pulseGain.gain.linearRampToValueAtTime(0.9, halfTime); + this.pulseGain.gain.linearRampToValueAtTime(0.4, now + pulseDuration); + + } else if (this.params.pulseShape === 'defiant') { + // Defiant: tight, aggressive pulse with rapid decay + const peakTime = now + pulseDuration * 0.15; + this.pulseGain.gain.cancelScheduledValues(now); + this.pulseGain.gain.setValueAtTime(0.3, now); + this.pulseGain.gain.linearRampToValueAtTime(1.0, peakTime); + this.pulseGain.gain.exponentialRampToValueAtTime(0.3, now + pulseDuration * 0.75); + + } else { + // NX / Industrial reactor chug + const peakTime = now + pulseDuration * 0.35; + this.pulseGain.gain.cancelScheduledValues(now); + this.pulseGain.gain.setValueAtTime(0.2, now); + this.pulseGain.gain.linearRampToValueAtTime(0.85, peakTime); + this.pulseGain.gain.linearRampToValueAtTime(0.2, now + pulseDuration); + } + + // Subtle stereo panning drift + if (this.panner && this.params.swirlMix > 0) { + const panTarget = Math.sin(this.pulsePhase * Math.PI * 0.5) * this.params.swirlMix; + this.panner.pan.linearRampToValueAtTime(panTarget, now + pulseDuration * 0.5); + } + } + + setBpm(bpm) { + this.params.bpm = Math.max(20, Math.min(160, bpm)); + if (this.nodes.length > 0) { + this.startPulseLoop(); + } + } + + setVolume(val) { + this.params.volume = Math.max(0, Math.min(1, val)); + if (this.gainNode && this.am.ctx && !this.isMuted) { + const now = this.am.ctx.currentTime; + this.gainNode.gain.cancelScheduledValues(now); + this.gainNode.gain.linearRampToValueAtTime(this.params.volume, now + 0.05); + } + } + + setCarrierFreq(freq) { + this.params.carrierFreq = freq; + if (this.carrier && this.subOsc && this.am.ctx) { + const now = this.am.ctx.currentTime; + this.carrier.frequency.linearRampToValueAtTime(freq, now + 0.1); + this.subOsc.frequency.linearRampToValueAtTime(freq * 0.5, now + 0.1); + } + } + + stop() { + if (this.pulseInterval) { + clearInterval(this.pulseInterval); + this.pulseInterval = null; + } + if (this.nodes.length > 0) { + try { + if (this.carrier) this.carrier.stop(); + if (this.subOsc) this.subOsc.stop(); + } catch (e) {} + this.nodes.forEach(node => { + try { node.disconnect(); } catch (e) {} + }); + this.nodes = []; + } + } + + applyPreset(config) { + if (config.volume !== undefined) this.params.volume = config.volume; + if (config.bpm !== undefined) this.setBpm(config.bpm); + if (config.carrierFreq !== undefined) this.setCarrierFreq(config.carrierFreq); + if (config.filterCutoff !== undefined) this.params.filterCutoff = config.filterCutoff; + if (config.pulseShape !== undefined) this.params.pulseShape = config.pulseShape; + if (config.resonance !== undefined) this.params.resonance = config.resonance; + if (config.swirlMix !== undefined) this.params.swirlMix = config.swirlMix; + this.setVolume(this.params.volume); + } +} + +window.WarpCoreSynth = WarpCoreSynth; + + +/** + * Environmental Life Support & Airflow Synthesizer + * Generates continuous ventilation airflow, atmospheric hiss, and room acoustic damping. + */ + +class LifeSupportSynth { + constructor(audioManager) { + this.am = audioManager; + this.nodes = []; + this.gainNode = null; + this.isMuted = false; + + this.params = { + volume: 0.5, + noiseType: 'pink', // 'pink' (warm TNG), 'white' (crisp Voyager), 'brown' (heavy NX-01) + highpassFreq: 180, // Cuts extreme sub rumble to isolate air movement + lowpassFreq: 1800, // Gentle top-end rolloff + airflowModSpeed: 0.15, // Subtle breathing movement of the environmental airflow + airflowModDepth: 0.12 // Depth of airflow intensity modulation + }; + } + + start() { + this.stop(); + const ctx = this.am.ctx; + if (!ctx) return; + + this.gainNode = ctx.createGain(); + this.gainNode.gain.setValueAtTime(this.isMuted ? 0 : this.params.volume, ctx.currentTime); + + // Highpass filter (cuts muddy lows) + const hpFilter = ctx.createBiquadFilter(); + hpFilter.type = 'highpass'; + hpFilter.frequency.setValueAtTime(this.params.highpassFreq, ctx.currentTime); + + // Lowpass filter (shapes the crispness vs warmth of the air) + this.lpFilter = ctx.createBiquadFilter(); + this.lpFilter.type = 'lowpass'; + this.lpFilter.frequency.setValueAtTime(this.params.lowpassFreq, ctx.currentTime); + this.lpFilter.Q.setValueAtTime(0.7, ctx.currentTime); + + // Noise buffer source + const noiseBuffer = this.am.createNoiseBuffer(this.params.noiseType, 6); + this.noiseSource = ctx.createBufferSource(); + this.noiseSource.buffer = noiseBuffer; + this.noiseSource.loop = true; + + // Slow airflow modulation LFO for organic breath + const airflowLfo = ctx.createOscillator(); + airflowLfo.type = 'sine'; + airflowLfo.frequency.setValueAtTime(this.params.airflowModSpeed, ctx.currentTime); + + const lfoGain = ctx.createGain(); + lfoGain.gain.setValueAtTime(this.params.airflowModDepth, ctx.currentTime); + + const modGain = ctx.createGain(); + modGain.gain.setValueAtTime(0.8, ctx.currentTime); + + airflowLfo.connect(lfoGain); + lfoGain.connect(modGain.gain); + + // Stereo widener using delay + const splitter = ctx.createChannelSplitter(2); + const merger = ctx.createChannelMerger(2); + const delayRight = ctx.createDelay(); + delayRight.delayTime.setValueAtTime(0.018, ctx.currentTime); // 18ms Haas effect widening + + // Graph: Noise -> ModGain -> HP -> LP -> Splitter -> (Left direct, Right delay) -> Merger -> Gain -> Compressor + this.noiseSource.connect(modGain); + modGain.connect(hpFilter); + hpFilter.connect(this.lpFilter); + this.lpFilter.connect(splitter); + + splitter.connect(merger, 0, 0); // Left channel + splitter.connect(delayRight, 1); + delayRight.connect(merger, 0, 1); // Right delayed channel + + merger.connect(this.gainNode); + this.gainNode.connect(this.am.compressor); + + this.noiseSource.start(); + airflowLfo.start(); + + this.nodes = [ + this.noiseSource, airflowLfo, lfoGain, modGain, + hpFilter, this.lpFilter, splitter, delayRight, merger, this.gainNode + ]; + } + + stop() { + if (this.nodes.length > 0) { + try { + if (this.noiseSource) this.noiseSource.stop(); + } catch (e) {} + this.nodes.forEach(node => { + try { node.disconnect(); } catch (e) {} + }); + this.nodes = []; + } + } + + setVolume(val) { + this.params.volume = Math.max(0, Math.min(1, val)); + if (this.gainNode && this.am.ctx && !this.isMuted) { + const now = this.am.ctx.currentTime; + this.gainNode.gain.cancelScheduledValues(now); + this.gainNode.gain.linearRampToValueAtTime(this.params.volume, now + 0.05); + } + } + + setFilterCutoff(freq) { + this.params.lowpassFreq = freq; + if (this.lpFilter && this.am.ctx) { + const now = this.am.ctx.currentTime; + this.lpFilter.frequency.linearRampToValueAtTime(freq, now + 0.1); + } + } + + applyPreset(config) { + if (config.volume !== undefined) this.params.volume = config.volume; + if (config.noiseType !== undefined) this.params.noiseType = config.noiseType; + if (config.highpassFreq !== undefined) this.params.highpassFreq = config.highpassFreq; + if (config.lowpassFreq !== undefined) this.setFilterCutoff(config.lowpassFreq); + if (config.airflowModSpeed !== undefined) this.params.airflowModSpeed = config.airflowModSpeed; + if (config.airflowModDepth !== undefined) this.params.airflowModDepth = config.airflowModDepth; + this.setVolume(this.params.volume); + } +} + +window.LifeSupportSynth = LifeSupportSynth; + + +/** + * Procedural Starship Telemetry, LCARS Chirps, Beeps & Console Synthesizer + * 100% synthesized programmatically via Web Audio API oscillators, FM synthesis, and envelopes. + * Zero stored audio samples. + */ + +class TelemetrySynth { + constructor(audioManager) { + this.am = audioManager; + this.gainNode = null; + this.isMuted = false; + this.schedulerTimer = null; + + this.params = { + volume: 0.4, + density: 0.5, // How often background telemetry chirps occur (0 = off, 1 = busy bridge) + era: 'tng', // 'tng', 'voyager', 'tos', 'ds9', 'nx' + reverbMix: 0.25 + }; + + // Musical pitch frequencies for authentic LCARS musical intervals (major/minor pentatonic & perfect 4ths/5ths) + this.lcarsPitches = [ + 880, 987.77, 1046.50, 1174.66, 1318.51, 1396.91, 1567.98, 1760, 1975.53, 2093.00, 2349.32, 2637.02 + ]; + + // TOS Bridge oscillator warble frequencies + this.tosFrequencies = [ + 440, 554.37, 659.25, 830.61, 880, 1108.73, 1318.51, 1661.22, 2217.46 + ]; + } + + start() { + this.stop(); + const ctx = this.am.ctx; + if (!ctx) return; + + this.gainNode = ctx.createGain(); + this.gainNode.gain.setValueAtTime(this.isMuted ? 0 : this.params.volume, ctx.currentTime); + this.gainNode.connect(this.am.compressor); + + this.startAutoTelemetryScheduler(); + } + + stop() { + if (this.schedulerTimer) { + clearTimeout(this.schedulerTimer); + this.schedulerTimer = null; + } + } + + setVolume(val) { + this.params.volume = Math.max(0, Math.min(1, val)); + if (this.gainNode && this.am.ctx && !this.isMuted) { + const now = this.am.ctx.currentTime; + this.gainNode.gain.cancelScheduledValues(now); + this.gainNode.gain.linearRampToValueAtTime(this.params.volume, now + 0.05); + } + } + + setDensity(val) { + this.params.density = Math.max(0, Math.min(1, val)); + } + + startAutoTelemetryScheduler() { + if (this.schedulerTimer) clearTimeout(this.schedulerTimer); + if (this.params.density <= 0.01) return; + + // Calculate delay inversely proportional to density (2s to 12s) + const baseDelay = 12000 * (1.05 - this.params.density); + const jitter = Math.random() * 4000; + const nextInterval = Math.max(800, baseDelay + jitter); + + this.schedulerTimer = setTimeout(() => { + this.playRandomTelemetrySound(); + this.startAutoTelemetryScheduler(); + }, nextInterval); + } + + playRandomTelemetrySound() { + if (this.isMuted || this.params.volume <= 0.01 || !this.am.ctx) return; + + switch (this.params.era) { + case 'tos': + Math.random() > 0.4 ? this.synthesizeTOSWarble() : this.synthesizeTOSRelayClick(); + break; + case 'ds9': + Math.random() > 0.5 ? this.synthesizeCardassianSensor() : this.synthesizeLCARSSingleChirp(); + break; + case 'voyager': + Math.random() > 0.4 ? this.synthesizeLCARSDoubleChirp() : this.synthesizeSensorSweep(); + break; + case 'nx': + Math.random() > 0.5 ? this.synthesizeNXRelay() : this.synthesizeNXIndicatorBeep(); + break; + case 'tng': + default: + const r = Math.random(); + if (r < 0.45) this.synthesizeLCARSSingleChirp(); + else if (r < 0.75) this.synthesizeLCARSDoubleChirp(); + else if (r < 0.90) this.synthesizeLCARSSequence(); + else this.synthesizeSensorSweep(); + break; + } + + // OBSERVATION intentionally treats telemetry as an abstract activity pulse, + // not as a claim that a specific fictional beep means a specific thing. + window.dispatchEvent(new CustomEvent('scifi-telemetry-activity', { + detail: { + era: this.params.era, + density: this.params.density, + firedAt: performance.now() + } + })); + } + + /** + * TNG/Voyager Single LCARS Touch Tone (Soft sine with gentle attack and rapid exponential decay) + */ + synthesizeLCARSSingleChirp(pitch = null) { + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + const freq = pitch || this.lcarsPitches[Math.floor(Math.random() * this.lcarsPitches.length)]; + const duration = 0.09; + + const osc = ctx.createOscillator(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(freq, now); + // Subtle downward micro-pitch glide (12Hz) for that warm capacitive touch feel + osc.frequency.exponentialRampToValueAtTime(freq * 0.98, now + duration); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, now); + env.gain.linearRampToValueAtTime(0.35, now + 0.008); + env.gain.exponentialRampToValueAtTime(0.0001, now + duration); + + // Filter to eliminate any click + const filter = ctx.createBiquadFilter(); + filter.type = 'lowpass'; + filter.frequency.setValueAtTime(3200, now); + + osc.connect(env); + env.connect(filter); + filter.connect(this.gainNode); + + osc.start(now); + osc.stop(now + duration); + } + + /** + * TNG LCARS Double Chirp (Iconic standard confirmation tone) + */ + synthesizeLCARSDoubleChirp() { + const ctx = this.am.ctx; + if (!ctx) return; + const idx = Math.floor(Math.random() * (this.lcarsPitches.length - 2)); + const p1 = this.lcarsPitches[idx]; + const p2 = this.lcarsPitches[idx + 2]; // Minor third or fourth higher + + this.synthesizeLCARSSingleChirp(p1); + setTimeout(() => { + this.synthesizeLCARSSingleChirp(p2); + }, 65); + } + + /** + * TNG LCARS Multi-Tone Data Acknowledgment Sequence + */ + synthesizeLCARSSequence() { + const ctx = this.am.ctx; + if (!ctx) return; + const notes = [ + this.lcarsPitches[Math.floor(Math.random() * 4) + 4], + this.lcarsPitches[Math.floor(Math.random() * 4) + 6], + this.lcarsPitches[Math.floor(Math.random() * 4) + 2] + ]; + + notes.forEach((freq, i) => { + setTimeout(() => { + this.synthesizeLCARSSingleChirp(freq); + }, i * 75); + }); + } + + /** + * High-tech Sensor Sweep Tone (Voyager/TNG Long-Range Sensor telemetry) + */ + synthesizeSensorSweep() { + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + const duration = 0.38; + const startFreq = 1200 + Math.random() * 800; + const endFreq = startFreq * (Math.random() > 0.5 ? 1.6 : 0.65); + + const osc = ctx.createOscillator(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(startFreq, now); + osc.frequency.exponentialRampToValueAtTime(endFreq, now + duration); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, now); + env.gain.linearRampToValueAtTime(0.18, now + 0.05); + env.gain.exponentialRampToValueAtTime(0.0001, now + duration); + + osc.connect(env); + env.connect(this.gainNode); + + osc.start(now); + osc.stop(now + duration); + } + + /** + * TOS Original Series Bridge Electronic Computer Warble + * Two detuned square/triangle oscillators modulated by high-speed vibrato LFO + */ + synthesizeTOSWarble() { + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + const duration = 0.45; + const baseFreq = this.tosFrequencies[Math.floor(Math.random() * this.tosFrequencies.length)]; + + const osc1 = ctx.createOscillator(); + osc1.type = 'triangle'; + osc1.frequency.setValueAtTime(baseFreq, now); + + const osc2 = ctx.createOscillator(); + osc2.type = 'sawtooth'; + osc2.frequency.setValueAtTime(baseFreq * 1.5, now); + + // Fast Vibrato LFO + const lfo = ctx.createOscillator(); + lfo.type = 'sine'; + lfo.frequency.setValueAtTime(14 + Math.random() * 8, now); // 14-22 Hz warble + + const lfoGain = ctx.createGain(); + lfoGain.gain.setValueAtTime(35, now); + lfo.connect(lfoGain); + lfoGain.connect(osc1.frequency); + lfoGain.connect(osc2.frequency); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, now); + env.gain.linearRampToValueAtTime(0.22, now + 0.04); + env.gain.setValueAtTime(0.22, now + duration * 0.7); + env.gain.exponentialRampToValueAtTime(0.0001, now + duration); + + // Bandpass filter to create that vintage analog 1960s telephone/relay resonance + const filter = ctx.createBiquadFilter(); + filter.type = 'bandpass'; + filter.frequency.setValueAtTime(baseFreq * 1.2, now); + filter.Q.setValueAtTime(3.5, now); + + osc1.connect(env); + osc2.connect(env); + env.connect(filter); + filter.connect(this.gainNode); + + osc1.start(now); + osc2.start(now); + lfo.start(now); + + osc1.stop(now + duration); + osc2.stop(now + duration); + lfo.stop(now + duration); + } + + /** + * TOS Mechanical Relay Solenoid Click + */ + synthesizeTOSRelayClick() { + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + const duration = 0.025; + + const osc = ctx.createOscillator(); + osc.type = 'square'; + osc.frequency.setValueAtTime(1400, now); + osc.frequency.exponentialRampToValueAtTime(300, now + duration); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.3, now); + env.gain.exponentialRampToValueAtTime(0.001, now + duration); + + osc.connect(env); + env.connect(this.gainNode); + + osc.start(now); + osc.stop(now + duration); + } + + /** + * DS9 / Cardassian Cavernous Sensor Tone (Resonant metallic ring) + */ + synthesizeCardassianSensor() { + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + const duration = 0.55; + const freq = 420 + Math.random() * 200; + + const osc1 = ctx.createOscillator(); + osc1.type = 'sine'; + osc1.frequency.setValueAtTime(freq, now); + + const osc2 = ctx.createOscillator(); + osc2.type = 'sine'; + osc2.frequency.setValueAtTime(freq * 1.414, now); // Tritone metallic dissonance + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, now); + env.gain.linearRampToValueAtTime(0.2, now + 0.015); + env.gain.exponentialRampToValueAtTime(0.0001, now + duration); + + osc1.connect(env); + osc2.connect(env); + env.connect(this.gainNode); + + osc1.start(now); + osc2.start(now); + osc1.stop(now + duration); + osc2.stop(now + duration); + } + + /** + * NX-01 Industrial Hydraulic Relay Click + */ + synthesizeNXRelay() { + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + const duration = 0.04; + + const osc = ctx.createOscillator(); + osc.type = 'triangle'; + osc.frequency.setValueAtTime(750, now); + osc.frequency.exponentialRampToValueAtTime(120, now + duration); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.25, now); + env.gain.exponentialRampToValueAtTime(0.001, now + duration); + + osc.connect(env); + env.connect(this.gainNode); + + osc.start(now); + osc.stop(now + duration); + } + + /** + * NX-01 Indicator Beep (Early 22nd century industrial tone) + */ + synthesizeNXIndicatorBeep() { + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + const duration = 0.08; + + const osc = ctx.createOscillator(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(950, now); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, now); + env.gain.linearRampToValueAtTime(0.2, now + 0.005); + env.gain.setValueAtTime(0.2, now + duration * 0.8); + env.gain.exponentialRampToValueAtTime(0.001, now + duration); + + osc.connect(env); + env.connect(this.gainNode); + + osc.start(now); + osc.stop(now + duration); + } + + /** + * Iconic TNG 2-Tone Door Chime ("Come in") + */ + synthesizeDoorChime() { + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + const f1 = 880; // A5 + const f2 = 1174.66; // D6 (Up a fourth) + + const osc1 = ctx.createOscillator(); + osc1.type = 'sine'; + osc1.frequency.setValueAtTime(f1, now); + + const env1 = ctx.createGain(); + env1.gain.setValueAtTime(0.001, now); + env1.gain.linearRampToValueAtTime(0.35, now + 0.015); + env1.gain.exponentialRampToValueAtTime(0.001, now + 0.45); + + osc1.connect(env1); + env1.connect(this.gainNode); + osc1.start(now); + osc1.stop(now + 0.45); + + // Second tone starts at 0.16s + const osc2 = ctx.createOscillator(); + osc2.type = 'sine'; + osc2.frequency.setValueAtTime(f2, now + 0.16); + + const env2 = ctx.createGain(); + env2.gain.setValueAtTime(0.001, now + 0.16); + env2.gain.linearRampToValueAtTime(0.4, now + 0.175); + env2.gain.exponentialRampToValueAtTime(0.001, now + 0.7); + + osc2.connect(env2); + env2.connect(this.gainNode); + osc2.start(now + 0.16); + osc2.stop(now + 0.7); + } + + applyPreset(config) { + if (config.volume !== undefined) this.params.volume = config.volume; + if (config.density !== undefined) this.setDensity(config.density); + if (config.era !== undefined) this.params.era = config.era; + this.setVolume(this.params.volume); + this.startAutoTelemetryScheduler(); + } +} + +window.TelemetrySynth = TelemetrySynth; + + +/** + * Procedural Starship Alert & Event Synthesizer + * 100% synthesized programmatically in Web Audio API. + * Includes TNG Red Alert (3-tone), TOS Red Alert (hooter buzzer), Movie-era descending klaxon, + * Yellow Alert chime, and dynamic Warp Drive Throttle swell. + */ + +class AlertSynth { + constructor(audioManager) { + this.am = audioManager; + this.gainNode = null; + this.activeAlert = null; // 'red', 'yellow', null + this.alertTimer = null; + this.alertType = 'tng'; // 'tng', 'tos', 'movie' + + this.params = { + volume: 0.6 + }; + } + + init() { + if (this.gainNode || !this.am.ctx) return; + const ctx = this.am.ctx; + this.gainNode = ctx.createGain(); + this.gainNode.gain.setValueAtTime(this.params.volume, ctx.currentTime); + this.gainNode.connect(this.am.compressor); + } + + setVolume(val) { + this.params.volume = Math.max(0, Math.min(1, val)); + if (this.gainNode && this.am.ctx) { + const now = this.am.ctx.currentTime; + this.gainNode.gain.linearRampToValueAtTime(this.params.volume, now + 0.05); + } + } + + triggerRedAlert(type = 'tng') { + this.init(); + this.stopAlert(); + this.activeAlert = 'red'; + this.alertType = type; + + const playLoop = () => { + if (this.activeAlert !== 'red') return; + let loopDuration = 1.35; + + if (this.alertType === 'tng') { + this.synthesizeTNGRedAlertCycle(); + loopDuration = 1.35; + } else if (this.alertType === 'tos') { + this.synthesizeTOSRedAlertCycle(); + loopDuration = 1.1; + } else { + this.synthesizeMovieRedAlertCycle(); + loopDuration = 1.4; + } + + this.alertTimer = setTimeout(playLoop, loopDuration * 1000); + }; + + playLoop(); + } + + triggerYellowAlert() { + this.init(); + this.stopAlert(); + this.activeAlert = 'yellow'; + + const playLoop = () => { + if (this.activeAlert !== 'yellow') return; + this.synthesizeYellowAlertCycle(); + this.alertTimer = setTimeout(playLoop, 2200); + }; + + playLoop(); + } + + stopAlert() { + this.activeAlert = null; + if (this.alertTimer) { + clearTimeout(this.alertTimer); + this.alertTimer = null; + } + } + + /** + * TNG Red Alert Klaxon (3-tone rising & cascading electronic horn with resonant envelope) + */ + synthesizeTNGRedAlertCycle() { + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + // Frequencies for the iconic TNG 3-tone klaxon chord: F5 (698.46Hz), Ab5 (830.61Hz), C6 (1046.50Hz) + const freqs = [698.46, 830.61, 1046.50]; + + freqs.forEach((freq, idx) => { + const osc = ctx.createOscillator(); + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(freq * 0.94, now); + // Fast upward swoop on trigger + osc.frequency.exponentialRampToValueAtTime(freq, now + 0.12); + + // Lowpass filter to give that brassy starship horn acoustic resonance + const filter = ctx.createBiquadFilter(); + filter.type = 'lowpass'; + filter.frequency.setValueAtTime(1600, now); + filter.Q.setValueAtTime(4.0, now); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, now); + env.gain.linearRampToValueAtTime(0.25 / freqs.length, now + 0.08); + env.gain.setValueAtTime(0.25 / freqs.length, now + 0.45); + env.gain.exponentialRampToValueAtTime(0.0001, now + 0.85); + + osc.connect(filter); + filter.connect(env); + env.connect(this.gainNode); + + osc.start(now); + osc.stop(now + 0.86); + }); + } + + /** + * TOS Red Alert Buzzer / Hooter Siren (Pulsing 2-tone frequency modulation) + */ + synthesizeTOSRedAlertCycle() { + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + const duration = 0.85; + + const osc = ctx.createOscillator(); + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(540, now); + osc.frequency.linearRampToValueAtTime(920, now + duration * 0.5); + osc.frequency.linearRampToValueAtTime(540, now + duration); + + const filter = ctx.createBiquadFilter(); + filter.type = 'bandpass'; + filter.frequency.setValueAtTime(800, now); + filter.Q.setValueAtTime(2.2, now); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.01, now); + env.gain.linearRampToValueAtTime(0.28, now + 0.05); + env.gain.setValueAtTime(0.28, now + duration * 0.85); + env.gain.exponentialRampToValueAtTime(0.001, now + duration); + + osc.connect(filter); + filter.connect(env); + env.connect(this.gainNode); + + osc.start(now); + osc.stop(now + duration); + } + + /** + * Star Trek Movie Era Refit Descending Red Alert Klaxon + */ + synthesizeMovieRedAlertCycle() { + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + const duration = 1.05; + + const osc = ctx.createOscillator(); + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(1350, now); + osc.frequency.exponentialRampToValueAtTime(420, now + duration * 0.9); + + const filter = ctx.createBiquadFilter(); + filter.type = 'lowpass'; + filter.frequency.setValueAtTime(2200, now); + filter.Q.setValueAtTime(3.5, now); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.01, now); + env.gain.linearRampToValueAtTime(0.3, now + 0.06); + env.gain.exponentialRampToValueAtTime(0.001, now + duration); + + osc.connect(filter); + filter.connect(env); + env.connect(this.gainNode); + + osc.start(now); + osc.stop(now + duration); + } + + /** + * Yellow Alert Pulsing Warning Chime + */ + synthesizeYellowAlertCycle() { + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + const f1 = 660; // E5 + const f2 = 880; // A5 + + [0, 0.22].forEach((offset, idx) => { + const freq = idx === 0 ? f1 : f2; + const osc = ctx.createOscillator(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(freq, now + offset); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, now + offset); + env.gain.linearRampToValueAtTime(0.32, now + offset + 0.015); + env.gain.exponentialRampToValueAtTime(0.001, now + offset + 0.5); + + osc.connect(env); + env.connect(this.gainNode); + + osc.start(now + offset); + osc.stop(now + offset + 0.52); + }); + } + + /** + * Procedural Warp Drive Acceleration Swell ("Engage!") + * Synthesizes rising plasma induction whine + deep bass detonation + */ + synthesizeWarpJump() { + this.init(); + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + const duration = 2.8; + + // 1. Rising High Induction Whine + const whineOsc = ctx.createOscillator(); + whineOsc.type = 'sawtooth'; + whineOsc.frequency.setValueAtTime(80, now); + whineOsc.frequency.exponentialRampToValueAtTime(3800, now + 1.8); + whineOsc.frequency.exponentialRampToValueAtTime(14000, now + 2.5); + + const whineFilter = ctx.createBiquadFilter(); + whineFilter.type = 'bandpass'; + whineFilter.frequency.setValueAtTime(200, now); + whineFilter.frequency.exponentialRampToValueAtTime(4500, now + 1.8); + whineFilter.Q.setValueAtTime(5.0, now); + + const whineEnv = ctx.createGain(); + whineEnv.gain.setValueAtTime(0.01, now); + whineEnv.gain.linearRampToValueAtTime(0.35, now + 1.6); + whineEnv.gain.exponentialRampToValueAtTime(0.001, now + 2.7); + + whineOsc.connect(whineFilter); + whineFilter.connect(whineEnv); + whineEnv.connect(this.gainNode); + + // 2. Sub-bass Matter-Antimatter Boom + const subOsc = ctx.createOscillator(); + subOsc.type = 'sine'; + subOsc.frequency.setValueAtTime(140, now + 1.4); + subOsc.frequency.exponentialRampToValueAtTime(32, now + 2.6); + + const subEnv = ctx.createGain(); + subEnv.gain.setValueAtTime(0.001, now + 1.4); + subEnv.gain.linearRampToValueAtTime(0.7, now + 1.7); + subEnv.gain.exponentialRampToValueAtTime(0.0001, now + duration); + + subOsc.connect(subEnv); + subEnv.connect(this.gainNode); + + whineOsc.start(now); + whineOsc.stop(now + 2.7); + subOsc.start(now + 1.4); + subOsc.stop(now + duration); + } +} + +window.AlertSynth = AlertSynth; + + +/** + * Procedural Doctor Who & TARDIS Sound Synthesizer + * 100% synthesized programmatically via Web Audio API. + * Includes Dematerialization Wheeze-Groan, Cloister Bell, Sonic Screwdriver, and TARDIS Console Foley. + */ + +class WhoniverseAudioSynth { + constructor(audioManager) { + this.am = audioManager; + this.gainNode = null; + this.activeCloister = false; + this.cloisterTimer = null; + } + + init() { + if (this.gainNode || !this.am.ctx) return; + const ctx = this.am.ctx; + this.gainNode = ctx.createGain(); + this.gainNode.gain.setValueAtTime(0.7, ctx.currentTime); + this.gainNode.connect(this.am.compressor); + } + + /** + * Procedural TARDIS Materialization / Dematerialization ("Wheeze-Groan") + * Modeled after Brian Hodgson's 1963 BBC Radiophonic technique: + * Dragging keys on piano bass strings -> reverse playback -> slow tape speed -> feedback loop. + */ + synthesizeDematCycle(cycles = 4) { + this.init(); + const ctx = this.am.ctx; + if (!ctx) return; + + for (let c = 0; c < cycles; c++) { + const cycleStart = ctx.currentTime + c * 1.85; + this.synthesizeSingleDematSwell(cycleStart, c, cycles); + } + } + + synthesizeSingleDematSwell(startTime, cycleIndex, totalCycles) { + const ctx = this.am.ctx; + const duration = 1.75; + + // Intensity fades slightly on later cycles + const intensity = 1.0 - (cycleIndex / totalCycles) * 0.35; + + // 1. Friction Scrape Carrier (Sawtooth through resonant highpass/bandpass with frequency glide) + const frictionOsc = ctx.createOscillator(); + frictionOsc.type = 'sawtooth'; + // Frequency glides up then groans down + frictionOsc.frequency.setValueAtTime(120, startTime); + frictionOsc.frequency.exponentialRampToValueAtTime(840, startTime + 0.65); + frictionOsc.frequency.exponentialRampToValueAtTime(95, startTime + duration); + + // Filter modeling the piano soundboard metallic scraping resonance + const frictionFilter = ctx.createBiquadFilter(); + frictionFilter.type = 'bandpass'; + frictionFilter.frequency.setValueAtTime(320, startTime); + frictionFilter.frequency.exponentialRampToValueAtTime(1450, startTime + 0.65); + frictionFilter.frequency.exponentialRampToValueAtTime(220, startTime + duration); + frictionFilter.Q.setValueAtTime(4.5, startTime); + + const frictionGain = ctx.createGain(); + frictionGain.gain.setValueAtTime(0.001, startTime); + frictionGain.gain.linearRampToValueAtTime(0.4 * intensity, startTime + 0.45); + frictionGain.gain.exponentialRampToValueAtTime(0.001, startTime + duration); + + // 2. Sub-Vortex Resonant Groan (FM synthesis for the deep cosmic groaning undertone) + const groanCarrier = ctx.createOscillator(); + groanCarrier.type = 'triangle'; + groanCarrier.frequency.setValueAtTime(55, startTime); + groanCarrier.frequency.linearRampToValueAtTime(138, startTime + 0.55); + groanCarrier.frequency.exponentialRampToValueAtTime(48, startTime + duration); + + const groanMod = ctx.createOscillator(); + groanMod.type = 'sine'; + groanMod.frequency.setValueAtTime(28, startTime); // Phasing FM modulator + groanMod.frequency.linearRampToValueAtTime(65, startTime + 0.6); + + const groanModGain = ctx.createGain(); + groanModGain.gain.setValueAtTime(45, startTime); + groanMod.connect(groanModGain); + groanModGain.connect(groanCarrier.frequency); + + const groanGain = ctx.createGain(); + groanGain.gain.setValueAtTime(0.001, startTime); + groanGain.gain.linearRampToValueAtTime(0.6 * intensity, startTime + 0.5); + groanGain.gain.exponentialRampToValueAtTime(0.001, startTime + duration); + + // 3. Phasing Flutter / Swell (Tape-flange simulation via slow LFO) + const lfo = ctx.createOscillator(); + lfo.type = 'sine'; + lfo.frequency.setValueAtTime(5.5, startTime); // 5.5 Hz flanging flutter + + const lfoDepth = ctx.createGain(); + lfoDepth.gain.setValueAtTime(0.25, startTime); + lfo.connect(lfoDepth); + lfoDepth.connect(frictionGain.gain); + + // Connect Graph + frictionOsc.connect(frictionFilter); + frictionFilter.connect(frictionGain); + frictionGain.connect(this.gainNode); + + groanCarrier.connect(groanGain); + groanGain.connect(this.gainNode); + + // Trigger Nodes + frictionOsc.start(startTime); + frictionOsc.stop(startTime + duration); + groanCarrier.start(startTime); + groanCarrier.stop(startTime + duration); + groanMod.start(startTime); + groanMod.stop(startTime + duration); + lfo.start(startTime); + lfo.stop(startTime + duration); + } + + /** + * Procedural Cloister Bell (Deep, ominous bronze cathedral bell) + */ + triggerCloisterBell() { + this.init(); + this.stopCloisterBell(); + this.activeCloister = true; + + const ringLoop = () => { + if (!this.activeCloister) return; + this.synthesizeCloisterStrike(); + this.cloisterTimer = setTimeout(ringLoop, 3200); // Canonical cloister bell repetition rate + }; + + ringLoop(); + } + + stopCloisterBell() { + this.activeCloister = false; + if (this.cloisterTimer) { + clearTimeout(this.cloisterTimer); + this.cloisterTimer = null; + } + } + + synthesizeCloisterStrike() { + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + const duration = 4.2; + + // Authentic bell inharmonic partial ratios: Fundamental, Minor 3rd, 5th, Octave, Major 7th + const bellPartials = [ + { freqRatio: 1.0, gain: 0.65, decay: 4.2 }, // Fundamental ~108 Hz + { freqRatio: 1.19, gain: 0.45, decay: 3.6 }, // Minor third + { freqRatio: 1.51, gain: 0.40, decay: 3.1 }, // Fifth + { freqRatio: 2.01, gain: 0.30, decay: 2.4 }, // Octave + { freqRatio: 2.74, gain: 0.22, decay: 1.8 }, // Upper strike tone + { freqRatio: 3.42, gain: 0.15, decay: 1.2 } // High strike transient + ]; + + const basePitch = 108.0; // Deep bronze bell pitch + + bellPartials.forEach(p => { + const osc = ctx.createOscillator(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(basePitch * p.freqRatio, now); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, now); + env.gain.linearRampToValueAtTime(p.gain * 0.35, now + 0.012); // Sharp hammer impact + env.gain.exponentialRampToValueAtTime(0.0001, now + p.decay); + + osc.connect(env); + env.connect(this.gainNode); + + osc.start(now); + osc.stop(now + p.decay + 0.05); + }); + } + + /** + * Procedural Sonic Screwdriver (High-frequency modulated sweep & vibrato warble) + */ + synthesizeSonicScrewdriver(durationSeconds = 1.2) { + this.init(); + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + const duration = durationSeconds; + + // Dual square/saw oscillators + const osc1 = ctx.createOscillator(); + osc1.type = 'square'; + osc1.frequency.setValueAtTime(2350, now); + osc1.frequency.linearRampToValueAtTime(2650, now + duration * 0.5); + osc1.frequency.linearRampToValueAtTime(2350, now + duration); + + const osc2 = ctx.createOscillator(); + osc2.type = 'sawtooth'; + osc2.frequency.setValueAtTime(2362, now); // 12Hz natural phase beat + + // Rapid Vibrato LFO + const vibrato = ctx.createOscillator(); + vibrato.type = 'sine'; + vibrato.frequency.setValueAtTime(32, now); // 32 Hz warble rate + + const vibGain = ctx.createGain(); + vibGain.gain.setValueAtTime(140, now); + vibrato.connect(vibGain); + vibGain.connect(osc1.frequency); + vibGain.connect(osc2.frequency); + + // Bandpass filter for metallic resonance + const filter = ctx.createBiquadFilter(); + filter.type = 'bandpass'; + filter.frequency.setValueAtTime(2500, now); + filter.Q.setValueAtTime(4.0, now); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, now); + env.gain.linearRampToValueAtTime(0.28, now + 0.03); + env.gain.setValueAtTime(0.28, now + duration * 0.85); + env.gain.exponentialRampToValueAtTime(0.0001, now + duration); + + osc1.connect(filter); + osc2.connect(filter); + filter.connect(env); + env.connect(this.gainNode); + + osc1.start(now); + osc2.start(now); + vibrato.start(now); + osc1.stop(now + duration); + osc2.stop(now + duration); + vibrato.stop(now + duration); + } + + /** + * Fast-Return Spring Lever (Heavy spring recoil clack + resonant ring) + */ + synthesizeFastReturn() { + this.init(); + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + const duration = 0.35; + + const osc = ctx.createOscillator(); + osc.type = 'triangle'; + osc.frequency.setValueAtTime(620, now); + osc.frequency.exponentialRampToValueAtTime(95, now + 0.08); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.45, now); + env.gain.exponentialRampToValueAtTime(0.001, now + duration); + + osc.connect(env); + env.connect(this.gainNode); + + osc.start(now); + osc.stop(now + duration); + } + + /** + * TARDIS Demat Switch / Relay Solenoid + */ + synthesizeDematSwitch() { + this.init(); + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + const duration = 0.06; + + const osc = ctx.createOscillator(); + osc.type = 'square'; + osc.frequency.setValueAtTime(850, now); + osc.frequency.exponentialRampToValueAtTime(140, now + duration); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.35, now); + env.gain.exponentialRampToValueAtTime(0.001, now + duration); + + osc.connect(env); + env.connect(this.gainNode); + + osc.start(now); + osc.stop(now + duration); + } + + /** + * Telepathic Circuit Chime (Glassy, mystical resonance) + */ + synthesizeTelepathicChime() { + this.init(); + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + const duration = 1.4; + const notes = [1046.50, 1318.51, 1567.98, 2093.00]; // C Major arpeggio shimmer + + notes.forEach((freq, idx) => { + const osc = ctx.createOscillator(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(freq, now + idx * 0.08); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, now + idx * 0.08); + env.gain.linearRampToValueAtTime(0.18, now + idx * 0.08 + 0.02); + env.gain.exponentialRampToValueAtTime(0.0001, now + duration); + + osc.connect(env); + env.connect(this.gainNode); + + osc.start(now + idx * 0.08); + osc.stop(now + duration + 0.05); + }); + } +} + +window.WhoniverseAudioSynth = WhoniverseAudioSynth; + + +/** + * Procedural Audio Synthesizers for Expanded Sci-Fi Universes + * Generates Epstein drives, Bio-ship neural pulses, DRADIS sonar, Singularity drives, + * Retro analog tone glides, and Ludicrous speed reality shifts via Web Audio API. + */ + +class ExpandedSciFiAudioSynth { + constructor(audioManager) { + this.am = audioManager; + this.gainNode = null; + } + + init() { + if (this.gainNode || !this.am.ctx) return; + const ctx = this.am.ctx; + this.gainNode = ctx.createGain(); + this.gainNode.gain.setValueAtTime(0.7, ctx.currentTime); + this.gainNode.connect(this.am.compressor); + } + + /** + * Epstein Drive Fusion Torch Burn (The Expanse / Industrial Space) + * Tremendous raw fusion thrust with high-pressure magnetic plasma acceleration + */ + synthesizeEpsteinBurn() { + this.init(); + const ctx = this.am.ctx; + if (!ctx) return; + + const now = ctx.currentTime; + const duration = 3.5; + + // 1. High-frequency plasma induction whine + const whineOsc = ctx.createOscillator(); + whineOsc.type = 'sawtooth'; + whineOsc.frequency.setValueAtTime(140, now); + whineOsc.frequency.exponentialRampToValueAtTime(1800, now + 1.2); + whineOsc.frequency.exponentialRampToValueAtTime(3200, now + 2.5); + + const whineFilter = ctx.createBiquadFilter(); + whineFilter.type = 'bandpass'; + whineFilter.frequency.setValueAtTime(280, now); + whineFilter.frequency.exponentialRampToValueAtTime(2600, now + 2.0); + whineFilter.Q.setValueAtTime(4.0, now); + + const whineGain = ctx.createGain(); + whineGain.gain.setValueAtTime(0.001, now); + whineGain.gain.linearRampToValueAtTime(0.35, now + 1.0); + whineGain.gain.exponentialRampToValueAtTime(0.001, now + duration); + + whineOsc.connect(whineFilter); + whineFilter.connect(whineGain); + whineGain.connect(this.gainNode); + + // 2. Colossal fusion blast roar (filtered noise) + const roarBuffer = this.am.createNoiseBuffer('brown', 4); + const roarSource = ctx.createBufferSource(); + roarSource.buffer = roarBuffer; + + const roarFilter = ctx.createBiquadFilter(); + roarFilter.type = 'lowpass'; + roarFilter.frequency.setValueAtTime(180, now); + roarFilter.frequency.linearRampToValueAtTime(550, now + 1.2); + roarFilter.frequency.exponentialRampToValueAtTime(120, now + duration); + + const roarGain = ctx.createGain(); + roarGain.gain.setValueAtTime(0.001, now); + roarGain.gain.linearRampToValueAtTime(0.7, now + 1.2); + roarGain.gain.exponentialRampToValueAtTime(0.001, now + duration); + + roarSource.connect(roarFilter); + roarFilter.connect(roarGain); + roarGain.connect(this.gainNode); + + whineOsc.start(now); + whineOsc.stop(now + duration); + roarSource.start(now); + roarSource.stop(now + duration); + } + + /** + * Bio-Ship Starburst / Neural Pulse (Farscape Moya & Bioships) + * Organic vocalized dimensional fold and vascular wave + */ + synthesizeStarburst() { + this.init(); + const ctx = this.am.ctx; + if (!ctx) return; + + const now = ctx.currentTime; + const duration = 2.8; + + const osc1 = ctx.createOscillator(); + osc1.type = 'sine'; + osc1.frequency.setValueAtTime(85, now); + osc1.frequency.exponentialRampToValueAtTime(940, now + 1.4); + osc1.frequency.exponentialRampToValueAtTime(45, now + duration); + + const osc2 = ctx.createOscillator(); + osc2.type = 'triangle'; + osc2.frequency.setValueAtTime(125, now); + osc2.frequency.exponentialRampToValueAtTime(1420, now + 1.4); + osc2.frequency.exponentialRampToValueAtTime(65, now + duration); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, now); + env.gain.linearRampToValueAtTime(0.45, now + 1.3); + env.gain.exponentialRampToValueAtTime(0.001, now + duration); + + osc1.connect(env); + osc2.connect(env); + env.connect(this.gainNode); + + osc1.start(now); + osc2.start(now); + osc1.stop(now + duration); + osc2.stop(now + duration); + } + + /** + * Battlestar Galactica DRADIS Sonar Ping (Military Space) + * The iconic tactical combat contact echo + */ + synthesizeDradisPing() { + this.init(); + const ctx = this.am.ctx; + if (!ctx) return; + + const now = ctx.currentTime; + const duration = 1.4; + + const osc = ctx.createOscillator(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(1860, now); + osc.frequency.exponentialRampToValueAtTime(1540, now + 0.08); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, now); + env.gain.linearRampToValueAtTime(0.35, now + 0.01); + env.gain.exponentialRampToValueAtTime(0.0001, now + duration); + + osc.connect(env); + env.connect(this.gainNode); + + osc.start(now); + osc.stop(now + duration); + } + + /** + * FTL Jump Thunderclap (BSG / Military Space) + * Sudden vacuum displacement shockwave + */ + synthesizeFtlJump() { + this.init(); + const ctx = this.am.ctx; + if (!ctx) return; + + const now = ctx.currentTime; + const duration = 2.2; + + const noiseBuffer = this.am.createNoiseBuffer('brown', 2.5); + const noise = ctx.createBufferSource(); + noise.buffer = noiseBuffer; + + const filter = ctx.createBiquadFilter(); + filter.type = 'lowpass'; + filter.frequency.setValueAtTime(800, now); + filter.frequency.exponentialRampToValueAtTime(45, now + 1.8); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.8, now); + env.gain.exponentialRampToValueAtTime(0.001, now + duration); + + noise.connect(filter); + filter.connect(env); + env.connect(this.gainNode); + + noise.start(now); + noise.stop(now + duration); + } + + /** + * Retro Astrogator Tone Glide (Jupiter 2 / Retro Future) + * 1960s Theremin / electronic oscillator glissando + */ + synthesizeRetroAstrogator() { + this.init(); + const ctx = this.am.ctx; + if (!ctx) return; + + const now = ctx.currentTime; + const duration = 1.6; + + const osc = ctx.createOscillator(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(440, now); + osc.frequency.linearRampToValueAtTime(1180, now + 0.6); + osc.frequency.linearRampToValueAtTime(320, now + 1.1); + osc.frequency.linearRampToValueAtTime(660, now + duration); + + const vibrato = ctx.createOscillator(); + vibrato.type = 'sine'; + vibrato.frequency.setValueAtTime(8, now); + + const vibGain = ctx.createGain(); + vibGain.gain.setValueAtTime(25, now); + vibrato.connect(vibGain); + vibGain.connect(osc.frequency); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, now); + env.gain.linearRampToValueAtTime(0.3, now + 0.1); + env.gain.exponentialRampToValueAtTime(0.001, now + duration); + + osc.connect(env); + env.connect(this.gainNode); + + osc.start(now); + vibrato.start(now); + osc.stop(now + duration); + vibrato.stop(now + duration); + } + + /** + * HAL 9000 Logic Confirmation Chime (Discovery One) + */ + synthesizeHalChime() { + this.init(); + const ctx = this.am.ctx; + if (!ctx) return; + + const now = ctx.currentTime; + const f1 = 784; // G5 + const f2 = 523; // C5 + + [0, 0.14].forEach((offset, idx) => { + const osc = ctx.createOscillator(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(idx === 0 ? f1 : f2, now + offset); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, now + offset); + env.gain.linearRampToValueAtTime(0.25, now + offset + 0.01); + env.gain.exponentialRampToValueAtTime(0.001, now + offset + 0.5); + + osc.connect(env); + env.connect(this.gainNode); + + osc.start(now + offset); + osc.stop(now + offset + 0.52); + }); + } + + /** + * Event Horizon Gravity Singularity Pulse (Deep Space) + * Deep sub-bass dimensional warping thrum + */ + synthesizeSingularityEngage() { + this.init(); + const ctx = this.am.ctx; + if (!ctx) return; + + const now = ctx.currentTime; + const duration = 3.2; + + const sub = ctx.createOscillator(); + sub.type = 'sine'; + sub.frequency.setValueAtTime(95, now); + sub.frequency.exponentialRampToValueAtTime(28, now + 2.2); + + const mod = ctx.createOscillator(); + mod.type = 'triangle'; + mod.frequency.setValueAtTime(14, now); + mod.frequency.linearRampToValueAtTime(45, now + 1.8); + + const modGain = ctx.createGain(); + modGain.gain.setValueAtTime(60, now); + mod.connect(modGain); + modGain.connect(sub.frequency); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, now); + env.gain.linearRampToValueAtTime(0.65, now + 1.5); + env.gain.exponentialRampToValueAtTime(0.0001, now + duration); + + sub.connect(env); + env.connect(this.gainNode); + + sub.start(now); + mod.start(now); + sub.stop(now + duration); + mod.stop(now + duration); + } + + /** + * Outlaw Afterburner Thruster Surge (Cowboy Bebop / Milano) + */ + synthesizeAfterburner() { + this.init(); + const ctx = this.am.ctx; + if (!ctx) return; + + const now = ctx.currentTime; + const duration = 2.4; + + const osc = ctx.createOscillator(); + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(80, now); + osc.frequency.exponentialRampToValueAtTime(850, now + 0.8); + osc.frequency.linearRampToValueAtTime(620, now + duration); + + const filter = ctx.createBiquadFilter(); + filter.type = 'bandpass'; + filter.frequency.setValueAtTime(350, now); + filter.frequency.exponentialRampToValueAtTime(1400, now + 0.8); + filter.Q.setValueAtTime(3.0, now); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, now); + env.gain.linearRampToValueAtTime(0.5, now + 0.6); + env.gain.exponentialRampToValueAtTime(0.001, now + duration); + + osc.connect(filter); + filter.connect(env); + env.connect(this.gainNode); + + osc.start(now); + osc.stop(now + duration); + } + + /** + * Space Station Docking Clamp Latch & Airlock Purge + */ + synthesizeDockingClamp() { + this.init(); + const ctx = this.am.ctx; + if (!ctx) return; + + const now = ctx.currentTime; + + // Heavy mechanical solenoid impact + const osc = ctx.createOscillator(); + osc.type = 'square'; + osc.frequency.setValueAtTime(380, now); + osc.frequency.exponentialRampToValueAtTime(65, now + 0.12); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.5, now); + env.gain.exponentialRampToValueAtTime(0.001, now + 0.25); + + osc.connect(env); + env.connect(this.gainNode); + + osc.start(now); + osc.stop(now + 0.25); + + // Followed by pneumatic seal hiss + setTimeout(() => { + if (!this.am.ctx) return; + const t = this.am.ctx.currentTime; + const hissBuf = this.am.createNoiseBuffer('white', 1.2); + const hiss = this.am.ctx.createBufferSource(); + hiss.buffer = hissBuf; + + const hFilter = this.am.ctx.createBiquadFilter(); + hFilter.type = 'bandpass'; + hFilter.frequency.setValueAtTime(2200, t); + hFilter.Q.setValueAtTime(2.5, t); + + const hEnv = this.am.ctx.createGain(); + hEnv.gain.setValueAtTime(0.001, t); + hEnv.gain.linearRampToValueAtTime(0.25, t + 0.05); + hEnv.gain.exponentialRampToValueAtTime(0.001, t + 0.9); + + hiss.connect(hFilter); + hFilter.connect(hEnv); + hEnv.connect(this.gainNode); + + hiss.start(t); + hiss.stop(t + 0.9); + }, 180); + } + + /** + * Ludicrous Speed Accelerator (Spaceball One / Comedy) + */ + synthesizeLudicrousSpeed() { + this.init(); + const ctx = this.am.ctx; + if (!ctx) return; + + const now = ctx.currentTime; + const duration = 3.2; + + const osc = ctx.createOscillator(); + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(60, now); + osc.frequency.exponentialRampToValueAtTime(5400, now + 2.2); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.01, now); + env.gain.linearRampToValueAtTime(0.4, now + 1.8); + env.gain.exponentialRampToValueAtTime(0.0001, now + duration); + + osc.connect(env); + env.connect(this.gainNode); + + osc.start(now); + osc.stop(now + duration); + } + + /** + * Infinite Improbability Reality-Warp Flip (Heart of Gold) + */ + synthesizeImprobabilityFlip() { + this.init(); + const ctx = this.am.ctx; + if (!ctx) return; + + const now = ctx.currentTime; + const duration = 1.8; + + const osc = ctx.createOscillator(); + osc.type = 'triangle'; + osc.frequency.setValueAtTime(1400, now); + osc.frequency.exponentialRampToValueAtTime(180, now + 0.7); + osc.frequency.exponentialRampToValueAtTime(2200, now + 1.3); + osc.frequency.exponentialRampToValueAtTime(440, now + duration); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, now); + env.gain.linearRampToValueAtTime(0.35, now + 0.1); + env.gain.exponentialRampToValueAtTime(0.001, now + duration); + + osc.connect(env); + env.connect(this.gainNode); + + osc.start(now); + osc.stop(now + duration); + } + + /** + * Geiger Counter Click Burst (Nostromo / Mining) + */ + synthesizeGeigerBurst() { + this.init(); + const ctx = this.am.ctx; + if (!ctx) return; + + const now = ctx.currentTime; + const clicks = 8 + Math.floor(Math.random() * 8); + + for (let i = 0; i < clicks; i++) { + const clickTime = now + (i * 0.04) + (Math.random() * 0.03); + const osc = ctx.createOscillator(); + osc.type = 'square'; + osc.frequency.setValueAtTime(2800 + Math.random() * 800, clickTime); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.18, clickTime); + env.gain.exponentialRampToValueAtTime(0.001, clickTime + 0.015); + + osc.connect(env); + env.connect(this.gainNode); + + osc.start(clickTime); + osc.stop(clickTime + 0.016); + } + } + + /** + * Cheerful Door Sigh (Heart of Gold / Sirius Cybernetics Corp) + */ + synthesizeCheerfulDoor() { + this.init(); + const ctx = this.am.ctx; + if (!ctx) return; + + const now = ctx.currentTime; + const duration = 0.9; + + const osc = ctx.createOscillator(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(620, now); + osc.frequency.linearRampToValueAtTime(840, now + 0.35); + osc.frequency.linearRampToValueAtTime(520, now + duration); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, now); + env.gain.linearRampToValueAtTime(0.25, now + 0.15); + env.gain.exponentialRampToValueAtTime(0.001, now + duration); + + osc.connect(env); + env.connect(this.gainNode); + + osc.start(now); + osc.stop(now + duration); + } +} + +window.ExpandedSciFiAudioSynth = ExpandedSciFiAudioSynth; + + +/** + * Canonical Starship & Location Sound Profiles Matrix + * Each preset defines the exact parameters for Hull Drone, Warp Core, Life Support, and Telemetry. + */ + diff --git a/js/config.js b/js/config.js new file mode 100644 index 0000000..f6c5e0b --- /dev/null +++ b/js/config.js @@ -0,0 +1,1039 @@ +const StarshipPresets = { + // === TNG GALAXY CLASS === + 'tng-bridge': { + id: 'tng-bridge', + name: 'Enterprise-D: Main Bridge', + era: 'tng', + theme: 'lcars-tng', + alertType: 'tng', + description: 'Warm, low-frequency 50Hz hull tone with gentle ventilation and soft LCARS computer chirps.', + hull: { volume: 0.65, baseFreq: 50, filterCutoff: 105, resonance: 2.2, noiseMix: 0.4, harmonicSpread: 1.015 }, + warp: { volume: 0.35, bpm: 48, carrierFreq: 58, filterCutoff: 150, pulseShape: 'tng', resonance: 2.8, swirlMix: 0.25 }, + lifeSupport: { volume: 0.55, noiseType: 'pink', highpassFreq: 180, lowpassFreq: 1600, airflowModSpeed: 0.12, airflowModDepth: 0.1 }, + telemetry: { volume: 0.45, density: 0.65, era: 'tng' } + }, + + 'tng-engineering': { + id: 'tng-engineering', + name: 'Enterprise-D: Main Engineering', + era: 'tng', + theme: 'lcars-tng', + alertType: 'tng', + description: 'Prominent, mesmerizing 4-stage warp core pulse with intense matter-antimatter reactor resonance.', + hull: { volume: 0.55, baseFreq: 54, filterCutoff: 130, resonance: 3.0, noiseMix: 0.45, harmonicSpread: 1.02 }, + warp: { volume: 0.95, bpm: 48, carrierFreq: 62, filterCutoff: 220, pulseShape: 'tng', resonance: 4.2, swirlMix: 0.45 }, + lifeSupport: { volume: 0.4, noiseType: 'pink', highpassFreq: 220, lowpassFreq: 1400, airflowModSpeed: 0.15, airflowModDepth: 0.08 }, + telemetry: { volume: 0.3, density: 0.35, era: 'tng' } + }, + + 'tng-quarters': { + id: 'tng-quarters', + name: 'Enterprise-D: Crew Quarters', + era: 'tng', + theme: 'lcars-tng', + alertType: 'tng', + description: 'Cozy, muffled acoustic damping designed for relaxation and deep sleep with soothing low engine rumble.', + hull: { volume: 0.7, baseFreq: 46, filterCutoff: 80, resonance: 1.6, noiseMix: 0.5, harmonicSpread: 1.01 }, + warp: { volume: 0.2, bpm: 44, carrierFreq: 52, filterCutoff: 110, pulseShape: 'tng', resonance: 2.0, swirlMix: 0.1 }, + lifeSupport: { volume: 0.65, noiseType: 'pink', highpassFreq: 140, lowpassFreq: 1100, airflowModSpeed: 0.08, airflowModDepth: 0.15 }, + telemetry: { volume: 0.0, density: 0.0, era: 'tng' } + }, + + // === VOYAGER INTREPID CLASS === + 'voyager-bridge': { + id: 'voyager-bridge', + name: 'USS Voyager: Bridge', + era: 'voyager', + theme: 'lcars-voyager', + alertType: 'tng', + description: 'Crisper environmental airflow, high-clarity telemetry sweeps, and modern streamlined acoustics.', + hull: { volume: 0.55, baseFreq: 58, filterCutoff: 120, resonance: 2.0, noiseMix: 0.35, harmonicSpread: 1.025 }, + warp: { volume: 0.4, bpm: 68, carrierFreq: 70, filterCutoff: 190, pulseShape: 'voyager', resonance: 3.5, swirlMix: 0.3 }, + lifeSupport: { volume: 0.6, noiseType: 'white', highpassFreq: 240, lowpassFreq: 2400, airflowModSpeed: 0.18, airflowModDepth: 0.12 }, + telemetry: { volume: 0.5, density: 0.75, era: 'voyager' } + }, + + 'voyager-engineering': { + id: 'voyager-engineering', + name: 'USS Voyager: Class-9 Warp Core', + era: 'voyager', + theme: 'lcars-voyager', + alertType: 'tng', + description: 'High-tempo, sharp magnetic pulse cycle characteristic of variable-geometry warp propulsion.', + hull: { volume: 0.5, baseFreq: 64, filterCutoff: 140, resonance: 2.8, noiseMix: 0.4, harmonicSpread: 1.03 }, + warp: { volume: 0.95, bpm: 72, carrierFreq: 76, filterCutoff: 260, pulseShape: 'voyager', resonance: 4.8, swirlMix: 0.5 }, + lifeSupport: { volume: 0.45, noiseType: 'white', highpassFreq: 260, lowpassFreq: 2100, airflowModSpeed: 0.2, airflowModDepth: 0.1 }, + telemetry: { volume: 0.35, density: 0.4, era: 'voyager' } + }, + + // === DS9 DEFIANT CLASS === + 'defiant-bridge': { + id: 'defiant-bridge', + name: 'USS Defiant: Tactical Bridge', + era: 'ds9', + theme: 'lcars-defiant', + alertType: 'tng', + description: 'Tight, aggressive warship rumble with high-output pulse engine harmonics and military console telemetry.', + hull: { volume: 0.75, baseFreq: 62, filterCutoff: 150, resonance: 3.5, noiseMix: 0.55, harmonicSpread: 1.04 }, + warp: { volume: 0.85, bpm: 60, carrierFreq: 82, filterCutoff: 240, pulseShape: 'defiant', resonance: 4.0, swirlMix: 0.35 }, + lifeSupport: { volume: 0.5, noiseType: 'pink', highpassFreq: 200, lowpassFreq: 1900, airflowModSpeed: 0.22, airflowModDepth: 0.14 }, + telemetry: { volume: 0.4, density: 0.6, era: 'ds9' } + }, + + // === DEEP SPACE 9 STATION === + 'ds9-ops': { + id: 'ds9-ops', + name: 'Deep Space 9: Ops Center', + era: 'ds9', + theme: 'lcars-ds9', + alertType: 'tng', + description: 'Cavernous industrial station ambience with deep ore-processing vibration and Cardassian sensor pings.', + hull: { volume: 0.7, baseFreq: 42, filterCutoff: 95, resonance: 2.0, noiseMix: 0.6, harmonicSpread: 1.01 }, + warp: { volume: 0.3, bpm: 36, carrierFreq: 48, filterCutoff: 120, pulseShape: 'nx', resonance: 2.2, swirlMix: 0.2 }, + lifeSupport: { volume: 0.65, noiseType: 'brown', highpassFreq: 120, lowpassFreq: 1300, airflowModSpeed: 0.09, airflowModDepth: 0.18 }, + telemetry: { volume: 0.45, density: 0.55, era: 'ds9' } + }, + + // === TOS ORIGINAL SERIES === + 'tos-bridge': { + id: 'tos-bridge', + name: 'Enterprise NCC-1701: Bridge (TOS)', + era: 'tos', + theme: 'tos-retro', + alertType: 'tos', + description: 'Classic 1960s electronic oscillator chatter, telemetry clicks, and vintage relay background drones.', + hull: { volume: 0.5, baseFreq: 75, filterCutoff: 160, resonance: 3.2, noiseMix: 0.3, harmonicSpread: 1.05 }, + warp: { volume: 0.45, bpm: 54, carrierFreq: 110, filterCutoff: 280, pulseShape: 'tos', resonance: 3.8, swirlMix: 0.2 }, + lifeSupport: { volume: 0.45, noiseType: 'pink', highpassFreq: 250, lowpassFreq: 2800, airflowModSpeed: 0.1, airflowModDepth: 0.05 }, + telemetry: { volume: 0.6, density: 0.8, era: 'tos' } + }, + + 'tos-engineering': { + id: 'tos-engineering', + name: 'Enterprise NCC-1701: Engineering (TOS)', + era: 'tos', + theme: 'tos-retro', + alertType: 'tos', + description: 'Rhythmic electromechanical generator drone, raw motor whine, and energized conduits.', + hull: { volume: 0.6, baseFreq: 88, filterCutoff: 220, resonance: 4.0, noiseMix: 0.35, harmonicSpread: 1.06 }, + warp: { volume: 0.88, bpm: 58, carrierFreq: 132, filterCutoff: 380, pulseShape: 'tos', resonance: 4.5, swirlMix: 0.3 }, + lifeSupport: { volume: 0.35, noiseType: 'pink', highpassFreq: 280, lowpassFreq: 2200, airflowModSpeed: 0.1, airflowModDepth: 0.05 }, + telemetry: { volume: 0.25, density: 0.3, era: 'tos' } + }, + + // === ENTERPRISE NX-01 === + 'nx-bridge': { + id: 'nx-bridge', + name: 'Enterprise NX-01: Command Bridge', + era: 'nx', + theme: 'nx-industrial', + alertType: 'movie', + description: 'Submarine-like mechanical pumps, heavy fan ventilation, and early tactile solenoid switchgear.', + hull: { volume: 0.7, baseFreq: 48, filterCutoff: 110, resonance: 2.6, noiseMix: 0.55, harmonicSpread: 1.03 }, + warp: { volume: 0.6, bpm: 42, carrierFreq: 65, filterCutoff: 170, pulseShape: 'nx', resonance: 3.2, swirlMix: 0.2 }, + lifeSupport: { volume: 0.7, noiseType: 'brown', highpassFreq: 150, lowpassFreq: 1800, airflowModSpeed: 0.16, airflowModDepth: 0.2 }, + telemetry: { volume: 0.45, density: 0.5, era: 'nx' } + } +}; + +window.StarshipPresets = StarshipPresets; + + +/** + * Extensible Multi-Universe Registry & Comprehensive Sci-Fi Profiles Matrix + * 10 Sci-Fi Universes with 55+ canonical starships, bioships, stations, and consoles. + */ + +const UniverseRegistry = { + // ========================================================================= + // 1. STARFLEET COMMAND (Star Trek) + // ========================================================================= + 'starfleet': { + id: 'starfleet', + name: 'STARFLEET COMMAND', + shortCode: 'STARFLEET', + icon: '★', + themeClass: 'theme-lcars-tng', + headerTitle: 'STARFLEET SOUND MATRIX v2.5', + visualizer: 'warp-core', + events: [ + { id: 'btn-warp-jump', label: 'WARP JUMP', type: 'warp' }, + { id: 'btn-red-alert', label: 'RED ALERT', type: 'alert-red' }, + { id: 'btn-yellow-alert', label: 'YELLOW ALERT', type: 'alert-yellow' } + ], + soundboard: [ + { id: 'btn-chirp-single', label: 'SINGLE CHIRP' }, + { id: 'btn-chirp-double', label: 'DOUBLE CHIRP' }, + { id: 'btn-chirp-ack', label: 'DATA ACK' }, + { id: 'btn-chirp-sweep', label: 'SENSOR SWEEP' }, + { id: 'btn-chirp-door', label: 'DOOR CHIME (2-TONE)', span: 2 } + ], + presets: StarshipPresets + }, + + // ========================================================================= + // 2. THE WHONIVERSE (Doctor Who TARDIS) + // ========================================================================= + 'whoniverse': { + id: 'whoniverse', + name: 'THE WHONIVERSE', + shortCode: 'T.A.R.D.I.S.', + icon: '⌛', + themeClass: 'theme-whoniverse-tardis', + headerTitle: 'TYPE 40 TT CAPSULE MATRIX', + visualizer: 'time-rotor', + events: [ + { id: 'btn-demat', label: 'DEMATERIALIZE', type: 'demat' }, + { id: 'btn-cloister', label: 'CLOISTER BELL', type: 'cloister' }, + { id: 'btn-vortex', label: 'VORTEX FLIGHT', type: 'vortex' } + ], + soundboard: [ + { id: 'btn-sonic', label: 'SONIC SCREWDRIVER' }, + { id: 'btn-fast-return', label: 'FAST-RETURN LEVER' }, + { id: 'btn-demat-switch', label: 'DEMAT SWITCH' }, + { id: 'btn-telepathic', label: 'TELEPATHIC CHIME' }, + { id: 'btn-scanner', label: 'SCANNER MONITOR HUM', span: 2 } + ], + presets: { + 'tardis-1963': { + id: 'tardis-1963', + name: '1963 Classic Console (1st / 2nd Doctors)', + era: 'classic', + theme: 'whoniverse-tardis', + alertType: 'cloister', + description: 'Sterile white room tone, high-frequency 2-tone oscillating rotor pulse, and mechanical telephone relays.', + hull: { volume: 0.5, baseFreq: 68, filterCutoff: 150, resonance: 2.8, noiseMix: 0.35, harmonicSpread: 1.04 }, + warp: { volume: 0.65, bpm: 52, carrierFreq: 96, filterCutoff: 210, pulseShape: 'tos', resonance: 3.5, swirlMix: 0.2 }, + lifeSupport: { volume: 0.45, noiseType: 'pink', highpassFreq: 220, lowpassFreq: 1800, airflowModSpeed: 0.08, airflowModDepth: 0.06 }, + telemetry: { volume: 0.55, density: 0.6, era: 'tos' } + }, + 'tardis-victorian': { + id: 'tardis-victorian', + name: 'Victorian Secondary Console (4th Doctor)', + era: 'classic', + theme: 'whoniverse-tardis', + alertType: 'cloister', + description: 'Warm wood-paneled room acoustic damping, rhythmic ticking clockwork, quiet fire hearth, and brass gears.', + hull: { volume: 0.65, baseFreq: 44, filterCutoff: 85, resonance: 1.8, noiseMix: 0.45, harmonicSpread: 1.015 }, + warp: { volume: 0.3, bpm: 40, carrierFreq: 50, filterCutoff: 120, pulseShape: 'nx', resonance: 2.0, swirlMix: 0.15 }, + lifeSupport: { volume: 0.55, noiseType: 'brown', highpassFreq: 110, lowpassFreq: 1200, airflowModSpeed: 0.07, airflowModDepth: 0.12 }, + telemetry: { volume: 0.35, density: 0.4, era: 'nx' } + }, + 'tardis-coral': { + id: 'tardis-coral', + name: 'The Coral Living TARDIS (9th / 10th Doctors)', + era: 'revival', + theme: 'whoniverse-tardis', + alertType: 'cloister', + description: 'Deep organic heartbeat thrum, bicycle pump squeaks, rattling mechanics, and steaming thermal exhaust.', + hull: { volume: 0.75, baseFreq: 52, filterCutoff: 135, resonance: 3.2, noiseMix: 0.5, harmonicSpread: 1.03 }, + warp: { volume: 0.9, bpm: 48, carrierFreq: 64, filterCutoff: 230, pulseShape: 'tng', resonance: 4.0, swirlMix: 0.45 }, + lifeSupport: { volume: 0.6, noiseType: 'pink', highpassFreq: 160, lowpassFreq: 1700, airflowModSpeed: 0.18, airflowModDepth: 0.15 }, + telemetry: { volume: 0.45, density: 0.5, era: 'tng' } + }, + 'tardis-copper': { + id: 'tardis-copper', + name: 'The Copper Workshop (11th Doctor)', + era: 'revival', + theme: 'whoniverse-tardis', + alertType: 'cloister', + description: 'Bustling kinetic atmosphere, whirring brass gyroscopes, bubbling conduits, and typewriter key clatter.', + hull: { volume: 0.6, baseFreq: 56, filterCutoff: 125, resonance: 2.5, noiseMix: 0.4, harmonicSpread: 1.02 }, + warp: { volume: 0.7, bpm: 62, carrierFreq: 72, filterCutoff: 210, pulseShape: 'voyager', resonance: 3.6, swirlMix: 0.35 }, + lifeSupport: { volume: 0.65, noiseType: 'white', highpassFreq: 220, lowpassFreq: 2200, airflowModSpeed: 0.15, airflowModDepth: 0.1 }, + telemetry: { volume: 0.55, density: 0.7, era: 'voyager' } + }, + 'tardis-cold-machine': { + id: 'tardis-cold-machine', + name: 'The Cold Machine (12th Doctor)', + era: 'revival', + theme: 'whoniverse-tardis', + alertType: 'cloister', + description: 'Studious acoustic focus, spinning ceiling planetary rings, electric neon tube hum, and authoritative switches.', + hull: { volume: 0.6, baseFreq: 50, filterCutoff: 110, resonance: 2.2, noiseMix: 0.38, harmonicSpread: 1.02 }, + warp: { volume: 0.75, bpm: 56, carrierFreq: 68, filterCutoff: 190, pulseShape: 'tng', resonance: 3.2, swirlMix: 0.3 }, + lifeSupport: { volume: 0.5, noiseType: 'pink', highpassFreq: 200, lowpassFreq: 1600, airflowModSpeed: 0.12, airflowModDepth: 0.08 }, + telemetry: { volume: 0.4, density: 0.5, era: 'ds9' } + }, + 'tardis-crystal': { + id: 'tardis-crystal', + name: 'The Singing Crystal Console (13th Doctor)', + era: 'revival', + theme: 'whoniverse-tardis', + alertType: 'cloister', + description: 'High-resonance singing bowl quartz harmonics, tectonic subterranean rumble, and kinetic crystal crackles.', + hull: { volume: 0.7, baseFreq: 46, filterCutoff: 100, resonance: 3.5, noiseMix: 0.5, harmonicSpread: 1.04 }, + warp: { volume: 0.8, bpm: 44, carrierFreq: 88, filterCutoff: 260, pulseShape: 'defiant', resonance: 4.4, swirlMix: 0.4 }, + lifeSupport: { volume: 0.55, noiseType: 'pink', highpassFreq: 180, lowpassFreq: 1900, airflowModSpeed: 0.16, airflowModDepth: 0.14 }, + telemetry: { volume: 0.5, density: 0.6, era: 'voyager' } + }, + 'tardis-infinite-white': { + id: 'tardis-infinite-white', + name: 'The Infinite White (14th / 15th Doctors)', + era: 'modern', + theme: 'whoniverse-tardis', + alertType: 'cloister', + description: 'Vast cathedral-scale spatial reverberation, pristine futuristic pulses, and smooth magnetic ramp tones.', + hull: { volume: 0.65, baseFreq: 48, filterCutoff: 95, resonance: 2.0, noiseMix: 0.42, harmonicSpread: 1.01 }, + warp: { volume: 0.6, bpm: 50, carrierFreq: 60, filterCutoff: 160, pulseShape: 'tng', resonance: 2.8, swirlMix: 0.3 }, + lifeSupport: { volume: 0.7, noiseType: 'white', highpassFreq: 160, lowpassFreq: 2600, airflowModSpeed: 0.14, airflowModDepth: 0.12 }, + telemetry: { volume: 0.45, density: 0.6, era: 'tng' } + } + } + }, + + // ========================================================================= + // 3. INDUSTRIAL SPACE (Alien, Firefly, Expanse, Red Dwarf, Space: 1999) + // ========================================================================= + 'industrial': { + id: 'industrial', + name: 'INDUSTRIAL SPACE', + shortCode: 'INDUSTRIAL', + icon: '⚙', + themeClass: 'theme-industrial', + headerTitle: 'HEAVY INDUSTRIAL REFINERY & FREIGHT', + visualizer: 'industrial-reactor', + events: [ + { id: 'btn-epstein', label: 'EPSTEIN BURN', type: 'epstein' }, + { id: 'btn-airlock-purge', label: 'PURGE AIRLOCK', type: 'purge' }, + { id: 'btn-reactor-vent', label: 'REACTOR VENT', type: 'vent' } + ], + soundboard: [ + { id: 'btn-geiger', label: 'GEIGER COUNTER' }, + { id: 'btn-dock-clamp', label: 'HYDRAULIC CLAMPS' }, + { id: 'btn-relay-click', label: 'M/TH/UR TELETYPE' }, + { id: 'btn-switch-pop', label: 'HEAVY SOLENOID' }, + { id: 'btn-pneumatic-door', label: 'PNEUMATIC SEAL PURGE', span: 2 } + ], + presets: { + 'ind-nostromo': { + id: 'ind-nostromo', + name: 'USCSS Nostromo: Ore Refinery Bridge', + era: 'alien', + description: 'Cavernous Weyland-Yutani ore hauler hum, dripping coolant pipes, M/TH/UR mainframe clicks.', + hull: { volume: 0.8, baseFreq: 42, filterCutoff: 95, resonance: 2.6, noiseMix: 0.6, harmonicSpread: 1.015 }, + warp: { volume: 0.5, bpm: 36, carrierFreq: 52, filterCutoff: 130, pulseShape: 'nx', resonance: 2.8, swirlMix: 0.2 }, + lifeSupport: { volume: 0.65, noiseType: 'brown', highpassFreq: 130, lowpassFreq: 1400, airflowModSpeed: 0.1, airflowModDepth: 0.16 }, + telemetry: { volume: 0.45, density: 0.45, era: 'nx' } + }, + 'ind-serenity': { + id: 'ind-serenity', + name: 'Serenity: Firefly-Class Cargo Hold', + era: 'firefly', + description: 'Compression coil gravitational thrum, loose vibrating structural bolts, and warm engine chug.', + hull: { volume: 0.7, baseFreq: 48, filterCutoff: 110, resonance: 2.2, noiseMix: 0.5, harmonicSpread: 1.025 }, + warp: { volume: 0.75, bpm: 44, carrierFreq: 64, filterCutoff: 180, pulseShape: 'nx', resonance: 3.5, swirlMix: 0.3 }, + lifeSupport: { volume: 0.55, noiseType: 'pink', highpassFreq: 160, lowpassFreq: 1600, airflowModSpeed: 0.14, airflowModDepth: 0.12 }, + telemetry: { volume: 0.35, density: 0.35, era: 'tos' } + }, + 'ind-red-dwarf': { + id: 'ind-red-dwarf', + name: 'Red Dwarf: Main Drive Corridor', + era: 'classic-scifi', + description: 'Immense hollow mining vessel ramscoop drone, cavernous reverberation, vending machine whine.', + hull: { volume: 0.85, baseFreq: 36, filterCutoff: 80, resonance: 2.0, noiseMix: 0.65, harmonicSpread: 1.01 }, + warp: { volume: 0.4, bpm: 30, carrierFreq: 46, filterCutoff: 110, pulseShape: 'nx', resonance: 2.2, swirlMix: 0.15 }, + lifeSupport: { volume: 0.6, noiseType: 'brown', highpassFreq: 110, lowpassFreq: 1200, airflowModSpeed: 0.08, airflowModDepth: 0.18 }, + telemetry: { volume: 0.25, density: 0.2, era: 'nx' } + }, + 'ind-starbug': { + id: 'ind-starbug', + name: 'Starbug 1: Cockpit Environment', + era: 'classic-scifi', + description: 'Buzzing thruster harmonics, claustrophobic air vents, and rattling pilot console switchgear.', + hull: { volume: 0.65, baseFreq: 76, filterCutoff: 170, resonance: 3.2, noiseMix: 0.45, harmonicSpread: 1.04 }, + warp: { volume: 0.6, bpm: 64, carrierFreq: 90, filterCutoff: 220, pulseShape: 'tos', resonance: 3.6, swirlMix: 0.25 }, + lifeSupport: { volume: 0.5, noiseType: 'pink', highpassFreq: 220, lowpassFreq: 2200, airflowModSpeed: 0.2, airflowModDepth: 0.14 }, + telemetry: { volume: 0.5, density: 0.55, era: 'tos' } + }, + 'ind-raza': { + id: 'ind-raza', + name: 'The Raza: Dark Matter Bridge', + era: 'dark-matter', + description: 'Dark, tense military-grade FTL engine hum with quiet tactical corridor acoustics.', + hull: { volume: 0.7, baseFreq: 54, filterCutoff: 120, resonance: 2.8, noiseMix: 0.48, harmonicSpread: 1.02 }, + warp: { volume: 0.65, bpm: 50, carrierFreq: 70, filterCutoff: 190, pulseShape: 'defiant', resonance: 3.4, swirlMix: 0.3 }, + lifeSupport: { volume: 0.55, noiseType: 'pink', highpassFreq: 180, lowpassFreq: 1800, airflowModSpeed: 0.12, airflowModDepth: 0.1 }, + telemetry: { volume: 0.4, density: 0.45, era: 'ds9' } + }, + 'ind-rocinante': { + id: 'ind-rocinante', + name: 'Rocinante: Combat Ops & Epstein Drive', + era: 'expanse', + description: 'Epstein fusion drive magnetic whine, high-output cooling, and Point Defense Cannon telemetry.', + hull: { volume: 0.8, baseFreq: 64, filterCutoff: 145, resonance: 3.4, noiseMix: 0.55, harmonicSpread: 1.035 }, + warp: { volume: 0.9, bpm: 58, carrierFreq: 84, filterCutoff: 250, pulseShape: 'defiant', resonance: 4.2, swirlMix: 0.4 }, + lifeSupport: { volume: 0.6, noiseType: 'white', highpassFreq: 200, lowpassFreq: 2100, airflowModSpeed: 0.18, airflowModDepth: 0.14 }, + telemetry: { volume: 0.5, density: 0.65, era: 'voyager' } + }, + 'ind-eagle': { + id: 'ind-eagle', + name: 'Eagle Transporter: Command Module', + era: 'space1999', + description: 'Nuclear fusion thruster hum, retro 1970s solenoid relays, and lunar reconnaissance telemetry.', + hull: { volume: 0.6, baseFreq: 70, filterCutoff: 160, resonance: 3.0, noiseMix: 0.4, harmonicSpread: 1.03 }, + warp: { volume: 0.55, bpm: 54, carrierFreq: 88, filterCutoff: 210, pulseShape: 'tos', resonance: 3.2, swirlMix: 0.2 }, + lifeSupport: { volume: 0.55, noiseType: 'pink', highpassFreq: 210, lowpassFreq: 2000, airflowModSpeed: 0.15, airflowModDepth: 0.1 }, + telemetry: { volume: 0.55, density: 0.6, era: 'tos' } + }, + 'ind-valley-forge': { + id: 'ind-valley-forge', + name: 'Valley Forge: Agro-Dome Forest Hub', + era: 'silent-running', + description: 'Geodesic greenhouse dome ventilation, slow drone servo whirrs, and deep sub-light propulsion.', + hull: { volume: 0.65, baseFreq: 46, filterCutoff: 100, resonance: 2.2, noiseMix: 0.5, harmonicSpread: 1.02 }, + warp: { volume: 0.4, bpm: 38, carrierFreq: 56, filterCutoff: 130, pulseShape: 'nx', resonance: 2.5, swirlMix: 0.2 }, + lifeSupport: { volume: 0.7, noiseType: 'pink', highpassFreq: 140, lowpassFreq: 1500, airflowModSpeed: 0.1, airflowModDepth: 0.15 }, + telemetry: { volume: 0.35, density: 0.3, era: 'nx' } + } + } + }, + + // ========================================================================= + // 4. BIOSHIPS (Living Starships & Symbiotes) + // ========================================================================= + 'bioships': { + id: 'bioships', + name: 'BIOSHIPS', + shortCode: 'BIOSHIPS', + icon: '🧬', + themeClass: 'theme-bioships', + headerTitle: 'ORGANIC LEVIATHAN & SYMBIOTIC PROPULSION', + visualizer: 'bio-heart', + events: [ + { id: 'btn-starburst', label: 'STARBURST', type: 'starburst' }, + { id: 'btn-neural-pulse', label: 'NEURAL PULSE', type: 'neural' }, + { id: 'btn-bio-defense', label: 'BIO-DEFENSE BURST', type: 'biodefense' } + ], + soundboard: [ + { id: 'btn-telepathic-chime', label: 'NEURAL TWITCH' }, + { id: 'btn-fast-return', label: 'VASCULAR PUMP' }, + { id: 'btn-demat-switch', label: 'CHITIN CREAK' }, + { id: 'btn-chirp-sweep', label: 'BIOPLASMIC HISS' }, + { id: 'btn-sonic', label: 'SYMBIOTE VOCALIZATION', span: 2 } + ], + presets: { + 'bio-moya': { + id: 'bio-moya', + name: 'Moya: Leviathan Central Nexus', + era: 'farscape', + description: 'Organic living Leviathan heartbeat, pulsating vascular fluid circulation, and Pilot neural link.', + hull: { volume: 0.75, baseFreq: 45, filterCutoff: 110, resonance: 3.2, noiseMix: 0.5, harmonicSpread: 1.03 }, + warp: { volume: 0.85, bpm: 38, carrierFreq: 58, filterCutoff: 180, pulseShape: 'tng', resonance: 4.2, swirlMix: 0.45 }, + lifeSupport: { volume: 0.65, noiseType: 'pink', highpassFreq: 150, lowpassFreq: 1600, airflowModSpeed: 0.08, airflowModDepth: 0.2 }, + telemetry: { volume: 0.4, density: 0.4, era: 'tng' } + }, + 'bio-talyn': { + id: 'bio-talyn', + name: 'Talyn: Gunship Neural Bridge', + era: 'farscape', + description: 'Young, aggressive warship Leviathan bio-heartbeat with tense, high-output combat bio-harmonics.', + hull: { volume: 0.8, baseFreq: 58, filterCutoff: 140, resonance: 3.6, noiseMix: 0.52, harmonicSpread: 1.04 }, + warp: { volume: 0.9, bpm: 64, carrierFreq: 76, filterCutoff: 240, pulseShape: 'defiant', resonance: 4.6, swirlMix: 0.4 }, + lifeSupport: { volume: 0.55, noiseType: 'pink', highpassFreq: 180, lowpassFreq: 1800, airflowModSpeed: 0.16, airflowModDepth: 0.16 }, + telemetry: { volume: 0.45, density: 0.5, era: 'ds9' } + }, + 'bio-lexx': { + id: 'bio-lexx', + name: 'The Lexx: Primary Organ Bridge', + era: 'lexx', + description: 'Massive biomechanical insectoid digestive engine drone, hollow chitin resonance, and organic respiration.', + hull: { volume: 0.85, baseFreq: 38, filterCutoff: 90, resonance: 2.8, noiseMix: 0.6, harmonicSpread: 1.02 }, + warp: { volume: 0.7, bpm: 34, carrierFreq: 48, filterCutoff: 140, pulseShape: 'nx', resonance: 3.4, swirlMix: 0.35 }, + lifeSupport: { volume: 0.6, noiseType: 'brown', highpassFreq: 120, lowpassFreq: 1300, airflowModSpeed: 0.06, airflowModDepth: 0.22 }, + telemetry: { volume: 0.35, density: 0.3, era: 'nx' } + }, + 'bio-vorlon': { + id: 'bio-vorlon', + name: 'Vorlon Cruiser: Sentient Core', + era: 'babylon5', + description: 'Telepathic singing crystal harmonics, undulating organic field harmonics, and luminous energy breath.', + hull: { volume: 0.65, baseFreq: 74, filterCutoff: 160, resonance: 3.8, noiseMix: 0.35, harmonicSpread: 1.05 }, + warp: { volume: 0.8, bpm: 52, carrierFreq: 104, filterCutoff: 260, pulseShape: 'voyager', resonance: 4.5, swirlMix: 0.5 }, + lifeSupport: { volume: 0.5, noiseType: 'pink', highpassFreq: 220, lowpassFreq: 2200, airflowModSpeed: 0.12, airflowModDepth: 0.1 }, + telemetry: { volume: 0.5, density: 0.6, era: 'voyager' } + }, + 'bio-wraith': { + id: 'bio-wraith', + name: 'Wraith Hive Ship: Throne Chamber', + era: 'stargate', + description: 'Groaning organic chitin hull growth, damp biomechanical respiration, and deep hive sub-bass.', + hull: { volume: 0.85, baseFreq: 34, filterCutoff: 85, resonance: 2.5, noiseMix: 0.65, harmonicSpread: 1.015 }, + warp: { volume: 0.6, bpm: 32, carrierFreq: 44, filterCutoff: 120, pulseShape: 'nx', resonance: 3.0, swirlMix: 0.25 }, + lifeSupport: { volume: 0.65, noiseType: 'brown', highpassFreq: 100, lowpassFreq: 1100, airflowModSpeed: 0.08, airflowModDepth: 0.18 }, + telemetry: { volume: 0.3, density: 0.25, era: 'nx' } + }, + 'bio-species-8472': { + id: 'bio-species-8472', + name: 'Species 8472: Fluidic Bioship', + era: 'startrek', + description: 'Fluidic space organic resonance, high-frequency biological firing capacitors, and pure genetic thrust.', + hull: { volume: 0.7, baseFreq: 68, filterCutoff: 150, resonance: 3.5, noiseMix: 0.45, harmonicSpread: 1.035 }, + warp: { volume: 0.85, bpm: 56, carrierFreq: 88, filterCutoff: 230, pulseShape: 'defiant', resonance: 4.4, swirlMix: 0.45 }, + lifeSupport: { volume: 0.5, noiseType: 'pink', highpassFreq: 200, lowpassFreq: 1900, airflowModSpeed: 0.14, airflowModDepth: 0.12 }, + telemetry: { volume: 0.45, density: 0.55, era: 'voyager' } + } + } + }, + + // ========================================================================= + // 5. RETRO FUTURE (Golden Age 1950s–1970s Sci-Fi) + // ========================================================================= + 'retrofuture': { + id: 'retrofuture', + name: 'RETRO FUTURE', + shortCode: 'RETRO-GOLD', + icon: '📡', + themeClass: 'theme-retrofuture', + headerTitle: 'GOLDEN AGE SPACE EXPLORATION MATRIX', + visualizer: 'retro-oscilloscope', + events: [ + { id: 'btn-centrifuge', label: 'CENTRIFUGE SPIN', type: 'centrifuge' }, + { id: 'btn-astrogator', label: 'ASTROGATOR SWEEP', type: 'astrogator' }, + { id: 'btn-hal-override', label: 'HAL 9000 OVERRIDE', type: 'hal' } + ], + soundboard: [ + { id: 'btn-hal-chime', label: 'HAL LOGIC CONFIRM' }, + { id: 'btn-relay-click', label: 'TAPE REEL SPOOL' }, + { id: 'btn-chirp-single', label: 'ASTROGATOR BEAT' }, + { id: 'btn-chirp-sweep', label: 'ANALOG GLISSANDO' }, + { id: 'btn-fast-return', label: 'MAGNETIC TAPE LATCH', span: 2 } + ], + presets: { + 'ret-jupiter-2': { + id: 'ret-jupiter-2', + name: 'Jupiter 2: Upper Deck Astrogator', + era: 'retro', + description: 'Classic sliding tone generator, continuous astrogator warbles, and smooth nuclear fusion reactor.', + hull: { volume: 0.65, baseFreq: 68, filterCutoff: 160, resonance: 3.2, noiseMix: 0.35, harmonicSpread: 1.04 }, + warp: { volume: 0.7, bpm: 52, carrierFreq: 96, filterCutoff: 230, pulseShape: 'tos', resonance: 4.0, swirlMix: 0.3 }, + lifeSupport: { volume: 0.5, noiseType: 'pink', highpassFreq: 240, lowpassFreq: 2200, airflowModSpeed: 0.1, airflowModDepth: 0.08 }, + telemetry: { volume: 0.65, density: 0.75, era: 'tos' } + }, + 'ret-liberator': { + id: 'ret-liberator', + name: 'The Liberator: Zen Flight Bridge', + era: 'blakes7', + description: 'Zen mainframe humming, exotic alien drive field harmonics, and crystalline telepathic circuit buzz.', + hull: { volume: 0.6, baseFreq: 60, filterCutoff: 135, resonance: 2.8, noiseMix: 0.4, harmonicSpread: 1.025 }, + warp: { volume: 0.65, bpm: 48, carrierFreq: 82, filterCutoff: 200, pulseShape: 'tos', resonance: 3.4, swirlMix: 0.35 }, + lifeSupport: { volume: 0.55, noiseType: 'pink', highpassFreq: 200, lowpassFreq: 2000, airflowModSpeed: 0.12, airflowModDepth: 0.1 }, + telemetry: { volume: 0.55, density: 0.6, era: 'tos' } + }, + 'ret-cygnus': { + id: 'ret-cygnus', + name: 'USS Cygnus: Victorian Engine Hall', + era: 'blackhole', + description: 'Gargantuan Victorian iron engine room, cavernous industrial echoes, and heavy drive pistons.', + hull: { volume: 0.85, baseFreq: 38, filterCutoff: 90, resonance: 2.5, noiseMix: 0.6, harmonicSpread: 1.015 }, + warp: { volume: 0.75, bpm: 36, carrierFreq: 52, filterCutoff: 140, pulseShape: 'nx', resonance: 3.2, swirlMix: 0.25 }, + lifeSupport: { volume: 0.6, noiseType: 'brown', highpassFreq: 120, lowpassFreq: 1300, airflowModSpeed: 0.08, airflowModDepth: 0.16 }, + telemetry: { volume: 0.35, density: 0.35, era: 'nx' } + }, + 'ret-palomino': { + id: 'ret-palomino', + name: 'USS Palomino: Deep Research Pod', + era: 'blackhole', + description: 'Compact research vessel cockpit, steady life-support airflow, and navigational telemetry.', + hull: { volume: 0.6, baseFreq: 54, filterCutoff: 120, resonance: 2.2, noiseMix: 0.45, harmonicSpread: 1.02 }, + warp: { volume: 0.5, bpm: 46, carrierFreq: 68, filterCutoff: 170, pulseShape: 'nx', resonance: 2.8, swirlMix: 0.2 }, + lifeSupport: { volume: 0.6, noiseType: 'pink', highpassFreq: 180, lowpassFreq: 1700, airflowModSpeed: 0.14, airflowModDepth: 0.1 }, + telemetry: { volume: 0.45, density: 0.5, era: 'nx' } + }, + 'ret-discovery-one': { + id: 'ret-discovery-one', + name: 'Discovery One: Habitation Centrifuge', + era: '2001', + description: 'Smooth artificial gravity centrifuge rotation hum, HAL 9000 soft logic hum, clean breathing air.', + hull: { volume: 0.65, baseFreq: 46, filterCutoff: 95, resonance: 1.8, noiseMix: 0.4, harmonicSpread: 1.01 }, + warp: { volume: 0.3, bpm: 30, carrierFreq: 50, filterCutoff: 110, pulseShape: 'tng', resonance: 2.0, swirlMix: 0.1 }, + lifeSupport: { volume: 0.7, noiseType: 'pink', highpassFreq: 150, lowpassFreq: 1500, airflowModSpeed: 0.08, airflowModDepth: 0.14 }, + telemetry: { volume: 0.3, density: 0.25, era: 'tng' } + }, + 'ret-dark-star': { + id: 'ret-dark-star', + name: 'Dark Star: Bomb Bay & Quarters', + era: 'darkstar', + description: 'Glitchy thermonuclear bomb circuits, tired life-support fans, and whistling hydraulic lines.', + hull: { volume: 0.7, baseFreq: 52, filterCutoff: 115, resonance: 2.6, noiseMix: 0.55, harmonicSpread: 1.03 }, + warp: { volume: 0.55, bpm: 42, carrierFreq: 64, filterCutoff: 160, pulseShape: 'nx', resonance: 3.0, swirlMix: 0.2 }, + lifeSupport: { volume: 0.65, noiseType: 'brown', highpassFreq: 160, lowpassFreq: 1600, airflowModSpeed: 0.15, airflowModDepth: 0.15 }, + telemetry: { volume: 0.45, density: 0.4, era: 'tos' } + }, + 'ret-gunstar': { + id: 'ret-gunstar', + name: 'Gunstar: Tactical Combat Cockpit', + era: 'starfighter', + description: 'Rylos starfighter turbo-thrusters, Death Blossom capacitors, and tactical computer chirps.', + hull: { volume: 0.7, baseFreq: 72, filterCutoff: 165, resonance: 3.4, noiseMix: 0.45, harmonicSpread: 1.035 }, + warp: { volume: 0.75, bpm: 68, carrierFreq: 88, filterCutoff: 240, pulseShape: 'defiant', resonance: 4.2, swirlMix: 0.35 }, + lifeSupport: { volume: 0.5, noiseType: 'white', highpassFreq: 220, lowpassFreq: 2100, airflowModSpeed: 0.18, airflowModDepth: 0.12 }, + telemetry: { volume: 0.6, density: 0.7, era: 'voyager' } + }, + 'ret-searcher': { + id: 'ret-searcher', + name: 'The Searcher: 25th Century Flagship', + era: 'buckrogers', + description: 'High-clarity navigation sweeps, smooth fusion drive purr, and crisp Directory computer tones.', + hull: { volume: 0.6, baseFreq: 62, filterCutoff: 140, resonance: 2.6, noiseMix: 0.38, harmonicSpread: 1.025 }, + warp: { volume: 0.6, bpm: 54, carrierFreq: 78, filterCutoff: 210, pulseShape: 'tng', resonance: 3.2, swirlMix: 0.3 }, + lifeSupport: { volume: 0.55, noiseType: 'pink', highpassFreq: 210, lowpassFreq: 2000, airflowModSpeed: 0.14, airflowModDepth: 0.1 }, + telemetry: { volume: 0.55, density: 0.65, era: 'tng' } + } + } + }, + + // ========================================================================= + // 6. MILITARY SPACE (Battlestar Galactica, Sulaco, Babylon Fleet) + // ========================================================================= + 'military': { + id: 'military', + name: 'MILITARY SPACE', + shortCode: 'FLEET-COMBAT', + icon: '⚔', + themeClass: 'theme-military', + headerTitle: 'TACTICAL BATTLESHIP & COMBAT COMMAND', + visualizer: 'warp-core', + events: [ + { id: 'btn-action-stations', label: 'ACTION STATIONS', type: 'action-stations' }, + { id: 'btn-ftl-jump', label: 'FTL JUMP', type: 'ftl-jump' }, + { id: 'btn-flak', label: 'FLAK BARRAGE', type: 'flak' } + ], + soundboard: [ + { id: 'btn-dradis-ping', label: 'DRADIS SCAN PING' }, + { id: 'btn-switch-pop', label: 'WEAPONS ARMED' }, + { id: 'btn-dock-clamp', label: 'BULKHEAD SEAL' }, + { id: 'btn-chirp-sweep', label: 'RADAR TARGET LOCK' }, + { id: 'btn-fast-return', label: 'TURBO-THRUSTER SWELL', span: 2 } + ], + presets: { + 'mil-sulaco': { + id: 'mil-sulaco', + name: 'USS Sulaco: Conestoga Hangar Deck', + era: 'aliens', + description: 'Heavy military sub-bass thrum, massive refrigeration ducts, dropship bay hydraulics.', + hull: { volume: 0.8, baseFreq: 44, filterCutoff: 105, resonance: 3.0, noiseMix: 0.55, harmonicSpread: 1.02 }, + warp: { volume: 0.6, bpm: 38, carrierFreq: 58, filterCutoff: 150, pulseShape: 'nx', resonance: 3.2, swirlMix: 0.25 }, + lifeSupport: { volume: 0.65, noiseType: 'brown', highpassFreq: 140, lowpassFreq: 1500, airflowModSpeed: 0.1, airflowModDepth: 0.14 }, + telemetry: { volume: 0.4, density: 0.45, era: 'nx' } + }, + 'mil-galactica': { + id: 'mil-galactica', + name: 'Battlestar Galactica: Combat Information Center (CIC)', + era: 'bsg', + description: 'Armored titanium hull vibration, plotting table hum, DRADIS sonar sweeps, and distant flak rumble.', + hull: { volume: 0.75, baseFreq: 48, filterCutoff: 115, resonance: 2.8, noiseMix: 0.5, harmonicSpread: 1.025 }, + warp: { volume: 0.7, bpm: 46, carrierFreq: 64, filterCutoff: 180, pulseShape: 'nx', resonance: 3.5, swirlMix: 0.3 }, + lifeSupport: { volume: 0.6, noiseType: 'pink', highpassFreq: 170, lowpassFreq: 1700, airflowModSpeed: 0.12, airflowModDepth: 0.12 }, + telemetry: { volume: 0.5, density: 0.55, era: 'ds9' } + }, + 'mil-white-star': { + id: 'mil-white-star', + name: 'White Star: Minbari/Vorlon Hybrid Bridge', + era: 'babylon5', + description: 'Gravimetric drive propulsion whisper, high-frequency organic bio-armor hum, tactical agility.', + hull: { volume: 0.6, baseFreq: 66, filterCutoff: 145, resonance: 3.2, noiseMix: 0.35, harmonicSpread: 1.035 }, + warp: { volume: 0.8, bpm: 58, carrierFreq: 88, filterCutoff: 230, pulseShape: 'voyager', resonance: 4.2, swirlMix: 0.4 }, + lifeSupport: { volume: 0.5, noiseType: 'pink', highpassFreq: 210, lowpassFreq: 2100, airflowModSpeed: 0.15, airflowModDepth: 0.1 }, + telemetry: { volume: 0.5, density: 0.65, era: 'voyager' } + }, + 'mil-agamemnon': { + id: 'mil-agamemnon', + name: 'EAS Agamemnon: Omega Destroyer Bridge', + era: 'babylon5', + description: 'Rotating habitat section centrifugal vibration, plasma cannon grid capacitors, EarthForce tactical systems.', + hull: { volume: 0.8, baseFreq: 46, filterCutoff: 110, resonance: 3.0, noiseMix: 0.55, harmonicSpread: 1.02 }, + warp: { volume: 0.7, bpm: 42, carrierFreq: 60, filterCutoff: 170, pulseShape: 'defiant', resonance: 3.6, swirlMix: 0.3 }, + lifeSupport: { volume: 0.6, noiseType: 'pink', highpassFreq: 180, lowpassFreq: 1800, airflowModSpeed: 0.14, airflowModDepth: 0.12 }, + telemetry: { volume: 0.45, density: 0.5, era: 'ds9' } + }, + 'mil-andromeda': { + id: 'mil-andromeda', + name: 'Andromeda Ascendant: Command Deck', + era: 'andromeda', + description: 'Slipstream drive resonance, anti-proton missile magazine servos, high-tech AI bridge acoustics.', + hull: { volume: 0.7, baseFreq: 56, filterCutoff: 130, resonance: 2.8, noiseMix: 0.45, harmonicSpread: 1.025 }, + warp: { volume: 0.75, bpm: 54, carrierFreq: 76, filterCutoff: 210, pulseShape: 'tng', resonance: 3.8, swirlMix: 0.35 }, + lifeSupport: { volume: 0.55, noiseType: 'pink', highpassFreq: 200, lowpassFreq: 1900, airflowModSpeed: 0.12, airflowModDepth: 0.1 }, + telemetry: { volume: 0.5, density: 0.6, era: 'tng' } + }, + 'mil-excalibur': { + id: 'mil-excalibur', + name: 'Excalibur: Victory-Class Heavy Combat Core', + era: 'crusade', + description: 'Advanced hybrid gravitic engines, quantum discharge weapons capacitors, and deep hull resonance.', + hull: { volume: 0.8, baseFreq: 50, filterCutoff: 125, resonance: 3.4, noiseMix: 0.5, harmonicSpread: 1.03 }, + warp: { volume: 0.85, bpm: 50, carrierFreq: 72, filterCutoff: 220, pulseShape: 'defiant', resonance: 4.0, swirlMix: 0.38 }, + lifeSupport: { volume: 0.55, noiseType: 'pink', highpassFreq: 190, lowpassFreq: 1800, airflowModSpeed: 0.14, airflowModDepth: 0.12 }, + telemetry: { volume: 0.45, density: 0.55, era: 'ds9' } + }, + 'mil-viper': { + id: 'mil-viper', + name: 'Colonial Viper Mk II: Cockpit Atmosphere', + era: 'bsg', + description: 'High-speed air rush, twin turbo-thruster whine, pilot G-suit regulator, and DRADIS radar sweeps.', + hull: { volume: 0.65, baseFreq: 76, filterCutoff: 175, resonance: 3.2, noiseMix: 0.4, harmonicSpread: 1.04 }, + warp: { volume: 0.7, bpm: 72, carrierFreq: 94, filterCutoff: 250, pulseShape: 'voyager', resonance: 4.2, swirlMix: 0.3 }, + lifeSupport: { volume: 0.65, noiseType: 'white', highpassFreq: 240, lowpassFreq: 2600, airflowModSpeed: 0.22, airflowModDepth: 0.18 }, + telemetry: { volume: 0.6, density: 0.7, era: 'voyager' } + } + } + }, + + // ========================================================================= + // 7. DEEP SPACE (Generation Ships, Exploration & Eldritch Voids) + // ========================================================================= + 'deepspace': { + id: 'deepspace', + name: 'DEEP SPACE', + shortCode: 'VOID-EXPLORE', + icon: '🌌', + themeClass: 'theme-deepspace', + headerTitle: 'LONG-RANGE INTERSTELLAR EXPLORATION & ANOMALIES', + visualizer: 'singularity-core', + events: [ + { id: 'btn-singularity', label: 'SINGULARITY ENGAGE', type: 'singularity' }, + { id: 'btn-gravity-pulse', label: 'GRAVITY PULSE', type: 'gravity' }, + { id: 'btn-solar-swell', label: 'SOLAR SHIELD SWELL', type: 'solar' } + ], + soundboard: [ + { id: 'btn-telepathic-chime', label: 'VOID RESONANCE' }, + { id: 'btn-switch-pop', label: 'GRAVITY INJECTOR' }, + { id: 'btn-dock-clamp', label: 'HULL STRAIN MOAN' }, + { id: 'btn-chirp-sweep', label: 'TACHYON SENSOR PING' }, + { id: 'btn-relay-click', label: 'CRYO-SUITE REGULATOR', span: 2 } + ], + presets: { + 'deep-avalon': { + id: 'deep-avalon', + name: 'The Avalon: Interstellar Cruise Concourse', + era: 'passengers', + description: '120-year journey whisper-quiet fusion core, ion shield deflection hiss, elegant concourse acoustics.', + hull: { volume: 0.6, baseFreq: 44, filterCutoff: 95, resonance: 1.8, noiseMix: 0.4, harmonicSpread: 1.015 }, + warp: { volume: 0.45, bpm: 40, carrierFreq: 54, filterCutoff: 130, pulseShape: 'tng', resonance: 2.5, swirlMix: 0.25 }, + lifeSupport: { volume: 0.7, noiseType: 'pink', highpassFreq: 160, lowpassFreq: 1600, airflowModSpeed: 0.08, airflowModDepth: 0.12 }, + telemetry: { volume: 0.35, density: 0.3, era: 'tng' } + }, + 'deep-nightflyer': { + id: 'deep-nightflyer', + name: 'The Nightflyer: Telepathic Corridor', + era: 'grrm', + description: 'Dark, haunted psychic hull vibration, distant solitary life-support, and deep cold void reverberation.', + hull: { volume: 0.75, baseFreq: 38, filterCutoff: 88, resonance: 2.8, noiseMix: 0.55, harmonicSpread: 1.02 }, + warp: { volume: 0.5, bpm: 34, carrierFreq: 46, filterCutoff: 120, pulseShape: 'nx', resonance: 3.0, swirlMix: 0.2 }, + lifeSupport: { volume: 0.55, noiseType: 'brown', highpassFreq: 120, lowpassFreq: 1300, airflowModSpeed: 0.07, airflowModDepth: 0.16 }, + telemetry: { volume: 0.3, density: 0.25, era: 'ds9' } + }, + 'deep-ascension': { + id: 'deep-ascension', + name: 'USS Ascension: Generation Ship Promenade', + era: 'ascension', + description: 'Mid-century generation vessel mechanical flywheels, centrifugal gravity hum, and air scrubbers.', + hull: { volume: 0.7, baseFreq: 48, filterCutoff: 105, resonance: 2.2, noiseMix: 0.48, harmonicSpread: 1.02 }, + warp: { volume: 0.4, bpm: 36, carrierFreq: 56, filterCutoff: 135, pulseShape: 'nx', resonance: 2.6, swirlMix: 0.2 }, + lifeSupport: { volume: 0.65, noiseType: 'pink', highpassFreq: 150, lowpassFreq: 1500, airflowModSpeed: 0.1, airflowModDepth: 0.14 }, + telemetry: { volume: 0.35, density: 0.3, era: 'nx' } + }, + 'deep-ark-one': { + id: 'deep-ark-one', + name: 'Ark One: Evacuation Ark Bridge', + era: 'theark', + description: 'Structural stress fatigue hum, recycling filtration loops, and automated life-support telemetry.', + hull: { volume: 0.7, baseFreq: 52, filterCutoff: 115, resonance: 2.5, noiseMix: 0.5, harmonicSpread: 1.025 }, + warp: { volume: 0.5, bpm: 42, carrierFreq: 62, filterCutoff: 160, pulseShape: 'tng', resonance: 2.8, swirlMix: 0.2 }, + lifeSupport: { volume: 0.6, noiseType: 'pink', highpassFreq: 170, lowpassFreq: 1700, airflowModSpeed: 0.12, airflowModDepth: 0.12 }, + telemetry: { volume: 0.4, density: 0.4, era: 'voyager' } + }, + 'deep-icarus-ii': { + id: 'deep-icarus-ii', + name: 'Icarus II: Solar Shield Core', + era: 'sunshine', + description: 'Massive golden heat shield thermal expansion creaks, solar wind roar, and silent mainframe.', + hull: { volume: 0.8, baseFreq: 40, filterCutoff: 95, resonance: 3.0, noiseMix: 0.6, harmonicSpread: 1.02 }, + warp: { volume: 0.5, bpm: 32, carrierFreq: 48, filterCutoff: 125, pulseShape: 'nx', resonance: 3.2, swirlMix: 0.2 }, + lifeSupport: { volume: 0.65, noiseType: 'brown', highpassFreq: 130, lowpassFreq: 1400, airflowModSpeed: 0.08, airflowModDepth: 0.18 }, + telemetry: { volume: 0.35, density: 0.3, era: 'tng' } + }, + 'deep-event-horizon': { + id: 'deep-event-horizon', + name: 'Event Horizon: Gravity Singularity Core', + era: 'eventhorizon', + description: 'Gravity drive singularity pulsation, extradimensional groaning hull, and deep chilling void drone.', + hull: { volume: 0.88, baseFreq: 32, filterCutoff: 80, resonance: 3.5, noiseMix: 0.65, harmonicSpread: 1.015 }, + warp: { volume: 0.85, bpm: 28, carrierFreq: 42, filterCutoff: 130, pulseShape: 'nx', resonance: 4.2, swirlMix: 0.35 }, + lifeSupport: { volume: 0.55, noiseType: 'brown', highpassFreq: 100, lowpassFreq: 1100, airflowModSpeed: 0.06, airflowModDepth: 0.22 }, + telemetry: { volume: 0.3, density: 0.25, era: 'nx' } + }, + 'deep-lewis-clark': { + id: 'deep-lewis-clark', + name: 'Lewis & Clark: Rescue Cutter Bridge', + era: 'eventhorizon', + description: 'Compact search and rescue vessel propulsion, tight pressurized air scrubbers, and emergency comms.', + hull: { volume: 0.65, baseFreq: 56, filterCutoff: 125, resonance: 2.4, noiseMix: 0.48, harmonicSpread: 1.025 }, + warp: { volume: 0.55, bpm: 48, carrierFreq: 68, filterCutoff: 175, pulseShape: 'defiant', resonance: 3.0, swirlMix: 0.25 }, + lifeSupport: { volume: 0.6, noiseType: 'pink', highpassFreq: 180, lowpassFreq: 1800, airflowModSpeed: 0.14, airflowModDepth: 0.12 }, + telemetry: { volume: 0.4, density: 0.4, era: 'ds9' } + } + } + }, + + // ========================================================================= + // 8. OUTLAW / FRONTIER (Cowboy Bebop, Milano, Outlaw Star, Firefly) + // ========================================================================= + 'outlaw': { + id: 'outlaw', + name: 'OUTLAW / FRONTIER', + shortCode: 'OUTLAW', + icon: '🚀', + themeClass: 'theme-outlaw', + headerTitle: 'BOUNTY HUNTERS, SMUGGLERS & SALVAGERS', + visualizer: 'warp-core', + events: [ + { id: 'btn-afterburner', label: 'AFTERBURNER ENGAGE', type: 'afterburner' }, + { id: 'btn-pirate-scramble', label: 'PIRATE SCRAMBLE', type: 'scramble' }, + { id: 'btn-sub-drive', label: 'SUB-DRIVE SWELL', type: 'subdrive' } + ], + soundboard: [ + { id: 'btn-tape-clunk', label: 'CASSETTE DECK CLUNK' }, + { id: 'btn-switch-pop', label: 'GRAPPLER ARM MOTOR' }, + { id: 'btn-dock-clamp', label: 'FUEL LINE VALVE' }, + { id: 'btn-chirp-sweep', label: 'RADIO STATIC BURST' }, + { id: 'btn-fast-return', label: 'TURBO JET BURN', span: 2 } + ], + presets: { + 'out-betty': { + id: 'out-betty', + name: 'The Betty: Salvage Freighter Mess', + era: 'alien4', + description: 'Smuggler ship hydraulic clutches, fuel line clatter, pirate radio static, and greasy engine rumblings.', + hull: { volume: 0.75, baseFreq: 50, filterCutoff: 115, resonance: 2.8, noiseMix: 0.55, harmonicSpread: 1.03 }, + warp: { volume: 0.7, bpm: 46, carrierFreq: 64, filterCutoff: 180, pulseShape: 'nx', resonance: 3.4, swirlMix: 0.3 }, + lifeSupport: { volume: 0.6, noiseType: 'brown', highpassFreq: 150, lowpassFreq: 1500, airflowModSpeed: 0.12, airflowModDepth: 0.15 }, + telemetry: { volume: 0.4, density: 0.35, era: 'nx' } + }, + 'out-bebop': { + id: 'out-bebop', + name: 'Bebop: Living Quarters & Hangar', + era: 'cowboybebop', + description: 'Converted fishing trawler engine drone, lounge room resonance, and vintage vacuum tube telemetry.', + hull: { volume: 0.7, baseFreq: 46, filterCutoff: 105, resonance: 2.4, noiseMix: 0.48, harmonicSpread: 1.02 }, + warp: { volume: 0.55, bpm: 42, carrierFreq: 58, filterCutoff: 150, pulseShape: 'nx', resonance: 2.8, swirlMix: 0.25 }, + lifeSupport: { volume: 0.6, noiseType: 'pink', highpassFreq: 160, lowpassFreq: 1600, airflowModSpeed: 0.1, airflowModDepth: 0.12 }, + telemetry: { volume: 0.35, density: 0.3, era: 'tos' } + }, + 'out-outlaw-star': { + id: 'out-outlaw-star', + name: 'Outlaw Star: Grappler Bridge', + era: 'outlawstar', + description: 'Grappler ship sub-drive hum, caster capacitor whine, and high-energy combat bridge acoustics.', + hull: { volume: 0.7, baseFreq: 64, filterCutoff: 150, resonance: 3.2, noiseMix: 0.45, harmonicSpread: 1.035 }, + warp: { volume: 0.75, bpm: 60, carrierFreq: 82, filterCutoff: 230, pulseShape: 'defiant', resonance: 4.0, swirlMix: 0.35 }, + lifeSupport: { volume: 0.55, noiseType: 'pink', highpassFreq: 200, lowpassFreq: 2000, airflowModSpeed: 0.16, airflowModDepth: 0.14 }, + telemetry: { volume: 0.5, density: 0.6, era: 'voyager' } + }, + 'out-scorpio': { + id: 'out-scorpio', + name: 'Scorpio: Wanderer Salvage Vessel', + era: 'blakes7', + description: 'Grimy salvage vessel wanderer sub-light drive, overheating power packs, and rattling cooling lines.', + hull: { volume: 0.75, baseFreq: 52, filterCutoff: 120, resonance: 2.6, noiseMix: 0.5, harmonicSpread: 1.025 }, + warp: { volume: 0.6, bpm: 46, carrierFreq: 68, filterCutoff: 175, pulseShape: 'tos', resonance: 3.2, swirlMix: 0.25 }, + lifeSupport: { volume: 0.55, noiseType: 'pink', highpassFreq: 180, lowpassFreq: 1800, airflowModSpeed: 0.12, airflowModDepth: 0.12 }, + telemetry: { volume: 0.45, density: 0.45, era: 'tos' } + }, + 'out-marauder': { + id: 'out-marauder', + name: 'The Marauder: Havoc Shuttle Bridge', + era: 'starwars', + description: 'Stripped shuttle sub-light propulsion, modified hyperdrive coils, and mercenary comm chatter.', + hull: { volume: 0.65, baseFreq: 60, filterCutoff: 135, resonance: 2.8, noiseMix: 0.42, harmonicSpread: 1.03 }, + warp: { volume: 0.7, bpm: 52, carrierFreq: 78, filterCutoff: 200, pulseShape: 'defiant', resonance: 3.6, swirlMix: 0.3 }, + lifeSupport: { volume: 0.55, noiseType: 'pink', highpassFreq: 190, lowpassFreq: 1900, airflowModSpeed: 0.14, airflowModDepth: 0.1 }, + telemetry: { volume: 0.45, density: 0.5, era: 'ds9' } + }, + 'out-milano': { + id: 'out-milano', + name: 'The Milano: M-Ship Cockpit Lounge', + era: 'guardians', + description: 'Dual atmospheric jet-burners, cassette player noise floor, kinetic thrusters, and playful telemetry.', + hull: { volume: 0.65, baseFreq: 62, filterCutoff: 140, resonance: 3.0, noiseMix: 0.4, harmonicSpread: 1.03 }, + warp: { volume: 0.7, bpm: 64, carrierFreq: 84, filterCutoff: 220, pulseShape: 'voyager', resonance: 3.8, swirlMix: 0.35 }, + lifeSupport: { volume: 0.6, noiseType: 'pink', highpassFreq: 210, lowpassFreq: 2200, airflowModSpeed: 0.16, airflowModDepth: 0.12 }, + telemetry: { volume: 0.55, density: 0.65, era: 'voyager' } + } + } + }, + + // ========================================================================= + // 9. SPACE STATIONS (Orbital Habitats, Megastructures & Outposts) + // ========================================================================= + 'spacestations': { + id: 'spacestations', + name: 'SPACE STATIONS', + shortCode: 'STATIONS', + icon: '🛰', + themeClass: 'theme-spacestations', + headerTitle: 'ORBITAL HABITATS & COLONIAL OUTPOSTS', + visualizer: 'industrial-reactor', + events: [ + { id: 'btn-station-rotation', label: 'STATION ROTATION DWELL', type: 'rotation' }, + { id: 'btn-docking-seq', label: 'DOCKING SEQUENCE', type: 'docking' }, + { id: 'btn-depressurize', label: 'AIRLOCK DECOMPRESSION', type: 'decompress' } + ], + soundboard: [ + { id: 'btn-air-handler', label: 'AIR HANDLER THUD' }, + { id: 'btn-dock-clamp', label: 'DOCKING CLAMP LATCH' }, + { id: 'btn-tram-gong', label: 'TRAM DEPARTURE GONG' }, + { id: 'btn-relay-click', label: 'COMM PA BEAT' }, + { id: 'btn-telepathic-chime', label: 'ZOCALO PLAZA CHIME', span: 2 } + ], + presets: { + 'sta-babylon-5': { + id: 'sta-babylon-5', + name: 'Babylon 5: Core Control & Zocalo', + era: 'babylon5', + description: '5-mile O\'Neill cylinder centrifugal rotation rumble, distant commerce hub chatter, and docking bay drones.', + hull: { volume: 0.8, baseFreq: 40, filterCutoff: 95, resonance: 2.5, noiseMix: 0.6, harmonicSpread: 1.015 }, + warp: { volume: 0.45, bpm: 34, carrierFreq: 50, filterCutoff: 130, pulseShape: 'nx', resonance: 2.8, swirlMix: 0.25 }, + lifeSupport: { volume: 0.7, noiseType: 'pink', highpassFreq: 140, lowpassFreq: 1600, airflowModSpeed: 0.08, airflowModDepth: 0.16 }, + telemetry: { volume: 0.45, density: 0.45, era: 'ds9' } + }, + 'sta-moonbase-alpha': { + id: 'sta-moonbase-alpha', + name: 'Moonbase Alpha: Main Mission Control', + era: 'space1999', + description: 'Subterranean lunar complex life-support, Main Mission desk communications, and nuclear power plant hum.', + hull: { volume: 0.65, baseFreq: 54, filterCutoff: 125, resonance: 2.6, noiseMix: 0.42, harmonicSpread: 1.02 }, + warp: { volume: 0.4, bpm: 44, carrierFreq: 64, filterCutoff: 160, pulseShape: 'tos', resonance: 2.8, swirlMix: 0.2 }, + lifeSupport: { volume: 0.6, noiseType: 'pink', highpassFreq: 180, lowpassFreq: 1800, airflowModSpeed: 0.1, airflowModDepth: 0.12 }, + telemetry: { volume: 0.55, density: 0.6, era: 'tos' } + }, + 'sta-sevastopol': { + id: 'sta-sevastopol', + name: 'Sevastopol Station: Habitation Deck', + era: 'alien-isolation', + description: 'Decaying orbital freeport, flickering conduits, distant tramway rumble, and automated emergency comms.', + hull: { volume: 0.85, baseFreq: 44, filterCutoff: 100, resonance: 3.0, noiseMix: 0.65, harmonicSpread: 1.02 }, + warp: { volume: 0.5, bpm: 36, carrierFreq: 52, filterCutoff: 140, pulseShape: 'nx', resonance: 3.2, swirlMix: 0.25 }, + lifeSupport: { volume: 0.6, noiseType: 'brown', highpassFreq: 130, lowpassFreq: 1400, airflowModSpeed: 0.1, airflowModDepth: 0.18 }, + telemetry: { volume: 0.4, density: 0.35, era: 'nx' } + }, + 'sta-tycho': { + id: 'sta-tycho', + name: 'Tycho Station: Asteroid Construction Bay', + era: 'expanse', + description: 'Massive spinning hollow asteroid dock, robotic construction gantries, and high-pressure plasma welding hum.', + hull: { volume: 0.8, baseFreq: 48, filterCutoff: 110, resonance: 2.8, noiseMix: 0.55, harmonicSpread: 1.025 }, + warp: { volume: 0.65, bpm: 42, carrierFreq: 66, filterCutoff: 180, pulseShape: 'defiant', resonance: 3.5, swirlMix: 0.3 }, + lifeSupport: { volume: 0.65, noiseType: 'pink', highpassFreq: 160, lowpassFreq: 1700, airflowModSpeed: 0.12, airflowModDepth: 0.14 }, + telemetry: { volume: 0.45, density: 0.5, era: 'voyager' } + }, + 'sta-ceres': { + id: 'sta-ceres', + name: 'Ceres Station: Sub-Crustal Tunnels', + era: 'expanse', + description: 'Subterranean spin-gravity rumble, ice-processing plant vibration, and Belter station ventilation ducts.', + hull: { volume: 0.85, baseFreq: 42, filterCutoff: 95, resonance: 2.6, noiseMix: 0.62, harmonicSpread: 1.02 }, + warp: { volume: 0.45, bpm: 38, carrierFreq: 54, filterCutoff: 135, pulseShape: 'nx', resonance: 3.0, swirlMix: 0.2 }, + lifeSupport: { volume: 0.7, noiseType: 'brown', highpassFreq: 120, lowpassFreq: 1400, airflowModSpeed: 0.08, airflowModDepth: 0.16 }, + telemetry: { volume: 0.35, density: 0.35, era: 'nx' } + }, + 'sta-gateway': { + id: 'sta-gateway', + name: 'Gateway Station: Quarantine Transfer Deck', + era: 'aliens', + description: 'Earth orbital transfer habitat, vacuum dockyard beacons, and high-volume environmental air handlers.', + hull: { volume: 0.7, baseFreq: 46, filterCutoff: 105, resonance: 2.2, noiseMix: 0.5, harmonicSpread: 1.02 }, + warp: { volume: 0.4, bpm: 40, carrierFreq: 58, filterCutoff: 145, pulseShape: 'nx', resonance: 2.6, swirlMix: 0.2 }, + lifeSupport: { volume: 0.65, noiseType: 'pink', highpassFreq: 160, lowpassFreq: 1600, airflowModSpeed: 0.1, airflowModDepth: 0.14 }, + telemetry: { volume: 0.45, density: 0.45, era: 'tng' } + } + } + }, + + // ========================================================================= + // 10. COMEDY / ADVENTURE (The Orville, Galaxy Quest, Hitchhiker's, Spaceballs) + // ========================================================================= + 'comedy': { + id: 'comedy', + name: 'COMEDY / ADVENTURE', + shortCode: 'ADVENTURE', + icon: '✨', + themeClass: 'theme-comedy', + headerTitle: 'WHIMSICAL & RETRO SCI-FI EXPLORATION', + visualizer: 'warp-core', + events: [ + { id: 'btn-ludicrous', label: 'LUDICROUS SPEED', type: 'ludicrous' }, + { id: 'btn-improbability', label: 'IMPROBABILITY FLIP', type: 'improbability' }, + { id: 'btn-quantum-jump', label: 'QUANTUM JUMP', type: 'quantum' } + ], + soundboard: [ + { id: 'btn-cheerful-door', label: 'CHEERFUL DOOR SIGH' }, + { id: 'btn-beryllium-pulse', label: 'BERYLLIUM CORE PULSE' }, + { id: 'btn-tea-machine', label: 'TEA DISPENSER GURGLE' }, + { id: 'btn-chirp-ack', label: 'ORVILLE ACK CHIRP' }, + { id: 'btn-sonic', label: 'OMEGA-13 CHARGE', span: 2 } + ], + presets: { + 'com-orville': { + id: 'com-orville', + name: 'USS Orville: Command Bridge', + era: 'orville', + description: 'Clean, uplifting quantum drive hum, gentle ventilation, and cheerful synthesizer console chirps.', + hull: { volume: 0.6, baseFreq: 58, filterCutoff: 130, resonance: 2.4, noiseMix: 0.35, harmonicSpread: 1.025 }, + warp: { volume: 0.65, bpm: 52, carrierFreq: 74, filterCutoff: 200, pulseShape: 'tng', resonance: 3.4, swirlMix: 0.3 }, + lifeSupport: { volume: 0.6, noiseType: 'pink', highpassFreq: 200, lowpassFreq: 1900, airflowModSpeed: 0.12, airflowModDepth: 0.1 }, + telemetry: { volume: 0.5, density: 0.65, era: 'tng' } + }, + 'com-protector': { + id: 'com-protector', + name: 'NSEA Protector: Beryllium Engine Room', + era: 'galaxyquest', + description: 'Beryllium sphere reactor thrum, Omega-13 capacitor charge, and earnest digital bridge beeps.', + hull: { volume: 0.7, baseFreq: 64, filterCutoff: 145, resonance: 3.2, noiseMix: 0.45, harmonicSpread: 1.035 }, + warp: { volume: 0.8, bpm: 56, carrierFreq: 82, filterCutoff: 230, pulseShape: 'defiant', resonance: 4.2, swirlMix: 0.35 }, + lifeSupport: { volume: 0.55, noiseType: 'pink', highpassFreq: 220, lowpassFreq: 2100, airflowModSpeed: 0.14, airflowModDepth: 0.12 }, + telemetry: { volume: 0.55, density: 0.6, era: 'voyager' } + }, + 'com-heart-of-gold': { + id: 'com-heart-of-gold', + name: 'Heart of Gold: Improbability Bridge', + era: 'hitchhikers', + description: 'Infinite Improbability Drive reality-bending swooshes, smugly cheerful doors, and Eddie the computer.', + hull: { volume: 0.6, baseFreq: 52, filterCutoff: 120, resonance: 2.5, noiseMix: 0.4, harmonicSpread: 1.02 }, + warp: { volume: 0.7, bpm: 60, carrierFreq: 70, filterCutoff: 210, pulseShape: 'voyager', resonance: 3.8, swirlMix: 0.4 }, + lifeSupport: { volume: 0.65, noiseType: 'white', highpassFreq: 180, lowpassFreq: 2400, airflowModSpeed: 0.15, airflowModDepth: 0.14 }, + telemetry: { volume: 0.5, density: 0.6, era: 'tng' } + }, + 'com-camden-lock': { + id: 'com-camden-lock', + name: 'HMS Camden Lock: Flight Deck & Mess', + era: 'hyperdrive', + description: 'British sub-light engine chug, electric kettle bubbling, and galactic council bureaucracy tones.', + hull: { volume: 0.65, baseFreq: 48, filterCutoff: 105, resonance: 2.2, noiseMix: 0.48, harmonicSpread: 1.02 }, + warp: { volume: 0.45, bpm: 38, carrierFreq: 58, filterCutoff: 140, pulseShape: 'nx', resonance: 2.6, swirlMix: 0.2 }, + lifeSupport: { volume: 0.6, noiseType: 'pink', highpassFreq: 160, lowpassFreq: 1600, airflowModSpeed: 0.1, airflowModDepth: 0.12 }, + telemetry: { volume: 0.4, density: 0.4, era: 'nx' } + }, + 'com-spaceball-one': { + id: 'com-spaceball-one', + name: 'Spaceball One: Mega-Warship Bridge', + era: 'spaceballs', + description: 'Absurdly huge mega-engine roar, ludicrous speed accelerator whine, and helmet intercom static.', + hull: { volume: 0.85, baseFreq: 64, filterCutoff: 150, resonance: 3.5, noiseMix: 0.55, harmonicSpread: 1.04 }, + warp: { volume: 0.9, bpm: 66, carrierFreq: 86, filterCutoff: 260, pulseShape: 'defiant', resonance: 4.4, swirlMix: 0.4 }, + lifeSupport: { volume: 0.6, noiseType: 'pink', highpassFreq: 220, lowpassFreq: 2200, airflowModSpeed: 0.16, airflowModDepth: 0.15 }, + telemetry: { volume: 0.5, density: 0.55, era: 'tos' } + } + } + } +}; + +window.UniverseRegistry = UniverseRegistry; + + +/** + * LCARS Audio Visualizer & Warp Core Intermix Chamber Display + * Renders real-time audio spectrum, waveform oscilloscope, and pulsing warp core animation. + */ + diff --git a/js/observation-bezels.js b/js/observation-bezels.js new file mode 100644 index 0000000..c796e7c --- /dev/null +++ b/js/observation-bezels.js @@ -0,0 +1,270 @@ +/** + * Procedural SVG Viewport Frames & Bezels for Observation Mode + */ +window.ObservationBezels = { + getViewportFrameSvg(universeId, presetId, engine) { + if (universeId === 'spacestations') { + // Space Stations has its own authentic native station viewport in observationStage! + return ''; + } + + if (universeId === 'starfleet') { + let era = 'tng'; + const preset = (typeof StarshipPresets !== 'undefined' && presetId) ? StarshipPresets[presetId] : null; + if (preset && preset.era) era = preset.era; + + if (era === 'tos') { + // TOS Original Series - hexagonal bridge viewscreen bezel + return ` + + + + + + + + + + + + + + ${(engine ? engine.showPillars : false) ? ` + + + ` : ''} + + U.S.S. ENTERPRISE NCC-1701 // BRIDGE VIEWSCREEN + + `; + } + + if (era === 'voyager') { + // Voyager Intrepid Class - sleeker rounded modern arch, cyan accent + return ` + + + + + + + + + + + + + + ${(engine ? engine.showPillars : false) ? ` + + + ` : ''} + + + + USS VOYAGER // BRIDGE VIEWPORT + + + `; + } + + if (era === 'ds9') { + // Defiant Class / DS9 Ops - angular, aggressive warship framing + return ` + + + + + + + + + + + + + USS DEFIANT // TACTICAL BRIDGE VIEWPORT + + `; + } + + if (era === 'nx') { + // Enterprise NX-01 - early, industrial, submarine-like bulkhead + return ` + + + + + + + + + + + + + ENTERPRISE NX-01 // COMMAND BRIDGE VIEWPORT + + `; + } + // era === 'tng' (or unrecognized) falls through to the default Ten Forward arch below. + } + + if (universeId === 'industrial' || universeId === 'outlaw') { + // Heavy Industrial Reinforced Bulkhead & Riveted Blast Shutter Framing + return ` + + + + + + + + + + + + + + + ${(engine ? engine.showPillars : false) ? ` + + + + ` : ''} + + + + + + + HEAVY INDUSTRIAL VIEWPORT // DECK 04 CARGO GANTRY + + `; + } + + if (universeId === 'whoniverse') { + // Gallifreyan Observatory Roundel Viewing Portal / TARDIS Frame + // Console era (classic/revival/modern) tints the rings and label. + let era = 'revival'; + const wUniverse = (typeof UniverseRegistry !== 'undefined') ? UniverseRegistry['whoniverse'] : null; + const preset = (wUniverse && wUniverse.presets && presetId) ? wUniverse.presets[presetId] : null; + if (preset && preset.era) era = preset.era; + + let ringA = '#d4af37', ringB = '#00e5ff', dash = '16 12', labelColor = '#d4af37'; + let label = 'T.A.R.D.I.S. TEMPORAL VORTEX OBSERVATION PORTAL'; + if (era === 'classic') { + ringA = '#b45309'; ringB = '#f5deb3'; dash = '22 10'; labelColor = '#f5deb3'; + label = 'TYPE 40 CLASSIC CONSOLE // TEMPORAL VIEWPORT'; + } else if (era === 'modern') { + ringA = '#e2e8f0'; ringB = '#94a3b8'; dash = '4 6'; labelColor = '#e2e8f0'; + label = 'INFINITE WHITE // TEMPORAL OBSERVATION PORTAL'; + } + + return ` + + + + + + + ${label} + + `; + } + + if (universeId === 'deepspace') { + // Terok Nor / DS9 Arched Station Viewport overlooking Docking Pylons + return ` + + + + + + + ${(engine ? engine.showPillars : false) ? ` + + + + ` : ''} + + DEEP SPACE STATION // PROMENADE OBSERVATION DECK + + `; + } + + if (universeId === 'bioships' || universeId === 'retrofuture' || universeId === 'military' || universeId === 'comedy') { + // These four universes previously had no case here at all and silently fell through + // to the Starfleet default below, which hardcodes "USS ENTERPRISE" into the plaque -- + // showing the wrong ship name in every one of these themes' Observation view. + // Fix: reuse the same bulkhead shape (it already themes correctly via the CSS + // accent variables set per universe), but pull the REAL ship/deck name for the + // plaque text from this universe's own preset data, same source the header uses. + const universe = UniverseRegistry[universeId]; + const preset = (universe && universe.presets && presetId) ? universe.presets[presetId] : null; + const plaqueText = preset + ? `${preset.name.toUpperCase()} // OBSERVATION DECK` + : `${universe ? universe.name : universeId.toUpperCase()} // OBSERVATION DECK`; + + return ` + + + + + + + + + + + + + + ${(engine ? engine.showPillars : false) ? ` + + + ` : ''} + + + + ${plaqueText} + + + `; + } + + // Default: Iconic Starfleet Ten Forward / Galaxy-Class Observation Lounge + return ` + + + + + + + + + + + + + + + + + + + ${(engine ? engine.showPillars : false) ? ` + + + + ` : ''} + + + + + USS ENTERPRISE // OBSERVATION LOUNGE // DECK 10 FORWARD + + + `; + } +}; diff --git a/js/observation-engine.js b/js/observation-engine.js new file mode 100644 index 0000000..588d0fc --- /dev/null +++ b/js/observation-engine.js @@ -0,0 +1,2783 @@ +const OBSERVATION_CANVAS = { + starfleet: { canvas: true, layers: { celestial: true, starfield: true }, + // Bespoke: sparser and cooler than Deep Space's field -- a calm survey sky + // behind the Class-M planet, not a busy multi-hued star rush. + starfield: { count: 150, sizeMin: 0.6, sizeRange: 1.5, warpStreaks: true, + colors: ['#e0f2fe', '#ffffff', '#bfdbfe', '#93c5fd', '#a5f3fc'] } }, + + whoniverse: { canvas: true, layers: { celestial: true } }, // Time Vortex on black, deliberately starless + + deepspace: { canvas: true, layers: { starfield: true, deepSpaceField: true, + constellations: true, shootingStars: true, + events: true } }, + + spacestations: { canvas: true, layers: { stationPanorama: true, shootingStars: true, + traffic: true, reticles: true, + events: true, cinematics: true } }, + + industrial: { canvas: false, layers: {} }, + bioships: { canvas: false, layers: {} }, + retrofuture: { canvas: false, layers: {} }, + military: { canvas: false, layers: {} }, + outlaw: { canvas: false, layers: {} }, + comedy: { canvas: false, layers: {} } +}; + +// Default starfield profile (Deep Space and any future universe that declares `starfield` +// without its own profile). +const DEFAULT_STARFIELD_PROFILE = { + count: 340, sizeMin: 0.8, sizeRange: 2.2, warpStreaks: true, + colors: ['#e0f2fe', '#ffffff', '#bfdbfe', '#fef08a', '#fca5a5', '#93c5fd', '#fdba74', '#a5f3fc', '#f0abfc', '#fde68a'] +}; + +class ObservationEngine { + constructor(audioManager, warpSynth, alertSynth, hullDroneSynth, lifeSupportSynth) { + this.am = audioManager; + this.warpSynth = warpSynth; + this.alerts = alertSynth; + this.hullDrone = hullDroneSynth || null; + this.lifeSupport = lifeSupportSynth || null; + + // DOM Elements + this.overlay = document.getElementById('observation-overlay'); + this.canvas = document.getElementById('observation-canvas'); + this.ctx = this.canvas ? this.canvas.getContext('2d') : null; + this.frameEl = document.getElementById('observation-viewport-frame'); + this.alertWash = document.getElementById('observation-alert-wash'); + this.dock = document.getElementById('observation-control-dock'); + this.btnWarp = document.getElementById('obs-btn-warp'); + this.btnFrame = document.getElementById('obs-btn-frame'); + this.btnPillars = document.getElementById('obs-btn-pillars'); + this.btnAlert = document.getElementById('obs-btn-alert'); + this.btnExit = document.getElementById('obs-btn-exit'); + this.selectPreset = document.getElementById('obs-select-preset'); + this.mainToggleViewport = document.getElementById('toggle-observation-viewport'); + this.mainValViewport = document.getElementById('val-observation-viewport'); + this.mainTogglePillars = document.getElementById('toggle-observation-pillars'); + this.mainValPillars = document.getElementById('val-observation-pillars'); + this.waveformCanvas = document.getElementById('observation-waveform-canvas'); + this.waveformCtx = this.waveformCanvas ? this.waveformCanvas.getContext('2d') : null; + + // State + this.running = false; + this.animId = null; + this.width = 1600; + this.height = 900; + this.dpr = 1; + this.activeUniverse = 'starfleet'; + this.flightMode = 'cruise'; // 'cruise' or 'warp' + this.warpSpeed = 1.0; + this.targetWarpSpeed = 1.0; + this.warpPulseEnergy = 0.0; + this.showViewport = true; + this.showPillars = false; // Default OFF per user request! + this.cameraWobble = { x: 0, y: 0 }; + this.time = 0; + this.lastFrameTime = performance.now(); + + // 3D Starfield + this.stars = []; + this.starCount = 340; + this.starColors = ['#e0f2fe', '#ffffff', '#bfdbfe', '#fef08a', '#fca5a5', '#93c5fd', '#fdba74', '#a5f3fc', '#f0abfc', '#fde68a']; + + // Entities & Encounters + this.traffic = []; + this.events = []; + this.nextTrafficTime = 0; + this.nextEventTime = 0; + this.dockHideTimer = null; + + // Audio Analysis buffer + this.analyserData = new Uint8Array(64); + + // --- Observation Mode Enhancements: State --- + // Audio-visual sync + this.audioEnergy = { bass: 0, mid: 0, treble: 0, overall: 0 }; + this.viewportVibration = { x: 0, y: 0 }; + this.scanlineBreath = 0.14; + this.scanlinesEl = this.overlay ? this.overlay.querySelector('.observation-scanlines') : null; + + // Nebula morphing + this.nebulaBlobs = []; + + // Binary star systems (occasional background variety for select universes) + this.binaryStar = null; + + // Shooting stars + this.shootingStars = []; + this.nextShootingStarTime = 0; + + // Constellation lines (self-contained synthetic points, independent of the main starfield) + this.constellationPoints = null; + this.constellationLines = null; + this.constellationAlpha = 0; + this.nextConstellationTime = 0; + + // 25-minute ambient lighting cycle + this.lightingCycleTime = 0; + this.lightingModifiers = null; + + // Cinematic event director + this.cinematicActive = null; + this.cinematicCaption = null; + this.nextCinematicTime = 0; + + // HUD status ticker + this.tickerTextEl = document.getElementById('observation-ticker-text'); + this.tickerMessages = []; + this.tickerIndex = -1; + this.nextTickerTime = 0; + + // Space Stations: 360-degree rotating panorama. + // Generated ONCE per page load and retained for the whole session (across entering/leaving + // Observation Mode and preset switches). Reloading the page rolls a brand new sky. + this.stationRotation = Math.random(); // starting bearing, 0..1 of a full revolution + this.stationRevolutionSeconds = 240; // ~4 minutes for a full 360 + this.stationFov = 0.22; // visible window covers ~22% of the full circle + this.stationPanorama = null; + + // Deep Space: procedurally generated celestial field (planets, nebula clouds, dust lanes, + // derelicts, a drifting asteroid field, and gravitational/radiation anomalies). Generated + // ONCE per page load and retained for the whole session -- same contract as the station + // panorama above. Reloading the page rolls a brand new sky; it never reshuffles mid-session + // even as you enter/leave Observation Mode or flip presets. + this.deepSpaceField = null; + + this.initStars(); + this.initNebulaBlobs(); + this.regenerateStationPanorama(); + this.regenerateDeepSpaceField(); + this.initEventListeners(); + } + + initStars() { + // v3co: the starfield is built from the active universe's declared profile, so two + // universes that both draw stars are still drawing their own sky, not a shared layer. + const prof = this.getStarfieldProfile(); + this.starProfile = prof; + this.starCount = prof.count; + this.stars = []; + for (let i = 0; i < prof.count; i++) { + this.stars.push({ + x: (Math.random() - 0.5) * 2600, + y: (Math.random() - 0.5) * 1600, + z: Math.random() * 1200 + 10, + pz: 1200, + size: Math.random() * prof.sizeRange + prof.sizeMin, + color: prof.colors[Math.floor(Math.random() * prof.colors.length)], + twinkleSpeed: Math.random() * 3 + 1, + twinklePhase: Math.random() * Math.PI * 2 + }); + } + } + + resize() { + if (!this.canvas) return; + this.dpr = window.devicePixelRatio || 1; + this.width = window.innerWidth; + this.height = window.innerHeight; + + this.canvas.width = this.width * this.dpr; + this.canvas.height = this.height * this.dpr; + if (this.ctx) { + this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + } + + if (this.waveformCanvas) { + const w = this.waveformCanvas.clientWidth || 360; + const h = this.waveformCanvas.clientHeight || 24; + this.waveformCanvas.width = w * this.dpr; + this.waveformCanvas.height = h * this.dpr; + if (this.waveformCtx) { + this.waveformCtx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + } + } + } + + initEventListeners() { + window.addEventListener('resize', () => { + if (this.running) this.resize(); + }); + + if (this.btnWarp) { + this.btnWarp.addEventListener('click', (e) => { + e.stopPropagation(); + this.toggleWarpFlight(); + }); + } + + if (this.btnFrame) { + this.btnFrame.addEventListener('click', (e) => { + e.stopPropagation(); + this.toggleViewport(); + }); + } + + if (this.btnPillars) { + this.btnPillars.addEventListener('click', (e) => { + e.stopPropagation(); + this.togglePillars(); + }); + } + + if (this.mainToggleViewport) { + this.mainToggleViewport.addEventListener('change', () => { + this.setViewport(this.mainToggleViewport.checked); + }); + } + + if (this.mainTogglePillars) { + this.mainTogglePillars.addEventListener('change', () => { + this.setPillars(this.mainTogglePillars.checked); + }); + } + + if (this.btnAlert) { + this.btnAlert.addEventListener('click', (e) => { + e.stopPropagation(); + this.toggleAlert(); + }); + } + + if (this.btnExit) { + this.btnExit.addEventListener('click', (e) => { + e.stopPropagation(); + if (typeof exitObservation === 'function') exitObservation(); + }); + } + + if (this.selectPreset) { + this.selectPreset.addEventListener('change', (e) => { + e.stopPropagation(); + const presetId = this.selectPreset.value; + if (presetId && window.selectPreset) { + window.selectPreset(presetId); + if (typeof refreshObservation === 'function') refreshObservation(); + } + }); + this.selectPreset.addEventListener('click', (e) => e.stopPropagation()); + } + + if (this.dock) { + this.dock.addEventListener('click', (e) => e.stopPropagation()); + } + + // Auto-hide control dock on mouse stillness + const pingDock = () => { + if (!this.running || !this.dock) return; + this.dock.classList.remove('dock-autohide'); + clearTimeout(this.dockHideTimer); + this.dockHideTimer = setTimeout(() => { + if (this.running && this.dock) { + this.dock.classList.add('dock-autohide'); + } + }, 3500); + }; + + window.addEventListener('mousemove', pingDock); + window.addEventListener('touchstart', pingDock, { passive: true }); + } + + setViewport(enabled) { + this.showViewport = !!enabled; + if (this.frameEl) { + this.frameEl.classList.toggle('frame-hidden', !this.showViewport); + } + const stage = document.getElementById('observation-stage'); + if (stage) { + stage.classList.toggle('obs-viewport-hidden', !this.showViewport); + } + if (this.btnFrame) { + this.btnFrame.classList.toggle('active', this.showViewport); + this.btnFrame.textContent = `VIEWPORT: ${this.showViewport ? 'ON' : 'OFF'}`; + } + if (this.mainToggleViewport) { + this.mainToggleViewport.checked = this.showViewport; + } + if (this.mainValViewport) { + this.mainValViewport.textContent = this.showViewport ? 'ON' : 'OFF'; + } + } + + toggleViewport() { + this.setViewport(!this.showViewport); + } + + toggleViewportFrame() { + this.toggleViewport(); + } + + setPillars(enabled) { + this.showPillars = !!enabled; + if (this.btnPillars) { + this.btnPillars.classList.toggle('active', this.showPillars); + this.btnPillars.textContent = `PILLARS: ${this.showPillars ? 'ON' : 'OFF'}`; + } + if (this.mainTogglePillars) { + this.mainTogglePillars.checked = this.showPillars; + } + if (this.mainValPillars) { + this.mainValPillars.textContent = this.showPillars ? 'ON' : 'OFF'; + } + this.updateViewportFrame(); + } + + togglePillars() { + this.setPillars(!this.showPillars); + } + + start(universeId) { + this.activeUniverse = universeId || 'starfleet'; + this.running = true; + + // --- v3co: apply the OBSERVATION_CANVAS manifest for this universe ------------------- + // Barrier 2 of 3: the overlay class scopes the background-rect CSS rule; the canvas + // visibility is belt-and-braces so a wrong manifest entry still cannot leak pixels. + const canvasOn = this.canvasEnabled(); + if (this.overlay) this.overlay.classList.toggle('obs-canvas-on', canvasOn); + if (this.canvas) this.canvas.style.visibility = canvasOn ? '' : 'hidden'; + + // Rebuild the starfield from this universe's declared profile. + this.initStars(); + + // Clear any entities left over from a universe that WAS allowed them, so a canvas-off + // theme can never inherit another theme's ships, events or shooting stars. + if (!this.hasLayer('traffic')) this.traffic = []; + if (!this.hasLayer('events')) this.events = []; + if (!this.hasLayer('shootingStars')) this.shootingStars = []; + if (!this.hasLayer('constellations')) { + this.constellationPoints = null; + this.constellationLines = null; + this.constellationAlpha = 0; + } + if (!this.hasLayer('cinematics')) { + this.cinematicActive = null; + this.cinematicCaption = null; + } + // ------------------------------------------------------------------------------------- + + this.resize(); + this.populatePresetDropdown(); + this.initNebulaBlobs(); + this.setViewport(this.showViewport); + this.setPillars(this.showPillars); + this.updateAlertState(); + + // Hook warp pulse + if (this.warpSynth) { + const existingPulse = this.warpSynth.onPulse; + this.warpSynth.onPulse = (phase, duration) => { + if (typeof existingPulse === 'function') existingPulse(phase, duration); + this.triggerWarpPulse(); + }; + } + + this.lastFrameTime = performance.now(); + this.nextTrafficTime = performance.now() + 2500; + this.nextEventTime = performance.now() + 5000; + + // --- Observation Mode Enhancements: reset per-session timers --- + this.nextShootingStarTime = performance.now() + 6000 + Math.random() * 6000; + this.nextConstellationTime = 0; // triggers an immediate first constellation on next update + this.constellationPoints = null; + this.constellationLines = null; + this.constellationAlpha = 0; + this.cinematicActive = null; + this.cinematicCaption = null; + // Shortened first wait so a cinematic sequence is easy to observe/verify; later ones cool down longer. + this.nextCinematicTime = performance.now() + 20000 + Math.random() * 15000; + this.tickerMessages = this.getTickerMessages(this.activeUniverse); + this.tickerIndex = -1; + this.nextTickerTime = 0; + + if (this.btnWarp) { + this.btnWarp.classList.toggle('active', this.flightMode === 'warp'); + this.btnWarp.textContent = `WARP: ${this.flightMode === 'warp' ? 'ENGAGED' : 'OFF'}`; + } + + this.loop(); + } + + stop() { + this.running = false; + if (this.animId) { + cancelAnimationFrame(this.animId); + this.animId = null; + } + clearTimeout(this.dockHideTimer); + if (this.ctx) { + this.ctx.clearRect(0, 0, this.width, this.height); + } + } + + triggerWarpPulse() { + this.warpPulseEnergy = 1.0; + } + + toggleWarpFlight() { + if (this.flightMode === 'warp') { + this.flightMode = 'cruise'; + this.targetWarpSpeed = 1.0; + } else { + this.flightMode = 'warp'; + // Scale warp streak speed according to warp BPM + const bpm = (this.warpSynth && this.warpSynth.params && this.warpSynth.params.bpm) ? this.warpSynth.params.bpm : 48; + this.targetWarpSpeed = Math.min(38, Math.max(16, (bpm / 48) * 24)); + // Trigger subtle warp flash + this.spawnEvent('warp-flash', this.width / 2, this.height / 2); + } + + if (this.btnWarp) { + this.btnWarp.classList.toggle('active', this.flightMode === 'warp'); + this.btnWarp.textContent = `WARP: ${this.flightMode === 'warp' ? 'ENGAGED' : 'OFF'}`; + } + } + + toggleAlert() { + if (this.alerts) { + if (this.alerts.activeAlert === 'red') { + this.alerts.stopAlert(); + } else { + this.alerts.triggerRedAlert('tng'); + } + } + this.updateAlertState(); + } + + updateAlertState() { + const alertType = this.alerts ? this.alerts.activeAlert : null; + if (this.alertWash) { + this.alertWash.className = 'observation-alert-wash'; + if (alertType === 'red') { + this.alertWash.classList.add('alert-red'); + } else if (alertType === 'yellow') { + this.alertWash.classList.add('alert-yellow'); + } + } + + if (this.btnAlert) { + this.btnAlert.classList.toggle('btn-alert-active', alertType === 'red'); + this.btnAlert.textContent = alertType === 'red' ? 'RED ALERT: ON' : (alertType === 'yellow' ? 'YELLOW ALERT' : 'RED ALERT'); + } + } + + populatePresetDropdown() { + if (!this.selectPreset) return; + this.selectPreset.innerHTML = ''; + const universe = UniverseRegistry[this.activeUniverse]; + if (universe && universe.presets) { + for (const [id, preset] of Object.entries(universe.presets)) { + const opt = document.createElement('option'); + opt.value = id; + opt.textContent = preset.name; + if (id === (window.activePresetId || '')) opt.selected = true; + this.selectPreset.appendChild(opt); + } + } + } + + loop() { + if (!this.running) return; + const now = performance.now(); + const dt = Math.min((now - this.lastFrameTime) / 1000, 0.1); + this.lastFrameTime = now; + this.time += dt; + + this.update(dt, now); + this.render(); + this.renderWaveform(); + + this.animId = requestAnimationFrame(() => this.loop()); + } + + update(dt, now) { + const stationView = this.isStationView(); + + // Smoothly interpolate warp speed + this.warpSpeed += (this.targetWarpSpeed - this.warpSpeed) * (dt * 4); + this.warpPulseEnergy *= Math.max(0, 1 - dt * 2.5); + + if (stationView) { + // A station rotates in place: no forward flight, so no camera sway and no + // starfield rush - just the fixed sky turning past the window. + this.cameraWobble.x *= 0.9; + this.cameraWobble.y *= 0.9; + if (this.hasLayer('stationPanorama')) this.updateStationPanorama(dt); + } else { + // Camera sway during cruise + if (this.flightMode === 'cruise') { + this.cameraWobble.x = Math.sin(this.time * 0.18) * 14; + this.cameraWobble.y = Math.cos(this.time * 0.14) * 8; + } else { + this.cameraWobble.x *= 0.9; + this.cameraWobble.y *= 0.9; + } + + // Update 3D Stars (only for universes that declare the starfield layer) + const speed = this.flightMode === 'warp' ? this.warpSpeed * 220 : 25 + this.warpPulseEnergy * 60; + const starsLen = this.hasLayer('starfield') ? this.stars.length : 0; + + for (let i = 0; i < starsLen; i++) { + const star = this.stars[i]; + star.pz = star.z; + star.z -= speed * dt; + + if (star.z <= 2) { + star.z = 1200; + star.pz = 1200; + star.x = (Math.random() - 0.5) * 2600; + star.y = (Math.random() - 0.5) * 1600; + } + } + + } + + // Traffic Spawning -- v3co: governed by the OBSERVATION_CANVAS manifest, which supersedes + // the v2cs `activeUniverse !== 'deepspace'` guard. Gating the SPAWNER (not just the renderer) + // is what stops ships accumulating invisibly in themes that should have none. + const activityFactor = (window.observationActivity !== undefined ? window.observationActivity : 0.6); + if (this.hasLayer('traffic') && now > this.nextTrafficTime && this.traffic.length < 3) { + this.spawnTraffic(); + this.nextTrafficTime = now + (14000 + Math.random() * 18000) / Math.max(0.1, activityFactor); + } + + // Update Traffic + for (let i = this.traffic.length - 1; i >= 0; i--) { + const ship = this.traffic[i]; + ship.progress += dt / ship.duration; + ship.x = ship.x0 + (ship.x1 - ship.x0) * ship.progress; + ship.y = ship.y0 + (ship.y1 - ship.y0) * ship.progress; + + // Engine trail particles + if (Math.random() < 0.6) { + ship.particles.push({ + x: ship.x, + y: ship.y, + vx: -ship.vx * 0.2 + (Math.random() - 0.5) * 4, + vy: -ship.vy * 0.2 + (Math.random() - 0.5) * 4, + alpha: 0.8, + size: Math.random() * 3 + 2, + color: ship.trailColor + }); + } + + // Update particles + for (let p = ship.particles.length - 1; p >= 0; p--) { + const pt = ship.particles[p]; + pt.x += pt.vx; + pt.y += pt.vy; + pt.alpha -= dt * 1.5; + if (pt.alpha <= 0) ship.particles.splice(p, 1); + } + + // Warp-out departure: flash just before the ship reaches the edge, if it was tagged for one + if (ship.warpExit && !ship.warpExitDone && ship.progress > 0.9) { + this.spawnEvent('warp-flash', ship.x, ship.y); + ship.warpExitDone = true; + } + + if (ship.progress >= 1.0) { + this.traffic.splice(i, 1); + } + } + + // Dynamic Events Spawning + if (this.hasLayer('events') && now > this.nextEventTime && this.events.length < 2) { + this.spawnRandomEvent(); + this.nextEventTime = now + (20000 + Math.random() * 25000) / Math.max(0.1, activityFactor); + } + + // Update Events + for (let i = this.events.length - 1; i >= 0; i--) { + const ev = this.events[i]; + ev.life += dt; + if (ev.life >= ev.maxLife) { + this.events.splice(i, 1); + } + } + + // --- Observation Mode Enhancements --- + this.updateAudioEnergy(); + this.updateViewportVibration(dt); + this.updateScanlineBreathing(); + this.updateLightingCycle(dt); + // v3co: all four are manifest-gated. `cinematics` in particular is a crossover vector -- + // its sequences call spawnTraffic()/spawnEvent() directly and buildFlybySpectacleSequence() + // is appended for every universe, so leaving it ungated would repopulate this.traffic for + // themes whose renderers are off. + if (this.hasLayer('shootingStars')) this.updateShootingStars(dt, now); + if (this.hasLayer('constellations')) this.updateConstellations(dt, now); + if (this.hasLayer('cinematics')) this.updateCinematicDirector(dt, now); + this.updateStatusTicker(dt, now); + + // Sync Alert wash state with AlertSynth + this.updateAlertState(); + } + + spawnTraffic(emphasis) { + const fromLeft = Math.random() > 0.5; + let yStart, yEnd; + if (this.isStationView()) { + // Keep dock traffic inside the station's window opening - the bulkhead hides anything else + const rect = this.getStationViewportRect(); + yStart = rect.y + rect.h * 0.18 + Math.random() * rect.h * 0.64; + yEnd = yStart + (Math.random() - 0.5) * rect.h * 0.3; + } else { + yStart = 160 + Math.random() * (this.height - 340); + yEnd = yStart + (Math.random() - 0.5) * 220; + } + const x0 = fromLeft ? -100 : this.width + 100; + const x1 = fromLeft ? this.width + 100 : -100; + let duration = 12 + Math.random() * 14; + + let shipType = 'shuttle'; + let label = 'SHUTTLE // TYPE-9'; + let trailColor = '#38bdf8'; + let scale = 1.0; + + if (this.activeUniverse === 'starfleet') { + const types = ['shuttle', 'cruiser', 'runabout']; + shipType = types[Math.floor(Math.random() * types.length)]; + if (shipType === 'cruiser') { + label = `USS GIBRALTAR // NCC-${Math.floor(Math.random()*80000+10000)}`; + trailColor = '#60a5fa'; + } else if (shipType === 'runabout') { + label = `RUNABOUT YANGTZE // NCC-72452`; + trailColor = '#f97316'; + } + } else if (this.activeUniverse === 'whoniverse') { + shipType = 'tardis'; + label = 'TYPE 40 TIME CAPSULE // DRIFT'; + trailColor = '#00e5ff'; + } else if (this.activeUniverse === 'industrial' || this.activeUniverse === 'outlaw') { + shipType = 'freighter'; + label = 'HEAVY HAULER // CLASS IV'; + trailColor = '#f97316'; + } else if (this.activeUniverse === 'military') { + const types = ['cruiser', 'fighterwing']; + shipType = types[Math.floor(Math.random() * types.length)]; + if (shipType === 'fighterwing') { + label = `VIPER WING // FLIGHT ${Math.floor(Math.random() * 9) + 1}`; + trailColor = '#eab308'; + scale = 0.75; + } else { + label = 'VIPER PATROL // CAP 04'; + trailColor = '#eab308'; + } + } else if (this.activeUniverse === 'bioships') { + shipType = 'bioshippod'; + label = 'SPAWN POD // DRIFTING'; + trailColor = '#34d399'; + } else if (this.activeUniverse === 'retrofuture') { + shipType = 'retrosaucer'; + label = 'ATOMIC CRUISER // SAUCER CLASS'; + trailColor = '#4ade80'; + } + + if (emphasis) { + // Close, dramatic flyby used by cinematic sequences: bigger, faster, more prominent + scale *= 1.6; + duration *= 0.55; + } + + // Occasionally have a vessel materialize via warp instead of drifting in from off-screen, + // and/or make a warp jump to depart instead of simply exiting the frame. + const warpEntry = !emphasis && Math.random() < 0.12; + const warpExit = !emphasis && Math.random() < 0.12; + const initialProgress = warpEntry ? (0.12 + Math.random() * 0.1) : 0; + const initialX = x0 + (x1 - x0) * initialProgress; + const initialY = yStart + (yEnd - yStart) * initialProgress; + + this.traffic.push({ + type: shipType, + label: label, + x0, y0: yStart, + x1, y1: yEnd, + x: initialX, y: initialY, + vx: (x1 - x0) / duration, + vy: (yEnd - yStart) / duration, + progress: initialProgress, + duration: duration, + scale: shipType === 'cruiser' ? 0.75 : scale, + trailColor: trailColor, + particles: [], + warpEntry, warpExit, + warpExitDone: !warpExit + }); + + if (warpEntry) { + this.spawnEvent('warp-flash', initialX, initialY); + } + } + + spawnRandomEvent() { + const kinds = ['comet', 'warp-flash']; + const kind = kinds[Math.floor(Math.random() * kinds.length)]; + let x, y; + if (this.isStationView()) { + // Confine arrivals and comets to the station's window opening + const rect = this.getStationViewportRect(); + x = rect.x + rect.w * 0.15 + Math.random() * rect.w * 0.7; + y = rect.y + rect.h * 0.18 + Math.random() * rect.h * 0.64; + } else { + x = 200 + Math.random() * (this.width - 400); + y = 140 + Math.random() * (this.height - 280); + } + this.spawnEvent(kind, x, y); + } + + spawnEvent(kind, x, y) { + this.events.push({ + kind, + x, y, + life: 0, + maxLife: kind === 'warp-flash' ? 1.4 : 5.0, + vx: kind === 'comet' ? (Math.random() > 0.5 ? 120 : -120) : 0, + vy: kind === 'comet' ? 50 : 0 + }); + } + + isStationView() { + return this.activeUniverse === 'spacestations'; + } + + // --- OBSERVATION_CANVAS accessors (v3co) ------------------------------- + // Single source of truth for "may this theme draw on the canvas at all" and + // "may it draw this particular layer". Default-deny in both directions. + canvasEnabled() { + const cfg = OBSERVATION_CANVAS[this.activeUniverse]; + return !!(cfg && cfg.canvas); + } + + hasLayer(name) { + const cfg = OBSERVATION_CANVAS[this.activeUniverse]; + return !!(cfg && cfg.canvas && cfg.layers && cfg.layers[name]); + } + + getStarfieldProfile() { + const cfg = OBSERVATION_CANVAS[this.activeUniverse]; + const p = (cfg && cfg.starfield) || {}; + return Object.assign({}, DEFAULT_STARFIELD_PROFILE, p); + } + + render() { + const ctx = this.ctx; + if (!ctx) return; + + // v3co: a universe not declared canvas-on in OBSERVATION_CANVAS draws nothing at all -- + // its OBSERVATION display is its own bespoke SVG art. First of three barriers (the other + // two: canvas visibility:hidden in start(), and its own opaque background rect). + if (!this.canvasEnabled()) return; + + const stationView = this.isStationView(); + + // Center of projection with sway + const cx = this.width / 2 + this.cameraWobble.x; + const cy = this.height / 2 + this.cameraWobble.y; + + // Clear canvas + if (this.flightMode === 'warp' && !stationView) { + // Warp motion blur trail + ctx.fillStyle = 'rgba(1, 3, 8, 0.35)'; + ctx.fillRect(0, 0, this.width, this.height); + } else { + // Stations always clear solid: a rotating station never smears into warp streaks + ctx.fillStyle = '#010308'; + ctx.fillRect(0, 0, this.width, this.height); + } + + // v3co: every layer is drawn only if the active universe declares it in + // OBSERVATION_CANVAS. The old isStationView() / activeUniverse === 'deepspace' + // branching is replaced by uniform manifest lookups -- there is no implicit + // "everyone gets this" layer any more. Draw order is unchanged. + + // 0. Space Stations: one fixed 360-degree sky wheeling past the window. + if (this.hasLayer('stationPanorama')) this.renderStationPanorama(ctx); + + // 1. Deep Space Nebulae (generic glow) + if (this.hasLayer('nebulae')) this.renderNebulae(ctx, cx, cy); + + // 2. Universe-Specific Celestial Objects (Planets, Time Vortex, Rings) + if (this.hasLayer('celestial')) this.renderCelestialObjects(ctx, cx, cy); + + // 2b. Deep Space: procedural, session-seeded field (planets, dust, derelicts, anomalies) + if (this.hasLayer('deepSpaceField')) this.renderDeepSpaceField(ctx, cx, cy); + + // 3. 3D Parallax Starfield & Warp Streaks (per-universe profile) + if (this.hasLayer('starfield')) this.renderStarfield(ctx, cx, cy); + + // 3b. Procedural Constellation Lines (faint, background layer) + if (this.hasLayer('constellations')) this.renderConstellations(ctx, cx, cy); + + // 3c. Shooting Stars + if (this.hasLayer('shootingStars')) this.renderShootingStars(ctx); + + // 4. Dynamic Encounters & Traffic + if (this.hasLayer('traffic') || this.hasLayer('events')) this.renderTrafficAndEvents(ctx); + + // 5. Holographic Target Reticles + if (this.hasLayer('reticles')) this.renderTargetReticles(ctx); + } + + getNebulaPalette() { + let g1 = '#3b82f6', g2 = '#6366f1', g3 = '#ec4899'; + + if (this.activeUniverse === 'whoniverse') { + g1 = '#00e5ff'; g2 = '#d4af37'; g3 = '#8b5cf6'; + } else if (this.activeUniverse === 'industrial') { + g1 = '#f59e0b'; g2 = '#b45309'; g3 = '#3f2c18'; + } else if (this.activeUniverse === 'bioships') { + g1 = '#10b981'; g2 = '#8b5cf6'; g3 = '#065f46'; + } else if (this.activeUniverse === 'retrofuture') { + g1 = '#22c55e'; g2 = '#15803d'; g3 = '#14532d'; + } else if (this.activeUniverse === 'military') { + g1 = '#64748b'; g2 = '#3f3f46'; g3 = '#7f1d1d'; + } else if (this.activeUniverse === 'deepspace') { + g1 = '#f59e0b'; g2 = '#7c2d12'; g3 = '#1e293b'; + } else if (this.activeUniverse === 'outlaw') { + g1 = '#f97316'; g2 = '#7c2d12'; g3 = '#4c0519'; + } else if (this.activeUniverse === 'spacestations') { + g1 = '#38bdf8'; g2 = '#0ea5e9'; g3 = '#1e3a8a'; + } else if (this.activeUniverse === 'comedy') { + g1 = '#f472b6'; g2 = '#a78bfa'; g3 = '#fde047'; + } + + return [g1, g2, g3]; + } + + initNebulaBlobs() { + const palette = this.getNebulaPalette(); + this.nebulaBlobs = []; + for (let i = 0; i < 4; i++) { + this.nebulaBlobs.push({ + baseX: (Math.random() - 0.5) * 1.6, + baseY: (Math.random() - 0.5) * 1.1, + radius: 260 + Math.random() * 340, + color: palette[i % palette.length], + driftSpeedX: (Math.random() - 0.5) * 0.015, + driftSpeedY: (Math.random() - 0.5) * 0.01, + pulseSpeed: 0.15 + Math.random() * 0.25, + pulsePhase: Math.random() * Math.PI * 2, + baseAlpha: 0.05 + Math.random() * 0.05, + morphSeed: Math.random() * 1000 + }); + } + } + + renderNebulae(ctx, cx, cy) { + if (!this.nebulaBlobs || !this.nebulaBlobs.length) this.initNebulaBlobs(); + + const bassBoost = this.warpPulseEnergy * 0.15 + (this.audioEnergy ? this.audioEnergy.bass * 0.12 : 0); + const lighting = this.getLightingModifiers(); + + for (const blob of this.nebulaBlobs) { + // Slow procedural drift/morph, looped via sine so blobs never run away off-scene + const driftX = Math.sin(this.time * blob.driftSpeedX * 10 + blob.morphSeed) * 220; + const driftY = Math.cos(this.time * blob.driftSpeedY * 10 + blob.morphSeed * 1.3) * 160; + const bx = cx + blob.baseX * this.width * 0.5 + driftX; + const by = cy + blob.baseY * this.height * 0.5 + driftY; + + const pulse = 1 + Math.sin(this.time * blob.pulseSpeed + blob.pulsePhase) * 0.18; + const radius = Math.max(40, blob.radius * pulse * (0.9 + bassBoost * 2)); + const alpha = Math.max(0, (blob.baseAlpha + bassBoost) * lighting.nebulaIntensity); + + const grad = ctx.createRadialGradient(bx, by, radius * 0.08, bx, by, radius); + grad.addColorStop(0, hexToRgba(blob.color, alpha)); + grad.addColorStop(0.55, hexToRgba(blob.color, alpha * 0.4)); + grad.addColorStop(1, 'transparent'); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, this.width, this.height); + } + } + + renderCelestialObjects(ctx, cx, cy) { + if (this.activeUniverse === 'whoniverse') { + // Procedural 3D Time Vortex + ctx.save(); + ctx.translate(cx, cy); + const ringCount = 14; + for (let i = ringCount; i >= 1; i--) { + const ringZ = ((this.time * 0.6 + i * 0.45) % ringCount); + const rNorm = ringZ / ringCount; + const rx = 80 + rNorm * 480; + const ry = 40 + rNorm * 220; + const rot = this.time * (i % 2 === 0 ? 0.35 : -0.28) + i * 0.3; + + ctx.save(); + ctx.rotate(rot); + ctx.beginPath(); + ctx.ellipse(0, 0, rx, ry, 0, 0, Math.PI * 2); + ctx.strokeStyle = i % 2 === 0 ? `rgba(0, 229, 255, ${0.15 + (1 - rNorm) * 0.45})` : `rgba(212, 175, 55, ${0.12 + (1 - rNorm) * 0.4})`; + ctx.lineWidth = 1.5 + (1 - rNorm) * 3; + ctx.stroke(); + ctx.restore(); + } + ctx.restore(); + return; + } + + if (this.activeUniverse === 'starfleet') { + // Class-M Planet with Rayleigh Scattering Atmosphere Halo + const px = cx + this.width * 0.26; + const py = cy - this.height * 0.06; + const pr = Math.min(this.width, this.height) * 0.22; + + // Outer Atmospheric Glow + const atmosGrad = ctx.createRadialGradient(px, py, pr * 0.85, px, py, pr * 1.35); + atmosGrad.addColorStop(0, 'rgba(56, 189, 248, 0.45)'); + atmosGrad.addColorStop(0.5, 'rgba(59, 130, 246, 0.18)'); + atmosGrad.addColorStop(1, 'transparent'); + ctx.fillStyle = atmosGrad; + ctx.beginPath(); + ctx.arc(px, py, pr * 1.35, 0, Math.PI * 2); + ctx.fill(); + + // Planet Body + const bodyGrad = ctx.createRadialGradient(px - pr * 0.35, py - pr * 0.35, pr * 0.1, px, py, pr); + bodyGrad.addColorStop(0, '#1e3a8a'); + bodyGrad.addColorStop(0.4, '#1d4ed8'); + bodyGrad.addColorStop(0.7, '#0f172a'); + bodyGrad.addColorStop(1, '#020617'); + + ctx.fillStyle = bodyGrad; + ctx.beginPath(); + ctx.arc(px, py, pr, 0, Math.PI * 2); + ctx.fill(); + + // Subtle atmospheric rim arc + ctx.beginPath(); + ctx.arc(px, py, pr, -Math.PI * 0.8, Math.PI * 0.2); + ctx.strokeStyle = 'rgba(186, 230, 253, 0.65)'; + ctx.lineWidth = 3.5; + ctx.stroke(); + + // Orbiting Moon + const moonAngle = this.time * 0.08; + const mx = px + Math.cos(moonAngle) * (pr * 1.7); + const my = py + Math.sin(moonAngle) * (pr * 0.6); + const mr = pr * 0.14; + + ctx.fillStyle = '#cbd5e1'; + ctx.beginPath(); + ctx.arc(mx, my, mr, 0, Math.PI * 2); + ctx.fill(); + + // Moon shadow + ctx.fillStyle = 'rgba(2, 6, 23, 0.75)'; + ctx.beginPath(); + ctx.arc(mx + mr * 0.3, my, mr * 0.95, 0, Math.PI * 2); + ctx.fill(); + return; + } + + // Deep Space's own procedural, session-seeded field (planets, nebulae, dust lanes, + // derelicts, anomalies -- each with its own computer callout, no near-passing traffic) + // is generated once in the constructor and drawn by renderDeepSpaceField() below, called + // separately from render(). Space Stations never reaches this method at all + // (isStationView() short-circuits render() first). + } + + regenerateDeepSpaceField() { + const rand = (min, max) => min + Math.random() * (max - min); + const pick = (arr) => arr[Math.floor(Math.random() * arr.length)]; + + // Planets, derelicts and anomalies all get a computer callout with a leader line, so two + // of them landing close together (or a small object landing inside a large planet) makes + // both illegible. This tracks every placed object's normalized position + how much + // clearance its callout needs, and biases new placements away from all of them -- + // same idea as the even bearing-spacing the Space Station panorama already does, just in + // 2D screen-fraction space instead of 1D bearing space. + // Distances are measured in actual pixels (converting each candidate's normalized + // offset through the canvas's own width/height) rather than raw nx/ny units, since a + // canvas is wider than it is tall -- comparing normalized deltas directly would treat a + // given horizontal separation as "closer" than the same vertical one. Clearance is also + // required from BOTH sides of a pair (the new candidate's own footprint, not just the + // already-placed object's), so two large planets can't still end up overlapping just + // because each one individually satisfied a one-sided check. + const placed = []; + const placeAway = (nxRange, nyRange, clearancePx, tries = 14) => { + let best = null, bestScore = -Infinity; + for (let attempt = 0; attempt < tries; attempt++) { + const nx = rand(nxRange[0], nxRange[1]); + const ny = rand(nyRange[0], nyRange[1]); + let minDist = placed.length ? Infinity : clearancePx; + for (const p of placed) { + const dPx = Math.hypot((nx - p.nx) * this.width, (ny - p.ny) * this.height); + const d = dPx - p.clearance - clearancePx; + if (d < minDist) minDist = d; + } + if (minDist > bestScore) { bestScore = minDist; best = { nx, ny }; } + if (minDist >= clearancePx) break; + } + placed.push({ nx: best.nx, ny: best.ny, clearance: clearancePx }); + return best; + }; + + // --- Planets: 2-4, each a distinct world type spread across the sky --- + const planetPalettes = [ + { body: ['#b45309', '#78350f', '#292524', '#0c0a09'], ring: '#fdba74', rim: '#fed7aa', label: 'GAS GIANT' }, + { body: ['#60a5fa', '#1d4ed8', '#1e3a8a', '#0b1220'], ring: '#93c5fd', rim: '#bae6fd', label: 'ICE WORLD // NO ATMOSPHERE' }, + { body: ['#a78bfa', '#6d28d9', '#312e81', '#1e1b4b'], ring: '#c4b5fd', rim: '#e9d5ff', label: 'GAS GIANT // ION STORMS' }, + { body: ['#f87171', '#991b1b', '#450a0a', '#1c0a0a'], ring: '#fca5a5', rim: '#fecaca', label: 'ROCKY WORLD // SEISMIC ACTIVITY' }, + { body: ['#34d399', '#047857', '#022c22', '#020c09'], ring: '#6ee7b7', rim: '#a7f3d0', label: 'ROCKY WORLD // BIOSIGNATURE?' }, + { body: ['#e2e8f0', '#94a3b8', '#334155', '#0f172a'], ring: '#f1f5f9', rim: '#f8fafc', label: 'FROZEN WORLD // DORMANT' } + ]; + const paletteBag = planetPalettes.slice(); + const planets = []; + const planetCount = 2 + Math.floor(Math.random() * 3); // 2-4 + for (let i = 0; i < planetCount; i++) { + const pal = paletteBag.splice(Math.floor(Math.random() * paletteBag.length), 1)[0]; + const moons = []; + const moonCount = Math.floor(Math.random() * 3); // 0-2 + for (let m = 0; m < moonCount; m++) { + moons.push({ + orbitR: rand(1.4, 2.4), + squash: rand(0.3, 0.65), + speed: rand(0.03, 0.09), + phase: Math.random() * Math.PI * 2, + size: rand(0.08, 0.16), + color: pick(['#cbd5e1', '#94a3b8', '#e2e8f0', '#78716c']) + }); + } + const pRadius = rand(0.07, 0.16); + const pPos = placeAway([-0.42, 0.42], [-0.30, 0.32], pRadius * Math.min(this.width, this.height) + 130); + planets.push({ + // At true interstellar range, a planet's apparent drift across a viewport during any + // realistic observation session is imperceptible -- these sit at a fixed screen-relative + // bearing, with only a slow independent sway standing in for that motion. + nx: pPos.nx, + ny: pPos.ny, + radius: pRadius, + palette: pal, + hasRings: Math.random() < 0.5, + ringTilt: rand(-0.55, -0.12), + lightAngle: rand(-Math.PI, Math.PI), + driftSpeedX: rand(-0.006, 0.006), + driftSpeedY: rand(-0.004, 0.004), + driftPhase: Math.random() * Math.PI * 2, + scanPhase: Math.random() * Math.PI * 2, + pingPhase: Math.random() * Math.PI * 2, + pingInterval: rand(9, 15), + moons + }); + } + + // --- Nebula clouds: richer and more varied than the generic 4-blob ambient system --- + const nebulaPalette = ['#6366f1', '#818cf8', '#38bdf8', '#312e81', '#4338ca', '#0ea5e9', '#a5b4fc']; + const nebulae = []; + const nebulaCount = 3 + Math.floor(Math.random() * 3); // 3-5 + for (let i = 0; i < nebulaCount; i++) { + nebulae.push({ + nx: rand(-0.6, 0.6), + ny: rand(-0.5, 0.5), + radius: rand(220, 620), + color: pick(nebulaPalette), + alpha: rand(0.035, 0.09), + driftSpeedX: rand(-0.008, 0.008), + driftSpeedY: rand(-0.006, 0.006), + pulseSpeed: rand(0.06, 0.18), + pulsePhase: Math.random() * Math.PI * 2, + morphSeed: Math.random() * 1000 + }); + } + + // --- Fine dust lanes: thin streak-like clouds for texture, distinct from the soft blobs --- + const dustLanes = []; + const laneCount = 4 + Math.floor(Math.random() * 4); // 4-7 + for (let i = 0; i < laneCount; i++) { + dustLanes.push({ + nx: rand(-0.55, 0.55), + ny: rand(-0.45, 0.45), + length: rand(260, 620), + thickness: rand(30, 90), + angle: rand(-0.6, 0.6), + color: pick(['#4338ca', '#1e1b4b', '#0f172a', '#312e81']), + alpha: rand(0.05, 0.12), + driftSpeed: rand(0.003, 0.01), + phase: Math.random() * Math.PI * 2 + }); + } + + // --- Distant derelict / megastructure silhouettes (generation-ship debris, thematic) --- + const derelicts = []; + if (Math.random() < 0.7) { + const derelictCount = 1 + (Math.random() < 0.3 ? 1 : 0); + for (let i = 0; i < derelictCount; i++) { + const dPos = placeAway([-0.46, 0.46], [-0.4, 0.4], 150); + derelicts.push({ + nx: dPos.nx, + ny: dPos.ny, + scale: rand(0.05, 0.13), + rotation: rand(-0.3, 0.3), + segments: 3 + Math.floor(Math.random() * 4), + beaconPhase: Math.random() * Math.PI * 2, + beaconSpeed: rand(1.2, 2.8), + color: pick(['#334155', '#3f3f46', '#292524', '#1e293b']), + label: 'DERELICT // NO SIGNAL', + scanPhase: Math.random() * Math.PI * 2, + pingPhase: Math.random() * Math.PI * 2, + pingInterval: rand(9, 15) + }); + } + } + + // --- Gravitational / radiation anomalies: rare phenomena gated by the activity slider --- + const anomalyCount = 1 + Math.floor(Math.random() * 2); // 1-2 + const anomalies = []; + const anomalyLabels = { lensing: 'GRAVITATIONAL LENSING', radiation: 'RADIATION SURGE' }; + for (let i = 0; i < anomalyCount; i++) { + const kind = pick(['lensing', 'radiation']); + const aRadius = rand(50, 120); + const aPos = placeAway([-0.4, 0.4], [-0.32, 0.32], aRadius + 150); + anomalies.push({ + nx: aPos.nx, + ny: aPos.ny, + radius: aRadius, + kind, + label: anomalyLabels[kind], + minActivity: rand(0.15, 0.55), + pulseSpeed: rand(0.15, 0.4), + pulsePhase: Math.random() * Math.PI * 2, + color: pick(['#818cf8', '#38bdf8', '#f43f5e']), + scanPhase: Math.random() * Math.PI * 2, + pingPhase: Math.random() * Math.PI * 2, + pingInterval: rand(9, 15) + }); + } + + this.deepSpaceField = { planets, nebulae, dustLanes, derelicts, anomalies }; + return this.deepSpaceField; + } + + renderDeepSpaceField(ctx, cx, cy) { + if (!this.deepSpaceField) this.regenerateDeepSpaceField(); + const field = this.deepSpaceField; + const lighting = this.getLightingModifiers(); + const activity = (window.observationActivity !== undefined ? window.observationActivity : 0.6); + const isWarp = this.flightMode === 'warp'; + const warpStretch = isWarp ? 1 + Math.min(2.2, this.warpSpeed * 0.5) : 1; + const bass = this.audioEnergy ? this.audioEnergy.bass : 0; + const minDim = Math.min(this.width, this.height); + + // 1. Dust lanes -- furthest back, softest, faint streaked texture + for (const lane of field.dustLanes) { + const t = this.time * lane.driftSpeed + lane.phase; + const bx = cx + lane.nx * this.width + Math.sin(t) * 40; + const by = cy + lane.ny * this.height + Math.cos(t * 0.7) * 30; + ctx.save(); + ctx.translate(bx, by); + ctx.rotate(lane.angle); + const grad = ctx.createLinearGradient(-lane.length / 2, 0, lane.length / 2, 0); + grad.addColorStop(0, 'transparent'); + grad.addColorStop(0.5, hexToRgba(lane.color, lane.alpha * lighting.nebulaIntensity)); + grad.addColorStop(1, 'transparent'); + ctx.fillStyle = grad; + ctx.fillRect(-lane.length / 2, -lane.thickness / 2, lane.length, lane.thickness); + ctx.restore(); + } + + // 2. Nebula clouds -- stretch into a relativistic streak while under warp + for (const neb of field.nebulae) { + const driftX = Math.sin(this.time * neb.driftSpeedX * 10 + neb.morphSeed) * 60; + const driftY = Math.cos(this.time * neb.driftSpeedY * 10 + neb.morphSeed * 1.3) * 45; + const bx = cx + neb.nx * this.width + driftX; + const by = cy + neb.ny * this.height + driftY; + const pulse = 1 + Math.sin(this.time * neb.pulseSpeed + neb.pulsePhase) * 0.15; + const radius = neb.radius * pulse; + const alpha = neb.alpha * lighting.nebulaIntensity; + + ctx.save(); + if (isWarp) { + ctx.translate(bx, by); + ctx.scale(warpStretch, 1); + ctx.translate(-bx, -by); + } + const grad = ctx.createRadialGradient(bx, by, radius * 0.1, bx, by, radius); + grad.addColorStop(0, hexToRgba(neb.color, alpha)); + grad.addColorStop(0.5, hexToRgba(neb.color, alpha * 0.4)); + grad.addColorStop(1, 'transparent'); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, this.width, this.height); + ctx.restore(); + } + + // 3. Derelicts / distant megastructures -- angular hull silhouette with a blinking beacon + for (const d of field.derelicts) { + const bx = cx + d.nx * this.width; + const by = cy + d.ny * this.height; + const scale = d.scale * minDim; + ctx.save(); + ctx.translate(bx, by); + ctx.rotate(d.rotation); + ctx.globalAlpha = 0.55; + ctx.fillStyle = d.color; + ctx.beginPath(); + ctx.moveTo(-scale, 0); + for (let s = 0; s < d.segments; s++) { + const ang = (s / d.segments) * Math.PI - Math.PI / 2; + ctx.lineTo(Math.cos(ang) * scale, Math.sin(ang) * scale * 0.3); + } + ctx.lineTo(scale, 0); + ctx.closePath(); + ctx.fill(); + ctx.globalAlpha = 0.3 + Math.max(0, Math.sin(this.time * d.beaconSpeed + d.beaconPhase)) * 0.7; + ctx.fillStyle = '#f43f5e'; + ctx.beginPath(); + ctx.arc(scale * 0.7, 0, Math.max(1.5, scale * 0.04), 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + + this.drawComputerCallout(ctx, bx, by, scale, d.label, '#f43f5e', d.scanPhase, d.pingPhase, d.pingInterval); + } + + // 4. Planets with atmosphere, rings and orbiting moons + for (const p of field.planets) { + const driftX = Math.sin(this.time * p.driftSpeedX * 10 + p.driftPhase) * 25; + const driftY = Math.cos(this.time * p.driftSpeedY * 10 + p.driftPhase * 1.2) * 18; + const px = cx + p.nx * this.width + driftX; + const py = cy + p.ny * this.height + driftY; + const pr = p.radius * minDim; + + // Rings are split into a back arc and a front arc so they read as an actual ring + // encircling a sphere (like Saturn) rather than a flat ellipse laid over a circle -- + // the back arc is drawn now, then the opaque planet body occludes its far half, then + // the front arc is drawn again afterward so it visibly crosses in front of the sphere. + if (p.hasRings) { + ctx.save(); + ctx.translate(px, py); + ctx.rotate(p.ringTilt); + for (let r = pr * 1.3; r <= pr * 2.0; r += pr * 0.12) { + ctx.beginPath(); + ctx.ellipse(0, 0, r, r * 0.26, 0, Math.PI, Math.PI * 2); + ctx.strokeStyle = hexToRgba(p.palette.ring, 0.1 + Math.sin(r * 0.15) * 0.06); + ctx.lineWidth = pr * 0.05; + ctx.stroke(); + } + ctx.restore(); + } + + const bodyGrad = ctx.createRadialGradient( + px - pr * Math.cos(p.lightAngle) * 0.35, py - pr * Math.sin(p.lightAngle) * 0.35, pr * 0.05, + px, py, pr + ); + bodyGrad.addColorStop(0, p.palette.body[0]); + bodyGrad.addColorStop(0.5, p.palette.body[1]); + bodyGrad.addColorStop(0.85, p.palette.body[2]); + bodyGrad.addColorStop(1, p.palette.body[3]); + ctx.fillStyle = bodyGrad; + ctx.beginPath(); + ctx.arc(px, py, pr, 0, Math.PI * 2); + ctx.fill(); + + // Atmospheric rim glow, gently audio-reactive + ctx.strokeStyle = hexToRgba(p.palette.rim, 0.4 + bass * 0.15); + ctx.lineWidth = pr * 0.06; + ctx.beginPath(); + ctx.arc(px, py, pr * 1.01, 0, Math.PI * 2); + ctx.stroke(); + + if (p.hasRings) { + ctx.save(); + ctx.translate(px, py); + ctx.rotate(p.ringTilt); + for (let r = pr * 1.3; r <= pr * 2.0; r += pr * 0.12) { + ctx.beginPath(); + ctx.ellipse(0, 0, r, r * 0.26, 0, 0, Math.PI); + ctx.strokeStyle = hexToRgba(p.palette.ring, 0.16 + Math.sin(r * 0.15) * 0.08); + ctx.lineWidth = pr * 0.05; + ctx.stroke(); + } + ctx.restore(); + } + + for (const moon of p.moons) { + const mAng = this.time * moon.speed + moon.phase; + const mx = px + Math.cos(mAng) * pr * moon.orbitR; + const my = py + Math.sin(mAng) * pr * moon.orbitR * moon.squash; + ctx.save(); + ctx.globalAlpha = 0.85; + ctx.fillStyle = moon.color; + ctx.beginPath(); + ctx.arc(mx, my, pr * moon.size, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + + this.drawComputerCallout(ctx, px, py, pr, p.palette.label, p.palette.rim, p.scanPhase, p.pingPhase, p.pingInterval); + } + + // 5. Gravitational / radiation anomalies -- rare, activity-gated, fade in past their threshold + for (const a of field.anomalies) { + if (activity < a.minActivity) continue; + const ax = cx + a.nx * this.width; + const ay = cy + a.ny * this.height; + const pulse = 0.5 + Math.sin(this.time * a.pulseSpeed + a.pulsePhase) * 0.5; + const visibility = Math.min(1, (activity - a.minActivity) / 0.25); + const alpha = pulse * 0.5 * visibility; + if (alpha <= 0) continue; + + if (a.kind === 'lensing') { + for (let ring = 0; ring < 3; ring++) { + const rr = a.radius * (0.5 + ring * 0.35) * (1 + pulse * 0.15); + ctx.beginPath(); + ctx.arc(ax, ay, rr, 0, Math.PI * 2); + ctx.strokeStyle = hexToRgba(a.color, alpha * (1 - ring * 0.25)); + ctx.lineWidth = 2; + ctx.stroke(); + } + } else { + const grad = ctx.createRadialGradient(ax, ay, 0, ax, ay, a.radius); + grad.addColorStop(0, hexToRgba(a.color, alpha * 0.6)); + grad.addColorStop(1, 'transparent'); + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.arc(ax, ay, a.radius, 0, Math.PI * 2); + ctx.fill(); + } + + // Computer callout instead of a bare floating label -- also keeps the text off the + // anomaly itself via the bracket's leader line, rather than sitting on top of it (or, + // as could happen before, on top of an unrelated planet placed nearby). + this.drawComputerCallout(ctx, ax, ay, a.radius, a.label, a.color, a.scanPhase, a.pingPhase, a.pingInterval, visibility); + } + } + + // Shared "computer console" callout: LCARS corner brackets sized to the object, a leader + // line, and a small mono-font tag -- the same drawing technique already used for ship + // traffic reticles (renderTargetReticles), extended here to any discrete Deep Space object. + // Idles with a slow breathing pulse and flashes briefly on a per-object interval, like a + // sensor periodically re-confirming a lock, rather than sitting static on screen. + drawComputerCallout(ctx, x, y, radius, label, color, scanPhase, pingPhase, pingInterval, intensityMul = 1) { + const breathe = 0.55 + Math.sin(this.time * 0.6 + scanPhase) * 0.2; + const cycle = pingInterval || 12; + const t = (this.time + pingPhase) % cycle; + const ping = t < 0.5 ? (1 - t / 0.5) : 0; + const alpha = Math.min(1, breathe + ping * 0.5) * intensityMul; + if (alpha <= 0.02) return; + + const boxSize = Math.max(18, radius * 1.18); + const bLen = Math.max(5, boxSize * 0.22); + const onRight = x > this.width * 0.55; + const dir = onRight ? -1 : 1; + + ctx.save(); + ctx.font = '11px "Share Tech Mono", monospace'; + ctx.textAlign = onRight ? 'right' : 'left'; + ctx.strokeStyle = hexToRgba(color, alpha); + ctx.fillStyle = hexToRgba(color, Math.min(1, alpha + 0.15)); + ctx.lineWidth = 1.2; + + ctx.beginPath(); + ctx.moveTo(x - boxSize, y - boxSize + bLen); + ctx.lineTo(x - boxSize, y - boxSize); + ctx.lineTo(x - boxSize + bLen, y - boxSize); + ctx.moveTo(x + boxSize - bLen, y - boxSize); + ctx.lineTo(x + boxSize, y - boxSize); + ctx.lineTo(x + boxSize, y - boxSize + bLen); + ctx.moveTo(x - boxSize, y + boxSize - bLen); + ctx.lineTo(x - boxSize, y + boxSize); + ctx.lineTo(x - boxSize + bLen, y + boxSize); + ctx.moveTo(x + boxSize - bLen, y + boxSize); + ctx.lineTo(x + boxSize, y + boxSize); + ctx.lineTo(x + boxSize, y + boxSize - bLen); + ctx.stroke(); + + // Route the leader line downward instead of upward when the object sits close enough to + // the top edge that an upward line would run into the HUD header text. + const routeDown = (y - boxSize - 40) < 70; + const vDir = routeDown ? 1 : -1; + const cornerX = onRight ? x - boxSize : x + boxSize; + const cornerY = routeDown ? y + boxSize : y - boxSize; + // Two objects at similar heights can still park their labels at the same offset even + // when their brackets are cleanly separated horizontally -- a small deterministic + // per-object jitter (derived from its own scanPhase, so it's stable frame to frame) + // spreads those cases apart without needing true label-bounding-box collision checks. + const kinkJitter = Math.sin(scanPhase * 1.7) * 16; + const kinkX = cornerX + dir * 22; + const kinkY = cornerY + vDir * 14 + kinkJitter; + const endX = cornerX + dir * 130; + ctx.beginPath(); + ctx.moveTo(cornerX, cornerY); + ctx.lineTo(kinkX, kinkY); + ctx.lineTo(endX, kinkY); + ctx.stroke(); + + ctx.fillText(label, cornerX + dir * 26, kinkY + (routeDown ? 14 : -4)); + ctx.restore(); + } + + renderStarfield(ctx, cx, cy) { + const fov = 420; + const prof = this.starProfile || DEFAULT_STARFIELD_PROFILE; + const isWarp = this.flightMode === 'warp' && prof.warpStreaks !== false; + const brightness = this.getLightingModifiers().starBrightness; + + for (let i = 0; i < this.stars.length; i++) { + const star = this.stars[i]; + const sx = cx + (star.x / star.z) * fov; + const sy = cy + (star.y / star.z) * fov; + + if (sx < -20 || sx > this.width + 20 || sy < -20 || sy > this.height + 20) { + continue; + } + + const normZ = 1 - star.z / 1200; + const alpha = Math.max(0.15, Math.min(1, normZ * (isWarp ? 1.0 : (0.7 + Math.sin(this.time * star.twinkleSpeed + star.twinklePhase) * 0.3)) * brightness)); + + if (isWarp) { + // Relativistic Warp Streaks + const spx = cx + (star.x / star.pz) * fov; + const spy = cy + (star.y / star.pz) * fov; + + ctx.beginPath(); + ctx.moveTo(spx, spy); + ctx.lineTo(sx, sy); + ctx.strokeStyle = star.color; + ctx.lineWidth = star.size * (1 + normZ * 1.5); + ctx.globalAlpha = alpha; + ctx.stroke(); + } else { + // Cruise Stars + ctx.beginPath(); + ctx.arc(sx, sy, star.size * (0.8 + normZ * 0.8), 0, Math.PI * 2); + ctx.fillStyle = star.color; + ctx.globalAlpha = alpha; + ctx.fill(); + } + } + ctx.globalAlpha = 1.0; + } + + renderTrafficAndEvents(ctx) { + // 1. Draw traffic engine trails + for (const ship of this.traffic) { + for (const pt of ship.particles) { + ctx.beginPath(); + ctx.arc(pt.x, pt.y, pt.size * pt.alpha, 0, Math.PI * 2); + ctx.fillStyle = pt.color; + ctx.globalAlpha = pt.alpha; + ctx.fill(); + } + ctx.globalAlpha = 1.0; + + // Draw Ship Silhouette / Vessel Graphic + this.drawShipVessel(ctx, ship); + } + + // 2. Draw Dynamic Events (Warp Flash, Comets) + for (const ev of this.events) { + if (ev.kind === 'warp-flash') { + const prog = ev.life / ev.maxLife; + const radius = prog * 160; + const alpha = Math.max(0, 1 - prog); + + ctx.save(); + ctx.translate(ev.x, ev.y); + + // Radial starburst + const fgrad = ctx.createRadialGradient(0, 0, 0, 0, 0, radius); + fgrad.addColorStop(0, '#ffffff'); + fgrad.addColorStop(0.3, '#38bdf8'); + fgrad.addColorStop(1, 'transparent'); + ctx.fillStyle = fgrad; + ctx.globalAlpha = alpha; + ctx.beginPath(); + ctx.arc(0, 0, radius, 0, Math.PI * 2); + ctx.fill(); + + // Anamorphic horizontal streak + ctx.strokeStyle = '#93c5fd'; + ctx.lineWidth = (1 - prog) * 6; + ctx.beginPath(); + ctx.moveTo(-radius * 3.5, 0); + ctx.lineTo(radius * 3.5, 0); + ctx.stroke(); + + ctx.restore(); + } else if (ev.kind === 'comet') { + const prog = ev.life / ev.maxLife; + const cx = ev.x + ev.vx * ev.life; + const cy = ev.y + ev.vy * ev.life; + const alpha = Math.sin(prog * Math.PI) * 0.85; + + ctx.save(); + ctx.globalAlpha = alpha; + + // Tail + ctx.beginPath(); + ctx.moveTo(cx, cy); + ctx.lineTo(cx - ev.vx * 0.7, cy - ev.vy * 0.7); + ctx.strokeStyle = 'rgba(186, 230, 253, 0.6)'; + ctx.lineWidth = 4; + ctx.stroke(); + + // Head + ctx.fillStyle = '#ffffff'; + ctx.beginPath(); + ctx.arc(cx, cy, 5, 0, Math.PI * 2); + ctx.fill(); + + ctx.restore(); + } + } + } + + drawShipVessel(ctx, ship) { + ctx.save(); + ctx.translate(ship.x, ship.y); + + const heading = Math.atan2(ship.vy, ship.vx); + ctx.rotate(heading); + ctx.scale(ship.scale, ship.scale); + + if (ship.type === 'shuttle') { + // Sleek Starfleet Shuttlecraft + // Hull + ctx.fillStyle = '#e2e8f0'; + ctx.beginPath(); + ctx.moveTo(28, 0); + ctx.lineTo(12, -10); + ctx.lineTo(-24, -10); + ctx.lineTo(-28, -6); + ctx.lineTo(-28, 6); + ctx.lineTo(-24, 10); + ctx.lineTo(12, 10); + ctx.closePath(); + ctx.fill(); + + // Cockpit windshield + ctx.fillStyle = '#0f172a'; + ctx.beginPath(); + ctx.moveTo(22, 0); + ctx.lineTo(10, -6); + ctx.lineTo(6, -6); + ctx.lineTo(6, 6); + ctx.lineTo(10, 6); + ctx.closePath(); + ctx.fill(); + + // Warp Nacelles with Glowing Blue Field + ctx.fillStyle = '#94a3b8'; + ctx.fillRect(-22, -16, 26, 4); + ctx.fillRect(-22, 12, 26, 4); + + ctx.fillStyle = '#38bdf8'; + ctx.shadowColor = '#38bdf8'; + ctx.shadowBlur = 8; + ctx.fillRect(-18, -15, 18, 2); + ctx.fillRect(-18, 13, 18, 2); + + // Red Bussard Collectors + ctx.fillStyle = '#ef4444'; + ctx.shadowColor = '#ef4444'; + ctx.beginPath(); + ctx.arc(5, -14, 2, 0, Math.PI * 2); + ctx.arc(5, 14, 2, 0, Math.PI * 2); + ctx.fill(); + } else if (ship.type === 'cruiser') { + // Starfleet Capital Cruiser Silhouette + ctx.fillStyle = '#cbd5e1'; + // Primary Saucer + ctx.beginPath(); + ctx.ellipse(30, 0, 24, 14, 0, 0, Math.PI * 2); + ctx.fill(); + + // Secondary Engineering Hull & Neck + ctx.fillStyle = '#94a3b8'; + ctx.fillRect(-15, -5, 34, 10); + ctx.beginPath(); + ctx.moveTo(-15, -4); + ctx.lineTo(-45, -3); + ctx.lineTo(-45, 3); + ctx.lineTo(-15, 4); + ctx.closePath(); + ctx.fill(); + + // Dual Nacelle Struts and Nacelles + ctx.strokeStyle = '#64748b'; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.moveTo(-10, 0); + ctx.lineTo(-25, -20); + ctx.moveTo(-10, 0); + ctx.lineTo(-25, 20); + ctx.stroke(); + + ctx.fillStyle = '#3b82f6'; + ctx.shadowColor = '#60a5fa'; + ctx.shadowBlur = 10; + ctx.fillRect(-45, -22, 42, 5); + ctx.fillRect(-45, 17, 42, 5); + + // Bussard + ctx.fillStyle = '#ef4444'; + ctx.beginPath(); + ctx.arc(-2, -19.5, 2.5, 0, Math.PI * 2); + ctx.arc(-2, 19.5, 2.5, 0, Math.PI * 2); + ctx.fill(); + } else if (ship.type === 'tardis') { + // Whoniverse TARDIS tumbling in vortex + ctx.rotate(this.time * 1.5); + ctx.fillStyle = '#1e3a8a'; + ctx.fillRect(-12, -18, 24, 36); + + // Panels + ctx.fillStyle = '#172554'; + ctx.fillRect(-10, -15, 9, 14); + ctx.fillRect(1, -15, 9, 14); + ctx.fillRect(-10, 1, 9, 14); + ctx.fillRect(1, 1, 9, 14); + + // Flashing amber lantern + ctx.fillStyle = Math.sin(this.time * 6) > 0 ? '#fbbf24' : '#78350f'; + ctx.shadowColor = '#fbbf24'; + ctx.shadowBlur = 8; + ctx.beginPath(); + ctx.arc(0, -21, 3.5, 0, Math.PI * 2); + ctx.fill(); + } else if (ship.type === 'fighterwing') { + // Military Fighter Wing - tight 3-ship delta formation + const drawFighter = (ox, oy) => { + ctx.save(); + ctx.translate(ox, oy); + ctx.fillStyle = '#4b5563'; + ctx.beginPath(); + ctx.moveTo(16, 0); + ctx.lineTo(-10, -9); + ctx.lineTo(-6, 0); + ctx.lineTo(-10, 9); + ctx.closePath(); + ctx.fill(); + + ctx.fillStyle = '#eab308'; + ctx.shadowColor = '#eab308'; + ctx.shadowBlur = 6; + ctx.beginPath(); + ctx.arc(-9, 0, 1.6, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + }; + drawFighter(0, 0); + drawFighter(-16, -14); + drawFighter(-16, 14); + } else if (ship.type === 'bioshippod') { + // Organic Bioship Spawn Pod - pulsing membrane sac drifting through space + const pulse = 1 + Math.sin(this.time * 2.4) * 0.12; + ctx.fillStyle = '#065f46'; + ctx.beginPath(); + ctx.ellipse(0, 0, 22 * pulse, 13 * pulse, 0, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = 'rgba(52, 211, 153, 0.55)'; + ctx.shadowColor = '#34d399'; + ctx.shadowBlur = 12; + ctx.beginPath(); + ctx.ellipse(0, 0, 13 * pulse, 7 * pulse, 0, 0, Math.PI * 2); + ctx.fill(); + + ctx.shadowBlur = 0; + ctx.strokeStyle = 'rgba(167, 243, 208, 0.5)'; + ctx.lineWidth = 1; + for (let v = -1; v <= 1; v++) { + ctx.beginPath(); + ctx.moveTo(-18, v * 6); + ctx.lineTo(18, v * 6); + ctx.stroke(); + } + } else if (ship.type === 'retrosaucer') { + // Retro-Future Atomic-Age Flying Saucer with a chasing rim-light pattern + ctx.fillStyle = '#d1d5db'; + ctx.beginPath(); + ctx.ellipse(0, 2, 26, 8, 0, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = '#9ca3af'; + ctx.beginPath(); + ctx.ellipse(0, -3, 12, 9, 0, 0, Math.PI * 2); + ctx.fill(); + + const litIndex = Math.floor(this.time * 4) % 6; + for (let i = 0; i < 6; i++) { + const ang = (i / 6) * Math.PI * 2; + const isLit = i === litIndex; + ctx.fillStyle = isLit ? '#4ade80' : 'rgba(74, 222, 128, 0.35)'; + ctx.shadowColor = '#4ade80'; + ctx.shadowBlur = isLit ? 8 : 0; + ctx.beginPath(); + ctx.arc(Math.cos(ang) * 22, 2 + Math.sin(ang) * 6, 2, 0, Math.PI * 2); + ctx.fill(); + } + } else { + // Heavy Industrial Cargo Freighter + ctx.fillStyle = '#78350f'; + ctx.fillRect(-35, -12, 55, 24); + ctx.fillStyle = '#b45309'; + ctx.fillRect(12, -8, 16, 16); + + // Orange Thrusters + ctx.fillStyle = '#f97316'; + ctx.shadowColor = '#ea580c'; + ctx.shadowBlur = 10; + ctx.fillRect(-38, -9, 4, 6); + ctx.fillRect(-38, 3, 4, 6); + } + + ctx.restore(); + } + + renderTargetReticles(ctx) { + ctx.save(); + ctx.font = '11px "Share Tech Mono", monospace'; + ctx.fillStyle = '#38bdf8'; + ctx.strokeStyle = '#38bdf8'; + ctx.lineWidth = 1.2; + + for (const ship of this.traffic) { + const boxSize = 34 * ship.scale; + const x = ship.x; + const y = ship.y; + + // Draw LCARS corner reticle brackets + const bLen = 8; + // Top-left + ctx.beginPath(); + ctx.moveTo(x - boxSize, y - boxSize + bLen); + ctx.lineTo(x - boxSize, y - boxSize); + ctx.lineTo(x - boxSize + bLen, y - boxSize); + // Top-right + ctx.moveTo(x + boxSize - bLen, y - boxSize); + ctx.lineTo(x + boxSize, y - boxSize); + ctx.lineTo(x + boxSize, y - boxSize + bLen); + // Bottom-left + ctx.moveTo(x - boxSize, y + boxSize - bLen); + ctx.lineTo(x - boxSize, y + boxSize); + ctx.lineTo(x - boxSize + bLen, y + boxSize); + // Bottom-right + ctx.moveTo(x + boxSize - bLen, y + boxSize); + ctx.lineTo(x + boxSize, y + boxSize); + ctx.lineTo(x + boxSize, y + boxSize - bLen); + ctx.stroke(); + + // Leader line and text tag + ctx.beginPath(); + ctx.moveTo(x + boxSize, y - boxSize); + ctx.lineTo(x + boxSize + 22, y - boxSize - 14); + ctx.lineTo(x + boxSize + 130, y - boxSize - 14); + ctx.stroke(); + + ctx.fillText(ship.label, x + boxSize + 26, y - boxSize - 18); + } + + ctx.restore(); + } + + renderWaveform() { + if (!this.waveformCtx || !this.waveformCanvas) return; + const ctx = this.waveformCtx; + const w = this.waveformCanvas.width / (this.dpr || 1); + const h = this.waveformCanvas.height / (this.dpr || 1); + + ctx.clearRect(0, 0, w, h); + + if (this.am && this.am.analyser) { + this.am.analyser.getByteFrequencyData(this.analyserData); + } + + const bars = 30; + const barW = Math.max(3, (w / bars) - 2); + const accent = getComputedStyle(document.body).getPropertyValue('--primary-accent').trim() || '#ff9900'; + + ctx.fillStyle = accent; + for (let i = 0; i < bars; i++) { + const val = (this.analyserData[i * 2] || 0) / 255; + const barH = Math.max(2, val * (h - 4)); + ctx.fillRect(i * (barW + 2), h - barH, barW, barH); + } + } + + // ========================================================================= + // Space Stations: Procedural 360-Degree Rotating Panorama + // + // A station rotating in place sees a fixed sky wheel past the window and come back + // around again - there is no forward motion, so no pilot-style starfield rush here. + // The sky is generated once per page load in normalized azimuth/elevation space, so + // it survives window resizes, preset switches and re-entering Observation Mode, and + // only changes when the page is reloaded. + // ========================================================================= + + regenerateStationPanorama() { + const rand = (min, max) => min + Math.random() * (max - min); + const pick = (arr) => arr[Math.floor(Math.random() * arr.length)]; + + // --- Background stars spread around the full circle --- + const stars = []; + for (let i = 0; i < 900; i++) { + stars.push({ + a: Math.random(), + e: (Math.random() - 0.5) * 2, + size: Math.pow(Math.random(), 2.2) * 2.4 + 0.5, // mostly pinpricks, a few bright ones + color: pick(this.starColors), + brightness: 0.35 + Math.random() * 0.65, + twinkleSpeed: 0.6 + Math.random() * 2.6, + twinklePhase: Math.random() * Math.PI * 2 + }); + } + + // --- Deep space nebula patches --- + const nebulaPalette = ['#38bdf8', '#0ea5e9', '#1e3a8a', '#6366f1', '#8b5cf6', '#0891b2', '#f472b6']; + const nebulae = []; + const nebulaCount = 3 + Math.floor(Math.random() * 3); + for (let i = 0; i < nebulaCount; i++) { + nebulae.push({ + a: Math.random(), + e: rand(-0.7, 0.7), + radius: rand(0.35, 1.1), + color: pick(nebulaPalette), + alpha: rand(0.05, 0.12), + pulseSpeed: rand(0.08, 0.22), + pulsePhase: Math.random() * Math.PI * 2 + }); + } + + // --- Planets, spaced apart so two never stack on the same bearing --- + const planetPalettes = [ + { c0: '#60a5fa', c1: '#1d4ed8', c2: '#0b1220', rim: '#bae6fd' }, + { c0: '#fbbf24', c1: '#b45309', c2: '#1c1917', rim: '#fed7aa' }, + { c0: '#4ade80', c1: '#15803d', c2: '#052e16', rim: '#bbf7d0' }, + { c0: '#e0f2fe', c1: '#7dd3fc', c2: '#0c4a6e', rim: '#f0f9ff' }, + { c0: '#fb923c', c1: '#9a3412', c2: '#1c1917', rim: '#fdba74' }, + { c0: '#c084fc', c1: '#6d28d9', c2: '#1e1b4b', rim: '#e9d5ff' } + ]; + const planets = []; + const planetCount = 1 + Math.floor(Math.random() * 2); + for (let i = 0; i < planetCount; i++) { + const pal = planetPalettes.splice(Math.floor(Math.random() * planetPalettes.length), 1)[0]; + const moons = []; + const moonCount = Math.floor(Math.random() * 3); + for (let m = 0; m < moonCount; m++) { + moons.push({ + orbit: rand(1.5, 2.6), + squash: rand(0.25, 0.6), + speed: rand(0.05, 0.15), + phase: Math.random() * Math.PI * 2, + size: rand(0.09, 0.17) + }); + } + planets.push({ + a: 0, // bearings are assigned below, evenly spread around the circle + e: rand(-0.35, 0.35), + radius: rand(0.18, 0.42), // fraction of the window band height + palette: pal, + hasRings: Math.random() < 0.45, + ringTilt: rand(-0.6, -0.15), + bandCount: 2 + Math.floor(Math.random() * 4), + lightAngle: rand(-Math.PI, Math.PI), + moons + }); + } + + // --- A distant sun --- + const sun = { + a: 0, + e: rand(-0.4, 0.4), + radius: rand(0.05, 0.1), + color: pick(['#fef9c3', '#fed7aa', '#e0f2fe', '#fecaca']) + }; + + // --- Far-off sister stations with blinking beacons --- + const structures = []; + const structureCount = 1 + Math.floor(Math.random() * 2); + for (let i = 0; i < structureCount; i++) { + structures.push({ + a: 0, + e: rand(-0.45, 0.45), + scale: rand(0.07, 0.15), + hasRing: Math.random() < 0.6, + panelCount: 1 + Math.floor(Math.random() * 2), + beaconSpeed: rand(1.4, 3.2), + beaconPhase: Math.random() * Math.PI * 2 + }); + } + + // --- An asteroid cluster --- + const rocks = []; + const clusterE = rand(-0.5, 0.5); + const rockCount = 9 + Math.floor(Math.random() * 10); + for (let i = 0; i < rockCount; i++) { + const verts = []; + const vertCount = 5 + Math.floor(Math.random() * 4); + for (let v = 0; v < vertCount; v++) { + verts.push({ + ang: (v / vertCount) * Math.PI * 2, + r: 0.6 + Math.random() * 0.5 + }); + } + rocks.push({ + da: rand(-0.035, 0.035), + de: rand(-0.35, 0.35), + size: rand(0.012, 0.035), + verts, + rotSpeed: rand(-0.35, 0.35), + rotPhase: Math.random() * Math.PI * 2, + shade: pick(['#57534e', '#44403c', '#78716c', '#3f3f46']) + }); + } + + const asteroids = { a: 0, e: clusterE, rocks }; + + // --- Spread the major features evenly around the circle --- + // Purely random bearings tend to clump: everything piles into one window while the + // rest of the revolution is empty sky. Slotting them gives a steady rhythm of one + // notable object drifting through every so often, which is the point of a 360 view. + const majors = [...planets, sun, ...structures, asteroids]; + for (let i = majors.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [majors[i], majors[j]] = [majors[j], majors[i]]; + } + const slot = 1 / majors.length; + const baseBearing = Math.random(); + majors.forEach((feature, i) => { + const bearing = baseBearing + (i + rand(-0.28, 0.28)) * slot; + feature.a = ((bearing % 1) + 1) % 1; + }); + + this.stationPanorama = { stars, nebulae, planets, sun, structures, asteroids }; + return this.stationPanorama; + } + + // The station stage SVG uses viewBox "0 0 1600 900" with preserveAspectRatio="xMidYMid slice", + // so its window cutout (220,140 -> 1380,680) lands on screen like a CSS `cover` background. + // Matching that math keeps celestial objects transiting through the real opening. + getStationViewportRect() { + if (!this.showViewport) { + return { x: 0, y: 0, w: this.width, h: this.height, cx: this.width / 2, cy: this.height / 2 }; + } + const scale = Math.max(this.width / 1600, this.height / 900); + const offX = (this.width - 1600 * scale) / 2; + const offY = (this.height - 900 * scale) / 2; + const x = offX + 220 * scale; + const y = offY + 140 * scale; + const w = 1160 * scale; + const h = 540 * scale; + return { x, y, w, h, cx: x + w / 2, cy: y + h / 2 }; + } + + updateStationPanorama(dt) { + if (!this.stationPanorama) this.regenerateStationPanorama(); + const period = this.stationRevolutionSeconds || 240; + this.stationRotation = (this.stationRotation + dt / period) % 1; + } + + // Maps a panorama bearing to a screen x, wrapping seamlessly through 360 degrees. + stationProjectX(a, rect) { + let rel = (a - this.stationRotation + 0.5) % 1; + if (rel < 0) rel += 1; + rel -= 0.5; + return rect.cx + (rel / (this.stationFov || 0.22)) * rect.w; + } + + stationProjectY(e, rect) { + return rect.cy + e * rect.h * 0.58; + } + + renderStationPanorama(ctx) { + if (!this.stationPanorama) this.regenerateStationPanorama(); + const pano = this.stationPanorama; + const rect = this.getStationViewportRect(); + const lighting = this.getLightingModifiers(); + const bass = this.audioEnergy ? this.audioEnergy.bass : 0; + + // --- Nebula patches --- + for (const neb of pano.nebulae) { + const radius = neb.radius * rect.h * (1 + Math.sin(this.time * neb.pulseSpeed + neb.pulsePhase) * 0.12); + const nx = this.stationProjectX(neb.a, rect); + if (nx < rect.x - radius * 1.2 || nx > rect.x + rect.w + radius * 1.2) continue; + const ny = this.stationProjectY(neb.e, rect); + const alpha = Math.max(0, (neb.alpha + bass * 0.06) * lighting.nebulaIntensity); + + const grad = ctx.createRadialGradient(nx, ny, radius * 0.08, nx, ny, radius); + grad.addColorStop(0, hexToRgba(neb.color, alpha)); + grad.addColorStop(0.55, hexToRgba(neb.color, alpha * 0.4)); + grad.addColorStop(1, 'transparent'); + ctx.fillStyle = grad; + ctx.fillRect(rect.x - 40, rect.y - 40, rect.w + 80, rect.h + 80); + } + + // --- Background stars --- + const brightness = lighting.starBrightness; + for (const star of pano.stars) { + const sx = this.stationProjectX(star.a, rect); + if (sx < rect.x - 20 || sx > rect.x + rect.w + 20) continue; + const sy = this.stationProjectY(star.e, rect); + const twinkle = 0.72 + Math.sin(this.time * star.twinkleSpeed + star.twinklePhase) * 0.28; + ctx.globalAlpha = Math.max(0.05, Math.min(1, star.brightness * twinkle * brightness)); + ctx.fillStyle = star.color; + ctx.beginPath(); + ctx.arc(sx, sy, star.size, 0, Math.PI * 2); + ctx.fill(); + } + ctx.globalAlpha = 1; + + // --- Distant sun --- + this.renderStationSun(ctx, pano.sun, rect, brightness); + + // --- Asteroid cluster --- + this.renderStationAsteroids(ctx, pano.asteroids, rect); + + // --- Planets --- + for (const planet of pano.planets) { + this.renderStationPlanet(ctx, planet, rect); + } + + // --- Far-off sister stations --- + for (const structure of pano.structures) { + this.renderStationStructure(ctx, structure, rect); + } + } + + renderStationSun(ctx, sun, rect, brightness) { + const sx = this.stationProjectX(sun.a, rect); + const r = sun.radius * rect.h; + if (sx < rect.x - r * 8 || sx > rect.x + rect.w + r * 8) return; + const sy = this.stationProjectY(sun.e, rect); + + ctx.save(); + const glow = ctx.createRadialGradient(sx, sy, r * 0.2, sx, sy, r * 6); + glow.addColorStop(0, hexToRgba(sun.color, 0.5 * brightness)); + glow.addColorStop(0.25, hexToRgba(sun.color, 0.14 * brightness)); + glow.addColorStop(1, 'transparent'); + ctx.fillStyle = glow; + ctx.beginPath(); + ctx.arc(sx, sy, r * 6, 0, Math.PI * 2); + ctx.fill(); + + // Core + ctx.fillStyle = '#ffffff'; + ctx.beginPath(); + ctx.arc(sx, sy, r, 0, Math.PI * 2); + ctx.fill(); + + // Soft anamorphic streak - gradient stroke so it fades out instead of ending as a hard bar + const streak = ctx.createLinearGradient(sx - r * 7, sy, sx + r * 7, sy); + streak.addColorStop(0, 'transparent'); + streak.addColorStop(0.5, hexToRgba(sun.color, 0.3 * brightness)); + streak.addColorStop(1, 'transparent'); + ctx.strokeStyle = streak; + ctx.lineWidth = r * 0.22; + ctx.beginPath(); + ctx.moveTo(sx - r * 7, sy); + ctx.lineTo(sx + r * 7, sy); + ctx.stroke(); + ctx.restore(); + } + + renderStationPlanet(ctx, planet, rect) { + const pr = planet.radius * rect.h * 0.5; + const px = this.stationProjectX(planet.a, rect); + const reach = planet.hasRings ? pr * 2.4 : pr * 2.8; + if (px < rect.x - reach || px > rect.x + rect.w + reach) return; + const py = this.stationProjectY(planet.e, rect); + const pal = planet.palette; + + ctx.save(); + + // Moons currently behind the planet + for (const moon of planet.moons) { + const ang = this.time * moon.speed + moon.phase; + if (Math.sin(ang) >= 0) continue; + this.drawStationMoon(ctx, px, py, pr, moon, ang); + } + + // Back half of the rings + if (planet.hasRings) { + this.drawStationRings(ctx, px, py, pr, planet, Math.PI, Math.PI * 2); + } + + // Atmospheric halo + const halo = ctx.createRadialGradient(px, py, pr * 0.9, px, py, pr * 1.3); + halo.addColorStop(0, hexToRgba(pal.rim, 0.28)); + halo.addColorStop(1, 'transparent'); + ctx.fillStyle = halo; + ctx.beginPath(); + ctx.arc(px, py, pr * 1.3, 0, Math.PI * 2); + ctx.fill(); + + // Body + const lx = px + Math.cos(planet.lightAngle) * pr * 0.4; + const ly = py + Math.sin(planet.lightAngle) * pr * 0.4; + const body = ctx.createRadialGradient(lx, ly, pr * 0.08, px, py, pr); + body.addColorStop(0, pal.c0); + body.addColorStop(0.45, pal.c1); + body.addColorStop(1, pal.c2); + ctx.fillStyle = body; + ctx.beginPath(); + ctx.arc(px, py, pr, 0, Math.PI * 2); + ctx.fill(); + + // Latitude banding, clipped to the disc + ctx.save(); + ctx.beginPath(); + ctx.arc(px, py, pr, 0, Math.PI * 2); + ctx.clip(); + ctx.globalAlpha = 0.16; + ctx.fillStyle = pal.c2; + for (let b = 0; b < planet.bandCount; b++) { + const by = py - pr + ((b + 0.5) / planet.bandCount) * pr * 2; + const bh = (pr * 2 / planet.bandCount) * 0.42; + ctx.fillRect(px - pr, by - bh / 2, pr * 2, bh); + } + ctx.restore(); + + // Lit rim arc + ctx.globalAlpha = 0.45; + ctx.strokeStyle = pal.rim; + ctx.lineWidth = Math.max(1.2, pr * 0.035); + ctx.beginPath(); + ctx.arc(px, py, pr, planet.lightAngle - Math.PI * 0.55, planet.lightAngle + Math.PI * 0.55); + ctx.stroke(); + ctx.globalAlpha = 1; + + // Front half of the rings + if (planet.hasRings) { + this.drawStationRings(ctx, px, py, pr, planet, 0, Math.PI); + } + + // Moons in front of the planet + for (const moon of planet.moons) { + const ang = this.time * moon.speed + moon.phase; + if (Math.sin(ang) < 0) continue; + this.drawStationMoon(ctx, px, py, pr, moon, ang); + } + + ctx.restore(); + } + + drawStationRings(ctx, px, py, pr, planet, startAngle, endAngle) { + ctx.save(); + ctx.translate(px, py); + ctx.rotate(planet.ringTilt); + const step = Math.max(3, pr * 0.1); + for (let r = pr * 1.35; r <= pr * 2.05; r += step) { + ctx.beginPath(); + ctx.ellipse(0, 0, r, r * 0.3, 0, startAngle, endAngle); + ctx.strokeStyle = hexToRgba(planet.palette.rim, 0.1 + Math.sin(r * 0.12) * 0.06); + ctx.lineWidth = step * 0.75; + ctx.stroke(); + } + ctx.restore(); + } + + drawStationMoon(ctx, px, py, pr, moon, ang) { + const mx = px + Math.cos(ang) * pr * moon.orbit; + const my = py + Math.sin(ang) * pr * moon.orbit * moon.squash; + const mr = pr * moon.size; + + ctx.fillStyle = '#cbd5e1'; + ctx.beginPath(); + ctx.arc(mx, my, mr, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = 'rgba(2, 6, 23, 0.7)'; + ctx.beginPath(); + ctx.arc(mx + mr * 0.35, my + mr * 0.1, mr * 0.95, 0, Math.PI * 2); + ctx.fill(); + } + + renderStationStructure(ctx, structure, rect) { + const s = structure.scale * rect.h; + const sx = this.stationProjectX(structure.a, rect); + if (sx < rect.x - s * 4 || sx > rect.x + rect.w + s * 4) return; + const sy = this.stationProjectY(structure.e, rect); + + ctx.save(); + ctx.translate(sx, sy); + + // Central hub + ctx.fillStyle = '#1e293b'; + ctx.strokeStyle = '#475569'; + ctx.lineWidth = Math.max(1, s * 0.06); + ctx.beginPath(); + ctx.ellipse(0, 0, s * 0.42, s * 0.3, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + + // Habitation ring + if (structure.hasRing) { + ctx.strokeStyle = '#334155'; + ctx.lineWidth = Math.max(1.5, s * 0.1); + ctx.beginPath(); + ctx.ellipse(0, 0, s, s * 0.34, 0, 0, Math.PI * 2); + ctx.stroke(); + } + + // Solar panel wings + ctx.fillStyle = '#0f2942'; + ctx.strokeStyle = '#1e40af'; + ctx.lineWidth = Math.max(0.8, s * 0.03); + for (let p = 0; p < structure.panelCount; p++) { + const off = (p + 1) * s * 0.55; + ctx.fillRect(-off - s * 0.5, -s * 0.16, s * 0.5, s * 0.32); + ctx.strokeRect(-off - s * 0.5, -s * 0.16, s * 0.5, s * 0.32); + ctx.fillRect(off, -s * 0.16, s * 0.5, s * 0.32); + ctx.strokeRect(off, -s * 0.16, s * 0.5, s * 0.32); + } + + // Communications mast + ctx.strokeStyle = '#64748b'; + ctx.lineWidth = Math.max(1, s * 0.04); + ctx.beginPath(); + ctx.moveTo(0, -s * 0.3); + ctx.lineTo(0, -s * 0.75); + ctx.stroke(); + + // Blinking beacons + const lit = Math.sin(this.time * structure.beaconSpeed + structure.beaconPhase) > 0.4; + ctx.fillStyle = lit ? '#f97316' : 'rgba(249, 115, 22, 0.25)'; + ctx.shadowColor = '#f97316'; + ctx.shadowBlur = lit ? s * 0.5 : 0; + ctx.beginPath(); + ctx.arc(0, -s * 0.78, Math.max(1.2, s * 0.07), 0, Math.PI * 2); + ctx.fill(); + + ctx.shadowBlur = 0; + ctx.fillStyle = lit ? 'rgba(125, 211, 252, 0.9)' : 'rgba(125, 211, 252, 0.35)'; + ctx.beginPath(); + ctx.arc(-s * 0.42, s * 0.1, Math.max(1, s * 0.05), 0, Math.PI * 2); + ctx.arc(s * 0.42, s * 0.1, Math.max(1, s * 0.05), 0, Math.PI * 2); + ctx.fill(); + + ctx.restore(); + } + + renderStationAsteroids(ctx, cluster, rect) { + for (const rock of cluster.rocks) { + const rx = this.stationProjectX(cluster.a + rock.da, rect); + const size = rock.size * rect.h; + if (rx < rect.x - size * 3 || rx > rect.x + rect.w + size * 3) continue; + const ry = this.stationProjectY(cluster.e + rock.de * 0.5, rect); + + ctx.save(); + ctx.translate(rx, ry); + ctx.rotate(this.time * rock.rotSpeed + rock.rotPhase); + ctx.fillStyle = rock.shade; + ctx.beginPath(); + rock.verts.forEach((v, i) => { + const vx = Math.cos(v.ang) * size * v.r; + const vy = Math.sin(v.ang) * size * v.r; + if (i === 0) ctx.moveTo(vx, vy); else ctx.lineTo(vx, vy); + }); + ctx.closePath(); + ctx.fill(); + + ctx.strokeStyle = 'rgba(148, 163, 184, 0.25)'; + ctx.lineWidth = 0.8; + ctx.stroke(); + ctx.restore(); + } + } + + // ========================================================================= + // Observation Mode Enhancements: Audio-Visual Integration + // ========================================================================= + + updateAudioEnergy() { + if (this.am && this.am.analyser) { + this.am.analyser.getByteFrequencyData(this.analyserData); + } + const data = this.analyserData; + const n = data.length; + if (!n) return; + + const bassEnd = Math.max(1, Math.floor(n * 0.12)); + const midEnd = Math.max(bassEnd + 1, Math.floor(n * 0.5)); + let bassSum = 0, bassCount = 0, midSum = 0, midCount = 0, trebleSum = 0, trebleCount = 0; + + for (let i = 0; i < n; i++) { + const v = data[i] / 255; + if (i < bassEnd) { bassSum += v; bassCount++; } + else if (i < midEnd) { midSum += v; midCount++; } + else { trebleSum += v; trebleCount++; } + } + + const bass = bassCount ? bassSum / bassCount : 0; + const mid = midCount ? midSum / midCount : 0; + const treble = trebleCount ? trebleSum / trebleCount : 0; + + // Smooth toward the new reading each frame so visuals don't flicker with raw FFT noise. + const smooth = 0.15; + this.audioEnergy.bass += (bass - this.audioEnergy.bass) * smooth; + this.audioEnergy.mid += (mid - this.audioEnergy.mid) * smooth; + this.audioEnergy.treble += (treble - this.audioEnergy.treble) * smooth; + this.audioEnergy.overall = this.audioEnergy.bass * 0.5 + this.audioEnergy.mid * 0.35 + this.audioEnergy.treble * 0.15; + } + + updateViewportVibration(dt) { + // Viewport vibration is intentionally disabled (v11co). + // The canvas and the viewport frame do not always share a layer - Space Stations, + // for example, draws its window architecture in the SVG stage instead of the frame + // element - so translating the canvas made the view slide inside a stationary window. + // Hull Drone energy still drives the nebulae; the viewport itself now stays locked. + this.viewportVibration.x = 0; + this.viewportVibration.y = 0; + if (this.canvas) this.canvas.style.transform = ''; + if (this.frameEl) this.frameEl.style.transform = ''; + } + + updateScanlineBreathing() { + if (!this.scanlinesEl) { + this.scanlinesEl = this.overlay ? this.overlay.querySelector('.observation-scanlines') : null; + if (!this.scanlinesEl) return; + } + const speed = (this.lifeSupport && this.lifeSupport.params) ? this.lifeSupport.params.airflowModSpeed : 0.15; + const depth = (this.lifeSupport && this.lifeSupport.params) ? this.lifeSupport.params.airflowModDepth : 0.12; + const base = 0.14; // matches the .observation-scanlines default CSS opacity + const breath = Math.sin(this.time * speed * Math.PI * 2) * depth; + this.scanlineBreath = Math.max(0.04, base + breath * base * 2); + this.scanlinesEl.style.opacity = this.scanlineBreath.toFixed(3); + } + + // ========================================================================= + // Observation Mode Enhancements: 25-Minute Ambient Lighting Cycle + // ========================================================================= + + updateLightingCycle(dt) { + this.lightingCycleTime = (this.lightingCycleTime || 0) + dt; + const cycleDuration = 1500; // 25 minutes: Deep Space -> Nebula Passage -> Star Approach -> Eclipse + const phases = [ + { name: 'Deep Space', starBrightness: 0.85, nebulaIntensity: 0.8 }, + { name: 'Nebula Passage', starBrightness: 0.7, nebulaIntensity: 1.6 }, + { name: 'Star Approach', starBrightness: 1.3, nebulaIntensity: 1.1 }, + { name: 'Eclipse', starBrightness: 0.55, nebulaIntensity: 0.9 } + ]; + + const t = (this.lightingCycleTime % cycleDuration) / cycleDuration; + const phaseLen = 1 / phases.length; + const idx = Math.min(phases.length - 1, Math.floor(t / phaseLen)); + const nextIdx = (idx + 1) % phases.length; + const localT = (t % phaseLen) / phaseLen; + const smoothT = (1 - Math.cos(localT * Math.PI)) / 2; // ease in/out crossfade between phases + + const a = phases[idx], b = phases[nextIdx]; + this.lightingModifiers = { + name: smoothT < 0.5 ? a.name : b.name, + starBrightness: a.starBrightness + (b.starBrightness - a.starBrightness) * smoothT, + nebulaIntensity: a.nebulaIntensity + (b.nebulaIntensity - a.nebulaIntensity) * smoothT + }; + } + + getLightingModifiers() { + return this.lightingModifiers || { name: 'Deep Space', starBrightness: 1, nebulaIntensity: 1 }; + } + + // ========================================================================= + // Observation Mode Enhancements: Shooting Stars + // ========================================================================= + + spawnShootingStar() { + const fromTop = Math.random() > 0.5; + let x, y; + if (this.isStationView()) { + // Enter through the top of the station's window rather than off the top of the screen, + // where the bulkhead would swallow the whole streak + const rect = this.getStationViewportRect(); + x = rect.x + Math.random() * rect.w * 0.7; + y = rect.y + (fromTop ? 0 : Math.random() * rect.h * 0.4); + } else { + x = Math.random() * this.width; + y = fromTop ? -20 : Math.random() * this.height * 0.4; + } + const angle = (Math.PI * 0.15) + Math.random() * (Math.PI * 0.2); // downward diagonal streak + const speed = 900 + Math.random() * 500; + + this.shootingStars.push({ + x, y, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed, + life: 0, + maxLife: 0.7 + Math.random() * 0.4, + length: 90 + Math.random() * 90, + color: this.starColors[Math.floor(Math.random() * this.starColors.length)] + }); + } + + updateShootingStars(dt, now) { + if (this.flightMode !== 'warp' && now > this.nextShootingStarTime && this.shootingStars.length < 2) { + this.spawnShootingStar(); + const activityFactor = (window.observationActivity !== undefined ? window.observationActivity : 0.6); + this.nextShootingStarTime = now + (9000 + Math.random() * 16000) / Math.max(0.15, activityFactor); + } + + for (let i = this.shootingStars.length - 1; i >= 0; i--) { + const s = this.shootingStars[i]; + s.life += dt; + s.x += s.vx * dt; + s.y += s.vy * dt; + if (s.life >= s.maxLife || s.x < -120 || s.x > this.width + 120 || s.y < -120 || s.y > this.height + 120) { + this.shootingStars.splice(i, 1); + } + } + } + + renderShootingStars(ctx) { + for (const s of this.shootingStars) { + const prog = s.life / s.maxLife; + const alpha = Math.sin(Math.min(1, prog) * Math.PI); // fade in then out across its short life + const mag = Math.hypot(s.vx, s.vy) || 1; + const tailX = s.x - (s.vx / mag) * s.length; + const tailY = s.y - (s.vy / mag) * s.length; + + const grad = ctx.createLinearGradient(s.x, s.y, tailX, tailY); + grad.addColorStop(0, hexToRgba(s.color, alpha)); + grad.addColorStop(1, 'transparent'); + + ctx.strokeStyle = grad; + ctx.lineWidth = 2.2; + ctx.beginPath(); + ctx.moveTo(s.x, s.y); + ctx.lineTo(tailX, tailY); + ctx.stroke(); + + ctx.fillStyle = `rgba(255,255,255,${alpha})`; + ctx.beginPath(); + ctx.arc(s.x, s.y, 1.8, 0, Math.PI * 2); + ctx.fill(); + } + } + + // ========================================================================= + // Observation Mode Enhancements: Procedural Constellation Lines + // ========================================================================= + + regenerateConstellation() { + const count = 4 + Math.floor(Math.random() * 3); + const points = []; + for (let i = 0; i < count; i++) { + points.push({ + x: (Math.random() - 0.5) * 1400, + y: (Math.random() - 0.5) * 900, + z: 550 + Math.random() * 500 + }); + } + this.constellationPoints = points; + this.constellationLines = []; + for (let i = 0; i < points.length - 1; i++) { + this.constellationLines.push([i, i + 1]); + } + if (points.length > 3 && Math.random() > 0.5) { + this.constellationLines.push([points.length - 1, 0]); // occasionally close the pattern into a loop + } + } + + updateConstellations(dt, now) { + if (this.flightMode === 'warp' || this.isStationView()) { + // Fade out during warp streaks rather than fighting them visually. + // Stations use the 360-degree panorama instead and skip this layer entirely. + this.constellationAlpha = Math.max(0, (this.constellationAlpha || 0) - dt * 0.6); + return; + } + + if (!this.nextConstellationTime || now > this.nextConstellationTime) { + this.regenerateConstellation(); + this.nextConstellationTime = now + 50000 + Math.random() * 30000; + } + + const speed = 8; // slow independent drift, distinct pacing from the main starfield rush + if (this.constellationPoints) { + for (const p of this.constellationPoints) { + p.z -= speed * dt; + } + if (this.constellationPoints.some(p => p.z < 80)) { + this.regenerateConstellation(); + } + } + + const target = (this.constellationPoints && this.constellationPoints.length) ? 0.3 : 0; + this.constellationAlpha = (this.constellationAlpha || 0) + (target - (this.constellationAlpha || 0)) * Math.min(1, dt * 0.4); + } + + renderConstellations(ctx, cx, cy) { + if (!this.constellationPoints || !this.constellationLines || (this.constellationAlpha || 0) <= 0.005) return; + const fov = 420; + + ctx.save(); + ctx.strokeStyle = `rgba(147, 197, 253, ${this.constellationAlpha.toFixed(3)})`; + ctx.fillStyle = `rgba(224, 242, 254, ${Math.min(1, this.constellationAlpha * 2.2).toFixed(3)})`; + ctx.lineWidth = 1; + + ctx.beginPath(); + for (const [ia, ib] of this.constellationLines) { + const a = this.constellationPoints[ia]; + const b = this.constellationPoints[ib]; + if (!a || !b) continue; + const ax = cx + (a.x / a.z) * fov, ay = cy + (a.y / a.z) * fov; + const bx = cx + (b.x / b.z) * fov, by = cy + (b.y / b.z) * fov; + ctx.moveTo(ax, ay); + ctx.lineTo(bx, by); + } + ctx.stroke(); + + for (const p of this.constellationPoints) { + const px = cx + (p.x / p.z) * fov, py = cy + (p.y / p.z) * fov; + ctx.beginPath(); + ctx.arc(px, py, 1.6, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); + } + + // ========================================================================= + // Observation Mode Enhancements: Cinematic Event Director + // ========================================================================= + + queueCinematicAction(action, params) { + if (action === 'flash-status' && params && params.text) { + this.cinematicCaption = params.text; + } + } + + buildFirstContactSequence() { + return { + name: 'First Contact', + steps: [ + { duration: 3.0, run: () => { this.spawnEvent('comet', this.width * 0.5, this.height * 0.3); } }, + { duration: 2.5, run: () => { this.queueCinematicAction('flash-status', { text: 'UNKNOWN VESSEL DETECTED' }); this.spawnTraffic(true); } }, + { duration: 4.0, run: () => { this.queueCinematicAction('flash-status', { text: 'HAILING FREQUENCIES OPEN' }); } }, + { duration: 3.0, run: () => { this.spawnEvent('warp-flash', this.width * 0.5, this.height * 0.5); } } + ] + }; + } + + buildHullBreachSequence() { + return { + name: 'Hull Breach', + steps: [ + { duration: 0.8, run: () => { this.triggerWarpPulse(); this.queueCinematicAction('flash-status', { text: 'HULL STRESS CRITICAL' }); } }, + { duration: 1.6, run: () => { if (this.alerts) this.alerts.triggerRedAlert('tng'); this.updateAlertState(); } }, + { duration: 3.5, run: () => { this.queueCinematicAction('flash-status', { text: 'DAMAGE CONTROL TEAMS RESPONDING' }); } }, + { duration: 3.0, run: () => { + if (this.alerts && this.alerts.activeAlert === 'red') { this.alerts.stopAlert(); this.updateAlertState(); } + this.queueCinematicAction('flash-status', { text: 'HULL INTEGRITY STABILIZED' }); + } } + ] + }; + } + + buildTemporalAnomalySequence() { + return { + name: 'Temporal Anomaly', + steps: [ + { duration: 2.0, run: () => { this.queueCinematicAction('flash-status', { text: 'TEMPORAL FLUX DETECTED' }); } }, + { duration: 3.0, run: () => { this.spawnEvent('warp-flash', this.width * Math.random(), this.height * Math.random()); } }, + { duration: 3.0, run: () => { this.queueCinematicAction('flash-status', { text: 'VORTEX STABILIZING' }); } } + ] + }; + } + + buildBioResonanceSequence() { + return { + name: 'Bio Resonance', + steps: [ + { duration: 2.5, run: () => { this.queueCinematicAction('flash-status', { text: 'RESONANT PULSE DETECTED' }); } }, + { duration: 3.5, run: () => { this.spawnEvent('comet', this.width * 0.6, this.height * 0.4); } }, + { duration: 2.5, run: () => { this.queueCinematicAction('flash-status', { text: 'PULSE DISSIPATING' }); } } + ] + }; + } + + buildFlybySpectacleSequence() { + return { + name: 'Close Flyby', + steps: [ + { duration: 0.5, run: () => { this.spawnTraffic(true); } }, + { duration: 4.5, run: () => {} } + ] + }; + } + + getCinematicSequencesFor(universeId) { + const sequences = []; + if (universeId === 'starfleet' || universeId === 'deepspace' || universeId === 'spacestations') { + sequences.push(this.buildFirstContactSequence()); + } + if (universeId === 'military' || universeId === 'outlaw' || universeId === 'industrial') { + sequences.push(this.buildHullBreachSequence()); + } + if (universeId === 'whoniverse') { + sequences.push(this.buildTemporalAnomalySequence()); + } + if (universeId === 'bioships') { + sequences.push(this.buildBioResonanceSequence()); + } + sequences.push(this.buildFlybySpectacleSequence()); // every universe can get a simple flyby spectacle + return sequences; + } + + startCinematicSequence(sequence) { + if (!sequence || !sequence.steps || !sequence.steps.length) return; + this.cinematicActive = { name: sequence.name, steps: sequence.steps, index: 0, elapsed: 0 }; + this.cinematicCaption = null; + const first = sequence.steps[0]; + if (first && typeof first.run === 'function') first.run(); + } + + updateCinematicDirector(dt, now) { + if (this.cinematicActive) { + this.cinematicActive.elapsed += dt; + const step = this.cinematicActive.steps[this.cinematicActive.index]; + if (step && this.cinematicActive.elapsed >= step.duration) { + this.cinematicActive.index++; + this.cinematicActive.elapsed = 0; + const next = this.cinematicActive.steps[this.cinematicActive.index]; + if (next) { + this.cinematicCaption = null; + if (typeof next.run === 'function') next.run(); + } else { + this.cinematicActive = null; + this.cinematicCaption = null; + this.nextCinematicTime = now + 90000 + Math.random() * 90000; + } + } + return; + } + + if (!this.nextCinematicTime) { + this.nextCinematicTime = now + 45000 + Math.random() * 30000; + } + if (now > this.nextCinematicTime) { + const sequences = this.getCinematicSequencesFor(this.activeUniverse); + const chosen = sequences[Math.floor(Math.random() * sequences.length)]; + this.startCinematicSequence(chosen); + } + } + + // ========================================================================= + // Observation Mode Enhancements: HUD Status Ticker + // ========================================================================= + + getTickerMessages(universeId) { + const sets = { + starfleet: [ + 'ALL DECKS REPORTING NOMINAL', + 'SUBSPACE ARRAY HOLDING STEADY LOCK', + 'STRUCTURAL INTEGRITY FIELD: 100%', + 'REPLICATOR SYSTEMS ON STANDBY', + 'SENSOR SWEEP: NO ANOMALIES DETECTED' + ], + whoniverse: [ + 'TEMPORAL GRACE PERIOD: ACTIVE', + 'CLOISTER BELL: SILENT', + 'CHAMELEON CIRCUIT: STUCK (AS USUAL)', + 'VORTEX MANIFOLD WITHIN TOLERANCE', + 'ARTRON ENERGY LEVELS STABLE' + ], + industrial: [ + 'BULK FREIGHT MANIFEST: ON SCHEDULE', + 'GANTRY CRANE 4: OPERATIONAL', + 'HULL PLATING STRESS: NOMINAL', + 'CARGO BAY PRESSURE HOLDING', + 'MAINTENANCE CYCLE: DECK 4 COMPLETE' + ], + bioships: [ + 'BIOMASS RESONANCE: SYNCHRONIZED', + 'NEURAL LATTICE: RESPONSIVE', + 'MEMBRANE INTEGRITY: HEALTHY', + 'SYMBIOTIC LINK STABLE', + 'PULSE RHYTHM WITHIN NORMAL RANGE' + ], + retrofuture: [ + 'ATOMIC REACTOR: WITHIN SAFE LIMITS', + 'RADAR SWEEP: ALL CLEAR', + 'VACUUM TUBE BANK: NOMINAL', + 'RETRO-ROCKET FUEL: SUFFICIENT', + 'AUTOPILOT: ENGAGED' + ], + military: [ + 'TACTICAL GRID: CLEAR', + 'SHIELD HARMONICS NOMINAL', + 'WEAPONS SYSTEMS: STANDBY', + 'PATROL SECTOR SWEEP COMPLETE', + 'THREAT ASSESSMENT: LOW' + ], + deepspace: [ + 'DEEP FIELD SCAN: CONTINUING', + 'LONG RANGE SENSORS: NOMINAL', + 'STELLAR CARTOGRAPHY UPDATING', + 'BACKGROUND RADIATION: BASELINE', + 'NAVIGATION LOCK: HOLDING' + ], + outlaw: [ + 'TRANSPONDER: SPOOFED', + 'CARGO MANIFEST: REDACTED', + 'PATROL CHATTER: MONITORING', + 'FUEL RESERVES: RUNNING LEAN', + 'NO QUESTIONS, NO PROBLEMS' + ], + spacestations: [ + 'DOCKING RING: CLEAR FOR APPROACH', + 'PROMENADE TRAFFIC: NORMAL', + 'LIFE SUPPORT: ALL SECTIONS NOMINAL', + 'TRANSIT SCHEDULE: ON TIME', + 'STATION SPIN: STABLE' + ], + comedy: [ + 'PROBABLY FINE, ACTUALLY', + 'TEA SUPPLIES: ADEQUATE', + 'PANIC LEVEL: STILL NOT REQUIRED', + 'SCENIC ROUTE: ENGAGED', + 'MOSTLY HARMLESS' + ] + }; + return sets[universeId] || sets.starfleet; + } + + setTickerText(text) { + if (!this.tickerTextEl) { + this.tickerTextEl = document.getElementById('observation-ticker-text'); + if (!this.tickerTextEl) return; + } + this.tickerTextEl.textContent = text; + // Restart the CSS scroll animation from the left edge whenever the message changes. + this.tickerTextEl.style.animation = 'none'; + void this.tickerTextEl.offsetWidth; + this.tickerTextEl.style.animation = ''; + } + + updateStatusTicker(dt, now) { + if (!this.tickerTextEl) { + this.tickerTextEl = document.getElementById('observation-ticker-text'); + if (!this.tickerTextEl) return; + } + + if (this.cinematicCaption) { + if (this.tickerTextEl.textContent !== this.cinematicCaption) { + this.setTickerText(this.cinematicCaption); + } + return; + } + + if (!this.tickerMessages || !this.tickerMessages.length) { + this.tickerMessages = this.getTickerMessages(this.activeUniverse); + } + + if (!this.nextTickerTime || now > this.nextTickerTime) { + this.tickerIndex = ((this.tickerIndex === undefined ? -1 : this.tickerIndex) + 1) % this.tickerMessages.length; + this.setTickerText(this.tickerMessages[this.tickerIndex]); + this.nextTickerTime = now + 9000; + } + } + + updateViewportFrame() { + if (!this.frameEl) return; + const presetId = this.selectPreset ? this.selectPreset.value : (window.activePresetId || null); + this.frameEl.innerHTML = this.getViewportFrameSvg(this.activeUniverse, presetId); + } + + getViewportFrameSvg(universeId, presetId) { + if (window.ObservationBezels && typeof window.ObservationBezels.getViewportFrameSvg === "function") { + return window.ObservationBezels.getViewportFrameSvg(universeId, presetId, this); + } + return ""; + } +} +window.ObservationEngine = ObservationEngine; + diff --git a/js/visualizer.js b/js/visualizer.js new file mode 100644 index 0000000..8604b8b --- /dev/null +++ b/js/visualizer.js @@ -0,0 +1,643 @@ +class StarshipVisualizer { + constructor(audioManager, warpSynth) { + this.am = audioManager; + this.warpSynth = warpSynth; + + this.spectrumCanvas = null; + this.spectrumCtx = null; + this.warpCoreCanvas = null; + this.warpCoreCtx = null; + + this.animationFrameId = null; + this.pulseEnergy = 0.2; + this.warpParticles = []; + this.mode = 'warp-core'; // 'warp-core' or 'time-rotor' + this.rotorPhase = 0; + + // Hook into warp core pulse callback + if (this.warpSynth) { + this.warpSynth.onPulse = (phase, duration) => { + this.pulseEnergy = 1.0; + this.spawnWarpPulses(); + }; + } + } + + setMode(mode) { + this.mode = mode || 'warp-core'; + } + + init(spectrumCanvasId, warpCoreCanvasId) { + this.spectrumCanvas = document.getElementById(spectrumCanvasId); + if (this.spectrumCanvas) { + this.spectrumCtx = this.spectrumCanvas.getContext('2d'); + } + + this.warpCoreCanvas = document.getElementById(warpCoreCanvasId); + if (this.warpCoreCanvas) { + this.warpCoreCtx = this.warpCoreCanvas.getContext('2d'); + this.initWarpParticles(); + } + + window.addEventListener('resize', () => this.resizeCanvases()); + this.resizeCanvases(); + this.startRenderLoop(); + } + + resizeCanvases() { + if (this.spectrumCanvas) { + const rect = this.spectrumCanvas.parentElement.getBoundingClientRect(); + this.spectrumCanvas.width = rect.width * window.devicePixelRatio; + this.spectrumCanvas.height = (rect.height || 160) * window.devicePixelRatio; + this.spectrumCtx.scale(window.devicePixelRatio, window.devicePixelRatio); + } + if (this.warpCoreCanvas) { + const rect = this.warpCoreCanvas.parentElement.getBoundingClientRect(); + this.warpCoreCanvas.width = rect.width * window.devicePixelRatio; + this.warpCoreCanvas.height = (rect.height || 260) * window.devicePixelRatio; + this.warpCoreCtx.scale(window.devicePixelRatio, window.devicePixelRatio); + } + } + + initWarpParticles() { + this.warpParticles = []; + for (let i = 0; i < 36; i++) { + this.warpParticles.push({ + y: Math.random(), + speed: (Math.random() * 0.008 + 0.004) * (Math.random() > 0.5 ? 1 : -1), + size: Math.random() * 4 + 2, + opacity: Math.random() * 0.7 + 0.3 + }); + } + } + + spawnWarpPulses() { + for (let i = 0; i < 6; i++) { + this.warpParticles.push({ + y: 0.5, // Center matter/antimatter reaction plane + speed: (Math.random() * 0.018 + 0.01) * (i % 2 === 0 ? 1 : -1), + size: Math.random() * 6 + 3, + opacity: 1.0 + }); + } + } + + startRenderLoop() { + const render = () => { + this.renderSpectrum(); + switch (this.mode) { + case 'time-rotor': + this.renderTimeRotor(); + break; + case 'industrial-reactor': + this.renderIndustrialReactor(); + break; + case 'bio-heart': + this.renderBioHeart(); + break; + case 'retro-oscilloscope': + this.renderRetroOscilloscope(); + break; + case 'singularity-core': + this.renderSingularityCore(); + break; + case 'warp-core': + default: + this.renderWarpCore(); + break; + } + + // Decay pulse energy smoothly + this.pulseEnergy = Math.max(0.15, this.pulseEnergy * 0.94); + + this.animationFrameId = requestAnimationFrame(render); + }; + + if (this.animationFrameId) cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = requestAnimationFrame(render); + } + + renderSpectrum() { + if (!this.spectrumCanvas || !this.spectrumCtx || !this.am.analyser) return; + + const ctx = this.spectrumCtx; + const w = this.spectrumCanvas.width / window.devicePixelRatio; + const h = this.spectrumCanvas.height / window.devicePixelRatio; + + const bufferLength = this.am.analyser.frequencyBinCount; + const dataArray = new Uint8Array(bufferLength); + this.am.analyser.getByteFrequencyData(dataArray); + + ctx.clearRect(0, 0, w, h); + + // Dynamic grid color per visualizer mode + let gridCol = 'rgba(255, 153, 0, 0.12)'; + if (this.mode === 'time-rotor' || this.mode === 'singularity-core') gridCol = 'rgba(0, 229, 255, 0.12)'; + else if (this.mode === 'bio-heart') gridCol = 'rgba(16, 185, 129, 0.12)'; + else if (this.mode === 'retro-oscilloscope') gridCol = 'rgba(34, 197, 94, 0.15)'; + else if (this.mode === 'industrial-reactor') gridCol = 'rgba(245, 158, 11, 0.15)'; + + ctx.strokeStyle = gridCol; + ctx.lineWidth = 1; + for (let y = 20; y < h; y += 30) { + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(w, y); + ctx.stroke(); + } + + // Draw segmented frequency bars + const numBars = 32; + const barWidth = (w / numBars) - 3; + + for (let i = 0; i < numBars; i++) { + const binIdx = Math.floor(Math.pow(i / numBars, 1.8) * (bufferLength * 0.6)); + const val = dataArray[binIdx] || 0; + const barHeight = Math.max(4, (val / 255) * (h - 20)); + + const x = i * (barWidth + 3); + const y = h - barHeight; + + let col; + if (this.mode === 'time-rotor') { + if (i < 8) col = '#00e5ff'; + else if (i < 20) col = '#38bdf8'; + else if (i < 28) col = '#d4af37'; + else col = '#ffffff'; + } else if (this.mode === 'bio-heart') { + if (i < 8) col = '#10b981'; + else if (i < 20) col = '#34d399'; + else if (i < 28) col = '#a855f7'; + else col = '#c084fc'; + } else if (this.mode === 'retro-oscilloscope') { + col = i < 28 ? '#22c55e' : '#86efac'; + } else if (this.mode === 'industrial-reactor') { + if (i < 8) col = '#d97706'; + else if (i < 20) col = '#f59e0b'; + else if (i < 28) col = '#fbbf24'; + else col = '#fef08a'; + } else if (this.mode === 'singularity-core') { + if (i < 8) col = '#4f46e5'; + else if (i < 20) col = '#6366f1'; + else if (i < 28) col = '#38bdf8'; + else col = '#ffffff'; + } else { + // Starfleet / Classic LCARS + if (i < 8) col = '#ff6600'; + else if (i < 20) col = '#ff9933'; + else if (i < 28) col = '#cc99cc'; + else col = '#99ccff'; + } + + ctx.fillStyle = col; + ctx.shadowColor = col; + ctx.shadowBlur = val > 120 ? 8 : 0; + ctx.fillRect(x, y, barWidth, barHeight); + + ctx.fillStyle = '#ffffff'; + ctx.fillRect(x, y - 2, barWidth, 2); + } + ctx.shadowBlur = 0; + } + + renderWarpCore() { + if (!this.warpCoreCanvas || !this.warpCoreCtx) return; + + const ctx = this.warpCoreCtx; + const w = this.warpCoreCanvas.width / window.devicePixelRatio; + const h = this.warpCoreCanvas.height / window.devicePixelRatio; + + ctx.clearRect(0, 0, w, h); + + const centerX = w / 2; + const chamberWidth = Math.min(70, w * 0.4); + + // 1. Draw outer intermix chamber housing + ctx.fillStyle = '#111625'; + ctx.fillRect(centerX - chamberWidth / 2 - 8, 0, chamberWidth + 16, h); + + // Chamber glass gradient + const glassGrad = ctx.createLinearGradient(centerX - chamberWidth / 2, 0, centerX + chamberWidth / 2, 0); + glassGrad.addColorStop(0, 'rgba(0, 50, 100, 0.4)'); + glassGrad.addColorStop(0.5, 'rgba(0, 180, 255, 0.15)'); + glassGrad.addColorStop(1, 'rgba(0, 50, 100, 0.4)'); + ctx.fillStyle = glassGrad; + ctx.fillRect(centerX - chamberWidth / 2, 0, chamberWidth, h); + + // 2. Matter / Antimatter injectors (Top and Bottom) + ctx.fillStyle = '#ff9900'; + ctx.fillRect(centerX - chamberWidth / 2 - 4, 0, chamberWidth + 8, 12); + ctx.fillRect(centerX - chamberWidth / 2 - 4, h - 12, chamberWidth + 8, 12); + + // 3. Central Reaction Intermix Chamber (Center glowing disc) + const centerY = h / 2; + const glowRadius = 24 + this.pulseEnergy * 28; + const coreGlow = ctx.createRadialGradient(centerX, centerY, 2, centerX, centerY, glowRadius); + coreGlow.addColorStop(0, '#ffffff'); + coreGlow.addColorStop(0.3, `rgba(0, 210, 255, ${0.7 + this.pulseEnergy * 0.3})`); + coreGlow.addColorStop(0.7, `rgba(0, 100, 255, ${0.4 + this.pulseEnergy * 0.4})`); + coreGlow.addColorStop(1, 'rgba(0, 0, 0, 0)'); + + ctx.fillStyle = coreGlow; + ctx.beginPath(); + ctx.arc(centerX, centerY, glowRadius, 0, Math.PI * 2); + ctx.fill(); + + // 4. Segmented Magnetic Constriction Coils (horizontal pulsing rings) + const numCoils = 14; + for (let i = 0; i < numCoils; i++) { + const coilY = (i / (numCoils - 1)) * (h - 30) + 15; + const distFromCenter = Math.abs(coilY - centerY) / (h / 2); + const coilIntensity = Math.max(0.2, (1.0 - distFromCenter * 0.6) * (0.4 + this.pulseEnergy * 0.6)); + + ctx.fillStyle = `rgba(0, 230, 255, ${coilIntensity})`; + ctx.shadowColor = '#00e6ff'; + ctx.shadowBlur = this.pulseEnergy > 0.6 ? 12 : 3; + + // Draw coil bar + ctx.fillRect(centerX - chamberWidth / 2 + 4, coilY - 2, chamberWidth - 8, 4); + } + ctx.shadowBlur = 0; + + // 5. Plasma stream particles + for (let i = this.warpParticles.length - 1; i >= 0; i--) { + const p = this.warpParticles[i]; + p.y += p.speed; + + if (p.y < 0 || p.y > 1) { + if (this.warpParticles.length > 36) { + this.warpParticles.splice(i, 1); + continue; + } else { + p.y = p.speed > 0 ? 0 : 1; + } + } + + const py = p.y * h; + const px = centerX + (Math.sin(p.y * 12) * (chamberWidth * 0.25)); + + ctx.fillStyle = `rgba(180, 240, 255, ${p.opacity * (0.4 + this.pulseEnergy * 0.6)})`; + ctx.beginPath(); + ctx.arc(px, py, p.size * (0.8 + this.pulseEnergy * 0.4), 0, Math.PI * 2); + ctx.fill(); + } + } + + /** + * Renders the canonical TARDIS Central Time Rotor + * A glass cylinder containing an interior mechanical column physically rising and falling + * in sync with the pulse cycle, illuminated with glowing Gallifreyan cyan/emerald light. + */ + renderTimeRotor() { + if (!this.warpCoreCanvas || !this.warpCoreCtx) return; + + const ctx = this.warpCoreCtx; + const w = this.warpCoreCanvas.width / window.devicePixelRatio; + const h = this.warpCoreCanvas.height / window.devicePixelRatio; + + ctx.clearRect(0, 0, w, h); + + const centerX = w / 2; + const columnWidth = Math.min(84, w * 0.45); + + // 1. TARDIS Console Plinth & Ceiling Collar (Victorian Brass / Gallifreyan Bronze) + const collarGrad = ctx.createLinearGradient(centerX - columnWidth / 2, 0, centerX + columnWidth / 2, 0); + collarGrad.addColorStop(0, '#593e10'); + collarGrad.addColorStop(0.3, '#d4af37'); + collarGrad.addColorStop(0.7, '#fef08a'); + collarGrad.addColorStop(1, '#593e10'); + + ctx.fillStyle = collarGrad; + ctx.fillRect(centerX - columnWidth / 2 - 8, 0, columnWidth + 16, 14); + ctx.fillRect(centerX - columnWidth / 2 - 8, h - 14, columnWidth + 16, 14); + + // 2. Outer Glass Column Tube + const glassGrad = ctx.createLinearGradient(centerX - columnWidth / 2, 0, centerX + columnWidth / 2, 0); + glassGrad.addColorStop(0, 'rgba(0, 40, 80, 0.45)'); + glassGrad.addColorStop(0.15, 'rgba(0, 229, 255, 0.25)'); + glassGrad.addColorStop(0.5, 'rgba(255, 255, 255, 0.12)'); + glassGrad.addColorStop(0.85, 'rgba(0, 229, 255, 0.25)'); + glassGrad.addColorStop(1, 'rgba(0, 40, 80, 0.45)'); + + ctx.fillStyle = glassGrad; + ctx.fillRect(centerX - columnWidth / 2, 14, columnWidth, h - 28); + + // Glass edge highlights + ctx.strokeStyle = 'rgba(0, 229, 255, 0.6)'; + ctx.lineWidth = 1.5; + ctx.strokeRect(centerX - columnWidth / 2, 14, columnWidth, h - 28); + + // 3. Central Bobbing Time Rotor Column + // Physical oscillation: rises and falls smoothly + this.rotorPhase += 0.038; + const maxTravel = (h - 90) * 0.35; + const rotorOffset = Math.sin(this.rotorPhase) * maxTravel; + const rotorCenterY = (h / 2) + rotorOffset; + const rotorHeight = (h - 28) * 0.48; + + // Moving Inner Rotor Rod & Glass Tubes + const innerWidth = columnWidth * 0.58; + + // Glowing core glow + const coreGlow = ctx.createRadialGradient(centerX, rotorCenterY, 4, centerX, rotorCenterY, 36 + this.pulseEnergy * 30); + coreGlow.addColorStop(0, '#ffffff'); + coreGlow.addColorStop(0.4, `rgba(0, 229, 255, ${0.7 + this.pulseEnergy * 0.3})`); + coreGlow.addColorStop(0.8, `rgba(0, 100, 200, ${0.3 + this.pulseEnergy * 0.4})`); + coreGlow.addColorStop(1, 'rgba(0, 0, 0, 0)'); + + ctx.fillStyle = coreGlow; + ctx.beginPath(); + ctx.arc(centerX, rotorCenterY, 36 + this.pulseEnergy * 30, 0, Math.PI * 2); + ctx.fill(); + + // Inner mechanical tubes + ctx.fillStyle = '#00e5ff'; + ctx.shadowColor = '#00e5ff'; + ctx.shadowBlur = 10 + this.pulseEnergy * 10; + ctx.fillRect(centerX - 4, rotorCenterY - rotorHeight / 2, 8, rotorHeight); + + // Left and right secondary crystal tubes + ctx.fillStyle = 'rgba(180, 240, 255, 0.85)'; + ctx.fillRect(centerX - innerWidth / 2 + 2, rotorCenterY - rotorHeight / 2 + 10, 5, rotorHeight - 20); + ctx.fillRect(centerX + innerWidth / 2 - 7, rotorCenterY - rotorHeight / 2 + 10, 5, rotorHeight - 20); + + // Gallifreyan Circular Rotor Rings + for (let r = 0; r < 4; r++) { + const ringY = rotorCenterY - rotorHeight / 2 + (r * (rotorHeight / 3)); + ctx.strokeStyle = '#d4af37'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.ellipse(centerX, ringY, innerWidth / 2 + 2, 5, 0, 0, Math.PI * 2); + ctx.stroke(); + } + ctx.shadowBlur = 0; + + // 4. Sparkling Vortex Time Energy Particles + for (let i = this.warpParticles.length - 1; i >= 0; i--) { + const p = this.warpParticles[i]; + p.y += p.speed * 0.8; + + if (p.y < 0.05 || p.y > 0.95) { + if (this.warpParticles.length > 36) { + this.warpParticles.splice(i, 1); + continue; + } else { + p.y = p.speed > 0 ? 0.05 : 0.95; + } + } + + const py = p.y * h; + const px = centerX + (Math.sin(p.y * 16 + this.rotorPhase) * (columnWidth * 0.32)); + + ctx.fillStyle = `rgba(0, 229, 255, ${p.opacity * (0.5 + this.pulseEnergy * 0.5)})`; + ctx.shadowColor = '#00e5ff'; + ctx.shadowBlur = 6; + ctx.beginPath(); + ctx.arc(px, py, p.size * (0.7 + this.pulseEnergy * 0.5), 0, Math.PI * 2); + ctx.fill(); + } + ctx.shadowBlur = 0; + } + + /** + * Industrial Fusion Reactor (Nostromo, Serenity, Rocinante) + * Heavy containment walls, incandescent glowing amber plasma core, heat radiating coils + */ + renderIndustrialReactor() { + if (!this.warpCoreCanvas || !this.warpCoreCtx) return; + const ctx = this.warpCoreCtx; + const w = this.warpCoreCanvas.width / window.devicePixelRatio; + const h = this.warpCoreCanvas.height / window.devicePixelRatio; + ctx.clearRect(0, 0, w, h); + + const centerX = w / 2; + const centerY = h / 2; + const chamberW = Math.min(80, w * 0.42); + + // Cast iron frame + ctx.fillStyle = '#1c150c'; + ctx.fillRect(centerX - chamberW / 2 - 10, 0, chamberW + 20, h); + + // Hazard warning bands at top and bottom + for (let x = centerX - chamberW / 2 - 10; x < centerX + chamberW / 2 + 10; x += 12) { + ctx.fillStyle = (x % 24 === 0) ? '#d97706' : '#1a1106'; + ctx.fillRect(x, 0, 12, 10); + ctx.fillRect(x, h - 10, 12, 10); + } + + // Incandescent molten amber core + const radius = 22 + this.pulseEnergy * 32; + const glow = ctx.createRadialGradient(centerX, centerY, 2, centerX, centerY, radius); + glow.addColorStop(0, '#ffffff'); + glow.addColorStop(0.2, '#fef08a'); + glow.addColorStop(0.5, `rgba(245, 158, 11, ${0.7 + this.pulseEnergy * 0.3})`); + glow.addColorStop(1, 'rgba(180, 83, 9, 0)'); + + ctx.fillStyle = glow; + ctx.beginPath(); + ctx.arc(centerX, centerY, radius, 0, Math.PI * 2); + ctx.fill(); + + // Heat induction coil clamps + for (let i = 0; i < 9; i++) { + const cy = 20 + i * ((h - 40) / 8); + ctx.fillStyle = (i % 2 === 0) ? '#f59e0b' : '#78350f'; + ctx.shadowColor = '#f59e0b'; + ctx.shadowBlur = this.pulseEnergy > 0.6 ? 10 : 2; + ctx.fillRect(centerX - chamberW / 2, cy - 3, chamberW, 6); + } + ctx.shadowBlur = 0; + } + + /** + * Living Leviathan Bio-Heart (Moya, Lexx, Species 8472) + * Pulsing vascular heart sac with bioluminescent emerald/violet energy and neural veins + */ + renderBioHeart() { + if (!this.warpCoreCanvas || !this.warpCoreCtx) return; + const ctx = this.warpCoreCtx; + const w = this.warpCoreCanvas.width / window.devicePixelRatio; + const h = this.warpCoreCanvas.height / window.devicePixelRatio; + ctx.clearRect(0, 0, w, h); + + const centerX = w / 2; + const centerY = h / 2; + + // Organic vascular expansion + const bioScale = 1.0 + Math.sin(this.rotorPhase * 1.2) * 0.12 + this.pulseEnergy * 0.18; + const baseR = 35 * bioScale; + + // Outer bioluminescent aura + const aura = ctx.createRadialGradient(centerX, centerY, 4, centerX, centerY, baseR * 1.8); + aura.addColorStop(0, '#a7f3d0'); + aura.addColorStop(0.3, `rgba(16, 185, 129, ${0.7 + this.pulseEnergy * 0.3})`); + aura.addColorStop(0.7, `rgba(139, 92, 246, ${0.3 + this.pulseEnergy * 0.3})`); + aura.addColorStop(1, 'rgba(0, 0, 0, 0)'); + + ctx.fillStyle = aura; + ctx.beginPath(); + ctx.arc(centerX, centerY, baseR * 1.8, 0, Math.PI * 2); + ctx.fill(); + + // Pulsing neural veins + ctx.strokeStyle = '#34d399'; + ctx.lineWidth = 2.5; + ctx.shadowColor = '#10b981'; + ctx.shadowBlur = 8; + for (let v = 0; v < 6; v++) { + const angle = (v / 6) * Math.PI * 2 + this.rotorPhase * 0.2; + ctx.beginPath(); + ctx.moveTo(centerX, centerY); + const cpX = centerX + Math.cos(angle + 0.5) * (baseR * 0.8); + const cpY = centerY + Math.sin(angle + 0.5) * (baseR * 0.8); + const endX = centerX + Math.cos(angle) * (baseR * 1.5); + const endY = centerY + Math.sin(angle) * (baseR * 1.5); + ctx.quadraticCurveTo(cpX, cpY, endX, endY); + ctx.stroke(); + } + ctx.shadowBlur = 0; + } + + /** + * Retro Oscilloscope & Analog Astrogator (Jupiter 2, Discovery One) + * 1950s/60s green phosphor CRT screen with glowing Lissajous audio wave rings + */ + renderRetroOscilloscope() { + if (!this.warpCoreCanvas || !this.warpCoreCtx || !this.am.analyser) return; + const ctx = this.warpCoreCtx; + const w = this.warpCoreCanvas.width / window.devicePixelRatio; + const h = this.warpCoreCanvas.height / window.devicePixelRatio; + ctx.clearRect(0, 0, w, h); + + const centerX = w / 2; + const centerY = h / 2; + const crtRadius = Math.min(w, h) * 0.42; + + // Circular CRT bezel + ctx.fillStyle = '#052e16'; + ctx.beginPath(); + ctx.arc(centerX, centerY, crtRadius, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = '#22c55e'; + ctx.lineWidth = 2; + ctx.stroke(); + + // Crosshairs + ctx.strokeStyle = 'rgba(34, 197, 94, 0.25)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(centerX - crtRadius, centerY); + ctx.lineTo(centerX + crtRadius, centerY); + ctx.moveTo(centerX, centerY - crtRadius); + ctx.lineTo(centerX, centerY + crtRadius); + ctx.stroke(); + + // Lissajous audio waveform + const bufferLength = this.am.analyser.fftSize; + const dataArray = new Uint8Array(bufferLength); + this.am.analyser.getByteTimeDomainData(dataArray); + + ctx.strokeStyle = '#86efac'; + ctx.shadowColor = '#22c55e'; + ctx.shadowBlur = 8; + ctx.lineWidth = 2; + ctx.beginPath(); + + const points = 48; + for (let i = 0; i < points; i++) { + const idx = Math.floor((i / points) * (bufferLength / 2)); + const v = (dataArray[idx] / 128.0) - 1.0; + const angle = (i / points) * Math.PI * 2 + this.rotorPhase; + const r = (crtRadius * 0.65) + (v * 28 * (0.8 + this.pulseEnergy)); + const x = centerX + Math.cos(angle) * r; + const y = centerY + Math.sin(angle) * r; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.closePath(); + ctx.stroke(); + ctx.shadowBlur = 0; + } + + /** + * Gravity Singularity Core (Event Horizon, Deep Space) + * Black hole event horizon with warping gravitational accretion disk + */ + renderSingularityCore() { + if (!this.warpCoreCanvas || !this.warpCoreCtx) return; + const ctx = this.warpCoreCtx; + const w = this.warpCoreCanvas.width / window.devicePixelRatio; + const h = this.warpCoreCanvas.height / window.devicePixelRatio; + ctx.clearRect(0, 0, w, h); + + const centerX = w / 2; + const centerY = h / 2; + const diskR = Math.min(w, h) * 0.44; + + // Glowing gravitational accretion disk + ctx.save(); + ctx.translate(centerX, centerY); + ctx.rotate(this.rotorPhase * 0.6); + + const grad = ctx.createRadialGradient(0, 0, 12, 0, 0, diskR); + grad.addColorStop(0, '#000000'); + grad.addColorStop(0.35, '#000000'); + grad.addColorStop(0.45, `rgba(99, 102, 241, ${0.8 + this.pulseEnergy * 0.2})`); + grad.addColorStop(0.7, `rgba(56, 189, 248, ${0.4 + this.pulseEnergy * 0.3})`); + grad.addColorStop(1, 'rgba(0, 0, 0, 0)'); + + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.ellipse(0, 0, diskR, diskR * 0.35, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + + // Pure black event horizon sphere at center + ctx.fillStyle = '#000000'; + ctx.strokeStyle = 'rgba(99, 102, 241, 0.8)'; + ctx.lineWidth = 2; + ctx.shadowColor = '#6366f1'; + ctx.shadowBlur = 12 + this.pulseEnergy * 10; + ctx.beginPath(); + ctx.arc(centerX, centerY, 18, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.shadowBlur = 0; + } +} + +window.StarshipVisualizer = StarshipVisualizer; + +// Helper: Hex color to RGBA +function hexToRgba(hex, alpha = 1) { + if (!hex || hex.charAt(0) !== '#') return `rgba(56, 189, 248, ${alpha})`; + let c = hex.substring(1); + if (c.length === 3) c = c.split('').map(x => x + x).join(''); + const num = parseInt(c, 16); + return `rgba(${(num >> 16) & 255}, ${(num >> 8) & 255}, ${num & 255}, ${alpha})`; +} + +/** + * ============================================================================ + * CINEMATIC OBSERVATION LOUNGE ENGINE (v9g) + * ============================================================================ + * Features: + * - 60fps DPI-Aware Deep Celestial Canvas (Parallax 3D Starfield & Warp Tunnel) + * - Relativistic Warp Flight vs. Orbital Cruise Impulse Modes + * - Procedural Celestial Bodies (Class-M Planet with Atmospheric Glow, Time Vortex, Gas Giants) + * - Living Traffic & Encounters (Shuttles, Cruisers, Decloaking Klingon BOP, TARDIS, Freighters) + * - Viewport Window Framing Architecture per Universe (Starfleet, Industrial, Station, Whoniverse, Military) + * - Emergency Alert Synchronization (Red/Yellow Alert Klaxon Strobes & Shield Grids) + * - Subspace Audio Harmonics Waveform Sill + * - Auto-Hiding Interactive Glass Control Dock + */ +// ========================================================================= +// OBSERVATION CANVAS MANIFEST (v3co) +// ========================================================================= +// Each universe declares exactly which canvas layers it draws. Default-deny: +// anything not listed is OFF. A universe missing from this table gets no canvas at all. +// This table is the contract that keeps each theme's OBSERVATION its own experience -- +// do not add a layer here to "fill space"; give the theme its own bespoke content instead. +// +// `starfield` entries may carry a per-universe profile so that two universes drawing stars +// are still drawing THEIR OWN stars (density, palette, scale), not one shared layer. diff --git a/tools/extract.ps1 b/tools/extract.ps1 new file mode 100644 index 0000000..7ddd9cb --- /dev/null +++ b/tools/extract.ps1 @@ -0,0 +1,80 @@ +$base = "g:\.vibe\SciFiSS\SciFiAmbientDisplay_v3co.html" +$dest = "g:\.vibe\SciFiSS\SciFi-XZBT" +$lines = [System.IO.File]::ReadAllLines($base) + +# 1. CSS: lines 19 to 2335 (0-based) +[System.IO.File]::WriteAllLines("$dest\css\style.css", $lines[19..2335]) + +# 2. HTML: Head (0..18), link css, Body (2338..2676), script tags, Tail (13628..13629) +$html = [System.Collections.Generic.List[string]]::new() +for ($i = 0; $i -le 17; $i++) { $html.Add($lines[$i]) } +$html.Add(' ') +$html.Add('') +for ($i = 2338; $i -le 2676; $i++) { $html.Add($lines[$i]) } + +$scripts = @( + 'js/audio.js', + 'js/config.js', + 'js/visualizer.js', + 'js/observation-bezels.js', + 'js/observation-engine.js', + 'js/app.js' +) +foreach ($s in $scripts) { + $html.Add(" ") +} +$html.Add($lines[13628]) +$html.Add($lines[13629]) +[System.IO.File]::WriteAllLines("$dest\index.html", $html) + +# 3. js/audio.js: 2683 to 4940 +[System.IO.File]::WriteAllLines("$dest\js\audio.js", $lines[2683..4940]) + +# 4. js/config.js: 4941 to 5979 +[System.IO.File]::WriteAllLines("$dest\js\config.js", $lines[4941..5979]) + +# 5. js/visualizer.js: 5980 to 6622 +[System.IO.File]::WriteAllLines("$dest\js\visualizer.js", $lines[5980..6622]) + +# 6. Viewport bezels extracted into js/observation-bezels.js +# In lines 9397..9661: getViewportFrameSvg +$bezels = [System.Collections.Generic.List[string]]::new() +$bezels.Add('/**') +$bezels.Add(' * Procedural SVG Viewport Frames & Bezels for Observation Mode') +$bezels.Add(' */') +$bezels.Add('window.ObservationBezels = {') +$bezels.Add(' getViewportFrameSvg(universeId, presetId, engine) {') +for ($i = 9398; $i -le 9660; $i++) { + $l = $lines[$i] + # replace "this.showPillars" with "(engine ? engine.showPillars : false)" + $l = $l -replace 'this\.showPillars', '(engine ? engine.showPillars : false)' + $bezels.Add(' ' + $l) +} +$bezels.Add(' }') +$bezels.Add('};') +[System.IO.File]::WriteAllLines("$dest\js\observation-bezels.js", $bezels) + +# 7. js/observation-engine.js: 6623 to 9396, delegate getViewportFrameSvg, then lines 9662..9664 +$obs = [System.Collections.Generic.List[string]]::new() +for ($i = 6623; $i -le 9396; $i++) { + $obs.Add($lines[$i]) +} +$obs.Add(' getViewportFrameSvg(universeId, presetId) {') +$obs.Add(' if (window.ObservationBezels && typeof window.ObservationBezels.getViewportFrameSvg === "function") {') +$obs.Add(' return window.ObservationBezels.getViewportFrameSvg(universeId, presetId, this);') +$obs.Add(' }') +$obs.Add(' return "";') +$obs.Add(' }') +for ($i = 9662; $i -le 9664; $i++) { + $obs.Add($lines[$i]) +} +[System.IO.File]::WriteAllLines("$dest\js\observation-engine.js", $obs) + +# 8. js/app.js: 9666 to 13626 +$app = [System.Collections.Generic.List[string]]::new() +for ($i = 9666; $i -le 13626; $i++) { + $app.Add($lines[$i]) +} +[System.IO.File]::WriteAllLines("$dest\js\app.js", $app) + +Write-Output "Extraction complete!" diff --git a/tools/package.ps1 b/tools/package.ps1 new file mode 100644 index 0000000..e17efdf --- /dev/null +++ b/tools/package.ps1 @@ -0,0 +1,77 @@ +<# +.SYNOPSIS + Packages the modular SciFi-XZBT project into a single, standalone, offline HTML file. +.EXAMPLE + .\package.ps1 +#> +[CmdletBinding()] +param( + [string]$OutputFile = "" +) + +$rootDir = (Resolve-Path "$PSScriptRoot\..").Path +$indexFile = Join-Path $rootDir "index.html" + +if ([string]::IsNullOrWhiteSpace($OutputFile)) { + $OutputFile = Join-Path $rootDir "dist\SciFiAmbientDisplay_v4.html" +} + +if (-not (Test-Path $indexFile)) { + Write-Error "index.html not found in $rootDir" + exit 1 +} + +Write-Host "Packaging SciFi Ambient Display from $rootDir..." -ForegroundColor Cyan + +$indexLines = [System.IO.File]::ReadAllLines($indexFile) +$output = [System.Collections.Generic.List[string]]::new() + +foreach ($line in $indexLines) { + # Inline CSS stylesheet + if ($line -match '') { + $cssRel = $matches[1] + $cssPath = Join-Path $rootDir $cssRel + if (Test-Path $cssPath) { + Write-Host " -> Inlining CSS: $cssRel" -ForegroundColor Green + $output.Add(" ") + } else { + Write-Warning "CSS file not found: $cssPath" + $output.Add($line) + } + continue + } + + # Inline JS scripts + if ($line -match '') { + $jsRel = $matches[1] + $jsPath = Join-Path $rootDir $jsRel + if (Test-Path $jsPath) { + Write-Host " -> Inlining JS: $jsRel" -ForegroundColor Green + $output.Add(" ") + } else { + Write-Warning "JS file not found: $jsPath" + $output.Add($line) + } + continue + } + + $output.Add($line) +} + +$outDir = [System.IO.Path]::GetDirectoryName($OutputFile) +if (-not (Test-Path $outDir)) { + New-Item -ItemType Directory -Path $outDir -Force | Out-Null +} + +[System.IO.File]::WriteAllLines($OutputFile, $output) +$fileSize = (Get-Item $OutputFile).Length +$fileSizeKb = [math]::Round($fileSize / 1KB, 2) + +Write-Host "Build Successful!" -ForegroundColor Green +Write-Host "Output: $OutputFile ($fileSizeKb KB, $($output.Count) lines)" -ForegroundColor Yellow