多轮审查和修复

This commit is contained in:
2026-05-12 15:34:08 +08:00
parent f55d2a57c3
commit ebbbb7332e
805 changed files with 838724 additions and 1905 deletions

View File

@@ -0,0 +1,925 @@
# BaseGames Framework — 全量深度评审
> 评审时间2026-05-12基于上轮修复后的最新代码
> 评审范围:`Assets/Scripts/` 全目录28 个子系统,全量阅读)
> 评审标准:成熟商业动作 RPGUnity 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.ApplyStatusEffecttype
```
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` 值类型,热路径零 GCBuilder 以 `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-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 任务无需修改。
```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;
// OnEnableRAII 订阅
_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 混合订阅 ✅ 合理
框架内共存两套订阅机制:
- **EventChannelSO**:跨场景/跨程序集通信
- **C# eventInputReaderSO**:玩家状态机内部输入响应
这是合理的设计选择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-1TutorialManager 移除 DontDestroyOnLoad
```csharp
// TutorialManager.Awake() — 删除以下行
// DontDestroyOnLoad(gameObject); ← 删除
```
`TutorialManager` 应位于 Persistent 场景的 GameManagers GameObject 下,由场景生命周期自动保证跨场景存活,无需自行调用。
---
### Fix H-2LocalizationManager 迁移为实例服务
**步骤 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-1DialogueManager 添加接口
```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-3AchievementManager + 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-4AnalyticsManager 去除静态 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-1EquipmentManager.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-2GlobalObjectPool 活跃计数改为 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-3SceneLoader 加载失败恢复
```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 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 级别优化,一次性收官。*