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

+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 { sceneMaterial, sceneWaterMaterial, sceneBlend, focusUniforms, SCENE_LIGHT } from '../../public/js/render/board3d/materials.js';
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';
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);
});
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', () => {
const { doc, bin } = fakeScene(PARTS);
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.crates && board.meshes.edges && board.meshes.mire, Object.keys(board.meshes).join());
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) {
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.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.uLm.value.colorSpace, THREE.NoColorSpace);
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);
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(board.sceneRes.size, 1);
board.destroy();
@@ -353,6 +401,27 @@ describe('BoardScene with an official scene (stub renderer)', () => {
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', () => {
const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() });
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');
});
}
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
// 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 board (🔍▶▶ shows it, 🔍◀◀ "返回战场" hides it again), scouting a teammate (前往查看) and coming back, and in
// the boss battle.
@@ -21,6 +22,7 @@ import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import path from 'node:path';
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 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`);
}
/**
* 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. */
async function checkBossPrep(c, side, tag) {
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 P = await pptr();
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);
const hs = await host.st();
assert.equal(hs.round, hs.bossRound, 'the boss round');
await checkBossPrep(host, 'L', 'host');
await checkBossPrep(guest, 'R', 'guest');
const hostSeat = await assignedBossSide(host);
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 gp = await placeOne(guest, 'RIGHT');
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);
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.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.page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'bossPrep', { timeout: 6000 });
await sleep(900);
const back = await viewState(host);
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');
// 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 });
}
for (const c of [host, guest]) await c.click('.readybtn');
await checkBossBattle(host, hp, 'L', 'host');
await checkBossBattle(guest, gp, 'R', 'guest');
await checkBossBattle(host, hp, hostSeat.side, 'host');
await checkBossBattle(guest, gp, guestSeat.side, 'guest');
assert.deepEqual(problemsOf([host, guest]), []);
} finally {
for (const c of [host, guest]) if (c.problems.length) console.log(c.label, c.problems.slice(0, 20).join('\n'));