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

39 KiB
Raw Blame History

BaseGames Framework — 全量深度评审

评审时间2026-05-12基于上轮修复后的最新代码 评审范围:Assets/Scripts/ 全目录28 个子系统,全量阅读) 评审标准:成熟商业动作 RPGUnity 2022.3 LTS / C# 框架定位:新框架,不需要兼容,不需要兜底;追求纯净、统一、数据逻辑一致性


目录

  1. 总体评分
  2. 架构设计:系统级深析
  3. 性能
  4. 可扩展性
  5. 编辑器友好性
  6. 使用便利性
  7. 问题清单(优先级排序)
  8. 修复方案
  9. 综合结论

1. 总体评分

维度 评分 说明
架构设计 ★★★★☆ 系统设计精良,服务模型局部仍有接口覆盖缺口,本地化为异类设计
性能 ★★★★★ 热路径全面优化BatchLOS、SpeedrunTimer、EventChain 具备商业顶级水准
可扩展性 ★★★★☆ SO 驱动覆盖率极高StatusEffect 工厂、Boss 分阶段均为标准商业实践
编辑器友好性 ★★★★★ 工具链丰富度超过绝大多数同体量商业框架
使用便利性 ★★★★☆ 统一度高;本地化 & 部分 Manager 静态/具体类注册是体验洼地

2. 架构设计:系统级深析

2.1 全局架构层次 & 依赖图

程序集依赖方向完全符合洁净架构原则:

┌────────────────────────────────────────────────────────────┐
│  Editor  (仅编辑器,无运行时引用)                           │
└───────────────────────────┬────────────────────────────────┘
                             │
┌────────┬────────┬──────────┼──────┬─────────────────────────┐
│  UI    │ World  │  Quest   │  ... │  表现/业务层              │
└────┬───┴───┬────┴────┬─────┘  ... └──────────┬──────────────┘
     │       │         │                        │
┌────┴───────┴─────────┴────────────────────────┴────────────┐
│  Combat / Player / Enemies / Audio / Skills / Equipment ... │
│  游戏系统层                                                  │
└─────────────────────┬───────────────────────────────────────┘
                       │
┌──────────────────────┴──────────────────────────────────────┐
│  Core / Core.Save / Core.Events  (服务 & 事件层)             │
└─────────────────────────────────────────────────────────────┘

所有 .asmdef 依赖均单向向下,无循环。BaseGames.Core.Events 是纯净基础层,完全不依赖任何游戏程序集。


2.2 事件系统

RAII 订阅模式 — 商业顶级

// 全框架统一(除下述问题外已 100% 覆盖)
private readonly CompositeDisposable _subs = new();
private void OnEnable()  => _channel?.Subscribe(Handler).AddTo(_subs);
private void OnDisable() => _subs.Clear();

EventSubscriptionreadonly struct,订阅/取消订阅零堆分配。CompositeDisposable 一行批量清理,在生产规模的 Unity 项目中这是严格正确的做法。

EventBusMonitor — 超越商业标准

固定大小环形缓冲区256 条Editor 下零 GC 记录全部 SO 事件调用,含 payload 类型、订阅者计数、帧号、过滤搜索,自动滚动。此工具在国内外独立 AA 游戏中均属罕见。

EventChainManager — 高效的帧内去重

private bool _evaluatePending;

private void OnEnable()
{
    _onBossDefeated?.Subscribe(id => { OnBossDefeated?.Invoke(id); EvaluateAll(); }).AddTo(_subs);
    // ...
}

private void EvaluateAll() => _evaluatePending = true;

private void Update()
{
    if (!_evaluatePending) return;
    _evaluatePending = false;
    DoEvaluateAll();
}

同帧内无论触发几个事件,只执行一次全量条件评估。这是事件链系统的标准性能优化手段。


2.3 ServiceLocator 服务模型

接口注册已覆盖核心服务

接口 注册处 说明
IAudioService GameServiceRegistrar 先注册 NullAudioService后被 AudioManager 覆盖
IDeathRespawnService GameServiceRegistrar 正确
ISceneService GameServiceRegistrar 正确
IEventChannelRegistry EventChannelRegistry 正确
ISaveService GameServiceRegistrar Adapter 模式,正确
IHitStopService HitStopManager 本轮修复完成
IQuestManager QuestManager 正确
IObjectPoolService GlobalObjectPool 正确

⚠️ 遗漏接口(中优先级)

