Go to file
troytt 897b3735d6 初始提交:从零实现的暗黑破坏神 II WebGL2 引擎(TypeScript + Vite,零运行时依赖)
内容概览
- 归档层:MPQ v1 容器(hash/block 表、Storm 加密、分扇区编解码),含 PKWARE
  implode/explode 移植;支持 HTTP Range 只读取所需字节。
- 格式层:dc6 / ds1 / dt1 / pal / pl2 / cel / pcx / tbl / sprite,以及 dcc / cof / bitstream。
- 地图层:等距投影场景构建(地板/墙/屋顶分层、逐格瓦片变体加权随机、sub-tile 碰撞),
  资源包(索引色 PNG 图集 + scene.json)离线烘焙与逐像素差分校验。
- 角色层:DCC + COF 合成(8 方向),法师行走/站立替换占位棋子。
- 页面:acts.html 全屏画布 + 三级选择器(章节 / 场景 / 细分场景),资源包优先、
  可回退到直读归档(?live=1)。
- 验证脚本:verify:all / implode / acts / d2 / packs / dcc / deploy / listfile /
  object-lookup / alignment / tiles / generators 等,均带断言与退出码。

数据与许可
- 游戏数据(samples/)与第三方参考源码(reference/)不入库:体积大且无再分发许可,
  见 THIRD_PARTY_NOTICES.md。
- 生成关卡(70 迷宫 + 31 野外)尚未烘焙,现状与修复清单见 HANDOVER.md §4f。
2026-09-13 18:10:28 +08:00
scripts 初始提交:从零实现的暗黑破坏神 II WebGL2 引擎(TypeScript + Vite,零运行时依赖) 2026-09-13 18:10:28 +08:00
src 初始提交:从零实现的暗黑破坏神 II WebGL2 引擎(TypeScript + Vite,零运行时依赖) 2026-09-13 18:10:28 +08:00
tools/go-oracle 初始提交:从零实现的暗黑破坏神 II WebGL2 引擎(TypeScript + Vite,零运行时依赖) 2026-09-13 18:10:28 +08:00
.gitignore 初始提交:从零实现的暗黑破坏神 II WebGL2 引擎(TypeScript + Vite,零运行时依赖) 2026-09-13 18:10:28 +08:00
HANDOVER.md 初始提交:从零实现的暗黑破坏神 II WebGL2 引擎(TypeScript + Vite,零运行时依赖) 2026-09-13 18:10:28 +08:00
README.md 初始提交:从零实现的暗黑破坏神 II WebGL2 引擎(TypeScript + Vite,零运行时依赖) 2026-09-13 18:10:28 +08:00
THIRD_PARTY_NOTICES.md 初始提交:从零实现的暗黑破坏神 II WebGL2 引擎(TypeScript + Vite,零运行时依赖) 2026-09-13 18:10:28 +08:00
acts.html 初始提交:从零实现的暗黑破坏神 II WebGL2 引擎(TypeScript + Vite,零运行时依赖) 2026-09-13 18:10:28 +08:00
index.html 初始提交:从零实现的暗黑破坏神 II WebGL2 引擎(TypeScript + Vite,零运行时依赖) 2026-09-13 18:10:28 +08:00
map.html 初始提交:从零实现的暗黑破坏神 II WebGL2 引擎(TypeScript + Vite,零运行时依赖) 2026-09-13 18:10:28 +08:00
net.html 初始提交:从零实现的暗黑破坏神 II WebGL2 引擎(TypeScript + Vite,零运行时依赖) 2026-09-13 18:10:28 +08:00
package-lock.json 初始提交:从零实现的暗黑破坏神 II WebGL2 引擎(TypeScript + Vite,零运行时依赖) 2026-09-13 18:10:28 +08:00
package.json 初始提交:从零实现的暗黑破坏神 II WebGL2 引擎(TypeScript + Vite,零运行时依赖) 2026-09-13 18:10:28 +08:00
tsconfig.json 初始提交:从零实现的暗黑破坏神 II WebGL2 引擎(TypeScript + Vite,零运行时依赖) 2026-09-13 18:10:28 +08:00
vite.config.ts 初始提交:从零实现的暗黑破坏神 II WebGL2 引擎(TypeScript + Vite,零运行时依赖) 2026-09-13 18:10:28 +08:00
walk.html 初始提交:从零实现的暗黑破坏神 II WebGL2 引擎(TypeScript + Vite,零运行时依赖) 2026-09-13 18:10:28 +08:00

README.md

d2web

Web 原生的暗黑破坏神 II 引擎(进行中)。仓库不含任何游戏素材:引擎只读取你自备的 经典版 MPQ(d2data.mpq / d2exp.mpq / patch_d2.mpq 等),素材始终留在你自己机器上。 零运行时依赖(纯 TypeScript + Vite + 平台 API)。项目位于 /root/diablo_web; 接手/继续开发先读 HANDOVER.md(目录导览、十条设计约定与踩坑、验证方法论、 环境依赖重建、未完成项分类)。

现状

