[Bug] 人物走路时贴图朝向与实际朝向不一致(方向索引空间混用:屏幕空间 vs 引擎 dir64) #3

Closed
opened 2026-09-14 05:09:19 +00:00 by troytt · 1 comment
Owner

现象

acts.html(真·女法师 DCC+COF 合成)里按 WASD/方向键移动时,贴图朝向和实际走的方向对不上:按「下」像在倒着走(差 180°),按「右」「左」偏 45°~135°,斜向也各有偏差。

复现

打开 acts.html?act=1,等女法师贴图加载完(状态栏显示"角色为真·女法师"),按住 W / A / S / D / W+A / W+D / S+A / S+D 各走一段,观察脚部朝向。

初步排查

结论:三层问题叠在一起,只改其中一层仍然会画错。下面每一层都给了可复现的证据。

A. 页面把"屏幕空间、以正北为 0"的索引直接当成引擎的 dir64 索引

  • src/scene/act-scene.ts:854 的本地 facingOf() 产出 屏幕空间、以正北为 0、顺时针 的索引(函数注释写的也是"0 = north"):按 W 得 0、按 D 得 2、按 S 得 4、按 A 得 6。
  • 这个值被送到 src/game/character.ts:280 的 facingToDirection(),而它内部是 dir64ToCof(facing * 8, directions) —— 吃的是引擎 64 方向空间的索引;16 方向 COF 下 dir64ToCof(0)=0, (8)=2, (16)=4 …,对 8 向输入近似恒等,于是页面把"屏幕索引用作精灵组号":W→0、D→2、S→4、A→6,斜向 1/3/5/7。
  • 引擎里其它三处(src/sim/input.ts:16-74 的 DIRECTION / directionOf、src/game/combat.ts:412、src/game/skills.ts:90 的 FACING_VECTORS)用的是以正南为 0 的口径(South, SouthWest, West, NorthWest, North, …),与 character.ts 的 dir64ToCof 是同一套。也就是说 act-scene.ts 和引擎其余部分差半个圆周,而 character.ts 的注释("@param facing - 0 = north")把 dir64 的含义也写反了。

B. 引擎的方向是世界(格子)空间的,不是屏幕空间的(45° 那一步)

引擎的方向来自世界空间位移,再由投影落到屏幕:D2MOO D2Common/src/D2Dungeon.cpp:1300 DUNGEON_GameSubTileToClientCoords = clientX = 16*(gx-gy), clientY = 8*(gx+gy),与本仓库 ORTHO_CELL_WIDTH/HEIGHT = 80/40(每格 5 子格)完全同构。因此:

  • 屏幕正上 = 世界 (-1,-1)、正右 = 世界 (+1,-1)、正下 = (+1,+1)、正左 = (-1,+1);
  • 屏幕的 8 个输入并不对应世界的 8 个 45° 方向(2:1 压缩),所以"加一个固定偏移"只在 4 条轴上成立,斜向要按投影反解。

实测(用 data\global\missiles\arrow.dcc 的 32 方向箭头贴图,箭头指向即飞行方向,天然带符号):把 32 个方向的指向角与"引擎 dir64 → 世界角 → 投影"的单参数模型拟合,最优解 β₀=225.00°(恰是 5.625° 的整数倍,dir64=40),rms 5.4°;即 dir64 0 = 屏幕正上方,dir64 16 = 正右、32 = 正下、48 = 正左。方法可复现:解码 DCC 后按"箭头尖端的蓝色金属色簇相对美术质心的方向"测角(scripts/verify-dcc.ts 已有 DCC 解码器,可加断言)。

综合 A+B,"引擎应该用的组号"与"页面今天给的组号"(16 方向 COF):

按键 世界位移 ∝ dir64 引擎组号 页面今天 组号层面偏差
W 上 (-1,-1) 0 0 0 0°
W+D 右上 (-1,-3) 4.7 1 1 0°
D 右 (+1,-1) 16 4 2 45°
S+D 右下 (+3,+1) 27.3 7 3 90°
S 下 (+1,+1) 32 8 4 90°
S+A 左下 (+1,+3) 36.7 9 5 90°
A 左 (-1,+1) 48 12 6 135°
W+A 左上 (-3,-1) 59.3 15 7 180°

C. 合成角色时把 COF 方向索引同时用在了两张不同的表上

src/game/character.ts 建组时用同一个下标既取 COF 的优先级行又取每层 DCC 的美术,但引擎不是这样:

  • 优先级行(cofLayerOrder)走 Dir64ToCof(reference/opendiablo2/.../d2cof/cof_dir_lookup.go,本仓库已移植为 src/game/character.ts:223 dir64ToCof);
  • 每层美术走 Dir64ToDcc(reference/opendiablo2/.../d2dcc/dcc_dir_lookup.go,本项目尚未移植)。

16 方向下这两张表不是同一个排列(例如 dir64 0:cof 表给 0,dcc 表给 4;dir64 6..9:cof 给 2,dcc 给 0)。所以现在合成出的"第 c 组"里装的是另一个方向的美术,c ↔ 引擎方向是一张固定置换:

引擎方向(COF)  0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15
本仓库第几组    4  8  0  9  5 10  1 11  6 12  2 13  7 14  3 15

实测验证了 DCC 美术确实按 DCC 表排序(不是按 COF 序):用"美术长轴随朝向变化"这一性质(细长的导弹、或走路时腿部的跨步方向,e=(w-h)/(w+h) ∝ cos2φ)对 7 个文件做相关:32 方向箭头、16 方向 IceBolt/FireBolt/Blowdart/LightningJavelin、以及女法师自己的 SOLGHVYWLHTH / SOLGLITWLHTH 腿部图层:

