feedback7 R22-HUD: a renderer that loses the 12 s race no longer mounts its canvas late — the attempt is aborted, a view that still comes back is destroyed, the host is no longer wiped (PR #302 by @siruimei07); golden: unchanged
Applied unchanged after review (Grok static review: MERGE; the diff read in full: no network, no new dependency, no
shipped eval). The leak was real on feedback7: mountFieldView raced createFieldView against a timeout and, on failure,
removed every child of the host while the factory kept running — Pixi finished later and appended its canvas over the
simplified view (taking its clicks); the host wipe could also delete a newer view mounted by the same hook.
- ui/fieldHost.js: mountFieldView(host, { signal }) owns an AbortController; the timeout, a failed import or an invalid
view aborts it, a view a factory returns after that is destroyed, an outer abort (useFieldView's cleanup) rejects
without a fallback; nothing clears the host's children any more.
- render/app.js createFieldView takes the signal: the Pixi load / manifest / font waits end on abort, the synchronous
build registers its disposables (setupCleanup) so a throw releases the app, tiles, fx, impostors, listeners and
observers it made, the board-art and 3D-board waits moved after the view exists (aborting them is view.destroy(),
idempotent), destroy() also removes the canvas. render/app/host.js withTimeout(p, ms, signal).
- Tests: test/ui/fieldHost-lifecycle.test.js (5, node vm, fake timers: all fail on the parent) and the opt-in real-browser
test/render/initialization.browser.test.js (6 scenarios in headless Chrome, 5 fail on the parent: a late Pixi load
covering the fallback — with a factory that honours the signal and one that ignores it —, art timeout, a setup
exception, an unmount racing a newer mount); test/ui/playtest2.test.js's source pin takes the new indentation.
fieldview / browser suites in Chrome unchanged (the two 4× throttled fps tests flake when two Chrome files run
at once, pass alone).
- The createFieldView body stays unindented inside its new try (the PR's choice, to keep the diff reviewable).
- Docs: design/client.md §9. Golden: unchanged (client lifecycle only).
This commit is contained in:
1 parent
8ae891f1f3
commit
030d0a2938
7 files changed
+654
-39
No files matched your search
+72
-22
@@ -148,14 +148,17 @@ export { releaseGl } from './app/host.js';
|
||||
/**
|
||||
* Create the battlefield view inside `host` (an element sized by CSS; the canvas fills it).
|
||||
* @param {HTMLElement} host
|
||||
* @param {{ data?: any, assets?: any, audio?: any, settings?: { damageNumbers?: boolean, quality?: string },
|
||||
* @param {{ data?: any, assets?: any, audio?: any, settings?: { damageNumbers?: boolean, quality?: string }, signal?: AbortSignal,
|
||||
* padding?: object|((kind:string, size:{width:number,height:number}) => object),
|
||||
* hud?: {top:number,bottom:number}|((kind:'prep'|'bossPrep', size:{width:number,height:number}, o:{shop:boolean}) => {top:number,bottom:number}|null) }} [opts]
|
||||
*/
|
||||
export async function createFieldView(host, options = {}) {
|
||||
if (!host || typeof host.appendChild !== 'function') throw new TypeError('createFieldView: host element required');
|
||||
const opts = options && typeof options === 'object' ? options : {};
|
||||
const P = await ensurePixi();
|
||||
const signal = opts.signal;
|
||||
signal?.throwIfAborted();
|
||||
const P = await withTimeout(ensurePixi(), null, signal);
|
||||
signal?.throwIfAborted();
|
||||
const assets = resolveAssets(opts.assets);
|
||||
const data = makeData(opts.data);
|
||||
const settings = { damageNumbers: true, quality: 'high', ...(opts.settings || {}) };
|
||||
@@ -164,19 +167,28 @@ export async function createFieldView(host, options = {}) {
|
||||
const want3d = boardPref !== '2d' && webgl2Available(boardPref === '3d');
|
||||
// three.js (~2 MB) is fetched only when the local-art manifest lists the board atlas (in parallel with the art)
|
||||
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));
|
||||
const threePromise = artListed.then((ok) => (ok ? loadThree() : null)).catch(() => null);
|
||||
const packPromise = artListed.then((ok) => (ok ? Promise.resolve(assets.ready ? assets.ready() : null).catch(() => null).then(() => loadBoardPack(assets)) : null)).catch(() => null);
|
||||
// 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);
|
||||
.map((p) => Promise.resolve(p).catch(() => {}))), 4000, signal);
|
||||
signal?.throwIfAborted();
|
||||
// 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 */ }
|
||||
try { if (document.fonts?.load) await withTimeout(Promise.all([document.fonts.load('700 40px Bender'), document.fonts.load('700 40px Oxanium')]), 1500, signal); } catch { /* optional fonts */ }
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const size = () => ({ width: Math.max(1, host.clientWidth || 1), height: Math.max(1, host.clientHeight || 1) });
|
||||
const dpr = () => Math.min(globalThis.devicePixelRatio || 1, QUALITY_RES[settings.quality] || 2);
|
||||
const boardDpr = () => Math.min(globalThis.devicePixelRatio || 1, BOARD_RES[settings.quality] || 2);
|
||||
const s0 = size();
|
||||
// Construction is synchronous until the complete view can own cancellation. If setup throws,
|
||||
// dispose only the resources acquired by this attempt, never another view's host contents.
|
||||
const setupCleanup = [];
|
||||
let destroyed = false;
|
||||
let view = null;
|
||||
let onAbort;
|
||||
try {
|
||||
const app = new P.Application({
|
||||
// MSAA only where it pays: dense (DPR ≥ 1.5) screens are sharp enough without it and it would cost 4× the fill
|
||||
// transparent: the 3D board canvas shows through (the 2D board paints an opaque backdrop itself)
|
||||
@@ -184,6 +196,11 @@ export async function createFieldView(host, options = {}) {
|
||||
resolution: dpr(), autoDensity: true, powerPreference: 'high-performance',
|
||||
});
|
||||
const canvas = app.view;
|
||||
setupCleanup.push(() => {
|
||||
releaseGl(app.renderer);
|
||||
try { app.destroy(true, { children: true, texture: false, baseTexture: false }); }
|
||||
finally { canvas.remove(); }
|
||||
});
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.width = '100%';
|
||||
canvas.style.height = '100%';
|
||||
@@ -240,7 +257,6 @@ export async function createFieldView(host, options = {}) {
|
||||
for (const fn of [...set]) { try { fn(payload); } catch (err) { console.error(`[render] ${name} listener failed`, err); } }
|
||||
};
|
||||
|
||||
let destroyed = false;
|
||||
let mode = 'idle'; // 'idle' | 'prep' | 'battle'
|
||||
let stageRec = null;
|
||||
let cam = presetCamera('prep', { width: s0.width, height: s0.height, padding: defaultPadding('prep', s0) }, { hud: hudBands('prep', s0) });
|
||||
@@ -306,6 +322,7 @@ export async function createFieldView(host, options = {}) {
|
||||
surfaceLayer: (row) => tiles.surfaceLayer(row),
|
||||
};
|
||||
const tiles = new TileField({ ground: layers.ground, overlay: layers.overlay, props: layers.units, anim: layers.anim });
|
||||
setupCleanup.push(() => tiles.destroy());
|
||||
const fx = new FxSystem({
|
||||
P, layers, cam: () => cam, settings, assets, heightAt, redVignette: bg.red, surfaceLayer: ctx.surfaceLayer,
|
||||
timeScale: () => ctx.timeScale(),
|
||||
@@ -320,10 +337,12 @@ export async function createFieldView(host, options = {}) {
|
||||
return Math.max(80, Math.min(size().height * 0.4, p.y));
|
||||
},
|
||||
});
|
||||
setupCleanup.push(() => fx.destroy());
|
||||
ctx.fx = fx;
|
||||
// battle devices (crates / turrets as sim units): the official crate mesh in the 3D scene, else a Pixi box
|
||||
ctx.createBox = () => switchableBox({ board: () => board3d, pixi: () => tiles.createBox() });
|
||||
const impostors = new ImpostorAtlas(app.renderer);
|
||||
setupCleanup.push(() => impostors.destroy());
|
||||
ctx.impostors = impostors;
|
||||
tiles.setView(bandFor('prep'), camRect(), fieldRows('prep'));
|
||||
// the real board art of the local client (optional): wait briefly so the first frame already uses it; a late
|
||||
@@ -332,7 +351,6 @@ export async function createFieldView(host, options = {}) {
|
||||
if (art && !destroyed) { tiles.setArt(art); tiles.project(cam, true); }
|
||||
return art;
|
||||
}, () => null);
|
||||
await withTimeout(artPromise, 2500);
|
||||
|
||||
// ---- 3D board layer (render/board3d, DESIGN §15) -----------------------------------------------------------
|
||||
let board3d = null; // BoardScene while the 3D board is on
|
||||
@@ -340,6 +358,7 @@ export async function createFieldView(host, options = {}) {
|
||||
let board3dError = null;
|
||||
// lost-context recovery: the THREE module + art pack of the last 3D board, the pending retry, failures so far
|
||||
const recover = { THREE: null, pack: null, timer: 0, tries: 0, since: 0, off: false, count: 0 };
|
||||
setupCleanup.push(() => { clearTimeout(recover.timer); disable3d(); });
|
||||
function enable3d(THREE, pack) {
|
||||
if (destroyed || board3d || !THREE || !pack) return false;
|
||||
recover.THREE = THREE; recover.pack = pack;
|
||||
@@ -409,13 +428,13 @@ export async function createFieldView(host, options = {}) {
|
||||
if (!enable3d(recover.THREE, recover.pack)) scheduleRecover();
|
||||
}, delay);
|
||||
}
|
||||
if (want3d) {
|
||||
const ready = Promise.all([threePromise, packPromise]).then(([THREE, pack]) => (THREE && pack ? enable3d(THREE, pack) : false), () => false);
|
||||
await withTimeout(ready, 6000);
|
||||
}
|
||||
// Attach the board only after synchronous setup completes; enable3d also guards late arrivals.
|
||||
const boardReady = Promise.all([threePromise, packPromise]).then(([THREE, pack]) => (THREE && pack ? enable3d(THREE, pack) : false), () => false);
|
||||
// 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;
|
||||
let offShadow = null;
|
||||
setupCleanup.push(() => offShadow?.());
|
||||
function loadShadow() {
|
||||
const shadowUrl = !shadowAsked && assets.ui ? assets.ui('battle/sprite_shadow') : null;
|
||||
if (!shadowUrl) return;
|
||||
@@ -428,7 +447,11 @@ export async function createFieldView(host, options = {}) {
|
||||
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);
|
||||
if (t.baseTexture.valid) use();
|
||||
else {
|
||||
t.baseTexture.once('loaded', use);
|
||||
offShadow = () => t.baseTexture.off('loaded', use);
|
||||
}
|
||||
} catch { /* optional */ }
|
||||
}
|
||||
loadShadow();
|
||||
@@ -1206,6 +1229,17 @@ export async function createFieldView(host, options = {}) {
|
||||
// the 收起 toggle of PR #149 moved the discs one button to the right) opened the bond popup instead of selecting the
|
||||
// unit. As a click target that holds the finger's point the canvas wins: a tap on the board stays on the tile under it.
|
||||
const onTapTarget = () => {};
|
||||
const removeInput = () => {
|
||||
canvas.removeEventListener('pointerdown', onPointerDown);
|
||||
canvas.removeEventListener('pointermove', onPointerMove);
|
||||
canvas.removeEventListener('pointerup', onPointerUp);
|
||||
canvas.removeEventListener('pointercancel', onPointerCancel);
|
||||
canvas.removeEventListener('pointerleave', onPointerLeave);
|
||||
canvas.removeEventListener('contextmenu', onContext);
|
||||
canvas.removeEventListener('touchend', onTouchEnd);
|
||||
canvas.removeEventListener('click', onTapTarget);
|
||||
};
|
||||
setupCleanup.push(removeInput);
|
||||
canvas.addEventListener('pointerdown', onPointerDown);
|
||||
canvas.addEventListener('pointermove', onPointerMove);
|
||||
canvas.addEventListener('pointerup', onPointerUp);
|
||||
@@ -1655,6 +1689,7 @@ export async function createFieldView(host, options = {}) {
|
||||
let ro = null;
|
||||
if (typeof ResizeObserver === 'function') {
|
||||
ro = new ResizeObserver(() => resize());
|
||||
setupCleanup.push(() => ro.disconnect());
|
||||
ro.observe(host);
|
||||
}
|
||||
// a DPR change without a size change (window dragged to another monitor, zoom) must re-rasterise too; polled
|
||||
@@ -1677,6 +1712,7 @@ export async function createFieldView(host, options = {}) {
|
||||
leader?.view.retryAssets?.();
|
||||
}
|
||||
const offAssets = typeof assets.onChange === 'function' ? assets.onChange(onAssets) : null;
|
||||
setupCleanup.push(() => offAssets?.());
|
||||
const onVisible = () => {
|
||||
if (destroyed || globalThis.document?.visibilityState !== 'visible') return;
|
||||
if (assets.loaded === false && typeof assets.ready === 'function') assets.ready();
|
||||
@@ -1685,10 +1721,11 @@ export async function createFieldView(host, options = {}) {
|
||||
leader?.view.retryAssets?.();
|
||||
};
|
||||
globalThis.document?.addEventListener?.('visibilitychange', onVisible);
|
||||
setupCleanup.push(() => globalThis.document?.removeEventListener?.('visibilitychange', onVisible));
|
||||
|
||||
// ---- public API ---------------------------------------------------------------------------------------------
|
||||
|
||||
const view = {
|
||||
view = {
|
||||
setStage,
|
||||
setCamera,
|
||||
setPrep,
|
||||
@@ -1804,14 +1841,9 @@ export async function createFieldView(host, options = {}) {
|
||||
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);
|
||||
canvas.removeEventListener('pointercancel', onPointerCancel);
|
||||
canvas.removeEventListener('pointerleave', onPointerLeave);
|
||||
canvas.removeEventListener('contextmenu', onContext);
|
||||
canvas.removeEventListener('touchend', onTouchEnd);
|
||||
canvas.removeEventListener('click', onTapTarget);
|
||||
removeInput();
|
||||
offShadow?.();
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
app.ticker.remove(frame);
|
||||
app.ticker.remove(preRender);
|
||||
app.ticker.remove(postRender);
|
||||
@@ -1829,6 +1861,7 @@ export async function createFieldView(host, options = {}) {
|
||||
try { board3dCanvas?.remove(); } catch { /* ignore */ }
|
||||
releaseGl(app.renderer);
|
||||
try { app.destroy(true, { children: true, texture: false, baseTexture: false }); } catch { /* ignore */ }
|
||||
canvas.remove();
|
||||
},
|
||||
stats() {
|
||||
return {
|
||||
@@ -1848,5 +1881,22 @@ export async function createFieldView(host, options = {}) {
|
||||
pick: { pieceAt, battleUnitAt, penUnitAt, groundTile },
|
||||
},
|
||||
};
|
||||
// All teardown dependencies now exist. Aborting during either asset wait immediately releases
|
||||
// the renderer, ticker and listeners; late shared asset results see destroyed and do nothing.
|
||||
setupCleanup.length = 0;
|
||||
onAbort = () => view.destroy();
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
signal?.throwIfAborted();
|
||||
await withTimeout(artPromise, 2500, signal);
|
||||
if (want3d) await withTimeout(boardReady, 6000, signal);
|
||||
signal?.throwIfAborted();
|
||||
return view;
|
||||
} catch (err) {
|
||||
if (view) view.destroy();
|
||||
else {
|
||||
destroyed = true;
|
||||
for (const dispose of setupCleanup.reverse()) { try { dispose(); } catch { /* continue cleanup */ } }
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,22 @@ import { createAssets, assets as defaultAssets } from '../../assets.js';
|
||||
import { standInRecord } from '../../../../shared/standIn.js';
|
||||
import { diyRecordOf } from '../../../../shared/diy.js';
|
||||
|
||||
function withTimeout(p, ms) {
|
||||
return Promise.race([p, new Promise((resolve) => setTimeout(resolve, ms))]);
|
||||
/** Wait for optional assets, or stop waiting when this view is cancelled. Shared loads keep running. */
|
||||
function withTimeout(p, ms, signal) {
|
||||
let timer;
|
||||
let onAbort;
|
||||
const stopped = new Promise((resolve, reject) => {
|
||||
if (ms != null) timer = setTimeout(resolve, ms);
|
||||
if (signal) {
|
||||
onAbort = () => reject(signal.reason);
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
if (signal.aborted) onAbort();
|
||||
}
|
||||
});
|
||||
return Promise.race([p, stopped]).finally(() => {
|
||||
clearTimeout(timer);
|
||||
if (onAbort) signal.removeEventListener('abort', onAbort);
|
||||
});
|
||||
}
|
||||
|
||||
/** Wrap whatever the caller passed as `assets` into the store API of public/js/assets.js. */
|
||||
|
||||
Reference in new issue
Block a user