以下 Manager 注册时使用具体类而非接口,导致调用方对实现类产生依赖:

当前注册 应注册为
DialogueManager Register<DialogueManager>(this) IDialogueService
AchievementManager Register<AchievementManager>(this) IAchievementService
ProjectileManager Register<ProjectileManager>(this) IProjectileService
TutorialManager Register<TutorialManager>(this) ITutorialService
AnalyticsManager Register<AnalyticsManager>(this) + 静态 facade 见下节

2.4 存档系统

SaveData 结构设计优秀

SaveData 字段覆盖完整Player、Equipment、World、Map、Quests、Achievements、Tools、ChallengeRooms、EventChains、Shops、Stats、NGPlus、Tutorial、DLC无遗漏。

[JsonExtensionData]
public Dictionary<string, JToken> ExtensionData = new();

public Dictionary<string, JObject> DLC = new();  // DLC 专用隔离字段

双重前向兼容设计(ExtensionData 保留未知字段 + DLC 专用命名空间),工程化程度达到 AAA 水准。

ISaveable 接口统一性 — 优秀

以下 Manager 均正确实现 ISaveable QuestManager / EquipmentManager / AchievementManager / SpeedrunTimer / TutorialManager / BossProgressTracker / ChallengeRoomManager

存档数据流完全统一:SaveManager.SaveAsync() → 遍历 ISaveable → 写入 SaveData → JSON 序列化。无任何 Manager 绕过此机制直接写文件。


2.5 战斗系统

HurtBox 8 步流水线 — 架构精良

① IsInvincible 或 HurtBoxInvincible → return
② 弹反窗口消费ParrySystem.ConsumeParry→ return
③ 霸体等级 ≥ Break 等级且不含 ForceBreak → HitConfirmed 广播后 return
④ 护盾层IShieldable.AbsorbDamage→ passThrough = 0 时 return
⑤ 防御减免max(1, amount - Defense)
⑥ IDamageable.TakeDamage(info)
⑦ 全局广播 EVT_DamageDealt
⑧ IStatusEffectable.ApplyStatusEffecttype

8 步均通过接口隔离,无任何具体类型直接依赖。ParrySystem 仅暴露 bool 窗口状态,伤害数据留在 Combat 层,完整跨程序集解耦。

StatusEffectManager 双结构设计 — 高性能

private readonly List<StatusEffect>                         _activeList  = new();
private readonly Dictionary<StatusEffectType, StatusEffect> _activeIndex = new();
  • _activeListUpdate 时逆序遍历(避免移除时索引错位)
  • _activeIndexO(1) 类型查找(叠层 / 净化 / 状态查询)
  • 工厂字典 _effectFactories运行时可注册自定义效果Boss 可扩展

MaterialPropertyBlock 修改 Shader 参数,不影响共享材质——正确的性能实践。

DamageInfo Builder 模式

// 90% 场景用工厂方法
var info = DamageInfo.From(_sourceSO, atkDir, sourcePos, layer);

// 复杂场景用 Builder
var info = new DamageInfo.Builder()
    .SetRaw(50).SetType(DamageType.Fire).SetFlags(DamageFlags.CanBeParried)
    .SetBreak(BreakLevel.Medium).Build();

struct 值类型,热路径零 GCBuilder 以 class 持有临时字段,仅在复杂构造时分配,是合理权衡。


2.6 玩家状态机

状态为纯 C# 类,零 MonoBehaviour 开销

PlayerStateBase 子类不继承 MonoBehaviour,由 PlayerController 统一驱动 OnStateUpdate() / OnStateFixedUpdate()。状态实例在 Awake 时创建后常驻,无频繁分配。

// 状态字典使用 Type 为 key查询 O(1)
private readonly Dictionary<Type, PlayerStateBase> _states = new();

数据驱动连击与 Animancer 帧事件

// AttackState — 连击段数从 SO 读取,无硬编码
int maxCombo = AnimCfg?.GroundAttacks?.Length ?? 1;

// HitBox 时间点由配置决定
events.Add(enterTime, () => Owner.Combat?.EnableWeaponHitBox(AttackDirection.Ground));
events.Add(exitTime,  () => Owner.Combat?.DisableAllWeaponHitBoxes());

新增攻击段数仅需修改 SO 资产代码零改动。Animancer 归一化时间点事件保证帧率无关的命中判定,是 2D 动作游戏的工业级实践。

冲刺无敌帧通过状态对象表达

