feedback7: merge fb7-hud — HP-bar readouts (#303 / #378 / #383: ammo cells, 伺夜's wolf-shadow pips, 斩业星熊's 我执 bar), #302 renderer abort, #292 held skill cue, #334 atlas slots, #354 梓兰's per-card frost
- Grok review: MERGE (no defect). Golden unchanged (union OK); broad node --test 5327 (5326 pass, 0 fail, 1 skipped); lint 0 errors.
This commit is contained in:
commit
7d6fefe8f5
33 files changed
+1751
-77
No files matched your search
+61
-14
@@ -132,8 +132,32 @@ function renderPref() {
|
||||
return globalThis.__SP_RENDER__ === 'fallback' ? 'fallback' : 'auto';
|
||||
}
|
||||
|
||||
function withTimeout(p, ms, what) {
|
||||
return Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(`${what} timed out`)), ms))]);
|
||||
function abortError(signal) {
|
||||
return signal?.reason ?? new DOMException('Field view initialization cancelled', 'AbortError');
|
||||
}
|
||||
|
||||
function checkActive(signal) {
|
||||
if (signal?.aborted) throw abortError(signal);
|
||||
}
|
||||
|
||||
function withTimeout(p, ms, what, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let timer;
|
||||
const finish = (done, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
done(value);
|
||||
};
|
||||
const onAbort = () => finish(reject, abortError(signal));
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
timer = setTimeout(() => finish(reject, new Error(`${what} timed out`)), ms);
|
||||
// Keep a rejection handler attached even when cancellation wins before this promise settles.
|
||||
Promise.resolve(p).then((value) => finish(resolve, value), (err) => finish(reject, err));
|
||||
if (signal?.aborted) onAbort();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,34 +210,55 @@ export function seedAssets(store) {
|
||||
/**
|
||||
* Create a field view in `host`: the render engine when available, else the DOM fallback.
|
||||
* @param {HTMLElement} host
|
||||
* @param {{ signal?: AbortSignal }} [options] Cancels a pending mount without creating a fallback.
|
||||
* @returns {Promise<ReturnType<typeof guardView>>}
|
||||
*/
|
||||
export async function mountFieldView(host) {
|
||||
export async function mountFieldView(host, { signal } = {}) {
|
||||
checkActive(signal);
|
||||
const pref = renderPref();
|
||||
const opts = { data, assets: data.get('assets'), audio, settings: settingsStore.get(), padding: hudPadding, hud: hudBands };
|
||||
if (pref !== 'fallback') {
|
||||
const controller = new AbortController();
|
||||
const onAbort = () => controller.abort(abortError(signal));
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
if (signal?.aborted) onAbort();
|
||||
let engineView = null;
|
||||
const dispose = (view) => { try { view?.destroy?.(); } catch { /* ignore */ } };
|
||||
try {
|
||||
checkActive(controller.signal);
|
||||
// the shared asset store (public/js/assets.js) keeps its Spine cache across remounts (next match, reconnect)
|
||||
const am = await withTimeout(import('../assets.js'), LOAD_TIMEOUT_MS, 'asset store import').catch(() => null);
|
||||
const am = await withTimeout(import('../assets.js'), LOAD_TIMEOUT_MS, 'asset store import', controller.signal).catch(() => {
|
||||
checkActive(controller.signal);
|
||||
return null;
|
||||
});
|
||||
checkActive(controller.signal);
|
||||
if (am?.assets && typeof am.assets.ready === 'function') {
|
||||
opts.assets = am.assets;
|
||||
seedAssets(am.assets);
|
||||
}
|
||||
const mod = await withTimeout(import('../render/app.js'), LOAD_TIMEOUT_MS, 'render engine import');
|
||||
const mod = await withTimeout(import('../render/app.js'), LOAD_TIMEOUT_MS, 'render engine import', controller.signal);
|
||||
checkActive(controller.signal);
|
||||
if (typeof mod?.createFieldView !== 'function') throw new Error('createFieldView missing');
|
||||
const view = await withTimeout(Promise.resolve(mod.createFieldView(host, opts)), LOAD_TIMEOUT_MS, 'createFieldView');
|
||||
// A factory that ignores cancellation still belongs to this attempt when it eventually returns.
|
||||
const pending = Promise.resolve(mod.createFieldView(host, { ...opts, signal: controller.signal })).then((view) => {
|
||||
if (controller.signal.aborted) dispose(view); else engineView = view;
|
||||
return view;
|
||||
});
|
||||
const view = await withTimeout(pending, LOAD_TIMEOUT_MS, 'createFieldView', controller.signal);
|
||||
checkActive(controller.signal);
|
||||
const missing = METHODS.filter((k) => typeof view?.[k] !== 'function');
|
||||
if (missing.length) {
|
||||
try { view?.destroy?.(); } catch { /* ignore */ }
|
||||
throw new Error(`view lacks ${missing.join(', ')}`);
|
||||
}
|
||||
if (missing.length) throw new Error(`view lacks ${missing.join(', ')}`);
|
||||
return guardView(view, 'engine');
|
||||
} catch (err) {
|
||||
controller.abort();
|
||||
dispose(engineView);
|
||||
checkActive(signal);
|
||||
console.warn('[field] render engine unavailable, using the simplified view:', err?.message || err);
|
||||
// anything the engine left behind in the host goes
|
||||
try { while (host.firstChild) host.removeChild(host.firstChild); } catch { /* ignore */ }
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
}
|
||||
}
|
||||
checkActive(signal);
|
||||
return guardView(createFallbackView(host, { ...opts, assets: data.get('assets') }), 'fallback');
|
||||
}
|
||||
|
||||
@@ -228,17 +273,19 @@ export function useFieldView(hostRef) {
|
||||
let dead = false;
|
||||
const host = hostRef.current;
|
||||
if (!host) return undefined;
|
||||
mountFieldView(host).then((view) => {
|
||||
const controller = new AbortController();
|
||||
mountFieldView(host, { signal: controller.signal }).then((view) => {
|
||||
if (dead) { view.destroy(); return; }
|
||||
viewRef.current = view;
|
||||
globalThis.__SP_VIEW__ = view; // dev / E2E introspection (view.raw.stats?.())
|
||||
setState({ view, kind: view.kind });
|
||||
}, (err) => console.error('[field] mount failed', err));
|
||||
}, (err) => { if (!dead && !controller.signal.aborted) console.error('[field] mount failed', err); });
|
||||
const unsub = settingsStore.subscribe((s) => viewRef.current?.setSettings?.(s));
|
||||
const onResize = () => viewRef.current?.resize();
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => {
|
||||
dead = true;
|
||||
controller.abort();
|
||||
unsub();
|
||||
window.removeEventListener('resize', onResize);
|
||||
// the dev hook must not keep the destroyed view — and through its host the whole detached match screen — alive
|
||||
|
||||
+10
-2
@@ -57,6 +57,14 @@ export function mergeHint(priv, chessId) {
|
||||
return mergeTarget(priv, chessId, LOOKUPS.getChess) ? t('精锐干员将出现在作战区原位置') : t('精锐干员将进入整备区');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a shop card shows its frost: its own slot's `frozen` (m.private shop.slots[i].frozen) — the 冻结 toggle copies onto
|
||||
* every unsold slot, and 梓兰's 猎头顾问 freezes ONE copied card with every active refresh while the toggle stays off
|
||||
* (GitHub #354: the bar drew only the toggle, so the frozen card showed no frost) — or the toggle itself (a frame without
|
||||
* per-slot flags). The 冻结 button and the bar's frame keep following the toggle alone.
|
||||
*/
|
||||
export const slotFrozen = (slot, toggle = false) => !!slot?.frozen || !!toggle;
|
||||
|
||||
/** The armed (first-tapped) card's confirm strip: 确认购买 / 确认选择, or 无法购买 + why. */
|
||||
function ArmedTag({ reason, free }) {
|
||||
if (reason) return html`<span class="scard__confirm is-no" role="status"><b>${t('无法购买')}</b><small>${reason}</small></span>`;
|
||||
@@ -350,7 +358,7 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel,
|
||||
${chessSlots.map(({ s, i }) => {
|
||||
if (!s || s.sold) return html`<${SoldCard} key=${`s${i}`} />`;
|
||||
const reason = shopBlockReason('buy', { priv, editable, slot: s, ...LOOKUPS });
|
||||
return html`<${ChessCard} key=${`c${i}:${s.id}`} slot=${s} idx=${i} priv=${priv} frozen=${frozen} onBuy=${onBuy} onDetail=${onDetail} offBonds=${offBonds}
|
||||
return html`<${ChessCard} key=${`c${i}:${s.id}`} slot=${s} idx=${i} priv=${priv} frozen=${slotFrozen(s, frozen)} onBuy=${onBuy} onDetail=${onDetail} offBonds=${offBonds}
|
||||
reason=${reason} armed=${armed === armKey('c', i, s)} onTap=${editable ? (idx) => tapCard('c', idx, s, 'chess', reason, onBuy) : null} />`;
|
||||
})}
|
||||
</div>`}
|
||||
@@ -359,7 +367,7 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel,
|
||||
? itemSlots.map(({ s, i }) => {
|
||||
if (s.sold) return html`<${SoldCard} key=${`is${i}`} item=${true} />`;
|
||||
const reason = shopBlockReason('buy', { priv, editable, slot: s, ...LOOKUPS });
|
||||
return html`<${ItemCard} key=${`i${i}:${s.id}`} slot=${s} idx=${i} frozen=${frozen} onBuy=${onBuy} onDetail=${onDetail} reason=${reason}
|
||||
return html`<${ItemCard} key=${`i${i}:${s.id}`} slot=${s} idx=${i} frozen=${slotFrozen(s, frozen)} onBuy=${onBuy} onDetail=${onDetail} reason=${reason}
|
||||
armed=${armed === armKey('i', i, s)} onTap=${editable ? (idx) => tapCard('i', idx, s, 'item', reason, onBuy) : null} />`;
|
||||
})
|
||||
: html`<${SoldCard} item=${true} />`}
|
||||
|
||||
Reference in new issue
Block a user