263 lines
13 KiB
JavaScript
263 lines
13 KiB
JavaScript
// render/board3d/load.js — loading of three.js and the official board art (local-client extraction, DESIGN §13/§15).
|
||
//
|
||
// const THREE = await loadThree() // '/vendor/three.module.js' (tools/vendor.mjs), null when missing
|
||
// const pack = await loadBoardPack(assets) // null when the board art is not installed (→ 2D board)
|
||
// → { 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
|
||
// (assets.localUrl) — nothing is guessed. Results are cached per page.
|
||
|
||
import { resolveUvTable } from './atlas.js';
|
||
import { parseObj } from './obj.js';
|
||
|
||
export const THREE_URL = '/vendor/three.module.js';
|
||
|
||
let threePromise = null;
|
||
let threeFailures = 0;
|
||
/** Dynamic import of the vendored three.js ESM build (browser only); null when unavailable. */
|
||
export function loadThree(url = THREE_URL) {
|
||
if (!threePromise) {
|
||
// Browsers also cache failed module imports. Two bounded retry URLs permit a transient download failure to
|
||
// recover; clearing only our promise would keep receiving that same cached rejection without another request.
|
||
if (threeFailures >= 3) return Promise.resolve(null);
|
||
const source = new URL(url, import.meta.url);
|
||
if (threeFailures) source.searchParams.set('sp-retry', String(threeFailures));
|
||
const pending = import(/* @vite-ignore */ source.href).then((m) => (m && m.WebGLRenderer ? m : null), () => null).then((m) => {
|
||
if (!m && threePromise === pending) { threePromise = null; threeFailures++; }
|
||
return m;
|
||
});
|
||
threePromise = pending;
|
||
}
|
||
return threePromise;
|
||
}
|
||
|
||
/**
|
||
* Can this browser run the 3D board? (WebGL2 — three r163+ requires it.) Software / blocklisted GPUs ("major
|
||
* performance caveat") count as unavailable unless `allowSlow` (an explicit `?board=3d`).
|
||
*/
|
||
export function webgl2Available(allowSlow = false) {
|
||
try {
|
||
if (typeof document === 'undefined') return false;
|
||
const c = document.createElement('canvas');
|
||
const gl = c.getContext('webgl2', allowSlow ? {} : { failIfMajorPerformanceCaveat: true });
|
||
if (!gl) return false;
|
||
const lose = gl.getExtension('WEBGL_lose_context');
|
||
if (lose) lose.loseContext();
|
||
return true;
|
||
} catch { return false; }
|
||
}
|
||
|
||
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
||
|
||
/** Manifest key → pack slot. */
|
||
export const PACK_IMAGES = Object.freeze({
|
||
D: ['map/autochess', 'TX_autochessi_D'],
|
||
N: ['map/autochess', 'TX_autochessi_N_rgb'],
|
||
R: ['map/autochess', 'TX_autochessi_M_rough'],
|
||
E: ['map/autochess', 'TX_autochessi_E'],
|
||
common: ['map/autochess', 'TX_autochessi_common_D'],
|
||
commonE: ['map/autochess', 'TX_autochessi_common_E'],
|
||
BG: ['map/autochess', 'TX_autochessi_BG'],
|
||
wind: ['map/common', 'TX_wind_device'],
|
||
gate: ['map/fx', '[opt]merged_textures'],
|
||
waterN: ['map/water', '[ucp]TX_water_normal'],
|
||
caustics: ['map/water', 'TX_Caustics256'],
|
||
noise: ['map/water', 'T_noise_clouds_01'],
|
||
});
|
||
|
||
export const PACK_MESHES = Object.freeze({
|
||
crate: ['mesh/s_common_box_01', 'pCube2'],
|
||
blower: ['mesh/s_wind_device', 'S_wild_wind_device'],
|
||
bgPlane: ['mesh/s_background_common', 'pPlane1'],
|
||
});
|
||
|
||
/** The official gate / objective boxes: prefab node → pack slot (meshes resolved through map/fx/prefab.json). */
|
||
export const GATE_NODES = Object.freeze({
|
||
startDown: 'Start_down', startUp: 'Start_up', startBack: 'Start_back', endDown: 'Start_down1', endUp: 'Start_up1',
|
||
});
|
||
|
||
let cached = null;
|
||
|
||
/**
|
||
* Does the local-art manifest list the board atlas? Reads the manifest only (no images): decides whether the
|
||
* ~2 MB three.js build is worth downloading at all (most hosts have no local-client art → 2D board, no three).
|
||
*/
|
||
export async function boardArtListed(assets) {
|
||
if (!assets || typeof assets.local !== 'function' || typeof assets.localUrl !== 'function') return false;
|
||
try { await assets.local(); } catch { return false; }
|
||
const u = assets.localUrl(...PACK_IMAGES.D);
|
||
return typeof u === 'string' && u.length > 0;
|
||
}
|
||
|
||
async function fetchText(url) {
|
||
try { const r = await fetch(url, { cache: 'no-cache' }); return r.ok ? await r.text() : null; } catch { return null; }
|
||
}
|
||
async function fetchJson(url) {
|
||
try { const r = await fetch(url, { cache: 'no-cache' }); return r.ok ? await r.json() : null; } catch { return null; }
|
||
}
|
||
|
||
/**
|
||
* Load the board pack through the asset store of public/js/assets.js (needs local(), localUrl(), image()).
|
||
* @returns {Promise<object|null>}
|
||
*/
|
||
export function loadBoardPack(assets) {
|
||
if (cached) return cached;
|
||
const pending = (async () => {
|
||
if (!assets || typeof assets.local !== 'function' || typeof assets.localUrl !== 'function' || typeof assets.image !== 'function') return null;
|
||
const manifest = await assets.local().catch(() => null);
|
||
if (!isObj(manifest)) return null;
|
||
const url = (g, n) => { const u = assets.localUrl(g, n); return typeof u === 'string' && u ? encodeURI(u) : null; };
|
||
const dUrl = url(...PACK_IMAGES.D);
|
||
if (!dUrl) return null;
|
||
const images = {};
|
||
await Promise.all(Object.entries(PACK_IMAGES).map(async ([k, [g, n]]) => {
|
||
const u = url(g, n);
|
||
if (!u) return;
|
||
const img = await assets.image(u, { retry: true }).catch(() => null);
|
||
if (img && (img.width || img.naturalWidth) > 0) images[k] = img;
|
||
}));
|
||
if (!images.D) return null;
|
||
const dir = dUrl.replace(/\/[^/]*$/, '');
|
||
const [tiles, theme, fxMats, fxPrefab] = await Promise.all([
|
||
fetchJson(`${dir}/tiles.json`),
|
||
(async () => { const u = url('map/autochess', 'materials'); return u ? fetchJson(u) : null; })(),
|
||
(async () => { const u = url('map/fx', 'materials'); return u ? fetchJson(u) : null; })(),
|
||
(async () => { const u = url('map/fx', 'prefab'); return u ? fetchJson(u) : null; })(),
|
||
]);
|
||
const meshes = { gate: {} };
|
||
await Promise.all(Object.entries(PACK_MESHES).map(async ([k, [g, n]]) => {
|
||
const u = url(g, n);
|
||
const text = u ? await fetchText(u) : null;
|
||
const m = text ? parseObj(text) : null;
|
||
if (m) meshes[k] = m;
|
||
}));
|
||
const prefab = Array.isArray(fxPrefab) ? fxPrefab : [];
|
||
await Promise.all(Object.entries(GATE_NODES).map(async ([slot, node]) => {
|
||
const rec = prefab.find((p) => p && p.name === node && (node !== 'Start_back' || p.parent === '[opt]start_box'));
|
||
const key = rec && typeof rec.mesh === 'string' ? rec.mesh : node;
|
||
const u = url('map/fx', key);
|
||
const text = u ? await fetchText(u) : null;
|
||
const m = text ? parseObj(text) : null;
|
||
if (m) meshes.gate[slot] = { mesh: m, material: rec && Array.isArray(rec.materials) ? rec.materials[0] || null : null };
|
||
}));
|
||
return {
|
||
key: `${dUrl}#${tiles?.version || 0}`,
|
||
images, meshes, tiles: isObj(tiles) ? tiles : null, uv: resolveUvTable(isObj(tiles) ? tiles : null),
|
||
materials: { theme: isObj(theme) ? theme : null, fx: isObj(fxMats) ? fxMats : null },
|
||
};
|
||
})().catch((err) => { console.warn('[board3d] art load failed', err); return null; }).then((pack) => {
|
||
// A timeout / failed atlas is not proof the host lacks 3D art. A later mount or visibility change may retry.
|
||
// An older attempt finishing after reset must not discard a newer cached request.
|
||
if (!pack && cached === pending) cached = null;
|
||
return pack;
|
||
});
|
||
cached = pending;
|
||
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; sceneCache.clear(); }
|