Convert Twitch controls to video overlay
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
let state=null,session=null,userId=null,lastSequence=0,activeSocket=null,extensionIdentityPending=false
|
||||
const extensionMode=location.pathname==='/extension';document.body.classList.toggle('extension-mode',extensionMode)
|
||||
const extensionMode=location.pathname==='/extension',overlayParams=new window.URLSearchParams(location.search),localOverlayHost=['localhost','127.0.0.1','[::1]'].includes(location.hostname),overlayDevMode=extensionMode&&localOverlayHost&&overlayParams.get('dev')==='1';document.documentElement.classList.toggle('extension-mode',extensionMode);document.documentElement.classList.toggle('overlay-debug',extensionMode&&localOverlayHost&&overlayParams.get('debug')==='1');document.documentElement.classList.toggle('overlay-dev',overlayDevMode)
|
||||
const $=s=>document.querySelector(s), $$=s=>document.querySelectorAll(s)
|
||||
const escapeHtml=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))
|
||||
function disabledReason(){if(extensionIdentityPending)return 'Share your Twitch identity to enable controls.';if(!userId)return extensionMode?'Waiting for Twitch authorization.':'Broadcast mode — log in locally to test controls.';const p=state?.players.find(x=>x.twitchUserId===userId);if(!p)return extensionMode?'Click Spawn character to join.':'Type !spawn in chat first.';if(p.lifeState==='dead')return 'Your character is dead.';if(state.phase.kind!=='player')return 'Wait for Player Phase.';if(!p.ap)return 'No AP remains this phase.';return ''}
|
||||
@@ -9,7 +9,7 @@ function render(){if(!state)return;$('#floor').textContent=`Floor ${state.floorN
|
||||
const map=$('#map');map.style.gridTemplateColumns=`repeat(${state.floor.width},auto)`;map.innerHTML=''
|
||||
for(let y=0;y<state.floor.height;y++)for(let x=0;x<state.floor.width;x++){const el=document.createElement('div'),tile=state.floor.tiles[y][x];el.className=`tile ${tile}`;if(tile==='exit')el.textContent='▣';const p=state.players.find(p=>p.lifeState==='alive'&&p.position.x===x&&p.position.y===y);const gob=state.goblin.mode!=='dead'&&state.goblin.position.x===x&&state.goblin.position.y===y;if(gob||p){const e=document.createElement('span');e.className=`entity ${gob?'goblin':'player'}`;e.textContent=gob?'◆':'●';e.title=gob?'Goblin':p.displayName;el.append(e)}map.append(el)}
|
||||
$('#log').innerHTML=state.actionLog.slice(-40).map(e=>`<li><small>#${e.sequence}</small> ${escapeHtml(e.message)}</li>`).join('');$('#log').scrollTop=$('#log').scrollHeight
|
||||
const reason=disabledReason();$('#disabled').textContent=reason;$$('[data-command]').forEach(b=>b.disabled=Boolean(reason));const spawnButton=$('#spawnExtension');spawnButton.hidden=!extensionMode;spawnButton.disabled=!session||Boolean(me);spawnButton.textContent=me?'Character spawned':'Spawn character'
|
||||
const reason=disabledReason();$('#disabled').textContent=reason;$$('[data-command]').forEach(b=>b.disabled=Boolean(reason));const spawnButton=$('#spawnExtension');spawnButton.hidden=!extensionMode||overlayDevMode;spawnButton.disabled=!session||Boolean(me);spawnButton.textContent=me?'Character spawned':'Spawn character'
|
||||
}
|
||||
async function api(path,options={}){const res=await fetch(path,{...options,headers:{'content-type':'application/json',...(session?{authorization:`Bearer ${session}`}:{})}});const data=await res.json();if(!res.ok)throw new Error(data.message||data.error||data.reason||'Request failed');return data}
|
||||
async function authorizeExtension(token){const auth=await api('/api/extension/session',{method:'POST',body:JSON.stringify({token})});session=auth.token;userId=auth.twitchUserId;state=auth.state;if(activeSocket?.readyState===WebSocket.OPEN)activeSocket.send(JSON.stringify({type:'authenticate',token:session}));render()}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Twungeon</title><link rel="stylesheet" href="/styles.css"><style>body.extension-mode{background:transparent}body.extension-mode .shell{height:100vh;min-height:0;padding:0;display:block}body.extension-mode .status{height:100%;padding:16px;gap:14px;background:#111315ee}body.extension-mode .game,body.extension-mode .log,body.extension-mode details{display:none}body.extension-mode h1{font-size:22px}body.extension-mode #status{grid-template-columns:repeat(3,1fr)}</style></head>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Twungeon</title><link rel="stylesheet" href="/styles.css"></head>
|
||||
<body><main class="shell">
|
||||
<aside class="panel status"><div><p class="eyebrow">Twitch plays together</p><h1>TWUNG<span>EON</span></h1></div><div id="status"></div>
|
||||
<section id="controller"><h2>Controller</h2><button id="spawnExtension" hidden>Spawn character</button><div class="dpad"><button data-command="up">▲</button><button data-command="left">◀</button><button data-command="down">▼</button><button data-command="right">▶</button></div><div class="actions"><button data-command="attack">Attack</button><button data-command="heal">Heal</button><button data-command="pass">Pass</button></div><button id="shareIdentity" hidden>Share Twitch identity</button><p id="disabled"></p></section>
|
||||
<aside class="panel status" id="controlRegion"><div class="branding"><p class="eyebrow">Twitch plays together</p><h1>TWUNG<span>EON</span></h1></div><div id="status"></div>
|
||||
<section id="controller"><h2>Controller</h2><button id="spawnExtension" hidden>Spawn character</button><div class="dpad"><button data-command="up" aria-label="Move up">▲</button><button data-command="left" aria-label="Move left">◀</button><button data-command="down" aria-label="Move down">▼</button><button data-command="right" aria-label="Move right">▶</button></div><div class="actions"><button data-command="attack">Attack</button><button data-command="heal">Heal</button><button data-command="pass">Pass</button></div><button id="shareIdentity" hidden>Share Twitch identity</button><p id="disabled" role="status"></p></section>
|
||||
<details><summary>Local viewer login</summary><label>User ID <input id="userId" value="viewer-1"></label><label>Name <input id="displayName" value="Viewer One"></label><button id="spawn">Spawn & bind</button><p><a href="/privacy.html" target="_blank" rel="noopener">Privacy notice</a></p></details>
|
||||
</aside>
|
||||
<section class="panel game"><div class="game-head"><div><p class="eyebrow">Shared dungeon</p><h2 id="floor">Floor 1</h2></div><div id="phase" class="phase"></div></div><div id="banner" hidden>Type !spawn to spawn in the Twungeon!</div><div id="map" aria-label="Dungeon map"></div><div class="legend"><span>● Adventurer</span><span>◆ Goblin</span><span>▣ Exit</span></div></section>
|
||||
<section class="panel log"><div class="log-head"><div><p class="eyebrow">Chronicle</p><h2>Action log</h2></div><span id="connection">Connecting…</span></div><ol id="log"></ol></section>
|
||||
</main><script src="https://extension-files.twitch.tv/helper/v1/twitch-ext.min.js"></script><script type="module" src="/app.js?v=20260817-spawn"></script></body></html>
|
||||
</main><script src="https://extension-files.twitch.tv/helper/v1/twitch-ext.min.js"></script><script type="module" src="/app.js?v=20260817-overlay"></script></body></html>
|
||||
|
||||
@@ -1 +1,112 @@
|
||||
:root{color-scheme:dark;--ink:#f5ecd8;--muted:#b8ad98;--gold:#e8b04b;--red:#db604c;--panel:#171a1d;--line:#34383b;--floor:#3a3832;--wall:#111315}*{box-sizing:border-box}body{margin:0;background:#0d0f10;color:var(--ink);font:15px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace;background-image:radial-gradient(#252017 1px,transparent 1px);background-size:20px 20px}.shell{height:100vh;min-height:650px;padding:18px;display:grid;grid-template-columns:minmax(260px,28%) 1fr;grid-template-rows:minmax(0,1fr) 31%;gap:14px}.panel{background:linear-gradient(145deg,#191c1f,#111315);border:1px solid var(--line);box-shadow:0 18px 60px #0008,inset 0 1px #ffffff0d;border-radius:5px}.status{padding:22px;display:flex;flex-direction:column;gap:20px;overflow:auto}.game{padding:20px;display:flex;flex-direction:column;min-width:0;position:relative}.log{grid-column:1/-1;padding:15px 20px;overflow:hidden;display:flex;flex-direction:column}h1,h2,p{margin:0}h1{font:800 31px/1 system-ui;letter-spacing:.08em}h1 span{color:var(--gold)}h2{font:700 18px/1.2 system-ui}.eyebrow{text-transform:uppercase;color:var(--gold);font-size:11px;letter-spacing:.16em;margin-bottom:6px}.game-head,.log-head{display:flex;align-items:center;justify-content:space-between}.phase{padding:6px 10px;border:1px solid var(--gold);color:var(--gold);border-radius:2px;text-transform:uppercase;font-size:12px}#banner{position:absolute;z-index:3;inset:45% auto auto 50%;transform:translate(-50%,-50%);padding:16px 22px;background:#111e;border:1px solid var(--gold);box-shadow:0 0 40px #000;text-align:center;color:var(--gold);font-weight:bold;white-space:nowrap}#map{flex:1;display:grid;align-content:center;justify-content:center;margin:12px 0;min-height:0}.tile{width:min(3.6vw,42px);aspect-ratio:1;border:1px solid #222;display:grid;place-items:center;position:relative;font-size:min(1.6vw,18px)}.tile.wall{background:var(--wall);box-shadow:inset 0 0 0 2px #1b1f21}.tile.floor{background:var(--floor)}.tile.exit{background:#68491b;color:#ffd77e}.entity{position:absolute;inset:12%;border-radius:50%;display:grid;place-items:center;font-weight:bold;text-shadow:0 1px 2px #000}.entity.player{background:#4f8cc9;border:2px solid #b9deff}.entity.goblin{background:var(--red);border:2px solid #ffb2a6;border-radius:20%}.legend{display:flex;gap:18px;justify-content:center;color:var(--muted);font-size:12px}#status{display:grid;grid-template-columns:1fr 1fr;gap:8px}.stat{border:1px solid var(--line);padding:9px}.stat b{display:block;color:var(--gold);font-size:18px}.dpad{display:grid;grid-template-columns:repeat(3,42px);gap:5px;margin:10px 0}.dpad button:nth-child(1){grid-column:2}.dpad button:nth-child(2){grid-column:1}.actions{display:flex;gap:5px;flex-wrap:wrap}button,input{font:inherit}button{background:#292d2f;color:var(--ink);border:1px solid #53595c;padding:8px 10px;cursor:pointer}button:hover:not(:disabled){border-color:var(--gold);color:var(--gold)}button:disabled{opacity:.35;cursor:not-allowed}#disabled{color:var(--muted);font-size:12px;margin-top:8px}details{margin-top:auto;color:var(--muted)}details label{display:block;margin:8px 0}input{display:block;width:100%;background:#0d0f10;border:1px solid var(--line);color:var(--ink);padding:6px}#log{margin:8px 0 0;padding:0;list-style:none;overflow:auto;display:flex;flex-direction:column;gap:3px}#log li{padding:4px 8px;border-left:2px solid var(--line);color:var(--muted)}#log li:last-child{color:var(--ink);border-color:var(--gold)}#connection{font-size:12px;color:var(--muted)}@media(max-width:760px){.shell{height:auto;grid-template-columns:1fr;grid-template-rows:auto minmax(500px,70vh) 360px}.log{grid-column:1}.tile{width:min(5vw,28px)}#banner{white-space:normal;width:75%}}
|
||||
:root {
|
||||
--ink: #f5ecd8;
|
||||
--muted: #b8ad98;
|
||||
--gold: #e8b04b;
|
||||
--red: #db604c;
|
||||
--panel: #171a1d;
|
||||
--line: #34383b;
|
||||
--floor: #3a3832;
|
||||
--wall: #111315;
|
||||
|
||||
/* Normalized to the matching control panel in the 16:9 broadcast layout. */
|
||||
--controls-left: 0.95%;
|
||||
--controls-top: 1.7%;
|
||||
--controls-width: 27.1%;
|
||||
--controls-height: 65.8%;
|
||||
--controls-padding: clamp(8px, 1.15vw, 22px);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html:not(.extension-mode) { color-scheme: dark; }
|
||||
html.extension-mode { color-scheme: normal; }
|
||||
body { margin: 0; background: #0d0f10; color: var(--ink); font: 15px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; background-image: radial-gradient(#252017 1px, transparent 1px); background-size: 20px 20px; }
|
||||
.shell { height: 100vh; min-height: 650px; padding: 18px; display: grid; grid-template-columns: minmax(260px, 28%) 1fr; grid-template-rows: minmax(0, 1fr) 31%; gap: 14px; }
|
||||
.panel { background: linear-gradient(145deg, #191c1f, #111315); border: 1px solid var(--line); box-shadow: 0 18px 60px #0008, inset 0 1px #ffffff0d; border-radius: 5px; }
|
||||
.status { padding: 22px; display: flex; flex-direction: column; gap: 20px; overflow: auto; }
|
||||
.game { padding: 20px; display: flex; flex-direction: column; min-width: 0; position: relative; }
|
||||
.log { grid-column: 1/-1; padding: 15px 20px; overflow: hidden; display: flex; flex-direction: column; }
|
||||
h1, h2, p { margin: 0; }
|
||||
h1 { font: 800 31px/1 system-ui; letter-spacing: .08em; }
|
||||
h1 span { color: var(--gold); }
|
||||
h2 { font: 700 18px/1.2 system-ui; }
|
||||
.eyebrow { text-transform: uppercase; color: var(--gold); font-size: 11px; letter-spacing: .16em; margin-bottom: 6px; }
|
||||
.game-head, .log-head { display: flex; align-items: center; justify-content: space-between; }
|
||||
.phase { padding: 6px 10px; border: 1px solid var(--gold); color: var(--gold); border-radius: 2px; text-transform: uppercase; font-size: 12px; }
|
||||
#banner { position: absolute; z-index: 3; inset: 45% auto auto 50%; transform: translate(-50%, -50%); padding: 16px 22px; background: #111e; border: 1px solid var(--gold); box-shadow: 0 0 40px #000; text-align: center; color: var(--gold); font-weight: bold; white-space: nowrap; }
|
||||
#map { flex: 1; display: grid; align-content: center; justify-content: center; margin: 12px 0; min-height: 0; }
|
||||
.tile { width: min(3.6vw, 42px); aspect-ratio: 1; border: 1px solid #222; display: grid; place-items: center; position: relative; font-size: min(1.6vw, 18px); }
|
||||
.tile.wall { background: var(--wall); box-shadow: inset 0 0 0 2px #1b1f21; }
|
||||
.tile.floor { background: var(--floor); }
|
||||
.tile.exit { background: #68491b; color: #ffd77e; }
|
||||
.entity { position: absolute; inset: 12%; border-radius: 50%; display: grid; place-items: center; font-weight: bold; text-shadow: 0 1px 2px #000; }
|
||||
.entity.player { background: #4f8cc9; border: 2px solid #b9deff; }
|
||||
.entity.goblin { background: var(--red); border: 2px solid #ffb2a6; border-radius: 20%; }
|
||||
.legend { display: flex; gap: 18px; justify-content: center; color: var(--muted); font-size: 12px; }
|
||||
#status { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.stat { border: 1px solid var(--line); padding: 9px; }
|
||||
.stat b { display: block; color: var(--gold); font-size: 18px; }
|
||||
.dpad { display: grid; grid-template-columns: repeat(3, 42px); gap: 5px; margin: 10px 0; }
|
||||
.dpad button:nth-child(1) { grid-column: 2; }
|
||||
.dpad button:nth-child(2) { grid-column: 1; }
|
||||
.actions { display: flex; gap: 5px; flex-wrap: wrap; }
|
||||
button, input { font: inherit; }
|
||||
button { background: #292d2f; color: var(--ink); border: 1px solid #53595c; padding: 8px 10px; cursor: pointer; }
|
||||
button:hover:not(:disabled) { border-color: var(--gold); color: var(--gold); }
|
||||
button:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }
|
||||
button:disabled { opacity: .35; cursor: not-allowed; }
|
||||
#disabled { color: var(--muted); font-size: 12px; margin-top: 8px; }
|
||||
details { margin-top: auto; color: var(--muted); }
|
||||
details label { display: block; margin: 8px 0; }
|
||||
input { display: block; width: 100%; background: #0d0f10; border: 1px solid var(--line); color: var(--ink); padding: 6px; }
|
||||
#log { margin: 8px 0 0; padding: 0; list-style: none; overflow: auto; display: flex; flex-direction: column; gap: 3px; }
|
||||
#log li { padding: 4px 8px; border-left: 2px solid var(--line); color: var(--muted); }
|
||||
#log li:last-child { color: var(--ink); border-color: var(--gold); }
|
||||
#connection { font-size: 12px; color: var(--muted); }
|
||||
|
||||
/* Twitch Video Overlay mode: the stream supplies every noninteractive surface. */
|
||||
html.extension-mode, html.extension-mode body { width: 100%; height: 100%; overflow: hidden; background: transparent; background-image: none; pointer-events: none; }
|
||||
html.extension-mode .shell { position: fixed; inset: 0; display: block; min-height: 0; padding: 0; pointer-events: none; }
|
||||
html.extension-mode .game, html.extension-mode .log, html.extension-mode .branding, html.extension-mode #status, html.extension-mode details { display: none; }
|
||||
html.extension-mode .status {
|
||||
position: absolute;
|
||||
left: var(--controls-left);
|
||||
top: var(--controls-top);
|
||||
width: var(--controls-width);
|
||||
height: var(--controls-height);
|
||||
padding: var(--controls-padding);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
container: overlay-controls / size;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
html.extension-mode #controller { width: min(100%, 18rem); pointer-events: none; }
|
||||
html.extension-mode #controller h2 { font-size: clamp(11px, 5cqw, 18px); margin-bottom: clamp(4px, 2cqh, 12px); text-shadow: 0 1px 3px #000; }
|
||||
html.extension-mode #controller button { pointer-events: auto; touch-action: manipulation; }
|
||||
html.extension-mode .dpad { grid-template-columns: repeat(3, minmax(28px, 1fr)); gap: clamp(3px, 1.8cqw, 7px); margin: 0 0 clamp(6px, 2.5cqh, 14px); }
|
||||
html.extension-mode .dpad button { aspect-ratio: 1; padding: 0; font-size: clamp(13px, 6cqw, 24px); }
|
||||
html.extension-mode .actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: clamp(3px, 1.8cqw, 7px); }
|
||||
html.extension-mode .actions button { padding: clamp(5px, 1.7cqh, 10px) clamp(2px, 1cqw, 8px); font-size: clamp(9px, 4.3cqw, 15px); }
|
||||
html.extension-mode #spawnExtension, html.extension-mode #shareIdentity { width: 100%; margin-bottom: clamp(5px, 2cqh, 12px); font-size: clamp(9px, 4.3cqw, 15px); }
|
||||
html.extension-mode #disabled { min-height: 2.8em; margin-top: clamp(5px, 2cqh, 10px); color: var(--ink); font-size: clamp(9px, 3.7cqw, 13px); text-shadow: 0 1px 3px #000, 0 0 8px #000; }
|
||||
|
||||
/* Opt-in local diagnostics. These classes are never enabled by production URLs. */
|
||||
html.extension-mode.overlay-debug .status { outline: 2px dashed #00e5ff; outline-offset: -2px; background: #00e5ff0d; }
|
||||
html.extension-mode.overlay-debug #controller { outline: 1px dashed #ff4fd8; }
|
||||
html.extension-mode.overlay-dev details { display: block; width: 100%; margin-top: clamp(8px, 3cqh, 16px); pointer-events: auto; font-size: clamp(9px, 3.7cqw, 13px); }
|
||||
html.extension-mode.overlay-dev .status { place-items: start center; overflow: auto; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
html:not(.extension-mode) .shell { height: auto; grid-template-columns: 1fr; grid-template-rows: auto minmax(500px, 70vh) 360px; }
|
||||
html:not(.extension-mode) .log { grid-column: 1; }
|
||||
html:not(.extension-mode) .tile { width: min(5vw, 28px); }
|
||||
html:not(.extension-mode) #banner { white-space: normal; width: 75%; }
|
||||
}
|
||||
|
||||
@media (max-aspect-ratio: 4/3) {
|
||||
html.extension-mode { --controls-width: 31%; --controls-height: 62%; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user