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).
158 lines
7.5 KiB
JavaScript
158 lines
7.5 KiB
JavaScript
// 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');
|
||
});
|
||
});
|