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 基线 ---