Merge pull request #145 from Pandipipas/add-new-view-for-fighting-games-uyw5fv

Add game assets manager (backend, store, UI) and character image fallbacks
This commit is contained in:
Pandipipas
2026-03-03 15:48:24 +01:00
committed by GitHub
10 changed files with 853 additions and 25 deletions
@@ -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 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,101 @@ 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) => ({
const allFightingGameOptions = computed(() => gameAssetsStore.installedGames.map((game) => ({
label: 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 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 +195,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,7 +713,7 @@ 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 },
@@ -719,6 +785,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 +805,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 +836,7 @@ watch(
:src="leftPanelImage"
:alt="`${leftDisplayName || t('scoreboardLeft')} ${t('scoreboardPreview')}`"
class="scoreboard-preview__image"
@error="onLeftImageError"
>
<div
v-else
@@ -1075,6 +1144,7 @@ watch(
:src="rightPanelImage"
:alt="`${rightDisplayName || t('scoreboardRight')} ${t('scoreboardPreview')}`"
class="scoreboard-preview__image"
@error="onRightImageError"
>
<div
v-else
+3
View File
@@ -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',
+1
View File
@@ -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
View File
@@ -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,120 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
type DownloadStatus = 'downloading' | 'completed' | 'error';
type ProgressPayload = {
title: string;
progress: number;
status: DownloadStatus;
};
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 loadingByTitle = ref<Record<string, boolean>>({});
const removingByTitle = ref<Record<string, boolean>>({});
const progressByTitle = ref<Record<string, number>>({});
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 refreshInstalledGames = async () => {
const response = await sendNodecgMessage<string[]>('scoreko-assets:listInstalled');
installedGames.value = Array.isArray(response) ? response : [];
return installedGames.value;
};
const downloadGame = async (title: string) => {
loadingByTitle.value = {
...loadingByTitle.value,
[title]: true,
};
progressByTitle.value = {
...progressByTitle.value,
[title]: 0,
};
try {
const response = await sendNodecgMessage<{ installedGames: string[] }>('scoreko-assets:downloadGame', { title });
installedGames.value = response.installedGames;
loadingByTitle.value = {
...loadingByTitle.value,
[title]: false,
};
progressByTitle.value = {
...progressByTitle.value,
[title]: 100,
};
return response;
} catch (error) {
loadingByTitle.value = {
...loadingByTitle.value,
[title]: false,
};
throw error;
}
};
const removeGame = async (title: string) => {
removingByTitle.value = {
...removingByTitle.value,
[title]: true,
};
try {
const response = await sendNodecgMessage<{ installedGames: string[] }>('scoreko-assets:removeGame', { title });
installedGames.value = response.installedGames;
return response;
} finally {
removingByTitle.value = {
...removingByTitle.value,
[title]: false,
};
}
};
return {
installedGames,
loadingByTitle,
removingByTitle,
progressByTitle,
refreshInstalledGames,
downloadGame,
removeGame,
};
});
@@ -0,0 +1,308 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useGameAssetsStore } from '../stores/game-assets';
import { fightingGamesCatalog } from '../../../shared/fighting-games';
const gameAssetsStore = useGameAssetsStore();
const errorMessage = ref('');
const selectedGameSlug = ref<string | null>(null);
const search = ref('');
const gameLogoModules = import.meta.glob('/src/shared/game-logos/*.png', {
eager: true,
import: 'default',
query: '?url',
}) as Record<string, string>;
const getGameLogoUrl = (logoFile: string) => gameLogoModules[`/src/shared/game-logos/${logoFile}`] ?? '';
const normalizedSearch = computed(() => search.value.trim().toLowerCase());
const filteredGames = computed(() => {
if (!normalizedSearch.value) {
return fightingGamesCatalog;
}
return fightingGamesCatalog.filter((game) => game.title.toLowerCase().includes(normalizedSearch.value));
});
const selectedGame = computed(() => fightingGamesCatalog.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 openTrailer = (trailerUrl: string) => {
window.open(trailerUrl, '_blank', 'noopener,noreferrer');
};
const downloadGameByTitle = async (title: string) => {
errorMessage.value = '';
try {
await gameAssetsStore.downloadGame(title);
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : 'No se pudieron descargar los assets.';
}
};
const removeGameByTitle = async (title: string) => {
errorMessage.value = '';
try {
await gameAssetsStore.removeGame(title);
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : 'No se pudieron borrar los assets.';
}
};
const onDownloadFromDialog = async () => {
if (!selectedGame.value) {
return;
}
const targetTitle = selectedGame.value.title;
closeGameDialog();
await downloadGameByTitle(targetTitle);
};
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.logoFile)"
fit="contain"
height="74px"
/>
<div
v-if="gameAssetsStore.loadingByTitle[game.title]"
class="download-overlay"
:style="{ '--progress-width': `${gameAssetsStore.progressByTitle[game.title] ?? 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.title)"
flat
round
size="md"
icon="check_circle"
color="positive"
disable
/>
<QBtn
v-else
flat
round
size="md"
:icon="gameAssetsStore.loadingByTitle[game.title] ? 'autorenew' : 'download'"
:class="{ 'downloading-spin': gameAssetsStore.loadingByTitle[game.title] }"
color="white"
:disable="gameAssetsStore.loadingByTitle[game.title]"
@click="downloadGameByTitle(game.title)"
/>
</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.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">
{{ selectedGame.description }}
</div>
<div class="text-caption text-weight-medium q-mb-md">
Peso aprox: {{ selectedGame.approxSize }}
</div>
<div class="row q-gutter-sm justify-end">
<QBtn
v-if="installedGameSet.has(selectedGame.title)"
flat
color="negative"
icon="delete"
:loading="gameAssetsStore.removingByTitle[selectedGame.title]"
label="Borrar assets"
@click="removeGameByTitle(selectedGame.title); closeGameDialog()"
/>
<QBtn
flat
color="secondary"
icon="smart_display"
label="Trailer"
@click="openTrailer(selectedGame.trailerUrl)"
/>
<QBtn
color="primary"
icon="download"
:disable="installedGameSet.has(selectedGame.title)"
:label="installedGameSet.has(selectedGame.title) ? '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>
+245
View File
@@ -0,0 +1,245 @@
import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { nodecg } from './util/nodecg.js';
const GITHUB_OWNER = 'Pandipipas';
const GITHUB_REPO = 'scoreko-assets';
const GITHUB_API_BASE = `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/contents`;
let cachedDefaultBranch: string | null = null;
const gameCatalog = [
{ title: 'Street Fighter 6', slug: 'street-fighter-6', repoFolder: 'street-fighter-6' },
{ title: 'TEKKEN 8', slug: 'tekken-8', repoFolder: 'tekken-8' },
{ title: '2XKO', slug: '2xko', repoFolder: '2xko' },
{ title: 'Guilty Gear -Strive-', slug: 'guilty-gear-strive', repoFolder: 'guilty-gear-strive' },
{ title: 'Mortal Kombat 1', slug: 'mortal-kombat-1', repoFolder: 'mortal-kombat-1' },
{ title: 'THE KING OF FIGHTERS XV', slug: 'the-king-of-fighters-xv', repoFolder: 'the-king-of-fighters-xv' },
] as const;
type GitHubContentEntry = {
type: 'file' | 'dir';
path: string;
size: number;
download_url: string | null;
};
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
const bundleRoot = path.resolve(extensionDir, '..');
const assetsRoot = path.join(bundleRoot, 'game-assets');
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 githubHeaders = {
Accept: 'application/vnd.github+json',
'User-Agent': 'scoreko-dev-nodecg-bundle',
};
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: githubHeaders });
if (!response.ok) {
throw new Error(`GitHub API error (${response.status}) while requesting ${url}`);
}
return response.json() as Promise<T>;
};
const getDefaultBranch = async () => {
if (cachedDefaultBranch) {
return cachedDefaultBranch;
}
const repoMetadata = await fetchJson<{ default_branch?: string }>(`https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}`);
cachedDefaultBranch = repoMetadata.default_branch || 'main';
return cachedDefaultBranch;
};
const listRepoFilesRecursively = async (repoPath: string): Promise<GitHubContentEntry[]> => {
const githubRef = await getDefaultBranch();
const url = `${GITHUB_API_BASE}/${repoPath}?ref=${encodeURIComponent(githubRef)}`;
const entries = await fetchJson<GitHubContentEntry[]>(url);
const files: GitHubContentEntry[] = [];
for (const entry of entries) {
if (entry.type === 'file') {
files.push(entry);
continue;
}
if (entry.type === 'dir') {
const nested = await listRepoFilesRecursively(entry.path);
files.push(...nested);
}
}
return files;
};
const listInstalledGames = async () => {
const entries = await readdir(assetsRoot, { withFileTypes: true }).catch(() => []);
const installedSlugs = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
return gameCatalog.filter((game) => installedSlugs.includes(game.slug)).map((game) => game.title);
};
const downloadGameAssets = async (gameTitle: string) => {
const game = gameCatalog.find((entry) => entry.title === gameTitle);
if (!game) {
throw new Error('Juego no soportado en el catálogo.');
}
const repoFolderPath = `games/${game.repoFolder}`;
emitProgress(game.title, 0, 'downloading');
const files = await listRepoFilesRecursively(repoFolderPath);
if (!files.length) {
throw new Error(`No se encontraron archivos en ${repoFolderPath} dentro de scoreko-assets.`);
}
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) {
if (!file.download_url) {
continue;
}
const relativePath = file.path.replace(`${repoFolderPath}/`, '');
const targetPath = path.join(destinationFolder, relativePath);
await mkdir(path.dirname(targetPath), { recursive: true });
const response = await fetch(file.download_url, { headers: githubHeaders });
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.title, progress, 'downloading');
}
emitProgress(game.title, 100, 'completed');
return {
title: game.title,
slug: game.slug,
};
};
const removeGameAssets = async (gameTitle: string) => {
const game = gameCatalog.find((entry) => entry.title === gameTitle);
if (!game) {
throw new Error('Juego no soportado en el catálogo.');
}
const destinationFolder = path.join(assetsRoot, game.slug);
await rm(destinationFolder, { recursive: true, force: true });
return {
title: game.title,
slug: game.slug,
};
};
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:downloadGame', async (payload: unknown, ack) => {
if (typeof ack !== 'function') {
return;
}
try {
const title = typeof payload === 'object' && payload !== null ? (payload as { title?: unknown }).title : undefined;
if (typeof title !== 'string') {
throw new Error('Título de juego inválido.');
}
const downloaded = await downloadGameAssets(title);
ack(null, {
downloaded,
installedGames: await listInstalledGames(),
});
} catch (error) {
if (typeof payload === 'object' && payload !== null && typeof (payload as { title?: unknown }).title === 'string') {
emitProgress((payload as { title: string }).title, 0, 'error');
}
ack((error as Error).message);
}
});
nodecg.listenFor('scoreko-assets:removeGame', async (payload: unknown, ack) => {
if (typeof ack !== 'function') {
return;
}
try {
const title = typeof payload === 'object' && payload !== null ? (payload as { title?: unknown }).title : undefined;
if (typeof title !== 'string') {
throw new Error('Título de juego inválido.');
}
const removed = await removeGameAssets(title);
ack(null, {
removed,
installedGames: await listInstalledGames(),
});
} catch (error) {
ack((error as Error).message);
}
});
+1
View File
@@ -11,4 +11,5 @@ export default async (nodecg: NodeCGServerAPI) => {
await import('./example.js');
await import('./startgg.js');
await import('./challonge.js');
await import('./game-assets.js');
};
+13 -3
View File
@@ -2,6 +2,8 @@ export interface FightingCharacterOption {
label: string;
value: string;
image: string;
bundledImage: string;
fallbackImage: string;
}
type GamePalette = readonly [startColor: string, endColor: string];
@@ -294,6 +296,11 @@ const buildCharacterPlaceholder = (game: string, character: string) => {
return toDataUrl(svg.trim());
};
const getCharacterAssetUrl = (game: string, characterValue: string) => {
const gameSlug = toSlug(game);
return `/bundles/scoreko-dev/game-assets/${gameSlug}/characters/${characterValue}.png`;
};
const characterImageModules = import.meta.glob('/src/shared/character-images/**/*.{png,jpg,jpeg,webp,avif,svg}', {
eager: true,
import: 'default',
@@ -323,12 +330,13 @@ const characterImageByKey = Object.entries(characterImageModules).reduce<Record<
return acc;
}, {});
const getCharacterImage = (game: string, character: string, characterValue: string) => {
const getBundledCharacterImage = (game: string, characterValue: string) => {
const gameSlug = toSlug(game);
const key = `${gameSlug}/${characterValue}`;
return characterImageByKey[key] ?? buildCharacterPlaceholder(game, character);
return characterImageByKey[`${gameSlug}/${characterValue}`] ?? '';
};
const getCharacterImage = (game: string, character: string, characterValue: string) => getCharacterAssetUrl(game, characterValue);
export const fightingCharactersByGame: Record<string, FightingCharacterOption[]> = Object.fromEntries(
Object.entries(characterNamesByGame).map(([game, characterNames]) => [
game,
@@ -339,6 +347,8 @@ export const fightingCharactersByGame: Record<string, FightingCharacterOption[]>
label: character,
value,
image: getCharacterImage(game, character, value),
bundledImage: getBundledCharacterImage(game, value),
fallbackImage: buildCharacterPlaceholder(game, character),
};
}),
]),
+68
View File
@@ -0,0 +1,68 @@
export interface FightingGameCatalogEntry {
title: string;
slug: string;
logoFile: string;
trailerUrl: string;
description: string;
approxSize: string;
repoFolder: string;
}
export const fightingGamesCatalog: FightingGameCatalogEntry[] = [
{
title: 'Street Fighter 6',
slug: 'street-fighter-6',
logoFile: 'street-fighter-6.png',
trailerUrl: 'https://www.youtube.com/watch?v=HfJ5UvzS0x0',
description: 'Combates rápidos con Drive System, roster moderno y estilo arcade competitivo.',
approxSize: '~45 MB',
repoFolder: 'street-fighter-6',
},
{
title: 'TEKKEN 8',
slug: 'tekken-8',
logoFile: 'tekken-8.png',
trailerUrl: 'https://www.youtube.com/watch?v=07FdDRbdurg',
description: '3D fighter con sistema Heat y enfoque agresivo para partidas espectaculares.',
approxSize: '~58 MB',
repoFolder: 'tekken-8',
},
{
title: '2XKO',
slug: '2xko',
logoFile: '2xko.png',
trailerUrl: 'https://www.youtube.com/watch?v=5hugGCZon3I',
description: 'Tag fighter 2v2 de Riot con assists y mecánicas de equipo.',
approxSize: '~26 MB',
repoFolder: '2xko',
},
{
title: 'Guilty Gear -Strive-',
slug: 'guilty-gear-strive',
logoFile: 'guilty-gear-strive.png',
trailerUrl: 'https://www.youtube.com/watch?v=8X5cQMdqZEA',
description: 'Anime fighter de Arc System Works con alto impacto visual y neutral explosivo.',
approxSize: '~33 MB',
repoFolder: 'guilty-gear-strive',
},
{
title: 'Mortal Kombat 1',
slug: 'mortal-kombat-1',
logoFile: 'mortal-kombat-1.png',
trailerUrl: 'https://www.youtube.com/watch?v=UZ6eFEjFfJ0',
description: 'Reinicio de la saga con Kameos, cast clásico y estética cinematográfica.',
approxSize: '~40 MB',
repoFolder: 'mortal-kombat-1',
},
{
title: 'THE KING OF FIGHTERS XV',
slug: 'the-king-of-fighters-xv',
logoFile: 'the-king-of-fighters-xv.png',
trailerUrl: 'https://www.youtube.com/watch?v=q3lX2p_Uy9I',
description: 'Combates 3v3 clásicos de SNK, ritmo rápido y amplio plantel.',
approxSize: '~36 MB',
repoFolder: 'the-king-of-fighters-xv',
},
];
export const fightingGameByTitle = Object.fromEntries(fightingGamesCatalog.map((entry) => [entry.title, entry]));