diff --git a/public/js/render/spine.js b/public/js/render/spine.js index d86bce68..e805074e 100644 --- a/public/js/render/spine.js +++ b/public/js/render/spine.js @@ -367,9 +367,13 @@ export class SpineActor { 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, !single, { timeScale: ts, start: hit, mix: 0.06 }); + // 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 — unless a clip-per-attack actor's rhythm leaves room for the whole clip (interval ≥ clip): + // then the clip plays complete from its wind-up at its own speed, the strike a little late (重犯's iron ball + // lifts before the slam; #246 by @TsangAsuna, accepted by the owner on 2026-10-07) + const whole = per && this.interval >= loopDur; + if (whole) this.attackUntil = this.clock + loopDur / ts; + this._play(clip.loop, !single, { timeScale: ts, start: whole ? 0 : hit, mix: 0.06 }); } else if (single) { const e = this.spine.state.tracks[0]; if (e) e.timeScale = ts; diff --git a/test/render/feedback2-enemy-attack-clip.test.js b/test/render/feedback2-enemy-attack-clip.test.js index 999877a1..5c560624 100644 --- a/test/render/feedback2-enemy-attack-clip.test.js +++ b/test/render/feedback2-enemy-attack-clip.test.js @@ -71,6 +71,27 @@ test('an enemy attacking quicker than its clip plays it faster, one clip per att assert.equal(a.current, 'Move', 'the rest of the clip at 2×: 0.23 s'); }); +// #246 (by @TsangAsuna, accepted by the owner on 2026-10-07): seen late, an attack whose rhythm leaves room for the whole +// clip plays it complete from the wind-up — 重犯's iron ball lifts before the slam instead of the slam appearing alone +test('an attack seen late plays the whole clip from its wind-up when the rhythm leaves room for it (重犯), else from the strike frame', () => { + const LIFBOS = 'enemy_1121_lifbos'; // 重犯: Attack_grey 2.333 s, strike at 1.0 s; attacks every 3 s + const a = actor(LIFBOS, true); + a.attack(3); + assert.equal(a.current, 'Attack_grey'); + assert.equal(track(a).trackTime, 0, 'from the wind-up (the ball lifts), not the 1.0 s strike frame'); + assert.equal(track(a).timeScale, 1, 'at its own speed'); + run(a, 2.25); + assert.equal(a.current, 'Attack_grey', 'the whole 2.333 s clip plays (it used to end 1.333 s after the strike frame)'); + run(a, 0.15); + assert.equal(a.current, 'Move_grey', 'then the resting clip'); + const quick = actor(LIFBOS, true); + quick.attack(2); // quicker than the clip: no room for the wind-up + assert.ok(Math.abs(track(quick).trackTime - 1) < 1e-9, 'from the strike frame'); + const op = actor(LIFBOS, false); + op.attack(3); // an operator's looping attack is unchanged + assert.ok(Math.abs(track(op).trackTime - 1) < 1e-9); +}); + test('an operator keeps its attack loop stretched over the attack rhythm (unchanged)', () => { const a = actor(JSHOOT, false); a.attack(2.7);