Files
ark-stronghold-protocol/public/js/render/app.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

1783 lines
90 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.
// render/app.js — battlefield view (DESIGN §9). PixiJS 7 (global PIXI) + pixi-spine (PIXI.spine), loaded on demand
// from /vendor when the page did not include them as classic <script> tags.
//
// const view = await createFieldView(host, { data, assets, audio, settings, padding, hud })
// view.setStage(stage) procedural tiles + devices (data/stages.json entry)
// view.setCamera(kind, { rect, side, padding, instant }) 'prep'|'normal'|'unite'|'boss'('hidden'); animated
// — the prep cameras (own board / Final Assault half) keep the bench
// and the field clear of `hud(kind, size)` = { top, bottom } px of
// DOM HUD along the top / bottom edge (projection.js clearHud; user
// playtest #5 item 9: the shop bar covered the bench on phones)
// view.setPrep(privateState, { editable, canPlace }) hand/temp/board pieces; editable enables drag & drop
// view.enterBattle(fieldMeta) m.field { fieldId, kind, rect, stageId, units: [UnitInfo] }
// view.pushSnapshot(snap); view.pushEvents(ev | { ev, gt }) b.snap / b.ev wire frames as received (game time in
// `gt`; a numeric `t` is accepted for raw Battle snapshots / recordings)
// — 100 ms interpolation buffer; b.snap `down` keeps knocked-out
// operators on the field under a redeploy ring and `elem` draws the
// element gauges (user playtest #4 items 8 / 9, render/units.js); a
// 'die' with reason FORCED_EXIT (an operator entering 联防 knocked out,
// user playtest #5 item 2) goes straight to the held pose, no burst
// view.setLocalFeed({ on, speed }) frames come from the local sim every frame (client-side combat):
// ~2-frame buffer at the battle's game speed
// view.highlightTiles(tiles, style) [[r,c]] | [{row,col}]; style 'legal'|'illegal'|'range'|'rangeStand'|
// 'hover'|'target'|{color,fill,line,group}; highlightTiles(null) clears all
// view.on(name, fn) → unsubscribe ; view.off(name, fn)
// pieceDragStart { uid, piece, from } · pieceDrop { uid, piece, from, target } · pieceDragEnd {uid, dropped}
// pieceClick { uid, piece, button, detail, clientX, clientY } (battle units: { unitId, uid, unit, … })
// pieceDetail (right-click / long-press) · pieceHover { uid } | { uid: null } (battle: + unitId, unit)
// tileHover { row, col, area, idx } | null (while dragging: the drop target — the tile under the pointer)
// view.pieceScreenRect(uid) → { left, top, right, bottom, width, height, x, y } (client px: the drawn body) | null
// Picking (user playtest #4 item 1: the ground is drawn as tiles — a press on a tile is a press on the unit standing
// there): every "which unit is under the pointer" — prep press / click / detail / drag start / hover (pieceAt), battle
// clicks and hover (battleUnitAt), the pen (penUnitAt) — is render/pick.js over the tile under the pointer (groundTile:
// raised tops first); battle enemies, which walk between tiles, by their ground position (flying ones by their drawn
// body; a huge boss also anywhere on its hit area — data/enemies.json `hitArea`, user playtest #5). A dragged piece
// is held under the pointer — a unit with its drawn feet DRAG_HOLD_TILES below it (the pointer on its body), an item
// plate centred on it — and drops on the tile under the pointer (render/drag.js); an item dropped on a unit's tile
// equips that unit.
// Direction step (ui/facingWheel.js, research 09 §1.2):
// view.tileScreen(row, col) → { x, y, s, poly: [[x,y]×4] } (client px: tile-top centre, px per tile, corners) | null
// view.holdPiece(uid, {row,col} | null) keep a dropped prep piece standing on a tile while its direction is
// chosen / the move is in flight (null releases; setPrep then settles it)
// view.setPieceDir(uid, dir) show a prep piece facing UP|RIGHT|DOWN|LEFT (wheel preview / stored dir)
// view.setSettings({ damageNumbers, quality: 'high'|'medium'|'low' }) ; view.resize() ; view.destroy()
// view.stats() → { fps, frameMs, cpuMs, renderMs, units, particles, impostor, impostorAtlas, boardArt, … } (dev / perf)
//
// Enemy preview pen (research 09 §2.2 / 08 §4.2, render/pen.js): in prep the next round's enemies idle in the pen
// (rows 14–18 × cols 7–13; upper-gate enemies rows 17–18, lower-gate rows 14–15; spawn-time order, ≤ 3 per tile,
// ≤ 50 figures, elites / bosses always), facing left, no bars, no simulation. Source: `setPrep(priv)` reads
// `priv.nextEnemies`; a read-only scouting board (`enterBattle({ prep: true, nextEnemies })`) shows the teammate's
// pen; `view.setPen(list | null)` sets it directly. A real battle (enterBattle without `prep`) empties it. A tap on a
// pen enemy emits pieceClick { enemyKey, preview: true, unit: { side: 'enemy', defId, enemyKey, preview: true } }.
// view.setCamera('pen') pans to the pen in 0.25 s (configBlackBoard move_time); `view.setCamera(kind, opts)` goes
// back (the same kind without options returns to the exact camera used before the pen).
// The pen is shown ONLY by the pen camera (user playtest #2 item 6: the battlefield with the waiting enemies above it
// looked wrong — after 返回战场 and in every battle): the prep, battle, 联防 and boss cameras neither build its 3D
// area nor draw its 2D rows (`boardArea` / `bandFor`), and its figures hide (`penShown`, kept for the way back); a
// flight to / from the pen shows both while it lasts.
// Final Assault / Hidden Core prep (research 09 §1.2, render/prepfield.js): `view.setCamera('bossPrep', { side })`
// (or 'prep' with a boss-row rect) shows the prep pieces on the player's half of the boss field — board rows 9–12 →
// boss rows 2–5, bench 7 → 0, temp 8 → 1, side 'R' mirrored col c → 20 − c with RIGHT ↔ LEFT. Every public
// coordinate stays in BOARD space (pieceDrop targets, canPlace, highlightTiles, tileScreen, holdPiece, setPieceDir,
// stored `dir`); `view.prepField()` → { kind, side, mirror } and `tileScreen(...).mirror` tell the direction wheel
// that a screen-right swipe means board LEFT on the mirrored half.
// Lost WebGL context of the 3D board: the 2D board takes over at once and the 3D board is rebuilt on a fresh context
// a moment later (up to 3 tries; a context lost again right away counts as a failure; `setBoardMode('2d')` stops it).
//
// Board art: the real 卫戍协议 board textures of the local client are used when installed (render/boardArt.js, crops
// from tools/crop-board-atlas.mjs); otherwise every material is procedural (same geometry, no network errors).
// 3D board (DESIGN §15): with the local art installed and WebGL2 available, the official board is a real three.js
// scene (render/board3d) on a canvas UNDER the (then transparent) Pixi canvas; the TileField keeps only highlights,
// airflow and the grid queries (tiles.setExternal). One camera (render/projection.js) drives both layers — the
// three.js PerspectiveCamera is synced from it every change. Missing art / three / WebGL2, a failed init or a lost
// 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. 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.
// canPlace: `(uid, target, piece) => bool` (the game UI's form: target = {area:'board',row,col}|{area:'hand',idx})
// or `(piece, row, col, target) => bool` (functions declaring ≥ 3 parameters). Throwing ⇒ illegal.
// Piece shape (DESIGN §8.3): { uid, kind: 'chess'|'item'|'token', id, golden, tier, items: [{uid,id}], count, ownerUid }.
// `assets` may be the store from public/js/assets.js or the raw /data/assets.json manifest (it is wrapped);
// `data` is the client data store (public/js/data.js: lookup(file, id)) or plain { chess, tokens, items, enemies } maps.
import { GEO, ANIM, UF } from '../../../shared/constants.js';
import { Camera, presetCamera, lerpCamera, easeInOutCubic, pickTile, normRect } from './projection.js';
import { SnapshotBuffer, frameTime } from './interp.js';
import { TileField } from './tiles.js';
import { UnitView, ItemView, DeviceView } from './units.js';
import { FxSystem, ensureDamageFonts } from './fx.js';
import { createDragController, pieceTile } from './drag.js';
import { backdropTextures, shadowTexture, refreshTierChips, silhouetteTexture } from './textures.js';
import { TILE_H, TIER_COLORS, COLORS } from './style.js';
import { createAssets, assets as defaultAssets } from '../assets.js';
import { loadBoardArt } from './boardArt.js';
import { ImpostorAtlas } from './impostor.js';
import { loadThree, loadBoardPack, webgl2Available, boardArtListed } from './board3d/load.js';
import { BoardScene } from './board3d/scene.js';
import { AREAS, areaFor, unionAreas } from './board3d/layout.js';
import { layoutPen, penSignature } from './pen.js';
import { IDENTITY, bossPrepField, tilesToDisp } from './prepfield.js';
import { pickOnTile, pickBattle, hitRectAt } from './pick.js';
const VENDOR = { pixi: '/vendor/pixi.min.js', spine: '/vendor/pixi-spine.js' };
const PIECE_DIRS = new Set(['UP', 'RIGHT', 'DOWN', 'LEFT']);
/** Stored facing of a prep piece (m.private board pieces carry `dir`; bench pieces have none ⇒ undefined). */
const pieceDirOf = (piece) => (typeof piece?.dir === 'string' && PIECE_DIRS.has(piece.dir.toUpperCase()) ? piece.dir.toUpperCase() : undefined);
const CAMERA_MS = 750;
/** Camera pan to / from the enemy preview pen (configBlackBoard move_time 0.25). */
export const PEN_CAMERA_MS = 250;
/** Recovery of a lost 3D board context: delays of the rebuild attempts (ms) and how soon a new loss counts as failure. */
const BOARD3D_RETRY_MS = [1200, 4000, 12000];
const BOARD3D_STABLE_MS = 10000;
/** Highlight groups that show a unit's range: never drawn on bench / temp pads (they are not part of any battle). */
const RANGE_GROUPS = new Set(['facing', 'range', 'rangeStand', 'select', 'sel', 'selRange']);
/** atk projectile kinds whose first id is the previous bounce target (sim ai.js), not the attacker. */
const CHAIN_KINDS = new Set(['chain', 'chainHeal']);
const DROP_PENDING_MS = 1300;
/**
* A dragged unit is held with its drawn feet this many tiles below the pointer — the pointer on its body, the model
* under the finger / mouse (user playtest #4 item 1: as in v2.1; mouse and touch alike). An item plate is centred on it.
*/
export const DRAG_HOLD_TILES = 0.45;
/**
* A view as a render/pick.js unit, or null when it cannot be picked (gone, faded out, dead): standing on the display
* tile it is drawn on, or — `walks` (battle enemies) — by its ground position and its drawn body (feet to head, on
* screen; a flying one by its body alone).
*/
function pickUnitOf(v, walks = false, hitArea = null) {
// a knocked-out operator lying on its tile waiting to redeploy (b.snap `down`, user playtest #4 item 9) is on that tile
// too: a press there selects it; other dead / dying views are gone
if (!v || v.destroyed || (v.alive === false && !v.down)) return null;
if (Number.isFinite(v.alpha) && v.alpha < 0.05) return null;
const sc = v.screen;
const body = walks && sc && sc.s > 0 && !v.culled ? { x: sc.x, top: Number.isFinite(sc.top) ? sc.top : sc.y, feet: sc.y, s: sc.s } : null;
const tile = walks ? null : { row: Math.round(v.y), col: Math.round(v.x) };
// a huge boss (data `hitArea`): its hit area on the ground and a body box as wide as it are pickable (render/pick.js)
const area = walks && !v.flying ? hitRectAt(v.x, v.y, hitArea) : null;
if (area && body) body.hw = hitArea.w / 2;
return { tile, x: v.x, y: v.y, fly: walks && !!v.flying, body, area, depth: v.root && !v.root.destroyed ? v.root.zIndex : 0, ref: v };
}
let pixiPromise = null;
function loadScript(src) {
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = src;
s.async = false;
s.onload = () => resolve();
s.onerror = () => reject(new Error(`failed to load ${src}`));
document.head.appendChild(s);
});
}
/** Make sure PIXI and PIXI.spine exist (classic scripts; loaded once). */
export function ensurePixi() {
if (globalThis.PIXI && globalThis.PIXI.spine) return Promise.resolve(globalThis.PIXI);
if (!pixiPromise) {
pixiPromise = (async () => {
if (!globalThis.PIXI) await loadScript(VENDOR.pixi);
if (!globalThis.PIXI?.spine) await loadScript(VENDOR.spine);
if (!globalThis.PIXI || !globalThis.PIXI.spine) throw new Error('PIXI / pixi-spine unavailable');
return globalThis.PIXI;
})();
pixiPromise.catch(() => { pixiPromise = null; });
}
return pixiPromise;
}
const VIEW_KINDS = new Set(['prep', 'normal', 'unite', 'boss', 'bossPrep', 'pen']);
/**
* The field a camera request actually shows — what render/projection.js presetCamera frames: 'hidden' → 'boss';
* a 'prep' camera asked for the boss rows (Final Assault prep: rect r1 ≤ 6) → 'bossPrep'; unknown kinds → 'normal'.
* Drives the built 3D area, the drawn 2D rows and the lit rect, so they always match the camera.
*/
export function viewKind(kind, opts) {
let k = kind === 'hidden' ? 'boss' : kind;
if (!VIEW_KINDS.has(k)) k = 'normal';
if (k === 'prep' && opts && opts.rect && normRect(opts.rect).r1 <= 6) k = 'bossPrep';
return k;
}
/** 3D areas without the enemy preview pen block (the own field / both normal halves); see `boardArea`. */
const AREA_NO_PEN = Object.freeze({
normal: Object.freeze(AREAS.normal.filter((a) => a.r1 <= 13)),
unite: Object.freeze(AREAS.unite.filter((a) => a.r1 <= 13)),
});
/**
* 3D area built for a view kind (viewKind): the enemy preview pen (rows 14–18) only for the 'pen' camera — the prep,
* battle and 联防 cameras show the field alone (user playtest #2 item 6) with its separator rows 6 and 13 (the row-13
* devices blow into the field: act2 m01's blowers, user playtest #5 item 6; the boss field's row-6 devices are drawn
* with the boss field only — board3d/layout.js stageDevices); the boss kinds build the boss field.
*/
export function boardArea(vk) {
if (vk === 'pen') return AREAS.normal;
if (vk === 'prep' || vk === 'normal') return AREA_NO_PEN.normal.length ? AREA_NO_PEN.normal : AREAS.normal;
if (vk === 'unite') return AREA_NO_PEN.unite.length ? AREA_NO_PEN.unite : AREAS.unite;
return areaFor(vk);
}
/**
* 2D rows drawn for a view kind: the pen rows (14–18) only for the 'pen' camera; the boss field with the separator
* and the normal rows behind it as scenery.
*/
export function bandFor(kind) {
if (kind === 'boss' || kind === 'hidden' || kind === 'bossPrep') return [0, 13];
return kind === 'pen' ? [6, 18] : [6, 13];
}
/**
* Active field rows [r0, r1] of a view kind for the 2D board (render/tiles.js `setView` field: drawn rows outside it are
* dim scenery without devices): the normal / 联防 / prep fields live between the separator walls (rows 6–13, the
* devices on the row-13 wall included; the row-6 wall's belong to the boss field — tiles.js _stageDevices); the pen
* camera adds the pen (6–18); the boss field 0–6.
*/
export function fieldRows(kind) {
return kind === 'boss' || kind === 'hidden' || kind === 'bossPrep' ? [0, 6] : kind === 'pen' ? [6, 18] : [6, 13];
}
/** Are the pen's figures shown for a view kind (a camera flight shows them when either end is the pen)? */
export const penShown = (vk, prevVk = null) => vk === 'pen' || prevVk === 'pen';
/**
* 'die' reason of an operator that enters the battle already knocked out — a 联防 helper's operator down at the end of
* its own combat, deployed and forced out at once (server/sim/constants.js FORCED_EXIT, user playtest #5 item 2): its
* view goes straight to the held knocked-down pose (UnitView.die(true)) without the death burst; b.snap `down` keeps it.
*/
export const FORCED_EXIT = 'forcedExit';
/**
* Generic death particles for a battle unit's 'die' event: never for stage devices, nor for a summon used up by its
* own effect (user playtest #2 item 4: 香槟炸弹's blast — sim fx `{ consumed: true, id }` before its 'die' — is its end,
* not a knock-out), nor for an operator entering the battle knocked out (FORCED_EXIT).
*/
export const showsDeathFx = (info, consumed = false, reason = null) => !consumed && reason !== FORCED_EXIT && info?.kind !== 'device';
/** '2d' | '3d' | 'auto' board preference: `?board=` in the page URL (dev), else the view option. */
export function boardPreference(opt) {
let q = null;
try { q = new URLSearchParams(globalThis.location?.search || '').get('board'); } catch { /* no location */ }
const v = q || opt;
return v === '2d' || v === '3d' ? v : 'auto';
}
/**
* A battle device box (render/units.js DeviceView `ctx.createBox()` contract: `{ mesh, update(cam, b), destroy() }`)
* that follows the board layer: the 3D scene's crate mesh while `board()` returns a BoardScene, else the Pixi box of
* the 2D board. A board switch mid-battle (lost WebGL context → 2D, or back to 3D) swaps the inner box on the next
* update instead of leaving the crate on a dead scene. `mesh` is the Pixi mesh (null in 3D: nothing to add to Pixi).
*/
export function switchableBox({ board, pixi }) {
let inner = null, owner;
const drop = () => { try { inner?.destroy(); } catch { /* ignore */ } inner = null; owner = undefined; };
return {
get mesh() { return inner ? inner.mesh || null : null; },
get inner() { return inner; },
update(cam, b) {
const scene = board() || null;
if (!inner || owner !== scene || inner.destroyed) {
drop();
inner = scene ? scene.createDevice() : pixi();
owner = scene;
}
inner?.update(cam, b);
},
destroy: drop,
};
}
function withTimeout(p, ms) {
return Promise.race([p, new Promise((resolve) => setTimeout(resolve, ms))]);
}
/** Wrap whatever the caller passed as `assets` into the store API of public/js/assets.js. */
function resolveAssets(a) {
if (a && typeof a === 'object' && typeof a.ready === 'function' && typeof a.spineEntry === 'function') return a;
if (a && typeof a === 'object' && (a.chars || a.enemies || a.version)) return createAssets({ manifest: a });
return defaultAssets;
}
function makeData(src) {
const look = (file, id) => {
if (id == null || id === '') return null;
try {
if (src && typeof src.lookup === 'function') return src.lookup(file, id) || null;
const m = src && src[file];
if (m instanceof Map) return m.get(id) || null;
if (m && typeof m === 'object' && Object.hasOwn(m, id)) return m[id] || null;
} catch { /* ignore */ }
return null;
};
return {
chess: (id) => look('chess', id), token: (id) => look('tokens', id), item: (id) => look('items', id),
enemy: (id) => look('enemies', id), stage: (id) => look('stages', id), bond: (id) => look('bonds', id),
};
}
const QUALITY_RES = { high: 2, medium: 1.5, low: 1 };
/** Pixel-ratio cap of the 3D board canvas per quality (its fill cost is the PBR board, not the sprites). */
const BOARD_RES = { high: 2, medium: 1.25, low: 1 };
/**
* Before a renderer is destroyed: free its GL copies of every texture / buffer / geometry / framebuffer it
* uploaded. Module-level textures (FX atlas, tier chips, backdrop, diamonds, Spine pages, PIXI.Texture.WHITE…)
* outlive the view; PIXI 7 leaves their per-context GL entries and 'dispose' listeners pointing at the dead
* renderer, which kept every unmounted view's renderer, canvas and WebGL context reachable.
*/
export function releaseGl(renderer) {
const ts = renderer && renderer.texture;
if (ts && Array.isArray(ts.managedTextures) && typeof ts.destroyTexture === 'function') {
for (const bt of ts.managedTextures.slice()) { try { ts.destroyTexture(bt, true); } catch { /* ignore */ } }
ts.managedTextures.length = 0;
}
for (const sys of [renderer?.geometry, renderer?.buffer, renderer?.framebuffer]) {
try { if (sys && typeof sys.disposeAll === 'function') sys.disposeAll(false); } catch { /* ignore */ }
}
// shader programs are cached globally by source (PIXI.utils.ProgramCache) with one GLProgram per context
const uid = renderer?.CONTEXT_UID, gl = renderer?.gl;
const programs = globalThis.PIXI?.utils?.ProgramCache;
if (uid != null && programs && typeof programs === 'object') {
for (const prog of Object.values(programs)) {
const g = prog && prog.glPrograms && prog.glPrograms[uid];
if (!g) continue;
try { if (gl && g.program) gl.deleteProgram(g.program); } catch { /* ignore */ }
delete prog.glPrograms[uid];
}
}
}
/**
* 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 },
* padding?: object|((kind:string, size:{width:number,height:number}) => object),
* hud?: {top:number,bottom:number}|((kind:'prep'|'bossPrep', size:{width:number,height:number}) => {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 assets = resolveAssets(opts.assets);
const data = makeData(opts.data);
const settings = { damageNumbers: true, quality: 'high', ...(opts.settings || {}) };
// the 3D board (three.js + the official art) loads in parallel with everything else
const boardPref = boardPreference(opts.board);
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));
await withTimeout(Promise.resolve(assets.ready ? assets.ready() : null).catch(() => {}), 4000);
// 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 */ }
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();
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)
width: s0.width, height: s0.height, antialias: opts.antialias ?? (settings.quality === 'high' && (globalThis.devicePixelRatio || 1) < 1.5), backgroundColor: 0x0a0e0d, backgroundAlpha: 0,
resolution: dpr(), autoDensity: true, powerPreference: 'high-performance',
});
const canvas = app.view;
canvas.style.display = 'block';
canvas.style.width = '100%';
canvas.style.height = '100%';
canvas.style.touchAction = 'none';
canvas.style.userSelect = 'none';
canvas.setAttribute('aria-label', '战场');
canvas.style.position = 'relative';
canvas.style.zIndex = '1';
host.appendChild(canvas);
// ---- layers ---------------------------------------------------------------------------------------------
const stage = app.stage;
const backdrop = new P.Container();
const world = new P.Container();
const layers = {
ground: new P.Container(), anim: new P.Container(), overlay: new P.Container(), shadow: new P.Container(),
groundFx: new P.Container(), fxNormal: new P.Container(), units: new P.Container(), fxAdd: new P.Container(),
bars: new P.Container(), text: new P.Container(), screen: new P.Container(),
};
layers.units.sortableChildren = true;
world.addChild(layers.ground, layers.anim, layers.overlay, layers.shadow, layers.groundFx, layers.fxNormal, layers.units, layers.fxAdd, layers.bars, layers.text);
stage.addChild(backdrop, world, layers.screen);
const bg = backdropTextures();
const bgGrad = new P.Sprite(bg.gradient);
const bgGrid = new P.TilingSprite(bg.grid, s0.width, s0.height);
bgGrid.alpha = 0.9;
const bgMountains = new P.TilingSprite(P.Texture.EMPTY, s0.width, 236);
bgMountains.tint = 0x1d2b28;
bgMountains.alpha = 0.9;
const bgMountains2 = new P.TilingSprite(P.Texture.EMPTY, s0.width, 236);
bgMountains2.tint = 0x121a18;
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) {
assets.image(mountainUrl).then((img) => {
if (!img || destroyed) return;
try { const mt = silhouetteTexture(img); bgMountains.texture = mt; bgMountains2.texture = mt; } catch { /* optional art */ }
}, () => {});
}
// ---- state ----------------------------------------------------------------------------------------------
const listeners = new Map();
const emit = (name, payload) => {
const set = listeners.get(name);
if (!set) return;
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) });
let camFrom = null, camTo = null, camT0 = 0, camKind = 'prep', camOpts = {}, camMs = CAMERA_MS;
let pendingView = null; // tile band/focus to apply when the camera transition ends
const views = new Map(); // key → view (prep: 'p:'+uid; battle: unit id)
let prepPieces = []; // { uid, piece, area, idx, row, col, key }
let battleMeta = null;
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
const gone = new Set();
const woundUp = new WeakSet(); // atk event tuples whose attack wind-up already started
const interp = new SnapshotBuffer({ delay: 0.1, rate: 2 });
const sample = new Map();
const meleePending = new Map(); // target id → { src, t }
const consumedIds = new Set(); // battle ids used up by their own effect (fx `consumed`): no death particles
let canPlaceFn = null;
let editable = false;
let dragState = null; // { uid, key, view, from, home: {x,y,z} }
const pending = new Map(); // key → { t, home } dropped pieces awaiting the server
const held = new Map(); // uid → world { x, y, z }: prep pieces pinned to a tile by the direction step (holdPiece)
let hoverUnit = null;
let frameMs = 16, fps = 60, lastNow = performance.now();
const layerPops = new Map();
// prep field (render/prepfield.js): identity on the own board, the boss-field half in the Final Assault prep
let prepXf = IDENTITY;
let lastPrep = null; // { ps, o } of the last setPrep (re-applied when the prep field changes)
const hlReq = new Map(); // highlight group → { tiles (board space), style } requested through highlightTiles
// enemy preview pen (render/pen.js): figure key → UnitView (own map: never touched by the battle sync)
const penViews = new Map();
let penSig = null;
let penHidden = true; // the pen's figures are shown only by the pen camera (setPenHidden)
let penList = null;
let ownPen = null; // the own m.private.nextEnemies (fallback composition of a scouted teammate's pen)
let camBeforePen = null; // { kind, opts } the camera the pen returns to
const heightAt = (r, c) => (tiles ? tiles.heightAt(r, c) : 0);
const ctx = {
P, layers, assets, settings, fx: null, shadowTex: shadowTexture(),
cam: () => cam, heightAt,
animRate: () => (mode === 'battle' ? interp.rate : 1),
timeScale: () => (mode === 'battle' ? interp.rate : 1),
lookupDef: (info) => (info.side === 'enemy' ? data.enemy(info.defId) : data.chess(info.defId) || data.token(info.defId)),
crowded: () => views.size > 90,
renderer: app.renderer,
frameNo: () => frameNo,
impostorInterval: () => impInterval,
clipAllowed: () => clipAllowed,
viewport: () => vp,
loadLevel: () => loadLevel,
surfaceLayer: (row) => tiles.surfaceLayer(row),
};
const tiles = new TileField({ ground: layers.ground, overlay: layers.overlay, props: layers.units, anim: layers.anim });
const fx = new FxSystem({
P, layers, cam: () => cam, settings, assets, heightAt, redVignette: bg.red, surfaceLayer: ctx.surfaceLayer,
timeScale: () => ctx.timeScale(),
loadLevel: () => loadLevel,
fieldRect: () => (mode === 'battle' && battleMeta ? battleMeta.rect : null),
subProfOf: (defId) => data.chess(defId)?.subProfessionId || null,
view: (id) => views.get(id) || null,
screenSize: size,
fieldTop: () => {
const R = camRect();
const p = cam.project((R.c0 + R.c1) / 2, R.r1 + 0.5, 1.6);
return Math.max(80, Math.min(size().height * 0.4, p.y));
},
});
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);
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
// arrival swaps the atlas in place
const artPromise = loadBoardArt(assets).then((art) => {
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
let board3dCanvas = null;
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 };
function enable3d(THREE, pack) {
if (destroyed || board3d || !THREE || !pack) return false;
recover.THREE = THREE; recover.pack = pack;
try {
try { if (getComputedStyle(host).position === 'static') host.style.position = 'relative'; } catch { /* ignore */ }
const c3 = document.createElement('canvas');
c3.setAttribute('aria-hidden', 'true');
Object.assign(c3.style, { position: 'absolute', left: '0', top: '0', width: '100%', height: '100%', display: 'block', pointerEvents: 'none', zIndex: '0' });
host.insertBefore(c3, canvas);
board3dCanvas = c3;
const b = new BoardScene(THREE, pack, {
canvas: c3, antialias: settings.quality !== 'low' && (globalThis.devicePixelRatio || 1) < 2, shadows: settings.quality !== 'low',
});
const sz = size();
b.resize(sz.width, sz.height, boardDpr());
board3d = b;
tiles.setExternal(true);
backdrop.visible = false;
b.setArea(boardArea(viewKind(camKind, camOpts)));
if (stageRec) b.setStage(stageRec);
b.setFocus(camRect());
b.setBattleRect(mode === 'battle' && battleMeta ? battleMeta.rect : null);
tiles.project(cam, true);
recover.since = performance.now();
for (const [g, r] of hlReq) drawHighlight(r.tiles, r.style, g);
return true;
} catch (err) {
board3dError = String(err?.message || err);
console.warn('[render] 3D board unavailable, using the 2D board:', err);
disable3d();
return false;
}
}
function disable3d() {
const b = board3d;
board3d = null;
try { b?.destroy(); } catch { /* ignore */ }
try { board3dCanvas?.remove(); } catch { /* ignore */ }
board3dCanvas = null;
if (!destroyed) {
tiles.setExternal(false); backdrop.visible = true; tiles.project(cam, true);
for (const [g, r] of hlReq) drawHighlight(r.tiles, r.style, g);
}
}
/**
* The 3D board's WebGL context was lost (GPU reset, driver, too many contexts): the 2D board takes over at once and a
* fresh 3D board (new canvas + context) is built a moment later. A board that loses its context again within
* BOARD3D_STABLE_MS counts as a failed try; after BOARD3D_RETRY_MS.length failures the view stays on the 2D board.
*/
function onBoard3dLost() {
console.warn('[render] WebGL context of the 3D board lost: 2D board until it can be rebuilt');
const quick = performance.now() - recover.since < BOARD3D_STABLE_MS;
disable3d();
recover.count++;
if (!quick) recover.tries = 0;
scheduleRecover();
}
function scheduleRecover() {
clearTimeout(recover.timer);
recover.timer = 0;
if (destroyed || recover.off || !recover.THREE || !recover.pack || recover.tries >= BOARD3D_RETRY_MS.length) return;
const delay = BOARD3D_RETRY_MS[recover.tries++];
recover.timer = setTimeout(() => {
recover.timer = 0;
if (destroyed || recover.off || board3d) return;
if (!webgl2Available(boardPref === '3d')) { scheduleRecover(); return; }
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);
}
// 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) {
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; };
if (t.baseTexture.valid) use(); else t.baseTexture.once('loaded', use);
} catch { /* optional */ }
}
ensureDamageFonts();
// web fonts may land after the first chips were drawn
if (document.fonts?.ready) document.fonts.ready.then(() => { if (!destroyed) refreshTierChips(); }).catch(() => {});
// ---- camera ---------------------------------------------------------------------------------------------
function defaultPadding(kind, sz) {
if (typeof opts.padding === 'function') { try { const p = opts.padding(kind, sz); if (p) return p; } catch { /* ignore */ } }
if (opts.padding && typeof opts.padding === 'object') return opts.padding;
const w = sz.width, h = sz.height;
if (kind === 'prep' || kind === 'bossPrep') return { top: h * 0.1, bottom: h * 0.24, left: Math.min(170, w * 0.09), right: w * 0.05 };
if (kind === 'boss' || kind === 'unite') return { top: h * 0.13, bottom: h * 0.1, left: Math.min(150, w * 0.08), right: w * 0.02 };
return { top: h * 0.13, bottom: h * 0.12, left: Math.min(170, w * 0.09), right: w * 0.05 };
}
// the HUD bands the prep cameras keep the bench / field clear of (projection.js clearHud; user playtest #5 item 9):
// `opts.hud` = (kind, size) => { top, bottom } | null, or a fixed object; none → the plain official framing
function hudBands(kind, sz) {
if (kind !== 'prep' && kind !== 'bossPrep') return null;
if (typeof opts.hud === 'function') { try { return opts.hud(kind, sz) || null; } catch { return null; } }
return opts.hud && typeof opts.hud === 'object' ? opts.hud : null;
}
function camRect() {
const k = viewKind(camKind, camOpts);
if (k === 'pen') return { r0: 14, r1: 18, c0: 7, c1: 13 };
// prep lights the bench (hand row 7 / temp row 8) with the field, like the official prep view
if (camOpts.rect) { const r = normRect(camOpts.rect); return k === 'prep' ? { ...r, r0: Math.min(r.r0, GEO.HAND_ROW) } : r; }
if (k === 'bossPrep') return camOpts.side === 'R' ? { r0: 0, r1: 5, c0: 10, c1: 20 } : { r0: 0, r1: 5, c0: 0, c1: 10 };
return k === 'boss' ? { ...GEO.BOSS_RECT } : k === 'unite' ? { ...GEO.UNITE_RECT } : k === 'prep' ? { r0: 7, r1: 12, c0: 0, c1: 10 } : { ...GEO.NORMAL_RECT };
}
function targetCamera(kind, o) {
const sz = size();
const k = kind === 'hidden' ? 'boss' : (['prep', 'normal', 'unite', 'boss', 'bossPrep', 'pen'].includes(kind) ? kind : 'normal');
let rect = o.rect ? normRect(o.rect) : null;
if (k === 'prep') rect = rect ? { ...rect, r0: Math.min(rect.r0, GEO.HAND_ROW) } : null;
// official configBlackBoard framing (render/projection.js presetCamera); the padding only matters for the
// fitted fallback (custom rects, portrait viewports); a prep camera keeps the bench and the field clear of the HUD
const vk = viewKind(kind, o); // (a 'prep' camera on the boss rows = the Final Assault prep)
return presetCamera(k, { width: sz.width, height: sz.height, padding: o.padding || defaultPadding(k, sz) }, {
rect, side: o.side, half: !!o.half, shop: o.shop, fit: !!o.fit, config: stageRec?.config || null,
hud: hudBands(vk, sz),
});
}
// rows drawn per camera kind: module `bandFor`; the active field rows: module `fieldRows`
function setCamera(kind, options) {
if (destroyed) return false;
let o = options && typeof options === 'object' ? options : {};
const prevView = viewKind(camKind, camOpts);
const prevBand = bandFor(prevView), prevField = fieldRows(prevView);
const nextKind = typeof kind === 'string' ? kind : 'normal';
// the enemy pen is a detour of the prep camera: remember where it came from; the same kind asked again without
// framing options goes back to exactly that camera (Final Assault half, shop state…)
const framing = (x) => Object.keys(x).some((k) => k !== 'instant' && k !== 'ms' && x[k] !== undefined);
if (viewKind(nextKind, o) === 'pen') {
if (prevView !== 'pen') camBeforePen = { kind: camKind, opts: { ...camOpts, instant: undefined, ms: undefined } };
} else if (prevView === 'pen' && camBeforePen && nextKind === camBeforePen.kind && !framing(o)) {
o = { ...camBeforePen.opts, instant: o.instant, ms: o.ms };
}
camKind = nextKind;
camOpts = { ...o };
// the field actually shown (a 'prep' camera on the boss rows is the Final Assault prep: boss field built / drawn)
const vk = viewKind(camKind, camOpts);
if (vk === 'prep') setPrepField(IDENTITY);
else if (vk === 'bossPrep') setPrepField(bossPrepField(camOpts.side === 'R' ? 'R' : 'L'));
const target = targetCamera(camKind, camOpts);
const band = bandFor(vk);
const field = fieldRows(vk);
const focus = camRect();
board3d?.setFocus(focus);
camMs = Number.isFinite(o.ms) && o.ms >= 0 ? o.ms : (vk === 'pen' || prevView === 'pen' ? PEN_CAMERA_MS : CAMERA_MS);
if (o.instant || camMs === 0 || mode === 'idle' && !camTo) {
board3d?.setArea(boardArea(vk));
cam = target; camFrom = camTo = null;
tiles.setView(band, focus, field);
setPenHidden(!penShown(vk));
pendingView = null;
} else {
camFrom = cam.clone();
camTo = target;
camT0 = performance.now();
// keep both fields drawn (and lit) while the camera flies between them
tiles.setView([Math.min(prevBand[0], band[0]), Math.max(prevBand[1], band[1])], focus, [Math.min(prevField[0], field[0]), Math.max(prevField[1], field[1])]);
board3d?.setArea(unionAreas(boardArea(prevView), boardArea(vk)));
setPenHidden(!penShown(vk, prevView));
pendingView = { band, focus, field, area: boardArea(vk), pen: penShown(vk) };
}
return true;
}
/**
* Where the prep pieces stand (render/prepfield.js): the own board, or the player's half of the boss field in the
* Final Assault prep. A change re-homes the pieces and re-draws the board-space highlight requests.
*/
function setPrepField(xf) {
const next = xf || IDENTITY;
if (next.kind === prepXf.kind && next.side === prepXf.side) return;
prepXf = next;
if (mode !== 'prep') return;
if (lastPrep) setPrep(lastPrep.ps, lastPrep.o);
for (const e of prepPieces) {
const v = views.get(e.key);
if (e.area === 'board' && v && typeof v.setDir === 'function') v.setDir(prepXf.dirToDisp(pieceDirOf(e.piece) || 'RIGHT'));
}
for (const [group, req] of hlReq) drawHighlight(req.tiles, req.style, group);
}
function stepCamera(now) {
if (!camTo) return;
const k = camMs > 0 ? Math.min(1, (now - camT0) / camMs) : 1;
const e = easeInOutCubic(k);
if (!cam || cam === camFrom) cam = new Camera();
lerpCamera(camFrom, camTo, e, cam);
if (k >= 1) {
cam = camTo; camFrom = camTo = null;
if (pendingView) {
tiles.setView(pendingView.band, pendingView.focus, pendingView.field);
board3d?.setArea(pendingView.area);
setPenHidden(!pendingView.pen);
pendingView = null;
tiles.project(cam, true);
}
}
}
// ---- backdrop -------------------------------------------------------------------------------------------
function layoutBackdrop() {
const { width, height } = size();
bgGrad.width = width; bgGrad.height = height;
bgVignette.width = width; bgVignette.height = height;
bgGrid.width = width; bgGrid.height = height;
bgMountains.width = width; bgMountains2.width = width;
}
function updateBackdrop(t) {
const { height } = size();
// horizon ≈ projection of the far rows; mountains sit behind the board
const horizon = Math.max(0, cam.project(cam.tx, cam.ty + 16, 0).y);
bgMountains.position.set(0, Math.min(height * 0.35, horizon) - 150);
bgMountains2.position.set(0, Math.min(height * 0.35, horizon) - 95);
bgMountains.tilePosition.x = -cam.tx * 12 + t * 2;
bgMountains2.tilePosition.x = -cam.tx * 22 + 300;
bgMountains2.tileScale.set(1.25);
bgGrid.tilePosition.set(-cam.tx * 30, -cam.ty * 10);
}
// ---- stage ----------------------------------------------------------------------------------------------
function setStage(st) {
if (!st || typeof st !== 'object' || !Array.isArray(st.rows)) return false;
stageRec = st;
tiles.setStage(st);
tiles.setBattleRect(mode === 'battle' && battleMeta ? battleMeta.rect : null);
if (board3d) { board3d.setStage(st); board3d.setBattleRect(mode === 'battle' && battleMeta ? battleMeta.rect : null); }
tiles.project(cam, true);
// a pen laid out before the stage arrived (setPrep / a scouting board first) used the default zones and the old
// tile heights: lay it out again on this stage
if (penList) { const l = penList; clearPen(); setPenList(l); }
return true;
}
// ---- views ----------------------------------------------------------------------------------------------
function dropView(key) {
const v = views.get(key);
if (!v) return;
views.delete(key);
if (hoverUnit === v) hoverUnit = null;
try { v.destroy(); } catch (err) { console.warn('[render] view destroy', err); }
}
function clearViews(prefix) {
for (const k of [...views.keys()]) if (prefix == null || String(k).startsWith(prefix)) dropView(k);
}
// ---- prep -----------------------------------------------------------------------------------------------
function pieceInfo(piece, area) {
const kind = piece.kind;
// board pieces face their stored dir (the server default RIGHT when absent), drawn through the prep field (mirrored
// on the right half of the Final Assault prep); bench pieces face right
const dir = area === 'board' ? dispDir(pieceDirOf(piece) || 'RIGHT') : undefined;
if (kind === 'item') {
const rec = data.item(piece.id);
const tier = rec?.tier || piece.tier || 1;
const url = assets.itemIcon ? assets.itemIcon(rec ? { trapId: rec.trapId, iconId: rec.iconId } : piece.id) : null;
return { kind: 'item', defId: piece.id, icon: url, color: (piece.golden || rec?.isGolden) ? 0xffc600 : TIER_COLORS[tier] || TIER_COLORS[1] };
}
if (kind === 'token') {
const rec = data.token(piece.id);
return { kind: 'token', side: 'ally', defId: piece.id, spine: rec?.assets?.spine || piece.id, avatar: rec?.assets?.avatar || piece.id, tier: piece.tier || 1, golden: false, dir };
}
const rec = data.chess(piece.id);
return {
kind: 'op', side: 'ally', defId: piece.id,
spine: rec?.assets?.spine || rec?.charId || null, avatar: rec?.assets?.avatar || rec?.charId || null,
tier: rec?.tier || piece.tier || 1, golden: !!(piece.golden || rec?.isGolden), dir,
};
}
/** Board-space direction → the direction drawn (mirrored on the right half of the Final Assault prep). */
function dispDir(d) { return d ? prepXf.dirToDisp(d) : d; }
/** Board-space tile → world point of its top (the prep field transform applied). */
function boardWorld(row, col, bench = false) {
const d = prepXf.toDisp(row, col);
return { x: d.col, y: d.row, z: bench ? TILE_H.bench : heightAt(d.row, d.col) };
}
/** The tile under a canvas point in BOARD space (null off-grid; an impossible tile off the player's half). */
function pickBoardTile(x, y) {
const t = pickTile(cam, x, y, heightAt, tiles.levels);
if (!t || prepXf === IDENTITY) return t;
const b = prepXf.toBoard(t.row, t.col);
return b ? { ...t, row: b.row, col: b.col } : { row: -1, col: -1, x: t.x, y: t.y };
}
function slotWorld(p) {
const h = held.get(p.uid);
if (h) return h;
const t = pieceTile(p);
if (!t) return null;
return boardWorld(t.row, t.col, p.area === 'hand' || p.area === 'temp');
}
function setPrep(ps, options) {
if (destroyed) return false;
const o = options && typeof options === 'object' ? options : {};
if (mode !== 'prep') enterPrepMode();
lastPrep = { ps, o };
editable = !!o.editable;
canPlaceFn = typeof o.canPlace === 'function' ? o.canPlace : null;
drag.setCanPlace(canPlaceFn ? adaptCanPlace(canPlaceFn) : null);
drag.setEditable(editable);
const list = [];
const addList = (arr, area) => {
if (!Array.isArray(arr)) return;
arr.forEach((pc, i) => {
if (!pc || typeof pc !== 'object' || !Number.isInteger(pc.uid)) return;
if (area === 'board') {
if (!Number.isInteger(pc.row) || !Number.isInteger(pc.col)) return;
list.push({ piece: pc, area, row: pc.row, col: pc.col, uid: pc.uid });
} else list.push({ piece: pc, area, idx: i, uid: pc.uid });
});
};
const src = ps && typeof ps === 'object' ? ps : {};
ownPen = Array.isArray(src.nextEnemies) ? src.nextEnemies : null;
setPenList(ownPen);
addList(src.hand, 'hand');
addList(src.temp, 'temp');
addList(src.board, 'board');
const seen = new Set();
const prevBoard = new Set(prepPieces.filter((p) => p.area === 'board').map((p) => p.uid));
for (const e of list) {
if (seen.has(e.uid)) continue; // duplicate uid in bad state: keep the first
seen.add(e.uid);
const key = 'p:' + e.uid;
e.key = key;
const info = pieceInfo(e.piece, e.area);
const sig = `${info.kind}|${info.defId}|${info.golden ? 1 : 0}`;
let v = views.get(key);
if (v && v._sig !== sig) { dropView(key); v = null; }
const w = slotWorld(e);
if (!w) continue;
if (!v) {
// board pieces are built facing their stored dir (no Front → Back swap after every round's rebuild)
const full = { ...info, id: key, uid: e.uid, x: w.x, y: w.y, facing: info.dir === 'LEFT' ? -1 : 1, maxHp: 1 };
v = info.kind === 'item' ? new ItemView(ctx, full) : new UnitView(ctx, full, { prep: true });
v._sig = sig;
v.setWorld(w.x, w.y, w.z);
views.set(key, v);
if (e.area === 'board' && prevBoard.size && !prevBoard.has(e.uid)) { v.onDeploy?.(); fx.deploy(v); }
} else {
const prevHome = v._home;
const moved = !prevHome || prevHome.x !== w.x || prevHome.y !== w.y || prevHome.z !== w.z;
if (dragState && dragState.key === key) {
dragState.home = w; // being dragged: only its home slot changes
} else if (pending.has(key) && !moved) {
// dropped, but this state predates the server applying the move: stay at the drop target
} else {
pending.delete(key);
if (moved || Math.abs(v.x - w.x) + Math.abs(v.y - w.y) > 1e-3) v._tween = { fx: v.x, fy: v.y, fz: v.z, tx: w.x, ty: w.y, tz: w.z, t: 0 };
v.lift = 0;
if (e.area === 'board' && !prevBoard.has(e.uid) && v.onDeploy) { v.onDeploy(); fx.deploy(v); }
}
}
v._home = w;
v.dimmed = false;
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';
// the stored facing (a piece held by the direction step shows the wheel's preview instead)
if (e.area === 'board' && info.dir && !held.has(e.uid) && typeof v.setDir === 'function') v.setDir(info.dir);
}
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); }
return true;
}
function adaptCanPlace(fn) {
if (fn.length >= 3) return (piece, row, col, target) => !!fn(piece, row, col, target);
return (piece, row, col, target) => !!fn(piece.uid, target, piece);
}
function enterPrepMode() {
mode = 'prep';
held.clear();
clearViews();
infos.clear();
gone.clear();
battleMeta = null;
interp.reset();
fx.clear();
meleePending.clear();
consumedIds.clear();
tiles.setBattleRect(null);
board3d?.setBattleRect(null);
clearHl();
}
// ---- enemy preview pen (render/pen.js) -----------------------------------------------------------------------
/** Show a preview list (m.private.nextEnemies shape) in the pen; null / [] empties it. Unchanged lists are kept. */
function setPenList(list) {
const sig = Array.isArray(list) && list.length ? penSignature(list) : '';
if (sig === penSig) return;
clearPen();
penSig = sig;
penList = sig ? list : null;
if (!sig) return;
const pen = layoutPen(list, { stage: stageRec });
for (const f of pen.figures) {
const rec = data.enemy(f.enemyKey);
const rank = rec?.rank;
const info = {
id: 'e:' + f.key, kind: 'enemy', side: 'enemy', defId: f.enemyKey, enemyKey: f.enemyKey, preview: true, name: rec?.name || f.enemyKey,
tier: f.boss || rank === 'BOSS' ? 3 : f.elite || rank === 'ELITE' ? 2 : 1, golden: false,
spine: rec?.spine || f.enemyKey, avatar: rec?.iconId || rec?.avatar || f.enemyKey,
x: f.x, y: f.y, facing: -1, maxHp: 1, boss: !!f.boss, motion: f.fly ? 'FLY' : undefined,
};
const v = new UnitView(ctx, info, { prep: true, lod: 'idle' });
v.setWorld(f.x, f.y, heightAt(f.row, f.col));
v.fadeIn = 0;
penViews.set(info.id, v);
if (penHidden) hidePenView(v, true);
}
}
/** Hide (or show again) the pen's figures: only the pen camera shows them (see the header). */
function setPenHidden(hidden) {
penHidden = !!hidden;
for (const v of penViews.values()) hidePenView(v, penHidden);
}
function hidePenView(v, hidden) {
if (!v || v.destroyed) return;
if (hidden) {
for (const k of ['root', 'hud', 'shadow', 'facingArrow', 'blockIcon']) if (v[k]) v[k].visible = false;
} else {
v.culled = undefined; // viewport culling decides the visibility again — now (fresh screen geometry for hit tests)
try { v.update(0, cam, clock); } catch { /* the next frame retries */ }
}
}
function clearPen() {
for (const v of penViews.values()) { try { v.destroy(); } catch { /* ignore */ } }
penViews.clear();
penSig = null;
penList = null;
}
/** The pen enemy under a canvas point: a figure on the tile under it (render/pick.js; ≤ 3 idle per pen tile). */
function penUnitAt(x, y) {
if (penHidden) return null;
const units = [];
for (const v of penViews.values()) { const u = pickUnitOf(v); if (u) units.push(u); }
const hit = pickOnTile(units, groundTile(x, y));
return hit ? hit.ref : null;
}
function emitPenClick(v, e) {
const info = { ...v.info };
emit('pieceClick', { enemyKey: info.enemyKey, preview: true, unit: info, unitId: null, uid: null, button: e.button, detail: e.button === 2, clientX: e.clientX, clientY: e.clientY });
}
// ---- drag & pointer ----------------------------------------------------------------------------------------
/** The display tile under a canvas point (raised tops first) and the point on its top (world x, y), or null. */
function groundTile(x, y) {
const t = pickTile(cam, x, y, heightAt, tiles.levels);
return t ? { row: t.row, col: t.col, x: t.x, y: t.y } : null;
}
/** The prep piece under a canvas point: the one on the tile under it (render/pick.js; board, bench and temp rows). */
function pieceAt(x, y) {
if (mode !== 'prep') return null;
const units = [];
for (const e of prepPieces) {
const u = pickUnitOf(views.get(e.key));
if (u) { u.entry = e; units.push(u); }
}
const best = pickOnTile(units, groundTile(x, y))?.entry;
if (!best) return null;
return { uid: best.uid, kind: best.piece.kind, id: best.piece.id, area: best.area, idx: best.idx, row: best.row, col: best.col, piece: best.piece, draggable: true };
}
const drag = createDragController({
hitPiece: (x, y) => pieceAt(x, y),
pickTile: (x, y) => pickBoardTile(x, y),
isOverCanvas: (cx, cy) => {
try { const el = document.elementFromPoint(cx, cy); return !el || el === canvas; } catch { return true; }
},
emit: (name, payload) => onDragEvent(name, payload),
});
function publicPiece(p) {
if (!p) return p;
return { ...p.piece, area: p.area, idx: p.idx, row: p.row, col: p.col };
}
function onDragEvent(name, payload) {
const out = payload && payload.piece ? { ...payload, piece: publicPiece(payload.piece) } : payload;
switch (name) {
case 'pieceDragStart': startDragVisual(payload); break;
case 'pieceDragMove': moveDragVisual(payload); return; // internal
case 'pieceDrop': dropDragVisual(payload); break;
case 'pieceDragEnd': if (!payload.dropped) endDragVisual(true); else clearHl('dragTarget'); break;
case 'pieceHover': {
for (const e of prepPieces) { const v = views.get(e.key); if (v) v.setHover(e.uid === payload.uid); }
break;
}
default: break;
}
emit(name, out);
}
/** Group key of a highlight request (render/tiles.js setHighlights rule). */
function hlKey(style, group) {
return group || (typeof style === 'object' && style ? style.group : null) || (typeof style === 'string' ? style : 'custom');
}
/**
* Draw a BOARD-space highlight group: range previews never light the bench / temp pads (rows 7–8: no battle
* happens there), the prep field transform maps the tiles onto the boss field in the Final Assault prep, and the 2D
* board stripes range previews under the units (the 3D board gets them from the direction wheel's layer).
*/
function drawHighlight(list, style, group) {
const key = hlKey(style, group);
const range = RANGE_GROUPS.has(key) || style === 'range';
let t = tilesToDisp(IDENTITY, list);
if (range) t = t.filter(([r]) => r !== GEO.HAND_ROW && r !== GEO.TEMP_ROW);
if (mode === 'prep' && prepXf !== IDENTITY) t = tilesToDisp(prepXf, t);
tiles.setHighlights(t, style, key, { stripes: range && key !== 'rangeStand' && !board3d });
}
function clearHl(group) {
if (group) hlReq.delete(group); else hlReq.clear();
tiles.clearHighlights(group);
}
function legalTiles(piece) {
const fn = canPlaceFn ? adaptCanPlace(canPlaceFn) : null;
if (!fn) return [];
const out = [];
const F = GEO.FIELD;
for (let r = F.r0; r <= F.r1; r++) for (let c = F.c0; c <= F.c1; c++) {
try { if (fn(piece, r, c, { area: 'board', row: r, col: c })) out.push([r, c]); } catch { /* illegal */ }
}
for (let i = 0; i < GEO.HAND_SIZE; i++) {
try { if (fn(piece, GEO.HAND_ROW, i, { area: 'hand', idx: i })) out.push([GEO.HAND_ROW, i]); } catch { /* illegal */ }
}
return out;
}
function startDragVisual(p) {
const key = 'p:' + p.uid;
const v = views.get(key);
if (!v) return;
dragState = { uid: p.uid, key, view: v, from: p.from, home: v._home || { x: v.x, y: v.y, z: v.z } };
v.lift = 0.3;
v._tween = null;
const t = pieceTile(p.piece);
if (t) drawHighlight([[t.row, t.col]], 'hover', 'dragFrom');
drawHighlight(legalTiles(p.piece), 'legal', 'legal');
}
function moveDragVisual(p) {
if (!dragState) return;
const v = dragState.view;
// held under the pointer: a unit with its drawn (lifted) feet DRAG_HOLD_TILES of its own px per tile below it (the
// scale at the pointer's ground, refined once at the feet), an item plate centred on it; the drop target
// (p.target, highlighted below) is the tile under the pointer
const item = v instanceof ItemView;
const g = cam.unproject(p.x, p.y, 0);
const hold = item ? 0 : DRAG_HOLD_TILES;
const s0 = g ? cam.scaleAt(g.x, g.y, 0) : cam.scale;
const t = pickTile(cam, p.x, p.y + hold * s0, heightAt, tiles.levels);
const z = t ? heightAt(t.row, t.col) : 0;
const up = item ? z : z + (v.lift || 0) + (v.hover || 0);
let w = cam.unproject(p.x, p.y + hold * s0, up);
if (w && hold) w = cam.unproject(p.x, p.y + hold * cam.scaleAt(w.x, w.y, up), up) || w;
if (w) {
v.x = Math.max(-1, Math.min(21, w.x));
v.y = Math.max(-1, Math.min(19, w.y));
v.z = z;
}
if (p.target && p.target.area !== 'temp') {
const r = p.target.area === 'board' ? p.target.row : GEO.HAND_ROW;
const c = p.target.area === 'board' ? p.target.col : p.target.idx;
drawHighlight([[r, c]], p.legal ? 'target' : 'illegal', 'dragTarget');
} else clearHl('dragTarget');
}
function dropDragVisual(p) {
if (!dragState) return;
const v = dragState.view;
const t = p.target;
if (t && (t.area === 'board' || t.area === 'hand')) {
const row = t.area === 'board' ? t.row : GEO.HAND_ROW, col = t.area === 'board' ? t.col : t.idx;
const w = boardWorld(row, col, t.area === 'hand');
v._tween = { fx: v.x, fy: v.y, fz: v.z, tx: w.x, ty: w.y, tz: w.z, t: 0 };
pending.set(dragState.key, { t: performance.now(), view: v });
} else {
const h = dragState.home;
v._tween = { fx: v.x, fy: v.y, fz: v.z, tx: h.x, ty: h.y, tz: h.z, t: 0 };
}
v.lift = 0;
clearHl('dragFrom'); clearHl('dragTarget'); clearHl('legal');
dragState = null;
}
function endDragVisual(cancelled) {
if (dragState) {
const v = dragState.view;
const h = dragState.home;
if (cancelled && v && !v.destroyed) v._tween = { fx: v.x, fy: v.y, fz: v.z, tx: h.x, ty: h.y, tz: h.z, t: 0 };
if (v) v.lift = 0;
}
dragState = null;
clearHl('dragFrom'); clearHl('dragTarget'); clearHl('legal');
}
function canvasPoint(e) {
const r = canvas.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function evPayload(e) {
const p = canvasPoint(e);
return { pointerId: e.pointerId, pointerType: e.pointerType, button: e.button, x: p.x, y: p.y, clientX: e.clientX, clientY: e.clientY };
}
/** The battle unit under a canvas point (render/pick.js: an ally on the tile under it, an enemy near its ground). */
function battleUnitAt(x, y) {
const units = [];
for (const v of views.values()) {
if (!v.info || v.info.kind === 'device') continue;
const enemy = v.info.side !== 'ally';
// a huge boss: its hit area (data/enemies.json `hitArea`, the sim's hit rectangle) is pickable too
const u = pickUnitOf(v, enemy, enemy ? data.enemy(v.info.defId)?.hitArea ?? null : null);
if (u) units.push(u);
}
const hit = pickBattle(units, groundTile(x, y), x, y);
return hit ? hit.ref : null;
}
const onPointerDown = (e) => {
if (destroyed) return;
const ev = evPayload(e);
if (mode === 'battle') {
const v = battleUnitAt(ev.x, ev.y);
if (v) {
const info = infos.get(v.id) || v.info;
const payload = { unitId: v.id, uid: info?.uid ?? null, unit: info, button: e.button, detail: e.button === 2, clientX: e.clientX, clientY: e.clientY };
emit('pieceClick', payload);
if (e.button === 2) emit('pieceDetail', payload);
} else if (penViews.size) {
const pv = penUnitAt(ev.x, ev.y);
if (pv) emitPenClick(pv, e);
}
return;
}
if (drag.pointerDown(ev)) { try { canvas.setPointerCapture(e.pointerId); } catch { /* ignore */ } return; }
if (penViews.size && mode === 'prep') {
const pv = penUnitAt(ev.x, ev.y);
if (pv) emitPenClick(pv, e);
}
};
const onPointerMove = (e) => {
if (destroyed) return;
const ev = evPayload(e);
if (mode === 'battle') {
if (e.pointerType === 'touch') return;
const v = battleUnitAt(ev.x, ev.y);
if (v !== hoverUnit) {
hoverUnit = v;
const info = v ? infos.get(v.id) || v.info : null;
emit('pieceHover', v ? { unitId: v.id, uid: info?.uid ?? null, unit: info, clientX: e.clientX, clientY: e.clientY } : { uid: null, unitId: null });
}
return;
}
drag.pointerMove(ev);
};
const onPointerUp = (e) => { if (!destroyed && mode !== 'battle') drag.pointerUp(evPayload(e)); try { canvas.releasePointerCapture(e.pointerId); } catch { /* ignore */ } };
const onPointerCancel = (e) => { if (!destroyed) drag.pointerCancel(evPayload(e)); };
const onPointerLeave = (e) => { if (!destroyed && !drag.dragging) drag.pointerLeave(evPayload(e)); if (hoverUnit) { hoverUnit = null; emit('pieceHover', { uid: null, unitId: null }); } };
const onContext = (e) => e.preventDefault();
// A finger is handled through the pointer events above only. The compatibility mouse events + click of a tap come
// after touchend, hit-tested at the finger again — where the tap may just have opened DOM UI: a tap on a unit's tile
// selects it and its underframe opens over the tile (clamped under the top bar on a phone), and the click pressed
// 撤退 / 出售 (user playtest #4 item 1 on a phone). Cancelling touchend drops them.
const onTouchEnd = (e) => { if (e.cancelable) e.preventDefault(); };
canvas.addEventListener('pointerdown', onPointerDown);
canvas.addEventListener('pointermove', onPointerMove);
canvas.addEventListener('pointerup', onPointerUp);
canvas.addEventListener('pointercancel', onPointerCancel);
canvas.addEventListener('pointerleave', onPointerLeave);
canvas.addEventListener('contextmenu', onContext);
canvas.addEventListener('touchend', onTouchEnd, { passive: false });
// ---- battle ---------------------------------------------------------------------------------------------
function enterBattle(meta) {
if (destroyed || !meta || typeof meta !== 'object') return false;
drag.reset();
endDragVisual(false);
clearViews();
prepPieces = [];
infos.clear();
gone.clear();
interp.reset();
fx.clear();
meleePending.clear();
consumedIds.clear();
clearHl();
renderT0Battle = null;
mode = 'battle';
const rect = meta.rect ? normRect(meta.rect) : (meta.kind === 'boss' || meta.kind === 'hidden' ? { ...GEO.BOSS_RECT } : meta.kind === 'unite' ? { ...GEO.UNITE_RECT } : { ...GEO.NORMAL_RECT });
// prep: true = a read-only scouting board (a teammate's lineup during prep): prep-style pieces, no bars
battleMeta = { fieldId: meta.fieldId ?? null, kind: meta.kind || 'normal', rect, stageId: meta.stageId ?? null, prep: meta.prep === true };
// a scouted teammate's board shows THEIR pen (m.field nextEnemies; without it the shared wave composition of the
// own preview, bounties excluded); a real battle empties the pen (the enemies are about to spawn)
if (battleMeta.prep) setPenList(Array.isArray(meta.nextEnemies) ? meta.nextEnemies : (ownPen || []).filter((x) => x && x.source !== 'bounty' && x.source !== 'effect'));
else setPenList(null);
if (meta.stageId && (!stageRec || stageRec.id !== meta.stageId)) {
const st = data.stage(meta.stageId);
if (st) setStage(st);
}
tiles.setBattleRect(rect);
board3d?.setBattleRect(rect);
for (const u of Array.isArray(meta.units) ? meta.units : []) addInfo(u);
return true;
}
function addInfo(u) {
if (!u || typeof u !== 'object' || (typeof u.id !== 'number' && typeof u.id !== 'string')) return null;
const info = {
id: u.id, uid: u.uid ?? null, kind: u.kind || 'enemy', side: u.side === 'ally' ? 'ally' : 'enemy', ownerId: u.ownerId ?? null,
defId: u.defId ?? null, name: u.name ?? '', tier: u.tier ?? 1, golden: !!u.golden, spine: u.spine ?? u.defId ?? null,
avatar: u.avatar ?? u.defId ?? null, x: Number(u.x) || 0, y: Number(u.y) || 0, facing: u.facing === -1 ? -1 : 1,
maxHp: Number(u.maxHp) || 1, boss: !!u.boss, motion: u.motion,
// deploy direction of allies (UnitInfo.dir, DESIGN §3): the model (Back for UP, mirrored for LEFT) and the
// ground wedge follow it; absent = unknown (legacy frames) → derived from `facing`, no wedge
dir: typeof u.dir === 'string' ? u.dir : undefined,
// DESIGN §16 loadout of an ally (UnitInfo.skillIndex / moduleId): the Spine actor plays that skill's clip, and a
// tap hands them to the detail card (a teammate's unit shows its owner's skill / module)
skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : undefined,
moduleId: typeof u.moduleId === 'string' ? u.moduleId : undefined,
};
infos.set(info.id, info);
return info;
}
function battleView(id) {
let v = views.get(id);
if (v) return v;
const info = infos.get(id);
if (!info || gone.has(id)) return null;
v = info.kind === 'device' ? new DeviceView(ctx, info) : new UnitView(ctx, info, { prep: !!battleMeta?.prep && info.side === 'ally' });
v.setWorld(info.x, info.y, 0);
v._seen = false;
v._born = performance.now();
views.set(id, v);
return v;
}
function pushSnapshot(snap) {
if (destroyed || mode !== 'battle') return false;
if (battleMeta?.fieldId && snap && snap.fieldId && snap.fieldId !== battleMeta.fieldId) return false;
return interp.push(snap, performance.now() / 1000);
}
function pushEvents(ev) {
if (destroyed || mode !== 'battle') return 0;
let list = ev, t;
if (ev && !Array.isArray(ev) && typeof ev === 'object') {
if (battleMeta?.fieldId && ev.fieldId && ev.fieldId !== battleMeta.fieldId) return 0;
list = ev.ev; t = frameTime(ev);
}
if (!Array.isArray(list)) return 0;
// spawn infos are needed as soon as possible (a snapshot may reference the unit first); a placeholder view made
// for a snapshot unit nobody announced is rebuilt with the real info
for (const e of list) {
if (!Array.isArray(e) || e[0] !== 'spawn') continue;
const info = addInfo(e[1]);
if (info && views.get(info.id)?._unknown) dropView(info.id);
}
return interp.pushEvents(list, performance.now() / 1000, t);
}
// attack wind-ups: an 'atk' still queued in the buffer starts its Spine clip early enough for the strike frame to
// land when the event is rendered (the look-ahead is the interpolation delay, ~0.2 game s)
function windUpAttacks(renderT) {
upcomingT = renderT;
interp.forEachUpcoming(renderT, renderT + 1.5, onUpcoming);
}
let upcomingT = 0;
function onUpcoming(e, t) {
if (e[0] !== 'atk' || woundUp.has(e) || CHAIN_KINDS.has(e[3])) return;
const v = views.get(e[1]);
if (!v || !v.windUp) return;
if (v.windUp(t - upcomingT)) woundUp.add(e);
}
const EVS = [];
function processEvents(renderT) {
EVS.length = 0;
interp.takeEvents(renderT, EVS, renderT - 1.5);
for (const e of EVS) {
try { handleEvent(e, renderT); } catch (err) { if (!handleEvent.warned) { handleEvent.warned = true; console.warn('[render] event failed', e, err); } }
}
}
function handleEvent(e, now) {
switch (e[0]) {
case 'spawn': {
const info = addInfo(e[1]);
if (!info) break;
gone.delete(info.id);
const v = battleView(info.id);
if (v && info.side === 'enemy' && renderT0Battle != null && now - renderT0Battle > 0.2) fx.ring(info.x, info.y, 0, 0.1, 0.7, 0xff5a4a, 0.4);
break;
}
case 'deploy': {
gone.delete(e[1]);
const v = battleView(e[1]);
if (v) { v.onDeploy?.(); if (v.info?.kind !== 'device') fx.deploy(v); }
break;
}
case 'atk': {
const src = views.get(e[1]) || battleView(e[1]);
const tgt = views.get(e[2]) || battleView(e[2]);
// chain / chainHeal bounces: the "source" is the previous target of the bounce, not an attacker
if (src && !CHAIN_KINDS.has(e[3])) src.onAttack?.(tgt, now);
if (e[3] === 'none' || !e[3]) { if (tgt && src) meleePending.set(tgt.id, { src, t: now }); }
fx.attack(src, tgt, e[3]);
break;
}
case 'dmg': {
const v = views.get(e[1]);
if (!v) break;
const amt = Number(e[2]) || 0;
const m = meleePending.get(v.id);
let src = null;
if (m && now - m.t < 0.35) { src = m.src; meleePending.delete(v.id); fx._slashAt = src.id; }
fx.damage(v, amt, e[3], src);
break;
}
case 'heal': { const v = views.get(e[1]); if (v) fx.heal(v, Number(e[2]) || 0); break; }
case 'skill': { const v = views.get(e[1]); if (v) { v.setSkill?.(!!e[2]); fx.skill(v, !!e[2]); } break; }
case 'die': {
const v = views.get(e[1]);
const used = consumedIds.delete(e[1]);
if (v && v.alive) { v.die(e[2] === FORCED_EXIT); if (showsDeathFx(v.info, used, e[2])) fx.death(v); }
break;
}
case 'leak': {
const v = views.get(e[1]);
if (v) {
leakFlash(v.x, v.y);
v.alive = false;
v.dying = 0.25; v.dieDur = 0.25;
}
fx.leak();
break;
}
case 'status': { const v = views.get(e[1]); if (v) v.onStatus?.(e[2], !!e[3]); break; }
case 'fx':
if (e[4] && typeof e[4] === 'object' && e[4].consumed && e[4].id != null) {
consumedIds.add(e[4].id);
if (consumedIds.size > 200) consumedIds.delete(consumedIds.values().next().value);
}
// an enemy's mode change (掠海漂移体 → 爬行模式, user playtest #5 item 1): its view switches clip set
// (UnitView.setForm); the info keeps it for a view built later
if (e[1] === 'phase' && e[4] && typeof e[4] === 'object' && e[4].id != null) {
const inf = infos.get(e[4].id);
if (inf) inf.form = typeof e[4].kind === 'string' ? e[4].kind : null;
views.get(e[4].id)?.setForm?.(e[4].kind);
}
fx.simFx(e[1], Number(e[2]), Number(e[3]), e[4]);
break;
case 'layer': {
const bondId = e[2], n = Number(e[3]) || 0;
if (!(n > 0) || typeof bondId !== 'string') break;
const k = bondId;
const cur = layerPops.get(k);
if (cur && now - cur.t < 0.8) { cur.n += n; break; }
layerPops.set(k, { t: now, n });
const url = assets.bondIcon ? assets.bondIcon(bondId) : null;
let tex = null;
if (url) { try { tex = P.Texture.from(url); } catch { tex = null; } }
fx.pop(tex, `+${n}`, 0xffffff, layerPops.size);
break;
}
case 'bounty': {
const n = Number(e[2]) || 0;
if (n > 0) fx.pop(fx.tex.coin, `+${n}`, COLORS.gold, 3);
break;
}
default: break;
}
}
function leakFlash(x, y) {
if (!stageRec) return;
const R = battleMeta?.rect || GEO.NORMAL_RECT;
let best = null, bd = Infinity;
for (let r = R.r0; r <= R.r1; r++) for (let c = R.c0; c <= R.c1; c++) {
if (tiles.tile(r, c).glyph !== 'E') continue;
const d = (r - y) ** 2 + (c - x) ** 2;
if (d < bd) { bd = d; best = [r, c]; }
}
if (best) { tiles.flashObjective(best[0], best[1]); board3d?.flashObjective(best[0], best[1]); }
}
let renderT0Battle = null; // game time of the first rendered battle frame (spawn puffs skip the initial wave)
let downSeq = 0; // syncBattle pass counter: a view still marked down after a pass left the `down` list
function syncBattle(renderT) {
if (renderT0Battle == null) renderT0Battle = renderT;
interp.sample(renderT, sample);
for (const [id, s] of sample) {
let v = views.get(id);
if (!v && gone.has(id)) {
if (s.anim === ANIM.DIE || !(s.hp > 0)) continue; // the tail of its DIE window: stays gone
gone.delete(id); // alive again (redeployed without an event)
}
if (!v) v = battleView(id) || createUnknown(id, s);
if (!v) continue;
if (!v._seen) { v._seen = true; v.fadeIn = 0; }
if (v.alive || v.info?.kind === 'device') v.sync(s, renderT);
else if (v.dying > 0) { v.x = s.x; v.y = s.y; }
else if (s.anim !== ANIM.DIE && s.hp > 0) { v.revive?.(); v.sync(s, renderT); }
}
// knocked-out operators waiting to redeploy (b.snap `down`, user playtest #4 item 9): their view stays on the
// field knocked down under a redeploy ring (UnitView.setDown) — made on the spot for one already down when this
// field was entered; one that leaves the list without a redeploy fades out
const down = interp.downAt(renderT);
downSeq++;
if (down) {
for (const d of down) {
let v = views.get(d[0]);
let fresh = false;
if (!v) {
const info = infos.get(d[0]);
if (!info || info.side !== 'ally' || info.kind === 'device') continue;
gone.delete(d[0]);
v = battleView(d[0]);
fresh = true;
}
if (!v || typeof v.setDown !== 'function') continue;
v._seen = true;
v.setDown(d, renderT, fresh);
v._downSeq = downSeq;
}
}
const now = performance.now();
for (const [id, v] of views) {
if (v.down && v._downSeq !== downSeq) v.setDown(null, renderT);
if (sample.has(id) || v.down) continue;
if (v._seen) {
// left the snapshot: removed (leaked / hidden / died and its DIE window passed)
if (v.alive) { v.alive = false; v.dying = 0.25; v.dieDur = 0.25; }
} else if (now - (v._born || now) > 8000) v.remove = true;
else if (v.root) v.root.alpha = 0;
}
}
function createUnknown(id, s) {
if (infos.has(id)) return null;
addInfo({ id, kind: 'enemy', side: 'enemy', defId: null, x: s.x, y: s.y, maxHp: s.maxHp });
infos.get(id)._unknown = true;
const v = battleView(id);
if (v) v._unknown = true;
return v;
}
// ---- frame --------------------------------------------------------------------------------------------------
let clock = 0;
let cpuMs = 0;
let frameNo = 0;
let impInterval = 0;
let vp = { width: s0.width, height: s0.height }; // viewport (CSS px) of this frame: unit culling
let culledCount = 0;
// Adaptive load level 0–3: a device that cannot hold the frame rate with the current work switches crowds to
// impostors earlier and animates small / far units at a lower rate (units.js); it steps back after a calm spell.
let loadLevel = 0, slowFor = 0, fastFor = 0;
function adaptLoad(dtRaw) {
if (!(dtRaw > 0) || dtRaw > 0.25 || globalThis.document?.hidden) return;
const busy = views.size + penViews.size > 8;
if (frameMs > 19.5 && busy) { slowFor += dtRaw; fastFor = 0; }
else if (frameMs < 17.6) { fastFor += dtRaw; slowFor = 0; }
if (slowFor > 1 && loadLevel < 3) { loadLevel++; slowFor = 0; fastFor = 0; impInterval = pickImpostorInterval(); }
else if (loadLevel > 0 && (fastFor > 6 * loadLevel || !busy && fastFor > 2)) { loadLevel--; fastFor = 0; impInterval = pickImpostorInterval(); }
}
// Crowded fields render skeletons through staggered RenderTexture impostors (units.js): the interval grows with
// the number of Spine units so the per-frame vertex work stays roughly constant (hysteresis: re-evaluated
// every 30 frames). Prep and ordinary fields keep full-rate direct rendering.
// Spine clipping masks (only eyeball clips on the current roster, invisible at chibi scale) each cost a stencil
// render-pass break (~2–5 ms of GPU on tiled GPUs): kept only for a lone clipped skeleton at high quality
let clipAllowed = true;
function pickClipping() {
let n = 0;
for (const v of views.values()) if (v.actor && v.actor.clipped && v.alive !== false) n++;
return settings.quality === 'high' && n <= 1;
}
function pickImpostorInterval() {
let n = 0;
for (const v of views.values()) if (v.actor && v.spineReady) n++;
const q = settings.quality;
const base = (q === 'low' ? 12 : q === 'medium' ? 26 : 44) / (1 + loadLevel);
if (n <= base) return 0;
// a struggling device (load level ≥ 2) may refresh a big crowd more rarely (10 Hz at worst)
return Math.min(loadLevel >= 2 ? 6 : 4, Math.max(2, Math.ceil(n / (base * 0.75))));
}
function frame() {
if (destroyed) return;
const now = performance.now();
try { frameBody(now); } finally { cpuMs = cpuMs * 0.9 + (performance.now() - now) * 0.1; }
}
function frameBody(now) {
frameNo++;
if (frameNo % 30 === 1) {
impInterval = pickImpostorInterval(); clipAllowed = pickClipping();
culledCount = 0;
for (const v of views.values()) if (v.culled) culledCount++;
for (const v of penViews.values()) if (v.culled) culledCount++;
}
const dtRaw = (now - lastNow) / 1000;
lastNow = now;
const dt = Math.min(0.1, Math.max(0, dtRaw));
if (dtRaw < 0.25) frameMs = frameMs * 0.9 + dtRaw * 1000 * 0.1;
fps = 1000 / Math.max(1, frameMs);
adaptLoad(dtRaw);
vp.width = Math.max(1, host.clientWidth || 1); vp.height = Math.max(1, host.clientHeight || 1);
clock += dt;
if ((globalThis.devicePixelRatio || 1) !== lastDpr) { lastDpr = globalThis.devicePixelRatio || 1; resize(); }
try {
stepCamera(now);
if (board3d) {
if (board3d.lost) onBoard3dLost();
else board3d.render(cam, clock);
}
tiles.project(cam);
updateBackdrop(clock);
if (mode === 'battle') {
const renderT = interp.update(now / 1000);
if (Number.isFinite(renderT)) {
processEvents(renderT);
syncBattle(renderT);
windUpAttacks(renderT);
}
} else if (mode === 'prep') {
for (const [key, pd] of pending) {
if (now - pd.t > DROP_PENDING_MS) {
pending.delete(key);
const v = views.get(key);
if (v && v._home && (!dragState || dragState.key !== key)) v._tween = { fx: v.x, fy: v.y, fz: v.z, tx: v._home.x, ty: v._home.y, tz: v._home.z, t: 0 };
}
}
}
for (const [key, v] of views) {
if (v._tween) {
const tw = v._tween;
tw.t = Math.min(1, tw.t + dt / 0.22);
const k = 1 - (1 - tw.t) * (1 - tw.t);
v.x = tw.fx + (tw.tx - tw.fx) * k; v.y = tw.fy + (tw.ty - tw.fy) * k; v.z = tw.fz + (tw.tz - tw.fz) * k;
if (tw.t >= 1) v._tween = null;
}
v.update(dt, cam, clock);
if (v.remove) { dropView(key); if (mode === 'battle') gone.add(key); }
}
if (!penHidden) for (const v of penViews.values()) v.update(dt, cam, clock);
tiles.update(dt);
fx.update(dt);
impostors.flush();
} catch (err) {
if (!frame.warned) { frame.warned = true; console.error('[render] frame failed', err); }
}
}
app.ticker.add(frame);
// render-cost probe (PIXI renders at UPDATE_PRIORITY.LOW = -25)
let renderT0 = 0, renderMs = 0;
const preRender = () => { renderT0 = performance.now(); };
const postRender = () => { if (renderT0) renderMs = renderMs * 0.9 + (performance.now() - renderT0) * 0.1; };
app.ticker.add(preRender, null, -24);
app.ticker.add(postRender, null, -26);
// ---- resize ---------------------------------------------------------------------------------------------------
function resize() {
if (destroyed) return;
const sz = size();
const res = dpr();
if (app.renderer.resolution !== res) app.renderer.resolution = res;
app.renderer.resize(sz.width, sz.height);
board3d?.resize(sz.width, sz.height, boardDpr());
layoutBackdrop();
const target = targetCamera(camKind, camOpts);
if (camTo) camTo = target; else cam = target;
tiles.project(cam, true);
}
let ro = null;
if (typeof ResizeObserver === 'function') {
ro = new ResizeObserver(() => resize());
ro.observe(host);
}
// a DPR change without a size change (window dragged to another monitor, zoom) must re-rasterise too; polled
// per frame because not every browser fires a resize / matchMedia change for it
let lastDpr = globalThis.devicePixelRatio || 1;
layoutBackdrop();
// ---- public API ---------------------------------------------------------------------------------------------
const view = {
setStage,
setCamera,
setPrep,
/** Enemy preview pen: a list in m.private.nextEnemies shape, or null to empty it (setPrep does this itself). */
setPen(list) { if (destroyed) return false; setPenList(Array.isArray(list) ? list : null); return true; },
enterBattle,
pushSnapshot,
pushEvents,
/**
* Client-side combat glue (DESIGN §14): battle frames come from the local simulation (public/js/battle/runner.js)
* every animation frame instead of the network, so the render clock trails them by ~2 frames (not the 100 ms
* jitter buffer) and runs at the battle's game speed. `{ on: false }` restores the network settings.
*/
setLocalFeed(o) {
const on = !!(o && o.on);
const speed = Number(o && o.speed) > 0 ? Number(o.speed) : 2;
interp.delay = on ? 0.034 : 0.1;
interp.defaultRate = on ? Math.min(20, speed) : 2;
interp.maxRate = on ? Math.max(8, speed * 1.5) : 8;
return true;
},
highlightTiles(tilesList, style) {
if (tilesList == null || (Array.isArray(tilesList) && !tilesList.length && style == null)) { clearHl(); return true; }
const st = style ?? 'range';
const key = hlKey(st);
if (Array.isArray(tilesList) && tilesList.length) hlReq.set(key, { tiles: tilesList, style: st }); else hlReq.delete(key);
drawHighlight(tilesList, st, key);
return true;
},
on(name, fn) {
if (typeof fn !== 'function') return () => {};
if (!listeners.has(name)) listeners.set(name, new Set());
listeners.get(name).add(fn);
return () => listeners.get(name)?.delete(fn);
},
off(name, fn) { listeners.get(name)?.delete(fn); },
pieceScreenRect(uid) {
let v = views.get('p:' + uid);
if (!v) for (const x of views.values()) if (x.uid === uid || x.info?.uid === uid || x.id === uid) { v = x; break; }
if (!v || v.destroyed) return null;
const b = v.bounds();
const r = canvas.getBoundingClientRect();
const left = r.left + b.x, top = r.top + b.y;
return { left, top, right: left + b.width, bottom: top + b.height, width: b.width, height: b.height, x: left, y: top };
},
/**
* Screen geometry of a BOARD-space tile (in the Final Assault prep: where that board tile is drawn on the boss
* field). `mirror` = the prep field is mirrored (the right-hand player): a screen-right swipe is board LEFT.
*/
tileScreen(row, col) {
if (destroyed || !Number.isInteger(row) || !Number.isInteger(col)) return null;
const d = mode === 'prep' ? prepXf.toDisp(row, col) : { row, col };
// Final Assault prep: a board tile that does not land on the boss field (a range reaching past it) has no screen
// place there (the same rule as the highlights, render/prepfield.js tilesToDisp)
if (mode === 'prep' && prepXf !== IDENTITY && !tilesToDisp(prepXf, [[row, col]]).length) return null;
const z = heightAt(d.row, d.col);
const r = canvas.getBoundingClientRect();
const c = cam.project(d.col, d.row, z);
if (!c || !Number.isFinite(c.x) || !Number.isFinite(c.y)) return null;
const corner = (dx, dy) => { const p = cam.project(d.col + dx, d.row + dy, z); return [r.left + p.x, r.top + p.y]; };
return { x: r.left + c.x, y: r.top + c.y, s: c.s, poly: [corner(-0.5, 0.5), corner(0.5, 0.5), corner(0.5, -0.5), corner(-0.5, -0.5)], mirror: mode === 'prep' && prepXf.mirror };
},
/**
* True when the view itself stripes range previews ('facing' / 'selRange' / 'range' groups) under the units (the 2D
* board): the direction wheel then needs no striped overlay of its own (it would cover the units).
*/
stripesUnder() { return !destroyed && !board3d; },
/** Where the prep pieces are shown: { kind: 'board'|'bossPrep', side: 'L'|'R', mirror } (render/prepfield.js). */
prepField() { return { kind: prepXf.kind, side: prepXf.side, mirror: prepXf.mirror }; },
holdPiece(uid, tile) {
if (destroyed || !Number.isInteger(uid)) return false;
if (!tile || !Number.isInteger(tile.row) || !Number.isInteger(tile.col)) { held.delete(uid); return true; }
const w = boardWorld(tile.row, tile.col);
held.set(uid, w);
const key = 'p:' + uid;
const v = views.get(key);
if (v && !v.destroyed && (!dragState || dragState.key !== key)) {
v._home = w;
if (Math.abs(v.x - w.x) + Math.abs(v.y - w.y) + Math.abs(v.z - w.z) > 1e-3) v._tween = { fx: v.x, fy: v.y, fz: v.z, tx: w.x, ty: w.y, tz: w.z, t: 0 };
v.lift = 0;
}
return true;
},
/** Show a prep piece facing a BOARD-space direction (drawn mirrored on the right half of the Final Assault prep). */
setPieceDir(uid, dir) {
const v = views.get('p:' + uid);
if (!v || v.destroyed || typeof v.setDir !== 'function' || typeof dir !== 'string') return false;
v.setDir(prepXf.dirToDisp(dir.toUpperCase()));
return true;
},
setSettings(s) {
if (!s || typeof s !== 'object') return;
const q = settings.quality;
if (typeof s.damageNumbers === 'boolean') settings.damageNumbers = s.damageNumbers;
if (s.quality === 'high' || s.quality === 'medium' || s.quality === 'low') settings.quality = s.quality;
if (q !== settings.quality) { board3d?.setQuality?.(settings.quality); resize(); }
},
resize,
/** Dev / settings: switch the board layer ('3d' loads three.js + the art when available; '2d' = atlas board). */
async setBoardMode(m) {
if (destroyed) return false;
if (m === '2d') { recover.off = true; clearTimeout(recover.timer); disable3d(); return false; }
recover.off = false; recover.tries = 0;
if (board3d) return true;
if (!webgl2Available(true)) return false; // an explicit request: a slow GPU is the caller's choice
const [THREE, pack] = await Promise.all([loadThree(), loadBoardPack(assets)]);
return enable3d(THREE, pack);
},
destroy() {
if (destroyed) return;
destroyed = true;
try { ro?.disconnect(); } catch { /* ignore */ }
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);
app.ticker.remove(frame);
app.ticker.remove(preRender);
app.ticker.remove(postRender);
clearTimeout(recover.timer);
drag.reset();
for (const k of [...views.keys()]) dropView(k);
clearPen();
try { fx.destroy(); } catch { /* ignore */ }
try { tiles.destroy(); } catch { /* ignore */ }
try { impostors.destroy(); } catch { /* ignore */ }
listeners.clear();
try { board3d?.destroy(); } catch { /* ignore */ }
board3d = null;
try { board3dCanvas?.remove(); } catch { /* ignore */ }
releaseGl(app.renderer);
try { app.destroy(true, { children: true, texture: false, baseTexture: false }); } catch { /* ignore */ }
},
stats() {
return {
fps: Math.round(fps * 10) / 10, frameMs: Math.round(frameMs * 100) / 100, cpuMs: Math.round(cpuMs * 100) / 100, renderMs: Math.round(renderMs * 100) / 100, mode, units: views.size, impostor: impInterval, impostorAtlas: { ...impostors.stats }, boardArt: !!tiles.atlas.art,
board3d: board3d ? { on: true, ...board3d.stats(), losses: recover.count } : { on: false, error: board3dError, recovering: !!recover.timer, losses: recover.count },
pen: penViews.size, prepField: prepXf.kind === 'bossPrep' ? prepXf.side : null, lod: loadLevel, culled: culledCount,
...fx.counts, spine: assets.spine?.stats ? assets.spine.stats() : null, renderT: interp.renderT, rate: interp.rate,
buffered: interp.size, camera: cam.params(),
};
},
get mode() { return mode; },
/** Dev hooks (demo / tests). */
debug: {
app, get cam() { return cam; }, get board3d() { return board3d; }, tiles, views, penViews, interp, fx, ctx, drag, get camKind() { return viewKind(camKind, camOpts); },
// picking (render/pick.js) at canvas px: the prep piece / battle view / pen view there, the ground tile under it
pick: { pieceAt, battleUnitAt, penUnitAt, groundTile },
},
};
return view;
}