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:
1 parent
0623336f30
commit
68da23e8ed
3 files changed
+136
-12
No files matched your search
+32
-12
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user