From aaef748ad95b33117654bf2f82b956de6b6509ff Mon Sep 17 00:00:00 2001 From: sganggs Date: Mon, 5 Oct 2026 17:22:20 +0800 Subject: [PATCH] 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. --- public/js/render/app.js | 289 ++------------------------- public/js/render/app/host.js | 66 ++++++ public/js/render/app/info.js | 46 +++++ public/js/render/app/pick.js | 29 +++ public/js/render/app/pixi.js | 31 +++ public/js/render/app/tune.js | 34 ++++ public/js/render/app/view.js | 95 +++++++++ test/docs-consistency.test.js | 3 +- test/ui/playtest-item-detail.test.js | 6 +- 9 files changed, 323 insertions(+), 276 deletions(-) create mode 100644 public/js/render/app/host.js create mode 100644 public/js/render/app/info.js create mode 100644 public/js/render/app/pick.js create mode 100644 public/js/render/app/pixi.js create mode 100644 public/js/render/app/tune.js create mode 100644 public/js/render/app/view.js diff --git a/public/js/render/app.js b/public/js/render/app.js index 97df1c9d..0b0098f7 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -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). diff --git a/public/js/render/app/host.js b/public/js/render/app/host.js new file mode 100644 index 00000000..5e551cfa --- /dev/null +++ b/public/js/render/app/host.js @@ -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 }; diff --git a/public/js/render/app/info.js b/public/js/render/app/info.js new file mode 100644 index 00000000..f5d3e27f --- /dev/null +++ b/public/js/render/app/info.js @@ -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, + }; +} diff --git a/public/js/render/app/pick.js b/public/js/render/app/pick.js new file mode 100644 index 00000000..c9cecc2e --- /dev/null +++ b/public/js/render/app/pick.js @@ -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 }; diff --git a/public/js/render/app/pixi.js b/public/js/render/app/pixi.js new file mode 100644 index 00000000..3184afb1 --- /dev/null +++ b/public/js/render/app/pixi.js @@ -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; +} diff --git a/public/js/render/app/tune.js b/public/js/render/app/tune.js new file mode 100644 index 00000000..eda59596 --- /dev/null +++ b/public/js/render/app/tune.js @@ -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 }; diff --git a/public/js/render/app/view.js b/public/js/render/app/view.js new file mode 100644 index 00000000..460dc3e2 --- /dev/null +++ b/public/js/render/app/view.js @@ -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, + }; +} diff --git a/test/docs-consistency.test.js b/test/docs-consistency.test.js index 65a796dc..1e1b21a5 100644 --- a/test/docs-consistency.test.js +++ b/test/docs-consistency.test.js @@ -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/); diff --git a/test/ui/playtest-item-detail.test.js b/test/ui/playtest-item-detail.test.js index 73f0e3d9..6e788b8b 100644 --- a/test/ui/playtest-item-detail.test.js +++ b/test/ui/playtest-item-detail.test.js @@ -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));