M0 · 资源管线 ✅

  • MPQ v1 容器:头解析、哈希表/块表解密、名字查找、扇区定位、按范围随机读取 (File/Blob 直接读,从不把整个归档载入内存)。
  • Storm 密码学:crypt table、HashString、块密码。密钥取纯文件名(basename)—— 哈希查找用全路径、解密密钥用 basename,混用会静默解出乱码(而且总长度还对,只校验 大小发现不了)。
  • 解压:zlib 走平台 DecompressionStream('deflate')。自适应 Huffman / PKWARE implode / ADPCM / bzip2 / sparse 按 StormLib 的掩码顺序留位,遇到未实现的掩码明确报错而非产生坏数据。
  • 精灵解码:.cel 与 .cl2(含多组 = 多方向、逐帧宽度、帧头跳过、跨行游程)。 元数(0x80 透明游程 / ≤0xBE 填充 / >0xBE 字面)与 CEL 的行封闭规则都按 DevilutionX 实现。
  • 调色板 / 变体:768 字节 .pal、256 字节 .trn 索引重映射(怪物换色与 D2 的 palette shift 同一机制)。
  • 浏览器检查器:拖入 MPQ → 列文件/筛选 → PCX 渲染、音频解码、文本/十六进制预览。

D2 格式解码层 🚧(已实现,等真 MPQ 收口)

  • DC6 精灵:24 字节文件头 + 帧指针表 + 32 字节帧头;游程三字母表 (0x80 换行 / (b&0x7f) 透明游程 / 否则字面游程),行序自下而上,索引 0 透明。
  • DT1 瓦片库:276 字节文件头 + 96 字节瓦片记录 + 20 字节块头;两种块编码都实现—— RLE((skip,count) 对,(0,0) 换行)与等距(固定 xjump/nbpix 菱形表,256 字节); 25 个子瓦片碰撞标志(bit0 阻挡行走、bit1 阻挡视线…)可直接用于 M1 碰撞。
  • PL2 调色板:1024 字节基础调色板 + 1727 张 256 字节换色表(光照 32 / 反色 16 / 选中单位 1 / 粗粒度 alpha 3×256 / 加色 256 / 乘色 256 / 色相 111 / 红绿蓝色调 3 / 未命名 14 / 最大分量 256 / 变暗 1)
    • 13 色文本调色板 + 13 张文本换色表,共 443175 字节——格式完全没有头/计数/目录, 表序与数量就是格式本身,所以解码器里把每组的数量逐条写出来而不是推算。 D2 的光照/混合/单位换色全靠"索引→索引"的换色表完成,这也是为什么解码器始终保留索引、最后才落色彩。
  • TBL 字符串表(经典布局):crc + 条目数 + 偏移表 + 每条目 u16 字符数 + UTF-16LE; 未用索引解析为 undefined(区别于空串)。
  • DS1 地图:版本化段序列(act / substitution / 内嵌文件名 / 4+4 层流 / 地板层 / 对象 / NPC 路径), 含低于 4 版的旧式层顺序与低于 7 版的方向查表;产出逐格 walls/floors/shadows/substitutions + 对象列表。

独立实现差分验证(关键证据)

没有真 D2 文件时,"解码器不报错"毫无意义。所以每个格式都与独立实现逐字段比对:

检查 独立实现 结果
DC6 像素 dc6png(npm,JS) 128/128 像素一致
DC6 全字段 OpenDiablo2/dc6(Go) JSON 完全相同
DS1 全字段 OpenDiablo2/ds1(Go) JSON 完全相同
DT1 元数据 OpenDiablo2/dt1(Go) JSON 完全相同(该包的像素解码是死代码,见下)
DT1 像素落位 夹具编码时的期望网格 102400/102400 像素一致
PL2 全表 OpenDiablo2/pl2(Go) 完全一致(基础/文本调色板 + 1727 张表的逐表摘要)
REFDUMP=/path/to/refdump DC6PNG=/path/to/dc6png/src/index.js \
  bash scripts/verify-format-parity.sh /tmp/d2fix

差分测试当场抓到了我自己的一个真 bug:我最初按 16 字节读 DT1 块头,而 社区权威文档 与参考实现都是 20 字节 (X, Y, 2 字节保留, GridX, GridY, Format, Length, 2 字节保留, FileOffset), 把 Format/Length/FileOffset 全部读错了偏移;同时 FileOffset 是相对该瓦片自己的块头起点。 另外 dc6png 独立确认了 DC6 的行序与游程字母表。

归档驱动路径 ✅("丢进 MPQ → 出地图"已跑通)

map.html 支持三种数据源:拖入 .mpq、?mpq=<url>、?data=<fixture 目录>。 打开归档后会按扩展名自动挑成员(town/act1 优先),页面上有 DS1 / DT1 / 调色板选择器与重新加载按钮, 因为真实归档里有成百上千个关卡与瓦片库需要人来选。

为了让这条路径在没有任何暴雪数据时也能验证,我写了 scripts/make-mpq-fixture.ts: 把夹具打成真正的 MPQ v1 归档(表加密、成员混用"原样存储"与"zlib 分扇区"两条路径), 于是"用户丢归档 → 引擎读取 → 解码 → 渲染地图"整条链现在就能端到端测试。

检查 结果
我的读取器往返 5/5 成员逐字节一致(54354 字节)
独立读取器(Python mpyq) 4/4 已发布成员逐字节一致
归档驱动场景 source=mpq、自动选中 3 个成员、9 地面 / 9 墙体、未解析引用 0、108/225 阻挡
运行 25.0 tps、双轴移动、撞墙被拒、glError = 0、截图 2946 种颜色

