118 lines
6.2 KiB
JavaScript
118 lines
6.2 KiB
JavaScript
// test/render/loadout-skill.test.js — the equipped skill (DESIGN §16 operator loadout, user playtest #2 item 1) reaches
|
||
// the battle presentation: the sim's UnitInfo carries `skillIndex`, the Spine actor plays THAT skill's clip
|
||
// (`anims.skills[index]`, e.g. 史尔特尔 on S1 never shows the S3 黄昏 transformation) and the audio plays that skill's
|
||
// own ON_SKILL_START sound (`sfx.units[id].skills[index]`). Integration regressions (the loadout batch had the sim,
|
||
// data and UI, but every battle still animated / sounded the default skill).
|
||
|
||
import { test, describe, before, after } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
|
||
import { presetCamera } from '../../public/js/render/projection.js';
|
||
import { makeBattle } from '../helpers/battleHarness.js';
|
||
import { hasGeneratedData } from '../../server/sim/simdata.js';
|
||
import { AudioManager } from '../../public/js/audio.js';
|
||
|
||
const REAL = { skip: !hasGeneratedData() };
|
||
|
||
describe('UnitInfo.skillIndex (sim)', () => {
|
||
test('spawn events carry the equipped skill index: the loadout one, else the default', REAL, () => {
|
||
const h = makeBattle({
|
||
units: [
|
||
{ chessId: 'chess_char_5_07_a', row: 10, col: 5, uid: 1, skillIndex: 0 }, // 史尔特尔 on S1 (default S3)
|
||
{ chessId: 'chess_char_1_01_a', row: 11, col: 5, uid: 2 }, // 隐现 on its default S2
|
||
],
|
||
enemies: [], autoFinish: false, timeLimit: 10,
|
||
});
|
||
h.step();
|
||
const spawns = h.eventsOf('spawn').map((e) => e[1]).filter((u) => u.side === 'ally');
|
||
const byUid = new Map(spawns.map((u) => [u.uid, u]));
|
||
assert.equal(byUid.get(1)?.skillIndex, 0, '史尔特尔: S1');
|
||
assert.equal(byUid.get(2)?.skillIndex, 1, '隐现: default S2');
|
||
});
|
||
});
|
||
|
||
describe('Spine actor + audio use the equipped skill', () => {
|
||
let fake, UnitView;
|
||
before(async () => {
|
||
fake = installFakePixi();
|
||
({ UnitView } = 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 });
|
||
const S1 = { begin: null, loop: 'Skill_1', end: null, index: 0, idle: null };
|
||
const S3 = { begin: 'Skill_3_Begin', loop: 'Skill_3_Loop', end: null, index: 2, idle: 'Skill_3_Idle' };
|
||
const entry = {
|
||
skel: '/s/x.skel', atlas: '/s/x.atlas', textures: ['/s/x.png'],
|
||
anims: { idle: 'Idle', attack: { begin: null, loop: 'Attack', end: null }, skill: S3, skills: { 0: S1, 2: S3 } },
|
||
animations: { Idle: 1, Attack: 1, Skill_1: 1, Skill_3_Begin: 0.6, Skill_3_Loop: 1, Skill_3_Idle: 1 },
|
||
};
|
||
const names = Object.keys(entry.animations).map((name) => ({ name }));
|
||
const assets = {
|
||
picture: () => null, image: async () => null, spineEntry: () => entry,
|
||
spine: { acquire: async () => ({ animations: names }), release() {} },
|
||
};
|
||
async function actorFor(skillIndex) {
|
||
const ctx = fakeViewCtx(fake.P, { assets, cam });
|
||
const v = new UnitView(ctx, { id: 1, side: 'ally', kind: 'op', defId: 'chess_char_5_07_a', spine: 'char_350_surtr', tier: 5, x: 5, y: 10, maxHp: 1000, skillIndex });
|
||
await tick(); await tick();
|
||
assert.ok(v.actor, 'spine actor built');
|
||
return v.actor;
|
||
}
|
||
|
||
test('S1 equipped: the skill clip is S1 (not the default S3 transformation)', async () => {
|
||
const a = await actorFor(0);
|
||
assert.equal(a.roles.skill.loop, 'Skill_1');
|
||
a.setSkill(true);
|
||
assert.equal(a._attackClip().loop, 'Skill_1', 'attacks during the skill use the S1 clip');
|
||
assert.notEqual(a.current, 'Skill_3_Begin', 'no S3 transformation');
|
||
const d = await actorFor(undefined);
|
||
d.setSkill(true);
|
||
assert.equal(d.current, 'Skill_3_Begin', 'control: the default S3 starts with its begin clip');
|
||
});
|
||
|
||
test('default / unknown index: the primary skill clip', async () => {
|
||
assert.equal((await actorFor(undefined)).roles.skill.loop, 'Skill_3_Loop');
|
||
assert.equal((await actorFor(2)).roles.skill.loop, 'Skill_3_Loop');
|
||
assert.equal((await actorFor(1)).roles.skill.loop, 'Skill_3_Loop', 'no clip for S2 in the model: primary');
|
||
});
|
||
|
||
// PR #275 (@xcdoge): the slot an enemy ability casts (render/app.js `case 'cast'` → UnitView.setSkillSlot) swaps its
|
||
// skill clip mid-battle, so a multi-skill boss does not show one cast animation for every ability.
|
||
test('an enemy cast slot swaps its skill clip mid-battle (a form in force is kept)', async () => {
|
||
const ctx = fakeViewCtx(fake.P, { assets, cam });
|
||
const v = new UnitView(ctx, { id: 9, side: 'enemy', kind: 'enemy', defId: 'enemy_1521_dslily', x: 5, y: 10, maxHp: 100 }, {});
|
||
await tick(); await tick();
|
||
assert.ok(v.actor, 'spine actor built');
|
||
assert.equal(v.actor.roles.skill.loop, 'Skill_3_Loop', 'the manifest primary clip to start with');
|
||
assert.equal(v.skillIndex, null, 'no slot reported yet');
|
||
v.setSkillSlot(0);
|
||
assert.equal(v.skillIndex, 0);
|
||
assert.equal(v.actor.roles.skill.loop, 'Skill_1', 'that slot’s own clip');
|
||
v.setSkillSlot(-1);
|
||
v.setSkillSlot(1.5);
|
||
assert.equal(v.skillIndex, 0, 'a non-slot is ignored');
|
||
v.actor.setForm({ idle: 'Attack' });
|
||
v.setSkillSlot(2);
|
||
assert.equal(v.actor.roles.skill.loop, 'Skill_3_Loop', 'the next slot');
|
||
assert.equal(v.actor.roles.idle, 'Attack', 'the form in force survives the slot change');
|
||
v.actor.setForm(null);
|
||
assert.equal(v.actor.roles.idle, 'Idle');
|
||
assert.equal(v.actor.roles.skill.loop, 'Skill_3_Loop', 'and the slot survives the form ending');
|
||
});
|
||
|
||
test('audio: the equipped skill’s own ON_SKILL_START sound', () => {
|
||
const manifest = { audio: { sfx: { units: { char_350_surtr: { skill: '/sfx/s3.mp3', skills: { 0: '/sfx/s1.mp3', 2: '/sfx/s3.mp3' } } } } } };
|
||
const a = new AudioManager({ win: null, getManifest: () => manifest });
|
||
a.ctx = {};
|
||
const played = [];
|
||
a._play = (url) => { played.push(url); };
|
||
a.setFieldUnits([
|
||
{ id: 1, side: 'ally', kind: 'op', defId: 'chess_char_5_07_a', spine: 'char_350_surtr', skillIndex: 0 },
|
||
{ id: 2, side: 'ally', kind: 'op', defId: 'chess_char_5_07_a', spine: 'char_350_surtr' },
|
||
]);
|
||
a.handleBattleEvents([['skill', 1, 1], ['skill', 2, 1]]);
|
||
assert.deepEqual(played, ['/sfx/s1.mp3', '/sfx/s3.mp3']);
|
||
});
|
||
});
|