diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-27 08:06:44 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-27 08:06:44 +0300 |
| commit | 371c54cb5ad3793cf4b61e7451b0710d317021d6 (patch) | |
| tree | c8ecc544ef01ee864cfdfb870ba790d6c87e112b /internal | |
| parent | eb6e4851110fc6a9ae336793fd87a2ba6fd48a5b (diff) | |
Add blank mode toggle (b key) and theme-hot-swap + Nukem sounds rework
- New blank mode hides all UI except the WebGL canvas; toggled via
the ‘blank’ nav button or the b key.
- Theme switching is now in-place (no page reload), avoiding the
Web Audio context reset that used to kill ambient music.
- Nukem ambient/riff rebuilt around the classic Grabbag riff (E5
E5 G5 A5 …).
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/generator/templates/shared/nav.tmpl | 1 | ||||
| -rw-r--r-- | internal/generator/templates/shared/shared.css | 21 | ||||
| -rw-r--r-- | internal/generator/templates/shared/shared.js | 133 | ||||
| -rw-r--r-- | internal/generator/theme_sounds.go | 32 |
4 files changed, 162 insertions, 25 deletions
diff --git a/internal/generator/templates/shared/nav.tmpl b/internal/generator/templates/shared/nav.tmpl index 4f620a5..b04b160 100644 --- a/internal/generator/templates/shared/nav.tmpl +++ b/internal/generator/templates/shared/nav.tmpl @@ -58,6 +58,7 @@ <button type="button" class="nav-fx-button" data-sno-fx="wild" aria-pressed="false" aria-label="Toggle wild mode"><kbd>w</kbd> wild</button> <button type="button" class="nav-fx-button" data-sno-fx="crt" aria-pressed="false" aria-label="Toggle CRT effect"><kbd>c</kbd> crt</button> <button type="button" class="nav-fx-button" data-sno-fx="ghost" aria-pressed="false" aria-label="Toggle ghost mode"><kbd>g</kbd> ghost</button> + <button type="button" class="nav-fx-button" data-sno-fx="blank" aria-pressed="false" aria-label="Toggle blank mode"><kbd>b</kbd> blank</button> <button type="button" class="nav-fx-button" data-sno-fx="ambient" aria-pressed="false" aria-label="Start or pause ambient music"><kbd>p</kbd> music</button> <button type="button" class="nav-fx-button" data-sno-fx="flash" aria-label="Trigger flash effect"><kbd>f</kbd> flash</button> <button type="button" class="nav-fx-button" data-sno-fx="scatter" aria-label="Trigger scatter effect"><kbd>x</kbd> scatter</button> diff --git a/internal/generator/templates/shared/shared.css b/internal/generator/templates/shared/shared.css index 87e981e..5fab9ac 100644 --- a/internal/generator/templates/shared/shared.css +++ b/internal/generator/templates/shared/shared.css @@ -850,6 +850,27 @@ body.sno-ghost-mode .post.post-active { opacity:1 !important; box-shadow:0 0 28p body.sno-ghost-mode header, body.sno-ghost-mode .page-nav-footer, body.sno-ghost-mode .nav-hints { opacity:0.25 !important; transition:opacity 0.4s ease; } +/* Blank mode — hide everything except WebGL background */ +body.sno-blank-mode .overlay { opacity:0 !important; pointer-events:none !important; transition:opacity 0.3s ease; } +body.sno-blank-mode #sno-crt-overlay { opacity:0 !important; } +/* Hide nav hints, pagination footer, header, post modal and splash overlay in blank mode */ +body.sno-blank-mode .nav-hints { opacity:0 !important; pointer-events:none !important; transition:opacity 0.3s ease; } +body.sno-blank-mode .page-nav-footer { opacity:0 !important; pointer-events:none !important; transition:opacity 0.3s ease; } +body.sno-blank-mode header { opacity:0 !important; pointer-events:none !important; transition:opacity 0.3s ease; } +/* Keep splash hidden too if it happens to still be around */ +body.sno-blank-mode .splash-overlay { opacity:0 !important; pointer-events:none !important; } +@keyframes sno-lightning-flash { + 0% { opacity:0; } + 8% { opacity:0.9; } + 12% { opacity:0.2; } + 18% { opacity:0.85; } + 24% { opacity:0.1; } + 30% { opacity:0.7; } + 100% { opacity:0; } +} +.sno-lightning { position:fixed; inset:0; z-index:9999; pointer-events:none; + background:radial-gradient(ellipse at 50% 30%, rgba(255,255,255,0.95), rgba(200,220,255,0.6) 40%, transparent 70%); + animation:sno-lightning-flash 0.5s ease-out both; } /* Post transition animations */ @keyframes sno-enter-from-below { 0%{transform:translateY(22px) scale(0.97);opacity:0.55} 100%{transform:translateY(0) scale(1);opacity:1} } @keyframes sno-enter-from-above { 0%{transform:translateY(-22px) scale(0.97);opacity:0.55} 100%{transform:translateY(0) scale(1);opacity:1} } diff --git a/internal/generator/templates/shared/shared.js b/internal/generator/templates/shared/shared.js index 70b97e0..5a287c7 100644 --- a/internal/generator/templates/shared/shared.js +++ b/internal/generator/templates/shared/shared.js @@ -161,13 +161,95 @@ return 'neon'; } - // snonuxSwitchTheme persists the user's choice and reloads. - // The shell's boot script picks it up on the next load. + // snonuxSwitchTheme swaps every per-theme asset in place — stylesheet, + // meta markup, sounds.json, splash WebGL — without a page reload. We used + // to call location.reload() here, but that destroys the AudioContext and + // browsers can't auto-resume Web Audio without a fresh user gesture, so + // toggling themes with 't' silently killed the music until the next page. function snonuxSwitchTheme(theme) { var all = (typeof window !== 'undefined' && window.SNONUX_ALL_THEMES) || []; if (all.indexOf(theme) < 0) return; + if (theme === window.SNONUX_CURRENT_THEME) return; try { localStorage.setItem('snonuxTheme', theme); } catch (_) {} - location.reload(); + + // Tear down the previous theme's splash WebGL animation so we don't + // leak its requestAnimationFrame loop and renderer when theme.js + // re-runs against a fresh canvas. + if (typeof window._snonuxSplashWebGLCleanup === 'function') { + try { window._snonuxSplashWebGLCleanup(); } catch (_) {} + } + + var prev = window.SNONUX_CURRENT_THEME; + window.SNONUX_CURRENT_THEME = theme; + document.documentElement.setAttribute('data-sno-theme', theme); + + var splashOverlay = document.getElementById('splash-overlay'); + if (splashOverlay) { + if (prev) splashOverlay.classList.remove('splash-' + prev); + splashOverlay.classList.add('splash-' + theme); + } + + // Swap the theme stylesheet by injecting the new <link> next to the + // old one and removing the old once the new one has loaded — that + // avoids a flash-of-default-theme between unstyled and restyled. + var bust = (window.SNONUX_BUILD || '') + '-' + Date.now(); + var oldLink = document.getElementById('sno-theme-css') || + document.querySelector('link[rel="stylesheet"][href*="/theme.css"]'); + var newLink = document.createElement('link'); + newLink.rel = 'stylesheet'; + newLink.id = 'sno-theme-css'; + newLink.href = 'themes/' + theme + '/theme.css?b=' + encodeURIComponent(bust); + if (oldLink && oldLink.parentNode) { + oldLink.parentNode.insertBefore(newLink, oldLink.nextSibling); + var dropOld = function() { if (oldLink && oldLink !== newLink) oldLink.remove(); }; + newLink.addEventListener('load', dropOld); + newLink.addEventListener('error', dropOld); + } else { + document.head.appendChild(newLink); + } + + // Fetch meta + sounds with cache-bust so the browser cannot serve a + // stale per-theme JSON across an in-page swap. + fetch('themes/' + theme + '/meta.json?b=' + encodeURIComponent(bust)) + .then(function (r) { return r.json(); }) + .then(function (m) { + if (m.title) document.title = m.title; + var headerEl = document.querySelector('header'); + if (headerEl && m.header_html) headerEl.innerHTML = m.header_html; + if (splashOverlay && m.splash_inner_html) splashOverlay.innerHTML = m.splash_inner_html; + var prevA = document.getElementById('sno-prev-page'); + if (prevA && m.prev_page_text) prevA.innerHTML = m.prev_page_text; + var nextA = document.getElementById('sno-next-page'); + if (nextA && m.next_page_text) nextA.innerHTML = m.next_page_text; + if (typeof window._snonuxRebindHeader === 'function') window._snonuxRebindHeader(); + }) + .catch(function () {}); + + fetch('themes/' + theme + '/sounds.json?b=' + encodeURIComponent(bust)) + .then(function (r) { return r.json(); }) + .then(function (s) { + window.SNONUX_SOUNDS = s; + SNONUX_SOUNDS = s; + if (window.snonuxAmbientSyncPreset) window.snonuxAmbientSyncPreset(); + }) + .catch(function () {}); + + // Refresh wild mode preset so banner/ticker/colour-wash come from the + // new theme if wild is currently active. + if (window._snoWildActive && typeof snonuxApplyWildPreset === 'function') { + snonuxApplyWildPreset(theme); + } + + // Replace theme.js so the splash WebGL is rebuilt against the new + // theme's settings. Removing the old <script> tag is cosmetic — its + // side effects already ran — but it keeps the DOM tidy across many + // 't' presses in a session. + var oldScript = document.getElementById('sno-theme-js'); + if (oldScript) oldScript.remove(); + var newScript = document.createElement('script'); + newScript.id = 'sno-theme-js'; + newScript.src = 'themes/' + theme + '/theme.js?b=' + encodeURIComponent(bust); + document.head.appendChild(newScript); } function snonuxRandomTheme() { @@ -841,9 +923,11 @@ if (!isPlaying) return; stopAll(); currentPreset = newPreset; + melodyIndex = 0; startDrones(newPreset); startNoise(newPreset); schedulePulse(); + scheduleDrums(newPreset, 0); var targetGain = 1.0; fadeMasterTo(targetGain, 0.5); }, 350); @@ -857,13 +941,20 @@ snonuxAmbientPause(); return; } + // stopAll resets every voice (including drums) — we then have to + // re-schedule each one or the new theme plays only the melody and + // we keep hearing whatever was cached as silence on top of it. stopAll(); currentPreset = preset; + melodyIndex = 0; startDrones(preset); startNoise(preset); schedulePulse(); - var targetGain = preset.gain != null ? preset.gain : 0.08; - fadeMasterTo(targetGain, 0.5); + scheduleDrums(preset, 0); + // masterGain is the binary on/off gate (per-note volumes carry + // preset.gain). Targeting preset.gain here would scale the gate + // by ~0.03 and silence the new theme until the user toggles 'p'. + fadeMasterTo(1.0, 0.3); }; window.snonuxAmbientIsPlaying = function() { @@ -1477,6 +1568,8 @@ if (crtButton) crtButton.setAttribute('aria-pressed', document.body.classList.contains('sno-crt-on') ? 'true' : 'false'); var ghostButton = getFxButton('ghost'); if (ghostButton) ghostButton.setAttribute('aria-pressed', document.body.classList.contains('sno-ghost-mode') ? 'true' : 'false'); + var blankButton = getFxButton('blank'); + if (blankButton) blankButton.setAttribute('aria-pressed', document.body.classList.contains('sno-blank-mode') ? 'true' : 'false'); var ambientButton = getFxButton('ambient'); if (ambientButton) ambientButton.setAttribute('aria-pressed', (window.snonuxAmbientIsPlaying && window.snonuxAmbientIsPlaying()) ? 'true' : 'false'); } @@ -1531,6 +1624,20 @@ syncFxButtonStates(); } + function snonuxLightningFlash() { + var d = document.createElement('div'); + d.className = 'sno-lightning'; + document.body.appendChild(d); + setTimeout(function() { d.remove(); }, 520); + } + + function toggleBlankMode() { + snonuxLightningFlash(); + document.body.classList.toggle('sno-blank-mode'); + pulseFxButton('blank'); + syncFxButtonStates(); + } + function snonuxAmbientSavePreference(enabled) { try { localStorage.setItem('snonuxAmbientEnabled', enabled ? '1' : '0'); } catch (_) {} } @@ -1560,6 +1667,7 @@ wild: function() { toggleWildMode(); }, crt: toggleCrtMode, ghost: toggleGhostMode, + blank: toggleBlankMode, ambient: toggleAmbientMode, flash: triggerFlashEffect, scatter: triggerScatterEffect @@ -1671,6 +1779,9 @@ } else if (e.key === 'g' && !e.repeat) { e.preventDefault(); toggleGhostMode(); + } else if (e.key === 'b' && !e.repeat) { + e.preventDefault(); + toggleBlankMode(); } else if (e.key === 't' && !e.repeat) { e.preventDefault(); var pick = snonuxRandomTheme(); @@ -1746,6 +1857,9 @@ case 'g': toggleGhostMode(); e.preventDefault(); break; + case 'b': + toggleBlankMode(); + e.preventDefault(); break; case 'f': triggerFlashEffect(); e.preventDefault(); break; @@ -1866,6 +1980,7 @@ function loadThemeJS() { var s = document.createElement('script'); + s.id = 'sno-theme-js'; s.src = 'themes/' + current + '/theme.js'; document.head.appendChild(s); } @@ -1880,12 +1995,16 @@ // its splash WebGL attaches to the final canvas. var pending = 2; function done() { if (--pending === 0) loadThemeJS(); } - fetch('themes/' + current + '/meta.json') + // Cache-bust both fetches: per-page templated SNONUX_BUILD or fallback + // to a per-load timestamp. Otherwise theme-switch can serve a stale + // sounds.json and the user keeps hearing the previous theme's music. + var bust = (window.SNONUX_BUILD || '') + '-' + Date.now(); + fetch('themes/' + current + '/meta.json?b=' + encodeURIComponent(bust)) .then(function (r) { return r.json(); }) .then(applyMeta) .catch(function () {}) .finally(done); - fetch('themes/' + current + '/sounds.json') + fetch('themes/' + current + '/sounds.json?b=' + encodeURIComponent(bust)) .then(function (r) { return r.json(); }) .then(function (s) { window.SNONUX_SOUNDS = s; SNONUX_SOUNDS = s; if (window.snonuxAmbientSyncPreset) window.snonuxAmbientSyncPreset(); }) .catch(function () {}) diff --git a/internal/generator/theme_sounds.go b/internal/generator/theme_sounds.go index 6b638bc..20ffacc 100644 --- a/internal/generator/theme_sounds.go +++ b/internal/generator/theme_sounds.go @@ -1174,35 +1174,31 @@ func soundsNukem() themeSounds { s.Close.Wave, s.Close.Start, s.Close.End, s.Close.Dur, s.Close.Gain = "square", 659.25, 164.81, 0.16, 0.1 s.Bounce.Wave, s.Bounce.Start, s.Bounce.End, s.Bounce.Dur, s.Bounce.Gain = "sawtooth", 220, 110, 0.1, 0.1 - // Duke's action-hero theme — aggressive E-minor power riffs with - // fanfare stabs for the heroic hook. Heavy palm-muted chugging bass, - // driving rock drums, distorted sawtooth edge. + // THE Grabbag riff (Lee Jackson) — E5 E5 G5 A5 | E5 E5 G5 A5 Bb5 A5 G5 E5 + // Heavier sawtooth rendition at 140 BPM with palm-muted bass chugging. beat := 0.429 // 140 BPM hard rock const ( - E2, B2 = 82.41, 123.47 - E3, G3, A3 = 164.81, 196.00, 220.00 - B3 = 246.94 + E2, E3, B3 = 82.41, 164.81, 246.94 E4, G4, A4 = 329.63, 392.00, 440.00 - B4, D5, E5 = 493.88, 587.33, 659.25 + Bb4, B4 = 466.16, 493.88 ) + riff := []float64{ + E4, 0.5, E4, 0.5, G4, 0.5, A4, 0.5, // bar 1a + E4, 0.5, E4, 0.5, G4, 0.5, A4, 0.5, // bar 1b + B4, 0.5, A4, 0.5, G4, 0.5, E4, 0.5, // bar 2 — climbs then drops + Bb4, 0.5, A4, 0.5, G4, 0.5, E4, 0.5, + } mel := concat( - palmMute(E2, beat), fanfareStab(minor(E4), 2, beat), - hook(beat, E4, 0.5, E4, 0.5, G4, 0.5, A4, 0.5, - B4, 0.5, A4, 0.5, G4, 0.5, E4, 0.5), - palmMute(B2, beat), fanfareStab(major(B3), 2, beat), - hook(beat, B4, 0.5, D5, 0.5, E5, 0.5, D5, 0.5, - B4, 0.5, A4, 0.5, G4, 0.5, A4, 0.5), - palmMute(E2, beat), padHold(minor(E3), 4, beat), - hook(beat, E5, 1.0, D5, 0.5, B4, 0.5, - A4, 0.5, G4, 0.5, E4, 1.0), + palmMute(E2, beat), padHold([3]float64{E3, B3, E4}, 4, beat), + hook(beat, riff...), ) wbeat := 0.333 // 180 BPM thrash wmel := concat( palmMute(E3, wbeat), hook(wbeat, E4*2, 0.25, E4*2, 0.25, G4*2, 0.25, A4*2, 0.25, + E4*2, 0.25, E4*2, 0.25, G4*2, 0.25, A4*2, 0.25, B4*2, 0.25, A4*2, 0.25, G4*2, 0.25, E4*2, 0.25, - B4*2, 0.25, D5*2, 0.25, E5*2, 0.25, D5*2, 0.25, - B4*2, 0.25, A4*2, 0.25, G4*2, 0.25, E4*2, 0.25), + Bb4*2, 0.25, A4*2, 0.25, G4*2, 0.25, E4*2, 0.25), ) s.Ambient.Normal = ambientPreset{ Gain: 0.035, BPM: 140, Wave: "sawtooth", |
