diff --git a/docs/ASSETS.md b/docs/ASSETS.md index caf5f83a..bb60d646 100644 --- a/docs/ASSETS.md +++ b/docs/ASSETS.md @@ -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 `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 -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`); +- 假想敌:骨刺 (`*_A` / `*_B`, above); - 转译基底·α'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 (`A_Die`, then `B_*`; `B_Revive`); diff --git a/public/js/render/spine.js b/public/js/render/spine.js index 8025f17b..c344c92e 100644 --- a/public/js/render/spine.js +++ b/public/js/render/spine.js @@ -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); } + /** + * 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'). */ _change(clip) { if (this.mode !== 'change') this.stunWanted = this.mode === 'stun'; diff --git a/public/js/render/units.js b/public/js/render/units.js index 4e38aaf7..31788b5c 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -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 * once first: * - 掠海漂移体 (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, * 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); @@ -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 本体. * 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 clipSet = (idle, move, die, attack = null) => Object.freeze({ idle, deploy: idle, die, move: loop(move), @@ -274,6 +283,9 @@ export const FORMS = Object.freeze({ enemy_2025_syufo: Object.freeze({ 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({ 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') }), @@ -600,6 +612,8 @@ export class UnitView { if (this.flags & UF.SKILL) this.actor.setSkill(true); this.actor.setBase(this._baseFromAnim()); 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; } @@ -747,6 +761,17 @@ export class UnitView { const prevFlags = this.flags; this.flags = s.flags | 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 ((prevFlags ^ this.flags) & UF.SKILL) this.setSkill(!!(this.flags & UF.SKILL)); if (this.anim === ANIM.DIE && this.alive) this.die(); diff --git a/test/render/forms.test.js b/test/render/forms.test.js index 66b4e6ff..94723378 100644 --- a/test/render/forms.test.js +++ b/test/render/forms.test.js @@ -19,6 +19,9 @@ import { installFakePixi, fakeViewCtx } from './fakepixi.js'; import { presetCamera } from '../../public/js/render/projection.js'; import { UF, ANIM } from '../../shared/constants.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 assets = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8')); @@ -133,6 +136,194 @@ async function enemy(id, info = {}) { 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)', () => { 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', () => {