feedback7: merge fb7-render — #382 赤刃明霄陈 S3 sword-qi drawn (sim chen3Wave fx), #381 enemy attack recovery held in interpolation, #380 push / pull slide (corpse stays where pushed; same-step kills finish the slide), #384 eye masks, 香槟炸弹 HP bar hidden, #246 an unwound attack plays its whole clip
- Conflicts with the merged fb7-hud (both added display-only snapshot lists): server/sim/battle/events.js, types/core.js,
public/js/render/interp.js, docs/SIM.md, docs/design/network.md — union of ammo / wolves / neg and stand / standCut.
- Grok review: MERGE AFTER FIXES — the HIGH finding (same-step push kill) fixed on the branch (7a33bc7e).
- Golden: diy-073 / diy-074 (fx counts only; union OK); broad node --test 5442 (5441 pass, 0 fail, 1 skipped); lint 0
errors; typecheck.
This commit is contained in:
commit
cf98f4c3f8
27 files changed
+1812
-53
No files matched your search
+14
-1
@@ -1296,7 +1296,7 @@ Unknown subprofessions fall back to the profession default (test `professions.te
|
||||
|
||||
## 9. Wire format (snapshot.js, DESIGN §8.2)
|
||||
|
||||
- `snapshot()` → `{ fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total, dps?, boss?, down?, elem?, ammo?, wolves?, neg? }`.
|
||||
- `snapshot()` → `{ fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total, dps?, boss?, down?, elem?, ammo?, wolves?, neg?, stand?, standCut? }`.
|
||||
`sp/spMax` show remaining duration/ammo as a draining bar while a timed skill is active (ammo: `ammoLeft / ammoMax`, the
|
||||
activation's real total — 拉特兰's and 逃犯引渡手续's extra bullets included, community report #35). Units in DIE state stay 0.8 s.
|
||||
Active finite zero-SP duration skills display `timeLeft/duration` using `duration` as `spMax`; after end they show
|
||||
@@ -1313,6 +1313,19 @@ Unknown subprofessions fall back to the profession default (test `professions.te
|
||||
`mem.shadows` of `mem.wolfCapacity`) and `negView` (`unit.negFill()`, set by 斩业星熊's T1 业火: the 我执 pool as a share of its cap);
|
||||
UnitInfo `ammoSkill` marks an ally whose skill is an ammo magazine (DESIGN §8.2).
|
||||
`fieldMeta()` lists the knocked-out operators too (a client joining mid-battle shows them; DESIGN §18.3).
|
||||
`stand: [[id, until]]` (only when non-empty) = the game time each enemy's attack recovery ends (`atkStandUntil`: it
|
||||
stands for the rest of its attack clip, §1.2 `attackStand`) — enemies alive, deployed, visible, neither feared nor stunned;
|
||||
display metadata the battle never reads (PR #381). When `until` falls between two snapshots the renderer holds the older
|
||||
position until then and interpolates the rest of the interval (HP / SP keep the whole interval); a newer snapshot that
|
||||
moved before `until` is interpolated linearly; while the newest snapshot still holds a stand nothing is extrapolated
|
||||
(the next one confirms the move). A snapshot without the field interpolates over the whole interval, a teleport or a
|
||||
redeploy still snaps first, and nothing is inferred from the attack animation. `standCut: [[id, at]]` (only when
|
||||
non-empty) = the latest game time each enemy's recovery was cut or ignored (a displacement — 失衡 —, fear, a stun, hiding,
|
||||
leaving the field), one per enemy: an interval whose newer snapshot carries a cut inside it is interpolated linearly even
|
||||
when the older stand ended inside it too, and an older cut leaves a later stand alone. It covers the enemies listed in
|
||||
`units` (the 0.8 s death window included), never a hidden or unlisted one. The newest snapshot showing a death, a stun
|
||||
(freeze, sleep, 浮空 included) or a block is not extrapolated until a later snapshot shows it moving again; the
|
||||
extrapolated velocity uses the same moving span as the interpolation (the stand is not in its denominator).
|
||||
- `drainEvents()` tuples: `['spawn', UnitInfo]` (first appearance), `['deploy', id]` (every (re)deploy and 【移动】 — the client plays the deploy and its interpolation snaps instead of sliding), `['atk', src, tgt, projKind]`
|
||||
(`none|arrow|bolt|bomb|lob|orb|drone|enemy|boomerang|droneBomb|chain|chainHeal`; a boomerang's way back has no event — the
|
||||
renderer flies it back to the thrower at `BOOMERANG_RETURN_SPEED`; an enemy's `profile.shot` may name another kind,
|
||||
|
||||
@@ -23,7 +23,14 @@ S→C:
|
||||
- NOTE: game time in `b.snap`/`b.ev` is carried as **`gt`** (the frame's `t` is the message type).
|
||||
- Active finite zero-SP timed skills display `sp=timeLeft`, `spMax=duration` and `UF.SKILL`; after end they display
|
||||
`0/0`. Snapshot display capacity does not change runtime SP/cost.
|
||||
- `b.snap` — `{ fieldId, gt, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]] , dp, killed, total, down?, elem?, ammo?, wolves?, neg? }` 20 Hz (a countdown summon's `hp` is maxHp × the share of its life left, §25.16.5). Optional, only when non-empty (§18.3): `down: [[id, respawnAt (game s), respawnTime (s), state, row, col]]` — knocked-out operators waiting to redeploy, `state` 0 counting | 1 timer done, waiting for DP | 2 timer done, its tile taken — a safeguard, no ally deploys there (sim/constants.js `DOWN_STATE`); `row, col` = the tile it lies on and comes back on (§21.24); `elem: [[id, element, fill, cooldownEnd (game s), cooldown (s)]]` — the one gauge a unit shows: the fullest (ties by the official element id 神经 < 侵蚀 < 灼燃 < 凋亡), fill with 2 decimals rounded DOWN and kept at 0.01–0.99 outside a burst (§20.8); during a 爆发冷却 the bursting element with fill 1 and the cooldown numbers (else 0, 0); the three HP-bar readouts (0.2.2, GitHub PR #303 / #378 / #383, display only — no sim state reads them, the nine-field tuples and the SP fraction an ammo skill still puts in them are unchanged; `render/interp.js` keeps them per snapshot and `sample()` hands them out from the older snapshot like `flags`, so they step with the skill flag; a malformed entry or an id the snapshot does not list is dropped, a snapshot without the list clears the readout): `ammo: [[id, rounds left, rounds in the magazine]]` — an ally's running ammo skill, whole rounds, the magazine being the activation's real total (extra bullets and refills included; 隐现's S2 holds 14, skill_table `attack@trigger_time`) — the client draws yellow cells in place of the SP bar (the count, not a pixel copy of the official magazine widget — its art is in no game data we have), and UnitInfo `ammoSkill: true` (an ally whose skill is an ammo magazine, known from its first appearance) tells it to draw no standing skill aura; `wolves: [[id, 狼影 left, the talent's maximum]]` — 伺夜's 狼群 (`mem.shadows` / `mem.wolfCapacity`, both the placed and the automatic pack), one diamond per slot under the HP bar (plain diamonds: no official counter art is known, the portrait of PR #378 was not taken), nothing in the 战术点形态; `neg: [[id, fill]]` — the share (0.01–1) of its cap a negative-HP pool holds (斩业星熊's 我执, `unit.negFill`), drawn as the red bar over her drained HP bar. These show only for a living, deployed, visible unit and only when it has one
|
||||
- `b.snap` — `{ fieldId, gt, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]] , dp, killed, total, down?, elem?, ammo?, wolves?, neg?, stand?, standCut? }` 20 Hz (a countdown summon's `hp` is maxHp × the share of its life left, §25.16.5). Optional, only when non-empty (§18.3): `down: [[id, respawnAt (game s), respawnTime (s), state, row, col]]` — knocked-out operators waiting to redeploy, `state` 0 counting | 1 timer done, waiting for DP | 2 timer done, its tile taken — a safeguard, no ally deploys there (sim/constants.js `DOWN_STATE`); `row, col` = the tile it lies on and comes back on (§21.24); `elem: [[id, element, fill, cooldownEnd (game s), cooldown (s)]]` — the one gauge a unit shows: the fullest (ties by the official element id 神经 < 侵蚀 < 灼燃 < 凋亡), fill with 2 decimals rounded DOWN and kept at 0.01–0.99 outside a burst (§20.8); during a 爆发冷却 the bursting element with fill 1 and the cooldown numbers (else 0, 0); the three HP-bar readouts (0.2.2, GitHub PR #303 / #378 / #383, display only — no sim state reads them, the nine-field tuples and the SP fraction an ammo skill still puts in them are unchanged; `render/interp.js` keeps them per snapshot and `sample()` hands them out from the older snapshot like `flags`, so they step with the skill flag; a malformed entry or an id the snapshot does not list is dropped, a snapshot without the list clears the readout): `ammo: [[id, rounds left, rounds in the magazine]]` — an ally's running ammo skill, whole rounds, the magazine being the activation's real total (extra bullets and refills included; 隐现's S2 holds 14, skill_table `attack@trigger_time`) — the client draws yellow cells in place of the SP bar (the count, not a pixel copy of the official magazine widget — its art is in no game data we have), and UnitInfo `ammoSkill: true` (an ally whose skill is an ammo magazine, known from its first appearance) tells it to draw no standing skill aura; `wolves: [[id, 狼影 left, the talent's maximum]]` — 伺夜's 狼群 (`mem.shadows` / `mem.wolfCapacity`, both the placed and the automatic pack), one diamond per slot under the HP bar (plain diamonds: no official counter art is known, the portrait of PR #378 was not taken), nothing in the 战术点形态; `neg: [[id, fill]]` — the share (0.01–1) of its cap a negative-HP pool holds (斩业星熊's 我执, `unit.negFill`), drawn as the red bar over her drained HP bar. These show only for a living, deployed, visible unit and only when it has one
|
||||
Optional, only when non-empty (PR #381; display metadata, SIM §9): `stand: [[id, until]]` — the game time an enemy's
|
||||
attack recovery ends (`atkStandUntil`; enemies alive, deployed, visible, neither feared nor stunned): when it falls
|
||||
between two snapshots the renderer holds the position until then and interpolates the rest (HP / SP unchanged), and
|
||||
extrapolates nothing while the newest snapshot still holds a stand; `standCut: [[id, at]]` — the latest time each
|
||||
listed enemy's recovery was cut or ignored (失衡, fear, stun, hiding, leaving): an interval with a cut inside it stays
|
||||
linear. A newest snapshot showing a death, a stun or a block is not extrapolated. Old snapshots without them
|
||||
interpolate linearly; nothing is inferred from the attack animation.
|
||||
- `b.ev` — `{ fieldId, ev: [ ... ] }` event tuples: `['spawn', UnitInfo]`, `['atk', srcId, tgtId, projKind]`, `['dmg', tgtId, amount, type]`, `['heal', tgtId, amount]`, `['skill', id, on:0|1]`, `['die', id, reason]` (reason `'killed'`, `'retreat'`, `'expired'` …; only `'killed'` plays an operator's knock-down sound), `['status', id, key, on]`, `['fx', kind, x, y, extra]` (`'hitCap' {id, n}` = a cancelled 限伤 hit, drawn as nothing, §20.12; `extra.form` = the unit's model form from then on (an enemy's mode, a 傀儡师's 替身 §22.11) — `shared/protocol.js fxForm`, state like a spawn: never dropped by the client, §21.4), `['layer', playerId, bondId, n]`, `['bounty', playerId, coins]`, `['deploy', id]`
|
||||
- `m.toast {kind, text}` · `m.ticker {text, id, type, priority, playerId}` (the strip plays the highest priority first, §21.10) · `m.emote {playerId, id}` · `m.result {...}`
|
||||
- `m.unitStats {seq, round, units: [unitStatsEntry]}` — the answer to `g.unitStats` (`seq` echoed): every own board operator / summon by uid with `{id, uid, defId, hp, alive, maxHp, atk, def, res, interval, blockCnt, moveSpeed, range?, base: {…same}}` (shared/protocol.js `unitStatsEntry`; `range` = an ally's live range grid, facing RIGHT, never a target-selection grid, §21.16) as its next battle starts (§18.5)
|
||||
|
||||
@@ -19,6 +19,8 @@ Tags: **[DATA]** client game data · **[VERIFIED]** official or wiki rule text,
|
||||
| `07-assets.md` / `07-assets.json` | Verified URL patterns and resolved URLs for avatars, portraits, skills, bond/item/band icons, UI, Spine (ops + enemies), BGM/SFX, fonts; build plan. Addendum: spot check 40/40 OK. | `operators[*]`, `enemies[*]`, `bonds`, `items`, `bands`, `autochessUi`, `audio` |
|
||||
| `11-limits-official.md` | Official limits read from the client (2.7.71 il2cpp) and the community: bond layers cap at **999** per bond (`MAX_GARRISON_STACK`, `AddBondCount` = min(L + n, 999)); in boss battles outside training a single hit of **≥ 300000** on a leader (`IsBossEnemy` = an `autoChessData.bossInfoDict` enemyId) is **cancelled** ("限伤", `MAX_BATTLE_DAMAGE`); implemented in DESIGN §20.12. | `shared/constants.js BOND_LAYER_CAP / BOSS_HIT_LIMIT` |
|
||||
| `12-flying-visuals-official.md` | Official flying visuals and model placement, read from the client (PR #211 by @xcdoge; ported into 0.2.0 by the owner's decision of 2026-10-06): the fly offset is the single model-independent constant **0.35** (`CharacterAnimator..ctor` stores `Vector3(0,0.35,0)` at field +0x114; `_SetFlyMountPointOffset` / `_SetFlyHitOffset` add it while flying, negate it on landing) — in the client's *character* space, whose unit is the standard prefab scale 0.27, so **0.35 / 0.27 ≈ 1.3 tiles**, confirmed by pixel-measuring an official screenshot (1.2–1.4). `FLY_HOVER` 0.32 → **1.3**, flat (no per-model term). The same sweep found two model quirks: `modelScaleY` 1.263 (帝国炮火先兆者 pair, vertically stretched) and `mirrorX` (木制瑞印, mirrored prefab). | `public/js/render/units.js FLY_HOVER / enemyModelScaleY`, `data/enemies.json`, `tools/local-extract/enemy_model_offsets.py` |
|
||||
| `13-knockback-official.md` | Push / pull (推拉) in the official client (PR #380 by @xcdoge; written in Chinese): the knockback abilities (`Knockback`, `KnockBackWithDirection`, `KnockBackWithCharacterDirection`, `DragTowardSource`) carry an impulse (`m_force`) and a velocity decaying under friction (`m_friction*`, the tile's `additionalFriction` — `GrasslandType` NORMAL_LAND / ICE_LAND / SLIME_LAND); the data has no `move_duration` / `move_distance` key (distances are emergent; 力度 comes from `skill_table` / `enemy_database`); every tile of this mode's 11 maps is normal land. Also noted, not modelled: wall bounces (`_bounceTimesAsDamageTimes`) and `unbalanceProtectDueTime`. The client's slide (`v0 = 2D/T`, `a = v0/T`, T = 0.14·√D in 0.12–0.45 s) is ours [ASSUMED: no source gives the official timing]. | `render/units.js DISPLACE_SLIDE / slideTo`, `render/app.js` `displace` fx |
|
||||
| `14-death-animations.md` | Enemy death animations (PR #380 by @xcdoge): the art never drops the body (the PR author's 234-clip sweep — no Die/Idle/Move/Attack clip translates the root), the 1.6 s death cap cut 25 clips short (盐风主教昆图斯 7.97 s) so it is 8 s, and a flying unit keeps its lift for the whole Die clip, dropping only inside the fade tail (the client removes its fly offset in `CharacterAnimator.OnFinish`). | `render/units.js DIE_CLIP_MAX / DIE_FADE_TAIL` |
|
||||
|
||||
## 2. Match structure
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
# 13 · 推拉(击退 / 拖拽)的官方实现:冲量 + 摩擦
|
||||
|
||||
这份文档回答两个问题:**官方推拉到底怎么动**,以及**摩擦是谁的参数**。结论全部来自官方客户端本体的
|
||||
`global-metadata.dat`(il2cpp 元数据)、官方 gamedata(社区镜像 Kengxxiao/ArknightsGameData)与官方 AB 资源包,
|
||||
并附上每一步的验证方式,便于复核。
|
||||
|
||||
## 0. 结论速览
|
||||
|
||||
| 问题 | 答案 |
|
||||
|---|---|
|
||||
| 官方怎么动 | **冲量 + 摩擦**:给被推者一个力(`m_force` / `m_forceVectorX/Y`),速度**逐帧被摩擦衰减**直到停下 |
|
||||
| 有固定时长/距离吗 | **没有**:数据里不存在 `move_duration` / `move_distance` / `move_tiles` 键(`moveDuration`、`moveTiles` 属于 UI 类)⇒ 位移量是**涌现**的,这也是社区只能给出"**推力-位移近似对应表**"的原因 |
|
||||
| "力度"从哪来 | **官方数据**:`skill_table.json` 的 blackboard `force`(41 个技能带它),敌人侧在 `enemy_database.json`(8 条) |
|
||||
| 摩擦是谁的参数 | **地板(地形)+ 单位质量**,**不是**技能参数:`GrasslandType { NORMAL_LAND, ICE_LAND, SLIME_LAND }`,每格是一条 `GrasslandData { modeIdx, grasslandType, additionalFriction }`,运行时还会被 `UpdateFrictionFactor` / `RestoreFrictionFactor` 临时改写 |
|
||||
| 卫戍协议里有几种地板 | **实际只有普通地面**:11 张地图共 18 种格子字符,无冰面/黏液 ⇒ 本模式下摩擦恒定,滑行时长**不必按地板分档** |
|
||||
| 飞行目标怎么算 | **没有独立的空中摩擦**(`boatAirFactor` / `m_airBase` 属于"船"玩法):飞行用的是**同一套**公式(单位摩擦 × 所在格子的 `additionalFriction`);差别只在**运动模式**(`ENEMY_MOTION_TYPE_FLY`、`HEIGHT_CHANGED_TO_FLY_BLOCK_MOTION_MODE` ⇒ 忽略地面阻挡),以及**失衡免疫 / 静态刚体**的空中单位根本不可位移 |
|
||||
| 曲线形状 | 恒定减速 ⇒ `d/D = 2k − k²`(ease-out quad)、`T ∝ √D`;客户端用的 `T = 0.14·√D`(夹 0.12–0.45 s)是我们拟定的 [ASSUMED],没有来源给出官方时长 |
|
||||
|
||||
## 1. 官方类与字段(`global-metadata.dat`)
|
||||
|
||||
推拉能力的类簇(与 PRTS 的"推与拉"术语逐字对应):
|
||||
|
||||
```
|
||||
Knockback · KnockBackWithDirection · KnockBackWithCharacterDirection · KnockBackTargetsByRootTile · DragTowardSource
|
||||
_decreaseForceLevelWhenNotInDirection(PRTS"特殊修正"−2 力度)· _dontChangeFaceByDirection · _GetPushDir
|
||||
TOO_CLOSE_TOLERANCE_SQR(PRTS"< 0.25 格转径向")
|
||||
```
|
||||
|
||||
运动学字段(同一批类里):
|
||||
|
||||
```
|
||||
m_force · m_forceVectorX/Y · forceScale · m_mass · m_velocity · get_mass · get_rigidbody2D
|
||||
m_friction · m_frictionBase · m_frictionFactor · get_frictionFactor · m_frictionFactorAdditional · get_additionalFriction
|
||||
UpdateFrictionFactor · RestoreFrictionFactor · _UpdateFrictionFactorAdditional · RestoreCached…
|
||||
_ApplyForce · _UpdateVelocityAndPos · _UpdateAllUnitDeltaPos · _GetForceByDirectionAndDamage
|
||||
m_bounceFrictionFactor · bounce · bounceease · _bounceTimesAsDamageTimes · _bounceBBKey · _bounceConditionValidator
|
||||
unbalanceProtectDueTime
|
||||
```
|
||||
|
||||
⇒ `m_force*`(冲量)+ `m_velocity` / `_UpdateVelocityAndPos`(逐帧速度积分)+ `m_friction*`(衰减)就是"冲量 + 摩擦";
|
||||
`UpdateFrictionFactor` / `RestoreFrictionFactor`(改完还要还原)说明摩擦**由外部临时提供**,不是能力自带。
|
||||
|
||||
## 2. 地板(地形)摩擦
|
||||
|
||||
地板系统就在**卫戍协议自己的格子类**里(同簇可见 `TileState { SHARED, MY_SIDE, MATE_SIDE }`、
|
||||
`CooperateStartTile` / `CooperateEndTile` / `CoopFootballTile`、`get_modeIndex` / `get_tilePlayerSide` /
|
||||
`_OnPlayerDying` / `_OnPlayerRevive`):
|
||||
|
||||
```
|
||||
_grasslandDatas · GrasslandType { NORMAL_LAND, ICE_LAND, SLIME_LAND } · GrasslandData { modeIdx, grasslandType, additionalFriction }
|
||||
DynamicBuffQuickSandTile(流沙)· DynamicBuffTile · DirectionTile(带方向的格子)
|
||||
```
|
||||
|
||||
⇒ 每格可带 `additionalFriction`,即"**地板提供摩擦**"。另有冰面相关因子(`iceFactor`、`iceTile`、`OnIce`、`Frozen`)
|
||||
与"船/水/空"那套(`m_boatFrictionFactor`、`m_boatAirFactor`、`m_airBase`、`m_waterFlowForce*` —— 属于多索雷斯船玩法,
|
||||
**不是**飞行单位)。
|
||||
|
||||
### 卫戍协议实际用到哪些地板
|
||||
|
||||
`docs/research/05-maps.json`(11 张地图)里出现过的全部格子字符:
|
||||
|
||||
```
|
||||
# ×1776(墙/虚空) r ×580(道路) X ×477(分区隔断) f ×448(地面) a ×330 b ×250 A ×165
|
||||
p ×77(预览栏) d ×76(装置) S ×66(起点) I / E ×33(路线起/终) h ×24(手牌栏) O ×22 m ×12 i ×8 g ×8 R ×4
|
||||
```
|
||||
|
||||
**没有冰面或黏液** ⇒ 本模式下所有可站地板摩擦一致。
|
||||
|
||||
## 3. 力度来自官方数据(不是我们拟定的)
|
||||
|
||||
`excel/skill_table.json` 的 blackboard 里 **`force` 出现 357 次、41 个技能**,例如:
|
||||
|
||||
| 技能 | 名称 | 力度(按等级) |
|
||||
|---|---|---|
|
||||
| `skchr_weedy_1` / `_3` | 温蒂 炮管敲击 / 液氮大炮 | 0,1,2 / 1,2,3 |
|
||||
| `skchr_forcer_1` / `_2` | 见行者 护身射击 / 惊爆射击 | 0,1,2 / 1,2,3 |
|
||||
| `skchr_panda_1` / `_2` | 食铁兽 铁意六合 / 崩拳式 | 0,1,2 / 1,2,3 |
|
||||
| `skchr_rope_1` / `_2` | 崖心 勾爪发射 / 复式勾爪 | 0,1,2 / 0,1,2 |
|
||||
| `skchr_glady_1` / `_3` | 歌蕾蒂娅 缺水的大洋裂断 / 缺水的碎漩狂舞 | 0,1,2 / 0,1,2 |
|
||||
| `skchr_sqrrel_1` / `_2` | 阿消 水蒸气泵 / 高压水炮 | 0,1,2 / 0,1,2 |
|
||||
| `skchr_moeshd_2` | 可颂 磁爆锤 | 1,2,3 |
|
||||
| `sktok_archook` | 钩索 token 发射 | **11** |
|
||||
|
||||
同一张表里 **没有** `friction` / `mass` / `knockback` 键 ⇒ **力度是数据(可查表),摩擦与质量在引擎里**。
|
||||
|
||||
### 靠"职业"就能推拉的干员
|
||||
|
||||
`character_table.json` 的 `subProfessionId` 有两个专门职业,**普通攻击**即推/拉,力不在技能 blackboard 里:
|
||||
|
||||
- **`pusher`(推击手)**:阿消 · 见行者 · 食铁兽 · 温蒂
|
||||
- **`hookmaster`(钩索师)**:暗索 · 歌蕾蒂娅 · 杏仁 · 雪雉 · 崖心
|
||||
|
||||
其余靠技能/天赋推拉的还有:Misery、灰烬、黑键、燧石、溯光星源、可颂、焰狐龙梓兰、傀影、莱伊、罗宾、圣聆初雪、
|
||||
琳琅诗怀雅、乌尔比安,以及 `勾爪` / `暴风雪` / `喷拒器` / `猎潮的骑士` / `流形` / `weedy_token` 等 token 与装置。
|
||||
|
||||
## 4. 顺带查清的两条官方机制(尚未建模)
|
||||
|
||||
- **撞墙弹跳**:`bounce` / `bounceease` / `m_bounceFrictionFactor` / `_bounceBBKey`,并且 **`_bounceTimesAsDamageTimes`
|
||||
—— 弹跳次数计入伤害次数**(温蒂 / 推击手那类撞墙伤害的官方口径)。
|
||||
- **失衡保护**:`unbalanceProtectDueTime` —— 连续推拉之间有保护窗口。
|
||||
|
||||
## 5. 客户端的做法(本次实现)
|
||||
|
||||
`render/units.js`:模拟位移是**瞬时**的(`sim/battle/displacement.js` 在一次调用里走完 0.1 格步进),快照只带终点,
|
||||
所以客户端补上中间帧——`UnitView.slideTo(x, y, { friction })` 建立一次滑行,`update()` **逐帧做速度积分**
|
||||
(`v ← max(0, v − a·dt)`、`d ← d + v·dt`),终点由模拟给出(**权威**),`v0 = 2D/T`、`a = v0/T` 保证
|
||||
`T` 秒内正好走完 `D` 且速度归零 —— 即官方"冲量 + 摩擦"的等效形式;`T = 0.14·√D`(夹 0.12–0.45 s)是我们拟定的
|
||||
[ASSUMED](让画面按模拟的距离到位;没有来源给出官方时长),`friction` 是**地板因子**(本模式恒为 1;将来有冰面就乘
|
||||
< 1 ⇒ 滑得更久)。`render/app.js` 在插值器开始显示「以带终点的快照结尾」的那一段时就用 `displace` fx 启动滑行:
|
||||
到达那张快照之前画面停在原处,之后从原处减速滑入模拟的实时位置(终点,以及敌人随后继续走的路),落地不跳。
|
||||
**模拟层、协议、golden 一律未改。**
|
||||
|
||||
滑行途中死亡:滑行照常走完,尸体停在被推到的位置(模拟里它已经在那里;0.2.2 合入时改掉了原 PR 的「回到出发格」);
|
||||
倒地的单位交给 `setDown` 放到它的格子上。
|
||||
|
||||
## 6. 复核方式
|
||||
|
||||
1. 元数据:`node` 读 `global-metadata.dat`,搜上列标识符(本文件所有类名/字段名都是这样得到的)。
|
||||
2. 力度表:`zh_CN/gamedata/excel/skill_table.json` → 各技能的 `levels[].blackboard` 里 `key === 'force'`;
|
||||
敌人侧 `levels/enemydata/enemy_database.json`。
|
||||
3. 职业:`excel/character_table.json` 的 `subProfessionId ∈ {pusher, hookmaster}`。
|
||||
4. 地板:`docs/research/05-maps.json` 的格子字符统计(本文件 §2)。
|
||||
|
||||
## 7. 附:官方 AB 资源包的读取要点(踩过的坑)
|
||||
|
||||
供以后要读官方战斗配置时参考(本次为定位"摩擦是不是数据"而打通):
|
||||
|
||||
- `Flags` 里的 `0x200`(`UsesAssetBundleEncryption`)在本客户端上是**虚设**:blocksInfo 既没有 70 字节加密前导,
|
||||
也不需要任何 16 字节 key(暴力扫 `global-metadata.dat` 144 万候选、`GameAssembly.dll` 5 万候选均无命中即因此)。
|
||||
- blocksInfo 位置 = `align16(headerEnd)`;**块数据是连续存储**的(不要逐块对齐)。
|
||||
- 压缩:`comp 3` = 普通 LZ4 ✓;`comp 4 / 5` = Arknights 自定义 LZ4AK(nibble 交换后按 LZ4 解,
|
||||
终止条件是 `op === uncompressedSize`,**严格相等**)。
|
||||
- 包内官方配置是**带 `$type` 的 JSON**(如 `Torappu.Battle.Action.Nodes+SetBodyDirection`),
|
||||
内嵌类型树 ⇒ 字段名可直接读;但**摩擦不在其中**(本文档 §2 已说明原因)。
|
||||
@@ -0,0 +1,109 @@
|
||||
# 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.
|
||||
Reference in new issue
Block a user