27 Commits

Author SHA1 Message Date
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
104 changed files with 9495 additions and 12614 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"
]
} }
-12277
View File
File diff suppressed because it is too large Load Diff
+34 -36
View File
@@ -13,50 +13,20 @@
"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\""
}, },
"devDependencies": {
"@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 +71,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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

+19 -236
View File
@@ -2,6 +2,8 @@ export interface FightingCharacterOption {
label: string; label: string;
value: string; value: string;
image: string; image: string;
bundledImage: string;
fallbackImage: string;
} }
type GamePalette = readonly [startColor: string, endColor: string]; type GamePalette = readonly [startColor: string, endColor: string];
@@ -9,224 +11,6 @@ type GamePalette = readonly [startColor: string, endColor: string];
const DEFAULT_PLACEHOLDER_PALETTE: GamePalette = ['#334155', '#0f172a']; const DEFAULT_PLACEHOLDER_PALETTE: GamePalette = ['#334155', '#0f172a'];
const MAX_INITIALS = 2; const MAX_INITIALS = 2;
const characterNamesByGame: Record<string, string[]> = {
'Guilty Gear -Strive-': [
'A.B.A',
'Anji Mito',
'Asuka R. Kreutz',
'Axl Low',
'Baiken',
'Bedman?',
'Bridget',
'Chipp Zanuff',
'Dizzy',
'Elphelt Valentine',
'Faust',
'Giovanna',
'Goldlewis Dickinson',
'Happy Chaos',
'I-No',
'Jack-O',
'Johnny',
'Ky Kiske',
'Leo Whitefang',
'Lucy',
'May',
'Millia Rage',
'Nagoriyuki',
'Potemkin',
'Ramlethal Valentine',
'Sin Kiske',
'Slayer',
'Sol Badguy',
'Testament',
'Unika',
'Venom',
'Zato-1',
],
'Street Fighter 6': [
'A.K.I.',
'Akuma',
'Alex',
'Bison',
'Blanka',
'Cammy',
'Chun-Li',
'Dee Jay',
'Dhalsim',
'E. Honda',
'Ed',
'Elena',
'Guile',
'Jamie',
'JP',
'Juri',
'Ken',
'Kimberly',
'Lily',
'Luke',
'Mai',
'Manon',
'Marisa',
'Rashid',
'Ryu',
'Sagat',
'Terry',
'Viper',
'Zangief',
],
'TEKKEN 8': [
'Alisa',
'Anna',
'Armor King',
'Asuka',
'Azucena',
'Bob',
'Bryan',
'Claudio',
'Clive',
'Devil Jin',
'Dragunov',
'Eddy',
'Fahkumram',
'Feng',
'Heihachi',
'Hwoarang',
'Jack-8',
'Jin',
'Jun',
'Kazuya',
'King',
'Kuma',
'Kunimitsu',
'Lars',
'Law',
'Lee',
'Leo',
'Leroy',
'Lidia',
'Lili',
'Miary Zo',
'Nina',
'Panda',
'Paul',
'Raven',
'Reina',
'Roger Jr',
'Shaheen',
'Steve',
'Victor',
'Xiaoyu',
'Yoshimitsu',
'Zafina',
],
'2XKO': [
'Ahri',
'Akali',
'Braum',
'Caitlyn',
'Darius',
'Ekko',
'Illaoi',
'Jinx',
'Senna',
'Teemo',
'Vi',
'Warwick',
'Yasuo',
],
'Mortal Kombat 1': [
'Ashrah',
'Baraka',
'Conan the Barbarian',
'Cyrax',
'Ermac',
'Geras',
'Ghostface',
'Havik',
'Homelander',
'Johnny Cage',
'Kenshi',
'Kitana',
'Kung Lao',
'Li Mei',
'Liu Kang',
'Mileena',
'Nitara',
'Noob Saibot',
'Omni-Man',
'Peacemaker',
'Quan Chi',
'Raiden',
'Rain',
'Reiko',
'Reptile',
'Scorpion',
'Sektor',
'Shang Tsung',
'Sindel',
'Smoke',
'Sub-Zero',
'Takeda',
'Tanya',
'T-1000',
],
'THE KING OF FIGHTERS XV': [
'Angel',
'Antonov',
'Ash Crimson',
'Athena Asamiya',
'Benimaru Nikaido',
'Billy Kane',
'Blue Mary',
'Chizuru Kagura',
'Chris',
'Clark Still',
'Dolores',
'Duo Lon',
'Elisabeth Blanctorche',
'Gato',
'Geese Howard',
'Goenitz',
'Heidern',
'Hinako Shijo',
'Iori Yagami',
'Isla',
'Joe Higashi',
"K'",
'Kim Kaphwan',
'King',
'King of Dinosaurs',
'Krohnen McDougall',
'Kula Diamond',
'Kukri',
'Kyo Kusanagi',
'Leona Heidern',
'Luong',
'Mai Shiranui',
'Maxima',
'Meitenkun',
'Najd',
'Orochi Chris',
'Orochi Shermie',
'Orochi Yashiro',
'Ralf Jones',
'Ramón',
'Robert Garcia',
'Rock Howard',
'Ryo Sakazaki',
'Ryuji Yamazaki',
'Shermie',
'Shingo Yabuki',
'Sylvie Paula Paula',
'Terry Bogard',
'Vanessa',
'Whip',
'Yashiro Nanakase',
'Yuri Sakazaki',
],
};
const defaultCharacterPairByGame: Record<string, { leftCharacter: string; rightCharacter: string }> = { const defaultCharacterPairByGame: Record<string, { leftCharacter: string; rightCharacter: string }> = {
'Guilty Gear -Strive-': { 'Guilty Gear -Strive-': {
leftCharacter: 'sol-badguy', leftCharacter: 'sol-badguy',
@@ -323,27 +107,26 @@ const characterImageByKey = Object.entries(characterImageModules).reduce<Record<
return acc; return acc;
}, {}); }, {});
const getCharacterImage = (game: string, character: string, characterValue: string) => { const getBundledCharacterImage = (game: string, characterValue: string) => {
const gameSlug = toSlug(game); const gameSlug = toSlug(game);
const key = `${gameSlug}/${characterValue}`; return characterImageByKey[`${gameSlug}/${characterValue}`] ?? '';
return characterImageByKey[key] ?? buildCharacterPlaceholder(game, character);
}; };
export const fightingCharactersByGame: Record<string, FightingCharacterOption[]> = Object.fromEntries( export const getCharacterAssetUrl = (game: string, characterValue: string) => {
Object.entries(characterNamesByGame).map(([game, characterNames]) => [ const gameSlug = toSlug(game);
game, return `/bundles/scoreko-dev/game-assets/${gameSlug}/characters/${characterValue}.png`;
characterNames.map((character) => { };
const value = toSlug(character);
// Prefer packaged artwork and gracefully fallback to a generated image.
return {
label: character,
value,
image: getCharacterImage(game, character, value),
};
}),
]),
);
export const getCharactersByGame = (game: string) => fightingCharactersByGame[game] ?? []; export const buildCharactersByGame = (game: string, characterNames: string[]) => characterNames.map((character) => {
const value = toSlug(character);
return {
label: character,
value,
image: getCharacterAssetUrl(game, value),
bundledImage: getBundledCharacterImage(game, value),
fallbackImage: buildCharacterPlaceholder(game, character),
};
});
export const getDefaultCharactersByGame = (game: string) => defaultCharacterPairByGame[game]; export const getDefaultCharactersByGame = (game: string) => defaultCharacterPairByGame[game];

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