feat: enhance pack management and character handling; implement automatic registry refresh and logo display updates

This commit is contained in:
2026-05-22 21:19:45 +02:00
parent 618d18d8fb
commit 8c270feb5b
6 changed files with 98 additions and 335 deletions
@@ -6,6 +6,7 @@
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
import { computed, watch } from 'vue'; import { computed, watch } from 'vue';
import { getPackLogoUrl } from '../../../shared/pack-config';
import type { PackRegistryEntry } from '../../../shared/pack-types'; import type { PackRegistryEntry } from '../../../shared/pack-types';
import { usePackRegistry } from '../composables/usePackRegistry'; import { usePackRegistry } from '../composables/usePackRegistry';
@@ -48,15 +49,13 @@ const isError = computed(() => downloadState.value?.status === 'error');
const progress = computed(() => downloadState.value?.progress ?? 0); const progress = computed(() => downloadState.value?.progress ?? 0);
const logoUrl = computed(() => // Pre-install: show logo directly from Gitea (pack not on disk yet).
props.packEntry ? packRegistry.getLocalLogoUrl(props.packEntry.id) : '', // Update mode: pack is installed, serve from local /packs/ route.
); const logoSrc = computed(() => {
if (!props.packEntry) return '';
const giteaLogoUrl = computed(() => if (props.isUpdate) return packRegistry.getLocalLogoUrl(props.packEntry.id);
props.packEntry return getPackLogoUrl(props.packEntry.id);
? `${packRegistry.registry.value ? '' : ''}` // resolved from packEntry.logoPath via Gitea });
: '',
);
// Close automatically once download completes and emit so parent sets the game // Close automatically once download completes and emit so parent sets the game
watch(isDone, (done) => { watch(isDone, (done) => {
@@ -98,8 +97,15 @@ const close = () => emit('update:modelValue', false);
{{ packEntry.name }} {{ packEntry.name }}
</div> </div>
<div class="text-caption text-grey-5"> <div class="text-caption text-grey-5">
<template v-if="isUpdate && updateInfo">
Bundled v{{ updateInfo.installedVersion }}
<span class="text-positive">v{{ updateInfo.latestVersion }}</span>
· {{ packEntry.characterCount }} personajes
</template>
<template v-else>
v{{ packEntry.version }} · {{ packEntry.characterCount }} personajes · v{{ packEntry.version }} · {{ packEntry.characterCount }} personajes ·
{{ packRegistry.formatBytes(packEntry.totalSizeBytes) }} {{ packRegistry.formatBytes(packEntry.totalSizeBytes) }}
</template>
</div> </div>
</div> </div>
<QBtn <QBtn
@@ -112,16 +118,23 @@ const close = () => emit('update:modelValue', false);
/> />
</div> </div>
<!-- Gradient banner using the pack's palette --> <!-- Banner: logo del juego con gradiente de fallback -->
<div <div
class="pack-download-dialog__banner" class="pack-download-dialog__banner"
:style="{ :style="{
background: `linear-gradient(135deg, ${packEntry.palette.start}, ${packEntry.palette.end})`, background: `linear-gradient(135deg, ${packEntry.palette.start}, ${packEntry.palette.end})`,
}" }"
> >
<img
v-if="logoSrc"
:src="logoSrc"
class="pack-download-dialog__logo"
alt=""
@error="($event.target as HTMLImageElement).style.display = 'none'"
/>
<QIcon <QIcon
:name="isUpdate ? 'upgrade' : 'sports_esports'" :name="isUpdate ? 'upgrade' : 'sports_esports'"
size="48px" size="40px"
color="white" color="white"
class="pack-download-dialog__banner-icon" class="pack-download-dialog__banner-icon"
/> />
@@ -253,8 +266,18 @@ const close = () => emit('update:modelValue', false);
overflow: hidden; overflow: hidden;
} }
.pack-download-dialog__logo {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 10px;
}
.pack-download-dialog__banner-icon { .pack-download-dialog__banner-icon {
opacity: 0.35; position: relative; /* above the logo */
opacity: 0.25;
} }
.pack-download-dialog__progress-section { .pack-download-dialog__progress-section {
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { inject, onMounted, ref } from 'vue'; import { inject, onMounted, onUnmounted, ref } from 'vue';
import { CHARACTER_GAME_KEY } from '../composables/useCharacterGame'; import { CHARACTER_GAME_KEY } from '../composables/useCharacterGame';
import { usePackRegistry } from '../composables/usePackRegistry'; import { usePackRegistry } from '../composables/usePackRegistry';
import { t } from '../i18n'; import { t } from '../i18n';
@@ -18,12 +18,20 @@ const {
showDownloadDialog, showDownloadDialog,
} = inject(CHARACTER_GAME_KEY)!; } = inject(CHARACTER_GAME_KEY)!;
// Refresca el catálogo de Gitea al montar el panel. // Refresca el catálogo al montar y luego cada 15 segundos automáticamente.
// Si Gitea no está disponible se usa la caché persistida del replicante. // Si Gitea no está disponible se usa la caché persistida del replicante.
onMounted(() => { onMounted(() => {
packRegistry.fetchRegistry(); packRegistry.fetchRegistry();
}); });
const refreshInterval = setInterval(() => {
packRegistry.fetchRegistry();
}, 15_000);
onUnmounted(() => {
clearInterval(refreshInterval);
});
const adjustLeftScore = (delta: number) => { const adjustLeftScore = (delta: number) => {
scoreboardStore.leftScore = Math.max(0, scoreboardStore.leftScore + delta); scoreboardStore.leftScore = Math.max(0, scoreboardStore.leftScore + delta);
}; };
@@ -188,27 +196,7 @@ const openUpdateDialog = (opt: import('../../../shared/pack-types').GameSelectOp
class="scoreboard-preview__action-btn" class="scoreboard-preview__action-btn"
@click="scoreboardStore.resetScores" @click="scoreboardStore.resetScores"
/> />
<!-- Botón para refrescar el catálogo de juegos desde Gitea -->
<QBtn
flat
dense
round
size="sm"
icon="refresh"
class="scoreboard-preview__action-btn"
@click="packRegistry.fetchRegistry()"
>
<QTooltip>Actualizar catálogo de juegos</QTooltip>
<!-- Badge con el número de packs que tienen actualización pendiente -->
<QBadge
v-if="packRegistry.updateCount.value > 0"
color="positive"
floating
rounded
>
{{ packRegistry.updateCount.value }}
</QBadge>
</QBtn>
</div> </div>
</div> </div>
@@ -39,7 +39,7 @@ export function useCharacterGame() {
* Populated from the pack registry when available; falls back to bundled games. * Populated from the pack registry when available; falls back to bundled games.
* GameSelectOption includes an `available` flag used to show the download icon. * GameSelectOption includes an `available` flag used to show the download icon.
*/ */
const fightingGameOptions = ref<GameSelectOption[]>(packRegistry.allGameOptions.value); const fightingGameOptions = ref<GameSelectOption[]>([]);
// Keep fightingGameOptions in sync when the registry updates // Keep fightingGameOptions in sync when the registry updates
watch( watch(
@@ -7,7 +7,6 @@
import { computed, ref, type ComputedRef, type InjectionKey } from 'vue'; import { computed, ref, type ComputedRef, type InjectionKey } from 'vue';
import { import {
BUNDLED_GAME_NAMES,
registerInstalledPack, registerInstalledPack,
unregisterInstalledPack, unregisterInstalledPack,
} from '../../../shared/fighting-characters'; } from '../../../shared/fighting-characters';
@@ -16,8 +15,7 @@ import type {
GameSelectOption, GameSelectOption,
PackDownloadState, PackDownloadState,
PackManifest, PackManifest,
PackRegistry, PackRegistry
PackRegistryEntry,
} from '../../../shared/pack-types'; } from '../../../shared/pack-types';
// ── NodeCG global type declarations ────────────────────────────────────────── // ── NodeCG global type declarations ──────────────────────────────────────────
@@ -110,12 +108,10 @@ const initReplicants = (): void => {
downloadStates.value = statesRep.value ?? {}; downloadStates.value = statesRep.value ?? {};
availableUpdates.value = updatesRep.value ?? {}; availableUpdates.value = updatesRep.value ?? {};
// Load manifests for packs already installed before this session // Load manifests for all installed packs
for (const id of installedPackIds.value) { for (const id of installedPackIds.value) {
if (!BUNDLED_GAME_NAMES.has(getGameNameById(id))) {
loadInstalledManifest(id); loadInstalledManifest(id);
} }
}
// Subscribe to changes // Subscribe to changes
registryRep.on('change', (val) => { registryRep.on('change', (val) => {
@@ -130,10 +126,8 @@ const initReplicants = (): void => {
// Load manifests for newly installed packs // Load manifests for newly installed packs
const added = next.filter((id) => !prev.includes(id)); const added = next.filter((id) => !prev.includes(id));
for (const id of added) { for (const id of added) {
if (!BUNDLED_GAME_NAMES.has(getGameNameById(id))) {
loadInstalledManifest(id); loadInstalledManifest(id);
} }
}
// Unregister packs that were removed // Unregister packs that were removed
const removed = prev.filter((id) => !next.includes(id)); const removed = prev.filter((id) => !next.includes(id));
@@ -198,29 +192,13 @@ export const PACK_REGISTRY_KEY: InjectionKey<PackRegistryContext> = Symbol('pack
const buildAllGameOptions = () => const buildAllGameOptions = () =>
computed<GameSelectOption[]>(() => { computed<GameSelectOption[]>(() => {
if (!registry.value) { // Registry not loaded yet — return empty list
// Registry not loaded yet — surface only the bundled games as available if (!registry.value) return [];
return Array.from(BUNDLED_GAME_NAMES).map((name) => ({
label: name,
value: name,
available: true,
registryEntry: {
id: '',
name,
version: '',
totalSizeBytes: 0,
logoPath: '',
characterCount: 0,
palette: { start: '#334155', end: '#0f172a' },
bundled: true,
} satisfies PackRegistryEntry,
}));
}
return registry.value.packs.map((entry) => ({ return registry.value.packs.map((entry) => ({
label: entry.name, label: entry.name,
value: entry.name, value: entry.name,
available: entry.bundled || installedPackIds.value.includes(entry.id), available: installedPackIds.value.includes(entry.id),
registryEntry: entry, registryEntry: entry,
updateInfo: availableUpdates.value[entry.id], updateInfo: availableUpdates.value[entry.id],
})); }));
@@ -233,8 +211,8 @@ export function usePackRegistry(): PackRegistryContext {
const isGameAvailable = (gameName: string): boolean => { const isGameAvailable = (gameName: string): boolean => {
const entry = registry.value?.packs.find((p) => p.name === gameName); const entry = registry.value?.packs.find((p) => p.name === gameName);
if (!entry) return BUNDLED_GAME_NAMES.has(gameName); if (!entry) return false;
return entry.bundled || installedPackIds.value.includes(entry.id); return installedPackIds.value.includes(entry.id);
}; };
const getDownloadState = (packId: string): PackDownloadState => const getDownloadState = (packId: string): PackDownloadState =>
+3 -1
View File
@@ -85,6 +85,7 @@ const reply = (ack: Acknowledgement | undefined, err: Error | null, result?: unk
const IMAGE_EXTENSIONS = ['png', 'webp', 'jpg', 'jpeg', 'avif'] as const; const IMAGE_EXTENSIONS = ['png', 'webp', 'jpg', 'jpeg', 'avif'] as const;
// Raíz del proyecto: 2 niveles por encima de extension/pack-manager.js // Raíz del proyecto: 2 niveles por encima de extension/pack-manager.js
// Usamos import.meta.url porque nodecg.bundleDir no está disponible cuando // Usamos import.meta.url porque nodecg.bundleDir no está disponible cuando
// NodeCG se usa como dependencia en lugar de servidor standalone. // NodeCG se usa como dependencia en lugar de servidor standalone.
@@ -211,6 +212,7 @@ const trySaveImage = async (
const checkForUpdates = (): void => { const checkForUpdates = (): void => {
const registry = packRegistryRep.value; const registry = packRegistryRep.value;
const installed = installedPacksRep.value ?? []; const installed = installedPacksRep.value ?? [];
if (!registry || installed.length === 0) { if (!registry || installed.length === 0) {
availableUpdatesRep.value = {}; availableUpdatesRep.value = {};
return; return;
@@ -372,7 +374,7 @@ nodecg.listenFor('updatePack', async (packId: unknown, ack: Acknowledgement | un
const packDir = path.join(packsDir, packId); const packDir = path.join(packsDir, packId);
const charsDir = path.join(packDir, 'characters'); const charsDir = path.join(packDir, 'characters');
// 2. Limpiar imágenes antiguas (evita residuos de personajes renombrados/eliminados) // 2. Limpiar imágenes antiguas para evitar residuos de personajes renombrados
if (fs.existsSync(charsDir)) { if (fs.existsSync(charsDir)) {
fs.rmSync(charsDir, { recursive: true, force: true }); fs.rmSync(charsDir, { recursive: true, force: true });
} }
+35 -263
View File
@@ -1,11 +1,7 @@
// src/shared/fighting-characters.ts // src/shared/fighting-characters.ts
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Two sources of character data: // Todo el contenido de personajes viene de packs descargados desde Gitea.
// 1. BUNDLED — shipped with the app, images loaded at build time via // No hay datos bundled — el proyecto arranca vacío y se rellena en runtime.
// import.meta.glob (unchanged from before).
// 2. INSTALLED — downloaded from Gitea at runtime, registered via
// registerInstalledPack(). Images served by NodeCG from
// /assets/<bundleName>/packs/<packId>/characters/<slug>.<ext>
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
import { ref } from 'vue'; import { ref } from 'vue';
@@ -18,154 +14,43 @@ export interface FightingCharacterOption {
dlc?: boolean; dlc?: boolean;
} }
type GamePalette = readonly [startColor: string, endColor: string]; // ── Runtime registry ──────────────────────────────────────────────────────────
const DEFAULT_PLACEHOLDER_PALETTE: GamePalette = ['#334155', '#0f172a']; const installedPackCharacters: Record<string, FightingCharacterOption[]> = {};
const MAX_INITIALS = 2; const installedPackDefaults: Record<string, { leftCharacter: string; rightCharacter: string }> = {};
// ───────────────────────────────────────────────────────────────────────────── /**
// BUNDLED DATA * Incrementado cada vez que se registra o elimina un pack.
// ───────────────────────────────────────────────────────────────────────────── * Los composables se suscriben a este ref para que Vue invalide los computed
* que dependen de installedPackCharacters (objeto plano, no reactivo).
*/
export const installedPacksRevision = ref(0);
const characterNamesByGame: Record<string, string[]> = { /**
'2XKO': [ * Vacío — ya no hay juegos bundled.
'Ahri', 'Akali', 'Braum', 'Caitlyn', 'Darius', 'Ekko', * Mantenido por compatibilidad con usePackRegistry.
'Illaoi', 'Jinx', 'Senna', 'Teemo', 'Vi', 'Warwick', 'Yasuo', */
], export const BUNDLED_GAME_NAMES = new Set<string>();
'FATAL FURY: City of the Wolves': [
'Andy Bogard', 'B. Jenet', 'Billy Kane', 'Blue Mary', 'Chun-Li',
'Cristiano Ronaldo', 'Gato', 'Hokutomaru', 'Hotaru Futaba', 'Joe Higashi',
'Kain R. Heinlein', 'Ken Masters', 'Kenshiro', 'Kevin Rian',
'Kim Dong Hwan', 'Kim Jae Hoon', 'Mai Shiranui', 'Marco Rodrigues',
'Mr. Big', 'Mr. Karate', 'Nightmare Geese', 'Preecha', 'Rock Howard',
'Salvatore Ganacci', 'Terry Bogard', 'Tizoc', 'Vox Reaper', 'Wolfgang Krauser',
],
'Guilty Gear -Strive-': [
'A.B.A', 'Anji Mito', 'Asuka R.', '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',
],
'Invincible VS': [
'Allen the Alien', 'Anissa', 'Atom Eve', 'Battle Beast', 'Bulletproof',
'Cecil', 'Conquest', 'Dupli-Kate', 'Ella Mental', 'Immortal', 'Invincible',
'Lucan', 'Monster Girl', 'Omni-Man', 'Powerplex', 'Rex Splode', 'Robot',
'Thula', 'Titan', 'Universa',
],
'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',
],
'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',
],
'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 }> = { // ── Placeholder SVG ───────────────────────────────────────────────────────────
'Guilty Gear -Strive-': { leftCharacter: 'sol-badguy', rightCharacter: 'ky-kiske' },
'Street Fighter 6': { leftCharacter: 'ryu', rightCharacter: 'chun-li' },
'TEKKEN 8': { leftCharacter: 'jin', rightCharacter: 'kazuya' },
'2XKO': { leftCharacter: 'ahri', rightCharacter: 'yasuo' },
'Mortal Kombat 1': { leftCharacter: 'scorpion', rightCharacter: 'sub-zero' },
'THE KING OF FIGHTERS XV': { leftCharacter: 'kyo-kusanagi', rightCharacter: 'iori-yagami' },
};
const paletteByGame: Record<string, GamePalette> = {
'Street Fighter 6': ['#f97316', '#b91c1c'],
'TEKKEN 8': ['#2563eb', '#111827'],
'Guilty Gear -Strive-': ['#a855f7', '#312e81'],
'2XKO': ['#7c3aed', '#1d4ed8'],
'Mortal Kombat 1': ['#f59e0b', '#7f1d1d'],
'THE KING OF FIGHTERS XV': ['#0ea5e9', '#1e3a8a'],
};
const dlcCharactersByGame: Record<string, ReadonlySet<string>> = {
'FATAL FURY: City of the Wolves': new Set([
'Chun-Li', 'Cristiano Ronaldo', 'Ken Masters', 'Kenshiro',
'Nightmare Geese', 'Salvatore Ganacci', 'Vox Reaper',
]),
'Guilty Gear -Strive-': new Set([
'Goldlewis Dickinson', 'Jack-O', 'Happy Chaos', 'Baiken', 'Testament',
'Bridget', 'Sin Kiske', 'Bedman?', 'Asuka R. Kreutz', 'Johnny',
'Elphelt Valentine', 'A.B.A', 'Slayer', 'Dizzy', 'Venom',
'Lucy', 'Unika',
]),
'Mortal Kombat 1': new Set([
'Ermac', 'Homelander', 'Omni-Man', 'Peacemaker', 'Quan Chi', 'Tanya',
'Conan the Barbarian', 'Cyrax', 'Ghostface', 'Noob Saibot', 'Sektor',
'Shang Tsung', 'Takeda', 'T-1000',
]),
'Street Fighter 6': new Set([
'A.K.I.', 'Akuma', 'Bison', 'Ed',
'Alex', 'Elena', 'Mai', 'Sagat', 'Terry', 'Viper',
]),
'TEKKEN 8': new Set([
'Clive', 'Eddy', 'Heihachi', 'Lidia',
'Anna', 'Fahkumram', 'Kunimitsu', 'Miary Zo', 'Roger Jr',
]),
'THE KING OF FIGHTERS XV': new Set([
'Antonov', 'Elisabeth Blanctorche', 'Gato', 'Geese Howard', 'Goenitz',
'Hinako Shijo', 'Krohnen McDougall', 'Kukri', 'Luong', 'Najd',
'Orochi Chris', 'Orochi Shermie', 'Orochi Yashiro', 'Rock Howard',
'Sylvie Paula Paula',
]),
};
// ─────────────────────────────────────────────────────────────────────────────
// Image resolution — BUNDLED
// ─────────────────────────────────────────────────────────────────────────────
const toSlug = (value: string): string =>
value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
const toDataUrl = (svg: string): string => const toDataUrl = (svg: string): string =>
`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
const buildCharacterPlaceholder = (game: string, character: string): string => { const buildPlaceholder = (game: string, character: string, start: string, end: string): string => {
const [startColor, endColor] = paletteByGame[game] ?? DEFAULT_PLACEHOLDER_PALETTE;
const initials = character const initials = character
.split(/\s+/) .split(/\s+/)
.map((part) => part[0]) .map((p) => p[0])
.join('') .join('')
.slice(0, MAX_INITIALS) .slice(0, 2)
.toUpperCase(); .toUpperCase();
const svg = ` const svg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 220" role="img" aria-label="${character}"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 220" role="img" aria-label="${character}">
<defs> <defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"> <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="${startColor}"/> <stop offset="0%" stop-color="${start}"/>
<stop offset="100%" stop-color="${endColor}"/> <stop offset="100%" stop-color="${end}"/>
</linearGradient> </linearGradient>
</defs> </defs>
<rect width="480" height="220" fill="url(#bg)" rx="18"/> <rect width="480" height="220" fill="url(#bg)" rx="18"/>
@@ -173,107 +58,27 @@ const buildCharacterPlaceholder = (game: string, character: string): string => {
<text x="90" y="130" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="56" font-weight="700">${initials}</text> <text x="90" y="130" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="56" font-weight="700">${initials}</text>
<text x="170" y="96" fill="#e2e8f0" font-family="Arial, sans-serif" font-size="20" font-weight="700">${game}</text> <text x="170" y="96" fill="#e2e8f0" font-family="Arial, sans-serif" font-size="20" font-weight="700">${game}</text>
<text x="170" y="145" fill="#ffffff" font-family="Arial, sans-serif" font-size="38" font-weight="700">${character}</text> <text x="170" y="145" fill="#ffffff" font-family="Arial, sans-serif" font-size="38" font-weight="700">${character}</text>
</svg>`; </svg>`.trim();
return toDataUrl(svg.trim()); return toDataUrl(svg);
}; };
const characterImageModules = import.meta.glob( // ── Pack registration ─────────────────────────────────────────────────────────
'/src/shared/character-images/**/*.{png,jpg,jpeg,webp,avif,svg}',
{ eager: true, import: 'default', query: '?url' },
) as Record<string, string>;
const resolveImageKey = (path: string): string | null => {
const segments = path.split('/');
const gameFolder = segments.at(-2);
const filename = segments.at(-1);
if (!gameFolder || !filename) return null;
return `${gameFolder}/${filename.replace(/\.[^.]+$/, '')}`;
};
const characterImageByKey = Object.entries(characterImageModules).reduce<Record<string, string>>(
(acc, [path, url]) => {
const key = resolveImageKey(path);
if (key) acc[key] = url;
return acc;
},
{},
);
const getBundledCharacterImage = (game: string, character: string, slug: string): string => {
const gameSlug = toSlug(game);
const key = `${gameSlug}/${slug}`;
return characterImageByKey[key] ?? buildCharacterPlaceholder(game, character);
};
// ─────────────────────────────────────────────────────────────────────────────
// Compile bundled game options
// ─────────────────────────────────────────────────────────────────────────────
export const fightingCharactersByGame: Record<string, FightingCharacterOption[]> = Object.fromEntries(
Object.entries(characterNamesByGame).map(([game, characterNames]) => [
game,
characterNames.map((character) => {
const value = toSlug(character);
return {
label: character,
value,
image: getBundledCharacterImage(game, character, value),
dlc: dlcCharactersByGame[game]?.has(character) ?? false,
};
}),
]),
);
/** /**
* The set of game names that are bundled with the application. * Registra un pack instalado para que getCharactersByGame() lo devuelva.
* Used by usePackRegistry to determine if a pack needs to be downloaded. * Llamado por usePackRegistry cuando carga el manifest.json local de un pack.
*/
export const BUNDLED_GAME_NAMES = new Set(Object.keys(characterNamesByGame));
// ─────────────────────────────────────────────────────────────────────────────
// INSTALLED PACK REGISTRY (runtime, populated by usePackRegistry)
// ─────────────────────────────────────────────────────────────────────────────
/**
* Runtime character data for packs that have been downloaded from Gitea.
* Keyed by game display name (same as PackManifest.name) so that
* getCharactersByGame() can look them up with the same key as bundled games.
*/
const installedPackCharacters: Record<string, FightingCharacterOption[]> = {};
const installedPackDefaults: Record<string, { leftCharacter: string; rightCharacter: string }> = {};
/**
* Incremented every time a pack is registered or unregistered.
* Composables subscribe to this ref so Vue re-evaluates computed values
* that depend on installedPackCharacters (which is a plain object, not reactive).
*/
export const installedPacksRevision = ref(0);
/**
* Registers an installed (downloaded) pack so that getCharactersByGame() and
* getDefaultCharactersByGame() return its data.
*
* Called by usePackRegistry when:
* - The composable mounts and an installed pack's manifest is read from disk.
* - A new pack finishes downloading.
*
* Images are served by NodeCG from /assets/<BUNDLE_NAME>/packs/<packId>/characters/.
* The function tries the most common extension; the browser will 404 gracefully
* for missing files (placeholder is shown by the img error handler in the template).
*/ */
export const registerInstalledPack = (manifest: PackManifest): void => { export const registerInstalledPack = (manifest: PackManifest): void => {
const { id, name, palette, characters, defaultPair } = manifest; const { id, name, palette, characters, defaultPair } = manifest;
const [startColor, endColor] = [palette.start, palette.end];
installedPackCharacters[name] = characters.map((char) => ({ installedPackCharacters[name] = characters.map((char) => ({
label: char.name, label: char.name,
value: char.slug, value: char.slug,
// Images are served at runtime by NodeCG's static asset handler
image: `/packs/${id}/characters/${char.slug}.png`, image: `/packs/${id}/characters/${char.slug}.png`,
dlc: char.dlc ?? false, dlc: char.dlc ?? false,
// Fallback placeholder uses the same palette as the manifest // Fallback inline por si la imagen no se encuentra en disco
_placeholder: buildInstalledPlaceholder(name, char.name, startColor, endColor), _placeholder: buildPlaceholder(name, char.name, palette.start, palette.end),
})); }));
if (defaultPair) { if (defaultPair) {
@@ -282,12 +87,13 @@ export const registerInstalledPack = (manifest: PackManifest): void => {
rightCharacter: defaultPair.right, rightCharacter: defaultPair.right,
}; };
} }
installedPacksRevision.value++; installedPacksRevision.value++;
}; };
/** /**
* Removes a previously registered installed pack. * Elimina un pack del registro en memoria.
* Called by usePackRegistry when a pack is uninstalled. * Llamado por usePackRegistry cuando el usuario desinstala un pack.
*/ */
export const unregisterInstalledPack = (gameName: string): void => { export const unregisterInstalledPack = (gameName: string): void => {
delete installedPackCharacters[gameName]; delete installedPackCharacters[gameName];
@@ -295,46 +101,12 @@ export const unregisterInstalledPack = (gameName: string): void => {
installedPacksRevision.value++; installedPacksRevision.value++;
}; };
const buildInstalledPlaceholder = ( // ── Public API ────────────────────────────────────────────────────────────────
game: string,
character: string,
startColor: string,
endColor: string,
): string => {
const initials = character
.split(/\s+/)
.map((p) => p[0])
.join('')
.slice(0, MAX_INITIALS)
.toUpperCase();
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 220" role="img" aria-label="${character}">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="${startColor}"/>
<stop offset="100%" stop-color="${endColor}"/>
</linearGradient>
</defs>
<rect width="480" height="220" fill="url(#bg)" rx="18"/>
<circle cx="90" cy="110" r="64" fill="rgba(255,255,255,0.13)"/>
<text x="90" y="130" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="56" font-weight="700">${initials}</text>
<text x="170" y="96" fill="#e2e8f0" font-family="Arial, sans-serif" font-size="20" font-weight="700">${game}</text>
<text x="170" y="145" fill="#ffffff" font-family="Arial, sans-serif" font-size="38" font-weight="700">${character}</text>
</svg>`;
return toDataUrl(svg.trim());
};
// ─────────────────────────────────────────────────────────────────────────────
// Public API
// ─────────────────────────────────────────────────────────────────────────────
/** Returns the character list for a game, checking both bundled and installed packs. */
export const getCharactersByGame = (game: string): FightingCharacterOption[] => export const getCharactersByGame = (game: string): FightingCharacterOption[] =>
fightingCharactersByGame[game] ?? installedPackCharacters[game] ?? []; installedPackCharacters[game] ?? [];
/** Returns the default character pair for a game, checking both bundled and installed packs. */
export const getDefaultCharactersByGame = ( export const getDefaultCharactersByGame = (
game: string, game: string,
): { leftCharacter: string; rightCharacter: string } | undefined => ): { leftCharacter: string; rightCharacter: string } | undefined =>
defaultCharacterPairByGame[game] ?? installedPackDefaults[game]; installedPackDefaults[game];