卫戍协议:盟约 web remake — initial import
Online co-op browser remake of Arknights' Stronghold Protocol: Alliance (client-simulated combat, official waves/bonds/items/strategies, 3D board, facing wheel, operator loadout, official emotes, deployment scripts).
This commit is contained in:
commit
01a44cd288
408 files changed
+308986
No files matched your search
@@ -0,0 +1,136 @@
|
||||
// render/board3d/atlas.js — which pixels of the official board textures dress each surface of the 3D board
|
||||
// (DESIGN §15). Pure data + UV helpers (no three import): the geometry builder (layout.js) maps these rects straight
|
||||
// onto the tiles, the three.js scene samples the textures of MT_autochess (TX_autochessi_D + _N + _M + _E, which
|
||||
// share one UV layout) and MT_autochess_common (TX_autochessi_common_D/_E, device plates).
|
||||
//
|
||||
// Source of truth for the rects: this table. tools/crop-board-atlas.mjs validates every rect against the extracted
|
||||
// images and writes it into public/assets/local/map/autochess/tiles.json → `board3d`, which the renderer prefers
|
||||
// (so a re-crop needs no code change); `resolveUvTable(tiles)` merges it over these defaults.
|
||||
//
|
||||
// surface = { src: 'D' | 'common', rect: [x, y, w, h] (source px, y down), rot?: 0|90|180|270 (clockwise, of the
|
||||
// texture on the face), flipX?: bool, tint?: '#rrggbb' (vertex colour multiply), glow?: number }
|
||||
//
|
||||
// Atlas pieces (TX_autochessi_D 2048², 256 px per floor cell; picked by eye, validated by the crop tool): gold-framed
|
||||
// high-ground plates (row 0: strip L / M / R + single), concrete slabs (rows 1–2: rails, ✕ marker, hazard stripes,
|
||||
// chevrons, red no-deploy stripes), wall panels (REST AREA / EVACUATION), REINFORCEMENTS / EQUIPMENTS bench pads
|
||||
// (512² each), ribbed side panels (gold / grey), the hazard-framed glass hatch, the ▲▼ lift plate, grille slats,
|
||||
// dark machinery hatch, gold ring hatch (objective), orange crate panels.
|
||||
|
||||
export const SOURCES = Object.freeze({
|
||||
D: Object.freeze({ w: 2048, h: 2048 }),
|
||||
common: Object.freeze({ w: 1024, h: 1024 }),
|
||||
});
|
||||
|
||||
const S = (src, rect, o = {}) => Object.freeze({ src, rect: Object.freeze(rect), ...o });
|
||||
|
||||
/** Surface → atlas region (see header). Names are used by layout.js. */
|
||||
export const SURFACES = Object.freeze({
|
||||
// floors
|
||||
concrete: S('D', [256, 512, 256, 256]),
|
||||
concreteRailTL: S('D', [0, 256, 256, 256]),
|
||||
concreteRailT: S('D', [256, 256, 256, 256]),
|
||||
concreteRailL: S('D', [0, 512, 256, 256]),
|
||||
concreteStripe: S('D', [512, 256, 256, 256]),
|
||||
concreteArrow: S('D', [768, 256, 256, 256]),
|
||||
concreteRed: S('D', [768, 512, 256, 256]),
|
||||
hatch: S('D', [549, 1787, 255, 256]),
|
||||
lift: S('D', [804, 1790, 234, 234]),
|
||||
ringHatch: S('D', [1290, 1400, 190, 190]),
|
||||
slats: S('D', [1036, 480, 404, 216]),
|
||||
mech: S('D', [1088, 708, 356, 310]),
|
||||
steel: S('D', [12, 776, 196, 196]),
|
||||
// high ground (gold-framed plates; texture top = the tile's far edge)
|
||||
plateL: S('D', [0, 0, 256, 256]),
|
||||
plateM: S('D', [256, 0, 256, 256]),
|
||||
plateR: S('D', [512, 0, 256, 256]),
|
||||
plateS: S('D', [768, 0, 256, 256]),
|
||||
// bench pads
|
||||
padReinf: S('D', [0, 1024, 512, 512]),
|
||||
padEquip: S('D', [512, 1024, 512, 512]),
|
||||
// side panels (cropped to the face aspect at use: see sideRect)
|
||||
goldSide: S('D', [0, 1664, 272, 384]),
|
||||
graySide: S('D', [272, 1664, 272, 384]),
|
||||
benchRail: S('D', [0, 1538, 512, 98]),
|
||||
restPanel: S('D', [0, 768, 512, 208]),
|
||||
evacPanel: S('D', [512, 768, 256, 208]),
|
||||
pipePanel: S('D', [544, 1540, 480, 244]),
|
||||
// devices
|
||||
crateSide: S('D', [1087, 1938, 134, 108]),
|
||||
crateTop: S('D', [1222, 1938, 134, 108], { rot: 90 }),
|
||||
// common atlas plates (MT_autochess_common, alpha-tested decals)
|
||||
hazardX: S('common', [2, 2, 250, 248]),
|
||||
heal: S('common', [264, 2, 256, 250]),
|
||||
shield: S('common', [538, 2, 256, 250]),
|
||||
target: S('common', [2, 262, 250, 250]),
|
||||
blast: S('common', [538, 262, 250, 250]),
|
||||
enemyMark: S('common', [792, 258, 232, 262]),
|
||||
fast: S('common', [2, 520, 250, 250]),
|
||||
arrowUp: S('common', [508, 762, 244, 244]),
|
||||
arrowDown: S('common', [776, 762, 244, 244]),
|
||||
});
|
||||
|
||||
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
||||
|
||||
/** Validate one surface record (from tiles.json) → a clean record or null. */
|
||||
export function cleanSurface(v) {
|
||||
if (!isObj(v) || !SOURCES[v.src] || !Array.isArray(v.rect) || v.rect.length !== 4) return null;
|
||||
const [x, y, w, h] = v.rect.map(Number);
|
||||
const src = SOURCES[v.src];
|
||||
if (![x, y, w, h].every(Number.isFinite) || w < 4 || h < 4 || x < 0 || y < 0 || x + w > src.w || y + h > src.h) return null;
|
||||
const rot = [0, 90, 180, 270].includes(v.rot) ? v.rot : 0;
|
||||
const out = { src: v.src, rect: [x, y, w, h], rot, flipX: !!v.flipX };
|
||||
if (typeof v.tint === 'string' && /^#[0-9a-f]{6}$/i.test(v.tint)) out.tint = v.tint;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Surface table: defaults overridden by a tiles.json `board3d` section (invalid entries ignored). */
|
||||
export function resolveUvTable(tiles) {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(SURFACES)) out[k] = cleanSurface(v);
|
||||
const extra = isObj(tiles) && isObj(tiles.board3d) ? tiles.board3d : null;
|
||||
if (extra) for (const [k, v] of Object.entries(extra)) { const c = cleanSurface(v); if (c) out[k] = c; }
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* UV corners of a surface for a quad whose corners are given as [bottom-left, bottom-right, top-right, top-left]
|
||||
* of the face as seen from outside (for a top face: near-left, near-right, far-right, far-left). Optional
|
||||
* `sub` = [u0, v0, u1, v1] (fractions of the rect, v down) crops the rect (side panels, partial faces).
|
||||
* Returns [u,v, u,v, u,v, u,v] in three's convention (v up, textures flipped on upload). `inset` (source px)
|
||||
* keeps mip filtering inside the region.
|
||||
*/
|
||||
export function surfaceUV(surface, sub = null, inset = 2) {
|
||||
const src = SOURCES[surface.src] || SOURCES.D;
|
||||
let [x, y, w, h] = surface.rect;
|
||||
if (sub) {
|
||||
const [a, b, c, d] = sub;
|
||||
x += w * a; y += h * b; const w2 = w * (c - a), h2 = h * (d - b); w = w2; h = h2;
|
||||
}
|
||||
const ix = Math.min(inset, w / 4), iy = Math.min(inset, h / 4);
|
||||
const u0 = (x + ix) / src.w, u1 = (x + w - ix) / src.w;
|
||||
const vTop = 1 - (y + iy) / src.h, vBot = 1 - (y + h - iy) / src.h;
|
||||
// texture corners in face order BL, BR, TR, TL
|
||||
let c = [[u0, vBot], [u1, vBot], [u1, vTop], [u0, vTop]];
|
||||
if (surface.flipX) c = [c[1], c[0], c[3], c[2]];
|
||||
const turns = ((surface.rot | 0) / 90) & 3;
|
||||
// rotating the texture clockwise on the face = the face corner k samples texture corner k − turns
|
||||
for (let t = 0; t < turns; t++) c = [c[1], c[2], c[3], c[0]];
|
||||
return [c[0][0], c[0][1], c[1][0], c[1][1], c[2][0], c[2][1], c[3][0], c[3][1]];
|
||||
}
|
||||
|
||||
/** Sub-rect (fractions) of a side panel that keeps the texture undistorted on a face `width` × `height` tiles. */
|
||||
export function sideRect(surface, width, height) {
|
||||
const [, , w, h] = surface.rect;
|
||||
const faceAspect = Math.max(1e-3, height / Math.max(1e-3, width));
|
||||
const texAspect = h / w;
|
||||
if (faceAspect >= texAspect) return [0, 0, 1, 1];
|
||||
const frac = faceAspect / texAspect;
|
||||
return [0, 0, 1, frac];
|
||||
}
|
||||
|
||||
/** '#rrggbb' → [r, g, b] (0..1); null → [1, 1, 1]. */
|
||||
export function tintRgb(hex) {
|
||||
if (typeof hex !== 'string' || !/^#[0-9a-f]{6}$/i.test(hex)) return [1, 1, 1];
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
// render/board3d/layout.js — the official board's geometry, built from a stage grid the way the client builds the
|
||||
// autochess board at runtime (DESIGN §15): pure JS, no three import (tested in Node; render/board3d/scene.js turns
|
||||
// the buckets into merged BufferGeometries).
|
||||
//
|
||||
// const board = buildBoard(stage, { uv: resolveUvTable(tiles), crate?: objMesh })
|
||||
// → { grid, buckets: { board, decal, pipe }, gates: [{ r, c, kind: 'start'|'end' }], devices: [...],
|
||||
// terrain: { water, mire, smog, infection: [[r,c]…] }, edges: [...cyan field edge strips], bounds, stats }
|
||||
//
|
||||
// World space = render/projection.js: x = col, y = row, z = height (up); tile (r,c) covers [c±½]×[r±½].
|
||||
// Heights follow render/style.js TILE_H (the Pixi layers stand units on the same tops: tiles.js heightAt).
|
||||
//
|
||||
// Tiles (glyph legend DATA.md §12):
|
||||
// LOW r road → concrete slab (8 rotation/flip variants) · R → hazard-striped concrete · f / p lane & preview pen →
|
||||
// the hazard-framed glass hatch · b fence → concrete + an orange tubular railing around the fenced region ·
|
||||
// S gate → chevron concrete (+ the official red start box, scene.js) · E objective → gold ring hatch (+ blue
|
||||
// end box) · I / O → lift plate + ▼/▲ plate · m / g / d / i → concrete / grille / sunk basin / concrete with the
|
||||
// animated terrain overlay of scene.js
|
||||
// HIGH h → gold-framed plates chosen by connectivity (strip L / M / R, vertical strips, single) with gold ribbed
|
||||
// sides · # / X → dark hatched blocks ("▨", the official boss-field / arena look; X with REST AREA /
|
||||
// EVACUATION wall panels) · a / A → REINFORCEMENTS / EQUIPMENTS bench pads (own bench), concrete plates
|
||||
// (Final Assault benches)
|
||||
// Every top has a small bevel (tile seams catch the light), raised blocks have chamfered edges, vertex colours carry
|
||||
// ambient occlusion at wall feet. The enemy preview pen's hatches (enemy_place_rect) go to their own `glass` bucket:
|
||||
// the official pen reads as glossy glass panes in a hazard frame reflecting the sky (scene.js glassMaterial), not as
|
||||
// the striped hatch. The Final Assault benches (rows 0–1) are dark-rimmed plates with a 2×2 grid of light panels. The island ("drawn" tiles: content + the forbidden ring around it) stands on
|
||||
// panelled cliff faces; beyond it the official background plane shows through (scene.js).
|
||||
|
||||
import { SURFACES, surfaceUV, sideRect, tintRgb } from './atlas.js';
|
||||
import { GLYPH, TILEKEY_GLYPH, TILE_H } from '../style.js';
|
||||
import { parsePenRect, PEN_RECT } from '../pen.js';
|
||||
|
||||
export const ROWS = 19;
|
||||
export const COLS = 21;
|
||||
/** How far the island's cliff faces go down (tiles). */
|
||||
export const CLIFF = 0.75;
|
||||
/** Top bevel: inset (tiles) and drop (tiles) of low tiles and raised blocks. */
|
||||
export const BEVEL = Object.freeze({ low: [0.035, 0.018], block: [0.04, 0.035] });
|
||||
/** Deep-sea basins: floor depth below the ground. */
|
||||
export const BASIN = 0.14;
|
||||
|
||||
const CONTENT_OF = (g) => g !== '#' && g !== 'X';
|
||||
|
||||
/**
|
||||
* Areas built per phase (inclusive tile rects; configBlackBoard leftNormal ((6,0),(12,10)), rightNormal
|
||||
* ((6,11),(12,20)), leftBoss ((0,0),(6,10)), rightBoss ((0,11),(6,20)); enemy_place_rect ((14,7),(18,13)) + the
|
||||
* separator in front of it). The official prep / battle view shows only the player's own field and the preview pen
|
||||
* (the island ends under the bench row); 联防 joins both normal halves; the Final Assault builds the boss field.
|
||||
*/
|
||||
export const AREAS = Object.freeze({
|
||||
normal: Object.freeze([Object.freeze({ r0: 6, r1: 12, c0: 0, c1: 10 }), Object.freeze({ r0: 13, r1: 18, c0: 6, c1: 14 })]),
|
||||
unite: Object.freeze([Object.freeze({ r0: 6, r1: 12, c0: 0, c1: 20 }), Object.freeze({ r0: 13, r1: 18, c0: 6, c1: 14 })]),
|
||||
boss: Object.freeze([Object.freeze({ r0: 0, r1: 6, c0: 0, c1: 20 })]),
|
||||
all: Object.freeze([Object.freeze({ r0: 0, r1: 18, c0: 0, c1: 20 })]),
|
||||
});
|
||||
|
||||
/** Area set of a camera kind ('prep' | 'normal' | 'pen' | 'unite' | 'boss' | 'hidden' | 'bossPrep'). */
|
||||
export function areaFor(kind) {
|
||||
if (kind === 'unite') return AREAS.unite;
|
||||
if (kind === 'boss' || kind === 'hidden' || kind === 'bossPrep') return AREAS.boss;
|
||||
return AREAS.normal;
|
||||
}
|
||||
|
||||
/** Union of area sets (camera transitions show both). */
|
||||
export function unionAreas(...sets) {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
for (const s of sets) for (const a of s || []) { const k = `${a.r0},${a.r1},${a.c0},${a.c1}`; if (!seen.has(k)) { seen.add(k); out.push(a); } }
|
||||
return out;
|
||||
}
|
||||
const hash2 = (r, c) => ((r * 73856093) ^ (c * 19349663)) >>> 0;
|
||||
|
||||
// ---- geometry accumulator ----------------------------------------------------------------------------------
|
||||
|
||||
export class Geom {
|
||||
constructor() { this.pos = []; this.nrm = []; this.uv = []; this.col = []; this.idx = []; this.n = 0; }
|
||||
/**
|
||||
* Quad from 4 corners in counter-clockwise order seen from the front (BL, BR, TR, TL), `uv` = 8 numbers in the
|
||||
* same corner order, `nrm` one [x,y,z] (flat) or 4, `col` one [r,g,b] or 4.
|
||||
*/
|
||||
quad(p, uv, nrm, col) {
|
||||
const base = this.n;
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const q = p[k];
|
||||
this.pos.push(q[0], q[1], q[2]);
|
||||
const nn = Array.isArray(nrm[0]) ? nrm[k] : nrm;
|
||||
this.nrm.push(nn[0], nn[1], nn[2]);
|
||||
this.uv.push(uv ? uv[k * 2] : 0, uv ? uv[k * 2 + 1] : 0);
|
||||
const cc = col ? (Array.isArray(col[0]) ? col[k] : col) : [1, 1, 1];
|
||||
this.col.push(cc[0], cc[1], cc[2]);
|
||||
}
|
||||
this.idx.push(base, base + 1, base + 2, base, base + 2, base + 3);
|
||||
this.n += 4;
|
||||
return this;
|
||||
}
|
||||
/** Triangle (CCW from the front). */
|
||||
tri(p, nrm, col) {
|
||||
const base = this.n;
|
||||
for (let k = 0; k < 3; k++) {
|
||||
this.pos.push(p[k][0], p[k][1], p[k][2]);
|
||||
const nn = Array.isArray(nrm[0]) ? nrm[k] : nrm;
|
||||
this.nrm.push(nn[0], nn[1], nn[2]);
|
||||
this.uv.push(0, 0);
|
||||
const cc = col ? (Array.isArray(col[0]) ? col[k] : col) : [1, 1, 1];
|
||||
this.col.push(cc[0], cc[1], cc[2]);
|
||||
}
|
||||
this.idx.push(base, base + 1, base + 2);
|
||||
this.n += 3;
|
||||
return this;
|
||||
}
|
||||
get vertexCount() { return this.n; }
|
||||
get triangleCount() { return this.idx.length / 3; }
|
||||
/** Typed arrays for a BufferGeometry. */
|
||||
finish() {
|
||||
return {
|
||||
position: new Float32Array(this.pos), normal: new Float32Array(this.nrm), uv: new Float32Array(this.uv),
|
||||
color: new Float32Array(this.col), index: this.n > 65535 ? new Uint32Array(this.idx) : new Uint16Array(this.idx),
|
||||
vertexCount: this.n, triangleCount: this.idx.length / 3,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const norm3 = (x, y, z) => { const l = Math.hypot(x, y, z) || 1; return [x / l, y / l, z / l]; };
|
||||
|
||||
/** Bilinear UV at fractions (fx right, fy far) of a face whose corner UVs are [BL, BR, TR, TL] (8 numbers). */
|
||||
export function uvAt(uv, fx, fy) {
|
||||
const bu = uv[0] + (uv[2] - uv[0]) * fx, bv = uv[1] + (uv[3] - uv[1]) * fx;
|
||||
const tu = uv[6] + (uv[4] - uv[6]) * fx, tv = uv[7] + (uv[5] - uv[7]) * fx;
|
||||
return [bu + (tu - bu) * fy, bv + (tv - bv) * fy];
|
||||
}
|
||||
|
||||
// ---- stage classification -----------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Per-tile description of a stage: `{ r, c, glyph, content, drawn, h (top height), raised, surf (top surface),
|
||||
* tint, side (side panel surface) }`. Tolerant of malformed stages (missing rows → forbidden). `area` = the built
|
||||
* areas (inclusive tile rects, see AREAS): content outside them is not built, like the client which only builds the
|
||||
* areas of the current phase (configBlackBoard leftNormal / rightNormal / leftBoss / rightBoss + the preview pen).
|
||||
*/
|
||||
export function classifyStage(stage, area = null) {
|
||||
const rows = Array.isArray(stage?.rows) ? stage.rows : [];
|
||||
const pen = parsePenRect(stage?.config?.enemy_place_rect) || PEN_RECT;
|
||||
const legend = stage && typeof stage.tiles === 'object' && stage.tiles ? stage.tiles : {};
|
||||
const grid = [];
|
||||
for (let r = 0; r < ROWS; r++) {
|
||||
const line = typeof rows[r] === 'string' ? rows[r] : '';
|
||||
const row = [];
|
||||
for (let c = 0; c < COLS; c++) {
|
||||
let g = line[c] || '#';
|
||||
if (!GLYPH[g]) g = TILEKEY_GLYPH[legend[g]?.tileKey] || (legend[g]?.height === 'HIGH' ? '#' : 'R');
|
||||
const def = GLYPH[g];
|
||||
const inArea = !area || area.some((a) => r >= a.r0 && r <= a.r1 && c >= a.c0 && c <= a.c1);
|
||||
row.push({ r, c, glyph: g, content: inArea && CONTENT_OF(g), inArea, drawn: false, h: def.h ? TILE_H[def.h] : 0, raised: !!def.h });
|
||||
}
|
||||
grid.push(row);
|
||||
}
|
||||
// the island: content tiles + the forbidden / separator ring around them (Chebyshev 1)
|
||||
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
|
||||
const t = grid[r][c];
|
||||
if (t.content) { t.drawn = true; continue; }
|
||||
if (!t.inArea) continue;
|
||||
let near = false;
|
||||
for (let dr = -1; dr <= 1 && !near; dr++) for (let dc = -1; dc <= 1; dc++) {
|
||||
const n = grid[r + dr]?.[c + dc];
|
||||
if (n && n.content) { near = true; break; }
|
||||
}
|
||||
t.drawn = near;
|
||||
}
|
||||
const at = (r, c) => grid[r]?.[c] || null;
|
||||
for (const row of grid) for (const t of row) {
|
||||
t.tint = [1, 1, 1];
|
||||
t.surf = 'concrete';
|
||||
t.rot = 0; t.flipX = false;
|
||||
const h = hash2(t.r, t.c);
|
||||
switch (t.glyph) {
|
||||
case 'r': t.rot = (h % 4) * 90; t.flipX = ((h >> 3) & 1) === 1; break;
|
||||
case 'R': t.surf = 'concreteStripe'; t.rot = (h & 1) ? 180 : 0; break;
|
||||
case 'f': case 'p':
|
||||
t.surf = 'hatch'; t.rot = (h % 4) * 90; if (t.glyph === 'p') t.tint = [0.9, 0.92, 0.95];
|
||||
t.glass = t.r >= pen.r0 && t.r <= pen.r1 && t.c >= pen.c0 && t.c <= pen.c1;
|
||||
break;
|
||||
case 'b': t.rot = (h % 4) * 90; break;
|
||||
case 'S': t.surf = 'concreteArrow'; break;
|
||||
case 'E': t.surf = 'ringHatch'; break;
|
||||
case 'I': case 'O': t.surf = 'lift'; t.rot = t.glyph === 'O' ? 180 : 0; break;
|
||||
case 'g': t.surf = 'slats'; t.tint = [0.72, 0.76, 0.78]; break;
|
||||
case 'm': t.rot = (h % 4) * 90; t.tint = [0.78, 0.8, 0.7]; break;
|
||||
case 'i': t.rot = (h % 4) * 90; t.tint = [0.95, 0.88, 0.86]; break;
|
||||
case 'd': t.surf = 'concrete'; t.tint = [0.35, 0.42, 0.46]; break;
|
||||
// the own bench (rows 7–8): REINFORCEMENTS / EQUIPMENTS pads; the Final Assault benches (rows 0–1) are plain
|
||||
// 2×2-slab concrete plates in the official boss field
|
||||
case 'a': t.surf = t.r <= 1 ? 'concrete' : 'padReinf'; t.side = 'benchRail'; if (t.r <= 1) { t.tint = [0.3, 0.32, 0.35]; t.panels = true; } break;
|
||||
case 'A': t.surf = t.r <= 1 ? 'concrete' : 'padEquip'; t.side = 'benchRail'; if (t.r <= 1) { t.tint = [0.3, 0.32, 0.35]; t.panels = true; } break;
|
||||
case 'h': t.side = 'goldSide'; break;
|
||||
// forbidden blocks and separators: the dark hatched ("▨") blocks of the official boss field / arena rows
|
||||
case '#': t.surf = 'hatch'; t.rot = (h & 1) ? 90 : 0; t.tint = [0.34, 0.36, 0.39]; t.side = 'graySide'; t.sideTint = [0.52, 0.55, 0.58]; break;
|
||||
case 'X': t.surf = 'hatch'; t.tint = [0.36, 0.38, 0.41]; t.side = (t.c % 5 === 2) ? 'evacPanel' : 'restPanel'; t.sideTint = [0.8, 0.82, 0.86]; break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
// high-ground plates by connectivity (texture top = the tile's far edge)
|
||||
const wall = (r, c) => { const n = at(r, c); return !!n && n.glyph === 'h'; };
|
||||
for (const row of grid) for (const t of row) {
|
||||
if (t.glyph !== 'h') continue;
|
||||
const L = wall(t.r, t.c - 1), R = wall(t.r, t.c + 1);
|
||||
if (L || R) { t.surf = L && R ? 'plateM' : R ? 'plateL' : 'plateR'; continue; }
|
||||
const near = wall(t.r - 1, t.c), far = wall(t.r + 1, t.c);
|
||||
if (near && far) { t.surf = 'plateM'; t.rot = 90; }
|
||||
else if (far) { t.surf = 'plateL'; t.rot = 270; }
|
||||
else if (near) { t.surf = 'plateL'; t.rot = 90; }
|
||||
else t.surf = 'plateS';
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
/** Top height of tile (r,c) of a classified grid (0 off-grid / not drawn), like tiles.js heightAt. */
|
||||
export function heightOf(grid, r, c) {
|
||||
const t = grid[r]?.[c];
|
||||
return t && t.drawn ? t.h : 0;
|
||||
}
|
||||
|
||||
// ---- builder ----------------------------------------------------------------------------------------------------
|
||||
|
||||
const DIRS = [['S', 0, -1], ['E', 1, 0], ['N', 0, 1], ['W', -1, 0]];
|
||||
const NRM = { S: [0, -1, 0], E: [1, 0, 0], N: [0, 1, 0], W: [-1, 0, 0] };
|
||||
|
||||
/** Corners (BL, BR, TR, TL seen from outside) of the side face `dir` of box [x0,x1]×[y0,y1] between zb and zt. */
|
||||
export function sideCorners(dir, x0, x1, y0, y1, zb, zt) {
|
||||
switch (dir) {
|
||||
case 'S': return [[x0, y0, zb], [x1, y0, zb], [x1, y0, zt], [x0, y0, zt]];
|
||||
case 'N': return [[x1, y1, zb], [x0, y1, zb], [x0, y1, zt], [x1, y1, zt]];
|
||||
case 'E': return [[x1, y0, zb], [x1, y1, zb], [x1, y1, zt], [x1, y0, zt]];
|
||||
default: return [[x0, y1, zb], [x0, y0, zb], [x0, y0, zt], [x0, y1, zt]];
|
||||
}
|
||||
}
|
||||
|
||||
const mul = (a, b) => [a[0] * b[0], a[1] * b[1], a[2] * b[2]];
|
||||
const scl = (a, k) => [a[0] * k, a[1] * k, a[2] * k];
|
||||
|
||||
/**
|
||||
* Beveled top of a tile: an inset flat top at `zt` + 4 sloped strips down to `zt − drop` at the tile border.
|
||||
* `ao` = corner occlusion [NL, NR, FR, FL] (1 = open).
|
||||
*/
|
||||
function bevelTop(g, uvTable, t, zt, inset, drop, ao, tint) {
|
||||
const x0 = t.c - 0.5, x1 = t.c + 0.5, y0 = t.r - 0.5, y1 = t.r + 0.5;
|
||||
const surf = uvTable[t.surf] || uvTable.concrete;
|
||||
const uv = surfaceUV({ ...surf, rot: ((surf.rot | 0) + (t.rot | 0)) % 360, flipX: !!surf.flipX !== !!t.flipX });
|
||||
const b = inset, zo = zt - drop;
|
||||
const f = (fx, fy) => uvAt(uv, fx, fy);
|
||||
const colAt = (fx, fy) => {
|
||||
// bilinear corner AO
|
||||
const a = ao[0] + (ao[1] - ao[0]) * fx, d = ao[3] + (ao[2] - ao[3]) * fx;
|
||||
return scl(tint, a + (d - a) * fy);
|
||||
};
|
||||
const ib = b, ie = 1 - b;
|
||||
// inner flat top
|
||||
const P = (fx, fy, z) => [x0 + fx, y0 + fy, z];
|
||||
g.quad([P(ib, ib, zt), P(ie, ib, zt), P(ie, ie, zt), P(ib, ie, zt)],
|
||||
[...f(ib, ib), ...f(ie, ib), ...f(ie, ie), ...f(ib, ie)], [0, 0, 1],
|
||||
[colAt(ib, ib), colAt(ie, ib), colAt(ie, ie), colAt(ib, ie)]);
|
||||
if (drop <= 0) return;
|
||||
const ns = norm3(0, -drop, b), nn = norm3(0, drop, b), ne = norm3(drop, 0, b), nw = norm3(-drop, 0, b);
|
||||
// south strip (outer edge y0)
|
||||
g.quad([P(0, 0, zo), P(1, 0, zo), P(ie, ib, zt), P(ib, ib, zt)], [...f(0, 0), ...f(1, 0), ...f(ie, ib), ...f(ib, ib)], ns,
|
||||
[colAt(0, 0), colAt(1, 0), colAt(ie, ib), colAt(ib, ib)]);
|
||||
// north strip (outer edge y1), seen from the north: BL = (1,1)
|
||||
g.quad([P(1, 1, zo), P(0, 1, zo), P(ib, ie, zt), P(ie, ie, zt)], [...f(1, 1), ...f(0, 1), ...f(ib, ie), ...f(ie, ie)], nn,
|
||||
[colAt(1, 1), colAt(0, 1), colAt(ib, ie), colAt(ie, ie)]);
|
||||
// east strip (outer edge x1), seen from the east: BL = (1,0)
|
||||
g.quad([P(1, 0, zo), P(1, 1, zo), P(ie, ie, zt), P(ie, ib, zt)], [...f(1, 0), ...f(1, 1), ...f(ie, ie), ...f(ie, ib)], ne,
|
||||
[colAt(1, 0), colAt(1, 1), colAt(ie, ie), colAt(ie, ib)]);
|
||||
// west strip (outer edge x0), seen from the west: BL = (0,1)
|
||||
g.quad([P(0, 1, zo), P(0, 0, zo), P(ib, ib, zt), P(ib, ie, zt)], [...f(0, 1), ...f(0, 0), ...f(ib, ib), ...f(ib, ie)], nw,
|
||||
[colAt(0, 1), colAt(0, 0), colAt(ib, ib), colAt(ib, ie)]);
|
||||
}
|
||||
|
||||
/** Final Assault bench plate: a 2×2 grid of light concrete panels on the dark-rimmed top (the official boss field). */
|
||||
export const PANEL = Object.freeze({ rim: 0.085, seam: 0.05, lift: 0.004 });
|
||||
function benchPanels(g, uvTable, t, zt, ao) {
|
||||
const surf = uvTable.concrete || uvTable.steel;
|
||||
if (!surf) return;
|
||||
const x0 = t.c - 0.5, y0 = t.r - 0.5, z = zt + PANEL.lift;
|
||||
const a = PANEL.rim, h = PANEL.seam / 2;
|
||||
const light = [0.9, 0.92, 0.94];
|
||||
const span = [[a, 0.5 - h], [0.5 + h, 1 - a]];
|
||||
for (let i = 0; i < 2; i++) for (let j = 0; j < 2; j++) {
|
||||
const [fx0, fx1] = span[i], [fy0, fy1] = span[j];
|
||||
// each panel shows a quarter of the concrete slab (texture v runs down: the far edge is the top of the crop)
|
||||
const uv = surfaceUV(surf, [i * 0.5, (1 - j) * 0.5, i * 0.5 + 0.5, (1 - j) * 0.5 + 0.5]);
|
||||
const col = (fx, fy) => { const s0 = ao[0] + (ao[1] - ao[0]) * fx, s1 = ao[3] + (ao[2] - ao[3]) * fx; return scl(light, s0 + (s1 - s0) * fy); };
|
||||
g.quad([[x0 + fx0, y0 + fy0, z], [x0 + fx1, y0 + fy0, z], [x0 + fx1, y0 + fy1, z], [x0 + fx0, y0 + fy1, z]], uv, [0, 0, 1],
|
||||
[col(fx0, fy0), col(fx1, fy0), col(fx1, fy1), col(fx0, fy1)]);
|
||||
}
|
||||
}
|
||||
|
||||
/** A panelled side face from zb to zt; the panel texture keeps its aspect (repeats along the face if needed). */
|
||||
function sideFace(g, uvTable, surfName, dir, x0, x1, y0, y1, zb, zt, colBottom, colTop, width = 1) {
|
||||
if (zt - zb < 1e-4) return;
|
||||
const surf = uvTable[surfName] || uvTable.graySide;
|
||||
const uv = surfaceUV(surf, sideRect(surf, width, zt - zb));
|
||||
const c = sideCorners(dir, x0, x1, y0, y1, zb, zt);
|
||||
g.quad(c, uv, NRM[dir], [colBottom, colBottom, colTop, colTop]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the whole board of a stage.
|
||||
* @param {any} stage data/stages.json entry
|
||||
* @param {{ uv?: object, devices?: boolean }} [opts] uv = resolveUvTable(tiles.json) (defaults to the built-in table)
|
||||
*/
|
||||
export function buildBoard(stage, opts = {}) {
|
||||
const uvTable = opts.uv || null;
|
||||
const UVT = uvTable || Object.fromEntries(Object.entries(SURFACES).map(([k, v]) => [k, { ...v, rect: [...v.rect] }]));
|
||||
const grid = classifyStage(stage, opts.area || null);
|
||||
const at = (r, c) => grid[r]?.[c] || null;
|
||||
const topZ = (t) => (t.glyph === 'd' ? -BASIN : t.h);
|
||||
const board = new Geom(), decal = new Geom(), pipe = new Geom(), glass = new Geom();
|
||||
const gates = [], terrain = { water: [], mire: [], smog: [], infection: [] };
|
||||
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||||
|
||||
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
|
||||
const t = grid[r][c];
|
||||
if (!t.drawn) continue;
|
||||
minX = Math.min(minX, c - 0.5); maxX = Math.max(maxX, c + 0.5); minY = Math.min(minY, r - 0.5); maxY = Math.max(maxY, r + 0.5);
|
||||
const zt = topZ(t);
|
||||
const x0 = c - 0.5, x1 = c + 0.5, y0 = r - 0.5, y1 = r + 0.5;
|
||||
const tint = t.tint;
|
||||
// corner occlusion: a corner touching a taller neighbour darkens (NL, NR, FR, FL)
|
||||
const occl = (dx, dy) => {
|
||||
let hi = false;
|
||||
for (const [ax, ay] of [[0, 0], [dx, 0], [0, dy], [dx, dy]]) {
|
||||
const n = at(r + ay, c + ax);
|
||||
if (n && n.drawn && topZ(n) > zt + 0.05) hi = true;
|
||||
}
|
||||
return hi ? 0.66 : 1;
|
||||
};
|
||||
const ao = [occl(-1, -1), occl(1, -1), occl(1, 1), occl(-1, 1)];
|
||||
const [inset, drop] = t.raised ? BEVEL.block : BEVEL.low;
|
||||
bevelTop(t.glass ? glass : board, UVT, t, zt, inset, t.glyph === 'd' ? 0 : drop, ao, tint);
|
||||
if (t.panels) benchPanels(board, UVT, t, zt, ao);
|
||||
const zEdge = zt - (t.glyph === 'd' ? 0 : drop);
|
||||
// sides: towards lower drawn neighbours (block sides / basin walls) and the island edge (cliffs)
|
||||
for (const [dir, dx, dy] of DIRS) {
|
||||
const n = at(r + dy, c + dx);
|
||||
const nDrawn = !!(n && n.drawn);
|
||||
const nz = nDrawn ? topZ(n) - (n.glyph === 'd' ? 0 : (n.raised ? BEVEL.block[1] : BEVEL.low[1])) : -CLIFF;
|
||||
if (!nDrawn) {
|
||||
// island edge: the tile's own side (if raised) then the platform's panelled cliff
|
||||
const sideTint = t.sideTint || [1, 1, 1];
|
||||
if (zEdge > 0.001) sideFace(board, UVT, t.side || 'graySide', dir, x0, x1, y0, y1, 0, zEdge, scl(sideTint, 0.7), sideTint);
|
||||
sideFace(board, UVT, 'pipePanel', dir, x0, x1, y0, y1, -CLIFF, Math.min(0, zEdge), [0.16, 0.18, 0.2], [0.62, 0.66, 0.7], 1);
|
||||
continue;
|
||||
}
|
||||
if (zEdge > nz + 1e-4) {
|
||||
const surf = t.glyph === 'd' ? 'graySide' : (t.side || 'graySide');
|
||||
const st = t.sideTint || (t.glyph === 'd' ? [0.45, 0.5, 0.55] : [1, 1, 1]);
|
||||
sideFace(board, UVT, surf, dir, x0, x1, y0, y1, nz, zEdge, scl(st, 0.62), st);
|
||||
}
|
||||
}
|
||||
// per-glyph extras
|
||||
switch (t.glyph) {
|
||||
case 'S': gates.push({ r, c, kind: 'start', z: 0 }); break;
|
||||
case 'E': gates.push({ r, c, kind: 'end', z: 0 }); break;
|
||||
case 'I': case 'O': {
|
||||
const surf = UVT[t.glyph === 'I' ? 'arrowDown' : 'arrowUp'];
|
||||
if (surf) {
|
||||
const a = 0.3, z = zt + 0.004;
|
||||
decal.quad([[c - a, r - a, z], [c + a, r - a, z], [c + a, r + a, z], [c - a, r + a, z]], surfaceUV(surf), [0, 0, 1], [1, 1, 1]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'd': terrain.water.push([r, c]); break;
|
||||
case 'm': terrain.mire.push([r, c]); break;
|
||||
case 'g': terrain.smog.push([r, c]); break;
|
||||
case 'i': terrain.infection.push([r, c]); break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
// fence railings: orange pipes around every connected region of fenced tiles ('b'), posts at the corners
|
||||
const fence = (r, c) => { const n = at(r, c); return !!n && n.drawn && n.glyph === 'b'; };
|
||||
const rail = { inset: 0.07, h: 0.26, rad: 0.032, post: 0.038 };
|
||||
const posts = new Set();
|
||||
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
|
||||
if (!fence(r, c)) continue;
|
||||
const e = rail.inset;
|
||||
const x0 = c - 0.5, x1 = c + 0.5, y0 = r - 0.5, y1 = r + 0.5;
|
||||
const S = !fence(r - 1, c), N = !fence(r + 1, c), W = !fence(r, c - 1), E = !fence(r, c + 1);
|
||||
const xa = W ? x0 + e : x0, xb = E ? x1 - e : x1, ya = S ? y0 + e : y0, yb = N ? y1 - e : y1;
|
||||
if (S) tube(pipe, [xa, y0 + e, rail.h], [xb, y0 + e, rail.h], rail.rad);
|
||||
if (N) tube(pipe, [xa, y1 - e, rail.h], [xb, y1 - e, rail.h], rail.rad);
|
||||
if (W) tube(pipe, [x0 + e, ya, rail.h], [x0 + e, yb, rail.h], rail.rad);
|
||||
if (E) tube(pipe, [x1 - e, ya, rail.h], [x1 - e, yb, rail.h], rail.rad);
|
||||
const corner = (x, y, key) => { if (!posts.has(key)) { posts.add(key); tube(pipe, [x, y, 0], [x, y, rail.h + rail.rad], rail.post); } };
|
||||
if (S && W) corner(x0 + e, y0 + e, `${r},${c},sw`);
|
||||
if (S && E) corner(x1 - e, y0 + e, `${r},${c},se`);
|
||||
if (N && W) corner(x0 + e, y1 - e, `${r},${c},nw`);
|
||||
if (N && E) corner(x1 - e, y1 - e, `${r},${c},ne`);
|
||||
// a mid post on long straight runs keeps the railing from floating
|
||||
if (S && !W && !E && (c % 2 === 0)) corner(x0, y0 + e, `${r},${c},sm`);
|
||||
if (N && !W && !E && (c % 2 === 0)) corner(x0, y1 - e, `${r},${c},nm`);
|
||||
if (W && !S && !N && (r % 2 === 0)) corner(x0 + e, y0, `${r},${c},wm`);
|
||||
if (E && !S && !N && (r % 2 === 0)) corner(x1 - e, y0, `${r},${c},em`);
|
||||
}
|
||||
|
||||
// cyan field edge: emissive strips along the play areas' outer border (the official boundary glow)
|
||||
const edges = [];
|
||||
const zone = (t) => !!t && t.drawn && t.content && t.glyph !== 'a' && t.glyph !== 'A';
|
||||
// "outside": undrawn tiles and the forbidden / separator tiles connected to them (interior holes are not)
|
||||
const outside = new Set();
|
||||
const stack = [];
|
||||
for (let r = -1; r <= ROWS; r++) for (let c = -1; c <= COLS; c++) {
|
||||
const t = at(r, c);
|
||||
if (!t || !t.drawn) { outside.add(`${r},${c}`); stack.push([r, c]); }
|
||||
}
|
||||
while (stack.length) {
|
||||
const [r, c] = stack.pop();
|
||||
for (const [, dx, dy] of DIRS) {
|
||||
const rr = r + dy, cc = c + dx, n = at(rr, cc);
|
||||
if (!n || outside.has(`${rr},${cc}`) || !n.drawn || n.content) continue;
|
||||
outside.add(`${rr},${cc}`);
|
||||
stack.push([rr, cc]);
|
||||
}
|
||||
}
|
||||
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
|
||||
const t = grid[r][c];
|
||||
if (!zone(t)) continue;
|
||||
for (const [dir, dx, dy] of DIRS) {
|
||||
const n = at(r + dy, c + dx);
|
||||
if (zone(n)) continue;
|
||||
if (!outside.has(`${r + dy},${c + dx}`)) continue; // interior holes, bench pads: no boundary glow
|
||||
edges.push({ r, c, dir, z: topZ(t) });
|
||||
}
|
||||
}
|
||||
|
||||
const devices = opts.devices === false ? [] : stageDevices(stage, grid);
|
||||
for (const d of devices) {
|
||||
if (d.kind === 'platform' || d.kind === 'sealedFloor' || d.kind === 'waterPlatform') slabDevice(board, decal, UVT, d, grid);
|
||||
}
|
||||
|
||||
return {
|
||||
grid, gates, devices, terrain, edges,
|
||||
buckets: { board: board.finish(), decal: decal.finish(), pipe: pipe.finish(), glass: glass.finish() },
|
||||
bounds: { x0: minX, x1: maxX, y0: minY, y1: maxY },
|
||||
stats: { tiles: grid.flat().filter((t) => t.drawn).length, vertices: board.vertexCount + decal.vertexCount + pipe.vertexCount },
|
||||
};
|
||||
}
|
||||
|
||||
// ---- pipes -----------------------------------------------------------------------------------------------------
|
||||
|
||||
/** Closed-side cylinder from a to b (8 segments) with flat caps. */
|
||||
export function tube(g, a, b, rad, seg = 8, col = [1, 1, 1]) {
|
||||
const d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
|
||||
const len = Math.hypot(d[0], d[1], d[2]);
|
||||
if (len < 1e-6) return g;
|
||||
const w = [d[0] / len, d[1] / len, d[2] / len];
|
||||
const ref = Math.abs(w[2]) < 0.9 ? [0, 0, 1] : [1, 0, 0];
|
||||
let u = [w[1] * ref[2] - w[2] * ref[1], w[2] * ref[0] - w[0] * ref[2], w[0] * ref[1] - w[1] * ref[0]];
|
||||
const ul = Math.hypot(...u); u = [u[0] / ul, u[1] / ul, u[2] / ul];
|
||||
const v = [w[1] * u[2] - w[2] * u[1], w[2] * u[0] - w[0] * u[2], w[0] * u[1] - w[1] * u[0]];
|
||||
const ring = (k) => { const t = (k / seg) * Math.PI * 2, cs = Math.cos(t), sn = Math.sin(t); return [u[0] * cs + v[0] * sn, u[1] * cs + v[1] * sn, u[2] * cs + v[2] * sn]; };
|
||||
for (let k = 0; k < seg; k++) {
|
||||
const n0 = ring(k), n1 = ring(k + 1);
|
||||
const p = (base, n) => [base[0] + n[0] * rad, base[1] + n[1] * rad, base[2] + n[2] * rad];
|
||||
g.quad([p(a, n0), p(a, n1), p(b, n1), p(b, n0)], null, [n0, n1, n1, n0], col);
|
||||
}
|
||||
for (const [end, sgn] of [[a, -1], [b, 1]]) {
|
||||
const n = [w[0] * sgn, w[1] * sgn, w[2] * sgn];
|
||||
for (let k = 0; k < seg; k++) {
|
||||
const r0 = ring(k), r1 = ring(k + 1);
|
||||
const p0 = [end[0] + r0[0] * rad, end[1] + r0[1] * rad, end[2] + r0[2] * rad];
|
||||
const p1 = [end[0] + r1[0] * rad, end[1] + r1[1] * rad, end[2] + r1[2] * rad];
|
||||
g.tri(sgn > 0 ? [end, p0, p1] : [end, p1, p0], n, col);
|
||||
}
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
// ---- devices ----------------------------------------------------------------------------------------------------
|
||||
|
||||
const DEVICE_KINDS = new Set(['crate', 'platform', 'mound', 'blower', 'turret', 'waterPlatform', 'bush', 'sealedFloor']);
|
||||
|
||||
/** Active stage devices standing on drawn tiles: `{ kind, r, c, dir, z0, key, alias }`. */
|
||||
export function stageDevices(stage, grid) {
|
||||
const list = Array.isArray(stage?.devices) ? stage.devices : [];
|
||||
const out = [];
|
||||
for (const d of list) {
|
||||
if (!d || !Array.isArray(d.pos) || !Number.isInteger(d.pos[0]) || !Number.isInteger(d.pos[1])) continue;
|
||||
if (!DEVICE_KINDS.has(d.role)) continue;
|
||||
const active = typeof d.active === 'boolean' ? d.active : !d.hidden;
|
||||
if (!active) continue;
|
||||
const [r, c] = d.pos;
|
||||
const t = grid?.[r]?.[c];
|
||||
if (!t || !t.drawn) continue;
|
||||
out.push({ kind: d.role, r, c, dir: ['UP', 'DOWN', 'LEFT', 'RIGHT'].includes(d.dir) ? d.dir : 'UP', z0: t.h, key: d.key || null, alias: d.alias || null, rangeTiles: Array.isArray(d.rangeTiles) ? d.rangeTiles : null });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Device heights on their tile (platform tops are standing surfaces: tiles.js heightAt adds them). */
|
||||
export const DEVICE_H = Object.freeze({ platform: TILE_H.platform, sealedFloor: 0.05, waterPlatform: 0.2 });
|
||||
|
||||
function slabDevice(board, decal, UVT, d, grid) {
|
||||
const size = d.kind === 'sealedFloor' ? 0.92 : 0.94;
|
||||
const h = DEVICE_H[d.kind] || 0.2;
|
||||
const z0 = grid[d.r][d.c].h, zt = z0 + h;
|
||||
const a = size / 2, x0 = d.c - a, x1 = d.c + a, y0 = d.r - a, y1 = d.r + a;
|
||||
const topSurf = d.kind === 'sealedFloor' ? null : UVT.plateS;
|
||||
if (topSurf) board.quad([[x0, y0, zt], [x1, y0, zt], [x1, y1, zt], [x0, y1, zt]], surfaceUV(topSurf), [0, 0, 1], [1.02, 1.02, 1.02]);
|
||||
else decal.quad([[x0, y0, zt], [x1, y0, zt], [x1, y1, zt], [x0, y1, zt]], surfaceUV(UVT.hazardX), [0, 0, 1], [1, 1, 1]);
|
||||
const side = UVT.graySide;
|
||||
for (const dir of ['S', 'E', 'N', 'W']) {
|
||||
const uv = surfaceUV(side, sideRect(side, size, h));
|
||||
board.quad(sideCorners(dir, x0, x1, y0, y1, z0, zt), uv, NRM[dir], [[0.5, 0.53, 0.56], [0.5, 0.53, 0.56], [0.85, 0.88, 0.9], [0.85, 0.88, 0.9]]);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- OBJ meshes → board space -------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Convert a parsed OBJ (board3d/obj.js, Unity Y-up exported right-handed with X mirrored) into board space
|
||||
* (x = col, y = row, z = up): (x', y', z') → (−x'·s, z'·s, y'·s) — a proper rotation, windings stay outward.
|
||||
*/
|
||||
export function objToBoard(obj, s = 1) {
|
||||
const p = obj.position, n = obj.normal;
|
||||
const pos = new Float32Array(p.length), nrm = n ? new Float32Array(n.length) : null;
|
||||
for (let i = 0; i < p.length; i += 3) {
|
||||
pos[i] = -p[i] * s; pos[i + 1] = p[i + 2] * s; pos[i + 2] = p[i + 1] * s;
|
||||
if (nrm) { nrm[i] = -n[i]; nrm[i + 1] = n[i + 2]; nrm[i + 2] = n[i + 1]; }
|
||||
}
|
||||
return { ...obj, position: pos, normal: nrm };
|
||||
}
|
||||
|
||||
/**
|
||||
* Box-project UVs of a (board-space) mesh onto atlas surfaces: faces pointing up take `top`, the others `side`
|
||||
* (mapped across the face's horizontal extent × height). Used for the official crate mesh (s_common_box_01), whose
|
||||
* own material is assigned at runtime by the client.
|
||||
*/
|
||||
export function boxProjectUV(mesh, top, side) {
|
||||
const p = mesh.position, n = mesh.normal;
|
||||
let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity, z0 = Infinity, z1 = -Infinity;
|
||||
for (let i = 0; i < p.length; i += 3) {
|
||||
x0 = Math.min(x0, p[i]); x1 = Math.max(x1, p[i]); y0 = Math.min(y0, p[i + 1]); y1 = Math.max(y1, p[i + 1]); z0 = Math.min(z0, p[i + 2]); z1 = Math.max(z1, p[i + 2]);
|
||||
}
|
||||
const uvTop = surfaceUV(top), uvSide = surfaceUV(side);
|
||||
const out = new Float32Array((p.length / 3) * 2);
|
||||
for (let i = 0, j = 0; i < p.length; i += 3, j += 2) {
|
||||
const nx = n ? n[i] : 0, ny = n ? n[i + 1] : 0, nz = n ? n[i + 2] : 1;
|
||||
let uv;
|
||||
if (nz > 0.6) uv = uvAt(uvTop, (p[i] - x0) / (x1 - x0 || 1), (p[i + 1] - y0) / (y1 - y0 || 1));
|
||||
else {
|
||||
const fy = (p[i + 2] - z0) / (z1 - z0 || 1);
|
||||
// horizontal coordinate along the face (seen from outside)
|
||||
let fx;
|
||||
if (Math.abs(nx) >= Math.abs(ny)) fx = nx > 0 ? (p[i + 1] - y0) / (y1 - y0 || 1) : (y1 - p[i + 1]) / (y1 - y0 || 1);
|
||||
else fx = ny < 0 ? (p[i] - x0) / (x1 - x0 || 1) : (x1 - p[i]) / (x1 - x0 || 1);
|
||||
uv = uvAt(uvSide, fx, fy);
|
||||
}
|
||||
out[j] = uv[0]; out[j + 1] = uv[1];
|
||||
}
|
||||
return { ...mesh, uv: out, bounds: { x0, x1, y0, y1, z0, z1 } };
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// 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;
|
||||
/** Dynamic import of the vendored three.js ESM build (browser only); null when unavailable. */
|
||||
export function loadThree(url = THREE_URL) {
|
||||
if (!threePromise) {
|
||||
threePromise = import(/* @vite-ignore */ url).then((m) => (m && m.WebGLRenderer ? m : null), () => null);
|
||||
}
|
||||
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;
|
||||
cached = (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).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; });
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** Forget the cached pack (tests / hot reload). */
|
||||
export function resetBoardPack() { cached = null; threePromise = null; }
|
||||
@@ -0,0 +1,391 @@
|
||||
// render/board3d/materials.js — three.js materials of the official board scene (DESIGN §15). THREE is injected
|
||||
// (the renderer loads /vendor/three.module.js; Node tests pass the npm package).
|
||||
//
|
||||
// MT_autochess → MeshStandardMaterial: albedo TX_autochessi_D (sRGB), normal TX_autochessi_N (BC5 → RGB,
|
||||
// tools/local-extract DERIVED), roughness 1 − smoothness of TX_autochessi_M (DERIVED), metal 0,
|
||||
// emission TX_autochessi_E (_EmissionColor white), vertex colours = baked AO / tints
|
||||
// MT_autochess_common → device plates of TX_autochessi_common_D, alpha test 0.408, emission _E × 0.559
|
||||
// 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
|
||||
// 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.
|
||||
|
||||
/** Shared focus uniforms (one object for every lit material). */
|
||||
export function focusUniforms(THREE) {
|
||||
return {
|
||||
uFocus: { value: new THREE.Vector4(0, 9, 10, 12) }, // x0, y0, x1, y1 (world)
|
||||
uFocusDim: { value: 0.72 },
|
||||
uFocusSoft: { value: 2.2 },
|
||||
// glossy "arena light" sheen: smooth surfaces (glass hatches, steel, plate centres; smoothness from TX_…_M) pick
|
||||
// up a bright reflection like the official baked reflections — added as sheen · smoothness²
|
||||
uSheen: { value: new THREE.Color(0.4, 0.45, 0.52) },
|
||||
};
|
||||
}
|
||||
|
||||
/** Inject the focus falloff into a built-in material (onBeforeCompile). */
|
||||
export function addFocus(material, uniforms) {
|
||||
const prev = material.onBeforeCompile;
|
||||
material.onBeforeCompile = (shader, renderer) => {
|
||||
if (typeof prev === 'function') prev(shader, renderer);
|
||||
Object.assign(shader.uniforms, uniforms);
|
||||
shader.vertexShader = shader.vertexShader
|
||||
.replace('#include <common>', '#include <common>\nvarying vec3 vFocusWorld;')
|
||||
.replace('#include <project_vertex>', '#include <project_vertex>\nvFocusWorld = (modelMatrix * vec4(transformed, 1.0)).xyz;');
|
||||
shader.fragmentShader = shader.fragmentShader
|
||||
// tinted (dark) blocks glow less: emission follows the vertex tint
|
||||
.replace('#include <emissivemap_fragment>', '#include <emissivemap_fragment>\n#if defined( USE_COLOR )\n { float ek = (vColor.r + vColor.g + vColor.b) / 3.0; totalEmissiveRadiance *= ek * ek; }\n#endif')
|
||||
.replace('#include <common>', '#include <common>\nvarying vec3 vFocusWorld;\nuniform vec4 uFocus;\nuniform float uFocusDim;\nuniform float uFocusSoft;\nuniform vec3 uSheen;')
|
||||
.replace('#include <opaque_fragment>', [
|
||||
'#ifdef USE_ROUGHNESSMAP',
|
||||
' { float sm = clamp(1.0 - texelRoughness.g, 0.0, 1.0); float tintk = 1.0;',
|
||||
' #if defined( USE_COLOR )',
|
||||
' tintk = (vColor.r + vColor.g + vColor.b) / 3.0; tintk *= tintk;',
|
||||
' #endif',
|
||||
' outgoingLight += uSheen * sm * sm * tintk * diffuseColor.a; }',
|
||||
'#endif',
|
||||
'{',
|
||||
' vec2 fq = vFocusWorld.xy;',
|
||||
' vec2 fd = max(vec2(0.0), max(uFocus.xy - fq, fq - uFocus.zw));',
|
||||
' float fm = 1.0 - smoothstep(0.0, uFocusSoft, length(fd));',
|
||||
' outgoingLight *= mix(uFocusDim, 1.0, fm);',
|
||||
'}',
|
||||
'#include <opaque_fragment>',
|
||||
].join('\n'));
|
||||
};
|
||||
material.customProgramCacheKey = () => 'sp-focus';
|
||||
return material;
|
||||
}
|
||||
|
||||
/** Texture from a loaded image (sRGB colour maps; linear data maps), mipmapped, anisotropic. */
|
||||
export function makeTexture(THREE, image, { srgb = true, aniso = 1, repeat = false, flipY = true } = {}) {
|
||||
if (!image) return null;
|
||||
const t = new THREE.Texture(image);
|
||||
t.colorSpace = srgb ? THREE.SRGBColorSpace : THREE.NoColorSpace;
|
||||
t.anisotropy = Math.max(1, aniso | 0);
|
||||
t.flipY = flipY;
|
||||
if (repeat) { t.wrapS = THREE.RepeatWrapping; t.wrapT = THREE.RepeatWrapping; }
|
||||
t.generateMipmaps = true;
|
||||
t.minFilter = THREE.LinearMipmapLinearFilter;
|
||||
t.magFilter = THREE.LinearFilter;
|
||||
t.needsUpdate = true;
|
||||
return t;
|
||||
}
|
||||
|
||||
/** MT_autochess (see header). `tex` = { D, N?, R?, E? } textures. */
|
||||
export function boardMaterial(THREE, tex, focus, opts = {}) {
|
||||
const m = new THREE.MeshStandardMaterial({
|
||||
map: tex.D, vertexColors: true,
|
||||
normalMap: tex.N || null,
|
||||
roughnessMap: tex.R || null, roughness: tex.R ? (opts.roughness ?? 0.78) : 0.78, metalness: 0,
|
||||
emissiveMap: tex.E || null, emissive: tex.E ? new THREE.Color(1, 1, 1) : new THREE.Color(0, 0, 0),
|
||||
emissiveIntensity: opts.emissive ?? 1,
|
||||
});
|
||||
if (tex.N) m.normalScale = new THREE.Vector2(opts.normalScale ?? 1, opts.normalScale ?? 1);
|
||||
return addFocus(m, focus);
|
||||
}
|
||||
|
||||
/**
|
||||
* The enemy preview pen's glass hatches (official look: glossy panes in a hazard frame reflecting a cloudy sky, not the
|
||||
* stripes under the glass): MT_autochess with the hatch's pane (the inner square of every tile, tile-local so the
|
||||
* random rotation does not matter; the hazard frame and the plate rim stay as they are) re-shaded as sky-lit glass: a
|
||||
* soft cloud reflection (T_noise_clouds_01 in world space), a faint trace of the pattern below, low roughness for the
|
||||
* key light's glint. Same UVs / atlas as the board.
|
||||
*/
|
||||
export function glassMaterial(THREE, tex, focus, opts = {}) {
|
||||
const m = boardMaterial(THREE, tex, focus, opts);
|
||||
const prev = m.onBeforeCompile;
|
||||
const uniforms = {
|
||||
uCloud: { value: tex.noise || null },
|
||||
uSkyLow: { value: new THREE.Color(0.4, 0.47, 0.54) },
|
||||
uSkyHigh: { value: new THREE.Color(0.72, 0.77, 0.82) },
|
||||
};
|
||||
m.userData.glass = uniforms;
|
||||
m.onBeforeCompile = (shader, renderer) => {
|
||||
if (typeof prev === 'function') prev(shader, renderer);
|
||||
Object.assign(shader.uniforms, uniforms);
|
||||
shader.fragmentShader = shader.fragmentShader
|
||||
.replace('#include <common>', '#include <common>\nuniform sampler2D uCloud;\nuniform vec3 uSkyLow;\nuniform vec3 uSkyHigh;')
|
||||
.replace('#include <roughnessmap_fragment>', [
|
||||
'#include <roughnessmap_fragment>',
|
||||
'{',
|
||||
// the pane: the inner square of the hatch (tile-local, any rotation), inside the hazard frame
|
||||
' vec2 gq = abs(fract(vFocusWorld.xy + 0.5) - 0.5);',
|
||||
' float glassK = 1.0 - smoothstep(0.355, 0.375, max(gq.x, gq.y));',
|
||||
' float cl = texture2D(uCloud, vFocusWorld.xy * 0.06 + vec2(0.13, 0.41), 3.0).r * 0.7 + texture2D(uCloud, vFocusWorld.xy * 0.17, 2.0).r * 0.3;',
|
||||
' vec3 sky = mix(uSkyLow, uSkyHigh, smoothstep(0.2, 0.8, cl));',
|
||||
' vec3 under = diffuseColor.rgb;',
|
||||
' diffuseColor.rgb = mix(under, sky + (under - vec3(0.35)) * 0.08, glassK);',
|
||||
' roughnessFactor = mix(roughnessFactor, 0.22, glassK);',
|
||||
' #ifdef USE_ROUGHNESSMAP',
|
||||
' texelRoughness.g = mix(texelRoughness.g, 0.4, glassK);', // the sheen (addFocus) sees an even pane
|
||||
' #endif',
|
||||
'}',
|
||||
].join('\n'));
|
||||
};
|
||||
m.customProgramCacheKey = () => 'sp-glass';
|
||||
return m;
|
||||
}
|
||||
|
||||
/** MT_autochess_common: alpha-tested device plates (drawn slightly above the tops). */
|
||||
export function decalMaterial(THREE, tex, focus) {
|
||||
const m = new THREE.MeshStandardMaterial({
|
||||
map: tex.common, alphaTest: 0.408, roughness: 0.7, metalness: 0,
|
||||
emissiveMap: tex.commonE || null, emissive: tex.commonE ? new THREE.Color(0.559, 0.559, 0.559) : new THREE.Color(0, 0, 0),
|
||||
polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2,
|
||||
});
|
||||
return addFocus(m, focus);
|
||||
}
|
||||
|
||||
/** Orange tubular fence railings. */
|
||||
export function pipeMaterial(THREE, focus) {
|
||||
return addFocus(new THREE.MeshStandardMaterial({ color: 0xf0922b, roughness: 0.42, metalness: 0.35 }), focus);
|
||||
}
|
||||
|
||||
/** Unlit textured material (Torappu/Unlit/Texture): background plane, wind device. */
|
||||
export function unlitMaterial(THREE, map, color = 0xffffff, opts = {}) {
|
||||
return new THREE.MeshBasicMaterial({ map, color, fog: opts.fog ?? true, side: opts.side ?? THREE.FrontSide });
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate / objective box material ([opt]start_end_add: additive; [opt]start_end_ab: alpha blend). `uniforms.uPulse`
|
||||
* animates the intensity (the clip's _TintColor.a curve).
|
||||
*/
|
||||
export function gateMaterial(THREE, map, { additive = true, tint = [1, 1, 1] } = {}) {
|
||||
const uniforms = { map: { value: map }, uTint: { value: new THREE.Color(tint[0], tint[1], tint[2]) }, uPulse: { value: 1 }, uFlash: { value: new THREE.Color(0, 0, 0) } };
|
||||
const m = new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
vertexShader: [
|
||||
'varying vec2 vUv;',
|
||||
'#include <common>',
|
||||
'#include <fog_pars_vertex>',
|
||||
'void main() {',
|
||||
' vUv = uv;',
|
||||
' vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);',
|
||||
' gl_Position = projectionMatrix * mvPosition;',
|
||||
' #include <fog_vertex>',
|
||||
'}',
|
||||
].join('\n'),
|
||||
fragmentShader: [
|
||||
'uniform sampler2D map;',
|
||||
'uniform vec3 uTint;',
|
||||
'uniform float uPulse;',
|
||||
'uniform vec3 uFlash;',
|
||||
'varying vec2 vUv;',
|
||||
'#include <common>',
|
||||
'#include <fog_pars_fragment>',
|
||||
'void main() {',
|
||||
' vec4 t = texture2D(map, vUv);',
|
||||
' vec3 c = t.rgb * uTint;',
|
||||
' c = mix(c, uFlash * max(max(t.r, t.g), t.b) * 1.6, clamp(length(uFlash), 0.0, 1.0));',
|
||||
additive ? ' gl_FragColor = vec4(c * t.a * uPulse, 1.0);' : ' gl_FragColor = vec4(c, t.a * clamp(uPulse, 0.0, 1.0));',
|
||||
' #include <colorspace_fragment>',
|
||||
'}',
|
||||
].join('\n'),
|
||||
transparent: true, depthWrite: false, side: THREE.DoubleSide,
|
||||
blending: additive ? THREE.AdditiveBlending : THREE.NormalBlending,
|
||||
fog: false,
|
||||
});
|
||||
m.userData.additive = additive;
|
||||
return m;
|
||||
}
|
||||
|
||||
/** Additive emissive strips (the cyan field edge); `map` = a dash pattern (alpha). */
|
||||
export function glowMaterial(THREE, map, color = 0x39d6ff) {
|
||||
return new THREE.MeshBasicMaterial({ map, color, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, fog: false, polygonOffset: true, polygonOffsetFactor: -3, polygonOffsetUnits: -3 });
|
||||
}
|
||||
|
||||
// ---- animated special terrain (ShaderMaterials, own cheap lighting) -------------------------------------------
|
||||
|
||||
const TERRAIN_VERT = [
|
||||
'varying vec2 vUv;',
|
||||
'varying vec3 vWorld;',
|
||||
'#include <common>',
|
||||
'void main() {',
|
||||
' vUv = uv;',
|
||||
' vec4 w = modelMatrix * vec4(position, 1.0);',
|
||||
' vWorld = w.xyz;',
|
||||
' gl_Position = projectionMatrix * viewMatrix * w;',
|
||||
'}',
|
||||
].join('\n');
|
||||
|
||||
const FOCUS_GLSL = [
|
||||
'uniform vec4 uFocus; uniform float uFocusDim; uniform float uFocusSoft;',
|
||||
'float focusMask(vec2 q) { vec2 d = max(vec2(0.0), max(uFocus.xy - q, q - uFocus.zw)); return mix(uFocusDim, 1.0, 1.0 - smoothstep(0.0, uFocusSoft, length(d))); }',
|
||||
].join('\n');
|
||||
|
||||
/** Deep sea: teal water with the official water normal map (two scrolling layers) and caustics. */
|
||||
export function waterMaterial(THREE, tex, focus) {
|
||||
const uniforms = { uTime: { value: 0 }, uNormal: { value: tex.waterN || null }, uCaustics: { value: tex.caustics || null }, ...focus };
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
vertexShader: TERRAIN_VERT,
|
||||
fragmentShader: [
|
||||
'uniform float uTime; uniform sampler2D uNormal; uniform sampler2D uCaustics;',
|
||||
FOCUS_GLSL,
|
||||
'varying vec2 vUv; varying vec3 vWorld;',
|
||||
'void main() {',
|
||||
' 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));',
|
||||
' float diff = clamp(dot(n, L), 0.0, 1.0);',
|
||||
' vec3 V = normalize(vec3(0.0, -0.5, 0.866));',
|
||||
' float spec = pow(clamp(dot(reflect(-L, n), V), 0.0, 1.0), 40.0);',
|
||||
' float ca = texture2D(uCaustics, vWorld.xy * 0.6 + n.xy * 0.08 + vec2(uTime * 0.02, 0.0)).r;',
|
||||
' vec3 deep = vec3(0.03, 0.2, 0.27), shallow = vec3(0.09, 0.46, 0.55);',
|
||||
' vec3 c = mix(deep, shallow, 0.35 + 0.45 * diff) + ca * vec3(0.18, 0.32, 0.34) + spec * vec3(0.8, 0.95, 1.0);',
|
||||
' c *= focusMask(vWorld.xy);',
|
||||
' gl_FragColor = vec4(c, 0.9);',
|
||||
' #include <colorspace_fragment>',
|
||||
'}',
|
||||
].join('\n'),
|
||||
transparent: true, depthWrite: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** Mire: slow brown-green sludge (cloud noise), sticky bubbles. */
|
||||
export function mireMaterial(THREE, tex, focus) {
|
||||
const uniforms = { uTime: { value: 0 }, uNoise: { value: tex.noise || null }, ...focus };
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
vertexShader: TERRAIN_VERT,
|
||||
fragmentShader: [
|
||||
'uniform float uTime; uniform sampler2D uNoise;',
|
||||
FOCUS_GLSL,
|
||||
'varying vec2 vUv; varying vec3 vWorld;',
|
||||
'void main() {',
|
||||
' vec2 p = vWorld.xy * 0.35;',
|
||||
' float a = texture2D(uNoise, p + vec2(uTime * 0.012, uTime * 0.008)).r;',
|
||||
' float b = texture2D(uNoise, p * 2.3 - vec2(uTime * 0.01, -uTime * 0.014)).r;',
|
||||
' float m = smoothstep(0.25, 0.75, a * 0.65 + b * 0.5);',
|
||||
' vec3 c = mix(vec3(0.16, 0.19, 0.08), vec3(0.37, 0.4, 0.17), m);',
|
||||
' float bub = smoothstep(0.82, 0.9, b) * (0.5 + 0.5 * sin(uTime * 3.0 + a * 20.0));',
|
||||
' c += bub * vec3(0.35, 0.4, 0.2);',
|
||||
' vec2 e = min(vUv, 1.0 - vUv);',
|
||||
' float edge = smoothstep(0.0, 0.12, min(e.x, e.y));',
|
||||
' c *= focusMask(vWorld.xy);',
|
||||
' gl_FragColor = vec4(c, 0.86 * edge);',
|
||||
' #include <colorspace_fragment>',
|
||||
'}',
|
||||
].join('\n'),
|
||||
transparent: true, depthWrite: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** Infection (活性源石): dark originium crust patches with glowing, pulsing orange veins over the concrete. */
|
||||
export function infectionMaterial(THREE, tex, focus) {
|
||||
const uniforms = { uTime: { value: 0 }, uNoise: { value: tex.noise || null }, ...focus };
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
vertexShader: TERRAIN_VERT,
|
||||
fragmentShader: [
|
||||
'uniform float uTime; uniform sampler2D uNoise;',
|
||||
FOCUS_GLSL,
|
||||
'varying vec2 vUv; varying vec3 vWorld;',
|
||||
'void main() {',
|
||||
' float n = texture2D(uNoise, vWorld.xy * 0.55).r;',
|
||||
' float n2 = texture2D(uNoise, vWorld.xy * 1.3 + 0.37).r;',
|
||||
' float crust = smoothstep(0.38, 0.62, n * 0.7 + n2 * 0.45);',
|
||||
' float vein = smoothstep(0.86, 0.98, 1.0 - abs(n2 * 2.0 - 1.0)) * (0.35 + 0.65 * crust);',
|
||||
' float pulse = 0.6 + 0.4 * sin(uTime * 2.2 + n * 9.0);',
|
||||
' vec2 e = min(vUv, 1.0 - vUv);',
|
||||
' float edge = smoothstep(0.0, 0.1, min(e.x, e.y));',
|
||||
' vec3 base = mix(vec3(0.16, 0.05, 0.05), vec3(0.3, 0.1, 0.07), n2);',
|
||||
' vec3 glow = vec3(1.0, 0.46, 0.18) * (1.2 + 0.8 * pulse);',
|
||||
' vec3 c = mix(base, glow, vein) * focusMask(vWorld.xy);',
|
||||
' float a = clamp(crust * 0.72 + vein, 0.0, 1.0) * edge;',
|
||||
' gl_FragColor = vec4(c, a);',
|
||||
' #include <colorspace_fragment>',
|
||||
'}',
|
||||
].join('\n'),
|
||||
transparent: true, depthWrite: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** Smog: drifting exhaust haze (vertical billboards over the grilles). */
|
||||
export function smogMaterial(THREE, tex, focus) {
|
||||
const uniforms = { uTime: { value: 0 }, uNoise: { value: tex.noise || null }, ...focus };
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
vertexShader: TERRAIN_VERT,
|
||||
fragmentShader: [
|
||||
'uniform float uTime; uniform sampler2D uNoise;',
|
||||
FOCUS_GLSL,
|
||||
'varying vec2 vUv; varying vec3 vWorld;',
|
||||
'void main() {',
|
||||
' vec2 p = vec2(vUv.x * 0.8 + vWorld.x * 0.13, vUv.y * 0.7 - uTime * 0.12);',
|
||||
' float n = texture2D(uNoise, p).r * 0.7 + texture2D(uNoise, p * 2.1 + vec2(uTime * 0.03, 0.0)).r * 0.5;',
|
||||
' float fade = smoothstep(0.0, 0.25, vUv.y) * (1.0 - smoothstep(0.55, 1.0, vUv.y)) * smoothstep(0.0, 0.2, vUv.x) * (1.0 - smoothstep(0.8, 1.0, vUv.x));',
|
||||
' float a = smoothstep(0.3, 0.9, n) * fade * 0.8;',
|
||||
' vec3 c = vec3(0.74, 0.79, 0.78) * focusMask(vWorld.xy);',
|
||||
' gl_FragColor = vec4(c, a);',
|
||||
' #include <colorspace_fragment>',
|
||||
'}',
|
||||
].join('\n'),
|
||||
transparent: true, depthWrite: false, side: THREE.DoubleSide,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Y-up equirect — the scene rotates it to the board's z-up (scene.environmentRotation).
|
||||
*/
|
||||
export function environmentMap(THREE, renderer) {
|
||||
if (typeof document === 'undefined' || !renderer) return null;
|
||||
const W = 256, H = 128;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = W; c.height = H;
|
||||
const g = c.getContext('2d');
|
||||
const grad = g.createLinearGradient(0, 0, 0, H);
|
||||
grad.addColorStop(0, '#dfe7ef'); grad.addColorStop(0.32, '#b9c3cc'); grad.addColorStop(0.5, '#6d757d');
|
||||
grad.addColorStop(0.56, '#262b30'); grad.addColorStop(1, '#0c0e10');
|
||||
g.fillStyle = grad;
|
||||
g.fillRect(0, 0, W, H);
|
||||
// overhead light panels (the arena's floodlights) for crisp highlights
|
||||
g.fillStyle = 'rgba(255,255,255,0.85)';
|
||||
for (let i = 0; i < 6; i++) g.fillRect(8 + i * 42, 14 + (i % 2) * 8, 26, 7);
|
||||
const tex = new THREE.CanvasTexture(c);
|
||||
tex.mapping = THREE.EquirectangularReflectionMapping;
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
const pm = new THREE.PMREMGenerator(renderer);
|
||||
const rt = pm.fromEquirectangular(tex);
|
||||
pm.dispose();
|
||||
tex.dispose();
|
||||
return rt.texture;
|
||||
}
|
||||
|
||||
/** Canvas texture of a dash pattern (cyan field edge). */
|
||||
export function dashTexture(THREE) {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 128; c.height = 8;
|
||||
const g = c.getContext('2d');
|
||||
g.clearRect(0, 0, 128, 8);
|
||||
const grad = g.createLinearGradient(0, 0, 0, 8);
|
||||
grad.addColorStop(0, 'rgba(255,255,255,0)'); grad.addColorStop(0.5, 'rgba(255,255,255,1)'); grad.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
g.fillStyle = grad;
|
||||
for (let x = 2; x < 128; x += 32) g.fillRect(x, 0, 26, 8);
|
||||
const t = new THREE.CanvasTexture(c);
|
||||
t.wrapS = THREE.RepeatWrapping;
|
||||
t.colorSpace = THREE.SRGBColorSpace;
|
||||
return t;
|
||||
}
|
||||
|
||||
/** Soft radial falloff texture (contact shadow of the island on the background, glow cards). */
|
||||
export function softTexture(THREE, size = 64) {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = c.height = size;
|
||||
const g = c.getContext('2d');
|
||||
const grad = g.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
|
||||
grad.addColorStop(0, 'rgba(255,255,255,1)'); grad.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
g.fillStyle = grad;
|
||||
g.fillRect(0, 0, size, size);
|
||||
const t = new THREE.CanvasTexture(c);
|
||||
t.colorSpace = THREE.SRGBColorSpace;
|
||||
return t;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// render/board3d/obj.js — minimal Wavefront OBJ reader for the meshes exported by tools/local-extract (UnityPy:
|
||||
// `v x y z [r g b [a]]`, `vt u v`, `vn x y z`, `f v/vt/vn …`, `g name`, `usemtl name`). Pure (Node-testable).
|
||||
//
|
||||
// parseObj(text) → { position: Float32Array, normal: Float32Array|null, uv: Float32Array|null,
|
||||
// color: Float32Array|null, index: Uint16Array|Uint32Array, groups: [{ name, start, count }],
|
||||
// bounds: { min: [x,y,z], max: [x,y,z] } } | null (no geometry)
|
||||
// Faces with more than 3 corners are fanned; negative (relative) indices are honoured; unknown statements are
|
||||
// ignored. Vertices are de-duplicated per (v, vt, vn) corner triplet.
|
||||
|
||||
export function parseObj(text) {
|
||||
if (typeof text !== 'string' || !text) return null;
|
||||
const v = [], vt = [], vn = [], vc = [];
|
||||
const outPos = [], outNrm = [], outUv = [], outCol = [], idx = [];
|
||||
const groups = [];
|
||||
const map = new Map();
|
||||
let group = { name: 'default', start: 0, count: 0 };
|
||||
let hasColor = false;
|
||||
const lines = text.split(/\r?\n/);
|
||||
const ref = (s, len) => {
|
||||
if (s === undefined || s === '') return -1;
|
||||
const n = parseInt(s, 10);
|
||||
if (!Number.isFinite(n) || n === 0) return -1;
|
||||
return n > 0 ? n - 1 : len + n;
|
||||
};
|
||||
const corner = (tok) => {
|
||||
const [a, b, c] = tok.split('/');
|
||||
const iv = ref(a, v.length / 3), it = ref(b, vt.length / 2), inn = ref(c, vn.length / 3);
|
||||
if (iv < 0 || iv * 3 + 2 >= v.length) return -1;
|
||||
const key = `${iv}/${it}/${inn}`;
|
||||
let id = map.get(key);
|
||||
if (id !== undefined) return id;
|
||||
id = outPos.length / 3;
|
||||
outPos.push(v[iv * 3], v[iv * 3 + 1], v[iv * 3 + 2]);
|
||||
outCol.push(vc[iv * 3] ?? 1, vc[iv * 3 + 1] ?? 1, vc[iv * 3 + 2] ?? 1);
|
||||
if (it >= 0 && it * 2 + 1 < vt.length) outUv.push(vt[it * 2], vt[it * 2 + 1]); else outUv.push(0, 0);
|
||||
if (inn >= 0 && inn * 3 + 2 < vn.length) outNrm.push(vn[inn * 3], vn[inn * 3 + 1], vn[inn * 3 + 2]); else outNrm.push(NaN, NaN, NaN);
|
||||
map.set(key, id);
|
||||
return id;
|
||||
};
|
||||
for (const raw of lines) {
|
||||
const line = raw.trim();
|
||||
if (!line || line[0] === '#') continue;
|
||||
const parts = line.split(/\s+/);
|
||||
switch (parts[0]) {
|
||||
case 'v': {
|
||||
const x = +parts[1], y = +parts[2], z = +parts[3];
|
||||
v.push(Number.isFinite(x) ? x : 0, Number.isFinite(y) ? y : 0, Number.isFinite(z) ? z : 0);
|
||||
if (parts.length >= 7) { hasColor = true; vc[v.length - 3] = +parts[4]; vc[v.length - 2] = +parts[5]; vc[v.length - 1] = +parts[6]; }
|
||||
break;
|
||||
}
|
||||
case 'vt': vt.push(+parts[1] || 0, +parts[2] || 0); break;
|
||||
case 'vn': vn.push(+parts[1] || 0, +parts[2] || 0, +parts[3] || 0); break;
|
||||
case 'g': case 'o': case 'usemtl': {
|
||||
if (group.count > 0) groups.push(group);
|
||||
group = { name: parts.slice(1).join(' ') || 'default', start: idx.length, count: 0 };
|
||||
break;
|
||||
}
|
||||
case 'f': {
|
||||
const ids = parts.slice(1).map(corner);
|
||||
if (ids.some((i) => i < 0) || ids.length < 3) break;
|
||||
for (let k = 1; k + 1 < ids.length; k++) { idx.push(ids[0], ids[k], ids[k + 1]); group.count += 3; }
|
||||
break;
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
if (group.count > 0) groups.push(group);
|
||||
if (!idx.length) return null;
|
||||
const position = new Float32Array(outPos);
|
||||
const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
|
||||
for (let i = 0; i < position.length; i += 3) for (let a = 0; a < 3; a++) { min[a] = Math.min(min[a], position[i + a]); max[a] = Math.max(max[a], position[i + a]); }
|
||||
const haveNormals = outNrm.every((x) => Number.isFinite(x));
|
||||
return {
|
||||
position,
|
||||
normal: haveNormals ? new Float32Array(outNrm) : null,
|
||||
uv: vt.length ? new Float32Array(outUv) : null,
|
||||
color: hasColor ? new Float32Array(outCol) : null,
|
||||
index: position.length / 3 > 65535 ? new Uint32Array(idx) : new Uint16Array(idx),
|
||||
groups,
|
||||
bounds: { min, max },
|
||||
};
|
||||
}
|
||||
|
||||
/** Uniformly transform a parsed mesh: `fn(x, y, z) → [x, y, z]` for positions, `nfn` for normals (optional). */
|
||||
export function mapMesh(mesh, fn, nfn) {
|
||||
const p = mesh.position, pos = new Float32Array(p.length);
|
||||
for (let i = 0; i < p.length; i += 3) { const q = fn(p[i], p[i + 1], p[i + 2]); pos[i] = q[0]; pos[i + 1] = q[1]; pos[i + 2] = q[2]; }
|
||||
let normal = mesh.normal;
|
||||
if (normal && nfn) {
|
||||
const n = mesh.normal; normal = new Float32Array(n.length);
|
||||
for (let i = 0; i < n.length; i += 3) { const q = nfn(n[i], n[i + 1], n[i + 2]); normal[i] = q[0]; normal[i + 1] = q[1]; normal[i + 2] = q[2]; }
|
||||
}
|
||||
return { ...mesh, position: pos, normal };
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
// render/board3d/scene.js — the official 卫戍协议 board as a real three.js scene (DESIGN §15), rendered on its own
|
||||
// canvas UNDER the Pixi canvas (units, FX, highlights, HP bars stay in Pixi). One camera model drives both layers:
|
||||
// render/projection.js `syncThreeCamera` makes the three.js PerspectiveCamera project exactly like the Pixi one.
|
||||
//
|
||||
// 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.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
|
||||
// board.createDevice() → handle { update(cam, { x, y, z, size, height, top, side, alpha, rot }), mesh: null, destroy() }
|
||||
// (render/units.js DeviceView boxes, drawn as the official crate mesh)
|
||||
// board.flashObjective(r, c) leak flash of a blue objective box
|
||||
// board.render(cam, timeSec) sync the camera, animate, draw
|
||||
// board.resize(w, h, dpr) · board.stats() · board.destroy()
|
||||
//
|
||||
// Draw calls: board (tiles + blocks + platforms, merged, MT_autochess), the pen's glass hatches (glassMaterial),
|
||||
// static crates (merged, same material),
|
||||
// decals (MT_autochess_common), fence pipes, blowers (merged, unlit), gate boxes (additive + alpha, merged per
|
||||
// material), cyan field edges, terrain overlays (≤ 4), background plane + its shadow catcher — ~12–16 in total.
|
||||
// The key light's shadow map is rendered only when the geometry changes (autoUpdate off).
|
||||
|
||||
import { buildBoard, objToBoard, boxProjectUV, ROWS, COLS, DEVICE_H, AREAS } from './layout.js';
|
||||
import { surfaceUV } from './atlas.js';
|
||||
import {
|
||||
focusUniforms, makeTexture, boardMaterial, glassMaterial, decalMaterial, pipeMaterial, unlitMaterial, gateMaterial, glowMaterial,
|
||||
waterMaterial, mireMaterial, infectionMaterial, smogMaterial, dashTexture, environmentMap,
|
||||
} from './materials.js';
|
||||
import { syncThreeCamera } from '../projection.js';
|
||||
|
||||
/** Lighting rig (tuned against the official screenshots: bright even tops, darker sides, soft shadows). */
|
||||
export const LIGHTING = Object.freeze({
|
||||
key: { color: 0xfff1df, intensity: 2.6, dir: [-5.2, -3.4, 10] },
|
||||
hemi: { sky: 0xe4ecf4, ground: 0x4a5058, intensity: 0.6 },
|
||||
env: 1.0,
|
||||
emissive: 0.25,
|
||||
roughness: 0.78,
|
||||
shadow: { size: 2048, radius: 3, bias: -0.0004, normalBias: 0.025 },
|
||||
exposure: 1.0,
|
||||
// the clear colour is the fog colour: the far background fades into the void without a visible edge
|
||||
clear: 0x0c1114,
|
||||
fog: { color: 0x0c1114, near: 17, far: 36 },
|
||||
// S_Background_common (100 × 0.4316 = 43.16 tiles); `tiles` × `tiles` mirrored copies around it so no framing
|
||||
// (21:9, 4:3, portrait fit, camera flights) sees past its edge
|
||||
bg: { z: -8, size: 43.16, color: 0.713, dim: 0.8, tiles: 3 },
|
||||
});
|
||||
|
||||
/** Additive gain of the gate boxes at the curve's mean (tuned against the official screenshots). */
|
||||
export const GATE_GAIN = 0.6;
|
||||
|
||||
/** Gate pulse: the official clip's _TintColor.a curve (2 s loop, 0.134 → 0.229 → 0.134) scaled to our intensity. */
|
||||
export function gatePulse(t, phase = 0) {
|
||||
const k = 0.5 - 0.5 * Math.cos(((t / 2 + phase) % 1) * Math.PI * 2);
|
||||
return 0.134 + (0.229 - 0.134) * k;
|
||||
}
|
||||
|
||||
const areaKey = (list) => (list || []).map((a) => `${a.r0},${a.r1},${a.c0},${a.c1}`).sort().join(';');
|
||||
|
||||
const mergeInto = (list) => {
|
||||
// concatenate { position, normal, uv, color?, index } records
|
||||
let nv = 0, ni = 0;
|
||||
for (const g of list) { nv += g.position.length / 3; ni += g.index.length; }
|
||||
const position = new Float32Array(nv * 3), normal = new Float32Array(nv * 3), uv = new Float32Array(nv * 2), color = new Float32Array(nv * 3);
|
||||
const index = nv > 65535 ? new Uint32Array(ni) : new Uint16Array(ni);
|
||||
let vo = 0, io = 0;
|
||||
for (const g of list) {
|
||||
const n = g.position.length / 3;
|
||||
position.set(g.position, vo * 3);
|
||||
if (g.normal) normal.set(g.normal, vo * 3);
|
||||
if (g.uv) uv.set(g.uv, vo * 2);
|
||||
if (g.color) color.set(g.color, vo * 3); else color.fill(1, vo * 3, (vo + n) * 3);
|
||||
for (let i = 0; i < g.index.length; i++) index[io + i] = g.index[i] + vo;
|
||||
vo += n; io += g.index.length;
|
||||
}
|
||||
return { position, normal, uv, color, index };
|
||||
};
|
||||
|
||||
/** Apply a 4×4-free transform (scale s, rotate about z by `rot` quarter turns, translate) to board-space data. */
|
||||
function placeMesh(src, { x = 0, y = 0, z = 0, s = 1, sz = s, rot = 0 }) {
|
||||
const p = src.position, n = src.normal;
|
||||
const pos = new Float32Array(p.length), nrm = n ? new Float32Array(n.length) : null;
|
||||
const a = rot * Math.PI / 2, ca = Math.round(Math.cos(a)), sa = Math.round(Math.sin(a));
|
||||
for (let i = 0; i < p.length; i += 3) {
|
||||
const px = p[i] * s, py = p[i + 1] * s;
|
||||
pos[i] = x + px * ca - py * sa; pos[i + 1] = y + px * sa + py * ca; pos[i + 2] = z + p[i + 2] * sz;
|
||||
if (nrm) { nrm[i] = n[i] * ca - n[i + 1] * sa; nrm[i + 1] = n[i] * sa + n[i + 1] * ca; nrm[i + 2] = n[i + 2]; }
|
||||
}
|
||||
return { ...src, position: pos, normal: nrm };
|
||||
}
|
||||
|
||||
/**
|
||||
* Grow a textured plane `k`× about (cx, cy) keeping its texel density: positions scale by k, UVs scale by k about
|
||||
* the texture centre (0..1 → (1−k)/2..(1+k)/2), so a MirroredRepeat texture tiles seamlessly around the original.
|
||||
*/
|
||||
export function extendPlane(src, cx, cy, k = 1) {
|
||||
if (!(k > 1) || !src || !src.position) return src;
|
||||
const p = src.position, pos = new Float32Array(p.length);
|
||||
for (let i = 0; i < p.length; i += 3) { pos[i] = cx + (p[i] - cx) * k; pos[i + 1] = cy + (p[i + 1] - cy) * k; pos[i + 2] = p[i + 2]; }
|
||||
let uv = src.uv;
|
||||
if (uv) { uv = new Float32Array(src.uv.length); for (let i = 0; i < uv.length; i++) uv[i] = 0.5 + (src.uv[i] - 0.5) * k; }
|
||||
return { ...src, position: pos, uv };
|
||||
}
|
||||
|
||||
/** Quarter turns (counter-clockwise about +z) that point the wind device's outlet (−x in the mesh) along `dir`. */
|
||||
export const DIR_TURNS = Object.freeze({ LEFT: 0, DOWN: 1, RIGHT: 2, UP: 3 });
|
||||
|
||||
export class BoardScene {
|
||||
/**
|
||||
* @param {any} THREE three.js module
|
||||
* @param {any} pack load.js loadBoardPack result
|
||||
* @param {{ canvas?: HTMLCanvasElement, quality?: string, antialias?: boolean, renderer?: any }} [opts]
|
||||
*/
|
||||
constructor(THREE, pack, opts = {}) {
|
||||
this.THREE = THREE;
|
||||
this.pack = pack;
|
||||
this.opts = opts;
|
||||
this.destroyed = false;
|
||||
this.lost = false;
|
||||
const T = THREE;
|
||||
this.renderer = opts.renderer || new T.WebGLRenderer({
|
||||
canvas: opts.canvas, antialias: opts.antialias ?? true, alpha: false, stencil: false, depth: true,
|
||||
powerPreference: 'high-performance', preserveDrawingBuffer: !!opts.preserveDrawingBuffer,
|
||||
});
|
||||
const R = this.renderer;
|
||||
R.outputColorSpace = T.SRGBColorSpace;
|
||||
R.toneMapping = T.NoToneMapping;
|
||||
R.setClearColor(LIGHTING.clear, 1);
|
||||
R.shadowMap.enabled = opts.shadows !== false;
|
||||
R.shadowMap.type = T.PCFShadowMap;
|
||||
R.shadowMap.autoUpdate = false;
|
||||
this.canvas = R.domElement;
|
||||
this._onLost = (e) => { e.preventDefault?.(); this.lost = true; };
|
||||
this.canvas.addEventListener?.('webglcontextlost', this._onLost);
|
||||
|
||||
this.scene = new T.Scene();
|
||||
this.scene.fog = new T.Fog(LIGHTING.fog.color, LIGHTING.fog.near, LIGHTING.fog.far);
|
||||
this.camera = new T.PerspectiveCamera(40, 16 / 9, 0.5, 120);
|
||||
this.focus = focusUniforms(T);
|
||||
if (opts.environment !== false) {
|
||||
try {
|
||||
this.envMap = environmentMap(T, R);
|
||||
if (this.envMap) {
|
||||
this.scene.environment = this.envMap;
|
||||
this.scene.environmentIntensity = LIGHTING.env;
|
||||
this.scene.environmentRotation?.set?.(-Math.PI / 2, 0, 0);
|
||||
}
|
||||
} catch { this.envMap = null; }
|
||||
}
|
||||
this.focusTarget = null;
|
||||
const aniso = Math.min(8, R.capabilities?.getMaxAnisotropy?.() || 1);
|
||||
const img = pack?.images || {};
|
||||
this.tex = {
|
||||
D: makeTexture(T, img.D, { aniso }), N: makeTexture(T, img.N, { srgb: false, aniso }), R: makeTexture(T, img.R, { srgb: false, aniso }),
|
||||
E: makeTexture(T, img.E, { aniso }), common: makeTexture(T, img.common, { aniso }), commonE: makeTexture(T, img.commonE, { aniso }),
|
||||
BG: makeTexture(T, img.BG, { aniso }), wind: makeTexture(T, img.wind, { aniso }), gate: makeTexture(T, img.gate, {}),
|
||||
waterN: makeTexture(T, img.waterN, { srgb: false, repeat: true }), caustics: makeTexture(T, img.caustics, { srgb: false, repeat: true }),
|
||||
noise: makeTexture(T, img.noise, { srgb: false, repeat: true }),
|
||||
};
|
||||
if (this.tex.BG) { this.tex.BG.wrapS = T.MirroredRepeatWrapping; this.tex.BG.wrapT = T.MirroredRepeatWrapping; }
|
||||
this.mat = {
|
||||
board: boardMaterial(T, this.tex, this.focus, { emissive: LIGHTING.emissive, roughness: LIGHTING.roughness }),
|
||||
glass: glassMaterial(T, this.tex, this.focus, { emissive: LIGHTING.emissive, roughness: LIGHTING.roughness }),
|
||||
decal: this.tex.common ? decalMaterial(T, this.tex, this.focus) : null,
|
||||
pipe: pipeMaterial(T, this.focus),
|
||||
bg: this.tex.BG ? unlitMaterial(T, this.tex.BG, new T.Color().setScalar(LIGHTING.bg.color * LIGHTING.bg.dim)) : new T.MeshBasicMaterial({ color: 0x1a1d1f }),
|
||||
wind: this.tex.wind ? unlitMaterial(T, this.tex.wind) : null,
|
||||
gateStartAdd: this.tex.gate ? gateMaterial(T, this.tex.gate, { additive: true }) : null,
|
||||
gateEndAdd: this.tex.gate ? gateMaterial(T, this.tex.gate, { additive: true }) : null,
|
||||
gateEndAb: this.tex.gate ? gateMaterial(T, this.tex.gate, { additive: false }) : null,
|
||||
edge: glowMaterial(T, dashTexture(T)),
|
||||
water: waterMaterial(T, this.tex, this.focus),
|
||||
mire: mireMaterial(T, this.tex, this.focus),
|
||||
infection: infectionMaterial(T, this.tex, this.focus),
|
||||
smog: smogMaterial(T, this.tex, this.focus),
|
||||
shadowCatcher: new T.ShadowMaterial({ opacity: 0.32, color: 0x000000 }),
|
||||
};
|
||||
this.mat.crateFade = null;
|
||||
// lights
|
||||
const hemi = new T.HemisphereLight(LIGHTING.hemi.sky, LIGHTING.hemi.ground, LIGHTING.hemi.intensity);
|
||||
hemi.position.set(0, 0, 1);
|
||||
this.scene.add(hemi);
|
||||
const key = new T.DirectionalLight(LIGHTING.key.color, LIGHTING.key.intensity);
|
||||
key.castShadow = R.shadowMap.enabled;
|
||||
key.shadow.mapSize.set(LIGHTING.shadow.size, LIGHTING.shadow.size);
|
||||
key.shadow.radius = LIGHTING.shadow.radius;
|
||||
key.shadow.bias = LIGHTING.shadow.bias;
|
||||
key.shadow.normalBias = LIGHTING.shadow.normalBias;
|
||||
this.scene.add(key, key.target);
|
||||
this.key = key;
|
||||
this.hemi = hemi;
|
||||
// static board geometry (rebuilt by setStage / setArea) and the battle device meshes (owned by render/units.js
|
||||
// DeviceView handles: they must survive a rebuild — a camera flight between areas rebuilds mid-battle)
|
||||
this.root = new T.Group();
|
||||
this.dynamic = new T.Group();
|
||||
this.scene.add(this.root, this.dynamic);
|
||||
this.stageKey = null;
|
||||
this.stage = null;
|
||||
this.area = AREAS.normal;
|
||||
this.areaKey = areaKey(AREAS.normal);
|
||||
this.battleRect = null;
|
||||
this.devices = new Set();
|
||||
this.flashes = [];
|
||||
this.time = 0;
|
||||
this.size = { w: 1, h: 1, dpr: 1 };
|
||||
this.camVersion = -1;
|
||||
this.camRef = null;
|
||||
this.frames = 0;
|
||||
this.lastMs = 0;
|
||||
this._crateGeom = null;
|
||||
}
|
||||
|
||||
// ---- geometry ----------------------------------------------------------------------------------------------
|
||||
|
||||
_geometry(data) {
|
||||
const T = this.THREE;
|
||||
const g = new T.BufferGeometry();
|
||||
g.setAttribute('position', new T.BufferAttribute(data.position, 3));
|
||||
if (data.normal) g.setAttribute('normal', new T.BufferAttribute(data.normal, 3));
|
||||
if (data.uv) g.setAttribute('uv', new T.BufferAttribute(data.uv, 2));
|
||||
if (data.color) g.setAttribute('color', new T.BufferAttribute(data.color, 3));
|
||||
g.setIndex(new T.BufferAttribute(data.index, 1));
|
||||
if (!data.normal) g.computeVertexNormals();
|
||||
g.computeBoundingSphere();
|
||||
return g;
|
||||
}
|
||||
|
||||
_mesh(data, material, { cast = true, receive = true, order = 0 } = {}) {
|
||||
if (!data || !material || !(data.index?.length > 0)) return null;
|
||||
const m = new this.THREE.Mesh(this._geometry(data), material);
|
||||
m.castShadow = cast; m.receiveShadow = receive; m.renderOrder = order;
|
||||
m.matrixAutoUpdate = false; m.updateMatrix();
|
||||
this.root.add(m);
|
||||
return m;
|
||||
}
|
||||
|
||||
_clear() {
|
||||
for (const ch of [...this.root.children]) {
|
||||
this.root.remove(ch);
|
||||
ch.traverse?.((o) => { if (o.geometry) o.geometry.dispose(); });
|
||||
}
|
||||
this.meshes = {};
|
||||
}
|
||||
|
||||
/** The crate mesh in board space (s_common_box_01 when loaded, else a unit chamfer-free box), UVs on D. */
|
||||
crateGeometry() {
|
||||
if (this._crateGeom) return this._crateGeom;
|
||||
const uv = this.pack?.uv || {};
|
||||
const obj = this.pack?.meshes?.crate;
|
||||
let base;
|
||||
if (obj && obj.normal) base = objToBoard(obj, 0.01);
|
||||
else base = boxData(0.9, 0.675);
|
||||
this._crateGeom = boxProjectUV(base, uv.crateTop || { src: 'D', rect: [1222, 1938, 134, 108] }, uv.crateSide || { src: 'D', rect: [1087, 1938, 134, 108] });
|
||||
this._crateGeom.color = null;
|
||||
return this._crateGeom;
|
||||
}
|
||||
|
||||
/** Built areas (inclusive tile rects): rebuilds when they change. */
|
||||
setArea(rects) {
|
||||
const list = Array.isArray(rects) && rects.length ? rects : AREAS.normal;
|
||||
const k = areaKey(list);
|
||||
if (k === this.areaKey) return false;
|
||||
this.area = list;
|
||||
this.areaKey = k;
|
||||
if (this.stage) { this.stageKey = null; this.setStage(this.stage); }
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 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]))}` : '';
|
||||
if (key === this.stageKey) return;
|
||||
this.stageKey = key;
|
||||
this.stage = stage || null;
|
||||
this._clear();
|
||||
if (!stage) return;
|
||||
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._buildDevices(board);
|
||||
this._buildGates(board);
|
||||
this._buildEdges(board);
|
||||
this._buildTerrain(board);
|
||||
this._buildBackground(board);
|
||||
this._fitShadow(board);
|
||||
this.renderer.shadowMap.needsUpdate = true;
|
||||
}
|
||||
|
||||
_buildDevices(board) {
|
||||
const crates = [], blowers = [];
|
||||
this.staticCrates = [];
|
||||
const wind = this.pack?.meshes?.blower ? objToBoard(this.pack.meshes.blower, 1) : null;
|
||||
for (const d of board.devices) {
|
||||
if (d.kind === 'crate') this.staticCrates.push(d);
|
||||
else if (d.kind === 'blower' && wind && this.mat.wind) blowers.push(placeMesh(wind, { x: d.c, y: d.r, z: d.z0 + 0.078, rot: DIR_TURNS[d.dir] ?? 0 }));
|
||||
else if (d.kind === 'blower' || d.kind === 'turret') crates.push(placeMesh(boxWithTop(this.pack?.uv, d.kind), { x: d.c, y: d.r, z: d.z0 }));
|
||||
else if (d.kind === 'mound') crates.push(placeMesh(this.crateGeometry(), { x: d.c, y: d.r, z: d.z0, s: 0.95, sz: 0.55 }));
|
||||
else if (d.kind === 'bush') crates.push(placeMesh(bushData(), { x: d.c, y: d.r, z: d.z0 }));
|
||||
}
|
||||
if (blowers.length) this.meshes.blowers = this._mesh(mergeInto(blowers), this.mat.wind, { receive: false });
|
||||
if (crates.length) this.meshes.props = this._mesh(mergeInto(crates), this.mat.board);
|
||||
this._rebuildCrates();
|
||||
}
|
||||
|
||||
_rebuildCrates() {
|
||||
if (this.meshes.crates) { this.root.remove(this.meshes.crates); this.meshes.crates.geometry.dispose(); this.meshes.crates = null; }
|
||||
const R = this.battleRect;
|
||||
const inside = (d) => !!R && d.r >= R.r0 && d.r <= R.r1 && d.c >= R.c0 && d.c <= R.c1;
|
||||
const list = (this.staticCrates || []).filter((d) => !inside(d)).map((d) => placeMesh(this.crateGeometry(), { x: d.c, y: d.r, z: d.z0 }));
|
||||
if (list.length) this.meshes.crates = this._mesh(mergeInto(list), this.mat.board);
|
||||
this.renderer.shadowMap.needsUpdate = true;
|
||||
}
|
||||
|
||||
/** Battle rect (null in prep): static crates inside it are hidden (the sim spawns them as device units). */
|
||||
setBattleRect(rect) {
|
||||
const next = rect ? { r0: rect.r0, r1: rect.r1, c0: rect.c0, c1: rect.c1 } : null;
|
||||
if (JSON.stringify(next) === JSON.stringify(this.battleRect)) return;
|
||||
this.battleRect = next;
|
||||
if (this.board) this._rebuildCrates();
|
||||
}
|
||||
|
||||
_buildGates(board) {
|
||||
const G = this.pack?.meshes?.gate || {};
|
||||
if (!this.tex.gate) return;
|
||||
const place = (slot) => {
|
||||
const rec = G[slot];
|
||||
if (!rec) return null;
|
||||
return objToBoard(rec.mesh, 0.01);
|
||||
};
|
||||
const parts = { startDown: place('startDown'), startUp: place('startUp'), startBack: place('startBack'), endDown: place('endDown'), endUp: place('endUp') };
|
||||
const startAdd = [], endAdd = [], endAb = [];
|
||||
for (const g of board.gates) {
|
||||
// the tile effect's prefab rotation (0, .707, −.707, 0) maps the mesh to (x', −z', y') of the exported OBJ:
|
||||
// objToBoard's frame turned half a turn — the warning glyph on the top face points north (the official look)
|
||||
const at = { x: g.c, y: g.r, z: g.z + 0.5 + 0.003, rot: 2 };
|
||||
if (g.kind === 'start') for (const k of ['startDown', 'startUp', 'startBack']) { if (parts[k]) startAdd.push(placeMesh(parts[k], at)); }
|
||||
else {
|
||||
if (parts.endDown) endAdd.push(placeMesh(parts.endDown, at));
|
||||
if (parts.endUp) endAb.push(placeMesh(parts.endUp, at));
|
||||
}
|
||||
}
|
||||
const opt = { cast: false, receive: false, order: 5 };
|
||||
if (startAdd.length) this.meshes.gateStart = this._mesh(mergeInto(startAdd), this.mat.gateStartAdd, opt);
|
||||
if (endAdd.length) this.meshes.gateEndAdd = this._mesh(mergeInto(endAdd), this.mat.gateEndAdd, opt);
|
||||
if (endAb.length) this.meshes.gateEndAb = this._mesh(mergeInto(endAb), this.mat.gateEndAb, { ...opt, order: 4 });
|
||||
}
|
||||
|
||||
_buildEdges(board) {
|
||||
const pos = [], uv = [], idx = [];
|
||||
const w = 0.085, e = 0.018;
|
||||
for (const s of board.edges) {
|
||||
const x0 = s.c - 0.5, x1 = s.c + 0.5, y0 = s.r - 0.5, y1 = s.r + 0.5, z = s.z + 0.006;
|
||||
let q;
|
||||
switch (s.dir) {
|
||||
case 'S': q = [[x0, y0 + e, z], [x1, y0 + e, z], [x1, y0 + e + w, z], [x0, y0 + e + w, z]]; break;
|
||||
case 'N': q = [[x1, y1 - e, z], [x0, y1 - e, z], [x0, y1 - e - w, z], [x1, y1 - e - w, z]]; break;
|
||||
case 'E': q = [[x1 - e, y0, z], [x1 - e, y1, z], [x1 - e - w, y1, z], [x1 - e - w, y0, z]]; break;
|
||||
default: q = [[x0 + e, y1, z], [x0 + e, y0, z], [x0 + e + w, y0, z], [x0 + e + w, y1, z]]; break;
|
||||
}
|
||||
const b = pos.length / 3;
|
||||
for (const p of q) pos.push(...p);
|
||||
uv.push(0, 0, 1, 0, 1, 1, 0, 1);
|
||||
// winding: all strips face up
|
||||
const n = [(q[1][0] - q[0][0]) * (q[3][1] - q[0][1]) - (q[1][1] - q[0][1]) * (q[3][0] - q[0][0])];
|
||||
if (n[0] >= 0) idx.push(b, b + 1, b + 2, b, b + 2, b + 3); else idx.push(b, b + 2, b + 1, b, b + 3, b + 2);
|
||||
}
|
||||
if (!pos.length) return;
|
||||
const nrm = new Float32Array(pos.length); for (let i = 2; i < nrm.length; i += 3) nrm[i] = 1;
|
||||
this.meshes.edges = this._mesh({ position: new Float32Array(pos), normal: nrm, uv: new Float32Array(uv), color: null, index: new Uint16Array(idx) }, this.mat.edge, { cast: false, receive: false, order: 3 });
|
||||
}
|
||||
|
||||
_buildTerrain(board) {
|
||||
const quads = (tiles, z, inset = 0) => {
|
||||
const pos = [], uv = [], idx = [];
|
||||
for (const [r, c] of tiles) {
|
||||
const a = 0.5 - inset, b = pos.length / 3;
|
||||
pos.push(c - a, r - a, z, c + a, r - a, z, c + a, r + a, z, c - a, r + a, z);
|
||||
uv.push(0, 0, 1, 0, 1, 1, 0, 1);
|
||||
idx.push(b, b + 1, b + 2, b, b + 2, b + 3);
|
||||
}
|
||||
const nrm = new Float32Array(pos.length); for (let i = 2; i < nrm.length; i += 3) nrm[i] = 1;
|
||||
return { position: new Float32Array(pos), normal: nrm, uv: new Float32Array(uv), color: null, index: new Uint16Array(idx) };
|
||||
};
|
||||
const T = board.terrain;
|
||||
const opt = { cast: false, receive: false, order: 2 };
|
||||
if (T.water.length) this.meshes.water = this._mesh(quads(T.water, -0.035), this.mat.water, opt);
|
||||
if (T.mire.length) this.meshes.mire = this._mesh(quads(T.mire, 0.006, 0.02), this.mat.mire, opt);
|
||||
if (T.infection.length) this.meshes.infection = this._mesh(quads(T.infection, 0.007, 0.02), this.mat.infection, opt);
|
||||
if (T.smog.length) {
|
||||
// two crossed haze cards per grille tile, standing up
|
||||
const pos = [], uv = [], idx = [];
|
||||
for (const [r, c] of T.smog) {
|
||||
for (const [dx, dy] of [[1, 0], [0.7, 0.7]]) {
|
||||
const b = pos.length / 3, h = 0.95, w = 0.55;
|
||||
pos.push(c - dx * w, r - dy * w, 0.02, c + dx * w, r + dy * w, 0.02, c + dx * w, r + dy * w, h, c - dx * w, r - dy * w, h);
|
||||
uv.push(0, 0, 1, 0, 1, 1, 0, 1);
|
||||
idx.push(b, b + 1, b + 2, b, b + 2, b + 3);
|
||||
}
|
||||
}
|
||||
const nrm = new Float32Array(pos.length); for (let i = 1; i < nrm.length; i += 3) nrm[i] = -1;
|
||||
this.meshes.smog = this._mesh({ position: new Float32Array(pos), normal: nrm, uv: new Float32Array(uv), color: null, index: new Uint16Array(idx) }, this.mat.smog, { ...opt, order: 6 });
|
||||
}
|
||||
}
|
||||
|
||||
_buildBackground(board) {
|
||||
const T = this.THREE;
|
||||
const B = LIGHTING.bg;
|
||||
const cx = (COLS - 1) / 2, cy = (ROWS - 1) / 2;
|
||||
let plane = null;
|
||||
const obj = this.pack?.meshes?.bgPlane;
|
||||
if (obj) plane = objToBoard(obj, B.size / 100);
|
||||
const data = plane || { position: new Float32Array([-B.size / 2, -B.size / 2, 0, B.size / 2, -B.size / 2, 0, B.size / 2, B.size / 2, 0, -B.size / 2, B.size / 2, 0]), normal: new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1]), uv: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]), index: new Uint16Array([0, 1, 2, 0, 2, 3]) };
|
||||
const placed = extendPlane(placeMesh(data, { x: cx, y: cy, z: B.z }), cx, cy, B.tiles);
|
||||
// the plane may face either way after the axis swap: make it face up
|
||||
if (placed.normal && placed.normal[2] < 0) {
|
||||
for (let i = 0; i < placed.normal.length; i++) placed.normal[i] = -placed.normal[i];
|
||||
const ix = placed.index;
|
||||
for (let i = 0; i < ix.length; i += 3) { const t = ix[i + 1]; ix[i + 1] = ix[i + 2]; ix[i + 2] = t; }
|
||||
}
|
||||
this.meshes.bg = this._mesh({ ...placed, color: null }, this.mat.bg, { cast: false, receive: false, order: -2 });
|
||||
// the key light's shadow of the island on the ground far below (S_Background_shadow's role)
|
||||
const s = B.size;
|
||||
const q = { position: new Float32Array([cx - s / 2, cy - s / 2, B.z + 0.02, cx + s / 2, cy - s / 2, B.z + 0.02, cx + s / 2, cy + s / 2, B.z + 0.02, cx - s / 2, cy + s / 2, B.z + 0.02]), normal: new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1]), uv: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]), color: null, index: new Uint16Array([0, 1, 2, 0, 2, 3]) };
|
||||
this.meshes.bgShadow = this._mesh(q, this.mat.shadowCatcher, { cast: false, receive: true, order: -1 });
|
||||
void T;
|
||||
}
|
||||
|
||||
_fitShadow(board) {
|
||||
const k = this.key;
|
||||
const b = board.bounds;
|
||||
const cx = (b.x0 + b.x1) / 2, cy = (b.y0 + b.y1) / 2;
|
||||
const d = LIGHTING.key.dir;
|
||||
const len = Math.hypot(d[0], d[1], d[2]);
|
||||
const dist = 30;
|
||||
k.position.set(cx + (d[0] / len) * dist, cy + (d[1] / len) * dist, (d[2] / len) * dist);
|
||||
k.target.position.set(cx, cy, 0);
|
||||
k.target.updateMatrixWorld();
|
||||
const cam = k.shadow.camera;
|
||||
// light-space box covering the island and its shadow on the background plane
|
||||
const half = Math.max(b.x1 - b.x0, b.y1 - b.y0) / 2 + 9;
|
||||
cam.left = -half; cam.right = half; cam.top = half; cam.bottom = -half;
|
||||
cam.near = 1; cam.far = dist + 30;
|
||||
cam.updateProjectionMatrix();
|
||||
k.updateMatrixWorld();
|
||||
}
|
||||
|
||||
// ---- dynamic devices (battle) ------------------------------------------------------------------------------
|
||||
|
||||
/** A crate / device box driven by render/units.js DeviceView (see header). */
|
||||
createDevice() {
|
||||
const T = this.THREE;
|
||||
const self = this;
|
||||
const geom = this._geometry({ ...this.crateGeometry(), color: null });
|
||||
const mat = this.mat.board.clone();
|
||||
mat.transparent = true;
|
||||
mat.vertexColors = false;
|
||||
// keep the focus falloff on the clone
|
||||
mat.onBeforeCompile = this.mat.board.onBeforeCompile;
|
||||
const mesh = new T.Mesh(geom, mat);
|
||||
mesh.castShadow = true; mesh.receiveShadow = true;
|
||||
this.dynamic.add(mesh);
|
||||
const bx = this.crateGeometry().bounds || { x0: -0.45, x1: 0.45, z0: 0, z1: 0.675 };
|
||||
const baseSize = Math.max(0.01, bx.x1 - bx.x0), baseH = Math.max(0.01, bx.z1 - bx.z0);
|
||||
let dead = false;
|
||||
const h = {
|
||||
mesh: null,
|
||||
board: this,
|
||||
get destroyed() { return dead; },
|
||||
update(cam, b) {
|
||||
if (!b || dead) return;
|
||||
mesh.visible = (b.alpha ?? 1) > 0.01;
|
||||
mesh.position.set(b.x, b.y, b.z);
|
||||
const s = (b.size || baseSize) / baseSize;
|
||||
mesh.scale.set(s, s, Math.max(0.001, (b.height ?? baseH) / baseH));
|
||||
mat.opacity = Math.max(0, Math.min(1, b.alpha ?? 1));
|
||||
mat.depthWrite = mat.opacity > 0.98;
|
||||
},
|
||||
destroy() {
|
||||
if (dead) return;
|
||||
dead = true;
|
||||
self.dynamic.remove(mesh); geom.dispose(); mat.dispose(); self.devices.delete(h);
|
||||
if (!self.destroyed) self.renderer.shadowMap.needsUpdate = true;
|
||||
},
|
||||
};
|
||||
this.devices.add(h);
|
||||
this.renderer.shadowMap.needsUpdate = true;
|
||||
return h;
|
||||
}
|
||||
|
||||
/** Red leak flash on the objective box at (r, c). */
|
||||
flashObjective(r, c) {
|
||||
this.flashes.push({ r, c, t: 0 });
|
||||
if (this.flashes.length > 8) this.flashes.shift();
|
||||
}
|
||||
|
||||
// ---- view ------------------------------------------------------------------------------------------------------
|
||||
|
||||
/** The lit field rect ({ r0, r1, c0, c1 } tiles; null = everything lit). Animated in render(). */
|
||||
setFocus(rect) {
|
||||
this.focusTarget = rect ? [rect.c0 - 0.5, rect.r0 - 0.5, rect.c1 + 0.5, rect.r1 + 0.5] : [-50, -50, 70, 70];
|
||||
}
|
||||
|
||||
/** Settings quality change: 'low' drops the key light's shadow (antialias is fixed at context creation). */
|
||||
setQuality(q) {
|
||||
const on = q !== 'low';
|
||||
const R = this.renderer;
|
||||
if (R.shadowMap.enabled === on && this.key.castShadow === on) return false;
|
||||
R.shadowMap.enabled = on;
|
||||
this.key.castShadow = on;
|
||||
R.shadowMap.needsUpdate = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
resize(w, h, dpr = 1) {
|
||||
const W = Math.max(1, Math.round(w)), H = Math.max(1, Math.round(h));
|
||||
if (W === this.size.w && H === this.size.h && dpr === this.size.dpr) return;
|
||||
this.size = { w: W, h: H, dpr };
|
||||
this.renderer.setPixelRatio(dpr);
|
||||
this.renderer.setSize(W, H, false);
|
||||
this.camVersion = -1;
|
||||
}
|
||||
|
||||
/** Draw a frame with projection camera `cam` at time `t` (seconds). */
|
||||
render(cam, t = 0) {
|
||||
if (this.destroyed || this.lost || !cam) return false;
|
||||
const t0 = typeof performance !== 'undefined' ? performance.now() : 0;
|
||||
const dt = Math.max(0, Math.min(0.1, t - this.time));
|
||||
this.time = t;
|
||||
if (cam !== this.camRef || cam.version !== this.camVersion) {
|
||||
this.camRef = cam; this.camVersion = cam.version;
|
||||
syncThreeCamera(cam, this.camera, this.size.w, this.size.h, { near: Math.max(0.3, cam.dist * 0.08), far: cam.dist + 70 });
|
||||
}
|
||||
// focus falloff (eased)
|
||||
const F = this.focus.uFocus.value;
|
||||
if (this.focusTarget) {
|
||||
const k = Math.min(1, dt * 5);
|
||||
const tg = this.focusTarget;
|
||||
if (Math.abs(F.x - tg[0]) + Math.abs(F.y - tg[1]) + Math.abs(F.z - tg[2]) + Math.abs(F.w - tg[3]) > 40) F.set(tg[0], tg[1], tg[2], tg[3]);
|
||||
else F.set(F.x + (tg[0] - F.x) * k, F.y + (tg[1] - F.y) * k, F.z + (tg[2] - F.z) * k, F.w + (tg[3] - F.w) * k);
|
||||
}
|
||||
// gate pulses (official 2 s curve → additive intensity), objective leak flashes
|
||||
const pulse = gatePulse(t);
|
||||
if (this.mat.gateStartAdd) this.mat.gateStartAdd.uniforms.uPulse.value = GATE_GAIN * pulse / 0.18;
|
||||
if (this.mat.gateEndAdd) this.mat.gateEndAdd.uniforms.uPulse.value = GATE_GAIN * gatePulse(t, 0.5) / 0.18;
|
||||
if (this.mat.gateEndAb) this.mat.gateEndAb.uniforms.uPulse.value = 0.9;
|
||||
let flash = 0;
|
||||
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;
|
||||
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;
|
||||
return true;
|
||||
}
|
||||
|
||||
stats() {
|
||||
const info = this.renderer.info;
|
||||
return { calls: info?.render?.calls ?? 0, triangles: info?.render?.triangles ?? 0, textures: info?.memory?.textures ?? 0, geometries: info?.memory?.geometries ?? 0, frames: this.frames, cpuMs: Math.round(this.lastMs * 100) / 100, lost: this.lost };
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
for (const d of [...this.devices]) d.destroy();
|
||||
this._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?.();
|
||||
this.envMap?.dispose?.();
|
||||
this.canvas.removeEventListener?.('webglcontextlost', this._onLost);
|
||||
try { this.renderer.dispose(); } catch { /* ignore */ }
|
||||
try { this.renderer.forceContextLoss?.(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- small procedural meshes (fallbacks / rare devices) ------------------------------------------------------------
|
||||
|
||||
/** Axis-aligned box on the ground, centred on the origin: size × size × height. */
|
||||
export function boxData(size, height) {
|
||||
const a = size / 2, pos = [], nrm = [], uv = [], idx = [];
|
||||
const faces = [
|
||||
[[0, 0, 1], [[-a, -a, height], [a, -a, height], [a, a, height], [-a, a, height]]],
|
||||
[[0, -1, 0], [[-a, -a, 0], [a, -a, 0], [a, -a, height], [-a, -a, height]]],
|
||||
[[1, 0, 0], [[a, -a, 0], [a, a, 0], [a, a, height], [a, -a, height]]],
|
||||
[[0, 1, 0], [[a, a, 0], [-a, a, 0], [-a, a, height], [a, a, height]]],
|
||||
[[-1, 0, 0], [[-a, a, 0], [-a, -a, 0], [-a, -a, height], [-a, a, height]]],
|
||||
];
|
||||
for (const [n, q] of faces) {
|
||||
const b = pos.length / 3;
|
||||
for (const p of q) { pos.push(...p); nrm.push(...n); }
|
||||
uv.push(0, 0, 1, 0, 1, 1, 0, 1);
|
||||
idx.push(b, b + 1, b + 2, b, b + 2, b + 3);
|
||||
}
|
||||
return { position: new Float32Array(pos), normal: new Float32Array(nrm), uv: new Float32Array(uv), index: new Uint16Array(idx) };
|
||||
}
|
||||
|
||||
/** Device box with an atlas top (blower fallback ▶▶ hatch, turret target plate) and grey sides. */
|
||||
function boxWithTop(uvTable, kind) {
|
||||
const size = kind === 'turret' ? 0.72 : 0.86, h = kind === 'turret' ? 0.34 : DEVICE_H.platform;
|
||||
const b = boxData(size, h);
|
||||
const top = surfaceUV(uvTable?.mech || { src: 'D', rect: [1088, 708, 356, 310] });
|
||||
const side = surfaceUV(uvTable?.graySide || { src: 'D', rect: [272, 1664, 272, 120] });
|
||||
const uv = new Float32Array(b.uv.length);
|
||||
for (let f = 0; f < 5; f++) uv.set(f === 0 ? top : side, f * 8);
|
||||
return { ...b, uv, color: null };
|
||||
}
|
||||
|
||||
/** Low green blob (act1 m07 bushes: inactive in 盟约, kept for completeness). */
|
||||
function bushData() {
|
||||
const b = boxData(0.7, 0.32);
|
||||
return { ...b, uv: new Float32Array(b.uv.length).fill(0.02), color: new Float32Array((b.position.length / 3) * 3).fill(0).map((_, i) => [0.25, 0.48, 0.22][i % 3]) };
|
||||
}
|
||||
Reference in new issue
Block a user