[动画] 施法/攻击后 castState 永不过期:单位一直停在施法动画,走路时朝着旧目标 #546

Open
opened 2026-10-02 07:13:25 +00:00 by troytt · 0 comments
Owner

现象

任何单位(本地玩家、其他玩家、怪物)收到一次 SkillCast(施法或普通攻击)以后:

  • 动画一直停在施法/攻击 mode(玩家 SC=10 / A1=7,怪物 SC=7 / A1=4),不会回到 NU / WL / RN;
  • 之后再走路,朝向每帧都被重算成指向那次施法的旧目标,而不是移动方向。

这和「人物移动时动画朝向与运动方向不一致」是同一类症状:dd775c8 修好了移动路径本身的朝向,但施法后残留的 castState 会把它盖掉。

复现(运行时探针,main @ 8fcd223)

用 tests/e2e-d2gs-online/helpers.ts 的 createOnlineHudSession 驱动真实的 OnlineSession.buildRenderableUnits + buildSceneDrawables:

步骤 期望 实际绘制
自己朝世界 +X 方向 10 格处施放技能 36,然后过 2 s(untilMs 只有 +400 ms) mode 回到 NU (1) mode 10 (SC),castState 仍在
接着自己朝世界 (−1,−1) 方向走 mode 2 (WL),dir64 32 mode 10 (SC),dir64 57(仍朝着旧施法点)
怪物攻击自己(skill 0),过 2 s 后沿世界 +X 走 mode 2 (WL),dir64 56 mode 4 (A1),dir64 31(仍朝着旧攻击目标)

57 ≈ 施法方向 56,31 ≈ 攻击方向 32;差的那 1 正是因为单位已经移动,每帧从新位置重新指向旧目标。

探针输出:

PROBE self 2s after cast (untilMs was +400ms): {"mode":10,"dir":56,"tx":5110,"ty":5100,"hasCast":true,"drawMode":10,"drawDir":56}
PROBE self walking to world(-1,-1) after cast: {"mode":10,"dir":56,"tx":5110,"ty":5100,"hasCast":true,"drawMode":10,"drawDir":57,"expectDir":32,"expectMode":2}
PROBE monster walking world +X, 2s after attacking: {"mode":4,"dir":56,"tx":5100,"ty":5100,"hasCast":true,"drawMode":4,"drawDir":31,"expectDir":56,"expectMode":2}
探针代码(临时放到 tests/ 下用 vitest 跑一次即可,不要提交)
import { describe, it } from 'vitest'
import { createOnlineHudSession } from './e2e-d2gs-online/helpers.ts'
import { buildSceneDrawables } from '../src/client/view/unit-drawables.ts'

describe('probe: castState lifetime', () => {
  it('self and monster after a cast', () => {
    const h = createOnlineHudSession({ selfId: 1, x: 5100, y: 5100 })
    const s = h.onlineSession
    const ev = (e: any) => s.handleServerEvent(e)
    const self = () => s.buildRenderableUnits().find(u => u.id === 1 && u.kind === 'player')!
    const draw = (u: any) => buildSceneDrawables([u], { originX: 0, originY: 0, tick: 1 }).unitDrawables[0]!
    let now = 1000
    const run = (ms: number) => {
      for (let t = 0; t < ms; t += 40) {
        now += 40
        s.tick(40, now)
      }
    }
    const show = (label: string, u: any, extra: Record<string, unknown> = {}) => {
      const d = draw(u)
      console.log(`PROBE ${label}: ` + JSON.stringify({
        mode: u.mode, dir: u.direction, tx: u.targetX, ty: u.targetY, hasCast: u.castState !== undefined,
        drawMode: d.mode, drawDir: d.direction, ...extra,
      }))
    }

    ev({ type: 'SkillCast', caster: { unitType: 0, id: 1 }, skill: 36, skillId: 36, level: 1, target: { x: 5110, y: 5100 } })
    run(2000)
    show('self 2s after cast (untilMs was +400ms)', self())
    h.predictor.setPath([{ x: 5090, y: 5090 }], false)
    run(200)
    show('self walking to world(-1,-1) after cast', self(), { expectDir: 32, expectMode: 2 })

    ev({ type: 'UnitAssign', unit: { unitType: 1, id: 40, classId: 5, x: 5200, y: 5200 } })
    ev({ type: 'SkillCast', caster: { unitType: 1, id: 40 }, skill: 0, level: 1, target: { unitType: 0, id: 1 } })
    run(2000)
    ev({ type: 'UnitMove', unitType: 1, id: 40, mode: 'walk', from: { x: 5200, y: 5200 }, to: { x: 5230, y: 5200 } })
    run(200)
    const mon = s.buildRenderableUnits().find(u => u.id === 40)!
    show('monster walking world +X, 2s after attacking', mon, { expectDir: 56, expectMode: 2 })
  })
})

