926 lines
39 KiB
Markdown
926 lines
39 KiB
Markdown
# BaseGames Framework — 全量深度评审
|
||
|
||
> 评审时间:2026-05-12(基于上轮修复后的最新代码)
|
||
> 评审范围:`Assets/Scripts/` 全目录(28 个子系统,全量阅读)
|
||
> 评审标准:成熟商业动作 RPG(Unity 2022.3 LTS / C#)
|
||
> 框架定位:新框架,不需要兼容,不需要兜底;追求纯净、统一、数据逻辑一致性
|
||
|
||
---
|
||
|
||
## 目录
|
||
|
||
1. [总体评分](#1-总体评分)
|
||
2. [架构设计:系统级深析](#2-架构设计系统级深析)
|
||
- [2.1 全局架构层次 & 依赖图](#21-全局架构层次--依赖图)
|
||
- [2.2 事件系统](#22-事件系统)
|
||
- [2.3 ServiceLocator 服务模型](#23-servicelocator-服务模型)
|
||
- [2.4 存档系统](#24-存档系统)
|
||
- [2.5 战斗系统](#25-战斗系统)
|
||
- [2.6 玩家状态机](#26-玩家状态机)
|
||
- [2.7 敌人系统](#27-敌人系统)
|
||
- [2.8 任务 & 事件链](#28-任务--事件链)
|
||
- [2.9 装备 & 护符](#29-装备--护符)
|
||
- [2.10 世界系统](#210-世界系统)
|
||
- [2.11 本地化系统(问题)](#211-本地化系统问题)
|
||
- [2.12 Persistent 场景服务注册(问题)](#212-persistent-场景服务注册问题)
|
||
3. [性能](#3-性能)
|
||
4. [可扩展性](#4-可扩展性)
|
||
5. [编辑器友好性](#5-编辑器友好性)
|
||
6. [使用便利性](#6-使用便利性)
|
||
7. [问题清单(优先级排序)](#7-问题清单优先级排序)
|
||
8. [修复方案](#8-修复方案)
|
||
9. [综合结论](#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 订阅模式 — 商业顶级
|
||
|
||
```csharp
|
||
// 全框架统一(除下述问题外已 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 — 高效的帧内去重
|
||
|
||
```csharp
|
||
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,无遗漏。
|
||
|
||
```csharp
|
||
[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 双结构设计 — 高性能
|
||
|
||
```csharp
|
||
private readonly List<StatusEffect> _activeList = new();
|
||
private readonly Dictionary<StatusEffectType, StatusEffect> _activeIndex = new();
|
||
```
|
||
|
||
- `_activeList`:Update 时逆序遍历(避免移除时索引错位)
|
||
- `_activeIndex`:O(1) 类型查找(叠层 / 净化 / 状态查询)
|
||
- 工厂字典 `_effectFactories`:运行时可注册自定义效果,Boss 可扩展
|
||
|
||
`MaterialPropertyBlock` 修改 Shader 参数,不影响共享材质——正确的性能实践。
|
||
|
||
#### ✅ DamageInfo Builder 模式
|
||
|
||
```csharp
|
||
// 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` 时创建后常驻,无频繁分配。
|
||
|
||
```csharp
|
||
// 状态字典使用 Type 为 key,查询 O(1)
|
||
private readonly Dictionary<Type, PlayerStateBase> _states = new();
|
||
```
|
||
|
||
#### ✅ 数据驱动连击与 Animancer 帧事件
|
||
|
||
```csharp
|
||
// 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 动作游戏的工业级实践。
|
||
|
||
#### ✅ 冲刺无敌帧通过状态对象表达
|
||
|
||
```csharp
|
||
// DashState
|
||
public override bool IsInvincible => true;
|
||
|
||
// PlayerController.TakeDamage
|
||
if (_currentState?.IsInvincible == true) return; // 不进入受击硬直
|
||
```
|
||
|
||
无敌帧语义清晰:状态本身声明是否无敌,控制器按声明行事,无任何硬编码状态类型判断。
|
||
|
||
#### ✅ Editor 状态转换白名单
|
||
|
||
```csharp
|
||
#if UNITY_EDITOR
|
||
public virtual IReadOnlyList<Type> ValidTransitions => Array.Empty<Type>();
|
||
#endif
|
||
```
|
||
|
||
编辑器下每次转换可验证合法性,Release 构建零开销。这是大型状态机调试的标准辅助手段。
|
||
|
||
---
|
||
|
||
### 2.7 敌人系统
|
||
|
||
#### ✅ BatchLOSSystem — 高性能帧分布视线检测
|
||
|
||
```csharp
|
||
// 每帧轮询部分请求者(均匀分配,避免单帧全量射线)
|
||
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 任务无需修改。
|
||
|
||
```csharp
|
||
// 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) 查找,完全正确
|
||
|
||
```csharp
|
||
// 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 动态获取,不依赖静态实例:
|
||
|
||
```csharp
|
||
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 依赖封装 — 优秀设计
|
||
|
||
```csharp
|
||
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` 结果通过返回值表达
|
||
|
||
```csharp
|
||
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 作运行时状态容器
|
||
|
||
```csharp
|
||
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`
|
||
|
||
```csharp
|
||
public class PuzzleSwitch : MonoBehaviour, ISwitchable, IInteractable
|
||
```
|
||
|
||
`PuzzleWire` / `PuzzleReceiver` / `PuzzleDoor` 均通过接口通信,无直接类型依赖。新增谜题元素只需实现接口,无需修改任何现有代码。
|
||
|
||
#### ✅ RoomTransition — 数据驱动场景切换
|
||
|
||
`_requiresKeyItem` + `_requiredItemId` 通过 `WorldStateRegistry.IsCollected()` 检查。传送需求写在配置中,无游戏逻辑代码。
|
||
|
||
---
|
||
|
||
### 2.11 本地化系统(问题)
|
||
|
||
#### ⚠️ 问题:LocalizationManager 是静态类,游离于框架服务模型之外
|
||
|
||
```csharp
|
||
// 当前:静态类,无法通过 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 与框架约定冲突
|
||
|
||
```csharp
|
||
// 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
|
||
|
||
```csharp
|
||
// 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 循环向后遍历避免:
|
||
|
||
```csharp
|
||
// 当前:分配 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 工厂模式 ✅ 最佳实践
|
||
|
||
```csharp
|
||
// 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` 经本轮修复后版本号对齐,架构已预留分版本迁移方法:
|
||
|
||
```csharp
|
||
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 — 统一字符串常量 ✅
|
||
|
||
```csharp
|
||
// 全框架统一 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 属性代理 ✅ 状态类友好
|
||
|
||
```csharp
|
||
// PlayerController 将所有依赖以只读属性暴露,状态类通过 Owner.X 访问
|
||
public PlayerMovement Movement => _movement;
|
||
public PlayerStats Stats => _stats;
|
||
public AnimancerComponent Animancer => _animancer;
|
||
// ...
|
||
```
|
||
|
||
状态类通过 `Owner.X` 访问,无需向构造函数传入多个参数,新增状态只需 `new MyState(owner)` 一行。
|
||
|
||
### 6.4 `NullAudioService` 缺省服务 ✅
|
||
|
||
```csharp
|
||
// 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
|
||
|
||
```csharp
|
||
// TutorialManager.Awake() — 删除以下行
|
||
// DontDestroyOnLoad(gameObject); ← 删除
|
||
```
|
||
|
||
`TutorialManager` 应位于 Persistent 场景的 GameManagers GameObject 下,由场景生命周期自动保证跨场景存活,无需自行调用。
|
||
|
||
---
|
||
|
||
### Fix H-2:LocalizationManager 迁移为实例服务
|
||
|
||
**步骤 1**:定义接口
|
||
|
||
```csharp
|
||
// 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 读写语言设置
|
||
|
||
```csharp
|
||
// 语言偏好通过 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**:调用方迁移
|
||
|
||
```csharp
|
||
// 旧:静态调用
|
||
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 添加接口
|
||
|
||
```csharp
|
||
// 新增接口
|
||
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 添加接口
|
||
|
||
```csharp
|
||
// 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);
|
||
}
|
||
```
|
||
|
||
注册时改为接口类型:
|
||
```csharp
|
||
ServiceLocator.Register<IAchievementService>(this);
|
||
ServiceLocator.Register<IProjectileService>(this);
|
||
```
|
||
|
||
---
|
||
|
||
### Fix M-4:AnalyticsManager 去除静态 Facade
|
||
|
||
将静态 `Track()` 改为实例方法,调用方通过 `ServiceLocator` 访问:
|
||
|
||
```csharp
|
||
// 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);
|
||
}
|
||
```
|
||
|
||
调用方:
|
||
```csharp
|
||
ServiceLocator.GetOrDefault<IAnalyticsService>()?.TrackBossKill(bossId, duration, deaths);
|
||
```
|
||
|
||
---
|
||
|
||
### Fix L-1:EquipmentManager.OnLoad 消除 .ToList()
|
||
|
||
```csharp
|
||
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
|
||
|
||
```csharp
|
||
// 用简单计数器替代 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 加载失败恢复
|
||
|
||
```csharp
|
||
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 级别优化,一次性收官。*
|