feedback1 ui: operators no longer stay placeholders after a reload or a battle begun in a hidden tab (GitHub #8)

A reload (a phone browser or Chrome's Memory Saver discarding the background tab) made the asset store download
/data/assets.json a second time; createFieldView waited at most 4 s for it, every view looked its model and avatar up
once, and a failed download kept an empty manifest for the page. Now the field host seeds the store with the game
data's copies (seedAssets); without one, ready() retries like data.js, never keeps a failure and tries again with a
bounded backoff, and onChange makes every view re-resolve (UnitView.retryAssets, ItemView.setIcon). A failed or
timed-out Spine load is retried by its view (SPINE_RETRY_MS, acquire { retry }) and at once when the tab is shown.
Battle mode holds the Spine cache (spine.hold()), so a battle that began in a hidden tab keeps its models; the idle
budget still applies and the quiet budget still frees everything in the lobby, room and result.
This commit is contained in:
sganggs committed 2026-10-03 16:15:01 +08:00
1 parent 82c54897c7
commit b7ac003e09
6 files changed
+676 -64

No files matched your search

+56 -15
View File
@@ -3,11 +3,13 @@
// UnitView = shadow sprite (shadow layer) + body container (depth-sorted unit layer: elite aura, Spine actor or
// 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) — except an optional local-client model (assets.js spineEntry `fallback`, DESIGN §13:
// 灼热源石虫 / 炽焰源石虫), which falls back to the web model first; that web alias (the plain 源石虫) is drawn tinted
// toward the slug's own colours (ALIAS_TINT). Every bar is a tinted Texture.WHITE sprite, so HUDs batch into few draw
// calls.
// cross-fades to the Spine model when it has loaded (the game never blocks on Spine); an optional local-client model
// (assets.js spineEntry `fallback`, DESIGN §13: 灼热源石虫 / 炽焰源石虫) falls back to the web model first; that web alias
// (the plain 源石虫) is drawn tinted toward the slug's own colours (ALIAS_TINT). A model that failed or timed out is
// loaded again after SPINE_RETRY_MS (bounded), and `retryAssets()` re-resolves a view's picture and model when the
// asset manifest arrives after the view was built or the tab is shown again (render/app.js; public issue #8 item 5:
// after a reload whose manifest was slow or failed, every operator stayed the image-less placeholder for good). 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
@@ -84,6 +86,12 @@ export const DIR_STEP = Object.freeze({ UP: [0, 1], RIGHT: [1, 0], DOWN: [0, -1]
const nowMs = () => (globalThis.performance ? globalThis.performance.now() : Date.now());
/** How long a view waits for its avatar before showing the image-less placeholder diamond. */
const PIC_WAIT_MS = 400;
/**
* Waits (ms, real time) before a view loads its Spine model again after the load failed or timed out (assets.js, 20 s)
* — bounded: after the last one the view keeps its diamond until `retryAssets` (a manifest that arrived late, the tab
* shown again). Counted from the failure; a hidden tab runs no frames, so nothing is retried while hidden.
*/
export const SPINE_RETRY_MS = Object.freeze([2000, 6000, 15000, 30000]);
/** Heights above this count as standing on a raised top (bench pads are the lowest raised tiles, 0.16). */
const RAISED_Z = 0.12;
@@ -317,6 +325,9 @@ export class UnitView {
this.body.addChild(this.fallback);
this.actor = null;
this.spineReady = false;
this._spineBusy = false; // a Spine load of this view is in flight
this._spineTries = 0; // failed loads since the last model (SPINE_RETRY_MS)
this._retryAt = 0; // when the next retry is due (ms, performance clock; 0 = none)
this.baseTint = 0xffffff; // the drawn model's own tint (ALIAS_TINT), under the status tints
this.hud = new P.Container();
@@ -365,7 +376,8 @@ export class UnitView {
pic.shown = want;
}
_loadSpine() {
/** @param {boolean} [retry] load again even after a remembered failure (assets.js acquire `{ retry }`) */
_loadSpine(retry = false) {
const a = this.ctx.assets;
if (!a || !a.spineEntry || !a.spine) return;
const id = this.info.spine || this.info.defId;
@@ -374,21 +386,38 @@ export class UnitView {
const entry = id ? a.spineEntry(id, { back }) : null;
if (!entry || this.ctx.settings?.quality === 'low' && this.isEnemy && !this.isBoss && this.ctx.crowded?.()) return;
this.entryBack = back;
this._acquireSpine(entry, id);
this._acquireSpine(entry, id, retry);
}
/**
* Re-resolve what this view could not draw yet (public issue #8 item 5): the picture when it has none (no avatar URL —
* the asset manifest arrived after the view was built — or the image failed) and, at once, the Spine model when none
* is shown and none is loading (no manifest entry then, or a load that failed / timed out). render/app.js calls it for
* every view when a manifest arrives (assets.js onChange) and when the tab is shown again. Nothing to do otherwise.
*/
retryAssets() {
if (this.destroyed) return;
if (!this._pic || this._pic.state === 'none') this._loadPicture();
if (!this.actor && !this._spineBusy) { this._retryAt = 0; this._loadSpine(true); }
}
/**
* 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.
* destroyed. A model that fails to load falls back to the entry's `fallback` (an optional local-client model,
* assets.js spineEntry: DESIGN §13) when it names one; otherwise the view keeps its fallback diamond and — while no
* model is shown — loads again after SPINE_RETRY_MS (a timed-out or failed download used to stay a diamond for good).
*/
_acquireSpine(entry, id) {
_acquireSpine(entry, id, retry = false) {
const a = this.ctx.assets;
this.entry = entry;
const req = this._spineReq = (this._spineReq || 0) + 1;
a.spine.acquire(entry).then((data) => {
this._spineBusy = true;
a.spine.acquire(entry, retry ? { retry: true } : undefined).then((data) => {
if (req === this._spineReq) this._spineBusy = false;
if (this.destroyed || req !== this._spineReq) { this._releaseEntry(entry); return; }
this._spineTries = 0;
this._retryAt = 0;
let actor = null;
try {
actor = new SpineActor(data, entry);
@@ -421,9 +450,12 @@ export class UnitView {
this.actor.setBase(this._baseFromAnim());
}
}, () => {
if (req === this._spineReq) this._spineBusy = false;
this._releaseEntry(entry);
if (entry.fallback && !this.destroyed && req === this._spineReq) this._acquireSpine(entry.fallback, id);
// else keep the fallback diamond
if (this.destroyed || req !== this._spineReq) return;
if (entry.fallback) { this._acquireSpine(entry.fallback, id, retry); return; }
// keep the fallback diamond (or the model already shown: a failed Front ⇄ Back swap) — and try again later
if (!this.actor && this._spineTries < SPINE_RETRY_MS.length) this._retryAt = nowMs() + SPINE_RETRY_MS[this._spineTries++];
});
}
@@ -729,6 +761,8 @@ export class UnitView {
update(dt, cam, t) {
if (this.destroyed) return;
const P = this.P;
// a failed / timed-out model load is tried again once its wait is over (SPINE_RETRY_MS; frames only: never hidden)
if (this._retryAt && nowMs() >= this._retryAt) { this._retryAt = 0; if (!this.actor && !this._spineBusy) this._loadSpine(true); }
if (this.zTarget != null && this.z !== this.zTarget) {
const d = this.zTarget - this.z;
this.z = Math.abs(d) < 1e-3 ? this.zTarget : this.z + d * Math.min(1, dt * 12);
@@ -1297,10 +1331,17 @@ export class ItemView {
this.plate = new P.Sprite(itemTexture(String(info.defId || 'item'), null, info.color || 0x9aa5a0));
this.plate.anchor.set(0.5, 1);
this.root.addChild(this.plate);
const url = info.icon;
if (url && ctx.assets?.image) ctx.assets.image(url).then((img) => { if (!this.destroyed && img) this.plate.texture = itemTexture(String(info.defId), img, info.color || 0x9aa5a0); }, () => {});
this.info.icon = null;
this.setIcon(info.icon);
this.hud = null;
}
/** Show the item's icon (an URL; the plain plate until it loads) — again when the asset manifest named it late. */
setIcon(url) {
if (this.destroyed || !url || url === this.info.icon) return;
this.info.icon = url;
const a = this.ctx.assets;
if (a?.image) a.image(url).then((img) => { if (!this.destroyed && img && this.info.icon === url) this.plate.texture = itemTexture(String(this.info.defId), img, this.info.color || 0x9aa5a0); }, () => {});
}
setWorld(x, y, z = 0) { this.x = x; this.y = y; this.z = z; }
update(dt, cam, t) {
const lifted = this.lift > 0;