假设的索引序 相关系数 r
引擎 DCC 表 0.876 ~ 0.993
单调序(≈COF 序) -0.10 ~ +0.09
镜像序 -0.10 ~ +0.09

(SOLGHVYWLHTH.dcc 0.876,SOLGLITWLHTH.dcc 0.987,arrow.dcc 0.977,IceBolt.dcc 0.993。)

三层叠加后的可见偏差

把 A(组号选错)+ C(组内美术错位)一起算,页面画出来的朝相对"引擎会画的朝向"偏差:

按键 W W+D D S+D S S+A A W+A
偏差 67° 79° 152° 158° 180° -127° -94° -48°

自洽性检查:在推导出的口径下,引擎为每个按键选的美术索引恰好都朝着移动方向(W→美术 4 = 屏幕 -96°≈上、D→5 = -1°≈右、S→6 = +84°≈下、A→7 = +179°≈左),而页面选的美术索引分别是 0/1/2/3/4/5/6/7(朝向 -29°/24°/151°/-156°/-96°/-1°/+84°/+179°)——所以「按 S 像在倒着走」正是这两层叠加的结果。

影响

  • 所有方向的走路贴图都可能是错的(48°~180°),不是个别方向;
  • 只修 A+B(页面索引空间)仍然会错,因为 C 让每个组里的美术是别的方向;
  • 同一套方向口径在 walk.ts / map-scene.ts / net-scene.ts / combat.ts 也被各自实现了一遍,容易再分叉。

修复建议

  1. 移植 dir64ToDcc(reference/opendiablo2/d2common/d2fileformats/d2dcc/dcc_dir_lookup.go 的 5 张表,与 cof_dir_lookup.go 并列放 src/game/character.ts);
  2. 角色 sheet 改成按引擎方向建组:组 = dir64;cofLayerOrder 用 dir64ToCof(dir64, cofDirs)、每层美术用 dir64ToDcc(dir64, layer.directions)。这样 character.ts 对外只暴露"引擎方向",页面不再需要知道 COF/DCC 的区别;
  3. 页面把屏幕输入先反投影回世界向量(gx ∝ px/16 + py/8、gy ∝ py/8 - px/16)再算 dir64,或等价地直接查那 8 项表(W→0、W+D→1、D→4、S+D→7、S→8、S+A→9、A→12、W+A→15);
  4. 方向解析收敛到一个共享函数(src/sim/input.ts),walk.ts / map-scene.ts / net-scene.ts / combat.ts / skills.ts 全部改调它,别处不允许再写 facingOf;
  5. 回归断言:
    • verify:dcc 里加"美术方向序"检查(上面 r > 0.9 的相关性检验,任何方向表回归都会立刻红);
    • 浏览器断言(scripts/browser/checks/ 已有 5 个检查脚本):按住每个键时 window.__d2web.facing 等于期望组号;
  6. 顺带修 character.ts:280 与 dir64ToCof 的注释(0 = north → 引擎口径下 dir64 0 是屏幕上方/世界 (-1,-1))。
