Files
ark-stronghold-protocol/public/js/render/board3d/load.js
T
sganggs 6bfc08270c 0.2.4-dev: round-29 community fixes and the official shared card pool
- 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.
2026-10-11 01:58:52 +08:00

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; }