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.
9.4 KiB
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. 复核方式
- 元数据:
node读global-metadata.dat,搜上列标识符(本文件所有类名/字段名都是这样得到的)。 - 力度表:
zh_CN/gamedata/excel/skill_table.json→ 各技能的levels[].blackboard里key === 'force'; 敌人侧levels/enemydata/enemy_database.json。 - 职业:
excel/character_table.json的subProfessionId ∈ {pusher, hookmaster}。 - 地板:
docs/research/05-maps.json的格子字符统计(本文件 §2)。
7. 附:官方 AB 资源包的读取要点(踩过的坑)
供以后要读官方战斗配置时参考(本次为定位"摩擦是不是数据"而打通):
Flags里的0x200(UsesAssetBundleEncryption)在本客户端上是虚设:blocksInfo 既没有 70 字节加密前导, 也不需要任何 16 字节 key(暴力扫global-metadata.dat144 万候选、GameAssembly.dll5 万候选均无命中即因此)。- blocksInfo 位置 =
align16(headerEnd);块数据是连续存储的(不要逐块对齐)。 - 压缩:
comp 3= 普通 LZ4 ✓;comp 4 / 5= Arknights 自定义 LZ4AK(nibble 交换后按 LZ4 解, 终止条件是op === uncompressedSize,严格相等)。 - 包内官方配置是带
$type的 JSON(如Torappu.Battle.Action.Nodes+SetBodyDirection), 内嵌类型树 ⇒ 字段名可直接读;但摩擦不在其中(本文档 §2 已说明原因)。