feedback5 R5: split the field-view helpers out of the render app
PIXI loading, pick records, tunables, view kind, unit info and the asset / GL host now live in public/js/render/app/*.js. createFieldView stays in app.js, unchanged, and every name the file used to export is re-exported. One pixiPromise. Two source pins follow the lines that moved.
This commit is contained in:
1 parent
c1b8292f92
commit
aaef748ad9
9 files changed
+323
-276
No files matched your search
+17
-272
@@ -103,8 +103,11 @@
|
||||
// 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, UF } from '../../../shared/constants.js';
|
||||
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';
|
||||
@@ -114,285 +117,27 @@ import { FxSystem, ensureDamageFonts } from './fx.js';
|
||||
import { createDragController, pieceTile } from './drag.js';
|
||||
import { backdropTextures, shadowTexture, refreshTierChips, silhouetteTexture } from './textures.js';
|
||||
import { TILE_H, TIER_COLORS, COLORS } from './style.js';
|
||||
import { createAssets, assets as defaultAssets } from '../assets.js';
|
||||
import { loadBoardArt } from './boardArt.js';
|
||||
import { ImpostorAtlas } from './impostor.js';
|
||||
import { loadThree, loadBoardPack, webgl2Available, boardArtListed } from './board3d/load.js';
|
||||
import { BoardScene } from './board3d/scene.js';
|
||||
import { AREAS, areaFor, unionAreas } from './board3d/layout.js';
|
||||
import { unionAreas } from './board3d/layout.js';
|
||||
import { layoutPen, penSignature } from './pen.js';
|
||||
import { IDENTITY, bossPrepField, tilesToDisp, leaderStand } from './prepfield.js';
|
||||
import { pickOnTile, pickBattle, hitRectAt, hitTiles } from './pick.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';
|
||||
|
||||
const VENDOR = { pixi: '/vendor/pixi.min.js', spine: '/vendor/pixi-spine.js' };
|
||||
const PIECE_DIRS = new Set(['UP', 'RIGHT', 'DOWN', 'LEFT']);
|
||||
/** Stored facing of a prep piece (m.private board pieces carry `dir`; bench pieces have none ⇒ undefined). */
|
||||
const pieceDirOf = (piece) => (typeof piece?.dir === 'string' && PIECE_DIRS.has(piece.dir.toUpperCase()) ? piece.dir.toUpperCase() : undefined);
|
||||
const CAMERA_MS = 750;
|
||||
/** Camera pan to / from the enemy preview pen (configBlackBoard move_time 0.25). */
|
||||
export const PEN_CAMERA_MS = 250;
|
||||
/** Recovery of a lost 3D board context: delays of the rebuild attempts (ms) and how soon a new loss counts as failure. */
|
||||
const BOARD3D_RETRY_MS = [1200, 4000, 12000];
|
||||
const BOARD3D_STABLE_MS = 10000;
|
||||
/** Highlight groups that show a unit's range: never drawn on bench / temp pads (they are not part of any battle). */
|
||||
const RANGE_GROUPS = new Set(['facing', 'range', 'rangeStand', 'select', 'sel', 'selRange']);
|
||||
/**
|
||||
* The round leader's hit tiles, lit beside an operator's range preview in the Final Assault / Hidden Core prep (see the
|
||||
* header; community report #12 "boss受击范围可以像官方原版那样用红色"). [ASSUMED] the red and its strength: the players' request,
|
||||
* no source shows the official colour; the range preview stays orange.
|
||||
*/
|
||||
export const LEADER_HIT_STYLE = Object.freeze({ group: 'leaderHit', color: 0xff3b30, fill: 0.3, line: 0.95 });
|
||||
/** atk projectile kinds whose first id is the previous bounce target (sim ai.js), not the attacker. */
|
||||
const CHAIN_KINDS = new Set(['chain', 'chainHeal']);
|
||||
const DROP_PENDING_MS = 1300;
|
||||
/**
|
||||
* A dragged unit is held with its drawn feet this many tiles below the pointer — the pointer on its body, the model
|
||||
* under the finger / mouse (user playtest #4 item 1: as in v2.1; mouse and touch alike). An item plate is centred on it.
|
||||
*/
|
||||
export const DRAG_HOLD_TILES = 0.45;
|
||||
|
||||
/**
|
||||
* A view as a render/pick.js unit, or null when it cannot be picked (gone, faded out, dead): standing on the display
|
||||
* tile it is drawn on, or — `walks` (battle enemies) — by its ground position and its drawn body (feet to head, on
|
||||
* screen; a flying one by its body alone).
|
||||
*/
|
||||
function pickUnitOf(v, walks = false, hitArea = null) {
|
||||
// a knocked-out operator lying on its tile waiting to redeploy (b.snap `down`, user playtest #4 item 9) is on that tile
|
||||
// too: a press there selects it; other dead / dying views are gone
|
||||
if (!v || v.destroyed || (v.alive === false && !v.down)) return null;
|
||||
if (Number.isFinite(v.alpha) && v.alpha < 0.05) return null;
|
||||
const sc = v.screen;
|
||||
const body = walks && sc && sc.s > 0 && !v.culled ? { x: sc.x, top: Number.isFinite(sc.top) ? sc.top : sc.y, feet: sc.y, s: sc.s } : null;
|
||||
const tile = walks ? null : { row: Math.round(v.y), col: Math.round(v.x) };
|
||||
// a huge boss (data `hitArea`): its hit area on the ground and a body box as wide as it are pickable (render/pick.js)
|
||||
const area = walks && !v.flying ? hitRectAt(v.x, v.y, hitArea) : null;
|
||||
if (area && body) body.hw = hitArea.w / 2;
|
||||
return { tile, x: v.x, y: v.y, fly: walks && !!v.flying, body, area, depth: v.root && !v.root.destroyed ? v.root.zIndex : 0, ref: v };
|
||||
}
|
||||
|
||||
let pixiPromise = null;
|
||||
|
||||
function loadScript(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = src;
|
||||
s.async = false;
|
||||
s.onload = () => resolve();
|
||||
s.onerror = () => reject(new Error(`failed to load ${src}`));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
/** Make sure PIXI and PIXI.spine exist (classic scripts; loaded once). */
|
||||
export function ensurePixi() {
|
||||
if (globalThis.PIXI && globalThis.PIXI.spine) return Promise.resolve(globalThis.PIXI);
|
||||
if (!pixiPromise) {
|
||||
pixiPromise = (async () => {
|
||||
if (!globalThis.PIXI) await loadScript(VENDOR.pixi);
|
||||
if (!globalThis.PIXI?.spine) await loadScript(VENDOR.spine);
|
||||
if (!globalThis.PIXI || !globalThis.PIXI.spine) throw new Error('PIXI / pixi-spine unavailable');
|
||||
return globalThis.PIXI;
|
||||
})();
|
||||
pixiPromise.catch(() => { pixiPromise = null; });
|
||||
}
|
||||
return pixiPromise;
|
||||
}
|
||||
|
||||
const VIEW_KINDS = new Set(['prep', 'normal', 'unite', 'boss', 'bossPrep', 'pen']);
|
||||
|
||||
/**
|
||||
* The field a camera request actually shows — what render/projection.js presetCamera frames: 'hidden' → 'boss';
|
||||
* a 'prep' camera asked for the boss rows (Final Assault prep: rect r1 ≤ 6) → 'bossPrep'; unknown kinds → 'normal'.
|
||||
* Drives the built 3D area, the drawn 2D rows and the lit rect, so they always match the camera.
|
||||
*/
|
||||
export function viewKind(kind, opts) {
|
||||
let k = kind === 'hidden' ? 'boss' : kind;
|
||||
if (!VIEW_KINDS.has(k)) k = 'normal';
|
||||
if (k === 'prep' && opts && opts.rect && normRect(opts.rect).r1 <= 6) k = 'bossPrep';
|
||||
return k;
|
||||
}
|
||||
|
||||
/** 3D areas without the enemy preview pen block (the own field / both normal halves); see `boardArea`. */
|
||||
const AREA_NO_PEN = Object.freeze({
|
||||
normal: Object.freeze(AREAS.normal.filter((a) => a.r1 <= 13)),
|
||||
unite: Object.freeze(AREAS.unite.filter((a) => a.r1 <= 13)),
|
||||
});
|
||||
|
||||
/**
|
||||
* 3D area built for a view kind (viewKind): the enemy preview pen (rows 14–18) only for the 'pen' camera — the prep,
|
||||
* battle and 联防 cameras show the field alone (user playtest #2 item 6) with its separator rows 6 and 13 (the row-13
|
||||
* devices blow into the field: act2 m01's blowers, user playtest #5 item 6; the boss field's row-6 devices are drawn
|
||||
* with the boss field only — board3d/layout.js stageDevices); the boss kinds build the boss field.
|
||||
*/
|
||||
export function boardArea(vk) {
|
||||
if (vk === 'pen') return AREAS.normal;
|
||||
if (vk === 'prep' || vk === 'normal') return AREA_NO_PEN.normal.length ? AREA_NO_PEN.normal : AREAS.normal;
|
||||
if (vk === 'unite') return AREA_NO_PEN.unite.length ? AREA_NO_PEN.unite : AREAS.unite;
|
||||
return areaFor(vk);
|
||||
}
|
||||
|
||||
/**
|
||||
* 2D rows drawn for a view kind: the pen rows (14–18) only for the 'pen' camera; the boss field with the separator
|
||||
* and the normal rows behind it as scenery.
|
||||
*/
|
||||
export function bandFor(kind) {
|
||||
if (kind === 'boss' || kind === 'hidden' || kind === 'bossPrep') return [0, 13];
|
||||
return kind === 'pen' ? [6, 18] : [6, 13];
|
||||
}
|
||||
|
||||
/**
|
||||
* Active field rows [r0, r1] of a view kind for the 2D board (render/tiles.js `setView` field: drawn rows outside it are
|
||||
* dim scenery without devices): the normal / 联防 / prep fields live between the separator walls (rows 6–13, the
|
||||
* devices on the row-13 wall included; the row-6 wall's belong to the boss field — tiles.js _stageDevices); the pen
|
||||
* camera adds the pen (6–18); the boss field 0–6.
|
||||
*/
|
||||
export function fieldRows(kind) {
|
||||
return kind === 'boss' || kind === 'hidden' || kind === 'bossPrep' ? [0, 6] : kind === 'pen' ? [6, 18] : [6, 13];
|
||||
}
|
||||
|
||||
/** Are the pen's figures shown for a view kind (a camera flight shows them when either end is the pen)? */
|
||||
export const penShown = (vk, prevVk = null) => vk === 'pen' || prevVk === 'pen';
|
||||
/** Is the prep's leader on the boss field shown for a view kind (a flight shows it when either end is the boss-field prep)? */
|
||||
export const leaderShown = (vk, prevVk = null) => vk === 'bossPrep' || prevVk === 'bossPrep';
|
||||
|
||||
/**
|
||||
* 'die' reason of an operator that enters the battle already knocked out — a 联防 helper's operator down at the end of
|
||||
* its own combat, deployed and forced out at once (server/sim/constants.js FORCED_EXIT, user playtest #5 item 2): its
|
||||
* view goes straight to the held knocked-down pose (UnitView.die(true)) without the death burst; b.snap `down` keeps it.
|
||||
*/
|
||||
export const FORCED_EXIT = 'forcedExit';
|
||||
|
||||
/**
|
||||
* Generic death particles for a battle unit's 'die' event: never for stage devices, nor for a summon used up by its
|
||||
* own effect (user playtest #2 item 4: 香槟炸弹's blast — sim fx `{ consumed: true, id }` before its 'die' — is its end,
|
||||
* not a knock-out), nor for an operator entering the battle knocked out (FORCED_EXIT).
|
||||
*/
|
||||
export const showsDeathFx = (info, consumed = false, reason = null) => !consumed && reason !== FORCED_EXIT && info?.kind !== 'device';
|
||||
|
||||
/**
|
||||
* The views' info of a battle unit from its UnitInfo (m.field / fieldMeta `units`, a 'spawn' event; snapshot.js
|
||||
* unitInfo), sanitised; null for a malformed entry. `form` — the unit's current model form (an enemy's, content/enemies.js
|
||||
* setForm: 转译基底·α's forms, a 逐火 余烬, a leader after its 重生, 掠海漂移体's crawl; a 傀儡师 fighting as its 替身, sim
|
||||
* professions.js) — makes a view built mid-battle (a teammate's
|
||||
* field watched later, 联防 observers, a reconnect, server-run watchers: no fx of the change is replayed) start on that
|
||||
* clip set (render/units.js FORMS); it used to be dropped here, so such views drew the first form (player report #5).
|
||||
*/
|
||||
export function renderInfo(u) {
|
||||
if (!u || typeof u !== 'object' || (typeof u.id !== 'number' && typeof u.id !== 'string')) return null;
|
||||
return {
|
||||
id: u.id, uid: u.uid ?? null, kind: u.kind || 'enemy', side: u.side === 'ally' ? 'ally' : 'enemy', ownerId: u.ownerId ?? null,
|
||||
defId: u.defId ?? null, name: u.name ?? '', tier: u.tier ?? 1, golden: !!u.golden, spine: u.spine ?? u.defId ?? null,
|
||||
avatar: u.avatar ?? u.defId ?? null, x: Number(u.x) || 0, y: Number(u.y) || 0, facing: u.facing === -1 ? -1 : 1,
|
||||
maxHp: Number(u.maxHp) || 1, boss: !!u.boss, motion: u.motion,
|
||||
// deploy direction of allies (UnitInfo.dir, DESIGN §3): the model (Back for UP, mirrored for LEFT) and the
|
||||
// ground wedge follow it; absent = unknown (legacy frames) → derived from `facing`, no wedge
|
||||
dir: typeof u.dir === 'string' ? u.dir : undefined,
|
||||
// the unit's current model form (UnitInfo.form: an enemy's mode, a 傀儡师's 替身): the view starts in it (UnitView reads info.form)
|
||||
form: typeof u.form === 'string' ? u.form : undefined,
|
||||
// DESIGN §16 loadout of an ally (UnitInfo.skillIndex / moduleId): the Spine actor plays that skill's clip, and a
|
||||
// tap hands them to the detail card (a teammate's unit shows its owner's skill / module)
|
||||
skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : undefined,
|
||||
moduleId: typeof u.moduleId === 'string' ? u.moduleId : undefined,
|
||||
// the ally's equipped item ids (UnitInfo.items, DESIGN §16 / §21.11): the detail card needs them for a teammate's
|
||||
// unit (resolveDetail `unitItems` → the read-only 装备 section and the 变形同构体 pairing chips); the owner's own
|
||||
// unit takes its items from the piece instead, so only other players' boards ever read this field
|
||||
items: Array.isArray(u.items) ? u.items.filter((x) => typeof x === 'string') : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** '2d' | '3d' | 'auto' board preference: `?board=` in the page URL (dev), else the view option. */
|
||||
export function boardPreference(opt) {
|
||||
let q = null;
|
||||
try { q = new URLSearchParams(globalThis.location?.search || '').get('board'); } catch { /* no location */ }
|
||||
const v = q || opt;
|
||||
return v === '2d' || v === '3d' ? v : 'auto';
|
||||
}
|
||||
|
||||
/**
|
||||
* A battle device box (render/units.js DeviceView `ctx.createBox()` contract: `{ mesh, update(cam, b), destroy() }`)
|
||||
* that follows the board layer: the 3D scene's crate mesh while `board()` returns a BoardScene, else the Pixi box of
|
||||
* the 2D board. A board switch mid-battle (lost WebGL context → 2D, or back to 3D) swaps the inner box on the next
|
||||
* update instead of leaving the crate on a dead scene. `mesh` is the Pixi mesh (null in 3D: nothing to add to Pixi).
|
||||
*/
|
||||
export function switchableBox({ board, pixi }) {
|
||||
let inner = null, owner;
|
||||
const drop = () => { try { inner?.destroy(); } catch { /* ignore */ } inner = null; owner = undefined; };
|
||||
return {
|
||||
get mesh() { return inner ? inner.mesh || null : null; },
|
||||
get inner() { return inner; },
|
||||
update(cam, b) {
|
||||
const scene = board() || null;
|
||||
if (!inner || owner !== scene || inner.destroyed) {
|
||||
drop();
|
||||
inner = scene ? scene.createDevice() : pixi();
|
||||
owner = scene;
|
||||
}
|
||||
inner?.update(cam, b);
|
||||
},
|
||||
destroy: drop,
|
||||
};
|
||||
}
|
||||
|
||||
function withTimeout(p, ms) {
|
||||
return Promise.race([p, new Promise((resolve) => setTimeout(resolve, ms))]);
|
||||
}
|
||||
|
||||
/** Wrap whatever the caller passed as `assets` into the store API of public/js/assets.js. */
|
||||
function resolveAssets(a) {
|
||||
if (a && typeof a === 'object' && typeof a.ready === 'function' && typeof a.spineEntry === 'function') return a;
|
||||
if (a && typeof a === 'object' && (a.chars || a.enemies || a.version)) return createAssets({ manifest: a });
|
||||
return defaultAssets;
|
||||
}
|
||||
|
||||
function makeData(src) {
|
||||
const look = (file, id) => {
|
||||
if (id == null || id === '') return null;
|
||||
try {
|
||||
if (src && typeof src.lookup === 'function') return src.lookup(file, id) || null;
|
||||
const m = src && src[file];
|
||||
if (m instanceof Map) return m.get(id) || null;
|
||||
if (m && typeof m === 'object' && Object.hasOwn(m, id)) return m[id] || null;
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
};
|
||||
return {
|
||||
chess: (id) => look('chess', id), token: (id) => look('tokens', id), item: (id) => look('items', id),
|
||||
enemy: (id) => look('enemies', id), stage: (id) => look('stages', id), bond: (id) => look('bonds', id),
|
||||
};
|
||||
}
|
||||
|
||||
const QUALITY_RES = { high: 2, medium: 1.5, low: 1 };
|
||||
/** Pixel-ratio cap of the 3D board canvas per quality (its fill cost is the PBR board, not the sprites). */
|
||||
const BOARD_RES = { high: 2, medium: 1.25, low: 1 };
|
||||
|
||||
/**
|
||||
* Before a renderer is destroyed: free its GL copies of every texture / buffer / geometry / framebuffer it
|
||||
* uploaded. Module-level textures (FX atlas, tier chips, backdrop, diamonds, Spine pages, PIXI.Texture.WHITE…)
|
||||
* outlive the view; PIXI 7 leaves their per-context GL entries and 'dispose' listeners pointing at the dead
|
||||
* renderer, which kept every unmounted view's renderer, canvas and WebGL context reachable.
|
||||
*/
|
||||
export function releaseGl(renderer) {
|
||||
const ts = renderer && renderer.texture;
|
||||
if (ts && Array.isArray(ts.managedTextures) && typeof ts.destroyTexture === 'function') {
|
||||
for (const bt of ts.managedTextures.slice()) { try { ts.destroyTexture(bt, true); } catch { /* ignore */ } }
|
||||
ts.managedTextures.length = 0;
|
||||
}
|
||||
for (const sys of [renderer?.geometry, renderer?.buffer, renderer?.framebuffer]) {
|
||||
try { if (sys && typeof sys.disposeAll === 'function') sys.disposeAll(false); } catch { /* ignore */ }
|
||||
}
|
||||
// shader programs are cached globally by source (PIXI.utils.ProgramCache) with one GLProgram per context
|
||||
const uid = renderer?.CONTEXT_UID, gl = renderer?.gl;
|
||||
const programs = globalThis.PIXI?.utils?.ProgramCache;
|
||||
if (uid != null && programs && typeof programs === 'object') {
|
||||
for (const prog of Object.values(programs)) {
|
||||
const g = prog && prog.glPrograms && prog.glPrograms[uid];
|
||||
if (!g) continue;
|
||||
try { if (gl && g.program) gl.deleteProgram(g.program); } catch { /* ignore */ }
|
||||
delete prog.glPrograms[uid];
|
||||
}
|
||||
}
|
||||
}
|
||||
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).
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// public/js/render/app/host.js — asset lookup, data lookup and GL teardown for one field view.
|
||||
|
||||
import { createAssets, assets as defaultAssets } from '../../assets.js';
|
||||
|
||||
function withTimeout(p, ms) {
|
||||
return Promise.race([p, new Promise((resolve) => setTimeout(resolve, ms))]);
|
||||
}
|
||||
|
||||
/** Wrap whatever the caller passed as `assets` into the store API of public/js/assets.js. */
|
||||
function resolveAssets(a) {
|
||||
if (a && typeof a === 'object' && typeof a.ready === 'function' && typeof a.spineEntry === 'function') return a;
|
||||
if (a && typeof a === 'object' && (a.chars || a.enemies || a.version)) return createAssets({ manifest: a });
|
||||
return defaultAssets;
|
||||
}
|
||||
|
||||
function makeData(src) {
|
||||
const look = (file, id) => {
|
||||
if (id == null || id === '') return null;
|
||||
try {
|
||||
if (src && typeof src.lookup === 'function') return src.lookup(file, id) || null;
|
||||
const m = src && src[file];
|
||||
if (m instanceof Map) return m.get(id) || null;
|
||||
if (m && typeof m === 'object' && Object.hasOwn(m, id)) return m[id] || null;
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
};
|
||||
return {
|
||||
chess: (id) => look('chess', id), token: (id) => look('tokens', id), item: (id) => look('items', id),
|
||||
enemy: (id) => look('enemies', id), stage: (id) => look('stages', id), bond: (id) => look('bonds', id),
|
||||
};
|
||||
}
|
||||
|
||||
const QUALITY_RES = { high: 2, medium: 1.5, low: 1 };
|
||||
|
||||
/** Pixel-ratio cap of the 3D board canvas per quality (its fill cost is the PBR board, not the sprites). */
|
||||
const BOARD_RES = { high: 2, medium: 1.25, low: 1 };
|
||||
|
||||
/**
|
||||
* Before a renderer is destroyed: free its GL copies of every texture / buffer / geometry / framebuffer it
|
||||
* uploaded. Module-level textures (FX atlas, tier chips, backdrop, diamonds, Spine pages, PIXI.Texture.WHITE…)
|
||||
* outlive the view; PIXI 7 leaves their per-context GL entries and 'dispose' listeners pointing at the dead
|
||||
* renderer, which kept every unmounted view's renderer, canvas and WebGL context reachable.
|
||||
*/
|
||||
export function releaseGl(renderer) {
|
||||
const ts = renderer && renderer.texture;
|
||||
if (ts && Array.isArray(ts.managedTextures) && typeof ts.destroyTexture === 'function') {
|
||||
for (const bt of ts.managedTextures.slice()) { try { ts.destroyTexture(bt, true); } catch { /* ignore */ } }
|
||||
ts.managedTextures.length = 0;
|
||||
}
|
||||
for (const sys of [renderer?.geometry, renderer?.buffer, renderer?.framebuffer]) {
|
||||
try { if (sys && typeof sys.disposeAll === 'function') sys.disposeAll(false); } catch { /* ignore */ }
|
||||
}
|
||||
// shader programs are cached globally by source (PIXI.utils.ProgramCache) with one GLProgram per context
|
||||
const uid = renderer?.CONTEXT_UID, gl = renderer?.gl;
|
||||
const programs = globalThis.PIXI?.utils?.ProgramCache;
|
||||
if (uid != null && programs && typeof programs === 'object') {
|
||||
for (const prog of Object.values(programs)) {
|
||||
const g = prog && prog.glPrograms && prog.glPrograms[uid];
|
||||
if (!g) continue;
|
||||
try { if (gl && g.program) gl.deleteProgram(g.program); } catch { /* ignore */ }
|
||||
delete prog.glPrograms[uid];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { withTimeout, resolveAssets, makeData, QUALITY_RES, BOARD_RES };
|
||||
@@ -0,0 +1,46 @@
|
||||
// public/js/render/app/info.js — battle-unit info handed to a view, and which deaths draw particles.
|
||||
|
||||
/**
|
||||
* 'die' reason of an operator that enters the battle already knocked out — a 联防 helper's operator down at the end of
|
||||
* its own combat, deployed and forced out at once (server/sim/constants.js FORCED_EXIT, user playtest #5 item 2): its
|
||||
* view goes straight to the held knocked-down pose (UnitView.die(true)) without the death burst; b.snap `down` keeps it.
|
||||
*/
|
||||
export const FORCED_EXIT = 'forcedExit';
|
||||
|
||||
/**
|
||||
* Generic death particles for a battle unit's 'die' event: never for stage devices, nor for a summon used up by its
|
||||
* own effect (user playtest #2 item 4: 香槟炸弹's blast — sim fx `{ consumed: true, id }` before its 'die' — is its end,
|
||||
* not a knock-out), nor for an operator entering the battle knocked out (FORCED_EXIT).
|
||||
*/
|
||||
export const showsDeathFx = (info, consumed = false, reason = null) => !consumed && reason !== FORCED_EXIT && info?.kind !== 'device';
|
||||
|
||||
/**
|
||||
* The views' info of a battle unit from its UnitInfo (m.field / fieldMeta `units`, a 'spawn' event; snapshot.js
|
||||
* unitInfo), sanitised; null for a malformed entry. `form` — the unit's current model form (an enemy's, content/enemies.js
|
||||
* setForm: 转译基底·α's forms, a 逐火 余烬, a leader after its 重生, 掠海漂移体's crawl; a 傀儡师 fighting as its 替身, sim
|
||||
* professions.js) — makes a view built mid-battle (a teammate's
|
||||
* field watched later, 联防 observers, a reconnect, server-run watchers: no fx of the change is replayed) start on that
|
||||
* clip set (render/units.js FORMS); it used to be dropped here, so such views drew the first form (player report #5).
|
||||
*/
|
||||
export function renderInfo(u) {
|
||||
if (!u || typeof u !== 'object' || (typeof u.id !== 'number' && typeof u.id !== 'string')) return null;
|
||||
return {
|
||||
id: u.id, uid: u.uid ?? null, kind: u.kind || 'enemy', side: u.side === 'ally' ? 'ally' : 'enemy', ownerId: u.ownerId ?? null,
|
||||
defId: u.defId ?? null, name: u.name ?? '', tier: u.tier ?? 1, golden: !!u.golden, spine: u.spine ?? u.defId ?? null,
|
||||
avatar: u.avatar ?? u.defId ?? null, x: Number(u.x) || 0, y: Number(u.y) || 0, facing: u.facing === -1 ? -1 : 1,
|
||||
maxHp: Number(u.maxHp) || 1, boss: !!u.boss, motion: u.motion,
|
||||
// deploy direction of allies (UnitInfo.dir, DESIGN §3): the model (Back for UP, mirrored for LEFT) and the
|
||||
// ground wedge follow it; absent = unknown (legacy frames) → derived from `facing`, no wedge
|
||||
dir: typeof u.dir === 'string' ? u.dir : undefined,
|
||||
// the unit's current model form (UnitInfo.form: an enemy's mode, a 傀儡师's 替身): the view starts in it (UnitView reads info.form)
|
||||
form: typeof u.form === 'string' ? u.form : undefined,
|
||||
// DESIGN §16 loadout of an ally (UnitInfo.skillIndex / moduleId): the Spine actor plays that skill's clip, and a
|
||||
// tap hands them to the detail card (a teammate's unit shows its owner's skill / module)
|
||||
skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : undefined,
|
||||
moduleId: typeof u.moduleId === 'string' ? u.moduleId : undefined,
|
||||
// the ally's equipped item ids (UnitInfo.items, DESIGN §16 / §21.11): the detail card needs them for a teammate's
|
||||
// unit (resolveDetail `unitItems` → the read-only 装备 section and the 变形同构体 pairing chips); the owner's own
|
||||
// unit takes its items from the piece instead, so only other players' boards ever read this field
|
||||
items: Array.isArray(u.items) ? u.items.filter((x) => typeof x === 'string') : undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// public/js/render/app/pick.js — prep-piece facing and the pick record of a view.
|
||||
|
||||
import { hitRectAt } from '../pick.js';
|
||||
|
||||
const PIECE_DIRS = new Set(['UP', 'RIGHT', 'DOWN', 'LEFT']);
|
||||
|
||||
/** Stored facing of a prep piece (m.private board pieces carry `dir`; bench pieces have none ⇒ undefined). */
|
||||
const pieceDirOf = (piece) => (typeof piece?.dir === 'string' && PIECE_DIRS.has(piece.dir.toUpperCase()) ? piece.dir.toUpperCase() : undefined);
|
||||
|
||||
/**
|
||||
* A view as a render/pick.js unit, or null when it cannot be picked (gone, faded out, dead): standing on the display
|
||||
* tile it is drawn on, or — `walks` (battle enemies) — by its ground position and its drawn body (feet to head, on
|
||||
* screen; a flying one by its body alone).
|
||||
*/
|
||||
function pickUnitOf(v, walks = false, hitArea = null) {
|
||||
// a knocked-out operator lying on its tile waiting to redeploy (b.snap `down`, user playtest #4 item 9) is on that tile
|
||||
// too: a press there selects it; other dead / dying views are gone
|
||||
if (!v || v.destroyed || (v.alive === false && !v.down)) return null;
|
||||
if (Number.isFinite(v.alpha) && v.alpha < 0.05) return null;
|
||||
const sc = v.screen;
|
||||
const body = walks && sc && sc.s > 0 && !v.culled ? { x: sc.x, top: Number.isFinite(sc.top) ? sc.top : sc.y, feet: sc.y, s: sc.s } : null;
|
||||
const tile = walks ? null : { row: Math.round(v.y), col: Math.round(v.x) };
|
||||
// a huge boss (data `hitArea`): its hit area on the ground and a body box as wide as it are pickable (render/pick.js)
|
||||
const area = walks && !v.flying ? hitRectAt(v.x, v.y, hitArea) : null;
|
||||
if (area && body) body.hw = hitArea.w / 2;
|
||||
return { tile, x: v.x, y: v.y, fly: walks && !!v.flying, body, area, depth: v.root && !v.root.destroyed ? v.root.zIndex : 0, ref: v };
|
||||
}
|
||||
|
||||
export { pieceDirOf, pickUnitOf };
|
||||
@@ -0,0 +1,31 @@
|
||||
// public/js/render/app/pixi.js — load PIXI and pixi-spine once (one module-level promise).
|
||||
|
||||
const VENDOR = { pixi: '/vendor/pixi.min.js', spine: '/vendor/pixi-spine.js' };
|
||||
|
||||
let pixiPromise = null;
|
||||
|
||||
function loadScript(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = src;
|
||||
s.async = false;
|
||||
s.onload = () => resolve();
|
||||
s.onerror = () => reject(new Error(`failed to load ${src}`));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
/** Make sure PIXI and PIXI.spine exist (classic scripts; loaded once). */
|
||||
export function ensurePixi() {
|
||||
if (globalThis.PIXI && globalThis.PIXI.spine) return Promise.resolve(globalThis.PIXI);
|
||||
if (!pixiPromise) {
|
||||
pixiPromise = (async () => {
|
||||
if (!globalThis.PIXI) await loadScript(VENDOR.pixi);
|
||||
if (!globalThis.PIXI?.spine) await loadScript(VENDOR.spine);
|
||||
if (!globalThis.PIXI || !globalThis.PIXI.spine) throw new Error('PIXI / pixi-spine unavailable');
|
||||
return globalThis.PIXI;
|
||||
})();
|
||||
pixiPromise.catch(() => { pixiPromise = null; });
|
||||
}
|
||||
return pixiPromise;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// public/js/render/app/tune.js — camera, drag and highlight tunables of the field view.
|
||||
|
||||
const CAMERA_MS = 750;
|
||||
|
||||
/** Camera pan to / from the enemy preview pen (configBlackBoard move_time 0.25). */
|
||||
export const PEN_CAMERA_MS = 250;
|
||||
|
||||
/** Recovery of a lost 3D board context: delays of the rebuild attempts (ms) and how soon a new loss counts as failure. */
|
||||
const BOARD3D_RETRY_MS = [1200, 4000, 12000];
|
||||
|
||||
const BOARD3D_STABLE_MS = 10000;
|
||||
|
||||
/** Highlight groups that show a unit's range: never drawn on bench / temp pads (they are not part of any battle). */
|
||||
const RANGE_GROUPS = new Set(['facing', 'range', 'rangeStand', 'select', 'sel', 'selRange']);
|
||||
|
||||
/**
|
||||
* The round leader's hit tiles, lit beside an operator's range preview in the Final Assault / Hidden Core prep (see the
|
||||
* header; community report #12 "boss受击范围可以像官方原版那样用红色"). [ASSUMED] the red and its strength: the players' request,
|
||||
* no source shows the official colour; the range preview stays orange.
|
||||
*/
|
||||
export const LEADER_HIT_STYLE = Object.freeze({ group: 'leaderHit', color: 0xff3b30, fill: 0.3, line: 0.95 });
|
||||
|
||||
/** atk projectile kinds whose first id is the previous bounce target (sim ai.js), not the attacker. */
|
||||
const CHAIN_KINDS = new Set(['chain', 'chainHeal']);
|
||||
|
||||
const DROP_PENDING_MS = 1300;
|
||||
|
||||
/**
|
||||
* A dragged unit is held with its drawn feet this many tiles below the pointer — the pointer on its body, the model
|
||||
* under the finger / mouse (user playtest #4 item 1: as in v2.1; mouse and touch alike). An item plate is centred on it.
|
||||
*/
|
||||
export const DRAG_HOLD_TILES = 0.45;
|
||||
|
||||
export { CAMERA_MS, BOARD3D_RETRY_MS, BOARD3D_STABLE_MS, RANGE_GROUPS, CHAIN_KINDS, DROP_PENDING_MS };
|
||||
@@ -0,0 +1,95 @@
|
||||
// public/js/render/app/view.js — which field a camera shows, and the board box that follows it.
|
||||
|
||||
import { AREAS, areaFor } from '../board3d/layout.js';
|
||||
import { normRect } from '../projection.js';
|
||||
|
||||
const VIEW_KINDS = new Set(['prep', 'normal', 'unite', 'boss', 'bossPrep', 'pen']);
|
||||
|
||||
/**
|
||||
* The field a camera request actually shows — what render/projection.js presetCamera frames: 'hidden' → 'boss';
|
||||
* a 'prep' camera asked for the boss rows (Final Assault prep: rect r1 ≤ 6) → 'bossPrep'; unknown kinds → 'normal'.
|
||||
* Drives the built 3D area, the drawn 2D rows and the lit rect, so they always match the camera.
|
||||
*/
|
||||
export function viewKind(kind, opts) {
|
||||
let k = kind === 'hidden' ? 'boss' : kind;
|
||||
if (!VIEW_KINDS.has(k)) k = 'normal';
|
||||
if (k === 'prep' && opts && opts.rect && normRect(opts.rect).r1 <= 6) k = 'bossPrep';
|
||||
return k;
|
||||
}
|
||||
|
||||
/** 3D areas without the enemy preview pen block (the own field / both normal halves); see `boardArea`. */
|
||||
const AREA_NO_PEN = Object.freeze({
|
||||
normal: Object.freeze(AREAS.normal.filter((a) => a.r1 <= 13)),
|
||||
unite: Object.freeze(AREAS.unite.filter((a) => a.r1 <= 13)),
|
||||
});
|
||||
|
||||
/**
|
||||
* 3D area built for a view kind (viewKind): the enemy preview pen (rows 14–18) only for the 'pen' camera — the prep,
|
||||
* battle and 联防 cameras show the field alone (user playtest #2 item 6) with its separator rows 6 and 13 (the row-13
|
||||
* devices blow into the field: act2 m01's blowers, user playtest #5 item 6; the boss field's row-6 devices are drawn
|
||||
* with the boss field only — board3d/layout.js stageDevices); the boss kinds build the boss field.
|
||||
*/
|
||||
export function boardArea(vk) {
|
||||
if (vk === 'pen') return AREAS.normal;
|
||||
if (vk === 'prep' || vk === 'normal') return AREA_NO_PEN.normal.length ? AREA_NO_PEN.normal : AREAS.normal;
|
||||
if (vk === 'unite') return AREA_NO_PEN.unite.length ? AREA_NO_PEN.unite : AREAS.unite;
|
||||
return areaFor(vk);
|
||||
}
|
||||
|
||||
/**
|
||||
* 2D rows drawn for a view kind: the pen rows (14–18) only for the 'pen' camera; the boss field with the separator
|
||||
* and the normal rows behind it as scenery.
|
||||
*/
|
||||
export function bandFor(kind) {
|
||||
if (kind === 'boss' || kind === 'hidden' || kind === 'bossPrep') return [0, 13];
|
||||
return kind === 'pen' ? [6, 18] : [6, 13];
|
||||
}
|
||||
|
||||
/**
|
||||
* Active field rows [r0, r1] of a view kind for the 2D board (render/tiles.js `setView` field: drawn rows outside it are
|
||||
* dim scenery without devices): the normal / 联防 / prep fields live between the separator walls (rows 6–13, the
|
||||
* devices on the row-13 wall included; the row-6 wall's belong to the boss field — tiles.js _stageDevices); the pen
|
||||
* camera adds the pen (6–18); the boss field 0–6.
|
||||
*/
|
||||
export function fieldRows(kind) {
|
||||
return kind === 'boss' || kind === 'hidden' || kind === 'bossPrep' ? [0, 6] : kind === 'pen' ? [6, 18] : [6, 13];
|
||||
}
|
||||
|
||||
/** Are the pen's figures shown for a view kind (a camera flight shows them when either end is the pen)? */
|
||||
export const penShown = (vk, prevVk = null) => vk === 'pen' || prevVk === 'pen';
|
||||
|
||||
/** Is the prep's leader on the boss field shown for a view kind (a flight shows it when either end is the boss-field prep)? */
|
||||
export const leaderShown = (vk, prevVk = null) => vk === 'bossPrep' || prevVk === 'bossPrep';
|
||||
|
||||
/** '2d' | '3d' | 'auto' board preference: `?board=` in the page URL (dev), else the view option. */
|
||||
export function boardPreference(opt) {
|
||||
let q = null;
|
||||
try { q = new URLSearchParams(globalThis.location?.search || '').get('board'); } catch { /* no location */ }
|
||||
const v = q || opt;
|
||||
return v === '2d' || v === '3d' ? v : 'auto';
|
||||
}
|
||||
|
||||
/**
|
||||
* A battle device box (render/units.js DeviceView `ctx.createBox()` contract: `{ mesh, update(cam, b), destroy() }`)
|
||||
* that follows the board layer: the 3D scene's crate mesh while `board()` returns a BoardScene, else the Pixi box of
|
||||
* the 2D board. A board switch mid-battle (lost WebGL context → 2D, or back to 3D) swaps the inner box on the next
|
||||
* update instead of leaving the crate on a dead scene. `mesh` is the Pixi mesh (null in 3D: nothing to add to Pixi).
|
||||
*/
|
||||
export function switchableBox({ board, pixi }) {
|
||||
let inner = null, owner;
|
||||
const drop = () => { try { inner?.destroy(); } catch { /* ignore */ } inner = null; owner = undefined; };
|
||||
return {
|
||||
get mesh() { return inner ? inner.mesh || null : null; },
|
||||
get inner() { return inner; },
|
||||
update(cam, b) {
|
||||
const scene = board() || null;
|
||||
if (!inner || owner !== scene || inner.destroyed) {
|
||||
drop();
|
||||
inner = scene ? scene.createDevice() : pixi();
|
||||
owner = scene;
|
||||
}
|
||||
inner?.update(cam, b);
|
||||
},
|
||||
destroy: drop,
|
||||
};
|
||||
}
|
||||
@@ -289,7 +289,8 @@ test('user playtest #4 (DESIGN §18): picking by tile, timers, 机变 two taps,
|
||||
// #1 the tile under the pointer; the dragged model held under the pointer (no touch lift, probe or body shapes)
|
||||
assert.equal(ENEMY_REACH, 0.6);
|
||||
const app = readFileSync(join(ROOT, 'public/js/render/app.js'), 'utf8');
|
||||
assert.match(app, /export const DRAG_HOLD_TILES = 0\.45;/);
|
||||
const tune = readFileSync(join(ROOT, 'public/js/render/app/tune.js'), 'utf8');
|
||||
assert.match(tune, /export const DRAG_HOLD_TILES = 0\.45;/);
|
||||
assert.ok(!/TOUCH_LIFT_TILES|drawnAt|pickShape|pieceDragOver/.test(app), 'no touch lift, pixel probe or body shapes (user playtest #4 item 1)');
|
||||
assert.match(DESIGN, /`DRAG_HOLD_TILES` = 0\.45 tile/);
|
||||
assert.match(DESIGN, /`ENEMY_REACH` 0\.6 tile/);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// 点击 / 观战队友单位时详情卡显示装备(游玩记录 #2:「观战队友时看不到干员的装备」)。DESIGN §16
|
||||
// 承诺 "a teammate's unit shows its owner's loadout in the detail card":服务器把 an ally operator 的
|
||||
// 装备 id 放进战斗快照与侦察 meta 的 UnitInfo.items(server/sim/snapshot.js、Match.prepFieldMeta),
|
||||
// detailPanel 的 unit 分支也从 `u.items` 取 `unitItems` —— 但 renderInfo(public/js/render/app.js)
|
||||
// detailPanel 的 unit 分支也从 `u.items` 取 `unitItems` —— 但 renderInfo(public/js/render/app/info.js)
|
||||
// 构建 info 白名单时把 items 丢了,队友单位点开的卡永远没有 装备 区,变形同构体的「同构」pairing
|
||||
// chips 对队友单位同样失效。renderInfo 在 app.js 里(模块顶层有 canvas 副作用,node 无法 import),
|
||||
// chips 对队友单位同样失效。renderInfo 在 app/info.js 里(app.js 模块顶层有 canvas 副作用,node 无法 import 整份视图),
|
||||
// 按docs-consistency / feedback1d 的惯例对源码断言;消费端行为已由 morph-bonds.test.js 锁住,
|
||||
// 这里再走一遍 unit → resolveDetail → ChessDetail 的完整用户可见结果。
|
||||
import { test } from 'node:test';
|
||||
@@ -22,7 +22,7 @@ const HAMMER = 'chess_item_1_01_e_a'; // 维式重锤 → 维多利亚
|
||||
// ---- renderInfo: the snapshot UnitInfo reaches the client info object ---------------------------------------------
|
||||
|
||||
test('renderInfo passes UnitInfo.items through (the teammate-unit equipment into the detail card)', () => {
|
||||
const app = readFileSync(path.join(ROOT, 'public/js/render/app.js'), 'utf8');
|
||||
const app = readFileSync(path.join(ROOT, 'public/js/render/app/info.js'), 'utf8');
|
||||
const start = app.indexOf('export function renderInfo(');
|
||||
assert.ok(start > 0, 'renderInfo exists');
|
||||
const body = app.slice(start, app.indexOf('\n}', start));
|
||||
|
||||
Reference in new issue
Block a user