This commit is contained in:
2026-07-07 03:34:56 +08:00
parent e7891d7e00
commit ecb20cf370
81 changed files with 2108 additions and 293 deletions
+264
View File
@@ -0,0 +1,264 @@
# 解耦架构重构方案(Unity Architecture Refactoring Plan
> 适用范围:`gold_dolphin/unity` 项目全局
> 设计准则:ScriptableObject 优先、单一职责、零 `GameObject.Find`、无静态单例调用方
> 配套代码:`Assets/Architecture/`(已落地,增量、零侵入,未改动任何现有文件)
> 版本:v1.1 —— 已根据同行评审(2 份意见)修正严重度、行号与遗漏点,见 §1.3
---
## 0. 执行摘要
当前项目**已经出现典型的耦合腐烂信号**:7 个跨场景单例、`GameObject.Find/FindWithTag/FindObjectsByType` 散落在运行时代码中(含 `GameHUD.Update``_playerHealth == null` 时的**条件性** `FindWithTag` 补救)、UI 通过**反射读取私有字段**来刷新(`GameHUD``EnemyHealthBar` 两处)、God Class`GameManager`/`HealthSystem`)身兼数职、系统间通过 `static Action` 事件 + 单例方法互相调用。
这套结构在小规模时跑得动,但已直接导致你正在经历的维护困难:
- 场景重载后引用丢失(`GameHUD``Update``_playerHealth == null` 时才 `FindWithTag` 补救——属于条件性回退,并非每帧都查,但本质仍是反模式)
- 改一个类名/字段名就引发连锁编译或**静默运行时失效**(反射那几处)
- 新增系统必须知道"谁是谁"(单例类名、Player 标签),无法并行开发
**目标架构**:以 ScriptableObject 为"通信总线",所有共享状态放进 Variable 资产、所有跨系统消息走 Event 通道、所有实体集合用 RuntimeSet 跟踪。MonoBehaviour 只做"执行者",且**永不直接引用彼此的实现类**——它们只认 SO 资产引用。
---
## 1. 现状诊断(Architecture Audit
### 1.1 反模式清单(含证据)
| 反模式 | 位置 | 具体表现 |
|---|---|---|
| **跨场景单例** | `GameManager : PersistentSingleton``AudioManager``ScoreManager``SceneLoader``TimeController``EnemyManager`/`LightMaskSystem``static Instance` | 调用方写死 `XxxManager.Instance`,系统间强耦合,且 `DontDestroyOnLoad` 引发跨场景引用生命周期问题 |
| **GameObject.Find / FindWithTag / FindObjectsByType** | `GameManager.cs:81,145,150,165``GameHUD.cs:49,76,104-115``EnemyAI.cs:112``MainMenuUIController.cs:55,61,66,143``GameLostOverlay.cs:85,273``BoundaryWallGenerator.cs:171``SceneInteraction.cs:49``SpawnPointGenerator.cs:263,293` | 依赖场景层级、对象名、标签。**`GameHUD.Update``GameHUD.cs:104`)仅在 `_playerHealth == null` 时才 `FindWithTag("Player")` 补救——条件性回退,非每帧**`GameHUD.cs:76``Start``FindObjectOfType<DamageFlashOverlay>()` 未发现则动态 `new GameObject` 创建,增加 `Start` 复杂度;`EnemyAI.cs:112``FindWithTag` **仅在 `Start` 执行一次**并缓存 `_playerHealth`,与 `GameHUD` 的高频补救需区分;`MainMenuUIController.cs:55``FindObjectsByType<StoryPVPlayer>``:61/66``GameObject.Find("SettingsPanel"/"CreditsPanel")``FindButton()``GameObject.Find(name)` 属主菜单场景的对象名耦合 |
| **反射读取私有字段** | `GameHUD.cs:150-152`health)、`170-176`rollCooldown/lastRollTime)、`183-189`cooldown/_lastEchoTime)、`197-199`lantern cooldown);`EnemyHealthBar.cs:50-53`(读 maxHealth)、`73-75`(读 health | 用 `typeof(X).GetField("xxx", NonPublic\|Instance)` 读私有字段;字段一改名即**静默失效**。`GameHUD``EnemyHealthBar` 均在每帧更新(`EnemyHealthBar``LateUpdate`)里读 `health`/`maxHealth`,既有性能开销,又对字段改名极度脆弱(初稿仅列 GameHUDEnemyHealthBar 已补) |
| **God Class** | `GameManager.cs`(约 295 行,6 项职责:状态/过场/敌人控制/分数/场景/输入分发)、`HealthSystem.cs`(约 216 行,健康+受伤+无敌+死亡+溶解动画+粒子) | 一处改动牵动全局,难以测试 |
| **轻度耦合(非 God Class** | `PlayerController``Player.cs`,约 112 行,仅移动+翻滚;`RequireComponent`**3 个** 服务系统:HealthSystem/SwordAttack、ProjectileShooter、SpiritLanternSystem | 体量可控,主要问题是 `RequireComponent` 把子系统的实现类硬绑到玩家预制体上;可在 P4 轻量剥离,不必列为重度重构对象(初稿误归为 God Class,已降级) |
| **static 事件伪总线** | `GameManager.onGameOver`/`onGameWin``GameManager.cs:20` 声明、`:61` 触发)、`HealthSystem.onPlayerDamaged``EchoSystem.OnEchoReleased``ScoreManager.onScoreChanged/onScoreSettled` | 全局无类型资产、不可在 Inspector 配置、跨场景订阅清理脆弱(`GameHUD` 在 Update 里二次订阅补洞);`onGameWin``GameResultScreen.cs:63,69` 监听(初稿漏列) |
| **直接跨实体耦合** | `EnemyAI.cs:116` 缓存 `_playerHealth = player.GetComponent<HealthSystem>()``:232` 直接 `_playerHealth.Damage()` | 敌人紧耦合玩家实现类;玩家死亡时 `HealthSystem.cs:82` 又反向调 `GameManager.GameOver()` —— 双向硬依赖(注:初稿误写为 `:230`,实际调用在 `:232` |
| **直接单例调用(覆盖面比初稿更广)** | `ScorePickup.cs:39``SoulDrop.cs:110` 直接 `ScoreManager.Instance.AddScore(...)``ScoringUIController.cs` 同时依赖 `ScoreManager.Instance`/`AudioManager.Instance`/`SceneLoader.Instance``MainMenuUIController.cs:206,216``SceneLoader.Instance.LoadGameplayScene()``GameLostOverlay.cs:246``SceneLoader.Instance.LoadMainMenuScene()` | 调用方写死单例类名,跨系统强耦合;`SoulDrop.cs` 甚至在无 `ScoreManager``new GameObject` 自建——单例缺失的运行时补洞,比显式依赖更危险 |
| **Resources.Load** | `AudioManager.cs:121``StoryPVPlayer.cs:141` | 资源路径硬编码,无法做分包/热更,构建裁剪风险 |
| **Shader.Find(同类硬编码)** | `GameLostOverlay.cs:358` `Shader.Find("GameFramework/UI/WaterRippleFade")` | 与 `Resources.Load` 同属"字符串硬编码资源路径",构建裁剪 / 资源改名即失效,归入 P5 一并治理;`GameLostOverlay.cs:85` 还用 `FindObjectOfType<GameLostOverlay>()` 做单体自检查 |
### 1.2 耦合地图(谁通过什么找谁)
```
[EnemyAI] --FindWithTag("Player")【仅 Start 一次 :112】--> [Player]
[EnemyAI] --GetComponent<HealthSystem>().Damage()【:232】--> [Player.HealthSystem]
[GameHUD] --FindWithTag【_playerHealth==null 时 :104】+ 反射--> [Player] 各子系统
[GameHUD] --FindObjectOfType<DamageFlashOverlay> :76 + 动态 new GameObject--> [DamageFlashOverlay]
[GameHUD] --ScoreManager.Instance + onScoreChanged--> [ScoreManager]
[EnemyHealthBar] --反射读 HealthSystem 私有字段 maxHealth/health :50/73--> [HealthSystem]
[GameManager] --FindObjectsOfType<EnemyAI>()--> 所有敌人
[GameManager] --FindWithTag + GetComponent 逐个禁用--> [Player] 子组件
[HealthSystem] --GameManager.GameOver()--> [GameManager]
[HealthSystem/Echo/Player/Lantern] --AudioManager.Instance.PlayX()--> [AudioManager]
[ScorePickup]/[SoulDrop] --ScoreManager.Instance.AddScore()--> [ScoreManager]
[ScoringUIController] --ScoreManager.Instance / AudioManager.Instance / SceneLoader.Instance--> [三服务]
[MainMenuUIController] --SceneLoader.Instance.LoadGameplayScene() :206/216--> [SceneLoader]
[MainMenuUIController] --GameObject.Find / FindObjectsByType【对象名耦合】--> [主菜单场景物体]
[GameLostOverlay] --SceneLoader.Instance.LoadMainMenuScene() :246--> [SceneLoader]
[GameLostOverlay] --Shader.Find :358 + FindObjectOfType 自检查 :85--> [Shader / 自身]
[GameResultScreen] --onGameWin / onGameOver 静态事件--> [GameManager]
[各系统] --static event--> 订阅方(无资产、无可见性)
```
**根因**:所有系统都把"另一个具体类/具体场景物体"当成了通信媒介,而不是把"消息"和"状态"抽象成独立资产。
### 1.3 评审补充(peer-review 修正记录)
本方案经 2 份同行评审,以下为已采纳的修正,供后续读者对照:
- **严重度下调**:初稿将 `GameHUD``FindWithTag` 描述为"每帧"。经核实为 `_playerHealth == null` 时的**条件性回退**`GameHUD.cs:104-115`),已下调严重度;但本质仍是反模式。
- **降级 `PlayerController`**:从 God Class 降级为"轻度耦合"(约 112 行、仅移动+翻滚、`RequireComponent` 为 3 个服务系统,非初稿所述的 4 个)。
- **行号修正**`EnemyAI``.Damage()` 调用在 `:232`(非初稿写的 `:230`);其 `FindWithTag` 仅在 `Start` 一次(`:112`),与 `GameHUD` 需区分。
- **补充遗漏点**`EnemyHealthBar` 反射读私有字段;`GameHUD` `Start``FindObjectOfType<DamageFlashOverlay>``onGameWin` 静态事件被 `GameResultScreen` 监听;`GameLostOverlay``Shader.Find``MainMenuUIController` 的对象名耦合;`ScorePickup`/`SoulDrop`/`ScoringUIController`/`MainMenuUIController`/`GameLostOverlay``ScoreManager`/`SceneLoader` 的直接单例调用线。
- **配套代码据评审补强**`TypedGameEvents` 已增加 `StringEventListener`(初稿声称有但未实现);`RuntimeSet` 已增加泛型 `RuntimeSetRegistrar<T>`(强类型集合用),详见 §2.3。
---
## 2. 目标架构(Target Architecture
### 2.1 分层
```
┌─────────────────────────────────────────────────────────────┐
│ L0 SO 通信总线(项目级资产,跨场景天然存活,无 MonoBehaviour
│ · Event 通道:GameEvent / IntEvent / Vector3Event ... │
│ · Variable 资产:FloatVariable / IntVariable / BoolVariable │
│ · RuntimeSet<T>:玩家集 / 敌人集 / 灵灯集 │
└───────────────┬───────────────────────────┬─────────────────┘
│ 监听/触发(Inspector 引用) │ 注册/读取
┌───────▼────────┐ ┌───────▼────────┐
│ L1 系统服务 │ │ L2 实体 │
│ (MonoBehaviour) │ │ Player/Enemy/ │
│ Audio/Scene/Time │ │ Lantern │
│ Spawn/Transition │ │ │
└───────┬────────┘ └───────┬────────┘
│ 触发事件 │ 触发事件
└─────────────┬──────────────┘
┌───────▼────────┐
│ L3 表现层 │
│ HUD / Overlay │
│ 只读 Variable + │
│ 监听 Event │
└─────────────────┘
```
**关键变化**L1/L2/L3 之间**没有任何直接的类引用**。它们只通过 L0 的 SO 资产通信。谁触发了什么、谁在监听,全部在 Inspector 里可见、可配、可审计。
### 2.2 四条铁律
1. **调用方不碰单例**:需要某个服务时,不是 `AudioManager.Instance.PlayX()`,而是 `audioEvent.Raise(clip)`。服务类自己订阅该 Event 资产。
2. **生产代码零 `GameObject.Find`**:找实体走 `RuntimeSet`(遍历集合);找玩家走 `PlayerRuntimeSet`(玩家生成时注册)。
3. **共享状态进 Variable**:血量/得分/魂灵数/冷却剩余 全是 SO 资产,UI 订阅 `OnValueChanged`,不再反射、不再逐帧 Find。
4. **一个 MonoBehaviour 一件事**`GameManager` 拆成状态机 + 过场 + 敌人控制;`HealthSystem` 拆成健康逻辑 + 死亡表现;`PlayerController` 仅剥离 `RequireComponent` 对子系统实现类的硬绑。
### 2.3 已落地的基础原语(`Assets/Architecture/`
| 文件 | 作用 |
|---|---|
| `Core/GameEvent.cs` | 无参事件通道 + `GameEventListener`Inspector 配置响应) |
| `Core/TypedGameEvents.cs` | 泛型 `GameEvent<T>` + `Int/Float/Vector3/String/GameObject` 五类通道;监听器组件 `Int/Float/Vector3/GameObject/StringEventListener`(初稿遗漏的 `StringEventListener` 已补) |
| `Variables/FloatVariable.cs` `IntVariable.cs` `BoolVariable.cs` `Vector3Variable.cs` | 共享变量资产,带 `OnValueChanged` 事件与 `ContextMenu` 重置 |
| `RuntimeSets/RuntimeSet.cs` | 泛型 `RuntimeSet<T>` + `TransformRuntimeSet` + `RuntimeSetRegistrar`Transform 集合自动注册)+ 泛型 `RuntimeSetRegistrar<T>`(强类型集合如 `EnemyRuntimeSet` 用,领域文件夹写一行具体子类即可) |
| `Editor/VariableDrawer.cs` | 变量资产在 Inspector 实时显示当前值(含 Play 模式) |
配套资产(在 Unity 里右键 Create)示例:
`Assets/ScriptableObjects/Events/OnPlayerDamaged.asset``.../Variables/PlayerHealth.asset``.../RuntimeSets/Enemies.asset`
> **关于 `RuntimeSetRegistrar` 的限制说明(评审点)**:初版仅 `RuntimeSetRegistrar` 支持 `TransformRuntimeSet`。若 P2 建立强类型集合(如 `EnemyRuntimeSet : RuntimeSet<EnemyAI>`),应使用泛型 `RuntimeSetRegistrar<EnemyAI>` 的闭包子类:
> ```csharp
> // 放在 enemy 文件夹,领域专用
> public class EnemySetRegistrar : RuntimeSetRegistrar<EnemyAI> { }
> ```
> 这样敌人生成时自动 `GetComponent<EnemyAI>()` 并加入集合,无需退回 Transform 集合再手动转型。
---
## 3. 逐条改造映射(Before → After
### 3.1 玩家引用:`FindWithTag("Player")` → `PlayerRuntimeSet`
```csharp
// ❌ 现有
var player = GameObject.FindWithTag("Player");
_playerHealth = player.GetComponent<HealthSystem>();
// ✅ 新增 PlayerRuntimeSet : RuntimeSet<Transform>(放在 enemy/或 player 文件夹)
// 玩家预制体挂 RuntimeSetRegistrar 并指向该 Set
// 任意系统:
public class EnemyAI : MonoBehaviour
{
[SerializeField] private TransformRuntimeSet _players; // 拖入 Player Set 资产
private Transform _player;
private void Awake() => _player = _players.Items.Count > 0 ? _players.Items[0] : null;
}
```
> 敌人不再假设"场景里一定有个带 Player 标签的物体",也无需每帧 Find。若用强类型集合(如 `EnemyRuntimeSet : RuntimeSet<EnemyAI>`),挂 `RuntimeSetRegistrar<EnemyAI>` 的闭包子类即可,无需退回 Transform 集合再转型。
### 3.2 敌人受伤:紧耦合 `HealthSystem.Damage()` → 事件 / 引用集
`EnemyAI` 仍可直接持有玩家 `HealthSystem` 引用(在注册时通过 Set 取得),但**不建议跨实体直接调用**。更干净的做法:伤害走"玩家健康"由玩家自己管理,敌人只负责"发起攻击意图"——例如提升一个 `FloatVariable` 或触发 `DamageEvent`。本方案第一步先解决查找问题,第二步再抽伤害意图。
### 3.3 玩家血量显示:反射 → `IntVariable` + 订阅
```csharp
// ❌ 现有:GameHUD.Update 里 typeof(HealthSystem).GetField("health", NonPublic|Instance)
// ✅ HealthSystem 写入 SO 变量;GameHUD 订阅
[SerializeField] private IntVariable _playerHealth; // 拖入 PlayerHealth.asset
private void OnEnable() { _playerHealth.OnValueChanged += UpdateLifeIcons; UpdateLifeIcons(_playerHealth.Value); }
private void OnDisable() => _playerHealth.OnValueChanged -= UpdateLifeIcons;
// UpdateLifeIcons 只读 _playerHealth.Value,零反射、零 Find
```
> `EnemyHealthBar``EnemyHealthBar.cs:50-53,73-75`)同理:让 `HealthSystem` 暴露 `maxHealth`/`health` 的 `IntVariable`,血条订阅刷新,彻底消灭两处反射。
### 3.4 游戏结束:static `onGameOver` → `GameOverEvent` 资产
```csharp
// ❌ HealthSystem.cs:82 → GameManager.GameOver() (反向硬依赖 + 单例)
// ✅ HealthSystem 只管健康;血量归零时触发事件资产
[SerializeField] private GameEvent _onPlayerDied;
// 在 health<=0 且 isPlayer 时:_onPlayerDied.Raise();
// GameOverTransition 组件监听该 Event → 播放过场 → 再 Raise LoadSceneEvent
```
`GameManager` 拆为:`GameStateController`(持有 `GameState` 枚举变量)、`GameOverTransition``VictoryTransition``EnemyPauseOnGameOver`(监听事件,遍历 `EnemyRuntimeSet` 禁用,替代 `FindObjectsOfType<EnemyAI>`)。`onGameWin` 同样改为 `VictoryEvent` 资产,`GameResultScreen` 改为监听该资产事件。
### 3.5 音频:`AudioManager.Instance.PlayX()` → `AudioEvent` 通道
```csharp
// ❌ 各系统:AudioManager.Instance.PlaySFXFromResources("SFX/Bell", 0.9f);
// ✅ 定义 AudioEvent : GameEvent<AudioClip> 资产;系统 RaiseAudioManager 订阅并播放
[SerializeField] private AudioEvent _bellSfx;
// 播放处:_bellSfx.Raise(bellClip); // clip 用 SO 引用或 Addressables,不再 Resources.Load
```
`AudioManager` 的 MonoBehaviour 保留(它需要持有 AudioSource、跑播放逻辑),但**外部不再通过单例调它**——它只是某个 Event 资产的订阅者。
### 3.6 分数:`ScoreManager.onScoreChanged` → `Score IntVariable`
分数本质是"共享值",用 `IntVariable` 比事件更自然。HUD 订阅 `OnValueChanged` 刷新;排行榜逻辑留在 `ScoreManager`(改为写该 Variable)。`ScorePickup`/`SoulDrop` 不再 `ScoreManager.Instance.AddScore(...)`,改为对 `Score IntVariable` `ApplyChange(...)`;缺失时也不会运行时 `new GameObject` 自建。
### 3.7 场景切换 / 暂停:`SceneLoader.Load()` / `TimeController.Pause()` → Event 资产
UI 按钮只 `loadSceneEvent.Raise("Gameplay")` / `pauseEvent.Raise()``SceneLoader``TimeController` 退化为事件订阅者(仍保留 MonoBehaviour 负责协程与 `Time.timeScale`)。场景名放进 SO 配置(`SceneList` 资产),消除 `"Gameplay"/"Scoring"` 魔法字符串。`MainMenuUIController`/`GameLostOverlay`/`ScoringUIController``SceneLoader.Instance` 调用全部改走事件通道。
### 3.8 回声:`EchoSystem.OnEchoReleased` → `Vector3Event` 资产
`EnemyAI.OnBell` 改为订阅 `EchoEvent`(带位置载荷)的 `Register(Action<Vector3>)`,彻底解耦对 `EchoSystem` 类的依赖。
### 3.9 着色器引用:`Shader.Find` → 资产引用(P5
`Shader.Find``Resources.Load` 同类,归入 P5。实际项目中共 **5 处**,应于 Inspector 中将 shader 作为 `Shader` 字段拖入(构建管线可静态识别,避免 shader stripping 将其剔除导致运行时 fallback 成粉色错误着色器):
- `GameLostOverlay.cs:358``Shader.Find("GameFramework/UI/WaterRippleFade")`
- `EchoSystem.cs:140``Shader.Find("IndianOcean/EchoRing")`**已落地 `ringShader` 序列化字段**
- `GroundBuilder.cs:180``Shader.Find("IndianOcean/AbyssEdgeGlow")`
- `CliffWallBuilder.cs:108``Shader.Find("IndianOcean/AbyssEdgeGlow")`
- `GroundClipTool.cs:19``Shader.Find("Custom/SpriteWithGroundClip")`(**Editor 脚本,运行时不进包,可保留,但建议同样改字段引用**)
### 3.10 变量跨局重置(VariableRegistryP0/P1 必做)
SO 变量(IntVariable/FloatVariable 等)在 Editor 下是**跨 Play Mode 域重载持久化**的。若 Play Mode 退出时 `PlayerHealth.Value` 残留 0,下次启动会直接触发死亡——这是 SO 架构常见坑。
已在 `Assets/Architecture/Variables/VariableRegistry.cs` 解决:
- 变量实现 `IVariable` 接口,含 `ResetToDefault()`
- `VariableRegistry``[RuntimeInitializeOnLoadMethod(BeforeSceneLoad)]` 中于每次进入 Play Mode 时统一 `ResetToDefault()`
- `AssetBootstrap` 生成变量时同时写入 `_value``_defaultValue`,保证重置目标正确。
(注:该回调在跨场景加载时只触发一次,故分数等跨场景共享状态可保留。)
---
## 4. 分阶段迁移路线图(Phased Roadmap
每个阶段独立、可回滚、可编译,不要求一次性大改。
| 阶段 | 目标 | 关键动作 | 风险 | 验证 |
|---|---|---|---|---|
| **P0 地基** | 引入通信总线 | 已落地 `Assets/Architecture/`,在 Unity 创建首批 Event/Variable/RuntimeSet 资产 | 极低(增量) | 编译通过,无现有代码改动 |
| **P1 事件去静态化** | 消灭 `static Action` 事件 | 把 `onGameOver/onGameWin/onPlayerDamaged/onScoreChanged/onScoreSettled/OnEchoReleased` 改为对应 SO 事件资产,1:1 替换订阅 | 低 | 各触发点行为不变 |
| **P2 去掉 Find** | 消灭 `GameObject.Find/FindWithTag/FindObjectsOfType` | 建 `PlayerRuntimeSet`/`EnemyRuntimeSet`/`LanternRuntimeSet`,预制体挂 `RuntimeSetRegistrar`(或泛型闭包子类);改写 `GameHUD`/`EnemyAI`/`GameManager`/`MainMenuUIController`/`GameLostOverlay`/`EnemyHealthBar` 的查找与反射 | 中 | Play 模式跑通;`GameHUD` 不再在 Update 里 Find`EnemyHealthBar` 反射消除 |
| **P3 去单例调用方** | 调用方不再 `XxxManager.Instance` | `AudioManager/SceneLoader/TimeController/ScoreManager` 改为事件订阅者;调用方改 `Raise``ScorePickup`/`SoulDrop`/`ScoringUIController` 改走 Variable/Event | 中 | 全功能回归测试 |
| **P4 拆分 God Class** | 单一职责 | `GameManager`→状态机+过场+敌人控制;`HealthSystem`→健康+死亡表现;`PlayerController` 仅轻度重构(剥离 `RequireComponent` 对子系统实现类的硬绑,改由 SO 引用/事件驱动,非重度重写) | 中高 | 单元/手动测试每个拆分组件 |
| **P5 资源与配置** | 去 `Resources.Load`/`Shader.Find` 与魔法串 | 音频/视频→**SerializeField 直接引用(AudioClip/VideoClip 拖到 SO/组件)****不引入 Addressables**(项目体量下工程复杂度过高);`Shader.Find` **共 5 处**→序列化 `Shader` 字段引用(GameLostOverlay:358 / EchoSystem:140 已有 ringShader / GroundBuilder:180 / CliffWallBuilder:108 / GroundClipTool:19 为 Editor 脚本);场景名/标签进 SO 配置 | 中 | 构建后资源不缺失 |
| **P6 工具与守门** | 防回归 | `VariableDrawer`(已落地);加构建期校验脚本(扫描生产代码 `GameObject.Find` 报错);设计师 SO 配置文档 | 低 | CI / 构建时报错拦截 |
**建议节奏**:P0→P1→P2 可在一个迭代内完成(收益最大、风险最低);P3/P4 按系统逐个推进;P5/P6 与功能开发并行。
---
## 5. 设计师赋能(Designer Empowerment
- 所有 SO 均已 `[CreateAssetMenu]`,策划/美术右键即可创建事件、变量、集合,**无需写代码**。
- `VariableDrawer` 让 Inspector 实时显示变量当前值(含 Play 模式),数值调试不再开脚本。
- 事件连线在 Inspector 可见:谁监听 `OnPlayerDamaged`、谁触发 `LoadSceneEvent` 一目了然,便于排查"为什么没反应"。
- 建议建立 `Assets/ScriptableObjects/{Events,Variables,RuntimeSets}` 目录规约,按领域分子文件夹。
---
## 6. 风险与回滚
- 所有新增代码位于 `Assets/Architecture/`,**未触碰任何现有文件**,可整体删除回滚。
- 迁移过程保持"旧接口可用、新接口并行",每个阶段独立验证,避免大爆炸式重写。
- 事件通道为引用语义:误删资产会在 Inspector 显示缺失引用(编译期可查),不会静默失效——比反射/单例安全得多。
---
## 7. 下一步
1. 在 Unity 中创建首批资产:`PlayerHealth`(Int)、`Score`(Int)、`OnPlayerDied`(GameEvent)、`Victory`(GameEvent)、`Echo`(Vector3Event)、`Enemies`(EnemyRuntimeSet)、`Players`(TransformRuntimeSet)。
2. 选一个最小系统(建议从 `GameHUD``PlayerHealth` 变量 + 消灭 `EnemyHealthBar` 反射开始)验证 P1+P2 链路。
3. 跑通后按路线图逐阶段推进;需要我直接改造某个具体系统时,指认文件即可。
+200
View File
@@ -0,0 +1,200 @@
# 解耦架构重构 — Review 清单
> 范围:P0 地基 + P1 事件去静态化 + P2a/P2b 去反射/Find + P5Shader.Find 部分)
> 状态:代码已全部落地(静态校验通过),**尚未在 Unity 内编译/接线/回归**。
> 用法:逐项勾选。每条都标注了「文件 / 风险等级 / 需核对的点」。
---
## 0. 改动总览
| 类别 | 文件数 | 说明 |
|------|--------|------|
| 新增架构框架 | 7 | `Assets/Architecture/` 下:Core / RuntimeSets / Variables / Editor |
| 修改发布方 | 4 | GameManager / HealthSystem / ScoreManager / EchoSystem |
| 修改订阅方 | 5 | GameResultScreen / GameOverScreen / DamageFlashOverlay / ScoreDisplay / EnemyAI |
| 去反射/Find 重写 | 3 | GameHUD / EnemyHealthBar / Player(属性) |
| Shader.Find 修复 | 3 | GameLostOverlay / GroundBuilder / CliffWallBuilder |
| 编辑器工具 | 1 | UIBuilder(自动连线事件资产) |
| 文档 | 2 | `plans/quantum-pulse-turing.md` + `Docs/解耦架构重构方案.md` |
---
## 1. 新增架构框架(设计正确性 · 需重点 review)
**文件**`Architecture/Core/GameEvent.cs``TypedGameEvents.cs``RuntimeSets/RuntimeSet.cs``Variables/VariableRegistry.cs` + `FloatVariable/IntVariable/BoolVariable/Vector3Variable.cs``Editor/VariableDrawer.cs``Editor/AssetBootstrap.cs`
- [ ] **GameEvent 双通道**`Raise()` 先触发代码监听器(`_codeListeners`),再触发 Inspector 监听器(`_listeners`)。顺序是否符合预期?(当前:代码监听优先)
- [ ] **代码监听器泄漏**`Register(Action)` / `Unregister(Action)` 未在 `OnDisable` 对称注销时,组件销毁后事件仍持有引用 → 悬空调用。已要求订阅方在 `OnDisable` 注销,请核对第 3 节。
- [ ] **VariableRegistry 重置时机**`[RuntimeInitializeOnLoadMethod(BeforeSceneLoad)]` 在每次进入 Play Mode 触发 `ResetAll()`。确认该回调在 Editor「停止→再播放」时确实重新执行(域重置后)。
- [ ] **`_defaultValue` 机制**`ResetToDefault()``_value` 恢复为 `_defaultValue``AssetBootstrap` 生成时已同时写 `_value=_defaultValue`;但**策划手工 `CreateAssetMenu` 新建变量时若只改 `_value` 没改 `_defaultValue`,Reset 会回到 0/初始值**——需在约定里提醒。
- [ ] **Reset 触发刷新**`ResetToDefault()``OnValueChanged?.Invoke(_value)`,可能令 UI 在启动瞬间刷新一次——确认无副作用。
- [ ] **RuntimeSetRegistrar 空集合**:若 Prefab 未挂 `RuntimeSetRegistrar` 或未拖 `Enemies` 集合,`GameManager.DisableAllEnemyAI``FindObjectsOfType` 兜底分支(见第 6 节)。
---
## 2. 发布方改造(static 事件 → SO 字段)
- [ ] **GameManager**`:51-66`):`GameOver()`/`Win()` 仍是 `static`,已改 `Instance.onGameOverEvent?.Raise()`,且方法开头 `if (Instance == null) return;` 保护。✓ 已修 CS0120。
- [ ] **HealthSystem**`:62-84`):
- `OnHealthChanged?.Invoke(health, maxHealth)` 在**每次 `Damage`** 触发(`:80`)——敌人血条据此刷新。
- **仅 `isPlayer` 时**写 `healthVar`/`maxHealthVar` + 触发 `onPlayerDamagedEvent``:65-69, 79, 83`)。⚠️ 敌人路径不写全局变量(关键正确性)。
- [ ] **ScoreManager**`:47-48`):`onScoreChanged` / `onScoreSettled` 两个静态事件**均已迁移**到 `scoreChangedEvent` / `scoreSettledEvent``SetScore`/`ResetScore`/`ScoreCountUp` 全部改 `Raise(...)`
- [ ] **EchoSystem**`:25`):`OnEchoReleased``echoReleasedEvent`;新增公共只读属性 `BellCooldown`(供 HUD),`ringShader` 已为序列化字段。
---
## 3. 订阅方改造(Register 替代 `+=` static
- [ ] **GameResultScreen**`:43-44, OnEnable/OnDisable`):`onGameOverEvent.Register(...)` / `onGameWinEvent.Register(...)`OnDisable 反注册。
- [ ] **GameOverScreen**`:27`):`onGameOverEvent.Register(ShowGameOver)`OnDisable 反注册。
- [ ] **DamageFlashOverlay**`:4 using, :32`):`onPlayerDamagedEvent.Register(OnPlayerDamaged)`OnDestroy 反注册。
- [ ] **ScoreDisplay**`:18, OnEnable/OnDisable`):`scoreChangedEvent.Register(UpdateText)`
- [ ] **EnemyAI**`:51, OnEnable/OnDisable`):`echoReleasedEvent.Register(OnBell)`OnDisable 反注册。
- [ ] **生命周期对称**:所有订阅在 `OnEnable`/`Start` 注册、`OnDisable`/`OnDestroy` 注销,避免重复订阅或组件销毁后悬空调用。
---
## 4. 去反射 / 去 Find(关键正确性 · 重点 review)
- [ ] **EnemyHealthBar 重写(最重要)**:改为订阅**同物体** `HealthSystem.OnHealthChanged(health, maxHealth)`**不再读全局 `PlayerHealth`**。修复了原方案「所有敌人共享同一份血量变量」的严重 bug。
- ⚠️ **接线约束**:敌人 Prefab 上的 `HealthSystem``healthVar`/`maxHealthVar`/`onPlayerDamagedEvent` 三个 SO 字段**必须留空**,否则敌人受伤会改写 `PlayerHealth`、污染玩家血条与 HUD。
- [ ] **GameHUD 去反射**
- 生命图标:`playerHealthVar.OnValueChanged += UpdateLifeIcons(int)`(已修 CS0123 签名)。✓
- 技能 CD:读 `PlayerController.RollCooldown` / `EchoSystem.BellCooldown` 公共属性。✓
- ⚠️ **残留 1 处反射**`:181-183`):灵灯最大 CD 仍 `typeof(SpiritLanternSystem).GetField("cooldown", NonPublic|Instance)`,属 P2b 未清完。建议给 `SpiritLanternSystem``public float Cooldown => cooldown;` 后删此反射。
- **残留 Find**`Start` 中仍有一次性 `FindWithTag("Player")``:55`)和 `FindObjectOfType<DamageFlashOverlay>()``:87`)——一次性可接受,非逐帧;若想彻底解耦可改场景引用(留待 P2e)。
- [ ] **Player.cs**:新增 `public (float remaining, float total) RollCooldown` 只读属性,供 HUD 替代反射。✓
---
## 5. Shader.Find → 序列化字段(P5 反馈项)
- [ ] **GameLostOverlay**`:waterRippleShader`):原 `Shader.Find("GameFramework/UI/WaterRippleFade")` 改为 `[SerializeField] Shader waterRippleShader`Inspector 需拖入 `WaterRippleFade.shader`
- [ ] **GroundBuilder**`:edgeGlowMaterial`):原 `Shader.Find("IndianOcean/AbyssEdgeGlow")` 改为序列化 `Shader` 字段,需拖 `AbyssEdgeGlow.shader`
- [ ] **CliffWallBuilder**`:edgeGlowMaterial`):同上,需拖 `AbyssEdgeGlow.shader`
- [ ] **EchoSystem**`ringShader` 字段已存在(确认 Inspector 已拖 `EchoRing.shader`)。
- [ ] **GroundClipTool.cs:19****Editor 脚本**`Shader.Find` 运行时不进包,暂保留;建议后续也改为序列化字段以统一。
---
## 6. 编辑器工具
- [ ] **UIBuilder.cs**`:305-307`):`BuildResultScreen``LoadAssetAtPath<GameEvent>(".../OnGameOver.asset")` 自动连线。⚠️ **前提是先运行 `Architecture > Bootstrap Core Assets` 生成资产**,否则两格为 `null`,需手动拖。
- [ ] **AssetBootstrap.cs**:菜单项 `Architecture/Bootstrap Core Assets`,生成 11 个 SO 资产到 `Assets/Architecture/Assets/`,已存在则跳过。✓ 已修 CS0246(补 `using Architecture.Core;`)。
- [ ] **自动创建的 DamageFlashOverlay**GameHUD.Start 兜底,`:87-91`):该自动实例的 `onPlayerDamagedEvent``null` → 受击不闪红。建议场景**预置**一个手动接好线的 `DamageFlashOverlay`HUD 检测到就不自动建。
---
## 7. Unity 编辑器内必做(手动 · 代码改完 ≠ 能跑)
- [ ] 运行菜单 `Architecture > Bootstrap Core Assets` 生成全部 SO 资产。
- [ ] GameManager`On Game Over Event`/`On Game Win Event` → 对应资产;`Enemies Set``Enemies``Enemy Manager Ref` → 场景 EnemyManager(可空)。
- [ ] **玩家** HealthSystem`On Player Damaged Event``OnPlayerDamaged``Health Var`/`Max Health Var``PlayerHealth`
- [ ] **敌人** HealthSystem:三个 SO 字段**留空**。
- [ ] EchoSystem`Echo Released Event``EchoReleased``Ring Shader``EchoRing.shader`
- [ ] ScoreManager`Score Changed Event``ScoreChanged``Score Settled Event``ScoreSettled`**最易漏**)。
- [ ] GameHUD`Score Changed Event``ScoreChanged``Player Health Var``PlayerHealth`
- [ ] DamageFlashOverlay`On Player Damaged Event``OnPlayerDamaged`
- [ ] ScoreDisplay / GameOverScreen / GameResultScreen`Score Changed Event` / `On Game Over Event` / `On Game Win Event` 对应资产(若用 UIBuilder 搭建则 GameResultScreen 已自动连)。
- [ ] EnemyAI`Echo Released Event``EchoReleased`
- [ ] 敌人 Prefab 挂 `RuntimeSetRegistrar`Set→`Enemies`);Player Prefab 挂 `RuntimeSetRegistrar`Set→`Players`,当前无代码消费,可延后)。
---
## 8. 编译 / 运行时验证
- [ ] **编译全绿**:已修 `GameManager` CS0120、`GameHUD` CS0123、`VariableDrawer` CS0246、`UIBuilder` CS0246;全仓排雷确认其余引用架构类型的文件已带齐 `using`
- [ ] **Play Mode 回归**
- [ ] 左上角魂灵数随吃魂变化 → `ScoreChanged` 链路通。
- [ ] 左下角生命图标随受伤减少 → `PlayerHealth` 链路通。
- [ ] 受伤时屏幕红边闪 → `OnPlayerDamaged` + DamageFlashOverlay。
- [ ] 按 E 摇铃,附近敌人朝铃铛移动 → `EchoReleased` 链路通。
- [ ] 玩家死亡 → 失败过场 + 敌人 AI 被禁用 → `OnGameOver` + `Enemies` 集合。
- [ ] **状态泄漏验证**:退出 Play Mode 再进一次,血量应从 5 开始(`VariableRegistry` 重置生效,无跨局泄漏)。
---
## 9. 已知风险 & 遗留 TODO
| 项 | 位置 | 说明 | 阶段 |
|----|------|------|------|
| MainMenuUIController 对象名耦合 | `FindButton`/`GameObject.Find` by name | 改 Inspector 引用或 SO 配置 | P2e |
| EnemyAI.cs:112 一次性 FindWithTag | Start 中 | 待 Players 集合接入后移除 | P2e |
| 单例调用方 | ScorePickup / SoulDrop 等 | 改走 SO 事件/变量 | P3 |
| God Class 拆分 | PlayerController 等 | 按 SRP 拆组件 | P4 |
| 构建期守门脚本 | 新 Editor 校验 | 静态扫描 GameObject.Find / 静态单例引用 | P6 |
> 已本轮解决:灵灯 CD 反射(改为 `SpiritLanternSystem.Cooldown` 属性)、GameLostOverlay 直调(改订阅 OnGameOver)、GameOverScreen 空响应(已删除)、DamageFlashOverlay 空接线(OnEnable 警告 + 场景预置)。
---
## 10. 回滚说明
- 所有新增代码位于 `Assets/Architecture/`,与现有游戏代码零耦合,可整体删除回滚。
- 对现有文件的修改(发布/订阅方、去反射、Shader.Find)为就地改写;如需回滚,请用 Git 版本对比 `Assets/` 下被改文件。
- 生成的 SO 资产位于 `Assets/Architecture/Assets/`,可安全删改、重新 `Bootstrap`
---
## 11. 第二轮 Review 意见处理记录
针对两份 review 意见(严重项 + 顺手修项),已全部落地。改动文件清单:
### 严重(需修复)
| # | 意见 | 处理 | 文件 |
|---|------|------|------|
| 1 | VariableRegistry.ResetAll 时序:BeforeSceneLoad 时 SO 尚未注册导致重置无效 | `Register()` 内立即调用 `variable.ResetToDefault()`,变量「上线」即重置;保留 `ResetAll` 作兜底 | `Architecture/Variables/VariableRegistry.cs` |
| 2 | HealthSystem 直接调 `GameManager.GameOver()` | 改为 Raise `OnPlayerDied` SO 事件;`GameManager` 订阅并触发 `GameOver()` | `HealthSystem.cs` / `GameManager.cs` |
| 3 | GameLostOverlay 被 `GameManager` 直接调用 | 改为订阅 `OnGameOver` 事件自激活;删除静态 `Show()``_instance`/`FindObjectOfType` | `GameLostOverlay.cs` / `GameManager.cs` |
| 4 | GameResultScreen / GameOverScreen 空响应 | `GameResultScreen` 删除空 `OnGameOver`(只负责胜利);`GameOverScreen` 无引用、已删除 | `GameResultScreen.cs`(删字段/订阅/空方法)、`GameOverScreen.cs`(删除)、`UIBuilder.cs`(移除 onGameOver 接线防 NRE |
### 顺手修(建议项)
| # | 意见 | 处理 | 文件 |
|---|------|------|------|
| 5 | GameEvent.Raise 顺序:Inspector 优先于代码 | `Raise()` 先触发 `_listeners`Inspector/UnityEvent),再 `_codeListeners` | `Architecture/Core/GameEvent.cs` |
| 6 | 灵灯反射残留 | `SpiritLanternSystem``public float Cooldown`GameHUD 灵灯 CD 改读该属性,删最后一处反射 | `SpiritLanternSystem.cs` / `GameHUD.cs` |
| 7 | GameHUD 延迟订阅逻辑 | 移除 `Update` 中逐帧 `ScoreManager.Instance` 延迟订阅,改为 `Start` 无条件注册事件 | `GameHUD.cs` |
| 8 | ScoreDisplay 初始值直接访问 `ScoreManager.Instance` | 初始值改走事件(显示 0,首个 `ScoreChanged` 刷新),不再访问单例 | `ScoreDisplay.cs` |
### 新增接线要求(Unity 编辑器内)
- `HealthSystem`(玩家):新增 `On Player Died Event``OnPlayerDied` 资产。
- `GameManager`:新增 `On Player Died Event``OnPlayerDied` 资产。
- `GameLostOverlay`Gameplay 场景物体):新增 `On Game Over Event``OnGameOver` 资产。
- `DamageFlashOverlay`**场景预置已接线实例**(拖 `On Player Damaged Event``OnPlayerDamaged`),否则受击不闪红(已加空接线警告)。
- `GameResultScreen`:仅 `On Game Win Event``OnGameWin`(不再有 `On Game Over Event` 字段)。
- `GameOverScreen` 已从项目删除:若旧场景/预制体仍挂该组件,请在 Unity 中移除该组件(避免 Missing Script)。
---
## 12. Play Mode 回归(用户实测)与诊断加固
### 回归结果(03:15
| 链路 | 结果 |
|------|------|
| 魂灵数 ← ScoreChanged | ✅ |
| 生命图标 ← PlayerHealth | ✅ |
| 受击泛红 ← OnPlayerDamaged + DamageFlashOverlay | ❌ **接线缺口** |
| 摇铃引敌 ← EchoReleased | ❌ **接线缺口** |
| 玩家死亡过场 ← OnPlayerDied→OnGameOver + Enemies 集合 | ✅ |
| 退出再进血量=5VariableRegistry 重置) | ✅ |
### 根因(两处失败均为 SO 事件资产未接,非代码 bug)
- **OnPlayerDamaged**`HealthSystem.Damage` 第87行 `onPlayerDamagedEvent?.Raise()` 逻辑正确(同机制 OnPlayerDied 已通),断链在 HealthSystem 或/且 DamageFlashOverlay 的 `On Player Damaged Event` 字段为 `null`
- **EchoReleased**`EchoSystem.StartEcho` 第220行 `echoReleasedEvent?.Raise(p)``EnemyAI.OnEnable` `Register(OnBell)` 均正确(Vector3Event 签名匹配),断链在 EchoSystem 或/且 **敌人预制体** EnemyAI 的 `Echo Released Event` 字段为 `null`(最可能是玩家预制体的 EchoSystem 未接)。
### 诊断加固(已落地,无需再改代码)
- `HealthSystem` / `EchoSystem` / `EnemyAI` / `DamageFlashOverlay` 的事件字段加 `OnValidate()`**编辑器内即刻在 Inspector 显示黄色警告三角 + 控制台告警**,精确锁定漏接组件。
- Raise 处加一次性空引用 `Debug.LogWarning`(运行时不刷屏)。
- `GameHUD` 不再自动 new 一个 event=null 的 DamageFlashOverlay,改为仅告警,避免掩盖「未接线」。
### 用户需做的接线修复(看 OnValidate 警告最准)
1. 玩家预制体 `HealthSystem``On Player Damaged Event``OnPlayerDamaged`
2. 场景中的 `DamageFlashOverlay``On Player Damaged Event``OnPlayerDamaged`(若场景没有该物体,先放一个)。
3. 玩家预制体 `EchoSystem``Echo Released Event``EchoReleased`
4. **敌人预制体**Project 里的 prefab,不是场景临时实例):`EnemyAI.Echo Released Event``EchoReleased`
5. 重新编译 + 进 Play Mode,先确认控制台无上述 `[HealthSystem]/[EchoSystem]/[EnemyAI]/[DamageFlashOverlay]` 黄色警告,再测受击与摇铃。