Files
ark-stronghold-protocol/public/js/render/tiles.js
T
sganggs 01a44cd288 卫戍协议:盟约 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).
2026-09-29 16:21:31 +08:00

1178 lines
52 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// render/tiles.js — stage renderer (DESIGN §9, research 07 §7).
//
// The ground (low tile tops, the island's cliff edges) is one PIXI.Mesh with a custom perspective-correct shader:
// every quad carries (u·w, v·w, w) texture coordinates with w = 1/depth, so the material atlas (render/textures.js:
// the real 卫戍协议 board art of the local client when installed — render/boardArt.js — else procedural) maps onto
// the trapezoid tiles without the affine "kink". Vertices are re-projected only when the camera changes. Draw
// order (painter's): backdrop ground plane → cliff faces → low tops. The island stands on a large textured ground
// plane (the board art's concrete backdrop) with baked contact shadows, fading into fog with distance.
//
// Raised blocks (high ground, forbidden blocks, walls, bench pads) are one mesh PER ROW placed in the depth-sorted
// unit layer (outer→inner within the row, each block's visible sides before its top), keyed at the row's far edge
// (`rowDepthKey`): a unit standing farther away than a block row is drawn before it, so the block hides its feet /
// lower body; units on or in front of the row are drawn after it. Things lying ON a raised top (highlights, unit
// shadows, facing chevrons, ground rings) go into that row's surface container (`surfaceLayer(row)`), drawn right
// after the row's blocks and devices but before its units.
//
// Also:
// * highlight groups (legal green / illegal red / attack range orange / custom) — Graphics, redrawn on change,
// * gate (red) and objective (blue) wireframe boxes with warning glyphs, pulsing (redrawn per frame),
// * animated terrain: smog puffs, deep-sea shimmer, infection glow, mire bubbles, teleport rings,
// * devices: crates, platforms, sealed floors, blowers (▶▶ plate + airflow along rangeTiles), turrets are textured
// boxes in their row's block mesh; mounds and bushes are Graphics in the depth-sorted unit layer. Battle
// devices (sim units) use `BoxMesh`, the same textured box as a standalone mesh.
// High-ground plates pick a frame piece by connectivity (horizontal strip L/M/R, vertical strip B/VM/T, single).
// Tiles outside the playable area fade out with distance, so the board reads as a lit island over the backdrop.
//
// External board (`setExternal(true)`): the official 3D board scene (render/board3d) draws the tiles, blocks, gates,
// devices and animated terrain on its own canvas under the Pixi one. The field then keeps only what lies on top
// of the board — highlight groups, blower airflow streaks — plus the grid queries (tile / heightAt / levels) that
// units, FX and hit-testing use, so both layers agree on every height. No meshes, Graphics props or anim sprites
// are built; switching back (WebGL context lost, art missing) rebuilds the 2D board.
import { tileAtlas, fxAtlas, rng, groundTexture } from './textures.js';
import { GLYPH, TILEKEY_GLYPH, TILE_H, COLORS } from './style.js';
import { parsePenRect, PEN_RECT } from './pen.js';
/**
* Depth-sort key (unit layer zIndex) of block row `row`: the row's far edge. Units use
* `-cam.depthOf(x, y, 0) * 100`, so a unit is drawn after (in front of) the row's blocks iff y < row + ½.
*/
export const rowDepthKey = (cam, row) => -cam.depthOf(0, row + 0.5, 0) * 100;
/** Offsets above rowDepthKey: row blocks < devices on the row < things lying on the row's raised tops. */
export const ROW_KEY = Object.freeze({ blocks: 0, devices: 0.01, surface: 0.02 });
/** Gate / objective boxes (low tiles) of row `row`: after the row's blocks, before units standing in the box. */
export const boxDepthKey = (cam, row) => -cam.depthOf(0, row + 0.45, 0) * 100;
const ROWS = 19, COLS = 21;
const PLAYABLE_DIST = 2; // margin tiles farther than this from a playable tile are not drawn
const CLIFF_DEPTH = 0.62; // how far the island's edge faces go down to the ground plane (tiles)
/** Brightness of the board meshes (vertex colour above 1 lifts the mid-grey board art a little). */
const EXPOSURE = 1.12;
/** Ground plane extent (world tiles) and cell size; contact shadows are baked per vertex. */
const PLANE = Object.freeze({ x0: -16, x1: 36, y0: -10, y1: 32, cell: 1 });
const VERT = `
precision highp float;
attribute vec2 aVertexPosition;
attribute vec3 aUvq;
attribute vec4 aColor;
uniform mat3 translationMatrix;
uniform mat3 projectionMatrix;
varying vec3 vUvq;
varying vec4 vColor;
void main(void) {
gl_Position = vec4((projectionMatrix * translationMatrix * vec3(aVertexPosition, 1.0)).xy, 0.0, 1.0);
vUvq = aUvq;
vColor = aColor;
}`;
const FRAG = `
precision mediump float;
varying vec3 vUvq;
varying vec4 vColor;
uniform sampler2D uSampler;
uniform float uAlpha;
void main(void) {
vec4 c = texture2D(uSampler, vUvq.xy / vUvq.z);
gl_FragColor = vec4(c.rgb * vColor.rgb, c.a) * (vColor.a * uAlpha);
}`;
const HL_STYLES = {
legal: { color: COLORS.legal, fill: 0.26, line: 0.9 },
illegal: { color: COLORS.illegal, fill: 0.32, line: 0.95 },
range: { color: COLORS.range, fill: 0.3, line: 0.85 },
rangeStand: { color: COLORS.rangeStand, fill: 0.3, line: 0.9 },
hover: { color: 0xffffff, fill: 0.14, line: 0.55 },
target: { color: 0xffffff, fill: 0.22, line: 1 },
deploy: { color: 0x9fd4ff, fill: 0.12, line: 0.45 },
};
const SIDE_MAT = {
wall: 'wallSide', forbid: 'forbidSide', forbid2: 'forbidSide', sep: 'sepSide', hand: 'benchSide', temp: 'benchSideTemp',
fence: 'fenceSide', margin: 'forbidSide',
};
/**
* Parse a stage record into a per-tile info grid (tolerant of partial/malformed data). `band` = [r0, r1] rows drawn;
* `field` = [f0, f1] rows of the active field — drawn rows outside it are dim, non-playable scenery (e.g. the enemy
* preview pen behind the normal field, or the normal field behind the boss field).
*/
export function parseStage(stage, band = [0, ROWS - 1], field = [0, 13]) {
const rows = Array.isArray(stage?.rows) ? stage.rows : [];
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 inBand = r >= band[0] && r <= band[1];
const scenery = r < field[0] || r > field[1];
row.push({ r, c, glyph: g, mat: def.mat, hClass: def.h, h: def.h ? TILE_H[def.h] : 0, playable: inBand && !scenery && g !== '#' && g !== 'X', inBand, scenery, dist: 0, alpha: 1, drawn: true, focus: true });
}
grid.push(row);
}
// the enemy preview pen's floor (enemy_place_rect) is the glass hatch of the official pen, row 16 included
const pen = parsePenRect(stage?.config?.enemy_place_rect) || PEN_RECT;
for (let r = pen.r0; r <= pen.r1; r++) for (let c = pen.c0; c <= pen.c1; c++) {
const t = grid[r]?.[c];
if (t && (t.glyph === 'f' || t.glyph === 'p')) t.mat = 'penGlass';
}
// distance (Chebyshev) to the nearest playable tile → fade / skip the margin
const INF = 99;
for (const row of grid) for (const t of row) t.dist = t.playable ? 0 : INF;
for (let pass = 0; pass < PLAYABLE_DIST + 1; pass++) {
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
const t = grid[r][c];
if (t.dist === 0) continue;
let best = t.dist;
for (let dr = -1; dr <= 1; dr++) for (let dc = -1; dc <= 1; dc++) {
const n = grid[r + dr]?.[c + dc];
if (n && n.dist + 1 < best) best = n.dist + 1;
}
t.dist = best;
}
}
for (const row of grid) for (const t of row) {
t.drawn = t.inBand && (t.dist <= PLAYABLE_DIST || (t.scenery && t.glyph !== '#'));
t.alpha = t.dist === 0 ? 1 : t.dist === 1 ? 0.92 : 0.5;
if (t.glyph === '#' && t.dist >= 2) t.mat = 'margin';
}
// high-ground plates: frame piece by connectivity (texture top = the tile's far edge)
const wall = (r, c) => { const n = grid[r]?.[c]; return !!n && n.drawn && n.glyph === 'h'; };
for (const row of grid) for (const t of row) {
if (t.glyph !== 'h' || !t.drawn) continue;
const L = wall(t.r, t.c - 1), Rt = wall(t.r, t.c + 1);
if (L || Rt) t.top = L && Rt ? 'wallM' : Rt ? 'wallL' : 'wallR';
else {
const near = wall(t.r - 1, t.c), far = wall(t.r + 1, t.c);
t.top = near && far ? 'wallVM' : far ? 'wallB' : near ? 'wallT' : 'wall';
}
}
return grid;
}
const hash2 = (r, c) => ((r * 73856093) ^ (c * 19349663)) >>> 0;
/** Stripe spacing (tiles, along x) of striped range highlights. */
const STRIPE_STEP = 0.2;
/**
* Diagonal stripes (world x − y = const, like the official striped range tiles) clipped to tile (r, c)'s top at
* height z, drawn as light orange lines into `g`.
*/
export function stripeSegments(r, c, inset = 0.05, step = STRIPE_STEP) {
const a = 0.5 - inset;
const x0 = c - a, x1 = c + a, y0 = r - a, y1 = r + a;
const out = [];
for (let d = (x0 - y1) + step / 2; d < x1 - y0; d += step) {
// points of x − y = d inside the square: x from max(x0, y0 + d) to min(x1, y1 + d)
const xa = Math.max(x0, y0 + d), xb = Math.min(x1, y1 + d);
if (xb - xa > 1e-4) out.push([xa, xa - d, xb, xb - d]);
}
return out;
}
function drawStripes(g, cam, r, c, z, inset, lw, p) {
g.lineStyle(lw, 0xffc27a, 0.55, 0.5);
for (const [xa, ya, xb, yb] of stripeSegments(r, c, inset)) {
cam.project(xa, ya, z, p);
g.moveTo(p.x, p.y);
cam.project(xb, yb, z, p);
g.lineTo(p.x, p.y);
}
g.lineStyle(0);
}
function variantMat(t) {
if (t.top) return t.top;
const h = hash2(t.r, t.c) % 7;
switch (t.mat) {
case 'road': return h < 3 ? 'road' : h < 5 ? 'road2' : 'road3';
case 'roadN': return h < 4 ? 'roadN' : 'roadN2';
case 'floor': return h < 4 ? 'floor' : 'floor2';
case 'forbid': return h < 4 ? 'forbid' : 'forbid2';
default: return t.mat;
}
}
/** Device roles we draw, and whether they stand on the tile as a 3D prop. */
const DEVICE_ROLES = new Set(['crate', 'platform', 'mound', 'blower', 'turret', 'waterPlatform', 'bush', 'sealedFloor']);
/** Textured box props: role → { size (tiles), height, top / side materials }. */
export const DEVICE_BOX = Object.freeze({
crate: { size: 0.84, height: 0.74, top: 'crateTop', side: 'crateSide' },
platform: { size: 0.94, height: 0.26, top: 'platformTop', side: 'forbidSide' },
waterPlatform: { size: 0.94, height: 0.2, top: 'platformTop', side: 'lowSide' },
sealedFloor: { size: 0.92, height: 0.05, top: 'sealed', side: 'forbidSide' },
blower: { size: 0.86, height: 0.28, top: 'blowerTop', side: 'forbidSide' },
turret: { size: 0.72, height: 0.34, top: 'turretTop', side: 'forbidSide' },
});
/** Box prop of a battle device unit by its defId (sim `spawnDevice`: stage device keys). */
export function deviceBoxOf(defId) {
const k = String(defId || '');
if (k.includes('accrate') || k.includes('crate')) return DEVICE_BOX.crate;
if (k.includes('aclasert') || k.includes('turret')) return DEVICE_BOX.turret;
if (k.includes('achplat') || k.includes('plat')) return DEVICE_BOX.platform;
if (k.includes('blower')) return DEVICE_BOX.blower;
return null;
}
/** Texture rotation (clockwise quarter turns) so a ▶ icon on a device top points along its `dir`. */
const DIR_ROT = Object.freeze({ RIGHT: 0, DOWN: 1, LEFT: 2, UP: 3 });
export class TileField {
/**
* @param {{ ground: PIXI.Container, overlay: PIXI.Container, props: PIXI.Container, anim: PIXI.Container }} layers
* props must be the depth-sorted unit layer (sortableChildren).
*/
constructor(layers) {
const P = globalThis.PIXI;
this.P = P;
this.layers = layers;
this.atlas = tileAtlas();
this.art = null;
this.stage = null;
this.grid = parseStage(null);
this.quads = [];
this.mesh = null; // ground mesh (low tops + cliffs)
this.meshes = []; // every mesh record { quads, row, buffers…, mesh }
this.rowMeshes = new Map(); // block row → mesh record (in the unit layer)
this.rowSurfaces = new Map(); // block row → Container for things on its raised tops
this.shader = null;
this.plane = null; // backdrop ground plane mesh record
this.planeShader = null;
this.hlGfx = new P.Graphics();
this.boxes = []; // gate / objective wire boxes { r, c, gate, g } (in the unit layer)
this.flowGfx = new P.Graphics();
this.flowGfx.blendMode = P.BLEND_MODES.ADD;
layers.overlay.addChild(this.hlGfx, this.flowGfx);
this.animLayer = new P.Container();
layers.anim.addChild(this.animLayer);
this.highlights = new Map(); // group → { tiles: [[r,c]], style }
this.devices = []; // Graphics props { dev, gfx, row, col, role } (mounds, bushes)
this.devBoxes = []; // textured box props (in the row meshes) { dev, row, col, role }
this.blowers = []; // { dev, row, col, dir } airflow sources
this.animSprites = []; // { sprite, r, c, kind, phase }
this.battleRect = null;
this.cam = null;
this.camVersion = -1;
this.time = 0;
this.flashes = []; // objective leak flashes { r, c, t }
this._p = { x: 0, y: 0, s: 0, depth: 0 };
this.levels = [0];
this.band = [0, ROWS - 1];
this.field = [0, 13];
this.focus = null;
this.boxMeshes = new Set(); // live BoxMesh instances (re-textured on art swaps)
this.external = false; // true: the 3D board layer draws the board (see header)
}
/** Let an external (3D) board layer draw the board: only highlights / airflow / grid queries stay here. */
setExternal(on) {
const next = !!on;
if (next === this.external) return;
this.external = next;
if (this.stage) this._rebuild();
else if (next) this._freeBoard();
}
/** Drop every board mesh, prop Graphics, anim sprite and gate box (external mode). */
_freeBoard() {
for (const m of this.meshes) m.mesh.destroy({ children: true });
for (const s of this.rowSurfaces.values()) this._freeSurface(s);
this.meshes = []; this.rowMeshes = new Map(); this.rowSurfaces = new Map(); this.mesh = null; this.plane = null;
for (const d of this.devices) d.gfx.destroy();
this.devices = [];
for (const a of this.animSprites) a.sprite.destroy();
this.animSprites = [];
for (const b of this.boxes) b.g.destroy();
this.boxes = [];
this.quads = [];
}
/**
* Rows drawn (`band` [r0, r1]) and the focused rect (tiles outside it are dimmed). Rebuilds the geometry only
* when something changed.
*/
setView(band, focus, field) {
const b = Array.isArray(band) ? [Math.max(0, band[0] | 0), Math.min(ROWS - 1, band[1] | 0)] : [0, ROWS - 1];
const fb = Array.isArray(field) ? [field[0] | 0, field[1] | 0] : [b[0], Math.min(b[1], 13)];
const f = focus ? { r0: focus.r0, r1: focus.r1, c0: focus.c0, c1: focus.c1 } : null;
const same = b[0] === this.band[0] && b[1] === this.band[1] && fb[0] === this.field[0] && fb[1] === this.field[1] &&
JSON.stringify(f) === JSON.stringify(this.focus);
this.band = b;
this.field = fb;
this.focus = f;
if (!same && this.stage) this._rebuild();
}
/**
* Use the real board art (render/boardArt.js result) or null for the procedural look. Swaps the atlas texture
* (identical UV layout) and the ground plane texture; geometry is rebuilt so art-only materials apply.
*/
setArt(art) {
const next = art && art.images ? art : null;
if ((next?.key || '') === (this.art?.key || '') && this.shader) return;
this.art = next;
this.atlas = tileAtlas(next);
if (this.shader) this.shader.uniforms.uSampler = this.atlas.texture;
if (this.planeShader) this.planeShader.uniforms.uSampler = groundTexture(next?.images?.BG || null, next?.tiles?.backdrop?.crop);
for (const b of this.boxMeshes) b.retexture();
if (this.stage) this._rebuild();
}
/** Tile info (always an object; off-grid → forbidden). */
tile(r, c) { return this.grid[r]?.[c] || { r, c, glyph: '#', mat: 'forbid', h: 0, playable: false, drawn: false }; }
heightAt(r, c) { const t = this.grid[r]?.[c]; return t && t.drawn ? t.h + (t.devH || 0) : 0; }
setStage(stage) {
this.stage = stage || null;
this._rebuild();
}
_rebuild() {
const stage = this.stage;
this.grid = parseStage(stage, this.band, this.field);
const F = this.focus;
for (const row of this.grid) for (const t of row) {
t.focus = !F || (t.r >= F.r0 && t.r <= F.r1 && t.c >= F.c0 && t.c <= F.c1);
}
// raised device tops (active platforms / mounds) change the standing height of their tile
for (const d of this._stageDevices()) {
const t = this.grid[d.pos[0]]?.[d.pos[1]];
if (!t) continue;
if (d.role === 'platform' || d.role === 'mound') t.devH = TILE_H.platform;
else if (d.role === 'waterPlatform') t.devH = DEVICE_BOX.waterPlatform.height;
}
const hs = new Set([0]);
for (const row of this.grid) for (const t of row) if (t.drawn) hs.add(t.h + (t.devH || 0));
this.levels = [...hs].sort((a, b) => b - a);
this._buildDevices();
if (this.external) {
this._freeBoard();
this.camVersion = -1;
return;
}
this._buildQuads();
this._buildMesh();
this._buildAnim();
this._buildBoxes();
this.camVersion = -1;
}
_stageDevices() {
const list = Array.isArray(this.stage?.devices) ? this.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_ROLES.has(d.role)) continue;
const active = typeof d.active === 'boolean' ? d.active : !d.hidden;
if (!active) continue;
if (d.pos[0] < 0 || d.pos[0] >= ROWS || d.pos[1] < 0 || d.pos[1] >= COLS || d.pos[0] >= 14) continue;
const gt = this.grid[d.pos[0]] && this.grid[d.pos[0]][d.pos[1]];
if (gt && (!gt.drawn || gt.scenery)) continue;
// a device on the field's edge wall only matters when it acts on this field (blowers blowing into it)
const F = this.field;
if (Array.isArray(d.rangeTiles) && d.rangeTiles.length > 1 && (d.pos[0] === F[0] || d.pos[0] === F[1])) {
const into = d.rangeTiles.some(([r, c]) => (r !== d.pos[0] || c !== d.pos[1]) && r > F[0] && r < F[1]);
if (!into) continue;
}
out.push(d);
}
return out;
}
// ---- geometry ------------------------------------------------------------------------------------------
_buildQuads() {
const uv = this.atlas.uv;
const quads = buildTileQuads(this.grid, uv);
for (const d of this.devBoxes) {
if (!d.visible) continue;
const spec = DEVICE_BOX[d.role];
const z0 = this.tile(d.row, d.col).h;
boxQuads(quads, uv, d.col, d.row, z0, spec.size, spec.height, spec.top, spec.side, { row: d.row, col: d.col, rot: d.role === 'blower' ? DIR_ROT[d.dir] ?? 0 : 0 });
}
this.quads = quads;
}
_makeMesh(quads, row, shader, noSort = false) {
const P = this.P;
const n = quads.length;
const m = { quads, row, noSort, pos: new Float32Array(n * 8), uvq: new Float32Array(n * 12), col: new Float32Array(n * 16), idx: new Uint16Array(n * 6), order: new Array(n) };
m.posBuf = new P.Buffer(m.pos, false, false);
m.uvqBuf = new P.Buffer(m.uvq, false, false);
m.colBuf = new P.Buffer(m.col, false, false);
m.idxBuf = new P.Buffer(m.idx, false, true);
const geom = new P.Geometry()
.addAttribute('aVertexPosition', m.posBuf, 2)
.addAttribute('aUvq', m.uvqBuf, 3)
.addAttribute('aColor', m.colBuf, 4)
.addIndex(m.idxBuf);
m.mesh = new P.Mesh(geom, shader);
return m;
}
_buildMesh() {
const P = this.P;
for (const m of this.meshes) m.mesh.destroy({ children: true });
for (const s of this.rowSurfaces.values()) this._freeSurface(s);
this.meshes = [];
this.rowMeshes = new Map();
this.rowSurfaces = new Map();
this.mesh = null;
const { ground, rows } = splitQuadGroups(this.quads);
if (!this.shader) this.shader = P.Shader.from(VERT, FRAG, { uSampler: this.atlas.texture, uAlpha: 1 });
if (!this.planeShader) this.planeShader = P.Shader.from(VERT, FRAG, { uSampler: groundTexture(this.art?.images?.BG || null, this.art?.tiles?.backdrop?.crop), uAlpha: 1 });
// backdrop ground plane (under everything)
const plane = buildPlaneQuads(this.grid, this.art?.tiles?.backdrop?.tilesPerRepeat || 8);
this.plane = this._makeMesh(plane, null, this.planeShader, true);
this.meshes.push(this.plane);
this.layers.ground.addChildAt(this.plane.mesh, 0);
if (ground.length) {
const m = this._makeMesh(ground, null, this.shader);
this.meshes.push(m);
this.mesh = m.mesh;
this.layers.ground.addChild(m.mesh);
}
for (const [row, quads] of rows) {
const m = this._makeMesh(quads, row, this.shader);
this.meshes.push(m);
this.rowMeshes.set(row, m);
this.layers.props.addChild(m.mesh);
}
}
/**
* Container for things lying on the raised tops of block row `row` (drawn after that row's blocks, before its
* units), or null when the row has no raised blocks (callers then use their ordinary ground layer).
*/
surfaceLayer(row) {
const r = Math.round(row);
if (!this.rowMeshes.has(r)) return null;
let s = this.rowSurfaces.get(r);
if (!s) {
s = new this.P.Container();
s.sortableChildren = false;
if (this.cam) s.zIndex = rowDepthKey(this.cam, r) + ROW_KEY.surface;
this.layers.props.addChild(s);
this.rowSurfaces.set(r, s);
}
return s;
}
_freeSurface(s) {
// children belong to their owners (unit shadows, fx rings…): hand them back to the ground layers
for (const ch of [...s.children]) { if (ch !== s._hl) this.layers.overlay.addChild(ch); }
s.destroy({ children: true });
}
/** Lighting parameters of the current view (fog distances + the focused field's spotlight). */
_light(cam) {
const F = this.focus || { r0: 9, r1: 12, c0: 0, c1: 10 };
return {
fogNear: cam.dist * 0.75, fogFar: cam.dist * 1.7,
lcx: (F.c0 + F.c1) / 2, lcy: (F.r0 + F.r1) / 2,
lrx: Math.max(3, (F.c1 - F.c0) / 2 + 2.5), lry: Math.max(2.5, (F.r1 - F.r0) / 2 + 2.5),
};
}
/** Re-project the ground when the camera changed (call every frame; cheap when unchanged). */
project(cam, force = false) {
if (!cam) return;
if (!force && cam === this.cam && cam.version === this.camVersion) return;
this.cam = cam;
this.camVersion = cam.version;
const light = this._light(cam);
for (const m of this.meshes) projectMesh(cam, m, light, this._p);
for (const [row, m] of this.rowMeshes) m.mesh.zIndex = rowDepthKey(cam, row) + ROW_KEY.blocks;
for (const [row, s] of this.rowSurfaces) s.zIndex = rowDepthKey(cam, row) + ROW_KEY.surface;
for (const b of this.boxMeshes) b.dirty = true;
this._drawHighlights();
this._placeDevices();
this._placeAnim();
}
/**
* A standalone textured box (battle devices: crates, turrets) sharing the tile shader & atlas. The caller owns
* it: `box.update(cam, { x, y, z, size, height, top, side, alpha, rot })` per frame (re-projects on change),
* `box.mesh` goes into the depth-sorted unit layer, `box.destroy()` when done.
*/
createBox() {
const b = new BoxMesh(this);
this.boxMeshes.add(b);
return b;
}
// ---- highlights ----------------------------------------------------------------------------------------
/**
* Set a highlight group. `tiles`: [[r,c]] or [{row,col}] (null/empty clears). `style`: 'legal' | 'illegal' |
* 'range' | 'rangeStand' | 'hover' | 'target' | 'deploy' | { color, fill, line, group }. `opts.stripes`: light
* diagonal stripes over the fill (the official striped range tiles; drawn under the units like every highlight).
*/
setHighlights(tiles, style = 'range', group, opts) {
let st = typeof style === 'object' && style ? { ...HL_STYLES.range, ...style } : (HL_STYLES[style] || HL_STYLES.range);
if (opts && opts.stripes) st = { ...st, stripes: true };
const key = group || (typeof style === 'object' && style?.group) || (typeof style === 'string' ? style : 'custom');
const list = [];
if (Array.isArray(tiles)) {
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) && r >= 0 && r < ROWS && c >= 0 && c < COLS) list.push([r, c]);
}
}
if (!list.length) this.highlights.delete(key);
else this.highlights.set(key, { tiles: list, style: st });
this._drawHighlights();
}
clearHighlights(group) {
if (group) this.highlights.delete(group); else this.highlights.clear();
this._drawHighlights();
}
_drawHighlights() {
this.hlGfx.clear();
for (const s of this.rowSurfaces.values()) if (s._hl) s._hl.clear();
const cam = this.cam;
if (!cam || !this.highlights.size) return;
const p = this._p;
const inset = 0.05;
for (const { tiles, style } of this.highlights.values()) {
for (const [r, c] of tiles) {
const h = this.heightAt(r, c);
let g = this.hlGfx;
if (h > 1e-3) {
// on a raised top: drawn with that block row (after the blocks, under the units standing there)
const s = this.surfaceLayer(r);
if (s) {
if (!s._hl) { s._hl = new this.P.Graphics(); s.addChildAt(s._hl, 0); }
g = s._hl;
}
}
const z = h + 0.012;
const cs = [c - 0.5 + inset, r + 0.5 - inset, c + 0.5 - inset, r + 0.5 - inset, c + 0.5 - inset, r - 0.5 + inset, c - 0.5 + inset, r - 0.5 + inset];
const pts = new Array(8); // Graphics keeps a reference to the polygon's points: never reuse this array
for (let k = 0; k < 4; k++) {
cam.project(cs[k * 2], cs[k * 2 + 1], z, p);
pts[k * 2] = p.x; pts[k * 2 + 1] = p.y;
}
const sc = cam.scaleAt(c, r, z);
const lw = Math.max(1.5, sc * 0.035);
g.lineStyle(lw, style.color, style.line, 0.5);
g.beginFill(style.color, style.fill);
g.drawPolygon(pts);
g.endFill();
if (style.stripes) drawStripes(g, cam, r, c, z, inset, Math.max(1.5, sc * 0.05), p);
}
}
}
// ---- devices -------------------------------------------------------------------------------------------
_buildDevices() {
for (const d of this.devices) d.gfx.destroy();
this.devices = [];
this.devBoxes = [];
this.blowers = [];
const P = this.P;
for (const dev of this._stageDevices()) {
const row = dev.pos[0], col = dev.pos[1];
if (dev.role === 'blower') this.blowers.push({ dev, row, col, dir: dev.dir || 'UP', visible: true });
if (DEVICE_BOX[dev.role]) { this.devBoxes.push({ dev, row, col, role: dev.role, dir: dev.dir || 'UP', visible: true }); continue; }
if (this.external) continue; // mounds / bushes are 3D props then
const gfx = new P.Graphics();
gfx.sortableChildren = false;
this.layers.props.addChild(gfx);
this.devices.push({ dev, gfx, row, col, role: dev.role, dir: dev.dir || 'UP', seed: hash2(row, col), visible: true });
}
this._applyBattleRect();
}
/** In battle, crates / turrets inside the field rect are sim units (kind 'device'): hide the static ones there. */
setBattleRect(rect) {
const next = rect ? { ...rect } : null;
if (JSON.stringify(next) === JSON.stringify(this.battleRect)) return;
this.battleRect = next;
const before = this.devBoxes.map((d) => d.visible).join();
this._applyBattleRect();
if (this.stage && !this.external && this.devBoxes.map((d) => d.visible).join() !== before) {
this._buildQuads();
this._buildMesh();
this.camVersion = -1;
}
}
_applyBattleRect() {
const R = this.battleRect;
const inside = (d) => !!R && d.row >= R.r0 && d.row <= R.r1 && d.col >= R.c0 && d.col <= R.c1;
for (const d of this.devBoxes) d.visible = !(inside(d) && (d.role === 'crate' || d.role === 'turret'));
for (const d of this.devices) { d.visible = true; d.gfx.visible = true; }
}
_placeDevices() {
const cam = this.cam;
if (!cam) return;
for (const d of this.devices) {
const g = d.gfx;
g.clear();
const z0 = this.tile(d.row, d.col).h;
g.zIndex = rowDepthKey(cam, d.row) + ROW_KEY.devices;
switch (d.role) {
case 'mound': drawMound(g, cam, d.col, d.row, z0, d.seed); break;
case 'bush': drawBush(g, cam, d.col, d.row, z0, d.seed); break;
default: break;
}
}
}
// ---- animated terrain ------------------------------------------------------------------------------------
_buildAnim() {
for (const a of this.animSprites) a.sprite.destroy();
this.animSprites = [];
const P = this.P;
const fx = fxAtlas();
const R = rng(4242);
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
const t = this.grid[r][c];
if (!t.drawn || t.scenery || r >= 13) continue;
const add = (kind, tex, blend, tint, n = 1) => {
for (let i = 0; i < n; i++) {
const s = new P.Sprite(fx.tex[tex]);
s.anchor.set(0.5);
s.blendMode = blend;
s.tint = tint;
this.animLayer.addChild(s);
this.animSprites.push({ sprite: s, r, c, kind, phase: R() * Math.PI * 2, ox: (R() - 0.5) * 0.5, oy: (R() - 0.5) * 0.5, sp: 0.6 + R() * 0.8 });
}
};
switch (t.glyph) {
case 'g': add('smog', 'smoke', P.BLEND_MODES.NORMAL, 0xb8c4c0, 2); break;
case 'd': add('sea', 'soft', P.BLEND_MODES.ADD, 0x5fe0ff, 1); break;
case 'i': add('infect', 'glow', P.BLEND_MODES.ADD, 0xff6a3d, 1); break;
case 'm': add('mire', 'dot', P.BLEND_MODES.ADD, 0xc8d890, 2); break;
case 'I': case 'O': add('tel', 'ring', P.BLEND_MODES.ADD, 0xb36bff, 1); break;
case 'S': if (r <= 12) add('gateGlow', 'glow', P.BLEND_MODES.ADD, COLORS.gateRed, 1); break;
case 'E': if (r <= 12) add('objGlow', 'glow', P.BLEND_MODES.ADD, COLORS.objBlue, 1); break;
default: break;
}
}
}
_placeAnim() {
const cam = this.cam;
if (!cam) return;
const p = this._p;
for (const a of this.animSprites) {
const z = this.tile(a.r, a.c).h + 0.02;
cam.project(a.c + (a.kind === 'smog' || a.kind === 'mire' ? a.ox : 0), a.r + (a.kind === 'smog' || a.kind === 'mire' ? a.oy : 0), z, p);
a.x = p.x; a.y = p.y; a.s = p.s;
a.sprite.position.set(p.x, p.y);
}
}
/** Leak flash on an objective tile (b.ev 'leak'). */
flashObjective(r, c) {
this.flashes.push({ r, c, t: 0 });
if (this.flashes.length > 8) this.flashes.shift();
}
/** Per-frame animation (dt seconds). */
update(dt) {
this.time += dt;
const t = this.time;
for (const a of this.animSprites) {
const s = a.sprite, px = a.s || 40;
switch (a.kind) {
case 'smog': {
const k = (t * 0.25 * a.sp + a.phase) % 1;
s.position.set(a.x + Math.sin(t * 0.5 + a.phase) * px * 0.08, a.y - k * px * 0.5);
s.scale.set((px / 128) * (0.9 + k * 0.8), (px / 128) * (0.6 + k * 0.5));
s.alpha = 0.55 * Math.sin(k * Math.PI);
break;
}
case 'sea': s.scale.set(px / 128 * 1.05, px / 128 * 0.62); s.alpha = 0.1 + 0.08 * Math.sin(t * 1.6 + a.phase); break;
case 'infect': s.scale.set(px / 128 * 1.2, px / 128 * 0.75); s.alpha = 0.22 + 0.16 * Math.sin(t * 2.2 + a.phase); break;
case 'mire': {
const k = (t * 0.6 * a.sp + a.phase) % 1;
s.scale.set(px / 32 * 0.12 * (0.4 + k));
s.alpha = 0.6 * (1 - k);
break;
}
case 'tel': s.scale.set(px / 128 * 0.9, px / 128 * 0.55); s.alpha = 0.35 + 0.25 * Math.sin(t * 2 + a.phase); s.rotation = 0; break;
case 'gateGlow': s.scale.set(px / 128 * 2.1, px / 128 * 1.2); s.alpha = 0.3 + 0.15 * Math.sin(t * 3.2 + a.phase); break;
case 'objGlow': s.scale.set(px / 128 * 2.0, px / 128 * 1.15); s.alpha = 0.28 + 0.1 * Math.sin(t * 2 + a.phase); break;
default: break;
}
}
for (let i = this.flashes.length - 1; i >= 0; i--) { this.flashes[i].t += dt; if (this.flashes[i].t > 1.2) this.flashes.splice(i, 1); }
this._drawBoxes();
this._drawFlow();
}
/** Gate / objective wire boxes: tall, so each is its own Graphics depth-sorted with the block rows and units. */
_buildBoxes() {
for (const b of this.boxes) b.g.destroy();
this.boxes = [];
// the field's gates / objectives, and the enemy preview pen's two gates (the red cubes behind which the next
// round's enemies wait, also while the pen is dim scenery behind the prep board)
const pen = parsePenRect(this.stage?.config?.enemy_place_rect) || PEN_RECT;
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
const tile = this.grid[r][c];
if (!tile.drawn || (tile.glyph !== 'S' && tile.glyph !== 'E')) continue;
const penGate = tile.glyph === 'S' && r >= pen.r0 && r <= pen.r1 && c >= pen.c0 && c <= pen.c1;
if ((tile.scenery || r > 12) && !penGate) continue;
const g = new this.P.Graphics();
g.blendMode = this.P.BLEND_MODES.ADD;
this.layers.props.addChild(g);
this.boxes.push({ r, c, gate: tile.glyph === 'S', g });
}
}
_drawBoxes() {
const cam = this.cam;
if (!cam) return;
const t = this.time;
for (const { r, c, gate, g } of this.boxes) {
g.clear();
// just in front of its row's blocks, behind the units standing in the gate
g.zIndex = boxDepthKey(cam, r);
let flash = 0;
if (!gate) for (const f of this.flashes) if (f.r === r && f.c === c) flash = Math.max(flash, 1 - f.t / 1.2);
const pulse = gate ? 0.62 + 0.38 * (0.5 + 0.5 * Math.sin(t * 3.4 + c)) : 0.75 + 0.25 * (0.5 + 0.5 * Math.sin(t * 2.1));
const color = gate ? COLORS.gateRed : flash > 0 ? mixColor(COLORS.objBlue, COLORS.gateRed, flash) : COLORS.objBlue;
drawWireBox(g, cam, c, r, 0.02, 0.96, 0.92, color, Math.min(1.25, pulse + flash * 0.6), gate);
}
}
/** Airflow streaks along the blowers' rangeTiles (the machines themselves are textured boxes). */
_drawFlow() {
const g = this.flowGfx;
g.clear();
const cam = this.cam;
if (!cam || !this.blowers.length) return;
const p = this._p, q = { x: 0, y: 0, s: 0, depth: 0 };
for (const d of this.blowers) {
const tiles = Array.isArray(d.dev.rangeTiles) ? d.dev.rangeTiles : [];
const dx = d.dir === 'LEFT' ? -1 : d.dir === 'RIGHT' ? 1 : 0, dy = d.dir === 'UP' ? 1 : d.dir === 'DOWN' ? -1 : 0;
for (const [r, c] of tiles) {
if (r === d.row && c === d.col) continue;
if (!this.tile(r, c).drawn) continue;
const z = this.heightAt(r, c) + 0.05;
for (let k = 0; k < 3; k++) {
const ph = (this.time * 0.9 + k / 3 + ((r * 7 + c * 3) % 10) / 10) % 1;
const ox = (k - 1) * 0.28 * (dy !== 0 ? 1 : 0), oy = (k - 1) * 0.28 * (dx !== 0 ? 1 : 0);
const x0 = c + ox + dx * (ph - 0.5), y0 = r + oy + dy * (ph - 0.5);
cam.project(x0, y0, z, p);
cam.project(x0 + dx * 0.28, y0 + dy * 0.28, z, q);
g.lineStyle(Math.max(1, p.s * 0.022), 0xd8f6ff, 0.32 * Math.sin(ph * Math.PI));
g.moveTo(p.x, p.y); g.lineTo(q.x, q.y);
}
}
}
}
destroy() {
for (const d of this.devices) d.gfx.destroy();
for (const a of this.animSprites) a.sprite.destroy();
for (const b of [...this.boxMeshes]) b.destroy();
this.devices = [];
this.animSprites = [];
for (const m of this.meshes) m.mesh.destroy();
for (const s of this.rowSurfaces.values()) this._freeSurface(s);
this.meshes = []; this.rowMeshes.clear(); this.rowSurfaces.clear(); this.mesh = null; this.plane = null;
for (const b of this.boxes) b.g.destroy();
this.boxes = [];
this.hlGfx.destroy(); this.flowGfx.destroy(); this.animLayer.destroy();
}
}
// ---- projection of textured quads (shared by the tile meshes and BoxMesh) ------------------------------------
/**
* Project mesh record `m` ({ quads, pos, uvq, col, idx, order, *Buf, noSort }) with camera `cam`: perspective-correct
* UVs (u·w, v·w, w), per-vertex fog + spotlight shading, painter's index order.
*/
export function projectMesh(cam, m, light, p = { x: 0, y: 0, s: 0, depth: 0 }) {
const qs = m.quads;
const n = qs.length;
if (!n) return;
const camX = cam.tx;
const pos = m.pos, uvq = m.uvq, col = m.col;
const { fogNear, fogFar, lcx, lcy, lrx, lry } = light;
const UVK = [[0, 1], [2, 1], [2, 3], [0, 3]]; // corner k → (u index, v index) of q.u, before rotation
for (let i = 0; i < n; i++) {
const q = qs[i];
// back-facing E/W faces collapse to nothing
let visible = q.alpha > 0;
if (q.faceDir === 'E') visible = visible && camX > q.fx;
else if (q.faceDir === 'W') visible = visible && camX < q.fx;
const u = q.u;
const rot = q.rot | 0;
for (let k = 0; k < 4; k++) {
const pt = q.pts[k];
cam.project(pt[0], pt[1], pt[2], p);
const vi = i * 4 + k;
if (visible || k === 0) { pos[vi * 2] = p.x; pos[vi * 2 + 1] = p.y; }
else { pos[vi * 2] = pos[i * 8]; pos[vi * 2 + 1] = pos[i * 8 + 1]; }
const w = 1 / p.depth;
const uk = UVK[(k - rot + 4) & 3];
uvq[vi * 3] = u[uk[0]] * w;
uvq[vi * 3 + 1] = u[uk[1]] * w;
uvq[vi * 3 + 2] = w;
const fog = Math.min(1, Math.max(0, (p.depth - fogNear) / (fogFar - fogNear)));
// soft spotlight on the focused field (cheap "lighting": edges of the island fall off)
const ddx = (pt[0] - lcx) / lrx, ddy = (pt[1] - lcy) / lry;
const spot = 1 - 0.22 * Math.min(1, ddx * ddx + ddy * ddy);
const ao = q.ao ? q.ao[k] : EXPOSURE;
const lum = q.shade * ao * (1 - 0.42 * fog) * spot;
col[vi * 4] = lum * (1 - 0.06 * fog);
col[vi * 4 + 1] = lum;
col[vi * 4 + 2] = lum * (1 + 0.05 * fog);
col[vi * 4 + 3] = q.alpha;
}
}
const order = m.order;
for (let i = 0; i < n; i++) order[i] = i;
if (!m.noSort) sortQuadOrder(order, qs, camX);
const idx = m.idx;
for (let j = 0; j < n; j++) {
const i = order[j], b = i * 4, o = j * 6;
idx[o] = b; idx[o + 1] = b + 1; idx[o + 2] = b + 2; idx[o + 3] = b; idx[o + 4] = b + 2; idx[o + 5] = b + 3;
}
m.posBuf.update();
m.uvqBuf.update();
m.colBuf.update();
m.idxBuf.update();
}
/**
* Append the visible faces of a textured box (top + S/E/W sides; N never faces the camera) to `quads`.
* `o`: { row, col (sort keys), rot (top texture quarter turns), alpha, kind ('block' default) }.
*/
export function boxQuads(quads, uv, cx, cy, z0, size, height, topMat, sideMat, o = {}) {
const a = size / 2;
const x0 = cx - a, x1 = cx + a, y0 = cy - a, y1 = cy + a, zt = z0 + height;
const blank = (uv && uv.blank) || [0, 0, 0, 0];
const top = (uv && uv[topMat]) || blank, side = (uv && uv[sideMat]) || blank;
const base = { kind: o.kind || 'block', row: o.row ?? Math.round(cy), col: o.col ?? Math.round(cx), alpha: o.alpha ?? 1 };
quads.push({ ...base, pts: sidePts('S', x0, x1, y0, y1, zt, z0), u: side, shade: 0.86, order: 1, faceDir: 'S', fx: cx });
quads.push({ ...base, pts: sidePts('E', x0, x1, y0, y1, zt, z0), u: side, shade: 0.64, order: 1, faceDir: 'E', fx: x1 });
quads.push({ ...base, pts: sidePts('W', x0, x1, y0, y1, zt, z0), u: side, shade: 0.64, order: 1, faceDir: 'W', fx: x0 });
quads.push({ ...base, pts: [[x0, y1, zt], [x1, y1, zt], [x1, y0, zt], [x0, y0, zt]], u: top, shade: 1, order: 0, faceDir: 0, rot: o.rot | 0 });
return quads;
}
/**
* Backdrop ground plane under the island (z = −CLIFF_DEPTH), textured with repeating concrete, with contact
* shadows baked into the vertex shading near drawn tiles.
*/
export function buildPlaneQuads(G, tilesPerRepeat = 5) {
const quads = [];
const T = Math.max(1, Number(tilesPerRepeat) || 5);
const z = -CLIFF_DEPTH;
// distance field (tiles) to the nearest drawn tile, sampled at vertices
const drawn = [];
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) if (G[r]?.[c]?.drawn) drawn.push([c, r]);
const occl = (x, y) => {
if (!drawn.length) return 1;
let best = Infinity;
for (const [c, r] of drawn) {
const dx = Math.max(0, Math.abs(x - c) - 0.5), dy = Math.max(0, Math.abs(y - r) - 0.5);
const d = dx * dx + dy * dy;
if (d < best) best = d;
}
const d = Math.sqrt(best);
// contact shadow at the island's foot, then a slow falloff into the dark surroundings
return (1 - 0.6 * Math.exp(-d / 0.9)) * (0.42 + 0.58 * Math.exp(-d / 7));
};
const cache = new Map();
const ao = (x, y) => { const k = x * 1000 + y; let v = cache.get(k); if (v === undefined) { v = occl(x, y); cache.set(k, v); } return v; };
const S = PLANE.cell;
for (let y = PLANE.y0; y < PLANE.y1; y += S) for (let x = PLANE.x0; x < PLANE.x1; x += S) {
const xa = x - 0.5, xb = x - 0.5 + S, ya = y - 0.5, yb = y - 0.5 + S;
quads.push({
kind: 'plane', row: y, col: x, order: 0, faceDir: 0, alpha: 1, shade: 0.62,
pts: [[xa, yb, z], [xb, yb, z], [xb, ya, z], [xa, ya, z]],
u: [xa / T, -yb / T, xb / T, -ya / T],
ao: [ao(xa, yb), ao(xb, yb), ao(xb, ya), ao(xa, ya)],
});
}
return quads;
}
/** A standalone textured box mesh (battle devices), sharing the TileField shader & atlas. */
export class BoxMesh {
constructor(field) {
this.field = field;
this.quads = [];
this.m = null;
this.key = '';
this.dirty = true;
this.mesh = null;
this.destroyed = false;
}
/** Material UVs changed (art swap): rebuild the quads next update. */
retexture() { this.key = ''; this.dirty = true; }
/** @param {any} cam @param {{ x, y, z, size, height, top, side, alpha?, rot? }} b */
update(cam, b) {
if (this.destroyed || !cam) return;
const f = this.field;
const key = `${b.x.toFixed(3)},${b.y.toFixed(3)},${b.z.toFixed(3)},${b.size.toFixed(3)},${b.height.toFixed(3)},${b.top},${b.side},${b.rot | 0}`;
const alpha = b.alpha ?? 1;
if (key !== this.key) {
this.key = key;
const quads = boxQuads([], f.atlas.uv, b.x, b.y, b.z, b.size, b.height, b.top, b.side, { rot: b.rot, row: Math.round(b.y), col: Math.round(b.x) });
if (!this.m || this.m.quads.length !== quads.length) {
const parent = this.mesh?.parent || null;
const z = this.mesh?.zIndex ?? 0;
if (this.mesh) this.mesh.destroy();
if (!f.shader) f.shader = f.P.Shader.from(VERT, FRAG, { uSampler: f.atlas.texture, uAlpha: 1 });
this.m = f._makeMesh(quads, null, f.shader);
this.mesh = this.m.mesh;
this.mesh.zIndex = z;
if (parent) parent.addChild(this.mesh);
} else this.m.quads = quads;
this.dirty = true;
}
if (this.m && this.m.quads.some((q) => q.alpha !== alpha)) { for (const q of this.m.quads) q.alpha = alpha; this.dirty = true; }
if (this.dirty || this.camVersion !== cam.version) {
this.dirty = false;
this.camVersion = cam.version;
projectMesh(cam, this.m, f._light(cam), f._p);
}
}
destroy() {
if (this.destroyed) return;
this.destroyed = true;
this.field.boxMeshes.delete(this);
if (this.mesh) this.mesh.destroy();
this.mesh = null;
}
}
/**
* Tile geometry as quads: `{ kind: 'low'|'block'|'cliff', pts: [[x,y,z]×4], u: [u0,v0,u1,v1], shade, alpha,
* order (0 top, 1 side), faceDir: 0|'S'|'E'|'W', fx (x of an E/W face: visible when the camera is beyond it),
* row, col, rot? (top texture quarter turns) }`. Pure (uv map from the tile atlas).
*/
export function buildTileQuads(G, uv) {
const quads = [];
const blank = (uv && uv.blank) || [0, 0, 0, 0];
const add = (kind, mat, pts, shade, alpha, order, faceDir, tile) => {
const u = (uv && uv[mat]) || blank;
const fx = faceDir === 'E' ? tile.c + 0.5 : faceDir === 'W' ? tile.c - 0.5 : tile.c;
quads.push({ kind, pts, u, shade, alpha, order, faceDir, row: tile.r, col: tile.c, fx });
};
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
const t = G[r][c];
if (!t.drawn) continue;
const H = t.h;
const x0 = c - 0.5, x1 = c + 0.5, y0 = r - 0.5, y1 = r + 0.5;
const topMat = variantMat(t);
const dim = t.scenery ? 0.36 : t.focus ? 1 : 0.58;
// top (texture top = far edge)
add(H > 0 ? 'block' : 'low', topMat, [[x0, y1, H], [x1, y1, H], [x1, y0, H], [x0, y0, H]], dim, t.alpha, 0, 0, t);
// sides: S (near), E, W. N faces are never visible (the camera is always south of the field).
const sideMat = SIDE_MAT[t.mat] || SIDE_MAT[t.hClass] || 'lowSide';
for (const [dir, dr, dc] of [['S', -1, 0], ['E', 0, 1], ['W', 0, -1]]) {
const n = G[r + dr]?.[c + dc];
const nDrawn = !!(n && n.drawn);
const nh = nDrawn ? n.h : -CLIFF_DEPTH;
if (H > (nDrawn ? nh : 0) + 1e-6) {
const zb = nDrawn ? nh : 0;
add(H > 0 ? 'block' : 'low', sideMat, sidePts(dir, x0, x1, y0, y1, H, zb), (dir === 'S' ? 0.86 : 0.66) * dim, t.alpha, 1, dir, t);
}
if (!nDrawn) {
// island edge: cliff going down from the ground
add('cliff', 'cliff', sidePts(dir, x0, x1, y0, y1, 0, -CLIFF_DEPTH), (dir === 'S' ? 0.9 : 0.62) * dim, t.alpha, 1, dir, t);
}
}
}
return quads;
}
/** Ground quads (cliffs + low tiles) and raised-block quads grouped by row (each row is depth-sorted with units). */
export function splitQuadGroups(quads) {
const ground = [];
const rows = new Map();
for (const q of quads) {
if (q.kind !== 'block') { ground.push(q); continue; }
let list = rows.get(q.row);
if (!list) { list = []; rows.set(q.row, list); }
list.push(q);
}
return { ground, rows };
}
/**
* Painter's order of quad indices (in place): cliffs, then low tiles far→near, then blocks far→near, outer→inner
* (by distance from the camera's column) within a row, each block's sides before its top.
*/
export function sortQuadOrder(order, qs, camX) {
return order.sort((a, b) => {
const A = qs[a], B = qs[b];
const ka = A.kind === 'cliff' ? 0 : A.kind === 'low' ? 1 : 2;
const kb = B.kind === 'cliff' ? 0 : B.kind === 'low' ? 1 : 2;
if (ka !== kb) return ka - kb;
if (ka === 2 || ka === 0) {
if (A.row !== B.row) return B.row - A.row;
const da = Math.abs(A.col - camX), db = Math.abs(B.col - camX);
if (da !== db) return db - da;
if (A.col !== B.col) return A.col - B.col;
return B.order - A.order; // sides (1) before top (0)
}
return B.row - A.row || A.col - B.col || B.order - A.order;
});
}
function sidePts(dir, x0, x1, y0, y1, zt, zb) {
switch (dir) {
case 'S': return [[x0, y0, zt], [x1, y0, zt], [x1, y0, zb], [x0, y0, zb]];
case 'E': return [[x1, y0, zt], [x1, y1, zt], [x1, y1, zb], [x1, y0, zb]];
case 'W': return [[x0, y1, zt], [x0, y0, zt], [x0, y0, zb], [x0, y1, zb]];
default: return [[x0, y1, zt], [x1, y1, zt], [x1, y1, zb], [x0, y1, zb]];
}
}
export function mixColor(a, b, t) {
const k = Math.max(0, Math.min(1, t));
const ar = (a >> 16) & 255, ag = (a >> 8) & 255, ab = a & 255;
const br = (b >> 16) & 255, bg = (b >> 8) & 255, bb = b & 255;
return (Math.round(ar + (br - ar) * k) << 16) | (Math.round(ag + (bg - ag) * k) << 8) | Math.round(ab + (bb - ab) * k);
}
// ---- device drawing helpers (Graphics, projected corners) -----------------------------------------------------
function boxCorners(cam, cx, cy, z0, size, height) {
const a = size / 2;
const P = (x, y, z) => { const p = cam.project(x, y, z); return [p.x, p.y]; };
return {
// bottom
b0: P(cx - a, cy - a, z0), b1: P(cx + a, cy - a, z0), b2: P(cx + a, cy + a, z0), b3: P(cx - a, cy + a, z0),
// top
t0: P(cx - a, cy - a, z0 + height), t1: P(cx + a, cy - a, z0 + height), t2: P(cx + a, cy + a, z0 + height), t3: P(cx - a, cy + a, z0 + height),
};
}
function poly(g, pts, color, alpha = 1, line = null) {
if (line) g.lineStyle(line[0], line[1], line[2] ?? 1, 0.5); else g.lineStyle(0);
g.beginFill(color, alpha);
g.drawPolygon(pts.flat());
g.endFill();
}
function drawBox(g, cam, cx, cy, z0, size, height, top, front, side) {
const k = boxCorners(cam, cx, cy, z0, size, height);
const camX = cam.tx;
if (camX > cx + size / 2) poly(g, [k.b1, k.b2, k.t2, k.t1], side);
if (camX < cx - size / 2) poly(g, [k.b3, k.b0, k.t0, k.t3], side);
poly(g, [k.b0, k.b1, k.t1, k.t0], front);
poly(g, [k.t0, k.t1, k.t2, k.t3], top);
return k;
}
function drawCrate(g, cam, cx, cy, z0, size, height, seed) {
const s = cam.scaleAt(cx, cy, z0);
// soft contact shadow
const sh = boxCorners(cam, cx, cy, z0 + 0.001, size + 0.16, 0);
poly(g, [sh.b0, sh.b1, sh.b2, sh.b3], 0x000000, 0.28);
const k = drawBox(g, cam, cx, cy, z0, size, height, 0xc89a5a, 0x9a6d38, 0x7d5529);
const lw = Math.max(1, s * 0.022);
// plank lines on the front
g.lineStyle(lw * 0.8, 0x5a3a18, 0.55);
for (const f of [0.33, 0.66]) {
const y0 = lerp2(k.b0, k.t0, f), y1 = lerp2(k.b1, k.t1, f);
g.moveTo(y0[0], y0[1]); g.lineTo(y1[0], y1[1]);
}
// X brace + frame
g.lineStyle(lw * 1.6, 0xe0b877, 0.95);
g.moveTo(k.b0[0], k.b0[1]); g.lineTo(k.t1[0], k.t1[1]);
g.moveTo(k.b1[0], k.b1[1]); g.lineTo(k.t0[0], k.t0[1]);
g.lineStyle(lw * 1.8, 0x3c2810, 0.9);
g.drawPolygon([...k.b0, ...k.b1, ...k.t1, ...k.t0]);
g.drawPolygon([...k.t0, ...k.t1, ...k.t2, ...k.t3]);
// lid highlight + center diamond marker (like the original's blue diamonds)
g.lineStyle(lw, 0xffe2a8, 0.7);
g.moveTo(k.t3[0], k.t3[1]); g.lineTo(k.t2[0], k.t2[1]);
const c = cam.project(cx, cy - size / 2, z0 + height * 0.5);
const d = s * 0.1;
g.lineStyle(lw * 1.2, 0x7fd3ff, 0.95);
g.drawPolygon([c.x, c.y - d, c.x + d, c.y, c.x, c.y + d, c.x - d, c.y]);
void seed;
}
function drawMound(g, cam, cx, cy, z0, seed) {
const R = rng(seed);
for (let i = 0; i < 5; i++) {
const p = cam.project(cx + (R() - 0.5) * 0.5, cy + (R() - 0.5) * 0.4, z0 + 0.12 + i * 0.03);
const s = p.s * (0.28 + R() * 0.12);
g.lineStyle(0);
g.beginFill(i % 2 ? 0x8a7a5e : 0x75664c, 1);
g.drawEllipse(p.x, p.y, s, s * 0.62);
g.endFill();
}
}
function drawBush(g, cam, cx, cy, z0, seed) {
const R = rng(seed);
for (let i = 0; i < 6; i++) {
const p = cam.project(cx + (R() - 0.5) * 0.55, cy + (R() - 0.5) * 0.45, z0 + 0.18 + R() * 0.2);
const s = p.s * (0.2 + R() * 0.1);
g.lineStyle(0);
g.beginFill(i % 2 ? 0x3f7a3a : 0x2f6230, 0.95);
g.drawCircle(p.x, p.y, s);
g.endFill();
}
}
const lerp2 = (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
/** Glowing wireframe box (gates / objective) with a warning glyph on the front face. */
function drawWireBox(g, cam, cx, cy, z0, size, height, color, pulse, gate) {
const k = boxCorners(cam, cx, cy, z0, size, height);
const s = cam.scaleAt(cx, cy, z0);
const lw = Math.max(1.2, s * 0.028);
// faces
g.lineStyle(0);
g.beginFill(color, 0.08 * pulse); g.drawPolygon([...k.b0, ...k.b1, ...k.t1, ...k.t0]); g.endFill();
g.beginFill(color, 0.05 * pulse); g.drawPolygon([...k.t0, ...k.t1, ...k.t2, ...k.t3]); g.endFill();
// back edges (dim)
g.lineStyle(lw * 0.7, color, 0.3 * pulse);
g.moveTo(k.b2[0], k.b2[1]); g.lineTo(k.b3[0], k.b3[1]);
g.moveTo(k.b2[0], k.b2[1]); g.lineTo(k.t2[0], k.t2[1]);
g.moveTo(k.b3[0], k.b3[1]); g.lineTo(k.t3[0], k.t3[1]);
g.moveTo(k.b1[0], k.b1[1]); g.lineTo(k.b2[0], k.b2[1]);
g.moveTo(k.b0[0], k.b0[1]); g.lineTo(k.b3[0], k.b3[1]);
// front + top edges (bright)
g.lineStyle(lw * 1.6, color, 0.95 * pulse);
g.drawPolygon([...k.b0, ...k.b1, ...k.t1, ...k.t0]);
g.drawPolygon([...k.t0, ...k.t1, ...k.t2, ...k.t3]);
g.lineStyle(lw * 3.2, color, 0.25 * pulse);
g.drawPolygon([...k.t0, ...k.t1, ...k.t2, ...k.t3]);
// corner ticks on the front face
g.lineStyle(lw * 1.3, 0xffffff, 0.55 * pulse);
const tick = 0.18;
for (const [a, b, c2] of [[k.b0, k.b1, k.t0], [k.b1, k.b0, k.t1], [k.t0, k.t1, k.b0], [k.t1, k.t0, k.b1]]) {
const p1 = lerp2(a, b, tick), p2 = lerp2(a, c2, tick);
g.moveTo(p1[0], p1[1]); g.lineTo(a[0], a[1]); g.lineTo(p2[0], p2[1]);
}
// warning triangle glyph on the front face
const f = cam.project(cx, cy - size / 2, z0 + height * 0.5);
const tri = s * 0.2;
g.lineStyle(lw * 1.4, gate ? 0xffd0c8 : 0xd8f0ff, 0.95 * pulse);
g.drawPolygon([f.x, f.y - tri, f.x + tri * 1.1, f.y + tri * 0.8, f.x - tri * 1.1, f.y + tri * 0.8]);
g.lineStyle(0);
g.beginFill(gate ? 0xffd0c8 : 0xd8f0ff, 0.95 * pulse);
g.drawRect(f.x - lw * 0.7, f.y - tri * 0.45, lw * 1.4, tri * 0.75);
g.drawCircle(f.x, f.y + tri * 0.52, lw * 0.9);
g.endFill();
}
export { drawCrate, drawWireBox };