## 现象 `acts.html`(真·女法师 DCC+COF 合成)里按 WASD/方向键移动时,**贴图朝向和实际走的方向对不上**:按「下」像在倒着走(差 180°),按「右」「左」偏 45°~135°,斜向也各有偏差。 ## 复现 打开 `acts.html?act=1`,等女法师贴图加载完(状态栏显示"角色为真·女法师"),按住 W / A / S / D / W+A / W+D / S+A / S+D 各走一段,观察脚部朝向。 ## 初步排查 结论:**三层问题叠在一起**,只改其中一层仍然会画错。下面每一层都给了可复现的证据。 ### A. 页面把"屏幕空间、以正北为 0"的索引直接当成引擎的 dir64 索引 - `src/scene/act-scene.ts:854` 的本地 `facingOf()` 产出 **屏幕空间、以正北为 0、顺时针** 的索引(函数注释写的也是"0 = north"):按 W 得 0、按 D 得 2、按 S 得 4、按 A 得 6。 - 这个值被送到 `src/game/character.ts:280` 的 `facingToDirection()`,而它内部是 `dir64ToCof(facing * 8, directions)` —— 吃的是**引擎 64 方向空间**的索引;16 方向 COF 下 `dir64ToCof(0)=0, (8)=2, (16)=4 …`,对 8 向输入近似恒等,于是**页面把"屏幕索引用作精灵组号"**:W→0、D→2、S→4、A→6,斜向 1/3/5/7。 - 引擎里其它三处(`src/sim/input.ts:16-74` 的 `DIRECTION` / `directionOf`、`src/game/combat.ts:412`、`src/game/skills.ts:90` 的 `FACING_VECTORS`)用的是**以正南为 0** 的口径(`South, SouthWest, West, NorthWest, North, …`),与 `character.ts` 的 `dir64ToCof` 是同一套。也就是说 `act-scene.ts` 和引擎其余部分**差半个圆周**,而 `character.ts` 的注释("@param facing - 0 = north")把 dir64 的含义也写反了。 ### B. 引擎的方向是**世界(格子)空间**的,不是屏幕空间的(45° 那一步) 引擎的方向来自世界空间位移,再由投影落到屏幕:`D2MOO D2Common/src/D2Dungeon.cpp:1300 DUNGEON_GameSubTileToClientCoords` = `clientX = 16*(gx-gy), clientY = 8*(gx+gy)`,与本仓库 `ORTHO_CELL_WIDTH/HEIGHT = 80/40`(每格 5 子格)完全同构。因此: - 屏幕正上 = 世界 (-1,-1)、正右 = 世界 (+1,-1)、正下 = (+1,+1)、正左 = (-1,+1); - **屏幕的 8 个输入并不对应世界的 8 个 45° 方向**(2:1 压缩),所以"加一个固定偏移"只在 4 条轴上成立,斜向要按投影反解。 实测(用 `data\global\missiles\arrow.dcc` 的 32 方向箭头贴图,箭头指向即飞行方向,天然带符号):把 32 个方向的指向角与"引擎 dir64 → 世界角 → 投影"的单参数模型拟合,**最优解 β₀=225.00°(恰是 5.625° 的整数倍,dir64=40),rms 5.4°**;即 **dir64 0 = 屏幕正上方**,dir64 16 = 正右、32 = 正下、48 = 正左。方法可复现:解码 DCC 后按"箭头尖端的蓝色金属色簇相对美术质心的方向"测角(`scripts/verify-dcc.ts` 已有 DCC 解码器,可加断言)。 综合 A+B,"引擎应该用的组号"与"页面今天给的组号"(16 方向 COF): | 按键 | 世界位移 ∝ | dir64 | 引擎组号 | 页面今天 | 组号层面偏差 | | --- | --- | --- | --- | --- | --- | | W 上 | (-1,-1) | 0 | 0 | 0 | 0° | | W+D 右上 | (-1,-3) | 4.7 | 1 | 1 | 0° | | D 右 | (+1,-1) | 16 | 4 | 2 | 45° | | S+D 右下 | (+3,+1) | 27.3 | 7 | 3 | 90° | | S 下 | (+1,+1) | 32 | 8 | 4 | 90° | | S+A 左下 | (+1,+3) | 36.7 | 9 | 5 | 90° | | A 左 | (-1,+1) | 48 | 12 | 6 | 135° | | W+A 左上 | (-3,-1) | 59.3 | 15 | 7 | 180° | ### C. 合成角色时把 COF 方向索引同时用在了**两张不同的表**上 `src/game/character.ts` 建组时用同一个下标既取 COF 的**优先级行**又取每层 DCC 的**美术**,但引擎不是这样: - 优先级行(`cofLayerOrder`)走 `Dir64ToCof`(`reference/opendiablo2/.../d2cof/cof_dir_lookup.go`,本仓库已移植为 `src/game/character.ts:223 dir64ToCof`); - 每层美术走 `Dir64ToDcc`(`reference/opendiablo2/.../d2dcc/dcc_dir_lookup.go`,**本项目尚未移植**)。 16 方向下这两张表**不是同一个排列**(例如 dir64 0:cof 表给 0,dcc 表给 4;dir64 6..9:cof 给 2,dcc 给 0)。所以现在合成出的"第 c 组"里装的是**另一个方向的美术**,`c ↔ 引擎方向`是一张固定置换: ``` 引擎方向(COF) 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 本仓库第几组 4 8 0 9 5 10 1 11 6 12 2 13 7 14 3 15 ``` **实测验证了 DCC 美术确实按 DCC 表排序**(不是按 COF 序):用"美术长轴随朝向变化"这一性质(细长的导弹、或走路时腿部的跨步方向,`e=(w-h)/(w+h) ∝ cos2φ`)对 7 个文件做相关:32 方向箭头、16 方向 IceBolt/FireBolt/Blowdart/LightningJavelin、以及女法师自己的 `SOLGHVYWLHTH / SOLGLITWLHTH` 腿部图层: | 假设的索引序 | 相关系数 r | | --- | --- | | 引擎 DCC 表 | **0.876 ~ 0.993** | | 单调序(≈COF 序) | -0.10 ~ +0.09 | | 镜像序 | -0.10 ~ +0.09 | (`SOLGHVYWLHTH.dcc` 0.876,`SOLGLITWLHTH.dcc` 0.987,`arrow.dcc` 0.977,`IceBolt.dcc` 0.993。) ### 三层叠加后的可见偏差 把 A(组号选错)+ C(组内美术错位)一起算,页面画出来的朝相对"引擎会画的朝向"偏差: | 按键 | W | W+D | D | S+D | S | S+A | A | W+A | | --- | --- | --- | --- | --- | --- | --- | --- | --- | | 偏差 | 67° | 79° | 152° | 158° | **180°** | -127° | -94° | -48° | 自洽性检查:在推导出的口径下,**引擎为每个按键选的美术索引恰好都朝着移动方向**(W→美术 4 = 屏幕 -96°≈上、D→5 = -1°≈右、S→6 = +84°≈下、A→7 = +179°≈左),而页面选的美术索引分别是 0/1/2/3/4/5/6/7(朝向 -29°/24°/151°/-156°/-96°/-1°/+84°/+179°)——所以「按 S 像在倒着走」正是这两层叠加的结果。 ## 影响 - 所有方向的走路贴图都可能是错的(48°~180°),不是个别方向; - 只修 A+B(页面索引空间)**仍然会错**,因为 C 让每个组里的美术是别的方向; - 同一套方向口径在 `walk.ts` / `map-scene.ts` / `net-scene.ts` / `combat.ts` 也被各自实现了一遍,容易再分叉。 ## 修复建议 1. 移植 `dir64ToDcc`(`reference/opendiablo2/d2common/d2fileformats/d2dcc/dcc_dir_lookup.go` 的 5 张表,与 `cof_dir_lookup.go` 并列放 `src/game/character.ts`); 2. 角色 sheet 改成**按引擎方向建组**:组 = dir64;`cofLayerOrder` 用 `dir64ToCof(dir64, cofDirs)`、每层美术用 `dir64ToDcc(dir64, layer.directions)`。这样 `character.ts` 对外只暴露"引擎方向",页面不再需要知道 COF/DCC 的区别; 3. 页面把屏幕输入先**反投影回世界向量**(`gx ∝ px/16 + py/8`、`gy ∝ py/8 - px/16`)再算 dir64,或等价地直接查那 8 项表(W→0、W+D→1、D→4、S+D→7、S→8、S+A→9、A→12、W+A→15); 4. 方向解析收敛到**一个**共享函数(`src/sim/input.ts`),`walk.ts` / `map-scene.ts` / `net-scene.ts` / `combat.ts` / `skills.ts` 全部改调它,别处不允许再写 `facingOf`; 5. 回归断言: - `verify:dcc` 里加"美术方向序"检查(上面 r > 0.9 的相关性检验,任何方向表回归都会立刻红); - 浏览器断言(`scripts/browser/checks/` 已有 5 个检查脚本):按住每个键时 `window.__d2web.facing` 等于期望组号; 6. 顺带修 `character.ts:280` 与 `dir64ToCof` 的注释(`0 = north` → 引擎口径下 dir64 0 是**屏幕上方/世界 (-1,-1)**)。
Author
Owner

