Files
sganggs 6bfc08270c 0.2.4-dev: round-29 community fixes and the official shared card pool
- Shared card pool and item stock as in the official mode (the owner's request, from the 路标档案馆 video and public notes): solo low-tier exceptions, co-op operators x2, shared item stock with special-item exceptions, normal / advanced occupancy 1 / 2, re-check on buy, 机变 exhaustion, release on consume / destroy / elimination; 整备 never exceeds stock; invariant held <= cap.
- 突袭: one taunt-first reselect right after a revive (the owner's rule); ordinary polls and redeploys keep the 0.2.3 rule; a revive on an enemy's tile stays. #475: online boss / hidden-boss Raid may land on the empty half (owner-limited scope).
- 浊心斯卡蒂 S1 x 坚守 no longer bounce redirected damage, and the two shares conserve the hit (idea of PR #464 by @HappyTanuki).
- Operator fixes adapted from PRs by @Xinglan233 (#459 哈洛德 S2 elemental trigger, #460 涤火杰西卡 shield on the four adjacent tiles, #461 家族徽章 first hit after stealth, #462 松果 S2 regrow per deploy); 克莱门莎 S2 splash 1.1 / 100% ATK and air auto-trigger (#467); 白面 copying 歌蕾蒂娅 / 余 skips the three-member condition (#473, the owner's video).
- 香槟炸弹 is not chosen by friendly auras / heals (PR #468 / #476 by @Siornya); 琳琅诗怀雅 coin balance (PR #450 by @IceCodeNew); Touch prep display (PR #451 by @YinqiMargatroid); lobby header (PR #471 by @YuriRestia); Japanese UI terms (PR #448 by @scutkane).
- AI-only boss countdown grace, render-failure cache / recovery without restarting a battle, device units kept on a same-field re-entry, DIY detail / skill previews, large-text result buttons.
- golden: battle families equal 0.2.3 except diy-135 / diy-136 (克莱门莎); all 18 release matches move through the shared pool, every flip named in docs/history/0.2.4.md §29.12; two coverage scenarios added.
2026-10-11 01:58:52 +08:00

957 lines
47 KiB
JavaScript
Raw Permalink 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. A
// skeleton is never handed out while its unload is in flight (RefLru; user playtest #3 item 1: invisible models).
//
// Manifest (public issue #8 item 5: after a page reload every operator was drawn as the image-less placeholder): seeded
// from the game data's copy when there is one (`seed`, ui/fieldHost.js), else fetched by `ready()` with retries; a
// failure is never kept as an empty manifest (the next `ready()` and a bounded background backoff fetch again), and
// `onChange` listeners hear when it arrives late (render/app.js re-resolves its views' models then).
//
// Spine memory policy (phones have little memory; a parsed skeleton holds ~0.1–12 MB, `spineDataWeight`):
// * a skeleton a view references is never evicted;
// * idle (unreferenced) skeletons are kept up to SPINE_IDLE_BYTES while a scene is on screen, each safe for
// SPINE_IDLE_GRACE_MS after its release (a prep ⇄ battle switch takes its models back); beyond the budget the least
// recently used go, in one pass SPINE_EVICT_DELAY_MS after the release;
// * quiet — no scene on screen (lobby, room, result: nothing referenced and no scene holding the cache for
// SPINE_QUIET_DELAY_MS) — every idle skeleton goes after its grace: the memory goes back to the phone;
// * a match view in battle mode holds the cache (`spine.hold()`, render/app.js): its battle views are built in
// animation frames, which a hidden tab does not run, so a battle that began in a background tab referenced nothing
// and the quiet budget emptied the cache ≈ 18 s later — back in the tab every unit was an avatar diamond until its
// model downloaded again (public issue #8 item 5). The idle budget still applies while it holds.
// A model that fails or times out is retried by its view (bounded, render/units.js), `acquire(entry, { retry: true })`.
// A load that may never settle (GitHub #68: begun in a hidden tab, still pending when the tab came back) is started again
// by its view (`spine.restart(entry)`, RefLru.restart); such a fresh load — and any load after a failure — drops PIXI's
// still-pending requests of that skeleton first (forgetPendingSpine), so it does not join them.
//
// 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, or seeded)
// await assets.ready(); // manifest loaded (or failed for now → every helper returns null)
// assets.seed(data.get('assets')); // adopt an already loaded copy of /data/assets.json
// assets.onChange((what) => …); // 'manifest' | 'local' arrived (late)
// 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)
// assets.spineEntry('enemy_1305_mhslim') // its official model from the local client once local() listed
// // it (manifest spineLocal; enemies and tokens), else the web
// // model; `.fallback`
//
// 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.
* `opts.local` (the data/local-assets.json manifest): an enemy or token whose official model only the local client has
* (`spineLocal`, e.g. 灼热源石虫 — user feedback after 0.1.0, D3 — and most 自选 summons) gets that model when the
* manifest lists every one of its files; the returned entry's `fallback` is the web model (an enemy's alias; a token's
* web model, mostly none — its view then keeps the avatar diamond), for a load failure (DESIGN §13: local art is
* optional, everything works without it).
*/
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) {
const web = validSpine(tk.spine) ? tk.spine : null;
return (opts && opts.local && localSpineEntry(tk.spineLocal, opts.local, web)) || web;
}
const en = get(get(m, 'enemies'), s);
if (en) {
let web = null;
if (validSpine(en.spine)) web = en.spine;
else {
const alias = str(en.spineAliasOf);
const al = alias ? get(get(m, 'enemies'), alias) : null;
web = al && validSpine(al.spine) ? al.spine : null;
}
return (opts && opts.local && localSpineEntry(en.spineLocal, opts.local, web)) || web;
}
return null;
}
const localSpines = new WeakMap(); // spineLocal record → { local, web, entry } (one entry object per manifest pair)
/**
* A manifest `spineLocal` (`{ group, skel, atlas, textures[], pma, anims, … }`, file names in the data/local-assets.json
* group) as a Spine entry with URLs, when `local` lists every file — skeleton and atlas side by side (pixi-spine finds
* the atlas by the skeleton's name); else null. `fallback` = `web`.
*/
export function localSpineEntry(sl, local, web) {
if (!isObj(sl) || !isObj(local)) return null;
const c = localSpines.get(sl);
if (c && c.local === local && c.web === web) return c.entry;
const g = str(sl.group);
const url = (name) => (g ? localAssetUrl(local, g, name) : null);
const skel = url(sl.skel), atlas = url(sl.atlas);
const textures = Array.isArray(sl.textures) ? sl.textures.map(url) : [];
let entry = null;
if (skel && atlas && skel.replace(/\.skel$/, '.atlas') === atlas && textures.length && textures.every(Boolean)) {
entry = { skel, atlas, textures, pma: !!sl.pma, anims: sl.anims, animations: sl.animations, events: sl.events, hits: sl.hits, bounds: sl.bounds, local: true, fallback: web || null };
if (!validSpine(entry)) entry = null;
}
localSpines.set(sl, { local, web, entry });
return entry;
}
/** 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]` and — for an attack or
* an impact — `attacks[i]` / `hits[i]` (the running skill's own files, tools/assets/audio.mjs pickModeAttacks /
* pickModeHits) when the manifest has them.
*/
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;
}
const per = kind === 'attack' ? u.attacks : kind === 'hit' ? u.hits : null;
if (Number.isInteger(skillIndex) && isObj(per)) {
const a = str(per[skillIndex]);
if (a) return a;
}
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) and may return a promise that settles once it is freed.
* 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 — except for
* `acquire(key, arg, { retry: true })`, which drops a remembered failure nobody references and loads again (a view
* retrying a failed or timed-out model: render/units.js). `restart(key, arg)` starts a load still in flight again (GitHub
* #68: one begun in a hidden tab may never settle): a new attempt of the same entry — its refs and the promise its
* holders wait on stay —, the earlier attempt is abandoned (its concurrency slot freed, a late result ignored: PIXI shares
* its resources by URL, so nothing is unloaded), the first attempt to succeed settles the entry and only the newest
* attempt's failure fails it. A load after a failure and a restart are `fresh` (`load(key, arg, { fresh: true })`).
* 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` once nothing at all
* has been referenced and no scene holds the cache (`hold()`) for `quietDelay` ms (no scene on screen; the instant zero
* in the middle of a scene switch is not quiet) — 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.
* `hold()` → release function: a scene that will reference its values again although it references none right now
* (render/app.js in battle mode: the battle's views are built in animation frames, which a hidden tab does not run)
* keeps the quiet budget off while it holds; the `maxIdleWeight` budget still applies.
* `evictDelay` > 0: a release (or a finished load) schedules the eviction pass that much later instead of running it
* at once, so a scene rebuilt in one go (every view destroyed, then the next scene's views built) never drops a value
* it takes again.
* A value whose unload is still in flight is never handed out again: a new load of that key waits for the unload to
* settle (user playtest #3 item 1 — PIXI.Assets keeps an unloading skeleton in its loader cache until a microtask
* later, so loading the same URL meanwhile returned the doomed skeleton, whose textures the unload then destroyed:
* invisible operator models).
*/
export class RefLru {
constructor({ load, unload = () => {}, max = 60, timeout = 20000, concurrency = 6, failTtl = 60000, now = () => Date.now(),
weigh = null, maxIdleWeight = Infinity, quietWeight, idleGrace = 0, quietDelay = 0, evictDelay = 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.quietDelay = budget(quietDelay, 0);
this.evictDelay = budget(evictDelay, 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 eviction pass (weight sweep / deferred eviction)
/** @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._refs = 0; // Σ refs of every entry
this._holds = 0; // scenes holding the cache (hold()): never quiet meanwhile
this._quietAt = this.now(); // since when nothing has been referenced nor held (null while something is)
/** @type {Map<string, Promise<void>>} key → unload still in flight (settles, never rejects) */
this._unloading = 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). `opts.retry`: a remembered
* failure that nobody references any more is dropped and the value loaded again (a failure still referenced — a
* caller that has not released it yet — is shared: dropping it would make that release hit the new entry).
*/
acquire(key, arg, opts) {
let e = this.map.get(key);
let fresh = false;
if (e && e.state === 'failed' && (this.now() - e.failedAt > this.failTtl || (opts && opts.retry && e.refs === 0))) { this.map.delete(key); e = null; fresh = true; }
if (!e) {
e = { key, promise: null, value: null, state: 'loading', refs: 0, used: ++this._tick, weight: 0, idleSince: null, attempt: 0, abort: null, settle: null };
this.map.set(key, e);
e.promise = new Promise((resolve, reject) => { e.settle = { resolve, reject }; });
e.promise.catch(() => {});
this._attempt(e, key, arg, fresh);
}
e.refs++;
this._refs++;
this._quietAt = null;
e.idleSince = null;
e.used = ++this._tick;
return e.promise;
}
/**
* Start the load of `key` again while it is still in flight (see the header); false when it is not loading (ready,
* failed or unknown).
*/
restart(key, arg) {
const e = this.map.get(key);
if (!e || e.state !== 'loading' || !e.settle) return false;
this._attempt(e, key, arg, true);
return true;
}
/** One load attempt of entry `e` (see the header): the first success settles it, only the newest attempt's failure. */
_attempt(e, key, arg, fresh) {
const n = ++e.attempt;
if (e.abort) { const abort = e.abort; e.abort = null; abort(); }
// an unload of this key still in flight: load again only once it has settled (never the doomed value)
const load = () => {
const pending = this._unloading.get(key);
const go = () => this._load(key, arg, fresh ? { fresh: true } : undefined);
return pending ? pending.then(go) : go();
};
const run = () => (n === e.attempt
? this._withTimeout(load(), key, (abort) => { if (n === e.attempt) e.abort = abort; })
: Promise.reject(new Error(`load superseded: ${key}`)));
this._schedule(run).then(
(v) => {
const s = e.settle;
if (!s) return; // settled by another attempt: a late duplicate (PIXI shares its resources by URL — keep them)
e.settle = null; e.abort = null;
if (this.map.get(key) !== e) { this._unloadNow(key, v, e); s.resolve(v); return; }
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._requestEvict();
s.resolve(v);
},
(err) => {
const s = e.settle;
if (!s || n !== e.attempt) return; // an abandoned attempt: the newest one decides
e.settle = null; e.abort = null;
e.state = 'failed'; e.error = err; e.failedAt = this.now();
s.reject(err);
},
);
}
/** Drop one reference (never below 0). */
release(key) {
const e = this.map.get(key);
if (!e) return;
if (e.refs > 0) {
if (--e.refs === 0) e.idleSince = this.now();
if (--this._refs <= 0) { this._refs = 0; if (this._holds === 0) this._quietAt = this.now(); }
}
e.used = ++this._tick;
this._requestEvict();
}
/**
* Hold the cache for a scene that will reference its values again (see the header): no quiet budget until the
* returned function is called (once; later calls do nothing). The quiet delay then counts from the release.
* @returns {() => void}
*/
hold() {
this._holds++;
this._quietAt = null;
let held = true;
return () => {
if (!held) return;
held = false;
if (--this._holds <= 0) { this._holds = 0; if (this._refs === 0) this._quietAt = this.now(); }
this._requestEvict();
};
}
/** Is an unload of `key` still in flight (a new load of it waits for it)? */
unloading(key) { return this._unloading.has(key); }
/** Evict now, or (evictDelay > 0) in one pass a moment later. */
_requestEvict() {
if (this.evictDelay > 0) this._sweepIn(this.evictDelay);
else 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` once nothing has been referenced
* for `quietDelay` ms).
*/
_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 idleWeight = 0;
const idle = [];
for (const e of this.map.values()) {
if (e.refs === 0 && e.state === 'ready') { idleWeight += e.weight; idle.push(e); }
}
const now = this.now();
let budget = this.maxIdleWeight;
let wait = Infinity;
if (this._refs === 0 && this._holds === 0) {
const quietFor = now - (this._quietAt ?? now);
if (quietFor >= this.quietDelay) budget = this.quietWeight;
else if (idleWeight > this.quietWeight) wait = this.quietDelay - quietFor; // re-check once the quiet has lasted
}
if (idleWeight > budget) {
idle.sort((a, b) => a.used - b.used);
let graceWait = Infinity;
for (const e of idle) {
if (idleWeight <= budget) break;
const age = now - (e.idleSince ?? now);
if (age < this.idleGrace) { graceWait = Math.min(graceWait, this.idleGrace - age); continue; }
idleWeight -= e.weight;
this._drop(e);
}
if (idleWeight > budget) wait = Math.min(wait, graceWait);
}
if (wait < Infinity) this._sweepIn(wait);
}
_drop(e) {
this.map.delete(e.key);
if (e.state === 'ready') this._unloadNow(e.key, e.value, e);
}
/** Run the unload of a value; while a returned promise is pending, loads of that key wait (see the header). */
_unloadNow(key, value, rec) {
let p = null;
try { p = this._unload(key, value, rec); } catch { p = null; }
if (!p || typeof p.then !== 'function') return;
const done = Promise.resolve(p).then(() => {}, () => {}).then(() => { if (this._unloading.get(key) === done) this._unloading.delete(key); });
this._unloading.set(key, done);
}
/** Re-run eviction in `ms` (keeps the earliest pending pass). */
_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; a scene's hold stays). */
clear() {
if (this._sweep) { this._timers.clear(this._sweep.timer); this._sweep = null; }
const all = [...this.map.values()];
this.map.clear();
this._refs = 0;
this._quietAt = this._holds > 0 ? null : this.now();
for (const e of all) if (e.state === 'ready') this._unloadNow(e.key, e.value, e);
}
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, holds: this._holds, weight, idleWeight, active: this._active, queued: this._queue.length, unloading: this._unloading.size };
}
_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);
});
}
/** `p` bounded by the timeout; `onAbort(abort)` receives a function that rejects it at once (a restart). */
_withTimeout(p, key, onAbort) {
if (!(this.timeout > 0) && !onAbort) return p;
return new Promise((resolve, reject) => {
const t = this.timeout > 0 ? setTimeout(() => reject(new Error(`load timeout: ${key}`)), this.timeout) : null;
const stop = () => { if (t !== null) clearTimeout(t); };
if (onAbort) onAbort(() => { stop(); reject(new Error(`load restarted: ${key}`)); });
p.then((v) => { stop(); resolve(v); }, (err) => { stop(); 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.crossOrigin = 'anonymous';
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, opts) {
const PIXI = globalThis.PIXI;
if (!PIXI || !PIXI.Assets || !PIXI.spine) throw new Error('PIXI / pixi-spine not loaded');
if (opts && opts.fresh) forgetPendingSpine(entry, opts.keep);
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;
}
/**
* Forget PIXI's requests of a skeleton that never finished loading — its .skel is not in `PIXI.Assets.cache` — so a fresh
* load asks for them again instead of joining them (GitHub #68: a request begun in a hidden tab may never settle, and
* `PIXI.Assets.load` of the same URL hands out the same pending promise): the .skel's and, unless the atlas finished
* (then its pages did too), the .atlas's and its pages' entries of the loader's promise cache. Pages in `keep` (another
* cached skeleton's) stay. Returns how many were dropped; best effort (0 without that cache).
* @param {object} entry manifest spine entry { skel, atlas?, textures? }
* @param {Set<string>} [keep]
*/
export function forgetPendingSpine(entry, keep) {
const PIXI = globalThis.PIXI;
const cache = PIXI && PIXI.Assets && PIXI.Assets.loader && PIXI.Assets.loader.promiseCache;
const skel = entry && entry.skel;
if (!skel || !cache || typeof cache !== 'object') return 0;
const done = (u) => { try { return !!PIXI.Assets.cache?.has?.(u); } catch { return true; } };
if (done(skel)) return 0;
const atlas = (typeof entry.atlas === 'string' && entry.atlas) || skel.replace(/\.skel$/, '.atlas');
const urls = (done(atlas) ? [skel] : [skel, atlas, ...spinePages(entry)]).filter((u) => !(keep && keep.has(u)));
let n = 0;
// the loader keys its cache by the resolved URL: the manifest path itself, or that path made absolute
for (const key of Object.keys(cache)) if (urls.some((u) => key === u || (u.startsWith('/') && key.endsWith(u)))) { delete cache[key]; n++; }
return n;
}
/**
* 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.
* The unloads are asynchronous (PIXI frees an asset a microtask after the call, and until then `PIXI.Assets.load` of
* the same URL hands out that doomed asset): the returned promise settles (never rejects) once every one has run, and
* the spine LRU makes a new load of the skeleton wait for it (RefLru header).
* @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
* @returns {Promise<void>|undefined}
*/
export function unloadSpineData(entry, _value, keep) {
const PIXI = globalThis.PIXI;
const skel = entry && entry.skel;
if (!skel || !PIXI?.Assets?.unload) return undefined;
const atlas = (typeof entry.atlas === 'string' && entry.atlas) || skel.replace(/\.skel$/, '.atlas');
const loader = PIXI.Assets.loader;
const pending = [];
const unload = (url, direct) => {
try {
const p = direct && loader && typeof loader.unload === 'function' ? loader.unload(url) : PIXI.Assets.unload(url);
pending.push(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);
return Promise.all(pending).then(() => {});
}
// ---- 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;
/**
* Spine LRU eviction runs this long after a release, in one pass: a scene switch (battle → prep destroys every battle
* view, then builds the prep views; prep → battle builds the battle views over the next frames) takes its models back
* before anything is dropped (user playtest #3 item 1: the bench models were unloaded and re-loaded in one go).
*/
export const SPINE_EVICT_DELAY_MS = 1000;
/** Nothing referenced (and no scene holding the cache) for this long = no scene on screen (lobby / room): the idle skeletons may all go. */
export const SPINE_QUIET_DELAY_MS = 3000;
/** Waits (ms) before the manifest is fetched again within one `ready()` after a transient failure (like data.js). */
export const MANIFEST_RETRY_MS = Object.freeze([600, 2000]);
/** Waits (ms) before the store tries again by itself after a `ready()` whose fetches all failed transiently. */
export const MANIFEST_BACKOFF_MS = Object.freeze([5000, 15000, 30000, 60000]);
/** A fetch failure worth retrying (data.js): network error, HTTP 5xx / 408 / 429 — not a 404 or a body that is no JSON. */
const transientFetch = (err) => {
if (!err || err.badJson) return false;
const s = err.status;
return !(Number.isInteger(s) && s >= 400 && s < 500 && s !== 408 && s !== 429);
};
/**
* Create an asset store.
*
* Manifest (public issue #8 item 5: after a page reload — a phone browser discarding a background tab, Chrome's Memory
* Saver — every operator was drawn as the image-less placeholder): the game data already holds /data/assets.json (the
* match screen waits for it, ui/gameComponents.js GAME_FILES), so `seed(manifest)` adopts that copy (ui/fieldHost.js) and
* the store fetches nothing. Fetched by `ready()` (no seed yet): a transient failure is retried after MANIFEST_RETRY_MS;
* when every try fails `ready()` resolves with an empty object — every helper returns null meanwhile, the views draw
* their fallbacks — but the failure is never kept: the next `ready()` fetches again and the store tries again by itself
* after MANIFEST_BACKOFF_MS (bounded; a 404 or a body that is no JSON only on the next `ready()`). `onChange(fn)` is told
* when the manifest (`'manifest'`) or the optional local-client manifest (`'local'`) arrives, so views built without it
* resolve their models then (render/app.js → UnitView.retryAssets).
* @param {{ url?: string, fetch?: typeof fetch, manifest?: object, localManifest?: object, loadImage?: (url) => Promise<any>,
* loadSpine?: (entry) => Promise<any>, unloadSpine?: (entry, value, keepPages:Set<string>) => (Promise<void>|void),
* spineMax?: number, spineTimeout?: number, spineWeigh?: (key, spineData) => number, spineIdleBytes?: number,
* spineQuietBytes?: number, spineIdleGrace?: number, spineEvictDelay?: number, spineQuietDelay?: number,
* spineTimers?: { set, clear }, spineNow?: () => number, retryDelays?: number[], backoff?: number[],
* wait?: (ms: number) => Promise<void>, timers?: { set, clear } }} [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 }
const retryDelays = Array.isArray(opts.retryDelays) ? opts.retryDelays : MANIFEST_RETRY_MS;
const backoff = Array.isArray(opts.backoff) ? opts.backoff : MANIFEST_BACKOFF_MS;
const wait = opts.wait || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
const timers = opts.timers && typeof opts.timers.set === 'function' ? opts.timers : {
set: (fn, ms) => { const t = setTimeout(fn, ms); t?.unref?.(); return t; },
clear: (t) => clearTimeout(t),
};
const listeners = new Set();
let warned = false;
let backoffN = 0; // background tries used since the last success
let backoffTimer = null;
const notify = (what) => {
for (const fn of [...listeners]) { try { fn(what); } catch (err) { console.warn('[assets] change listener failed', err); } }
};
/** Adopt a manifest (fetched or seeded): resolves `ready()`, stops the background tries, tells the listeners. */
function adopt(m) {
if (manifest || !isObj(m)) return false;
manifest = m;
readyPromise = Promise.resolve(m);
backoffN = 0;
if (backoffTimer != null) { timers.clear(backoffTimer); backoffTimer = null; }
notify('manifest');
return true;
}
/** One fetch of the manifest → the JSON object, or throws (err.status / err.badJson as data.js). */
async function fetchOnce() {
const res = await doFetch(url, { cache: 'no-cache' });
if (!res || !res.ok) throw Object.assign(new Error(`HTTP ${res ? res.status : '???'}`), { status: res ? res.status : null });
let json;
try { json = await res.json(); } catch (err) { throw Object.assign(err instanceof Error ? err : new Error(String(err)), { badJson: true }); }
if (!isObj(json)) throw Object.assign(new Error('not a manifest'), { badJson: true });
return json;
}
function scheduleBackoff() {
if (manifest || backoffTimer != null || backoffN >= backoff.length) return;
const ms = backoff[backoffN++];
backoffTimer = timers.set(() => { backoffTimer = null; if (!manifest) ready(); }, ms);
}
function ready() {
if (manifest) return Promise.resolve(manifest);
if (!readyPromise) {
const p = readyPromise = (async () => {
for (let attempt = 0; ; attempt++) {
try {
const json = await fetchOnce();
adopt(json);
return manifest;
} catch (err) {
if (manifest) return manifest; // seeded meanwhile
if (transientFetch(err) && attempt < retryDelays.length) { await wait(retryDelays[attempt]); if (manifest) return manifest; continue; }
if (!warned) { warned = true; console.warn(`[assets] ${url} unavailable (${err?.message || err}); using fallbacks until it loads`); }
// not kept: the next ready() fetches again; the store tries again by itself after a transient failure
if (readyPromise === p) readyPromise = null;
if (transientFetch(err)) scheduleBackoff();
return {};
}
}
})();
}
return readyPromise;
}
const spineEntries = new Map(); // skel URL → manifest entry (for the atlas / page URLs on unload)
const spine = new RefLru({
load: (key, entry, o) => {
spineEntries.set(key, entry);
if (!(o && o.fresh)) return (opts.loadSpine || loadSpineData)(entry);
// a fresh load (a restart, or after a failure): not joined to a request that may never settle — but the pages
// another cached skeleton uses stay (forgetPendingSpine `keep`)
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);
return (opts.loadSpine || loadSpineData)(entry, { fresh: true, keep });
},
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);
// the promise (when the unloader returns one) holds back a new load of this skeleton until it has settled
return (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 has referenced or held any for SPINE_QUIET_DELAY_MS (lobby / room / result; a field view in
// battle mode holds the cache — see the header), each after a grace that covers a prep ⇄ battle switch; evictions
// run SPINE_EVICT_DELAY_MS after the release that allows them (a scene switch takes its models back first)
weigh: opts.spineWeigh || ((key, value) => spineDataWeight(value)),
maxIdleWeight: opts.spineIdleBytes ?? SPINE_IDLE_BYTES,
quietWeight: opts.spineQuietBytes ?? 0,
idleGrace: opts.spineIdleGrace ?? SPINE_IDLE_GRACE_MS,
quietDelay: opts.spineQuietDelay ?? SPINE_QUIET_DELAY_MS,
evictDelay: opts.spineEvictDelay ?? SPINE_EVICT_DELAY_MS,
timers: opts.spineTimers,
now: opts.spineNow,
});
/** Image element (cached; failures resolve to null). */
function image(u, { retry = false } = {}) {
const s = str(u);
if (!s) return Promise.resolve(null);
let e = images.get(s);
if (retry && e?.done && !e.value) { images.delete(s); e = null; }
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 (or seeded). */
function local() {
if (!localPromise) {
localPromise = (async () => {
try {
const res = await doFetch(localUrl, { cache: 'no-cache' });
if (!res || !res.ok) return localManifest;
const json = await res.json();
if (!localManifest && isObj(json) && isObj(json.groups)) { localManifest = json; notify('local'); }
} catch { /* optional art: absent */ }
return localManifest;
})();
}
return localPromise;
}
const m = () => manifest || {};
return {
ready,
local,
/**
* Adopt the game data's copy of /data/assets.json (data.js 'assets', already loaded by the match screen) unless a
* manifest is loaded: no second download, nothing to wait for. Returns whether it was adopted.
*/
seed: (mf) => adopt(mf),
/** The same for the optional local-client manifest (data.js 'local'; `{ groups }`), unless one is known. */
seedLocal(lm) {
if (localManifest || !isObj(lm) || !isObj(lm.groups)) return false;
localManifest = lm;
localPromise = Promise.resolve(lm);
notify('local');
return true;
},
/** `fn('manifest' | 'local')` when a manifest arrives (seeded or fetched, also late); returns the unsubscribe. */
onChange(fn) {
if (typeof fn !== 'function') return () => {};
listeners.add(fn);
return () => listeners.delete(fn);
},
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, localManifest ? { ...o, local: localManifest } : 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, { retry }) → Promise<spineData> (`retry`: load again after a remembered failure);
* restart(entry) → bool (a load still in flight starts again: RefLru.restart); release(entry); peek(entry);
* hold() → release function (a scene that will need its skeletons again: RefLru).
*/
spine: {
acquire: (entry, o) => (validSpine(entry) ? spine.acquire(entry.skel, entry, o) : Promise.reject(new Error('no spine entry'))),
restart: (entry) => (validSpine(entry) ? spine.restart(entry.skel, entry) : false),
release: (entry) => { if (entry && entry.skel) spine.release(entry.skel); },
peek: (entry) => (entry && entry.skel ? spine.peek(entry.skel) : null),
hold: () => spine.hold(),
stats: () => spine.stats(),
clear: () => spine.clear(),
cache: spine,
},
};
}
/** Browser singleton. */
export const assets = createAssets();