The sim displaces at once (battle/displacement.js), so the snapshots only show the destination: a ≤ 2.5-tile push was lerped over one snapshot interval (3 frames, 0.58 tiles in one frame — frame-loop repro over a real battle), a longer pull snapped. render/app.js now starts UnitView.slideTo as soon as the interval it samples ends on the snapshot that carries the destination (a `displace` fx still queued up to SnapshotBuffer.nextSnapT); the view holds where it stood until that snapshot is shown, then eases into the sampled position under a constant deceleration (v0 = 2D/T, a = v0/T, T = 0.14·√D in 0.12–0.45 s — ours, [ASSUMED]: no source gives the official timing; docs/research/13 has the official impulse-and-friction classes). A unit that dies on the way finishes the slide and lies where the push put it. Death: DIE_CLIP_MAX 1.6 → 8 s (data/assets.json: 234 enemy Die clips, the longest 7.97 s, 25 over 1.6 s), and a flyer holds FLY_HOVER until the fade tail (DIE_FADE_TAIL 0.55 s). Substantially @xcdoge's code (PR #380: the slide, the death timing, research 13 / 14, most tests), authored as theirs. Changed on review: the corpse no longer goes back to where it stood (the PR's test asserted that; the sim's body is at the destination); the PR's pre-pass scanned (renderT − 0.5, renderT] after processEvents had taken those events, so it never fired and the slide started a frame before landing — it now looks ahead to the next snapshot and holds the view until then; the slide eases into the live sample instead of the fixed destination (no jump back onto an enemy that walked on); each step uses its mean velocity (the end-of-step speed ran 9 % short, then jumped); the constants' comments say the timing is ours; research 14 no longer cites a sweep script and a data guard that were not in the PR. Golden: unchanged (client only). Tests: test/render/unitview.test.js 35/35 (slide: deceleration, idempotence, facing, snapshots in flight, friction, √D, corpse lies where pushed, hold-then-ease without a landing jump, a never-shown slide handed back; the app.js frame order over a real battle: a 1.7-tile push over ≥ 8 frames with every step < 0.35 tiles, a push-kill's body staying at the destination; death: a dying flyer's lift, every Die clip within DIE_CLIP_MAX and a 7.97 s clip scheduled whole); test/sim/displace-fx.test.js 1/1; render/interp 16/16; docs tests pass.
1875 lines
101 KiB
JavaScript
1875 lines
101 KiB
JavaScript
// 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, shop }) '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, { shop })` = { 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); `shop:
|
||
// false` = the folded shop: the official shop-collapsed prep camera
|
||
// (left_prepare / *_boss_prepare) clear of the folded shop's band (public
|
||
// issue #5: folding the shop did not grow the board)
|
||
// view.setPrep(privateState, { editable, canPlace }) hand/temp/board pieces; editable enables drag & drop; a
|
||
// new board piece flashes (fx.deploy), and so does a card that just arrived in the hand or temp; a merge's elite — on the tile of
|
||
// the deployed copy it replaced, or on its bench slot — gets the
|
||
// promotion cue instead (render/promote.js, fx.promote)
|
||
// view.enterBattle(fieldMeta) m.field { fieldId, kind, rect, stageId, units: [UnitInfo] } — each
|
||
// unit through renderInfo(UnitInfo) (an enemy's `form`: a view built
|
||
// mid-battle starts in the current model form)
|
||
// 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; an
|
||
// fx with a `form` (shared/protocol.js fxForm) switches the enemy's
|
||
// model to that clip set (render/units.js FORMS)
|
||
// 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)
|
||
// tileClick { row, col, x, y } — the ground itself was tapped and nothing stands there (GitHub issue #184:
|
||
// a special terrain tile's own tip; the screen resolves it with gameLogic.terrainInfo)
|
||
// 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.
|
||
// The round's leader (community report #12, owner's decision 2026-10-04; research 09 §2.2: in the official Final Assault
|
||
// prep image the boss stands on the boss field with its HP bar): a nextEnemies entry with its spawn tile `start`
|
||
// (render/prepfield.js leaderStand) stands there — idle, with its (full) HP bar, facing the player's half, a tap shows its
|
||
// details like a pen enemy — instead of in the pen, shown by the boss-field prep camera only (`leaderShown`); while an
|
||
// operator's range preview shows (a RANGE_GROUPS highlight: the direction wheel's 'facing', a selected piece's range) its
|
||
// hit tiles (the sim's hit rectangle, render/pick.js hitTiles) are lit in red beside it (LEADER_HIT_STYLE). Display only.
|
||
// 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. The pair partner's board stands on the other half
|
||
// (`priv.bossMate` units, already in boss-field coordinates — server/match/match/views.js bossMateView), read-only
|
||
// ('m:<uid>' views: never picked or dragged), and the lit rect is the whole boss field: both players of a pair are
|
||
// shown together, as in the battle (community report of 2026-10-06, item 51).
|
||
// 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. That manifest is awaited with the asset
|
||
// manifest (≤ 4 s; the game seeds the store with its own copies, ui/fieldHost.js seedAssets), so an enemy whose model
|
||
// only the local client has draws it (assets.js spineEntry). A manifest that arrives later (assets.js onChange) makes
|
||
// every view re-resolve its picture and model (UnitView.retryAssets), and so does showing the tab again; battle mode
|
||
// holds the Spine cache so a battle begun in a hidden tab keeps its models (holdScene; public issue #8 item 5). 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.
|
||
//
|
||
// The helpers live in public/js/render/app/*.js and are re-exported below, so existing imports keep
|
||
// working. createFieldView stays here: it is one closure over the view's own state.
|
||
|
||
import { GEO, ANIM } from '../../../shared/constants.js';
|
||
import { fxForm } from '../../../shared/protocol.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, FORMS } 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 { 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 { unionAreas } from './board3d/layout.js';
|
||
import { layoutPen, penSignature } from './pen.js';
|
||
import { IDENTITY, bossPrepField, tilesToDisp, leaderStand } from './prepfield.js';
|
||
import { pickOnTile, pickBattle, hitTiles } from './pick.js';
|
||
import { promotionsOf } from './promote.js';
|
||
import { ensurePixi } from './app/pixi.js';
|
||
import { pieceDirOf, pickUnitOf } from './app/pick.js';
|
||
import { CAMERA_MS, BOARD3D_STABLE_MS, BOARD3D_RETRY_MS, PEN_CAMERA_MS, RANGE_GROUPS, LEADER_HIT_STYLE, DRAG_HOLD_TILES, CHAIN_KINDS, DROP_PENDING_MS } from './app/tune.js';
|
||
import { boardPreference, switchableBox, bandFor, fieldRows, boardArea, viewKind, penShown, leaderShown } from './app/view.js';
|
||
import { renderInfo, FORCED_EXIT, showsDeathFx } from './app/info.js';
|
||
import { resolveAssets, makeData, withTimeout, QUALITY_RES, BOARD_RES, releaseGl } from './app/host.js';
|
||
import { t } from '../../../shared/i18n.js';
|
||
|
||
export { ensurePixi } from './app/pixi.js';
|
||
export { PEN_CAMERA_MS, LEADER_HIT_STYLE, DRAG_HOLD_TILES } from './app/tune.js';
|
||
export { viewKind, boardArea, bandFor, fieldRows, penShown, leaderShown, boardPreference, switchableBox } from './app/view.js';
|
||
export { FORCED_EXIT, showsDeathFx, renderInfo } from './app/info.js';
|
||
export { releaseGl } from './app/host.js';
|
||
|
||
/**
|
||
* Create the battlefield view inside `host` (an element sized by CSS; the canvas fills it).
|
||
* @param {HTMLElement} host
|
||
* @param {{ data?: any, assets?: any, audio?: any, settings?: { damageNumbers?: boolean, quality?: string },
|
||
* padding?: object|((kind:string, size:{width:number,height:number}) => object),
|
||
* hud?: {top:number,bottom:number}|((kind:'prep'|'bossPrep', size:{width:number,height:number}, o:{shop:boolean}) => {top:number,bottom:number}|null) }} [opts]
|
||
*/
|
||
export async function createFieldView(host, options = {}) {
|
||
if (!host || typeof host.appendChild !== 'function') throw new TypeError('createFieldView: host element required');
|
||
const opts = options && typeof options === 'object' ? options : {};
|
||
const P = await ensurePixi();
|
||
const 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));
|
||
// the manifest, and the optional local-art manifest in parallel: unit views pick an enemy's local-client model by it
|
||
// (assets.js spineEntry, DESIGN §13 — 灼热源石虫 / 炽焰源石虫); absent or slow, they draw the web models
|
||
await withTimeout(Promise.all([assets.ready ? assets.ready() : null, assets.local ? assets.local() : null]
|
||
.map((p) => Promise.resolve(p).catch(() => {}))), 4000);
|
||
// 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', t('战场'));
|
||
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);
|
||
let mountainsAsked = false;
|
||
/** The backdrop's mountain silhouette (optional art; asked again when the manifest arrives late). */
|
||
function loadMountains() {
|
||
const mountainUrl = !mountainsAsked && assets.ui ? assets.ui('entry/bg_mountains_tiled') : null;
|
||
if (!mountainUrl || !assets.image) return;
|
||
mountainsAsked = true;
|
||
assets.image(mountainUrl).then((img) => {
|
||
if (!img || destroyed) return;
|
||
try { const mt = silhouetteTexture(img); bgMountains.texture = mt; bgMountains2.texture = mt; } catch { /* optional art */ }
|
||
}, () => {});
|
||
}
|
||
loadMountains();
|
||
|
||
// ---- 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 }
|
||
// the last prep pieces before a battle / scouting board emptied prepPieces: a merge completed between two preps (a band
|
||
// or 机变 grant at ROUND_START, a SETTLE merge) is still recognised by the next setPrep (QA 6b)
|
||
let promoBase = [];
|
||
const promotions = []; // the last merges cued by setPrep (fx.promote): { uid, id, area, row, col, idx, copies } — dev / tests
|
||
let battleMeta = null;
|
||
let sceneHold = null; // release function of the Spine cache hold of battle mode (holdScene)
|
||
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 standInList = []; // the own m.private.standIns (0.2.0 补位): pieces of these chess draw the stand-in
|
||
let diyPicks = {}; // the own m.private.diy (0.2.0 自选编队): pieces of these DIY slots draw the operator
|
||
let camBeforePen = null; // { kind, opts } the camera the pen returns to
|
||
let leader = null; // { key, view, stand, area } the round leader standing on the boss field in the prep (setLeader)
|
||
let leaderHidden = true; // shown only by the boss-field prep camera (leaderShown)
|
||
|
||
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),
|
||
// (a chess fighting as its 补位 stand-in — `standInFor` — reads the stand-in's record: its attack interval)
|
||
lookupDef: (info) => (info.side === 'enemy' ? data.enemy(info.defId)
|
||
: (info.standInFor && data.standIn(info.defId)) || (info.diy && data.diy(info.defId, info.diy)) || 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, info = null) => ((info && info.standInFor && data.standIn(defId)) || (info && info.diy && data.diy(defId, info.diy)) || 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; asked again when the
|
||
// manifest arrives late)
|
||
let shadowAsked = false;
|
||
function loadShadow() {
|
||
const shadowUrl = !shadowAsked && assets.ui ? assets.ui('battle/sprite_shadow') : null;
|
||
if (!shadowUrl) return;
|
||
shadowAsked = true;
|
||
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;
|
||
for (const v of penViews.values()) if (v.shadow && !v.destroyed) v.shadow.texture = t;
|
||
};
|
||
if (t.baseTexture.valid) use(); else t.baseTexture.once('loaded', use);
|
||
} catch { /* optional */ }
|
||
}
|
||
loadShadow();
|
||
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, { shop }) => { top, bottom } | null, or a fixed object; none → the plain official framing.
|
||
// `shop: false` asks for the folded shop's band (the camera request's `shop: false`, public issue #5)
|
||
function hudBands(kind, sz, o) {
|
||
if (kind !== 'prep' && kind !== 'bossPrep') return null;
|
||
if (typeof opts.hud === 'function') { try { return opts.hud(kind, sz, o) || 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; }
|
||
// the boss round's prep lights the whole boss field: the pair partner's half is shown with its pieces (item 51)
|
||
if (k === 'bossPrep') return { ...GEO.BOSS_RECT };
|
||
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
|
||
// — `shop: false` (the folded shop, public issue #5) = the official shop-collapsed camera, clear of the folded
|
||
// shop's HUD band (re-evaluated on resize: camOpts keep the flag)
|
||
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, { shop: o.shop !== false }),
|
||
});
|
||
}
|
||
|
||
// 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));
|
||
setLeaderHidden(!leaderShown(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));
|
||
setLeaderHidden(!leaderShown(vk, prevView));
|
||
pendingView = { band, focus, field, area: boardArea(vk), pen: penShown(vk), leader: leaderShown(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);
|
||
faceLeader();
|
||
syncLeaderHits();
|
||
}
|
||
|
||
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);
|
||
setLeaderHidden(!pendingView.leader);
|
||
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 chess = data.chess(piece.id);
|
||
// 0.2.0 补位: a piece of a chess the player does not own (m.private.standIns) is its stand-in's model, in the hand,
|
||
// the 临时整备区 and on the board alike (the owner's recall of the official mode, 2026-10-06)
|
||
const si = chess && standInList.includes(chess.baseId || chess.chessId) ? data.standIn(piece.id) : null;
|
||
// 0.2.0 自选编队: a DIY slot the player filled is its operator, on the board and on the bench alike
|
||
const pick = chess && chess.isDiy ? diyPicks[chess.baseId || chess.chessId] : null;
|
||
const dr = pick ? data.diy(piece.id, pick) : null;
|
||
const rec = si || dr || chess;
|
||
return {
|
||
kind: 'op', side: 'ally', defId: piece.id,
|
||
spine: rec?.assets?.spine || rec?.charId || null, avatar: rec?.assets?.avatar || rec?.charId || null,
|
||
tier: chess?.tier || piece.tier || 1, golden: !!(piece.golden || chess?.isGolden), dir,
|
||
...(si ? { standInFor: si.standInFor } : null),
|
||
...(dr ? { diy: { charId: pick.charId, skillIndex: pick.skillIndex ?? null, uniEquipId: pick.uniEquipId ?? null } } : null),
|
||
};
|
||
}
|
||
|
||
/** 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 : {};
|
||
standInList = Array.isArray(src.standIns) ? src.standIns.filter((x) => typeof x === 'string') : [];
|
||
diyPicks = src.diy && typeof src.diy === 'object' ? src.diy : {};
|
||
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));
|
||
// merges since the last state (render/promote.js): new elite uid → where its consumed copies stood (their views
|
||
// still exist until the sweep below) — the elite gets the promotion cue instead of the deploy flash
|
||
const promoFrom = new Map();
|
||
const before = prepPieces.length ? prepPieces : promoBase;
|
||
promoBase = [];
|
||
for (const [uid, copies] of promotionsOf(before, list, (id) => data.chess(id))) {
|
||
// the consumed copies' views (gone after a battle rebuilt the scene: their slots instead) — the streaks' origins
|
||
promoFrom.set(uid, copies.map((g) => {
|
||
const cv = views.get(g.key);
|
||
if (cv && !cv.destroyed) return { x: cv.x, y: cv.y, z: cv.z || 0 };
|
||
const w = slotWorld(g);
|
||
return w ? { x: w.x, y: w.y, z: w.z || 0 } : null;
|
||
}).filter(Boolean));
|
||
}
|
||
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);
|
||
// (the model is part of it: a piece whose body changes — a merge, an own 补位 / 自选 setting arriving — is rebuilt)
|
||
const sig = `${info.kind}|${info.defId}|${info.golden ? 1 : 0}|${info.spine || ''}`;
|
||
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 (promoFrom.has(e.uid)) {
|
||
// a merge's elite: on the tile of the deployed copy it replaced, or on its bench slot
|
||
if (e.area === 'board') v.onDeploy?.();
|
||
fx.promote(v, promoFrom.get(e.uid).filter((f) => Math.abs(f.x - w.x) + Math.abs(f.y - w.y) > 1e-3));
|
||
promotions.push({ uid: e.uid, id: e.piece.id, area: e.area, row: e.row ?? null, col: e.col ?? null, idx: e.idx ?? null, copies: promoFrom.get(e.uid).length });
|
||
if (promotions.length > 20) promotions.shift();
|
||
} else if (e.area === 'board' && prevBoard.size && !prevBoard.has(e.uid)) { v.onDeploy?.(); fx.deploy(v); }
|
||
else if (before.length && (e.area === 'hand' || e.area === 'temp') && !before.some((g) => g.uid === e.uid)) 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 && !splitLanded(e, pending.get(key).board, list)) {
|
||
// 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 (info.kind === 'item' && v.setIcon) v.setIcon(info.icon); // an icon the manifest named late (onAssets)
|
||
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);
|
||
// the boss round's prep: the pair partner's board on its half of the boss field (item 51; display only)
|
||
syncMates(prepXf.kind === 'bossPrep' && src.bossMate && Array.isArray(src.bossMate.units) ? src.bossMate.units : []);
|
||
prepPieces = list.filter((e) => e.key && views.has(e.key));
|
||
if (dragState && !views.has(dragState.key)) { drag.reset(); endDragVisual(false); }
|
||
holdScene(false); // the prep pieces reference their models now (a battle's hold ends here)
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* The pair partner's pieces in the Final Assault / Hidden Core prep (m.private bossMate units: UnitInfo in boss-field
|
||
* coordinates, facing as the battle will place them): read-only 'm:<uid>' views — not prep pieces, so they are never
|
||
* picked, dragged or swept with the own ones; a view is rebuilt when its body, tile or facing changes.
|
||
*/
|
||
function syncMates(units) {
|
||
const keep = new Set();
|
||
for (const u of units) {
|
||
const info = u && Number.isInteger(u.uid) ? renderInfo({ ...u, id: `m:${u.uid}` }) : null;
|
||
if (!info) continue;
|
||
keep.add(info.id);
|
||
const sig = `${info.defId}|${info.golden ? 1 : 0}|${info.spine || ''}|${info.x},${info.y}|${info.dir || ''}|${(info.items || []).join(',')}`;
|
||
let v = views.get(info.id);
|
||
if (v && v._sig !== sig) { dropView(info.id); v = null; }
|
||
if (v) continue;
|
||
v = new UnitView(ctx, { ...info, maxHp: 1 }, { prep: true });
|
||
v._sig = sig;
|
||
v.setWorld(info.x, info.y, heightAt(info.y, info.x));
|
||
if (info.dir && typeof v.setDir === 'function') v.setDir(info.dir);
|
||
v._showFacing = true;
|
||
if (v.setItems && Array.isArray(info.items) && info.items.length) {
|
||
v.setItems(info.items.map((it) => { const r = data.item(it); return assets.itemIcon ? assets.itemIcon(r ? { trapId: r.trapId, iconId: r.iconId } : it) : null; }));
|
||
}
|
||
views.set(info.id, v);
|
||
}
|
||
for (const k of [...views.keys()]) if (String(k).startsWith('m:') && !keep.has(k)) dropView(k);
|
||
}
|
||
|
||
/**
|
||
* A summon stack of several copies (凯瑟琳's 2 devices, user playtest #6) dropped on a board tile stays in the hand
|
||
* and one copy — a new piece of the same token — lands there: the state already shows the move, the stack goes home.
|
||
*/
|
||
function splitLanded(e, tile, list) {
|
||
if (!tile || !e || e.piece?.kind !== 'token' || e.area === 'board') return false;
|
||
return list.some((x) => x.area === 'board' && x.uid !== e.uid && x.piece?.kind === 'token' && x.piece.id === e.piece.id && x.row === tile.row && x.col === tile.col);
|
||
}
|
||
|
||
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;
|
||
// the leader with a spawn tile stands on the boss field (setLeader), not in the pen
|
||
const stand = leaderStand(list, (k) => data.enemy(k)?.hitArea ?? null, hitTiles);
|
||
setLeader(stand);
|
||
const pen = layoutPen(stand ? list.filter((e) => e !== stand.entry) : 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;
|
||
clearLeader();
|
||
}
|
||
|
||
/**
|
||
* The round's leader standing on the boss field in the Final Assault / Hidden Core prep (`stand`: prepfield.js
|
||
* leaderStand; see the header) or none. Kept while the same leader stands on the same tile.
|
||
*/
|
||
function setLeader(stand) {
|
||
const key = stand ? `${stand.entry.enemyKey}@${stand.row},${stand.col}` : null;
|
||
if ((leader ? leader.key : null) === key) return;
|
||
clearLeader();
|
||
if (!stand) return;
|
||
const k = stand.entry.enemyKey;
|
||
const rec = data.enemy(k);
|
||
// not a prep view: a boss shows its HP bar (full: the round has not begun)
|
||
const v = new UnitView(ctx, {
|
||
id: 'leader:' + k, kind: 'enemy', side: 'enemy', defId: k, enemyKey: k, preview: true, name: rec?.name || k, tier: 3, golden: false,
|
||
spine: rec?.spine || k, avatar: rec?.iconId || rec?.avatar || k, x: stand.col, y: stand.row, facing: -1, maxHp: 1, boss: true,
|
||
});
|
||
v.setWorld(stand.col, stand.row, heightAt(stand.row, stand.col));
|
||
v.fadeIn = 0;
|
||
leader = { key, view: v, stand, area: rec?.hitArea ?? null };
|
||
faceLeader();
|
||
if (leaderHidden) hidePenView(v, true);
|
||
syncLeaderHits();
|
||
}
|
||
/** The leader faces the player's half of the boss field (left half → left) [ASSUMED look]. */
|
||
function faceLeader() { if (leader) leader.view.visFacing = prepXf.side === 'R' ? 1 : -1; }
|
||
function clearLeader() {
|
||
if (!leader) return;
|
||
try { leader.view.destroy(); } catch { /* ignore */ }
|
||
leader = null;
|
||
syncLeaderHits();
|
||
}
|
||
function setLeaderHidden(hidden) {
|
||
leaderHidden = !!hidden;
|
||
if (leader) hidePenView(leader.view, leaderHidden);
|
||
syncLeaderHits();
|
||
}
|
||
/** The leader's hit tiles in red while an operator's range preview shows in the boss-field prep (LEADER_HIT_STYLE). */
|
||
function syncLeaderHits() {
|
||
if (!tiles) return;
|
||
const on = !!leader && !leaderHidden && mode === 'prep' && prepXf.kind === 'bossPrep' && [...hlReq.keys()].some((g) => RANGE_GROUPS.has(g));
|
||
if (on) tiles.setHighlights(leader.stand.tiles, LEADER_HIT_STYLE, LEADER_HIT_STYLE.group);
|
||
else tiles.clearHighlights(LEADER_HIT_STYLE.group);
|
||
}
|
||
/** The leader under a canvas point (its drawn body or hit area, like a battle enemy), or null. */
|
||
function leaderAt(x, y) {
|
||
if (!leader || leaderHidden) return null;
|
||
const u = pickUnitOf(leader.view, true, leader.area);
|
||
return u && pickBattle([u], groundTile(x, y), x, y) ? leader.view : 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);
|
||
syncLeaderHits();
|
||
}
|
||
|
||
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, board: t.area === 'board' ? { row, col } : null });
|
||
} 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;
|
||
}
|
||
|
||
/**
|
||
* The ground itself was tapped: nothing stands there, so the TILE explains itself — a special terrain tile (活性源石,
|
||
* 沼泽, 排气格栅, 深水区, 红/蓝门, 传送) opens its own card (GitHub issue #184; screens/game.js `tileClick` →
|
||
* gameLogic.terrainInfo, which says nothing about an ordinary floor / road / wall tile).
|
||
* The tile is picked as a BOARD tile (`pickBoardTile`, i.e. through `prepXf.toBoard`): on a Final Assault / Hidden Core
|
||
* PREP the board draws the boss field's own rows (stage 2–5 as board 9–12), and the screen maps board → stage once more
|
||
* with `gameLogic.fieldTile` — reporting the DRAWN tile here would be converted twice and explain the wrong tile
|
||
* (review on #185).
|
||
*/
|
||
function emitTileClick(ev, e) {
|
||
const t = pickBoardTile(ev.x, ev.y);
|
||
if (!t || !(t.row >= 0) || !(t.col >= 0)) return; // outside the board this field draws
|
||
emit('tileClick', { row: t.row, col: t.col, button: e.button, clientX: e.clientX, clientY: e.clientY });
|
||
}
|
||
|
||
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);
|
||
return;
|
||
}
|
||
const pv = penViews.size ? penUnitAt(ev.x, ev.y) : null;
|
||
if (pv) emitPenClick(pv, e);
|
||
else emitTileClick(ev, e);
|
||
return;
|
||
}
|
||
if (drag.pointerDown(ev)) { try { canvas.setPointerCapture(e.pointerId); } catch { /* ignore */ } return; }
|
||
if (mode === 'prep') { const lv = leaderAt(ev.x, ev.y); if (lv) { emitPenClick(lv, e); return; } }
|
||
if (penViews.size && mode === 'prep') {
|
||
const pv = penUnitAt(ev.x, ev.y);
|
||
if (pv) { emitPenClick(pv, e); return; }
|
||
}
|
||
emitTileClick(ev, 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(); };
|
||
// The canvas is a click target too (a no-op listener). The browser's touch adjustment moves a tap onto a nearby
|
||
// element that responds to clicks (click / mousedown listeners, buttons, links; pointer listeners do not count) when the
|
||
// finger's contact area reaches one, so a tap on the back row right under the bond strip's discs (row 12 at 844×390 once
|
||
// the 收起 toggle of PR #149 moved the discs one button to the right) opened the bond popup instead of selecting the
|
||
// unit. As a click target that holds the finger's point the canvas wins: a tap on the board stays on the tile under it.
|
||
const onTapTarget = () => {};
|
||
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 });
|
||
canvas.addEventListener('click', onTapTarget);
|
||
|
||
// ---- battle ---------------------------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Battle mode holds the Spine cache (assets.js `spine.hold()`, its memory policy): the battle's views are built in
|
||
* animation frames, which a hidden tab does not run — a battle that began in a background tab referenced no skeleton, and
|
||
* the quiet budget emptied the cache ≈ 18 s later, so back in the tab every unit was an avatar diamond until its model
|
||
* downloaded again (public issue #8 item 5). Released once the prep pieces reference their models (setPrep) and on
|
||
* destroy; the idle budget still applies meanwhile.
|
||
*/
|
||
function holdScene(on) {
|
||
if (on) { if (!sceneHold && typeof assets.spine?.hold === 'function') sceneHold = assets.spine.hold(); return; }
|
||
const release = sceneHold;
|
||
sceneHold = null;
|
||
if (release) { try { release(); } catch { /* ignore */ } }
|
||
}
|
||
|
||
function enterBattle(meta) {
|
||
if (destroyed || !meta || typeof meta !== 'object') return false;
|
||
holdScene(true); // before the prep views go: the cache is never "quiet" across the switch
|
||
drag.reset();
|
||
endDragVisual(false);
|
||
clearViews();
|
||
if (prepPieces.length) promoBase = prepPieces;
|
||
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) {
|
||
const info = renderInfo(u);
|
||
if (info) infos.set(info.id, info);
|
||
return info;
|
||
}
|
||
|
||
// a hand item on a scouted prep board (UnitInfo kind 'item'): the plate's icon and colour resolve client-side,
|
||
// exactly like the own prep bench (pieceInfo)
|
||
function scoutItemInfo(info) {
|
||
const rec = data.item(info.defId);
|
||
const tier = rec?.tier || info.tier || 1;
|
||
return { ...info,
|
||
icon: assets.itemIcon ? assets.itemIcon(rec ? { trapId: rec.trapId, iconId: rec.iconId } : info.defId) : null,
|
||
color: (info.golden || rec?.isGolden) ? 0xffc600 : TIER_COLORS[tier] || TIER_COLORS[1] };
|
||
}
|
||
|
||
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)
|
||
: info.kind === 'item' ? new ItemView(ctx, scoutItemInfo(info))
|
||
: new UnitView(ctx, info, { prep: !!battleMeta?.prep && info.side === 'ally' });
|
||
// a teammate's operator shows its equipped items like the own prep bench does (item pips; user playtest #2:
|
||
// at the unit, not only in the detail card) — prep surfaces only, the battle HUD stays as it is
|
||
if (v.setItems && battleMeta?.prep && Array.isArray(info.items) && info.items.length) {
|
||
v.setItems(info.items.map((it) => { const r = data.item(it); return assets.itemIcon ? assets.itemIcon(r ? { trapId: r.trapId, iconId: r.iconId } : it) : null; }));
|
||
}
|
||
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, e[3])) woundUp.add(e);
|
||
}
|
||
|
||
const EVS = [];
|
||
/** State events handed out more than 1.5 game s late (a stall, a hidden tab, a field entered late) → how late. */
|
||
const LATE = new Map();
|
||
function processEvents(renderT) {
|
||
EVS.length = 0;
|
||
LATE.clear();
|
||
interp.takeEvents(renderT, EVS, renderT - 1.5, LATE);
|
||
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, e[3]);
|
||
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; }
|
||
// the skill slot an enemy ability casts (PR #275): the view swaps to that slot's clip (a multi-skill boss's Skill_01..04)
|
||
case 'cast': { const v = views.get(e[1]); if (v) v.setSkillSlot?.(e[2] | 0); 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);
|
||
}
|
||
// 推拉: a push / pull (battle/displacement.js) moves the enemy inside one call; its `displace` fx carries the
|
||
// destination ['fx', 'displace', x, y, { id }] and the view slides there (UnitView.slideTo, PR #380) — normally
|
||
// started already by syncBattle's look-ahead, which this repeats harmlessly
|
||
if (e[1] === 'displace') {
|
||
const d = e[4] && typeof e[4] === 'object' ? e[4] : null;
|
||
const v = d && d.id != null ? views.get(d.id) : null;
|
||
if (v && v.slideTo) v.slideTo(Number(e[2]), Number(e[3]));
|
||
}
|
||
// an enemy's mode change — the `form` of a sim setForm fx (shared/protocol.js fxForm: 掠海漂移体 → 爬行模式, user
|
||
// playtest #5 item 1; 转译基底's forms, a 逐火 ember and its revival, the leaders' 重生, 守墓石像 — user report after
|
||
// 0.1.0) — switches the view's clip set (UnitView.setForm; a kind without a clip set of that skeleton changes
|
||
// nothing); the info keeps it for a view built later. No client stage drops these fx: battle/runner.js
|
||
// keepsState (catch-up frames, hidden-tab backlog), screens/game.js keepEarly (the pre-entry buffer) and
|
||
// render/interp.js isCosmeticEvent (stale-event drop, full-queue shed). One handed out late switches the model
|
||
// without its telegraph, its closing clip shortened by the lateness.
|
||
const form = fxForm(e);
|
||
const late = form !== undefined ? LATE.get(e) || 0 : 0;
|
||
if (form !== undefined) {
|
||
const inf = infos.get(e[4].id);
|
||
if (inf && (form === null || FORMS[inf.spine || inf.defId]?.[form])) inf.form = form;
|
||
const x = late > 0 ? { ...e[4], late, ...(Number(e[4].dur) > 0 ? { dur: Math.max(0, Number(e[4].dur) - late) } : {}) } : e[4];
|
||
views.get(e[4].id)?.setForm?.(form, x);
|
||
}
|
||
if (!(late > 0)) 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]); }
|
||
}
|
||
|
||
/** syncBattle's look-ahead: a queued `displace` fx ['fx', 'displace', x, y, { id }] starts that view's slide. */
|
||
let slideAt = 0;
|
||
function startSlide(e) {
|
||
if (!Array.isArray(e) || e[0] !== 'fx' || e[1] !== 'displace') return;
|
||
const extra = e[4] && typeof e[4] === 'object' ? e[4] : null;
|
||
const v = extra && extra.id != null ? views.get(extra.id) : null;
|
||
if (v && v.slideTo) v.slideTo(Number(e[2]), Number(e[3]), { at: slideAt });
|
||
}
|
||
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;
|
||
// A push / pull's slide starts as soon as the interval sampled below ends on the snapshot that carries the
|
||
// destination: its `displace` fx is due only when renderT reaches that snapshot, and by then sample() has already
|
||
// lerped (≤ teleport) or snapped the enemy most of the way — no frames in between (PR #380). The fx are still queued
|
||
// here (processEvents took only those ≤ renderT); the view holds until `at`, then eases into the sim's position.
|
||
slideAt = interp.nextSnapT(renderT);
|
||
if (slideAt > renderT) interp.forEachUpcoming(renderT, slideAt, startSlide);
|
||
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);
|
||
if (leader && !leaderHidden) leader.view.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();
|
||
|
||
// ---- late assets, hidden tabs (public issue #8 item 5: operators drawn as image-less placeholders) ----------------------
|
||
// The asset manifest (or the local-client one) arrived after views were built — a reload whose manifest fetch was slow or
|
||
// failed (assets.js onChange): every view re-resolves what it could not draw (UnitView.retryAssets) and the prep pieces
|
||
// re-read their item icons. The tab shown again: a manifest still missing is asked for again and views without a model
|
||
// load it again at once (their bounded retries never run while hidden: no frames).
|
||
function onAssets() {
|
||
if (destroyed) return;
|
||
loadShadow();
|
||
loadMountains();
|
||
if (mode === 'prep' && lastPrep) setPrep(lastPrep.ps, lastPrep.o);
|
||
for (const v of views.values()) v.retryAssets?.();
|
||
for (const v of penViews.values()) v.retryAssets?.();
|
||
leader?.view.retryAssets?.();
|
||
}
|
||
const offAssets = typeof assets.onChange === 'function' ? assets.onChange(onAssets) : null;
|
||
const onVisible = () => {
|
||
if (destroyed || globalThis.document?.visibilityState !== 'visible') return;
|
||
if (assets.loaded === false && typeof assets.ready === 'function') assets.ready();
|
||
for (const v of views.values()) v.retryAssets?.();
|
||
for (const v of penViews.values()) v.retryAssets?.();
|
||
leader?.view.retryAssets?.();
|
||
};
|
||
globalThis.document?.addEventListener?.('visibilitychange', onVisible);
|
||
|
||
// ---- 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);
|
||
syncLeaderHits();
|
||
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 */ }
|
||
try { offAssets?.(); } catch { /* ignore */ }
|
||
globalThis.document?.removeEventListener?.('visibilitychange', onVisible);
|
||
canvas.removeEventListener('pointerdown', onPointerDown);
|
||
canvas.removeEventListener('pointermove', onPointerMove);
|
||
canvas.removeEventListener('pointerup', onPointerUp);
|
||
canvas.removeEventListener('pointercancel', onPointerCancel);
|
||
canvas.removeEventListener('pointerleave', onPointerLeave);
|
||
canvas.removeEventListener('contextmenu', onContext);
|
||
canvas.removeEventListener('touchend', onTouchEnd);
|
||
canvas.removeEventListener('click', onTapTarget);
|
||
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();
|
||
holdScene(false); // no scene any more: the quiet budget may free the skeletons (lobby / room / result)
|
||
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, get leader() { return leader; }, interp, fx, ctx, drag, get camKind() { return viewKind(camKind, camOpts); },
|
||
promotions,
|
||
// 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;
|
||
}
|