observer effect content
This commit is contained in:
parent
625a847a81
commit
e4ee25295c
23 changed files with 2982 additions and 0 deletions
438
services/observer-effect/site/clock.css
Normal file
438
services/observer-effect/site/clock.css
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
/* Olympian Holobeam Array — Master Chronometer.
|
||||
Atomic-age console: brushed steel, nixie tubes, warning lamps. 1964. */
|
||||
|
||||
:root {
|
||||
--bg: #0a0b0c;
|
||||
--steel-hi: #4c5054;
|
||||
--steel: #2c2f32;
|
||||
--steel-lo: #16181a;
|
||||
--engrave: #0c0d0e;
|
||||
--label: #c9cdd1;
|
||||
--nixie: #ff7a1e;
|
||||
--nixie-core: #ffe2bc;
|
||||
--nixie-glow: #ff6a00;
|
||||
--amber: #ffb347;
|
||||
--red: #ff3b30;
|
||||
--green: #46e06a;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body.screen {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100%;
|
||||
padding: clamp(0.5rem, 3vw, 3rem);
|
||||
background:
|
||||
radial-gradient(120% 120% at 50% 30%, #15171a 0%, var(--bg) 70%);
|
||||
color: var(--label);
|
||||
font-family: "Helvetica Neue", Arial, system-ui, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* --- the console body ----------------------------------------------------- */
|
||||
.console {
|
||||
width: min(92vw, 880px);
|
||||
}
|
||||
|
||||
.bezel {
|
||||
position: relative;
|
||||
padding: clamp(1rem, 3.5vw, 2.4rem);
|
||||
border-radius: 14px;
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.035) 0 1px,
|
||||
transparent 1px 3px
|
||||
),
|
||||
linear-gradient(180deg, var(--steel-hi) -20%, var(--steel) 45%, var(--steel-lo));
|
||||
border: 2px solid #0d0e0f;
|
||||
box-shadow:
|
||||
inset 0 2px 0 rgba(255, 255, 255, 0.12),
|
||||
inset 0 -3px 8px rgba(0, 0, 0, 0.6),
|
||||
0 22px 60px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
/* corner screws */
|
||||
.bezel::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 10px;
|
||||
border-radius: 8px;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(circle at 0 0, #555 1.5px, #1a1a1a 3px, transparent 4px) 6px 6px / 100% 100% no-repeat,
|
||||
radial-gradient(circle at 100% 0, #555 1.5px, #1a1a1a 3px, transparent 4px) -6px 6px / 100% 100% no-repeat,
|
||||
radial-gradient(circle at 0 100%, #555 1.5px, #1a1a1a 3px, transparent 4px) 6px -6px / 100% 100% no-repeat,
|
||||
radial-gradient(circle at 100% 100%, #555 1.5px, #1a1a1a 3px, transparent 4px) -6px -6px / 100% 100% no-repeat;
|
||||
}
|
||||
|
||||
/* --- engraved plates ------------------------------------------------------ */
|
||||
.plate {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
font-size: clamp(0.6rem, 1.6vw, 0.9rem);
|
||||
color: var(--label);
|
||||
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.8), 0 -1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.stencil {
|
||||
font-weight: 800;
|
||||
}
|
||||
.subplate {
|
||||
margin: 0.35rem 0 1.1rem;
|
||||
text-align: center;
|
||||
letter-spacing: 0.3em;
|
||||
font-size: clamp(0.5rem, 1.3vw, 0.72rem);
|
||||
color: #8b9094;
|
||||
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
.plate--bottom {
|
||||
margin-top: 1.2rem;
|
||||
justify-content: center;
|
||||
letter-spacing: 0.22em;
|
||||
font-size: clamp(0.45rem, 1.2vw, 0.66rem);
|
||||
color: #7d8286;
|
||||
}
|
||||
.rec {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
color: #9aa0a4;
|
||||
}
|
||||
|
||||
/* --- nixie readout -------------------------------------------------------- */
|
||||
.readout {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
gap: clamp(0.15rem, 0.8vw, 0.5rem);
|
||||
padding: clamp(0.6rem, 2vw, 1.2rem) clamp(0.4rem, 2vw, 1rem);
|
||||
border-radius: 10px;
|
||||
background:
|
||||
radial-gradient(120% 160% at 50% 0%, #141312 0%, #070605 100%);
|
||||
border: 1px solid #000;
|
||||
box-shadow: inset 0 6px 18px rgba(0, 0, 0, 0.9), inset 0 -1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.tube {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: clamp(2.6rem, 9vw, 5.2rem);
|
||||
padding: clamp(0.3rem, 1.5vw, 0.9rem) 0;
|
||||
border-radius: 40% 40% 38% 38% / 14% 14% 12% 12%;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(80, 70, 55, 0.16), rgba(20, 16, 10, 0.05) 30%, rgba(0, 0, 0, 0.25));
|
||||
box-shadow:
|
||||
inset 0 2px 6px rgba(255, 200, 140, 0.06),
|
||||
inset 0 -8px 14px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
/* curvature vignette behind the digit */
|
||||
.tube::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(120% 130% at 50% 45%, transparent 52%, rgba(0, 0, 0, 0.45) 100%);
|
||||
}
|
||||
/* curved-glass glare in front (offset to a corner so it doesn't wash the digit) */
|
||||
.tube::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(55% 32% at 32% 15%, rgba(255, 255, 255, 0.28), rgba(255, 255, 255, 0.06) 45%, transparent 70%),
|
||||
radial-gradient(38% 22% at 72% 82%, rgba(255, 255, 255, 0.06), transparent 60%);
|
||||
}
|
||||
|
||||
.digit {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
font-size: clamp(2.4rem, 9vw, 5.4rem);
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
transform: scaleY(1.18);
|
||||
color: var(--nixie-core);
|
||||
text-shadow:
|
||||
0 0 4px var(--nixie),
|
||||
0 0 14px var(--nixie-glow),
|
||||
0 0 34px var(--nixie-glow),
|
||||
0 0 60px rgba(255, 90, 0, 0.55);
|
||||
/* per-tube flicker via vars, so the communion rupture can fully override it */
|
||||
animation: tube-flicker var(--flick-dur, 6s) var(--flick-delay, 0s) infinite steps(40);
|
||||
}
|
||||
|
||||
/* afterglow: the outgoing numeral lingers and fades (neon de-ionization) */
|
||||
.ghost {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
opacity: 0;
|
||||
font-size: clamp(2.4rem, 9vw, 5.4rem);
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
transform: scaleY(1.18);
|
||||
color: var(--nixie-core);
|
||||
text-shadow: 0 0 4px var(--nixie), 0 0 16px var(--nixie-glow);
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes tube-flicker {
|
||||
0%, 97%, 100% { opacity: 1; }
|
||||
98% { opacity: 0.86; }
|
||||
99% { opacity: 0.95; }
|
||||
}
|
||||
|
||||
.colon {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: clamp(0.5rem, 2vw, 1.2rem);
|
||||
padding: 0 clamp(0.05rem, 0.5vw, 0.3rem);
|
||||
}
|
||||
.colon i {
|
||||
width: clamp(0.3rem, 1vw, 0.6rem);
|
||||
height: clamp(0.3rem, 1vw, 0.6rem);
|
||||
border-radius: 50%;
|
||||
background: var(--nixie-core);
|
||||
box-shadow: 0 0 6px var(--nixie), 0 0 16px var(--nixie-glow);
|
||||
}
|
||||
|
||||
.srtime {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
}
|
||||
|
||||
/* --- lamps ---------------------------------------------------------------- */
|
||||
.lamps {
|
||||
margin-top: 1.3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: clamp(1rem, 5vw, 3rem);
|
||||
}
|
||||
.lampcell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
letter-spacing: 0.16em;
|
||||
font-size: clamp(0.55rem, 1.4vw, 0.78rem);
|
||||
color: #8b9094;
|
||||
}
|
||||
.lamp {
|
||||
width: 0.85em;
|
||||
height: 0.85em;
|
||||
border-radius: 50%;
|
||||
background: #2a2a2a;
|
||||
box-shadow: inset 0 0 3px #000, 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
transition: background 0.08s, box-shadow 0.08s;
|
||||
}
|
||||
.lamp--rec {
|
||||
width: 0.7em;
|
||||
height: 0.7em;
|
||||
}
|
||||
.lamp--on {
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 8px var(--green), 0 0 16px rgba(70, 224, 106, 0.6);
|
||||
}
|
||||
/* REC indicator: steady recording lamp (flavor). */
|
||||
.lamp--rec {
|
||||
background: var(--red);
|
||||
box-shadow: 0 0 5px var(--red);
|
||||
}
|
||||
|
||||
/* --- pulse shudder --------------------------------------------------------- */
|
||||
body.pulsing #cellPulse .lamp {
|
||||
background: var(--amber);
|
||||
box-shadow: 0 0 10px var(--amber), 0 0 24px rgba(255, 179, 71, 0.8);
|
||||
}
|
||||
body.pulsing .bezel {
|
||||
animation: shudder 0.45s ease-out;
|
||||
}
|
||||
body.pulsing .digit {
|
||||
text-shadow:
|
||||
0 0 6px var(--nixie-core),
|
||||
0 0 20px var(--nixie),
|
||||
0 0 50px var(--nixie-glow),
|
||||
0 0 90px rgba(255, 120, 0, 0.8);
|
||||
}
|
||||
@keyframes shudder {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
20% { transform: translate(-3px, 1px); }
|
||||
40% { transform: translate(3px, -2px); }
|
||||
60% { transform: translate(-2px, -1px); }
|
||||
80% { transform: translate(2px, 1px); }
|
||||
}
|
||||
|
||||
/* --- dread wash (driven by --tension, 0..1) -------------------------------- */
|
||||
.screen::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(120% 120% at 50% 50%, transparent 38%, rgba(120, 0, 0, 0.6) 100%);
|
||||
opacity: calc(var(--tension, 0) * 0.95);
|
||||
z-index: 5;
|
||||
}
|
||||
/* once the menace is high, the wash starts to crawl */
|
||||
body.agitated::before {
|
||||
animation: wash-flicker 0.4s infinite;
|
||||
}
|
||||
@keyframes wash-flicker {
|
||||
0%, 100% { opacity: calc(var(--tension, 0) * 0.95); }
|
||||
50% { opacity: calc(var(--tension, 0) * 0.7); }
|
||||
}
|
||||
|
||||
/* --- communion: Azathoth breaks through ------------------------------------ */
|
||||
/* A ~6s sequence over three layers: the void (roiling black), the flash (the
|
||||
blue-white detonation), plus a console quake and digit rupture. */
|
||||
.void {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 21;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
background: #000;
|
||||
}
|
||||
.flash {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 22;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
mix-blend-mode: screen;
|
||||
background: radial-gradient(circle at 50% 45%, #fff 0%, #dce9ff 12%, #6fb0ff 34%, #163a8f 68%, transparent 100%);
|
||||
}
|
||||
|
||||
body.communion .console { animation: quake 2.3s cubic-bezier(0.36, 0.07, 0.19, 0.97) both; }
|
||||
body.communion .void { animation: thevoid 6s ease-out forwards; }
|
||||
body.communion .flash { animation: detonate 6s ease-out forwards; }
|
||||
body.communion .digit { animation: digit-rupture 6s steps(60) forwards; }
|
||||
body.communion .lamp { animation: panic-lamp 0.18s steps(1) 12; }
|
||||
|
||||
@keyframes quake {
|
||||
0%, 100% { transform: translate(0, 0) rotate(0deg); }
|
||||
5% { transform: translate(-14px, 8px) rotate(-0.6deg); }
|
||||
10% { transform: translate(13px, -10px) rotate(0.5deg); }
|
||||
15% { transform: translate(-16px, -6px) rotate(0.4deg); }
|
||||
21% { transform: translate(15px, 11px) rotate(-0.5deg); }
|
||||
27% { transform: translate(-19px, 7px) rotate(0.7deg); }
|
||||
33% { transform: translate(18px, -9px) rotate(-0.6deg); }
|
||||
41% { transform: translate(-11px, 7px) rotate(0.3deg); }
|
||||
52% { transform: translate(8px, -5px) rotate(-0.2deg); }
|
||||
64% { transform: translate(-5px, 3px); }
|
||||
78% { transform: translate(3px, -2px); }
|
||||
}
|
||||
@keyframes detonate {
|
||||
0% { opacity: 0; }
|
||||
18% { opacity: 0.12; }
|
||||
22% { opacity: 0; }
|
||||
26% { opacity: 1; }
|
||||
30% { opacity: 0.7; }
|
||||
34% { opacity: 1; }
|
||||
44% { opacity: 0.2; }
|
||||
60% { opacity: 0.06; }
|
||||
78%, 100% { opacity: 0; }
|
||||
}
|
||||
@keyframes thevoid {
|
||||
0%, 30% { opacity: 0; }
|
||||
45% { opacity: 1; }
|
||||
86% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
@keyframes digit-rupture {
|
||||
0% { color: var(--nixie-core); text-shadow: 0 0 4px var(--nixie), 0 0 14px var(--nixie-glow); }
|
||||
10% { color: #fff; text-shadow: -3px 0 #ff003c, 3px 0 #00e5ff, 0 0 18px #fff; }
|
||||
20% { text-shadow: 4px 0 #ff003c, -4px 0 #00e5ff, 0 0 22px #fff; }
|
||||
26% { color: #eaf3ff; text-shadow: -6px 0 #ff003c, 6px 0 #00e5ff, 0 0 30px #fff; }
|
||||
34% { opacity: 0.2; }
|
||||
40% { opacity: 1; color: #b9c6ff; text-shadow: -5px 0 #ff003c, 5px 0 #00e5ff; }
|
||||
55% { opacity: 0.5; text-shadow: -3px 0 #6a00ff, 3px 0 #00d0ff; }
|
||||
70% { opacity: 0.12; }
|
||||
82% { opacity: 0.04; }
|
||||
100% { opacity: 1; color: var(--nixie-core); text-shadow: 0 0 4px var(--nixie), 0 0 14px var(--nixie-glow); }
|
||||
}
|
||||
@keyframes panic-lamp {
|
||||
0% { background: var(--red); box-shadow: 0 0 10px var(--red); }
|
||||
50% { background: #1a0000; box-shadow: inset 0 0 3px #000; }
|
||||
}
|
||||
|
||||
/* --- message overlay ------------------------------------------------------- */
|
||||
.message {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 30;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.22em;
|
||||
font-size: clamp(1.6rem, 7vw, 4.5rem);
|
||||
color: #fff;
|
||||
text-shadow: 0 0 12px #6fb0ff, 0 0 40px #2a6bff;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.4s, visibility 0.4s;
|
||||
}
|
||||
.message.show {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* --- offline -------------------------------------------------------------- */
|
||||
body.offline .bezel {
|
||||
filter: saturate(0.5) brightness(0.85);
|
||||
}
|
||||
|
||||
/* --- sound button --------------------------------------------------------- */
|
||||
.sound {
|
||||
position: fixed;
|
||||
right: 0.8rem;
|
||||
bottom: 0.8rem;
|
||||
z-index: 40;
|
||||
padding: 0.4rem 0.8rem;
|
||||
font: inherit;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.14em;
|
||||
color: #9aa0a4;
|
||||
background: #1b1d1f;
|
||||
border: 1px solid #000;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sound.on {
|
||||
color: var(--green);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.digit,
|
||||
body.agitated::before,
|
||||
body.communion .console,
|
||||
body.communion .digit,
|
||||
body.communion .lamp {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
46
services/observer-effect/site/clock.html
Normal file
46
services/observer-effect/site/clock.html
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>Warner Center Radio Array — Master Chronometer</title>
|
||||
<link rel="stylesheet" href="clock.css" />
|
||||
</head>
|
||||
<body class="screen">
|
||||
<main class="console" id="console">
|
||||
<div class="bezel">
|
||||
<header class="plate plate--top">
|
||||
<span class="stencil">WARNER CENTER RADIO ARRAY</span>
|
||||
<span class="rec"><i class="lamp lamp--rec"></i> REC</span>
|
||||
</header>
|
||||
<div class="subplate">MASTER CHRONOMETER</div>
|
||||
|
||||
<div class="readout" id="tubes" aria-hidden="true"></div>
|
||||
<p class="srtime" aria-live="off"><span id="srtime">17:00:00</span> local time</p>
|
||||
|
||||
<div class="lamps">
|
||||
<div class="lampcell">
|
||||
<i class="lamp lamp--green" id="lampLink"></i><span>ARRAY</span>
|
||||
</div>
|
||||
<div class="lampcell" id="cellPulse">
|
||||
<i class="lamp lamp--amber"></i><span>SIGNAL</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="plate plate--bottom">
|
||||
PROPERTY OF THE U.S. DEPARTMENT OF ENERGY · MCMLXIV · AUTHORIZED PERSONNEL ONLY
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div class="void" id="void" aria-hidden="true"></div>
|
||||
<div class="flash" id="flash" aria-hidden="true"></div>
|
||||
<div class="message" id="message" aria-hidden="true"></div>
|
||||
<button class="sound" id="soundBtn" type="button">◌ SOUND OFF</button>
|
||||
|
||||
<script src="common.js"></script>
|
||||
<script src="sound.js"></script>
|
||||
<script src="clock.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
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;
|
||||
}
|
||||
});
|
||||
})();
|
||||
134
services/observer-effect/site/common.js
Normal file
134
services/observer-effect/site/common.js
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
/* Observer Effect — shared clock logic for the player screen and the Handler's
|
||||
panel. Classic script (no modules) so the pages also open straight from disk
|
||||
for a quick look. Everything hangs off the global `OE`. */
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// In-world second-of-day for the moment Azathoth achieves communion.
|
||||
const COMMUNION = 79417; // 22:03:37
|
||||
|
||||
// The scenario's beats, in second-of-day. `pulse: true` marks the live
|
||||
// on-site shudders the player screen reacts to; the rest are scene markers
|
||||
// and useful jump targets for the Handler.
|
||||
const EVENTS = [
|
||||
{ t: 36000, label: "Array activated", time: "10:00:00" },
|
||||
{ t: 55735, label: "Power surge", time: "15:28:55" },
|
||||
{ t: 59682, label: "Phantom signal", time: "16:34:42" },
|
||||
{ t: 61200, label: "Agents arrive", time: "17:00:00" },
|
||||
{ t: 63629, label: "Pulse", time: "17:40:29", pulse: true },
|
||||
{ t: 67576, label: "Takagawa awakens", time: "18:46:16", pulse: true },
|
||||
{ t: 71523, label: "Klinger appears", time: "19:52:03", pulse: true },
|
||||
{ t: 75470, label: "Klinger's rampage", time: "20:57:50", pulse: true },
|
||||
{ t: COMMUNION, label: "COMMUNION", time: "22:03:37", communion: true },
|
||||
];
|
||||
|
||||
const RESET_POINTS = { 1: 61200, 2: 67576, 3: 75470, 4: COMMUNION };
|
||||
const ROMAN = { 1: "I", 2: "II", 3: "III", 4: "IV" };
|
||||
|
||||
function pad(n) {
|
||||
return String(Math.floor(n)).padStart(2, "0");
|
||||
}
|
||||
|
||||
// second-of-day (may exceed a day or go negative) -> "HH:MM:SS", clamped 0..24h.
|
||||
function clock(sec) {
|
||||
let s = Math.max(0, Math.min(86399, Math.floor(sec)));
|
||||
return pad(s / 3600) + ":" + pad((s % 3600) / 60) + ":" + pad(s % 60);
|
||||
}
|
||||
|
||||
// A signed duration in seconds -> "H:MM:SS".
|
||||
function duration(sec) {
|
||||
const neg = sec < 0;
|
||||
let s = Math.abs(Math.floor(sec));
|
||||
const h = Math.floor(s / 3600);
|
||||
return (neg ? "-" : "") + h + ":" + pad((s % 3600) / 60) + ":" + pad(s % 60);
|
||||
}
|
||||
|
||||
/* Holds the latest authoritative snapshot and free-runs from it locally, so
|
||||
the digits keep ticking between the Handler's actions. */
|
||||
function ClockModel() {
|
||||
let base = RESET_POINTS[1];
|
||||
let baseAt = performance.now();
|
||||
let running = false;
|
||||
let rate = 1;
|
||||
let iteration = 1;
|
||||
|
||||
return {
|
||||
apply(state) {
|
||||
base = state.inworld;
|
||||
baseAt = performance.now();
|
||||
running = state.running;
|
||||
rate = state.rate;
|
||||
iteration = state.iteration;
|
||||
},
|
||||
now() {
|
||||
const live = running
|
||||
? base + ((performance.now() - baseAt) / 1000) * rate
|
||||
: base;
|
||||
return {
|
||||
inworld: live,
|
||||
remaining: COMMUNION - live,
|
||||
running,
|
||||
rate,
|
||||
iteration,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* Auto-reconnecting WebSocket. `onstate`/`onfx` get parsed messages;
|
||||
`onstatus` gets "open"/"closed" for the link lamp. */
|
||||
function connect(url, handlers) {
|
||||
let ws;
|
||||
let closed = false;
|
||||
|
||||
function open() {
|
||||
ws = new WebSocket(url);
|
||||
ws.onopen = () => handlers.onstatus && handlers.onstatus("open");
|
||||
ws.onclose = () => {
|
||||
handlers.onstatus && handlers.onstatus("closed");
|
||||
if (!closed) setTimeout(open, 1000);
|
||||
};
|
||||
ws.onerror = () => ws.close();
|
||||
ws.onmessage = (e) => {
|
||||
let m;
|
||||
try {
|
||||
m = JSON.parse(e.data);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
if (m.type === "state") handlers.onstate && handlers.onstate(m);
|
||||
else if (m.type === "fx") handlers.onfx && handlers.onfx(m);
|
||||
};
|
||||
}
|
||||
open();
|
||||
|
||||
return {
|
||||
send(obj) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
|
||||
},
|
||||
close() {
|
||||
closed = true;
|
||||
if (ws) ws.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Default WS endpoint: same host, /ws, matching page's TLS.
|
||||
function defaultWsUrl() {
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return proto + "//" + location.host + "/ws";
|
||||
}
|
||||
|
||||
window.OE = {
|
||||
COMMUNION,
|
||||
EVENTS,
|
||||
RESET_POINTS,
|
||||
ROMAN,
|
||||
clock,
|
||||
duration,
|
||||
pad,
|
||||
ClockModel,
|
||||
connect,
|
||||
defaultWsUrl,
|
||||
};
|
||||
})();
|
||||
260
services/observer-effect/site/control.css
Normal file
260
services/observer-effect/site/control.css
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
/* Array Control — the Handler's cockpit. Same steel as the chronometer,
|
||||
but laid out as a dense bank of switches and push-buttons. */
|
||||
|
||||
:root {
|
||||
--bg: #0a0b0c;
|
||||
--steel-hi: #4c5054;
|
||||
--steel: #2c2f32;
|
||||
--steel-lo: #16181a;
|
||||
--label: #c9cdd1;
|
||||
--amber: #ffb347;
|
||||
--red: #ff3b30;
|
||||
--green: #46e06a;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body.panel {
|
||||
min-height: 100vh;
|
||||
padding: clamp(0.6rem, 2.5vw, 2rem);
|
||||
background: radial-gradient(120% 120% at 50% 0%, #15171a, var(--bg) 70%);
|
||||
color: var(--label);
|
||||
font-family: "Helvetica Neue", Arial, system-ui, sans-serif;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
.rig {
|
||||
width: min(96vw, 760px);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
/* engraved header */
|
||||
.plate {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.16em;
|
||||
font-size: clamp(0.7rem, 2vw, 0.95rem);
|
||||
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
.link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
color: #9aa0a4;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.lamp {
|
||||
width: 0.8em;
|
||||
height: 0.8em;
|
||||
border-radius: 50%;
|
||||
background: #2a2a2a;
|
||||
box-shadow: inset 0 0 3px #000;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.lamp--on {
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 8px var(--green);
|
||||
}
|
||||
|
||||
/* --- mirror --------------------------------------------------------------- */
|
||||
.mirror {
|
||||
padding: clamp(0.7rem, 2.5vw, 1.2rem);
|
||||
border-radius: 12px;
|
||||
background: radial-gradient(120% 160% at 50% 0%, #141312, #070605);
|
||||
border: 1px solid #000;
|
||||
box-shadow: inset 0 5px 16px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
.mirror-time {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.big {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: clamp(2.4rem, 12vw, 4.5rem);
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.04em;
|
||||
color: #ffe2bc;
|
||||
text-shadow: 0 0 6px #ff7a1e, 0 0 22px #ff6a00, 0 0 50px rgba(255, 90, 0, 0.5);
|
||||
}
|
||||
.run {
|
||||
letter-spacing: 0.2em;
|
||||
font-size: 0.75rem;
|
||||
color: #7d8286;
|
||||
}
|
||||
.run.live {
|
||||
color: var(--green);
|
||||
text-shadow: 0 0 8px rgba(70, 224, 106, 0.6);
|
||||
}
|
||||
.mirror-meta {
|
||||
margin-top: 0.6rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.6rem 1.4rem;
|
||||
font-size: clamp(0.6rem, 1.6vw, 0.8rem);
|
||||
letter-spacing: 0.12em;
|
||||
color: #8b9094;
|
||||
}
|
||||
.mirror-meta b {
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
/* --- banks ---------------------------------------------------------------- */
|
||||
.bank {
|
||||
padding: 0.8rem clamp(0.7rem, 2.5vw, 1.1rem) 1rem;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, var(--steel-hi) -40%, var(--steel) 50%, var(--steel-lo));
|
||||
border: 1px solid #0d0e0f;
|
||||
box-shadow: inset 0 2px 0 rgba(255, 255, 255, 0.1), inset 0 -3px 8px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.bank-label,
|
||||
.sub {
|
||||
display: block;
|
||||
letter-spacing: 0.18em;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
color: #8b9094;
|
||||
margin-bottom: 0.6rem;
|
||||
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
.sub {
|
||||
display: inline;
|
||||
margin: 0 0.4rem 0 0;
|
||||
align-self: center;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.row + .row {
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
/* --- buttons -------------------------------------------------------------- */
|
||||
.btn {
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
font-size: 0.8rem;
|
||||
color: #e7eaed;
|
||||
padding: 0.55rem 0.9rem;
|
||||
border-radius: 7px;
|
||||
border: 1px solid #000;
|
||||
background: linear-gradient(180deg, #3c4044, #23262a);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14), 0 2px 4px rgba(0, 0, 0, 0.5);
|
||||
cursor: pointer;
|
||||
transition: transform 0.05s, filter 0.1s;
|
||||
}
|
||||
.btn:hover {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
.btn:active {
|
||||
transform: translateY(1px);
|
||||
box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.btn--wide {
|
||||
flex: 1;
|
||||
}
|
||||
.btn.active {
|
||||
color: #0a0b0c;
|
||||
background: linear-gradient(180deg, #ffd98a, var(--amber));
|
||||
box-shadow: 0 0 10px rgba(255, 179, 71, 0.6);
|
||||
}
|
||||
.btn--amber {
|
||||
color: #2a1c00;
|
||||
background: linear-gradient(180deg, #ffd98a, var(--amber));
|
||||
}
|
||||
.btn--red {
|
||||
color: #fff;
|
||||
background: linear-gradient(180deg, #ff6a60, var(--red));
|
||||
}
|
||||
.btn--big {
|
||||
flex: 1;
|
||||
font-size: 1rem;
|
||||
padding: 0.9rem;
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
.jump {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
padding: 0.5rem 0.7rem;
|
||||
}
|
||||
.jump b {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.jump span {
|
||||
font-weight: 400;
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.06em;
|
||||
color: #aeb3b7;
|
||||
}
|
||||
.jump.btn--amber span,
|
||||
.jump.btn--red span {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.iters .iter {
|
||||
flex: 1;
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
/* --- inputs --------------------------------------------------------------- */
|
||||
input {
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
color: #e7eaed;
|
||||
background: #101214;
|
||||
border: 1px solid #000;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.6rem;
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
input:focus {
|
||||
outline: 1px solid var(--amber);
|
||||
}
|
||||
#token,
|
||||
#msg {
|
||||
flex: 1;
|
||||
min-width: 8rem;
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.7rem;
|
||||
color: var(--amber);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.foot {
|
||||
text-align: center;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: #7d8286;
|
||||
}
|
||||
.foot a {
|
||||
color: var(--amber);
|
||||
}
|
||||
101
services/observer-effect/site/control.html
Normal file
101
services/observer-effect/site/control.html
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>Array Control</title>
|
||||
<link rel="stylesheet" href="control.css" />
|
||||
</head>
|
||||
<body class="panel">
|
||||
<main class="rig">
|
||||
<header class="plate">
|
||||
<span class="stencil">ARRAY CONTROL · HANDLER ONLY</span>
|
||||
<span class="link"><i class="lamp" id="lampLink"></i> LINK</span>
|
||||
</header>
|
||||
|
||||
<!-- live mirror of what the players see -->
|
||||
<section class="mirror">
|
||||
<div class="mirror-time">
|
||||
<span class="big" id="mClock">17:00:00</span>
|
||||
<span class="run" id="mRun">PAUSED</span>
|
||||
</div>
|
||||
<div class="mirror-meta">
|
||||
<span>COMMUNION IN <b id="mCountdown">—:—:—</b></span>
|
||||
<span>ITERATION <b id="mIter">I</b></span>
|
||||
<span>NEXT <b id="mNext">—</b></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- token -->
|
||||
<section class="bank">
|
||||
<label class="bank-label" for="token">CONTROL KEY</label>
|
||||
<div class="row">
|
||||
<input id="token" type="password" autocomplete="off" placeholder="shared token" />
|
||||
<button class="btn" id="saveToken" type="button">SET</button>
|
||||
<span class="hint" id="tokenHint"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- transport -->
|
||||
<section class="bank">
|
||||
<span class="bank-label">TRANSPORT</span>
|
||||
<div class="row">
|
||||
<button class="btn btn--wide" data-cmd="play" type="button">▶ RUN</button>
|
||||
<button class="btn btn--wide" data-cmd="pause" type="button">⏸ HOLD</button>
|
||||
</div>
|
||||
<div class="row rates" id="rates">
|
||||
<span class="sub">RATE</span>
|
||||
<button class="btn rate" data-rate="1" type="button">×1</button>
|
||||
<button class="btn rate" data-rate="6" type="button">×6</button>
|
||||
<button class="btn rate" data-rate="30" type="button">×30</button>
|
||||
<button class="btn rate" data-rate="60" type="button">×60</button>
|
||||
<button class="btn rate" data-rate="300" type="button">×300</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- timeline jumps -->
|
||||
<section class="bank">
|
||||
<span class="bank-label">JUMP TO BEAT</span>
|
||||
<div class="grid" id="jumps"></div>
|
||||
<div class="row">
|
||||
<span class="sub">SET</span>
|
||||
<input id="manualTime" type="time" step="1" value="17:00:00" />
|
||||
<button class="btn" id="manualJump" type="button">JUMP</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- iterations -->
|
||||
<section class="bank">
|
||||
<span class="bank-label">ITERATION — resets reality to its wake point</span>
|
||||
<div class="row iters" id="iters">
|
||||
<button class="btn iter" data-iter="1" type="button">I · 17:00</button>
|
||||
<button class="btn iter" data-iter="2" type="button">II · 18:46</button>
|
||||
<button class="btn iter" data-iter="3" type="button">III · 20:57</button>
|
||||
<button class="btn iter" data-iter="4" type="button">IV · SINGULARITY</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- effects -->
|
||||
<section class="bank">
|
||||
<span class="bank-label">EFFECTS — fire on the players' screen</span>
|
||||
<div class="row">
|
||||
<button class="btn btn--amber btn--big" data-cmd="pulse" type="button">◉ PULSE</button>
|
||||
<button class="btn btn--red btn--big" data-cmd="communion" type="button">☢ COMMUNION</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<input id="msg" type="text" maxlength="60" placeholder="broadcast a line of text…" />
|
||||
<button class="btn" id="sendMsg" type="button">SHOW</button>
|
||||
<button class="btn" id="clearMsg" type="button">CLEAR</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="foot">
|
||||
Player screen: <a id="clockLink" href="clock.html" target="_blank" rel="noopener">open the chronometer ↗</a>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<script src="common.js"></script>
|
||||
<script src="control.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
120
services/observer-effect/site/control.js
Normal file
120
services/observer-effect/site/control.js
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
/* Handler's control panel. Sends authenticated commands to the relay and
|
||||
mirrors the live clock so the Handler sees exactly what the players see. */
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
// --- control key (persisted locally) --------------------------------------
|
||||
const tokenInput = $("token");
|
||||
tokenInput.value = localStorage.getItem("oe-token") || "";
|
||||
function saveToken() {
|
||||
localStorage.setItem("oe-token", tokenInput.value.trim());
|
||||
flashHint("key set");
|
||||
}
|
||||
$("saveToken").addEventListener("click", saveToken);
|
||||
tokenInput.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") saveToken();
|
||||
});
|
||||
let hintTimer = null;
|
||||
function flashHint(text) {
|
||||
const el = $("tokenHint");
|
||||
el.textContent = text;
|
||||
clearTimeout(hintTimer);
|
||||
hintTimer = setTimeout(() => (el.textContent = ""), 1500);
|
||||
}
|
||||
|
||||
// --- connection -----------------------------------------------------------
|
||||
const model = OE.ClockModel();
|
||||
const conn = OE.connect(OE.defaultWsUrl(), {
|
||||
onstatus(s) {
|
||||
$("lampLink").classList.toggle("lamp--on", s === "open");
|
||||
},
|
||||
onstate(state) {
|
||||
model.apply(state);
|
||||
syncRateButtons(state.rate);
|
||||
},
|
||||
});
|
||||
window.addEventListener("beforeunload", () => conn.close());
|
||||
|
||||
function send(obj) {
|
||||
const token = (localStorage.getItem("oe-token") || "").trim();
|
||||
if (!token) {
|
||||
flashHint("no control key set");
|
||||
tokenInput.focus();
|
||||
return;
|
||||
}
|
||||
conn.send(Object.assign({ token }, obj));
|
||||
}
|
||||
|
||||
// --- wire fixed-command buttons (play/pause/pulse/communion) ---------------
|
||||
document.querySelectorAll("[data-cmd]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => send({ cmd: btn.dataset.cmd }));
|
||||
});
|
||||
|
||||
// --- rate -----------------------------------------------------------------
|
||||
document.querySelectorAll(".rate").forEach((btn) => {
|
||||
btn.addEventListener("click", () => send({ cmd: "rate", rate: Number(btn.dataset.rate) }));
|
||||
});
|
||||
function syncRateButtons(rate) {
|
||||
document.querySelectorAll(".rate").forEach((b) => {
|
||||
b.classList.toggle("active", Number(b.dataset.rate) === rate);
|
||||
});
|
||||
}
|
||||
|
||||
// --- iterations -----------------------------------------------------------
|
||||
document.querySelectorAll(".iter").forEach((btn) => {
|
||||
btn.addEventListener("click", () => send({ cmd: "iteration", n: Number(btn.dataset.iter) }));
|
||||
});
|
||||
|
||||
// --- timeline jumps -------------------------------------------------------
|
||||
const jumps = $("jumps");
|
||||
OE.EVENTS.forEach((ev) => {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "btn jump" + (ev.communion ? " btn--red" : ev.pulse ? " btn--amber" : "");
|
||||
b.innerHTML = "<b>" + ev.time + "</b><span>" + ev.label + "</span>";
|
||||
b.addEventListener("click", () => send({ cmd: "set", inworld: ev.t }));
|
||||
jumps.appendChild(b);
|
||||
});
|
||||
|
||||
// manual time -> seconds-of-day
|
||||
$("manualJump").addEventListener("click", () => {
|
||||
const parts = $("manualTime").value.split(":").map(Number);
|
||||
if (parts.length >= 2 && parts.every((n) => !Number.isNaN(n))) {
|
||||
const sec = (parts[0] || 0) * 3600 + (parts[1] || 0) * 60 + (parts[2] || 0);
|
||||
send({ cmd: "set", inworld: sec });
|
||||
}
|
||||
});
|
||||
|
||||
// --- broadcast message ----------------------------------------------------
|
||||
$("sendMsg").addEventListener("click", () => {
|
||||
const text = $("msg").value.trim();
|
||||
if (text) send({ cmd: "msg", text });
|
||||
});
|
||||
$("clearMsg").addEventListener("click", () => send({ cmd: "msg", text: null }));
|
||||
$("msg").addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") $("sendMsg").click();
|
||||
});
|
||||
|
||||
// player-screen link points at clock.html next to this page
|
||||
$("clockLink").href = new URL("clock.html", location.href).href;
|
||||
|
||||
// --- mirror loop ----------------------------------------------------------
|
||||
function nextBeat(inworld) {
|
||||
const upcoming = OE.EVENTS.filter((e) => e.t > inworld + 0.5);
|
||||
return upcoming.length ? upcoming[0] : null;
|
||||
}
|
||||
function frame() {
|
||||
const s = model.now();
|
||||
$("mClock").textContent = OE.clock(s.inworld);
|
||||
$("mIter").textContent = OE.ROMAN[s.iteration] || s.iteration;
|
||||
$("mCountdown").textContent = s.remaining <= 0 ? "00:00:00" : OE.duration(s.remaining);
|
||||
$("mRun").textContent = s.running ? "RUNNING" : "PAUSED";
|
||||
$("mRun").classList.toggle("live", s.running);
|
||||
const nb = nextBeat(s.inworld);
|
||||
$("mNext").textContent = nb ? nb.time + " " + nb.label : "—";
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
})();
|
||||
BIN
services/observer-effect/site/sfx/communion.mp3
Normal file
BIN
services/observer-effect/site/sfx/communion.mp3
Normal file
Binary file not shown.
65
services/observer-effect/site/sfx/generate.sh
Executable file
65
services/observer-effect/site/sfx/generate.sh
Executable file
|
|
@ -0,0 +1,65 @@
|
|||
#!/usr/bin/env bash
|
||||
# Synthesize the Observer Effect SFX with ffmpeg. Pure lavfi synthesis — no
|
||||
# samples. Re-run to regenerate the .mp3s in this directory.
|
||||
#
|
||||
# nix-shell -p ffmpeg --run ./generate.sh
|
||||
#
|
||||
# Expressions are deliberately comma-free (logistic gates 1/(1+exp(-k*(t-T)))
|
||||
# and exp() envelopes instead of between()/min()/max()) so nothing needs
|
||||
# escaping inside the filtergraph.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
SR=48000
|
||||
MP3=(-ac 1 -c:a libmp3lame -b:a 128k)
|
||||
# Per-second clicks use WAV: mp3 adds a few ms of priming silence at the buffer
|
||||
# start, which smears the transient against the digit changeover.
|
||||
WAV=(-ac 1 -c:a pcm_s16le)
|
||||
|
||||
# --- pulse: a deep drum thump, a rising flute whistle, a metallic shimmer ----
|
||||
PULSE='exp(-5*t)*0.9*sin(2*PI*(90*t-25*t*t))'
|
||||
PULSE+=' + (1-exp(-25*t))*exp(-3*t)*0.16*sin(2*PI*(1500*t+850*t*t))'
|
||||
PULSE+=' + exp(-2.2*t)*0.05*sin(2*PI*2700*t)*(1+0.5*sin(2*PI*7*t))'
|
||||
ffmpeg -y -hide_banner -loglevel error \
|
||||
-f lavfi -i "aevalsrc=${PULSE}:s=${SR}:d=1.4" \
|
||||
-af "aecho=0.8:0.7:45|110:0.35|0.2,alimiter=limit=0.95,aresample=44100" \
|
||||
"${MP3[@]}" pulse.mp3
|
||||
|
||||
# --- communion: rising roar + noise swell -> sub-bass impact -> screaming
|
||||
# flutes over a beating void drone -------------------------------------
|
||||
ROAR='(1-exp(-2.5*t))*exp(-0.28*t)*0.28*(sin(2*PI*(60*t-2.5*t*t))+0.5*sin(4*PI*(60*t-2.5*t*t))+0.33*sin(6*PI*(60*t-2.5*t*t)))'
|
||||
IMPACT='(1/(1+exp(-30*(t-1.6))))*exp(-3.2*(t-1.6))*0.95*sin(2*PI*(80*(t-1.6)-35*(t-1.6)*(t-1.6)))'
|
||||
FLUTES='(1/(1+exp(-30*(t-1.7))))*exp(-0.9*(t-1.7))*0.07*(sin(2*PI*1700*t)+sin(2*PI*2050*t)+sin(2*PI*2390*t)+sin(2*PI*2900*t)+sin(2*PI*3550*t))*(1+0.4*sin(2*PI*5*t))'
|
||||
DRONE='(1/(1+exp(-25*(t-1.7))))*exp(-0.5*(t-1.7))*0.22*(sin(2*PI*40*t)+sin(2*PI*40.5*t))'
|
||||
TONAL="${ROAR} + ${IMPACT} + ${FLUTES} + ${DRONE}"
|
||||
SWELL="volume=volume='0.4*(1-exp(-2.2*t))*(1/(1+exp(8*(t-1.95))))*(0.6+0.4*sin(2*PI*0.9*t))':eval=frame"
|
||||
ffmpeg -y -hide_banner -loglevel error \
|
||||
-f lavfi -i "aevalsrc=${TONAL}:s=${SR}:d=6.5" \
|
||||
-f lavfi -i "anoisesrc=color=white:amplitude=0.6:r=${SR}:d=6.5" \
|
||||
-filter_complex "[1:a]highpass=f=3000,${SWELL}[n];[0:a][n]amix=inputs=2:normalize=0,aecho=0.8:0.85:55|150:0.35|0.2,alimiter=limit=0.96,aresample=44100[out]" \
|
||||
-map "[out]" "${MP3[@]}" communion.mp3
|
||||
|
||||
# --- whisper: a brief eerie transmission for broadcast text ------------------
|
||||
WHISP='(1-exp(-8*t))*exp(-2*t)*0.12*(sin(2*PI*620*t)+sin(2*PI*623*t))'
|
||||
WHISP+=' + exp(-3*t)*0.04*sin(2*PI*(2200*t-300*t*t))'
|
||||
WHISP+=' + 0.03*exp(-1.5*t)*sin(2*PI*1240*t)*(1+0.6*sin(2*PI*11*t))'
|
||||
ffmpeg -y -hide_banner -loglevel error \
|
||||
-f lavfi -i "aevalsrc=${WHISP}:s=${SR}:d=1.6" \
|
||||
-f lavfi -i "anoisesrc=color=white:amplitude=0.25:r=${SR}:d=1.6" \
|
||||
-filter_complex "[1:a]bandpass=f=1500:width_type=h:w=900,volume=volume='0.18*exp(-2.5*t)*(0.5+0.5*sin(2*PI*13*t))':eval=frame[n];[0:a][n]amix=inputs=2:normalize=0,volume=12dB,aecho=0.8:0.6:70:0.3,alimiter=limit=0.9,aresample=44100[out]" \
|
||||
-map "[out]" "${MP3[@]}" whisper.mp3
|
||||
|
||||
# --- relay: a dry electromechanical seconds step (armature snap + body) -------
|
||||
# The seconds advance is a stepping relay, not a pendulum: a sharp broadband
|
||||
# snap with a faint low "thunk" and almost no tonal tail.
|
||||
RELAY='exp(-120*t)*0.3*sin(2*PI*430*t) + exp(-220*t)*0.32*sin(2*PI*1700*t)'
|
||||
ffmpeg -y -hide_banner -loglevel error \
|
||||
-f lavfi -i "aevalsrc=${RELAY}:s=${SR}:d=0.09" \
|
||||
-f lavfi -i "anoisesrc=color=white:amplitude=0.6:r=${SR}:d=0.09" \
|
||||
-filter_complex "[1:a]bandpass=f=2400:width_type=h:w=2400,volume=volume='0.6*exp(-230*t)':eval=frame[n];[0:a][n]amix=inputs=2:normalize=0,alimiter=limit=0.95,aresample=44100[out]" \
|
||||
-map "[out]" "${WAV[@]}" relay.wav
|
||||
|
||||
echo "generated:"
|
||||
for f in pulse.mp3 communion.mp3 whisper.mp3 relay.wav; do
|
||||
printf ' %-14s %s\n' "$f" "$(du -h "$f" | cut -f1)"
|
||||
done
|
||||
BIN
services/observer-effect/site/sfx/pulse.mp3
Normal file
BIN
services/observer-effect/site/sfx/pulse.mp3
Normal file
Binary file not shown.
BIN
services/observer-effect/site/sfx/relay.wav
Normal file
BIN
services/observer-effect/site/sfx/relay.wav
Normal file
Binary file not shown.
BIN
services/observer-effect/site/sfx/whisper.mp3
Normal file
BIN
services/observer-effect/site/sfx/whisper.mp3
Normal file
Binary file not shown.
326
services/observer-effect/site/sound.js
Normal file
326
services/observer-effect/site/sound.js
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
/* Optional soundscape for the player screen — the "flutes and drums" the
|
||||
scenario keeps describing. Silent until the player clicks to enable it
|
||||
(browsers block audio without a gesture). All synthesized; no asset files. */
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
let ctx = null;
|
||||
let master = null;
|
||||
let droneGain = null;
|
||||
|
||||
// Pre-rendered ffmpeg SFX (see sfx/generate.sh). Decoded into buffers on
|
||||
// enable; if a file fails to load we fall back to the synth below.
|
||||
const FILES = {
|
||||
pulse: "sfx/pulse.mp3",
|
||||
communion: "sfx/communion.mp3",
|
||||
whisper: "sfx/whisper.mp3",
|
||||
relay: "sfx/relay.wav",
|
||||
};
|
||||
const buffers = {};
|
||||
|
||||
function loadBuffers() {
|
||||
for (const [name, url] of Object.entries(FILES)) {
|
||||
fetch(new URL(url, location.href))
|
||||
.then((r) => (r.ok ? r.arrayBuffer() : Promise.reject()))
|
||||
.then((data) => ctx.decodeAudioData(data))
|
||||
.then((buf) => (buffers[name] = buf))
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function play(name) {
|
||||
const buf = buffers[name];
|
||||
if (!buf || !ctx) return false;
|
||||
const src = ctx.createBufferSource();
|
||||
src.buffer = buf;
|
||||
src.connect(master);
|
||||
src.start();
|
||||
return true;
|
||||
}
|
||||
|
||||
function enable() {
|
||||
if (ctx) return true;
|
||||
const AC = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AC) return false;
|
||||
ctx = new AC();
|
||||
master = ctx.createGain();
|
||||
master.gain.value = 0.8;
|
||||
master.connect(ctx.destination);
|
||||
|
||||
// A near-subaudible drone that swells as communion nears (see setDread).
|
||||
droneGain = ctx.createGain();
|
||||
droneGain.gain.value = 0;
|
||||
droneGain.connect(master);
|
||||
const drone = ctx.createOscillator();
|
||||
drone.type = "sine";
|
||||
drone.frequency.value = 33;
|
||||
const drone2 = ctx.createOscillator();
|
||||
drone2.type = "sine";
|
||||
drone2.frequency.value = 33.4; // beat against the first for unease
|
||||
drone.connect(droneGain);
|
||||
drone2.connect(droneGain);
|
||||
drone.start();
|
||||
drone2.start();
|
||||
|
||||
// Mains hum: this is a high-voltage 1964 nixie device, so it's never truly
|
||||
// silent. A 60 Hz transformer hum with harmonics plus a faint HV whine,
|
||||
// synthesized live so it loops seamlessly. Constant while sound is on.
|
||||
const humGain = ctx.createGain();
|
||||
humGain.gain.value = 0.07;
|
||||
humGain.connect(master);
|
||||
const humLfo = ctx.createOscillator(); // slow amplitude waver
|
||||
humLfo.frequency.value = 0.5;
|
||||
const humLfoGain = ctx.createGain();
|
||||
humLfoGain.gain.value = 0.012;
|
||||
humLfo.connect(humLfoGain).connect(humGain.gain);
|
||||
humLfo.start();
|
||||
const humLp = ctx.createBiquadFilter();
|
||||
humLp.type = "lowpass";
|
||||
humLp.frequency.value = 500;
|
||||
humLp.connect(humGain);
|
||||
[[60, 0.5], [120, 0.32], [180, 0.12]].forEach(([f, g]) => {
|
||||
const o = ctx.createOscillator();
|
||||
o.type = "sine";
|
||||
o.frequency.value = f;
|
||||
const og = ctx.createGain();
|
||||
og.gain.value = g;
|
||||
o.connect(og).connect(humLp);
|
||||
o.start();
|
||||
});
|
||||
const whine = ctx.createOscillator(); // high-voltage supply whine
|
||||
whine.type = "sine";
|
||||
whine.frequency.value = 15000;
|
||||
const whineGain = ctx.createGain();
|
||||
whineGain.gain.value = 0.015;
|
||||
whine.connect(whineGain).connect(humGain);
|
||||
whine.start();
|
||||
|
||||
loadBuffers();
|
||||
return true;
|
||||
}
|
||||
|
||||
function enabled() {
|
||||
return !!ctx;
|
||||
}
|
||||
|
||||
// Low drum thump + a thin flute whistle: one "pulse" of Azathoth's court.
|
||||
function synthPulse() {
|
||||
if (!ctx) return;
|
||||
const t = ctx.currentTime;
|
||||
|
||||
const drum = ctx.createOscillator();
|
||||
const dg = ctx.createGain();
|
||||
drum.type = "sine";
|
||||
drum.frequency.setValueAtTime(64, t);
|
||||
drum.frequency.exponentialRampToValueAtTime(34, t + 0.5);
|
||||
dg.gain.setValueAtTime(0.0001, t);
|
||||
dg.gain.exponentialRampToValueAtTime(0.9, t + 0.02);
|
||||
dg.gain.exponentialRampToValueAtTime(0.0001, t + 0.7);
|
||||
drum.connect(dg).connect(master);
|
||||
drum.start(t);
|
||||
drum.stop(t + 0.75);
|
||||
|
||||
const flute = ctx.createOscillator();
|
||||
const fg = ctx.createGain();
|
||||
flute.type = "triangle";
|
||||
flute.frequency.setValueAtTime(1900, t);
|
||||
flute.frequency.exponentialRampToValueAtTime(3300, t + 0.6);
|
||||
fg.gain.setValueAtTime(0.0001, t);
|
||||
fg.gain.exponentialRampToValueAtTime(0.12, t + 0.05);
|
||||
fg.gain.exponentialRampToValueAtTime(0.0001, t + 0.65);
|
||||
flute.connect(fg).connect(master);
|
||||
flute.start(t);
|
||||
flute.stop(t + 0.7);
|
||||
}
|
||||
|
||||
function noise(dur) {
|
||||
const len = Math.floor(ctx.sampleRate * dur);
|
||||
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
|
||||
const data = buf.getChannelData(0);
|
||||
for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1;
|
||||
return buf;
|
||||
}
|
||||
|
||||
// The full extrusion: a rising roar and noise swell that detonate into a
|
||||
// sub-bass impact, then a cluster of screaming flutes over a void drone.
|
||||
// Timed so the impact lands with the visual blast (~1.6s in).
|
||||
function synthCommunion() {
|
||||
if (!ctx) return;
|
||||
const t = ctx.currentTime;
|
||||
const blast = t + 1.6;
|
||||
|
||||
// rising roar into the blast
|
||||
const roar = ctx.createOscillator();
|
||||
const rg = ctx.createGain();
|
||||
roar.type = "sawtooth";
|
||||
roar.frequency.setValueAtTime(26, t);
|
||||
roar.frequency.exponentialRampToValueAtTime(64, blast);
|
||||
roar.frequency.exponentialRampToValueAtTime(30, blast + 2.6);
|
||||
rg.gain.setValueAtTime(0.0001, t);
|
||||
rg.gain.exponentialRampToValueAtTime(0.5, blast);
|
||||
rg.gain.exponentialRampToValueAtTime(0.0001, blast + 3.2);
|
||||
roar.connect(rg).connect(master);
|
||||
roar.start(t);
|
||||
roar.stop(blast + 3.4);
|
||||
|
||||
// reverse-cymbal noise swell sucking up into the blast
|
||||
const sw = ctx.createBufferSource();
|
||||
sw.buffer = noise(2);
|
||||
const swf = ctx.createBiquadFilter();
|
||||
swf.type = "highpass";
|
||||
swf.frequency.setValueAtTime(200, t);
|
||||
swf.frequency.exponentialRampToValueAtTime(9000, blast);
|
||||
const swg = ctx.createGain();
|
||||
swg.gain.setValueAtTime(0.0001, t);
|
||||
swg.gain.exponentialRampToValueAtTime(0.22, blast);
|
||||
swg.gain.exponentialRampToValueAtTime(0.0001, blast + 0.4);
|
||||
sw.connect(swf).connect(swg).connect(master);
|
||||
sw.start(t);
|
||||
sw.stop(blast + 0.5);
|
||||
|
||||
// THE IMPACT: a plunging sub-bass body + a broadband burst
|
||||
const imp = ctx.createOscillator();
|
||||
const ig = ctx.createGain();
|
||||
imp.type = "sine";
|
||||
imp.frequency.setValueAtTime(82, blast);
|
||||
imp.frequency.exponentialRampToValueAtTime(18, blast + 0.8);
|
||||
ig.gain.setValueAtTime(0.0001, blast);
|
||||
ig.gain.exponentialRampToValueAtTime(0.95, blast + 0.03);
|
||||
ig.gain.exponentialRampToValueAtTime(0.0001, blast + 1.4);
|
||||
imp.connect(ig).connect(master);
|
||||
imp.start(blast);
|
||||
imp.stop(blast + 1.5);
|
||||
|
||||
const burst = ctx.createBufferSource();
|
||||
burst.buffer = noise(1);
|
||||
const bf = ctx.createBiquadFilter();
|
||||
bf.type = "bandpass";
|
||||
bf.frequency.value = 1100;
|
||||
bf.Q.value = 0.4;
|
||||
const bg = ctx.createGain();
|
||||
bg.gain.setValueAtTime(0.55, blast);
|
||||
bg.gain.exponentialRampToValueAtTime(0.0001, blast + 0.6);
|
||||
burst.connect(bf).connect(bg).connect(master);
|
||||
burst.start(blast);
|
||||
burst.stop(blast + 0.7);
|
||||
|
||||
// screaming flute cluster after the rupture
|
||||
[1700, 2050, 2390, 2900, 3550, 4200].forEach((f, i) => {
|
||||
const o = ctx.createOscillator();
|
||||
const g = ctx.createGain();
|
||||
o.type = "triangle";
|
||||
o.frequency.setValueAtTime(f, blast);
|
||||
o.frequency.linearRampToValueAtTime(f * 1.22, blast + 2.6);
|
||||
g.gain.setValueAtTime(0.0001, blast + i * 0.06);
|
||||
g.gain.exponentialRampToValueAtTime(0.06, blast + 0.3 + i * 0.06);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, blast + 3);
|
||||
o.connect(g).connect(master);
|
||||
o.start(blast + i * 0.06);
|
||||
o.stop(blast + 3.2);
|
||||
});
|
||||
|
||||
// void drone tail (two detuned sines, beating)
|
||||
const vg = ctx.createGain();
|
||||
vg.gain.setValueAtTime(0.0001, blast + 0.2);
|
||||
vg.gain.exponentialRampToValueAtTime(0.3, blast + 1);
|
||||
vg.gain.exponentialRampToValueAtTime(0.0001, blast + 4);
|
||||
vg.connect(master);
|
||||
[40, 40.5].forEach((f) => {
|
||||
const o = ctx.createOscillator();
|
||||
o.type = "sine";
|
||||
o.frequency.value = f;
|
||||
o.connect(vg);
|
||||
o.start(blast);
|
||||
o.stop(blast + 4.2);
|
||||
});
|
||||
}
|
||||
|
||||
// level 0..1 — swells the drone with tension, but cuts cleanly to silence
|
||||
// once tension is spent so the deep pulse doesn't hang around.
|
||||
function setDread(level) {
|
||||
if (!ctx || !droneGain) return;
|
||||
const clamped = Math.max(0, Math.min(1, level));
|
||||
const now = ctx.currentTime;
|
||||
if (clamped <= 0.08) {
|
||||
droneGain.gain.cancelScheduledValues(now);
|
||||
droneGain.gain.setTargetAtTime(0, now, 0.3);
|
||||
return;
|
||||
}
|
||||
droneGain.gain.setTargetAtTime(clamped * 0.22, now, 1.5);
|
||||
}
|
||||
|
||||
// File first, synth as fallback.
|
||||
function pulse() {
|
||||
if (!play("pulse")) synthPulse();
|
||||
}
|
||||
function communion() {
|
||||
if (!play("communion")) synthCommunion();
|
||||
}
|
||||
function message() {
|
||||
play("whisper");
|
||||
}
|
||||
|
||||
// Per-second stepping relay — consistent level, no variation. Optionally
|
||||
// scheduled at a future AudioContext time (`when`) so ticks keep firing while
|
||||
// the tab is backgrounded. Pending clicks are tracked so they can be canceled.
|
||||
let pendingTicks = [];
|
||||
function step(when) {
|
||||
if (!ctx) return;
|
||||
const at = when || ctx.currentTime;
|
||||
const g = ctx.createGain();
|
||||
g.gain.value = 0.4;
|
||||
g.connect(master);
|
||||
let node;
|
||||
if (buffers.relay) {
|
||||
node = ctx.createBufferSource();
|
||||
node.buffer = buffers.relay;
|
||||
node.connect(g);
|
||||
node.start(at);
|
||||
} else {
|
||||
node = ctx.createOscillator();
|
||||
node.type = "square";
|
||||
node.frequency.value = 1800;
|
||||
const e = ctx.createGain();
|
||||
e.gain.setValueAtTime(0.0001, at);
|
||||
e.gain.exponentialRampToValueAtTime(0.4, at + 0.002);
|
||||
e.gain.exponentialRampToValueAtTime(0.0001, at + 0.03);
|
||||
node.connect(e).connect(g);
|
||||
node.start(at);
|
||||
node.stop(at + 0.04);
|
||||
}
|
||||
pendingTicks.push({ node, at });
|
||||
if (pendingTicks.length > 64) {
|
||||
pendingTicks = pendingTicks.filter((p) => p.at > ctx.currentTime - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Stop any clicks scheduled for the future (e.g. on pause or a clock jump).
|
||||
function cancelTicks() {
|
||||
if (!ctx) return;
|
||||
const t = ctx.currentTime;
|
||||
for (const p of pendingTicks) {
|
||||
if (p.at > t) {
|
||||
try {
|
||||
p.node.stop(t);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
pendingTicks = pendingTicks.filter((p) => p.at > t);
|
||||
}
|
||||
|
||||
function audioNow() {
|
||||
return ctx ? ctx.currentTime : 0;
|
||||
}
|
||||
|
||||
window.OESound = {
|
||||
enable,
|
||||
enabled,
|
||||
pulse,
|
||||
communion,
|
||||
message,
|
||||
step,
|
||||
cancelTicks,
|
||||
now: audioNow,
|
||||
setDread,
|
||||
};
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue