Files
ark-stronghold-protocol/public/js/assets.js
T
sganggs 01a44cd288 卫戍协议:盟约 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).
2026-09-29 16:21:31 +08:00

619 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// assets.js — asset manifest (/data/assets.json, docs/ASSETS.md) URL helpers, image cache/preload, and the
// Spine loader (LRU + refcount + memory budget + timeout + concurrency cap) used by the battlefield renderer.
//
// Pure at import time: no PIXI, no DOM access until a loader actually runs (Node tests import this file).
//
// import { assets } from './assets.js'; // browser singleton (manifest fetched on first use)
// await assets.ready(); // manifest loaded (or failed → every helper returns null)
// assets.avatar('char_002_amiya', { e2: true }) // URL or null
// const data = await assets.spine.acquire(entry); // spineData (PIXI.spine) — throws on failure/timeout
// assets.spine.release(entry);
// await assets.local(); // optional local-client art manifest (data/local-assets.json,
// assets.localUrl('map/autochess', 'TX_autochessi_D') DESIGN §13) → URL or null (never required)
//
// Every URL helper is also exported as a pure function taking the manifest first (`avatarUrl(manifest, …)`),
// so it can be unit tested without a browser. Helpers never throw on unknown ids — they return null and the
// caller falls back (docs/ASSETS.md "Other fallbacks").
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
const str = (v) => (typeof v === 'string' && v ? v : null);
const get = (o, k) => (isObj(o) && Object.hasOwn(o, k) ? o[k] : undefined);
// ---- pure URL helpers (manifest first) ---------------------------------------------------------------------
/** Strip a trailing `_1` / `_2` art-phase suffix (data/chess.json asset ids). */
export function baseCharId(id) {
const s = str(id);
if (!s) return null;
return s.replace(/_(1|2)$/, '');
}
/**
* Operator avatar. Accepts a charId (`char_002_amiya`) or an asset id with the E2 suffix (`char_002_amiya_2`).
* `opts.e2` prefers the E2 art. Falls back to the base avatar, then null.
*/
export function avatarUrl(m, id, opts) {
const s = str(id);
if (!s) return null;
const direct = get(get(m, 'chars'), s);
let rec = direct, e2 = !!(opts && opts.e2);
if (!rec) {
const base = baseCharId(s);
rec = get(get(m, 'chars'), base);
if (rec && /_2$/.test(s)) e2 = true;
}
if (rec) return str(e2 && rec.avatarE2) || str(rec.avatar) || null;
return null;
}
/** Operator half-body portrait (`_1` base, `_2` E2). */
export function portraitUrl(m, id, opts) {
const s = str(id);
if (!s) return null;
let rec = get(get(m, 'chars'), s), e2 = !!(opts && opts.e2);
if (!rec) {
rec = get(get(m, 'chars'), baseCharId(s));
if (rec && /_2$/.test(s)) e2 = true;
}
if (!rec) return null;
return str(e2 && rec.portraitE2) || str(rec.portrait) || null;
}
export function enemyIconUrl(m, enemyId) {
const rec = get(get(m, 'enemies'), str(enemyId) || '');
return str(rec && rec.icon);
}
/** Token avatar, falling back to its owner's avatar (docs/ASSETS.md token fallbacks). */
export function tokenAvatarUrl(m, tokenId) {
const rec = get(get(m, 'tokens'), str(tokenId) || '');
if (!rec) return null;
return str(rec.avatar) || (rec.owner ? avatarUrl(m, rec.owner) : null) || str(get(get(get(m, 'prof'), 'battlecard'), 'token'));
}
export const bondIconUrl = (m, bondId) => str(get(get(m, 'bonds'), str(bondId) || ''));
export const bandIconUrl = (m, bandId) => str(get(get(m, 'bands'), str(bandId) || ''));
/** Item icon by trapId (`trap_1041_acarm041`) or item record (`{ trapId | iconId }`). */
export function itemIconUrl(m, item) {
const id = isObj(item) ? (str(item.iconId) || str(item.trapId)) : str(item);
return id ? str(get(get(m, 'items'), id)) : null;
}
/** Skill icon by iconId, or by skillId via `skillsById`. Falls back to the empty skill icon. */
export function skillIconUrl(m, id, opts) {
const fallback = !(opts && opts.fallback === false);
const s = str(id);
const skills = get(m, 'skills');
let url = s ? str(get(skills, s)) : null;
if (!url && s) {
const icon = str(get(get(m, 'skillsById'), s));
if (icon) url = str(get(skills, icon));
}
return url || (fallback ? uiUrl(m, 'skillIcon/empty') : null);
}
/** UI sprite by `group/key` (e.g. 'battle/sprite_shadow'). */
export const uiUrl = (m, name) => str(get(get(m, 'ui'), str(name) || ''));
/** Profession icon: kind 'icon' | 'large' | 'battlecard'; profession in any case (SNIPER → sniper). */
export function profIconUrl(m, profession, kind = 'icon') {
const p = str(profession);
if (!p) return null;
return str(get(get(get(m, 'prof'), kind), p.toLowerCase()));
}
/** Sub-profession icon by subProfessionId or `sub_<id>_icon` asset id. */
export function subProfIconUrl(m, sub) {
const s = str(sub);
if (!s) return null;
const id = s.replace(/^sub_/, '').replace(/_icon$/, '');
return str(get(get(get(m, 'prof'), 'sub'), id));
}
/**
* Spine manifest entry for a unit asset id (operator charId, token id, enemy id). `opts.back` asks for the Back
* model (operators only; falls back to Front). Enemy aliases are resolved transparently. Returns the Spine
* object of docs/ASSETS.md or null.
*/
export function spineEntry(m, id, opts) {
const s = str(id);
if (!s) return null;
const ch = get(get(m, 'chars'), s) || get(get(m, 'chars'), baseCharId(s));
if (ch && isObj(ch.spine)) {
const sp = (opts && opts.back && isObj(ch.spine.back)) ? ch.spine.back : ch.spine.front;
return validSpine(sp) ? sp : null;
}
const tk = get(get(m, 'tokens'), s);
if (tk) return validSpine(tk.spine) ? tk.spine : null;
const en = get(get(m, 'enemies'), s);
if (en) {
if (validSpine(en.spine)) return en.spine;
const alias = str(en.spineAliasOf);
const al = alias ? get(get(m, 'enemies'), alias) : null;
return al && validSpine(al.spine) ? al.spine : null;
}
return null;
}
/** Whether an operator/token/enemy has a Back model. */
export function hasBackSpine(m, id) {
const ch = get(get(m, 'chars'), str(id) || '') || get(get(m, 'chars'), baseCharId(id) || '');
return !!(ch && isObj(ch.spine) && validSpine(ch.spine.back));
}
export function validSpine(sp) {
return isObj(sp) && typeof sp.skel === 'string' && /^\/[^\s]*\.skel$/.test(sp.skel) && typeof sp.atlas === 'string' && isObj(sp.anims);
}
/** Best 2D picture for a unit asset id (operator avatar, token avatar, enemy icon, item icon). */
export function unitPictureUrl(m, id) {
return avatarUrl(m, id) || tokenAvatarUrl(m, id) || enemyIconUrl(m, id) || itemIconUrl(m, id) || null;
}
// ---- audio ---------------------------------------------------------------------------------------------------
/** BGM `{ intro?, loop }` for 'lobby' | 'prep' | 'combat' | 'boss', or a boss id. */
export function bgmEntry(m, kind) {
const a = get(m, 'audio');
const b = get(get(a, 'bossBgm'), str(kind) || '') || get(get(a, 'bgm'), str(kind) || '');
return isObj(b) && str(b.loop) ? b : null;
}
/** SFX URL: group 'ui' | 'battle', key e.g. 'buy'. */
export const sfxUrl = (m, group, key) => str(get(get(get(get(m, 'audio'), 'sfx'), str(group) || ''), str(key) || ''));
/** Per-unit SFX: kind 'attack' | 'hit' | 'skill' | 'die' | 'born'; `skillIndex` picks `skills[i]` when present. */
export function unitSfxUrl(m, id, kind, skillIndex) {
const u = get(get(get(get(m, 'audio'), 'sfx'), 'units'), str(id) || '') || get(get(get(get(m, 'audio'), 'sfx'), 'units'), baseCharId(id) || '');
if (!isObj(u)) return null;
if (kind === 'skill' && Number.isInteger(skillIndex) && isObj(u.skills)) {
const s = str(u.skills[skillIndex]);
if (s) return s;
}
return str(u[kind]);
}
// ---- generic caches ----------------------------------------------------------------------------------------
/**
* LRU cache of refcounted async resources. `load(key, arg)` → Promise<value>; `unload(key, value, record)` frees it
* (`record` is the cache record the value belonged to).
* Entries with refs > 0 are never evicted; the cache may exceed `max` while everything is in use.
* Failures are remembered for `failTtl` ms (so a missing model is not refetched every frame) and rethrown.
* Memory budget (optional): `weigh(key, value, arg)` → cost of a ready value; idle (refs 0) ready entries are evicted,
* least recently used first, while their total weight exceeds `maxIdleWeight` — or `quietWeight` while nothing at all
* is referenced (no scene on screen) — once they have been idle for `idleGrace` ms (a scene switch releases and
* re-acquires its models within that window, so it never reloads them); a timer sweeps what the grace held back.
*/
export class RefLru {
constructor({ load, unload = () => {}, max = 60, timeout = 20000, concurrency = 6, failTtl = 60000, now = () => Date.now(),
weigh = null, maxIdleWeight = Infinity, quietWeight, idleGrace = 0, timers } = {}) {
if (typeof load !== 'function') throw new TypeError('RefLru: load required');
this._load = load;
this._unload = unload;
this.max = Math.max(1, max | 0);
this.timeout = timeout;
this.concurrency = Math.max(1, concurrency | 0);
this.failTtl = failTtl;
this.now = now;
this._weigh = typeof weigh === 'function' ? weigh : null;
const budget = (v, d) => (typeof v === 'number' && v >= 0 ? v : d);
this.maxIdleWeight = budget(maxIdleWeight, Infinity);
this.quietWeight = budget(quietWeight, this.maxIdleWeight);
this.idleGrace = budget(idleGrace, 0);
this._timers = timers && typeof timers.set === 'function' ? timers : {
set: (fn, ms) => { const t = setTimeout(fn, ms); t?.unref?.(); return t; },
clear: (t) => clearTimeout(t),
};
this._sweep = null; // { timer, at } pending weight sweep
/** @type {Map<string, { key, promise, value, state: 'loading'|'ready'|'failed', refs: number, used: number, weight: number, idleSince: number|null, error?, failedAt? }>} */
this.map = new Map();
this._active = 0;
this._queue = [];
this._tick = 0;
}
/** Synchronous peek: the value if ready (does not change refs). */
peek(key) {
const e = this.map.get(key);
return e && e.state === 'ready' ? e.value : null;
}
has(key) { return this.map.has(key); }
get size() { return this.map.size; }
/** Acquire a reference; resolves to the value (or rejects). Always pair with release(key). */
acquire(key, arg) {
let e = this.map.get(key);
if (e && e.state === 'failed' && this.now() - e.failedAt > this.failTtl) { this.map.delete(key); e = null; }
if (!e) {
e = { key, promise: null, value: null, state: 'loading', refs: 0, used: ++this._tick, weight: 0, idleSince: null };
this.map.set(key, e);
e.promise = this._schedule(() => this._withTimeout(this._load(key, arg), key)).then(
(v) => {
if (this.map.get(key) !== e) { try { this._unload(key, v, e); } catch { /* ignore */ } return v; }
e.value = v; e.state = 'ready';
if (this._weigh) { let w = 0; try { w = Number(this._weigh(key, v, arg)); } catch { /* ignore */ } e.weight = w > 0 ? w : 0; }
this._evict();
return v;
},
(err) => {
e.state = 'failed'; e.error = err; e.failedAt = this.now();
throw err;
},
);
e.promise.catch(() => {});
}
e.refs++;
e.idleSince = null;
e.used = ++this._tick;
return e.promise;
}
/** Drop one reference (never below 0). */
release(key) {
const e = this.map.get(key);
if (!e) return;
if (e.refs > 0 && --e.refs === 0) e.idleSince = this.now();
e.used = ++this._tick;
this._evict();
}
/**
* Evict least-recently-used unreferenced ready/failed entries above `max`, then idle ready entries past their
* grace while the idle weight is over budget (`maxIdleWeight`, or `quietWeight` when nothing is referenced).
*/
_evict() {
if (this.map.size > this.max) {
const idle = [...this.map.values()].filter((e) => e.refs === 0 && e.state !== 'loading').sort((a, b) => a.used - b.used);
for (const e of idle) {
if (this.map.size <= this.max) break;
this._drop(e);
}
}
if (!this._weigh) return;
let refs = 0, idleWeight = 0;
const idle = [];
for (const e of this.map.values()) {
refs += e.refs;
if (e.refs === 0 && e.state === 'ready') { idleWeight += e.weight; idle.push(e); }
}
const budget = refs === 0 ? this.quietWeight : this.maxIdleWeight;
if (idleWeight <= budget) return;
idle.sort((a, b) => a.used - b.used);
const now = this.now();
let wait = Infinity;
for (const e of idle) {
if (idleWeight <= budget) break;
const age = now - (e.idleSince ?? now);
if (age < this.idleGrace) { wait = Math.min(wait, this.idleGrace - age); continue; }
idleWeight -= e.weight;
this._drop(e);
}
if (idleWeight > budget && wait < Infinity) this._sweepIn(wait);
}
_drop(e) {
this.map.delete(e.key);
if (e.state === 'ready') { try { this._unload(e.key, e.value, e); } catch { /* ignore */ } }
}
/** Re-run eviction in `ms` (keeps the earliest pending sweep). */
_sweepIn(ms) {
const at = this.now() + ms;
if (this._sweep && this._sweep.at <= at) return;
if (this._sweep) this._timers.clear(this._sweep.timer);
const timer = this._timers.set(() => { this._sweep = null; this._evict(); }, Math.ceil(ms) + 5);
this._sweep = { timer, at };
}
/** Unload everything (refs ignored). */
clear() {
if (this._sweep) { this._timers.clear(this._sweep.timer); this._sweep = null; }
const all = [...this.map.values()];
this.map.clear();
for (const e of all) if (e.state === 'ready') { try { this._unload(e.key, e.value, e); } catch { /* ignore */ } }
}
stats() {
let ready = 0, loading = 0, failed = 0, refs = 0, weight = 0, idleWeight = 0;
for (const e of this.map.values()) {
if (e.state === 'ready') ready++; else if (e.state === 'loading') loading++; else failed++;
refs += e.refs;
weight += e.weight || 0;
if (e.refs === 0) idleWeight += e.weight || 0;
}
return { size: this.map.size, ready, loading, failed, refs, weight, idleWeight, active: this._active, queued: this._queue.length };
}
_schedule(fn) {
return new Promise((resolve, reject) => {
const run = () => {
this._active++;
let p;
try { p = Promise.resolve(fn()); } catch (err) { p = Promise.reject(err); }
p.then(resolve, reject).finally(() => {
this._active--;
const next = this._queue.shift();
if (next) next();
});
};
if (this._active < this.concurrency) run(); else this._queue.push(run);
});
}
_withTimeout(p, key) {
if (!(this.timeout > 0)) return p;
return new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error(`load timeout: ${key}`)), this.timeout);
p.then((v) => { clearTimeout(t); resolve(v); }, (err) => { clearTimeout(t); reject(err); });
});
}
}
/** URL of a local-client art entry from a data/local-assets.json manifest (`{ groups: { g: { name: { path } } } }`). */
export function localAssetUrl(m, group, name) {
const e = get(get(get(m, 'groups'), str(group) || ''), str(name) || '');
return isObj(e) ? str(e.path) : null;
}
/** Default browser image loader. */
export function loadImageElement(url) {
return new Promise((resolve, reject) => {
if (typeof Image === 'undefined') { reject(new Error('no Image in this environment')); return; }
const img = new Image();
img.decoding = 'async';
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`image failed: ${url}`));
img.src = url;
});
}
/** Default Spine loader: PIXI.Assets.load(skel) → spineData (needs globalThis.PIXI + PIXI.spine). */
export async function loadSpineData(entry) {
const PIXI = globalThis.PIXI;
if (!PIXI || !PIXI.Assets || !PIXI.spine) throw new Error('PIXI / pixi-spine not loaded');
const res = await PIXI.Assets.load(entry.skel);
const data = res && (res.spineData || res);
if (!data || !Array.isArray(data.animations)) throw new Error(`bad spine data: ${entry.skel}`);
return data;
}
/**
* Estimated memory (bytes) held by a parsed skeleton (pixi-spine 3.8 SkeletonData): its typed/plain arrays (mesh
* vertices/UVs/triangles, timeline frames/curves, deform frames, draw orders) plus a flat cost per object. A parsed
* .skel costs ~10–15× its file size (0.1–1.4 MB files → ~1–12 MB of heap + backing store), mostly timelines, so a
* count cap alone pins anywhere from tens to hundreds of MB; the spine LRU budgets idle skeletons by this weight.
* Never throws; unknown shapes weigh `SPINE_WEIGHT_MIN`.
*/
export const SPINE_WEIGHT_MIN = 64 * 1024;
export function spineDataWeight(data) {
if (!data || typeof data !== 'object') return SPINE_WEIGHT_MIN;
const OBJ = 96, NUM = 8;
const list = (a) => (Array.isArray(a) ? a : []);
const arr = (a) => (!a || typeof a !== 'object' ? 0 : ArrayBuffer.isView(a) ? a.byteLength + OBJ
: Array.isArray(a) ? a.length * NUM + OBJ : 0);
let w = 0;
try {
for (const k of ['bones', 'slots', 'events', 'ikConstraints', 'transformConstraints', 'pathConstraints']) w += list(data[k]).length * OBJ * 2;
for (const skin of list(data.skins)) {
for (const slot of list(skin && skin.attachments)) {
if (!slot || typeof slot !== 'object') continue;
for (const at of Object.values(slot)) {
w += OBJ * 2;
if (at && typeof at === 'object') for (const f of ['vertices', 'regionUVs', 'uvs', 'triangles', 'bones', 'edges', 'lengths', 'offset']) w += arr(at[f]);
}
}
}
for (const anim of list(data.animations)) {
w += OBJ;
for (const t of list(anim && anim.timelines)) {
if (!t || typeof t !== 'object') continue;
w += OBJ + arr(t.frames) + arr(t.curves) + arr(t.attachmentNames) + arr(t.events);
for (const f of list(t.frameVertices)) w += arr(f);
for (const d of list(t.drawOrders)) w += arr(d);
}
}
} catch { /* exotic shape: whatever was counted */ }
return Math.max(SPINE_WEIGHT_MIN, w);
}
/** Atlas page image URLs of a Spine manifest entry (`textures`, else the `<skel name>.png` next to it). */
export function spinePages(entry) {
if (!isObj(entry)) return [];
const list = Array.isArray(entry.textures) ? entry.textures.filter((u) => str(u)) : [];
if (list.length) return list;
const skel = str(entry.skel);
return skel ? [skel.replace(/\.skel$/, '.png')] : [];
}
/**
* Free a skeleton evicted from the LRU: the skeleton asset, its .atlas asset (pixi-spine loads the atlas as a
* separate cached asset, whose unload disposes the page textures on the GPU) AND the atlas page images: the atlas
* parser loads each page through the loader (`loader.load({ src })`), so the decoded page (an ImageBitmap) stays in
* `PIXI.Assets.loader.promiseCache` until that URL is unloaded too. Pages listed in `keep` (still used by another
* cached skeleton) are left alone.
* @param {object} entry manifest spine entry { skel, atlas?, textures? }
* @param {any} [_value] the unloaded spineData (unused)
* @param {Set<string>} [keep] page URLs not to unload
*/
export function unloadSpineData(entry, _value, keep) {
const PIXI = globalThis.PIXI;
const skel = entry && entry.skel;
if (!skel || !PIXI?.Assets?.unload) return;
const atlas = (typeof entry.atlas === 'string' && entry.atlas) || skel.replace(/\.skel$/, '.atlas');
const loader = PIXI.Assets.loader;
const unload = (url, direct) => {
try {
const p = direct && loader && typeof loader.unload === 'function' ? loader.unload(url) : PIXI.Assets.unload(url);
Promise.resolve(p).catch(() => {});
} catch { /* ignore */ }
};
unload(skel, false);
unload(atlas, false);
// pages were loaded by the atlas parser straight through the loader (never through Assets.load / the resolver)
for (const url of spinePages(entry)) if (!(keep && keep.has(url))) unload(url, true);
}
// ---- store ---------------------------------------------------------------------------------------------------
/** Spine LRU memory budget: estimated bytes (`spineDataWeight`) of idle skeletons kept while a scene is shown. */
export const SPINE_IDLE_BYTES = 48 * 1024 * 1024;
/** How long a released skeleton is safe from the weight budget (a prep ⇄ battle switch re-acquires within it). */
export const SPINE_IDLE_GRACE_MS = 15000;
/**
* Create an asset store.
* @param {{ url?: string, fetch?: typeof fetch, manifest?: object, loadImage?: (url) => Promise<any>,
* loadSpine?: (entry) => Promise<any>, unloadSpine?: (entry, value, keepPages:Set<string>) => void, spineMax?: number,
* spineTimeout?: number, spineWeigh?: (key, spineData) => number, spineIdleBytes?: number,
* spineQuietBytes?: number, spineIdleGrace?: number, spineTimers?: { set, clear }, spineNow?: () => number }} [opts]
*/
export function createAssets(options) {
const opts = options && typeof options === 'object' ? options : {};
const url = opts.url || '/data/assets.json';
const localUrl = opts.localUrl || '/data/local-assets.json';
let localPromise = isObj(opts.localManifest) ? Promise.resolve(opts.localManifest) : null;
let localManifest = isObj(opts.localManifest) ? opts.localManifest : null;
const doFetch = opts.fetch || ((...a) => globalThis.fetch(...a));
let manifest = isObj(opts.manifest) ? opts.manifest : null;
let readyPromise = manifest ? Promise.resolve(manifest) : null;
const loadImage = opts.loadImage || loadImageElement;
const images = new Map(); // url → { promise, value }
function ready() {
if (!readyPromise) {
readyPromise = (async () => {
try {
const res = await doFetch(url, { cache: 'no-cache' });
if (!res || !res.ok) throw new Error(`HTTP ${res ? res.status : '???'}`);
const json = await res.json();
manifest = isObj(json) ? json : {};
} catch (err) {
console.warn(`[assets] ${url} unavailable (${err?.message || err}); using fallbacks`);
manifest = {};
}
return manifest;
})();
}
return readyPromise;
}
const spineEntries = new Map(); // skel URL → manifest entry (for the atlas / page URLs on unload)
const spine = new RefLru({
load: (key, entry) => { spineEntries.set(key, entry); return (opts.loadSpine || loadSpineData)(entry); },
unload: (key, value, rec) => {
// a late value of an entry that was dropped while loading, with the key already loading/cached again: the
// PIXI caches are shared by URL, so unloading would pull the resources out from under the new entry
const cur = spine.map.get(key);
if (cur && rec && cur !== rec) return;
const e = spineEntries.get(key) || { skel: key };
spineEntries.delete(key);
const keep = new Set();
for (const [k, other] of spineEntries) if (k !== key && spine.map.has(k)) for (const u of spinePages(other)) keep.add(u);
(opts.unloadSpine || unloadSpineData)(e, value, keep);
},
max: opts.spineMax ?? 60,
timeout: opts.spineTimeout ?? 20000,
concurrency: opts.spineConcurrency ?? 6,
// memory budget (a parsed skeleton holds ~0.1–12 MB): idle skeletons beyond SPINE_IDLE_BYTES go, and all of
// them once no scene references any (lobby / room), each after a grace that covers a prep ⇄ battle switch
weigh: opts.spineWeigh || ((key, value) => spineDataWeight(value)),
maxIdleWeight: opts.spineIdleBytes ?? SPINE_IDLE_BYTES,
quietWeight: opts.spineQuietBytes ?? 0,
idleGrace: opts.spineIdleGrace ?? SPINE_IDLE_GRACE_MS,
timers: opts.spineTimers,
now: opts.spineNow,
});
/** Image element (cached; failures resolve to null). */
function image(u) {
const s = str(u);
if (!s) return Promise.resolve(null);
let e = images.get(s);
if (!e) {
e = { value: null, done: false, promise: null };
e.promise = Promise.resolve().then(() => loadImage(s)).then(
(img) => { e.value = img; e.done = true; return img; },
() => { e.done = true; return null; },
);
images.set(s, e);
}
return e.promise;
}
/** Optional local-client art manifest (null when absent: every consumer falls back). Fetched once. */
function local() {
if (!localPromise) {
localPromise = (async () => {
try {
const res = await doFetch(localUrl, { cache: 'no-cache' });
if (!res || !res.ok) return null;
const json = await res.json();
localManifest = isObj(json) && isObj(json.groups) ? json : null;
} catch { localManifest = null; }
return localManifest;
})();
}
return localPromise;
}
const m = () => manifest || {};
return {
ready,
local,
localUrl: (group, name) => localAssetUrl(localManifest, group, name),
get manifest() { return manifest; },
get loaded() { return !!manifest; },
avatar: (id, o) => avatarUrl(m(), id, o),
portrait: (id, o) => portraitUrl(m(), id, o),
enemyIcon: (id) => enemyIconUrl(m(), id),
tokenAvatar: (id) => tokenAvatarUrl(m(), id),
bondIcon: (id) => bondIconUrl(m(), id),
bandIcon: (id) => bandIconUrl(m(), id),
itemIcon: (item) => itemIconUrl(m(), item),
skillIcon: (id, o) => skillIconUrl(m(), id, o),
profIcon: (p, kind) => profIconUrl(m(), p, kind),
subProfIcon: (s) => subProfIconUrl(m(), s),
ui: (name) => uiUrl(m(), name),
picture: (id) => unitPictureUrl(m(), id),
spineEntry: (id, o) => spineEntry(m(), id, o),
hasBack: (id) => hasBackSpine(m(), id),
audio: {
bgm: (kind) => bgmEntry(m(), kind),
sfx: (group, key) => sfxUrl(m(), group, key),
unit: (id, kind, idx) => unitSfxUrl(m(), id, kind, idx),
},
/** Cached image element promise (null on failure). */
image,
/** Already-loaded image element or null (sync). */
imageNow(u) { const e = images.get(str(u) || ''); return e && e.done ? e.value : null; },
/**
* Preload URLs; `onProgress(done, total, url)` after each. Resolves to { ok, failed } counts (never rejects).
*/
async preload(urls, onProgress) {
const list = [...new Set((Array.isArray(urls) ? urls : []).map(str).filter(Boolean))];
let done = 0, ok = 0;
await Promise.all(list.map((u) => image(u).then((img) => {
done++;
if (img) ok++;
try { onProgress?.(done, list.length, u); } catch { /* ignore */ }
})));
return { ok, failed: list.length - ok, total: list.length };
},
/** Spine data LRU: acquire(entry) → Promise<spineData>; release(entry); peek(entry). */
spine: {
acquire: (entry) => (validSpine(entry) ? spine.acquire(entry.skel, entry) : Promise.reject(new Error('no spine entry'))),
release: (entry) => { if (entry && entry.skel) spine.release(entry.skel); },
peek: (entry) => (entry && entry.skel ? spine.peek(entry.skel) : null),
stats: () => spine.stats(),
clear: () => spine.clear(),
cache: spine,
},
};
}
/** Browser singleton. */
export const assets = createAssets();