mirror of
https://github.com/Pandipipas/scoreko-dev.git
synced 2026-06-06 03:32:06 +00:00
feat: update pack handling and character image paths; implement installed packs revision tracking
This commit is contained in:
@@ -142,3 +142,4 @@ dist
|
|||||||
/db/
|
/db/
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
/scoreko-electron-dev/
|
/scoreko-electron-dev/
|
||||||
|
/packs/
|
||||||
@@ -13,10 +13,10 @@
|
|||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
import { computed, ref, watch, type InjectionKey, type Ref } from 'vue';
|
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 { useScoreboardStore } from '../stores/scoreboard';
|
||||||
import { usePackRegistry } from './usePackRegistry';
|
import { usePackRegistry } from './usePackRegistry';
|
||||||
import type { GameSelectOption, PackRegistryEntry } from '../../../shared/pack-types';
|
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -77,7 +77,13 @@ export function useCharacterGame() {
|
|||||||
|
|
||||||
// ── Character state ───────────────────────────────────────────────────────
|
// ── Character state ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
const characterOptions = computed(() => getCharactersByGame(scoreboardStore.scoreboard.game));
|
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 leftCharacterOptions = ref<CharacterOption[]>([]);
|
||||||
const rightCharacterOptions = ref<CharacterOption[]>([]);
|
const rightCharacterOptions = ref<CharacterOption[]>([]);
|
||||||
const leftCharacterInput = ref('');
|
const leftCharacterInput = ref('');
|
||||||
@@ -150,6 +156,13 @@ export function useCharacterGame() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const options = getCharactersByGame(newGame);
|
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;
|
leftCharacterOptions.value = options;
|
||||||
rightCharacterOptions.value = options;
|
rightCharacterOptions.value = options;
|
||||||
const allowed = new Set(options.map((o) => o.value));
|
const allowed = new Set(options.map((o) => o.value));
|
||||||
@@ -219,6 +232,37 @@ export function useCharacterGame() {
|
|||||||
{ immediate: true },
|
{ 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 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ export function usePackRegistry(): PackRegistryContext {
|
|||||||
downloadStates.value[packId] ?? { status: 'idle', progress: 0 };
|
downloadStates.value[packId] ?? { status: 'idle', progress: 0 };
|
||||||
|
|
||||||
const getLocalLogoUrl = (packId: string): string =>
|
const getLocalLogoUrl = (packId: string): string =>
|
||||||
`/assets/${BUNDLE_NAME}/packs/${packId}/logo.png`;
|
`/packs/${packId}/logo.png`;
|
||||||
|
|
||||||
const fetchRegistry = (): void => {
|
const fetchRegistry = (): void => {
|
||||||
nodecg.sendMessage('fetchPackRegistry', undefined, (err) => {
|
nodecg.sendMessage('fetchPackRegistry', undefined, (err) => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
import type { IncomingMessage, ServerResponse } from 'http';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { nodecg } from './util/nodecg.js';
|
import { nodecg } from './util/nodecg.js';
|
||||||
@@ -87,7 +88,7 @@ const IMAGE_EXTENSIONS = ['png', 'webp', 'jpg', 'jpeg', 'avif'] as const;
|
|||||||
// Raíz del proyecto: 2 niveles por encima de extension/pack-manager.js
|
// Raíz del proyecto: 2 niveles por encima de extension/pack-manager.js
|
||||||
// Usamos import.meta.url porque nodecg.bundleDir no está disponible cuando
|
// Usamos import.meta.url porque nodecg.bundleDir no está disponible cuando
|
||||||
// NodeCG se usa como dependencia en lugar de servidor standalone.
|
// NodeCG se usa como dependencia en lugar de servidor standalone.
|
||||||
const bundleDir = fileURLToPath(new URL('../../', import.meta.url));
|
const bundleDir = fileURLToPath(new URL('../', import.meta.url));
|
||||||
|
|
||||||
// ── Replicants ────────────────────────────────────────────────────────────────
|
// ── Replicants ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -114,8 +115,49 @@ const availableUpdatesRep = nodecg.Replicant<Record<string, { installedVersion:
|
|||||||
|
|
||||||
// ── Filesystem ────────────────────────────────────────────────────────────────
|
// ── Filesystem ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const packsDir = path.join(bundleDir, 'assets', 'packs');
|
const packsDir = path.join(bundleDir, 'packs');
|
||||||
fs.mkdirSync(packsDir, { recursive: true });
|
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
|
// Verificación de integridad al arrancar
|
||||||
const installedAtStart = installedPacksRep.value ?? [];
|
const installedAtStart = installedPacksRep.value ?? [];
|
||||||
@@ -152,7 +194,10 @@ const trySaveImage = async (
|
|||||||
for (const ext of extensions) {
|
for (const ext of extensions) {
|
||||||
try {
|
try {
|
||||||
const buffer = await fetchBuffer(buildUrl(ext));
|
const buffer = await fetchBuffer(buildUrl(ext));
|
||||||
fs.writeFileSync(path.join(destDir, `${filename}.${ext}`), buffer);
|
// 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;
|
return true;
|
||||||
} catch { /* prueba siguiente extensión */ }
|
} catch { /* prueba siguiente extensión */ }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
// /assets/<bundleName>/packs/<packId>/characters/<slug>.<ext>
|
// /assets/<bundleName>/packs/<packId>/characters/<slug>.<ext>
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
import { BUNDLE_NAME } from './pack-config';
|
import { ref } from 'vue';
|
||||||
import type { PackManifest } from './pack-types';
|
import type { PackManifest } from './pack-types';
|
||||||
|
|
||||||
export interface FightingCharacterOption {
|
export interface FightingCharacterOption {
|
||||||
@@ -243,6 +243,13 @@ export const BUNDLED_GAME_NAMES = new Set(Object.keys(characterNamesByGame));
|
|||||||
const installedPackCharacters: Record<string, FightingCharacterOption[]> = {};
|
const installedPackCharacters: Record<string, FightingCharacterOption[]> = {};
|
||||||
const installedPackDefaults: Record<string, { leftCharacter: string; rightCharacter: string }> = {};
|
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
|
* Registers an installed (downloaded) pack so that getCharactersByGame() and
|
||||||
* getDefaultCharactersByGame() return its data.
|
* getDefaultCharactersByGame() return its data.
|
||||||
@@ -263,7 +270,7 @@ export const registerInstalledPack = (manifest: PackManifest): void => {
|
|||||||
label: char.name,
|
label: char.name,
|
||||||
value: char.slug,
|
value: char.slug,
|
||||||
// Images are served at runtime by NodeCG's static asset handler
|
// Images are served at runtime by NodeCG's static asset handler
|
||||||
image: `/assets/${BUNDLE_NAME}/packs/${id}/characters/${char.slug}.png`,
|
image: `/packs/${id}/characters/${char.slug}.png`,
|
||||||
dlc: char.dlc ?? false,
|
dlc: char.dlc ?? false,
|
||||||
// Fallback placeholder uses the same palette as the manifest
|
// Fallback placeholder uses the same palette as the manifest
|
||||||
_placeholder: buildInstalledPlaceholder(name, char.name, startColor, endColor),
|
_placeholder: buildInstalledPlaceholder(name, char.name, startColor, endColor),
|
||||||
@@ -275,6 +282,7 @@ export const registerInstalledPack = (manifest: PackManifest): void => {
|
|||||||
rightCharacter: defaultPair.right,
|
rightCharacter: defaultPair.right,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
installedPacksRevision.value++;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -284,6 +292,7 @@ export const registerInstalledPack = (manifest: PackManifest): void => {
|
|||||||
export const unregisterInstalledPack = (gameName: string): void => {
|
export const unregisterInstalledPack = (gameName: string): void => {
|
||||||
delete installedPackCharacters[gameName];
|
delete installedPackCharacters[gameName];
|
||||||
delete installedPackDefaults[gameName];
|
delete installedPackDefaults[gameName];
|
||||||
|
installedPacksRevision.value++;
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildInstalledPlaceholder = (
|
const buildInstalledPlaceholder = (
|
||||||
|
|||||||
@@ -51,4 +51,4 @@ export const getCharacterImageRepoUrl = (packId: string, slug: string, ext: stri
|
|||||||
* NodeCG serves everything under assets/ at /assets/<bundleName>/.
|
* NodeCG serves everything under assets/ at /assets/<bundleName>/.
|
||||||
*/
|
*/
|
||||||
export const getInstalledCharacterImageUrl = (packId: string, slug: string, ext = 'png'): string =>
|
export const getInstalledCharacterImageUrl = (packId: string, slug: string, ext = 'png'): string =>
|
||||||
`/assets/${BUNDLE_NAME}/packs/${packId}/characters/${slug}.${ext}`;
|
`/packs/${packId}/characters/${slug}.${ext}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user