// DashState
public override bool IsInvincible => true;

// PlayerController.TakeDamage
if (_currentState?.IsInvincible == true) return;  // 不进入受击硬直

无敌帧语义清晰:状态本身声明是否无敌,控制器按声明行事,无任何硬编码状态类型判断。

Editor 状态转换白名单

#if UNITY_EDITOR
public virtual IReadOnlyList<Type> ValidTransitions => Array.Empty<Type>();
#endif

编辑器下每次转换可验证合法性Release 构建零开销。这是大型状态机调试的标准辅助手段。


2.7 敌人系统

BatchLOSSystem — 高性能帧分布视线检测

// 每帧轮询部分请求者(均匀分配,避免单帧全量射线)
int count = Mathf.Min(_maxRequestersPerFrame, _requesters.Count);

// Swap-and-popO(1) 无搬移删除
int idx  = _indexMap[requester];
int last = _requesters.Count - 1;
if (idx != last)
{
    var moved        = _requesters[last];
    _requesters[idx] = moved;
    _indexMap[moved] = idx;
}
_requesters.RemoveAt(last);

双结构(List + HashSet + Dictionary 三件套)确保:注册 O(1)、Unregister O(1)、Contains O(1)、帧内迭代 O(k)k = 每帧预算数量)。是视线检测系统的教科书实现。

注释中已记录 Unity 2022.3 中 Physics2D.RaycastCommand 未稳定的限制并给出升级路径Job System工程判断与文档意识兼备。

Behavior Designer 任务层接口化

所有 BD_* 任务均通过 EnemyBase 公共虚方法访问敌人能力(MoveTo / BeginAttack / FacePlayer不直接操作具体组件。子类可重写任意方法AI 任务无需修改。

// BD_Attack.cs — 不依赖任何具体组件,全部通过 EnemyBase 接口
if (_enemy == null || !_enemy.CanAttack())
    return TaskStatus.Failure;
_enemy.BeginAttack(AttackType.Melee);

EnemyQuotaManager 波次结算

EnemyBase 通过 public event Action OnDied 通知 ChallengeRoomManager,而非广播全局 SO 事件,因为这是局部的波次结算通知,不需要跨系统传播。正确判断了何时用 C# event何时用 SO EventChannel。


2.8 任务 & 事件链

QuestManager — 接口 + RAII + O(1) 查找,完全正确

// Awake接口注册
ServiceLocator.Register<IQuestManager>(this);

// Awake构建 O(1) 查找索引
_questIndex = new Dictionary<string, QuestSO>(_allQuests?.Length ?? 0);
foreach (var q in _allQuests)
    if (q != null && !string.IsNullOrEmpty(q.questId))
        _questIndex[q.questId] = q;

// OnEnableRAII 订阅
_onEnemyDied?.Subscribe(HandleEnemyDefeated).AddTo(_subs);

与 SaveManager 的注册/注销通过 ServiceLocator 动态获取,不依赖静态实例:

private void OnEnable()  => ServiceLocator.GetOrDefault<SaveManager>()?.Register(this);
private void OnDisable() => ServiceLocator.GetOrDefault<SaveManager>()?.Unregister(this);

这是框架内对 ISaveable 自动注册最干净的写法。

EventChainSO 条件状态重置机制

EventChainManager.OnEnable() 在绑定中继事件前调用 cond?.ResetState(),显式处理了 SO 资产在 Editor 重新进入 Play Mode 时的状态残留问题。这说明开发者清楚 SO 运行时状态的限制,主动防御。


2.9 装备 & 护符

EquipmentContext 依赖封装 — 优秀设计

private EquipmentContext _ctx = new EquipmentContext
{
    Stats     = GetComponent<PlayerStats>(),
    Feedback  = GetComponent<PlayerFeedback>(),
    Events    = ServiceLocator.GetOrDefault<IEventChannelRegistry>(),
    SkillMods = GetComponent<SkillModifierRegistry>(),
    WeaponMgr = GetComponent<WeaponManager>(),
};

ICharmEffect.OnEquip(EquipmentContext ctx) 接收上下文对象,护符效果无需直接引用 Player 上的各组件,依赖通过注入传递。新增护符效果只需实现接口,开闭原则完美遵守。

TryEquipCharm 结果通过返回值表达

public string TryEquipCharm(CharmSO charm)
{
    if (charm == null)              return "护符不存在";
    if (_equipped.Contains(charm))  return "已经装备";
    if (!_collected.Contains(charm)) return "尚未收集此护符";
    int remaining = _currentNotchCapacity - UsedNotches;
    if (charm.notchCost > remaining)
        return $"笔记不足(需要 {charm.notchCost},剩余 {remaining}";
    // ...
    return null; // null = 成功
}

返回 null 代表成功,string 代表失败原因——UI 直接用于显示,无需额外枚举类型。简洁务实。


2.10 世界系统

WorldStateRegistry — ScriptableObject 作运行时状态容器

private void OnEnable() => _states.Clear();  // 每次 Play Mode 进入时清空

统一 API Mark(category, id) / IsMarked(category, id) + 语义化快捷方法O(1) 查询。注释明确说明 OnEnable 的作用Domain Reload 兼容性),是有意识的 SO 运行时状态模式。

