Files
ark-stronghold-protocol/test/ui/unitFacing.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

158 lines
7.5 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/units.js facing display (research 09 §1.2, DESIGN §3 "Facing (corrected)"): a unit's deploy direction picks
// its model (Back for UP when one exists, Front for RIGHT / DOWN, Front mirrored for LEFT), `setDir` re-orients a live
// view (swapping Front ⇄ Back without a fallback flash and keeping every Spine acquire paired with one release), and
// the orange ground wedge of prep board pieces points along the direction on the ground plane.
// Headless: test/render/fakepixi.js stands in for PIXI.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { installFakePixi, fakeViewCtx } from '../render/fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
let fake, UnitView, unitDir;
before(async () => {
fake = installFakePixi();
({ UnitView, unitDir } = await import('../../public/js/render/units.js'));
});
after(() => fake.restore());
const tick = () => new Promise((r) => setImmediate(r));
const cam = () => presetCamera('prep', { width: 1280, height: 720 });
/** Asset store stub with Front / Back Spine entries and acquire / release bookkeeping. */
function store({ back = true } = {}) {
const front = { skel: '/s/front.skel', atlas: '/s/front.atlas', anims: { idle: 'Idle' }, animations: { Idle: 1 } };
const backE = { skel: '/s/back.skel', atlas: '/s/back.atlas', anims: { idle: 'Idle' }, animations: { Idle: 1 } };
const refs = new Map();
const log = [];
return {
refs, log, front, backE,
picture: () => null,
image: async () => null,
hasBack: () => back,
spineEntry: (id, o) => { log.push(!!(o && o.back)); return o && o.back && back ? backE : front; },
spine: {
acquire: async (e) => { refs.set(e.skel, (refs.get(e.skel) || 0) + 1); return { animations: [{ name: 'Idle' }] }; },
release: (e) => { refs.set(e.skel, (refs.get(e.skel) || 0) - 1); },
},
};
}
function view(info, opts = {}, assets = store()) {
const ctx = fakeViewCtx(fake.P, { assets, cam });
return new UnitView(ctx, { id: 1, side: 'ally', kind: 'chess', defId: 'char_x', spine: 'char_x', tier: 3, x: 5, y: 10, maxHp: 1000, ...info }, opts);
}
describe('deploy direction → model', () => {
test('unitDir: `dir` (any case) wins, else the legacy facing sign', () => {
assert.equal(unitDir({ dir: 'up' }), 'UP');
assert.equal(unitDir({ facing: -1 }), 'LEFT');
assert.equal(unitDir({}), 'RIGHT');
assert.equal(unitDir({ dir: 'north', facing: -1 }), 'LEFT');
});
test('UP loads the Back model; LEFT mirrors the Front one; enemies ignore dir', async () => {
const s = store();
const up = view({ dir: 'UP' }, { prep: true }, s);
await tick(); await tick();
assert.equal(s.log[0], true, 'Back model requested');
assert.equal(up.visFacing, 1);
const left = view({ dir: 'LEFT' }, { prep: true }, s);
await tick();
assert.equal(s.log[1], false, 'Front model');
assert.equal(left.visFacing, -1, 'mirrored');
const noBack = store({ back: false });
view({ dir: 'UP' }, {}, noBack);
assert.equal(noBack.log[0], false, 'no Back model → Front');
const en = view({ side: 'enemy', kind: 'enemy', dir: 'UP' }, {}, s);
assert.equal(en.dir, null);
en.setDir('RIGHT');
assert.equal(en.visFacing, -1, 'enemies keep their own facing rule');
});
test('setDir swaps Front ⇄ Back in place (no fallback flash) and pairs every acquire with a release', async () => {
const s = store();
const v = view({ dir: 'RIGHT' }, { prep: true }, s);
await tick(); await tick();
assert.ok(v.spineReady);
const first = v.actor;
v.setDir('DOWN');
await tick();
assert.equal(v.actor, first, 'DOWN keeps the Front model');
v.setDir('UP');
assert.equal(v.actor, first, 'the old model stays up while the Back one loads');
await tick(); await tick();
assert.notEqual(v.actor, first, 'Back model in place');
assert.ok(first.spine === null || first.spine?.destroyed !== false, 'previous actor destroyed');
assert.equal(v.swapT, 1, 'no cross-fade from the fallback diamond');
assert.equal(s.refs.get('/s/front.skel'), 0, 'Front released');
assert.equal(s.refs.get('/s/back.skel'), 1);
// a quick UP → LEFT → UP: superseded loads release themselves
v.setDir('LEFT');
v.setDir('UP');
await tick(); await tick();
v.destroy();
await tick(); await tick();
for (const [k, n] of s.refs) assert.equal(n, 0, `${k} balanced after destroy`);
});
test('setFacing(±1) keeps an UP / DOWN operator vertical', () => {
const v = view({ dir: 'UP' }, {}, store({ back: false }));
v.setFacing(1);
assert.equal(v.dir, 'UP');
v.setFacing(-1);
assert.equal(v.dir, 'LEFT');
v.setFacing(1);
assert.equal(v.dir, 'RIGHT');
});
});
describe('ground wedge "›" of prep board pieces', () => {
const angle = (dir) => {
const v = view({ dir }, { prep: true }, store({ back: false }));
v._showFacing = true;
v.update(1 / 60, cam(), 0);
assert.ok(v.facingArrow && v.facingArrow.visible, 'wedge shown');
const a = v.facingArrow.rotation;
const pos = { x: v.facingArrow.position.x, y: v.facingArrow.position.y };
const c = cam().project(v.x, v.y, 0.01);
return { a, dx: pos.x - c.x, dy: pos.y - c.y, sx: v.facingArrow.scale.x, sy: v.facingArrow.scale.y };
};
test('points along the direction on screen (RIGHT → right, UP → up the board, …) and is foreshortened along it', () => {
const r = angle('RIGHT'); const l = angle('LEFT'); const u = angle('UP'); const d = angle('DOWN');
assert.ok(Math.abs(r.a) < 0.05 && r.dx > 0, `RIGHT (${r.a})`);
assert.ok(Math.abs(Math.abs(l.a) - Math.PI) < 0.05 && l.dx < 0, `LEFT (${l.a})`);
assert.ok(Math.abs(u.a + Math.PI / 2) < 0.05 && u.dy < 0, `UP points up the screen (${u.a})`);
assert.ok(Math.abs(d.a - Math.PI / 2) < 0.05 && d.dy > 0, `DOWN (${d.a})`);
assert.ok(u.sx < r.sx, 'a vertical wedge is shorter along its axis (ground tilt)');
assert.ok(r.sx > 0 && l.sx > 0, 'no negative scale (rotation does the mirroring)');
});
test('setDir re-points the wedge of a live piece', () => {
const v = view({ dir: 'RIGHT' }, { prep: true }, store({ back: false }));
v._showFacing = true;
v.update(1 / 60, cam(), 0);
v.setDir('LEFT');
v.update(1 / 60, cam(), 1 / 60);
assert.ok(Math.abs(Math.abs(v.facingArrow.rotation) - Math.PI) < 0.05);
});
});
describe('ground wedge in combat (research 09 §1.2: "prep and combat") — review regression', () => {
test('battle allies show it only when the UnitInfo carries dir; enemies and bench pieces never', () => {
const withDir = view({ dir: 'UP' }, {}, store({ back: false }));
withDir.update(1 / 60, cam(), 0);
assert.ok(withDir.facingArrow && withDir.facingArrow.visible, 'battle ally with dir: wedge');
assert.ok(Math.abs(withDir.facingArrow.rotation + Math.PI / 2) < 0.05, 'pointing UP');
const legacy = view({ facing: 1 }, {}, store({ back: false }));
legacy.update(1 / 60, cam(), 0);
assert.ok(!legacy.facingArrow || !legacy.facingArrow.visible, 'no dir (legacy ±1 only): no wedge rather than a guessed RIGHT');
const enemy = view({ side: 'enemy', kind: 'enemy', dir: 'LEFT' }, {}, store({ back: false }));
enemy.update(1 / 60, cam(), 0);
assert.ok(!enemy.facingArrow || !enemy.facingArrow.visible, 'enemies: none');
const bench = view({ dir: 'RIGHT' }, { prep: true }, store({ back: false }));
bench._showFacing = false;
bench.update(1 / 60, cam(), 0);
assert.ok(!bench.facingArrow || !bench.facingArrow.visible, 'own bench piece: none');
});
});