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 { getPackLogoUrl } from '../../../shared/pack-config';
import type { PackRegistryEntry } from '../../../shared/pack-types';
import { usePackRegistry } from '../composables/usePackRegistry';
@@ -48,15 +49,13 @@ const isError = computed(() => downloadState.value?.status === 'error');
const progress = computed(() => downloadState.value?.progress ?? 0);
const logoUrl = computed(() =>
props.packEntry ? packRegistry.getLocalLogoUrl(props.packEntry.id) : '',
);
const giteaLogoUrl = computed(() =>
props.packEntry
? `${packRegistry.registry.value ? '' : ''}` // resolved from packEntry.logoPath via Gitea
: '',
);
// Pre-install: show logo directly from Gitea (pack not on disk yet).
// Update mode: pack is installed, serve from local /packs/ route.
const logoSrc = computed(() => {
if (!props.packEntry) return '';
if (props.isUpdate) return packRegistry.getLocalLogoUrl(props.packEntry.id);
return getPackLogoUrl(props.packEntry.id);
});
// Close automatically once download completes and emit so parent sets the game
watch(isDone, (done) => {
@@ -98,8 +97,15 @@ const close = () => emit('update:modelValue', false);
{{ packEntry.name }}
</div>
<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 ·
{{ packRegistry.formatBytes(packEntry.totalSizeBytes) }}
</template>
</div>
</div>
<QBtn
@@ -112,16 +118,23 @@ const close = () => emit('update:modelValue', false);
/>
</div>
<!-- Gradient banner using the pack's palette -->
<!-- Banner: logo del juego con gradiente de fallback -->
<div
class="pack-download-dialog__banner"
:style="{
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
:name="isUpdate ? 'upgrade' : 'sports_esports'"
size="48px"
size="40px"
color="white"
class="pack-download-dialog__banner-icon"
/>
@@ -253,8 +266,18 @@ const close = () => emit('update:modelValue', false);
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 {
opacity: 0.35;
position: relative; /* above the logo */
opacity: 0.25;
}
.pack-download-dialog__progress-section {
@@ -1,5 +1,5 @@
<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 { usePackRegistry } from '../composables/usePackRegistry';
import { t } from '../i18n';
@@ -18,12 +18,20 @@ const {
showDownloadDialog,
} = 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.
onMounted(() => {
packRegistry.fetchRegistry();
});
const refreshInterval = setInterval(() => {
packRegistry.fetchRegistry();
}, 15_000);
onUnmounted(() => {
clearInterval(refreshInterval);
});
const adjustLeftScore = (delta: number) => {
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"
@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>
@@ -39,7 +39,7 @@ export function useCharacterGame() {
* Populated from the pack registry when available; falls back to bundled games.
* 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
watch(
@@ -7,7 +7,6 @@
import { computed, ref, type ComputedRef, type InjectionKey } from 'vue';
import {
BUNDLED_GAME_NAMES,
registerInstalledPack,
unregisterInstalledPack,
} from '../../../shared/fighting-characters';
@@ -16,8 +15,7 @@ import type {
GameSelectOption,
PackDownloadState,
PackManifest,
PackRegistry,
PackRegistryEntry,
PackRegistry
} from '../../../shared/pack-types';
// ── NodeCG global type declarations ──────────────────────────────────────────
@@ -110,12 +108,10 @@ const initReplicants = (): void => {
downloadStates.value = statesRep.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) {
if (!BUNDLED_GAME_NAMES.has(getGameNameById(id))) {
loadInstalledManifest(id);
}
}
// Subscribe to changes
registryRep.on('change', (val) => {
@@ -130,10 +126,8 @@ const initReplicants = (): void => {
// Load manifests for newly installed packs
const added = next.filter((id) => !prev.includes(id));
for (const id of added) {
if (!BUNDLED_GAME_NAMES.has(getGameNameById(id))) {
loadInstalledManifest(id);
}
}
// Unregister packs that were removed
const removed = prev.filter((id) => !next.includes(id));
@@ -198,29 +192,13 @@ export const PACK_REGISTRY_KEY: InjectionKey<PackRegistryContext> = Symbol('pack
const buildAllGameOptions = () =>
computed<GameSelectOption[]>(() => {
if (!registry.value) {
// Registry not loaded yet — surface only the bundled games as available
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,
}));
}
// Registry not loaded yet — return empty list
if (!registry.value) return [];
return registry.value.packs.map((entry) => ({
label: entry.name,
value: entry.name,
available: entry.bundled || installedPackIds.value.includes(entry.id),
available: installedPackIds.value.includes(entry.id),
registryEntry: entry,
updateInfo: availableUpdates.value[entry.id],
}));
@@ -233,8 +211,8 @@ export function usePackRegistry(): PackRegistryContext {
const isGameAvailable = (gameName: string): boolean => {
const entry = registry.value?.packs.find((p) => p.name === gameName);
if (!entry) return BUNDLED_GAME_NAMES.has(gameName);
return entry.bundled || installedPackIds.value.includes(entry.id);
if (!entry) return false;
return installedPackIds.value.includes(entry.id);
};
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;
// Raíz del proyecto: 2 niveles por encima de extension/pack-manager.js
// Usamos import.meta.url porque nodecg.bundleDir no está disponible cuando
// NodeCG se usa como dependencia en lugar de servidor standalone.
@@ -211,6 +212,7 @@ const trySaveImage = async (
const checkForUpdates = (): void => {
const registry = packRegistryRep.value;
const installed = installedPacksRep.value ?? [];
if (!registry || installed.length === 0) {
availableUpdatesRep.value = {};
return;
@@ -372,7 +374,7 @@ nodecg.listenFor('updatePack', async (packId: unknown, ack: Acknowledgement | un
const packDir = path.join(packsDir, packId);
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)) {
fs.rmSync(charsDir, { recursive: true, force: true });
}
+35 -263
View File
@@ -1,11 +1,7 @@
// src/shared/fighting-characters.ts
// ─────────────────────────────────────────────────────────────────────────────
// Two sources of character data:
// 1. BUNDLED — shipped with the app, images loaded at build time via
// 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>
// Todo el contenido de personajes viene de packs descargados desde Gitea.
// No hay datos bundled — el proyecto arranca vacío y se rellena en runtime.
// ─────────────────────────────────────────────────────────────────────────────
import { ref } from 'vue';
@@ -18,154 +14,43 @@ export interface FightingCharacterOption {
dlc?: boolean;
}
type GamePalette = readonly [startColor: string, endColor: string];
// ── Runtime registry ──────────────────────────────────────────────────────────
const DEFAULT_PLACEHOLDER_PALETTE: GamePalette = ['#334155', '#0f172a'];
const MAX_INITIALS = 2;
const installedPackCharacters: Record<string, FightingCharacterOption[]> = {};
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': [
'Ahri', 'Akali', 'Braum', 'Caitlyn', 'Darius', 'Ekko',
'Illaoi', 'Jinx', 'Senna', 'Teemo', 'Vi', 'Warwick', 'Yasuo',
],
'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',
],
};
/**
* Vacío — ya no hay juegos bundled.
* Mantenido por compatibilidad con usePackRegistry.
*/
export const BUNDLED_GAME_NAMES = new Set<string>();
const defaultCharacterPairByGame: Record<string, { leftCharacter: string; rightCharacter: string }> = {
'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, '');
// ── Placeholder SVG ───────────────────────────────────────────────────────────
const toDataUrl = (svg: string): string =>
`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
const buildCharacterPlaceholder = (game: string, character: string): string => {
const [startColor, endColor] = paletteByGame[game] ?? DEFAULT_PLACEHOLDER_PALETTE;
const buildPlaceholder = (game: string, character: string, start: string, end: string): string => {
const initials = character
.split(/\s+/)
.map((part) => part[0])
.map((p) => p[0])
.join('')
.slice(0, MAX_INITIALS)
.slice(0, 2)
.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}"/>
<stop offset="0%" stop-color="${start}"/>
<stop offset="100%" stop-color="${end}"/>
</linearGradient>
</defs>
<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="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>`;
</svg>`.trim();
return toDataUrl(svg.trim());
return toDataUrl(svg);
};
const characterImageModules = import.meta.glob(
'/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,
};
}),
]),
);
// ── Pack registration ─────────────────────────────────────────────────────────
/**
* The set of game names that are bundled with the application.
* Used by usePackRegistry to determine if a pack needs to be downloaded.
*/
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).
* Registra un pack instalado para que getCharactersByGame() lo devuelva.
* Llamado por usePackRegistry cuando carga el manifest.json local de un pack.
*/
export const registerInstalledPack = (manifest: PackManifest): void => {
const { id, name, palette, characters, defaultPair } = manifest;
const [startColor, endColor] = [palette.start, palette.end];
installedPackCharacters[name] = characters.map((char) => ({
label: char.name,
value: char.slug,
// Images are served at runtime by NodeCG's static asset handler
image: `/packs/${id}/characters/${char.slug}.png`,
dlc: char.dlc ?? false,
// Fallback placeholder uses the same palette as the manifest
_placeholder: buildInstalledPlaceholder(name, char.name, startColor, endColor),
// Fallback inline por si la imagen no se encuentra en disco
_placeholder: buildPlaceholder(name, char.name, palette.start, palette.end),
}));
if (defaultPair) {
@@ -282,12 +87,13 @@ export const registerInstalledPack = (manifest: PackManifest): void => {
rightCharacter: defaultPair.right,
};
}
installedPacksRevision.value++;
};
/**
* Removes a previously registered installed pack.
* Called by usePackRegistry when a pack is uninstalled.
* Elimina un pack del registro en memoria.
* Llamado por usePackRegistry cuando el usuario desinstala un pack.
*/
export const unregisterInstalledPack = (gameName: string): void => {
delete installedPackCharacters[gameName];
@@ -295,46 +101,12 @@ export const unregisterInstalledPack = (gameName: string): void => {
installedPacksRevision.value++;
};
const buildInstalledPlaceholder = (
game: string,
character: string,
startColor: string,
endColor: string,
): string => {
const initials = character
.split(/\s+/)
.map((p) => p[0])
.join('')
.slice(0, MAX_INITIALS)
.toUpperCase();
// ── Public API ────────────────────────────────────────────────────────────────
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[] =>
fightingCharactersByGame[game] ?? installedPackCharacters[game] ?? [];
installedPackCharacters[game] ?? [];
/** Returns the default character pair for a game, checking both bundled and installed packs. */
export const getDefaultCharactersByGame = (
game: string,
): { leftCharacter: string; rightCharacter: string } | undefined =>
defaultCharacterPairByGame[game] ?? installedPackDefaults[game];
installedPackDefaults[game];