- act2 m01: the boss field's row-6 blower machines stand under the bench in the normal and 联防 views again
(the user: "吹风机原版道中也该有"); the integration-QA filter (BOSS_WALL_ROW) is removed from the 3D and 2D
boards, so every device on a built tile is drawn in every view, as the workstream had it
- 联防: a knocked-out operator's redeploy timer restarting in full is confirmed by the user ("进联防复活时间确实是
重新算"); the [ASSUMED] tags in DESIGN §5.5 / §6.1 / §19.3, META §4 and unite.js say so
- DESIGN §19 intro / §19.7 and the docs-consistency assertions updated; 2595 unit tests, all render browser suites green
576 lines
31 KiB
JavaScript
576 lines
31 KiB
JavaScript
// 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, area?: AREAS.normal })
|
||
// → { 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 }
|
||
// `devices` = the active stage devices standing on a built tile — separator walls included (act2 m01's blowers: the
|
||
// row-13 ones at the top edge of the normal / 联防 field, the row-6 ones at its bottom edge and at the top edge of the
|
||
// Final Assault field — user playtest #5 item 6 and its follow-up).
|
||
//
|
||
// 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.
|
||
* The normal / 联防 field rects also take the separator row 13 above the field (like leftBoss takes row 6 above the
|
||
* boss field): the devices standing on it act on the field — act2 m01's 源石流发生装置 #001/#002 (#101/#102) at (13, 5 /
|
||
* 9 / 13 / 17) blow DOWN into rows 12–10 — and the official normal rounds show those machines at the field's top edge
|
||
* (user playtest #5 item 6: the wind lanes worked but the blowers were missing; the 2D board always drew row 13). The
|
||
* row-6 wall under the bench is built in these views too (in leftNormal), with the boss field's blowers standing on it.
|
||
*/
|
||
export const AREAS = Object.freeze({
|
||
normal: Object.freeze([Object.freeze({ r0: 6, r1: 13, c0: 0, c1: 10 }), Object.freeze({ r0: 13, r1: 18, c0: 6, c1: 14 })]),
|
||
unite: Object.freeze([Object.freeze({ r0: 6, r1: 13, 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, rangeTiles }`. Every visible
|
||
* predefined token of the level is a scene object; a device on a separator wall is drawn wherever that wall is built,
|
||
* whichever field it blows into (act2 m01: row 6 belongs to both leftNormal and leftBoss, row 13 tops the normal field).
|
||
* The official client hides none of them by the way they face: the row-13 machines show in the official normal rounds
|
||
* although row 13 lies outside every configBlackBoard area rect (user playtest #5 item 6), and so do the boss field's
|
||
* row-6 machines under the bench (user playtest #5 follow-up: "吹风机原版道中也该有").
|
||
*/
|
||
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 } };
|
||
}
|