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).
298 lines
17 KiB
JavaScript
298 lines
17 KiB
JavaScript
// 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);
|
||
});
|
||
});
|