卫戍协议:盟约 web remake — initial import

Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
This commit is contained in:
sganggs committed 2026-09-29 16:21:31 +08:00
commit 01a44cd288
408 files changed
+308986

No files matched your search

+432
View File
@@ -0,0 +1,432 @@
// render/projection.js — the battlefield camera: one perspective camera model shared by the three.js board scene
// (render/board3d, DESIGN §15) and the Pixi layers on top of it (units, FX, drag & drop, hit-testing). Pure math, no
// PIXI / three import (Node tests and the 2D fallback use it as is).
//
// World space (DESIGN §3): x = col, y = row (row 0 = bottom = nearest to the camera), z = height in tiles (up).
// Tile (r,c) covers x ∈ [c−½, c+½], y ∈ [r−½, r+½]. The camera is a three.js-style PerspectiveCamera: a pinhole
// south of its target (towards −y), pitched `tilt` degrees away from looking straight down, `dist` tiles from the
// target point on the ground, with an off-axis lens (principal point (cx, cy), three's setViewOffset):
//
// C = T + dist·(0, −sin tilt, cos tilt) camera position
// f = (0, sin tilt, −cos tilt) forward u = (0, cos tilt, sin tilt) up r = (1,0,0)
// depth = (P−C)·f ; screen = (cx, cy) + scale·dist·((P−C)·r, −(P−C)·u) / depth
//
// `scale` = CSS px per world unit at the target's depth, i.e. the focal length is scale·dist px and the vertical
// field of view of a viewport H px high is 2·atan(H / 2 / (scale·dist)). `threeCameraParams` / `syncThreeCamera`
// turn a Camera into the equivalent THREE.PerspectiveCamera (fov, aspect, position, up, look-at, view offset) —
// test/render/projection.test.js projects every tile centre through both and requires identical pixels.
//
// Official framing (research 09 §2.1, fitted against official 1920×1080 screenshots; DESIGN §15): every 卫戍协议
// level's configBlackBoard carries camera params "(x,y,z,w)" (left_prepare / left_shop / left_battle / mid_battle /
// *_boss_* / enemy). They are offsets of Arknights' standard battle camera: position = OFFICIAL.base + param in the
// level's centred Unity frame (x = col − 10, y = row − 9, z = −height), pitched OFFICIAL.pitch = 30° with a vertical
// field of view of OFFICIAL.fovY = 40° and the principal point at the screen centre. (Measured: the shop camera
// (−4.83, −1.75, −3.77) puts the camera at col 5.17, row 2.44, height 11.53 — the least-squares fit of tile seams in
// an official shop-view screenshot gave col 5.17, row 2.40, height 11.57 at 30°/40°.) Screens narrower than 16:9
// back the camera off like the client (up to 4:3: Δ = (0, −1.4, −2.8)·t). `presetCamera` returns these official
// cameras; `fitCamera` (frame any rect inside a padded viewport) remains for custom rects and portrait viewports.
export const DEG = Math.PI / 180;
/** Arknights' standard battle camera (see header). */
export const OFFICIAL = Object.freeze({
base: Object.freeze([0, -4.81, -7.76]), // standard view position in the centred Unity frame
pitch: 30, // degrees from straight down
fovY: 40, // vertical field of view, degrees
center: Object.freeze([10, 9]), // (col, row) of the Unity frame origin on the 19×21 grid
aspectAdjust: Object.freeze([0, -1.4, -2.8]), // camera offset at 3:4 (H/W), interpolated from 9:16
});
/** configBlackBoard camera params (identical in all 11 autochess stages; stage.config overrides). */
export const OFFICIAL_PARAMS = Object.freeze({
left_prepare_camera_param: '(-4.67,0.3,-2.46,1)',
left_shop_camera_param: '(-4.83,-1.75,-3.77,1)',
left_battle_camera_param: '(-4,1.91,-0.6,1)',
right_battle_camera_param: '(4,1.91,-0.6,1)',
mid_battle_camera_param: '(0,0,-4.22,1)',
left_boss_prepare_camera_param: '(-4.67,-6.68,-2.46,1)',
left_boss_shop_camera_param: '(-4.83,-8.73,-3.77,1)',
left_boss_battle_camera_param: '(-4,-5.54,-0.6,1)',
right_boss_battle_camera_param: '(4,-5.54,-0.6,1)',
mid_boss_battle_camera_param: '(0,-7.95,-4.22,1)',
right_boss_prepare_camera_param: '(5.93,-6.68,-2.46,1)',
right_boss_shop_camera_param: '(5.83,-8.73,-3.77,1)',
enemy_camera_param: '(0,7.86,0.9,1)',
});
/** Default optics of the fitted (non-official) cameras: the official pitch; dist is a moderate lens. */
export const DEFAULT_OPTICS = Object.freeze({ tilt: OFFICIAL.pitch, dist: 16 });
/**
* Rects framed by each camera kind (inclusive tile bounds), framing parameters of the fitted fallback, and the
* official configBlackBoard param(s) of the kind (`official`: side → param key; `half` for the ‹ › half views).
*/
export const CAMERA_PRESETS = Object.freeze({
prep: Object.freeze({
rect: Object.freeze({ r0: 7, r1: 12, c0: 0, c1: 10 }), margin: 0.35, headroom: 1.25, tilt: 30, dist: 13,
official: Object.freeze({ L: 'left_shop_camera_param', R: 'left_shop_camera_param' }),
officialNoShop: Object.freeze({ L: 'left_prepare_camera_param', R: 'left_prepare_camera_param' }),
}),
normal: Object.freeze({
rect: Object.freeze({ r0: 9, r1: 12, c0: 0, c1: 10 }), margin: 0.45, headroom: 1.35, tilt: 30, dist: 13,
official: Object.freeze({ L: 'left_battle_camera_param', R: 'right_battle_camera_param' }),
}),
unite: Object.freeze({
rect: Object.freeze({ r0: 9, r1: 12, c0: 0, c1: 20 }), margin: 0.25, headroom: 1.35, tilt: 30, dist: 18,
official: Object.freeze({ L: 'mid_battle_camera_param', R: 'mid_battle_camera_param' }),
half: Object.freeze({ L: 'left_battle_camera_param', R: 'right_battle_camera_param' }),
}),
boss: Object.freeze({
rect: Object.freeze({ r0: 0, r1: 5, c0: 0, c1: 20 }), margin: 0.2, headroom: 1.5, tilt: 30, dist: 18,
official: Object.freeze({ L: 'mid_boss_battle_camera_param', R: 'mid_boss_battle_camera_param' }),
half: Object.freeze({ L: 'left_boss_battle_camera_param', R: 'right_boss_battle_camera_param' }),
}),
bossPrep: Object.freeze({
rect: Object.freeze({ r0: 0, r1: 5, c0: 0, c1: 10 }), margin: 0.35, headroom: 1.25, tilt: 30, dist: 13,
official: Object.freeze({ L: 'left_boss_shop_camera_param', R: 'right_boss_shop_camera_param' }),
officialNoShop: Object.freeze({ L: 'left_boss_prepare_camera_param', R: 'right_boss_prepare_camera_param' }),
}),
pen: Object.freeze({
rect: Object.freeze({ r0: 14, r1: 18, c0: 7, c1: 13 }), margin: 0.3, headroom: 1.2, tilt: 30, dist: 11,
official: Object.freeze({ L: 'enemy_camera_param', R: 'enemy_camera_param' }),
}),
});
const EPS = 1e-6;
const MIN_DEPTH = 0.05;
const finite = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
/**
* Perspective camera. Mutable; call `update()` after changing fields directly (setters do it for you).
*/
export class Camera {
/**
* @param {{ tx?: number, ty?: number, tz?: number, tilt?: number, dist?: number, scale?: number, cx?: number, cy?: number }} [o]
*/
constructor(options) {
const o = options && typeof options === 'object' ? options : {};
this.tx = finite(o.tx, 5);
this.ty = finite(o.ty, 10);
this.tz = finite(o.tz, 0);
this.tilt = clamp(finite(o.tilt, DEFAULT_OPTICS.tilt), 0, 80);
this.dist = Math.max(1, finite(o.dist, DEFAULT_OPTICS.dist));
this.scale = Math.max(EPS, finite(o.scale, 64));
this.cx = finite(o.cx, 0);
this.cy = finite(o.cy, 0);
/** bumped by update(); lets consumers cache projections */
this.version = 0;
this.update();
}
/** Recompute derived values (camera position, basis). */
update() {
const t = this.tilt * DEG;
this._s = Math.sin(t);
this._c = Math.cos(t);
this._px = this.tx;
this._py = this.ty - this.dist * this._s;
this._pz = this.tz + this.dist * this._c;
this._k = this.scale * this.dist;
this.version++;
return this;
}
/** Copy every parameter from another camera. */
copy(o) {
this.tx = o.tx; this.ty = o.ty; this.tz = o.tz; this.tilt = o.tilt; this.dist = o.dist;
this.scale = o.scale; this.cx = o.cx; this.cy = o.cy;
return this.update();
}
clone() { return new Camera(this.params()); }
/** Plain parameter object (serialisable). */
params() {
return { tx: this.tx, ty: this.ty, tz: this.tz, tilt: this.tilt, dist: this.dist, scale: this.scale, cx: this.cx, cy: this.cy };
}
/** World position of the camera (the pinhole). */
position() { return { x: this._px, y: this._py, z: this._pz }; }
/** Focal length in CSS px (screen distance of the image plane). */
focal() { return this._k; }
/** Depth of a world point along the view axis (≥ MIN_DEPTH). */
depthOf(x, y, z = 0) {
const d = (y - this._py) * this._s - (z - this._pz) * this._c;
return d > MIN_DEPTH ? d : MIN_DEPTH;
}
/**
* Project a world point. Writes `{ x, y, s, depth }` into `out` (allocated when omitted):
* screen position (CSS px) and `s` = CSS px per world unit at that point.
*/
project(x, y, z = 0, out = { x: 0, y: 0, s: 0, depth: 0 }) {
const vx = x - this._px, vy = y - this._py, vz = z - this._pz;
let depth = vy * this._s - vz * this._c;
if (!(depth > MIN_DEPTH)) depth = MIN_DEPTH;
const up = vy * this._c + vz * this._s;
const k = this._k / depth;
out.x = this.cx + vx * k;
out.y = this.cy - up * k;
out.s = k;
out.depth = depth;
return out;
}
/** CSS px per world unit at a world point. */
scaleAt(x, y, z = 0) { return this._k / this.depthOf(x, y, z); }
/**
* Inverse projection onto the horizontal plane at height `z`. Returns `{ x, y }` (written into `out`) or
* null when the view ray does not hit the plane in front of the camera.
*/
unproject(sx, sy, z = 0, out = { x: 0, y: 0 }) {
// Ray direction for screen point: d = f + r·a + u·b with a = (sx−cx)/k, b = −(sy−cy)/k.
const a = (sx - this.cx) / this._k;
const b = -(sy - this.cy) / this._k;
const dx = a;
const dy = this._s + b * this._c;
const dz = -this._c + b * this._s;
if (Math.abs(dz) < EPS) return null;
const t = (z - this._pz) / dz;
if (!(t > 0) || !Number.isFinite(t)) return null;
out.x = this._px + t * dx;
out.y = this._py + t * dy;
return out;
}
/** The equivalent THREE.PerspectiveCamera parameters for a `width`×`height` CSS px viewport (see below). */
threeParams(width, height, opts) { return threeCameraParams(this, width, height, opts); }
}
// ---- three.js PerspectiveCamera equivalence ------------------------------------------------------------------
/**
* Parameters of the THREE.PerspectiveCamera that projects exactly like `cam` onto a `width`×`height` viewport:
* `{ fov (vertical, degrees), aspect, near, far, position: [x,y,z], up: [x,y,z], target: [x,y,z],
* view: { fullWidth, fullHeight, offsetX, offsetY, width, height } }` (three's setViewOffset: the off-axis
* principal point (cx, cy)). World axes are the projection's (x = col, y = row, z = up).
*/
export function threeCameraParams(cam, width, height, opts) {
const o = opts && typeof opts === 'object' ? opts : {};
const W = Math.max(1, finite(width, 1)), H = Math.max(1, finite(height, 1));
const t = cam.tilt * DEG, s = Math.sin(t), c = Math.cos(t);
const fovY = 2 * Math.atan(H / 2 / Math.max(EPS, cam.scale * cam.dist)) / DEG;
const pos = [cam.tx, cam.ty - cam.dist * s, cam.tz + cam.dist * c];
return {
fov: fovY, aspect: W / H,
near: Math.max(0.01, finite(o.near, cam.dist * 0.05)), far: Math.max(1, finite(o.far, cam.dist + 80)),
position: pos, up: [0, c, s], target: [cam.tx, cam.ty, cam.tz],
view: { fullWidth: W, fullHeight: H, offsetX: W / 2 - cam.cx, offsetY: H / 2 - cam.cy, width: W, height: H },
};
}
/**
* Configure a THREE.PerspectiveCamera (duck-typed: fov, aspect, near, far, position, up, lookAt, setViewOffset,
* updateProjectionMatrix, updateMatrixWorld) to project exactly like `cam` on a `width`×`height` CSS px viewport.
*/
export function syncThreeCamera(cam, three, width, height, opts) {
const p = threeCameraParams(cam, width, height, opts);
three.fov = p.fov;
three.aspect = p.aspect;
three.near = p.near;
three.far = p.far;
three.position.set(p.position[0], p.position[1], p.position[2]);
three.up.set(p.up[0], p.up[1], p.up[2]);
three.lookAt(p.target[0], p.target[1], p.target[2]);
const v = p.view;
three.setViewOffset(v.fullWidth, v.fullHeight, v.offsetX, v.offsetY, v.width, v.height);
three.updateProjectionMatrix();
three.updateMatrixWorld(true);
return three;
}
/** Ease for camera transitions. */
export const easeInOutCubic = (t) => (t <= 0 ? 0 : t >= 1 ? 1 : t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
export const easeOutCubic = (t) => (t <= 0 ? 0 : t >= 1 ? 1 : 1 - Math.pow(1 - t, 3));
/** Interpolate two cameras into `out` (scale in log space so zooms feel linear). */
export function lerpCamera(a, b, t, out = new Camera()) {
const u = t <= 0 ? 0 : t >= 1 ? 1 : t;
const L = (p, q) => p + (q - p) * u;
out.tx = L(a.tx, b.tx); out.ty = L(a.ty, b.ty); out.tz = L(a.tz, b.tz);
out.tilt = L(a.tilt, b.tilt); out.dist = L(a.dist, b.dist);
out.scale = Math.exp(L(Math.log(Math.max(EPS, a.scale)), Math.log(Math.max(EPS, b.scale))));
out.cx = L(a.cx, b.cx); out.cy = L(a.cy, b.cy);
return out.update();
}
/** Normalised copy of a rect ({r0,r1,c0,c1} inclusive; swapped bounds fixed; clamped to the 19×21 grid). */
export function normRect(rect, rows = 19, cols = 21) {
const src = rect && typeof rect === 'object' ? rect : {};
let r0 = Math.round(finite(src.r0, 0)), r1 = Math.round(finite(src.r1, rows - 1));
let c0 = Math.round(finite(src.c0, 0)), c1 = Math.round(finite(src.c1, cols - 1));
if (r0 > r1) [r0, r1] = [r1, r0];
if (c0 > c1) [c0, c1] = [c1, c0];
return { r0: clamp(r0, 0, rows - 1), r1: clamp(r1, 0, rows - 1), c0: clamp(c0, 0, cols - 1), c1: clamp(c1, 0, cols - 1) };
}
/**
* Camera that frames `rect` inside the viewport.
* @param {{r0,r1,c0,c1}} rect inclusive tile bounds
* @param {{ width: number, height: number, padding?: { top?, right?, bottom?, left? } }} viewport CSS px
* @param {{ tilt?, dist?, margin?: number, headroom?: number, maxTilePx?: number, minTilePx?: number }} [opts]
* margin (tiles) around the rect; headroom (tiles) of vertical space kept above the far row for units.
*/
export function fitCamera(rect, viewport, options) {
const opts = options && typeof options === 'object' ? options : {};
const R = normRect(rect);
const W = Math.max(1, finite(viewport?.width, 1));
const H = Math.max(1, finite(viewport?.height, 1));
const pad = viewport?.padding || {};
const pt = Math.max(0, finite(pad.top, 0)), pr = Math.max(0, finite(pad.right, 0));
const pb = Math.max(0, finite(pad.bottom, 0)), pl = Math.max(0, finite(pad.left, 0));
const availW = Math.max(16, W - pl - pr), availH = Math.max(16, H - pt - pb);
const margin = Math.max(0, finite(opts.margin, 0.35));
const headroom = Math.max(0, finite(opts.headroom, 1.2));
const cam = new Camera({
tx: (R.c0 + R.c1) / 2, ty: (R.r0 + R.r1) / 2, tz: 0,
tilt: finite(opts.tilt, DEFAULT_OPTICS.tilt), dist: finite(opts.dist, DEFAULT_OPTICS.dist), scale: 1, cx: 0, cy: 0,
});
const x0 = R.c0 - 0.5 - margin, x1 = R.c1 + 0.5 + margin;
const y0 = R.r0 - 0.5 - margin, y1 = R.r1 + 0.5 + margin;
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
const p = { x: 0, y: 0, s: 0, depth: 0 };
for (const x of [x0, x1]) for (const y of [y0, y1]) for (const z of [0, headroom]) {
cam.project(x, y, z, p);
if (p.x < minX) minX = p.x; if (p.x > maxX) maxX = p.x;
if (p.y < minY) minY = p.y; if (p.y > maxY) maxY = p.y;
}
const bw = Math.max(EPS, maxX - minX), bh = Math.max(EPS, maxY - minY);
let s = Math.min(availW / bw, availH / bh);
const maxTile = finite(opts.maxTilePx, 360), minTile = finite(opts.minTilePx, 4);
s = clamp(s, minTile, maxTile);
cam.scale = s;
cam.cx = pl + availW / 2 - s * (minX + maxX) / 2;
cam.cy = pt + availH / 2 - s * (minY + maxY) / 2;
return cam.update();
}
/** Parse a configBlackBoard camera param "(x,y,z,w)" → [x, y, z, w] (null when malformed). */
export function parseCameraParam(v) {
if (Array.isArray(v)) {
const a = v.map(Number);
return a.length >= 3 && a.slice(0, 3).every(Number.isFinite) ? [a[0], a[1], a[2], Number.isFinite(a[3]) ? a[3] : 1] : null;
}
if (typeof v !== 'string') return null;
const nums = v.replace(/[()\s]/g, '').split(',').map(Number);
if (nums.length < 3 || !nums.slice(0, 3).every(Number.isFinite)) return null;
return [nums[0], nums[1], nums[2], Number.isFinite(nums[3]) ? nums[3] : 1];
}
/**
* The official camera of a configBlackBoard param on a `width`×`height` viewport (see the header): position =
* OFFICIAL.base + param (+ the narrow-screen adjustment), pitch 30°, vertical FOV 40°, centred principal point.
* @param {string|number[]} param "(x,y,z,w)" or [x,y,z]
* @param {{ width: number, height: number }} viewport
*/
export function officialCamera(param, viewport, opts) {
const o = opts && typeof opts === 'object' ? opts : {};
const p = parseCameraParam(param) || [0, 0, 0, 1];
const W = Math.max(1, finite(viewport?.width, 1920)), H = Math.max(1, finite(viewport?.height, 1080));
const pitch = finite(o.pitch, OFFICIAL.pitch), fovY = finite(o.fovY, OFFICIAL.fovY);
const base = Array.isArray(o.base) ? o.base : OFFICIAL.base;
// narrow screens (up to 4:3) back the camera off like the client; wider ones keep the 16:9 position
const ratio = H / W;
const k = clamp((9 / 16 - ratio) / (9 / 16 - 3 / 4), 0, 1);
const adj = OFFICIAL.aspectAdjust;
const ux = p[0] + base[0] + adj[0] * k, uy = p[1] + base[1] + adj[1] * k, uz = p[2] + base[2] + adj[2] * k;
const cx = ux + OFFICIAL.center[0], cy = uy + OFFICIAL.center[1], height = Math.max(0.5, -uz);
const t = clamp(pitch, 1, 80) * DEG;
const dist = height / Math.cos(t);
const focal = (H / 2) / Math.tan((clamp(fovY, 5, 150) * DEG) / 2);
return new Camera({ tx: cx, ty: cy + height * Math.tan(t), tz: 0, tilt: pitch, dist, scale: focal / dist, cx: W / 2, cy: H / 2 });
}
const sameRect = (a, b) => !!a && !!b && a.r0 === b.r0 && a.r1 === b.r1 && a.c0 === b.c0 && a.c1 === b.c1;
/**
* Preset camera for a field kind: 'prep' | 'normal' | 'unite' | 'boss' ('hidden') | 'bossPrep' | 'pen'.
* Official framing (configBlackBoard params, `opts.config` = stage.config overrides the defaults) unless the
* viewport is narrower than 4:3 (portrait), `opts.fit` is set, or `opts.rect` is a custom rect — then the rect is
* fitted into the padded viewport (`fitCamera`). `opts.side` 'L'|'R' picks the side's param (right boss half);
* `opts.half` frames only that half of a unite/boss field; `opts.shop === false` uses the shop-collapsed prep camera.
*/
export function presetCamera(kind, viewport, options) {
const opts = options && typeof options === 'object' ? options : {};
let k = kind === 'hidden' ? 'boss' : kind;
if (!CAMERA_PRESETS[k]) k = 'normal';
let rect = opts.rect ? normRect(opts.rect) : null;
// the Final Assault prep happens on the boss field: a 'prep' camera asked for the boss rows is the boss one
if (k === 'prep' && rect && rect.r1 <= 6) k = 'bossPrep';
const preset = CAMERA_PRESETS[k];
const side = opts.side === 'R' ? 'R' : 'L';
const W = Math.max(1, finite(viewport?.width, 1)), H = Math.max(1, finite(viewport?.height, 1));
const knownRect = !rect || sameRect(rect, preset.rect) || (k === 'prep' && rect.r0 >= 6 && rect.r1 <= 12 && rect.c1 <= 10)
|| (k === 'normal' && rect.r0 >= 6 && rect.r1 <= 13) || (k === 'unite' && rect.r0 >= 6 && rect.r1 <= 13)
|| (k === 'boss' && rect.r1 <= 6) || k === 'bossPrep' || k === 'pen';
const useOfficial = !opts.fit && knownRect && W / H >= 4 / 3 - 1e-6;
if (useOfficial) {
const table = opts.half && preset.half ? preset.half : opts.shop === false && preset.officialNoShop ? preset.officialNoShop : preset.official;
const key = table[side];
const cfg = opts.config && typeof opts.config === 'object' ? opts.config : null;
const param = (cfg && parseCameraParam(cfg[key])) || parseCameraParam(OFFICIAL_PARAMS[key]);
return officialCamera(param, { width: W, height: H }, opts);
}
if (!rect) {
rect = { ...preset.rect };
// the right-hand Final Assault player prepares on the right boss half (mirrored col c → 20 − c)
if (k === 'bossPrep' && side === 'R') rect = { ...rect, c0: 20 - preset.rect.c1, c1: 20 - preset.rect.c0 };
}
if (k === 'prep') rect = { ...rect, r0: Math.min(rect.r0, 7) };
if (opts.half && (k === 'boss' || k === 'unite')) {
const mid = Math.floor((rect.c0 + rect.c1) / 2);
rect = side === 'R' ? { ...rect, c0: mid } : { ...rect, c1: mid };
}
// a narrow rect (e.g. a solo boss half) keeps the normal optics; wide ones use the preset's wider lens
const wide = rect.c1 - rect.c0 >= 14;
const base = wide ? preset : CAMERA_PRESETS[k === 'prep' || k === 'bossPrep' ? 'prep' : 'normal'];
return fitCamera(rect, viewport, {
tilt: finite(opts.tilt, base.tilt), dist: finite(opts.dist, base.dist),
margin: finite(opts.margin, preset.margin), headroom: finite(opts.headroom, preset.headroom),
maxTilePx: opts.maxTilePx, minTilePx: opts.minTilePx,
});
}
/**
* Tile under a screen point, honouring raised tiles: planes are tried from the highest height down, and a hit
* counts only when the tile found there really has that height. Returns `{ row, col }` or null (off-grid).
* @param {Camera} cam
* @param {(row:number, col:number) => number} heightAt tile top height (0 for low / off-grid)
* @param {number[]} [levels] distinct heights to test (descending), default [heights present…, 0]
*/
export function pickTile(cam, sx, sy, heightAt, levels = [0], rows = 19, cols = 21) {
const p = { x: 0, y: 0 };
const hs = [...levels].sort((a, b) => b - a);
if (!hs.includes(0)) hs.push(0);
for (const h of hs) {
if (!cam.unproject(sx, sy, h, p)) continue;
const col = Math.round(p.x) + 0, row = Math.round(p.y) + 0; // + 0 turns -0 into 0
if (row < 0 || row >= rows || col < 0 || col >= cols) {
if (h === 0) return null;
continue;
}
const th = heightAt ? finite(heightAt(row, col), 0) : 0;
if (h === 0 || Math.abs(th - h) < 1e-3) return { row, col, x: p.x, y: p.y };
}
return null;
}
/** Screen-space quad (4 projected corners, counter-clockwise from near-left) of a tile top at height z. */
export function tileQuad(cam, row, col, z = 0, inset = 0) {
const a = 0.5 - inset;
return [
cam.project(col - a, row - a, z, { x: 0, y: 0, s: 0, depth: 0 }),
cam.project(col + a, row - a, z, { x: 0, y: 0, s: 0, depth: 0 }),
cam.project(col + a, row + a, z, { x: 0, y: 0, s: 0, depth: 0 }),
cam.project(col - a, row + a, z, { x: 0, y: 0, s: 0, depth: 0 }),
];
}