[P1][CR-B6/F5] 前后端边界:后端依赖 UI、前端直写引擎内部、globalThis 服务定位、import 环 #526

Open
opened 2026-09-29 06:43:57 +00:00 by troytt · 0 comments
Owner

来源:#516|优先级 P1|评审编号 B6、F5|基线 e475c2e

问题描述

e475c2e 把 act-scene 拆成了 scene/backend/ 和 scene/frontend/,但依赖方向和写权限并没有随之分开:

  • 后端 import UI,并且通过 globalThis 拿到 HUD 实例;
  • 前端直接改引擎的内部字段;
  • 后端产出"DOM 形状"的中英文文案;
  • 模块之间存在值级 import 环。

结果是后端离不开浏览器和 UI,无法单独运行或测试。这也是 #520(状态双源)和 #523(模拟不确定)的结构性原因。

证据

1. 反向依赖:后端 → UI / 渲染

2. globalThis 服务定位

  • 后端通过 globalThis 读取 HUD 实例和前端回调,包括 __d2webHudInstance、__d2PlayInventoryFullFeedback、__d2ShowNotification:skill-caster.ts:116、skill-caster.ts:128、skill-caster.ts:394-397。
  • 这些全局变量是 overhead-labels.ts:165-168 在模块被 import 时作为副作用注册的。也就是说,后端能否正常工作,取决于某个前端模块有没有先被 import。
  • 全仓共有 21 个 __d2* 全局键。

3. 后端输出"DOM 形状"的文案

  • castSkill 的上下文参数里有一个 status: { textContent }(skill-caster.ts:63)。skill-caster.ts 中共有 62 处 status.textContent = …。
  • 文案是硬编码的中英文三元表达式,语言由 hudManager?.lang ?? getTblLang() 决定(skill-caster.ts:115)。

4. 前端直接写引擎内部

