- Shared card pool and item stock as in the official mode (the owner's request, from the 路标档案馆 video and public notes): solo low-tier exceptions, co-op operators x2, shared item stock with special-item exceptions, normal / advanced occupancy 1 / 2, re-check on buy, 机变 exhaustion, release on consume / destroy / elimination; 整备 never exceeds stock; invariant held <= cap. - 突袭: one taunt-first reselect right after a revive (the owner's rule); ordinary polls and redeploys keep the 0.2.3 rule; a revive on an enemy's tile stays. #475: online boss / hidden-boss Raid may land on the empty half (owner-limited scope). - 浊心斯卡蒂 S1 x 坚守 no longer bounce redirected damage, and the two shares conserve the hit (idea of PR #464 by @HappyTanuki). - Operator fixes adapted from PRs by @Xinglan233 (#459 哈洛德 S2 elemental trigger, #460 涤火杰西卡 shield on the four adjacent tiles, #461 家族徽章 first hit after stealth, #462 松果 S2 regrow per deploy); 克莱门莎 S2 splash 1.1 / 100% ATK and air auto-trigger (#467); 白面 copying 歌蕾蒂娅 / 余 skips the three-member condition (#473, the owner's video). - 香槟炸弹 is not chosen by friendly auras / heals (PR #468 / #476 by @Siornya); 琳琅诗怀雅 coin balance (PR #450 by @IceCodeNew); Touch prep display (PR #451 by @YinqiMargatroid); lobby header (PR #471 by @YuriRestia); Japanese UI terms (PR #448 by @scutkane). - AI-only boss countdown grace, render-failure cache / recovery without restarting a battle, device units kept on a same-field re-entry, DIY detail / skill previews, large-text result buttons. - golden: battle families equal 0.2.3 except diy-135 / diy-136 (克莱门莎); all 18 release matches move through the shared pool, every flip named in docs/history/0.2.4.md §29.12; two coverage scenarios added.
163 lines
7.7 KiB
JavaScript
163 lines
7.7 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? } }
|
|
//
|
|
// 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;
|
|
}
|
|
|
|
/** Forget the cached pack and the three.js import attempt (tests / hot reload). */
|
|
export function resetBoardPack() { cached = null; threePromise = null; threeFailures = 0; }
|