16 KiB
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 设计
// HitBox.cs — struct 工厂,无堆分配
var info = DamageInfo.From(_currentSource, knockDir, _attackerTransform.position, layer);
DamageInfo 作为 struct 传递,配合 HashSet<Collider2D> 防重复命中,在高密度战斗中避免 GC 压力,是商业级设计。
BossSkillExecutor 可组合 SO 序列
// BossSkillExecutor.cs
private IEnumerator ExecuteSkillCoroutine(BossSkillSO skill)
{
// 并行 VulnerabilityWindow + 攻击序列
// SO 驱动,设计师无需修改代码即可调整Boss行为
}
BossSkillSO → SkillSequenceSO → AttackPatternSO 三层嵌套 SO 结构支持 RepeatIfPlayerInRange、MaxRepeatCount 等行为配置,是少见的干净 Boss 设计。
BaseEventChannelSO 订阅句柄模式
// 安全订阅,配合 CompositeDisposable 防止内存泄漏
public EventSubscription Subscribe(Action<T> callback)
Editor 环境自动计数 _subscriberCount,配合 EventBusMonitor.Record 实时调试,优于裸 C# 事件。
GlobalObjectPool LRU 回收
// 达到 MaxCount 时 O(1) 回收最早活跃对象
po = aliveList.First.Value;
aliveList.RemoveFirst();
po.ForceReturnToPool();
LinkedList 保持 spawn 顺序,O(1) LRU,是商业游戏对象池的标准做法。
EnemyBase 批量 LOS
// 避免每个敌人每帧独立执行射线检测
public virtual bool IsPlayerVisible() => _losResult; // BatchLOSSystem 写入
通过 BatchLOSSystem 统一处理视线检测,避免 N 个敌人 × N 帧的射线广播,是高性能敌人 AI 的必要优化。
PlayerController 事件驱动 FSM
// 无 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 污染
问题代码:
private AudioSource NextSFXSource()
{
if (_sfxSources == null || _sfxSources.Length == 0) return _bgmSourceA; // ❌ 污染 BGM Source
return _sfxSources[_sfxRoundRobin++ % _sfxSources.Length];
}
修复后:
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() 实现音量恢复
问题:
AudioManager.Initialize()为空存根,Volume 永远不从存档恢复SettingsManager未注册到 ServiceLocator,其他组件无法访问已加载的设置数据
修复:
SettingsManager.cs新增Awake()向 ServiceLocator 注册自身AudioManager.Initialize()通过 ServiceLocator 获取 SettingsManager,应用四路音量AudioManager.Awake()在注册 IAudioService 后调用Initialize()
Fix A-1 — CameraStateController Instance 标注过时
[System.Obsolete("Use ServiceLocator.Get<ICameraService>() instead.")]
public static CameraStateController Instance { get; private set; }
Fix A-2 — GameManager 注册 ServiceLocator
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 守卫
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 指令
// 文件顶部添加
using System.Collections.Generic;
// UpdateSkillSet() 内替换
var active = new List<FormSkillSO>(3); // ← 移除 System.Collections.Generic. 限定名
Fix P-3 — EnemyMovement SpriteRenderer.flipX
// 新增字段
[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
// 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 / 长期)
-
TutorialManager ServiceLocator 整合
将DontDestroyOnLoad+ 静态单例替换为 GameServiceRegistrar 注册,与 SaveManager/DialogueManager 保持一致。 -
SettingsManager Apply() 补全音量应用
在SettingsManager.Apply()中加入 AudioMixer 音量设置(通过 ServiceLocator 获取 IAudioService),避免 SettingsManager 和 AudioManager 之间的职责模糊。 -
AudioManager Phase 2 占位符实现
PlayBGM(string key)和PlaySFX(string key)接入 AudioEventSO,替换当前 LogWarning 占位符。 -
EnemyCombat.StartAttack() 动画
补全动画播放逻辑,与 PlayerCombat 的 HitBox 事件链对齐。 -
StatusEffectManager.CleanseEffect 优化
将List<T>替换为带索引的数据结构(如Dictionary<string, StatusEffect>),O(n) Remove → O(1) Remove。
文档生成时间: 2026-Q3 | 工具: GitHub Copilot Claude Sonnet 4.6