这里又抓到两个只有独立实现能发现的问题:

  1. 存储型成员必须带 SINGLE_UNIT 标志。不带的话,标准读取器会走"多扇区表"路径, 把文件自身的数据当成偏移表读出垃圾——而我自己的读取器恰好容忍(我实现成"无表直读")。 于是出现了"自己读写全对、别人读不出来"的假绿:fixture.ds1(zlib)能读、palette.pal(存储)读出 0 字节, 这个不对称正是定位问题的线索。
  2. 读取器对未压缩成员多读了一张扇区表。StormLib 只在 COMPRESS 标志下加载偏移表, 未压缩文件是从块起点直读的;我原先对非 single-unit 文件一律读表。已按参考实现修正,并对加密+存储的成员按 key + 扇区号 逐扇区解密。

M5 · 存档与联机 🚧(存档、确定性锁步、2–4 人浏览器联机合作已跑通;.d2s 待真文件确认)

存档(快照):把续跑所需的一切装进一个带版本号的纯数据结构——战斗世界、背包(按原有格子位置)、 任务进度、随机流的位置,以及地面掉落物。随机流位置是最容易被漏掉的一项:不保存它,读档后 同一批掉落会以不同顺序出现,看起来"存档能用",直到有人注意到掉的东西不对。

验证方式是存档唯一有意义的验证方式:读档后继续模拟,要求与原进程逐 tick 完全一致—— 漏掉任何字段都会在几百 tick 后表现为状态分叉。另含畸形存档与未来版本存档的拒绝。

.d2s 角色档:实现了签名、版本、名字/职业/等级字段与校验和;物品/任务/传送点/佣兵等段 原样保留字节而不去猜(没有真文件对照时猜这些会写出静默损坏存档的解码器)。 这轮在这里抓到一个真 bug:校验和用 JS 的 << 1(32 位有符号移位)会把高位丢弃而不是折叠进位, 导致离末尾 32 字节以外的字节对校验和完全不可见——那种位置的篡改检测不出来。已改为按规范做进位折叠。

确定性锁步:这一块完全不依赖暴雪数据,所以可以强验证。三条规则: ① 某个 tick 只有在所有对等方的输入都到齐时才推进(缺输入就 waiting,绝不猜测或抢跑——抢跑必分叉); ② 输入延迟若干 tick 作为网络延迟预算;③ 逐 tick 状态哈希互相比对,第一处不一致即 desync, 且报告里带上是哪个 tick 分叉的(这决定 bug 能不能查)。

scripts/verify-m5.ts 无头断言 33 项:快照续跑一致性、畸形/未来版本存档拒绝、地面掉落随档保存、 .d2s 往返与校验和篡改检测、双会话 300 tick 哈希全同、缺输入等待且计数、输入延迟语义、 过期输入被丢弃、以及篡改输入必须被识别为 desync 并报出正确 tick(不会响的 desync 检测器比没有更糟)。

浏览器内实测:存档后状态继续变化(击杀 3→5、金币 39→46、地面 2→3),读档后 击杀/背包/金币/地面掉落/等级/位置/怪物存活/任务进度/经验全部回到存档点,随后继续运行, glError = 0、25 tps。这里也抓到一个真实场景 bug:场景里缓存了 const player = world.player, 读档替换世界后这个引用仍指向旧世界,导致读档那一 tick 的后半段还在写旧数据——已改为读档后重新绑定。

联机合作(net.html):每页一个浏览器,2–4 人同一个世界、各控一名角色(?peers= 指定人数,?peer= 是本页编号)。网络上只传四类消息 (谁、按了什么键、我这 tick 的状态哈希、再见),世界状态一个字节都不传——每台机器各自把整个 世界算出来,这正是 25 Hz 能跑在普通连接上的原因。

  • src/net/protocol.ts —— 手写小端编解码。移动量按定点 ×1000 传,因为浮点在两台机器上可能 舍入出不同的结果;每条消息都做长度与范围校验,畸形包在网络层就被拒掉,不会流进模拟。
  • src/net/transport.ts —— 传输是一根「可靠的字节管道」:memoryTransportPair()(进程内成对, 可 hold 住模拟慢链路)与 socketTransport()(浏览器 WebSocket 与 Node 全局 WebSocket 共用同一份代码, 所以真实 socket 路径能无头验证)。消息尺寸上限在传输层就挡住。
  • src/net/netplay.ts —— 把锁步内核接到传输上:按对等方记账的握手门控、输入发送游标、哈希收发与超时。 握手不是"听到对方"就算完成,而是对方确认收到了我的 hello(ackTo 指名道姓,因为四人局里 "我听到了"必须说清是听到了谁);所有对等方都到齐才开跑。
  • src/scene/net-scene.ts + net.html —— 2–4 人合作场景:每人一个角色、怪物追最近的那个活人、 经验记给补刀的那个、?ws= 联机 / 不带则单机跑同一条模拟路径。

