README 精简为「项目总览 + 部署与使用」
- 总览:能力表(等距渲染 / 碰撞 / 35 关 62 块 / 女法师 DCC+COF / 资源包 / 按需读归档 / 三级选择器)、代码结构、尚未实现的部分(对象美术、101 个生成关卡 → HANDOVER §4f)。 - 部署与使用:自备数据前置、本地启动(夹具 → pack:data → dev)、五个页面与查询参数、 键位、生产部署(build:game + nginx 三个 location + Range 要求)、自检命令清单、数据与许可。 - 450 行 → 134 行;原来的 M0–M5 里程碑与逐轮验收证据留在 Git 历史(# d2web Web 原生的暗黑破坏神 II 引擎(进行中)。**仓库不含任何游戏素材**:引擎只读取你自备的 经典版 MPQ(`d2data.mpq` / `d2exp.mpq` / `patch_d2.mpq` 等),素材始终留在你自己机器上。 零运行时依赖(纯 TypeScript + Vite + 平台 API)。项目位于 `/root/diablo_web`; **接手/继续开发先读 [`HANDOVER.md`](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 张表的逐表摘要) | ```bash REFDUMP=/path/to/refdump DC6PNG=/path/to/dc6png/src/index.js \ bash scripts/verify-format-parity.sh /tmp/d2fix ``` **差分测试当场抓到了我自己的一个真 bug**:我最初按 16 字节读 DT1 块头,而 [社区权威文档](http://paul.siramy.free.fr/_divers/dt1_doc/) 与参考实现都是 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`。 ## 验证方式(可复现) ```bash 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 # 与独立实现的完整差分 ``` 多人联机需要中继(自写、零依赖,只转发字节,不懂游戏): ```bash 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 内): ```bash 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` 选项。 ## 浏览器端 ```bash 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/夹具,仓库不含任何暴雪素材): ```bash 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](https://github.com/ladislav-zezula/StormLib)(MIT,© Ladislav Zezula)— MPQ 容器、密码学、解压 - [DevilutionX](https://github.com/diasurgical/DevilutionX) / [Devilution](https://github.com/diasurgical/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++ 源码。), HANDOVER.md 里也加了指引。
This commit is contained in:
parent
255915036e
commit
956f7e8b45
11
HANDOVER.md
11
HANDOVER.md
|
|
@ -335,6 +335,17 @@ node scripts/browser/many-pages.mjs "$CHROME" \
|
|||
4. `src/net/netplay.ts` 的握手与发送游标 —— 四个真 bug 的现场。
|
||||
5. `scripts/verify-net.ts` —— 读测试比读实现更快理解协议与时序。
|
||||
|
||||
## 4e-2. README 只做总览;旧版 README 在 Git 历史里
|
||||
|
||||
README.md 按要求精简为「项目总览 + 部署与使用」两部分。此前那份 M0–M5 里程碑、逐轮验收证据、
|
||||
踩坑记录(宽度守恒、深度排序、资源包体积实测等)没有丢:它在首个提交里,可随时取回:
|
||||
|
||||
```bash
|
||||
git show 897b373:README.md > /tmp/README-old.md
|
||||
```
|
||||
|
||||
同类的实现细节与验证方法论仍然完整保留在本文件的其它小节。
|
||||
|
||||
## 4f. 生成关卡(101 关)现状与修复清单 —— 暂缓,未烘焙
|
||||
|
||||
页面上的 **35 个场景全部是 `DrlgType=2` 的固定 DS1**;生成类关卡一个都没进包:
|
||||
|
|
|
|||
522
README.md
522
README.md
|
|
@ -1,450 +1,134 @@
|
|||
# d2web
|
||||
|
||||
Web 原生的暗黑破坏神 II 引擎(进行中)。**仓库不含任何游戏素材**:引擎只读取你自备的
|
||||
经典版 MPQ(`d2data.mpq` / `d2exp.mpq` / `patch_d2.mpq` 等),素材始终留在你自己机器上。
|
||||
零运行时依赖(纯 TypeScript + Vite + 平台 API)。项目位于 `/root/diablo_web`;
|
||||
**接手/继续开发先读 [`HANDOVER.md`](HANDOVER.md)**(目录导览、十条设计约定与踩坑、验证方法论、
|
||||
环境依赖重建、未完成项分类)。
|
||||
从零实现的**暗黑破坏神 II 引擎**(纯 TypeScript + Vite + WebGL2,**零运行时依赖**),在浏览器里读取
|
||||
用户自备的经典版 D2 归档(MPQ),渲染等距地图、驱动角色走动,并支持把地图离线烘焙成 web 原生格式。
|
||||
|
||||
## 现状
|
||||
- **仓库不含任何暴雪资源**:没有 `.mpq` / `.dc6` / `.dt1` / 图集 PNG;原版数据要自己放进 `samples/d2/`。
|
||||
- **代码可复现一切**:格式解码、地图构建、资源包烘焙、角色合成都在这份代码里,脚本可重跑。
|
||||
- 实现细节、验证记录与未完成项在 [`HANDOVER.md`](HANDOVER.md)(README 只做总览与部署使用)。
|
||||
|
||||
### 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 张表的逐表摘要) |
|
||||
|
||||
```bash
|
||||
REFDUMP=/path/to/refdump DC6PNG=/path/to/dc6png/src/index.js \
|
||||
bash scripts/verify-format-parity.sh /tmp/d2fix
|
||||
```
|
||||
|
||||
**差分测试当场抓到了我自己的一个真 bug**:我最初按 16 字节读 DT1 块头,而
|
||||
[社区权威文档](http://paul.siramy.free.fr/_divers/dt1_doc/) 与参考实现都是 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 种颜色 |
|
||||
| **等距地图渲染** | DS1 + DT1 投影(2:1 dimetric,格 80×40 px),地板 / 墙 / **屋顶** 三层分别绘制;逐格按 `RarityFrameIndex` 加权随机选瓦片变体;画家序排序 |
|
||||
| **碰撞** | 每格 5×5 个 sub-tile 标志位(一层内所有层做 OR 合并,含 `type 13` 影子层),角色按 sub-tile 判定走位 |
|
||||
| **真实关卡** | 已烘焙 **35 个预置关卡 / 62 个地图块**,覆盖 5 个 act(罗格营地、修道院、大教堂、地下墓穴、鲁高因、库拉斯特码头、哈洛加斯…) |
|
||||
| **真·女法师** | `d2char.mpq` 的 DCC + COF 逐层合成(8 方向;行走 `SOWLHTH`、站立 `SONUHTH`),不是占位棋子 |
|
||||
| **资源包(离线烘焙)** | 索引色 PNG 图集页(≤2048²,PLTE + tRNS 取自该 act 的 `pal.pl2`)+ `scene.json`(绘制列表、碰撞 RLE、出生点、对象元数据);首屏只加载视口需要的页,其余后台补 |
|
||||
| **按需读归档** | 也可不烘焙,直接用 HTTP Range 只拉需要的 MPQ 字节当场解码(`?live=1`,慢,作为回退路径) |
|
||||
| **三级关卡选择器** | 章节 → 场景 → 细分场景(如 第一章 / 罗格营地 / 北);中文场景名见 `src/game/level-names-zh.ts` |
|
||||
|
||||
**这里又抓到两个只有独立实现能发现的问题**:
|
||||
**尚未实现**:怪物与可破坏物品的美术(对象的位置与美术成员已进包,静态帧未接);70 个随机迷宫 +
|
||||
31 个野外关卡(鲜血荒野等)尚未生成 —— 现状与修复清单见 `HANDOVER.md` §4f。
|
||||
|
||||
1. **存储型成员必须带 `SINGLE_UNIT` 标志**。不带的话,标准读取器会走"多扇区表"路径,
|
||||
把文件自身的数据当成偏移表读出垃圾——而**我自己的读取器恰好容忍**(我实现成"无表直读")。
|
||||
于是出现了"自己读写全对、别人读不出来"的假绿:`fixture.ds1`(zlib)能读、`palette.pal`(存储)读出 0 字节,
|
||||
这个不对称正是定位问题的线索。
|
||||
2. **读取器对未压缩成员多读了一张扇区表**。StormLib 只在 `COMPRESS` 标志下加载偏移表,
|
||||
未压缩文件是从块起点直读的;我原先对非 single-unit 文件一律读表。已按参考实现修正,并对加密+存储的成员按 `key + 扇区号` 逐扇区解密。
|
||||
### 代码结构
|
||||
|
||||
### M5 · 存档与联机 🚧(存档、确定性锁步、**2–4 人浏览器联机合作已跑通**;`.d2s` 待真文件确认)
|
||||
```
|
||||
src/mpq/ MPQ v1 容器:hash/block 表、Storm 加密、分扇区编解码、PKWARE implode/explode、Range 读取
|
||||
src/formats/ dc6 / ds1 / dt1 / pal / pl2 / cel / pcx / tbl / sprite / dcc / cof / bitstream
|
||||
src/game/ 等距场景与碰撞、关卡表解析、DCC+COF 角色合成、对象与物品解析、确定性 RNG
|
||||
src/render/ WebGL2 渲染器(多图集;纹理切换时 flush 以保画家序)
|
||||
src/scene/ 页面级装配(资源包优先,失败回退到直读归档)
|
||||
src/net/ src/sim/ 联机中继与确定性锁步(夹具地图上的 2–4 人合作)
|
||||
scripts/ 资源包烘焙、格式/数据/部署校验、无头浏览器检查、夹具生成
|
||||
*.html 五个入口页面:acts / index / map / walk / net
|
||||
```
|
||||
|
||||
**存档(快照)**:把续跑所需的一切装进一个带版本号的纯数据结构——战斗世界、背包(**按原有格子位置**)、
|
||||
任务进度、**随机流的位置**,以及地面掉落物。随机流位置是最容易被漏掉的一项:不保存它,读档后
|
||||
同一批掉落会以不同顺序出现,看起来"存档能用",直到有人注意到掉的东西不对。
|
||||
## 部署与使用
|
||||
|
||||
验证方式是存档唯一有意义的验证方式:**读档后继续模拟,要求与原进程逐 tick 完全一致**——
|
||||
漏掉任何字段都会在几百 tick 后表现为状态分叉。另含畸形存档与未来版本存档的拒绝。
|
||||
### 1. 前置:自备游戏数据
|
||||
|
||||
**`.d2s` 角色档**:实现了签名、版本、名字/职业/等级字段与**校验和**;物品/任务/传送点/佣兵等段
|
||||
**原样保留字节而不去猜**(没有真文件对照时猜这些会写出静默损坏存档的解码器)。
|
||||
这轮在这里抓到一个**真 bug**:校验和用 JS 的 `<< 1`(32 位有符号移位)会把高位丢弃而不是折叠进位,
|
||||
导致**离末尾 32 字节以外的字节对校验和完全不可见**——那种位置的篡改检测不出来。已改为按规范做进位折叠。
|
||||
把你自己合法拥有的**经典版暗黑破坏神 II(1.13c)** 数据放进 `samples/d2/`,至少需要
|
||||
`d2data.mpq`、`d2char.mpq`、`d2exp.mpq`、`Patch_D2.mpq`。资源包模式(页面默认)只需要
|
||||
`samples/d2-packs/`——本仓库已附带 62 个地图块,所以**没有原版数据也能看地图**。
|
||||
|
||||
**确定性锁步**:这一块**完全不依赖暴雪数据**,所以可以强验证。三条规则:
|
||||
① **某个 tick 只有在所有对等方的输入都到齐时才推进**(缺输入就 waiting,绝不猜测或抢跑——抢跑必分叉);
|
||||
② **输入延迟**若干 tick 作为网络延迟预算;③ **逐 tick 状态哈希**互相比对,第一处不一致即 desync,
|
||||
且报告里带上是**哪个 tick** 分叉的(这决定 bug 能不能查)。
|
||||
### 2. 本地跑起来
|
||||
|
||||
`scripts/verify-m5.ts` 无头断言 **33 项**:快照续跑一致性、畸形/未来版本存档拒绝、地面掉落随档保存、
|
||||
`.d2s` 往返与校验和篡改检测、双会话 300 tick 哈希全同、缺输入等待且计数、输入延迟语义、
|
||||
过期输入被丢弃、以及**篡改输入必须被识别为 desync 并报出正确 tick**(不会响的 desync 检测器比没有更糟)。
|
||||
```bash
|
||||
npm install # 仅 vite / typescript / @types/node
|
||||
node scripts/make-fixtures.ts samples/fixtures # 夹具:DC6 + 调色板 + 文本表
|
||||
node scripts/make-map-fixtures.ts samples/fixtures # 夹具:DT1 + DS1 + 夹具 MPQ
|
||||
npm run pack:data # 从 samples/d2/ 烘焙 62 个地图块 → samples/d2-packs
|
||||
npm run dev # http://127.0.0.1:5173/acts.html
|
||||
```
|
||||
|
||||
浏览器内实测:存档后状态继续变化(击杀 3→5、金币 39→46、地面 2→3),读档后
|
||||
**击杀/背包/金币/地面掉落/等级/位置/怪物存活/任务进度/经验全部回到存档点**,随后继续运行,
|
||||
`glError = 0`、25 tps。这里也抓到一个真实场景 bug:场景里缓存了 `const player = world.player`,
|
||||
读档替换世界后**这个引用仍指向旧世界**,导致读档那一 tick 的后半段还在写旧数据——已改为读档后重新绑定。
|
||||
`pack:data` 需要原版 MPQ(约 2 分钟);只用仓库自带的 `samples/d2-packs/` 可以跳过它,
|
||||
代价是 `?live=1` 与重新烘焙不可用。
|
||||
|
||||
**联机合作(`net.html`)**:每页一个浏览器,**2–4 人同一个世界、各控一名角色**(`?peers=` 指定人数,`?peer=` 是本页编号)。网络上只传四类消息
|
||||
(谁、按了什么键、我这 tick 的状态哈希、再见),**世界状态一个字节都不传**——每台机器各自把整个
|
||||
世界算出来,这正是 25 Hz 能跑在普通连接上的原因。
|
||||
### 3. 页面与操作
|
||||
|
||||
* `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 个角色像素 |
|
||||
| `/acts.html` | **主页面**:全屏画布 + 三级选择器(章节 / 场景 / 细分场景)+ HUD |
|
||||
| `/index.html` | MPQ 资源检查器(头、存储标志、压缩掩码分布、成员列表、精灵预览) |
|
||||
| `/map.html?data=samples/fixtures` | 单人战役沙盒(走路 / 战斗 / 掉落 / 拾取 / 技能 / 任务 / 存读档) |
|
||||
| `/walk.html?sample=<你的.mpq>` | 可行走演示(D1 试玩版 `spawn.mpq` 已验证) |
|
||||
| `/net.html?data=samples/fixtures&ws=ws://127.0.0.1:8787&peers=3&peer=0` | 2–4 人联机(先 `npm run net-server`) |
|
||||
|
||||
**角色动画已接入**(`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`。
|
||||
|
||||
## 验证方式(可复现)
|
||||
|
||||
```bash
|
||||
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 # 与独立实现的完整差分
|
||||
```
|
||||
?act=1..5 章节
|
||||
?level=<地图块 label> 直接指定细分场景,如 33-act-1-cathedral-cathy3
|
||||
?quadrant=<DS1 名> 直读归档模式下指定该关卡的某个 DS1
|
||||
?live=1 不走资源包,直接按 HTTP Range 读 MPQ 解码(慢)
|
||||
?pack=<目录> 换一个资源包目录 ?base=<目录> 换一个归档目录
|
||||
```
|
||||
|
||||
多人联机需要中继(自写、零依赖,只转发字节,不懂游戏):
|
||||
操作:**WASD / 方向键**移动(按屏幕方向),**滚轮 或 `+` / `-`** 缩放,`0` 复位;画布随窗口自适应并铺满视口。
|
||||
|
||||
### 4. 部署到服务器
|
||||
|
||||
```bash
|
||||
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
|
||||
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/
|
||||
mkdir -p /var/www/d2data && ln -f samples/d2/*.mpq /var/www/d2data/ # 只有 ?live=1 用得到
|
||||
```
|
||||
|
||||
地图场景需要夹具被 HTTP 提供(`samples/` 已在 .gitignore 内):
|
||||
nginx(`/etc/nginx/sites-enabled/default`):
|
||||
|
||||
```nginx
|
||||
location = /diablo2 { return 301 /diablo2/; }
|
||||
location = /diablo2/ { rewrite ^ /diablo2/acts.html last; }
|
||||
location /diablo2/ { alias /var/www/d2web/; autoindex off; add_header Cache-Control 'no-store'; }
|
||||
|
||||
location /diablo2/packs/ { # 资源包:可缓存
|
||||
alias /var/www/d2packs/;
|
||||
autoindex off;
|
||||
add_header Cache-Control 'public, max-age=3600';
|
||||
}
|
||||
|
||||
location ~ ^/diablo2/data/(?<d2file>[A-Za-z0-9_.-]+\.mpq)$ { # 只暴露 .mpq,不列目录
|
||||
alias /var/www/d2data/$d2file;
|
||||
autoindex off;
|
||||
add_header Accept-Ranges bytes;
|
||||
add_header Cache-Control 'public, max-age=3600';
|
||||
}
|
||||
```
|
||||
|
||||
要点:归档必须支持 **Range**(否则页面会退化成整文件下载);`/root` 是 0700,所以用硬链接暴露给 nginx。
|
||||
部署后跑 `npm run verify:deploy` 自检。
|
||||
|
||||
### 5. 自检命令
|
||||
|
||||
```bash
|
||||
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
|
||||
npm run verify:deploy # 线上:页面 / 资源包索引 / 图集 / Range 206 / 旧入口 410
|
||||
npm run verify:packs # 资源包与"现读现解"逐项比对(绘制序、碰撞栅格逐字节、每帧像素哈希)
|
||||
npm run verify:tiles # 每个槽位画的是不是该有的瓦片类型(地面=type 0)+ 位置公式
|
||||
npm run verify:alignment # 墙 / 地面基线、屋顶偏移与引擎公式一致
|
||||
npm run verify:object-lookup # DS1 对象 id → token/mode 与 Objects.txt 交叉核对
|
||||
npm run verify:acts # 关卡表 → DS1 → DT1 → 等距场景 + 碰撞
|
||||
npm run verify:d2 # 真 MPQ 数据端到端解码
|
||||
npm run verify:implode # PKWARE implode 解码器(全成员)
|
||||
npm run typecheck # tsc --noEmit
|
||||
```
|
||||
|
||||
在暗黑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` 选项。
|
||||
|
||||
## 浏览器端
|
||||
|
||||
```bash
|
||||
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/夹具,仓库不含任何暴雪素材):
|
||||
|
||||
```bash
|
||||
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](https://github.com/ladislav-zezula/StormLib)(MIT,© Ladislav Zezula)— MPQ 容器、密码学、解压
|
||||
- [DevilutionX](https://github.com/diasurgical/DevilutionX) / [Devilution](https://github.com/diasurgical/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++ 源码。
|
||||
- 仓库**不含**暴雪美术:原版 MPQ、`reference/` 下的第三方参考源码都不入库(规则写在 `.gitignore` 与
|
||||
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md));`samples/fixtures/`(本项目自产)与
|
||||
`samples/d2-packs/`(本仓库内的派生数据)仅在本私有仓库内流通。
|
||||
- 使用本引擎需要你自己合法拥有的游戏数据。将来落地的生成类关卡(迷宫 / 野外)会在页面与文档中标注为
|
||||
**"按官方参数表复现,非引擎原版布局"**(生成逻辑移植自 D2MOO)。
|
||||
|
|
|
|||
Loading…
Reference in New Issue