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).
306 lines
14 KiB
JavaScript
306 lines
14 KiB
JavaScript
// test/render/fxnumbers.test.js — damage-number layout of render/fx.js (FxSystem.number / _updateNums) without PIXI:
|
||
// numbers of different styles landing on one unit in quick succession must never be drawn on top of each other
|
||
// (an arts '41' and a phys '509' 0.1 s apart used to read as one '41509').
|
||
|
||
import { test, describe } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { FxSystem } from '../../public/js/render/fx.js';
|
||
import { presetCamera } from '../../public/js/render/projection.js';
|
||
|
||
class FakeText {
|
||
constructor(text) {
|
||
this.text = text;
|
||
this.visible = true;
|
||
this.alpha = 1;
|
||
this.anchor = { set() {} };
|
||
this.scale = { x: 1, set: (v) => { this.scale.x = v; } };
|
||
this.position = { x: 0, y: 0, set: (x, y) => { this.position.x = x; this.position.y = y; } };
|
||
}
|
||
destroy() { this.destroyed = true; }
|
||
}
|
||
|
||
function fakeFx(cam) {
|
||
const fx = Object.create(FxSystem.prototype);
|
||
Object.assign(fx, {
|
||
P: { BitmapText: FakeText },
|
||
ctx: { cam: () => cam, layers: { text: { addChild() {} } }, settings: {} },
|
||
nums: [], numFree: [], time: 0,
|
||
_p: { x: 0, y: 0, s: 0, depth: 0 }, _q: { x: 0, y: 0, s: 0, depth: 0 },
|
||
});
|
||
return fx;
|
||
}
|
||
|
||
// screen box of a number (BitmapText, anchor 0.5/1, fontSize 24 × scale): digits ≈ 0.55 em wide, ≈ 0.8 em tall
|
||
function box(t) {
|
||
const sc = t.text.scale.x;
|
||
const w = String(t.text.text).length * 24 * 0.55 * sc, h = 24 * 0.8 * sc;
|
||
const { x, y } = t.text.position;
|
||
return { x0: x - w / 2, x1: x + w / 2, y0: y - h, y1: y };
|
||
}
|
||
const overlap = (a, b) => a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
|
||
|
||
function run(fx, hits, dur = 1.2, dt = 1 / 60) {
|
||
const sorted = [...hits].sort((a, b) => a.t - b.t);
|
||
let worst = null;
|
||
for (let t = 0; t <= dur; t += dt) {
|
||
while (sorted.length && sorted[0].t <= t + 1e-9) {
|
||
const h = sorted.shift();
|
||
fx.number(h.view, h.n, h.style, !!h.big);
|
||
}
|
||
fx._updateNums(dt);
|
||
fx.time += dt;
|
||
const live = fx.nums.filter((n) => n.text.visible && n.text.alpha > 0.35);
|
||
for (let i = 0; i < live.length; i++) {
|
||
for (let j = i + 1; j < live.length; j++) {
|
||
if (live[i].unit !== live[j].unit) continue;
|
||
if (overlap(box(live[i]), box(live[j]))) worst = worst || { t: +t.toFixed(3), a: live[i].text.text, b: live[j].text.text, A: box(live[i]), B: box(live[j]) };
|
||
}
|
||
}
|
||
}
|
||
return worst;
|
||
}
|
||
|
||
describe('damage numbers on one unit', () => {
|
||
for (const [label, vp] of [['1920×1080', { width: 1920, height: 1080 }], ['1280×720', { width: 1280, height: 720 }]]) {
|
||
test(`an arts and a phys number 0.05–0.3 s apart never overlap (${label})`, () => {
|
||
const cam = presetCamera('normal', vp);
|
||
for (let i = 0; i <= 10; i++) {
|
||
const gap = 0.05 + i * 0.025;
|
||
const fx = fakeFx(cam);
|
||
const drone = { x: 9, y: 9, z: 0, _headTiles: 1.2, maxHp: 3000 };
|
||
const bad = run(fx, [{ view: drone, n: 41, style: 'arts', t: 0 }, { view: drone, n: 509, style: 'phys', t: gap }]);
|
||
assert.equal(bad, null, `gap ${gap.toFixed(3)} s: ${JSON.stringify(bad)}`);
|
||
}
|
||
});
|
||
}
|
||
|
||
test('a burst of mixed styles (phys/arts/true/elem/heal) stays readable', () => {
|
||
const cam = presetCamera('normal', { width: 1920, height: 1080 });
|
||
const fx = fakeFx(cam);
|
||
const u = { x: 6, y: 10, z: 0, _headTiles: 1.2, maxHp: 5000 };
|
||
const styles = ['phys', 'arts', 'true', 'elem', 'heal', 'phys', 'arts'];
|
||
const bad = run(fx, styles.map((style, i) => ({ view: u, n: 100 + i * 37, style, t: i * 0.09 })));
|
||
assert.equal(bad, null, JSON.stringify(bad));
|
||
});
|
||
|
||
test('a unit under heavy mixed fire (≈ 8 numbers/s, big hits included) keeps its numbers apart', () => {
|
||
for (const [kind, vp] of [['normal', { width: 1920, height: 1080 }], ['normal', { width: 1280, height: 720 }], ['boss', { width: 1920, height: 1080 }]]) {
|
||
const cam = presetCamera(kind, vp);
|
||
const fx = fakeFx(cam);
|
||
const u = { x: 8, y: kind === 'boss' ? 4 : 9, z: 0, _headTiles: 1.2, maxHp: 3000 };
|
||
let seed = 7;
|
||
const rnd = () => { seed = (seed * 16807) % 2147483647; return seed / 2147483647; };
|
||
const dt = 1 / 60;
|
||
let frames = 0, bad = 0;
|
||
const saved = Math.random;
|
||
Math.random = rnd;
|
||
try {
|
||
for (let t = 0; t < 20; t += dt) {
|
||
if (rnd() < dt / 0.3) fx.number(u, Math.floor(rnd() * 3000) + 1, 'phys', rnd() < 0.2);
|
||
if (rnd() < dt / 0.45) fx.number(u, Math.floor(rnd() * 900) + 1, 'arts', rnd() < 0.1);
|
||
if (rnd() < dt / 0.8) fx.number(u, Math.floor(rnd() * 400) + 1, 'heal', false);
|
||
if (rnd() < dt / 1.1) fx.number(u, Math.floor(rnd() * 12000) + 1, 'elem', rnd() < 0.5);
|
||
fx._updateNums(dt);
|
||
fx.time += dt;
|
||
frames++;
|
||
const live = fx.nums.filter((n) => n.text.visible && n.text.alpha > 0.35);
|
||
let hit = false;
|
||
for (let i = 0; i < live.length && !hit; i++) for (let j = i + 1; j < live.length; j++) if (overlap(box(live[i]), box(live[j]))) { hit = true; break; }
|
||
if (hit) bad++;
|
||
}
|
||
} finally { Math.random = saved; }
|
||
assert.ok(bad <= frames * 0.01, `${kind} ${vp.width}: ${bad}/${frames} frames with overlapping numbers`);
|
||
}
|
||
});
|
||
|
||
test('same-style hits within the merge window still merge into one number', () => {
|
||
const cam = presetCamera('normal', { width: 1920, height: 1080 });
|
||
const fx = fakeFx(cam);
|
||
const u = { x: 6, y: 10, z: 0, _headTiles: 1.2, maxHp: 5000 };
|
||
fx.number(u, 120, 'phys', false);
|
||
fx._updateNums(0.05); fx.time += 0.05;
|
||
fx.number(u, 80, 'phys', false);
|
||
assert.equal(fx.nums.length, 1);
|
||
assert.equal(fx.nums[0].text.text, '200');
|
||
});
|
||
|
||
test('numbers on different units are independent (no stacking lift)', () => {
|
||
const cam = presetCamera('normal', { width: 1920, height: 1080 });
|
||
const fx = fakeFx(cam);
|
||
const a = { x: 4, y: 10, z: 0, _headTiles: 1.2, maxHp: 5000 }, b = { x: 8, y: 10, z: 0, _headTiles: 1.2, maxHp: 5000 };
|
||
fx.number(a, 10, 'phys', false);
|
||
fx.number(b, 10, 'arts', false);
|
||
assert.equal(fx.nums[0].z0, fx.nums[1].z0);
|
||
});
|
||
|
||
test('a long column is capped: it never climbs more than a few lines above the head', () => {
|
||
const cam = presetCamera('normal', { width: 1920, height: 1080 });
|
||
const fx = fakeFx(cam);
|
||
const u = { x: 6, y: 10, z: 0, _headTiles: 1.2, maxHp: 5000 };
|
||
for (let i = 0; i < 30; i++) {
|
||
fx.number(u, 10 + i, ['phys', 'arts', 'true', 'elem'][i % 4], false);
|
||
fx._updateNums(0.03); fx.time += 0.03;
|
||
}
|
||
const s = cam.project(u.x, u.y, 0).s;
|
||
const line = 24 * 0.95 * Math.min(1.05, Math.max(0.42, s / 115)) * 1.35;
|
||
for (const n of fx.nums) assert.ok(-n.oy <= line * 5 + 1e-6, `oy ${n.oy} (line ${line})`);
|
||
assert.ok(fx.nums.filter((n) => !n.fading).length <= 4, 'at most 4 live numbers per unit');
|
||
});
|
||
});
|
||
|
||
// every live, readable number against every other one (any unit): boxes must not touch (a 2 px margin — two numbers
|
||
// side by side read as one, the '41509' / '201625' screenshots)
|
||
function overlapsAll(fx, margin = 2) {
|
||
const live = fx.nums.filter((n) => n.text.visible && n.text.alpha > 0.35);
|
||
for (let i = 0; i < live.length; i++) {
|
||
for (let j = i + 1; j < live.length; j++) {
|
||
const A = box(live[i]), B = box(live[j]);
|
||
if (A.x0 - margin < B.x1 && B.x0 - margin < A.x1 && A.y0 < B.y1 && B.y0 < A.y1) return { a: live[i].text.text, b: live[j].text.text, A, B };
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
describe('damage numbers across units (screen-space layout)', () => {
|
||
for (const [label, kind, vp] of [['boss 1920', 'boss', { width: 1920, height: 1080 }], ['normal 1280', 'normal', { width: 1280, height: 720 }]]) {
|
||
test(`a knot of units on one spot (a boss and 4 minions) under rapid mixed hits: numbers never touch (${label})`, () => {
|
||
const cam = presetCamera(kind, vp);
|
||
const fx = fakeFx(cam);
|
||
const y = kind === 'boss' ? 3 : 10;
|
||
const units = [
|
||
{ x: 7.2, y, z: 0, _headTiles: 2.2, maxHp: 400000 },
|
||
{ x: 7.35, y: y - 0.1, z: 0, _headTiles: 1.0, maxHp: 6000 },
|
||
{ x: 7.1, y: y - 0.2, z: 0, _headTiles: 1.0, maxHp: 6000 },
|
||
{ x: 7.5, y: y + 0.1, z: 0, _headTiles: 1.2, maxHp: 6000 },
|
||
{ x: 8.1, y, z: 0, _headTiles: 1.2, maxHp: 12000 },
|
||
];
|
||
let seed = 3;
|
||
const rnd = () => { seed = (seed * 16807) % 2147483647; return seed / 2147483647; };
|
||
const dt = 1 / 60;
|
||
let frames = 0, bad = 0, first = null;
|
||
for (let t = 0; t < 15; t += dt) {
|
||
for (let k = 0; k < 3; k++) {
|
||
if (rnd() > dt / 0.1) continue;
|
||
const i = (rnd() * units.length) | 0;
|
||
const boss = i === 0;
|
||
fx.number(units[i], boss ? 2000 + ((rnd() * 200000) | 0) : 40 + ((rnd() * 900) | 0), ['phys', 'arts', 'true', 'elem', 'heal'][(rnd() * 5) | 0], rnd() < 0.15);
|
||
}
|
||
fx._updateNums(dt);
|
||
fx.time += dt;
|
||
frames++;
|
||
const o = overlapsAll(fx);
|
||
if (o) { bad++; first = first || { t: +t.toFixed(2), ...o }; }
|
||
}
|
||
assert.ok(bad <= frames * 0.01, `${bad}/${frames} frames with touching numbers; first ${JSON.stringify(first)}`);
|
||
});
|
||
}
|
||
|
||
test("two neighbours hit at once: '41' and '509' are placed apart (never side by side as '41509')", () => {
|
||
const cam = presetCamera('normal', { width: 1920, height: 1080 });
|
||
for (let i = 0; i <= 8; i++) {
|
||
const fx = fakeFx(cam);
|
||
const a = { x: 7, y: 10, z: 0, _headTiles: 1.2, maxHp: 3000 }, b = { x: 7 + i * 0.1, y: 10, z: 0, _headTiles: 1.2, maxHp: 3000 };
|
||
fx.number(a, 41, 'arts', false);
|
||
fx._updateNums(0.02); fx.time += 0.02;
|
||
fx.number(b, 509, 'phys', false);
|
||
for (let f = 0; f < 60; f++) {
|
||
fx._updateNums(1 / 60); fx.time += 1 / 60;
|
||
const o = overlapsAll(fx, 4);
|
||
assert.equal(o, null, `offset ${i * 0.1}: ${JSON.stringify(o)}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
test('rapid same-style hits merge into a running total that stays up while the hits go on', () => {
|
||
const cam = presetCamera('normal', { width: 1920, height: 1080 });
|
||
const fx = fakeFx(cam);
|
||
const u = { x: 6, y: 10, z: 0, _headTiles: 1.2, maxHp: 50000 };
|
||
let sum = 0;
|
||
for (let i = 0; i < 5; i++) {
|
||
fx.number(u, 100 + i, 'phys', false);
|
||
sum += 100 + i;
|
||
for (let f = 0; f < 6; f++) { fx._updateNums(1 / 60); fx.time += 1 / 60; }
|
||
}
|
||
const phys = fx.nums.filter((n) => n.style === 'phys');
|
||
assert.equal(phys.length, 1);
|
||
assert.equal(phys[0].text.text, String(sum));
|
||
assert.ok(phys[0].end > 0.9, `life extended (${phys[0].end})`);
|
||
// a pause longer than the merge gap starts a new number
|
||
for (let f = 0; f < 24; f++) { fx._updateNums(1 / 60); fx.time += 1 / 60; }
|
||
fx.number(u, 7, 'phys', false);
|
||
assert.equal(fx.nums.filter((n) => n.style === 'phys').length, 2);
|
||
});
|
||
|
||
test('crowded spots fade faster; the per-font pools are reused (no new text objects once warm)', () => {
|
||
const cam = presetCamera('unite', { width: 1920, height: 1080 });
|
||
const fx = fakeFx(cam);
|
||
const units = Array.from({ length: 8 }, (_, i) => ({ x: 8 + (i % 4) * 0.4, y: 10 + (i >> 2) * 0.4, z: 0, _headTiles: 1.2, maxHp: 5000 }));
|
||
for (let i = 0; i < 8; i++) fx.number(units[i], 100 + i, 'arts', false);
|
||
const lives = fx.nums.map((n) => n.end);
|
||
assert.ok(Math.min(...lives) < Math.max(...lives), `shorter lives when crowded: ${lives}`);
|
||
let made = 0;
|
||
const Orig = fx.P.BitmapText;
|
||
fx.P.BitmapText = class extends Orig { constructor(...a) { super(...a); made++; } };
|
||
for (let f = 0; f < 120; f++) { fx._updateNums(1 / 60); fx.time += 1 / 60; }
|
||
for (let i = 0; i < 8; i++) fx.number(units[i], 50 + i, 'arts', false);
|
||
assert.equal(made, 0, 'recycled from the arts pool');
|
||
});
|
||
});
|
||
|
||
describe('damage numbers under heavy AoE (review regression: cost per hit, readability)', () => {
|
||
// a projection-counting camera: the layout of the live numbers must happen once per frame, not once per hit
|
||
function countingCam(kind, vp) {
|
||
const cam = presetCamera(kind, vp);
|
||
const project = cam.project.bind(cam);
|
||
cam.calls = 0;
|
||
cam.project = (...a) => { cam.calls++; return project(...a); };
|
||
return cam;
|
||
}
|
||
for (const layout of ['spread', 'knot']) {
|
||
test(`60 enemies (${layout}) hit twice each in one frame, every 0.1 s: O(hits) projections per frame, numbers never touch`, () => {
|
||
const cam = countingCam('unite', { width: 1920, height: 1080 });
|
||
const fx = fakeFx(cam);
|
||
const units = [];
|
||
for (let i = 0; i < 60; i++) {
|
||
units.push(layout === 'knot' ? { x: 12 + (i % 5) * 0.2, y: 10 + ((i / 5) | 0) * 0.1, z: 0, _headTiles: 1.2 } : { x: 1 + (i % 20), y: 9 + ((i / 20) | 0) + 0.3, z: 0, _headTiles: 1.2 });
|
||
}
|
||
const dt = 1 / 60;
|
||
let worstPerHit = 0, bad = 0, frames = 0, maxLive = 0;
|
||
for (let f = 0; f < 240; f++) {
|
||
if (f % 6 === 0) {
|
||
const before = cam.calls;
|
||
for (const u of units) { fx.number(u, 100 + ((f * 7) % 900), 'arts', false); fx.number(u, 50 + ((f * 3) % 500), 'phys', false); }
|
||
worstPerHit = Math.max(worstPerHit, (cam.calls - before) / 120);
|
||
}
|
||
fx._updateNums(dt);
|
||
fx.time += dt;
|
||
frames++;
|
||
maxLive = Math.max(maxLive, fx.nums.length);
|
||
if (overlapsAll(fx)) bad++;
|
||
// every entry of the list is a live number (displaced ones are released at once, never left as obstacles)
|
||
assert.ok(fx.nums.every((n) => n.text.visible), 'no released number stays in the live list');
|
||
}
|
||
// 2 projections for the hit itself + one layout of ≤ 90 live numbers per frame (spread over 120 hits) — the
|
||
// per-hit relayout this replaces cost ≈ 2 + 90 per hit
|
||
assert.ok(worstPerHit < 4, `projections per hit ${worstPerHit.toFixed(2)}`);
|
||
assert.ok(maxLive <= 90, `live numbers ${maxLive}`);
|
||
assert.ok(bad <= frames * 0.02, `${bad}/${frames} frames with touching numbers`);
|
||
});
|
||
}
|
||
|
||
test('a merged running total grows its box at once: the next hit of the same frame does not land on its digits', () => {
|
||
const cam = presetCamera('normal', { width: 1920, height: 1080 });
|
||
const fx = fakeFx(cam);
|
||
const a = { x: 6, y: 10, z: 0, _headTiles: 1.2 }, b = { x: 6.35, y: 10, z: 0, _headTiles: 1.2 };
|
||
fx.number(a, 9, 'phys', false);
|
||
fx._updateNums(0.02); fx.time += 0.02;
|
||
const w0 = fx.nums[0]._w;
|
||
fx.number(a, 99990, 'phys', false); // merges: '99999' (5 digits instead of 1)
|
||
assert.equal(fx.nums.length, 1);
|
||
assert.ok(fx.nums[0]._w > w0 * 2, 'box re-sized with the merge');
|
||
fx.number(b, 777, 'arts', false); // same frame: laid out against the grown box
|
||
fx._updateNums(0.001); fx.time += 0.001;
|
||
assert.equal(overlapsAll(fx), null);
|
||
});
|
||
});
|