四个真 bug,全靠验证抓出来,都不是"写错一个字段"级别的:

  1. 输入延迟导致的死锁:延迟 D tick 时,第一个该发的 tick 是 D,0..D-1 谁都不发,游戏在第一 tick 前就卡住。 改为发送游标从 0 补齐到「当前应付 tick」。
  2. 握手是单向的:只听见对方 hello 的peer 认为自己已完成握手,于是永远不发自己的 hello; 对方因此一直等,双方都 ready 不了。现在收到 hello 必回一个 hello,且只认「对方确认收到我的 hello」才算完成, 否则每 25 次 pump 重发一次。
  3. 首发帧掉进虚空:先连上中继的一页把 0..4 帧发出去时对面还没连上,中继无人可转发(发送方无从得知), 而发送游标已经前进——这些 tick 的输入永久消失,两边永久互等。现在有握手门控:没确认对方在听,就不发输入。
  4. 超时判定用「世界 tick」计时:世界一旦卡住,tick 不再前进,超时因此永远不会触发——恰好在它唯一有用的场景里失效。 现在按「距上次收到消息经过了多少次 pump」计。

另外两处是设计加强,同样由数字逼出来:哈希比对面向前进中的世界时, 落后方收到的是自己还没算到的 tick,于是大量哈希只能被丢弃(落后方几乎什么都没验证到); 现在把对方哈希缓存起来、等本地历史追上再比对,并单独统计 hashesIgnored——把无法核对的哈希谎报成"一致", 比不检查更糟。

scripts/verify-net.ts 无头断言 143 项:协议编解码(含固定字节布局与全部畸形包)、 内存管道与内存 hub 的排队/上限/关闭语义、双 peer 200 tick 世界完全一致、 慢链路只停世界不坏世界(落后方靠已有积压追上同 tick 后世界逐字段相同)、 四人局:三个 peer 不会因为第四个还没来就抢先开跑(少一人就等,否则先发出的输入会掉进虚空)、 迟到者入场后四人逐 tick 齐平且四个世界逐字段相同、四人中一人掉线时其余三个只停不裂、 恢复后各自用积压追平;篡改飞行中的输入→在正确 tick 报 desync、垃圾包被丢弃且世界不受影响、 静默 peer → 超时而非 desync、老旧哈希计为"无法核对",以及真 socket(自写 RFC6455 中继 + 两个真客户端 150 tick、三个真客户端 160 tick)无分叉。

浏览器实测(三个独立 Chromium 实例 + 真中继):三页 peers 3、各听到 2/2 名队友、 handshaked/acknowledged,三页都跑到 worldTick 306 且完全齐平、各比对 110–115 个哈希全部一致、 malformed 0、无 desync、glError 0,三页 players 数组逐字段相同(位置/血量/经验), 击杀数一致(7),三张截图互不相同(各页相机跟各自角色)。

M4 · 技能 / 任务 / NPC ✅(结构层已跑通;真实 MPQ 数据待接入)

  • 技能(Skills.txt 形状):法力消耗、冷却、射程、伤害随等级成长;两种施放形态—— 投射物(按 25 Hz 飞行、撞墙即毁、超时消失、命中后伤害走战斗模块的同一套击杀/经验/尸体管线) 与瞬发范围(半径内全部命中)。伤害成长是唯一显式简化的地方:真实表用 5 个等级区间 (MinLevDam1..5)插值,我读单一斜率 PerLevel,公式隔离在 skillDamageAt 里等映射层替换。
  • 名字来自真 TBL:表里的数字单元格通过 data/local/string.tbl 解析(我上轮标记为"弱验证"的解码器 现在真正进入了游戏路径);没有 TBL 时数字保持字面量,两种路径都保留。
  • 任务/NPC:Quest.txt 形状的目标(按怪物 id 或 * 通配计数)、奖励(经验+金币)、 状态机 inactive → active → complete 且不重复发奖;NPC 台词按状态选择 (未接/进行中/已完成各一套)——"NPC 在你已接任务时还在推销"是经典 bug,这里用状态选台词避免。
  • 交互:数字键选技能位(1..4)、攻击键施放、T 键与最近 NPC 对话(自动接取任务)、 拾取 E/F;对话显示在页面面板上。
  • 两个真实缺陷在这轮被浏览器测试抓出:① 选中法术且魔法耗尽后空格既不施法也不近战, 玩家彻底失去攻击能力 → 改为"法术放不出时回退近战"并加魔法缓慢回复; ② 台词先按 | 拆、再解析 TBL,而 TBL 字符串本身含 |,导致多行台词没被拆开 → 改成先解析再拆行。 另外还修了施法决策与近战推进的顺序(原先二者会同一 tick 同时触发)。

scripts/verify-m4.ts 无头断言 56 项:TBL 名字解析三种情形(命中/字面量/无表)、 技能表解析与投射物判定、伤害随等级单调、施放的冷却与法力门(含"刚好够")、 投射物飞行/命中/撞墙/超时/忽略尸体、任务按 id 与通配计数、阈值达成发奖且不重复、 NPC 台词随状态切换、施法可复现。

浏览器内实测(夹具归档):textSource=tbl、技能名 ['Basic Attack','Fire Bolt','Frost Nova'] (来自归档内的真 TBL)、施放 7 次、走到 NPC 前对话得到两行台词并接取任务(den:active)、 追问台词切换为进行中版本、随后击杀使任务计数 推进到 1/3、glError = 0、25.6 tps。 (3/3 完成与奖励发放由无头断言覆盖;浏览器脚本受合成地图通行性限制未打满。)

