以 Diablo II v1.13c D2Common.dll 为黄金标准,在 maze.ts 的 stamp() 中补齐两处
原版装载期行为。
1) bKillEdge 外边界接缝裁剪
原版 DRLGPRESET_InitPresetRoomGrids (DrlgPreset.cpp:1105) 由 LvlPrest.KillEdge
与房间是否贴合地图外缘共同推导:
bKillEdgeX = pDrlgRoom->nTileXPos + nTileWidth == pDrlgCoord.nPosX + nWidth
再由 DRLGROOMTILE_LoadInitRoomTiles (DrlgRoomTile.cpp:449) 转成
nTileCountX = nTileWidth + (bKillEdgeX == 0)。
- MazePiece 新增 killEdge 字段(来源 LvlPrest.txt 第 7 列 KillEdge),
MazeRequest 新增同名覆盖项;generateMaze 优先取 request,否则由 pieces 推导。
- stamp() 在 KillEdge 生效时将合成图收窄一列一行,并对贴合外缘的房间按
variant.width - 1 / height - 1 落图,内部接缝的一格重叠保持不变。
- pack-act-assets.ts 的 getMazePieces 读取真实 KillEdge 列。
经实测 MPQ 内 LvlPrest.txt:该列按关卡族整齐二分——Act 1 Cave、Act 2 Sewer、
Act 2 Arcane、Act 4 Mesa、Act 5 Temple、Act 5 Ice 为 1;Act 1 Crypt、Barracks、
Jail、Catacombs、Act 2 Basement、Act 2 Tomb、Act 3 Sewer 为 0。此前引擎完全忽略
该列,上述 6 个关卡族生成的地图恒比原版多一列一行。
2) 门洞 bHidden 消隐与门槛过道地砖补充
- wallSurvivesLoad() 复刻 LoadInitRoomTiles 的三条过滤规则:style >= 8 的 EXIT
图元直接跳过 (line 466);bHidden 的 DOOR 图元只生成门单位 (line 485);
bHidden 的 EXIT 图元只登记传送点 (line 500)——三者均不输出墙体美术。
- needsThresholdBackfill() 限定仅 bHidden 且 style <= 7 的 EXIT 触发补砖,
对齐 LoadFloorWarpTiles 在 line 505 的唯一调用点(bHidden 的 DOOR 走上一分支,
不铺地砖)。
- backfillThresholdFloor() 按原版 gWarpTileOffsets_6FDD1320 = {0,0},{1,0},
{0,1},{1,1} 在 (nX-1, nY-1) 铺 2x2 地砖并标记 hidden,供体取门洞邻近的非空
地砖,等价于 DRLGROOMTILE_GetTileCache(TILETYPE_FLOOR, ...)。
- 补砖在所有房间落图完成后统一执行:门洞位于两房交界,供体可能属于当时尚未
落图的邻居。
verify-packs 只校验 preset 关卡(entry.kind !== 'preset' 时跳过),故迷宫尺寸
变化不会影响 1671 项逐字节断言。
新增 tests/killedge-113c.test.ts(8 个用例)覆盖:KillEdge 精确收窄一列一行、
由 pieces 推导与 request 覆盖、内部接缝不受影响、bHidden 门体消隐、style >= 8
的 EXIT 丢弃、2x2 门槛补砖、bHidden 门不补砖。已做变异测试:关闭裁剪或关闭墙体
过滤各会导致 3 个用例失败,确认断言有效。
Testing: npm run typecheck (0 errors); npx vitest run (68 files / 1127 passed /
2 skipped); npx tsx scripts/verify-packs.ts (1671/1671).
TAG=agy
CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
|
||
|---|---|---|
| .gitea/workflows | ||
| docs | ||
| samples/fixtures | ||
| scripts | ||
| src | ||
| tests | ||
| tools/go-oracle | ||
| .gitignore | ||
| README.md | ||
| ROADMAP.md | ||
| THIRD_PARTY_NOTICES.md | ||
| acts.html | ||
| arena.html | ||
| index.html | ||
| net.html | ||
| package-lock.json | ||
| package.json | ||
| tsconfig.json | ||
| vite.config.ts | ||
| vitest.config.ts | ||
| walk.html | ||
README.md
d2web
从零实现的暗黑破坏神 II 引擎(纯 TypeScript + Vite + WebGL2,零运行时依赖),在浏览器里读取 用户自备的经典版 D2 归档(MPQ),渲染等距地图、驱动角色走动,并支持把地图离线烘焙成 web 原生格式。
- 仓库不含任何暴雪资源:没有
.mpq/.dc6/.dt1/ 图集 PNG;原版数据要自己放进samples/d2/。 - 代码可复现一切:格式解码、地图构建、资源包烘焙、角色合成都在这份代码里,脚本可重跑。
- 路线图、实现细节、验证方法论与未完成项在
ROADMAP.md(README 只做总览与部署使用)。
总览
| 能力 | 说明 |
|---|---|
| 等距地图渲染 | DS1 + DT1 投影(2:1 dimetric,格 80×40 px),地板 / 墙 / 屋顶 三层分别绘制;逐格按 RarityFrameIndex 加权随机选瓦片变体;画家序排序 |
| 碰撞 | 每格 5×5 个 sub-tile 标志位(一层内所有层做 OR 合并,含 type 13 影子层),角色按 sub-tile 判定走位 |
| 真实关卡 | 已烘焙 365 张地图,覆盖 5 个 act:35 个预置关卡(罗格营地、修道院、大教堂、地下墓穴、鲁高因、库拉斯特码头、哈洛加斯…)+ 70 个随机迷宫 + 31 个野外关卡(生成逻辑移植自 D2MOO,按官方参数表复现) |
| 真·女法师 | 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 |
尚未实现:真实地图上的怪物还是占位配置——怪物种类、密度、精英怪、超级唯一怪都尚未从
Levels.txt / MonStats.txt / SuperUniques.txt 读取(见 M7);关卡之间还不连通(见 M8);
战斗数值不是暗黑2 的公式(见 M9);可破坏物品的美术(位置与美术成员已进包,DCC 帧未接)。
完整的里程碑排期见 ROADMAP.md。
代码结构
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
部署与使用
1. 前置:自备游戏数据
把你自己合法拥有的经典版暗黑破坏神 II(1.13c) 数据放进 samples/d2/,至少需要
d2data.mpq、d2char.mpq、d2exp.mpq、Patch_D2.mpq。资源包模式(页面默认)只需要
samples/d2-packs/——它不在 Git 里(63 MB 的索引 PNG 每次重烘都会让仓库再长一份),
按下面的「资源包从哪来」下载解包后,没有原版数据也能看地图。
samples/d2/ 通常做成软链接,指向一个磁盘上的持久目录。切勿链到 /tmp——它是内存盘,
重启即清空,链接会变死链而角色/怪物退化成占位方块。本机的实际摆放、清单与逆向参考用的原版
DLL 见 docs/LOCAL_DATA.md。
2. 资源包从哪来(不在 Git 里)
图集包(62 个地图块 / 35 关,索引色 PNG + scene.json)不在本仓库里——它是 ~66 MB 的索引色
PNG,每次重烘几乎全部文件都变,而 PNG 无法被 git 的 delta 压缩,随仓库分发会让每烘焙一次仓库再长
~60 MB。它放在图库仓库,那里刻意只保留一个提交(每次重烘 orphan 提交 + force push),
体积恒定在「一份快照」:
git clone https://git.projectdiablo2.cn/troytt/diablo2-web-assets.git samples/d2-packs
只想要一份压缩包(不需要 git):
curl -O https://www.laiseek.xyz/diablo2/assets/d2-packs-2026-09-14.tar.xz
sha256sum d2-packs-2026-09-14.tar.xz # 应为 9cdfd0c19ac68de842c6725070b65d056e25f5bcc77f36d6d9a29cd9f1b431e8
tar xJf d2-packs-2026-09-14.tar.xz # 解出 samples/d2-packs/
仓库里只保留 .gitignore 规则与本文档;要自己生成包就照第 3 步 npm run pack:data 从原版 MPQ
烘焙,再用 npm run publish:packs 发布到图库仓库(脚本会重建单提交历史)。
3. 本地跑起来
npm install # 仅 vite / typescript / @types/node
tsx scripts/make-fixtures.ts samples/fixtures # 夹具:DC6 + 调色板 + 文本表
tsx 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
pack:data 需要原版 MPQ(约 2 分钟);只用上面下载来的 samples/d2-packs/ 可以跳过它,
代价是 ?live=1 与重新烘焙不可用。
4. 页面与操作
| 页面 | 用途 |
|---|---|
/acts.html |
主页面:全屏画布 + 三级选择器(章节 / 场景 / 细分场景)+ HUD |
/index.html |
MPQ 资源检查器(头、存储标志、压缩掩码分布、成员列表、精灵预览) |
/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) |
主页面查询参数:
?act=1..5 章节
?level=<地图块 label> 直接指定细分场景,如 33-act-1-cathedral-cathy3
?quadrant=<DS1 名> 直读归档模式下指定该关卡的某个 DS1
?live=1 不走资源包,直接按 HTTP Range 读 MPQ 解码(慢)
?pack=<目录> 换一个资源包目录 ?base=<目录> 换一个归档目录
操作:WASD / 方向键移动(按屏幕方向),滚轮 或 + / - 缩放,0 复位;画布随窗口自适应并铺满视口。
5. 部署到服务器
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 用得到
nginx(/etc/nginx/sites-enabled/default):
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 自检。
6. 自检命令
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
数据与许可
- 仓库不含暴雪美术:原版 MPQ、
reference/下的第三方参考源码都不入库(规则写在.gitignore与THIRD_PARTY_NOTICES.md);samples/fixtures/(本项目自产)与samples/d2-packs/(派生数据)只作为/diablo2/assets/下的归档发布,不进 Git 历史。 图集包每次重烘都会让 Git 仓库再长 ~60 MB(PNG 无法 delta 压缩),所以不再入库。 - 使用本引擎需要你自己合法拥有的游戏数据。将来落地的生成类关卡(迷宫 / 野外)会在页面与文档中标注为 "按官方参数表复现,非引擎原版布局"(生成逻辑移植自 D2MOO)。