SpineActor.attack() starts an attack that was not wound up (no look-ahead: a batch that arrived late) on its strike frame — right for a quick rhythm, but for a slow clip-per-attack enemy the wind-up was cut out: 重犯 (Attack_grey 2.333 s, strike at 1.0 s, attacks every 3 s) showed the slam without the ball ever rising, and only 1.33 s of the clip. The owner's decision of 2026-10-07 on #246: re-implement on the current spine.js so that, when the attack interval leaves room for the whole clip, it plays once completely from the start. A clip-per-attack actor (enemies) with interval ≥ the clip now plays it from 0 at its own speed for the whole clip, the strike landing a little late; a quicker rhythm keeps the strike-frame start, and operators' looping attacks are unchanged (the PR's condition had it the other way round: it changed the looping attacks and left out the clip-per-attack enemies it was written for). Tests: test/render/feedback2-enemy-attack-clip.test.js (+1: 重犯 plays the whole clip from its wind-up, a quicker rhythm and an operator start on the strike frame) 5/5; render eyemask, unitview, texas skill clip, skill idle 51/51.
124 lines
6.3 KiB
JavaScript
124 lines
6.3 KiB
JavaScript
// 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');
|
||
});
|
||
|
||
// #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);
|
||
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);
|
||
});
|