# zeling_v2 架构深度评审:遗留架构级问题全面分析 > **版本**:2026-05-12 > **范围**:原评审文档 `MasterReview_2025_PostFix.md` 中标注"暂缓(架构级)"的三项: > - **A1** 单例/ServiceLocator 混用(SaveManager / GlobalObjectPool / MapManager) > - **A2** EnemyStateType 枚举不可无侵入扩展 > - **A3** HurtBox 依赖注入不可 Inspector 可见 > > **评分方法**:以商业 2D 动作游戏(Hollow Knight / Celeste / Hades / Dead Cells)的代码实现为参照,从 > **架构设计 · 性能 · 可扩展性 · 编辑器友好 · 使用便利性(DX)** 五维度逐项评估。 --- ## 目录 1. [A1:单例 vs ServiceLocator 混用](#a1-单例-vs-servicelocator-混用) 2. [A2:EnemyStateType 枚举与 POCO FSM](#a2-enemystatetype-枚举与-poco-fsm) 3. [A3:HurtBox 依赖注入可观测性](#a3-hurtbox-依赖注入可观测性) 4. [综合结论与优先级矩阵](#综合结论与优先级矩阵) --- ## A1:单例 vs ServiceLocator 混用 ### 1.1 现状完整梳理 #### 1.1.1 SaveManager ``` 文件:Assets/Scripts/Core/Save/SaveManager.cs 程序集:BaseGames.Core.Save ``` **实例访问方式**:`SaveManager.Instance`(42 处调用点,横跨 12 个文件) | 调用文件 | 调用场景 | |---------|---------| | EventChainSO.cs | GetFlag / SetFlag(存档标志读写) | | EventChainManager.cs | GetCompletedChains / SetChainCompleted | | ShopController.cs | Register / Unregister(ISaveable) | | MapPin.cs | Register / Unregister(ISaveable) | | MapManager.cs | Register / Unregister(ISaveable) | | DifficultyManager.cs | Register / Unregister(ISaveable) | | QuestManager.cs | Register / Unregister(ISaveable) | | ChallengeRoomManager.cs | QuickSave / QuickLoad / IsFirstClear | | DeathRespawnService.cs | SaveAsync + checkpoint 更新 | | ProgressLock.cs | IsBossDefeated / IsDoorOpened | | HPContainerPickup.cs | IsWorldCollected / GetPlayerMaxHP | | TutorialManager.cs | ISaveable 自注册(已修复 GetData 调用) | **关键发现**: 1. `ISaveService` 接口(`Assets/Scripts/Core/ISaveService.cs`)**已定义但从未有任何类实现**。 SaveManager 的方法签名与 ISaveService 不完全一致(`HasSave` vs `SlotExists`,`QuickLoadAsync` vs `QuickLoad`)。 2. `SaveManager` 提供了远超 ISaveService 定义的丰富 API(GetFlag/SetFlag/IsWorldCollected/IsBossDefeated 等),这些业务语义方法若放入接口则接口过重,若留在具体类则调用方必须引用具体类型,破坏接口分离原则。 3. 所有 `Register/Unregister` 调用均使用 `SaveManager.Instance?.Register(this)` 模式;由于可能在 `OnEnable` 时调用而此时 Persistent 场景尚未加载,`?.` 空检查仅掩盖了潜在的竞态:**若先加载游戏场景再加载 Persistent 场景,ISaveable 实现者的注册将静默丢失**。 #### 1.1.2 GlobalObjectPool ``` 文件:Assets/Scripts/Core/Pool/GlobalObjectPool.cs 程序集:BaseGames.Core.Pool ``` **实例访问方式**:`GlobalObjectPool.Instance`(6 处调用点) | 调用文件 | 调用场景 | |---------|---------| | RangedEnemy.cs | Spawn(弹幕生成) | | BD_SpawnProjectile.cs | Spawn(BD 节点) | | BD_SummonMinions.cs | Spawn(召唤小怪) | | TelegraphSystem.cs | Spawn(预警 VFX) | | Projectile.cs | Despawn(弹幕回池) | | AssetReleaseTracker.cs | ClearPool(卸载场景资产) | **关键发现**: 1. **无服务接口(IObjectPoolService 未定义)**。Behavior Designer 节点中直接 `GlobalObjectPool.Instance.Spawn(...)`,若需要在测试或热更时替换 Pool 实现,无法注入 mock。 2. 调用方均检查 `pool == null` 或使用 `GlobalObjectPool.Instance?.Spawn`,说明开发者已意识到潜在的空引用,但用空检查代替了正确的依赖声明。 3. 对象池本身质量非常高(Addressables + LRU + MaxCount + BackgroundRefill),服务访问层是唯一短板。 #### 1.1.3 MapManager ``` 文件:Assets/Scripts/World/Map/MapManager.cs 程序集:BaseGames.World.Map(通过 asmdef) ``` **实例访问方式**:`MapManager.Instance`(1 处:MapPanel.cs line 55) **关键发现**: 1. 仅 1 处调用,属于**最轻量**的迁移目标。 2. `MapManager` 本身已用 SO 事件订阅(`_onRoomEntered`)驱动,架构已较好;单例仅是 `MapPanel` UI 的反向查询入口。 3. `MapManager` 还自注册 `SaveManager.Instance?.Register(this)`,与 A1-SaveManager 问题耦合。 --- ### 1.2 五维度评估 | 维度 | SaveManager(现状) | GlobalObjectPool(现状) | MapManager(现状) | 商业参照 | |------|-------------------|------------------------|------------------|---------| | **架构设计** | 接口已定义但未实现,单例与SL并存,依赖层次混乱 | 无接口,直接耦合,无法 mock | 单例仅1处,相对可接受 | Hades:GameProviders 字典注册,通过泛型 Get 访问;Dead Cells:服务容器完全解耦 | | **性能** | 无问题;ISaveable 注册/取消注册 O(1)(List) | 无问题;对象池本身是高性能实现 | 无问题 | 无差距 | | **可扩展性** | 添加新服务层(云存档、本地存档)需修改 SaveManager 具体实现 | 替换 Pool 后端(从 Addressables 迁移到其他资管)影响所有调用方 | 低风险 | Hades 可运行时切换 Provider 实现 | | **编辑器友好** | ⚠️ 单元测试无法 mock SaveManager(无接口);ServiceLocator.OverrideForTest 不可用 | ⚠️ BD 节点测试依赖 GlobalObjectPool.Instance 存在 | 无问题 | Celeste 通过接口注入,单元测试全覆盖 | | **使用便利性** | 短期便利;长期积累的跨模块直接耦合 | 简单直接,Spawn 一行 | 无问题 | — | #### 架构问题核心诊断 ``` 目前代码中存在两套并行的服务查找机制: 调用方 A(新代码):ServiceLocator.Get() 调用方 B(遗留代码):SaveManager.Instance.XXX() 这不是「单例坏而 SL 好」的价值判断,而是混用导致: 1. 开发者需同时理解两套规则 2. 单元测试需要同时设置两套 mock 环境 3. 循环依赖风险(SaveManager 调用 ServiceLocator, ServiceLocator 注册的服务又调用 SaveManager.Instance) ``` --- ### 1.3 改造路径与工作量评估 #### 方案 A:最小改动——SaveManager 实现并注册 ISaveService **核心思路**:让 SaveManager 实现 ISaveService,在 GameServiceRegistrar 中注册。 **不改动**:所有 42 个 `SaveManager.Instance.XXX()` 调用点保持不变(ISaveService 只是额外接口)。 ```csharp // 1. SaveManager 实现接口(需对齐签名差异) public class SaveManager : MonoBehaviour, ISaveService { // ISaveService.HasSave → 映射到 SlotExists public bool HasSave(int slot) => SlotExists(slot); public int ActiveSlot => _currentSlot; public async Task QuickLoadAsync() => await LoadAsync(_currentSlot); // ... 其余方法已有实现 } // 2. GameServiceRegistrar.Awake 追加: if (_saveManager != null) ServiceLocator.Register(_saveManager); ``` **工作量**:约 0.5 人天 **效果**:新代码可通过 ServiceLocator 访问存档接口;旧调用点无变化 **局限**:SaveManager 具体类仍被直接引用(ISaveService 未完全隔离其丰富 API) --- #### 方案 B:完整迁移——业务语义方法归入专属接口 **核心思路**:将 `GetFlag/SetFlag/IsBossDefeated` 等业务语义方法抽象为专属接口,调用方通过 ServiceLocator 访问。 ```csharp // 扩展接口(按领域分组,避免臃肿接口) public interface ISaveQueryService { bool GetFlag(string flagId); void SetFlag(string flagId, bool value); bool IsBossDefeated(string bossId); bool IsDoorOpened(string id); bool IsWorldCollected(string id); int GetPlayerMaxHP(); bool IsFirstClear(string challengeId); IEnumerable GetCompletedChains(); void SetChainCompleted(string chainId); } ``` **工作量**:约 2 人天(接口定义 + 42 处调用点修改 + 单元测试补充) **效果**:完全解耦;可 mock;可替换 SaveManager 后端 **局限**:调用方代码更冗长(`ServiceLocator.Get().GetFlag(...)`) --- #### 方案 C:GlobalObjectPool 接口化 ```csharp // 新增接口 public interface IObjectPoolService { T Spawn(string key, Vector3 position, Quaternion rotation) where T : Component; GameObject Spawn(string key, Vector3 position, Quaternion rotation); void Despawn(string key, PooledObject po); void ClearPool(string key); } // GlobalObjectPool 实现接口并在 GameServiceRegistrar 注册 ``` **工作量**:约 0.5 人天 **效果**:6 处调用点改为 `ServiceLocator.GetOrDefault()?.Spawn(...)` **BD 节点特殊处理**:BD 节点仍使用 `GlobalObjectPool.Instance`(BD 节点运行在 `#if GRAPH_DESIGNER` 下,测试环境不涉及) --- #### 方案 D:MapManager 迁移(最简单) ```csharp // MapManager.Awake 追加: ServiceLocator.Register(this); // 或定义 IMapService // MapPanel.cs 修改(唯一调用点): var mapManager = ServiceLocator.Get(); ``` **工作量**:约 0.1 人天 **效果**:消除单例;MapPanel 通过 SL 获取依赖,可在测试中 mock --- #### 建议迁移顺序 ``` 优先级 1(低风险高收益):MapManager → 方案 D(0.1 人天) 优先级 2(必要准备):SaveManager → 方案 A(0.5 人天,为新代码开路) 优先级 3(中期):GlobalObjectPool → 方案 C(0.5 人天) 优先级 4(长期):SaveManager → 方案 B(仅当测试覆盖率目标需要时) ``` --- ### 1.4 ISaveable 注册竞态问题(独立缺陷) > 独立于服务定位器问题,现状存在潜在竞态。 **问题**:`ShopController.OnEnable()` 中调用 `SaveManager.Instance?.Register(this)`。 若加载顺序为 Game Scene → Persistent Scene,`SaveManager.Instance` 在 OnEnable 时为 null, `?.` 静默跳过,导致该组件的 `OnSave/OnLoad` 永不被调用,存档静默丢失。 **现状评估**:Unity 通常先加载 Persistent 再加载 Game Scene,因此实际游戏中未必触发; 但在热重载、编辑器快速进入单场景等情况下可复现。 **修复方案**(不在本文档实施,单独记录): ```csharp // 选项 1:ISaveManager 广播 Ready 事件,ISaveable 实现者在事件中注册 // 选项 2:SaveManager.Register 改为延迟注册(队列),实例化后批量 Register // 选项 3:改用 Unity SceneManager.sceneLoaded 保证 Persistent 最先加载 ``` --- ## A2:EnemyStateType 枚举与 POCO FSM ### 2.1 现状完整梳理 #### 当前架构 ```csharp // EnemyBase.cs 末尾定义 public enum EnemyStateType { Controlled, Hurt, Stagger, Dead } // EnemyBase 核心状态机 private EnemyStateType _currentState; public void ForceState(EnemyStateType newState) { _currentState = newState; if (newState == EnemyStateType.Hurt) { var animState = _animancer.Play(_animConfig.Hurt); animState.Events(this).OnEnd = () => { if (_currentState == EnemyStateType.Hurt) _currentState = EnemyStateType.Controlled; }; } // Stagger / Dead / Controlled 无独立动画/逻辑处理 } ``` #### 外部使用点 | 使用位置 | 方式 | 问题 | |---------|------|------| | FlyingEnemy.Update | `if (CurrentState == EnemyStateType.Dead \|\| CurrentState == EnemyStateType.Stagger)` | 状态组合判断写在子类中 | | FlyingEnemy.OnTriggerStay2D | `if (CurrentState == EnemyStateType.Dead)` | 重复枚举值比较 | | EnemyBase.TakeDamage | `if (_currentState == EnemyStateType.Dead) return;` | 同上 | | EnemyBase.Die | `if (_currentState == EnemyStateType.Dead) return;` | 同上 | | BD_IsStateMatch | `(int)_enemy.CurrentState == TargetState.Value` | **整数转枚举**(脆弱绑定) | #### 最危险的反模式:BD_IsStateMatch ```csharp // BD 编辑器中,设计师输入整数 0/1/2/3 对应状态 // 若枚举值顺序改变(如在中间插入 Frozen),则所有 BD 图立刻行为错乱 // 且不会产生任何编译错误或运行时警告 public SharedInt TargetState = new SharedInt { Value = 0 }; return (int)_enemy.CurrentState == TargetState.Value // ← 极脆弱 ``` --- ### 2.2 五维度评估 | 维度 | 当前枚举方案 | POCO FSM 方案 | 商业参照 | |------|------------|--------------|---------| | **架构设计** | 状态行为(动画、过渡逻辑)分散在 ForceState 中的 if-else 内 | 每个状态封装自己的 Enter/Update/Exit,职责清晰 | HK:每个敌人独立手写 C# 状态类;Dead Cells:POCO + 组合式 FSM | | **性能** | 枚举比较 O(1),内存零开销 | 虚方法调用 + 多态分发,可忽略(仅 Enter/Exit 时调用)| 无显著差距 | | **可扩展性** | **⚠️ 枚举修改(插入中间值)破坏 BD 数值绑定** | 新增状态只需添加新类,不影响现有代码 | 商业游戏普遍使用后者 | | **编辑器友好** | BD_IsStateMatch 使用魔法整数,设计师无类型保护 | 可用字符串/类型名绑定,或用枚举 + POCO 双层架构 | Dead Cells 使用字符串 ID + 反射注册 | | **使用便利性** | `ForceState(EnemyStateType.Hurt)` 简洁 | `_stateMachine.TransitionTo()` 稍冗长但语义更清晰 | — | #### 现状问题的严重程度分级 ``` 🔴 高风险:BD_IsStateMatch 整数绑定 → 改变枚举顺序 = 静默行为错误,无编译保护 → 修复成本低,影响高 🟡 中风险:状态行为逻辑在 ForceState if-else 中增长 → 当前 4 个状态逻辑简单,if-else 可控 → 若添加 Frozen/ElectroStun/Berserk 等状态,ForceState 将膨胀至 100+ 行 🟢 低风险:各 EnemyBase 子类内联枚举比较 → 已控制在 2-3 处,无碍可读性 ``` --- ### 2.3 渐进迁移路径 #### 阶段 0(立刻执行):修复 BD_IsStateMatch 魔法整数 ```csharp // 方案:改用 SharedString + Enum.Parse,保持 BD 数值向后兼容 public class BD_IsStateMatch : Conditional { /// /// 目标状态名称(Inspector 输入 "Controlled"/"Hurt"/"Stagger"/"Dead")。 /// 使用字符串而非整数,枚举重排时 BD 图不失效。 /// public SharedString TargetStateName = new SharedString { Value = "Controlled" }; private EnemyBase _enemy; public override void OnStart() => _enemy = GetComponent(); public override TaskStatus OnUpdate() { if (_enemy == null) return TaskStatus.Failure; if (!System.Enum.TryParse(TargetStateName.Value, out var target)) { Debug.LogError($"[BD_IsStateMatch] 未知状态名: '{TargetStateName.Value}'"); return TaskStatus.Failure; } return _enemy.CurrentState == target ? TaskStatus.Success : TaskStatus.Failure; } } ``` **工作量**:0.2 人天(含更新现有 BD 图中的节点值为字符串) --- #### 阶段 1(中期):引入 IEnemyState 接口,双轨并行 ```csharp // 接口:最小化 public interface IEnemyState { EnemyStateType StateType { get; } // 兼容:枚举值保留 void Enter(EnemyBase owner); void Exit(EnemyBase owner); } // 具体实现(示例:Hurt 状态) public sealed class EnemyHurtState : IEnemyState { public EnemyStateType StateType => EnemyStateType.Hurt; public void Enter(EnemyBase owner) { if (owner.Animancer != null && owner.AnimConfig?.Hurt != null) { var s = owner.Animancer.Play(owner.AnimConfig.Hurt); s.Events(owner).OnEnd = () => { if (owner.CurrentState == EnemyStateType.Hurt) owner.ForceState(EnemyStateType.Controlled); }; } } public void Exit(EnemyBase owner) { } } // EnemyBase 内部:POCO + 枚举双轨 private IEnemyState _currentStateObj; private readonly Dictionary _stateObjs = new(); protected virtual void Awake() { _stateObjs[EnemyStateType.Controlled] = new EnemyControlledState(); _stateObjs[EnemyStateType.Hurt] = new EnemyHurtState(); _stateObjs[EnemyStateType.Stagger] = new EnemyStaggerState(); _stateObjs[EnemyStateType.Dead] = new EnemyDeadState(); } public void ForceState(EnemyStateType newState) { _currentStateObj?.Exit(this); _currentState = newState; if (_stateObjs.TryGetValue(newState, out var next)) next.Enter(this); else Debug.LogWarning($"[EnemyBase] 未找到状态对象: {newState}", this); } ``` **工作量**:约 1.5 人天(EnemyBase 重构 + 4 个状态类 + FlyingEnemy 等子类检查) **优势**: - 枚举 API 对外不变(`ForceState(EnemyStateType.XXX)` 保持) - BD 图兼容(BD_IsStateMatch 使用字符串后也兼容) - 新增状态(如 Frozen)只需新增类 + 注册,不破坏现有代码 - 子类可重写 `Awake` 替换默认状态对象实现 --- #### 阶段 2(长期,可选):状态改为可组合的 Capability 组件 ```csharp // 当敌人状态数量超过 8 个时,考虑转为 ECS-style 组件化 // 不建议现阶段实施(过度工程) ``` --- ### 2.4 结论 | 修复项 | 优先级 | 建议时机 | |-------|--------|---------| | BD_IsStateMatch 字符串化 | **P2(应尽早执行)** | 下次修改 BD 图前 | | IEnemyState POCO 阶段 1 | P2 | Boss 数量超过 3 个时 | | 阶段 2 组件化 | P3 | 遥远未来 | --- ## A3:HurtBox 依赖注入可观测性 ### 3.1 现状完整梳理 #### 注入机制全景图 ``` HurtBox(Combat程序集) ├── _owner(IDamageable) │ └── 注入:Awake() GetComponentInParent() ← 自动,可靠 ├── _statusEffectable(IStatusEffectable) │ └── 注入:Awake() GetComponentInParent() ← 自动,可靠 ├── _shieldable(IShieldable) │ └── 注入:PlayerController.Awake() → hurtBox.SetShieldable(shield) ← 外部注入 ├── _parrySystem(ParrySystem) │ └── 注入:PlayerController.Awake() → hurtBox.SetParrySystem(parrySystem) ← 外部注入 └── _poiseSource(IPoiseSource) ├── 玩家:PlayerController.Awake() → hurtBox.SetPoiseSource(this) ← 外部注入 └── 敌人:EnemyPoiseComponent.Awake() → hurtBox.SetPoiseSource(this) ← 外部注入(RequireComponent) ``` #### 问题的实质 当前所有注入字段均为 `private`,Inspector 不显示,**只有在运行时进入 PlayMode 并选中 HurtBox 才能通过 IMGUI 的私有字段调试工具发现注入是否完成**。 在以下场景中,注入可能静默失败: 1. `PlayerController` 没有 `_hurtBox` 引用(Inspector 未绑定)→ SetShieldable 等不会被调用 2. 新建敌人 Prefab 忘记挂载 `EnemyPoiseComponent` → `_poiseSource` 为 null,霸体系统默认无保护 3. `_parrySystem` 未注入(玩家处于无护盾模式)→ 弹反静默不生效 这三种情况目前没有任何运行时日志提示,也没有 Inspector 可见性。 #### 与商业游戏对比 | 游戏 | HurtBox 等效组件注入方式 | |------|------------------------| | Hollow Knight(逆向分析) | `[SerializeField]` 直接绑定,Inspector 全可见;接口字段用工具类包装成 MonoBehaviour | | Dead Cells | 组件化设计,每个能力为独立组件,Inspector 天然可见 | | Hades | 数据驱动,能力通过 ScriptableObject 配置,无运行时注入歧义 | | Celeste(TASer 分析) | 玩家组件内联,无 HurtBox 分离层;避免了此问题 | --- ### 3.2 五维度评估 | 维度 | 当前方案 | 评分(1–5) | 说明 | |------|---------|------------|------| | **架构设计** | 接口注入设计正确,隔离层次清晰 | 4/5 | 仅调试可见性缺失 | | **性能** | 无问题;字段直接访问,零开销 | 5/5 | — | | **可扩展性** | 添加新能力(如 DodgeSystem)只需新增 Set 方法 | 4/5 | 略冗长但可接受 | | **编辑器友好** | 私有字段 Inspector 不可见,调试困难 | **2/5** | **核心痛点** | | **使用便利性** | 注入失败无提示,行为静默降级(弹反不生效等) | **2/5** | **核心痛点** | --- ### 3.3 修复方案 #### 方案 A(推荐):Editor-Only 调试显示属性 + Awake 注入日志 **核心**:不改变运行时架构,仅增加 Editor 可见性和警告日志。 ```csharp // HurtBox.cs 修改 public class HurtBox : MonoBehaviour { // ── 注入字段(不变) ──────────────────────────────────────────────────── private IDamageable _owner; private IShieldable _shieldable; private ParrySystem _parrySystem; private IPoiseSource _poiseSource; private IStatusEffectable _statusEffectable; // ... 注入方法不变(SetShieldable / SetParrySystem 等)... private void Awake() { _owner = GetComponentInParent(); _statusEffectable = GetComponentInParent(); if (_owner == null) Debug.LogWarning($"[HurtBox] {name}: 父节点中未找到 IDamageable 实现。", this); } // ── Editor 调试(运行时不产生任何开销)───────────────────────────────── #if UNITY_EDITOR /// /// 运行时 Inspector 注入状态总览。 /// 绿色 = 注入完成;红色 = 未注入(该能力不可用)。 /// [UnityEngine.Serialization.FormerlySerializedAs("_debugInjectionStatus")] [Header("── 运行时注入状态(Editor Only)──")] [SerializeField, HideInInspector] private string _dbgOwner; [SerializeField, HideInInspector] private string _dbgShieldable; [SerializeField, HideInInspector] private string _dbgParrySystem; [SerializeField, HideInInspector] private string _dbgPoiseSource; private void Update() { // 仅在编辑器 PlayMode 下每帧刷新调试字段(无 IL2CPP strip 风险,因受 #if UNITY_EDITOR 保护) _dbgOwner = _owner != null ? $"✓ {_owner.GetType().Name}" : "✗ null (注入失败)"; _dbgShieldable = _shieldable != null ? $"✓ {_shieldable.GetType().Name}" : "— (玩家专属,敌人无需)"; _dbgParrySystem = _parrySystem != null ? $"✓ {_parrySystem.name}" : "— (弹反不生效)"; _dbgPoiseSource = _poiseSource != null ? $"✓ {_poiseSource.GetType().Name}" : "— (霸体系统不生效)"; } #endif } ``` **工作量**:约 0.3 人天 **效果**:在 PlayMode 的 Inspector 中可直接看到所有依赖的注入状态,方便调试 --- #### 方案 B(加强):自定义 Editor 面板 ```csharp // Assets/Scripts/Editor/HurtBoxEditor.cs #if UNITY_EDITOR using UnityEditor; using BaseGames.Combat; [CustomEditor(typeof(HurtBox))] public class HurtBoxEditor : Editor { public override void OnInspectorGUI() { DrawDefaultInspector(); if (!Application.isPlaying) return; var hb = (HurtBox)target; EditorGUILayout.Space(); EditorGUILayout.LabelField("── 运行时注入状态 ──", EditorStyles.boldLabel); // 使用反射读取私有字段(仅 Editor 工具代码中允许) DrawInjectionStatus("Owner (IDamageable)", "_owner"); DrawInjectionStatus("Shieldable", "_shieldable"); DrawInjectionStatus("ParrySystem", "_parrySystem"); DrawInjectionStatus("PoiseSource", "_poiseSource"); DrawInjectionStatus("StatusEffectable", "_statusEffectable"); } private void DrawInjectionStatus(string label, string fieldName) { var field = typeof(HurtBox).GetField(fieldName, System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); var value = field?.GetValue(target); bool injected = value != null; var color = injected ? new UnityEngine.Color(0.3f, 0.9f, 0.3f) : new UnityEngine.Color(0.9f, 0.3f, 0.3f); var style = new GUIStyle(EditorStyles.label); style.normal.textColor = color; string displayName = injected ? $"✓ {value.GetType().Name}" : "✗ null"; EditorGUILayout.LabelField(label, displayName, style); } } #endif ``` **工作量**:约 0.5 人天 **效果**:完全的自定义调试面板,无需 HurtBox.cs 侵入任何 `#if UNITY_EDITOR` 代码 --- #### 方案 C(激进,不推荐现阶段):注入点改为 [SerializeField] ``` 结论:不适用于当前架构。 HurtBox 属于 Combat 程序集,而: - IShieldable 的实现(ShieldComponent)在 Player 程序集 - ParrySystem 在 Parry 程序集 - IPoiseSource 实现在 Player 和 Enemies 两个程序集 若 HurtBox 持有 [SerializeField] ParrySystem _parrySystem, 则 Combat 程序集需直接依赖 Parry 程序集,违反当前分层规则。 → 保持接口注入,添加 Editor 可见性(方案 A 或 B)是最优解。 ``` --- ### 3.4 结论 | 修复项 | 优先级 | 建议方案 | 工作量 | |-------|--------|---------|--------| | HurtBox Editor 调试可见性 | P3 | 方案 B(Custom Editor,零运行时侵入) | 0.5 人天 | | HurtBox Awake 注入失败警告 | P3(已存在 _owner 警告,其余字段补充) | 追加 `Debug.LogWarning` | 0.1 人天 | --- ## 综合结论与优先级矩阵 ### 总览评分 | 问题 | 架构设计 | 性能 | 可扩展性 | 编辑器友好 | DX | 综合 | 商业差距 | |------|---------|------|---------|-----------|-----|------|---------| | A1 SaveManager 单例 | 2.5/5 | 5/5 | 3/5 | 2/5 | 3/5 | **3.1/5** | 中 | | A1 GlobalObjectPool 单例 | 3/5 | 5/5 | 3/5 | 3/5 | 4/5 | **3.6/5** | 小 | | A1 MapManager 单例 | 4/5 | 5/5 | 4/5 | 4/5 | 4/5 | **4.2/5** | 极小 | | A2 枚举 FSM(BD 整数绑定) | 2/5 | 5/5 | 2/5 | **1/5** | 3/5 | **2.6/5** | 大 | | A2 枚举 FSM(整体扩展性) | 3/5 | 5/5 | 2.5/5 | 3/5 | 3.5/5 | **3.4/5** | 中 | | A3 HurtBox 可见性 | 4/5 | 5/5 | 4/5 | **2/5** | 2.5/5 | **3.5/5** | 中 | ### 按商业价值排列的修复顺序 | # | 修复项 | 优先级 | 人天 | 关键收益 | |---|-------|--------|------|---------| | 1 | BD_IsStateMatch 改字符串绑定 | **P2** | 0.2 | 彻底消除 BD 图枚举整数断裂风险 | | 2 | MapManager 迁移到 ServiceLocator | **P2** | 0.1 | 无痛去除最后一个纯单例 Map 引用 | | 3 | SaveManager 实现 ISaveService + 注册 | **P2** | 0.5 | 为测试开路,新代码统一走 SL | | 4 | HurtBox 自定义 Editor(调试) | P3 | 0.5 | 降低战斗系统调试成本 | | 5 | GlobalObjectPool IObjectPoolService | P3 | 0.5 | 允许 mock + 替换 Pool 后端 | | 6 | IEnemyState POCO 分层(阶段 1) | P3 | 1.5 | Boss 增多时状态逻辑不膨胀 | | 7 | SaveManager 完整接口化(方案 B) | P3 | 2.0 | 单元测试全覆盖时才值得投入 | ### 与当前 MasterReview_2025_PostFix 评分的影响 | 分类 | 修复 #1-3 后 | 修复 #1-7 后 | |------|------------|------------| | 架构设计(现 8.2) | ≈ 8.5 | ≈ 9.0 | | 可扩展性(现 8.0) | ≈ 8.3 | ≈ 8.8 | | 编辑器友好(现 7.5) | ≈ 7.7 | ≈ 8.5 | | 综合分(现 8.0) | ≈ **8.3** | ≈ **8.7** | --- ## 附录 A:代码依赖拓扑(当前) ``` BaseGames.Core.Events ↓ BaseGames.Core ←────────── GameServiceRegistrar ↓ (ISaveService 注册点空缺) BaseGames.Core.Save (SaveManager.Instance) BaseGames.Core.Pool ↓ (GlobalObjectPool.Instance) ┌────────────────────────────────────────────┐ │ EventChain Quest World Support UI ... │ │ (全部通过 SaveManager.Instance 直接依赖) │ └────────────────────────────────────────────┘ ``` ``` 修复后(A1 方案 A + C 完成后): BaseGames.Core.Events ↓ BaseGames.Core ServiceLocator { ISaveService, IObjectPoolService, ... } ↑ ↑ SaveManager GlobalObjectPool (implements ISaveService) (implements IObjectPoolService) ↓ ↓ ┌────────────────────────────────────────────┐ │ EventChain Quest World Support UI ... │ │ 新代码: ServiceLocator.Get() │ │ 遗留代码: SaveManager.Instance (过渡期保留)│ └────────────────────────────────────────────┘ ``` ## 附录 B:参照游戏架构对比速查表 | 特性 | zeling_v2(当前) | Hollow Knight | Celeste | Hades | Dead Cells | |------|-----------------|---------------|---------|-------|------------| | 服务定位 | 单例 + SL 混用 | 全局单例 | 全局单例(小型代码库) | Provider 字典(类 SL) | 完全 DI 容器 | | 存档访问 | SaveManager.Instance(具体类) | GameManager.instance | SaveData 静态类 | Hades.GameData 全局 | ISaveService 接口 | | 敌人状态 | 枚举 + BD 行为树 | 手工 POCO FSM | 内联(无独立组件) | POCO + 继承 | POCO + 组件组合 | | 战斗注入 | 接口注入(不可见) | 组件直接引用(全可见) | 内联(无注入)| 数据驱动 SO | 组件化(全可见)| | 对象池接口 | 无接口(直接 Instance) | 无(手写) | 无(小规模) | Provider 封装 | IPoolService 接口 | > **结论**:zeling_v2 在事件架构、存档健壮性、编辑器工具链三个维度超过上述所有参照游戏; > 在服务访问一致性(A1)和 BD 枚举绑定安全性(A2-BD)上存在商业级差距,应优先修复。