卫戍协议:盟约 web remake — initial import
Online co-op browser remake of Arknights' Stronghold Protocol: Alliance (client-simulated combat, official waves/bonds/items/strategies, 3D board, facing wheel, operator loadout, official emotes, deployment scripts).
This commit is contained in:
commit
01a44cd288
408 files changed
+308986
No files matched your search
@@ -0,0 +1,228 @@
|
||||
// Lazy loader + cache for the generated game data served at /data/*.json.
|
||||
//
|
||||
// Files are fetched on first use and cached (the Promise is shared, so concurrent callers trigger a
|
||||
// single request). Missing files (404, network failure, bad JSON) resolve to `null` and are
|
||||
// reported once on the console — the UI must degrade gracefully while data is being generated.
|
||||
//
|
||||
// Each file is indexed tolerantly so the getters work whether a file is
|
||||
// - an array of records carrying an id field (id / chessId / bondId / itemId / …),
|
||||
// - an object map { id: record },
|
||||
// - or either of those wrapped as { <name>: … } / { list: … } / { data: … }.
|
||||
// Synchronous getters (getChess, getBond, …) return null until the file has loaded; use
|
||||
// `loadData(...)` to await, or the `useData(...)` hook to re-render when files arrive.
|
||||
|
||||
import { useEffect, useReducer } from '../vendor/hooks.module.js';
|
||||
|
||||
/** Known data files (name → URL basename). Unknown names are allowed too (`/data/<name>.json`). */
|
||||
export const DATA_FILES = Object.freeze({
|
||||
chess: 'chess.json',
|
||||
bonds: 'bonds.json',
|
||||
items: 'items.json',
|
||||
bands: 'bands.json',
|
||||
enemies: 'enemies.json',
|
||||
bosses: 'bosses.json',
|
||||
stages: 'stages.json',
|
||||
tokens: 'tokens.json',
|
||||
choices: 'choices.json',
|
||||
config: 'config.json',
|
||||
assets: 'assets.json',
|
||||
// Optional art extracted from a local game client (DESIGN §13): { groups: { '<subdir>': { name: { path, w, h } } } }.
|
||||
local: 'local-assets.json',
|
||||
});
|
||||
|
||||
const ID_KEYS = ['id', 'chessId', 'bondId', 'itemId', 'bandId', 'enemyKey', 'enemyId', 'stageId', 'bossId', 'tokenId', 'choiceId', 'key'];
|
||||
const WRAPPER_KEYS = ['list', 'data', 'records', 'entries'];
|
||||
|
||||
function idOf(rec) {
|
||||
if (!rec || typeof rec !== 'object') return null;
|
||||
for (const k of ID_KEYS) {
|
||||
const v = rec[k];
|
||||
if ((typeof v === 'string' && v) || Number.isFinite(v)) return String(v);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an id → record Map from a data file's JSON, tolerating the shapes listed in the header.
|
||||
* @param {string} name file name (used to unwrap `{ [name]: … }`)
|
||||
* @param {any} json
|
||||
* @returns {Map<string, any>}
|
||||
*/
|
||||
export function buildIndex(name, json) {
|
||||
const map = new Map();
|
||||
let src = json;
|
||||
if (src && typeof src === 'object' && !Array.isArray(src)) {
|
||||
for (const k of [name, ...WRAPPER_KEYS]) {
|
||||
const inner = src[k];
|
||||
if (inner && typeof inner === 'object') { src = inner; break; }
|
||||
}
|
||||
}
|
||||
if (Array.isArray(src)) {
|
||||
for (const rec of src) {
|
||||
const id = idOf(rec);
|
||||
if (id != null && !map.has(id)) map.set(id, rec);
|
||||
}
|
||||
} else if (src && typeof src === 'object') {
|
||||
for (const [k, v] of Object.entries(src)) {
|
||||
if (v && typeof v === 'object') map.set(k, v);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a data store bound to a fetch implementation (injectable for tests).
|
||||
* @param {{ fetch?: typeof fetch, base?: string }} [opts]
|
||||
*/
|
||||
export function createDataStore(opts = {}) {
|
||||
const base = opts.base ?? '/data/';
|
||||
const doFetch = opts.fetch || ((...a) => globalThis.fetch(...a));
|
||||
/** @type {Map<string, { status: 'loading'|'ready'|'missing', promise: Promise<any>, value: any, index: Map<string, any>|null }>} */
|
||||
const entries = new Map();
|
||||
const listeners = new Set();
|
||||
const warned = new Set();
|
||||
|
||||
const notify = (name) => {
|
||||
for (const fn of [...listeners]) {
|
||||
try { fn(name); } catch (err) { console.error('[data] listener failed', err); }
|
||||
}
|
||||
};
|
||||
|
||||
const urlFor = (name) => base + (DATA_FILES[name] || `${name}.json`);
|
||||
|
||||
function load(name) {
|
||||
if (typeof name !== 'string' || !/^[A-Za-z0-9_-]+$/.test(name)) return Promise.resolve(null);
|
||||
const cur = entries.get(name);
|
||||
if (cur) return cur.promise;
|
||||
const entry = { status: 'loading', promise: null, value: null, index: null };
|
||||
entry.promise = (async () => {
|
||||
try {
|
||||
const res = await doFetch(urlFor(name), { cache: 'no-cache' });
|
||||
if (!res || !res.ok) throw new Error(`HTTP ${res ? res.status : '???'}`);
|
||||
const json = await res.json();
|
||||
entry.value = json;
|
||||
entry.status = 'ready';
|
||||
} catch (err) {
|
||||
if (!warned.has(name)) {
|
||||
warned.add(name);
|
||||
console.warn(`[data] ${urlFor(name)} unavailable (${err?.message || err}); continuing without it`);
|
||||
}
|
||||
entry.value = null;
|
||||
entry.status = 'missing';
|
||||
}
|
||||
// A load superseded by invalidate() must not announce itself (its entry is no longer cached).
|
||||
if (entries.get(name) === entry) notify(name);
|
||||
return entry.value;
|
||||
})();
|
||||
entries.set(name, entry);
|
||||
return entry.promise;
|
||||
}
|
||||
|
||||
function index(name) {
|
||||
const e = entries.get(name);
|
||||
if (!e || e.status !== 'ready') return null;
|
||||
if (!e.index) e.index = buildIndex(name, e.value);
|
||||
return e.index;
|
||||
}
|
||||
|
||||
return {
|
||||
/** Fetch (once) and return a file's JSON, or null when missing. */
|
||||
load,
|
||||
/** Load several files; resolves when all settled. */
|
||||
loadAll: (...names) => Promise.all(names.flat().map(load)),
|
||||
/** Raw JSON of a loaded file (null when missing / not loaded yet). */
|
||||
get: (name) => entries.get(name)?.value ?? null,
|
||||
/** 'idle' | 'loading' | 'ready' | 'missing' */
|
||||
status: (name) => entries.get(name)?.status ?? 'idle',
|
||||
/** Record by id from a loaded file (null when unknown / not loaded). */
|
||||
lookup(name, id) {
|
||||
if (id == null) return null;
|
||||
return index(name)?.get(String(id)) ?? null;
|
||||
},
|
||||
/** All records of a loaded file as an array (empty when not loaded). */
|
||||
list: (name) => [...(index(name)?.values() ?? [])],
|
||||
/** Drop a cached file and refetch it now (subscribers are notified when it settles). */
|
||||
invalidate(name) {
|
||||
if (!entries.has(name)) return Promise.resolve(null);
|
||||
entries.delete(name);
|
||||
warned.delete(name);
|
||||
const p = load(name);
|
||||
notify(name); // status is 'loading' again, never a stuck 'idle'
|
||||
return p;
|
||||
},
|
||||
/** Called with the file name whenever a file finishes loading (or is invalidated). */
|
||||
subscribe(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Browser data store singleton. */
|
||||
export const data = createDataStore();
|
||||
|
||||
/** @param {...string} names @returns {Promise<any[]>} */
|
||||
export const loadData = (...names) => data.loadAll(...names);
|
||||
|
||||
/** @param {string} id chess id (normal or elite) */
|
||||
export const getChess = (id) => data.lookup('chess', id);
|
||||
/** @param {string} id bond id, e.g. 'yanShip' */
|
||||
export const getBond = (id) => data.lookup('bonds', id);
|
||||
/** @param {string} id item (trap) id */
|
||||
export const getItem = (id) => data.lookup('items', id);
|
||||
/** @param {string} id band (strategy) id */
|
||||
export const getBand = (id) => data.lookup('bands', id);
|
||||
/** @param {string} key enemy key */
|
||||
export const getEnemy = (key) => data.lookup('enemies', key);
|
||||
/** @param {string} id boss id */
|
||||
export const getBoss = (id) => data.lookup('bosses', id);
|
||||
/** @param {string} id stage id */
|
||||
export const getStage = (id) => data.lookup('stages', id);
|
||||
/** @param {string} id token id */
|
||||
export const getToken = (id) => data.lookup('tokens', id);
|
||||
/** @returns {any|null} data/config.json */
|
||||
export const getConfig = () => data.get('config');
|
||||
|
||||
/**
|
||||
* URL of a local-client art entry (data/local-assets.json, DESIGN §13), or null when the manifest / entry is missing
|
||||
* (callers draw their own fallback). Load it first with `data.load('local')` / `useData('local')`.
|
||||
* @param {string} group e.g. 'ui/battle', 'emoticon/basic', 'guide'
|
||||
* @param {string} name entry name without extension
|
||||
*/
|
||||
export function localAsset(group, name) {
|
||||
const g = data.get('local')?.groups?.[group];
|
||||
const e = g && typeof g === 'object' ? g[name] : null;
|
||||
return e && typeof e.path === 'string' && e.path ? e.path : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mode record from config.json (`modes[modeId]`), or null.
|
||||
* @param {string} modeId e.g. 'mode_multi_hard'
|
||||
*/
|
||||
export function getMode(modeId) {
|
||||
const cfg = getConfig();
|
||||
const modes = cfg && typeof cfg === 'object' ? (cfg.modes || cfg.modeDataDict) : null;
|
||||
if (!modes || typeof modes !== 'object') return null;
|
||||
if (Array.isArray(modes)) return modes.find((m) => m && (m.modeId === modeId || m.id === modeId)) || null;
|
||||
return modes[modeId] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preact hook: start loading the given files and re-render when any of them settles.
|
||||
* @param {...string} names
|
||||
* @returns {boolean} true once every file is settled (ready or missing)
|
||||
*/
|
||||
export function useData(...names) {
|
||||
const [, force] = useReducer((c) => c + 1, 0);
|
||||
const key = names.join('|');
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const unsub = data.subscribe((n) => { if (alive && names.includes(n)) force(); });
|
||||
for (const n of names) data.load(n);
|
||||
return () => { alive = false; unsub(); };
|
||||
}, [key]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
return names.every((n) => {
|
||||
const s = data.status(n);
|
||||
return s === 'ready' || s === 'missing';
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user