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">
import { computed, onMounted, ref, watch, watchEffect, type Ref } from 'vue';
import { getCountryLabel, getCountryOptions } from '../../../shared/countries';
import { getCharactersByGame, getDefaultCharactersByGame } from '../../../shared/fighting-characters';
import { buildCharactersByGame, getDefaultCharactersByGame } from '../../../shared/fighting-characters';
import type { Schemas } from '../../../types';
import { usePlayersStore } from '../stores/players';
import { useScoreboardStore } from '../stores/scoreboard';
@@ -40,8 +40,11 @@ const allFightingGameOptions = computed(() => gameAssetsStore.installedGames.map
const fightingGameOptions = ref(allFightingGameOptions.value);
const characterOptions = computed(() => getCharactersByGame(scoreboardStore.scoreboard.game));
type CharacterOption = ReturnType<typeof getCharactersByGame>[number];
const characterOptions = computed(() => buildCharactersByGame(
scoreboardStore.scoreboard.game,
gameAssetsStore.characterNamesByGame[scoreboardStore.scoreboard.game] ?? [],
));
type CharacterOption = ReturnType<typeof buildCharactersByGame>[number];
const leftCharacterOptions = ref<CharacterOption[]>([]);
const rightCharacterOptions = ref<CharacterOption[]>([]);
@@ -720,8 +723,14 @@ watch(
);
watch(
() => scoreboardStore.scoreboard.game,
(newGame, previousGame) => {
() => [scoreboardStore.scoreboard.game, characterOptions.value] as const,
([newGame, options], previousState) => {
const previousGame = previousState?.[0];
if (newGame && gameAssetsStore.characterNamesByGame[newGame] === undefined) {
return;
}
if (previousGame) {
charactersByGame.value[previousGame] = {
leftCharacter: scoreboardStore.scoreboard.leftCharacter,
@@ -729,7 +738,6 @@ watch(
};
}
const options = getCharactersByGame(scoreboardStore.scoreboard.game);
leftCharacterOptions.value = options;
rightCharacterOptions.value = options;
const allowed = new Set(options.map((option) => option.value));
@@ -24,6 +24,7 @@ let progressListenerAttached = false;
export const useGameAssetsStore = defineStore('game-assets', () => {
const installedGames = ref<string[]>([]);
const characterNamesByGame = ref<Record<string, string[]>>({});
const loadingByTitle = ref<Record<string, boolean>>({});
const removingByTitle = ref<Record<string, boolean>>({});
const progressByTitle = ref<Record<string, number>>({});
@@ -53,9 +54,16 @@ export const useGameAssetsStore = defineStore('game-assets', () => {
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 response = await sendNodecgMessage<string[]>('scoreko-assets:listInstalled');
installedGames.value = Array.isArray(response) ? response : [];
await refreshCharacterNamesByGame();
return installedGames.value;
};
@@ -72,6 +80,7 @@ export const useGameAssetsStore = defineStore('game-assets', () => {
try {
const response = await sendNodecgMessage<{ installedGames: string[] }>('scoreko-assets:downloadGame', { title });
installedGames.value = response.installedGames;
await refreshCharacterNamesByGame();
loadingByTitle.value = {
...loadingByTitle.value,
[title]: false,
@@ -99,6 +108,7 @@ export const useGameAssetsStore = defineStore('game-assets', () => {
try {
const response = await sendNodecgMessage<{ installedGames: string[] }>('scoreko-assets:removeGame', { title });
installedGames.value = response.installedGames;
await refreshCharacterNamesByGame();
return response;
} finally {
removingByTitle.value = {
@@ -110,10 +120,12 @@ export const useGameAssetsStore = defineStore('game-assets', () => {
return {
installedGames,
characterNamesByGame,
loadingByTitle,
removingByTitle,
progressByTitle,
refreshInstalledGames,
refreshCharacterNamesByGame,
downloadGame,
removeGame,
};
+49
View File
@@ -6,6 +6,7 @@ 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`;
const CHARACTER_NAMES_FILE = 'fighting-characters.json';
let cachedDefaultBranch: string | null = null;
@@ -122,6 +123,37 @@ const listInstalledGames = async () => {
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 game = gameCatalog.find((entry) => entry.title === gameTitle);
if (!game) {
@@ -136,6 +168,11 @@ const downloadGameAssets = async (gameTitle: string) => {
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);
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) => {
if (typeof ack !== 'function') {
return;
+7 -4
View File
@@ -3,7 +3,7 @@ import { useHead } from '@unhead/vue';
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../browser_shared/replicants';
import { resolveCountryCode } from '../../shared/countries';
import { getCharactersByGame } from '../../shared/fighting-characters';
import { getCharacterAssetUrl } from '../../shared/fighting-characters';
import type { Schemas } from '../../types';
useHead({ title: 'Scoreboard 2XKO' });
@@ -35,9 +35,12 @@ const rightName = computed(() => scoreboard.value.rightNameOverride || players.v
const leftTeam = computed(() => scoreboard.value.leftTeamOverride);
const rightTeam = computed(() => scoreboard.value.rightTeamOverride);
const charMap = new Map(getCharactersByGame('2XKO').map((char) => [char.value, char.image]));
const leftCharacterImage = computed(() => charMap.get(scoreboard.value.leftCharacter) ?? '');
const rightCharacterImage = computed(() => charMap.get(scoreboard.value.rightCharacter) ?? '');
const leftCharacterImage = computed(() => scoreboard.value.leftCharacter
? getCharacterAssetUrl('2XKO', scoreboard.value.leftCharacter)
: '');
const rightCharacterImage = computed(() => scoreboard.value.rightCharacter
? getCharacterAssetUrl('2XKO', scoreboard.value.rightCharacter)
: '');
const flagModules = import.meta.glob('/node_modules/flag-icons/flags/4x3/*.svg', { import: 'default', query: '?url' }) as Record<string, () => Promise<string>>;
const flagUrlCache: Record<string, string> = {};
+14 -241
View File
@@ -11,224 +11,6 @@ type GamePalette = readonly [startColor: string, endColor: string];
const DEFAULT_PLACEHOLDER_PALETTE: GamePalette = ['#334155', '#0f172a'];
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 }> = {
'Guilty Gear -Strive-': {
leftCharacter: 'sol-badguy',
@@ -296,11 +78,6 @@ 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',
@@ -335,25 +112,21 @@ const getBundledCharacterImage = (game: string, characterValue: string) => {
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(
Object.entries(characterNamesByGame).map(([game, characterNames]) => [
game,
characterNames.map((character) => {
const value = toSlug(character);
// Prefer packaged artwork and gracefully fallback to a generated image.
return {
label: character,
value,
image: getCharacterImage(game, character, value),
bundledImage: getBundledCharacterImage(game, value),
fallbackImage: buildCharacterPlaceholder(game, character),
};
}),
]),
);
export const buildCharactersByGame = (game: string, characterNames: string[]) => characterNames.map((character) => {
const value = toSlug(character);
export const getCharactersByGame = (game: string) => fightingCharactersByGame[game] ?? [];
return {
label: character,
value,
image: getCharacterAssetUrl(game, value),
bundledImage: getBundledCharacterImage(game, value),
fallbackImage: buildCharacterPlaceholder(game, character),
};
});
export const getDefaultCharactersByGame = (game: string) => defaultCharacterPairByGame[game];