feedback2 WK: enemies play their attack clip once per attack at its own speed, then their Move clip (no stretching over the attack interval; GitHub #58)

This commit is contained in:
sganggs committed 2026-10-04 02:49:33 +08:00
1 parent 0623336f30
commit 68da23e8ed
3 files changed
+136 -12

No files matched your search

@@ -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);
});