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

+1 -1
View File
@@ -1 +1 @@
{"version":1,"hash":"b699458e3e10","generator":"tools/fetch-assets.mjs","stats":{"files":7969,"bytes":485061859,"chars":209,"charsWithBack":206,"enemies":252,"enemiesWithSpine":251,"tokens":58,"tokensWithSpine":13,"spineModels":671,"bonds":23,"items":59,"bands":40,"skills":522,"modules":131,"ui":602,"sfxUnits":483,"voiceChars":191},"chars":{"char_003_kalts":{"avatar":"/assets/char/avatar/char_003_kalts.png","avatarE2":"/assets/char/avatar/char_003_kalts_2.png","portrait":"/assets/char/portrait/char_003_kalts_1.png","portraitE2":"/assets/char/portrait/char_003_kalts_2.png","spine":{"front":{"skel":"/assets/spine/op/char_003_kalts/front/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/front/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/front/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Die":1,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":-227.579,"y":-138.244,"width":532.04,"height":551.479}},"back":{"skel":"/assets/spine/op/char_003_kalts/back/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/back/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/back/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":410.785,"y":-2.26,"width":531.785,"height":416.492}}}},"char_010_chen":{"avatar":"/assets/char/avatar/char_010_chen.png","avatarE2":"/assets/char/avatar/char_010_chen_2.png","portrait":"/assets/char/portrait/char_010_chen_1.png","portraitE2":"/assets/char/portrait/char_010_chen_2.png","spine":{"front":{"skel":"/assets/spine/op/char_010_chen/front/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/front/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/front/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill_2","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Die":1,"Idle":2,"Skill":1,"Skill_2":1.167,"Skill_3":2.933,"Skill_End":0.6,"Skill_End_2":0.6,"Skill_End_3":0.3,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467],"Skill_2":[0.467],"Skill_3":[0.6,0.8,1,1.2,1.4,1.6,1.8,2,2.2,2.833,2.933]},"bounds":{"x":-322.335,"y":-109.274,"width":663.549,"height":524.806}},"back":{"skel":"/assets/spine/op/char_010_chen/back/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/back/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/back/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill","end":"Skill_End","index":1,"idle":null},"2":{"begin":null,"loop":"Skill","end":"Skill_End","index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Idle":2,"Skill":1,"Skill_End":0.6,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467]},"bounds":{"x":-173.082,"y":-13.829,"width":315.887,"height":404.623}}}},"char_017_huang":{"avatar":"/assets/char/avatar/char_017_huang.png","avatarE2":"/assets/char/avatar/char_017_huang_2.png","portrait":"/assets/char/portrait/char_017_huang_1.png","portraitE2":"/assets/char/portrait/char_017_huang_2.png","spine":{"front":{"skel":"/assets/spine/op/char_017_huang/front/char_017_huang.skelLine truncated
{"version":1,"hash":"7ae1d03466cb","generator":"tools/fetch-assets.mjs","stats":{"files":7969,"bytes":485061859,"chars":209,"charsWithBack":206,"enemies":252,"enemiesWithSpine":251,"tokens":58,"tokensWithSpine":13,"spineModels":671,"bonds":23,"items":59,"bands":40,"skills":522,"modules":131,"ui":602,"sfxUnits":483,"voiceChars":191},"chars":{"char_003_kalts":{"avatar":"/assets/char/avatar/char_003_kalts.png","avatarE2":"/assets/char/avatar/char_003_kalts_2.png","portrait":"/assets/char/portrait/char_003_kalts_1.png","portraitE2":"/assets/char/portrait/char_003_kalts_2.png","spine":{"front":{"skel":"/assets/spine/op/char_003_kalts/front/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/front/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/front/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Die":1,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":-227.579,"y":-138.244,"width":532.04,"height":551.479}},"back":{"skel":"/assets/spine/op/char_003_kalts/back/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/back/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/back/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":410.785,"y":-2.26,"width":531.785,"height":416.492}}}},"char_010_chen":{"avatar":"/assets/char/avatar/char_010_chen.png","avatarE2":"/assets/char/avatar/char_010_chen_2.png","portrait":"/assets/char/portrait/char_010_chen_1.png","portraitE2":"/assets/char/portrait/char_010_chen_2.png","spine":{"front":{"skel":"/assets/spine/op/char_010_chen/front/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/front/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/front/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill_2","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Die":1,"Idle":2,"Skill":1,"Skill_2":1.167,"Skill_3":2.933,"Skill_End":0.6,"Skill_End_2":0.6,"Skill_End_3":0.3,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467],"Skill_2":[0.467],"Skill_3":[0.6,0.8,1,1.2,1.4,1.6,1.8,2,2.2,2.833,2.933]},"bounds":{"x":-322.335,"y":-109.274,"width":663.549,"height":524.806}},"back":{"skel":"/assets/spine/op/char_010_chen/back/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/back/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/back/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill","end":"Skill_End","index":1,"idle":null},"2":{"begin":null,"loop":"Skill","end":"Skill_End","index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Idle":2,"Skill":1,"Skill_End":0.6,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467]},"bounds":{"x":-173.082,"y":-13.829,"width":315.887,"height":404.623}}}},"char_017_huang":{"avatar":"/assets/char/avatar/char_017_huang.png","avatarE2":"/assets/char/avatar/char_017_huang_2.png","portrait":"/assets/char/portrait/char_017_huang_1.png","portraitE2":"/assets/char/portrait/char_017_huang_2.png","spine":{"front":{"skel":"/assets/spine/op/char_017_huang/front/char_017_huang.skelLine truncated
+4 -2
View File
@@ -242,11 +242,13 @@ Roles = {
attack: Clip, // play begin once, loop per attack, end when stopping; `via:'idle'` ⇒ add a flash
attackDown: Clip|null, // _Down variants (target below the unit)
skill: SkillClip|null, // for the chess's default skill (primary index)
skills?: { [index]: SkillClip }, // when the char is used with several default skills (backups)
skills?: { [index]: SkillClip }, // when the char is used with several default skills (backups); an enemy: every
// numbered skill clip (Skill_01..04 of 盐风主教昆图斯 — the slot a sim `cast` event names, PR #275)
die: string|null, // null ⇒ a Back model gives way to the Front model's Die (DESIGN §22.1); any other
// skeleton holds its idle's first frame while it fades out
move: Clip|null, // enemies: Move_Begin|Move_Start + Move_Loop|Move + Move_End → Run_*
stun: Clip|null // null ⇒ freeze the track (timeScale 0)
run?: Clip, // the model's own Run cycle (Run_Begin/Loop/End): an enemy faster than moveSpeed 1 moves on it
stun: Clip|null // Stun | Stun_1 | Dizzy_Loop (+ *_Begin / *_End); null ⇒ freeze the track (timeScale 0)
}
```
+2
View File
@@ -1388,6 +1388,8 @@ export async function createFieldView(host, options = {}) {
}
case 'heal': { const v = views.get(e[1]); if (v) fx.heal(v, Number(e[2]) || 0); break; }
case 'skill': { const v = views.get(e[1]); if (v) { v.setSkill?.(!!e[2]); fx.skill(v, !!e[2]); } break; }
// the skill slot an enemy ability casts (PR #275): the view swaps to that slot's clip (a multi-skill boss's Skill_01..04)
case 'cast': { const v = views.get(e[1]); if (v) v.setSkillSlot?.(e[2] | 0); break; }
case 'die': {
const v = views.get(e[1]);
const used = consumedIds.delete(e[1]);
+29 -5
View File
@@ -107,6 +107,9 @@ export class SpineActor {
this.endClip = null; // setForm's closing clip, played as a change clip once the clock reaches endAt,
this.endAt = 0; // landing in endRoles (the next form's) when given
this.endRoles = null;
this.skillIndex = null; // the skill slot whose clip `skill` plays (setSkillIndex), null = the manifest's primary
this.runMode = false; // move on the model's own Run cycle (setRunMode)
this.formRoles = null; // the clip set of the form in force (setForm), over the unit's own roles
this.skillOn = false;
this.skillClipOnce = false; // skillBegin: the skill's own clip plays once (a clip with no Begin / own Idle)
this.skillEndPending = false; // … and the skill turned off inside it: play its End clip / base when it ends
@@ -145,12 +148,32 @@ export class SpineActor {
* @param {number|undefined} index 0-based skill index
*/
setSkillIndex(index) {
const anims = this.entry?.anims || {};
const clip = Number.isInteger(index) && anims.skills ? anims.skills[String(index)] : null;
this.roles = this.baseRoles = clip ? { ...anims, skill: clip } : anims;
this.skillIndex = Number.isInteger(index) ? index : null;
this._applyRoles();
}
/** The unit's own roles: the manifest's with its equipped skill's clip (setSkillIndex) — what a form ends in. */
/**
* A fast mover walks on its model's own Run cycle (`anims.run`; PR #275 by @xcdoge): 猎狗pro (moveSpeed 1.9, 行动速度很快)
* ships Move_Loop 0.80 s next to Run_Loop 0.53 s. Composes with setSkillIndex; a model without a Run cycle is unchanged.
*/
setRunMode(on) {
this.runMode = !!on;
this._applyRoles();
}
/** The unit's own roles (the manifest's, its skill slot's clip and the Run cycle applied) under the form in force. */
_applyRoles() {
const anims = this.entry?.anims || {};
const clip = Number.isInteger(this.skillIndex) && anims.skills ? anims.skills[String(this.skillIndex)] : null;
const run = this.runMode && anims.run ? anims.run : null;
const base = clip || run ? { ...anims } : anims;
if (clip) base.skill = clip;
if (run) base.move = run;
this.baseRoles = base;
this.roles = this.formRoles ? { ...base, ...this.formRoles } : base;
}
/** The unit's own roles: the manifest's with its skill slot's clip (setSkillIndex) and Run cycle — what a form ends in. */
_baseRoles() { return this.baseRoles || this.entry?.anims || {}; }
/**
@@ -163,6 +186,7 @@ export class SpineActor {
*/
setForm(roles, change = null, end = null) {
const anims = this._baseRoles();
this.formRoles = roles || null; // kept over a later skill slot (_applyRoles)
this.roles = roles ? { ...anims, ...roles } : anims;
this.endClip = null;
if (this.dead) return;
@@ -464,7 +488,7 @@ export class SpineActor {
if (this.endClip && this.clock >= this.endAt) {
const clip = this.endClip;
this.endClip = null;
if (this.endRoles) this.roles = { ...this._baseRoles(), ...this.endRoles };
if (this.endRoles) { this.formRoles = this.endRoles; this.roles = { ...this._baseRoles(), ...this.endRoles }; }
if (!this.dead) this._change(clip);
}
if (this.windUntil != null && this.clock >= this.windUntil) {
+31 -1
View File
@@ -289,6 +289,18 @@ export const FORMS = Object.freeze({
enemy_1525_blkswb: rebirth('Revive1', 'Revive2', 'Revive3', clipSet('B_Idle', 'B_Move', 'B_Die', 'B_Attack')),
enemy_1535_wlfmster: rebirth('A_revive_1', 'A_revive_2', 'A_revive_3', clipSet('B_Idle', 'B_Move', 'B_Die', 'B_Attack')),
enemy_1539_reid: rebirth('Revive_Begin', 'Revive_Loop', 'Revive_End'),
// 重生 on the skeleton's own Revive clip (PR #275 by @xcdoge; content/enemies/leaders.js kitUglyThing / kitXi): 巨大的丑东西
// — Revive 8.67 s of the 10 s 重生 (its self-destruct 2.17 s in), then the fleeing 大祭司 (Idle_2 / Move_2 / Stun_2;
// 不进行攻击) holding its idle while the sim still holds the 重生 (reported as a stun); 自在 — Revive_01 5.33 s for the
// 5 s 重生, then the same model, stronger (reborn.atk)
enemy_1512_mcmstr: Object.freeze({
reborn: Object.freeze({ change: 'Revive', next: 'form2', roles: Object.freeze({ idle: 'Idle_2', deploy: 'Idle_2', move: loop('Idle_2'), stun: loop('Idle_2'), attack: null, skill: null }) }),
form2: Object.freeze({ change: null, roles: Object.freeze({ ...clipSet('Idle_2', 'Move_2', 'Die'), stun: loop('Stun_2') }) }),
}),
enemy_1517_xi: Object.freeze({
reborn: Object.freeze({ change: 'Revive_01', next: 'form2', roles: Object.freeze({}) }),
form2: Object.freeze({ change: null, roles: Object.freeze({}) }),
}),
enemy_1516_jakill: Object.freeze({
reborn: Object.freeze({ change: 'C1_Die', roles: JAKILL2 }),
form2: Object.freeze({ change: null, roles: JAKILL2 }),
@@ -355,6 +367,9 @@ export class UnitView {
this.info = { ...info };
this.id = info.id;
this.uid = info.uid ?? null;
// the skill slot whose Spine clip the unit shows (DESIGN §16): an ally's equipped skill; an enemy's cast slot when the
// sim reports one (`cast` event, setSkillSlot — a multi-skill boss's Skill_01..04, PR #275)
this.skillIndex = Number.isInteger(info.skillIndex) ? info.skillIndex : null;
this.prep = !!opts.prep;
this.lodIdle = opts.lod === 'idle';
this.culled = false; // outside the viewport this frame (not animated, not drawn)
@@ -365,6 +380,9 @@ export class UnitView {
// enemies: the official prefab's size factor (1 for operators, summons and enemies at the standard size)
const def = this.isEnemy && ctx.lookupDef ? ctx.lookupDef(info) : null;
this.modelK = this.isEnemy ? enemyModelScale(def) : 1;
// a fast enemy walks on its model's own Run cycle when it has one (PR #275 by @xcdoge: 猎狗pro, moveSpeed 1.9, Run_Loop
// 0.53 s next to Move_Loop 0.80 s; also 深池侦察犬 1.7). [ASSUMED] the threshold: faster than the standard 1
this.moveFast = this.isEnemy && Number(def?.stats?.moveSpeed) > 1;
// the official's own model quirks (enemies.json, read from its battle prefabs — tools/local-extract/enemy_model_offsets.py,
// PR #211): a vertical stretch (its Graphic scale's sy / sx, the two 帝国炮火先兆者 at 1.263) and a mirrored X scale
// (the Graphic's sx is negative, so the official draws the authored model flipped: 木制瑞印)
@@ -546,7 +564,8 @@ export class UnitView {
let actor = null;
try {
actor = new SpineActor(data, entry);
actor.setSkillIndex(this.info.skillIndex);
actor.setSkillIndex(this.skillIndex ?? undefined);
actor.setRunMode(this.moveFast);
// enemies play their attack clip once per attack, then walk on (GitHub #58: the sim stands them for that clip)
actor.clipPerAttack = this.isEnemy;
} catch (err) {
@@ -849,6 +868,17 @@ export class UnitView {
if (this.actor) this.actor.setSkill(on);
}
/**
* The skill slot an enemy casts (the sim's `cast` event; PR #275 by @xcdoge): its clip set swaps to that slot's skill
* clip (`anims.skills`, a multi-skill boss's Skill_01..04), which the SKILL flag right after plays. The same slot again
* or a non-slot changes nothing.
*/
setSkillSlot(index) {
if (!Number.isInteger(index) || index < 0 || index === this.skillIndex) return;
this.skillIndex = index;
if (this.actor) this.actor.setSkillIndex(index);
}
onDeploy() {
this.fadeIn = 0;
if (!this.alive) this.revive();
+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' });
+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');
});
});
+42 -7
View File
@@ -28,17 +28,21 @@
// loop = P_Loop → P_Attack… → P → P_Idle → P_Begin; begin = P_Begin|P_Start; end = P_End;
// idle = P_Idle (replaces idle while the skill is active);
// else the same over directional-only clips (Skill_Right_Loop, Skill_Loop_Up; Right before Up);
// else attack (via attack)
// else attack (via attack); with `numberedSkills` (enemies) every numbered skill clip of the skeleton
// (Skill_01, Skill_2_Loop …) adds its index after the caller's, giving `skills` (a multi-skill boss)
// die : Die → <form>Die → first /^Die/ → first /_Die$/ → null (renderer: Front model's Die, else fade)
// move : Move_Begin|Move_Start + (Move_Loop → Move) + Move_End → <form>Move → Run_Begin/Loop/End
// → Run → first /^Move/ → null
// stun : Stun_Begin + Stun (+ Stun_End) → Stun → null (renderer: freeze the track)
// run : Run_Begin/Loop/End → Run → <form>Run → absent (only a model with its own Run cycle has the role)
// stun : (Stun_Begin | Dizzy_Begin) + (Stun → Stun_1 → Dizzy_Loop) (+ Stun_End | Dizzy_End) → Stun_Begin alone
// → null (renderer: freeze the track)
/**
* @typedef {{ begin: string|null, loop: string, end: string|null, via?: string }} Clip
* @typedef {Clip & { index: number, idle: string|null }} SkillClip
* @typedef {{ idle: string|null, deploy: string|null, attack: Clip|null, attackDown: Clip|null,
* skill: SkillClip|null, skills?: Record<string, SkillClip>, die: string|null, move: Clip|null, stun: Clip|null }} Roles
* skill: SkillClip|null, skills?: Record<string, SkillClip>, die: string|null, move: Clip|null, run?: Clip,
* stun: Clip|null }} Roles
*/
function makeFinder(names) {
@@ -221,10 +225,27 @@ function resolveMove(names, find, form) {
return any ? clip(null, any, null) : null;
}
/**
* The model's own run cycle, when it has one (猎狗pro: Move_Loop 0.80 s next to Run_Loop 0.53 s): a role of its own, not
* the move choice — the renderer walks a fast enemy on it (render/units.js `moveFast`; PR #275 by @xcdoge).
*/
function resolveRun(find, form) {
const t = triple(find, 'Run');
if (t) return t;
const r = find('Run') ?? form('Run');
return r ? clip(null, r, null) : null;
}
/**
* The stun family is spelled two ways and sometimes numbered (PR #275 by @xcdoge): Stun / Stun_Begin / Stun_End
* (吉兆飞鳞, 乌顶巨角卢鲁), Stun_1 / Stun_2 (巨大的丑东西: its first and its second form — render/units.js FORMS gives the
* second form Stun_2), Dizzy_Begin / Dizzy_Loop / Dizzy_End (斩胄之剑 / 破胄之锤, stun-immune in this mode). Without a role
* a stunned enemy freezes its current clip.
*/
function resolveStun(find) {
const stun = find('Stun');
const begin = find('Stun_Begin');
if (stun) return clip(begin, stun, find('Stun_End'));
const stun = find('Stun') ?? find('Stun_1') ?? find('Dizzy_Loop');
const begin = find('Stun_Begin') ?? find('Dizzy_Begin');
if (stun) return clip(begin, stun, find('Stun_End') ?? find('Dizzy_End'));
if (begin) return clip(null, begin, null);
return null;
}
@@ -241,6 +262,8 @@ function resolveDie(names, find, form) {
* @param {string[]} animationNames all animation names in the skeleton
* @param {object} [opts]
* @param {number[]} [opts.skillIndices] 0-based skill indices to resolve (first = primary); default [0]
* @param {boolean} [opts.numberedSkills] also resolve every numbered skill clip of the skeleton (Skill_01, Skill_2_Loop …)
* after the caller's indices: an enemy's per-skill clips (a multi-skill boss such as 盐风主教昆图斯, Skill_01..04)
* @param {Record<string, number>} [opts.durations] animation durations in seconds (prefers animated idles)
* @returns {Roles}
*/
@@ -256,6 +279,16 @@ export function resolveRoles(animationNames, opts = {}) {
const indices = [...new Set((opts.skillIndices?.length ? opts.skillIndices : [0])
.filter((i) => Number.isInteger(i) && i >= 0 && i < 10))];
if (!indices.length) indices.push(0);
// an enemy's numbered skill clips (PR #275 by @xcdoge: its manifest only ever had index 0, so a multi-skill boss could
// show one cast clip): appended after the caller's indices, whose first stays the primary `skill`
if (opts.numberedSkills) {
const found = new Set();
for (const n of names) {
const m = /^Skill_?0*(\d+)(?:$|_)/i.exec(n);
if (m && Number(m[1]) >= 1 && Number(m[1]) <= 10) found.add(Number(m[1]) - 1);
}
for (const i of [...found].sort((a, b) => a - b)) if (!indices.includes(i)) indices.push(i);
}
/** @type {Record<string, SkillClip>} */
const skills = {};
for (const i of indices) {
@@ -272,6 +305,8 @@ export function resolveRoles(animationNames, opts = {}) {
move: resolveMove(names, find, form),
stun: resolveStun(find),
};
const run = resolveRun(find, form);
if (run) roles.run = run;
if (indices.length > 1) roles.skills = skills;
return roles;
}
@@ -289,7 +324,7 @@ export function roleAnimationNames(roles) {
};
if (!roles) return [];
for (const k of ['idle', 'deploy', 'die']) if (typeof roles[k] === 'string') out.add(roles[k]);
for (const k of ['attack', 'attackDown', 'skill', 'move', 'stun']) addClip(roles[k]);
for (const k of ['attack', 'attackDown', 'skill', 'move', 'run', 'stun']) addClip(roles[k]);
if (roles.skills) for (const c of Object.values(roles.skills)) addClip(c);
return [...out];
}
+6 -3
View File
@@ -166,7 +166,8 @@ export function applyRoleFix(roles, fix, durations) {
/**
* Spine metadata of extracted models (findLocalEnemyModels / findLocalTokenModels), parsed like processModels does — but
* read only: the atlas is sized / pma-normalized in memory (extract.py already writes it so), nothing on disk changes.
* Roles are resolved for skill index 0, like the web enemy and token models, then LOCAL_SPINE_ROLES applied.
* Roles are resolved for skill index 0 (an enemy also for its numbered skill clips), like the web enemy and token models,
* then LOCAL_SPINE_ROLES applied.
* @param {string} root absolute public/assets directory
* @param {Record<string, { dir: string, skel: string, atlas: string, pngs: string[] }>} found
* @returns {Promise<{ meta: Record<string, LocalSpineMeta>, problems: string[] }>}
@@ -192,7 +193,7 @@ export async function localSpineMeta(root, found) {
const sk = parseSkel(await readFile(join(root, m.skel)), info.regions);
if (!sk.animations.length) throw new Error('skeleton has no animations');
if (sk.missingRegions?.length) problems.push(`${id}: ${sk.missingRegions.length} attachment(s) not in atlas (e.g. ${sk.missingRegions[0]})`);
const fixed = applyRoleFix(resolveRoles(sk.animations, { skillIndices: [0], durations: sk.durations }), LOCAL_SPINE_ROLES[id], sk.durations);
const fixed = applyRoleFix(resolveRoles(sk.animations, { skillIndices: [0], numberedSkills: id.startsWith('enemy_'), durations: sk.durations }), LOCAL_SPINE_ROLES[id], sk.durations);
if (fixed.missing.length) problems.push(`${id}: role fix not applied (clip missing) for ${fixed.missing.join(', ')}`);
meta[id] = {
skel: base(m.skel), atlas: base(m.atlas), textures: info.pages, pma: true,
@@ -330,7 +331,9 @@ export async function processModels(models, { root, dl, cachePath, download = tr
if (!sk.animations.length) { problems.push(`${m.key}: skeleton has no animations`); continue; }
const key = String(m.key);
const id = key.startsWith('enemy:') ? key.slice('enemy:'.length) : key.startsWith('op:') ? key.split(':')[1] : null; // op:<char>:front|back
const fixed = applyRoleFix(resolveRoles(sk.animations, { skillIndices: m.skillIndices, durations: sk.durations }), id ? PREFAB_SPINE_ROLES[id] : undefined, sk.durations);
// an enemy's numbered skill clips are resolved too (anims.skills: the clip of each skill slot it casts, PR #275);
// operators keep the pool's indices (their equipped skill picks among them)
const fixed = applyRoleFix(resolveRoles(sk.animations, { skillIndices: m.skillIndices, numberedSkills: key.startsWith('enemy:'), durations: sk.durations }), id ? PREFAB_SPINE_ROLES[id] : undefined, sk.durations);
if (fixed.missing.length) problems.push(`${m.key}: role fix not applied (clip missing) for ${fixed.missing.join(', ')}`);
const anims = fixed.roles;
entries.set(m.key, {