From b28ce37e8a7165299b6d3af98d80ef3d330ab80b Mon Sep 17 00:00:00 2001
From: troytt <47798984@qq.com>
Date: Tue, 15 Sep 2026 06:52:29 +0000
Subject: [PATCH] =?UTF-8?q?docs(roadmap):=20correct=20M6=20=E2=80=94=20dis?=
=?UTF-8?q?card=20map-scene.ts=20instead=20of=20merging?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
原诊断断言 act-scene.ts「无战斗/无物品/无技能/无存档」,据此把 M6 定为
「两轨合流、抽出第三个共享层」。逐行核查后发现这个断言是错的。
act-scene.ts:777 就实例化了 GameEngine,820 行 tick 它,962/968/978/984 行
分别绘制怪物、NPC、投射物、地面掉落,826-827 行的存读档也是经引擎走的。
之前 grep 不到是因为查的是 tickCombat/spawnMonsters 的直接导入,而
act-scene 是通过 GameEngine 间接获得这些能力的。
真相是 GameEngine(engine.ts,350 行)本身就已经是地图无关的共享层——
它只依赖 WorldMapProvider { widthPx, heightPx, overlap() }。原计划要新建的
src/scene/shared/gameplay.ts 不需要了。
「真地图上没有怪物」的全部原因是 act-scene.ts:785 的 stats: [],
因为 combat.ts:376 是 if (stats.length === 0) return 0。
反过来 map-scene.ts 查出三个确凿缺陷:
1. 孤儿世界:399-403 行 createWorld + spawnMonsters 建的世界从未被 tick,
453 行的 GameEngine 又建了自己的世界;let player = world.player 是死变量。
2. 怪物动画冻结(真实 bug):572 行读孤儿世界的 world.tick,而 world.tick += 1
只在 combat.ts:518 的 tickCombat 里发生,map-scene 从不直接调它。world.tick
恒为 0,表达式退化成每只怪一个固定帧,追击/攻击时动画静止。
3. 存读档绕过引擎:488-489 行传 saving:false/loading:false,然后在 492-511 行
于引擎外部读写 localStorage。在 tick 外做存读档对确定性是隐患。
另有 12 个死导入(tickCombat/rebindPlayer/damageMonster/castSkill/
tickProjectiles/QuestLog/npcDialog/captureSnapshot/parseSnapshot/
restoreSnapshot/serializeSnapshot/Rng),是逻辑搬进 GameEngine 后未清理的外壳。
因此 M6 改为:舍弃 map-scene.ts 与 map.html,收敛到 act-scene.ts 单轨。
范围收窄为三件事——打通 stats、修复 overlap 布尔化导致的脱困梯度丢失、
删除 map-scene 并把夹具回归检查改为直接驱动 GameEngine。风险由「高」下调为「中」。
TAG=agy
CONV=c89513df-cd12-4749-b5bd-5e9f16639391
---
ROADMAP.md | 168 ++++++++++++++++++++++++++++++++++++++++-------------
1 file changed, 128 insertions(+), 40 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index a1b9141..1128e26 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -25,35 +25,65 @@
| DRLG 随机地图生成器(迷宫 70 + 野外 31) | `verify-generators` **788/788 断言**,缺瓦片 0.00%,可达率 100% |
| 战斗 / 物品 / 技能 / 任务 / 存档 / 联机骨架 | `verify:all`(combat 38 + items 58 + m4 56 + m5 33 + net 143) |
-但存在一个**根本性的架构断层**——这是整个路线图的排序依据:
+> [!IMPORTANT]
+> **2026-09-15 修正**:本节最初的判断是「两条轨道从未合流,acts.html 无战斗/无物品/无技能/无存档」。
+> 经逐行核查,**这个判断是错的**。`act-scene.ts` 第 777 行就实例化了 `GameEngine`,
+> 第 820 行 tick 它,第 962 / 968 / 978 / 984 行分别绘制怪物、NPC、投射物、地面掉落。
+> 之前的结论来自 grep `tickCombat` / `spawnMonsters` 的直接导入——而 act-scene 是
+> **通过 `GameEngine` 间接获得全部玩法的**。下文是修正后的事实。
+
+真实的架构是**三层**,而不是两条平行轨道:
```mermaid
graph TB
- subgraph A["轨道 A:acts.html(真地图轨)"]
- A1["act-scene.ts (1437 行)"]
- A2["✅ buildIsoMapScene 真实等距 DS1/DT1"]
- A3["✅ loadCharacterSheet 真实法师 16 层 × 8 向"]
- A4["✅ CollisionGrid 真实碰撞"]
- A5["❌ 无战斗 ❌ 无物品 ❌ 无技能
❌ 无存档 ❌ 无小地图/传送点"]
+ subgraph E["玩法层:src/game/engine.ts (350 行)"]
+ E1["GameEngine —— 已经是地图无关的共享层"]
+ E2["只依赖 WorldMapProvider { widthPx, heightPx, overlap() }"]
+ E3["✅ 战斗 ✅ 物品/背包 ✅ 技能/投射物
✅ 任务/NPC ✅ 存读档 ✅ 掉落/拾取"]
end
- subgraph B["轨道 B:map.html(玩法轨)"]
- B1["map-scene.ts (1054 行)"]
- B2["❌ 正交 fixture 地图,非真实 D2"]
- B3["✅ tickCombat / spawnMonsters"]
- B4["✅ Inventory / rollDrop"]
- B5["✅ castSkill / tickProjectiles"]
- B6["✅ 存读档 / 任务 / NPC"]
- B7["⚠️ 全部跑在 demo-data.ts 的
3 怪 / 3 物 / 2 词缀 / 2 技能 / 5 级经验上"]
+ subgraph A["表现层 A:act-scene.ts (1437 行) → acts.html"]
+ A1["✅ 真实等距 DS1/DT1 + 屋顶 + 深度排序"]
+ A2["✅ 真·女法师 DCC+COF 16 层 × 8 向"]
+ A3["✅ 已接 GameEngine,已画怪物/NPC/投射物/掉落"]
+ A4["✅ NPC 用真实 DS1 坐标"]
+ A5["✅ 存读档经引擎(saving: input.saving)"]
+ A6["❌ stats: [] —— 唯一的「没有怪物」原因"]
+ A7["⚠️ overlap 被布尔化,丢失重叠计数梯度"]
+ A8["❌ 无背包 UI / 无血蓝球(属 M12)"]
end
- subgraph C["轨道 C:net.html(联机轨)"]
- C1["net-scene.ts (653 行) 25Hz 锁步"]
- C2["⚠️ 背包/任务不进同步哈希"]
+ subgraph B["表现层 B:map-scene.ts (1055 行) → map.html"]
+ B1["❌ 正交 fixture 地图,非真实 D2"]
+ B2["🐛 孤儿 world + 12 个死导入"]
+ B3["🐛 怪物动画冻结(读未 tick 的 world.tick)"]
+ B4["🐛 存读档绕过引擎"]
+ B5["✅ 背包 UI + 血蓝球(唯一优势,属 M12)"]
end
- A -.->|"两条轨道从未合流
这是最大的阻塞点"| B
- B -.-> C
+ E --> A
+ E --> B
+ B -.->|"逻辑不正确,M6 决定舍弃"| X["🗑️"]
```
-`acts.html` 的现状可以概括为:**在一张漂亮的真地图上,操纵一个真法师,永远独自行走。**
+**结论:`GameEngine` 本身就是共享层,不需要再抽一层。**
+`acts.html` 现状可概括为:**玩法已经接好,但怪物定义是空数组。**
+
+### `map-scene.ts` 的三个确凿缺陷(M6 决定舍弃它的依据)
+
+1. **孤儿世界**:第 399–403 行 `createWorld` + `spawnMonsters` 建了一个世界,
+ 但第 453 行的 `GameEngine` 又建了自己的世界(`engine.ts:82-83`)。前者从未被 tick。
+ `let player = world.player`(403 行)此后再未使用。
+2. **怪物动画冻结(真实 bug)**:第 572 行
+ `group[moving ? (world.tick + monster.index * 3) % group.length : 0]`
+ 读的是孤儿世界的 `tick`。而 `world.tick += 1` 只在 `combat.ts:518` 的 `tickCombat` 里发生,
+ map-scene 从不直接调它。**`world.tick` 恒为 0**,表达式退化成每只怪一个固定帧——
+ 怪物在追击/攻击时动画是静止的。
+3. **存读档绕过引擎**:第 488–489 行向 `engine.tick()` 传 `saving: false, loading: false`,
+ 然后在 492–511 行于引擎外部自行读写 `localStorage`。
+ act-scene 的做法(826–827 行传入真实标志)才是对的;在 tick 外做存读档对确定性是隐患。
+
+另有 **12 个死导入**(`tickCombat`、`rebindPlayer`、`damageMonster`、`castSkill`、
+`tickProjectiles`、`QuestLog`、`npcDialog`、`captureSnapshot`、`parseSnapshot`、
+`restoreSnapshot`、`serializeSnapshot`、`Rng`)——全部 import 但零使用,
+是「逻辑搬进 `GameEngine` 后未清理外壳」留下的残骸。
### demo 数据与真实 D2 的差距
@@ -72,7 +102,7 @@ graph TB
| 议题 | 裁决 |
| --- | --- |
-| M6 合流方向 | **抽出第三个共享层**,两个页面都变薄 |
+| M6 合流方向 | ~~抽出第三个共享层~~ → **舍弃 `map-scene.ts`,直接在 `act-scene.ts` 上做**(核查后变更,见 M6) |
| 联机 | **不作为目标**,只需单机;**保留扩展可能**(模拟层纯函数约束必须继续遵守) |
| 怪物精灵包体 | **接受按幕分包 + 按需懒加载** |
| 音频 | 解码器保留,**暂不接入场景层** |
@@ -84,7 +114,7 @@ graph TB
| # | 里程碑 | Issue | 重要程度 | 风险 | 状态 |
| --- | --- | --- | --- | --- | --- |
-| M6 | 两轨合流(共享玩法层) | [#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) | ★★★★☆ | 中 | 待开始 |
@@ -115,28 +145,86 @@ graph LR
---
-## M6 · 两轨合流(共享玩法层)
+## M6 · 舍弃 map-scene.ts,收敛到 act-scene.ts 单轨
-**★★★★★ 风险:高 全部后续里程碑的前提**
+**★★★★★ 风险:中(原评估为「高」,核查后下调) 全部后续里程碑的前提**
-把 `map-scene.ts` 上跑通的整套玩法抽成与地图实现无关的共享层,两个页面共同依赖;
-核心技术点是让 `CombatWorld` 绑定到 `d2map.ts` 的 `CollisionGrid` 而不是 `map.ts` 的正交网格。
+> [!IMPORTANT]
+> **2026-09-15 方案变更(Master 裁决)**:原方案是「抽出第三个共享层,两个页面都变薄」。
+> 核查发现 **`GameEngine` 本身就已经是那个共享层**(只依赖 `WorldMapProvider`,地图无关),
+> 而 `act-scene.ts` 早已接入它。同时 `map-scene.ts` 被查出三个确凿缺陷(见上文 §0)。
+> 因此改为:**舍弃 `map-scene.ts`,直接在 `act-scene.ts` 上继续做,不做合流。**
-`combat.ts` 的 `CombatTerrain` 是**重叠计数谓词**而非布尔值(身体能从坏出生点逃出),
-而 `d2map.ts` 提供的是 `isBlockedAt(scene, x, y): boolean` + `cellAt` + `cellCentre` + `findIsoSpawn`。
-需要一个适配器把后者升格成前者,并处理等距坐标下 sub-tile **16×8 的各向异性**
-(`ORTHO_SUB_TILE_WIDTH = 16` / `ORTHO_SUB_TILE_HEIGHT = 8`)。
+### 范围(比原计划小得多)
-- **[NEW] `src/scene/shared/gameplay.ts`** —— 与地图实现无关的玩法编排:世界创建、tick 循环、
- 事件流消费、掉落、拾取、存读档钩子。
-- **[NEW] `src/game/iso-terrain.ts`** —— `CollisionGrid` → `CombatTerrain` 适配器。
- **最容易出 bug 的地方:正交轨假设 sub-tile 是正方形。**
-- **[MODIFY] `src/scene/act-scene.ts`** —— 接入共享层。怪物与投射物必须插入**现有的等距深度排序**
- (`IsoDraw` 列表),不能另起一层,否则会穿墙穿屋顶;屋顶层(DS1 wall type 15)仍最后画。
-- **[MODIFY] `src/scene/map-scene.ts`** —— 改为消费共享层,保留 fixture 地图作为**快速回归测试台**
- (它比真地图快得多,单元测试仍应打在这条轨上)。
+原计划要新建的 `src/scene/shared/gameplay.ts` **不需要了**——`src/game/engine.ts` 就是它。
-**验收**:在 `acts.html` 的第一幕荒野上,能被怪物追、能打死怪、能捡到掉落、能 K/L 存读档。
+实际只有三件事:
+
+#### 1. `stats: []` → 真实怪物定义
+
+`act-scene.ts:785` 传的是空数组,而 `combat.ts:376` 是 `if (stats.length === 0) return 0`。
+**这一行就是「真地图上没有怪物」的全部原因。** 怪物的绘制、tick、AI 早就接好了。
+
+真实怪物数据的接入属于 M7(#22),M6 只需把这个口子打通并用占位数据验证链路。
+
+#### 2. 修复碰撞语义退化(真实缺陷)
+
+```ts
+// act-scene.ts:781 —— 布尔化,丢失重叠计数
+overlap: (x, y) => (collides(runtime.grid, x, y) ? 1 : 0)
+```
+
+`collides`(`act-scene.ts:1120`)对脚底盒采样 9 个点,任一阻挡即返回 `true`。
+但 `combat.ts` 的脱困规则是 `terrain.overlap(x + dx, y) <= current`:
+
+- **有计数时**:陷在墙里 5 格深 → 向 3 格深移动被允许(沿梯度爬出),向 7 格深被拒绝
+- **布尔化后**:`1 <= 1` 恒成立 → 在墙里**随机游走**,能出来但不可靠、无方向性
+
+需要一个返回**真实阻挡子格计数**的 `overlap`,并处理等距坐标下 sub-tile
+**16×8 的各向异性**(`ORTHO_SUB_TILE_WIDTH = 16` / `ORTHO_SUB_TILE_HEIGHT = 8`)——
+`map.ts` 的 `blockedOverlap` 假设 sub-tile 是正方形,不能直接搬。
+
+- **[NEW] `src/game/iso-terrain.ts`** —— 等距 `CollisionGrid` → 计数型 `WorldMapProvider`
+
+#### 3. 删除 map-scene.ts 与 map.html
+
+- **[DELETE] `src/scene/map-scene.ts`**(1055 行)
+- **[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`(纯逻辑,无场景层),
+> 而不是驱动一个完整页面。
+
+### 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 坐标覆盖了它,但这段兜底逻辑应下沉为可选项,
+ 避免将来有人误用。
+
+### 必须遵守的铁律
+
+- 怪物与投射物必须插入**现有的等距深度排序**(`pushEntity`),不能另起一层,
+ 否则会穿墙穿屋顶;屋顶层(DS1 wall type 15)仍最后画
+- `CombatWorld.player` 与 `CombatWorld.players[i]` **必须是同一个对象**(用 `rebindPlayer()`)
+- 渲染后**必须 `renderer.flush()`**
+- 模拟层保持纯函数:禁 `Date.now()` / `Math.random()`
+
+### 验收
+
+- [ ] `acts.html` 第一幕荒野上能被怪物追、能打死怪、能捡掉落、能 K/L 存读档
+- [ ] 角色从墙内出生时能**沿梯度**脱困(不是随机游走)
+- [ ] 新增 `tests/iso-terrain.test.ts` 验证等距碰撞计数的各向异性
+- [ ] `map.html` 及 `map-scene.ts` 已删除,且 `verify:all` 仍全绿
+- [ ] `npm run typecheck` 0 错误、`npm test` 不低于 442 passed 基线
---