Files
sganggs 01a44cd288 卫戍协议:盟约 web remake — initial import
Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
2026-09-29 16:21:31 +08:00

235 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// test/render/board3d-lifecycle.test.js — lifecycle and framing regressions of the official 3D board (DESIGN §15,
// public/js/render/board3d + render/app.js glue), found in the board3d review:
// * battle device meshes (render/units.js DeviceView crates) survive the area / stage rebuilds that a camera
// flight triggers mid-battle (prep → Final Assault rebuilt the scene and dropped every crate);
// * GPU resources: every BufferGeometry the scene creates is disposed again on rebuilds and on destroy;
// * the background plane covers every official / fitted framing (no visible edge at 21:9, 4:3, portrait);
// * a 'prep' camera on the boss rows (Final Assault prep) builds / draws / lights the boss field (viewKind);
// * device boxes follow the board layer (3D ⇄ 2D switch mid-battle: lost context) — switchableBox;
// * fitted (portrait) Final Assault prep camera frames the right half for the right-hand player;
// * three.js is only fetched when the local-art manifest lists the board atlas (boardArtListed).
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as THREE from 'three';
import { BoardScene, extendPlane, LIGHTING } from '../../public/js/render/board3d/scene.js';
import { AREAS, unionAreas, areaFor } from '../../public/js/render/board3d/layout.js';
import { resolveUvTable } from '../../public/js/render/board3d/atlas.js';
import { parseObj } from '../../public/js/render/board3d/obj.js';
import { boardArtListed } from '../../public/js/render/board3d/load.js';
import { presetCamera } from '../../public/js/render/projection.js';
import { viewKind, switchableBox } from '../../public/js/render/app.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const stages = JSON.parse(readFileSync(path.join(ROOT, 'data/stages.json'), 'utf8'));
function stubRenderer() {
return {
domElement: { addEventListener() {}, removeEventListener() {} },
shadowMap: { enabled: true, needsUpdate: false },
capabilities: { getMaxAnisotropy: () => 8 },
info: { render: { calls: 7, triangles: 1234 }, memory: { textures: 3, geometries: 5 } },
setClearColor() {}, setPixelRatio() {}, setSize() {}, render() {}, dispose() {},
};
}
// the official background plane as exported by tools/local-extract (mesh/s_background_common/pPlane1.obj)
const PLANE_OBJ = ['g pPlane1', 'v -50 0 50', 'v -50 0 -50', 'v 50 0 -50', 'v 50 0 50', 'vt 0 0', 'vt 0 1', 'vt 1 1', 'vt 1 0',
'vn -0 1 0', 'vn -0 1 0', 'vn -0 1 0', 'vn -0 1 0', 'g pPlane1_0', 'f 3/3/3 2/2/2 1/1/1', 'f 4/4/4 3/3/3 1/1/1'].join('\n');
function fakePack() {
const img = { width: 4, height: 4 };
const cube = parseObj(['v -50 -50 -50', 'v 50 -50 -50', 'v 50 50 -50', 'v -50 50 -50', 'vt 0 0', 'vt 1 0', 'vt 1 1', 'vt 0 1', 'vn 0 0 -1', 'f 1/1/1 2/2/1 3/3/1 4/4/1'].join('\n'));
return {
key: 'test', images: { D: img, N: img, R: img, E: img, common: img, gate: img, BG: img, wind: img },
meshes: { crate: null, blower: cube, bgPlane: parseObj(PLANE_OBJ), gate: { startDown: { mesh: cube }, startUp: { mesh: cube }, startBack: { mesh: cube }, endDown: { mesh: cube }, endUp: { mesh: cube } } },
tiles: null, uv: resolveUvTable(null), materials: {},
};
}
const inScene = (board, obj) => { let found = false; board.scene.traverse((o) => { if (o === obj) found = true; }); return found; };
describe('BoardScene lifecycle', () => {
test('battle device meshes survive the area / stage rebuilds of a camera flight (prep → Final Assault)', () => {
const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() });
board.setStage(stages.act1autochess_m01);
board.setBattleRect({ r0: 0, r1: 5, c0: 0, c1: 20 });
const h = board.createDevice();
h.update(null, { x: 5, y: 4, z: 0, size: 0.9, height: 0.675, alpha: 1 });
const mesh = board.dynamic.children.at(-1);
let disposed = 0;
mesh.geometry.addEventListener('dispose', () => { disposed++; });
// the flight: union of both areas, then the boss field only; a stage swap for good measure
assert.equal(board.setArea(unionAreas(areaFor('prep'), areaFor('boss'))), true);
assert.equal(board.setArea(AREAS.boss), true);
board.setStage(stages.act2autochess_m03);
assert.ok(inScene(board, mesh), 'device mesh still in the scene');
assert.equal(disposed, 0, 'device geometry not disposed by the rebuilds');
assert.equal(board.devices.size, 1);
h.update(null, { x: 6, y: 3, z: 0, size: 0.9, height: 0.675, alpha: 1 });
assert.equal(mesh.position.x, 6, 'the handle still drives its mesh');
h.destroy();
h.destroy(); // idempotent (DeviceView + the board teardown may both destroy it)
assert.equal(disposed, 1);
assert.ok(!inScene(board, mesh));
assert.equal(h.destroyed, true);
board.destroy();
});
test('every BufferGeometry created is disposed again (stage / area rebuilds, crates, devices, destroy)', () => {
const live = new Set();
class CountingGeometry extends THREE.BufferGeometry {
constructor() { super(); live.add(this); this.addEventListener('dispose', () => live.delete(this)); }
}
const T = { ...THREE, BufferGeometry: CountingGeometry };
const board = new BoardScene(T, fakePack(), { renderer: stubRenderer() });
const meshCount = () => { let n = 0; board.scene.traverse((o) => { if (o.isMesh) n++; }); return n; };
const devices = [];
for (const id of ['act2autochess_m01', 'act1autochess_m01', 'act2autochess_m04', 'act1autochess_m03', 'act2autochess_m01']) {
board.setStage(stages[id]);
for (const area of [AREAS.boss, AREAS.unite, AREAS.normal]) board.setArea(area);
board.setBattleRect({ r0: 9, r1: 12, c0: 0, c1: 10 });
board.setBattleRect(null);
devices.push(board.createDevice());
if (devices.length > 2) devices.shift().destroy();
assert.equal(live.size, meshCount(), `${id}: live geometries = meshes in the scene`);
}
assert.ok(live.size < 40, `bounded (${live.size})`);
board.destroy();
assert.equal(live.size, 0, 'destroy frees everything');
});
test('the background plane covers every framing (official 16:9 / 21:9 / 4:3 and fitted portrait); clear = fog colour', () => {
const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() });
board.setStage(stages.act2autochess_m04);
const g = board.meshes.bg.geometry;
g.computeBoundingBox();
const bb = g.boundingBox;
const B = LIGHTING.bg;
assert.ok(bb.max.x - bb.min.x >= B.size * B.tiles - 1e-3 && bb.max.y - bb.min.y >= B.size * B.tiles - 1e-3, 'extended plane');
assert.ok(Math.abs(bb.max.z - B.z) < 1e-6 && Math.abs(bb.min.z - B.z) < 1e-6, 'flat at the official depth');
const uv = g.getAttribute('uv').array;
assert.ok(Math.min(...uv) <= -0.99 && Math.max(...uv) >= 1.99, 'texel density kept: UVs span the mirrored copies');
assert.equal(board.tex.BG.wrapS, THREE.MirroredRepeatWrapping);
assert.equal(board.tex.BG.wrapT, THREE.MirroredRepeatWrapping);
assert.equal(LIGHTING.clear, LIGHTING.fog.color, 'the void beyond the fogged plane has the fog colour');
// every screen corner of every preset hits the plane, or lies beyond the fog (fully fogged → no edge)
const cx = (bb.min.x + bb.max.x) / 2, cy = (bb.min.y + bb.max.y) / 2, half = (bb.max.x - bb.min.x) / 2;
const bad = [];
for (const [W, H] of [[1920, 1080], [2560, 1080], [3440, 1440], [1024, 768], [900, 1600], [800, 800], [390, 844], [1280, 720]]) {
for (const [kind, o] of [['prep', {}], ['prep', { shop: false }], ['normal', {}], ['unite', {}], ['unite', { half: true, side: 'R' }],
['boss', {}], ['boss', { half: true, side: 'L' }], ['bossPrep', { side: 'R' }], ['bossPrep', {}], ['pen', {}]]) {
const cam = presetCamera(kind, { width: W, height: H }, o);
const P = cam.position();
for (const [sx, sy] of [[0, 0], [W, 0], [0, H], [W, H], [W / 2, 0]]) {
const p = cam.unproject(sx, sy, B.z);
const inside = !!p && Math.abs(p.x - cx) <= half && Math.abs(p.y - cy) <= half;
const far = !!p && Math.hypot(p.x - P.x, p.y - P.y, B.z - P.z) >= LIGHTING.fog.far;
if (!inside && !far) bad.push(`${W}×${H} ${kind} ${JSON.stringify(o)} (${sx},${sy})`);
}
}
}
assert.deepEqual(bad, []);
board.destroy();
});
test('settings quality: low drops the key-light shadow, back to high restores it (shadow map re-rendered)', () => {
const R = stubRenderer();
const board = new BoardScene(THREE, fakePack(), { renderer: R });
board.setStage(stages.act2autochess_m01);
R.shadowMap.needsUpdate = false;
assert.equal(board.setQuality('low'), true);
assert.equal(R.shadowMap.enabled, false);
assert.equal(board.key.castShadow, false);
assert.equal(board.setQuality('low'), false, 'no-op');
assert.equal(board.setQuality('high'), true);
assert.equal(R.shadowMap.enabled, true);
assert.equal(board.key.castShadow, true);
assert.equal(R.shadowMap.needsUpdate, true);
board.destroy();
});
test('extendPlane grows a plane about its centre with UVs scaled about the texture centre', () => {
const src = { position: new Float32Array([0, 0, -8, 2, 0, -8, 2, 2, -8]), uv: new Float32Array([0, 0, 1, 0, 1, 1]), index: new Uint16Array([0, 1, 2]) };
const out = extendPlane(src, 1, 1, 3);
assert.deepEqual([...out.position], [-2, -2, -8, 4, -2, -8, 4, 4, -8]);
assert.deepEqual([...out.uv], [-1, -1, 2, -1, 2, 2]);
assert.equal(extendPlane(src, 1, 1, 1), src, 'k ≤ 1: unchanged');
});
});
describe('render/app.js glue', () => {
test('viewKind: the field a camera request shows (Final Assault prep = the boss field)', () => {
assert.equal(viewKind('prep', { rect: { r0: 9, r1: 12, c0: 0, c1: 10 } }), 'prep');
assert.equal(viewKind('prep', { rect: { r0: 0, r1: 5, c0: 0, c1: 10 }, side: 'L' }), 'bossPrep');
assert.equal(viewKind('prep', { rect: { r0: 0, r1: 5, c0: 10, c1: 20 }, side: 'R' }), 'bossPrep');
assert.equal(viewKind('prep', {}), 'prep');
assert.equal(viewKind('hidden', {}), 'boss');
assert.equal(viewKind('pen'), 'pen');
assert.equal(viewKind('bogus', null), 'normal');
// the built 3D area follows (the boss field, not the normal field + pen)
assert.equal(areaFor(viewKind('prep', { rect: { r0: 0, r1: 5, c0: 0, c1: 10 } })), AREAS.boss);
assert.equal(areaFor(viewKind('prep', { rect: { r0: 9, r1: 12, c0: 0, c1: 10 } })), AREAS.normal);
});
test('switchableBox: device boxes follow the board layer (3D scene ⇄ 2D Pixi box), never a dead scene', () => {
const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() });
board.setStage(stages.act1autochess_m01);
let current = board;
const pixiBoxes = [];
const pixi = () => { const b = { mesh: { parent: null }, destroyed: false, updates: 0, update() { this.updates++; }, destroy() { this.destroyed = true; } }; pixiBoxes.push(b); return b; };
const box = switchableBox({ board: () => current, pixi });
const B = { x: 5, y: 10, z: 0, size: 0.9, height: 0.675, alpha: 1 };
assert.equal(box.mesh, null, 'nothing before the first update');
box.update(null, B);
assert.equal(box.mesh, null, '3D: no Pixi mesh');
assert.equal(board.devices.size, 1);
// the WebGL context is lost: the scene is destroyed and the view falls back to the 2D board
board.destroy();
current = null;
box.update(null, B);
assert.equal(pixiBoxes.length, 1);
assert.equal(box.mesh, pixiBoxes[0].mesh, '2D: the Pixi box mesh (DeviceView parents it)');
assert.equal(pixiBoxes[0].updates, 1);
// back to 3D (setBoardMode('3d')): the Pixi box goes, a device of the new scene takes over
const board2 = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() });
board2.setStage(stages.act1autochess_m01);
current = board2;
box.update(null, B);
assert.equal(pixiBoxes[0].destroyed, true);
assert.equal(board2.devices.size, 1);
box.destroy();
assert.equal(board2.devices.size, 0);
box.destroy();
board2.destroy();
});
test('fitted Final Assault prep camera (portrait) frames the right half for the right-hand player', () => {
const vp = { width: 900, height: 1600 };
const L = presetCamera('bossPrep', vp, { side: 'L' }), R = presetCamera('bossPrep', vp, { side: 'R' });
assert.ok(L.tx < 10 && R.tx > 10, `L ${L.tx} R ${R.tx}`);
assert.ok(Math.abs(L.tx + R.tx - 20) < 1e-6, 'mirrored about col 10');
// landscape keeps the official left / right boss prepare params
const oL = presetCamera('bossPrep', { width: 1920, height: 1080 }, { side: 'L' }), oR = presetCamera('bossPrep', { width: 1920, height: 1080 }, { side: 'R' });
assert.ok(oL.tx < 10 && oR.tx > 10);
});
});
describe('asset pack gate', () => {
test('boardArtListed: three.js is fetched only when the manifest lists the board atlas', async () => {
const store = (groups, fail = false) => ({
local: async () => { if (fail) throw new Error('offline'); return { groups }; },
localUrl: (g, n) => groups?.[g]?.[n]?.path || null,
});
assert.equal(await boardArtListed(store({ 'map/autochess': { TX_autochessi_D: { path: '/assets/local/map/autochess/TX_autochessi_D.png' } } })), true);
assert.equal(await boardArtListed(store({ 'map/autochess': {} })), false);
assert.equal(await boardArtListed(store(null)), false);
assert.equal(await boardArtListed(store({}, true)), false);
assert.equal(await boardArtListed(null), false);
assert.equal(await boardArtListed({ local: 1 }), false);
});
});