Files
zeling_v2/Docs/Review/ArchitectureDeepDive_2025.md
2026-05-12 15:34:08 +08:00

723 lines
30 KiB
Markdown
Raw 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.
# 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. [A2EnemyStateType 枚举与 POCO FSM](#a2-enemystatetype-枚举与-poco-fsm)
3. [A3HurtBox 依赖注入可观测性](#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 / UnregisterISaveable |
| MapPin.cs | Register / UnregisterISaveable |
| MapManager.cs | Register / UnregisterISaveable |
| DifficultyManager.cs | Register / UnregisterISaveable |
| QuestManager.cs | Register / UnregisterISaveable |
| 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 定义的丰富 APIGetFlag/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 | SpawnBD 节点) |
| 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处相对可接受 | HadesGameProviders 字典注册,通过泛型 Get 访问Dead Cells服务容器完全解耦 |
| **性能** | 无问题ISaveable 注册/取消注册 O(1)List | 无问题;对象池本身是高性能实现 | 无问题 | 无差距 |
| **可扩展性** | 添加新服务层(云存档、本地存档)需修改 SaveManager 具体实现 | 替换 Pool 后端(从 Addressables 迁移到其他资管)影响所有调用方 | 低风险 | Hades 可运行时切换 Provider 实现 |
| **编辑器友好** | ⚠️ 单元测试无法 mock SaveManager无接口ServiceLocator.OverrideForTest 不可用 | ⚠️ BD 节点测试依赖 GlobalObjectPool.Instance 存在 | 无问题 | Celeste 通过接口注入,单元测试全覆盖 |
| **使用便利性** | 短期便利;长期积累的跨模块直接耦合 | 简单直接Spawn 一行 | 无问题 | — |
#### 架构问题核心诊断
```
目前代码中存在两套并行的服务查找机制:
调用方 A新代码ServiceLocator.Get<IDeathRespawnService>()
调用方 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<ISaveService>(_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<string> GetCompletedChains();
void SetChainCompleted(string chainId);
}
```
**工作量**:约 2 人天(接口定义 + 42 处调用点修改 + 单元测试补充)
**效果**:完全解耦;可 mock可替换 SaveManager 后端
**局限**:调用方代码更冗长(`ServiceLocator.Get<ISaveQueryService>().GetFlag(...)`
---
#### 方案 CGlobalObjectPool 接口化
```csharp
// 新增接口
public interface IObjectPoolService
{
T Spawn<T>(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<IObjectPoolService>()?.Spawn(...)`
**BD 节点特殊处理**BD 节点仍使用 `GlobalObjectPool.Instance`BD 节点运行在 `#if GRAPH_DESIGNER` 下,测试环境不涉及)
---
#### 方案 DMapManager 迁移(最简单)
```csharp
// MapManager.Awake 追加:
ServiceLocator.Register<MapManager>(this); // 或定义 IMapService
// MapPanel.cs 修改(唯一调用点):
var mapManager = ServiceLocator.Get<MapManager>();
```
**工作量**:约 0.1 人天
**效果**消除单例MapPanel 通过 SL 获取依赖,可在测试中 mock
---
#### 建议迁移顺序
```
优先级 1低风险高收益MapManager → 方案 D0.1 人天)
优先级 2必要准备SaveManager → 方案 A0.5 人天,为新代码开路)
优先级 3中期GlobalObjectPool → 方案 C0.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
// 选项 1ISaveManager 广播 Ready 事件ISaveable 实现者在事件中注册
// 选项 2SaveManager.Register 改为延迟注册(队列),实例化后批量 Register
// 选项 3改用 Unity SceneManager.sceneLoaded 保证 Persistent 最先加载
```
---
## A2EnemyStateType 枚举与 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 CellsPOCO + 组合式 FSM |
| **性能** | 枚举比较 O(1),内存零开销 | 虚方法调用 + 多态分发,可忽略(仅 Enter/Exit 时调用)| 无显著差距 |
| **可扩展性** | **⚠️ 枚举修改(插入中间值)破坏 BD 数值绑定** | 新增状态只需添加新类,不影响现有代码 | 商业游戏普遍使用后者 |
| **编辑器友好** | BD_IsStateMatch 使用魔法整数,设计师无类型保护 | 可用字符串/类型名绑定,或用枚举 + POCO 双层架构 | Dead Cells 使用字符串 ID + 反射注册 |
| **使用便利性** | `ForceState(EnemyStateType.Hurt)` 简洁 | `_stateMachine.TransitionTo<HurtState>()` 稍冗长但语义更清晰 | — |
#### 现状问题的严重程度分级
```
🔴 高风险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
{
/// <summary>
/// 目标状态名称Inspector 输入 "Controlled"/"Hurt"/"Stagger"/"Dead")。
/// 使用字符串而非整数,枚举重排时 BD 图不失效。
/// </summary>
public SharedString TargetStateName = new SharedString { Value = "Controlled" };
private EnemyBase _enemy;
public override void OnStart() => _enemy = GetComponent<EnemyBase>();
public override TaskStatus OnUpdate()
{
if (_enemy == null) return TaskStatus.Failure;
if (!System.Enum.TryParse<EnemyStateType>(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<EnemyStateType, IEnemyState> _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 | 遥远未来 |
---
## A3HurtBox 依赖注入可观测性
### 3.1 现状完整梳理
#### 注入机制全景图
```
HurtBoxCombat程序集
├── _ownerIDamageable
│ └── 注入Awake() GetComponentInParent<IDamageable>() ← 自动,可靠
├── _statusEffectableIStatusEffectable
│ └── 注入Awake() GetComponentInParent<IStatusEffectable>() ← 自动,可靠
├── _shieldableIShieldable
│ └── 注入PlayerController.Awake() → hurtBox.SetShieldable(shield) ← 外部注入
├── _parrySystemParrySystem
│ └── 注入PlayerController.Awake() → hurtBox.SetParrySystem(parrySystem) ← 外部注入
└── _poiseSourceIPoiseSource
├── 玩家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 配置,无运行时注入歧义 |
| CelesteTASer 分析) | 玩家组件内联,无 HurtBox 分离层;避免了此问题 |
---
### 3.2 五维度评估
| 维度 | 当前方案 | 评分15 | 说明 |
|------|---------|------------|------|
| **架构设计** | 接口注入设计正确,隔离层次清晰 | 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<IDamageable>();
_statusEffectable = GetComponentInParent<IStatusEffectable>();
if (_owner == null)
Debug.LogWarning($"[HurtBox] {name}: 父节点中未找到 IDamageable 实现。", this);
}
// ── Editor 调试(运行时不产生任何开销)─────────────────────────────────
#if UNITY_EDITOR
/// <summary>
/// 运行时 Inspector 注入状态总览。
/// 绿色 = 注入完成;红色 = 未注入(该能力不可用)。
/// </summary>
[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 | 方案 BCustom 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 枚举 FSMBD 整数绑定) | 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<ISaveService>() │
│ 遗留代码: 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上存在商业级差距应优先修复。