- Cause, client: the prisoner skeletons carry three clip sets whose collar light is grey, blinking orange and red. The official battle prefabs (local client battle/enm_pfb_6.ab: the Graphic component's anim key -> clip table, the modes' replace pairs, the clip the Spine starts on) draw mode Default (【禁锢】) on the grey set, mode R (the warning) on the orange set and mode L (【解放】) on the red one. The name-based role resolver gave 普通囚犯 / 老练囚犯 their unnumbered set — the red one — so they came out of the gate freed; no FORMS entry existed, so no prisoner ever changed (强壮囚犯 / 拳师囚犯 / 重犯 / 传奇重犯 stayed grey). - Cause, sim: buff_template_data enemy_confinement[atk_cnt] spends a charge ON_BEFORE_ATTACK (not while 麻痹) and switches to mode R when one is left; enemy_liberty_listener[warning] switches to mode L before the next attack (PRTS "进行第4次攻击前,切换至解放状态"). archetypes.js prisoner() counted on the 'attack' hook, after the damage, so the 4th attack still hit confined, and it sent no form. - Now: prisoner() counts in `before` (an attack 麻痹 interrupts never gets there, PRTS 拳师囚犯), setForm 'warning' (fx 'phase', drawn silently: kinds.js SILENT_PHASES) before attack times-1 and 'liberty' (fx 'liberate') before attack times; 重犯's free-all and 杰斯顿's KO switch the freed ones too. render/units.js FORMS maps 'warning' / 'liberty' for the six skeletons (普通 / 老练囚犯 *2 / unnumbered, 强壮囚犯 *2 / *3, 拳师囚犯 / 重犯 / 传奇重犯 *_orange / *_red). tools/assets/spine.mjs PREFAB_SPINE_ROLES gives 普通囚犯 / 老练囚犯 their grey *3 set; data/assets.json regenerated (fetch-assets --offline --add-only, JSON compare: only those two anims and the hash), data/enemies.json too (build-data --offline: only their attackAnim.clip Attack -> Attack3, same length and strike frame). The 3D board draws the same Pixi views. - Docs: ASSETS.md (PREFAB_SPINE_ROLES, the prisoner forms), SIM.md (the form fx), design/client.md (FORMS). - Tests: test/content/feedback5-prisoners.test.js (the six: warning before attack 3, attack 4 hits with ATK +50 %, the forms reach the client and UnitInfo; free-all; 麻痹), test/render/feedback5-prisoners.test.js (manifest = the prefab's grey set, FORMS sets, view clips, PREFAB_SPINE_ROLES = the committed manifest) — both fail on the parent but the 麻痹 pin; test/render/feedback5-prisoners.browser.test.js (RENDER_E2E=1: three prisoners on the 2D and the 3D board, each model's set follows its sim form). - Golden: 28 scenarios. Only the warning's new 'phase' fx (the prisoner fell before its 4th attack): roster-001, roster-003, roster-007, roster-024, roster-033, bond-miraShip-low. The 4th attack now hits freed, one attack earlier than before, so the fight after it moves: roster-004, roster-022, roster-026, bond-kazimierzShip-low, bond-raidShip-low, bond-soloShip-low, bond-victoriaShip-low, bond-visiShip-low, standin-01, diy-024, diy-061, diy-065, diy-066, diy-094, diy-097, diy-104, diy-105, diy-127; only the per-second snapshots besides the fx: roster-021, diy-017; and the bot matches whose battles field prisoners: solo-ABYSS-2, solo-HARD-9-boosted.
118 lines
6.2 KiB
JavaScript
118 lines
6.2 KiB
JavaScript
// test/render/feedback5-prisoners.test.js — community report of 2026-10-06 「囚徒类敌人出门就是已解放的模型」, client side.
|
|
// The 孤岛风云 prisoners' skeletons carry three clip sets whose collar light is grey, blinking orange and red; their
|
|
// official battle prefabs (local client battle/enm_pfb_6.ab: the Graphic component's anim key → clip table and the
|
|
// modes' replace pairs) draw 【禁锢】 on the grey set (the Spine starts on it), mode R — the warning before the last
|
|
// confined attack — on the orange set and 【解放】 (mode L) on the red one. The manifest's default roles used to pick
|
|
// 普通囚犯 / 老练囚犯's unnumbered set, the red one (tools/assets/spine.mjs PREFAB_SPINE_ROLES fixes it), and no FORMS entry
|
|
// existed, so no prisoner ever changed. The 3D board draws the same Pixi views over its canvas (render/board3d/scene.js),
|
|
// so one fix covers both boards. Headless fake PIXI (test/render/fakepixi.js); sim side:
|
|
// test/content/feedback5-prisoners.test.js.
|
|
// Run: node --test test/render/feedback5-prisoners.test.js
|
|
|
|
import { test, before, after } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
|
|
import { presetCamera } from '../../public/js/render/projection.js';
|
|
import { ANIM } from '../../shared/constants.js';
|
|
import { fxSpec } from '../../public/js/render/fx/kinds.js';
|
|
import { resolveRoles } from '../../tools/assets/anim-roles.mjs';
|
|
import { PREFAB_SPINE_ROLES, applyRoleFix } from '../../tools/assets/spine.mjs';
|
|
|
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
|
const assets = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8'));
|
|
|
|
/**
|
|
* The official prefabs' idle clip per mode (Default = confined / R = warning / L = liberty) and the clip-name suffix of
|
|
* that mode's Move / Attack / Die (read from battle/enm_pfb_6.ab; the light colours from the skeletons' slot colour
|
|
* timelines: 普通囚犯 Idle red / Idle2 orange / Idle3 grey, 强壮囚犯 Idle grey / Idle2 orange / Idle3 red).
|
|
*/
|
|
const OFFICIAL = {
|
|
enemy_1116_liprr: ['3', '2', ''],
|
|
enemy_1116_liprr_2: ['3', '2', ''],
|
|
enemy_1119_vofsd: ['', '2', '3'],
|
|
enemy_1118_lidbox_2: ['_grey', '_orange', '_red'],
|
|
enemy_1121_lifbos: ['_grey', '_orange', '_red'],
|
|
enemy_1121_lifbos_2: ['_grey', '_orange', '_red'],
|
|
};
|
|
|
|
let fake, UnitView, FORMS;
|
|
before(async () => {
|
|
fake = installFakePixi();
|
|
({ UnitView, FORMS } = await import('../../public/js/render/units.js'));
|
|
});
|
|
after(() => fake.restore());
|
|
|
|
const tick = () => new Promise((r) => setImmediate(r));
|
|
const cam = () => presetCamera('normal', { width: 1280, height: 720 });
|
|
function store(id) {
|
|
const entry = assets.enemies[id].spine;
|
|
const names = Object.keys(entry.animations || {});
|
|
return {
|
|
picture: () => null,
|
|
image: async () => null,
|
|
spineEntry: () => entry,
|
|
spine: { acquire: async () => ({ animations: names.map((name) => ({ name })) }), release() {} },
|
|
};
|
|
}
|
|
async function view(id, info = {}) {
|
|
const ctx = fakeViewCtx(fake.P, { assets: store(id), cam });
|
|
const v = new UnitView(ctx, { id: 7, side: 'enemy', kind: 'enemy', defId: id, spine: id, tier: 1, x: 8, y: 9, maxHp: 1000, facing: -1, ...info });
|
|
await tick(); await tick();
|
|
assert.ok(v.actor, `${id}: Spine model built`);
|
|
return v;
|
|
}
|
|
const sample = (anim = 0) => ({ x: 8, y: 9, hp: 1000, maxHp: 1000, sp: 0, spMax: 0, flags: 0, anim, vx: 0 });
|
|
|
|
test('the manifest draws every prisoner confined first (the official prefab\'s grey set), and FORMS holds its warning and liberty sets', () => {
|
|
for (const [id, [conf, warn, free]] of Object.entries(OFFICIAL)) {
|
|
const sp = assets.enemies[id].spine;
|
|
assert.equal(sp.anims.idle, `Idle${conf}`, `${id} idle`);
|
|
assert.equal(sp.anims.move.loop, `Move${conf}`, `${id} move`);
|
|
assert.equal(sp.anims.attack.loop, `Attack${conf}`, `${id} attack`);
|
|
assert.equal(sp.anims.die, `Die${conf}`, `${id} die`);
|
|
for (const [form, suf] of [['warning', warn], ['liberty', free]]) {
|
|
const r = FORMS[id]?.[form]?.roles;
|
|
assert.ok(r, `${id}: FORMS.${form}`);
|
|
assert.deepEqual([r.idle, r.move.loop, r.attack.loop, r.die], [`Idle${suf}`, `Move${suf}`, `Attack${suf}`, `Die${suf}`], `${id} ${form}`);
|
|
for (const n of [r.idle, r.move.loop, r.attack.loop, r.die]) assert.ok(n in sp.animations, `${id}: ${n} in the skeleton`);
|
|
assert.equal(FORMS[id][form].change, null, 'the prefab switches the set at once');
|
|
}
|
|
}
|
|
});
|
|
|
|
test('a prisoner view: confined clips, the orange set on \'warning\', the red set on \'liberty\'; a view built freed starts red', async () => {
|
|
for (const [id, [conf, warn, free]] of Object.entries(OFFICIAL)) {
|
|
const v = await view(id);
|
|
v.sync(sample(), 1);
|
|
assert.equal(v.actor.current, `Idle${conf}`, `${id} out of the gate`);
|
|
v.sync(sample(ANIM.MOVE), 2);
|
|
assert.equal(v.actor.current, `Move${conf}`, `${id} walking confined`);
|
|
v.setForm('warning');
|
|
assert.equal(v.actor.current, `Move${warn}`, `${id} warning`);
|
|
v.setForm('liberty');
|
|
assert.equal(v.actor.current, `Move${free}`, `${id} freed`);
|
|
v.sync(sample(), 3);
|
|
assert.equal(v.actor.current, `Idle${free}`, `${id} freed idle`);
|
|
const late = await view(id, { form: 'liberty' });
|
|
late.sync(sample(), 1);
|
|
assert.equal(late.actor.current, `Idle${free}`, `${id}: a view built after the liberation (UnitInfo form)`);
|
|
}
|
|
});
|
|
|
|
test('the warning fx draws nothing of its own (the blinking collar is the model\'s); the liberation keeps its white blast', () => {
|
|
assert.equal(fxSpec('phase', { kind: 'warning', form: 'warning' }).a, 'none');
|
|
assert.equal(fxSpec('liberate', { form: 'liberty' }).a, 'blast');
|
|
});
|
|
|
|
test('tools/assets/spine.mjs PREFAB_SPINE_ROLES: the committed manifest is what fetch-assets resolves for 普通囚犯 / 老练囚犯', () => {
|
|
for (const id of ['enemy_1116_liprr', 'enemy_1116_liprr_2']) {
|
|
const sp = assets.enemies[id].spine;
|
|
const out = applyRoleFix(resolveRoles(Object.keys(sp.animations), { skillIndices: [0], durations: sp.animations }), PREFAB_SPINE_ROLES[id], sp.animations);
|
|
assert.deepEqual(out.missing, []);
|
|
assert.deepEqual(out.roles, sp.anims, id);
|
|
}
|
|
});
|