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:
1 parent
e2bc005c88
commit
3bb7fef74c
6 files changed
+232
-17
No files matched your search
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = {};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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'));
|
||||||
|
|||||||
Reference in new issue
Block a user