The sim displaces at once (battle/displacement.js), so the snapshots only show the destination: a ≤ 2.5-tile push was lerped over one snapshot interval (3 frames, 0.58 tiles in one frame — frame-loop repro over a real battle), a longer pull snapped. render/app.js now starts UnitView.slideTo as soon as the interval it samples ends on the snapshot that carries the destination (a `displace` fx still queued up to SnapshotBuffer.nextSnapT); the view holds where it stood until that snapshot is shown, then eases into the sampled position under a constant deceleration (v0 = 2D/T, a = v0/T, T = 0.14·√D in 0.12–0.45 s — ours, [ASSUMED]: no source gives the official timing; docs/research/13 has the official impulse-and-friction classes). A unit that dies on the way finishes the slide and lies where the push put it. Death: DIE_CLIP_MAX 1.6 → 8 s (data/assets.json: 234 enemy Die clips, the longest 7.97 s, 25 over 1.6 s), and a flyer holds FLY_HOVER until the fade tail (DIE_FADE_TAIL 0.55 s). Substantially @xcdoge's code (PR #380: the slide, the death timing, research 13 / 14, most tests), authored as theirs. Changed on review: the corpse no longer goes back to where it stood (the PR's test asserted that; the sim's body is at the destination); the PR's pre-pass scanned (renderT − 0.5, renderT] after processEvents had taken those events, so it never fired and the slide started a frame before landing — it now looks ahead to the next snapshot and holds the view until then; the slide eases into the live sample instead of the fixed destination (no jump back onto an enemy that walked on); each step uses its mean velocity (the end-of-step speed ran 9 % short, then jumped); the constants' comments say the timing is ours; research 14 no longer cites a sweep script and a data guard that were not in the PR. Golden: unchanged (client only). Tests: test/render/unitview.test.js 35/35 (slide: deceleration, idempotence, facing, snapshots in flight, friction, √D, corpse lies where pushed, hold-then-ease without a landing jump, a never-shown slide handed back; the app.js frame order over a real battle: a 1.7-tile push over ≥ 8 frames with every step < 0.35 tiles, a push-kill's body staying at the destination; death: a dying flyer's lift, every Die clip within DIE_CLIP_MAX and a 7.97 s clip scheduled whole); test/sim/displace-fx.test.js 1/1; render/interp 16/16; docs tests pass.
110 lines
7.5 KiB
Markdown
110 lines
7.5 KiB
Markdown
# 14 · Enemy death animations: the art never drops the body, and 25 clips were being cut short
|
||
|
||
A player report — "a dying 帝国炮火先兆者 seems to drop at the very end", then "what about the other enemies'
|
||
animations?" — answered by reading the client's own data and its code.
|
||
|
||
## 0. TL;DR
|
||
|
||
- **No enemy animation moves the whole body.** Playing all 251 enemies' clips and sampling every bone's world position,
|
||
the **root bone's vertical displacement is 0.000 tiles in every Die (234), Idle (235), Move (234) and Attack (215)
|
||
clip**. Limbs move (up to 2.16 tiles for “帝国的甲胄”'s arms); the body never does. Every vertical placement is the
|
||
renderer's: the fly lift, the model's own art hang (`.skel bounds.y`) and nothing else.
|
||
- **A dying flyer dropped at the wrong moment.** The client removes its fly offset in `CharacterAnimator.OnFinish` —
|
||
when the finish state ends, not when the unit dies — so a drone stays aloft through the death animation and drops as
|
||
it goes. The remake dropped it the instant `alive` went false, so the death clip played on the ground. Fixed: the lift
|
||
is held for the Die clip and released inside the fade tail (§4).
|
||
- **25 death clips were cut short by a 1.6 s cap** — 盐风主教昆图斯's 7.97 s clip played 20 % of itself, 鼠王 37 %,
|
||
杰斯顿·威廉姆斯 40 %, and 22 more (§3). The cap is now 8 s, above every clip in the data.
|
||
- 17 manifest entries have no Die clip at all; they are the ground items/collectibles (§5).
|
||
|
||
## 1. How the clips were read [DATA]
|
||
|
||
A sweep script of the PR author (PR #380, not in this repository) reads `data/assets.json` + the extracted `.skel` files
|
||
and **plays** each clip: `Skeleton` + `AnimationState`, stepped at 30 Hz, `state.apply(skeleton)` and
|
||
`skeleton.updateWorldTransform()` after every step, recording the range of `skeleton.y` (the root) and of every
|
||
`bone.worldY`. Ranges are converted to tiles with the model's `modelScale` (320 skeleton units = one tile at scale 1 —
|
||
the conversion the flying-visuals write-up uses for the same models).
|
||
|
||
Do **not** try to read the timelines directly: in this build of `@pixi-spine/runtime-3.8` they hold packed numeric
|
||
arrays (`timeline.frames[0]` is a number, not `{time, x, y}`), so a keyframe walk silently reports zero motion — the
|
||
first attempt here did, and 270 Attack clips "had no rotation". Sampling the played skeleton is layout-agnostic and
|
||
matches what the client draws.
|
||
|
||
## 2. Nothing translates the body [DATA]
|
||
|
||
| clip | read | root displacement > 0.01 tiles |
|
||
|---|---|---|
|
||
| Die | 234 | **0** |
|
||
| Idle / Default | 235 | **0** |
|
||
| Move / Walk | 234 | **0** |
|
||
| Attack | 215 | **0** |
|
||
|
||
The largest single-bone travel inside a Die clip is 2.16 tiles (“帝国的甲胄”, a flailing limb), then 1.40 (节日气球),
|
||
1.29 (“独轮车玩具”), 1.18 (业余竞演者) — normal animation, not a fall. A "the body sinks" effect can therefore only come
|
||
from the code paths that place the model (`FLY_HOVER`, the `zTarget` ease, `lift`, and the artist's `bounds.y`).
|
||
|
||
## 3. Death clip durations, and the 1.6 s cap that cut them [DATA]
|
||
|
||
234 Death clips: shortest **0.50 s**, median **1.00 s**, longest **7.97 s**. `render/units.js` used
|
||
`clamp(d / rate, 0.35, 1.6)`, so **25 clips were trimmed** (11 %), every one of them a boss or elite:
|
||
|
||
| enemy | Die clip | played by the old cap |
|
||
|---|---|---|
|
||
| 盐风主教昆图斯 | 7.97 s | 1.60 s (20 %) |
|
||
| 鼠王 | 4.33 s | 37 % |
|
||
| 杰斯顿·威廉姆斯 | 4.00 s | 40 % |
|
||
| W / 泥岩 | 3.33 s | 48 % |
|
||
| 假想敌:胄 ×2 | 3.10 s | 52 % |
|
||
| “巨大的丑东西” | 2.83 s | 56 % |
|
||
| 腐败骑士 | 2.67 s | 60 % |
|
||
| 高普尼克 ×2 / 卢西恩,“猩红血钻” / 假想敌:管 ×2 | 2.33 s | 69 % |
|
||
| 假想敌:弦 | 2.30 s | 70 % |
|
||
| 雪孩子 / 伊利昂的木驮兽 / 鸭爵 ×2 / 纠缠藤蔓 / 凋零骑士 | 2.00 s | 80 % |
|
||
| 陷落雪祀 | 1.80 s | 89 % |
|
||
| “帝国的甲胄” / 底海滑动者 / 富营养的滑动者 | 1.67 s | 96 % |
|
||
|
||
The lower bound (0.35 s) trims nothing: the shortest real clip is 0.50 s. The client plays its clip out — its `OnFinish`
|
||
fires when the finish state ends — so the cap only ever made the remake's deaths shorter than the official's.
|
||
|
||
## 4. A dying flyer drops at the end of the clip, like the client [DATA]
|
||
|
||
The client's fly offset is one constant (`Vector3(0, 0.35f, 0)` in `Torappu.Battle.CharacterAnimator`, applied by
|
||
`_SetFlyMountPointOffset` / `_SetFlyHitOffset` while a unit flies and negated when it lands; the remake draws 0.35 in
|
||
character space ≈ **1.3 tiles** as `FLY_HOVER` — the flying-visuals write-up has the full derivation). Its only direct
|
||
caller is `OnFinish` (#62924, call site
|
||
0x1805feb87), disassembled as `xor edx, edx; mov rcx, rbx; call _SetFlyMountPointOffset` — **arg 0**, the negation
|
||
branch — followed by clearing `[this+0x128]` and a tail call to another cleanup. `OnFinish` has no direct `call` site of
|
||
its own: it is a virtual lifecycle method (there is a `<>xLuaBaseProxy_OnFinish`), so the battle framework calls it
|
||
through the vtable when the character's **finish** state ends.
|
||
|
||
Since the Die clips carry no vertical motion (§2):
|
||
|
||
- **Client**: the drone stays aloft for the whole death animation and drops as the finish state ends.
|
||
- **Remake before**: `hoverTo = this.flying && this.alive ? FLY_HOVER : 0` — `die()` clears `alive` *before* the clip
|
||
plays, so every flyer sank to the ground inside ≈0.42 s (lerp τ = 1/6 s) and played its death clip **on the ground**.
|
||
- **Remake now**: the hover target is `this.alive || (this.dying > DIE_FADE_TAIL && !this.down)` — the flyer holds
|
||
`FLY_HOVER` for the whole clip (the view fades over the last `DIE_FADE_TAIL` = 0.55 s of `dying`) and drops inside
|
||
that fade, which is the client's `OnFinish` moment. A knocked-down (`down`) flyer still lies on the ground.
|
||
|
||
All 27 flying enemies of the data have a Die clip of 0.67–1.00 s, well inside the cap, so the cap change never touches
|
||
them; 帝国炮火先兆者 and 帝国炮火中枢先兆者 are unexceptional (Die 1.00 s, root displacement 0.000).
|
||
|
||
## 5. The 17 entries with no Die clip [DATA]
|
||
|
||
未装配刀片 · 防护背心 · 冲击式施术单元 (deployable equipment) and 木制瑞印 · 红木瑞印 · 小说卷轴 · 诗画卷轴 · 青铜镜 ·
|
||
黄铜镜 · 木制镇纸 · 红木镇纸 · 青瓷茶器 · 彩瓷茶器 · 铜矛头 · 铁矛头 · 铜灯盘 · 铁灯盘 (the collectible/relic props). They take
|
||
the documented frozen-first-idle-frame path (see the `die()` comment in `render/spine.js`), which is right for an item
|
||
that is picked up rather than killed.
|
||
|
||
## 6. Implementation (2026-10-06, client-only)
|
||
|
||
- `public/js/render/units.js`: new `DIE_FADE_TAIL = 0.55` (the fade tail `die()` already appended as a literal) and
|
||
`DIE_CLIP_MAX = 8`; `dying = clamp(d / rate, 0.35, DIE_CLIP_MAX) + DIE_FADE_TAIL`; the hover target as in §4; the three
|
||
`0.55` literals in `die()` / the knockdown branch / the fade now use `DIE_FADE_TAIL`.
|
||
- `test/render/unitview.test.js`: a dying flyer keeps `FLY_HOVER` through the Die clip and is under a quarter of it
|
||
inside the fade (a walker stays at 0); a data guard — no enemy Die clip of `data/assets.json` exceeds `DIE_CLIP_MAX`
|
||
(234 clips, the longest 7.97 s: the §3 durations re-checked from the manifest when this was merged in 0.2.2) — and
|
||
the 7.97 s clip is scheduled whole, the view still on screen 5 s in.
|
||
- The root-displacement sweep of §2 is the PR author's measurement (their script is not in the repository).
|
||
- Client-only: the server serves `units.js` per request, so a hard refresh (Ctrl+F5) applies it — no restart.
|