M3 · 物品系统 ✅(结构层已跑通;真实 MPQ 数据待接入)

物品的建模方式跟着 D2 的真实存储走:物品 = 基物(weapons.txt/armor.txt/misc.txt 行)

  • 可选前缀/后缀(magicprefix.txt/magicsuffix.txt 行),词缀的修饰项指向 ItemStatCost.txt 里的属性名。没有硬编码——剑就是一行带尺寸和伤害的表行, "Cruel" 就是一行带等级要求、可用物品类型与数值范围的表行。

两个关键决定:

  • 随机数是注入的(src/game/rng.ts,mulberry32,零依赖)。掉落是种子的纯函数, 因此可以被测试精确复现——这同时也是 M5 联机 lockstep 能成立的前提。
  • 背包是格子占用模型,不是列表。D2 物品有宽高、不能重叠、形状固定, 不建模这个,"背包满了"就没有任何意义。

已实现的约束:词缀等级门与物品类型门(itype1..7)、前缀/后缀/基物的名字组合、 属性合并(基础 + 各修饰项)、堆叠到上限后溢出到新格子而不是丢弃、金币堆叠、 拾取时背包满拒绝而不是销毁物品(并计数)、按类型着色的地面物品与背包网格覆盖层。

scripts/verify-items.ts 无头断言 58 项,涵盖表解析(含真实文件常见的前导空列)、 词缀资格、掷取可复现、名字与属性合成、背包边界/重叠/堆叠/移除/金币、掉落表的等级门与可复现。

浏览器内实测(夹具归档):items=archive · tables=archive、3 次击杀产生 3 次掉落 (1 次金币 39 自动入账 + 2 件地面物品)、拾取 2 件 → 背包 3 件 / 6/40 格、护甲防御合计 12、 24.6→25.6 tps、glError = 0;掉落名为 Sturdy Buckler of the Fox(前缀仅限护甲 + 后缀不限) 与 Minor Healing Potion of the Fox——正是表驱动的词缀资格在起作用。

M2 · 战斗沙盒 ✅(结构层已跑通;真实 MPQ 数据待接入)

战斗模拟刻意不依赖渲染:一次只推进一个 25 Hz tick,输入是一条小船小记录、地形是一个碰撞谓词。 这样整套系统能在 Node 里无头模拟与断言——只能靠看屏幕验证的战斗,通常是坏的。

  • 数据表驱动:怪物血量/伤害/冷却/攻击距离/仇恨半径/速度/经验全部来自表行 (data/global/excel/monstats.txt 与 experience.txt,D2 用的制表符文本格式,.bin 只是它的编译形式), 换真表不用改代码。表缺失时回退到内置演示表并在 HUD 标注 tables builtin。
  • AI 状态机:idle → chase → attack,按仇恨半径发现、按攻击距离切换、按各自冷却出手; 尸体保留 100 tick 再消失。
  • 玩家:近战按冷却与魔法消耗,命中最近目标;经验按表累加升级(升级提升并回满资源); 死亡后按计时重生。
  • 移动权限收归模拟:地形谓词返回重叠计数而不是布尔——已经卡在实心格里的身体必须能走出来, 规则是"不让重叠变深";布尔表达不了这件事,而"永久卡死"比"短暂蹭到墙角"糟糕得多。
  • 渲染:角色与怪物一起按深度插进墙体序列(怪物复用同一套图集,按类型着色), 带血条;左下/右下是血蓝球(屏幕锚定、世界坐标绘制)。

scripts/verify-combat.ts 无头断言 38 项:表解析(空单元格/(null)/默认值/大小写)、 经验表单调化、生成不落进实心格且可复现、仇恨/追击/攻击距离、冷却决定出手次数(24 tick 冷却在 100 tick 内正好 5 次)、 魔法消耗与不足时不落伤害、击杀/经验/升级/资源回满、死亡与重生、以及同样输入产生同样结果。

浏览器内实测(夹具归档):tables=archive、生成 8 只、击杀 3 只、经验 28 → 升到 2 级(上限血 60→70)、 魔法 30→11、25.2 tps、glError = 0;HUD 显示 hp 70/70 · mana 11/35 · lvl 2 (28 xp) · monsters 5/8 alive · kills 3。

M1 · 地图渲染与碰撞 ✅(结构层已跑通;真实 MPQ 数据待接入)

map.html:把解码出的 DS1 布局 + DT1 瓦片库渲染成地图——逐格分层绘制 (地面按格序、墙体按画家序:先远后近),碰撞直接来自 DT1 的 5×5 子瓦片标志 (blockWalk / blockPlayerWalk),网格步长 = 瓦片边长 / 5 = 标准 160px 瓦片的 32px。

浏览器内实测(无头 Chromium + SwiftShader,合成夹具):

检查 结果
绘制 9 次地面 + 9 次墙体,4 张瓦片图集,未解析引用 0
碰撞网格 15×15 子瓦片,108/225 为阻挡(由瓦片标志推导)
定步长 25.2 tps
移动 空位可走;撞到实心子瓦片被拒;无穿墙、无越出地图边界
渲染 glError = 0;截图 3207 种颜色、70.9% 非背景像素、5205 个角色像素

