Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 618d18d8fb | |||
| 0bc6f60b2c | |||
| 88aeedb5ff |
@@ -142,3 +142,4 @@ dist
|
||||
/db/
|
||||
*.sqlite3
|
||||
/scoreko-electron-dev/
|
||||
/packs/
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/* 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,96 @@
|
||||
// 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 { getCharactersByGame, getDefaultCharactersByGame, installedPacksRevision } from '../../../shared/fighting-characters';
|
||||
import type { GameSelectOption, PackRegistryEntry } from '../../../shared/pack-types';
|
||||
import { useScoreboardStore } from '../stores/scoreboard';
|
||||
import { usePackRegistry } from './usePackRegistry';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
const characterOptions = computed(() => getCharactersByGame(scoreboardStore.scoreboard.game));
|
||||
/**
|
||||
* 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(() => {
|
||||
// Subscribing to installedPacksRevision forces Vue to re-evaluate this
|
||||
// computed whenever a pack is registered/unregistered at runtime, even
|
||||
// though scoreboardStore.scoreboard.game itself hasn't changed.
|
||||
void installedPacksRevision.value;
|
||||
return 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 +105,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 +132,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 },
|
||||
);
|
||||
@@ -121,6 +156,13 @@ export function useCharacterGame() {
|
||||
}
|
||||
|
||||
const options = getCharactersByGame(newGame);
|
||||
|
||||
// If the game is set but has no options yet, the pack is still loading
|
||||
// (installed pack whose registerInstalledPack() hasn't run yet).
|
||||
// Bail out — the installedPacksRevision watcher below will restore state
|
||||
// once the pack becomes available.
|
||||
if (newGame && options.length === 0) return;
|
||||
|
||||
leftCharacterOptions.value = options;
|
||||
rightCharacterOptions.value = options;
|
||||
const allowed = new Set(options.map((o) => o.value));
|
||||
@@ -133,7 +175,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 +200,6 @@ export function useCharacterGame() {
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Keep left character display input and charactersByGame cache in sync
|
||||
watch(
|
||||
() => scoreboardStore.scoreboard.leftCharacter,
|
||||
(value) => {
|
||||
@@ -176,7 +216,6 @@ export function useCharacterGame() {
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Keep right character display input and charactersByGame cache in sync
|
||||
watch(
|
||||
() => scoreboardStore.scoreboard.rightCharacter,
|
||||
(value) => {
|
||||
@@ -193,16 +232,55 @@ export function useCharacterGame() {
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// When an installed pack becomes available (e.g. after page refresh while
|
||||
// the pack loads asynchronously), re-validate and restore the characters
|
||||
// that are already in the store but couldn't be confirmed before.
|
||||
watch(installedPacksRevision, () => {
|
||||
const game = scoreboardStore.scoreboard.game;
|
||||
if (!game) return;
|
||||
|
||||
const options = getCharactersByGame(game);
|
||||
if (options.length === 0) return;
|
||||
|
||||
const allowed = new Set(options.map((o) => o.value));
|
||||
leftCharacterOptions.value = options;
|
||||
rightCharacterOptions.value = options;
|
||||
|
||||
const { leftCharacter, rightCharacter } = scoreboardStore.scoreboard;
|
||||
|
||||
if (leftCharacter && allowed.has(leftCharacter)) {
|
||||
leftCharacterInput.value = options.find((o) => o.value === leftCharacter)?.label ?? '';
|
||||
} else if (leftCharacter && !allowed.has(leftCharacter)) {
|
||||
scoreboardStore.scoreboard.leftCharacter = '';
|
||||
leftCharacterInput.value = '';
|
||||
}
|
||||
|
||||
if (rightCharacter && allowed.has(rightCharacter)) {
|
||||
rightCharacterInput.value = options.find((o) => o.value === rightCharacter)?.label ?? '';
|
||||
} else if (rightCharacter && !allowed.has(rightCharacter)) {
|
||||
scoreboardStore.scoreboard.rightCharacter = '';
|
||||
rightCharacterInput.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// ── 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 =>
|
||||
`/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,
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
// 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 type { IncomingMessage, ServerResponse } from 'http';
|
||||
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://10.0.0.10:3002';
|
||||
const GITEA_OWNER = 'Pandipipas';
|
||||
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, 'packs');
|
||||
fs.mkdirSync(packsDir, { recursive: true });
|
||||
nodecg.log.info(`[pack-manager] Packs directory: ${packsDir}`);
|
||||
|
||||
// Registrar el directorio de packs como ruta estática usando nodecg.mount().
|
||||
// Las imágenes quedan accesibles en /packs/<packId>/characters/<slug>.png
|
||||
// independientemente de cómo NodeCG configure el resto de rutas del bundle.
|
||||
const packsMiddleware = (req: IncomingMessage, res: ServerResponse) => {
|
||||
const urlPath = decodeURIComponent(req.url ?? '/');
|
||||
const safe = path.normalize(urlPath).replace(/^(\.\.[/\\])+/, '');
|
||||
const file = path.join(packsDir, safe);
|
||||
|
||||
// Security: only serve files inside packsDir
|
||||
if (!file.startsWith(packsDir)) {
|
||||
res.writeHead(403);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
fs.stat(file, (statErr, stat) => {
|
||||
if (statErr || !stat.isFile()) {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const mimeTypes: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp',
|
||||
'.avif': 'image/avif',
|
||||
'.json': 'application/json',
|
||||
};
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
res.setHeader('Content-Type', mimeTypes[ext] ?? 'application/octet-stream');
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
fs.createReadStream(file).pipe(res);
|
||||
});
|
||||
};
|
||||
|
||||
// nodecg.mount registra el middleware en el servidor Express de NodeCG
|
||||
(nodecg as unknown as { mount: (p: string, h: typeof packsMiddleware) => void })
|
||||
.mount('/packs', packsMiddleware);
|
||||
|
||||
// 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));
|
||||
// Siempre guardamos como .png para que la URL del dashboard sea predecible.
|
||||
// Los navegadores modernos identifican el formato por el contenido (magic bytes),
|
||||
// no por la extensión, así que WebP/AVIF/JPEG se renderizan correctamente.
|
||||
fs.writeFileSync(path.join(destDir, `${filename}.png`), 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}`));
|
||||
}
|
||||
});
|
||||
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 527 KiB |
|
Before Width: | Height: | Size: 4.4 MiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 3.9 MiB After Width: | Height: | Size: 311 KiB |
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 441 KiB |
|
Before Width: | Height: | Size: 3.9 MiB After Width: | Height: | Size: 410 KiB |
|
Before Width: | Height: | Size: 3.4 MiB After Width: | Height: | Size: 586 KiB |
|
Before Width: | Height: | Size: 4.6 MiB After Width: | Height: | Size: 606 KiB |
|
Before Width: | Height: | Size: 3.3 MiB After Width: | Height: | Size: 16 MiB |
|
Before Width: | Height: | Size: 4.5 MiB After Width: | Height: | Size: 920 KiB |
|
Before Width: | Height: | Size: 4.0 MiB After Width: | Height: | Size: 744 KiB |
|
Before Width: | Height: | Size: 4.3 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 4.6 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 5.2 MiB After Width: | Height: | Size: 898 KiB |
|
Before Width: | Height: | Size: 4.9 MiB After Width: | Height: | Size: 12 MiB |
|
Before Width: | Height: | Size: 4.5 MiB After Width: | Height: | Size: 522 KiB |
|
Before Width: | Height: | Size: 4.1 MiB After Width: | Height: | Size: 972 KiB |
|
Before Width: | Height: | Size: 3.5 MiB After Width: | Height: | Size: 611 KiB |
|
Before Width: | Height: | Size: 3.3 MiB After Width: | Height: | Size: 580 KiB |
|
Before Width: | Height: | Size: 4.0 MiB After Width: | Height: | Size: 606 KiB |
|
Before Width: | Height: | Size: 4.3 MiB After Width: | Height: | Size: 877 KiB |
|
Before Width: | Height: | Size: 5.6 MiB After Width: | Height: | Size: 967 KiB |
|
Before Width: | Height: | Size: 4.0 MiB After Width: | Height: | Size: 826 KiB |
|
Before Width: | Height: | Size: 3.4 MiB After Width: | Height: | Size: 561 KiB |
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 775 KiB |
|
Before Width: | Height: | Size: 4.1 MiB After Width: | Height: | Size: 476 KiB |
|
Before Width: | Height: | Size: 4.5 MiB After Width: | Height: | Size: 708 KiB |
|
Before Width: | Height: | Size: 4.1 MiB After Width: | Height: | Size: 464 KiB |
|
Before Width: | Height: | Size: 3.5 MiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 3.5 MiB After Width: | Height: | Size: 584 KiB |
|
Before Width: | Height: | Size: 5.0 MiB After Width: | Height: | Size: 965 KiB |
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 657 KiB |
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 687 KiB |
|
Before Width: | Height: | Size: 3.7 MiB After Width: | Height: | Size: 445 KiB |
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 772 KiB |
|
Before Width: | Height: | Size: 4.0 MiB After Width: | Height: | Size: 673 KiB |
|
Before Width: | Height: | Size: 3.7 MiB After Width: | Height: | Size: 577 KiB |
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 418 KiB |
|
Before Width: | Height: | Size: 4.6 MiB After Width: | Height: | Size: 807 KiB |
|
Before Width: | Height: | Size: 4.1 MiB After Width: | Height: | Size: 552 KiB |
@@ -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 { ref } from 'vue';
|
||||
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,163 @@ 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 }> = {};
|
||||
|
||||
/**
|
||||
* 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 => {
|
||||
const { id, name, palette, characters, defaultPair } = manifest;
|
||||
const [startColor, endColor] = [palette.start, palette.end];
|
||||
|
||||
installedPackCharacters[name] = characters.map((char) => ({
|
||||
label: char.name,
|
||||
value: char.slug,
|
||||
// Images are served at runtime by NodeCG's static asset handler
|
||||
image: `/packs/${id}/characters/${char.slug}.png`,
|
||||
dlc: char.dlc ?? false,
|
||||
// Fallback placeholder uses the same palette as the manifest
|
||||
_placeholder: buildInstalledPlaceholder(name, char.name, startColor, endColor),
|
||||
}));
|
||||
|
||||
if (defaultPair) {
|
||||
installedPackDefaults[name] = {
|
||||
leftCharacter: defaultPair.left,
|
||||
rightCharacter: defaultPair.right,
|
||||
};
|
||||
}
|
||||
installedPacksRevision.value++;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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];
|
||||
installedPacksRevision.value++;
|
||||
};
|
||||
|
||||
const buildInstalledPlaceholder = (
|
||||
game: string,
|
||||
character: string,
|
||||
startColor: string,
|
||||
endColor: string,
|
||||
): string => {
|
||||
const initials = character
|
||||
.split(/\s+/)
|
||||
.map((p) => p[0])
|
||||
.join('')
|
||||
.slice(0, MAX_INITIALS)
|
||||
.toUpperCase();
|
||||
|
||||
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];
|
||||
|
||||
@@ -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}`;
|
||||
@@ -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 =>
|
||||
`/packs/${packId}/characters/${slug}.${ext}`;
|
||||
@@ -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 {};
|
||||
@@ -0,0 +1,89 @@
|
||||
// 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 0–100. */
|
||||
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;
|
||||
/** Present when there is a newer version of this pack available in the registry. */
|
||||
updateInfo?: { installedVersion: string; latestVersion: string };
|
||||
}
|
||||