通过 [SerializeField] 注入而非 ServiceLocator 是合理选择SO 不需要运行时实例管理,场景内组件直接引用资产即可。

谜题系统接口化 — ISwitchable + IInteractable

public class PuzzleSwitch : MonoBehaviour, ISwitchable, IInteractable

PuzzleWire / PuzzleReceiver / PuzzleDoor 均通过接口通信,无直接类型依赖。新增谜题元素只需实现接口,无需修改任何现有代码。

RoomTransition — 数据驱动场景切换

_requiresKeyItem + _requiredItemId 通过 WorldStateRegistry.IsCollected() 检查。传送需求写在配置中,无游戏逻辑代码。


2.11 本地化系统(问题)

⚠️ 问题LocalizationManager 是静态类,游离于框架服务模型之外

// 当前:静态类,无法通过 ServiceLocator 访问
public static class LocalizationManager
{
    private static Language _currentLanguage = Language.ChineseSimplified;

    // 语言持久化使用 PlayerPrefs绕过了 SaveData
    public static void SetLanguage(Language language)
    {
        PlayerPrefs.SetString("Language", language.ToString());
        OnLanguageChanged?.Invoke(language);
    }

    public static void LoadSavedLanguage()
    {
        string saved = PlayerPrefs.GetString("Language", string.Empty);
        // ...
    }
}

问题 1 — PlayerPrefs 绕过 SaveData:语言偏好是用户设置的一部分,与其他设置(如 SettingsManager)不一致地存入 PlayerPrefs而非存档数据的 SettingsSaveData 字段。

问题 2 — 静态类无法测试替换:无法通过 ServiceLocator 覆盖语言服务Editor 工具、测试套件无法在运行时注入不同语言实现。

问题 3 — PlayerPrefs.GetString 在 Awake 使用字符串参数:当前 LoadSavedLanguage() 使用字符串 "Language",应迁移到 GameIds 或专属常量,防止魔法字符串散落。


2.12 Persistent 场景服务注册(问题)

⚠️ TutorialManager.DontDestroyOnLoad 与框架约定冲突

// TutorialManager.Awake() — 当前代码
private void Awake()
{
    if (ServiceLocator.GetOrDefault<TutorialManager>() != null) { Destroy(gameObject); return; }
    ServiceLocator.Register<TutorialManager>(this);
    DontDestroyOnLoad(gameObject);   // ← 自己管理生命周期
}

与上轮已修复的 EventChannelRegistry 完全相同的问题:各 Manager 不应自行调用 DontDestroyOnLoad,由 Persistent 场景的根 GameObject 统一保证。此处两次 DontDestroyOnLoad 可能导致场景层级混乱。

⚠️ AnalyticsManager — 静态 Facade 混合 ServiceLocator

// AnalyticsManager.cs — 混合设计
public class AnalyticsManager : MonoBehaviour  // 实例注册到 ServiceLocator
{
    // 但对外通过静态方法暴露 API
    public static void Track(string eventName, ...)
    {
        var inst = ServiceLocator.GetOrDefault<AnalyticsManager>();
        if (inst == null || !inst._enabled) return;
        inst.Enqueue(eventName, parameters);
    }
}

