feedback7 R22-GHSIM: 假想敌:骨刺 changes model with its 隐匿 at once — the three-headed snake (*_A) while hidden, its *_B set when blocked or revealed, back to *_A when hidden again (GitHub #296, PR #365 by @Sukvii, render part); golden: unchanged
The contributor's code (render/spine.js syncFormPose, the render/units.js wiring and FORMS entry, the headless test/render/forms.test.js block), applied from PR #365, so they are the author of this commit. Maintainer side: the hard-coded Spine id became a STEALTH_FORMS table (form while the stealth bit is off), comments and docs/ASSETS.md reworded. Not ported: the PR's sim targeting rewrite (content/enemies/invisible.js), its enemies_bosses tests, its roster.json and the Chrome suite (forms.browser.test.js, not run here). Seen (#296, FrogThai): a blocked 骨刺 kept the snake. The sim already sends UF.STEALTH only while its 隐匿 is effective (not blocked, not revealed — targeting.js enemyStealthed); the view now keys the FORMS 'revealed' clip set (Idle_B / Move_B / Die_B / Attack_B, all in the skeleton) off that bit in UnitView.sync — before a DIE of the same snapshot — and re-poses at once: a running attack continues in the other attack clip at the same point relative to its strike frame (Attack_A hit 0.533 s, Attack_B 0.733 s; a pending wind-up still strikes on time), a stun / idle restarts on the new set without a crossfade, and a frozen skeleton is posed with update(0); a model loaded later is posed the same way. Golden: unchanged (render only).
This commit is contained in:
1 parent
ca671f2fd0
commit
bfef34ffa3
4 files changed
+256
-1
No files matched your search
+5
-1
@@ -273,8 +273,12 @@ catch-up frames and hidden tabs (`keepsState`), the game screen's pre-entry buff
|
|||||||
event queue (`render/interp.js isCosmeticEvent`) too — or, for a view built mid-battle, UnitInfo `form`, which `render/app/info.js renderInfo` passes to the view; a
|
event queue (`render/interp.js isCosmeticEvent`) too — or, for a view built mid-battle, UnitInfo `form`, which `render/app/info.js renderInfo` passes to the view; a
|
||||||
`change` clip plays once first, an `end` clip is timed from the fx's `dur` to finish as that state ends, keeping the
|
`change` clip plays once first, an `end` clip is timed from the fx's `dur` to finish as that state ends, keeping the
|
||||||
current form's death clip until the next form's fx). A blocked or revealed 隐匿 enemy is drawn solid: the sim sends the
|
current form's death clip until the next form's fx). A blocked or revealed 隐匿 enemy is drawn solid: the sim sends the
|
||||||
stealth bit only while its 隐匿 is on:
|
stealth bit only while its 隐匿 is on — and 假想敌:骨刺 changes model with that bit, not with an fx (`STEALTH_FORMS`, GitHub
|
||||||
|
#296, PR #365): the manifest's `*_A` (the three-headed snake) while the bit is on, its `*_B` set (form `revealed`) the
|
||||||
|
moment it is blocked or revealed, `*_A` again when its 隐匿 is back; no change clip, a running attack, stun or freeze
|
||||||
|
pose carried over (`render/spine.js syncFormPose`). The forms:
|
||||||
- 掠海漂移体's crawl (`Change`, then `*_02`);
|
- 掠海漂移体's crawl (`Change`, then `*_02`);
|
||||||
|
- 假想敌:骨刺 (`*_A` / `*_B`, above);
|
||||||
- 转译基底·α's three forms (`A_Die_B` / `_C` / `_D`, 2 s each, then `B_*` 寻仇者, `C_*` 幽灵, `D_*` 特战术师);
|
- 转译基底·α's three forms (`A_Die_B` / `_C` / `_D`, 2 s each, then `B_*` 寻仇者, `C_*` 幽灵, `D_*` 特战术师);
|
||||||
- the 深池逐火 embers (`Die`, then `Idle_2` / `Move_2` / `Die_2`; `Revive` ends as it stands up) and 假想敌:再生's puppet
|
- the 深池逐火 embers (`Die`, then `Idle_2` / `Move_2` / `Die_2`; `Revive` ends as it stands up) and 假想敌:再生's puppet
|
||||||
(`A_Die`, then `B_*`; `B_Revive`);
|
(`A_Die`, then `B_*`; `B_Revive`);
|
||||||
|
|||||||
@@ -200,6 +200,41 @@ export class SpineActor {
|
|||||||
else if (this.mode === 'stun' && this.has(this.roles.stun?.loop)) this._play(this.roles.stun.loop, true);
|
else if (this.mode === 'stun' && this.has(this.roles.stun?.loop)) this._play(this.roles.stun.loop, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-pose on the clip set in force right after an immediate form switch (render/units.js STEALTH_FORMS: 假想敌:骨刺's
|
||||||
|
* stealth bit; PR #365): a running attack goes on in the new set's attack clip at the same point relative to its
|
||||||
|
* strike frame (a pending wind-up still strikes at windUntil), a stun / the resting state restarts on the new set's
|
||||||
|
* clip without a crossfade, and the skeleton is posed at once — also while a freeze holds it (`frozen`).
|
||||||
|
*/
|
||||||
|
syncFormPose() {
|
||||||
|
if (this.dead) return;
|
||||||
|
if (this.mode === 'attack') {
|
||||||
|
const track = this.spine.state.tracks[0];
|
||||||
|
const clip = this._attackClip();
|
||||||
|
if (!track || !clip) return;
|
||||||
|
const dur = this.dur(clip.loop);
|
||||||
|
const oldHit = this._hitTime(this.current, this.dur(this.current));
|
||||||
|
const hit = this._hitTime(clip.loop, dur);
|
||||||
|
const start = clampN(track.trackTime + hit - oldHit, 0, dur);
|
||||||
|
const lead = this.wound && this.windUntil != null
|
||||||
|
? Math.max(0, this.windUntil - this.clock) : 0;
|
||||||
|
const ts = lead > 0 ? Math.max(0, hit - start) / lead : track.timeScale;
|
||||||
|
const tailTs = lead > 0 ? this.windTs : ts;
|
||||||
|
this._play(clip.loop, track.loop, { start, timeScale: ts, mix: 0 });
|
||||||
|
this.attackUntil = this.clock + lead +
|
||||||
|
Math.max(0, dur - (lead > 0 ? hit : start)) / tailTs;
|
||||||
|
} else if (this.mode === 'stun') {
|
||||||
|
const name = this.has(this.roles.stun?.loop) ? this.roles.stun.loop : this._baseName();
|
||||||
|
this._play(name, true, { mix: 0 });
|
||||||
|
} else if (this.mode === 'base') {
|
||||||
|
this._play(this._baseName(), true, { mix: 0 });
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Apply the new attachments even when normal updates are frozen.
|
||||||
|
this.spine.update(0);
|
||||||
|
}
|
||||||
|
|
||||||
/** Play a form's transition clip once; attacks and the resting state wait for it (mode 'change'). */
|
/** Play a form's transition clip once; attacks and the resting state wait for it (mode 'change'). */
|
||||||
_change(clip) {
|
_change(clip) {
|
||||||
if (this.mode !== 'change') this.stunWanted = this.mode === 'stun';
|
if (this.mode !== 'change') this.stunWanted = this.mode === 'stun';
|
||||||
|
|||||||
@@ -190,6 +190,9 @@ export const EL_BAR = Object.freeze({ icon: 0.15, min: 8, max: 15, enemy: 0.8, g
|
|||||||
* → UnitView.setForm), per Spine id; the mode's roles override the manifest's (data/assets.json anims), `change` plays
|
* → UnitView.setForm), per Spine id; the mode's roles override the manifest's (data/assets.json anims), `change` plays
|
||||||
* once first:
|
* once first:
|
||||||
* - 掠海漂移体 (PRTS: 受晕眩/沉睡/冻结影响后进入爬行模式 — for good) crawls on its *_02 clips after 'Change';
|
* - 掠海漂移体 (PRTS: 受晕眩/沉睡/冻结影响后进入爬行模式 — for good) crawls on its *_02 clips after 'Change';
|
||||||
|
* - 假想敌:骨刺 (GitHub #296, PR #365): not an fx — its form follows the snapshot's stealth bit (STEALTH_FORMS): the
|
||||||
|
* manifest's *_A (the three-headed snake) while its 隐匿 is on, 'revealed' *_B the moment it is blocked or revealed, *_A
|
||||||
|
* again when the 隐匿 is back; no change clip, a running attack / stun pose carried over (SpineActor.syncFormPose);
|
||||||
* - 暴鸰 flies on its bomb-less *_2 clips once its one bomb left (the official prefab's mode S1: Move→Move_2, Idle→Idle_2,
|
* - 暴鸰 flies on its bomb-less *_2 clips once its one bomb left (the official prefab's mode S1: Move→Move_2, Idle→Idle_2,
|
||||||
* Die→Die_2; user feedback after 0.1.0, D4: the bomb used to stay under the drone — no change clip, the drop is its
|
* Die→Die_2; user feedback after 0.1.0, D4: the bomb used to stay under the drone — no change clip, the drop is its
|
||||||
* Attack clip);
|
* Attack clip);
|
||||||
@@ -224,6 +227,12 @@ export const EL_BAR = Object.freeze({ icon: 0.15, min: 8, max: 15, enemy: 0.8, g
|
|||||||
* built for the knock-out — and one rebuilt while it is down — the 替身's death clip; it stands up as the 本体.
|
* built for the knock-out — and one rebuilt while it is down — the 替身's death clip; it stands up as the 本体.
|
||||||
* A kind without a clip set of this skeleton (barriers, charges, …) changes nothing. 吉兆飞鳞's 晕眩模式 is its Stun clip.
|
* A kind without a clip set of this skeleton (barriers, charges, …) changes nothing. 吉兆飞鳞's 晕眩模式 is its Stun clip.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Enemy forms that follow the snapshot's stealth bit (UF.STEALTH: the sim sends it only while the enemy's 隐匿 is on —
|
||||||
|
* not blocked, not revealed) instead of a sim fx, per Spine id: the FORMS kind drawn while the bit is OFF; the manifest's
|
||||||
|
* clips while it is on. Switched at once (UnitView.sync, and for a model loaded later the load callback).
|
||||||
|
*/
|
||||||
|
export const STEALTH_FORMS = Object.freeze({ enemy_9008_acbunn: 'revealed' });
|
||||||
const loop = (name, via = null) => Object.freeze(via ? { begin: null, loop: name, end: null, via } : { begin: null, loop: name, end: null });
|
const loop = (name, via = null) => Object.freeze(via ? { begin: null, loop: name, end: null, via } : { begin: null, loop: name, end: null });
|
||||||
const clipSet = (idle, move, die, attack = null) => Object.freeze({
|
const clipSet = (idle, move, die, attack = null) => Object.freeze({
|
||||||
idle, deploy: idle, die, move: loop(move),
|
idle, deploy: idle, die, move: loop(move),
|
||||||
@@ -274,6 +283,9 @@ export const FORMS = Object.freeze({
|
|||||||
enemy_2025_syufo: Object.freeze({
|
enemy_2025_syufo: Object.freeze({
|
||||||
crawl: Object.freeze({ change: 'Change', roles: clipSet('Idle_02', 'Move_02', 'Die_02', 'Attack_02') }),
|
crawl: Object.freeze({ change: 'Change', roles: clipSet('Idle_02', 'Move_02', 'Die_02', 'Attack_02') }),
|
||||||
}),
|
}),
|
||||||
|
enemy_9008_acbunn: Object.freeze({
|
||||||
|
revealed: Object.freeze({ change: null, roles: clipSet('Idle_B', 'Move_B', 'Die_B', 'Attack_B') }),
|
||||||
|
}),
|
||||||
enemy_10081_mpplai: Object.freeze({
|
enemy_10081_mpplai: Object.freeze({
|
||||||
translator_fuchou: Object.freeze({ change: 'A_Die_B', roles: clipSet('B_Idle', 'B_Move', 'B_Die', 'B_Attack') }),
|
translator_fuchou: Object.freeze({ change: 'A_Die_B', roles: clipSet('B_Idle', 'B_Move', 'B_Die', 'B_Attack') }),
|
||||||
translator_youling: Object.freeze({ change: 'A_Die_C', roles: clipSet('C_Idle', 'C_Move', 'C_Die') }),
|
translator_youling: Object.freeze({ change: 'A_Die_C', roles: clipSet('C_Idle', 'C_Move', 'C_Die') }),
|
||||||
@@ -600,6 +612,8 @@ export class UnitView {
|
|||||||
if (this.flags & UF.SKILL) this.actor.setSkill(true);
|
if (this.flags & UF.SKILL) this.actor.setSkill(true);
|
||||||
this.actor.setBase(this._baseFromAnim());
|
this.actor.setBase(this._baseFromAnim());
|
||||||
if (deployed != null) { this.actor.deploy(); if (deployed > 0) this.actor.update(deployed); }
|
if (deployed != null) { this.actor.deploy(); if (deployed > 0) this.actor.update(deployed); }
|
||||||
|
// a stealth-driven form (STEALTH_FORMS): the pose of the clip set in force now, even under a frozen stun
|
||||||
|
if (STEALTH_FORMS[id]) this.actor.syncFormPose();
|
||||||
}
|
}
|
||||||
}, () => {
|
}, () => {
|
||||||
if (req === this._spineReq) { this._spineBusy = false; this._stuckAt = 0; }
|
if (req === this._spineReq) { this._spineBusy = false; this._stuckAt = 0; }
|
||||||
@@ -747,6 +761,17 @@ export class UnitView {
|
|||||||
const prevFlags = this.flags;
|
const prevFlags = this.flags;
|
||||||
this.flags = s.flags | 0;
|
this.flags = s.flags | 0;
|
||||||
this.anim = s.anim | 0;
|
this.anim = s.anim | 0;
|
||||||
|
// a stealth-driven form (STEALTH_FORMS: 假想敌:骨刺) follows this snapshot's stealth bit at once — before a DIE in the
|
||||||
|
// same snapshot, so it dies in the form it had
|
||||||
|
const stealthForm = this.isEnemy && this.alive ? STEALTH_FORMS[this.info.spine || this.info.defId] : null;
|
||||||
|
if (stealthForm) {
|
||||||
|
const form = this.flags & UF.STEALTH ? null : stealthForm;
|
||||||
|
if (form !== this.form) {
|
||||||
|
this.setForm(form);
|
||||||
|
this.actor?.syncFormPose();
|
||||||
|
if (this.imp) this.imp.dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (this.isEnemy && Math.abs(s.vx) > 0.08) this.visFacing = s.vx < 0 ? -1 : 1;
|
if (this.isEnemy && Math.abs(s.vx) > 0.08) this.visFacing = s.vx < 0 ? -1 : 1;
|
||||||
if ((prevFlags ^ this.flags) & UF.SKILL) this.setSkill(!!(this.flags & UF.SKILL));
|
if ((prevFlags ^ this.flags) & UF.SKILL) this.setSkill(!!(this.flags & UF.SKILL));
|
||||||
if (this.anim === ANIM.DIE && this.alive) this.die();
|
if (this.anim === ANIM.DIE && this.alive) this.die();
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ import { installFakePixi, fakeViewCtx } from './fakepixi.js';
|
|||||||
import { presetCamera } from '../../public/js/render/projection.js';
|
import { presetCamera } from '../../public/js/render/projection.js';
|
||||||
import { UF, ANIM } from '../../shared/constants.js';
|
import { UF, ANIM } from '../../shared/constants.js';
|
||||||
import { makeBattle, chessRec } from '../helpers/battleHarness.js';
|
import { makeBattle, chessRec } from '../helpers/battleHarness.js';
|
||||||
|
import * as enemiesMod from '../../server/sim/content/enemies.js';
|
||||||
|
import { enemyStealthed } from '../../server/sim/targeting.js';
|
||||||
|
import { flagsOf } from '../../server/sim/snapshot.js';
|
||||||
|
|
||||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
const assets = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8'));
|
const assets = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8'));
|
||||||
@@ -133,6 +136,194 @@ async function enemy(id, info = {}) {
|
|||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe('骨刺 effective stealth drives A/B clips', () => {
|
||||||
|
const BONE = 'enemy_9008_acbunn';
|
||||||
|
const near = (a, b) => assert.ok(Math.abs(a - b) < 1e-6, `${a} ≈ ${b}`);
|
||||||
|
function arena() {
|
||||||
|
const h = makeBattle({
|
||||||
|
content: 'generic', extraContent: [enemiesMod], seed: 7, autoFinish: false,
|
||||||
|
defs: { chess: { wall: chessRec({ id: 'wall', profession: 'TANK', stats: { atk: 0, maxHp: 1e7, def: 0, res: 0, blockCnt: 3 }, rangeGrid: [[0, 0]], skill: null }) } },
|
||||||
|
kits: { wall: () => ({ trait: { noAttack: true } }) }, units: [{ chessId: 'wall', row: 9, col: 6 }],
|
||||||
|
});
|
||||||
|
h.step();
|
||||||
|
const e = h.spawn(BONE, { pos: [9, 6], routeIndex: 0, mods: { speedMul: 0 } });
|
||||||
|
return { h, e, wall: h.unit('wall') };
|
||||||
|
}
|
||||||
|
function pose(v, h, e, hidden) {
|
||||||
|
const flags = flagsOf(e);
|
||||||
|
assert.equal(enemyStealthed(e), hidden);
|
||||||
|
assert.equal(!!(flags & UF.STEALTH), hidden);
|
||||||
|
v.sync({ ...sample(flags), x: e.x, y: e.y }, h.b.time);
|
||||||
|
assert.equal(v.form, hidden ? null : 'revealed');
|
||||||
|
assert.equal(clip(v), hidden ? 'Idle_A' : 'Idle_B');
|
||||||
|
v.fadeIn = 1;
|
||||||
|
v.update(1 / 60, cam(), h.b.time);
|
||||||
|
assert.equal(v.root.alpha, hidden ? 0.45 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('骨刺: actual blocking, blocker stun, reveal and expiry give A → B → A → B → A', async () => {
|
||||||
|
const { h, e, wall } = arena();
|
||||||
|
const v = await enemy(BONE);
|
||||||
|
try {
|
||||||
|
pose(v, h, e, true);
|
||||||
|
h.step(2); assert.equal(e.blockedBy, wall); pose(v, h, e, false);
|
||||||
|
h.b.applyStatus(wall, 'stun', { duration: 30, force: true });
|
||||||
|
h.step(2); assert.equal(e.blockedBy, null); pose(v, h, e, true);
|
||||||
|
h.b.addBuff(e, { key: 'test:reveal', duration: 1, flags: { reveal: true } });
|
||||||
|
pose(v, h, e, false);
|
||||||
|
h.run(1.1); pose(v, h, e, true);
|
||||||
|
} finally { v.destroy(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('骨刺: overlapping block and reveal keep B until both end', async () => {
|
||||||
|
const { h, e, wall } = arena();
|
||||||
|
const v = await enemy(BONE);
|
||||||
|
try {
|
||||||
|
h.step(2); assert.equal(e.blockedBy, wall);
|
||||||
|
h.b.addBuff(e, { key: 'test:reveal', duration: 0.1, flags: { reveal: true } });
|
||||||
|
pose(v, h, e, false);
|
||||||
|
h.run(0.2); assert.equal(e.blockedBy, wall); pose(v, h, e, false);
|
||||||
|
h.b.addBuff(e, { key: 'test:reveal', duration: 1, flags: { reveal: true } });
|
||||||
|
h.b.applyStatus(wall, 'stun', { duration: 30, force: true });
|
||||||
|
h.step(2); assert.equal(e.blockedBy, null); pose(v, h, e, false);
|
||||||
|
h.run(1.1); pose(v, h, e, true);
|
||||||
|
} finally { v.destroy(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('骨刺: switching an ongoing attack maps A 0.8 ↔ B 1.0 without another attack or a repeated-snapshot restart', async () => {
|
||||||
|
const v = await enemy(BONE);
|
||||||
|
try {
|
||||||
|
v.sync(sample(UF.STEALTH), 1);
|
||||||
|
v.atkInterval = 4;
|
||||||
|
v.onAttack(null, 1.1);
|
||||||
|
v.actor.clock = 2;
|
||||||
|
v.actor.spine.state.tracks[0].trackTime = 0.8;
|
||||||
|
const last = v.lastAtk, interval = v.atkInterval;
|
||||||
|
v.imp = { dirty: false };
|
||||||
|
v.sync(sample(0), 2);
|
||||||
|
assert.equal(clip(v), 'Attack_B');
|
||||||
|
near(v.actor.spine.state.tracks[0].trackTime, 1);
|
||||||
|
near(v.actor.attackUntil, 2.5);
|
||||||
|
assert.equal(v.imp.dirty, true);
|
||||||
|
const track = v.actor.spine.state.tracks[0];
|
||||||
|
v.sync(sample(0), 2.1);
|
||||||
|
assert.equal(v.actor.spine.state.tracks[0], track);
|
||||||
|
v.sync(sample(UF.STEALTH), 2.2);
|
||||||
|
assert.equal(clip(v), 'Attack_A');
|
||||||
|
near(v.actor.spine.state.tracks[0].trackTime, 0.8);
|
||||||
|
near(v.actor.attackUntil, 2.7);
|
||||||
|
assert.equal(v.lastAtk, last);
|
||||||
|
assert.equal(v.atkInterval, interval);
|
||||||
|
assert.equal(v.actor.wound, false);
|
||||||
|
v.imp = null;
|
||||||
|
} finally { v.imp = null; v.destroy(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const fromB of [false, true]) {
|
||||||
|
test(`骨刺: ${fromB ? 'B → A truncated' : 'A → B'} wind-up retains its deadline and tail speed`, async () => {
|
||||||
|
const v = await enemy(BONE);
|
||||||
|
try {
|
||||||
|
v.sync(sample(fromB ? 0 : UF.STEALTH), 1);
|
||||||
|
v.actor.clock = 2;
|
||||||
|
const lead = fromB ? 0.633 : 0.333;
|
||||||
|
assert.ok(v.actor.windUp(4, lead));
|
||||||
|
v.actor.spine.state.tracks[0].trackTime = fromB ? 0.1 : 0.2;
|
||||||
|
const until = v.actor.windUntil, last = v.lastAtk, interval = v.atkInterval;
|
||||||
|
v.sync(sample(fromB ? UF.STEALTH : 0), 2);
|
||||||
|
const track = v.actor.spine.state.tracks[0];
|
||||||
|
assert.equal(clip(v), fromB ? 'Attack_A' : 'Attack_B');
|
||||||
|
near(track.trackTime, fromB ? 0 : 0.4);
|
||||||
|
near(track.timeScale, fromB ? 0.533 / lead : 1);
|
||||||
|
assert.equal(v.actor.windUntil, until);
|
||||||
|
assert.equal(v.actor.windTs, 1);
|
||||||
|
assert.equal(v.actor.wound, true);
|
||||||
|
near(v.actor.attackUntil, 2 + lead + 1.5 - (fromB ? 0.533 : 0.733));
|
||||||
|
assert.equal(v.lastAtk, last);
|
||||||
|
assert.equal(v.atkInterval, interval);
|
||||||
|
v.sync(sample(fromB ? UF.STEALTH : 0), 2.1);
|
||||||
|
assert.equal(v.actor.spine.state.tracks[0], track);
|
||||||
|
v.actor.update(lead + 0.001);
|
||||||
|
assert.equal(track.timeScale, 1, 'the original tail speed resumes');
|
||||||
|
} finally { v.destroy(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const control of [UF.FROZEN, UF.STUNNED, UF.SLEEP]) {
|
||||||
|
test(`骨刺: control ${control} keeps the skeleton frozen while B/A attachments are applied immediately`, async () => {
|
||||||
|
const v = await enemy(BONE);
|
||||||
|
try {
|
||||||
|
v.sync(sample(UF.STEALTH | control), 1);
|
||||||
|
let zeroUpdates = 0;
|
||||||
|
const update = v.actor.spine.update.bind(v.actor.spine);
|
||||||
|
v.actor.spine.update = (dt) => { if (dt === 0) zeroUpdates++; update(dt); };
|
||||||
|
for (const hidden of [false, true]) {
|
||||||
|
v.sync(sample(control | (hidden ? UF.STEALTH : 0)), 2);
|
||||||
|
assert.equal(clip(v), hidden ? 'Idle_A' : 'Idle_B');
|
||||||
|
assert.equal(v.actor.mode, 'stun');
|
||||||
|
assert.equal(v.actor.frozen, true);
|
||||||
|
assert.equal(v.actor.spine.state.tracks[0].mixDuration, 0);
|
||||||
|
}
|
||||||
|
assert.equal(zeroUpdates, 2);
|
||||||
|
v.sync(sample(UF.STEALTH, ANIM.MOVE), 3);
|
||||||
|
assert.equal(v.actor.frozen, false);
|
||||||
|
assert.equal(clip(v), 'Move_A');
|
||||||
|
} finally { v.destroy(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('骨刺: a first snapshot with flags = 0 selects B even though previous flags were also 0', async () => {
|
||||||
|
const v = await enemy(BONE);
|
||||||
|
try {
|
||||||
|
assert.equal(v.flags, 0);
|
||||||
|
v.sync(sample(0), 1);
|
||||||
|
assert.equal(v.form, 'revealed');
|
||||||
|
assert.equal(clip(v), 'Idle_B');
|
||||||
|
} finally { v.destroy(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const finalFlags of [0, UF.STEALTH, UF.FROZEN]) {
|
||||||
|
test(`骨刺: delayed model loading applies the latest snapshot (${finalFlags}), including frozen B`, async () => {
|
||||||
|
const a = store(BONE);
|
||||||
|
let resolve;
|
||||||
|
a.spine.acquire = () => new Promise((r) => { resolve = r; });
|
||||||
|
const v = new UnitView(fakeViewCtx(fake.P, { assets: a, cam }), { id: 9, side: 'enemy', kind: 'enemy', defId: BONE, spine: BONE, maxHp: 1000 });
|
||||||
|
const proto = fake.P.spine.Spine.prototype, update = proto.update;
|
||||||
|
let zeroUpdates = 0;
|
||||||
|
proto.update = function (dt) { if (dt === 0) zeroUpdates++; return update.call(this, dt); };
|
||||||
|
try {
|
||||||
|
v.sync(sample(0), 1);
|
||||||
|
assert.equal(v.actor, null);
|
||||||
|
assert.equal(v.form, 'revealed');
|
||||||
|
v.sync(sample(finalFlags), 2);
|
||||||
|
resolve({ animations: Object.keys(a.spineEntry().animations).map((name) => ({ name })) });
|
||||||
|
await tick(); await tick();
|
||||||
|
assert.ok(v.actor);
|
||||||
|
assert.equal(clip(v), finalFlags & UF.STEALTH ? 'Idle_A' : 'Idle_B');
|
||||||
|
assert.ok(zeroUpdates > 0, 'the loaded pose is applied at zero time');
|
||||||
|
assert.equal(v.actor.frozen, !!(finalFlags & UF.FROZEN));
|
||||||
|
} finally { proto.update = update; v.destroy(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const hidden of [true, false]) {
|
||||||
|
test(`骨刺: ${hidden ? 'A' : 'B'} idle, move and death; a DIE snapshot selects its form before dying`, async () => {
|
||||||
|
const flags = hidden ? UF.STEALTH : 0, suffix = hidden ? 'A' : 'B';
|
||||||
|
const v = await enemy(BONE), w = await enemy(BONE);
|
||||||
|
try {
|
||||||
|
v.sync(sample(flags, ANIM.MOVE), 1); assert.equal(clip(v), `Move_${suffix}`);
|
||||||
|
v.sync(sample(flags, ANIM.IDLE), 2); assert.equal(clip(v), `Idle_${suffix}`);
|
||||||
|
v.die(); assert.equal(clip(v), `Die_${suffix}`);
|
||||||
|
w.sync(sample(hidden ? 0 : UF.STEALTH), 1);
|
||||||
|
w.sync(sample(flags, ANIM.DIE), 2); assert.equal(clip(w), `Die_${suffix}`);
|
||||||
|
w.sync(sample(hidden ? 0 : UF.STEALTH, ANIM.DIE), 3);
|
||||||
|
frames(w, 10);
|
||||||
|
assert.equal(clip(w), `Die_${suffix}`, 'death keeps its chosen form');
|
||||||
|
assert.equal(w.form, hidden ? null : 'revealed');
|
||||||
|
} finally { v.destroy(); w.destroy(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
describe('转译基底·α forms (user report after 0.1.0, #5)', () => {
|
describe('转译基底·α forms (user report after 0.1.0, #5)', () => {
|
||||||
const TR = 'enemy_10081_mpplai';
|
const TR = 'enemy_10081_mpplai';
|
||||||
test('every clip of the three forms exists in the skeleton; the change clips last the official 2 s; the manifest keeps the original form', () => {
|
test('every clip of the three forms exists in the skeleton; the change clips last the official 2 s; the manifest keeps the original form', () => {
|
||||||
|
|||||||
Reference in new issue
Block a user