角色动画已接入(src/game/animation.ts):格式无关的 clip/方向/帧推进系统, 按 25 Hz tick 计数播放(而不是毫秒——否则在不同机器上速度不同), walk / stand 自动切换、方向由移动向量决定(0=南、6=东、4=北,与文件内组序一致)。

绘制顺序也做对了:角色不再永远画在最上层,而是按深度插进墙体序列 (规则 = 第一个"比角色更近"的墙之前),所以站到墙后会被正确遮挡。这条规则抽成了纯函数 depthInsertIndex 并单独验证(16 个位置、0 问题)——小地图上肉眼很难看出插桩点变化。

角色素材解析顺序:DC6 优先(D2 大量单位本身就是逐方向 DC6 图集,且每帧自带尺寸)→ D1 CL2 回退(用游戏表宽度 96)→ 都没有才用黄色方块占位。 为了在没有真数据时也能验证,夹具里生成了一个合成角色 DC6(8 方向 × 8 帧 × 64×64, 每个方向图案不同,便于看出方向/帧错位),并打进夹具归档。

浏览器内实测:actor=actor.dc6、东=6 / 北=4 / 南=0、走→站切换、帧推进、角色位于 9 个墙体绘制的第 6 位、25.0 tps、glError = 0。

引擎骨架(D1 演示,仍可用)

walk.html:真实角色精灵(战士行走/站立,宽度 96 = 游戏表数值)+ 8 方向朝向 + 碰撞 + 相机跟随 + 25 Hz 定步长循环;地面与墙是占位几何,等 DS1/DT1 解码器落地后替换。

浏览器内实测(无头 Chromium + SwiftShader):25.6 tps、朝北 facing=4、朝东 facing=6、 撞墙后两次推挤位置不变、glError = 0。

验证方式(可复现)

node scripts/inspect-mpq.ts samples/spawn.mpq header   # 头 + 存储标志分布
node scripts/inspect-mpq.ts samples/spawn.mpq hist     # 压缩掩码分布 → 该归档需要哪些解码器
node scripts/verify-archive.ts samples/spawn.mpq       # 全量解包 + 魔数校验
node scripts/verify-sprites.ts samples/spawn.mpq       # 全量精灵解码 + 宽度推断
node scripts/verify-widths.ts <archive> <member> <widths-file>   # 逐帧宽度精灵
node scripts/make-fixtures.ts /tmp/d2fix            # 生成 DC6 夹具 + 期望网格
node scripts/make-map-fixtures.ts /tmp/d2fix        # 生成 DT1/DS1 夹具
node scripts/verify-dc6.ts /tmp/d2fix               # DC6 自校验
node scripts/verify-dt1-pixels.ts /tmp/d2fix         # DT1 像素落位校验
node scripts/make-mpq-fixture.ts samples/fixtures   # 把夹具打包成真 MPQ
node scripts/verify-mpq-roundtrip.ts samples/fixtures  # 归档往返验证(含 mpyq 交叉)
node scripts/make-pl2-fixture.ts /tmp/d2fix         # 合成 PL2(1727 张表,模式可辨位置)
node scripts/verify-tbl.ts                          # TBL 构造检查(CJK/星形平面/长串)
node scripts/verify-depth-order.ts                  # 角色深度插入规则(纯函数,无需渲染器)
node scripts/verify-combat.ts                       # 战斗行为无头断言(38 项)
node scripts/verify-items.ts                        # 物品/词缀/背包/掉落无头断言(58 项)
node scripts/verify-m4.ts                           # 技能/投射物/任务/NPC 无头断言(56 项)
node scripts/verify-m5.ts                           # 存档快照/角色档/确定性锁步无头断言(33 项)
node scripts/verify-net.ts                          # 协议/传输/2–4 人锁步/真 socket 无头断言(143 项)
bash scripts/verify-format-parity.sh /tmp/d2fix     # 与独立实现的完整差分

多人联机需要中继(自写、零依赖,只转发字节,不懂游戏):

node scripts/net-server.ts 8787     # 或 npm run net-server
# 每个浏览器开一页,2–4 人都可以(也可以在多台机器上,把 127.0.0.1 换成主机 IP):
#   http://127.0.0.1:5173/net.html?data=samples/fixtures&ws=ws://127.0.0.1:8787&peers=3&peer=0
#   ...&peers=3&peer=1        ...&peers=3&peer=2

地图场景需要夹具被 HTTP 提供(samples/ 已在 .gitignore 内):

node scripts/make-map-fixtures.ts samples/fixtures
# 打开 http://127.0.0.1:5173/map.html?data=samples/fixtures
# 或走归档路径:http://127.0.0.1:5173/map.html?mpq=samples/fixtures/fixture.mpq

在暗黑1 试玩版 spawn.mpq(25.8 MB / 1029 项)上的实测结果:

检查 结果
成员解包 1024/1029 精确解出,0 个长度不符(5 项是归档 listfile 与实际条目不一致)
PCX / CEL / TRN 魔数 全部通过
压缩掩码 1010 zlib、14 原样存储 → 只需 zlib
.cl2 精灵 305/305 用游戏表宽度解出(玩家 96、怪物 128)
.cel 精灵 217/221 单宽度自动推断成功(含 12 张 640px 过场图)
逐帧宽度精灵 data\inv\objcurs.cel 179 帧按宽度表全部解出,无空帧

