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
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
|
||||
Reference in new issue
Block a user