diff --git a/public/js/render/spine.js b/public/js/render/spine.js index ea78f0ca..137d8254 100644 --- a/public/js/render/spine.js +++ b/public/js/render/spine.js @@ -9,7 +9,10 @@ // attack(interval, once) one attack happened now (b.ev 'atk'): plays begin→loop, re-phases the loop // so its OnAttack frame lands now, timeScale = loopDuration / interval; // `once` (a one-off cast, style.js PROJ[kind].once — 暴鸰's bomb drop): the -// clip plays once at its own speed, then base +// clip plays once at its own speed, then base; `clipPerAttack` (enemies, +// 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 // deploy() 'Start' once, then base // die() die clip once (callers fade out afterwards); a skeleton without one holds its @@ -20,8 +23,8 @@ // closing clip timed to end `in` s from now (a 重生's last clip ends with the // 重生), 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: to the end of its clip), then the -// end clip (if any) and base. +// 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. const clampN = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); @@ -98,6 +101,13 @@ export class SpineActor { this.frozen = false; this.dead = false; this.interval = 1; + /** + * Enemies (render/units.js, GitHub #58): each attack plays the attack clip once — at its own speed, faster only when + * the attacks come quicker than the clip — and then the resting state; the sim stands the enemy for exactly that + * clip (server/sim/ai.js attackStand). Off (operators): the clip loops over the attack rhythm. + */ + this.clipPerAttack = false; + this.wound = false; // clipPerAttack: wound up for the coming attack (windUp → attack) this._play(this._idleName(), true); } @@ -241,19 +251,23 @@ export class SpineActor { if (this.dead || this.mode === 'stun' || this.mode === 'die' || this.mode === 'change' || !(lead >= 0)) return false; const clip = this._attackClip(); if (!clip) return false; - if (this.mode === 'attack' && this.current === clip.loop) return false; // in rhythm: attack() re-phases + const single = once || this.clipPerAttack; + // in rhythm: attack() re-phases; a clip-per-attack actor starts each attack's clip anew unless already wound up for it + if (this.mode === 'attack' && this.current === clip.loop && (!this.clipPerAttack || once || this.wound)) return false; const loopDur = this.dur(clip.loop); // a one-off cast plays at the clip's own speed (one loop per clip length), whatever the attack rhythm const iv = once ? loopDur : clampN(Number.isFinite(interval) && interval > 0 ? interval : this.interval, 0.08, 8); const hit = this._hitTime(clip.loop, loopDur); - const plan = windUpPlan(loopDur, hit, iv, lead); + // clip per attack: its own speed, sped up only when the attacks come quicker than the clip + const plan = windUpPlan(loopDur, hit, this.clipPerAttack && !once ? Math.min(iv, loopDur) : iv, lead); if (!(hit > 0) || lead * plan.ts > hit + 1e-6) return false; if (!once) this.interval = iv; this.mode = 'attack'; - this.attackUntil = this.clock + lead + (once ? Math.max(0, loopDur - hit) / plan.ts : Math.max(0.45, iv * 1.4)); - this._play(clip.loop, !once, { timeScale: plan.tsWind, start: plan.start, mix: 0.06 }); + this.attackUntil = this.clock + lead + (single ? Math.max(0, loopDur - hit) / plan.ts : Math.max(0.45, iv * 1.4)); + this._play(clip.loop, !single, { timeScale: plan.tsWind, start: plan.start, mix: 0.06 }); this.windTs = plan.ts; this.windUntil = this.clock + lead; + this.wound = true; return true; } @@ -267,17 +281,22 @@ export class SpineActor { const clip = this._attackClip(); if (!clip) return; const loopDur = this.dur(clip.loop); - const ts = once ? 1 : clampN(loopDur / this.interval, 0.35, 4); + const per = this.clipPerAttack && !once; + const single = once || per; + // clip per attack: its own speed (ts 1), faster only when the attacks come quicker than the clip + const ts = once ? 1 : per ? clampN(loopDur / Math.min(this.interval, loopDur), 1, 4) : clampN(loopDur / this.interval, 0.35, 4); const hit = this._hitTime(clip.loop, loopDur); - const wasAttacking = this.mode === 'attack' && this.current === clip.loop; + // (a clip-per-attack actor still playing the previous attack's clip was not wound up for this one) + const wasAttacking = this.mode === 'attack' && this.current === clip.loop && (!per || this.wound); this.mode = 'attack'; - this.attackUntil = this.clock + (once ? Math.max(0, loopDur - hit) / ts : Math.max(0.45, this.interval * 1.4)); + this.attackUntil = this.clock + (single ? Math.max(0, loopDur - hit) / ts : Math.max(0.45, this.interval * 1.4)); this.windUntil = null; + this.wound = false; if (!wasAttacking) { // not wound up (no look-ahead, e.g. a batch that arrived late): the sim already resolved the hit, so show // the strike frame now - this._play(clip.loop, !once, { timeScale: ts, start: hit, mix: 0.06 }); - } else if (once) { + this._play(clip.loop, !single, { timeScale: ts, start: hit, mix: 0.06 }); + } else if (single) { const e = this.spine.state.tracks[0]; if (e) e.timeScale = ts; } else { @@ -408,6 +427,7 @@ export class SpineActor { case 'attack': if (this.clock > this.attackUntil) { 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); } else this._play(this._baseName(), true, { mix: 0.15 }); diff --git a/public/js/render/units.js b/public/js/render/units.js index dbd23b44..864b4993 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -460,6 +460,8 @@ export class UnitView { try { actor = new SpineActor(data, entry); actor.setSkillIndex(this.info.skillIndex); + // 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) { console.warn('[render] spine build failed', id, err?.message || err); this._releaseEntry(entry); diff --git a/test/render/feedback2-enemy-attack-clip.test.js b/test/render/feedback2-enemy-attack-clip.test.js new file mode 100644 index 00000000..999877a1 --- /dev/null +++ b/test/render/feedback2-enemy-attack-clip.test.js @@ -0,0 +1,102 @@ +// test/render/feedback2-enemy-attack-clip.test.js — GitHub #58, client side: an enemy plays its attack clip once per +// attack at the clip's own speed (faster only when the attacks come quicker than the clip) and then its resting clip — +// Move while the sim walks it again (server/sim/ai.js attackStand stands it for exactly that clip). It used to stretch +// the clip over the whole attack interval and loop it for 1.4 intervals, so a ranged enemy seemed to slide while it +// attacked. Operators keep the clip looping over their attack rhythm. 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, fakeViewCtx } from './fakepixi.js'; +import { presetCamera } from '../../public/js/render/projection.js'; +import { ANIM } from '../../shared/constants.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const assets = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8')); +const JSHOOT = 'enemy_1019_jshoot'; // 隐形弩手: Attack 1.0 s, strike at 0.533 s; attacks every 2.7 s + +let fake, SpineActor, UnitView; +before(async () => { + fake = installFakePixi(); + ({ SpineActor } = await import('../../public/js/render/spine.js')); + ({ UnitView } = await import('../../public/js/render/units.js')); +}); +after(() => fake.restore()); + +const entryOf = (id) => assets.enemies[id].spine; +const dataOf = (entry) => ({ animations: Object.keys(entry.animations).map((name) => ({ name })) }); +function actor(id, perAttack) { + const entry = entryOf(id); + const a = new SpineActor(dataOf(entry), entry); + a.clipPerAttack = perAttack; + a.setBase('move'); + return a; +} +const track = (a) => a.spine.state.tracks[0]; +const run = (a, s, dt = 1 / 60) => { for (let t = 0; t < s - 1e-9; t += dt) a.update(dt); }; + +test('an enemy: wound up before the strike, the Attack clip plays once at its own speed, then Move again — no stretching over the interval', () => { + const a = actor(JSHOOT, true); + assert.equal(a.current, 'Move'); + assert.ok(a.windUp(2.7, 0.2), 'winds up 0.2 s before the strike'); + assert.equal(a.current, 'Attack'); + assert.equal(track(a).loop, false, 'one clip per attack'); + run(a, 0.2); + a.attack(2.7); + assert.equal(track(a).timeScale, 1, 'its own speed (the 2.7 s interval used to slow it to 0.37)'); + run(a, 0.45); + assert.equal(a.current, 'Attack', 'the clip plays to its end (0.467 s after the strike)'); + run(a, 0.05); + assert.equal(a.current, 'Move', 'then walks on its Move clip (it used to hold the attack for 1.4 intervals: 3.8 s)'); + // the next attack starts the clip anew + run(a, 1.5); + assert.ok(a.windUp(2.7, 0.3)); + assert.equal(a.current, 'Attack'); + run(a, 0.3); + a.attack(2.7); + run(a, 0.5); + assert.equal(a.current, 'Move'); +}); + +test('an enemy attacking quicker than its clip plays it faster, one clip per attack; an attack seen late starts on the strike frame', () => { + const a = actor(JSHOOT, true); + a.attack(0.5); // no wind-up seen: from the strike frame, at 1.0 / 0.5 = 2× + assert.equal(a.current, 'Attack'); + assert.equal(track(a).loop, false); + assert.equal(track(a).timeScale, 2); + assert.ok(Math.abs(track(a).trackTime - 0.533) < 1e-9, 'on the strike frame'); + run(a, 0.25); + assert.equal(a.current, 'Move', 'the rest of the clip at 2×: 0.23 s'); +}); + +test('an operator keeps its attack loop stretched over the attack rhythm (unchanged)', () => { + const a = actor(JSHOOT, false); + a.attack(2.7); + assert.equal(track(a).loop, true); + assert.ok(Math.abs(track(a).timeScale - 1 / 2.7) < 1e-9); + run(a, 2); + assert.equal(a.current, 'Attack', 'still in attack mode 2 s later (1.4 × the interval)'); +}); + +test('UnitView: enemy models play a clip per attack, operator models do not', async () => { + const entry = entryOf(JSHOOT); + const store = { picture: () => null, image: async () => null, spineEntry: () => entry, spine: { acquire: async () => dataOf(entry), release() {} } }; + const cam = () => presetCamera('normal', { width: 1280, height: 720 }); + const tick = () => new Promise((r) => setImmediate(r)); + const make = async (side) => { + const v = new UnitView(fakeViewCtx(fake.P, { assets: store, cam }), { id: side === 'enemy' ? 7 : 8, side, kind: side === 'enemy' ? 'enemy' : 'op', defId: JSHOOT, spine: JSHOOT, tier: 1, x: 8, y: 9, maxHp: 1000, facing: -1 }); + await tick(); await tick(); + assert.ok(v.actor, 'model built'); + return v; + }; + const e = await make('enemy'); + assert.equal(e.actor.clipPerAttack, true); + e.sync({ x: 8, y: 9, hp: 1000, maxHp: 1000, sp: 0, spMax: 0, flags: 0, anim: ANIM.MOVE, vx: 0 }, 1); + e.onAttack(null, 1); + assert.equal(e.actor.current, 'Attack'); + assert.equal(e.actor.spine.state.tracks[0].loop, false); + const o = await make('ally'); + assert.equal(o.actor.clipPerAttack, false); +});