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

298 lines
17 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.
// test/render/projection.test.js — camera math: projection ↔ unprojection round trips, perspective
// properties, framing presets (the official configBlackBoard cameras, fitted against official screenshots), lerp,
// tile picking with raised tiles, and the three.js PerspectiveCamera equivalence the 3D board relies on (every tile
// centre projected through both layers lands on the same pixel).
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import {
Camera, fitCamera, presetCamera, lerpCamera, pickTile, tileQuad, normRect, CAMERA_PRESETS, easeInOutCubic,
OFFICIAL, OFFICIAL_PARAMS, officialCamera, parseCameraParam, threeCameraParams, syncThreeCamera,
} from '../../public/js/render/projection.js';
import * as THREE from 'three';
const near = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
describe('Camera', () => {
test('project → unproject round trip on several planes and tilts', () => {
for (const tilt of [0, 20, 36, 55]) {
for (const dist of [8, 16, 40]) {
const cam = new Camera({ tx: 7, ty: 10, tilt, dist, scale: 70, cx: 640, cy: 360 });
for (let i = 0; i < 200; i++) {
const x = -2 + (i * 7.31) % 24, y = -1 + (i * 3.77) % 20, z = [0, 0.18, 0.42, 1.3][i % 4];
if (cam.depthOf(x, y, z) < 0.5) continue; // behind / at the camera: not invertible (depth is clamped)
const p = cam.project(x, y, z);
const w = cam.unproject(p.x, p.y, z);
assert.ok(w, `unproject null for ${x},${y},${z}`);
assert.ok(near(w.x, x, 1e-6) && near(w.y, y, 1e-6), `round trip ${x},${y},${z} tilt ${tilt} → ${w.x},${w.y}`);
}
}
}
});
test('target maps to (cx, cy) and scale is px per unit at the target', () => {
const cam = new Camera({ tx: 5, ty: 10, tilt: 36, dist: 16, scale: 50, cx: 300, cy: 200 });
const p = cam.project(5, 10, 0);
assert.ok(near(p.x, 300) && near(p.y, 200));
assert.ok(near(p.s, 50));
const q = cam.project(6, 10, 0);
assert.ok(near(q.x - p.x, 50, 1e-9));
});
test('farther rows are narrower and higher on screen; heights go up', () => {
const cam = new Camera({ tx: 5, ty: 10, tilt: 36, dist: 16, scale: 60, cx: 500, cy: 400 });
const w = (row) => cam.project(5.5, row, 0).x - cam.project(4.5, row, 0).x;
assert.ok(w(12) < w(9), 'row 12 narrower than row 9');
assert.ok(w(9) < w(7), 'row 9 narrower than row 7');
const ratio = w(7) / w(12);
assert.ok(ratio > 1.1 && ratio < 1.35, `row 7 / row 12 width ratio ${ratio} ≈ 1.2 like the original`);
assert.ok(cam.project(5, 12, 0).y < cam.project(5, 9, 0).y, 'far rows are higher');
assert.ok(cam.project(5, 10, 1).y < cam.project(5, 10, 0).y, 'z up is screen up');
// tile depth foreshortening: a tile is shorter than it is wide
const h = cam.project(5, 9.5, 0).y - cam.project(5, 10.5, 0).y;
assert.ok(h > 0.6 * w(10) && h < 0.95 * w(10), `tile aspect ${h / w(10)}`);
});
test('top-down (tilt 0) is an orthographic-like square grid at the target depth', () => {
const cam = new Camera({ tx: 0, ty: 0, tilt: 0, dist: 10, scale: 40, cx: 0, cy: 0 });
const a = cam.project(1, 0, 0), b = cam.project(0, 1, 0);
assert.ok(near(a.x, 40) && near(a.y, 0));
assert.ok(near(b.x, 0) && near(b.y, -40));
});
test('unproject returns null for rays that miss the plane (above the horizon)', () => {
const cam = new Camera({ tx: 0, ty: 0, tilt: 60, dist: 10, scale: 40, cx: 0, cy: 0 });
// far above the screen: ray goes above the horizon
assert.equal(cam.unproject(0, -1e6, 0), null);
// plane above the camera is never hit looking down
assert.equal(cam.unproject(0, 0, 100), null);
});
test('points behind the camera do not produce NaN', () => {
const cam = new Camera({ tx: 0, ty: 0, tilt: 36, dist: 5, scale: 40, cx: 0, cy: 0 });
const p = cam.project(0, -50, 0);
assert.ok(Number.isFinite(p.x) && Number.isFinite(p.y) && Number.isFinite(p.s));
});
test('constructor sanitises bad input; copy/clone/params', () => {
const cam = new Camera({ tx: NaN, tilt: 999, dist: -3, scale: 0 });
assert.ok(Number.isFinite(cam.tx));
assert.ok(cam.tilt <= 80 && cam.dist >= 1 && cam.scale > 0);
const b = new Camera({ tx: 3, ty: 4, scale: 9, cx: 1, cy: 2 });
const c = cam.clone().copy(b);
assert.deepEqual(c.params(), b.params());
assert.notEqual(c, b);
});
});
describe('framing', () => {
const vp = { width: 1920, height: 1080 };
function bounds(cam, rect, margin = 0, headroom = 0) {
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const x of [rect.c0 - 0.5 - margin, rect.c1 + 0.5 + margin]) for (const y of [rect.r0 - 0.5 - margin, rect.r1 + 0.5 + margin]) for (const z of [0, headroom]) {
const p = cam.project(x, y, z);
minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x); minY = Math.min(minY, p.y); maxY = Math.max(maxY, p.y);
}
return { minX, maxX, minY, maxY };
}
test('fitCamera keeps the rect (+headroom) inside the padded viewport and touches one axis', () => {
for (const [w, h] of [[1920, 1080], [1280, 720], [800, 1200], [3440, 1440]]) {
for (const kind of Object.keys(CAMERA_PRESETS)) {
const P = CAMERA_PRESETS[kind];
const pad = { top: 90, bottom: 180, left: 120, right: 40 };
const cam = fitCamera(P.rect, { width: w, height: h, padding: pad }, { margin: P.margin, headroom: P.headroom, tilt: P.tilt, dist: P.dist });
const b = bounds(cam, P.rect, P.margin, P.headroom);
const tol = 0.5;
assert.ok(b.minX >= pad.left - tol && b.maxX <= w - pad.right + tol, `${kind} ${w}x${h} x ${b.minX}..${b.maxX}`);
assert.ok(b.minY >= pad.top - tol && b.maxY <= h - pad.bottom + tol, `${kind} ${w}x${h} y ${b.minY}..${b.maxY}`);
const fillX = (b.maxX - b.minX) / (w - pad.left - pad.right);
const fillY = (b.maxY - b.minY) / (h - pad.top - pad.bottom);
if (cam.scale < 360) assert.ok(Math.max(fillX, fillY) > 0.99, `${kind} ${w}x${h} fills one axis (${fillX}, ${fillY})`);
// centred on the free axis
const cxMid = (b.minX + b.maxX) / 2, cyMid = (b.minY + b.maxY) / 2;
assert.ok(near(cxMid, pad.left + (w - pad.left - pad.right) / 2, 1e-6));
assert.ok(near(cyMid, pad.top + (h - pad.top - pad.bottom) / 2, 1e-6));
}
}
});
test('presets (official framing): unite is wider than normal; boss shows its field; prep includes the hand row', () => {
const n = presetCamera('normal', vp), u = presetCamera('unite', vp), b = presetCamera('boss', vp), p = presetCamera('prep', vp);
assert.ok(u.scale < n.scale);
const inView = (cam, x, y) => { const q = cam.project(x, y, 0); return q.x >= 0 && q.x <= vp.width && q.y >= 0 && q.y <= vp.height; };
// 全景 of the boss field: the playable rows 1–5 × cols 2–18 (the official view crops the outer bench corners)
for (let c = 2; c <= 18; c++) for (let r = 1; r <= 5; r++) assert.ok(inView(b, c, r), `boss ${r},${c}`);
// 联防 全景 = mid_battle_camera_param (0,0,−4.22) exactly like the official client: at 16:9 the view ends at col
// ≈1.73 / 18.27 on row 9, so the E (9,2) and the gate (9,18) sit at the screen edges (centres x ≈ 31 / 1889 of
// 1920) with the outer part of their cubes cut — official framing, not to be "fixed" by re-fitting the rect
assert.ok(near(u.project(2, 9, 0).x, 31.3, 0.5) && near(u.project(18, 9, 0).x, 1888.7, 0.5));
for (let c = 2; c <= 18; c++) for (const r of [9, 12]) assert.ok(inView(u, c, r), `unite ${r},${c}`);
for (let c = 0; c <= 10; c++) for (const r of [7, 8, 9, 12]) assert.ok(inView(p, c, r), `prep ${r},${c}`);
// the official battle camera frames the playable part of the field (cols 2–10: E at col 2, the gates at col 10)
for (let c = 2; c <= 10; c++) for (const r of [9, 12]) assert.ok(inView(n, c, r), `normal ${r},${c}`);
assert.ok(!inView(n, 5, 2), 'normal camera does not show the boss field');
// unknown kind falls back to normal; explicit rect overrides
assert.deepEqual(presetCamera('???', vp).params(), n.params());
const half = presetCamera('boss', vp, { half: true, side: 'R' });
const q = half.project(18, 3, 0);
assert.ok(q.x > 0 && q.x < vp.width);
assert.ok(half.project(0, 3, 0).x < 0, 'left half off screen when framing the right half');
// the preview pen camera shows the whole pen (rows 14–18 × cols 7–13)
const pen = presetCamera('pen', vp);
for (let c = 7; c <= 13; c++) for (const r of [14, 18]) assert.ok(inView(pen, c, r), `pen ${r},${c}`);
// shop collapsed: the prepare camera is closer than the shop one
assert.ok(presetCamera('prep', vp, { shop: false }).scale > p.scale);
// Final Assault prep on the boss rows uses the boss shop cameras (left / right)
const bp = presetCamera('prep', vp, { rect: { r0: 0, r1: 5, c0: 0, c1: 10 }, side: 'R' });
assert.ok(inView(bp, 15, 3) && bp.tx > 14 && !inView(bp, 3, 3), 'right boss prep camera frames the right half');
});
test('official cameras reproduce the official screenshots (tile seams of a 1920×1079 shop view)', () => {
// act1autochess_m06 (上半, same configBlackBoard), shop panel open → left_shop_camera_param; seams measured by
// eye on the official screenshot (bahamut a7a712…): (x = col boundary, y = row boundary) → screen px
const cam = presetCamera('prep', { width: 1920, height: 1079 });
const seams = [[6.5, 11.5, 1099.3, 326], [7.5, 11.5, 1199.3, 326], [8.5, 11.5, 1299.3, 326], [6.5, 10.5, 1101.7, 408],
[7.5, 10.5, 1205, 409], [8.5, 10.5, 1306.7, 410], [4.5, 9.5, 888, 501], [6.5, 9.5, 1104, 501], [3.5, 9.5, 776, 501]];
let sq = 0;
for (const [x, y, sx, sy] of seams) { const p = cam.project(x, y, 0); sq += (p.x - sx) ** 2 + (p.y - sy) ** 2; }
const rms = Math.sqrt(sq / seams.length);
assert.ok(rms < 5, `rms ${rms.toFixed(2)} px`);
// the camera itself: pitch 30°, vertical FOV 40°, principal point at the centre, standard view + shop param
assert.equal(cam.tilt, OFFICIAL.pitch);
assert.ok(near(2 * Math.atan(1079 / 2 / (cam.scale * cam.dist)) / (Math.PI / 180), OFFICIAL.fovY, 1e-9));
assert.deepEqual([cam.cx, cam.cy], [960, 539.5]);
const pos = cam.position();
assert.ok(near(pos.x, 5.17, 1e-9) && near(pos.y, 2.44, 1e-9) && near(pos.z, 11.53, 1e-9), JSON.stringify(pos));
});
test('officialCamera / parseCameraParam: data-driven params, narrow screens back off, garbage is safe', () => {
assert.deepEqual(parseCameraParam('(-4.67,0.3,-2.46,1)'), [-4.67, 0.3, -2.46, 1]);
assert.deepEqual(parseCameraParam([1, 2, 3]), [1, 2, 3, 1]);
assert.equal(parseCameraParam('(a,b)'), null);
assert.equal(parseCameraParam(null), null);
for (const k of Object.keys(OFFICIAL_PARAMS)) assert.ok(parseCameraParam(OFFICIAL_PARAMS[k]), k);
// stage.config overrides the defaults (same keys as configBlackBoard)
const moved = presetCamera('normal', vp, { config: { left_battle_camera_param: '(0,1.91,-0.6,1)' } });
assert.ok(near(moved.tx, 10), 'param x = col − 10');
// 4:3 backs the camera off (smaller tiles than a 16:9 screen of the same height)
const wide = officialCamera(OFFICIAL_PARAMS.left_battle_camera_param, { width: 1440, height: 810 });
const tall = officialCamera(OFFICIAL_PARAMS.left_battle_camera_param, { width: 1080, height: 810 });
assert.ok(tall.scale < wide.scale && tall.position().z > wide.position().z);
// portrait: the fitted fallback keeps the whole field on screen
const port = presetCamera('normal', { width: 700, height: 1400 });
for (let c = 0; c <= 10; c++) assert.ok(port.project(c, 9, 0).x > 0 && port.project(c, 9, 0).x < 700, `portrait col ${c}`);
for (const v of Object.values(officialCamera('garbage', { width: NaN, height: 0 }).params())) assert.ok(Number.isFinite(v));
});
test('tiny / degenerate viewports never produce NaN', () => {
const cam = fitCamera({ r0: 9, r1: 12, c0: 0, c1: 10 }, { width: 0, height: NaN, padding: { top: 5000 } });
for (const v of Object.values(cam.params())) assert.ok(Number.isFinite(v));
});
test('normRect swaps and clamps', () => {
assert.deepEqual(normRect({ r0: 12, r1: 9, c0: 30, c1: -4 }), { r0: 9, r1: 12, c0: 0, c1: 20 });
assert.deepEqual(normRect(null), { r0: 0, r1: 18, c0: 0, c1: 20 });
});
test('lerpCamera endpoints and midpoint; ease', () => {
const a = presetCamera('prep', vp), b = presetCamera('boss', vp);
const s0 = lerpCamera(a, b, 0).params(), ap = a.params();
for (const k of Object.keys(ap)) assert.ok(near(s0[k], ap[k], 1e-9), k);
const e = lerpCamera(a, b, 1).params(), bp = b.params();
for (const k of Object.keys(bp)) assert.ok(near(e[k], bp[k], 1e-9), k);
const m = lerpCamera(a, b, 0.5);
assert.ok(near(m.scale, Math.sqrt(a.scale * b.scale), 1e-9), 'scale lerps in log space');
assert.equal(easeInOutCubic(0), 0); assert.equal(easeInOutCubic(1), 1); assert.ok(near(easeInOutCubic(0.5), 0.5));
assert.ok(easeInOutCubic(0.25) < 0.25 && easeInOutCubic(0.75) > 0.75);
});
});
describe('pickTile / tileQuad', () => {
const cam = presetCamera('prep', { width: 1600, height: 900 });
const heights = (r, c) => (r === 7 ? 0.18 : r === 10 && c === 4 ? 0.42 : 0);
const levels = [0.42, 0.18, 0];
test('centres of every tile pick back the same tile (low, bench and raised)', () => {
for (let r = 7; r <= 12; r++) for (let c = 0; c <= 10; c++) {
const z = heights(r, c);
const p = cam.project(c, r, z);
const t = pickTile(cam, p.x, p.y, heights, levels);
assert.ok(t, `tile ${r},${c}`);
assert.deepEqual([t.row, t.col], [r, c], `pick ${r},${c}`);
}
});
test('the top face of a raised tile wins over the ground tile behind it', () => {
// a point on the raised top near its far edge projects over the ground of the row behind
const p = cam.project(4, 10.45, 0.42);
const ground = cam.unproject(p.x, p.y, 0);
assert.equal(Math.round(ground.y), 11, 'ground ray hits the row behind');
const t = pickTile(cam, p.x, p.y, heights, levels);
assert.deepEqual([t.row, t.col], [10, 4]);
});
test('off-grid points return null', () => {
const far = cam.project(-5, 10, 0);
assert.equal(pickTile(cam, far.x, far.y, heights, levels), null);
assert.equal(pickTile(cam, 800, -1e6, heights, levels), null);
});
test('tileQuad is a trapezoid narrower at the far edge', () => {
const q = tileQuad(cam, 10, 5);
const nearW = q[1].x - q[0].x, farW = q[2].x - q[3].x;
assert.ok(nearW > farW && farW > 0);
assert.ok(q[3].y < q[0].y);
});
});
describe('three.js PerspectiveCamera equivalence (the 3D board layer)', () => {
const kinds = ['prep', 'normal', 'unite', 'boss', 'pen', 'bossPrep'];
test('every tile centre (ground and raised) projects to the same pixel through Camera and THREE', () => {
for (const [W, H] of [[1920, 1080], [1280, 720], [1600, 1000], [900, 1300]]) {
for (const kind of kinds) {
const cam = presetCamera(kind, { width: W, height: H, padding: { top: 90, bottom: 180, left: 120, right: 40 } });
const three = syncThreeCamera(cam, new THREE.PerspectiveCamera(), W, H);
let worst = 0;
const v = new THREE.Vector3();
for (let r = 0; r < 19; r++) for (let c = 0; c < 21; c++) for (const z of [0, 0.16, 0.42]) {
if (cam.depthOf(c, r, z) < 0.5) continue;
const p = cam.project(c, r, z);
v.set(c, r, z).project(three);
const sx = (v.x + 1) / 2 * W, sy = (1 - v.y) / 2 * H;
worst = Math.max(worst, Math.abs(sx - p.x), Math.abs(sy - p.y));
}
assert.ok(worst < 1e-6, `${kind} ${W}×${H}: ${worst} px`);
}
}
});
test('screen → world agrees too (three ray vs Camera.unproject), lens shift included', () => {
const W = 1280, H = 720;
const cam = fitCamera({ r0: 9, r1: 12, c0: 0, c1: 10 }, { width: W, height: H, padding: { top: 200, left: 300 } });
const three = syncThreeCamera(cam, new THREE.PerspectiveCamera(), W, H);
const ray = new THREE.Raycaster();
const plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
const hit = new THREE.Vector3();
for (const [sx, sy] of [[640, 360], [100, 600], [1200, 150], [333, 444]]) {
ray.setFromCamera(new THREE.Vector2((sx / W) * 2 - 1, 1 - (sy / H) * 2), three);
assert.ok(ray.ray.intersectPlane(plane, hit));
const w = cam.unproject(sx, sy, 0);
assert.ok(near(w.x, hit.x, 1e-6) && near(w.y, hit.y, 1e-6), `${sx},${sy}`);
}
});
test('threeCameraParams: fov / aspect / position / view offset', () => {
const cam = presetCamera('normal', { width: 1920, height: 1080 });
const p = threeCameraParams(cam, 1920, 1080);
assert.ok(near(p.fov, OFFICIAL.fovY, 1e-9));
assert.ok(near(p.aspect, 1920 / 1080));
assert.deepEqual(p.view, { fullWidth: 1920, fullHeight: 1080, offsetX: 0, offsetY: 0, width: 1920, height: 1080 });
const pos = cam.position();
assert.deepEqual(p.position.map((x) => Math.round(x * 1e9) / 1e9), [pos.x, pos.y, pos.z].map((x) => Math.round(x * 1e9) / 1e9));
assert.ok(p.near > 0 && p.far > p.near);
});
});