mirror of
https://github.com/Pandipipas/scoreko-dev.git
synced 2026-06-06 11:42:06 +00:00
Compare commits
1 Commits
618d18d8fb
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c270feb5b |
@@ -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">
|
||||||
v{{ packEntry.version }} · {{ packEntry.characterCount }} personajes ·
|
<template v-if="isUpdate && updateInfo">
|
||||||
{{ packRegistry.formatBytes(packEntry.totalSizeBytes) }}
|
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>
|
||||||
</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,11 +108,9 @@ 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
|
||||||
@@ -130,9 +126,7 @@ 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
|
||||||
@@ -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 =>
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
Reference in New Issue
Block a user