observer effect content
This commit is contained in:
parent
625a847a81
commit
e4ee25295c
23 changed files with 2982 additions and 0 deletions
215
services/observer-effect/site/clock.js
Normal file
215
services/observer-effect/site/clock.js
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
/* 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;
|
||||
}
|
||||
});
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue