Files
ark-stronghold-protocol/public/js/ui/fieldHost.js
T

315 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Field view loader: mounts the Pixi render engine (public/js/render/app.js → createFieldView, DESIGN §9)
// into a host element, falling back to the DOM view (fallbackField.js) when the engine is missing, times
// out or throws. Every call into the view goes through a guard so a render bug can never crash the HUD.
// The shared asset store gets the game data's copy of the asset manifest (`seedAssets`, public issue #8 item 5), and the
// prep cameras the HUD bands they keep clear (`hudBands`; the folded shop's band: public issue #5).
//
// `?render=fallback` (or globalThis.__SP_RENDER__ = 'fallback') skips the engine (dev / mock harness);
// `?render=engine` never falls back silently (errors are logged and the fallback still mounts).
// relative, like every other client module (the import map only resolves to this same URL; no bare specifier here)
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { createFallbackView } from './fallbackField.js';
import { data } from '../data.js';
import { audio } from '../audio.js';
import { settingsStore } from './settings.js';
const LOAD_TIMEOUT_MS = 12000;
const METHODS = ['setStage', 'setCamera', 'setPrep', 'enterBattle', 'pushSnapshot', 'pushEvents', 'highlightTiles', 'on', 'resize', 'destroy'];
// direction-step hooks (ui/facingWheel.js): optional — the wheel falls back to the engine's dev hooks when absent;
// setPen (enemy preview pen list), prepField ({ kind, side, mirror } of the Final Assault prep), stripesUnder (the view
// stripes range previews under the units itself), replayDeploys (the round start's Start-clip replay, PR #416) —
// render/app.js; the DOM fallback lacks them (→ null)
const OPTIONAL = ['pieceScreenRect', 'setSettings', 'off', 'tileScreen', 'holdPiece', 'setPieceDir', 'setPen', 'prepField', 'stripesUnder', 'replayDeploys'];
/**
* Camera padding (px) that keeps the field clear of the DOM HUD (top bar + bond strip, team panel, shop bar /
* combat switcher, effects column). Sizes follow the rem scale of css/theme.css.
* The Final Assault prep ('bossPrep') has the prep HUD (shop bar below) on the boss field: prep padding; the enemy pen
* ('pen') is viewed with the shop collapsed.
* @param {string} kind 'prep'|'bossPrep'|'pen'|'normal'|'unite'|'boss'|'hidden'
* @param {{ width: number, height: number }} size
*/
export function hudPadding(kind, size) {
let rem = 100;
try { rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 100; } catch { /* ignore */ }
const h = size?.height || 1080;
const clampH = (v) => Math.min(v, h * 0.4);
if (kind === 'prep' || kind === 'bossPrep') return { top: clampH(rem * 1.95), bottom: clampH(rem * 3.2), left: rem * 2.25, right: rem * 0.9 };
if (kind === 'boss' || kind === 'hidden' || kind === 'unite') return { top: clampH(rem * 1.9), bottom: clampH(rem * 0.95), left: rem * 2.1, right: rem * 0.95 };
return { top: clampH(rem * 1.95), bottom: clampH(rem * 1.0), left: rem * 2.25, right: rem * 1.05 };
}
/**
* HUD geometry (rem) the prep cameras keep clear (they mirror the CSS; test/ui/playtest5-ui.test.js checks the rules):
* the bond strip's bottom edge (css/screens/game.css .gm__bonds top 1.36rem + a .bslot: disc .52rem + name ≈
* 2.15rem measured) and the shop bar's top edge above the viewport's bottom (css/screens/game-shop.css .shopbar
* bottom .2rem + .shopbar__row padding .1rem ×2 + card height 2.24rem, plus its 2 px + 1 px borders). The shop bar
* sits on the viewport's bottom edge even on a notched phone (css/devices.css, DESIGN §18.1).
* The folded shop (public issue #5): the tab's top edge (.shopbar-tab bottom .2rem + padding .08rem ×2 + its .44rem
* button, plus its 2 px + 1 px borders; it sits inside the HUD layer, above the bottom safe-area inset) and the corner
* buttons' top edge (css/screens/game.css .gm__corner bottom .24rem + a .56rem row — the fallback when the corner
* cannot be measured: on phones its buttons grow to 34 px and below 768 CSS px of width they wrap into two rows,
* css/devices.css).
*/
export const HUD_REM = Object.freeze({
bondStripBottom: 2.16, shopBarTop: 2.64, shopBarBorderPx: 3, shopTabTop: 0.8, shopTabBorderPx: 3, cornerTop: 0.8,
});
/**
* CSS px of HUD along the top edge (top bar + bond strip) and the bottom edge of the viewport during prep — the own
* board ('prep') or the Final Assault half ('bossPrep'); null for every other camera. The prep camera keeps the bench /
* temp rows and the field's back row clear of them (render/projection.js clearHud; user playtest #5 item 9: the rem
* floor of 40 px makes the HUD relatively taller on phones in landscape and the shop bar covered the bench).
* The bottom band is the shop bar's — also for an eliminated player's own board (no shop bar: the band only costs size
* there, while a camera following the bar's presence would have to re-frame whenever it appears, e.g. when the private
* state arrives after the prep camera was set) — unless `opts.shop === false`: the player folded the shop (收起) and the
* own prep board moved to the official shop-collapsed camera (public issue #5: the board did not grow; screens/game.js
* asks for it only while a folded bar is shown). Its band is the higher of the folded tab (bottom right) and the corner
* buttons' hit areas (交流 / ⚙ / 📖 / ⛶, bottom left, measured by `cornerBand`: on a narrow phone two rows of 34 px buttons
* with 44 px touch areas) — under the official collapsed camera the bench's left pads reach under the corner, and with
* the tab alone it would cover bench pad 0 at the user's 756×366 Android and the near edge of pads 0–2 at 844×390; both
* are 1-D bands like the bar's, so every bench pad stays fully pressable. Scouting a
* teammate's board uses the 'normal' camera: no band. An armed shop card (two-tap buy, css/screens/game-shop.css
* .scard.is-armed) rises 4 px above the bar's top on a phone and covers the bench pads' near corners by ≈ 3 px while it
* stays armed — less than under the unchanged official camera at 1920×1080 (13 px above the bar, ≈ 11 px over the pads).
* @param {string} kind
* @param {{ width: number, height: number }} size
* @param {{ shop?: boolean }} [opts] `shop: false` = the folded shop's band
* @returns {{ top: number, bottom: number }|null}
*/
export function hudBands(kind, size, opts) {
if (kind !== 'prep' && kind !== 'bossPrep') return null;
const folded = !!opts && opts.shop === false;
const h = size?.height || 1080;
let rem = 100;
let safeTop = 0;
let safeBottom = 0;
let corner = 0;
try {
rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 100;
// the HUD layer starts below the top safe-area inset and ends above the bottom one (css/devices.css .gm__hud)
const hud = document.querySelector('.gm__hud')?.getBoundingClientRect();
safeTop = Math.max(0, hud?.top || 0);
if (folded) {
if (hud && hud.bottom > 0) safeBottom = Math.max(0, h - hud.bottom);
corner = cornerBand(h);
}
} catch { /* ignore */ }
const bottom = folded
? Math.max(safeBottom + rem * HUD_REM.shopTabTop + HUD_REM.shopTabBorderPx, corner || safeBottom + rem * HUD_REM.cornerTop)
: rem * HUD_REM.shopBarTop + HUD_REM.shopBarBorderPx;
return {
top: Math.min(h * 0.4, safeTop + rem * HUD_REM.bondStripBottom),
bottom: Math.min(h * 0.4, bottom),
};
}
/**
* CSS px from the viewport's bottom edge (`h` high) up to the top of the corner buttons' hit areas (交流 / ⚙ / 📖 / ⛶,
* .gm__corner), 0 when they are not on the page. On a touch screen every one of them takes taps from an invisible area
* of at least --tap-min (44 px) centred on it (css/devices.css touch targets), 5 px above a 34 px phone button — a bench
* pad edge there is not pressable.
* @param {number} h
*/
function cornerBand(h) {
const btns = document.querySelectorAll('.gm__corner .gm__gear, .gm__corner .ewheel__btn');
if (!btns || !btns.length) return 0;
const root = document.documentElement;
const tap = root?.classList?.contains('sp-coarse') ? parseFloat(getComputedStyle(root).getPropertyValue('--tap-min')) || 0 : 0;
let top = Infinity;
for (const b of btns) {
const r = b.getBoundingClientRect();
if (r && r.height > 0) top = Math.min(top, r.top + r.height / 2 - Math.max(r.height, tap) / 2);
}
return top < Infinity ? Math.max(0, h - top) : 0;
}
function renderPref() {
try {
const q = new URLSearchParams(globalThis.location?.search || '').get('render');
if (q === 'fallback' || q === 'engine') return q;
} catch { /* ignore */ }
return globalThis.__SP_RENDER__ === 'fallback' ? 'fallback' : 'auto';
}
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();
});
}
/**
* Wrap a view so no method can throw into the UI (each failing method is logged once).
* @param {any} view
* @param {'engine'|'fallback'} kind
*/
export function guardView(view, kind) {
const warned = new Set();
const safe = { kind, raw: view };
for (const name of [...METHODS, ...OPTIONAL]) {
safe[name] = (...args) => {
const fn = view && view[name];
if (typeof fn !== 'function') return name === 'on' ? () => {} : null;
try {
const out = fn.apply(view, args);
if (out && typeof out.then === 'function') out.catch((err) => { if (!warned.has(name)) { warned.add(name); console.warn(`[field] ${name} failed`, err); } });
if (name === 'on') return typeof out === 'function' ? out : () => { try { view.off?.(args[0], args[1]); } catch { /* ignore */ } };
return out;
} catch (err) {
if (!warned.has(name)) { warned.add(name); console.warn(`[field] ${kind} view ${name} failed`, err); }
return name === 'on' ? () => {} : null;
}
};
}
return safe;
}
/**
* Hand the game data's copies of /data/assets.json and /data/local-assets.json (data.js 'assets' / 'local': the match
* screen waits for them, gameComponents GAME_FILES) to the asset store, now or when they land: it then never downloads
* the manifest a second time — after a page reload (a phone browser discarding a background tab, Chrome's Memory Saver)
* that second download was the one createFieldView waited ≤ 4 s for, and a slow or failed one left every operator the
* image-less placeholder (public issue #8 item 5). Without a copy the store fetches and retries by itself.
* @param {{ seed?: (m: any) => boolean, seedLocal?: (m: any) => boolean }} store public/js/assets.js store
*/
export function seedAssets(store) {
if (!store) return;
const give = (name, fn) => {
if (typeof fn !== 'function') return;
const now = data.get(name);
if (now) { fn(now); return; }
// still loading (or failed): adopt it if it lands; a later success of the store's own fetch makes this a no-op
Promise.resolve(data.load(name)).then((m) => { if (m) fn(m); }, () => {});
};
give('assets', store.seed?.bind(store));
give('local', store.seedLocal?.bind(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, { 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', 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', controller.signal);
checkActive(controller.signal);
if (typeof mod?.createFieldView !== 'function') throw new Error('createFieldView missing');
// 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) 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);
} finally {
signal?.removeEventListener('abort', onAbort);
}
}
checkActive(signal);
return guardView(createFallbackView(host, { ...opts, assets: data.get('assets') }), 'fallback');
}
/**
* Preact hook: mount a field view into `hostRef` once; returns { view, kind } (view null while loading).
* @param {{ current: HTMLElement|null }} hostRef
*/
export function useFieldView(hostRef) {
const [state, setState] = useState({ view: null, kind: 'loading' });
const [attempt, setAttempt] = useState(0);
const retries = useRef(0);
const viewRef = useRef(null);
useEffect(() => {
let dead = false, retrying = false;
const host = hostRef.current;
if (!host) return undefined;
setState({ view: null, kind: 'loading' });
const controller = new AbortController();
mountFieldView(host, { signal: controller.signal }).then((view) => {
if (dead) { view.destroy(); return; }
viewRef.current = view;
if (view.kind === 'engine') retries.current = 0;
globalThis.__SP_VIEW__ = view; // dev / E2E introspection (view.raw.stats?.())
setState({ view, kind: view.kind });
}, (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();
// A temporary network/vendor failure must not pin this mounted match to fallback forever. Retry on a useful
// user event, at most twice in succession; the effect cleanup disposes the old view before creating its successor.
const onRetry = () => {
if (dead || retrying || viewRef.current?.kind !== 'fallback' || renderPref() === 'fallback'
|| document.visibilityState !== 'visible' || globalThis.navigator?.onLine === false || retries.current >= 2) return;
retrying = true; retries.current++; setAttempt((n) => n + 1);
};
window.addEventListener('resize', onResize);
window.addEventListener('online', onRetry);
document.addEventListener('visibilitychange', onRetry);
return () => {
dead = true;
controller.abort();
unsub();
window.removeEventListener('resize', onResize);
window.removeEventListener('online', onRetry);
document.removeEventListener('visibilitychange', onRetry);
// the dev hook must not keep the destroyed view — and through its host the whole detached match screen — alive
if (viewRef.current && globalThis.__SP_VIEW__ === viewRef.current) globalThis.__SP_VIEW__ = null;
viewRef.current?.destroy();
viewRef.current = null;
};
}, [attempt]);
return state;
}