feedback1d WN: the local-client slug models are an optional overlay; the manifest never depends on the extraction

data/assets.json pointed 灼热源石虫 / 炽焰源石虫 at /assets/local/ files that only exist
where tools/local-extract ran, with no fallback: without the extraction the
asset tests failed, setup re-ran fetch-assets (rewriting the tracked manifest)
and the slugs drew as icon diamonds.

- The web model stays the 源石虫 alias (spine + spineAliasOf). The official
  model is enemies[id].spineLocal: file names in the data/local-assets.json
  group spine/enemy/<id> plus the parsed metadata, from the committed
  tools/assets/local-enemy-spines.json (fetch-assets --local-spines refreshes
  it from the extracted files, read only). The manifest is byte-identical with
  or without the extraction and has no /assets/local/ URL.
- Client: assets.js spineEntry uses spineLocal only when the local manifest
  lists every file (createFieldView awaits assets.local() with the manifest);
  UnitView loads the web model when the local one fails.
- extract.py writes the atlas with size: / pma: true itself (the same text as
  atlas.mjs normalizeAtlas), so the extraction is usable without a fetch pass.
- setup notes when an older extraction lacks the enemy models.
This commit is contained in:
sganggs committed 2026-10-03 05:18:31 +08:00
1 parent f705797a86
commit 04634f5bf7
14 files changed
+633 -119

No files matched your search

+39 -5
View File
@@ -11,6 +11,8 @@
// 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), 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
@@ -116,6 +118,10 @@ export function subProfIconUrl(m, sub) {
* 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 whose official model only the local client has
* (`spineLocal`, e.g. 灼热源石虫 — user feedback after 0.1.0, D3) gets that model when the manifest lists every one of
* its files; the returned entry's `fallback` is the web model (aliased), for a load failure (DESIGN §13: local art is
* optional, everything works without it).
*/
export function spineEntry(m, id, opts) {
const s = str(id);
@@ -129,14 +135,42 @@ export function spineEntry(m, id, opts) {
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;
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) || '');
@@ -651,7 +685,7 @@ export function createAssets(options) {
subProfIcon: (s) => subProfIconUrl(m(), s),
ui: (name) => uiUrl(m(), name),
picture: (id) => unitPictureUrl(m(), id),
spineEntry: (id, o) => spineEntry(m(), id, o),
spineEntry: (id, o) => spineEntry(m(), id, localManifest ? { ...o, local: localManifest } : o),
hasBack: (id) => hasBackSpine(m(), id),
audio: {
bgm: (kind) => bgmEntry(m(), kind),