home-server/services/observer-effect/site/clock.js
2026-06-15 21:22:41 -07:00

215 lines
8 KiB
JavaScript

/* 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 = "<i></i><i></i>";
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;
}
});
})();