39 KiB
BaseGames Framework — 全量深度评审
评审时间:2026-05-12(基于上轮修复后的最新代码) 评审范围:
Assets/Scripts/全目录(28 个子系统,全量阅读) 评审标准:成熟商业动作 RPG(Unity 2022.3 LTS / C#) 框架定位:新框架,不需要兼容,不需要兜底;追求纯净、统一、数据逻辑一致性
目录
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();
EventSubscription 为 readonly 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.ApplyStatusEffect(type)
8 步均通过接口隔离,无任何具体类型直接依赖。ParrySystem 仅暴露 bool 窗口状态,伤害数据留在 Combat 层,完整跨程序集解耦。
✅ StatusEffectManager 双结构设计 — 高性能
private readonly List<StatusEffect> _activeList = new();
private readonly Dictionary<StatusEffectType, StatusEffect> _activeIndex = new();
_activeList:Update 时逆序遍历(避免移除时索引错位)_activeIndex:O(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 值类型,热路径零 GC;Builder 以 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-pop:O(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;
// OnEnable:RAII 订阅
_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 |
struct,Builder 仅复杂场景分配 |
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-1(低):EquipmentManager.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-2(低):GlobalObjectPool._alive 使用 LinkedList<PooledObject> 追踪活跃对象,每次 Spawn 时分配 LinkedListNode。如果 alive 追踪仅用于限制上限,可改为纯计数器(int _aliveCount)消除节点分配。
⚠️ 问题 P-3(低):SceneLoader.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]均有注释说明原因(-2000GameServiceRegistrar →-1000事件注册 →-200LOS 系统 →-100PlayerController →+50UIManager) 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 { ... }
}
本地化字符串 Key(LocalizationManager.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 混合订阅 ✅ 合理
框架内共存两套订阅机制:
- EventChannel(SO):跨场景/跨程序集通信
- C# event(InputReaderSO):玩家状态机内部输入响应
这是合理的设计选择:InputReaderSO 的 event Action 是状态机内部信号,不需要全局 SO 资产。AttackState / ParryState 订阅 Input C# event,于 OnStateExit 取消订阅——语义清晰,无泄漏风险。保持现状,无需统一。
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 |
_alive 用 LinkedList 追踪活跃对象,每次 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-1:TutorialManager 移除 DontDestroyOnLoad
// TutorialManager.Awake() — 删除以下行
// DontDestroyOnLoad(gameObject); ← 删除
TutorialManager 应位于 Persistent 场景的 GameManagers GameObject 下,由场景生命周期自动保证跨场景存活,无需自行调用。
Fix H-2:LocalizationManager 迁移为实例服务
步骤 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;
}
步骤 2:LocalizationManager 实现接口 + 从 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-1:DialogueManager 添加接口
// 新增接口
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-3:AchievementManager + 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-4:AnalyticsManager 去除静态 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-1:EquipmentManager.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-2:GlobalObjectPool 活跃计数改为 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-3:SceneLoader 加载失败恢复
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 string,IDE 补全 + 编译期校验 |
| SpeedrunTimer 整秒更新 | 每帧跳过字符串重建,细节处理体现工程素养 |
剩余问题概览
| 优先级 | 数量 | 核心内容 |
|---|---|---|
| 🔴 高 | 2 | TutorialManager DontDestroyOnLoad;LocalizationManager 静态类 + PlayerPrefs |
| 🟡 中 | 5 | DialogueManager / AchievementManager / ProjectileManager / TutorialManager 缺少接口;AnalyticsManager 混合静态模式 |
| 🟢 低 | 5 | EquipmentManager OnLoad GC;GlobalObjectPool LinkedList;SceneLoader 失败恢复;magic string |
修复以上 7+5 个问题后,框架可达到完全无设计混用、模式统一、商业可发布的代码质量标准。
本评审基于全量源码静态分析(28 个子系统,逐文件阅读)。
建议优先修复 H-1 和 M-1~M-5(接口补完),然后合并至 L 级别优化,一次性收官。