原因

  1. ClientWorld.applySkillCastToUnit 写入 unit.castState(L237),其中 untilMs: now + 400(L235);本地玩家还会同时写 self.castState(L238-L240)。
  2. UnitCastState.untilMs 在 src/ 里没有任何读取方,也没有任何代码清除 ClientUnit.castState。唯一的清除点是 MutableSelfPlayerState.reset() 里的 this.castState = undefined(L211),而它只在 ClientWorld.reset() 整体重置时被调用。
  3. OnlineSession.buildRenderableUnits 对本地玩家优先使用 castState:
    • selfMode = selfCastState.mode(L1327-L1328),盖过预测器给出的 WL / RN;
    • selfDirection = selfUnit.direction(L1336-L1337),绕过预测器的 predSnap.dir64;
    • 把施法目标作为 targetX / targetY 传给绘制层(L1345-L1346)。
  4. 其他单位同理(L1438-L1442):mode = castState.mode || u.mode,targetX = castState?.targetX ?? u.targetX。
  5. 绘制层 unitFacingDir64 每帧用 dir64FromPoints(unit.x, unit.y, targetX, targetY) 重算朝向(调用点 L485-L511),所以单位一边走,朝向一边被拧回旧目标。

期望行为(1.13c)

  • 施法 / 攻击 mode 只持续一个动画周期(时长由动画帧数和 FCR / IAS 决定,不是固定值),结束后回到 NU / WL / RN(城里是 TN / TW)。
  • 移动时的朝向 = 当前路径段的朝向。
  • 施法结束后原地站着时,保持施法时的朝向,直到下一次移动;不应跳回施法前的 predSnap.dir64。

修复方向

  • castState 要在动画结束、收到新的 UnitMove / UnitMode、或本地开始新路径时清除;最低限度也要在 untilMs 到期时清除。注意固定的 400 ms 不是 1.13c 数据,最终应改成由动画帧数驱动。
  • 本地玩家施法结束时,把施法朝向交给预测器,避免站立时朝向跳回去。
  • 次要(同一段代码):其他单位只应在 castState 有效时传施法目标,不要回退到 u.targetX / u.targetY。每帧朝 u.targetX 重算朝向,和 1.13c「每段路径定一次朝向」不一致;目前唯一可能的可见影响是接近终点时的 FP16 抖动。
  • 建议把上面的探针改造成回归测试。

引入:8dac79e(R1-R5 online gameplay loops 中的 SkillCast 部分)。相关:#543、#544。

