Files
gold_dolphin/unity/Docs/解耦架构重构方案.md
2026-07-07 03:34:56 +08:00

265 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 解耦架构重构方案(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. 跑通后按路线图逐阶段推进;需要我直接改造某个具体系统时,指认文件即可。