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),
+8 -3
View File
@@ -75,8 +75,10 @@
// three.js PerspectiveCamera is synced from it every change. Missing art / three / WebGL2, a failed init or a lost
// context fall back to the 2D atlas board. `opts.board`: 'auto' (default) | '3d' | '2d'; `?board=2d|3d` in the URL
// overrides (dev; '3d' also accepts a slow / software GPU). stats().board3d → { on, calls, triangles, cpuMs }.
// three.js is only downloaded when the local-art manifest lists the board atlas. The built 3D area, the drawn 2D rows
// and the lit rect follow `viewKind` (a 'prep' camera on the boss rows = the Final Assault prep = the boss field).
// three.js is only downloaded when the local-art manifest lists the board atlas. That manifest is awaited with the asset
// manifest (≤ 4 s), so an enemy whose model only the local client has draws it (assets.js spineEntry). The built 3D
// area, the drawn 2D rows and the lit rect follow `viewKind` (a 'prep' camera on the boss rows = the Final Assault
// prep = the boss field).
// Battle device boxes (`ctx.createBox`) follow the board layer (switchableBox), so a 3D ⇄ 2D switch keeps crates.
// Crowds and clipped Spine skeletons render through the shared impostor atlas (render/impostor.js), flushed once per
// frame before the main pass.
@@ -357,7 +359,10 @@ export async function createFieldView(host, options = {}) {
const artListed = want3d ? boardArtListed(assets).catch(() => false) : Promise.resolve(false);
const threePromise = artListed.then((ok) => (ok ? loadThree() : null));
const packPromise = artListed.then((ok) => (ok ? Promise.resolve(assets.ready ? assets.ready() : null).catch(() => null).then(() => loadBoardPack(assets)) : null));
await withTimeout(Promise.resolve(assets.ready ? assets.ready() : null).catch(() => {}), 4000);
// the manifest, and the optional local-art manifest in parallel: unit views pick an enemy's local-client model by it
// (assets.js spineEntry, DESIGN §13 — 灼热源石虫 / 炽焰源石虫); absent or slow, they draw the web models
await withTimeout(Promise.all([assets.ready ? assets.ready() : null, assets.local ? assets.local() : null]
.map((p) => Promise.resolve(p).catch(() => {}))), 4000);
// web fonts for the bitmap damage numbers / tier chips (never block long)
try { if (document.fonts?.load) await withTimeout(Promise.all([document.fonts.load('700 40px Bender'), document.fonts.load('700 40px Oxanium')]), 1500); } catch { /* ignore */ }
+20 -5
View File
@@ -4,7 +4,9 @@
// the avatar-in-rarity-diamond fallback) + HUD container (bar layer: HP bar with delayed "ghost" damage, SP bar
// with ready glow / draining skill bar, tier chip, status icons, blocked marker). The fallback shows at once and
// cross-fades to the Spine model when it has loaded; missing or failed models keep the fallback forever (the
// game never blocks on Spine). Every bar is a tinted Texture.WHITE sprite, so HUDs batch into few draw calls.
// game never blocks on Spine) — except an optional local-client model (assets.js spineEntry `fallback`, DESIGN §13:
// 灼热源石虫 / 炽焰源石虫), which falls back to the web model first. Every bar is a tinted Texture.WHITE sprite, so
// HUDs batch into few draw calls.
//
// Placement: feet anchored at world (x, y, z); scale = camera px-per-tile at the feet × UNIT.modelScale, so
// chibis shrink with distance like the original. An enemy's model is also scaled by its official prefab factor
@@ -308,10 +310,19 @@ export class UnitView {
const back = this._wantsBack();
const entry = id ? a.spineEntry(id, { back }) : null;
if (!entry || this.ctx.settings?.quality === 'low' && this.isEnemy && !this.isBoss && this.ctx.crowded?.()) return;
// every acquire is paired with exactly one release: a superseded / failed / post-destroy load releases its own
// entry; the displayed model's entry (`_actorEntry`) is released when that model is replaced or destroyed
this.entry = entry;
this.entryBack = back;
this._acquireSpine(entry, id);
}
/**
* Load `entry` and show it. Every acquire is paired with exactly one release: a superseded / failed / post-destroy
* load releases its own entry; the displayed model's entry (`_actorEntry`) is released when that model is replaced or
* destroyed. A model that fails to load keeps the fallback diamond — unless the entry names a `fallback` (an optional
* local-client model, assets.js spineEntry: DESIGN §13), which is loaded in its place.
*/
_acquireSpine(entry, id) {
const a = this.ctx.assets;
this.entry = entry;
const req = this._spineReq = (this._spineReq || 0) + 1;
a.spine.acquire(entry).then((data) => {
if (this.destroyed || req !== this._spineReq) { this._releaseEntry(entry); return; }
@@ -345,7 +356,11 @@ export class UnitView {
if (this.flags & UF.SKILL) this.actor.setSkill(true);
this.actor.setBase(this._baseFromAnim());
}
}, () => { this._releaseEntry(entry); /* keep the fallback */ });
}, () => {
this._releaseEntry(entry);
if (entry.fallback && !this.destroyed && req === this._spineReq) this._acquireSpine(entry.fallback, id);
// else keep the fallback diamond
});
}
_formSpec() {