28 Commits

Author SHA1 Message Date
Pandipipas d956dfb30b feat(electron): build compressed Windows installer with Electron 40.6.1 2026-03-05 23:41:56 +01:00
Pandipipas 20cc81e696 Merge pull request #153 from Pandipipas/make-asset-downloads-permanent
Make downloaded game assets persistent and usable offline
2026-03-04 16:54:49 +01:00
Pandipipas 4db5c89f0a Cache game titles for offline selector labels 2026-03-04 16:52:16 +01:00
Pandipipas 752232eeca Store persistent assets in bundle-local db directory 2026-03-04 16:45:48 +01:00
Pandipipas c1e9133970 Make downloaded game assets persistent and offline-friendly 2026-03-04 16:10:08 +01:00
Pandipipas 774bd373d3 chore: add pnpm workspace configuration with only built dependencies 2026-03-04 14:50:51 +01:00
Pandipipas e922a6061e feat: remove deprecated character images and README for character catalog 2026-03-03 23:09:12 +01:00
Pandipipas dbbf17c917 Merge pull request #152 from Pandipipas/add-slug-formatting-for-game-names
Soportar nombres visibles de juegos mediante `games/games.json`
2026-03-03 23:04:16 +01:00
Pandipipas 097c9014f9 Mostrar nombre personalizado de juego en selector del dashboard 2026-03-03 21:52:53 +01:00
Pandipipas 2b2dd3180b Soportar nombres de juegos personalizados desde games.json 2026-03-03 21:45:55 +01:00
Pandipipas 69cb280ec1 Merge pull request #151 from Pandipipas/update-gameassetsview-to-auto-load-games
feat: descubrir y mostrar juegos desde el servidor HTTP de assets
2026-03-03 21:22:44 +01:00
Pandipipas 586c95ec11 feat: cargar juegos de assets desde servidor HTTP 2026-03-03 21:06:51 +01:00
Pandipipas 98f08e39d3 Merge pull request #150 from Pandipipas/cache-logos-from-http-server
Use configurable assets base URL for game logos and construct remote asset paths
2026-03-03 20:59:20 +01:00
Pandipipas c8097a72d8 Document how to host remote game logos on HTTP server 2026-03-03 20:59:03 +01:00
Pandipipas f91d5eaf48 Serve game logos directly from assets HTTP server 2026-03-03 20:44:19 +01:00
Pandipipas 5e6276ee19 Merge pull request #149 from Pandipipas/migrate-assets-to-local-server-1rhwip
Switch game assets to HTTP server (remove GitHub API) and require assetsBaseUrl
2026-03-03 20:28:06 +01:00
Pandipipas d4fe407b92 fix: relax bundle config requirements and default assetsBaseUrl 2026-03-03 20:24:56 +01:00
Pandipipas a93492b86b refactor: remove GitHub assets source and require HTTP provider 2026-03-03 20:01:13 +01:00
Pandipipas 13db5528a8 Merge pull request #146 from Pandipipas/add-character-names-download-with-assets
Descargar y usar listas de personajes por juego desde los assets
2026-03-03 16:22:12 +01:00
Pandipipas fc82c9215a Load character names from downloaded game assets 2026-03-03 16:15:11 +01:00
Pandipipas 584f872954 Merge pull request #145 from Pandipipas/add-new-view-for-fighting-games-uyw5fv
Add game assets manager (backend, store, UI) and character image fallbacks
2026-03-03 15:48:24 +01:00
Pandipipas 057b5a29c3 Remove tracked PNG game logos from repository 2026-03-03 15:44:47 +01:00
Pandipipas 1735e38edd Merge pull request #143 from Pandipipas/adapta-bundle-a-nueva-dependencia-nodecg
Bump Node.js engine, simplify start script, and add usage to README
2026-03-03 12:29:50 +01:00
Pandipipas f8ffad02cb Update docs to require Node.js 24.14.0+ 2026-03-03 12:26:58 +01:00
Pandipipas d289b7e0b7 Merge pull request #142 from Pandipipas/node24-deps
Update Node.js version to 24.14.0 in .nvmrc and package.json; adjust …
2026-03-01 15:23:29 +01:00
Pandipipas 27e2e441c0 fixes 2026-03-01 15:21:34 +01:00
Pandipipas 7ec56575d1 Refactor scoreboard settings layout for improved usability; consolidate options into a single sectioned card and enhance input styling. 2026-03-01 15:03:50 +01:00
Pandipipas d26e0df713 Update Node.js version to 24.14.0 in .nvmrc and package.json; adjust TypeScript configurations to extend from @tsconfig/node24. Reorganize dependencies and devDependencies for clarity. 2026-03-01 14:53:16 +01:00
107 changed files with 9734 additions and 12615 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 24
cache: npm cache: npm
- name: Install dependencies - name: Install dependencies
+1
View File
@@ -134,3 +134,4 @@ dist
/extension/ /extension/
/graphics/ /graphics/
/shared/dist/ /shared/dist/
/db/
+1
View File
@@ -0,0 +1 @@
24.14.0
+98 -3
View File
@@ -9,8 +9,7 @@ NodeCG bundle for producing fighting game overlays.
## Requirements ## Requirements
- Node.js 22+ - Node.js 24.14.0+
- NodeCG 2.3+
## Scripts ## Scripts
@@ -18,9 +17,105 @@ NodeCG bundle for producing fighting game overlays.
- `npm run build`: builds dashboard/graphics and extension. - `npm run build`: builds dashboard/graphics and extension.
- `npm run lint`: validates project linting. - `npm run lint`: validates project linting.
- `npm run schema-types`: generates types from schemas. - `npm run schema-types`: generates types from schemas.
- `npm run start`: starts NodeCG. - `npm run start`: starts NodeCG using the local dependency (`nodecg start`).
- `npm run watch`: development mode with watch. - `npm run watch`: development mode with watch.
## Usage
- `npm install`
- `npm run build`
- `npm run start` (equivalent to `npx nodecg start`)
## Version ## Version
Initial project version: `0.1.0`. Initial project version: `0.1.0`.
## Assets por HTTP (sin GitHub API)
La descarga de assets usa **únicamente HTTP**. Debes configurar un servidor propio.
1. En `cfg/scoreko-dev.json`, configura `assetsBaseUrl` (opcional, por defecto `http://localhost`):
```json
{
"scoreko-dev": {
"assetsBaseUrl": "http://localhost"
}
}
```
2. Sirve por HTTP esta estructura:
```text
games/
games.json (opcional, para nombres visibles personalizados)
street-fighter-6/
street-fighter-6.png
manifest.json
fighting-characters.json
characters/...
tekken-8/
tekken-8.png
manifest.json
...
```
`games/games.json` es opcional y permite mapear `slug -> nombre visible`.
Formato objeto:
```json
{
"2xko": "2XKO",
"tekken-8": "Tekken 8"
}
```
También se acepta array de objetos:
```json
[
{ "slug": "2xko", "title": "2XKO" },
{ "slug": "tekken-8", "title": "Tekken 8" }
]
```
## Logos en servidor HTTP (sin logos locales en el bundle)
La vista de "Game Assets" carga los logos directamente desde:
```text
{assetsBaseUrl}/games/{repoFolder}/{logoFile}
```
Ejemplos:
- `http://TU_SERVIDOR/games/street-fighter-6/street-fighter-6.png`
- `http://TU_SERVIDOR/games/tekken-8/tekken-8.png`
### Cómo guardarlos en la carpeta HTTP
1. Crea la carpeta del juego en tu web root (si no existe).
2. Copia el logo con el nombre esperado (`logoFile` de `src/shared/fighting-games.ts`).
3. Verifica desde navegador o `curl` que responde `200`.
Ejemplo rápido en Linux (Nginx/Apache):
```bash
sudo mkdir -p /var/www/assets/games/street-fighter-6
sudo cp ./street-fighter-6.png /var/www/assets/games/street-fighter-6/street-fighter-6.png
curl -I http://TU_SERVIDOR/games/street-fighter-6/street-fighter-6.png
```
Opcional (recomendado): añade cache HTTP (`Cache-Control`, `ETag`) en tu servidor para que el navegador no los vuelva a descargar en cada visita.
3. Cada `manifest.json` debe ser un array con rutas relativas, o con objetos `{ "path", "size", "url" }`.
Ejemplo mínimo:
```json
[
"fighting-characters.json",
"characters/ryu.png"
]
```
+8 -4
View File
@@ -4,7 +4,8 @@
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"exampleProperty": { "exampleProperty": {
"type": "string" "type": "string",
"default": ""
}, },
"startggClientId": { "startggClientId": {
"type": "string", "type": "string",
@@ -39,9 +40,12 @@
"minimum": 1, "minimum": 1,
"maximum": 65535, "maximum": 65535,
"description": "Puerto local para callback OAuth de Challonge" "description": "Puerto local para callback OAuth de Challonge"
},
"assetsBaseUrl": {
"type": "string",
"default": "http://localhost",
"description": "URL base para descargar assets por HTTP (ej: http://192.168.1.50:8080). Por defecto usa http://localhost."
} }
}, },
"required": [ "required": []
"exampleProperty"
]
} }
+49
View File
@@ -0,0 +1,49 @@
# Electron wrapper (Windows)
Este wrapper crea una app de escritorio para Windows que lanza NodeCG sin requerir que el usuario final tenga Node.js instalado.
## Requisitos de build (solo para quien genera el instalador)
1. Instalar dependencias del bundle raíz:
```bash
pnpm install
```
2. Instalar dependencias del wrapper:
```bash
cd electron
pnpm install
```
## Desarrollo local
Desde `electron/`:
```bash
pnpm start
```
## Generar instalador `.exe` (comprimido)
Desde `electron/`:
```bash
pnpm dist:win
```
Esto genera un instalador NSIS en `electron/dist/` con compresión máxima (`compression: maximum`).
## Qué incluye el instalador
- Runtime de Electron (incluye Node embebido).
- Dependencia `nodecg` dentro de la app.
- El bundle `scoreko-dev` como recurso (`resources/bundle`).
Con eso, el usuario final instala y ejecuta la app sin instalar Node.js aparte.
## Variables opcionales
- `NODECG_PORT` (por defecto `9090`)
- `NODECG_HOST` (por defecto `127.0.0.1`)
+118
View File
@@ -0,0 +1,118 @@
import { app, BrowserWindow } from 'electron';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawn } from 'node:child_process';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const BUNDLE_ROOT = app.isPackaged
? path.join(process.resourcesPath, 'bundle')
: path.resolve(__dirname, '..');
const NODECG_PORT = Number.parseInt(process.env.NODECG_PORT ?? '9090', 10);
const NODECG_HOST = process.env.NODECG_HOST ?? '127.0.0.1';
const NODECG_URL = `http://${NODECG_HOST}:${NODECG_PORT}`;
let nodecgProcess;
function getNodecgCliPath() {
return app.isPackaged
? path.join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', 'nodecg', 'bin', 'nodecg.js')
: path.join(__dirname, 'node_modules', 'nodecg', 'bin', 'nodecg.js');
}
function startNodecg() {
const nodecgCli = getNodecgCliPath();
nodecgProcess = spawn(process.execPath, [nodecgCli, 'start'], {
cwd: BUNDLE_ROOT,
env: {
...process.env,
ELECTRON_RUN_AS_NODE: '1',
NODECG_PORT: String(NODECG_PORT),
},
stdio: 'inherit',
});
nodecgProcess.on('exit', (code) => {
if (code !== 0) {
console.error(`[electron] NodeCG process exited with code ${code}`);
}
if (!app.isQuitting) {
app.quit();
}
});
}
function stopNodecg() {
if (!nodecgProcess || nodecgProcess.killed) {
return;
}
app.isQuitting = true;
nodecgProcess.kill();
}
async function waitForNodecg(retries = 80, delayMs = 250) {
for (let attempt = 0; attempt < retries; attempt += 1) {
try {
const response = await fetch(NODECG_URL, { method: 'HEAD' });
if (response.ok || response.status >= 300) {
return;
}
} catch {
// Ignore connection errors while NodeCG boots.
}
await new Promise((resolve) => {
setTimeout(resolve, delayMs);
});
}
throw new Error(`NodeCG did not start in time at ${NODECG_URL}`);
}
async function createWindow() {
const mainWindow = new BrowserWindow({
width: 1366,
height: 768,
autoHideMenuBar: true,
webPreferences: {
contextIsolation: true,
sandbox: true,
},
});
await mainWindow.loadURL(NODECG_URL);
}
app.whenReady().then(async () => {
startNodecg();
try {
await waitForNodecg();
await createWindow();
} catch (error) {
console.error('[electron] Failed to start wrapper:', error);
app.quit();
}
app.on('activate', async () => {
if (BrowserWindow.getAllWindows().length === 0) {
await createWindow();
}
});
});
app.on('before-quit', () => {
app.isQuitting = true;
stopNodecg();
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
+69
View File
@@ -0,0 +1,69 @@
{
"name": "scoreko-dev-electron-wrapper",
"version": "0.2.0",
"private": true,
"description": "Electron wrapper for running the scoreko-dev NodeCG bundle on Windows.",
"main": "main.mjs",
"type": "module",
"scripts": {
"start": "electron .",
"dist:win": "electron-builder --win nsis"
},
"dependencies": {
"electron": "40.6.1",
"nodecg": "^2.6.4"
},
"devDependencies": {
"electron-builder": "^26.0.12"
},
"build": {
"appId": "com.scoreko.dev",
"productName": "Scoreko Dev",
"compression": "maximum",
"asar": true,
"directories": {
"output": "dist"
},
"files": [
"main.mjs",
"package.json",
"node_modules/**/*"
],
"extraResources": [
{
"from": "../",
"to": "bundle",
"filter": [
"package.json",
"configschema.json",
"LICENSE",
"README.md",
"schemas/**/*",
"dashboard/**/*",
"graphics/**/*",
"extension/**/*",
"src/**/*",
"node_modules/**/*",
"!electron/**/*",
"!.git/**/*"
]
}
],
"win": {
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
}
]
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true
}
}
}
-12277
View File
File diff suppressed because it is too large Load Diff
+37 -37
View File
@@ -13,50 +13,22 @@
"license": "MIT", "license": "MIT",
"author": "Pandipipas", "author": "Pandipipas",
"type": "module", "type": "module",
"engines": {
"node": ">=24.14.0"
},
"scripts": { "scripts": {
"autofix": "eslint --fix", "autofix": "eslint --fix",
"prebuild": "trash ./extension && trash ./node_modules/.vite && trash ./shared/dist && trash ./dashboard && trash ./graphics", "prebuild": "trash ./extension && trash ./node_modules/.vite && trash ./shared/dist && trash ./dashboard && trash ./graphics",
"build": "vite build && tsc -b tsconfig.extension.json", "build": "vite build && tsc -b tsconfig.extension.json",
"lint": "eslint", "lint": "eslint",
"schema-types": "nodecg schema-types", "schema-types": "nodecg schema-types",
"start": "cd ../.. && node index.js", "start": "nodecg start",
"watch": "conc -n B,E -c red,blue -k vite \"tsc -b -w --preserveWatchOutput tsconfig.extension.json\"" "watch": "conc -n B,E -c red,blue -k vite \"tsc -b -w --preserveWatchOutput tsconfig.extension.json\"",
}, "electron:start": "pnpm --dir electron start",
"devDependencies": { "electron:dist": "pnpm --dir electron dist:win"
"@eslint/js": "^9.39.0",
"@quasar/extras": "^1.17.0",
"@quasar/vite-plugin": "^1.10.0",
"@tsconfig/node22": "^22.0.2",
"@types/node": "^22.18.13",
"@unhead/vue": "^2.0.19",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/tsconfig": "^0.8.1",
"concurrently": "^9.2.1",
"eslint": "^9.39.0",
"eslint-plugin-vue": "^10.5.1",
"nodecg": "^2.6.1",
"nodecg-vue-composable": "^1.1.0",
"pinia": "^2.3.1",
"quasar": "^2.18.5",
"sass-embedded": "^1.93.3",
"trash-cli": "^7.0.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.2",
"vite": "^7.1.12",
"vite-plugin-checker": "^0.11.0",
"vite-plugin-nodecg": "^2.1.0",
"vue": "^3.5.22",
"vue-router": "^4.5.0",
"vue-tsc": "^3.1.2"
},
"pnpm": {
"overrides": {
"vite-plugin-nodecg>vite": "$vite"
}
}, },
"nodecg": { "nodecg": {
"compatibleRange": "^2.3.0", "compatibleRange": "^2.6.0",
"dashboardPanels": [ "dashboardPanels": [
{ {
"name": "scoreko-dev", "name": "scoreko-dev",
@@ -101,7 +73,35 @@
} }
}, },
"dependencies": { "dependencies": {
"@quasar/extras": "^1.17.0",
"@unhead/vue": "^2.0.19",
"country-list": "^2.4.1", "country-list": "^2.4.1",
"flag-icons": "^7.5.0" "flag-icons": "^7.5.0",
"nodecg": "^2.6.4",
"nodecg-vue-composable": "^1.1.0",
"pinia": "^2.3.1",
"quasar": "^2.18.5",
"vue": "^3.5.22",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@eslint/js": "^9.39.0",
"@quasar/vite-plugin": "^1.10.0",
"@tsconfig/node24": "^24.0.4",
"@types/node": "^22.18.13",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/tsconfig": "^0.8.1",
"concurrently": "^9.2.1",
"eslint": "^9.39.0",
"eslint-plugin-vue": "^10.5.1",
"sass-embedded": "^1.93.3",
"trash-cli": "^7.0.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.2",
"vite": "^7.1.12",
"vite-plugin-checker": "^0.11.0",
"vite-plugin-nodecg": "^2.1.0",
"vue-tsc": "^3.1.2"
} }
} }
+8167
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
onlyBuiltDependencies:
- '@parcel/watcher'
- '@vaadin/vaadin-usage-statistics'
- better-sqlite3
- esbuild
- msgpackr-extract
- vue-demi
@@ -1,14 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch, watchEffect, type Ref } from 'vue'; import { computed, onMounted, ref, watch, watchEffect, type Ref } from 'vue';
import { getCountryLabel, getCountryOptions } from '../../../shared/countries'; import { getCountryLabel, getCountryOptions } from '../../../shared/countries';
import { getCharactersByGame, getDefaultCharactersByGame } from '../../../shared/fighting-characters'; import { buildCharactersByGame, getDefaultCharactersByGame } from '../../../shared/fighting-characters';
import type { Schemas } from '../../../types'; import type { Schemas } from '../../../types';
import { usePlayersStore } from '../stores/players'; import { usePlayersStore } from '../stores/players';
import { useScoreboardStore } from '../stores/scoreboard'; import { useScoreboardStore } from '../stores/scoreboard';
import { useGameAssetsStore } from '../stores/game-assets';
import { locale, t } from '../i18n'; import { locale, t } from '../i18n';
const playersStore = usePlayersStore(); const playersStore = usePlayersStore();
const scoreboardStore = useScoreboardStore(); const scoreboardStore = useScoreboardStore();
const gameAssetsStore = useGameAssetsStore();
const CUSTOM_LEFT_PLAYER_ID = '__custom_left_player__'; const CUSTOM_LEFT_PLAYER_ID = '__custom_left_player__';
const CUSTOM_RIGHT_PLAYER_ID = '__custom_right_player__'; const CUSTOM_RIGHT_PLAYER_ID = '__custom_right_player__';
@@ -31,37 +33,108 @@ const rightCharacterInput = ref('');
const gameInput = ref(''); const gameInput = ref('');
const charactersByGame = ref<Record<string, { leftCharacter: string; rightCharacter: string }>>({}); const charactersByGame = ref<Record<string, { leftCharacter: string; rightCharacter: string }>>({});
const allFightingGameOptions = [ const gameTitleBySlug = computed(() => new Map(
'2XKO', gameAssetsStore.availableGames.map((game) => [game.slug, game.title] as const),
'Mortal Kombat 1', ));
'Street Fighter 6',
'TEKKEN 8', const allFightingGameOptions = computed(() => gameAssetsStore.installedGames.map((game) => ({
'Guilty Gear -Strive-', label: gameTitleBySlug.value.get(game) ?? game,
'THE KING OF FIGHTERS XV',
].map((game) => ({
label: game,
value: game, value: game,
})); })));
const fightingGameOptions = ref(allFightingGameOptions); const fightingGameOptions = ref(allFightingGameOptions.value);
const characterOptions = computed(() => getCharactersByGame(scoreboardStore.scoreboard.game)); const characterOptions = computed(() => buildCharactersByGame(
type CharacterOption = ReturnType<typeof getCharactersByGame>[number]; scoreboardStore.scoreboard.game,
gameAssetsStore.characterNamesByGame[scoreboardStore.scoreboard.game] ?? [],
));
type CharacterOption = ReturnType<typeof buildCharactersByGame>[number];
const leftCharacterOptions = ref<CharacterOption[]>([]); const leftCharacterOptions = ref<CharacterOption[]>([]);
const rightCharacterOptions = ref<CharacterOption[]>([]); const rightCharacterOptions = ref<CharacterOption[]>([]);
const leftCharacterImage = computed(() => { const leftImageStage = ref<'asset' | 'bundled' | 'fallback'>('asset');
const match = characterOptions.value.find((option) => option.value === scoreboardStore.scoreboard.leftCharacter); const rightImageStage = ref<'asset' | 'bundled' | 'fallback'>('asset');
return match?.image ?? '';
const leftCharacterImage = computed(() => characterOptions.value.find((option) => option.value === scoreboardStore.scoreboard.leftCharacter));
const rightCharacterImage = computed(() => characterOptions.value.find((option) => option.value === scoreboardStore.scoreboard.rightCharacter));
const leftPanelImage = computed(() => {
const option = leftCharacterImage.value;
if (!option) {
return '';
}
if (leftImageStage.value === 'asset') {
return option.image;
}
if (leftImageStage.value === 'bundled' && option.bundledImage) {
return option.bundledImage;
}
return option.fallbackImage;
}); });
const rightCharacterImage = computed(() => { const rightPanelImage = computed(() => {
const match = characterOptions.value.find((option) => option.value === scoreboardStore.scoreboard.rightCharacter); const option = rightCharacterImage.value;
return match?.image ?? ''; if (!option) {
return '';
}
if (rightImageStage.value === 'asset') {
return option.image;
}
if (rightImageStage.value === 'bundled' && option.bundledImage) {
return option.bundledImage;
}
return option.fallbackImage;
}); });
const leftPanelImage = computed(() => leftCharacterImage.value); const onLeftImageError = () => {
const rightPanelImage = computed(() => rightCharacterImage.value); const option = leftCharacterImage.value;
if (!option) {
return;
}
if (leftImageStage.value === 'asset' && option.bundledImage) {
leftImageStage.value = 'bundled';
return;
}
leftImageStage.value = 'fallback';
};
const onRightImageError = () => {
const option = rightCharacterImage.value;
if (!option) {
return;
}
if (rightImageStage.value === 'asset' && option.bundledImage) {
rightImageStage.value = 'bundled';
return;
}
rightImageStage.value = 'fallback';
};
watch(allFightingGameOptions, (options) => {
fightingGameOptions.value = options;
if (!options.some((option) => option.value === scoreboardStore.scoreboard.game)) {
scoreboardStore.scoreboard.game = '';
scoreboardStore.scoreboard.leftCharacter = '';
scoreboardStore.scoreboard.rightCharacter = '';
}
});
onMounted(async () => {
await gameAssetsStore.refreshInstalledGames();
});
const normalizeName = (value: string) => value.trim().toLowerCase(); const normalizeName = (value: string) => value.trim().toLowerCase();
@@ -129,10 +202,10 @@ const onGameFilter = (value: string, update: (callback: () => void) => void) =>
update(() => { update(() => {
const needle = value.toLowerCase().trim(); const needle = value.toLowerCase().trim();
if (!needle) { if (!needle) {
fightingGameOptions.value = allFightingGameOptions; fightingGameOptions.value = allFightingGameOptions.value;
return; return;
} }
fightingGameOptions.value = allFightingGameOptions.filter((game) => game.label.toLowerCase().includes(needle)); fightingGameOptions.value = allFightingGameOptions.value.filter((game) => game.label.toLowerCase().includes(needle));
}); });
}; };
@@ -647,15 +720,21 @@ watchEffect(() => {
watch( watch(
() => scoreboardStore.scoreboard.game, () => scoreboardStore.scoreboard.game,
(value) => { (value) => {
const match = allFightingGameOptions.find((option) => option.value === value); const match = allFightingGameOptions.value.find((option) => option.value === value);
gameInput.value = match?.label ?? ''; gameInput.value = match?.label ?? '';
}, },
{ immediate: true }, { immediate: true },
); );
watch( watch(
() => scoreboardStore.scoreboard.game, () => [scoreboardStore.scoreboard.game, characterOptions.value] as const,
(newGame, previousGame) => { ([newGame, options], previousState) => {
const previousGame = previousState?.[0];
if (newGame && gameAssetsStore.characterNamesByGame[newGame] === undefined) {
return;
}
if (previousGame) { if (previousGame) {
charactersByGame.value[previousGame] = { charactersByGame.value[previousGame] = {
leftCharacter: scoreboardStore.scoreboard.leftCharacter, leftCharacter: scoreboardStore.scoreboard.leftCharacter,
@@ -663,7 +742,6 @@ watch(
}; };
} }
const options = getCharactersByGame(scoreboardStore.scoreboard.game);
leftCharacterOptions.value = options; leftCharacterOptions.value = options;
rightCharacterOptions.value = options; rightCharacterOptions.value = options;
const allowed = new Set(options.map((option) => option.value)); const allowed = new Set(options.map((option) => option.value));
@@ -719,6 +797,7 @@ watch(countryOptions, (value) => {
watch( watch(
() => scoreboardStore.scoreboard.leftCharacter, () => scoreboardStore.scoreboard.leftCharacter,
(value) => { (value) => {
leftImageStage.value = 'asset';
const match = characterOptions.value.find((option) => option.value === value); const match = characterOptions.value.find((option) => option.value === value);
leftCharacterInput.value = match?.label ?? ''; leftCharacterInput.value = match?.label ?? '';
@@ -738,6 +817,7 @@ watch(
watch( watch(
() => scoreboardStore.scoreboard.rightCharacter, () => scoreboardStore.scoreboard.rightCharacter,
(value) => { (value) => {
rightImageStage.value = 'asset';
const match = characterOptions.value.find((option) => option.value === value); const match = characterOptions.value.find((option) => option.value === value);
rightCharacterInput.value = match?.label ?? ''; rightCharacterInput.value = match?.label ?? '';
@@ -768,6 +848,7 @@ watch(
:src="leftPanelImage" :src="leftPanelImage"
:alt="`${leftDisplayName || t('scoreboardLeft')} ${t('scoreboardPreview')}`" :alt="`${leftDisplayName || t('scoreboardLeft')} ${t('scoreboardPreview')}`"
class="scoreboard-preview__image" class="scoreboard-preview__image"
@error="onLeftImageError"
> >
<div <div
v-else v-else
@@ -1075,6 +1156,7 @@ watch(
:src="rightPanelImage" :src="rightPanelImage"
:alt="`${rightDisplayName || t('scoreboardRight')} ${t('scoreboardPreview')}`" :alt="`${rightDisplayName || t('scoreboardRight')} ${t('scoreboardPreview')}`"
class="scoreboard-preview__image" class="scoreboard-preview__image"
@error="onRightImageError"
> >
<div <div
v-else v-else
+3
View File
@@ -6,6 +6,7 @@ type Translations = {
menuDashboard: string; menuDashboard: string;
menuPlayers: string; menuPlayers: string;
menuGraphics: string; menuGraphics: string;
menuAssets: string;
menuSettings: string; menuSettings: string;
menuAbout: string; menuAbout: string;
settingsTitle: string; settingsTitle: string;
@@ -93,6 +94,7 @@ const messages: Record<Locale, Translations> = {
menuDashboard: 'Dashboard', menuDashboard: 'Dashboard',
menuPlayers: 'Players', menuPlayers: 'Players',
menuGraphics: 'Graphics', menuGraphics: 'Graphics',
menuAssets: 'Game Assets',
menuSettings: 'Settings', menuSettings: 'Settings',
menuAbout: 'About', menuAbout: 'About',
settingsTitle: 'Settings', settingsTitle: 'Settings',
@@ -176,6 +178,7 @@ const messages: Record<Locale, Translations> = {
menuDashboard: 'Panel', menuDashboard: 'Panel',
menuPlayers: 'Jugadores', menuPlayers: 'Jugadores',
menuGraphics: 'Gráficos', menuGraphics: 'Gráficos',
menuAssets: 'Assets de juego',
menuSettings: 'Configuración', menuSettings: 'Configuración',
menuAbout: 'Acerca de', menuAbout: 'Acerca de',
settingsTitle: 'Configuración', settingsTitle: 'Configuración',
+1
View File
@@ -8,6 +8,7 @@ const menuItems = computed(() => [
{ label: t('menuDashboard'), to: '/', icon: 'dashboard' }, { label: t('menuDashboard'), to: '/', icon: 'dashboard' },
{ label: t('menuPlayers'), to: '/players', icon: 'groups' }, { label: t('menuPlayers'), to: '/players', icon: 'groups' },
{ label: t('menuGraphics'), to: '/graphics', icon: 'collections' }, { label: t('menuGraphics'), to: '/graphics', icon: 'collections' },
{ label: t('menuAssets'), to: '/game-assets', icon: 'sports_esports' },
{ label: t('menuSettings'), to: '/settings', icon: 'settings' }, { label: t('menuSettings'), to: '/settings', icon: 'settings' },
{ label: t('menuAbout'), to: '/about', icon: 'info' }, { label: t('menuAbout'), to: '/about', icon: 'info' },
]); ]);
+2
View File
@@ -2,6 +2,7 @@ import { createRouter, createWebHashHistory } from 'vue-router';
import AboutView from './views/About.vue'; import AboutView from './views/About.vue';
import DashboardView from './views/Dashboard.vue'; import DashboardView from './views/Dashboard.vue';
import GraphicsView from './views/Graphics.vue'; import GraphicsView from './views/Graphics.vue';
import GameAssetsView from './views/GameAssets.vue';
import PlayersView from './views/Players.vue'; import PlayersView from './views/Players.vue';
import SettingsView from './views/Settings.vue'; import SettingsView from './views/Settings.vue';
@@ -11,6 +12,7 @@ const router = createRouter({
{ path: '/', name: 'dashboard', component: DashboardView }, { path: '/', name: 'dashboard', component: DashboardView },
{ path: '/players', name: 'players', component: PlayersView }, { path: '/players', name: 'players', component: PlayersView },
{ path: '/graphics', name: 'graphics', component: GraphicsView }, { path: '/graphics', name: 'graphics', component: GraphicsView },
{ path: '/game-assets', name: 'game-assets', component: GameAssetsView },
{ path: '/settings', name: 'settings', component: SettingsView }, { path: '/settings', name: 'settings', component: SettingsView },
{ path: '/about', name: 'about', component: AboutView }, { path: '/about', name: 'about', component: AboutView },
], ],
@@ -0,0 +1,154 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
type DownloadStatus = 'downloading' | 'completed' | 'error';
type ProgressPayload = {
title: string;
progress: number;
status: DownloadStatus;
};
type RemoteGame = {
title: string;
slug: string;
repoFolder: string;
logoFile: string;
};
const sendNodecgMessage = <TResponse>(messageName: string, payload?: unknown) => new Promise<TResponse>((resolve, reject) => {
nodecg.sendMessage(messageName, payload, (error: unknown, response: unknown) => {
if (error) {
reject(error instanceof Error ? error : new Error(String(error)));
return;
}
resolve(response as TResponse);
});
});
let progressListenerAttached = false;
export const useGameAssetsStore = defineStore('game-assets', () => {
const installedGames = ref<string[]>([]);
const availableGames = ref<RemoteGame[]>([]);
const characterNamesByGame = ref<Record<string, string[]>>({});
const loadingByTitle = ref<Record<string, boolean>>({});
const removingByTitle = ref<Record<string, boolean>>({});
const progressByTitle = ref<Record<string, number>>({});
const assetsBaseUrl = ref('http://localhost');
if (!progressListenerAttached) {
nodecg.listenFor('scoreko-assets:downloadProgress', (payload: unknown) => {
const message = payload as Partial<ProgressPayload>;
if (typeof message.title !== 'string') {
return;
}
if (typeof message.progress === 'number') {
progressByTitle.value = {
...progressByTitle.value,
[message.title]: message.progress,
};
}
if (message.status === 'completed' || message.status === 'error') {
loadingByTitle.value = {
...loadingByTitle.value,
[message.title]: false,
};
}
});
progressListenerAttached = true;
}
const refreshCharacterNamesByGame = async () => {
const response = await sendNodecgMessage<Record<string, string[]>>('scoreko-assets:listCharactersByGame');
characterNamesByGame.value = response;
return characterNamesByGame.value;
};
const refreshInstalledGames = async () => {
try {
const availableResponse = await sendNodecgMessage<RemoteGame[]>('scoreko-assets:listRemoteGames');
availableGames.value = Array.isArray(availableResponse) ? availableResponse : [];
} catch {
availableGames.value = [];
}
const response = await sendNodecgMessage<string[]>('scoreko-assets:listInstalled');
installedGames.value = Array.isArray(response) ? response : [];
const configResponse = await sendNodecgMessage<{ assetsBaseUrl?: string }>('scoreko-assets:getAssetsBaseUrl');
assetsBaseUrl.value = typeof configResponse?.assetsBaseUrl === 'string' && configResponse.assetsBaseUrl.trim()
? configResponse.assetsBaseUrl
: 'http://localhost';
await refreshCharacterNamesByGame();
return installedGames.value;
};
const downloadGame = async (slug: string) => {
loadingByTitle.value = {
...loadingByTitle.value,
[slug]: true,
};
progressByTitle.value = {
...progressByTitle.value,
[slug]: 0,
};
try {
const response = await sendNodecgMessage<{ installedGames: string[] }>('scoreko-assets:downloadGame', { slug });
installedGames.value = response.installedGames;
await refreshCharacterNamesByGame();
loadingByTitle.value = {
...loadingByTitle.value,
[slug]: false,
};
progressByTitle.value = {
...progressByTitle.value,
[slug]: 100,
};
return response;
} catch (error) {
loadingByTitle.value = {
...loadingByTitle.value,
[slug]: false,
};
throw error;
}
};
const removeGame = async (slug: string) => {
removingByTitle.value = {
...removingByTitle.value,
[slug]: true,
};
try {
const response = await sendNodecgMessage<{ installedGames: string[] }>('scoreko-assets:removeGame', { slug });
installedGames.value = response.installedGames;
await refreshCharacterNamesByGame();
return response;
} finally {
removingByTitle.value = {
...removingByTitle.value,
[slug]: false,
};
}
};
return {
installedGames,
availableGames,
characterNamesByGame,
loadingByTitle,
removingByTitle,
progressByTitle,
assetsBaseUrl,
refreshInstalledGames,
refreshCharacterNamesByGame,
downloadGame,
removeGame,
};
});
@@ -0,0 +1,292 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useGameAssetsStore } from '../stores/game-assets';
const gameAssetsStore = useGameAssetsStore();
const errorMessage = ref('');
const selectedGameSlug = ref<string | null>(null);
const search = ref('');
const getGameLogoUrl = (repoFolder: string, logoFile: string) => {
const cleanBaseUrl = gameAssetsStore.assetsBaseUrl.replace(/\/+$/, '');
const cleanRepoFolder = repoFolder.replace(/^\/+|\/+$/g, '');
const cleanLogoFile = logoFile.replace(/^\/+/, '');
return `${cleanBaseUrl}/games/${cleanRepoFolder}/${cleanLogoFile}`;
};
const normalizedSearch = computed(() => search.value.trim().toLowerCase());
const filteredGames = computed(() => {
if (!normalizedSearch.value) {
return gameAssetsStore.availableGames;
}
return gameAssetsStore.availableGames.filter((game) => game.title.toLowerCase().includes(normalizedSearch.value));
});
const selectedGame = computed(() => gameAssetsStore.availableGames.find((game) => game.slug === selectedGameSlug.value) ?? null);
const installedGameSet = computed(() => new Set(gameAssetsStore.installedGames));
const openGameDialog = (slug: string) => {
selectedGameSlug.value = slug;
};
const closeGameDialog = () => {
selectedGameSlug.value = null;
};
const downloadGameBySlug = async (slug: string) => {
errorMessage.value = '';
try {
await gameAssetsStore.downloadGame(slug);
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : 'No se pudieron descargar los assets.';
}
};
const removeGameBySlug = async (slug: string) => {
errorMessage.value = '';
try {
await gameAssetsStore.removeGame(slug);
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : 'No se pudieron borrar los assets.';
}
};
const onDownloadFromDialog = async () => {
if (!selectedGame.value) {
return;
}
const targetSlug = selectedGame.value.slug;
closeGameDialog();
await downloadGameBySlug(targetSlug);
};
onMounted(async () => {
try {
await gameAssetsStore.refreshInstalledGames();
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : 'No se pudo cargar el estado de assets.';
}
});
</script>
<template>
<QPage class="q-pa-lg">
<div class="text-h5 text-weight-bold q-mb-sm">
Biblioteca de juegos
</div>
<p class="text-body2 q-mb-md">
Busca un juego, pulsa información para ver detalles o descarga directamente.
</p>
<QInput
v-model="search"
dense
outlined
clearable
label="Buscar juego"
class="q-mb-lg"
>
<template #prepend>
<QIcon name="search" />
</template>
</QInput>
<div class="game-grid">
<div
v-for="game in filteredGames"
:key="game.slug"
class="game-cell"
>
<div class="logo-tile">
<QImg
:src="getGameLogoUrl(game.repoFolder, game.logoFile)"
fit="contain"
height="74px"
/>
<div
v-if="gameAssetsStore.loadingByTitle[game.slug]"
class="download-overlay"
:style="{ '--progress-width': `${gameAssetsStore.progressByTitle[game.slug] ?? 0}%` }"
/>
<div class="tile-actions">
<QBtn
flat
round
size="md"
icon="info"
color="white"
@click="openGameDialog(game.slug)"
/>
<div class="row items-center no-wrap q-gutter-sm">
<QBtn
v-if="installedGameSet.has(game.slug)"
flat
round
size="md"
icon="check_circle"
color="positive"
disable
/>
<QBtn
v-else
flat
round
size="md"
:icon="gameAssetsStore.loadingByTitle[game.slug] ? 'autorenew' : 'download'"
:class="{ 'downloading-spin': gameAssetsStore.loadingByTitle[game.slug] }"
color="white"
:disable="gameAssetsStore.loadingByTitle[game.slug]"
@click="downloadGameBySlug(game.slug)"
/>
</div>
</div>
</div>
</div>
</div>
<QDialog
:model-value="Boolean(selectedGame)"
@update:model-value="(value) => { if (!value) closeGameDialog(); }"
>
<QCard
v-if="selectedGame"
class="q-pa-md"
style="min-width: 360px; max-width: 480px"
>
<QImg
:src="getGameLogoUrl(selectedGame.repoFolder, selectedGame.logoFile)"
fit="contain"
height="80px"
class="q-mb-md"
/>
<div class="text-h6 text-weight-bold q-mb-sm">
{{ selectedGame.title }}
</div>
<div class="text-body2 q-mb-sm">
Carpeta en servidor: <strong>{{ selectedGame.repoFolder }}</strong>.
</div>
<div class="row q-gutter-sm justify-end">
<QBtn
v-if="installedGameSet.has(selectedGame.slug)"
flat
color="negative"
icon="delete"
:loading="gameAssetsStore.removingByTitle[selectedGame.slug]"
label="Borrar assets"
@click="removeGameBySlug(selectedGame.slug); closeGameDialog()"
/>
<QBtn
color="primary"
icon="download"
:disable="installedGameSet.has(selectedGame.slug)"
:label="installedGameSet.has(selectedGame.slug) ? 'Descargado' : 'Descargar assets'"
@click="onDownloadFromDialog"
/>
</div>
</QCard>
</QDialog>
<QBanner
v-if="errorMessage"
dense
rounded
class="bg-red-2 text-red-10 q-mt-lg"
>
{{ errorMessage }}
</QBanner>
</QPage>
</template>
<style scoped>
.game-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
}
.game-cell {
min-width: 0;
}
.logo-tile {
position: relative;
min-height: 132px;
border: 1px solid #2f3b52;
border-radius: 10px;
padding: 10px;
overflow: hidden;
}
.download-overlay {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: var(--progress-width);
background:
repeating-linear-gradient(
-45deg,
rgba(13, 148, 136, 0.45) 0 12px,
rgba(13, 148, 136, 0.25) 12px 24px
);
background-size: 36px 36px;
animation: shade-slide 1s linear infinite;
pointer-events: none;
z-index: 1;
}
.tile-actions {
position: absolute;
left: 0;
right: 0;
bottom: 4px;
display: flex;
justify-content: center;
align-items: center;
gap: 26px;
z-index: 2;
}
.downloading-spin :deep(.q-icon) {
animation: icon-spin 1s linear infinite;
}
@keyframes shade-slide {
from {
background-position: 0 0;
}
to {
background-position: 36px 0;
}
}
@keyframes icon-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (max-width: 1400px) {
.game-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (max-width: 1100px) {
.game-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
</style>
+513
View File
@@ -0,0 +1,513 @@
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { nodecg } from './util/nodecg.js';
const CHARACTER_NAMES_FILE = 'fighting-characters.json';
const LOCAL_MANIFEST_FILE = 'manifest.json';
const GAME_TITLES_FILE = 'games.json';
const CACHED_GAME_TITLES_FILE = 'games-cache.json';
type RemoteGame = {
title: string;
slug: string;
repoFolder: string;
logoFile: string;
};
type AssetFileEntry = {
path: string;
size: number;
downloadUrl: string;
};
type HttpManifestEntry = string | {
path?: unknown;
size?: unknown;
url?: unknown;
};
type HttpGameTitleEntry = {
slug?: unknown;
title?: unknown;
};
type HttpGameTitlesFile = Record<string, unknown> | HttpGameTitleEntry[];
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
const bundleRoot = path.resolve(extensionDir, '..');
const legacyAssetsRoot = path.join(bundleRoot, 'game-assets');
const assetsRoot = path.join(bundleRoot, 'db', `${nodecg.bundleName}-game-assets`);
let assetsStorageReady = false;
const ensureAssetsStorageReady = async () => {
if (assetsStorageReady) {
return;
}
await mkdir(path.dirname(assetsRoot), { recursive: true });
const [currentStats, legacyStats] = await Promise.all([
stat(assetsRoot).catch(() => null),
stat(legacyAssetsRoot).catch(() => null),
]);
if (!currentStats && legacyStats?.isDirectory()) {
await rename(legacyAssetsRoot, assetsRoot).catch(async () => {
await mkdir(assetsRoot, { recursive: true });
});
} else {
await mkdir(assetsRoot, { recursive: true });
}
assetsStorageReady = true;
};
void ensureAssetsStorageReady();
const assetsRouter = nodecg.Router();
assetsRouter.get('/*', async (req, res) => {
const wildcardParam = (req.params as Record<string, unknown>)['0']
?? (req.params as Record<string, unknown>)[''];
const requestedPath = Array.isArray(wildcardParam)
? String(wildcardParam[0] ?? '')
: typeof wildcardParam === 'string'
? wildcardParam
: '';
const normalizedPath = path.normalize(requestedPath).replace(/^(\.\.(?:[\\/]|$))+/, '');
const filePath = path.resolve(assetsRoot, normalizedPath);
if (!filePath.startsWith(assetsRoot)) {
res.status(400).send('Invalid asset path.');
return;
}
try {
const fileStats = await stat(filePath);
if (!fileStats.isFile()) {
res.status(404).send('Asset not found.');
return;
}
res.type(path.extname(filePath));
res.send(await readFile(filePath));
} catch {
res.status(404).send('Asset not found.');
}
});
nodecg.mount(`/bundles/${nodecg.bundleName}/game-assets`, assetsRouter);
const requestHeaders = {
'User-Agent': 'scoreko-dev-nodecg-bundle',
};
const getConfiguredAssetsBaseUrl = () => {
const configuredValue = nodecg.bundleConfig.assetsBaseUrl;
if (typeof configuredValue !== 'string') {
return 'http://localhost';
}
const trimmed = configuredValue.trim();
if (!trimmed) {
return 'http://localhost';
}
return trimmed.replace(/\/+$/, '');
};
const emitProgress = (title: string, progress: number, status: 'downloading' | 'completed' | 'error') => {
nodecg.sendMessage('scoreko-assets:downloadProgress', {
title,
progress: Math.max(0, Math.min(100, progress)),
status,
});
};
const fetchJson = async <T>(url: string): Promise<T> => {
const response = await fetch(url, { headers: requestHeaders });
if (!response.ok) {
throw new Error(`Error HTTP (${response.status}) al solicitar ${url}`);
}
return response.json() as Promise<T>;
};
const normalizeManifestEntry = (entry: HttpManifestEntry, gameTitle: string) => {
if (typeof entry === 'string') {
return {
path: entry,
size: 0,
explicitUrl: null,
};
}
if (typeof entry === 'object' && entry !== null && typeof entry.path === 'string') {
return {
path: entry.path,
size: typeof entry.size === 'number' ? entry.size : 0,
explicitUrl: typeof entry.url === 'string' ? entry.url : null,
};
}
throw new Error(`El ${LOCAL_MANIFEST_FILE} de ${gameTitle} contiene entradas inválidas.`);
};
const titleFromSlug = (slug: string) => slug
.split('-')
.filter(Boolean)
.map((segment) => segment[0].toUpperCase() + segment.slice(1))
.join(' ');
const parseGameTitlesMap = (payload: unknown): Map<string, string> => {
const map = new Map<string, string>();
if (Array.isArray(payload)) {
for (const entry of payload) {
const parsedEntry = entry as HttpGameTitleEntry;
if (
typeof entry === 'object'
&& entry !== null
&& typeof parsedEntry.slug === 'string'
&& typeof parsedEntry.title === 'string'
) {
const slug = parsedEntry.slug.trim();
const title = parsedEntry.title.trim();
if (slug && title) {
map.set(slug, title);
}
}
}
return map;
}
if (typeof payload === 'object' && payload !== null) {
for (const [slug, value] of Object.entries(payload)) {
if (typeof value !== 'string') {
continue;
}
const normalizedSlug = slug.trim();
const title = value.trim();
if (normalizedSlug && title) {
map.set(normalizedSlug, title);
}
}
}
return map;
};
const fetchCustomGameTitles = async (): Promise<Map<string, string>> => {
const baseUrl = getConfiguredAssetsBaseUrl();
const url = `${baseUrl}/games/${GAME_TITLES_FILE}`;
try {
const payload = await fetchJson<HttpGameTitlesFile>(url);
return parseGameTitlesMap(payload);
} catch {
return new Map<string, string>();
}
};
const loadCachedGameTitles = async (): Promise<Map<string, string>> => {
await ensureAssetsStorageReady();
const cachePath = path.join(assetsRoot, CACHED_GAME_TITLES_FILE);
try {
const raw = await readFile(cachePath, 'utf8');
const parsed = JSON.parse(raw) as unknown;
return parseGameTitlesMap(parsed);
} catch {
return new Map<string, string>();
}
};
const saveCachedGameTitles = async (titles: Map<string, string>) => {
await ensureAssetsStorageReady();
const cachePath = path.join(assetsRoot, CACHED_GAME_TITLES_FILE);
const payload = Object.fromEntries([...titles.entries()].sort((left, right) => left[0].localeCompare(right[0])));
await writeFile(cachePath, JSON.stringify(payload, null, 2));
};
const listRemoteGames = async (): Promise<RemoteGame[]> => {
const baseUrl = getConfiguredAssetsBaseUrl();
const gamesIndexUrl = `${baseUrl}/games/`;
const customTitles = await fetchCustomGameTitles();
const response = await fetch(gamesIndexUrl, { headers: requestHeaders });
if (!response.ok) {
throw new Error(`Error HTTP (${response.status}) al solicitar ${gamesIndexUrl}`);
}
const html = await response.text();
const hrefMatches = [...html.matchAll(/href=["']([^"']+)["']/gi)].map((match) => match[1]);
const slugs = hrefMatches
.map((href) => {
const withoutQuery = href.split('?')[0]?.split('#')[0] ?? '';
if (!withoutQuery.endsWith('/')) {
return null;
}
const decoded = decodeURIComponent(withoutQuery);
const trimmed = decoded.replace(/^\/+|\/+$/g, '');
if (!trimmed || trimmed.includes('/') || trimmed === '.' || trimmed === '..') {
return null;
}
return trimmed;
})
.filter((slug): slug is string => slug !== null);
const uniqueSlugs = [...new Set(slugs)].sort((left, right) => left.localeCompare(right));
return uniqueSlugs.map((slug) => ({
slug,
repoFolder: slug,
title: customTitles.get(slug) ?? titleFromSlug(slug),
logoFile: `${slug}.png`,
}));
};
const listHttpFiles = async (game: RemoteGame): Promise<AssetFileEntry[]> => {
const baseUrl = getConfiguredAssetsBaseUrl();
const manifestUrl = `${baseUrl}/games/${game.repoFolder}/${LOCAL_MANIFEST_FILE}`;
const entries = await fetchJson<HttpManifestEntry[]>(manifestUrl);
if (!Array.isArray(entries) || entries.length === 0) {
throw new Error(`No se encontraron archivos en ${manifestUrl}.`);
}
return entries.map((rawEntry) => {
const normalized = normalizeManifestEntry(rawEntry, game.title);
const cleanPath = normalized.path.replace(/^\/+/, '');
return {
path: `games/${game.repoFolder}/${cleanPath}`,
size: Math.max(0, normalized.size),
downloadUrl: normalized.explicitUrl ?? `${baseUrl}/games/${game.repoFolder}/${cleanPath}`,
};
});
};
const listInstalledGames = async () => {
await ensureAssetsStorageReady();
const entries = await readdir(assetsRoot, { withFileTypes: true }).catch(() => []);
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort((left, right) => left.localeCompare(right));
};
const listInstalledGamesAsRemote = async (): Promise<RemoteGame[]> => {
const installedGames = await listInstalledGames();
const cachedTitles = await loadCachedGameTitles();
return installedGames.map((slug) => ({
slug,
repoFolder: slug,
title: cachedTitles.get(slug) ?? titleFromSlug(slug),
logoFile: `${slug}.png`,
}));
};
const parseCharacterNames = (content: string, gameTitle: string) => {
const parsed = JSON.parse(content) as unknown;
const names = Array.isArray(parsed)
? parsed
: typeof parsed === 'object' && parsed !== null && Array.isArray((parsed as { characters?: unknown }).characters)
? (parsed as { characters: unknown[] }).characters
: null;
if (!names || names.some((name) => typeof name !== 'string')) {
throw new Error(`El archivo ${CHARACTER_NAMES_FILE} de ${gameTitle} no tiene un formato válido.`);
}
return names;
};
const listInstalledCharacterNamesByGame = async () => {
const installedGames = await listInstalledGames();
const charactersByGame = await Promise.all(installedGames.map(async (slug) => {
const sourcePath = path.join(assetsRoot, slug, CHARACTER_NAMES_FILE);
try {
const fileContent = await readFile(sourcePath, 'utf8');
const names = parseCharacterNames(fileContent, slug);
return [slug, names] as const;
} catch {
return [slug, []] as const;
}
}));
return Object.fromEntries(charactersByGame) as Record<string, string[]>;
};
const downloadGameAssets = async (gameSlug: string) => {
await ensureAssetsStorageReady();
const customTitles = await fetchCustomGameTitles();
const game: RemoteGame = {
slug: gameSlug,
repoFolder: gameSlug,
title: customTitles.get(gameSlug) ?? titleFromSlug(gameSlug),
logoFile: `${gameSlug}.png`,
};
emitProgress(game.slug, 0, 'downloading');
const files = await listHttpFiles(game);
if (!files.length) {
throw new Error(`No se encontraron archivos para ${game.title}.`);
}
const hasCharacterNamesFile = files.some((file) => file.path.endsWith(`/${CHARACTER_NAMES_FILE}`));
if (!hasCharacterNamesFile) {
throw new Error(`No se encontró ${CHARACTER_NAMES_FILE} para ${game.title}.`);
}
const totalBytes = files.reduce((acc, file) => acc + (file.size || 0), 0);
let downloadedBytes = 0;
const destinationFolder = path.join(assetsRoot, game.slug);
await rm(destinationFolder, { recursive: true, force: true });
for (const file of files) {
const relativePath = file.path.replace(`games/${game.repoFolder}/`, '');
const targetPath = path.join(destinationFolder, relativePath);
await mkdir(path.dirname(targetPath), { recursive: true });
const response = await fetch(file.downloadUrl, { headers: requestHeaders });
if (!response.ok) {
throw new Error(`No se pudo descargar ${file.path} (status ${response.status}).`);
}
const arrayBuffer = await response.arrayBuffer();
await writeFile(targetPath, Buffer.from(arrayBuffer));
downloadedBytes += file.size || 0;
const progress = totalBytes > 0 ? Math.round((downloadedBytes / totalBytes) * 100) : 100;
emitProgress(game.slug, progress, 'downloading');
}
emitProgress(game.slug, 100, 'completed');
return {
title: game.title,
slug: game.slug,
};
};
const removeGameAssets = async (gameSlug: string) => {
await ensureAssetsStorageReady();
const customTitles = await fetchCustomGameTitles();
const destinationFolder = path.join(assetsRoot, gameSlug);
await rm(destinationFolder, { recursive: true, force: true });
return {
title: customTitles.get(gameSlug) ?? titleFromSlug(gameSlug),
slug: gameSlug,
};
};
nodecg.listenFor('scoreko-assets:listRemoteGames', async (_payload: unknown, ack) => {
if (typeof ack !== 'function') {
return;
}
try {
const remoteGames = await listRemoteGames();
const titlesToCache = new Map<string, string>();
remoteGames.forEach((game) => {
titlesToCache.set(game.slug, game.title);
});
await saveCachedGameTitles(titlesToCache);
ack(null, remoteGames);
} catch (error) {
try {
const installedGames = await listInstalledGamesAsRemote();
if (installedGames.length > 0) {
ack(null, installedGames);
return;
}
} catch {
// noop
}
ack((error as Error).message);
}
});
nodecg.listenFor('scoreko-assets:listInstalled', async (_payload: unknown, ack) => {
if (typeof ack !== 'function') {
return;
}
try {
ack(null, await listInstalledGames());
} catch (error) {
ack((error as Error).message);
}
});
nodecg.listenFor('scoreko-assets:listCharactersByGame', async (_payload: unknown, ack) => {
if (typeof ack !== 'function') {
return;
}
try {
ack(null, await listInstalledCharacterNamesByGame());
} catch (error) {
ack((error as Error).message);
}
});
nodecg.listenFor('scoreko-assets:getAssetsBaseUrl', async (_payload: unknown, ack) => {
if (typeof ack !== 'function') {
return;
}
try {
ack(null, { assetsBaseUrl: getConfiguredAssetsBaseUrl() });
} catch (error) {
ack((error as Error).message);
}
});
nodecg.listenFor('scoreko-assets:downloadGame', async (payload: unknown, ack) => {
if (typeof ack !== 'function') {
return;
}
try {
const slug = typeof payload === 'object' && payload !== null ? (payload as { slug?: unknown }).slug : undefined;
if (typeof slug !== 'string') {
throw new Error('Slug de juego inválido.');
}
const downloaded = await downloadGameAssets(slug);
ack(null, {
downloaded,
installedGames: await listInstalledGames(),
});
} catch (error) {
if (typeof payload === 'object' && payload !== null && typeof (payload as { slug?: unknown }).slug === 'string') {
emitProgress((payload as { slug: string }).slug, 0, 'error');
}
ack((error as Error).message);
}
});
nodecg.listenFor('scoreko-assets:removeGame', async (payload: unknown, ack) => {
if (typeof ack !== 'function') {
return;
}
try {
const slug = typeof payload === 'object' && payload !== null ? (payload as { slug?: unknown }).slug : undefined;
if (typeof slug !== 'string') {
throw new Error('Slug de juego inválido.');
}
const removed = await removeGameAssets(slug);
ack(null, {
removed,
installedGames: await listInstalledGames(),
});
} catch (error) {
ack((error as Error).message);
}
});
+1
View File
@@ -11,4 +11,5 @@ export default async (nodecg: NodeCGServerAPI) => {
await import('./example.js'); await import('./example.js');
await import('./startgg.js'); await import('./startgg.js');
await import('./challonge.js'); await import('./challonge.js');
await import('./game-assets.js');
}; };
+7 -4
View File
@@ -3,7 +3,7 @@ import { useHead } from '@unhead/vue';
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'; import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../browser_shared/replicants'; import { graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../browser_shared/replicants';
import { resolveCountryCode } from '../../shared/countries'; import { resolveCountryCode } from '../../shared/countries';
import { getCharactersByGame } from '../../shared/fighting-characters'; import { getCharacterAssetUrl } from '../../shared/fighting-characters';
import type { Schemas } from '../../types'; import type { Schemas } from '../../types';
useHead({ title: 'Scoreboard 2XKO' }); useHead({ title: 'Scoreboard 2XKO' });
@@ -35,9 +35,12 @@ const rightName = computed(() => scoreboard.value.rightNameOverride || players.v
const leftTeam = computed(() => scoreboard.value.leftTeamOverride); const leftTeam = computed(() => scoreboard.value.leftTeamOverride);
const rightTeam = computed(() => scoreboard.value.rightTeamOverride); const rightTeam = computed(() => scoreboard.value.rightTeamOverride);
const charMap = new Map(getCharactersByGame('2XKO').map((char) => [char.value, char.image])); const leftCharacterImage = computed(() => scoreboard.value.leftCharacter
const leftCharacterImage = computed(() => charMap.get(scoreboard.value.leftCharacter) ?? ''); ? getCharacterAssetUrl('2XKO', scoreboard.value.leftCharacter)
const rightCharacterImage = computed(() => charMap.get(scoreboard.value.rightCharacter) ?? ''); : '');
const rightCharacterImage = computed(() => scoreboard.value.rightCharacter
? getCharacterAssetUrl('2XKO', scoreboard.value.rightCharacter)
: '');
const flagModules = import.meta.glob('/node_modules/flag-icons/flags/4x3/*.svg', { import: 'default', query: '?url' }) as Record<string, () => Promise<string>>; const flagModules = import.meta.glob('/node_modules/flag-icons/flags/4x3/*.svg', { import: 'default', query: '?url' }) as Record<string, () => Promise<string>>;
const flagUrlCache: Record<string, string> = {}; const flagUrlCache: Record<string, string> = {};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 471 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 780 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 371 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 456 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 KiB

-21
View File
@@ -1,21 +0,0 @@
# Character image catalog
Put custom character images here using this structure:
```text
src/shared/character-images/
street-fighter-6/
ryu.png
ken.png
tekken-8/
jin.png
```
Rules:
- Folder name = game slug (`toLowerCase`, replace non alphanumeric with `-`).
- Example: `Guilty Gear -Strive-` -> `guilty-gear-strive`
- File name = character slug with the same rule.
- Example: `Chun-Li` -> `chun-li`
- Supported extensions: `.png`, `.jpg`, `.jpeg`, `.webp`, `.avif`, `.svg`.
- If an image is missing, the dashboard shows a generated placeholder preview.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

Some files were not shown because too many files have changed in this diff Show More