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

30 KiB
Raw Blame History

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 混用
  2. A2EnemyStateType 枚举与 POCO FSM
  3. A3HurtBox 依赖注入可观测性
  4. 综合结论与优先级矩阵

A1单例 vs ServiceLocator 混用

1.1 现状完整梳理

1.1.1 SaveManager

文件Assets/Scripts/Core/Save/SaveManager.cs
程序集BaseGames.Core.Save

实例访问方式SaveManager.Instance42 处调用点,横跨 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 SlotExistsQuickLoadAsync 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.Instance6 处调用点)

调用文件 调用场景
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.Instance1 处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 只是额外接口)。

// 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(...)


方案 CGlobalObjectPool 接口化

// 新增接口
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.InstanceBD 节点运行在 #if GRAPH_DESIGNER 下,测试环境不涉及)


方案 DMapManager 迁移(最简单)

// 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 SceneSaveManager.Instance 在 OnEnable 时为 null
?. 静默跳过,导致该组件的 OnSave/OnLoad 永不被调用,存档静默丢失。

现状评估Unity 通常先加载 Persistent 再加载 Game Scene因此实际游戏中未必触发
但在热重载、编辑器快速进入单场景等情况下可复现。

修复方案(不在本文档实施,单独记录):

// 选项 1ISaveManager 广播 Ready 事件ISaveable 实现者在事件中注册
// 选项 2SaveManager.Register 改为延迟注册(队列),实例化后批量 Register
// 选项 3改用 Unity SceneManager.sceneLoaded 保证 Persistent 最先加载

A2EnemyStateType 枚举与 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 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 魔法整数

// 方案:改用 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 遥远未来

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

问题的实质

当前所有注入字段均为 privateInspector 不显示,只有在运行时进入 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 可见性和警告日志。

// 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 方案 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上存在商业级差距应优先修复。