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

+46
View File
@@ -378,3 +378,49 @@ test('render/app.js hands the `form` of a sim fx (shared/protocol.js fxForm) wit
assert.match(src, /if \(!\(late > 0\)\) fx\.simFx\(/);
assert.match(src, /interp\.takeEvents\(renderT, EVS, renderT - 1\.5, LATE\);/);
});
// PR #275 (@xcdoge): 巨大的丑东西 (kitUglyThing: KO ⇒ 10 s 重生 ⇒ the fleeing 大祭司) and 自在 (kitXi: KO ⇒ 5 s 重生 ⇒ the
// same model, stronger) had no clip set, so their 重生 played no clip. The Revive clip is the 重生's change clip; the sim
// reports the 重生 as a stun (archetypes.js reborn), so 巨大的丑东西's 重生 holds the 大祭司's idle after its 8.67 s clip.
test('“巨大的丑东西” / “自在”: the 重生 plays the skeleton’s Revive clip, then the second form', () => {
const mc = FORMS.enemy_1512_mcmstr;
assert.equal(mc.reborn.change, 'Revive', 'the 8.67 s Revive clip opens the 10 s 重生');
assert.equal(mc.reborn.next, 'form2');
assert.equal(mc.reborn.roles.stun.loop, 'Idle_2', 'the rest of the 重生 (a sim stun) holds the 大祭司 idle, not the first form’s Stun_1');
assert.equal(mc.form2.roles.idle, 'Idle_2');
assert.equal(mc.form2.roles.move.loop, 'Move_2');
assert.equal(mc.form2.roles.stun.loop, 'Stun_2', 'the 大祭司 stunned: its own clip');
assert.equal(mc.form2.roles.attack, null, 'the 大祭司 never attacks');
const xi = FORMS.enemy_1517_xi;
assert.equal(xi.reborn.change, 'Revive_01', 'the 5.33 s Revive_01 is the 5 s 重生');
assert.deepEqual(xi.form2.roles, {}, 'the same model, only stronger (reborn.atk)');
});
// Every clip a form set names must exist in that model's manifest entry: a typo is a silently missing animation.
test('every clip FORMS names exists in its model’s manifest', () => {
const clipsOf = (v, out = new Set()) => {
if (typeof v === 'string') { if (v) out.add(v); return out; }
if (Array.isArray(v)) { for (const x of v) clipsOf(x, out); return out; }
if (v && typeof v === 'object') for (const [k, x] of Object.entries(v)) if (k !== 'via' && k !== 'next') clipsOf(x, out);
return out;
};
const entries = (id) => {
const out = [];
const e = assets.enemies?.[id]?.spine; if (e) out.push(e.front || e);
const t = assets.tokens?.[id]?.spine; if (t) out.push(t);
const c = assets.chars?.[id]?.spine; if (c) out.push(...[c.front, c.back].filter(Boolean));
return out;
};
const bad = [];
let checked = 0;
for (const [id, forms] of Object.entries(FORMS)) {
const es = entries(id);
if (!es.length) continue;
checked++;
for (const [form, spec] of Object.entries(forms)) {
for (const clip of clipsOf(spec)) if (!es.some((e) => e.animations && Object.hasOwn(e.animations, clip))) bad.push(`${id}.${form}: ${clip}`);
}
}
assert.ok(checked > 10, `the table was walked (${checked} models)`);
assert.deepEqual(bad, [], 'clips named by a form set but missing from the manifest');
});
+24
View File
@@ -77,6 +77,30 @@ describe('Spine actor + audio use the equipped skill', () => {
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 });
+44
View File
@@ -391,3 +391,47 @@ describe('an enemy flyer crossing a raised tile keeps its height (GitHub #277)',
assert.ok(Math.abs(op.z - RAISED.h) < 1e-6, `the operator stands on the high ground (${op.z})`);
});
});
// PR #275 (@xcdoge): 猎狗pro ships Move_Loop 0.80 s next to Run_Loop 0.53 s and its moveSpeed is 1.9, so a fast enemy
// walks on its model's own Run cycle (anims.run) while a standard one keeps Move; the cast slot composes with it.
describe('a fast enemy walks on its Run cycle (PR #275)', () => {
const entry = {
skel: '/s/x.skel', atlas: '/s/x.atlas', textures: ['/s/x.png'],
anims: {
idle: 'Idle', deploy: 'Idle', die: 'Die', attack: null,
move: { begin: 'Move_Begin', loop: 'Move_Loop', end: 'Move_End' },
run: { begin: 'Run_Begin', loop: 'Run_Loop', end: 'Run_End' },
skill: { begin: null, loop: 'Skill_01', end: null, index: 0, idle: null },
skills: { 0: { begin: null, loop: 'Skill_01', end: null, index: 0, idle: null }, 1: { begin: null, loop: 'Skill_02', end: null, index: 1, idle: null } },
},
animations: { Idle: 1, Die: 0.67, Move_Begin: 0.17, Move_Loop: 0.8, Move_End: 0.17, Run_Begin: 0.17, Run_Loop: 0.53, Run_End: 0.17, Skill_01: 1, Skill_02: 1 },
};
const assets = {
picture: () => null, image: async () => null, spineEntry: () => entry,
spine: { acquire: async () => ({ animations: Object.keys(entry.animations).map((name) => ({ name })) }), release() {} },
};
const enemyView = async (id, speed, side = 'enemy') => {
const ctx = fakeViewCtx(fake.P, { assets, cam, lookupDef: () => ({ stats: { moveSpeed: speed } }) });
const v = new UnitView(ctx, { id, side, kind: side === 'enemy' ? 'enemy' : 'op', defId: 'enemy_1000_gopro_2', x: 5, y: 12, maxHp: 100 }, {});
await tick(); await tick();
assert.ok(v.actor, 'spine actor built');
return v;
};
test('moveSpeed 1.9 moves on Run, 1 on Move; the cast slot and the Run cycle compose', async () => {
const hound = await enemyView(1, 1.9);
assert.equal(hound.actor.roles.move.loop, 'Run_Loop');
const slug = await enemyView(2, 1);
assert.equal(slug.actor.roles.move.loop, 'Move_Loop');
// the MOVE anim code plays it
hound.sync({ x: 5, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 1, vx: 0.5 }, 1);
for (let i = 0; i < 20; i++) hound.update(1 / 60, cam(), i / 60);
assert.match(String(hound.actor.current), /^Run/, `playing ${hound.actor.current}`);
hound.setSkillSlot(1);
assert.equal(hound.actor.roles.skill.loop, 'Skill_02', 'the cast slot');
assert.equal(hound.actor.roles.move.loop, 'Run_Loop', 'and the Run cycle survives it');
hound.actor.setRunMode(false);
assert.equal(hound.actor.roles.move.loop, 'Move_Loop');
assert.equal(hound.actor.roles.skill.loop, 'Skill_02', 'the cast slot survives that too');
});
});