Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d956dfb30b | |||
| 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
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -134,3 +134,4 @@ dist
|
||||
/extension/
|
||||
/graphics/
|
||||
/shared/dist/
|
||||
/db/
|
||||
@@ -9,8 +9,7 @@ NodeCG bundle for producing fighting game overlays.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 22+
|
||||
- NodeCG 2.3+
|
||||
- Node.js 24.14.0+
|
||||
|
||||
## Scripts
|
||||
|
||||
@@ -18,9 +17,105 @@ NodeCG bundle for producing fighting game overlays.
|
||||
- `npm run build`: builds dashboard/graphics and extension.
|
||||
- `npm run lint`: validates project linting.
|
||||
- `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.
|
||||
|
||||
## Usage
|
||||
|
||||
- `npm install`
|
||||
- `npm run build`
|
||||
- `npm run start` (equivalent to `npx nodecg start`)
|
||||
|
||||
## Version
|
||||
|
||||
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,
|
||||
"properties": {
|
||||
"exampleProperty": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"startggClientId": {
|
||||
"type": "string",
|
||||
@@ -39,9 +40,12 @@
|
||||
"minimum": 1,
|
||||
"maximum": 65535,
|
||||
"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": [
|
||||
"exampleProperty"
|
||||
]
|
||||
"required": []
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Electron wrapper (Windows)
|
||||
|
||||
Este wrapper crea una app de escritorio para Windows que lanza NodeCG sin requerir que el usuario final tenga Node.js instalado.
|
||||
|
||||
## Requisitos de build (solo para quien genera el instalador)
|
||||
|
||||
1. Instalar dependencias del bundle raíz:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
2. Instalar dependencias del wrapper:
|
||||
|
||||
```bash
|
||||
cd electron
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## Desarrollo local
|
||||
|
||||
Desde `electron/`:
|
||||
|
||||
```bash
|
||||
pnpm start
|
||||
```
|
||||
|
||||
## Generar instalador `.exe` (comprimido)
|
||||
|
||||
Desde `electron/`:
|
||||
|
||||
```bash
|
||||
pnpm dist:win
|
||||
```
|
||||
|
||||
Esto genera un instalador NSIS en `electron/dist/` con compresión máxima (`compression: maximum`).
|
||||
|
||||
## Qué incluye el instalador
|
||||
|
||||
- Runtime de Electron (incluye Node embebido).
|
||||
- Dependencia `nodecg` dentro de la app.
|
||||
- El bundle `scoreko-dev` como recurso (`resources/bundle`).
|
||||
|
||||
Con eso, el usuario final instala y ejecuta la app sin instalar Node.js aparte.
|
||||
|
||||
## Variables opcionales
|
||||
|
||||
- `NODECG_PORT` (por defecto `9090`)
|
||||
- `NODECG_HOST` (por defecto `127.0.0.1`)
|
||||
@@ -0,0 +1,118 @@
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const BUNDLE_ROOT = app.isPackaged
|
||||
? path.join(process.resourcesPath, 'bundle')
|
||||
: path.resolve(__dirname, '..');
|
||||
|
||||
const NODECG_PORT = Number.parseInt(process.env.NODECG_PORT ?? '9090', 10);
|
||||
const NODECG_HOST = process.env.NODECG_HOST ?? '127.0.0.1';
|
||||
const NODECG_URL = `http://${NODECG_HOST}:${NODECG_PORT}`;
|
||||
|
||||
let nodecgProcess;
|
||||
|
||||
function getNodecgCliPath() {
|
||||
return app.isPackaged
|
||||
? path.join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', 'nodecg', 'bin', 'nodecg.js')
|
||||
: path.join(__dirname, 'node_modules', 'nodecg', 'bin', 'nodecg.js');
|
||||
}
|
||||
|
||||
function startNodecg() {
|
||||
const nodecgCli = getNodecgCliPath();
|
||||
|
||||
nodecgProcess = spawn(process.execPath, [nodecgCli, 'start'], {
|
||||
cwd: BUNDLE_ROOT,
|
||||
env: {
|
||||
...process.env,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
NODECG_PORT: String(NODECG_PORT),
|
||||
},
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
nodecgProcess.on('exit', (code) => {
|
||||
if (code !== 0) {
|
||||
console.error(`[electron] NodeCG process exited with code ${code}`);
|
||||
}
|
||||
|
||||
if (!app.isQuitting) {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function stopNodecg() {
|
||||
if (!nodecgProcess || nodecgProcess.killed) {
|
||||
return;
|
||||
}
|
||||
|
||||
app.isQuitting = true;
|
||||
nodecgProcess.kill();
|
||||
}
|
||||
|
||||
async function waitForNodecg(retries = 80, delayMs = 250) {
|
||||
for (let attempt = 0; attempt < retries; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(NODECG_URL, { method: 'HEAD' });
|
||||
if (response.ok || response.status >= 300) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Ignore connection errors while NodeCG boots.
|
||||
}
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, delayMs);
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`NodeCG did not start in time at ${NODECG_URL}`);
|
||||
}
|
||||
|
||||
async function createWindow() {
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1366,
|
||||
height: 768,
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
},
|
||||
});
|
||||
|
||||
await mainWindow.loadURL(NODECG_URL);
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
startNodecg();
|
||||
|
||||
try {
|
||||
await waitForNodecg();
|
||||
await createWindow();
|
||||
} catch (error) {
|
||||
console.error('[electron] Failed to start wrapper:', error);
|
||||
app.quit();
|
||||
}
|
||||
|
||||
app.on('activate', async () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
await createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
app.isQuitting = true;
|
||||
stopNodecg();
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"name": "scoreko-dev-electron-wrapper",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"description": "Electron wrapper for running the scoreko-dev NodeCG bundle on Windows.",
|
||||
"main": "main.mjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"dist:win": "electron-builder --win nsis"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron": "40.6.1",
|
||||
"nodecg": "^2.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron-builder": "^26.0.12"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.scoreko.dev",
|
||||
"productName": "Scoreko Dev",
|
||||
"compression": "maximum",
|
||||
"asar": true,
|
||||
"directories": {
|
||||
"output": "dist"
|
||||
},
|
||||
"files": [
|
||||
"main.mjs",
|
||||
"package.json",
|
||||
"node_modules/**/*"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "../",
|
||||
"to": "bundle",
|
||||
"filter": [
|
||||
"package.json",
|
||||
"configschema.json",
|
||||
"LICENSE",
|
||||
"README.md",
|
||||
"schemas/**/*",
|
||||
"dashboard/**/*",
|
||||
"graphics/**/*",
|
||||
"extension/**/*",
|
||||
"src/**/*",
|
||||
"node_modules/**/*",
|
||||
"!electron/**/*",
|
||||
"!.git/**/*"
|
||||
]
|
||||
}
|
||||
],
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,50 +13,22 @@
|
||||
"license": "MIT",
|
||||
"author": "Pandipipas",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24.14.0"
|
||||
},
|
||||
"scripts": {
|
||||
"autofix": "eslint --fix",
|
||||
"prebuild": "trash ./extension && trash ./node_modules/.vite && trash ./shared/dist && trash ./dashboard && trash ./graphics",
|
||||
"build": "vite build && tsc -b tsconfig.extension.json",
|
||||
"lint": "eslint",
|
||||
"schema-types": "nodecg schema-types",
|
||||
"start": "cd ../.. && node index.js",
|
||||
"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"
|
||||
}
|
||||
"start": "nodecg start",
|
||||
"watch": "conc -n B,E -c red,blue -k vite \"tsc -b -w --preserveWatchOutput tsconfig.extension.json\"",
|
||||
"electron:start": "pnpm --dir electron start",
|
||||
"electron:dist": "pnpm --dir electron dist:win"
|
||||
},
|
||||
"nodecg": {
|
||||
"compatibleRange": "^2.3.0",
|
||||
"compatibleRange": "^2.6.0",
|
||||
"dashboardPanels": [
|
||||
{
|
||||
"name": "scoreko-dev",
|
||||
@@ -101,7 +73,35 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@quasar/extras": "^1.17.0",
|
||||
"@unhead/vue": "^2.0.19",
|
||||
"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">
|
||||
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 { getCharactersByGame, getDefaultCharactersByGame } from '../../../shared/fighting-characters';
|
||||
import { buildCharactersByGame, getDefaultCharactersByGame } from '../../../shared/fighting-characters';
|
||||
import type { Schemas } from '../../../types';
|
||||
import { usePlayersStore } from '../stores/players';
|
||||
import { useScoreboardStore } from '../stores/scoreboard';
|
||||
import { useGameAssetsStore } from '../stores/game-assets';
|
||||
import { locale, t } from '../i18n';
|
||||
|
||||
const playersStore = usePlayersStore();
|
||||
const scoreboardStore = useScoreboardStore();
|
||||
const gameAssetsStore = useGameAssetsStore();
|
||||
|
||||
const CUSTOM_LEFT_PLAYER_ID = '__custom_left_player__';
|
||||
const CUSTOM_RIGHT_PLAYER_ID = '__custom_right_player__';
|
||||
@@ -31,37 +33,108 @@ const rightCharacterInput = ref('');
|
||||
const gameInput = ref('');
|
||||
const charactersByGame = ref<Record<string, { leftCharacter: string; rightCharacter: string }>>({});
|
||||
|
||||
const allFightingGameOptions = [
|
||||
'2XKO',
|
||||
'Mortal Kombat 1',
|
||||
'Street Fighter 6',
|
||||
'TEKKEN 8',
|
||||
'Guilty Gear -Strive-',
|
||||
'THE KING OF FIGHTERS XV',
|
||||
].map((game) => ({
|
||||
label: game,
|
||||
const gameTitleBySlug = computed(() => new Map(
|
||||
gameAssetsStore.availableGames.map((game) => [game.slug, game.title] as const),
|
||||
));
|
||||
|
||||
const allFightingGameOptions = computed(() => gameAssetsStore.installedGames.map((game) => ({
|
||||
label: gameTitleBySlug.value.get(game) ?? game,
|
||||
value: game,
|
||||
}));
|
||||
})));
|
||||
|
||||
const fightingGameOptions = ref(allFightingGameOptions);
|
||||
const fightingGameOptions = ref(allFightingGameOptions.value);
|
||||
|
||||
const characterOptions = computed(() => getCharactersByGame(scoreboardStore.scoreboard.game));
|
||||
type CharacterOption = ReturnType<typeof getCharactersByGame>[number];
|
||||
const characterOptions = computed(() => buildCharactersByGame(
|
||||
scoreboardStore.scoreboard.game,
|
||||
gameAssetsStore.characterNamesByGame[scoreboardStore.scoreboard.game] ?? [],
|
||||
));
|
||||
type CharacterOption = ReturnType<typeof buildCharactersByGame>[number];
|
||||
const leftCharacterOptions = ref<CharacterOption[]>([]);
|
||||
const rightCharacterOptions = ref<CharacterOption[]>([]);
|
||||
|
||||
const leftCharacterImage = computed(() => {
|
||||
const match = characterOptions.value.find((option) => option.value === scoreboardStore.scoreboard.leftCharacter);
|
||||
return match?.image ?? '';
|
||||
const leftImageStage = ref<'asset' | 'bundled' | 'fallback'>('asset');
|
||||
const rightImageStage = ref<'asset' | 'bundled' | 'fallback'>('asset');
|
||||
|
||||
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 match = characterOptions.value.find((option) => option.value === scoreboardStore.scoreboard.rightCharacter);
|
||||
return match?.image ?? '';
|
||||
const rightPanelImage = computed(() => {
|
||||
const option = rightCharacterImage.value;
|
||||
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 rightPanelImage = computed(() => rightCharacterImage.value);
|
||||
const onLeftImageError = () => {
|
||||
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();
|
||||
@@ -129,10 +202,10 @@ const onGameFilter = (value: string, update: (callback: () => void) => void) =>
|
||||
update(() => {
|
||||
const needle = value.toLowerCase().trim();
|
||||
if (!needle) {
|
||||
fightingGameOptions.value = allFightingGameOptions;
|
||||
fightingGameOptions.value = allFightingGameOptions.value;
|
||||
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(
|
||||
() => scoreboardStore.scoreboard.game,
|
||||
(value) => {
|
||||
const match = allFightingGameOptions.find((option) => option.value === value);
|
||||
const match = allFightingGameOptions.value.find((option) => option.value === value);
|
||||
gameInput.value = match?.label ?? '';
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => scoreboardStore.scoreboard.game,
|
||||
(newGame, previousGame) => {
|
||||
() => [scoreboardStore.scoreboard.game, characterOptions.value] as const,
|
||||
([newGame, options], previousState) => {
|
||||
const previousGame = previousState?.[0];
|
||||
|
||||
if (newGame && gameAssetsStore.characterNamesByGame[newGame] === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (previousGame) {
|
||||
charactersByGame.value[previousGame] = {
|
||||
leftCharacter: scoreboardStore.scoreboard.leftCharacter,
|
||||
@@ -663,7 +742,6 @@ watch(
|
||||
};
|
||||
}
|
||||
|
||||
const options = getCharactersByGame(scoreboardStore.scoreboard.game);
|
||||
leftCharacterOptions.value = options;
|
||||
rightCharacterOptions.value = options;
|
||||
const allowed = new Set(options.map((option) => option.value));
|
||||
@@ -719,6 +797,7 @@ watch(countryOptions, (value) => {
|
||||
watch(
|
||||
() => scoreboardStore.scoreboard.leftCharacter,
|
||||
(value) => {
|
||||
leftImageStage.value = 'asset';
|
||||
const match = characterOptions.value.find((option) => option.value === value);
|
||||
leftCharacterInput.value = match?.label ?? '';
|
||||
|
||||
@@ -738,6 +817,7 @@ watch(
|
||||
watch(
|
||||
() => scoreboardStore.scoreboard.rightCharacter,
|
||||
(value) => {
|
||||
rightImageStage.value = 'asset';
|
||||
const match = characterOptions.value.find((option) => option.value === value);
|
||||
rightCharacterInput.value = match?.label ?? '';
|
||||
|
||||
@@ -768,6 +848,7 @@ watch(
|
||||
:src="leftPanelImage"
|
||||
:alt="`${leftDisplayName || t('scoreboardLeft')} ${t('scoreboardPreview')}`"
|
||||
class="scoreboard-preview__image"
|
||||
@error="onLeftImageError"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
@@ -1075,6 +1156,7 @@ watch(
|
||||
:src="rightPanelImage"
|
||||
:alt="`${rightDisplayName || t('scoreboardRight')} ${t('scoreboardPreview')}`"
|
||||
class="scoreboard-preview__image"
|
||||
@error="onRightImageError"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
|
||||
@@ -6,6 +6,7 @@ type Translations = {
|
||||
menuDashboard: string;
|
||||
menuPlayers: string;
|
||||
menuGraphics: string;
|
||||
menuAssets: string;
|
||||
menuSettings: string;
|
||||
menuAbout: string;
|
||||
settingsTitle: string;
|
||||
@@ -93,6 +94,7 @@ const messages: Record<Locale, Translations> = {
|
||||
menuDashboard: 'Dashboard',
|
||||
menuPlayers: 'Players',
|
||||
menuGraphics: 'Graphics',
|
||||
menuAssets: 'Game Assets',
|
||||
menuSettings: 'Settings',
|
||||
menuAbout: 'About',
|
||||
settingsTitle: 'Settings',
|
||||
@@ -176,6 +178,7 @@ const messages: Record<Locale, Translations> = {
|
||||
menuDashboard: 'Panel',
|
||||
menuPlayers: 'Jugadores',
|
||||
menuGraphics: 'Gráficos',
|
||||
menuAssets: 'Assets de juego',
|
||||
menuSettings: 'Configuración',
|
||||
menuAbout: 'Acerca de',
|
||||
settingsTitle: 'Configuración',
|
||||
|
||||
@@ -8,6 +8,7 @@ const menuItems = computed(() => [
|
||||
{ label: t('menuDashboard'), to: '/', icon: 'dashboard' },
|
||||
{ label: t('menuPlayers'), to: '/players', icon: 'groups' },
|
||||
{ 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('menuAbout'), to: '/about', icon: 'info' },
|
||||
]);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createRouter, createWebHashHistory } from 'vue-router';
|
||||
import AboutView from './views/About.vue';
|
||||
import DashboardView from './views/Dashboard.vue';
|
||||
import GraphicsView from './views/Graphics.vue';
|
||||
import GameAssetsView from './views/GameAssets.vue';
|
||||
import PlayersView from './views/Players.vue';
|
||||
import SettingsView from './views/Settings.vue';
|
||||
|
||||
@@ -11,6 +12,7 @@ const router = createRouter({
|
||||
{ path: '/', name: 'dashboard', component: DashboardView },
|
||||
{ path: '/players', name: 'players', component: PlayersView },
|
||||
{ path: '/graphics', name: 'graphics', component: GraphicsView },
|
||||
{ path: '/game-assets', name: 'game-assets', component: GameAssetsView },
|
||||
{ path: '/settings', name: 'settings', component: SettingsView },
|
||||
{ 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('./startgg.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 { graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../browser_shared/replicants';
|
||||
import { resolveCountryCode } from '../../shared/countries';
|
||||
import { getCharactersByGame } from '../../shared/fighting-characters';
|
||||
import { getCharacterAssetUrl } from '../../shared/fighting-characters';
|
||||
import type { Schemas } from '../../types';
|
||||
|
||||
useHead({ title: 'Scoreboard 2XKO' });
|
||||
@@ -35,9 +35,12 @@ const rightName = computed(() => scoreboard.value.rightNameOverride || players.v
|
||||
const leftTeam = computed(() => scoreboard.value.leftTeamOverride);
|
||||
const rightTeam = computed(() => scoreboard.value.rightTeamOverride);
|
||||
|
||||
const charMap = new Map(getCharactersByGame('2XKO').map((char) => [char.value, char.image]));
|
||||
const leftCharacterImage = computed(() => charMap.get(scoreboard.value.leftCharacter) ?? '');
|
||||
const rightCharacterImage = computed(() => charMap.get(scoreboard.value.rightCharacter) ?? '');
|
||||
const leftCharacterImage = computed(() => scoreboard.value.leftCharacter
|
||||
? getCharacterAssetUrl('2XKO', scoreboard.value.leftCharacter)
|
||||
: '');
|
||||
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 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 |