M6:舍弃 map-scene.ts,收敛到 act-scene.ts 单轨
真地图上一直没有玩法,原因不是「两轨没合流」——act-scene.ts 早就通过 GameEngine 接入了战斗、物品、技能、任务、存档。真正的原因只有一行:act-scene.ts 给引擎传的是 `stats: []`,而 combat.ts 的 spawnMonsters 在 stats 为空时直接 return 0。 同时 map-scene.ts 被查出三个确凿缺陷,不值得保留: 1. 它建了一个从不被 tick 的孤儿 world,`let player = world.player` 之后再没用过 2. 怪物动画取帧读的是那个孤儿 world 的 tick,而 world.tick 只在 tickCombat 里自增, map-scene 从不直接调它,于是 tick 恒为 0,每只怪只显示一个固定帧——追击和攻击时 怪物是静止的 3. 存读档绕过引擎,在 tick 之外直接读写 localStorage 外加 12 个死 import。 本次改动: 碰撞语义从布尔改为计数(src/game/iso-terrain.ts) combat.ts 的脱困规则是 `if (overlap(next) <= current) 才走`。原来 act-scene 传的是 `collides(...) ? 1 : 0`,在墙里读到的永远是 1,`1 <= 1` 恒真,于是角色在墙里随机 游走。改成计数之后同一条规则变成了梯度下降:陷进去 5 格深可以走到 3 格深,朝 7 格 深的方向会被拒绝。 不能直接复用 map.ts 的 blockedOverlap:那个函数遍历一个矩形的格索引区间,只有在 sub-tile 是屏幕矩形时才成立;等距下 sub-tile 是 16×8 的菱形,屏幕矩形映射过去是 错切且交错的,没有矩形索引区间可遍历。所以这里改为在包围盒上采样一个固定点阵, 逐点问 isBlockedAt。点阵固定而非自适应,保证计数是位置的纯函数——存档与 lockstep 都依赖这一点。 引擎硬编码下沉为选项(src/game/engine.ts) monsterCount(原写死 8)、monsterSpread(原写死 260)、npcRingFallback(NPC 合成 圆环,act-scene 本来就用真实 DS1 坐标覆盖掉了,现在干脆不构造)。 补上一个死接口:EngineInput.saving / loading 这两个字段声明了但 tick() 从不读,也就是说 act-scene 的 K/L 其实什么也不做。 现在引擎新增 SnapshotStore 端口(read/write),在 tick 末尾按上升沿处理: 存档点永远是一个已结算的 tick;按住键不会每 tick 存一次;localStorage 仍留在场景 层,模拟层不碰宿主环境。配额满、隐私窗口、旧格式存档都记进 metrics.saveError。 删除 map-scene.ts 与 map.html map-save-load.js 这个无头检查改写为纯逻辑的 tests/engine-save-load.test.ts: 同样的断言(走位砍怪 → 存档 → 继续跑 → 读档,比对位置/击杀/金币/地面/等级), 但不需要 headless Chromium 和 WebGL 上下文,而且顺带覆盖了浏览器版从未走到的 in-tick 存读档路径。verify-m4 / verify-m5 只依赖 src/game 与 src/net,原样保留。 顺带修了 inspect-page.mjs 的一个目标选择缺陷:它按「第一个 type 为 page 的目标」 附着,而 Chrome 在打开命令行 URL 之前会先建一个空白标签页,轮询往往抢到空白页, 之后的断言就报「没有场景状态」——和页面启动失败完全无法区分。改为等待 URL 匹配的 目标。(注:当前沙盒里 headless Chrome 完全没有网络,浏览器实测仍待补,已在 scripts/browser/README.md 记录判定方法。) 验证: npx tsc --noEmit 0 错误 npm test 461 passed / 2 skipped(基线 442,新增 14,无回归) npm run verify:all 全绿 npm run build 4 个入口,1.89s Fixes #21 TAG=agy CONV=c89513df-cd12-4749-b5bd-5e9f16639391
This commit is contained in:
parent
b28ce37e8a
commit
ae94755401
|
|
@ -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`) |
|
||||
|
||||
|
|
|
|||
65
ROADMAP.md
65
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°,按「上」沿格子对角线向上走)。
|
||||
|
|
|
|||
54
map.html
54
map.html
|
|
@ -1,54 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>d2web · DS1/DT1 地图场景(M1)</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: #0d0b09; color: #e8dcc8;
|
||||
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
header { padding: 10px 16px; border-bottom: 1px solid #33291f;
|
||||
display: flex; gap: 14px; align-items: baseline; flex-wrap: wrap; }
|
||||
h1 { font-size: 14px; margin: 0; color: #c8a15a; letter-spacing: .06em; }
|
||||
a { color: #c8a15a; }
|
||||
#hud { color: #9c8d78; }
|
||||
#drop { margin: 10px 16px 6px; padding: 8px 12px; border: 1px dashed #6d5a34;
|
||||
border-radius: 6px; color: #9c8d78; cursor: pointer; }
|
||||
#drop.hot { border-color: #c8a15a; color: #c8a15a; }
|
||||
#controls { margin: 0 16px 8px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
#controls label { color: #9c8d78; }
|
||||
select { background: #1c1813; color: #e8dcc8; border: 1px solid #33291f;
|
||||
border-radius: 4px; padding: 3px 6px; max-width: 340px; font: inherit; }
|
||||
button { background: #241d16; color: #e8dcc8; border: 1px solid #33291f;
|
||||
border-radius: 4px; padding: 4px 10px; font: inherit; cursor: pointer; }
|
||||
#status { margin: 0 16px 10px; color: #9c8d78; }
|
||||
#dialog { margin: 0 16px 10px; padding: 10px 12px; max-width: 720px;
|
||||
background: #1c1813; border: 1px solid #6d5a34; border-radius: 6px;
|
||||
color: #e8dcc8; white-space: pre-wrap; }
|
||||
canvas { display: block; margin: 0 16px; background: #000; border: 1px solid #33291f;
|
||||
width: 960px; height: 560px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>d2web · DS1 + DT1 地图</h1>
|
||||
<a href="/">资源检查器</a>
|
||||
<a href="/walk.html?sample=samples/spawn.mpq">暗黑1 走动演示</a>
|
||||
<span id="hud">—</span>
|
||||
</header>
|
||||
<div id="drop">拖入 .mpq 打开你的归档;或用 ?mpq=<url> / ?data=<fixture 目录></div>
|
||||
<input id="picker" type="file" accept=".mpq,.MPQ" hidden />
|
||||
<div id="controls" hidden>
|
||||
<label>关卡 DS1 <select id="ds1"></select></label>
|
||||
<label>瓦片 DT1 <select id="dt1"></select></label>
|
||||
<label>调色板 <select id="pal"></select></label>
|
||||
<button id="reload">重新加载</button>
|
||||
</div>
|
||||
<p id="status">正在加载地图…</p>
|
||||
<div id="dialog" hidden></div>
|
||||
<canvas id="view" width="960" height="560"></canvas>
|
||||
<script type="module" src="/src/scene/map-scene.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
2
net.html
2
net.html
|
|
@ -28,7 +28,7 @@
|
|||
<header>
|
||||
<h1>d2web · 双人联机合作</h1>
|
||||
<a href="/">资源检查器</a>
|
||||
<a href="/map.html?data=samples/fixtures">单人地图场景</a>
|
||||
<a href="/acts.html">真实战役场景</a>
|
||||
<span id="net">未联机</span>
|
||||
</header>
|
||||
<p id="status">正在加载地图…</p>
|
||||
|
|
|
|||
|
|
@ -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` 迁移过来的。
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
})()
|
||||
|
|
@ -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,
|
||||
}
|
||||
})()
|
||||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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<string, unknown> {
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue