地图:物品动态效果 #387

Closed
opened 2026-09-23 01:45:42 +00:00 by troytt · 0 comments
Owner

1. 问题现状与视觉缺陷分析

在当前 Web 版场景(例如罗格营地 Act 1 Town towne1)中:

  1. 场景物件完全静态:
    • 地图上的营地篝火(Campfire / Rogue Bonfire RB)、立式火炬(Torch TO)、罗格营地旗帜(Banners N1/N2)、传送点(Waypoint WP)等全部静止不动,画面缺少暗黑 2 原版活灵活现的火光与环境氛围。
  2. 离线打包管线(scripts/pack-act-assets.ts)存在首帧截断:
    • 打包管线在第 1348 行(DC6)与第 1359 行(DCC)解码精灵时,硬编码只取了首帧(f = dir?.frames[0]),丢弃了后续全部帧。
    • scene.json 中每个 PackObject 仅记录单一静态的 frame: { page, x, y, width, height, offsetX, offsetY }。
    • objects-0.png 仅包含了单张首帧切片(整张图集仅 21KB)。
  3. 运行时渲染管线(src/scene/act-scene.ts)缺少帧状态推进:
    • 现有 ObjectDrawable 接口仅保留静态单一 frame: AtlasFrame,未建立类似 DT1 地块瓦片(animatedFrames)的动画切帧与时钟推进逻辑,导致物件永远固定在第 0 帧。

2. 暗黑破坏神 2 v1.13c 原版规范(Ground Truth Invariant)

