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:
1 parent
f705797a86
commit
04634f5bf7
14 files changed
+633
-119
No files matched your search
+39
-5
@@ -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),
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user