卫戍协议:盟约 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:
sganggs committed 2026-09-29 16:21:31 +08:00
commit 01a44cd288
408 files changed
+308986

No files matched your search

File diff suppressed because it is too large. Load diff
+136
View File
@@ -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];
}
+560
View File
@@ -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 } };
}
+146
View File
@@ -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; }
+391
View File
@@ -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;
}
+94
View File
@@ -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 };
}
+615
View File
@@ -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]) };
}
+45
View File
@@ -0,0 +1,45 @@
// render/boardArt.js — optional real board art from the local game client (DESIGN §13).
//
// const art = await loadBoardArt(assets) // null when the art (or its crop table) is not installed
// → { key, tiles, images: { D, common?, BG? } } (tiles = tiles.json of tools/crop-board-atlas.mjs)
//
// Discovery never guesses URLs: the local-art manifest (data/local-assets.json, assets.local()) must list the atlas
// `map/autochess → TX_autochessi_D`; its crop table `tiles.json` sits next to it (written by
// tools/crop-board-atlas.mjs). Every image referenced by the table is loaded through the shared image cache; a
// missing image drops only the materials that use it. Everything is cached per page (one load for every view).
let cached = null;
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
/** @param {any} assets store of public/js/assets.js (needs local(), localUrl(), image()) */
export function loadBoardArt(assets) {
if (cached) return cached;
cached = (async () => {
if (!assets || typeof assets.local !== 'function' || typeof assets.image !== 'function') return null;
const manifest = await assets.local().catch(() => null);
if (!manifest) return null;
const atlasUrl = assets.localUrl ? assets.localUrl('map/autochess', 'TX_autochessi_D') : null;
if (!atlasUrl) return null;
const dir = atlasUrl.replace(/\/[^/]*$/, '');
let tiles = null;
try {
const res = await fetch(`${dir}/tiles.json`, { cache: 'no-cache' });
tiles = res.ok ? await res.json() : null;
} catch { tiles = null; }
if (!isObj(tiles) || !isObj(tiles.materials) || !isObj(tiles.source)) return null;
const images = {};
await Promise.all(Object.entries(tiles.source).map(async ([k, s]) => {
const url = isObj(s) && typeof s.path === 'string' ? s.path : null;
if (!url) return;
const img = await assets.image(url).catch(() => null);
if (img && img.width > 0) images[k] = img;
}));
if (!images.D) return null;
return { key: `${atlasUrl}#${tiles.version || 0}`, tiles, images };
})().catch(() => null);
return cached;
}
/** Forget the cached art (tests / hot reload). */
export function resetBoardArt() { cached = null; }
+252
View File
@@ -0,0 +1,252 @@
// render/drag.js — prep-phase pointer interactions: hover, click, right-click / long-press detail, and
// drag & drop of pieces between hand, temp and board (DESIGN §9, §10). Pure logic (no PIXI, no DOM access):
// app.js feeds it canvas pointer events and supplies hit-testing callbacks, so the state machine and the drop
// target resolution are unit tested in Node.
//
// Drop targets (protocol g.move `to`): { area: 'board', row, col } | { area: 'hand', idx }, plus
// { area: 'outside', clientX, clientY } when the pointer is released off the stage grid or over DOM UI that
// covers the canvas (the UI decides what, if anything, that means). Releasing over a grid tile that is not a legal
// target (temp slot, enemy lane, `canPlace` false, or the piece's own bench slot) cancels the drag without a
// pieceDrop. A board piece released on its OWN tile is a drop (when `canPlace` allows it): the UI opens the
// direction wheel to re-orient it in place (research 09 §1.2 — "drag the unit onto its own tile and swipe").
//
// Events emitted through `emit(name, payload)`:
// pieceHover { uid, piece, clientX, clientY } | { uid: null } (mouse/pen hover changes)
// tileHover { row, col, area, idx } | null (hover / drag tile changes)
// pieceClick { uid, piece, button, detail, clientX, clientY } (detail: right-click / long-press)
// pieceDetail { uid, piece, clientX, clientY } (right-click / long-press)
// pieceDragStart { uid, piece, from }
// pieceDragMove { uid, piece, x, y, clientX, clientY, target, legal } (for the ghost; high frequency)
// pieceDrop { uid, piece, from, target }
// pieceDragEnd { uid, piece, dropped, cancelled }
import { GEO } from '../../../shared/constants.js';
/** Where a piece currently sits, as a tile. `piece.area` 'board' uses row/col; 'hand'/'temp' use idx. */
export function pieceTile(piece) {
if (!piece || typeof piece !== 'object') return null;
if (piece.area === 'hand' && Number.isInteger(piece.idx)) return { row: GEO.HAND_ROW, col: piece.idx };
if (piece.area === 'temp' && Number.isInteger(piece.idx)) return { row: GEO.TEMP_ROW, col: GEO.TEMP_C0 + piece.idx };
if (Number.isInteger(piece.row) && Number.isInteger(piece.col)) return { row: piece.row, col: piece.col };
return null;
}
/** The slot a piece occupies, in protocol target form (temp slots → { area: 'temp', idx }). */
export function pieceSlot(piece) {
if (!piece || typeof piece !== 'object') return null;
if (piece.area === 'hand' && Number.isInteger(piece.idx)) return { area: 'hand', idx: piece.idx };
if (piece.area === 'temp' && Number.isInteger(piece.idx)) return { area: 'temp', idx: piece.idx };
if (Number.isInteger(piece.row) && Number.isInteger(piece.col)) return { area: 'board', row: piece.row, col: piece.col };
return null;
}
/**
* Classify a grid tile as a potential drop slot.
* @returns {{area:'board',row,col}|{area:'hand',idx}|{area:'temp',idx}|null}
*/
export function tileSlot(tile, geo = GEO) {
if (!tile || !Number.isInteger(tile.row) || !Number.isInteger(tile.col)) return null;
const { row, col } = tile;
if (row === geo.HAND_ROW && col >= 0 && col < geo.HAND_SIZE) return { area: 'hand', idx: col };
if (row === geo.TEMP_ROW && col >= geo.TEMP_C0 && col < geo.TEMP_C0 + geo.TEMP_SIZE) return { area: 'temp', idx: col - geo.TEMP_C0 };
const F = geo.FIELD;
if (row >= F.r0 && row <= F.r1 && col >= F.c0 && col <= F.c1) return { area: 'board', row, col };
return null;
}
export const sameSlot = (a, b) => !!a && !!b && a.area === b.area &&
(a.area === 'board' ? a.row === b.row && a.col === b.col : a.idx === b.idx);
/**
* Decide what a release does.
* @param {{ piece, from, tile, overCanvas: boolean, clientX: number, clientY: number,
* canPlace?: (piece, row, col, target) => boolean, geo? }} p
* @returns {{ kind: 'drop', target } | { kind: 'cancel', reason: string }}
*/
export function resolveDrop(p) {
const { piece, from, tile, overCanvas, clientX, clientY } = p;
if (!overCanvas || !tile) {
return { kind: 'drop', target: { area: 'outside', clientX: Number(clientX) || 0, clientY: Number(clientY) || 0 } };
}
const slot = tileSlot(tile, p.geo || GEO);
if (!slot) return { kind: 'cancel', reason: 'notTarget' };
if (slot.area === 'temp') return { kind: 'cancel', reason: 'temp' };
if (sameSlot(slot, from) && slot.area !== 'board') return { kind: 'cancel', reason: 'same' };
if (!isLegal(p.canPlace, piece, slot)) return { kind: 'cancel', reason: 'illegal' };
return { kind: 'drop', target: slot };
}
/** Legality via the UI callback (board → (piece,row,col); hand → (piece, HAND_ROW, idx)). Throwing ⇒ illegal. */
export function isLegal(canPlace, piece, slot) {
if (!slot || slot.area === 'temp' || slot.area === 'outside') return false;
if (typeof canPlace !== 'function') return true;
const row = slot.area === 'board' ? slot.row : GEO.HAND_ROW;
const col = slot.area === 'board' ? slot.col : slot.idx;
try { return canPlace(piece, row, col, slot) !== false; } catch { return false; }
}
const DEFAULTS = { threshold: 6, touchThreshold: 10, longPressMs: 480 };
/**
* Pointer state machine.
* @param {{
* hitPiece: (x, y) => (object|null), // piece under canvas point (front-most); piece.uid required
* pickTile: (x, y) => ({row, col}|null), // grid tile under canvas point
* isOverCanvas?: (clientX, clientY) => boolean, // false when DOM UI covers the point (default true)
* canPlace?: (piece, row, col, target) => boolean,
* emit: (name, payload) => void,
* setTimeout?, clearTimeout?, threshold?, touchThreshold?, longPressMs?
* }} hooks
*/
export function createDragController(hooks) {
const h = { ...DEFAULTS, ...hooks };
const setT = h.setTimeout || ((fn, ms) => setTimeout(fn, ms));
const clearT = h.clearTimeout || ((id) => clearTimeout(id));
let editable = false;
let canPlace = typeof h.canPlace === 'function' ? h.canPlace : null;
/** @type {null | { mode: 'pressed'|'dragging'|'held', pointerId, pointerType, piece, from, sx, sy, timer }} */
let st = null;
let hoverUid = null;
let hoverTileKey = '';
const emit = (name, payload) => { try { h.emit(name, payload); } catch (err) { console.error('[drag] listener failed', err); } };
const draggable = (piece) => !!piece && piece.draggable !== false && piece.uid != null;
const tileAt = (x, y) => { try { return h.pickTile(x, y); } catch { return null; } };
const pieceAt = (x, y) => { try { return h.hitPiece(x, y); } catch { return null; } };
const overCanvas = (cx, cy) => { try { return h.isOverCanvas ? h.isOverCanvas(cx, cy) !== false : true; } catch { return true; } };
function setHoverTile(tile) {
const slot = tile ? tileSlot(tile) : null;
const key = tile ? `${tile.row},${tile.col}` : '';
if (key === hoverTileKey) return;
hoverTileKey = key;
emit('tileHover', tile ? { row: tile.row, col: tile.col, area: slot ? slot.area : null, idx: slot && slot.area !== 'board' ? slot.idx : undefined } : null);
}
function setHoverPiece(piece, e) {
const uid = piece ? piece.uid : null;
if (uid === hoverUid) return;
hoverUid = uid;
emit('pieceHover', piece ? { uid, piece, clientX: e.clientX, clientY: e.clientY } : { uid: null });
}
function clearTimer() { if (st && st.timer != null) { clearT(st.timer); st.timer = null; } }
function startDrag(e) {
clearTimer();
st.mode = 'dragging';
setHoverPiece(null, e);
emit('pieceDragStart', { uid: st.piece.uid, piece: st.piece, from: st.from });
moveDrag(e);
}
function moveDrag(e) {
const tile = tileAt(e.x, e.y);
const over = overCanvas(e.clientX, e.clientY);
const slot = over && tile ? tileSlot(tile) : null;
const legal = !!slot && slot.area !== 'temp' && (slot.area === 'board' || !sameSlot(slot, st.from)) && isLegal(canPlace, st.piece, slot);
setHoverTile(over ? tile : null);
emit('pieceDragMove', { uid: st.piece.uid, piece: st.piece, x: e.x, y: e.y, clientX: e.clientX, clientY: e.clientY, target: slot, legal });
}
function endDrag(e, cancelled) {
const s = st;
st = null;
if (!s) return;
let dropped = false;
if (!cancelled) {
const tile = tileAt(e.x, e.y);
const d = resolveDrop({ piece: s.piece, from: s.from, tile, overCanvas: overCanvas(e.clientX, e.clientY), clientX: e.clientX, clientY: e.clientY, canPlace });
if (d.kind === 'drop') {
dropped = true;
emit('pieceDrop', { uid: s.piece.uid, piece: s.piece, from: s.from, target: d.target });
}
}
emit('pieceDragEnd', { uid: s.piece.uid, piece: s.piece, dropped, cancelled: !dropped });
}
return {
get editable() { return editable; },
get dragging() { return !!st && st.mode === 'dragging'; },
get dragPiece() { return st && st.mode === 'dragging' ? st.piece : null; },
get hoverUid() { return hoverUid; },
setEditable(v) {
editable = !!v;
if (!editable && st && st.mode === 'dragging') endDrag(null, true);
},
setCanPlace(fn) { canPlace = typeof fn === 'function' ? fn : null; },
/** e: { pointerId, pointerType, button, x, y (canvas CSS px), clientX, clientY } */
pointerDown(e) {
if (st) return false; // one pointer at a time
const piece = pieceAt(e.x, e.y);
if (!piece) { setHoverTile(tileAt(e.x, e.y)); return false; }
if (e.button === 2) {
emit('pieceClick', { uid: piece.uid, piece, button: 2, detail: true, clientX: e.clientX, clientY: e.clientY });
emit('pieceDetail', { uid: piece.uid, piece, clientX: e.clientX, clientY: e.clientY });
return true;
}
if (e.button != null && e.button !== 0) return false;
st = { mode: 'pressed', pointerId: e.pointerId, pointerType: e.pointerType || 'mouse', piece, from: pieceSlot(piece), sx: e.x, sy: e.y, cx: e.clientX, cy: e.clientY, timer: null };
if (st.pointerType === 'touch' || st.pointerType === 'pen') {
const snapshot = st;
st.timer = setT(() => {
if (st !== snapshot || st.mode !== 'pressed') return;
st.timer = null;
st.mode = 'held';
emit('pieceClick', { uid: piece.uid, piece, button: 0, detail: true, clientX: snapshot.cx, clientY: snapshot.cy });
emit('pieceDetail', { uid: piece.uid, piece, clientX: snapshot.cx, clientY: snapshot.cy });
}, h.longPressMs);
}
return true;
},
pointerMove(e) {
if (!st) {
if (e.pointerType === 'touch') return;
setHoverPiece(pieceAt(e.x, e.y), e);
setHoverTile(tileAt(e.x, e.y));
return;
}
if (e.pointerId !== st.pointerId) return;
if (st.mode === 'pressed') {
const th = st.pointerType === 'touch' ? h.touchThreshold : h.threshold;
if (Math.hypot(e.x - st.sx, e.y - st.sy) >= th) {
if (editable && draggable(st.piece)) startDrag(e);
else { clearTimer(); st.mode = 'held'; }
}
} else if (st.mode === 'dragging') moveDrag(e);
},
pointerUp(e) {
if (!st || e.pointerId !== st.pointerId) return;
if (st.mode === 'dragging') { endDrag(e, false); return; }
const s = st;
clearTimer();
st = null;
if (s.mode === 'pressed') emit('pieceClick', { uid: s.piece.uid, piece: s.piece, button: 0, detail: false, clientX: e.clientX, clientY: e.clientY });
},
pointerCancel(e) {
if (!st || (e && e.pointerId !== st.pointerId)) return;
if (st.mode === 'dragging') { endDrag(e, true); return; }
clearTimer();
st = null;
},
pointerLeave(e) {
if (st) return;
setHoverPiece(null, e || {});
setHoverTile(null);
},
/** Abort any interaction (piece list replaced, view destroyed…). */
reset() {
if (st && st.mode === 'dragging') endDrag(null, true);
else { clearTimer(); st = null; }
hoverUid = null;
hoverTileKey = '';
},
};
}
File diff suppressed because it is too large. Load diff
+168
View File
@@ -0,0 +1,168 @@
// render/impostor.js — shared render-target atlas for Spine impostors (render/units.js).
//
// Crowded fields render skeletons through impostors (a sprite showing a cached image of the skeleton, refreshed
// every few frames), and skeletons with clipping attachments always do: pixi-spine implements clipping with
// stencil masks, and every mask inside the main (MSAA) pass costs a render-pass break with a full-screen
// load/store on tiled GPUs. Instead of one RenderTexture per unit (one framebuffer switch each), every due
// impostor of a frame is drawn into slots of a few shared atlas pages in ONE render call per page, before the
// main pass starts:
// page = RenderTexture (2048² physical px) + a batch container [erasers…, skeletons…]
// slot = shelf-packed rect (16 px grid) owned by one unit; erased (ERASE blend quad) then redrawn when due
// Pages are created on demand (≤ MAX_PAGES); when every page is full `alloc` returns null and the unit renders
// directly (still correct, just not batched). A shelf whose last slot is freed is emptied for reuse, and empty shelves
// at the top of a page are given back (a page whose slots are all freed — every battle / prep rebuild — starts over):
// without that, zooming a crowd in and out (the enemy pen camera) left pages full of shelves of stale sizes and the
// next battle's units fell back to one private render target each. Clipped skeletons get slots on small separate "clip" pages: their
// stencil clears break the page's render pass, and the cost of a break scales with the page size.
//
// const imp = new ImpostorAtlas(renderer)
// const slot = imp.alloc(w, h) // CSS px; null when full
// imp.draw(slot, displayObject, matrixLike { a, d, tx, ty }) // queue for this frame (object must be parked here)
// imp.park(displayObject) / imp.unpark(displayObject) // keep skeletons of impostor units in the batch
// imp.flush() // once per frame, after every unit update, before the main render
// imp.free(slot) ; imp.destroy()
const MAX_PAGES = { main: 3, clip: 2 };
const PAGE_PX = { main: 2048, clip: 1024 };
const GRID = 16;
export class ImpostorAtlas {
/** @param {any} renderer PIXI renderer */
constructor(renderer) {
this.P = globalThis.PIXI;
this.R = renderer;
this.res = Math.max(1, Math.min(2, renderer.resolution || 1));
this.pages = [];
this.parked = new this.P.Container(); // skeletons of impostor units (never rendered directly)
this.parked.visible = false;
this.stats = { pages: 0, slots: 0, drawn: 0, full: 0 };
}
/** CSS px per side of a page of `kind`. */
sizeOf(kind) { return Math.floor(PAGE_PX[kind] / this.res); }
_page(kind) {
if (this.pages.filter((p) => p.kind === kind).length >= MAX_PAGES[kind]) return null;
const P = this.P;
const size = this.sizeOf(kind);
const rt = P.RenderTexture.create({ width: size, height: size, resolution: this.res });
try { rt.framebuffer.enableStencil(); } catch { /* masks fall back to scissor */ }
const batch = new P.Container();
const erasers = new P.Container();
const bodies = new P.Container();
batch.addChild(erasers, bodies);
const page = { kind, size, rt, batch, erasers, bodies, shelves: [], nextY: 0, queued: 0, cleared: false, index: this.pages.length };
this.pages.push(page);
this.stats.pages = this.pages.length;
return page;
}
/** Allocate a slot of at least w×h CSS px (rounded up to the grid); null when every page is full. */
alloc(w, h, { clip = false } = {}) {
const kind = clip ? 'clip' : 'main';
const W = Math.max(GRID, Math.ceil(w / GRID) * GRID), H = Math.max(GRID, Math.ceil(h / GRID) * GRID);
if (W > this.sizeOf(kind) || H > this.sizeOf(kind)) return null;
const pages = this.pages.filter((p) => p.kind === kind);
for (let i = 0; i <= pages.length; i++) {
const page = pages[i] || this._page(kind);
if (!page) break;
const s = this._allocIn(page, W, H);
if (s) { this.stats.slots++; return s; }
}
this.stats.full++;
return null;
}
_allocIn(page, W, H) {
// reuse a freed slot of a fitting size first
for (const sh of page.shelves) {
if (sh.h < H || sh.h > H * 1.5) continue;
const k = sh.free.findIndex((f) => f.w >= W && f.w <= W * 1.5);
if (k >= 0) { const f = sh.free.splice(k, 1)[0]; return this._slot(page, sh, f.x, f.w); }
if (sh.x + W <= page.size) { const x = sh.x; sh.x += W; return this._slot(page, sh, x, W); }
}
if (page.nextY + H > page.size) return null;
const sh = { y: page.nextY, h: H, x: W, free: [], live: 0 };
page.nextY += H;
page.shelves.push(sh);
return this._slot(page, sh, 0, W);
}
_slot(page, sh, x, w) {
const P = this.P;
const y = sh.y, h = sh.h;
const eraser = new P.Sprite(P.Texture.WHITE);
eraser.blendMode = P.BLEND_MODES.ERASE;
eraser.position.set(x, y);
eraser.width = w; eraser.height = h;
eraser.visible = false;
page.erasers.addChild(eraser);
const tex = new P.Texture(page.rt.baseTexture, new P.Rectangle(x, y, w, h));
sh.live = (sh.live || 0) + 1;
return { page, shelf: sh, clip: page.kind === 'clip', x, y, w, h, eraser, tex, freed: false };
}
free(slot) {
if (!slot || slot.freed) return;
slot.freed = true;
this.stats.slots = Math.max(0, this.stats.slots - 1);
try { slot.eraser.destroy(); } catch { /* ignore */ }
try { slot.tex.destroy(false); } catch { /* ignore */ }
const page = slot.page;
const sh = slot.shelf && page.shelves.includes(slot.shelf) ? slot.shelf : page.shelves.find((s) => s.y === slot.y);
if (!sh) return;
sh.live = Math.max(0, (sh.live || 0) - 1);
if (sh.live > 0) { sh.free.push({ x: slot.x, w: slot.w }); return; }
// the shelf's last slot: empty it; empty shelves at the top of the page are given back
sh.x = 0;
sh.free.length = 0;
const L = page.shelves;
while (L.length && L[L.length - 1].live === 0) {
const top = L.pop();
page.nextY = top.y;
}
}
/** Keep a skeleton here while its unit is an impostor (hidden unless drawn this frame). */
park(obj) { if (obj && obj.parent !== this.parked) this.parked.addChild(obj); obj.visible = false; }
unpark(obj) { if (obj && obj.parent === this.parked) this.parked.removeChild(obj); if (obj) obj.visible = true; }
/** Queue `obj` to be drawn into `slot` this frame with the local transform (scale a/d, offset tx/ty in the slot). */
draw(slot, obj, m) {
if (!slot || slot.freed || !obj) return;
const page = slot.page;
slot.eraser.visible = true;
obj.position.set(slot.x + m.tx, slot.y + m.ty);
obj.scale.set(m.a, m.d);
obj.visible = true;
if (obj.parent !== page.bodies) page.bodies.addChild(obj);
page.queued++;
}
/** Render every queued slot: one render call per page with queued work. */
flush() {
let drawn = 0;
for (const page of this.pages) {
if (!page.queued) continue;
try {
this.R.render(page.batch, { renderTexture: page.rt, clear: !page.cleared, skipUpdateTransform: false });
page.cleared = true;
} catch { /* lost context etc. */ }
drawn += page.queued;
page.queued = 0;
for (const e of page.erasers.children) e.visible = false;
// back to the parking lot until due again
for (const b of [...page.bodies.children]) this.park(b);
}
this.stats.drawn = drawn;
}
destroy() {
for (const page of this.pages) {
try { page.batch.destroy({ children: false }); } catch { /* ignore */ }
try { page.rt.destroy(true); } catch { /* ignore */ }
}
this.pages = [];
try { this.parked.destroy({ children: false }); } catch { /* ignore */ }
}
}
+313
View File
@@ -0,0 +1,313 @@
// render/interp.js — snapshot interpolation buffer for battle rendering (pure logic, no PIXI / DOM).
//
// The server streams `b.snap` at ~20 Hz real time; snapshot time `t` is GAME seconds and combat runs at 2×
// real time (DESIGN §4), so the game clock advances `rate` ≈ 2 game-s per real second. The buffer:
// * keeps ~2 s of snapshots (each indexed id → tuple once, at push time),
// * estimates `rate` from arrival times (sliding window, clamped, default 2),
// * runs a render clock `renderT` that trails the newest snapshot by `delay` real seconds (default 100 ms),
// advancing at `rate` and gently steered back when network jitter pushes it off target (hard snap when it
// is more than `snapAfter` real seconds off),
// * extrapolation guard: renderT never runs more than `maxExtrapolate` real seconds past the newest snapshot;
// positions are extrapolated along the last velocity for at most that long, then freeze,
// * sample(): per unit, lerps x/y/hp/sp between the two snapshots bracketing renderT; flags/anim come from
// the older one. A unit missing from the newer snapshot (died/left mid-buffer) holds its last position
// until renderT reaches the newer snapshot; a unit that only exists in the newer one (spawned mid-buffer)
// appears when renderT reaches it. Moves longer than `teleport` tiles between two snapshots snap.
// * event queue: a `b.ev` batch is stamped with its game time (`gt`, the snapshot it was drained with); a batch
// without one is placed inside the latest snapshot interval (newest snapshot time minus half an interval),
// and handed out by `takeEvents()` once renderT passes the stamp. Stale cosmetic events (> `eventMaxLag`
// game s behind) are dropped by the caller's choice (`isCosmeticEvent`); state events are always delivered.
//
// Snapshot tuple layout (DESIGN §8.2): [id, x, y, hp, maxHp, sp, spMax, flags, anim].
export const TUPLE = Object.freeze({ ID: 0, X: 1, Y: 2, HP: 3, MAXHP: 4, SP: 5, SPMAX: 6, FLAGS: 7, ANIM: 8 });
const MAX_EVENTS = 6000;
const finite = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
/** Cosmetic event kinds that may be dropped when far behind (never state-changing). */
export const COSMETIC_EVENTS = new Set(['atk', 'dmg', 'heal', 'fx', 'layer', 'bounty']);
export const isCosmeticEvent = (ev) => Array.isArray(ev) && COSMETIC_EVENTS.has(ev[0]);
/**
* Game time (s) of a b.snap / b.ev payload, or NaN. On the wire every frame is `{ t: '<type>', … }`, so the server
* sends the game time as `gt` (server/match/fields.js snapFrame); raw Battle snapshots and the demo recordings
* carry it as a numeric `t`. This is the only place that conversion happens.
*/
export function frameTime(msg) {
if (!msg || typeof msg !== 'object') return NaN;
const t = typeof msg.gt === 'number' ? msg.gt : msg.t;
return typeof t === 'number' && Number.isFinite(t) ? t : NaN;
}
/**
* Validate & normalise a b.snap payload. Returns `{ t, units: Map<id, tuple>, raw }` or null when unusable.
* Tuples with a non-finite id/x/y are skipped; other numbers default to 0.
*/
export function normalizeSnapshot(snap) {
if (!snap || typeof snap !== 'object') return null;
const t = frameTime(snap);
if (!Number.isFinite(t)) return null;
const units = new Map();
const list = Array.isArray(snap.units) ? snap.units : [];
for (const u of list) {
if (!Array.isArray(u) || u.length < 3) continue;
const id = u[0];
if (!(typeof id === 'number' || typeof id === 'string')) continue;
const x = u[1], y = u[2];
if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) continue;
units.set(id, [id, x, y, finite(u[3]), finite(u[4]), finite(u[5]), finite(u[6]), finite(u[7]) | 0, finite(u[8]) | 0]);
}
return { t, units, raw: snap };
}
export class SnapshotBuffer {
/**
* @param {{ delay?: number, rate?: number, maxExtrapolate?: number, keep?: number, teleport?: number,
* snapAfter?: number, minRate?: number, maxRate?: number }} [opts]
* delay / maxExtrapolate / snapAfter / keep are REAL seconds.
*/
constructor(options) {
const opts = options && typeof options === 'object' ? options : {};
this.delay = Math.max(0, finite(opts.delay, 0.1));
this.defaultRate = clamp(finite(opts.rate, 2), 0.05, 20);
this.minRate = finite(opts.minRate, 0.25);
this.maxRate = finite(opts.maxRate, 8);
this.maxExtrapolate = Math.max(0, finite(opts.maxExtrapolate, 0.12));
this.keep = Math.max(0.5, finite(opts.keep, 2.5));
this.teleport = Math.max(0.5, finite(opts.teleport, 2.5));
this.snapAfter = Math.max(0.2, finite(opts.snapAfter, 0.75));
this.reset();
}
reset() {
/** @type {{t:number, units:Map<any, any[]>, raw:any, at:number}[]} ascending by t */
this.snaps = [];
/** @type {{t:number, at:number}[]} arrival log for rate estimation */
this.arrivals = [];
this.rate = this.defaultRate;
this.renderT = NaN;
this.lastNow = NaN;
this.interval = 0.1 * this.defaultRate / 2; // game s between snapshots (estimated)
/** @type {{t:number, ev:any}[]} */
this.events = [];
this.meta = null; // latest snapshot's extra fields (dp, killed, total, boss…)
return this;
}
get newestT() { return this.snaps.length ? this.snaps[this.snaps.length - 1].t : NaN; }
get oldestT() { return this.snaps.length ? this.snaps[0].t : NaN; }
get size() { return this.snaps.length; }
/**
* Add a snapshot received at real time `now` (seconds). Out-of-order / duplicate snapshots are dropped;
* a snapshot far older than the newest (a server-side reset, e.g. a new battle) resets the buffer.
* @returns {boolean} accepted
*/
push(snap, now) {
const s = normalizeSnapshot(snap);
if (!s) return false;
now = finite(now, this.lastNow || 0);
const newest = this.newestT;
if (this.snaps.length) {
if (s.t < newest - 5) this.reset(); // the server restarted the stream (new battle): old events are obsolete
else if (s.t <= newest) return false;
}
s.at = now;
const prev = this.snaps[this.snaps.length - 1];
if (prev) {
const dt = s.t - prev.t;
if (dt > 0 && dt < 2) this.interval = this.interval * 0.8 + dt * 0.2;
}
this.snaps.push(s);
this.meta = s.raw;
// rate estimate over a ~1.5 s window of arrivals
this.arrivals.push({ t: s.t, at: now });
while (this.arrivals.length > 2 && now - this.arrivals[0].at > 1.5) this.arrivals.shift();
if (this.arrivals.length >= 4) {
const a = this.arrivals[0], b = this.arrivals[this.arrivals.length - 1];
const dReal = b.at - a.at, dGame = b.t - a.t;
if (dReal > 0.2 && dGame > 0) this.rate = clamp(this.rate * 0.7 + (dGame / dReal) * 0.3, this.minRate, this.maxRate);
}
// trim old snapshots (keep at least 2). Trimming never waits for the render clock: while the page is hidden
// (no animation frames) snapshots keep arriving and must not pile up; a clock left behind is clamped.
const horizon = s.t - this.keep * this.rate;
while (this.snaps.length > 2 && this.snaps[1].t < horizon) this.snaps.shift();
if (!Number.isFinite(this.renderT)) this.renderT = Math.max(this.snaps[0].t, s.t - this.delay * this.rate);
else if (this.renderT < this.snaps[0].t) this.renderT = this.snaps[0].t;
return true;
}
/**
* Queue a b.ev batch. `t` (game s, the frame's `gt`) when the server stamps it; otherwise the batch is placed
* inside the latest snapshot interval (see header).
*/
pushEvents(evs, now, t) {
if (!Array.isArray(evs) || !evs.length) return 0;
let stamp = typeof t === 'number' && Number.isFinite(t) ? t : NaN;
if (!Number.isFinite(stamp)) {
const n = this.newestT;
stamp = Number.isFinite(n) ? n - this.interval * 0.5 : (Number.isFinite(this.renderT) ? this.renderT : 0);
}
let added = 0;
for (const ev of evs) {
if (!Array.isArray(ev) || typeof ev[0] !== 'string') continue;
this.events.push({ t: stamp, ev });
added++;
}
// bounded queue (hidden tab: nothing consumes it) — shed the oldest cosmetic events first
if (this.events.length > MAX_EVENTS) {
const keep = [];
const excess = this.events.length - MAX_EVENTS;
let shed = 0;
for (const e of this.events) {
if (shed < excess && isCosmeticEvent(e.ev)) { shed++; continue; }
keep.push(e);
}
this.events = keep.length > MAX_EVENTS * 2 ? keep.slice(keep.length - MAX_EVENTS * 2) : keep;
}
// keep sorted (batches normally arrive in order; a server stamp may be older)
if (this.events.length > 1 && this.events[this.events.length - 1].t < this.events[this.events.length - 1 - added]?.t) {
this.events.sort((a, b) => a.t - b.t);
}
return added;
}
/**
* Advance the render clock to real time `now` (seconds). Returns renderT (NaN until the first snapshot).
*/
update(now) {
now = finite(now, 0);
if (!this.snaps.length) { this.lastNow = now; return NaN; }
const dt = Number.isFinite(this.lastNow) ? clamp(now - this.lastNow, 0, 1) : 0;
this.lastNow = now;
const newest = this.newestT;
const target = newest - this.delay * this.rate;
if (!Number.isFinite(this.renderT)) this.renderT = target;
this.renderT += dt * this.rate;
const err = target - this.renderT;
if (Math.abs(err) > this.snapAfter * this.rate) this.renderT = target;
else this.renderT += err * Math.min(1, dt * 3);
const cap = newest + this.maxExtrapolate * this.rate;
if (this.renderT > cap) this.renderT = cap;
if (this.renderT < this.snaps[0].t) this.renderT = this.snaps[0].t;
return this.renderT;
}
/** Jump the render clock to the newest snapshot (scrubbing / first frame after a reset). */
snapToNewest() {
if (this.snaps.length) this.renderT = this.newestT;
return this.renderT;
}
/** Index of the newest snapshot with t ≤ time (−1 when time is before all). */
_indexAt(time) {
const s = this.snaps;
let lo = 0, hi = s.length - 1, ans = -1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (s[mid].t <= time) { ans = mid; lo = mid + 1; } else hi = mid - 1;
}
return ans;
}
/**
* Interpolated state at `time` (default renderT). Fills and returns `out` (a Map id → sample object reused
* across calls: `{ id, x, y, hp, maxHp, sp, spMax, flags, anim, vx, vy, seen }`). Samples of units no longer
* present are deleted from `out`.
*/
sample(time = this.renderT, out = new Map()) {
const s = this.snaps;
if (!s.length || !Number.isFinite(time)) { out.clear(); return out; }
let ia = this._indexAt(time);
if (ia < 0) ia = 0;
const A = s[ia];
const B = ia + 1 < s.length ? s[ia + 1] : null;
const span = B ? B.t - A.t : 0;
const alpha = B && span > 0 ? clamp((time - A.t) / span, 0, 1) : 0;
const P = !B && ia > 0 ? s[ia - 1] : null; // for extrapolation
const ext = !B ? clamp(time - A.t, 0, this.maxExtrapolate * this.rate) : 0;
const stamp = A.t;
for (const [id, a] of A.units) {
let o = out.get(id);
if (!o) { o = { id, x: 0, y: 0, hp: 0, maxHp: 0, sp: 0, spMax: 0, flags: 0, anim: 0, vx: 0, vy: 0, seen: 0 }; out.set(id, o); }
const b = B ? B.units.get(id) : null;
if (b) {
const dx = b[1] - a[1], dy = b[2] - a[2];
const tele = dx * dx + dy * dy > this.teleport * this.teleport;
o.x = tele ? (alpha < 1 ? a[1] : b[1]) : a[1] + dx * alpha;
o.y = tele ? (alpha < 1 ? a[2] : b[2]) : a[2] + dy * alpha;
o.vx = !tele && span > 0 ? dx / span : 0;
o.vy = !tele && span > 0 ? dy / span : 0;
o.hp = a[3] + (b[3] - a[3]) * alpha;
o.maxHp = b[4] || a[4];
o.sp = a[5] + (b[5] - a[5]) * alpha;
o.spMax = b[6] || a[6];
} else {
let vx = 0, vy = 0;
if (P && ext > 0) {
const p = P.units.get(id);
const dtp = A.t - P.t;
if (p && dtp > 0) {
vx = (a[1] - p[1]) / dtp; vy = (a[2] - p[2]) / dtp;
if (vx * vx + vy * vy > (this.teleport / dtp) ** 2) { vx = 0; vy = 0; }
}
}
o.x = a[1] + vx * ext;
o.y = a[2] + vy * ext;
o.vx = vx; o.vy = vy;
o.hp = a[3]; o.maxHp = a[4]; o.sp = a[5]; o.spMax = a[6];
}
// the sim rounds hp up and maxHp to nearest, so a snapshot may say hp = maxHp + 1
if (o.hp > o.maxHp && o.maxHp > 0) o.hp = o.maxHp;
else if (!(o.hp > 0)) o.hp = 0;
o.flags = a[7];
o.anim = a[8];
o.seen = stamp;
}
for (const id of out.keys()) if (!A.units.has(id)) out.delete(id);
return out;
}
/**
* Remove and return (in order) the queued events due at `time` (default renderT). When `dropCosmeticBefore`
* is a number, cosmetic events stamped earlier than it are discarded instead of returned.
*/
takeEvents(time = this.renderT, out = [], dropCosmeticBefore = null) {
if (!this.events.length || !Number.isFinite(time)) return out;
let n = 0;
while (n < this.events.length && this.events[n].t <= time) n++;
if (!n) return out;
const list = this.events;
for (let i = 0; i < n; i++) {
const e = list[i];
if (typeof dropCosmeticBefore === 'number' && e.t < dropCosmeticBefore && isCosmeticEvent(e.ev)) continue;
out.push(e.ev);
}
const rest = list.length - n;
list.copyWithin(0, n);
list.length = rest;
return out;
}
/**
* Look ahead: call `fn(ev, t)` for every queued event stamped in (from, to] (ascending; nothing is removed).
* Lets the renderer start animations that must peak when an event becomes due (attack wind-ups).
*/
forEachUpcoming(from, to, fn) {
const list = this.events;
for (let i = 0; i < list.length; i++) {
const e = list[i];
if (e.t > to) break;
if (e.t > from) fn(e.ev, e.t);
}
}
/** Deliver every queued event now (used before a reset so state events are not lost). */
flushEvents(out = []) {
for (const e of this.events) out.push(e.ev);
this.events.length = 0;
return out;
}
}
+236
View File
@@ -0,0 +1,236 @@
// render/pen.js — the enemy preview pen of the prep phase (research 09 §2.2, research 08 §4.2, DESIGN §3): where the
// next round's enemies stand, idling and facing left, behind the gates. Pure logic (no PIXI): render/app.js turns the
// layout into idle enemy views.
//
// const pen = layoutPen(nextEnemies, { stage }) // m.private.nextEnemies (per-action entries with counts)
// → { figures: [{ key, enemyKey, row, col, x, y, fly, elite, boss, t, zone }], total, shown, sig }
//
// Official algorithm (client AutoChessEnemyPreviewManager, decoded in research 08 §4.2), with the documented choices:
// * entries sorted by spawn time (`t`, _CompareActionByTime; stable);
// * thinning: total = Σ count, ratio = min(1, 50 / total); each entry shows max(1, round(count · ratio)) figures,
// elites and bosses (handbook level ≠ NORMAL) always their full count; normal figures past MAX_PREVIEW (50) are
// dropped from the latest spawns first (elites / bosses are never dropped);
// * zones: the pen rect `enemy_place_rect` ((14,7),(18,13)) minus the `previewNotAlloed` row (16) and the two
// `tile_start` anchors (15,7) / (18,7); every tile belongs to the Manhattan-nearest anchor → lower zone rows 14–15,
// upper zone rows 17–18 (13 tiles each). Upper-gate enemies → upper zone, lower-gate → lower zone;
// * tile choice: the k-th figure sent to a zone goes to zoneTiles[clamp(round(k / totalShowCnt · len) − jitter)]
// (totalShowCnt = min(50, Σ count), jitter ∈ {0, 1}, seeded — `Random.Range(−1, 0)` [ASSUMED both values]); each
// zone's tiles in ROW-MAJOR order as the client builds them (research 08 §4.2 step 3 [DATA]: low row first, col
// 7 → 13), so the first spawns stand next to the gate anchor and a small zone stays by its gate;
// * at most PER_TILE (3) figures per tile ([ASSUMED], research 09 §2.2): a full tile passes the figure to the next
// tile of its zone with room; a zone whose every tile is full stacks the rest on its least-loaded tiles — never in
// the other gate's zone ("keep the grouping exact: one zone per gate", research 08 §4.2 step 6; only a zone without
// tiles falls back to any pen tile, m_backUpTiles); figures sharing a tile stand in a small cluster (≤ 6 distinct
// spots) with a seeded jitter.
// Deterministic for a given preview (the seed is a hash of the entries), so re-sending the same m.private never
// reshuffles the pen.
export const PEN_RECT = Object.freeze({ r0: 14, r1: 18, c0: 7, c1: 13 });
export const MAX_PREVIEW = 50;
export const PER_TILE = 3;
/** Cluster offsets (tiles; x = col, y = row) of 1–6 figures sharing a tile (deeper ones a bit to the right). */
const CLUSTER = Object.freeze([
Object.freeze([[0, 0]]),
Object.freeze([[-0.17, 0.13], [0.17, -0.13]]),
Object.freeze([[-0.22, 0.16], [0.2, 0.18], [-0.02, -0.2]]),
Object.freeze([[-0.22, 0.2], [0.22, 0.2], [-0.22, -0.2], [0.22, -0.2]]),
Object.freeze([[-0.25, 0.22], [0.25, 0.22], [0, 0], [-0.25, -0.22], [0.25, -0.22]]),
Object.freeze([[-0.27, 0.22], [0, 0.26], [0.27, 0.22], [-0.27, -0.22], [0, -0.26], [0.27, -0.22]]),
]);
const isObj = (v) => !!v && typeof v === 'object';
/** mulberry32 */
function rng(seed) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hashStr(s) {
let h = 2166136261;
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
return h >>> 0;
}
/** Parse a configBlackBoard rect "((r0,c0),(r1,c1))" → { r0, r1, c0, c1 } (null when malformed). */
export function parsePenRect(v) {
if (typeof v !== 'string') return null;
const n = v.replace(/[()\s]/g, '').split(',').map(Number);
if (n.length !== 4 || !n.every(Number.isInteger)) return null;
return { r0: Math.min(n[0], n[2]), r1: Math.max(n[0], n[2]), c0: Math.min(n[1], n[3]), c1: Math.max(n[1], n[3]) };
}
/**
* The pen's two zones for a stage (defaults when the stage is missing or malformed): `{ rect, lower, upper }`, each
* zone `{ anchor: [r, c], tiles: [[r, c]…] }` in row-major order (research 08 §4.2: low row first, then col).
*/
export function penZones(stage) {
const rect = parsePenRect(stage?.config?.enemy_place_rect) || { ...PEN_RECT };
const rows = Array.isArray(stage?.rows) ? stage.rows : null;
const legend = isObj(stage?.tiles) ? stage.tiles : {};
const glyph = (r, c) => (rows && typeof rows[r] === 'string' ? rows[r][c] : null);
const anchors = [];
const cand = [];
for (let r = rect.r0; r <= rect.r1; r++) {
for (let c = rect.c0; c <= rect.c1; c++) {
const g = glyph(r, c);
const def = g ? legend[g] : null;
if (rows) {
if (!def) continue;
if (def.tileKey === 'tile_start') { anchors.push([r, c]); continue; }
if (def.bb && def.bb.previewNotAlloed) continue;
if (def.passable && def.passable !== 'ALL') continue;
cand.push([r, c]);
} else {
// no stage: the official layout ((14,7),(18,13)), anchors (15,7) / (18,7), row 16 not allowed
if (c === rect.c0 && (r === rect.r0 + 1 || r === rect.r1)) { anchors.push([r, c]); continue; }
if (r === rect.r0 + 2) continue;
cand.push([r, c]);
}
}
}
if (!anchors.length) anchors.push([rect.r0 + 1, rect.c0], [rect.r1, rect.c0]);
anchors.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
const zones = anchors.map((a) => ({ anchor: a, tiles: [] }));
const dist = (a, t) => Math.abs(a[0] - t[0]) + Math.abs(a[1] - t[1]);
for (const t of cand) {
let best = zones[0], bd = Infinity;
for (const z of zones) { const d = dist(z.anchor, t); if (d < bd) { bd = d; best = z; } }
best.tiles.push(t);
}
// row-major, as the client lists them (the candidates are collected row by row; kept explicit for custom rects)
for (const z of zones) z.tiles.sort((p, q) => p[0] - q[0] || p[1] - q[1]);
return { rect, lower: zones[0], upper: zones[zones.length - 1], zones };
}
/** Clean, time-sorted entries of an m.private.nextEnemies list. */
export function penEntries(list) {
const out = [];
if (!Array.isArray(list)) return out;
list.forEach((e, i) => {
if (!isObj(e) || typeof e.enemyKey !== 'string' || !e.enemyKey) return;
const count = Math.max(1, Math.min(200, Math.trunc(Number(e.count) || 1)));
out.push({
enemyKey: e.enemyKey, count, gate: e.gate === 'upper' ? 'upper' : 'lower', t: Number.isFinite(Number(e.t)) ? Number(e.t) : 0,
fly: !!e.fly, boss: !!e.boss || e.tag === 'boss', elite: !!e.elite || !!e.boss || e.tag === 'boss', i,
});
});
out.sort((a, b) => a.t - b.t || a.i - b.i);
return out;
}
/** Signature of a preview list (unchanged signature ⇒ the pen is left as it is). */
export function penSignature(list) {
return penEntries(list).map((e) => `${e.enemyKey}:${e.count}:${e.gate[0]}:${e.t}:${e.fly ? 1 : 0}${e.elite ? 1 : 0}${e.boss ? 1 : 0}`).join('|');
}
/**
* Figures shown per entry after thinning (see header): [{ entry, n }] in spawn order.
* @param {ReturnType<typeof penEntries>} entries
*/
export function thinPreview(entries, max = MAX_PREVIEW) {
const total = entries.reduce((s, e) => s + e.count, 0);
const ratio = total > 0 ? Math.min(1, max / total) : 1;
const plan = entries.map((e) => ({ entry: e, n: e.elite ? e.count : Math.max(1, Math.round(e.count * ratio)) }));
let shown = plan.reduce((s, p) => s + p.n, 0);
// over the cap (rounding, elites): take figures from the biggest normal groups first, so every action keeps a
// figure while it can; then drop single normal figures from the latest spawns (elites / bosses always stay)
while (shown > max) {
let big = null;
for (const p of plan) if (!p.entry.elite && p.n > 1 && (!big || p.n > big.n)) big = p;
if (!big) break;
big.n--;
shown--;
}
for (let i = plan.length - 1; i >= 0 && shown > max; i--) {
const p = plan[i];
if (p.entry.elite || !p.n) continue;
p.n--;
shown--;
}
return plan.filter((p) => p.n > 0);
}
/**
* Lay the preview out in the pen (see header).
* @param {Array<object>} list m.private.nextEnemies
* @param {{ stage?: object, max?: number, perTile?: number }} [opts]
*/
export function layoutPen(list, opts = {}) {
const entries = penEntries(list);
const sig = penSignature(list);
const Z = penZones(opts.stage);
const max = Number.isInteger(opts.max) && opts.max > 0 ? opts.max : MAX_PREVIEW;
const perTile = Number.isInteger(opts.perTile) && opts.perTile > 0 ? opts.perTile : PER_TILE;
const plan = thinPreview(entries, max);
const shown = plan.reduce((s, p) => s + p.n, 0);
const total = entries.reduce((s, e) => s + e.count, 0);
// the index divisor of the client: totalShowCnt = min(MAX_PREVIEW_CNT, Σ count) (research 08 §4.2 step 2)
const totalShowCnt = Math.max(1, Math.min(max, total));
const rand = rng(hashStr(sig) || 1);
const load = new Map(); // "r,c" → figures on the tile
const key = (t) => `${t[0]},${t[1]}`;
const has = (t) => (load.get(key(t)) || 0) < perTile;
const allTiles = [...Z.lower.tiles, ...Z.upper.tiles];
const placed = [];
const kOf = { lower: 0, upper: 0 };
for (const { entry, n } of plan) {
const zoneName = entry.gate === 'upper' ? 'upper' : 'lower';
const zone = Z[zoneName];
for (let j = 0; j < n; j++) {
const k = kOf[zoneName]++;
const len = zone.tiles.length;
let tile = null;
if (len) {
const jitter = rand() < 0.5 ? 1 : 0;
let idx = Math.round((k / totalShowCnt) * len) - jitter;
idx = Math.max(0, Math.min(len - 1, idx));
// a full tile passes the figure on (further along the zone list, then back towards its start)
for (let d = 0; d < len && !tile; d++) {
for (const ii of d ? [idx + d, idx - d] : [idx]) if (ii >= 0 && ii < len && has(zone.tiles[ii])) { tile = zone.tiles[ii]; break; }
}
// the whole zone is full: stack on its least-loaded tile nearest the index (never in the other gate's zone)
if (!tile) {
let best = Infinity;
for (let ii = 0; ii < len; ii++) {
const score = (load.get(key(zone.tiles[ii])) || 0) * len + Math.abs(ii - idx);
if (score < best) { best = score; tile = zone.tiles[ii]; }
}
}
}
if (!tile) tile = allTiles.find(has) || allTiles[0] || [Z.rect.r0, Z.rect.c1];
load.set(key(tile), (load.get(key(tile)) || 0) + 1);
placed.push({ entry, tile, zone: zoneName, slot: 0 });
}
}
// cluster offsets once every tile's final occupancy is known
const seen = new Map();
const figures = placed.map((p, idx) => {
const kk = key(p.tile);
const cnt = Math.min(CLUSTER.length, load.get(kk) || 1);
const slot = seen.get(kk) || 0;
seen.set(kk, slot + 1);
const off = CLUSTER[cnt - 1][slot % cnt] || [0, 0];
const jx = (rand() - 0.5) * 0.08, jy = (rand() - 0.5) * 0.08;
const e = p.entry;
return {
key: `${idx}:${e.enemyKey}`, enemyKey: e.enemyKey, row: p.tile[0], col: p.tile[1],
x: p.tile[1] + off[0] + jx, y: p.tile[0] + off[1] + jy,
fly: e.fly, elite: e.elite, boss: e.boss, t: e.t, zone: p.zone,
};
});
return { figures, total, shown, sig };
}
/** Whether a tile lies inside the pen rect (rows 14–18 × cols 7–13 by default). */
export function inPen(row, col, rect = PEN_RECT) {
return row >= rect.r0 && row <= rect.r1 && col >= rect.c0 && col <= rect.c1;
}
+70
View File
@@ -0,0 +1,70 @@
// render/prepfield.js — where the prep board is shown (research 09 §1.2, DESIGN §3 / §15): normally the own board
// (rows 7–12, the stage grid as is); in the Final Assault / Hidden Core prep the player's pieces stand on THEIR half of
// the boss field — the sim's mapping (server/match/finalAssault.js bossFieldPlacement, sim Battle.mapTile / mapDir):
// board rows ≥ 7 → row − 7 (board 9–12 → boss 2–5, the bench row 7 → 0, the temp row 8 → 1); the right-hand player
// (side 'R') is mirrored col c → 20 − c with RIGHT ↔ LEFT (UP / DOWN unchanged, ConvertChessPositionInfoToBossMap).
// Pieces only ever stand on rows ≥ 7; other board rows (range tiles reaching past the field) shift the same way, so a
// DOWN range below the bench falls off the grid (row < 0) instead of landing on the boss rows, and `tilesToDisp` drops
// every tile that is not on the boss field (rows 0–6) — e.g. an UP range past the top wall onto the normal board.
//
// The view keeps every public coordinate in BOARD space (the server's, g.move targets, canPlace, highlightTiles,
// tileScreen, holdPiece, setPieceDir): only what is drawn and picked goes through this transform. Pure functions.
export const BOSS_ROW_SHIFT = -7;
export const MAX_COL = 20;
const MIRROR = Object.freeze({ RIGHT: 'LEFT', LEFT: 'RIGHT', UP: 'UP', DOWN: 'DOWN' });
/** RIGHT ↔ LEFT (UP / DOWN unchanged); unknown values pass through. */
export function mirrorDir(d) {
return typeof d === 'string' && MIRROR[d.toUpperCase()] ? MIRROR[d.toUpperCase()] : d;
}
/** The identity transform (normal prep). */
export const IDENTITY = Object.freeze({
kind: 'board', side: 'L', mirror: false,
toDisp: (row, col) => ({ row, col }),
toBoard: (row, col) => ({ row, col }),
dirToDisp: (d) => d,
dirToBoard: (d) => d,
});
/**
* Transform of the Final Assault prep on side 'L' | 'R' of the boss field. `toBoard` returns null for display tiles
* that are not on the player's half of the boss rows (0–5) — nothing of the board is there.
*/
export function bossPrepField(side) {
const R = side === 'R';
const lo = R ? 10 : 0, hi = R ? MAX_COL : 10;
return Object.freeze({
kind: 'bossPrep', side: R ? 'R' : 'L', mirror: R,
toDisp: (row, col) => ({ row: row + BOSS_ROW_SHIFT, col: R ? MAX_COL - col : col }),
toBoard: (row, col) => {
if (!Number.isInteger(row) || !Number.isInteger(col) || row < 0 || row > 5 || col < lo || col > hi) return null;
return { row: row - BOSS_ROW_SHIFT, col: R ? MAX_COL - col : col };
},
dirToDisp: (d) => (R ? mirrorDir(d) : d),
dirToBoard: (d) => (R ? mirrorDir(d) : d),
});
}
/** Last display row of the boss field band (rows 0–5 + the wall row 6) in the Final Assault prep. */
export const BOSS_DISP_MAX_ROW = 6;
/**
* Map a list of board tiles ([[r,c]] or [{row,col}]) to display tiles ([[r,c]]). In the Final Assault prep tiles that
* do not land on the boss field (display rows 0–6, cols 0–20) are dropped.
*/
export function tilesToDisp(xf, tiles) {
const out = [];
if (!Array.isArray(tiles)) return out;
const boss = xf && xf.kind === 'bossPrep';
for (const t of tiles) {
const r = Array.isArray(t) ? t[0] : t?.row, c = Array.isArray(t) ? t[1] : t?.col;
if (!Number.isInteger(r) || !Number.isInteger(c)) continue;
const d = xf.toDisp(r, c);
if (boss && (d.row < 0 || d.row > BOSS_DISP_MAX_ROW || d.col < 0 || d.col > MAX_COL)) continue;
out.push([d.row, d.col]);
}
return out;
}
+432
View File
@@ -0,0 +1,432 @@
// render/projection.js — the battlefield camera: one perspective camera model shared by the three.js board scene
// (render/board3d, DESIGN §15) and the Pixi layers on top of it (units, FX, drag & drop, hit-testing). Pure math, no
// PIXI / three import (Node tests and the 2D fallback use it as is).
//
// World space (DESIGN §3): x = col, y = row (row 0 = bottom = nearest to the camera), z = height in tiles (up).
// Tile (r,c) covers x ∈ [c−½, c+½], y ∈ [r−½, r+½]. The camera is a three.js-style PerspectiveCamera: a pinhole
// south of its target (towards −y), pitched `tilt` degrees away from looking straight down, `dist` tiles from the
// target point on the ground, with an off-axis lens (principal point (cx, cy), three's setViewOffset):
//
// C = T + dist·(0, −sin tilt, cos tilt) camera position
// f = (0, sin tilt, −cos tilt) forward u = (0, cos tilt, sin tilt) up r = (1,0,0)
// depth = (P−C)·f ; screen = (cx, cy) + scale·dist·((P−C)·r, −(P−C)·u) / depth
//
// `scale` = CSS px per world unit at the target's depth, i.e. the focal length is scale·dist px and the vertical
// field of view of a viewport H px high is 2·atan(H / 2 / (scale·dist)). `threeCameraParams` / `syncThreeCamera`
// turn a Camera into the equivalent THREE.PerspectiveCamera (fov, aspect, position, up, look-at, view offset) —
// test/render/projection.test.js projects every tile centre through both and requires identical pixels.
//
// Official framing (research 09 §2.1, fitted against official 1920×1080 screenshots; DESIGN §15): every 卫戍协议
// level's configBlackBoard carries camera params "(x,y,z,w)" (left_prepare / left_shop / left_battle / mid_battle /
// *_boss_* / enemy). They are offsets of Arknights' standard battle camera: position = OFFICIAL.base + param in the
// level's centred Unity frame (x = col − 10, y = row − 9, z = −height), pitched OFFICIAL.pitch = 30° with a vertical
// field of view of OFFICIAL.fovY = 40° and the principal point at the screen centre. (Measured: the shop camera
// (−4.83, −1.75, −3.77) puts the camera at col 5.17, row 2.44, height 11.53 — the least-squares fit of tile seams in
// an official shop-view screenshot gave col 5.17, row 2.40, height 11.57 at 30°/40°.) Screens narrower than 16:9
// back the camera off like the client (up to 4:3: Δ = (0, −1.4, −2.8)·t). `presetCamera` returns these official
// cameras; `fitCamera` (frame any rect inside a padded viewport) remains for custom rects and portrait viewports.
export const DEG = Math.PI / 180;
/** Arknights' standard battle camera (see header). */
export const OFFICIAL = Object.freeze({
base: Object.freeze([0, -4.81, -7.76]), // standard view position in the centred Unity frame
pitch: 30, // degrees from straight down
fovY: 40, // vertical field of view, degrees
center: Object.freeze([10, 9]), // (col, row) of the Unity frame origin on the 19×21 grid
aspectAdjust: Object.freeze([0, -1.4, -2.8]), // camera offset at 3:4 (H/W), interpolated from 9:16
});
/** configBlackBoard camera params (identical in all 11 autochess stages; stage.config overrides). */
export const OFFICIAL_PARAMS = Object.freeze({
left_prepare_camera_param: '(-4.67,0.3,-2.46,1)',
left_shop_camera_param: '(-4.83,-1.75,-3.77,1)',
left_battle_camera_param: '(-4,1.91,-0.6,1)',
right_battle_camera_param: '(4,1.91,-0.6,1)',
mid_battle_camera_param: '(0,0,-4.22,1)',
left_boss_prepare_camera_param: '(-4.67,-6.68,-2.46,1)',
left_boss_shop_camera_param: '(-4.83,-8.73,-3.77,1)',
left_boss_battle_camera_param: '(-4,-5.54,-0.6,1)',
right_boss_battle_camera_param: '(4,-5.54,-0.6,1)',
mid_boss_battle_camera_param: '(0,-7.95,-4.22,1)',
right_boss_prepare_camera_param: '(5.93,-6.68,-2.46,1)',
right_boss_shop_camera_param: '(5.83,-8.73,-3.77,1)',
enemy_camera_param: '(0,7.86,0.9,1)',
});
/** Default optics of the fitted (non-official) cameras: the official pitch; dist is a moderate lens. */
export const DEFAULT_OPTICS = Object.freeze({ tilt: OFFICIAL.pitch, dist: 16 });
/**
* Rects framed by each camera kind (inclusive tile bounds), framing parameters of the fitted fallback, and the
* official configBlackBoard param(s) of the kind (`official`: side → param key; `half` for the ‹ › half views).
*/
export const CAMERA_PRESETS = Object.freeze({
prep: Object.freeze({
rect: Object.freeze({ r0: 7, r1: 12, c0: 0, c1: 10 }), margin: 0.35, headroom: 1.25, tilt: 30, dist: 13,
official: Object.freeze({ L: 'left_shop_camera_param', R: 'left_shop_camera_param' }),
officialNoShop: Object.freeze({ L: 'left_prepare_camera_param', R: 'left_prepare_camera_param' }),
}),
normal: Object.freeze({
rect: Object.freeze({ r0: 9, r1: 12, c0: 0, c1: 10 }), margin: 0.45, headroom: 1.35, tilt: 30, dist: 13,
official: Object.freeze({ L: 'left_battle_camera_param', R: 'right_battle_camera_param' }),
}),
unite: Object.freeze({
rect: Object.freeze({ r0: 9, r1: 12, c0: 0, c1: 20 }), margin: 0.25, headroom: 1.35, tilt: 30, dist: 18,
official: Object.freeze({ L: 'mid_battle_camera_param', R: 'mid_battle_camera_param' }),
half: Object.freeze({ L: 'left_battle_camera_param', R: 'right_battle_camera_param' }),
}),
boss: Object.freeze({
rect: Object.freeze({ r0: 0, r1: 5, c0: 0, c1: 20 }), margin: 0.2, headroom: 1.5, tilt: 30, dist: 18,
official: Object.freeze({ L: 'mid_boss_battle_camera_param', R: 'mid_boss_battle_camera_param' }),
half: Object.freeze({ L: 'left_boss_battle_camera_param', R: 'right_boss_battle_camera_param' }),
}),
bossPrep: Object.freeze({
rect: Object.freeze({ r0: 0, r1: 5, c0: 0, c1: 10 }), margin: 0.35, headroom: 1.25, tilt: 30, dist: 13,
official: Object.freeze({ L: 'left_boss_shop_camera_param', R: 'right_boss_shop_camera_param' }),
officialNoShop: Object.freeze({ L: 'left_boss_prepare_camera_param', R: 'right_boss_prepare_camera_param' }),
}),
pen: Object.freeze({
rect: Object.freeze({ r0: 14, r1: 18, c0: 7, c1: 13 }), margin: 0.3, headroom: 1.2, tilt: 30, dist: 11,
official: Object.freeze({ L: 'enemy_camera_param', R: 'enemy_camera_param' }),
}),
});
const EPS = 1e-6;
const MIN_DEPTH = 0.05;
const finite = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
/**
* Perspective camera. Mutable; call `update()` after changing fields directly (setters do it for you).
*/
export class Camera {
/**
* @param {{ tx?: number, ty?: number, tz?: number, tilt?: number, dist?: number, scale?: number, cx?: number, cy?: number }} [o]
*/
constructor(options) {
const o = options && typeof options === 'object' ? options : {};
this.tx = finite(o.tx, 5);
this.ty = finite(o.ty, 10);
this.tz = finite(o.tz, 0);
this.tilt = clamp(finite(o.tilt, DEFAULT_OPTICS.tilt), 0, 80);
this.dist = Math.max(1, finite(o.dist, DEFAULT_OPTICS.dist));
this.scale = Math.max(EPS, finite(o.scale, 64));
this.cx = finite(o.cx, 0);
this.cy = finite(o.cy, 0);
/** bumped by update(); lets consumers cache projections */
this.version = 0;
this.update();
}
/** Recompute derived values (camera position, basis). */
update() {
const t = this.tilt * DEG;
this._s = Math.sin(t);
this._c = Math.cos(t);
this._px = this.tx;
this._py = this.ty - this.dist * this._s;
this._pz = this.tz + this.dist * this._c;
this._k = this.scale * this.dist;
this.version++;
return this;
}
/** Copy every parameter from another camera. */
copy(o) {
this.tx = o.tx; this.ty = o.ty; this.tz = o.tz; this.tilt = o.tilt; this.dist = o.dist;
this.scale = o.scale; this.cx = o.cx; this.cy = o.cy;
return this.update();
}
clone() { return new Camera(this.params()); }
/** Plain parameter object (serialisable). */
params() {
return { tx: this.tx, ty: this.ty, tz: this.tz, tilt: this.tilt, dist: this.dist, scale: this.scale, cx: this.cx, cy: this.cy };
}
/** World position of the camera (the pinhole). */
position() { return { x: this._px, y: this._py, z: this._pz }; }
/** Focal length in CSS px (screen distance of the image plane). */
focal() { return this._k; }
/** Depth of a world point along the view axis (≥ MIN_DEPTH). */
depthOf(x, y, z = 0) {
const d = (y - this._py) * this._s - (z - this._pz) * this._c;
return d > MIN_DEPTH ? d : MIN_DEPTH;
}
/**
* Project a world point. Writes `{ x, y, s, depth }` into `out` (allocated when omitted):
* screen position (CSS px) and `s` = CSS px per world unit at that point.
*/
project(x, y, z = 0, out = { x: 0, y: 0, s: 0, depth: 0 }) {
const vx = x - this._px, vy = y - this._py, vz = z - this._pz;
let depth = vy * this._s - vz * this._c;
if (!(depth > MIN_DEPTH)) depth = MIN_DEPTH;
const up = vy * this._c + vz * this._s;
const k = this._k / depth;
out.x = this.cx + vx * k;
out.y = this.cy - up * k;
out.s = k;
out.depth = depth;
return out;
}
/** CSS px per world unit at a world point. */
scaleAt(x, y, z = 0) { return this._k / this.depthOf(x, y, z); }
/**
* Inverse projection onto the horizontal plane at height `z`. Returns `{ x, y }` (written into `out`) or
* null when the view ray does not hit the plane in front of the camera.
*/
unproject(sx, sy, z = 0, out = { x: 0, y: 0 }) {
// Ray direction for screen point: d = f + r·a + u·b with a = (sx−cx)/k, b = −(sy−cy)/k.
const a = (sx - this.cx) / this._k;
const b = -(sy - this.cy) / this._k;
const dx = a;
const dy = this._s + b * this._c;
const dz = -this._c + b * this._s;
if (Math.abs(dz) < EPS) return null;
const t = (z - this._pz) / dz;
if (!(t > 0) || !Number.isFinite(t)) return null;
out.x = this._px + t * dx;
out.y = this._py + t * dy;
return out;
}
/** The equivalent THREE.PerspectiveCamera parameters for a `width`×`height` CSS px viewport (see below). */
threeParams(width, height, opts) { return threeCameraParams(this, width, height, opts); }
}
// ---- three.js PerspectiveCamera equivalence ------------------------------------------------------------------
/**
* Parameters of the THREE.PerspectiveCamera that projects exactly like `cam` onto a `width`×`height` viewport:
* `{ fov (vertical, degrees), aspect, near, far, position: [x,y,z], up: [x,y,z], target: [x,y,z],
* view: { fullWidth, fullHeight, offsetX, offsetY, width, height } }` (three's setViewOffset: the off-axis
* principal point (cx, cy)). World axes are the projection's (x = col, y = row, z = up).
*/
export function threeCameraParams(cam, width, height, opts) {
const o = opts && typeof opts === 'object' ? opts : {};
const W = Math.max(1, finite(width, 1)), H = Math.max(1, finite(height, 1));
const t = cam.tilt * DEG, s = Math.sin(t), c = Math.cos(t);
const fovY = 2 * Math.atan(H / 2 / Math.max(EPS, cam.scale * cam.dist)) / DEG;
const pos = [cam.tx, cam.ty - cam.dist * s, cam.tz + cam.dist * c];
return {
fov: fovY, aspect: W / H,
near: Math.max(0.01, finite(o.near, cam.dist * 0.05)), far: Math.max(1, finite(o.far, cam.dist + 80)),
position: pos, up: [0, c, s], target: [cam.tx, cam.ty, cam.tz],
view: { fullWidth: W, fullHeight: H, offsetX: W / 2 - cam.cx, offsetY: H / 2 - cam.cy, width: W, height: H },
};
}
/**
* Configure a THREE.PerspectiveCamera (duck-typed: fov, aspect, near, far, position, up, lookAt, setViewOffset,
* updateProjectionMatrix, updateMatrixWorld) to project exactly like `cam` on a `width`×`height` CSS px viewport.
*/
export function syncThreeCamera(cam, three, width, height, opts) {
const p = threeCameraParams(cam, width, height, opts);
three.fov = p.fov;
three.aspect = p.aspect;
three.near = p.near;
three.far = p.far;
three.position.set(p.position[0], p.position[1], p.position[2]);
three.up.set(p.up[0], p.up[1], p.up[2]);
three.lookAt(p.target[0], p.target[1], p.target[2]);
const v = p.view;
three.setViewOffset(v.fullWidth, v.fullHeight, v.offsetX, v.offsetY, v.width, v.height);
three.updateProjectionMatrix();
three.updateMatrixWorld(true);
return three;
}
/** Ease for camera transitions. */
export const easeInOutCubic = (t) => (t <= 0 ? 0 : t >= 1 ? 1 : t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
export const easeOutCubic = (t) => (t <= 0 ? 0 : t >= 1 ? 1 : 1 - Math.pow(1 - t, 3));
/** Interpolate two cameras into `out` (scale in log space so zooms feel linear). */
export function lerpCamera(a, b, t, out = new Camera()) {
const u = t <= 0 ? 0 : t >= 1 ? 1 : t;
const L = (p, q) => p + (q - p) * u;
out.tx = L(a.tx, b.tx); out.ty = L(a.ty, b.ty); out.tz = L(a.tz, b.tz);
out.tilt = L(a.tilt, b.tilt); out.dist = L(a.dist, b.dist);
out.scale = Math.exp(L(Math.log(Math.max(EPS, a.scale)), Math.log(Math.max(EPS, b.scale))));
out.cx = L(a.cx, b.cx); out.cy = L(a.cy, b.cy);
return out.update();
}
/** Normalised copy of a rect ({r0,r1,c0,c1} inclusive; swapped bounds fixed; clamped to the 19×21 grid). */
export function normRect(rect, rows = 19, cols = 21) {
const src = rect && typeof rect === 'object' ? rect : {};
let r0 = Math.round(finite(src.r0, 0)), r1 = Math.round(finite(src.r1, rows - 1));
let c0 = Math.round(finite(src.c0, 0)), c1 = Math.round(finite(src.c1, cols - 1));
if (r0 > r1) [r0, r1] = [r1, r0];
if (c0 > c1) [c0, c1] = [c1, c0];
return { r0: clamp(r0, 0, rows - 1), r1: clamp(r1, 0, rows - 1), c0: clamp(c0, 0, cols - 1), c1: clamp(c1, 0, cols - 1) };
}
/**
* Camera that frames `rect` inside the viewport.
* @param {{r0,r1,c0,c1}} rect inclusive tile bounds
* @param {{ width: number, height: number, padding?: { top?, right?, bottom?, left? } }} viewport CSS px
* @param {{ tilt?, dist?, margin?: number, headroom?: number, maxTilePx?: number, minTilePx?: number }} [opts]
* margin (tiles) around the rect; headroom (tiles) of vertical space kept above the far row for units.
*/
export function fitCamera(rect, viewport, options) {
const opts = options && typeof options === 'object' ? options : {};
const R = normRect(rect);
const W = Math.max(1, finite(viewport?.width, 1));
const H = Math.max(1, finite(viewport?.height, 1));
const pad = viewport?.padding || {};
const pt = Math.max(0, finite(pad.top, 0)), pr = Math.max(0, finite(pad.right, 0));
const pb = Math.max(0, finite(pad.bottom, 0)), pl = Math.max(0, finite(pad.left, 0));
const availW = Math.max(16, W - pl - pr), availH = Math.max(16, H - pt - pb);
const margin = Math.max(0, finite(opts.margin, 0.35));
const headroom = Math.max(0, finite(opts.headroom, 1.2));
const cam = new Camera({
tx: (R.c0 + R.c1) / 2, ty: (R.r0 + R.r1) / 2, tz: 0,
tilt: finite(opts.tilt, DEFAULT_OPTICS.tilt), dist: finite(opts.dist, DEFAULT_OPTICS.dist), scale: 1, cx: 0, cy: 0,
});
const x0 = R.c0 - 0.5 - margin, x1 = R.c1 + 0.5 + margin;
const y0 = R.r0 - 0.5 - margin, y1 = R.r1 + 0.5 + margin;
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
const p = { x: 0, y: 0, s: 0, depth: 0 };
for (const x of [x0, x1]) for (const y of [y0, y1]) for (const z of [0, headroom]) {
cam.project(x, y, z, p);
if (p.x < minX) minX = p.x; if (p.x > maxX) maxX = p.x;
if (p.y < minY) minY = p.y; if (p.y > maxY) maxY = p.y;
}
const bw = Math.max(EPS, maxX - minX), bh = Math.max(EPS, maxY - minY);
let s = Math.min(availW / bw, availH / bh);
const maxTile = finite(opts.maxTilePx, 360), minTile = finite(opts.minTilePx, 4);
s = clamp(s, minTile, maxTile);
cam.scale = s;
cam.cx = pl + availW / 2 - s * (minX + maxX) / 2;
cam.cy = pt + availH / 2 - s * (minY + maxY) / 2;
return cam.update();
}
/** Parse a configBlackBoard camera param "(x,y,z,w)" → [x, y, z, w] (null when malformed). */
export function parseCameraParam(v) {
if (Array.isArray(v)) {
const a = v.map(Number);
return a.length >= 3 && a.slice(0, 3).every(Number.isFinite) ? [a[0], a[1], a[2], Number.isFinite(a[3]) ? a[3] : 1] : null;
}
if (typeof v !== 'string') return null;
const nums = v.replace(/[()\s]/g, '').split(',').map(Number);
if (nums.length < 3 || !nums.slice(0, 3).every(Number.isFinite)) return null;
return [nums[0], nums[1], nums[2], Number.isFinite(nums[3]) ? nums[3] : 1];
}
/**
* The official camera of a configBlackBoard param on a `width`×`height` viewport (see the header): position =
* OFFICIAL.base + param (+ the narrow-screen adjustment), pitch 30°, vertical FOV 40°, centred principal point.
* @param {string|number[]} param "(x,y,z,w)" or [x,y,z]
* @param {{ width: number, height: number }} viewport
*/
export function officialCamera(param, viewport, opts) {
const o = opts && typeof opts === 'object' ? opts : {};
const p = parseCameraParam(param) || [0, 0, 0, 1];
const W = Math.max(1, finite(viewport?.width, 1920)), H = Math.max(1, finite(viewport?.height, 1080));
const pitch = finite(o.pitch, OFFICIAL.pitch), fovY = finite(o.fovY, OFFICIAL.fovY);
const base = Array.isArray(o.base) ? o.base : OFFICIAL.base;
// narrow screens (up to 4:3) back the camera off like the client; wider ones keep the 16:9 position
const ratio = H / W;
const k = clamp((9 / 16 - ratio) / (9 / 16 - 3 / 4), 0, 1);
const adj = OFFICIAL.aspectAdjust;
const ux = p[0] + base[0] + adj[0] * k, uy = p[1] + base[1] + adj[1] * k, uz = p[2] + base[2] + adj[2] * k;
const cx = ux + OFFICIAL.center[0], cy = uy + OFFICIAL.center[1], height = Math.max(0.5, -uz);
const t = clamp(pitch, 1, 80) * DEG;
const dist = height / Math.cos(t);
const focal = (H / 2) / Math.tan((clamp(fovY, 5, 150) * DEG) / 2);
return new Camera({ tx: cx, ty: cy + height * Math.tan(t), tz: 0, tilt: pitch, dist, scale: focal / dist, cx: W / 2, cy: H / 2 });
}
const sameRect = (a, b) => !!a && !!b && a.r0 === b.r0 && a.r1 === b.r1 && a.c0 === b.c0 && a.c1 === b.c1;
/**
* Preset camera for a field kind: 'prep' | 'normal' | 'unite' | 'boss' ('hidden') | 'bossPrep' | 'pen'.
* Official framing (configBlackBoard params, `opts.config` = stage.config overrides the defaults) unless the
* viewport is narrower than 4:3 (portrait), `opts.fit` is set, or `opts.rect` is a custom rect — then the rect is
* fitted into the padded viewport (`fitCamera`). `opts.side` 'L'|'R' picks the side's param (right boss half);
* `opts.half` frames only that half of a unite/boss field; `opts.shop === false` uses the shop-collapsed prep camera.
*/
export function presetCamera(kind, viewport, options) {
const opts = options && typeof options === 'object' ? options : {};
let k = kind === 'hidden' ? 'boss' : kind;
if (!CAMERA_PRESETS[k]) k = 'normal';
let rect = opts.rect ? normRect(opts.rect) : null;
// the Final Assault prep happens on the boss field: a 'prep' camera asked for the boss rows is the boss one
if (k === 'prep' && rect && rect.r1 <= 6) k = 'bossPrep';
const preset = CAMERA_PRESETS[k];
const side = opts.side === 'R' ? 'R' : 'L';
const W = Math.max(1, finite(viewport?.width, 1)), H = Math.max(1, finite(viewport?.height, 1));
const knownRect = !rect || sameRect(rect, preset.rect) || (k === 'prep' && rect.r0 >= 6 && rect.r1 <= 12 && rect.c1 <= 10)
|| (k === 'normal' && rect.r0 >= 6 && rect.r1 <= 13) || (k === 'unite' && rect.r0 >= 6 && rect.r1 <= 13)
|| (k === 'boss' && rect.r1 <= 6) || k === 'bossPrep' || k === 'pen';
const useOfficial = !opts.fit && knownRect && W / H >= 4 / 3 - 1e-6;
if (useOfficial) {
const table = opts.half && preset.half ? preset.half : opts.shop === false && preset.officialNoShop ? preset.officialNoShop : preset.official;
const key = table[side];
const cfg = opts.config && typeof opts.config === 'object' ? opts.config : null;
const param = (cfg && parseCameraParam(cfg[key])) || parseCameraParam(OFFICIAL_PARAMS[key]);
return officialCamera(param, { width: W, height: H }, opts);
}
if (!rect) {
rect = { ...preset.rect };
// the right-hand Final Assault player prepares on the right boss half (mirrored col c → 20 − c)
if (k === 'bossPrep' && side === 'R') rect = { ...rect, c0: 20 - preset.rect.c1, c1: 20 - preset.rect.c0 };
}
if (k === 'prep') rect = { ...rect, r0: Math.min(rect.r0, 7) };
if (opts.half && (k === 'boss' || k === 'unite')) {
const mid = Math.floor((rect.c0 + rect.c1) / 2);
rect = side === 'R' ? { ...rect, c0: mid } : { ...rect, c1: mid };
}
// a narrow rect (e.g. a solo boss half) keeps the normal optics; wide ones use the preset's wider lens
const wide = rect.c1 - rect.c0 >= 14;
const base = wide ? preset : CAMERA_PRESETS[k === 'prep' || k === 'bossPrep' ? 'prep' : 'normal'];
return fitCamera(rect, viewport, {
tilt: finite(opts.tilt, base.tilt), dist: finite(opts.dist, base.dist),
margin: finite(opts.margin, preset.margin), headroom: finite(opts.headroom, preset.headroom),
maxTilePx: opts.maxTilePx, minTilePx: opts.minTilePx,
});
}
/**
* Tile under a screen point, honouring raised tiles: planes are tried from the highest height down, and a hit
* counts only when the tile found there really has that height. Returns `{ row, col }` or null (off-grid).
* @param {Camera} cam
* @param {(row:number, col:number) => number} heightAt tile top height (0 for low / off-grid)
* @param {number[]} [levels] distinct heights to test (descending), default [heights present…, 0]
*/
export function pickTile(cam, sx, sy, heightAt, levels = [0], rows = 19, cols = 21) {
const p = { x: 0, y: 0 };
const hs = [...levels].sort((a, b) => b - a);
if (!hs.includes(0)) hs.push(0);
for (const h of hs) {
if (!cam.unproject(sx, sy, h, p)) continue;
const col = Math.round(p.x) + 0, row = Math.round(p.y) + 0; // + 0 turns -0 into 0
if (row < 0 || row >= rows || col < 0 || col >= cols) {
if (h === 0) return null;
continue;
}
const th = heightAt ? finite(heightAt(row, col), 0) : 0;
if (h === 0 || Math.abs(th - h) < 1e-3) return { row, col, x: p.x, y: p.y };
}
return null;
}
/** Screen-space quad (4 projected corners, counter-clockwise from near-left) of a tile top at height z. */
export function tileQuad(cam, row, col, z = 0, inset = 0) {
const a = 0.5 - inset;
return [
cam.project(col - a, row - a, z, { x: 0, y: 0, s: 0, depth: 0 }),
cam.project(col + a, row - a, z, { x: 0, y: 0, s: 0, depth: 0 }),
cam.project(col + a, row + a, z, { x: 0, y: 0, s: 0, depth: 0 }),
cam.project(col - a, row + a, z, { x: 0, y: 0, s: 0, depth: 0 }),
];
}
+361
View File
@@ -0,0 +1,361 @@
// render/spine.js — Spine battle chibi wrapper + animation state machine (research 07 §5.4–5.5, ASSETS.md Roles).
//
// SpineActor owns one PIXI.spine.Spine built from cached skeleton data (assets.spine LRU; the instance never
// owns the atlas, so destroying it never frees shared textures). Animation roles come from the manifest
// (`anims`: idle, deploy, attack{begin,loop,end}, attackDown, skill{begin,loop,end,idle}, die, move, stun).
//
// Driving (units.js calls these; the sim is authoritative, the actor only visualises):
// setBase('idle'|'move'|'stun') the resting state from the snapshot anim code
// attack(interval) one attack happened now (b.ev 'atk'): plays begin→loop, re-phases the loop
// so its OnAttack frame lands now, timeScale = loopDuration / interval
// setSkill(on) skill begin→loop while active (skill idle replaces idle), end on stop
// deploy() 'Start' once, then base
// die() die clip once (callers fade out afterwards)
// stunned (setBase('stun')) stun clip, or the current track frozen at timeScale 0
// update(dt) advances the skeleton (autoUpdate is off: one clock for everything)
// Attack mode lasts until ~1.4 attack intervals without a new attack, then the end clip (if any) and base.
const clampN = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
/** Longest wind-up compression (× the rhythm speed) when the look-ahead is shorter than the natural wind-up. */
export const MAX_WIND_SPEEDUP = 2.5;
/**
* Attack clip timing (pure). `loopDur` / `hit` are clip seconds (strike frame at `hit`), `interval` and `lead` game
* seconds. The clip plays at `ts = loopDur / interval` (one loop per attack); started `lead` before the attack it
* reaches the strike frame on time: from `start = hit − lead·ts` at `ts` when little of the wind-up is lost,
* otherwise from (nearly) the beginning at up to MAX_WIND_SPEEDUP × ts (`tsWind`).
* @returns {{ ts: number, tsWind: number, start: number }}
*/
export function windUpPlan(loopDur, hit, interval, lead) {
const ts = clampN(loopDur / Math.max(0.08, interval), 0.35, 4);
const L = Math.max(0, lead);
let start = Math.max(0, hit - L * ts), tsWind = ts;
if (start > hit * 0.15 && L > 0) {
tsWind = Math.min(ts * MAX_WIND_SPEEDUP, hit / L);
start = Math.max(0, hit - L * tsWind);
}
return { ts, tsWind, start };
}
/** Whether any skin of the skeleton data has a clipping attachment (pixi-spine AttachmentType.Clipping = 6). */
export function hasClipping(data) {
try {
for (const skin of data?.skins || []) {
const list = typeof skin.getAttachments === 'function' ? skin.getAttachments() : [];
for (const e of list) {
const a = e && e.attachment;
if (a && (a.type === 6 || a.constructor?.name === 'ClippingAttachment' || ('endSlot' in a && 'vertices' in a && !('uvs' in a)))) return true;
}
}
} catch { /* unknown runtime shape: assume none */ }
return false;
}
export class SpineActor {
/**
* @param {any} spineData PIXI.spine skeleton data
* @param {object} entry manifest Spine entry (anims, animations, hits, bounds, pma)
*/
constructor(spineData, entry) {
const P = globalThis.PIXI;
this.entry = entry;
this.roles = entry.anims || {};
this.durations = entry.animations || {};
this.spine = new P.spine.Spine(spineData);
this.spine.autoUpdate = false;
this.names = new Set((spineData.animations || []).map((a) => a.name));
/**
* Clipping attachments render as stencil masks (≈1.5 ms of GPU each per frame on tiled GPUs): such skeletons
* are drawn through the impostor atlas while clipping is on, and clipping is switched off (unclipped slots,
* visually negligible on battle chibis) when too many of them share a field (app.js budget).
*/
this.clipped = hasClipping(spineData);
this.clipOn = true;
if (this.clipped) {
const sp = this.spine;
const orig = typeof sp.createGraphics === 'function' ? sp.createGraphics.bind(sp) : null;
if (orig) sp.createGraphics = (slot, att) => { const g = orig(slot, att); if (!this.clipOn && slot.clippingContainer) { slot.clippingContainer.mask = null; g.renderable = false; } return g; };
}
try { this.spine.stateData.defaultMix = 0.12; } catch { /* ignore */ }
this.base = 'idle';
this.mode = 'base'; // base | attack | skillBegin | deploy | die | stun
this.skillOn = false;
this.attackUntil = 0;
this.clock = 0;
this.current = '';
this.frozen = false;
this.dead = false;
this.interval = 1;
this._play(this._idleName(), true);
}
/** Enable / disable the skeleton's clipping masks. */
setClipping(on) {
on = !!on;
if (!this.clipped || on === this.clipOn) return;
this.clipOn = on;
for (const slot of this.spine?.skeleton?.slots || []) {
if (!slot.clippingContainer) continue;
slot.clippingContainer.mask = on ? slot.currentGraphics || null : null;
// PIXI makes a released mask renderable again: the clip polygon must never draw as a white shape
if (slot.currentGraphics) slot.currentGraphics.renderable = false;
}
}
/**
* The equipped skill (DESIGN §16 loadout, UnitInfo.skillIndex): its own Spine clip when the model has one per skill
* index (`anims.skills`), else the primary skill's clip.
* @param {number|undefined} index 0-based skill index
*/
setSkillIndex(index) {
const anims = this.entry?.anims || {};
const clip = Number.isInteger(index) && anims.skills ? anims.skills[String(index)] : null;
this.roles = clip ? { ...anims, skill: clip } : anims;
}
has(name) { return !!name && this.names.has(name); }
dur(name) { const d = this.durations[name]; return typeof d === 'number' && d > 0 ? d : this._durFromData(name); }
_durFromData(name) {
try { const a = this.spine.spineData.findAnimation(name); return a && a.duration > 0 ? a.duration : 1; } catch { return 1; }
}
_idleName() {
const sk = this.roles.skill;
if (this.skillOn && sk && this.has(sk.idle)) return sk.idle;
return this.has(this.roles.idle) ? this.roles.idle : (this.has('Idle') ? 'Idle' : [...this.names][0]);
}
_baseName() {
if (this.base === 'move') {
const mv = this.roles.move;
if (mv && this.has(mv.loop)) return mv.loop;
}
return this._idleName();
}
_play(name, loop, { timeScale = 1, mix, track = 0, start = 0 } = {}) {
if (!this.has(name)) return false;
const st = this.spine.state;
const e = st.setAnimation(track, name, loop);
if (e) {
e.timeScale = timeScale;
if (mix != null) e.mixDuration = mix;
if (start) e.trackTime = start;
}
this.current = name;
return true;
}
_queue(name, loop, timeScale = 1) {
if (!this.has(name)) return false;
const e = this.spine.state.addAnimation(0, name, loop, 0);
if (e) e.timeScale = timeScale;
return true;
}
/** Resting state from the snapshot. */
setBase(base) {
if (this.dead) return;
const b = base === 'move' || base === 'stun' ? base : 'idle';
if (b === 'stun') { this._enterStun(); return; }
if (this.mode === 'stun') this._leaveStun();
if (b === this.base && this.mode !== 'stun') return;
this.base = b;
if (this.mode === 'base') this._play(this._baseName(), true);
}
_enterStun() {
if (this.mode === 'stun') return;
this.mode = 'stun';
const s = this.roles.stun;
if (s && this.has(s.loop)) {
if (this.has(s.begin)) { this._play(s.begin, false); this._queue(s.loop, true); }
else this._play(s.loop, true);
} else {
this.frozen = true;
}
}
_leaveStun() {
this.frozen = false;
this.mode = 'base';
this._play(this._baseName(), true);
}
/**
* An attack is due in `lead` game seconds (the renderer sees it ahead in the snapshot buffer): start the attack
* clip from its wind-up so the strike frame lands when the attack event is rendered. Returns true once started;
* false when it is still too early (call again next frame) or there is nothing to wind up.
*/
windUp(interval, lead) {
if (this.dead || this.mode === 'stun' || this.mode === 'die' || !(lead >= 0)) return false;
const clip = this._attackClip();
if (!clip) return false;
if (this.mode === 'attack' && this.current === clip.loop) return false; // in rhythm: attack() re-phases
const iv = clampN(Number.isFinite(interval) && interval > 0 ? interval : this.interval, 0.08, 8);
const loopDur = this.dur(clip.loop);
const hit = this._hitTime(clip.loop, loopDur);
const plan = windUpPlan(loopDur, hit, iv, lead);
if (!(hit > 0) || lead * plan.ts > hit + 1e-6) return false;
this.interval = iv;
this.mode = 'attack';
this.attackUntil = this.clock + lead + Math.max(0.45, iv * 1.4);
this._play(clip.loop, true, { timeScale: plan.tsWind, start: plan.start, mix: 0.06 });
this.windTs = plan.ts;
this.windUntil = this.clock + lead;
return true;
}
/** An attack happened now. `interval` = seconds between attacks (game time already scaled to real). */
attack(interval) {
if (this.dead || this.mode === 'stun' || this.mode === 'die') return;
this.interval = clampN(Number.isFinite(interval) && interval > 0 ? interval : this.interval, 0.08, 8);
const clip = this._attackClip();
if (!clip) return;
const loopDur = this.dur(clip.loop);
const ts = clampN(loopDur / this.interval, 0.35, 4);
const hit = this._hitTime(clip.loop, loopDur);
const wasAttacking = this.mode === 'attack' && this.current === clip.loop;
this.mode = 'attack';
this.attackUntil = this.clock + Math.max(0.45, this.interval * 1.4);
this.windUntil = null;
if (!wasAttacking) {
// not wound up (no look-ahead, e.g. a batch that arrived late): the sim already resolved the hit, so show
// the strike frame now
this._play(clip.loop, true, { timeScale: ts, start: hit, mix: 0.06 });
} else {
const e = this.spine.state.tracks[0];
if (e) {
e.timeScale = ts;
// re-phase gently so the strike frame lines up with this attack
const t = e.trackTime % loopDur;
let d = hit - t;
if (d > loopDur / 2) d -= loopDur; else if (d < -loopDur / 2) d += loopDur;
e.trackTime += d * 0.8;
}
}
}
_attackClip() {
const sk = this.roles.skill;
if (this.skillOn && sk && this.has(sk.loop) && sk.via !== 'attack' && !this._skillIsBuffOnly()) return sk;
const a = this.roles.attack;
if (a && this.has(a.loop)) return a;
return null;
}
// Skill loops that are pure stances (no OnAttack in the loop) are still valid attack visuals during a skill;
// only an idle-typed skill loop is treated as buff-only.
_skillIsBuffOnly() {
const sk = this.roles.skill;
return !!sk && sk.loop === this.roles.idle;
}
_hitTime(anim, dur) {
const hits = this.entry.hits && this.entry.hits[anim];
if (Array.isArray(hits) && hits.length && Number.isFinite(hits[0])) return clampN(hits[0], 0, dur);
return dur * 0.5;
}
/** Skill active flag changed. */
setSkill(on) {
on = !!on;
if (on === this.skillOn || this.dead) return;
this.skillOn = on;
const sk = this.roles.skill;
if (this.mode === 'stun' || this.mode === 'die') return;
if (on && sk) {
if (this.has(sk.begin)) {
this.mode = 'skillBegin';
this._play(sk.begin, false, { mix: 0.08 });
this.skillBeginUntil = this.clock + this.dur(sk.begin);
if (this.has(sk.loop)) this._queue(sk.loop, true);
} else if (this.mode === 'base') this._play(this._baseName(), true);
} else if (!on && sk) {
if (this.has(sk.end)) {
this.mode = 'skillEnd';
this._play(sk.end, false, { mix: 0.08 });
this.skillEndUntil = this.clock + this.dur(sk.end);
} else {
this.mode = 'base';
this._play(this._baseName(), true);
}
}
}
deploy() {
if (this.dead) return;
const d = this.roles.deploy;
if (this.has(d) && d !== this.roles.idle) {
this.mode = 'deploy';
this._play(d, false, { mix: 0 });
this.deployUntil = this.clock + this.dur(d);
}
}
/** Play the death clip; returns its duration (0 when there is none). */
die() {
if (this.dead) return 0;
this.dead = true;
this.frozen = false;
this.mode = 'die';
const d = this.roles.die || (this.has('Die') ? 'Die' : null);
if (d && this.has(d)) { this._play(d, false, { mix: 0.05 }); return this.dur(d); }
return 0;
}
/** Revive (redeploy after death). */
revive() {
this.dead = false;
this.mode = 'base';
this.skillOn = false;
this._play(this._baseName(), true);
}
update(dt) {
this.clock += dt;
if (this.windUntil != null && this.clock >= this.windUntil) {
// a compressed wind-up reached its strike frame: back to the rhythm speed (attack() also does it)
this.windUntil = null;
const e = this.spine.state.tracks[0];
if (e && this.mode === 'attack') e.timeScale = this.windTs;
}
switch (this.mode) {
case 'attack':
if (this.clock > this.attackUntil) {
this.mode = 'base';
const clip = this._attackClip() || this.roles.attack;
if (clip && this.has(clip.end)) { this._play(clip.end, false); this._queue(this._baseName(), true); }
else this._play(this._baseName(), true, { mix: 0.15 });
}
break;
case 'skillBegin':
if (this.clock >= this.skillBeginUntil) { this.mode = 'base'; if (!this.has(this.roles.skill?.loop)) this._play(this._baseName(), true); }
break;
case 'skillEnd':
if (this.clock >= this.skillEndUntil) { this.mode = 'base'; this._play(this._baseName(), true); }
break;
case 'deploy':
if (this.clock >= this.deployUntil) { this.mode = 'base'; this._play(this._baseName(), true); }
break;
default: break;
}
if (!this.frozen) {
try { this.spine.update(dt); } catch { /* a broken skeleton must not stop the frame */ }
}
}
/** Model height in skeleton units (setup-pose bounds, else a chibi default). */
get height() {
const b = this.entry.bounds;
if (b && Number.isFinite(b.height) && b.height > 20) return Math.min(b.height, 900);
return 380;
}
destroy() {
try { this.spine.destroy({ children: true, texture: false, baseTexture: false }); } catch { /* ignore */ }
this.spine = null;
}
}
+138
View File
@@ -0,0 +1,138 @@
// render/style.js — palette and visual tunables of the battlefield renderer (pure data).
// Colours follow research 07 §7 (tile palette, tuned by eye) and the UI design system (theme.css).
export const TILE_H = Object.freeze({
wall: 0.42, // 'h' high ground
forbid: 0.3, // '#' forbidden block (data: HIGH)
sep: 0.55, // 'X' hard separator wall (rows 6 / 13)
bench: 0.16, // 'a' / 'A' hand & temp slots (data: HIGH)
platform: 0.26, // active 射击台 / mound devices
});
/** Stage glyph → material + height class (data/stages.json legend, DATA.md §12). */
export const GLYPH = Object.freeze({
'#': { mat: 'forbid', h: 'forbid' },
X: { mat: 'sep', h: 'sep' },
r: { mat: 'road', h: null },
R: { mat: 'roadN', h: null },
f: { mat: 'floor', h: null },
p: { mat: 'preview', h: null },
h: { mat: 'wall', h: 'wall' },
b: { mat: 'fence', h: null },
a: { mat: 'hand', h: 'bench' },
A: { mat: 'temp', h: 'bench' },
S: { mat: 'start', h: null },
E: { mat: 'end', h: null },
I: { mat: 'telin', h: null },
O: { mat: 'telout', h: null },
m: { mat: 'mire', h: null },
g: { mat: 'smog', h: null },
d: { mat: 'deepsea', h: null },
i: { mat: 'infection', h: null },
});
/** tileKey (when a stage uses glyphs outside the legend) → glyph. */
export const TILEKEY_GLYPH = Object.freeze({
tile_forbidden: '#', tile_road: 'r', tile_floor: 'f', tile_wall: 'h', tile_fence_bound: 'b', tile_fence: 'b',
tile_achand: 'a', tile_start: 'S', tile_end: 'E', tile_telin: 'I', tile_telout: 'O', tile_mire: 'm',
tile_smog: 'g', tile_deepsea: 'd', tile_infection: 'i', tile_grass: 'r', tile_hole: '#',
});
export const COLORS = Object.freeze({
bgTop: '#0b0f0e',
bgMid: '#131a18',
bgBottom: '#07090a',
fog: [0.62, 0.68, 0.72],
mint: 0x4ed8af,
mintHi: 0x59f4ca,
gold: 0xffc600,
gateRed: 0xff3b30,
gateRedDeep: 0xd0453b,
objBlue: 0x39a7ff,
objBlueDeep: 0x2d7fd6,
legal: 0x3ce08c,
illegal: 0xff4040,
range: 0xff9c33,
rangeStand: 0x4ed8af,
hpAlly: 0x5fe07a,
hpAllyLow: 0xe8c547,
hpEnemy: 0xff4b3e,
hpBoss: 0xff2d55,
hpGhost: 0xfff0c8,
hpBack: 0x0c0f0e,
sp: 0x6fd3ff,
spReady: 0xffe066,
spActive: 0xffb347,
shield: 0xdfe8ff,
});
/** Chess tier accents (theme.css --tier-1…6) and rarity-ish frames for enemies. */
export const TIER_COLORS = Object.freeze([0x9aa5a0, 0x9aa5a0, 0x7fd37a, 0x52b6ff, 0xb98cff, 0xffc600, 0xff6b3d]);
export const ENEMY_FRAME = Object.freeze({ normal: 0xc84a3c, elite: 0xff7a33, boss: 0xff2d55 });
export const DMG_STYLE = Object.freeze({
phys: { font: 'sp-dmg-phys', fill: ['#fffbe8', '#ffb35c'], stroke: '#3b1400' },
arts: { font: 'sp-dmg-arts', fill: ['#fbe8ff', '#c77dff'], stroke: '#2a0b45' },
true: { font: 'sp-dmg-true', fill: ['#ffffff', '#e8e8e8'], stroke: '#2a2a2a' },
heal: { font: 'sp-dmg-heal', fill: ['#eafff0', '#62f08a'], stroke: '#07361a' },
elem: { font: 'sp-dmg-elem', fill: ['#fff4e0', '#ff7b3a'], stroke: '#3a1000' },
});
/** Map b.ev dmg types to a style key. */
export function dmgStyleKey(type) {
if (type === 'phys' || type === 'arts' || type === 'true' || type === 'heal') return type;
if (type === 'burn' || type === 'neural' || type === 'necrosis' || type === 'apoptosis' || type === 'erosion' || type === 'element') return 'elem';
return 'phys';
}
/** Hit spark colour per damage type. */
export const HIT_TINT = Object.freeze({ phys: 0xffd9a0, arts: 0xc77dff, true: 0xffffff, heal: 0x62f08a, elem: 0xff7b3a });
/** Projectile visuals per atk projKind (sim: none|arrow|bolt|bomb|lob|orb|drone|enemy|chain|chainHeal). */
export const PROJ = Object.freeze({
arrow: { speed: 14, tex: 'streak', tint: 0xfff2d0, len: 0.55, width: 0.09, trail: 0 },
bolt: { speed: 11, tex: 'orb', tint: 0xd49bff, len: 0.28, width: 0.28, trail: 0xa35cff },
orb: { speed: 11, tex: 'orb', tint: 0x9dffb8, len: 0.26, width: 0.26, trail: 0x4ee07a },
bomb: { speed: 8, tex: 'orb', tint: 0xffb066, len: 0.24, width: 0.24, trail: 0xff7a33, arc: 0.9 },
lob: { speed: 8, tex: 'orb', tint: 0xffc78a, len: 0.22, width: 0.22, trail: 0xffa04a, arc: 1.2 },
drone: { speed: 16, tex: 'orb', tint: 0x8fe6ff, len: 0.16, width: 0.16, trail: 0x57c9ff },
enemy: { speed: 12, tex: 'orb', tint: 0xff6a5a, len: 0.22, width: 0.22, trail: 0xff3b30 },
});
/** Status keys (b.ev 'status' + UF flags) → icon atlas key (render/textures.js) and colour. */
export const STATUS_ICON = Object.freeze({
stun: 'stun', freeze: 'freeze', cold: 'cold', stealth: 'stealth', shield: 'shield', fragile: 'fragile',
artsFragile: 'fragile', physFragile: 'fragile', elemFragile: 'fragile', sleep: 'sleep', invulnerable: 'invuln',
silence: 'silence', slow: 'slow', sluggish: 'slow', bind: 'bind', fear: 'fear', tremble: 'fear', weaken: 'weaken',
levitate: 'levitate', taunt: 'taunt', defDown: 'weaken', resDown: 'weaken', aspdDown: 'slow', disarm: 'silence',
burn: 'burn', burnBurst: 'burn', neural: 'neural', neuralBurst: 'neural', necrosis: 'necrosis', apoptosis: 'necrosis',
});
/** Keyword fallbacks for namespaced / content status keys ('ab:frost', 'reed2:scorch', 'skill:shotst_shred' …). */
const STATUS_GUESS = [
[/frost|chill|cold/i, 'cold'], [/freez/i, 'freeze'], [/scorch|burn|ignit|flame/i, 'burn'], [/stun|daze/i, 'stun'],
[/sleep|slumber/i, 'sleep'], [/silenc/i, 'silence'], [/slow|slugg|bind|root|snare/i, 'slow'], [/fear|trembl/i, 'fear'],
[/fragil|shred|expos|wanted|vulner|mark/i, 'fragile'], [/weak|down$/i, 'weaken'], [/shield|resist|guard|ward|barrier/i, 'shield'],
[/invul|immun/i, 'invuln'], [/stealth|camou|invis/i, 'stealth'], [/levit|float/i, 'levitate'], [/taunt/i, 'taunt'],
[/neural/i, 'neural'], [/necro|apopt|erosion/i, 'necrosis'],
];
/** Icon key (textures STATUS_KEYS) for a b.ev status key or flag name; null when it has no icon. */
export function statusIconKey(key) {
if (typeof key !== 'string' || !key) return null;
if (STATUS_ICON[key]) return STATUS_ICON[key];
const tail = key.includes(':') ? key.slice(key.lastIndexOf(':') + 1) : key;
if (STATUS_ICON[tail]) return STATUS_ICON[tail];
for (const [re, icon] of STATUS_GUESS) if (re.test(tail)) return icon;
return null;
}
export const UNIT = Object.freeze({
/** Spine skeleton units → world tiles (skeletons are ~360–450 units tall ⇒ ~1.2 tiles). */
modelScale: 1 / 320,
/** Fallback diamond size in tiles. */
diamond: 0.78,
headroom: 1.18, // tiles above the feet where bars sit (operators; enemies use their bounds)
barWidth: 0.64,
bossBarWidth: 2.2,
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff