# 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["✅ 战斗 ✅ 物品/背包 ✅ 技能/投射物
✅ 任务/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
能从头走到尾打怪"}} D1 --> M12["M12 UI/HUD
(建议插队)"] M12 --> M9["M9 战斗数学"] M9 --> M10["M10 物品"] M10 --> D2{{"交付点 B
数值可信"}} D2 --> M11["M11 职业技能"] M11 --> D3{{"交付点 C
完整单机"}} 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 解析 `.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` —— 读测试比读实现更快理解协议与时序。