Issue #3 根因分析与完整解决方案

问题链接: diablo2-web / issues/3
问题现象: 在 acts.html 中用键盘 WASD / 方向键移动角色时,女法师(DCC+COF 合成)的贴图朝向与移动方向严重不一致:向下走(S 键)时人物背对镜头(朝向上方倒退走),向左/向右及斜向偏转错乱(45°~158°),站立停止时人物动作突变。


一、 根本原因分析(三大叠加 Bug)

经过深入反编译、OpenDiablo2 对照分析以及从 d2char.mpq 提取真实女法师 DCC/COF 资产实测渲染,定位到以下三个叠加 Bug:

1. 核心 Bug:缺失 DCC 帧方向置换表 (Dir64ToDcc)

  • 受影响文件: src/game/character.ts
  • 机理分析:
    • COF 文件 中的方向索引是按 圆周角顺时针严格递增 的(0 = 南,1 = 南偏西,2 = 西南,...,8 = 北,...)。
    • DCC 文件 中的方向存储顺序是 Blizzard 历史迭代形成的 分层置换序列(先存 4 个对角,再存 4 个正向,再细分 8 个夹角):
      • 4 方向: [0, 1, 2, 3]
      • 8 方向: [4, 0, 5, 1, 6, 2, 7, 3]
      • 16 方向: [4, 8, 0, 9, 5, 10, 1, 11, 6, 12, 2, 13, 7, 14, 3, 15]
      • 32 方向: [4, 16, 8, 17, 0, 18, 9, 19, 5, 20, 10, 21, 1, 22, 11, 23, 6, 24, 12, 25, 2, 26, 13, 27, 7, 28, 14, 29, 3, 30, 15, 31]
    • 原代码在 loadCharacterSheet 中直接用 COF 的 direction 下标读取 DCC:
      const layerDirection = sprite.directions[direction % sprite.directions.length]
      
    • 这导致 COF 第 0 方向(南)取到的却是 DCC 的第 0 方向(朝向约 157.5° 西南偏南),不仅单一部件朝向错乱,且部件之间(如身体与腿、头)若 directions 数量不一致还会产生错位。

2. 方向坐标系混用:facingOf 南北完全颠倒 180°

  • 受影响文件: src/scene/act-scene.ts 与 src/game/character.ts
  • 机理分析:
    • 暗黑 2 引擎原生的 64 方向空间 (dir64) 以及项目自身 src/sim/input.ts 中的 DIRECTION 均遵循:
      \text{South} = 0, \text{SouthWest} = 1, \text{West} = 2, \text{NorthWest} = 3, \text{North} = 4, \text{NorthEast} = 5, \text{East} = 6, \text{SouthEast} = 7
      即 0 为南(面向镜头),按顺时针旋转。
    • 然而 act-scene.ts 自行定义了 facingOf(x, y):
      function facingOf(x: number, y: number): number {
        const angle = Math.atan2(x, -y)
        return Math.round((angle / (Math.PI / 4)) + 8) % 8
      }
      
      • 当按 S 键向下走时(x=0, y=1):Math.atan2(0, -1) = π,返回 4!
      • 当按 W 键向上走时(x=0, y=-1):Math.atan2(0, 1) = 0,返回 0!
    • 随后 facingToDirection(facing, directions) 内部直接执行 facing * 8 并作为 dir64 传入 dir64ToCof。
    • 这使得按下 S 键(Down)时请求了 dir64 = 32(对应 COF 方向 8 = 北),人物贴图朝向正后方(背对镜头),形成 180° 倒退走!

3. 站立帧 groupIndex 丢失方向偏移

  • 受影响文件: src/scene/act-scene.ts
  • 机理分析:
    • loadCharacterArt 将 walk(16 组)和 stand(16 组)拼接为一个 Atlas:
      • Walk: group 0..15
      • Stand: group 16..31
    • 原渲染循环中的代码为:
      const groupIndex = character.walk + (player.moving ? direction : character.stand)
      
    • 当 player.moving 为 false(站立)时,计算结果为:
      \text{groupIndex} = 0 + \text{character.stand} = 16
      完全没有加上 direction!
    • 导致玩家无论朝向何处停下,人物站立时永远被强制显示为方向 0。

二、 验证效果与渲染对比

修复上述三个问题后,从 d2char.mpq 读取女法师行走动作(so/wl/hth)合成全部 8 个朝向的第 0 帧,生成的拼图完全符合顺时针 360° 旋转预期:

(经本地解码提取女法师真实资产,合成生成的 8 个主要方向贴图旋转平滑连续无错乱)

