Game scene: HUD + camera frame + status bar with live OBS sync

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).
This commit is contained in:
Jakub Zych
2026-04-26 02:40:24 +02:00
parent 0a73f7e254
commit 3792d61fdb

433
game/index.html Normal file
View File

@@ -0,0 +1,433 @@
<!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>