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:
Sukvii authored and sganggs committed 2026-10-08 19:23:49 +08:00
1 parent ca671f2fd0
commit bfef34ffa3
4 files changed
+256 -1

No files matched your search

+5 -1
View File
@@ -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`);
+35
View File
@@ -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';
+25
View File
@@ -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();
+191
View File
@@ -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', () => {