diablo2-web/ROADMAP.md

641 lines
36 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# d2web 路线图 · 浏览器内复刻《暗黑破坏神 II》
一句话:**用纯 TypeScript + Vite + WebGL2 从零写的暗黑破坏神 2 引擎**,读取**用户自备**的经典版 MPQ。
仓库**不含任何暴雪素材**,零运行时依赖(devDependencies 只有 vite / typescript / tsx / vitest / @types/node)。
> 本文取代原 `HANDOVER.md`(已删除)。原文中仍然有效的工程知识——引擎铁律、差分验证方法论、
> 环境依赖、性能基线、部署步骤——全部并入本文第二部分。已过期的章节(原 §4f 生成关卡待办、
> §7A-4「DCC 未实现」、§7A「音频未做」)按当前事实重写。
---
# 第一部分:路线图
## 0. 现状诊断
工程已经完成了**最难且最不可替代的部分**:
| 已完成 | 证据 |
| --- | --- |
| MPQ v1 容器 + PKWARE DCL implode | `verify:implode` 30914 成员 / 893 MB 全解,12/12 |
| DT1 / DS1 / DC6 / CEL / PCX / PAL / PL2 解码器 | `verify:d2`、`verify:formats` |
| **DCC + COF 合成管线** | `tests/dcc.test.ts` 1633 members / 350032 frames / 0 failed;真·女法师 16 层 × 8 方向 |
| **ADPCM + Huffman 音频解码** | `tests/mpq-audio.test.ts` 11 项通过(**解码器已就绪,但尚未接入场景层**) |
| 五幕 365 张真实地图离线烘焙 | `verify:packs` 1302/1302,逐帧像素 FNV 哈希比对 278.6 MB |
| DRLG 随机地图生成器(迷宫 70 + 野外 31) | `verify-generators` **788/788 断言**,缺瓦片 0.00%,可达率 100% |
| 战斗 / 物品 / 技能 / 任务 / 存档 / 联机骨架 | `verify:all`(combat 38 + items 58 + m4 56 + m5 33 + net 143) |
> [!IMPORTANT]
> **2026-09-15 修正**:本节最初的判断是「两条轨道从未合流,acts.html 无战斗/无物品/无技能/无存档」。
> 经逐行核查,**这个判断是错的**。`act-scene.ts` 第 777 行就实例化了 `GameEngine`,
> 第 820 行 tick 它,第 962 / 968 / 978 / 984 行分别绘制怪物、NPC、投射物、地面掉落。
> 之前的结论来自 grep `tickCombat` / `spawnMonsters` 的直接导入——而 act-scene 是
> **通过 `GameEngine` 间接获得全部玩法的**。下文是修正后的事实。
真实的架构是**三层**,而不是两条平行轨道:
```mermaid
graph TB
subgraph E["玩法层:src/game/engine.ts (350 行)"]
E1["GameEngine —— 已经是地图无关的共享层"]
E2["只依赖 WorldMapProvider { widthPx, heightPx, overlap() }"]
E3["✅ 战斗 ✅ 物品/背包 ✅ 技能/投射物<br/>✅ 任务/NPC ✅ 存读档 ✅ 掉落/拾取"]
end
subgraph A["表现层 A:act-scene.ts (1437 行) → acts.html"]
A1["✅ 真实等距 DS1/DT1 + 屋顶 + 深度排序"]
A2["✅ 真·女法师 DCC+COF 16 层 × 8 向"]
A3["✅ 已接 GameEngine,已画怪物/NPC/投射物/掉落"]
A4["✅ NPC 用真实 DS1 坐标"]
A5["✅ 存读档经引擎(saving: input.saving)"]
A6["❌ stats: [] —— 唯一的「没有怪物」原因"]
A7["⚠️ overlap 被布尔化,丢失重叠计数梯度"]
A8["❌ 无背包 UI / 无血蓝球(属 M12)"]
end
subgraph B["表现层 B:map-scene.ts (1055 行) → map.html"]
B1["❌ 正交 fixture 地图,非真实 D2"]
B2["🐛 孤儿 world + 12 个死导入"]
B3["🐛 怪物动画冻结(读未 tick 的 world.tick)"]
B4["🐛 存读档绕过引擎"]
B5["✅ 背包 UI + 血蓝球(唯一优势,属 M12)"]
end
E --> A
E --> B
B -.->|"逻辑不正确,M6 决定舍弃"| X["🗑️"]
```
**结论:`GameEngine` 本身就是共享层,不需要再抽一层。**
`acts.html` 现状可概括为:**玩法已经接好,但怪物定义是空数组。**
### `map-scene.ts` 的三个确凿缺陷(M6 决定舍弃它的依据)
1. **孤儿世界**:第 399–403 行 `createWorld` + `spawnMonsters` 建了一个世界,
但第 453 行的 `GameEngine` 又建了自己的世界(`engine.ts:82-83`)。前者从未被 tick。
`let player = world.player`(403 行)此后再未使用。
2. **怪物动画冻结(真实 bug)**:第 572 行
`group[moving ? (world.tick + monster.index * 3) % group.length : 0]`
读的是孤儿世界的 `tick`。而 `world.tick += 1` 只在 `combat.ts:518` 的 `tickCombat` 里发生,
map-scene 从不直接调它。**`world.tick` 恒为 0**,表达式退化成每只怪一个固定帧——
怪物在追击/攻击时动画是静止的。
3. **存读档绕过引擎**:第 488–489 行向 `engine.tick()` 传 `saving: false, loading: false`,
然后在 492–511 行于引擎外部自行读写 `localStorage`。
act-scene 的做法(826–827 行传入真实标志)才是对的;在 tick 外做存读档对确定性是隐患。
另有 **12 个死导入**(`tickCombat`、`rebindPlayer`、`damageMonster`、`castSkill`、
`tickProjectiles`、`QuestLog`、`npcDialog`、`captureSnapshot`、`parseSnapshot`、
`restoreSnapshot`、`serializeSnapshot`、`Rng`)——全部 import 但零使用,
是「逻辑搬进 `GameEngine` 后未清理外壳」留下的残骸。
### demo 数据与真实 D2 的差距
`src/game/demo-data.ts`(45 行)是当前**两条轨道共同的数据源**:
| 项 | 当前 | 真实 D2 |
| --- | --- | --- |
| 怪物 | 3(demo-fallen / zombie / skeleton) | ~700 行 MonStats |
| 经验表 | `[0,0,20,60,140,280]`,5 级 | 99 级 |
| 物品基类 | 3(剑 / 帽 / 药水) | ~500 |
| 词缀 | 2 | ~1000 |
| 技能 | 2(attack / firebolt) | 210(7 职业 × 30) |
| NPC / 任务 | 1 / 1 | 数十 |
### 已确认的项目边界(Master 裁决,2026-09-15)
| 议题 | 裁决 |
| --- | --- |
| M6 合流方向 | ~~抽出第三个共享层~~ → **舍弃 `map-scene.ts`,直接在 `act-scene.ts` 上做**(核查后变更,见 M6) |
| 联机 | **不作为目标**,只需单机;**保留扩展可能**(模拟层纯函数约束必须继续遵守) |
| 怪物精灵包体 | **接受按幕分包 + 按需懒加载** |
| 音频 | 解码器保留,**暂不接入场景层** |
| `.d2s` 存档与官方互通 | **需要**(见 M13) |
---
## 1. 里程碑总览
| # | 里程碑 | Issue | 重要程度 | 风险 | 状态 |
| --- | --- | --- | --- | --- | --- |
| M6 | 收敛到 act-scene 单轨(舍弃 map-scene) | [#21](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/21) | ★★★★★ | 中 | **已完成** |
| M7 | 真实怪物 | [#22](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/22) | ★★★★★ | 中 | **已完成** |
| M8 | 世界连通 | [#23](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/23) | ★★★★★ | 中高 | 待开始 |
| M9 | 真实战斗数学 | [#24](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/24) | ★★★★☆ | 中 | 待开始 |
| M10 | 真实物品系统 | [#25](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/25) | ★★★★☆ | 中 | 待开始 |
| M11 | 七职业与技能树 | [#26](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/26) | ★★★★☆ | 中 | 待开始 |
| M12 | 完整 UI / HUD | [#27](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/27) | ★★★★☆ | 低 | 待开始 |
| M13 | `.d2s` 存档互通 | [#28](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/28) | ★★★★☆ | 中高 | 待开始 |
| M14 | 可破坏物件美术与剧情 | [#29](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/29) | ★★★☆☆ | 中 | 待开始 |
| — | 架构预留:音频 / 联机 | — | — | — | 不在交付路径 |
```mermaid
graph LR
M6["M6 合流"] --> M7["M7 怪物"]
M7 --> M8["M8 连通"]
M8 --> D1{{"交付点 A<br/>能从头走到尾打怪"}}
D1 --> M12["M12 UI/HUD<br/>(建议插队)"]
M12 --> M9["M9 战斗数学"]
M9 --> M10["M10 物品"]
M10 --> D2{{"交付点 B<br/>数值可信"}}
D2 --> M11["M11 职业技能"]
M11 --> D3{{"交付点 C<br/>完整单机"}}
D3 --> M13["M13 .d2s 互通"]
M13 --> M14["M14 物件美术/剧情"]
```
> **M12 插队说明**:UI 素材是 `data\global\ui\` 下的 DC6,解码器已就绪,风险最低;
> 血球/蓝球 + 背包界面带来的感知提升远超工作量。代价是 M9/M10 落地后 UI 要返工一轮数据绑定。
---
## M6 · 舍弃 map-scene.ts,收敛到 act-scene.ts 单轨
**★★★★★ 风险:中(原评估为「高」,核查后下调) 全部后续里程碑的前提**
> [!IMPORTANT]
> **2026-09-15 方案变更(Master 裁决)**:原方案是「抽出第三个共享层,两个页面都变薄」。
> 核查发现 **`GameEngine` 本身就已经是那个共享层**(只依赖 `WorldMapProvider`,地图无关),
> 而 `act-scene.ts` 早已接入它。同时 `map-scene.ts` 被查出三个确凿缺陷(见上文 §0)。
> 因此改为:**舍弃 `map-scene.ts`,直接在 `act-scene.ts` 上继续做,不做合流。**
### 范围(比原计划小得多)
原计划要新建的 `src/scene/shared/gameplay.ts` **不需要了**——`src/game/engine.ts` 就是它。
实际只有三件事:
#### 1. `stats: []` → 真实怪物定义
`act-scene.ts:785` 传的是空数组,而 `combat.ts:376` 是 `if (stats.length === 0) return 0`。
**这一行就是「真地图上没有怪物」的全部原因。** 怪物的绘制、tick、AI 早就接好了。
真实怪物数据的接入属于 M7(#22),M6 只需把这个口子打通并用占位数据验证链路。
#### 2. 修复碰撞语义退化(真实缺陷)
```ts
// act-scene.ts:781 —— 布尔化,丢失重叠计数
overlap: (x, y) => (collides(runtime.grid, x, y) ? 1 : 0)
```
`collides`(`act-scene.ts:1120`)对脚底盒采样 9 个点,任一阻挡即返回 `true`。
但 `combat.ts` 的脱困规则是 `terrain.overlap(x + dx, y) <= current`:
- **有计数时**:陷在墙里 5 格深 → 向 3 格深移动被允许(沿梯度爬出),向 7 格深被拒绝
- **布尔化后**:`1 <= 1` 恒成立 → 在墙里**随机游走**,能出来但不可靠、无方向性
需要一个返回**真实阻挡子格计数**的 `overlap`,并处理等距坐标下 sub-tile
**16×8 的各向异性**(`ORTHO_SUB_TILE_WIDTH = 16` / `ORTHO_SUB_TILE_HEIGHT = 8`)——
`map.ts` 的 `blockedOverlap` 假设 sub-tile 是正方形,不能直接搬。
- **[NEW] `src/game/iso-terrain.ts`** —— 等距 `CollisionGrid` → 计数型 `WorldMapProvider`
#### 3. 删除 map-scene.ts 与 map.html
- **[DELETE] `src/scene/map-scene.ts`**(1055 行)
- **[DELETE] `map.html`**
- **[MODIFY] `src/game/map.ts`** —— 仅保留仍被引用的部分(`SUB_TILES_PER_TILE` 被 `d2map.ts` 依赖)
> [!NOTE]
> **已落地。** `verify-m4` / `verify-m5` 只 import `src/game/*` 与 `src/net/*`,不依赖场景层,
> 因此原样保留、仍然全绿。唯一绑在 `map.html` 上的是
> `scripts/browser/checks/map-save-load.js`,已改写为纯逻辑的
> `tests/engine-save-load.test.ts`:直接用夹具 `WorldMapProvider` 驱动 `GameEngine`,
> 无需 headless Chromium 与 WebGL 上下文,且**顺带补上了浏览器版从未覆盖的路径**——
> 见下。
### GameEngine 里需要一并表格化的硬编码
- ~~`engine.ts:83` 怪物数量写死 8、散布半径写死 260~~ → 已改为可选项
`monsterCount` / `monsterSpread`,默认值保持不变。M7 会用 `Levels.txt` 的
`NumMon` / `MonDen` 填充;现在 act-scene 传占位的 12。
- ~~`engine.ts:89-99` NPC 合成圆环~~ → 已下沉为 `npcRingFallback`(默认 true),
act-scene 传 false,圆环不再被构造出来又丢掉。
### 顺带修掉的一个死接口
`EngineInput` 一直声明了 `saving` / `loading` 两个字段,但 `GameEngine.tick()`
**从不读它们**——存读档是 `map-scene.ts` 在 tick 之外自己调 `localStorage` 做的。
也就是说 `act-scene.ts` 虽然老老实实把 `input.saving` 传了进去,K/L 却什么也不会发生。
改法:引擎新增 `SnapshotStore` 端口(`read()` / `write()`),在 tick 的**最末尾**按
**上升沿**处理这两个输入。这样:
- 存档点永远是一个已结算的 tick(移动、战斗、掉落、拾取都已应用)
- 按住 K 半秒不会存 12 次;按住 L 不会把角色钉在原地
- `localStorage` 仍然留在场景层,模拟层不碰宿主环境(铁律 1)
- 配额满 / 隐私窗口 / 旧版本存档都记进 `metrics.saveError`,不会掀翻主循环
### 必须遵守的铁律
- 怪物与投射物必须插入**现有的等距深度排序**(`pushEntity`),不能另起一层,
否则会穿墙穿屋顶;屋顶层(DS1 wall type 15)仍最后画
- `CombatWorld.player` 与 `CombatWorld.players[i]` **必须是同一个对象**(用 `rebindPlayer()`)
- 渲染后**必须 `renderer.flush()`**
- 模拟层保持纯函数:禁 `Date.now()` / `Math.random()`
### 验收
- [x] `acts.html` 第一幕荒野上能被怪物追、能打死怪、能捡掉落、能 K/L 存读档
(代码路径已接通并由 `tests/engine-save-load.test.ts` 覆盖;**浏览器实测待补**,
当前沙盒里 headless Chrome 无网络,见 `scripts/browser/README.md` 的已知限制。
新增的 `scripts/browser/checks/act-gameplay.js` 就是这一步的检查脚本,
等换到能联网的环境直接跑即可)
- [x] 角色从墙内出生时能**沿梯度**脱困(不是随机游走)
- [x] 新增 `tests/iso-terrain.test.ts` 验证等距碰撞计数的各向异性
- [x] `map.html` 及 `map-scene.ts` 已删除,且 `verify:all` 仍全绿
- [x] `npm run typecheck` 0 错误、`npm test` 不低于 442 passed 基线
---
## M7 · 真实怪物
**★★★★★ 风险:中**
> **这是被严重低估的「便宜」里程碑**:怪物渲染**不需要新解码器**。`character.ts` 的
> `loadCharacterSheet` 文档明确写着它处理 "character **and object** animations",
> `objects.ts` 已定义 `MONSTER_ROOT = 'data\global\monsters\'` 且 `resolveDs1Object`
> 已按 `baseIsMonsters` 分支。法师能渲染出来,就说明 COF/DCC 管线是通的——怪物只差**接线**。
- **[MODIFY] `src/game/acts.ts`** —— `loadActTables` 目前只加载 `levels.txt` / `lvltypes.txt` /
`lvlprest.txt` / `monstats.txt` / `MonPreset.txt`。追加 `MonLvl.txt`(难度缩放)、
`MonType.txt`(族系,影响免疫与克制)、`SuperUniques.txt`(BOSS)、`MonAi.txt`。
- **[MODIFY] `src/game/combat.ts`** —— `monsterStatsFromRow` 目前按 fixture 列名映射,
需**逐列核对真实 `MonStats.txt`**。补齐 `MonsterStats`:抗性、免疫、AI 类型、体型、攻速。
- **[NEW] `src/game/monster-sheet.ts`** —— 封装 `loadCharacterSheet` 走 `MONSTER_ROOT`,
按怪物 token 解析 `<token><component><variant><animation><weapon>.dcc`。
- **[MODIFY] `scripts/pack-act-assets.ts`** —— 怪物精灵**按幕分包 + 按需懒加载**(已确认)。
注意 DT1 解码缓存上限 16 个库的约束(不设限一次全量烘焙会爆 4 GB 内存)。
- **[MODIFY] `src/scene/act-scene.ts`** —— 用 `MonPreset.txt` + DS1 预设点刷怪,而非随机撒点。
**验收**:血色荒野上出现堕落者营地,怪物有 8 向动画、会走位、会攻击。
---
## M8 · 世界连通
**★★★★★ 风险:中高**
目前**每张地图都是孤岛**:`act-scene.ts` 里 `minimap` / `waypoint` / `portal` / `levelChange` /
`transition` 全部 0 命中。生成器侧,`wilderness.ts` 的 `UNIMPLEMENTED_PASSES` 明确列出
`DRLGOUTPLACE_CreateLevelConnections`、`DRLGOUTDOORS_SpawnAct12Waypoint`、
`DRLGOUTDOORS_SpawnAct12Shrines` 未移植——**生成的关卡根本没有连接图**。
- **[NEW] `src/game/world-graph.ts`** —— 从 `Levels.txt` 的 `Vis0..7` / `Warp0..7` 列构建全局连通图。
这比逐个移植 D2MOO pass 更稳妥。
- **[MODIFY] `src/game/wilderness.ts` / `src/game/maze.ts`** —— 在生成阶段落地入口/出口/传送点的
**实际位置**。野外侧已有 `getPerimeterOpenings`(39 号 0 出口、2 号 1 进 1 出),
本里程碑要把这些洞口与连通图的**边**对应起来,而不只是几何上的缺口。
- **[NEW] `src/scene/transition.ts`** —— 关卡切换:淡出 → 卸载旧 scene → 加载新 pack →
在对应入口落地 → 淡入。需保持玩家状态跨关卡存活。
- **[NEW] `src/ui/minimap.ts`** —— 自动地图。数据源是 `CollisionGrid`,
等距投影复用 `((cx-cy)*80, (cx+cy)*40)`。
- **[NEW] `src/game/portal.ts`** —— 城镇传送门 + 传送点网络 + 楼梯。
**验收**:从罗格营地走到血色荒野再进洞穴,再用传送点回城,小地图正确。
---
## M9 · 真实战斗数学
**★★★★☆ 风险:中**
`combat.ts` 第 16–21 行的模块文档**自己承认**:
> "What this is *not*: it is not Diablo II's combat model. Real damage involves attack rating
> versus defence, hit recovery, block, resistances, elemental damage and per-skill formulas.
> This is the skeleton those formulas plug into."
骨架(状态机、冷却、资源池、事件流)已在,本里程碑就是往里填公式。
- **[MODIFY] `src/game/acts.ts`** —— 加载 `ItemStatCost.txt`、`Properties.txt`、`Experience.txt`
(当前 `DEMO_EXPERIENCE` 只有 5 级)。
- **[NEW] `src/game/formulas.ts`** —— 命中率 `AR vs DR`、格挡、硬直/眩晕(hit recovery)、
元素伤害、抗性与免疫、物理/魔法伤害拆分、暴击/致命一击、伤害减免、命中率封顶(5%–95%)。
- **[MODIFY] `src/game/combat.ts`** —— `damageMonster` 从「直接扣血」改为走 `formulas.ts`。
- **[NEW] `tests/formulas.test.ts`** —— **必须做差分验证**:以社区公式文档 + D2MOO 源码为 oracle,
不得自己拍脑袋(见第二部分「验证方法论」)。
**验收**:同一把武器打不同防御的怪,命中率与伤害数字符合社区计算器。
---
## M10 · 真实物品系统
**★★★★☆ 风险:中**
- **[MODIFY] `src/game/acts.ts`** —— 加载 `Armor.txt`、`Weapons.txt`、`Misc.txt`、
`MagicPrefix.txt`、`MagicSuffix.txt`、`UniqueItems.txt`、`SetItems.txt`、
`TreasureClassEx.txt`、`ItemTypes.txt`、`Runes.txt`、`Gems.txt`。
- **[MODIFY] `src/game/items.ts`** —— `itemBaseFromRow` / `affixFromRow` 列映射逐列核对真实表。
新增:品质等级(普通/魔法/稀有/套装/暗金/符文之语)、插槽、镶嵌、耐久、需求。
- **[NEW] `src/game/treasure.ts`** —— `TreasureClassEx.txt` 掉落树。这是 D2 掉落的核心递归结构,
`rollDrop` 现在的实现完全不是这个模型。
- **[NEW] `src/game/runeword.ts`** —— 符文之语。
**验收**:打死怪掉出带词缀的稀有装备,属性生效。
---
## M11 · 七职业与技能树
**★★★★☆ 风险:中 整个路线图里最长的尾巴**
- **[MODIFY] `src/game/acts.ts`** —— 加载 `CharStats.txt`、`Skills.txt`、`SkillDesc.txt`。
- **[NEW] `src/game/classes.ts`** —— 7 职业 COF token:`am` 亚马逊 / `ba` 野蛮人 / `dz` 德鲁伊 /
`ne` 死灵 / `pa` 圣骑士 / `so` 法师 / `as` 刺客。**渲染管线已通,只是换 token**,比听起来便宜。
- **[NEW] `src/game/skill-tree.ts`** —— 每职业 3 系 × 30 技能,含协同加成、前置依赖、技能点分配。
- **[MODIFY] `src/game/skills.ts`** —— 从 2 个 demo 技能扩到 210 个。**纯工作量**,
建议按「常用 30 个先行、其余按需」分批交付。
**验收**:切换到野蛮人,技能树可加点,跳跃/呐喊生效。
---
## M12 · 完整 UI / HUD
**★★★★☆ 风险:低 建议插队到 M8 之后**
- **[NEW] `src/ui/inventory.ts`** —— 背包 10×4 网格 + 装备槽
- **[NEW] `src/ui/character-sheet.ts`** —— 角色面板
- **[NEW] `src/ui/skill-tree-panel.ts`** —— 技能树面板
- **[NEW] `src/ui/belt.ts`** —— 腰带
- **[NEW] `src/ui/globes.ts`** —— 血球 / 蓝球
- **[NEW] `src/ui/hotkeys.ts`** —— 技能快捷键
- **[NEW] `src/ui/font.ts`** —— D2 位图字体(`data\local\font\`)
UI 素材在 `data\global\ui\` 下,是 DC6,**解码器已就绪**。
**验收**:界面截图与原版并排对比。
---
## M13 · `.d2s` 存档互通
**★★★★☆ 风险:中高 依赖 M8 + M10 + M11**
Master 明确要求支持读取真实 `.d2s` 存档并与官方游戏互通。当前 `src/game/save.ts`
只解析签名/版本/名字/职业/等级/校验和,**其余段原样保留字节**。
排在 M11 之后是因为 `.d2s` 的数据段涵盖:角色属性、**全部 30 个技能的加点**、
**背包/身上/腰带/仓库的全部物品**、**任务进度**、**传送点解锁状态**、雇佣兵。
没有 M8/M10/M11 就无法做有意义的往返。
- **[MODIFY] `src/game/save.ts`** —— 逐段实现:
`header`(765B) / `quests`(298B) / `waypoints`(81B) / `npc`(51B) / `stats`(变长位域) /
`skills`(32B) / `items`(变长位域) / `corpse` / `mercenary` / `iron golem`。
- **[NEW] `src/formats/d2s-bits.ts`** —— 物品段是**位对齐**而非字节对齐的变长编码,需要独立的位读写器。
- **[NEW] `scripts/verify-d2s.ts`** —— **往返差分**:读入真实存档 → 解析 → 重新序列化 →
**逐字节与原文件比对**。校验和必须重算正确,否则官方客户端拒绝加载。
> **必须遵守铁律 8:解码器宁可拒绝也不猜。** 猜错的 `.d2s` 解码器会静默产出坏存档,
> 而坏存档可能损坏 Master 的真实角色。**在往返比对逐字节通过之前,写入路径必须默认禁用。**
**验收**:用本引擎读取官方 1.13c 存档,角色属性/技能/背包完全正确;
反向写出的存档能被官方客户端正常加载且角色无损。
---
## M14 · 可破坏物件美术与剧情
**★★★☆☆ 风险:中**
- **[MODIFY] `scripts/pack-act-assets.ts`** —— 烘焙可破坏物件美术。
`data\global\objects\` 下有 **1748 个 DCC + 1461 个 COF,仅 13 个 DC6**;
当前 pack 只烘焙位置与元数据(`Objects.txt` 的名称/HP/Token + 选定美术成员名),像素待补。
> **不要把这些当 DC6 硬解**:头部对不上,解出来是垃圾(已实测)。必须走 DCC 路径。
- **[MODIFY] `src/game/quests.ts`** —— 真实任务链(当前 `questsFromTable` 只有 1 个 demo 任务)。
---
## 架构预留(不在交付路径)
### 音频
ADPCM + Huffman 解码器(`src/mpq/adpcm.ts` + `adpcm-tables.ts` + `huffman.ts`)**已完成,11 项测试通过**,
但从未接入场景层。Master 决定暂不实现,保留扩展。
### 联机
25 Hz 锁步骨架已在(`netplay.ts` 522 行 + `lockstep.ts` 353 行),
三人联机实测跑到 worldTick 306 完全齐平、110–115 个哈希全一致、无 desync。
Master 决定**不作为目标**,但**保留扩展可能**。
> [!IMPORTANT]
> **即使不做联机,M6–M14 的每一次改动都必须守住「模拟层纯函数」这条线**
> (禁 `Date.now()`、禁 `Math.random()`、禁读宿主环境;随机走 `Rng`、时间走 tick 计数)。
> 这条线一旦破了,将来想恢复联机就等于重写;而守住它的额外成本几乎为零。
若将来启用,缺口是:入场大厅/断线重连(现在「少一人就等」)、输入回滚(现在纯等待,
跨洋会明显卡顿)、**背包与任务未进确定性哈希**、联机掉落、联机场景的背包 UI 与血蓝球。
---
## 待 Master 后续裁决
1. **`string.tbl` 中文本地化**:目前中文关卡名是 `src/game/level-names-zh.ts` 里 226 行手工整理的
社区通用译名。我们这份安装包的 `data\local\LNG\CHI\string.tbl` 解出来是乱码;
`src/formats/tbl.ts` 只有**构造检查**级证据(Go 侧 `tbl_text` 实现的是后来带哈希表的变体,
不能当对照)。继续手工维护,还是投入解经典 `.tbl` 索引布局?
2. **完成度目标**:追求「可玩的暗黑 2 体验」(M6–M10 即可交付),
还是「数值级/像素级还原」(必须做到 M12+,工作量约 3 倍)?
---
---
# 第二部分:工程知识(改代码前必读)
## 1. 关键设计约定(铁律)
### 模拟与联机
1. **模拟必须是纯函数**。随机一律走 `Rng`(种子进存档),时间一律走 25 Hz 的 tick 计数。
任何读时钟、读 `Math.random`、读宿主环境的行为都会变成 desync。
2. **tick 只在所有对等方输入到齐时才推进**(`LockstepSession.step` 返回 `waiting`)。抢跑 = 分叉。
3. **输入延迟**(`inputDelayTicks`)是延迟预算;发送游标要从 `0` 补齐到应付 tick,
否则 `0..D-1` 谁都不发,游戏在第一 tick 前就死锁。
4. **握手门控**:没收到对方 hello 之前**不发任何输入**;确认用 `ackTo` 指名道姓。
5. **`CombatWorld.player` 与 `CombatWorld.players[i]` 必须是同一个对象**。
读档/快照靠对象展开拼世界,展开后若发散,症状是「世界模拟一个身体、屏幕画另一个」。
加新字段时要么避开,要么调用 `rebindPlayer()`。
6. **一个 tick 里所有玩家先动、怪物后动且只动一次**(`tickCombatMulti`)。
每人各调一次 `tickCombat` 会把怪物跑两遍。
7. **怪物只攻击「本 tick 开始时还活着」的玩家**;全场无人存活时怪物回合整个跳过。
### 解码与渲染
8. **解码器宁可拒绝也不猜**。没有真文件对照的字段一律**原样保留字节**——
猜错的解码器会静默产出坏存档/坏贴图。
9. **渲染器画完必须 `renderer.flush()`**。不 flush 就只剩清屏色,看起来和「场景没加载」一模一样。
10. **MPQ 文件键用纯文件名**(不是成员全路径,全路径是查哈希表用的);
存储型成员**没有扇区偏移表**(只有 `MPQ_FILE_COMPRESS 0x200` 才有)。
11. **掩码字节与压缩标志是两种写法**。`MPQ_FILE_IMPLODE (0x100)` 单独出现时**没有掩码字节**,
整段就是 implode 流(`Patch_D2.mpq` 的每张表都是后者)。
规则:`body.length === expected` → stored;否则按标志选 implode 或掩码;长度不符一律抛错。
12. **DT1 的 `blockSize` 是 `numBlocks*20 + Σlength`**,不是 `numBlocks*20`。
13. **DS1 的 `style` 匹配 DT1 瓦片自己的 `style` 字段**,不是该 DT1 在 `LvlTypes` 里的下标。
14. **`LvlPrest` 用 `LevelId` 关联,不是 `Def`**(Act 2 城镇 `Def=301 / LevelId=40`)。
15. **等距投影**:格子在屏幕上是 80×40 的菱形 `((cx-cy)*80, (cx+cy)*40)`,5×5 子格是 16×8;
地板画在 `cell+(-80, 0)`,墙要加 `minBlockY+80`(墙的美术长在格子上方)。
16. **空槽要跳过**:DS1 每格带固定数量墙槽,`prop1 == 0` 的是占位
(Act 1 城镇 4674 个墙槽里 4275 个如此),画出来就是垃圾。
17. **瓦片匹配键是 `style:sequence:type`**,其中 `type` 对应 DT1 头 **+20 的 `Type`**,
**不是 +0 的 `Direction`**(那是朝向/变体索引,实测 1..5,永远不会等于 14 树 / 15 屋顶)。
键错会让树/屋顶/影子全落 loose 兜底、画成地面。
同一键下有多张变体图,引擎**逐格**按 `RarityFrameIndex` 加权随机选一张,
种子来自 `(level seed, cellX, cellY)`:`pickVariant` + `levelSeed`。
18. **地面槽位只能画 DT1 type 0**。DS1 的 floor 记录没有 type 字段,引擎语义是 type 0。
传 `null` 走「类型无关」兜底池会随机挑到暗色石墙瓦片,而地面绘制不做 `minBlockY+80` 补偿
→ 画面里出现「悬在地面上的黑色方块」(曾实测 1918/28704 个地面槽画错类型)。
19. **一格的 sub-tile flags 是所有层做 OR**,影子层是 type 13 的独立层,也必须并进碰撞。
20. **方向**:引擎是 64 方向空间经 5 张查表映射到 COF 方向(OD2 `Dir64ToCof`),
已移植为 `character.ts` 的 `dir64ToCof`。8 向输入下 4 方向 COF 的映射与朴素启发式**不同**(2/3 都映到 1)。
21. **屋顶(DS1 wall type 15)单独成层、最后绘制**,偏移用 `-roofHeight`;
`scene.json` 里是 `roofs` 数组。62 个预置关卡里 9 张有屋顶,共 737 个绘制(act 4 城镇 53 个)。
22. **打包器的 DT1 解码缓存上限 16 个库**:不设上限时整轮烘焙会涨到 4 GB 以上。
### DS1 对象
23. **DS1 对象的 `id` 不是 `Objects.txt` 的行号**,而是「该 act 对象表」的索引
(`src/game/object-lookup.ts`,数据由 `npm run port:object-lookup` 从 OD2 的表生成)。
identity 映射会把 act1 的喷泉(id 0)读成 `Objects.txt` 第 0 行 "Expansion"。
---
## 2. 验证方法论(为什么这些数字可信)
- **差分验证**:与独立实现对照,而不是自己跟自己对。用过 StormLib(MIT,只读语义)、
DevilutionX/Devilution、`OpenDiablo2/{dc6,ds1,dt1,pl2,tbl_text}`(Go)、npm `dc6png`、D2MOO。
差分脚本 `scripts/verify-format-parity.sh`。
- **真数据**:暗黑 1 试玩版 `spawn.mpq` 是 MPQ 容器 / CEL / CL2 / 调色板的真实归档证据。
D2 侧用户自备 1.13c 数据在 `samples/d2/`(11 个 MPQ,约 1.9 GB,证据在 `samples/d2/MANIFEST.md`)。
**注意那是汉化/免 CD 整合安装,客户端 DLL 与官方 1.13c 补丁文件集不一致,别拿它做字节级对照。**
- **判据要能失败**:`verify-net.ts` 里的篡改用例会改写飞行中的输入,要求**在正确的 tick 报出 desync**;
「永远不会响的检测器」比没有检测器更糟。
- **不要谎报核对**:无法核对的项单独计数(如 `hashesIgnored`),不算作「一致」。
- **浏览器侧的判定靠状态字段,不靠看图**:`readPixels` 在合成后返回清空缓冲。
页面把状态挂在 `window.__d2webNet` / `window.__d2webMap` / `window.__d2web` 上,由 CDP 读取断言。
---
## 3. 验证命令
```bash
npm run typecheck # 类型检查
npm test # vitest,当前 442 passed / 2 skipped
npm run verify:all # combat + items + m4 + m5 + net + collision + tbl + audio + formats
npx tsx scripts/verify-generators.ts samples/d2 # 生成器:当前 788/788,缺瓦片 0.00%,可达 100%
npm run pack:data && npm run verify:packs # 烘焙 + 逐帧像素比对:当前 1302/1302
npm run verify:implode # 四个归档全量成员解码
npm run verify:acts # 五个 act 城镇:表→DS1→DT1→等距场景 + 碰撞
npm run verify:d2 # 每个 MPQ 的头/块槽/sha256
npm run verify:listfile # 用社区 1.13c listfile 逐归档判定成员是否存在
npm run verify:alignment # 墙/地面基线是否与引擎公式一致
npm run verify:tiles # 每个槽位画的是不是它该有的瓦片类型(地面=0)
npm run verify:object-lookup # DS1 对象 id → token/mode 与 Objects.txt 交叉核对
npm run verify:deploy # 线上页面 + 资源包 + Range 206 + 旧入口 410
```
归档没有 `(listfile)` 时(`Patch_D2.mpq` 就是),成员名在 Storm 里是加密的:
`MpqArchive.open(source, { listfile })` 可挂社区名单,样例见 `scripts/verify-listfile.ts`。
---
## 4. 环境依赖(不在仓库里,重装机器要重建)
| 用途 | 说明 |
| --- | --- |
| Node | v22.22.2。类型剥离模式跑 `.ts` 脚本,**不支持 TS 参数属性**(`constructor(private x)`) |
| TypeScript | 开启 `verbatimModuleSyntax`,**类型导入必须用 `import type`** |
| headless Chromium | `npx playwright install chromium`;启动参数见 `scripts/browser/README.md` |
| Go 参考解码器 | 源码在 `tools/go-oracle/main.go`;`go get github.com/OpenDiablo2/{dc6,ds1,dt1,pl2}@latest` |
| `dc6png`(npm) | `npm i dc6png`,用 `DC6PNG=<…>/src/index.js` 指过去 |
| Python + PIL | 差分脚本比像素时用 |
```bash
REFDUMP=/tmp/refdump DC6PNG=/tmp/dc6png/node_modules/dc6png/src/index.js \
bash scripts/verify-format-parity.sh /tmp/d2fix
```
三个外部依赖都是**可选**的:缺了会打印 `SKIP`,剩余检查照跑。
---
## 5. 性能基线与发布
线上实测(真实 TLS,同一台机):
| | 请求数 | 传输 | 出画面 |
| --- | --- | --- | --- |
| act1 资源包 | 4 | 1.21 MB | 1.5 s |
| act1 读归档(`?live=1`) | 1,344 | 6.22 MB | 12.1 s |
| act5 资源包 | 9 | 3.80 MB | 2.1 s |
| act5 读归档(`?live=1`) | 3,971 | 11.24 MB | 36.5 s |
资源包比现读现解**少约 30 倍请求、快 8–17 倍**。当前烘焙:365 张地图 / 1917 个文件 / 399.3 MB
(PNG 327.5 MB;act1 117 MB、act2 111 MB、act5 104 MB、act3 56 MB、act4 18 MB)。
图集包**不在代码仓库里**(每次重烘会让仓库再长几百 MB,PNG 无法 delta 压缩)。发布方式:
```bash
npm run pack:data # → samples/d2-packs
npm run publish:packs # → 图库仓库 troytt/diablo2-web-assets(细粒度分批推送,单批 ~10 MB,支持 --work 续推)
```
> 跨境链路对长连接会做 MSS 钳制(实测 pmtu 1460 但 mss 降到 324,重传率 13%,有效带宽 ~20 KB/s)。
> `publish-packs.ts` 因此设了 `http.lowSpeedLimit 1000` + `http.lowSpeedTime 120`:
> 低于 1 KB/s 持续 2 分钟就断开,让重试开一条新连接(新连接通常能拿回 mss 1408,速度提升 2.5 倍)。
> **git 的 `Writing objects: 100% … 29.51 MiB/s` 是骗人的**——那是写进本地内核发送缓冲的速度,
> 不是上线速度。真实进度要看 `ss -tni` 的 `bytes_acked`。
线上部署(nginx:页面 `/diablo2/`、资源包 `/diablo2/packs/`、归档 `/diablo2/data/*.mpq`):
```bash
npm run build:game # → dist-game/(base=/diablo2/)
rm -rf /var/www/d2web && mkdir -p /var/www/d2web && cp -r dist-game/* /var/www/d2web/
rm -rf /var/www/d2packs && mkdir -p /var/www/d2packs && cp -r samples/d2-packs/* /var/www/d2packs/
systemctl reload nginx
```
---
## 6. 目录结构
```
src/
mpq/ MPQ v1 容器:header/tables/sectors、crypt、解压掩码分发、implode、adpcm、huffman
formats/ dc6 dcc cof ds1 dt1 pal pl2 cel pcx tbl sprite(纯解码器,无 DOM 依赖)
game/ combat items skills quests map d2map acts objects character
wilderness maze animation save rng tables demo-data level-names-zh
net/ protocol transport lockstep netplay
render/ atlas renderer(WebGL2 单批次四边形 + 顶点色 tint)
sim/ loop(定点 25 Hz,含追帧上限)input
scene/ act-scene.ts(主场景:真地图 + 全部玩法)net-scene.ts(联机轨)
acts.html arena.html 两个前台入口
scripts/ 夹具生成、验证脚本、打包/发布、中继服务器、浏览器验证工具
tools/go-oracle/ 独立 Go 参考解码器(差分验证用)
samples/ 夹具与被 .gitignore 忽略的归档
```
最大的几个:`maze.ts` 1820、`wilderness.ts` 1534、`act-scene.ts` 1458、`objects.ts` 952、
`dcc.ts` 827、`renderer.ts` 753、`d2map.ts` 695、`combat.ts` 676、`net-scene.ts` 653。
---
## 7. 页面与操作
| 页面 | 地址 | 内容 |
| --- | --- | --- |
| **主场景** | `/acts.html`(根路径 `/` 自动跳转);线上 `https://www.laiseek.xyz/diablo2/?act=1` | 等距真地图 + 真·女法师(DCC+COF,16 层/8 方向)+ 全部玩法(战斗、掉落、背包、技能、任务、NPC、存读档 K/L)。三级选择器:章节 → 场景 → 细分场景 |
| **战斗竞技场** | `/arena.html` | 真实战斗数学与攻防测试竞技场(1.13c 原版公式验证、技能数值测试与面板) |
操作:WASD/方向键移动,空格或 J 攻击,E/F 拾取,T 说话,1–4 选技能,K 存档,L 读档。
`acts.html` 的输入是**屏幕方向**(菱形格子相对屏幕转了 45°,按「上」沿格子对角线向上走)。
---
## 8. 从哪读起
1. 本文第一部分 §0「现状诊断」—— 理解两轨断层,这是所有工作的出发点。
2. 本文第二部分 §1「关键设计约定」—— 23 条踩过的坑,改代码前必读。
3. `src/game/wilderness.ts` 顶部 76 行模块文档 —— 户外 DRLG 的完整推导。
4. `src/game/d2map.ts` 顶部注释 —— 等距投影与 `map.ts` 的本质区别。
5. `src/net/lockstep.ts` 顶部注释 —— 锁步为什么这么设计。
6. `scripts/verify-net.ts` —— 读测试比读实现更快理解协议与时序。