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 */ }
|
||||
|
||||
Reference in new issue
Block a user