Files
room_glitch/setup/pi/tools/room_controller/operator.html
T

29 lines
11 KiB
HTML

<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Arcade · Room operator</title>
<style>
:root{color-scheme:dark;font:16px system-ui,sans-serif;background:#0b111a;color:#e3ebf5}*{box-sizing:border-box}body{margin:0}header{padding:24px 4vw;border-bottom:1px solid #293749;display:flex;align-items:center;justify-content:space-between;gap:20px}h1{font-size:24px;margin:0}h2{font-size:18px;margin:0 0 16px}p{line-height:1.5}small,.muted{color:#9eb0c6}main{max-width:1500px;margin:auto;padding:28px 4vw}.panel{background:#121c29;border:1px solid #293749;border-radius:14px;padding:22px;margin-bottom:20px}.row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.columns{display:grid;grid-template-columns:1.25fr 1fr;gap:22px}button,input,select{font:inherit;border-radius:7px;border:1px solid #45576c;padding:12px;background:#1c2a3b;color:inherit;min-height:46px}button{cursor:pointer}button.primary{background:#69ddcd;color:#092923;border-color:#69ddcd;font-weight:650}button:disabled{opacity:.4;cursor:default}button:focus-visible,select:focus-visible,input:focus-visible{outline:3px solid #69ddcd;outline-offset:3px}select{max-width:100%}#login{max-width:550px;margin:8vh auto}#login input{width:100%;margin:10px 0}#status{color:#69ddcd}#error{color:#ffce96;white-space:pre-wrap}#error:empty{display:none}#cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:12px;margin-top:16px}.card{text-align:left;padding:16px;background:#152335}.card[aria-pressed=true]{border-color:#69ddcd;box-shadow:inset 0 0 0 1px #69ddcd}.card span{display:block;margin-top:9px;font-size:14px}svg{width:100%;height:340px;background:#0b131e;border-radius:8px}.steps{display:flex;gap:6px;flex-wrap:wrap;margin:18px 0}.steps span{padding:8px 10px;border-radius:5px;background:#223043;font-size:13px}.steps .current{background:#69ddcd;color:#092923}table{width:100%;border-collapse:collapse;font-size:14px}td,th{text-align:left;padding:10px 6px;border-bottom:1px solid #293749;overflow-wrap:anywhere}#events{max-height:270px;overflow:auto;padding-left:22px;font-size:14px}#events li{padding:6px}#assignment{white-space:pre-line}#scenarioSummary{font-size:14px;color:#b9c8d9}#output{overflow-x:auto}#signout{font-size:14px} [hidden]{display:none!important}@media(max-width:850px){.columns{grid-template-columns:1fr}header{align-items:flex-start}main{padding:18px}svg{height:280px}}
</style></head><body>
<header><div><small>ARCADE ESCAPE / OPERATIONS</small><h1>Room control</h1></div><div class="row"><span id="status" role="status">Not connected</span><button id="signout" hidden>Lock</button></div></header>
<main><div id="error" class="panel" role="alert"></div>
<form id="login" class="panel"><h2>Connect to this room</h2><p class="muted">Use the operator token configured on this controller. It stays in memory until this page closes or locks.</p><label for="token">Operator token</label><input id="token" type="password" autocomplete="off" required><button class="primary">Connect</button></form>
<div id="workspace" hidden>
<section class="panel"><h2>Prepare a scenario</h2><div class="row"><select id="scenario" aria-label="Scenario"></select><button id="prepareScenario" class="primary">Prepare assigned cabinets</button></div><p id="scenarioSummary"></p><small>Preparation loads games and their event-to-hardware mappings. Start each cabinet when ready. Reset active cabinets before changing assignments.</small></section>
<div class="columns"><section class="panel"><h2>Room overview</h2><svg id="map" role="img" aria-label="Room layout and cabinet status"></svg><p class="muted"><small>Cabinet colors show runtime state. Room devices are reference locations; physical sensor feedback is not connected.</small></p><div id="cards"></div></section>
<section class="panel"><h2 id="cabinetTitle">Select a cabinet</h2><p id="connection" class="muted"></p><p id="assignment"></p><div id="steps" class="steps"></div><div class="row"><button id="start" class="primary">Start game</button><button id="reset">Reset cabinet…</button></div><hr style="border:0;border-top:1px solid #293749;margin:24px 0"><h2>Manual game assignment</h2><div class="row"><select id="game" aria-label="Compatible game"></select><button id="prepare">Prepare game</button></div><p><small>Only compatible games are listed. Manual assignments run without scenario hardware rewards.</small></p><h2>Hardware commands</h2><p id="reward" class="muted"></p><div id="output"></div><h2 style="margin-top:24px">Activity</h2><ol id="events"></ol></section></div>
</div></main><script>
'use strict';
const $=id=>document.getElementById(id);let token='',state=null,selected='',pending=false,connected=false,refreshing=false;
const colors={ready:'#69ddcd',playing:'#7bbaff',won:'#b4ec86',lost:'#ffae9e'};
function node(tag,text,cls){const e=document.createElement(tag);if(text!==undefined)e.textContent=text;if(cls)e.className=cls;return e}
async function api(path,data={}){const response=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json',Authorization:'Bearer '+token},body:JSON.stringify(data),signal:AbortSignal.timeout(6000)});const value=await response.json();if(!response.ok)throw Error(value.error||'Controller request failed');return value}
function options(id,entries){const select=$(id),before=select.value;if(select.options.length===entries.length&&entries.every(([value,name],i)=>select.options[i].value===value&&select.options[i].textContent===name))return;select.replaceChildren(...entries.map(([value,name])=>{const o=node('option',name);o.value=value;return o}));if(entries.some(x=>x[0]===before))select.value=before}
function enable(){const c=state?.cabinets[selected],available=connected&&!pending;const queued=c?.commands.some(x=>x.status==='queued');$('start').disabled=!available||!c?.online||c.phase!=='ready'||!!c.gate||queued;$('reset').disabled=!available||!c?.online||['preparing','starting','resetting'].includes(c.phase);$('prepare').disabled=!available||!c?.online||queued||!['idle','won','lost','needs_prepare'].includes(c.phase)||!$('game').value;$('prepareScenario').disabled=!available||!$('scenario').value}
function scenarioSummary(){if(!state)return;const s=state.catalog.scenarios[$('scenario').value];$('scenarioSummary').textContent=s?s.assignments.map(a=>(state.catalog.cabinets[a.cabinet]?.name||a.cabinet)+' → '+(state.catalog.games[a.game]?.name||a.game)).join(' · '):'No scenarios exported'}
function drawMap(){const svg=$('map'),layout=state.catalog.layout,points=[...layout.boundary,...Object.values(state.cabinets).filter(c=>c.definition.position).map(c=>c.definition.position),...layout.walls.flat(),...layout.devices.map(d=>d.position)];svg.replaceChildren();if(!points.length)return;const xs=points.map(p=>p[0]),ys=points.map(p=>p[1]),minX=Math.min(...xs)-1,minY=Math.min(...ys)-1,w=Math.max(...xs)-minX+1,h=Math.max(...ys)-minY+1;svg.setAttribute('viewBox',`${minX} ${minY} ${w} ${h}`);const unit=Math.max(w,h)/18;function shape(tag,attrs,text){const e=document.createElementNS('http://www.w3.org/2000/svg',tag);for(const [k,v]of Object.entries(attrs))e.setAttribute(k,v);if(text)e.textContent=text;svg.append(e);return e}if(layout.boundary.length)shape('polygon',{points:layout.boundary.map(p=>p.join(',')).join(' '),fill:'#152335',stroke:'#435770','stroke-width':unit/14});for(const wall of layout.walls)shape('line',{x1:wall[0][0],y1:wall[0][1],x2:wall[1][0],y2:wall[1][1],stroke:'#647a92','stroke-width':unit/10});for(const d of layout.devices){const e=shape('circle',{cx:d.position[0],cy:d.position[1],r:unit*.18,fill:'#8491a2'});const title=document.createElementNS(svg.namespaceURI,'title');title.textContent=d.name+' — no sensor feedback';e.append(title)}for(const [id,c]of Object.entries(state.cabinets)){const p=c.definition.position;if(!p)continue;const e=shape('circle',{cx:p[0],cy:p[1],r:unit*.5,fill:c.online?colors[c.phase]||'#e5c57b':'#647081',stroke:id===selected?'white':'none','stroke-width':unit/10});e.style.cursor='pointer';e.onclick=()=>{selected=id;render()};shape('text',{x:p[0],y:p[1]-unit*.75,fill:'#e3ebf5','font-size':unit*.6,'text-anchor':'middle'},c.definition.name)}}
function render(){if(!state?.cabinets)throw Error('Update the room controller to use the browser operator');if(!state.cabinets[selected])selected=Object.keys(state.cabinets)[0];options('scenario',Object.entries(state.catalog.scenarios).map(([id,s])=>[id,s.name]));scenarioSummary();$('cards').replaceChildren();for(const[id,c]of Object.entries(state.cabinets)){const b=node('button',c.definition.name,'card');b.setAttribute('aria-pressed',id===selected);b.append(node('span',(c.online?'ONLINE':'OFFLINE')+' · '+c.phase.toUpperCase()));b.append(node('span',state.catalog.games[c.game]?.name||c.game,'muted'));if(!c.definition.position)b.append(node('span','Not placed in room map','muted'));b.onclick=()=>{selected=id;render()};$('cards').append(b)}drawMap();const c=state.cabinets[selected];$('cabinetTitle').textContent=c.definition.name;$('connection').textContent=(c.online?'Online':'Offline')+' · '+c.version+' · GPIO: '+c.gpio;$('assignment').textContent='Game: '+(state.catalog.games[c.game]?.name||c.game)+'\nAssignment: '+(c.scenario||'None prepared')+(c.gate?'\nActivation: after '+c.gate.cabinet+' wins':'');$('steps').replaceChildren(...['idle','waiting','preparing','ready','playing','won','lost'].map(p=>node('span',p.toUpperCase(),c.phase===p?'current':'')));options('game',c.compatible_games.map(id=>[id,state.catalog.games[id].name]));$('reward').textContent='Win output: '+c.reward;const table=node('table'),head=node('tr');for(const name of ['Action','Status'])head.append(node('th',name));table.append(head);for(const command of c.commands.filter(x=>['output','reward'].includes(x.type))){const row=node('tr');row.append(node('td',command.action_id||command.type),node('td',command.status==='accepted'?'Accepted · physical result unconfirmed':command.status));table.append(row)}$('output').replaceChildren(table);$('events').replaceChildren(...c.history.slice().reverse().map(e=>node('li',new Date(e.time*1000).toLocaleTimeString()+' · '+e.message)));enable()}
async function refresh(){if(!token||refreshing||pending)return;refreshing=true;try{state=await api('/operator/state');connected=true;render();$('workspace').hidden=false;$('login').hidden=true;$('signout').hidden=false;$('status').textContent='Controller connected'}catch(e){connected=false;$('status').textContent='Connection unavailable';$('error').textContent=e.message;enable()}finally{refreshing=false}}
async function command(action,extra={}){if(pending||!connected)return;pending=true;enable();$('error').textContent='';try{state=await api('/operator/command',{action,cabinet:selected,...extra});render()}catch(e){$('error').textContent=e.message}finally{pending=false;enable();refresh()}}
$('login').onsubmit=e=>{e.preventDefault();token=$('token').value.trim();$('token').value='';$('error').textContent='';refresh()};$('signout').onclick=()=>location.reload();$('scenario').onchange=scenarioSummary;$('prepareScenario').onclick=()=>command('prepare_scenario',{scenario:$('scenario').value});$('prepare').onclick=()=>command('prepare',{game:$('game').value});$('start').onclick=()=>command('start');$('reset').onclick=()=>{if(confirm('Reset '+state.cabinets[selected].definition.name+'? This ends its current game.'))command('reset')};setInterval(refresh,1000);
</script></body></html>