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 @@ - - - - - - d2web · DS1/DT1 地图场景(M1) - - - -
-

d2web · DS1 + DT1 地图

- 资源检查器 - 暗黑1 走动演示 - — -
-
拖入 .mpq 打开你的归档;或用 ?mpq=<url> / ?data=<fixture 目录>
- - -

正在加载地图…

- - - - - diff --git a/net.html b/net.html index c5de5e8..df5157f 100644 --- a/net.html +++ b/net.html @@ -28,7 +28,7 @@

d2web · 双人联机合作

资源检查器 - 单人地图场景 + 真实战役场景 未联机

正在加载地图…

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 { - const response = await fetch(url) - if (!response.ok) throw new Error(`${url}: HTTP ${String(response.status)}`) - return new Uint8Array(await response.arrayBuffer()) -} - -/** Lowercased extension of a member name. */ -function extensionOf(name: string): string { - const cut = name.lastIndexOf('.') - return cut === -1 ? '' : name.slice(cut + 1).toLowerCase() -} - -/** - * Build the scene from decoded bytes and start the loop. - * - * @param sources - the raw members. - * @param status - element to report progress in. - * @param hud - element to show the running state in. - */ -function startScene(sources: MapSources, status: HTMLElement, hud: HTMLElement): void { - const canvas = document.querySelector('#view')! - const level = decodeDs1(sources.ds1) - const libraries = sources.libraries.map(library => decodeDt1(library)) - const palette = decodePaletteMember(sources.paletteName, sources.palette) - // A map may reference several libraries; they are merged so tile lookups see - // one namespace, which is how the game composes them too. - const first = libraries[0]! - const merged = libraries.length === 1 ? first : { - versionMajor: first.versionMajor, - versionMinor: first.versionMinor, - tiles: libraries.flatMap(library => library.tiles), - // Libraries merged for a fixture map carry no structural warnings. - warnings: [], - } - const scene: MapScene = buildMapScene(level, merged) - const tileWidth = Math.max(...merged.tiles.map(tile => tile.width), 1) - const tileHeight = Math.max(...merged.tiles.map(tile => Math.abs(tile.height)), 1) - - running?.stop() - input?.detach() - activeRenderer?.dispose() - const renderer = new SpriteRenderer(canvas, { - onContextLost: () => { - status.textContent = 'WebGL 上下文丢失,正在尝试恢复…' - }, - onContextRestored: () => { - status.textContent = 'WebGL 上下文已恢复,正在重新加载…' - location.reload() - }, - }) - activeRenderer = renderer - // The actor's frames ride in the *same* atlas as the tiles: one texture means - // the actor can be drawn at its depth among the walls without a second bind. - const combined: SpriteSheet = sources.actor === null - ? scene.sheet - : { groups: [...scene.sheet.groups, ...sources.actor.sheet.groups], width: null } - const atlas = buildAtlas(combined, palette) - renderer.setAtlas(atlas) - const frames = atlas.groups[0] ?? [] - const actorGroups = sources.actor === null ? null : atlas.groups.slice(1) - const animator = new ActorAnimator() - if (actorGroups !== null && actorGroups.length > 0) { - // Direction-major sheets: every direction plays its whole frame list while - // walking, and holds its first frame while standing — which is the shape - // Diablo II's own per-direction DC6 sheets have. - animator.add({ name: 'walk', directions: actorGroups, ticksPerFrame: 2, loop: true }) - animator.add({ name: 'stand', directions: actorGroups.map(group => group.slice(0, 1)), ticksPerFrame: 8, loop: false }) - animator.play('stand', 0) - } - state.actorSource = sources.actor?.name ?? 'marker' - - const spawn = findFreeSpawn(scene, tileWidth, tileHeight, FEET_WIDTH, FEET_HEIGHT) - input = new KeyboardInput() - input.attach() - - // Combat: the simulation owns movement (including the escape rule), and each - // monster's numbers are its own table row. - const stats = sources.tables.monstats === null - ? DEMO_MONSTERS - : monsterStatsFromTable(parseTable(new TextDecoder().decode(sources.tables.monstats))) - const xpTable: readonly number[] = sources.tables.experience === null - ? DEMO_EXPERIENCE - : experienceTable(parseTable(new TextDecoder().decode(sources.tables.experience))) - state.tablesSource = sources.tables.monstats === null ? 'builtin' : 'archive' - const terrain = { widthPx: scene.widthPx, heightPx: scene.heightPx, - overlap: (x: number, y: number): number => - blockedOverlap(scene, tileWidth, tileHeight, x, y, FEET_WIDTH, FEET_HEIGHT), - } - const options: CombatOptions = { - playerSpeed: WALK_SPEED, - playerReach: 52, - playerCooldownTicks: 12, - playerDamage: 7, - playerManaPerAttack: 2, - respawnTicks: 75, - } - let world: CombatWorld = createWorld(spawn.x, spawn.y) - state.monstersSpawned = spawnMonsters(world, stats, 8, spawn, 260, terrain) - // Rebound after a load: the world object is replaced wholesale, so anything - // holding a piece of the old one would keep simulating the previous game. - let player = world.player - - // Items: bases and affixes from the archive's tables, one seeded loot stream, - // and an inventory that is a real grid rather than a list. - const text = new TextDecoder() - const fromTable = (bytes: Uint8Array | null, kind: 'weapon' | 'armor' | 'misc'): ItemBase[] => - bytes === null ? [] : itemBasesFromTable(parseTable(text.decode(bytes)), kind) - const tableBases = [ - ...fromTable(sources.itemTables.weapons, 'weapon'), - ...fromTable(sources.itemTables.armor, 'armor'), - ...fromTable(sources.itemTables.misc, 'misc'), - ] - const itemBases = tableBases.length === 0 ? [...DEMO_BASES] : tableBases - const prefixAffixes = sources.itemTables.prefixes === null - ? DEMO_AFFIXES.filter(affix => affix.kind === 'prefix') - : affixesFromTable(parseTable(text.decode(sources.itemTables.prefixes)), 'prefix') - const suffixAffixes = sources.itemTables.suffixes === null - ? DEMO_AFFIXES.filter(affix => affix.kind === 'suffix') - : affixesFromTable(parseTable(text.decode(sources.itemTables.suffixes)), 'suffix') - state.itemTablesSource = tableBases.length === 0 ? 'builtin' : 'archive' - // Mutable on purpose: loading a game replaces the world, the random stream and - // the bag wholesale, and the loop reads them fresh every tick. - - // Skills, names, NPCs and quests. Numeric cells in the tables resolve through - - // the string table when the archive has one, and stay literal when it does not. - - const textSource = textSourceOf(sources.m4Tables.strings === null ? null : decodeTbl(sources.m4Tables.strings)) - - state.textSource = sources.m4Tables.strings === null ? "literal" : "tbl" - - const skills: readonly SkillDef[] = sources.m4Tables.skills === null - - ? DEMO_SKILLS - - : skillsFromTable(parseTable(text.decode(sources.m4Tables.skills)), textSource) - - const npcDefs: readonly NpcDef[] = sources.m4Tables.npcs === null - - ? DEMO_NPCS - - : npcsFromTable(parseTable(text.decode(sources.m4Tables.npcs)), textSource) - - const questDefs = sources.m4Tables.quests === null - - ? [...DEMO_QUESTS] - - : questsFromTable(parseTable(text.decode(sources.m4Tables.quests)), textSource) - - - const engine = new GameEngine(terrain, { - spawn, - stats, - xpTable, - itemBases, - prefixAffixes, - suffixAffixes, - skills, - npcDefs, - questDefs, - combatOptions: options, - lootSeed: LOOT_SEED, - talkRadius: TALK_RADIUS, - pickupRadius: PICKUP_RADIUS, - inventoryCols: INVENTORY_COLS, - inventoryRows: INVENTORY_ROWS, - }) - state.monstersSpawned = engine.world.monsters.length - state.npcs = engine.npcEntities.length - state.skills = skills.map(skill => skill.name) - - let blockedThisTick = false - const loop = new GameLoop({ - tickRate: 25, - onTick: () => { - const movement = input?.movement() ?? { x: 0, y: 0 } - const beforeX = engine.world.player.x - const beforeY = engine.world.player.y - - engine.tick({ - movement, - attacking: input?.attacking ?? false, - pickingUp: input?.pickingUp ?? false, - talking: input?.talking ?? false, - digits: input?.digits ?? [], - saving: false, - loading: false - }) - - if (input?.saving ?? false) { - try { - localStorage.setItem(SAVE_KEY, engine.saveSnapshot()) - engine.metrics.saves += 1 - engine.metrics.saveError = null - } catch(error) { - engine.metrics.saveError = error instanceof Error ? error.message : String(error) - } - } - if (input?.loading ?? false) { - try { - const text = localStorage.getItem(SAVE_KEY) - if (text === null) throw new Error('no save in this browser') - engine.loadSnapshot(text) - engine.metrics.loads += 1 - engine.metrics.saveError = null - } catch(error) { - engine.metrics.saveError = error instanceof Error ? error.message : String(error) - } - } - - const moved = engine.world.player.x !== beforeX || engine.world.player.y !== beforeY - animator.play(moved ? 'walk' : 'stand', engine.world.player.facing) - animator.tick(engine.world.player.facing) - state.actorClip = animator.clipName - state.actorDirection = animator.facing - state.actorFrame = animator.frameIndex - state.blocked = (movement.x !== 0 || movement.y !== 0) && !moved - - syncEngineState(engine, state) - // Custom states not in engine: - state.tick = loop.tick - state.monstersNear = engine.world.monsters - .filter(monster => monster.state !== 'dead') - .map(monster => ({ x: monster.x, y: monster.y, distance: Math.hypot(monster.x - engine.world.player.x, monster.y - engine.world.player.y) })) - .sort((a, b) => a.distance - b.distance) - .slice(0, 4) - - state.quests = engine.questLog.all.map(entry => ({ - id: entry.def.id, status: entry.status, kills: entry.kills, needed: entry.def.killCount, - })) - state.dialog = engine.dialog - }, - onRender: () => { - const camera = { x: engine.world.player.x, y: engine.world.player.y, zoom: 2 } - renderer.begin(camera, [0.04, 0.03, 0.03]) - for (const draw of scene.floors) { - const frame = frames[draw.tileIndex] - if (frame !== undefined) renderer.draw(frame, draw.x, draw.y) - } - - // Actors and walls share one painter's order: walls far to near, with each - // actor spliced in where its depth says it belongs. - const cellOf = (value: number, size: number): number => Math.floor(value / size) - interface ActorDraw { - readonly x: number - readonly y: number - readonly cellX: number - readonly cellY: number - readonly frame: AtlasFrame | null - readonly tint: readonly [number, number, number, number] - readonly bar: { readonly hp: number; readonly maxHp: number } | null - /** A engine.ground item, drawn as a small tile until its art exists. */ - readonly ground?: Item - } - const actors: ActorDraw[] = [] - const playerFrame = animator.frame() - if (playerFrame !== undefined) { - actors.push({ - x: engine.world.player.x, y: engine.world.player.y, - cellX: cellOf(engine.world.player.x, tileWidth), cellY: cellOf(engine.world.player.y, tileHeight), - frame: playerFrame, - tint: engine.world.player.alive ? [1, 1, 1, 1] : [0.55, 0.5, 0.5, 1], - bar: null, - }) - } - for (const monster of engine.world.monsters) { - const group = actorGroups?.[monster.facing] - if (group === undefined || group.length === 0) continue - const moving = monster.state === 'chase' || monster.state === 'attack' - const frame = group[moving ? (world.tick + monster.index * 3) % group.length : 0] - if (frame === undefined) continue - actors.push({ - x: monster.x, y: monster.y, - cellX: cellOf(monster.x, tileWidth), cellY: cellOf(monster.y, tileHeight), - frame, - tint: monster.state === 'dead' - ? [0.42, 0.36, 0.34, 1] - : monster.hitFlash > 0 ? [1, 0.55, 0.55, 1] : MONSTER_TINTS[monster.index % MONSTER_TINTS.length]!, - bar: monster.state === 'dead' ? null : { hp: monster.hp, maxHp: monster.stats.hp }, - }) - } - // NPCs and engine.projectiles share the same painter's order as everything else. - for (const npc of engine.npcEntities) { - const group = actorGroups?.[2] - const frame = group?.[0] ?? null - actors.push({ - x: npc.x, y: npc.y, - cellX: cellOf(npc.x, tileWidth), cellY: cellOf(npc.y, tileHeight), - frame, - tint: [1, 0.85, 0.55, 1], - bar: null, - }) - } - for (const shot of engine.projectiles) { - actors.push({ - x: shot.x, y: shot.y, - cellX: cellOf(shot.x, tileWidth), cellY: cellOf(shot.y, tileHeight), - frame: null, - tint: [1, 0.75, 0.25, 1], - bar: null, - }) - } - // Ground items take their place in the same depth order as everything else. - for (const entry of engine.ground) { - actors.push({ - x: entry.x, y: entry.y, - cellX: cellOf(entry.x, tileWidth), cellY: cellOf(entry.y, tileHeight), - frame: null, - tint: entry.item.base.kind === 'weapon' - ? [0.85, 0.87, 0.92, 1] - : entry.item.base.kind === 'armor' ? [0.8, 0.66, 0.45, 1] : [0.6, 0.85, 0.55, 1], - bar: null, - ground: entry.item, - }) - } - actors.sort((a, b) => (a.cellY + a.cellX) - (b.cellY + b.cellX) || a.y - b.y) - - let wallIndex = 0 - const drawNextWall = (): void => { - const draw = scene.walls[wallIndex] - wallIndex += 1 - if (draw === undefined) return - const frame = frames[draw.tileIndex] - if (frame !== undefined) renderer.draw(frame, draw.x, draw.y) - } - state.actorDrawOrder = 0 - for (const actor of actors) { - const insertAt = depthInsertIndex(scene.walls, actor.cellX, actor.cellY) - while (wallIndex < insertAt) drawNextWall() - if (actor.frame === null) { - const size = actor.ground === undefined ? 6 : 10 + Math.min(actor.ground.stack, 5) * 2 - renderer.drawSolid(actor.x - size / 2, actor.y - size, size, size, actor.tint) - } else { - renderer.draw(actor.frame, actor.x - actor.frame.width / 2, actor.y - actor.frame.height, { tint: actor.tint }) - } - if (actor.bar !== null && actor.frame !== null) { - // A health bar over the sprite; the fill is the fraction of the - // monster's own table maximum. - const width = actor.frame.width - const top = actor.y - actor.frame.height - 10 - const fraction = Math.max(0, Math.min(1, actor.bar.hp / Math.max(actor.bar.maxHp, 1))) - renderer.drawSolid(actor.x - width / 2, top, width, 5, [0.1, 0.08, 0.08, 1]) - renderer.drawSolid(actor.x - width / 2, top, width * fraction, 5, [0.75, 0.2, 0.18, 1]) - } - } - while (wallIndex < scene.walls.length) drawNextWall() - - // Life and mana orbs, anchored to the screen rather than the world. - const toWorld = (sx: number, sy: number): { x: number; y: number } => ({ - x: camera.x + (sx - canvas.width / 2) / camera.zoom, - y: camera.y + (sy - canvas.height / 2) / camera.zoom, - }) - const orb = (screenX: number, screenY: number, width: number, height: number, fraction: number, color: readonly [number, number, number, number]): void => { - const at = toWorld(screenX, screenY) - const size = { w: width / camera.zoom, h: height / camera.zoom } - renderer.drawSolid(at.x, at.y, size.w, size.h, ORB_BACKDROP) - const filled = size.h * Math.max(0, Math.min(1, fraction)) - renderer.drawSolid(at.x + 1 / camera.zoom, at.y + size.h - filled, size.w - 2 / camera.zoom, filled, color) - } - state.orbs = [ - { name: 'life', x: 24, y: canvas.height - 84, width: 44, height: 60, fraction: engine.world.player.maxHp === 0 ? 0 : engine.world.player.hp / engine.world.player.maxHp }, - { name: 'mana', x: canvas.width - 68, y: canvas.height - 84, width: 44, height: 60, fraction: engine.world.player.maxMana === 0 ? 0 : engine.world.player.mana / engine.world.player.maxMana }, - ] - for (const entry of state.orbs) { - orb(entry.x, entry.y, entry.width, entry.height, entry.fraction, - entry.name === 'life' ? LIFE_COLOR : MANA_COLOR) - } - // Inventory overlay, anchored to the screen: a real grid, because items - // occupy cells and a list would hide the constraint that matters. - const bagAt = toWorld(canvas.width - INVENTORY_COLS * CELL_PX - 20, 20) - const cellWorld = CELL_PX / camera.zoom - renderer.drawSolid( - bagAt.x - 4 / camera.zoom, bagAt.y - 4 / camera.zoom, - INVENTORY_COLS * cellWorld + 8 / camera.zoom, INVENTORY_ROWS * cellWorld + 8 / camera.zoom, - [0.06, 0.05, 0.05, 1], - ) - for (let row = 0; row < INVENTORY_ROWS; row += 1) { - for (let column = 0; column < INVENTORY_COLS; column += 1) { - renderer.drawSolid( - bagAt.x + column * cellWorld + 0.5 / camera.zoom, - bagAt.y + row * cellWorld + 0.5 / camera.zoom, - cellWorld - 1 / camera.zoom, cellWorld - 1 / camera.zoom, - [0.16, 0.14, 0.12, 1], - ) - } - } - for (const placed of engine.bag.contents) { - renderer.drawSolid( - bagAt.x + placed.x * cellWorld + 1 / camera.zoom, - bagAt.y + placed.y * cellWorld + 1 / camera.zoom, - placed.item.invWidth * cellWorld - 2 / camera.zoom, - placed.item.invHeight * cellWorld - 2 / camera.zoom, - placed.item.base.kind === 'weapon' - ? [0.7, 0.74, 0.82, 1] - : placed.item.base.kind === 'armor' ? [0.7, 0.56, 0.36, 1] : [0.5, 0.72, 0.48, 1], - ) - } - renderer.flush() - const dialogPanel = document.querySelector('#dialog') - if (dialogPanel !== null) { - dialogPanel.hidden = state.dialog.length === 0 - dialogPanel.textContent = state.dialog.join('\n') - } - state.tickRate = loop.tickRate - hud.textContent = `tick ${String(state.tick)} · ${loop.tickRate.toFixed(1)} tps` - + ` · ${state.source} · tables ${state.tablesSource}` - + ` · hp ${String(Math.max(0, Math.round(engine.world.player.hp)))}/${String(engine.world.player.maxHp)}` - + ` · mana ${String(Math.round(engine.world.player.mana))}/${String(engine.world.player.maxMana)}` - + ` · lvl ${String(engine.world.player.level)} (${String(engine.world.player.xp)} xp)` - + ` · monsters ${String(state.monstersAlive)}/${String(state.monstersSpawned)} alive · kills ${String(engine.world.kills)}` - + ` · items ${state.itemTablesSource} · bag ${String(state.cellsUsed)}/${String(state.cellsTotal)}` - + ` · gold ${String(state.gold)} · engine.ground ${String(state.groundItems)} · pickups ${String(state.pickups)}` - + (state.inventoryRefusals > 0 ? ` · bag full x${String(state.inventoryRefusals)}` : '') - + ` · skill ${String(engine.selectedSkill)}/${String(engine.opts.skills.length - 1)} ${engine.opts.skills[engine.selectedSkill]?.name ?? '-'}` - + ` · casts ${String(state.casts)} (hits ${String(state.castHits)}, refused ${String(state.castRefusals)})` - + ` · quests ${state.quests.map(entry => `${entry.id}:${entry.status}(${String(entry.kills)}/${String(entry.needed)})`).join(' ') || '-'}` - + ` · saves ${String(state.saves)}/loads ${String(state.loads)}` - + (state.saveError === null ? '' : ` · save error: ${state.saveError}`) - + (state.dialog.length > 0 ? ' · 对话中' : '') - + (engine.world.player.alive ? '' : ' · 死亡中,稍后重生') - }, - }) - - state.cellsX = level.width - state.cellsY = level.height - state.widthPx = scene.widthPx - state.heightPx = scene.heightPx - state.floorDraws = scene.floors.length - state.wallDraws = scene.walls.length - state.missingTiles = scene.missingTiles - state.gridWidth = scene.gridWidth - state.gridHeight = scene.gridHeight - state.atlasTiles = frames.length - let blocked = 0 - for (const value of scene.blocked) if (value === 1) blocked += 1 - state.blockedSubTiles = blocked - state.error = null - loop.start() - running = loop - state.ready = true - status.textContent = `地图已加载(${state.source}):${String(level.width)}×${String(level.height)} 格,` - + `地面 ${String(scene.floors.length)} / 墙体 ${String(scene.walls.length)} 次绘制,` - + `碰撞网格 ${String(scene.gridWidth)}×${String(scene.gridHeight)},` - + `瓦片库 ${String(libraries.length)} 个,未解析引用 ${String(scene.missingTiles)}。` - + `WASD 移动(黄色方块=角色占位)。` -} - -/** - * Find and decode actor art in an archive. - * - * Both games animate units from direction-major sheets, so one loader covers - * them: Diablo II's per-direction DC6 (which carries each frame's own size) is - * preferred, and Diablo I's CL2 is accepted as a fallback for archives that only - * have it. Names are ranked rather than required — `actor`, then anything that - * looks like a unit, then the first sheet in the archive — because real data - * names art by convention, not by contract. - * - * @param archive - the open archive. - * @returns the actor's name and sheet, or null when the archive has no unit art. - */ -async function loadActorArt(archive: MpqArchive): Promise<{ name: string; sheet: SpriteSheet } | null> { - const names = await archive.listFiles() - const rank = (name: string): number => { - const lower = name.toLowerCase() - if (lower.includes('actor')) return 0 - if (lower.includes('monster') || lower.includes('player') || lower.includes('char')) return 1 - return 2 - } - const dc6Names = names.filter(name => name.toLowerCase().endsWith('.dc6')).sort((a, b) => rank(a) - rank(b) || a.localeCompare(b)) - const chosen = dc6Names[0] - if (chosen !== undefined) { - return { name: chosen, sheet: decodeDc6(await archive.readByName(chosen)) } - } - const cl2Name = names.find(name => /plrgfx.*\.cl2$/i.test(name)) - if (cl2Name !== undefined) { - // Diablo I sheets do not store their width; the game's own table does. - const sheet = decodeSpriteFile(await archive.readByName(cl2Name), 'cl2', { width: PLAYER_SPRITE_WIDTH.walk }) - return { name: cl2Name, sheet } - } - return null -} - -/** - * Read the combat tables an archive provides, if any. - * - * Diablo II keeps them at `data\\global\\excel\\.txt`; a missing table is not - * an error — the sandbox falls back to a built-in demo table and says so, which - * keeps the scene runnable against archives that have no tables at all. - * - * @param archive - the open archive. - * @returns the table bytes, or null per table. - */ -async function loadTables(archive: MpqArchive): Promise<{ - monstats: Uint8Array | null - experience: Uint8Array | null -}> { - const names = await archive.listFiles() - const find = (suffix: string): string | undefined => - names.find(name => name.toLowerCase().endsWith(suffix)) - const read = async (suffix: string): Promise => { - const name = find(suffix) - return name === undefined ? null : archive.readByName(name) - } - return { - monstats: await read('monstats.txt'), - experience: await read('experience.txt'), - } -} - -/** - * Read the item tables an archive provides. - * - * Same member path and same "missing is fine" rule as the combat tables. - * - * @param archive - the open archive. - * @returns the table bytes, null per table that is absent. - */ -async function loadItemTables(archive: MpqArchive): Promise<{ - weapons: Uint8Array | null - armor: Uint8Array | null - misc: Uint8Array | null - prefixes: Uint8Array | null - suffixes: Uint8Array | null -}> { - const names = await archive.listFiles() - const read = async (suffix: string): Promise => { - const name = names.find(candidate => candidate.toLowerCase().endsWith(suffix)) - return name === undefined ? null : archive.readByName(name) - } - return { - weapons: await read('weapons.txt'), - armor: await read('armor.txt'), - misc: await read('misc.txt'), - prefixes: await read('magicprefix.txt'), - suffixes: await read('magicsuffix.txt'), - } -} - -/** - * Read the skill, NPC and quest tables and the string table. - * - * @param archive - the open archive. - * @returns the bytes, null per member that is absent. - */ -async function loadM4Tables(archive: MpqArchive): Promise<{ - skills: Uint8Array | null - npcs: Uint8Array | null - quests: Uint8Array | null - strings: Uint8Array | null -}> { - const names = await archive.listFiles() - const read = async (suffix: string): Promise => { - const name = names.find(candidate => candidate.toLowerCase().endsWith(suffix)) - return name === undefined ? null : archive.readByName(name) - } - return { - skills: await read('skills.txt'), - npcs: await read('npcs.txt'), - quests: await read('quests.txt'), - strings: await read('string.tbl'), - } -} - -/** - * Read a map's members out of an open archive. - * - * A real archive holds many levels and tile libraries, so members are chosen by - * extension and, when there is a choice, by name: town/act names first, then the - * first member found. The picker in the page overrides either choice. - * - * @param archive - the open archive. - * @param choices - explicit member names, when the picker supplied them. - * @returns the raw bytes. - */ -async function loadFromArchive( - archive: MpqArchive, - choices: { ds1?: string | undefined; dt1?: string | undefined; palette?: string | undefined } = {}, -): Promise { - const names = await archive.listFiles() - const rank = (name: string, preferred: readonly string[]): number => { - const lower = name.toLowerCase() - const index = preferred.findIndex(token => lower.includes(token)) - return index === -1 ? preferred.length : index - } - const candidates = (extension: string, preferred: readonly string[]): string[] => - names.filter(name => extensionOf(name) === extension) - .sort((a, b) => rank(a, preferred) - rank(b, preferred) || a.localeCompare(b)) - - const ds1Name = choices.ds1 ?? candidates('ds1', ['town', 'act1', 'rogue'])[0] - if (ds1Name === undefined) throw new Error('archive has no .ds1 member to load') - const dt1Names = choices.dt1 !== undefined - ? [choices.dt1] - : candidates('dt1', ['town', 'act1']).slice(0, MAX_LIBRARIES) - if (dt1Names.length === 0) throw new Error('archive has no .dt1 member to load') - // Real Diablo II archives carry `.pl2`; `.pal` is accepted for Diablo I - // archives and tooling. - const paletteName = choices.palette - ?? [...candidates('pl2', ['act1', 'pal']), ...candidates('pal', ['town', 'act1'])][0] - if (paletteName === undefined) throw new Error('archive has no .pl2 or .pal member to load') - - state.members = [ds1Name, ...dt1Names, paletteName] - return { - ds1: await archive.readByName(ds1Name), - libraries: await Promise.all(dt1Names.map(async name => archive.readByName(name))), - palette: await archive.readByName(paletteName), - paletteName, - actor: await loadActorArt(archive), - tables: await loadTables(archive), - itemTables: await loadItemTables(archive), - m4Tables: await loadM4Tables(archive), - } -} - -/** - * Read a map's members from a fixture directory over HTTP. - * - * @param dir - the directory. - * @returns the raw bytes. - */ -async function loadFromFixtureDir(dir: string): Promise { - const [ds1, dt1, palette, actor] = await Promise.all([ - fetchBytes(`${dir}/fixture.ds1`), - fetchBytes(`${dir}/fixture.dt1`), - fetchBytes(`${dir}/palette.pal`), - fetchBytes(`${dir}/actor.dc6`).catch(() => null), - ]) - state.members = [`${dir}/fixture.ds1`, `${dir}/fixture.dt1`, `${dir}/palette.pal`] - return { - ds1, libraries: [dt1], palette, paletteName: 'palette.pal', - actor: actor === null ? null : { name: `${dir}/actor.dc6`, sheet: decodeDc6(actor) }, - tables: { - monstats: await fetchBytes(`${dir}/data/global/excel/monstats.txt`).catch(() => null), - experience: await fetchBytes(`${dir}/data/global/excel/experience.txt`).catch(() => null), - }, - m4Tables: { - skills: await fetchBytes(`${dir}/data/global/excel/skills.txt`).catch(() => null), - npcs: await fetchBytes(`${dir}/data/global/excel/npcs.txt`).catch(() => null), - quests: await fetchBytes(`${dir}/data/global/excel/quests.txt`).catch(() => null), - strings: await fetchBytes(`${dir}/data/local/string.tbl`).catch(() => null), - }, - itemTables: { - weapons: await fetchBytes(`${dir}/data/global/excel/weapons.txt`).catch(() => null), - armor: await fetchBytes(`${dir}/data/global/excel/armor.txt`).catch(() => null), - misc: await fetchBytes(`${dir}/data/global/excel/misc.txt`).catch(() => null), - prefixes: await fetchBytes(`${dir}/data/global/excel/magicprefix.txt`).catch(() => null), - suffixes: await fetchBytes(`${dir}/data/global/excel/magicsuffix.txt`).catch(() => null), - }, - } -} - -/** Wire the page: archive drop zone, member picker, and the two URL modes. */ -function boot(): void { - const status = document.querySelector('#status')! - const hud = document.querySelector('#hud')! - const drop = document.querySelector('#drop')! - const picker = document.querySelector('#picker')! - const controls = document.querySelector('#controls')! - const ds1Select = document.querySelector('#ds1')! - const dt1Select = document.querySelector('#dt1')! - const palSelect = document.querySelector('#pal')! - const reload = document.querySelector('#reload')! - - let archive: MpqArchive | null = null - - const fill = (select: HTMLSelectElement, options: readonly string[]): void => { - select.replaceChildren(...options.map(name => { - const option = document.createElement('option') - option.value = name - option.textContent = name - return option - })) - } - - const reloadFromArchive = async (): Promise => { - if (archive === null) return - status.textContent = '正在从归档构建地图…' - startScene(await loadFromArchive(archive, { - ds1: ds1Select.value === '' ? undefined : ds1Select.value, - dt1: dt1Select.value === '' ? undefined : dt1Select.value, - palette: palSelect.value === '' ? undefined : palSelect.value, - }), status, hud) - } - - const openMpq = async (file: File | Blob, label: string): Promise => { - try { - status.textContent = `正在打开 ${label} …` - state.archive = label - archive = await MpqArchive.open(blobSource(file, label)) - const names = await archive.listFiles() - const byExtension = (extension: string): string[] => names.filter(name => extensionOf(name) === extension) - fill(ds1Select, byExtension('ds1')) - fill(dt1Select, byExtension('dt1')) - fill(palSelect, [...byExtension('pl2'), ...byExtension('pal')]) - controls.hidden = false - state.source = 'mpq' - await reloadFromArchive() - } catch (error) { - state.error = error instanceof Error ? error.message : String(error) - status.textContent = `打开失败:${state.error}` - } - } - - drop.onclick = () => { picker.click() } - picker.onchange = () => { const file = picker.files?.[0]; if (file !== undefined) void openMpq(file, file.name) } - drop.addEventListener('dragover', (event) => { event.preventDefault() }) - drop.addEventListener('drop', (event) => { - event.preventDefault() - const file = event.dataTransfer?.files?.[0] - if (file !== undefined) void openMpq(file, file.name) - }) - reload.onclick = () => { void reloadFromArchive() } - - const params = new URLSearchParams(location.search) - const mpq = params.get('mpq') - const dir = params.get('data') - if (mpq !== null) { - void (async () => { - try { - status.textContent = `正在通过 HTTP 读取 ${mpq} …` - const response = await fetch(mpq) - if (!response.ok) throw new Error(`HTTP ${String(response.status)}`) - await openMpq(await response.blob(), mpq.split('/').pop() ?? 'archive.mpq') - } catch (error) { - state.error = error instanceof Error ? error.message : String(error) - status.textContent = `加载失败:${state.error}` - } - })() - return - } - if (dir !== null) { - state.source = 'fixture' - void (async () => { - try { - startScene(await loadFromFixtureDir(dir), status, hud) - } catch (error) { - state.error = error instanceof Error ? error.message : String(error) - status.textContent = `加载失败:${state.error}` - } - })() - return - } - status.textContent = '拖入 .mpq,或用 ?mpq= / ?data= 指定数据源。' -} - -window.addEventListener('beforeunload', () => { - running?.stop() - input?.detach() - activeRenderer?.dispose() - activeRenderer = null -}) - -boot() diff --git a/tests/engine-save-load.test.ts b/tests/engine-save-load.test.ts new file mode 100644 index 0000000..acf8fb3 --- /dev/null +++ b/tests/engine-save-load.test.ts @@ -0,0 +1,230 @@ +/** + * The save/load loop, as a pure simulation. + * + * This replaces `scripts/browser/checks/map-save-load.js`, which drove the + * retired `map.html` sandbox with synthetic key events and compared four + * snapshots of `window.__d2webMap`. That check was slow, needed a headless + * browser and a WebGL context, and — because the sandbox did its `localStorage` + * I/O *outside* `engine.tick` — it never actually exercised the + * `EngineInput.saving` / `EngineInput.loading` path the real scene uses. + * + * The assertions here are the same ones: fight for a while, save, keep playing + * so the state drifts, load, and require that position, kills, gold, ground + * items and level all come back to the save point. Driving the engine directly + * makes them deterministic and instant. + */ +import { describe, expect, it } from 'vitest' +import { GameEngine } from '../src/game/engine.ts' +import type { EngineInput, GameEngineOptions, SnapshotStore, WorldMapProvider } from '../src/game/engine.ts' + +/** Open ground. Collision has its own tests; this one is about persistence. */ +const openTerrain: WorldMapProvider = { widthPx: 2000, heightPx: 2000, overlap: () => 0 } + +/** + * A snapshot store backed by a variable, standing in for `localStorage`. + * + * @returns the store plus a peek at what it holds. + */ +function memoryStore(): SnapshotStore & { readonly slot: () => string | null } { + let saved: string | null = null + return { + read: () => saved, + write: (data: string) => { saved = data }, + slot: () => saved, + } +} + +/** + * Engine options with enough monsters and loot to make the save interesting. + * + * @param store - where snapshots go. + * @returns the options. + */ +function optionsWith(store: SnapshotStore | undefined): GameEngineOptions { + return { + spawn: { x: 1000, y: 1000 }, + stats: [ + { id: 'mon1', name: 'Goblin', hp: 12, damage: 1, cooldownTicks: 10, reach: 20, aggroRadius: 400, speed: 60, xp: 40 }, + ], + xpTable: [0, 0, 10, 30, 60, 100], + itemBases: [ + { id: 'sword', name: 'Sword', kind: 'weapon', invWidth: 1, invHeight: 3, maxStack: 1, value: 10, damage: 5, defense: 0, tags: ['weap'], level: 1 }, + ], + prefixAffixes: [], + suffixAffixes: [], + skills: [ + { id: 'attack', name: 'Attack', manaCost: 0, cooldownTicks: 1, range: 48, projectile: false, speed: 0, baseMinDamage: 3, baseMaxDamage: 4, damagePerLevel: 1, radius: 20 }, + ], + npcDefs: [], + questDefs: [], + combatOptions: { + playerSpeed: 100, + playerReach: 60, + playerCooldownTicks: 4, + playerDamage: 9, + playerManaPerAttack: 0, + respawnTicks: 100, + }, + lootSeed: 0x5eed, + talkRadius: 50, + pickupRadius: 50, + inventoryCols: 10, + inventoryRows: 4, + monsterCount: 6, + monsterSpread: 120, + // `exactOptionalPropertyTypes` is on, so "no store" has to be an absent + // property, not a present `undefined` one. + ...(store === undefined ? {} : { snapshotStore: store }), + } +} + +/** Neutral input, so each test only has to name the field it cares about. */ +const idle: EngineInput = { + movement: { x: 0, y: 0 }, + attacking: false, + pickingUp: false, + talking: false, + digits: [], + saving: false, + loading: false, +} + +/** + * The state the browser check used to compare across four sample points. + * + * @param engine - the engine to read. + * @returns a plain, comparable object. + */ +function snapshotOf(engine: GameEngine): Record { + return { + tick: engine.world.tick, + x: Math.round(engine.world.player.x), + y: Math.round(engine.world.player.y), + level: engine.world.player.level, + xp: engine.world.player.xp, + kills: engine.world.kills, + gold: engine.bag.gold, + ground: engine.ground.length, + hp: engine.world.player.hp, + } +} + +/** + * Walk and swing for a while, the way the browser check tapped D and Space. + * + * @param engine - the engine to drive. + * @param ticks - how many ticks to run. + */ +function fight(engine: GameEngine, ticks: number): void { + for (let i = 0; i < ticks; i += 1) { + engine.tick({ ...idle, movement: { x: i % 40 < 20 ? 1 : 0, y: i % 40 < 20 ? 0 : 1 }, attacking: true, pickingUp: i % 7 === 0 }) + } +} + +describe('save and load through the tick', () => { + it('restores the save point after the state has drifted', () => { + const store = memoryStore() + const engine = new GameEngine(openTerrain, optionsWith(store)) + + fight(engine, 120) + expect(engine.world.kills).toBeGreaterThan(0) + + engine.tick({ ...idle, saving: true }) + const savedAt = snapshotOf(engine) + expect(engine.metrics.saves).toBe(1) + expect(engine.metrics.saveError).toBeNull() + + fight(engine, 120) + const drifted = snapshotOf(engine) + expect(drifted).not.toEqual(savedAt) + + engine.tick({ ...idle, loading: true }) + expect(engine.metrics.loads).toBe(1) + expect(engine.metrics.saveError).toBeNull() + + // `handleSaveLoad` runs last, so the loading tick itself advanced the world + // by one before restoring — compare the fields the player cares about. + const after = snapshotOf(engine) + expect(after.x).toBe(savedAt.x) + expect(after.y).toBe(savedAt.y) + expect(after.kills).toBe(savedAt.kills) + expect(after.gold).toBe(savedAt.gold) + expect(after.ground).toBe(savedAt.ground) + expect(after.level).toBe(savedAt.level) + expect(after.xp).toBe(savedAt.xp) + expect(after.hp).toBe(savedAt.hp) + }) + + it('saves once per press, not once per tick', () => { + const store = memoryStore() + const engine = new GameEngine(openTerrain, optionsWith(store)) + + // A key held for half a second is 12 ticks at 25 Hz. + for (let i = 0; i < 12; i += 1) engine.tick({ ...idle, saving: true }) + expect(engine.metrics.saves).toBe(1) + + engine.tick(idle) + engine.tick({ ...idle, saving: true }) + expect(engine.metrics.saves).toBe(2) + }) + + it('loads once per press, so a held key does not pin the player', () => { + const store = memoryStore() + const engine = new GameEngine(openTerrain, optionsWith(store)) + + engine.tick({ ...idle, saving: true }) + const savedX = engine.world.player.x + + for (let i = 0; i < 12; i += 1) { + engine.tick({ ...idle, movement: { x: 1, y: 0 }, loading: true }) + } + + expect(engine.metrics.loads).toBe(1) + // The first tick restored; the other eleven were free to walk away. + expect(engine.world.player.x).toBeGreaterThan(savedX) + }) + + it('reports an empty slot as nothing to load rather than an error', () => { + const store = memoryStore() + const engine = new GameEngine(openTerrain, optionsWith(store)) + + engine.tick({ ...idle, loading: true }) + + expect(engine.metrics.loads).toBe(0) + expect(engine.metrics.saveError).toBeNull() + expect(store.slot()).toBeNull() + }) + + it('survives a store that throws, because a full quota must not kill the loop', () => { + const throwing: SnapshotStore = { + read: () => { throw new Error('access denied') }, + write: () => { throw new Error('quota exceeded') }, + } + const engine = new GameEngine(openTerrain, optionsWith(throwing)) + + engine.tick({ ...idle, saving: true }) + expect(engine.metrics.saves).toBe(0) + expect(engine.metrics.saveError).toBe('quota exceeded') + + engine.tick(idle) + engine.tick({ ...idle, loading: true }) + expect(engine.metrics.loads).toBe(0) + expect(engine.metrics.saveError).toBe('access denied') + + // Still simulating. + const before = engine.world.tick + engine.tick(idle) + expect(engine.world.tick).toBeGreaterThan(before) + }) + + it('leaves the inputs inert when no store is configured', () => { + const engine = new GameEngine(openTerrain, optionsWith(undefined)) + + engine.tick({ ...idle, saving: true }) + engine.tick({ ...idle, loading: true }) + + expect(engine.metrics.saves).toBe(0) + expect(engine.metrics.loads).toBe(0) + expect(engine.metrics.saveError).toBeNull() + }) +}) diff --git a/tests/iso-terrain.test.ts b/tests/iso-terrain.test.ts new file mode 100644 index 0000000..6fcc7ff --- /dev/null +++ b/tests/iso-terrain.test.ts @@ -0,0 +1,180 @@ +/** + * Isometric collision counting. + * + * The point of {@link isoOverlap} is that it returns a *count*, not a boolean. + * `combat.ts` escapes geometry with `if (terrain.overlap(next) <= current)`, so + * a boolean predicate reads `1` everywhere inside a wall, `1 <= 1` always holds, + * and a body that spawns inside geometry random-walks out. A count gives that + * rule a gradient to descend. These tests therefore assert the *shape* of the + * returned numbers, not just "blocked or not". + * + * The grid is synthetic rather than a decoded DS1 so the expected values can be + * reasoned about by hand; the DS1 path is already covered by the act tests. + */ +import { describe, expect, it } from 'vitest' +import { isoOverlap } from '../src/game/iso-terrain.ts' +import { createIsoTerrain } from '../src/game/iso-terrain.ts' +import { isBlockedAt } from '../src/game/d2map.ts' +import type { CollisionGrid } from '../src/game/d2map.ts' + +/** Sub-tiles per tile edge, matching `d2map.ts`. */ +const SUB = 5 +/** Grid size in cells. Large enough to hold a wall block and clear space. */ +const CELLS = 10 +/** + * Origin large enough that every cell of the grid maps to a positive scene x. + * + * `cx - cy` ranges over ±9, and a cell is 80 wide in x, so 900 clears it. + */ +const ORIGIN_X = 900 + +/** The player's feet box in `act-scene.ts`. Deliberately 2:1, like the tiles. */ +const FEET_WIDTH = 20 +const FEET_HEIGHT = 10 + +/** + * An empty collision grid. + * + * @returns a grid with nothing blocked. + */ +function makeGrid(): CollisionGrid { + return { + originX: ORIGIN_X, + originY: 0, + cellsX: CELLS, + cellsY: CELLS, + blocked: new Uint8Array(CELLS * SUB * CELLS * SUB), + gridWidth: CELLS * SUB, + } +} + +/** + * Mark every sub-tile of one cell blocked. + * + * @param grid - the grid to mutate. + * @param cellX - cell column. + * @param cellY - cell row. + */ +function blockCell(grid: CollisionGrid, cellX: number, cellY: number): void { + for (let sy = 0; sy < SUB; sy += 1) { + for (let sx = 0; sx < SUB; sx += 1) { + grid.blocked[(cellY * SUB + sy) * grid.gridWidth + cellX * SUB + sx] = 1 + } + } +} + +/** + * Fractional cell coordinates as a scene-space point. + * + * Forward of `cellAt`'s inversion: `x = (cx - cy) * 80`, `y = (cx + cy) * 40`. + * + * @param cellX - fractional cell column. + * @param cellY - fractional cell row. + * @returns the scene point. + */ +function pointAt(cellX: number, cellY: number): { x: number; y: number } { + return { x: (cellX - cellY) * 80 + ORIGIN_X, y: (cellX + cellY) * 40 } +} + +/** + * A grid with cells 2..5 in both axes blocked — a wall four cells thick. + * + * Thick enough that a body at its centre is fully enclosed, so the count + * saturates and the walk outwards has somewhere to descend from. + * + * @returns the grid. + */ +function gridWithWallBlock(): CollisionGrid { + const grid = makeGrid() + for (let cy = 2; cy <= 5; cy += 1) { + for (let cx = 2; cx <= 5; cx += 1) blockCell(grid, cx, cy) + } + return grid +} + +/** Samples taken for the feet box: 4 columns × 4 rows. */ +const FEET_SAMPLES = 16 + +describe('isoOverlap', () => { + it('counts nothing on clear ground', () => { + const grid = gridWithWallBlock() + const clear = pointAt(8.5, 8.5) + expect(isoOverlap(grid, clear.x, clear.y, FEET_WIDTH, FEET_HEIGHT)).toBe(0) + }) + + it('saturates when the whole body is inside geometry', () => { + const grid = gridWithWallBlock() + const deep = pointAt(4, 4) + expect(isoOverlap(grid, deep.x, deep.y, FEET_WIDTH, FEET_HEIGHT)).toBe(FEET_SAMPLES) + }) + + it('treats off-grid as fully blocked, so the level edge needs no special case', () => { + const grid = gridWithWallBlock() + expect(isoOverlap(grid, -99_999, -99_999, FEET_WIDTH, FEET_HEIGHT)).toBe(FEET_SAMPLES) + }) + + it('falls monotonically as the body leaves a wall, and passes through partial counts', () => { + const grid = gridWithWallBlock() + const deep = pointAt(4, 4) + const sweep: number[] = [] + for (let dx = 0; dx <= 400; dx += 20) { + sweep.push(isoOverlap(grid, deep.x + dx, deep.y, FEET_WIDTH, FEET_HEIGHT)) + } + + // Never worse as we walk away: this is exactly the invariant combat.ts's + // `overlap(next) <= current` rule needs in order to make progress. + for (let i = 1; i < sweep.length; i += 1) expect(sweep[i]).toBeLessThanOrEqual(sweep[i - 1]!) + + expect(sweep[0]).toBe(FEET_SAMPLES) + expect(sweep.at(-1)).toBe(0) + // A boolean predicate could only ever produce 0 and 1 here. Partial counts + // are the whole reason this module exists. + expect(sweep.some(count => count > 0 && count < FEET_SAMPLES)).toBe(true) + }) + + it('counts an overlap before the body centre enters the wall', () => { + const grid = gridWithWallBlock() + const deep = pointAt(4, 4) + // Found by sweeping: the feet box still clips the block here while its + // centre is already on clear ground. + const x = deep.x + 320 + expect(isBlockedAt(grid, x, deep.y)).toBe(false) + expect(isoOverlap(grid, x, deep.y, FEET_WIDTH, FEET_HEIGHT)).toBeGreaterThan(0) + }) + + it('samples y twice as densely as x, because sub-tiles are 16 by 8', () => { + const grid = gridWithWallBlock() + const deep = pointAt(4, 4) + + // A 2:1 box matches the projection, so it costs the same on both axes. + expect(isoOverlap(grid, deep.x, deep.y, 20, 10)).toBe(16) + // A square box is twice as tall in sub-tiles as it is wide, so it grows + // rows, not columns. A grid-index scan over a rectangle — what map.ts does + // for the orthogonal fixture — cannot express this. + expect(isoOverlap(grid, deep.x, deep.y, 20, 20)).toBe(24) + // And doubling the width grows columns by the same ratio. + expect(isoOverlap(grid, deep.x, deep.y, 40, 10)).toBe(24) + }) +}) + +describe('createIsoTerrain', () => { + it('exposes the level size and binds the body once', () => { + const grid = gridWithWallBlock() + const terrain = createIsoTerrain(grid, 1234, 567, { width: FEET_WIDTH, height: FEET_HEIGHT }) + expect(terrain.widthPx).toBe(1234) + expect(terrain.heightPx).toBe(567) + + const deep = pointAt(4, 4) + const clear = pointAt(8.5, 8.5) + expect(terrain.overlap(deep.x, deep.y)).toBe(FEET_SAMPLES) + expect(terrain.overlap(clear.x, clear.y)).toBe(0) + }) + + it('is a pure function of position, so saves and lockstep stay reproducible', () => { + const grid = gridWithWallBlock() + const terrain = createIsoTerrain(grid, 100, 100, { width: FEET_WIDTH, height: FEET_HEIGHT }) + const p = pointAt(5.9, 4.1) + const first = terrain.overlap(p.x, p.y) + for (let i = 0; i < 5; i += 1) expect(terrain.overlap(p.x, p.y)).toBe(first) + }) +}) diff --git a/vite.config.ts b/vite.config.ts index 4d4dd64..475404e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,9 +3,9 @@ import { defineConfig } from 'vite' /** * The engine is plain TypeScript with no runtime dependencies: Vite only serves * and bundles it. Four entries: the asset inspector (`index.html`), the walkable - * scene (`walk.html`), the campaign map scene (`map.html`) and the two-player - * co-op scene (`net.html`). `samples/` is served in dev so all of them can be - * smoke-tested over HTTP (`?sample=samples/spawn.mpq`, + * scene (`walk.html`), the two-player co-op scene (`net.html`) and the real + * campaign scene (`acts.html`). `samples/` is served in dev so all of them can + * be smoke-tested over HTTP (`?sample=samples/spawn.mpq`, * `?data=samples/fixtures`). */ export default defineConfig({ @@ -21,7 +21,6 @@ export default defineConfig({ input: { index: new URL('./index.html', import.meta.url).pathname, walk: new URL('./walk.html', import.meta.url).pathname, - map: new URL('./map.html', import.meta.url).pathname, net: new URL('./net.html', import.meta.url).pathname, acts: new URL('./acts.html', import.meta.url).pathname, },