Files
ark-stronghold-protocol/public/js/ui/fieldHost.js
T
sganggs 15291c2293 Playtest #5 fixes: hovering enemies, target order, blocking hand-over, 联防 knocked-out, elements, huge-boss hit area, maps, gear icon, phone HUD, 至简 price
- #1 近地悬浮 enemies count as air units (Unit.isFlying): ground-only attacks, splash, terrain and traps skip them;
  PRTS 不可对空 skill effects made ground-only; 掠海漂移体 / 吉兆飞鳞 drop modes; the bot plans them as air
- #2 an operator knocked out before 联防 is fielded and force-exited (PRTS 强制退场): it lies on its tile with the
  redeploy ring and redeploys (full timer [ASSUMED]); partners, observers and the server fallback included
- #3 element audit of every operator, item and enemy: 海嗣 numbers are official; bursts are 无来源, 元素脆弱 only
  raises 元素伤害, 元素抗性 applied, 盟约·辅助干员 fills all three gauges, 菲莱 / 哈洛德 / 纯烬 / enemy kits fixed
- #4 blocking by the official contact radius (0.7071): another operator in contact takes over at once when the
  blocker dies, withdraws or is stunned; melee blockers attack what they block
- #5 enemy target order per PRTS 索敌: blocker, special priority, taunt, last deployed; initial deploy 从上到下>从左到右,
  operators before summons; special priorities break ties by taunt then deploy order
- #6 act2 m01's normal-field blower machines (row 13) are drawn in the normal / 联防 views
- #7 Final Assault prep legality reads the player's half of the boss field (fence tiles deployable); all 11 stages
  audited against the official level files
- #8 regular settings gear; #9 the prep camera keeps the bench and temp rows clear of the shop bar on phones
- #10 huge bosses have the official 4.95 × 2.95 hit area (sim/body.js) and are 自缚 / unblockable
- #11 至简 costs 1: 购买价格为N's bb.price is a discount off the tier price, applied before other price effects
- DESIGN §19 and the normative §3 / §5 / §6 / §7 lines, SIM / META / DATA / PLAYING updated; 2595 unit tests and
  every browser suite green; dev recordings regenerated
2026-09-30 19:12:36 +08:00

182 lines
9.7 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.
//
// `?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) — render/app.js; the DOM fallback lacks them (→ null)
const OPTIONAL = ['pieceScreenRect', 'setSettings', 'off', 'tileScreen', 'holdPiece', 'setPieceDir', 'setPen', 'prepField', 'stripesUnder'];
/**
* 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).
*/
export const HUD_REM = Object.freeze({ bondStripBottom: 2.16, shopBarTop: 2.64, shopBarBorderPx: 3 });
/**
* CSS px of HUD along the top edge (top bar + bond strip) and the bottom edge (the shop bar) 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 prep camera is the shop camera whether or not the bar is collapsed, so the band assumes the bar —
* 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). 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
* @returns {{ top: number, bottom: number }|null}
*/
export function hudBands(kind, size) {
if (kind !== 'prep' && kind !== 'bossPrep') return null;
let rem = 100;
let safeTop = 0;
try {
rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 100;
// the HUD layer starts below the top safe-area inset (css/devices.css .gm__hud)
safeTop = Math.max(0, document.querySelector('.gm__hud')?.getBoundingClientRect().top || 0);
} catch { /* ignore */ }
const h = size?.height || 1080;
return {
top: Math.min(h * 0.4, safeTop + rem * HUD_REM.bondStripBottom),
bottom: Math.min(h * 0.4, rem * HUD_REM.shopBarTop + HUD_REM.shopBarBorderPx),
};
}
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 withTimeout(p, ms, what) {
return Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(`${what} timed out`)), ms))]);
}
/**
* 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;
}
/**
* Create a field view in `host`: the render engine when available, else the DOM fallback.
* @param {HTMLElement} host
* @returns {Promise<ReturnType<typeof guardView>>}
*/
export async function mountFieldView(host) {
const pref = renderPref();
const opts = { data, assets: data.get('assets'), audio, settings: settingsStore.get(), padding: hudPadding, hud: hudBands };
if (pref !== 'fallback') {
try {
// 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);
if (am?.assets && typeof am.assets.ready === 'function') opts.assets = am.assets;
const mod = await withTimeout(import('../render/app.js'), LOAD_TIMEOUT_MS, 'render engine import');
if (typeof mod?.createFieldView !== 'function') throw new Error('createFieldView missing');
const view = await withTimeout(Promise.resolve(mod.createFieldView(host, opts)), LOAD_TIMEOUT_MS, 'createFieldView');
const missing = METHODS.filter((k) => typeof view?.[k] !== 'function');
if (missing.length) {
try { view?.destroy?.(); } catch { /* ignore */ }
throw new Error(`view lacks ${missing.join(', ')}`);
}
return guardView(view, 'engine');
} catch (err) {
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 */ }
}
}
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 viewRef = useRef(null);
useEffect(() => {
let dead = false;
const host = hostRef.current;
if (!host) return undefined;
mountFieldView(host).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));
const unsub = settingsStore.subscribe((s) => viewRef.current?.setSettings?.(s));
const onResize = () => viewRef.current?.resize();
window.addEventListener('resize', onResize);
return () => {
dead = true;
unsub();
window.removeEventListener('resize', onResize);
// 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;
};
}, []);
return state;
}