严格对齐 D2 v1.13c 反编译汇编(D2Common.dll ordinals #10350, #10374 / 0x6FDBEC10 UNITS_ChangeAnimMode)与数据表 Objects.txt、AnimData.d2:

2.1 实体类型与动画模式(Mode)

  • 场景装饰与功能物件属于 UNIT_OBJECT(Type 2)。
  • 物件在地图放置时具有初始模式(由 src/game/object-lookup-data.ts 定义):
    • 营地篝火 RB: Mode 2 (ON), Objects.txt Id 39 (rbtrlitonhth.dcc)。
    • 营地火炬 TO: Mode 2 (ON), Objects.txt Id 37 (totrlitonhth.dcc)。
    • 罗格营地旗帜 N1/N2: Mode 0 (NU), Objects.txt Id 35/36 (N1trlitnuhth.dcc / N2trlitnuhth.dcc)。
    • 传送点 WP: Mode 2 (ON), Objects.txt Id 119 (wptrlitonhth.dcc)。
    • 储物箱 B6: Mode 0 (NU), Objects.txt Id 267 (b6trlitnuhth.dcc,静态 1 帧)。

2.2 总帧数与播放速率公式(FrameCnt & FrameDelta)

  • Objects.txt 权威定义了各模式的帧数与播放速率:
    • 营地篝火 RB (Mode 2 ON):FrameCnt2 = 20, FrameDelta2 = 256, CycleAnim2 = 1。DCC 对应 20 帧,播放速率 256/256 = 1.0 帧/tick(25Hz 下为 25 FPS,即 40ms/帧)。
    • 营地火炬 TO (Mode 2 ON):FrameCnt2 = 20, FrameDelta2 = 200, CycleAnim2 = 1。DCC 对应 20 帧,播放速率 200/256 ≈ 0.78125 帧/tick(25Hz 下约为 19.53 FPS,即 51.2ms/帧)。
    • 营地旗帜 N1/N2 (Mode 0 NU):FrameCnt0 = 10, FrameDelta0 = 256, CycleAnim0 = 1。
  • 原版定点数帧累加机制(8.8 fixed-point):
    pUnit->nSeqCurrentFramePrecise += pUnit->wAnimSpeed;
    if (pUnit->nSeqCurrentFramePrecise >= pUnit->dwFrameCountPrecise) {
        pUnit->nSeqCurrentFramePrecise -= pUnit->dwFrameCountPrecise;
    }
    int nCurrentFrame = pUnit->nSeqCurrentFramePrecise >> 8;
    

2.3 去同步与高熵相位扰动(Desynchronization & Phase Jitter)

  • 原版 D2Common.0x6FDBEC10:对于无同步标记的物件(!pObjectsTxtRecord->nSync),会引入微扰速度:
    wAnimSpeed = uRandom + wFrameDelta - (wFrameDelta / 16),其中 uRandom = roll(wFrameDelta / 8)。
  • 罗格营地中分布着 12 根火炬,原版通过位置种子给每个火炬注入不同的起始相位,避免全部火炬如同机械信号灯一般完全同步闪烁。

3. 技术架���与实现方案

3.1 离线资产烘焙管线升级(scripts/pack-act-assets.ts)

  1. 多帧序列检测与图集打包:
    • 读取对象 DCC/DC6 成员时,获取方向 0 的完整帧数 frameCount。
    • 若 frameCount > 1 且对象具备循环动画属性(cycleAnim === 1):
      • 依次将 0 ~ frameCount - 1 帧写入 objectPages(图集)。
      • 计算每帧的像素偏移:
        • DCC: offsetX = -16 + dir.box.left, offsetY = 16 + dir.box.top。
        • DC6: offsetX = -16 + f.offsetX, offsetY = 16 + (f.offsetY - f.height + 1)。
  2. 图元去重与纹理预算控制:
    • 保持 placementByMember 级别的缓存机制:营地内全部 12 根火炬复用同一套 20 帧切片。
    • 营地所有动态对象全部切片总像素约 550,000 像素,在单张 1024×1024 图集内即可完全容纳,体积增量仅约 80KB~130KB。
  3. 数据契约扩展(scene.json):
    • PackObject 增加 animatedFrames 与动画时钟元数据:
      export interface PackObjectFrame {
        readonly page: number
        readonly x: number
        readonly y: number
        readonly width: number
        readonly height: number
        readonly offsetX: number
        readonly offsetY: number
      }
      
      export interface PackObject {
        readonly id: number
        readonly type: number
        readonly name: string
        readonly token: string
        readonly mode: string
        readonly objectsTxtId?: number
        readonly frame?: PackObjectFrame | null // 保留首帧向后兼容
        readonly animatedFrames?: readonly PackObjectFrame[] | null // 序列帧列表
        readonly frameDelta?: number // 来源于 Objects.txt FrameDelta (如 200, 256)
        readonly cycleAnim?: boolean // 是否循环
        readonly x: number
        readonly y: number
        readonly depth: number
      }
      

3.2 运行时场景与渲染对接(src/scene/act-scene.ts)

  1. ObjectDrawable 扩展:
    • 支持动态切换帧索引:
      interface ObjectDrawable {
        frame: AtlasFrame
        readonly x: number
        readonly y: number
        readonly depth: number
        page: number
        readonly animatedFrames?: readonly AnimatedObjectFrame[] | undefined
        readonly frameDurationMs?: number | undefined
        readonly cycleAnim?: boolean | undefined
        readonly phaseOffsetMs?: number | undefined
        currentFrameIndex?: number | undefined
      }
      
  2. 基于 SplitMix64 的高熵空间相位哈希:
    • 根据物件坐标 (x, y) 注入确定的初始相位偏移 phaseOffsetMs,使多根火炬交错燃烧。
  3. 帧轮转调度:
    • 在每帧渲染或仿真 tick 中推进帧:
      const elapsed = renderStarted + (d.phaseOffsetMs ?? 0)
      const frameIdx = Math.floor(elapsed / d.frameDurationMs) % d.animatedFrames.length
      if (frameIdx !== d.currentFrameIndex) {
        d.currentFrameIndex = frameIdx
        d.frame = d.animatedFrames[frameIdx].frame
        d.page = d.animatedFrames[frameIdx].page
      }
      
    • 保持原有的深度排序(depth)与批处理状态不变。

4. 验收标准与验证方案

  1. 真实动态视觉效果:
    • 罗格营地中央的篝火(Bonfire RB)具有 20 帧火焰跃动与火舌升腾动画。
    • 营地各处的立式火炬(Torch TO)具有 20 帧火焰摇曳动态,且各火炬间具有自然的交错相位,无机械同步感。
    • 营地旗帜(Banners N1/N2)呈现 10 帧随风飘拂效果;传送点(Waypoint WP)处于激活运转状态。
  2. 静态物件零退化:
    • 储物箱(Stash B6)、石块、死树等无动画物件保持单帧轻量存储,不产生额外图集与计算开销。
  3. 坐标与遮挡不变性(Anchor & Depth Parity):
    • 各帧精灵的 (offsetX, offsetY) 精确对齐底部中心基准点,动画播放过程中无抖动、漂移或脚���断层现象。
    • 深度排序(Painter's Order)在玩家与 NPC 穿行于篝火/火炬前后时保持严格正确。
  4. 全量自动化测试覆盖:
    • 编写 tests/object-animation-113c.test.ts:
      • 断言 towne1 场景导出的动态物件帧数与 Objects.txt 完全一致。
      • 断言帧间隔时间(frameDurationMs)与原版 FrameDelta(200 / 256)数学换算吻合。
      • 验证 headless 环境下 60 帧仿真渲染过程中帧索引正确轮转。
    • 保证现有 npm run typecheck 与 npm test 100% 通过。
## 1. 问题现状与视觉缺陷分析 在当前 Web 版场景(例如罗格营地 Act 1 Town `towne1`)中: 1. **场景物件完全静态**: - 地图上的营地篝火(Campfire / Rogue Bonfire `RB`)、立式火炬(Torch `TO`)、罗格营地旗帜(Banners `N1`/`N2`)、传送点(Waypoint `WP`)等全部静止不动,画面缺少暗黑 2 原版活灵活现的火光与环境氛围。 2. **离线打包管线(`scripts/pack-act-assets.ts`)存在首帧截断**: - 打包管线在第 1348 行(DC6)与第 1359 行(DCC)解码精灵时,硬编码只取了首帧(`f = dir?.frames[0]`),丢弃了后续全部帧。 - `scene.json` 中每个 `PackObject` 仅记录单一静态的 `frame: { page, x, y, width, height, offsetX, offsetY }`。 - `objects-0.png` 仅包含了单张首帧切片(整张图集仅 21KB)。 3. **运行时渲染管线(`src/scene/act-scene.ts`)缺少帧状态推进**: - 现有 `ObjectDrawable` 接口仅保留静态单一 `frame: AtlasFrame`,未建立类似 DT1 地块瓦片(`animatedFrames`)的动画切帧与时钟推进逻辑,导致物件永远固定在第 0 帧。 --- ## 2. 暗黑破坏神 2 v1.13c 原版规范(Ground Truth Invariant) 严格对齐 D2 v1.13c 反编译汇编(`D2Common.dll` ordinals `#10350`, `#10374` / 0x6FDBEC10 `UNITS_ChangeAnimMode`)与数据表 `Objects.txt`、`AnimData.d2`: ### 2.1 实体类型与动画模式(Mode) - 场景装饰与功能物件属于 `UNIT_OBJECT`(Type 2)。 - 物件在地图放置时具有初始模式(由 `src/game/object-lookup-data.ts` 定义): - 营地篝火 `RB`: Mode 2 (`ON`), `Objects.txt` Id 39 (`rbtrlitonhth.dcc`)。 - 营地火炬 `TO`: Mode 2 (`ON`), `Objects.txt` Id 37 (`totrlitonhth.dcc`)。 - 罗格营地旗帜 `N1`/`N2`: Mode 0 (`NU`), `Objects.txt` Id 35/36 (`N1trlitnuhth.dcc` / `N2trlitnuhth.dcc`)。 - 传送点 `WP`: Mode 2 (`ON`), `Objects.txt` Id 119 (`wptrlitonhth.dcc`)。 - 储物箱 `B6`: Mode 0 (`NU`), `Objects.txt` Id 267 (`b6trlitnuhth.dcc`,静态 1 帧)。 ### 2.2 总帧数与播放速率公式(FrameCnt & FrameDelta) - `Objects.txt` 权威定义了各模式的帧数与播放速率: - **营地篝火 `RB` (Mode 2 ON)**:`FrameCnt2 = 20`, `FrameDelta2 = 256`, `CycleAnim2 = 1`。DCC 对应 20 帧,播放速率 256/256 = 1.0 帧/tick(25Hz 下为 25 FPS,即 40ms/帧)。 - **营地火炬 `TO` (Mode 2 ON)**:`FrameCnt2 = 20`, `FrameDelta2 = 200`, `CycleAnim2 = 1`。DCC 对应 20 帧,播放速率 200/256 ≈ 0.78125 帧/tick(25Hz 下约为 19.53 FPS,即 51.2ms/帧)。 - **营地旗帜 `N1`/`N2` (Mode 0 NU)**:`FrameCnt0 = 10`, `FrameDelta0 = 256`, `CycleAnim0 = 1`。 - 原版定点数帧累加机制(8.8 fixed-point): ```cpp pUnit->nSeqCurrentFramePrecise += pUnit->wAnimSpeed; if (pUnit->nSeqCurrentFramePrecise >= pUnit->dwFrameCountPrecise) { pUnit->nSeqCurrentFramePrecise -= pUnit->dwFrameCountPrecise; } int nCurrentFrame = pUnit->nSeqCurrentFramePrecise >> 8; ``` ### 2.3 去同步与高熵相位扰动(Desynchronization & Phase Jitter) - 原版 `D2Common.0x6FDBEC10`:对于无同步标记的物件(`!pObjectsTxtRecord->nSync`),会引入微扰速度: `wAnimSpeed = uRandom + wFrameDelta - (wFrameDelta / 16)`,其中 `uRandom = roll(wFrameDelta / 8)`。 - 罗格营地中分布着 12 根火炬,原版通过位置种子给每个火炬注入不同的起始相位,避免全部火炬如同机械信号灯一般完全同步闪烁。 --- ## 3. 技术架���与实现方案 ### 3.1 离线资产烘焙管线升级(`scripts/pack-act-assets.ts`) 1. **多帧序列检测与图集打包**: - 读取对象 DCC/DC6 成员时,获取方向 0 的完整帧数 `frameCount`。 - 若 `frameCount > 1` 且对象具备循环动画属性(`cycleAnim === 1`): - 依次将 0 ~ `frameCount - 1` 帧写入 `objectPages`(图集)。 - 计算每帧的像素偏移: - DCC: `offsetX = -16 + dir.box.left`, `offsetY = 16 + dir.box.top`。 - DC6: `offsetX = -16 + f.offsetX`, `offsetY = 16 + (f.offsetY - f.height + 1)`。 2. **图元去重与纹理预算控制**: - 保持 `placementByMember` 级别的缓存机制:营地内全部 12 根火炬复用同一套 20 帧切片。 - 营地所有动态对象全部切片总像素约 550,000 像素,在单张 1024×1024 图集内即可完全容纳,体积增量仅约 80KB~130KB。 3. **数据契约扩展(`scene.json`)**: - `PackObject` 增加 `animatedFrames` 与动画时钟元数据: ```typescript export interface PackObjectFrame { readonly page: number readonly x: number readonly y: number readonly width: number readonly height: number readonly offsetX: number readonly offsetY: number } export interface PackObject { readonly id: number readonly type: number readonly name: string readonly token: string readonly mode: string readonly objectsTxtId?: number readonly frame?: PackObjectFrame | null // 保留首帧向后兼容 readonly animatedFrames?: readonly PackObjectFrame[] | null // 序列帧列表 readonly frameDelta?: number // 来源于 Objects.txt FrameDelta (如 200, 256) readonly cycleAnim?: boolean // 是否循环 readonly x: number readonly y: number readonly depth: number } ``` ### 3.2 运行时场景与渲染对接(`src/scene/act-scene.ts`) 1. **`ObjectDrawable` 扩展**: - 支持动态切换帧索引: ```typescript interface ObjectDrawable { frame: AtlasFrame readonly x: number readonly y: number readonly depth: number page: number readonly animatedFrames?: readonly AnimatedObjectFrame[] | undefined readonly frameDurationMs?: number | undefined readonly cycleAnim?: boolean | undefined readonly phaseOffsetMs?: number | undefined currentFrameIndex?: number | undefined } ``` 2. **基于 SplitMix64 的高熵空间相位哈希**: - 根据物件坐标 `(x, y)` 注入确定的初始相位偏移 `phaseOffsetMs`,使多根火炬交错燃烧。 3. **帧轮转调度**: - 在每帧渲染或仿真 tick 中推进帧: ```typescript const elapsed = renderStarted + (d.phaseOffsetMs ?? 0) const frameIdx = Math.floor(elapsed / d.frameDurationMs) % d.animatedFrames.length if (frameIdx !== d.currentFrameIndex) { d.currentFrameIndex = frameIdx d.frame = d.animatedFrames[frameIdx].frame d.page = d.animatedFrames[frameIdx].page } ``` - 保持原有的深度排序(`depth`)与批处理状态不变。 --- ## 4. 验收标准与验证方案 1. **真实动态视觉效果**: - 罗格营地中央的篝火(Bonfire `RB`)具有 20 帧火焰跃动与火舌升腾动画。 - 营地各处的立式火炬(Torch `TO`)具有 20 帧火焰摇曳动态,且各火炬间具有自然的交错相位,无机械同步感。 - 营地旗帜(Banners `N1`/`N2`)呈现 10 帧随风飘拂效果;传送点(Waypoint `WP`)处于激活运转状态。 2. **静态物件零退化**: - 储物箱(Stash `B6`)、石块、死树等无动画物件保持单帧轻量存储,不产生额外图集与计算开销。 3. **坐标与遮挡不变性(Anchor & Depth Parity)**: - 各帧精灵的 `(offsetX, offsetY)` 精确对齐底部中心基准点,动画播放过程中无抖动、漂移或脚���断层现象。 - 深度排序(Painter's Order)在玩家与 NPC 穿行于篝火/火炬前后时保持严格正确。 4. **全量自动化测试覆盖**: - 编写 `tests/object-animation-113c.test.ts`: - 断言 `towne1` 场景导出的动态物件帧数与 `Objects.txt` 完全一致。 - 断言帧间隔时间(`frameDurationMs`)与原版 `FrameDelta`(200 / 256)数学换算吻合。 - 验证 headless 环境下 60 帧仿真渲染过程中帧索引正确轮转。 - 保证现有 `npm run typecheck` 与 `npm test` 100% 通过。
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#387
No description provided.