723 lines
30 KiB
Markdown
723 lines
30 KiB
Markdown
# 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<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(...)`)
|
||
|
||
---
|
||
|
||
#### 方案 C:GlobalObjectPool 接口化
|
||
|
||
```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` 下,测试环境不涉及)
|
||
|
||
---
|
||
|
||
#### 方案 D:MapManager 迁移(最简单)
|
||
|
||
```csharp
|
||
// MapManager.Awake 追加:
|
||
ServiceLocator.Register<MapManager>(this); // 或定义 IMapService
|
||
|
||
// MapPanel.cs 修改(唯一调用点):
|
||
var mapManager = ServiceLocator.Get<MapManager>();
|
||
```
|
||
|
||
**工作量**:约 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<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 | 遥远未来 |
|
||
|
||
---
|
||
|
||
## A3:HurtBox 依赖注入可观测性
|
||
|
||
### 3.1 现状完整梳理
|
||
|
||
#### 注入机制全景图
|
||
|
||
```
|
||
HurtBox(Combat程序集)
|
||
├── _owner(IDamageable)
|
||
│ └── 注入:Awake() GetComponentInParent<IDamageable>() ← 自动,可靠
|
||
├── _statusEffectable(IStatusEffectable)
|
||
│ └── 注入:Awake() GetComponentInParent<IStatusEffectable>() ← 自动,可靠
|
||
├── _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<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 | 方案 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<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)上存在商业级差距,应优先修复。
|