这种 "静态门面调用实例" 的模式:

  • 调用者无法感知是否已注册(失败无声)
  • 无法通过接口替换实现(测试时难以 Mock
  • 与框架 ServiceLocator.GetOrDefault<T>().DoSomething() 的统一模式不一致

3. 性能

3.1 热路径零 GC 全覆盖

系统 优化手段
DamageInfo structBuilder 仅复杂场景分配
EventSubscription readonly struct,订阅/取消零 GC
EventBusMonitor 固定 256 环形缓冲区,零动态分配
PlayerStateBase 纯 C# 类,无 MonoBehaviour常驻零 GC
BatchLOSSystem 固定 List + 帧预算,无每帧分配
SkillManager 固定大小数组复用(本轮修复)
HitBox HashSet / Dictionary 预设 capacity(8)(本轮修复)
GlobalObjectPool Addressables 异步预热 + Queue 复用
SpeedrunTimer 仅在整秒变化时重建 TMP 字符串,每帧零 GC
WorldStateRegistry HashSet<string> O(1) 查询,常驻内存

3.2 待改进点

⚠️ 问题 P-1EquipmentManager.OnLoad_equipped.ToList() 创建临时列表,仅在加载存档时触发,影响可接受,但可用 for 循环向后遍历避免:

// 当前:分配 List 副本
foreach (var c in _equipped.ToList())
    foreach (var fx in c.effects) fx?.OnUnequip(_ctx);
_equipped.Clear();

// 改进:从末尾向前遍历,避免副本分配
for (int i = _equipped.Count - 1; i >= 0; i--)
    foreach (var fx in _equipped[i].effects) fx?.OnUnequip(_ctx);
_equipped.Clear();

⚠️ 问题 P-2GlobalObjectPool._alive 使用 LinkedList<PooledObject> 追踪活跃对象,每次 Spawn 时分配 LinkedListNode。如果 alive 追踪仅用于限制上限,可改为纯计数器(int _aliveCount)消除节点分配。

⚠️ 问题 P-3SceneLoader.LoadSceneCoroutine 在加载新场景失败时(AsyncOperationStatus != Succeeded),旧场景已被卸载但新场景未成功加载,游戏将处于无场景状态。需补充失败恢复路径。


4. 可扩展性

4.1 ScriptableObject 驱动层 商业顶级

领域 SO 驱动范围
护符 CharmSO.effects[]ICharmEffect 接口,新增护符只需资产
Boss BossSkillSO + SkillSequenceSO + AttackPatternSO 三层
技能 FormSkillSO → 形态绑定技能,数据驱动
状态效果 _effectFactories 运行时注册Boss 可注入自定义效果
连击动画 PlayerAnimationConfigSO.GroundAttacks[] 决定连击段数
任务 QuestSO.branches 分支,RewardSO.Apply(player) 奖励
事件链条件 ChainCondition 子类,Register/Unregister 接口
谜题元素 ISwitchable + IInteractable 接口SO 数据驱动触发方式

4.2 StatusEffect 工厂模式 最佳实践

// Awake 注册标准效果
RegisterEffectFactory(DamageType.Fire,   () => new FireEffect());
RegisterEffectFactory(DamageType.Poison, () => new PoisonEffect());

// Boss 或外部模块运行时注册自定义效果
statusEffectMgr.RegisterEffectFactory(DamageType.Frost, () => new FrostEffect());

工厂方法而非 switch/if-else符合开闭原则对未来 DLC 效果扩展友好。

4.3 存档版本迁移架构 路径已就绪

SaveMigrator 经本轮修复后版本号对齐,架构已预留分版本迁移方法:

if (data.Meta.Version == "1.0") MigrateFrom_1_0(data);
if (data.Meta.Version == "2.0") MigrateFrom_2_0(data);

SaveData.DLC 专用字段 + [JsonExtensionData] 双重前向兼容,支持 DLC 包在不修改主存档结构的情况下独立扩展数据。


5. 编辑器友好性

5.1 工具链清单 超越商业标准

工具 功能
EventBusMonitorWindow 实时事件监控,环形缓冲,过滤搜索,订阅者计数
SceneScaffoldTools 一键生成 Persistent 场景 GameObject 层级 + 自动资产绑定
EventChainEditorWindow 可视化事件链编辑器
BossSkillSequenceWindow Boss 技能序列可视化
CreateEventChannelAssets 批量创建 EventChannel SO 资产
AddressReferenceGraphWindow Addressables 引用关系图
NavSurfaceBakeShortcut 快捷 NavSurface Bake
ScriptExecutionOrderTools 执行顺序管理
ValidationSystem IValidatable 批量校验Editor 下一键检查资产引用完整性
Combat / Equipment / World Editor 各领域专属 Inspector 扩展

5.2 运行时调试支持

  • HurtBox.OnDrawGizmos() — 三色可视化(激活/无敌/非激活)
  • PlayerController._debugValidateTransitions#if UNITY_EDITOR 状态转换白名单验证
  • HitBox.Awake() — 运行时验证 IsTrigger错误时日志警告
  • 所有 [DefaultExecutionOrder] 均有注释说明原因(-2000 GameServiceRegistrar → -1000 事件注册 → -200 LOS 系统 → -100 PlayerController → +50 UIManager
  • GameServiceRegistrar._primaryListener — Inspector 预绑定可跳过全场景 FindObjectsOfType

5.3 GameIds — 统一字符串常量

// 全框架统一 ID 常量
public static class GameIds
{
    public static class Boss    { public const string ForestBoss = "Boss_Forest"; ... }
    public static class Chain   { public const string BossForestDefeated = "Chain_BossForest_Defeated"; ... }
    public static class Quest   { ... }
    public static class Ability { ... }
    public static class Scene   { ... }
    public static class Flag    { ... }
    public static class Npc     { ... }
    public static class Collectible { ... }
}

本地化字符串 KeyLocalizationManager.Get("ui_start"))和谜题 ID_switchId)是框架内尚未纳入 GameIds 的字符串,存在魔法字符串风险(见问题清单 L-5


6. 使用便利性

6.1 数据流路径唯一

操作 唯一路径
事件广播 _channel?.Raise(payload)
事件订阅 _channel?.Subscribe(H).AddTo(_subs)
服务获取 ServiceLocator.GetOrDefault<IXxx>()
存档注册 SaveManager.Register(this)ISaveable
世界状态查询 _worldState.IsMarked(category, id)
本地化文本(例外) LocalizationManager.Get("key") ← 静态类

除本地化外,所有操作路径唯一,无方式混用。

6.2 Input 混合订阅 合理

框架内共存两套订阅机制:

  • EventChannelSO:跨场景/跨程序集通信
  • C# eventInputReaderSO:玩家状态机内部输入响应

这是合理的设计选择InputReaderSO 的 event Action 是状态机内部信号,不需要全局 SO 资产。AttackState / ParryState 订阅 Input C# eventOnStateExit 取消订阅——语义清晰,无泄漏风险。保持现状,无需统一

6.3 PlayerController 属性代理 状态类友好

// PlayerController 将所有依赖以只读属性暴露,状态类通过 Owner.X 访问
public PlayerMovement          Movement   => _movement;
public PlayerStats             Stats      => _stats;
public AnimancerComponent      Animancer  => _animancer;
// ...

状态类通过 Owner.X 访问,无需向构造函数传入多个参数,新增状态只需 new MyState(owner) 一行。

6.4 NullAudioService 缺省服务

// GameServiceRegistrar
ServiceLocator.RegisterIfAbsent<IAudioService>(new NullAudioService());
// AudioManager.Awake 后覆盖为真实实现

空对象模式Null Object Pattern确保任何场景下 ServiceLocator.GetOrDefault<IAudioService>() 都不为 null调用方无需判空。这是缺省服务的标准实现。


7. 问题清单(优先级排序)

🔴 高优先级(直接影响框架一致性 / 数据正确性)

# 文件 问题描述
H-1 Tutorial/TutorialManager.cs Awake() 中调用 DontDestroyOnLoad(gameObject),违反 Persistent 场景统一生命周期原则(同类问题已在 EventChannelRegistry 修复)
H-2 Localization/LocalizationManager.cs 静态类游离于 ServiceLocator 体系之外;语言持久化走 PlayerPrefs 而非 SaveData,违背数据统一一致性原则

🟡 中优先级(影响架构纯净度 / 可测试性)

# 文件 问题描述
M-1 Dialogue/DialogueManager.cs 以具体类 DialogueManager 注册,无 IDialogueService 接口,调用方对实现类产生依赖
M-2 Progression/AchievementManager.cs 以具体类注册,无 IAchievementService 接口
M-3 Combat/ProjectileManager.cs 以具体类注册,无 IProjectileService 接口
M-4 Support/Analytics/AnalyticsManager.cs 混合静态 Facade + ServiceLocator 实例,模式不一致;static Track() 失败无声,无法 Mock
M-5 Tutorial/TutorialManager.cs 以具体类注册,无 ITutorialService 接口(附于 H-1

🟢 低优先级(性能小改进 / 健壮性)

# 文件 问题描述
L-1 Equipment/EquipmentManager.cs OnLoad_equipped.ToList() 分配临时列表;可用逆序 for 循环消除
L-2 Core/Pool/GlobalObjectPool.cs _aliveLinkedList 追踪活跃对象,每次 Spawn 分配 LinkedListNode;如仅用于上限检查可改为 int _aliveCount
L-3 Core/SceneLoader.cs 场景加载失败时(Succeeded == false)旧场景已卸载但新场景未加载,缺少失败恢复路径
L-4 Localization/LocalizationManager.cs PlayerPrefs.GetString("Language") 使用 magic string应迁移至 GameIds 或专属常量
L-5 多处 谜题 ID_switchId)、本地化 Key、NPC ID 等字段尚有 magic string 散落,应扩充 GameIds 覆盖

8. 修复方案

Fix H-1TutorialManager 移除 DontDestroyOnLoad

// TutorialManager.Awake() — 删除以下行
// DontDestroyOnLoad(gameObject);   ← 删除

TutorialManager 应位于 Persistent 场景的 GameManagers GameObject 下,由场景生命周期自动保证跨场景存活,无需自行调用。


Fix H-2LocalizationManager 迁移为实例服务

步骤 1:定义接口

// Core 程序集(或 Localization 程序集)
public interface ILocalizationService
{
    string Get(string key, string table = "UI");
    void   SetLanguage(Language language);
    Language CurrentLanguage { get; }
    event System.Action<Language> OnLanguageChanged;
}

步骤 2LocalizationManager 实现接口 + 从 SaveData 读写语言设置

// 语言偏好通过 SaveData 持久化(而非 PlayerPrefs
public class LocalizationManager : MonoBehaviour, ILocalizationService, ISaveable
{
    private Language _currentLanguage = Language.ChineseSimplified;

    private void Awake()
    {
        if (ServiceLocator.GetOrDefault<ILocalizationService>() != null) { Destroy(gameObject); return; }
        ServiceLocator.Register<ILocalizationService>(this);
    }

    // ISaveable
    public void OnSave(SaveData data) => data.Settings.Language = _currentLanguage.ToString();
    public void OnLoad(SaveData data)
    {
        if (!string.IsNullOrEmpty(data.Settings?.Language) &&
            System.Enum.TryParse<Language>(data.Settings.Language, out var lang))
            _currentLanguage = lang;
    }
}

步骤 3:调用方迁移

// 旧:静态调用
var text = LocalizationManager.Get("ui_start");

// 新:通过服务接口
var loc  = ServiceLocator.GetOrDefault<ILocalizationService>();
var text = loc?.Get("ui_start") ?? "ui_start";

⚠️ 若迁移成本过高(大量现有调用),可保留静态 Get() 作为 Facade内部委托到服务实例但语言持久化必须从 PlayerPrefs 迁移至 SaveData。


Fix M-1DialogueManager 添加接口

// 新增接口
public interface IDialogueService
{
    bool IsDialogueActive { get; }
    void StartDialogue(DialogueSequenceSO sequence, string npcId = "");
}

// DialogueManager 实现接口
public class DialogueManager : MonoBehaviour, IDialogueService
{
    private void Awake()
    {
        if (ServiceLocator.GetOrDefault<IDialogueService>() != null) { Destroy(gameObject); return; }
        ServiceLocator.Register<IDialogueService>(this);
    }
    private void OnDestroy() => ServiceLocator.Unregister<IDialogueService>(this);
}

// 调用方
var dialogue = ServiceLocator.GetOrDefault<IDialogueService>();
dialogue?.StartDialogue(sequence, npcId);

Fix M-2 & M-3AchievementManager + ProjectileManager 添加接口

// IAchievementService
public interface IAchievementService
{
    void CheckAll(SaveData saveData);
    bool IsUnlocked(string achievementId);
    float GetProgress(string achievementId);
}

// IProjectileService
public interface IProjectileService
{
    Transform PlayerTransform { get; }
    void LaunchHoming(HomingProjectile proj, Vector2 direction,
                      ProjectileConfigSO config, DamageInfo damageInfo);
}

注册时改为接口类型:

ServiceLocator.Register<IAchievementService>(this);
ServiceLocator.Register<IProjectileService>(this);

Fix M-4AnalyticsManager 去除静态 Facade

将静态 Track() 改为实例方法,调用方通过 ServiceLocator 访问:

// AnalyticsManager — 移除所有 static 方法
public void Track(string eventName, Dictionary<string, object> parameters = null) { ... }
public void TrackBossKill(string bossId, float duration, int deathCount)          { ... }

// 新增接口(可选)
public interface IAnalyticsService
{
    void Track(string eventName, Dictionary<string, object> parameters = null);
    void TrackBossKill(string bossId, float duration, int deathCount);
}

调用方:

ServiceLocator.GetOrDefault<IAnalyticsService>()?.TrackBossKill(bossId, duration, deaths);

Fix L-1EquipmentManager.OnLoad 消除 .ToList()

public void OnLoad(SaveData data)
{
    // 逆序遍历,无需 .ToList() 副本
    for (int i = _equipped.Count - 1; i >= 0; i--)
        foreach (var fx in _equipped[i].effects) fx?.OnUnequip(_ctx);
    _equipped.Clear();
    _usedNotches = 0;
    // ... 其余逻辑不变
}

Fix L-2GlobalObjectPool 活跃计数改为 int

// 用简单计数器替代 LinkedList<PooledObject>
private readonly Dictionary<string, int> _aliveCount = new();

// Spawn 时
_aliveCount[key] = _aliveCount.GetValueOrDefault(key, 0) + 1;

// Despawn 时
_aliveCount[key] = Mathf.Max(0, _aliveCount.GetValueOrDefault(key, 0) - 1);

// 上限检查
int maxCount = _maxCounts.GetValueOrDefault(key, 0);
if (maxCount > 0 && _aliveCount.GetValueOrDefault(key, 0) >= maxCount) { ... }

Fix L-3SceneLoader 加载失败恢复

private IEnumerator LoadSceneCoroutine(SceneLoadRequest request)
{
    // 加载新场景(先加载再卸载,防止加载失败后无场景)
    var loadOp = Addressables.LoadSceneAsync(request.SceneName, LoadSceneMode.Additive);
    yield return loadOp;

    if (loadOp.Status != AsyncOperationStatus.Succeeded)
    {
        Debug.LogError($"[SceneLoader] 加载场景失败:{request.SceneName}");
        // 加载失败:不卸载旧场景,维持当前状态
        yield break;
    }

    // 加载成功后再卸载旧场景
    if (!string.IsNullOrEmpty(_currentRoomScene) && _currentHandle.IsValid())
    {
        var unloadOp = Addressables.UnloadSceneAsync(_currentHandle);
        yield return unloadOp;
    }

    _currentHandle    = loadOp;
    _currentRoomScene = request.SceneName;
    _onSceneLoaded?.Raise(request.SceneName);
}

9. 综合结论

框架综合水平

经过深度全量评审,本框架的代码质量超过大多数已发布商业独立 AA 游戏,在以下方面达到或超越行业最高标准:

亮点 具体体现
事件系统 RAII 模式 CompositeDisposable + EventSubscription (readonly struct),行业领先
BatchLOS Swap-and-pop O(1) 注册/注销,帧预算均分,含升级路径文档
EventChain 帧内去重 _evaluatePending + Update 合并,同帧多事件单次评估
StatusEffect 双结构 + 工厂 List + Dict 双结构兼顾遍历/查询,工厂运行时可扩展
存档系统 原子写入 + HMAC + DLC 字段 + 版本迁移架构,工程化程度接近 AAA
数据驱动覆盖率 连击段数、Boss 阶段、状态效果、护符效果,全部通过 SO 配置
编辑器工具链 EventBusMonitor + ScaffoldTools + 多窗口,超出同体量框架标准
GameIds 统一常量 消除 magic stringIDE 补全 + 编译期校验
SpeedrunTimer 整秒更新 每帧跳过字符串重建,细节处理体现工程素养

剩余问题概览

优先级 数量 核心内容
🔴 2 TutorialManager DontDestroyOnLoadLocalizationManager 静态类 + PlayerPrefs
🟡 5 DialogueManager / AchievementManager / ProjectileManager / TutorialManager 缺少接口AnalyticsManager 混合静态模式
🟢 5 EquipmentManager OnLoad GCGlobalObjectPool LinkedListSceneLoader 失败恢复magic string

修复以上 7+5 个问题后,框架可达到完全无设计混用、模式统一、商业可发布的代码质量标准。


本评审基于全量源码静态分析28 个子系统,逐文件阅读)。
建议优先修复 H-1 和 M-1~M-5接口补完然后合并至 L 级别优化,一次性收官。