Files
ark-stronghold-protocol/public/js/render/board3d/layout.js
T
sganggs 15291c2293 Playtest #5 fixes: hovering enemies, target order, blocking hand-over, 联防 knocked-out, elements, huge-boss hit area, maps, gear icon, phone HUD, 至简 price
- #1 近地悬浮 enemies count as air units (Unit.isFlying): ground-only attacks, splash, terrain and traps skip them;
  PRTS 不可对空 skill effects made ground-only; 掠海漂移体 / 吉兆飞鳞 drop modes; the bot plans them as air
- #2 an operator knocked out before 联防 is fielded and force-exited (PRTS 强制退场): it lies on its tile with the
  redeploy ring and redeploys (full timer [ASSUMED]); partners, observers and the server fallback included
- #3 element audit of every operator, item and enemy: 海嗣 numbers are official; bursts are 无来源, 元素脆弱 only
  raises 元素伤害, 元素抗性 applied, 盟约·辅助干员 fills all three gauges, 菲莱 / 哈洛德 / 纯烬 / enemy kits fixed
- #4 blocking by the official contact radius (0.7071): another operator in contact takes over at once when the
  blocker dies, withdraws or is stunned; melee blockers attack what they block
- #5 enemy target order per PRTS 索敌: blocker, special priority, taunt, last deployed; initial deploy 从上到下>从左到右,
  operators before summons; special priorities break ties by taunt then deploy order
- #6 act2 m01's normal-field blower machines (row 13) are drawn in the normal / 联防 views
- #7 Final Assault prep legality reads the player's half of the boss field (fence tiles deployable); all 11 stages
  audited against the official level files
- #8 regular settings gear; #9 the prep camera keeps the bench and temp rows clear of the shop bar on phones
- #10 huge bosses have the official 4.95 × 2.95 hit area (sim/body.js) and are 自缚 / unblockable
- #11 至简 costs 1: 购买价格为N's bb.price is a discount off the tier price, applied before other price effects
- DESIGN §19 and the normative §3 / §5 / §6 / §7 lines, SIM / META / DATA / PLAYING updated; 2595 unit tests and
  every browser suite green; dev recordings regenerated
2026-09-30 19:12:36 +08:00

585 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 — the separator row 13 included (act2 m01's blowers at
// the top edge of the normal / 联防 field); the boss field's (rows ≤ 6: its row-6 blowers) only when the boss field is
// built — user playtest #5 item 6 (stageDevices).
//
// 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), without the boss field's devices on it
* (stageDevices).
*/
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, opts.area || null);
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']);
/** The boss field's top wall — the row under the normal field's bench (rows ≤ this one are the boss field's). */
export const BOSS_WALL_ROW = 6;
/**
* 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 the separator row 13 included (act2 m01's 源石流发生装置
* #001 / #002 blow DOWN into the normal field: the official normal rounds show them at its top edge although row 13
* lies outside every configBlackBoard area rect, user playtest #5 item 6). The boss field's devices (rows ≤ 6, its top
* wall under the bench included: act2 m01's #201 / #202 blow DOWN into rows 5–3) are drawn only when `area` builds the
* boss field (a rect below row 6): the official normal cameras never show that wall — it lies under the shop bar in
* prep and off-screen in battle and in the shop-collapsed prep view — while the remake's prep view showed the boss
* round's machines under the bench ("反而在关底吹风机的贴图又出现了"). `area` null = the whole map. The 2D board
* (render/tiles.js _stageDevices) follows the same rule.
*/
export function stageDevices(stage, grid, area = null) {
const list = Array.isArray(stage?.devices) ? stage.devices : [];
const bossShown = !area || area.some((a) => a.r0 < BOSS_WALL_ROW);
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;
if (r <= BOSS_WALL_ROW && !bossShown) continue;
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 } };
}