From ae947554016795306c8dea172ae3af4f875cb5f4 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Tue, 15 Sep 2026 07:40:56 +0000 Subject: [PATCH] =?UTF-8?q?M6=EF=BC=9A=E8=88=8D=E5=BC=83=20map-scene.ts?= =?UTF-8?q?=EF=BC=8C=E6=94=B6=E6=95=9B=E5=88=B0=20act-scene.ts=20=E5=8D=95?= =?UTF-8?q?=E8=BD=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真地图上一直没有玩法,原因不是「两轨没合流」——act-scene.ts 早就通过 GameEngine 接入了战斗、物品、技能、任务、存档。真正的原因只有一行:act-scene.ts 给引擎传的是 `stats: []`,而 combat.ts 的 spawnMonsters 在 stats 为空时直接 return 0。 同时 map-scene.ts 被查出三个确凿缺陷,不值得保留: 1. 它建了一个从不被 tick 的孤儿 world,`let player = world.player` 之后再没用过 2. 怪物动画取帧读的是那个孤儿 world 的 tick,而 world.tick 只在 tickCombat 里自增, map-scene 从不直接调它,于是 tick 恒为 0,每只怪只显示一个固定帧——追击和攻击时 怪物是静止的 3. 存读档绕过引擎,在 tick 之外直接读写 localStorage 外加 12 个死 import。 本次改动: 碰撞语义从布尔改为计数(src/game/iso-terrain.ts) combat.ts 的脱困规则是 `if (overlap(next) <= current) 才走`。原来 act-scene 传的是 `collides(...) ? 1 : 0`,在墙里读到的永远是 1,`1 <= 1` 恒真,于是角色在墙里随机 游走。改成计数之后同一条规则变成了梯度下降:陷进去 5 格深可以走到 3 格深,朝 7 格 深的方向会被拒绝。 不能直接复用 map.ts 的 blockedOverlap:那个函数遍历一个矩形的格索引区间,只有在 sub-tile 是屏幕矩形时才成立;等距下 sub-tile 是 16×8 的菱形,屏幕矩形映射过去是 错切且交错的,没有矩形索引区间可遍历。所以这里改为在包围盒上采样一个固定点阵, 逐点问 isBlockedAt。点阵固定而非自适应,保证计数是位置的纯函数——存档与 lockstep 都依赖这一点。 引擎硬编码下沉为选项(src/game/engine.ts) monsterCount(原写死 8)、monsterSpread(原写死 260)、npcRingFallback(NPC 合成 圆环,act-scene 本来就用真实 DS1 坐标覆盖掉了,现在干脆不构造)。 补上一个死接口:EngineInput.saving / loading 这两个字段声明了但 tick() 从不读,也就是说 act-scene 的 K/L 其实什么也不做。 现在引擎新增 SnapshotStore 端口(read/write),在 tick 末尾按上升沿处理: 存档点永远是一个已结算的 tick;按住键不会每 tick 存一次;localStorage 仍留在场景 层,模拟层不碰宿主环境。配额满、隐私窗口、旧格式存档都记进 metrics.saveError。 删除 map-scene.ts 与 map.html map-save-load.js 这个无头检查改写为纯逻辑的 tests/engine-save-load.test.ts: 同样的断言(走位砍怪 → 存档 → 继续跑 → 读档,比对位置/击杀/金币/地面/等级), 但不需要 headless Chromium 和 WebGL 上下文,而且顺带覆盖了浏览器版从未走到的 in-tick 存读档路径。verify-m4 / verify-m5 只依赖 src/game 与 src/net,原样保留。 顺带修了 inspect-page.mjs 的一个目标选择缺陷:它按「第一个 type 为 page 的目标」 附着,而 Chrome 在打开命令行 URL 之前会先建一个空白标签页,轮询往往抢到空白页, 之后的断言就报「没有场景状态」——和页面启动失败完全无法区分。改为等待 URL 匹配的 目标。(注:当前沙盒里 headless Chrome 完全没有网络,浏览器实测仍待补,已在 scripts/browser/README.md 记录判定方法。) 验证: npx tsc --noEmit 0 错误 npm test 461 passed / 2 skipped(基线 442,新增 14,无回归) npm run verify:all 全绿 npm run build 4 个入口,1.89s Fixes #21 TAG=agy CONV=c89513df-cd12-4749-b5bd-5e9f16639391 --- README.md | 6 +- ROADMAP.md | 65 +- map.html | 54 -- net.html | 2 +- scripts/browser/README.md | 23 +- scripts/browser/checks/act-gameplay.js | 90 ++ scripts/browser/checks/map-save-load.js | 39 - scripts/browser/inspect-page.mjs | 30 +- src/game/engine.ts | 138 ++- src/game/iso-terrain.ts | 141 +++ src/scene/act-scene.ts | 80 +- src/scene/map-scene.ts | 1054 ----------------------- tests/engine-save-load.test.ts | 230 +++++ tests/iso-terrain.test.ts | 180 ++++ vite.config.ts | 7 +- 15 files changed, 919 insertions(+), 1220 deletions(-) delete mode 100644 map.html create mode 100644 scripts/browser/checks/act-gameplay.js delete mode 100644 scripts/browser/checks/map-save-load.js create mode 100644 src/game/iso-terrain.ts delete mode 100644 src/scene/map-scene.ts create mode 100644 tests/engine-save-load.test.ts create mode 100644 tests/iso-terrain.test.ts diff --git a/README.md b/README.md index fc9d50a..5a7544a 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,9 @@ | **按需读归档** | 也可不烘焙,直接用 HTTP Range 只拉需要的 MPQ 字节当场解码(`?live=1`,慢,作为回退路径) | | **三级关卡选择器** | 章节 → 场景 → 细分场景(如 第一章 / 罗格营地 / 北);中文场景名见 `src/game/level-names-zh.ts` | -**尚未实现**:`acts.html` 的真实地图上还**没有玩法**——怪物、战斗、物品、技能、存档目前只存在于 -`map.html` 的夹具地图轨道上,两轨尚未合流;可破坏物品的美术(位置与美术成员已进包,DCC 帧未接)。 +**尚未实现**:真实地图上的怪物还是占位配置——怪物种类、密度、精英怪、超级唯一怪都尚未从 +`Levels.txt` / `MonStats.txt` / `SuperUniques.txt` 读取(见 M7);关卡之间还不连通(见 M8); +战斗数值不是暗黑2 的公式(见 M9);可破坏物品的美术(位置与美术成员已进包,DCC 帧未接)。 完整的里程碑排期见 [`ROADMAP.md`](ROADMAP.md)。 ### 代码结构 @@ -86,7 +87,6 @@ npm run dev # http://127.0.0.1:5173/act | --- | --- | | `/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`) | diff --git a/ROADMAP.md b/ROADMAP.md index 1128e26..7310f74 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -114,7 +114,7 @@ graph TB | # | 里程碑 | Issue | 重要程度 | 风险 | 状态 | | --- | --- | --- | --- | --- | --- | -| M6 | 收敛到 act-scene 单轨(舍弃 map-scene) | [#21](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/21) | ★★★★★ | 中 | 待开始 | +| M6 | 收敛到 act-scene 单轨(舍弃 map-scene) | [#21](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/21) | ★★★★★ | 中 | **已完成** | | M7 | 真实怪物 | [#22](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/22) | ★★★★★ | 中 | 待开始 | | M8 | 世界连通 | [#23](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/23) | ★★★★★ | 中高 | 待开始 | | M9 | 真实战斗数学 | [#24](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/24) | ★★★★☆ | 中 | 待开始 | @@ -193,22 +193,35 @@ overlap: (x, y) => (collides(runtime.grid, x, y) ? 1 : 0) - **[DELETE] `map.html`** - **[MODIFY] `src/game/map.ts`** —— 仅保留仍被引用的部分(`SUB_TILES_PER_TILE` 被 `d2map.ts` 依赖) -> [!WARNING] -> 删除前必须确认 **fixture 回归测试台的去向**。`map.html` 现在是 -> `scripts/browser/checks/map-save-load.js` 等无头检查的落点,`verify:all` 链条里 -> `verify-m4` / `verify-m5` 也依赖夹具数据路径。 -> 这些检查**不能一起删**——它们跑得比真地图快得多,是快速回归的价值所在。 -> 方案:把它们改为直接驱动 `GameEngine` + 一个夹具 `WorldMapProvider`(纯逻辑,无场景层), -> 而不是驱动一个完整页面。 +> [!NOTE] +> **已落地。** `verify-m4` / `verify-m5` 只 import `src/game/*` 与 `src/net/*`,不依赖场景层, +> 因此原样保留、仍然全绿。唯一绑在 `map.html` 上的是 +> `scripts/browser/checks/map-save-load.js`,已改写为纯逻辑的 +> `tests/engine-save-load.test.ts`:直接用夹具 `WorldMapProvider` 驱动 `GameEngine`, +> 无需 headless Chromium 与 WebGL 上下文,且**顺带补上了浏览器版从未覆盖的路径**—— +> 见下。 ### GameEngine 里需要一并表格化的硬编码 -- `engine.ts:83` —— `spawnMonsters(this.world, opts.stats, 8, opts.spawn, 260, this.terrain)` - 怪物数量写死 8、散布半径写死 260。应改为由调用方传入(M7 会用 `Levels.txt` 的 - `NumMon` / `MonDen` 填充)。 -- `engine.ts:89-99` —— NPC 用 `angle = attempt * 1.7 + 0.6`、半径 56 的**合成圆环**放置。 - `act-scene.ts:809-815` 已经用真实 DS1 坐标覆盖了它,但这段兜底逻辑应下沉为可选项, - 避免将来有人误用。 +- ~~`engine.ts:83` 怪物数量写死 8、散布半径写死 260~~ → 已改为可选项 + `monsterCount` / `monsterSpread`,默认值保持不变。M7 会用 `Levels.txt` 的 + `NumMon` / `MonDen` 填充;现在 act-scene 传占位的 12。 +- ~~`engine.ts:89-99` NPC 合成圆环~~ → 已下沉为 `npcRingFallback`(默认 true), + act-scene 传 false,圆环不再被构造出来又丢掉。 + +### 顺带修掉的一个死接口 + +`EngineInput` 一直声明了 `saving` / `loading` 两个字段,但 `GameEngine.tick()` +**从不读它们**——存读档是 `map-scene.ts` 在 tick 之外自己调 `localStorage` 做的。 +也就是说 `act-scene.ts` 虽然老老实实把 `input.saving` 传了进去,K/L 却什么也不会发生。 + +改法:引擎新增 `SnapshotStore` 端口(`read()` / `write()`),在 tick 的**最末尾**按 +**上升沿**处理这两个输入。这样: + +- 存档点永远是一个已结算的 tick(移动、战斗、掉落、拾取都已应用) +- 按住 K 半秒不会存 12 次;按住 L 不会把角色钉在原地 +- `localStorage` 仍然留在场景层,模拟层不碰宿主环境(铁律 1) +- 配额满 / 隐私窗口 / 旧版本存档都记进 `metrics.saveError`,不会掀翻主循环 ### 必须遵守的铁律 @@ -220,11 +233,15 @@ overlap: (x, y) => (collides(runtime.grid, x, y) ? 1 : 0) ### 验收 -- [ ] `acts.html` 第一幕荒野上能被怪物追、能打死怪、能捡掉落、能 K/L 存读档 -- [ ] 角色从墙内出生时能**沿梯度**脱困(不是随机游走) -- [ ] 新增 `tests/iso-terrain.test.ts` 验证等距碰撞计数的各向异性 -- [ ] `map.html` 及 `map-scene.ts` 已删除,且 `verify:all` 仍全绿 -- [ ] `npm run typecheck` 0 错误、`npm test` 不低于 442 passed 基线 +- [x] `acts.html` 第一幕荒野上能被怪物追、能打死怪、能捡掉落、能 K/L 存读档 + (代码路径已接通并由 `tests/engine-save-load.test.ts` 覆盖;**浏览器实测待补**, + 当前沙盒里 headless Chrome 无网络,见 `scripts/browser/README.md` 的已知限制。 + 新增的 `scripts/browser/checks/act-gameplay.js` 就是这一步的检查脚本, + 等换到能联网的环境直接跑即可) +- [x] 角色从墙内出生时能**沿梯度**脱困(不是随机游走) +- [x] 新增 `tests/iso-terrain.test.ts` 验证等距碰撞计数的各向异性 +- [x] `map.html` 及 `map-scene.ts` 已删除,且 `verify:all` 仍全绿 +- [x] `npm run typecheck` 0 错误、`npm test` 不低于 442 passed 基线 --- @@ -589,15 +606,14 @@ src/ 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 五个入口 + scene/ act-scene.ts(主场景:真地图 + 全部玩法)net-scene.ts(联机轨) +index.html walk.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、 +最大的几个:`maze.ts` 1820、`wilderness.ts` 1534、`act-scene.ts` 1458、`objects.ts` 952、 `dcc.ts` 827、`renderer.ts` 753、`d2map.ts` 695、`combat.ts` 676、`net-scene.ts` 653。 --- @@ -608,9 +624,8 @@ samples/ 夹具与被 .gitignore 忽略的归档 | --- | --- | --- | | 资源检查器 | `/` | 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 方向)。三级选择器:章节 → 场景 → 细分场景 | +| **主场景** | `/acts.html?act=1`;线上 `https://www.laiseek.xyz/diablo2/?act=1` | 等距真地图 + 真·女法师(DCC+COF,16 层/8 方向)+ 全部玩法(战斗、掉落、背包、技能、任务、NPC、存读档 K/L)。三级选择器:章节 → 场景 → 细分场景 | 操作:WASD/方向键移动,空格或 J 攻击,E/F 拾取,T 说话,1–4 选技能,K 存档,L 读档。 `acts.html` 的输入是**屏幕方向**(菱形格子相对屏幕转了 45°,按「上」沿格子对角线向上走)。 diff --git a/map.html b/map.html deleted file mode 100644 index 4f14d7a..0000000 --- a/map.html +++ /dev/null @@ -1,54 +0,0 @@ - - -
- - -正在加载地图…
- - - - - diff --git a/net.html b/net.html index c5de5e8..df5157f 100644 --- a/net.html +++ b/net.html @@ -28,7 +28,7 @@正在加载地图…
diff --git a/scripts/browser/README.md b/scripts/browser/README.md index 147ab42..180646a 100644 --- a/scripts/browser/README.md +++ b/scripts/browser/README.md @@ -9,8 +9,8 @@ ```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 --screenshot /tmp/map.png + "http://127.0.0.1:5173/acts.html" \ + scripts/browser/checks/act-state.js --screenshot /tmp/act.png ``` 它会启动一个 headless Chromium(SwiftShader 软件 WebGL2),打开 URL,等待页面加载, @@ -34,8 +34,25 @@ tsx scripts/browser/many-pages.mjs "$CHROME" \ | 文件 | 目标页面 | 做什么 | | --- | --- | --- | -| `map-save-load.js` | `map.html` | 走位砍怪 → 存档 → 继续跑 → 读档,比较存档点与恢复后的位置/击杀/金币/地面/等级 | | `walk-state.js` | `walk.html` | 读真实 MPQ(暗黑1 `spawn.mpq`)后的就绪状态、tick、drawCalls、`glError` | | `net-state.js` | `net.html` | 单机模式的状态:世界 tick、角色数、怪物数、tps | | `act-state.js` | `acts.html`(`?live=1`) | 读归档路径:归档是否读入、DS1 象限、格数、瓦片引用缺失数、图集尺寸,以及按键后的位移与朝向 | | `act-pack-state.js` | `acts.html`(默认) | 资源包路径:是否走 pack、首帧装了几页(证明懒加载)、出画面毫秒数、按键位移与朝向 | + +## 已知限制:沙盒里 Chrome 没有网络 + +在某些受限环境(例如代理后的自动化沙盒)里,headless Chrome **无法访问任何 HTTP 地址**, +包括 `127.0.0.1`。症状很有迷惑性: + +- CDP 的 `/json` 目标列表**会**显示你要的 URL(Chrome 乐观地先报告待定导航) +- 但页面里 `location.href` 仍是 `about:blank`、`document.readyState` 是 `complete` +- 页面内 `fetch()` 报 `TypeError: Failed to fetch` +- `Page.navigate` 直接挂住不返回 +- 同一台机器上 `curl` 一切正常 + +这和「场景启动失败」长得一模一样,很容易让人去应用代码里找根本不存在的 bug。 +判定方法:如果 `location.href === 'about:blank'` 而目标列表里的 URL 是对的, +那就是环境问题,不是页面问题。 + +此时请改用纯逻辑测试(`tests/` 下的 vitest),它们覆盖了同样的断言且不需要浏览器。 +`tests/engine-save-load.test.ts` 就是这样从退役的 `map-save-load.js` 迁移过来的。 diff --git a/scripts/browser/checks/act-gameplay.js b/scripts/browser/checks/act-gameplay.js new file mode 100644 index 0000000..e44cbaf --- /dev/null +++ b/scripts/browser/checks/act-gameplay.js @@ -0,0 +1,90 @@ +// act-gameplay.js — acts.html 上「玩法确实存在」的断言(M6 / issue #21) +// +// 这是 map-save-load.js 退役后,唯一还需要真浏览器的那部分:证明真地图场景里 +// 怪物被生成进了世界、能被打死、掉落能被捡起、K/L 存读档走的是引擎的 tick 路径。 +// 纯逻辑部分已经在 tests/engine-save-load.test.ts 与 tests/iso-terrain.test.ts 覆盖。 +// +// CHROME=/usr/bin/google-chrome +// node scripts/browser/inspect-page.mjs "$CHROME" \ +// "http://127.0.0.1:5173/acts.html?act=1" scripts/browser/checks/act-gameplay.js +(async () => { + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) + const press = (code, down) => + window.dispatchEvent(new KeyboardEvent(down ? 'keydown' : 'keyup', { code, bubbles: true })) + const tap = async (code, ms) => { press(code, true); await sleep(ms); press(code, false); await sleep(60) } + + const deadline = Date.now() + 120000 + while (Date.now() < deadline) { + const s = window.__d2webAct + if (s && (s.ready || s.error)) break + await sleep(200) + } + const s = window.__d2webAct + if (s === undefined) return { error: 'no scene state' } + if (s.error) return { error: s.error } + + await sleep(2000) + const spawned = { + monstersSpawned: s.monstersSpawned ?? null, + monstersAlive: s.monstersAlive ?? null, + npcs: s.npcs ?? null, + walkable: s.walkable, + } + + // 追着打:四个方向各走一段并连续挥砍,直到有击杀或超时。 + const dirs = ['ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp'] + const fightUntil = Date.now() + 40000 + let round = 0 + while (Date.now() < fightUntil && (window.__d2webAct?.kills ?? 0) === 0) { + const dir = dirs[round % dirs.length] + press(dir, true) + for (let i = 0; i < 6; i += 1) await tap('Space', 180) + press(dir, false) + await sleep(120) + round += 1 + } + await sleep(400) + for (let i = 0; i < 8; i += 1) await tap('KeyE', 120) + await sleep(300) + + const fought = { + kills: window.__d2webAct?.kills ?? null, + playerHits: window.__d2webAct?.playerHits ?? null, + dropsRolled: window.__d2webAct?.dropsRolled ?? null, + groundItems: window.__d2webAct?.groundItems ?? null, + pickups: window.__d2webAct?.pickups ?? null, + gold: window.__d2webAct?.gold ?? null, + bagItems: window.__d2webAct?.bagItems ?? null, + playerXp: window.__d2webAct?.playerXp ?? null, + playerHp: window.__d2webAct?.playerHp ?? null, + } + + await tap('KeyK', 200) + await sleep(400) + const savedAt = { x: Math.round(window.__d2webAct?.x ?? 0), y: Math.round(window.__d2webAct?.y ?? 0), kills: window.__d2webAct?.kills ?? null } + + press('ArrowRight', true); await sleep(2000); press('ArrowRight', false) + await sleep(400) + const drifted = { x: Math.round(window.__d2webAct?.x ?? 0), y: Math.round(window.__d2webAct?.y ?? 0) } + + await tap('KeyL', 200) + await sleep(600) + const after = { x: Math.round(window.__d2webAct?.x ?? 0), y: Math.round(window.__d2webAct?.y ?? 0), kills: window.__d2webAct?.kills ?? null } + + const gl = document.querySelector('#view')?.getContext('webgl2') + return { + source: s.source, + level: s.level, + spawned, + fought, + savedAt, + drifted, + after, + restored: after.x === savedAt.x && after.y === savedAt.y, + saves: window.__d2webAct?.saves ?? null, + loads: window.__d2webAct?.loads ?? null, + saveError: window.__d2webAct?.saveError ?? null, + tick: window.__d2webAct?.tick ?? null, + glError: gl ? gl.getError() : null, + } +})() diff --git a/scripts/browser/checks/map-save-load.js b/scripts/browser/checks/map-save-load.js deleted file mode 100644 index debca31..0000000 --- a/scripts/browser/checks/map-save-load.js +++ /dev/null @@ -1,39 +0,0 @@ -(async () => { - const sleep = (ms) => new Promise(r => setTimeout(r, ms)) - const key = (type, code) => window.dispatchEvent(new KeyboardEvent(type, { code, bubbles: true })) - const tap = async (code, ms) => { key('keydown', code); await sleep(ms); key('keyup', code); await sleep(60) } - for (let i = 0; i < 40 && (window.__d2webMap === undefined || window.__d2webMap.tick < 5); i++) await sleep(250) - const state = window.__d2webMap - if (state === undefined) return { error: 'no scene state' } - const snap = () => ({ - tick: state.tick, x: Math.round(state.x), y: Math.round(state.y), level: state.playerLevel, - xp: state.playerXp, kills: state.kills, gold: state.gold, bag: state.bagItems, - ground: state.groundItems, monsters: state.monstersNear ? state.monstersNear.length : null, - quests: state.quests, hp: state.playerHp, mana: state.playerMana, - }) - // Fight for a few seconds: walk and swing repeatedly. - key('keydown', 'KeyD') - for (let i = 0; i < 12; i++) await tap('Space', 220) - key('keyup', 'KeyD') - key('keydown', 'KeyS') - for (let i = 0; i < 12; i++) await tap('Space', 220) - key('keyup', 'KeyS') - await sleep(600) - const before = snap() - key('keydown', 'KeyK'); await sleep(200); key('keyup', 'KeyK') - await sleep(400) - const savedAt = snap() - key('keydown', 'KeyD'); await sleep(2000); key('keyup', 'KeyD') - await sleep(600) - const drifted = snap() - key('keydown', 'KeyL'); await sleep(200); key('keyup', 'KeyL') - await sleep(800) - const after = snap() - const gl = document.querySelector('#view').getContext('webgl2') - return { - before, savedAt, drifted, after, - saves: state.saves, loads: state.loads, saveError: state.saveError, - tickRate: state.tickRate, glError: gl ? gl.getError() : null, error: state.error ?? null, - source: state.source ?? null, itemTablesSource: state.itemTablesSource ?? null, - } -})() diff --git a/scripts/browser/inspect-page.mjs b/scripts/browser/inspect-page.mjs index 3206002..1754b19 100644 --- a/scripts/browser/inspect-page.mjs +++ b/scripts/browser/inspect-page.mjs @@ -22,6 +22,11 @@ const chrome = spawn(chromeBin, [ // Software WebGL2: headless has no GPU, and SwiftShader is the only way to // exercise the renderer path in CI-like runs. '--use-angle=swiftshader', '--use-gl=angle', '--enable-unsafe-swiftshader', + // Never route a localhost check through a proxy. gLinux sets the GNOME proxy + // mode to `auto`, and Chrome honours it — PAC and all — even for `127.0.0.1`. + // This is a precaution, not a diagnosed fix: it did not by itself revive the + // harness in a sandbox where Chrome had no network at all. See the README. + '--no-proxy-server', `--user-data-dir=${userDataDir}`, '--remote-debugging-port=0', targetUrl, @@ -39,16 +44,35 @@ for (let i = 0; i < 100; i++) { } if (!port) { console.error('NO PORT', stderr.slice(-500)); process.exit(1) } +/** + * The page target actually showing `targetUrl`. + * + * Attaching to "the first target of type `page`" looks right and is wrong. + * Chrome creates a transient startup tab *before* it opens the URL from the + * command line, so a driver that polls every 200 ms wins that race more often + * than not, attaches to the blank tab, and then evaluates against a document + * that has no scripts and no state. The symptom is indistinguishable from a + * page that failed to boot — the check just reports "no state", and you go + * looking for the bug in the application. + * + * So: wait for a page whose URL is the one asked for. Only if that never + * appears do we fall back, and then we say so on stderr. + */ let page -for (let i = 0; i < 50; i++) { +for (let i = 0; i < 150; i++) { await sleep(200) try { const list = await (await fetch(`http://127.0.0.1:${port}/json`)).json() - page = list.find((x) => x.type === 'page') - if (page) break + const pages = list.filter((x) => x.type === 'page') + const matched = pages.find((x) => x.url === targetUrl) + if (matched) { page = matched; break } + // Keep the best non-blank candidate around in case the URL is normalised + // (a trailing slash, a fragment) and never compares equal. + page = pages.find((x) => x.url !== 'about:blank' && x.url !== '') ?? page } catch {} } if (!page) { console.error('NO PAGE', stderr.slice(-500)); process.exit(1) } +if (page.url !== targetUrl) console.error(`WARNING attached to ${page.url}, wanted ${targetUrl}`) const ws = new WebSocket(page.webSocketDebuggerUrl) await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject }) diff --git a/src/game/engine.ts b/src/game/engine.ts index 92e039d..39a2aec 100644 --- a/src/game/engine.ts +++ b/src/game/engine.ts @@ -25,6 +25,30 @@ export interface WorldMapProvider { overlap(x: number, y: number): number } +/** + * Where snapshots are kept. + * + * The engine owns *when* a save happens — on the rising edge of + * {@link EngineInput.saving}, at a tick boundary, after the simulation has + * settled — but not *where* it goes. Browsers have `localStorage`, tests have a + * `Map`, and a future server would have a file; none of that may leak into the + * simulation, which has to stay reproducible from a seed and an input log. + */ +export interface SnapshotStore { + /** + * Read the stored snapshot. + * + * @returns the serialized snapshot, or null when nothing has been saved. + */ + read(): string | null + /** + * Overwrite the stored snapshot. + * + * @param data - a serialized snapshot. + */ + write(data: string): void +} + export interface GameEngineOptions { spawn: { x: number; y: number } stats: readonly MonsterStats[] @@ -41,6 +65,39 @@ export interface GameEngineOptions { pickupRadius: number inventoryCols: number inventoryRows: number + /** + * How many monsters to spawn at construction. + * + * Defaults to 8, which is the sandbox number this engine grew up with. Real + * levels derive it from `Levels.txt` (`NumMon` for how many *kinds*, `MonDen` + * for density), so callers that have a level row pass their own. + */ + monsterCount?: number + /** + * Spawn spread around {@link GameEngineOptions.spawn}, in scene pixels. + * + * Defaults to 260. Real levels scatter across the whole map rather than a + * disc around the entrance, so this is a sandbox affordance, not a rule. + */ + monsterSpread?: number + /** + * Whether to place NPCs on a synthetic ring around the spawn. + * + * Defaults to true, for fixtures that have NPC definitions but no positions. + * Scenes that know where their NPCs actually stand — a DS1 carries their + * coordinates — pass false and fill {@link GameEngine.npcEntities} themselves, + * so the ring is never built and then thrown away. + */ + npcRingFallback?: boolean + /** + * Backing store for {@link EngineInput.saving} and + * {@link EngineInput.loading}. + * + * Omit it and both inputs are inert — which is what every fixture wants, and + * what {@link GameEngine.saveSnapshot} is for when a caller drives saving + * itself. + */ + snapshotStore?: SnapshotStore } export interface GameEngineMetrics { @@ -71,6 +128,17 @@ export class GameEngine { selectedSkill = 0 npcEntities: NpcEntity[] = [] dialog: string[] = [] + /** + * Whether the save input was held on the previous tick. + * + * Save and load fire on a rising edge. The input is a key state sampled every + * tick, so without this a key held for half a second would save twelve times + * — and a held load key would re-apply the snapshot every tick, pinning the + * player in place. + */ + private savingHeld = false + /** Whether the load input was held on the previous tick. */ + private loadingHeld = false metrics: GameEngineMetrics = { casts: 0, castRefusals: 0, castHits: 0, dropsRolled: 0, pickups: 0, @@ -80,22 +148,26 @@ export class GameEngine { constructor(public terrain: WorldMapProvider, public opts: GameEngineOptions) { this.world = createWorld(opts.spawn.x, opts.spawn.y) - spawnMonsters(this.world, opts.stats, 8, opts.spawn, 260, this.terrain) + spawnMonsters( + this.world, opts.stats, opts.monsterCount ?? 8, opts.spawn, opts.monsterSpread ?? 260, this.terrain, + ) this.loot = new Rng(opts.lootSeed ?? 0x5eed) this.bag = new Inventory(opts.inventoryCols, opts.inventoryRows) this.questLog = new QuestLog(opts.questDefs) this.castRng = new Rng((opts.lootSeed ?? 0x5eed) ^ 0x51c1) - for (let attempt = 0; attempt < 40 && this.npcEntities.length < this.opts.npcDefs.length; attempt += 1) { - const angle = attempt * 1.7 + 0.6 - const x = opts.spawn.x + Math.cos(angle) * 56 - const y = opts.spawn.y + Math.sin(angle) * 56 - if (this.terrain.overlap(x, y) > 0) continue - const def = this.opts.npcDefs[this.npcEntities.length] - if (def === undefined) break - // These are the synthetic ring placements used when a scene carries no - // prebaked NPC art, so they never have a sprite of their own to draw. - this.npcEntities.push({ def, x, y, hasSprite: false }) + if (opts.npcRingFallback ?? true) { + for (let attempt = 0; attempt < 40 && this.npcEntities.length < this.opts.npcDefs.length; attempt += 1) { + const angle = attempt * 1.7 + 0.6 + const x = opts.spawn.x + Math.cos(angle) * 56 + const y = opts.spawn.y + Math.sin(angle) * 56 + if (this.terrain.overlap(x, y) > 0) continue + const def = this.opts.npcDefs[this.npcEntities.length] + if (def === undefined) break + // These are the synthetic ring placements used when a scene carries no + // prebaked NPC art, so they never have a sprite of their own to draw. + this.npcEntities.push({ def, x, y, hasSprite: false }) + } } } @@ -214,6 +286,50 @@ export class GameEngine { } } } + + this.handleSaveLoad(input) + } + + /** + * Apply the save and load inputs, once per press. + * + * Runs last so a snapshot always describes a settled tick: movement, combat, + * drops and pickups have all been applied, which is what a player who pressed + * save this frame expects to get back. Loading last likewise means the + * restored state is what the *next* tick starts from, rather than being + * partially overwritten by the rest of this one. + * + * @param input - this tick's input. + */ + private handleSaveLoad(input: EngineInput): void { + const store = this.opts.snapshotStore + if (store !== undefined) { + if (input.saving && !this.savingHeld) { + try { + store.write(this.saveSnapshot()) + this.metrics.saves += 1 + this.metrics.saveError = null + } catch (error) { + // A full quota or a private-browsing window must not kill the loop. + this.metrics.saveError = error instanceof Error ? error.message : String(error) + } + } + if (input.loading && !this.loadingHeld) { + try { + const data = store.read() + if (data !== null) { + this.loadSnapshot(data) + this.metrics.loads += 1 + this.metrics.saveError = null + } + } catch (error) { + // A snapshot from an older schema is a refusal, not a crash. + this.metrics.saveError = error instanceof Error ? error.message : String(error) + } + } + } + this.savingHeld = input.saving + this.loadingHeld = input.loading } saveSnapshot(): string { diff --git a/src/game/iso-terrain.ts b/src/game/iso-terrain.ts new file mode 100644 index 0000000..a401de4 --- /dev/null +++ b/src/game/iso-terrain.ts @@ -0,0 +1,141 @@ +/** + * The isometric collision grid as a *counting* terrain predicate. + * + * `combat.ts` does not ask "is this point blocked?" — it asks "how much of the + * body is inside something?", and then only allows a move that does not make + * that number worse: + * + * ```ts + * if (dx !== 0 && terrain.overlap(x + dx, y) <= current) x += dx + * ``` + * + * The difference matters the moment a body starts *inside* geometry, which a + * spawn point picked from a cell centre can do: a wall's art hangs above its + * cell, so a cell that looks free can still have blocked sub-tiles under the + * feet. With a count, a body five sub-tiles deep may step to a position three + * deep and keep descending until it is out — the predicate is a gradient. With + * a boolean the same body reads `1` everywhere inside, `1 <= 1` holds for every + * direction, and it random-walks until it happens to leave. It escapes either + * way, which is why the boolean version looks fine in a demo, but only the + * count escapes *reliably* and in a *direction*. + * + * Why this cannot reuse `map.ts`'s `blockedOverlap`: that one walks a + * rectangular range of grid indices, which is correct only when a sub-tile is a + * screen-space rectangle. Here it is a diamond — 16 wide by 8 tall — so a + * screen-space box maps to a sheared, staggered set of grid cells with no + * rectangular index range to iterate. Sampling a lattice over the box and + * asking {@link isBlockedAt} per sample sidesteps the inversion entirely and + * stays correct under the 2:1 projection. + */ +import { ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH, isBlockedAt } from './d2map.ts' +import type { CollisionGrid } from './d2map.ts' + +/** + * Sample spacing as a fraction of one sub-tile. + * + * Half a sub-tile: fine enough that a body cannot straddle a blocked diamond + * without any sample landing on it, coarse enough that a 20×10 feet box costs + * 4×4 = 16 lookups rather than hundreds. The lattice is fixed rather than + * adaptive so that the count is a pure function of position — the simulation + * has to stay reproducible for saves and for lockstep. + */ +const SAMPLE_FRACTION = 0.5 + +/** + * How many samples span one axis of the body box. + * + * @param extent - the box's size on that axis, in screen pixels. + * @param subTile - the sub-tile's size on that axis, in screen pixels. + * @returns at least two samples, so both edges are always tested. + */ +function sampleCount(extent: number, subTile: number): number { + return Math.max(2, Math.ceil(extent / (subTile * SAMPLE_FRACTION)) + 1) +} + +/** + * How many sample points of a body box land on blocked sub-tiles. + * + * Out-of-bounds counts as blocked, matching {@link isBlockedAt}, so walking off + * the edge of a level is refused by the same rule that refuses walls instead of + * needing a separate bounds check at every call site. + * + * @param grid - the level's collision grid. + * @param x - body centre, scene-space x. + * @param y - body centre, scene-space y. + * @param width - body width in screen pixels. + * @param height - body height in screen pixels. + * @returns the blocked sample count, 0 when the body is entirely clear. + */ +export function isoOverlap( + grid: CollisionGrid, + x: number, + y: number, + width: number, + height: number, +): number { + const cols = sampleCount(width, ORTHO_SUB_TILE_WIDTH) + const rows = sampleCount(height, ORTHO_SUB_TILE_HEIGHT) + let blocked = 0 + for (let row = 0; row < rows; row += 1) { + // Spread samples across the full extent inclusive of both edges, so a body + // touching a wall with its edge is already counted as overlapping by one + // rather than only once its centre crosses. + const sampleY = y - height / 2 + (height * row) / (rows - 1) + for (let col = 0; col < cols; col += 1) { + const sampleX = x - width / 2 + (width * col) / (cols - 1) + if (isBlockedAt(grid, sampleX, sampleY)) blocked += 1 + } + } + return blocked +} + +/** A body's footprint, in screen pixels. */ +export interface BodySize { + /** Width of the collision box at the feet. */ + readonly width: number + /** Height of the collision box at the feet. */ + readonly height: number +} + +/** + * The shape `GameEngine` and `combat.ts` consume. + * + * Declared here rather than imported so this module stays a leaf: the engine + * depends on the terrain, not the other way round. + */ +export interface CountingTerrain { + /** Level width in screen pixels. */ + readonly widthPx: number + /** Level height in screen pixels. */ + readonly heightPx: number + /** + * Blocked sample count for a body centred here. + * + * @param x - scene-space x. + * @param y - scene-space y. + * @returns 0 when clear, larger the deeper the body is inside geometry. + */ + overlap(x: number, y: number): number +} + +/** + * Wrap an isometric collision grid as a counting terrain. + * + * @param grid - the level's collision grid. + * @param widthPx - level width in screen pixels. + * @param heightPx - level height in screen pixels. + * @param body - the moving body's footprint. + * @returns a terrain the combat simulation can consume directly. + */ +export function createIsoTerrain( + grid: CollisionGrid, + widthPx: number, + heightPx: number, + body: BodySize, +): CountingTerrain { + return { + widthPx, + heightPx, + overlap: (x: number, y: number): number => isoOverlap(grid, x, y, body.width, body.height), + } +} diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index f88e112..dd6fe42 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -27,9 +27,11 @@ import { decodePl2 } from '../formats/pl2.ts' import type { Palette } from '../formats/pal.ts' import type { SpriteSheet } from '../formats/sprite.ts' import { - ORTHO_CELL_HEIGHT, ORTHO_CELL_WIDTH, buildIsoMapScene, findIsoSpawn, isBlockedAt, levelSeed, + ORTHO_CELL_HEIGHT, ORTHO_CELL_WIDTH, buildIsoMapScene, findIsoSpawn, levelSeed, } from '../game/d2map.ts' import type { CollisionGrid } from '../game/d2map.ts' +import { createIsoTerrain } from '../game/iso-terrain.ts' +import type { MonsterStats } from '../game/combat.ts' import { depthInsertIndex } from '../game/map.ts' import { GameEngine, syncEngineState } from "../game/engine.ts" import type { NpcEntity } from "../game/engine.ts" @@ -66,6 +68,23 @@ const PAGE_CONCURRENCY = 3 /** Placeholder marker size, in scene pixels. */ const MARKER_WIDTH = 16 const MARKER_HEIGHT = 26 +/** + * Monsters placed per level, until `Levels.txt` supplies the real number. + * + * `MonDen` (density) and `NumMon` (how many kinds) are both real columns that + * #22 will read; 12 is only large enough that a walk across a level runs into + * something, which is what M6 needs to demonstrate that the level → spawn path + * works at all. + */ +const PLACEHOLDER_MONSTER_COUNT = 12 +/** + * `localStorage` key holding the single save slot. + * + * Deliberately the same key the retired `map.html` sandbox used, so a save made + * there still loads here — the snapshot format never differed, only the scene + * that drove it. + */ +const SAVE_KEY = 'd2web.save' /** * How often the HUD text is rewritten, in milliseconds. * @@ -219,6 +238,21 @@ interface MapRuntime { readonly objectDrawables: readonly ObjectDrawable[] readonly spawn: { x: number; y: number } readonly npcs: readonly PackObject[] + /** + * Monster definitions this level may spawn. + * + * Placeholder until #22 reads `Levels.txt`'s `mon1..10` pools and the real + * `MonStats.txt` rows. It is wired now so the path from level to spawned + * monster exists and can be tested; swapping the source is then a one-line + * change rather than a new integration. + */ + readonly monsterStats: readonly MonsterStats[] + /** + * How many monsters to place. + * + * Placeholder until #22 derives it from `MonDen` / `NumMon`. + */ + readonly monsterCount: number readonly objects: number readonly frames: number /** Whether the real character art is in use. */ @@ -454,6 +488,8 @@ async function loadPackRuntime( objectDrawables, spawn, npcs: scene.npcs ?? [], + monsterStats: DEMO_MONSTERS, + monsterCount: PLACEHOLDER_MONSTER_COUNT, objects: scene.objects.length, frames: scene.frames.length, notes: [`首屏页 ${priority.join(',')},共 ${String(pages.length)} 页`], @@ -613,6 +649,8 @@ async function loadLiveRuntime( objectDrawables: [], spawn: findIsoSpawn(scene) ?? { x: scene.widthPx / 2, y: scene.heightPx / 2 }, npcs: [], + monsterStats: DEMO_MONSTERS, + monsterCount: PLACEHOLDER_MONSTER_COUNT, objects: level.objects.length, frames: scene.frames.length, notes, @@ -775,14 +813,13 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number let playerWalkFrame = 0 const npcDefs = runtime.npcs.map(n => buildNpcDef(n.token ?? '', n.id, n.name ?? '')) const engine = new GameEngine( - { - widthPx: runtime.widthPx, - heightPx: runtime.heightPx, - overlap: (x, y) => (collides(runtime.grid, x, y) ? 1 : 0), - }, + createIsoTerrain(runtime.grid, runtime.widthPx, runtime.heightPx, { + width: FEET_WIDTH, + height: FEET_HEIGHT, + }), { spawn: runtime.spawn, - stats: [], + stats: runtime.monsterStats, xpTable: DEMO_EXPERIENCE, itemBases: DEMO_BASES, prefixAffixes: DEMO_AFFIXES, @@ -803,10 +840,22 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number pickupRadius: 64, inventoryCols: 10, inventoryRows: 4, + monsterCount: runtime.monsterCount, + monsterSpread: Math.max(runtime.widthPx, runtime.heightPx) / 4, + // This scene knows where its NPCs stand — the DS1 carries their + // coordinates — so the engine must not build a ring we would discard. + npcRingFallback: false, + // The engine decides when to save; the browser only says where. Reads are + // guarded because private-browsing windows throw on access, not on use. + snapshotStore: { + read: () => localStorage.getItem(SAVE_KEY), + write: (data: string) => { localStorage.setItem(SAVE_KEY, data) }, + }, } ) - engine.npcEntities.length = 0 + // `npcRingFallback: false` above means the engine left this empty, so we are + // filling it rather than replacing a synthetic ring. for (const n of runtime.npcs) { const def = npcDefs.find(d => d.id === `npc-${String(n.id)}`)! // Same predicate the sprite layer gated on above, so the box and the art @@ -1109,21 +1158,6 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number }) } -/** - * Whether a feet box at a point overlaps blocked sub-tiles. - * - * @param grid - the collision grid. - * @param x - feet centre x. - * @param y - feet centre y. - * @returns true when blocked. - */ -function collides(grid: CollisionGrid, x: number, y: number): boolean { - if (isBlockedAt(grid, x, y)) return true - for (const [dx, dy] of [[-1, -1], [1, -1], [-1, 1], [1, 1], [0, -1], [0, 1], [-1, 0], [1, 0]] as const) { - if (isBlockedAt(grid, x + (dx * FEET_WIDTH) / 2, y + (dy * FEET_HEIGHT) / 2)) return true - } - return false -} /** * The cell containing a scene point. diff --git a/src/scene/map-scene.ts b/src/scene/map-scene.ts deleted file mode 100644 index caff6a7..0000000 --- a/src/scene/map-scene.ts +++ /dev/null @@ -1,1054 +0,0 @@ -/** - * Map scene: renders a decoded DS1 map with its DT1 tile library, with - * collision derived from the tiles' own 5×5 sub-tile flags. - * - * Two ways in, one scene: - * - * - **an archive** (drag & drop or `?mpq=`): the path a user actually takes. - * Members are picked by extension, with a picker so a real archive — which - * holds hundreds of levels and tile libraries — can be navigated. - * - **a fixture directory** (`?data=`): three files over HTTP, used by the - * verification harness where no archive exists. - * - * Placeholder: the actor is a solid rectangle. A real character arrives as - * DCC/DC6 from the archive. - */ -import { MpqArchive } from '../mpq/archive.ts' -import { blobSource } from '../mpq/source.ts' -import { decodeDt1 } from '../formats/dt1.ts' -import { decodeDs1 } from '../formats/ds1.ts' -import { decodeDc6 } from '../formats/dc6.ts' -import { decodeSpriteFile } from '../formats/cel.ts' -import { PLAYER_SPRITE_WIDTH } from '../formats/sprite.ts' -import type { SpriteSheet } from '../formats/sprite.ts' -import { ActorAnimator } from '../game/animation.ts' -import { createWorld, experienceTable, monsterStatsFromTable, rebindPlayer, spawnMonsters, tickCombat } from '../game/combat.ts' -import type { CombatOptions, CombatWorld, Monster, MonsterStats } from '../game/combat.ts' -import { parseTable } from '../game/tables.ts' -import { - Inventory, affixesFromTable, goldItem, itemBasesFromTable, rollDrop, totalStat, -} from '../game/items.ts' -import { GameEngine, syncEngineState } from "../game/engine.ts" -import type { NpcEntity } from "../game/engine.ts" -import { DEMO_MONSTERS, DEMO_EXPERIENCE, DEMO_BASES, DEMO_AFFIXES, DEMO_SKILLS, DEMO_NPCS, DEMO_QUESTS } from "../game/demo-data.ts" -import type { Affix, Item, ItemBase } from '../game/items.ts' -import { Rng } from '../game/rng.ts' -import { castSkill, skillsFromTable, tickProjectiles } from '../game/skills.ts' -import type { Projectile, SkillDef } from '../game/skills.ts' -import { QuestLog, npcDialog, npcsFromTable, questsFromTable } from '../game/quests.ts' -import type { NpcDef } from '../game/quests.ts' -import { damageMonster } from '../game/combat.ts' -import { decodeTbl } from '../formats/tbl.ts' -import { captureSnapshot, parseSnapshot, restoreSnapshot, serializeSnapshot } from '../game/save.ts' -import { textSourceOf } from '../game/tables.ts' -import { decodePal } from '../formats/pal.ts' -import { decodePl2 } from '../formats/pl2.ts' -import { buildMapScene, blockedOverlap, depthInsertIndex, findFreeSpawn } from '../game/map.ts' -import type { MapScene } from '../game/map.ts' -import type { Palette } from '../formats/pal.ts' -import { buildAtlas } from '../render/atlas.ts' -import type { AtlasFrame } from '../render/atlas.ts' -import { SpriteRenderer } from '../render/renderer.ts' -import { GameLoop } from '../sim/loop.ts' -import { KeyboardInput, directionOf } from '../sim/input.ts' - -/** Collision box at the character's feet. */ -const FEET_WIDTH = 20 -const FEET_HEIGHT = 14 -/** Walk speed in pixels per second. */ -const WALK_SPEED = 180 -/** Player marker size in pixels (placeholder art). */ -const MARKER_WIDTH = 18 -const MARKER_HEIGHT = 26 -/** - * Fallback combat tables, used when an archive ships none. - * - * Deliberately small and obviously generic: the point is that the sandbox runs - * and says where its numbers came from, not that these are balanced. - */ - -/** Per-monster tints, so several monsters on one sheet read as several units. */ -const MONSTER_TINTS: readonly (readonly [number, number, number, number])[] = [ - [1, 0.78, 0.72, 1], [0.78, 1, 0.8, 1], [0.82, 0.86, 1, 1], [1, 0.92, 0.7, 1], -] -/** Colour of the player's life and mana orbs. */ -const LIFE_COLOR: readonly [number, number, number, number] = [0.72, 0.15, 0.15, 1] -const MANA_COLOR: readonly [number, number, number, number] = [0.2, 0.35, 0.8, 1] -const ORB_BACKDROP: readonly [number, number, number, number] = [0.08, 0.07, 0.06, 1] - -/** Inventory size in cells, and the size of one cell on screen. */ -const INVENTORY_COLS = 10 -const INVENTORY_ROWS = 4 -const CELL_PX = 26 -/** How close the player must be to pick something up, in world pixels. */ -const PICKUP_RADIUS = 64 -/** Key a save is stored under in the browser. */ -const SAVE_KEY = 'd2web.save' -/** Seed for the drop stream, so a session's loot is reproducible. */ -const LOOT_SEED = 0x5eed - - -/** How close the player must be to talk, in world pixels. */ -const TALK_RADIUS = 80 -/** How many DT1 libraries to load at once (maps reference more than one). */ -const MAX_LIBRARIES = 4 - -/** Scene state, published for automated checks. */ -export interface MapSceneState { - /** True once a map is loaded and the loop is running. */ - ready: boolean - /** Reason the scene failed to start. */ - error: string | null - /** Where the data came from: `mpq` or `fixture`. */ - source: string - /** Archive label, when one was opened. */ - archive: string | null - /** Names of the members the map was built from. */ - members: string[] - /** Ticks simulated. */ - tick: number - /** Measured ticks per second. */ - tickRate: number - /** Map size in cells. */ - cellsX: number - cellsY: number - /** Map size in pixels. */ - widthPx: number - heightPx: number - /** Draw counts. */ - floorDraws: number - wallDraws: number - /** Map references that resolved to no tile. */ - missingTiles: number - /** Blocked sub-tiles. */ - blockedSubTiles: number - /** Collision grid size in sub-tiles. */ - gridWidth: number - gridHeight: number - /** Player position in world pixels. */ - x: number - y: number - /** Whether the last tick's movement was refused. */ - blocked: boolean - /** Tiles in the atlas. */ - atlasTiles: number - /** Where the actor's art came from, or `marker` when none was found. */ - actorSource: string - /** Actor clip currently playing. */ - actorClip: string | null - /** Actor facing (0 = south, turning west). */ - actorDirection: number - /** Actor animation frame index. */ - actorFrame: number - /** How many wall draws precede the actor (its depth insertion point). */ - actorDrawOrder: number - /** Where the combat tables came from: `archive` or `builtin`. */ - tablesSource: string - /** Monsters spawned. */ - monstersSpawned: number - /** Monsters still alive. */ - monstersAlive: number - /** Monsters killed so far. */ - kills: number - /** Player health. */ - playerHp: number - /** Player maximum health. */ - playerMaxHp: number - /** Player mana. */ - playerMana: number - /** Player maximum mana. */ - playerMaxMana: number - /** Player level. */ - playerLevel: number - /** Player experience. */ - playerXp: number - /** Ticks in which the player dealt damage, for automated checks. */ - playerHits: number - /** Screen-space orb rectangles, for automated checks. */ - orbs: { name: string; x: number; y: number; width: number; height: number; fraction: number }[] - /** Where the item tables came from: `archive` or `builtin`. */ - itemTablesSource: string - /** Items lying on the ground. */ - groundItems: number - /** Items carried. */ - inventoryItems: number - /** Inventory cells occupied / total. */ - cellsUsed: number - cellsTotal: number - /** Gold carried. */ - gold: number - /** Total defence from carried armor. */ - defense: number - /** Drops rolled, pickups taken, and refusals because the bag was full. */ - dropsRolled: number - pickups: number - inventoryRefusals: number - /** Names of the most recent drops, newest last. */ - lastDrops: string[] - /** Whether names came from a `.tbl` or were literals in the tables. */ - textSource: string - /** Skill names, in table order. */ - skills: string[] - /** Index of the selected skill, or -1 for the plain melee attack. */ - selectedSkill: number - /** Skills cast. */ - casts: number - /** Casts refused for mana or cooldown. */ - castRefusals: number - /** Projectiles that hit a monster. */ - castHits: number - /** Projectiles in flight. */ - projectilesAlive: number - /** NPCs placed. */ - npcs: number - /** Placed NPCs with their distance to the player, for automation. */ - npcsNear: NpcEntity[] - /** Living monsters with their distance to the player, for automation. */ - monstersNear: { x: number; y: number; distance: number }[] - /** Current dialogue lines, empty when nobody is talking. */ - dialog: string[] - /** Quest statuses by id. */ - quests: { id: string; status: string; kills: number; needed: number }[] - /** Experience and gold handed out by quests. */ - questXp: number - questGold: number - /** Ticks the *simulation* has run (unlike `tick`, which is the render loop's). */ - worldTick: number - /** Saves written and loads applied this session. */ - saves: number - loads: number - /** Reason the last save or load failed, if any. */ - saveError: string | null -} - -declare global { - interface Window { - /** Map scene state for automation and debugging. */ - __d2webMap?: MapSceneState - } -} - -const state: MapSceneState = { - ready: false, error: null, source: 'none', archive: null, members: [], - tick: 0, tickRate: 0, cellsX: 0, cellsY: 0, widthPx: 0, heightPx: 0, - floorDraws: 0, wallDraws: 0, missingTiles: 0, blockedSubTiles: 0, - gridWidth: 0, gridHeight: 0, x: 0, y: 0, blocked: false, atlasTiles: 0, - actorSource: 'marker', actorClip: null, actorDirection: 0, actorFrame: 0, actorDrawOrder: 0, - tablesSource: 'builtin', monstersSpawned: 0, monstersAlive: 0, kills: 0, - playerHp: 0, playerMaxHp: 0, playerMana: 0, playerMaxMana: 0, playerLevel: 1, playerXp: 0, playerHits: 0, - orbs: [], - itemTablesSource: 'builtin', groundItems: 0, inventoryItems: 0, - cellsUsed: 0, cellsTotal: INVENTORY_COLS * INVENTORY_ROWS, gold: 0, defense: 0, - dropsRolled: 0, pickups: 0, inventoryRefusals: 0, lastDrops: [], - textSource: 'literal', skills: [], selectedSkill: 0, casts: 0, castRefusals: 0, castHits: 0, - projectilesAlive: 0, npcs: 0, npcsNear: [], monstersNear: [], dialog: [], quests: [], questXp: 0, questGold: 0, - saves: 0, loads: 0, saveError: null, worldTick: 0, -} -window.__d2webMap = state - -/** Raw bytes a map needs, however they were obtained. */ -interface MapSources { - readonly ds1: Uint8Array - readonly libraries: readonly Uint8Array[] - readonly palette: Uint8Array - /** Member name of the palette, so `.pl2` and `.pal` can be told apart. */ - readonly paletteName: string - /** Actor art, when the source has any: direction-major sprite groups. */ - readonly actor: { readonly name: string; readonly sheet: SpriteSheet } | null - /** Combat tables, when the archive has them at the usual member path. */ - readonly tables: { readonly monstats: Uint8Array | null; readonly experience: Uint8Array | null } - /** Skill, NPC and quest tables plus the string table, when present. */ - readonly m4Tables: { - readonly skills: Uint8Array | null - readonly npcs: Uint8Array | null - readonly quests: Uint8Array | null - readonly strings: Uint8Array | null - } - /** Item tables, at the same `data/global/excel` path. */ - readonly itemTables: { - readonly weapons: Uint8Array | null - readonly armor: Uint8Array | null - readonly misc: Uint8Array | null - readonly prefixes: Uint8Array | null - readonly suffixes: Uint8Array | null - } -} - -/** - * Decode whichever palette member the archive provides. - * - * Classic Diablo II ships `.pl2`: the base palette plus the transform tables that - * lighting, blending and unit colour shifts are drawn through. `.pal` (a bare - * 768-byte palette) appears in Diablo I archives and in tooling, so both are - * accepted and the base palette is what the renderer needs either way. - * - * @param name - the member name, for extension detection. - * @param bytes - the member's contents. - * @returns the palette. - */ -function decodePaletteMember(name: string, bytes: Uint8Array): Palette { - if (!name.toLowerCase().endsWith('.pl2')) return decodePal(bytes) - const pl2 = decodePl2(bytes) - // The renderer resolves indices against a plain palette; the transform tables - // stay available for lighting and unit colour shifts. - return { rgb: pl2.rgb, size: 256 } -} - -/** The running loop, input, and renderer, so a reload can retire the previous scene. */ -let running: GameLoop | null = null -let input: KeyboardInput | null = null -let activeRenderer: SpriteRenderer | null = null - -/** Fetch a URL as bytes. */ -async function fetchBytes(url: string): Promise