feat: update character images for Tekken 8 and enhance pack management

- Updated character images for Tekken 8, including Jin, Jun, Kazuya, and others.
- Introduced a new pack configuration system to manage character packs from a Gitea instance.
- Added types for pack management, including PackCharacter, PackManifest, and PackRegistry.
- Implemented functions to register and unregister installed packs, allowing dynamic character loading.
- Enhanced the character image retrieval system to support both bundled and installed packs.
This commit is contained in:
2026-05-21 17:59:13 +02:00
parent 04f2c2037a
commit 88aeedb5ff
50 changed files with 1621 additions and 409 deletions
@@ -0,0 +1,301 @@
<script setup lang="ts">
// src/dashboard/scoreboard/components/GamePackDownloadDialog.vue
// ─────────────────────────────────────────────────────────────────────────────
// Shown when the user clicks a game that is not yet installed.
// Displays size, character roster, and a download progress bar.
// ─────────────────────────────────────────────────────────────────────────────
import { computed, watch } from 'vue';
import type { PackRegistryEntry } from '../../../shared/pack-types';
import { usePackRegistry } from '../composables/usePackRegistry';
// ── Props / emits ─────────────────────────────────────────────────────────────
const props = defineProps<{
/** v-model visibility */
modelValue: boolean;
/** The registry entry for the game the user wants to download/update */
packEntry: PackRegistryEntry | null;
/** When true the dialog shows "update" language and calls updatePack instead of downloadPack */
isUpdate?: boolean;
/** Version info shown in update mode */
updateInfo?: { installedVersion: string; latestVersion: string };
}>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
/** Emitted after a successful download/update so the parent can switch to the game */
downloaded: [gameName: string];
}>();
// ── Pack registry ─────────────────────────────────────────────────────────────
const packRegistry = usePackRegistry();
// ── Computed ──────────────────────────────────────────────────────────────────
const downloadState = computed(() =>
props.packEntry ? packRegistry.getDownloadState(props.packEntry.id) : null,
);
const isDownloading = computed(() =>
downloadState.value?.status === 'downloading' ||
downloadState.value?.status === 'fetching-manifest',
);
const isDone = computed(() => downloadState.value?.status === 'done');
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
: '',
);
// Close automatically once download completes and emit so parent sets the game
watch(isDone, (done) => {
if (done && props.packEntry) {
emit('downloaded', props.packEntry.name);
emit('update:modelValue', false);
}
});
// ── Actions ───────────────────────────────────────────────────────────────────
const startDownload = () => {
if (!props.packEntry) return;
if (props.isUpdate) {
packRegistry.updatePack(props.packEntry.id);
} else {
packRegistry.downloadPack(props.packEntry.id);
}
};
const close = () => emit('update:modelValue', false);
</script>
<template>
<QDialog
:model-value="modelValue"
persistent
@update:model-value="emit('update:modelValue', $event)"
>
<QCard
v-if="packEntry"
class="pack-download-dialog"
>
<!-- Header -->
<QCardSection class="pack-download-dialog__header">
<div class="pack-download-dialog__title-row">
<div>
<div class="text-h6 text-weight-bold">
{{ packEntry.name }}
</div>
<div class="text-caption text-grey-5">
v{{ packEntry.version }} · {{ packEntry.characterCount }} personajes ·
{{ packRegistry.formatBytes(packEntry.totalSizeBytes) }}
</div>
</div>
<QBtn
v-if="!isDownloading"
flat
round
dense
icon="close"
@click="close"
/>
</div>
<!-- Gradient banner using the pack's palette -->
<div
class="pack-download-dialog__banner"
:style="{
background: `linear-gradient(135deg, ${packEntry.palette.start}, ${packEntry.palette.end})`,
}"
>
<QIcon
:name="isUpdate ? 'upgrade' : 'sports_esports'"
size="48px"
color="white"
class="pack-download-dialog__banner-icon"
/>
</div>
<!-- Version info shown only in update mode -->
<div
v-if="isUpdate && updateInfo"
class="pack-download-dialog__version-badge"
>
<span class="text-grey-5">v{{ updateInfo.installedVersion }}</span>
<QIcon name="arrow_forward" size="14px" color="grey-5" />
<span class="text-positive text-weight-bold">v{{ updateInfo.latestVersion }}</span>
</div>
</QCardSection>
<QSeparator />
<!-- ── Progress / error ───────────────────────────────────────────── -->
<QCardSection
v-if="isDownloading || isDone || isError"
class="pack-download-dialog__progress-section"
>
<div
v-if="isError"
class="pack-download-dialog__error"
>
<QIcon
name="error"
color="negative"
size="20px"
/>
<span>{{ downloadState?.error ?? 'Error desconocido' }}</span>
</div>
<template v-else>
<div class="pack-download-dialog__progress-label">
<span>{{ isDownloading ? 'Descargando' : '¡Listo!' }}</span>
<span>{{ progress }}%</span>
</div>
<QLinearProgress
:value="progress / 100"
:color="isDone ? 'positive' : 'primary'"
rounded
size="8px"
/>
</template>
</QCardSection>
<!-- ── Character list ─────────────────────────────────────────────── -->
<QCardSection class="pack-download-dialog__char-section">
<div class="text-caption text-grey-5 q-mb-sm">
Personajes incluidos
</div>
<!-- We only have the count in the registry entry; the full list lives
in the manifest. Show a placeholder grid until the registry has
a characters array (future enhancement: include it in registry.json). -->
<div class="pack-download-dialog__char-count">
<QIcon
name="sports_martial_arts"
size="16px"
/>
{{ packEntry.characterCount }} personajes en este pack
</div>
</QCardSection>
<QSeparator />
<!-- ── Actions ────────────────────────────────────────────────────── -->
<QCardActions
align="right"
class="q-pa-md"
>
<QBtn
v-if="!isDownloading"
flat
label="Cancelar"
color="grey-5"
@click="close"
/>
<QBtn
v-if="!isDownloading && !isDone"
unelevated
:label="isError ? 'Reintentar' : isUpdate ? 'Actualizar pack' : 'Descargar pack'"
:color="isUpdate ? 'positive' : 'primary'"
:icon="isUpdate ? 'upgrade' : 'download'"
@click="startDownload"
/>
<QBtn
v-if="isDownloading"
flat
:label="isUpdate ? 'Actualizando' : 'Descargando'"
:color="isUpdate ? 'positive' : 'primary'"
loading
disable
/>
</QCardActions>
</QCard>
</QDialog>
</template>
<style scoped>
.pack-download-dialog {
width: 420px;
max-width: 95vw;
border-radius: 12px;
overflow: hidden;
}
.pack-download-dialog__header {
padding-bottom: 0;
}
.pack-download-dialog__title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 14px;
}
.pack-download-dialog__banner {
position: relative;
height: 88px;
border-radius: 10px;
margin-bottom: 4px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.pack-download-dialog__banner-icon {
opacity: 0.35;
}
.pack-download-dialog__progress-section {
padding-top: 12px;
padding-bottom: 12px;
}
.pack-download-dialog__progress-label {
display: flex;
justify-content: space-between;
font-size: 13px;
margin-bottom: 6px;
color: rgba(255, 255, 255, 0.75);
}
.pack-download-dialog__error {
display: flex;
align-items: center;
gap: 8px;
color: var(--q-negative);
font-size: 13px;
}
.pack-download-dialog__char-section {
padding-top: 10px;
padding-bottom: 10px;
}
.pack-download-dialog__char-count {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: rgba(255, 255, 255, 0.85);
}
.pack-download-dialog__version-badge {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
margin-top: 8px;
}
</style>
@@ -1,11 +1,28 @@
<script setup lang="ts">
import { inject } from 'vue';
import { useScoreboardStore } from '../stores/scoreboard';
import { inject, onMounted, ref } from 'vue';
import { CHARACTER_GAME_KEY } from '../composables/useCharacterGame';
import { usePackRegistry } from '../composables/usePackRegistry';
import { t } from '../i18n';
import { useScoreboardStore } from '../stores/scoreboard';
import GamePackDownloadDialog from './GamePackDownloadDialog.vue';
const scoreboardStore = useScoreboardStore();
const { gameInput, fightingGameOptions, onGameFilter } = inject(CHARACTER_GAME_KEY)!;
const packRegistry = usePackRegistry();
const {
gameInput,
fightingGameOptions,
onGameFilter,
handleGameSelect,
pendingDownloadEntry,
showDownloadDialog,
} = inject(CHARACTER_GAME_KEY)!;
// Refresca el catálogo de Gitea al montar el panel.
// Si Gitea no está disponible se usa la caché persistida del replicante.
onMounted(() => {
packRegistry.fetchRegistry();
});
const adjustLeftScore = (delta: number) => {
scoreboardStore.leftScore = Math.max(0, scoreboardStore.leftScore + delta);
@@ -14,12 +31,33 @@ const adjustLeftScore = (delta: number) => {
const adjustRightScore = (delta: number) => {
scoreboardStore.rightScore = Math.max(0, scoreboardStore.rightScore + delta);
};
/** Tras una descarga exitosa, activa el juego en el store. */
const onPackDownloaded = (gameName: string) => {
scoreboardStore.scoreboard.game = gameName;
};
// ── Estado del diálogo de actualización ───────────────────────────────────────
const pendingUpdateEntry = ref<import('../../../shared/pack-types').PackRegistryEntry | null>(null);
const pendingUpdateInfo = ref<{ installedVersion: string; latestVersion: string } | undefined>(undefined);
const showUpdateDialog = ref(false);
const openUpdateDialog = (opt: import('../../../shared/pack-types').GameSelectOption, event: Event) => {
event.stopPropagation(); // evitar que el QItem cambie la selección
pendingUpdateEntry.value = opt.registryEntry;
pendingUpdateInfo.value = opt.updateInfo;
showUpdateDialog.value = true;
};
</script>
<template>
<div class="scoreboard-preview__center">
<!--
v-model :model-value + @update:model-value para interceptar la
selección de juegos no instalados antes de escribir en el store.
-->
<QSelect
v-model="scoreboardStore.scoreboard.game"
:model-value="scoreboardStore.scoreboard.game"
v-model:input-value="gameInput"
:options="fightingGameOptions"
:label="t('scoreboardLabelGame')"
@@ -32,10 +70,59 @@ const adjustRightScore = (delta: number) => {
fill-input
class="scoreboard-preview__field scoreboard-preview__game-field"
@filter="onGameFilter"
@update:model-value="handleGameSelect"
>
<template #prepend>
<QIcon name="sports_esports" />
</template>
<!-- Slot personalizado: muestra iconos de descarga o actualización según el estado -->
<template #option="scope">
<QItem
v-bind="scope.itemProps"
:class="{ 'pack-option--unavailable': !scope.opt.available }"
>
<QItemSection>
<QItemLabel>{{ scope.opt.label }}</QItemLabel>
</QItemSection>
<!-- Icono de actualización disponible (pack instalado, versión nueva en repo) -->
<QItemSection
v-if="scope.opt.available && scope.opt.updateInfo"
side
>
<QBtn
flat
round
dense
size="xs"
icon="upgrade"
color="positive"
@click="openUpdateDialog(scope.opt, $event)"
>
<QTooltip>
Actualización disponible:
v{{ scope.opt.updateInfo.installedVersion }}
v{{ scope.opt.updateInfo.latestVersion }}
</QTooltip>
</QBtn>
</QItemSection>
<!-- Icono de descarga (pack no instalado) -->
<QItemSection
v-else-if="!scope.opt.available"
side
>
<QIcon
name="download"
size="16px"
color="grey-5"
>
<QTooltip>Pack no instalado haz clic para descargarlo</QTooltip>
</QIcon>
</QItemSection>
</QItem>
</template>
</QSelect>
<div class="scoreboard-preview__score-controls">
@@ -101,8 +188,45 @@ const adjustRightScore = (delta: number) => {
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>
<!-- Dialog de descarga se abre automáticamente al seleccionar un juego no instalado -->
<GamePackDownloadDialog
v-model="showDownloadDialog"
:pack-entry="pendingDownloadEntry"
@downloaded="onPackDownloaded"
/>
<!-- Dialog de actualización se abre al hacer clic en el icono de upgrade -->
<GamePackDownloadDialog
v-model="showUpdateDialog"
:pack-entry="pendingUpdateEntry"
:is-update="true"
:update-info="pendingUpdateInfo"
@downloaded="onPackDownloaded"
/>
</template>
<style scoped>
@@ -188,4 +312,13 @@ const adjustRightScore = (delta: number) => {
.scoreboard-preview__field :deep(.q-field__label) {
color: rgba(255, 255, 255, 0.92);
}
</style>
/* Atenúa visualmente los juegos no instalados en el desplegable */
.pack-option--unavailable {
opacity: 0.6;
}
.pack-option--unavailable:hover {
opacity: 1;
}
</style>
@@ -1,60 +1,90 @@
// src/dashboard/scoreboard/composables/useCharacterGame.ts
// ─────────────────────────────────────────────────────────────────────────────
// Manages game selection and character state for both PlayerSidePanels.
// Must be called ONCE in ScoreboardPanel and provided via CHARACTER_GAME_KEY.
//
// Changes from original:
// - fightingGameOptions is now driven by the pack registry (allGameOptions)
// rather than a static hardcoded list. It falls back to bundled names
// while the registry loads.
// - Game selection is intercepted: selecting an unavailable game triggers
// the download dialog instead of updating the store.
// - pendingDownloadEntry / showDownloadDialog are exposed for ScoreCenterPanel.
// ─────────────────────────────────────────────────────────────────────────────
import { computed, ref, watch, type InjectionKey, type Ref } from 'vue';
import { getCharactersByGame, getDefaultCharactersByGame } from '../../../shared/fighting-characters';
import { useScoreboardStore } from '../stores/scoreboard';
import { usePackRegistry } from './usePackRegistry';
import type { GameSelectOption, PackRegistryEntry } from '../../../shared/pack-types';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
export const ALL_FIGHTING_GAME_OPTIONS = [
'2XKO',
'FATAL FURY: City of the Wolves',
'Guilty Gear -Strive-',
'Invincible VS',
'Mortal Kombat 1',
'Street Fighter 6',
'TEKKEN 8',
'THE KING OF FIGHTERS XV',
].map((game) => ({ label: game, value: game }));
// ── Types ─────────────────────────────────────────────────────────────────────
export type CharacterOption = ReturnType<typeof getCharactersByGame>[number];
// ---------------------------------------------------------------------------
// Injection key (type-safe provide/inject)
// ---------------------------------------------------------------------------
export type CharacterGameContext = ReturnType<typeof useCharacterGame>;
export const CHARACTER_GAME_KEY: InjectionKey<CharacterGameContext> = Symbol('characterGame');
// ---------------------------------------------------------------------------
// Composable
// ---------------------------------------------------------------------------
// ── Composable ────────────────────────────────────────────────────────────────
/**
* Manages game selection and character state for both sides.
* Must be called ONCE in the parent (ScoreboardPanel) and provided via
* CHARACTER_GAME_KEY so both PlayerSidePanel instances share the same state.
*/
export function useCharacterGame() {
const scoreboardStore = useScoreboardStore();
const packRegistry = usePackRegistry();
// ── Game selector state ───────────────────────────────────────────────────
// Game selector
const gameInput = ref('');
const fightingGameOptions = ref(ALL_FIGHTING_GAME_OPTIONS);
// Per-side character state
/**
* Game options surfaced to the QSelect.
* 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);
// Keep fightingGameOptions in sync when the registry updates
watch(
packRegistry.allGameOptions,
(options) => {
fightingGameOptions.value = options;
},
);
// ── Download dialog state ─────────────────────────────────────────────────
/** Set when the user selects a game that isn't installed yet. */
const pendingDownloadEntry = ref<PackRegistryEntry | null>(null);
const showDownloadDialog = ref(false);
/**
* Intercepting setter for the game selector.
* If the selected game is not available, opens the download dialog instead
* of writing to the store.
*/
const handleGameSelect = (gameName: string) => {
if (!gameName) {
scoreboardStore.scoreboard.game = '';
return;
}
if (!packRegistry.isGameAvailable(gameName)) {
const entry = fightingGameOptions.value.find((o) => o.value === gameName)?.registryEntry ?? null;
pendingDownloadEntry.value = entry;
showDownloadDialog.value = true;
// Do NOT update the store — the game isn't installed
return;
}
scoreboardStore.scoreboard.game = gameName;
};
// ── Character state ───────────────────────────────────────────────────────
const characterOptions = computed(() => getCharactersByGame(scoreboardStore.scoreboard.game));
const leftCharacterOptions = ref<CharacterOption[]>([]);
const rightCharacterOptions = ref<CharacterOption[]>([]);
const leftCharacterInput = ref('');
const rightCharacterInput = ref('');
// Remembers selected characters per game so swapping games restores them
const charactersByGame = ref<Record<string, { leftCharacter: string; rightCharacter: string }>>({});
// Character images for preview
const leftCharacterImage = computed(() => {
const match = characterOptions.value.find(
(o) => o.value === scoreboardStore.scoreboard.leftCharacter,
@@ -69,20 +99,21 @@ export function useCharacterGame() {
return match?.image ?? '';
});
// ---------------------------------------------------------------------------
// Filter handlers
// ---------------------------------------------------------------------------
// ── Filter handlers ───────────────────────────────────────────────────────
const onGameFilter = (value: string, update: (fn: () => void) => void) => {
update(() => {
const needle = value.toLowerCase().trim();
fightingGameOptions.value = needle
? ALL_FIGHTING_GAME_OPTIONS.filter((g) => g.label.toLowerCase().includes(needle))
: ALL_FIGHTING_GAME_OPTIONS;
? packRegistry.allGameOptions.value.filter((g) =>
g.label.toLowerCase().includes(needle),
)
: packRegistry.allGameOptions.value;
});
};
const makeCharacterFilter = (target: Ref<CharacterOption[]>) =>
const makeCharacterFilter =
(target: Ref<CharacterOption[]>) =>
(value: string, update: (fn: () => void) => void) => {
update(() => {
const needle = value.toLowerCase().trim();
@@ -95,16 +126,14 @@ export function useCharacterGame() {
const onLeftCharacterFilter = makeCharacterFilter(leftCharacterOptions);
const onRightCharacterFilter = makeCharacterFilter(rightCharacterOptions);
// ---------------------------------------------------------------------------
// Watchers
// ---------------------------------------------------------------------------
// ── Watchers ──────────────────────────────────────────────────────────────
// Keep gameInput display value in sync
// Keep gameInput display value in sync with the store
watch(
() => scoreboardStore.scoreboard.game,
(value) => {
const match = ALL_FIGHTING_GAME_OPTIONS.find((o) => o.value === value);
gameInput.value = match?.label ?? '';
const match = fightingGameOptions.value.find((o) => o.value === value);
gameInput.value = match?.label ?? value;
},
{ immediate: true },
);
@@ -133,7 +162,6 @@ export function useCharacterGame() {
if (!allowed.has(nextLeft)) nextLeft = '';
if (!allowed.has(nextRight)) nextRight = '';
// Apply defaults only when neither side had a character yet
if ((!nextLeft || !nextRight) && (!curLeft || !curRight)) {
const defaults = getDefaultCharactersByGame(newGame);
if (defaults) {
@@ -159,7 +187,6 @@ export function useCharacterGame() {
{ immediate: true },
);
// Keep left character display input and charactersByGame cache in sync
watch(
() => scoreboardStore.scoreboard.leftCharacter,
(value) => {
@@ -176,7 +203,6 @@ export function useCharacterGame() {
{ immediate: true },
);
// Keep right character display input and charactersByGame cache in sync
watch(
() => scoreboardStore.scoreboard.rightCharacter,
(value) => {
@@ -193,16 +219,24 @@ export function useCharacterGame() {
{ immediate: true },
);
// ── Return ────────────────────────────────────────────────────────────────
return {
// Game selector
gameInput,
fightingGameOptions,
onGameFilter,
handleGameSelect,
// Download dialog
pendingDownloadEntry,
showDownloadDialog,
// Character state
leftCharacterOptions,
rightCharacterOptions,
leftCharacterInput,
rightCharacterInput,
leftCharacterImage,
rightCharacterImage,
onGameFilter,
onLeftCharacterFilter,
onRightCharacterFilter,
};
@@ -0,0 +1,288 @@
// src/dashboard/scoreboard/composables/usePackRegistry.ts
// ─────────────────────────────────────────────────────────────────────────────
// Singleton composable. The first caller sets up NodeCG replicant listeners;
// subsequent calls return the same reactive state. This avoids duplicate event
// listeners when multiple components call usePackRegistry().
// ─────────────────────────────────────────────────────────────────────────────
import { computed, ref, type ComputedRef, type InjectionKey } from 'vue';
import {
BUNDLED_GAME_NAMES,
registerInstalledPack,
unregisterInstalledPack,
} from '../../../shared/fighting-characters';
import { BUNDLE_NAME } from '../../../shared/pack-config';
import type {
GameSelectOption,
PackDownloadState,
PackManifest,
PackRegistry,
PackRegistryEntry,
} from '../../../shared/pack-types';
// ── NodeCG global type declarations ──────────────────────────────────────────
// NodeCG injects these into the browser window via its bundle script.
declare const NodeCG: {
Replicant: <T>(
name: string,
bundleName: string,
opts?: { defaultValue?: T },
) => {
value: T;
on(event: 'change', handler: (newVal: T, oldVal?: T) => void): void;
off(event: string, handler: (...args: unknown[]) => void): void;
};
waitForReplicants: (...reps: unknown[]) => Promise<void>;
};
declare const nodecg: {
sendMessage(name: string, data?: unknown): void;
sendMessage(
name: string,
data: unknown,
cb: (err: Error | null, result?: unknown) => void,
): void;
};
// ── Module-level singleton state ──────────────────────────────────────────────
let initialized = false;
const registry = ref<PackRegistry | null>(null);
const installedPackIds = ref<string[]>([]);
const downloadStates = ref<Record<string, PackDownloadState>>({});
const availableUpdates = ref<Record<string, { installedVersion: string; latestVersion: string }>>({});
// Tracks which installed pack manifests have been loaded into fighting-characters.ts
const loadedManifestIds = new Set<string>();
// ── Helpers ───────────────────────────────────────────────────────────────────
const formatBytes = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
/**
* Asks the NodeCG extension to read the local manifest.json for an installed
* pack and registers the characters in fighting-characters.ts.
*/
const loadInstalledManifest = (packId: string): void => {
if (loadedManifestIds.has(packId)) return;
nodecg.sendMessage('readLocalManifest', packId, (err, result) => {
if (err) {
console.error(`[usePackRegistry] Failed to load manifest for "${packId}":`, err);
return;
}
const manifest = result as PackManifest;
registerInstalledPack(manifest);
loadedManifestIds.add(packId);
});
};
// ── Replicant setup (runs once) ───────────────────────────────────────────────
const initReplicants = (): void => {
if (initialized) return;
initialized = true;
const registryRep = NodeCG.Replicant<PackRegistry | null>('packRegistry', BUNDLE_NAME, {
defaultValue: null,
});
const installedRep = NodeCG.Replicant<string[]>('installedPacks', BUNDLE_NAME, {
defaultValue: [],
});
const statesRep = NodeCG.Replicant<Record<string, PackDownloadState>>('downloadStates', BUNDLE_NAME, {
defaultValue: {},
});
const updatesRep = NodeCG.Replicant<Record<string, { installedVersion: string; latestVersion: string }>>('availableUpdates', BUNDLE_NAME, {
defaultValue: {},
});
NodeCG.waitForReplicants(registryRep, installedRep, statesRep, updatesRep).then(() => {
// Hydrate initial values
registry.value = registryRep.value;
installedPackIds.value = installedRep.value ?? [];
downloadStates.value = statesRep.value ?? {};
availableUpdates.value = updatesRep.value ?? {};
// Load manifests for packs already installed before this session
for (const id of installedPackIds.value) {
if (!BUNDLED_GAME_NAMES.has(getGameNameById(id))) {
loadInstalledManifest(id);
}
}
// Subscribe to changes
registryRep.on('change', (val) => {
registry.value = val;
});
installedRep.on('change', (newVal, oldVal) => {
const next = newVal ?? [];
const prev = oldVal ?? [];
installedPackIds.value = next;
// 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));
for (const id of removed) {
const gameName = getGameNameById(id);
unregisterInstalledPack(gameName);
loadedManifestIds.delete(id);
}
});
statesRep.on('change', (val) => {
downloadStates.value = val ?? {};
});
updatesRep.on('change', (val) => {
availableUpdates.value = val ?? {};
});
});
};
/**
* Given a pack ID (e.g. "street-fighter-6"), returns the matching game name
* from the current registry, or an empty string if the registry isn't loaded.
*/
const getGameNameById = (packId: string): string =>
registry.value?.packs.find((p) => p.id === packId)?.name ?? '';
// ── Public composable ─────────────────────────────────────────────────────────
export interface PackRegistryContext {
/** Full registry fetched from Gitea (null until first fetch). */
registry: typeof registry;
/** IDs of packs installed on disk (bundled packs are NOT in this list). */
installedPackIds: typeof installedPackIds;
/** Per-pack download state. */
downloadStates: typeof downloadStates;
/** Checks if a game is available (bundled OR installed). */
isGameAvailable: (gameName: string) => boolean;
/** Returns the download state for a pack, or a default idle state. */
getDownloadState: (packId: string) => PackDownloadState;
/** All games from the registry, enriched with availability info. */
allGameOptions: ReturnType<typeof buildAllGameOptions>;
/** Tells the extension to fetch the latest registry.json from Gitea. */
fetchRegistry: () => void;
/** Tells the extension to download and install a pack. */
downloadPack: (packId: string) => void;
/** Tells the extension to uninstall a pack and delete its files. */
uninstallPack: (packId: string) => void;
/** Tells the extension to download and apply an update for an installed pack. */
updatePack: (packId: string) => void;
/** Map of packId → version info for packs that have a newer version available. */
availableUpdates: typeof availableUpdates;
/** Total number of packs with available updates. */
updateCount: ComputedRef<number>;
/** Human-readable file size. */
formatBytes: typeof formatBytes;
/** Returns the URL for the pack's logo served by NodeCG (installed packs only). */
getLocalLogoUrl: (packId: string) => string;
}
export const PACK_REGISTRY_KEY: InjectionKey<PackRegistryContext> = Symbol('packRegistry');
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,
}));
}
return registry.value.packs.map((entry) => ({
label: entry.name,
value: entry.name,
available: entry.bundled || installedPackIds.value.includes(entry.id),
registryEntry: entry,
updateInfo: availableUpdates.value[entry.id],
}));
});
export function usePackRegistry(): PackRegistryContext {
initReplicants();
const allGameOptions = buildAllGameOptions();
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);
};
const getDownloadState = (packId: string): PackDownloadState =>
downloadStates.value[packId] ?? { status: 'idle', progress: 0 };
const getLocalLogoUrl = (packId: string): string =>
`/assets/${BUNDLE_NAME}/packs/${packId}/logo.png`;
const fetchRegistry = (): void => {
nodecg.sendMessage('fetchPackRegistry', undefined, (err) => {
if (err) console.error('[usePackRegistry] fetchPackRegistry failed:', err);
});
};
const downloadPack = (packId: string): void => {
nodecg.sendMessage('downloadPack', packId, (err) => {
if (err) console.error(`[usePackRegistry] downloadPack "${packId}" failed:`, err);
});
};
const uninstallPack = (packId: string): void => {
nodecg.sendMessage('uninstallPack', packId, (err) => {
if (err) console.error(`[usePackRegistry] uninstallPack "${packId}" failed:`, err);
});
};
const updatePack = (packId: string): void => {
nodecg.sendMessage('updatePack', packId, (err) => {
if (err) console.error(`[usePackRegistry] updatePack "${packId}" failed:`, err);
});
};
const updateCount = computed(() => Object.keys(availableUpdates.value).length);
return {
registry,
installedPackIds,
downloadStates,
isGameAvailable,
getDownloadState,
allGameOptions,
fetchRegistry,
downloadPack,
uninstallPack,
updatePack,
availableUpdates,
updateCount,
formatBytes,
getLocalLogoUrl,
};
}
+1
View File
@@ -11,4 +11,5 @@ export default async (nodecg: NodeCGServerAPI) => {
await import('./example.js');
await import('./startgg.js');
await import('./challonge.js');
await import('./pack-manager.js');
};
+394
View File
@@ -0,0 +1,394 @@
// src/extension/pack-manager.ts
// ─────────────────────────────────────────────────────────────────────────────
// Módulo autocontenido: no importa nada de src/shared/ para respetar el
// rootDir del tsconfig de la extensión. Las constantes de Gitea y los tipos
// necesarios están definidos aquí directamente.
//
// Para activarlo, añade UNA línea en src/extension/index.ts:
// await import('./pack-manager.js');
// ─────────────────────────────────────────────────────────────────────────────
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { nodecg } from './util/nodecg.js';
// ── Configuración de Gitea ────────────────────────────────────────────────────
// Edita estas constantes para apuntar a tu instancia.
const GITEA_BASE_URL = 'http://localhost:3000';
const GITEA_OWNER = 'admin';
const GITEA_REPO = 'fighting-game-packs';
const GITEA_BRANCH = 'main';
const rawUrl = (repoPath: string) =>
`${GITEA_BASE_URL}/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}/${repoPath}`;
const REGISTRY_URL = rawUrl('registry.json');
const getManifestUrl = (id: string) => rawUrl(`${id}/manifest.json`);
const getPackLogoUrl = (id: string) => rawUrl(`${id}/logo.png`);
const getCharacterImageRepoUrl = (id: string, slug: string, ext: string) =>
rawUrl(`${id}/characters/${slug}.${ext}`);
// ── Tipos locales ─────────────────────────────────────────────────────────────
interface PackCharacter {
name: string;
slug: string;
dlc?: boolean;
sizeBytes: number;
}
interface PackManifest {
id: string;
name: string;
version: string;
palette: { start: string; end: string };
defaultPair?: { left: string; right: string };
characters: PackCharacter[];
}
interface PackRegistry {
schemaVersion: number;
updatedAt: string;
packs: Array<{
id: string;
name: string;
version: string;
totalSizeBytes: number;
logoPath: string;
characterCount: number;
palette: { start: string; end: string };
bundled: boolean;
}>;
}
interface PackDownloadState {
status: 'idle' | 'fetching-manifest' | 'downloading' | 'done' | 'error';
progress: number;
error?: string;
}
// Replicamos la forma exacta del tipo Acknowledgement de NodeCG sin necesidad
// de importar @nodecg/types. HandledAcknowledgement NO es callable (es un objeto),
// UnhandledAcknowledgement SÍ lo es. El helper reply() comprueba cuál es antes de llamar.
type HandledAcknowledgement = { handled: true };
type UnhandledAcknowledgement = ((error?: Error | null, ...args: unknown[]) => void) & { handled: false };
type Acknowledgement = HandledAcknowledgement | UnhandledAcknowledgement;
const reply = (ack: Acknowledgement | undefined, err: Error | null, result?: unknown): void => {
if (ack && !ack.handled) ack(err ?? undefined, result);
};
// ── Constantes ────────────────────────────────────────────────────────────────
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.
const bundleDir = fileURLToPath(new URL('../../', import.meta.url));
// ── Replicants ────────────────────────────────────────────────────────────────
const installedPacksRep = nodecg.Replicant<string[]>('installedPacks', {
defaultValue: [],
persistent: true,
});
const packRegistryRep = nodecg.Replicant<PackRegistry | null>('packRegistry', {
defaultValue: null,
persistent: true,
});
const downloadStatesRep = nodecg.Replicant<Record<string, PackDownloadState>>('downloadStates', {
defaultValue: {},
persistent: false,
});
/** Packs instalados para los que hay una versión más nueva en el registro. */
const availableUpdatesRep = nodecg.Replicant<Record<string, { installedVersion: string; latestVersion: string }>>('availableUpdates', {
defaultValue: {},
persistent: false,
});
// ── Filesystem ────────────────────────────────────────────────────────────────
const packsDir = path.join(bundleDir, 'assets', 'packs');
fs.mkdirSync(packsDir, { recursive: true });
// Verificación de integridad al arrancar
const installedAtStart = installedPacksRep.value ?? [];
const verified = installedAtStart.filter((id) =>
fs.existsSync(path.join(packsDir, id, 'manifest.json')),
);
if (verified.length !== installedAtStart.length) {
nodecg.log.warn('[pack-manager] Algunos packs instalados no estaban en disco y se han eliminado del registro.');
installedPacksRep.value = verified;
}
// ── Helpers internos ──────────────────────────────────────────────────────────
const setDownloadState = (packId: string, patch: Partial<PackDownloadState>): void => {
const current = downloadStatesRep.value?.[packId] ?? { status: 'idle', progress: 0 };
downloadStatesRep.value = {
...downloadStatesRep.value,
[packId]: { ...current, ...patch },
};
};
const fetchBuffer = async (url: string): Promise<Buffer> => {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}${url}`);
return Buffer.from(await response.arrayBuffer());
};
const trySaveImage = async (
destDir: string,
filename: string,
extensions: readonly string[],
buildUrl: (ext: string) => string,
): Promise<boolean> => {
for (const ext of extensions) {
try {
const buffer = await fetchBuffer(buildUrl(ext));
fs.writeFileSync(path.join(destDir, `${filename}.${ext}`), buffer);
return true;
} catch { /* prueba siguiente extensión */ }
}
return false;
};
// ── Detección de actualizaciones ─────────────────────────────────────────────
// Compara la versión en el manifest.json local de cada pack instalado contra
// la versión en el registro de Gitea. Solo aplica a packs descargados (no bundled).
const checkForUpdates = (): void => {
const registry = packRegistryRep.value;
const installed = installedPacksRep.value ?? [];
if (!registry || installed.length === 0) {
availableUpdatesRep.value = {};
return;
}
const updates: Record<string, { installedVersion: string; latestVersion: string }> = {};
for (const packId of installed) {
const registryEntry = registry.packs.find((p) => p.id === packId);
if (!registryEntry) continue;
const manifestPath = path.join(packsDir, packId, 'manifest.json');
try {
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as PackManifest;
if (manifest.version !== registryEntry.version) {
updates[packId] = {
installedVersion: manifest.version,
latestVersion: registryEntry.version,
};
nodecg.log.info(
`[pack-manager] Actualización disponible para "${packId}": ${manifest.version}${registryEntry.version}`,
);
}
} catch {
// Manifest ilegible — ignorar este pack
}
}
availableUpdatesRep.value = updates;
};
// Comprobar al arrancar si ya hay un registro cacheado
checkForUpdates();
// ── Mensaje: fetchPackRegistry ────────────────────────────────────────────────
nodecg.listenFor('fetchPackRegistry', async (_data: unknown, ack: Acknowledgement | undefined) => {
try {
const response = await fetch(REGISTRY_URL);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const registry = await response.json() as PackRegistry;
packRegistryRep.value = registry;
checkForUpdates(); // re-evaluar actualizaciones con el registro nuevo
reply(ack, null, registry);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
nodecg.log.error(`[pack-manager] Error al obtener el registro: ${message}`);
reply(ack, new Error(message));
}
});
// ── Mensaje: downloadPack ─────────────────────────────────────────────────────
nodecg.listenFor('downloadPack', async (packId: unknown, ack: Acknowledgement | undefined) => {
if (typeof packId !== 'string' || !packId) {
return reply(ack, new Error('downloadPack requiere un packId no vacío.'));
}
if (installedPacksRep.value?.includes(packId)) {
return reply(ack, null, { alreadyInstalled: true });
}
if (downloadStatesRep.value?.[packId]?.status === 'downloading') {
return reply(ack, new Error(`El pack "${packId}" ya se está descargando.`));
}
setDownloadState(packId, { status: 'fetching-manifest', progress: 0, error: undefined });
try {
const manifestRes = await fetch(getManifestUrl(packId));
if (!manifestRes.ok) throw new Error(`No se puede obtener el manifest: HTTP ${manifestRes.status}`);
const manifest = await manifestRes.json() as PackManifest;
const packDir = path.join(packsDir, packId);
const charsDir = path.join(packDir, 'characters');
fs.mkdirSync(charsDir, { recursive: true });
fs.writeFileSync(path.join(packDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
setDownloadState(packId, { status: 'downloading', progress: 2 });
try {
const logoBuffer = await fetchBuffer(getPackLogoUrl(packId));
fs.writeFileSync(path.join(packDir, 'logo.png'), logoBuffer);
} catch {
nodecg.log.warn(`[pack-manager] No se encontró logo para "${packId}" — se omite.`);
}
const total = manifest.characters.length;
for (let i = 0; i < total; i++) {
const char = manifest.characters[i]!;
const saved = await trySaveImage(
charsDir,
char.slug,
IMAGE_EXTENSIONS,
(ext) => getCharacterImageRepoUrl(packId, char.slug, ext),
);
if (!saved) {
nodecg.log.warn(`[pack-manager] Sin imagen para "${packId}/${char.slug}" — se usará placeholder.`);
}
setDownloadState(packId, { progress: 5 + Math.round(((i + 1) / total) * 93) });
}
const current = installedPacksRep.value ?? [];
if (!current.includes(packId)) installedPacksRep.value = [...current, packId];
setDownloadState(packId, { status: 'done', progress: 100 });
reply(ack, null, { packId, characterCount: manifest.characters.length });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
nodecg.log.error(`[pack-manager] Error al descargar "${packId}": ${message}`);
setDownloadState(packId, { status: 'error', error: message });
reply(ack, new Error(message));
}
});
// ── Mensaje: uninstallPack ────────────────────────────────────────────────────
nodecg.listenFor('uninstallPack', (packId: unknown, ack: Acknowledgement | undefined) => {
if (typeof packId !== 'string' || !packId) {
return reply(ack, new Error('uninstallPack requiere un packId no vacío.'));
}
try {
fs.rmSync(path.join(packsDir, packId), { recursive: true, force: true });
installedPacksRep.value = (installedPacksRep.value ?? []).filter((id) => id !== packId);
const states = { ...downloadStatesRep.value };
delete states[packId];
downloadStatesRep.value = states;
const updates = { ...availableUpdatesRep.value };
delete updates[packId];
availableUpdatesRep.value = updates;
reply(ack, null);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
nodecg.log.error(`[pack-manager] Error al desinstalar "${packId}": ${message}`);
reply(ack, new Error(message));
}
});
// ── Mensaje: updatePack ──────────────────────────────────────────────────────
// Dashboard → Extension: "Actualiza el pack <packId> a la última versión."
// Borra las imágenes antiguas y descarga las nuevas desde Gitea.
nodecg.listenFor('updatePack', async (packId: unknown, ack: Acknowledgement | undefined) => {
if (typeof packId !== 'string' || !packId) {
return reply(ack, new Error('updatePack requiere un packId no vacío.'));
}
if (!installedPacksRep.value?.includes(packId)) {
return reply(ack, new Error(`El pack "${packId}" no está instalado. Usa downloadPack primero.`));
}
if (downloadStatesRep.value?.[packId]?.status === 'downloading') {
return reply(ack, new Error(`El pack "${packId}" ya se está actualizando.`));
}
setDownloadState(packId, { status: 'fetching-manifest', progress: 0, error: undefined });
try {
// 1. Obtener nuevo manifest
const manifestRes = await fetch(getManifestUrl(packId));
if (!manifestRes.ok) throw new Error(`No se puede obtener el manifest: HTTP ${manifestRes.status}`);
const manifest = await manifestRes.json() as PackManifest;
const packDir = path.join(packsDir, packId);
const charsDir = path.join(packDir, 'characters');
// 2. Limpiar imágenes antiguas (evita residuos de personajes renombrados/eliminados)
if (fs.existsSync(charsDir)) {
fs.rmSync(charsDir, { recursive: true, force: true });
}
fs.mkdirSync(charsDir, { recursive: true });
// 3. Guardar nuevo manifest en disco
fs.writeFileSync(path.join(packDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
// 4. Logo
setDownloadState(packId, { status: 'downloading', progress: 2 });
try {
const logoBuffer = await fetchBuffer(getPackLogoUrl(packId));
fs.writeFileSync(path.join(packDir, 'logo.png'), logoBuffer);
} catch {
nodecg.log.warn(`[pack-manager] No se encontró logo para "${packId}" — se omite.`);
}
// 5. Imágenes de personajes
const total = manifest.characters.length;
for (let i = 0; i < total; i++) {
const char = manifest.characters[i]!;
const saved = await trySaveImage(
charsDir,
char.slug,
IMAGE_EXTENSIONS,
(ext) => getCharacterImageRepoUrl(packId, char.slug, ext),
);
if (!saved) {
nodecg.log.warn(`[pack-manager] Sin imagen para "${packId}/${char.slug}" — se usará placeholder.`);
}
setDownloadState(packId, { progress: 5 + Math.round(((i + 1) / total) * 93) });
}
// 6. Quitar de availableUpdates
const updates = { ...availableUpdatesRep.value };
delete updates[packId];
availableUpdatesRep.value = updates;
setDownloadState(packId, { status: 'done', progress: 100 });
nodecg.log.info(`[pack-manager] Pack "${packId}" actualizado a v${manifest.version}.`);
reply(ack, null, { packId, version: manifest.version });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
nodecg.log.error(`[pack-manager] Error al actualizar "${packId}": ${message}`);
setDownloadState(packId, { status: 'error', error: message });
reply(ack, new Error(message));
}
});
// ── Mensaje: readLocalManifest ────────────────────────────────────────────────
nodecg.listenFor('readLocalManifest', (packId: unknown, ack: Acknowledgement | undefined) => {
if (typeof packId !== 'string' || !packId) {
return reply(ack, new Error('readLocalManifest requiere un packId no vacío.'));
}
const manifestPath = path.join(packsDir, packId, 'manifest.json');
try {
const raw = fs.readFileSync(manifestPath, 'utf-8');
reply(ack, null, JSON.parse(raw) as PackManifest);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
reply(ack, new Error(`No se puede leer el manifest de "${packId}": ${message}`));
}
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

After

Width:  |  Height:  |  Size: 527 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 MiB

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 MiB

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

After

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 MiB

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 MiB

After

Width:  |  Height:  |  Size: 606 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 MiB

After

Width:  |  Height:  |  Size: 16 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 MiB

After

Width:  |  Height:  |  Size: 920 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 MiB

After

Width:  |  Height:  |  Size: 744 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 MiB

After

Width:  |  Height:  |  Size: 898 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 MiB

After

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 MiB

After

Width:  |  Height:  |  Size: 522 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

After

Width:  |  Height:  |  Size: 972 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 MiB

After

Width:  |  Height:  |  Size: 611 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 MiB

After

Width:  |  Height:  |  Size: 580 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 MiB

After

Width:  |  Height:  |  Size: 606 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

After

Width:  |  Height:  |  Size: 877 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 MiB

After

Width:  |  Height:  |  Size: 967 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 MiB

After

Width:  |  Height:  |  Size: 826 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

After

Width:  |  Height:  |  Size: 561 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

After

Width:  |  Height:  |  Size: 775 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

After

Width:  |  Height:  |  Size: 476 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 MiB

After

Width:  |  Height:  |  Size: 708 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

After

Width:  |  Height:  |  Size: 464 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 MiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 MiB

After

Width:  |  Height:  |  Size: 584 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 MiB

After

Width:  |  Height:  |  Size: 965 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

After

Width:  |  Height:  |  Size: 657 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

After

Width:  |  Height:  |  Size: 687 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

After

Width:  |  Height:  |  Size: 445 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

After

Width:  |  Height:  |  Size: 772 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 MiB

After

Width:  |  Height:  |  Size: 673 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

After

Width:  |  Height:  |  Size: 577 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 MiB

After

Width:  |  Height:  |  Size: 807 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

After

Width:  |  Height:  |  Size: 552 KiB

+231 -354
View File
@@ -1,3 +1,16 @@
// 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>
// ─────────────────────────────────────────────────────────────────────────────
import { BUNDLE_NAME } from './pack-config';
import type { PackManifest } from './pack-types';
export interface FightingCharacterOption {
label: string;
value: string;
@@ -10,301 +23,81 @@ type GamePalette = readonly [startColor: string, endColor: string];
const DEFAULT_PLACEHOLDER_PALETTE: GamePalette = ['#334155', '#0f172a'];
const MAX_INITIALS = 2;
// ─────────────────────────────────────────────────────────────────────────────
// BUNDLED DATA
// ─────────────────────────────────────────────────────────────────────────────
const characterNamesByGame: Record<string, string[]> = {
'2XKO': [
'Ahri',
'Akali',
'Braum',
'Caitlyn',
'Darius',
'Ekko',
'Illaoi',
'Jinx',
'Senna',
'Teemo',
'Vi',
'Warwick',
'Yasuo',
'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',
'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',
'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',
'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',
],
'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',
'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',
'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',
'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',
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',
},
'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> = {
@@ -316,11 +109,49 @@ const paletteByGame: Record<string, GamePalette> = {
'THE KING OF FIGHTERS XV': ['#0ea5e9', '#1e3a8a'],
};
const toSlug = (value: string) => value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
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',
]),
};
const toDataUrl = (svg: string) => `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
// ─────────────────────────────────────────────────────────────────────────────
// Image resolution — BUNDLED
// ─────────────────────────────────────────────────────────────────────────────
const buildCharacterPlaceholder = (game: string, character: string) => {
const toSlug = (value: string): string =>
value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
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 initials = character
.split(/\s+/)
@@ -347,108 +178,154 @@ const buildCharacterPlaceholder = (game: string, character: string) => {
return toDataUrl(svg.trim());
};
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 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;
}
const characterSlug = filename.replace(/\.[^.]+$/, '');
return `${gameFolder}/${characterSlug}`;
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) {
const characterImageByKey = Object.entries(characterImageModules).reduce<Record<string, string>>(
(acc, [path, url]) => {
const key = resolveImageKey(path);
if (key) acc[key] = url;
return acc;
}
},
{},
);
acc[key] = url;
return acc;
}, {});
const getCharacterImage = (game: string, character: string, characterValue: string) => {
const getBundledCharacterImage = (game: string, character: string, slug: string): string => {
const gameSlug = toSlug(game);
const key = `${gameSlug}/${characterValue}`;
const key = `${gameSlug}/${slug}`;
return characterImageByKey[key] ?? buildCharacterPlaceholder(game, character);
};
/**
* DLC characters per game. Update as new content is released.
* Characters not listed here are treated as base-roster.
*/
const dlcCharactersByGame: Record<string, ReadonlySet<string>> = {
'FATAL FURY: City of the Wolves': new Set([
'Chun-Li', // Season Pass (crossover)
'Cristiano Ronaldo', // Season Pass (celebrity)
'Ken Masters', // Season Pass (crossover)
'Kenshiro', // Season Pass (crossover)
'Nightmare Geese', // Season Pass
'Salvatore Ganacci', // Season Pass (celebrity)
'Vox Reaper', // Season Pass
]),
'Guilty Gear -Strive-': new Set([
// Season 1
'Goldlewis Dickinson', 'Jack-O', 'Happy Chaos', 'Baiken', 'Testament',
// Season 2
'Bridget', 'Sin Kiske', 'Bedman?', 'Asuka R. Kreutz', 'Johnny',
// Season 3
'Elphelt Valentine', 'A.B.A', 'Slayer', 'Dizzy', 'Venom',
// Season 4
'Lucy', 'Unika',
]),
'Mortal Kombat 1': new Set([
// Kombat Pack 1
'Ermac', 'Homelander', 'Omni-Man', 'Peacemaker', 'Quan Chi', 'Tanya',
// Kombat Pack 2
'Conan the Barbarian', 'Cyrax', 'Ghostface', 'Noob Saibot', 'Sektor',
'Shang Tsung', 'Takeda', 'T-1000',
]),
'Street Fighter 6': new Set([
// Year 1
'A.K.I.', 'Akuma', 'Bison', 'Ed',
// Year 2
'Alex', 'Elena', 'Mai', 'Sagat', 'Terry', 'Viper',
]),
'TEKKEN 8': new Set([
// Season 1
'Clive', 'Eddy', 'Heihachi', 'Lidia',
// Season 2
'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',
]),
};
// ─────────────────────────────────────────────────────────────────────────────
// 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);
// Prefer packaged artwork and gracefully fallback to a generated image.
return {
label: character,
value,
image: getCharacterImage(game, character, value),
image: getBundledCharacterImage(game, character, value),
dlc: dlcCharactersByGame[game]?.has(character) ?? false,
};
}),
]),
);
export const getCharactersByGame = (game: string) => fightingCharactersByGame[game] ?? [];
/**
* 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));
export const getDefaultCharactersByGame = (game: string) => defaultCharacterPairByGame[game];
// ─────────────────────────────────────────────────────────────────────────────
// 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 }> = {};
/**
* 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 => {
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: `/assets/${BUNDLE_NAME}/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),
}));
if (defaultPair) {
installedPackDefaults[name] = {
leftCharacter: defaultPair.left,
rightCharacter: defaultPair.right,
};
}
};
/**
* Removes a previously registered installed pack.
* Called by usePackRegistry when a pack is uninstalled.
*/
export const unregisterInstalledPack = (gameName: string): void => {
delete installedPackCharacters[gameName];
delete installedPackDefaults[gameName];
};
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();
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] ?? [];
/** 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];
+37
View File
@@ -0,0 +1,37 @@
// src/shared/pack-config.ts
// ─────────────────────────────────────────────────────────────────────────────
// Edit ONLY this file to point the pack system at your Gitea instance.
// All other files import their Gitea/NodeCG constants from here.
// ─────────────────────────────────────────────────────────────────────────────
/** Base URL of your Gitea instance — no trailing slash. */
export const GITEA_BASE_URL = 'http://10.0.0.10:3002';
/** Gitea owner (user or organisation) that owns the packs repository. */
export const GITEA_OWNER = 'Pandipipas';
/** Name of the repository that contains all game packs. */
export const GITEA_REPO = 'fighting-game-packs';
/** Branch to pull assets from. */
export const GITEA_BRANCH = 'main';
/**
* NodeCG bundle name.
* Must match the "name" field in your package.json / nodecg config.
*/
export const BUNDLE_NAME = 'scoreko-dev';
// ── Derived URL helpers (do not edit below this line) ────────────────────────
/** Returns the Gitea raw-file URL for any repo-relative path. */
export const getGiteaRawUrl = (repoPath) => `${GITEA_BASE_URL}/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}/${repoPath}`;
/** URL of the master registry file that lists every available pack. */
export const REGISTRY_URL = getGiteaRawUrl('registry.json');
/** Returns the URL for a specific pack's manifest.json. */
export const getManifestUrl = (packId) => getGiteaRawUrl(`${packId}/manifest.json`);
/** Returns the URL for a pack's logo. */
export const getPackLogoUrl = (packId) => getGiteaRawUrl(`${packId}/logo.png`);
/**
* Returns the URL for a specific character image stored in the Gitea repo.
* Used during download; at runtime installed packs are served by NodeCG.
*/
export const getCharacterImageRepoUrl = (packId, slug, ext) => getGiteaRawUrl(`${packId}/characters/${slug}.${ext}`);
/**
* Returns the runtime URL for a character image from an *installed* (downloaded) pack.
* NodeCG serves everything under assets/ at /assets/<bundleName>/.
*/
export const getInstalledCharacterImageUrl = (packId, slug, ext = 'png') => `/assets/${BUNDLE_NAME}/packs/${packId}/characters/${slug}.${ext}`;
+54
View File
@@ -0,0 +1,54 @@
// src/shared/pack-config.ts
// ─────────────────────────────────────────────────────────────────────────────
// Edit ONLY this file to point the pack system at your Gitea instance.
// All other files import their Gitea/NodeCG constants from here.
// ─────────────────────────────────────────────────────────────────────────────
/** Base URL of your Gitea instance — no trailing slash. */
export const GITEA_BASE_URL = 'http://10.0.0.10:3002';
/** Gitea owner (user or organisation) that owns the packs repository. */
export const GITEA_OWNER = 'Pandipipas';
/** Name of the repository that contains all game packs. */
export const GITEA_REPO = 'fighting-game-packs';
/** Branch to pull assets from. */
export const GITEA_BRANCH = 'main';
/**
* NodeCG bundle name.
* Must match the "name" field in your package.json / nodecg config.
*/
export const BUNDLE_NAME = 'scoreko-dev';
// ── Derived URL helpers (do not edit below this line) ────────────────────────
/** Returns the Gitea raw-file URL for any repo-relative path. */
export const getGiteaRawUrl = (repoPath: string): string =>
`${GITEA_BASE_URL}/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}/${repoPath}`;
/** URL of the master registry file that lists every available pack. */
export const REGISTRY_URL = getGiteaRawUrl('registry.json');
/** Returns the URL for a specific pack's manifest.json. */
export const getManifestUrl = (packId: string): string =>
getGiteaRawUrl(`${packId}/manifest.json`);
/** Returns the URL for a pack's logo. */
export const getPackLogoUrl = (packId: string): string =>
getGiteaRawUrl(`${packId}/logo.png`);
/**
* Returns the URL for a specific character image stored in the Gitea repo.
* Used during download; at runtime installed packs are served by NodeCG.
*/
export const getCharacterImageRepoUrl = (packId: string, slug: string, ext: string): string =>
getGiteaRawUrl(`${packId}/characters/${slug}.${ext}`);
/**
* Returns the runtime URL for a character image from an *installed* (downloaded) pack.
* NodeCG serves everything under assets/ at /assets/<bundleName>/.
*/
export const getInstalledCharacterImageUrl = (packId: string, slug: string, ext = 'png'): string =>
`/assets/${BUNDLE_NAME}/packs/${packId}/characters/${slug}.${ext}`;
+6
View File
@@ -0,0 +1,6 @@
// src/shared/pack-types.ts
// ─────────────────────────────────────────────────────────────────────────────
// Shared between the NodeCG extension (Node.js) and the dashboard (browser).
// Do NOT import anything that is browser-only or Node-only from this file.
// ─────────────────────────────────────────────────────────────────────────────
export {};
+87
View File
@@ -0,0 +1,87 @@
// src/shared/pack-types.ts
// ─────────────────────────────────────────────────────────────────────────────
// Shared between the NodeCG extension (Node.js) and the dashboard (browser).
// Do NOT import anything that is browser-only or Node-only from this file.
// ─────────────────────────────────────────────────────────────────────────────
/** A single character entry inside a pack manifest. */
export interface PackCharacter {
/** Display name, e.g. "Chun-Li" */
name: string;
/** URL-safe slug that matches the image filename, e.g. "chun-li" */
slug: string;
/** True when the character is paid DLC (shown with the DLC badge in the UI). */
dlc?: boolean;
/** Approximate compressed size of the character image file in bytes. */
sizeBytes: number;
}
/**
* Lightweight entry in the top-level registry.json.
* Enough for the UI to render the game list and the download dialog preview
* without having to fetch the full manifest.
*/
export interface PackRegistryEntry {
/** Unique identifier — must match the folder name in the repo, e.g. "street-fighter-6". */
id: string;
/** Human-readable game title shown in the selector, e.g. "Street Fighter 6". */
name: string;
/** Semantic version of this pack, e.g. "1.0.0". Bump when adding/updating characters. */
version: string;
/** Total download size (sum of all character images + logo) in bytes. */
totalSizeBytes: number;
/** Repo-relative path to the game's logo image, e.g. "street-fighter-6/logo.png". */
logoPath: string;
/** Pre-computed character count so the dialog can show it without loading the manifest. */
characterCount: number;
/** Gradient used for placeholder images when a character has no artwork. */
palette: { start: string; end: string };
/**
* True when the pack ships inside the application bundle (bundled via Vite's
* import.meta.glob). Bundled packs are always "installed" and never show the
* download button, but they still appear in the registry so the app can detect
* updates (version mismatch between bundle and registry).
*/
bundled: boolean;
}
/** Full pack data — lives at <packId>/manifest.json in the repo. */
export interface PackManifest {
/** Must match PackRegistryEntry.id and the folder name. */
id: string;
/** Must match PackRegistryEntry.name. */
name: string;
version: string;
palette: { start: string; end: string };
/** Default characters pre-selected when this game is first chosen. */
defaultPair?: { left: string; right: string };
/** Full character roster, in the order they should appear in the selector. */
characters: PackCharacter[];
}
/** Top-level registry.json structure. */
export interface PackRegistry {
schemaVersion: number;
updatedAt: string;
packs: PackRegistryEntry[];
}
/** Tracks the download lifecycle of a single pack. */
export interface PackDownloadState {
status: 'idle' | 'fetching-manifest' | 'downloading' | 'done' | 'error';
/** Progress percentage 0100. */
progress: number;
error?: string;
}
/** Shape of the option objects surfaced by usePackRegistry.allGameOptions. */
export interface GameSelectOption {
/** Display label for the QSelect. */
label: string;
/** Value stored in the scoreboard (equals PackRegistryEntry.name for installed games). */
value: string;
/** Whether the pack can be used right now (bundled or already downloaded). */
available: boolean;
/** Mirrors PackRegistryEntry so the download dialog can be populated inline. */
registryEntry: PackRegistryEntry;
}