// 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__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; `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} */ 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 `.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} [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, * loadSpine?: (entry) => Promise, unloadSpine?: (entry, value, keepPages:Set) => 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; 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();