宽度的真相(踩坑记录)

CEL / CL2 都不存储帧宽度:

  • .cel 的游程是行封闭的,所以"每个游程正好填满一行"这个判据是真的—— 错误宽度会让游程溢出整行而被拒绝,因此单宽度自动推断是可靠的。
  • .cl2 的游程可以跨行,任何宽度都能把游程流消费到帧尾,"精确消费"这个判据恒真。 宽度只能来自游戏数据(SetPlrAnims 给玩家站立/行走 96、近战攻击 128; monsterdata[].width 给怪物 128)。可用的弱判据是"游程流正好落在列边界"(cl2WidthCandidates)。
  • 地砖表与光标表是逐帧不同宽度的(data\inv\objcurs-widths.txt、地砖定义), 单宽度推断必然失败——这不是解码器 bug,见 decodeSpriteFile 的 widths 选项。

浏览器端

npm install
npm run dev
#   http://127.0.0.1:5173/            资源检查器(?sample=samples/spawn.mpq 免拖拽自检)
#   http://127.0.0.1:5173/walk.html  可行走场景(同上)
#   http://127.0.0.1:5173/map.html?data=samples/fixtures       单人地图场景
#   http://127.0.0.1:5173/net.html?data=samples/fixtures&ws=ws://127.0.0.1:8787&peers=3&peer=0   多人联机

验收:整条目标链的证据(12 轮)

目标是从零做一个浏览器内可玩的暗黑破坏神 2 引擎,读用户自备的旧版 D2 MPQ, 依次打通 M0→M5,最终形成可玩的垂直切片。逐项对照如下——每条都给出可复现的命令或页面, 不是"应该能跑"。

里程碑 交付物 证据(可复现) 强度
M0 资源管线 src/mpq/(v1 容器、crypt、解压掩码)、src/formats/(DC6/DS1/DT1/PAL/PL2/CEL/CL2/TBL)、index.html 可视化检查器 verify-archive.ts 全量解包 暗黑1 正版 spawn.mpq(25.8 MB / 1029 项)+ 魔数校验;verify-sprites.ts 全量精灵解码;verify-mpq-roundtrip.ts 自写 MPQ 写入器往返 + mpyq 独立读取;bash scripts/verify-format-parity.sh 与 dc6png、OpenDiablo2 三个 Go 实现全字段差分 ALL PARITY CHECKS PASSED 强(真 D1 归档 + 独立实现)
M1 可走地图 map.html + src/game/map.ts(DS1+DT1 合成、5×5 子瓦片碰撞、深度插入)、8 方向动画、25 Hz 循环 verify-depth-order.ts 深度规则;浏览器实跑 map.html?mpq=samples/fixtures/fixture.mpq(真 MPQ → 真地图);25 tps 实测 强(结构)/ 中(DT1 像素落位仅夹具自洽)
M2 战斗沙盒 src/game/combat.ts(追逐/攻击/冷却/受击/死亡/重生/经验/升级)、血蓝球 HUD verify-combat.ts 38 项:命中判定、冷却、法力消耗、经验与升级、死亡与重生顺序、确定性重放 强(自洽行为)
M3 物品系统 src/game/items.ts(基础物品、前后缀、词缀适用范围、按格背包/堆叠/金币、掉落流) verify-items.ts 58 项:词缀资格、掉落确定性、格子放置与堆叠、金币 强(自洽行为)
M4 技能/任务/NPC src/game/skills.ts、quests.ts、tables.ts、TBL 文本源、对话 verify-m4.ts 56 项:投射物、技能伤害曲线、任务计数与奖励、对话通过 TBL 解析 强(自洽行为)/ TBL 布局弱(见下)
M5 存档与联机 src/game/save.ts(快照 + .d2s)、src/net/(协议、传输、锁步、2–4 人)、net.html verify-m5.ts 33 项(读档续跑逐 tick 一致);verify-net.ts 143 项(2/3/4 人、慢链路、篡改检测、真 socket);浏览器:三个 Chromium 实例同时联机,三页 worldTick 306 齐平、哈希全一致、世界逐字段相同 强(无外部实现可对照,故只证明自家实现自洽)

浏览器里现在能玩什么(都只读用户自备 MPQ/夹具,仓库不含任何暴雪素材):

npm install && npm run dev          # 夹具:node scripts/make-map-fixtures.ts samples/fixtures
# 单人战役沙盒(走路/砍怪/掉落/拾取/技能/任务/NPC/存读档):map.html?data=samples/fixtures
# 用真归档:map.html?mpq=<你的 .mpq>(暗黑1 试玩版 spawn.mpq 已验证)
# 2–4 人联机:先 npm run net-server,再每页开一个 net.html?ws=ws://127.0.0.1:8787&peers=3&peer=N
# 真实 D2 五个 act 的城镇(无怪物,走位/碰撞;数据来自 samples/d2,按 HTTP range 只读所需字节):
#   http://127.0.0.1:5173/acts.html?act=1        # act 1..5;?quadrant=<DS1 名> 切换地图块
#   线上同一页面:https://www.laiseek.xyz/diablo2/?act=1(默认走预解包资源包;?live=1 强制直接读归档)
#
# 资源包(离线解包成 web 原生格式:索引 PNG 图集 + scene.json):
#   npm run pack:data      # 预置关卡 → samples/d2-packs(当前 62 个地图块 / 46.7 MB)
#   npm run verify:packs   # 与"现读现解"逐项比对(绘制序、碰撞栅格逐字节、每帧像素哈希)
# 线上产物:
#   npm run build:game     # → dist-game/(base=/diablo2/),再拷到 /var/www/d2web
# 线上实测(同一台服务器、真实 TLS):
#   act1 资源包 4 请求 / 1.21 MB / 首帧 1.5 s   ←→  读归档 1344 请求 / 6.22 MB / 12.1 s
#   act5 资源包 9 请求 / 3.80 MB / 首帧 2.1 s   ←→  读归档 3971 请求 / 11.24 MB / 36.5 s

