Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 20cc81e696 | |||
| 4db5c89f0a | |||
| 752232eeca | |||
| c1e9133970 | |||
| 774bd373d3 | |||
| e922a6061e | |||
| dbbf17c917 | |||
| 097c9014f9 | |||
| 2b2dd3180b | |||
| 69cb280ec1 | |||
| 586c95ec11 | |||
| 98f08e39d3 | |||
| c8097a72d8 | |||
| f91d5eaf48 | |||
| 5e6276ee19 | |||
| d4fe407b92 | |||
| a93492b86b | |||
| 13db5528a8 | |||
| fc82c9215a | |||
| 584f872954 | |||
| 057b5a29c3 | |||
| 1735e38edd | |||
| f8ffad02cb | |||
| d289b7e0b7 | |||
| 27e2e441c0 | |||
| 7ec56575d1 | |||
| d26e0df713 |
@@ -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
|
||||||
|
|||||||
@@ -134,3 +134,4 @@ dist
|
|||||||
/extension/
|
/extension/
|
||||||
/graphics/
|
/graphics/
|
||||||
/shared/dist/
|
/shared/dist/
|
||||||
|
/db/
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|||||||
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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> = {};
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 471 KiB |
|
Before Width: | Height: | Size: 558 KiB |
|
Before Width: | Height: | Size: 780 KiB |
|
Before Width: | Height: | Size: 371 KiB |
|
Before Width: | Height: | Size: 506 KiB |
|
Before Width: | Height: | Size: 356 KiB |
|
Before Width: | Height: | Size: 456 KiB |
|
Before Width: | Height: | Size: 500 KiB |
|
Before Width: | Height: | Size: 438 KiB |
|
Before Width: | Height: | Size: 417 KiB |
|
Before Width: | Height: | Size: 440 KiB |
|
Before Width: | Height: | Size: 322 KiB |
@@ -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.
|
|
||||||
|
Before Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 4.2 MiB |
|
Before Width: | Height: | Size: 4.4 MiB |
|
Before Width: | Height: | Size: 3.9 MiB |
|
Before Width: | Height: | Size: 4.2 MiB |
|
Before Width: | Height: | Size: 3.9 MiB |
|
Before Width: | Height: | Size: 3.4 MiB |
|
Before Width: | Height: | Size: 4.6 MiB |
|
Before Width: | Height: | Size: 3.3 MiB |
|
Before Width: | Height: | Size: 4.5 MiB |
|
Before Width: | Height: | Size: 4.0 MiB |
|
Before Width: | Height: | Size: 4.3 MiB |
|
Before Width: | Height: | Size: 4.6 MiB |
|
Before Width: | Height: | Size: 5.2 MiB |
|
Before Width: | Height: | Size: 4.9 MiB |
|
Before Width: | Height: | Size: 4.5 MiB |
|
Before Width: | Height: | Size: 4.1 MiB |
|
Before Width: | Height: | Size: 3.5 MiB |
|
Before Width: | Height: | Size: 3.3 MiB |
|
Before Width: | Height: | Size: 4.0 MiB |
|
Before Width: | Height: | Size: 4.3 MiB |
|
Before Width: | Height: | Size: 5.6 MiB |
|
Before Width: | Height: | Size: 4.0 MiB |
|
Before Width: | Height: | Size: 3.4 MiB |
|
Before Width: | Height: | Size: 4.2 MiB |
|
Before Width: | Height: | Size: 4.1 MiB |
|
Before Width: | Height: | Size: 4.5 MiB |
|
Before Width: | Height: | Size: 4.1 MiB |
|
Before Width: | Height: | Size: 4.1 MiB |
|
Before Width: | Height: | Size: 3.5 MiB |
|
Before Width: | Height: | Size: 3.5 MiB |
|
Before Width: | Height: | Size: 5.0 MiB |
|
Before Width: | Height: | Size: 4.2 MiB |
|
Before Width: | Height: | Size: 4.2 MiB |
|
Before Width: | Height: | Size: 3.7 MiB |
|
Before Width: | Height: | Size: 4.2 MiB |
|
Before Width: | Height: | Size: 4.0 MiB |
|
Before Width: | Height: | Size: 3.7 MiB |
|
Before Width: | Height: | Size: 4.2 MiB |
|
Before Width: | Height: | Size: 4.6 MiB |
|
Before Width: | Height: | Size: 4.1 MiB |
@@ -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) => {
|
};
|
||||||
|
|
||||||
|
export const buildCharactersByGame = (game: string, characterNames: string[]) => characterNames.map((character) => {
|
||||||
const value = toSlug(character);
|
const value = toSlug(character);
|
||||||
// Prefer packaged artwork and gracefully fallback to a generated image.
|
|
||||||
return {
|
return {
|
||||||
label: character,
|
label: character,
|
||||||
value,
|
value,
|
||||||
image: getCharacterImage(game, character, value),
|
image: getCharacterAssetUrl(game, value),
|
||||||
|
bundledImage: getBundledCharacterImage(game, value),
|
||||||
|
fallbackImage: buildCharacterPlaceholder(game, character),
|
||||||
};
|
};
|
||||||
}),
|
});
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const getCharactersByGame = (game: string) => fightingCharactersByGame[game] ?? [];
|
|
||||||
|
|
||||||
export const getDefaultCharactersByGame = (game: string) => defaultCharacterPairByGame[game];
|
export const getDefaultCharactersByGame = (game: string) => defaultCharacterPairByGame[game];
|
||||||
|
|||||||