diff --git a/public/js/render/spine.js b/public/js/render/spine.js index 137d8254..13baee77 100644 --- a/public/js/render/spine.js +++ b/public/js/render/spine.js @@ -13,7 +13,10 @@ // GitHub #58): every attack plays the clip once — at its own speed, faster // only when the attacks come quicker than the clip —, then base (Move while // the sim walks it: it stands for that clip, server/sim/ai.js attackStand) -// setSkill(on) skill begin→loop while active (skill idle replaces idle), end on stop +// setSkill(on) skill begin, then — when the skill has an idle clip of its own (skill.idle, +// not its loop) — that idle between attacks, its loop (the skill's attack clip) +// only on attacks (community report #23: 折桠's S2 jump attack looped with no +// enemy engaged); a skill without one keeps its loop as the stance; end on stop // deploy() 'Start' once, then base // die() die clip once (callers fade out afterwards); a skeleton without one holds its // idle clip's first frame (GitHub issue #25: the attack loop went on) @@ -24,7 +27,9 @@ // 重生), landing in `roles` // update(dt) advances the skeleton (autoUpdate is off: one clock for everything) // Attack mode lasts until ~1.4 attack intervals without a new attack (a `once` cast and every attack of a -// `clipPerAttack` actor: to the end of its clip), then the end clip (if any) and base. +// `clipPerAttack` actor: to the end of its clip), then the end clip (if any) and base — except the attacks of a skill +// with its own idle clip, which go straight back to that idle: the skill's end clip closes the skill, not each spell of +// attacks while it runs. const clampN = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); @@ -327,6 +332,17 @@ export class SpineActor { return !!sk && sk.loop === this.roles.idle; } + /** + * The running skill's own idle clip when its attacks are its loop clip (anims skill.idle ≠ skill.loop: 折桠's + * Skill_2_Idle beside the Skill_2_Loop jump attack, 史尔特尔's Skill_3_Idle, 耀骑士临光's Skill_3_Idle …): the pose + * between its attacks. null otherwise — no skill on, no idle clip, or the loop is that idle (蕾缪安's S2 / S3). + */ + _skillIdle() { + const sk = this.roles.skill; + if (!this.skillOn || !sk || sk.idle === sk.loop || !this.has(sk.idle)) return null; + return this._attackClip() === sk ? sk.idle : null; + } + _hitTime(anim, dur) { const hits = this.entry.hits && this.entry.hits[anim]; if (Array.isArray(hits) && hits.length && Number.isFinite(hits[0])) return clampN(hits[0], 0, dur); @@ -345,7 +361,9 @@ export class SpineActor { this.mode = 'skillBegin'; this._play(sk.begin, false, { mix: 0.08 }); this.skillBeginUntil = this.clock + this.dur(sk.begin); - if (this.has(sk.loop)) this._queue(sk.loop, true); + // then the skill's own idle until an attack plays its loop (community report #23); without one, the loop + const next = this._skillIdle() || (this.has(sk.loop) ? sk.loop : null); + if (next) this._queue(next, true); } else if (this.mode === 'base') this._play(this._baseName(), true); } else if (!on && sk) { if (this.has(sk.end)) { @@ -429,7 +447,9 @@ export class SpineActor { this.mode = 'base'; this.wound = false; const clip = this._attackClip() || this.roles.attack; - if (clip && this.has(clip.end)) { this._play(clip.end, false); this._queue(this._baseName(), true); } + // a skill with its own idle: back to that idle (its end clip is for the end of the skill, setSkill(false)) + const toSkillIdle = clip === this.roles.skill && !!this._skillIdle(); + if (clip && this.has(clip.end) && !toSkillIdle) { this._play(clip.end, false); this._queue(this._baseName(), true); } else this._play(this._baseName(), true, { mix: 0.15 }); } break; diff --git a/test/render/feedback3-skill-idle.test.js b/test/render/feedback3-skill-idle.test.js new file mode 100644 index 00000000..dcc651ab --- /dev/null +++ b/test/render/feedback3-skill-idle.test.js @@ -0,0 +1,91 @@ +// test/render/feedback3-skill-idle.test.js — community report #23 (0.1.3): "折娅开了技能就会不停做跳起来打人的动作,哪怕没有接敌". +// 折桠's S2 model (char_4207_branch) has Skill_2_Begin, Skill_2_Loop (the jump attack, OnAttack at 0.8 s), Skill_2_End +// and Skill_2_Idle; the actor queued the loop after the begin clip, so it jumped with nobody to hit (the sim made no +// attack). Now a skill with an idle clip of its own (anims skill.idle ≠ skill.loop) stands in that idle between attacks, +// plays the loop only on attacks, and goes back to the idle after a spell of attacks (its End clip only when the skill +// ends). Skills whose loop IS their idle (蕾缪安 S3) and skills without an idle (宴) are unchanged. Real manifest entries, +// headless fake PIXI (test/render/fakepixi.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 } from './fakepixi.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const assets = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8')); + +let fake, SpineActor; +before(async () => { + fake = installFakePixi(); + ({ SpineActor } = await import('../../public/js/render/spine.js')); +}); +after(() => fake.restore()); + +/** An actor of a real operator's Front model with its equipped skill, recording every clip set / queued. */ +function actor(charId, skillIndex) { + const entry = assets.chars[charId].spine.front; + const a = new SpineActor({ animations: Object.keys(entry.animations).map((name) => ({ name })) }, entry); + a.setSkillIndex(skillIndex); + a.log = []; + const st = a.spine.state; + const set = st.setAnimation, add = st.addAnimation; + st.setAnimation = (i, name, loop) => { a.log.push(['set', name, !!loop]); return set(i, name, loop); }; + st.addAnimation = (i, name, loop) => { a.log.push(['queue', name, !!loop]); return add(i, name, loop); }; + return a; +} +const run = (a, s, dt = 1 / 60) => { for (let t = 0; t < s - 1e-9; t += dt) a.update(dt); }; +const names = (a) => a.log.map(([, n]) => n); + +test('折桠 S2: begin, then Skill_2_Idle — the jump attack only on attacks, back to the idle after them, End only when the skill ends', () => { + const a = actor('char_4207_branch', 1); + assert.deepEqual([a.roles.skill.loop, a.roles.skill.idle], ['Skill_2_Loop', 'Skill_2_Idle'], 'the manifest roles'); + a.setSkill(true); + assert.deepEqual(a.log, [['set', 'Skill_2_Begin', false], ['queue', 'Skill_2_Idle', true]], 'no Skill_2_Loop queued after the begin clip'); + run(a, 3); + assert.equal(a.mode, 'base'); + assert.ok(!names(a).includes('Skill_2_Loop'), 'no attack: no jump attack'); + // she is hit, an enemy stands on her tile: attacks every 1.5 s + a.log.length = 0; + a.attack(1.5); + assert.equal(a.current, 'Skill_2_Loop', 'the attack plays the skill loop'); + run(a, 1.5); a.attack(1.5); + run(a, 1.5 * 1.4 + 0.1); + assert.equal(a.mode, 'base'); + assert.equal(a.current, 'Skill_2_Idle', 'the enemy is gone: back to the skill idle'); + assert.ok(!names(a).includes('Skill_2_End'), 'the skill End clip is not played between attacks'); + a.setSkill(false); + assert.equal(a.current, 'Skill_2_End', 'the skill ends on its End clip'); + run(a, 0.2); + assert.equal(a.current, 'Idle'); +}); + +test('the other skills with an own idle clip follow the same rule (史尔特尔 S3 begin → Skill_3_Idle); 耀骑士临光 S3 (no begin) already idled', () => { + const s = actor('char_350_surtr', 2); + s.setSkill(true); + assert.deepEqual(s.log, [['set', 'Skill_3_Begin', false], ['queue', 'Skill_3_Idle', true]]); + s.attack(1.25); + assert.equal(s.current, 'Skill_3_Loop'); + const n = actor('char_1014_nearl2', 2); + n.setSkill(true); + assert.deepEqual(n.log, [['set', 'Skill_3_Idle', true]], 'unchanged: the skill idle at once'); + n.attack(1.2); + assert.equal(n.current, 'Skill_3'); + run(n, 1.2 * 1.4 + 0.1); + assert.equal(n.current, 'Skill_3_Idle'); +}); + +test('unchanged: a loop that is the skill idle (蕾缪安 S3) and a skill without an idle clip (宴: its loop stays the stance)', () => { + const l = actor('char_4193_lemuen', 2); + l.setSkill(true); + assert.deepEqual(l.log, [['set', 'Skill_3_Begin', false], ['queue', 'Skill_3_Idle', true]]); + const u = actor('char_337_utage', 1); + assert.equal(u.roles.skill.idle, null); + u.setSkill(true); + assert.deepEqual(u.log, [['set', 'Skill_Start', false], ['queue', 'Skill_Loop', true]], 'begin → loop, as before'); + u.log.length = 0; + u.attack(1.2); + run(u, 1.2 * 1.4 + 0.1); + assert.deepEqual(names(u).slice(-2), ['Skill_End', 'Idle'], 'its spell of attacks still ends as before'); +});