diablo2-web/ROADMAP.md

36 KiB
Raw Permalink Blame History

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 间接获得全部玩法的。下文是修正后的事实。

真实的架构是三层,而不是两条平行轨道:

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 ★★★★★ 中 已完成
M7 真实怪物 #22 ★★★★★ 中 已完成
M8 世界连通 #23 ★★★★★ 中高 待开始
M9 真实战斗数学 #24 ★★★★☆ 中 待开始
M10 真实物品系统 #25 ★★★★☆ 中 待开始
M11 七职业与技能树 #26 ★★★★☆ 中 待开始
M12 完整 UI / HUD #27 ★★★★☆ 低 待开始
M13 .d2s 存档互通 #28 ★★★★☆ 中高 待开始
M14 可破坏物件美术与剧情 #29 ★★★☆☆ 中 待开始
— 架构预留:音频 / 联机 — — — 不在交付路径
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. 修复碰撞语义退化(真实缺陷)

// 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()

验收

  • acts.html 第一幕荒野上能被怪物追、能打死怪、能捡掉落、能 K/L 存读档 (代码路径已接通并由 tests/engine-save-load.test.ts 覆盖;浏览器实测待补, 当前沙盒里 headless Chrome 无网络,见 scripts/browser/README.md 的已知限制。 新增的 scripts/browser/checks/act-gameplay.js 就是这一步的检查脚本, 等换到能联网的环境直接跑即可)
  • 角色从墙内出生时能沿梯度脱困(不是随机游走)
  • 新增 tests/iso-terrain.test.ts 验证等距碰撞计数的各向异性
  • map.html 及 map-scene.ts 已删除,且 verify:all 仍全绿
  • 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 开始时还活着」的玩家;全场无人存活时怪物回合整个跳过。

解码与渲染

  1. 解码器宁可拒绝也不猜。没有真文件对照的字段一律原样保留字节—— 猜错的解码器会静默产出坏存档/坏贴图。
  2. 渲染器画完必须 renderer.flush()。不 flush 就只剩清屏色,看起来和「场景没加载」一模一样。
  3. MPQ 文件键用纯文件名(不是成员全路径,全路径是查哈希表用的); 存储型成员没有扇区偏移表(只有 MPQ_FILE_COMPRESS 0x200 才有)。
  4. 掩码字节与压缩标志是两种写法。MPQ_FILE_IMPLODE (0x100) 单独出现时没有掩码字节, 整段就是 implode 流(Patch_D2.mpq 的每张表都是后者)。 规则:body.length === expected → stored;否则按标志选 implode 或掩码;长度不符一律抛错。
  5. DT1 的 blockSize 是 numBlocks*20 + Σlength,不是 numBlocks*20。
  6. DS1 的 style 匹配 DT1 瓦片自己的 style 字段,不是该 DT1 在 LvlTypes 里的下标。
  7. LvlPrest 用 LevelId 关联,不是 Def(Act 2 城镇 Def=301 / LevelId=40)。
  8. 等距投影:格子在屏幕上是 80×40 的菱形 ((cx-cy)*80, (cx+cy)*40),5×5 子格是 16×8; 地板画在 cell+(-80, 0),墙要加 minBlockY+80(墙的美术长在格子上方)。
  9. 空槽要跳过:DS1 每格带固定数量墙槽,prop1 == 0 的是占位 (Act 1 城镇 4674 个墙槽里 4275 个如此),画出来就是垃圾。
  10. 瓦片匹配键是 style:sequence:type,其中 type 对应 DT1 头 +20 的 Type, 不是 +0 的 Direction(那是朝向/变体索引,实测 1..5,永远不会等于 14 树 / 15 屋顶)。 键错会让树/屋顶/影子全落 loose 兜底、画成地面。 同一键下有多张变体图,引擎逐格按 RarityFrameIndex 加权随机选一张, 种子来自 (level seed, cellX, cellY):pickVariant + levelSeed。
  11. 地面槽位只能画 DT1 type 0。DS1 的 floor 记录没有 type 字段,引擎语义是 type 0。 传 null 走「类型无关」兜底池会随机挑到暗色石墙瓦片,而地面绘制不做 minBlockY+80 补偿 → 画面里出现「悬在地面上的黑色方块」(曾实测 1918/28704 个地面槽画错类型)。
  12. 一格的 sub-tile flags 是所有层做 OR,影子层是 type 13 的独立层,也必须并进碰撞。
  13. 方向:引擎是 64 方向空间经 5 张查表映射到 COF 方向(OD2 Dir64ToCof), 已移植为 character.ts 的 dir64ToCof。8 向输入下 4 方向 COF 的映射与朴素启发式不同(2/3 都映到 1)。
  14. 屋顶(DS1 wall type 15)单独成层、最后绘制,偏移用 -roofHeight; scene.json 里是 roofs 数组。62 个预置关卡里 9 张有屋顶,共 737 个绘制(act 4 城镇 53 个)。
  15. 打包器的 DT1 解码缓存上限 16 个库:不设上限时整轮烘焙会涨到 4 GB 以上。

DS1 对象

  1. 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. 验证命令

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 差分脚本比像素时用
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 压缩)。发布方式:

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):

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 —— 读测试比读实现更快理解协议与时序。