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:
1 parent
7fd0478dc0
commit
d482fbb8ba
19 files changed
+1659
-22
No files matched your search
+11
-3
@@ -125,7 +125,7 @@ import { backdropTextures, shadowTexture, refreshTierChips, refreshDownLabels, s
|
||||
import { TILE_H, TIER_COLORS, COLORS } from './style.js';
|
||||
import { loadBoardArt } from './boardArt.js';
|
||||
import { ImpostorAtlas } from './impostor.js';
|
||||
import { loadThree, loadBoardPack, webgl2Available, boardArtListed } from './board3d/load.js';
|
||||
import { loadThree, loadBoardPack, loadScenePack, webgl2Available, boardArtListed } from './board3d/load.js';
|
||||
import { BoardScene } from './board3d/scene.js';
|
||||
import { unionAreas } from './board3d/layout.js';
|
||||
import { layoutPen, penSignature } from './pen.js';
|
||||
@@ -361,6 +361,14 @@ export async function createFieldView(host, options = {}) {
|
||||
// lost-context recovery: the THREE module + art pack of the last 3D board, the pending retry, failures so far
|
||||
const recover = { THREE: null, pack: null, timer: 0, tries: 0, since: 0, off: false, count: 0 };
|
||||
setupCleanup.push(() => { clearTimeout(recover.timer); disable3d(); });
|
||||
/** The stage's official scene, once loaded (the procedural board shows meanwhile and when there is none). */
|
||||
function useOfficialScene(st) {
|
||||
const id = st && typeof st.id === 'string' ? st.id : '';
|
||||
if (!id || !board3d) return;
|
||||
loadScenePack(assets, id).then((scene) => {
|
||||
if (scene && !destroyed && board3d && stageRec && stageRec.id === id) board3d.setScenePack(id, scene);
|
||||
}).catch(() => {});
|
||||
}
|
||||
function enable3d(THREE, pack) {
|
||||
if (destroyed || board3d || !THREE || !pack) return false;
|
||||
recover.THREE = THREE; recover.pack = pack;
|
||||
@@ -380,7 +388,7 @@ export async function createFieldView(host, options = {}) {
|
||||
tiles.setExternal(true);
|
||||
backdrop.visible = false;
|
||||
b.setArea(boardArea(viewKind(camKind, camOpts)));
|
||||
if (stageRec) b.setStage(stageRec);
|
||||
if (stageRec) { b.setStage(stageRec); useOfficialScene(stageRec); }
|
||||
b.setFocus(camRect());
|
||||
b.setBattleRect(mode === 'battle' && battleMeta ? battleMeta.rect : null);
|
||||
tiles.project(cam, true);
|
||||
@@ -630,7 +638,7 @@ export async function createFieldView(host, options = {}) {
|
||||
stageRec = st;
|
||||
tiles.setStage(st);
|
||||
tiles.setBattleRect(mode === 'battle' && battleMeta ? battleMeta.rect : null);
|
||||
if (board3d) { board3d.setStage(st); board3d.setBattleRect(mode === 'battle' && battleMeta ? battleMeta.rect : null); }
|
||||
if (board3d) { board3d.setStage(st); board3d.setBattleRect(mode === 'battle' && battleMeta ? battleMeta.rect : null); useOfficialScene(st); }
|
||||
tiles.project(cam, true);
|
||||
// a pen laid out before the stage arrived (setPrep / a scouting board first) used the default zones and the old
|
||||
// tile heights: lay it out again on this stage
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
// → { key, images: { D, N?, R?, E?, common?, commonE?, BG?, wind?, gate?, waterN?, caustics?, noise? },
|
||||
// meshes: { crate?, blower?, bgPlane?, gate: { startDown?, startUp?, startBack?, endDown?, endUp? } },
|
||||
// tiles, uv, materials: { theme?, fx? } }
|
||||
// const scene = await loadScenePack(assets, stageId) // the stage's official scene (tools/local-extract/scenes.py),
|
||||
// → { key, stage, doc, chunks: [{ material, lightmap, group, bounds, position, uv, uv1, normal, color, index }],
|
||||
// groups: { name: [x0, y0, x1, y1] }, images: { tex: { name: img }, lm: [img|null] } } null when not extracted
|
||||
//
|
||||
// Everything is optional except the diffuse atlas: a missing map or mesh only drops that feature (e.g. no normal
|
||||
// map → flat shading of the atlas; no crate mesh → a procedural box). URLs come from data/local-assets.json only
|
||||
@@ -158,5 +161,102 @@ export function loadBoardPack(assets) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** Manifest group of a stage's official scene, and of the textures the scenes share. */
|
||||
export const sceneGroup = (stageId) => `scene/${stageId}`;
|
||||
export const SCENE_TEX_GROUP = 'scene/tex';
|
||||
export const SCENE_VERSION = 1;
|
||||
/** Bytes per vertex of a scene chunk: position f32×3, uv f32×2, lightmap uv f32×2, normal i8×4, colour u8×4. */
|
||||
export const SCENE_VERTEX_BYTES = 36;
|
||||
|
||||
/**
|
||||
* Typed-array views of the chunks of a scene.bin (scenes.py pack_chunk); null when a record does not fit the buffer.
|
||||
* @param {object} doc scene.json
|
||||
* @param {ArrayBuffer} buf scene.bin
|
||||
*/
|
||||
export function parseSceneChunks(doc, buf) {
|
||||
if (!isObj(doc) || !Array.isArray(doc.chunks) || !(buf instanceof ArrayBuffer)) return null;
|
||||
const out = [];
|
||||
for (const c of doc.chunks) {
|
||||
const n = c?.verts | 0, m = (c?.tris | 0) * 3, o = c?.offset | 0;
|
||||
const end = o + n * SCENE_VERTEX_BYTES + m * (c.index32 ? 4 : 2);
|
||||
if (!(n > 0) || !(m > 0) || o < 0 || o % 4 || end > buf.byteLength || typeof c.material !== 'string') return null;
|
||||
let p = o;
|
||||
const position = new Float32Array(buf, p, n * 3); p += n * 12;
|
||||
const uv = new Float32Array(buf, p, n * 2); p += n * 8;
|
||||
const uv1 = new Float32Array(buf, p, n * 2); p += n * 8;
|
||||
const normal = new Int8Array(buf, p, n * 4); p += n * 4;
|
||||
const color = new Uint8Array(buf, p, n * 4); p += n * 4;
|
||||
const index = c.index32 ? new Uint32Array(buf, p, m) : new Uint16Array(buf, p, m);
|
||||
out.push({
|
||||
material: c.material, lightmap: Number.isInteger(c.lightmap) ? c.lightmap : -1, group: typeof c.group === 'string' ? c.group : '',
|
||||
bounds: Array.isArray(c.bounds) ? c.bounds : null, position, uv, uv1, normal, color, index,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Ground rect [x0, y0, x1, y1] (board space) of every named group of chunks (the scene's switchable parts). */
|
||||
export function sceneGroupRects(chunks) {
|
||||
const out = {};
|
||||
for (const c of chunks || []) {
|
||||
if (!c.group || !c.bounds) continue;
|
||||
const b = c.bounds, r = out[c.group];
|
||||
out[c.group] = r ? [Math.min(r[0], b[0]), Math.min(r[1], b[1]), Math.max(r[2], b[3]), Math.max(r[3], b[4])] : [b[0], b[1], b[3], b[4]];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchBuffer(url) {
|
||||
try { const r = await fetch(url, { cache: 'no-cache' }); return r.ok ? await r.arrayBuffer() : null; } catch { return null; }
|
||||
}
|
||||
|
||||
const sceneCache = new Map();
|
||||
|
||||
/** Does the local-art manifest list the official scene of `stageId`? (manifest only) */
|
||||
export async function sceneListed(assets, stageId) {
|
||||
if (!stageId || !assets || typeof assets.local !== 'function' || typeof assets.localUrl !== 'function') return false;
|
||||
try { await assets.local(); } catch { return false; }
|
||||
return !!assets.localUrl(sceneGroup(stageId), 'scene') && !!assets.localUrl(sceneGroup(stageId), 'bin');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a stage's official scene (scene.json + scene.bin + lightmap pages + albedo textures). A missing texture or
|
||||
* lightmap page only drops that map; a missing or malformed scene gives null (→ the procedural board).
|
||||
* @returns {Promise<object|null>}
|
||||
*/
|
||||
export function loadScenePack(assets, stageId) {
|
||||
if (typeof stageId !== 'string' || !stageId) return Promise.resolve(null);
|
||||
if (sceneCache.has(stageId)) return sceneCache.get(stageId);
|
||||
const pending = (async () => {
|
||||
if (!(await sceneListed(assets, stageId)) || typeof assets.image !== 'function') return null;
|
||||
const url = (g, n) => { const u = assets.localUrl(g, n); return typeof u === 'string' && u ? encodeURI(u) : null; };
|
||||
const group = sceneGroup(stageId);
|
||||
const [doc, buf] = await Promise.all([fetchJson(url(group, 'scene')), fetchBuffer(url(group, 'bin'))]);
|
||||
if (!isObj(doc) || doc.version !== SCENE_VERSION || !buf) return null;
|
||||
const chunks = parseSceneChunks(doc, buf);
|
||||
if (!chunks || !chunks.length) return null;
|
||||
const img = async (u) => {
|
||||
if (!u) return null;
|
||||
const i = await assets.image(u, { retry: true }).catch(() => null);
|
||||
return i && (i.width || i.naturalWidth) > 0 ? i : null;
|
||||
};
|
||||
const mats = isObj(doc.materials) ? doc.materials : {};
|
||||
const names = [...new Set(Object.values(mats).map((m) => (isObj(m) && typeof m.map === 'string' ? m.map : null)).filter(Boolean))];
|
||||
const pages = Math.max(0, Math.min(16, doc.lightmaps | 0));
|
||||
const [texList, lm] = await Promise.all([
|
||||
Promise.all(names.map((n) => img(url(SCENE_TEX_GROUP, n)))),
|
||||
Promise.all(Array.from({ length: pages }, (_, i) => img(url(group, `lm${i}`)))),
|
||||
]);
|
||||
const tex = {};
|
||||
names.forEach((n, i) => { if (texList[i]) tex[n] = texList[i]; });
|
||||
return { key: `${url(group, 'scene')}#${doc.binBytes | 0}`, stage: stageId, doc, chunks, groups: sceneGroupRects(chunks), images: { tex, lm } };
|
||||
})().catch((err) => { console.warn('[board3d] scene load failed', err); return null; }).then((pack) => {
|
||||
if (!pack && sceneCache.get(stageId) === pending) sceneCache.delete(stageId);
|
||||
return pack;
|
||||
});
|
||||
sceneCache.set(stageId, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** Forget the cached pack and the three.js import attempt (tests / hot reload). */
|
||||
export function resetBoardPack() { cached = null; threePromise = null; threeFailures = 0; }
|
||||
export function resetBoardPack() { cached = null; threePromise = null; threeFailures = 0; sceneCache.clear(); }
|
||||
@@ -8,6 +8,7 @@
|
||||
// MT_autochessi_BG → unlit TX_autochessi_BG × 0.713 (Torappu/Unlit/Texture, _Color)
|
||||
// MT_wind_device → unlit TX_wind_device (the blower mesh)
|
||||
// [opt]start_end_add / _ab → the gate / objective boxes: Torappu/Particles/Additive (2 · tint · tex) / AlphaBlend
|
||||
// the materials of an official battlefield scene (load.js loadScenePack) → sceneMaterial / sceneWaterMaterial
|
||||
// Every lit material gets the "focus" spotlight: fragments outside the camera's field rect fade towards `dim`
|
||||
// (a soft world-space falloff), so the active field reads like the original's lit board without re-building
|
||||
// geometry when the camera moves.
|
||||
@@ -343,6 +344,132 @@ export function smogMaterial(THREE, tex, focus) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tuning of the official scene materials (sceneMaterial). The official shader (Torappu/Scene/StandardRealtimeShadow) is
|
||||
* not reproduced: the albedo is lit by the baked indirect light of the scene's lightmap plus the scene's key light
|
||||
* masked by its baked shadow, in linear light (Unity's Shadowmask lighting mode). `range` — the linear light a lightmap
|
||||
* texel of 1.0 stands for when scene.json has no lightRange; [ASSUMED] `ambient` — the light of a mesh without a
|
||||
* lightmap page; `direct` / `indirect` — gains of the two terms (1 = the scene's own values).
|
||||
*/
|
||||
export const SCENE_LIGHT = Object.freeze({ range: 8.0, ambient: 0.2, direct: 0.8, indirect: 0.4 });
|
||||
|
||||
const colorOf = (info, key, fallback = [1, 1, 1, 1]) => {
|
||||
const c = info && info.colors && Array.isArray(info.colors[key]) ? info.colors[key] : fallback;
|
||||
return [0, 1, 2, 3].map((i) => (Number.isFinite(c[i]) ? c[i] : fallback[i]));
|
||||
};
|
||||
const floatOf = (info, key, fallback) => (info && info.floats && Number.isFinite(info.floats[key]) ? info.floats[key] : fallback);
|
||||
const hasKeyword = (info, kw) => !!(info && Array.isArray(info.keywords) && info.keywords.includes(kw));
|
||||
|
||||
/** Blend class of a scene material record (scene.json materials[name]): 'opaque' | 'cutout' | 'blend'. */
|
||||
export function sceneBlend(info) {
|
||||
if (hasKeyword(info, '_ALPHABLEND_ON') || hasKeyword(info, '_ALPHAPREMULTIPLY_ON')) return 'blend';
|
||||
return hasKeyword(info, '_ALPHATEST_ON') ? 'cutout' : 'opaque';
|
||||
}
|
||||
|
||||
/**
|
||||
* A lit material of an official scene: albedo (`map`, × _Color) × (the lightmap's baked light + key light · N·L ·
|
||||
* baked shadow (lightmap A, by _ShadowStrength)), alpha test / blend by the material's keywords, the focus falloff of
|
||||
* the other board materials. `map` is an sRGB texture; `lightMap` holds (light / lightRange)^(1/2.2) in RGB (no colour
|
||||
* space). Geometry attributes: position, normal, uv, aLm (lightmap uv), aColor.
|
||||
* @param {{ map?: any, lightMap?: any, info?: object, lightRange?: number, light?: { dir: number[], color: number[], intensity: number } }} o
|
||||
*/
|
||||
export function sceneMaterial(THREE, o, focus) {
|
||||
const info = o.info || null;
|
||||
const blend = sceneBlend(info);
|
||||
const L = o.light || { dir: [0.36, 0.45, 0.82], color: [1, 1, 1], intensity: 1 };
|
||||
const k = (Number.isFinite(L.intensity) ? L.intensity : 1) * SCENE_LIGHT.direct;
|
||||
const lin = (v) => Math.pow(Math.max(0, v), 2.2); // the scene's colours are authored in gamma space
|
||||
const col = colorOf(info, '_Color');
|
||||
const uniforms = {
|
||||
uMap: { value: o.map || null }, uLm: { value: o.lightMap || null },
|
||||
uColor: { value: new THREE.Vector4(lin(col[0]), lin(col[1]), lin(col[2]), col[3]) },
|
||||
uCutoff: { value: blend === 'cutout' ? floatOf(info, '_Cutoff', 0.5) : 0 },
|
||||
uLmScale: { value: o.lightMap ? (o.lightRange > 0 ? o.lightRange : SCENE_LIGHT.range) * SCENE_LIGHT.indirect : 0 },
|
||||
uAmbient: { value: o.lightMap ? 0 : SCENE_LIGHT.ambient },
|
||||
uShadow: { value: floatOf(info, '_ShadowStrength', 1) },
|
||||
uLightDir: { value: new THREE.Vector3(...L.dir).normalize() },
|
||||
uLightColor: { value: new THREE.Vector3(lin(L.color[0]) * k, lin(L.color[1]) * k, lin(L.color[2]) * k) },
|
||||
...focus,
|
||||
};
|
||||
const m = new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
defines: { ...(o.map ? { SCENE_MAP: 1 } : {}), ...(o.lightMap ? { SCENE_LM: 1 } : {}) },
|
||||
vertexShader: [
|
||||
'attribute vec2 aLm;',
|
||||
'varying vec2 vUv; varying vec2 vLm; varying vec3 vN; varying vec3 vWorld;',
|
||||
'void main() {',
|
||||
' vUv = uv; vLm = aLm;',
|
||||
' vN = normalize(mat3(modelMatrix) * normal);',
|
||||
' vec4 w = modelMatrix * vec4(position, 1.0);',
|
||||
' vWorld = w.xyz;',
|
||||
' gl_Position = projectionMatrix * viewMatrix * w;',
|
||||
'}',
|
||||
].join('\n'),
|
||||
fragmentShader: [
|
||||
'uniform sampler2D uMap; uniform sampler2D uLm; uniform vec4 uColor; uniform float uCutoff;',
|
||||
'uniform float uLmScale; uniform float uAmbient; uniform float uShadow; uniform vec3 uLightDir; uniform vec3 uLightColor;',
|
||||
FOCUS_GLSL,
|
||||
'varying vec2 vUv; varying vec2 vLm; varying vec3 vN; varying vec3 vWorld;',
|
||||
'void main() {',
|
||||
' vec4 a = uColor;',
|
||||
' #ifdef SCENE_MAP',
|
||||
' a *= texture2D(uMap, vUv);',
|
||||
' #endif',
|
||||
' if (a.a < uCutoff) discard;',
|
||||
' vec3 light = vec3(uAmbient); float shade = 1.0;',
|
||||
' #ifdef SCENE_LM',
|
||||
' vec4 lm = texture2D(uLm, vLm);',
|
||||
' light += pow(lm.rgb, vec3(2.2)) * uLmScale; shade = mix(1.0, lm.a, uShadow);',
|
||||
' #endif',
|
||||
' vec3 n = normalize(gl_FrontFacing ? vN : -vN);',
|
||||
' light += uLightColor * max(dot(n, uLightDir), 0.0) * shade;',
|
||||
' gl_FragColor = vec4(a.rgb * light * focusMask(vWorld.xy), a.a);',
|
||||
' #include <colorspace_fragment>',
|
||||
'}',
|
||||
].join('\n'),
|
||||
transparent: blend === 'blend', depthWrite: blend !== 'blend',
|
||||
});
|
||||
m.userData.blend = blend;
|
||||
return m;
|
||||
}
|
||||
|
||||
/** A water surface of an official scene (Torappu/Scene/StylizedWater): the board's water shader in the material's colours. */
|
||||
export function sceneWaterMaterial(THREE, tex, focus, info) {
|
||||
const deep = colorOf(info, '_DeepWaterColor', [0.03, 0.2, 0.27, 0.7]), shallow = colorOf(info, '_ShallowWaterColor', [0.09, 0.46, 0.55, 0.5]);
|
||||
const uniforms = {
|
||||
uTime: { value: 0 }, uNormal: { value: tex.waterN || null }, uCaustics: { value: tex.caustics || null },
|
||||
uDeep: { value: new THREE.Vector4(...deep) }, uShallow: { value: new THREE.Vector4(...shallow) }, ...focus,
|
||||
};
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
defines: tex.waterN && tex.caustics ? { WATER_MAPS: 1 } : {},
|
||||
vertexShader: TERRAIN_VERT,
|
||||
fragmentShader: [
|
||||
'uniform float uTime; uniform sampler2D uNormal; uniform sampler2D uCaustics; uniform vec4 uDeep; uniform vec4 uShallow;',
|
||||
FOCUS_GLSL,
|
||||
'varying vec2 vUv; varying vec3 vWorld;',
|
||||
'void main() {',
|
||||
' float diff = 0.5, spec = 0.0, ca = 0.0;',
|
||||
' #ifdef WATER_MAPS',
|
||||
' vec2 p = vWorld.xy * 0.45;',
|
||||
' vec3 n1 = texture2D(uNormal, p + vec2(uTime * 0.03, uTime * 0.021)).xyz * 2.0 - 1.0;',
|
||||
' vec3 n2 = texture2D(uNormal, p * 1.7 - vec2(uTime * 0.025, -uTime * 0.017)).xyz * 2.0 - 1.0;',
|
||||
' vec3 n = normalize(vec3(n1.xy + n2.xy, 2.2));',
|
||||
' vec3 L = normalize(vec3(-0.45, -0.3, 0.84));',
|
||||
' diff = clamp(dot(n, L), 0.0, 1.0);',
|
||||
' spec = pow(clamp(dot(reflect(-L, n), normalize(vec3(0.0, -0.5, 0.866))), 0.0, 1.0), 40.0);',
|
||||
' ca = texture2D(uCaustics, vWorld.xy * 0.6 + n.xy * 0.08 + vec2(uTime * 0.02, 0.0)).r;',
|
||||
' #endif',
|
||||
' vec4 w = mix(uDeep, uShallow, 0.08 + 0.2 * diff);',
|
||||
' vec3 c = pow(w.rgb, vec3(2.2)) + ca * vec3(0.03, 0.05, 0.05) + spec * vec3(0.5, 0.6, 0.65);',
|
||||
' gl_FragColor = vec4(c * focusMask(vWorld.xy), clamp(w.a + 0.2, 0.0, 1.0));',
|
||||
' #include <colorspace_fragment>',
|
||||
'}',
|
||||
].join('\n'),
|
||||
transparent: true, depthWrite: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft studio environment for image-based light (PMREM of a canvas equirect: bright sky with light panels above the
|
||||
* horizon, dark void below): gives the glass hatches, steel and gold frames their sheen and fills the shadows.
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
//
|
||||
// const board = new BoardScene(THREE, pack, { canvas, quality }) (pack = load.js loadBoardPack)
|
||||
// board.setStage(stage) geometry from the stage grid (layout.js) + devices + gates + terrain
|
||||
// board.setScenePack(id, pack) the official scene of stage `id` (load.js loadScenePack): drawn instead of the
|
||||
// procedural tiles and background while that stage is set (null → procedural)
|
||||
// board.setArea(rects) the built areas (layout.js AREAS / areaFor: normal field + pen, 联防, boss)
|
||||
// board.setFocus(rect) the lit field (others fade, animated)
|
||||
// board.setBattleRect(rect|null) crates / turrets inside a running battle are sim units: static ones hide
|
||||
@@ -23,7 +25,7 @@ import { buildBoard, objToBoard, boxProjectUV, ROWS, COLS, DEVICE_H, AREAS } fro
|
||||
import { surfaceUV } from './atlas.js';
|
||||
import {
|
||||
focusUniforms, makeTexture, boardMaterial, glassMaterial, decalMaterial, pipeMaterial, unlitMaterial, gateMaterial, glowMaterial,
|
||||
waterMaterial, mireMaterial, infectionMaterial, smogMaterial, dashTexture, environmentMap,
|
||||
waterMaterial, mireMaterial, infectionMaterial, smogMaterial, dashTexture, environmentMap, sceneMaterial, sceneWaterMaterial,
|
||||
} from './materials.js';
|
||||
import { syncThreeCamera } from '../projection.js';
|
||||
|
||||
@@ -53,6 +55,17 @@ export function gatePulse(t, phase = 0) {
|
||||
return 0.134 + (0.229 - 0.134) * k;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is a named part of an official scene (its ground rect [x0, y0, x1, y1]) shown with the built `areas`? The official
|
||||
* scene keeps each field as a sub-graphic it switches on (HIDDEN@n: the left / right normal field, the boss field);
|
||||
* a part is shown when its centre lies in one of the areas.
|
||||
*/
|
||||
export function sceneGroupVisible(rect, areas) {
|
||||
if (!Array.isArray(rect) || rect.length < 4) return true;
|
||||
const cx = (rect[0] + rect[2]) / 2, cy = (rect[1] + rect[3]) / 2;
|
||||
return (areas || []).some((a) => cx >= a.c0 - 0.5 && cx <= a.c1 + 0.5 && cy >= a.r0 - 0.5 && cy <= a.r1 + 0.5);
|
||||
}
|
||||
|
||||
const areaKey = (list) => (list || []).map((a) => `${a.r0},${a.r1},${a.c0},${a.c1}`).sort().join(';');
|
||||
|
||||
const mergeInto = (list) => {
|
||||
@@ -194,6 +207,10 @@ export class BoardScene {
|
||||
this.scene.add(this.root, this.dynamic);
|
||||
this.stageKey = null;
|
||||
this.stage = null;
|
||||
this.scenePacks = new Map(); // stage id → official scene pack (setScenePack)
|
||||
this.sceneRes = new Map(); // pack key → { tex, lm, mats } GPU resources (kept across rebuilds)
|
||||
this.official = null;
|
||||
this.sceneWater = [];
|
||||
this.area = AREAS.normal;
|
||||
this.areaKey = areaKey(AREAS.normal);
|
||||
this.battleRect = null;
|
||||
@@ -267,7 +284,8 @@ export class BoardScene {
|
||||
/** Rebuild everything for a stage (no-op when the same stage object/grid is set again). */
|
||||
setStage(stage) {
|
||||
if (this.destroyed) return;
|
||||
const key = stage ? `${stage.id || ''}|${(stage.rows || []).join('/')}|${JSON.stringify((stage.devices || []).map((d) => [d.key, d.pos, d.active, d.dir]))}` : '';
|
||||
const official = stage ? this.scenePacks.get(stage.id) || null : null;
|
||||
const key = stage ? `${stage.id || ''}|${official ? official.key : ''}|${(stage.rows || []).join('/')}|${JSON.stringify((stage.devices || []).map((d) => [d.key, d.pos, d.active, d.dir]))}` : '';
|
||||
if (key === this.stageKey) return;
|
||||
this.stageKey = key;
|
||||
this.stage = stage || null;
|
||||
@@ -276,19 +294,96 @@ export class BoardScene {
|
||||
const board = buildBoard(stage, { uv: this.pack?.uv || null, area: this.area });
|
||||
this.board = board;
|
||||
const M = this.meshes = {};
|
||||
M.board = this._mesh(board.buckets.board, this.mat.board);
|
||||
M.glass = this._mesh(board.buckets.glass, this.mat.glass);
|
||||
M.decal = this._mesh(board.buckets.decal, this.mat.decal, { cast: false });
|
||||
M.pipe = this._mesh(board.buckets.pipe, this.mat.pipe);
|
||||
this.official = official && this._buildScene(official) ? official : null;
|
||||
if (!this.official) {
|
||||
M.board = this._mesh(board.buckets.board, this.mat.board);
|
||||
M.glass = this._mesh(board.buckets.glass, this.mat.glass);
|
||||
M.decal = this._mesh(board.buckets.decal, this.mat.decal, { cast: false });
|
||||
M.pipe = this._mesh(board.buckets.pipe, this.mat.pipe);
|
||||
}
|
||||
this._buildDevices(board);
|
||||
this._buildGates(board);
|
||||
this._buildEdges(board);
|
||||
this._buildTerrain(board);
|
||||
this._buildBackground(board);
|
||||
if (!this.official) this._buildBackground(board);
|
||||
this._fitShadow(board);
|
||||
this.renderer.shadowMap.needsUpdate = true;
|
||||
}
|
||||
|
||||
/** Use (or drop, with null) the official scene of stage `id`; rebuilds when that stage is the current one. */
|
||||
setScenePack(id, pack) {
|
||||
if (this.destroyed || typeof id !== 'string' || !id) return false;
|
||||
const prev = this.scenePacks.get(id) || null;
|
||||
if (prev === (pack || null)) return false;
|
||||
if (prev) this._disposeScene(prev);
|
||||
if (pack) this.scenePacks.set(id, pack); else this.scenePacks.delete(id);
|
||||
if (this.stage && this.stage.id === id) { this.stageKey = null; this.setStage(this.stage); }
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Textures and materials of a scene pack (created once per pack, kept across rebuilds). */
|
||||
_sceneResources(pack) {
|
||||
let res = this.sceneRes.get(pack.key);
|
||||
if (res) return res;
|
||||
const T = this.THREE;
|
||||
const aniso = Math.min(8, this.renderer.capabilities?.getMaxAnisotropy?.() || 1);
|
||||
const tex = {}, lm = [];
|
||||
for (const [name, img] of Object.entries(pack.images?.tex || {})) tex[name] = makeTexture(T, img, { aniso, repeat: true });
|
||||
for (const img of pack.images?.lm || []) lm.push(img ? makeTexture(T, img, { srgb: false }) : null);
|
||||
res = { tex, lm, mats: new Map() };
|
||||
this.sceneRes.set(pack.key, res);
|
||||
return res;
|
||||
}
|
||||
|
||||
_disposeScene(pack) {
|
||||
const res = this.sceneRes.get(pack.key);
|
||||
if (!res) return;
|
||||
for (const m of res.mats.values()) m.dispose();
|
||||
for (const t of [...Object.values(res.tex), ...res.lm]) t?.dispose?.();
|
||||
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). */
|
||||
_buildScene(pack) {
|
||||
const T = this.THREE;
|
||||
const res = this._sceneResources(pack);
|
||||
const infos = pack.doc?.materials || {};
|
||||
this.sceneWater = [];
|
||||
let n = 0;
|
||||
for (const c of pack.chunks || []) {
|
||||
if (c.group && !sceneGroupVisible(pack.groups?.[c.group], this.area)) continue;
|
||||
const info = infos[c.material] || null;
|
||||
const water = info?.kind === 'water';
|
||||
const mk = `${c.material}|${water ? '' : c.lightmap}`;
|
||||
let mat = res.mats.get(mk);
|
||||
if (!mat) {
|
||||
mat = water
|
||||
? sceneWaterMaterial(T, this.tex, this.focus, info)
|
||||
: sceneMaterial(T, {
|
||||
map: res.tex[info?.map] || null, lightMap: c.lightmap >= 0 ? (res.lm[c.lightmap] || null) : null, info,
|
||||
light: pack.doc?.light || null, lightRange: pack.doc?.lightRange,
|
||||
}, this.focus);
|
||||
res.mats.set(mk, mat);
|
||||
}
|
||||
if (water) this.sceneWater.push(mat);
|
||||
const g = new T.BufferGeometry();
|
||||
g.setAttribute('position', new T.BufferAttribute(c.position, 3));
|
||||
g.setAttribute('normal', new T.BufferAttribute(c.normal, 4, true));
|
||||
g.setAttribute('uv', new T.BufferAttribute(c.uv, 2));
|
||||
g.setAttribute('aLm', new T.BufferAttribute(c.uv1, 2));
|
||||
g.setAttribute('aColor', new T.BufferAttribute(c.color, 4, true));
|
||||
g.setIndex(new T.BufferAttribute(c.index, 1));
|
||||
g.computeBoundingSphere();
|
||||
const mesh = new T.Mesh(g, mat);
|
||||
mesh.castShadow = false; mesh.receiveShadow = false;
|
||||
mesh.renderOrder = water ? 1 : mat.userData.blend === 'blend' ? 2 : mat.userData.blend === 'cutout' ? -3 : -4;
|
||||
mesh.matrixAutoUpdate = false; mesh.updateMatrix();
|
||||
this.root.add(mesh);
|
||||
this.meshes[`scene${n++}`] = mesh;
|
||||
}
|
||||
return n > 0;
|
||||
}
|
||||
|
||||
_buildDevices(board) {
|
||||
const crates = [], blowers = [];
|
||||
this.staticCrates = [];
|
||||
@@ -550,6 +645,7 @@ export class BoardScene {
|
||||
for (let i = this.flashes.length - 1; i >= 0; i--) { const f = this.flashes[i]; f.t += dt; if (f.t > 1.2) this.flashes.splice(i, 1); else flash = Math.max(flash, 1 - f.t / 1.2); }
|
||||
for (const m of [this.mat.gateEndAdd, this.mat.gateEndAb]) if (m) m.uniforms.uFlash.value.setRGB(flash, flash * 0.12, flash * 0.1);
|
||||
for (const k of ['water', 'mire', 'infection', 'smog']) this.mat[k].uniforms.uTime.value = t;
|
||||
if (this.official) for (const m of this.sceneWater || []) m.uniforms.uTime.value = t;
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
this.frames++;
|
||||
if (t0) this.lastMs = this.lastMs * 0.9 + ((typeof performance !== 'undefined' ? performance.now() : t0) - t0) * 0.1;
|
||||
@@ -566,6 +662,8 @@ export class BoardScene {
|
||||
this.destroyed = true;
|
||||
for (const d of [...this.devices]) d.destroy();
|
||||
this._clear();
|
||||
for (const p of [...this.scenePacks.values()]) this._disposeScene(p);
|
||||
this.scenePacks.clear();
|
||||
this.mat.edge?.map?.dispose?.(); // canvas dash texture (not in this.tex)
|
||||
for (const m of Object.values(this.mat)) m?.dispose?.();
|
||||
for (const t of Object.values(this.tex)) t?.dispose?.();
|
||||
|
||||
Reference in new issue
Block a user