From 072e1231ae7d60cea370bfcdb914bf270cb1b543 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 22:58:19 +0800 Subject: [PATCH 1/3] feedback2 WA: a knocked-out operator facing UP falls and lies with its Front model, the Back model holds still meanwhile (GitHub issue #25) 131 of the 135 Back skeletons have no Die clip, so SpineActor.die() changed nothing and the attack / skill / idle loop went on under the redeploy ring. UnitView._wantsBack() now wants the Back model only while the unit stands (or when its Back skeleton has a Die clip of its own); die() / revive() mark the view and its update swaps the model in place after the frame's events (a death and a redeploy in one frame load nothing). The Front model replays the fall (held end for a unit already down), gets the knocked-down grey, and the redeploy brings the Back model back with the deploy clip carried over. A skeleton without a Die clip holds its idle's first frame. --- public/js/render/spine.js | 30 ++- public/js/render/units.js | 70 +++++- test/render/issue25-downback.test.js | 308 +++++++++++++++++++++++++++ 3 files changed, 396 insertions(+), 12 deletions(-) create mode 100644 test/render/issue25-downback.test.js diff --git a/public/js/render/spine.js b/public/js/render/spine.js index 4ac86919..b9b40a87 100644 --- a/public/js/render/spine.js +++ b/public/js/render/spine.js @@ -12,7 +12,8 @@ // clip plays once at its own speed, then base // 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) +// die() die clip once (callers fade out afterwards); a skeleton without one holds its +// idle clip's first frame (GitHub issue #25: the attack loop went on) // stunned (setBase('stun')) stun clip, or the current track frozen at timeScale 0 // setForm(roles, change, end) another clip set of the skeleton (an enemy's mode), after a change clip // (no attack cuts the change clip short); `end` = { clip, in, roles? }: a @@ -341,24 +342,45 @@ export class SpineActor { if (this.has(d) && d !== this.roles.idle) { this.mode = 'deploy'; this._play(d, false, { mix: 0 }); + this.deployAt = this.clock; this.deployUntil = this.clock + this.dur(d); } } - /** Play the death clip; returns its duration (0 when there is none). */ + /** Seconds into the deploy clip while it plays, else null (a model swapped mid-deploy carries it over: units.js). */ + deployElapsed() { + return this.mode === 'deploy' ? Math.max(0, this.clock - (this.deployAt || 0)) : null; + } + + /** The skeleton's death clip, or null. */ + dieClip() { + const d = this.roles.die || (this.has('Die') ? 'Die' : null); + return d && this.has(d) ? d : null; + } + + /** + * Play the death clip; returns its duration (0 when there is none). A skeleton without one (131 of the 135 Back + * models, GitHub issue #25; a few idle-only summons and enemies) stops whatever looped — an attack, a skill or the idle — + * and holds the first frame of its idle clip (frozen when it has none either): a dead unit never goes on attacking. A + * knocked-out operator shows its fall with the Front model instead (render/units.js _wantsBack). + */ die() { if (this.dead) return 0; this.dead = true; this.frozen = false; this.mode = 'die'; - const d = this.roles.die || (this.has('Die') ? 'Die' : null); - if (d && this.has(d)) { this._play(d, false, { mix: 0.05 }); return this.dur(d); } + const d = this.dieClip(); + if (d) { this._play(d, false, { mix: 0.05 }); return this.dur(d); } + const idle = this.has(this.roles.idle) ? this.roles.idle : this.has('Idle') ? 'Idle' : null; + if (idle) this._play(idle, false, { mix: 0.1, timeScale: 0 }); + else this.frozen = true; return 0; } /** Revive (redeploy after death). */ revive() { this.dead = false; + this.frozen = false; this.mode = 'base'; this.skillOn = false; this._play(this._baseName(), true); diff --git a/public/js/render/units.js b/public/js/render/units.js index e88c92b9..db52cc3b 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -19,12 +19,15 @@ // the legacy `facing` ±1): the Back model for UP (when one exists, research 07 §5.5), the Front model for RIGHT and // DOWN, mirrored for LEFT; the orange ground wedge "›" of prep board pieces points along `dir` (rotated on the ground // plane). `setDir(dir)` re-orients a live view (swapping Front ⇄ Back without a fallback flash). Enemies flip by the -// sign of their horizontal velocity (with hysteresis). +// sign of their horizontal velocity (with hysteresis). A dead or knocked-out operator facing UP falls and lies with its +// Front model — 131 of the 135 Back skeletons have no Die clip (GitHub issue #25: the Back model went on with its +// attack loop under the redeploy ring) — and stands up again with the Back model (`_wantsBack`, `_syncModel`). // // Knocked-out operators (user playtest #4 item 9, b.snap `down`): `setDown([id, respawnAt, respawnTime, state, row, // col])` keeps a dead operator on the tile it lies on (row / col: where it fell, or its home — sim Battle._layBody, // player report F5 after 0.1.0) in its knocked-down pose — the Spine Die clip played once and held on its last -// frame (the collapsed / kneeling pose with closed eyes), slightly greyed — with a redeploy ring above its head: +// frame (the collapsed / kneeling pose with closed eyes; the Front model's for one facing UP, see above), slightly +// greyed — with a redeploy ring above its head: // a dark disc, a mint arc filling as the respawn timer runs and the seconds left; once the timer is done and it still // waits, a full amber ring with "DP" (not enough DP) or a red ring with "!" (its tile is taken). `onDeploy` (the // redeploy) plays the deploy clip and restores the normal look; `setDown(null)` on a dead view lets it fade out. @@ -81,6 +84,17 @@ export function enemyModelScale(rec) { const k = Number(rec && rec.modelScale); return Number.isFinite(k) && k > 0.05 && k < 20 ? k : 1; } +/** + * Seconds of the death clip of a manifest Spine entry (`anims.die`, else a 'Die' clip, as SpineActor.dieClip; its + * `animations` duration), 0 when it has none — 131 of the 135 operator Back models (GitHub issue #25). + */ +export function dieClipDur(entry) { + const durs = entry && entry.animations && typeof entry.animations === 'object' ? entry.animations : null; + if (!durs) return 0; + const name = (entry.anims && typeof entry.anims.die === 'string' && entry.anims.die) || 'Die'; + const d = Object.hasOwn(durs, name) ? Number(durs[name]) : NaN; + return Number.isFinite(d) && d > 0 ? d : 0; +} /** World step (x = col, y = row) of a direction. */ export const DIR_STEP = Object.freeze({ UP: [0, 1], RIGHT: [1, 0], DOWN: [0, -1], LEFT: [-1, 0] }); const nowMs = () => (globalThis.performance ? globalThis.performance.now() : Date.now()); @@ -325,6 +339,7 @@ export class UnitView { this.body.addChild(this.fallback); this.actor = null; this.spineReady = false; + this._modelDirty = false; // died / stood up since the last frame: update() checks Front ⇄ Back (_syncModel) this._spineBusy = false; // a Spine load of this view is in flight this._spineTries = 0; // failed loads since the last model (SPINE_RETRY_MS) this._retryAt = 0; // when the next retry is due (ms, performance clock; 0 = none) @@ -381,7 +396,8 @@ export class UnitView { const a = this.ctx.assets; if (!a || !a.spineEntry || !a.spine) return; const id = this.info.spine || this.info.defId; - // Front/Back rule (research 07 §5.5 / 09 §1.2): Front facing right/down (mirrored for left), Back facing up. + // Front/Back rule (research 07 §5.5 / 09 §1.2): Front facing right/down (mirrored for left), Back facing up — while + // standing (a knocked-out operator lies with the model that has a fall: _wantsBack). const back = this._wantsBack(); const entry = id ? a.spineEntry(id, { back }) : null; if (!entry || this.ctx.settings?.quality === 'low' && this.isEnemy && !this.isBoss && this.ctx.crowded?.()) return; @@ -427,8 +443,11 @@ export class UnitView { this._releaseEntry(entry); return; } - // a Front ⇄ Back swap (setDir) replaces the previous model in place: no fallback diamond in between + // a Front ⇄ Back swap (setDir, or knocked out / standing again: _syncModel) replaces the previous model in place: + // no fallback diamond in between; a deploy clip it was playing goes on on the new model (an operator facing UP + // redeployed: its Back model takes over from the Front model it lay down with) const swap = !!this.actor; + const deployed = swap && this.alive ? this.actor.deployElapsed() : null; if (swap) this._dropActor(); this.actor = actor; this._actorEntry = entry; @@ -448,6 +467,7 @@ export class UnitView { } else { 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); } } }, () => { if (req === this._spineReq) this._spineBusy = false; @@ -602,11 +622,27 @@ export class UnitView { else if (this.dir === 'LEFT') this.setDir('RIGHT'); } - /** Whether this unit should show its Back model (facing UP and a Back model exists). */ + /** + * Whether this unit should show its Back model: facing UP and a Back model exists — while it stands. A dead or + * knocked-out one keeps the Back model only when that skeleton has a fall of its own (4 of the 135 Back models); the + * others have no Die clip, so the Front model shows the fall and the held knocked-down pose (GitHub issue #25: the + * Back model went on with its attack loop under the redeploy ring) [ASSUMED: no source says which model the official + * client lies down with; a Back skeleton without a Die clip cannot show a fall]. + */ _wantsBack() { const a = this.ctx.assets; const id = this.info.spine || this.info.defId; - return !this.isEnemy && this.dir === 'UP' && !!id && !!a && typeof a.hasBack === 'function' && !!a.hasBack(id); + if (this.isEnemy || this.dir !== 'UP' || !id || !a || typeof a.hasBack !== 'function' || !a.hasBack(id)) return false; + return this.alive || dieClipDur(typeof a.spineEntry === 'function' ? a.spineEntry(id, { back: true }) : null) > 0; + } + + /** + * Load the model the unit wants when it is not the one shown or loading (Front ⇄ Back: its direction, or it went down / + * stood up again). A swap replaces the model in place (_acquireSpine: no diamond in between; a fall or a deploy clip + * goes on where it is). Nothing before the first model was asked for (no manifest entry yet). + */ + _syncModel() { + if (this.entry && this._wantsBack() !== !!this.entryBack) this._loadSpine(); } /** @@ -623,7 +659,7 @@ export class UnitView { this.facing = d === 'LEFT' ? -1 : 1; this.visFacing = this.facing; if (this.imp) this.imp.dirty = true; - if (this.entry && this._wantsBack() !== !!this.entryBack) this._loadSpine(); + this._syncModel(); } _releaseEntry(entry) { @@ -637,6 +673,7 @@ export class UnitView { this.imp = null; } this._box = null; + this._tint = undefined; // the next model gets the current tint on its first frame (a knocked-down one its grey) const old = this.actor; this.actor = null; this.spineReady = false; @@ -705,11 +742,17 @@ export class UnitView { /** * The unit died: its Die clip plays, then it fades (unless it stays down, setDown). `instant`: it starts on the held * end of the clip — a unit that is already down (a field joined mid-battle, an operator entering 联防 knocked out). + * An operator facing UP whose Back model has no Die clip (GitHub issue #25) falls with its Front model: the swap is + * made by this frame's update (`_modelDirty` → _syncModel, after every event and snapshot of the frame, so a death + * and a redeploy in one frame — a 突袭 jump, a 不屈 revive, a backlog after a hidden tab — load nothing); meanwhile the + * Back model holds still (SpineActor.die), and the Front model's Die clip times the fade. */ die(instant = false) { if (!this.alive) return; this.alive = false; - const d = this.actor ? this.actor.die() : 0; + this._modelDirty = true; + let d = this.actor ? this.actor.die() : 0; + if (this.actor && !d) d = this._fallDur(); const rate = this.ctx.animRate?.() || 1; this.dieT = 0; this.dying = clamp(d / rate, 0.35, 1.6) + 0.55; @@ -717,8 +760,17 @@ export class UnitView { if (instant) { this.dieT = 30; if (this.actor) this.actor.update(30); } } + /** Seconds of the fall of the model the unit wants now (its manifest entry's Die clip), 0 without one. */ + _fallDur() { + const a = this.ctx.assets; + const id = this.info.spine || this.info.defId; + return a && typeof a.spineEntry === 'function' && id ? dieClipDur(a.spineEntry(id, { back: this._wantsBack() })) : 0; + } + + /** Standing again (a redeploy, a revive): an operator facing UP gets its Back model back (this frame's update). */ revive() { this.alive = true; + this._modelDirty = true; this.dying = 0; this.remove = false; this.alpha = 1; this.down = null; this.hp = this.maxHp; this.ghostHp = this.hp; @@ -761,6 +813,8 @@ export class UnitView { update(dt, cam, t) { if (this.destroyed) return; const P = this.P; + // the model for the state the frame's events and snapshot left (Front ⇄ Back when it went down / stood up: die, revive) + if (this._modelDirty) { this._modelDirty = false; this._syncModel(); } // a failed / timed-out model load is tried again once its wait is over (SPINE_RETRY_MS; frames only: never hidden) if (this._retryAt && nowMs() >= this._retryAt) { this._retryAt = 0; if (!this.actor && !this._spineBusy) this._loadSpine(true); } if (this.zTarget != null && this.z !== this.zTarget) { diff --git a/test/render/issue25-downback.test.js b/test/render/issue25-downback.test.js new file mode 100644 index 00000000..dae13b2e --- /dev/null +++ b/test/render/issue25-downback.test.js @@ -0,0 +1,308 @@ +// test/render/issue25-downback.test.js — GitHub issue #25 「部分干员被击倒后在部署状态时仍有攻击动作」, client side: an +// operator facing UP shows its Back model, and 131 of the 135 Back skeletons have no Die clip, so a knocked-out one went +// on with its looping attack / skill / idle clip under the redeploy ring (a front-facing one lay down). Now it falls and +// lies with its Front model (render/units.js _wantsBack / _syncModel), stands up again with the Back model (the deploy +// clip carried over), a Back skeleton with a fall of its own keeps it, and a skeleton without a Die clip holds still +// (render/spine.js SpineActor.die). Headless fake PIXI (test/render/fakepixi.js) with the real manifest entries +// (data/assets.json) and the real asset helpers (public/js/assets.js spineEntry / hasBackSpine). DESIGN §22.1. + +import { test, describe, 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 { spineEntry, hasBackSpine } from '../../public/js/assets.js'; +import { ANIM } from '../../shared/constants.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const M = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8')); +const CAPER = 'char_4100_caper'; // 跃跃: the operator of the report, no Die clip in its Back skeleton +const MINT = 'char_388_mint'; // one of the 4 Back skeletons with a Die clip + +let fake, UnitView, DOWN_STATE, DOWN_LOOK, dieClipDur, SpineActor; +before(async () => { + fake = installFakePixi(); + ({ UnitView, DOWN_STATE, DOWN_LOOK, dieClipDur } = await import('../../public/js/render/units.js')); + ({ SpineActor } = await import('../../public/js/render/spine.js')); +}); +after(() => fake.restore()); + +const tick = () => new Promise((r) => setImmediate(r)); +const settle = async () => { await tick(); await tick(); }; +const cam = () => presetCamera('normal', { width: 1280, height: 720 }); +let clockT = 0; +const frames = (v, n, dt = 1 / 60) => { for (let i = 0; i < n; i++) { clockT += dt; v.update(dt, cam(), clockT); } }; + +/** The real manifest behind the asset store's helpers; acquire / release counted per skeleton. */ +function store() { + const refs = new Map(); + let acquired = 0; + return { + refs, acquired: () => acquired, + picture: () => null, + image: async () => null, + hasBack: (id) => hasBackSpine(M, id), + spineEntry: (id, o) => spineEntry(M, id, o), + spine: { + acquire: async (e) => { acquired++; refs.set(e.skel, (refs.get(e.skel) || 0) + 1); return { animations: Object.keys(e.animations || {}).map((name) => ({ name })) }; }, + release: (e) => { refs.set(e.skel, (refs.get(e.skel) || 0) - 1); }, + }, + }; +} +const held = (s) => [...s.refs].filter(([, n]) => n !== 0); + +async function op(id, dir, s = store(), extra = {}) { + const v = new UnitView(fakeViewCtx(fake.P, { assets: s, cam }), { id: 5, side: 'ally', kind: 'chess', defId: id, spine: id, tier: 1, x: 5, y: 10, maxHp: 1000, dir, ...extra }); + await settle(); + frames(v, 5); + return v; +} +const front = (id) => M.chars[id].spine.front; +const back = (id) => M.chars[id].spine.back; +const DOWN = (t = 1) => [5, t + 60, 60, DOWN_STATE.COUNTING, 10, 5]; + +describe('the manifest (data/assets.json)', () => { + test('most Back skeletons have no fall, every operator Front skeleton has one', () => { + const backs = Object.entries(M.chars).filter(([, c]) => c.spine?.back); + const withFall = backs.filter(([, c]) => dieClipDur(c.spine.back) > 0).map(([id]) => id).sort(); + assert.ok(backs.length >= 130, `${backs.length} Back skeletons`); + assert.deepEqual(withFall, ['char_388_mint', 'char_4064_mlynar', 'char_440_pinecn', 'char_602_cdfend']); + for (const [id, c] of Object.entries(M.chars)) assert.ok(dieClipDur(c.spine.front) > 0, `${id} Front has a Die clip`); + assert.equal(dieClipDur(back(CAPER)), 0); + assert.equal(dieClipDur(front(CAPER)), front(CAPER).animations.Die); + assert.equal(dieClipDur(null), 0); + assert.equal(dieClipDur({ anims: { die: null }, animations: { Die: 0.8 } }), 0.8, 'a Die clip the roles did not name'); + }); +}); + +describe('an operator facing UP knocked out (Back model without a Die clip)', () => { + test('mid-attack: the Back model stops at once, then the Front model falls and holds its Die pose under the ring', async () => { + const s = store(); + const v = await op(CAPER, 'UP', s); + assert.equal(v.entryBack, true); + assert.equal(v.actor.entry, back(CAPER), 'standing: the Back model'); + v.onAttack(null, 1, 'none'); + assert.equal(v.actor.current, 'Attack'); + const n0 = s.acquired(); + v.setDown(DOWN(), 1); // b.snap down: knocked out mid-attack + assert.equal(v.alive, false); + // until the Front model is there the Back model holds the first frame of its idle — no attack loop + assert.equal(v.actor.entry, back(CAPER)); + assert.equal(v.actor.current, 'Idle'); + assert.equal(v.actor.spine.state.tracks[0].timeScale, 0, 'held still'); + v.onAttack(null, 1.2, 'none'); // a late 'atk' of the same frame + assert.equal(v.windUp(0.1, 'none'), false); + assert.equal(v.actor.current, 'Idle', 'nothing restarts the attack'); + assert.equal(s.acquired(), n0, 'nothing loads before the frame\'s update'); + frames(v, 1); + assert.equal(v.entryBack, false, 'the Front model requested'); + assert.equal(v.actor.current, 'Idle', 'the Back model holds still while it loads'); + await settle(); + assert.equal(v.actor.entry, front(CAPER), 'the Front model in place'); + assert.equal(v.actor.current, 'Die'); + assert.equal(v.swapT, 1, 'swapped in place (no fallback diamond)'); + frames(v, 180); // 3 s down + assert.equal(v.actor.current, 'Die', 'the Die pose is held'); + assert.ok(v.actor.clock >= front(CAPER).animations.Die, 'past the end of the fall'); + assert.ok(v._downRing.root.visible, 'under the redeploy ring'); + assert.equal(v.actor.spine.tint, DOWN_LOOK.tint, 'the Front model greyed like any knocked-down one'); + assert.equal(v.remove, false); + assert.deepEqual(held(s), [[front(CAPER).skel, 1]], 'the Back model released, the Front one held'); + v.destroy(); + await settle(); + assert.deepEqual(held(s), [], 'every acquire paired with a release'); + }); + + test('facing RIGHT it falls with its Front model as before (one model, no swap)', async () => { + const s = store(); + const v = await op(CAPER, 'RIGHT', s); + v.onAttack(null, 1, 'none'); + const a = v.actor; + v.setDown(DOWN(), 1); + assert.equal(a.current, 'Die'); + frames(v, 2); + await settle(); + assert.equal(v.actor, a, 'the same model'); + assert.equal(s.acquired(), 1); + }); + + test('the redeploy: the Back model again, its deploy clip carried over from the Front model', async () => { + const s = store(); + const v = await op(CAPER, 'UP', s); + v.setDown(DOWN(), 1); + frames(v, 1); + await settle(); + frames(v, 60); + assert.equal(v.actor.entry, front(CAPER)); + v.onDeploy(); // b.ev 'deploy' (timed, 不屈, 阿戈尔: every redeploy) + assert.equal(v.alive, true); + assert.equal(v.actor.current, 'Start', 'the Front model starts the deploy clip'); + frames(v, 3); + assert.equal(v.entryBack, true, 'the Back model requested'); + await settle(); + assert.equal(v.actor.entry, back(CAPER), 'standing facing UP: the Back model'); + assert.equal(v.actor.current, 'Start', 'the deploy clip goes on on the Back model'); + assert.equal(v.actor.mode, 'deploy'); + assert.ok(Math.abs(v.actor.deployElapsed() - 3 / 60) < 1e-9, 'where the Front model was in it'); + frames(v, 70); + assert.equal(v.actor.current, 'Idle'); + assert.equal(v.actor.spine.tint, 0xffffff, 'not greyed any more'); + v.onAttack(null, 9, 'none'); + assert.equal(v.actor.current, 'Attack', 'it attacks again'); + assert.equal(v.down, null); + v.destroy(); + await settle(); + assert.deepEqual(held(s), []); + }); + + test('the snapshot\'s DIE anim (no die event) and a revive from the snapshot go the same way', async () => { + const s = store(); + const v = await op(CAPER, 'UP', s); + const sample = (anim, hp) => ({ x: 5, y: 10, hp, maxHp: 1000, sp: 0, spMax: 0, flags: 0, anim, vx: 0, vy: 0 }); + v.sync(sample(ANIM.DIE, 0), 2); + frames(v, 1); + await settle(); + assert.equal(v.actor.entry, front(CAPER)); + assert.equal(v.actor.current, 'Die'); + v.revive(); // render/app.js syncBattle: alive again without an event + v.sync(sample(ANIM.IDLE, 1000), 3); + frames(v, 1); + await settle(); + assert.equal(v.actor.entry, back(CAPER)); + assert.equal(v.actor.current, 'Idle'); + }); + + test('a death and a redeploy in one frame (不屈, a 突袭 jump, a backlog after a hidden tab) load nothing', async () => { + const s = store(); + const v = await op(CAPER, 'UP', s); + const a = v.actor, n0 = s.acquired(); + v.die(); // b.ev 'die' … + v.onDeploy(); // … and 'deploy' handed out in the same frame + frames(v, 2); + await settle(); + assert.equal(s.acquired(), n0, 'no model loaded'); + assert.equal(v.actor, a, 'the Back model stays'); + assert.equal(v.actor.current, 'Start'); + assert.equal(v.alive, true); + }); + + test('a view built for one already down (a field entered late, a re-entry after a hidden tab) lies with the Front model at the end of its fall', async () => { + const s = store(); + const v = new UnitView(fakeViewCtx(fake.P, { assets: s, cam }), { id: 5, side: 'ally', kind: 'chess', defId: CAPER, spine: CAPER, tier: 1, x: 5, y: 10, maxHp: 1000, dir: 'UP' }); + v.setDown(DOWN(30), 30, true); // render/app.js syncBattle: made on the spot, `fresh` + frames(v, 1); + await settle(); + assert.equal(v.actor.entry, front(CAPER)); + assert.equal(v.actor.current, 'Die'); + assert.ok(v.actor.clock >= front(CAPER).animations.Die, 'the held end, no fall'); + assert.deepEqual(held(s), [[front(CAPER).skel, 1]], 'the Back model asked for at construction was released unseen'); + }); + + test('an operator entering 联防 knocked out (deploy + die FORCED_EXIT in one batch): the Front model\'s held pose', async () => { + const s = store(); + const v = new UnitView(fakeViewCtx(fake.P, { assets: s, cam }), { id: 5, side: 'ally', kind: 'chess', defId: CAPER, spine: CAPER, tier: 1, x: 5, y: 10, maxHp: 1000, dir: 'UP' }); + v.onDeploy(); + v.die(true); + v.setDown(DOWN(0.03), 0.03); + frames(v, 1); + await settle(); + assert.equal(v.actor.entry, front(CAPER)); + assert.equal(v.actor.current, 'Die'); + assert.ok(v.actor.clock >= front(CAPER).animations.Die); + }); + + test('withdrawn (no `down` entry): the Front model\'s fall times the fade, as for one facing RIGHT', async () => { + const up = await op(CAPER, 'UP'); + const right = await op(CAPER, 'RIGHT'); + up.die(); right.die(); + assert.ok(up.dieDur > 1.5 && up.dieDur === right.dieDur, `${up.dieDur} = ${right.dieDur}`); + frames(up, 1); + await settle(); + assert.equal(up.actor.current, 'Die'); + frames(up, 120); + assert.equal(up.remove, true, 'faded out'); + }); + + test('a Back skeleton with a fall of its own keeps it (薄绿)', async () => { + const s = store(); + const v = await op(MINT, 'UP', s); + const a = v.actor, n0 = s.acquired(); + v.setDown(DOWN(), 1); + frames(v, 2); + await settle(); + assert.equal(v.actor, a, 'the Back model stays'); + assert.equal(v.actor.entry, back(MINT)); + assert.equal(v.actor.current, 'Die'); + assert.equal(s.acquired(), n0); + }); +}); + +describe('the safety net: a skeleton without a Die clip (SpineActor.die)', () => { + const actor = (entry) => new SpineActor({ animations: Object.keys(entry.animations).map((name) => ({ name })) }, entry); + + test('holds the first frame of its idle; no attack, skill, form or stun moves it; a revive plays again', () => { + const a = actor(back(CAPER)); + a.attack(1); + assert.equal(a.current, 'Attack'); + assert.equal(a.die(), 0, 'no clip to time'); + assert.equal(a.current, 'Idle'); + assert.equal(a.spine.state.tracks[0].timeScale, 0); + assert.equal(a.mode, 'die'); + a.attack(1); + assert.equal(a.windUp(1, 0.1), false); + a.setSkill(true); + a.setBase('move'); a.setBase('stun'); + a.setForm({ idle: 'Skill_Idle' }, 'Skill_Begin'); + a.update(5); + assert.equal(a.current, 'Idle'); + assert.equal(a.spine.state.tracks[0].timeScale, 0); + a.setForm(null); + a.revive(); + assert.equal(a.current, 'Idle'); + assert.equal(a.spine.state.tracks[0].timeScale, 1, 'moving again'); + assert.equal(a.spine.state.tracks[0].loop, true); + }); + + test('without an idle clip either it freezes; a revive thaws it; a skeleton with a Die clip plays it', () => { + const a = actor({ anims: { idle: null, attack: { begin: null, loop: 'Attack', end: null } }, animations: { Attack: 1 } }); + a.attack(1); + a.die(); + assert.equal(a.frozen, true); + assert.equal(a.current, 'Attack', 'the pose it had'); + a.revive(); + assert.equal(a.frozen, false); + const b = actor(front(CAPER)); + assert.equal(b.die(), front(CAPER).animations.Die); + assert.equal(b.current, 'Die'); + assert.equal(b.spine.state.tracks[0].timeScale, 1); + }); +}); + +describe('enemies and summons are unchanged', () => { + test('an enemy dies on its own model (no swap); an idle-only summon facing UP holds still and fades', async () => { + const s = store(); + const e = new UnitView(fakeViewCtx(fake.P, { assets: s, cam }), { id: 7, side: 'enemy', kind: 'enemy', defId: 'enemy_1007_slime', spine: 'enemy_1007_slime', tier: 1, x: 8, y: 9, maxHp: 100, facing: -1 }); + await settle(); + const ea = e.actor; + e.die(); + frames(e, 2); + await settle(); + assert.equal(e.actor, ea); + assert.equal(e.actor.current, 'Die'); + const tok = 'token_10017_skadi2_dedant'; // 浊心斯卡蒂's 海嗣: Idle / Skill / Start, no Die clip, no Back model + const t = new UnitView(fakeViewCtx(fake.P, { assets: s, cam }), { id: 8, side: 'ally', kind: 'token', defId: tok, spine: tok, tier: 1, x: 4, y: 9, maxHp: 100, dir: 'UP' }); + await settle(); + assert.equal(t.entryBack, false); + const ta = t.actor, n0 = s.acquired(); + t.onAttack(null, 1, 'none'); + t.die(); + assert.equal(ta.current, 'Idle'); + assert.equal(ta.spine.state.tracks[0].timeScale, 0, 'held still while it fades'); + frames(t, 90); + assert.equal(t.remove, true); + assert.equal(s.acquired(), n0, 'no model loaded'); + assert.equal(t.actor, ta); + }); +}); From e08f22447ba5ac875233884c13cad037e93efd0b Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 22:58:26 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feedback2=20WA:=20DESIGN=20=C2=A722.1=20(Ba?= =?UTF-8?q?ck-facing=20operators=20play=20their=20fall,=20GitHub=20issue?= =?UTF-8?q?=20#25),=20docs/ASSETS.md=20model=20choice=20and=20die=20role?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ASSETS.md | 5 +++-- docs/DESIGN.md | 15 +++++++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/ASSETS.md b/docs/ASSETS.md index ff1380b1..785d13a2 100644 --- a/docs/ASSETS.md +++ b/docs/ASSETS.md @@ -197,7 +197,8 @@ Roles = { attackDown: Clip|null, // _Down variants (target below the unit) skill: SkillClip|null, // for the chess's default skill (primary index) skills?: { [index]: SkillClip }, // when the char is used with several default skills (backups) - die: string|null, // null ⇒ use the Front model's Die (Back lacks it) or fade out 0.5 s + die: string|null, // null ⇒ a Back model gives way to the Front model's Die (DESIGN §22.1); any other + // skeleton holds its idle's first frame while it fades out move: Clip|null, // enemies: Move_Begin|Move_Start + Move_Loop|Move + Move_End → Run_* stun: Clip|null // null ⇒ freeze the track (timeScale 0) } @@ -237,7 +238,7 @@ Not mapped (clip names ambiguous): “自在”, “巨大的丑东西”, 主 is not known) keep their manifest clips. Other renderer rules from research 07 §5.4–5.5: -- **Choosing the model:** Front when the unit faces right or down; Front mirrored when facing left; Back when facing up. +- **Choosing the model:** Front when the unit faces right or down; Front mirrored when facing left; Back when facing up — while it stands: a dead or knocked-out operator falls and lies with the Front model unless its Back skeleton has a Die clip of its own (131 of the 135 have none; DESIGN §22.1, GitHub issue #25). - **Attack speed:** set the attack `timeScale` to `duration / attackInterval`. - **Model size:** every skeleton is drawn at one `UNIT.modelScale` (render/style.js, 320 skeleton units per tile), which stands for the official standard. The official client also scales each enemy model in its battle prefab: the Graphic / FaceSwitcher / Spine transforms multiply to 0.27 for most enemies and for the operators' battle skins, but not for all of them. For example, 威龙 is 0.16, 妖怪 0.20 and 青铜镜 0.6. The skeletons themselves carry no such scale, because every enemy SkeletonDataAsset uses 0.01. So an enemy is drawn × data/enemies.json `modelScale` (its prefab's product ÷ 0.27, see docs/DATA.md; user playtest #6: 威龙 used to be drawn 1.35× a 妖怪 instead of 1.08×), and its HP bar sits on that model: at its setup-pose bounds' height × the same factors, or, for a skeleton without bounds, at the chibi headroom × `modelScale` (bosses 2.2 tiles). `tools/local-extract/enemy_scales.py` reads the products from a local client, and `tools/build-data.mjs MODEL_SCALES` keeps them. - **Enemy aliases:** `enemies[id].spineAliasOf` means the model belongs to another enemy. Two cases: diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 41c932cb..4adb0e3f 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1462,9 +1462,20 @@ Two public GitHub issues after 0.1.0, both client-only. Normative lines rewritte TBD-22-intro -### 22.1 Back-facing operators play their fall (GitHub #25) +### 22.1 Back-facing operators play their fall (GitHub #25) — `render/units.js _wantsBack / _syncModel / die / revive / _acquireSpine / _dropActor / dieClipDur`, `render/spine.js SpineActor.die / dieClip / deployElapsed` -TBD-22.1 +- **Seen** (public issue #25, "部分干员被击倒后在部署状态时仍有攻击动作"; the screenshot: 跃跃 seen from behind under its redeploy ring, still in its attack pose, while 拉普兰德 beside it lies down; the reporter also saw it on 拉普兰德, 斯卡蒂 and 德克萨斯, "并非稳定出现"). **Real** in 0.1.0 and 0.1.1, drawing only (the sim had the operator down; it never attacked): an operator deployed facing UP is drawn with its Back model (§9, research 07 §5.5), and 131 of the mode's 135 Back skeletons (data/assets.json) have no Die clip — only 薄绿, 玛恩纳, 松果 and 预备干员-重装 have one, while every operator's Front skeleton has one. Not every time: only operators facing UP. +- **Official**: PRTS 卫戍协议/帮助 says a knocked-out operator stays on its tile as a "倒地干员" (§21.24); which model the official client lies it down with is in no source. The project's asset plan already had the rule — research 07 §5.4 "`die`: `Die` (if the Back model lacks it, swap to the Front model for the death animation)", docs/ASSETS.md Roles `die: null` — but the renderer never implemented it. [ASSUMED] the official client does the same (a Back skeleton without a Die clip cannot show a fall), and the four Back skeletons with a Die clip fall with it. +- **Cause**: `UnitView._wantsBack()` ignored whether the unit stands; `SpineActor.die()` found no clip, returned 0 and changed nothing, so the attack / skill / idle loop playing at the knock-out went on under the ring for the whole respawn timer. +- **Now**: + - `_wantsBack()`: facing UP with a Back model, while the unit stands; a dead or knocked-out one keeps the Back model only when that skeleton has a Die clip (`dieClipDur` of its manifest entry), so the other 131 fall and lie with the Front model (unmirrored, as facing UP). + - `die()` and `revive()` mark the view, and its next `update()` — after every event and the snapshot of that frame (render/app.js) — runs `_syncModel()`: the model the state wants is loaded when it is neither shown nor loading, and swapped in place like a direction change (no avatar diamond in between). A death and a redeploy handed out in the same frame load nothing: a 突袭 jump (retreat + redeploy in one tick), a 不屈 revive (from the death hook), a backlog delivered after a hidden tab. + - The Front model replays the state: the Die clip where it would be (`dieT`), a knocked-down one holding its end; `die(true)` and a view built for a unit already down start on the held end (the 联防 entry `FORCED_EXIT`, a field entered late, the re-entry from the field meta after an overflowed hidden-tab backlog). Its first frame gets the knocked-down grey (`_dropActor` drops the cached tint; a swapped-in model used to keep the previous model's tint state). A withdrawn operator facing UP (no `down` entry) fades like one facing RIGHT: the Front model's Die clip times the fade (`_fallDur`). + - The redeploy (a `deploy` event: timed, 不屈, 阿戈尔 — every `Battle._deploy` — or a revive seen only in the snapshot) brings the Back model back; a deploy clip the Front model is playing goes on on the Back model where it is (`SpineActor.deployElapsed`). + - Safety net: until the Front model is there, or when it fails to load, the Back model holds still — `SpineActor.die()` on a skeleton without a Die clip stops whatever looped and holds the first frame of its idle clip (frozen when it has none either), and nothing restarts an attack on a dead actor. The 20 summon / enemy skeletons without a Die clip (e.g. 浊心斯卡蒂's 海嗣) now fade holding still instead of looping [ASSUMED look]. + - Unchanged: operators facing RIGHT / DOWN / LEFT, enemies and summons (no Back models in the manifest: `_wantsBack` is false for them), the redeploy ring, the sim. Nothing changes in render/interp.js or battle/runner.js: `die` / `deploy` are state events every client stage keeps (runner `keepsState`, game.js `keepEarly`, interp `isCosmeticEvent`), and b.snap `down` / `fieldMeta` rebuild the view of an operator already down; both now reach the model swap. +- [ASSUMED]: the Front model for the fall and the knocked-down pose (above); the Back skeleton's own Die clip when it has one; the held first idle frame of a skeleton without a Die clip. +- Tests: `test/render/issue25-downback.test.js` (fake PIXI with the real manifest and asset helpers: the manifest counts; an UP knock-out mid-attack — the Back model holds still, then the Front model's Die is held under the ring, greyed, every acquire paired with a release; RIGHT unchanged; the redeploy back to the Back model with the deploy clip carried over; the snapshot's DIE anim and a snapshot revive; a death and a redeploy in one frame load nothing; a view built for an operator already down and the 联防 entry lie on the held end; a withdrawn one fades like a RIGHT one; 薄绿 keeps its Back Die clip; the SpineActor safety net; enemies and summons unchanged); `downring`, `feedback1f-down-tile`, `unitview`, `forms`, `test/ui/unitFacing.test.js` unchanged. ### 22.2 突袭's idle redeploy lands where it can attack (#51) From 234d2c571409d4d946dd60e28b06f46db7af70fa Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 23:09:33 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feedback2=20WA:=20the=20five=20operators=20?= =?UTF-8?q?of=20GitHub=20issue=20#25=20(=E8=B7=83=E8=B7=83,=20=E6=8B=89?= =?UTF-8?q?=E6=99=AE=E5=85=B0=E5=BE=B7,=20=E7=BC=84=E9=BB=98=E5=BE=B7?= =?UTF-8?q?=E5=85=8B=E8=90=A8=E6=96=AF,=20=E6=96=AF=E5=8D=A1=E8=92=82,=20?= =?UTF-8?q?=E5=BE=B7=E5=85=8B=E8=90=A8=E6=96=AF)=20pinned=20in=20the=20tes?= =?UTF-8?q?t=20and=20named=20in=20DESIGN=20=C2=A722.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/DESIGN.md | 4 ++-- test/render/issue25-downback.test.js | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 4adb0e3f..fb6f449a 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1464,7 +1464,7 @@ TBD-22-intro ### 22.1 Back-facing operators play their fall (GitHub #25) — `render/units.js _wantsBack / _syncModel / die / revive / _acquireSpine / _dropActor / dieClipDur`, `render/spine.js SpineActor.die / dieClip / deployElapsed` -- **Seen** (public issue #25, "部分干员被击倒后在部署状态时仍有攻击动作"; the screenshot: 跃跃 seen from behind under its redeploy ring, still in its attack pose, while 拉普兰德 beside it lies down; the reporter also saw it on 拉普兰德, 斯卡蒂 and 德克萨斯, "并非稳定出现"). **Real** in 0.1.0 and 0.1.1, drawing only (the sim had the operator down; it never attacked): an operator deployed facing UP is drawn with its Back model (§9, research 07 §5.5), and 131 of the mode's 135 Back skeletons (data/assets.json) have no Die clip — only 薄绿, 玛恩纳, 松果 and 预备干员-重装 have one, while every operator's Front skeleton has one. Not every time: only operators facing UP. +- **Seen** (public issue #25, "部分干员被击倒后在部署状态时仍有攻击动作"; the screenshot: 跃跃 seen from behind under its redeploy ring, still in its attack pose, while 拉普兰德 beside it lies down; the reporter also saw it on 拉普兰德, 异德 (缄默德克萨斯), 斯卡蒂 and 德克萨斯, "并非稳定出现" — all five have a Back skeleton without a Die clip). **Real** in 0.1.0 and 0.1.1, drawing only (the sim had the operator down; it never attacked): an operator deployed facing UP is drawn with its Back model (§9, research 07 §5.5), and 131 of the mode's 135 Back skeletons (data/assets.json) have no Die clip — only 薄绿, 玛恩纳, 松果 and 预备干员-重装 have one, while every operator's Front skeleton has one. Not every time: only operators facing UP. - **Official**: PRTS 卫戍协议/帮助 says a knocked-out operator stays on its tile as a "倒地干员" (§21.24); which model the official client lies it down with is in no source. The project's asset plan already had the rule — research 07 §5.4 "`die`: `Die` (if the Back model lacks it, swap to the Front model for the death animation)", docs/ASSETS.md Roles `die: null` — but the renderer never implemented it. [ASSUMED] the official client does the same (a Back skeleton without a Die clip cannot show a fall), and the four Back skeletons with a Die clip fall with it. - **Cause**: `UnitView._wantsBack()` ignored whether the unit stands; `SpineActor.die()` found no clip, returned 0 and changed nothing, so the attack / skill / idle loop playing at the knock-out went on under the ring for the whole respawn timer. - **Now**: @@ -1475,7 +1475,7 @@ TBD-22-intro - Safety net: until the Front model is there, or when it fails to load, the Back model holds still — `SpineActor.die()` on a skeleton without a Die clip stops whatever looped and holds the first frame of its idle clip (frozen when it has none either), and nothing restarts an attack on a dead actor. The 20 summon / enemy skeletons without a Die clip (e.g. 浊心斯卡蒂's 海嗣) now fade holding still instead of looping [ASSUMED look]. - Unchanged: operators facing RIGHT / DOWN / LEFT, enemies and summons (no Back models in the manifest: `_wantsBack` is false for them), the redeploy ring, the sim. Nothing changes in render/interp.js or battle/runner.js: `die` / `deploy` are state events every client stage keeps (runner `keepsState`, game.js `keepEarly`, interp `isCosmeticEvent`), and b.snap `down` / `fieldMeta` rebuild the view of an operator already down; both now reach the model swap. - [ASSUMED]: the Front model for the fall and the knocked-down pose (above); the Back skeleton's own Die clip when it has one; the held first idle frame of a skeleton without a Die clip. -- Tests: `test/render/issue25-downback.test.js` (fake PIXI with the real manifest and asset helpers: the manifest counts; an UP knock-out mid-attack — the Back model holds still, then the Front model's Die is held under the ring, greyed, every acquire paired with a release; RIGHT unchanged; the redeploy back to the Back model with the deploy clip carried over; the snapshot's DIE anim and a snapshot revive; a death and a redeploy in one frame load nothing; a view built for an operator already down and the 联防 entry lie on the held end; a withdrawn one fades like a RIGHT one; 薄绿 keeps its Back Die clip; the SpineActor safety net; enemies and summons unchanged); `downring`, `feedback1f-down-tile`, `unitview`, `forms`, `test/ui/unitFacing.test.js` unchanged. +- Tests: `test/render/issue25-downback.test.js` (fake PIXI with the real manifest and asset helpers: the manifest counts; the five operators of the report lie down with their Front model's Die; an UP knock-out mid-attack — the Back model holds still, then the Front model's Die is held under the ring, greyed, every acquire paired with a release; RIGHT unchanged; the redeploy back to the Back model with the deploy clip carried over; the snapshot's DIE anim and a snapshot revive; a death and a redeploy in one frame load nothing; a view built for an operator already down and the 联防 entry lie on the held end; a withdrawn one fades like a RIGHT one; 薄绿 keeps its Back Die clip; the SpineActor safety net; enemies and summons unchanged); `downring`, `feedback1f-down-tile`, `unitview`, `forms`, `test/ui/unitFacing.test.js` unchanged. ### 22.2 突袭's idle redeploy lands where it can attack (#51) diff --git a/test/render/issue25-downback.test.js b/test/render/issue25-downback.test.js index dae13b2e..45e8e325 100644 --- a/test/render/issue25-downback.test.js +++ b/test/render/issue25-downback.test.js @@ -115,6 +115,21 @@ describe('an operator facing UP knocked out (Back model without a Die clip)', () assert.deepEqual(held(s), [], 'every acquire paired with a release'); }); + test('every operator of the report (跃跃, 拉普兰德, 异德 = 缄默德克萨斯, 斯卡蒂, 德克萨斯) lies down with its Front model\'s Die', async () => { + for (const id of [CAPER, 'char_140_whitew', 'char_1028_texas2', 'char_263_skadi', 'char_102_texas']) { + assert.equal(dieClipDur(back(id)), 0, `${id}: no Die clip in its Back skeleton`); + const v = await op(id, 'UP'); + v.onAttack(null, 1, 'none'); + v.setDown(DOWN(), 1); + frames(v, 1); + await settle(); + frames(v, 120); + assert.equal(v.actor.entry, front(id), id); + assert.equal(v.actor.current, front(id).anims.die, id); + v.destroy(); + } + }); + test('facing RIGHT it falls with its Front model as before (one model, no swap)', async () => { const s = store(); const v = await op(CAPER, 'RIGHT', s);