356 lines
16 KiB
Markdown
356 lines
16 KiB
Markdown
# zeling_v2 代码深度评审 — Q3 2026
|
||
|
||
> **审查周期**: 第三轮 (Q3) — 延续 DeepDive_2026_Q2.md
|
||
> **范围**: 本周期新增审查模块 + 对全项目做整体架构复查
|
||
> **基准**: 成熟商业 2D 动作平台游戏(空洞骑士 / 盐和献祭 / Axiom Verge)标准
|
||
> **Unity 版本**: 2022.3 LTS / .NET Standard 2.1 / C# 9
|
||
> **审查工程师**: GitHub Copilot (Claude Sonnet 4.6)
|
||
|
||
---
|
||
|
||
## 一、审查范围
|
||
|
||
### 本轮新增审查模块
|
||
|
||
| 模块 | 核心文件 |
|
||
|------|----------|
|
||
| 游戏主管理 | `Core/GameManager.cs`, `Core/GameStateMachine.cs` |
|
||
| 音频系统 | `Audio/AudioManager.cs`, `Audio/BGMController.cs`, `Audio/CombatSFXController.cs`, `Audio/AudioMixerKeys.cs` |
|
||
| 玩家状态机 | `Player/States/PlayerController.cs`, `AttackState.cs`, `DashState.cs` |
|
||
| 玩家战斗 | `Player/PlayerCombat.cs`, `Player/PlayerStats.cs`, `Player/PlayerMovement.cs` |
|
||
| 状态效果 | `Combat/StatusEffects/StatusEffectManager.cs` |
|
||
| 相机 | `Camera/CameraStateController.cs` |
|
||
| 敌人基础 | `Enemies/EnemyBase.cs`, `Enemies/EnemyMovement.cs`, `Enemies/EnemyCombat.cs` |
|
||
| Boss | `Enemies/Boss/BossBase.cs`, `Enemies/Boss/BossSkillExecutor.cs` |
|
||
| Boss 模式 | `Enemies/Boss/Patterns/TelegraphSystem.cs` |
|
||
| 招架系统 | `Parry/ParrySystem.cs` |
|
||
| 技能系统 | `Skills/SkillManager.cs` |
|
||
| 对话 | `Dialogue/DialogueManager.cs` |
|
||
| 服务层 | `Core/GameServiceRegistrar.cs`, `Core/ServiceLocator.cs` |
|
||
| 场景 | `Core/SceneLoader.cs` |
|
||
| UI | `UI/UIManager.cs` |
|
||
| 世界 | `World/RoomController.cs`, `World/RoomTransition.cs`, `World/MovingPlatform.cs`, `World/CrumblePlatform.cs` |
|
||
| 对象池 | `Core/Pool/GlobalObjectPool.cs` |
|
||
| VFX | `VFX/VFXPool.cs` |
|
||
| 教程 | `Tutorial/TutorialManager.cs` |
|
||
| 表单控制 | `Player/FormController.cs` |
|
||
| 过场 | `Cutscene/CutsceneManager.cs` |
|
||
| 碰撞 | `Combat/HitBox.cs` |
|
||
| 设置 | `Core/SettingsManager.cs`, `Core/GlobalSettingsSO.cs` |
|
||
|
||
---
|
||
|
||
## 二、五维评分表
|
||
|
||
| 维度 | Q2 得分 | **Q3 得分** | 变化 | 说明 |
|
||
|------|---------|------------|------|------|
|
||
| **架构设计** | 8.0 | **8.0** | → | 整体架构依然优秀;发现 GameManager/CameraStateController/TutorialManager 存在"双身份"问题(既用静态单例又用 ServiceLocator),ServiceLocator 的一致性尚未全面落实 |
|
||
| **性能** | 7.5 | **7.5** | → | BGM Source 作为 SFX 兜底的 bug 严重影响实际运行;EnemyMovement 的 localScale 翻转会引发物理帧同步问题;其余性能表现良好(无 GC alloc、批量 LOS、sqrMagnitude 等) |
|
||
| **可扩展性** | 8.5 | **8.5** | → | SO 配置驱动、事件频道、HitBox/DamageInfo 结构体等设计均出色;BossSkillExecutor 的可组合 SO 技能序列是同类游戏中少见的干净实现 |
|
||
| **编辑器友好** | 8.0 | **8.0** | → | EventBusMonitor Editor 调试、Inspector Tooltip 完整、RequireComponent 正确;CombatSFXController 每个音效类型单独 [SerializeField] 利于调试 |
|
||
| **使用便利性** | 7.8 | **7.8** | → | ServiceLocator 模式整体可用;AudioManager.Initialize() 空实现导致音量从未从存档恢复;DialogueManager 未处理 _inputReader 为 null 的场景 |
|
||
| **综合** | 7.96 | **7.96** | → | Q2 修复后整体稳固,Q3 发现问题以运行时正确性为主,不涉及架构重构 |
|
||
|
||
---
|
||
|
||
## 三、问题全表(Q3 新发现)
|
||
|
||
### A — 架构问题
|
||
|
||
| ID | 文件 | 严重性 | 描述 |
|
||
|----|------|-------|------|
|
||
| A-1 | `Camera/CameraStateController.cs` | ⚠️ 中 | `public static CameraStateController Instance` 未标注 `[System.Obsolete]`;该类同时注册 ServiceLocator,双重访问入口造成调用混乱 |
|
||
| A-2 | `Core/GameManager.cs` | ⚠️ 中 | `public static GameManager Instance` 使用纯静态单例,从未向 ServiceLocator 注册,与项目模式不一致;其他模块无法通过 ServiceLocator 获取 GameManager |
|
||
| A-3 | `Tutorial/TutorialManager.cs` | 🔵 低 | 保留原始 `DontDestroyOnLoad` 静态单例,未整合 ServiceLocator;与 GameServiceRegistrar 注册模式不一致 |
|
||
| A-4 | `Enemies/Boss/Patterns/TelegraphSystem.cs` | 🔵 低 | 直接访问 `GlobalObjectPool.Instance`(已标 `[Obsolete]`),应改用 `ServiceLocator.Get<IObjectPoolService>()` |
|
||
|
||
### P — 性能与正确性问题
|
||
|
||
| ID | 文件 | 严重性 | 描述 |
|
||
|----|------|-------|------|
|
||
| P-1 | `Audio/AudioManager.cs` | 🔴 高 | `NextSFXSource()`: `_sfxSources` 为空时返回 `_bgmSourceA`,导致 SFX 通过 BGM AudioSource 播放,破坏交叉淡入淡出的音量状态 |
|
||
| P-2 | `Combat/StatusEffects/StatusEffectManager.cs` | 🔵 低 | `CleanseEffect()` 调用 `_activeList.Remove(effect)` — 已知引用的情况下做 O(n) 线性扫描;可改用 `List<T>.RemoveSwapBack` 或直接索引删除 |
|
||
| P-3 | `Enemies/EnemyMovement.cs` | ⚠️ 中 | `UpdateFacing()` 用 `transform.localScale` X 翻转,影响整个 GameObject 层次(含碰撞体偏移),应改为 `SpriteRenderer.flipX`(PlayerMovement 已采用此模式) |
|
||
|
||
### D — 代码质量 / 开发体验问题
|
||
|
||
| ID | 文件 | 严重性 | 描述 |
|
||
|----|------|-------|------|
|
||
| D-1 | `Dialogue/DialogueManager.cs` | ⚠️ 中 | `OnEnable()` / `OnDisable()` 直接访问 `_inputReader.SubmitEvent` 无 null 检查;若 Inspector 未绑定 `_inputReader` 则引发 NullReferenceException |
|
||
| D-2 | `Skills/SkillManager.cs` | 🔵 低 | 文件顶部缺少 `using System.Collections.Generic;`,`UpdateSkillSet()` 内用完整限定名 `System.Collections.Generic.List<FormSkillSO>` 内联书写,降低可读性 |
|
||
| D-3 | `Audio/AudioManager.cs` | ⚠️ 中 | `Initialize()` 为空方法(TODO 注释)——音量从未从存档/默认值中恢复,每次启动均使用 AudioMixer 默认值,忽略用户设置 |
|
||
| D-4 | `Audio/AudioManager.cs` | 🔵 低 | `PlayBGM(string key)` 和 `PlaySFX(string key)` 仅发出 `Debug.LogWarning`(Phase 2 占位符)——已知问题,文档记录备忘 |
|
||
| D-5 | `Enemies/EnemyCombat.cs` | 🔵 低 | `StartAttack()` 有 TODO 注释,动画播放未实现——已知缺口,不影响当前流程 |
|
||
| D-6 | `Core/SettingsManager.cs` | ⚠️ 中 | `SettingsManager` 未向 ServiceLocator 注册自身,导致 `AudioManager.Initialize()` 等需要读取设置的组件无法通过 ServiceLocator 访问已加载的设置数据 |
|
||
|
||
---
|
||
|
||
## 四、亮点记录(继续保持)
|
||
|
||
这些实现值得在商业项目中重用,不做修改:
|
||
|
||
### HitBox + DamageInfo 零 GC 设计
|
||
```csharp
|
||
// HitBox.cs — struct 工厂,无堆分配
|
||
var info = DamageInfo.From(_currentSource, knockDir, _attackerTransform.position, layer);
|
||
```
|
||
`DamageInfo` 作为 struct 传递,配合 `HashSet<Collider2D>` 防重复命中,在高密度战斗中避免 GC 压力,是商业级设计。
|
||
|
||
### BossSkillExecutor 可组合 SO 序列
|
||
```csharp
|
||
// BossSkillExecutor.cs
|
||
private IEnumerator ExecuteSkillCoroutine(BossSkillSO skill)
|
||
{
|
||
// 并行 VulnerabilityWindow + 攻击序列
|
||
// SO 驱动,设计师无需修改代码即可调整Boss行为
|
||
}
|
||
```
|
||
`BossSkillSO → SkillSequenceSO → AttackPatternSO` 三层嵌套 SO 结构支持 RepeatIfPlayerInRange、MaxRepeatCount 等行为配置,是少见的干净 Boss 设计。
|
||
|
||
### BaseEventChannelSO 订阅句柄模式
|
||
```csharp
|
||
// 安全订阅,配合 CompositeDisposable 防止内存泄漏
|
||
public EventSubscription Subscribe(Action<T> callback)
|
||
```
|
||
Editor 环境自动计数 `_subscriberCount`,配合 `EventBusMonitor.Record` 实时调试,优于裸 C# 事件。
|
||
|
||
### GlobalObjectPool LRU 回收
|
||
```csharp
|
||
// 达到 MaxCount 时 O(1) 回收最早活跃对象
|
||
po = aliveList.First.Value;
|
||
aliveList.RemoveFirst();
|
||
po.ForceReturnToPool();
|
||
```
|
||
LinkedList 保持 spawn 顺序,O(1) LRU,是商业游戏对象池的标准做法。
|
||
|
||
### EnemyBase 批量 LOS
|
||
```csharp
|
||
// 避免每个敌人每帧独立执行射线检测
|
||
public virtual bool IsPlayerVisible() => _losResult; // BatchLOSSystem 写入
|
||
```
|
||
通过 BatchLOSSystem 统一处理视线检测,避免 N 个敌人 × N 帧的射线广播,是高性能敌人 AI 的必要优化。
|
||
|
||
### PlayerController 事件驱动 FSM
|
||
```csharp
|
||
// 无 Update() switch 语句,状态对象独立封装逻辑
|
||
Owner.TransitionTo(Owner.GetState<IdleState>());
|
||
```
|
||
每个 State 封装 `OnStateEnter/Exit/Update/FixedUpdate`,配合 `Input.AttackEvent` 驱动,干净解耦,添加新状态无需修改现有状态。
|
||
|
||
---
|
||
|
||
## 五、对比商业标准
|
||
|
||
### 与《空洞骑士》架构对比
|
||
|
||
| 特性 | 空洞骑士参考 | zeling_v2 | 差距 |
|
||
|------|------------|----------|------|
|
||
| 服务访问 | 静态单例(较老实践) | ServiceLocator + 接口 | ✅ 更先进 |
|
||
| 配置驱动 | SO 广泛使用 | SO + 事件频道 | ✅ 更彻底 |
|
||
| Boss 行为 | 硬编码状态机 | 可组合 SO 序列 | ✅ 更灵活 |
|
||
| 音频系统 | FMOD | AudioMixer + 双源淡入淡出 | ⚠️ 可用,但初始化缺口 |
|
||
| 单例一致性 | 全静态单例 | 混合(部分 ServiceLocator,部分静态) | ⚠️ 需统一 |
|
||
| 存档音量恢复 | 启动时读取 | ❌ Initialize() 为空 | 🔴 缺失功能 |
|
||
|
||
### 与《盐和献祭》性能实践对比
|
||
|
||
| 特性 | 参考实践 | zeling_v2 | 状态 |
|
||
|------|---------|----------|------|
|
||
| 敌人 facing 翻转 | SpriteRenderer.flipX | transform.localScale | ⚠️ 需修复 |
|
||
| 对象池 | 统一池 + 预热 | GlobalObjectPool + WarmupAsync | ✅ 正确 |
|
||
| 攻击判定 | HashSet 防重复 | HitBox._hitThisActivation | ✅ 正确 |
|
||
| SFX 多源轮转 | 多源池 | ✅ 有实现,但空池兜底有 bug | ⚠️ P-1 |
|
||
|
||
---
|
||
|
||
## 六、修复优先级与计划
|
||
|
||
| 优先级 | ID | 文件 | 修复方案 |
|
||
|--------|-----|------|---------|
|
||
| 🔴 立即修复 | P-1 | `AudioManager.cs` | `NextSFXSource()` 空池时返回 `null`;`PlaySFX` 增加 null 守卫 |
|
||
| ⚠️ 本周期修复 | D-3 | `AudioManager.cs` + `SettingsManager.cs` | 实现 `Initialize()` 从 SettingsManager 读取音量;SettingsManager 注册到 ServiceLocator |
|
||
| ⚠️ 本周期修复 | D-1 | `DialogueManager.cs` | `OnEnable/OnDisable` 增加 `_inputReader != null` 守卫 |
|
||
| ⚠️ 本周期修复 | A-1 | `CameraStateController.cs` | `Instance` 字段标注 `[System.Obsolete]` |
|
||
| ⚠️ 本周期修复 | A-2 | `GameManager.cs` | `Awake()` 中注册 ServiceLocator;`Instance` 标注 `[System.Obsolete]` |
|
||
| ⚠️ 本周期修复 | P-3 | `EnemyMovement.cs` | `UpdateFacing()` 改用 `SpriteRenderer.flipX` |
|
||
| 🔵 优化修复 | D-2 | `SkillManager.cs` | 添加 `using System.Collections.Generic;`,移除内联完整限定名 |
|
||
| 🔵 优化修复 | A-4 | `TelegraphSystem.cs` | 改用 `ServiceLocator.GetOrDefault<IObjectPoolService>()` |
|
||
| 📋 记录待办 | A-3 | `TutorialManager.cs` | ServiceLocator 整合(Phase 2 重构时处理) |
|
||
| 📋 记录待办 | D-4/D-5 | `AudioManager.cs`, `EnemyCombat.cs` | Phase 2 实现占位符 |
|
||
|
||
---
|
||
|
||
## 七、修复详情(Q3 已应用)
|
||
|
||
### Fix P-1 — AudioManager SFX 池空时 BGM Source 污染
|
||
|
||
**问题代码**:
|
||
```csharp
|
||
private AudioSource NextSFXSource()
|
||
{
|
||
if (_sfxSources == null || _sfxSources.Length == 0) return _bgmSourceA; // ❌ 污染 BGM Source
|
||
return _sfxSources[_sfxRoundRobin++ % _sfxSources.Length];
|
||
}
|
||
```
|
||
|
||
**修复后**:
|
||
```csharp
|
||
private AudioSource NextSFXSource()
|
||
{
|
||
if (_sfxSources == null || _sfxSources.Length == 0)
|
||
{
|
||
Debug.LogError("[AudioManager] SFX Source 池为空,请在 Inspector 中为 _sfxSources 赋值。");
|
||
return null;
|
||
}
|
||
return _sfxSources[_sfxRoundRobin++ % _sfxSources.Length];
|
||
}
|
||
// PlaySFX 增加 null 守卫
|
||
public void PlaySFX(AudioClip clip, float volumeScale = 1f)
|
||
{
|
||
if (clip == null) return;
|
||
var src = NextSFXSource();
|
||
if (src == null) return; // ← 新增
|
||
src.volume = volumeScale;
|
||
src.PlayOneShot(clip);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Fix D-3 — AudioManager.Initialize() 实现音量恢复
|
||
|
||
**问题**:
|
||
1. `AudioManager.Initialize()` 为空存根,Volume 永远不从存档恢复
|
||
2. `SettingsManager` 未注册到 ServiceLocator,其他组件无法访问已加载的设置数据
|
||
|
||
**修复**:
|
||
- `SettingsManager.cs` 新增 `Awake()` 向 ServiceLocator 注册自身
|
||
- `AudioManager.Initialize()` 通过 ServiceLocator 获取 SettingsManager,应用四路音量
|
||
- `AudioManager.Awake()` 在注册 IAudioService 后调用 `Initialize()`
|
||
|
||
---
|
||
|
||
### Fix A-1 — CameraStateController Instance 标注过时
|
||
|
||
```csharp
|
||
[System.Obsolete("Use ServiceLocator.Get<ICameraService>() instead.")]
|
||
public static CameraStateController Instance { get; private set; }
|
||
```
|
||
|
||
---
|
||
|
||
### Fix A-2 — GameManager 注册 ServiceLocator
|
||
|
||
```csharp
|
||
private void Awake()
|
||
{
|
||
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
|
||
Instance = this;
|
||
ServiceLocator.Register<GameManager>(this); // ← 新增
|
||
DontDestroyOnLoad(transform.root.gameObject);
|
||
// ...
|
||
}
|
||
|
||
[System.Obsolete("Use ServiceLocator.Get<GameManager>() instead.")]
|
||
public static GameManager Instance { get; private set; }
|
||
```
|
||
|
||
---
|
||
|
||
### Fix D-1 — DialogueManager _inputReader null 守卫
|
||
|
||
```csharp
|
||
private void OnEnable()
|
||
{
|
||
if (_inputReader != null) _inputReader.SubmitEvent += OnSubmit; // ← 添加 null check
|
||
}
|
||
private void OnDisable()
|
||
{
|
||
if (_inputReader != null) _inputReader.SubmitEvent -= OnSubmit; // ← 添加 null check
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Fix D-2 — SkillManager using 指令
|
||
|
||
```csharp
|
||
// 文件顶部添加
|
||
using System.Collections.Generic;
|
||
|
||
// UpdateSkillSet() 内替换
|
||
var active = new List<FormSkillSO>(3); // ← 移除 System.Collections.Generic. 限定名
|
||
```
|
||
|
||
---
|
||
|
||
### Fix P-3 — EnemyMovement SpriteRenderer.flipX
|
||
|
||
```csharp
|
||
// 新增字段
|
||
[SerializeField] private SpriteRenderer _spriteRenderer;
|
||
|
||
// UpdateFacing 替换
|
||
private void UpdateFacing(float dir)
|
||
{
|
||
if (Mathf.Approximately(dir, 0f)) return;
|
||
int newDir = dir > 0f ? 1 : -1;
|
||
if (newDir == _facingDir) return;
|
||
_facingDir = newDir;
|
||
if (_spriteRenderer != null)
|
||
_spriteRenderer.flipX = newDir < 0;
|
||
else
|
||
{
|
||
// Fallback: localScale(当 Inspector 未绑定 SpriteRenderer 时)
|
||
Vector3 s = transform.localScale;
|
||
transform.localScale = new Vector3(Mathf.Abs(s.x) * newDir, s.y, s.z);
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Fix B-1 — TelegraphSystem 改用 ServiceLocator
|
||
|
||
```csharp
|
||
// Before
|
||
var pool = GlobalObjectPool.Instance;
|
||
|
||
// After
|
||
var pool = BaseGames.Core.ServiceLocator.GetOrDefault<BaseGames.Core.Pool.IObjectPoolService>();
|
||
```
|
||
|
||
---
|
||
|
||
## 八、累计修复记录(三轮汇总)
|
||
|
||
| 轮次 | 修复数 | 主题 |
|
||
|------|-------|------|
|
||
| Q1 (DeepDive_2026.md) | 15 | 命名空间、反射清理、SaveManager 迁移、基础 Null 守卫 |
|
||
| Q2 (DeepDive_2026_Q2.md) | 12 | SaveManager→ServiceLocator、死代码清理、TogglePause 逻辑、IndentFix |
|
||
| Q3 (本文档) | 9 | BGM 源污染 bug、音量恢复实现、SpriteRenderer 翻转、架构 Obsolete 标注、null 守卫 |
|
||
| **合计** | **36** | |
|
||
|
||
---
|
||
|
||
## 九、后续建议(Phase 2 / 长期)
|
||
|
||
1. **TutorialManager ServiceLocator 整合**
|
||
将 `DontDestroyOnLoad` + 静态单例替换为 GameServiceRegistrar 注册,与 SaveManager/DialogueManager 保持一致。
|
||
|
||
2. **SettingsManager Apply() 补全音量应用**
|
||
在 `SettingsManager.Apply()` 中加入 AudioMixer 音量设置(通过 ServiceLocator 获取 IAudioService),避免 SettingsManager 和 AudioManager 之间的职责模糊。
|
||
|
||
3. **AudioManager Phase 2 占位符实现**
|
||
`PlayBGM(string key)` 和 `PlaySFX(string key)` 接入 AudioEventSO,替换当前 LogWarning 占位符。
|
||
|
||
4. **EnemyCombat.StartAttack() 动画**
|
||
补全动画播放逻辑,与 PlayerCombat 的 HitBox 事件链对齐。
|
||
|
||
5. **StatusEffectManager.CleanseEffect 优化**
|
||
将 `List<T>` 替换为带索引的数据结构(如 `Dictionary<string, StatusEffect>`),O(n) Remove → O(1) Remove。
|
||
|
||
---
|
||
|
||
*文档生成时间: 2026-Q3 | 工具: GitHub Copilot Claude Sonnet 4.6*
|