// 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); } /** * World height above (x, y, z) whose projection lies `px` CSS px above the projection of (x, y, z) — exact for this * camera. A unit's model is an upright screen billboard, so a point at a given drawn height on it (render/fx.js * SHOT_HEIGHT, GitHub #61) is this lift, not that many tiles: under the 30° pitch a vertical world height shows at * about half its length. */ liftFor(x, y, z, px) { const 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; // screen y = cy − k·up/depth; raising z by h: up += h·sin, depth −= h·cos — solve for the wanted up/depth ratio const ratio = up / depth + px / this._k; const den = this._s + ratio * this._c; if (!(den > 1e-6)) return px / Math.max(1e-6, (this._k / depth) * Math.max(this._s, 0.1)); return (ratio * depth - up) / den; } /** * 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 }), ]; }