0.2.5-dev: official scenes merge chunks that share a material / lightmap into one mesh at load (draw calls on the 3D board back within budget, e.g. act2autochess_m02 prep 22 → 13); the boss-round real-server test reads the actual random pairing instead of assuming the host on the left half; golden: unchanged

This commit is contained in:
sganggs committed 2026-10-12 17:48:31 +08:00
1 parent e2bc005c88
commit 3bb7fef74c
6 files changed
+232 -17

No files matched your search

+1 -1
View File
@@ -87,7 +87,7 @@ Findings (2026-09-28): 卫戍协议 levels have no scene bundle (`mapId` null; t
Correction (2026-10-11, GitHub #532; PR #533 by @andrew05060414): the first finding above was made off season and is wrong. While a season runs, the official hot-update list carries **one prebuilt scene per battlefield** — `scenes/activities/act1autochess/level_act1autochess_m01…m07` and `scenes/activities/act2autochess/level_act2autochess_m01…m04` (each with `…/lightingdata.ab` and `…_lighting_settings.ab`; Android versions `25-11-21-15-21-44_ee1197`, `26-03-13-06-11-47_14fb99`, `26-04-13-12-31-42_e9b560`); the lists of 2026-09-13 and 2026-10-08 have none, and no 卫戍协议 level data either. The asset index of those versions (`<hash>.idx`: each bundle's dependencies) gives each scene's art: act1 m01–m07 and act2 m02 / m04 depend on `arts/maps/map_autochesssand` (m05 and act2 m04 also on `map_doss_shore`), act2 m01 on `map_lakegarden` + `map_syracuse_metro`, act2 m03 on `map_kxmr` + `map_lm_center`. **No battlefield scene depends on `arts/maps/map_autochess`** (the metal theme the procedural board below draws; in the current client the 矢量突破 scenes `act3break` use it). A scene holds the logic tiles (`(row,col)#tileKey` at anchor-local (col, row, −height), high ground 0.4), the art under `GraphicHolder` (static parts merged into "Combined Mesh (root: scene)" objects, lightmapped: Shadowmask mode, one directional key light), and three sub-graphics `HIDDEN@1…3` (`MapSubGraphicHolder`) that the game switches on: the left normal field, the right normal field and the boss field — the areas of `layout.js AREAS`. Correction (2026-10-11, GitHub #532; PR #533 by @andrew05060414): the first finding above was made off season and is wrong. While a season runs, the official hot-update list carries **one prebuilt scene per battlefield** — `scenes/activities/act1autochess/level_act1autochess_m01…m07` and `scenes/activities/act2autochess/level_act2autochess_m01…m04` (each with `…/lightingdata.ab` and `…_lighting_settings.ab`; Android versions `25-11-21-15-21-44_ee1197`, `26-03-13-06-11-47_14fb99`, `26-04-13-12-31-42_e9b560`); the lists of 2026-09-13 and 2026-10-08 have none, and no 卫戍协议 level data either. The asset index of those versions (`<hash>.idx`: each bundle's dependencies) gives each scene's art: act1 m01–m07 and act2 m02 / m04 depend on `arts/maps/map_autochesssand` (m05 and act2 m04 also on `map_doss_shore`), act2 m01 on `map_lakegarden` + `map_syracuse_metro`, act2 m03 on `map_kxmr` + `map_lm_center`. **No battlefield scene depends on `arts/maps/map_autochess`** (the metal theme the procedural board below draws; in the current client the 矢量突破 scenes `act3break` use it). A scene holds the logic tiles (`(row,col)#tileKey` at anchor-local (col, row, −height), high ground 0.4), the art under `GraphicHolder` (static parts merged into "Combined Mesh (root: scene)" objects, lightmapped: Shadowmask mode, one directional key light), and three sub-graphics `HIDDEN@1…3` (`MapSubGraphicHolder`) that the game switches on: the left normal field, the right normal field and the boss field — the areas of `layout.js AREAS`.
Official battlefield scenes (`board3d/load.js loadScenePack`, `scene.js setScenePack`, `materials.js sceneMaterial`): when `data/local-assets.json` lists `scene/<stageId>`, the 3D board draws that scene instead of the procedural tiles, cliffs and background plane; the devices, gate / objective boxes, field edges and terrain overlays stay as below. The base part is always drawn; a `HIDDEN@n` part is drawn when its centre lies in one of the built areas. Without the scene (not extracted, a load failure, an unknown `version`) the procedural metal board is drawn as before. Approximations: the official shaders are not reproduced — a lit material is albedo × (the lightmap's baked light + key light · N·L · the baked shadow mask), in linear light; normal, metallic and emission maps, the vertex-colour blend (`_HG_VERTEX_COLOR_BLEND_ON`), the sky box, the map effects (`mapeff_*`) and the post-processing profile are not drawn; `Torappu/Scene/StylizedWater` is the board's water shader in the material's colours; crates cast no shadow on a scene. **[ASSUMED]** `SCENE_LIGHT` (`direct` 0.8, `indirect` 0.4, `ambient` 0.2: tuned by eye against the PRTS screenshot of 战场#06, the official post-processing being unknown) and the RGBM reading of the lightmap pages (`scenes.py decode_rgbm`: Unity's linear-space decode, range 5). Owner decision (2026-10-12): these extracted files belong to the 0.2.5 asset pack and may ship in the full release zip; a lite package or a source install may download and extract them on that machine. Missing extractor dependencies keep the metal board. The repository still never stores the official files. Official battlefield scenes (`board3d/load.js loadScenePack`, `scene.js setScenePack`, `materials.js sceneMaterial`): when `data/local-assets.json` lists `scene/<stageId>`, the 3D board draws that scene instead of the procedural tiles, cliffs and background plane; the devices, gate / objective boxes, field edges and terrain overlays stay as below. The base part is always drawn; a `HIDDEN@n` part is drawn when its centre lies in one of the built areas. Chunks of those shown parts that share a material and a lightmap page are one mesh when the area is built (`load.js mergeSceneChunks`); water shares a mesh across pages because its shader has no lightmap. A switched-off part stays out of that mesh. Without the scene (not extracted, a load failure, an unknown `version`) the procedural metal board is drawn as before. Approximations: the official shaders are not reproduced — a lit material is albedo × (the lightmap's baked light + key light · N·L · the baked shadow mask), in linear light; normal, metallic and emission maps, the vertex-colour blend (`_HG_VERTEX_COLOR_BLEND_ON`), the sky box, the map effects (`mapeff_*`) and the post-processing profile are not drawn; `Torappu/Scene/StylizedWater` is the board's water shader in the material's colours; crates cast no shadow on a scene. **[ASSUMED]** `SCENE_LIGHT` (`direct` 0.8, `indirect` 0.4, `ambient` 0.2: tuned by eye against the PRTS screenshot of 战场#06, the official post-processing being unknown) and the RGBM reading of the lightmap pages (`scenes.py decode_rgbm`: Unity's linear-space decode, range 5). Owner decision (2026-10-12): these extracted files belong to the 0.2.5 asset pack and may ship in the full release zip; a lite package or a source install may download and extract them on that machine. Missing extractor dependencies keep the metal board. The repository still never stores the official files.
Target: a **real 3D board layer** rendered with three.js (vendored ESM, no CDN) underneath the existing Pixi layer: Target: a **real 3D board layer** rendered with three.js (vendored ESM, no CDN) underneath the existing Pixi layer:
- Geometry generated from `data/stages.json` like the official builder: LOW tiles = top quads; HIGH tiles = raised blocks with side walls; forbidden/void, fences, gates (red), objective (blue), hand/temp pads, special terrain; UVs mapped onto the official atlas regions (reuse/extend `public/assets/local/map/autochess/tiles.json`); normal + emission + metallic maps; lighting tuned to match official screenshots (key directional light, ambient/hemisphere, soft contact shadows); official background meshes & texture; device meshes (OBJ exported by `tools/local-extract`) with their textures; animated gate/objective effects. - Geometry generated from `data/stages.json` like the official builder: LOW tiles = top quads; HIGH tiles = raised blocks with side walls; forbidden/void, fences, gates (red), objective (blue), hand/temp pads, special terrain; UVs mapped onto the official atlas regions (reuse/extend `public/assets/local/map/autochess/tiles.json`); normal + emission + metallic maps; lighting tuned to match official screenshots (key directional light, ambient/hemisphere, soft contact shadows); official background meshes & texture; device meshes (OBJ exported by `tools/local-extract`) with their textures; animated gate/objective effects.
+3 -1
View File
@@ -391,7 +391,9 @@ lightmap pages with the baked shadow mask in alpha, albedo textures) to `public/
`.cache/official-scenes/*`; `tools/local-extract/fetch_scenes.py` downloads the in-season bundles from the official `.cache/official-scenes/*`; `tools/local-extract/fetch_scenes.py` downloads the in-season bundles from the official
CDN (md5-checked against that version's hot-update list). The 3D board draws a stage's scene when the local-art CDN (md5-checked against that version's hot-update list). The 3D board draws a stage's scene when the local-art
manifest lists it (`board3d/load.js loadScenePack`, `scene.js setScenePack`); the procedural metal board stays the manifest lists it (`board3d/load.js loadScenePack`, `scene.js setScenePack`); the procedural metal board stays the
fallback and still draws the devices, gates, field edges and terrain overlays. fallback and still draws the devices, gates, field edges and terrain overlays. Chunks of the parts on screen that
share a material and a lightmap page are one mesh (`mergeSceneChunks`), so a battlefield such as act2 m02 (36 chunks,
5 lightmap pages) stays inside the board's draw-call budget. A switched-off HIDDEN part is left out of that mesh.
Owner decision (2026-10-12): the eleven scenes belong to the 0.2.5 asset pack. The maintainer's full release zip Owner decision (2026-10-12): the eleven scenes belong to the 0.2.5 asset pack. The maintainer's full release zip
includes the extracted files. A lite package or a source install may download and extract them on that machine includes the extracted files. A lite package or a source install may download and extract them on that machine
+68
View File
@@ -195,6 +195,74 @@ export function parseSceneChunks(doc, buf) {
return out; return out;
} }
/**
* Mesh bucket of a scene chunk. One material and one lightmap page are one draw. Water ignores the page: that shader
* has no lightmap (`scene.js` `sceneWaterMaterial`).
* @param {{ material?: string, lightmap?: number }} chunk
* @param {{ kind?: string }|null} info scene.json materials[name]
*/
export function sceneMaterialKey(chunk, info) {
return `${chunk?.material}|${info?.kind === 'water' ? '' : chunk?.lightmap}`;
}
/**
* One scene chunk from several that share a material bucket (`sceneMaterialKey`). Vertex attributes stay in the
* scene.bin layout (position f32×3, uv f32×2, lightmap uv f32×2, normal i8×4, colour u8×4) and indices are rewritten
* with the vertex offset; past 65535 vertices the index is 32-bit (WebGL2). A single chunk is returned as-is, so the
* pack's views of scene.bin stay shared. The caller passes chunks of one bucket that are on screen together: a
* file-level merge would bake a switched-off HIDDEN part into the base mesh.
* @param {object[]|null} chunks
* @returns {object|null}
*/
export function mergeSceneChunks(chunks) {
if (!Array.isArray(chunks) || chunks.length === 0) return null;
if (chunks.length === 1) return chunks[0];
const first = chunks[0];
let nv = 0, ni = 0, index32 = false;
for (const c of chunks) {
const n = c?.position?.length / 3;
if (!Number.isInteger(n) || n <= 0) throw new Error(`scene chunk ${c?.material}: ${c?.position?.length ?? 0} position components`);
if (c.material !== first.material) throw new Error(`scene merge mixes ${first.material} and ${c.material}`);
if (c.uv?.length !== n * 2 || c.uv1?.length !== n * 2 || c.normal?.length !== n * 4 || c.color?.length !== n * 4) {
throw new Error(`scene chunk ${c.material}: attributes do not match ${n} vertices`);
}
if (!c.index || c.index.length === 0) throw new Error(`scene chunk ${c.material}: no indices`);
nv += n;
ni += c.index.length;
if (c.index instanceof Uint32Array) index32 = true;
}
if (nv > 65535) index32 = true;
const position = new Float32Array(nv * 3);
const uv = new Float32Array(nv * 2);
const uv1 = new Float32Array(nv * 2);
const normal = new Int8Array(nv * 4);
const color = new Uint8Array(nv * 4);
const index = index32 ? new Uint32Array(ni) : new Uint16Array(ni);
let vo = 0, io = 0, bounds = null;
for (const c of chunks) {
const n = c.position.length / 3;
position.set(c.position, vo * 3);
uv.set(c.uv, vo * 2);
uv1.set(c.uv1, vo * 2);
normal.set(c.normal, vo * 4);
color.set(c.color, vo * 4);
for (let i = 0; i < c.index.length; i++) {
const v = c.index[i];
if (v < 0 || v >= n) throw new Error(`scene chunk ${c.material}: index ${v} out of ${n}`);
index[io + i] = v + vo;
}
vo += n;
io += c.index.length;
if (Array.isArray(c.bounds) && c.bounds.length >= 6) {
const b = c.bounds;
bounds = bounds
? [Math.min(bounds[0], b[0]), Math.min(bounds[1], b[1]), Math.min(bounds[2], b[2]), Math.max(bounds[3], b[3]), Math.max(bounds[4], b[4]), Math.max(bounds[5], b[5])]
: b.slice();
}
}
return { material: first.material, lightmap: first.lightmap, group: '', bounds, position, uv, uv1, normal, color, index };
}
/** Ground rect [x0, y0, x1, y1] (board space) of every named group of chunks (the scene's switchable parts). */ /** Ground rect [x0, y0, x1, y1] (board space) of every named group of chunks (the scene's switchable parts). */
export function sceneGroupRects(chunks) { export function sceneGroupRects(chunks) {
const out = {}; const out = {};
+17 -3
View File
@@ -19,6 +19,8 @@
// static crates (merged, same material), // static crates (merged, same material),
// decals (MT_autochess_common), fence pipes, blowers (merged, unlit), gate boxes (additive + alpha, merged per // decals (MT_autochess_common), fence pipes, blowers (merged, unlit), gate boxes (additive + alpha, merged per
// material), cyan field edges, terrain overlays (≤ 4), background plane + its shadow catcher — ~12–16 in total. // material), cyan field edges, terrain overlays (≤ 4), background plane + its shadow catcher — ~12–16 in total.
// An official scene replaces the tiles, glass, decals, pipes and background with one mesh per material and lightmap
// page of the parts the built areas show (load.js mergeSceneChunks). Devices, gates, edges and terrain stay.
// The key light's shadow map is rendered only when the geometry changes (autoUpdate off). // The key light's shadow map is rendered only when the geometry changes (autoUpdate off).
import { buildBoard, objToBoard, boxProjectUV, ROWS, COLS, DEVICE_H, AREAS } from './layout.js'; import { buildBoard, objToBoard, boxProjectUV, ROWS, COLS, DEVICE_H, AREAS } from './layout.js';
@@ -28,6 +30,7 @@ import {
waterMaterial, mireMaterial, infectionMaterial, smogMaterial, dashTexture, environmentMap, sceneMaterial, sceneWaterMaterial, waterMaterial, mireMaterial, infectionMaterial, smogMaterial, dashTexture, environmentMap, sceneMaterial, sceneWaterMaterial,
} from './materials.js'; } from './materials.js';
import { syncThreeCamera } from '../projection.js'; import { syncThreeCamera } from '../projection.js';
import { mergeSceneChunks, sceneMaterialKey } from './load.js';
/** Lighting rig (tuned against the official screenshots: bright even tops, darker sides, soft shadows). */ /** Lighting rig (tuned against the official screenshots: bright even tops, darker sides, soft shadows). */
export const LIGHTING = Object.freeze({ export const LIGHTING = Object.freeze({
@@ -343,18 +346,29 @@ export class BoardScene {
this.sceneRes.delete(pack.key); this.sceneRes.delete(pack.key);
} }
/** The official scene's meshes: one per chunk of a shown part (the base + the fields of the built areas). */ /**
* The official scene's meshes: the base plus the fields of the built areas, one mesh per material and lightmap
* page (`mergeSceneChunks`). A switched-off HIDDEN part is left out before the merge.
*/
_buildScene(pack) { _buildScene(pack) {
const T = this.THREE; const T = this.THREE;
const res = this._sceneResources(pack); const res = this._sceneResources(pack);
const infos = pack.doc?.materials || {}; const infos = pack.doc?.materials || {};
this.sceneWater = []; this.sceneWater = [];
let n = 0; const buckets = new Map();
for (const c of pack.chunks || []) { for (const c of pack.chunks || []) {
if (c.group && !sceneGroupVisible(pack.groups?.[c.group], this.area)) continue; if (c.group && !sceneGroupVisible(pack.groups?.[c.group], this.area)) continue;
const info = infos[c.material] || null; const info = infos[c.material] || null;
const mk = sceneMaterialKey(c, info);
let bucket = buckets.get(mk);
if (!bucket) { bucket = { info, chunks: [] }; buckets.set(mk, bucket); }
bucket.chunks.push(c);
}
let n = 0;
for (const [mk, bucket] of buckets) {
const c = mergeSceneChunks(bucket.chunks);
const info = bucket.info;
const water = info?.kind === 'water'; const water = info?.kind === 'water';
const mk = `${c.material}|${water ? '' : c.lightmap}`;
let mat = res.mats.get(mk); let mat = res.mats.get(mk);
if (!mat) { if (!mat) {
mat = water mat = water
+105 -5
View File
@@ -18,7 +18,8 @@ import { resolveUvTable } from '../../public/js/render/board3d/atlas.js';
import { presetCamera } from '../../public/js/render/projection.js'; import { presetCamera } from '../../public/js/render/projection.js';
import { sceneMaterial, sceneWaterMaterial, sceneBlend, focusUniforms, SCENE_LIGHT } from '../../public/js/render/board3d/materials.js'; import { sceneMaterial, sceneWaterMaterial, sceneBlend, focusUniforms, SCENE_LIGHT } from '../../public/js/render/board3d/materials.js';
import { import {
parseSceneChunks, sceneGroupRects, loadScenePack, sceneListed, resetBoardPack, sceneGroup, SCENE_TEX_GROUP, SCENE_VERSION, SCENE_VERTEX_BYTES, parseSceneChunks, sceneGroupRects, loadScenePack, sceneListed, resetBoardPack, sceneGroup, sceneMaterialKey, mergeSceneChunks,
SCENE_TEX_GROUP, SCENE_VERSION, SCENE_VERTEX_BYTES,
} from '../../public/js/render/board3d/load.js'; } from '../../public/js/render/board3d/load.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
@@ -211,6 +212,45 @@ describe('scene chunks (load.js)', () => {
assert.equal(parseSceneChunks(doc, null), null); assert.equal(parseSceneChunks(doc, null), null);
}); });
test('sceneMaterialKey / mergeSceneChunks: one mesh per material and lightmap page; indices keep each quad', () => {
assert.equal(sceneMaterialKey({ material: 'M', lightmap: 3 }, { kind: 'lit' }), 'M|3');
assert.equal(sceneMaterialKey({ material: 'M', lightmap: 0 }, null), 'M|0');
assert.equal(sceneMaterialKey({ material: 'W', lightmap: -1 }, { kind: 'water' }), 'W|');
assert.equal(sceneMaterialKey({ material: 'W', lightmap: 2 }, { kind: 'water' }), 'W|', 'water has no lightmap page');
const { doc, bin } = fakeScene([{ material: 'M', x0: 0, y0: 0 }, { material: 'M', x0: 3, y0: 1, group: 'HIDDEN@1' }]);
const chunks = parseSceneChunks(doc, bin);
assert.equal(mergeSceneChunks([chunks[0]]), chunks[0], 'a single chunk stays the scene.bin view');
assert.equal(mergeSceneChunks([]), null);
assert.equal(mergeSceneChunks(null), null);
const merged = mergeSceneChunks(chunks);
assert.equal(merged.position.length / 3, 8);
assert.ok(merged.index instanceof Uint16Array);
assert.deepEqual([...merged.index], [0, 1, 2, 0, 2, 3, 4, 5, 6, 4, 6, 7]);
assert.equal(merged.position[12], 3, 'the second quad keeps its board position');
assert.equal(merged.normal[18], 127);
assert.deepEqual([...merged.color.slice(16, 20)], [255, 255, 255, 255]);
assert.deepEqual([...merged.uv1.slice(8, 12)], [0, 0, 0.5, 0]);
assert.deepEqual([...chunks[1].index], [0, 1, 2, 0, 2, 3], 'the source chunk is not rewritten');
assert.deepEqual(merged.bounds, [0, 0, 0, 4, 2, 0]);
const big = new Float32Array(65535 * 3);
const one = {
material: 'M', lightmap: 0, group: '', bounds: null,
position: big, uv: new Float32Array(65535 * 2), uv1: new Float32Array(65535 * 2),
normal: new Int8Array(65535 * 4), color: new Uint8Array(65535 * 4), index: new Uint16Array([0, 0, 0]),
};
const extra = {
material: 'M', lightmap: 0, group: 'HIDDEN@1', bounds: null,
position: new Float32Array([9, 8, 7]), uv: new Float32Array(2), uv1: new Float32Array(2),
normal: new Int8Array(4), color: new Uint8Array(4), index: new Uint16Array([0, 0, 0]),
};
const wide = mergeSceneChunks([one, extra]);
assert.ok(wide.index instanceof Uint32Array, 'past 65535 vertices the index is 32-bit');
assert.equal(wide.position.length / 3, 65536);
assert.equal(wide.index[3], 65535);
assert.equal(wide.position[65535 * 3], 9);
assert.throws(() => mergeSceneChunks([{ ...chunks[0], material: 'Other' }, chunks[1]]), /mixes/);
});
test('sceneGroupRects / sceneGroupVisible: the official sub-graphics are the fields of the built areas', () => { test('sceneGroupRects / sceneGroupVisible: the official sub-graphics are the fields of the built areas', () => {
const { doc, bin } = fakeScene(PARTS); const { doc, bin } = fakeScene(PARTS);
const rects = sceneGroupRects(parseSceneChunks(doc, bin)); const rects = sceneGroupRects(parseSceneChunks(doc, bin));
@@ -305,9 +345,17 @@ describe('BoardScene with an official scene (stub renderer)', () => {
assert.ok(!board.meshes.board && !board.meshes.glass && !board.meshes.decal && !board.meshes.pipe && !board.meshes.bg && !board.meshes.bgShadow); assert.ok(!board.meshes.board && !board.meshes.glass && !board.meshes.decal && !board.meshes.pipe && !board.meshes.bg && !board.meshes.bgShadow);
assert.ok(board.meshes.crates && board.meshes.edges && board.meshes.mire, Object.keys(board.meshes).join()); assert.ok(board.meshes.crates && board.meshes.edges && board.meshes.mire, Object.keys(board.meshes).join());
const meshes = sceneMeshes(board); const meshes = sceneMeshes(board);
assert.equal(meshes.length, 4, 'base ×2 + the left normal field ×2 (AREAS.normal)'); assert.equal(meshes.length, 3, 'base + the left field: the two M quads share one mesh');
assert.deepEqual(meshes.map((m) => m.geometry.getAttribute('position').count).sort((a, b) => a - b), [4, 4, 8]);
const xs = meshes.flatMap((m) => {
const a = m.geometry.getAttribute('position').array;
const out = [];
for (let i = 0; i < a.length; i += 3) out.push(a[i]);
return out;
});
assert.ok(xs.includes(9) && xs.includes(4), 'base and the left field');
assert.ok(!xs.includes(16) && !xs.includes(11), 'the right field and the boss field stay out');
for (const m of meshes) { for (const m of meshes) {
assert.equal(m.geometry.getAttribute('position').count, 4);
assert.ok(m.geometry.getAttribute('aLm') && m.geometry.getAttribute('aColor').normalized && m.geometry.getAttribute('normal').normalized); assert.ok(m.geometry.getAttribute('aLm') && m.geometry.getAttribute('aColor').normalized && m.geometry.getAttribute('normal').normalized);
assert.equal(m.castShadow, false); assert.equal(m.castShadow, false);
} }
@@ -328,9 +376,9 @@ describe('BoardScene with an official scene (stub renderer)', () => {
assert.equal(lit.uniforms.uMap.value.colorSpace, THREE.SRGBColorSpace); assert.equal(lit.uniforms.uMap.value.colorSpace, THREE.SRGBColorSpace);
assert.equal(lit.uniforms.uLm.value.colorSpace, THREE.NoColorSpace); assert.equal(lit.uniforms.uLm.value.colorSpace, THREE.NoColorSpace);
board.setArea(AREAS.boss); board.setArea(AREAS.boss);
assert.equal(sceneMeshes(board).length, 3, 'base ×2 + the boss field'); assert.equal(sceneMeshes(board).length, 2, 'base + the boss field, the two M quads merged');
board.setArea(AREAS.unite); board.setArea(AREAS.unite);
assert.equal(sceneMeshes(board).length, 5, 'base ×2 + both normal fields (2 + 1)'); assert.equal(sceneMeshes(board).length, 3, 'base + both normal fields: M merged, water, cutout');
assert.equal(sceneMeshes(board).find((m) => m.material.uniforms.uMap).material, lit, 'same material object'); assert.equal(sceneMeshes(board).find((m) => m.material.uniforms.uMap).material, lit, 'same material object');
assert.equal(board.sceneRes.size, 1); assert.equal(board.sceneRes.size, 1);
board.destroy(); board.destroy();
@@ -353,6 +401,27 @@ describe('BoardScene with an official scene (stub renderer)', () => {
board.destroy(); board.destroy();
}); });
test('a different lightmap page stays its own mesh; water pages share one', () => {
const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() });
const parts = [
{ material: 'M', x0: 4, y0: 9, lightmap: 0, group: 'HIDDEN@1' },
{ material: 'M', x0: 9, y0: 15, lightmap: 1 },
{ material: 'W', x0: 5, y0: 9, lightmap: 0, group: 'HIDDEN@1' },
{ material: 'W', x0: 6, y0: 9, lightmap: 1, group: 'HIDDEN@1' },
];
const { doc, bin } = fakeScene(parts);
const chunks = parseSceneChunks(doc, bin);
board.setScenePack(stage.id, { key: 'split', stage: stage.id, doc, chunks, groups: sceneGroupRects(chunks), images: { tex: {}, lm: [] } });
board.setStage(stage);
const meshes = sceneMeshes(board);
assert.equal(meshes.length, 3, 'M|0, M|1, and one water mesh');
const water = meshes.filter((m) => m.material.uniforms.uDeep);
assert.equal(water.length, 1);
assert.equal(water[0].geometry.getAttribute('position').count, 8, 'both water quads');
assert.equal(board.sceneWater.length, 1);
board.destroy();
});
test('a scene whose chunks are all hidden parts of other areas keeps the procedural board', () => { test('a scene whose chunks are all hidden parts of other areas keeps the procedural board', () => {
const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() }); const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() });
const { doc, bin } = fakeScene([{ material: 'M', x0: 10, y0: 3, group: 'HIDDEN@3' }]); const { doc, bin } = fakeScene([{ material: 'M', x0: 10, y0: 3, group: 'HIDDEN@3' }]);
@@ -391,4 +460,35 @@ describe('extracted scenes (when scene bundles were extracted here)', { skip: !O
assert.ok(L && Math.abs(Math.hypot(...L.dir) - 1) < 1e-3 && L.dir[2] > 0, 'the key light shines down on the board'); assert.ok(L && Math.abs(Math.hypot(...L.dir) - 1) < 1e-3 && L.dir[2] > 0, 'the key light shines down on the board');
}); });
} }
test('each built area is one mesh per material and lightmap, with every shown vertex and no switched-off part', () => {
const views = [['normal', AREAS.normal], ['boss', AREAS.boss], ['unite', AREAS.unite]];
for (const g of Object.keys(groups).filter((k) => k.startsWith('scene/act'))) {
if (!existsSync(file(groups[g].scene?.path || '/x'))) continue;
const doc = JSON.parse(readFileSync(file(groups[g].scene.path), 'utf8'));
const bin = readFileSync(file(groups[g].bin.path));
const chunks = parseSceneChunks(doc, bin.buffer.slice(bin.byteOffset, bin.byteOffset + bin.length));
const rects = sceneGroupRects(chunks);
const stage = stages[doc.stage];
const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() });
board.setScenePack(doc.stage, { key: g, stage: doc.stage, doc, chunks, groups: rects, images: { tex: {}, lm: [] } });
board.setStage(stage);
for (const [name, area] of views) {
board.setArea(area);
const shown = chunks.filter((c) => !c.group || sceneGroupVisible(rects[c.group], area));
const keys = new Set(shown.map((c) => sceneMaterialKey(c, doc.materials[c.material])));
const meshes = sceneMeshes(board);
assert.equal(meshes.length, keys.size, `${doc.stage} ${name}: ${shown.length} chunks → ${keys.size} meshes`);
assert.ok(meshes.length > 0 && meshes.length <= 16, `${doc.stage} ${name}: ${meshes.length} scene draw calls`);
let verts = 0;
for (const m of meshes) verts += m.geometry.getAttribute('position').count;
assert.equal(verts, shown.reduce((a, c) => a + c.position.length / 3, 0), `${doc.stage} ${name} vertices`);
if (name !== 'unite') assert.ok(board.root.children.length <= 19, `${doc.stage} ${name}: ${board.root.children.length} meshes beside one battle device still fit 20 draws`);
}
assert.equal(board.meshes.board, undefined, `${doc.stage}: the scene replaces the metal tiles`);
board.setScenePack(doc.stage, null);
assert.ok(board.meshes.board && board.meshes.bg, `${doc.stage}: dropping the scene restores the metal board`);
board.destroy();
}
});
}); });
+38 -7
View File
@@ -1,6 +1,7 @@
// Real-server browser E2E of user playtest #2 item 10 (+ item 6 on the real flow): the boss round's prep shows the // Real-server browser E2E of user playtest #2 item 10 (+ item 6 on the real flow): the boss round's prep shows the
// player's own half of the boss field (tiles, gates, objective) and placing works — solo (标准: R9 最终攻势; 险境: // player's own half of the boss field (tiles, gates, objective) and placing works — solo (标准: R9 最终攻势; 险境:
// R15 隐秘核心) and co-op (two humans: the host prepares on the LEFT half, the guest on the mirrored RIGHT half) — then // R15 隐秘核心) and co-op (two humans: each prepares on the half m.public.bossPairs assigns, the second of the pair
// on the mirrored RIGHT half) — then
// the Final Assault starts with the placed units on the boss field. Along the way (item 6): the pen stays hidden with // the Final Assault starts with the placed units on the boss field. Along the way (item 6): the pen stays hidden with
// the board (🔍▶▶ shows it, 🔍◀◀ "返回战场" hides it again), scouting a teammate (前往查看) and coming back, and in // the board (🔍▶▶ shows it, 🔍◀◀ "返回战场" hides it again), scouting a teammate (前往查看) and coming back, and in
// the boss battle. // the boss battle.
@@ -21,6 +22,7 @@ import assert from 'node:assert/strict';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { Client, ROOT, sleep, hasChrome, startRealServer, problemsOf, waitForFunctionLong } from '../e2e/client.mjs'; import { Client, ROOT, sleep, hasChrome, startRealServer, problemsOf, waitForFunctionLong } from '../e2e/client.mjs';
import { prepCamera } from '../../public/js/ui/gameLogic/camera.js';
const ENABLED = process.env.SP_E2E === '1' && hasChrome() && existsSync(path.join(ROOT, 'public/assets')); const ENABLED = process.env.SP_E2E === '1' && hasChrome() && existsSync(path.join(ROOT, 'public/assets'));
const ONLY = process.env.SP_P2_REAL || ''; const ONLY = process.env.SP_P2_REAL || '';
@@ -101,6 +103,29 @@ async function placeOne(c, dir = 'RIGHT') {
throw new Error(`${c.label}: nothing could be placed`); throw new Error(`${c.label}: nothing could be placed`);
} }
/**
* The half this client prepares on, from the published pairing (`m.public.bossPairs` through `prepCamera`).
* The first of a pair is on the left half; the second is on the mirrored right half. Seat order is not a side.
*/
async function assignedBossSide(c) {
const snap = await c.page.evaluate(() => {
const s = globalThis.__SP__.store.get();
const pub = s.match.public || {};
return {
me: s.me?.playerId ?? null,
phase: pub.phase ?? null, round: pub.round ?? null, bossRound: pub.bossRound ?? null, hiddenRound: pub.hiddenRound ?? null,
bossPairs: pub.bossPairs ?? null,
players: (pub.players || []).map((p) => ({ playerId: p.playerId, seat: p.seat, alive: p.alive !== false })),
};
});
const cam = prepCamera(snap, snap.me);
const pair = (Array.isArray(snap.bossPairs) ? snap.bossPairs : []).find((g) => Array.isArray(g) && g.includes(snap.me));
assert.equal(cam.kind, 'bossPrep', `${c.label}: boss-round prep (pairs ${JSON.stringify(snap.bossPairs)})`);
assert.ok(pair, `${c.label}: a published pair`);
assert.equal(cam.opts.side, pair.indexOf(snap.me) % 2 === 1 ? 'R' : 'L', `${c.label}: the side is the pair index`);
return { side: cam.opts.side, me: snap.me, pair, bossPairs: snap.bossPairs };
}
/** The boss-round prep checks shared by every variant. */ /** The boss-round prep checks shared by every variant. */
async function checkBossPrep(c, side, tag) { async function checkBossPrep(c, side, tag) {
const v = await viewState(c); const v = await viewState(c);
@@ -344,7 +369,7 @@ describe('user playtest #2 item 10 — boss-round prep on the boss field (real s
} }
}); });
test('co-op 险境 (R14): host on the left half, guest on the mirrored right half; scouting and 返回 keep the pen hidden', { skip: only('coop'), timeout: 10 * 60 * 1000 }, async () => { test('co-op 险境 (R14): each player on the half bossPairs assigns (the right half mirrored); scouting and 返回 keep the pen hidden', { skip: only('coop'), timeout: 10 * 60 * 1000 }, async () => {
const srv = await startRealServer({ fast: { timerScale: 1, combatSpeed: 8, startRound: 'boss', items: ['chess_item_6_03_m'] } }); const srv = await startRealServer({ fast: { timerScale: 1, combatSpeed: 8, startRound: 'boss', items: ['chess_item_6_03_m'] } });
const P = await pptr(); const P = await pptr();
const host = new Client(P, srv.base, 'host', { prefix: 'fix' }); const host = new Client(P, srv.base, 'host', { prefix: 'fix' });
@@ -386,8 +411,12 @@ describe('user playtest #2 item 10 — boss-round prep on the boss field (real s
await sleep(1800); await sleep(1800);
const hs = await host.st(); const hs = await host.st();
assert.equal(hs.round, hs.bossRound, 'the boss round'); assert.equal(hs.round, hs.bossRound, 'the boss round');
await checkBossPrep(host, 'L', 'host'); const hostSeat = await assignedBossSide(host);
await checkBossPrep(guest, 'R', 'guest'); const guestSeat = await assignedBossSide(guest);
assert.ok(hostSeat.pair.includes(guestSeat.me), `the two players share a pair (${JSON.stringify(hostSeat.bossPairs)})`);
assert.notEqual(hostSeat.side, guestSeat.side, 'a pair occupies both halves');
await checkBossPrep(host, hostSeat.side, 'host');
await checkBossPrep(guest, guestSeat.side, 'guest');
const hp = await placeOne(host, 'RIGHT'); const hp = await placeOne(host, 'RIGHT');
const gp = await placeOne(guest, 'RIGHT'); const gp = await placeOne(guest, 'RIGHT');
await sleep(600); await sleep(600);
@@ -404,12 +433,14 @@ describe('user playtest #2 item 10 — boss-round prep on the boss field (real s
const w = await viewState(host); const w = await viewState(host);
assert.equal(w.penVisible, 0, 'scouting: no pen figure with the teammate\'s board'); assert.equal(w.penVisible, 0, 'scouting: no pen figure with the teammate\'s board');
assert.ok(w.band[1] <= 13, `scouting: no pen rows (${w.band})`); assert.ok(w.band[1] <= 13, `scouting: no pen rows (${w.band})`);
assert.equal(w.camKind, 'bossPrep', 'scouting: the boss field');
assert.deepEqual(w.prepField, { kind: 'bossPrep', side: guestSeat.side, mirror: guestSeat.side === 'R' }, 'scouting: the teammate\'s half');
await host.click('.gm__watching button, .team__back', null, { timeout: 4000 }); await host.click('.gm__watching button, .team__back', null, { timeout: 4000 });
await host.page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'bossPrep', { timeout: 6000 }); await host.page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'bossPrep', { timeout: 6000 });
await sleep(900); await sleep(900);
const back = await viewState(host); const back = await viewState(host);
assert.equal(back.penVisible, 0, '返回: no pen figure'); assert.equal(back.penVisible, 0, '返回: no pen figure');
assert.deepEqual(back.prepField, { kind: 'bossPrep', side: 'L', mirror: false }, '返回: the own boss half again'); assert.deepEqual(back.prepField, { kind: 'bossPrep', side: hostSeat.side, mirror: hostSeat.side === 'R' }, '返回: the own boss half again');
} }
await penRoundTrip(guest, 'guest'); await penRoundTrip(guest, 'guest');
// Real clients open their own 教鞭 candidates together; refreshing one tab restores that same private choice. // Real clients open their own 教鞭 candidates together; refreshing one tab restores that same private choice.
@@ -454,8 +485,8 @@ describe('user playtest #2 item 10 — boss-round prep on the boss field (real s
await c.page.waitForFunction(() => globalThis.__SP__.store.get().match.private.personalChoice === null, { timeout: 6000 }); await c.page.waitForFunction(() => globalThis.__SP__.store.get().match.private.personalChoice === null, { timeout: 6000 });
} }
for (const c of [host, guest]) await c.click('.readybtn'); for (const c of [host, guest]) await c.click('.readybtn');
await checkBossBattle(host, hp, 'L', 'host'); await checkBossBattle(host, hp, hostSeat.side, 'host');
await checkBossBattle(guest, gp, 'R', 'guest'); await checkBossBattle(guest, gp, guestSeat.side, 'guest');
assert.deepEqual(problemsOf([host, guest]), []); assert.deepEqual(problemsOf([host, guest]), []);
} finally { } finally {
for (const c of [host, guest]) if (c.problems.length) console.log(c.label, c.problems.slice(0, 20).join('\n')); for (const c of [host, guest]) if (c.problems.length) console.log(c.label, c.problems.slice(0, 20).join('\n'));