19 KiB
代码质量全面评估报告
评估日期:2026-05
评估范围:Assets/Scripts/(25 个程序集,覆盖全部 24 个功能模块)
评估标准:以商业独立/AA 级 2D 动作游戏(如 Hollow Knight、Ori 系列、Dead Cells 技术架构)为基准
评估维度:架构设计、性能、可扩展性、编辑器友好性、使用便利性
综合评分
| 评估维度 | 评分(5 分制) | 简评 |
|---|---|---|
| 架构设计 | ⭐⭐⭐⭐½ | 模块隔离清晰,模式选型专业,少量结构性问题 |
| 性能 | ⭐⭐⭐⭐ | 热路径零 GC、批量 LOS、对象池均到位,有几处隐患 |
| 可扩展性 | ⭐⭐⭐⭐ | 接口驱动+SO数据驱动,新增功能低耦合,主要短板在玩家状态机 |
| 编辑器友好性 | ⭐⭐⭐⭐½ | EventBusMonitor、SO 数据资产、Header 属性完善 |
| 使用便利性 | ⭐⭐⭐⭐ | 依赖注入清晰,部分 API 命名/一致性可改进 |
| 综合 | ⭐⭐⭐⭐ | 接近商业品质,核心问题为单例泛滥和局部硬编码 |
一、架构设计
1.1 程序集划分(Assembly Definition)✅ 优秀
项目将 25+ 个功能域拆分为独立 .asmdef 程序集:
BaseGames.Core → BaseGames.Core.Events → BaseGames.Core.Save
BaseGames.Combat → BaseGames.Combat.StatusEffects
BaseGames.Player → BaseGames.Player.States
BaseGames.Enemies → BaseGames.Enemies.AI → BaseGames.Enemies.Navigation
...
优势:
- 增量编译:修改
BaseGames.Player.States不触发BaseGames.Core重新编译,加快迭代速度 - 强制接口边界:
EnemyBase不可直接访问PlayerController内部,只能通过IDamageable - 符合《Hollow Knight》及同类商业项目的程序集组织标准
不足:
BaseGames.Core.Events程序集中混入了DamageInfo、HitInfo等战斗领域对象(见Core/Events/DamageInfo.cs),违反了"Core.Events 只存事件通道基础设施"的原则,应移至BaseGames.Combat
1.2 ServiceLocator 模式 ✅ 专业
// 接口类型注册,天然支持依赖倒置
ServiceLocator.Register<IAudioService>(realAudioManager);
ServiceLocator.RegisterIfAbsent<IAudioService>(new NullAudioService()); // Null Object 兜底
优势:
RegisterIfAbsent防止多场景重复注册,符合 Persistent 场景架构NullAudioService是教科书级 Null Object 模式,避免 null 检查污染业务代码- Editor 下提供
OverrideForTest/Reset方法,支持单元测试 - 轻量静态字典,无 DI 框架依赖,适合游戏运行时
不足:
- 静态类在场景重载时不会自动清空,若忘记在
GameServiceRegistrar.OnDestroy注销,可能持有失效引用(已通过DontDestroyOnLoad缓解但未根治) - 建议补充
Unregister<T>()方法,便于单元测试隔离
1.3 ScriptableObject 事件频道系统(Event Channel SO)✅ 行业标准
基于 Unity Open Projects 推广的 SO 事件频道模式:
public abstract class BaseEventChannelSO<T> : ScriptableObject
{
public EventSubscription Subscribe(Action<T> callback) { ... } // 返回可 Dispose 句柄
}
优势:
- 发布者/订阅者完全解耦,不需要互相持有引用
EventSubscription+CompositeDisposable构成 Rx-like 生命周期管理,防内存泄漏EventBusMonitor在 Editor 下记录每次调用(频道名、payload、监听器数量、帧号),调试体验优异- SO 作为 Inspector 资产,便于在 Prefab 中任意组合引用,无需代码修改
不足:
- 缺少 事件频道查找表:25+ 个 SO 频道资产散落在
Assets/Data/下,若多人协作或频道名称不统一,容易创建重复频道。EventChannelRegistry已有雏形,建议强制所有频道注册其中
1.4 游戏状态机(GameStateMachine)✅ 稳健
public bool TransitionTo(GameStateId nextId, out string error)
{
if (!_current.ValidNextStates.Contains(nextId)) { error = ...; return false; }
_current?.OnExit(nextId);
_current = next;
_current.OnEnter(prev);
...
}
优势:
- 转换合法性在状态机层面校验(
ValidNextStates),而非散落在各处的 if-else - 状态对象不继承
MonoBehaviour,纯 C# 类,可单元测试 [DefaultExecutionOrder(-1000)]确保 GameManager 最先 Awake
不足:
IGameState.ValidNextStates如果用HashSet<GameStateId>而非IEnumerable<GameStateId>可避免Contains()的 O(n) 查找(当前状态数少影响不大,但属于最佳实践缺失)
1.5 玩家状态机(Player FSM)⚠️ 有结构性问题
PlayerController 声明了 16 个具体状态字段:
private IdleState _idleState;
private RunState _runState;
private JumpState _jumpState;
// ... 共 16 个
优势:
- 状态为纯 POCO(Plain Old C# Object),无 MonoBehaviour 开销
PlayerStateBase持有 Controller 引用并暴露属性,状态类代码简洁
不足(商业级对比):
- 强耦合:新增状态需修改
PlayerController,违反开闭原则。Dead Cells、Celeste 等同类项目通常用Dictionary<Type, PlayerStateBase>或枚举映射表管理状态,PlayerController只需调用_states[StateType.Idle] - 状态切换 API 散落:
Owner.TryTransitionState(Owner.IdleState)中Owner.IdleState是公开属性,但这要求PlayerController对外暴露所有状态实例,破坏封装 - HitBox 时间点硬编码在状态类中:
AttackState中events.Add(0.3f, ...)和events.Add(0.6f, ...)应配置在PlayerAnimationConfigSO而非状态代码中
1.6 伤害流水线 ✅ 设计完善
HitBox.OnTriggerEnter2D → DamageInfo.From(SO) → HurtBox.ReceiveDamage
→ [1] 无敌帧 → [2] 弹反 → [3] 霸体 → [4] 护盾 → [5] 防御减免 → [6] HP 扣除
→ [7] 状态效果 → [8] 事件广播
8 步流水线文档清晰,DamageInfo struct 使用 Builder 模式和零 GC 工厂方法 From(SO),接口驱动(IDamageable、IShieldable、IPoiseSource、IStatusEffectable)使各步骤可独立替换。
1.7 单例模式滥用 ⚠️ 需关注
全项目存在以下单例:
| 类名 | 必要性评估 |
|---|---|
GameManager.Instance |
合理(全局生命周期协调者) |
SaveManager.Instance |
合理(跨场景持久) |
GlobalObjectPool.Instance |
合理(全局池) |
ClashResolver.Instance |
⚠️ 可通过 ServiceLocator 消除 |
ProjectileManager.Instance |
⚠️ 可通过 ServiceLocator 消除 |
DifficultyManager.Instance |
⚠️ 可通过 ServiceLocator 消除 |
商业项目通常将"功能性单例"注册到 ServiceLocator,仅保留 2-3 个真正的全局单例。当前 6 个单例增加了测试和多场景管理的难度。
二、性能
2.1 对象池 ✅ 专业实现
public class GlobalObjectPool : MonoBehaviour
{
private readonly Dictionary<string, Queue<PooledObject>> _pools = new();
private readonly Dictionary<string, List<PooledObject>> _alive = new();
...
public T Spawn<T>(string key, Vector3 position, Quaternion rotation) where T : Component
}
优势:
- 双数据结构(空闲队列 + 活跃列表)支持上限控制和活跃对象回收
- 同时提供
IEnumerator WarmupCoroutine()和async Task WarmupAsync(),适应 MonoBehaviour 和纯 C# 两种调用场景 - Addressables 驱动预热,支持异步加载资源
不足:
GetComponentCached<T>()是否做了组件缓存?若每次 Spawn 都GetComponent,频繁 Spawn 时仍有 GC 压力。建议在PooledObject.Awake缓存各常用组件接口
2.2 批量视线检测(BatchLOSSystem)✅ 优化到位
// 每 FixedUpdate 只检测部分敌人(Round-Robin 分帧)
int count = Mathf.Min(_maxRequestersPerFrame, _requesters.Count);
for (int i = 0; i < count; i++)
{
int idx = (_currentOffset + i) % _requesters.Count;
Physics2D.Raycast(...);
}
_currentOffset = (_currentOffset + count) % ...;
优势:
- 分帧批处理,避免同帧对 20+ 敌人全部执行 Raycast
- 接口驱动(
ILOSRequester),与EnemyBase解耦
不足:
_requesters.RemoveAt(idx)是 O(n) 操作,敌人频繁死亡/重生时有性能隐患。商业实现通常用标记-清除(null 标记 + 批量清理)代替直接移除
2.3 DamageInfo 零 GC ✅
// 热路径:struct 工厂,无堆分配
var info = DamageInfo.From(_currentSource);
info.KnockbackDirection = knockDir;
info.KnockbackForce = _currentSource.KnockbackForce;
DamageInfo 设计为非 readonly struct(便于 Builder 就地写入),同时提供 From(SO) 零 GC 工厂路径,在命中密集场景(群战)中显著减少 GC 压力,是商业级实现水准。
2.4 潜在性能隐患
| 位置 | 问题 | 建议 |
|---|---|---|
HitBox.cs |
_hitCooldownTimers: Dictionary<Collider2D, float> 每帧 Mathf.Max 遍历,高频场景有 GC |
改用固定大小数组 + 索引映射 |
GameServiceRegistrar.EnsureSingleAudioListener() |
FindObjectsOfType<AudioListener> 每次场景加载调用 |
可接受(非热路径),已限制场景加载时调用 |
AttackState.PlayAttackClip() |
每次进入攻击状态都 state.Events(this) 注册 OnEnd 和帧事件 |
Animancer 内部有缓存,影响有限,但帧事件时间点应提取到 SO |
EnemyQuotaManager |
未见实现细节,需确认波次结算时无 LINQ 热路径 | - |
三、可扩展性
3.1 接口驱动设计 ✅ 充分
| 接口 | 用途 | 实现类 |
|---|---|---|
IDamageable |
可受击 | PlayerController, EnemyBase |
IShieldable |
护盾拦截 | ShieldComponent |
IPoiseSource |
霸体来源 | PlayerController, EnemyBase |
IBreakable |
可破坏机关 | 场景对象 |
IStatusEffectable |
状态效果 | StatusEffectManager |
IPathAgent |
导航代理 | EnemyNavAgent(Navigation 程序集) |
ISaveStorage |
存档存储后端 | LocalFileStorage(可替换为云存储) |
ILOSRequester |
视线检测请求者 | EnemyBase 实现 |
跨程序集依赖反转做得好:HurtBox(Combat 程序集)通过 IStatusEffectable 接口调用 StatusEffectManager(StatusEffects 程序集),避免反向依赖。
3.2 ScriptableObject 数据资产驱动 ✅
DamageSourceSO:伤害源(伤害值、类型、标记)独立资产,策划可直接编辑PlayerMovementConfigSO:所有移动参数集中配置EnemyStatsSO:敌人属性数据分离BossSkillSO+AttackPatternSO:Boss 技能和攻击模式纯数据化DifficultyScalerSO:难度缩放系数可独立调节
这是 Hollow Knight 同类项目的标准实践,大幅降低策划-程序沟通成本。
3.3 存档迁移(SaveMigrator)✅ 生产级
case "1.0": data = MigrateFrom1_0(data); goto case "1.1";
case "1.1": data = MigrateFrom1_1(data); goto case "2.0";
case "2.0": data = MigrateFrom2_0(data); goto case "2.1";
版本链式迁移(fall-through),支持从任意旧版本升级到最新版,加上 checksum 校验,是 AA 级游戏存档系统的标准实现。
3.4 扩展瓶颈
1. 玩家状态扩展成本高
新增一个玩家状态(如游泳攻击状态)需要:
- 新建
SwimAttackState.cs - 在
PlayerController中声明字段private SwimAttackState _swimAttackState; - 在
PlayerController.Awake()中初始化 - 在相关状态的
OnStateUpdate()中添加跳转判断
对比 Dead Cells 等商业项目的状态字典方案,每次新增需修改 Controller 核心类,违反开闭原则。
2. Boss 技能扩展
BossSkillSO + AttackPatternSO + SkillSequenceSO 组合提供了良好的数据驱动扩展能力,但 BossSkillExecutor 的具体执行逻辑需要查看是否支持新技能类型不修改代码。
四、编辑器友好性
4.1 EventBusMonitor ✅ 出色
#if UNITY_EDITOR
EventBusMonitor.Record(name, value?.ToString() ?? "null",
OnEventRaised?.GetInvocationList().Length ?? 0,
Time.frameCount);
#endif
每次 EventChannel 触发均记录:频道名、payload 字符串表示、当前监听器数量、帧号、时间戳。配合自定义 EditorWindow 可实现完整事件流可视化,是商业项目必备的调试工具。#if UNITY_EDITOR 包裹确保零 Release 开销。
4.2 Inspector 标注 ✅
[Header(...)]分组清晰:PlayerController中 "核心组件"、"配置"、"战斗"、"事件频道" 分组一目了然[DefaultExecutionOrder]在 5 个关键类上正确标注,执行顺序有保证[RequireComponent(typeof(...))]在HitBox、HurtBox、PlayerMovement等关键组件上使用,防止配置错误[Multiline]用于 EventChannel 的 description 字段,方便编辑器中多行文字说明
4.3 IValidatable 接口 ⚠️ 未完全落地
Core/Validation/IValidatable.cs 定义了验证接口,但未见对应的 Editor 自动扫描器(如 OnValidate() 批量调用),导致验证逻辑无法在编辑器保存时自动触发。建议补充:
// Editor/ValidatorEditor.cs
[MenuItem("BaseGames/Validate All ScriptableObjects")]
static void ValidateAll()
{
foreach (var so in Resources.FindObjectsOfTypeAll<ScriptableObject>())
if (so is IValidatable v) v.Validate();
}
4.4 SO 资产菜单 ✅
关键数据类均有 [CreateAssetMenu],策划可直接右键创建,无需找程序,符合现代 Unity 工作流。
五、使用便利性(API 设计)
5.1 EventChannel 订阅 API ✅ 流畅
// 推荐用法:链式订阅,自动生命周期管理
_onPlayerDied.Subscribe(HandlePlayerDied).AddTo(_subscriptions);
// OnDisable 一行清理
_subscriptions.Clear();
EventSubscription.AddTo() 扩展方法是良好的 Fluent API 设计,使用体验接近 UniRx。
5.2 对象池 API ✅
// 泛型获取,类型安全
var proj = GlobalObjectPool.Instance.Spawn<LinearProjectile>("Proj_Arrow", pos, rot);
API 简洁,类型安全,无需 GetComponent 手动转型。
5.3 存档 API ✅
// async/await 风格,UI 层友好
await SaveManager.Instance.SaveAsync(slot: 0);
bool ok = await SaveManager.Instance.LoadAsync(slot: 0);
现代 async/await 模式,配合存档指示器事件频道,完整覆盖 UI 反馈需求。
5.4 InputReaderSO 输入 API ⚠️ 轻微不足
// 事件驱动(✅ 推荐用法)
_inputReader.AttackEvent += OnAttack;
// 轮询(✅ 也支持)
Vector2 move = _inputReader.MoveInput;
不足:InputReaderSO 是 ScriptableObject,跨场景加载时 OnEnable/OnDisable 行为依赖 Unity 的 SO 生命周期,容易在热重载或 Play-mode 重新进入时出现"Map must be contained in state"错误(代码注释中已提及,但解决方案(EnsureInitialized + Reset)属于防御性修复而非根治)。建议将 Input 初始化移至专用 MonoBehaviour 组件(如现有的 InputReaderBootstrap.cs)中管理。
5.5 硬编码问题汇总
| 位置 | 硬编码内容 | 建议 |
|---|---|---|
HitBox.cs:42-43 |
PlayerHitBoxLayer = 13, EnemyHitBoxLayer = 16 |
改用 LayerMask 字段或 Physics2D.GetLayerCollisionMask |
AttackState.cs:38-40 |
events.Add(0.3f, ...) 命中盒激活时间 |
移至 PlayerAnimationConfigSO 的 HitBoxActiveStart/End 字段 |
PlayerMovement.cs:58 |
CoyoteTime 默认值 0.12f 内联 fallback |
SO 必填,移除内联 fallback 以强制配置 |
SaveManager.cs:13 |
QuickSaveSlot = 98 |
移至 GlobalSettingsSO |
六、与商业项目对比
对比基准:Hollow Knight / Dead Cells 技术架构(公开分析资料)
| 对比项 | 本项目 | 商业基准 | 差距 |
|---|---|---|---|
| 程序集隔离 | 25 个 asmdef | 通常 15-30 个 | ✅ 持平 |
| 事件系统 | SO EventChannel + CompositeDisposable | 通常 SO Channel 或自研消息总线 | ✅ 持平 |
| 玩家状态机 | POCO 状态 + Controller 字段 | 通常状态字典/工厂,更低耦合 | ⚠️ 有差距 |
| 对象池 | Addressables + Queue,支持上限 | 商业项目通常更完整(类型缓存、统计面板) | ✅ 基本持平 |
| 存档系统 | JSON + checksum + 版本迁移链 | ✅ 已达商业标准 | ✅ 持平 |
| 伤害流水线 | 8 步,接口驱动 | 商业项目通常 6-10 步 | ✅ 持平 |
| 难度系统 | SO Scaler + ISaveable | 商业标准实现 | ✅ 持平 |
| 输入系统 | Unity Input System + Buffer | 商业标准实现 | ✅ 持平 |
| 单例数量 | 6 个 | 建议 ≤ 3 个,其余用 ServiceLocator | ⚠️ 偏多 |
| 测试支持 | ServiceLocator 支持 Mock,但无测试用例 | 商业项目通常有核心系统单元测试 | 🔴 缺失 |
| CI/自动验证 | 无 | 商业项目通常有 | 🔴 缺失 |
七、优先级改进建议
🔴 高优先级(影响开发效率或潜在 Bug)
-
PlayerController 状态管理重构
将 16 个状态字段改为Dictionary<System.Type, PlayerStateBase>+ 工厂方法,新增状态无需修改 Controller -
AttackState 帧事件时间点提取
events.Add(0.3f, ...)移至PlayerAnimationConfigSO,支持策划在 Inspector 调节命中盒窗口而无需修改代码 -
HitBox 层级硬编码消除
PlayerHitBoxLayer = 13改为[SerializeField] LayerMask _rivalHitBoxMask,配置驱动,层级重排时不破坏逻辑
🟠 中优先级(提升可维护性)
-
ServiceLocator 替换功能性单例
ClashResolver、ProjectileManager、DifficultyManager注册到ServiceLocator,消除直接Instance引用 -
EventChannelRegistry 强制注册
要求所有 EventChannel SO 在GameServiceRegistrar.Awake时注册到IEventChannelRegistry,防止孤立频道 -
核心类迁移 DamageInfo / HitInfo
从Core/Events/移至Combat/,保持程序集领域边界清晰
🟡 低优先级(质量提升)
-
补充 IValidatable Editor 扫描器
批量验证 SO 配置,减少运行时null警告 -
BatchLOSSystem 移除优化
改用标记清除替代RemoveAt,消除 O(n) 开销 -
补充核心系统单元测试
GameStateMachine、DamageInfo.Builder、SaveMigrator逻辑简单,非常适合作为第一批测试用例
八、总结
本项目代码质量在国内独立游戏中属于偏上水准,核心架构模式(SO 事件系统、程序集隔离、接口驱动伤害流水线、存档迁移)均达到或接近商业标准。
主要差距集中在两类问题:
- 结构性:玩家状态机扩展成本偏高,单例过多
- 规范性:少量硬编码、未落地的验证基础设施、缺少自动化测试
这两类问题不影响当前功能运作,但随项目规模增大(尤其玩家状态和 Boss 数量增加)会造成明显的维护负担。建议在 Phase 3-4 内容开发前完成高优先级改进。