Files
ark-stronghold-protocol/public/js/render/projection.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

433 lines
22 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/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 }),
];
}