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