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

497 lines
27 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.
//
// HUD clearance of the prep views (user playtest #5 item 9, `clearHud`): the official shop camera puts the bench's
// near edge right on the shop bar's top and the field's back row right under the bond strip at 16:9 (both measured
// flush at 1920×1080). The remake's HUD is sized in rem with a 40 px floor (css/theme.css), so on screens shorter
// than 432 CSS px (phones in landscape) it is taller than the official one relative to the screen and the shop bar
// covered the lower part of bench pads 3–9 (29 % at 844×390, 46 % at the user's Android = 756×366, 51 % at 800×360;
// the Final Assault prep alike). With `opts.hud` (the px bands the HUD covers along the top and bottom edge) the
// official prep camera is kept whenever the band from the bench's near edge to the field's back row fits between them
// — every desktop viewport measured (the Final Assault bench, 1.2 px lower, is nudged up < 3 px at 16:9) — and
// otherwise panned and, only when the band is taller than the space, zoomed out (844×390: ×0.90, 756×366: ×0.84,
// 800×360: ×0.83): a 2D pan / zoom of the image (principal point and focal length), so the perspective and the
// three.js camera stay the official ones.
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 });
/** Tile-top heights (tiles) of the prep band's edges (render/style.js TILE_H): bench pads, raised high ground. */
const KEEP_Z_BENCH = 0.16, KEEP_Z_HIGH = 0.42;
/**
* 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).
* `keep` (prep views): the band `clearHud` keeps clear of the HUD — `near` = world row (y) of the bench's near edge
* (hand row 7 / boss row 0) with the pads' tops at `zNear`, `far` = the field's back edge (row 12 / boss row 5) with
* its raised tops at `zFar`.
*/
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' }),
keep: Object.freeze({ near: 6.5, zNear: KEEP_Z_BENCH, far: 12.5, zFar: KEEP_Z_HIGH }),
}),
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' }),
keep: Object.freeze({ near: -0.5, zNear: KEEP_Z_BENCH, far: 5.5, zFar: KEEP_Z_HIGH }),
}),
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 });
}
/** Overlap (px) between the kept band and a HUD band that still counts as clear (float noise). */
const HUD_TOLERANCE = 0.5;
/** Gap (px) an adjusted camera leaves between the kept band and the HUD when there is room (no seam under an edge). */
const HUD_GAP = 1;
/**
* Keep a band of the board clear of the HUD (see the header). `hud` = { top, bottom }: CSS px the HUD covers along
* the viewport's top edge (top bar + bond strip) and bottom edge (the shop bar); `keep` = { near, zNear?, far, zFar? }:
* world rows (y) of the band's near and far edge, at the heights zNear / zFar. Returns `cam` itself when the band
* fits between the HUD bands; else a new camera whose image is panned vertically by the smallest shift that clears
* the HUD (by up to HUD_GAP where there is room) or — only when the band is taller than the space between — zoomed
* out about the viewport's centre so it fills that space less HUD_GAP at both ends. The screen y of a horizontal
* world line is the same at every x (the camera has no roll), so one point per edge decides.
* @param {Camera} cam
* @param {{ top?: number, bottom?: number }|null} hud
* @param {{ near: number, zNear?: number, far: number, zFar?: number }|null} keep
* @param {{ width: number, height: number }} viewport CSS px
* @returns {Camera}
*/
export function clearHud(cam, hud, keep, viewport) {
if (!cam || !hud || typeof hud !== 'object' || !keep) return cam;
const W = Math.max(1, finite(viewport?.width, 1)), H = Math.max(1, finite(viewport?.height, 1));
const top = Math.max(0, finite(hud.top, 0)), bottom = H - Math.max(0, finite(hud.bottom, 0));
if (!(bottom - top > 16)) return cam; // no usable space: keep the official framing
const yNear = cam.project(cam.tx, keep.near, finite(keep.zNear, 0)).y;
const yFar = cam.project(cam.tx, keep.far, finite(keep.zFar, 0)).y;
if (!Number.isFinite(yNear) || !Number.isFinite(yFar) || !(yNear > yFar)) return cam;
if (yNear <= bottom + HUD_TOLERANCE && yFar >= top - HUD_TOLERANCE) return cam;
const spare = bottom - top - (yNear - yFar);
const gap = spare >= 0 ? Math.min(HUD_GAP, spare / 2) : HUD_GAP;
const lo = top + gap, hi = bottom - gap;
const f = spare >= 0 ? 1 : (hi - lo) / (yNear - yFar);
const out = cam.clone();
// image transform x' = xc + f·(x − xc), y' = lo + f·(y − yFar) (zoom) or y' = y + dy (pan, the smallest shift
// that puts [yFar, yNear] inside [lo, hi]): scale the focal length by f and move the principal point accordingly
out.scale = cam.scale * f;
out.cx = W / 2 + f * (cam.cx - W / 2);
out.cy = f < 1 ? lo + f * (cam.cy - yFar) : cam.cy + Math.min(Math.max(0, lo - yFar), hi - yNear);
return out.update();
}
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.
* `opts.hud` = { top, bottom } (CSS px of HUD along the top / bottom edge): the official prep / Final Assault prep
* camera keeps the bench and the field clear of it (`clearHud`, the preset's `keep` band).
*/
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]);
const cam = officialCamera(param, { width: W, height: H }, opts);
return preset.keep && opts.hud ? clearHud(cam, opts.hud, preset.keep, { width: W, height: H }) : cam;
}
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 }),
];
}