## 现象 任何单位(本地玩家、其他玩家、怪物)收到一次 `SkillCast`(施法或普通攻击)以后: - 动画一直停在施法/攻击 mode(玩家 SC=10 / A1=7,怪物 SC=7 / A1=4),不会回到 NU / WL / RN; - 之后再走路,朝向每帧都被重算成指向**那次施法的旧目标**,而不是移动方向。 这和「人物移动时动画朝向与运动方向不一致」是同一类症状:`dd775c8` 修好了移动路径本身的朝向,但施法后残留的 castState 会把它盖掉。 ## 复现(运行时探针,main @ 8fcd223) 用 `tests/e2e-d2gs-online/helpers.ts` 的 `createOnlineHudSession` 驱动真实的 `OnlineSession.buildRenderableUnits` + `buildSceneDrawables`: | 步骤 | 期望 | 实际绘制 | |---|---|---| | 自己朝世界 +X 方向 10 格处施放技能 36,然后过 2 s(`untilMs` 只有 +400 ms) | mode 回到 NU (1) | mode 10 (SC),`castState` 仍在 | | 接着自己朝世界 (−1,−1) 方向走 | mode 2 (WL),dir64 32 | mode 10 (SC),dir64 57(仍朝着旧施法点) | | 怪物攻击自己(skill 0),过 2 s 后沿世界 +X 走 | mode 2 (WL),dir64 56 | mode 4 (A1),dir64 31(仍朝着旧攻击目标) | 57 ≈ 施法方向 56,31 ≈ 攻击方向 32;差的那 1 正是因为单位已经移动,每帧从新位置重新指向旧目标。 探针输出: ```text PROBE self 2s after cast (untilMs was +400ms): {"mode":10,"dir":56,"tx":5110,"ty":5100,"hasCast":true,"drawMode":10,"drawDir":56} PROBE self walking to world(-1,-1) after cast: {"mode":10,"dir":56,"tx":5110,"ty":5100,"hasCast":true,"drawMode":10,"drawDir":57,"expectDir":32,"expectMode":2} PROBE monster walking world +X, 2s after attacking: {"mode":4,"dir":56,"tx":5100,"ty":5100,"hasCast":true,"drawMode":4,"drawDir":31,"expectDir":56,"expectMode":2} ``` <details> <summary>探针代码(临时放到 tests/ 下用 vitest 跑一次即可,不要提交)</summary> ```ts import { describe, it } from 'vitest' import { createOnlineHudSession } from './e2e-d2gs-online/helpers.ts' import { buildSceneDrawables } from '../src/client/view/unit-drawables.ts' describe('probe: castState lifetime', () => { it('self and monster after a cast', () => { const h = createOnlineHudSession({ selfId: 1, x: 5100, y: 5100 }) const s = h.onlineSession const ev = (e: any) => s.handleServerEvent(e) const self = () => s.buildRenderableUnits().find(u => u.id === 1 && u.kind === 'player')! const draw = (u: any) => buildSceneDrawables([u], { originX: 0, originY: 0, tick: 1 }).unitDrawables[0]! let now = 1000 const run = (ms: number) => { for (let t = 0; t < ms; t += 40) { now += 40 s.tick(40, now) } } const show = (label: string, u: any, extra: Record<string, unknown> = {}) => { const d = draw(u) console.log(`PROBE ${label}: ` + JSON.stringify({ mode: u.mode, dir: u.direction, tx: u.targetX, ty: u.targetY, hasCast: u.castState !== undefined, drawMode: d.mode, drawDir: d.direction, ...extra, })) } ev({ type: 'SkillCast', caster: { unitType: 0, id: 1 }, skill: 36, skillId: 36, level: 1, target: { x: 5110, y: 5100 } }) run(2000) show('self 2s after cast (untilMs was +400ms)', self()) h.predictor.setPath([{ x: 5090, y: 5090 }], false) run(200) show('self walking to world(-1,-1) after cast', self(), { expectDir: 32, expectMode: 2 }) ev({ type: 'UnitAssign', unit: { unitType: 1, id: 40, classId: 5, x: 5200, y: 5200 } }) ev({ type: 'SkillCast', caster: { unitType: 1, id: 40 }, skill: 0, level: 1, target: { unitType: 0, id: 1 } }) run(2000) ev({ type: 'UnitMove', unitType: 1, id: 40, mode: 'walk', from: { x: 5200, y: 5200 }, to: { x: 5230, y: 5200 } }) run(200) const mon = s.buildRenderableUnits().find(u => u.id === 40)! show('monster walking world +X, 2s after attacking', mon, { expectDir: 56, expectMode: 2 }) }) }) ``` </details> ## 原因 1. [`ClientWorld.applySkillCastToUnit`](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/8fcd2238cc70a054a2051845d247a3a3bc0024cd/src/client/world/client-world.ts#L219-L241) 写入 `unit.castState`(L237),其中 `untilMs: now + 400`(L235);本地玩家还会同时写 `self.castState`(L238-L240)。 2. [`UnitCastState.untilMs`](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/8fcd2238cc70a054a2051845d247a3a3bc0024cd/src/client/world/client-unit.ts#L20-L27) 在 `src/` 里**没有任何读取方**,也没有任何代码清除 `ClientUnit.castState`。唯一的清除点是 [`MutableSelfPlayerState.reset()`](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/8fcd2238cc70a054a2051845d247a3a3bc0024cd/src/client/world/self.ts#L183-L212) 里的 `this.castState = undefined`(L211),而它只在 [`ClientWorld.reset()`](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/8fcd2238cc70a054a2051845d247a3a3bc0024cd/src/client/world/client-world.ts#L1316-L1330) 整体重置时被调用。 3. [`OnlineSession.buildRenderableUnits`](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/8fcd2238cc70a054a2051845d247a3a3bc0024cd/src/client/session/online-session.ts#L1322-L1356) 对本地玩家优先使用 castState: - `selfMode = selfCastState.mode`(L1327-L1328),盖过预测器给出的 WL / RN; - `selfDirection = selfUnit.direction`(L1336-L1337),绕过预测器的 `predSnap.dir64`; - 把施法目标作为 `targetX` / `targetY` 传给绘制层(L1345-L1346)。 4. 其他单位同理([L1438-L1442](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/8fcd2238cc70a054a2051845d247a3a3bc0024cd/src/client/session/online-session.ts#L1438-L1442)):`mode = castState.mode || u.mode`,`targetX = castState?.targetX ?? u.targetX`。 5. 绘制层 [`unitFacingDir64`](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/8fcd2238cc70a054a2051845d247a3a3bc0024cd/src/client/view/unit-drawables.ts#L278-L287) 每帧用 `dir64FromPoints(unit.x, unit.y, targetX, targetY)` 重算朝向(调用点 [L485-L511](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/8fcd2238cc70a054a2051845d247a3a3bc0024cd/src/client/view/unit-drawables.ts#L485-L511)),所以单位一边走,朝向一边被拧回旧目标。 ## 期望行为(1.13c) - 施法 / 攻击 mode 只持续一个动画周期(时长由动画帧数和 FCR / IAS 决定,不是固定值),结束后回到 NU / WL / RN(城里是 TN / TW)。 - 移动时的朝向 = 当前路径段的朝向。 - 施法结束后原地站着时,保持施法时的朝向,直到下一次移动;不应跳回施法前的 `predSnap.dir64`。 ## 修复方向 - castState 要在动画结束、收到新的 `UnitMove` / `UnitMode`、或本地开始新路径时清除;最低限度也要在 `untilMs` 到期时清除。注意固定的 400 ms 不是 1.13c 数据,最终应改成由动画帧数驱动。 - 本地玩家施法结束时,把施法朝向交给预测器,避免站立时朝向跳回去。 - 次要(同一段代码):其他单位只应在 castState 有效时传施法目标,不要回退到 `u.targetX` / `u.targetY`。每帧朝 `u.targetX` 重算朝向,和 1.13c「每段路径定一次朝向」不一致;目前唯一可能的可见影响是接近终点时的 FP16 抖动。 - 建议把上面的探针改造成回归测试。 引入:`8dac79e`(R1-R5 online gameplay loops 中的 SkillCast 部分)。相关:#543、#544。
Sign in to join this conversation.
No Label
No Milestone
No project
No Assignees
1 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: troytt/diablo2-web#546
No description provided.