Refine party, timer, and Extension mechanics
This commit is contained in:
@@ -2,14 +2,18 @@ let state=null,session=null,userId=null,lastSequence=0,activeSocket=null,extensi
|
||||
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 ''}
|
||||
const playerColors={blue:'#3f8cff',green:'#38c976',red:'#ef5350',yellow:'#f2c94c'},colorNames={blue:'Blue',green:'Green',red:'Red',yellow:'Yellow'}
|
||||
function setMobileLayout(context={}){const platform=String(context.platform??context.mode??'').toLowerCase(),mobile=platform.includes('mobile')||window.matchMedia('(max-width: 600px)').matches;document.documentElement.classList.toggle('extension-mobile',extensionMode&&mobile)}
|
||||
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 state?.players.length>=4?'Party full for this level. Spectating until the next level.':extensionMode?'Click Spawn character to join.':'Activate the Extension to spawn.';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 ''}
|
||||
function render(){if(!state)return;$('#floor').textContent=`Floor ${state.floorNumber}`;$('#phase').textContent=state.phase.kind;$('#banner').hidden=state.phase.kind!=='dormant'
|
||||
const me=state.players.find(p=>p.twitchUserId===userId),seconds=state.phase.kind==='player'?Math.max(0,Math.ceil((state.phase.deadlineAt-Date.now())/1000)):'—'
|
||||
$('#status').innerHTML=[['Players',state.players.filter(p=>p.lifeState==='alive').length],['Timer',seconds],['HP',me?`${me.hp}/3`:'—'],['AP',me?.ap??'—'],['Guard',me?.guard??'—'],['Heal',me?(me.healAvailable?'Ready':'Used'):'—']].map(([k,v])=>`<div class="stat">${k}<b>${v}</b></div>`).join('')
|
||||
$('#status').innerHTML=[['Party',`${state.players.length}/4`],['Living',state.players.filter(p=>p.lifeState==='alive').length],['Timer',seconds],['HP',me?`${me.hp}/3`:'—'],['AP',me?.ap??'—'],['Guard',me?.guard??'—'],['Heal',me?(me.healAvailable?'Ready':'Used'):'—']].map(([k,v])=>`<div class="stat">${k}<b>${v}</b></div>`).join('')
|
||||
const viewerStatus=$('#viewerStatus');viewerStatus.hidden=!extensionMode||!me;viewerStatus.innerHTML=me?`<div class="viewer-identity"><span class="color-dot" style="--player-color:${playerColors[me.color]}"></span><b>${escapeHtml(me.displayName)}</b><span>Slot ${me.partySlot} · ${colorNames[me.color]}</span></div><div><span>HP</span><b>${me.hp}/3</b></div><div><span>AP</span><b>${me.ap}</b></div><div><span>Heal</span><b>${me.healAvailable?'Ready':'Used'}</b></div>`:''
|
||||
$('#partyLegend').innerHTML=state.players.map(player=>`<span class="party-name" style="--player-color:${playerColors[player.color]}">● ${escapeHtml(player.displayName)}</span>`).join(' ')
|
||||
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||overlayDevMode;spawnButton.disabled=!session||Boolean(me);spawnButton.textContent=me?'Character spawned':'Spawn character'
|
||||
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 players=state.players.filter(p=>p.lifeState==='alive'&&p.position.x===x&&p.position.y===y),gob=state.goblin.mode!=='dead'&&state.goblin.position.x===x&&state.goblin.position.y===y;if(gob){const entity=document.createElement('span');entity.className='entity goblin';entity.textContent='◆';entity.title='Goblin';el.append(entity)}for(const player of players){const entity=document.createElement('span');entity.className=`entity player${players.length>1?` stacked slot-${player.partySlot}`:''}`;entity.textContent='●';entity.title=player.displayName;entity.style.setProperty('--player-color',playerColors[player.color]);const label=document.createElement('span');label.className='entity-name';label.textContent=player.displayName;entity.append(label);el.append(entity)}map.append(el)}
|
||||
$('#log').innerHTML=state.actionLog.slice(-40).map(e=>{const actor=state.players.find(player=>player.twitchUserId===e.actorId),style=actor?` style="--player-color:${playerColors[actor.color]}"`:'';return`<li${style}><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||overlayDevMode;spawnButton.disabled=!session||Boolean(me)||state.players.length>=4;spawnButton.textContent=me?'Character spawned':state.players.length>=4?'Party full':'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()}
|
||||
@@ -20,4 +24,5 @@ $('#spawn').onclick=()=>spawn().catch(e=>$('#disabled').textContent=e.message);$
|
||||
function connect(){const ws=activeSocket=new WebSocket(`${location.protocol==='https:'?'wss':'ws'}://${location.host}/ws`);ws.onopen=()=>{if(session)ws.send(JSON.stringify({type:'authenticate',token:session}));$('#connection').textContent='Live'};ws.onclose=()=>{$('#connection').textContent='Reconnecting…';setTimeout(connect,1000)};ws.onmessage=e=>{const msg=JSON.parse(e.data);if(msg.type!=='snapshot')return;if(lastSequence&&msg.sequence>lastSequence+1){fetch('/api/state').then(r=>r.json()).then(s=>{state=s;lastSequence=s.nextEventSequence-1;render()});return}lastSequence=msg.sequence;state=msg.state;render()}}
|
||||
connect()
|
||||
setInterval(()=>{if(state?.phase.kind==='player')render()},250)
|
||||
if(window.Twitch?.ext)window.Twitch.ext.onAuthorized(auth=>{const linked=Boolean(window.Twitch.ext.viewer?.isLinked);extensionIdentityPending=!linked;$('#shareIdentity').hidden=linked;if(!linked){render();return}authorizeExtension(auth.token).catch(e=>$('#disabled').textContent=e.message)})
|
||||
setMobileLayout();window.matchMedia('(max-width: 600px)').addEventListener?.('change',()=>setMobileLayout())
|
||||
if(window.Twitch?.ext){window.Twitch.ext.onContext?.(context=>setMobileLayout(context));window.Twitch.ext.onAuthorized(auth=>{const linked=Boolean(window.Twitch.ext.viewer?.isLinked);extensionIdentityPending=!linked;$('#shareIdentity').hidden=linked;if(!linked){render();return}authorizeExtension(auth.token).catch(e=>$('#disabled').textContent=e.message)})}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<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" 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>
|
||||
<section id="controller"><h2>Controller</h2><div id="viewerStatus" hidden></div><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>Activate the Extension to spawn your character in the Twungeon!<span class="banner-note">Must be a follower to spawn.</span></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 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>Activate the Extension to spawn your character in the Twungeon!<span class="banner-note">Must be a follower to spawn.</span></div><div id="map" aria-label="Dungeon map"></div><div class="legend"><span id="partyLegend"></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-overlay"></script></body></html>
|
||||
|
||||
@@ -40,9 +40,16 @@ h2 { font: 700 18px/1.2 system-ui; }
|
||||
.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.player { background: var(--player-color, #3f8cff); border: 2px solid #fff; color: #111; }
|
||||
.entity.player.stacked { inset: auto; width: 40%; height: 40%; }
|
||||
.entity.player.stacked.slot-1 { left: 6%; top: 6%; }
|
||||
.entity.player.stacked.slot-2 { right: 6%; top: 6%; }
|
||||
.entity.player.stacked.slot-3 { left: 6%; bottom: 6%; }
|
||||
.entity.player.stacked.slot-4 { right: 6%; bottom: 6%; }
|
||||
.entity-name { position: absolute; top: 100%; left: 50%; z-index: 2; transform: translate(-50%, 2px); padding: 1px 3px; border-radius: 2px; background: #000c; color: var(--player-color, #fff); font-size: 9px; line-height: 1.1; white-space: nowrap; text-shadow: 0 1px 2px #000; }
|
||||
.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; }
|
||||
.party-name { color: var(--player-color); white-space: nowrap; }
|
||||
#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; }
|
||||
@@ -60,7 +67,7 @@ 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 { padding: 4px 8px; border-left: 2px solid var(--player-color, var(--line)); color: var(--muted); }
|
||||
#log li:last-child { color: var(--ink); border-color: var(--gold); }
|
||||
#connection { font-size: 12px; color: var(--muted); }
|
||||
|
||||
@@ -87,6 +94,13 @@ html.extension-mode .status {
|
||||
}
|
||||
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 #viewerStatus { display: grid; grid-template-columns: repeat(3, 1fr); gap: clamp(3px, 1.4cqw, 7px); margin-bottom: clamp(5px, 2cqh, 10px); color: var(--ink); text-shadow: 0 1px 3px #000; }
|
||||
html.extension-mode #viewerStatus[hidden] { display: none; }
|
||||
html.extension-mode #viewerStatus > div { padding: clamp(3px, 1cqw, 6px); background: #080a0bd9; border: 1px solid #ffffff2b; font-size: clamp(8px, 3.3cqw, 12px); }
|
||||
html.extension-mode #viewerStatus > div > span, html.extension-mode #viewerStatus > div > b { display: block; }
|
||||
html.extension-mode #viewerStatus .viewer-identity { grid-column: 1/-1; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 6px; }
|
||||
html.extension-mode #viewerStatus .viewer-identity > span, html.extension-mode #viewerStatus .viewer-identity > b { display: initial; }
|
||||
.color-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--player-color); border: 1px solid #fff; }
|
||||
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); }
|
||||
@@ -108,6 +122,25 @@ html.extension-mode.overlay-dev .status { place-items: start center; overflow: a
|
||||
html:not(.extension-mode) #banner { white-space: normal; width: 75%; }
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
html.extension-mode .status { left: 3%; top: 3%; width: 94%; height: 94%; padding: 12px; place-items: center; overflow: auto; }
|
||||
html.extension-mode #controller { width: min(100%, 24rem); }
|
||||
html.extension-mode #controller h2 { font-size: 17px; }
|
||||
html.extension-mode #viewerStatus { gap: 7px; margin-bottom: 10px; }
|
||||
html.extension-mode #viewerStatus > div { min-height: 44px; padding: 7px 9px; font-size: 13px; }
|
||||
html.extension-mode .dpad { grid-template-columns: repeat(3, minmax(56px, 1fr)); gap: 8px; margin-bottom: 10px; }
|
||||
html.extension-mode .dpad button { min-height: 56px; font-size: 24px; }
|
||||
html.extension-mode .actions { gap: 8px; }
|
||||
html.extension-mode .actions button, html.extension-mode #spawnExtension, html.extension-mode #shareIdentity { min-height: 52px; padding: 10px 6px; font-size: 15px; }
|
||||
html.extension-mode #disabled { font-size: 13px; }
|
||||
}
|
||||
|
||||
html.extension-mode.extension-mobile .status { left: 3%; top: 3%; width: 94%; height: 94%; padding: 12px; place-items: center; overflow: auto; }
|
||||
html.extension-mode.extension-mobile #controller { width: min(100%, 24rem); }
|
||||
html.extension-mode.extension-mobile .dpad { grid-template-columns: repeat(3, minmax(56px, 1fr)); gap: 8px; }
|
||||
html.extension-mode.extension-mobile .dpad button { min-height: 56px; font-size: 24px; }
|
||||
html.extension-mode.extension-mobile .actions button, html.extension-mode.extension-mobile #spawnExtension, html.extension-mode.extension-mobile #shareIdentity { min-height: 52px; font-size: 15px; }
|
||||
|
||||
@media (max-aspect-ratio: 4/3) {
|
||||
html.extension-mode { --controls-width: 31%; --controls-height: 62%; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user