0.2.5-dev: official battlefield scenes on the 3D board — a local fetch / extract step for the season scene bundles, per-field scene rendering with the metal board as fallback, the full bundle can include the extracted scenes and setup can fetch them (--scenes); nothing official is stored in the repository (PR #533 by @andrew05060414, ported; the owner's decision of 2026-10-12); golden: unchanged

This commit is contained in:
sganggs committed 2026-10-12 14:59:20 +08:00
1 parent 7fd0478dc0
commit d482fbb8ba
19 files changed
+1659 -22

No files matched your search

+394
View File
@@ -0,0 +1,394 @@
// test/render/board3d-scene.test.js — the official battlefield scenes (DESIGN §15): tools/local-extract/scenes.py
// (the scene table, the board-space conversion, the chunk records, the sub-graphic groups, the RGBM lightmap decode;
// run without UnityPy), fetch_scenes.py's bundle selection, the browser side (render/board3d/load.js chunk reader and
// loader with a fake store; scene.js BoardScene with a stub renderer: the scene replaces the procedural tiles and
// background, its parts follow the built areas, a missing scene keeps the procedural board), and — when scenes were
// extracted here — the files the manifest lists.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as THREE from 'three';
import { BoardScene, sceneGroupVisible } from '../../public/js/render/board3d/scene.js';
import { AREAS } from '../../public/js/render/board3d/layout.js';
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,
} from '../../public/js/render/board3d/load.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const TOOL = path.join(ROOT, 'tools/local-extract');
const ENV = { ...process.env, PYTHONDONTWRITEBYTECODE: '1' };
const PY = ['python3', 'python'].find((bin) => spawnSync(bin, ['--version']).status === 0);
const HAS_PIL = !!PY && spawnSync(PY, ['-c', 'import PIL'], { env: ENV }).status === 0;
const stages = JSON.parse(readFileSync(path.join(ROOT, 'data/stages.json'), 'utf8'));
const manifest = (() => { try { return JSON.parse(readFileSync(path.join(ROOT, 'data/local-assets.json'), 'utf8')); } catch { return null; } })();
function py(code) {
const src = `import sys, json, struct\nsys.path.insert(0, ${JSON.stringify(TOOL)})\nimport scenes as s\n${code}`;
const r = spawnSync(PY, ['-c', src], { encoding: 'utf8', env: ENV });
assert.equal(r.status, 0, r.stderr);
return JSON.parse(r.stdout);
}
/** One quad as a scene chunk record + its bytes (the layout of scenes.py pack_chunk). */
function quadChunk({ x0 = 0, y0 = 0, z = 0, material = 'M', lightmap = 0, group = '' } = {}) {
const n = 4, idx = [0, 1, 2, 0, 2, 3];
const buf = new ArrayBuffer(n * SCENE_VERTEX_BYTES + idx.length * 2);
new Float32Array(buf, 0, 12).set([x0, y0, z, x0 + 1, y0, z, x0 + 1, y0 + 1, z, x0, y0 + 1, z]);
new Float32Array(buf, 48, 8).set([0, 0, 1, 0, 1, 1, 0, 1]);
new Float32Array(buf, 80, 8).set([0, 0, 0.5, 0, 0.5, 0.5, 0, 0.5]);
new Int8Array(buf, 112, 16).set([0, 0, 127, 0, 0, 0, 127, 0, 0, 0, 127, 0, 0, 0, 127, 0]);
new Uint8Array(buf, 128, 16).fill(255);
new Uint16Array(buf, 144, 6).set(idx);
return { rec: { material, lightmap, group, verts: n, tris: 2, index32: false, bounds: [x0, y0, z, x0 + 1, y0 + 1, z] }, bytes: new Uint8Array(buf) };
}
/** scene.json + scene.bin of a few quads. */
function fakeScene(parts) {
const chunks = parts.map(quadChunk);
const total = chunks.reduce((a, c) => a + c.bytes.length, 0);
const bin = new Uint8Array(total);
let off = 0;
const recs = chunks.map((c) => { bin.set(c.bytes, off); const r = { ...c.rec, offset: off }; off += c.bytes.length; return r; });
const doc = {
version: SCENE_VERSION, stage: 'act2autochess_m02', lightmaps: 1, lightRange: 8, binBytes: total,
light: { dir: [0.36, 0.45, 0.82], color: [1, 0.93, 0.87], intensity: 1.1 },
materials: {
M: { shader: 'Torappu/Scene/StandardRealtimeShadow', keywords: [], colors: { _Color: [1, 1, 1, 1] }, floats: { _ShadowStrength: 0.9 }, textures: {}, kind: 'lit', map: 'TX_A' },
Cut: { shader: 'Torappu/Scene/StandardRealtimeShadow', keywords: ['_ALPHATEST_ON'], colors: {}, floats: { _Cutoff: 0.4 }, textures: {}, kind: 'lit', map: 'TX_A' },
W: { shader: 'Torappu/Scene/StylizedWater', keywords: [], colors: { _DeepWaterColor: [0, 0.27, 0.4, 0.67] }, floats: {}, textures: {}, kind: 'water' },
},
chunks: recs,
};
return { doc, bin: bin.buffer };
}
const PARTS = [
{ material: 'M', x0: 9, y0: 15 }, // the base (pen side)
{ material: 'W', x0: 20, y0: 12, lightmap: -1 }, // water of the base
{ material: 'M', x0: 4, y0: 9, group: 'HIDDEN@1' }, // left normal field
{ material: 'Cut', x0: 5, y0: 9, group: 'HIDDEN@1' },
{ material: 'M', x0: 16, y0: 9, group: 'HIDDEN@2' }, // right normal field
{ material: 'M', x0: 10, y0: 3, group: 'HIDDEN@3' }, // boss field
];
function fakeScenePack() {
const { doc, bin } = fakeScene(PARTS);
const chunks = parseSceneChunks(doc, bin);
const img = { width: 4, height: 4 };
return { key: 'scene-test', stage: doc.stage, doc, chunks, groups: sceneGroupRects(chunks), images: { tex: { TX_A: img }, lm: [img] } };
}
function stubRenderer() {
const calls = [];
return {
calls,
domElement: { addEventListener() {}, removeEventListener() {} },
shadowMap: { enabled: true, needsUpdate: false },
capabilities: { getMaxAnisotropy: () => 8 },
info: { render: { calls: 7, triangles: 1234 }, memory: { textures: 3, geometries: 5 } },
setClearColor() {}, setPixelRatio() {}, setSize() {}, render() {}, dispose() { calls.push(['dispose']); },
};
}
function fakePack() {
const img = { width: 4, height: 4 };
return { key: 'test', images: { D: img, common: img, gate: img, BG: img }, meshes: { gate: {} }, tiles: null, uv: resolveUvTable(null), materials: {} };
}
const sceneMeshes = (board) => Object.entries(board.meshes).filter(([k, m]) => k.startsWith('scene') && m).map(([, m]) => m);
describe('scenes.py (no UnityPy needed)', { skip: !PY && 'no python3' }, () => {
test('scene table: one prebuilt scene per battlefield of data/stages.json, with the theme bundles of its materials', () => {
const r = spawnSync(PY, [path.join(TOOL, 'extract.py'), '--print-jobs'], { encoding: 'utf8', env: ENV });
assert.equal(r.status, 0, r.stderr);
const { scenes } = JSON.parse(r.stdout);
assert.equal(scenes.sub, 'scene');
assert.equal(scenes.tex, SCENE_TEX_GROUP);
const ids = scenes.stages.map((x) => x.id);
const battlefields = Object.keys(stages).filter((id) => /^act\dautochess_m\d+$/.test(id)).sort();
assert.deepEqual([...ids].sort(), battlefields, 'every battlefield has a scene');
for (const st of scenes.stages) {
const season = st.id.replace(/_m\d+$/, '');
assert.equal(st.bundle, `scenes/activities/${season}/level_${st.id}/level_${st.id}.ab`);
assert.ok(st.deps.length > 0 && st.deps.every((d) => d.startsWith('arts/maps/') && d.endsWith('.ab')), st.id);
}
const sand = scenes.stages.filter((x) => x.deps.includes('arts/maps/map_autochesssand/res.ab')).map((x) => x.id);
assert.deepEqual(sand, ['act1autochess_m01', 'act1autochess_m02', 'act1autochess_m03', 'act1autochess_m04', 'act1autochess_m05',
'act1autochess_m06', 'act1autochess_m07', 'act2autochess_m02', 'act2autochess_m04'], 'the sand-theme battlefields (official asset index)');
assert.ok(!scenes.stages.some((x) => x.deps.includes('arts/maps/map_autochess/res.ab')), 'no battlefield uses the metal theme');
});
test('--only scene / scene/<id> selects scenes; a client without scene bundles skips them', () => {
const out = py(`import extract as e
print(json.dumps({'all': len(e.scene_ids([])), 'scene': len(e.scene_ids(['scene'])), 'one': e.scene_ids(['scene/act2autochess_m02']),
'other': e.scene_ids(['emoticon']), 'missing': s.find_scene('act2autochess_m02', ['/no/such/root']) is None}))`);
assert.deepEqual(out, { all: 11, scene: 11, one: ['act2autochess_m02'], other: [], missing: true });
});
test('board space: Unity anchor-local (x, y, −z); transforms compose; a mirrored transform has a negative determinant', () => {
const out = py(`m = s.mul(s.trs((1, 2, 3), (0, 0, 0, 1), (2, 2, 2)), s.trs((1, 0, 0), (0, 0, 0, 1), (1, 1, 1)))
rot = s.trs((0, 0, 0), (0, 0, 0.7071068, 0.7071068), (1, 1, 1))
print(json.dumps({'board': s.to_board((-6.0, -4.0, -0.4), (-10.0, -9.0, 0.0)), 'm': [m[0][3], m[1][3], m[2][3], m[0][0]],
'rot': [round(rot[0][1], 4), round(rot[1][0], 4)], 'det': [s.det3(s.IDENTITY), s.det3(s.trs((0, 0, 0), (0, 0, 0, 1), (1, 1, -1)))]}))`);
assert.deepEqual(out.board, [4, 5, 0.4], 'tile (row 5, col 4) on high ground');
assert.deepEqual(out.m, [3, 2, 3, 2]);
assert.deepEqual(out.rot, [-1, 1], 'a quarter turn about z');
assert.deepEqual(out.det, [1, -1]);
});
test('pack_chunk: 36 bytes per vertex + 16-bit indices padded to 4 bytes (what load.js parseSceneChunks reads)', () => {
const out = py(`ch = {'pos': [(0, 0, 0), (1, 0, 0), (0, 1, 0.5)], 'uv': [(0, 0), (1, 0), (0, 1)], 'uv1': [(0.1, 0.2), (0.3, 0.2), (0.1, 0.4)],
'nrm': [(0, 0, 1)] * 3, 'col': [(1, 0.5, 0, 1)] * 3, 'idx': [0, 1, 2]}
data, idx32 = s.pack_chunk(ch)
print(json.dumps({'len': len(data), 'idx32': idx32, 'hex': data.hex()}))`);
assert.equal(out.idx32, false);
assert.equal(out.len, 3 * SCENE_VERTEX_BYTES + 8, '3 vertices, 3 indices (6 bytes) padded to 8');
const bin = Uint8Array.from(Buffer.from(out.hex, 'hex')).buffer;
const [c] = parseSceneChunks({ chunks: [{ material: 'M', lightmap: 0, group: '', verts: 3, tris: 1, offset: 0, index32: false, bounds: [0, 0, 0, 1, 1, 0.5] }] }, bin);
assert.deepEqual([...c.position], [0, 0, 0, 1, 0, 0, 0, 1, 0.5]);
assert.deepEqual([...c.uv1].map((v) => Math.round(v * 10) / 10), [0.1, 0.2, 0.3, 0.2, 0.1, 0.4]);
assert.deepEqual([...c.normal.slice(0, 4)], [0, 0, 127, 0]);
assert.deepEqual([...c.color.slice(0, 4)], [255, 128, 0, 255]);
assert.deepEqual([...c.index], [0, 1, 2]);
});
test('hidden_groups: the objects listed after each sub-graphic name of a MapSubGraphicHolder', () => {
const out = py(`def name(t):
b = t.encode(); return struct.pack('<i', len(b)) + b + b'\\0' * (-len(b) % 4)
ptr = lambda pid: struct.pack('<iq', 0, pid)
raw = struct.pack('<i', 2) + name('HIDDEN@1') + struct.pack('<ii', 1, 2) + ptr(11) + ptr(12) + struct.pack('<i', 1) + ptr(21) + struct.pack('<i', 0) \\
+ name('HIDDEN@2') + struct.pack('<ii', 1, 1) + ptr(13) + struct.pack('<i', 0) + ptr(99)
objs = {11: 1, 12: 1, 13: 1, 21: 1}
print(json.dumps({str(k): v for k, v in s.hidden_groups(raw, objs).items()}))`);
assert.deepEqual(out, { 11: 'HIDDEN@1', 12: 'HIDDEN@1', 21: 'HIDDEN@1', 13: 'HIDDEN@2' }, 'pointers to objects of the file; an unknown id is no pointer');
});
test('decode_rgbm: the RGBM light as (L / LIGHT_RANGE)^(1/2.2); a page without alpha is kept', { skip: !HAS_PIL && 'no Pillow' }, () => {
const out = py(`from PIL import Image
img = Image.new('RGBA', (2, 1)); img.putdata([(255, 128, 0, 51), (255, 255, 255, 255)])
d = s.decode_rgbm(img)
print(json.dumps({'mode': d.mode, 'px': list(d.getdata()), 'range': s.LIGHT_RANGE, 'plain': s.decode_rgbm(Image.new('RGB', (1, 1), (9, 8, 7))).getpixel((0, 0))}))`);
assert.equal(out.mode, 'RGB');
// texel (1, 0.5, 0, A = 0.2): L = 5^2.2 · 0.2^2.2 · rgb → stored (L / range)^(1/2.2) = (5^2.2 / range)^(1/2.2) · 0.2 · rgb^(1/2.2)
const k = (5 ** 2.2 / out.range) ** (1 / 2.2);
const want = [1, 128 / 255, 0].map((c) => Math.round(255 * Math.min(1, k * 0.2) * c ** (1 / 2.2)));
for (let i = 0; i < 3; i++) assert.ok(Math.abs(out.px[0][i] - want[i]) <= 2, `channel ${i}: ${out.px[0][i]} vs ${want[i]}`);
assert.deepEqual(out.px[1], [255, 255, 255], 'clamped at the range');
assert.deepEqual(out.plain, [9, 8, 7]);
});
test('fetch_scenes.py: CDN file names and the bundles of the scene table', () => {
const src = `import sys, json\nsys.path.insert(0, ${JSON.stringify(TOOL)})\nimport fetch_scenes as f\nnames = ['arts/maps/map_autochess/res.ab', 'scenes/activities/act2autochess/level_act2autochess_m02/level_act2autochess_m02.ab', 'scenes/activities/act2autochess/level_act2autochess_m02/level_act2autochess_m02/lightingdata.ab', 'scenes/activities/act44side/level_act44side_01/level_act44side_01.ab']\nprint(json.dumps({'dat': f.dat_name('scenes/a/level_x/level_x.ab'), 'hash': f.dat_name('avg/a#1.ab'), 'wanted': f.wanted(names, ['act2autochess_m02'])}))`;
const r = spawnSync(PY, ['-c', src], { encoding: 'utf8', env: ENV });
assert.equal(r.status, 0, r.stderr);
const out = JSON.parse(r.stdout);
assert.equal(out.dat, 'scenes_a_level_x_level_x.dat');
assert.equal(out.hash, 'avg_a__1.dat');
assert.equal(out.wanted.length, 2, 'the scene and its lighting data, nothing else');
});
});
describe('scene chunks (load.js)', () => {
test('parseSceneChunks: typed-array views per chunk; a record that does not fit the buffer rejects the scene', () => {
const { doc, bin } = fakeScene(PARTS);
const chunks = parseSceneChunks(doc, bin);
assert.equal(chunks.length, PARTS.length);
assert.equal(chunks[0].position.length, 12);
assert.deepEqual([...chunks[2].position.slice(0, 3)], [4, 9, 0]);
assert.deepEqual([...chunks[0].index], [0, 1, 2, 0, 2, 3]);
assert.equal(chunks[1].lightmap, -1);
assert.equal(parseSceneChunks(doc, bin.slice(0, bin.byteLength - 4)), null, 'truncated bin');
assert.equal(parseSceneChunks({ chunks: [{ ...doc.chunks[0], offset: 2 }] }, bin), null, 'unaligned offset');
assert.equal(parseSceneChunks({ chunks: [{ ...doc.chunks[0], verts: 0 }] }, bin), null);
assert.equal(parseSceneChunks(null, bin), null);
assert.equal(parseSceneChunks(doc, null), null);
});
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));
assert.deepEqual(Object.keys(rects).sort(), ['HIDDEN@1', 'HIDDEN@2', 'HIDDEN@3']);
assert.deepEqual(rects['HIDDEN@1'], [4, 9, 6, 10], 'union of the group\'s chunks');
const shown = (areas) => Object.keys(rects).filter((g) => sceneGroupVisible(rects[g], areas)).sort();
assert.deepEqual(shown(AREAS.normal), ['HIDDEN@1'], 'own field');
assert.deepEqual(shown(AREAS.unite), ['HIDDEN@1', 'HIDDEN@2'], 'both normal fields');
assert.deepEqual(shown(AREAS.boss), ['HIDDEN@3'], 'the boss field');
assert.deepEqual(shown(AREAS.all), ['HIDDEN@1', 'HIDDEN@2', 'HIDDEN@3']);
assert.equal(sceneGroupVisible(null, AREAS.normal), true, 'a part without bounds is shown');
});
test('loadScenePack: scene.json + scene.bin + lightmap pages + textures through the store; null when not listed or malformed', async () => {
resetBoardPack();
const { doc, bin } = fakeScene(PARTS);
const group = sceneGroup('act2autochess_m02');
const urls = { [`${group}|scene`]: '/s/scene.json', [`${group}|bin`]: '/s/scene.bin', [`${group}|lm0`]: '/s/lm0.webp', [`${SCENE_TEX_GROUP}|TX_A`]: '/t/TX_A.webp' };
const seen = [];
const store = { local: async () => ({ groups: {} }), localUrl: (g, n) => urls[`${g}|${n}`] || null, image: async (u) => { seen.push(u); return { width: 8, height: 8 }; } };
const original = globalThis.fetch;
let json = doc;
globalThis.fetch = async (u) => (u === '/s/scene.json' ? { ok: true, json: async () => json } : u === '/s/scene.bin' ? { ok: true, arrayBuffer: async () => bin } : { ok: false });
try {
assert.equal(await sceneListed(store, 'act2autochess_m02'), true);
assert.equal(await sceneListed(store, 'act2autochess_m01'), false);
assert.equal(await loadScenePack(store, 'act2autochess_m01'), null, 'not extracted');
const pack = await loadScenePack(store, 'act2autochess_m02');
assert.equal(pack.chunks.length, PARTS.length);
assert.deepEqual(seen.sort(), ['/s/lm0.webp', '/t/TX_A.webp']);
assert.ok(pack.images.tex.TX_A && pack.images.lm[0]);
assert.deepEqual(Object.keys(pack.groups).sort(), ['HIDDEN@1', 'HIDDEN@2', 'HIDDEN@3']);
assert.equal(await loadScenePack(store, 'act2autochess_m02'), pack, 'cached');
resetBoardPack();
json = { ...doc, version: SCENE_VERSION + 1 };
assert.equal(await loadScenePack(store, 'act2autochess_m02'), null, 'unknown version → the procedural board');
json = doc;
assert.ok(await loadScenePack(store, 'act2autochess_m02'), 'a failed load is not cached');
assert.equal(await loadScenePack(null, 'act2autochess_m02').catch(() => 'threw'), await loadScenePack(store, 'act2autochess_m02'), 'cached per stage');
} finally { globalThis.fetch = original; resetBoardPack(); }
});
});
describe('scene materials', () => {
test('sceneBlend: alpha test / blend by the Unity keywords', () => {
assert.equal(sceneBlend({ keywords: ['_NORMALMAP'] }), 'opaque');
assert.equal(sceneBlend({ keywords: ['_ALPHATEST_ON'] }), 'cutout');
assert.equal(sceneBlend({ keywords: ['_ALPHABLEND_ON', '_EMISSION'] }), 'blend');
assert.equal(sceneBlend(null), 'opaque');
});
test('sceneMaterial: linear albedo tint, lightmap range, key light, cutoff; unlit fallback without a lightmap page', () => {
const focus = focusUniforms(THREE);
const map = new THREE.Texture(), lm = new THREE.Texture();
const info = { keywords: ['_ALPHATEST_ON'], colors: { _Color: [0.5, 1, 1, 1] }, floats: { _Cutoff: 0.4, _ShadowStrength: 0.9 } };
const m = sceneMaterial(THREE, { map, lightMap: lm, info, lightRange: 8, light: { dir: [0, 0, 2], color: [1, 1, 1], intensity: 1.1 } }, focus);
assert.equal(m.uniforms.uMap.value, map);
assert.equal(m.uniforms.uLm.value, lm);
assert.ok(Math.abs(m.uniforms.uColor.value.x - 0.5 ** 2.2) < 1e-6, 'authored in gamma space');
assert.equal(m.uniforms.uCutoff.value, 0.4);
assert.equal(m.uniforms.uShadow.value, 0.9);
assert.equal(m.uniforms.uLmScale.value, 8 * SCENE_LIGHT.indirect);
assert.deepEqual(m.uniforms.uLightDir.value.toArray(), [0, 0, 1], 'normalised');
assert.ok(Math.abs(m.uniforms.uLightColor.value.x - 1.1 * SCENE_LIGHT.direct) < 1e-6);
assert.equal(m.uniforms.uFocus, focus.uFocus, 'the shared focus falloff');
assert.equal(m.transparent, false);
assert.ok(m.defines.SCENE_MAP && m.defines.SCENE_LM);
const bare = sceneMaterial(THREE, { info: { keywords: ['_ALPHABLEND_ON'] } }, focus);
assert.equal(bare.uniforms.uLmScale.value, 0);
assert.equal(bare.uniforms.uAmbient.value, SCENE_LIGHT.ambient);
assert.equal(bare.uniforms.uCutoff.value, 0);
assert.equal(bare.transparent, true);
assert.equal(bare.depthWrite, false);
assert.ok(!bare.defines.SCENE_MAP && !bare.defines.SCENE_LM);
const w = sceneWaterMaterial(THREE, {}, focus, { colors: { _DeepWaterColor: [0, 0.27, 0.4, 0.67] } });
assert.deepEqual(w.uniforms.uDeep.value.toArray(), [0, 0.27, 0.4, 0.67]);
assert.equal(w.transparent, true);
assert.ok(!w.defines.WATER_MAPS, 'no water maps: a flat tint');
});
});
describe('BoardScene with an official scene (stub renderer)', () => {
const stage = stages.act2autochess_m02;
test('the scene replaces the procedural tiles and background; devices, gates, edges and terrain stay', () => {
const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() });
board.setStage(stage);
assert.ok(board.meshes.board && board.meshes.bg, 'procedural board first');
assert.equal(board.official, null);
assert.equal(board.setScenePack(stage.id, fakeScenePack()), true);
assert.ok(board.official, 'official scene on');
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)');
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);
}
const water = meshes.find((m) => m.material.uniforms.uDeep);
assert.ok(water && water.material.transparent);
board.resize(1280, 720, 1);
board.render(presetCamera('normal', { width: 1280, height: 720 }), 2.5);
assert.equal(water.material.uniforms.uTime.value, 2.5, 'water animates');
board.destroy();
});
test('the scene\'s fields follow the built areas; materials and textures are reused across rebuilds', () => {
const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() });
const pack = fakeScenePack();
board.setScenePack(stage.id, pack);
board.setStage(stage);
const lit = sceneMeshes(board).find((m) => m.material.uniforms.uMap).material;
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');
board.setArea(AREAS.unite);
assert.equal(sceneMeshes(board).length, 5, 'base ×2 + both normal fields (2 + 1)');
assert.equal(sceneMeshes(board).find((m) => m.material.uniforms.uMap).material, lit, 'same material object');
assert.equal(board.sceneRes.size, 1);
board.destroy();
assert.equal(board.sceneRes.size, 0, 'scene resources disposed');
});
test('another stage, or dropping the scene, shows the procedural board again', () => {
const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() });
board.setScenePack(stage.id, fakeScenePack());
board.setStage(stages.act2autochess_m01);
assert.equal(board.official, null);
assert.ok(board.meshes.board && board.meshes.bg && !sceneMeshes(board).length, 'a stage without a scene');
board.setStage(stage);
assert.ok(board.official && !board.meshes.board);
assert.equal(board.setScenePack(stage.id, null), true);
assert.equal(board.official, null);
assert.ok(board.meshes.board && board.meshes.bg && !sceneMeshes(board).length);
assert.equal(board.setScenePack(stage.id, null), false, 'nothing to drop');
assert.equal(board.setScenePack('', fakeScenePack()), false);
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' }]);
const chunks = parseSceneChunks(doc, bin);
board.setScenePack(stage.id, { key: 'k2', stage: stage.id, doc, chunks, groups: sceneGroupRects(chunks), images: { tex: {}, lm: [] } });
board.setStage(stage);
assert.equal(board.official, null);
assert.ok(board.meshes.board && board.meshes.bg);
board.destroy();
});
});
describe('extracted scenes (when scene bundles were extracted here)', { skip: !Object.keys(manifest?.groups || {}).some((g) => g.startsWith('scene/act')) && 'no scene in data/local-assets.json' }, () => {
const groups = manifest?.groups || {};
const file = (p) => path.join(ROOT, 'public', p.replace(/^\//, ''));
for (const g of Object.keys(groups).filter((k) => k.startsWith('scene/act'))) {
test(`${g}: scene.json records fit scene.bin; lightmap pages and textures are listed and on disk`, { skip: !existsSync(file(groups[g].scene?.path || '/x')) && 'files not on this machine' }, () => {
const doc = JSON.parse(readFileSync(file(groups[g].scene.path), 'utf8'));
const bin = readFileSync(file(groups[g].bin.path));
assert.equal(doc.version, SCENE_VERSION);
assert.equal(`scene/${doc.stage}`, g);
assert.equal(doc.binBytes, bin.length);
const chunks = parseSceneChunks(doc, bin.buffer.slice(bin.byteOffset, bin.byteOffset + bin.length));
assert.ok(chunks && chunks.length === doc.chunks.length && chunks.length > 0);
assert.ok(chunks.some((c) => !c.group), 'a base part');
for (const c of chunks) {
let max = 0;
for (const i of c.index) if (i > max) max = i;
assert.ok(max < c.position.length / 3, 'indices in range');
assert.ok(doc.materials[c.material], `material ${c.material}`);
if (c.lightmap >= 0) assert.ok(c.lightmap < doc.lightmaps && groups[g][`lm${c.lightmap}`], `lightmap page ${c.lightmap}`);
}
for (const m of Object.values(doc.materials)) if (m.map) assert.ok(groups[SCENE_TEX_GROUP]?.[m.map], `texture ${m.map}`);
const L = doc.light;
assert.ok(L && Math.abs(Math.hypot(...L.dir) - 1) < 1e-3 && L.dir[2] > 0, 'the key light shines down on the board');
});
}
});
+135
View File
@@ -0,0 +1,135 @@
// Official battlefield scenes in the 0.2.5 asset pack (PR #533, GitHub #532).
// The repository never stores the bundles or the extracted files. The full release zip includes
// public/assets/local/scene when the maintainer extracted it; a lite zip and a source install do not,
// and setup may fetch + extract on that machine. Missing extractor dependencies keep the metal board.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { artPlan, formatSummary, isPlayerFile, isRefused } from '../tools/package.mjs';
import { localGaps, sceneInstallPlan } from '../tools/setup.mjs';
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
test('sceneInstallPlan: fetch only with a client and extractor deps; otherwise the metal board', () => {
const present = sceneInstallPlan({ hasScenes: true, client: false });
assert.equal(present.run, false);
assert.equal(present.state, 'ok');
assert.match(present.detail, /金属地块/);
const check = sceneInstallPlan({ hasScenes: false, check: true, client: true, python: true });
assert.equal(check.run, false);
assert.equal(check.state, 'skip');
assert.match(check.detail, /--scenes/);
assert.match(check.detail, /金属地块/);
const noClient = sceneInstallPlan({ hasScenes: false, client: false, python: true });
assert.equal(noClient.run, false);
assert.equal(noClient.state, 'skip');
assert.match(noClient.detail, /完整包/);
assert.match(noClient.detail, /金属地块/);
const incomplete = sceneInstallPlan({ hasScenes: false, client: 'incomplete', python: true, force: true });
assert.equal(incomplete.run, false);
assert.equal(incomplete.state, 'warn');
assert.match(incomplete.detail, /金属地块/);
const noPy = sceneInstallPlan({ hasScenes: false, client: true, python: false });
assert.equal(noPy.run, false);
assert.equal(noPy.state, 'warn');
assert.match(noPy.detail, /Python/);
assert.match(noPy.detail, /金属地块/);
const noDeps = sceneInstallPlan({ hasScenes: false, client: true, python: true, deps: false });
assert.equal(noDeps.run, false);
assert.equal(noDeps.state, 'warn');
assert.match(noDeps.detail, /UnityPy/);
assert.match(noDeps.detail, /金属地块/);
const declined = sceneInstallPlan({ hasScenes: false, client: true, python: true, declined: true });
assert.equal(declined.run, false);
assert.equal(declined.state, 'skip');
const ask = sceneInstallPlan({ hasScenes: false, client: true, python: true });
assert.equal(ask.run, false);
assert.equal(ask.state, 'ask');
assert.match(ask.detail, /金属地块/);
for (const extra of [{ force: true, declined: true }, { yes: true }]) {
const go = sceneInstallPlan({ hasScenes: false, client: true, python: true, ...extra });
assert.equal(go.run, true, JSON.stringify(extra));
assert.match(go.detail, /金属地块/);
}
});
test('an older extraction is told it lacks the battlefield scenes', () => {
assert.equal(localGaps({ enemySpines: true, tokenSpines: true }), '');
assert.match(localGaps({ enemySpines: true, tokenSpines: true, scenes: false }), /各战场官方场景/);
assert.match(localGaps({ enemySpines: false, tokenSpines: false, scenes: false }), /各战场官方场景/);
});
test('official scene bundles and extracted scenes are not git files; the extractor scripts are', () => {
const gi = fs.readFileSync(path.join(ROOT, '.gitignore'), 'utf8');
assert.match(gi, /^public\/assets\/$/m);
assert.match(gi, /^data\/local-assets\.json$/m);
assert.match(gi, /^\.cache\/$/m);
assert.match(gi, /official-scenes/);
assert.equal(isRefused('.cache/official-scenes/26-04-13-12-31-42_e9b560/scenes/a.ab'), true);
assert.equal(isRefused('public/assets/local/scene/act2autochess_m02/scene.bin'), false);
assert.equal(isPlayerFile('public/assets/local/scene/act2autochess_m02/scene.bin'), false);
assert.equal(isPlayerFile('tools/local-extract/scenes.py'), true);
assert.equal(isPlayerFile('tools/local-extract/fetch_scenes.py'), true);
});
test('the full asset plan ships extracted scenes; the lite plan ships none', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-scenes-'));
try {
const sceneDir = path.join(dir, 'public/assets/local/scene/act2autochess_m02');
const texDir = path.join(dir, 'public/assets/local/scene/tex');
fs.mkdirSync(sceneDir, { recursive: true });
fs.mkdirSync(texDir, { recursive: true });
fs.mkdirSync(path.join(dir, 'data'), { recursive: true });
fs.writeFileSync(path.join(dir, 'data/assets.json'), '{}\n');
fs.writeFileSync(path.join(sceneDir, 'scene.bin'), 'mesh-bytes');
fs.writeFileSync(path.join(sceneDir, 'scene.json'), '{}\n');
fs.writeFileSync(path.join(texDir, 'TX_A.webp'), 'webp');
fs.writeFileSync(path.join(dir, 'data/local-assets.json'), JSON.stringify({
version: 1,
groups: {
'scene/act2autochess_m02': {
scene: { path: '/assets/local/scene/act2autochess_m02/scene.json' },
bin: { path: '/assets/local/scene/act2autochess_m02/scene.bin' },
},
'scene/tex': { TX_A: { path: '/assets/local/scene/tex/TX_A.webp' } },
},
}));
const full = artPlan(dir, { lite: false });
for (const f of [
'public/assets/local/scene/act2autochess_m02/scene.bin',
'public/assets/local/scene/act2autochess_m02/scene.json',
'public/assets/local/scene/tex/TX_A.webp',
'data/local-assets.json',
]) assert.ok(full.files.includes(f), f);
assert.ok(full.scenes.includes('public/assets/local/scene/act2autochess_m02/scene.bin'));
assert.equal(full.scenes.length, 3);
const lite = artPlan(dir, { lite: true });
assert.deepEqual(lite.files, []);
assert.deepEqual(lite.scenes, []);
const summary = formatSummary({
lite: false, version: '0.2.5-dev', tracked: [], art: [], localArt: true, generated: [], dropped: {}, orphans: [], held: [],
scanned: 0, names: 0, problems: [], scenes: { files: full.scenes, bytes: 9 + 3 + 4 },
bytes: { tracked: 0, art: 16, modules: 0, vendor: 0, total: 16, leftOut: 0, orphans: 0 },
});
assert.match(summary, /battlefield scenes: 3 files/);
const empty = formatSummary({
lite: false, version: '0.2.5-dev', tracked: [], art: [], localArt: true, generated: [], dropped: {}, orphans: [], held: [],
scanned: 0, names: 0, problems: [], scenes: { files: [], bytes: 0 },
bytes: { tracked: 0, art: 0, modules: 0, vendor: 0, total: 0, leftOut: 0, orphans: 0 },
});
assert.match(empty, /battlefield scenes: none/);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});