From 2669b6ab57d433850785c7548937f63ef9de2239 Mon Sep 17 00:00:00 2001 From: xcdoge Date: Thu, 8 Oct 2026 19:08:27 +0800 Subject: [PATCH] feedback7 R22-RENDER: a pushed / pulled enemy slides to where the sim put it, a dying flyer keeps its lift through the Die clip, a long Die clip plays whole (PR #380 by @xcdoge); golden: unchanged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/research/00-INDEX.md | 2 + docs/research/13-knockback-official.md | 134 ++++++++++++ docs/research/14-death-animations.md | 109 ++++++++++ public/js/render/app.js | 22 ++ public/js/render/interp.js | 10 + public/js/render/units.js | 108 +++++++++- test/render/unitview.test.js | 274 +++++++++++++++++++++++++ test/sim/displace-fx.test.js | 33 +++ 8 files changed, 686 insertions(+), 6 deletions(-) create mode 100644 docs/research/13-knockback-official.md create mode 100644 docs/research/14-death-animations.md create mode 100644 test/sim/displace-fx.test.js diff --git a/docs/research/00-INDEX.md b/docs/research/00-INDEX.md index 64a68fa8..0f4d19c7 100644 --- a/docs/research/00-INDEX.md +++ b/docs/research/00-INDEX.md @@ -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 diff --git a/docs/research/13-knockback-official.md b/docs/research/13-knockback-official.md new file mode 100644 index 00000000..19d20d2d --- /dev/null +++ b/docs/research/13-knockback-official.md @@ -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 已说明原因)。 diff --git a/docs/research/14-death-animations.md b/docs/research/14-death-animations.md new file mode 100644 index 00000000..a6c8ecf8 --- /dev/null +++ b/docs/research/14-death-animations.md @@ -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. diff --git a/public/js/render/app.js b/public/js/render/app.js index 32784604..40b505a7 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -1412,6 +1412,14 @@ export async function createFieldView(host, options = {}) { consumedIds.add(e[4].id); if (consumedIds.size > 200) consumedIds.delete(consumedIds.values().next().value); } + // 推拉: a push / pull (battle/displacement.js) moves the enemy inside one call; its `displace` fx carries the + // destination ['fx', 'displace', x, y, { id }] and the view slides there (UnitView.slideTo, PR #380) — normally + // started already by syncBattle's look-ahead, which this repeats harmlessly + if (e[1] === 'displace') { + const d = e[4] && typeof e[4] === 'object' ? e[4] : null; + const v = d && d.id != null ? views.get(d.id) : null; + if (v && v.slideTo) v.slideTo(Number(e[2]), Number(e[3])); + } // an enemy's mode change — the `form` of a sim setForm fx (shared/protocol.js fxForm: 掠海漂移体 → 爬行模式, user // playtest #5 item 1; 转译基底's forms, a 逐火 ember and its revival, the leaders' 重生, 守墓石像 — user report after // 0.1.0) — switches the view's clip set (UnitView.setForm; a kind without a clip set of that skeleton changes @@ -1464,10 +1472,24 @@ export async function createFieldView(host, options = {}) { if (best) { tiles.flashObjective(best[0], best[1]); board3d?.flashObjective(best[0], best[1]); } } + /** syncBattle's look-ahead: a queued `displace` fx ['fx', 'displace', x, y, { id }] starts that view's slide. */ + let slideAt = 0; + function startSlide(e) { + if (!Array.isArray(e) || e[0] !== 'fx' || e[1] !== 'displace') return; + const extra = e[4] && typeof e[4] === 'object' ? e[4] : null; + const v = extra && extra.id != null ? views.get(extra.id) : null; + if (v && v.slideTo) v.slideTo(Number(e[2]), Number(e[3]), { at: slideAt }); + } let renderT0Battle = null; // game time of the first rendered battle frame (spawn puffs skip the initial wave) let downSeq = 0; // syncBattle pass counter: a view still marked down after a pass left the `down` list function syncBattle(renderT) { if (renderT0Battle == null) renderT0Battle = renderT; + // A push / pull's slide starts as soon as the interval sampled below ends on the snapshot that carries the + // destination: its `displace` fx is due only when renderT reaches that snapshot, and by then sample() has already + // lerped (≤ teleport) or snapped the enemy most of the way — no frames in between (PR #380). The fx are still queued + // here (processEvents took only those ≤ renderT); the view holds until `at`, then eases into the sim's position. + slideAt = interp.nextSnapT(renderT); + if (slideAt > renderT) interp.forEachUpcoming(renderT, slideAt, startSlide); interp.sample(renderT, sample); for (const [id, s] of sample) { let v = views.get(id); diff --git a/public/js/render/interp.js b/public/js/render/interp.js index 1df0d9de..608fdbf9 100644 --- a/public/js/render/interp.js +++ b/public/js/render/interp.js @@ -283,6 +283,16 @@ export class SnapshotBuffer { return ans; } + /** + * Game time of the first snapshot after `time` (default renderT) — the newer end of the interval sample() interpolates + * — or NaN when there is none (extrapolating). render/app.js starts a push's slide while that interval is shown. + */ + nextSnapT(time = this.renderT) { + const s = this.snaps; + const i = this._indexAt(time) + 1; + return Number.isFinite(time) && i < s.length ? s[i].t : NaN; + } + /** * The `down` list (knocked-out operators waiting to redeploy, see header) of the snapshot shown at `time` (default * renderT) — the same snapshot sample() reads flags from — or null. diff --git a/public/js/render/units.js b/public/js/render/units.js index 4e38aaf7..a7de69ad 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -161,6 +161,36 @@ const RAISED_Z = 0.12; * ride the body (`hover`); range highlights are tiles. */ export const FLY_HOVER = 1.3; +/** + * A push / pull slide (推拉, PR #380 by @xcdoge): real seconds per √tile of travel. The sim displaces at once — + * server/sim/battle/displacement.js walks its 0.1-tile steps inside one call — so the snapshots only ever show the + * destination, and a pushed / pulled enemy appeared at the end of its path (a ≤ 2.5-tile push in one snapshot interval, + * a longer pull snapped). The view now slides there under a constant deceleration (UnitView.slideTo): the official + * knockback abilities (`Knockback`, `KnockBackWithDirection`, `DragTowardSource` in the client's metadata) carry an + * impulse (`m_force`) and a velocity decaying under friction (`m_friction`, the tile's `additionalFriction`), and the data + * has no per-skill duration (docs/research/13-knockback-official.md). The duration T = DISPLACE_SLIDE·√D, kept in + * 0.12–0.45 s, is ours [ASSUMED: a fit so the view arrives where the sim put it — no source gives the official + * timing]; v0 = 2D/T, a = v0/T then run the distance in exactly T. Purely visual: the sim's timing, damage, blocking and + * the golden digests are untouched. + */ +export const DISPLACE_SLIDE = 0.14; +/** + * Terrain friction factor of a slide: 1 = normal land, < 1 = slippery (ICE_LAND) slides longer. Every tile of this mode + * is normal land (its 11 maps have no ice or slime), so it is always 1. + */ +export const SLIDE_FRICTION = 1; +/** + * Seconds of the death fade that follows the Die clip in `dying` (die(): the clip, then this tail). A flying unit keeps + * its lift for the whole clip and drops only inside this tail (PR #380: the official client removes its fly offset in + * `CharacterAnimator.OnFinish`, when the finish state ends — docs/research/14-death-animations.md). + */ +const DIE_FADE_TAIL = 0.55; +/** + * Longest Die clip a death plays out (s): the longest enemy Die clip of data/assets.json is 盐风主教昆图斯's 7.97 s (234 + * enemy Die clips; the old 1.6 s cap cut 25 of them short — the bosses' most), and the official client plays its clip + * out (PR #380). It only bounds a view whose data lies. + */ +export const DIE_CLIP_MAX = 8; /** b.snap `down` entry states (server/sim/constants.js DOWN_STATE). */ export const DOWN_STATE = Object.freeze({ COUNTING: 0, WAIT_DP: 1, WAIT_TILE: 2 }); @@ -393,6 +423,7 @@ export class UnitView { this.tier = clamp(Number(info.tier) || 1, 1, 6); this.x = Number(info.x) || 0; this.y = Number(info.y) || 0; this.z = 0; this.zTarget = null; // battle: standing height the feet ease towards (tile top under the unit; 0 for enemies) + this.slide = null; // a push / pull slide (slideTo): held, then easing into the sim's position (update) /** @type {number|null} */ this.shadowZ = null; // an enemy flyer's shadow height (the tile top under it), else null: the shadow is at z /** @type {number|null} */ @@ -726,13 +757,18 @@ export class UnitView { // the element gauge shown (b.snap `elem`): element, fill 0..1, cooldown end (game s) and length this.el = typeof s.el === 'string' ? s.el : null; this.elFill = this.el ? s.elFill || 0 : 0; this.elUntil = this.el ? s.elUntil || 0 : 0; this.elDur = this.el ? s.elDur || 0 : 0; - this.x = s.x; this.y = s.y; + // a push / pull slide owns the drawn position (slideTo): the sample only anchors where it eases to, from the + // snapshot that carries the destination on (`at`) — before that the sample is still sliding or snapping there + const sl = this.slide; + if (!sl) { this.x = s.x; this.y = s.y; } + else if (!(t < sl.at)) { sl.lx = s.x; sl.ly = s.y; sl.live = true; } this.flying = !!(s.flags & UF.FLYING) || this.info.motion === 'FLY'; // enemies keep to the road plane: ground enemies only ever walk low tiles (a rounding step onto a block edge must not // pop them up), and a flyer hovers FLY_HOVER above the road whatever tile it crosses — the official lift is one // constant over the route (docs/research/12), so a block under it is no step (GitHub #277: a flyer passing over one // high-ground / forbidden block rose and dropped like stairs). Its shadow still lies on the tile top under it. - const floor = groundZ(this.ctx, s.x, s.y); + // While sliding, the floor is read under the view's own (mid-flight) position, not the destination's. + const floor = this.slide ? groundZ(this.ctx, this.x, this.y) : groundZ(this.ctx, s.x, s.y); const gz = this.isEnemy ? 0 : floor; if (this.zTarget == null) this.z = gz; this.zTarget = gz; @@ -755,6 +791,39 @@ export class UnitView { setWorld(x, y, z = 0) { this.x = x; this.y = y; this.z = z; } + /** + * Slide to a displacement's destination (推拉, the `displace` fx at (x, y)) instead of appearing there (DISPLACE_SLIDE). + * `at` (game s): the time of the snapshot that carries the destination — render/app.js starts the slide as soon as the + * interpolator shows the interval before it, and until the render clock reaches `at` the view holds where it stood + * (the sample in between is a lerp or a snap towards the destination). From then on it eases from there into the + * sampled position — the destination, and the walk the enemy resumes from it —, so it lands without a jump. Without + * `at` the destination counts as shown. A unit that dies on the way finishes the slide: its body lies where the push + * put it (the sim's position); a dead one is placed there, a knocked-down one is left to setDown. + */ + slideTo(x, y, opts = {}) { + if (!Number.isFinite(x) || !Number.isFinite(y)) return; + // already on the way there: app.js starts the slide ahead of the event, then the event itself asks again + if (this.slide && this.slide.x1 === x && this.slide.y1 === y) return; + if (this.down) { this.slide = null; return; } + const dx = x - this.x, dy = y - this.y; + const D = Math.hypot(dx, dy); + if (!this.alive || !(D > 0.05)) { this.x = x; this.y = y; this.slide = null; return; } + const f = Number.isFinite(opts.friction) && opts.friction > 0 ? opts.friction : SLIDE_FRICTION; + // a slippery tile (f < 1) slides longer; the duration still follows √D (constant deceleration) + const T = Math.max(0.05, (opts.dur > 0 ? opts.dur : clamp(DISPLACE_SLIDE * Math.sqrt(D), 0.12, 0.45)) / f); + // v0 = 2D/T and a = v0/T run the distance in exactly T with the velocity reaching 0 there, integrated in update(). + // The official turns a displaced unit towards the force (its _dontChangeFaceByDirection is an opt-in flag), while + // our facing comes from the snapshot's vx — which points back down the route the enemy resumes after the + // displacement, i.e. against the push. The slide therefore owns the facing until it lands. + if (this.isEnemy) this.visFacing = dx < 0 ? -1 : 1; + const v0 = 2 * D / T; + const at = Number.isFinite(opts.at) ? opts.at : -Infinity; + this.slide = { + x1: x, y1: y, sx: this.x, sy: this.y, ux: dx / D, uy: dy / D, D, done: 0, v: v0, a: v0 / T, t: 0, dur: T, + at, live: at === -Infinity, lx: x, ly: y, ox: 0, oy: 0, run: false, wait: 0, + }; + } + setFacing(f) { if (this.isEnemy) { this.facing = f === -1 ? -1 : 1; return; } // the legacy ±1 only speaks about left / right: an UP / DOWN operator keeps its direction @@ -909,7 +978,7 @@ export class UnitView { 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; + this.dying = clamp(d / rate, 0.35, DIE_CLIP_MAX) + DIE_FADE_TAIL; this.dieDur = this.dying; if (instant) { this.dieT = 30; if (this.actor) this.actor.update(30); } } @@ -948,7 +1017,7 @@ export class UnitView { if (!d) { if (!this.down) return; this.down = null; - if (!this.alive) { this.dying = 0.55; this.dieDur = 0.55; } + if (!this.alive) { this.dying = DIE_FADE_TAIL; this.dieDur = DIE_FADE_TAIL; } return; } if (this.alive) this.die(instant); @@ -969,6 +1038,31 @@ export class UnitView { /** @param {number} dt real seconds @param {any} cam camera @param {number} t real clock */ update(dt, cam, t) { if (this.destroyed) return; + // a displacement's slide (slideTo): held where it stood until the snapshot with the destination is shown, then + // eased into the sampled position (sync keeps `lx, ly` current) under a constant deceleration + if (this.slide) { + const sl = this.slide; + if (this.down) this.slide = null; // a knocked-down view lies on its tile (setDown) + else if (!sl.live) { + this.x = sl.sx; this.y = sl.sy; + // never shown (the unit left the snapshots, a stalled stream): the snapshot takes the view back + if ((sl.wait += dt) > 0.5) this.slide = null; + } else { + if (!sl.run) { sl.run = true; sl.ox = sl.sx - sl.lx; sl.oy = sl.sy - sl.ly; } + const step = Math.min(dt, 0.1); // a hidden tab's catch-up frame must not overshoot + const v0 = sl.v; + sl.v = Math.max(0, sl.v - sl.a * step); // friction: the velocity decays every frame + // the step's mean velocity — exact under a constant deceleration (the end-of-step speed fell ~D·dt/T short: + // 9 % of a 0.18 s slide at 60 fps, then a jump onto the end) + sl.done = Math.min(sl.D, sl.done + (v0 + sl.v) / 2 * step); + sl.t += step; + if (this.isEnemy) this.visFacing = sl.ux < 0 ? -1 : 1; + const k = 1 - sl.done / sl.D; // the share of the way still to go + this.x = sl.lx + sl.ox * k; this.y = sl.ly + sl.oy * k; + // the clock or the spent velocity lands it on the sampled position + if (sl.t >= sl.dur - 1e-9 || sl.done >= sl.D - 1e-9 || sl.v <= 1e-9) { this.x = sl.lx; this.y = sl.ly; this.slide = null; } + } + } 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(); } @@ -987,7 +1081,9 @@ export class UnitView { const d = this.shadowZTarget - this.shadowZ; this.shadowZ = Math.abs(d) < 1e-3 ? this.shadowZTarget : this.shadowZ + d * Math.min(1, dt * 12); } - const hoverTo = this.flying && this.alive ? FLY_HOVER : 0; + // a flyer keeps its lift through the Die clip and drops only as it fades out: the client removes the fly offset in + // CharacterAnimator.OnFinish, when the finish state ends; a knocked-down flyer lies on the ground + const hoverTo = this.flying && (this.alive || (this.dying > DIE_FADE_TAIL && !this.down)) ? FLY_HOVER : 0; if (this.hover !== hoverTo) this.hover = Math.abs(hoverTo - this.hover) < 1e-3 ? hoverTo : this.hover + (hoverTo - this.hover) * Math.min(1, dt * 6); const p = cam.project(this.x, this.y, this.z + this.hover + this.lift, this.screen); const s = p.s; @@ -1001,7 +1097,7 @@ export class UnitView { } else if (this.dying > 0) { this.dieT += dt; this.dying -= dt; - const tail = 0.55; + const tail = DIE_FADE_TAIL; if (this.dying < tail) alpha *= Math.max(0, this.dying / tail); if (this.dying <= 0) { this.dying = 0; this.remove = true; alpha = 0; } } diff --git a/test/render/unitview.test.js b/test/render/unitview.test.js index 26e426b9..1e033118 100644 --- a/test/render/unitview.test.js +++ b/test/render/unitview.test.js @@ -435,3 +435,277 @@ describe('a fast enemy walks on its Run cycle (PR #275)', () => { assert.equal(hound.actor.roles.skill.loop, 'Skill_02', 'the cast slot survives that too'); }); }); + +// 推拉 (player report): the sim displaces instantly (battle/displacement.js walks 0.1-tile steps inside one call) and the +// snapshot only carries the destination, so the view eases there (app.js `displace` fx → UnitView.slideTo) instead of +// appearing at the end of the path. +describe('a push / pull slide (推拉: the official impulse under friction)', () => { + const view = async (id = 1) => { + const ctx = fakeViewCtx(fake.P, { assets: store({ spine: true }), cam }); + const v = new UnitView(ctx, { id, side: 'enemy', kind: 'enemy', defId: 'enemy_1007_slime', x: 5, y: 12, maxHp: 100 }, {}); + await tick(); await tick(); + return v; + }; + // 推进 n 帧(update 会把单帧 dt 截到 0.1 s,所以必须多帧走完) + const step = (v, dur, n = 60) => { for (let i = 0; i < n; i++) v.update(dur / n, cam(), 0); }; + + test('slideTo runs a real deceleration: the velocity falls every frame and stops on the destination', async () => { + const v = await view(); + v.slideTo(7.7, 12); + const sl = v.slide; + assert.ok(sl, 'a slide is in flight'); + assert.ok(sl.v > 0 && sl.a > 0, 'it starts with a speed and a deceleration'); + assert.ok(Math.abs(sl.v * sl.v / (2 * sl.a) - 2.7) < 1e-6, 'v²/2a = the distance (constant deceleration)'); + let prev = sl.v; + let monotone = true; + for (let i = 0; i < 40; i++) { + v.update(sl.dur / 80, cam(), 0); + if (v.slide && v.slide.v > prev + 1e-9) monotone = false; + if (!v.slide) break; + prev = v.slide.v; + } + assert.ok(monotone, 'the velocity only ever decreases'); + step(v, sl.dur); + assert.equal(v.x, 7.7, 'it arrives exactly on the authoritative destination'); + assert.equal(v.y, 12, 'the cross axis holds'); + assert.equal(v.slide, null); + }); + + test('starting a slide from the destination is a no-op (why app.js pre-scans the frame)', async () => { + // The trap this guards: app.js's snapshot loop runs before processEvents, and the snapshot already carries the + // displacement's destination, so a fx handler that runs after it finds the view there and has nothing to animate — + // the reported pause with no frames. app.js therefore starts the slide from the pre-snapshot position. + const v = await view(8); + v.sync({ x: 7.7, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 0 }); + assert.equal(v.x, 7.7); + v.slideTo(7.7, 12); + assert.equal(v.slide, null, 'no slide when the view is already there'); + }); + + test('a second request for the same destination never restarts the slide in flight', async () => { + const v = await view(9); + v.slideTo(7.7, 12); + const first = v.slide; + for (let i = 0; i < 10; i++) v.update(first.dur / 40, cam(), 0); + const mid = v.x, done = v.slide.done; + v.slideTo(7.7, 12); // the fx handler, later in the same frame + assert.equal(v.slide, first, 'the same slide object, not a restart'); + assert.equal(v.x, mid, 'the position is untouched'); + assert.equal(v.slide.done, done); + step(v, first.dur); + assert.equal(v.x, 7.7, 'and it still lands'); + }); + + test('a pushed enemy faces the way it is pushed, not the route it resumes', async () => { + // The official turns a displaced unit towards the force (_dontChangeFaceByDirection is an opt-in flag); our facing + // comes from the snapshot's vx, which after a displacement points back down the route the enemy walks again — + // i.e. against the push (player report: 被推开敌人的方向反过来了). + const v = await view(10); + v.sync({ x: 5, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 0, vx: -1.2 }); + assert.equal(v.visFacing, -1, 'walking left before the push'); + v.slideTo(7.7, 12); // pushed to the right + assert.equal(v.visFacing, 1, 'faces the push at once'); + for (let i = 0; i < 20; i++) v.update(v.slide.dur / 40, cam(), 0); + assert.equal(v.visFacing, 1, 'still facing the push in flight'); + v.slideTo(5.2, 12); // now pulled back to the left + assert.equal(v.visFacing, -1, 'and the other way for a pull'); + }); + + test('a snapshot in flight does not teleport it (the slide owns the position until it lands)', async () => { + const v = await view(7); + v.slideTo(7.7, 12); + const dur = v.slide.dur; + for (let i = 0; i < 12; i++) v.update(dur / 40, cam(), 0); + const mid = v.x; + assert.ok(mid > 5.2 && mid < 7.6, `mid-flight (${mid})`); + // the sim's snapshots already carry the destination (it displaced the enemy inside one call), so a plain sync + // would snap the view there — the reported "no frames in between" + v.sync({ x: 7.7, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 0 }); + assert.equal(v.x, mid, 'the snapshot does not move a sliding view'); + assert.equal(v.y, 12); + step(v, dur); + assert.equal(v.x, 7.7, 'and it still lands on the destination'); + // once landed, snapshots drive it again + v.update(dur, cam(), 0); + v.sync({ x: 6.4, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 0 }); + assert.equal(v.x, 6.4, 'a landed view follows the snapshot again'); + }); + + test('a slippery floor (a smaller friction factor) slides longer and lands just the same', async () => { + const dry = await view(2), ice = await view(3); + dry.slideTo(7.7, 12); + ice.slideTo(7.7, 12, { friction: 0.5 }); + assert.ok(ice.slide.dur > dry.slide.dur * 1.5, `ice ${ice.slide.dur} > dry ${dry.slide.dur}`); + step(ice, ice.slide.dur * 1.05, 90); + assert.equal(ice.x, 7.7, 'the destination is authoritative whatever the floor'); + }); + + test('the duration follows √distance, not distance', async () => { + const near = await view(4), far = await view(5); + near.slideTo(6.2, 12); far.slideTo(10.5, 12); // 1.2 vs 5.5 tiles + assert.ok(far.slide.dur > near.slide.dur, `${far.slide.dur} > ${near.slide.dur}`); + assert.ok(far.slide.dur / near.slide.dur < 2.6, 'sub-linear (∝ √d)'); + }); + + test('a trivial move snaps, an unknown target is ignored; a unit that dies on the way lies where it was pushed', async () => { + const v = await view(6); + v.slideTo(5.01, 12); + assert.equal(v.slide, null); assert.equal(v.x, 5.01, 'a sub-0.05 tile move is not a displacement'); + v.slideTo(NaN, 12); + assert.equal(v.slide, null); assert.equal(v.x, 5.01); + v.slideTo(7.7, 12); + step(v, v.slide.dur / 3, 20); + assert.ok(v.x > 5 && v.x < 7.7, `moving (${v.x})`); + // the sim's displacement already put the body at the destination (a Weedy wall kill dies there): the corpse + // finishes the slide and stays — PR #380 sent it back to where it stood, and the next snapshot then jumped it + v.die(); + v.update(1, cam(), 0); + v.update(0.1, cam(), 0); + assert.equal(v.x, 7.7, 'the corpse lies where the push put it'); + assert.equal(v.slide, null); + const dead = await view(11); + dead.die(); + dead.slideTo(7.7, 12); + assert.equal(dead.slide, null, 'a dead view does not start a slide'); + assert.equal(dead.x, 7.7, 'it is placed on the destination'); + }); + + test('held until the snapshot with the destination is shown (`at`), then eased into the sampled position — the walk it resumes included — with no jump on landing', async () => { + const v = await view(12); + v.sync({ x: 5, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 1, vx: -0.8 }, 2.9); + v.slideTo(7.7, 12, { at: 3 }); // app.js: the interval ending on the destination's snapshot is shown + for (const [x, t] of [[5.9, 2.93], [6.8, 2.96]]) { // the interpolator lerps there in the meantime + v.sync({ x, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 1, vx: 27 }, t); + v.update(1 / 60, cam(), 0); + assert.equal(v.x, 5, `held where it stood at ${t}`); + } + // from the destination's snapshot on the sample anchors the slide, and the enemy walks back down its route + let live = 7.7, prev = v.x, maxStep = 0, frames = 0; + for (let t = 3; v.slide && frames < 120; t += 1 / 30, frames++) { + v.sync({ x: live, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 1, vx: -0.8 }, t); + v.update(1 / 60, cam(), 0); + maxStep = Math.max(maxStep, Math.abs(v.x - prev)); prev = v.x; + live -= 0.8 / 30; + } + assert.ok(frames >= 8, `a slide of ${frames} frames, not a jump`); + assert.equal(v.slide, null); + assert.ok(maxStep < 0.4, `no frame moves more than ${maxStep.toFixed(3)} tiles`); + const landed = v.x; + v.sync({ x: live, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 1, vx: -0.8 }, 3 + frames / 30); + assert.ok(Math.abs(v.x - landed) < 0.05, `landed on the walking enemy (${landed.toFixed(3)} → ${v.x.toFixed(3)})`); + }); + + test('a slide that is never shown (the unit left the snapshots) hands the view back', async () => { + const v = await view(13); + v.slideTo(7.7, 12, { at: 3 }); + for (let i = 0; i < 40; i++) v.update(1 / 60, cam(), 0); + assert.equal(v.slide, null); + v.sync({ x: 6.1, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 1 }, 3.4); + assert.equal(v.x, 6.1); + }); +}); + +// the app.js frame order (render/app.js: processEvents takes the events ≤ renderT, then syncBattle's look-ahead starts a +// slide for a `displace` fx still queued up to the next snapshot, then sample → sync, then update) run over a real +// battle: a pushed enemy is drawn sliding, not snapped +describe('a push through SnapshotBuffer and UnitView in the app.js frame order (PR #380)', () => { + async function pushed({ kill = false } = {}) { + const { makeBattle, enemyRec } = await import('../helpers/battleHarness.js'); + const { SnapshotBuffer } = await import('../../public/js/render/interp.js'); + const { snapFrame } = await import('../../server/match/fields.js'); + const h = makeBattle({ defs: { enemies: { enemy_walker: enemyRec({ key: 'enemy_walker', hp: 1e6, speed: 0.8 }) } }, + enemies: [{ key: 'enemy_walker', time: 0, route: 0 }], content: 'none', autoFinish: false, timeLimit: 60 }); + const frames = []; + let e = null, info = null, moved = 0, dest = null; + for (let i = 0; i < 30 * 6; i++) { + h.step(); + e = e || h.enemy('enemy_walker'); + if (!moved && h.b.time >= 3) { + moved = h.b.displace(e, { x: 1, y: 0 }, 1.7); + dest = e.x; + if (kill) h.b.kill(e, null); + } + if (i % 3 === 2) { + const ev = h.b.drainEvents(); + info = info || ev.find((x) => x[0] === 'spawn')?.[1]; + const s = h.b.snapshot(); + frames.push({ at: s.t / 2, snap: snapFrame('f', s), ev, gt: s.t }); + } + } + assert.ok(moved > 1, `the sim pushed it ${moved} tiles`); + const ctx = fakeViewCtx(fake.P, { cam }); + const v = new UnitView(ctx, { ...info, id: e.id }, {}); + const buf = new SnapshotBuffer(), sample = new Map(), xs = []; + const start = (ev, at) => { if (ev[0] === 'fx' && ev[1] === 'displace' && ev[4]?.id === e.id) v.slideTo(Number(ev[2]), Number(ev[3]), at == null ? {} : { at }); }; + for (let f = 0, fi = 0; f < 60 * 3; f++) { + const now = f / 60; + for (; fi < frames.length && frames[fi].at <= now; fi++) { buf.push(frames[fi].snap, frames[fi].at); buf.pushEvents(frames[fi].ev, frames[fi].at, frames[fi].gt); } + const renderT = buf.update(now); + if (!Number.isFinite(renderT)) continue; + for (const ev of buf.takeEvents(renderT, [])) { start(ev); if (ev[0] === 'die' && ev[1] === e.id && v.alive) v.die(); } + const at = buf.nextSnapT(renderT); + if (at > renderT) buf.forEachUpcoming(renderT, at, (ev) => start(ev, at)); + buf.sample(renderT, sample); + if (sample.get(e.id)) v.sync(sample.get(e.id), renderT); + v.update(1 / 60, cam(), now); + if (renderT > 2.8 && renderT < 3.8) xs.push(v.x); + } + return { xs, dest }; + } + + test('a 1.7-tile push is drawn over a dozen frames, every frame a step under 0.35 tiles', async () => { + const { xs } = await pushed(); + let big = 0, moving = 0; + for (let i = 1; i < xs.length; i++) { const d = Math.abs(xs[i] - xs[i - 1]); big = Math.max(big, d); if (d > 0.02) moving++; } + assert.ok(moving >= 8, `${moving} frames show it moving (the bare interpolator: 3)`); + assert.ok(big < 0.35, `largest step ${big.toFixed(3)} tiles (the bare interpolator: 0.58)`); + }); + + test('killed right after the push, the body slides on to where the sim left it and stays there', async () => { + const { xs, dest } = await pushed({ kill: true }); + assert.ok(Math.abs(xs[xs.length - 1] - dest) < 0.011, `${xs[xs.length - 1]} vs ${dest}`); + const from = xs.findIndex((x, i) => i > 0 && x > xs[i - 1] + 0.01); // the slide's first frame (it walked left before) + assert.ok(from > 0); + for (let i = from; i < xs.length; i++) assert.ok(xs[i] >= xs[i - 1] - 1e-9, `never back towards where it stood (frame ${i})`); + }); +}); + +// PR #380, death: a flyer holds its lift through the Die clip (the official client drops its fly offset in +// CharacterAnimator.OnFinish) and a long Die clip plays whole (DIE_CLIP_MAX, data/assets.json's longest 7.97 s) +describe('death timing (PR #380): a dying flyer drops inside the fade, a long Die clip plays whole', () => { + test('a dying flyer keeps FLY_HOVER through its Die clip and sinks only in the fade tail; a walker stays down', async () => { + const { FLY_HOVER, UF } = { ...(await import('../../public/js/render/units.js')), ...(await import('../../shared/constants.js')) }; + const ctx = fakeViewCtx(fake.P, { cam }); + const fly = new UnitView(ctx, { id: 21, side: 'enemy', kind: 'enemy', defId: 'enemy_fly', motion: 'FLY', x: 5, y: 12, maxHp: 100 }, {}); + fly.sync({ x: 5, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: UF.FLYING, anim: 1 }, 1); + for (let i = 0; i < 120; i++) fly.update(1 / 60, cam(), 0); + assert.ok(Math.abs(fly.hover - FLY_HOVER) < 1e-3, 'aloft while alive'); + fly.die(); // no clip known: 0.35 s of clip, then the 0.55 s fade + for (let i = 0; i < 18; i++) fly.update(1 / 60, cam(), 0); + assert.ok(Math.abs(fly.hover - FLY_HOVER) < 1e-3, `still aloft in the Die clip (${fly.hover.toFixed(3)})`); + for (let i = 0; i < 30; i++) fly.update(1 / 60, cam(), 0); + assert.ok(fly.hover < FLY_HOVER * 0.25, `sinking in the fade (${fly.hover.toFixed(3)})`); + const walker = new UnitView(ctx, { id: 22, side: 'enemy', kind: 'enemy', defId: 'enemy_walk', x: 5, y: 12, maxHp: 100 }, {}); + walker.die(); + walker.update(1 / 60, cam(), 0); + assert.equal(walker.hover, 0); + }); + + test('every enemy Die clip of data/assets.json is within DIE_CLIP_MAX, and a 7.97 s one is scheduled whole', async () => { + const { DIE_CLIP_MAX, dieClipDur } = await import('../../public/js/render/units.js'); + const durs = Object.values(ASSETS.enemies).map((e) => dieClipDur(e && e.spine)).filter((d) => d > 0); + assert.ok(durs.length > 200, `${durs.length} enemy Die clips`); + const longest = Math.max(...durs); + assert.ok(longest <= DIE_CLIP_MAX, `longest Die clip ${longest} s ≤ ${DIE_CLIP_MAX}`); + assert.ok(durs.filter((d) => d > 1.6).length > 0, 'the old 1.6 s cap cut some clips short'); + const entry = { skel: '/s/q.skel', atlas: '/s/q.atlas', textures: ['/s/q.png'], anims: { idle: 'Idle', die: 'Die' }, animations: { Idle: 1, Die: longest } }; + const assets = { ...store({ spine: true }), spineEntry: () => entry }; + const v = new UnitView(fakeViewCtx(fake.P, { assets, cam }), { id: 23, side: 'enemy', kind: 'enemy', defId: 'enemy_1521_dslily', x: 5, y: 12, maxHp: 100 }, {}); + await tick(); await tick(); + assert.ok(v.actor, 'the model is up'); + v.die(); + assert.ok(Math.abs(v.dying - (longest + 0.55)) < 1e-6, `the whole clip, then the fade (${v.dying.toFixed(3)} s)`); + for (let i = 0; i < 300; i++) v.update(1 / 60, cam(), 0); + assert.ok(!v.remove && v.alpha > 0.5, 'still on screen 5 s in'); + }); +}); diff --git a/test/sim/displace-fx.test.js b/test/sim/displace-fx.test.js new file mode 100644 index 00000000..680e3743 --- /dev/null +++ b/test/sim/displace-fx.test.js @@ -0,0 +1,33 @@ +// The contract between server/sim/battle/displacement.js and render/app.js: a displacement puts a +// ['fx', 'displace', x, y, { id }] tuple into the stream the client renders. The client must read the destination +// from the tuple's own slots — e[4] carries the id only, and reading x/y from it silently produced NaN, which made +// slideTo a no-op (player report: 有停顿但没有中间帧). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, enemyRec } from '../helpers/battleHarness.js'; + +const walker = (o = {}) => enemyRec({ key: 'enemy_walker', hp: 1e6, speed: 1, ...o }); + +test('a displacement puts a `displace` fx into the event stream the client receives', () => { + const h = makeBattle({ + defs: { enemies: { enemy_walker: walker({ speed: 0.01 }) } }, + enemies: [{ key: 'enemy_walker', pos: [10, 6] }], + content: 'none', autoFinish: false, + }); + h.step(); + const e = h.enemy('enemy_walker'); + h.b.drainEvents(); // clear the spawn/etc. batch + const moved = h.b.displace(e, { x: 1, y: 0 }, 2, { force: 1 }); + assert.ok(moved > 1.8, 'the enemy moved: ' + moved); + const evs = h.b.drainEvents(); + const fx = (evs || []).filter((t) => Array.isArray(t) && t[0] === 'fx'); + const disp = fx.find((t) => t[1] === 'displace'); + assert.ok(disp, 'the stream carries a displace fx: ' + JSON.stringify(fx)); + // the fx rounds its coordinates to 2 decimals (events.js fx), the unit keeps full precision + assert.ok(Math.abs(disp[2] - e.x) <= 0.01, 'e[2] is the destination x: ' + disp[2] + ' vs ' + e.x); + assert.ok(Math.abs(disp[3] - e.y) <= 0.01, 'e[3] is the destination y: ' + disp[3] + ' vs ' + e.y); + assert.equal(disp[4] && disp[4].id, e.id, 'e[4].id is the displaced unit'); + assert.equal(disp[4].x, undefined, 'e[4] carries no x — reading it gave NaN, so no slide ever ran'); + assert.equal(disp[4].y, undefined, 'e[4] carries no y either'); +}); +