30 KiB
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) 五维度逐项评估。
目录
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 调用) |
关键发现:
ISaveService接口(Assets/Scripts/Core/ISaveService.cs)已定义但从未有任何类实现。
SaveManager 的方法签名与 ISaveService 不完全一致(HasSavevsSlotExists,QuickLoadAsyncvsQuickLoad)。SaveManager提供了远超 ISaveService 定义的丰富 API(GetFlag/SetFlag/IsWorldCollected/IsBossDefeated 等),这些业务语义方法若放入接口则接口过重,若留在具体类则调用方必须引用具体类型,破坏接口分离原则。- 所有
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(卸载场景资产) |
关键发现:
- 无服务接口(IObjectPoolService 未定义)。Behavior Designer 节点中直接
GlobalObjectPool.Instance.Spawn(...),若需要在测试或热更时替换 Pool 实现,无法注入 mock。 - 调用方均检查
pool == null或使用GlobalObjectPool.Instance?.Spawn,说明开发者已意识到潜在的空引用,但用空检查代替了正确的依赖声明。 - 对象池本身质量非常高(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 处调用,属于最轻量的迁移目标。
MapManager本身已用 SO 事件订阅(_onRoomEntered)驱动,架构已较好;单例仅是MapPanelUI 的反向查询入口。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 只是额外接口)。
// 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 访问。
// 扩展接口(按领域分组,避免臃肿接口)
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 接口化
// 新增接口
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 迁移(最简单)
// 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,因此实际游戏中未必触发;
但在热重载、编辑器快速进入单场景等情况下可复现。
修复方案(不在本文档实施,单独记录):
// 选项 1:ISaveManager 广播 Ready 事件,ISaveable 实现者在事件中注册
// 选项 2:SaveManager.Register 改为延迟注册(队列),实例化后批量 Register
// 选项 3:改用 Unity SceneManager.sceneLoaded 保证 Persistent 最先加载
A2:EnemyStateType 枚举与 POCO FSM
2.1 现状完整梳理
当前架构
// 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
// 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 魔法整数
// 方案:改用 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 接口,双轨并行
// 接口:最小化
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 组件
// 当敌人状态数量超过 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 的私有字段调试工具发现注入是否完成。
在以下场景中,注入可能静默失败:
PlayerController没有_hurtBox引用(Inspector 未绑定)→ SetShieldable 等不会被调用- 新建敌人 Prefab 忘记挂载
EnemyPoiseComponent→_poiseSource为 null,霸体系统默认无保护 _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 可见性和警告日志。
// 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 面板
// 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)上存在商业级差距,应优先修复。