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

1180 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;
/** The boss field's top wall under the normal field's bench (= board3d/layout.js BOSS_WALL_ROW). */
const BOSS_WALL_ROW = 6;
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 : [];
// the boss field's devices (rows ≤ 6, its top wall under the bench included) only in the views of the boss field
// (board3d/layout.js stageDevices: the official normal cameras never show that wall; user playtest #5 item 6)
const bossShown = this.field[0] < BOSS_WALL_ROW;
const out = [];
for (const d of list) {
if (!d || !Array.isArray(d.pos) || !Number.isInteger(d.pos[0]) || !Number.isInteger(d.pos[1])) continue;
if (!DEVICE_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;
if (d.pos[0] <= BOSS_WALL_ROW && !bossShown) continue;
const gt = this.grid[d.pos[0]] && this.grid[d.pos[0]][d.pos[1]];
// devices stand on the island (playable tiles + the ring around them, dist ≤ 1 — the 3D board's drawn tiles), not
// on the faded margin; the separator row 13 above the field included (act2 m01's blowers)
if (gt && (!gt.drawn || gt.scenery || gt.dist > 1)) 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 };