mirror of
https://github.com/Pandipipas/scoreko-dev.git
synced 2026-06-06 03:32:06 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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,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>
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}),
|
||||
]),
|
||||
|
||||
@@ -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]));
|
||||
Reference in New Issue
Block a user