@ing.jorgeu/wargaming-overlay 2.2.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/LICENSE +13 -0
- package/PUBLISHING.md +35 -0
- package/README.md +471 -0
- package/THIRD_PARTY_ASSETS.md +7 -0
- package/battle_reader.py +110 -0
- package/cli.js +78 -0
- package/diagnostics.js +38 -0
- package/game-store.js +17 -0
- package/package.json +46 -0
- package/platform.js +46 -0
- package/public/assets/deathwatch-texture.png +0 -0
- package/public/assets/faction-emblem-placeholder.png +0 -0
- package/public/assets/factions/adepta-sororitas.png +0 -0
- package/public/assets/factions/adeptus-custodes.png +0 -0
- package/public/assets/factions/adeptus-mechanicus.png +0 -0
- package/public/assets/factions/aeldari.png +0 -0
- package/public/assets/factions/agents-of-the-imperium.png +0 -0
- package/public/assets/factions/astra-militarum.png +0 -0
- package/public/assets/factions/black-templars.png +0 -0
- package/public/assets/factions/blood-angels.png +0 -0
- package/public/assets/factions/chaos-daemons.png +0 -0
- package/public/assets/factions/chaos-knights.png +0 -0
- package/public/assets/factions/chaos-space-marines.png +0 -0
- package/public/assets/factions/custom.png +0 -0
- package/public/assets/factions/dark-angels.png +0 -0
- package/public/assets/factions/death-guard.png +0 -0
- package/public/assets/factions/deathwatch.png +0 -0
- package/public/assets/factions/drukhari.png +0 -0
- package/public/assets/factions/emperors-children.png +0 -0
- package/public/assets/factions/genestealer-cults.png +0 -0
- package/public/assets/factions/grey-knights.png +0 -0
- package/public/assets/factions/imperial-knights.png +0 -0
- package/public/assets/factions/leagues-of-votann.png +0 -0
- package/public/assets/factions/necrons.png +0 -0
- package/public/assets/factions/orks.png +0 -0
- package/public/assets/factions/space-marines.png +0 -0
- package/public/assets/factions/space-wolves.png +0 -0
- package/public/assets/factions/tau-empire.png +0 -0
- package/public/assets/factions/thousand-sons.png +0 -0
- package/public/assets/factions/tyranids.png +0 -0
- package/public/assets/factions/world-eaters.png +0 -0
- package/public/assets/formations/disruption.png +0 -0
- package/public/assets/formations/priority-assets.png +0 -0
- package/public/assets/formations/purge-the-foe.png +0 -0
- package/public/assets/formations/reconnaissance.png +0 -0
- package/public/assets/formations/take-and-hold.png +0 -0
- package/public/assets/generic-texture.png +0 -0
- package/public/assets/thousand-sons-texture.png +0 -0
- package/public/battle-title.css +2 -0
- package/public/control.html +3 -0
- package/public/control.js +35 -0
- package/public/detachments.css +5 -0
- package/public/faction-medallion.css +6 -0
- package/public/formations.css +5 -0
- package/public/generic-texture.css +1 -0
- package/public/live-score.css +7 -0
- package/public/live-score.js +49 -0
- package/public/overlay-art.html +1 -0
- package/public/overlay-v4-pre-art.css +10 -0
- package/public/overlay-v4.css +10 -0
- package/public/overlay-wide.html +1 -0
- package/public/overlay.html +1 -0
- package/public/overlay.js +13 -0
- package/public/resolution.css +2 -0
- package/public/resolution.js +9 -0
- package/public/style.css +1 -0
- package/reader.py +36 -0
- package/server.js +67 -0
package/cli.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const fs=require('fs'),path=require('path'),os=require('os');
|
|
3
|
+
const {spawnSync}=require('child_process');
|
|
4
|
+
const {check,locate}=require('./diagnostics');
|
|
5
|
+
const {dataDirectory,installPlan,platformGuide}=require('./platform');
|
|
6
|
+
const version=require('./package.json').version;
|
|
7
|
+
const help=`wargaming-overlay ${version} — overlay local para OBS
|
|
8
|
+
|
|
9
|
+
wargaming-overlay start Inicia el overlay y lector
|
|
10
|
+
wargaming-overlay doctor Revisa Python, ADB y teléfono
|
|
11
|
+
wargaming-overlay setup Guía de configuración
|
|
12
|
+
wargaming-overlay setup --install-adb Instala ADB con el gestor del sistema
|
|
13
|
+
wargaming-overlay setup --dry-run Muestra el plan sin instalar ni consultar ADB
|
|
14
|
+
wargaming-overlay migrate CARPETA Copia el estado del overlay anterior
|
|
15
|
+
|
|
16
|
+
Opciones de start/doctor:
|
|
17
|
+
--port 8765 Puerto local (1024–65535); no necesita sudo
|
|
18
|
+
--serial SERIAL Selecciona un dispositivo
|
|
19
|
+
--interval 3 Pausa entre lecturas (segundos, mínimo 1)
|
|
20
|
+
--adb RUTA Ejecutable ADB
|
|
21
|
+
--python RUTA Python 3.8 o posterior
|
|
22
|
+
--data-dir RUTA Carpeta para guardar configuración y partida
|
|
23
|
+
|
|
24
|
+
Por defecto: ~/Library/Application Support/wargaming-overlay en macOS;
|
|
25
|
+
%LOCALAPPDATA%/wargaming-overlay en Windows; ~/.local/share/wargaming-overlay en Linux. Solo escucha en 127.0.0.1.
|
|
26
|
+
Ctrl+C detiene el servidor y lector. El último estado queda guardado.
|
|
27
|
+
`;
|
|
28
|
+
function main(){
|
|
29
|
+
const args=process.argv.slice(2);
|
|
30
|
+
if(args.includes('--help')||args.includes('-h')){console.log(help);return}
|
|
31
|
+
if(args.includes('--version')){console.log(version);return}
|
|
32
|
+
const command=args.shift()||'start',options={};let source;
|
|
33
|
+
const keys={'--port':'port','--serial':'serial','--interval':'interval','--adb':'adb','--python':'python','--data-dir':'dataDir'};
|
|
34
|
+
while(args.length){const a=args.shift();if(a==='--install-adb')options.install=true;else if(a==='--dry-run')options.dryRun=true;else if(keys[a]){const value=args.shift();if(!value||value.startsWith('--'))throw Error(`Falta el valor de ${a}`);options[keys[a]]=value}else if(command==='migrate'&&!source&&!a.startsWith('-'))source=a;else throw Error(`Argumento desconocido: ${a}. Usa --help.`)}
|
|
35
|
+
if(!['start','doctor','setup','migrate'].includes(command))throw Error('Comando desconocido. Usa --help.');
|
|
36
|
+
const dataDir=path.resolve(options.dataDir||dataDirectory());
|
|
37
|
+
if((options.install||options.dryRun)&&command!=='setup')throw Error('--install-adb y --dry-run solo se usan con setup.');
|
|
38
|
+
if(command==='migrate'){
|
|
39
|
+
if(!source)throw Error('Indica la carpeta anterior: wargaming-overlay migrate /ruta/al/overlay-anterior');
|
|
40
|
+
const copies=['overlay-state.json','game-state.json'].filter(f=>fs.existsSync(path.join(source,f)));
|
|
41
|
+
if(!copies.length)throw Error('La carpeta no contiene datos del overlay.');
|
|
42
|
+
for(const f of copies){JSON.parse(fs.readFileSync(path.join(source,f),'utf8'));if(fs.existsSync(path.join(dataDir,f)))throw Error(`Ya existe ${f} en ${dataDir}. No se sobrescribió nada.`)}
|
|
43
|
+
fs.mkdirSync(dataDir,{recursive:true});for(const f of copies)fs.copyFileSync(path.join(source,f),path.join(dataDir,f),fs.constants.COPYFILE_EXCL);
|
|
44
|
+
console.log(`Datos copiados a ${dataDir}. Ejecuta wargaming-overlay start.`);return;
|
|
45
|
+
}
|
|
46
|
+
if(command==='setup'){
|
|
47
|
+
console.log('1. Instala ADB (Android SDK Platform-Tools) y Python 3.8+.\n2. Activa Opciones de desarrollador y Depuración USB en Android.\n3. Conecta un cable de datos, desbloquea y acepta la autorización USB.\n4. Abre la ronda en la app y ejecuta wargaming-overlay doctor.\nGuía oficial: https://developer.android.com/studio/run/device');
|
|
48
|
+
console.log(platformGuide(process.platform));
|
|
49
|
+
const plan=installPlan(process.platform,locate);
|
|
50
|
+
if(plan)console.log('Instalación de ADB: '+(plan.elevated?'sudo ':'')+[plan.command,...plan.args].join(' '));
|
|
51
|
+
else console.log('Descarga oficial: https://developer.android.com/tools/releases/platform-tools');
|
|
52
|
+
if(options.dryRun)return;
|
|
53
|
+
if(options.install){
|
|
54
|
+
if(options.adb&&!locate(options.adb))throw Error('La ruta --adb no existe. Corrígela o elimina la opción para instalar ADB.');
|
|
55
|
+
if(locate(options.adb||'adb'))console.log('ADB ya está instalado; no se modificó.');
|
|
56
|
+
else {
|
|
57
|
+
if(!plan)throw Error('No se encontró un gestor compatible. Instala Platform-Tools desde el enlace oficial y ejecuta doctor.');
|
|
58
|
+
let command=locate(plan.command),args=plan.args;
|
|
59
|
+
if(plan.elevated&&process.getuid?.()!==0){if(!locate('sudo'))throw Error('Se necesitan permisos de administrador. Ejecuta el comando de instalación mostrado.');args=[command,...args];command=locate('sudo');}
|
|
60
|
+
const result=spawnSync(command,args,{stdio:'inherit',shell:false});
|
|
61
|
+
if(result.error||result.status!==0)throw Error('No se completó la instalación de ADB. Revisa el mensaje del gestor y vuelve a ejecutar setup.');
|
|
62
|
+
console.log('Instalación terminada. Si ADB aún no aparece, abre otra terminal y ejecuta doctor.');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const report=check(options);console.log(report.message);if(!report.ok)process.exitCode=1;return;
|
|
66
|
+
}
|
|
67
|
+
if(command==='doctor'){const r=check(options);console.log(`ADB: ${r.adb||'no encontrado'}\nPython: ${r.python||'no encontrado'}\n${r.message}`);if(!r.ok)process.exitCode=1;return}
|
|
68
|
+
if(process.getuid?.()===0)throw Error('Ejecuta sin sudo. El puerto predeterminado es 8765 y los datos se guardan en tu usuario.');
|
|
69
|
+
const port=Number(options.port||8765),interval=Number(options.interval||3);
|
|
70
|
+
if(!Number.isInteger(port)||port<1024||port>65535)throw Error('--port debe ser un entero entre 1024 y 65535.');
|
|
71
|
+
if(!Number.isFinite(interval)||interval<1||interval>60)throw Error('--interval debe estar entre 1 y 60 segundos.');
|
|
72
|
+
const r=check(options);if(!r.adb||!r.python||['PYTHON_VERSION','PYTHON_MISSING'].includes(r.code))throw Error(r.message);
|
|
73
|
+
if(!r.ok)console.error(r.message+'\nEl panel permanecerá disponible mientras conectas el teléfono.');
|
|
74
|
+
fs.mkdirSync(dataDir,{recursive:true});fs.accessSync(dataDir,fs.constants.W_OK);
|
|
75
|
+
process.env.OVERLAY_DATA_DIR=dataDir;process.env.PORT=String(port);process.env.OVERLAY_ADB=r.adb;process.env.OVERLAY_PYTHON=r.python;process.env.OVERLAY_SERIAL=options.serial||'';process.env.OVERLAY_INTERVAL=String(interval);
|
|
76
|
+
require('./server');
|
|
77
|
+
}
|
|
78
|
+
try{main()}catch(e){console.error('Error: '+e.message);process.exitCode=1}
|
package/diagnostics.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const fs=require('fs'),path=require('path'),os=require('os');
|
|
2
|
+
const {spawnSync}=require('child_process');
|
|
3
|
+
const {locate,platformGuide}=require('./platform');
|
|
4
|
+
function deviceStatus(output,serial){
|
|
5
|
+
const devices=output.split(/\r?\n/).map(l=>l.trim()).filter(l=>l&&!l.startsWith('List of')&&!l.startsWith('*')).map(l=>{const [id,state]=l.split(/\s+/);return {id,state}});
|
|
6
|
+
const none='ADB no detecta un teléfono. Conéctalo con un cable de datos y desbloquéalo. En Android: Ajustes → Acerca del teléfono → Información de software → pulsa 7 veces Número de compilación; después Opciones de desarrollador → Depuración USB. ADB no permite distinguir entre un cable desconectado y depuración desactivada.';
|
|
7
|
+
if(!devices.length)return {ok:false,code:'NO_DEVICE',message:none};
|
|
8
|
+
if(!serial&&devices.length>1)return {ok:false,code:'MULTIPLE_DEVICES',message:'Hay varios dispositivos. Usa --serial SERIAL. Disponibles: '+devices.map(d=>d.id).join(', ')};
|
|
9
|
+
const d=serial?devices.find(d=>d.id===serial):devices[0];
|
|
10
|
+
if(!d)return {ok:false,code:'DEVICE_NOT_FOUND',message:`No está conectado el dispositivo ${serial}.`};
|
|
11
|
+
if(d.state==='unauthorized')return {ok:false,code:'UNAUTHORIZED',message:'Teléfono conectado, pero sin autorización. Desbloquéalo y acepta «Permitir depuración USB» para este equipo. Si no aparece, desconecta y reconecta el cable.'};
|
|
12
|
+
if(d.state==='no')return {ok:false,code:'USB_PERMISSIONS',message:'Sin permisos USB. Revisa las reglas udev y los grupos del usuario. En Ubuntu: android-sdk-platform-tools-common y grupo plugdev; vuelve a iniciar sesión tras cambiar los grupos.'};
|
|
13
|
+
if(d.state==='offline')return {ok:false,code:'OFFLINE',message:'El teléfono aparece offline. Desbloquéalo y reconecta el cable USB.'};
|
|
14
|
+
if(d.state!=='device')return {ok:false,code:'DEVICE_UNAVAILABLE',message:`Estado del teléfono: ${d.state}. Abre Android normalmente y comprueba la conexión USB.`};
|
|
15
|
+
return {ok:true,code:'READY',message:'Teléfono conectado y autorizado.',serial:d.id};
|
|
16
|
+
}
|
|
17
|
+
function findPython(explicit){
|
|
18
|
+
const commands=explicit?[explicit]:process.platform==='win32'?['python3','python','py']:['python3','python'];
|
|
19
|
+
for(const command of commands){
|
|
20
|
+
const exe=locate(command);if(!exe)continue;
|
|
21
|
+
const args=(!explicit&&command==='py')?['-3']:[];
|
|
22
|
+
const r=spawnSync(exe,[...args,'-c','import sys; assert sys.version_info >= (3,8); print(sys.executable)'],{encoding:'utf8',timeout:10000,windowsHide:true});
|
|
23
|
+
if(r.status===0&&r.stdout.trim())return r.stdout.trim();
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
function check(options={}){
|
|
28
|
+
const adb=locate(options.adb||'adb');
|
|
29
|
+
const python=findPython(options.python);
|
|
30
|
+
if(!adb)return {ok:false,code:'ADB_MISSING',message:'No se encontró ADB. Ejecuta wargaming-overlay setup --install-adb o instala Android SDK Platform-Tools.',python};
|
|
31
|
+
if(!python)return {ok:false,code:'PYTHON_MISSING',message:'No se encontró Python 3.8 o posterior. '+platformGuide(process.platform),adb};
|
|
32
|
+
const py=spawnSync(python,['-c','import sys; assert sys.version_info >= (3,8)'],{encoding:'utf8',timeout:10000});
|
|
33
|
+
if(py.status!==0)return {ok:false,code:'PYTHON_VERSION',message:'Se necesita Python 3.8 o posterior. Usa --python /ruta/a/python3.',adb,python};
|
|
34
|
+
const result=spawnSync(adb,['devices','-l'],{encoding:'utf8',timeout:15000});
|
|
35
|
+
if(result.error||result.status!==0)return {ok:false,code:'ADB_ERROR',message:'ADB no pudo consultar el teléfono: '+(result.error?.message||(result.stderr||'').trim()).slice(0,600),adb,python};
|
|
36
|
+
return {...deviceStatus(result.stdout,options.serial),adb,python};
|
|
37
|
+
}
|
|
38
|
+
module.exports={locate,deviceStatus,check,findPython};
|
package/game-store.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const emptyGame=()=>({players:[],connected:false,updatedAt:null,error:null});
|
|
2
|
+
function mergeGame(previous,incoming){
|
|
3
|
+
if(!incoming.connected || !Array.isArray(incoming.players) || incoming.players.length!==2)
|
|
4
|
+
return {...previous,connected:false,error:incoming.error||'Sin lectura válida'};
|
|
5
|
+
const same=previous.round===incoming.round && previous.players?.length===2 && incoming.players.every(p=>previous.players.some(old=>old.name===p.name));
|
|
6
|
+
const players=incoming.players.map(p=>{
|
|
7
|
+
const old=same?previous.players.find(old=>old.name===p.name):null;
|
|
8
|
+
const merged={...p,observedAt:{}};
|
|
9
|
+
for(const key of ['cp','primary','secondary','secondaries','primaryObjectives']){
|
|
10
|
+
if(p[key]!=null)merged.observedAt[key]=incoming.updatedAt;
|
|
11
|
+
else {merged[key]=old?.[key]??null;merged.observedAt[key]=old?.observedAt?.[key]??null;}
|
|
12
|
+
}
|
|
13
|
+
return merged;
|
|
14
|
+
});
|
|
15
|
+
return {...incoming,players};
|
|
16
|
+
}
|
|
17
|
+
module.exports={emptyGame,mergeGame};
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ing.jorgeu/wargaming-overlay",
|
|
3
|
+
"version": "2.2.0",
|
|
4
|
+
"description": "Overlay local para OBS con lectura Android por ADB",
|
|
5
|
+
"bin": {
|
|
6
|
+
"wargaming-overlay": "cli.js"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"start": "node cli.js start",
|
|
10
|
+
"test": "node --test",
|
|
11
|
+
"prepublishOnly": "npm test"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"cli.js",
|
|
18
|
+
"diagnostics.js",
|
|
19
|
+
"server.js",
|
|
20
|
+
"reader.py",
|
|
21
|
+
"battle_reader.py",
|
|
22
|
+
"game-store.js",
|
|
23
|
+
"public",
|
|
24
|
+
"README.md",
|
|
25
|
+
"platform.js",
|
|
26
|
+
"PUBLISHING.md",
|
|
27
|
+
"THIRD_PARTY_ASSETS.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"license": "0BSD",
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"os": [
|
|
35
|
+
"darwin",
|
|
36
|
+
"win32",
|
|
37
|
+
"linux"
|
|
38
|
+
],
|
|
39
|
+
"keywords": [
|
|
40
|
+
"obs",
|
|
41
|
+
"overlay",
|
|
42
|
+
"wargaming",
|
|
43
|
+
"adb",
|
|
44
|
+
"cli"
|
|
45
|
+
]
|
|
46
|
+
}
|
package/platform.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const fs=require('fs'),path=require('path'),os=require('os');
|
|
2
|
+
function context(overrides={}){return {platform:process.platform,env:process.env,home:os.homedir(),...overrides}}
|
|
3
|
+
function dataDirectory(overrides={}){
|
|
4
|
+
const {platform,env,home}=context(overrides),p=platform==='win32'?path.win32:path.posix;
|
|
5
|
+
if(platform==='win32')return p.join(env.LOCALAPPDATA||p.join(home,'AppData','Local'),'wargaming-overlay');
|
|
6
|
+
if(platform==='darwin')return p.join(home,'Library','Application Support','wargaming-overlay');
|
|
7
|
+
return p.join(env.XDG_DATA_HOME||p.join(home,'.local','share'),'wargaming-overlay');
|
|
8
|
+
}
|
|
9
|
+
function candidates(command,overrides={}){
|
|
10
|
+
const {platform,env,home}=context(overrides),win=platform==='win32',p=win?path.win32:path.posix;
|
|
11
|
+
const explicit=/[\\/]/.test(command)||p.isAbsolute(command);
|
|
12
|
+
const names=win&&!p.extname(command)?[command+'.exe',command+'.com',command]:[command];
|
|
13
|
+
// Only native executables: never shell out through a .cmd or .bat wrapper.
|
|
14
|
+
const searchPath=env.PATH||env.Path||env.path||'';
|
|
15
|
+
const paths=explicit?names:searchPath.split(win?';':':').filter(Boolean).flatMap(dir=>names.map(n=>p.join(dir.replace(/^"|"$/g,''),n)));
|
|
16
|
+
if(!explicit&&command==='adb'){
|
|
17
|
+
const sdkRoots=[env.ANDROID_HOME,env.ANDROID_SDK_ROOT,win&&p.join(env.LOCALAPPDATA||p.join(home,'AppData','Local'),'Android','Sdk'),!win&&p.join(home,'Library','Android','sdk'),!win&&p.join(home,'Android','Sdk')].filter(Boolean);
|
|
18
|
+
for(const sdk of sdkRoots)paths.push(p.join(sdk,'platform-tools',win?'adb.exe':'adb'));
|
|
19
|
+
if(win&&env.LOCALAPPDATA)paths.push(p.join(env.LOCALAPPDATA,'Microsoft','WinGet','Links','adb.exe'));
|
|
20
|
+
if(!win)paths.push('/opt/homebrew/bin/adb','/usr/local/bin/adb');
|
|
21
|
+
}
|
|
22
|
+
if(!explicit&&command==='brew'&&platform==='darwin')paths.push('/opt/homebrew/bin/brew','/usr/local/bin/brew');
|
|
23
|
+
return [...new Set(paths)];
|
|
24
|
+
}
|
|
25
|
+
function locate(command,overrides={}){
|
|
26
|
+
const {platform}=context(overrides);
|
|
27
|
+
for(const file of candidates(command,overrides))try{if(!fs.statSync(file).isFile())continue;fs.accessSync(file,platform==='win32'?fs.constants.F_OK:fs.constants.X_OK);return file}catch{}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
function installPlan(platform,has){
|
|
31
|
+
if(platform==='darwin'&&has('brew'))return {command:'brew',args:['install','--cask','android-platform-tools']};
|
|
32
|
+
if(platform==='win32'&&has('winget'))return {command:'winget',args:['install','--exact','--id','Google.PlatformTools','--source','winget']};
|
|
33
|
+
if(platform==='linux'){
|
|
34
|
+
if(has('apt-get'))return {command:'apt-get',args:['install','adb','android-sdk-platform-tools-common'],elevated:true};
|
|
35
|
+
if(has('dnf'))return {command:'dnf',args:['install','android-tools'],elevated:true};
|
|
36
|
+
if(has('pacman'))return {command:'pacman',args:['-S','android-tools','android-udev'],elevated:true};
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
function platformGuide(platform){
|
|
41
|
+
if(platform==='win32')return 'Windows: instala ADB con WinGet o descarga Platform-Tools de Google. Si falta Python, instala Python 3 desde python.org. Si el teléfono no aparece, revisa el controlador USB del fabricante. Después de instalar, abre otra terminal para actualizar PATH.';
|
|
42
|
+
if(platform==='darwin')return 'macOS: ADB con Homebrew (brew install --cask android-platform-tools). Python: brew install python, o python.org. No se requieren controladores USB adicionales.';
|
|
43
|
+
if(platform==='linux')return 'Linux: ADB con apt-get (Debian/Ubuntu), dnf (Fedora) o pacman (Arch). Instala Python 3 con el gestor de tu distribución. Si aparece «no permissions», revisa reglas udev y permisos USB. En Ubuntu comprueba el grupo plugdev y vuelve a iniciar sesión después de cambiarlo.';
|
|
44
|
+
return 'Plataforma sin instalador automático. Instala Python 3 y Android SDK Platform-Tools manualmente.';
|
|
45
|
+
}
|
|
46
|
+
module.exports={dataDirectory,candidates,locate,installPlan,platformGuide};
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
.battle-title { position: absolute; top: 8px; left: 50%; z-index: 5; max-width: 680px; transform: translateX(-50%); padding: 10px 26px; overflow: hidden; border: 1px solid rgba(232, 228, 216, .7); border-radius: 0 0 10px 10px; background: rgba(7, 10, 14, .92); box-shadow: 0 5px 16px #000c; color: #fffaf0; font-size: 32px; letter-spacing: .1em; line-height: 1.15; text-align: center; text-overflow: ellipsis; text-shadow: 0 2px 5px #000; white-space: nowrap; }
|
|
2
|
+
.battle-title:empty { display: none; }
|
|
@@ -0,0 +1,3 @@
|
|
|
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
|
+
<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><button>Actualizar overlay</button><p id="saved" aria-live="polite"></p></form><section><h2>Datos de la partida</h2><p id="phone-status" aria-live="polite"></p><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><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="control.js"></script></body></html>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const factions = {
|
|
2
|
+
'Imperium': [
|
|
3
|
+
'Adepta Sororitas', 'Adeptus Custodes', 'Adeptus Mechanicus', 'Agents of the Imperium',
|
|
4
|
+
'Astra Militarum', 'Black Templars', 'Blood Angels', 'Dark Angels', 'Deathwatch',
|
|
5
|
+
'Grey Knights', 'Imperial Knights', 'Space Marines', 'Space Wolves'
|
|
6
|
+
],
|
|
7
|
+
'Chaos': [
|
|
8
|
+
'Chaos Daemons', 'Chaos Knights', 'Chaos Space Marines', 'Death Guard',
|
|
9
|
+
'Emperor\'s Children', 'Thousand Sons', 'World Eaters'
|
|
10
|
+
],
|
|
11
|
+
'Xenos': [
|
|
12
|
+
'Aeldari', 'Drukhari', 'Genestealer Cults', 'Leagues of Votann', 'Necrons',
|
|
13
|
+
'Orks', 'T\'au Empire', 'Tyranids'
|
|
14
|
+
],
|
|
15
|
+
'Other': ['Custom']
|
|
16
|
+
};
|
|
17
|
+
let state;
|
|
18
|
+
const players = document.querySelector('#players');
|
|
19
|
+
const formations = ['Take and Hold', 'Purge the Foe', 'Disruption', 'Reconnaissance', 'Priority Assets'];
|
|
20
|
+
function card(side, label, player) { const options = Object.entries(factions).map(([group, names]) => `<optgroup label="${group}">${names.map(name => `<option ${name === player.faction ? 'selected' : ''}>${name}</option>`).join('')}</optgroup>`).join(''); const formation = formations.includes(player.formation) ? player.formation : 'Take and Hold'; const detachments = Array.isArray(player.detachments) ? player.detachments : []; return `<fieldset><legend>${label}</legend><label>Nombre<input data-side="${side}" data-key="name" maxlength="80" value="${player.name}"></label><label>Nombre en la app (opcional)<input data-side="${side}" data-key="appName" maxlength="80" value="${(player.appName || '').replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<')}"></label><label>Facción<select data-side="${side}" data-key="faction">${options}</select></label><label>Disposición<select data-side="${side}" data-key="formation">${formations.map(name => `<option ${name === formation ? 'selected' : ''}>${name}</option>`).join('')}</select></label><label>Destacamento 1 (opcional)<input data-side="${side}" data-detachment="0" maxlength="42" value="${detachments[0] || ''}"></label><label>Destacamento 2 (opcional)<input data-side="${side}" data-detachment="1" maxlength="42" value="${detachments[1] || ''}"></label><label>Destacamento 3 (opcional)<input data-side="${side}" data-detachment="2" maxlength="42" value="${detachments[2] || ''}"></label><label>Color de fondo<input data-side="${side}" data-key="color" type="color" value="${player.color}"></label></fieldset>`; }
|
|
21
|
+
async function init(){ state=await fetch('/state').then(r=>r.json()); document.querySelector('#title').value=state.title; players.innerHTML=card('left','Jugador izquierda',state.left)+card('right','Jugador derecha',state.right); document.querySelectorAll('input[type=color]').forEach(input=>input.closest('label').remove()); }
|
|
22
|
+
document.querySelector('#form').addEventListener('submit',async e=>{e.preventDefault(); state.title=document.querySelector('#title').value; document.querySelectorAll('[data-side][data-key]').forEach(i=>state[i.dataset.side][i.dataset.key]=i.value); ['left','right'].forEach(side=>{state[side].detachments=[...document.querySelectorAll(`[data-side="${side}"][data-detachment]`)].map(i=>i.value.trim()).filter(Boolean);}); await fetch('/state',{method:'POST',body:JSON.stringify(state)}); document.querySelector('#saved').textContent='Overlay actualizado.'; setTimeout(()=>document.querySelector('#saved').textContent='',1800);}); init();
|
|
23
|
+
|
|
24
|
+
document.querySelector('#reset-game').addEventListener('click',async()=>{
|
|
25
|
+
const button=document.querySelector('#reset-game');button.disabled=true;
|
|
26
|
+
try {const r=await fetch('/game/reset',{method:'POST'});if(!r.ok)throw Error();document.querySelector('#reset-status').textContent='Datos restablecidos. Esperando una nueva lectura.';}
|
|
27
|
+
catch {document.querySelector('#reset-status').textContent='No se pudo restablecer. Comprueba que el servidor esté actualizado y funcionando.';}
|
|
28
|
+
finally{button.disabled=false}
|
|
29
|
+
});
|
|
30
|
+
async function phoneStatus(){try{const r=await fetch('/game-state.json',{cache:'no-store'});const g=await r.json();document.querySelector('#phone-status').textContent=g.connected?'Teléfono conectado.':(g.error||'Sin conexión.')+(g.players?.length?' Se conserva la última lectura.':'');}catch{document.querySelector('#phone-status').textContent='Servidor no disponible.'}finally{setTimeout(phoneStatus,3000)}}phoneStatus();
|
|
31
|
+
|
|
32
|
+
document.querySelector('#obs-url').textContent=location.origin+'/overlay-art.html';
|
|
33
|
+
|
|
34
|
+
function resolutionHelp(){const [w,h]=document.querySelector('#resolution-preset').value.split('x');document.querySelector('#resolution-help').textContent=`En OBS configura Ancho ${w} y Alto ${h} en la fuente Navegador. El overlay se adapta automáticamente; no cambia la resolución de tu transmisión.`;}
|
|
35
|
+
document.querySelector('#resolution-preset').addEventListener('change',resolutionHelp);resolutionHelp();
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
.detachment-list { position: absolute; top: 181px; display: flex; flex-wrap: nowrap; gap: 12px; width: max-content; }
|
|
2
|
+
.detachment-list.left { left: 382px; }
|
|
3
|
+
.detachment-list.right { right: 382px; justify-content: flex-end; }
|
|
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: nowrap; }
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
.team { position: relative; }
|
|
2
|
+
.faction-icon { position: absolute; top: 50%; width: 128px; height: 128px; transform: translateY(-50%); border: 3px solid rgba(232, 228, 216, .82); border-radius: 12px; background: linear-gradient(135deg, rgba(20, 24, 30, .92), rgba(4, 6, 9, .96)); box-shadow: 0 0 0 6px rgba(0, 0, 0, .3), 0 5px 16px #000b; z-index: 2; }
|
|
3
|
+
.faction-icon::after { content: ''; position: absolute; inset: 9px; border: 1px solid rgba(232, 228, 216, .5); border-radius: 6px; }
|
|
4
|
+
.faction-icon img { width: 100%; height: 100%; object-fit: contain; padding: 8px; position: relative; z-index: 1; }
|
|
5
|
+
.team.left .faction-icon { right: 110px; }
|
|
6
|
+
.team.right .faction-icon { left: 110px; }
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
.formation { --formation: #a88742; position: absolute; top: 170px; width: 350px; height: 62px; display: flex; align-items: center; gap: 12px; padding: 8px 18px; background: color-mix(in srgb, var(--formation), #10131a 55%); border: 1px solid color-mix(in srgb, var(--formation), white 30%); box-shadow: 0 5px 12px #0007; font-size: 25px; letter-spacing: .05em; }
|
|
2
|
+
.formation.left { left: 20px; border-radius: 0 0 10px 0; }
|
|
3
|
+
.formation.right { right: 20px; justify-content: flex-end; border-radius: 0 0 0 10px; }
|
|
4
|
+
.formation-mark { width: 46px; height: 46px; border: 2px solid currentColor; border-radius: 7px; display: grid; place-items: center; overflow: hidden; flex: 0 0 auto; background: rgba(0, 0, 0, .22); }
|
|
5
|
+
.formation-mark img { width: 100%; height: 100%; object-fit: contain; padding: 2px; }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.team { background-image: linear-gradient(115deg, color-mix(in srgb, var(--team), #111 28%), color-mix(in srgb, var(--team), transparent 55%)), url('assets/generic-texture.png'); background-size: cover; background-position: center; }
|
|
@@ -0,0 +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:28px;line-height:1.55;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
|
+
|
|
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:22px;line-height:1.3;margin:7px 0}.live-missions{width:400px}
|
|
4
|
+
|
|
5
|
+
.primary-objective{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.objective-name{flex:1}.objective-check,.objective-counter{flex:0 0 auto;min-width:27px;height:27px;display:inline-flex;align-items:center;justify-content:center;font-size:23px;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
|
+
|
|
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}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
const normalize=s=>(s||'').normalize('NFKC').trim().replace(/\s+/g,' ').toLocaleLowerCase();
|
|
3
|
+
const make=(tag,text,cls)=>{const n=document.createElement(tag);n.textContent=text;if(cls)n.className=cls;return n};
|
|
4
|
+
const panel=make('section','','live-score');document.querySelector('#overlay-stage').append(panel);
|
|
5
|
+
let settings=null,game=null;
|
|
6
|
+
function renderLive(){
|
|
7
|
+
panel.replaceChildren();if(!settings)return;
|
|
8
|
+
const matches=['left','right'].map(side=>{const key=normalize(settings[side].appName||settings[side].name);const found=(game?.players||[]).filter(p=>normalize(p.name)===key);return found.length===1?found[0]:null});
|
|
9
|
+
if(matches[0]===matches[1])matches.fill(null);
|
|
10
|
+
const round=make('div',matches.every(Boolean)?`RONDA ${game.round}`:'RONDA —','live-round');panel.append(round);
|
|
11
|
+
['left','right'].forEach((side,i)=>{
|
|
12
|
+
const p=matches[i],card=make('article','',`live-player ${side}`);
|
|
13
|
+
const value=v=>v==null?'—':typeof v==='object'?`${v.score}/${v.max}`:v;
|
|
14
|
+
card.append(make('div',`${value(p?.score)} VP`,'live-vp'));
|
|
15
|
+
card.append(make('div',`${value(p?.cp)} CP`,'live-cp'));
|
|
16
|
+
const missions=make('div','','live-missions');card.append(missions);
|
|
17
|
+
if(p?.primary){
|
|
18
|
+
const primary=make('section','','live-primary');
|
|
19
|
+
primary.append(make('div',`PRIMARIA · ${value(p.primary)}`,'mission-heading'));
|
|
20
|
+
for(const objective of p.primaryObjectives||[]){
|
|
21
|
+
const row=make('div','','primary-objective');
|
|
22
|
+
row.append(make('span',objective.name,'objective-name'));
|
|
23
|
+
const hasCounter=objective.counter!=null;
|
|
24
|
+
const marker=make('span',hasCounter?String(objective.counter):objective.checked===true?'✓':objective.checked===false?'':'—',hasCounter?'objective-counter':'objective-check');
|
|
25
|
+
marker.setAttribute('aria-label',hasCounter?`Contador: ${objective.counter}`:objective.checked===true?'Cumplida':objective.checked===false?'Pendiente':'Sin lectura');
|
|
26
|
+
if(objective.checked===true)marker.classList.add('completed');
|
|
27
|
+
row.append(marker);primary.append(row);
|
|
28
|
+
}
|
|
29
|
+
missions.append(primary);
|
|
30
|
+
}
|
|
31
|
+
if(p?.secondaries?.length)missions.append(make('div','SECUNDARIAS','mission-heading'));
|
|
32
|
+
for(const m of p?.secondaries||[])missions.append(make('div',`${m.name} ${m.score}/${m.max}`,'live-mission'));
|
|
33
|
+
if(!p)card.append(make('div','Sin jugador vinculado','live-note'));
|
|
34
|
+
panel.append(card);
|
|
35
|
+
const rect=document.querySelector('.team.'+side+' .faction-icon').getBoundingClientRect();
|
|
36
|
+
const stage=document.querySelector('#overlay-stage').getBoundingClientRect();
|
|
37
|
+
const scale=stage.width/2560;
|
|
38
|
+
const icon={left:(rect.left-stage.left)/scale,right:(rect.right-stage.left)/scale,top:(rect.top-stage.top)/scale,width:rect.width/scale,height:rect.height/scale};
|
|
39
|
+
const vp=card.querySelector('.live-vp'),cp=card.querySelector('.live-cp');
|
|
40
|
+
vp.style.top=(icon.top+icon.height/2)+'px';
|
|
41
|
+
if(side==='left')vp.style.right=(2560-icon.left+12)+'px';else vp.style.left=(icon.right+12)+'px';
|
|
42
|
+
cp.style.left=(icon.left+icon.width/2)+'px';
|
|
43
|
+
cp.style.top='185px';
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
window.addEventListener('resize',renderLive);
|
|
47
|
+
new EventSource('/events').onmessage=e=>{settings=JSON.parse(e.data);renderLive()};
|
|
48
|
+
async function poll(){try{const r=await fetch('/game-state.json',{cache:'no-store',signal:AbortSignal.timeout(2500)});if(r.ok){game=await r.json();renderLive()}}catch{}finally{setTimeout(poll,1500)}}poll();
|
|
49
|
+
})();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<!doctype html><html><head><meta charset="utf-8"><title>Battle Report Overlay</title><link rel="stylesheet" href="overlay-v4-pre-art.css"><link rel="stylesheet" href="generic-texture.css?v=1"><link rel="stylesheet" href="faction-medallion.css?v=1"><link rel="stylesheet" href="formations.css?v=1"><link rel="stylesheet" href="battle-title.css?v=1"><link rel="stylesheet" href="detachments.css?v=1"><link rel="stylesheet" href="live-score.css?v=1"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="resolution.css"></head><body class="overlay"><main id="overlay-stage"><div class="battle-title" id="battleTitle"></div><section class="upper"><article class="team left"><div class="faction" id="leftFaction"></div><div class="name fit" id="leftName"></div><div class="faction-icon"><img src="assets/faction-emblem-placeholder.png" alt=""></div></article><div class="versus">VS</div><article class="team right"><div class="faction" id="rightFaction"></div><div class="name fit" id="rightName"></div><div class="faction-icon"><img src="assets/faction-emblem-placeholder.png" alt=""></div></article></section><div class="formation left" id="leftFormation"><span class="formation-mark">I</span><span>Disposición I</span></div><div class="formation right" id="rightFormation"><span>Disposición I</span><span class="formation-mark">I</span></div><div class="detachment-list left" id="leftDetachments"></div><div class="detachment-list right" id="rightDetachments"></div></main><script src="resolution.js"></script><script src="overlay.js?detachments=1"></script><script src="live-score.js?v=1"></script></body></html>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
* { box-sizing: border-box; }
|
|
2
|
+
body { margin: 0; font-family: Arial, sans-serif; }
|
|
3
|
+
.overlay { width: 100vw; height: 100vh; overflow: hidden; background: transparent; color: white; font-weight: bold; text-transform: uppercase; }
|
|
4
|
+
.upper { position: absolute; top: 20px; left: 20px; right: 20px; display: flex; align-items: center; gap: 20px; }
|
|
5
|
+
.team { --team: #333; flex: 1; min-width: 0; height: 150px; padding: 20px 56px; background: linear-gradient(115deg, color-mix(in srgb, var(--team), #111 40%), color-mix(in srgb, var(--team), transparent 24%)); border: 1px solid color-mix(in srgb, var(--team), white 25%); box-shadow: 0 5px 16px #0006; backdrop-filter: blur(5px); }
|
|
6
|
+
.team.left { clip-path: polygon(0 0, 100% 0, 94% 100%, 0 100%); }
|
|
7
|
+
.team.right { text-align: right; clip-path: polygon(6% 0, 100% 0, 100% 100%, 0 100%); }
|
|
8
|
+
.faction { font-size: 32px; letter-spacing: .1em; opacity: .95; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
9
|
+
.name { height: 112px; line-height: 112px; margin-top: -12px; white-space: nowrap; overflow: hidden; text-shadow: 2px 3px 4px #000; }
|
|
10
|
+
.versus { flex: 0 0 76px; text-align: center; font-size: 44px; letter-spacing: .08em; text-shadow: 2px 3px 4px #000; }
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
* { box-sizing: border-box; }
|
|
2
|
+
body { margin: 0; font-family: Arial, sans-serif; }
|
|
3
|
+
.overlay { width: 100vw; height: 100vh; overflow: hidden; background: transparent; color: white; font-weight: bold; text-transform: uppercase; }
|
|
4
|
+
.upper { position: absolute; top: 20px; left: 20px; right: 20px; display: flex; align-items: center; gap: 20px; }
|
|
5
|
+
.team { --team: #333; --art: none; flex: 1; min-width: 0; height: 150px; padding: 20px 56px; background-image: linear-gradient(115deg, color-mix(in srgb, var(--team), #111 15%), color-mix(in srgb, var(--team), transparent 70%)), var(--art); background-size: cover; background-position: center; border: 1px solid color-mix(in srgb, var(--team), white 25%); box-shadow: 0 5px 16px #0006; backdrop-filter: blur(3px); }
|
|
6
|
+
.team.left { clip-path: polygon(0 0, 100% 0, 94% 100%, 0 100%); }
|
|
7
|
+
.team.right { text-align: right; clip-path: polygon(6% 0, 100% 0, 100% 100%, 0 100%); }
|
|
8
|
+
.faction { font-size: 32px; letter-spacing: .1em; opacity: .95; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
9
|
+
.name { height: 112px; line-height: 112px; margin-top: -12px; white-space: nowrap; overflow: hidden; text-shadow: 2px 3px 4px #000; }
|
|
10
|
+
.versus { flex: 0 0 76px; text-align: center; font-size: 44px; letter-spacing: .08em; text-shadow: 2px 3px 4px #000; }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<!doctype html><html><head><meta charset="utf-8"><title>Battle Report Overlay Wide</title><link rel="stylesheet" href="overlay-v4.css"></head><body class="overlay"><section class="upper"><article class="team left"><div class="faction" id="leftFaction"></div><div class="name fit" id="leftName"></div></article><div class="versus">VS</div><article class="team right"><div class="faction" id="rightFaction"></div><div class="name fit" id="rightName"></div></article></section><script src="overlay.js?v=4"></script></body></html>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<!doctype html><html><head><meta charset="utf-8"><title>Battle Report Overlay</title><link rel="stylesheet" href="overlay-v4.css"></head><body class="overlay"><section class="upper"><article class="team left"><div class="faction" id="leftFaction"></div><div class="name fit" id="leftName"></div></article><div class="versus">VS</div><article class="team right"><div class="faction" id="rightFaction"></div><div class="name fit" id="rightName"></div></article></section><script src="overlay.js?v=4"></script></body></html>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const factionArt = { 'Thousand Sons': 'assets/thousand-sons-texture.png', 'Deathwatch': 'assets/deathwatch-texture.png' };
|
|
2
|
+
const teamColors = { left: '#245eb8', right: '#7d1520' };
|
|
3
|
+
const formationStyle = {
|
|
4
|
+
'Take and Hold': ['take-and-hold', '#126634'],
|
|
5
|
+
'Purge the Foe': ['purge-the-foe', '#8d2425'],
|
|
6
|
+
'Disruption': ['disruption', '#07557d'],
|
|
7
|
+
'Reconnaissance': ['reconnaissance', '#087e7e'],
|
|
8
|
+
'Priority Assets': ['priority-assets', '#a78b18']
|
|
9
|
+
};
|
|
10
|
+
const slugify = value => value.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/['’]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
11
|
+
function fit(el){ let size=Math.round(2560 * 0.035); el.style.fontSize=size+'px'; while(el.scrollWidth>el.clientWidth && size>20){ el.style.fontSize=--size+'px'; } }
|
|
12
|
+
function render(s){ document.querySelector('#battleTitle').textContent = s.title || ''; ['left','right'].forEach(side=>{const p=s[side], team=document.querySelector('.team.'+side), formation=document.querySelector('#'+side+'Formation'), detachmentList=document.querySelector('#'+side+'Detachments'), selected=formationStyle[p.formation] || formationStyle['Take and Hold']; document.querySelector('#'+side+'Name').textContent=p.name; document.querySelector('#'+side+'Faction').textContent=p.faction; team.style.setProperty('--team',teamColors[side]); team.style.setProperty('--art', factionArt[p.faction] ? `url('${factionArt[p.faction]}')` : 'none'); team.querySelector('.faction-icon img').src=`assets/factions/${slugify(p.faction)}.png`; formation.style.setProperty('--formation', selected[1]); formation.querySelector('.formation-mark').innerHTML=`<img src="assets/formations/${selected[0]}.png?v=5" alt="">`; formation.querySelector('span:not(.formation-mark)').textContent=p.formation || 'Take and Hold'; detachmentList.innerHTML=(Array.isArray(p.detachments) ? p.detachments : []).filter(Boolean).slice(0,3).map(name=>`<span class="detachment"></span>`).join(''); [...detachmentList.children].forEach((item,index)=>item.textContent=p.detachments[index]); fit(document.querySelector('#'+side+'Name'));}); }
|
|
13
|
+
new EventSource('/events').onmessage=e=>render(JSON.parse(e.data)); window.addEventListener('resize',()=>document.querySelectorAll('.fit').forEach(fit));
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
const stage=document.querySelector('#overlay-stage');
|
|
3
|
+
function resize(){
|
|
4
|
+
const scale=Math.min(window.innerWidth/2560,window.innerHeight/1440);
|
|
5
|
+
stage.style.transform=`scale(${scale})`;
|
|
6
|
+
stage.style.left=`${(window.innerWidth-2560*scale)/2}px`;
|
|
7
|
+
}
|
|
8
|
+
resize();window.addEventListener('resize',resize);
|
|
9
|
+
})();
|
package/public/style.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
*{box-sizing:border-box}body{margin:0;font-family:Arial,sans-serif}.control{min-height:100vh;background:#11151d;color:#e8e4d8;padding:42px}.control main{max-width:880px;margin:auto}.eyebrow{color:#d9ad4a;letter-spacing:.16em;font-size:12px;font-weight:bold}.control h1{margin:4px 0;font-size:34px}.control form{margin-top:30px}.control label{display:block;margin:15px 0;font-size:13px;font-weight:bold;letter-spacing:.04em}.control input,.control select{display:block;width:100%;margin-top:6px;padding:11px;background:#202936;border:1px solid #536171;border-radius:5px;color:#fff;font-size:16px}.control input[type=color]{height:44px;padding:3px}.control section{display:grid;grid-template-columns:1fr 1fr;gap:18px}.control fieldset{border:1px solid #536171;border-radius:8px;padding:14px}.control legend{color:#d9ad4a}.control button{margin-top:10px;background:#d9ad4a;color:#17130b;border:0;padding:12px 20px;border-radius:5px;font-size:16px;font-weight:bold}.control aside{margin-top:30px;padding:16px;border-left:3px solid #d9ad4a;background:#1b222c}.control code{color:#d9ad4a}.overlay{width:100vw;height:100vh;overflow:hidden;background:transparent;color:white;font-weight:bold;text-transform:uppercase}.upper{position:absolute;top:12px;left:12px;right:12px;display:flex;align-items:center;gap:20px}.team{--team:#333;flex:1;min-width:0;height:96px;padding:13px 22px;background:linear-gradient(115deg,color-mix(in srgb,var(--team),#111 40%),color-mix(in srgb,var(--team),transparent 24%));border:1px solid color-mix(in srgb,var(--team),white 25%);box-shadow:0 5px 16px #0006;backdrop-filter:blur(5px)}.team.left{clip-path:polygon(0 0,100% 0,94% 100%,0 100%)}.team.right{text-align:right;clip-path:polygon(6% 0,100% 0,100% 100%,0 100%)}.faction{font-size:14px;letter-spacing:.12em;opacity:.9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.name{height:66px;line-height:66px;white-space:nowrap;overflow:hidden;font-size:58px;text-shadow:2px 3px 4px #000}.versus{flex:0 0 48px;text-align:center;font-size:21px;letter-spacing:.1em;text-shadow:2px 3px 4px #000}@media(max-width:700px){.control section{grid-template-columns:1fr}.control{padding:20px}}
|
package/reader.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import json, os, subprocess, time, sys
|
|
2
|
+
# Windows consoles may default to a legacy encoding; the Node pipe expects UTF-8.
|
|
3
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
4
|
+
from battle_reader import parse, capture
|
|
5
|
+
ADB = os.environ.get('OVERLAY_ADB', 'adb')
|
|
6
|
+
SERIAL = os.environ.get('OVERLAY_SERIAL', '')
|
|
7
|
+
|
|
8
|
+
def select_device(output):
|
|
9
|
+
devices = [line.split()[:2] for line in output.splitlines() if line.strip() and not line.startswith(('List of', '*'))]
|
|
10
|
+
if not devices:
|
|
11
|
+
raise ValueError('ADB no detecta un teléfono. Conecta un cable de datos, desbloquea Android y activa Opciones de desarrollador → Depuración USB. No se puede distinguir cable desconectado de depuración desactivada.')
|
|
12
|
+
if not SERIAL and len(devices) > 1:
|
|
13
|
+
raise ValueError('Hay varios dispositivos. Reinicia con --serial SERIAL: ' + ', '.join(d[0] for d in devices))
|
|
14
|
+
device = next((d for d in devices if d[0] == SERIAL), None) if SERIAL else devices[0]
|
|
15
|
+
if not device: raise ValueError('El dispositivo seleccionado no está conectado: ' + SERIAL)
|
|
16
|
+
serial, status = device
|
|
17
|
+
if status == 'unauthorized': raise ValueError('Teléfono sin autorizar. Desbloquéalo y acepta «Permitir depuración USB» para este equipo. Si no aparece, reconecta el cable.')
|
|
18
|
+
if status == 'no': raise ValueError('Sin permisos USB. Revisa reglas udev y grupos del usuario; en Ubuntu, android-sdk-platform-tools-common y plugdev. Vuelve a iniciar sesión después de cambiar grupos.')
|
|
19
|
+
if status == 'offline': raise ValueError('Teléfono offline. Desbloquéalo y reconecta el cable USB.')
|
|
20
|
+
if status != 'device': raise ValueError('Dispositivo no disponible: ' + status)
|
|
21
|
+
return serial
|
|
22
|
+
|
|
23
|
+
if __name__ == '__main__':
|
|
24
|
+
while True:
|
|
25
|
+
try:
|
|
26
|
+
result = subprocess.run([ADB, 'devices', '-l'], capture_output=True, text=True, encoding='utf-8', errors='replace', check=True, timeout=10)
|
|
27
|
+
serial = select_device(result.stdout)
|
|
28
|
+
state = parse(capture(serial))
|
|
29
|
+
print(json.dumps(dict(state, connected=True, updatedAt=time.time(), error=None)), flush=True)
|
|
30
|
+
except Exception as e:
|
|
31
|
+
message = str(e)
|
|
32
|
+
if isinstance(e, subprocess.TimeoutExpired): message = 'El teléfono no respondió a tiempo. Desbloquéalo y abre la ronda; volveré a intentar la lectura.'
|
|
33
|
+
elif isinstance(e, subprocess.CalledProcessError): message = 'No se pudo leer Android. Abre la ronda y cierra otros lectores UIAutomator. Se reintentará. ' + (e.stderr or '').strip()[:300]
|
|
34
|
+
elif isinstance(e, FileNotFoundError): message = 'ADB no está disponible. Ejecuta wargaming-overlay setup --install-adb.'
|
|
35
|
+
print(json.dumps({'connected':False, 'error':message}), flush=True)
|
|
36
|
+
time.sleep(float(os.environ.get('OVERLAY_INTERVAL', '3')))
|