215 lines
8 KiB
JavaScript
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;
|
|
}
|
|
});
|
|
})();
|