@ing.jorgeu/wargaming-overlay 2.4.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PUBLISHING.md +13 -20
- package/README.md +244 -4
- package/THIRD_PARTY_ASSETS.md +8 -0
- package/battle_reader.py +50 -7
- package/cli.js +27 -7
- package/device_connection.py +14 -5
- package/game-store.js +26 -3
- package/package.json +25 -6
- package/public/army-list.css +18 -0
- package/public/army-list.html +1 -0
- package/public/army-list.js +59 -0
- package/public/control.html +3 -3
- package/public/control.js +3 -2
- package/public/detachments.css +2 -2
- package/public/live-score.css +3 -3
- package/public/live-score.js +8 -4
- package/public/match-view.js +15 -4
- package/public/roster-view.js +8 -0
- package/public/rosters-control.js +77 -0
- package/public/style.css +8 -0
- package/reader-status.js +19 -0
- package/rosters.js +39 -0
- package/scripts/setup-kokoro.js +2 -0
- package/scripts/tts-demo.js +10 -0
- package/server.js +53 -11
- package/tts/kokoro-assets.js +27 -0
- package/tts/kokoro-engine.js +31 -0
- package/tts/kokoro-worker.js +13 -0
- package/tts/licenses/espeak-ng-COPYING +674 -0
- package/tts/playback.js +28 -0
- package/tts/queue.js +22 -0
- package/tts/sanitizer.js +31 -0
- package/tts/speech.js +72 -0
- package/tts/voice-command.js +14 -0
- package/tts/wav.js +6 -0
- package/tts/windows-playback.js +14 -0
- package/turn-tracker.js +33 -21
- package/youtube/auth.js +71 -0
- package/youtube/chat.proto +29 -0
- package/youtube/index.js +23 -0
- package/youtube/live-broadcast.js +13 -0
- package/youtube/live-chat.js +24 -0
- package/youtube/poll-chat.js +15 -0
- package/youtube/service.js +73 -0
package/package.json
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ing.jorgeu/wargaming-overlay",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Overlay local para OBS con lectura Android por ADB",
|
|
5
5
|
"bin": {
|
|
6
|
-
"wargaming-overlay": "cli.js"
|
|
6
|
+
"wargaming-overlay": "cli.js",
|
|
7
|
+
"warhammer-overlay": "cli.js"
|
|
7
8
|
},
|
|
8
9
|
"scripts": {
|
|
9
10
|
"start": "node cli.js start",
|
|
10
11
|
"test": "node --test",
|
|
11
12
|
"prepublishOnly": "npm test",
|
|
12
|
-
"facts:coverage": "node scripts/fact-coverage.js"
|
|
13
|
+
"facts:coverage": "node scripts/fact-coverage.js",
|
|
14
|
+
"test-tts": "node scripts/tts-demo.js",
|
|
15
|
+
"setup-tts": "node scripts/setup-kokoro.js"
|
|
13
16
|
},
|
|
14
17
|
"engines": {
|
|
15
|
-
"node": ">=
|
|
18
|
+
"node": ">=22"
|
|
16
19
|
},
|
|
17
20
|
"files": [
|
|
18
21
|
"cli.js",
|
|
@@ -21,6 +24,8 @@
|
|
|
21
24
|
"reader.py",
|
|
22
25
|
"battle_reader.py",
|
|
23
26
|
"game-store.js",
|
|
27
|
+
"rosters.js",
|
|
28
|
+
"reader-status.js",
|
|
24
29
|
"turn-tracker.js",
|
|
25
30
|
"public",
|
|
26
31
|
"README.md",
|
|
@@ -33,7 +38,11 @@
|
|
|
33
38
|
"data/faction-facts.json",
|
|
34
39
|
"factions.js",
|
|
35
40
|
"scripts/fact-coverage.js",
|
|
36
|
-
"device_connection.py"
|
|
41
|
+
"device_connection.py",
|
|
42
|
+
"youtube",
|
|
43
|
+
"tts",
|
|
44
|
+
"scripts/tts-demo.js",
|
|
45
|
+
"scripts/setup-kokoro.js"
|
|
37
46
|
],
|
|
38
47
|
"license": "0BSD",
|
|
39
48
|
"publishConfig": {
|
|
@@ -50,5 +59,15 @@
|
|
|
50
59
|
"wargaming",
|
|
51
60
|
"adb",
|
|
52
61
|
"cli"
|
|
53
|
-
]
|
|
62
|
+
],
|
|
63
|
+
"dependencies": {
|
|
64
|
+
"@echogarden/espeak-ng-emscripten": "0.3.5",
|
|
65
|
+
"@grpc/grpc-js": "^1.14.5",
|
|
66
|
+
"@grpc/proto-loader": "^0.8.1",
|
|
67
|
+
"onnxruntime-node": "1.30.0",
|
|
68
|
+
"onnxruntime-web": "1.30.0"
|
|
69
|
+
},
|
|
70
|
+
"optionalDependencies": {
|
|
71
|
+
"node-cpal": "1.0.0"
|
|
72
|
+
}
|
|
54
73
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
*{box-sizing:border-box}body{margin:0;overflow:hidden;background:transparent;color:#f5f1e7;font-family:Arial,sans-serif}#army-stage{--accent:#6aabfa;position:absolute;left:50%;top:50%;width:1920px;height:1080px;transform-origin:center;background:radial-gradient(ellipse at top right,#263348 0,transparent 55%),#10151e;padding:58px 72px;border-top:8px solid var(--accent)}header{height:210px;display:flex;justify-content:space-between;gap:50px;border-bottom:1px solid #566171}.eyebrow{font-size:18px;color:#dfb56a;letter-spacing:.25em;margin:0 0 12px}h1{font-size:62px;line-height:1.05;margin:0 0 12px;max-width:1400px;overflow-wrap:anywhere}#army-faction{font-size:30px;margin:0 0 9px;color:var(--accent)}#army-detachments{font-size:22px;color:#c2cad6;margin:0;max-width:1450px}.army-summary{display:flex;flex-direction:column;text-align:right;justify-content:center;gap:16px;white-space:nowrap;font-size:24px;color:#dfb56a}#army-page{font-size:20px;color:#adb8c7}#army-units{height:724px;padding-top:24px;display:grid;grid-template-columns:1fr 1fr;gap:16px 22px;align-content:stretch}.army-unit{min-width:0;min-height:0;padding:19px 22px;background:#1c2532;border:1px solid #394759;border-left:4px solid var(--accent);border-radius:8px;display:flex;flex-direction:column;justify-content:center;gap:8px}.unit-title{display:flex;gap:14px;align-items:flex-start}.unit-number{color:#8b9aac;font-size:22px;padding-top:4px}.army-unit h2{font-size:30px;line-height:1.15;margin:0;overflow-wrap:anywhere}.unit-detail{font-size:21px;line-height:1.28;margin:0;white-space:pre-wrap;overflow-wrap:anywhere}.unit-detail strong{color:#dfb56a;font-weight:normal}.unit-notes{color:#bac5d4;font-size:20px}.unit-badges{display:flex;gap:10px}.unit-badge{font-size:15px;letter-spacing:.08em;text-transform:uppercase;color:#c9e5ff;border:1px solid #6085a7;padding:4px 8px;border-radius:4px}.army-unit.destroyed{opacity:.55;border-left-color:#89919c}.destroyed h2{text-decoration:line-through;text-decoration-thickness:3px}.destroyed .unit-badge{color:#eee;border-color:#858585}footer{position:absolute;bottom:27px;left:72px;right:72px;display:flex;justify-content:space-between;font-size:16px;letter-spacing:.12em;color:#929dad}.army-empty{grid-column:1/-1;align-self:center;text-align:center;font-size:30px;color:#a5b1c2}
|
|
2
|
+
#army-stage{isolation:isolate}#army-background{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;z-index:-2}#army-stage:has(#army-background[src]):after{content:"";position:absolute;inset:0;background:rgba(8,13,22,.78);z-index:-1}#army-background[hidden]{display:none}#army-units{display:block;height:724px;padding-top:20px;column-count:2;column-gap:32px;column-fill:auto;font-size:28px;overflow:visible}.army-group{margin:0 0 .65em;padding:.5em .65em;background:#142031de;border:1px solid #53607188;border-left:3px solid var(--accent);border-radius:6px;box-decoration-break:clone}.group-name{font-size:1em;line-height:1.12;margin:0 0 .2em;overflow-wrap:anywhere;break-after:avoid}.all-destroyed{text-decoration:line-through;opacity:.55}.army-member{break-inside:avoid;padding:.15em 0}.army-member+.army-member{border-top:1px solid #53607166;margin-top:.18em;padding-top:.25em}.army-member.destroyed{opacity:.5}.army-member.destroyed .member-number{text-decoration:line-through}.unit-detail{font-size:.72em;line-height:1.25;margin:.12em 0;white-space:pre-wrap;overflow-wrap:anywhere}.unit-detail strong{color:#dfb56a}.unit-notes{color:#bec7d3}.member-status{display:flex;align-items:center;gap:.45em;margin-bottom:.12em}.member-number{font-size:.7em;color:#cbd5e4}.unit-badge{font-size:.55em;padding:.14em .4em;letter-spacing:.04em}.army-empty{font-size:1em}
|
|
3
|
+
|
|
4
|
+
/* Fill any OBS source aspect ratio without cropping content or leaving bars. */
|
|
5
|
+
html,body{width:100%;height:100%;background:#10151e}
|
|
6
|
+
#army-stage{display:flex;flex-direction:column}
|
|
7
|
+
#army-stage header{flex:0 0 210px}
|
|
8
|
+
#army-units{flex:1;min-height:0;height:auto}
|
|
9
|
+
#army-stage footer{position:static;flex:0 0 30px;padding-top:12px;margin-top:16px}
|
|
10
|
+
|
|
11
|
+
#army-list-name{max-width:1400px;overflow-wrap:anywhere;letter-spacing:.12em}
|
|
12
|
+
|
|
13
|
+
.unit-badge.battleshocked{color:#ffe1a0;border-color:#d5a145;background:#68471066}
|
|
14
|
+
|
|
15
|
+
.member-title{margin-bottom:.2em}.led-unit{color:#bec7d3;margin-bottom:.3em}.army-member.destroyed .member-title{text-decoration:line-through}
|
|
16
|
+
|
|
17
|
+
/* Keep the heading and every repeated unit together in one column. */
|
|
18
|
+
.army-group{break-inside:avoid;page-break-inside:avoid}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<!doctype html><html lang="es"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Lista de ejército · OBS</title><link rel="stylesheet" href="army-list.css"></head><body><main id="army-stage"><img id="army-background" alt="" referrerpolicy="no-referrer"><header><div><p class="eyebrow" id="army-list-name">ORDEN DE BATALLA</p><h1 id="army-name">Lista de ejército</h1><p id="army-faction"></p><p id="army-detachments"></p></div><div class="army-summary"><span id="army-count"></span></div></header><div id="army-units"></div><footer><span>WARHAMMER 40,000</span><span id="army-status">Conectando…</span></footer></main><script src="roster-view.js"></script><script src="army-list.js"></script></body></html>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
(()=>{
|
|
2
|
+
const $=s=>document.querySelector(s),stage=$('#army-stage'),params=new URLSearchParams(location.search),id=params.get('player');
|
|
3
|
+
let snapshot;
|
|
4
|
+
const el=(tag,text,cls)=>{const n=document.createElement(tag);if(text!=null)n.textContent=text;if(cls)n.className=cls;return n;};
|
|
5
|
+
function fit(){
|
|
6
|
+
const root=$('#army-units');
|
|
7
|
+
// Multicolumn flow retains order; reduce typography only as needed to fit all content.
|
|
8
|
+
let best={font:0,columns:2};
|
|
9
|
+
for(const columns of [2,3]){
|
|
10
|
+
root.style.columnCount=columns;
|
|
11
|
+
for(let font=28;font>=4;font--){root.style.fontSize=font+'px';if(root.scrollWidth<=root.clientWidth+1&&root.scrollHeight<=root.clientHeight+1&&[...root.querySelectorAll('.army-group')].every(group=>group.getClientRects().length===1)){if(font>best.font)best={font,columns};break;}}
|
|
12
|
+
}
|
|
13
|
+
root.style.columnCount=best.columns;root.style.fontSize=(best.font||4)+'px';
|
|
14
|
+
}
|
|
15
|
+
function render(){
|
|
16
|
+
if(!snapshot)return;
|
|
17
|
+
const requestedSide=params.get('side')||'blue',resolvedSide=({blue:'left',red:'right'})[requestedSide]||requestedSide;
|
|
18
|
+
const entry=Object.entries(snapshot.players).find(([side,p])=>p.rosterId===id||(!id&&side===resolvedSide));
|
|
19
|
+
const root=$('#army-units');root.replaceChildren();
|
|
20
|
+
if(!entry){$('#army-name').textContent='Jugador no encontrado';$('#army-status').textContent='Copia la URL desde el administrador.';return;}
|
|
21
|
+
const [side,p]=entry,roster=snapshot.rosters[p.rosterId]||{},units=roster.units||[];
|
|
22
|
+
$('#army-list-name').textContent=roster.name||'ORDEN DE BATALLA';
|
|
23
|
+
const background=$('#army-background');
|
|
24
|
+
if(roster.backgroundUrl){if(background.getAttribute('src')!==roster.backgroundUrl)background.src=roster.backgroundUrl;background.hidden=false;}else{background.removeAttribute('src');background.hidden=true;}
|
|
25
|
+
stage.style.setProperty('--accent',side==='left'?'#6aabfa':'#ed8c8a');
|
|
26
|
+
$('#army-name').textContent=p.name;$('#army-name').style.fontSize=p.name.length>45?'36px':p.name.length>28?'48px':'62px';$('#army-faction').textContent=p.faction;$('#army-detachments').textContent=(p.detachments||[]).join(' · ');
|
|
27
|
+
$('#army-count').textContent=`${units.filter(u=>!u.destroyed).length} / ${units.length} unidades`;
|
|
28
|
+
for(const group of RosterView.groupUnits(units)){
|
|
29
|
+
const block=el('section',null,'army-group'),heading=el('h2',group.name+(group.units.length>1?' ×'+group.units.length:''),'group-name');
|
|
30
|
+
const hasLeaders=group.units.some(u=>u.characters.length);
|
|
31
|
+
if(group.units.every(u=>u.destroyed))heading.classList.add('all-destroyed');if(!hasLeaders)block.append(heading);
|
|
32
|
+
group.units.forEach((u,i)=>{
|
|
33
|
+
const row=el('div',null,'army-member'+(u.destroyed?' destroyed':''));
|
|
34
|
+
if(hasLeaders){
|
|
35
|
+
row.append(el('h2',u.characters.length?u.characters.join(' · '):u.name,'group-name member-title'));
|
|
36
|
+
if(u.characters.length)row.append(el('p','Lidera: '+u.name,'unit-detail led-unit'));
|
|
37
|
+
}
|
|
38
|
+
const label=el('div',null,'member-status');if(group.units.length>1)label.append(el('span',`Unidad ${i+1}`,'member-number'));
|
|
39
|
+
if(u.destroyed)label.append(el('span','Destruida','unit-badge'));else if(u.deepStrike)label.append(el('span','En reservas','unit-badge'));
|
|
40
|
+
if(u.battleshocked&&!u.destroyed)label.append(el('span','Battleshocked','unit-badge battleshocked'));
|
|
41
|
+
if(label.childNodes.length)row.append(label);
|
|
42
|
+
const detail=(name,text)=>{if(!text)return;const line=el('p',null,'unit-detail');line.append(el('strong',name+' '),document.createTextNode(text));row.append(line);};
|
|
43
|
+
detail('Mejora:',u.enhancement);if(u.notes)row.append(el('p',u.notes,'unit-detail unit-notes'));
|
|
44
|
+
if(row.childNodes.length)block.append(row);
|
|
45
|
+
});root.append(block);
|
|
46
|
+
}
|
|
47
|
+
if(!units.length)root.append(el('p','Lista pendiente de cargar','army-empty'));
|
|
48
|
+
fit();
|
|
49
|
+
}
|
|
50
|
+
const stream=new EventSource('/rosters/events');stream.onmessage=e=>{snapshot=JSON.parse(e.data);$('#army-status').textContent='';render();};stream.onerror=()=>{$('#army-status').textContent='Reconectando · última lista guardada';};
|
|
51
|
+
$('#army-background').onerror=()=>{$('#army-background').hidden=true;$('#army-status').textContent='No se pudo cargar la imagen de fondo';};
|
|
52
|
+
function resize(){
|
|
53
|
+
const scale=Math.min(innerWidth/1920,innerHeight/1080);
|
|
54
|
+
stage.style.width=(innerWidth/scale)+'px';stage.style.height=(innerHeight/scale)+'px';
|
|
55
|
+
stage.style.transform=`translate(-50%,-50%) scale(${scale})`;
|
|
56
|
+
if(snapshot)fit();
|
|
57
|
+
}
|
|
58
|
+
addEventListener('resize',resize);resize();
|
|
59
|
+
})();
|
package/public/control.html
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<!doctype html><html lang="es"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Battle Report Control</title><link rel="stylesheet" href="style.css"></head><body class="control">
|
|
2
2
|
<main><header><p class="eyebrow">BATTLE REPORT</p><h1>Control de transmisión</h1><p>Los cambios se reflejan al instante en OBS.</p></header>
|
|
3
|
-
<form id="form"><label>Rótulo del encuentro<input id="title" maxlength="40"></label><section id="players"></section><p>Top / Bottom se detecta automáticamente
|
|
3
|
+
<nav class="admin-tabs" aria-label="Secciones"><button type="button" id="tab-match" aria-pressed="true">Partida</button><button type="button" id="tab-rosters" class="secondary-button" aria-pressed="false">Listas de ejército</button></nav><div id="match-panel"><form id="form"><label>Rótulo del encuentro<input id="title" maxlength="40"></label><section id="players"></section><p>Top / Bottom se detecta automáticamente al sacar nuevas secundarias en Tabletop Battles.</p><div class="announcement-actions"><button type="submit" disabled>Actualizar overlay</button><button type="button" id="swap-players" class="secondary-button" disabled>Intercambiar jugadores ⇄</button></div><p>Intercambiar jugadores guarda los cambios del formulario y mueve nombres, facciones, destacamentos y marcadores al lado contrario.</p><p id="saved" aria-live="polite"></p></form><section><h2>Datos de la partida</h2><p id="phone-status" aria-live="polite"></p><div class="turn-indicator" aria-live="polite"><strong id="turn-status">RONDA —</strong><p id="turn-order">Esperando la lectura de Tabletop Battles.</p><p id="turn-reason"></p></div><button type="button" id="reset-game">Restablecer datos de la partida</button><p>Vacía los puntos y misiones guardados. Conserva nombres, facciones y diseño. Si el teléfono está conectado, la próxima lectura los volverá a completar.</p><p id="reset-status" aria-live="polite"></p></section><section class="announcement-panel" aria-labelledby="announcement-heading">
|
|
4
4
|
<h2 id="announcement-heading">Mensajes en pantalla</h2>
|
|
5
5
|
<p>Se muestran abajo durante 15 segundos. Primero guarda las facciones y disposiciones del encuentro.</p>
|
|
6
6
|
<form id="message-form"><label for="toast-message">Mensaje para la transmisión</label><textarea id="toast-message" rows="3" maxlength="280" placeholder="¡Bienvenidos! La próxima ronda comienza en unos minutos." required></textarea><div class="announcement-actions"><span id="message-count">0 / 280</span><button type="submit">Mostrar mensaje · 15 s</button></div></form>
|
|
@@ -8,5 +8,5 @@
|
|
|
8
8
|
<div class="announcement-actions"><button type="button" id="next-fact">Mostrar siguiente dato</button><button type="button" class="secondary-button" id="new-toast-session">Nueva sesión de mensajes</button></div>
|
|
9
9
|
<p id="rotation-status" aria-live="polite"></p><p id="toast-feedback" role="status"></p>
|
|
10
10
|
<div class="announcement-preview"><span class="eyebrow">ÚLTIMO MENSAJE</span><p id="last-toast">Todavía no has mostrado un mensaje.</p><a id="toast-source" target="_blank" rel="noopener noreferrer" hidden>Consultar fuente</a></div>
|
|
11
|
-
<p class="announcement-help">El orden es aleatorio y cada dato aparece una sola vez por
|
|
12
|
-
</section><aside><strong>En OBS:</strong> añade una fuente “Navegador” con la URL <code id="obs-url"></code>. Resolución de la fuente: <select id="resolution-preset"><option value="1280x720">720p · 1280 × 720</option><option value="1600x900">900p · 1600 × 900</option><option value="1664x936">936p · 1664 × 936</option><option value="1920x1080">1080p · 1920 × 1080</option><option value="2560x1440" selected>1440p · 2560 × 1440</option><option value="3840x2160">4K · 3840 × 2160</option></select><p id="resolution-help"></p></aside></main><script src="match-view.js"></script><script src="control.js"></script><script src="announcements-control.js"></script></body></html>
|
|
11
|
+
<p class="announcement-help">El orden es aleatorio y cada dato aparece una sola vez por ciclo. Al agotarse, comienza otro ciclo automáticamente. El historial se conserva al recargar o reiniciar. «Nueva sesión» lo reinicia para otra transmisión. Los mensajes manuales reemplazan el aviso actual.</p>
|
|
12
|
+
</section><aside><strong>En OBS:</strong> añade una fuente “Navegador” con la URL <code id="obs-url"></code>. Resolución de la fuente: <select id="resolution-preset"><option value="1280x720">720p · 1280 × 720</option><option value="1600x900">900p · 1600 × 900</option><option value="1664x936">936p · 1664 × 936</option><option value="1920x1080">1080p · 1920 × 1080</option><option value="2560x1440" selected>1440p · 2560 × 1440</option><option value="3840x2160">4K · 3840 × 2160</option></select><p id="resolution-help"></p></aside></div><div id="rosters-panel" hidden><h2>Listas de ejército</h2><p>Añade unidades o importa un archivo JSON. Los nombres, facciones y destacamentos se toman de la pestaña Partida. Los cambios se guardan automáticamente y se muestran en OBS.</p><div id="roster-editors"></div><p id="roster-feedback" role="status"></p></div></main><script src="match-view.js"></script><script src="control.js"></script><script src="announcements-control.js"></script><script src="rosters-control.js"></script></body></html>
|
package/public/control.js
CHANGED
|
@@ -24,7 +24,8 @@ async function saveLayout(swap=false){
|
|
|
24
24
|
const next=swap?MatchView.swapPlayers(form):form;
|
|
25
25
|
const response=await fetch('/state',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(next)});
|
|
26
26
|
if(!response.ok)throw Error('No se pudo guardar');
|
|
27
|
-
state=
|
|
27
|
+
state=await fetch('/state').then(r=>r.json());renderPlayers();
|
|
28
|
+
window.dispatchEvent(new Event('layout-saved'));
|
|
28
29
|
document.querySelector('#saved').textContent=swap?'Jugadores intercambiados. Overlay actualizado.':'Overlay actualizado.';
|
|
29
30
|
}catch{
|
|
30
31
|
document.querySelector('#saved').textContent='No se pudo confirmar el cambio. Comprueba la conexión con el servidor y recarga el panel antes de intentarlo de nuevo.';
|
|
@@ -47,7 +48,7 @@ document.querySelector('#reset-game').addEventListener('click',async()=>{
|
|
|
47
48
|
catch {document.querySelector('#reset-status').textContent='No se pudo restablecer. Comprueba que el servidor esté actualizado y funcionando.';}
|
|
48
49
|
finally{button.disabled=false}
|
|
49
50
|
});
|
|
50
|
-
async function phoneStatus(){try{const r=await fetch('/game-state.json',{cache:'no-store'});const g=await r.json();document.querySelector('#turn-status').textContent=(
|
|
51
|
+
async function phoneStatus(){try{const r=await fetch('/game-state.json',{cache:'no-store'});const g=await r.json();const turn=MatchView.turnStatus(g,state);document.querySelector('#turn-status').textContent=turn.label;document.querySelector('#turn-order').textContent=turn.order;document.querySelector('#turn-reason').textContent=turn.reason;document.querySelector('#phone-status').textContent=g.connected?`Teléfono conectado por ${g.transport==='wifi'?'Wi-Fi':'USB'}.`:(g.phoneConnected?`Teléfono conectado por ${g.transport==='wifi'?'Wi-Fi':'USB'}. `:'')+(g.error||'Reconectando…')+(g.players?.length?' Se conserva la última lectura.':'');}catch{document.querySelector('#phone-status').textContent='Servidor no disponible.'}finally{setTimeout(phoneStatus,3000)}}phoneStatus();
|
|
51
52
|
|
|
52
53
|
document.querySelector('#obs-url').textContent=location.origin+'/overlay-art.html';
|
|
53
54
|
|
package/public/detachments.css
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
.detachment-list { position: absolute; top: 181px; display: flex; flex-wrap:
|
|
1
|
+
.detachment-list { position: absolute; top: 181px; display: flex; flex-wrap: wrap; gap: 12px; width: calc(50% - 740px); }
|
|
2
2
|
.detachment-list.left { left: 382px; }
|
|
3
3
|
.detachment-list.right { right: 382px; justify-content: flex-end; }
|
|
4
4
|
.detachment-list:empty { display: none; }
|
|
5
|
-
.detachment { padding: 7px 12px; border: 1px solid rgba(232, 228, 216, .35); border-radius: 5px; background: rgba(7, 10, 14, .78); box-shadow: 0 3px 8px #0008; color: #f1ecdc; font-size: 20px; letter-spacing: .05em; text-transform: uppercase; white-space:
|
|
5
|
+
.detachment { padding: 7px 12px; border: 1px solid rgba(232, 228, 216, .35); border-radius: 5px; background: rgba(7, 10, 14, .78); box-shadow: 0 3px 8px #0008; color: #f1ecdc; font-size: 20px; letter-spacing: .05em; text-transform: uppercase; white-space: normal; max-width: 100%; overflow-wrap: anywhere; }
|
package/public/live-score.css
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
.live-score{color:#fffaf0;text-shadow:0 2px 4px #000,0 0 8px #000;pointer-events:none}.live-vp{position:absolute;transform:translateY(-50%);font-size:40px;white-space:nowrap;font-variant-numeric:tabular-nums}.live-cp{position:absolute;transform:translateX(-50%);font-size:34px;white-space:nowrap}.live-round{position:absolute;top:145px;left:50%;transform:translateX(-50%);font-size:28px;white-space:nowrap}.live-missions{position:absolute;top:248px;width:350px;font-size:
|
|
1
|
+
.live-score{color:#fffaf0;text-shadow:0 2px 4px #000,0 0 8px #000;pointer-events:none}.live-vp{position:absolute;transform:translateY(-50%);font-size:40px;white-space:nowrap;font-variant-numeric:tabular-nums}.live-cp{position:absolute;transform:translateX(-50%);font-size:34px;white-space:nowrap}.live-round{position:absolute;top:145px;left:50%;transform:translateX(-50%);font-size:28px;white-space:nowrap}.live-missions{position:absolute;top:248px;width:350px;font-size:30px;line-height:1.35;text-transform:none}.live-player.left .live-missions{left:24px}.live-player.right .live-missions{right:24px;text-align:right}.live-mission{overflow-wrap:anywhere}.live-note{display:none}
|
|
2
2
|
|
|
3
|
-
.live-primary{padding-bottom:12px;margin-bottom:12px;border-bottom:1px solid rgba(255,250,240,.5)}.mission-heading{font-size:18px;letter-spacing:.12em;margin-bottom:6px;color:#e2ceb0}.primary-objective{font-size:
|
|
3
|
+
.live-primary{padding-bottom:12px;margin-bottom:12px;border-bottom:1px solid rgba(255,250,240,.5)}.mission-heading{font-size:18px;letter-spacing:.12em;margin-bottom:6px;color:#e2ceb0}.primary-objective{font-size:28px;line-height:1.25;margin:12px 0}.live-missions{width:400px}
|
|
4
4
|
|
|
5
|
-
.primary-objective{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.objective-
|
|
5
|
+
.primary-objective{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.objective-copy{flex:1;min-width:0}.objective-name{overflow-wrap:anywhere}.objective-details{font-size:20px;line-height:1.3;color:#e2ceb0;margin-top:4px;overflow-wrap:anywhere}.objective-check,.objective-counter{flex:0 0 auto;min-width:30px;height:30px;display:inline-flex;align-items:center;justify-content:center;font-size:26px;line-height:1;font-variant-numeric:tabular-nums}.objective-check{border:2px solid #fffaf0aa;border-radius:4px}.objective-check.completed{color:#bcf2c2;border-color:#bcf2c2}.objective-counter{font-weight:bold}
|
|
6
6
|
|
|
7
7
|
.live-round{z-index:6;padding:7px 16px;background:rgba(7,10,14,.96);border:1px solid rgba(232,228,216,.65);border-radius:7px;box-shadow:0 3px 10px #0008;line-height:1.15}
|
package/public/live-score.js
CHANGED
|
@@ -15,9 +15,12 @@
|
|
|
15
15
|
if(p?.primary){
|
|
16
16
|
const primary=make('section','','live-primary');
|
|
17
17
|
primary.append(make('div',`PRIMARIA · ${value(p.primary)}`,'mission-heading'));
|
|
18
|
-
for(const objective of p.primaryObjectives||[]){
|
|
18
|
+
for(const objective of (p.primaryObjectives||[]).filter(m=>m.enabled!==false)){
|
|
19
19
|
const row=make('div','','primary-objective');
|
|
20
|
-
|
|
20
|
+
const copy=make('div','','objective-copy');
|
|
21
|
+
copy.append(make('div',objective.name,'objective-name'));
|
|
22
|
+
if(objective.details)copy.append(make('div',objective.details,'objective-details'));
|
|
23
|
+
row.append(copy);
|
|
21
24
|
const hasCounter=objective.counter!=null;
|
|
22
25
|
const marker=make('span',hasCounter?String(objective.counter):objective.checked===true?'✓':objective.checked===false?'':'—',hasCounter?'objective-counter':'objective-check');
|
|
23
26
|
marker.setAttribute('aria-label',hasCounter?`Contador: ${objective.counter}`:objective.checked===true?'Cumplida':objective.checked===false?'Pendiente':'Sin lectura');
|
|
@@ -26,8 +29,9 @@
|
|
|
26
29
|
}
|
|
27
30
|
missions.append(primary);
|
|
28
31
|
}
|
|
29
|
-
|
|
30
|
-
|
|
32
|
+
const secondaries=(p?.secondaries||[]).filter(m=>m.enabled!==false);
|
|
33
|
+
if(secondaries.length)missions.append(make('div','SECUNDARIAS','mission-heading'));
|
|
34
|
+
for(const m of secondaries)missions.append(make('div',`${m.name} ${m.score}/${m.max}`,'live-mission'));
|
|
31
35
|
if(!p)card.append(make('div','Sin jugador vinculado','live-note'));
|
|
32
36
|
panel.append(card);
|
|
33
37
|
const rect=document.querySelector('.team.'+side+' .faction-icon').getBoundingClientRect();
|
package/public/match-view.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
}
|
|
8
8
|
function matchPlayers(layout, game) {
|
|
9
9
|
const matches = ['left', 'right'].map(side => {
|
|
10
|
-
const key = normalize(layout[side]
|
|
10
|
+
const key = normalize(layout?.[side]?.appName || layout?.[side]?.name);
|
|
11
11
|
if (!key) return null;
|
|
12
12
|
const found = (game?.players || []).filter(player => normalize(player.name) === key);
|
|
13
13
|
return found.length === 1 ? found[0] : null;
|
|
@@ -17,10 +17,21 @@
|
|
|
17
17
|
}
|
|
18
18
|
function roundLabel(layout, game, matched) {
|
|
19
19
|
const round = matched && Number.isInteger(game?.round) && game.round > 0 ? game.round : '—';
|
|
20
|
-
const half = {top: 'TOP
|
|
21
|
-
|
|
20
|
+
const half = {top: 'TOP', bottom: 'BOTTOM'}[matched ? game?.half : null];
|
|
21
|
+
const first=game?.turn?.firstPlayer;
|
|
22
|
+
const active=half && first ? game?.players?.find(p=>game.half==='top' ? p.name===first : p.name!==first) : null;
|
|
23
|
+
const matches=matchPlayers(layout,game);
|
|
24
|
+
const side=active ? matches.indexOf(active) : -1;
|
|
25
|
+
const color=side===0?'AZUL':side===1?'ROJO':null;
|
|
26
|
+
return `RONDA ${round}${half ? ` · ${half}` : ''}${color ? ` · ${color}` : ''}`;
|
|
22
27
|
}
|
|
23
|
-
|
|
28
|
+
function turnStatus(game, layout={}) {
|
|
29
|
+
const label=roundLabel(layout,game,Number.isInteger(game?.round));
|
|
30
|
+
const first=game?.turn?.orderSource==='ui-order' ? game.turn.firstPlayer : null;
|
|
31
|
+
const second=first ? game.players?.find(p=>p.name!==first)?.name : null;
|
|
32
|
+
return {label,order:first && second ? `TOP: ${first} · BOTTOM: ${second}` : 'Orden de jugadores pendiente de lectura',reason:game?.turn?.reason || 'Esperando nuevas secundarias en Tabletop Battles.'};
|
|
33
|
+
}
|
|
34
|
+
const api = {swapPlayers, matchPlayers, roundLabel, turnStatus};
|
|
24
35
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
25
36
|
else root.MatchView = api;
|
|
26
37
|
})(globalThis);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
(function(root){
|
|
2
|
+
function groupUnits(units){
|
|
3
|
+
const groups=[];
|
|
4
|
+
for(const unit of units){const key=unit.name.normalize('NFKC').trim().replace(/\s+/g,' ').toLocaleLowerCase();const last=groups.at(-1);if(last?.key===key)last.units.push(unit);else groups.push({key,name:unit.name,units:[unit]});}
|
|
5
|
+
return groups;
|
|
6
|
+
}
|
|
7
|
+
const api={groupUnits};if(typeof module!=='undefined'&&module.exports)module.exports=api;else root.RosterView=api;
|
|
8
|
+
})(globalThis);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
(()=>{
|
|
2
|
+
const $=s=>document.querySelector(s),root=$('#roster-editors'),feedback=$('#roster-feedback');
|
|
3
|
+
const drafts=new Map();let snapshot,dragged=null;
|
|
4
|
+
const el=(tag,text,cls)=>{const n=document.createElement(tag);if(text!=null)n.textContent=text;if(cls)n.className=cls;return n;};
|
|
5
|
+
function tab(which){for(const name of ['match','rosters']){const selected=name===which;$('#'+name+'-panel').hidden=!selected;$('#tab-'+name).setAttribute('aria-pressed',String(selected));$('#tab-'+name).classList.toggle('secondary-button',!selected);}if(which==='rosters')load();}
|
|
6
|
+
$('#tab-match').onclick=()=>tab('match');$('#tab-rosters').onclick=()=>tab('rosters');
|
|
7
|
+
function dirty(){return [...drafts.values()].some(d=>d.dirty);}
|
|
8
|
+
async function load(){
|
|
9
|
+
if([...drafts.values()].some(d=>d.saving))return;
|
|
10
|
+
try{const r=await fetch('/rosters');if(!r.ok)throw Error('No se pudieron cargar las listas.');snapshot=await r.json();
|
|
11
|
+
for(const p of Object.values(snapshot.players)){if(!drafts.has(p.rosterId)||!drafts.get(p.rosterId).dirty)drafts.set(p.rosterId,{...structuredClone(snapshot.rosters[p.rosterId]||{revision:0,units:[]}),dirty:false});}
|
|
12
|
+
render();
|
|
13
|
+
}catch(e){feedback.textContent=e.message;}
|
|
14
|
+
}
|
|
15
|
+
function button(text,fn,secondary=true){const b=el('button',text,secondary?'secondary-button':'');b.type='button';b.onclick=fn;return b;}
|
|
16
|
+
function field(label,value,max,fn,multiline=false){const l=el('label',label),i=el(multiline?'textarea':'input');i.value=value;i.maxLength=max;if(multiline)i.rows=2;i.oninput=()=>fn(i.value);l.append(i);return l;}
|
|
17
|
+
function render(){
|
|
18
|
+
root.replaceChildren();if(!snapshot)return;
|
|
19
|
+
for(const [side,p] of Object.entries(snapshot.players)){
|
|
20
|
+
const d=drafts.get(p.rosterId),section=el('article',null,'roster-editor');section.dataset.roster=p.rosterId;
|
|
21
|
+
section.append(el('h3',p.name||'Jugador'),el('p',[p.faction,...(p.detachments||[])].filter(Boolean).join(' · '),'roster-meta'));
|
|
22
|
+
const color=side==='left'?'blue':'red',url=location.origin+'/army-list.html?side='+color;
|
|
23
|
+
const link=el('a','Abrir lista para OBS ↗');link.href=url;link.target='_blank';link.rel='noopener';section.append(link,el('code',url,'roster-url'),el('p','Fuente Navegador: 1920 × 1080. Toda la lista se muestra en una sola pantalla.'));
|
|
24
|
+
section.append(button('Copiar URL '+(color==='blue'?'azul':'roja'),async()=>{try{await navigator.clipboard.writeText(url);feedback.textContent='URL copiada al portapapeles.';}catch{feedback.textContent='No se pudo copiar. Selecciona y copia la URL que aparece arriba.';}}));
|
|
25
|
+
const toolbar=el('div',null,'roster-toolbar');
|
|
26
|
+
const change=()=>{d.dirty=true;d.version=(d.version||0)+1;feedback.textContent='Cambios pendientes…';schedule(p.rosterId);};
|
|
27
|
+
section.append(field('Nombre de la lista',d.name||'',80,v=>{d.name=v;change();}));
|
|
28
|
+
section.append(field('URL de imagen de fondo (opcional)',d.backgroundUrl||'',2048,v=>{d.backgroundUrl=v;change();}));
|
|
29
|
+
toolbar.append(button('Añadir unidad',()=>{if(d.units.length>=80){feedback.textContent='Máximo 80 unidades.';return;}d.units.push({id:crypto.randomUUID(),name:'',enhancement:'',characters:[],notes:'',deepStrike:false,destroyed:false,battleshocked:false});change();render();sectionFor(p.rosterId)?.querySelector('.unit-editor:last-child input')?.focus();}));
|
|
30
|
+
const upload=el('input');upload.type='file';upload.accept='.json,application/json';upload.hidden=true;
|
|
31
|
+
upload.onchange=async()=>{try{const file=upload.files[0];if(!file)return;if(file.size>200000)throw Error('El archivo es demasiado grande.');const json=JSON.parse(await file.text());const units=Array.isArray(json)?json:json.units;if(!Array.isArray(units)||units.length>80)throw Error('El archivo debe contener una lista de hasta 80 unidades.');
|
|
32
|
+
const clean=units.map(u=>{if(typeof u?.name!=='string'||!u.name.trim()||u.name.length>100||!Array.isArray(u.characters??[])||(u.characters??[]).some(c=>typeof c!=='string'||!c.trim()||c.length>80)||(u.characters??[]).length>8||typeof(u.enhancement??'')!=='string'||(u.enhancement??'').length>100||typeof(u.notes??'')!=='string'||(u.notes??'').length>160)throw Error('Una unidad tiene campos inválidos. Consulta el formato del README.');return {id:crypto.randomUUID(),name:u.name,enhancement:u.enhancement||'',characters:u.characters||[],notes:u.notes||'',deepStrike:u.deepStrike===true,destroyed:u.destroyed===true,battleshocked:u.battleshocked===true};});
|
|
33
|
+
if(d.units.length&&!confirm('¿Reemplazar esta lista con el archivo?'))return;
|
|
34
|
+
d.units=clean;d.name=typeof json.name==='string'?json.name:d.name||'';d.backgroundUrl=typeof json.backgroundUrl==='string'?json.backgroundUrl:d.backgroundUrl||'';change();render();
|
|
35
|
+
}catch(e){feedback.textContent=e.message;}finally{upload.value='';}};
|
|
36
|
+
toolbar.append(button('Importar JSON',()=>upload.click()),upload,button('Exportar JSON',()=>{const a=el('a');a.href=URL.createObjectURL(new Blob([JSON.stringify({name:d.name||'',backgroundUrl:d.backgroundUrl||'',units:d.units},null,2)],{type:'application/json'}));a.download='lista-ejercito.json';a.click();setTimeout(()=>URL.revokeObjectURL(a.href),1000);}));
|
|
37
|
+
section.append(toolbar);
|
|
38
|
+
const list=el('ol',null,'unit-editors');
|
|
39
|
+
d.units.forEach((u,index)=>{
|
|
40
|
+
const row=el('li',null,'unit-editor');const tools=el('div',null,'unit-tools');const handle=button('⠿',()=>{});handle.className='drag-handle';handle.draggable=true;handle.setAttribute('aria-label','Arrastrar '+(u.name||'unidad'));handle.title='Arrastra para ordenar';
|
|
41
|
+
handle.draggable=false;
|
|
42
|
+
handle.onpointerdown=e=>{if(e.button!==0)return;e.preventDefault();handle.setPointerCapture(e.pointerId);dragged={roster:p.rosterId,id:u.id,startY:e.clientY,target:null};row.classList.add('dragging');};
|
|
43
|
+
handle.onpointermove=e=>{if(!dragged||dragged.id!==u.id)return;const target=document.elementFromPoint(e.clientX,e.clientY)?.closest('.unit-editor');root.querySelectorAll('.drag-over').forEach(n=>n.classList.remove('drag-over'));if(target&&list.contains(target)){dragged.target=target;dragged.after=e.clientY>target.getBoundingClientRect().top+target.getBoundingClientRect().height/2;target.classList.add('drag-over');}if(e.clientY<70)window.scrollBy(0,-25);else if(e.clientY>innerHeight-70)window.scrollBy(0,25);};
|
|
44
|
+
const finishDrag=e=>{if(!dragged||dragged.id!==u.id)return;const drag=dragged;dragged=null;root.querySelectorAll('.drag-over,.dragging').forEach(n=>n.classList.remove('drag-over','dragging'));if(e.type==='pointercancel'||!drag.target||Math.abs(e.clientY-drag.startY)<4)return;const from=d.units.findIndex(x=>x.id===u.id);let to=[...list.children].indexOf(drag.target)+(drag.after?1:0);const [moved]=d.units.splice(from,1);if(from<to)to--;d.units.splice(to,0,moved);change();render();};
|
|
45
|
+
handle.onpointerup=finishDrag;handle.onpointercancel=finishDrag;
|
|
46
|
+
tools.append(handle);const heading=el('strong',`${index+1}. ${u.name||'Nueva unidad'}`);tools.append(heading);
|
|
47
|
+
for(const [delta,label] of [[-1,'Subir ↑'],[1,'Bajar ↓']]){const b=button(label,()=>{const j=index+delta;[d.units[index],d.units[j]]=[d.units[j],d.units[index]];change();render();});b.disabled=index+delta<0||index+delta>=d.units.length;tools.append(b);}
|
|
48
|
+
tools.append(button('Eliminar',()=>{if(u.name&&!confirm(`¿Eliminar ${u.name}?`))return;d.units.splice(index,1);change();render();}));row.append(tools);
|
|
49
|
+
const details=el('details',null,'unit-fields');details.open=!u.name;details.append(el('summary','Editar nombre, mejora, personajes y notas'));
|
|
50
|
+
details.append(field('Nombre de la unidad',u.name,100,v=>{u.name=v;heading.textContent=`${index+1}. ${v||'Nueva unidad'}`;change();}),field('Mejora (opcional)',u.enhancement,100,v=>{u.enhancement=v;change();}),field('Personajes asignados · uno por línea (máximo 8)',u.characters.join('\n'),647,v=>{u.characters=v.split('\n').filter(s=>s.trim());change();},true),field('Notas (opcional)',u.notes,160,v=>{u.notes=v;change();},true));
|
|
51
|
+
row.append(details);
|
|
52
|
+
const flags=el('div',null,'unit-flags');for(const [key,label] of [['deepStrike','En reservas'],['battleshocked','Battleshocked'],['destroyed','Unidad destruida']]){const l=el('label'),box=el('input');box.type='checkbox';box.checked=u[key];box.onchange=()=>{u[key]=box.checked;change();};l.append(box,document.createTextNode(label));flags.append(l);}row.append(flags);list.append(row);
|
|
53
|
+
});
|
|
54
|
+
if(!d.units.length)section.append(el('p','Todavía no hay unidades. Añade la primera o importa una lista.','roster-empty'));
|
|
55
|
+
section.append(list);root.append(section);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const sectionFor=id=>[...root.children].find(n=>n.dataset.roster===id);
|
|
59
|
+
function schedule(id,delay=600){const d=drafts.get(id);clearTimeout(d.timer);d.timer=setTimeout(()=>save(id),delay);}
|
|
60
|
+
async function save(id){
|
|
61
|
+
const d=drafts.get(id);if(!d||!d.dirty||d.saving)return;
|
|
62
|
+
if(d.units.some(u=>!u.name.trim())){feedback.textContent='Escribe el nombre de cada unidad para guardar automáticamente.';return;}
|
|
63
|
+
const version=d.version;d.saving=true;feedback.textContent='Guardando lista…';let retry=false;
|
|
64
|
+
try{
|
|
65
|
+
const r=await fetch('/rosters/'+encodeURIComponent(id),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({revision:d.revision,name:d.name||'',units:d.units,backgroundUrl:d.backgroundUrl||''})});
|
|
66
|
+
const data=await r.json();if(!r.ok){retry=r.status>=500;throw Error(data.error||'No se pudo guardar.');}
|
|
67
|
+
d.revision=data.revision;
|
|
68
|
+
if(typeof data.backgroundUrl!=='string'||typeof data.name!=='string'||data.units.some(u=>typeof u.battleshocked!=='boolean')){retry=true;throw Error('Reinicia npm start para actualizar el servicio. Conservamos los cambios y reintentaremos automáticamente.');}
|
|
69
|
+
if(d.version===version)d.dirty=false;
|
|
70
|
+
feedback.textContent=dirty()?'Cambios pendientes…':'Listas guardadas. OBS actualizado.';
|
|
71
|
+
}catch(e){if(e instanceof TypeError)retry=true;feedback.textContent=e.message+(retry?' Reintentando en unos segundos.':' Los cambios siguen en el editor.');}
|
|
72
|
+
finally{d.saving=false;if(retry)schedule(id,5000);else if(d.dirty&&d.version!==version)schedule(id);}
|
|
73
|
+
}
|
|
74
|
+
window.addEventListener('online',()=>{for(const [id,d] of drafts)if(d.dirty)schedule(id,0);});
|
|
75
|
+
window.addEventListener('layout-saved',load);
|
|
76
|
+
window.addEventListener('beforeunload',e=>{if(dirty()){e.preventDefault();e.returnValue='';}});
|
|
77
|
+
})();
|
package/public/style.css
CHANGED
|
@@ -8,3 +8,11 @@
|
|
|
8
8
|
.control .secondary-button{background:transparent;border:1px solid #647284;color:#e8e4d8}.control button:disabled{opacity:.5;cursor:wait}
|
|
9
9
|
.announcement-preview{background:#101620;border-left:3px solid #d9ad4a;padding:14px 18px;border-radius:3px}.announcement-preview p{white-space:pre-wrap;overflow-wrap:anywhere}.announcement-preview a{color:#e8bf70;font-size:13px}
|
|
10
10
|
.announcement-help{font-size:13px;color:#b9c1cd}#toast-feedback{color:#e8bf70;min-height:24px}
|
|
11
|
+
|
|
12
|
+
.turn-indicator{margin:16px 0;padding:18px;border:2px solid #c59b52;border-radius:12px;background:#172335;color:#fff}.turn-indicator strong{display:block;font-size:1.35rem}.turn-indicator p{margin:8px 0 0}#turn-reason{font-size:.9rem;opacity:.8}
|
|
13
|
+
[hidden]{display:none!important}.admin-tabs{display:flex;gap:12px;margin:24px 0}.control .roster-editor{margin:24px 0;padding:22px;border:1px solid #536171;border-radius:10px;background:#171e29}.roster-editor h3{margin:0;font-size:25px}.roster-meta{color:#dbc08b}.roster-editor a{color:#e8bf70}.roster-url{display:block;overflow-wrap:anywhere;margin-top:12px}.roster-toolbar,.unit-tools{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.unit-tools strong{margin-right:auto}.control .unit-tools button{padding:8px 12px;font-size:13px}.unit-editors{list-style:none;padding:0}.unit-editor{padding:18px;margin:18px 0;border:1px solid #455469;border-radius:8px;background:#101620}.control .unit-editor textarea{display:block;width:100%;margin-top:6px;padding:11px;color:white;background:#202936;border:1px solid #536171;border-radius:5px;font:16px/1.4 Arial;resize:vertical}.unit-flags{display:flex;gap:24px}.control .unit-flags label{display:flex;align-items:center;gap:8px}.control .unit-flags input{width:20px;height:20px;margin:0;accent-color:#d9ad4a}#roster-feedback{position:sticky;bottom:8px;background:#1b2738;padding:12px;color:#f3d69f;border-radius:6px}#roster-feedback:empty{display:none}
|
|
14
|
+
|
|
15
|
+
.unit-fields{margin-top:16px}.unit-fields summary{color:#b8c7db;cursor:pointer;font-size:14px}.unit-tools strong{overflow-wrap:anywhere;max-width:100%}
|
|
16
|
+
.control .drag-handle{cursor:grab;padding:6px 10px;background:transparent;color:#e2bd78;font-size:26px;touch-action:none}.drag-handle:active{cursor:grabbing}.unit-editor.dragging{opacity:.4}.unit-editor.drag-over{outline:2px solid #d9ad4a;outline-offset:3px}
|
|
17
|
+
|
|
18
|
+
.drag-handle{touch-action:none;user-select:none}
|
package/reader-status.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Log state transitions, never every polling cycle or score update.
|
|
2
|
+
function createReaderStatus(log=console.log){
|
|
3
|
+
let last=null;
|
|
4
|
+
return incoming=>{
|
|
5
|
+
const transport=incoming.transport==='wifi'?'Wi-Fi':incoming.transport==='usb'?'USB':'conexión desconocida';
|
|
6
|
+
let key,message;
|
|
7
|
+
if(incoming.connected){
|
|
8
|
+
const names=(incoming.players||[]).map(p=>p.name).join(' vs ');
|
|
9
|
+
key=JSON.stringify(['reading',transport,names,incoming.round]);
|
|
10
|
+
message=`Leyendo teléfono por ${transport}: ${names} · ronda ${incoming.round}. Actualización automática activa.`;
|
|
11
|
+
}else{
|
|
12
|
+
const phone=incoming.phoneConnected?`Teléfono conectado por ${transport}; lectura pendiente`:'Sin lectura del teléfono';
|
|
13
|
+
key=JSON.stringify(['waiting',incoming.phoneConnected,transport,incoming.error]);
|
|
14
|
+
message=`${phone}: ${incoming.error||'buscando conexión USB o Wi-Fi; se reintentará automáticamente.'}`;
|
|
15
|
+
}
|
|
16
|
+
if(key!==last){log(message);last=key;}
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
module.exports={createReaderStatus};
|
package/rosters.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const {randomUUID}=require('node:crypto');
|
|
2
|
+
function linkPlayers(next,previous={}){
|
|
3
|
+
const used=new Set();const result={...next};
|
|
4
|
+
for(const side of ['left','right']){
|
|
5
|
+
const player=next[side];if(!player||typeof player!=='object')throw Error('Faltan los dos jugadores.');
|
|
6
|
+
const known=['left','right'].map(s=>previous[s]).filter(Boolean);
|
|
7
|
+
let id=known.find(p=>p.rosterId===player.rosterId)?.rosterId;
|
|
8
|
+
if(!id)id=known.find(p=>(p.appName||p.name)===(player.appName||player.name))?.rosterId;
|
|
9
|
+
if(!id)id=previous[side]?.rosterId;
|
|
10
|
+
if(!id||used.has(id))id=randomUUID();
|
|
11
|
+
used.add(id);result[side]={...player,rosterId:id};
|
|
12
|
+
}
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
15
|
+
function validateUnits(input){
|
|
16
|
+
if(!Array.isArray(input)||input.length>80)throw Error('La lista admite hasta 80 unidades.');
|
|
17
|
+
const ids=new Set();
|
|
18
|
+
const text=(v,max,label,required=false)=>{if(typeof v!=='string'||v.length>max||(required&&!v.trim()))throw Error(`${label}: texto ${required?'obligatorio, ':''}máximo ${max} caracteres.`);return v.trim();};
|
|
19
|
+
return input.map(u=>{
|
|
20
|
+
if(!u||typeof u!=='object')throw Error('Unidad inválida.');
|
|
21
|
+
const id=u.id||randomUUID();if(typeof id!=='string'||!/^[a-zA-Z0-9_-]{1,80}$/.test(id)||ids.has(id))throw Error('Identificador de unidad inválido o repetido.');ids.add(id);
|
|
22
|
+
if(!Array.isArray(u.characters)||u.characters.length>8)throw Error('Cada unidad admite hasta 8 personajes.');
|
|
23
|
+
if(typeof u.deepStrike!=='boolean'||typeof u.destroyed!=='boolean')throw Error('En reservas y destruida deben ser true o false.');
|
|
24
|
+
if(u.battleshocked!==undefined&&typeof u.battleshocked!=='boolean')throw Error('Battleshocked debe ser true o false.');
|
|
25
|
+
return {id,name:text(u.name,100,'Unidad',true),enhancement:text(u.enhancement??'',100,'Mejora'),characters:u.characters.map(c=>text(c,80,'Personaje',true)),notes:text(u.notes??'',160,'Notas'),deepStrike:u.deepStrike,destroyed:u.destroyed,battleshocked:u.battleshocked===true};
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
function validateBackground(value=''){
|
|
29
|
+
if(typeof value!=='string'||value.length>2048)throw Error('URL de fondo inválida.');
|
|
30
|
+
if(!value.trim())return '';
|
|
31
|
+
let url;try{url=new URL(value);}catch{throw Error('Introduce una URL completa para el fondo.');}
|
|
32
|
+
if(!['http:','https:'].includes(url.protocol)||url.username||url.password)throw Error('El fondo debe usar HTTP o HTTPS, sin credenciales.');
|
|
33
|
+
return url.href;
|
|
34
|
+
}
|
|
35
|
+
function validateName(value=''){
|
|
36
|
+
if(typeof value!=='string'||value.length>80)throw Error('Nombre de lista: máximo 80 caracteres.');
|
|
37
|
+
return value.trim();
|
|
38
|
+
}
|
|
39
|
+
module.exports={linkPlayers,validateUnits,validateBackground,validateName};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const {SpeechQueue}=require('../tts/queue');
|
|
2
|
+
const {sanitize}=require('../tts/sanitizer');
|
|
3
|
+
async function main(){
|
|
4
|
+
const q=new SpeechQueue();
|
|
5
|
+
for(const [author,text] of [['Pedro','Hola, esto es una prueba.'],['John','Este mensaje debe sonar después del anterior.'],['Pedro','Mira esto https://example.com/prueba']])q.enqueue(sanitize({author,text}));
|
|
6
|
+
const stop=()=>{q.stop();};process.once('SIGINT',stop);process.once('SIGTERM',stop);
|
|
7
|
+
await q.idle();process.removeListener('SIGINT',stop);process.removeListener('SIGTERM',stop);
|
|
8
|
+
if(q.failed)throw Error('La prueba de voz falló. Revisa los mensajes de TTS.');
|
|
9
|
+
}
|
|
10
|
+
main().catch(e=>{console.error(e.message);process.exitCode=1;});
|
package/server.js
CHANGED
|
@@ -14,17 +14,31 @@ function persistGame() { fs.writeFileSync(gamePath + '.tmp', JSON.stringify(game
|
|
|
14
14
|
const readerOptions = { env: { ...process.env, PYTHONIOENCODING: "utf-8" }, stdio: ['ignore', 'pipe', 'inherit'] };
|
|
15
15
|
if (process.env.SUDO_UID) { readerOptions.uid = Number(process.env.SUDO_UID); readerOptions.gid = Number(process.env.SUDO_GID); }
|
|
16
16
|
let reader;
|
|
17
|
+
let youtube;
|
|
17
18
|
function startReader(){
|
|
18
|
-
|
|
19
|
+
const report = require('./reader-status').createReaderStatus();
|
|
20
|
+
console.log('Buscando teléfono por USB o Wi-Fi… Esperando la primera lectura de Tabletop Battles.');
|
|
21
|
+
let lastResponse=Date.now();
|
|
22
|
+
let stalled=false;
|
|
23
|
+
const watchdog=setInterval(()=>{
|
|
24
|
+
if(!stalled && Date.now()-lastResponse>30000){
|
|
25
|
+
stalled=true;console.error('El lector lleva más de 30 segundos sin responder. Comprueba el teléfono y la conexión; sigo esperando.');
|
|
26
|
+
}
|
|
27
|
+
},5000);
|
|
28
|
+
watchdog.unref();
|
|
19
29
|
reader = spawn(process.env.OVERLAY_PYTHON || 'python3', [path.join(__dirname, 'reader.py')], readerOptions);
|
|
20
30
|
readline.createInterface({ input: reader.stdout }).on('line', line => {
|
|
21
|
-
try {
|
|
31
|
+
try {
|
|
32
|
+
const incoming=JSON.parse(line);lastResponse=Date.now();
|
|
33
|
+
if(stalled){console.log('El lector volvió a responder.');stalled=false;}
|
|
34
|
+
game=mergeGame(game,incoming);persistGame();report(incoming);
|
|
35
|
+
} catch(e){console.error('Error procesando la lectura:',e.message);}
|
|
22
36
|
});
|
|
23
|
-
reader.on('error', e => console.error('
|
|
24
|
-
reader.on('exit', () => { game = mergeGame(game, {connected:false, error:'Lector detenido. Reinicia el servidor.'}); persistGame(); });
|
|
37
|
+
reader.on('error', e => {clearInterval(watchdog);console.error('No se pudo iniciar el lector:',e.message);});
|
|
38
|
+
reader.on('exit', (code,signal) => { clearInterval(watchdog);console.error(`Lector detenido (${signal||'código '+code}). Reinicia el servidor.`); game = mergeGame(game, {connected:false, error:'Lector detenido. Reinicia el servidor.'}); persistGame(); });
|
|
25
39
|
}
|
|
26
|
-
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => { reader?.kill(); process.exit(); });
|
|
27
|
-
process.on('exit', () => reader?.kill());
|
|
40
|
+
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => { reader?.kill(); youtube?.stop(); process.exit(); });
|
|
41
|
+
process.on('exit', () => {reader?.kill();youtube?.queue?.clear();});
|
|
28
42
|
const {syncStats} = require('./listhammer');
|
|
29
43
|
const root = path.join(__dirname, 'public');
|
|
30
44
|
const statePath = path.join(dataDir, 'overlay-state.json');
|
|
@@ -37,8 +51,17 @@ const defaultState = {
|
|
|
37
51
|
let state = defaultState;
|
|
38
52
|
try { state = { ...defaultState, ...JSON.parse(fs.readFileSync(statePath, 'utf8')) }; }
|
|
39
53
|
catch { /* First run: use the defaults. */ }
|
|
54
|
+
const {linkPlayers,validateUnits,validateBackground,validateName}=require('./rosters');
|
|
55
|
+
state=linkPlayers(state,state);
|
|
56
|
+
saveState();
|
|
57
|
+
const rostersPath=path.join(dataDir,'rosters.json');
|
|
58
|
+
let rosters={};
|
|
59
|
+
try{rosters=JSON.parse(fs.readFileSync(rostersPath,'utf8'));}catch{}
|
|
60
|
+
function rosterSnapshot(){return {players:{left:state.left,right:state.right},rosters};}
|
|
61
|
+
const rosterListeners=new Set();
|
|
62
|
+
function broadcastRosters(){const msg=`data: ${JSON.stringify(rosterSnapshot())}\n\n`;for(const res of rosterListeners)res.write(msg);}
|
|
40
63
|
const listeners = new Set();
|
|
41
|
-
function broadcast() { const msg = `data: ${JSON.stringify(state)}\n\n`; listeners.forEach(r => r.write(msg)); }
|
|
64
|
+
function broadcast() { const msg = `data: ${JSON.stringify(state)}\n\n`; listeners.forEach(r => r.write(msg)); broadcastRosters(); }
|
|
42
65
|
function send(res, code, type, body) { res.writeHead(code, { 'Content-Type': type }); res.end(body); }
|
|
43
66
|
function saveState() { const temporaryPath = `${statePath}.tmp`; fs.writeFileSync(temporaryPath, JSON.stringify(state, null, 2)); fs.renameSync(temporaryPath, statePath); }
|
|
44
67
|
|
|
@@ -55,11 +78,11 @@ const announcements = new Announcements({
|
|
|
55
78
|
});
|
|
56
79
|
const toastTimer = setInterval(() => { try { announcements.tick([...toastListeners.values()].includes('overlay')); } catch(e) { console.error('Mensajes: '+e.message); } }, 1000);
|
|
57
80
|
toastTimer.unref();
|
|
58
|
-
async function readToastBody(req) {
|
|
81
|
+
async function readToastBody(req,limit=8192) {
|
|
59
82
|
const chunks = []; let size = 0;
|
|
60
83
|
for await (const chunk of req) {
|
|
61
84
|
size += chunk.length;
|
|
62
|
-
if (size >
|
|
85
|
+
if (size > limit) throw new Error('El mensaje es demasiado largo.');
|
|
63
86
|
chunks.push(chunk);
|
|
64
87
|
}
|
|
65
88
|
return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
|
@@ -68,6 +91,25 @@ async function readToastBody(req) {
|
|
|
68
91
|
const server = http.createServer(async (req, res) => {
|
|
69
92
|
res.setHeader('Cache-Control','no-store');
|
|
70
93
|
const url = new URL(req.url, 'http://localhost');
|
|
94
|
+
if(url.pathname==='/rosters/events' && req.method==='GET'){
|
|
95
|
+
res.writeHead(200,{'Content-Type':'text/event-stream','Cache-Control':'no-cache',Connection:'keep-alive'});
|
|
96
|
+
rosterListeners.add(res);res.write(`data: ${JSON.stringify(rosterSnapshot())}\n\n`);
|
|
97
|
+
req.on('close',()=>rosterListeners.delete(res));return;
|
|
98
|
+
}
|
|
99
|
+
if(url.pathname==='/rosters' && req.method==='GET')return send(res,200,'application/json',JSON.stringify(rosterSnapshot()));
|
|
100
|
+
if(url.pathname.startsWith('/rosters/') && req.method==='POST'){
|
|
101
|
+
try{
|
|
102
|
+
const id=url.pathname.slice('/rosters/'.length);
|
|
103
|
+
if(![state.left.rosterId,state.right.rosterId].includes(id))return send(res,404,'application/json',JSON.stringify({error:'Jugador no encontrado. Recarga el panel.'}));
|
|
104
|
+
const input=await readToastBody(req,200000);
|
|
105
|
+
const old=rosters[id]||{revision:0,units:[]};
|
|
106
|
+
if(input.revision!==old.revision)return send(res,409,'application/json',JSON.stringify({error:'La lista cambió en otro panel. Recarga antes de guardar.'}));
|
|
107
|
+
const roster={revision:old.revision+1,name:validateName(input.name??old.name??''),units:validateUnits(input.units),backgroundUrl:validateBackground(input.backgroundUrl??old.backgroundUrl??'')};
|
|
108
|
+
const next={...rosters,[id]:roster};
|
|
109
|
+
fs.writeFileSync(rostersPath+'.tmp',JSON.stringify(next,null,2));fs.renameSync(rostersPath+'.tmp',rostersPath);rosters=next;
|
|
110
|
+
broadcastRosters();return send(res,200,'application/json',JSON.stringify(roster));
|
|
111
|
+
}catch(e){return send(res,400,'application/json',JSON.stringify({error:e.message}));}
|
|
112
|
+
}
|
|
71
113
|
if (url.pathname === '/toasts/events' && req.method === 'GET') {
|
|
72
114
|
res.writeHead(200, {'Content-Type':'text/event-stream', 'Cache-Control':'no-cache', Connection:'keep-alive'});
|
|
73
115
|
toastListeners.set(res, url.searchParams.get('client') === 'control' ? 'control' : 'overlay');
|
|
@@ -98,7 +140,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
98
140
|
if (url.pathname === '/state' && req.method === 'GET') return send(res, 200, 'application/json', JSON.stringify(state));
|
|
99
141
|
if (url.pathname === '/state' && req.method === 'POST') {
|
|
100
142
|
let body = ''; req.on('data', c => body += c); req.on('end', () => {
|
|
101
|
-
try { state = JSON.parse(body); saveState(); broadcast(); announcements.refresh(); send(res, 200, 'application/json', JSON.stringify({ ok: true })); }
|
|
143
|
+
try { state = linkPlayers(JSON.parse(body),state); saveState(); broadcast(); announcements.refresh(); send(res, 200, 'application/json', JSON.stringify({ ok: true })); }
|
|
102
144
|
catch { send(res, 400, 'application/json', JSON.stringify({ error: 'Invalid data' })); }
|
|
103
145
|
}); return;
|
|
104
146
|
}
|
|
@@ -109,4 +151,4 @@ const server = http.createServer(async (req, res) => {
|
|
|
109
151
|
send(res, 200, types[path.extname(filePath)] || 'application/octet-stream', fs.readFileSync(filePath));
|
|
110
152
|
});
|
|
111
153
|
server.on('error', e => { console.error(e.code==='EADDRINUSE' ? 'El puerto está ocupado. Detén el otro servidor o usa --port 8766.' : 'No se pudo iniciar: '+e.message); reader?.kill(); process.exitCode=1; });
|
|
112
|
-
server.listen(Number(process.env.PORT || 8765), '127.0.0.1', () => { startReader(); syncStats(dataDir).then(stats => { statsCache = stats; announcements.refresh(); }).catch(e => console.error('Listhammer: ' + e.message + ' Se conserva la última copia completa, si existe. El overlay sigue funcionando; se reintentará en el próximo inicio.')); console.log(`Panel: http://127.0.0.1:${process.env.PORT || 8765}\nOBS: http://127.0.0.1:${process.env.PORT || 8765}/overlay-art.html\nDatos: ${dataDir}\nCtrl+C para detener.`); });
|
|
154
|
+
server.listen(Number(process.env.PORT || 8765), '127.0.0.1', () => { startReader(); youtube=require('./youtube').startYouTube(dataDir); syncStats(dataDir).then(stats => { statsCache = stats; announcements.refresh(); }).catch(e => console.error('Listhammer: ' + e.message + ' Se conserva la última copia completa, si existe. El overlay sigue funcionando; se reintentará en el próximo inicio.')); console.log(`Panel: http://127.0.0.1:${process.env.PORT || 8765}\nOBS: http://127.0.0.1:${process.env.PORT || 8765}/overlay-art.html\nDatos: ${dataDir}\nCtrl+C para detener.`); });
|