Compare commits

...

2 Commits

Author SHA1 Message Date
troytt 258be4d608 fix(publish): fine-grained batched push with resume
跨境链路对长连接做 MSS 钳制:实测 pmtu 1460 但 mss 被压到 324,重传率
13.7%,cwnd 触底到 3-4,有效带宽只剩 ~20 KB/s。原来按 act 分 5 批推送,
单批最大 117 MB,在这种链路上必然超时,且失败后整批重来。

改为:
- 按地图目录累计到 ~10 MB 就切一批(45 批 / 399.1 MB),失败只重推一批
- http.lowSpeedLimit 1000 + http.lowSpeedTime 120:低于 1 KB/s 持续 2 分钟
  就断开,让重试开新连接(实测新连接能拿回 mss 1408,速度提升 2.5 倍)
- --work 支持续推:用 git ls-remote 读远端 main,回卷本地工作区对齐,
  再跳过所有已在 HEAD 中的批次,避免重复上传
- 5 次重试,线性退避 15/30/45/60 秒

另外修正 tests/wilderness-features.test.ts 的 WildernessPiece 导入——
verbatimModuleSyntax 下类型导入必须用 import type。

TAG=agy
CONV=c89513df-cd12-4749-b5bd-5e9f16639391
2026-09-15 06:33:19 +00:00
troytt de384f0744 docs: replace HANDOVER.md with ROADMAP.md (M6-M14)
HANDOVER.md 有三处已经过期:§4f 说 101 个生成关卡未烘焙(现已全部烘焙,
788/788 断言)、§7A-4 说 DCC/COF 未实现(现已实现,法师 16 层 8 方向)、
§7A 说 ADPCM+Huffman 音频未做(现已完成,11 项测试通过)。

新的 ROADMAP.md 分两部分:

第一部分是路线图。核心发现是一个此前没被点破的架构断层——acts.html
有真实等距地图和真实角色但零玩法,map.html 有完整玩法栈但跑在夹具地图
和 demo-data.ts 的 3 怪 / 3 物 / 2 技能上,两条轨道从未合流。因此把
「两轨合流」定为 M6,它是 M7-M14 全部里程碑的前提。后续依次为:真实怪物、
世界连通、真实战斗数学、真实物品系统、七职业与技能树、完整 UI/HUD、
.d2s 存档互通、可破坏物件美术与剧情。

第二部分保留 HANDOVER 中仍然有效的工程知识:23 条引擎铁律(模拟纯函数、
瓦片匹配键用 DT1 +20 的 Type 而非 +0 的 Direction、地面槽位只能画 type 0、
LvlPrest 用 LevelId 关联等)、差分验证方法论、验证命令清单、环境依赖、
性能基线与发布流程、目录结构。

README.md 同步更新:文档入口改指 ROADMAP.md,关卡数从「35 个预置」更正为
「365 张(含 70 迷宫 + 31 野外)」,「尚未实现」段落重写为真实缺口。

TAG=agy
CONV=c89513df-cd12-4749-b5bd-5e9f16639391
2026-09-15 06:32:59 +00:00
5 changed files with 773 additions and 417 deletions

View File

