// Screensaver controller — owns overlay lifecycle, hotkeys, fullscreen, modes.
//
// Host coupling: this file expects a Plugin-shaped host (settings tree +
// saveData). In the Obsidian plugin that's the actual `Plugin` instance; in
// the standalone web build it's a shim around WebHost (see
// `src/host-web/plugin-shim.ts`). The shape is declared locally so the engine
// stays free of `from 'obsidian'`.
import { Engine, ALL_SCENES } from './engine/core';
import { resolveColor, readKuroFxDefaults } from './engine/color';
import { Hud } from './hud';
import { showBoot } from './hud/boot';
import { AudioLayer } from './audio/synth';
import { DICT } from './data/dictionary';
import type { ScreensaverSettings, SceneId } from './data/defaults';
import { HUD_PRESETS, SCENES, SCENE_LABELS } from './data/defaults';
import { PRESETS } from './data/presets';
import { LOOKS, applyLook } from './data/looks';
export interface HostPlugin {
settings: {
screensaver: ScreensaverSettings;
activePreset?: string;
vaultKanji?: string;
vaultKanjiCustom?: string;
[k: string]: any;
};
saveData: (data: any) => Promise<void>;
app?: { workspace?: { getActiveFile?: () => { basename?: string } | null } };
}
import { NarrativeRunner, type Phase } from './terminal/narrative';
import { ReactiveWorld } from './fx/reactive-world';
import { profileFor } from './modes/transition-profiles';
import { FilmDirector } from './modes/film-director';
import { foreshadowLine, arrivalLine, ARCS } from './terminal/script-bank';
import { ChatterDirector } from './modes/chatter-director';
import { mkRng, freshSeed } from './engine/rng';
import { CrtSim } from './fx/crt-sim';
export interface OverlayState {
open: boolean;
embed: boolean;
}
export class ScreensaverController {
state: OverlayState = { open: false, embed: false };
overlay: HTMLElement | null = null;
engine: Engine | null = null;
hud: Hud | null = null;
audio: AudioLayer | null = null;
narrative: NarrativeRunner | null = null;
private chatter: ChatterDirector | null = null;
reactiveWorld: ReactiveWorld | null = null;
crt: CrtSim | null = null;
private autoCycleTimer = 0;
private film: FilmDirector | null = null;
private phaseCounter = 0;
private currentPhase: Phase = 'ROUTINE';
private currentFilmScene: SceneId | null = null;
private idleTimer = 0;
private lastActivity = 0;
private dayNightT0 = 0;
/** The active scene's own fog density, captured on load — the base the
* day/night cycle and fogMode work relative to. */
private baseFogDensity = 0.01;
private openedAt = 0;
private statsSaveDebounce = 0;
/** Power-bridge state (Windows wallpaper): true while 'hidden'/'frozen' has
* the engine loop stopped. Gates the perfAdapt interval and the pause/resume
* of the DOM-side loops (CrtSim, narrative). */
private powerPaused = false;
/** Last power message — recorded even when open() hasn't built the engine yet
* (the WebMessage listener is live from module load), replayed at the end of
* open(). The host skips identical re-posts, so without the replay a message
* that lands during the boot sequence would be lost for the whole session. */
private lastPowerState: 'hidden' | 'frozen' | 'animating' | null = null;
private lastPowerFps: number | undefined = undefined;
constructor(public plugin: HostPlugin) {
this.startIdleWatcher();
}
get s(): ScreensaverSettings {
return this.plugin.settings.screensaver;
}
/** Enrich with theme-derived defaults if fxInheritFromTheme is set. */
private effectiveSettings(): ScreensaverSettings {
const s: ScreensaverSettings = JSON.parse(JSON.stringify(this.s));
if (s.fxInheritFromTheme) {
const k = readKuroFxDefaults();
s.fx.bloom.strength = Math.max(0.4, k.glowIntensity * 4);
s.fx.scan.opacity = Math.max(0.02, k.scanlineOpacity * 5);
s.fx.vignette.strength = Math.max(0.15, k.vignetteStrength + 0.15);
}
return s;
}
applyHudPreset(s: ScreensaverSettings): ScreensaverSettings {
if (s.hudPreset === 'custom') return s;
const p = HUD_PRESETS[s.hudPreset];
if (p) Object.assign(s.hud, p);
return s;
}
async open(opts: { embed?: boolean; scene?: SceneId; storyScale?: number; reactiveThreat?: number } = {}) {
if (this.state.open) return;
this.state.open = true;
this.state.embed = !!opts.embed;
const s = this.applyHudPreset(this.effectiveSettings());
// v1.1 (Live-Test 2026-05-11): aspectPaletteMode === 'matchAspect'
// makes resolveColor read `data-aspect` off <html> and pick the matching
// signal preset. The lookup itself lives in engine/color.ts; the explicit
// mention here is a traceability anchor so anyone tracing palette flow
// from the controller sees the data-aspect dependency.
const color = resolveColor(s);
const scene: SceneId = opts.scene || s.defaultScene;
// Accessibility: honour prefers-reduced-motion by dialling down the most intense
// motion artifacts (CRT glitches, afterimage trails, scanline drift, noise bursts,
// auto-cycle, fast flight). Runtime-only — applied to this `s` clone, not persisted,
// so flipping the OS setting takes effect on the next open without losing the user's
// saved preferences.
const reduceMotion = typeof window !== 'undefined' &&
!!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
if (reduceMotion) {
s.crtSim.on = false;
s.autoCycle.on = false;
s.speed = 'slow';
s.fx.trails.on = false;
s.fx.burnDecay.on = false;
s.fx.noiseBursts.on = false;
s.fx.scanlineDrift.on = false;
}
// Build overlay
this.overlay = document.createElement('div');
this.overlay.id = 'kuro-screensaver-overlay';
this.overlay.setAttribute('style',
`position:fixed;inset:0;z-index:9999;background:#000;` +
(this.state.embed ? `opacity:${s.embed.opacity};` : '') +
`cursor:none;overflow:hidden;color:${color.css}`);
document.body.appendChild(this.overlay);
// Engine
this.engine = new Engine(this.overlay, s, color);
// HUD
this.hud = new Hud(this.overlay, s, color, this.engine);
// Vault kanji — aspect-aware (v1.2, 2026-05-13).
// Resolution order: explicit user override (vaultKanjiCustom) wins,
// then the current vault-aspect's signature kanji (護/軍/監/霊),
// then the global vaultKanji setting, then empty.
// Aspect-mapping mirrors KSP: Shugo=護(guard), Gunshi=軍(strategy),
// Kantoku=監(oversight), Sensei=霊(spirit).
const kuroSettings = (this.plugin as any).settings;
const ASPECT_KANJI: Record<string, string> = {
shugo: '護', gunshi: '軍', kantoku: '監', sensei: '霊',
};
const liveAspect = (document.documentElement.getAttribute('data-aspect') || '').toLowerCase();
const aspectKanji = ASPECT_KANJI[liveAspect];
const k = kuroSettings.vaultKanjiCustom
|| aspectKanji
|| kuroSettings.vaultKanji
|| '';
this.hud.setKanji(k);
// Audio
this.audio = new AudioLayer(s);
if (!this.state.embed) this.audio.start();
// Mouse listener for control bar + parallax
if (!this.state.embed) {
// ── Mouse handlers ──
// Mousemove: always reveal cursor + open control bar (auto-hides per setting),
// also drives parallax with a top-deadzone so the user can reach the bar.
// Click outside the bar: dismiss the bar immediately (alternative to waiting for timeout).
this.overlay.addEventListener('mousemove', (ev) => {
// Kiosk (native saver host): the host exits on real input, so a revealed
// cursor / control bar would only ever burn in — keep both hidden.
// Parallax stays active (harmless without a moving mouse).
if (!s.kioskMode) {
this.overlay!.style.cursor = '';
clearTimeout(this.cursorHideTimer);
this.cursorHideTimer = window.setTimeout(() => {
if (this.overlay) this.overlay.style.cursor = 'none';
}, 2500);
if (s.hud.controlBar && this.hud) {
this.hud.showControlBar(this.s.controlBarAutoHideSec);
}
}
if (s.parallax && this.engine) {
this.engine.parallaxEnabled = true;
if (ev.clientY < 80) {
this.engine.parallaxTargetX = 0;
this.engine.parallaxTargetY = 0;
} else {
this.engine.parallaxTargetX = (ev.clientX / window.innerWidth) * 2 - 1;
this.engine.parallaxTargetY = (ev.clientY / window.innerHeight) * 2 - 1;
}
}
});
// Click anywhere in the overlay: if the click is NOT on the bar (which has
// its own button handlers using stopPropagation), hide the bar.
this.overlay.addEventListener('click', () => {
if (this.hud?.isControlBarVisible()) this.hud.hideControlBar();
});
// v1.2 — Touch parity for Mobile (Obsidian iOS).
// WebView mousemove is not generated for touch — without explicit
// touchstart/touchmove handlers the control bar never reveals on
// phone. We mirror the mouse code paths so the bar shows on first
// tap and parallax follows touch position.
// Also: first touch is used to unlock the AudioContext (iOS Safari
// only resumes audio after a user gesture).
const handleTouch = (ev: TouchEvent) => {
const t = ev.touches[0] || ev.changedTouches[0];
if (!t) return;
if (s.hud.controlBar && this.hud) {
this.hud.showControlBar(this.s.controlBarAutoHideSec);
}
if (s.parallax && this.engine) {
this.engine.parallaxEnabled = true;
if (t.clientY < 80) {
this.engine.parallaxTargetX = 0;
this.engine.parallaxTargetY = 0;
} else {
this.engine.parallaxTargetX = (t.clientX / window.innerWidth) * 2 - 1;
this.engine.parallaxTargetY = (t.clientY / window.innerHeight) * 2 - 1;
}
}
// Unlock audio on first user gesture (iOS WKWebView requirement).
// Cheap to call repeatedly — resume() on a running context is a no-op.
try { (this.audio as any)?.ctx?.resume?.(); } catch {}
};
this.overlay.addEventListener('touchstart', handleTouch, { passive: true });
this.overlay.addEventListener('touchmove', handleTouch, { passive: true });
if (s.hud.controlBar) this.buildControlBar(s);
}
if (s.hud.controlBar && !this.state.embed && !s.kioskMode) {
// Show briefly on first open as orientation; auto-hides per setting.
this.hud.showControlBar(this.s.controlBarAutoHideSec);
}
// Keyboard close + live hotkeys
if (!this.state.embed) {
document.addEventListener('keydown', this.onKeyDown, { capture: true });
}
// Fullscreen (only non-embed; kiosk hosts size their own window — a
// requestFullscreen without user gesture just throws in WebView2 anyway)
if (!this.state.embed && !s.kioskMode) {
try { await this.overlay.requestFullscreen(); } catch { /* user denied or unsupported */ }
}
// Power-on flash
if (s.fx.powerOn.on) this.hud.flashPowerOn();
// Boot sequence
if (s.bootEnabled && !this.state.embed) {
this.audio?.bootBeep();
await showBoot(this.overlay, scene, s.bootSpeed);
}
// Init scene
this.engine.loadScene(scene);
this.baseFogDensity = (this.engine.scene.fog as any)?.density ?? 0.01;
const labels = DICT.MODE_LABELS[scene];
this.hud.setMode(labels[0]);
this.hud.setTri(this.engine.getSceneObj()?.triCount || '----');
this.hud.flashSceneLabel(scene);
// Stats
this.s.stats.scenesLoaded = (this.s.stats.scenesLoaded || 0) + 1;
this.s.stats.perScene[scene] = (this.s.stats.perScene[scene] || 0) + 1;
void this.plugin.saveData(this.plugin.settings);
// CRT signal-degradation simulator — fires h-tear, brightness flicker,
// chroma spikes, scanline pulse, hum bar, interlace flicker, etc. on top
// of the rendered canvas. Single intensity controls all (replaces fx.glitch).
this.crt = new CrtSim(this.engine.canvas, this.overlay, this.engine, s, this.hud.scanlines);
this.crt.start();
// Engine start + HUD loop start
this.engine.start();
const t0 = performance.now();
this.dayNightT0 = t0;
this.openedAt = Date.now();
this.hud.startLoop(t0);
// Narrative terminal: instantiate runner that drives the bottom terminal
// through a CORP operator's shift (routine → intrusion → alarm → panic → reset).
if (s.narrativeTerminal && s.hud.terminal && !this.state.embed) {
this.film = new FilmDirector(this.engine!.seed);
this.narrative = new NarrativeRunner({
hud: this.hud,
promptInputEl: this.hud.promptInputEl,
promptHandleEl: this.hud.promptHandleEl,
// End of shift → diegetic CRT crash, then reboot into a fresh shift.
onShiftEnd: (clearScreen) =>
this.crt?.playCrash(clearScreen) ?? Promise.resolve(),
// Brick C: the narrative phase arc drives the film's scene itinerary.
// Brick E: each phase change can also trigger multi-speaker radio chatter.
onPhaseEnter: (phase) => { this.currentPhase = phase; this.chatter?.fire(`phase:${phase}`); this.onFilmPhase(phase); },
sceneForeshadow: (nextPhase) => this.foreshadowFor(nextPhase),
// Slice 3: seed from + force-flush to the persisted story memory.
story: this.s.story,
persistStory: (story) => { this.s.story = story; void this.plugin.saveData(this.plugin.settings); },
}, mkRng(freshSeed()), opts.storyScale, ARCS);
this.narrative.start();
}
// Reactive world: the 3D environment reacts to the shift phase (subtle build →
// PANIC payoff, released by the crash). Only when the narrative drives a phase.
if (s.narrativeReactiveWorld && this.narrative && this.engine && !this.state.embed) {
this.reactiveWorld = new ReactiveWorld({
engine: this.engine,
narrative: this.narrative,
crt: this.crt,
settings: s,
calm: reduceMotion,
forcedThreat: opts.reactiveThreat,
});
}
// Combat actors (Brick D): the engine's CombatDirector emits events on the bus;
// react with a camera flinch + bloom flash + a terminal line.
if (this.engine) {
// Brick E: multi-speaker radio chatter replaces the single-speaker combat line.
this.chatter = new ChatterDirector(this.engine.seed, this.hud!);
this.engine.bus.subscribe('*', (e) => {
if (e.kind === 'incomingFire') {
if (!reduceMotion) this.engine!.director.enqueue({ kind: 'kick', dur: 0.6, dir: 0, intensity: 0.5 });
this.reactiveWorld?.pulse('flash');
} else if (e.kind === 'unitCrash') {
this.reactiveWorld?.pulse('flash');
}
if (e.kind === 'incomingFire' || e.kind === 'unitArrive' || e.kind === 'unitCrash') {
this.chatter?.fire(e.kind);
}
if (e.kind === 'sceneChange') {
// Slice 5: course-correction chatter — phase-keyed, calm-gated + cooldown'd.
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
this.chatter?.fireSceneChange(this.currentPhase, reduceMotion, now);
}
});
}
// Scene changes are now phase-driven (Brick C): the FilmDirector warps at each
// narrative phase boundary when autoCycle.on (= film mode). No interval timer.
// Weather: fold the fog override into baseFogDensity so it composes with fogMode +
// the day-night cycle (the engine is the single fog writer: fogBase × fogThreatMult).
{
switch (s.weather) {
case 'clear': this.baseFogDensity = 0.004; break;
case 'light-fog': /* scene default */ break;
case 'heavy-fog': this.baseFogDensity *= 2.5; break;
case 'storm':
// Storm flicker: occasional bloom-strength surge (on bloomBase — the engine
// composes the final strength as bloomBase × bloomThreatMult each frame).
(this as any)._stormInterval = window.setInterval(() => {
if (this.engine && Math.random() < 0.06) {
const orig = this.engine.bloomBase;
this.engine.bloomBase = orig * 2.4;
setTimeout(() => { if (this.engine) this.engine.bloomBase = orig; }, 80 + Math.random() * 60);
}
}, 400);
break;
case 'dust': this.baseFogDensity *= 2.4; break;
}
}
// Performance auto-adapt: monitor FPS, downgrade FX when sustained low.
// v0.10 two-stage escalation: first the cheap FX throttle (trails + bloom),
// then step the render resolution down tier by tier (0.85/0.7/0.55). Sustained
// headroom (~20 s > 55 fps) steps the resolution back up — the FX throttle
// stays (it was needed once; re-enabling would oscillate).
if (s.perfAdapt) {
let lowFrames = 0;
let highFrames = 0;
let throttledFx = false;
let perfTier = 0;
const TIER_SCALE = [1, 0.85, 0.7, 0.55];
(this as any)._perfInterval = window.setInterval(() => {
if (!this.engine) return;
// Wallpaper power bridge caps the frame rate — measured fps then IS the
// cap, not a perf signal. Same when the bridge (powerPaused) or the
// p-hotkey stopped the loop entirely (rafId 0): engine.fps freezes on
// its last value (core only updates it inside tick), so escalating here
// would act on stale data — and setAdaptiveScale on a stopped engine
// clears the visible WebGL buffer (renderer.setSize) with nothing
// re-rendering it → permanently black frozen frame. Don't escalate (or
// recover) in any of these states; counters restart once uncapped
// rendering resumes. (rafId read via any-cast — same idiom as the
// p-hotkey's `_paused` flag; core.ts stays untouched.)
if (this.powerPaused || this.engine.frameCap !== null || !(this.engine as any).rafId) {
lowFrames = 0; highFrames = 0; return;
}
const fps = this.engine.fps;
if (fps && fps < 40) lowFrames++; else lowFrames = 0;
if (fps && fps > 55) highFrames++; else highFrames = 0;
if (lowFrames >= 3) {
lowFrames = 0;
if (!throttledFx) {
// Stage 1 — reduce trails + bloom strength
throttledFx = true;
this.engine.bloomBase = Math.max(0.4, this.engine.bloomBase * 0.85);
if (this.engine.trailPass.enabled) this.engine.trailPass.enabled = false;
if (this.engine.burnPass.enabled) this.engine.burnPass.enabled = false;
} else if (perfTier < 3) {
// Stage 2 — adaptive render-resolution downscale
perfTier++;
this.engine.setAdaptiveScale(TIER_SCALE[perfTier]);
}
}
if (highFrames >= 13 && perfTier > 0) { // 13 × 1.5 s ≈ 20 s recovery window
highFrames = 0;
perfTier--;
this.engine.setAdaptiveScale(TIER_SCALE[perfTier]);
}
}, 1500);
}
// v1.2 — Real-note flash (Live-Test 2026-05-13 cool-idea).
// Every ~60s with a 12% chance, slip the currently active vault
// note title into the narrative terminal as if it were a CORP-OS
// command. Subtle enough that the operator-narrative still feels
// coherent, but means the screensaver gestures at *your* work.
// Skipped if narrative-terminal is off or terminal-strip hidden.
(this as any)._noteFlashInterval = window.setInterval(() => {
if (!this.hud || !s.narrativeTerminal || !s.hud.terminal) return;
if (Math.random() > 0.12) return;
const file = (this.plugin as any).app?.workspace?.getActiveFile?.();
const name = file?.basename;
if (!name) return;
const out = this.hud.termOut;
const el = document.createElement('div');
el.className = 'tl';
el.textContent = `$ open "${name}"`;
out.appendChild(el);
// Keep scrollback bounded — narrative cleans its own, this is a guest.
while (out.children.length > 40) out.firstChild?.remove();
}, 60_000);
// v1.2 — Idle-drift mode (Live-Test 2026-05-13 cool-idea).
// After ~5 minutes without user interaction, slowly bias the engine
// toward "drift" — current speed-multiplier eases toward 0.55, the
// ambient hum gets a touch louder, and the narrative terminal pauses.
// On any touch / mousemove the state restores within a few seconds.
// Subtle by design — meant to feel like the screensaver settles into
// contemplation, not that it broke.
let lastInteractionT = performance.now();
const driftHandler = () => { lastInteractionT = performance.now(); };
this.overlay!.addEventListener('mousemove', driftHandler, { passive: true });
this.overlay!.addEventListener('touchstart', driftHandler, { passive: true });
this.overlay!.addEventListener('touchmove', driftHandler, { passive: true });
let inDrift = false;
let preDriftBloom = 0, preDriftFog = 0;
(this as any)._driftInterval = window.setInterval(() => {
if (!this.engine) return;
const idleMs = performance.now() - lastInteractionT;
const shouldDrift = idleMs > 5 * 60_000;
if (shouldDrift && !inDrift) {
inDrift = true;
// Visual drift: gentler bloom + thicker fog gives a "settling" feel.
// Operate on the engine bases (the engine is the single fog/bloom writer).
preDriftBloom = this.engine.bloomBase;
preDriftFog = this.engine.fogBase;
this.engine.bloomBase = preDriftBloom * 0.65;
this.engine.fogBase = preDriftFog * 1.45;
if (this.audio?.master) this.audio.master.gain.value = Math.min(1.0, this.s.sound.volume * 1.3);
this.audio?.pauseSoundscape?.();
// NOTE: deliberately do NOT pause the narrative here. A screensaver is
// idle by definition, so this branch always fires after 5 min and would
// pause the shift loop forever (no input ever arrives to resume it) —
// which also stopped the diegetic crash from recurring. The visual drift
// (bloom/fog/ambient) is enough of a "settling" cue on its own.
} else if (!shouldDrift && inDrift) {
inDrift = false;
this.engine.bloomBase = preDriftBloom;
this.engine.fogBase = preDriftFog;
if (this.audio?.master) this.audio.master.gain.value = this.s.sound.volume;
this.audio?.resumeSoundscape?.();
}
}, 5_000);
// Day/night cycle: subtle modulation of bloom strength + (in fogMode
// 'auto' only) fog density. fogMode 'clear'/'dense' set a fixed density
// that this loop leaves alone.
if (s.dayNightCycle.on) {
const periodMs = s.dayNightCycle.periodMin * 60_000;
const baseBloom = this.engine!.bloomBase;
(this as any)._dayNightInterval = window.setInterval(() => {
const phase = ((Date.now() - this.dayNightT0) % periodMs) / periodMs;
const cycleVal = Math.sin(phase * Math.PI * 2);
if (this.engine) {
this.engine.bloomBase = baseBloom * (1 + cycleVal * 0.35);
if (this.s.fogMode === 'auto') {
this.engine.fogBase = this.baseFogDensity * (1 + cycleVal * 0.4);
}
}
}, 250);
}
// Apply the chosen fog mode (clear/dense set a fixed density now; auto
// hands fog back to the day/night loop above).
this.applyFogMode();
// Power-bridge replay: the WebMessage listener is live from module load, so
// the host's FIRST policy message can arrive while open() is still awaiting
// the boot sequence — applyPowerState then finds no engine and only records
// the state. The host skips identical re-posts (wallpaper_window.cpp,
// lastPowerMsg), so nothing would ever correct the default-uncapped engine.
// Re-apply the recorded state now that engine/crt/narrative all exist.
// ('animating' is replayed too — its fps cap would be lost the same way.)
if (this.lastPowerState !== null) {
this.applyPowerState(this.lastPowerState, this.lastPowerFps);
}
}
/** Fog / view-distance control — see ScreensaverSettings.fogMode. Sets the engine's
* fogBase; the engine composes the final density as fogBase × fogThreatMult. */
private applyFogMode() {
if (!this.engine) return;
switch (this.s.fogMode) {
case 'clear': this.engine.fogBase = this.baseFogDensity * 0.45; break;
case 'dense': this.engine.fogBase = this.baseFogDensity * 2.2; break;
case 'auto':
default: this.engine.fogBase = this.baseFogDensity; break;
}
}
private cursorHideTimer = 0;
private fpsBarEl: HTMLSpanElement | null = null;
private fpsBarTimer = 0;
/**
* Live control bar layout (left → right):
* SCENE | SPD | COLOR | FX (5 primary toggles) | FX+ (6 advanced toggles) | FPS
*
* Each click stops propagation to prevent the global "any-key/click closes" path
* from terminating the screensaver. The bar is rebuilt on every state change so
* `.on` highlights stay in sync.
*/
private buildControlBar(s: ScreensaverSettings) {
if (!this.hud) return;
const bar = this.hud.controlBar;
bar.innerHTML = '';
const lbl = (text: string) => {
const span = document.createElement('span'); span.className = 'ks-lbl'; span.textContent = text; bar.appendChild(span);
};
const sep = () => { const d = document.createElement('div'); d.className = 'ks-sep'; bar.appendChild(d); };
const btn = (text: string, active: boolean, fn: () => void, title = '') => {
const b = document.createElement('button');
b.className = 'ks-btn' + (active ? ' on' : '');
b.textContent = text;
if (title) b.title = title;
b.addEventListener('click', (e) => { e.stopPropagation(); fn(); });
bar.appendChild(b); return b;
};
// CLOSE + AUDIO at the very front of the bar
//
// v1.2 — Live-Test 2026-05-13: with all FX/COLOR/SCENE/FX+ buttons, the
// bar overflows on the default desktop width and `flex-wrap:wrap` pushes
// the last items onto a 2nd row. Audio and Close had ended up on that
// 2nd row — small, easy to miss. Pinning them first keeps them in row 1.
// If a wrap is needed it now drops the less-critical FX+ block (vignette,
// ping, noise) onto row 2 instead.
const closeBtn = btn('×', false, () => { void this.close(); }, 'Close screensaver');
closeBtn.style.cssText += 'color:rgba(255,200,200,.85);font-size:14px;line-height:1;padding:2px 9px;';
btn(s.sound.master ? '🔊' : '🔇', s.sound.master, () => {
this.s.sound.master = !this.s.sound.master;
if (!this.s.sound.master) {
this.audio?.dispose();
} else {
this.audio = new AudioLayer(this.effectiveSettings());
this.audio.start();
}
this.saveSettingsDebounced();
this.rebuildBar();
}, this.s.sound.master ? 'Audio on (toggle)' : 'Audio muted (toggle)');
// SCENE
sep(); lbl('SCENE');
SCENES.forEach(sc => btn(SCENE_LABELS[sc], this.engine?.currentScene === sc, () => this.switchScene(sc, { user: true, dir: 0 })));
// SPD
sep(); lbl('SPD');
(['slow', 'norm', 'fast'] as const).forEach(sp => btn(
sp === 'slow' ? '▶' : sp === 'norm' ? '▶▶' : '▶▶▶',
s.speed === sp,
() => {
// v1.1 — Live-Test 2026-05-11. Persist + push to the running engine.
// Without the engine.setSpeed() call, scenes keep reading their
// captured ctx.settings reference and the button click is a no-op.
this.s.speed = sp;
this.engine?.setSpeed(sp);
this.saveSettingsDebounced();
this.rebuildBar();
},
));
// COLOR — cycle through Kuro presets via colored dots
//
// v1.2 — Live-Test 2026-05-13 (color-button no-op fix):
// In v1.1 we introduced `aspectPaletteMode: 'matchAspect'` as the new
// default — resolveColor() reads `html[data-aspect]` and overrides
// colorMode/colorPreset. That means a click on the COLOR-button set the
// preset but resolveColor() then ignored it and returned the aspect-
// matched color → user-visible no-op.
// Fix: an explicit user pick on the COLOR-button is interpreted as
// override intent, so we flip aspectPaletteMode to 'inherit'. The user
// can re-enable matchAspect from the settings tab.
sep(); lbl('COLOR');
const presetKeys = Object.keys(PRESETS);
presetKeys.forEach(key => {
const p = (PRESETS as any)[key];
const isActive = (s.aspectPaletteMode !== 'matchAspect') && (
(s.colorMode === 'kuro-preset' && s.colorPreset === key) ||
(s.colorMode === 'kuro-auto' && this.plugin.settings.activePreset === key)
);
const b = btn('', isActive, () => {
this.s.aspectPaletteMode = 'inherit'; // explicit user pick wins over aspect-match
this.s.colorMode = 'kuro-preset';
this.s.colorPreset = key;
this.saveSettingsDebounced();
const newColor = resolveColor(this.effectiveSettings());
this.engine?.setColor(newColor);
this.hud?.applyColor(newColor);
this.rebuildBar();
}, `${p.label} (${p.darkAccent.color})`);
b.style.cssText += `width:14px;height:14px;border-radius:50%;padding:0;background:${p.darkAccent.color};margin:0 2px;`;
b.textContent = '';
});
// LOOK — one-click bundles that set the color preset + every CRT knob at once.
// Active state is DERIVED (activeLookKey) by matching current settings to a Look,
// so it needs no reset bookkeeping: change any knob/color directly and the Look
// highlight clears itself on the next rebuild.
sep(); lbl('LOOK');
const activeLook = this.activeLookKey();
Object.keys(LOOKS).forEach(k => btn(LOOKS[k].label, activeLook === k, () => {
applyLook(this.s, k);
this.saveSettingsDebounced();
const eff = this.effectiveSettings();
const c = resolveColor(eff);
this.engine?.setColor(c); this.hud?.applyColor(c);
this.engine?.applyFxSettings(eff);
this.hud?.applySettings(eff);
this.rebuildBar();
}, `Look: ${LOOKS[k].label}`));
// FX primary
//
// v1.2 — Live-Test 2026-05-13 hard-merge of overlapping FX toggles:
// • TRAILS + BURN were both `AfterimagePass` (different damp). Now a single
// AFTER-button cycles 3 stages: off → trails (mild) → burn (heavy) → off.
// • CRT-Sim already produces chromatic aberration spikes, so the separate
// CHRM button is hidden — exposing it created a "two-effect-doing-the-
// same-thing" puzzle. The underlying fx.chromaticAberration setting is
// preserved internally for users who scripted it.
// • DRIFT (scanline drift) only makes sense when SCAN is on. It's now an
// implicit sub-setting of SCAN — turning SCAN on enables DRIFT, turning
// SCAN off disables DRIFT. No separate DRIFT button.
sep(); lbl('FX');
(['bloom', 'matrix', 'scan'] as const).forEach(fx => btn(
fx.toUpperCase(), (s.fx as any)[fx].on, () => this.toggleFx(fx),
));
// CRT sim toggle (replaces glitch + subsumes chromatic aberration)
btn('CRT', s.crtSim.on, () => {
this.s.crtSim.on = !this.s.crtSim.on;
this.saveSettingsDebounced();
this.rebuildBar();
});
// AFTER — 3-stage cycler: off → trails → burn → off
const afterStage = s.fx.burnDecay.on ? 'BURN' : (s.fx.trails.on ? 'TRAIL' : 'OFF');
const afterActive = afterStage !== 'OFF';
btn(`AFTER:${afterStage}`, afterActive, () => {
// Cycle order matches stages above
if (!this.s.fx.trails.on && !this.s.fx.burnDecay.on) {
this.s.fx.trails.on = true; this.s.fx.burnDecay.on = false;
} else if (this.s.fx.trails.on && !this.s.fx.burnDecay.on) {
this.s.fx.trails.on = false; this.s.fx.burnDecay.on = true;
} else {
this.s.fx.trails.on = false; this.s.fx.burnDecay.on = false;
}
this.saveSettingsDebounced();
const eff = this.effectiveSettings();
this.engine?.applyFxSettings(eff);
this.rebuildBar();
}, 'After-image / motion-blur stages: off · mild trails · heavy burn-in');
// FOG — 3-stage cycler: auto (day/night breathing) → clear → dense → auto
btn(`FOG:${this.s.fogMode.toUpperCase()}`, this.s.fogMode !== 'auto', () => {
this.s.fogMode = this.s.fogMode === 'auto' ? 'clear'
: this.s.fogMode === 'clear' ? 'dense'
: 'auto';
this.applyFogMode();
this.saveSettingsDebounced();
this.rebuildBar();
}, 'Fog / view distance: auto (day-night) · clear (far) · dense (moody)');
// FX advanced — remaining toggles that don't overlap with anything else
sep(); lbl('FX+');
(['vignette', 'radarPing', 'noiseBursts'] as const).forEach(fx => {
const labels: Record<string, string> = {
vignette: 'VIG', radarPing: 'PING', noiseBursts: 'NOISE',
};
btn(labels[fx], (s.fx as any)[fx].on, () => this.toggleFx(fx as any));
});
// REACT — the world reacts to the narrative shift (fog/CRT/camera/storm). Live toggle.
btn('REACT', this.s.narrativeReactiveWorld, () => {
this.s.narrativeReactiveWorld = !this.s.narrativeReactiveWorld;
this.saveSettingsDebounced();
if (this.s.narrativeReactiveWorld) {
if (!this.reactiveWorld && this.narrative && this.engine) {
this.reactiveWorld = new ReactiveWorld({
engine: this.engine, narrative: this.narrative, crt: this.crt, settings: this.s,
});
}
} else {
this.reactiveWorld?.dispose();
this.reactiveWorld = null;
}
this.rebuildBar();
}, 'The 3D world reacts to the shift: fog closes in, CRT degrades, camera hesitates, PANIC storm');
// FPS — live, updated by separate ticker so we don't rebuild the bar each frame
sep(); lbl('FPS');
this.fpsBarEl = document.createElement('span');
this.fpsBarEl.className = 'ks-v';
this.fpsBarEl.style.cssText = 'font-family:"Share Tech Mono",monospace;font-size:11px;letter-spacing:.06em;padding:0 4px;';
this.fpsBarEl.textContent = '--';
bar.appendChild(this.fpsBarEl);
// Start FPS ticker if not running
if (!this.fpsBarTimer) {
this.fpsBarTimer = window.setInterval(() => {
if (this.fpsBarEl && this.engine) this.fpsBarEl.textContent = String(this.engine.fps || '--');
}, 500);
}
}
/** Which Look (if any) the current settings exactly match — for the LOOK button
* highlight. Derived from state, so no reset logic is needed when the user edits
* a knob directly. Mirrors applyLook()'s field mapping. */
private activeLookKey(): string | null {
const s = this.s, f = s.fx;
const near = (a: number, b: number) => Math.abs(a - b) < 0.01;
for (const k of Object.keys(LOOKS)) {
const L = LOOKS[k];
if (s.colorPreset !== L.preset) continue;
if (!near(f.curvature.amount, L.curvature)) continue;
if (!near(f.aperture.strength, L.aperture)) continue;
if (!near(f.ntsc.amount, L.ntsc)) continue;
if (!near(f.halation.amount, L.halation)) continue;
if (!near(f.bloom.strength, L.bloom)) continue;
if (!near(f.trails.damp, 0.5 + L.trails * 0.45)) continue;
if (f.matrix.on !== L.matrix) continue;
if (!near(s.crtSim.intensity, L.intensity)) continue;
return k;
}
return null;
}
private toggleFx(fx: keyof ScreensaverSettings['fx']) {
(this.s.fx as any)[fx].on = !(this.s.fx as any)[fx].on;
// v1.2 — DRIFT is an implicit sub-setting of SCAN (see buildControlBar
// hard-merge comment). When SCAN toggles, DRIFT follows so the visible
// result matches user intent without exposing a separate DRIFT button.
if (fx === 'scan') this.s.fx.scanlineDrift.on = this.s.fx.scan.on;
this.saveSettingsDebounced();
const eff = this.effectiveSettings();
this.engine?.applyFxSettings(eff);
this.hud?.applySettings(eff);
this.rebuildBar();
}
/**
* Refresh control-bar contents (active states, FPS, etc) WITHOUT changing visibility.
* If the bar is currently visible (mouse-driven), updates appear live; if hidden
* (e.g. after a hotkey-driven scene switch), the next mouse-reveal shows the new state.
* Hotkeys must NOT auto-pop the bar — only mouse interaction does.
*/
private rebuildBar() {
if (!this.hud) return;
this.buildControlBar(this.effectiveSettings());
// Intentionally do NOT call showControlBar() here.
}
private onKeyDown = (e: KeyboardEvent) => {
if (!this.state.open || this.state.embed) return;
const k = e.key;
// ── Escape-only exit mode ──
// When enabled, only Escape closes the screensaver. All other keys are
// swallowed (live hotkeys still work). Useful for "I want this to keep running
// even if I bump the keyboard."
if (this.s.escapeOnlyExit) {
if (k === 'Escape') {
e.preventDefault(); e.stopPropagation();
void this.close();
return;
}
// Live hotkeys remain available even in escape-only mode
if (this.s.liveHotkeysEnabled && this.handleLiveHotkey(e, k)) return;
// Swallow everything else
e.preventDefault(); e.stopPropagation();
return;
}
// ── Default mode: any key closes, live hotkeys intercept first ──
if (this.s.liveHotkeysEnabled && this.handleLiveHotkey(e, k)) return;
e.preventDefault(); e.stopPropagation();
void this.close();
};
/** Returns true if the key was handled as a live hotkey. */
private handleLiveHotkey(e: KeyboardEvent, k: string): boolean {
// 1-N → scene switch (digit maps to SCENES index; guard bounds it)
if (k >= '1' && k <= '9') {
const idx = parseInt(k, 10) - 1;
if (idx < SCENES.length) {
e.preventDefault(); e.stopPropagation();
this.switchScene(SCENES[idx], { user: true, dir: 0 }); // Slice 5: manual pick = course-correction
return true;
}
}
// Slice 5: ←/→ steer the flight (course-correction). MUST preventDefault + return
// true so the arrow does not fall through to the any-key-closes path.
if (k === 'ArrowLeft' || k === 'ArrowRight') {
e.preventDefault(); e.stopPropagation();
this.cycleScene(k === 'ArrowLeft' ? -1 : 1);
return true;
}
if (k === 'm' || k === 'M') {
e.preventDefault(); e.stopPropagation();
this.s.sound.master = !this.s.sound.master;
if (!this.s.sound.master) this.audio?.dispose();
else { this.audio = new AudioLayer(this.effectiveSettings()); this.audio.start(); }
return true;
}
if (k === 'p' || k === 'P' || k === ' ') {
e.preventDefault(); e.stopPropagation();
if (this.engine) {
if ((this.engine as any)._paused) {
this.engine.start(); this.narrative?.resume();
(this.engine as any)._paused = false;
} else {
this.engine.stop(); this.narrative?.pause();
(this.engine as any)._paused = true;
}
}
return true;
}
if (k === 's' || k === 'S') {
e.preventDefault(); e.stopPropagation();
this.takeScreenshot();
return true;
}
if (k === 'f' || k === 'F') {
e.preventDefault(); e.stopPropagation();
return true;
}
return false;
}
switchScene(id: SceneId, opts?: { user?: boolean; dir?: number }) {
if (!this.engine || !this.hud) return;
// Slice 5: only USER-initiated scene changes emit sceneChange (auto film-mode warps
// and the startup transition pass no opts → never emit → golden stream untouched).
if (opts?.user) this.engine.bus.emit({ kind: 'sceneChange', dir: opts.dir ?? 0 });
this.audio?.sceneSwitch();
// Brick B — warp transition (replaces the old fade-to-black cut). The director
// accelerates into a warp; the scene swap happens at the peak, hidden under the
// streak; then the camera emerges into the new scene (terrain→city descends).
const cur = (this.engine.currentScene ?? id) as SceneId;
const calm = typeof window !== 'undefined' &&
!!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
this.engine.beginTransition(profileFor(cur, id, calm), () => {
if (!this.engine || !this.hud) return;
this.reactiveWorld?.pulse('warp'); // bloom punch at the warp peak (hides the rebuild)
this.engine.loadScene(id);
this.baseFogDensity = (this.engine.scene.fog as any)?.density ?? 0.01;
this.applyFogMode();
this.hud.setMode(DICT.MODE_LABELS[id][0]);
this.hud.setTri(this.engine.getSceneObj()?.triCount || '----');
this.hud.flashSceneLabel(id);
const arr = arrivalLine(id, this.phaseCounter); // accompany the location change
if (arr) void this.hud.addLine(arr, 'HQ');
this.s.stats.scenesLoaded = (this.s.stats.scenesLoaded || 0) + 1;
this.s.stats.perScene[id] = (this.s.stats.perScene[id] || 0) + 1;
this.saveSettingsDebounced();
this.rebuildBar();
});
}
/** Phase began: warp to the film's scene for this phase (film mode = autoCycle.on). */
private onFilmPhase(phase: Phase) {
if (!this.engine || !this.film || !this.s.autoCycle?.on) return;
if (this.currentFilmScene === null) { // first phase: adopt the opened scene, no warp
this.currentFilmScene = this.engine.currentScene as SceneId;
this.phaseCounter++;
return;
}
const next = this.film.sceneAt(this.phaseCounter, phase, this.currentFilmScene, this.narrative?.currentArc);
if (next !== this.engine.currentScene) this.switchScene(next);
this.currentFilmScene = next;
this.phaseCounter++;
}
/** Foreshadow line for the upcoming phase's scene (matches the arrival query). */
private foreshadowFor(nextPhase: Phase): string | null {
if (!this.engine || !this.film || !this.s.autoCycle?.on) return null;
const sc = this.film.sceneAt(this.phaseCounter, nextPhase, this.currentFilmScene, this.narrative?.currentArc);
return foreshadowLine(sc, this.phaseCounter);
}
cycleScene(dir: number = 1) {
if (!this.engine) return;
if (this.engine.director.inTransition) return; // don't stack transitions
const step = dir < 0 ? -1 : 1; // Slice 5: ←/→ steer back/forward
const cur = this.engine.currentScene;
const idx = ALL_SCENES.indexOf(cur as any);
const n = ALL_SCENES.length;
const next = ALL_SCENES[((idx + step) % n + n) % n];
// v1.2 — cool-idea: when autoCycle is on, also rotate the screensaver
// palette through the 4 KSP aspects in sync with the scene change.
// Each scene gets its own "personality" — shugo phosphor for terrain,
// gunshi spectre for city, kantoku crimson for rift, sensei ember for
// tunnel/void. We only touch the screensaver's local color settings,
// not html[data-aspect] (so the vault chrome stays where it is).
if (this.s.autoCycle?.on) {
const ASPECT_PRESETS = ['phosphor', 'spectre', 'crimson', 'ember'] as const;
const curIdx = ASPECT_PRESETS.indexOf(this.s.colorPreset as any);
const base = curIdx >= 0 ? curIdx : 0;
const m = ASPECT_PRESETS.length;
const nextPreset = ASPECT_PRESETS[((base + step) % m + m) % m];
this.s.aspectPaletteMode = 'inherit';
this.s.colorMode = 'kuro-preset';
this.s.colorPreset = nextPreset;
const newColor = resolveColor(this.effectiveSettings());
this.engine?.setColor(newColor);
this.hud?.applyColor(newColor);
}
this.switchScene(next, { user: true, dir: step });
}
/**
* Host→page power bridge (Windows wallpaper mode). The native host maps its
* render policy onto three states:
* hidden/frozen — stop rendering entirely (engine rAF + HUD loop) and pause
* the DOM-side loops the p-hotkey pause path pauses too
* (CrtSim artifacts, narrative typing), so the "still" frame
* really is still — no glitches / terminal typing / compositor
* wakeups in the battery-saving state,
* animating — run, optionally throttled to <fps> (battery cap).
* engine.start()/stop(), hud.startLoop()/stopLoop(), crt.start()/stop() and
* narrative.pause()/resume() are all idempotent, so repeated messages (e.g.
* fps-cap changes, double frozen) are safe.
* The last state is ALWAYS recorded, even before open() built the engine —
* the host never re-posts identical states, so open() replays it at the end.
*/
applyPowerState(state: 'hidden' | 'frozen' | 'animating', fps?: number): void {
this.lastPowerState = state;
this.lastPowerFps = fps;
if (!this.engine) return;
if (state === 'hidden' || state === 'frozen') {
this.powerPaused = true;
this.engine.frameCap = null;
this.engine.stop();
this.hud?.stopLoop();
this.crt?.stop();
this.narrative?.pause();
} else {
this.powerPaused = false;
this.engine.frameCap = fps && fps > 0 ? fps : null;
this.engine.start();
// Resume the HUD on the original open-t0 so the T+ shift clock stays real-time.
this.hud?.startLoop(this.dayNightT0 || performance.now());
this.crt?.start();
this.narrative?.resume();
}
}
takeScreenshot() {
if (!this.engine) return;
this.engine.canvas.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `kuro-screensaver-${Date.now()}.png`;
a.click();
URL.revokeObjectURL(url);
});
}
async close() {
if (!this.state.open) return;
document.removeEventListener('keydown', this.onKeyDown, { capture: true } as any);
clearInterval(this.autoCycleTimer);
clearInterval((this as any)._dayNightInterval);
clearInterval((this as any)._stormInterval);
clearInterval((this as any)._perfInterval);
clearInterval((this as any)._driftInterval);
clearInterval((this as any)._noteFlashInterval);
clearInterval(this.fpsBarTimer);
this.fpsBarTimer = 0;
this.fpsBarEl = null;
// Update uptime stat
if (this.openedAt > 0) {
this.s.stats.totalUptimeMs = (this.s.stats.totalUptimeMs || 0) + (Date.now() - this.openedAt);
void this.plugin.saveData(this.plugin.settings);
this.openedAt = 0;
}
if (document.fullscreenElement) {
try { await document.exitFullscreen(); } catch {}
}
this.reactiveWorld?.dispose();
this.narrative?.stop();
this.crt?.dispose();
this.audio?.dispose();
this.hud?.dispose();
this.engine?.dispose();
this.overlay?.remove();
this.overlay = null; this.engine = null; this.hud = null; this.audio = null; this.narrative = null; this.crt = null;
this.reactiveWorld = null;
this.state.open = false;
}
// Debounced settings save — call this from frequent paths (scene-switch stats, etc.)
private saveSettingsDebounced() {
clearTimeout(this.statsSaveDebounce);
this.statsSaveDebounce = window.setTimeout(() => this.plugin.saveData(this.plugin.settings), 1500);
}
// Idle watcher
private startIdleWatcher() {
this.lastActivity = Date.now();
const reset = () => { this.lastActivity = Date.now(); };
document.addEventListener('mousemove', reset, { passive: true });
document.addEventListener('keydown', reset, { passive: true });
document.addEventListener('mousedown', reset, { passive: true });
document.addEventListener('wheel', reset, { passive: true });
this.idleTimer = window.setInterval(() => {
if (!this.state.open && this.s?.idleLaunch?.on) {
const ms = this.s.idleLaunch.minutes * 60_000;
if (Date.now() - this.lastActivity > ms) {
void this.open();
}
}
}, 10_000);
}
dispose() {
clearInterval(this.idleTimer);
void this.close();
}
}