/* Player screen. Read-only and deliberately ignorant of the scenario's structure — no countdown, no iteration counter, nothing the in-world DOE inspectors couldn't see. It shows the Array's current time and reacts to whatever the Handler chooses to push: signal pulses, the catastrophe, text. Dread builds from the pulses the Handler fires, not from any visible clock. */ (function () { "use strict"; const $ = (id) => document.getElementById(id); const body = document.body; const reduceMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; // Build six nixie tubes as HH MM SS with two colons; keep digit + ghost refs. const tubeWrap = $("tubes"); const digits = []; const ghosts = []; "HH:MM:SS".split("").forEach((ch) => { if (ch === ":") { const c = document.createElement("span"); c.className = "colon"; c.innerHTML = ""; tubeWrap.appendChild(c); } else { const tube = document.createElement("span"); tube.className = "tube"; // afterglow layer: holds the outgoing numeral as it de-ionizes const g = document.createElement("span"); g.className = "ghost"; const d = document.createElement("span"); d.className = "digit"; d.textContent = "0"; // each tube flickers on its own rhythm (via CSS vars, so the communion // rupture animation can still fully override the flicker) d.style.setProperty("--flick-dur", (5 + Math.random() * 4).toFixed(2) + "s"); d.style.setProperty("--flick-delay", (-Math.random() * 6).toFixed(2) + "s"); tube.appendChild(g); tube.appendChild(d); tubeWrap.appendChild(tube); digits.push(d); ghosts.push(g); } }); function renderDigits(str) { const order = [0, 1, 3, 4, 6, 7]; // skip the colon positions const glitching = body.classList.contains("communion"); order.forEach((pos, i) => { const ch = str[pos]; if (digits[i].textContent !== ch) { // leave a fading ghost of the outgoing numeral (neon persistence) if (!glitching && !reduceMotion) { ghosts[i].textContent = digits[i].textContent; ghosts[i].animate([{ opacity: 0.7 }, { opacity: 0 }], { duration: 160, easing: "ease-out", }); } digits[i].textContent = ch; } }); } // cathode-poisoning exercise: a rapid slot-machine cycle through 0-9, // staggered per tube, run only while the clock sits idle function renderSpin(now) { for (let i = 0; i < digits.length; i++) { digits[i].textContent = ((Math.floor(now / 45) + i * 2) % 10).toString(); } } // --- dread / tension ------------------------------------------------------- // 0..1, nudged up by each pulse and decaying slowly. Drives the red wash and // the audio drone, so the Handler paces the menace by firing pulses. let tension = 0; function bumpTension(amount) { tension = Math.min(1, tension + amount); } // --- effects --------------------------------------------------------------- let pulseTimer = null; function flashPulse() { body.classList.add("pulsing"); clearTimeout(pulseTimer); pulseTimer = setTimeout(() => body.classList.remove("pulsing"), 750); bumpTension(0.2); if (window.OESound) OESound.pulse(); } let communionTimer = null; let scrambleUntil = 0; function communionSeq() { body.classList.remove("communion"); void body.offsetWidth; // restart the whole sequence body.classList.add("communion"); if (window.OESound && OESound.cancelTicks) OESound.cancelTicks(); tension = 1; scrambleUntil = performance.now() + 2300; // digits possessed through the blast clearTimeout(communionTimer); communionTimer = setTimeout(() => body.classList.remove("communion"), 6200); if (window.OESound) OESound.communion(); } function renderScramble() { for (const d of digits) d.textContent = ((Math.random() * 10) | 0).toString(); } const msgEl = $("message"); function showMessage(text) { msgEl.textContent = text; msgEl.classList.add("show"); } function hideMessage() { msgEl.classList.remove("show"); } // --- model + connection ---------------------------------------------------- const model = OE.ClockModel(); let nextTickTime = null; // next scheduled relay click, in AudioContext time const conn = OE.connect(OE.defaultWsUrl(), { onstatus(s) { $("lampLink").classList.toggle("lamp--on", s === "open"); body.classList.toggle("offline", s !== "open"); }, onstate(state) { model.apply(state); // drop queued clicks and realign — so pausing stops ticks promptly and a // jump/rate change re-aligns instead of replaying a backlog if (window.OESound && OESound.cancelTicks) OESound.cancelTicks(); nextTickTime = null; }, onfx(fx) { if (fx.fx === "pulse") flashPulse(); else if (fx.fx === "communion") communionSeq(); else if (fx.fx === "msg") { if (fx.text) { showMessage(fx.text); if (window.OESound && OESound.enabled()) OESound.message(); } else hideMessage(); } }, }); window.addEventListener("beforeunload", () => conn.close()); // --- tick scheduler -------------------------------------------------------- // Schedule relay clicks ahead of time on the AudioContext clock, so they keep // ticking precisely even when the tab is backgrounded (requestAnimationFrame // is paused there, but Web Audio scheduling is not). Each click is aligned to // the in-world second boundary — i.e. the digit changeover. const TICK_LOOKAHEAD = 2.5; // seconds of clicks kept queued function scheduleTicks() { if (!window.OESound || !OESound.enabled()) return (nextTickTime = null); const m = model.now(); if (!m.running || body.classList.contains("communion")) return (nextTickTime = null); const aNow = OESound.now(); const rate = m.rate || 1; if (nextTickTime === null) { const nextSec = Math.floor(m.inworld) + 1; nextTickTime = aNow + (nextSec - m.inworld) / rate; } const stepGap = Math.max(1 / rate, 0.07); // never buzz at high rates while (nextTickTime < aNow + TICK_LOOKAHEAD) { if (nextTickTime > aNow) OESound.step(nextTickTime); nextTickTime += stepGap; } } setInterval(scheduleTicks, 250); // --- render loop ----------------------------------------------------------- let last = performance.now(); let spinUntil = 0; const SPIN_IDLE = 90000; // exercise the tubes after this long sitting idle let nextSpinAt = performance.now() + SPIN_IDLE; function frame(now) { const dt = (now - last) / 1000; last = now; const s = model.now(); // cathode-poisoning spin, but only while the clock sits idle (paused) if (s.running || body.classList.contains("communion") || reduceMotion) { nextSpinAt = now + SPIN_IDLE; } else if (now >= nextSpinAt && now >= spinUntil) { spinUntil = now + 1600; nextSpinAt = now + SPIN_IDLE; } if (now < scrambleUntil) renderScramble(); else if (now < spinUntil) renderSpin(now); else renderDigits(OE.clock(s.inworld)); $("srtime").textContent = OE.clock(s.inworld); // (relay ticks are scheduled on the audio clock above, not here) // decay tension and reflect it on the console — quick enough that the // drone and red wash settle within several seconds, not minutes tension = Math.max(0, tension - dt * 0.12); body.style.setProperty("--tension", tension.toFixed(3)); body.classList.toggle("agitated", tension > 0.6); if (window.OESound && OESound.enabled()) OESound.setDread(tension); requestAnimationFrame(frame); } requestAnimationFrame(frame); // --- sound toggle ---------------------------------------------------------- const soundBtn = $("soundBtn"); soundBtn.addEventListener("click", () => { if (OESound.enable()) { soundBtn.textContent = "◉ SOUND ON"; soundBtn.classList.add("on"); soundBtn.disabled = true; } }); })();