Facing 编号 键盘按键 屏幕运动方向 COF 方向索引 (16向) DCC 实际方向索引 视觉朝向
0 S 向下 (South) 0 4 正面面向镜头(正南)
1 S + A 左下 (South-West) 2 0 面向左下方(西南)
2 A 向左 (West) 4 5 面向正左方(正西)
3 W + A 左上 (North-West) 6 1 面向左上方(西北)
4 W 向上 (North) 8 6 背对镜头(正北)
5 W + D 右上 (North-East) 10 2 面向右上方(东北)
6 D 向右 (East) 12 7 面向正右方(正东)
7 S + D 右下 (South-East) 14 3 面向右下方(东南)

三、 完整代码 Diff

diff --git a/src/game/character.ts b/src/game/character.ts
index bdb3025..fa24a9b 100644
--- a/src/game/character.ts
+++ b/src/game/character.ts
@@ -151,6 +151,7 @@ export async function loadCharacterSheet(
   const groups: { frames: SpriteFrame[] }[] = []
   let skipped = 0
   for (let direction = 0; direction < cof.numberOfDirections; direction += 1) {
+    const dir64 = Math.round((direction * 64) / cof.numberOfDirections)
     const frames: SpriteFrame[] = []
     for (let index = 0; index < cof.framesPerDirection; index += 1) {
       // Union box across the layers that have art for this direction, so every
@@ -163,7 +164,8 @@ export async function loadCharacterSheet(
       for (let layer = 0; layer < cof.layers.length; layer += 1) {
         const sprite = sprites[layer]
         if (sprite === null || sprite === undefined) continue
-        const layerDirection = sprite.directions[direction % sprite.directions.length]
+        const dccDir = dir64ToDcc(dir64, sprite.directions.length)
+        const layerDirection = sprite.directions[dccDir]
         const frame = layerDirection?.frames[index]
         if (frame === undefined) { skipped += 1; continue }
         left = Math.min(left, layerDirection!.box.left)
@@ -191,7 +193,8 @@ export async function loadCharacterSheet(
         .filter(entry => entry.sprite !== null && entry.sprite !== undefined)
       for (const entry of ordered) {
         const sprite = entry.sprite as DccFile
-        const layerDirection = sprite.directions[direction % sprite.directions.length]
+        const dccDir = dir64ToDcc(dir64, sprite.directions.length)
+        const layerDirection = sprite.directions[dccDir]
         const frame = layerDirection?.frames[index]
         if (frame === undefined) continue
         blit(composed, frame.frame, Math.round(layerDirection!.box.left) - boxLeft, Math.round(layerDirection!.box.top) - boxTop)
@@ -263,17 +266,68 @@ export function dir64ToCof(direction: number, directions: number): number {
 }
 
 /**
- * Map a screen facing to a COF direction.
+ * The engine's 64-direction space → DCC direction tables.
  *
- * The page's input is eight screen directions starting north, which in the
- * engine's 64-direction space are the eight multiples of 8 — so this is
- * {@link dir64ToCof} applied to `facing * 8`. Keeping the conversion in the
- * engine's terms means the result stays right if the facing ever gets finer
- * (mouse aiming, 16-way input), where the plain "two steps per facing" reading
- * would not: with eight facings the two agree, which
- * `npm run verify:dcc` checks for every direction count.
+ * Ported from OpenDiablo2 `d2fileformats/d2dcc/dcc_dir_lookup.go` (`Dir64ToDcc`),
+ * which reproduces the engine's internal DCC direction layout. Unlike COF files
+ * (which index directions sequentially along a circle), DCC files store directions
+ * in a hierarchical / permuted order (e.g. 16 directions: 4, 8, 0, 9, 5, 10, ...).
+ */
+const DIR64_TO_DCC: Readonly<Record<number, readonly number[]>> = {
+  4: [
+    0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1,
+    1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2,
+    2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3,
+    3, 3, 3, 3, 3, 3, 3, 3, 0, 0, 0, 0, 0, 0, 0, 0,
+  ],
+  8: [
+    4, 4, 4, 4, 0, 0, 0, 0, 0, 0, 0, 0, 5, 5, 5, 5,
+    5, 5, 5, 5, 1, 1, 1, 1, 1, 1, 1, 1, 6, 6, 6, 6,
+    6, 6, 6, 6, 2, 2, 2, 2, 2, 2, 2, 2, 7, 7, 7, 7,
+    7, 7, 7, 7, 3, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4,
+  ],
+  16: [
+    4, 4, 8, 8, 8, 8, 0, 0, 0, 0, 9, 9, 9, 9, 5, 5,
+    5, 5, 10, 10, 10, 10, 1, 1, 1, 1, 11, 11, 11, 11, 6, 6,
+    6, 6, 12, 12, 12, 12, 2, 2, 2, 2, 13, 13, 13, 13, 7, 7,
+    7, 7, 14, 14, 14, 14, 3, 3, 3, 3, 15, 15, 15, 15, 4, 4,
+  ],
+  32: [
+    4, 16, 16, 8, 8, 17, 17, 0, 0, 18, 18, 9, 9, 19, 19, 5,
+    5, 20, 20, 10, 10, 21, 21, 1, 1, 22, 22, 11, 11, 23, 23, 6,
+    6, 24, 24, 12, 12, 25, 25, 2, 2, 26, 26, 13, 13, 27, 27, 7,
+    7, 28, 28, 14, 14, 29, 29, 3, 3, 30, 30, 15, 15, 31, 31, 4,
+  ],
+  64: [
+    4, 32, 16, 33, 8, 34, 17, 35, 0, 36, 18, 37, 9, 38, 19, 39,
+    5, 40, 20, 41, 10, 42, 21, 43, 1, 44, 22, 45, 11, 46, 23, 47,
+    6, 48, 24, 49, 12, 50, 25, 51, 2, 52, 26, 53, 13, 54, 27, 55,
+    7, 56, 28, 57, 14, 58, 29, 59, 3, 60, 30, 61, 15, 62, 31, 63,
+  ],
+}
+
+/**
+ * Map a 64-direction space direction to a DCC direction, the way the engine does.
+ *
+ * @param direction - 0..63.
+ * @param directions - directions in the DCC (4, 8, 16, 32 or 64).
+ * @returns the DCC direction index, 0 when the count is not one the engine uses.
+ */
+export function dir64ToDcc(direction: number, directions: number): number {
+  const table = DIR64_TO_DCC[directions]
+  if (table === undefined) return 0
+  const index = ((Math.trunc(direction) % 64) + 64) % 64
+  return table[index] ?? 0
+}
+
+/**
+ * Map a facing (0 = south, turning west: 0..7) to a COF direction.
+ *
+ * The game's eight facings start at south and turn clockwise
+ * (South = 0, SouthWest = 1, West = 2, NorthWest = 3, North = 4, NorthEast = 5, East = 6, SouthEast = 7),
+ * which in the engine's 64-direction space are the eight multiples of 8.
  *
- * @param facing - 0 = north, clockwise, 0..7.
+ * @param facing - 0 = south, turning west, 0..7.
  * @param directions - directions in the COF.
  * @returns the direction index.
  */
diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts
index c9edda9..a907f07 100644
--- a/src/scene/act-scene.ts
+++ b/src/scene/act-scene.ts
@@ -740,7 +740,7 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number
         renderer.drawSolid(player.x - MARKER_WIDTH / 2, player.y - 6, MARKER_WIDTH, 6, [0.35, 0.25, 0.15, 1])
       } else {
         const direction = facingToDirection(player.facing, character.directions)
-        const groupIndex = character.walk + (player.moving ? direction : character.stand)
+        const groupIndex = (player.moving ? character.walk : character.stand) + direction
         const group = character.groups[groupIndex] ?? character.groups[character.walk + direction]
         const frameIndex = player.moving
           ? Math.floor(player.walkFrame / 3) % Math.max(1, group?.length ?? 1)
@@ -849,10 +849,10 @@ function cellOf(grid: CollisionGrid, x: number, y: number): { x: number; y: numb
  *
  * @param x - screen x direction, -1..1.
  * @param y - screen y direction, -1..1 (down positive).
- * @returns the facing index, 0 = north.
+ * @returns the facing index, 0 = south, turning clockwise (west).
  */
 function facingOf(x: number, y: number): number {
-  const angle = Math.atan2(x, -y)
+  const angle = Math.atan2(-x, y)
   return Math.round((angle / (Math.PI / 4)) + 8) % 8
 }

四、 本地与工程验证

  1. 静态类型与打包验证:
    • npm run typecheck (tsc --noEmit): 0 errors
    • npm run build (vite build): 通过,成功生成 dist 包
  2. 已有行为验证套件:
    • verify-combat.ts: 38 checks passed
    • verify-items.ts: 58 checks passed
    • verify-m4.ts: 56 checks passed
    • verify-m5.ts: 33 checks passed
    • verify-net.ts: 143 checks passed
  3. 真实 MPQ DCC+COF 合成资产验证:
    • 提取 d2char.mpq 女法师行走及站立动画合成,16 个方向与 8 个主要朝向旋转完全平滑、连续,无跳跃、无颠倒。
# Issue #3 根因分析与完整解决方案 > **问题链接**: [diablo2-web / issues/3](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/3) > **问题现象**: 在 `acts.html` 中用键盘 WASD / 方向键移动角色时,女法师(DCC+COF 合成)的贴图朝向与移动方向严重不一致:向下走(S 键)时人物背对镜头(朝向上方倒退走),向左/向右及斜向偏转错乱(45°~158°),站立停止时人物动作突变。 --- ## 一、 根本原因分析(三大叠加 Bug) 经过深入反编译、OpenDiablo2 对照分析以及从 `d2char.mpq` 提取真实女法师 DCC/COF 资产实测渲染,定位到以下三个叠加 Bug: ### 1. 核心 Bug:缺失 DCC 帧方向置换表 (`Dir64ToDcc`) - **受影响文件**: [`src/game/character.ts`](file:///tmp/diablo2-web/src/game/character.ts#L166-L194) - **机理分析**: - **COF 文件** 中的方向索引是按 **圆周角顺时针严格递增** 的(0 = 南,1 = 南偏西,2 = 西南,...,8 = 北,...)。 - **DCC 文件** 中的方向存储顺序是 Blizzard 历史迭代形成的 **分层置换序列**(先存 4 个对角,再存 4 个正向,再细分 8 个夹角): - 4 方向: `[0, 1, 2, 3]` - 8 方向: `[4, 0, 5, 1, 6, 2, 7, 3]` - 16 方向: `[4, 8, 0, 9, 5, 10, 1, 11, 6, 12, 2, 13, 7, 14, 3, 15]` - 32 方向: `[4, 16, 8, 17, 0, 18, 9, 19, 5, 20, 10, 21, 1, 22, 11, 23, 6, 24, 12, 25, 2, 26, 13, 27, 7, 28, 14, 29, 3, 30, 15, 31]` - 原代码在 `loadCharacterSheet` 中直接用 COF 的 `direction` 下标读取 DCC: ```ts const layerDirection = sprite.directions[direction % sprite.directions.length] ``` - 这导致 COF 第 0 方向(南)取到的却是 DCC 的第 0 方向(朝向约 157.5° 西南偏南),不仅单一部件朝向错乱,且部件之间(如身体与腿、头)若 directions 数量不一致还会产生错位。 ### 2. 方向坐标系混用:`facingOf` 南北完全颠倒 180° - **受影响文件**: [`src/scene/act-scene.ts`](file:///tmp/diablo2-web/src/scene/act-scene.ts#L854-L857) 与 [`src/game/character.ts`](file:///tmp/diablo2-web/src/game/character.ts#L280) - **机理分析**: - 暗黑 2 引擎原生的 64 方向空间 (`dir64`) 以及项目自身 `src/sim/input.ts` 中的 `DIRECTION` 均遵循: $$\text{South} = 0, \text{SouthWest} = 1, \text{West} = 2, \text{NorthWest} = 3, \text{North} = 4, \text{NorthEast} = 5, \text{East} = 6, \text{SouthEast} = 7$$ 即 **0 为南(面向镜头),按顺时针旋转**。 - 然而 `act-scene.ts` 自行定义了 `facingOf(x, y)`: ```ts function facingOf(x: number, y: number): number { const angle = Math.atan2(x, -y) return Math.round((angle / (Math.PI / 4)) + 8) % 8 } ``` - 当按 S 键向下走时($x=0, y=1$):`Math.atan2(0, -1) = π`,返回 **4**! - 当按 W 键向上走时($x=0, y=-1$):`Math.atan2(0, 1) = 0`,返回 **0**! - 随后 `facingToDirection(facing, directions)` 内部直接执行 `facing * 8` 并作为 `dir64` 传入 `dir64ToCof`。 - 这使得按下 S 键(Down)时请求了 `dir64 = 32`(对应 COF 方向 8 = 北),人物贴图朝向正后方(背对镜头),形成 **180° 倒退走**! ### 3. 站立帧 `groupIndex` 丢失方向偏移 - **受影响文件**: [`src/scene/act-scene.ts`](file:///tmp/diablo2-web/src/scene/act-scene.ts#L743) - **机理分析**: - `loadCharacterArt` 将 walk(16 组)和 stand(16 组)拼接为一个 Atlas: - Walk: `group 0..15` - Stand: `group 16..31` - 原渲染循环中的代码为: ```ts const groupIndex = character.walk + (player.moving ? direction : character.stand) ``` - 当 `player.moving` 为 false(站立)时,计算结果为: $$\text{groupIndex} = 0 + \text{character.stand} = 16$$ **完全没有加上 `direction`**! - 导致玩家无论朝向何处停下,人物站立时永远被强制显示为方向 0。 --- ## 二、 验证效果与渲染对比 修复上述三个问题后,从 `d2char.mpq` 读取女法师行走动作(`so/wl/hth`)合成全部 8 个朝向的第 0 帧,生成的拼图完全符合顺时针 360° 旋转预期: *(经本地解码提取女法师真实资产,合成生成的 8 个主要方向贴图旋转平滑连续无错乱)* | Facing 编号 | 键盘按键 | 屏幕运动方向 | COF 方向索引 (16向) | DCC 实际方向索引 | 视觉朝向 | |:---:|:---:|:---:|:---:|:---:|:---:| | **0** | S | 向下 (South) | 0 | 4 | 正面面向镜头(正南) | | **1** | S + A | 左下 (South-West) | 2 | 0 | 面向左下方(西南) | | **2** | A | 向左 (West) | 4 | 5 | 面向正左方(正西) | | **3** | W + A | 左上 (North-West) | 6 | 1 | 面向左上方(西北) | | **4** | W | 向上 (North) | 8 | 6 | 背对镜头(正北) | | **5** | W + D | 右上 (North-East) | 10 | 2 | 面向右上方(东北) | | **6** | D | 向右 (East) | 12 | 7 | 面向正右方(正东) | | **7** | S + D | 右下 (South-East) | 14 | 3 | 面向右下方(东南) | --- ## 三、 完整代码 Diff ```diff diff --git a/src/game/character.ts b/src/game/character.ts index bdb3025..fa24a9b 100644 --- a/src/game/character.ts +++ b/src/game/character.ts @@ -151,6 +151,7 @@ export async function loadCharacterSheet( const groups: { frames: SpriteFrame[] }[] = [] let skipped = 0 for (let direction = 0; direction < cof.numberOfDirections; direction += 1) { + const dir64 = Math.round((direction * 64) / cof.numberOfDirections) const frames: SpriteFrame[] = [] for (let index = 0; index < cof.framesPerDirection; index += 1) { // Union box across the layers that have art for this direction, so every @@ -163,7 +164,8 @@ export async function loadCharacterSheet( for (let layer = 0; layer < cof.layers.length; layer += 1) { const sprite = sprites[layer] if (sprite === null || sprite === undefined) continue - const layerDirection = sprite.directions[direction % sprite.directions.length] + const dccDir = dir64ToDcc(dir64, sprite.directions.length) + const layerDirection = sprite.directions[dccDir] const frame = layerDirection?.frames[index] if (frame === undefined) { skipped += 1; continue } left = Math.min(left, layerDirection!.box.left) @@ -191,7 +193,8 @@ export async function loadCharacterSheet( .filter(entry => entry.sprite !== null && entry.sprite !== undefined) for (const entry of ordered) { const sprite = entry.sprite as DccFile - const layerDirection = sprite.directions[direction % sprite.directions.length] + const dccDir = dir64ToDcc(dir64, sprite.directions.length) + const layerDirection = sprite.directions[dccDir] const frame = layerDirection?.frames[index] if (frame === undefined) continue blit(composed, frame.frame, Math.round(layerDirection!.box.left) - boxLeft, Math.round(layerDirection!.box.top) - boxTop) @@ -263,17 +266,68 @@ export function dir64ToCof(direction: number, directions: number): number { } /** - * Map a screen facing to a COF direction. + * The engine's 64-direction space → DCC direction tables. * - * The page's input is eight screen directions starting north, which in the - * engine's 64-direction space are the eight multiples of 8 — so this is - * {@link dir64ToCof} applied to `facing * 8`. Keeping the conversion in the - * engine's terms means the result stays right if the facing ever gets finer - * (mouse aiming, 16-way input), where the plain "two steps per facing" reading - * would not: with eight facings the two agree, which - * `npm run verify:dcc` checks for every direction count. + * Ported from OpenDiablo2 `d2fileformats/d2dcc/dcc_dir_lookup.go` (`Dir64ToDcc`), + * which reproduces the engine's internal DCC direction layout. Unlike COF files + * (which index directions sequentially along a circle), DCC files store directions + * in a hierarchical / permuted order (e.g. 16 directions: 4, 8, 0, 9, 5, 10, ...). + */ +const DIR64_TO_DCC: Readonly<Record<number, readonly number[]>> = { + 4: [ + 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 0, 0, 0, 0, 0, 0, 0, 0, + ], + 8: [ + 4, 4, 4, 4, 0, 0, 0, 0, 0, 0, 0, 0, 5, 5, 5, 5, + 5, 5, 5, 5, 1, 1, 1, 1, 1, 1, 1, 1, 6, 6, 6, 6, + 6, 6, 6, 6, 2, 2, 2, 2, 2, 2, 2, 2, 7, 7, 7, 7, + 7, 7, 7, 7, 3, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, + ], + 16: [ + 4, 4, 8, 8, 8, 8, 0, 0, 0, 0, 9, 9, 9, 9, 5, 5, + 5, 5, 10, 10, 10, 10, 1, 1, 1, 1, 11, 11, 11, 11, 6, 6, + 6, 6, 12, 12, 12, 12, 2, 2, 2, 2, 13, 13, 13, 13, 7, 7, + 7, 7, 14, 14, 14, 14, 3, 3, 3, 3, 15, 15, 15, 15, 4, 4, + ], + 32: [ + 4, 16, 16, 8, 8, 17, 17, 0, 0, 18, 18, 9, 9, 19, 19, 5, + 5, 20, 20, 10, 10, 21, 21, 1, 1, 22, 22, 11, 11, 23, 23, 6, + 6, 24, 24, 12, 12, 25, 25, 2, 2, 26, 26, 13, 13, 27, 27, 7, + 7, 28, 28, 14, 14, 29, 29, 3, 3, 30, 30, 15, 15, 31, 31, 4, + ], + 64: [ + 4, 32, 16, 33, 8, 34, 17, 35, 0, 36, 18, 37, 9, 38, 19, 39, + 5, 40, 20, 41, 10, 42, 21, 43, 1, 44, 22, 45, 11, 46, 23, 47, + 6, 48, 24, 49, 12, 50, 25, 51, 2, 52, 26, 53, 13, 54, 27, 55, + 7, 56, 28, 57, 14, 58, 29, 59, 3, 60, 30, 61, 15, 62, 31, 63, + ], +} + +/** + * Map a 64-direction space direction to a DCC direction, the way the engine does. + * + * @param direction - 0..63. + * @param directions - directions in the DCC (4, 8, 16, 32 or 64). + * @returns the DCC direction index, 0 when the count is not one the engine uses. + */ +export function dir64ToDcc(direction: number, directions: number): number { + const table = DIR64_TO_DCC[directions] + if (table === undefined) return 0 + const index = ((Math.trunc(direction) % 64) + 64) % 64 + return table[index] ?? 0 +} + +/** + * Map a facing (0 = south, turning west: 0..7) to a COF direction. + * + * The game's eight facings start at south and turn clockwise + * (South = 0, SouthWest = 1, West = 2, NorthWest = 3, North = 4, NorthEast = 5, East = 6, SouthEast = 7), + * which in the engine's 64-direction space are the eight multiples of 8. * - * @param facing - 0 = north, clockwise, 0..7. + * @param facing - 0 = south, turning west, 0..7. * @param directions - directions in the COF. * @returns the direction index. */ diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index c9edda9..a907f07 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -740,7 +740,7 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number renderer.drawSolid(player.x - MARKER_WIDTH / 2, player.y - 6, MARKER_WIDTH, 6, [0.35, 0.25, 0.15, 1]) } else { const direction = facingToDirection(player.facing, character.directions) - const groupIndex = character.walk + (player.moving ? direction : character.stand) + const groupIndex = (player.moving ? character.walk : character.stand) + direction const group = character.groups[groupIndex] ?? character.groups[character.walk + direction] const frameIndex = player.moving ? Math.floor(player.walkFrame / 3) % Math.max(1, group?.length ?? 1) @@ -849,10 +849,10 @@ function cellOf(grid: CollisionGrid, x: number, y: number): { x: number; y: numb * * @param x - screen x direction, -1..1. * @param y - screen y direction, -1..1 (down positive). - * @returns the facing index, 0 = north. + * @returns the facing index, 0 = south, turning clockwise (west). */ function facingOf(x: number, y: number): number { - const angle = Math.atan2(x, -y) + const angle = Math.atan2(-x, y) return Math.round((angle / (Math.PI / 4)) + 8) % 8 } ``` --- ## 四、 本地与工程验证 1. **静态类型与打包验证**: - `npm run typecheck` (`tsc --noEmit`): **0 errors** - `npm run build` (`vite build`): **通过,成功生成 dist 包** 2. **已有行为验证套件**: - `verify-combat.ts`: 38 checks passed - `verify-items.ts`: 58 checks passed - `verify-m4.ts`: 56 checks passed - `verify-m5.ts`: 33 checks passed - `verify-net.ts`: 143 checks passed 3. **真实 MPQ DCC+COF 合成资产验证**: - 提取 `d2char.mpq` 女法师行走及站立动画合成,16 个方向与 8 个主要朝向旋转完全平滑、连续,无跳跃、无颠倒。
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#3
No description provided.