位置 写了什么
pack-loader.ts:919-937 terrain、player.x/y、npcEntities、monsters、ground
pack-loader.ts:1031-1032 levelId、isTown
mouse-controller.ts:800、mouse-controller.ts:808 engine.dialog;NPC 治疗时直接写 player.hp
mouse-controller.ts:1176-1179 (this.engine as any).dialog、player.infernoChanneling 等
ui/world-panels.ts 买、卖、赌博、修理和仓库操作直接改 HUD 里的金币(见 #520)

5. act-scene 仍然是一个巨型桶

6. import 环

值级 import 构成了 6 个强连通分量。最大的一个由 17 个 drlg 文件组成;另外还有 skills.ts ↔ missile-engine ↔ combat-pipeline ↔ stat-list ↔ state-bus 等。

根因

这次拆分只移动了文件,没有定义接口。后端需要通知 UI、UI 需要改变世界时,没有一个约定好的通道,于是各处分别用 import、全局变量和直接赋值打通了。

修复指南

  1. 定义分层规则。写进 AGENTS.md,并用 lint 固化(见 #537):
    • src/game/** 和 src/scene/backend/** 不得 import ui/、render/、scene/frontend/,也不得访问 window、document 和 globalThis.__d2*。
    • 前端只能通过引擎的公开命令修改世界。
  2. 后端 → 前端:事件端口
    type SimEvent =
      | { kind: 'status'; key: StatusKey; args?: Record<string, string | number> }
      | { kind: 'notify'; key: StatusKey; durationMs?: number }
      | { kind: 'sfx'; sound: string; x?: number; y?: number }
      | { kind: 'inventoryFull' }
    
    interface SimEvents {
      emit(event: SimEvent): void
    }
    
    • 后端只发 key 和参数,不产出文案,本地化由前端负责(见 #534 的 i18n 部分)。
    • castSkill 的 status: { textContent } 参数,以及所有 globalThis.__d2* 读取,都改为 events.emit(...)。
    • ground-items.ts 里直接调用 audioManager.playSfx 的地方,也改为发出 sfx 事件。
  3. 前端 → 后端:命令
    • engine.loadLevel(spec):取代 pack-loader 里逐个字段改 terrain、player、monsters、ground、levelId、isTown 的代码。
    • engine.execute(cmd):用于对话、治疗、交易、拾取等操作。它和 #520、#523 里的命令队列是同一个机制。
    • 引擎内部字段改为 private 或 readonly,由类型系统阻止直接赋值;删除所有 (engine as any)。
  4. 领域规则移回 game/:金币上限(getInventoryGoldLimit)、itemToUiInventoryItem 这类 item-bridge 逻辑从 ui/ 移到 game/,改为由 UI 依赖它们。
  5. runScene 改为 SceneController:
    • 闭包里的可变局部变量变成类字段,getX / setX 回调变成方法;
    • 生命周期明确为 start() / dispose(),与 #533 的监听器清理配合。
  6. 去掉桶导出和全局钩子:
    • 测试改为直接 import 子模块,逐步删除 act-scene 的 re-export;
    • 调试钩子收敛为 import.meta.env.DEV 下的一个类型化 DebugApi。
  7. 拆除 import 环:把环上共享的类型抽到独立的 types.ts,然后开启 import/no-cycle(见 #537)。

验收标准

  • src/game/** 和 src/scene/backend/** 中没有对 ui/、render/、scene/frontend/ 的 import,也不访问 globalThis.__d2*、window、document;对应的 lint 规则已开启。
  • 新增测试:后端在 Node 中不 import 任何前端模块、不 mock DOM,就能完成"加载关卡 → 施法 → 拾取 → 存档"。
  • scene/frontend/** 中没有对 engine.* 内部字段的直接赋值。
  • skill-caster.ts 中没有 status.textContent。
  • 值级 import 环的数量为 0;暂时拆不掉的环要列入白名单,并附上拆分计划。
  • npm run typecheck 0 error;npx vitest run 全部通过。

相关

  • #520:状态归一依赖这里的命令通道。
  • #523:命令队列与确定性。
  • #533:SceneController 的 dispose 与监听器清理。
  • #537:no-restricted-imports、import/no-cycle。
  • #534:i18n。
  • #511(M20-P3 WorldView 解耦):方向一致,可以一起推进。
> 来源:#516|优先级 P1|评审编号 B6、F5|基线 `e475c2e` ## 问题描述 `e475c2e` 把 act-scene 拆成了 `scene/backend/` 和 `scene/frontend/`,但依赖方向和写权限并没有随之分开: - 后端 import UI,并且通过 `globalThis` 拿到 HUD 实例; - 前端直接改引擎的内部字段; - 后端产出"DOM 形状"的中英文文案; - 模块之间存在值级 import 环。 结果是后端离不开浏览器和 UI,无法单独运行或测试。这也是 #520(状态双源)和 #523(模拟不确定)的结构性原因。 ## 证据 ### 1. 反向依赖:后端 → UI / 渲染 - `game → ui` 共 10 处。例如 `engine.ts` 从 `ui/inventory.ts` 引入金币上限规则:[engine.ts:21](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/engine.ts#L21)。 - `game → render` 共 5 处。 - `scene/backend → ui` 共 2 处:[skill-caster.ts:32-33](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/backend/skill-caster.ts#L32-L33)。 - 后端直接播放音效:[ground-items.ts:1429-1432](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/ground-items.ts#L1429-L1432)。 ### 2. `globalThis` 服务定位 - 后端通过 `globalThis` 读取 HUD 实例和前端回调,包括 `__d2webHudInstance`、`__d2PlayInventoryFullFeedback`、`__d2ShowNotification`:[skill-caster.ts:116](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/backend/skill-caster.ts#L116)、[skill-caster.ts:128](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/backend/skill-caster.ts#L128)、[skill-caster.ts:394-397](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/backend/skill-caster.ts#L394-L397)。 - 这些全局变量是 [overhead-labels.ts:165-168](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/frontend/overhead-labels.ts#L165-L168) 在**模块被 import 时作为副作用**注册的。也就是说,后端能否正常工作,取决于某个前端模块有没有先被 import。 - 全仓共有 21 个 `__d2*` 全局键。 ### 3. 后端输出"DOM 形状"的文案 - `castSkill` 的上下文参数里有一个 `status: { textContent }`([skill-caster.ts:63](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/backend/skill-caster.ts#L63))。`skill-caster.ts` 中共有 62 处 `status.textContent = …`。 - 文案是硬编码的中英文三元表达式,语言由 `hudManager?.lang ?? getTblLang()` 决定([skill-caster.ts:115](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/backend/skill-caster.ts#L115))。 ### 4. 前端直接写引擎内部 | 位置 | 写了什么 | |---|---| | [pack-loader.ts:919-937](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/frontend/pack-loader.ts#L919-L937) | terrain、`player.x/y`、`npcEntities`、`monsters`、`ground` | | [pack-loader.ts:1031-1032](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/frontend/pack-loader.ts#L1031-L1032) | `levelId`、`isTown` | | [mouse-controller.ts:800](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/frontend/mouse-controller.ts#L800)、[mouse-controller.ts:808](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/frontend/mouse-controller.ts#L808) | `engine.dialog`;NPC 治疗时直接写 `player.hp` | | [mouse-controller.ts:1176-1179](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/frontend/mouse-controller.ts#L1176-L1179) | `(this.engine as any).dialog`、`player.infernoChanneling` 等 | | `ui/world-panels.ts` | 买、卖、赌博、修理和仓库操作直接改 HUD 里的金币(见 #520) | ### 5. act-scene 仍然是一个巨型桶 - 一次性 re-export 约 150 个符号:[act-scene.ts:109-153](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/act-scene.ts#L109-L153)。 - `runScene` 是一个 586 行的闭包(从 [act-scene.ts:158](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/act-scene.ts#L158) 开始),通过十几个 `getX` / `setX` 回调共享可变的局部变量([act-scene.ts:214-249](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/act-scene.ts#L214-L249))。 - 另外还有一个模块级单例 `state`。 ### 6. import 环 值级 import 构成了 6 个强连通分量。最大的一个由 17 个 drlg 文件组成;另外还有 `skills.ts ↔ missile-engine ↔ combat-pipeline ↔ stat-list ↔ state-bus` 等。 ## 根因 这次拆分只移动了文件,没有定义接口。后端需要通知 UI、UI 需要改变世界时,没有一个约定好的通道,于是各处分别用 import、全局变量和直接赋值打通了。 ## 修复指南 1. **定义分层规则**。写进 `AGENTS.md`,并用 lint 固化(见 #537): - `src/game/**` 和 `src/scene/backend/**` 不得 import `ui/`、`render/`、`scene/frontend/`,也不得访问 `window`、`document` 和 `globalThis.__d2*`。 - 前端只能通过引擎的公开命令修改世界。 2. **后端 → 前端:事件端口** ```ts type SimEvent = | { kind: 'status'; key: StatusKey; args?: Record<string, string | number> } | { kind: 'notify'; key: StatusKey; durationMs?: number } | { kind: 'sfx'; sound: string; x?: number; y?: number } | { kind: 'inventoryFull' } interface SimEvents { emit(event: SimEvent): void } ``` - 后端只发 key 和参数,不产出文案,本地化由前端负责(见 #534 的 i18n 部分)。 - `castSkill` 的 `status: { textContent }` 参数,以及所有 `globalThis.__d2*` 读取,都改为 `events.emit(...)`。 - `ground-items.ts` 里直接调用 `audioManager.playSfx` 的地方,也改为发出 `sfx` 事件。 3. **前端 → 后端:命令** - `engine.loadLevel(spec)`:取代 pack-loader 里逐个字段改 terrain、player、monsters、ground、levelId、isTown 的代码。 - `engine.execute(cmd)`:用于对话、治疗、交易、拾取等操作。它和 #520、#523 里的命令队列是同一个机制。 - 引擎内部字段改为 `private` 或 `readonly`,由类型系统阻止直接赋值;删除所有 `(engine as any)`。 4. **领域规则移回 `game/`**:金币上限(`getInventoryGoldLimit`)、`itemToUiInventoryItem` 这类 item-bridge 逻辑从 `ui/` 移到 `game/`,改为由 UI 依赖它们。 5. **`runScene` 改为 `SceneController`**: - 闭包里的可变局部变量变成类字段,`getX` / `setX` 回调变成方法; - 生命周期明确为 `start()` / `dispose()`,与 #533 的监听器清理配合。 6. **去掉桶导出和全局钩子**: - 测试改为直接 import 子模块,逐步删除 act-scene 的 re-export; - 调试钩子收敛为 `import.meta.env.DEV` 下的一个类型化 `DebugApi`。 7. **拆除 import 环**:把环上共享的类型抽到独立的 `types.ts`,然后开启 `import/no-cycle`(见 #537)。 ## 验收标准 - [ ] `src/game/**` 和 `src/scene/backend/**` 中没有对 `ui/`、`render/`、`scene/frontend/` 的 import,也不访问 `globalThis.__d2*`、`window`、`document`;对应的 lint 规则已开启。 - [ ] 新增测试:后端在 Node 中不 import 任何前端模块、不 mock DOM,就能完成"加载关卡 → 施法 → 拾取 → 存档"。 - [ ] `scene/frontend/**` 中没有对 `engine.*` 内部字段的直接赋值。 - [ ] `skill-caster.ts` 中没有 `status.textContent`。 - [ ] 值级 import 环的数量为 0;暂时拆不掉的环要列入白名单,并附上拆分计划。 - [ ] `npm run typecheck` 0 error;`npx vitest run` 全部通过。 ## 相关 - #520:状态归一依赖这里的命令通道。 - #523:命令队列与确定性。 - #533:`SceneController` 的 `dispose` 与监听器清理。 - #537:`no-restricted-imports`、`import/no-cycle`。 - #534:i18n。 - #511(M20-P3 `WorldView` 解耦):方向一致,可以一起推进。
Sign in to join this conversation.
No Label
No Milestone
No project
No Assignees
1 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: troytt/diablo2-web#526
No description provided.