feedback6 R21F: enemy animation states, the client part of PR #275 by @xcdoge re-implemented — the resolver gives an enemy every numbered skill clip (anims.skills; the caller's first index stays the primary, operators unchanged, 宴's PREFAB_SPINE_ROLES fix kept), the stun spellings Stun_1 and Dizzy_Begin/Loop/End, a separate run role (Run_Begin/Loop/End); the renderer walks an enemy faster than moveSpeed 1 on its Run cycle (猎狗pro, 深池侦察犬 ×2; [ASSUMED] threshold), swaps an enemy's skill clip on a cast event (UnitView.setSkillSlot; a form in force is kept), and plays the 重生 of 巨大的丑东西 (Revive, then the fleeing 大祭司 Idle_2 / Move_2 / Stun_2) and 自在 (Revive_01); data/assets.json regenerated offline: 18 enemies gain anims.skills, 6 anims.run, 3 a stun clip (巨大的丑东西, 斩胄之剑, 破胄之锤), nothing else; the sim's cast slot (a new event) is not part of it; golden: unchanged

This commit is contained in:
sganggs committed 2026-10-07 17:59:11 +08:00
1 parent 14df10a5a3
commit 76a2db7dd0
11 files changed
+315 -19

No files matched your search

+86
View File
@@ -77,6 +77,92 @@ describe('animation-role resolver (research 07 §5.4)', () => {
assert.deepEqual(r.skill, { begin: 'Skill2_Begin', loop: 'Skill2_Loop', end: 'Skill2_End', index: 1, idle: null });
});
// PR #275 (@xcdoge): an enemy's manifest only ever had skill index 0 (plan.mjs passes [0]), so a multi-skill boss could
// show one cast clip — 盐风主教昆图斯 has Skill_01..04. With `numberedSkills` (spine.mjs: enemy models) every numbered
// skill clip is resolved after the caller's indices, whose first stays the primary.
test('numbered skill clips of an enemy are all resolved (a multi-skill boss), the primary stays the caller’s first', () => {
const names = ['Attack', 'Default', 'Die', 'Idle', 'Skill_01', 'Skill_02', 'Skill_03', 'Skill_04'];
assert.equal(resolveRoles(names, { skillIndices: [0] }).skills, undefined, 'without the option: index 0 only (operators)');
const r = resolveRoles(names, { skillIndices: [0], numberedSkills: true });
assert.deepEqual(Object.keys(r.skills).sort(), ['0', '1', '2', '3']);
assert.equal(r.skills['1'].loop, 'Skill_02');
assert.equal(r.skills['3'].loop, 'Skill_04');
assert.equal(r.skill.loop, 'Skill_01', 'the first index stays the primary clip');
const first = resolveRoles(names, { skillIndices: [2], numberedSkills: true });
assert.equal(first.skill.index, 2, 'a caller-declared primary still wins');
assert.equal(first.skill.loop, 'Skill_03');
// 自在: Skill_01 / Skill_01_02 are one slot, Skill_02_Begin / Loop / End the next
const xi = resolveRoles(['Attack_01', 'Die', 'Idle', 'Skill_01', 'Skill_01_02', 'Skill_02_Begin', 'Skill_02_End', 'Skill_02_Loop'], { numberedSkills: true });
assert.deepEqual(xi.skills['1'], { begin: 'Skill_02_Begin', loop: 'Skill_02_Loop', end: 'Skill_02_End', index: 1, idle: null });
// phase-style skill clips (no index in the name) stay a single primary skill
assert.equal(resolveRoles(['Attack', 'Die', 'Idle', 'Skill_Begin', 'Skill_Loop', 'Skill_End'], { numberedSkills: true }).skills, undefined);
});
// PR #275: the stun family is spelled Stun / Stun_End, Stun_1 / Stun_2 (巨大的丑东西: first form, second form) and
// Dizzy_Begin / Dizzy_Loop / Dizzy_End (斩胄之剑 / 破胄之锤); without a role a stunned enemy freezes its clip.
test('stun clips: numbered Stun_N and the Dizzy_* family are recognised', () => {
const mc = resolveRoles(['Attack_1', 'Die', 'Idle_1', 'Idle_2', 'Move_1', 'Move_2', 'Stun_1', 'Stun_2'], {});
assert.deepEqual(mc.stun, { begin: null, loop: 'Stun_1', end: null });
const dz = resolveRoles(['Attack', 'Die', 'Idle_B', 'Move', 'Dizzy_Begin', 'Dizzy_Loop', 'Dizzy_End', 'Dizzy_Die'], {});
assert.deepEqual(dz.stun, { begin: 'Dizzy_Begin', loop: 'Dizzy_Loop', end: 'Dizzy_End' });
assert.deepEqual(resolveRoles(['Attack', 'Die', 'Idle', 'Stun', 'Stun_End'], {}).stun, { begin: null, loop: 'Stun', end: 'Stun_End' });
assert.equal(resolveRoles(['Attack', 'Die', 'Idle', 'Move'], {}).stun, null);
});
// PR #275: a model's own Run cycle is a role of its own (猎狗pro: Move_Loop 0.80 s, Run_Loop 0.53 s); the move choice
// is unchanged, the renderer switches to it for a fast enemy.
test('the model’s own Run cycle becomes a separate run role', () => {
const names = ['Attack', 'Default', 'Die', 'Idle', 'Move_Begin', 'Move_End', 'Move_Loop', 'Run_Begin', 'Run_End', 'Run_Loop'];
const r = resolveRoles(names, {});
assert.deepEqual(r.move, { begin: 'Move_Begin', loop: 'Move_Loop', end: 'Move_End' }, 'the move role is unchanged');
assert.deepEqual(r.run, { begin: 'Run_Begin', loop: 'Run_Loop', end: 'Run_End' });
assert.ok(roleAnimationNames(r).includes('Run_Loop'), 'the manifest writer validates the Run clips');
assert.equal(resolveRoles(['Attack', 'Die', 'Idle', 'Move'], {}).run, undefined, 'no Run cycle, no role');
});
// The manifest (tools/fetch-assets.mjs --offline with this resolver): every model whose skeleton has a stun / Run clip
// has the role, and every enemy with numbered skill clips has one clip per slot.
describe('data/assets.json roles (PR #275)', () => {
const manifest = JSON.parse(readFileSync(new URL('../data/assets.json', import.meta.url), 'utf8'));
const models = [];
for (const [id, e] of Object.entries(manifest.enemies || {})) if (e.spine) models.push([`enemies.${id}`, e.spine]);
for (const [id, e] of Object.entries(manifest.tokens || {})) if (e.spine) models.push([`tokens.${id}`, e.spine]);
for (const [id, e] of Object.entries(manifest.chars || {})) if (e.spine?.front) models.push([`chars.${id}`, e.spine.front]);
const walk = (re, ok) => {
const bad = [];
let seen = 0;
for (const [id, sp] of models) {
if (!sp.animations || !Object.keys(sp.animations).some((n) => re.test(n))) continue;
seen++;
if (!ok(sp.anims || {}, id)) bad.push(id);
}
return { bad, seen };
};
test('every model with a stun clip has a stun role', () => {
const { bad, seen } = walk(/^(stun|dizzy)/i, (a) => !!a.stun);
assert.ok(seen >= 6, `models with a stun clip (${seen})`);
assert.deepEqual(bad, []);
});
test('every model with a Run clip has a run role', () => {
const { bad, seen } = walk(/^run/i, (a) => !!a.run);
assert.ok(seen >= 3, `models with a Run clip (${seen})`);
assert.deepEqual(bad, []);
});
test('every enemy with numbered skill clips has a clip per slot (盐风主教昆图斯: Skill_01..04)', () => {
const { bad, seen } = walk(/^Skill_?0*[1-9](?:$|_)/i, (a, id) => !id.startsWith('enemies.') || !!a.skills);
assert.ok(seen >= 10, `models with numbered skill clips (${seen})`);
assert.deepEqual(bad, []);
const q = manifest.enemies.enemy_1521_dslily.spine.anims;
assert.deepEqual(Object.values(q.skills).map((c) => c.loop), ['Skill_01', 'Skill_02', 'Skill_03', 'Skill_04']);
assert.equal(q.skill.loop, 'Skill_01', 'the primary clip is unchanged');
// operators keep their pool's indices (宴's fix, PREFAB_SPINE_ROLES / DESIGN §25.22.9, stays as it was)
const utage = manifest.chars.char_337_utage.spine.front.anims;
assert.deepEqual(Object.keys(utage.skills).sort(), ['0', '1']);
assert.equal(utage.skills['0'].loop, 'Skill_Loop');
assert.equal(utage.skill.loop, 'Attack');
});
});
test('pure supporter without Attack (char_4134_cetsyr)', () => {
const r = resolveRoles(['Die', 'Idle', 'Skill_1_Begin', 'Skill_1_End', 'Skill_1_Loop', 'Skill_2_Begin', 'Skill_2_Loop', 'Start', 'Stun', 'Stun_Begin'], { skillIndices: [1] });
assert.deepEqual(r.attack, { begin: null, loop: 'Skill_1_Loop', end: null, via: 'skill' });