仍然缺什么(诚实记录)

  1. 真 D2 MPQ 已到手,且能读了。用户自备的 1.13c 数据在 samples/d2/(11 个 MPQ, 来源与 sha256 见 samples/d2/MANIFEST.md),PKWARE implode 解码器已实现 (src/mpq/implode.ts):npm run verify:implode 实测 30,914 个成员、893 MB 全部解出; npm run verify:acts 把五个 act 的城镇从 levels/lvltypes/lvlprest 一路解到等距场景,40/40。 /acts.html?act=1..5 现在能在浏览器里走真实城镇(无怪物),线上 https://www.laiseek.xyz/diablo2/?act=1;DCC + COF 解码器已实现 (src/formats/dcc.ts / cof.ts,按 OpenDiablo2 的 d2dcc/d2cof 独立移植, npm run verify:dcc 逐层比对),角色不再是占位棋子而是真·女法师: 行走 SOWLHTH 16 方向 × 8 帧 × 8 层、站立 SONUHTH 合成后画在脚下, d2char.mpq 只按 HTTP range 取需要的成员。 仍缺的:可破坏物品的静态美术帧(Objects.txt 的 token/mode 与成员已进资源包, 解码器也已就绪,只差把它们插进绘制序)、ADPCM+Huffmann 音频(572 个成员)、 以及下面这些"待真文件确认"的项——它们现在有真数据了: DT1 block 数据偏移基准(真文件已解出,dt1.ts 已按真布局修正)、经典 string.tbl 索引布局、 .d2s 各数据段布局。引擎对这些一律保持原字节而不猜。
  2. D2 的表列映射层:真实 MonStats.txt/weapons.txt 等是制表符大表,列名映射只按夹具列名 与少量官方文档验证过,接上真表后需要逐列核对(代码里已把列名集中,改起来是一处)。
  3. 多人尚未覆盖的工程项:入场大厅/断线重连的策略选择(现在是"少一人就等")、 输入回滚(现在只做等待)、以及把联机接进单人战役那套存档/任务状态 (目前联机是独立沙盒:世界与角色是共享的,背包/任务不进网络)。

路线

里程碑 内容
M0 ✅ MPQ 容器 + 解压 + 精灵/调色板解码 + 浏览器检查器
M1 🚧 DS1+DT1 地图渲染、子瓦片碰撞、8 方向角色动画与深度排序均已在浏览器内验证(合成夹具);待接入真实 MPQ 数据;DCC/COF(复合动画)留待有真文件时做——Go 参考实现自己就未实现 bottom-up 帧,做不了完整对照
M2 ✅ 战斗沙盒:表驱动怪物、AI 状态机、近战与冷却、血蓝球、经验与升级(真表待接入)
M3 ✅ 物品:表驱动基物与词缀、掉落(可复现随机)、背包网格、金币、拾取(真表待接入)
M4 ✅ 技能(投射物/瞬发)、TBL 文本、任务状态机与奖励、NPC 状态台词(真表待接入)
M5 ✅ 存档快照(读档续跑逐 tick 一致)、.d2s 核心字段与校验和、确定性锁步(输入延迟 + 逐 tick 哈希 + 检测到分歧即报 tick)、WebSocket 传输层与 2–4 人浏览器实测均已跑通;.d2s 数据段布局待真文件确认

第三方参考

格式语义的移植参考:

  • StormLib(MIT,© Ladislav Zezula)— MPQ 容器、密码学、解压
  • DevilutionX / Devilution — CEL/CL2 帧结构与游程语义、玩家/怪物宽度表、方向顺序

验证强度分级(诚实记录)

格式 独立对照 强度
MPQ 容器 mpyq(Python)+ StormLib 语义 强(往返 + 独立读取)
DC6 dc6png(JS)+ OpenDiablo2/dc6(Go) 强(像素 + 全字段)
DS1 / DT1 元数据 / PL2 OpenDiablo2 对应 Go 包 强(全字段 / 逐表摘要)
DT1 像素落位 夹具期望网格(编码器↔解码器) 中(自洽,非独立)
CEL / CL2(D1) 真实归档全量 + 游戏表宽度 强(真数据)
联机锁步 无(自行设计,无外部实现可对照) 强(两独立世界 + 真 TCP/WebSocket + 真浏览器两实例;但只验证过自家实现)
TBL 无(Go 包实现的是带哈希表的扩展变体) 弱(仅构造检查,待真文件确认)

详见 THIRD_PARTY_NOTICES.md。本项目为独立 TypeScript 实现,不复制 C/C++ 源码。