diff --git a/src/dashboard/scoreko-dev/components/ScoreboardPanel.vue b/src/dashboard/scoreko-dev/components/ScoreboardPanel.vue index 354187b..60bda15 100644 --- a/src/dashboard/scoreko-dev/components/ScoreboardPanel.vue +++ b/src/dashboard/scoreko-dev/components/ScoreboardPanel.vue @@ -1,14 +1,16 @@ + + + + diff --git a/src/extension/game-assets.ts b/src/extension/game-assets.ts new file mode 100644 index 0000000..cb94bcf --- /dev/null +++ b/src/extension/game-assets.ts @@ -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)['0'] + ?? (req.params as Record)['']; + 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 (url: string): Promise => { + 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; +}; + +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 => { + const githubRef = await getDefaultBranch(); + const url = `${GITHUB_API_BASE}/${repoPath}?ref=${encodeURIComponent(githubRef)}`; + const entries = await fetchJson(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); + } +}); diff --git a/src/extension/index.ts b/src/extension/index.ts index d8ce32c..aebf858 100644 --- a/src/extension/index.ts +++ b/src/extension/index.ts @@ -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'); }; diff --git a/src/shared/fighting-characters.ts b/src/shared/fighting-characters.ts index 89e0d6a..aad22cf 100644 --- a/src/shared/fighting-characters.ts +++ b/src/shared/fighting-characters.ts @@ -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 { +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 = Object.fromEntries( Object.entries(characterNamesByGame).map(([game, characterNames]) => [ game, @@ -339,6 +347,8 @@ export const fightingCharactersByGame: Record label: character, value, image: getCharacterImage(game, character, value), + bundledImage: getBundledCharacterImage(game, value), + fallbackImage: buildCharacterPlaceholder(game, character), }; }), ]), diff --git a/src/shared/fighting-games.ts b/src/shared/fighting-games.ts new file mode 100644 index 0000000..3ce88f4 --- /dev/null +++ b/src/shared/fighting-games.ts @@ -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]));