Merge pull request #146 from Pandipipas/add-character-names-download-with-assets

Descargar y usar listas de personajes por juego desde los assets
This commit is contained in:
Pandipipas
2026-03-03 16:22:12 +01:00
committed by GitHub
5 changed files with 96 additions and 251 deletions
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch, watchEffect, type Ref } from 'vue'; import { computed, onMounted, ref, watch, watchEffect, type Ref } from 'vue';
import { getCountryLabel, getCountryOptions } from '../../../shared/countries'; import { getCountryLabel, getCountryOptions } from '../../../shared/countries';
import { getCharactersByGame, getDefaultCharactersByGame } from '../../../shared/fighting-characters'; import { buildCharactersByGame, getDefaultCharactersByGame } from '../../../shared/fighting-characters';
import type { Schemas } from '../../../types'; import type { Schemas } from '../../../types';
import { usePlayersStore } from '../stores/players'; import { usePlayersStore } from '../stores/players';
import { useScoreboardStore } from '../stores/scoreboard'; import { useScoreboardStore } from '../stores/scoreboard';
@@ -40,8 +40,11 @@ const allFightingGameOptions = computed(() => gameAssetsStore.installedGames.map
const fightingGameOptions = ref(allFightingGameOptions.value); const fightingGameOptions = ref(allFightingGameOptions.value);
const characterOptions = computed(() => getCharactersByGame(scoreboardStore.scoreboard.game)); const characterOptions = computed(() => buildCharactersByGame(
type CharacterOption = ReturnType<typeof getCharactersByGame>[number]; scoreboardStore.scoreboard.game,
gameAssetsStore.characterNamesByGame[scoreboardStore.scoreboard.game] ?? [],
));
type CharacterOption = ReturnType<typeof buildCharactersByGame>[number];
const leftCharacterOptions = ref<CharacterOption[]>([]); const leftCharacterOptions = ref<CharacterOption[]>([]);
const rightCharacterOptions = ref<CharacterOption[]>([]); const rightCharacterOptions = ref<CharacterOption[]>([]);
@@ -720,8 +723,14 @@ watch(
); );
watch( watch(
() => scoreboardStore.scoreboard.game, () => [scoreboardStore.scoreboard.game, characterOptions.value] as const,
(newGame, previousGame) => { ([newGame, options], previousState) => {
const previousGame = previousState?.[0];
if (newGame && gameAssetsStore.characterNamesByGame[newGame] === undefined) {
return;
}
if (previousGame) { if (previousGame) {
charactersByGame.value[previousGame] = { charactersByGame.value[previousGame] = {
leftCharacter: scoreboardStore.scoreboard.leftCharacter, leftCharacter: scoreboardStore.scoreboard.leftCharacter,
@@ -729,7 +738,6 @@ watch(
}; };
} }
const options = getCharactersByGame(scoreboardStore.scoreboard.game);
leftCharacterOptions.value = options; leftCharacterOptions.value = options;
rightCharacterOptions.value = options; rightCharacterOptions.value = options;
const allowed = new Set(options.map((option) => option.value)); const allowed = new Set(options.map((option) => option.value));
@@ -24,6 +24,7 @@ let progressListenerAttached = false;
export const useGameAssetsStore = defineStore('game-assets', () => { export const useGameAssetsStore = defineStore('game-assets', () => {
const installedGames = ref<string[]>([]); const installedGames = ref<string[]>([]);
const characterNamesByGame = ref<Record<string, string[]>>({});
const loadingByTitle = ref<Record<string, boolean>>({}); const loadingByTitle = ref<Record<string, boolean>>({});
const removingByTitle = ref<Record<string, boolean>>({}); const removingByTitle = ref<Record<string, boolean>>({});
const progressByTitle = ref<Record<string, number>>({}); const progressByTitle = ref<Record<string, number>>({});
@@ -53,9 +54,16 @@ export const useGameAssetsStore = defineStore('game-assets', () => {
progressListenerAttached = true; progressListenerAttached = true;
} }
const refreshCharacterNamesByGame = async () => {
const response = await sendNodecgMessage<Record<string, string[]>>('scoreko-assets:listCharactersByGame');
characterNamesByGame.value = response;
return characterNamesByGame.value;
};
const refreshInstalledGames = async () => { const refreshInstalledGames = async () => {
const response = await sendNodecgMessage<string[]>('scoreko-assets:listInstalled'); const response = await sendNodecgMessage<string[]>('scoreko-assets:listInstalled');
installedGames.value = Array.isArray(response) ? response : []; installedGames.value = Array.isArray(response) ? response : [];
await refreshCharacterNamesByGame();
return installedGames.value; return installedGames.value;
}; };
@@ -72,6 +80,7 @@ export const useGameAssetsStore = defineStore('game-assets', () => {
try { try {
const response = await sendNodecgMessage<{ installedGames: string[] }>('scoreko-assets:downloadGame', { title }); const response = await sendNodecgMessage<{ installedGames: string[] }>('scoreko-assets:downloadGame', { title });
installedGames.value = response.installedGames; installedGames.value = response.installedGames;
await refreshCharacterNamesByGame();
loadingByTitle.value = { loadingByTitle.value = {
...loadingByTitle.value, ...loadingByTitle.value,
[title]: false, [title]: false,
@@ -99,6 +108,7 @@ export const useGameAssetsStore = defineStore('game-assets', () => {
try { try {
const response = await sendNodecgMessage<{ installedGames: string[] }>('scoreko-assets:removeGame', { title }); const response = await sendNodecgMessage<{ installedGames: string[] }>('scoreko-assets:removeGame', { title });
installedGames.value = response.installedGames; installedGames.value = response.installedGames;
await refreshCharacterNamesByGame();
return response; return response;
} finally { } finally {
removingByTitle.value = { removingByTitle.value = {
@@ -110,10 +120,12 @@ export const useGameAssetsStore = defineStore('game-assets', () => {
return { return {
installedGames, installedGames,
characterNamesByGame,
loadingByTitle, loadingByTitle,
removingByTitle, removingByTitle,
progressByTitle, progressByTitle,
refreshInstalledGames, refreshInstalledGames,
refreshCharacterNamesByGame,
downloadGame, downloadGame,
removeGame, removeGame,
}; };
+49
View File
@@ -6,6 +6,7 @@ import { nodecg } from './util/nodecg.js';
const GITHUB_OWNER = 'Pandipipas'; const GITHUB_OWNER = 'Pandipipas';
const GITHUB_REPO = 'scoreko-assets'; const GITHUB_REPO = 'scoreko-assets';
const GITHUB_API_BASE = `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/contents`; const GITHUB_API_BASE = `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/contents`;
const CHARACTER_NAMES_FILE = 'fighting-characters.json';
let cachedDefaultBranch: string | null = null; let cachedDefaultBranch: string | null = null;
@@ -122,6 +123,37 @@ const listInstalledGames = async () => {
return gameCatalog.filter((game) => installedSlugs.includes(game.slug)).map((game) => game.title); return gameCatalog.filter((game) => installedSlugs.includes(game.slug)).map((game) => game.title);
}; };
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 charactersByGame = await Promise.all(gameCatalog.map(async (game) => {
const sourcePath = path.join(assetsRoot, game.slug, CHARACTER_NAMES_FILE);
try {
const fileContent = await readFile(sourcePath, 'utf8');
const names = parseCharacterNames(fileContent, game.title);
return [game.title, names] as const;
} catch {
return [game.title, []] as const;
}
}));
return Object.fromEntries(charactersByGame) as Record<string, string[]>;
};
const downloadGameAssets = async (gameTitle: string) => { const downloadGameAssets = async (gameTitle: string) => {
const game = gameCatalog.find((entry) => entry.title === gameTitle); const game = gameCatalog.find((entry) => entry.title === gameTitle);
if (!game) { if (!game) {
@@ -136,6 +168,11 @@ const downloadGameAssets = async (gameTitle: string) => {
throw new Error(`No se encontraron archivos en ${repoFolderPath} dentro de scoreko-assets.`); throw new Error(`No se encontraron archivos en ${repoFolderPath} dentro de scoreko-assets.`);
} }
const hasCharacterNamesFile = files.some((file) => file.path === `${repoFolderPath}/${CHARACTER_NAMES_FILE}`);
if (!hasCharacterNamesFile) {
throw new Error(`No se encontró ${CHARACTER_NAMES_FILE} en ${repoFolderPath} dentro de scoreko-assets.`);
}
const totalBytes = files.reduce((acc, file) => acc + (file.size || 0), 0); const totalBytes = files.reduce((acc, file) => acc + (file.size || 0), 0);
let downloadedBytes = 0; let downloadedBytes = 0;
@@ -199,6 +236,18 @@ nodecg.listenFor('scoreko-assets:listInstalled', async (_payload: unknown, ack)
} }
}); });
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:downloadGame', async (payload: unknown, ack) => { nodecg.listenFor('scoreko-assets:downloadGame', async (payload: unknown, ack) => {
if (typeof ack !== 'function') { if (typeof ack !== 'function') {
return; return;
+7 -4
View File
@@ -3,7 +3,7 @@ import { useHead } from '@unhead/vue';
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'; import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../browser_shared/replicants'; import { graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../browser_shared/replicants';
import { resolveCountryCode } from '../../shared/countries'; import { resolveCountryCode } from '../../shared/countries';
import { getCharactersByGame } from '../../shared/fighting-characters'; import { getCharacterAssetUrl } from '../../shared/fighting-characters';
import type { Schemas } from '../../types'; import type { Schemas } from '../../types';
useHead({ title: 'Scoreboard 2XKO' }); useHead({ title: 'Scoreboard 2XKO' });
@@ -35,9 +35,12 @@ const rightName = computed(() => scoreboard.value.rightNameOverride || players.v
const leftTeam = computed(() => scoreboard.value.leftTeamOverride); const leftTeam = computed(() => scoreboard.value.leftTeamOverride);
const rightTeam = computed(() => scoreboard.value.rightTeamOverride); const rightTeam = computed(() => scoreboard.value.rightTeamOverride);
const charMap = new Map(getCharactersByGame('2XKO').map((char) => [char.value, char.image])); const leftCharacterImage = computed(() => scoreboard.value.leftCharacter
const leftCharacterImage = computed(() => charMap.get(scoreboard.value.leftCharacter) ?? ''); ? getCharacterAssetUrl('2XKO', scoreboard.value.leftCharacter)
const rightCharacterImage = computed(() => charMap.get(scoreboard.value.rightCharacter) ?? ''); : '');
const rightCharacterImage = computed(() => scoreboard.value.rightCharacter
? getCharacterAssetUrl('2XKO', scoreboard.value.rightCharacter)
: '');
const flagModules = import.meta.glob('/node_modules/flag-icons/flags/4x3/*.svg', { import: 'default', query: '?url' }) as Record<string, () => Promise<string>>; const flagModules = import.meta.glob('/node_modules/flag-icons/flags/4x3/*.svg', { import: 'default', query: '?url' }) as Record<string, () => Promise<string>>;
const flagUrlCache: Record<string, string> = {}; const flagUrlCache: Record<string, string> = {};
+8 -235
View File
@@ -11,224 +11,6 @@ type GamePalette = readonly [startColor: string, endColor: string];
const DEFAULT_PLACEHOLDER_PALETTE: GamePalette = ['#334155', '#0f172a']; const DEFAULT_PLACEHOLDER_PALETTE: GamePalette = ['#334155', '#0f172a'];
const MAX_INITIALS = 2; const MAX_INITIALS = 2;
const characterNamesByGame: Record<string, string[]> = {
'Guilty Gear -Strive-': [
'A.B.A',
'Anji Mito',
'Asuka R. Kreutz',
'Axl Low',
'Baiken',
'Bedman?',
'Bridget',
'Chipp Zanuff',
'Dizzy',
'Elphelt Valentine',
'Faust',
'Giovanna',
'Goldlewis Dickinson',
'Happy Chaos',
'I-No',
'Jack-O',
'Johnny',
'Ky Kiske',
'Leo Whitefang',
'Lucy',
'May',
'Millia Rage',
'Nagoriyuki',
'Potemkin',
'Ramlethal Valentine',
'Sin Kiske',
'Slayer',
'Sol Badguy',
'Testament',
'Unika',
'Venom',
'Zato-1',
],
'Street Fighter 6': [
'A.K.I.',
'Akuma',
'Alex',
'Bison',
'Blanka',
'Cammy',
'Chun-Li',
'Dee Jay',
'Dhalsim',
'E. Honda',
'Ed',
'Elena',
'Guile',
'Jamie',
'JP',
'Juri',
'Ken',
'Kimberly',
'Lily',
'Luke',
'Mai',
'Manon',
'Marisa',
'Rashid',
'Ryu',
'Sagat',
'Terry',
'Viper',
'Zangief',
],
'TEKKEN 8': [
'Alisa',
'Anna',
'Armor King',
'Asuka',
'Azucena',
'Bob',
'Bryan',
'Claudio',
'Clive',
'Devil Jin',
'Dragunov',
'Eddy',
'Fahkumram',
'Feng',
'Heihachi',
'Hwoarang',
'Jack-8',
'Jin',
'Jun',
'Kazuya',
'King',
'Kuma',
'Kunimitsu',
'Lars',
'Law',
'Lee',
'Leo',
'Leroy',
'Lidia',
'Lili',
'Miary Zo',
'Nina',
'Panda',
'Paul',
'Raven',
'Reina',
'Roger Jr',
'Shaheen',
'Steve',
'Victor',
'Xiaoyu',
'Yoshimitsu',
'Zafina',
],
'2XKO': [
'Ahri',
'Akali',
'Braum',
'Caitlyn',
'Darius',
'Ekko',
'Illaoi',
'Jinx',
'Senna',
'Teemo',
'Vi',
'Warwick',
'Yasuo',
],
'Mortal Kombat 1': [
'Ashrah',
'Baraka',
'Conan the Barbarian',
'Cyrax',
'Ermac',
'Geras',
'Ghostface',
'Havik',
'Homelander',
'Johnny Cage',
'Kenshi',
'Kitana',
'Kung Lao',
'Li Mei',
'Liu Kang',
'Mileena',
'Nitara',
'Noob Saibot',
'Omni-Man',
'Peacemaker',
'Quan Chi',
'Raiden',
'Rain',
'Reiko',
'Reptile',
'Scorpion',
'Sektor',
'Shang Tsung',
'Sindel',
'Smoke',
'Sub-Zero',
'Takeda',
'Tanya',
'T-1000',
],
'THE KING OF FIGHTERS XV': [
'Angel',
'Antonov',
'Ash Crimson',
'Athena Asamiya',
'Benimaru Nikaido',
'Billy Kane',
'Blue Mary',
'Chizuru Kagura',
'Chris',
'Clark Still',
'Dolores',
'Duo Lon',
'Elisabeth Blanctorche',
'Gato',
'Geese Howard',
'Goenitz',
'Heidern',
'Hinako Shijo',
'Iori Yagami',
'Isla',
'Joe Higashi',
"K'",
'Kim Kaphwan',
'King',
'King of Dinosaurs',
'Krohnen McDougall',
'Kula Diamond',
'Kukri',
'Kyo Kusanagi',
'Leona Heidern',
'Luong',
'Mai Shiranui',
'Maxima',
'Meitenkun',
'Najd',
'Orochi Chris',
'Orochi Shermie',
'Orochi Yashiro',
'Ralf Jones',
'Ramón',
'Robert Garcia',
'Rock Howard',
'Ryo Sakazaki',
'Ryuji Yamazaki',
'Shermie',
'Shingo Yabuki',
'Sylvie Paula Paula',
'Terry Bogard',
'Vanessa',
'Whip',
'Yashiro Nanakase',
'Yuri Sakazaki',
],
};
const defaultCharacterPairByGame: Record<string, { leftCharacter: string; rightCharacter: string }> = { const defaultCharacterPairByGame: Record<string, { leftCharacter: string; rightCharacter: string }> = {
'Guilty Gear -Strive-': { 'Guilty Gear -Strive-': {
leftCharacter: 'sol-badguy', leftCharacter: 'sol-badguy',
@@ -296,11 +78,6 @@ const buildCharacterPlaceholder = (game: string, character: string) => {
return toDataUrl(svg.trim()); 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}', { const characterImageModules = import.meta.glob('/src/shared/character-images/**/*.{png,jpg,jpeg,webp,avif,svg}', {
eager: true, eager: true,
import: 'default', import: 'default',
@@ -335,25 +112,21 @@ const getBundledCharacterImage = (game: string, characterValue: string) => {
return characterImageByKey[`${gameSlug}/${characterValue}`] ?? ''; return characterImageByKey[`${gameSlug}/${characterValue}`] ?? '';
}; };
const getCharacterImage = (game: string, character: string, characterValue: string) => getCharacterAssetUrl(game, characterValue); export const getCharacterAssetUrl = (game: string, characterValue: string) => {
const gameSlug = toSlug(game);
return `/bundles/scoreko-dev/game-assets/${gameSlug}/characters/${characterValue}.png`;
};
export const fightingCharactersByGame: Record<string, FightingCharacterOption[]> = Object.fromEntries( export const buildCharactersByGame = (game: string, characterNames: string[]) => characterNames.map((character) => {
Object.entries(characterNamesByGame).map(([game, characterNames]) => [
game,
characterNames.map((character) => {
const value = toSlug(character); const value = toSlug(character);
// Prefer packaged artwork and gracefully fallback to a generated image.
return { return {
label: character, label: character,
value, value,
image: getCharacterImage(game, character, value), image: getCharacterAssetUrl(game, value),
bundledImage: getBundledCharacterImage(game, value), bundledImage: getBundledCharacterImage(game, value),
fallbackImage: buildCharacterPlaceholder(game, character), fallbackImage: buildCharacterPlaceholder(game, character),
}; };
}), });
]),
);
export const getCharactersByGame = (game: string) => fightingCharactersByGame[game] ?? [];
export const getDefaultCharactersByGame = (game: string) => defaultCharacterPairByGame[game]; export const getDefaultCharactersByGame = (game: string) => defaultCharacterPairByGame[game];