Streaming overlay for the Game scene. Reads the manifest written by
loading.sh (game name, subtitle, mic state) and pulls live state from
OBS WebSocket on the same connection that delivers music updates.
- Top HUD: TRANSMISSION ID / SIGNAL bars / UTC clock — same vibe as
Landing/Loading, sized smaller so it doesn't dominate the gameplay.
- Camera frame (top-right, 480×270 with cyan corner ticks): decorative
drop-target. The actual OBS camera source layers ON TOP; a centered
placeholder (camcorder SVG + 'PLACE CAMERA HERE' + frame coordinates
'2008, 100 · 480×270') shows during setup and gets covered when the
camera source is positioned. Configurable via URL params:
?camera=Webcam&scene=Game.
- Status bar (bottom): solid gradient + cyan top edge so it reads on
any gameplay background. Two lines:
LIVE · GAME · SUBTITLE
elapsed timer · mic state · now-playing track
- Live camera-frame sync: queries GetSceneItemEnabled on connect and
subscribes to SceneItemEnableStateChanged, so toggling the camera's
eye-icon in OBS hides/shows the frame within ~50ms — overrides the
manifest's static value.
- Music sync: shares the same WS connection; now-playing line updates
from mpd:state broadcasts.
- CRT overlays kept lighter than Landing (gameplay must remain readable).
434 lines
14 KiB
HTML
434 lines
14 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title>OPHI-118 / GAME</title>
|
||
<style>
|
||
:root {
|
||
--ink: #e8e8e0;
|
||
--ink-dim: rgba(232, 232, 224, 0.62);
|
||
--hud: #4fd2ff;
|
||
--hud-dim: #2a7fa6;
|
||
--hud-edge: rgba(79, 210, 255, 0.85);
|
||
--hud-glow: rgba(79, 210, 255, 0.40);
|
||
--accent: #e63a2e;
|
||
--warn: #ffd000;
|
||
--term-fg: #5fdc62;
|
||
--term-fg-dim:#2c8d2f;
|
||
--mono: 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', monospace;
|
||
--display: 'DejaVu Sans', 'Liberation Sans', 'Helvetica', 'Arial', sans-serif;
|
||
--pad-y: 40px;
|
||
--pad-x: 72px;
|
||
/* Camera frame size — match this in OBS when positioning the camera source */
|
||
--cam-w: 480px;
|
||
--cam-h: 270px;
|
||
}
|
||
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
html, body {
|
||
width: 100%; height: 100%;
|
||
background: transparent; /* overlay only — game capture shows through */
|
||
color: var(--ink);
|
||
font-family: var(--display);
|
||
overflow: hidden;
|
||
user-select: none;
|
||
}
|
||
|
||
/* ───────── Top HUD ───────── */
|
||
.hud {
|
||
position: absolute;
|
||
top: var(--pad-y);
|
||
left: var(--pad-x);
|
||
right: var(--pad-x);
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr 1fr;
|
||
align-items: center;
|
||
font-family: var(--mono);
|
||
font-size: 20px;
|
||
font-weight: 700;
|
||
color: var(--hud);
|
||
letter-spacing: 0.08em;
|
||
text-shadow: 0 0 8px rgba(7, 8, 13, 0.9), 0 0 4px rgba(7, 8, 13, 0.9);
|
||
}
|
||
.hud .group { display: flex; gap: 24px; align-items: center; }
|
||
.hud > .group:nth-child(1) { justify-self: start; }
|
||
.hud > .group:nth-child(2) { justify-self: center; }
|
||
.hud > .group:nth-child(3) { justify-self: end; }
|
||
.hud .dim { color: var(--hud-dim); }
|
||
.hud .led {
|
||
display: inline-block;
|
||
width: 10px; height: 10px;
|
||
border-radius: 50%;
|
||
background: var(--accent);
|
||
box-shadow: 0 0 10px var(--accent);
|
||
margin-right: 10px;
|
||
vertical-align: middle;
|
||
animation: rec-blink 1.6s ease-in-out infinite;
|
||
}
|
||
@keyframes rec-blink {
|
||
0%, 55% { opacity: 1; }
|
||
65%, 100% { opacity: 0.2; }
|
||
}
|
||
|
||
/* ───────── Camera frame (top-right) ─────────
|
||
Decorative only — drop your camera OBS source ON TOP of this frame
|
||
(positioned to match --cam-w / --cam-h). The frame has its own dark
|
||
backdrop + a centered placeholder so you can SEE where to put the
|
||
camera; once the camera source covers it, the placeholder disappears. */
|
||
.camera-frame {
|
||
position: absolute;
|
||
top: calc(var(--pad-y) + 60px); /* clear of the HUD */
|
||
right: var(--pad-x);
|
||
width: var(--cam-w);
|
||
height: var(--cam-h);
|
||
pointer-events: none;
|
||
background: rgba(6, 10, 18, 0.85);
|
||
border: 1.5px solid rgba(79, 210, 255, 0.55);
|
||
box-shadow:
|
||
0 0 28px rgba(79, 210, 255, 0.20),
|
||
inset 0 0 60px rgba(0, 0, 0, 0.55);
|
||
}
|
||
.camera-frame .tick {
|
||
position: absolute;
|
||
width: 24px; height: 24px;
|
||
border: 3px solid var(--hud-edge);
|
||
box-shadow: 0 0 8px var(--hud-glow);
|
||
}
|
||
.camera-frame .tick.tl { top: -2px; left: -2px; border-right: none; border-bottom: none; }
|
||
.camera-frame .tick.tr { top: -2px; right: -2px; border-left: none; border-bottom: none; }
|
||
.camera-frame .tick.bl { bottom: -2px; left: -2px; border-right: none; border-top: none; }
|
||
.camera-frame .tick.br { bottom: -2px; right: -2px; border-left: none; border-top: none; }
|
||
.camera-frame .label {
|
||
position: absolute;
|
||
top: 10px; left: 14px;
|
||
font-family: var(--mono);
|
||
font-size: 12px;
|
||
color: var(--hud);
|
||
letter-spacing: 0.32em;
|
||
text-shadow: 0 0 4px rgba(7, 8, 13, 0.9);
|
||
}
|
||
.camera-frame .placeholder {
|
||
position: absolute; inset: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
font-family: var(--mono);
|
||
color: var(--hud);
|
||
pointer-events: none;
|
||
}
|
||
.camera-frame .placeholder svg { opacity: 0.6; }
|
||
.camera-frame .placeholder .ph-text {
|
||
font-size: 13px;
|
||
letter-spacing: 0.45em;
|
||
color: rgba(79, 210, 255, 0.7);
|
||
}
|
||
.camera-frame .placeholder .ph-coords {
|
||
font-size: 11px;
|
||
letter-spacing: 0.20em;
|
||
color: var(--hud-dim);
|
||
}
|
||
.camera-frame.off { display: none; }
|
||
|
||
/* ───────── Bottom status bar ───────── */
|
||
.status-bar {
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 0; right: 0;
|
||
padding: 22px var(--pad-x) 26px;
|
||
/* Solid darker base + cyan top-edge so the bar reads cleanly on any
|
||
gameplay background. Top fade is short so the strip feels grounded. */
|
||
background:
|
||
linear-gradient(
|
||
to top,
|
||
rgba(7, 8, 13, 0.96) 0%,
|
||
rgba(7, 8, 13, 0.96) 65%,
|
||
rgba(7, 8, 13, 0.50) 90%,
|
||
rgba(7, 8, 13, 0) 100%
|
||
);
|
||
border-top: 1px solid rgba(79, 210, 255, 0.28);
|
||
box-shadow: 0 -2px 16px rgba(0, 0, 0, 0.5);
|
||
font-family: var(--mono);
|
||
letter-spacing: 0.08em;
|
||
}
|
||
.status-line { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; }
|
||
.status-line + .status-line { margin-top: 8px; }
|
||
.status-line .sep { color: var(--hud-dim); }
|
||
|
||
.live-block {
|
||
display: flex; align-items: center; gap: 10px;
|
||
color: var(--accent);
|
||
font-weight: 700;
|
||
letter-spacing: 0.14em;
|
||
font-size: 18px;
|
||
}
|
||
.live-dot {
|
||
width: 11px; height: 11px;
|
||
border-radius: 50%;
|
||
background: var(--accent);
|
||
box-shadow: 0 0 10px var(--accent);
|
||
animation: rec-blink 1.6s ease-in-out infinite;
|
||
}
|
||
.game-name {
|
||
color: var(--ink);
|
||
font-weight: 700;
|
||
font-size: 22px;
|
||
letter-spacing: 0.10em;
|
||
}
|
||
.game-mode {
|
||
color: var(--hud);
|
||
font-size: 17px;
|
||
letter-spacing: 0.18em;
|
||
}
|
||
.meta-line {
|
||
color: var(--ink-dim);
|
||
font-size: 16px;
|
||
}
|
||
.meta-line .icon { color: var(--hud); }
|
||
.np-block { display: flex; align-items: center; gap: 10px; visibility: hidden; }
|
||
.np-block.on { visibility: visible; }
|
||
.np-block .arrow {
|
||
color: var(--term-fg);
|
||
animation: np-pulse 1.05s ease-in-out infinite alternate;
|
||
text-shadow: 0 0 6px rgba(80, 220, 100, 0.4);
|
||
}
|
||
.np-block .title { color: var(--ink); }
|
||
.np-block .time { color: var(--ink-dim); }
|
||
@keyframes np-pulse {
|
||
0% { opacity: 0.45; }
|
||
100% { opacity: 1.00; }
|
||
}
|
||
|
||
/* ───────── Subtle CRT overlays (lighter than Landing — game must stay readable) ───────── */
|
||
#static {
|
||
position: absolute; inset: 0;
|
||
width: 100%; height: 100%;
|
||
pointer-events: none;
|
||
opacity: 0.022;
|
||
mix-blend-mode: screen;
|
||
image-rendering: pixelated;
|
||
}
|
||
.scanlines {
|
||
position: absolute; inset: 0;
|
||
pointer-events: none;
|
||
background: repeating-linear-gradient(
|
||
to bottom,
|
||
rgba(0,0,0,0) 0px,
|
||
rgba(0,0,0,0) 2px,
|
||
rgba(0,0,0,0.13) 3px,
|
||
rgba(0,0,0,0.13) 4px
|
||
);
|
||
opacity: 0.40;
|
||
mix-blend-mode: multiply;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header class="hud">
|
||
<div class="group">
|
||
<span><span class="led"></span>TRANSMISSION</span>
|
||
<span>OPHI-118 // LIVE</span>
|
||
</div>
|
||
<div class="group">
|
||
<span class="dim">SIGNAL</span>
|
||
<span id="signal">▮▮▮▯▯</span>
|
||
</div>
|
||
<div class="group">
|
||
<span class="dim">UTC</span>
|
||
<span id="clock">--:--:--</span>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="camera-frame" id="cam">
|
||
<span class="tick tl"></span>
|
||
<span class="tick tr"></span>
|
||
<span class="tick bl"></span>
|
||
<span class="tick br"></span>
|
||
<span class="label">CAM 01</span>
|
||
<div class="placeholder">
|
||
<svg width="64" height="48" viewBox="0 0 64 48" xmlns="http://www.w3.org/2000/svg" fill="none">
|
||
<rect x="2" y="8" width="44" height="32" rx="2" stroke="#4fd2ff" stroke-width="2"/>
|
||
<path d="M46 18 L60 10 L60 38 L46 30 Z" stroke="#4fd2ff" stroke-width="2" stroke-linejoin="round"/>
|
||
<circle cx="14" cy="14" r="2" fill="#e63a2e"/>
|
||
</svg>
|
||
<span class="ph-text">— PLACE CAMERA HERE —</span>
|
||
<span class="ph-coords">2008, 100 · 480 × 270</span>
|
||
</div>
|
||
</div>
|
||
|
||
<footer class="status-bar">
|
||
<div class="status-line">
|
||
<div class="live-block">
|
||
<span class="live-dot"></span>
|
||
<span>LIVE</span>
|
||
</div>
|
||
<span class="sep">·</span>
|
||
<span class="game-name" id="game-name">—</span>
|
||
<span class="sep" id="game-mode-sep">·</span>
|
||
<span class="game-mode" id="game-mode">—</span>
|
||
</div>
|
||
<div class="status-line meta-line">
|
||
<span class="icon">◷</span>
|
||
<span class="elapsed" id="elapsed">00:00:00</span>
|
||
<span class="sep">·</span>
|
||
<span class="icon" id="mic-icon" title="microphone">🎙</span>
|
||
<span id="mic-state">—</span>
|
||
<span class="sep">·</span>
|
||
<div class="np-block" id="np">
|
||
<span class="arrow">▶</span>
|
||
<span class="title" id="np-title">—</span>
|
||
<span class="time" id="np-time">[--:-- / --:--]</span>
|
||
</div>
|
||
</div>
|
||
</footer>
|
||
|
||
<canvas id="static"></canvas>
|
||
<div class="scanlines"></div>
|
||
|
||
<!-- Manifest from Project Loading. Same loading.js the Project Loading scene
|
||
uses, so the Game scene picks up the game / subtitle / camera / mic the
|
||
streamer already entered. -->
|
||
<script src="../loading/loading.js"></script>
|
||
<script src="../vendor/obs-config.js"></script>
|
||
<script src="../vendor/obs-ws-mini.js"></script>
|
||
|
||
<script>
|
||
'use strict';
|
||
const pad = n => String(n).padStart(2, '0');
|
||
|
||
// ── UTC clock ──
|
||
const clockEl = document.getElementById('clock');
|
||
function tickClock() {
|
||
const d = new Date();
|
||
clockEl.textContent =
|
||
`${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
|
||
}
|
||
tickClock(); setInterval(tickClock, 1000);
|
||
|
||
// ── Signal bar fluctuation ──
|
||
const sigEl = document.getElementById('signal');
|
||
setInterval(() => {
|
||
const r = Math.random();
|
||
const n = r < 0.06 ? 2 : r < 0.70 ? 3 : r < 0.95 ? 4 : 5;
|
||
sigEl.textContent = '▮'.repeat(n) + '▯'.repeat(5 - n);
|
||
}, 1500);
|
||
|
||
// ── Manifest ──
|
||
const m = window.__LOADING || {};
|
||
const gameName = (m.game || '—').toUpperCase();
|
||
const subtitle = m.subtitle ? m.subtitle.toUpperCase() : null;
|
||
const cameraOn = m.camera ?? true;
|
||
const micOn = m.microphone ?? true;
|
||
|
||
document.getElementById('game-name').textContent = gameName;
|
||
if (subtitle) {
|
||
document.getElementById('game-mode').textContent = subtitle;
|
||
} else {
|
||
document.getElementById('game-mode-sep').style.display = 'none';
|
||
document.getElementById('game-mode').style.display = 'none';
|
||
}
|
||
document.getElementById('mic-state').textContent = micOn ? 'ON' : 'MUTED';
|
||
document.getElementById('mic-state').style.color = micOn ? 'var(--term-fg)' : 'var(--accent)';
|
||
if (!cameraOn) document.getElementById('cam').classList.add('off');
|
||
|
||
// ── Stream elapsed time (since page load / scene activation) ──
|
||
const streamStart = Date.now();
|
||
function tickElapsed() {
|
||
const s = Math.floor((Date.now() - streamStart) / 1000);
|
||
const h = Math.floor(s / 3600);
|
||
const mm = Math.floor((s / 60) % 60);
|
||
const ss = s % 60;
|
||
document.getElementById('elapsed').textContent = `${pad(h)}:${pad(mm)}:${pad(ss)}`;
|
||
}
|
||
tickElapsed(); setInterval(tickElapsed, 1000);
|
||
|
||
// ── Static noise (very subtle; gameplay must remain readable) ──
|
||
const cv = document.getElementById('static'), ctx = cv.getContext('2d');
|
||
const W = 320, H = 180;
|
||
cv.width = W; cv.height = H;
|
||
const img = ctx.createImageData(W, H);
|
||
function drawNoise() {
|
||
const d = img.data;
|
||
for (let i = 0; i < d.length; i += 4) {
|
||
const v = (Math.random() * 255) | 0;
|
||
d[i] = d[i+1] = d[i+2] = v;
|
||
d[i+3] = 255;
|
||
}
|
||
ctx.putImageData(img, 0, 0);
|
||
}
|
||
drawNoise();
|
||
setInterval(drawNoise, 1000 / 8);
|
||
|
||
// ── OBS WS: music sync + live camera visibility ──
|
||
// URL params let you rename the camera source / scene without editing JS:
|
||
// ?camera=Webcam&scene=Game
|
||
const params = new URLSearchParams(location.search);
|
||
const CAM_SOURCE = params.get('camera') || 'Camera';
|
||
const CAM_SCENE = params.get('scene') || 'Game';
|
||
|
||
function fmtTime(s) {
|
||
if (!isFinite(s) || s < 0) return '--:--';
|
||
return `${pad(Math.floor(s / 60))}:${pad(Math.floor(s % 60))}`;
|
||
}
|
||
const npBlock = document.getElementById('np');
|
||
const npTitle = document.getElementById('np-title');
|
||
const npTime = document.getElementById('np-time');
|
||
const camEl = document.getElementById('cam');
|
||
function setCameraVisible(visible) { camEl.classList.toggle('off', !visible); }
|
||
|
||
async function syncCamera(obs) {
|
||
let camItemId = null;
|
||
try {
|
||
const r = await obs.call('GetSceneItemId',
|
||
{ sceneName: CAM_SCENE, sourceName: CAM_SOURCE });
|
||
camItemId = r.sceneItemId;
|
||
const e = await obs.call('GetSceneItemEnabled',
|
||
{ sceneName: CAM_SCENE, sceneItemId: camItemId });
|
||
setCameraVisible(e.sceneItemEnabled);
|
||
} catch (err) {
|
||
// Source not found — keep manifest value, don't block other features.
|
||
console.warn(`[game] camera sync init failed (${CAM_SCENE}/${CAM_SOURCE}):`, err.message);
|
||
return;
|
||
}
|
||
obs.addEventListener('event', (ev) => {
|
||
const d = ev.detail || {};
|
||
if (d.eventType === 'SceneItemEnableStateChanged'
|
||
&& d.eventData?.sceneName === CAM_SCENE
|
||
&& d.eventData?.sceneItemId === camItemId) {
|
||
setCameraVisible(d.eventData.sceneItemEnabled);
|
||
}
|
||
});
|
||
}
|
||
|
||
async function connectOBS() {
|
||
if (!window.__OBSWS || !window.OBSWSMini) return;
|
||
try {
|
||
const obs = new OBSWSMini(window.__OBSWS.url, window.__OBSWS.password);
|
||
await obs.connect();
|
||
obs.addEventListener('close', () => {
|
||
npBlock.classList.remove('on');
|
||
setTimeout(connectOBS, 2500);
|
||
});
|
||
|
||
// Music sync
|
||
obs.onCustom('mpd:state', (s) => {
|
||
if (!s.title) return;
|
||
npBlock.classList.add('on');
|
||
npTitle.textContent = s.title;
|
||
npTime.textContent = `[${fmtTime(s.currentTime)} / ${fmtTime(s.duration)}]`;
|
||
});
|
||
|
||
// Live camera visibility
|
||
await syncCamera(obs);
|
||
} catch (e) {
|
||
// OBS WS optional — silently retry; meanwhile manifest values stand.
|
||
setTimeout(connectOBS, 2500);
|
||
}
|
||
}
|
||
connectOBS();
|
||
</script>
|
||
</body>
|
||
</html>
|