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

+70 -9
View File
@@ -83,9 +83,12 @@
// 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. 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).
// manifest (≤ 4 s; the game seeds the store with its own copies, ui/fieldHost.js seedAssets), so an enemy whose model
// only the local client has draws it (assets.js spineEntry). A manifest that arrives later (assets.js onChange) makes
// every view re-resolve its picture and model (UnitView.retryAssets), and so does showing the tab again; battle mode
// holds the Spine cache so a battle begun in a hidden tab keeps its models (holdScene; public issue #8 item 5). 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.
@@ -446,13 +449,18 @@ export async function createFieldView(host, options = {}) {
bgMountains2.alpha = 1;
const bgVignette = new P.Sprite(bg.vignette);
backdrop.addChild(bgGrad, bgMountains, bgMountains2, bgGrid, bgVignette);
const mountainUrl = assets.ui ? assets.ui('entry/bg_mountains_tiled') : null;
if (mountainUrl && assets.image) {
let mountainsAsked = false;
/** The backdrop's mountain silhouette (optional art; asked again when the manifest arrives late). */
function loadMountains() {
const mountainUrl = !mountainsAsked && assets.ui ? assets.ui('entry/bg_mountains_tiled') : null;
if (!mountainUrl || !assets.image) return;
mountainsAsked = true;
assets.image(mountainUrl).then((img) => {
if (!img || destroyed) return;
try { const mt = silhouetteTexture(img); bgMountains.texture = mt; bgMountains2.texture = mt; } catch { /* optional art */ }
}, () => {});
}
loadMountains();
// ---- state ----------------------------------------------------------------------------------------------
const listeners = new Map();
@@ -475,6 +483,7 @@ export async function createFieldView(host, options = {}) {
let promoBase = [];
const promotions = []; // the last merges cued by setPrep (fx.promote): { uid, id, area, row, col, idx, copies } — dev / tests
let battleMeta = null;
let sceneHold = null; // release function of the Spine cache hold of battle mode (holdScene)
const infos = new Map(); // battle unit id → UnitInfo
// battle ids whose view finished its death / leak fade: a snapshot may still list them for a moment (the sim keeps
// dead units for DIE_ANIM_TIME), which must not bring the view back; a spawn / deploy / live sample clears it
@@ -628,15 +637,25 @@ export async function createFieldView(host, options = {}) {
const ready = Promise.all([threePromise, packPromise]).then(([THREE, pack]) => (THREE && pack ? enable3d(THREE, pack) : false), () => false);
await withTimeout(ready, 6000);
}
// the official soft shadow sprite replaces the procedural one once loaded (may already be cached)
const shadowUrl = assets.ui ? assets.ui('battle/sprite_shadow') : null;
if (shadowUrl) {
// the official soft shadow sprite replaces the procedural one once loaded (may already be cached; asked again when the
// manifest arrives late)
let shadowAsked = false;
function loadShadow() {
const shadowUrl = !shadowAsked && assets.ui ? assets.ui('battle/sprite_shadow') : null;
if (!shadowUrl) return;
shadowAsked = true;
try {
const t = P.Texture.from(shadowUrl);
const use = () => { if (destroyed) return; ctx.shadowTex = t; for (const v of views.values()) if (v.shadow && !v.destroyed) v.shadow.texture = t; };
const use = () => {
if (destroyed) return;
ctx.shadowTex = t;
for (const v of views.values()) if (v.shadow && !v.destroyed) v.shadow.texture = t;
for (const v of penViews.values()) if (v.shadow && !v.destroyed) v.shadow.texture = t;
};
if (t.baseTexture.valid) use(); else t.baseTexture.once('loaded', use);
} catch { /* optional */ }
}
loadShadow();
ensureDamageFonts();
// web fonts may land after the first chips were drawn
if (document.fonts?.ready) document.fonts.ready.then(() => { if (!destroyed) refreshTierChips(); }).catch(() => {});
@@ -950,6 +969,7 @@ export async function createFieldView(host, options = {}) {
}
v._home = w;
v.dimmed = false;
if (info.kind === 'item' && v.setIcon) v.setIcon(info.icon); // an icon the manifest named late (onAssets)
if (v.setCount) v.setCount(e.piece.kind === 'token' ? e.piece.count : 0);
if (v.setItems) v.setItems(Array.isArray(e.piece.items) ? e.piece.items.map((it) => { const r = data.item(it?.id); return assets.itemIcon ? assets.itemIcon(r ? { trapId: r.trapId, iconId: r.iconId } : it?.id) : null; }) : []);
v._showFacing = e.area === 'board';
@@ -959,6 +979,7 @@ export async function createFieldView(host, options = {}) {
for (const k of [...views.keys()]) if (String(k).startsWith('p:') && !seen.has(Number(String(k).slice(2)))) dropView(k);
prepPieces = list.filter((e) => e.key && views.has(e.key));
if (dragState && !views.has(dragState.key)) { drag.reset(); endDragVisual(false); }
holdScene(false); // the prep pieces reference their models now (a battle's hold ends here)
return true;
}
@@ -1291,8 +1312,23 @@ export async function createFieldView(host, options = {}) {
// ---- battle ---------------------------------------------------------------------------------------------
/**
* Battle mode holds the Spine cache (assets.js `spine.hold()`, its memory policy): the battle's views are built in
* animation frames, which a hidden tab does not run — a battle that began in a background tab referenced no skeleton, and
* the quiet budget emptied the cache ≈ 18 s later, so back in the tab every unit was an avatar diamond until its model
* downloaded again (public issue #8 item 5). Released once the prep pieces reference their models (setPrep) and on
* destroy; the idle budget still applies meanwhile.
*/
function holdScene(on) {
if (on) { if (!sceneHold && typeof assets.spine?.hold === 'function') sceneHold = assets.spine.hold(); return; }
const release = sceneHold;
sceneHold = null;
if (release) { try { release(); } catch { /* ignore */ } }
}
function enterBattle(meta) {
if (destroyed || !meta || typeof meta !== 'object') return false;
holdScene(true); // before the prep views go: the cache is never "quiet" across the switch
drag.reset();
endDragVisual(false);
clearViews();
@@ -1701,6 +1737,28 @@ export async function createFieldView(host, options = {}) {
let lastDpr = globalThis.devicePixelRatio || 1;
layoutBackdrop();
// ---- late assets, hidden tabs (public issue #8 item 5: operators drawn as image-less placeholders) ----------------------
// The asset manifest (or the local-client one) arrived after views were built — a reload whose manifest fetch was slow or
// failed (assets.js onChange): every view re-resolves what it could not draw (UnitView.retryAssets) and the prep pieces
// re-read their item icons. The tab shown again: a manifest still missing is asked for again and views without a model
// load it again at once (their bounded retries never run while hidden: no frames).
function onAssets() {
if (destroyed) return;
loadShadow();
loadMountains();
if (mode === 'prep' && lastPrep) setPrep(lastPrep.ps, lastPrep.o);
for (const v of views.values()) v.retryAssets?.();
for (const v of penViews.values()) v.retryAssets?.();
}
const offAssets = typeof assets.onChange === 'function' ? assets.onChange(onAssets) : null;
const onVisible = () => {
if (destroyed || globalThis.document?.visibilityState !== 'visible') return;
if (assets.loaded === false && typeof assets.ready === 'function') assets.ready();
for (const v of views.values()) v.retryAssets?.();
for (const v of penViews.values()) v.retryAssets?.();
};
globalThis.document?.addEventListener?.('visibilitychange', onVisible);
// ---- public API ---------------------------------------------------------------------------------------------
const view = {
@@ -1816,6 +1874,8 @@ export async function createFieldView(host, options = {}) {
if (destroyed) return;
destroyed = true;
try { ro?.disconnect(); } catch { /* ignore */ }
try { offAssets?.(); } catch { /* ignore */ }
globalThis.document?.removeEventListener?.('visibilitychange', onVisible);
canvas.removeEventListener('pointerdown', onPointerDown);
canvas.removeEventListener('pointermove', onPointerMove);
canvas.removeEventListener('pointerup', onPointerUp);
@@ -1830,6 +1890,7 @@ export async function createFieldView(host, options = {}) {
drag.reset();
for (const k of [...views.keys()]) dropView(k);
clearPen();
holdScene(false); // no scene any more: the quiet budget may free the skeletons (lobby / room / result)
try { fx.destroy(); } catch { /* ignore */ }
try { tiles.destroy(); } catch { /* ignore */ }
try { impostors.destroy(); } catch { /* ignore */ }
+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;