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:
1 parent
82c54897c7
commit
b7ac003e09
6 files changed
+676
-64
No files matched your search
+70
-9
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user