@ -1,384 +0,0 @@
# 交接文档 · d2web
**项目路径:`/root/diablo_web`**(原 `/root/codex_web/dsh/d2web`,2025-09-12 迁移;目录内没有任何写死的绝对路径,迁移后功能与迁移前一致,验证见文末「迁移后复验」)
一句话:**用纯 TypeScript + Vite + WebGL2 从零写的暗黑破坏神 2 引擎**,读取**用户自备**的经典版 MPQ,
目标是浏览器内可玩的垂直切片。仓库**不含任何暴雪素材**,零运行时依赖(只用平台 API)。
---
## 1. 现在能玩什么
```bash
cd /root/diablo_web
npm install # 只装 devDependencies:vite / typescript / @types/node
# 夹具(仓库不含素材,夹具是脚本生成的)
tsx scripts/make-map-fixtures.ts samples/fixtures
tsx scripts/make-mpq-fixture.ts samples/fixtures # 把夹具打成真 MPQ
npm run dev # http://127.0.0.1:5173/
```
| 页面 | 地址 | 内容 |
| --- | --- | --- |
| 资源检查器 | `/` 或 `/?sample=samples/spawn.mpq` | MPQ 头/存储标志/压缩掩码分布、成员列表、精灵逐帧预览 |
| 可行走演示 | `/walk.html?sample=samples/spawn.mpq` | **真实暗黑 1 归档**:CEL/CL2 精灵、调色板、8 方向走动、碰撞 |
| 单人战役沙盒 | `/map.html?data=samples/fixtures` 或 `?mpq=<你的.mpq>` | DS1+DT1 地图、战斗、掉落与拾取、背包、技能、任务、NPC 对话、存档/读档(K/L) |
| 联机合作 | `/net.html?data=samples/fixtures&ws=ws://127.0.0.1:8787&peers=3&peer=0` | 2–4 人同一个世界,各控一名角色 |
| **diablo2 地图 + 女法师** | 本地 `/acts.html?act=1`;线上 `https://www.laiseek.xyz/diablo2/?act=1`(`?act=1..5`、`?level=<label>`、`?quadrant=<DS1>`、`?live=1`)。页面上是三级选择器:**章节 → 场景 → 细分场景**(如 第一章 / 罗格营地 / 北),中文场景名见 `src/game/level-names-zh.ts` | **真实 D2 数据**:默认读预解包资源包(`samples/d2-packs`,索引 PNG + JSON),没有包或 `?live=1` 时直接按 HTTP range 读归档并解码。等距投影地图 + 8 方向走动 + 碰撞 + **真·女法师(DCC+COF 合成,16 层/8 方向)**,**无怪物**;可破坏物品位置与美术成员已进包,静态帧待接 |
| 旧 act 页(已下线) | `/acts/`、`/acts-packs/`、`/acts-data/` | 全部 `410 Gone`;页面迁到 `/diablo2/`,资源包 `/diablo2/packs/`,归档 `/diablo2/data/*.mpq` |
联机需要先起中继(自写、零依赖、只转发字节、不懂游戏):
```bash
npm run net-server # 等价于 node scripts/net-server.ts 8787
```
操作:WASD/方向键移动,空格或 J 攻击,E/F 拾取,T 说话,1–4 选技能,K 存档,L 读档。
`acts.html` 只需 WASD/方向键:输入是**屏幕方向**(菱形格子相对屏幕转了 45°,按"上"沿格子对角线向上走)。
资源包(离线解包,页面默认路径):
```bash
npm run pack:data # scripts/pack-act-assets.ts → samples/d2-packs(62 个地图块 / 35 关,约 67 MB)
# 图集包不在代码仓库里(每次重烘会让仓库再长 ~60 MB,PNG 无法 delta 压缩)。发布方式:
# 1) 图库仓库 troytt/diablo2-web-assets:npm run publish:packs
# → 每次 orphan 提交 + force push,仓库恒定"一份快照"(~66 MB),代码仓库 clone 仅 ~2.7 MB
# 2) 归档镜像(可选):https://www.laiseek.xyz/diablo2/assets/d2-packs-<日期>.tar.xz
# (nginx location /diablo2/assets/ → /var/www/d2assets/)
# 注意:force push 后旧提交对象要服务端 git gc 才回收;Gitea 的 [cron.git_gc_repos] 默认关闭,
# 而 Gitea 的 Release 附件在本实例(124.221.104.39)是坏的:1KB..59.5MB 全部 HTTP 500。
npm run verify:packs # 与现读现解逐项比对:绘制序/坐标、碰撞栅格逐字节、每帧像素 FNV 哈希(992/992)
npm run verify:listfile # 用社区 1.13c listfile 逐归档判定"这个成员到底存不存在"
npm run verify:deploy # 线上 /diablo2/ 页面 + 资源包 + Range 206 + 旧入口 410(17/17)
npm run verify:alignment # 墙/地面基线是否与引擎公式一致、D2MOO 的 80px 差是不是常量(6/6)
npm run verify:tiles # 每个槽位画的是不是它该有的瓦片类型(地面=0)+ 位置公式(5/5)
npm run verify:object-lookup # DS1 对象 id → token/mode 与 Objects.txt 交叉核对(19/19)
```
线上实测(真实 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(首帧只要 7 页里的 4 页) |
| act5 读归档(`?live=1`) | 3,971 | 11.24 MB | 36.5 s |
线上部署(nginx 在 `/etc/nginx/sites-enabled/default` 里,页面 `/diablo2/`、资源包 `/diablo2/packs/`、归档 `/diablo2/data/*.mpq`;旧 `/acts*` 一律 410):
```bash
npm run build:game # → dist-game/(base=/diablo2/)
rm -rf /var/www/d2web && mkdir -p /var/www/d2web && cp -r dist-game/* /var/www/d2web/
rm -rf /var/www/d2packs && mkdir -p /var/www/d2packs && cp -r samples/d2-packs/* /var/www/d2packs/
# 归档用硬链接暴露给 nginx(/root 是 0700,nginx 读不到):
# mkdir -p /var/www/d2data && ln -f samples/d2/*.mpq /var/www/d2data/
systemctl reload nginx
```
真数据相关的验收命令:
```bash
npm run verify:implode # 四个归档全量成员解码(implode 端到端证明)
npm run verify:acts # 五个 act 城镇:表→DS1→DT1→等距场景 + 碰撞
# 归档没有 (listfile) 时(Patch_D2.mpq 就是),成员名在 Storm 里是加密的:
# MpqArchive.open(source, { listfile }) 可挂社区名单,样例见 scripts/verify-listfile.ts
#
# 对象/瓦片这几件事都按 OpenDiablo2 的引擎口径对齐过(副本在 samples/od2/):
# 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. 瓦片匹配的键是 `style:sequence:type`,其中 `type` 对应 DT1 头 **+20 的 `Type`**,
# **不是 +0 的 `Direction`**(那是朝向/变体索引,实测 1..5,永远不会等于 14 树/15 屋顶)。
# 键错会让树/屋顶/影子全落 loose 兜底、画成地面——罗格营地的帐篷"没屋顶"就是这个。
# 改键后 35 关 9,595 条墙引用 100% 精确命中(verify:alignment 有永久断言)。
# 同一 style:sequence:type 下有多张变体图(Direction 与 RarityFrameIndex 不同),引擎**逐格**
# 按 RarityFrameIndex 加权随机选一张,种子来自 (level seed, cellX, cellY):pickVariant + levelSeed。
# 3. 一格的 sub-tile flags 是**所有层做 OR**(OD2 SubTileFlags.Combine),影子层是 type 13 的
# 独立层,也必须并进碰撞;实测三张城镇里影子贡献 0 个阻挡子格(scene.shadowBlockedSubtiles)。
# 4. 方向:引擎是 64 方向空间经 5 张查表映射到 COF 方向(OD2 Dir64ToCof),
# src/game/character.ts 的 dir64ToCof 已按表移植;8 向输入下 4 方向 COF 的映射与
# 旧启发式**不同**(2/3 都映到 1),16/32/64 方向下相同。
# 4b. **地面槽位只能画 DT1 type 0**:DS1 的 floor 记录没有 type 字段,引擎语义是 type 0
# (OD2 `TileData(style, sequence, 0)`)。曾经这里传 null 走"类型无关"兜底池,池里混着
# 墙/柱子/影子/树/屋顶;加上变体加权随机后,地面槽位会随机挑到暗色石墙瓦片,而地面绘制
# 不做墙那套 minBlockY+80 补偿 → 画面里出现"悬在地面上的黑色方块"(35 关实测
# 1,918/28,704 个地面槽画错类型;修道院大教堂 28.2%、地下墓穴 4 层 41.5%)。
# `npm run verify:tiles` 把这个普查变成永久断言(错类型 0 / looseRefs 0 / missingTiles 0
# / 地面·墙·屋顶位置公式自洽)。
# 5. 屋顶(DS1 wall type 15)单独成层、最后绘制,偏移用引擎的 -roofHeight:
# scene.json 里是 `roofs` 数组,页面在地面/墙/角色之后画它(verify:packs 单独比对)。
# 当前 62 个预置关卡里 9 张有屋顶,共 737 个绘制(act 4 城镇 53 个)。
# (打包器的 DT1 解码缓存有上限 16 个库:不设上限时整轮烘焙会涨到 4 GB 以上。)
#
# 页面 UI:header 只留标题 + HUD(原来的三个站内链接已去掉);三个下拉框是
# `#act`(章节)/ `#scene`(场景,取自 pack 索引的 slug 分组)/ `#variant`(细分场景,
# 该场景下的 DS1 变体)。变体名:城镇/回廊/鲁高因按 `TownN1`/`CourtW`/`LutN` 后缀给方位名,
# 其余显示 DS1 名。中文场景名是**社区通用译名**(手工整理,见 level-names-zh.ts 头部说明;
# 我们这份安装包的 `data\local\LNG\CHI\string.tbl` 解出来是乱码,`.tbl` 解码器尚未对真文件校准)。
# 回归断言:`scripts/browser/checks/map-switch.js`(8 项:无多余链接、三级结构、三个下拉框的跳转参数)。
npm run verify:d2 # samples/d2 里每个 MPQ 的头/块槽/sha256
```
---
## 2. 目录结构
```
src/
mpq/ MPQ v1 容器:header/tables/sectors、crypt(HashString/块加密/文件键)、解压掩码分发
formats/ dc6 ds1 dt1 pal pl2 cel pcx tbl sprite(各格式的纯解码器,无 DOM 依赖)
game/ combat(战斗/怪物 AI/经验)items(基物/词缀/背包/掉落)skills(投射物)
quests(任务/NPC/对话)map(DS1+DT1 合成、子瓦片碰撞、深度插入)
animation(按方向的帧控制器)save(快照 + .d2s)rng(可复现随机)tables(表解析)
hitflash/… 等辅助
net/ protocol(二进制消息)transport(内存对 / 内存 hub / WebSocket)lockstep(锁步内核)
netplay(握手门控 + 输入游标 + 哈希收发 + 超时)
render/ atlas(精灵图集打包)renderer(WebGL2 单批次四边形 + 顶点色 tint)
sim/ loop(定点 25 Hz 循环,含追帧上限)input(键盘)
scene/ map-scene.ts(单人战役,~1200 行)net-scene.ts(联机,~600 行)
index.html walk.html map.html net.html 四个入口
scripts/ 夹具生成、验证脚本、中继服务器、浏览器验证工具(见 scripts/browser/README.md)
tools/go-oracle/ 独立 Go 参考解码器源码(差分验证用,见 §6)
samples/ 夹具与被 .gitignore 忽略的归档(spawn.mpq、fixtures/)
dist/ 构建产物
```
---
## 3. 里程碑状态与证据
| 里程碑 | 状态 | 证据 |
| --- | --- | --- |
| M0 资源管线 | ✅ | 真暗黑 1 `spawn.mpq`(25.8 MB / 1029 项)全量解包 + 精灵解码;自写 MPQ 写入器往返 + `mpyq` 独立读取;与 3 个独立实现全字段差分 |
| M1 可走地图 | ✅ | DS1+DT1 渲染、5×5 子瓦片碰撞、8 方向动画、深度插入;`map.html`(夹具/真归档)、`walk.html`(真 D1 归档,25 tps) |
| M2 战斗沙盒 | ✅ | `verify-combat.ts` 38 项;浏览器内实测 |
| M3 物品系统 | ✅ | `verify-items.ts` 58 项 |
| M4 技能/任务/NPC | ✅ | `verify-m4.ts` 56 项 |
| M5 存档与联机 | ✅ | `verify-m5.ts` 33 项(读档续跑逐 tick 一致);`verify-net.ts` 143 项;三浏览器同时联机实测 |
一条命令跑完无头验证:
```bash
npm run verify:all # combat + items + m4 + m5 + net
```
浏览器端证据用 `scripts/browser/`(见其 README)。实测过的数字(本轮迁移前):
- 单人战役:存档后继续到 tick 364,读档回到存档点(位置 276、击杀 4、金币 46、地面 2、等级 2),24.6 tps、`glError 0`
- 真归档:`walk.html?sample=samples/spawn.mpq` → `ready: true`、25.0 tps、`glError 0`
- 三人联机:三页 `peers 3`、各听到 2/2 名队友、都跑到 **worldTick 306 且完全齐平**、各比对 110–115 个哈希全一致、`malformed 0`、无 desync、三页 `players` 数组逐字段相同
---
## 4. 关键设计约定(改代码前先读这段)
1. **模拟必须是纯函数**。联机靠锁步:每台机器各自算整个世界,网络上**只传按键**。
所以任何读时钟、读 `Math.random`、读宿主环境的行为都会变成 desync。
随机一律走 `Rng`(种子进存档),时间一律走 25 Hz 的 tick 计数。
2. **tick 只在所有对等方输入到齐时才推进**(`LockstepSession.step` 返回 `waiting`)。
抢跑 = 分叉;等待 = 正确。
3. **输入延迟**(`inputDelayTicks`)是延迟预算:某一 tick 的输入提前若干 tick 发出。
发送游标要从 `0` 补齐到应付 tick,否则 `0..D-1` 谁都不发,游戏在第一 tick 前就死锁。
4. **握手门控**:没确认对方在监听(收到对方的 hello)之前**不发任何输入**。
先发出去的帧如果对面还没连上,中继无人可转发、发送方无从得知,那些输入永久消失 → 两边永久互等。
确认用 `ackTo` 指名道姓(四人局里"我听到了"必须说清听到了谁)。
5. **`CombatWorld.player` 与 `CombatWorld.players[i]` 必须是同一个对象**。
读档/快照是靠对象展开拼世界的,展开后 `player` 是新的、`players` 是旧的,
症状是"世界模拟一个身体、屏幕画另一个"。加新字段时要么避开,要么调用 `rebindPlayer()` 重新绑定。
6. **一个 tick 里所有玩家先动、怪物后动,且只动一次**(`tickCombatMulti`)。
每人各调一次 `tickCombat` 会把怪物跑两遍。
7. **怪物只攻击"本 tick 开始时还活着"的玩家**;全场无人存活时怪物回合整个跳过。
这条是单人死亡/重生手感的来源(死亡时世界停住、重生当 tick 不挨打)。
8. **解码器宁可拒绝也不猜**。没有真文件对照的字段(`.d2s` 数据段、部分表列)
一律**原样保留字节**,不做"看起来合理"的解析——猜错的解码器会静默产出坏存档/坏贴图。
9. **渲染器画完必须 `renderer.flush()`**。批处理器不 flush 就只剩清屏色,
看起来和"场景没加载"一模一样。
10. **MPQ 文件键用纯文件名**(不是成员全路径),查到表项用的是全路径;存储型成员**没有扇区偏移表**
(只有 `COMPRESS` 才需要)。这三条都踩过。
---
## 5. 验证方法论(为什么这些数字可信)
- **差分验证**:与独立实现对照,而不是自己跟自己对。用过:StormLib(MIT,只读语义)、
DevilutionX/Devilution、`OpenDiablo2/{dc6,ds1,dt1,pl2,tbl_text}`(Go)、npm `dc6png`。
差分脚本 `scripts/verify-format-parity.sh`(见 §6)。
- **真数据**:暗黑 1 试玩版 `spawn.mpq` 是唯一的**真实归档**证据,用于 MPQ 容器、CEL/CL2、调色板、
精灵宽度推断。
- **判据要能失败**:`verify-net.ts` 里的篡改用例会改写飞行中的输入,要求**在正确的 tick 报出 desync**;
"永远不会响的检测器"比没有检测器更糟。
- **不要谎报核对**:联机时落后方收到的是自己还没算到的 tick 的哈希,
这些哈希**缓存到本地历史追上再比**,无法核对的单独计数(`hashesIgnored`),不算作"一致"。
- **浏览器侧的判定靠状态字段,不靠看图**:这套环境里模型读不了图,
且 `readPixels` 在合成后返回清空缓冲。页面把状态挂在 `window.__d2webNet` /
`window.__d2webMap` / `window.__d2web` 上,由 CDP 读取断言。
---
## 6. 环境依赖(都在本机,但**不在仓库里**,重装机器要重建)
| 用途 | 位置 | 重建方式 |
| --- | --- | --- |
| Node | v22.22.2(系统) | Node 的类型剥离模式跑 `.ts` 脚本,**不支持 TS 参数属性**(`constructor(private x)`),脚本里别写 |
| headless Chromium | `/root/.cache/ms-playwright/chromium-1223/chrome-linux64/chrome` | `npx playwright install chromium`;启动参数见 `scripts/browser/README.md` |
| Go 参考解码器 | 源码已收进 `tools/go-oracle/main.go` | `cd tools/go-oracle && /opt/go1.22/bin/go mod init ref && /opt/go1.22/bin/go get github.com/OpenDiablo2/{dc6,ds1,dt1,pl2}@latest && /opt/go1.22/bin/go build -o /tmp/refdump .`(Go 在 `/opt/go1.22`,GOPROXY 用 `https://goproxy.cn,direct`) |
| `dc6png`(npm) | 曾在 `/tmp/dc6png` | `npm i dc6png` 到任意目录,用 `DC6PNG=<…>/src/index.js` 指过去 |
| Python + PIL | 系统 | 差分脚本比像素时用 |
跑完整差分:
```bash
REFDUMP=/tmp/refdump DC6PNG=/tmp/dc6png/node_modules/dc6png/src/index.js \
bash scripts/verify-format-parity.sh /tmp/d2fix
```
三个外部依赖都是**可选**的:缺了会打印 `SKIP`,剩余检查照跑。
---
## 7. 还没做完的(按"能不能自己做"分类)
### A. 原来卡在"没有真 D2 MPQ"上的(**已解除**,`samples/d2/` 有真数据)
现状:用户自备的 1.13c 数据落在 `samples/d2/`(11 个 MPQ,约 1.9GB,来源/字节数/sha256/
版本证据在 `samples/d2/MANIFEST.md`)。**但那是汉化/免 CD 整合安装**,客户端 DLL 与官方
1.13c 补丁文件集不一致,别拿它做字节级对照。
**第一前置已做完**:`src/mpq/implode.ts` 实现了 **PKWARE DCL implode**,`decompress.ts` 接线,
`archive.ts` 的读法修正。现在实测能读:
```bash
npm run verify:implode # 30914 个成员、893 MB 全部解出,12/12 断言
npm run verify:acts # 五个 act 城镇解析 + 合成,40/40
```
只剩 **ADPCM+Huffmann 音频**(572 个成员,`mask 0x41/0x81`,本轮不做)。
这一轮踩到的坑(都写进了代码注释,别再踩):
1. **掩码字节与压缩标志是两种写法**。`MPQ_FILE_COMPRESS(0x200)` 才有扇区首字节掩码;
`MPQ_FILE_IMPLODE(0x100)` 单独出现时**没有掩码字节**,整段就是 implode 流。
`Patch_D2.mpq` 的每张表都是后者;旧代码按"有掩码"读,遇到首字节 0x00 就当成 stored,
**静默返回压缩字节**(`levels.txt` 能返回 66,615 字节却不含 `LevelName`、尾部全是 0)。
现在的规则:`body.length === expected` → stored;否则按标志选 implode 或掩码,且长度不符一律抛错。
2. **DT1 的 `blockSize` 不是 `numBlocks * 20`**。真文件里 25 个块该字段是 6900
(= 25×20 头 + 25×256 体)。旧断言用 20×N 校验,于是**只认自己生成的夹具、拒绝所有真文件**。
现在按 `numBlocks*20 + Σlength` 校验,不符只记 warning。
3. **DS1 的 `style` 匹配的是 DT1 瓦片自己的 `style` 字段**,不是该 DT1 在 `LvlTypes` 里的下标。
`Act1/Outdoors/River.dt1` 里的瓦片内部 style 是 2/3,`Fence.dt1` 只有 0;按"库下标"解会把
大部分城镇解对、再丢掉 219 个引用。改成合并所有库按 `style:sequence:direction` 查之后 missing=0。
4. **`LvlPrest` 要用 `LevelId` 关联,不是 `Def`**。Act 2 城镇 `Def=301 / LevelId=40`;用 `Def` 会
给四个 act 返回**别的关卡**的地图。
5. **D2 是等距投影**:格子在屏幕上是 80×40 的菱形(`(cx-cy)*80, (cx+cy)*40`),5×5 子格是 16×8;
地板画在 `cell+(-80, 0)`,墙要加 `minBlockY+80`(墙的美术长在格子上方)。矩形摆放只对夹具成立。
6. **空槽要跳过**:DS1 每个格子都带固定数量的墙槽,其中 `prop1 == 0` 的是占位(Act 1 城镇 4674 个
墙槽里 4275 个如此),画出来就是垃圾。
**可破坏物品的美术卡在同一个解码器上**:`data\global\objects\` 下是 **1748 个 DCC + 1461 个 COF,只有 13 个 DC6** ——
也就是说 D2 画对象用的是和角色一样的 COF+DCC 合成管线。资源包因此只烘了它们的**位置与元数据**
(`Objects.txt` 的名称/HP/Token + 选定的美术成员名),像素留给 DCC/COF 解码器(和法师同一件事)。
不要把这些文件当 DC6 硬解:头部对不上,解出来是垃圾(本轮实测过)。
implode 落地后,§7A 剩下这四件(现在都有真文件可对照):
1. `tsx scripts/inspect-mpq.ts <file> header` 和 `hist` → 头字段与压缩掩码分布。
2. **DT1 数据块偏移基准**:`src/formats/dt1.ts` 现在按"先相对、失败再绝对"处理 `FileOffset`,需要真文件确认。
3. **经典 `string.tbl` 索引布局**:`src/formats/tbl.ts` 目前只有**构造检查**级证据
(Go 侧 `tbl_text` 实现的是后来带哈希表的变体,不能当对照)。
4. **DCC / COF 动画**:未实现。Go 参考实现自己就报 `bottom up frames are not implemented`,
所以当时判断做不出可信对照;真文件到手后按格式文档 + 逐字节对照可以做。
5. **`.d2s` 数据段布局**:现在只实现签名/版本/名字/职业/等级/校验和,其余段原样保留。
6. **D2 表列映射**:`src/game/*` 里读表用的是夹具列名 + 少量官方文档确认的列名。
接真表要逐列核对(列名集中在各 `*FromTable`/`monsterStatsFromRow`,改动集中)。
### B. 工程上可以做但还没做的
- **入场大厅 / 断线重连策略**:现在是"少一人就等"(正确但生硬),没有"踢人/暂停/恢复"的选择界面。
- **输入回滚**:只做了"等待",没做预测 + 回滚(对 25 Hz 局域网够用,跨洋会明显卡顿)。
- **联机与战役状态**:联机是独立沙盒——世界和角色共享,但**背包/任务不进网络**。
要打通需要把物品/任务状态纳入确定性模拟并进哈希。
- **联机场景的怪物/掉落**:目前联机场景只有战斗与经验,没有联机的物品掉落与拾取。
- **DCC/COF 之外的美术缺口**:联机场景没有背包 UI、没有血蓝球(单人场景有)。
---
## 8. 迁移后复验(本次迁移做的检查)
```bash
cd /root/diablo_web
npx tsc --noEmit # 类型检查
npm run build # 四个入口都进产物
tsx scripts/verify-net.ts # 143 项
```
三者在迁移后均通过(见本次会话最后一条工具输出);开发服务器与中继已改为从新路径启动:
```bash
npm run dev # 后台任务,http://127.0.0.1:5173/
npm run net-server # 后台任务,ws://127.0.0.1:8787
```
页面级复验(`scripts/browser/`):
```bash
CHROME=/root/.cache/ms-playwright/chromium-1223/chrome-linux64/chrome
tsx scripts/browser/inspect-page.mjs "$CHROME" \
"http://127.0.0.1:5173/map.html?data=samples/fixtures" \
scripts/browser/checks/map-save-load.js
tsx scripts/browser/many-pages.mjs "$CHROME" \
"http://127.0.0.1:5173/net.html?data=samples/fixtures&ws=ws://127.0.0.1:8787&peer=0" \
"http://127.0.0.1:5173/net.html?data=samples/fixtures&ws=ws://127.0.0.1:8787&peer=1"
```
---
## 9. 从哪读起
1. `README.md` —— 里程碑叙述 + **「验收:整条目标链的证据」** 一节(逐项对照可复现命令)。
2. 本文 §4「关键设计约定」—— 十条踩过的坑,改代码前必读。
3. `src/net/lockstep.ts` 顶部注释 —— 锁步为什么这么设计。
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**;生成类关卡一个都没进包:
| `DrlgType` | 关卡数 | 已烘焙 |
| --- | --- | --- |
| 2 预置 | 35 | 35 |
| 1 随机迷宫 | 70 | 0 |
| 3 野外 | 31 | 0 |
例:**鲜血荒野 = `Act 1 - Wilderness 1`(Id 2,DrlgType=3)**,未烘焙。`scripts/pack-act-assets.ts`
里以 `pending: { wilderness: 31, maze: 70 }` 如实记录,页面不会假装有这些图。
`npm run verify:generators` 实测(2026-09-13):
1. **9 关直接抛错** `no usable LvlPrest pieces`:Act 2 Harem 2、Act 3 憎恨囚牢 1/2、
Act 5 冰窟 1/1A/2/2A/3/3A。根因:这些迷宫的 `LvlPrest` **没有该 `LevelId` 的行**
(实测行数 = 0),房间件要按 `LvlMaze` 行 + **关卡类型的件名**解析。
2. **可达率**:75 关中 15 关 < 50%,其中 8 关 = **0.0%**(Crypt 1A/2A/3A/3B/3C/3D、
Pandemonium 1、Wilderness 5)——开口方向/拼接没对上,或出生点被围死。
3. **瓦片引用缺失**:36/75 关存在,最高 **56.8%**(Act 2 Lair/Tomb 系列)。
4. **野外不可玩**:鲜血荒野可达 1.6%、黑色沼泽 0.0%、墓地 0.0%、冰冷之原 3.2%。
修复顺序(每步都要过 `verify-generators` 的阈值门:确定性 + 可达率 + 缺引用 + 房间数 ≥
`LvlMaze.Rooms`):① 件来源改写(`LvlMaze` + 类型件名)→ ② 开口拼接与出生点连通 →
③ 件→瓦片映射(按 `Dt1Mask` 与件自身 style 选库,注意 type(+20) 那条教训)→
④ 野外 `LvlSub` 替换规则。烘进包时必须在 pack 索引/HUD/文档里标注
**“按官方参数表复现,非引擎原版布局”**(生成逻辑移植自 D2MOO,不是逐字节还原)。

View File

@ -5,7 +5,7 @@
- **仓库不含任何暴雪资源**:没有 `.mpq` / `.dc6` / `.dt1` / 图集 PNG;原版数据要自己放进 `samples/d2/`。
- **代码可复现一切**:格式解码、地图构建、资源包烘焙、角色合成都在这份代码里,脚本可重跑。
- 实现细节、验证记录与未完成项在 [`HANDOVER.md`](HANDOVER.md)(README 只做总览与部署使用)。
- 路线图、实现细节、验证方法论与未完成项在 [`ROADMAP.md`](ROADMAP.md)(README 只做总览与部署使用)。
## 总览
@ -13,14 +13,15 @@
| --- | --- |
| **等距地图渲染** | DS1 + DT1 投影(2:1 dimetric,格 80×40 px),地板 / 墙 / **屋顶** 三层分别绘制;逐格按 `RarityFrameIndex` 加权随机选瓦片变体;画家序排序 |
| **碰撞** | 每格 5×5 个 sub-tile 标志位(一层内所有层做 OR 合并,含 `type 13` 影子层),角色按 sub-tile 判定走位 |
| **真实关卡** | 已烘焙 **35 个预置关卡 / 62 个地图块**,覆盖 5 个 act(罗格营地、修道院、大教堂、地下墓穴、鲁高因、库拉斯特码头、哈洛加斯…) |
| **真实关卡** | 已烘焙 **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` |
**尚未实现**:怪物与可破坏物品的美术(对象的位置与美术成员已进包,静态帧未接);70 个随机迷宫 +
31 个野外关卡(鲜血荒野等)尚未生成 —— 现状与修复清单见 `HANDOVER.md` §4f。
**尚未实现**:`acts.html` 的真实地图上还**没有玩法**——怪物、战斗、物品、技能、存档目前只存在于
`map.html` 的夹具地图轨道上,两轨尚未合流;可破坏物品的美术(位置与美术成员已进包,DCC 帧未接)。
完整的里程碑排期见 [`ROADMAP.md`](ROADMAP.md)。
### 代码结构

539
ROADMAP.md Normal file
View File

@ -0,0 +1,539 @@
# 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) |
但存在一个**根本性的架构断层**——这是整个路线图的排序依据:
```mermaid
graph TB
subgraph A["轨道 A:acts.html(真地图轨)"]
A1["act-scene.ts (1437 行)"]
A2["✅ buildIsoMapScene 真实等距 DS1/DT1"]
A3["✅ loadCharacterSheet 真实法师 16 层 × 8 向"]
A4["✅ CollisionGrid 真实碰撞"]
A5["❌ 无战斗 ❌ 无物品 ❌ 无技能<br/>❌ 无存档 ❌ 无小地图/传送点"]
end
subgraph B["轨道 B:map.html(玩法轨)"]
B1["map-scene.ts (1054 行)"]
B2["❌ 正交 fixture 地图,非真实 D2"]
B3["✅ tickCombat / spawnMonsters"]
B4["✅ Inventory / rollDrop"]
B5["✅ castSkill / tickProjectiles"]
B6["✅ 存读档 / 任务 / NPC"]
B7["⚠️ 全部跑在 demo-data.ts 的<br/>3 怪 / 3 物 / 2 词缀 / 2 技能 / 5 级经验上"]
end
subgraph C["轨道 C:net.html(联机轨)"]
C1["net-scene.ts (653 行) 25Hz 锁步"]
C2["⚠️ 背包/任务不进同步哈希"]
end
A -.->|"两条轨道从未合流<br/>这是最大的阻塞点"| B
B -.-> C
```
`acts.html` 的现状可以概括为:**在一张漂亮的真地图上,操纵一个真法师,永远独自行走。**
### 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 合流方向 | **抽出第三个共享层**,两个页面都变薄 |
| 联机 | **不作为目标**,只需单机;**保留扩展可能**(模拟层纯函数约束必须继续遵守) |
| 怪物精灵包体 | **接受按幕分包 + 按需懒加载** |
| 音频 | 解码器保留,**暂不接入场景层** |
| `.d2s` 存档与官方互通 | **需要**(见 M13) |
---
## 1. 里程碑总览
| # | 里程碑 | 重要程度 | 风险 | 状态 |
| --- | --- | --- | --- | --- |
| M6 | 两轨合流(共享玩法层) | ★★★★★ | 高 | 待开始 |
| M7 | 真实怪物 | ★★★★★ | 中 | 待开始 |
| M8 | 世界连通 | ★★★★★ | 中高 | 待开始 |
| M9 | 真实战斗数学 | ★★★★☆ | 中 | 待开始 |
| M10 | 真实物品系统 | ★★★★☆ | 中 | 待开始 |
| M11 | 七职业与技能树 | ★★★★☆ | 中 | 待开始 |
| M12 | 完整 UI / HUD | ★★★★☆ | 低 | 待开始 |
| M13 | `.d2s` 存档互通 | ★★★★☆ | 中高 | 待开始 |
| M14 | 可破坏物件美术与剧情 | ★★★☆☆ | 中 | 待开始 |
| — | 架构预留:音频 / 联机 | — | — | 不在交付路径 |
```mermaid
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` 上跑通的整套玩法抽成与地图实现无关的共享层,两个页面共同依赖;
核心技术点是让 `CombatWorld` 绑定到 `d2map.ts` 的 `CollisionGrid` 而不是 `map.ts` 的正交网格。
`combat.ts` 的 `CombatTerrain` 是**重叠计数谓词**而非布尔值(身体能从坏出生点逃出),
而 `d2map.ts` 提供的是 `isBlockedAt(scene, x, y): boolean` + `cellAt` + `cellCentre` + `findIsoSpawn`。
需要一个适配器把后者升格成前者,并处理等距坐标下 sub-tile **16×8 的各向异性**
(`ORTHO_SUB_TILE_WIDTH = 16` / `ORTHO_SUB_TILE_HEIGHT = 8`)。
- **[NEW] `src/scene/shared/gameplay.ts`** —— 与地图实现无关的玩法编排:世界创建、tick 循环、
事件流消费、掉落、拾取、存读档钩子。
- **[NEW] `src/game/iso-terrain.ts`** —— `CollisionGrid` → `CombatTerrain` 适配器。
**最容易出 bug 的地方:正交轨假设 sub-tile 是正方形。**
- **[MODIFY] `src/scene/act-scene.ts`** —— 接入共享层。怪物与投射物必须插入**现有的等距深度排序**
(`IsoDraw` 列表),不能另起一层,否则会穿墙穿屋顶;屋顶层(DS1 wall type 15)仍最后画。
- **[MODIFY] `src/scene/map-scene.ts`** —— 改为消费共享层,保留 fixture 地图作为**快速回归测试台**
(它比真地图快得多,单元测试仍应打在这条轨上)。
**验收**:在 `acts.html` 的第一幕荒野上,能被怪物追、能打死怪、能捡到掉落、能 K/L 存读档。
---
## 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 开始时还活着」的玩家**;全场无人存活时怪物回合整个跳过。
### 解码与渲染
8. **解码器宁可拒绝也不猜**。没有真文件对照的字段一律**原样保留字节**——
猜错的解码器会静默产出坏存档/坏贴图。
9. **渲染器画完必须 `renderer.flush()`**。不 flush 就只剩清屏色,看起来和「场景没加载」一模一样。
10. **MPQ 文件键用纯文件名**(不是成员全路径,全路径是查哈希表用的);
存储型成员**没有扇区偏移表**(只有 `MPQ_FILE_COMPRESS 0x200` 才有)。
11. **掩码字节与压缩标志是两种写法**。`MPQ_FILE_IMPLODE (0x100)` 单独出现时**没有掩码字节**,
整段就是 implode 流(`Patch_D2.mpq` 的每张表都是后者)。
规则:`body.length === expected` → stored;否则按标志选 implode 或掩码;长度不符一律抛错。
12. **DT1 的 `blockSize` 是 `numBlocks*20 + Σlength`**,不是 `numBlocks*20`。
13. **DS1 的 `style` 匹配 DT1 瓦片自己的 `style` 字段**,不是该 DT1 在 `LvlTypes` 里的下标。
14. **`LvlPrest` 用 `LevelId` 关联,不是 `Def`**(Act 2 城镇 `Def=301 / LevelId=40`)。
15. **等距投影**:格子在屏幕上是 80×40 的菱形 `((cx-cy)*80, (cx+cy)*40)`,5×5 子格是 16×8;
地板画在 `cell+(-80, 0)`,墙要加 `minBlockY+80`(墙的美术长在格子上方)。
16. **空槽要跳过**:DS1 每格带固定数量墙槽,`prop1 == 0` 的是占位
(Act 1 城镇 4674 个墙槽里 4275 个如此),画出来就是垃圾。
17. **瓦片匹配键是 `style:sequence:type`**,其中 `type` 对应 DT1 头 **+20 的 `Type`**,
**不是 +0 的 `Direction`**(那是朝向/变体索引,实测 1..5,永远不会等于 14 树 / 15 屋顶)。
键错会让树/屋顶/影子全落 loose 兜底、画成地面。
同一键下有多张变体图,引擎**逐格**按 `RarityFrameIndex` 加权随机选一张,
种子来自 `(level seed, cellX, cellY)`:`pickVariant` + `levelSeed`。
18. **地面槽位只能画 DT1 type 0**。DS1 的 floor 记录没有 type 字段,引擎语义是 type 0。
传 `null` 走「类型无关」兜底池会随机挑到暗色石墙瓦片,而地面绘制不做 `minBlockY+80` 补偿
→ 画面里出现「悬在地面上的黑色方块」(曾实测 1918/28704 个地面槽画错类型)。
19. **一格的 sub-tile flags 是所有层做 OR**,影子层是 type 13 的独立层,也必须并进碰撞。
20. **方向**:引擎是 64 方向空间经 5 张查表映射到 COF 方向(OD2 `Dir64ToCof`),
已移植为 `character.ts` 的 `dir64ToCof`。8 向输入下 4 方向 COF 的映射与朴素启发式**不同**(2/3 都映到 1)。
21. **屋顶(DS1 wall type 15)单独成层、最后绘制**,偏移用 `-roofHeight`;
`scene.json` 里是 `roofs` 数组。62 个预置关卡里 9 张有屋顶,共 737 个绘制(act 4 城镇 53 个)。
22. **打包器的 DT1 解码缓存上限 16 个库**:不设上限时整轮烘焙会涨到 4 GB 以上。
### DS1 对象
23. **DS1 对象的 `id` 不是 `Objects.txt` 的行号**,而是「该 act 对象表」的索引
(`src/game/object-lookup.ts`,数据由 `npm run port:object-lookup` 从 OD2 的表生成)。
identity 映射会把 act1 的喷泉(id 0)读成 `Objects.txt` 第 0 行 "Expansion"。
---
## 2. 验证方法论(为什么这些数字可信)
- **差分验证**:与独立实现对照,而不是自己跟自己对。用过 StormLib(MIT,只读语义)、
DevilutionX/Devilution、`OpenDiablo2/{dc6,ds1,dt1,pl2,tbl_text}`(Go)、npm `dc6png`、D2MOO。
差分脚本 `scripts/verify-format-parity.sh`。
- **真数据**:暗黑 1 试玩版 `spawn.mpq` 是 MPQ 容器 / CEL / CL2 / 调色板的真实归档证据。
D2 侧用户自备 1.13c 数据在 `samples/d2/`(11 个 MPQ,约 1.9 GB,证据在 `samples/d2/MANIFEST.md`)。
**注意那是汉化/免 CD 整合安装,客户端 DLL 与官方 1.13c 补丁文件集不一致,别拿它做字节级对照。**
- **判据要能失败**:`verify-net.ts` 里的篡改用例会改写飞行中的输入,要求**在正确的 tick 报出 desync**;
「永远不会响的检测器」比没有检测器更糟。
- **不要谎报核对**:无法核对的项单独计数(如 `hashesIgnored`),不算作「一致」。
- **浏览器侧的判定靠状态字段,不靠看图**:`readPixels` 在合成后返回清空缓冲。
页面把状态挂在 `window.__d2webNet` / `window.__d2webMap` / `window.__d2web` 上,由 CDP 读取断言。
---
## 3. 验证命令
```bash
npm run typecheck # 类型检查
npm test # vitest,当前 442 passed / 2 skipped
npm run verify:all # combat + items + m4 + m5 + net + collision + tbl + audio + formats
npx tsx scripts/verify-generators.ts samples/d2 # 生成器:当前 788/788,缺瓦片 0.00%,可达 100%
npm run pack:data && npm run verify:packs # 烘焙 + 逐帧像素比对:当前 1302/1302
npm run verify:implode # 四个归档全量成员解码
npm run verify:acts # 五个 act 城镇:表→DS1→DT1→等距场景 + 碰撞
npm run verify:d2 # 每个 MPQ 的头/块槽/sha256
npm run verify:listfile # 用社区 1.13c listfile 逐归档判定成员是否存在
npm run verify:alignment # 墙/地面基线是否与引擎公式一致
npm run verify:tiles # 每个槽位画的是不是它该有的瓦片类型(地面=0)
npm run verify:object-lookup # DS1 对象 id → token/mode 与 Objects.txt 交叉核对
npm run verify:deploy # 线上页面 + 资源包 + Range 206 + 旧入口 410
```
归档没有 `(listfile)` 时(`Patch_D2.mpq` 就是),成员名在 Storm 里是加密的:
`MpqArchive.open(source, { listfile })` 可挂社区名单,样例见 `scripts/verify-listfile.ts`。
---
## 4. 环境依赖(不在仓库里,重装机器要重建)
| 用途 | 说明 |
| --- | --- |
| Node | v22.22.2。类型剥离模式跑 `.ts` 脚本,**不支持 TS 参数属性**(`constructor(private x)`) |
| TypeScript | 开启 `verbatimModuleSyntax`,**类型导入必须用 `import type`** |
| headless Chromium | `npx playwright install chromium`;启动参数见 `scripts/browser/README.md` |
| Go 参考解码器 | 源码在 `tools/go-oracle/main.go`;`go get github.com/OpenDiablo2/{dc6,ds1,dt1,pl2}@latest` |
| `dc6png`(npm) | `npm i dc6png`,用 `DC6PNG=<…>/src/index.js` 指过去 |
| Python + PIL | 差分脚本比像素时用 |
```bash
REFDUMP=/tmp/refdump DC6PNG=/tmp/dc6png/node_modules/dc6png/src/index.js \
bash scripts/verify-format-parity.sh /tmp/d2fix
```
三个外部依赖都是**可选**的:缺了会打印 `SKIP`,剩余检查照跑。
---
## 5. 性能基线与发布
线上实测(真实 TLS,同一台机):
| | 请求数 | 传输 | 出画面 |
| --- | --- | --- | --- |
| act1 资源包 | 4 | 1.21 MB | 1.5 s |
| act1 读归档(`?live=1`) | 1,344 | 6.22 MB | 12.1 s |
| act5 资源包 | 9 | 3.80 MB | 2.1 s |
| act5 读归档(`?live=1`) | 3,971 | 11.24 MB | 36.5 s |
资源包比现读现解**少约 30 倍请求、快 8–17 倍**。当前烘焙:365 张地图 / 1917 个文件 / 399.3 MB
(PNG 327.5 MB;act1 117 MB、act2 111 MB、act5 104 MB、act3 56 MB、act4 18 MB)。
图集包**不在代码仓库里**(每次重烘会让仓库再长几百 MB,PNG 无法 delta 压缩)。发布方式:
```bash
npm run pack:data # → samples/d2-packs
npm run publish:packs # → 图库仓库 troytt/diablo2-web-assets(细粒度分批推送,单批 ~10 MB,支持 --work 续推)
```
> 跨境链路对长连接会做 MSS 钳制(实测 pmtu 1460 但 mss 降到 324,重传率 13%,有效带宽 ~20 KB/s)。
> `publish-packs.ts` 因此设了 `http.lowSpeedLimit 1000` + `http.lowSpeedTime 120`:
> 低于 1 KB/s 持续 2 分钟就断开,让重试开一条新连接(新连接通常能拿回 mss 1408,速度提升 2.5 倍)。
> **git 的 `Writing objects: 100% … 29.51 MiB/s` 是骗人的**——那是写进本地内核发送缓冲的速度,
> 不是上线速度。真实进度要看 `ss -tni` 的 `bytes_acked`。
线上部署(nginx:页面 `/diablo2/`、资源包 `/diablo2/packs/`、归档 `/diablo2/data/*.mpq`):
```bash
npm run build:game # → dist-game/(base=/diablo2/)
rm -rf /var/www/d2web && mkdir -p /var/www/d2web && cp -r dist-game/* /var/www/d2web/
rm -rf /var/www/d2packs && mkdir -p /var/www/d2packs && cp -r samples/d2-packs/* /var/www/d2packs/
systemctl reload nginx
```
---
## 6. 目录结构
```
src/
mpq/ MPQ v1 容器:header/tables/sectors、crypt、解压掩码分发、implode、adpcm、huffman
formats/ dc6 dcc cof ds1 dt1 pal pl2 cel pcx tbl sprite(纯解码器,无 DOM 依赖)
game/ combat items skills quests map d2map acts objects character
wilderness maze animation save rng tables demo-data level-names-zh
net/ protocol transport lockstep netplay
render/ atlas renderer(WebGL2 单批次四边形 + 顶点色 tint)
sim/ loop(定点 25 Hz,含追帧上限)input
scene/ act-scene.ts(真地图轨)map-scene.ts(玩法轨)net-scene.ts(联机轨)
index.html walk.html map.html net.html acts.html 五个入口
scripts/ 夹具生成、验证脚本、打包/发布、中继服务器、浏览器验证工具
tools/go-oracle/ 独立 Go 参考解码器(差分验证用)
samples/ 夹具与被 .gitignore 忽略的归档
```
共 63 个 `.ts` 源文件 / 22,097 行。最大的几个:
`maze.ts` 1820、`wilderness.ts` 1534、`act-scene.ts` 1437、`map-scene.ts` 1054、`objects.ts` 952、
`dcc.ts` 827、`renderer.ts` 753、`d2map.ts` 695、`combat.ts` 676、`net-scene.ts` 653。
---
## 7. 页面与操作
| 页面 | 地址 | 内容 |
| --- | --- | --- |
| 资源检查器 | `/` | MPQ 头/存储标志/压缩掩码分布、成员列表、精灵逐帧预览 |
| 可行走演示 | `/walk.html?sample=samples/spawn.mpq` | 真实暗黑 1 归档:CEL/CL2、调色板、8 方向走动、碰撞 |
| 单人战役沙盒 | `/map.html?data=samples/fixtures` | **玩法轨**:战斗、掉落、背包、技能、任务、NPC、存读档(K/L) |
| 联机合作 | `/net.html?...&peers=3&peer=0` | 2–4 人同一世界(保留,不在交付路径) |
| **D2 地图 + 女法师** | `/acts.html?act=1`;线上 `https://www.laiseek.xyz/diablo2/?act=1` | **真地图轨**:等距地图 + 真·女法师(DCC+COF,16 层/8 方向)。三级选择器:章节 → 场景 → 细分场景 |
操作: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` —— 读测试比读实现更快理解协议与时序。

View File

@ -149,51 +149,251 @@ npm run publish:packs # orphan 提交 + force push 覆盖本仓库
`
}
const remote = process.argv[2] ?? process.env['D2_ASSETS_REMOTE'] ?? DEFAULT_REMOTE
/* ------------------------------------------------------------------------- *
* Batch planning
* ------------------------------------------------------------------------- */
/**
* Target size of one push batch, in bytes.
*
* The asset remote sits behind a high-loss cross-border link: measured 13.7%
* TCP retransmission, MSS clamped to 324 bytes by a middlebox, and a congestion
* window pinned at 3–4 segments, which caps a single connection at roughly
* 20 KB/s. A 100 MB push therefore needs ~1.5 h of uninterrupted connection and
* loses everything on the first reset. Batching at ~10 MB keeps each push to a
* few minutes and makes a retry cheap.
*/
const MAX_BATCH_BYTES = 10 * 1024 * 1024
/** How many times to retry one push before giving up. */
const PUSH_ATTEMPTS = 5
/** The order acts are published in, smallest first so the remote is usable early. */
const ACT_ORDER = ['act4', 'act3', 'act5', 'act2', 'act1'] as const
/** One push batch: a set of map directories inside one act. */
interface Batch {
readonly act: string
/** Paths relative to {@link PACKS_DIR}, e.g. `act1/1-act-1-town-townn1`. */
readonly paths: readonly string[]
readonly bytes: number
}
/**
* Total size of a directory tree, in bytes.
*
* @param dir - absolute path.
* @returns bytes, `.git` excluded.
*/
function dirSize(dir: string): number {
let bytes = 0
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.name === '.git') continue
const path = join(dir, entry.name)
if (entry.isDirectory()) bytes += dirSize(path)
else bytes += statSync(path).size
}
return bytes
}
/**
* Split every act into batches of at most {@link MAX_BATCH_BYTES}.
*
* Map directories are never split, so a single oversized map becomes a batch of
* its own. The order within an act is the directory listing order, sorted, so
* the plan is stable across runs — which is what makes resuming safe.
*
* @returns the batches, act by act in {@link ACT_ORDER}.
*/
function planBatches(): Batch[] {
const batches: Batch[] = []
for (const act of ACT_ORDER) {
const actDir = join(PACKS_DIR, act)
if (!existsSync(actDir)) continue
const maps = readdirSync(actDir, { withFileTypes: true })
.filter(entry => entry.isDirectory())
.map(entry => entry.name)
.sort()
let current: string[] = []
let currentBytes = 0
const flush = (): void => {
if (current.length === 0) return
batches.push({ act, paths: current, bytes: currentBytes })
current = []
currentBytes = 0
}
for (const map of maps) {
const bytes = dirSize(join(actDir, map))
if (currentBytes > 0 && currentBytes + bytes > MAX_BATCH_BYTES) flush()
current.push(`${act}/${map}`)
currentBytes += bytes
}
// Loose files directly under the act directory ride along in the last batch.
const loose = readdirSync(actDir, { withFileTypes: true })
.filter(entry => !entry.isDirectory())
.map(entry => `${act}/${entry.name}`)
for (const file of loose) current.push(file)
flush()
}
return batches
}
/* ------------------------------------------------------------------------- *
* Git helpers that tolerate a lossy link
* ------------------------------------------------------------------------- */
/**
* Run one git command and capture stdout instead of inheriting it.
*
* @param args - git arguments.
* @param cwd - directory to run in.
* @returns trimmed stdout, or null when the command failed.
*/
function gitQuery(args: readonly string[], cwd: string): string | null {
try {
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
} catch {
return null
}
}
/**
* Push `main`, retrying with linear backoff.
*
* `http.lowSpeedLimit`/`http.lowSpeedTime` are what stop a dead connection from
* hanging for hours: git aborts once throughput stays under 1 KB/s for two
* minutes, and the retry opens a fresh connection — which on this link usually
* comes back with a healthy congestion window.
*
* @param remoteUrl - the push target.
* @param cwd - the work repository.
* @param label - what is being pushed, for the log.
* @throws when every attempt fails.
*/
function pushWithRetry(remoteUrl: string, cwd: string, label: string): void {
for (let attempt = 1; attempt <= PUSH_ATTEMPTS; attempt += 1) {
try {
execFileSync('git', ['push', remoteUrl, 'main'], { cwd, stdio: ['ignore', 'inherit', 'inherit'] })
return
} catch {
if (attempt === PUSH_ATTEMPTS) {
throw new PublishError(`推送「${label}」失败:已重试 ${String(PUSH_ATTEMPTS)} 次`)
}
const waitSeconds = attempt * 15
console.log(` ↻ 推送「${label}」第 ${String(attempt)} 次失败,${String(waitSeconds)} 秒后重试…`)
execFileSync('sleep', [String(waitSeconds)], { stdio: 'ignore' })
}
}
}
/**
* Is `path` already committed in the work repository's `HEAD`?
*
* @param path - path relative to the repository root.
* @param cwd - the work repository.
* @returns true when `HEAD` already carries it.
*/
function committed(path: string, cwd: string): boolean {
const listed = gitQuery(['ls-tree', '--name-only', 'HEAD', path], cwd)
return listed !== null && listed !== ''
}
/* ------------------------------------------------------------------------- *
* Driver
* ------------------------------------------------------------------------- */
const args = process.argv.slice(2)
const workFlagIndex = args.indexOf('--work')
const reuseWork = workFlagIndex >= 0 ? args[workFlagIndex + 1] : undefined
const positional = args.filter((value, index) =>
value !== '--work' && index !== workFlagIndex + 1)
const remote = positional[0] ?? process.env['D2_ASSETS_REMOTE'] ?? DEFAULT_REMOTE
const stats = measure()
const stamp = new Date().toISOString().slice(0, 10)
const work = mkdtempSync(join(tmpdir(), 'd2-assets-'))
const keepWork = reuseWork !== undefined
const work = reuseWork ?? mkdtempSync(join(tmpdir(), 'd2-assets-'))
try {
const batches = planBatches()
const totalBytes = batches.reduce((sum, batch) => sum + batch.bytes, 0)
console.log(`图集包:${String(stats.maps)} 块地图 / ${String(stats.files)} 个文件 / ${(stats.bytes / 1048576).toFixed(1)} MB`)
console.log(`index.json sha256:${stats.indexSha}`)
console.log(`临时仓库:${work}`)
console.log(`临时仓库:${work}${keepWork ? '(复用,断点续传)' : ''}`)
console.log(`推送计划:${String(batches.length)} 个批次,单批上限 ${String(MAX_BATCH_BYTES / 1048576)} MB,合计 ${(totalBytes / 1048576).toFixed(1)} MB`)
git(['init', '-q', '-b', 'main'], work)
if (!keepWork) git(['init', '-q', '-b', 'main'], work)
// A 500 MB post buffer keeps git from chunking the upload; HTTP/1.1 avoids the
// remote's HTTP/2 stream resets; the low-speed guard turns a stalled
// connection into a fast failure that `pushWithRetry` can recover from.
git(['config', 'http.postBuffer', '524288000'], work)
git(['config', 'http.version', 'HTTP/1.1'], work)
git(['config', 'http.lowSpeedLimit', '1000'], work)
git(['config', 'http.lowSpeedTime', '120'], work)
// PNG atlas pages are already deflate compressed, so delta search is pure cost.
git(['config', 'pack.window', '0'], work)
git(['config', 'core.compression', '1'], work)
// Step 1: Base metadata and index
writeFileSync(join(work, 'README.md'), readme(stats, stamp))
writeFileSync(join(work, '.gitattributes'), '*.png binary -diff\n*.json text eol=lf\n')
cpSync(join(PACKS_DIR, 'index.json'), join(work, 'index.json'))
git(['add', '-A'], work)
git([
'-c', `user.name=${process.env['GIT_AUTHOR_NAME'] ?? 'Dao Tao'}`,
'-c', `user.email=${process.env['GIT_AUTHOR_EMAIL'] ?? 'taodao@google.com'}`,
'commit', '-q', '-m',
`烘焙图集包 ${stamp} [0/5]:索引与元数据\n\nindex.json sha256: ${stats.indexSha}\n\n由 troytt/diablo2-web 的 npm run pack:data 生成。`,
], work)
console.log(`推送到 ${remote}(force,覆盖历史,初始化基线)…`)
git(['push', '--force', remote, 'main'], work)
// Resume: rewind the work tree to whatever the remote already has, so a
// half-finished oversized commit from an earlier run is discarded rather than
// re-uploaded.
if (keepWork) {
const remoteSha = gitQuery(['ls-remote', remote, 'main'], work)?.split(/\s+/)[0]
if (remoteSha !== undefined && gitQuery(['cat-file', '-e', `${remoteSha}^{commit}`], work) !== null) {
console.log(`远端 main 位于 ${remoteSha.slice(0, 7)},回卷工作区以对齐…`)
git(['reset', '--hard', '-q', remoteSha], work)
git(['clean', '-qfd'], work)
}
}
// Step 2..6: Push acts in small incremental batches to stay under HTTP timeout limits
const acts = ['act4', 'act3', 'act5', 'act2', 'act1'] as const
for (let i = 0; i < acts.length; i++) {
const act = acts[i]!
console.log(`添加并提交 ${act} [${i + 1}/${acts.length}]…`)
cpSync(join(PACKS_DIR, act), join(work, act), { recursive: true })
git(['add', act], work)
// Step 0: metadata and index.
if (!committed('index.json', work)) {
writeFileSync(join(work, 'README.md'), readme(stats, stamp))
writeFileSync(join(work, '.gitattributes'), '*.png binary -diff\n*.json text eol=lf\n')
cpSync(join(PACKS_DIR, 'index.json'), join(work, 'index.json'))
git(['add', '-A'], work)
git([
'-c', `user.name=${process.env['GIT_AUTHOR_NAME'] ?? 'Dao Tao'}`,
'-c', `user.email=${process.env['GIT_AUTHOR_EMAIL'] ?? 'taodao@google.com'}`,
'commit', '-q', '-m',
`烘焙图集包 ${stamp} [${i + 1}/5]:${act} 图集`,
`烘焙图集包 ${stamp} [0]:索引与元数据\n\nindex.json sha256: ${stats.indexSha}\n\n由 troytt/diablo2-web 的 npm run pack:data 生成。`,
], work)
console.log(`推送到 ${remote}(增量推送 ${act})…`)
git(['push', remote, 'main'], work)
console.log(`推送到 ${remote}(force,覆盖历史,初始化基线)…`)
git(['push', '--force', remote, 'main'], work)
} else {
console.log('索引与元数据已在远端,跳过。')
}
// Steps 1..N: one small batch per push.
let sentBytes = 0
for (let i = 0; i < batches.length; i += 1) {
const batch = batches[i]!
const label = `${String(i + 1)}/${String(batches.length)} ${batch.act} (${(batch.bytes / 1048576).toFixed(1)} MB)`
sentBytes += batch.bytes
if (batch.paths.every(path => committed(path, work))) {
console.log(`[${label}] 已在远端,跳过。`)
continue
}
for (const path of batch.paths) {
cpSync(join(PACKS_DIR, path), join(work, path), { recursive: true })
}
git(['add', ...batch.paths], work)
git([
'-c', `user.name=${process.env['GIT_AUTHOR_NAME'] ?? 'Dao Tao'}`,
'-c', `user.email=${process.env['GIT_AUTHOR_EMAIL'] ?? 'taodao@google.com'}`,
'commit', '-q', '-m',
`烘焙图集包 ${stamp} [${String(i + 1)}/${String(batches.length)}]:${batch.act} ${String(batch.paths.length)} 项`,
], work)
console.log(`[${label}] 推送中…(累计 ${(sentBytes / 1048576).toFixed(1)}/${(totalBytes / 1048576).toFixed(1)} MB)`)
pushWithRetry(remote, work, label)
}
console.log('完成。使用方:git clone ' + remote + ' samples/d2-packs')
} finally {
rmSync(work, { recursive: true, force: true })
if (!keepWork) rmSync(work, { recursive: true, force: true })
else console.log(`工作区保留在 ${work}(下次可用 --work ${work} 续传)`)
}

View File

@ -5,8 +5,8 @@ import {
getPerimeterOpenings,
SPECIAL_PRESETS_BY_LEVEL,
generateWilderness,
WildernessPiece,
} from "../src/game/wilderness.ts"
import type { WildernessPiece } from "../src/game/wilderness.ts"
import { Rng } from "../src/game/rng.ts"
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"