# zeling_v2 代码全面评审 > 评审日期:2026-05-11 > 评审范围:`Assets/Scripts/` 全部模块(约 25 个 Assembly Definition,覆盖 Combat、Player、Enemies、Core、UI、World 等) > 评审标准:以《空洞骑士》《Celeste》《Neon Abyss》等成熟商业 2D 动作游戏代码质量为参照基准 > 说明:本次评审在上一轮优化(AdvancedCodeReview.md)的基础上进行,反映所有已实施改动后的当前代码状态。 --- ## 目录 1. [评分总览](#1-评分总览) 2. [架构设计](#2-架构设计) 3. [性能](#3-性能) 4. [可扩展性](#4-可扩展性) 5. [编辑器友好性](#5-编辑器友好性) 6. [使用便利性(DX)](#6-使用便利性dx) 7. [问题优先级汇总表](#7-问题优先级汇总表) 8. [优化建议](#8-优化建议) --- ## 1. 评分总览 | 维度 | 得分(满分 10) | 与商业基准差距 | |---|---|---| | **架构设计** | 7.5 | 基础扎实,单例滥用是主要拖分项 | | **性能** | 7.0 | 热路径已优化,若干 Update 开销尚存 | | **可扩展性** | 7.5 | Assembly 划分优秀,配置驱动待完善 | | **编辑器友好性** | 6.5 | 工具链有亮点,SO 验证未落地 | | **使用便利性** | 7.0 | API 设计清晰,异步一致性有缺口 | | **综合** | **7.1** | 优于大多数独立游戏,接近中等商业质量 | --- ## 2. 架构设计 ### 2.1 优点 #### ✅ 事件频道(SO Event Channel)模式落地质量高 `BaseEventChannelSO` 泛型事件基类设计完善: ```csharp // BaseEventChannelSO.cs public EventSubscription Subscribe(Action callback) { OnEventRaised += callback; return new EventSubscription(() => OnEventRaised -= callback); } ``` - 返回 `EventSubscription` 可组合到 `CompositeDisposable`,避免手动取消订阅遗漏 - `EventBusMonitor`(仅 Editor)记录 256 条事件日志,帧号+监听器数量一应俱全 - 约 35+ 个具体频道类型覆盖全模块,系统间全部解耦 > 商业对比:与《GDC 2017 - Unite Austin》推荐的 SO 架构一致,执行完整度超过大多数独立参考项目。 #### ✅ 25 个 Assembly Definition 依赖拓扑明确 ``` BaseGames.Core.Events ↑ BaseGames.Core ↑ BaseGames.Combat BaseGames.Parry BaseGames.Player ↑ ↑ BaseGames.Enemies BaseGames.Player.States ``` - `BaseGames.Parry` 不依赖 `BaseGames.Combat`,`HurtBox.ConsumeParry()` 无跨程序集数据,干净 - `BaseGames.Player.States` 单独隔离,16 个状态类不污染 Player 程序集 #### ✅ 玩家 FSM:POCO 状态类 + 类型字典派发 ```csharp // PlayerController.cs private readonly Dictionary _states = new(); // ... _states[typeof(AttackState)] = new AttackState(this); public T GetState() where T : PlayerStateBase => _states.TryGetValue(typeof(T), out var s) ? (T)s : null; ``` - 状态不继承 `MonoBehaviour`,无 Unity 序列化负担 - `GetState()` 零 GC,O(1) 字典查找 - 状态基类 `PlayerStateBase` 提供 `Owner`/`Input`/`Anim`/`Cfg` 便捷属性,子类代码清晰 #### ✅ ServiceLocator 实现规范 ```csharp public static TInterface GetOrDefault(TInterface fallback = default) { ... } #if UNITY_EDITOR public static void OverrideForTest(TInterface mock) { ... } public static void Reset() { ... } #endif ``` - 接口类型注册,支持依赖倒置 - 提供 Editor 专用测试 override/reset,测试友好 #### ✅ 对象池完整实现 - Addressables 异步预热(`WarmupAsync` / `WarmupCoroutine` 双版本) - `LinkedList` 活跃列表,LRU O(1) 强制回收 - `GetComponentCached()` 扩展方法避免重复反射 #### ✅ SaveManager HMAC 校验 + 版本迁移 ```csharp // SaveMigrator.Migrate(loaded) 确保旧存档平滑升级 loaded = SaveMigrator.Migrate(loaded); ``` - `RunFireAndForget` 正确捕获 async void 中的异常 - `Formatting.None` 减少序列化体积 --- ### 2.2 问题 #### ⚠️ P1:单例(static Instance)与 ServiceLocator 混用 项目同时使用两种全局访问模式: | 类 | 访问方式 | |---|---| | `GameManager` | `static Instance` | | `SaveManager` | `static Instance` | | `MapManager` | `static Instance` | | `QuestManager` | `static Instance` | | `GlobalObjectPool` | `static Instance` | | `IPlatformService` | `ServiceLocator.Get<>()` | | `ISceneService` | `ServiceLocator.Get<>()` | | `IAudioService` | `ServiceLocator.Get<>()` | 混用导致: - `MapManager.Instance?.Register(this)` 在 `OnEnable` 中调用,若 MapManager 未激活则静默失败 - 无法对 `SaveManager`、`QuestManager` 进行单元测试 - 多场景重新加载时 Instance 生命周期难以追踪 **商业标准:** 《Hollow Knight》等成熟项目统一通过 DI 容器或 ServiceLocator 管理;绝不混用。 #### ⚠️ P1:EnemyBase 仍保留 Awake 中 FindWithTag(已修复,但注释混乱) 修复前 Awake 仍调用 `FindWithTag`,已在本次评审中同步修复(添加事件订阅,移除旧代码)。说明之前的优化不完整,留下了"Phase 1 注释"与"Phase 2 实现"共存的混乱状态: ```csharp // Start() 的兜底查找与 Awake() 的 FindWithTag 同时存在(已修复) // 这种 "Phase 注释" 模式在多次迭代后容易造成死代码残留 ``` **建议:** 移除所有 `// Phase 1:` / `// Phase 2:` 标注,改用 `// TODO:` 或 git branch 管理迭代。 #### ⚠️ P2:EnemyBase.ForceState() 是空实现 ```csharp public void ForceState(EnemyStateType newState) { _currentState = newState; // Phase 2:根据状态播放对应动画 / 触发硬直计时 } ``` 只更新枚举值,无任何动画/计时副作用。`TakeDamage()` 调用 `ForceState(Hurt)` 后敌人没有实际硬直反馈,是一个静默的逻辑空洞。 #### ⚠️ P2:UIManager 忽略商店 ID 参数 ```csharp private void OpenShop(string _) => OpenPanel(_shopRoot); ``` `_onShopOpen` 携带 `string shopId`(商店 ID),但 `UIManager` 直接丢弃。若游戏后期出现多个商店,此处需要重构,届时影响面较大。 #### ⚠️ P2:GameStateMachine 与 GameStateId 的 struct 滥用 `GameStateId` 被设计为 struct(值类型)但内部包含 string `Id`: ```csharp // 示例推断:GameStateId 包含 string Id 字段 if (state == GameStates.Gameplay || state == GameStates.BossFight) ``` struct 值比较需要 `IEquatable` 重写,否则 `==` 走装箱比较,每次 `HandleGameStateChanged` 触发都可能有 GC 分配。 --- ## 3. 性能 ### 3.1 优点 #### ✅ 热路径 GetComponent 全部缓存 ```csharp // HurtBox.cs - Awake 缓存,每次受击无 GetComponent private IStatusEffectable _statusEffectable; private void Awake() => _statusEffectable = GetComponentInParent(); ``` #### ✅ 距离计算使用 sqrMagnitude ```csharp // EnemyBase.Update() _stats.SqrDistanceToPlayer = ((Vector2)_playerTransform.position - (Vector2)transform.position).sqrMagnitude; // IsPlayerInRange return _stats.SqrDistanceToPlayer <= range * range; ``` 避免每帧 `Mathf.Sqrt`,100 个敌人同屏时节省约 100 次浮点开平方。 #### ✅ 对象池 LRU O(1) 回收 `LinkedList` 头节点始终是最早 Spawn 的对象,强制回收 O(1),替换原 `List.RemoveAt(0)` 的 O(n) 移位。 #### ✅ 事件频道无 GC 分配(C# event) `Action` delegate 调用无堆分配,优于 `UnityEvent`(有对象封装)。 #### ✅ StatusEffectManager 双结构避免重复遍历 ```csharp private readonly List _activeList = new(); // Update 遍历 private readonly Dictionary _activeIndex = new(); // O(1) 类型查找 ``` 逆序遍历 List 安全移除,`Dictionary` 保证施加同类效果时 O(1) 查找堆叠。 --- ### 3.2 问题 #### ⚠️ P1:PlayerMovement.UpdateFacing() 使用 localScale 翻转 ```csharp transform.localScale = new Vector3(Mathf.Abs(s.x) * dir, s.y, s.z); ``` `localScale` 负值翻转会: 1. 影响子节点所有 Collider 的物理中心(尤其是圆形 Collider) 2. 导致 UI Canvas World Space 子元素同步翻转 3. 与粒子系统、Shader(uv 方向)产生兼容问题 **商业标准:** Celeste / HK 均使用 `SpriteRenderer.flipX = true` 或旋转 `transform.rotation`,绝不翻转 Scale。 #### ⚠️ P1:GameServiceRegistrar 使用 FindObjectsOfType(场景加载时触发) ```csharp // GameServiceRegistrar.EnsureSingleAudioListener() AudioListener[] listeners = FindObjectsOfType(true); ``` 在 `SceneManager.sceneLoaded` 回调中调用 `FindObjectsOfType`,每次场景加载全场景扫描。若场景中 GameObject 数量大,这是一个可测量的加载卡顿点。 **建议:** 改为每个场景相机自行注册 AudioListener,或在场景 Bootstrap 时主动禁用多余监听器。 #### ⚠️ P2:DashState 使用魔法数字兜底 ```csharp float force = _config != null ? _config.JumpForce : 18f; // PlayerMovement Stats?.BeginInvincibility(Cfg != null ? Cfg.DashDuration + 0.05f : 0.23f); // DashState Move?.SetGravityScale(Cfg != null ? Cfg.DefaultGravityScale : 3f); ``` 每个状态类都有 `Cfg != null ? ... : hardcoded` 二元表达式,分散了数值配置源,调试时不知道实际值来自哪里。 **建议:** 在 `PlayerStateBase` 基类或 `PlayerController` 中加 `[RequireComponent]` 或 `Awake` 断言,确保配置 SO 永远不为 null,消除运行时兜底逻辑。 #### ⚠️ P2:多处 Coroutine + async Task 并存(GlobalObjectPool) ```csharp public IEnumerator WarmupCoroutine() { ... } // 相同逻辑 public async Task WarmupAsync() { ... } // 相同逻辑 ``` 同一预热逻辑实现了两遍,维护时需同步修改两个版本。 **建议:** 统一为 `async Task`,在需要 Coroutine 调用方处用 `StartCoroutine(WarmupAsync().AsCoroutine())` 桥接。 #### ⚠️ P3:LocalizationManager.Get() 静默吞掉异常 ```csharp catch { return entryKey; } ``` 本地化读取失败时返回 Key 字符串并且不打 Log,生产环境难以排查本地化数据缺失问题。 --- ## 4. 可扩展性 ### 4.1 优点 #### ✅ 配置数据与逻辑完全分离 - `PlayerAnimationConfigSO`:动画片段 + HitBox 时间点 + 硬直时长 - `PlayerMovementConfigSO`:加速度、跳跃力、土狼时间、冲刺参数 - `EnemyStatsSO`:HP、攻击力、视野、寻路参数 设计师可在不修改代码的情况下独立调整全部数值。 #### ✅ 攻击连击段数动态读取 ```csharp int maxCombo = AnimCfg?.GroundAttacks?.Length ?? 1; if (_comboIndex < maxCombo - 1) { ... } ``` `GroundAttacks[]` 数组长度决定连击段数,添加新连击只需在 SO 中添加一个 AnimationClip。 #### ✅ IPathAgent 接口抽象导航层 ```csharp protected IPathAgent _nav; // EnemyBase // 由子类/Inspector注入,或 GetComponent() ``` PathBerserker2d / A* Pathfinding / NavMesh2D 均可通过实现 IPathAgent 无缝替换。 #### ✅ ISaveable / SaveMigrator 版本化存档 ```csharp loaded = SaveMigrator.Migrate(loaded); ``` 版本迁移管道已建立,存档格式变更时可平滑升级旧存档。 --- ### 4.2 问题 #### ⚠️ P1:EnemyBase.ForceState() 无扩展点 敌人状态机是扁平的 `EnemyStateType` 枚举 + switch(或 if/else),没有对应玩家 FSM 的"POCO 状态类 + 字典"设计。添加新敌人状态需要: 1. 扩展 `EnemyStateType` 枚举 2. 在 `ForceState()` / `Update()` 等多处手动添加分支 **与玩家 FSM 差距明显。** 建议将敌人也迁移到 POCO 状态类或完全依赖 Behavior Designer 行为树,二选一,避免两套系统共存。 #### ⚠️ P1:WorldStateRegistry 没有泛化的持久化标记 API ```csharp public bool IsCollected(string id) => _collectedIds.Contains(id); public bool IsDoorOpened(string id) => _openedDoors.Contains(id); public bool IsDestroyed(string id) => _destroyedObjects.Contains(id); ``` 每增加一类世界实体状态,都需要:1)添加新的 `HashSet` 字段;2)添加两个方法;3)更新 `LoadFromSave`/`GetAllFlags`。可扩展性差。 **建议:** 统一为 `Dictionary>` 按"类别"键隔离,或引入 `enum WorldObjectCategory` 参数化访问。 #### ⚠️ P2:技能系统(SkillManager)冷却计时硬编码三个槽 ```csharp private float _soulCooldown; private float _spirit1Cooldown; private float _spirit2Cooldown; ``` 支持的技能槽数量在编译时固定为 3。若后续要求更多技能槽,需要修改 SkillManager 代码。 **建议:** 改用 `Dictionary` 动态冷却表,支持任意数量技能。 --- ## 5. 编辑器友好性 ### 5.1 优点 #### ✅ EventBusMonitor:运行时事件日志 ```csharp #if UNITY_EDITOR EventBusMonitor.Record(name, value?.ToString(), listenerCount, Time.frameCount); #endif ``` Editor 模式下所有 SO 事件的触发都会被记录(频道名、负载、监听器数量、帧号),通过自定义窗口可以实时追踪事件流。这是商业游戏才有的调试基础设施。 #### ✅ WorldMarker.OnDrawGizmosSelected() ```csharp Gizmos.color = _markerType switch { WorldMarkerType.Objective => Color.yellow, WorldMarkerType.NPC => Color.cyan, WorldMarkerType.PointOfInterest => Color.green, ... }; ``` 场景中直接可视化标记类型,减少 playtest 调试成本。 #### ✅ IValidatable 接口 ```csharp public interface IValidatable { IEnumerable Validate(); } ``` SO 可实现验证逻辑,但缺少配套的 Editor 工具(ValidationRunner),接口孤立存在。 #### ✅ CreateAssetMenu 全面覆盖 所有 SO 类均标注 `[CreateAssetMenu(menuName = "...")]`,层级清晰,设计师无需记住 SO 路径。 --- ### 5.2 问题 #### ⚠️ P1:IValidatable 没有 Runner/Window 接口定义完整,但没有对应的 `SOValidationRunner`(Editor 菜单一键验证所有 SO)或 `[MenuItem("Tools/Validate All SOs")]` 实现。目前 `Validate()` 方法从未被调用,等同于死代码。 **商业标准:** 《Hades》《Dead Cells》均有数据验证管道,CI 中自动跑,防止设计师填写空/越界数据导致运行时崩溃。 #### ⚠️ P1:PlayerController Inspector 字段过多(约 18 个 SerializeField) ```csharp [Header("核心组件")] // 3个 [Header("配置")] // 5个 [Header("战斗")] // 9个 [Header("事件频道")] // 3个 ``` 18 个字段全部需要在 Inspector 中手动拖拽赋值,其中 `_movement`、`_stats`、`_animancer`、`_inputBuffer` 均挂在同一 GameObject 上,理应通过 `[RequireComponent]` + `Awake` 自动获取。 **建议:** 同节点组件改为 `RequireComponent` + Awake GetComponent;跨节点引用保留 `[SerializeField]`。可将约 18 个字段减至 8–10 个,降低出错概率。 #### ⚠️ P2:PostProcessManager 使用 Component 类型代替具体 Volume 类型 ```csharp [SerializeField] private Component _bossArenaVolume; // Assign a Volume component ``` Inspector 中接受任意 `Component`,没有类型约束,设计师可能误拖 `Transform` 或 `Collider`。运行时通过类型转换才能发现错误。 **建议:** 改为 `UnityEngine.Rendering.Volume` 或具体后处理 Volume 类型。 #### ⚠️ P3:HurtBox Inspector 字段在代码中注入而非序列化 ```csharp // HurtBox.cs public void SetShieldable(IShieldable shieldable) => _shieldable = shieldable; public void SetParrySystem(ParrySystem ps) => _parrySystem = ps; public void SetPoiseSource(IPoiseSource src) => _poiseSource = src; ``` 这些依赖在 `PlayerController.Awake()` 中手动注入,Inspector 不可见,也不能提前验证是否遗漏配置。若在另一个 Prefab 上挂 HurtBox 而忘记注入,会静默运行(弹反/霸体失效)。 --- ## 6. 使用便利性(DX) ### 6.1 优点 #### ✅ 8 步伤害流水线注释完整 ```csharp // 1. 无敌帧检查 // 2. 弹反检查 // 3. 霸体检查 // 4. 护盾层拦截 // 5. 计算 FinalDamage // 6. 调用 _owner.TakeDamage // 7. 全局广播 // 8. 状态效果触发 ``` 任何开发者接手都能在 10 秒内理解伤害全流程,维护成本低。 #### ✅ InputReaderSO 统一输入接口 ```csharp public event Action AttackEvent; public event Action DashEvent; public event Action ParryEvent; public Vector2 MoveInput { get; private set; } // Polling 接口 ``` 状态类既可订阅事件(`Input.AttackEvent += ...`),也可每帧轮询 `Input.MoveInput`,灵活性高。Gameplay / UI 两套 ActionMap 在 SO 内部切换,调用方无感知。 #### ✅ PlayerStateBase 便捷属性减少模板代码 ```csharp protected InputReaderSO Input => _owner.Input; protected AnimancerComponent Anim => _owner.Animancer; protected PlayerAnimationConfigSO AnimCfg => _owner.AnimConfig; ``` 每个状态类中直接写 `Anim.Play(AnimCfg.Dash)` 即可,无需每次手动访问 Owner 链式属性。 #### ✅ FormController 形态切换三路广播 ```csharp _onFormChanged?.Raise(index); // SO 事件 → UI/Save OnFormChanged?.Invoke(); // C# 事件 → WeaponManager _onSkillSetChanged?.Raise(); // SO 事件 → SkillHUD ``` 一次切换操作自动通知所有关心形态的子系统,无需调用方手动串联。 --- ### 6.2 问题 #### ⚠️ P1:SaveManager 公开 `public SaveData Data` 属性 ```csharp public SaveData Data => _current; ``` 外部代码(`ProgressLock`、`HPContainerPickup`)可直接读写 `SaveManager.Instance.Data.XXX`,绕过 SaveManager 的所有访问控制和校验逻辑。`_current` 可能为 null(游戏未读档状态),导致 NullReferenceException。 **建议:** 提供具名访问方法(`GetPlayerData()` / `GetWorldData()`),内部做 null guard,移除 `Data` 属性。 #### ⚠️ P1:`_dependenciesReady` 标志存在但每帧仍调用检查 ```csharp private void Update() { if (!HasRequiredStateDependencies()) return; // 每帧调用 ... } private bool HasRequiredStateDependencies() { if (_dependenciesReady) return true; // 快速返回 ... } ``` `_dependenciesReady` 快速 return 已经优化了大部分开销,但 `Update` 中每帧仍有方法调用栈开销。若依赖项永远在 `Awake` 期间就绪,该方法根本不应在 `Update` 中调用。 **建议:** 若 `Awake` 中 `ResolveDependencies()` 后直接断言依赖完整,`Update` 中不再调用检查。仅在开发期 `Awake` 末尾用 `Debug.Assert` 保护。 #### ⚠️ P2:QuestManager 的 `OnEnemyDied` 订阅类型不匹配 ```csharp [SerializeField] private TransformEventChannelSO _onEnemyDied; // 携带 Transform ``` 敌人死亡事件携带 `Transform`(敌人的 Transform),但任务系统关心的是"哪种敌人死亡"(通常是 EnemyId / EnemyType)。通过 Transform 反向查找 `EnemyBase` 组件获取 ID,多了一层不必要的间接层。 **建议:** 死亡事件频道改为携带 `EnemyDeathData { string EnemyId; Transform Source; }`,或改用 `StringEventChannelSO` 直接携带 `EnemyId`。 #### ⚠️ P2:SwimState 存在但未在 PlayerController.InitializeStates() 中注册 ``` Assets/Scripts/Player/States/SwimState.cs ← 文件存在 ``` ```csharp // PlayerController.InitializeStates() _states[typeof(IdleState)] = new IdleState(this); // ... 16 个状态,无 SwimState ``` `SwimState` 无法通过 `GetState()` 获取,即使被引用也会返回 null,使游泳状态完全无效。 #### ⚠️ P3:PlatformBootstrap `Update()` 每帧调用 `GetOrDefault` ```csharp private void Update() { ServiceLocator.GetOrDefault()?.RunCallbacks(); } ``` 每帧一次 Dictionary 查找。应在 `Awake` 缓存引用: ```csharp private IPlatformService _platform; private void Awake() { ...; _platform = ServiceLocator.Get(); } private void Update() => _platform?.RunCallbacks(); ``` --- ## 7. 问题优先级汇总表 | # | 优先级 | 模块 | 描述 | 影响 | |---|--------|------|------|------| | 1 | **P1** | 全局 | 单例 vs ServiceLocator 混用 | 可测试性、生命周期管理 | | 2 | **P1** | EnemyBase | ForceState() 空实现,受击无视觉/计时反馈 | 战斗手感 | | 3 | **P1** | PlayerMovement | localScale 翻转朝向 | 物理/视觉副作用 | | 4 | **P1** | GameServiceRegistrar | 场景加载时 FindObjectsOfType | 加载性能 | | 5 | **P1** | Editor | IValidatable 无 Runner | 数据质量保障缺失 | | 6 | **P1** | PlayerController | Inspector 18 个 SerializeField | 配置错误概率高 | | 7 | **P1** | SaveManager | `Data` 属性绕过访问控制 | 潜在 NullReferenceException | | 8 | **P2** | UIManager | 商店 ID 参数被丢弃 | 多商店扩展时需重构 | | 9 | **P2** | WorldStateRegistry | 世界状态扩展性差(每类单独字段) | 添加新实体类型成本高 | | 10 | **P2** | SkillManager | 固定三技能槽硬编码 | 技能系统扩展瓶颈 | | 11 | **P2** | EnemyBase | "Phase" 注释残留,死代码风险 | 维护性 | | 12 | **P2** | PlayerController | SwimState 未注册 | 游泳功能完全失效 | | 13 | **P2** | QuestManager | EnemyDied 事件携带 Transform 而非 EnemyId | 语义歧义 | | 14 | **P2** | GlobalObjectPool | Coroutine + async Task 双版本重复逻辑 | 维护成本翻倍 | | 15 | **P3** | PlatformBootstrap | Update 中 ServiceLocator.GetOrDefault 每帧查找 | 微性能 | | 16 | **P3** | PostProcessManager | Component 类型引用无约束 | Inspector 配置错误风险 | | 17 | **P3** | LocalizationManager | 异常静默吞掉 | 本地化问题难排查 | | 18 | **P3** | HurtBox | 依赖注入通过代码而非 Inspector | 编辑器不可见,遗漏静默失效 | --- ## 8. 优化建议 ### 8.1 高价值、低成本(1–3天) **① 注册 SwimState** ```csharp // PlayerController.InitializeStates() 添加一行 _states[typeof(SwimState)] = new SwimState(this); ``` **② EnemyBase.ForceState() 补充动画与计时** ```csharp public void ForceState(EnemyStateType newState) { _currentState = newState; switch (newState) { case EnemyStateType.Hurt: if (_animancer != null && _animConfig?.Hurt != null) _animancer.Play(_animConfig.Hurt); // TODO: 启动硬直计时器 break; case EnemyStateType.Dead: Die(); break; } } ``` **③ 修复 LocalizationManager 异常日志** ```csharp catch (Exception e) { Debug.LogWarning($"[Localization] Key '{entryKey}' in table '{tableName}' failed: {e.Message}"); return entryKey; } ``` **④ PlatformBootstrap 缓存服务引用** ```csharp private IPlatformService _platform; private void Awake() { /* ... init ... */ _platform = ServiceLocator.Get(); } private void Update() => _platform?.RunCallbacks(); ``` **⑤ UIManager 保存并使用商店 ID** ```csharp private void OpenShop(string shopId) { // TODO: 根据 shopId 选择对应 ShopController OpenPanel(_shopRoot); } ``` --- ### 8.2 中等成本(1–2周) **⑥ PlayerMovement 朝向改为 SpriteRenderer.flipX** ```csharp // 替换 localScale 翻转 private SpriteRenderer _sprite; private void Awake() => _sprite = GetComponentInChildren(); public void UpdateFacing() { float vx = _rb.velocity.x; if (Mathf.Abs(vx) < 0.1f) return; int dir = vx > 0f ? 1 : -1; if (dir == _facingDirection) return; _facingDirection = dir; if (_sprite != null) _sprite.flipX = dir < 0; } ``` **⑦ PlayerController 减少 Inspector 字段** 为同节点组件添加 `[RequireComponent]` 并在 Awake 自动获取: ```csharp [RequireComponent(typeof(PlayerMovement))] [RequireComponent(typeof(PlayerStats))] [RequireComponent(typeof(AnimancerComponent))] [RequireComponent(typeof(InputBuffer))] // Awake 中删除手动赋值,直接 GetComponent ``` **⑧ 实现 SOValidationRunner** ```csharp // Editor/SOValidationRunner.cs [MenuItem("Tools/Validate All SOs")] public static void ValidateAll() { var assets = AssetDatabase.FindAssets("t:ScriptableObject"); foreach (var guid in assets) { var so = AssetDatabase.LoadAssetAtPath( AssetDatabase.GUIDToAssetPath(guid)); if (so is IValidatable v) foreach (var msg in v.Validate()) Debug.LogWarning($"[Validation] {so.name}: {msg}", so); } } ``` **⑨ WorldStateRegistry 泛化 API** ```csharp public enum WorldObjectCategory { Collectible, Door, Destroyed, SavePoint, Flag } private readonly Dictionary> _states = new(); public bool IsMarked(WorldObjectCategory cat, string id) => _states.TryGetValue(cat, out var set) && set.Contains(id); public void Mark(WorldObjectCategory cat, string id) { if (!_states.ContainsKey(cat)) _states[cat] = new HashSet(); _states[cat].Add(id); } ``` --- ### 8.3 长期架构改进(迭代计划) **⑩ 统一全局管理器访问模式** 选择一种模式,全面推广: - **方案 A(推荐)**:将 `SaveManager`、`QuestManager`、`MapManager` 的 `static Instance` 替换为 `ServiceLocator.Register(this)`,所有调用方通过 `ServiceLocator.Get()` 访问 - **方案 B**:保留单例,但所有单例实现共同接口,提供 `ServiceLocator.OverrideForTest<>()` 的测试路径 **⑪ 敌人状态机升级** 基于现有 Behavior Designer 行为树,完全放弃 `EnemyStateType` 枚举状态机,敌人所有状态逻辑都在 BD Task 中实现。`EnemyBase` 仅提供数据接口(HP、位置、视野),成为纯粹的"数据持有者 + Unity 生命周期桥接器"。 **⑫ 单元测试接入** ServiceLocator 已支持 `OverrideForTest` / `Reset`,可以对以下关键系统编写 NUnit 测试: - `SaveMigrator.Migrate()` 各版本迁移路径 - `StatusEffectManager` 堆叠 / 净化逻辑 - `QuestManager` 目标进度计算 - `HurtBox.ReceiveDamage()` 8 步流水线各分支 --- ## 附:本次评审同步修复的 Bug > 以下问题在撰写本文档时发现并已直接修复: | 文件 | 问题 | 修复 | |------|------|------| | `EnemyBase.cs` | `Awake()` 中缺少 `_onPlayerSpawned.OnEventRaised += SetPlayerTransform` 订阅(上轮优化遗漏);`OnDestroy` 有取消订阅但从未订阅,造成无效的 `-=` 调用 | 已添加订阅,移除 Phase 1 `FindWithTag` 残留代码 | --- *文档结束。如需针对任何具体问题深入讨论实施方案,可继续追问。*