调整文档
This commit is contained in:
@@ -0,0 +1,884 @@
|
||||
# 存档与UI商业化完善计划 — 01(v1.1 去重修订版)
|
||||
|
||||
> 依据《04_SaveSystem_DataPersistence_Guide》《05_UISystem_Architecture_Guide》商业对标评估结论,
|
||||
> 结合源码深度阅读整理。
|
||||
> **v1.1 修订**:经逐文件核查,已移除计划中与框架现有实现重复的任务:
|
||||
> - ✅ **P0-A(IFocusable + CloseTopPanel)**:接口与 UIManager 机制已完整实现,7 个面板已接入;仅 2 个面板有遗漏(降级为小修复)。
|
||||
> - ✅ **P1-A(AutoSaveService)**:已完整实现(8 个事件触发点),计划整节删除。
|
||||
> - ✅ **P2-A(Switch 支持)**:`InputDeviceType.SwitchController` / `InputDeviceDetector` / `InputIconService._switchSet` / `ICN_Switch.asset` 均已完整实现,Switch 无需额外开发。
|
||||
> - ⚠️ **P2-B(无障碍/设置)**:`SettingsPanelController` 已有 UIScale 滑条与色盲模式 Dropdown,但存储层(`SettingsSaveData`)缺字段、持久化未接线,需补全存储链路而非从零实现。
|
||||
|
||||
---
|
||||
|
||||
## 架构关键约束
|
||||
|
||||
> ⚠️ **在编写任何代码前,必须理解以下约束,否则将产生运行时错误或与框架冲突。**
|
||||
|
||||
### 1. 异步规范
|
||||
|
||||
框架**只使用标准 `Task` / `Task<T>`**,不引入 UniTask 或 Cysharp。所有 async 方法遵循:
|
||||
- 公开 API:`async Task` / `async Task<bool>`
|
||||
- UI 回调(不等待结果):`_ = SomeAsync()` 或 `RunFireAndForget(task, context)` 模式
|
||||
- OnEnable 中启动的异步操作:必须配 `CancellationTokenSource _cts`,在 OnDisable 中 `Cancel()` + `Dispose()`
|
||||
|
||||
### 2. 事件订阅规范
|
||||
|
||||
所有事件频道订阅必须使用 `CompositeDisposable _subs` 管理:
|
||||
|
||||
```csharp
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_onGameStateChanged?.Subscribe(HandleGameStateChanged).AddTo(_subs);
|
||||
}
|
||||
private void OnDisable()
|
||||
{
|
||||
_subs.Clear();
|
||||
}
|
||||
```
|
||||
|
||||
`CompositeDisposable` 定义在 `BaseGames.Core.Events`。**不允许**直接使用 `+=` / `-=` 操作 SO 事件频道(`Dispose` 模式是硬约束)。
|
||||
|
||||
### 3. 字段命名规范
|
||||
|
||||
| 类型 | 规则 | 示例 |
|
||||
|------|------|------|
|
||||
| `[SerializeField] private` | `_camelCase` | `_defaultFocusButton` |
|
||||
| 公开属性 | `PascalCase` | `public bool IsCapturing` |
|
||||
| 事件处理方法 | `Handle{Name}` | `HandleGameStateChanged` |
|
||||
| 按钮点击处理方法 | `On{Name}Clicked` | `OnNewGameClicked` |
|
||||
| 异步方法 | 末尾加 `Async` | `RefreshAsync` / `SaveAsync` |
|
||||
|
||||
### 4. 服务注册约束
|
||||
|
||||
新增服务必须在 `GameServiceRegistrar.Awake()` 中注册(`[DefaultExecutionOrder(-2000)]`),其他系统的 Awake 中通过 `ServiceLocator.Get<TInterface>()` 或 `ServiceLocator.GetOrDefault<TInterface>()` 获取。**不允许**直接持有 MonoBehaviour 引用作为跨场景服务使用。
|
||||
|
||||
### 5. SaveableMonoBehaviour 基类
|
||||
|
||||
需要持久化的组件应继承 `SaveableMonoBehaviour`(`BaseGames.Core.Save`)而非手动管理注册,该基类已封装 OnEnable/OnDisable 自注册逻辑。
|
||||
|
||||
### 6. ISaveable 生命周期约束
|
||||
|
||||
`OnSave` 只写、`OnLoad` 只读,两者**不能**播放音效、触发动画、启动 Coroutine 或执行 GameObject 操作。这些副作用须移至 `OnLoad` 后首帧的 Update / 单次 Coroutine 中处理。
|
||||
|
||||
---
|
||||
|
||||
## 优先级总览
|
||||
|
||||
| 编号 | 特性 | 优先级 | 工作量 | 影响范围 | 状态 |
|
||||
|------|------|--------|--------|---------|------|
|
||||
| P0-A | IFocusable 补全(2 个面板遗漏)| **P0** | XS | DeathScreenController / SaveSlotController | ⚠️ 部分遗漏 |
|
||||
| P0-B | Cancel / ESC 全局关闭逻辑 | **P0** | S | UIManager + InputReaderSO | ❌ 未实现 |
|
||||
| P0-C | HMAC 密钥安全管理 | **P0** | S | GameSaveManager + 构建流程 | ❌ 硬编码 |
|
||||
| ~~P1-A~~ | ~~AutoSaveService~~ | — | — | — | ✅ **已完整实现,删除** |
|
||||
| P1-B | 存档槽区域背景图 | P1 | M | RegionDefinitionSO / SlotSummary / SaveSlotUI | ❌ 未实现 |
|
||||
| P2-A | 输入图标系统优化(修复冗余刷新 + null 重试)| P2 | XS | InputIconImage / InputDeviceIconSwitcher | ⚠️ 已实现,含 Switch,小修复 |
|
||||
| P2-B | 设置持久化(UIScale + 色盲模式存储链路)| P2 | S | SettingsSaveData / SettingsPanelController | ⚠️ UI 已有,存储缺失 |
|
||||
|
||||
---
|
||||
|
||||
## P0-A:IFocusable 补全(小修复)
|
||||
|
||||
> **现有状态(已核查):**
|
||||
> `IFocusable` 接口、`UIManager.CloseTopPanel()` 焦点恢复机制**已完整实现**。
|
||||
> `PauseMenuController`、`SettingsPanelController`、`InventoryHubPanel`、`ItemInventoryPanel`、`QuestLogPanel`、`CharmEquipPanel`、`ShopPanelUI` 共 7 个面板已正确接入。
|
||||
> **仅以下 2 个面板遗漏,需补充。**
|
||||
|
||||
### A-1:DeathScreenController — 在 ShowAfterDelay 末尾补充焦点设置
|
||||
|
||||
**文件:** `Assets/_Game/Scripts/UI/HUD/DeathScreenController.cs`(具体路径以实际为准)
|
||||
|
||||
`DeathScreenController` 不走面板栈(由 `UIManager` 按 GameState 直接 `SetActive`),无需实现 `IFocusable`。
|
||||
只需在已有的 `ShowAfterDelay` 协程末尾追加焦点设置:
|
||||
|
||||
```csharp
|
||||
// 在 ShowAfterDelay 协程末尾(显示按钮之后)追加:
|
||||
if (EventSystem.current != null && _btnRespawn != null)
|
||||
EventSystem.current.SetSelectedGameObject(_btnRespawn.gameObject);
|
||||
```
|
||||
|
||||
**Inspector 配置:** 无需额外字段,`_btnRespawn` 在 `DeathScreenController` 中已有。
|
||||
|
||||
### A-2:SaveSlotController — 实现 IFocusable
|
||||
|
||||
**文件:** `Assets/_Game/Scripts/UI/Menus/SaveSlotController.cs`
|
||||
|
||||
`SaveSlotController` 经由面板栈管理,需实现 `IFocusable` 以便从设置面板/确认对话框返回时自动恢复焦点:
|
||||
|
||||
在类声明处追加接口(`:` 后加 `, IFocusable`),并追加字段与方法:
|
||||
|
||||
```csharp
|
||||
// ★ 追加字段(在 [Header("Event Channels")] 之前):
|
||||
[Header("焦点")]
|
||||
[SerializeField] private Button _defaultFocusButton; // Inspector:赋值第一个存档槽的 SelectButton
|
||||
|
||||
// ★ 实现接口:
|
||||
public void OnFocusRestored()
|
||||
{
|
||||
// 延一帧:避免 EventSystem 在同帧 SetActive 后尚未刷新
|
||||
StartCoroutine(RestoreFocusNextFrame());
|
||||
}
|
||||
|
||||
private System.Collections.IEnumerator RestoreFocusNextFrame()
|
||||
{
|
||||
yield return null;
|
||||
if (EventSystem.current != null && _defaultFocusButton != null)
|
||||
EventSystem.current.SetSelectedGameObject(_defaultFocusButton.gameObject);
|
||||
}
|
||||
```
|
||||
|
||||
**Inspector 配置:** `_defaultFocusButton` → `SaveSlotPanel/_slotUIs[0]/_selectButton`
|
||||
|
||||
### 验证标准
|
||||
|
||||
| 场景 | 预期行为 |
|
||||
|------|---------|
|
||||
| 玩家死亡,死亡画面出现 1.5s 后 | 复活按钮自动获得焦点,可直接按手柄 A 复活 |
|
||||
| 主菜单 → 存档槽 → 覆盖确认对话框 → 取消 | 焦点回到存档槽第一个按钮 |
|
||||
|
||||
---
|
||||
|
||||
## P0-B:Cancel / ESC 全局关闭逻辑
|
||||
|
||||
### 目标
|
||||
|
||||
用户按 ESC(键盘)或手柄 B/Circle 时,UIManager 自动关闭当前栈顶面板,不需要每个面板各自监听 Cancel 输入。
|
||||
|
||||
### 2-B-1:添加 EVT_UICancelPressed 事件频道
|
||||
|
||||
在 `BaseGames → Tools → Create Event Channel Assets` 中或手动创建:
|
||||
|
||||
```
|
||||
Assets/_Game/Data/Events/UI/EVT_UICancelPressed.asset (VoidEventChannelSO)
|
||||
```
|
||||
|
||||
> 使用独立 SO 而非直接引用 `InputReaderSO`,保持 UIManager 对输入系统的解耦。
|
||||
|
||||
### 2-B-2:InputReaderSO 发布 Cancel 事件
|
||||
|
||||
**文件:** `Assets/_Game/Scripts/Input/InputReaderSO.cs`
|
||||
**命名空间:** `BaseGames.Input`(或对应命名空间)
|
||||
|
||||
在已有的 `UICancel` 输入动作回调中追加频道发布:
|
||||
|
||||
```csharp
|
||||
[Header("UI Cancel 频道")]
|
||||
[SerializeField] private BaseGames.Core.Events.VoidEventChannelSO _onUICancelPressed;
|
||||
|
||||
// 已有的 UICancel 输入动作回调(方法名可能为 OnUICancelPerformed 或类似):
|
||||
private void OnUICancelPerformed(InputAction.CallbackContext context)
|
||||
{
|
||||
// 现有逻辑(如果有)保持不变
|
||||
_onUICancelPressed?.Raise();
|
||||
}
|
||||
```
|
||||
|
||||
**Inspector 配置:** `InputReaderSO._onUICancelPressed` → `EVT_UICancelPressed.asset`
|
||||
|
||||
### 2-B-3:UIManager 订阅并全局处理
|
||||
|
||||
**文件:** `Assets/_Game/Scripts/UI/UIManager.cs`
|
||||
|
||||
在现有 `[SerializeField]` 区追加字段(在 Event Channels Header 下):
|
||||
|
||||
```csharp
|
||||
[SerializeField] private VoidEventChannelSO _onUICancelPressed;
|
||||
```
|
||||
|
||||
在 `OnEnable` 中追加订阅(`_subs` 已存在):
|
||||
|
||||
```csharp
|
||||
_onUICancelPressed?.Subscribe(HandleUICancelPressed).AddTo(_subs);
|
||||
```
|
||||
|
||||
追加处理方法:
|
||||
|
||||
```csharp
|
||||
private void HandleUICancelPressed()
|
||||
{
|
||||
if (_panelStack.Count > 0)
|
||||
CloseTopPanel();
|
||||
}
|
||||
```
|
||||
|
||||
**Inspector 配置:** `UIManager._onUICancelPressed` → `EVT_UICancelPressed.asset`
|
||||
|
||||
> **为何不在 Update() 中轮询 Input.GetKeyDown(KeyCode.Escape)**:
|
||||
> 项目使用 Unity InputSystem(`InputReaderSO`),不混用旧 Input API。
|
||||
> 通过 SO 事件频道发布 Cancel 信号,保持 UIManager 与 InputSystem 解耦。
|
||||
|
||||
### 验证标准
|
||||
|
||||
| 场景 | 预期行为 |
|
||||
|------|---------|
|
||||
| 面板栈为空时按 ESC | 无反应(不报错)|
|
||||
| 暂停面板打开时按 ESC | 暂停面板关闭,焦点恢复(P0-A 联动)|
|
||||
| 设置面板叠加在暂停面板上时按 ESC | 关闭设置面板,暂停面板恢复焦点 |
|
||||
| 死亡画面时按 ESC | 无反应(DeathScreen 不在面板栈中,独立状态节点)|
|
||||
|
||||
---
|
||||
|
||||
## P0-C:HMAC 密钥安全管理
|
||||
|
||||
### 目标
|
||||
|
||||
将当前硬编码于 `GameSaveManager.cs` 的 HMAC 密钥 `"ZelingV2SaveIntegrity_v2_9a3f7c1b"` 移出源代码,通过构建流水线注入,避免密钥随代码仓库泄露。
|
||||
|
||||
### 3-C-1:新建 SaveSecurityConfig ScriptableObject
|
||||
|
||||
**文件:** `Assets/_Game/Scripts/Core/Save/SaveSecurityConfig.cs`
|
||||
**命名空间:** `BaseGames.Core.Save`
|
||||
|
||||
```csharp
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Core.Save
|
||||
{
|
||||
// 不使用 CreateAssetMenu — 由构建脚本创建,不暴露给策划
|
||||
public sealed class SaveSecurityConfig : ScriptableObject
|
||||
{
|
||||
// 不加 [SerializeField],防止在普通 Inspector 中显示
|
||||
// 使用 internal 允许 Editor 构建脚本访问
|
||||
[HideInInspector]
|
||||
public string HmacKey = string.Empty;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**资产路径:** `Assets/_Game/Data/Core/SaveSecurityConfig.asset`
|
||||
**Resources 路径:** `Assets/Resources/SaveSecurityConfig.asset`(用于同步加载,不走 Addressables)
|
||||
|
||||
> 使用 `Resources` 而非 Addressables:`GameServiceRegistrar.Awake()` 是同步方法,
|
||||
> Addressables 的异步加载不适用于此阶段。`Resources.Load<T>` 在 Awake 中安全。
|
||||
|
||||
### 3-C-2:在 GameSaveManager 中使用配置
|
||||
|
||||
**文件:** `Assets/_Game/Scripts/Core/Save/GameSaveManager.cs`
|
||||
|
||||
将现有硬编码密钥替换为从 `SaveSecurityConfig` 加载:
|
||||
|
||||
```csharp
|
||||
// 原代码(需删除):
|
||||
// private const string HmacSecret = "ZelingV2SaveIntegrity_v2_9a3f7c1b";
|
||||
|
||||
// ★ 替换为:
|
||||
private static string _hmacSecret;
|
||||
|
||||
// 在 Initialize() 方法中(由 GameServiceRegistrar.Awake 调用)加载配置:
|
||||
public void Initialize(ISaveStorage storage)
|
||||
{
|
||||
_storage = storage;
|
||||
|
||||
// ★ 新增:加载密钥配置
|
||||
var cfg = Resources.Load<SaveSecurityConfig>("SaveSecurityConfig");
|
||||
_hmacSecret = (cfg != null && !string.IsNullOrEmpty(cfg.HmacKey))
|
||||
? cfg.HmacKey
|
||||
: "ZelingV2SaveIntegrity_v2_9a3f7c1b_FALLBACK"; // 开发期兜底
|
||||
|
||||
if (cfg == null || string.IsNullOrEmpty(cfg.HmacKey))
|
||||
Debug.LogWarning("[SaveSecurity] ⚠ SaveSecurityConfig 未找到或密钥为空,使用开发期兜底密钥。正式构建前必须修复。");
|
||||
}
|
||||
```
|
||||
|
||||
### 3-C-3:Editor 构建脚本自动注入密钥
|
||||
|
||||
**文件:** `Assets/_Game/Scripts/Editor/Build/SaveKeyInjector.cs`
|
||||
**命名空间:** `BaseGames.Editor.Build`
|
||||
|
||||
```csharp
|
||||
using UnityEditor;
|
||||
using UnityEditor.Build;
|
||||
using UnityEditor.Build.Reporting;
|
||||
using BaseGames.Core.Save;
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Editor.Build
|
||||
{
|
||||
public class SaveKeyInjector : IPreprocessBuildWithReport
|
||||
{
|
||||
public int callbackOrder => 0;
|
||||
|
||||
public void OnPreprocessBuild(BuildReport report)
|
||||
{
|
||||
// 方案 A:从环境变量读取(CI/CD 流水线注入)
|
||||
var key = Environment.GetEnvironmentVariable("ZELING_SAVE_HMAC_KEY");
|
||||
|
||||
// 方案 B:从项目本地密钥文件读取(不提交到 Git)
|
||||
if (string.IsNullOrEmpty(key))
|
||||
{
|
||||
const string keyFilePath = "Assets/_Game/Data/Core/.save_key";
|
||||
if (System.IO.File.Exists(keyFilePath))
|
||||
key = System.IO.File.ReadAllText(keyFilePath).Trim();
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(key))
|
||||
{
|
||||
Debug.LogWarning("[SaveKeyInjector] 未找到 HMAC 密钥,构建将使用开发期兜底密钥。");
|
||||
return;
|
||||
}
|
||||
|
||||
// 写入 Resources 资产
|
||||
const string assetPath = "Assets/Resources/SaveSecurityConfig.asset";
|
||||
var cfg = AssetDatabase.LoadAssetAtPath<SaveSecurityConfig>(assetPath);
|
||||
if (cfg == null)
|
||||
{
|
||||
cfg = ScriptableObject.CreateInstance<SaveSecurityConfig>();
|
||||
AssetDatabase.CreateAsset(cfg, assetPath);
|
||||
}
|
||||
|
||||
cfg.HmacKey = key;
|
||||
EditorUtility.SetDirty(cfg);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
Debug.Log("[SaveKeyInjector] ✅ HMAC 密钥已注入 SaveSecurityConfig。");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**.gitignore 追加(防止密钥文件提交):**
|
||||
|
||||
```
|
||||
Assets/_Game/Data/Core/.save_key
|
||||
Assets/_Game/Data/Core/.save_key.meta
|
||||
```
|
||||
|
||||
### 验证标准
|
||||
|
||||
| 场景 | 预期行为 |
|
||||
|------|---------|
|
||||
| 本地开发(无密钥文件)| Console 输出警告,使用兜底密钥,游戏正常运行 |
|
||||
| 创建 `.save_key` 文件 | 构建时 Console 输出 `✅ HMAC 密钥已注入` |
|
||||
| 设置环境变量 `ZELING_SAVE_HMAC_KEY` | CI 构建自动注入,无需本地文件 |
|
||||
| 不同密钥加载旧存档 | Normal 模式:警告但允许;SteelSoul 模式:拒绝加载 |
|
||||
|
||||
---
|
||||
|
||||
## ~~P1-A:AutoSaveService~~ ✅ 已完整实现,无需开发
|
||||
|
||||
> **核查结论:** `Assets/_Game/Scripts/Core/AutoSaveService.cs` 已完整实现(125 行),
|
||||
> 挂载在 Persistent 场景中,以事件驱动方式触发自动存档。
|
||||
> **触发点(8 个):** SceneLoaded / BossFightEnded / AbilityUnlocked / ShopPurchase / CollectiblePickup / MaxHPContainerPickedUp / DoorOpened / QuestStateChanged。
|
||||
> 内置防抖(`_cooldownSeconds = 2f`),`IsEnabled` 开关可在教程/过场段临时禁用。
|
||||
> **本节已从开发计划中删除,下方直接进入 P1-B。**
|
||||
|
||||
---
|
||||
|
||||
## P1-B:存档槽区域背景图
|
||||
|
||||
### 目标
|
||||
|
||||
存档槽卡片根据存档点所属区域(`RegionDefinitionSO`)显示对应的美术背景图,替代纯文本的区域名称展示,提升玩家辨识存档内容的直观性。
|
||||
|
||||
**方案:** 通过已有的 `RegionDefinitionSO`(`BaseGames.Progression`)扩展一个 `saveSlotBackground` Sprite 字段;新增 `RegionRegistrySO` 支持按场景名反查区域;`SlotSummary` 增加 `RegionId`;`SaveSlotUI` 根据 `RegionId` 查表并显示背景图。
|
||||
|
||||
### 2-B-1:RegionDefinitionSO 追加背景图字段
|
||||
|
||||
**文件:** `Assets/_Game/Scripts/Progression/RegionDefinitionSO.cs`
|
||||
|
||||
在 `[Header("Map")]` 下追加(与 `mapIconSprite` 同组):
|
||||
|
||||
```csharp
|
||||
[Header("存档槽展示")]
|
||||
[Tooltip("存档槽卡片背景图,建议尺寸与卡片比例一致(如 480×270)")]
|
||||
public Sprite saveSlotBackground;
|
||||
```
|
||||
|
||||
> **美术规范:** 背景图放于 `Assets/_Game/Art/UI/SaveSlot/` 下,
|
||||
> 命名规则:`SaveSlot_BG_{RegionId}.png`(如 `SaveSlot_BG_Cave.png`)。
|
||||
> 建议尺寸:480×270(16:9),Import Settings:Sprite,Compression:Normal Quality。
|
||||
|
||||
### 2-B-2:RegionRegistrySO — 场景名反查区域
|
||||
|
||||
**文件:** `Assets/_Game/Scripts/Progression/RegionRegistrySO.cs`
|
||||
**命名空间:** `BaseGames.Progression`
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Progression
|
||||
{
|
||||
/// <summary>
|
||||
/// 全局区域注册表 SO。
|
||||
/// 收录项目中所有 RegionDefinitionSO,支持按场景名反查所属区域。
|
||||
/// 资产路径:Assets/_Game/Data/Progression/RegionRegistry.asset
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "BaseGames/Progression/RegionRegistry",
|
||||
fileName = "RegionRegistry")]
|
||||
public class RegionRegistrySO : ScriptableObject
|
||||
{
|
||||
[SerializeField] private RegionDefinitionSO[] _regions;
|
||||
|
||||
// 缓存,首次查询时构建
|
||||
private Dictionary<string, RegionDefinitionSO> _sceneToRegion;
|
||||
|
||||
/// <summary>根据场景名(SaveMeta.SavePointId 所在的场景)查找所属区域;未找到返回 null。</summary>
|
||||
public RegionDefinitionSO FindBySceneName(string sceneName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sceneName)) return null;
|
||||
BuildCacheIfNeeded();
|
||||
_sceneToRegion.TryGetValue(sceneName, out var region);
|
||||
return region;
|
||||
}
|
||||
|
||||
/// <summary>根据 regionId 直接查找;未找到返回 null。</summary>
|
||||
public RegionDefinitionSO FindById(string regionId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(regionId)) return null;
|
||||
if (_regions == null) return null;
|
||||
foreach (var r in _regions)
|
||||
if (r != null && r.regionId == regionId) return r;
|
||||
return null;
|
||||
}
|
||||
|
||||
private void BuildCacheIfNeeded()
|
||||
{
|
||||
if (_sceneToRegion != null) return;
|
||||
_sceneToRegion = new Dictionary<string, RegionDefinitionSO>(
|
||||
System.StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (_regions == null) return;
|
||||
foreach (var region in _regions)
|
||||
{
|
||||
if (region == null) continue;
|
||||
if (region.roomSceneNames != null)
|
||||
foreach (var scene in region.roomSceneNames)
|
||||
if (!string.IsNullOrEmpty(scene))
|
||||
_sceneToRegion[scene] = region;
|
||||
if (!string.IsNullOrEmpty(region.bossSceneName))
|
||||
_sceneToRegion[region.bossSceneName] = region;
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑器下资产重新导入时清理缓存
|
||||
private void OnValidate() => _sceneToRegion = null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**资产路径:** `Assets/_Game/Data/Progression/RegionRegistry.asset`
|
||||
|
||||
**配置步骤:**
|
||||
1. 在 Project 窗口右键 → Create → BaseGames/Progression/RegionRegistry,保存为 `RegionRegistry.asset`
|
||||
2. 在 Inspector 的 `_regions` 数组中注册所有 `RegionDefinitionSO` 资产(`Assets/_Game/Data/Progression/Regions/`)
|
||||
3. 每新增区域 SO 时同步补充到此数组
|
||||
|
||||
### 2-B-3:SlotSummary 追加区域字段
|
||||
|
||||
**文件:** `Assets/_Game/Scripts/Core/Save/SaveData.cs`(`SlotSummary` 类)
|
||||
|
||||
在现有字段末尾追加(SlotSummary 是非序列化纯内存类,改动不影响存档文件):
|
||||
|
||||
```csharp
|
||||
public class SlotSummary
|
||||
{
|
||||
// 现有字段(保持不变):
|
||||
// public int SlotIndex; public float Playtime; public string LastSaved;
|
||||
// public string SceneName; public string ActiveFormId;
|
||||
// public int CurrentLingZhu; public int MaxHP; public bool IsSteelSoul;
|
||||
|
||||
// ★ 新增:
|
||||
/// <summary>存档点所在区域 ID,对应 RegionDefinitionSO.regionId;无区域时为 null。</summary>
|
||||
public string RegionId;
|
||||
}
|
||||
```
|
||||
|
||||
### 2-B-4:GameSaveManager.GetSlotSummaryAsync 填充 RegionId
|
||||
|
||||
**文件:** `Assets/_Game/Scripts/Core/Save/GameSaveManager.cs`
|
||||
|
||||
`GetSlotSummaryAsync` 已通过 `JObject` 部分解析 JSON 获取 `Meta` 字段。
|
||||
在已有的 `summary.SceneName = metaObj[...].Value<string>()` 行之后追加:
|
||||
|
||||
```csharp
|
||||
// 根据 SceneName 反查区域 ID(通过 RegionRegistrySO,不加载完整存档)
|
||||
// RegionRegistrySO 由 GameSaveManager.Initialize 时一次性加载并缓存
|
||||
summary.RegionId = _regionRegistry?.FindBySceneName(summary.SceneName)?.regionId;
|
||||
```
|
||||
|
||||
在 `GameSaveManager` 中追加字段与初始化:
|
||||
|
||||
```csharp
|
||||
// ★ 新增字段(序列化,由 GameServiceRegistrar 或 Inspector 赋值)
|
||||
[SerializeField] private RegionRegistrySO _regionRegistry;
|
||||
```
|
||||
|
||||
> **为何在 GameSaveManager 而非 SaveSlotUI 做查找:**
|
||||
> `GetSlotSummaryAsync` 已走部分 JSON 解析,是填充摘要数据的唯一位置;
|
||||
> UI 层(`SaveSlotUI`)不应持有 Progression 程序集引用(避免 `BaseGames.UI → BaseGames.Progression` 正向依赖扩大)。
|
||||
> `RegionId`(string)通过 `SlotSummary` 传递给 UI,UI 再通过自持有的 `RegionRegistrySO` SerializeField 引用查背景图——两层都只持有 string,保持解耦。
|
||||
|
||||
### 2-B-5:SaveSlotUI 显示区域背景图
|
||||
|
||||
**文件:** `Assets/_Game/Scripts/UI/Menus/SaveSlotController.cs`(`SaveSlotUI` 类内)
|
||||
|
||||
在现有 `[Header("槽位状态")]` 之前追加字段:
|
||||
|
||||
```csharp
|
||||
[Header("区域背景图")]
|
||||
[SerializeField] private Image _backgroundImage; // 卡片背景 Image 组件
|
||||
[SerializeField] private RegionRegistrySO _regionRegistry; // 与 GameSaveManager 共用同一 SO 资产
|
||||
[SerializeField] private Sprite _fallbackBackground; // 无区域时的默认背景(如通用迷雾图)
|
||||
```
|
||||
|
||||
> **程序集引用**:`SaveSlotUI` 所在的 `BaseGames.UI.asmdef` 已引用 `BaseGames.Progression`(确认 `.asmdef` 中包含),
|
||||
> 若未包含,在 `BaseGames.UI.asmdef` 的 `references` 数组中添加 `"BaseGames.Progression"`。
|
||||
|
||||
在现有 `Refresh(SlotSummary summary, SaveSlotPanelMode mode)` 方法末尾追加:
|
||||
|
||||
```csharp
|
||||
// ★ 新增:区域背景图
|
||||
RefreshBackground(summary);
|
||||
```
|
||||
|
||||
追加私有方法:
|
||||
|
||||
```csharp
|
||||
private void RefreshBackground(SlotSummary summary)
|
||||
{
|
||||
if (_backgroundImage == null) return;
|
||||
|
||||
Sprite bg = null;
|
||||
|
||||
if (summary != null && !string.IsNullOrEmpty(summary.RegionId))
|
||||
bg = _regionRegistry?.FindById(summary.RegionId)?.saveSlotBackground;
|
||||
|
||||
_backgroundImage.sprite = bg != null ? bg : _fallbackBackground;
|
||||
_backgroundImage.enabled = _backgroundImage.sprite != null;
|
||||
}
|
||||
```
|
||||
|
||||
### 2-B-6:Inspector 配置
|
||||
|
||||
| 组件 | 字段 | 赋值 |
|
||||
|------|------|------|
|
||||
| `GameSaveManager`(Persistent 场景)| `_regionRegistry` | `RegionRegistry.asset` |
|
||||
| `SaveSlotUI[0/1/2]`(MainMenu 场景)| `_backgroundImage` | 各卡片的 Background Image 组件 |
|
||||
| `SaveSlotUI[0/1/2]` | `_regionRegistry` | `RegionRegistry.asset`(同一 SO)|
|
||||
| `SaveSlotUI[0/1/2]` | `_fallbackBackground` | `SaveSlot_BG_Default.png`(默认背景)|
|
||||
|
||||
> **提示:** `RegionRegistry.asset` 被 GameSaveManager 和所有 SaveSlotUI 共用,
|
||||
> Inspector 引用同一个 SO 实例即可,运行时缓存仅构建一次(OnValidate 会清理)。
|
||||
|
||||
### 验证标准
|
||||
|
||||
| 场景 | 预期行为 |
|
||||
|------|---------|
|
||||
| 存档点在 Cave 区域 | 存档槽显示 `SaveSlot_BG_Cave.png` |
|
||||
| 存档点不在任何已注册区域 | 显示 `_fallbackBackground`(默认图)|
|
||||
| 空槽(无存档)| `summary == null`,显示默认图或不显示 |
|
||||
| 新增区域后忘记添加到 RegionRegistry | `RegionId = null`,显示默认图,不崩溃 |
|
||||
| 区域 SO 的 `saveSlotBackground` 未填写 | `bg = null`,显示默认图,不崩溃 |
|
||||
|
||||
---
|
||||
|
||||
## P2-A:输入图标系统优化(现有实现审查)
|
||||
|
||||
### 现有实现评估
|
||||
|
||||
经源码核查,项目**已完整实现**四设备输入图标系统,涵盖:
|
||||
|
||||
| 文件 | 职责 | 状态 |
|
||||
|------|------|------|
|
||||
| `InputDeviceType.cs` | 设备类型枚举(KB、Xbox、PS、Switch)| ✅ 完整 |
|
||||
| `InputDeviceDetector.cs` | 监听 InputSystem 原始事件,识别设备,Raise `InputDeviceTypeEventChannelSO` | ✅ 完整 |
|
||||
| `IInputIconService.cs` | 服务接口,支持改键跟随 | ✅ 完整 |
|
||||
| `InputIconService.cs` | 接口实现,4 图标集 + 方案过滤 + `OnIconSetChanged` C# 事件 | ✅ 完整 |
|
||||
| `InputIconImage.cs` | UI 组件,`ByActionName`(跟随改键)/ `ByBindingPath`(固定路径)双模式 | ✅ 完整 |
|
||||
| `InputDeviceIconSwitcher.cs` | 设备切换时调用 `InputIconImage.RefreshAll()` | ⚠️ 冗余,见下方 |
|
||||
| `ICN_Keyboard/Xbox/PlayStation/Switch.asset` | 4 套图标集资产 | ✅ 已创建 |
|
||||
|
||||
**使用方式(已可直接使用):**
|
||||
|
||||
在任意 `Image` 组件上挂载 `InputIconImage`,选择查询模式:
|
||||
- `ByActionName`(推荐):填入 Action 名称如 `"Interact"`,自动跟随当前设备和改键
|
||||
- `ByBindingPath`:填入固定路径如 `<Keyboard>/space`,用于教程/装饰性说明
|
||||
|
||||
### 发现问题:双重刷新(double refresh)
|
||||
|
||||
**根因:** `InputIconImage.OnEnable` 订阅了 `IInputIconService.OnIconSetChanged` C# 事件,设备切换时服务直接调用各实例的 `Refresh()`。同时 `InputDeviceIconSwitcher.OnDeviceChanged` 也调用 `InputIconImage.RefreshAll()`,导致每次设备切换 **每个 InputIconImage 被 Refresh 两次**。
|
||||
|
||||
```
|
||||
设备切换 → InputDeviceDetector → Raise _onDeviceChanged
|
||||
│
|
||||
├─ InputIconService.HandleDeviceChanged()
|
||||
│ 更新 _activeSet → 触发 OnIconSetChanged → 每个 InputIconImage.Refresh() ← 第1次
|
||||
│
|
||||
└─ InputDeviceIconSwitcher.OnDeviceChanged()
|
||||
→ InputIconImage.RefreshAll()
|
||||
→ 每个 InputIconImage.Refresh() ← 第2次(冗余)
|
||||
```
|
||||
|
||||
双重刷新本身不影响正确性(Refresh 是幂等操作),但在大量 InputIconImage 存在时(教程界面、操作提示密集 HUD)会造成无效 CPU 开销。
|
||||
|
||||
**次要问题:** `InputIconImage.OnEnable` 通过 `ServiceLocator.GetOrDefault<IInputIconService>()` 获取服务,若组件在服务注册前 Enable(边缘情况),`_iconService` 为 `null` 且后续不会重试——`RefreshAll()` 时也不会补救,导致该图标永久空白。
|
||||
|
||||
### 2-A-1:修复双重刷新
|
||||
|
||||
**方案:** 移除 `InputDeviceIconSwitcher` 中的 `RefreshAll()` 调用。
|
||||
`InputIconImage` 已通过订阅 `OnIconSetChanged` 自主刷新,`InputDeviceIconSwitcher` 无需再重复驱动。
|
||||
|
||||
**文件:** `Assets/_Game/Scripts/UI/InputDeviceIconSwitcher.cs`
|
||||
|
||||
```csharp
|
||||
// 修改前:
|
||||
private void OnDeviceChanged(InputDeviceType _)
|
||||
{
|
||||
InputIconImage.RefreshAll(); // ← 删除此行
|
||||
}
|
||||
|
||||
// 修改后:
|
||||
private void OnDeviceChanged(InputDeviceType _)
|
||||
{
|
||||
// InputIconImage 已通过 IInputIconService.OnIconSetChanged 事件自主刷新,
|
||||
// 无需在此处再次调用 RefreshAll()。
|
||||
// InputDeviceIconSwitcher 保留用于将来可能挂载其他设备切换响应逻辑。
|
||||
}
|
||||
```
|
||||
|
||||
> `InputDeviceIconSwitcher` 组件本身保留,不删除:
|
||||
> 其 `_onDeviceChanged` 订阅保持完整的事件链路;将来若需要在设备切换时做其他 UI 响应(如切换操作提示文本、播放反馈动画),仍在此处扩展,而非直接修改 `InputIconService`。
|
||||
|
||||
### 2-A-2:修复 InputIconImage 服务为 null 时的静默失败
|
||||
|
||||
**文件:** `Assets/_Game/Scripts/UI/InputDeviceIconSwitcher.cs`(`InputIconImage` 类内)
|
||||
|
||||
在 `Refresh()` 方法开头追加服务重试逻辑:
|
||||
|
||||
```csharp
|
||||
public void Refresh()
|
||||
{
|
||||
if (_image == null) return;
|
||||
|
||||
// ★ 修复:服务为 null 时重试(处理组件在服务注册前 Enable 的边缘情况)
|
||||
if (_iconService == null)
|
||||
{
|
||||
_iconService = ServiceLocator.GetOrDefault<IInputIconService>();
|
||||
if (_iconService != null)
|
||||
_iconService.OnIconSetChanged += Refresh; // 补订阅
|
||||
}
|
||||
|
||||
// 原有逻辑保持不变...
|
||||
Sprite sprite = null;
|
||||
if (_mode == LookupMode.ByActionName && !string.IsNullOrEmpty(_actionName))
|
||||
sprite = _iconService?.GetActionIcon(_actionName);
|
||||
else if (_mode == LookupMode.ByBindingPath && !string.IsNullOrEmpty(_bindingPath))
|
||||
sprite = _iconService?.GetPathIcon(_bindingPath);
|
||||
|
||||
if (sprite != null) { _image.sprite = sprite; _image.enabled = true; }
|
||||
else _image.enabled = false;
|
||||
}
|
||||
```
|
||||
|
||||
### 验证标准
|
||||
|
||||
| 场景 | 预期行为 |
|
||||
|------|---------|
|
||||
| 键盘操作时 | InputIconImage 显示键盘图标 |
|
||||
| 插入 Xbox 手柄并按键 | 图标自动切换为 Xbox 图标,Console 无重复 Refresh 日志 |
|
||||
| 插入 PS 手柄并按键 | 图标切换为 PlayStation 图标(DualSense/DualShock 均识别)|
|
||||
| 插入 Switch Pro Controller 并按键 | 图标切换为 Switch 图标 |
|
||||
| 玩家改键后 | ByActionName 模式自动显示新绑定按键图标 |
|
||||
| 图标集某按键 Sprite 未配置 | Image 自动隐藏(`enabled = false`),不报空引用 |
|
||||
|
||||
### 图标集资产配置规范
|
||||
|
||||
图标集资产已存在于 `Assets/_Game/Data/UI/InputIcons/`,美术补全时遵循:
|
||||
|
||||
```
|
||||
ICN_Keyboard.asset → 每个 InputAction 在键盘上的绑定路径 → Sprite
|
||||
路径格式:<Keyboard>/space, <Keyboard>/e, <Mouse>/leftButton ...
|
||||
|
||||
ICN_Xbox.asset → <Gamepad>/buttonSouth (A), /buttonNorth (Y),
|
||||
/buttonWest (X), /buttonEast (B),
|
||||
/leftTrigger, /rightTrigger, /leftShoulder, /rightShoulder,
|
||||
/start, /select, /leftStickPress, /rightStickPress ...
|
||||
|
||||
ICN_PlayStation.asset → 路径与 Xbox 相同(<Gamepad>/buttonSouth 等),但 Sprite 换为 ✕/△/□/○ 图标
|
||||
|
||||
ICN_Switch.asset → 路径与 Xbox 相同,Sprite 换为 A/B/X/Y(Switch 布局)图标
|
||||
```
|
||||
|
||||
> PS 和 Switch 图标集与 Xbox 使用**相同的 BindingPath**(`<Gamepad>/buttonSouth` 等),
|
||||
> 图标切换仅替换 Sprite,路径查找逻辑不变。这是 InputIconService 的设计意图。
|
||||
|
||||
---
|
||||
|
||||
## P2-B:设置持久化(UIScale + 色盲模式)
|
||||
|
||||
> **现有状态(已核查):**
|
||||
> `SettingsPanelController` 已有 `_uiScaleSlider`(0.8–1.5 范围)和 `_colorblindDropdown`(None/Prot/Deut/Trit)控件并实现 `SetUIScale` / `SetColorblindMode` 回调。
|
||||
> **缺失**:`SettingsSaveData` 无对应字段,数值不持久化,每次重启复位。
|
||||
> **本节目标**:补全存储层,将现有 UI 控件的值接入 ISaveable 存档系统。
|
||||
|
||||
### B-1:SettingsSaveData 追加存储字段
|
||||
|
||||
**文件:** `Assets/_Game/Scripts/Core/Save/SaveData.cs`(`SettingsSaveData` 类)
|
||||
|
||||
在现有 `Language` 字段末尾追加(字段名与 `SettingsPanelController` 内部变量对齐):
|
||||
|
||||
```csharp
|
||||
[Serializable]
|
||||
public class SettingsSaveData
|
||||
{
|
||||
public string Language = "zh-CN"; // 现有字段
|
||||
|
||||
// ★ 新增:
|
||||
[Range(0.8f, 1.5f)] public float UIScale = 1.0f; // 对应 _uiScaleSlider 范围
|
||||
public int ColorblindMode = 0; // 0=None,1=Prot,2=Deut,3=Trit
|
||||
public bool ScreenShake = true; // 对应 _screenShakeToggle(顺便补全)
|
||||
}
|
||||
```
|
||||
|
||||
> **SaveMigrator 版本递增**:追加字段后必须在 `SaveMigrator.cs` 中递增 `CurrentVersion` 并添加迁移补丁(初始化为默认值),否则旧存档加载时字段为 null/0 可能与滑条范围不符。
|
||||
|
||||
### B-2:SettingsPanelController 接入 ISaveable
|
||||
|
||||
**文件:** `Assets/_Game/Scripts/UI/Menus/SettingsPanelController.cs`
|
||||
|
||||
`SettingsPanelController` 已继承 `SaveableMonoBehaviour`(核查确认)则直接补全 `OnSave` / `OnLoad`;若未继承,在类声明处追加 `, ISaveable` 并手动管理注册(参考 P0-A 约束规范)。
|
||||
|
||||
**追加 OnSave / OnLoad:**
|
||||
|
||||
```csharp
|
||||
public override void OnSave(SaveData saveData)
|
||||
{
|
||||
saveData.Settings.UIScale = _uiScaleSlider != null ? _uiScaleSlider.value : 1.0f;
|
||||
saveData.Settings.ColorblindMode = _colorblindDropdown != null ? _colorblindDropdown.value : 0;
|
||||
saveData.Settings.ScreenShake = _screenShakeToggle != null && _screenShakeToggle.isOn;
|
||||
}
|
||||
|
||||
public override void OnLoad(SaveData saveData)
|
||||
{
|
||||
// 仅更新内存状态,不触发 UI 副作用(OnLoad 约束)
|
||||
_pendingUIScale = saveData.Settings.UIScale;
|
||||
_pendingColorblind = saveData.Settings.ColorblindMode;
|
||||
_pendingScreenShake = saveData.Settings.ScreenShake;
|
||||
_settingsPendingApply = true;
|
||||
}
|
||||
```
|
||||
|
||||
在已有的 `OnEnable`(面板打开时)中,从 pending 值刷新滑条位置:
|
||||
|
||||
```csharp
|
||||
private void OnEnable()
|
||||
{
|
||||
// 现有订阅逻辑...
|
||||
|
||||
// ★ 追加:从存档还原 UI 控件初始值
|
||||
if (_settingsPendingApply)
|
||||
{
|
||||
if (_uiScaleSlider != null) _uiScaleSlider.SetValueWithoutNotify(_pendingUIScale);
|
||||
if (_colorblindDropdown != null) _colorblindDropdown.SetValueWithoutNotify(_pendingColorblind);
|
||||
if (_screenShakeToggle != null) _screenShakeToggle.SetIsOnWithoutNotify(_pendingScreenShake);
|
||||
_settingsPendingApply = false;
|
||||
|
||||
// 立即应用效果(面板激活后)
|
||||
SetUIScale(_pendingUIScale);
|
||||
SetColorblindMode(_pendingColorblind);
|
||||
}
|
||||
}
|
||||
|
||||
// 追加字段:
|
||||
private float _pendingUIScale = 1.0f;
|
||||
private int _pendingColorblind = 0;
|
||||
private bool _pendingScreenShake = true;
|
||||
private bool _settingsPendingApply;
|
||||
```
|
||||
|
||||
> **SetValueWithoutNotify 用意**:避免滑条 `onValueChanged` 事件在 OnLoad 阶段触发,符合 ISaveable 规范(OnLoad 不触发副作用)。
|
||||
|
||||
### B-3:SaveMigrator 版本补丁
|
||||
|
||||
**文件:** `Assets/_Game/Scripts/Core/Save/SaveMigrator.cs`
|
||||
|
||||
在迁移链末尾追加(假设当前版本为 `"2.2"`,则升为 `"2.3"`):
|
||||
|
||||
```csharp
|
||||
// 版本 2.2 → 2.3:为 SettingsSaveData 补充 UIScale / ColorblindMode / ScreenShake
|
||||
if (IsVersionBelow(data.Meta.Version, "2.3"))
|
||||
{
|
||||
data.Settings ??= new SettingsSaveData();
|
||||
// 字段有默认值构造函数已覆盖,无需显式赋值
|
||||
data.Meta.Version = "2.3";
|
||||
}
|
||||
```
|
||||
|
||||
### 验证标准
|
||||
|
||||
| 场景 | 预期行为 |
|
||||
|------|---------|
|
||||
| 调大 UIScale 滑条 → 存档 → 重启 | 重启后滑条位置和 UI 缩放与调整后一致 |
|
||||
| 切换色盲模式 → 存档 → 重启 | 色盲模式保持 |
|
||||
| 旧版存档(无 UIScale 字段)| SaveMigrator 迁移后补默认值,不报错 |
|
||||
|
||||
---
|
||||
|
||||
## 资产路径规范
|
||||
|
||||
```
|
||||
Assets/_Game/
|
||||
├── Scripts/Core/Save/
|
||||
│ └── SaveSecurityConfig.cs ← P0-C(新建)
|
||||
├── Scripts/Progression/
|
||||
│ └── RegionRegistrySO.cs ← P1-B(新建)
|
||||
├── Scripts/Editor/Build/
|
||||
│ └── SaveKeyInjector.cs ← P0-C(新建)
|
||||
├── Data/Core/
|
||||
│ └── .save_key ← P0-C(不提交 Git,本地密钥文件)
|
||||
├── Data/Events/UI/
|
||||
│ └── EVT_UICancelPressed.asset ← P0-B(新建,VoidEventChannelSO)
|
||||
├── Data/Progression/
|
||||
│ ├── RegionRegistry.asset ← P1-B(新建,收录所有 RegionDefinitionSO)
|
||||
│ └── Regions/
|
||||
│ └── Region_*.asset ← 现有,补填 saveSlotBackground 字段
|
||||
├── Art/UI/SaveSlot/
|
||||
│ ├── SaveSlot_BG_Default.png ← P1-B(美术制作,默认背景)
|
||||
│ └── SaveSlot_BG_{RegionId}.png ← P1-B(每区域一张,480×270,Sprite)
|
||||
├── Art/UI/InputIcons/ ← 已存在
|
||||
│ ├── ICN_Keyboard.asset ← 现有,美术补全缺失路径映射
|
||||
│ ├── ICN_Xbox.asset ← 现有,美术补全
|
||||
│ ├── ICN_PlayStation.asset ← 现有,美术补全
|
||||
│ └── ICN_Switch.asset ← 现有(Switch 已完整实现),美术补全
|
||||
└── Resources/
|
||||
└── SaveSecurityConfig.asset ← P0-C(开发期提交空占位,构建时注入密钥)
|
||||
|
||||
已存在,无需新建:
|
||||
Core/AutoSaveService.cs ✅ 已实现
|
||||
UI/IFocusable.cs ✅ 已实现
|
||||
UI/InputDeviceDetector.cs ✅ 包含 Switch 检测
|
||||
UI/InputIconService.cs ✅ 包含 _switchSet
|
||||
UI/InputDeviceType.cs(SwitchController) ✅ 已实现
|
||||
UI/Menus/SettingsPanelController.cs ✅ UIScale+色盲控件已有,补存储层
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 实现顺序
|
||||
|
||||
| 阶段 | 编号 | 内容 | 新建文件 | 修改文件 |
|
||||
|------|------|------|---------|---------|
|
||||
| 1 | P0-A | DeathScreenController 焦点 + SaveSlotController IFocusable | — | DeathScreenController.cs, SaveSlotController.cs |
|
||||
| 1 | P0-B | Cancel/ESC 全局关闭 | EVT_UICancelPressed.asset | InputReaderSO.cs, UIManager.cs |
|
||||
| 2 | P0-C | HMAC 密钥外部化 | SaveSecurityConfig.cs, SaveKeyInjector.cs, Resources/SaveSecurityConfig.asset | GameSaveManager.cs |
|
||||
| 3 | P1-B | RegionRegistrySO + RegionDefinitionSO 背景字段 | RegionRegistrySO.cs, RegionRegistry.asset | RegionDefinitionSO.cs, SaveData.cs, GameSaveManager.cs, SaveSlotController.cs |
|
||||
| 3 | P1-B | 美术配合:各区域 SO 填写 saveSlotBackground | — | Region_*.asset |
|
||||
| 4 | P2-A | 输入图标系统修复(双重刷新 + null 重试)| — | InputDeviceIconSwitcher.cs(InputIconImage 类内)|
|
||||
| 4 | P2-A | 美术配合:补全 4 套图标集(含 Switch)缺失路径映射 | — | ICN_Keyboard/Xbox/PlayStation/Switch.asset |
|
||||
| 5 | P2-B | 设置持久化(SettingsSaveData + SettingsPanelController ISaveable)| — | SaveData.cs, SettingsPanelController.cs, SaveMigrator.cs |
|
||||
|
||||
---
|
||||
|
||||
## 待确认项
|
||||
|
||||
| # | 问题 | 影响范围 |
|
||||
|---|------|---------|
|
||||
| Q1 | Cancel 按键优先级:部分界面(如过场对话框)不希望被 ESC 关闭,是否需要 `IUnclosable` 标记接口? | UIManager |
|
||||
| Q2 | AutoSaveService 自动存档是否需要 HUD 视觉反馈(右下角短暂存档图标)?`EVT_SaveCompleted` 已存在,只需 HUD 订阅 | AutoSaveService + HUD |
|
||||
| Q3 | 存档槽背景图卡片比例:16:9 / 4:3 / 自由?影响美术制作规范(建议确认后统一 480×270 或其他)| 美术 / UI 设计 |
|
||||
| Q4 | 区域背景图是否区分 Boss 战场景与普通房间?(同区域 Boss 房可能希望显示不同背景)| RegionDefinitionSO 是否需要 `bossSceneBackground` 独立字段 |
|
||||
| Q5 | UIScale 应用粒度:全局 `CanvasScaler` 统一缩放,还是仅影响特定 TMP 字号?| SettingsPanelController + Canvas 配置 |
|
||||
| Q6 | `SettingsPanelController` 是否已继承 `SaveableMonoBehaviour`?影响 P2-B 的接入写法 | 确认后去掉 P2-B 的条件分支 |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
||||
# Zeling v2 · 测试方案总索引
|
||||
|
||||
> **文档版本**:1.0
|
||||
> **创建日期**:2026-05-13
|
||||
> **覆盖范围**:`Assets/Scripts/` 全量代码
|
||||
> **Unity 版本**:2022.3 LTS
|
||||
> **测试框架**:Unity Test Framework(NUnit)
|
||||
|
||||
---
|
||||
|
||||
## 一、测试策略概述
|
||||
|
||||
本项目测试分为两大类:
|
||||
|
||||
### 1.1 单元测试(EditMode / 代码测试)
|
||||
|
||||
凡满足以下条件之一的逻辑,**必须编写 NUnit 单元测试**:
|
||||
|
||||
- 纯 C# 类(无 `MonoBehaviour` 继承),逻辑完全与 Unity 运行时解耦
|
||||
- 有明确输入/输出的算法(序列化、状态机转换、数值计算)
|
||||
- 可通过 Mock / Stub 隔离 Unity 依赖的组件
|
||||
|
||||
测试文件存放于 `Assets/Tests/EditMode/`,程序集 `BaseGames.Tests.EditMode`。
|
||||
|
||||
### 1.2 手动测试(Unity Editor Play Mode)
|
||||
|
||||
需要在 Unity Editor 中运行 Play Mode 的场景验证:
|
||||
|
||||
- 动画状态机(Animancer FSM)与物理交互
|
||||
- HitBox/HurtBox 碰撞判定
|
||||
- 敌人 AI 行为(Behavior Designer)
|
||||
- 场景切换与相机系统
|
||||
- VFX/Feedback 链路
|
||||
|
||||
### 1.3 测试前必做环境检查
|
||||
|
||||
每次进行任何测试前,请先完成以下检查:
|
||||
|
||||
| 检查项 | 操作 |
|
||||
|--------|------|
|
||||
| Console 无红色 Error | `Window → General → Console`,Error 数量 = 0 |
|
||||
| Addressables 已构建 | `Window → Asset Management → Addressables → Groups → Build → New Build → Default Build Script` |
|
||||
| NavSurface 已烘焙 | 选中 NavSurface GameObject → Inspector → Bake,Scene 视图显示蓝绿网格 |
|
||||
| SO 事件资产已生成 | `BaseGames → Tools → Create Event Channel Assets`,`Assets/Data/Events/` 下存在 `.asset` 文件 |
|
||||
| Physics2D Layer 矩阵已配置 | `Edit → Project Settings → Physics 2D` → PlayerHitBox ↔ EnemyHurtBox 开启碰撞 |
|
||||
|
||||
---
|
||||
|
||||
## 二、文档列表
|
||||
|
||||
### 单元测试文档(包含可直接运行的 C# 测试代码)
|
||||
|
||||
| 文档 | 覆盖模块 | 测试类型 |
|
||||
|------|---------|---------|
|
||||
| [01_Unit_EventSystem_ServiceLocator.md](01_Unit_EventSystem_ServiceLocator.md) | SO 事件系统、ServiceLocator、CompositeDisposable | EditMode 单元测试 |
|
||||
| [02_Unit_SaveSystem.md](02_Unit_SaveSystem.md) | SaveMigrator、SaveData 序列化、Checksum | EditMode 单元测试 |
|
||||
| [03_Unit_StatusEffects.md](03_Unit_StatusEffects.md) | StatusEffect 叠加/互斥/到期(扩展现有测试) | EditMode 单元测试 |
|
||||
| [04_Unit_GameStateMachine.md](04_Unit_GameStateMachine.md) | GameStateMachine 状态注册/转换/非法转换 | EditMode 单元测试 |
|
||||
|
||||
### 手动测试文档(Unity Editor Play Mode 操作步骤)
|
||||
|
||||
| 文档 | 覆盖模块 | 测试类型 |
|
||||
|------|---------|---------|
|
||||
| [05_Manual_Core_Infrastructure.md](05_Manual_Core_Infrastructure.md) | ServiceLocator 初始化、ObjectPool、Addressables | Play Mode 手动 |
|
||||
| [06_Manual_PlayerFSM_Movement.md](06_Manual_PlayerFSM_Movement.md) | 玩家 FSM、移动、跳跃、冲刺、蹬墙、治疗 | Play Mode 手动 |
|
||||
| [07_Manual_CombatSystem.md](07_Manual_CombatSystem.md) | 战斗管道、弹反、护盾、霸体、状态效果 | Play Mode 手动 |
|
||||
| [08_Manual_EnemySystem.md](08_Manual_EnemySystem.md) | 敌人 AI、寻路、远程/飞行/Boss | Play Mode 手动 |
|
||||
| [09_Manual_WorldSystem.md](09_Manual_WorldSystem.md) | 房间切换、互动机关、液态谜题、存档点 | Play Mode 手动 |
|
||||
| [10_Manual_ProgressionSystem.md](10_Manual_ProgressionSystem.md) | 技能/护符/任务/成就/商店/形态切换 | Play Mode 手动 |
|
||||
| [11_Manual_UIAudioVFX.md](11_Manual_UIAudioVFX.md) | HUD、UI 面板、音频 Mixer、VFX/Feedback | Play Mode 手动 |
|
||||
| [12_Manual_CameraSystem.md](12_Manual_CameraSystem.md) | 区域相机切换、CinemachineConfiner、屏幕抖动 | Play Mode 手动 |
|
||||
| [13_Manual_PlayerAbilities_Skills.md](13_Manual_PlayerAbilities_Skills.md) | 攻击三连击/上劈/下劈Pogo/空中攻击、抓墙/蹬墙跳、二段跳、无敌冲刺、三形态切换、三套资源、灵泉使用/充能、魂技能/魄技能、弹反 | Play Mode 手动 |
|
||||
|
||||
---
|
||||
|
||||
## 三、运行单元测试
|
||||
|
||||
1. 打开 Unity Editor
|
||||
2. 菜单 `Window → General → Test Runner`
|
||||
3. 在 **Test Runner** 窗口选择 `EditMode` 标签页
|
||||
4. 点击 `Run All` 或展开 `BaseGames.Tests.EditMode` 运行指定测试套件
|
||||
5. 所有测试应显示 **绿色勾**,无红色失败
|
||||
|
||||
### 新增测试文件操作
|
||||
|
||||
1. 在 `Assets/Tests/EditMode/` 下创建新的 `.cs` 文件
|
||||
2. 确保文件头部有正确命名空间和 `[TestFixture]` 特性
|
||||
3. 确保 `.asmdef` 引用了被测程序集(见各单元测试文档说明)
|
||||
|
||||
---
|
||||
|
||||
## 四、缺陷登记
|
||||
|
||||
发现问题时,在下表记录:
|
||||
|
||||
| BUG-ID | 模块 | 描述 | 复现步骤 | 严重程度 | 状态 |
|
||||
|--------|------|------|---------|---------|------|
|
||||
| BUG-001 | | | | P0/P1/P2/P3 | 开放/修复 |
|
||||
@@ -0,0 +1,384 @@
|
||||
# 单元测试 01 · SO 事件系统 & ServiceLocator
|
||||
|
||||
> **测试类型**:EditMode 单元测试(NUnit)
|
||||
> **测试文件**:`Assets/Tests/EditMode/EventSystemTests.cs`
|
||||
> **被测程序集**:`BaseGames.Core.Events`
|
||||
> **asmdef 依赖配置**:`BaseGames.Tests.EditMode.asmdef` 需引用 `BaseGames.Core.Events`
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [测试覆盖范围](#1-测试覆盖范围)
|
||||
2. [EventSubscription & CompositeDisposable 测试](#2-eventsubscription--compositedisposable-测试)
|
||||
3. [BaseEventChannelSO 测试](#3-baseeventchannelso-测试)
|
||||
4. [ServiceLocator 测试](#4-servicelocator-测试)
|
||||
5. [完整测试代码](#5-完整测试代码)
|
||||
|
||||
---
|
||||
|
||||
## 1. 测试覆盖范围
|
||||
|
||||
| 类 | 测试点 |
|
||||
|----|--------|
|
||||
| `EventSubscription` | Dispose 后自动取消回调 |
|
||||
| `CompositeDisposable` | Clear/Dispose 批量取消;Add 后 Clear 清空 |
|
||||
| `EventSubscriptionExtensions.AddTo` | 返回原订阅句柄(链式调用不丢失引用) |
|
||||
| `BaseEventChannelSO<T>` | Raise 触发所有订阅者;订阅者为 0 时 Raise 不抛异常 |
|
||||
| `VoidBaseEventChannelSO` | 同上(无负载版) |
|
||||
| `ServiceLocator` | Register/Get/Unregister;未注册时 Get 抛异常;RegisterIfAbsent 防重复 |
|
||||
|
||||
---
|
||||
|
||||
## 2. EventSubscription & CompositeDisposable 测试
|
||||
|
||||
### 测试说明
|
||||
|
||||
`EventSubscription` 是纯 C# struct,完全无 Unity 依赖,适合 EditMode 测试。
|
||||
|
||||
**关键验证点:**
|
||||
|
||||
- `Dispose()` 调用 unsubscribe Action
|
||||
- `CompositeDisposable.Clear()` 遍历调用所有子项的 `Dispose()`
|
||||
- `AddTo()` 扩展方法正确将订阅加入 CompositeDisposable
|
||||
|
||||
---
|
||||
|
||||
## 3. BaseEventChannelSO 测试
|
||||
|
||||
### 测试说明
|
||||
|
||||
`BaseEventChannelSO<T>` 继承 `ScriptableObject`,在 EditMode 中需要用 `ScriptableObject.CreateInstance<T>()` 创建实例(不能 `new`)。
|
||||
|
||||
**创建方式:**
|
||||
|
||||
```csharp
|
||||
var channel = ScriptableObject.CreateInstance<IntEventChannelSO>();
|
||||
```
|
||||
|
||||
> ⚠️ 测试结束后必须调用 `Object.DestroyImmediate(channel)` 防止内存泄漏。
|
||||
|
||||
---
|
||||
|
||||
## 4. ServiceLocator 测试
|
||||
|
||||
### 测试说明
|
||||
|
||||
`ServiceLocator` 是静态类,测试间有状态污染风险。每个测试前必须调用 `ServiceLocator.Reset()` 清空状态(已在 `#if UNITY_EDITOR` 块内提供)。
|
||||
|
||||
**关键验证点:**
|
||||
|
||||
- `Register<T>` 后 `Get<T>()` 返回正确实例
|
||||
- `Get<T>()` 未注册时抛出 `InvalidOperationException`
|
||||
- `GetOrDefault<T>()` 未注册时返回 `default` 不抛异常
|
||||
- `RegisterIfAbsent<T>()` 重复注册时不覆盖
|
||||
- `Unregister<T>(impl)` 只在实例匹配时才移除
|
||||
|
||||
---
|
||||
|
||||
## 5. 完整测试代码
|
||||
|
||||
将以下代码保存至 `Assets/Tests/EditMode/EventSystemTests.cs`:
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.Core;
|
||||
|
||||
namespace BaseGames.Tests.EditMode
|
||||
{
|
||||
/// <summary>
|
||||
/// SO 事件系统 + ServiceLocator 单元测试(EditMode,无需 Play Mode)。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class EventSystemTests
|
||||
{
|
||||
// ── EventSubscription ────────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void EventSubscription_Dispose_CallsUnsubscribeAction()
|
||||
{
|
||||
bool called = false;
|
||||
var sub = new EventSubscription(() => called = true);
|
||||
sub.Dispose();
|
||||
Assert.IsTrue(called, "Dispose 应调用 unsubscribe action");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EventSubscription_Dispose_NullAction_DoesNotThrow()
|
||||
{
|
||||
var sub = new EventSubscription(null);
|
||||
Assert.DoesNotThrow(() => sub.Dispose());
|
||||
}
|
||||
|
||||
// ── CompositeDisposable ──────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void CompositeDisposable_Clear_DisposesAllItems()
|
||||
{
|
||||
var composite = new CompositeDisposable();
|
||||
int count = 0;
|
||||
composite.Add(new EventSubscription(() => count++));
|
||||
composite.Add(new EventSubscription(() => count++));
|
||||
composite.Add(new EventSubscription(() => count++));
|
||||
|
||||
composite.Clear();
|
||||
Assert.AreEqual(3, count, "Clear 应 Dispose 所有已添加的订阅");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CompositeDisposable_Clear_TwiceSafe()
|
||||
{
|
||||
var composite = new CompositeDisposable();
|
||||
int count = 0;
|
||||
composite.Add(new EventSubscription(() => count++));
|
||||
composite.Clear();
|
||||
composite.Clear(); // 第二次 Clear 不应重复 Dispose
|
||||
Assert.AreEqual(1, count, "Clear 两次不应重复 Dispose");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CompositeDisposable_Dispose_SameAsClear()
|
||||
{
|
||||
var composite = new CompositeDisposable();
|
||||
bool disposed = false;
|
||||
composite.Add(new EventSubscription(() => disposed = true));
|
||||
((IDisposable)composite).Dispose();
|
||||
Assert.IsTrue(disposed);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddTo_ReturnsOriginalSubscription()
|
||||
{
|
||||
var composite = new CompositeDisposable();
|
||||
bool called = false;
|
||||
var sub = new EventSubscription(() => called = true);
|
||||
var returned = sub.AddTo(composite);
|
||||
returned.Dispose();
|
||||
Assert.IsTrue(called, "AddTo 返回的订阅 Dispose 后应触发原 unsubscribe");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddTo_Collection_AddsToList()
|
||||
{
|
||||
var list = new List<IDisposable>();
|
||||
bool called = false;
|
||||
var sub = new EventSubscription(() => called = true);
|
||||
sub.AddTo(list);
|
||||
Assert.AreEqual(1, list.Count, "AddTo(ICollection) 应将订阅添加到列表");
|
||||
list[0].Dispose();
|
||||
Assert.IsTrue(called);
|
||||
}
|
||||
|
||||
// ── IntEventChannelSO ────────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void IntEventChannel_Raise_CallsAllSubscribers()
|
||||
{
|
||||
var channel = ScriptableObject.CreateInstance<IntEventChannelSO>();
|
||||
try
|
||||
{
|
||||
int sum = 0;
|
||||
channel.OnEventRaised += v => sum += v;
|
||||
channel.OnEventRaised += v => sum += v;
|
||||
channel.Raise(5);
|
||||
Assert.AreEqual(10, sum, "两个订阅者应各收到一次事件");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(channel);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IntEventChannel_Raise_NoSubscribers_DoesNotThrow()
|
||||
{
|
||||
var channel = ScriptableObject.CreateInstance<IntEventChannelSO>();
|
||||
try
|
||||
{
|
||||
Assert.DoesNotThrow(() => channel.Raise(42));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(channel);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IntEventChannel_UnsubscribeAfterDispose()
|
||||
{
|
||||
var channel = ScriptableObject.CreateInstance<IntEventChannelSO>();
|
||||
try
|
||||
{
|
||||
int callCount = 0;
|
||||
void Handler(int v) => callCount++;
|
||||
channel.OnEventRaised += Handler;
|
||||
channel.Raise(1);
|
||||
Assert.AreEqual(1, callCount);
|
||||
|
||||
channel.OnEventRaised -= Handler;
|
||||
channel.Raise(1);
|
||||
Assert.AreEqual(1, callCount, "取消订阅后不应再收到事件");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(channel);
|
||||
}
|
||||
}
|
||||
|
||||
// ── VoidEventChannelSO ───────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void VoidEventChannel_Raise_CallsSubscriber()
|
||||
{
|
||||
var channel = ScriptableObject.CreateInstance<VoidEventChannelSO>();
|
||||
try
|
||||
{
|
||||
bool received = false;
|
||||
channel.OnEventRaised += () => received = true;
|
||||
channel.Raise();
|
||||
Assert.IsTrue(received);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(channel);
|
||||
}
|
||||
}
|
||||
|
||||
// ── ServiceLocator ───────────────────────────────────────────────────
|
||||
|
||||
[SetUp]
|
||||
public void SetUp() => ServiceLocator.Reset();
|
||||
|
||||
[TearDown]
|
||||
public void TearDown() => ServiceLocator.Reset();
|
||||
|
||||
[Test]
|
||||
public void ServiceLocator_RegisterAndGet_ReturnsInstance()
|
||||
{
|
||||
var mock = new MockService();
|
||||
ServiceLocator.Register<IMockService>(mock);
|
||||
var retrieved = ServiceLocator.Get<IMockService>();
|
||||
Assert.AreSame(mock, retrieved);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ServiceLocator_GetUnregistered_ThrowsInvalidOperation()
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => ServiceLocator.Get<IMockService>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ServiceLocator_GetOrDefault_Unregistered_ReturnsDefault()
|
||||
{
|
||||
var result = ServiceLocator.GetOrDefault<IMockService>();
|
||||
Assert.IsNull(result);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ServiceLocator_GetOrDefault_Registered_ReturnsInstance()
|
||||
{
|
||||
var mock = new MockService();
|
||||
ServiceLocator.Register<IMockService>(mock);
|
||||
var result = ServiceLocator.GetOrDefault<IMockService>();
|
||||
Assert.AreSame(mock, result);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ServiceLocator_RegisterIfAbsent_DoesNotOverwrite()
|
||||
{
|
||||
var first = new MockService();
|
||||
var second = new MockService();
|
||||
ServiceLocator.Register<IMockService>(first);
|
||||
ServiceLocator.RegisterIfAbsent<IMockService>(second);
|
||||
Assert.AreSame(first, ServiceLocator.Get<IMockService>(),
|
||||
"RegisterIfAbsent 不应覆盖已存在的注册");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ServiceLocator_Unregister_RemovesService()
|
||||
{
|
||||
ServiceLocator.Register<IMockService>(new MockService());
|
||||
ServiceLocator.Unregister<IMockService>();
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => ServiceLocator.Get<IMockService>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ServiceLocator_Unregister_WithWrongInstance_DoesNotRemove()
|
||||
{
|
||||
var first = new MockService();
|
||||
var second = new MockService();
|
||||
ServiceLocator.Register<IMockService>(first);
|
||||
ServiceLocator.Unregister<IMockService>(second); // 不同实例,不应移除
|
||||
Assert.AreSame(first, ServiceLocator.Get<IMockService>(),
|
||||
"错误实例的 Unregister 不应移除已注册的服务");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ServiceLocator_OverrideForTest_ReplacesService()
|
||||
{
|
||||
var original = new MockService { Value = 1 };
|
||||
var replacement = new MockService { Value = 99 };
|
||||
ServiceLocator.Register<IMockService>(original);
|
||||
ServiceLocator.OverrideForTest<IMockService>(replacement);
|
||||
Assert.AreEqual(99, ServiceLocator.Get<IMockService>().Value);
|
||||
}
|
||||
|
||||
// ── Test Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
private interface IMockService { int Value { get; set; } }
|
||||
private class MockService : IMockService { public int Value { get; set; } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 运行方式
|
||||
|
||||
1. 将上方代码保存至 `Assets/Tests/EditMode/EventSystemTests.cs`
|
||||
2. 打开 `Window → General → Test Runner`
|
||||
3. 选择 `EditMode` → 展开 `BaseGames.Tests.EditMode` → 展开 `EventSystemTests`
|
||||
4. 点击 `Run All` 或逐个运行
|
||||
|
||||
### asmdef 配置确认
|
||||
|
||||
打开 `Assets/Tests/EditMode/BaseGames.Tests.EditMode.asmdef`,确认 `references` 数组包含:
|
||||
|
||||
```json
|
||||
{
|
||||
"references": [
|
||||
"BaseGames.Core.Events",
|
||||
"BaseGames.Core",
|
||||
"BaseGames.Combat.StatusEffects"
|
||||
],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": true,
|
||||
"precompiledReferences": ["nunit.framework.dll"],
|
||||
"autoReferenced": false,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 预期结果
|
||||
|
||||
所有 **20 个测试** 全部通过(绿色)。
|
||||
|
||||
若出现失败,常见原因:
|
||||
|
||||
| 失败现象 | 排查方向 |
|
||||
|---------|---------|
|
||||
| `ServiceLocator.Reset()` 方法不存在 | 检查 `#if UNITY_EDITOR` 编译条件是否生效(EditMode 测试应在 UNITY_EDITOR 下) |
|
||||
| `IntEventChannelSO` 找不到 | 确认 asmdef references 已包含 `BaseGames.Core.Events` |
|
||||
| `NullReferenceException` on `channel.Raise` | 确认使用 `ScriptableObject.CreateInstance<>()` 而非 `new` |
|
||||
@@ -0,0 +1,335 @@
|
||||
# 单元测试 02 · 存档系统(SaveSystem)
|
||||
|
||||
> **测试类型**:EditMode 单元测试(NUnit)
|
||||
> **测试文件**:`Assets/Tests/EditMode/SaveSystemTests.cs`
|
||||
> **被测程序集**:`BaseGames.Core.Save`
|
||||
> **asmdef 依赖**:`BaseGames.Tests.EditMode.asmdef` 需引用 `BaseGames.Core.Save`
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [测试覆盖范围](#1-测试覆盖范围)
|
||||
2. [SaveMigrator 版本迁移测试](#2-savemigrator-版本迁移测试)
|
||||
3. [SaveData 序列化/反序列化测试](#3-savedata-序列化反序列化测试)
|
||||
4. [Checksum 完整性测试](#4-checksum-完整性测试)
|
||||
5. [完整测试代码](#5-完整测试代码)
|
||||
|
||||
---
|
||||
|
||||
## 1. 测试覆盖范围
|
||||
|
||||
| 类 | 测试点 |
|
||||
|----|--------|
|
||||
| `SaveMigrator` | 空数据不崩溃;旧版本迁移至 2.0;2.0 迁移至 2.1;已是最新版本不重复迁移;未知版本输出警告 |
|
||||
| `SaveData` | JSON 序列化后反序列化数据完整;扩展字段(`ExtensionData`)不丢失;`NGPlus = null` 正确处理 |
|
||||
| `SaveMeta` | Version/SlotIndex/Checksum 字段正确序列化 |
|
||||
| `PlayerSaveData` | AbilityFlags 位掩码正确保存/读取;`DeathShade` 嵌套对象完整 |
|
||||
|
||||
---
|
||||
|
||||
## 2. SaveMigrator 版本迁移测试
|
||||
|
||||
### 测试说明
|
||||
|
||||
`SaveMigrator.Migrate()` 是纯静态方法,完全无 Unity 运行时依赖,是最理想的单元测试目标。
|
||||
|
||||
**版本迁移链:** `旧版本` → `2.0` → `2.1`(`CurrentVersion`)
|
||||
|
||||
**关键验证点:**
|
||||
|
||||
- `null` 输入安全返回 `null`
|
||||
- `Meta == null` 的输入安全返回原对象
|
||||
- 空版本号 → 迁移至 2.1,填充 `Tutorial/Settings/EventChains/ChallengeRooms`
|
||||
- `"2.0"` 版本 → 迁移至 2.1,`Map.Pins` 补充为空列表
|
||||
- `"2.1"` 版本 → 无操作,直接返回
|
||||
- 迁移后 `Meta.Version == "2.1"`
|
||||
|
||||
---
|
||||
|
||||
## 3. SaveData 序列化/反序列化测试
|
||||
|
||||
### 测试说明
|
||||
|
||||
使用 `Newtonsoft.Json`(项目依赖)测试完整序列化/反序列化往返(round-trip),确保存档读写等价。
|
||||
|
||||
**关键验证点:**
|
||||
|
||||
- 基本字段往返无损
|
||||
- `ExtensionData`(`[JsonExtensionData]`)保留未知字段
|
||||
- `NGPlus = null` 序列化后反序列化仍为 null
|
||||
- `AbilityFlags` `uint` 类型正确处理(不发生符号扩展错误)
|
||||
|
||||
---
|
||||
|
||||
## 4. Checksum 完整性测试
|
||||
|
||||
### 测试说明
|
||||
|
||||
`SaveManager` 中的 Checksum 是 HMAC-SHA256 值,存储在 `Meta.Checksum`。测试通过反射或辅助方法访问私有计算逻辑。
|
||||
|
||||
> **注意**:若 `ComputeChecksum` 为私有方法,在测试中通过构造相同 JSON 字符串并比较来间接验证,或将方法改为 `internal` 以支持测试。
|
||||
|
||||
---
|
||||
|
||||
## 5. 完整测试代码
|
||||
|
||||
将以下代码保存至 `Assets/Tests/EditMode/SaveSystemTests.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using NUnit.Framework;
|
||||
using Newtonsoft.Json;
|
||||
using BaseGames.Core.Save;
|
||||
|
||||
namespace BaseGames.Tests.EditMode
|
||||
{
|
||||
/// <summary>
|
||||
/// 存档系统单元测试(EditMode,纯 C# 逻辑,无 MonoBehaviour 依赖)。
|
||||
/// 覆盖:SaveMigrator 迁移链、SaveData 序列化/反序列化往返、字段完整性。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class SaveSystemTests
|
||||
{
|
||||
// ── SaveMigrator · 边界输入 ──────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void Migrate_NullInput_ReturnsNull()
|
||||
{
|
||||
var result = SaveMigrator.Migrate(null);
|
||||
Assert.IsNull(result, "null 输入应安全返回 null");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Migrate_NullMeta_ReturnsSameObject()
|
||||
{
|
||||
var data = new SaveData { Meta = null };
|
||||
var result = SaveMigrator.Migrate(data);
|
||||
Assert.AreSame(data, result, "Meta 为 null 时应返回原对象");
|
||||
}
|
||||
|
||||
// ── SaveMigrator · 版本迁移 ──────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void Migrate_EmptyVersion_MigratesTo_2_1()
|
||||
{
|
||||
var data = new SaveData();
|
||||
data.Meta.Version = "";
|
||||
var result = SaveMigrator.Migrate(data);
|
||||
Assert.AreEqual(SaveMigrator.CurrentVersion, result.Meta.Version);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Migrate_OldVersion_FillsMissingSubObjects()
|
||||
{
|
||||
var data = new SaveData();
|
||||
data.Meta.Version = "1.0";
|
||||
data.Tutorial = null;
|
||||
data.Settings = null;
|
||||
data.EventChains = null;
|
||||
data.ChallengeRooms = null;
|
||||
|
||||
var result = SaveMigrator.Migrate(data);
|
||||
|
||||
Assert.IsNotNull(result.Tutorial, "迁移后 Tutorial 不应为 null");
|
||||
Assert.IsNotNull(result.Settings, "迁移后 Settings 不应为 null");
|
||||
Assert.IsNotNull(result.EventChains, "迁移后 EventChains 不应为 null");
|
||||
Assert.IsNotNull(result.ChallengeRooms, "迁移后 ChallengeRooms 不应为 null");
|
||||
Assert.IsNull(result.NGPlus, "迁移后 NGPlus 应为 null(非 NG+ 模式)");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Migrate_OldVersion_FillsPlayerDeathShade()
|
||||
{
|
||||
var data = new SaveData();
|
||||
data.Meta.Version = "1.0";
|
||||
data.Player.DeathShade = null;
|
||||
|
||||
var result = SaveMigrator.Migrate(data);
|
||||
|
||||
Assert.IsNotNull(result.Player.DeathShade, "迁移后 DeathShade 不应为 null");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Migrate_Version2_0_FillsMapPins()
|
||||
{
|
||||
var data = new SaveData();
|
||||
data.Meta.Version = "2.0";
|
||||
data.Map.Pins = null;
|
||||
|
||||
var result = SaveMigrator.Migrate(data);
|
||||
|
||||
Assert.IsNotNull(result.Map.Pins, "从 2.0 迁移后 Map.Pins 不应为 null");
|
||||
Assert.IsEmpty(result.Map.Pins, "Map.Pins 应初始化为空列表");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Migrate_CurrentVersion_NoChange()
|
||||
{
|
||||
var data = new SaveData();
|
||||
data.Meta.Version = SaveMigrator.CurrentVersion;
|
||||
var result = SaveMigrator.Migrate(data);
|
||||
Assert.AreEqual(SaveMigrator.CurrentVersion, result.Meta.Version);
|
||||
}
|
||||
|
||||
// ── SaveData · JSON 序列化往返 ───────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void SaveData_SerializeDeserialize_PreservesPlayerData()
|
||||
{
|
||||
var original = new SaveData();
|
||||
original.Player.CurrentHP = 55;
|
||||
original.Player.MaxHP = 100;
|
||||
original.Player.CurrentGeo = 1234;
|
||||
original.Player.Scene = "TestRoom";
|
||||
original.Player.PosX = 3.14f;
|
||||
original.Player.PosY = -2.71f;
|
||||
|
||||
string json = JsonConvert.SerializeObject(original, Formatting.None);
|
||||
var restored = JsonConvert.DeserializeObject<SaveData>(json);
|
||||
|
||||
Assert.AreEqual(original.Player.CurrentHP, restored.Player.CurrentHP);
|
||||
Assert.AreEqual(original.Player.MaxHP, restored.Player.MaxHP);
|
||||
Assert.AreEqual(original.Player.CurrentGeo, restored.Player.CurrentGeo);
|
||||
Assert.AreEqual(original.Player.Scene, restored.Player.Scene);
|
||||
Assert.AreEqual(original.Player.PosX, restored.Player.PosX, 0.0001f);
|
||||
Assert.AreEqual(original.Player.PosY, restored.Player.PosY, 0.0001f);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SaveData_SerializeDeserialize_PreservesAbilityFlags()
|
||||
{
|
||||
var original = new SaveData();
|
||||
original.Player.AbilityFlags = 0xDEADBEEFu;
|
||||
|
||||
string json = JsonConvert.SerializeObject(original, Formatting.None);
|
||||
var restored = JsonConvert.DeserializeObject<SaveData>(json);
|
||||
|
||||
Assert.AreEqual(original.Player.AbilityFlags, restored.Player.AbilityFlags,
|
||||
"AbilityFlags uint 不应因符号扩展而错误");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SaveData_SerializeDeserialize_NGPlusNull_StaysNull()
|
||||
{
|
||||
var original = new SaveData();
|
||||
original.NGPlus = null;
|
||||
|
||||
string json = JsonConvert.SerializeObject(original, Formatting.None);
|
||||
var restored = JsonConvert.DeserializeObject<SaveData>(json);
|
||||
|
||||
Assert.IsNull(restored.NGPlus, "NGPlus null 应序列化/反序列化保持 null");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SaveData_SerializeDeserialize_MetaVersionPreserved()
|
||||
{
|
||||
var original = new SaveData();
|
||||
original.Meta.Version = "2.1";
|
||||
original.Meta.SlotIndex = 2;
|
||||
original.Meta.SaveCount = 42;
|
||||
|
||||
string json = JsonConvert.SerializeObject(original, Formatting.None);
|
||||
var restored = JsonConvert.DeserializeObject<SaveData>(json);
|
||||
|
||||
Assert.AreEqual("2.1", restored.Meta.Version);
|
||||
Assert.AreEqual(2, restored.Meta.SlotIndex);
|
||||
Assert.AreEqual(42, restored.Meta.SaveCount);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SaveData_ExtensionData_PreservesUnknownFields()
|
||||
{
|
||||
// 模拟未来版本存档包含当前版本未知的字段
|
||||
string futureJson = @"{
|
||||
""Meta"": { ""Version"": ""3.0"", ""SlotIndex"": 0 },
|
||||
""Player"": { ""CurrentHP"": 80 },
|
||||
""FutureFeature"": { ""SomeValue"": 123 }
|
||||
}";
|
||||
|
||||
var restored = JsonConvert.DeserializeObject<SaveData>(futureJson);
|
||||
|
||||
Assert.IsNotNull(restored);
|
||||
Assert.AreEqual(80, restored.Player.CurrentHP);
|
||||
// ExtensionData 应保留 FutureFeature
|
||||
Assert.IsTrue(restored.ExtensionData.ContainsKey("FutureFeature"),
|
||||
"ExtensionData 应保留未知字段,确保向前兼容");
|
||||
}
|
||||
|
||||
// ── SaveMeta · Checksum 字段 ─────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void SaveMeta_Checksum_DefaultIsNull()
|
||||
{
|
||||
var meta = new SaveMeta();
|
||||
Assert.IsNull(meta.Checksum, "新建 SaveMeta 的 Checksum 默认应为 null");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SaveData_Serialize_WithNullChecksum_NotThrow()
|
||||
{
|
||||
var data = new SaveData();
|
||||
data.Meta.Checksum = null;
|
||||
Assert.DoesNotThrow(
|
||||
() => JsonConvert.SerializeObject(data, Formatting.None));
|
||||
}
|
||||
|
||||
// ── PlayerSaveData · ShieldHP 默认值 ────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void PlayerSaveData_ShieldHP_DefaultIsMinusOne()
|
||||
{
|
||||
var player = new PlayerSaveData();
|
||||
Assert.AreEqual(-1, player.ShieldHP,
|
||||
"ShieldHP 默认 -1 表示满护盾");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PlayerSaveData_ShieldIsBroken_DefaultIsFalse()
|
||||
{
|
||||
var player = new PlayerSaveData();
|
||||
Assert.IsFalse(player.ShieldIsBroken);
|
||||
}
|
||||
|
||||
// ── SaveData · IsSteelSoul ────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void SaveMeta_IsSteelSoul_DefaultIsFalse()
|
||||
{
|
||||
var meta = new SaveMeta();
|
||||
Assert.IsFalse(meta.IsSteelSoul, "IsSteelSoul 默认应为 false");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## asmdef 配置
|
||||
|
||||
在 `Assets/Tests/EditMode/BaseGames.Tests.EditMode.asmdef` 的 `references` 中确认包含:
|
||||
|
||||
```
|
||||
"BaseGames.Core.Save"
|
||||
```
|
||||
|
||||
并在 `precompiledReferences` 中确认包含:
|
||||
|
||||
```
|
||||
"Newtonsoft.Json.dll"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 预期结果
|
||||
|
||||
所有 **17 个测试** 全部通过(绿色)。
|
||||
|
||||
### 常见问题排查
|
||||
|
||||
| 失败现象 | 排查方向 |
|
||||
|---------|---------|
|
||||
| `SaveMigrator` 找不到 | asmdef 未引用 `BaseGames.Core.Save` |
|
||||
| `JsonConvert` 找不到 | asmdef 的 `precompiledReferences` 未包含 `Newtonsoft.Json.dll` |
|
||||
| `ExtensionData` 测试失败 | 确认 `SaveData.ExtensionData` 字段有 `[JsonExtensionData]` 特性 |
|
||||
| `Map.Pins` 测试失败 | 确认 `MapSaveData` 类有 `Pins` 字段且 `SaveMigrator` 已处理 2.0→2.1 迁移 |
|
||||
@@ -0,0 +1,222 @@
|
||||
# 单元测试 03 · 状态效果系统(StatusEffects)
|
||||
|
||||
> **测试类型**:EditMode 单元测试(NUnit)
|
||||
> **测试文件**:`Assets/Tests/EditMode/StatusEffectTests.cs`(已存在,本文为完整规范)
|
||||
> **被测程序集**:`BaseGames.Combat.StatusEffects`
|
||||
> **asmdef 依赖**:`BaseGames.Tests.EditMode.asmdef` 需引用 `BaseGames.Combat.StatusEffects`
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [测试覆盖范围](#1-测试覆盖范围)
|
||||
2. [现有测试验证](#2-现有测试验证)
|
||||
3. [补充测试说明](#3-补充测试说明)
|
||||
4. [完整扩展测试代码](#4-完整扩展测试代码)
|
||||
5. [手动集成验证(Play Mode)](#5-手动集成验证play-mode)
|
||||
|
||||
---
|
||||
|
||||
## 1. 测试覆盖范围
|
||||
|
||||
| 类 | 测试点 |
|
||||
|----|--------|
|
||||
| `FireEffect` | MaxStacks=1;MutualExclusions 含 Freeze;OnStack 刷新持续时间;EffectType=Fire |
|
||||
| `PoisonEffect` | MaxStacks=3;MutualExclusions 为空;OnStack 增加 StackCount;超限截断;EffectType=Poison |
|
||||
| `StaggerEffect` | MaxStacks=1;BlockedBy 含 Stun;EffectType=Stagger |
|
||||
| `StatusEffect` 基类 | IsExpired 在 Duration 耗尽后为 true;Update 正确减少剩余时间 |
|
||||
| `StatusEffectManager` | ApplyEffect 正确添加;互斥效果自动移除;到期效果自动清理(需 MonoBehaviour Test 或接口 Mock) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 现有测试验证
|
||||
|
||||
项目已存在 `Assets/Tests/EditMode/StatusEffectTests.cs`,覆盖以下测试(共 14 个):
|
||||
|
||||
| 测试名 | 验证点 |
|
||||
|--------|--------|
|
||||
| `FireEffect_MaxStacks_IsOne` | FireEffect.MaxStacks == 1 |
|
||||
| `PoisonEffect_MaxStacks_IsThree` | PoisonEffect.MaxStacks == 3 |
|
||||
| `PoisonEffect_OnStack_IncreasesStackCount` | OnStack 后 StackCount == 2 |
|
||||
| `PoisonEffect_OnStack_ClampsAtMaxStacks` | 多次叠加不超过 MaxStacks |
|
||||
| `StaggerEffect_MaxStacks_IsOne` | StaggerEffect.MaxStacks == 1 |
|
||||
| `FireEffect_MutualExclusions_ContainsFreeze` | MutualExclusions 含 Freeze |
|
||||
| `PoisonEffect_MutualExclusions_IsEmpty` | PoisonEffect 无互斥 |
|
||||
| `StaggerEffect_BlockedBy_ContainsStun` | BlockedBy 含 Stun |
|
||||
| `FireEffect_BlockedBy_IsEmpty` | FireEffect 无阻断 |
|
||||
| `StatusEffect_IsExpired_AfterDurationDepleted` | 时间耗尽后 IsExpired == true |
|
||||
| `FireEffect_OnStack_RefreshDuration` | OnStack 刷新剩余时间 |
|
||||
| `FireEffect_EffectType_IsFire` | EffectType == StatusEffectType.Fire |
|
||||
| `PoisonEffect_EffectType_IsPoison` | EffectType == StatusEffectType.Poison |
|
||||
| `StaggerEffect_EffectType_IsStagger` | EffectType == StatusEffectType.Stagger |
|
||||
|
||||
**运行现有测试步骤:**
|
||||
|
||||
1. 打开 `Window → General → Test Runner`
|
||||
2. 选择 `EditMode` 标签页
|
||||
3. 展开 `BaseGames.Tests.EditMode → StatusEffectTests`
|
||||
4. 点击 `Run All`(或右键 `Run`)
|
||||
5. 确认所有 14 个测试 **全部绿色**
|
||||
|
||||
---
|
||||
|
||||
## 3. 补充测试说明
|
||||
|
||||
现有测试未覆盖以下场景,需补充:
|
||||
|
||||
### 3.1 StatusEffect.Update 时间精度
|
||||
|
||||
验证 `Update(delta)` 正确累计时间,不因浮点精度导致 IsExpired 判断提前/延迟。
|
||||
|
||||
### 3.2 多效果独立计时
|
||||
|
||||
同时持有 `FireEffect` 和 `PoisonEffect` 时,两个效果的剩余时间独立递减,互不影响。
|
||||
|
||||
### 3.3 OnApply / OnRemove 回调
|
||||
|
||||
`OnApply(owner)` 和 `OnRemove(owner)` 在正确时机被调用(owner 可传 `null` 用于纯计时测试)。
|
||||
|
||||
### 3.4 StackCount 初始值
|
||||
|
||||
新建 Effect 时 `StackCount == 1`(已 Apply 一层)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 完整扩展测试代码
|
||||
|
||||
将以下代码**追加**到现有 `StatusEffectTests.cs` 的 `}` 前,或创建新文件 `StatusEffectExtendedTests.cs`:
|
||||
|
||||
```csharp
|
||||
// 追加到 BaseGames.Tests.EditMode 命名空间下,StatusEffectTests 类中
|
||||
|
||||
// ── 补充测试 ────────────────────────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void StatusEffect_StackCount_InitialValueIsOne()
|
||||
{
|
||||
var effect = new FireEffect();
|
||||
Assert.AreEqual(1, effect.StackCount, "新建 Effect 的初始 StackCount 应为 1");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void StatusEffect_Update_RemainingTime_Decreases()
|
||||
{
|
||||
var effect = new StaggerEffect(2.0f);
|
||||
effect.OnApply(null);
|
||||
float initialDuration = effect.Duration;
|
||||
|
||||
effect.Update(0.5f);
|
||||
|
||||
Assert.Less(effect.Duration, initialDuration, "Update 后剩余时间应减少");
|
||||
Assert.IsFalse(effect.IsExpired, "0.5s Update 后 2.0s 效果不应过期");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void StatusEffect_Update_ExactExpiry()
|
||||
{
|
||||
var effect = new StaggerEffect(1.0f);
|
||||
effect.OnApply(null);
|
||||
|
||||
effect.Update(1.0f); // 恰好耗尽
|
||||
|
||||
Assert.IsTrue(effect.IsExpired, "恰好耗尽 duration 后应 IsExpired == true");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MultipleEffects_IndependentTimers()
|
||||
{
|
||||
var fire = new FireEffect();
|
||||
var poison = new PoisonEffect();
|
||||
fire.OnApply(null);
|
||||
poison.OnApply(null);
|
||||
|
||||
float fireDuration = fire.Duration;
|
||||
float poisonDuration = poison.Duration;
|
||||
|
||||
fire.Update(0.5f);
|
||||
|
||||
// poison 未调用 Update,时间不应变化
|
||||
Assert.AreEqual(poisonDuration, poison.Duration, 0.0001f,
|
||||
"未 Update 的 Effect 持续时间不应减少");
|
||||
Assert.Less(fire.Duration, fireDuration, "已 Update 的 Effect 持续时间应减少");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PoisonEffect_OnStack_DoesNotExceedMaxStacks_EdgeCase()
|
||||
{
|
||||
var effect = new PoisonEffect();
|
||||
// MaxStacks = 3,初始 StackCount = 1,再叠加 2 次达到上限
|
||||
effect.OnStack();
|
||||
effect.OnStack();
|
||||
Assert.AreEqual(3, effect.StackCount);
|
||||
|
||||
// 再叠加,不应超过 3
|
||||
effect.OnStack();
|
||||
effect.OnStack();
|
||||
Assert.AreEqual(3, effect.StackCount, "StackCount 不应超过 MaxStacks");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FireEffect_OnApply_ThenRemove_DoesNotThrow()
|
||||
{
|
||||
var effect = new FireEffect();
|
||||
Assert.DoesNotThrow(() =>
|
||||
{
|
||||
effect.OnApply(null);
|
||||
effect.OnRemove(null);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 手动集成验证(Play Mode)
|
||||
|
||||
以下场景需要在 Unity Editor 中手动测试(无法在 EditMode 测试中验证):
|
||||
|
||||
### 5.1 StatusEffectManager 添加效果
|
||||
|
||||
**前提条件**:测试场景中有玩家或敌人,其 GameObject 挂有 `StatusEffectManager` 组件。
|
||||
|
||||
**步骤:**
|
||||
1. 进入 Play Mode
|
||||
2. 在 Inspector 中找到目标对象的 `StatusEffectManager` 组件
|
||||
3. 通过 Console 调用(或临时测试按钮):`statusEffectManager.Apply(new PoisonEffect())`
|
||||
4. 在 Console 中观察 Poison 效果 Tick 日志(每 `tickInterval` 秒一条)
|
||||
|
||||
**预期结果:**
|
||||
- Poison 效果持续 `duration` 秒
|
||||
- 每次 Tick 扣减目标 HP
|
||||
- 到期后自动从管理器移除,不再 Tick
|
||||
|
||||
### 5.2 互斥效果测试
|
||||
|
||||
**步骤:**
|
||||
1. 对同一目标施加 `FireEffect`
|
||||
2. 随后施加 `FreezeEffect`(FireEffect 的 MutualExclusion)
|
||||
|
||||
**预期结果:**
|
||||
- `FireEffect` 被自动移除
|
||||
- Console 无 `NullReferenceException`
|
||||
|
||||
### 5.3 效果叠加(Poison)
|
||||
|
||||
**步骤:**
|
||||
1. 对目标施加 `PoisonEffect`(StackCount = 1)
|
||||
2. 再施加一次 `PoisonEffect`
|
||||
3. 再施加一次(StackCount 达到 3)
|
||||
4. 再施加一次(超过上限)
|
||||
|
||||
**预期结果:**
|
||||
- StackCount 分别变为 2、3、3(截断)
|
||||
- Inspector 中 `StatusEffectManager` 显示正确的 StackCount
|
||||
|
||||
### 5.4 阻断效果(StaggerEffect BlockedBy Stun)
|
||||
|
||||
**步骤:**
|
||||
1. 对目标先施加 `StunEffect`
|
||||
2. 尝试施加 `StaggerEffect`
|
||||
|
||||
**预期结果:**
|
||||
- `StaggerEffect` 被阻断,未添加到管理器
|
||||
- Console 无报错
|
||||
@@ -0,0 +1,375 @@
|
||||
# 单元测试 04 · 游戏状态机(GameStateMachine)
|
||||
|
||||
> **测试类型**:EditMode 单元测试(NUnit)
|
||||
> **测试文件**:`Assets/Tests/EditMode/GameStateMachineTests.cs`
|
||||
> **被测程序集**:`BaseGames.Core`、`BaseGames.Core.Events`
|
||||
> **asmdef 依赖**:`BaseGames.Tests.EditMode.asmdef` 需引用 `BaseGames.Core`、`BaseGames.Core.Events`
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [测试覆盖范围](#1-测试覆盖范围)
|
||||
2. [GameStateMachine 测试](#2-gamestatemachine-测试)
|
||||
3. [GameStateId 测试](#3-gamestateid-测试)
|
||||
4. [BuiltinGameStates 测试](#4-builtingamestates-测试)
|
||||
5. [完整测试代码](#5-完整测试代码)
|
||||
|
||||
---
|
||||
|
||||
## 1. 测试覆盖范围
|
||||
|
||||
| 类 | 测试点 |
|
||||
|----|--------|
|
||||
| `GameStateMachine` | 状态注册;合法转换成功;非法转换拒绝并返回 false;未知状态 ID 拒绝;OnEnter/OnExit 回调顺序正确;初始状态为 default |
|
||||
| `GameStateId` | 值相等性(struct);ToString 输出 |
|
||||
| `BuiltinGameStates` | 8 个内置状态 ID 唯一;`GameStates.Gameplay` 等工厂属性非 default |
|
||||
|
||||
---
|
||||
|
||||
## 2. GameStateMachine 测试
|
||||
|
||||
### 测试说明
|
||||
|
||||
`GameStateMachine` 是纯 C# 类(非 MonoBehaviour),完全可以在 EditMode 中实例化测试。
|
||||
|
||||
需要创建 `IGameState` 的 Mock 实现来注册状态:
|
||||
|
||||
```csharp
|
||||
private class MockState : IGameState
|
||||
{
|
||||
public GameStateId Id { get; }
|
||||
public IReadOnlyCollection<GameStateId> ValidNextStates { get; }
|
||||
public bool OnEnterCalled { get; private set; }
|
||||
public bool OnExitCalled { get; private set; }
|
||||
public GameStateId EnteredFrom { get; private set; }
|
||||
public GameStateId ExitedTo { get; private set; }
|
||||
|
||||
public MockState(GameStateId id, params GameStateId[] validNext)
|
||||
{
|
||||
Id = id;
|
||||
ValidNextStates = new HashSet<GameStateId>(validNext);
|
||||
}
|
||||
|
||||
public void OnEnter(GameStateId from) { OnEnterCalled = true; EnteredFrom = from; }
|
||||
public void OnExit(GameStateId to) { OnExitCalled = true; ExitedTo = to; }
|
||||
public void Tick(float dt) { }
|
||||
}
|
||||
```
|
||||
|
||||
**关键验证点:**
|
||||
|
||||
- 初始状态 `CurrentStateId == default`
|
||||
- `TransitionTo` 未知状态返回 `false` + error 包含 "未知状态"
|
||||
- 合法转换返回 `true` + error 为 `null`
|
||||
- 非法转换(不在 ValidNextStates)返回 `false` + error 包含 "非法转换"
|
||||
- `OnExit` 在旧状态上调用,传入新状态 ID
|
||||
- `OnEnter` 在新状态上调用,传入旧状态 ID
|
||||
|
||||
---
|
||||
|
||||
## 3. GameStateId 测试
|
||||
|
||||
### 测试说明
|
||||
|
||||
`GameStateId` 是值类型(struct),验证其相等性语义(两个相同 ID 的实例应 `==` 相等)。
|
||||
|
||||
---
|
||||
|
||||
## 4. BuiltinGameStates 测试
|
||||
|
||||
### 测试说明
|
||||
|
||||
`BuiltinGameStates`(或 `GameStates` 静态类)提供 8 个内置状态的 `GameStateId` 常量,验证它们都是有效的非 default 值。
|
||||
|
||||
---
|
||||
|
||||
## 5. 完整测试代码
|
||||
|
||||
将以下代码保存至 `Assets/Tests/EditMode/GameStateMachineTests.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using NUnit.Framework;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.Tests.EditMode
|
||||
{
|
||||
/// <summary>
|
||||
/// GameStateMachine + GameStateId 单元测试(EditMode)。
|
||||
/// 覆盖:状态注册、合法/非法转换、OnEnter/OnExit 回调顺序。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class GameStateMachineTests
|
||||
{
|
||||
// ── Mock 状态实现 ────────────────────────────────────────────────────
|
||||
|
||||
private class MockState : IGameState
|
||||
{
|
||||
public GameStateId Id { get; }
|
||||
public IReadOnlyCollection<GameStateId> ValidNextStates { get; }
|
||||
public bool OnEnterCalled { get; private set; }
|
||||
public bool OnExitCalled { get; private set; }
|
||||
public GameStateId EnteredFrom { get; private set; }
|
||||
public GameStateId ExitedTo { get; private set; }
|
||||
public int TickCount { get; private set; }
|
||||
|
||||
public MockState(GameStateId id, params GameStateId[] validNext)
|
||||
{
|
||||
Id = id;
|
||||
ValidNextStates = new HashSet<GameStateId>(validNext);
|
||||
}
|
||||
|
||||
public void OnEnter(GameStateId from) { OnEnterCalled = true; EnteredFrom = from; }
|
||||
public void OnExit(GameStateId to) { OnExitCalled = true; ExitedTo = to; }
|
||||
public void Tick(float dt) => TickCount++;
|
||||
}
|
||||
|
||||
private static readonly GameStateId StateA = new GameStateId("StateA");
|
||||
private static readonly GameStateId StateB = new GameStateId("StateB");
|
||||
private static readonly GameStateId StateC = new GameStateId("StateC");
|
||||
|
||||
// ── 初始状态 ─────────────────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void GameStateMachine_InitialState_IsDefault()
|
||||
{
|
||||
var fsm = new GameStateMachine();
|
||||
Assert.AreEqual(default(GameStateId), fsm.CurrentStateId,
|
||||
"未注册任何状态时,CurrentStateId 应为 default");
|
||||
}
|
||||
|
||||
// ── 状态注册与转换 ────────────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void TransitionTo_UnknownState_ReturnsFalse()
|
||||
{
|
||||
var fsm = new GameStateMachine();
|
||||
bool result = fsm.TransitionTo(StateA, out string error);
|
||||
Assert.IsFalse(result, "未注册的状态转换应返回 false");
|
||||
StringAssert.Contains("未知状态", error);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TransitionTo_RegisteredState_FromNull_Succeeds()
|
||||
{
|
||||
var fsm = new GameStateMachine();
|
||||
var stateA = new MockState(StateA);
|
||||
fsm.Register(stateA);
|
||||
|
||||
bool result = fsm.TransitionTo(StateA, out string error);
|
||||
|
||||
Assert.IsTrue(result, "注册状态后首次转换应成功");
|
||||
Assert.IsNull(error, "成功转换时 error 应为 null");
|
||||
Assert.AreEqual(StateA, fsm.CurrentStateId);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TransitionTo_ValidNextState_Succeeds()
|
||||
{
|
||||
var fsm = new GameStateMachine();
|
||||
var stateA = new MockState(StateA, StateB); // A 可以转换到 B
|
||||
var stateB = new MockState(StateB);
|
||||
fsm.Register(stateA);
|
||||
fsm.Register(stateB);
|
||||
fsm.TransitionTo(StateA, out _);
|
||||
|
||||
bool result = fsm.TransitionTo(StateB, out string error);
|
||||
|
||||
Assert.IsTrue(result);
|
||||
Assert.IsNull(error);
|
||||
Assert.AreEqual(StateB, fsm.CurrentStateId);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TransitionTo_InvalidNextState_ReturnsFalse()
|
||||
{
|
||||
var fsm = new GameStateMachine();
|
||||
var stateA = new MockState(StateA); // A 无有效下一状态
|
||||
var stateB = new MockState(StateB);
|
||||
fsm.Register(stateA);
|
||||
fsm.Register(stateB);
|
||||
fsm.TransitionTo(StateA, out _);
|
||||
|
||||
bool result = fsm.TransitionTo(StateB, out string error);
|
||||
|
||||
Assert.IsFalse(result, "非法转换应返回 false");
|
||||
StringAssert.Contains("非法转换", error);
|
||||
Assert.AreEqual(StateA, fsm.CurrentStateId, "非法转换后状态不应改变");
|
||||
}
|
||||
|
||||
// ── OnEnter / OnExit 回调 ────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void TransitionTo_CallsOnExit_OnPreviousState()
|
||||
{
|
||||
var fsm = new GameStateMachine();
|
||||
var stateA = new MockState(StateA, StateB);
|
||||
var stateB = new MockState(StateB);
|
||||
fsm.Register(stateA);
|
||||
fsm.Register(stateB);
|
||||
fsm.TransitionTo(StateA, out _);
|
||||
|
||||
fsm.TransitionTo(StateB, out _);
|
||||
|
||||
Assert.IsTrue(stateA.OnExitCalled, "转出 StateA 时应调用 OnExit");
|
||||
Assert.AreEqual(StateB, stateA.ExitedTo, "OnExit 传入的下一状态应为 StateB");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TransitionTo_CallsOnEnter_OnNextState()
|
||||
{
|
||||
var fsm = new GameStateMachine();
|
||||
var stateA = new MockState(StateA, StateB);
|
||||
var stateB = new MockState(StateB);
|
||||
fsm.Register(stateA);
|
||||
fsm.Register(stateB);
|
||||
fsm.TransitionTo(StateA, out _);
|
||||
|
||||
fsm.TransitionTo(StateB, out _);
|
||||
|
||||
Assert.IsTrue(stateB.OnEnterCalled, "转入 StateB 时应调用 OnEnter");
|
||||
Assert.AreEqual(StateA, stateB.EnteredFrom, "OnEnter 传入的前一状态应为 StateA");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TransitionTo_FirstEntry_OnEnterFromIsDefault()
|
||||
{
|
||||
var fsm = new GameStateMachine();
|
||||
var stateA = new MockState(StateA);
|
||||
fsm.Register(stateA);
|
||||
fsm.TransitionTo(StateA, out _);
|
||||
|
||||
Assert.AreEqual(default(GameStateId), stateA.EnteredFrom,
|
||||
"首次进入状态时,OnEnter 的 from 参数应为 default");
|
||||
}
|
||||
|
||||
// ── Tick ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void Tick_DelegatesTo_CurrentState()
|
||||
{
|
||||
var fsm = new GameStateMachine();
|
||||
var stateA = new MockState(StateA);
|
||||
fsm.Register(stateA);
|
||||
fsm.TransitionTo(StateA, out _);
|
||||
|
||||
fsm.Tick(0.016f);
|
||||
fsm.Tick(0.016f);
|
||||
|
||||
Assert.AreEqual(2, stateA.TickCount, "Tick 应转发给当前状态");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Tick_NoCurrentState_DoesNotThrow()
|
||||
{
|
||||
var fsm = new GameStateMachine();
|
||||
Assert.DoesNotThrow(() => fsm.Tick(0.016f));
|
||||
}
|
||||
|
||||
// ── 重复注册 ─────────────────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void Register_SameId_OverwritesPreviousState()
|
||||
{
|
||||
var fsm = new GameStateMachine();
|
||||
var stateA1 = new MockState(StateA);
|
||||
var stateA2 = new MockState(StateA, StateB);
|
||||
fsm.Register(stateA1);
|
||||
fsm.Register(stateA2); // 覆盖
|
||||
fsm.TransitionTo(StateA, out _);
|
||||
|
||||
var stateB = new MockState(StateB);
|
||||
fsm.Register(stateB);
|
||||
|
||||
bool result = fsm.TransitionTo(StateB, out _);
|
||||
Assert.IsTrue(result, "重新注册后的状态(含 ValidNextStates)应覆盖旧注册");
|
||||
}
|
||||
|
||||
// ── GameStateId 值相等性 ───────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void GameStateId_SameKey_AreEqual()
|
||||
{
|
||||
var id1 = new GameStateId("TestState");
|
||||
var id2 = new GameStateId("TestState");
|
||||
Assert.AreEqual(id1, id2, "相同 key 的 GameStateId 应相等");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GameStateId_DifferentKey_AreNotEqual()
|
||||
{
|
||||
var id1 = new GameStateId("StateA");
|
||||
var id2 = new GameStateId("StateB");
|
||||
Assert.AreNotEqual(id1, id2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GameStateId_DefaultIsNotEqualToNamed()
|
||||
{
|
||||
var named = new GameStateId("Something");
|
||||
var defaultId = default(GameStateId);
|
||||
Assert.AreNotEqual(named, defaultId);
|
||||
}
|
||||
|
||||
// ── BuiltinGameStates 唯一性 ───────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void BuiltinStates_AllIdsAreUnique()
|
||||
{
|
||||
var ids = new[]
|
||||
{
|
||||
GameStates.MainMenu,
|
||||
GameStates.Gameplay,
|
||||
GameStates.Paused,
|
||||
GameStates.BossFight,
|
||||
GameStates.Cutscene,
|
||||
GameStates.Loading,
|
||||
GameStates.Dead,
|
||||
GameStates.GameOver,
|
||||
};
|
||||
|
||||
var set = new HashSet<GameStateId>(ids);
|
||||
Assert.AreEqual(ids.Length, set.Count, "8 个内置游戏状态 ID 必须全部唯一");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BuiltinStates_NoneIsDefault()
|
||||
{
|
||||
var ids = new[]
|
||||
{
|
||||
GameStates.MainMenu,
|
||||
GameStates.Gameplay,
|
||||
GameStates.Paused,
|
||||
GameStates.BossFight,
|
||||
GameStates.Cutscene,
|
||||
GameStates.Loading,
|
||||
GameStates.Dead,
|
||||
GameStates.GameOver,
|
||||
};
|
||||
|
||||
foreach (var id in ids)
|
||||
{
|
||||
Assert.AreNotEqual(default(GameStateId), id,
|
||||
$"内置状态 {id} 不应等于 default(GameStateId)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 预期结果
|
||||
|
||||
所有 **17 个测试** 全部通过(绿色)。
|
||||
|
||||
### 常见问题排查
|
||||
|
||||
| 失败现象 | 排查方向 |
|
||||
|---------|---------|
|
||||
| `GameStateId` 构造函数找不到 | 确认 `GameStateId` 是 struct,构造函数参数为 string key |
|
||||
| `GameStates.MainMenu` 找不到 | 确认 `GameStates` 静态类(或 `BuiltinGameStates`)在 `BaseGames.Core` 命名空间下 |
|
||||
| `IGameState` 接口找不到 | asmdef 需同时引用 `BaseGames.Core` 和 `BaseGames.Core.Events` |
|
||||
| TransitionTo 返回 true 但 error 非 null | 确认 `GameStateMachine.TransitionTo` 成功时将 `error` 设为 `null` |
|
||||
@@ -0,0 +1,346 @@
|
||||
# 手动测试 05 · Core 基础设施
|
||||
|
||||
> **测试类型**:Unity Editor 手动测试(Play Mode)
|
||||
> **覆盖模块**:ServiceLocator 初始化、GlobalObjectPool、Addressables 加载、GameManager 状态
|
||||
> **前置文档**:`Phase1_Verification_Guide.md` §1(验证前准备)
|
||||
|
||||
---
|
||||
|
||||
## 快速工具
|
||||
|
||||
| 工具 | 用途 | 菜单路径 |
|
||||
|------|------|----------|
|
||||
| **Scaffold Persistent Scene** | 一键生成 Persistent 场景根节点与服务注册器 | `BaseGames → Tools → Scaffold Persistent Scene` |
|
||||
| **Validate Address Keys** | 验证 `AddressKeys.cs` 常量是否全部对应 Addressable 注册项 | `BaseGames → Addressables → Validate Address Keys` |
|
||||
| **Addressable Batch Tool** | 批量将资产注册到 Addressables | `BaseGames → Tools → Addressable Batch Tool` |
|
||||
|
||||
**典型工作流**:
|
||||
1. 菜单 `BaseGames → Tools → Scaffold Persistent Scene` 生成基础场景结构。
|
||||
2. 打开 `Window → BaseGames → EventBus Monitor` 监听 `EVT_ServiceRegistered` 等服务注册事件。
|
||||
3. `BaseGames → Addressables → Validate Address Keys` 确认 `MT-CORE-02` 所需地址全部存在;若有缺失,用 **Addressable Batch Tool** 一键补全。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [测试前检查清单](#1-测试前检查清单)
|
||||
2. [MT-CORE-01:服务层启动顺序](#mt-core-01服务层启动顺序)
|
||||
3. [MT-CORE-02:Addressables 资产加载](#mt-core-02addressables-资产加载)
|
||||
4. [MT-CORE-03:GlobalObjectPool 对象池](#mt-core-03globalobjectpool-对象池)
|
||||
5. [MT-CORE-04:GameManager 状态转换](#mt-core-04gamemanager-状态转换)
|
||||
6. [MT-CORE-05:SceneLoader 异步加载](#mt-core-05sceneloader-异步加载)
|
||||
7. [MT-CORE-06:DifficultyManager 难度缩放](#mt-core-06difficultymanager-难度缩放)
|
||||
|
||||
---
|
||||
|
||||
## 1. 测试前检查清单
|
||||
|
||||
| # | 检查项 | 操作 | ✓ |
|
||||
|---|--------|------|---|
|
||||
| 1 | Console 无红色 Error | `Window → General → Console` | ☐ |
|
||||
| 2 | Persistent.unity 在 Build Settings 第一位 | `File → Build Settings` | ☐ |
|
||||
| 3 | SO 事件资产已生成 | `Assets/Data/Events/` 下有 `EVT_*.asset` 文件 | ☐ |
|
||||
| 4 | Addressables 已构建 | `Window → Asset Management → Addressables → Groups → Build → New Build` | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-CORE-01:服务层启动顺序
|
||||
|
||||
**目的**:验证 `GameServiceRegistrar` 在 Awake 阶段正确将核心服务注册到 `ServiceLocator`。
|
||||
|
||||
**步骤:**
|
||||
|
||||
1. 打开 `Persistent.unity` 场景(或含 Persistent 场景的测试场景)
|
||||
2. 在 Hierarchy 中确认存在挂有 `GameServiceRegistrar` 组件的 GameObject
|
||||
3. 选中该 GameObject → Inspector 确认 4 个字段均已绑定(非 None):
|
||||
`_deathRespawnService` / `_sceneService` / `_eventChannelRegistry` / `_saveManager`
|
||||
4. 按 **Play** 进入 Play Mode
|
||||
5. 打开 `Window → General → Console`,Filter 输入 `GameServiceRegistrar`
|
||||
|
||||
**预期结果:**
|
||||
|
||||
| 检查点 | 期望输出 | ✓ |
|
||||
|--------|---------|---|
|
||||
| Console 出现 `[GameServiceRegistrar] ✅ 服务注册完成` | 列出 4 项服务名称 | ☐ |
|
||||
| 无 `⚠ _xxx 未绑定` 黄色警告 | 所有字段均已 Inspector 绑定 | ☐ |
|
||||
| 无 `[ServiceLocator] Service 'X' is not registered` 红色 Error | 后续系统正常访问服务 | ☐ |
|
||||
| `AudioManager`(或 `NullAudioService`)注册成功 | Console 无音频服务相关错误 | ☐ |
|
||||
|
||||
> **注意**:`✅ 服务注册完成` 日志仅在 **Unity Editor** 下输出(`#if UNITY_EDITOR`),打包后不存在。
|
||||
> 若控制台**没有任何输出**,最可能的原因见下方。
|
||||
|
||||
**常见问题排查:**
|
||||
|
||||
| 现象 | 原因 | 解决 |
|
||||
|------|------|------|
|
||||
| Console 完全无输出 | `GameServiceRegistrar` 组件未挂到场景,或所在 GameObject 被禁用 | 检查 Hierarchy,确认 GameObject 激活(✓) |
|
||||
| `⚠ _saveManager 未绑定` 黄色警告 | Inspector 字段 None | 将对应组件拖入字段 |
|
||||
| `✅` 日志显示但后续仍有 `is not registered` Error | 其他 Manager(如 `GameManager`)用旧接口 `ServiceLocator.Get` 先于 Awake 执行 | 检查 ExecutionOrder;`GameServiceRegistrar` 应为 `-2000`,须先于所有调用方 |
|
||||
| Filter 输入 `ServiceLocator` 无结果 | `GameServiceRegistrar` 的日志前缀是 `[GameServiceRegistrar]` | 改为 Filter 输入 `GameServiceRegistrar` |
|
||||
|
||||
---
|
||||
|
||||
## MT-CORE-02:Addressables 资产加载
|
||||
|
||||
**目的**:验证 Addressables 异步加载流程正常,不抛出 `InvalidKeyException`。
|
||||
|
||||
**步骤:**
|
||||
|
||||
1. 确认 Addressables 已构建(见前置检查)
|
||||
2. 在测试场景或通过 `AddressKeyValidator` 工具验证
|
||||
3. 按 **Play** 进入 Play Mode
|
||||
4. Console Filter 输入 `[AddressKeyValidator]`
|
||||
|
||||
**预期结果:**
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| Console 无 `[AddressKeyValidator] ❌` 错误 | 所有注册 key 均可解析 | ☐ |
|
||||
| 无 `InvalidKeyException` 红色 Error | 0 个 | ☐ |
|
||||
| 预热(WarmupAsync)无 Timeout 警告 | 正常完成 | ☐ |
|
||||
|
||||
**使用 Addressable Batch Tool 修复孤儿 Key:**
|
||||
|
||||
> **入口**:菜单 `BaseGames → Tools → Addressable Batch Tool`(快捷键 `Alt+Shift+A`)。
|
||||
|
||||
工具提供三个 Tab,根据资产创建情况选用:
|
||||
|
||||
---
|
||||
|
||||
### Tab ① 同步 AddressKeys
|
||||
|
||||
**适用场景**:资产文件已存在,但尚未注册到 Addressables,或地址字符串与 `AddressKeys.cs` 中的常量不一致。
|
||||
|
||||
**步骤:**
|
||||
|
||||
1. 打开工具 → 切换到 **① 同步 AddressKeys**
|
||||
2. 工具自动列出 `AddressKeys.cs` 中的全部常量,并派生搜索词后在 Project 中搜索匹配资产
|
||||
**搜索词推导规则(`DeriveName`)**:取 Key 中最后一个 `/` 之后的部分,再去掉第一个 `_` 及其前缀
|
||||
例:`Scene_Persistent` → `Persistent`;`ENM_GruntWarrior` → `GruntWarrior`;`Config/FootstepCatalog` → `FootstepCatalog`(无下划线,直接用原名)
|
||||
注意:注册时使用的是**完整的原始 Key 值**(如 `Config/FootstepCatalog`),搜索词仅用于定位文件。
|
||||
3. 勾选 **仅显示未注册项**,过滤已处理项
|
||||
4. 结果分三类:
|
||||
|
||||
| 状态标记 | 含义 | 操作 |
|
||||
|----------|------|------|
|
||||
| `✅ 已注册` | 资产已在 Addressables 中,地址匹配 | 无需操作 |
|
||||
| `⚠ 未注册`(橙色) | 资产文件存在,地址未写入 Addressables | 点击行右侧 **注册** 按钮,或点 **注册所有已匹配项** 一键批量处理 |
|
||||
| `❌ 未找到`(红色) | 资产文件尚未创建 | 在行末的 ObjectField 手动拖入资产后点 **注册**;若资产未创建则跳过,待制作后再补注册 |
|
||||
|
||||
5. 批量处理后点击 **刷新列表**,确认所有可处理项变为 `✅ 已注册`
|
||||
|
||||
**预期结果:**
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 底部状态栏 `已注册 X` 数值增加 | 与操作前相比增加了已处理的 Key 数量 | ☐ |
|
||||
| `⚠ 未注册` 行清零(仅剩 `❌ 未找到`) | 存在资产的 Key 全部注册完毕 | ☐ |
|
||||
| 重新执行 `Validate Address Keys` | 孤儿 Key 数量减少,剩余均为"资产未创建"类 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
### Tab ② 文件夹批量注册
|
||||
|
||||
**适用场景**:一批 Prefab/Scene/SO 文件集中在某个文件夹下,需要整体注册。
|
||||
|
||||
**步骤:**
|
||||
|
||||
1. 切换到 **② 文件夹批量注册**
|
||||
2. 将目标文件夹(蓝色图标)从 Project 窗口拖入 **目标文件夹** 字段
|
||||
3. 按需勾选资产类型(Prefab / Scene / SO/Asset 等)
|
||||
4. 选择 **地址格式**:
|
||||
|
||||
| 格式选项(枚举名) | Inspector 显示名 | 生成地址示例 | 推荐场景 |
|
||||
|--------------------|-----------------|-------------|----------|
|
||||
| `FileName`(默认) | 文件名(推荐) | `PLY_Player` | 资产命名已与 AddressKeys 常量值一致 |
|
||||
| `FullAssetPath` | 完整 Asset 路径 | `Assets/Prefabs/Enemies/ENM_GruntWarrior.prefab` | 需要绝对路径作为地址时 |
|
||||
| `RelativeToFolder` | 相对于选定文件夹 | `Enemies/ENM_GruntWarrior.prefab` | 按子文件夹相对路径区分同名文件 |
|
||||
| `PrefixPlusFileName` | 前缀 + 文件名 | `Config/FootstepCatalog` | Key 包含斜杠前缀(如 `Config/`) |
|
||||
| `PrefixPlusRelativePath` | 前缀 + 相对路径 | `Config/Sub/FootstepCatalog` | 前缀 + 多级子目录相对路径 |
|
||||
|
||||
5. 点击 **扫描文件夹** 预览待注册资产列表,可在表格中手动修改每行的 **预计地址**
|
||||
6. 确认无误后点击 **注册所有**
|
||||
|
||||
**预期结果:**
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 扫描后列表出现资产行 | 文件夹下资产正确识别 | ☐ |
|
||||
| 注册后列表中各行状态变为 `✅ 已有` | 全部写入 Addressables 分组 | ☐ |
|
||||
| Addressables Groups 窗口中对应分组出现新条目 | `Window → Asset Management → Addressables → Groups` 可见 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
### Tab ③ 选中资产注册
|
||||
|
||||
**适用场景**:仅需注册零散几个资产。
|
||||
|
||||
**步骤:**
|
||||
|
||||
1. 在 Project 窗口中 **多选** 目标资产(`Ctrl+单击` 或框选)
|
||||
2. 切换到 **③ 选中资产注册** → 点击 **读取选中项**
|
||||
3. 在列表中确认资产路径和地址字符串正确(可手动编辑地址列)
|
||||
4. 点击 **注册所有**
|
||||
|
||||
---
|
||||
|
||||
### 当前孤儿 Key 分类处理建议
|
||||
|
||||
> 根据上方 `ValidateAddressKeys` 输出的 19 个孤儿 Key:
|
||||
|
||||
| 分类 | Key 示例 | 推荐操作 |
|
||||
|------|----------|----------|
|
||||
| **场景(未注册)** | `Scene_Persistent`、`Scene_MainMenu` | `DeriveName` 搜索词为 `Persistent`/`MainMenu`。**若 `.unity` 存在且从未注册**:Tab ① 显示 `⚠ 未注册`,点注册即可。**若已注册但地址不对**:启用共用选项区的「已注册的资产也覆盖地址」后再用 Tab ①,或在 Addressables Groups 手动修改 Address。 |
|
||||
| **Prefab(尚未制作)** | `PLY_Player`、`ENM_*`、`WPN_*`、`VFX_*`、`PROJ_*`、`COL_*`、`UI_*` | 先制作资产,制作完成后用 **Tab ①** 一键补注册。⚠ `COL_Item` 搜索词为通用词 "Item",注册前须确认匹配资产路径正确,如有误请手动拖入正确资产。 |
|
||||
| **Data/Config(SO 资产)** | `Config/FootstepCatalog` | **Tab ① 即可处理**:`DeriveName` 取斜杠后部分 "FootstepCatalog" 搜索文件,注册时保留完整地址 "Config/FootstepCatalog"。创建 SO 后直接在 Tab ① 点注册,无需 Tab ②。 |
|
||||
|
||||
> ⚠ **注意**:19 个 Key 中大部分对应 Prefab 资产尚未制作,这是早期开发阶段的正常状态,不影响当前 MT-CORE-01/03/04/05/06 的验证。`MT-CORE-02` 的"无 `InvalidKeyException`"检查项待资产补全后再验证。
|
||||
|
||||
---
|
||||
|
||||
**手动加载测试(可选):**
|
||||
|
||||
在测试脚本中加入以下代码临时验证:
|
||||
|
||||
```csharp
|
||||
// 在任意 MonoBehaviour.Start() 中
|
||||
var handle = Addressables.LoadAssetAsync<GameObject>("你的Prefab的AddressKey");
|
||||
await handle.Task;
|
||||
if (handle.Status == AsyncOperationStatus.Succeeded)
|
||||
Debug.Log("✅ 加载成功: " + handle.Result.name);
|
||||
else
|
||||
Debug.LogError("❌ 加载失败: " + handle.OperationException);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MT-CORE-03:GlobalObjectPool 对象池
|
||||
|
||||
**目的**:验证对象池 Spawn/Despawn 无内存泄漏,PooledObject 自动归还正常。
|
||||
|
||||
> **🔧 资源准备**
|
||||
>
|
||||
> 此测试需要场景中有可以被攻击的目标,才能驱动 HitFX / 投射物从对象池中取出:
|
||||
>
|
||||
> 菜单 `BaseGames → Scene → Place → Enemy (Basic)` 在场景中放置一个带 HurtBox、HitBox_Body 和 EnemyStats 的基础敌人对象,作为攻击目标。
|
||||
|
||||
**步骤:**
|
||||
|
||||
1. 确认测试场景中 `GlobalObjectPool` 已被 `GameServiceRegistrar` 初始化
|
||||
2. 确认场景中已有假人(见上方资源准备)
|
||||
3. 按 **Play** 进入 Play Mode
|
||||
4. 打开 **Window → Analysis → Profiler**,切换到 **Memory** 视图
|
||||
5. 走到假人旁攻击数次,触发 HitFX / 投射物从对象池生成
|
||||
6. 等待 VFX/子弹归还(生命周期结束)
|
||||
|
||||
**预期结果:**
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| Profiler GC Alloc 无持续增长(稳定后每帧 < 1KB) | 无内存泄漏 | ☐ |
|
||||
| Hierarchy 中 VFX/弹药 GameObject 在归还后 `SetActive(false)` | 对象回池而非 Destroy | ☐ |
|
||||
| Console 无 `[Pool] Object not from pool` 警告 | 无错误归还 | ☐ |
|
||||
|
||||
**Inspector 查验:**
|
||||
|
||||
- 选中 `GlobalObjectPool` GameObject
|
||||
- 展开 Inspector 中的缓存字典(若有 Editor 显示)
|
||||
- 确认池的容量不无限增长
|
||||
|
||||
---
|
||||
|
||||
## MT-CORE-04:GameManager 状态转换
|
||||
|
||||
**目的**:验证 `GameManager` 的状态机在游戏事件触发时正确转换。
|
||||
|
||||
**步骤:**
|
||||
|
||||
### 暂停/恢复流程
|
||||
|
||||
1. 进入 Play Mode(Gameplay 状态)
|
||||
2. 按 **Escape**(默认暂停键)
|
||||
3. 检查 Console:`[GameManager] → Paused`(或等价日志)
|
||||
4. 再按 **Escape** 恢复
|
||||
5. 检查 Console:`[GameManager] → Gameplay`
|
||||
|
||||
**预期结果:**
|
||||
|
||||
| 步骤 | 期望 | ✓ |
|
||||
|------|------|---|
|
||||
| 按 Esc | `GameState` 切换到 `Paused`,`Time.timeScale == 0`(或等价冻结) | ☐ |
|
||||
| 再按 Esc | `GameState` 切换回 `Gameplay`,游戏恢复正常运行 | ☐ |
|
||||
| `EVT_GameStateChanged` 频道触发 | `EventBusMonitor` 窗口显示事件 | ☐ |
|
||||
|
||||
### 死亡流程
|
||||
|
||||
1. 打开 `Window → BaseGames → EventBus Monitor`(若已实现)
|
||||
2. 进入 Play Mode
|
||||
3. 降低玩家 HP 至 0(通过调试工具或故意让敌人攻击)
|
||||
|
||||
**预期结果:**
|
||||
|
||||
| 步骤 | 期望 | ✓ |
|
||||
|------|------|---|
|
||||
| HP 归零 | `EVT_PlayerDied` 触发,GameManager 转换到 `Dead` 状态 | ☐ |
|
||||
| 死亡动画播放后 | 死亡 UI(`DeathScreen`)显示 | ☐ |
|
||||
| 点击 "重试" / 确认 | `RespawnCoroutine` 启动,场景重新加载,玩家在上次存档点复活 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-CORE-05:SceneLoader 异步加载
|
||||
|
||||
**目的**:验证场景异步加载/卸载无卡顿,过渡动画正确播放。
|
||||
|
||||
> **🔧 资源准备(一键)**
|
||||
>
|
||||
> 需要至少一个 Room 场景注册到 Build Settings:
|
||||
>
|
||||
> **步骤**:
|
||||
>
|
||||
> 1. `File → New Scene → Empty` 创建空场景,保存为 `Assets/_Game/Scenes/Room_A.unity`
|
||||
> 2. 添加地面 GameObject(Layer=Ground,BoxCollider2D 宽40)
|
||||
> 3. 添加 `RoomController` 组件,设置 `_roomId = "Room_A"`
|
||||
> 4. 添加 `RoomTransition`,设置 `_transitionId="exit_right"`,`_targetSceneAddress="Room_B"`,`_autoTrigger=true`
|
||||
> 5. `File → Build Settings → Add Open Scenes` 注册
|
||||
> 6. 在 Addressables Groups 中将两个场景添加为条目,地址与 Key 匹配
|
||||
|
||||
**步骤:**
|
||||
|
||||
1. 确认 `Persistent.unity` 和 `Room_A.unity`(或等价测试场景)在 Build Settings 中
|
||||
2. 确认两个场景的 `RoomTransition._targetSceneAddress` 已分别填写对方的 Addressable Key
|
||||
3. 进入 Play Mode
|
||||
4. 触发房间切换(通过 `RoomTransition` 触发器进入过渡区)
|
||||
|
||||
**预期结果:**
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 场景加载期间显示过渡(淡黑/淡白) | Loading UI 出现并消失 | ☐ |
|
||||
| Additive 加载:新场景对象出现,旧场景对象卸载 | Hierarchy 中旧房间 GameObject 被移除 | ☐ |
|
||||
| Console 无 `Scene 'X' is not in Build Settings` 错误 | 0 个 | ☐ |
|
||||
| Persistent 场景 GameObject(GameManager、ServiceRegistrar 等)在切换后**不**重复 | 单例不重复创建 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-CORE-06:DifficultyManager 难度缩放
|
||||
|
||||
**目的**:验证难度切换后敌人属性缩放正确应用。
|
||||
|
||||
**步骤:**
|
||||
|
||||
1. 进入 Play Mode
|
||||
2. 打开 Inspector,找到 `DifficultyManager` 组件
|
||||
3. 修改 `CurrentDifficulty` 字段(或通过调试菜单切换到 `Hard`/`SteelSoul`)
|
||||
4. 观察 Console 中 `[DifficultyManager]` 日志
|
||||
|
||||
**预期结果:**
|
||||
|
||||
| 难度 | 敌人 HP 倍率 | 敌人伤害倍率 | ✓ |
|
||||
|------|------------|------------|---|
|
||||
| Normal | 1.0x | 1.0x | ☐ |
|
||||
| Hard | `DifficultyScalerSO.hpMultiplier`(如 1.5x) | `damageMultiplier`(如 1.5x) | ☐ |
|
||||
| SteelSoul | 1.0x(或更高) | 1.0x | ☐ |
|
||||
|
||||
**切换验证:**
|
||||
- `EVT_DifficultyChanged` 频道触发(EventBusMonitor 可见)
|
||||
- 存档后重载,难度设置与存档一致
|
||||
@@ -0,0 +1,424 @@
|
||||
# 手动测试 06 · 玩家 FSM 与移动系统
|
||||
|
||||
> **测试类型**:Unity Editor 手动测试(Play Mode)
|
||||
> **覆盖模块**:`BaseGames.Player`、`BaseGames.Player.States`
|
||||
> **依赖组件**:`PlayerController`、`PlayerMovement`、`AnimancerComponent`、`PlayerStats`
|
||||
> **场景要求**:包含玩家 Prefab 的测试场景(TestRoom.unity),地面 Layer = `Ground`
|
||||
|
||||
---
|
||||
|
||||
## 快速工具
|
||||
|
||||
| 工具 | 用途 | 菜单路径 |
|
||||
|------|------|----------|
|
||||
| **Place Player** | 在场景中放置带完整组件的玩家 GameObject(PlayerController、PlayerStats、HurtBox 等) | `BaseGames → Scene → Place → Player` |
|
||||
| **Place Ground Platform** | 放置地面平台(BoxCollider2D,Layer=Ground) | `BaseGames → Scene → Place → Ground Platform` |
|
||||
| **Place Obstacle (Static)** | 放置静止障碍物(用于蹬墙跳测试墙),手动调整尺寸和位置 | `BaseGames → Scene → Place → Obstacle (Static)` |
|
||||
| **Place Room Camera** | 放置带 Cinemachine + RoomCamera + CinemachineConfiner2D 的房间相机 | `BaseGames → Scene → Place → Room Camera` |
|
||||
|
||||
> **注意**:PlayModeDebugOverlay 已移除。Play Mode 运行时调试请使用 `Window → Analysis → EventBus Monitor`(若已集成)或 Console 过滤器查看状态机日志。
|
||||
|
||||
**典型工作流**:
|
||||
1. `BaseGames → Scene → Place → Player` 放置玩家;`Place → Ground Platform` 放置地面;`Place → Room Camera` 放置相机。
|
||||
2. `MT-PLAYER-04` 蹬墙跳测试:`Place → Obstacle (Static)` 在玩家右侧放置垂直墙体,调整位置和尺寸。
|
||||
3. Play Mode 运行时通过 Inspector 直接修改 HP / Soul 字段,或者利用 Console + EventBus Monitor 观察状态机转换。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [场景搭建要求](#1-场景搭建要求)
|
||||
2. [MT-PLAYER-01:基础移动状态转换](#mt-player-01基础移动状态转换)
|
||||
3. [MT-PLAYER-02:跳跃与下落](#mt-player-02跳跃与下落)
|
||||
4. [MT-PLAYER-03:冲刺(地面/空中)](#mt-player-03冲刺地面空中)
|
||||
5. [MT-PLAYER-04:蹬墙滑行与蹬墙跳](#mt-player-04蹬墙滑行与蹬墙跳)
|
||||
6. [MT-PLAYER-05:受击与死亡状态](#mt-player-05受击与死亡状态)
|
||||
7. [MT-PLAYER-06:灵泉治疗](#mt-player-06灵泉治疗)
|
||||
8. [MT-PLAYER-07:形态切换](#mt-player-07形态切换)
|
||||
9. [MT-PLAYER-08:连击链完整性](#mt-player-08连击链完整性)
|
||||
10. [MT-PLAYER-09:存档点复活流程](#mt-player-09存档点复活流程)
|
||||
|
||||
---
|
||||
|
||||
## 1. 场景搭建要求
|
||||
|
||||
在开始任何玩家测试前,确认测试场景包含以下元素:
|
||||
|
||||
| 元素 | 说明 | ✓ |
|
||||
|------|------|---|
|
||||
| 玩家 Prefab | 挂有 `PlayerController`、`PlayerMovement`、`AnimancerComponent`、`PlayerStats`、`HurtBox` | ☐ |
|
||||
| 地面平台 | Layer = `Ground`,带 `Collider2D` | ☐ |
|
||||
| 墙壁 | 至少一面垂直墙壁,Layer = `Ground`,用于蹬墙测试 | ☐ |
|
||||
| InputReader SO | `PlayerController` 的 `_inputReader` 字段已绑定 `InputReaderSO` 资产 | ☐ |
|
||||
| PlayerStatsSO | `PlayerController` 的 `_statsConfig` 字段已绑定 | ☐ |
|
||||
| PlayerMovementConfigSO | `PlayerMovement` 的 `_config` 字段已绑定 | ☐ |
|
||||
| AnimationConfigSO | `PlayerController` 的 `_animConfig` 字段已绑定 | ☐ |
|
||||
| Physics2D Layer 矩阵 | Player、Ground Layer 碰撞开启 | ☐ |
|
||||
|
||||
> **🔧 场景快速搭建**
|
||||
>
|
||||
> 1. `BaseGames → Scene → Place → Player` 放置玩家 + `Place → Ground Platform` 生成地面 + `Place → Room Camera` 生成相机
|
||||
> 2. `MT-PLAYER-04` 蹬墙跳:`Place → Obstacle (Static)` 在玩家右侧放置垂直墙体,调整 Transform 尺寸。
|
||||
>
|
||||
> **玩家 Prefab 手动组装(若无预制体)**:
|
||||
>
|
||||
> 1. 场景 `[Player]` 根节点下创建 `PLY_Player` GameObject
|
||||
> 2. 添加以下组件:
|
||||
> - `Rigidbody2D`(Dynamic,FreezeRotation,Interpolate)
|
||||
> - `CapsuleCollider2D`(尺寸参考美术图层,isTrigger=false)
|
||||
> - `AnimancerComponent`(从 Kybernetik.Animancer 包)
|
||||
> - `PlayerController`(`BaseGames.Player`)
|
||||
> - `PlayerMovement`(`BaseGames.Player`)
|
||||
> - `StatusEffectManager`(`BaseGames.Combat.StatusEffects`)
|
||||
> 3. 创建子 GameObject `HurtBox`(isTrigger=true,Layer=PlayerHurtBox)并添加 `HurtBox` 组件
|
||||
> 4. 创建子 GameObject `HitBox_Sword`(isTrigger=true,Layer=PlayerHitBox),**初始 SetActive(false)**,添加 `HitBox` 组件
|
||||
> 5. **Inspector 中绑定 SO 字段**:
|
||||
> - `PlayerController._inputReader` → 拖入 `InputReaderSO.asset`(`Assets/Data/Input/`)
|
||||
> - `PlayerController._statsConfig` → 拖入 `PlayerStats.asset`(`Assets/Data/Settings/`)
|
||||
> - `PlayerController._animConfig` → 拖入 `AnimationConfig.asset`
|
||||
> - `PlayerMovement._config` → 拖入 `PlayerMovementConfig.asset`
|
||||
> 6. 菜单 `BaseGames → Tools → Physics2D Layer Matrix → Check` / **Auto Fix** 确保层碰撞矩阵正确
|
||||
>
|
||||
> **一键生成 SO 资产(若尚未创建)**:
|
||||
> 菜单 `BaseGames → Tools → Create Test Assets` 自动生成上述所有 SO 占位资产。
|
||||
|
||||
---
|
||||
|
||||
## MT-PLAYER-01:基础移动状态转换
|
||||
|
||||
**目的**:验证 `IdleState → RunState → IdleState` 转换,Animancer FSM 动画同步。
|
||||
|
||||
### 步骤
|
||||
|
||||
1. 进入 Play Mode
|
||||
2. 玩家站立不动,观察动画
|
||||
|
||||
**预期**:播放 `Idle` 动画,无抖动。
|
||||
|
||||
3. 按住 **A/D**(或方向键左右)移动
|
||||
|
||||
**预期**:
|
||||
- 玩家水平位移,`PlayerMovement.IsGrounded == true`
|
||||
- 动画切换到 `Run`(Animancer 状态可在 Animator 窗口观察)
|
||||
|
||||
4. 松开移动键
|
||||
|
||||
**预期**:
|
||||
- 玩家减速到静止(`Deceleration` 生效)
|
||||
- 动画切回 `Idle`
|
||||
|
||||
5. 按住移动并朝反方向转向
|
||||
|
||||
**预期**:
|
||||
- `SpriteRenderer.flipX` 改变(或 Transform.localScale.x 取反)
|
||||
- 朝向立即响应,无延迟
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| Idle 动画播放 | 无卡帧,无 A-pose | ☐ |
|
||||
| Run 动画播放 | 移动速度与动画速率匹配 | ☐ |
|
||||
| 朝向正确 | 面向移动方向 | ☐ |
|
||||
| Console 无 Error | 0 个红色 Error | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-PLAYER-02:跳跃与下落
|
||||
|
||||
**目的**:验证跳跃物理弧线、土狼时间(Coyote Time)、可变跳跃高度(提前松键降低高度)。
|
||||
|
||||
### 步骤
|
||||
|
||||
**步骤 A:普通跳跃**
|
||||
|
||||
1. 地面上按 **Space**(跳跃键)
|
||||
2. 观察轨迹
|
||||
|
||||
**预期**:抛物线跳跃弧线,高度约为 `PlayerMovementConfigSO.JumpForce / Gravity`。
|
||||
|
||||
**步骤 B:可变跳跃**
|
||||
|
||||
1. 跳跃后立即松开 **Space**
|
||||
|
||||
**预期**:玩家跳跃高度降低(`PlayerMovement` 在松键时减少向上速度),动画保持 Jump → Fall 正确过渡。
|
||||
|
||||
**步骤 C:土狼时间(Coyote Time)**
|
||||
|
||||
1. 让玩家走到平台边缘,**走出平台悬空**
|
||||
2. 在 `CoyoteTime`(约 0.15s)内按 **Space**
|
||||
|
||||
**预期**:跳跃生效(玩家可以从空中起跳),而非立即下落。
|
||||
|
||||
**步骤 D:下落加速**
|
||||
|
||||
1. 跳跃到最高点后松键,等待自然下落
|
||||
|
||||
**预期**:下落速度比上升时快(`FallMultiplier` 生效),手感有重量感。
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| Jump 动画 | 起跳时播放 Jump 动画 | ☐ |
|
||||
| Fall 动画 | 下落时切换 Fall 动画 | ☐ |
|
||||
| 土狼时间有效 | 走出平台后短时内仍可跳跃 | ☐ |
|
||||
| 落地动画 | 落地瞬间切回 Idle/Run(无卡帧) | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-PLAYER-03:冲刺(地面/空中)
|
||||
|
||||
**目的**:验证 `DashState`(地面)和 `AerialDashState`(空中)的无敌帧、冷却、次数限制。
|
||||
|
||||
### 步骤
|
||||
|
||||
**步骤 A:地面冲刺**
|
||||
|
||||
1. 地面上按 **Left Shift**(冲刺键)
|
||||
|
||||
**预期**:
|
||||
- 玩家水平快速位移(距离约 `dashDistance`)
|
||||
- 冲刺期间播放 Dash 动画
|
||||
- 冲刺期间受到敌人攻击**不触发** `HurtState`(无敌帧)
|
||||
- 冲刺结束后自然过渡 `IdleState` 或 `RunState`
|
||||
|
||||
**步骤 B:空中冲刺**
|
||||
|
||||
1. 跳跃后按 **Left Shift**
|
||||
|
||||
**预期**:
|
||||
- 空中水平冲刺
|
||||
- 消耗 `_aerialDashCount`(通常 1 次)
|
||||
- 落地后次数重置
|
||||
|
||||
**步骤 C:空中冲刺次数用尽**
|
||||
|
||||
1. 空中冲刺 1 次(次数用尽)
|
||||
2. 再次按冲刺键
|
||||
|
||||
**预期**:冲刺不触发(次数为 0 时无反应或有 UI 提示)。
|
||||
|
||||
**步骤 D:冲刺冷却**
|
||||
|
||||
1. 地面冲刺后立即再次按冲刺键
|
||||
|
||||
**预期**:冷却期间(`dashCooldown`)无法再次冲刺。
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 地面冲刺位移 | 快速平移 `dashDistance` 距离 | ☐ |
|
||||
| 无敌帧 | 冲刺期间不受伤 | ☐ |
|
||||
| 空中冲刺次数 | 次数耗尽后无法再冲 | ☐ |
|
||||
| 落地重置次数 | 落地后次数恢复 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-PLAYER-04:蹬墙滑行与蹬墙跳
|
||||
|
||||
**目的**:验证 `WallSlideState` 减速下滑、`WallJumpState` 弹离逻辑。
|
||||
|
||||
### 步骤
|
||||
|
||||
**步骤 A:蹬墙滑行**
|
||||
|
||||
1. 跳跃接近垂直墙壁,按住朝向墙壁的方向键
|
||||
2. 玩家贴墙后观察
|
||||
|
||||
**预期**:
|
||||
- 下落速度从自由落体减缓到 `wallSlideSpeed`
|
||||
- 播放 `WallSlide` 动画
|
||||
|
||||
**步骤 B:蹬墙跳**
|
||||
|
||||
1. WallSlide 状态下按 **Space**
|
||||
|
||||
**预期**:
|
||||
- 玩家弹离墙壁(方向取反)
|
||||
- 施加 `wallJumpForce`(包含 X/Y 两个分量)
|
||||
- 播放 `WallJump` 动画
|
||||
|
||||
**步骤 C:离墙后方向控制**
|
||||
|
||||
1. 蹬墙跳后立即按反向方向键(远离墙壁方向)
|
||||
|
||||
**预期**:玩家可控制方向(不被强制锁定朝向太久)。
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| WallSlide 减速 | 贴墙后下落速度明显降低 | ☐ |
|
||||
| WallJump 弹离 | 弹离墙壁,方向翻转 | ☐ |
|
||||
| 跳跃后可控 | `wallJumpLockTime` 后方向键恢复控制 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-PLAYER-05:受击与死亡状态
|
||||
|
||||
**目的**:验证 `HurtState` 硬直和 `DeadState` 死亡冻结。
|
||||
|
||||
### 步骤(需要有能攻击玩家的敌人或调试伤害源)
|
||||
|
||||
**步骤 A:受击硬直**
|
||||
|
||||
1. 让敌人攻击玩家(HP 不为 0)
|
||||
2. 观察玩家反应
|
||||
|
||||
**预期**:
|
||||
- 受击动画播放
|
||||
- 硬直期间(`hurtDuration`)玩家无法输入
|
||||
- 硬直结束后自动恢复 `IdleState`
|
||||
- HP 减少,HUD HP 条更新
|
||||
|
||||
**步骤 B:死亡**
|
||||
|
||||
1. 让玩家 HP 降至 0
|
||||
|
||||
**预期**:
|
||||
- 死亡动画播放
|
||||
- `Rigidbody2D.constraints` 冻结(角色不再受物理影响下移)
|
||||
- `_onPlayerDied` 事件触发(EventBusMonitor 可见)
|
||||
- 死亡屏幕 UI 出现
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| HurtState 动画 | 受击动画播放 `hurtDuration` 秒 | ☐ |
|
||||
| 硬直期间无法输入 | 按攻击键无反应 | ☐ |
|
||||
| HP 减少 | HUD HP 条正确减少 | ☐ |
|
||||
| DeadState 冻结 | 死亡后角色不再移动 | ☐ |
|
||||
| 死亡 UI | DeathScreen 显示 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-PLAYER-06:灵泉治疗
|
||||
|
||||
**目的**:验证 `SpringState` 治疗动画、灵泉消耗、打断限制。
|
||||
|
||||
### 步骤
|
||||
|
||||
1. 确保玩家有灵泉(`SpringCount > 0`)且 HP 未满
|
||||
2. 按 **治疗键**(默认参考 `InputReaderSO` 配置)
|
||||
|
||||
**预期**:
|
||||
- 播放治疗动画(`Spring` 动画片段)
|
||||
- 治疗动画期间不可被打断(敌人攻击触发 `HurtState` 优先级低于 `SpringState` 的硬直保护期)
|
||||
- 动画结束后 HP 增加(`springHealAmount`)
|
||||
- `SpringCount - 1`,HUD 灵泉图标减少
|
||||
|
||||
3. 灵泉耗尽后再按治疗键
|
||||
|
||||
**预期**:无反应(`SpringCount == 0` 时不进入 `SpringState`)。
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 治疗动画 | Spring 动画播放完整 | ☐ |
|
||||
| HP 回复 | HP 增加 `springHealAmount` | ☐ |
|
||||
| 灵泉数量 -1 | HUD 灵泉图标减少 | ☐ |
|
||||
| 灵泉耗尽 | `SpringCount == 0` 时无法治疗 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-PLAYER-07:形态切换
|
||||
|
||||
**目的**:验证 `FormController` 三形态(Sky/Earth/Death)切换正确更新外观和事件。
|
||||
|
||||
### 步骤
|
||||
|
||||
1. 进入 Play Mode
|
||||
2. 打开 `Window → BaseGames → EventBus Monitor`
|
||||
3. 按形态切换键(循环切换 Sky → Earth → Death → Sky)
|
||||
|
||||
**预期(每次切换):**
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 调色板更新 | `SpriteRenderer` 颜色/材质变化(Palette Swap) | ☐ |
|
||||
| `EVT_FormChanged` 触发 | EventBusMonitor 显示频道触发 | ☐ |
|
||||
| `EVT_SkillSetChanged` 触发 | 技能组随形态更新 | ☐ |
|
||||
| HUD 形态图标 | 形态图标切换到对应形态 | ☐ |
|
||||
| 武器伤害来源刷新 | 不同形态的武器 SO 绑定正确 | ☐ |
|
||||
|
||||
4. 切换到 Earth 形态后存档(与存档点交互)
|
||||
5. 退出并重新进入 Play Mode,加载存档
|
||||
|
||||
**预期**:重载后当前形态仍为 Earth(`PlayerSaveData.ActiveFormId` 持久化)。
|
||||
|
||||
---
|
||||
|
||||
## MT-PLAYER-08:连击链完整性
|
||||
|
||||
**目的**:验证 3 段连击链(Attack1 → Attack2 → Attack3)、超时重置、空中攻击、下劈/上劈。
|
||||
|
||||
### 步骤
|
||||
|
||||
**步骤 A:3 段地面连击**
|
||||
|
||||
1. 地面站立状态,连续快速按 3 次攻击键(间隔在 Combo 窗口内)
|
||||
|
||||
**预期**:
|
||||
- 播放 Attack1 → Attack2 → Attack3 三段动画
|
||||
- 第 3 段结束后返回 Idle,不可延续第 4 段
|
||||
|
||||
**步骤 B:连击超时重置**
|
||||
|
||||
1. 按 1 次攻击后等待超时(约 1-2 秒)
|
||||
2. 再次按攻击键
|
||||
|
||||
**预期**:从 Attack1 重新开始(Combo 计数重置)。
|
||||
|
||||
**步骤 C:空中攻击**
|
||||
|
||||
1. 跳跃后按攻击键
|
||||
|
||||
**预期**:播放 `AirAttack` 动画,HitBox 激活(正前方)。
|
||||
|
||||
**步骤 D:下劈**
|
||||
|
||||
1. 跳跃后按 **向下 + 攻击**
|
||||
|
||||
**预期**:播放 `DownAttack` 动画;若正下方有敌人,命中后玩家向上反弹(`trampolineForce`)。
|
||||
|
||||
**步骤 E:上劈**
|
||||
|
||||
1. 地面上按 **向上 + 攻击**
|
||||
|
||||
**预期**:播放 `UpAttack` 动画,HitBox 激活(正上方)。
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 3 段连击 | Attack1→Attack2→Attack3 顺序正确 | ☐ |
|
||||
| 超时重置 | 超时后从 Attack1 重新开始 | ☐ |
|
||||
| 空中攻击 | AirAttack 动画,前方 HitBox | ☐ |
|
||||
| 下劈反弹 | 下方命中后向上弹起 | ☐ |
|
||||
| 上劈 | UpAttack 动画,上方 HitBox | ☐ |
|
||||
| 命中灵力增加 | 攻击命中敌人后灵力条增加 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-PLAYER-09:存档点复活流程
|
||||
|
||||
**目的**:端到端验证存档/死亡/复活完整链路。
|
||||
|
||||
### 步骤
|
||||
|
||||
1. 进入 Play Mode,找到场景中的 `SavePoint`(存档点)
|
||||
2. 走到存档点附近,按交互键(默认 E)
|
||||
3. 确认 Console 出现 `[SaveManager] 存档成功` 日志
|
||||
4. 让玩家死亡(HP 降至 0)
|
||||
5. 在死亡屏幕点击 "重试"(或按确认键)
|
||||
|
||||
**预期**:
|
||||
- 玩家复活在存档点位置
|
||||
- HP 和灵力恢复满值
|
||||
- 死亡时丢失的 Geo 以 `DeathShade` 形式出现在死亡地点
|
||||
|
||||
6. 走到 `DeathShade` 并与其交互
|
||||
|
||||
**预期**:
|
||||
- 回收丢失的 Geo
|
||||
- `DeathShade` 消失
|
||||
- Geo 数量正确增加
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 存档触发 | SavePoint 交互后存档成功 | ☐ |
|
||||
| 死亡屏幕 | HP 归零后显示 DeathScreen | ☐ |
|
||||
| 复活位置 | 复活在存档点,不是死亡地点 | ☐ |
|
||||
| HP 满值 | 复活后 HP = MaxHP | ☐ |
|
||||
| DeathShade 出现 | 死亡地点有 DeathShade | ☐ |
|
||||
| Geo 回收 | 与 DeathShade 交互回收 Geo | ☐ |
|
||||
@@ -0,0 +1,358 @@
|
||||
# 手动测试 07 · 战斗系统
|
||||
|
||||
> **测试类型**:Unity Editor 手动测试(Play Mode)
|
||||
> **覆盖模块**:`BaseGames.Combat`、`BaseGames.Parry`、`BaseGames.Combat.StatusEffects`
|
||||
> **依赖组件**:`HitBox`、`HurtBox`、`ShieldComponent`、`ParrySystem`、`StatusEffectManager`
|
||||
> **场景要求**:测试场景含玩家 + 至少一只敌人,Physics2D Layer 矩阵已配置
|
||||
|
||||
---
|
||||
|
||||
## 快速工具
|
||||
|
||||
| 工具 | 用途 | 菜单路径 |
|
||||
|------|------|----------|
|
||||
| **Check Physics2D Layer Matrix** | 一键检查层碰撞矩阵配置,输出 ✅/❌ 报告 | `BaseGames → Tools → Physics2D Layer Matrix → Check` |
|
||||
| **Auto Fix Physics2D Layer Matrix** | 对所有错误层对自动调用 `Physics2D.IgnoreLayerCollision()` 并持久化 | `BaseGames → Tools → Physics2D Layer Matrix → Auto Fix` |
|
||||
| **Place Enemy (Basic)** | 放置带 HurtBox、HitBox_Body 和 EnemyStats 的基础敌人(护盾测试需手动再添加 ShieldComponent) | `BaseGames → Scene → Place → Enemy (Basic)` |
|
||||
| **Place Obstacle (Static)** | 放置静止障碍物(投射物测试挡墙) | `BaseGames → Scene → Place → Obstacle (Static)` |
|
||||
|
||||
> **注意**:PlayModeDebugOverlay 已移除。Play Mode 运行时调试请利用 Inspector 直接修改字段,或通过代码调用施加状态效果(见方式 C)。
|
||||
|
||||
**典型工作流**:
|
||||
1. 测试前:`BaseGames → Tools → Physics2D Layer Matrix → Check` 一键确认矩阵,若有红项立即 **Auto Fix**,无需手动翻 Project Settings。
|
||||
2. `MT-COMBAT-04` 护盾:`Place → Enemy (Basic)` 放置敌人,Inspector 中手动添加 `ShieldComponent`,进入 Play Mode 直接攻击。
|
||||
3. `MT-COMBAT-06` 状态效果:通过代码或配置有状态效果的敌人攻击触发(见下方方式 A/B/C)。
|
||||
4. `MT-COMBAT-07` 投射物:`Place → Obstacle (Static)` 放置挡墙,观察投射物命中效果。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [Physics2D 配置检查](#1-physics2d-配置检查)
|
||||
2. [MT-COMBAT-01:战斗管道基础](#mt-combat-01战斗管道基础)
|
||||
3. [MT-COMBAT-02:HitBox 激活时序](#mt-combat-02hitbox-激活时序)
|
||||
4. [MT-COMBAT-03:弹反系统](#mt-combat-03弹反系统)
|
||||
5. [MT-COMBAT-04:护盾系统](#mt-combat-04护盾系统)
|
||||
6. [MT-COMBAT-05:霸体(Poise)系统](#mt-combat-05霸体poise系统)
|
||||
7. [MT-COMBAT-06:状态效果集成](#mt-combat-06状态效果集成)
|
||||
8. [MT-COMBAT-07:投射物系统](#mt-combat-07投射物系统)
|
||||
9. [MT-COMBAT-08:碰撞冲突(Clash)系统](#mt-combat-08碰撞冲突clash系统)
|
||||
|
||||
---
|
||||
|
||||
## 1. Physics2D 配置检查
|
||||
|
||||
在开始战斗测试前,必须确认 Layer 碰撞矩阵:
|
||||
|
||||
**路径**:`Edit → Project Settings → Physics 2D → Layer Collision Matrix`
|
||||
|
||||
| Layer A | Layer B | 应开启碰撞 |
|
||||
|---------|---------|---------|
|
||||
| `PlayerHitBox` | `EnemyHurtBox` | ✅ 开启 |
|
||||
| `EnemyHitBox` | `PlayerHurtBox` | ✅ 开启 |
|
||||
| `Player` | `Ground` | ✅ 开启 |
|
||||
| `Enemy` | `Ground` | ✅ 开启 |
|
||||
| `Projectile` | `EnemyHurtBox` | ✅ 开启 |
|
||||
| `Projectile` | `PlayerHurtBox` | ✅ 开启 |
|
||||
| `PlayerHitBox` | `PlayerHurtBox` | ❌ 关闭(不自伤) |
|
||||
| `EnemyHitBox` | `EnemyHurtBox` | ❌ 关闭(不自伤) |
|
||||
|
||||
---
|
||||
|
||||
## MT-COMBAT-01:战斗管道基础
|
||||
|
||||
**目的**:验证完整攻击链路:玩家攻击 → HitBox 触发 → DamageInfo 计算 → 敌人 HP 扣减。
|
||||
|
||||
> **🔧 资源准备**
|
||||
>
|
||||
> 此测试需要场景中同时存在玩家和至少一只敌人:
|
||||
>
|
||||
> 1. `BaseGames → Scene → Place → Player` 生成玩家;`Place → Ground Platform` 生成地面(若尚未搭建)
|
||||
> 2. `BaseGames → Scene → Place → Enemy (Basic)` 放置带 HurtBox 的基础敌人
|
||||
> 3. `BaseGames → Tools → Physics2D Layer Matrix → Check` 检查并按需 **Auto Fix** 层碰撞矩阵
|
||||
>
|
||||
> **最小手动步骤**:场景中必须有 Layer=`EnemyHurtBox` 的触发碰撞体(挂 `HurtBox`),以及 Layer=`PlayerHitBox` 的攻击碰撞体(挂 `HitBox`)。
|
||||
|
||||
### 步骤
|
||||
|
||||
1. 进入 Play Mode
|
||||
2. 走到敌人附近(攻击范围内)
|
||||
3. 按攻击键(Z/J)
|
||||
|
||||
**预期**:
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 伤害数字弹出 | 屏幕出现伤害数字 FloatingText | ☐ |
|
||||
| 敌人 HP 减少 | Inspector 中 `EnemyStats.CurrentHP` 减少 | ☐ |
|
||||
| HitStop 停顿 | 命中瞬间短暂帧停顿(约 0.05-0.1s),有命中质感 | ☐ |
|
||||
| HitFX 播放 | 命中点产生对应类型的击中特效(斩击/钝击等) | ☐ |
|
||||
| EVT_HitConfirmed 触发 | EventBusMonitor 显示 `EVT_HitConfirmed` 频道触发 | ☐ |
|
||||
| Console 无 Error | 0 个红色 Error | ☐ |
|
||||
|
||||
4. 让敌人攻击玩家
|
||||
|
||||
**预期**:
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 玩家 HP 减少 | HUD HP 条正确减少 | ☐ |
|
||||
| HurtState 触发 | 玩家进入受击硬直 | ☐ |
|
||||
| 受击闪白 | 玩家 Sprite 短暂白色(HurtFlash) | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-COMBAT-02:HitBox 激活时序
|
||||
|
||||
**目的**:验证 `HitBox` 只在攻击动画关键帧期间处于 Active 状态,防止持续判定。
|
||||
|
||||
### 步骤
|
||||
|
||||
1. 打开 `Window → Analysis → Physics Debugger`(Unity 内置)
|
||||
2. 勾选 `Show Colliders`,并设置 Active Colliders 的颜色(绿色)
|
||||
3. 进入 Play Mode,放慢时间(在 Console 输入 `Time.timeScale = 0.1f` 或使用调试菜单)
|
||||
4. 执行攻击动作,在 Scene 视图观察 HitBox Collider
|
||||
|
||||
**预期**:
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 攻击帧前 HitBox | `HitBox.gameObject.activeSelf == false` | ☐ |
|
||||
| 攻击关键帧 HitBox | HitBox 变为 Active(绿色 Collider 出现) | ☐ |
|
||||
| 攻击帧后 HitBox | HitBox 重新 Deactive | ☐ |
|
||||
|
||||
**Console 验证方式(替代方案)**:
|
||||
|
||||
在 `HitBox.cs` 的 `OnTriggerEnter2D` 中已有调试日志,观察 Console 中 HitBox 触发时序是否仅在攻击帧。
|
||||
|
||||
---
|
||||
|
||||
## MT-COMBAT-03:弹反系统
|
||||
|
||||
**目的**:验证 `ParrySystem` 的弹反窗口判定、成功/失败反馈。
|
||||
|
||||
### 步骤
|
||||
|
||||
**步骤 A:弹反成功**
|
||||
|
||||
1. 等待敌人发动攻击
|
||||
2. 在攻击**命中前 `parryWindow` 秒内**按弹反键(默认 Q)
|
||||
|
||||
**预期**:
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 玩家不扣血 | HP 保持不变 | ☐ |
|
||||
| 敌人硬直 | 敌人进入 `parriedStunDuration` 秒硬直 | ☐ |
|
||||
| ParryFlash VFX | 命中点产生弹反特效 | ☐ |
|
||||
| `EVT_ParrySuccess` 触发 | EventBusMonitor 显示触发 | ☐ |
|
||||
| Stats.ParryCount +1 | 调试窗口或 Inspector 中 ParryCount 增加 | ☐ |
|
||||
|
||||
**步骤 B:弹反窗口外(过早)**
|
||||
|
||||
1. 提前 1 秒按弹反键,等待敌人攻击到来
|
||||
|
||||
**预期**:弹反失败,玩家正常受击扣血,无弹反 VFX。
|
||||
|
||||
**步骤 C:弹反窗口外(过晚)**
|
||||
|
||||
1. 等待敌人攻击已命中后再按弹反键
|
||||
|
||||
**预期**:弹反无效(已受伤),无弹反 VFX。
|
||||
|
||||
**步骤 D:不可弹反攻击(Unblockable)**
|
||||
|
||||
1. 找到标记为 `Unblockable`(`InteractionTag`)的敌人攻击
|
||||
2. 在弹反窗口内按弹反键
|
||||
|
||||
**预期**:弹反无效,玩家正常受击,无弹反 VFX。
|
||||
|
||||
**步骤 E:弹反冷却**
|
||||
|
||||
1. 成功弹反后立即再次按弹反键
|
||||
|
||||
**预期**:冷却期间(`parryCooldown`)弹反无效。
|
||||
|
||||
| 弹反结果 | 预期现象 | ✓ |
|
||||
|---------|---------|---|
|
||||
| 成功(窗口内) | 不扣血 + 敌人硬直 + ParryFlash | ☐ |
|
||||
| 失败(过早) | 正常受击扣血,无特效 | ☐ |
|
||||
| 失败(过晚) | 正常受击扣血,无特效 | ☐ |
|
||||
| Unblockable 攻击 | 弹反无效 | ☐ |
|
||||
| 冷却期 | 再次按弹反无反应 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-COMBAT-04:护盾系统
|
||||
|
||||
**目的**:验证 `ShieldComponent` 护盾先吸收伤害,耗尽后 HP 才减少,护盾恢复计时。
|
||||
|
||||
### 前提条件
|
||||
|
||||
- 玩家装备含护盾护符(`ShieldComponent` 初始化,`ShieldHP > 0`)
|
||||
|
||||
### 步骤
|
||||
|
||||
1. 确认 Inspector 中 `ShieldComponent.CurrentShieldHP` 为满值
|
||||
2. 让敌人攻击玩家(伤害小于护盾值)
|
||||
|
||||
**预期**:护盾 HP 减少,玩家 HP 不变。
|
||||
|
||||
3. 继续承受伤害直到护盾耗尽
|
||||
|
||||
**预期**:护盾耗尽(`ShieldHP == 0`),护盾破碎 VFX/音效播放,后续攻击直接扣 HP。
|
||||
|
||||
4. 停止受击,等待 `shieldRecoveryDelay` 秒
|
||||
|
||||
**预期**:护盾开始恢复,逐步回满。
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 护盾先吸收伤害 | 护盾耗尽前 HP 不减少 | ☐ |
|
||||
| 护盾耗尽 | ShieldHP == 0,ShieldIsBroken == true | ☐ |
|
||||
| 耗尽后正常受伤 | 护盾破碎后攻击直接扣 HP | ☐ |
|
||||
| 护盾恢复 | 停止受击后护盾逐步回满 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-COMBAT-05:霸体(Poise)系统
|
||||
|
||||
**目的**:验证 `PoiseComponent` 防止低优先级攻击打断高优先级动作。
|
||||
|
||||
### 步骤
|
||||
|
||||
**步骤 A:普通攻击不打断霸体**
|
||||
|
||||
1. 进入攻击动作(Attack3 动画)
|
||||
2. 此时让霸体值足够(`currentPoise >= breakLevel of attack`)的敌人攻击玩家
|
||||
|
||||
**预期**:攻击动作**不被打断**,`HurtState` 未触发(霸体值保护)。
|
||||
|
||||
**步骤 B:重攻击打断霸体**
|
||||
|
||||
1. 找到攻击 `BreakLevel` 高于玩家 `Poise` 的敌人(Boss 重击或特殊攻击)
|
||||
2. 让其攻击玩家
|
||||
|
||||
**预期**:`HurtState` 触发,攻击动作被打断,霸体值减少。
|
||||
|
||||
**步骤 C:霸体恢复**
|
||||
|
||||
1. 玩家停止受击(不再被攻击),等待 `poiseRecoveryRate` 恢复
|
||||
|
||||
**预期**:Inspector 中 `PoiseComponent.CurrentPoise` 逐步恢复至 `maxPoise`。
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 普通攻击不打断 | 霸体充足时动作不中断 | ☐ |
|
||||
| 重攻击打断 | BreakLevel > Poise 时触发 HurtState | ☐ |
|
||||
| 霸体恢复 | 停止受击后逐步回满 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-COMBAT-06:状态效果集成
|
||||
|
||||
**目的**:在 Play Mode 中验证 StatusEffect 与 MonoBehaviour 的集成(Tick 逻辑、VFX 显示)。
|
||||
|
||||
> **🔧 触发状态效果(三种方式)**
|
||||
>
|
||||
> **方式 A — 带状态效果的敌人攻击**
|
||||
>
|
||||
> 在敌人的攻击 `HitBox` 上配置 `_applyStatusEffect`(`StatusEffectSO` 引用 FireEffect / PoisonEffect),让敌人打到玩家后自动施加
|
||||
>
|
||||
> **方式 C — 代码直接调用**
|
||||
>
|
||||
> ```csharp
|
||||
> // 在场景内任意 MonoBehaviour 中调用(Play Mode)
|
||||
> var sem = FindFirstObjectByType<StatusEffectManager>();
|
||||
> sem?.Apply(poisonEffectSO, 3); // 施加 3 层 Poison
|
||||
> ```
|
||||
|
||||
### 步骤(通过配置敌人或代码触发效果)
|
||||
|
||||
**Poison(中毒)测试**:
|
||||
|
||||
1. 使玩家或敌人受到 Poison 状态效果(配置带毒敌人攻击,或代码调用 `sem?.Apply(poisonEffectSO, 3)`)
|
||||
2. 观察绿色粒子特效
|
||||
|
||||
**预期**:
|
||||
- 每 `tickInterval` 秒扣减 `tickDamage` HP
|
||||
- 绿色 VFX 粒子持续显示
|
||||
- `duration` 秒后效果自动移除,VFX 消失
|
||||
|
||||
**Fire(燃烧)测试**:
|
||||
|
||||
1. 施加 Fire 效果,再施加 Freeze 效果
|
||||
|
||||
**预期**:
|
||||
- Fire 被 Freeze 互斥移除(`MutualExclusions` 生效)
|
||||
- Console 无 NullReferenceException
|
||||
|
||||
**Stagger(硬直)测试**:
|
||||
|
||||
1. 对敌人施加 Stagger 效果
|
||||
|
||||
**预期**:
|
||||
- 敌人进入硬直动画,`staggerDuration` 秒内无法移动/攻击
|
||||
|
||||
| 效果 | 检查点 | ✓ |
|
||||
|------|--------|---|
|
||||
| Poison | 定期 Tick 扣血 + 绿色粒子 + 到期自动移除 | ☐ |
|
||||
| Fire | 施加 Freeze 后 Fire 自动移除 | ☐ |
|
||||
| Stagger | 敌人硬直期间无法行动 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-COMBAT-07:投射物系统
|
||||
|
||||
**目的**:验证 `LinearProjectile`、`HomingProjectile`、`ArcProjectile` 三种投射物类型。
|
||||
|
||||
### 步骤
|
||||
|
||||
**LinearProjectile(直线子弹)**:
|
||||
|
||||
1. 让远程敌人向玩家发射直线子弹
|
||||
2. 观察子弹轨迹
|
||||
|
||||
**预期**:子弹沿直线运动,命中玩家扣血,命中地面消失(归还对象池)。
|
||||
|
||||
**HomingProjectile(追踪子弹)**:
|
||||
|
||||
1. 触发追踪子弹(若场景有此类敌人)
|
||||
2. 移动玩家改变位置
|
||||
|
||||
**预期**:子弹自动转向追踪玩家(`turnSpeed` 控制转向速率)。
|
||||
|
||||
**ParryableProjectile(可弹反子弹)**:
|
||||
|
||||
1. 在子弹飞来时的弹反窗口内按弹反键
|
||||
|
||||
**预期**:子弹反向飞回,命中发射者并造成伤害。
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| Linear 直线运动 | 子弹直线飞行到目标或边界 | ☐ |
|
||||
| Homing 追踪 | 子弹追踪玩家运动 | ☐ |
|
||||
| 命中后归还对象池 | Hierarchy 中子弹 `SetActive(false)` | ☐ |
|
||||
| Parryable 弹反 | 弹反后子弹反向 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-COMBAT-08:碰撞冲突(Clash)系统
|
||||
|
||||
**目的**:验证当玩家攻击与敌人投射物同时碰撞时,`ClashResolver` 正确处理冲突。
|
||||
|
||||
### 步骤
|
||||
|
||||
1. 在敌人发射子弹的同时,玩家攻击命中子弹(HitBox 与子弹 Collider 重叠)
|
||||
2. 观察双方结果
|
||||
|
||||
**预期**:
|
||||
- 若玩家攻击力 >= 子弹 ClashBreakValue:子弹被消除,玩家无伤
|
||||
- 若玩家攻击力 < 子弹 ClashBreakValue:双方均受到部分伤害(根据 `ClashConfigSO` 设置)
|
||||
- Console 无 NullReferenceException
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 攻击胜出 | 子弹消除,玩家无伤 | ☐ |
|
||||
| 攻击失败 | 双方受伤,无 Error | ☐ |
|
||||
| Console 无 Error | 0 个 | ☐ |
|
||||
@@ -0,0 +1,324 @@
|
||||
# 手动测试 08 · 敌人系统
|
||||
|
||||
> **测试类型**:Unity Editor 手动测试(Play Mode)
|
||||
> **覆盖模块**:`BaseGames.Enemies`、`BaseGames.Enemies.AI`、`BaseGames.Enemies.Navigation`
|
||||
> **依赖组件**:`EnemyBase`、`EnemyCombat`、`EnemyMovement`、`BehaviorDesigner`、`PathBerserker2d`
|
||||
> **场景要求**:已烘焙 NavSurface,至少包含近战/远程/飞行三种敌人各一只
|
||||
|
||||
---
|
||||
|
||||
## 快速工具
|
||||
|
||||
| 工具 | 用途 | 菜单路径 |
|
||||
|------|------|----------|
|
||||
| **Place Enemy (Basic)** | 放置带 EnemyBase、EnemyStats、HurtBox、HitBox_Body 的基础敌人;多次调用可摆放多种变体,然后手动调整组件 | `BaseGames → Scene → Place → Enemy (Basic)` |
|
||||
| **Place Nav Surface** | 在场景中放置 PathBerserker2d NavSurface 对象 | `BaseGames → Scene → Place → Nav Surface` |
|
||||
| **Place Ground Platform** | 放置地面平台(Layer=Ground) | `BaseGames → Scene → Place → Ground Platform` |
|
||||
|
||||
> **NavSurface 烘焙**:在 Inspector 中找到 `NavSurface` 组件,点击 **Bake** 按钮(无对应菜单命令)。
|
||||
> **注意**:PlayModeDebugOverlay 已移除。运行时状态效果测试请通过配置带效果的敌人攻击,或代码调用 `StatusEffectManager.Apply()`。
|
||||
|
||||
**典型工作流**:
|
||||
1. 测试前:`Place → Ground Platform` 生成地面 + `Place → Enemy (Basic)` 放置近战 / 远程 / 飞行三种敌人(多次调用,手动调整组件和配置)。
|
||||
2. **Add Enemy Variants**:`Place → Enemy (Basic)` 多次,分别调整 `EnemyStats` 和行为树为远程 / 飞行变体。
|
||||
3. Inspector NavSurface → **Bake** 烘焙寻路数据(相比手动查找 Inspector 更直接)。
|
||||
4. 状态效果测试(`MT-ENEMY-04`):配置带毒/燃烧效果的敌人攻击,或通过代码调用;观察 VFX 变化和 Console 事件。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [NavSurface 烘焙检查](#1-navsurface-烘焙检查)
|
||||
2. [MT-ENEMY-01:近战敌人 AI 基础行为](#mt-enemy-01近战敌人-ai-基础行为)
|
||||
3. [MT-ENEMY-02:远程敌人(RangedEnemy)](#mt-enemy-02远程敌人rangedenemy)
|
||||
4. [MT-ENEMY-03:飞行敌人(FlyingEnemy)](#mt-enemy-03飞行敌人flyingenemy)
|
||||
5. [MT-ENEMY-04:敌人霸体与击退](#mt-enemy-04敌人霸体与击退)
|
||||
6. [MT-ENEMY-05:敌人死亡与掉落](#mt-enemy-05敌人死亡与掉落)
|
||||
7. [MT-ENEMY-06:敌人配额管理](#mt-enemy-06敌人配额管理)
|
||||
8. [MT-ENEMY-07:Boss 战切换流程](#mt-enemy-07boss-战切换流程)
|
||||
|
||||
---
|
||||
|
||||
## 1. NavSurface 烘焙检查
|
||||
|
||||
PathBerserker2d 的寻路**完全依赖烘焙数据**,未烘焙时敌人原地站立无响应。
|
||||
|
||||
> **🔧 敌人场景快速搭建**
|
||||
>
|
||||
> 1. `BaseGames → Scene → Place → Ground Platform` 生成地面 + `Place → Player` 放置玩家(若尚未搭建)
|
||||
> 2. `BaseGames → Scene → Place → Enemy (Basic)` 多次调用,放置近战 / 远程 / 飞行三种敌人:
|
||||
> - **MeleeEnemy**:保持默认配置(EnemyBase + EnemyStats + EnemyMovement + HurtBox)
|
||||
> - **RangedEnemy**:手动添加 `ShootPoint` 子 GameObject,调整 EnemyStats 为远程变体 SO
|
||||
> - **FlyingEnemy**:手动修改 Rigidbody2D → Kinematic,`gravityScale = 0`
|
||||
>
|
||||
> 三只敌人均需在 Inspector 中绑定 Behavior Designer 行为树资产(`BehaviorTree._externalBehavior` 字段)
|
||||
> 3. Inspector NavSurface 组件 → 点击 **Bake** 烘焙近战 + 远程敌人的寻路数据
|
||||
>
|
||||
> **注意**:`FlyingEnemy` 无需 NavSurface 寻路;`RangedEnemy` 需要 NavSurface 才能进行保距移动。
|
||||
|
||||
**检查步骤**:
|
||||
|
||||
1. 在测试场景中选中挂有 `NavSurface` 组件的 GameObject
|
||||
2. Inspector 中找到 `NavSurface` 组件,点击 **Bake**
|
||||
3. Scene 视图中地面显示**蓝绿色半透明网格** → 烘焙成功
|
||||
|
||||
**提示**:若 Gizmo 不可见,点击 Scene 视图右上角 `Gizmos` → 确认 PathBerserker2d 相关项已勾选。
|
||||
|
||||
---
|
||||
|
||||
## MT-ENEMY-01:近战敌人 AI 基础行为
|
||||
|
||||
**目的**:验证近战敌人的巡逻 → 追击 → 攻击 → 返回 Behavior Designer 行为树。
|
||||
|
||||
> **🔧 前置检查**
|
||||
> - `Place → Enemy (Basic)` 已放置 `MeleeEnemy` 并挂载所有必要组件
|
||||
> - Inspector 中 `BehaviorTree._externalBehavior` 字段已绑定行为树资产(手动拖入)
|
||||
> - NavSurface 已烘焙(Inspector → Bake 按钮)
|
||||
|
||||
### 步骤
|
||||
|
||||
**步骤 A:巡逻行为**
|
||||
|
||||
1. 进入 Play Mode
|
||||
2. 玩家保持在敌人**视野范围外**(超过 `detectionRange`)
|
||||
3. 观察敌人
|
||||
|
||||
**预期**:
|
||||
- 敌人在巡逻点之间来回移动
|
||||
- 播放 `Walk`/`Patrol` 动画
|
||||
- Console 无 PathBerserker2d 寻路错误
|
||||
|
||||
**步骤 B:追击行为**
|
||||
|
||||
1. 玩家进入敌人视野(`detectionRange` 内且无遮挡物)
|
||||
2. 观察敌人反应
|
||||
|
||||
**预期**:
|
||||
- 敌人停止巡逻,转向玩家方向
|
||||
- 播放 `Run`/`Chase` 动画
|
||||
- 以最优路径追击玩家(PathBerserker2d 动态路径)
|
||||
|
||||
**步骤 C:攻击行为**
|
||||
|
||||
1. 玩家进入敌人攻击范围(`attackRange` 内)
|
||||
2. 观察敌人攻击
|
||||
|
||||
**预期**:
|
||||
- 播放攻击动画(Behavior Designer 行为树触发攻击节点)
|
||||
- `EnemyCombat.HitBox` 激活,若玩家在判定范围内触发伤害
|
||||
- 攻击后进入冷却(`attackCooldown`)
|
||||
|
||||
**步骤 D:掉失目标后返回**
|
||||
|
||||
1. 玩家跑出敌人追击范围(`chaseRange` 外)
|
||||
2. 观察敌人
|
||||
|
||||
**预期**:
|
||||
- 敌人停止追击,返回初始位置或巡逻路径
|
||||
- 返回后恢复巡逻动画
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 巡逻动画 | 视野外敌人来回巡逻 | ☐ |
|
||||
| 追击切换 | 玩家进入视野后立即追击 | ☐ |
|
||||
| 攻击命中 | 攻击范围内玩家 HP 减少 | ☐ |
|
||||
| 返回巡逻 | 丢失目标后返回初始位置 | ☐ |
|
||||
| 无寻路错误 | Console 无 PathBerserker2d 相关 Error | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-ENEMY-02:远程敌人(RangedEnemy)
|
||||
|
||||
**目的**:验证 `RangedEnemy` 的投射物发射、移动闪避、最优射击位置。
|
||||
|
||||
> **🔧 前置检查**
|
||||
> - `BaseGames → Scene → Place → Enemy (Basic)` 已放置 `RangedEnemy`(调整 EnemyStats 为远程变体,位置 x=8)
|
||||
> - Inspector 中为 `RangedEnemy` 配置行为树资产(保距 + LOS 检测 + 发射节点)
|
||||
> - `RangedEnemy._shootPoint` 子 Transform 已手动添加
|
||||
> - 场景有静止障碍物(`Place → Obstacle (Static)`)以便观察子弹碰撞
|
||||
|
||||
### 步骤
|
||||
|
||||
1. 确认测试场景中有 `RangedEnemy` 实例(挂有 `EnemyCombat` 组件)
|
||||
2. 进入 Play Mode,玩家接近远程敌人
|
||||
|
||||
**步骤 A:保持距离**
|
||||
|
||||
**预期**:远程敌人尝试保持在 `preferredDistance` 附近,玩家靠近时后退。
|
||||
|
||||
**步骤 B:发射投射物**
|
||||
|
||||
**预期**:
|
||||
- 在 `shootRange` 内发射投射物(`LinearProjectile` 或 `ArcProjectile`)
|
||||
- 投射物从 `_shootPoint` Transform 位置发出
|
||||
- 命中玩家触发伤害
|
||||
|
||||
**步骤 C:LOS(视线)检测**
|
||||
|
||||
1. 让玩家躲在墙壁后面(无视线)
|
||||
2. 观察远程敌人是否仍然发射
|
||||
|
||||
**预期**:无视线时,敌人停止发射(LOS 检测生效)。
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 保持距离 | 玩家靠近时后退 | ☐ |
|
||||
| 发射投射物 | 视线内发射子弹命中玩家 | ☐ |
|
||||
| LOS 遮挡 | 墙后无视线时停止发射 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-ENEMY-03:飞行敌人(FlyingEnemy)
|
||||
|
||||
**目的**:验证 `FlyingEnemy` 的空中移动、俯冲攻击、不受地面 NavSurface 约束。
|
||||
|
||||
> **🔧 前置检查**
|
||||
> - `BaseGames → Scene → Place → Enemy (Basic)` 已放置 `FlyingEnemy`(位置 (3,5,0))
|
||||
> - `Rigidbody2D.gravityScale = 0`,`bodyType = Kinematic`(手动在 Inspector 设置)
|
||||
> - 配置行为树资产(直线追击 + 俯冲攻击,无需 NavSurface)
|
||||
|
||||
### 步骤
|
||||
|
||||
1. 确认场景有 `FlyingEnemy` 实例(Kinematic RB,gravityScale=0)
|
||||
2. 进入 Play Mode
|
||||
|
||||
**步骤 A:空中悬停/巡逻**
|
||||
|
||||
**预期**:
|
||||
- 飞行敌人在空中自由移动,不受地形限制
|
||||
- 可以穿越平台上下(不像地面敌人需要寻路绕路)
|
||||
|
||||
**步骤 B:俯冲攻击**
|
||||
|
||||
**预期**:
|
||||
- 锁定玩家后俯冲攻击
|
||||
- 攻击后飞回起始高度,继续攻击循环
|
||||
|
||||
**步骤 C:不被地面碰撞阻挡**
|
||||
|
||||
1. 让飞行敌人在追击路径中有地形障碍
|
||||
|
||||
**预期**:飞行敌人从障碍物上方飞过(不被地面碰撞体阻挡)。
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 空中自由移动 | 不受地面寻路限制 | ☐ |
|
||||
| 俯冲攻击 | 正确识别玩家位置并俯冲 | ☐ |
|
||||
| 绕过地形 | 从障碍上方飞过 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-ENEMY-04:敌人霸体与击退
|
||||
|
||||
**目的**:验证 `EnemyPoiseComponent` 在普通攻击下保持动画,重攻击才打断。
|
||||
|
||||
### 步骤
|
||||
|
||||
**步骤 A:普通连击不打断**
|
||||
|
||||
1. 对有较高霸体的敌人进行连击
|
||||
|
||||
**预期**:若连击伤害低于霸体 `breakLevel`,敌人动画**不被打断**,继续执行 AI 行为。
|
||||
|
||||
**步骤 B:重攻击打断**
|
||||
|
||||
1. 对同一敌人使用 `BreakLevel` 高的攻击(如下劈 `DownAttack` 或特殊技能)
|
||||
|
||||
**预期**:敌人进入受击硬直,AI 行为树暂停。
|
||||
|
||||
**步骤 C:击退效果**
|
||||
|
||||
1. 攻击有击退(`knockbackForce > 0`)的攻击
|
||||
|
||||
**预期**:敌人被击退一定距离(`knockbackForce` 方向与大小),不会穿墙。
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 普通攻击不打断 | 霸体保护时 AI 行为继续 | ☐ |
|
||||
| 重攻击打断 | BreakLevel > Poise 时触发硬直 | ☐ |
|
||||
| 击退物理 | 被击退后不穿越地形 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-ENEMY-05:敌人死亡与掉落
|
||||
|
||||
**目的**:验证敌人 HP 归零后的死亡流程、Geo 掉落、`LootResolver`。
|
||||
|
||||
### 步骤
|
||||
|
||||
1. 将敌人 HP 降至 0
|
||||
|
||||
**预期**:
|
||||
- 死亡动画播放
|
||||
- 死亡动画结束后 GameObject 从场景移除(或 `SetActive(false)` 归还对象池)
|
||||
- `LootTableSO` 根据权重随机掉落 Geo 或其他物品
|
||||
|
||||
2. 在掉落的 Geo 上移动玩家
|
||||
|
||||
**预期**:Geo 被拾取,玩家 `CurrentGeo` 增加,HUD Geo 数量更新。
|
||||
|
||||
3. 重新进入场景(房间切换后返回)
|
||||
|
||||
**预期**:根据 `WorldStateRegistry` 配置,死亡敌人是否重生(可配置的一次性/可重生区别)。
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 死亡动画 | HP 归零后播放死亡动画 | ☐ |
|
||||
| 清理 | 动画结束后 GameObject 消失或归池 | ☐ |
|
||||
| Geo 掉落 | 掉落 Geo 可拾取,HUD 更新 | ☐ |
|
||||
| LootTable | 掉落物符合权重概率 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-ENEMY-06:敌人配额管理
|
||||
|
||||
**目的**:验证 `EnemyQuotaManager` 限制同屏激活敌人数量,防止性能劣化。
|
||||
|
||||
### 步骤
|
||||
|
||||
1. 打开 Inspector,找到场景中的 `EnemyQuotaManager`
|
||||
2. 记录当前 `maxActiveEnemies` 配置值(如 8)
|
||||
3. 进入 Play Mode,触发大量敌人(通过多个 EnemySpawner)
|
||||
|
||||
**预期**:
|
||||
- 同屏激活的敌人不超过 `maxActiveEnemies`
|
||||
- 超出配额的敌人保持待机(Deactivate 或等待)
|
||||
- 当已激活敌人死亡后,待机敌人激活补充
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 不超过配额 | 激活敌人数量 ≤ maxActiveEnemies | ☐ |
|
||||
| 死亡后补充 | 敌人死亡后待机敌人激活 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-ENEMY-07:Boss 战切换流程
|
||||
|
||||
**目的**:验证 Boss 战触发的 GameState 切换(Gameplay → BossFight)、Boss 血条 UI 显示。
|
||||
|
||||
### 步骤
|
||||
|
||||
1. 进入有 Boss 触发器的场景或房间
|
||||
2. 玩家进入 Boss 房间触发器
|
||||
|
||||
**预期**:
|
||||
- `EVT_BossFightStarted` 事件触发(EventBusMonitor 可见)
|
||||
- `GameManager` 状态切换到 `BossFight`
|
||||
- Boss HP 血条 UI 出现(大型 HP 条 + Boss 名称文字)
|
||||
- 背景音乐切换为 Boss 战曲目
|
||||
|
||||
3. 将 Boss HP 降至 0
|
||||
|
||||
**预期**:
|
||||
- `EVT_BossFightEnded` 事件触发(`victory = true`)
|
||||
- GameManager 切回 `Gameplay` 状态
|
||||
- Boss 血条 UI 隐藏
|
||||
- 胜利 VFX/音效播放
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| Boss 战触发 | 进入触发器后 GameState = BossFight | ☐ |
|
||||
| Boss HP 条 | Boss 血条 UI 正确显示 | ☐ |
|
||||
| BGM 切换 | 战斗音乐正确播放 | ☐ |
|
||||
| Boss 死亡 | 胜利后状态恢复 Gameplay | ☐ |
|
||||
| BossProgressTracker | Console 中 Boss 击败状态记录 | ☐ |
|
||||
@@ -0,0 +1,350 @@
|
||||
# 手动测试 09 · 世界与场景系统
|
||||
|
||||
> **测试类型**:Unity Editor 手动测试(Play Mode)
|
||||
> **覆盖模块**:`BaseGames.World`、`BaseGames.World.Map`
|
||||
> **依赖组件**:`RoomController`、`RoomTransition`、`SavePoint`、`DeathShade`、`WorldStateRegistry`
|
||||
> **场景要求**:含多房间连接(至少 2 个 Room Scene),AbilityGate,MovingPlatform,SavePoint
|
||||
|
||||
---
|
||||
|
||||
## 快速工具
|
||||
|
||||
| 工具 | 用途 | 菜单路径 |
|
||||
|------|------|----------|
|
||||
| **Place Save Point** | 放置带 SavePoint 组件和 BoxCollider2D(TriggerZone) 的存档点 | `BaseGames → Scene → Place → Save Point` |
|
||||
| **Place Camera Trigger Zone** | 放置带 CameraTriggerZone 和 BoxCollider2D(TriggerZone) 的摄像机触发区 | `BaseGames → Scene → Place → Camera Trigger Zone` |
|
||||
| **Place Room Camera** | 放置带 Cinemachine + RoomCamera + CinemachineConfiner2D 的房间摄像机 | `BaseGames → Scene → Place → Room Camera` |
|
||||
| **Place Ground Platform** | 放置地面平台(Layer=Ground) | `BaseGames → Scene → Place → Ground Platform` |
|
||||
| **Place Tilemap Ground** | 放置 Grid + Tilemap + CompositeCollider2D(Layer=Ground) | `BaseGames → Scene → Place → Tilemap Ground` |
|
||||
| **Scaffold Room Scene** | 一键生成完整房间场景结构 | `BaseGames → Tools → Scaffold Room Scene` |
|
||||
|
||||
> **注意**:PlayModeDebugOverlay 已移除。Run Mode 存档调试请直接通过交互键触发存档点,或在 Inspector 中手动调用 `ISaveService.QuickSave()`。
|
||||
> 房间过渡对象、移动平台、可破坏平台、能力门等复杂对象请参照下方各节的**手动步骤**手工创建。
|
||||
|
||||
**典型工作流**:
|
||||
1. `MT-WORLD-01` 房间过渡:手动创建 `RoomTransition` GameObject,添加 BoxCollider2D Trigger,配置 `_targetSceneAddress`,两端各一个出口(参考下方手动步骤)。
|
||||
2. `MT-WORLD-02` 存档:`Place → Save Point` 放置存档点,Play Mode 交互键激活;确认文件写入通过文件浏览器查看 `Application.persistentDataPath`。
|
||||
3. `MT-WORLD-05` 移动平台 / `MT-WORLD-06` 能力门:手动创建对象,参照各节步骤配置组件(无专用菜单命令)。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [场景结构检查](#1-场景结构检查)
|
||||
2. [MT-WORLD-01:房间过渡(RoomTransition)](#mt-world-01房间过渡roomtransition)
|
||||
3. [MT-WORLD-02:存档点交互](#mt-world-02存档点交互)
|
||||
4. [MT-WORLD-03:死亡阴影(DeathShade)](#mt-world-03死亡阴影deathshade)
|
||||
5. [MT-WORLD-04:世界状态持久化(WorldStateRegistry)](#mt-world-04世界状态持久化worldstateregistry)
|
||||
6. [MT-WORLD-05:可破坏平台与移动平台](#mt-world-05可破坏平台与移动平台)
|
||||
7. [MT-WORLD-06:能力门(AbilityGate)](#mt-world-06能力门abilitygate)
|
||||
8. [MT-WORLD-07:可收集物(Collectibles)](#mt-world-07可收集物collectibles)
|
||||
9. [MT-WORLD-08:世界地图显示](#mt-world-08世界地图显示)
|
||||
|
||||
---
|
||||
|
||||
## 1. 场景结构检查
|
||||
|
||||
| 元素 | 说明 | ✓ |
|
||||
|------|------|---|
|
||||
| RoomController | 每个 Room Scene 挂有 `RoomController`,`roomId` 唯一 | ☐ |
|
||||
| RoomTransition | 房间出入口各有 `RoomTransition`,`targetSceneName` 和 `targetTransitionId` 已配置 | ☐ |
|
||||
| SavePoint | 测试场景含至少 1 个 `SavePoint`,挂有 `SavePointSO` 资产 | ☐ |
|
||||
| WorldStateRegistry | 场景挂有 `WorldStateRegistrar` 或 GlobalObject 上有 `WorldStateRegistry` | ☐ |
|
||||
|
||||
> **🔧 一键搭建世界测试场景**
|
||||
>
|
||||
> | 需求 | 工具 | 操作 |
|
||||
> |------|------|------|
|
||||
> | 基础场景(地面 + 玩家) | `BaseGames → Scene → Place → Player` + `Place → Ground Platform` | 分别放置玩家和地面 |
|
||||
> | 需要存档点(MT-WORLD-02) | `BaseGames → Scene → Place → Save Point` | 放置 SavePoint + 手动绑定事件频道 |
|
||||
> | 需要 2 个 Room 场景(MT-WORLD-01) | 手动创建场景(见下方 MT-WORLD-01 步骤) | 注册到 Build Settings + Addressables |
|
||||
> | 需要死亡阴影(MT-WORLD-03) | 手动创建 `DeathShade` GameObject | 挂 `DeathShade` 组件,设置 `_geoAmount = 50` |
|
||||
> | 需要过渡触发器 | 手动创建 `RoomTransition` GameObject | 挂 BoxCollider2D Trigger + `RoomTransition` 组件 |
|
||||
>
|
||||
> **完整世界测试场景搭建顺序**:
|
||||
> 1. `Place → Player` + `Place → Ground Platform`(地面 + 玩家)
|
||||
> 2. 手动创建 Room_A / Room_B 并注册(仅 MT-WORLD-01 需要)
|
||||
> 3. `Place → Save Point` + 手动创建 DeathShade
|
||||
> 4. 手动创建 RoomTransition 对(若在当前单一场景内测试)
|
||||
> 5. Addressable Batch Tool → 注册 Room_A / Room_B 场景
|
||||
|
||||
---
|
||||
|
||||
## MT-WORLD-01:房间过渡(RoomTransition)
|
||||
|
||||
**目的**:验证 `SceneLoader` + `RoomTransition` 的场景加载/卸载流程(Addressables 异步加载)。
|
||||
|
||||
> **🔧 资源准备**
|
||||
>
|
||||
> 1. 手动创建两个测试场景:`File → New Scene → Empty`,保存为 `Assets/_Game/Scenes/Room_A.unity` 和 `Room_B.unity`,添加地面、`RoomController`、出生点、`RoomTransition` 触发器(左右各一),然后 `File → Build Settings → Add Open Scenes` 注册。
|
||||
> 2. 用 **Addressable Batch Tool** 将 `Room_A.unity` / `Room_B.unity` 注册,Key 建议 = `Room_A` / `Room_B`
|
||||
> 3. 在两个场景中分别打开对应的 `RoomTransition` GameObject,填写 `_targetSceneAddress`:
|
||||
> - `Room_A` 场景的右侧 Transition:`_targetSceneAddress = "Room_B"`
|
||||
> - `Room_B` 场景的左侧 Transition:`_targetSceneAddress = "Room_A"`
|
||||
> 4. 也可在**单一测试场景**中测试(手动创建 RoomTransition 对),但此方案无法测试实际场景卸载/加载
|
||||
|
||||
### 步骤
|
||||
|
||||
1. 进入 Play Mode
|
||||
2. 玩家走入场景边缘 `RoomTransition` 触发区域
|
||||
|
||||
**预期(过渡动画开始)**:
|
||||
- `ScreenFader` 黑幕淡入(淡出当前场景)
|
||||
- Console 出现 `[SceneLoader] Loading: RoomB` 类似日志
|
||||
- `EVT_RoomTransitionStart` 触发(EventBusMonitor)
|
||||
|
||||
**预期(新场景加载完成)**:
|
||||
- 新场景画面淡入
|
||||
- 玩家出现在目标 `RoomTransition` 的 `spawnPoint` 位置
|
||||
- `EVT_RoomTransitionEnd` 触发
|
||||
|
||||
3. 立即再次走回上一个 `RoomTransition`
|
||||
|
||||
**预期**:可无缝往返,无加载错误,HP 和状态保持不变。
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 黑幕过渡 | 进出场景时有黑幕淡入淡出 | ☐ |
|
||||
| 玩家出现位置 | 出现在目标过渡点的 spawnPoint | ☐ |
|
||||
| 无加载错误 | Console 无 `InvalidKeyException` 或 `NullReferenceException` | ☐ |
|
||||
| 往返正常 | 多次房间切换无内存泄漏或重复 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-WORLD-02:存档点交互
|
||||
|
||||
**目的**:端到端验证 `SavePoint` 激活→存档→读取全流程。
|
||||
|
||||
> **🔧 资源准备(一键)**
|
||||
>
|
||||
> 使用 `BaseGames → Scene → Place → Save Point` 在场景中快速放置存档点,或手动创建(见下方手动步骤)。
|
||||
>
|
||||
> **手动步骤**:
|
||||
> 1. 创建空 GameObject,挂 `SavePoint`、`CapsuleCollider2D`(isTrigger=true)
|
||||
> 2. 设置 `_savePointId`(唯一字符串,如 `"testroom_SP_01"`)
|
||||
> 3. 将 `EVT_SavePointActivated`(StringEventChannelSO)拖入 `_onSavePointActivated`
|
||||
>
|
||||
> **触发存档**:进入 Play Mode → 走到存档点 → 按 **E 键**(或配置的 Interact 键)激活
|
||||
> **快速确认**:存档后打开文件浏览器查看 `Application.persistentDataPath` 目录,确认存档文件已生成
|
||||
|
||||
### 步骤
|
||||
|
||||
1. 走到 `SavePoint`(场景中有 `SavePoint.cs` 的 GameObject)
|
||||
2. 按**交互键**(默认 E 或 F)
|
||||
|
||||
**预期**:
|
||||
- `SavePoint` 播放激活动画/粒子
|
||||
- `EVT_SavePointActivated` 触发
|
||||
- `SaveManager.Save()` 被调用
|
||||
- Console 出现 `[SaveManager] 存档成功` 日志
|
||||
- 灵泉次数恢复满值(`SpringCount = MaxSpringCount`)
|
||||
|
||||
3. 打开存档文件位置验证(`%AppData%\..\LocalLow\[CompanyName]\[AppName]\save.dat`)
|
||||
|
||||
**预期**:存档文件修改时间与测试时间一致。
|
||||
|
||||
4. 退出 Play Mode,再进入 Play Mode(模拟游戏重启)
|
||||
5. 验证是否从存档点位置开始
|
||||
|
||||
**预期**:玩家出现在存档点坐标,HP 保持存档时的值。
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 激活动画 | SavePoint 激活 VFX/动画播放 | ☐ |
|
||||
| 存档文件更新 | 存档文件时间戳更新 | ☐ |
|
||||
| 灵泉恢复 | SpringCount = MaxSpringCount | ☐ |
|
||||
| 读档位置 | 重启后从存档点出生 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-WORLD-03:死亡阴影(DeathShade)
|
||||
|
||||
**目的**:验证 `DeathShade` 在死亡位置生成、Geo 附着、回收互动。
|
||||
|
||||
> **🔧 资源准备**
|
||||
>
|
||||
> **全流程测试(推荐)**:
|
||||
> 1. 确保场景已有存档点(`BaseGames → Scene → Place → Save Point`)
|
||||
> 2. 正常游戏中让玩家死亡 → DeathShade 自动由 `DeathRespawnService` 在死亡位置创建
|
||||
>
|
||||
> **快速单元测试(无需真实死亡)**:
|
||||
> - 手动创建 `DeathShade` GameObject,挂 `DeathShade` 组件,设置 `_geoAmount = 50`,放置在场景 x=5 位置
|
||||
> - 进入 Play Mode → 玩家走到 DeathShade 位置 → 观察 Geo 回收交互
|
||||
> - ⚠ 此为手动占位;完整流程(覆盖测试项 DeathShade 生成位置/第二次死亡覆盖)仍需通过真实死亡触发
|
||||
|
||||
### 步骤
|
||||
|
||||
1. 确认玩家携带 **Geo > 0**,存档(与存档点交互)
|
||||
2. 让玩家**在存档点之外的区域死亡**(HP 归零)
|
||||
3. 在死亡屏幕选择重试(复活在存档点)
|
||||
|
||||
**预期(复活后)**:
|
||||
- 玩家出现在存档点
|
||||
- 死亡位置(Room B 某坐标)出现 `DeathShade` 对象
|
||||
- `DeathShade` 持有死亡时的 Geo 数量
|
||||
|
||||
4. 走到 `DeathShade` 位置,与其交互(进入触发区域)
|
||||
|
||||
**预期**:
|
||||
- Geo 被回收,玩家 `CurrentGeo` 增加
|
||||
- `DeathShade` 消失(`Destroy` 或 `SetActive(false)`)
|
||||
|
||||
5. 不回收 DeathShade,再次死亡
|
||||
|
||||
**预期**:旧 `DeathShade` 被新的取代(只保留最新一次),旧 Geo 丢失。
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| DeathShade 生成位置 | 出现在死亡坐标 | ☐ |
|
||||
| Geo 附着 | 显示正确的 Geo 数量 | ☐ |
|
||||
| 回收 | 交互后 Geo 增加,DeathShade 消失 | ☐ |
|
||||
| 第二次死亡覆盖 | 只保留最新 DeathShade | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-WORLD-04:世界状态持久化(WorldStateRegistry)
|
||||
|
||||
**目的**:验证 `WorldStateRegistry` 中场景状态(门开关、敌人死亡、机关激活)在房间切换后正确持久化。
|
||||
|
||||
### 步骤
|
||||
|
||||
1. 找到场景中一个可**一次性触发**的机关(如需要击打的开关、某只 One-Shot 敌人)
|
||||
2. 激活该机关(或击杀该敌人)
|
||||
3. 记录其 `stateId`(Inspector 可查)
|
||||
4. 离开本房间,进入另一个房间,再返回本房间
|
||||
|
||||
**预期**:
|
||||
- 机关仍处于已激活状态(门仍开启)
|
||||
- One-Shot 敌人不重新生成
|
||||
- `WorldStateRegistry.GetState(stateId)` 返回 `true`
|
||||
|
||||
5. 退出 Play Mode,再进入 Play Mode(读取存档)
|
||||
|
||||
**预期**:状态仍持久化(`SaveData.WorldStates` 中有对应 `stateId`)。
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 房间返回后状态 | 机关/敌人状态不重置 | ☐ |
|
||||
| 读档后状态 | 重新进入 Play Mode 后状态仍持久 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-WORLD-05:可破坏平台与移动平台
|
||||
|
||||
**目的**:验证 `CrumblePlatform`(踩后掉落)和 `MovingPlatform`(循环移动)物理行为。
|
||||
|
||||
### CrumblePlatform(碎裂平台)
|
||||
|
||||
1. 站在 `CrumblePlatform` 上
|
||||
|
||||
**预期**:
|
||||
- 约 `crumbleDelay` 秒后平台开始抖动
|
||||
- 抖动后平台消失(Deactivate/Destroy)
|
||||
- 玩家正常下落
|
||||
|
||||
2. 等待 `resetTime` 秒
|
||||
|
||||
**预期**:平台重新出现(`SetActive(true)` 或重置到初始位置)。
|
||||
|
||||
### MovingPlatform(移动平台)
|
||||
|
||||
1. 站在 `MovingPlatform` 上
|
||||
|
||||
**预期**:
|
||||
- 平台在两个 waypoint 之间来回循环移动
|
||||
- 玩家随平台移动(玩家相对平台位置不变,通过 `transform.SetParent` 或速度叠加实现)
|
||||
|
||||
2. 从移动平台跳跃
|
||||
|
||||
**预期**:跳跃方向和高度正确(平台速度叠加到跳跃速度)。
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| CrumblePlatform 掉落 | 站上后 crumbleDelay 后消失 | ☐ |
|
||||
| CrumblePlatform 重置 | resetTime 后平台重新出现 | ☐ |
|
||||
| MovingPlatform 携带玩家 | 站上平台随之移动 | ☐ |
|
||||
| 平台跳跃 | 速度叠加正确 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-WORLD-06:能力门(AbilityGate)
|
||||
|
||||
**目的**:验证 `AbilityGate` 根据玩家已解锁能力决定是否通行。
|
||||
|
||||
### 步骤
|
||||
|
||||
**步骤 A:无对应能力时**
|
||||
|
||||
1. 确保玩家**未解锁** `AbilityGate` 要求的能力(如 DoubleJump)
|
||||
2. 走到 `AbilityGate`
|
||||
|
||||
**预期**:门保持关闭(碰撞体阻挡玩家),显示所需能力提示。
|
||||
|
||||
**步骤 B:解锁能力后**
|
||||
|
||||
1. 通过调试工具或正常流程解锁所需能力
|
||||
2. 再次走到 `AbilityGate`
|
||||
|
||||
**预期**:门开启(碰撞体禁用或动画播放),玩家可通过。
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 无能力 | 门阻挡通行 | ☐ |
|
||||
| 有能力 | 门开启可通行 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-WORLD-07:可收集物(Collectibles)
|
||||
|
||||
**目的**:验证地图中固定位置收集物(Geo 堆、道具)的拾取与持久化。
|
||||
|
||||
### 步骤
|
||||
|
||||
1. 找到场景中一个 `Collectible` 物品(如固定 Geo 堆)
|
||||
2. 玩家走过拾取
|
||||
|
||||
**预期**:
|
||||
- 拾取动画/音效播放
|
||||
- 玩家 CurrentGeo 增加
|
||||
- `Collectible.stateId` 写入 `WorldStateRegistry`
|
||||
|
||||
3. 离开房间再返回
|
||||
|
||||
**预期**:该收集物不再出现(One-Shot 语义)。
|
||||
|
||||
4. 读取存档重进
|
||||
|
||||
**预期**:该收集物仍不出现(状态持久化)。
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 拾取效果 | 动画/音效 + Geo 增加 | ☐ |
|
||||
| 房间返回 | 不再出现 | ☐ |
|
||||
| 读档后 | 仍不出现(持久化) | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-WORLD-08:世界地图显示
|
||||
|
||||
**目的**:验证 `WorldMap` UI 正确显示已探索房间和玩家当前位置。
|
||||
|
||||
### 步骤
|
||||
|
||||
1. 在多个房间之间来回切换(探索新房间)
|
||||
2. 打开世界地图 UI(默认 Tab 键)
|
||||
|
||||
**预期**:
|
||||
- 已探索的房间在地图上显示(灰色/彩色)
|
||||
- 未探索的房间不显示(或显示为迷雾)
|
||||
- 玩家当前位置有图标标识
|
||||
|
||||
3. 找到并激活 `MapPin`(地图标记物,如 Boss 房间标记)
|
||||
|
||||
**预期**:地图上对应位置出现 `MapPin` 图标(存档后重进仍存在)。
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 已探索房间显示 | 地图正确显示探索过的房间 | ☐ |
|
||||
| 未探索房间 | 未探索区域显示迷雾或不显示 | ☐ |
|
||||
| 玩家位置标记 | 玩家图标在正确房间 | ☐ |
|
||||
| MapPin 持久化 | 存档后 MapPin 仍存在 | ☐ |
|
||||
@@ -0,0 +1,342 @@
|
||||
# 手动测试 10 · 进程与养成系统
|
||||
|
||||
> **测试类型**:Unity Editor 手动测试(Play Mode)
|
||||
> **覆盖模块**:`BaseGames.Skills`、`BaseGames.Equipment`、`BaseGames.Quest`、`BaseGames.Progression`、`BaseGames.World.Shop`
|
||||
> **依赖组件**:`SkillManager`、`EquipmentManager`、`QuestManager`、`AchievementManager`、`ShopKeeper`
|
||||
> **场景要求**:含存档点、商店 NPC、任务触发器的完整测试场景
|
||||
|
||||
---
|
||||
|
||||
## 快速工具
|
||||
|
||||
| 工具 | 用途 | 菜单路径 |
|
||||
|------|------|----------|
|
||||
| **Validate All ScriptableObjects** | 遍历所有实现 `IValidatable` 的 SO,输出验证结果(含 FormSkillSO、QuestDatabaseSO 等) | `BaseGames → Tools → Validate All ScriptableObjects` |
|
||||
|
||||
> **注意**:PlayModeDebugOverlay 已移除。Geo 注入、能力解锁测试等请通过 Inspector 直接修改对应 SO/Manager 字段,或临时编写 Editor 脚本触发。
|
||||
> Tab 10 的场景摆放工具(Add Quest Trigger、Add Shop NPC)已不再提供;请参照下方各节**手动步骤**手工创建对应对象。
|
||||
>
|
||||
> **重要:本项目没有"技能点 + 技能树解锁"系统。** 技能(`FormSkillSO`)随形态绑定,由 `FormController` 在切换形态时注入 `SkillManager`,无需花费技能点解锁;`SkillTreePanel` 仅为只读的形态技能一览面板。能力(二段跳/冲刺/形态等)通过 `AbilityType` 位掩码解锁(见 MT-PROG-06),不涉及任何技能树。
|
||||
|
||||
**典型工作流**:
|
||||
1. 测试前:`BaseGames → Tools → Validate All ScriptableObjects` 一键确认 SO 存在;若有缺失,Console 给出路径提示。
|
||||
2. `MT-PROG-01` 技能:进入 Play Mode,通过 `FormController` 切换形态,确认 `SkillManager` 注入对应形态的三个技能,施放消耗魂力/灵力并进入冷却。
|
||||
3. `MT-PROG-03` 任务:手动放置 `QuestGiver` NPC(见下方步骤),将 `QuestSO` 拖入 Inspector,Play Mode 中交互验证。
|
||||
4. `MT-PROG-05` 商店:手动放置 `ShopNPC`(见下方步骤),通过 Inspector 修改 `_geo` 字段给玩家加钱,交互购买。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [前置数据检查](#1-前置数据检查)
|
||||
2. [MT-PROG-01:形态技能与施放](#mt-prog-01形态技能与施放)
|
||||
3. [MT-PROG-02:装备系统(护符/武器)](#mt-prog-02装备系统护符武器)
|
||||
4. [MT-PROG-03:任务系统(QuestManager)](#mt-prog-03任务系统questmanager)
|
||||
5. [MT-PROG-04:成就系统(AchievementManager)](#mt-prog-04成就系统achievementmanager)
|
||||
6. [MT-PROG-05:商店系统(Shop)](#mt-prog-05商店系统shop)
|
||||
7. [MT-PROG-06:能力解锁(AbilityUnlock)](#mt-prog-06能力解锁abilityunlock)
|
||||
|
||||
---
|
||||
|
||||
## 1. 前置数据检查
|
||||
|
||||
| 资产 | 路径(示例) | 必要性 | ✓ |
|
||||
|------|------------|--------|---|
|
||||
| FormSkillSO ×N | `Assets/_Game/Data/Skills/SKL_*.asset` | 技能测试必须(每形态 3 个:魂技+灵技1+灵技2) | ☐ |
|
||||
| EquipmentSlotConfigSO | `Assets/Data/Equipment/SlotConfig.asset` | 装备测试必须 | ☐ |
|
||||
| QuestDatabaseSO | `Assets/Data/Quests/` | 任务测试必须 | ☐ |
|
||||
| AchievementDatabaseSO | `Assets/Data/Achievements/` | 成就测试必须 | ☐ |
|
||||
| ShopInventorySO | `Assets/Data/Shop/` | 商店测试必须 | ☐ |
|
||||
|
||||
> **🔧 一键检查 + 资产创建**
|
||||
>
|
||||
> **步骤 1 — 验证资产存在性**:
|
||||
> 菜单 `BaseGames → Tools → Validate All ScriptableObjects`
|
||||
> - Console 输出每项 ✅(通过)或 ❌(失败/未找到)
|
||||
>
|
||||
> **步骤 2 — 创建所有占位 SO**(若有缺失):
|
||||
> 按照下方**步骤 3** 手动通过 Project 右键菜单创建对应资产。
|
||||
>
|
||||
> **步骤 3 — 创建尚未覆盖的数据资产(手动)**:
|
||||
>
|
||||
> | 资产 | 创建方法 |
|
||||
> |------|----------|
|
||||
> | `FormSkillSO` | Project 右键 → Create → BaseGames → Skills → Form Skill,保存到 `Assets/_Game/Data/Skills/`,命名 `SKL_{skillId}`;如有近战/爆炸判定再配套 `SKL_{skillId}_HitBox` 预制体 |
|
||||
> | `EquipmentSlotConfigSO` | Project 右键 → Create → BaseGames → Equipment → Slot Config,保存到 `Assets/_Game/Data/Equipment/` |
|
||||
> | `QuestDatabaseSO` | Project 右键 → Create → BaseGames → Quest → Quest Database,保存到 `Assets/_Game/Data/Quests/` |
|
||||
> | `AchievementDatabaseSO` | Project 右键 → Create → BaseGames → Progression → Achievement Database,保存到 `Assets/_Game/Data/Achievements/` |
|
||||
> | `ShopInventorySO` | 已由 Create Test Assets 创建为 `ShopInventory_Test.asset`;点 Inspector 的 `+` 按钮添加 `ShopItem` 条目 |
|
||||
>
|
||||
> **步骤 4 — 绑定 Manager 字段**(Play Mode 前):
|
||||
> - 找到 Player 上的 `SkillManager` 组件 → Inspector → `_formSkillSets` 数组,按形态(天魂/地魂/命魂)逐项填入 `soulSkill` + `spiritSkill1` + `spiritSkill2`(对应 `FormSkillSO` 资产)
|
||||
> - 找到 `ShopNPC` GameObject(Tab 10 → 添加商店 NPC)→ `_inventory` 字段拖入 `ShopInventory_Test.asset`
|
||||
|
||||
---
|
||||
|
||||
## MT-PROG-01:形态技能与施放
|
||||
|
||||
**目的**:验证 `SkillManager` 的形态技能注入、技能施放、冷却管理、魂力/灵力消耗,以及 `FormSkillPanel`(只读技能一览)的展示。
|
||||
|
||||
> **架构说明(务必先读)**
|
||||
> - 技能 = `FormSkillSO` 资产,**不通过技能点解锁**。每个形态绑定 3 个技能槽:魂技(`soulSkill`)+ 灵技1(`spiritSkill1`)+ 灵技2(`spiritSkill2`)。
|
||||
> - `FormController` 在切换形态时回调 `SkillManager.UpdateSkillSet(...)`,把当前形态的三个技能注入到输入槽。玩家无须"学习"技能,切到该形态即可用。
|
||||
> - 施放消耗的是**魂力(SoulPower)或灵力(SpiritPower)**(由 `FormSkillSO.resourceType` 决定),不是 MP。
|
||||
> - `FormSkillPanel`(形态技能一览面板)只是只读一览:翻页查看各形态的技能图标/名称/描述/消耗/冷却,**没有解锁交互、没有技能点、没有节点**。
|
||||
> - 技能数值可被护符改写,见 MT-PROG-02 与 `SkillModifierRegistry`。
|
||||
|
||||
> **🔧 资源准备**
|
||||
> 1. 确认 Player 上 `SkillManager._formSkillSets` 已为待测形态填好三个 `FormSkillSO`。
|
||||
> 2. 若待测形态尚未解锁(地魂/命魂),先解锁其形态能力:编辑 `PlayerConfigSO.InitialAbilities` 勾选 `FormDiHun` / `FormMingHun`,或临时写 Editor 脚本调用 `PlayerStats.UnlockAbility(AbilityType.FormDiHun)`。天魂(`FormTianHun`)默认初始解锁。
|
||||
> 3. 确认输入动作已绑定:魂技 = `SoulSkillEvent`,灵技1 = `SpiritSkill1StartedEvent`,灵技2 = `SpiritSkill2StartedEvent`(见 `InputReaderSO`)。
|
||||
|
||||
### 步骤
|
||||
|
||||
**步骤 A:形态切换注入技能**
|
||||
|
||||
1. 进入 Play Mode,通过 `FormController` 切换到目标形态(切换输入键,或 Inspector 下调用 `FormController.SwitchForm(FormType.XXX)`)。
|
||||
2. 观察 `SkillManager` 的当前技能(`SoulSkill` / `Spirit1` / `Spirit2` 属性)。
|
||||
|
||||
**预期**:
|
||||
- 切换形态后,`SkillManager` 的三个技能引用更新为该形态 `_formSkillSets` 中配置的技能。
|
||||
- 冷却字典 `_cooldowns` 重建并清零(切换后立即可施放)。
|
||||
- 若切换的是未解锁形态,`FormController.SwitchForm` 被 `HasAbility(FormXXX)` 拦截,技能集不变。
|
||||
|
||||
**步骤 B:技能施放**
|
||||
|
||||
1. 进入战斗场景,按魂技/灵技1/灵技2 输入键。
|
||||
|
||||
**预期**:
|
||||
- 技能动画播放(`AnimancerComponent.Play(castAnimation)`)。
|
||||
- 资源扣减:`SoulPower`/`SpiritPower -= effectiveCost`(`PlayerStats.ConsumeSoulPower/ConsumeSpiritPower`)。
|
||||
- 命中判定生成(若技能配了 `SkillHitBoxPrefab`,从对象池取实例并 `Activate`)。
|
||||
- 进入冷却(`effectiveCooldown` 秒内再次按键无效)。
|
||||
- 触发 `skill_cast` 反馈预设。
|
||||
|
||||
**步骤 C:资源不足时**
|
||||
|
||||
1. 使当前魂力/灵力 < `effectiveCost`(可在 Inspector 消耗资源或调小上限)。
|
||||
2. 按技能键。
|
||||
|
||||
**预期**:`ConsumeXxxPower` 返回 false,技能不释放(无动画、无判定、不进冷却)。
|
||||
|
||||
**步骤 D:技能冷却**
|
||||
|
||||
1. 施放技能后立即再次按同一技能键。
|
||||
|
||||
**预期**:冷却期内 `_cooldowns[skill] > 0`,不触发;HUD 冷却指示(`SoulCooldownRatio`)显示剩余比例。
|
||||
|
||||
**步骤 E:FormSkillPanel 只读一览**
|
||||
|
||||
1. 打开技能一览面板(`FormSkillPanel`)。
|
||||
2. 左右翻页浏览各形态,确认当前实际形态被高亮(`_activeFormIndicator`)。
|
||||
|
||||
**预期**:面板展示各形态三技能的图标/名称/描述/消耗/冷却;**无任何"解锁/锁定"状态或点击解锁交互**(纯查看)。
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 形态注入 | 切换形态后 SkillManager 三技能引用随之更新 | ☐ |
|
||||
| 未解锁形态拦截 | 切到未解锁形态被 HasAbility 拦截 | ☐ |
|
||||
| 技能施放 | 动画播放,判定生成,魂力/灵力扣减 | ☐ |
|
||||
| 资源不足 | 无法释放,不进冷却 | ☐ |
|
||||
| 冷却 | 冷却期无法再次使用,比例显示 | ☐ |
|
||||
| 一览面板 | 只读展示各形态技能,无解锁交互 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-PROG-02:装备系统(护符/武器)
|
||||
|
||||
**目的**:验证 `EquipmentManager` 护符槽管理、装备属性叠加、超出槽数无法装备。
|
||||
|
||||
### 步骤
|
||||
|
||||
**步骤 A:装备护符**
|
||||
|
||||
1. 打开装备 UI(默认 Tab 键或 E 键进入背包)
|
||||
2. 拖拽/确认护符到空槽位
|
||||
|
||||
**预期**:
|
||||
- 护符装备成功,护符图标显示在槽位
|
||||
- 护符效果立即生效(如 HP+20,查看 `PlayerStats.MaxHP`)
|
||||
- `EquipmentManager.IsEquipped(amuletId) == true`
|
||||
|
||||
**步骤 B:槽位已满**
|
||||
|
||||
1. 将所有护符槽填满(`maxAmuletSlots` 个护符)
|
||||
2. 尝试装备第 `maxAmuletSlots + 1` 个护符
|
||||
|
||||
**预期**:系统提示"护符栏已满",无法装备(不会覆盖现有护符)。
|
||||
|
||||
**步骤 C:卸下护符**
|
||||
|
||||
1. 选中已装备的护符,点击"卸下"
|
||||
|
||||
**预期**:
|
||||
- 护符移回背包
|
||||
- 护符提供的属性加成撤销(HP 恢复原值)
|
||||
|
||||
**步骤 D:武器切换(FormController 联动)**
|
||||
|
||||
1. 切换形态(Sky/Earth/Death)
|
||||
|
||||
**预期**:装备的武器 SO 根据形态切换,攻击力/攻击动画随形态变化。
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 装备护符 | 效果立即生效,图标显示 | ☐ |
|
||||
| 槽位限制 | 超出槽数无法装备 | ☐ |
|
||||
| 卸下护符 | 属性加成撤销 | ☐ |
|
||||
| 形态武器 | 不同形态武器属性不同 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-PROG-03:任务系统(QuestManager)
|
||||
|
||||
**目的**:验证 `QuestManager` 的任务激活→进度追踪→完成→奖励全流程。
|
||||
|
||||
> **🔧 资源准备**
|
||||
> 1. 在 **Tab 10 → `添加任务触发器(QuestTrigger)`** 一键放置 `QuestTrigger` GameObject(含 CapsuleCollider2D)
|
||||
> 2. 在 Inspector 中将 `QuestTriggerSO`(手动创建:Project 右键 → Create → BaseGames → Quest → QuestSO)拖入 `QuestTrigger._questToStart`
|
||||
> 3. 确认 `QuestDatabaseSO` 中已注册该 QuestSO 条目
|
||||
|
||||
### 步骤
|
||||
|
||||
**步骤 A:任务激活**
|
||||
|
||||
1. 找到场景中的任务触发器(如 NPC 对话后触发任务)或手动调用 `QuestManager.StartQuest(questId)`
|
||||
2. 打开任务日志 UI
|
||||
|
||||
**预期**:任务出现在"进行中"列表,任务目标文本正确显示。
|
||||
|
||||
**步骤 B:进度追踪**
|
||||
|
||||
1. 完成部分任务目标(如击杀 X 敌人/收集 X 物品)
|
||||
2. 查看任务日志
|
||||
|
||||
**预期**:任务进度更新(如 "击杀 2/5 只敌人"),`EVT_QuestProgressUpdated` 事件触发。
|
||||
|
||||
**步骤 C:任务完成**
|
||||
|
||||
1. 完成所有任务目标
|
||||
|
||||
**预期**:
|
||||
- `EVT_QuestCompleted` 事件触发
|
||||
- 任务移入"已完成"列表
|
||||
- 奖励自动发放(Geo/能力解锁/道具)
|
||||
- 存档文件中 `SaveData.Quests[questId].IsCompleted == true`
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 任务激活 | 任务出现在进行中列表 | ☐ |
|
||||
| 进度追踪 | 完成目标后进度数字更新 | ☐ |
|
||||
| 完成奖励 | 奖励正确发放 | ☐ |
|
||||
| 持久化 | 存档中 IsCompleted == true | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-PROG-04:成就系统(AchievementManager)
|
||||
|
||||
**目的**:验证 `AchievementManager` 触发条件监听、达成弹窗、持久化。
|
||||
|
||||
### 步骤
|
||||
|
||||
1. 触发某个成就的条件(如"首次击杀 Boss"、"连续弹反 5 次"等)
|
||||
|
||||
**预期**:
|
||||
- 屏幕右上角弹出成就解锁通知(`AchievementPopup`)
|
||||
- 通知显示成就名称和图标
|
||||
- `EVT_AchievementUnlocked` 触发
|
||||
|
||||
2. 打开成就列表 UI
|
||||
|
||||
**预期**:该成就显示为已解锁状态(金色)。
|
||||
|
||||
3. 退出并重新进入 Play Mode
|
||||
|
||||
**预期**:成就状态仍为已解锁(`SaveData.Achievements` 持久化)。
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 触发弹窗 | 条件达成后弹出通知 | ☐ |
|
||||
| 列表状态 | 成就列表中显示已解锁 | ☐ |
|
||||
| 持久化 | 重进后仍为已解锁 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-PROG-05:商店系统(Shop)
|
||||
|
||||
**目的**:验证 `ShopKeeper`/`ShopInventory` 的购买/售出流程、Geo 扣减、背包更新。
|
||||
|
||||
> **🔧 资源准备**
|
||||
> 1. 手动在场景中创建 `ShopNPC` GameObject,添加 `CapsuleCollider2D`,并挂载 `ShopNPC` 组件
|
||||
> 2. 在 Inspector 中将 `ShopInventory_Test.asset` 拖入 `ShopNPC._inventory`
|
||||
> 3. 打开 `ShopInventory_Test.asset`,在 Inspector 展开 `_items` 数组,添加几个 `ShopItem`(配置 itemId、price、count)
|
||||
> 4. Play Mode 中在 Inspector 直接将 `PlayerController` / `GeoManager._geoCount` 设为 500 快速获取购物用 Geo
|
||||
|
||||
### 步骤
|
||||
|
||||
**步骤 A:打开商店**
|
||||
|
||||
1. 走到商店 NPC,按交互键
|
||||
|
||||
**预期**:商店 UI 打开,显示 `ShopInventorySO` 中的物品列表(价格、图标、名称)。
|
||||
|
||||
**步骤 B:购买物品**
|
||||
|
||||
1. 选择一个 Geo 充足的物品,确认购买
|
||||
|
||||
**预期**:
|
||||
- `CurrentGeo -= item.price`
|
||||
- 物品出现在背包
|
||||
- HUD Geo 数量更新
|
||||
|
||||
**步骤 C:Geo 不足**
|
||||
|
||||
1. 选择价格超过当前 Geo 的物品
|
||||
|
||||
**预期**:购买失败,提示"Geo 不足",Geo 不变。
|
||||
|
||||
**步骤 D:售出物品**
|
||||
|
||||
1. 在商店卖出背包中的物品
|
||||
|
||||
**预期**:
|
||||
- `CurrentGeo += item.sellPrice`
|
||||
- 物品从背包移除
|
||||
- HUD Geo 数量更新
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 商店 UI 打开 | 物品列表正确显示 | ☐ |
|
||||
| 购买成功 | Geo 减少,物品进背包 | ☐ |
|
||||
| Geo 不足 | 购买失败,Geo 不变 | ☐ |
|
||||
| 售出 | Geo 增加,物品移除 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-PROG-06:能力解锁(AbilityUnlock)
|
||||
|
||||
**目的**:验证特殊能力(DoubleJump、WallCling、Dash 等 `AbilityType` 位)的解锁与 `AbilityGate` 联动。
|
||||
|
||||
### 步骤
|
||||
|
||||
1. 确认某能力(如 DoubleJump)当前**未解锁**
|
||||
2. 找到对应的能力解锁点(Boss 击败后掉落,或特定区域触发)
|
||||
3. 触发解锁
|
||||
|
||||
**预期**:
|
||||
- `EVT_AbilityUnlocked(AbilityType)` 触发(`AbilityTypeEventChannelSO`,payload 为解锁的能力位)
|
||||
- `PlayerStats.HasAbility(ability) == true`(位掩码 `_unlockedAbilities |= ability`)
|
||||
- 存档中 `SaveData.Player.AbilityFlags` 含该能力位(`(uint)_unlockedAbilities`)
|
||||
- 对应 `AbilityGate` 自动开启(若当前场景有联动门)
|
||||
|
||||
4. 测试新解锁的能力(如 DoubleJump:跳跃后再次跳跃)
|
||||
|
||||
**预期**:能力生效(二段跳可用)。
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 解锁事件 | EVT_AbilityUnlocked 触发 | ☐ |
|
||||
| HasAbility == true | PlayerStats.HasAbility 返回 true | ☐ |
|
||||
| AbilityGate 开启 | 对应能力门自动开启 | ☐ |
|
||||
| 能力可用 | 新能力实际可使用 | ☐ |
|
||||
| 持久化 | 存档 AbilityFlags 含该能力位 | ☐ |
|
||||
@@ -0,0 +1,364 @@
|
||||
# 手动测试 11 · UI、音频与特效系统
|
||||
|
||||
> **测试类型**:Unity Editor 手动测试(Play Mode)
|
||||
> **覆盖模块**:`BaseGames.UI`、`BaseGames.Audio`、`BaseGames.VFX`、`BaseGames.Feedback`、`BaseGames.Localization`
|
||||
> **依赖组件**:`HUDController`、`AudioManager`、`VFXCatalogSO`、`MMF_Player`(Feel)、`LocalizationManager`
|
||||
> **场景要求**:含完整 HUD Canvas、AudioMixer,Feel 的 MMF_Player 绑定到角色
|
||||
|
||||
---
|
||||
|
||||
## 快速工具
|
||||
|
||||
| 工具 | 用途 | 菜单路径 |
|
||||
|------|------|----------|
|
||||
| **Validate All ScriptableObjects** | 遍历所有实现 `IValidatable` 的 SO,输出 ✅/❌ 报告(含 HUDController 频道字段、VFXCatalogSO 等) | `BaseGames → Tools → Validate All ScriptableObjects` |
|
||||
|
||||
> **注意**:PlayModeDebugOverlay 已移除。HUD 变化测试请通过 Inspector 直接修改对应 Manager 字段(HP、Geo 等),或配置带伤害的敌人触发真实战斗流程。
|
||||
> Tab 11 的 Validate HUD Bindings、Ensure AudioMixer、Validate VFX Catalog 等独立检查已合并到 `Validate All ScriptableObjects`,如需单独检查各 SO 字段请在 Console 过滤对应日志。
|
||||
|
||||
**典型工作流**:
|
||||
1. 测试前:`BaseGames → Tools → Validate All ScriptableObjects` 确认 HUD 频道全部绑定、VFX Catalog 无空引用;AudioMixer 检查参照下方 **MT-UI-03** 手动步骤。
|
||||
2. `MT-UI-01` HUD 绑定:Inspector 中直接修改 `PlayerStats._hp` 字段,观察 HP 条平滑动画。
|
||||
3. `MT-UI-04` VFX:配置带燃烧效果的敌人攻击玩家,Scene 视图观察 VFX Prefab 实例化。
|
||||
4. `MT-UI-05` Feel 反馈:配置敌人打玩家触发受击,观察 Camera Shake 和 Chromatic Aberration。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [HUD 与 UI 前置检查](#1-hud-与-ui-前置检查)
|
||||
2. [MT-UI-01:HUD 实时数据绑定](#mt-ui-01hud-实时数据绑定)
|
||||
3. [MT-UI-02:UI 面板管理(PanelManager)](#mt-ui-02ui-面板管理panelmanager)
|
||||
4. [MT-UI-03:AudioManager 混音器快照](#mt-ui-03audiomanager-混音器快照)
|
||||
5. [MT-UI-04:VFX 目录(VFXCatalogSO)](#mt-ui-04vfx-目录vfxcatalogso)
|
||||
6. [MT-UI-05:Feel 反馈链(MMF_Player)](#mt-ui-05feel-反馈链mmf_player)
|
||||
7. [MT-UI-06:本地化(Localization)](#mt-ui-06本地化localization)
|
||||
8. [MT-UI-07:对话系统(DialogueSystem)](#mt-ui-07对话系统dialoguesystem)
|
||||
|
||||
---
|
||||
|
||||
## 1. HUD 与 UI 前置检查
|
||||
|
||||
| 元素 | 说明 | ✓ |
|
||||
|------|------|---|
|
||||
| HUDController | Canvas 下有 `HUDController.cs`,绑定 HP/MP/Geo/Spring 事件频道 | ☐ |
|
||||
| AudioMixer | `Assets/Settings/MainMixer.mixer` 已创建,含 Master/Music/SFX/UI 子轨道 | ☐ |
|
||||
| VFXCatalogSO | `Assets/Data/VFX/VFXCatalog.asset` 已配置并引用正确 VFX Prefab | ☐ |
|
||||
| Feel MMF_Player | 角色 Prefab 上 `PlayerFeedbacks` 组件已绑定 | ☐ |
|
||||
|
||||
> **🔧 前置资产创建指导**
|
||||
>
|
||||
> **① HUD Canvas 创建(若尚无 Canvas)**:
|
||||
> 1. Hierarchy 右键 → **UI → Canvas**,命名 `HUD_Canvas`
|
||||
> 2. 在 `HUD_Canvas` 下添加 `HUDController` 组件
|
||||
> 3. 运行 `BaseGames → Tools → Validate All ScriptableObjects`,根据 Console ❌ 提示,将以下频道 SO 拖入对应字段:
|
||||
> - `_onHPChanged` → `EVT_HPChanged`(IntEventChannelSO)
|
||||
> - `_onMaxHPChanged` → `EVT_MaxHPChanged`(IntEventChannelSO)
|
||||
> - `_onSoulPowerChanged` → `EVT_SoulPowerChanged`(IntEventChannelSO)
|
||||
> - `_onSpiritPowerChanged` → `EVT_SpiritPowerChanged`(IntEventChannelSO)
|
||||
> - `_onGeoChanged` → `EVT_GeoChanged`(IntEventChannelSO)
|
||||
> - `_onSpringChargesChanged` → `EVT_SpringChargesChanged`(IntEventChannelSO)
|
||||
>
|
||||
> 若 SO 资产不存在,先运行菜单 `BaseGames → Tools → Create Event Channel Assets` 生成全部频道资产
|
||||
>
|
||||
> **② AudioMixer 创建(无法脚本化,仅可手动)**:
|
||||
> 1. Project 右键 → **Create → Audio Mixer** → 命名 `MainMixer` → 保存到 `Assets/_Game/Settings/`
|
||||
> 2. 在 Mixer 窗口(Window → Audio → Audio Mixer)中选中 `Master`,点 **+** 按钮创建三个子组 `Music`、`SFX`、`UI`
|
||||
> 3. 将 `MainMixer` 资产拖入 `AudioManager._mixer` 字段
|
||||
>
|
||||
> **③ VFXCatalogSO 条目填写**:
|
||||
> 1. 运行 `BaseGames → Tools → Validate All ScriptableObjects` 查看空引用条目
|
||||
> 2. 在 Project 视图找到 `VFXCatalog.asset`,展开 `_entries` 数组,将制作好的 VFX Prefab 拖入每条的 `prefab` 字段
|
||||
> 3. ⚠ VFX Prefab 本身需手动制作(ParticleSystem),无法自动生成
|
||||
|
||||
---
|
||||
|
||||
## MT-UI-01:HUD 实时数据绑定
|
||||
|
||||
**目的**:验证 HP/MP/Geo/灵泉 HUD 元素实时响应游戏状态变化。
|
||||
|
||||
> **🔧 资源准备(驱动 HUD 数据变化)**
|
||||
>
|
||||
> 进入 Play Mode 后,通过 Inspector 直接修改 `PlayerStats._hp` / `_geo` 等字段,观察对应 HUD 元素响应:
|
||||
> - **HP 变化** — 修改 `PlayerStats._hp`,观察 HP 条减少 / 恢复动效
|
||||
> - **Geo 变化** — 修改 `PlayerStats._geo`,观察 Geo 数字滚动
|
||||
> - **灵泉变化** — 修改 `PlayerStats._springCharges`,观察图标变化
|
||||
>
|
||||
> 也可配置带伤害的敌人触发真实战斗流程。
|
||||
|
||||
### 步骤
|
||||
|
||||
**HP 条**:
|
||||
|
||||
1. 让玩家受到攻击(HP 减少)
|
||||
|
||||
**预期**:
|
||||
- HP 条动画更新(平滑或直接减少,取决于设计)
|
||||
- HP 条颜色/动效响应(HP 低时变红或闪烁)
|
||||
|
||||
**MP(灵力)条**:
|
||||
|
||||
1. 玩家释放技能消耗 MP
|
||||
|
||||
**预期**:MP 条立即减少;停止使用后 MP 逐步恢复(如有恢复机制)。
|
||||
|
||||
**Geo 计数器**:
|
||||
|
||||
1. 拾取 Geo 或购买物品
|
||||
|
||||
**预期**:Geo 数字动画更新(计数滚动动效)。
|
||||
|
||||
**灵泉图标**:
|
||||
|
||||
1. 使用治疗(灵泉 -1)
|
||||
|
||||
**预期**:灵泉图标减少一个(图标变暗或消失)。
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| HP 实时响应 | 受击后 HP 条即时更新 | ☐ |
|
||||
| MP 实时响应 | 技能使用后 MP 条即时更新 | ☐ |
|
||||
| Geo 数字更新 | 拾取/消费后 Geo 即时更新 | ☐ |
|
||||
| 灵泉图标 | 使用后图标减少 | ☐ |
|
||||
| 无 UI 错误 | Console 无 UI Null 或 Missing Reference | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-UI-02:UI 面板管理(PanelManager)
|
||||
|
||||
**目的**:验证多个 UI 面板(形态技能一览/装备/任务/暂停)的互斥打开和关闭逻辑。
|
||||
|
||||
### 步骤
|
||||
|
||||
1. 按形态技能一览快捷键(P)打开 `FormSkillPanel`
|
||||
|
||||
**预期**:形态技能一览面板打开,游戏暂停(`Time.timeScale = 0`)。
|
||||
|
||||
2. 按装备快捷键(Tab)(在形态技能一览面板开启的情况下)
|
||||
|
||||
**预期**:形态技能一览关闭,装备面板打开(面板互斥,不同时显示多个菜单面板)。
|
||||
|
||||
3. 按 ESC 键
|
||||
|
||||
**预期**:当前打开的任意面板关闭,游戏恢复(`Time.timeScale = 1`)。
|
||||
|
||||
4. 打开暂停菜单(ESC 键)
|
||||
|
||||
**预期**:暂停菜单显示,`EVT_GamePaused` 事件触发,其他 UI 面板不显示。
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 面板互斥 | 同时只有一个主面板开启 | ☐ |
|
||||
| ESC 关闭面板 | ESC 关闭当前面板 | ☐ |
|
||||
| 暂停时 timeScale=0 | 面板打开时游戏暂停 | ☐ |
|
||||
| 关闭后 timeScale=1 | 面板关闭后游戏恢复 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-UI-03:AudioManager 混音器快照
|
||||
|
||||
**目的**:验证 `AudioManager` 在不同 GameState 下切换 AudioMixer 快照,正确控制音量。
|
||||
|
||||
> **🔧 资源准备(AudioMixer 必须手动创建)**
|
||||
>
|
||||
> 1. **手动创建步骤**:
|
||||
> - Project 右键 → **Create → Audio Mixer** → 命名 `MainMixer` → 保存到 `Assets/_Game/Settings/`
|
||||
> - 打开 Window → Audio → Audio Mixer
|
||||
> - 选中 `Master` 轨道,点 `+` 添加子组:`Music`、`SFX`、`UI`
|
||||
> - 展开 Snapshots,点 `+` 添加:`Gameplay`、`Pause`、`BossFight`(用于快照切换测试)
|
||||
> - 将 `Gameplay` 设为默认快照(右键 → Set as Start Snapshot)
|
||||
> 2. 将 `MainMixer` 拖入场景 `AudioManager._mixer` 字段
|
||||
> 3. ⚠ Unity 不支持通过代码创建 AudioMixer 资产(Editor API 无此功能),必须手动完成
|
||||
|
||||
### 步骤
|
||||
|
||||
**步骤 A:游戏运行快照**
|
||||
|
||||
1. 正常 Gameplay 状态下,观察 `Window → Audio → AudioMixer`
|
||||
|
||||
**预期**:Master 轨道均衡,`Gameplay` 快照激活,BGM 正常播放。
|
||||
|
||||
**步骤 B:暂停快照**
|
||||
|
||||
1. 按 ESC 打开暂停菜单
|
||||
|
||||
**预期**:
|
||||
- `Pause` 快照切换(BGM 音量降低或混响增强)
|
||||
- SFX 暂停或降低音量
|
||||
- 暂停菜单 BGM 或静音正确切换
|
||||
|
||||
**步骤 C:Boss 战快照**
|
||||
|
||||
1. 进入 Boss 战触发区域
|
||||
|
||||
**预期**:
|
||||
- `BossFight` 快照激活
|
||||
- Boss 战 BGM 淡入
|
||||
- 普通 BGM 淡出
|
||||
|
||||
**步骤 D:音量设置持久化**
|
||||
|
||||
1. 打开设置菜单,调整 BGM 音量
|
||||
2. 关闭游戏(退出 Play Mode)并重新进入
|
||||
|
||||
**预期**:BGM 音量设置被保存(`PlayerPrefs` 或 `SaveData.Settings.BGMVolume`)。
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| Gameplay BGM | 正常播放,音量正常 | ☐ |
|
||||
| 暂停快照 | BGM 降低/混响变化 | ☐ |
|
||||
| Boss BGM | Boss 战 BGM 正确切换 | ☐ |
|
||||
| 音量持久化 | 重进后音量设置保留 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-UI-04:VFX 目录(VFXCatalogSO)
|
||||
|
||||
**目的**:验证 `VFXCatalogSO` 在各种触发条件下正确播放对应 VFX Prefab(通过对象池)。
|
||||
|
||||
> **🔧 资源准备(VFXCatalogSO 条目填写)**
|
||||
>
|
||||
> 1. 运行 `BaseGames → Tools → Validate All ScriptableObjects`,Console 显示空引用条目
|
||||
> 2. 找到 `VFXCatalog.asset`(自定义路径),在 Inspector 展开 `_entries`
|
||||
> 3. 为每个条目填入对应的 VFX Prefab(ParticleSystem GameObject):
|
||||
>
|
||||
> | Key(示例) | 用途 | Prefab 路径(示例) |
|
||||
> |------------|------|---------------------|
|
||||
> | `hit_slash` | 斩击命中 | `Assets/_Game/VFX/Hit_Slash.prefab` |
|
||||
> | `hit_blunt` | 钝击命中 | `Assets/_Game/VFX/Hit_Blunt.prefab` |
|
||||
> | `parry_ring` | 弹反 | `Assets/_Game/VFX/Parry_Ring.prefab` |
|
||||
> | `player_hurt` | 玩家受击 | `Assets/_Game/VFX/Player_Hurt.prefab` |
|
||||
> | `player_death` | 玩家死亡 | `Assets/_Game/VFX/Player_Death.prefab` |
|
||||
> | `heal_spring` | 灵泉治疗 | `Assets/_Game/VFX/Heal_Spring.prefab` |
|
||||
> | `plunge_land` | 下劈落地 | `Assets/_Game/VFX/Plunge_Land.prefab` |
|
||||
>
|
||||
> ⚠ VFX Prefab 需手动制作(ParticleSystem + 自定义 Shader),无法自动生成。若当前测试阶段 Prefab 未就绪,可将占位 Cube/Sphere 临时拖入以验证调用链正常。
|
||||
|
||||
### 触发点验证列表
|
||||
|
||||
执行以下动作,观察对应 VFX 是否播放:
|
||||
|
||||
| 触发动作 | 预期 VFX | ✓ |
|
||||
|---------|---------|---|
|
||||
| 玩家攻击命中敌人(斩击) | 击中溅血特效(slash hit) | ☐ |
|
||||
| 玩家攻击命中敌人(钝击) | 击中火花/尘土特效(blunt hit) | ☐ |
|
||||
| 弹反成功 | 弹反光圈特效(parry ring) | ☐ |
|
||||
| 玩家受击 | 受击白闪 + 短暂粒子 | ☐ |
|
||||
| 玩家死亡 | 死亡消散特效 | ☐ |
|
||||
| 灵泉治疗 | 治疗绿色粒子 | ☐ |
|
||||
| 下劈反弹 | 落点尘土爆炸 | ☐ |
|
||||
|
||||
### 对象池验证
|
||||
|
||||
1. 在 Hierarchy 中找到 `VFXPoolRoot`(或 `ObjectPool` GameObject)
|
||||
2. 执行大量攻击,观察 Hierarchy 的 VFX Pool 子对象
|
||||
|
||||
**预期**:VFX 对象被复用(`SetActive(false)` 后再 `SetActive(true)`),不会无限创建新对象。
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 各 VFX 正确播放 | 对应触发点显示正确特效 | ☐ |
|
||||
| 对象池复用 | 不重复创建新 GameObject | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-UI-05:Feel 反馈链(MMF_Player)
|
||||
|
||||
**目的**:验证 `MMF_Player`(MoreMountains Feel)相机抖动、控制器震动、屏幕冲击等反馈。
|
||||
|
||||
### 步骤
|
||||
|
||||
**相机抖动(Camera Shake)**:
|
||||
|
||||
1. 执行强力攻击(如下劈落地、受到 Boss 重击)
|
||||
|
||||
**预期**:相机产生震动效果(`CinemachineImpulseSource` 或 `MMWiggle` 驱动),震动强度与事件大小相符。
|
||||
|
||||
**控制器震动(Controller Vibration)**(如在 Windows 上使用 Xbox 手柄):
|
||||
|
||||
1. 受到攻击
|
||||
|
||||
**预期**:手柄发生震动(`MMNVibrate` 或 `NiceVibrations` 调用)。
|
||||
|
||||
**屏幕闪烁(Screen Flash)**:
|
||||
|
||||
1. 使用爆炸性技能或受到大伤害
|
||||
|
||||
**预期**:屏幕短暂白色或红色闪烁(`PostProcessing` 或 `Image` Overlay)。
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 相机抖动 | 重攻击/落地时相机抖动 | ☐ |
|
||||
| 控制器震动 | 受击时手柄震动(手柄测试) | ☐ |
|
||||
| 屏幕闪烁 | 大伤害时屏幕闪烁 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-UI-06:本地化(Localization)
|
||||
|
||||
**目的**:验证 `LocalizationManager` 切换语言后 UI 文本正确更新。
|
||||
|
||||
### 步骤
|
||||
|
||||
1. 在游戏设置中切换语言(如:中文 → 英文)
|
||||
|
||||
**预期**:
|
||||
- HUD 中的提示文本变为英文
|
||||
- 对话框文本变为英文
|
||||
- 技能/物品名称变为英文
|
||||
|
||||
2. 再切换回中文
|
||||
|
||||
**预期**:所有文本恢复中文,无残留英文。
|
||||
|
||||
3. 观察是否存在文本溢出(UI 元素装不下翻译后更长的字符串)
|
||||
|
||||
**预期**:UI 文本框自适应或截断处理正确,无文字超出边界。
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 语言切换 | UI 文本全部更新 | ☐ |
|
||||
| 切换回中文 | 无残留英文 | ☐ |
|
||||
| 文本不溢出 | UI 无字符超出边界 | ☐ |
|
||||
| 缺失 Key 检测 | Console 无 `Missing Localization Key` 警告 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-UI-07:对话系统(DialogueSystem)
|
||||
|
||||
**目的**:验证 `DialogueManager` 与 NPC 对话的触发、分支选择、事件链。
|
||||
|
||||
### 步骤
|
||||
|
||||
1. 走到场景中有对话触发的 NPC,按**交互键**(E)
|
||||
|
||||
**预期**:
|
||||
- 对话 UI 面板打开
|
||||
- 第一行对话文本正确显示(逐字显示动效)
|
||||
- 游戏输入暂时切换为"对话输入"(按键 E/Space 翻页,WASD 选择选项)
|
||||
|
||||
2. 按 E/Space 翻页
|
||||
|
||||
**预期**:逐行显示对话,直到当前节点末尾。
|
||||
|
||||
3. 遇到分支选项时(如"同意/拒绝")
|
||||
|
||||
**预期**:
|
||||
- 分支选项 UI 显示
|
||||
- 上下移动光标选择
|
||||
- 按确认键执行对应分支逻辑
|
||||
|
||||
4. 对话结束后
|
||||
|
||||
**预期**:
|
||||
- 对话 UI 关闭
|
||||
- 游戏输入恢复正常
|
||||
- 若对话触发任务/事件:相关 EventChannel 已触发(EventBusMonitor 可见)
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 对话 UI 打开 | 交互后对话面板显示 | ☐ |
|
||||
| 逐字显示 | 文字逐字出现动效 | ☐ |
|
||||
| 分支选择 | 分支选项响应方向键 | ☐ |
|
||||
| 对话结束 | UI 关闭,输入恢复 | ☐ |
|
||||
| 事件触发 | 对话触发的事件正确执行 | ☐ |
|
||||
@@ -0,0 +1,500 @@
|
||||
# 手动测试 12 · 区域相机系统
|
||||
|
||||
> **测试类型**:Unity Editor 手动测试(Play Mode)
|
||||
> **覆盖模块**:`CameraArea`、`CameraTriggerZone`、`CameraStateController`、`CinemachineConfiner2D`、`CameraBlendProfileSO`
|
||||
> **前置文档**:`Phase1_Verification_Guide.md` §1(验证前准备)
|
||||
|
||||
---
|
||||
|
||||
## 快速工具
|
||||
|
||||
| 工具 | 用途 | 菜单路径 |
|
||||
|------|------|----------|
|
||||
| **Camera Area Setup(窗口)** | 扫描场景中所有 CameraArea/TriggerZone/Controller,显示绑定状态,提供一键修复 | `BaseGames → Camera → Camera Area Setup` |
|
||||
| **Place Camera Area** | 生成 CameraArea 节点(含 PolygonCollider2D 限位边界) | `BaseGames → Scene → Place → Camera Area` |
|
||||
| **Place Camera Trigger Zone** | 生成 CameraTriggerZone + BoxCollider2D Trigger | `BaseGames → Scene → Place → Camera Trigger Zone` |
|
||||
|
||||
**典型工作流**:
|
||||
1. 在 Persistent 场景中放置两台全局虚拟相机(`VCamA` / `VCamB`),绑定到 `CameraStateController._vcamA/_vcamB`。
|
||||
2. 在关卡场景中使用 **Place Camera Area** 为每个相机区域放置 `CameraArea` 数据节点(一个房间可放多个)。
|
||||
3. 选中 `CameraArea`,在 Scene 视图中拖拽**黄色可视区域**的边 Handle 调整可见范围,然后点击 Inspector 底部 **「从可视区域更新限位区域(透视)」** 自动换算限位多边形。
|
||||
4. 使用 **Place Camera Trigger Zone** 在区域入口放置触发器,并将目标 `CameraArea` 拖入 `_targetArea`。
|
||||
5. 打开 **Camera Area Setup** 窗口,点击 **为全局 VCam 赋值 Follow 目标**(会自动在 Player 下创建或复用 `CameraFollowTarget` 子节点并绑定)。
|
||||
6. 所有条目显示绿色 ● → 进入 Play Mode 验证。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [系统架构说明](#1-系统架构说明)
|
||||
2. [Persistent 场景配置(CameraStateController)](#2-persistent-场景配置cameraStatecontroller)
|
||||
3. [关卡场景配置(CameraArea + CameraTriggerZone)](#3-关卡场景配置cameraarea--cameratriggerzone)
|
||||
4. [ScriptableObject 资产说明](#4-scriptableobject-资产说明)
|
||||
5. [Camera Area Setup 工具详解](#5-camera-area-setup-工具详解)
|
||||
6. [验收测试用例](#6-验收测试用例)
|
||||
7. [常见问题排查](#7-常见问题排查)
|
||||
|
||||
---
|
||||
|
||||
## 1. 系统架构说明
|
||||
|
||||
```
|
||||
Persistent.unity
|
||||
└── [Camera]
|
||||
└── CameraStateController ExecutionOrder = -100
|
||||
组件: CameraStateController
|
||||
│ _vcamA → VCamA(全局虚拟相机 A)
|
||||
│ _vcamB → VCamB(全局虚拟相机 B)
|
||||
│ _lookSystem → CameraLookSystem 节点
|
||||
组件: CinemachineBrain ← 实际渲染相机(随 Main Camera 放置)
|
||||
组件: CinemachineImpulseSource ← 屏幕抖动信号源
|
||||
├── CameraLookSystem ← 运行时由 SetFollowTarget() 赋值基准目标
|
||||
│ 组件: CameraLookSystem 双轴窥视偏移 + 速度门控;输出 VirtualTarget
|
||||
├── VCamA
|
||||
│ 组件: CinemachineCamera Follow = CameraLookSystem.VirtualTarget(运行时自动赋值)
|
||||
│ 组件: CinemachinePositionComposer Body 跟随组件;初始值与 CameraArea 默认值对齐
|
||||
│ 组件: CinemachineConfiner2D ← 由 CameraStateController 动态更新 BoundingShape2D
|
||||
│ 组件: CameraAxisLockExtension 锁定 X 或 Y 轴(竖井 / 走廊场景)
|
||||
│ 组件: CameraAsymmetricDampingExtension 非对称 Y 阻尼(下落快恢复,起跳慢追赶)
|
||||
│ 组件: CameraAdaptiveLookaheadExtension 速度自适应 Lookahead(移动快 → 预见更多)
|
||||
└── VCamB
|
||||
组件: CinemachineCamera Follow = CameraLookSystem.VirtualTarget(运行时自动赋值)
|
||||
组件: CinemachinePositionComposer 同 VCamA
|
||||
组件: CinemachineConfiner2D ← 由 CameraStateController 动态更新 BoundingShape2D
|
||||
组件: CameraAxisLockExtension
|
||||
组件: CameraAsymmetricDampingExtension
|
||||
组件: CameraAdaptiveLookaheadExtension
|
||||
|
||||
Level_01.unity
|
||||
├── [CameraAreas]
|
||||
│ ├── CameraArea_A 区域 A(可在同一房间内放置多个)
|
||||
│ │ 组件: CameraArea _confinerCollider → PolygonCollider2D
|
||||
│ │ │ _visibleBounds(可视矩形)
|
||||
│ │ │ _blendProfile → CameraBlendProfileSO(可选)
|
||||
│ │ │ _dedicatedCamera(专有 VCam,可选,priority > 全局)
|
||||
│ │ └── PolygonCollider2D 定义该区域的相机限位边界
|
||||
│ │
|
||||
│ └── CameraArea_B 区域 B
|
||||
│ ...(同上)
|
||||
│
|
||||
└── [Triggers]
|
||||
├── CameraTriggerZone_AB 区域 A→B 入口
|
||||
│ 组件: CameraTriggerZone _targetArea = CameraArea_B
|
||||
│ 组件: BoxCollider2D isTrigger = true
|
||||
└── CameraTriggerZone_BA 区域 B→A 入口
|
||||
组件: CameraTriggerZone _targetArea = CameraArea_A
|
||||
组件: BoxCollider2D isTrigger = true
|
||||
```
|
||||
|
||||
**核心流程**:
|
||||
1. 玩家进入 `CameraTriggerZone` → `OnTriggerEnter2D` 调用 `ICameraService.SwitchArea(targetArea)`
|
||||
2. `CameraStateController.SwitchArea` → 应用 `BlendProfile` 到 `CinemachineBrain.DefaultBlend`
|
||||
- **无专有 VCam**:配置非活跃全局 VCam 的 `CinemachineConfiner2D.BoundingShape2D` → 提升其优先级至 10 → 降低旧 VCam 优先级至 0(ping-pong),Cinemachine Brain 自动触发混合过渡
|
||||
- **有专有 VCam**:提升 `_dedicatedCamera` 优先级至 `_dedicatedPriority`(默认 20),高于全局 VCam,Cinemachine 自动切换
|
||||
|
||||
---
|
||||
|
||||
## 2. Persistent 场景配置(CameraStateController)
|
||||
|
||||
### 2.1 组件放置
|
||||
|
||||
在 Persistent 场景的 `[Camera]` 下建立以下节点结构:
|
||||
|
||||
| GameObject | 挂载组件 | 说明 |
|
||||
|-----------|---------|------|
|
||||
| `[CameraController]` | `CameraStateController`、`CinemachineBrain`、`CinemachineImpulseSource` | ExecutionOrder = -100 |
|
||||
| `CameraLookSystem` | `CameraLookSystem` | 双轴窥视偏移(垂直+水平)+ 速度门控;输出 `VirtualTarget` 供 VCam 跟随;拖入 `CameraStateController._lookSystem` |
|
||||
| `VCamA` | `CinemachineCamera`、**`CinemachinePositionComposer`**、`CinemachineConfiner2D`、`CameraAxisLockExtension`、`CameraAsymmetricDampingExtension`、`CameraAdaptiveLookaheadExtension` | 全局虚拟相机 A;**PositionComposer 是 Body 组件,必须存在**;拖入 `CameraStateController._vcamA` |
|
||||
| `VCamB` | `CinemachineCamera`、**`CinemachinePositionComposer`**、`CinemachineConfiner2D`、`CameraAxisLockExtension`、`CameraAsymmetricDampingExtension`、`CameraAdaptiveLookaheadExtension` | 全局虚拟相机 B;同 VCamA;拖入 `CameraStateController._vcamB` |
|
||||
|
||||
> **注意**:`CinemachineBrain` 须挂在附有 `Camera` 组件(Main Camera)的 GameObject 上,
|
||||
> 否则 Cinemachine 无法驱动视口渲染。两台全局 VCam 初始优先级均为 0,由 `CameraStateController` 在运行时动态管理。
|
||||
|
||||
### 2.2 字段绑定清单
|
||||
|
||||
打开 **Camera Area Setup** 窗口(`BaseGames → Camera → Camera Area Setup`),
|
||||
在 **CameraStateController** 区域确认以下项目全为绿色 ●:
|
||||
|
||||
| 字段 | 期望状态 |
|
||||
|------|---------|
|
||||
| `_vcamA` (CinemachineCamera) | ● 已绑定 |
|
||||
| `_vcamB` (CinemachineCamera) | ● 已绑定 |
|
||||
| `_brain` (CinemachineBrain) | ● 已绑定 |
|
||||
| `_lookSystem` (CameraLookSystem) | ● 已绑定 |
|
||||
| `_impulseSource` (CinemachineImpulseSource) | ◌ 可选;用于屏幕抖动 |
|
||||
| `_defaultBlendProfile` (CameraBlendProfileSO) | ◌ 可选;未设置则无混合过渡 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 关卡场景配置(CameraArea + CameraTriggerZone)
|
||||
|
||||
### 3.1 添加 CameraArea
|
||||
|
||||
**方式 A:使用快速放置工具**(推荐)
|
||||
|
||||
1. 菜单 `BaseGames → Scene → Place → Camera Area`
|
||||
2. 工具自动创建以下节点结构:
|
||||
```
|
||||
CameraArea
|
||||
├── CameraArea(组件,_confinerCollider 已绑定)
|
||||
└── PolygonCollider2D(默认矩形 24×12,isTrigger = true,定义限位区域)
|
||||
```
|
||||
3. 打开 **Camera Area Setup** 窗口,点击 **为全局 VCam 赋值 Follow 目标**
|
||||
(工具会自动在 Player 下查找或创建 `CameraFollowTarget` 子节点,绑定到两台全局 VCam)。
|
||||
4. 手动调整子节点 `PolygonCollider2D` 顶点定义限位范围。
|
||||
|
||||
**方式 B:手动创建**
|
||||
|
||||
1. 新建空 GameObject,命名如 `CameraArea_A`
|
||||
2. 挂载 `CameraArea`(BaseGames.Camera)
|
||||
3. 在同一 GameObject 或子对象上创建 `PolygonCollider2D`
|
||||
4. 将 `PolygonCollider2D` 拖入 `CameraArea._confinerCollider`
|
||||
5. (可选)如需专有相机参数,新建独立 VCam GameObject,挂载 `CinemachineCamera`,拖入 `CameraArea._dedicatedCamera`
|
||||
|
||||
> **一个房间可放置多个 `CameraArea`**,如大厅区域与 Boss 区域分别使用不同的限位和混合配置。
|
||||
|
||||
### 3.2 调整限位区域(PolygonCollider2D)
|
||||
|
||||
`CameraArea` 上(或子节点)的 `PolygonCollider2D` 定义了相机在该区域内的移动边界。
|
||||
|
||||
- **编辑顶点**:选中 `CameraArea` 节点 → Inspector 中 `PolygonCollider2D` → 点击 **Edit Collider** 图标,在 Scene 视图拖动顶点
|
||||
- **自动修复**:打开 **Camera Area Setup** 窗口,对应条目点击 **修复:绑定子节点 PolygonCollider2D**
|
||||
|
||||
> **最佳实践**:限位区域应比实际可见范围大一格以上(约 1 unit),避免相机卡在边缘。
|
||||
|
||||
### 3.3 添加 CameraTriggerZone
|
||||
|
||||
**使用快速放置工具**(推荐)
|
||||
|
||||
1. 菜单 `BaseGames → Scene → Place → Camera Trigger Zone`
|
||||
2. 工具生成:
|
||||
```
|
||||
CameraTriggerZone
|
||||
├── CameraTriggerZone(组件,_playerTag = "Player")
|
||||
└── BoxCollider2D(isTrigger = true,默认 2×2)
|
||||
```
|
||||
3. 在 Inspector 中将目标 `CameraArea` 拖入 `CameraTriggerZone._targetArea`
|
||||
4. 调整 `BoxCollider2D` 大小至覆盖整个区域过渡走廊宽度(通常 2×3 或 2×4)
|
||||
|
||||
**典型布局**:
|
||||
|
||||
```
|
||||
[区域 A] ‖ [走廊] ‖ [区域 B]
|
||||
←← TriggerZone_A→B (_targetArea = CameraArea_B)
|
||||
TriggerZone_B→A (_targetArea = CameraArea_A) →→
|
||||
```
|
||||
|
||||
> 双向过渡需要两个 TriggerZone 分别放置在走廊两端,各自指向对应区域的 `CameraArea`。
|
||||
|
||||
### 3.4 全局 VCam Follow 绑定
|
||||
|
||||
Persistent 场景中两台全局 VCam 的 `CinemachineCamera.Follow` **不直接指向 Player**,
|
||||
而是指向 `CameraLookSystem` 组件在运行时生成的 **`[CameraLookTarget]` 虚拟目标节点**。
|
||||
该节点由 `CameraStateController.SetFollowTarget(Transform)` 在玩家注册时自动创建并赋值,
|
||||
其世界位置 = Player 基准目标位置 + 当前窥视偏移(`CameraLookSystem` 的双轴输出)。
|
||||
|
||||
> **不要在 Inspector 中手动把 VCam.Follow 拖到 Player 本身或 Player/CameraFollowTarget。**
|
||||
> `SetFollowTarget` 会在运行时覆盖,且直接指向 Player 会绕过窥视偏移计算。
|
||||
|
||||
**工具支持**:
|
||||
**Camera Area Setup** 窗口 → **为全局 VCam 赋值 Follow 目标** 按钮
|
||||
→ 该按钮仅用于调试回退(绕过 LookSystem,直指 Player/CameraFollowTarget)。
|
||||
正式流程请通过 `ICameraService.SetFollowTarget(playerTransform)` 注册,由系统自动处理。
|
||||
|
||||
---
|
||||
|
||||
### 3.5 编辑可视区域(透视相机)
|
||||
|
||||
`CameraArea` 支持在 Scene 视图中直接定义摄像机的最大可视范围,并自动换算为限位 `PolygonCollider2D` 的顶点坐标。
|
||||
|
||||
**Inspector 字段**:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `_visibleBounds` | 摄像机应显示的最大可视矩形(世界坐标);Scene 视图选中时显示为**黄色矩形** |
|
||||
| `_cameraDepth` | 摄像机到场景平面(Z = 0)的垂直距离;留 `0` 则自动读取 `\|transform.position.z\|` |
|
||||
|
||||
**Scene 视图拖拽编辑**:
|
||||
|
||||
选中 `CameraArea` GameObject 后,Scene 视图出现:
|
||||
- **黄色矩形**:可视区域(玩家在此区域内的最大可见范围)
|
||||
- **蓝色多边形**:当前 `PolygonCollider2D` 限位边界(参考用)
|
||||
|
||||
矩形四条边各有一个滑动 Handle,拖拽即可调整:
|
||||
- 左 / 右边 Handle:沿 X 轴滑动
|
||||
- 上 / 下边 Handle:沿 Y 轴滑动
|
||||
|
||||
**同步到限位区域**:
|
||||
|
||||
调整好可视区域后,在 Inspector 底部点击 **「从可视区域更新限位区域(透视)」**,工具根据以下公式换算限位多边形:
|
||||
|
||||
```
|
||||
halfH = depth × tan(vFOV / 2)
|
||||
halfW = halfH × aspectRatio
|
||||
confiner = visibleBounds 向内收缩 (halfW, halfH)
|
||||
```
|
||||
|
||||
> **含义**:相机视口边缘恰好与可视区域边框对齐。若区域小于单屏(inset 后为负),限位收缩为中心点,相机固定居中。
|
||||
|
||||
Inspector 参数预览区实时显示 FOV(来源:专有 VCam → 全局 VCamA → Camera.main → 60°)、深度、视口半宽 / 半高的计算值。
|
||||
|
||||
### 3.6 专有 VCam(特殊区域)
|
||||
|
||||
需要独特相机参数(如 Boss 区域特写 FOV)的区域,可在 `CameraArea._dedicatedCamera` 中指定一台独立的 `CinemachineCamera`:
|
||||
|
||||
1. 在关卡场景中新建空 GameObject,挂载 `CinemachineCamera`(设置好 Lens、Follow、Noise 等参数)
|
||||
2. 将其拖入该 `CameraArea._dedicatedCamera`
|
||||
3. `_dedicatedPriority`(默认 20)须高于全局 VCam 的激活优先级(10)
|
||||
|
||||
进入该区域时,`CameraStateController` 自动提升专有 VCam 优先级,Cinemachine 混合切换;离开时优先级归零,全局 VCam 重新接管。
|
||||
|
||||
---
|
||||
|
||||
## 4. ScriptableObject 资产说明
|
||||
|
||||
### 4.1 CameraBlendProfileSO
|
||||
|
||||
**创建路径**:`Assets → Create → BaseGames → Camera → BlendProfile`
|
||||
|
||||
| 字段 | 说明 | 典型值 |
|
||||
|------|------|--------|
|
||||
| `Style` | 混合曲线类型(EaseInOut / Linear / Cut / Custom) | `EaseInOut` |
|
||||
| `BlendTime` | 混合持续时间(秒) | `0.5` |
|
||||
| `CustomCurve` | 仅 `Style = Custom` 时使用 | — |
|
||||
|
||||
**使用**:
|
||||
- 全局默认:拖入 `CameraStateController._defaultBlendProfile`
|
||||
- 单独区域:拖入对应 `CameraArea._blendProfile`(覆盖全局默认)
|
||||
|
||||
### 4.2 CameraConfigSO
|
||||
|
||||
> ⚠ **Legacy / 已废弃**:`CameraConfigSO` 是早期相机系统的配置资产,现已不再被 `CameraStateController` 读取或应用。
|
||||
> 当前架构直接通过 `CameraArea` 字段(`ScreenPosition`、`DeadZoneSize`、`LookaheadTime`、`DampingDown`、`DampingUp` 等)在 `ConfigureSlot` 时写入 Cinemachine 组件,无需此 SO。
|
||||
> 若项目中仍存在 `CameraConfigSO` 资产,可安全忽略或删除。
|
||||
|
||||
~~**创建路径**:`Assets → Create → BaseGames → Camera → CameraConfig`~~
|
||||
|
||||
| 字段 | 说明 | 典型值 |
|
||||
|------|------|--------|
|
||||
| `FollowDamping` | ~~跟随阻尼~~ | — |
|
||||
| `LookAheadTime` | ~~朝向预见时间~~ | — |
|
||||
| `DeadZoneSize` | ~~死区尺寸~~ | — |
|
||||
| `SoftZoneSize` | ~~软区尺寸~~ | — |
|
||||
| `LookDownOffset` | ~~俯视偏移~~ | — |
|
||||
| `LookUpOffset` | ~~仰视偏移~~ | — |
|
||||
| `DefaultImpulseStrength` | ~~默认震屏强度~~ | — |
|
||||
|
||||
|
||||
> `CameraConfigSO` 的配置值须由运行时的 `CameraStateController` 或相机系统读取并写入 Cinemachine 组件,具体写入逻辑取决于 `CameraStateController.ApplyConfig()` 的实现(如有扩展)。
|
||||
|
||||
---
|
||||
|
||||
## 5. Camera Area Setup 工具详解
|
||||
|
||||
菜单:`BaseGames → Camera → Camera Area Setup`
|
||||
|
||||
### 5.1 界面区域说明
|
||||
|
||||
**工具栏**
|
||||
- `↻ 刷新`:手动重新扫描当前已加载场景
|
||||
- `Place Camera Area`:快捷调用 `BaseGames → Scene → Place → Camera Area`
|
||||
- `Place Trigger Zone`:快捷调用 `BaseGames → Scene → Place → Camera Trigger Zone`
|
||||
|
||||
**CameraStateController 区域**
|
||||
显示控制器组件绑定状态。若显示"未找到"提示,说明 Persistent 场景未加载(属正常)。
|
||||
|
||||
| 图标 | 含义 |
|
||||
|------|------|
|
||||
| `●`(绿) | 项目已正确配置 |
|
||||
| `✗`(红) | 缺失必填项 |
|
||||
| `◌`(黄) | 可选项未设置 |
|
||||
|
||||
检查项:`_vcamA`、`_vcamB`(必填)、`_brain`(必填)、`_lookSystem`(必填)、`_impulseSource`(可选)、`_defaultBlendProfile`(可选)
|
||||
|
||||
底部按钮:**为全局 VCam 赋值 Follow 目标** → 查找 Player/CameraFollowTarget 并写入两台 VCam 的 Follow 字段。
|
||||
|
||||
**Camera Areas 区域**
|
||||
为每个 `CameraArea` 显示一行,检查项:
|
||||
|
||||
| 字段 | 状态 |
|
||||
|------|------|
|
||||
| `_confinerCollider` (PolygonCollider2D) | 必填 |
|
||||
| `_dedicatedCamera`(专有 VCam) | 可选 |
|
||||
| `_blendProfile` | 可选 |
|
||||
|
||||
每行可点击 **修复:绑定子节点 PolygonCollider2D** 自动修复 `_confinerCollider` 未绑定的情况。
|
||||
|
||||
**Camera Trigger Zones 区域**
|
||||
列出所有 `CameraTriggerZone`,高亮显示 `_targetArea` 未绑定的项目(红色 ✗)。
|
||||
|
||||
### 5.2 典型使用流程
|
||||
|
||||
```
|
||||
1. 打开窗口 BaseGames → Camera → Camera Area Setup
|
||||
2. (仅首次)加载 Persistent 场景,确认 _vcamA/_vcamB/_brain 全绿
|
||||
3. 在关卡场景中使用 Place Camera Area × N(一个房间可放多个)
|
||||
4. 选中每个 CameraArea,在 Scene 视图拖拽黄色可视区域边 Handle,点击 [从可视区域更新限位区域(透视)]
|
||||
5. 点击 [为全局 VCam 赋值 Follow 目标](自动创建 Player/CameraFollowTarget 并绑定)
|
||||
6. 使用 Place Trigger Zone 添加 N 个触发器,手动绑定 _targetArea
|
||||
7. 所有条目绿色 ● → 进入 Play Mode 验证
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 验收测试用例
|
||||
|
||||
### 测试前检查清单
|
||||
|
||||
| # | 检查项 | 操作 |
|
||||
|---|--------|------|
|
||||
| 1 | Console 无红色 Error | `Window → General → Console` |
|
||||
| 2 | **Camera Area Setup** 窗口所有必填项为绿色 ● | `BaseGames → Camera → Camera Area Setup` |
|
||||
| 3 | Player 已在场景中(tag = Player) | Hierarchy |
|
||||
| 4 | Physics2D Layer 矩阵已配置 | `BaseGames → Tools → Validate Physics2D Layer Matrix` |
|
||||
|
||||
---
|
||||
|
||||
### MT-CAM-01:全局 VCam 正常跟随
|
||||
|
||||
**目的**:验证全局 VCam 激活后 `CinemachineCamera` 跟随玩家移动。
|
||||
|
||||
**步骤:**
|
||||
|
||||
1. Persistent 场景中放置 VCamA/VCamB,`Follow = Player/CameraFollowTarget`
|
||||
2. 关卡场景中放置一个 `CameraArea`,通过 `CameraTriggerZone` 或 `RoomController` 触发 `SwitchArea`
|
||||
3. 按 **Play**,在 Scene 视图和 Game 视图同时观察
|
||||
4. 用 WASD/方向键移动 Player
|
||||
|
||||
**预期结果:**
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| Game 视图相机跟随 Player 移动 | 玩家靠近边缘时相机平滑跟进 | ☐ |
|
||||
| 相机不会越出 CameraArea 的限位范围 | 玩家走到边角时相机贴边停止 | ☐ |
|
||||
| 无跳变(平滑)跟随 | 无抖动、跳帧 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
### MT-CAM-02:区域相机切换(CameraTriggerZone)
|
||||
|
||||
**目的**:验证玩家穿越触发器后全局 VCam ping-pong 平滑过渡到目标区域。
|
||||
|
||||
**步骤:**
|
||||
|
||||
1. 场景中放置两个 `CameraArea`(A、B),各有独立 `PolygonCollider2D` 限位
|
||||
2. 在两区域之间放置两个 `CameraTriggerZone`(各自 `_targetArea` 互指)
|
||||
3. 按 **Play**,引导 Player 穿越触发区进入区域 B
|
||||
|
||||
**预期结果:**
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 穿越触发器后 Game 视图开始混合过渡 | 相机平滑从 A 过渡到 B(非切割) | ☐ |
|
||||
| 过渡时长约等于 `CameraBlendProfileSO.BlendTime` | 与 SO 设置一致(默认 0.5s) | ☐ |
|
||||
| 过渡后相机限位在 CameraArea_B 的边界内 | 玩家无法把相机带出 B 的限位范围 | ☐ |
|
||||
| 反向穿越触发器后相机切回 A | 同上,反向过渡 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
### MT-CAM-03:CinemachineConfiner2D 边界限位
|
||||
|
||||
**目的**:验证 `CinemachineConfiner2D` 正确将相机限制在 `CameraArea` 限位范围内。
|
||||
|
||||
**步骤:**
|
||||
|
||||
1. 打开关卡场景,确认 `CameraArea._confinerCollider` 已绑定,且 `CameraStateController` 已调用 `SwitchArea`
|
||||
2. 按 **Play**,将 Player 移动到房间的各个角落和边缘
|
||||
|
||||
**预期结果:**
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 相机在所有方向均不超出 `PolygonCollider2D` 多边形范围 | 无越界 | ☐ |
|
||||
| 小房间(相机视口 > 房间)时相机居中,不晃动 | 稳定居中 | ☐ |
|
||||
|
||||
**常见失败原因**:
|
||||
- `CameraArea._confinerCollider` 未绑定 → 打开 Camera Area Setup 点击修复
|
||||
- PolygonCollider2D 顶点数量少于 3 → 确认 `_confinerCollider` 路径顶点完整
|
||||
|
||||
---
|
||||
|
||||
### MT-CAM-04:屏幕抖动(CinemachineImpulseSource)
|
||||
|
||||
**目的**:验证调用 `ICameraService.TriggerImpulse` 时 Game 视图画面抖动。
|
||||
|
||||
**步骤:**
|
||||
|
||||
1. 确认 `CameraStateController._impulseSource` 已绑定
|
||||
2. 按 **Play**
|
||||
3. 在 Console 执行(或通过游戏内事件触发):
|
||||
```csharp
|
||||
ServiceLocator.Get<ICameraService>().TriggerImpulse(0.5f);
|
||||
```
|
||||
或让玩家受到一次伤害(若伤害系统已接入抖动调用)
|
||||
|
||||
**预期结果:**
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| Game 视图画面发生轻微抖动后恢复稳定 | 抖动时长约 0.2–0.4s,幅度可见 | ☐ |
|
||||
| 无 Console 错误 | 无 NullReferenceException | ☐ |
|
||||
|
||||
---
|
||||
|
||||
### MT-CAM-05:初始场景无 CameraStateController 时安全降级
|
||||
|
||||
**目的**:验证场景中未加载 Persistent 场景时,`CameraTriggerZone` 不崩溃。
|
||||
|
||||
**步骤:**
|
||||
|
||||
1. 单独打开关卡场景(不加载 Persistent.unity)
|
||||
2. 按 **Play**,移动 Player 穿越 `CameraTriggerZone`
|
||||
|
||||
**预期结果:**
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 无 NullReferenceException | `ServiceLocator.GetOrDefault<ICameraService>()` 返回 null 时跳过 | ☐ |
|
||||
| Console 可能有黄色 Warning(服务未注册) | 无红色 Error | ☐ |
|
||||
|
||||
---
|
||||
|
||||
### MT-CAM-06:多场景加载时相机状态正确恢复
|
||||
|
||||
**目的**:验证通过 `SceneLoader` 加载新场景时,`CameraStateController` 正确切换到新场景首个 `CameraArea`。
|
||||
|
||||
**步骤:**
|
||||
|
||||
1. 以 Persistent + Level_01 双场景启动
|
||||
2. 按 **Play**,触发场景加载切换到 Level_02
|
||||
3. Level_02 加载完成后观察相机
|
||||
|
||||
**预期结果:**
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| Level_02 的 `RoomController` 调用 `SwitchArea` 后相机切换正确 | 无黑屏、无旧场景相机残影 | ☐ |
|
||||
| 旧场景 `CameraArea` 随场景卸载,全局 VCam 状态不受干扰 | 无相机混合错误 | ☐ |
|
||||
|
||||
> **注意**:全局 VCam 常驻 Persistent 场景,`CinemachineConfiner2D.BoundingShape2D` 在 `SwitchArea` 时动态更新,
|
||||
> 旧场景卸载后引用会变为 null,须确保 `SwitchArea` 在新场景 `CameraArea` 可用后再调用。
|
||||
|
||||
---
|
||||
|
||||
## 7. 常见问题排查
|
||||
|
||||
| 现象 | 原因 | 解决 |
|
||||
|------|------|------|
|
||||
| Game 视图相机不动(黑屏或固定位置) | 全局 VCam `Follow` 未绑定 | `ICameraService.SetFollowTarget(player)` 注册玩家;或 Camera Area Setup → 为全局 VCam 赋值 Follow 目标 |
|
||||
| 相机追赶卡顿/震颤 | `CinemachineConfiner2D.BoundingShape2D` 未绑定或碰撞体顶点有误 | 确认 `CameraArea._confinerCollider` 已绑定,PolygonCollider2D 顶点数 ≥ 3 |
|
||||
| 进入区域后限位未更新(仍在旧区域限位内) | `CameraArea._confinerCollider` 为空,`ConfigureSlot` 跳过了更新 | 打开 Camera Area Setup 修复 `_confinerCollider` 绑定 |
|
||||
| 场景中有多个 `CinemachineBrain` | Persistent 场景外又添加了含 Camera 组件的对象 | 仅 Main Camera 上保留一个 Brain |
|
||||
| 过渡时画面闪切而非混合 | `CameraBlendProfileSO.BlendTime = 0` 或 Style = Cut | 检查 BlendProfile 并将 BlendTime 设置为 > 0 |
|
||||
| `CameraStateController` 未找到(Console 错误) | Persistent 场景未加载 | 确认 Build Settings 中 Persistent.unity 第一位;开发测试用 `SceneManager.LoadScene("Persistent", Additive)` |
|
||||
| 触发器无响应(玩家穿越后相机不切) | `CameraTriggerZone._targetArea` 未绑定,或 `_playerTag` 不匹配 | 检查 `_targetArea` 是否已拖入 `CameraArea`;确认 Player Tag = "Player" |
|
||||
| `Camera Area Setup` 窗口列表为空 | 场景未保存或 DomainReload 后未刷新 | 点击窗口内 `↻ 刷新` 按钮 |
|
||||
| 专有 VCam 不切换 | `_dedicatedPriority` ≤ 全局激活优先级(默认 10) | 将 `_dedicatedPriority` 设置为 > 10(默认 20 已满足) |
|
||||
| Camera Area Setup 中 `_lookSystem` 红色 ✗ | `CameraStateController._lookSystem` 未绑定 | 将 Persistent 场景 `[Camera]/CameraLookSystem` 节点拖入该字段;或重新运行 SceneScaffoldTools |
|
||||
| 按住方向键相机偏移不出现(窥视无效) | `CameraLookSystem._baseTarget` 未设置 | 确认 `ICameraService.SetFollowTarget(player)` 在玩家 Awake/Start 后调用 |
|
||||
| 下落时相机跟随过慢(非对称阻尼异常) | `CameraAsymmetricDampingExtension` 未挂到 VCamA/VCamB,或 `DampingDown` 未被 `ConfigureSlot` 写入 | 确认两台 VCam 已挂载该扩展;检查 `CameraArea._dampingDown` > 0 |
|
||||
| 高速移动时 Lookahead 不变化 | `CameraAdaptiveLookaheadExtension` 未挂到 VCamA/VCamB,或 `SetConfiguredMax` 未被调用 | 确认两台 VCam 已挂载该扩展;检查 `ConfigureSlot` 代码路径正常执行 |
|
||||
@@ -0,0 +1,937 @@
|
||||
# 手动测试 13 · 玩家能力与技能系统
|
||||
|
||||
> **测试类型**:Unity Editor 手动测试(Play Mode)
|
||||
> **覆盖模块**:`BaseGames.Player`、`BaseGames.Player.States`、`BaseGames.Skills`
|
||||
> **依赖组件**:`PlayerController`、`PlayerMovement`、`PlayerStats`、`PlayerCombat`、`FormController`、`SkillManager`、`SpringSystem`、`ParrySystem`
|
||||
> **场景要求**:包含玩家 Prefab 的测试场景,至少一块地面平台、一面垂直墙、若干测试用敌人
|
||||
|
||||
---
|
||||
|
||||
## 快速工具
|
||||
|
||||
| 工具 | 用途 | 菜单路径 |
|
||||
|------|------|----------|
|
||||
| **Place Player** | 放置完整玩家 GameObject | `BaseGames → Scene → Place → Player` |
|
||||
| **Place Ground Platform** | 放置地面平台(Layer=Ground) | `BaseGames → Scene → Place → Ground Platform` |
|
||||
| **Place Obstacle (Static)** | 放置垂直墙壁(用于抓墙测试) | `BaseGames → Scene → Place → Obstacle (Static)` |
|
||||
| **Place Test Enemy** | 放置静止测试用敌人(带 HurtBox) | `BaseGames → Scene → Place → Test Enemy` |
|
||||
| **Place Room Camera** | 放置 Cinemachine 相机 | `BaseGames → Scene → Place → Room Camera` |
|
||||
|
||||
---
|
||||
|
||||
## 场景搭建要求
|
||||
|
||||
在运行所有测试前,请确认以下清单:
|
||||
|
||||
| 检查项 | 说明 | ✓ |
|
||||
|--------|------|---|
|
||||
| 玩家 Prefab 已放置 | 带 `PlayerController`、`PlayerMovement`、`PlayerStats`、`PlayerCombat`、`FormController`、`SkillManager`、`SpringSystem`、`ParrySystem`、`HurtBox`、`AnimancerComponent`、`WeaponManager` | ☐ |
|
||||
| 地面平台 | Layer = `Ground`,至少一块宽平台 | ☐ |
|
||||
| 垂直墙壁 | Layer = `Ground`,高度 ≥ 4 格,用于抓墙/蹬墙跳测试 | ☐ |
|
||||
| 测试用敌人 | 带 `HurtBox`、`EnemyBase`,初始静止,用于攻击/Pogo/灵泉充能测试 | ☐ |
|
||||
| InputReaderSO | 已绑定 `_inputReader` 字段 | ☐ |
|
||||
| PlayerMovementConfigSO | 已绑定,含跳跃/冲刺/墙壁相关参数 | ☐ |
|
||||
| PlayerAnimationConfigSO | 已绑定,含所有动画 Clip 资产 | ☐ |
|
||||
| FormConfigSO | 已绑定到 `FormController._config`,三形态 SO 已配置 | ☐ |
|
||||
| SkillManager._formSkillSets | Inspector 中已配置三个 `FormSkillSet`,每项指定对应形态的三个技能 SO | ☐ |
|
||||
| SkillManager._formController | Inspector 中已绑定 `FormController` 引用 | ☐ |
|
||||
| Physics2D Layer 矩阵 | PlayerHitBox ↔ EnemyHurtBox、Player ↔ Ground 碰撞均已开启 | ☐ |
|
||||
| Console 无红色 Error | 进入 Play Mode 前 Error = 0 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [MT-ABILITY-01:地面三连击与攻击缓冲](#mt-ability-01地面三连击与攻击缓冲)
|
||||
2. [MT-ABILITY-02:上劈(地面 / 空中)](#mt-ability-02上劈地面--空中)
|
||||
3. [MT-ABILITY-03:下劈与 Pogo 弹起](#mt-ability-03下劈与-pogo-弹起)
|
||||
4. [MT-ABILITY-04:空中水平攻击](#mt-ability-04空中水平攻击)
|
||||
5. [MT-ABILITY-05:攻击取消窗口](#mt-ability-05攻击取消窗口)
|
||||
6. [MT-ABILITY-06:抓墙与高度记忆机制](#mt-ability-06抓墙与高度记忆机制)
|
||||
7. [MT-ABILITY-07:蹬墙跳(背墙跳 / 对墙跳)](#mt-ability-07蹬墙跳背墙跳--对墙跳)
|
||||
8. [MT-ABILITY-08:二段跳](#mt-ability-08二段跳)
|
||||
9. [MT-ABILITY-09:无敌冲刺](#mt-ability-09无敌冲刺)
|
||||
10. [MT-ABILITY-10:三形态切换](#mt-ability-10三形态切换)
|
||||
11. [MT-ABILITY-11:三套资源系统](#mt-ability-11三套资源系统)
|
||||
12. [MT-ABILITY-12:灵泉使用](#mt-ability-12灵泉使用)
|
||||
13. [MT-ABILITY-13:灵泉充能(击杀积累)](#mt-ability-13灵泉充能击杀积累)
|
||||
14. [MT-ABILITY-14:魂技能施放](#mt-ability-14魂技能施放)
|
||||
15. [MT-ABILITY-15:魄技能施放(技能 1 / 2)](#mt-ability-15魄技能施放技能-1--2)
|
||||
16. [MT-ABILITY-16:弹反系统](#mt-ability-16弹反系统)
|
||||
|
||||
---
|
||||
|
||||
## MT-ABILITY-01:地面三连击与攻击缓冲
|
||||
|
||||
**目的**:验证地面三段连击的前摇/有效帧/后摇三阶段、HitBox 激活时序、攻击缓冲机制。
|
||||
|
||||
### 前置条件
|
||||
|
||||
- 玩家站于地面
|
||||
- 在玩家攻击范围内放置测试用敌人
|
||||
|
||||
### 步骤 A:三段连击完整触发
|
||||
|
||||
1. 进入 Play Mode
|
||||
2. 玩家站立地面,按 **Attack 键**(默认 `J`)
|
||||
|
||||
**预期**:
|
||||
- 播放第 1 段攻击动画(`AnimationConfigSO.GroundAttacks[0]`)
|
||||
- 动画前摇结束时 HitBox 激活(可在 Scene 视图 Gizmos 中观察 HitBox 矩形出现)
|
||||
- 命中敌人后敌人 HP 减少,玩家方向上有微小横向反嵈位移
|
||||
|
||||
3. 动画进入后摇阶段后(有效帧结束),再次按 **Attack 键**
|
||||
|
||||
**预期**:
|
||||
- 无缝衔接第 2 段攻击动画(`GroundAttacks[1]`)
|
||||
- 第 2 段 HitBox 重新激活
|
||||
|
||||
4. 同样方式触发第 3 段
|
||||
|
||||
**预期**:
|
||||
- 播放 `GroundAttacks[2]`,第三段攻击完成后动画自然结束
|
||||
- 玩家返回 `IdleState` 或 `RunState`
|
||||
|
||||
### 步骤 B:攻击缓冲(后摇内预输入)
|
||||
|
||||
1. 按下 **Attack 键** 触发第 1 段攻击
|
||||
2. **在后摇期间**(有效帧结束前后)提前再次按 **Attack 键**
|
||||
|
||||
**预期**:
|
||||
- 第 1 段后摇结束后**自动**衔接第 2 段,无需再次按键
|
||||
- 缓冲窗口内的输入被 `InputBuffer` 记录并消耗
|
||||
|
||||
### 步骤 C:攻击中断复位
|
||||
|
||||
1. 第 1 段攻击完成后等待后摇完全结束(约 1.5 秒不操作)
|
||||
2. 再次按 **Attack 键**
|
||||
|
||||
**预期**:
|
||||
- 从第 1 段重新开始,而非继续第 2 段(连击计数已重置)
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 三段动画各不相同 | 三段 Clip 依次切换 | ☐ |
|
||||
| HitBox 仅在有效帧激活 | 前摇期间无碰撞,Gizmos 不显示 HitBox | ☐ |
|
||||
| 命中反嵈 | 命中敌人时玩家微小位移(打击感) | ☐ |
|
||||
| 攻击缓冲生效 | 后摇内按键可自动续接下一段 | ☐ |
|
||||
| 连击计数超时重置 | 久未续接后从第 1 段重新开始 | ☐ |
|
||||
| Console 无 Error | 0 个红色 Error | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-ABILITY-02:上劈(地面 / 空中)
|
||||
|
||||
**目的**:验证上劈的组合键判定(Move Y 轴正向 + Attack)、向上 HitBox 激活、空中向下反嵈。
|
||||
|
||||
### 前置条件
|
||||
|
||||
- 在玩家正上方位置放置测试用敌人(调高 Y 坐标)
|
||||
|
||||
### 步骤 A:地面上劈
|
||||
|
||||
1. 玩家站立地面
|
||||
2. 按住 **Move 上方向键**(`W` 或上方向键,Y 轴正向),同时按 **Attack 键**
|
||||
|
||||
**预期**:
|
||||
- 触发上劈动画(`AnimationConfigSO.UpAttack` 对应 Clip)
|
||||
- HitBox 在角色**正上方**激活(Scene 视图 Gizmos 可见)
|
||||
- 命中正上方敌人,敌人 HP 减少
|
||||
- 玩家仍停留地面,无明显上移(地面上劈无空中反嵈)
|
||||
|
||||
### 步骤 B:空中上劈与向下反嵈
|
||||
|
||||
1. 跳跃至空中
|
||||
2. 在空中按住 **Move 上方向键** + **Attack 键**
|
||||
|
||||
**预期**:
|
||||
- 触发上劈动画
|
||||
- **玩家 Y 轴速度减少约 3(向下反嵈)**,可在 Inspector → `Rigidbody2D.velocity.y` 观察到短暂下移
|
||||
- 上劈动画结束后进入 `FallState`
|
||||
- 命中敌人后敌人 HP 减少
|
||||
|
||||
### 步骤 C:Move Y 阈值边界
|
||||
|
||||
1. 空中仅按轻推上方向(Y 轴输入 < 0.5),同时按 **Attack 键**
|
||||
|
||||
**预期**:
|
||||
- 触发普通空中水平攻击,而非上劈(Y 阈值未达到 0.5 不判定为上劈)
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 地面上劈 HitBox 在上方 | Scene Gizmos 可见上方碰撞盒 | ☐ |
|
||||
| 空中上劈向下反嵈 | velocity.y 降低约 3,玩家微向下位移 | ☐ |
|
||||
| Y 轴阈值正确 | 轻推不触发上劈 | ☐ |
|
||||
| 动画正确 | 播放上劈专属 Clip | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-ABILITY-03:下劈与 Pogo 弹起
|
||||
|
||||
**目的**:验证下劈仅限空中触发、向下速度施加、命中 Pogo 弹起机制(重置空中能力)、未命中继续下落。
|
||||
|
||||
### 前置条件
|
||||
|
||||
- 玩家位于测试敌人正上方(高度 ≥ 3 格)
|
||||
- 测试敌人带有 `HurtBox` 组件
|
||||
|
||||
### 步骤 A:空中下劈命中 Pogo
|
||||
|
||||
1. 跳跃至测试敌人正上方
|
||||
2. 按住 **Move 下方向键**(`S` 或下方向键,Y 轴负向),同时按 **Attack 键**
|
||||
|
||||
**预期**:
|
||||
- 触发下劈动画(`AnimationConfigSO.DownAttack` 对应 Clip)
|
||||
- 玩家 Y 轴速度被设为约 `-18`,快速向下冲击
|
||||
- 下方 HitBox 激活
|
||||
- **命中敌人后**:玩家立即向上弹起(`Pogo Jump`,高度固定),与普通跳跃力度相似
|
||||
- Pogo 触发后空中冲刺次数重置(`_airDashUsed = false`)
|
||||
- Pogo 触发后空中跳跃次数重置(`AirJumpsLeft` 恢复为最大值)
|
||||
|
||||
### 步骤 B:空中下劈未命中
|
||||
|
||||
1. 在空旷区域(无敌人/无特殊物体)跳跃
|
||||
2. 空中使用下劈
|
||||
|
||||
**预期**:
|
||||
- 下劈动画和向下速度正常
|
||||
- **未命中任何目标**:玩家**不弹起**,继续受重力下落至地面
|
||||
- 落地后进入 `IdleState`
|
||||
|
||||
### 步骤 C:地面下劈无效
|
||||
|
||||
1. 玩家站立地面
|
||||
2. 按住 **Move 下方向键** + **Attack 键**
|
||||
|
||||
**预期**:
|
||||
- **触发普通地面水平攻击**(或无响应),不触发下劈
|
||||
- 下劈判定限制在空中状态 `!IsGrounded`
|
||||
|
||||
### 步骤 D:Pogo 后续接二段跳
|
||||
|
||||
1. 解锁二段跳(`PlayerStats.UnlockAbility(AbilityType.DoubleJump)`,可在 Inspector 勾选)
|
||||
2. 空中下劈命中 Pogo 弹起
|
||||
3. 弹起途中按 **Jump 键**
|
||||
|
||||
**预期**:
|
||||
- Pogo 重置空中跳跃次数,可再次使用二段跳
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 仅限空中触发 | 地面不触发下劈 | ☐ |
|
||||
| 向下冲击速度 | velocity.y ≈ -18 | ☐ |
|
||||
| 命中 Pogo 弹起 | 玩家向上弹起,高度固定 | ☐ |
|
||||
| Pogo 重置冲刺次数 | Pogo 后可再次空中冲刺 | ☐ |
|
||||
| Pogo 重置跳跃次数 | Pogo 后可再次二段跳(已解锁时) | ☐ |
|
||||
| 未命中不弹起 | 继续下落至地面 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-ABILITY-04:空中水平攻击
|
||||
|
||||
**目的**:验证空中水平攻击(`AirAttackState`)的触发条件、单次攻击、动画、结束返回 FallState。
|
||||
|
||||
### 步骤 A:空中普通攻击
|
||||
|
||||
1. 跳跃至空中
|
||||
2. 按 **Attack 键**(不按方向,或左右方向,Y 轴绝对值 < 0.5)
|
||||
|
||||
**预期**:
|
||||
- 触发空中水平攻击动画(`AnimationConfigSO.AirAttack`)
|
||||
- HitBox 在角色侧面激活(`AttackDirection.Air`)
|
||||
- 动画完成后进入 `FallState`,继续下落
|
||||
- 命中敌人 HP 减少
|
||||
|
||||
### 步骤 B:空中攻击不循环
|
||||
|
||||
1. 空中连续多次按 **Attack 键**
|
||||
|
||||
**预期**:
|
||||
- 空中攻击为单次,不进行连段
|
||||
- 每次须等动画结束后重新输入才能再次攻击
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 触发空中攻击动画 | 播放 AirAttack Clip | ☐ |
|
||||
| HitBox 侧面激活 | Scene Gizmos 可见侧面碰撞盒 | ☐ |
|
||||
| 结束后 FallState | 攻击完成后继续下落,不卡住 | ☐ |
|
||||
| 单次不循环 | 无法连续多段空中攻击 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-ABILITY-05:攻击取消窗口
|
||||
|
||||
**目的**:验证攻击后摇期间的取消窗口允许通过跳跃/冲刺打断攻击动作。
|
||||
|
||||
### 步骤 A:攻击后跳跃取消
|
||||
|
||||
1. 地面攻击触发第 1 段
|
||||
2. 在**有效帧结束后、后摇结束前**(取消窗口期间)按 **Jump 键**
|
||||
|
||||
**预期**:
|
||||
- 立即从 `AttackState` 转入 `JumpState`
|
||||
- 不必等待后摇完全结束即可起跳
|
||||
- 取消窗口关闭后(后摇末尾)按 Jump 则无法打断
|
||||
|
||||
### 步骤 B:攻击后冲刺取消
|
||||
|
||||
1. 地面攻击触发第 1 段
|
||||
2. 在取消窗口期间按 **Dash 键**
|
||||
|
||||
**预期**:
|
||||
- 立即进入 `DashState`,打断后摇
|
||||
|
||||
### 步骤 C:取消窗口外不可取消
|
||||
|
||||
1. 地面攻击触发第 1 段
|
||||
2. 在**前摇期间**(有效帧之前)按 **Jump 键**
|
||||
|
||||
**预期**:
|
||||
- 跳跃**无效**,必须等待取消窗口开放后才可取消
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 取消窗口内可跳跃 | Jump 立即打断后摇 | ☐ |
|
||||
| 取消窗口内可冲刺 | Dash 立即打断后摇 | ☐ |
|
||||
| 前摇内无法取消 | Jump/Dash 在前摇期间无响应 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-ABILITY-06:抓墙与高度记忆机制
|
||||
|
||||
**目的**:验证触发条件、无需持续按键维持、高度记忆防无限攀爬逻辑(正常模式静止 / 受限模式下滑)。
|
||||
|
||||
### 前置条件
|
||||
|
||||
- 在玩家右侧放置高度 ≥ 6 格的垂直墙壁(Layer=Ground)
|
||||
|
||||
### 步骤 A:基本抓墙触发
|
||||
|
||||
1. 向右跳跃,在空中接触右侧墙壁
|
||||
2. 按住 **Move 右方向键**(朝向墙壁方向,X 轴正向)
|
||||
|
||||
**预期**:
|
||||
- 进入 `WallSlideState`
|
||||
- 播放抓墙动画
|
||||
- **松开方向键**后角色仍保持抓墙状态(无需持续按键)
|
||||
|
||||
### 步骤 B:主动松墙
|
||||
|
||||
1. 抓墙状态下按下 **Move 左方向键**(反方向键)
|
||||
|
||||
**预期**:
|
||||
- 立即解除抓墙,进入 `FallState`
|
||||
|
||||
### 步骤 C:正常模式(抓墙高度 ≤ wallGrabY)—— 静止悬挂
|
||||
|
||||
1. 跳跃至墙壁中部,触发抓墙(记录 `wallGrabY`)
|
||||
2. 松开方向键,观察角色是否移动
|
||||
|
||||
**预期**:
|
||||
- 角色**静止悬挂**,Y 坐标不变
|
||||
- 可以在此状态触发蹬墙跳
|
||||
|
||||
### 步骤 D:受限模式(抓墙高度 > wallGrabY)—— 持续下滑且无法蹬墙跳
|
||||
|
||||
1. 在 C 的基础上,直接跳跃后**更高处再次贴墙**(同一面墙,但 Y 坐标高于 `wallGrabY`)
|
||||
2. 触发抓墙,松开方向键
|
||||
|
||||
**预期**:
|
||||
- 角色**持续向下滑动**(受限模式)
|
||||
- **无法触发蹬墙跳**(按 Jump 键无效)
|
||||
|
||||
> **💡 操作技巧**:从地面站立后直接跳上更高位置并贴墙,此时 Y > wallGrabY(因为上一次落地已重置),也可以先蹬墙跳到更高处再贴同一面墙来制造 Y > wallGrabY 的情况。
|
||||
|
||||
### 步骤 E:落地重置 wallGrabY
|
||||
|
||||
1. 完成 C 的抓墙后蹬墙跳离墙,落地
|
||||
2. 再次跳跃贴同一面墙(高度与 C 中相同)
|
||||
|
||||
**预期**:
|
||||
- 落地后 `wallGrabY` 已重置,该高度重新进入**正常模式**(静止悬挂,可蹬墙跳)
|
||||
|
||||
### 步骤 F:贴另一面墙重置 wallGrabY
|
||||
|
||||
1. 在步骤 C 中抓右侧墙壁后,蹬墙跳至左侧墙壁
|
||||
2. 在左侧墙壁比之前更高处抓墙
|
||||
|
||||
**预期**:
|
||||
- 切换到另一面墙壁时 `wallGrabY` 重置为新值
|
||||
- 该高度为正常模式(可静止,可蹬墙跳)
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 朝墙方向键触发抓墙 | 接触墙 + 朝墙输入 → WallSlideState | ☐ |
|
||||
| 无需持续按键 | 松开方向键后仍保持抓墙 | ☐ |
|
||||
| 反向键松墙 | FallState | ☐ |
|
||||
| 正常模式静止 | Y 坐标不变,不下滑 | ☐ |
|
||||
| 受限模式下滑 | 高于 wallGrabY 时持续下滑 | ☐ |
|
||||
| 受限模式无蹬墙跳 | Jump 键在受限模式无效 | ☐ |
|
||||
| 落地重置 | 落地后 wallGrabY 清零 | ☐ |
|
||||
| 换墙重置 | 抓另一面墙时记录新 wallGrabY | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-ABILITY-07:蹬墙跳(背墙跳 / 对墙跳)
|
||||
|
||||
**目的**:验证背墙跳(Away)和对墙跳(Toward)的方向判定与施力、视为第一段跳、可变高度支持。
|
||||
|
||||
### 前置条件
|
||||
|
||||
- 玩家处于正常抓墙状态(高度 ≤ wallGrabY,可触发蹬墙跳)
|
||||
|
||||
### 步骤 A:背墙跳(Away Jump)
|
||||
|
||||
1. 抓右侧墙壁,**不按任何水平方向键**(或按左键,即反方向)
|
||||
2. 按 **Jump 键**
|
||||
|
||||
**预期**:
|
||||
- 玩家朝**远离右墙方向**弹出(向左上方),播放 WallJumpAway 动画
|
||||
- 弹出后有约 0.2-0.3 秒的水平输入锁定(`_inputLockTimer`),锁定结束后可自由控制水平方向
|
||||
- `AirJumpsLeft` 恢复(视为第一段跳)
|
||||
|
||||
### 步骤 B:对墙跳(Toward Jump)
|
||||
|
||||
1. 抓右侧墙壁,按住 **Move 右方向键**(朝墙方向)
|
||||
2. 按 **Jump 键**
|
||||
|
||||
**预期**:
|
||||
- 玩家朝**朝向右墙方向弹出**(向右上方),施力偏向竖直,水平分量较小
|
||||
- 播放 WallJumpToward 动画
|
||||
- 同样视为第一段跳,`AirJumpsLeft` 恢复
|
||||
|
||||
### 步骤 C:蹬墙跳后可接二段跳(已解锁时)
|
||||
|
||||
1. 背墙跳后,在空中再次按 **Jump 键**
|
||||
|
||||
**预期**:
|
||||
- 若已解锁二段跳,此时可触发二段跳(`AirJumpsLeft > 0`)
|
||||
- 若未解锁,按 Jump 无效
|
||||
|
||||
### 步骤 D:蹬墙跳可变高度
|
||||
|
||||
1. 背墙跳后立即松开 **Jump 键**
|
||||
|
||||
**预期**:
|
||||
- 跳跃高度降低(同普通跳跃可变高度,提前松键截断上升速度)
|
||||
|
||||
### 步骤 E:受限模式下无蹬墙跳
|
||||
|
||||
1. 使玩家抓墙高度 > wallGrabY(受限模式)
|
||||
2. 按 **Jump 键**
|
||||
|
||||
**预期**:
|
||||
- 蹬墙跳不触发(按 Jump 在受限模式无反应)
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 背墙跳方向正确 | 远离墙壁斜上方弹出 | ☐ |
|
||||
| 对墙跳方向正确 | 朝墙壁斜上方弹出,更偏垂直 | ☐ |
|
||||
| 输入锁定(背墙跳) | 弹出后短时无法控制水平方向 | ☐ |
|
||||
| 视为第一段跳 | AirJumpsLeft 重置 | ☐ |
|
||||
| 可接二段跳 | 蹬墙跳后可再次按 Jump(已解锁) | ☐ |
|
||||
| 可变高度 | 提前松键降低弹跳高度 | ☐ |
|
||||
| 受限模式无效 | 受限抓墙时 Jump 无响应 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-ABILITY-08:二段跳
|
||||
|
||||
**目的**:验证二段跳的解锁门控、空中二次起跳、高度低于一段跳、可变高度支持。
|
||||
|
||||
### 前置条件
|
||||
|
||||
- 在 `PlayerStats` Inspector 中确认 `AbilityType.DoubleJump` 是否已解锁
|
||||
- **步骤 A 使用未解锁状态,步骤 B 及以后需先解锁**
|
||||
|
||||
### 步骤 A:未解锁时空中 Jump 无效
|
||||
|
||||
1. 确认二段跳**未解锁**(`AbilityFlags` 中无 `DoubleJump`)
|
||||
2. 跳跃至空中
|
||||
3. 在下落途中按 **Jump 键**
|
||||
|
||||
**预期**:
|
||||
- Jump 无效,不产生二次跳跃
|
||||
|
||||
### 步骤 B:解锁后空中二段跳
|
||||
|
||||
1. 在 Inspector 中勾选 `DoubleJump` 解锁(或调用 `Stats.UnlockAbility(DoubleJump)` 通过 Console)
|
||||
2. 跳跃至空中,在最高点前或下落时按 **Jump 键**
|
||||
|
||||
**预期**:
|
||||
- 触发二段跳动画(`DoubleJump` Clip)
|
||||
- 玩家再次向上弹起,高度明显低于一段跳
|
||||
- `AirJumpsLeft` 由 1 减为 0
|
||||
|
||||
3. 二段跳后再次按 **Jump 键**
|
||||
|
||||
**预期**:
|
||||
- 无响应(`AirJumpsLeft == 0`,无更多空中跳跃机会)
|
||||
|
||||
### 步骤 C:二段跳可变高度
|
||||
|
||||
1. 二段跳后立即松开 **Jump 键**
|
||||
|
||||
**预期**:
|
||||
- 跳跃高度降低(提前松键截断上升速度)
|
||||
|
||||
### 步骤 D:落地重置二段跳次数
|
||||
|
||||
1. 使用二段跳后落地
|
||||
2. 再次跳跃,在空中按 **Jump 键**
|
||||
|
||||
**预期**:
|
||||
- `AirJumpsLeft` 已重置(落地时 `ResetAirJumps()` 调用),可再次使用二段跳
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 未解锁时无效 | 空中 Jump 无响应 | ☐ |
|
||||
| 解锁后可二段跳 | 空中第二次 Jump 触发 | ☐ |
|
||||
| 二段跳高度低于一段 | 弹起高度明显更低 | ☐ |
|
||||
| 二段跳次数限制 | 第三次 Jump 无响应 | ☐ |
|
||||
| 可变高度 | 提前松键降低高度 | ☐ |
|
||||
| 落地后重置 | 落地后可再次二段跳 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-ABILITY-09:无敌冲刺
|
||||
|
||||
**目的**:验证无敌冲刺的解锁门控、冲刺期间无敌帧(不受伤害)、独立冷却机制。
|
||||
|
||||
### 前置条件
|
||||
|
||||
- 在 `PlayerStats` Inspector 中确认 `AbilityType.InvincibleDash` 是否已解锁
|
||||
- 在测试场景中放置一个会持续攻击的测试敌人(或使用调试工具手动给玩家造成伤害)
|
||||
|
||||
### 步骤 A:未解锁时冲刺无无敌帧
|
||||
|
||||
1. 确认无敌冲刺**未解锁**
|
||||
2. 在敌人攻击范围内进行冲刺
|
||||
|
||||
**预期**:
|
||||
- 冲刺期间受到伤害,进入 `HurtState`(基础冲刺无无敌帧)
|
||||
|
||||
### 步骤 B:解锁后冲刺无敌帧
|
||||
|
||||
1. 解锁 `InvincibleDash`
|
||||
2. 在敌人攻击方向上冲刺穿越
|
||||
|
||||
**预期**:
|
||||
- 冲刺期间**不受伤害**(`Stats.IsInvincible == true` 持续 `DashInvincibilityDuration` 约 0.20s)
|
||||
- 无敌期间 Inspector 中 `IsInvincible` 字段为 true
|
||||
- 无敌帧结束后恢复正常受伤判定
|
||||
|
||||
### 步骤 C:无敌冲刺独立冷却
|
||||
|
||||
1. 无敌冲刺后立即再次冲刺
|
||||
|
||||
**预期**:
|
||||
- 第 2 次冲刺可正常触发(冲刺本身冷却 ≈ 0.4s)
|
||||
- 但第 2 次冲刺**无无敌帧**(无敌帧有独立冷却,需等待更长时间恢复)
|
||||
- Inspector 中 `_invincibilityCooldownTimer > 0` 时不授予无敌
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 未解锁无无敌 | 冲刺中受伤进入 HurtState | ☐ |
|
||||
| 解锁后有无敌帧 | 冲刺期间 IsInvincible=true,不受伤 | ☐ |
|
||||
| 无敌帧持续时间 | 约 0.20s 后 IsInvincible 变 false | ☐ |
|
||||
| 独立冷却 | 短时间内第 2 次冲刺无无敌帧 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-ABILITY-10:三形态切换
|
||||
|
||||
**目的**:验证三形态切换的输入响应、武器/动画随形态更新、技能集随形态更新。
|
||||
|
||||
### 前置条件
|
||||
|
||||
- `FormConfigSO` 中三个 FormSO(天魂/地魂/命魂)已配置,各有不同武器 Prefab
|
||||
- `SkillManager._formSkillSets` 数组已填写三形态对应的技能 SO
|
||||
- `WeaponManager` 已绑定 `FormController`
|
||||
|
||||
### 步骤 A:切换至天魂(SwitchSkyForm)
|
||||
|
||||
1. 进入 Play Mode,玩家默认形态
|
||||
2. 按 **SwitchSkyForm 键**(默认 `1`)
|
||||
|
||||
**预期**:
|
||||
- `FormController.CurrentForm.formType == TianHun`
|
||||
- 武器切换到天魂武器 Prefab(`WeaponManager.ActiveWeapon` 更新)
|
||||
- `SkillManager` 中 `_soulSkill`、`_spirit1`、`_spirit2` 替换为天魂对应技能
|
||||
- HUD 形态颜色/图标更新(若已实现 UI 订阅)
|
||||
- Console 无 Error
|
||||
|
||||
### 步骤 B:切换至地魂(SwitchEarthForm)
|
||||
|
||||
1. 按 **SwitchEarthForm 键**(默认 `2`)
|
||||
|
||||
**预期**:同 A,武器和技能集更换为地魂版本。
|
||||
|
||||
### 步骤 C:切换至命魂(SwitchDeathForm)
|
||||
|
||||
1. 按 **SwitchDeathForm 键**(默认 `3`)
|
||||
|
||||
**预期**:同 A,武器和技能集更换为命魂版本。
|
||||
|
||||
### 步骤 D:切换后攻击使用新武器
|
||||
|
||||
1. 切换形态后立即攻击
|
||||
|
||||
**预期**:
|
||||
- HitBox 来自新形态武器实例
|
||||
- 伤害值使用新形态武器 `WeaponSO.Damage`
|
||||
|
||||
### 步骤 E:重复切换同一形态
|
||||
|
||||
1. 当前已在天魂,再次按 **SwitchSkyForm 键**
|
||||
|
||||
**预期**:
|
||||
- 无副作用(不重复切换,也不报错)
|
||||
- `OnFormChanged` 事件视实现可能不触发(取决于 `FormController` 是否有相同形态判断)
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 形态正确更新 | CurrentForm.formType 对应键值 | ☐ |
|
||||
| 武器随形态切换 | WeaponManager.ActiveWeapon 更新 | ☐ |
|
||||
| 技能集随形态切换 | SkillManager _soulSkill/_spirit1/_spirit2 更新 | ☐ |
|
||||
| 攻击使用新武器 | 新武器 HitBox 生效 | ☐ |
|
||||
| Console 无 Error | 0 个红色 Error | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-ABILITY-11:三套资源系统
|
||||
|
||||
**目的**:验证灵力、魄元、灵泉次数三者相互独立、各自消耗/恢复逻辑。
|
||||
|
||||
### 步骤 A:灵力(SoulPower)—— 攻击积累
|
||||
|
||||
1. 在 Inspector 中观察 `PlayerStats._currentSoulPower`(初始 = 0)
|
||||
2. 攻击命中测试敌人多次
|
||||
|
||||
**预期**:
|
||||
- 每次命中后 `_currentSoulPower` 增加
|
||||
- 不影响 `_currentSpiritPower` 和 `_currentSpringCharges`
|
||||
|
||||
3. 消耗至 0(触发魂技能,见 MT-ABILITY-14)
|
||||
|
||||
**预期**:
|
||||
- `_currentSoulPower` 降低
|
||||
- `_currentSpiritPower` 不变
|
||||
|
||||
### 步骤 B:魄元(SpiritPower)—— 时间恢复
|
||||
|
||||
1. 使 `_currentSpiritPower` 低于上限(触发魄技能消耗)
|
||||
2. 等待约 3 秒
|
||||
|
||||
**预期**:
|
||||
- `_currentSpiritPower` 每秒自动增加(`SpiritRegenRate` 配置值)
|
||||
- 不影响 `_currentSoulPower` 和 `_currentSpringCharges`
|
||||
|
||||
### 步骤 C:灵泉次数(SpringCharges)—— 三者独立
|
||||
|
||||
1. 攻击积累灵力,同时魄元自然恢复,灵泉次数保持不变
|
||||
|
||||
**预期**:
|
||||
- 三个字段**完全独立变动**,互无影响
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 灵力随攻击命中增加 | 每次命中 SoulPower 增加 | ☐ |
|
||||
| 魄元随时间自动恢复 | 未满时 SpiritPower 逐秒增加 | ☐ |
|
||||
| 三者独立 | 任一变化不影响其他两项 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-ABILITY-12:灵泉使用
|
||||
|
||||
**目的**:验证灵泉使用的前置条件(地面 + 有充能)、HP 恢复、Overlay 动画、充能消耗。
|
||||
|
||||
### 前置条件
|
||||
|
||||
- `PlayerStats._currentSpringCharges > 0`(至少 1 次充能)
|
||||
- 玩家 HP 不满(降低 HP 以观察回复效果)
|
||||
|
||||
### 步骤 A:地面使用灵泉
|
||||
|
||||
1. 玩家站立地面(`IsGrounded == true`)
|
||||
2. HP 低于上限
|
||||
3. 按 **UseSpring 键**(默认 `E`)
|
||||
|
||||
**预期**:
|
||||
- 播放 Overlay 动画(Layer 1 叠加于 Base Layer 之上),不打断移动动画
|
||||
- `_currentSpringCharges` 减少 1
|
||||
- `PlayerStats.CurrentHP` 增加(`SpringHealAmount` 配置值)
|
||||
- 动画结束后玩家返回正常状态
|
||||
|
||||
### 步骤 B:空中使用灵泉无效
|
||||
|
||||
1. 跳跃至空中(`IsGrounded == false`)
|
||||
2. 按 **UseSpring 键**
|
||||
|
||||
**预期**:
|
||||
- **无响应**(`OnUseSpring` 检测到非地面,直接返回)
|
||||
|
||||
### 步骤 C:充能为 0 时无效
|
||||
|
||||
1. 确认 `_currentSpringCharges == 0`
|
||||
2. 地面按 **UseSpring 键**
|
||||
|
||||
**预期**:
|
||||
- **无响应**(充能为 0 时不进入 `SpringState`)
|
||||
|
||||
### 步骤 D:Overlay 动画不打断移动
|
||||
|
||||
1. 地面移动中按 **UseSpring 键**
|
||||
|
||||
**预期**:
|
||||
- 使用灵泉 Overlay 动画在 Layer 1 播放
|
||||
- 玩家可继续移动(Layer 0 移动动画继续)
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 地面使用成功 | HP 增加,ChargesCount 减 1 | ☐ |
|
||||
| 空中无效 | 空中按键无任何响应 | ☐ |
|
||||
| 充能为 0 无效 | 无充能时按键无响应 | ☐ |
|
||||
| Overlay 动画叠加 | Layer 1 动画播放,Layer 0 不中断 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-ABILITY-13:灵泉充能(击杀积累)
|
||||
|
||||
**目的**:验证 `SpringSystem` 通过 EVT_EnemyDied 事件积累点数、达到阈值后自动增加灵泉次数。
|
||||
|
||||
### 前置条件
|
||||
|
||||
- 场景内有可被击杀的测试敌人
|
||||
- `SpringSystem` 组件挂在玩家或管理器 GameObject 上,`_onEnemyDied` 字段已绑定 `EVT_EnemyDied` SO 事件资产
|
||||
- 在 Inspector 中调低 `PlayerStatsSO.SpringKillThreshold`(如设为 2)以方便观察
|
||||
|
||||
### 步骤 A:击杀积累点数
|
||||
|
||||
1. 攻击并击杀第 1 个测试敌人
|
||||
|
||||
**预期**:
|
||||
- EVT_EnemyDied 事件触发
|
||||
- `PlayerStats._springKillPoints` 增加 1(可在 Inspector 观察)
|
||||
|
||||
2. 再次击杀一个敌人
|
||||
|
||||
**预期**:
|
||||
- `_springKillPoints` 增加至阈值
|
||||
- 自动调用 `RestoreSpringCharges(1)`,`_currentSpringCharges` 增加 1
|
||||
- `_springKillPoints` 清零
|
||||
|
||||
### 步骤 B:充能上限限制
|
||||
|
||||
1. 将 `_currentSpringCharges` 调至上限(`MaxSpringCharges`)
|
||||
2. 继续击杀敌人
|
||||
|
||||
**预期**:
|
||||
- `_springKillPoints` 继续积累
|
||||
- 当 `_currentSpringCharges == MaxSpringCharges` 时,`RestoreSpringCharges` 不超过上限
|
||||
|
||||
### 步骤 C:存档点恢复次数至上限
|
||||
|
||||
1. 消耗 1 次灵泉
|
||||
2. 与测试场景中的存档点 GameObject 交互(或调用 `Stats.RestoreOnSave()`)
|
||||
|
||||
**预期**:
|
||||
- `_currentSpringCharges` 恢复至 `MaxSpringCharges`
|
||||
- `_springKillPoints` 清零(存档点重置积累点)
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| EVT_EnemyDied 触发点数积累 | 击杀后 _springKillPoints 增加 | ☐ |
|
||||
| 达阈值自动 +1 次充能 | 积累满后 ChargesCount 增加,点数清零 | ☐ |
|
||||
| 充能不超上限 | MaxSpringCharges 限制 | ☐ |
|
||||
| 存档点恢复至上限 | ChargesCount = MaxSpringCharges | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-ABILITY-14:魂技能施放
|
||||
|
||||
**目的**:验证 `SkillManager` 响应 SoulSkill 输入、消耗灵力、播放技能动画、冷却限制。
|
||||
|
||||
### 前置条件
|
||||
|
||||
- 当前形态已配置 `FormSkillSet.soulSkill`(有效的 `FormSkillSO` 资产)
|
||||
- `PlayerStats._currentSoulPower` 达到技能消耗量以上
|
||||
|
||||
### 步骤 A:灵力充足时施放魂技能
|
||||
|
||||
1. 攻击敌人积累足够灵力(`_currentSoulPower ≥ soulSkill.Cost`)
|
||||
2. 按 **SoulSkill 键**(默认 `Q`)
|
||||
|
||||
**预期**:
|
||||
- 技能动画播放(`FormSkillSO.animationClip`)
|
||||
- `_currentSoulPower` 减少 `soulSkill.Cost`
|
||||
- 技能特效实例化(若 `FormSkillSO` 中配置了 VFX)
|
||||
- 技能进入冷却(`_cooldowns[soulSkill]` 开始计时)
|
||||
|
||||
### 步骤 B:冷却中无法再次施放
|
||||
|
||||
1. 技能施放后立即再次按 **SoulSkill 键**
|
||||
|
||||
**预期**:
|
||||
- 无响应(冷却剩余 `> 0` 时拒绝施放)
|
||||
|
||||
### 步骤 C:灵力不足时无法施放
|
||||
|
||||
1. `_currentSoulPower < soulSkill.Cost`(可通过消耗后立即尝试)
|
||||
2. 按 **SoulSkill 键**
|
||||
|
||||
**预期**:
|
||||
- 无响应(灵力不足,`SkillManager` 内部判断拒绝)
|
||||
|
||||
### 步骤 D:形态切换后技能变更
|
||||
|
||||
1. 切换到另一个形态
|
||||
2. 按 **SoulSkill 键**
|
||||
|
||||
**预期**:
|
||||
- 施放的是**新形态**的魂技能(不同动画、不同效果)
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 灵力充足时施放成功 | 动画播放,SoulPower 减少 | ☐ |
|
||||
| 冷却中无法施放 | 再次按键无响应 | ☐ |
|
||||
| 灵力不足无法施放 | 资源不足时无响应 | ☐ |
|
||||
| 形态切换后使用新技能 | 切换形态后施放新形态魂技能 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-ABILITY-15:魄技能施放(技能 1 / 2)
|
||||
|
||||
**目的**:验证 `SpiritSkill1`/`SpiritSkill2` 的消耗魄元、独立冷却、形态切换更新。
|
||||
|
||||
### 前置条件
|
||||
|
||||
- 当前形态已配置 `FormSkillSet.spiritSkill1` 和 `spiritSkill2`
|
||||
- `PlayerStats._currentSpiritPower` 充足
|
||||
|
||||
### 步骤 A:施放魄技能 1
|
||||
|
||||
1. 按 **SpiritSkill1 键**(默认 `R`,长按触发 Start,松开触发 Cancelled)
|
||||
|
||||
**预期**:
|
||||
- 技能动画播放
|
||||
- `_currentSpiritPower` 减少 `spiritSkill1.Cost`
|
||||
- 若技能为蓄力型,按住触发蓄力动画,松开触发释放
|
||||
|
||||
### 步骤 B:施放魄技能 2
|
||||
|
||||
1. 按 **SpiritSkill2 键**(默认 `F`)
|
||||
|
||||
**预期**:
|
||||
- 施放魄技能 2(不同动画/效果)
|
||||
- 消耗独立的魄元量(`spiritSkill2.Cost`)
|
||||
|
||||
### 步骤 C:技能 1 和技能 2 冷却独立
|
||||
|
||||
1. 依次施放技能 1 和技能 2
|
||||
|
||||
**预期**:
|
||||
- 技能 1 冷却中**不影响**技能 2 的施放(`_cooldowns` 字典各自独立计时)
|
||||
|
||||
### 步骤 D:魄元自动恢复后可再次施放
|
||||
|
||||
1. 魄技能消耗魄元后等待恢复
|
||||
2. 再次施放
|
||||
|
||||
**预期**:
|
||||
- 魄元恢复至足够后可再次施放(`SpiritRegenRate` 自动回复)
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 技能 1 施放成功 | 动画播放,SpiritPower 减少 | ☐ |
|
||||
| 技能 2 施放成功 | 不同动画,SpiritPower 减少 | ☐ |
|
||||
| 冷却独立 | 技能 1 冷却不阻止技能 2 | ☐ |
|
||||
| 形态切换后使用新技能 | 新形态的魄技能 1/2 生效 | ☐ |
|
||||
| 魄元恢复后可再施放 | 等待后 SpiritPower 恢复,可用 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## MT-ABILITY-16:弹反系统
|
||||
|
||||
**目的**:验证 `ParrySystem`/`ParryState` 的触发窗口、弹反成功判定、灵力奖励、护盾恢复。
|
||||
|
||||
### 前置条件
|
||||
|
||||
- 场景中有能发动近战攻击的测试敌人(或使用 Debug 工具手动触发弹反机会)
|
||||
- `ParrySystem` 已绑定 `InputReaderSO`
|
||||
- `ShieldComponent` 已配置(若需验证护盾恢复)
|
||||
|
||||
### 步骤 A:弹反触发与成功
|
||||
|
||||
1. 敌人发起攻击时,在攻击即将命中前按下**弹反键**(`Parry`,默认 `K`)
|
||||
|
||||
**预期**:
|
||||
- 进入 `ParryState`,播放弹反动画
|
||||
- 若在弹反窗口内成功拦截敌人攻击:
|
||||
- 弹反命中判定成功,敌人受到弹反反馈(硬直或被弹飞)
|
||||
- `PlayerStats._currentSoulPower` 增加(`ParryInfo.SoulGained`)
|
||||
- `ShieldComponent.OnParrySuccess()` 调用(护盾恢复)
|
||||
- 玩家**不受任何伤害**(弹反期间有效)
|
||||
|
||||
### 步骤 B:弹反窗口外受伤
|
||||
|
||||
1. 弹反动画开始后**等待窗口结束**(约 0.2-0.3s 后),此时再受到敌人攻击
|
||||
|
||||
**预期**:
|
||||
- 玩家正常受伤,进入 `HurtState`(弹反窗口已关闭)
|
||||
|
||||
### 步骤 C:空弹反(无敌人攻击)
|
||||
|
||||
1. 地面不受攻击时按弹反键
|
||||
|
||||
**预期**:
|
||||
- 播放弹反动画
|
||||
- 弹反窗口结束后自动返回 `IdleState`
|
||||
- 无报错,无副作用
|
||||
|
||||
| 检查点 | 期望 | ✓ |
|
||||
|--------|------|---|
|
||||
| 弹反动画播放 | ParryState 正确进入 | ☐ |
|
||||
| 弹反成功不受伤 | 窗口内拦截,玩家 HP 不减少 | ☐ |
|
||||
| 灵力奖励 | SoulPower 增加 SoulGained 值 | ☐ |
|
||||
| 护盾恢复 | ShieldComponent.OnParrySuccess 调用 | ☐ |
|
||||
| 窗口外受伤 | 窗口关闭后受攻击进入 HurtState | ☐ |
|
||||
| 空弹反无副作用 | 无敌人攻击时弹反后正常返回 Idle | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:Inspector 调试辅助字段
|
||||
|
||||
以下字段在 Play Mode 下可在 Inspector 中实时观察,便于调试:
|
||||
|
||||
| GameObject | 组件 | 字段 | 含义 |
|
||||
|-----------|------|------|------|
|
||||
| Player | `PlayerController` | `_dbg_CurrentState` | 当前 FSM 状态名 |
|
||||
| Player | `PlayerController` | `_dbg_IsGrounded` | 是否落地 |
|
||||
| Player | `PlayerController` | `_dbg_AirJumpsLeft` | 剩余空中跳跃次数 |
|
||||
| Player | `PlayerController` | `_dbg_CanDash` | 当前是否可冲刺 |
|
||||
| Player | `PlayerController` | `_dbg_IsInvincible` | 当前是否无敌 |
|
||||
| Player | `PlayerStats` | `_currentHP` | 当前 HP |
|
||||
| Player | `PlayerStats` | `_currentSoulPower` | 当前灵力 |
|
||||
| Player | `PlayerStats` | `_currentSpiritPower` | 当前魄元 |
|
||||
| Player | `PlayerStats` | `_currentSpringCharges` | 当前灵泉次数 |
|
||||
| Player | `PlayerStats` | `_springKillPoints` | 当前灵泉积累点 |
|
||||
| Player | `FormController` | `CurrentForm` | 当前形态 SO |
|
||||
| Player | `SkillManager` | `_soulSkill` | 当前魂技能 |
|
||||
| Player | `SkillManager` | `_spirit1` | 当前魄技能 1 |
|
||||
| Player | `SkillManager` | `_spirit2` | 当前魄技能 2 |
|
||||
|
||||
---
|
||||
|
||||
## 附录 B:常见问题排查
|
||||
|
||||
| 问题现象 | 可能原因 | 排查步骤 |
|
||||
|---------|---------|---------|
|
||||
| 攻击无 HitBox 效果 | WeaponManager 武器未实例化 | Inspector 检查 `WeaponManager.ActiveHitBoxInstance` 是否为 null |
|
||||
| 形态切换后技能未更新 | `SkillManager._formController` 未赋值 | 确认 Inspector 中已拖入 `FormController` 引用,并检查 `_formSkillSets` 数组长度 ≥ 3 |
|
||||
| 灵泉使用无响应 | `UseSpringEvent` 未绑定 | 检查 `InputReaderSO` 中 UseSpring Action 名称拼写,确认 PlayerController 已订阅 |
|
||||
| 抓墙后立即滑落 | WallDetector 未检测到墙 | 检查墙壁 Layer 是否为 `Ground`,`PlayerWallDetector` 的 `wallLayer` 掩码包含该 Layer |
|
||||
| 蹬墙跳无效 | 受限模式(高于 wallGrabY) | 在受限模式(下滑状态)时蹬墙跳设计上不可用,属预期行为 |
|
||||
| 魄技能冷却独立验证失败 | 技能 SO 资产共用同一实例 | 确认技能 1、技能 2 使用的是**不同** `FormSkillSO` 资产(不同 `.asset` 文件) |
|
||||
| Pogo 未弹起 | DownAttackState `OnDownHitConfirmed` 未订阅 | 检查 `PlayerCombat._currentHitBoxInstance` 是否正确订阅,Console 查看 `HandleWeaponChanged` 是否调用 |
|
||||
| Console 出现 NullReferenceException | Inspector 中某 SO/组件字段未赋值 | 运行时在 `PlayerController.Awake` 的 `Debug.Assert` 输出中查看具体缺失字段 |
|
||||
@@ -0,0 +1,597 @@
|
||||
# Zeling v2 · 全阶段验收测试文档
|
||||
|
||||
> **文档版本**:1.0
|
||||
> **覆盖范围**:Phase 0 → Phase 4(`Assets/Scripts/` 全量代码)
|
||||
> **Unity 版本**:2022.3 LTS
|
||||
> **前置文档**:`Docs/Verification/Phase1_Verification_Guide.md`(Phase 0/1 详细步骤,本文在此基础上新增 Phase 2–4 验收)
|
||||
> **测试环境**:Unity Editor Play Mode;测试房间场景(`TestRoom.unity`);Persistent 场景同时加载
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [测试前环境检查清单](#1-测试前环境检查清单)
|
||||
2. [阶段验收状态总览](#2-阶段验收状态总览)
|
||||
3. [P0 基础设施(快速回归)](#3-p0-基础设施快速回归)
|
||||
4. [P1 核心系统(快速回归)](#4-p1-核心系统快速回归)
|
||||
5. [P2 核心玩法扩展](#5-p2-核心玩法扩展)
|
||||
- [5.1 玩家 FSM 完整状态](#51-玩家-fsm-完整状态)
|
||||
- [5.2 形态切换与武器系统](#52-形态切换与武器系统)
|
||||
- [5.3 完整连击链](#53-完整连击链)
|
||||
- [5.4 弹反系统](#54-弹反系统)
|
||||
- [5.5 护盾与霸体(Poise)](#55-护盾与霸体poise)
|
||||
- [5.6 状态效果系统](#56-状态效果系统)
|
||||
- [5.7 VFX 与 Feedback 扩展](#57-vfx-与-feedback-扩展)
|
||||
- [5.8 音频 Mixer 快照](#58-音频-mixer-快照)
|
||||
- [5.9 难度系统](#59-难度系统)
|
||||
- [5.10 敌人扩展(远程 / 飞行 / Boss 骨架)](#510-敌人扩展远程--飞行--boss-骨架)
|
||||
6. [P3 世界与进程系统](#6-p3-世界与进程系统)
|
||||
- [6.1 房间切换与场景管理](#61-房间切换与场景管理)
|
||||
- [6.2 世界互动机关](#62-世界互动机关)
|
||||
- [6.3 WorldStateRegistry 持久化](#63-worldstateregistry-持久化)
|
||||
- [6.4 液态谜题系统](#64-液态谜题系统)
|
||||
- [6.5 能力解锁与能力门禁](#65-能力解锁与能力门禁)
|
||||
- [6.6 护符与装备系统](#66-护符与装备系统)
|
||||
- [6.7 技能管理器与修改器](#67-技能管理器与修改器)
|
||||
- [6.8 任务系统](#68-任务系统)
|
||||
- [6.9 挑战房间](#69-挑战房间)
|
||||
- [6.10 地图模块](#610-地图模块)
|
||||
- [6.11 商店系统](#611-商店系统)
|
||||
7. [P4 内容与完善](#7-p4-内容与完善)
|
||||
- [7.1 Boss 技能系统](#71-boss-技能系统)
|
||||
- [7.2 对话与叙事模块](#72-对话与叙事模块)
|
||||
- [7.3 事件链与过场](#73-事件链与过场)
|
||||
- [7.4 UI 完整面板](#74-ui-完整面板)
|
||||
- [7.5 按键重绑定](#75-按键重绑定)
|
||||
- [7.6 本地化系统](#76-本地化系统)
|
||||
- [7.7 成就系统](#77-成就系统)
|
||||
- [7.8 平台支撑(Steam / 存档云同步)](#78-平台支撑steam--存档云同步)
|
||||
- [7.9 支撑模块(调试 / 无卡点 / 无障碍 / 速通)](#79-支撑模块调试--无卡点--无障碍--速通)
|
||||
8. [全流程端到端验证](#8-全流程端到端验证)
|
||||
9. [验收通过标准](#9-验收通过标准)
|
||||
10. [缺陷登记表](#10-缺陷登记表)
|
||||
|
||||
---
|
||||
|
||||
## 1. 测试前环境检查清单
|
||||
|
||||
在任意测试用例运行前,必须确认以下项全部通过:
|
||||
|
||||
| # | 检查项 | 验证方式 | 状态 |
|
||||
|---|--------|---------|------|
|
||||
| ENV-01 | Console 无红色 Error | `Window → Console` 错误数 = 0 | ☐ |
|
||||
| ENV-02 | Addressables 已构建 | `Window → Addressables → Groups → Build → New Build → Default` | ☐ |
|
||||
| ENV-03 | NavSurface 已烘焙 | Scene 视图可见蓝绿导航网格 Gizmo | ☐ |
|
||||
| ENV-04 | Physics2D Layer 矩阵已配置 | `Edit → Project Settings → Physics 2D`,PlayerHitBox ↔ EnemyHurtBox 碰撞开启 | ☐ |
|
||||
| ENV-05 | SO 事件资产存在 | `Assets/Data/Events/` 下有 `EVT_*.asset` 文件 | ☐ |
|
||||
| ENV-06 | Persistent 场景已加入 Build Settings | `File → Build Settings`,Persistent.unity 位于列表第一位 | ☐ |
|
||||
| ENV-07 | Domain Reload 设置确认 | `Edit → Project Settings → Editor → Enter Play Mode Options`,勾选 `Disable Domain Reload` 时验证静态字典缓存行为 | ☐ |
|
||||
|
||||
> **注意**:ENV-07 主要影响 `BossSkillExecutor._wfsCache`、`WaitForSecondsCache` 等静态字典。若 Disable Domain Reload 开启,每次 Play Mode 前需手动触发清空(或保持默认:不禁用 Domain Reload)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 阶段验收状态总览
|
||||
|
||||
| Phase | 代码状态 | 资产状态 | 验收状态 |
|
||||
|-------|---------|---------|---------|
|
||||
| P0 基础设施 | ✅ 完成 | ✅ 完成 | ☐ 待验 |
|
||||
| P1 垂直切片 | ✅ 完成 | ⚠️ 需场景搭建 | ☐ 待验 |
|
||||
| P2 核心玩法 | ✅ 完成 | ⚠️ VFX 资产待填充 | ☐ 待验 |
|
||||
| P3 世界进程 | ✅ 完成 | ⚠️ Prefab 装配待完成 | ☐ 待验 |
|
||||
| P4 内容完善 | ✅ 完成 | ✅ 完成 | ☐ 待验 |
|
||||
|
||||
---
|
||||
|
||||
## 3. P0 基础设施(快速回归)
|
||||
|
||||
> 此阶段已在 Phase1_Verification_Guide.md V1–V3 详细验证,此处仅快速回归确认。
|
||||
|
||||
| TC-ID | 测试项 | 步骤 | 期望结果 | ☐ |
|
||||
|-------|-------|------|---------|---|
|
||||
| TC-P0-01 | asmdef 编译无错 | Unity 菜单 → Assets → Open C# Project,IDE 执行 Rebuild | 0 个编译错误 | ☐ |
|
||||
| TC-P0-02 | ServiceLocator 注册/获取 | Play 进入 Persistent 场景,Console 搜索 `[ServiceLocator]` | 无 `NullReferenceException`,核心服务(SaveManager / ObjectPool / AudioManager / SceneLoader)注册成功 | ☐ |
|
||||
| TC-P0-03 | SO 事件系统 | 在 Inspector 打开任意 `EVT_*.asset`,点击 Raise 按钮 | Console 输出对应频道事件(开启 EventBusMonitor Window) | ☐ |
|
||||
| TC-P0-04 | Addressables 加载 | Play Mode 下 `AssetLoader.LoadAsync` 通过 AddressKey 加载一个 Prefab | Prefab 实例化无 `InvalidKeyException` | ☐ |
|
||||
| TC-P0-05 | SaveManager 读写 | 调用 `SaveManager.SaveAsync(slot:0)`,Exit Play Mode,再次 Enter Play Mode 调用 `LoadAsync(0)` | 存档文件存在于磁盘,加载后 Console 无 checksum 错误 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## 4. P1 核心系统(快速回归)
|
||||
|
||||
> 详细验证步骤见 `Phase1_Verification_Guide.md`,此处列出快速回归清单。
|
||||
|
||||
| TC-ID | 测试项 | 期望结果 | ☐ |
|
||||
|-------|-------|---------|---|
|
||||
| TC-P1-01 | 输入系统 | WASD/方向键控制玩家移动,Space 跳跃,Z 攻击,Esc 暂停,无按键漏检 | ☐ |
|
||||
| TC-P1-02 | Animancer FSM | Idle→Run→Jump→Fall 状态转换流畅,无动画卡帧 | ☐ |
|
||||
| TC-P1-03 | PlayerMovement 物理 | 地面正常重力,跳跃弧线正确,土狼时间(Coyote)有效 | ☐ |
|
||||
| TC-P1-04 | 战斗管道 | 攻击敌人触发伤害数字,敌人 HP 减少;受到敌人攻击,玩家 HP 减少 | ☐ |
|
||||
| TC-P1-05 | 敌人寻路 AI | 敌人巡逻,玩家进入视野后追击并攻击 | ☐ |
|
||||
| TC-P1-06 | 存档点完整流程 | 与 SavePoint 交互 → 触发存档 → 死亡 → 复活在存档点 → HP/灵力满值 | ☐ |
|
||||
| TC-P1-07 | HUD 显示 | HP 条/灵力条/Geo 数量随游戏状态正确更新 | ☐ |
|
||||
| TC-P1-08 | 相机系统 | 玩家进入 CameraTriggerZone 时相机平滑切换到目标房间 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## 5. P2 核心玩法扩展
|
||||
|
||||
### 5.1 玩家 FSM 完整状态
|
||||
|
||||
| TC-ID | 测试项 | 操作步骤 | 期望结果 | ☐ |
|
||||
|-------|-------|---------|---------|---|
|
||||
| TC-P2-01 | DashState 冲刺 | 地面状态按冲刺键(默认 LeftShift) | 玩家水平快速位移,冲刺期间无敌帧生效(敌人攻击不触发 HurtState),结束后自然过渡 Idle/Run | ☐ |
|
||||
| TC-P2-02 | AerialDashState 空中冲刺 | 跳跃后按冲刺键 | 空中水平冲刺,消耗空中冲刺次数;落地后次数重置 | ☐ |
|
||||
| TC-P2-03 | WallSlideState 蹬墙下滑 | 跳跃后贴近墙壁按住方向 | 下滑速度减缓(`wallSlideSpeed`),Wall Slide 动画播放 | ☐ |
|
||||
| TC-P2-04 | WallJumpState 蹬墙跳 | WallSlide 状态按跳跃键 | 玩家弹离墙壁(`wallJumpForce`),方向翻转 | ☐ |
|
||||
| TC-P2-05 | HurtState 受击硬直 | 被攻击未被霸体保护 | 受击动画播放,持续 `hurtDuration`,期间不可输入 | ☐ |
|
||||
| TC-P2-06 | DeadState 死亡冻结 | HP 归零 | 死亡动画,物理冻结(Rigidbody2D.constraints),死亡屏幕出现 | ☐ |
|
||||
| TC-P2-07 | SpringState 使用灵泉 | 持有灵泉时按治疗键 | 治疗动画,HP 恢复,灵泉数量-1,不可打断(HurtState 优先级低于 SpringState 硬直期) | ☐ |
|
||||
|
||||
---
|
||||
|
||||
### 5.2 形态切换与武器系统
|
||||
|
||||
| TC-ID | 测试项 | 操作步骤 | 期望结果 | ☐ |
|
||||
|-------|-------|---------|---------|---|
|
||||
| TC-P2-08 | 三形态切换 | 按形态切换键循环(Sky→Earth→Death→Sky) | 每次切换:玩家调色板更新(Palette Swap),武器 HitBox 伤害来源刷新,HUD 形态图标同步 | ☐ |
|
||||
| TC-P2-09 | FormController 事件广播 | 切换形态后检查 Console(开启 EventBusMonitor) | `EVT_FormChanged` 频道触发,`EVT_SkillSetChanged` 频道触发 | ☐ |
|
||||
| TC-P2-10 | WeaponManager 武器数据刷新 | 切换形态后攻击敌人 | 不同形态输出不同 DamageSource 的伤害值(需三形态武器 SO 伤害值不同) | ☐ |
|
||||
| TC-P2-11 | SaveData 形态持久化 | 切换到 Earth 形态 → 存档 → 重载 | 重载后当前形态仍为 Earth | ☐ |
|
||||
|
||||
---
|
||||
|
||||
### 5.3 完整连击链
|
||||
|
||||
| TC-ID | 测试项 | 操作步骤 | 期望结果 | ☐ |
|
||||
|-------|-------|---------|---------|---|
|
||||
| TC-P2-12 | 地面 3 段连击 | 连续按 3 次攻击(间隔在 Combo 窗口内) | 播放 Attack1 → Attack2 → Attack3 三段动画,第 3 段结束后返回 Idle,不可延续第 4 段 | ☐ |
|
||||
| TC-P2-13 | 连击超时重置 | 按 1 次攻击后等待超时 | Combo 计数重置到 Attack1,再按攻击仍从头开始 | ☐ |
|
||||
| TC-P2-14 | 空中攻击 | 跳跃后按攻击键 | 播放 AirAttack 动画,HitBox 激活(正前方) | ☐ |
|
||||
| TC-P2-15 | 下劈(DownAttack) | 跳跃后向下+攻击 | 播放 DownAttack 动画,检测到下方敌人时反弹(`trampolineForce`) | ☐ |
|
||||
| TC-P2-16 | 上劈(UpAttack) | 地面状态向上+攻击 | 播放 UpAttack 动画,HitBox 激活(正上方) | ☐ |
|
||||
| TC-P2-17 | HitBox 激活时序 | 使用 Unity Physics Debugger 或 Console 输出 | HitBox 仅在动画攻击帧 Active,其他帧 Deactivate(无穿透判定) | ☐ |
|
||||
| TC-P2-18 | 命中灵力增加 | 攻击命中敌人 | 玩家灵力条(SoulPower)增加,HUD 同步更新 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
### 5.4 弹反系统
|
||||
|
||||
| TC-ID | 测试项 | 操作步骤 | 期望结果 | ☐ |
|
||||
|-------|-------|---------|---------|---|
|
||||
| TC-P2-19 | 弹反触发 | 弹反键(默认 Q)在敌人攻击命中前 `parryWindow` 秒内按下 | 弹反成功:敌人进入受击僵直(`parriedStunDuration`),玩家不扣血,ParryFlash VFX 播放 | ☐ |
|
||||
| TC-P2-20 | 弹反窗口外按键 | 过早或过晚按弹反键 | 弹反失败,玩家正常受击,无 ParryFlash | ☐ |
|
||||
| TC-P2-21 | 弹反计数事件 | 成功弹反后检查 Console | `EVT_ParrySuccess` 频道触发,`Stats.ParryCount` +1(用于 ParryCountCondition 成就) | ☐ |
|
||||
| TC-P2-22 | 不可弹反攻击 | 敌人使用标记为 `Unblockable`(InteractionTag)的攻击时尝试弹反 | 弹反无效,玩家正常受击,无 ParryFlash | ☐ |
|
||||
| TC-P2-23 | 弹反冷却 | 成功弹反后立即再次按弹反键 | 弹反冷却期间无效(`parryCooldown`),CD 期间 UI 弹反图标变灰(若实现) | ☐ |
|
||||
|
||||
---
|
||||
|
||||
### 5.5 护盾与霸体(Poise)
|
||||
|
||||
| TC-ID | 测试项 | 操作步骤 | 期望结果 | ☐ |
|
||||
|-------|-------|---------|---------|---|
|
||||
| TC-P2-24 | ShieldComponent 护盾吸收 | 装备护盾护符后受到攻击 | 护盾先扣除伤害,护盾耗尽后 HP 才扣减;护盾恢复计时器启动 | ☐ |
|
||||
| TC-P2-25 | 霸体(Poise)保护 | 重攻击(`Break Level >= poise`)触发 HurtState;普通攻击(低于 poise)不触发 | 霸体值足够时攻击动画不被打断,霸体耗尽后下次攻击触发 HurtState | ☐ |
|
||||
| TC-P2-26 | 霸体恢复 | 玩家停止受击一段时间 | 霸体值按 `poiseRecoveryRate` 恢复至满值 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
### 5.6 状态效果系统
|
||||
|
||||
| TC-ID | 测试项 | 操作步骤 | 期望结果 | ☐ |
|
||||
|-------|-------|---------|---------|---|
|
||||
| TC-P2-27 | Poison 中毒效果 | 使敌人/玩家受到 Poison StatusEffect(通过调试工具或专用敌人) | 每 `tickInterval` 秒扣除 `tickDamage` 血量,持续 `duration` 秒后自动移除,VFX 粒子(绿色)在角色上显示 | ☐ |
|
||||
| TC-P2-28 | Burn 燃烧效果 | 触发 Burn | 类似 Poison,VFX 为橙红色,效果叠加计算(若实现 stack) | ☐ |
|
||||
| TC-P2-29 | Stagger 硬直效果 | 触发 Stagger | 目标进入硬直动画,`staggerDuration` 秒内无法行动 | ☐ |
|
||||
| TC-P2-30 | 状态效果叠加 | 同时触发 Poison + Burn | 两个效果独立计时,互不覆盖(StatusEffectManager 分别管理) | ☐ |
|
||||
| TC-P2-31 | 状态效果免疫 | 对标记为免疫(DamageFlags 或 StatusResistance)的目标施加效果 | 效果不附着,Console 无报错 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
### 5.7 VFX 与 Feedback 扩展
|
||||
|
||||
| TC-ID | 测试项 | 操作步骤 | 期望结果 | ☐ |
|
||||
|-------|-------|---------|---------|---|
|
||||
| TC-P2-32 | HitFX 分类型播放 | 不同 HitFxType 攻击命中(斩击/钝击/魔法) | `VFXCatalogSO.TryGetHitFX` 正确查表,对应 VFX Prefab 从对象池生成,无 `Debug.Assert` 失败 | ☐ |
|
||||
| TC-P2-33 | HurtFlash 受击闪白 | 玩家受击 | HurtFlash 动画播放,`WaitForSeconds` 使用缓存无 GC(Profiler 确认) | ☐ |
|
||||
| TC-P2-34 | ParryFlash VFX | 弹反成功 | ParryFlash 粒子从对象池生成后自动归还(`PooledObject.ReturnToPool`) | ☐ |
|
||||
| TC-P2-35 | FormSwitch VFX | 切换形态 | 形态切换 VFX(颜色渐变或粒子)播放,`MMF_Player` 事件触发 | ☐ |
|
||||
| TC-P2-36 | Feel MMF_Player 反馈链 | 攻击/受击/弹反等事件触发 | `PlayerFeedback.cs` 各方法(PlayAttack/PlayHurt/PlayParry)正确触发对应 MMF_Player,无反射调用 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
### 5.8 音频 Mixer 快照
|
||||
|
||||
| TC-ID | 测试项 | 操作步骤 | 期望结果 | ☐ |
|
||||
|-------|-------|---------|---------|---|
|
||||
| TC-P2-37 | 战斗快照切换 | 玩家进入敌人 Alert 范围 | AudioMixer 渐变到战斗快照(鼓点增强,环境音淡出),过渡时长符合配置 | ☐ |
|
||||
| TC-P2-38 | 平静快照恢复 | 击败所有敌人或离开战斗区 | AudioMixer 渐变回平静快照 | ☐ |
|
||||
| TC-P2-39 | 形态 BGM 切换 | 切换形态 | BGM 切换到对应形态音轨,旧轨道淡出(双 AudioSource 交叉淡入) | ☐ |
|
||||
|
||||
---
|
||||
|
||||
### 5.9 难度系统
|
||||
|
||||
| TC-ID | 测试项 | 操作步骤 | 期望结果 | ☐ |
|
||||
|-------|-------|---------|---------|---|
|
||||
| TC-P2-40 | 难度切换数值注入 | 在 SettingsPanel 切换难度(Easy/Normal/Hard) | `DifficultyManager.ApplyDifficulty()` 调用,`EnemyStats` 的伤害/HP 乘数按 `DifficultyScalerSO` 更新,`PlayerStats` 受伤倍率更新 | ☐ |
|
||||
| TC-P2-41 | 难度数值持久化 | 切换 Hard → 存档 → 重载 | 重载后难度仍为 Hard,乘数正确应用 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
### 5.10 敌人扩展(远程 / 飞行 / Boss 骨架)
|
||||
|
||||
| TC-ID | 测试项 | 操作步骤 | 期望结果 | ☐ |
|
||||
|-------|-------|---------|---------|---|
|
||||
| TC-P2-42 | RangedEnemy 远程攻击 | 玩家进入 RangedEnemy 视野 | 敌人在保持距离状态下发射弹射物(从对象池生成),弹射物命中玩家扣血 | ☐ |
|
||||
| TC-P2-43 | FlyingEnemy 飞行巡逻 | FlyingEnemy 在场景中 | 不受地形约束飞行巡逻,BatchLOSSystem 检测到玩家后追击,`IsPlayerVisible` 正确更新 | ☐ |
|
||||
| TC-P2-44 | BossBase 血量分段 | BossBase 实例的 HP 降至配置阈值(如 50%) | `BossBase.EnterPhase(1)` 被调用,`EVT_BossPhaseChanged` 频道广播,Boss 进入第二阶段行为 | ☐ |
|
||||
| TC-P2-45 | LootTableSO 掉落 | 击败有 LootTableSO 的敌人 | 按权重随机生成对应战利品(Geo / 道具),无 `NullReferenceException` | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## 6. P3 世界与进程系统
|
||||
|
||||
### 6.1 房间切换与场景管理
|
||||
|
||||
| TC-ID | 测试项 | 操作步骤 | 期望结果 | ☐ |
|
||||
|-------|-------|---------|---------|---|
|
||||
| TC-P3-01 | 自动触发传送点 | 玩家走入 `_autoTrigger = true` 的 RoomTransition 碰撞体 | 场景切换启动:广播 `SceneLoadRequest`,旧场景卸载,新场景加载,玩家在 `EntryTransitionId` 对应 PlayerSpawnPoint 出现 | ☐ |
|
||||
| TC-P3-02 | 交互键触发传送点 | 玩家走入 `_autoTrigger = false` 的传送点,按交互键 | 弹出交互提示(InteractableDetector 管理),按键后触发场景切换 | ☐ |
|
||||
| TC-P3-03 | 钥匙物品传送门 | 传送点设有 `requiredItemId`,玩家未持有时尝试进入 | 传送不触发,提示提示信息(如"需要 xx 钥匙") | ☐ |
|
||||
| TC-P3-04 | Loading Screen | 场景切换时 `ShowLoadingScreen = true` 的传送请求 | Loading 画面出现并在新场景加载完成后淡出 | ☐ |
|
||||
| TC-P3-05 | 多场景 Persistent 常驻 | 场景切换全程 | Persistent 场景全程不卸载,ServiceLocator 中注册的服务始终有效 | ☐ |
|
||||
| TC-P3-06 | 玩家位置记录(BreadcrumbTracker) | 玩家移动 30 秒 | `BreadcrumbTracker.GetRecentCrumbs(5)` 返回正确的近 5 个坐标,移动距离未超过阈值的位置不记录 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
### 6.2 世界互动机关
|
||||
|
||||
| TC-ID | 测试项 | 操作步骤 | 期望结果 | ☐ |
|
||||
|-------|-------|---------|---------|---|
|
||||
| TC-P3-07 | HazardZone 伤害 | 玩家进入 HazardZone 碰撞体 | 每 `_damageInterval` 秒扣除 `_damage` HP,离开后停止 | ☐ |
|
||||
| TC-P3-08 | Collectible Geo 拾取 | 玩家接触 Geo 收集物 | Geo 数量增加,收集物弹起后被自动吸附(若实现磁吸),`_collected` 标记防止重复拾取 | ☐ |
|
||||
| TC-P3-09 | DestructibleTile 破坏 | 玩家攻击 DestructibleTile | 根据 `breakLevel` vs 攻击 `BreakLevel` 判断:满足时瓦片碎裂(替换 Tilemap 或 Destroy),不满足时无效 | ☐ |
|
||||
| TC-P3-10 | MovingPlatform 乘客跟随 | 玩家站上移动平台 | 玩家随平台移动,物理稳定无抖动;离开平台后父节点正确还原 | ☐ |
|
||||
| TC-P3-11 | CrumblePlatform 崩塌 | 玩家站立在 CrumblePlatform 上 | 延迟 `_crumbleDelay` 秒后平台开始崩裂,`_respawnDelay` 秒后复原 | ☐ |
|
||||
| TC-P3-12 | DirectionalInteractable 单向 | 从正确方向触发 DirectionalInteractable(攻击/接触/按键) | 机关激活,VoidEventChannel 广播;从错误方向触发无响应 | ☐ |
|
||||
| TC-P3-13 | PhantomInteractable 太虚斩通过 | 玩家在 ShadowDecoy(太虚斩)形态下触碰 PhantomInteractable | 机关激活(PhantomBody Layer 检测有效);普通形态触碰无效 | ☐ |
|
||||
| TC-P3-14 | DeathShade Geo 回收 | 死亡后重生,找到死亡遗骸(DeathShade)并交互 | Geo 增加(`_onGeoRecovered.Raise(geo)`),DeathShade 销毁,`EVT_ShadeCollected` 触发 | ☐ |
|
||||
| TC-P3-15 | InteractableDetector 最近物体 | 同时有多个可交互物在范围内 | 始终与最近的可交互物交互,Gizmo 半径圈在编辑器下正确显示 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
### 6.3 WorldStateRegistry 持久化
|
||||
|
||||
| TC-ID | 测试项 | 操作步骤 | 期望结果 | ☐ |
|
||||
|-------|-------|---------|---------|---|
|
||||
| TC-P3-16 | 一次性机关状态保存 | 触发 `_isOneShot = true` 的 DirectionalInteractable → 存档 → 重载场景 | 机关已激活状态保留,`WorldStateRegistry.IsFlagSet(id)` 返回 true | ☐ |
|
||||
| TC-P3-17 | 跨场景状态不丢失 | 触发机关 → 切换到另一个场景 → 返回 | 机关状态保持已激活(无重置) | ☐ |
|
||||
|
||||
---
|
||||
|
||||
### 6.4 液态谜题系统
|
||||
|
||||
| TC-ID | 测试项 | 操作步骤 | 期望结果 | ☐ |
|
||||
|-------|-------|---------|---------|---|
|
||||
| TC-P3-18 | SwimState 游泳 | 玩家进入 LiquidZone 碰撞体 | 切换到 SwimState,浮力应用,游泳动画播放,潜水键可下潜,离开液体恢复原状态 | ☐ |
|
||||
| TC-P3-19 | LiquidPuzzleController 谜题完成 | 操控 Valve/Pump/Drain 三件套达到谜题完成条件 | `LiquidPuzzleController.IsSolved` 变为 true,广播相关事件,机关门打开 | ☐ |
|
||||
| TC-P3-20 | LiquidFlowSimulator 流向模拟 | 打开 Valve | 液体按重力/管道方向模拟流动(骨架级别验证:液位高度变化即可) | ☐ |
|
||||
|
||||
---
|
||||
|
||||
### 6.5 能力解锁与能力门禁
|
||||
|
||||
| TC-ID | 测试项 | 操作步骤 | 期望结果 | ☐ |
|
||||
|-------|-------|---------|---------|---|
|
||||
| TC-P3-21 | AbilityUnlock 解锁演出 | 玩家进入 AbilityUnlock 触发区 | `MMF_Player` 反馈播放(无反射调用),等待 `_cutsceneDuration` 后调用 `PlayerStats.UnlockAbility()`,`EVT_AbilityUnlocked` 广播,组件销毁 | ☐ |
|
||||
| TC-P3-22 | AbilityGate 阻挡 | 未解锁对应能力时走向 AbilityGate | 玩家无法通过(碰撞体激活),UI 提示"需要 xx 能力" | ☐ |
|
||||
| TC-P3-23 | AbilityGate 实时开启 | 解锁能力后,已存在的 AbilityGate 响应 | 订阅 `AbilityTypeEventChannelSO` 的 AbilityGate 自动开启碰撞体,玩家可通过 | ☐ |
|
||||
| TC-P3-24 | 能力持久化 | 解锁冲刺能力 → 存档 → 重载 | 重载后 `PlayerStats.HasAbility(AbilityType.Dash)` 仍为 true | ☐ |
|
||||
|
||||
---
|
||||
|
||||
### 6.6 护符与装备系统
|
||||
|
||||
| TC-ID | 测试项 | 操作步骤 | 期望结果 | ☐ |
|
||||
|-------|-------|---------|---------|---|
|
||||
| TC-P3-25 | 护符装备生效 | 打开 InventoryPanel,将某 CharmSO 拖入装备槽 | `EquipmentManager` 调用 `ICharmEffect.OnEquip(player)`,效果立即应用(如 HP+1、冲刺延长) | ☐ |
|
||||
| TC-P3-26 | 护符卸除回滚 | 卸除已装备的护符 | `ICharmEffect.OnUnequip(player)` 回滚数值,效果消失 | ☐ |
|
||||
| TC-P3-27 | 护符插槽限制 | 装备超过 `maxCharmSlots` 个护符 | 超出时提示"插槽不足",无法装备 | ☐ |
|
||||
| TC-P3-28 | 护符持久化 | 装备护符 → 存档 → 重载 | 重载后护符仍处于装备状态,效果生效 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
### 6.7 技能管理器与修改器
|
||||
|
||||
| TC-ID | 测试项 | 操作步骤 | 期望结果 | ☐ |
|
||||
|-------|-------|---------|---------|---|
|
||||
| TC-P3-29 | 技能施放基础 | 形态切换到 Sky,按 SoulSkill 键 | 执行 `FormSkillSO.effectType` 对应逻辑(如 MeleeAoE 触发范围攻击),消耗 `baseCost` 灵力,进入 `cooldown` | ☐ |
|
||||
| TC-P3-30 | 技能冷却 | 施放后立即再按技能键 | 冷却中无法施放,CD 结束后可再次施放 | ☐ |
|
||||
| TC-P3-31 | SkillModifierRegistry 数值修改 | 装备影响某技能 Damage 的护符 | `SkillModifierRegistry.GetEffectiveParams()` 返回 `damageMult > 1.0f`,实际伤害增加 | ☐ |
|
||||
| TC-P3-32 | SkillSlotOverride 技能替换 | 装备包含 `SkillSlotOverride` 的护符 | 指定形态/插槽的技能被替换为 `replacementSkill`,HUD 显示新技能图标 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
### 6.8 任务系统
|
||||
|
||||
| TC-ID | 测试项 | 操作步骤 | 期望结果 | ☐ |
|
||||
|-------|-------|---------|---------|---|
|
||||
| TC-P3-33 | 任务接取 | 与 QuestGiver NPC 交互,选择接受任务 | `QuestManager.StartQuest(questSO)` 调用,任务出现在 Journal UI | ☐ |
|
||||
| TC-P3-34 | 任务目标追踪 | 完成任务中"击败 N 个敌人"目标 | `QuestObjectiveSO.OnEnemyDefeated` 计数更新,HUD 追踪数字变化 | ☐ |
|
||||
| TC-P3-35 | 任务完成与奖励 | 满足所有目标后返回 QuestGiver | `QuestManager.CompleteQuest(id)` 调用,`IRewardTarget.GiveReward()` 执行(Geo/物品/能力奖励),任务状态变 Completed | ☐ |
|
||||
| TC-P3-36 | 任务持久化 | 接取任务 → 完成部分目标 → 存档 → 重载 | 任务进度正确恢复(ObjectiveProgress 保存) | ☐ |
|
||||
|
||||
---
|
||||
|
||||
### 6.9 挑战房间
|
||||
|
||||
| TC-ID | 测试项 | 操作步骤 | 期望结果 | ☐ |
|
||||
|-------|-------|---------|---------|---|
|
||||
| TC-P3-37 | 挑战房间触发 | 玩家进入 ChallengeRoomTrigger | 房间封锁(出口门关闭),`ChallengeRoomManager.StartChallenge(so)` 调用,限时计时器启动 | ☐ |
|
||||
| TC-P3-38 | 挑战胜利 | 在时限内满足 `ChallengeRoomSO` 通关条件 | 出口门开启,奖励生成,`EVT_ChallengeCompleted` 广播 | ☐ |
|
||||
| TC-P3-39 | 挑战失败 | 超时或玩家死亡 | 挑战失败流程触发,可重置 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
### 6.10 地图模块
|
||||
|
||||
| TC-ID | 测试项 | 操作步骤 | 期望结果 | ☐ |
|
||||
|-------|-------|---------|---------|---|
|
||||
| TC-P3-40 | 房间探索记录 | 进入新房间 | `SaveData.Map.ExploredRooms` 添加该房间 ID,迷雾(Fog of War)在 MapPanel 中对应区域揭开 | ☐ |
|
||||
| TC-P3-41 | 传送点图标 | 存档点被激活后 | MapPanel 上对应位置显示传送点图标 | ☐ |
|
||||
| TC-P3-42 | 地图面板开关 | 按地图键(默认 Tab) | MapPanel 开/关,全屏 Fog of War 渲染,已探索区域可见 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
### 6.11 商店系统
|
||||
|
||||
| TC-ID | 测试项 | 操作步骤 | 期望结果 | ☐ |
|
||||
|-------|-------|---------|---------|---|
|
||||
| TC-P3-43 | 商店面板打开 | 与 ShopKeeper NPC 交互 | ShopPanel 打开,显示 `ShopInventorySO` 中的商品列表及价格 | ☐ |
|
||||
| TC-P3-44 | 购买道具 | 选择商品点击购买,Geo 足够 | Geo 扣减,道具加入背包,`SaveData` 同步更新 | ☐ |
|
||||
| TC-P3-45 | Geo 不足拒绝 | 选择商品点击购买,Geo 不足 | 购买失败,提示"Geo 不足",无扣减 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## 7. P4 内容与完善
|
||||
|
||||
### 7.1 Boss 技能系统
|
||||
|
||||
| TC-ID | 测试项 | 操作步骤 | 期望结果 | ☐ |
|
||||
|-------|-------|---------|---------|---|
|
||||
| TC-P4-01 | BossSkillExecutor 技能执行 | Boss 行为树调用 `BossSkillExecutor.ExecuteSkill(so)` | 技能协程启动,`IsExecuting = true`,攻击图案按 `AttackPatternSO` 时序逐步激活 HitBox | ☐ |
|
||||
| TC-P4-02 | 技能中断 | 技能执行中切换阶段触发 `InterruptCurrentSkill()` | `StopCoroutine` 被调用,`IsExecuting` 重置为 false,无挂起协程 | ☐ |
|
||||
| TC-P4-03 | WFS 缓存零 GC | BossSkillExecutor 协程执行(Profiler 录制) | `Profiler → GC Alloc` 在协程帧无新 `WaitForSeconds` 分配,静态缓存生效 | ☐ |
|
||||
| TC-P4-04 | 弱点窗口开启 | `VulnerabilityWindow` 触发条件满足(如弹反成功) | `WeakPointSystem.SetActive(true, multiplier)` 调用,`EVT_VulnerabilityWindowOpened` 广播,弱点 HurtBox 可受击(伤害乘数生效) | ☐ |
|
||||
| TC-P4-05 | TelegraphSystem 预警 VFX | BD_TelegraphAttack Task 执行 | 预警 VFX 从对象池生成,持续 `_duration` 秒后自动归还(`PooledObject.ReturnToPool`) | ☐ |
|
||||
| TC-P4-06 | BD_EnterPhase 阶段切换 | BD 行为树执行 BD_EnterPhase Task | `BossBase.EnterPhase(phaseIndex)` 调用,`EVT_BossPhaseChanged` 广播,Boss 进入新阶段行为 | ☐ |
|
||||
| TC-P4-07 | SkillSequenceSO 连段 | Boss 执行含 SkillSequenceSO 的技能 | `SequenceStep[]` 按 `delayBeforeStep` 间隔依次执行,`RepeatIfPlayerInRange` 逻辑生效 | ☐ |
|
||||
| TC-P4-08 | BossResourceConfigSO 满值触发 | Boss 愤怒值积累到 `maxValue` | `autoTriggerOnFull = true` 时自动执行 `fullTriggerSkill`,触发后 `resetValueAfterTrigger` 重置资源值 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
### 7.2 对话与叙事模块
|
||||
|
||||
| TC-ID | 测试项 | 操作步骤 | 期望结果 | ☐ |
|
||||
|-------|-------|---------|---------|---|
|
||||
| TC-P4-09 | 对话启动 | 与 InteractableNPC 交互 | DialoguePanel 开启,打字机效果逐字显示文本,`InputReaderSO.EnableUIInput()` 自动切换 | ☐ |
|
||||
| TC-P4-10 | 对话快进 | 对话中按确认键 | 当前对话行完整显示(跳过打字机),再按则跳到下一行 | ☐ |
|
||||
| TC-P4-11 | 分支选择 | 对话出现选项节点 | 选项 UI 列表显示,导航键选择,确认后走对应分支 | ☐ |
|
||||
| TC-P4-12 | 对话结束恢复 | 对话播完最后一行 | DialoguePanel 关闭,`InputReaderSO.EnableGameplayInput()` 恢复游戏输入 | ☐ |
|
||||
| TC-P4-13 | NPC 任务绑定 | QuestGiver NPC 对话结束触发任务 | `QuestGiver.TryTriggerQuest()` 调用,任务接取(见 TC-P3-33) | ☐ |
|
||||
|
||||
---
|
||||
|
||||
### 7.3 事件链与过场
|
||||
|
||||
| TC-ID | 测试项 | 操作步骤 | 期望结果 | ☐ |
|
||||
|-------|-------|---------|---------|---|
|
||||
| TC-P4-14 | EventChain 顺序执行 | 触发 EventChainSO | 链条中各步骤按序执行(延迟/对话/VFX/音效),最后一步完成后 `OnChainCompleted` 回调 | ☐ |
|
||||
| TC-P4-15 | CutsceneManager Timeline | 触发过场 | Unity Timeline 播放,玩家输入锁定,过场结束后输入恢复 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
### 7.4 UI 完整面板
|
||||
|
||||
| TC-ID | 测试项 | 操作步骤 | 期望结果 | ☐ |
|
||||
|-------|-------|---------|---------|---|
|
||||
| TC-P4-16 | PausePanel 暂停 | 按 Esc | 游戏暂停(`Time.timeScale = 0`),PausePanel 显示,继续/存档/退出按钮可用 | ☐ |
|
||||
| TC-P4-17 | InventoryPanel 背包 | 打开背包 | 已持有护符/工具列表显示,装备/卸除操作生效(见 TC-P3-25/26) | ☐ |
|
||||
| TC-P4-18 | SettingsPanel 设置 | 打开设置 | 音量/分辨率/按键重绑定选项显示,修改后实时生效 | ☐ |
|
||||
| TC-P4-19 | AchievementPanel 成就 | 打开成就面板 | 已解锁成就高亮,未解锁为灰,进度条正确(如护符收集比例) | ☐ |
|
||||
|
||||
---
|
||||
|
||||
### 7.5 按键重绑定
|
||||
|
||||
| TC-ID | 测试项 | 操作步骤 | 期望结果 | ☐ |
|
||||
|-------|-------|---------|---------|---|
|
||||
| TC-P4-20 | 重绑定操作 | 打开 SettingsPanel → 按键重绑定 → 点击攻击键 → 按新键 | 新键绑定生效,旧键不再触发攻击,绑定信息写入 PlayerPrefs | ☐ |
|
||||
| TC-P4-21 | 绑定冲突检测 | 尝试将攻击键绑定为已被跳跃键占用的按键 | 提示冲突(或自动交换),不出现双动作绑定同一键 | ☐ |
|
||||
| TC-P4-22 | 重置默认绑定 | 点击"恢复默认"按钮 | 所有绑定恢复出厂设置 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
### 7.6 本地化系统
|
||||
|
||||
| TC-ID | 测试项 | 操作步骤 | 期望结果 | ☐ |
|
||||
|-------|-------|---------|---------|---|
|
||||
| TC-P4-23 | 语言切换 | 在 SettingsPanel 切换语言(简体中文/English) | 所有 `LocalizedText` 组件立即更新为目标语言,无"Missing Key"占位符 | ☐ |
|
||||
| TC-P4-24 | 语言持久化 | 切换语言 → 退出 Play Mode → 再次 Enter | 语言设置保留 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
### 7.7 成就系统
|
||||
|
||||
| TC-ID | 测试项 | 操作步骤 | 期望结果 | ☐ |
|
||||
|-------|-------|---------|---------|---|
|
||||
| TC-P4-25 | NailClashCountCondition | 触发 5 次 NailClash 弹反碰撞 | `Stats.SkillUseCounts["NailClash"] >= 5`,成就解锁,`EVT_AchievementUnlocked` 广播 | ☐ |
|
||||
| TC-P4-26 | MapExplorationCondition | 探索达到 `requiredRoomCount` 个房间 | 成就解锁,AchievementPanel 对应项高亮 | ☐ |
|
||||
| TC-P4-27 | UnlockedAllAbilitiesCondition | 解锁 `requiredAbilities` 中所有能力 | 位掩码检查通过,成就解锁 | ☐ |
|
||||
| TC-P4-28 | 成就进度显示 | 部分完成 CollectedAllCharmsCondition | `GetProgress()` 返回 `owned / totalCharmsCount`,AchievementPanel 进度条正确 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
### 7.8 平台支撑(Steam / 存档云同步)
|
||||
|
||||
| TC-ID | 测试项 | 操作步骤 | 期望结果 | ☐ |
|
||||
|-------|-------|---------|---------|---|
|
||||
| TC-P4-29 | PlatformBootstrap 初始化 | Play Mode(非编辑器模式或 Dev Build) | Steam SDK 初始化(Console 无 `Steamworks.NET` 错误),不崩溃 | ☐ |
|
||||
| TC-P4-30 | Steam 成就上报 | 满足成就条件后 | `SteamUserStats.SetAchievement()` 被调用(通过 Steam SDK 日志验证);Steamworks 未初始化时本地静默降级 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
### 7.9 支撑模块(调试 / 无卡点 / 无障碍 / 速通)
|
||||
|
||||
| TC-ID | 测试项 | 操作步骤 | 期望结果 | ☐ |
|
||||
|-------|-------|---------|---------|---|
|
||||
| TC-P4-31 | DebugCheatSystem 作弊 | Development Build / Editor:输入作弊码(如全体 HP 回满) | 作弊生效,Console 输出 `[Cheat] HP restored`;Release Build 中指令无效 | ☐ |
|
||||
| TC-P4-32 | AntiSoftlockSystem 出门触发 | 玩家在封闭区域卡住超过 `stuckTimeout` | AntiSoftlock 弹出选项("是否返回最近存档点"),选是则安全返回 | ☐ |
|
||||
| TC-P4-33 | AccessibilityManager 色盲模式 | 设置开启 Color Blind 模式 | `PaletteSwapSystem` 切换色盲调色板,VFX 颜色对比度提升 | ☐ |
|
||||
| TC-P4-34 | AccessibilityManager 震动开关 | 关闭震动 | `NiceVibrations` 调用不触发(`MMVibrationManager` 在 disabled 时静默) | ☐ |
|
||||
| TC-P4-35 | SpeedrunTimer IGT 计时 | Play Mode 进入游戏 | SpeedrunTimer 从 0 开始计时,暂停时停止(IGT 不含暂停),存档保存 `ElapsedTime` | ☐ |
|
||||
| TC-P4-36 | AnalyticsManager 本地日志 | 触发若干游戏事件(死亡/存档/能力解锁) | `Analytics/session_{date}.jsonl` 文件在磁盘生成,包含对应事件条目 | ☐ |
|
||||
|
||||
---
|
||||
|
||||
## 8. 全流程端到端验证
|
||||
|
||||
以下场景模拟完整游戏会话,覆盖跨系统协同:
|
||||
|
||||
### E2E-01:新游戏完整启动流程
|
||||
|
||||
**步骤**:
|
||||
1. 删除所有存档文件(`Application.persistentDataPath/saves/`)
|
||||
2. Enter Play Mode,进入主菜单
|
||||
3. 选择"新游戏",确认选择难度
|
||||
4. 场景加载完成,玩家出现在起始房间
|
||||
5. 观察 Console 和 Profiler
|
||||
|
||||
**期望结果**:
|
||||
- 无 Error,Persistent 场景 + 游戏场景同时加载
|
||||
- ServiceLocator 注册日志正确(7 个核心服务全部注册)
|
||||
- HUD 正确初始化(HP 满,Geo=0,形态 = Sky)
|
||||
- 输入立即响应
|
||||
- GC Alloc 在启动后 3 秒内趋近 0(Profiler 确认)
|
||||
|
||||
---
|
||||
|
||||
### E2E-02:完整战斗至死亡复活流
|
||||
|
||||
**步骤**:
|
||||
1. 与 SavePoint 交互存档
|
||||
2. 进入带 LootTableSO 的普通敌人房间
|
||||
3. 使用 3 段连击击败敌人
|
||||
4. 拾取掉落 Geo
|
||||
5. 进入第二波(含远程敌人),故意让玩家死亡
|
||||
6. 死亡屏幕出现,等待复活
|
||||
|
||||
**期望结果**:
|
||||
- 连击判定正确(HitBox 时序准确,无穿透)
|
||||
- Geo 正确累加(HUD 同步)
|
||||
- 远程敌人弹射物从对象池生成,无 `Instantiate` 调用(Profiler 对象池)
|
||||
- 死亡:DeadState 冻结物理,`EVT_PlayerDied` 广播
|
||||
- DeathShade 在死亡位置生成,含本次积累的 Geo
|
||||
- 复活在上次存档点,HP 满,Geo = 0(未回收 DeathShade 则 Geo 不恢复)
|
||||
|
||||
---
|
||||
|
||||
### E2E-03:多房间探索 + 传送 + 存档完整性
|
||||
|
||||
**步骤**:
|
||||
1. 起始房间探索,地图揭开
|
||||
2. 通过 RoomTransition 进入第二房间(带 Loading Screen)
|
||||
3. 第二房间激活 AbilityGate → 发现需要冲刺能力
|
||||
4. 找到 AbilityUnlock 触发区,解锁冲刺
|
||||
5. AbilityGate 自动开启,通过
|
||||
6. 与 SavePoint 交互存档
|
||||
7. Exit Play Mode → Enter Play Mode → 加载存档
|
||||
|
||||
**期望结果**:
|
||||
- 每步无 Error
|
||||
- 地图探索记录(`ExploredRooms`)在两个房间均有记录
|
||||
- 冲刺能力解锁后 AbilityGate 实时响应(无需重进场景)
|
||||
- 存档重载后:位置=SavePoint 坐标,冲刺能力=已解锁,地图探索=保留,Geo=已保存值
|
||||
|
||||
---
|
||||
|
||||
### E2E-04:Boss 完整战斗流
|
||||
|
||||
**步骤**:
|
||||
1. 进入 Boss 房间,触发 Boss Arena 锁定
|
||||
2. 观察 Boss 第一阶段行为树(巡逻→预警→攻击→技能连段)
|
||||
3. HP 降至 50%,Boss 进入第二阶段
|
||||
4. 成功弹反 Boss 攻击,触发弱点窗口
|
||||
5. 对弱点集中攻击
|
||||
6. Boss HP 归零,死亡
|
||||
|
||||
**期望结果**:
|
||||
- BD_IsPlayerVisible 读取 BatchLOSSystem 缓存(无每帧 Raycast)
|
||||
- 阶段切换:`EVT_BossPhaseChanged` 广播,UI Boss HP 条颜色变化
|
||||
- 弹反成功:弱点 HurtBox 激活,伤害乘数生效,`EVT_VulnerabilityWindowOpened` 广播
|
||||
- TelegraphSystem VFX 从对象池生成归还,无残留
|
||||
- Boss 死亡:`EVT_BossFightEnded` 广播,Arena 解锁,战利品生成
|
||||
|
||||
---
|
||||
|
||||
### E2E-05:成就全流程
|
||||
|
||||
**步骤**:
|
||||
1. 累计触发 5 次 NailClash(弹反碰撞检测)
|
||||
2. 探索 `requiredRoomCount` 个房间
|
||||
3. 打开 AchievementPanel
|
||||
|
||||
**期望结果**:
|
||||
- NailClash 计数通过 `SaveData.Stats.SkillUseCounts["NailClash"]` 存档
|
||||
- 房间探索通过 `SaveData.Map.ExploredRooms` 存档
|
||||
- AchievementPanel 对应成就解锁高亮,进度条准确
|
||||
- Steam 成就上报(若在 Dev Build 模式)
|
||||
|
||||
---
|
||||
|
||||
## 9. 验收通过标准
|
||||
|
||||
### 必须全部通过(阻断级)
|
||||
|
||||
- ENV-01~07 全通过(0 编译错误)
|
||||
- TC-P0-01 ~ TC-P0-05(基础设施可运行)
|
||||
- TC-P1-01 ~ TC-P1-08(最小可玩流程)
|
||||
- E2E-01(新游戏启动无 Error)
|
||||
- E2E-02(战斗至死亡复活完整流程)
|
||||
|
||||
### 核心功能通过(发布前必须通过)
|
||||
|
||||
- P2 系列:TC-P2-01 ~ TC-P2-45(核心玩法全覆盖)
|
||||
- P3 系列:TC-P3-01 ~ TC-P3-45(世界互动全覆盖)
|
||||
- E2E-03(多房间探索存档完整性)
|
||||
- E2E-04(Boss 完整战斗流)
|
||||
|
||||
### 支撑功能通过(发布质量)
|
||||
|
||||
- P4 系列:TC-P4-01 ~ TC-P4-36(内容与完善全覆盖)
|
||||
- E2E-05(成就全流程)
|
||||
|
||||
### 性能基准(Profile 验证)
|
||||
|
||||
| 指标 | 基准值 | 验证方法 |
|
||||
|------|--------|---------|
|
||||
| 战斗帧率(单房间 + 3 敌人) | ≥ 60 FPS(Mobile:≥ 30) | Profiler CPU Usage |
|
||||
| 战斗中帧 GC Alloc | ≤ 0 B/帧(稳定运行后) | Profiler GC Alloc Track |
|
||||
| 内存增量(10 分钟游戏) | ≤ 5 MB(对象池正常回收) | Profiler Memory Profiler |
|
||||
| 场景加载时间(带 Loading Screen) | ≤ 2 秒(含 Addressables 异步) | `Stopwatch` / SceneLoader 日志 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 缺陷登记表
|
||||
|
||||
> 测试过程中发现的缺陷记录于此,格式与代码评审 TD 编号体系对齐(使用 BUG- 前缀区分)。
|
||||
|
||||
| BUG-ID | 严重程度 | 发现于 | 复现 TC | 描述 | 状态 | 修复版本 |
|
||||
|--------|---------|-------|---------|------|------|---------|
|
||||
| *(示例)* BUG-01 | 高 | E2E-02 | TC-P2-17 | HitBox 在 Attack3 后未 Deactivate,造成持续判定 | ⏳ 待修复 | — |
|
||||
|
||||
---
|
||||
|
||||
*文档维护:每次 Phase 验收后更新「阶段验收状态总览」及「缺陷登记表」;新增测试用例按模块追加,TC-ID 自增。*
|
||||
@@ -0,0 +1,743 @@
|
||||
# Architecture 与 Assets/Scripts 一致性评估
|
||||
|
||||
> 评估日期:2026-05-11
|
||||
> 评估范围:Docs/Architecture 与 Assets/Scripts
|
||||
> 评估目标:验证架构设计文档、覆盖矩阵与当前代码实现的一致性,识别已落地能力、命名或落点漂移、以及文档声明但尚未实现的项。
|
||||
|
||||
---
|
||||
|
||||
## 1. 结论摘要
|
||||
|
||||
本次核对结果显示:项目的“模块边界”层面整体一致性较高,但“文档完成度声明”与“具体类型级实现”之间存在明显偏差。
|
||||
|
||||
综合判断如下:
|
||||
|
||||
- 结构层一致性:高
|
||||
- 核心运行时模块落地度:高
|
||||
- 文档与代码的命名同步度:中
|
||||
- 覆盖矩阵可信度:中偏低
|
||||
- 类型级细节一致性:中
|
||||
|
||||
总体结论:
|
||||
|
||||
1. Docs/Architecture 对项目的分层、模块切分、程序集边界和主要通信模式描述,和 Assets/Scripts 的实际结构高度吻合。
|
||||
2. Core、Combat、Enemy、World、Narrative、Camera、QuestChallenge 等模块的“主干设计”已经明显落地。
|
||||
3. 但 00_CoverageIndex 中“架构完整度 100%”的表述,和当前代码状态并不完全一致;至少存在若干文档明确声明、且被标为完成,但代码库中未找到对应类型的情况。
|
||||
4. 部分文档摘要仍保留旧命名或旧组件名,导致 README/索引文档与模块正文、模块正文与代码之间出现双重漂移。
|
||||
|
||||
如果按工程可执行性而不是文档完整性来评估,当前状态更适合定义为:
|
||||
|
||||
- 主体架构已成形
|
||||
- 多数核心模块已落地
|
||||
- 文档细节需要一轮“去过时化”和“完成度回标”
|
||||
|
||||
---
|
||||
|
||||
## 2. 评估方法
|
||||
|
||||
本次评估采用以下方法进行交叉核对:
|
||||
|
||||
1. 以 Docs/Architecture/README.md 作为总索引,提取架构模块列表与模块职责。
|
||||
2. 以各模块文档中的“路径 + 类型名 + 代码片段”作为声明依据。
|
||||
3. 到 Assets/Scripts 中查找对应 C# 文件、命名空间、目录位置和注释中的架构锚点。
|
||||
4. 将发现分为四类:
|
||||
- 强一致:文档声明与代码实现基本一致
|
||||
- 部分一致:实现存在,但路径、命名或职责边界有漂移
|
||||
- 文档有、代码未见:文档明确声明且索引视为完成,但代码库未找到
|
||||
- 代码有、文档弱覆盖:代码中存在明确实现,但未在架构文档中得到足够表达
|
||||
|
||||
说明:
|
||||
|
||||
- 本评估基于仓库静态内容,不包含 Unity 场景对象、Prefab 绑定、Inspector 序列化引用以及 Addressables 资产配置的运行时验证。
|
||||
- 因此本报告评估的是“代码结构与架构文档一致性”,不是“游戏功能是否可玩”。
|
||||
|
||||
---
|
||||
|
||||
## 3. 总体观察
|
||||
|
||||
### 3.1 高一致区域
|
||||
|
||||
以下区域表现出较强的一致性:
|
||||
|
||||
- Core
|
||||
- Combat
|
||||
- Enemy
|
||||
- World
|
||||
- Narrative
|
||||
- Camera
|
||||
- QuestChallenge
|
||||
|
||||
这些模块通常同时满足以下特征:
|
||||
|
||||
- Docs/Architecture 中存在独立模块文档
|
||||
- Assets/Scripts 中存在对应目录或子目录
|
||||
- 关键类型名可在代码中直接找到
|
||||
- 代码注释中有反向引用具体架构章节
|
||||
|
||||
### 3.2 主要不一致模式
|
||||
|
||||
当前不一致主要不是“完全没实现”,而是以下几种更常见的偏差:
|
||||
|
||||
1. 文档摘要仍使用旧名字,代码已演进为新实现。
|
||||
2. 模块正文给出的路径与真实目录落点不同,但职责本身存在。
|
||||
3. 覆盖矩阵将某些能力标记为“完整”,但同名类型在代码库中未找到。
|
||||
4. 架构文档偏重“框架基类”,对具体敌种、具体业务脚本、独立子系统的覆盖不足。
|
||||
|
||||
### 3.3 风险判断
|
||||
|
||||
最主要风险不在代码本身,而在文档对研发协作的误导:
|
||||
|
||||
- 新成员可能误以为某些接口或工具已经存在
|
||||
- 代码审查时可能参考到过时路径或旧组件名
|
||||
- 后续迭代可能在已有实现外再造重复 abstraction
|
||||
- 覆盖矩阵的“100% 完整”会掩盖实际缺口
|
||||
|
||||
---
|
||||
|
||||
## 4. 逐模块评估
|
||||
|
||||
### 4.1 Core 模块
|
||||
|
||||
文档依据:
|
||||
|
||||
- Docs/Architecture/03_CoreModule.md
|
||||
- Docs/Architecture/README.md
|
||||
|
||||
关键声明:
|
||||
|
||||
- GameManager
|
||||
- SceneLoader
|
||||
- GlobalObjectPool
|
||||
- SettingsManager
|
||||
- ServiceLocator
|
||||
|
||||
代码核对结果:
|
||||
|
||||
- Assets/Scripts/Core/GameManager.cs
|
||||
- Assets/Scripts/Core/SceneLoader.cs
|
||||
- Assets/Scripts/Core/Pool/GlobalObjectPool.cs
|
||||
- Assets/Scripts/Core/SettingsManager.cs
|
||||
- Assets/Scripts/Core/ServiceLocator.cs
|
||||
|
||||
评估:强一致。
|
||||
|
||||
说明:
|
||||
|
||||
- 核心入口、场景加载、对象池、设置管理和服务定位器均已落地。
|
||||
- 代码注释与文档术语基本保持一致。
|
||||
|
||||
### 4.2 Combat 模块
|
||||
|
||||
文档依据:
|
||||
|
||||
- Docs/Architecture/06_CombatModule.md
|
||||
|
||||
关键声明:
|
||||
|
||||
- DamageInfo
|
||||
- HitBox
|
||||
- HurtBox
|
||||
- Projectile
|
||||
- StatusEffectManager
|
||||
- Parry 相关能力
|
||||
|
||||
代码核对结果:
|
||||
|
||||
- Assets/Scripts/Combat/DamageInfo.cs
|
||||
- Assets/Scripts/Combat/HitBox.cs
|
||||
- Assets/Scripts/Combat/HurtBox.cs
|
||||
- Assets/Scripts/Combat/Projectile.cs
|
||||
- Assets/Scripts/Combat/LinearProjectile.cs
|
||||
- Assets/Scripts/Combat/ParryableProjectile.cs
|
||||
- Assets/Scripts/Combat/StatusEffects/StatusEffectManager.cs
|
||||
- Assets/Scripts/Parry/ParrySystem.cs
|
||||
|
||||
评估:强一致。
|
||||
|
||||
说明:
|
||||
|
||||
- 战斗主干类型已形成完整骨架。
|
||||
- 状态效果和投射物已经细分到独立实现层。
|
||||
- Combat 与 Parry 的跨模块拆分也与项目整体程序集边界匹配。
|
||||
|
||||
### 4.3 Enemy 模块
|
||||
|
||||
文档依据:
|
||||
|
||||
- Docs/Architecture/07_EnemyModule.md
|
||||
|
||||
关键声明:
|
||||
|
||||
- EnemyBase
|
||||
- EnemyStats
|
||||
- EnemyMovement
|
||||
- EnemyCombat
|
||||
- EnemyNavAgent
|
||||
- BossBase
|
||||
- AttackPatternSO
|
||||
- TelegraphSystem
|
||||
- LootTableSO
|
||||
- LootResolver
|
||||
- BatchLOSSystem
|
||||
|
||||
代码核对结果:
|
||||
|
||||
- Assets/Scripts/Enemies/EnemyBase.cs
|
||||
- Assets/Scripts/Enemies/EnemyStats.cs
|
||||
- Assets/Scripts/Enemies/EnemyMovement.cs
|
||||
- Assets/Scripts/Enemies/EnemyCombat.cs
|
||||
- Assets/Scripts/Enemies/Navigation/EnemyNavAgent.cs
|
||||
- Assets/Scripts/Enemies/Boss/BossBase.cs
|
||||
- Assets/Scripts/Enemies/Boss/AttackPatternSO.cs
|
||||
- Assets/Scripts/Enemies/Boss/Patterns/TelegraphSystem.cs
|
||||
- Assets/Scripts/Enemies/LootTableSO.cs
|
||||
- Assets/Scripts/Enemies/LootResolver.cs
|
||||
- Assets/Scripts/Enemies/AI/BatchLOSSystem.cs
|
||||
|
||||
评估:强一致。
|
||||
|
||||
补充观察:
|
||||
|
||||
- Assets/Scripts/Enemies/FlyingEnemy.cs 之类具体敌种实现存在,但架构文档对具体敌种覆盖较弱,更偏向于描述框架层而非实例层。
|
||||
|
||||
### 4.4 World 模块
|
||||
|
||||
文档依据:
|
||||
|
||||
- Docs/Architecture/08_WorldModule.md
|
||||
|
||||
关键声明:
|
||||
|
||||
- RoomTransition
|
||||
- SavePoint
|
||||
- HazardZone
|
||||
- Collectible
|
||||
- AbilityUnlock
|
||||
- InteractableDetector
|
||||
- WorldStateRegistry
|
||||
- CollectibleSpawner
|
||||
|
||||
代码核对结果:
|
||||
|
||||
- Assets/Scripts/World/RoomTransition.cs
|
||||
- Assets/Scripts/World/SavePoint.cs
|
||||
- Assets/Scripts/World/HazardZone.cs
|
||||
- Assets/Scripts/World/Collectible.cs
|
||||
- Assets/Scripts/World/AbilityUnlock.cs
|
||||
- Assets/Scripts/World/InteractableDetector.cs
|
||||
- Assets/Scripts/World/WorldStateRegistry.cs
|
||||
- Assets/Scripts/World/CollectibleSpawner.cs
|
||||
|
||||
评估:强一致。
|
||||
|
||||
说明:
|
||||
|
||||
- 世界交互、场景切换、收集物、存档点等核心能力均可找到直接实现。
|
||||
|
||||
### 4.5 Narrative 模块
|
||||
|
||||
文档依据:
|
||||
|
||||
- Docs/Architecture/14_NarrativeModule.md
|
||||
|
||||
关键声明:
|
||||
|
||||
- IInteractable
|
||||
- InteractionPromptController
|
||||
- DialogueManager
|
||||
- InteractableNPC
|
||||
- NarrativeNPC
|
||||
- EventChainManager
|
||||
- CutsceneManager
|
||||
|
||||
代码核对结果:
|
||||
|
||||
- Assets/Scripts/World/IInteractable.cs
|
||||
- Assets/Scripts/Dialogue/InteractionPromptController.cs
|
||||
- Assets/Scripts/Dialogue/DialogueManager.cs
|
||||
- Assets/Scripts/Dialogue/InteractableNPC.cs
|
||||
- Assets/Scripts/Dialogue/NarrativeNPC.cs
|
||||
- Assets/Scripts/EventChain/EventChainManager.cs
|
||||
- Assets/Scripts/Cutscene/CutsceneManager.cs
|
||||
|
||||
评估:强一致,但存在模块拆分补充说明不足。
|
||||
|
||||
说明:
|
||||
|
||||
- 叙事实现实际横跨 Dialogue、Cutscene、EventChain、World 四个位置。
|
||||
- 文档虽有描述,但从目录组织上看,EventChain 已经是独立程序集级实现面,建议在 Architecture 入口层明确强调这一点。
|
||||
|
||||
### 4.6 Camera 模块
|
||||
|
||||
文档依据:
|
||||
|
||||
- Docs/Architecture/17_CameraModule.md
|
||||
|
||||
关键声明:
|
||||
|
||||
- CameraStateController
|
||||
- RoomVisibleArea
|
||||
- CameraTriggerZone
|
||||
- RoomCamera
|
||||
- CameraConfigSO
|
||||
- CameraBlendProfileSO
|
||||
|
||||
代码核对结果:
|
||||
|
||||
- Assets/Scripts/Camera/CameraStateController.cs
|
||||
- Assets/Scripts/Camera/RoomVisibleArea.cs
|
||||
- Assets/Scripts/Camera/CameraTriggerZone.cs
|
||||
- Assets/Scripts/Camera/RoomCamera.cs
|
||||
- Assets/Scripts/Camera/CameraConfigSO.cs
|
||||
- Assets/Scripts/Camera/CameraBlendProfileSO.cs
|
||||
|
||||
评估:强一致。
|
||||
|
||||
### 4.7 Save 模块
|
||||
|
||||
文档依据:
|
||||
|
||||
- Docs/Architecture/12_SaveModule.md
|
||||
- Docs/Architecture/00_CoverageIndex.md
|
||||
|
||||
关键声明:
|
||||
|
||||
- SaveData
|
||||
- ISaveStorage
|
||||
- SaveManager
|
||||
- SaveMigrator
|
||||
- EmergencySaveService
|
||||
- CrashReporter
|
||||
- SaveValidator
|
||||
- IDlcSaveExtension
|
||||
|
||||
代码核对结果:
|
||||
|
||||
- 已找到:
|
||||
- Assets/Scripts/Core/Save/SaveData.cs
|
||||
- Assets/Scripts/Core/Save/ISaveStorage.cs
|
||||
- Assets/Scripts/Core/Save/SaveManager.cs
|
||||
- Assets/Scripts/Core/Save/SaveMigrator.cs
|
||||
- Assets/Scripts/Core/Save/EmergencySaveService.cs
|
||||
- Assets/Scripts/Core/Save/CrashReporter.cs
|
||||
- 未找到同名实现:
|
||||
- SaveValidator
|
||||
- IDlcSaveExtension
|
||||
|
||||
评估:部分一致,且存在高优先级文档失真。
|
||||
|
||||
说明:
|
||||
|
||||
- 存档主骨架已实现。
|
||||
- 但文档中对 SaveValidator 与 IDlcSaveExtension 给出了明确路径、接口/类定义和调用方式,覆盖矩阵也将其视为已完成。
|
||||
- 代码库内未检出这两个同名类型,因此这部分不能视为“已实现”,最多只能视为“设计方案已写入文档”。
|
||||
|
||||
### 4.8 Input 模块
|
||||
|
||||
文档依据:
|
||||
|
||||
- Docs/Architecture/04_InputModule.md
|
||||
|
||||
关键声明:
|
||||
|
||||
- InputReaderSO
|
||||
- InputBuffer
|
||||
- RebindPanel
|
||||
- ConflictDetector
|
||||
- RebindPersistence
|
||||
|
||||
代码核对结果:
|
||||
|
||||
- Assets/Scripts/Input/InputReaderSO.cs
|
||||
- Assets/Scripts/Input/InputBuffer.cs
|
||||
- Assets/Scripts/UI/Settings/RebindPanel.cs
|
||||
- Assets/Scripts/Input/ConflictDetector.cs
|
||||
- 未找到独立类型:RebindPersistence
|
||||
|
||||
评估:部分一致。
|
||||
|
||||
说明:
|
||||
|
||||
- RebindPanel 与 ConflictDetector 已存在。
|
||||
- 文档对 RebindPersistence 的表述更像“InputReaderSO 内部持久化能力的抽象命名”,而不是仓库中真实存在的独立 C# 类型。
|
||||
- 这里的问题不一定是缺功能,更可能是文档层把“能力”写成了“类名”。
|
||||
|
||||
### 4.9 Player 模块
|
||||
|
||||
文档依据:
|
||||
|
||||
- Docs/Architecture/05_PlayerModule.md
|
||||
|
||||
关键声明:
|
||||
|
||||
- PlayerController
|
||||
- PlayerMovement
|
||||
- PlayerStats
|
||||
- PlayerCombat
|
||||
- FormController
|
||||
- WeaponManager
|
||||
- SkillManager
|
||||
- SpringSystem
|
||||
- FSM States
|
||||
|
||||
代码核对结果:
|
||||
|
||||
- Assets/Scripts/Player/States/PlayerController.cs
|
||||
- Assets/Scripts/Player/PlayerMovement.cs
|
||||
- Assets/Scripts/Player/PlayerStats.cs
|
||||
- Assets/Scripts/Player/PlayerCombat.cs
|
||||
- Assets/Scripts/Player/FormController.cs
|
||||
- Assets/Scripts/Player/WeaponManager.cs
|
||||
- Assets/Scripts/Skills/SkillManager.cs
|
||||
- Assets/Scripts/Player/SpringSystem.cs
|
||||
|
||||
评估:部分一致。
|
||||
|
||||
说明:
|
||||
|
||||
- 核心能力都存在。
|
||||
- 但 PlayerController 并不在 Player 根目录,而是在 Player/States 下。
|
||||
- SkillManager 也被拆分到独立的 Skills 模块,而不是完全留在 Player 模块目录中。
|
||||
- 这类偏差对运行时影响不大,但会误导按文档定位代码的开发者。
|
||||
|
||||
### 4.10 Progression 模块
|
||||
|
||||
文档依据:
|
||||
|
||||
- Docs/Architecture/09_ProgressionModule.md
|
||||
|
||||
关键声明:
|
||||
|
||||
- AbilityType
|
||||
- AbilityGate
|
||||
- Equipment/Charm/Tool/Skill 等能力与成长系统
|
||||
|
||||
代码核对结果:
|
||||
|
||||
- Assets/Scripts/Player/AbilityType.cs
|
||||
- Assets/Scripts/World/AbilityGate.cs
|
||||
- Assets/Scripts/Equipment/...
|
||||
- Assets/Scripts/Skills/...
|
||||
|
||||
评估:主体一致,但 AbilityType 定义存在明显漂移。
|
||||
|
||||
关键差异:
|
||||
|
||||
- 文档枚举项包含:AerialDash、InvincibleDash、ClimbVines、UseTools、ReadShrine、UseGrapple 等
|
||||
- 代码枚举项包含:AirDash、SuperJump、Dive、Spell1、Spell2、Spell3、SpiritForm、SpiritDash、FastTravel 等
|
||||
|
||||
判断:
|
||||
|
||||
- 这不是简单命名差异,而是能力模型本身发生了演进。
|
||||
- 当前文档中 AbilityType 的示例和真实代码已不属于同一版本。
|
||||
|
||||
影响:高。
|
||||
|
||||
- AbilityType 直接影响存档兼容、关卡门禁和能力查询语义。
|
||||
- 如果开发者依据文档新增位标志,存在破坏现有位序或存档含义的风险。
|
||||
|
||||
### 4.11 UI 模块
|
||||
|
||||
文档依据:
|
||||
|
||||
- Docs/Architecture/10_UIModule.md
|
||||
|
||||
关键声明:
|
||||
|
||||
- UIManager
|
||||
- HUDController
|
||||
- BossHPBar
|
||||
- PauseMenuController
|
||||
- DeathScreenController
|
||||
- SettingsPanelController
|
||||
- SaveSlotController
|
||||
- SaveIndicator
|
||||
- LoadingOverlay
|
||||
- IBossHPProvider
|
||||
|
||||
代码核对结果:
|
||||
|
||||
- 多数 UI 控制器可在 Assets/Scripts/UI 中找到
|
||||
- BossHPBar 存在:Assets/Scripts/UI/HUD/BossHPBar.cs
|
||||
- 未找到独立接口:IBossHPProvider
|
||||
|
||||
评估:部分一致。
|
||||
|
||||
说明:
|
||||
|
||||
- UI 模块大多数实体存在。
|
||||
- 但文档明确给出 IBossHPProvider 路径和接口定义,仓库内未发现同名接口。
|
||||
- 这意味着 BossHPBar 的依赖抽象层与文档不一致,或者文档记录了一个尚未落地的重构目标。
|
||||
|
||||
### 4.12 VFX / Feedback 模块
|
||||
|
||||
文档依据:
|
||||
|
||||
- Docs/Architecture/18_VFXFeedbackModule.md
|
||||
- Docs/Architecture/README.md
|
||||
|
||||
关键声明:
|
||||
|
||||
- IFeedbackPlayer
|
||||
- PlayerFeedback
|
||||
- EnemyFeedback
|
||||
- FeedbackConfigSO
|
||||
- VFXPool
|
||||
- HitFXSpawner
|
||||
- HurtFlashController
|
||||
- PaletteSwapSystem
|
||||
- PostProcessManager
|
||||
- RegionLightController
|
||||
|
||||
代码核对结果:
|
||||
|
||||
- VFXPool、HitFXSpawner 等主干命名可在 Assets/Scripts/VFX 中找到
|
||||
- 但 README 层摘要与模块正文、代码之间存在术语漂移
|
||||
|
||||
评估:部分一致。
|
||||
|
||||
说明:
|
||||
|
||||
- 模块正文比 README 更接近现状。
|
||||
- README 的摘要更像较旧版本的名称集合,不适合作为准确索引。
|
||||
|
||||
### 4.13 Liquid / Puzzle 模块
|
||||
|
||||
文档依据:
|
||||
|
||||
- Docs/Architecture/21_LiquidPuzzleModule.md
|
||||
- Docs/Architecture/README.md
|
||||
|
||||
关键声明:
|
||||
|
||||
- README 摘要中可见旧名称:LiquidSimulator、LiquidTile、LiquidTriggerZone、HazardLiquid
|
||||
- 模块正文与代码更接近的名称:LiquidZone、SwimState 等
|
||||
|
||||
代码核对结果:
|
||||
|
||||
- Assets/Scripts/World/Liquid/LiquidZone.cs
|
||||
- Assets/Scripts/Player/States/SwimState.cs
|
||||
- Assets/Scripts/World/Liquid/LiquidPhysicsConfigSO.cs
|
||||
- Assets/Scripts/World/Liquid/UnderwaterPostProcessingController.cs
|
||||
|
||||
评估:部分一致。
|
||||
|
||||
说明:
|
||||
|
||||
- 模块真实实现明显采用了 LiquidZone 体系。
|
||||
- README 摘要没有同步更新,保留了旧名,造成总览页与真实实现不一致。
|
||||
|
||||
### 4.14 Supporting 模块
|
||||
|
||||
文档依据:
|
||||
|
||||
- Docs/Architecture/16_SupportingModules.md
|
||||
|
||||
关键声明:
|
||||
|
||||
- LocalizationManager
|
||||
- AchievementManager
|
||||
- PlatformManager
|
||||
- DebugCheatSystem
|
||||
- AntiSoftlockSystem
|
||||
- AccessibilityManager
|
||||
- AnalyticsManager
|
||||
|
||||
代码核对结果:
|
||||
|
||||
- AchievementManager 实际位于 Assets/Scripts/Progression/AchievementManager.cs
|
||||
- Accessibility 相关实现位于 Support 子层
|
||||
- 平台相关实现位于 Platform / Support 交叉区域
|
||||
|
||||
评估:部分一致。
|
||||
|
||||
说明:
|
||||
|
||||
- 功能存在,但“支撑模块”更像是文档维度的归类,而不总是对应单一物理目录。
|
||||
- 其中 AchievementManager 被放在 Progression 下更符合运行时职责,但与 SupportingModules 的组织口径不完全一致。
|
||||
|
||||
### 4.15 BossSkill 模块
|
||||
|
||||
文档依据:
|
||||
|
||||
- Docs/Architecture/23_BossSkillModule.md
|
||||
- Docs/Architecture/README.md
|
||||
|
||||
关键声明:
|
||||
|
||||
- BossSkillSO
|
||||
- BossSkillExecutor
|
||||
- SkillSequenceSO
|
||||
- VulnerabilityWindow
|
||||
- BossOrchestrator 集成
|
||||
- README 摘要中还提到 BossPhaseController
|
||||
|
||||
代码核对结果:
|
||||
|
||||
- Assets/Scripts/Enemies/Boss/BossSkillSO.cs
|
||||
- Assets/Scripts/Enemies/Boss/BossSkillExecutor.cs
|
||||
- Assets/Scripts/Enemies/Boss/SkillSequenceSO.cs
|
||||
- BossPhaseController 未找到同名实现
|
||||
|
||||
评估:部分一致。
|
||||
|
||||
说明:
|
||||
|
||||
- 模块正文和代码大体对齐。
|
||||
- 但 README 摘要中的 BossPhaseController 未在代码中体现,说明入口摘要比正文更过时。
|
||||
|
||||
---
|
||||
|
||||
## 5. 重点问题清单
|
||||
|
||||
以下问题按优先级排序。
|
||||
|
||||
### P1. 覆盖矩阵“100% 完整”结论失真
|
||||
|
||||
证据:
|
||||
|
||||
- Docs/Architecture/00_CoverageIndex.md 声称架构完整度 100%
|
||||
- 但 SaveValidator、IDlcSaveExtension、IBossHPProvider、BossPhaseController 等同名类型在代码库中未找到
|
||||
|
||||
影响:高。
|
||||
|
||||
- 会导致文档被高估为“可直接实施现状说明”,而实际上其中一部分仍属于“设计稿状态”。
|
||||
|
||||
建议:
|
||||
|
||||
- 将 CoverageIndex 的状态从“完整”改为“已定义 / 已实现 / 部分实现 / 待实现”四态。
|
||||
|
||||
### P2. Progression 的 AbilityType 文档与代码版本明显脱节
|
||||
|
||||
证据:
|
||||
|
||||
- Docs/Architecture/09_ProgressionModule.md 中的枚举值与 Assets/Scripts/Player/AbilityType.cs 不一致
|
||||
|
||||
影响:高。
|
||||
|
||||
- 该枚举是 bitmask,错误文档会直接影响存档位定义和门禁逻辑。
|
||||
|
||||
建议:
|
||||
|
||||
- 以代码为准回写文档,或在文档中明确“当前实现版本”和“设计目标版本”的差别。
|
||||
|
||||
### P3. README 摘要层存在多处旧名称残留
|
||||
|
||||
典型例子:
|
||||
|
||||
- Liquid 模块摘要中的 LiquidSimulator / LiquidTile / LiquidTriggerZone
|
||||
- Boss 模块摘要中的 BossPhaseController
|
||||
- VFX 摘要的旧术语
|
||||
|
||||
影响:中高。
|
||||
|
||||
- README 是入口文档,过时摘要会比模块正文更容易误导。
|
||||
|
||||
建议:
|
||||
|
||||
- 优先更新 README,使其只承担“导航”和“当前真实入口”职责,不再保留历史名词。
|
||||
|
||||
### P4. 文档将“能力描述”写成“独立类型”,造成查找失败
|
||||
|
||||
典型例子:
|
||||
|
||||
- RebindPersistence
|
||||
|
||||
影响:中。
|
||||
|
||||
- 读者会误以为仓库中应存在该类。
|
||||
|
||||
建议:
|
||||
|
||||
- 将这类条目重写为“由 InputReaderSO 提供的持久化能力”,避免伪类型名。
|
||||
|
||||
### P5. 物理目录与文档逻辑目录存在合理但未解释的偏移
|
||||
|
||||
典型例子:
|
||||
|
||||
- PlayerController 位于 Player/States
|
||||
- SkillManager 位于 Skills
|
||||
- AchievementManager 位于 Progression
|
||||
- EventChain 独立成模块级目录
|
||||
|
||||
影响:中。
|
||||
|
||||
- 偏移本身不是 bug,但若文档不解释,会增加代码定位成本。
|
||||
|
||||
建议:
|
||||
|
||||
- 在 README 或对应模块文档中增加“实现落点说明”小节。
|
||||
|
||||
---
|
||||
|
||||
## 6. 一致性评级
|
||||
|
||||
本报告给出如下评级:
|
||||
|
||||
| 维度 | 评级 | 说明 |
|
||||
|---|---|---|
|
||||
| 模块划分一致性 | A | 目录结构、程序集边界、模块切分与架构文档高度一致 |
|
||||
| 核心系统落地度 | A- | Core、Combat、Enemy、World、Narrative、Camera 等主干已经落地 |
|
||||
| 文档摘要准确性 | B- | README 与 CoverageIndex 含明显过时内容 |
|
||||
| 类型级一致性 | B- | 若干文档中的明确类型在代码中不存在 |
|
||||
| 文档可作为“当前事实来源”的可靠性 | B | 可用于理解架构,但不能无条件视为当前实现真相 |
|
||||
|
||||
综合评级:B+
|
||||
|
||||
解释:
|
||||
|
||||
- 从工程结构看,这是一套已经较成熟的模块化代码库。
|
||||
- 从文档治理看,Architecture 文档更接近“设计与实现混合体”,部分章节已经演变为前瞻设计,不再完全等于当前代码状态。
|
||||
|
||||
---
|
||||
|
||||
## 7. 修正建议
|
||||
|
||||
建议按以下顺序处理文档治理问题。
|
||||
|
||||
### 第一优先级
|
||||
|
||||
1. 修正 Docs/Architecture/00_CoverageIndex.md 的完成度口径。
|
||||
2. 修正 Docs/Architecture/09_ProgressionModule.md 中 AbilityType 的当前实现定义。
|
||||
3. 修正 Docs/Architecture/README.md 中已过时的模块摘要名称。
|
||||
|
||||
### 第二优先级
|
||||
|
||||
1. 在 Save、UI、BossSkill、Input 等模块文档中,将“已实现”与“设计建议”明确分栏。
|
||||
2. 对未在代码中找到的类型增加状态标记:
|
||||
- 设计中
|
||||
- 计划实现
|
||||
- 已被内联到其他类
|
||||
- 已废弃
|
||||
|
||||
### 第三优先级
|
||||
|
||||
1. 在 Player、Progression、Narrative、Supporting 等文档中补充“真实代码落点说明”。
|
||||
2. 对具体敌种、EventChain、Support 下功能子系统增加简短架构附录,避免只有框架文档、没有实现面索引。
|
||||
|
||||
---
|
||||
|
||||
## 8. 建议的文档治理规则
|
||||
|
||||
为避免文档再次与实现脱节,建议建立以下规则:
|
||||
|
||||
1. Architecture README 只保留当前已存在的模块入口和当前术语,不承载历史命名。
|
||||
2. CoverageIndex 不再使用单一“完整/不完整”,改为多状态矩阵。
|
||||
3. 模块文档中的代码块若为“目标方案”,必须明确标记为“设计草案”;若为“当前实现”,路径和类型名必须可在仓库直接检索。
|
||||
4. 所有 bitmask、事件频道、序列化结构等高风险契约,优先以代码为准回写文档。
|
||||
5. 每次较大重构后,至少同步更新 README、CoverageIndex 和对应模块文档三处入口。
|
||||
|
||||
---
|
||||
|
||||
## 9. 最终判断
|
||||
|
||||
这套架构文档并不是“失真严重”,相反,它对项目的整体模块化设计、程序集边界、职责拆分和通信方式提供了很强的指导价值;问题主要出在:部分文档已经领先于当前实现,另一些文档仍停留在旧版本命名上,而覆盖矩阵又把这些差异全部抹平成了“100% 完整”。
|
||||
|
||||
因此,更准确的表述应当是:
|
||||
|
||||
- 架构主线与代码实现整体一致
|
||||
- 核心模块普遍已落地
|
||||
- 文档细节存在中等强度漂移
|
||||
- 覆盖矩阵结论高估了当前一致性
|
||||
|
||||
如果只从“是否还能指导开发”来看,答案是可以;如果从“是否能作为当前实现的严格事实来源”来看,答案是否定的,尤其在 Save、Progression、UI 抽象接口和 README 摘要层面,需要尽快回收偏差。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,634 @@
|
||||
# BrainGraph 接通敌人阶段(E001 + 脚手架一键)实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 让脚手架一键放置的 E001(草蛭)敌人,其 AI 完全由自研 BrainGraph 驱动、且**不挂任何 `BehaviorTree` 组件**:新增按 id 解析共享图的注册表、把 EnemyBase 子系统包成能力接口的上下文、承载 `AiRuntime` 的 `EnemyAiBrain` 组件、E001 的 AiScript 定义,移除 EnemyBase 的 Opsive BT 集成,并在 `SceneObjectPlacerTool` 里自动挂载/绑定。
|
||||
|
||||
**Architecture:** `EnemyAiBrain : MonoBehaviour` 持 `_definitionId`(string),`Awake` 经 `AiDefinitionRegistry.GetGraph(id)` 取每类型共享的不可变 `AiGraph`,构造 `EnemyBrainContext`(实现 `IAiContext`+四能力接口,委托 EnemyBase 子系统)与 `AiRuntime`,`Update` 里 `Tick`。EnemyBase 只在死亡处 `Send(AiSignal.Died)`、`OnSpawn` 调 `ResetBrain()`,受击让位由 `IsControllable` 门自动处理。脚手架把 `EnemyAiBrain` 挂上并 `AssignString(_definitionId,"E001")`。
|
||||
|
||||
**Tech Stack:** Unity 2022.3(C# 9), Unity Test Framework (EditMode), asmdef, Addressables, MCP(编译/测试/PlayMode 验证)。
|
||||
|
||||
**依赖:** BrainGraph 第 1 阶段核心运行时已合并 master(`BaseGames.AI`:AiGraph/BrainBuilder/AiRuntime/AiScript/[AiDefinition]/IAiContext/4 接口/Blackboard)。
|
||||
|
||||
**Spec:** `Docs/superpowers/specs/2026-07-03-enemy-ai-framework-design.md`(本阶段覆盖 §4 适配、§7 组件与脚手架、§8 迁移第 1-2 步的 E001 部分;LOD/AiScheduler、能力编排 sugar、Boss、参数 SO 热调、批量删 BD/Opsive 仍属后续)。
|
||||
|
||||
---
|
||||
|
||||
## 关键前提(来自代码探查,务必按真实结构对接)
|
||||
|
||||
- `SceneObjectPlacerTool.PlaceE001_CaoZhi(EnemyBodyColliderType)`(`Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs:439`)用 `GetOrAddComponent<T>`、`AssignReference`/`AssignAsset`/`AssignString`(该文件内 private static,见 :2204/:2244/:2257/:2333)装配敌人;末尾 `report.Add("★ 挂载行为树…E001_CaoZhi.asset。")`(**:524**)是要替换的接线点。E001 已带 `HurtBox` + `ContactDamageZone`(`BodyContactDamage`)——**接触即伤害**,无需攻击能力。
|
||||
- `EnemyBase`(`Assets/_Game/Scripts/Enemies/EnemyBase.cs`)暴露:`IsPlayerVisible()`、`IsPlayerInRange(float)`、`PlayerTransform`、`Nav`(`IPathAgent`,含 `IsMoving`/`WalkToRandom`)、`MoveTo(Vector2)`、`FacePlayer()`、`StopMovement()`、`BeginLookAround()`、`Abilities`(`EnemyAbilityRegistry`)、`IsAlive`、`CurrentState`(`EnemyStateType`)、`Stats`(`EnemyStats`, `CurrentHP`)、`StatsSO`(`MaxHP`)。`EnemyStateType{Controlled,Hurt,Stagger,KnockUp,Dead}`。`IPoolable` 只有 `OnSpawn`/`OnDespawn`。
|
||||
- `EnemyBase` 的 Opsive BT 集成全部在 `#if GRAPH_DESIGNER` 内(精确行号见 Task 5)。
|
||||
- 已关 Domain Reload:静态注册表须 `RuntimeInitializeOnLoadMethod` 重置。
|
||||
|
||||
## 文件结构(本阶段)
|
||||
|
||||
新增(`BaseGames.AI`,`Assets/_Game/Scripts/AI/`):
|
||||
- `AiDefinitionRegistry.cs` — 反射按 id 提供共享 AiGraph。
|
||||
|
||||
新增(`BaseGames.Enemies`,`Assets/_Game/Scripts/Enemies/AIBrain/`):
|
||||
- `EnemyBrainContext.cs` — 实现 IAiContext+4 接口,委托 EnemyBase 子系统。
|
||||
- `EnemyAiBrain.cs` — MonoBehaviour,承载 AiRuntime。
|
||||
- `Ai/E001CaoZhiAi.cs` — `[AiDefinition("E001")]` 的 AiScript。
|
||||
|
||||
修改:
|
||||
- `Assets/_Game/Scripts/Enemies/BaseGames.Enemies.asmdef` — references 增加 `BaseGames.AI`。
|
||||
- `Assets/_Game/Scripts/Enemies/EnemyBase.cs` — 移除 BT 集成,加 brain 引用/Send(Died)/OnSpawn 重置。
|
||||
- `Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs` — `PlaceE001_CaoZhi` 挂 `EnemyAiBrain` + 绑 id。
|
||||
|
||||
测试(`Assets/Tests/EditMode/AI/`):
|
||||
- `AiDefinitionRegistryTests.cs`。
|
||||
|
||||
> 命名/注释禁参考游戏名(CLAUDE.md §4);根因暴露、禁下游兜底(§6);创建走脚手架(§2)。
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `AiDefinitionRegistry`(反射按 id 取共享图)
|
||||
|
||||
**Files:**
|
||||
- Create: `Assets/_Game/Scripts/AI/AiDefinitionRegistry.cs`
|
||||
- Create: `Assets/Tests/EditMode/AI/AiDefinitionRegistryTests.cs`
|
||||
|
||||
- [ ] **Step 1: 写失败测试 `AiDefinitionRegistryTests.cs`**
|
||||
|
||||
```csharp
|
||||
using NUnit.Framework;
|
||||
using BaseGames.AI;
|
||||
|
||||
namespace BaseGames.Tests.EditMode.AI
|
||||
{
|
||||
// 顶层测试定义,供反射注册表发现
|
||||
[AiDefinition("__test_grunt")]
|
||||
internal sealed class TestGruntAi : AiScript
|
||||
{
|
||||
protected override void Build(BrainBuilder b)
|
||||
{
|
||||
b.Entry("A");
|
||||
b.State("A").To("B").When(c => c.Sensor.SeesPlayer(), "SeesPlayer");
|
||||
b.State("B");
|
||||
}
|
||||
}
|
||||
|
||||
public class AiDefinitionRegistryTests
|
||||
{
|
||||
[Test]
|
||||
public void Has_FindsRegisteredDefinition()
|
||||
{
|
||||
Assert.IsTrue(AiDefinitionRegistry.Has("__test_grunt"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetGraph_ResolvesById()
|
||||
{
|
||||
var g = AiDefinitionRegistry.GetGraph("__test_grunt");
|
||||
Assert.AreEqual("A", g.EntryState);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetGraph_SameId_ReturnsSharedInstance()
|
||||
{
|
||||
Assert.AreSame(
|
||||
AiDefinitionRegistry.GetGraph("__test_grunt"),
|
||||
AiDefinitionRegistry.GetGraph("__test_grunt"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetGraph_UnknownId_Throws()
|
||||
{
|
||||
Assert.Throws<System.InvalidOperationException>(
|
||||
() => AiDefinitionRegistry.GetGraph("__nope__"));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行确认失败**
|
||||
|
||||
Run: EditMode `AiDefinitionRegistryTests`(经 MCP:`unity_advanced_tool` → `unity_testing_run_tests` testMode=EditMode)。
|
||||
Expected: 编译失败 —— `AiDefinitionRegistry` 未定义。
|
||||
|
||||
- [ ] **Step 3: 实现 `AiDefinitionRegistry.cs`**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.AI
|
||||
{
|
||||
/// <summary>
|
||||
/// 反射收集所有 [AiDefinition] 的 AiScript 子类,按 id 提供共享 AiGraph(flyweight)。
|
||||
/// 同一 id 全实例共享同一 AiScript 实例 → 同一张不可变 AiGraph。
|
||||
/// </summary>
|
||||
public static class AiDefinitionRegistry
|
||||
{
|
||||
static Dictionary<string, AiScript> _byId;
|
||||
|
||||
// 项目已关闭 Domain Reload:静态缓存需在进入播放时重置,保证按最新类型重建。
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
|
||||
static void ResetOnPlay() => _byId = null;
|
||||
|
||||
static void EnsureBuilt()
|
||||
{
|
||||
if (_byId != null) return;
|
||||
_byId = new Dictionary<string, AiScript>();
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
Type[] types;
|
||||
try { types = asm.GetTypes(); }
|
||||
catch (ReflectionTypeLoadException e) { types = e.Types; }
|
||||
if (types == null) continue;
|
||||
for (int i = 0; i < types.Length; i++)
|
||||
{
|
||||
var t = types[i];
|
||||
if (t == null || t.IsAbstract || !typeof(AiScript).IsAssignableFrom(t)) continue;
|
||||
var attr = t.GetCustomAttribute<AiDefinitionAttribute>();
|
||||
if (attr == null) continue;
|
||||
if (_byId.ContainsKey(attr.Id))
|
||||
throw new InvalidOperationException(
|
||||
$"AiDefinitionRegistry: 重复的 AI 定义 id '{attr.Id}'({t.FullName})。");
|
||||
_byId[attr.Id] = (AiScript)Activator.CreateInstance(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static bool Has(string id)
|
||||
{
|
||||
EnsureBuilt();
|
||||
return _byId.ContainsKey(id);
|
||||
}
|
||||
|
||||
/// <summary>取 id 对应的共享 AiGraph;不存在则显式抛错(根因暴露,不做兜底)。</summary>
|
||||
public static AiGraph GetGraph(string id)
|
||||
{
|
||||
EnsureBuilt();
|
||||
if (!_byId.TryGetValue(id, out var script))
|
||||
throw new InvalidOperationException(
|
||||
$"AiDefinitionRegistry: 未找到 AI 定义 id '{id}'。请确认存在 [AiDefinition(\"{id}\")] 的 AiScript 子类。");
|
||||
return script.GetOrBuildGraph();
|
||||
}
|
||||
|
||||
public static IEnumerable<string> Ids
|
||||
{
|
||||
get { EnsureBuilt(); return _byId.Keys; }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 运行确认通过**
|
||||
|
||||
Run: EditMode `AiDefinitionRegistryTests`
|
||||
Expected: PASS(4 项)。全量测试无失败。
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add Assets/_Game/Scripts/AI/AiDefinitionRegistry.cs Assets/_Game/Scripts/AI/AiDefinitionRegistry.cs.meta \
|
||||
Assets/Tests/EditMode/AI/AiDefinitionRegistryTests.cs Assets/Tests/EditMode/AI/AiDefinitionRegistryTests.cs.meta
|
||||
git commit -m "feat(ai): AiDefinitionRegistry——反射按id提供共享AiGraph(关域重载重置)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `BaseGames.Enemies` 引用 `BaseGames.AI` + `EnemyBrainContext`
|
||||
|
||||
**Files:**
|
||||
- Modify: `Assets/_Game/Scripts/Enemies/BaseGames.Enemies.asmdef`
|
||||
- Create: `Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs`
|
||||
|
||||
无独立单测(依赖 EnemyBase MonoBehaviour,行为由 Task 7 集成验证);本任务编译校验。
|
||||
|
||||
- [ ] **Step 1: 给 `BaseGames.Enemies.asmdef` 的 references 增加 `"BaseGames.AI"`**
|
||||
|
||||
先 Read `Assets/_Game/Scripts/Enemies/BaseGames.Enemies.asmdef`,在 `references` 数组里加入一行 `"BaseGames.AI"`(放在 `"BaseGames.Core"` 之后即可)。其余字段不动。
|
||||
|
||||
- [ ] **Step 2: 创建 `Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs`**
|
||||
|
||||
```csharp
|
||||
using UnityEngine;
|
||||
using BaseGames.AI;
|
||||
|
||||
namespace BaseGames.Enemies
|
||||
{
|
||||
/// <summary>
|
||||
/// 把 EnemyBase 的子系统包装成 BrainGraph 的能力接口(每敌人一实例)。
|
||||
/// 状态逻辑只依赖这些接口,不直接引用具体子系统。
|
||||
/// </summary>
|
||||
public sealed class EnemyBrainContext : IAiContext, ISensor, IMover, ICombatant, IActorVitals
|
||||
{
|
||||
readonly EnemyBase _enemy;
|
||||
readonly Blackboard _blackboard = new Blackboard();
|
||||
Vector2 _lastKnown;
|
||||
float _lostTimer;
|
||||
|
||||
public EnemyBrainContext(EnemyBase enemy) { _enemy = enemy; }
|
||||
|
||||
// ---- IAiContext ----
|
||||
public ISensor Sensor => this;
|
||||
public IMover Mover => this;
|
||||
public ICombatant Combat => this;
|
||||
public IActorVitals Vitals => this;
|
||||
public Blackboard Blackboard => _blackboard;
|
||||
|
||||
/// <summary>每帧由 EnemyAiBrain 在 Tick 之前调用,维护最后已知位置与丢失计时。</summary>
|
||||
public void Refresh(float dt)
|
||||
{
|
||||
if (_enemy.IsPlayerVisible() && _enemy.PlayerTransform != null)
|
||||
{
|
||||
_lastKnown = _enemy.PlayerTransform.position;
|
||||
_lostTimer = 0f;
|
||||
}
|
||||
else
|
||||
{
|
||||
_lostTimer += dt;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>对象池复用时清空临时态。</summary>
|
||||
public void ResetScratch()
|
||||
{
|
||||
_blackboard.Clear();
|
||||
_lastKnown = _enemy.transform.position;
|
||||
_lostTimer = 0f;
|
||||
}
|
||||
|
||||
// ---- ISensor ----
|
||||
public bool SeesPlayer() => _enemy.IsPlayerVisible();
|
||||
public bool InRange(float range) => _enemy.IsPlayerInRange(range);
|
||||
public bool LostFor(float seconds) => !_enemy.IsPlayerVisible() && _lostTimer >= seconds;
|
||||
public Vector2 LastKnown => _lastKnown;
|
||||
|
||||
// ---- IMover ----
|
||||
public void MoveTo(Vector2 target) => _enemy.MoveTo(target);
|
||||
public void FacePlayer() => _enemy.FacePlayer();
|
||||
public void Stop() => _enemy.StopMovement();
|
||||
public void WalkRandom()
|
||||
{
|
||||
var nav = _enemy.Nav;
|
||||
if (nav != null && !nav.IsMoving) nav.WalkToRandom();
|
||||
}
|
||||
public void LookAround() => _enemy.BeginLookAround();
|
||||
|
||||
// ---- ICombatant ----
|
||||
public bool UseAbility(string abilityId)
|
||||
{
|
||||
var a = _enemy.Abilities?.Get(abilityId);
|
||||
return a != null && a.Execute();
|
||||
}
|
||||
public bool IsAbilityRunning(string abilityId = null)
|
||||
{
|
||||
var reg = _enemy.Abilities;
|
||||
if (reg == null) return false;
|
||||
if (abilityId != null)
|
||||
{
|
||||
var a = reg.Get(abilityId);
|
||||
return a != null && a.IsRunning;
|
||||
}
|
||||
var all = reg.All;
|
||||
for (int i = 0; i < all.Count; i++)
|
||||
if (all[i].IsRunning) return true;
|
||||
return false;
|
||||
}
|
||||
public bool NoAbilityRunning => !IsAbilityRunning();
|
||||
public bool CanUseAbility(string abilityId)
|
||||
{
|
||||
var a = _enemy.Abilities?.Get(abilityId);
|
||||
return a != null && a.CanUse;
|
||||
}
|
||||
|
||||
// ---- IActorVitals ----
|
||||
public bool IsAlive => _enemy.IsAlive;
|
||||
public bool IsControllable => _enemy.CurrentState == EnemyStateType.Controlled;
|
||||
public float HpPercent
|
||||
{
|
||||
get
|
||||
{
|
||||
var so = _enemy.StatsSO;
|
||||
return so != null && so.MaxHP > 0 ? (float)_enemy.Stats.CurrentHP / so.MaxHP : 0f;
|
||||
}
|
||||
}
|
||||
public bool HpBelow(float ratio) => HpPercent < ratio;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 若某个成员的 EnemyBase 属性/方法名与上面不符(如 `Nav` 无 `IsMoving`),**先 Read `EnemyBase.cs`/`IPathAgent.cs`/`EnemyAbilityRegistry.cs` 核对真实签名再调整**——不要臆造。`EnemyStateType` 若是 `EnemyBase` 的嵌套类型,则写 `EnemyBase.EnemyStateType.Controlled`。
|
||||
|
||||
- [ ] **Step 3: 刷新并确认编译零错误**
|
||||
|
||||
`unity_execute_menu_item`("Assets/Refresh") → `unity_get_compilation_errors`(severity 'error') = 0。
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add Assets/_Game/Scripts/Enemies/BaseGames.Enemies.asmdef \
|
||||
Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs.meta \
|
||||
Assets/_Game/Scripts/Enemies/AIBrain.meta
|
||||
git commit -m "feat(ai): EnemyBrainContext——EnemyBase子系统适配为BrainGraph能力接口"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `EnemyAiBrain` 组件
|
||||
|
||||
**Files:**
|
||||
- Create: `Assets/_Game/Scripts/Enemies/AIBrain/EnemyAiBrain.cs`
|
||||
|
||||
- [ ] **Step 1: 创建 `EnemyAiBrain.cs`**
|
||||
|
||||
```csharp
|
||||
using UnityEngine;
|
||||
using BaseGames.AI;
|
||||
|
||||
namespace BaseGames.Enemies
|
||||
{
|
||||
/// <summary>
|
||||
/// 把 BrainGraph 决策层挂到敌人上:按 id 取共享 AiGraph、构造 AiRuntime、逐帧推进。
|
||||
/// 取代旧的 Opsive BehaviorTree 组件。
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
[RequireComponent(typeof(EnemyBase))]
|
||||
public sealed class EnemyAiBrain : MonoBehaviour
|
||||
{
|
||||
[Tooltip("对应 [AiDefinition(id)] 的敌人 AI 定义 id,例如 E001")]
|
||||
[SerializeField] string _definitionId;
|
||||
|
||||
EnemyBase _enemy;
|
||||
EnemyBrainContext _context;
|
||||
AiRuntime _runtime;
|
||||
|
||||
public string DefinitionId => _definitionId;
|
||||
public string CurrentStateName => _runtime != null ? _runtime.CurrentStateName : "(none)";
|
||||
public bool IsSuspended => _runtime != null && _runtime.IsSuspended;
|
||||
public AiRuntime Runtime => _runtime;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
_enemy = GetComponent<EnemyBase>();
|
||||
if (string.IsNullOrEmpty(_definitionId))
|
||||
{
|
||||
// 根因暴露:漏配 id 直接报错,不静默兜底。
|
||||
Debug.LogError($"EnemyAiBrain 未配置 _definitionId:{name}", this);
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
var graph = AiDefinitionRegistry.GetGraph(_definitionId); // 不存在则抛异常
|
||||
_context = new EnemyBrainContext(_enemy);
|
||||
_runtime = new AiRuntime(graph, _context);
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (_runtime == null) return;
|
||||
float dt = Time.deltaTime;
|
||||
_context.Refresh(dt);
|
||||
_runtime.Tick(dt);
|
||||
}
|
||||
|
||||
/// <summary>对象池复用时由 EnemyBase.OnSpawn 调用:回到 Entry、清临时态。</summary>
|
||||
public void ResetBrain()
|
||||
{
|
||||
_context?.ResetScratch();
|
||||
_runtime?.Reset();
|
||||
}
|
||||
|
||||
/// <summary>信号入口,供 EnemyBase 转发死亡等事件到决策层。</summary>
|
||||
public void Send(AiSignal signal) => _runtime?.Send(signal);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 设计说明:本阶段每帧 Tick(不做 LOD 节流)——LOD/错峰调度属后续 AiScheduler 阶段。`AiRuntime.Reset()` 已清 Blackboard,但 `EnemyBrainContext` 的 `_lastKnown/_lostTimer` 另需 `ResetScratch()` 清(`AiRuntime` 不知道 context 内部字段)。
|
||||
|
||||
- [ ] **Step 2: 刷新并确认编译零错误**
|
||||
|
||||
`unity_execute_menu_item`("Assets/Refresh") → `unity_get_compilation_errors`(error) = 0。
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add Assets/_Game/Scripts/Enemies/AIBrain/EnemyAiBrain.cs Assets/_Game/Scripts/Enemies/AIBrain/EnemyAiBrain.cs.meta
|
||||
git commit -m "feat(ai): EnemyAiBrain 组件——承载 AiRuntime、逐帧推进、池复用重置、信号入口"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: E001 的 AiScript 定义
|
||||
|
||||
**Files:**
|
||||
- Create: `Assets/_Game/Scripts/Enemies/AIBrain/Ai/E001CaoZhiAi.cs`
|
||||
|
||||
- [ ] **Step 1: 创建 `E001CaoZhiAi.cs`**
|
||||
|
||||
```csharp
|
||||
using BaseGames.AI;
|
||||
|
||||
namespace BaseGames.Enemies
|
||||
{
|
||||
/// <summary>
|
||||
/// E001(草蛭)的 AI 定义:巡逻 → 追击(接触即伤害,无需攻击能力)→ 搜查 → 巡逻。
|
||||
/// 数值目前用字面量常量;后续可外挂 SO 做热调。
|
||||
/// </summary>
|
||||
[AiDefinition("E001")]
|
||||
public sealed class E001CaoZhiAi : AiScript
|
||||
{
|
||||
const float ContactRange = 1.2f; // 贴近判定(仅用于 Chase→Search 前的接近,接触伤害由 BodyContactDamage 触发)
|
||||
const float LoseTimeout = 2.5f; // 丢失玩家判定
|
||||
const float SearchTime = 3f; // 搜查后返回巡逻
|
||||
|
||||
protected override void Build(BrainBuilder b)
|
||||
{
|
||||
b.Entry("Patrol");
|
||||
|
||||
// 死亡:全局事件,终止决策
|
||||
b.Global().To("Dead").OnEvent(AiSignal.Died);
|
||||
|
||||
b.State("Patrol")
|
||||
.Tick(c => c.Mover.WalkRandom())
|
||||
.To("Chase").When(c => c.Sensor.SeesPlayer(), "SeesPlayer");
|
||||
|
||||
b.State("Chase")
|
||||
.OnEnter(c => c.Mover.FacePlayer())
|
||||
.Tick(c => c.Mover.MoveTo(c.Sensor.LastKnown)) // 冲向玩家,接触即由 BodyContactDamage 造成伤害
|
||||
.To("Search").When(c => c.Sensor.LostFor(LoseTimeout), "LostFor");
|
||||
|
||||
b.State("Search")
|
||||
.OnEnter(c => c.Mover.LookAround())
|
||||
.To("Chase").When(c => c.Sensor.SeesPlayer(), "SeesPlayer")
|
||||
.To("Patrol").After(SearchTime);
|
||||
|
||||
b.State("Dead")
|
||||
.OnEnter(c => c.Mover.Stop());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `ContactRange` 常量暂未用于转换(接触伤害由碰撞触发);保留供后续若加"贴脸减速/停顿"用。如实现时发现 E001 需要一个显式攻击能力,再加 Combat 态与 `c.Combat.UseAbility(...)`(先 Read E001 prefab 的 Abilities 子节点确认真实 abilityId)。
|
||||
|
||||
- [ ] **Step 2: 刷新并确认编译零错误;确认注册表能发现 E001**
|
||||
|
||||
`unity_execute_menu_item`("Assets/Refresh") → `unity_get_compilation_errors`(error) = 0。
|
||||
用 `unity_execute_code`(port 7890) 执行断言:`BaseGames.AI.AiDefinitionRegistry.Has("E001")` 应为 true,`GetGraph("E001").EntryState` 应为 `"Patrol"`。
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add Assets/_Game/Scripts/Enemies/AIBrain/Ai/E001CaoZhiAi.cs Assets/_Game/Scripts/Enemies/AIBrain/Ai/E001CaoZhiAi.cs.meta \
|
||||
Assets/_Game/Scripts/Enemies/AIBrain/Ai.meta
|
||||
git commit -m "feat(ai): E001(草蛭) AiScript 定义——巡逻/追击/搜查(接触伤害杂兵)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: EnemyBase 移除 Opsive BT 集成 + 接 EnemyAiBrain
|
||||
|
||||
**Files:**
|
||||
- Modify: `Assets/_Game/Scripts/Enemies/EnemyBase.cs`
|
||||
|
||||
这是本阶段最需谨慎的改动。**务必先完整 Read `EnemyBase.cs` 确认每个 `#if GRAPH_DESIGNER` 块与目标方法的真实当前内容与行号**(下面行号为探查快照,可能因前面提交而漂移)。逐块处理、每步后刷新确认零编译错误。
|
||||
|
||||
- [ ] **Step 1: 先 Read `EnemyBase.cs`,定位所有 `#if GRAPH_DESIGNER ... #endif` 块**(用 Grep `GRAPH_DESIGNER`)。预期位置:using(~:8-10)、LOD 间隔字段(~:60-72)、调试字段(~:135-137)、`BehaviorTree` 属性(~:257-259)、`SetAggroTickRate`(~:376-380)、`SetAiPhase` 内 BT 间隔 switch(~:471-483)、Awake 挂树(~:606-618)、Update tick(~:629-648)、Update 调试写(~:654-656)、BT 字段(~:697-702)、`StopBehaviorTree()`(~:709-714)、OnSpawn(~:807-809)、OnDespawn(~:823-825)。
|
||||
|
||||
- [ ] **Step 2: 删除所有 Opsive/BT 专属代码**
|
||||
- 删 `#if GRAPH_DESIGNER using Opsive...#endif`。
|
||||
- 删 `_btIdleTickInterval`…`_btCombatTickInterval` 等 LOD 间隔字段与 `_dbg_BtTickInterval` 调试字段。
|
||||
- 删 `public BehaviorTree BehaviorTree => _behaviorTree;` 属性。
|
||||
- 删 Awake 里 `#if GRAPH_DESIGNER … _behaviorTree = GetComponent<BehaviorTree>() … #endif` 整块。
|
||||
- 删 Update 里 `#if GRAPH_DESIGNER … _behaviorTree.Tick() … #endif` 整块与其后调试写行。
|
||||
- 删 `_behaviorTree/_btManualMode/_btTickTimer/_btCurrentInterval` 字段。
|
||||
- 删 `StopBehaviorTree()` 方法**体内**的 BT 调用;见 Step 4 关于该 public 方法的处理。
|
||||
- 删 OnSpawn/OnDespawn 里的 `#if GRAPH_DESIGNER … _behaviorTree… #endif` 块(OnSpawn 的替换见 Step 4)。
|
||||
- `SetAggroTickRate(bool)`:删其 BT 间隔赋值;若方法体因此为空,保留为空方法体并加注释 `// LOD tick 速率控制迁移到后续 AiScheduler 阶段`(不要删方法,避免改动调用方)。
|
||||
- `SetAiPhase(...)`:只删其中设置 `_btCurrentInterval` 的 switch;**保留** `_currentAiPhase` 赋值、`OnAiPhaseChanged` 广播、阶段动画等其余逻辑。
|
||||
|
||||
- [ ] **Step 3: 加 EnemyAiBrain 引用与 Awake 获取**
|
||||
- 加字段:`private EnemyAiBrain _brain;`
|
||||
- 在 Awake 的子系统收集处(如 `_abilities.CollectFrom(gameObject);` 附近)加:`_brain = GetComponent<EnemyAiBrain>();`
|
||||
|
||||
- [ ] **Step 4: 死亡发信号 + 池复用重置**
|
||||
- 在 `PerformDeath()`(死亡结算处,原先调用 `StopBehaviorTree`/`ForceState(Dead)` 的地方)加:`_brain?.Send(BaseGames.AI.AiSignal.Died);`
|
||||
- `StopBehaviorTree()` 现无 BT 可停:把方法体改为 `_brain?.Send(BaseGames.AI.AiSignal.Died);`(保留方法名,因 `EnemyDeathSequence` 等外部调用它;语义变为"通知决策层终止")。若 Step 4 已在 PerformDeath 直接 Send,则 `StopBehaviorTree` 保留同一 Send 以兼容既有调用点即可(幂等,重复 Died 事件不会二次转换)。
|
||||
- 在 `OnSpawn()`(原 `#if GRAPH_DESIGNER _behaviorTree?.StartBehavior(); #endif` 处)替换为:`_brain?.ResetBrain();`
|
||||
|
||||
- [ ] **Step 5: 逐步刷新并确认零编译错误**
|
||||
|
||||
每删/改一处后 `unity_execute_menu_item`("Assets/Refresh") → `unity_get_compilation_errors`(error)。全部处理完应为 **0 error**。注意:删除 BT 字段后若有残留引用(如 debug inspector),一并清理。
|
||||
|
||||
- [ ] **Step 6: 跑全量 EditMode 测试确认无回归**
|
||||
|
||||
`unity_advanced_tool` → `unity_testing_run_tests`(EditMode) → `unity_testing_get_job`:应无失败(此改动不涉及 EditMode 测试,但确认未破坏编译面)。
|
||||
|
||||
- [ ] **Step 7: 提交**
|
||||
|
||||
```bash
|
||||
git add Assets/_Game/Scripts/Enemies/EnemyBase.cs
|
||||
git commit -m "refactor(ai): EnemyBase 移除 Opsive BT 集成,改接 EnemyAiBrain(Died信号/OnSpawn重置)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: 脚手架一键挂载 `EnemyAiBrain`(`SceneObjectPlacerTool`)
|
||||
|
||||
**Files:**
|
||||
- Modify: `Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs`
|
||||
|
||||
- [ ] **Step 1: 定位 `PlaceE001_CaoZhi` 里的行为树 TODO 提示行**
|
||||
|
||||
先 Read 该方法(~:439-530),找到 `report.Add("★ 挂载行为树 BehaviorTree 组件,指定 E001_CaoZhi.asset。");`(~:524)。
|
||||
|
||||
- [ ] **Step 2: 用真实挂载替换该提示行**
|
||||
|
||||
把该 `report.Add("★ …")` 行替换为(放在 `SetupPerceptionSystemSlots(...)` 之后、方法收尾之前):
|
||||
|
||||
```csharp
|
||||
// BrainGraph AI:挂载决策组件并绑定定义 id(取代旧的 Opsive BehaviorTree)
|
||||
var brain = GetOrAddComponent<EnemyAiBrain>(go);
|
||||
AssignString(brain, "_definitionId", "E001", report);
|
||||
report.Add("✔ 已挂载 EnemyAiBrain 并绑定 AI 定义 id=E001(BrainGraph)。");
|
||||
```
|
||||
|
||||
> `EnemyAiBrain` 在 `BaseGames.Enemies`,`SceneObjectPlacerTool` 是 Editor 程序集,需确认 Editor asmdef 已引用 `BaseGames.Enemies`(放置工具本就 new/操作 EnemyBase,故必然已引用——若编译报找不到类型,检查 Editor asmdef references)。文件顶部按需加 `using BaseGames.Enemies;`。`AssignString` 为该文件内既有 private static 辅助(~:2333)。
|
||||
|
||||
- [ ] **Step 3: 刷新并确认编译零错误**
|
||||
|
||||
`unity_execute_menu_item`("Assets/Refresh") → `unity_get_compilation_errors`(error) = 0。
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs
|
||||
git commit -m "feat(ai): 脚手架 PlaceE001 一键挂载 EnemyAiBrain 并绑定 id=E001"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 集成验证(脚手架一键 → PlayMode 驱动 → 无 BehaviorTree)
|
||||
|
||||
**Files:** 无(经 MCP 驱动 Unity 验证;如需可加临时验证脚本但完成后删除)。
|
||||
|
||||
- [ ] **Step 1: 准备一个干净场景并确认活动场景**
|
||||
|
||||
用 MCP:`unity_scene_new`(或打开一个测试场景)。**务必确认活动场景正确**(避免污染其他场景,见项目约束)。记录场景名。
|
||||
|
||||
- [ ] **Step 2: 脚手架一键放置 E001**
|
||||
|
||||
用 MCP `unity_execute_menu_item`(menuPath `"BaseGames/Scene/Place/Enemy E001 (草蛭)"`, port 7890)。
|
||||
然后 `unity_scene_hierarchy` / `unity_gameobject_info` 找到新建的 `ENM_CaoZhi`。
|
||||
|
||||
- [ ] **Step 3: 静态断言——挂了 EnemyAiBrain、绑了 id、且无 BehaviorTree**
|
||||
|
||||
用 `unity_component_get_properties`(或 `unity_gameobject_info`)确认 `ENM_CaoZhi`:
|
||||
- 有 `EnemyAiBrain` 组件,其 `_definitionId == "E001"`。
|
||||
- **没有** `BehaviorTree`(Opsive)组件。
|
||||
|
||||
- [ ] **Step 4: PlayMode 冒烟——决策层随玩家远近推进**
|
||||
|
||||
- 确保场景内有玩家(或用 MCP 在玩家可被 E001 感知的位置放一个带 Player 标签/层的对象;若测试场景无玩家系统,可退而验证 Step 5 的状态读取)。
|
||||
- `unity_play_mode`(enter) → 等一帧。
|
||||
- `unity_execute_code`(port 7890) 读取:`GameObject.Find("ENM_CaoZhi").GetComponent<BaseGames.Enemies.EnemyAiBrain>().CurrentStateName`。
|
||||
- 无玩家在感知范围时应为 `"Patrol"`(证明 AiRuntime 在 Tick、状态机在跑)。
|
||||
- 让玩家进入 E001 感知范围(移动玩家 Transform 到近处),再读 `CurrentStateName` 应变为 `"Chase"`;移开并等待 `LoseTimeout` 后应变 `"Search"`。
|
||||
- `unity_play_mode`(exit)。
|
||||
|
||||
- [ ] **Step 5: 记录验证结论**
|
||||
|
||||
在计划文件或提交信息里记录:脚手架一键产出的 E001 由 BrainGraph 驱动、无 BehaviorTree 组件、状态随感知推进。若无玩家系统导致 Chase/Search 未能触发,至少确认 Patrol 态在跑 + 无 BehaviorTree + 注册表解析 E001 成功,并记录"完整行为待真实关卡验证"。
|
||||
|
||||
- [ ] **Step 6: 运行项目自检(合规)**
|
||||
|
||||
用 MCP `unity_execute_menu_item` 依次跑(若菜单存在):
|
||||
- `BaseGames/Tools/Validation/Validate All ScriptableObjects`
|
||||
- `BaseGames/Addressables/Validate Address Keys`
|
||||
- `BaseGames/Tools/Maintenance/Physics2D Layer Matrix/Check`
|
||||
记录无新增错误。
|
||||
|
||||
- [ ] **Step 7: 提交验证产物(若有)/收尾**
|
||||
|
||||
```bash
|
||||
# 若 Step 1 新建了测试场景且需要保留,按规范命名保存;否则不提交场景。
|
||||
git add -- <仅本任务需保留的文件>
|
||||
git commit -m "test(ai): 集成验证——脚手架一键 E001 跑在 BrainGraph 上、无 BehaviorTree"
|
||||
```
|
||||
|
||||
> ⚠️ 只提交本任务确需保留的文件;不要 `git add -A`(仓库有既存无关改动)。
|
||||
|
||||
---
|
||||
|
||||
## 后续(不属本阶段)
|
||||
|
||||
- LOD/错峰 tick(AiScheduler)、能力编排 sugar、参数 SO 热调(把 E001 字面量迁到 SO)。
|
||||
- 其余敌人(E002-E006)与 Boss(ChaoFeng,多阶段/加权技能/弱点)迁移;`CharacterWizardWindow` 若要显式选 AI 定义再加下拉。
|
||||
- 全部敌人达到 parity 后:删 49 个 `BD_*`、`BaseGames.Enemies.AI` 去 Opsive 引用、移除 `com.opsive.*` 包与 `GRAPH_DESIGNER` define。
|
||||
- 交付面:MermaidExporter / AiDebugService / 运行时调试器窗口。
|
||||
|
||||
---
|
||||
|
||||
## 自检记录(writing-plans self-review)
|
||||
|
||||
- **Spec 覆盖**:注册表(§7.1)、上下文适配(§4/单类实现,用户已选)、EnemyAiBrain 组件(§7.1)、EnemyBase 去 BT+信号+池复用重置(§7.1/§3.6/§3.8)、脚手架接线(§7.3/§2)、E001 试点(§8)、集成验证(§11.1)。LOD/编排/参数SO/其余敌人/删BD 明确列入后续。
|
||||
- **占位扫描**:无 TBD。ContactRange 常量未用于转换已注明用途;ability 相关按"接触伤害"简化,注明若需攻击能力再加。
|
||||
- **类型一致性**:`AiDefinitionRegistry.GetGraph/Has/Ids`、`EnemyBrainContext`(实现 IAiContext+4 接口)、`EnemyAiBrain.CurrentStateName/ResetBrain/Send/DefinitionId`、`AiRuntime.Reset/Send/CurrentStateName/IsSuspended`、`AiScript.GetOrBuildGraph`、`[AiDefinition]` 跨任务一致。
|
||||
- **风险点**:Task 5(EnemyBase 手术)最需谨慎——已要求先 Read 核对真实行号、逐块刷新验证;Task 2 的 EnemyBase 成员名以真实签名为准(要求核对不臆造);Task 7 依赖测试场景有玩家,已给降级验证路径。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,792 @@
|
||||
# 敌人执行层统一 (EnemyLocomotion) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 用统一的 `EnemyLocomotion` 执行器取代 7 种巡逻/4 套移动/`IMover`/三个协程能力,删除 `AiPhase` 影子层与 `EnemyStatsSO` 死字段,让敌人 AI 保持"只决策"、执行层单一化——先在 E001 落地并验证。
|
||||
|
||||
**Architecture:** 三条正交轴:反应态 FSM(`EnemyStateType`) 当门,BrainGraph 决策(声明 locomotion 意图 + 触发攻击能力),执行层 = `EnemyLocomotion`(移动/朝向/步态动画) + `EnemyAbilityBase`(攻击)。`EnemyLocomotion` 是 `EnemyNavAgent`(地面 PB2d) / `FlyingDirectNavigator`(飞行直飞) 两个 `IPathAgent` 后端之上的唯一门面,对外暴露 `SetMode/Approach/MoveTo/Face/Stop`(声明意图,执行在内部)。
|
||||
|
||||
**Tech Stack:** Unity 2022.3 (C# 9)、PathBerserker2d、Animancer、自研 BrainGraph、NUnit EditMode 测试、Unity MCP(端口 7890)做 PlayMode 验证。
|
||||
|
||||
**前置状态:** P1(删死代码 BD_*/Opsive/宏) 已完成 (commit `e894a9d`),编译 0 错误。本计划从 P2 起。设计见 `Docs/superpowers/specs/2026-07-10-enemy-locomotion-refactor-design.md`。
|
||||
|
||||
**全局约定:**
|
||||
- 每个 Task 结束提交一次;提交信息中文 + 类型前缀。
|
||||
- 编译验证统一用:`unity_execute_code` 触发 `AssetDatabase.Refresh()` + `RequestScriptCompilation()`,等待后 `unity_get_compilation_errors severity=error` 期望 count=0。下称"**编译门**"。
|
||||
- PlayMode 验证统一:`unity_play_mode play` → `unity_execute_code` 断言 → `unity_play_mode stop`。TestRoomA 为活动场景,E001 实例名含 "CaoZhi"。
|
||||
- 禁止在 AI 脚本(`AiScript`/`PerceptionStateMachine`)里出现 velocity/朝向/动画的**实现**;只允许声明意图。物理原生约束保持(寻路只给方向、velocity 执行、碰撞体底部 y=0)。
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**新建:**
|
||||
- `Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs` — 统一移动执行器 + `LocomotionMode`/`PatrolStrategy` 枚举 + `IEnemyLocomotion` 接口。
|
||||
- `Assets/Tests/EditMode/AI/Fakes/FakeLocomotion.cs` — 测试用 locomotion spy(记录调用)。
|
||||
|
||||
**修改:**
|
||||
- `Assets/_Game/Scripts/AI/IAiContext.cs` — `IMover Mover` → `IEnemyLocomotion Locomotion`(注:接口在 Enemies 程序集,见 Task 说明)。
|
||||
- `Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs` — 去 `IMover` 实现,暴露 `Locomotion`。
|
||||
- `Assets/_Game/Scripts/Enemies/AIBrain/PerceptionStateMachine.cs` — `AddAbilityState` → `AddLocomotionState`(状态绑意图)。
|
||||
- `Assets/_Game/Scripts/Enemies/AIBrain/Ai/E001CaoZhiAi.cs` — 声明每状态 locomotion 意图 + Chase 能力。
|
||||
- `Assets/_Game/Scripts/Enemies/Abilities/ContactChaseAbility.cs` — 内部 `MoveTo` → `Locomotion.Approach`。
|
||||
- `Assets/_Game/Scripts/Enemies/EnemyBase.cs` — 缓存/暴露 `Locomotion`;删 `SetAiPhase`/`AiPhase` 相关(P4)。
|
||||
- `Assets/_Game/Scripts/Enemies/EnemyMovement.cs` — 删对 `EnemyStatsSO` 的速度直读(P5)。
|
||||
- `Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs` — 删死字段(P5)。
|
||||
- `Assets/_Game/Scripts/Enemies/EnemyAnimationConfigSO.cs` / 动画驱动 — locomotion 模式→步态 clip(P4)。
|
||||
- `Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs` — PlaceE001 挂 `EnemyLocomotion`、去三协程能力。
|
||||
- `Assets/Tests/EditMode/AI/AiRuntimeTests.cs`、`FakeAiContext.cs` — `Mover`→`Locomotion` spy。
|
||||
|
||||
**删除:**
|
||||
- `Assets/_Game/Scripts/AI/IMover.cs`(+meta)
|
||||
- `Assets/_Game/Scripts/Enemies/Abilities/IdleAbility.cs`、`PatrolAbility.cs`、`AlertAbility.cs`(+meta)
|
||||
- `Assets/_Game/Data/Enemies/E001/Abilities/ABL_E001_Idle.asset`、`ABL_E001_Patrol.asset`(+ Alert 的处理见 Task)
|
||||
- `Assets/_Game/Scripts/Enemies/AiPhase.cs`(+meta)(P4)
|
||||
|
||||
**接口归属说明:** `IEnemyLocomotion` 引用 `LocomotionMode`(Enemies 命名空间概念)。为避免 `BaseGames.AI` 程序集反向依赖 Enemies,采用与现有 `IEnemyActor` 相同的模式:`IAiContext.Locomotion` 的**类型**放在 `BaseGames.AI` 里定义为 `IEnemyLocomotion`(AI 程序集内),`LocomotionMode`/`PatrolStrategy` 枚举**也放 AI 程序集**(纯枚举无依赖),`EnemyLocomotion` MonoBehaviour 在 Enemies 程序集实现该接口。这与 `ISensor`/`ICombatant` 一致。
|
||||
|
||||
---
|
||||
|
||||
## P2 — 引入 EnemyLocomotion(与旧 API 并存,不改 AI)
|
||||
|
||||
本阶段只新增组件并接到 E001,Model A 协程能力暂留、AI 不改。目的:`EnemyLocomotion` 能独立驱动 Idle/Patrol/Face/Approach。
|
||||
|
||||
### Task 1: 定义 `IEnemyLocomotion` 接口与枚举(AI 程序集)
|
||||
|
||||
**Files:**
|
||||
- Create: `Assets/_Game/Scripts/AI/IEnemyLocomotion.cs`
|
||||
|
||||
- [ ] **Step 1: 创建接口文件**
|
||||
|
||||
```csharp
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.AI
|
||||
{
|
||||
/// <summary>移动执行器的行为模式。</summary>
|
||||
public enum LocomotionMode { Idle, Patrol, Face, Approach }
|
||||
|
||||
/// <summary>巡逻策略(Patrol 模式下的具体走法)。</summary>
|
||||
public enum PatrolStrategy { Wander, Pace, Waypoints }
|
||||
|
||||
/// <summary>
|
||||
/// 敌人移动执行器的声明式接口。AI/能力只"声明意图",执行在实现内部完成
|
||||
/// (AI 不 actuate)。实现见 BaseGames.Enemies.EnemyLocomotion。
|
||||
/// </summary>
|
||||
public interface IEnemyLocomotion
|
||||
{
|
||||
void SetMode(LocomotionMode mode); // Idle(停) / Patrol(按配置策略游走)
|
||||
void Approach(Transform target); // 持续跟随,派生 RunSpeed
|
||||
void MoveTo(Vector2 point); // 一次性目标点
|
||||
void Face(Vector2 lookAt); // 停 + 朝向
|
||||
void Stop();
|
||||
LocomotionMode CurrentMode { get; }
|
||||
bool IsMoving { get; }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 编译门** — 触发编译,`unity_get_compilation_errors` 期望 count=0。
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add Assets/_Game/Scripts/AI/IEnemyLocomotion.cs Assets/_Game/Scripts/AI/IEnemyLocomotion.cs.meta
|
||||
git commit -m "feat(ai): 新增 IEnemyLocomotion 接口与 LocomotionMode/PatrolStrategy 枚举"
|
||||
```
|
||||
|
||||
### Task 2: 实现 `EnemyLocomotion` 组件(委托现有 EnemyBase/Nav)
|
||||
|
||||
**Files:**
|
||||
- Create: `Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs`
|
||||
|
||||
- [ ] **Step 1: 创建组件**(P2 只实现 Wander 巡逻;Pace/Waypoints 在 P6)
|
||||
|
||||
```csharp
|
||||
using UnityEngine;
|
||||
using BaseGames.AI;
|
||||
|
||||
namespace BaseGames.Enemies
|
||||
{
|
||||
/// <summary>
|
||||
/// 敌人移动执行器:唯一的移动/朝向入口。AI 状态与能力经 IEnemyLocomotion
|
||||
/// 声明意图,本组件每帧把当前模式翻译为对 EnemyBase/IPathAgent 的调用。
|
||||
/// 取代散落的 MoveTo/StopMovement/FacePlayer/FaceTarget 直调与三个协程能力。
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
public sealed class EnemyLocomotion : MonoBehaviour, IEnemyLocomotion
|
||||
{
|
||||
[Header("巡逻策略(P6 实现 Pace/Waypoints;当前仅 Wander)")]
|
||||
[SerializeField] private PatrolStrategy _patrolStrategy = PatrolStrategy.Wander;
|
||||
|
||||
private EnemyBase _enemy;
|
||||
private LocomotionMode _mode = LocomotionMode.Idle;
|
||||
private Transform _approachTarget;
|
||||
private Vector2 _facePoint;
|
||||
|
||||
public LocomotionMode CurrentMode => _mode;
|
||||
public bool IsMoving => _enemy != null && _enemy.Nav != null && _enemy.Nav.IsMoving;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_enemy = GetComponentInParent<EnemyBase>();
|
||||
if (_enemy == null)
|
||||
Debug.LogError("[EnemyLocomotion] 找不到 EnemyBase。", this);
|
||||
}
|
||||
|
||||
// ── IEnemyLocomotion(声明意图,不立即 actuate 之外的副作用)──
|
||||
public void SetMode(LocomotionMode mode)
|
||||
{
|
||||
if (_mode == mode) return;
|
||||
_mode = mode;
|
||||
if (mode == LocomotionMode.Idle) _enemy?.StopMovement();
|
||||
if (mode == LocomotionMode.Patrol && _enemy?.Stats != null)
|
||||
_enemy.Nav?.SetSpeed(_enemy.Stats.WalkSpeed);
|
||||
}
|
||||
|
||||
public void Approach(Transform target)
|
||||
{
|
||||
_mode = LocomotionMode.Approach;
|
||||
_approachTarget = target;
|
||||
if (_enemy?.Stats != null) _enemy.Nav?.SetSpeed(_enemy.Stats.RunSpeed);
|
||||
}
|
||||
|
||||
public void MoveTo(Vector2 point)
|
||||
{
|
||||
_mode = LocomotionMode.Approach;
|
||||
_approachTarget = null;
|
||||
_enemy?.MoveTo(point);
|
||||
}
|
||||
|
||||
public void Face(Vector2 lookAt)
|
||||
{
|
||||
_mode = LocomotionMode.Face;
|
||||
_facePoint = lookAt;
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
_mode = LocomotionMode.Idle;
|
||||
_enemy?.StopMovement();
|
||||
}
|
||||
|
||||
// ── 每帧把模式翻译成执行 ──
|
||||
private void Update()
|
||||
{
|
||||
if (_enemy == null) return;
|
||||
switch (_mode)
|
||||
{
|
||||
case LocomotionMode.Idle:
|
||||
break; // SetMode(Idle) 已停;保持
|
||||
case LocomotionMode.Patrol:
|
||||
TickPatrol();
|
||||
break;
|
||||
case LocomotionMode.Face:
|
||||
_enemy.StopMovement();
|
||||
_enemy.FaceTarget(_facePoint);
|
||||
break;
|
||||
case LocomotionMode.Approach:
|
||||
if (_approachTarget != null) _enemy.MoveTo(_approachTarget.position);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void TickPatrol()
|
||||
{
|
||||
var nav = _enemy.Nav;
|
||||
if (nav == null) return;
|
||||
switch (_patrolStrategy)
|
||||
{
|
||||
case PatrolStrategy.Wander:
|
||||
if (!nav.IsMoving) nav.WalkToRandom();
|
||||
break;
|
||||
// Pace / Waypoints:P6 实现
|
||||
default:
|
||||
if (!nav.IsMoving) nav.WalkToRandom();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 编译门** — count=0。
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs.meta
|
||||
git commit -m "feat(enemy): 新增 EnemyLocomotion 执行器(Idle/Patrol-Wander/Face/Approach)"
|
||||
```
|
||||
|
||||
### Task 3: 缓存并暴露 `EnemyBase.Locomotion`
|
||||
|
||||
**Files:**
|
||||
- Modify: `Assets/_Game/Scripts/Enemies/EnemyBase.cs`(字段区 + Awake 获取 + 属性)
|
||||
|
||||
> **关键约束(已核实):** `EnemyLocomotion` 在 `BaseGames.Enemies.Navigation` 程序集,该程序集引用 `BaseGames.Enemies`;因此 `EnemyBase`(在 `BaseGames.Enemies`)**不能引用具体类 `EnemyLocomotion`**(会造成循环依赖)。必须用接口 `IEnemyLocomotion`(在 `BaseGames.AI`,Enemies 已引用)+ 运行时 `GetComponent` 发现——与现有 `IPathAgent _nav`(`EnemyBase.cs:60,568`)完全相同的模式。**不要**用 `[SerializeField]` 具体类型。
|
||||
|
||||
- [ ] **Step 1: 加字段与属性**(放在 `_nav`(约 `EnemyBase.cs:60`)字段旁,同 `Nav` 属性风格约 `:216`;确保文件已 `using BaseGames.AI;`)
|
||||
|
||||
```csharp
|
||||
// 移动执行器(IEnemyLocomotion;由 EnemyLocomotion 在 Navigation 程序集实现)
|
||||
protected IEnemyLocomotion _locomotion;
|
||||
```
|
||||
属性(放在 `public IPathAgent Nav => _nav;` 旁):
|
||||
```csharp
|
||||
public IEnemyLocomotion Locomotion => _locomotion;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Awake 获取**(紧邻 `_nav = GetComponent<IPathAgent>() ?? new NullPathAgent();`,约 `EnemyBase.cs:568`)
|
||||
|
||||
```csharp
|
||||
_locomotion = GetComponentInChildren<IEnemyLocomotion>(true);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 编译门** — count=0。
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add Assets/_Game/Scripts/Enemies/EnemyBase.cs
|
||||
git commit -m "feat(enemy): EnemyBase 缓存并暴露 Locomotion"
|
||||
```
|
||||
|
||||
### Task 4: 脚手架 PlaceE001 挂载 EnemyLocomotion 并绑定引用;重生成 prefab
|
||||
|
||||
**Files:**
|
||||
- Modify: `Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs`(PlaceE001_CaoZhi 内)
|
||||
|
||||
- [ ] **Step 1: 在 PlaceE001 里挂组件**(`using BaseGames.Enemies;` 已在文件顶部)
|
||||
|
||||
在 `var brain = GetOrAddComponent<EnemyAiBrain>(go);` 之前插入(`_locomotion` 由 EnemyBase.Awake 经 `GetComponentInChildren<IEnemyLocomotion>` 自动发现,**无需 AssignReference**——它非序列化字段):
|
||||
```csharp
|
||||
GetOrAddComponent<EnemyLocomotion>(go);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 编译门** — count=0。
|
||||
|
||||
- [ ] **Step 3: 重生成 E001 prefab(脚手架为权威)**
|
||||
|
||||
用 `unity_execute_code` 反射调用 `PlaceAndSaveEnemyPrefab("ENM_CaoZhi", PlaceE001_CaoZhi, removeSceneInstance:true)`(同既往做法)。注意此调用较慢、桥接可能短时超时,超时后 `unity_editor_ping` 等恢复再继续。
|
||||
|
||||
- [ ] **Step 4: 用 prefab 实例替换 TestRoomA 场景实例并保存**(保留位置;`PrefabUtility.InstantiatePrefab`,`EditorSceneManager.SaveScene`)。
|
||||
|
||||
- [ ] **Step 5: 验证 prefab 挂了 EnemyLocomotion**
|
||||
|
||||
`unity_execute_code`:加载 prefab,断言 `root.GetComponentInChildren<BaseGames.Enemies.EnemyLocomotion>(true) != null`。(`_locomotion` 是运行时 `GetComponentInChildren` 发现的非序列化字段,故在 PlayMode(Task 5)验证 `EnemyBase.Locomotion != null`,此处只验组件存在。)
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
git add Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs Assets/_Game/Prefabs/Enemies/E001/ENM_CaoZhi.prefab Assets/_Game/Scenes/Testings/TestRoomA.unity
|
||||
git commit -m "feat(scaffold): PlaceE001 挂载 EnemyLocomotion 并绑定引用,重生成 prefab"
|
||||
```
|
||||
|
||||
### Task 5: PlayMode 冒烟验证 EnemyLocomotion 独立可用
|
||||
|
||||
**Files:** 无(仅 MCP 验证)
|
||||
|
||||
- [ ] **Step 1: 进入 PlayMode**,`unity_play_mode play`,等待 ~3s。
|
||||
|
||||
- [ ] **Step 2: 直接驱动 locomotion(绕过 AI)并断言**
|
||||
|
||||
`unity_execute_code`:
|
||||
```csharp
|
||||
var e = UnityEngine.Object.FindObjectsOfType<BaseGames.Enemies.EnemyBase>(true).FirstOrDefault(x=>x.name.Contains("CaoZhi"));
|
||||
var loco = e.Locomotion;
|
||||
loco.Approach(e.PlayerTransform); // 让它朝玩家移动
|
||||
return "mode=" + loco.CurrentMode; // 期望 Approach
|
||||
```
|
||||
等待 ~2s 后再断言 `loco.IsMoving == true`(或位置发生变化)。再 `loco.SetMode(BaseGames.AI.LocomotionMode.Idle)`,断言随后 `IsMoving == false`。
|
||||
|
||||
- [ ] **Step 3: console 0 报错**,`unity_console_log type=error` count=0。
|
||||
|
||||
- [ ] **Step 4: 退出 PlayMode**,`unity_play_mode stop`。(无代码改动,无需提交。)
|
||||
|
||||
---
|
||||
|
||||
## P3 — AI 改绑 locomotion 意图,删协程能力/IMover
|
||||
|
||||
> **执行顺序调整(Unity 全程序集编译约束):** Unity 里测试程序集编译失败会阻断整个项目编译与 MCP 编译门,因此不能提交"编译红"的测试。P3 实际执行顺序改为 **Task 7 → Task 8 → Task 6 → Task 9 → 10 → 11**:先做接口切换(Task 7,含创建 FakeLocomotion 并修好现有 `AiRuntimeTests`/`FakeAiContext`),再做 `AddLocomotionState`(Task 8),最后补 `PerceptionStateMachineTests`(Task 6,此时全部类型已存在,测试应为**绿**——作为回归/特征测试,而非 red-first)。每次提交保持编译绿。
|
||||
|
||||
### Task 6: 编写失败测试 — PerceptionStateMachine 状态设置正确 locomotion 意图
|
||||
|
||||
**Files:**
|
||||
- Create: `Assets/Tests/EditMode/AI/Fakes/FakeLocomotion.cs`
|
||||
- Modify: `Assets/Tests/EditMode/AI/Fakes/FakeAiContext.cs`
|
||||
- Create/Modify: `Assets/Tests/EditMode/AI/PerceptionStateMachineTests.cs`
|
||||
|
||||
- [ ] **Step 1: 创建 FakeLocomotion spy**
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using BaseGames.AI;
|
||||
|
||||
namespace BaseGames.Tests.EditMode.AI
|
||||
{
|
||||
public sealed class FakeLocomotion : IEnemyLocomotion
|
||||
{
|
||||
public List<string> Calls = new List<string>();
|
||||
public LocomotionMode CurrentMode { get; private set; }
|
||||
public bool IsMoving { get; set; }
|
||||
public void SetMode(LocomotionMode mode) { CurrentMode = mode; Calls.Add("SetMode:" + mode); }
|
||||
public void Approach(Transform t) { CurrentMode = LocomotionMode.Approach; Calls.Add("Approach"); }
|
||||
public void MoveTo(Vector2 p) { Calls.Add("MoveTo"); }
|
||||
public void Face(Vector2 p) { CurrentMode = LocomotionMode.Face; Calls.Add("Face"); }
|
||||
public void Stop() { CurrentMode = LocomotionMode.Idle; Calls.Add("Stop"); }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在 FakeAiContext 用 Locomotion 替换 Mover**
|
||||
|
||||
将 `FakeAiContext` 中 `public FakeMover M ...` / `public IMover Mover => M;` 替换为:
|
||||
```csharp
|
||||
public FakeLocomotion L = new FakeLocomotion();
|
||||
public IEnemyLocomotion Locomotion => L;
|
||||
```
|
||||
(`FakeMover` 类可整体删除。)
|
||||
|
||||
- [ ] **Step 3: 写测试(先失败)**——用一个最小 `AiScript` 经 `PerceptionStateMachine` 构图,驱动到各态断言意图。因 `PerceptionStateMachine` 目前还是 `AddAbilityState`,此测试会编译失败或断言失败。
|
||||
|
||||
```csharp
|
||||
using NUnit.Framework;
|
||||
using BaseGames.AI;
|
||||
using BaseGames.Enemies;
|
||||
|
||||
namespace BaseGames.Tests.EditMode.AI
|
||||
{
|
||||
public class PerceptionStateMachineTests
|
||||
{
|
||||
static AiGraph Graph()
|
||||
{
|
||||
var b = new BrainBuilder();
|
||||
PerceptionStateMachine.Add(b, new PerceptionStateMachine.Config
|
||||
{
|
||||
Idle = "Idle", Patrol = "Patrol", Alert = "Alert", Chase = "Chase",
|
||||
Death = "Death", Entry = "Idle", Rest = "Patrol",
|
||||
IdleMode = LocomotionMode.Idle,
|
||||
PatrolMode = LocomotionMode.Patrol,
|
||||
AlertMode = LocomotionMode.Face,
|
||||
ChaseAbilityId = "chase",
|
||||
});
|
||||
return b.Build();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IdleState_SetsIdleMode()
|
||||
{
|
||||
var ctx = new FakeAiContext();
|
||||
var rt = new AiRuntime(Graph(), ctx);
|
||||
Assert.AreEqual(LocomotionMode.Idle, ctx.L.CurrentMode);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ChaseState_TriggersChaseAbility_NotLocomotionDirectly()
|
||||
{
|
||||
var ctx = new FakeAiContext();
|
||||
var rt = new AiRuntime(Graph(), ctx);
|
||||
ctx.S.InChaseZoneValue = true; // 见下方 FakeSensor 字段
|
||||
rt.Tick(0.1f);
|
||||
Assert.AreEqual("Chase", rt.CurrentStateName);
|
||||
CollectionAssert.Contains(ctx.C.Calls, "UseAbility:chase");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
(若 `FakeSensor`/`FakeCombat` 缺 `InChaseZoneValue`/`Calls` 字段,在其 Fake 中补上最小实现——见 Step 记录。)
|
||||
|
||||
- [ ] **Step 4: 运行 EditMode 测试,确认失败**(Unity Test Runner → EditMode,或 MCP 运行测试)。期望:编译失败(`Config.IdleMode` 不存在)或断言失败。这是预期红。
|
||||
|
||||
- [ ] **Step 5: 提交测试(红)**
|
||||
|
||||
```bash
|
||||
git add Assets/Tests/EditMode/AI/Fakes/FakeLocomotion.cs Assets/Tests/EditMode/AI/Fakes/FakeAiContext.cs Assets/Tests/EditMode/AI/PerceptionStateMachineTests.cs
|
||||
git commit -m "test(ai): PerceptionStateMachine locomotion 意图测试(先失败)"
|
||||
```
|
||||
|
||||
### Task 7: 切换 IAiContext.Mover → Locomotion,删 IMover
|
||||
|
||||
**Files:**
|
||||
- Modify: `Assets/_Game/Scripts/AI/IAiContext.cs`
|
||||
- Delete: `Assets/_Game/Scripts/AI/IMover.cs`(+meta)
|
||||
- Modify: `Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs`
|
||||
- Modify: `Assets/Tests/EditMode/AI/AiRuntimeTests.cs`
|
||||
|
||||
- [ ] **Step 1: IAiContext 换成员**
|
||||
|
||||
在 `IAiContext.cs` 把 `IMover Mover { get; }` 改为 `IEnemyLocomotion Locomotion { get; }`。
|
||||
|
||||
- [ ] **Step 2: 删 IMover.cs**
|
||||
|
||||
```bash
|
||||
rm Assets/_Game/Scripts/AI/IMover.cs Assets/_Game/Scripts/AI/IMover.cs.meta
|
||||
```
|
||||
|
||||
- [ ] **Step 3: EnemyBrainContext 实现 Locomotion,删 IMover 实现块**
|
||||
|
||||
- 类声明去掉 `IMover`。
|
||||
- 删除 `// ---- IMover ----` 整段(`MoveTo/FacePlayer/Stop/WalkRandom/LookAround/UseChaseSpeed/UsePatrolSpeed`)。
|
||||
- 把 `public IMover Mover => this;` 改为 `public IEnemyLocomotion Locomotion => _enemy.Locomotion;`。
|
||||
|
||||
- [ ] **Step 4: 修 AiRuntimeTests 里的 `c.Mover.*` 引用**——那些测试用 Mover 当通用副作用 spy,改用 Locomotion spy 语义:
|
||||
- `.Tick(c => c.Mover.WalkRandom())` → `.Tick(c => c.Locomotion.SetMode(LocomotionMode.Patrol))`
|
||||
- `.OnEnter(c => c.Mover.FacePlayer())` → `.OnEnter(c => c.Locomotion.Face(c.Sensor.LastKnown))`
|
||||
- `.OnExit(c => c.Mover.Stop())` → `.OnExit(c => c.Locomotion.Stop())`
|
||||
- `.Tick(c => c.Mover.MoveTo(c.Sensor.LastKnown))` → `.Tick(c => c.Locomotion.MoveTo(c.Sensor.LastKnown))`
|
||||
- `.OnEnter(c => c.Mover.LookAround())` → `.OnEnter(c => c.Locomotion.SetMode(LocomotionMode.Idle))`
|
||||
- 断言里 `ctx.M.Calls` → `ctx.L.Calls`,字符串相应改(如 `"WalkRandom"` → `"SetMode:Patrol"`、`"FacePlayer"`→`"Face"`、`"Stop"`→`"Stop"`、`"LookAround"`→`"SetMode:Idle"`)。
|
||||
|
||||
- [ ] **Step 5: 编译门** — count=0。
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
git add -A Assets/_Game/Scripts/AI Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs Assets/Tests/EditMode/AI/AiRuntimeTests.cs
|
||||
git commit -m "refactor(ai): IAiContext.Mover→Locomotion,删除 IMover"
|
||||
```
|
||||
|
||||
### Task 8: PerceptionStateMachine 改 AddLocomotionState(让 Task 6 测试转绿)
|
||||
|
||||
**Files:**
|
||||
- Modify: `Assets/_Game/Scripts/Enemies/AIBrain/PerceptionStateMachine.cs`
|
||||
|
||||
- [ ] **Step 1: 重写 Config 与状态构建**
|
||||
|
||||
`Config` 把 `IdleAbilityId/PatrolAbilityId/AlertAbilityId` 换成 locomotion 意图字段,保留 `ChaseAbilityId`:
|
||||
```csharp
|
||||
public sealed class Config
|
||||
{
|
||||
public string Idle = "Idle", Patrol = "Patrol", Alert = "Alert",
|
||||
Chase = "Chase", Death = "Death", Entry = "Idle", Rest = "Patrol";
|
||||
public LocomotionMode IdleMode = LocomotionMode.Idle;
|
||||
public LocomotionMode PatrolMode = LocomotionMode.Patrol;
|
||||
public LocomotionMode AlertMode = LocomotionMode.Face; // 停+朝向玩家
|
||||
public string ChaseAbilityId; // Chase 走能力
|
||||
public string DeathAbilityId; // 通常留空
|
||||
}
|
||||
```
|
||||
|
||||
状态构建:Idle/Patrol/Alert 用 locomotion 意图;Chase 用能力;转换规则不变(照抄现有 `.To(...).When(...)`)。新增私有助手:
|
||||
```csharp
|
||||
static BrainBuilder.StateBuilder AddLocomotionState(BrainBuilder b, string state, LocomotionMode mode, bool facePlayer)
|
||||
{
|
||||
return b.State(state)
|
||||
.OnEnter(x => Apply(x, mode, facePlayer))
|
||||
.Tick(x => Apply(x, mode, facePlayer))
|
||||
.OnExit(x => x.Locomotion.Stop());
|
||||
}
|
||||
static void Apply(IAiContext x, LocomotionMode mode, bool facePlayer)
|
||||
{
|
||||
if (facePlayer && x is IEnemyActor && x.Sensor != null)
|
||||
{ /* Face 需玩家点,见下 */ }
|
||||
switch (mode)
|
||||
{
|
||||
case LocomotionMode.Face: x.Locomotion.Face(x.Sensor.LastKnown); break;
|
||||
default: x.Locomotion.SetMode(mode); break;
|
||||
}
|
||||
}
|
||||
static BrainBuilder.StateBuilder AddAbilityState(BrainBuilder b, string state, string abilityId)
|
||||
{
|
||||
return b.State(state)
|
||||
.OnEnter(x => EnsureAbility(x, abilityId))
|
||||
.Tick(x => EnsureAbility(x, abilityId))
|
||||
.OnExit(x => x.Combat.InterruptAbilities());
|
||||
}
|
||||
```
|
||||
说明:`Face` 用 `Sensor.LastKnown`(`EnemyBrainContext.Refresh` 每帧更新为玩家位置),避免 AI 直接引用玩家 Transform;`AddAbilityState` 仅 Chase/Death 用。`EnsureAbility`/`HasAlert` 保持原实现。
|
||||
|
||||
`Add(b,c)`:Idle/Patrol 用 `AddLocomotionState(b, c.Idle, c.IdleMode, false)` / `(c.Patrol, c.PatrolMode, false)`,Alert 用 `AddLocomotionState(b, c.Alert, c.AlertMode, true)`,Chase 用 `AddAbilityState(b, c.Chase, c.ChaseAbilityId)`,Death 用 `AddAbilityState(b, c.Death, c.DeathAbilityId)`。转换 `.To().When()` 全部照抄现有。
|
||||
|
||||
- [ ] **Step 2: 运行 Task 6 的 EditMode 测试,转绿**。期望 `IdleState_SetsIdleMode` 与 `ChaseState_TriggersChaseAbility` PASS。
|
||||
|
||||
- [ ] **Step 3: 编译门** — count=0。
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add Assets/_Game/Scripts/Enemies/AIBrain/PerceptionStateMachine.cs
|
||||
git commit -m "refactor(ai): PerceptionStateMachine 状态改绑 locomotion 意图,测试转绿"
|
||||
```
|
||||
|
||||
### Task 9: E001CaoZhiAi 声明意图;ContactChaseAbility 用 Approach;删三协程能力/SO
|
||||
|
||||
**Files:**
|
||||
- Modify: `Assets/_Game/Scripts/Enemies/AIBrain/Ai/E001CaoZhiAi.cs`
|
||||
- Modify: `Assets/_Game/Scripts/Enemies/Abilities/ContactChaseAbility.cs`
|
||||
- Delete: `IdleAbility.cs`、`PatrolAbility.cs`、`AlertAbility.cs`(+meta)、`ABL_E001_Idle.asset`、`ABL_E001_Patrol.asset`(+meta)
|
||||
|
||||
- [ ] **Step 1: E001CaoZhiAi 用意图 Config**
|
||||
|
||||
```csharp
|
||||
protected override void Build(BrainBuilder b)
|
||||
{
|
||||
PerceptionStateMachine.Add(b, new PerceptionStateMachine.Config
|
||||
{
|
||||
Idle = "Idle_Disguise", Patrol = "Move_Patrol", Alert = "Alert",
|
||||
Chase = "Chase", Death = "Death", Entry = "Idle_Disguise", Rest = "Move_Patrol",
|
||||
IdleMode = LocomotionMode.Idle,
|
||||
PatrolMode = LocomotionMode.Patrol,
|
||||
AlertMode = LocomotionMode.Face,
|
||||
ChaseAbilityId = "e001_chase",
|
||||
});
|
||||
}
|
||||
```
|
||||
(加 `using BaseGames.AI;` 若缺。)
|
||||
|
||||
- [ ] **Step 2: ContactChaseAbility 内部改 Approach**
|
||||
|
||||
把 `ExecuteCoroutine` 里的追击循环由 `_enemy.MoveTo(_enemy.PlayerTransform.position)` 改为经 locomotion:
|
||||
- 起始:`if (_enemy.Nav != null && _enemy.Stats != null) _enemy.Nav.SetSpeed(_enemy.Stats.RunSpeed);` 保留即可(Approach 内也会设),并改追击循环体为:
|
||||
```csharp
|
||||
_enemy.Locomotion.Approach(_enemy.PlayerTransform);
|
||||
while (_enemy.PlayerTransform != null)
|
||||
yield return null;
|
||||
```
|
||||
(`Approach` 每帧由 EnemyLocomotion.Update 维持朝玩家移动,能力不再逐帧 MoveTo。)`CleanupChase()` 保持(关接触伤害);`OnInterrupted` 里追加 `_enemy.Locomotion.Stop();`。删除 `SetAiPhase(AiPhase.Chase)` 行(P4 会统一删,但这里先删避免编译依赖 AiPhase;若 P4 未做,暂留由 P4 清)。
|
||||
> 注:`SetAiPhase` 的删除统一在 P4;本 Task 若 AiPhase 尚存则保留该行,Approach 改造与之无关。
|
||||
|
||||
- [ ] **Step 3: 删三协程能力与两个 SO**
|
||||
|
||||
```bash
|
||||
rm Assets/_Game/Scripts/Enemies/Abilities/IdleAbility.cs Assets/_Game/Scripts/Enemies/Abilities/IdleAbility.cs.meta
|
||||
rm Assets/_Game/Scripts/Enemies/Abilities/PatrolAbility.cs Assets/_Game/Scripts/Enemies/Abilities/PatrolAbility.cs.meta
|
||||
rm Assets/_Game/Scripts/Enemies/Abilities/AlertAbility.cs Assets/_Game/Scripts/Enemies/Abilities/AlertAbility.cs.meta
|
||||
rm Assets/_Game/Data/Enemies/E001/Abilities/ABL_E001_Idle.asset Assets/_Game/Data/Enemies/E001/Abilities/ABL_E001_Idle.asset.meta
|
||||
rm Assets/_Game/Data/Enemies/E001/Abilities/ABL_E001_Patrol.asset Assets/_Game/Data/Enemies/E001/Abilities/ABL_E001_Patrol.asset.meta
|
||||
```
|
||||
(`ABL_E001_Alert.asset` 是否删见 Task 10:Alert 不再是能力,其 SO 也应删;此处一并 `rm` 它 +meta。)
|
||||
|
||||
- [ ] **Step 4: 编译门** — count=0(若报 AiPhase 相关错,说明 Step 2 注保留项处理不当,回退保留 `SetAiPhase` 行)。
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add -A Assets/_Game/Scripts/Enemies/AIBrain/Ai/E001CaoZhiAi.cs Assets/_Game/Scripts/Enemies/Abilities Assets/_Game/Data/Enemies/E001/Abilities
|
||||
git commit -m "refactor(ai): E001 状态改声明 locomotion 意图;Chase 走 Approach;删三协程能力/SO"
|
||||
```
|
||||
|
||||
### Task 10: 脚手架 PlaceE001 去三能力子节点;重生成 prefab;替换场景实例
|
||||
|
||||
**Files:**
|
||||
- Modify: `Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs`(PlaceE001)
|
||||
|
||||
- [ ] **Step 1: 删掉 IdleAbility_Idle/PatrolAbility_Patrol/AlertAbility_Alert 三节点创建与其 AssignAsset**——回到只有 `ContactChaseAbility_Chase`(保留其 `_config=ABL_E001_Chase` 与 `_contactDamage` 绑定)。删除对应的 `GetOrAddComponent<IdleAbility>/PatrolAbility/AlertAbility` 与 `AssignAsset(...ABL_E001_Idle/Patrol/Alert...)` 行。
|
||||
|
||||
- [ ] **Step 2: 编译门** — count=0。
|
||||
|
||||
- [ ] **Step 3: 重生成 prefab**(反射调 `PlaceAndSaveEnemyPrefab("ENM_CaoZhi", PlaceE001_CaoZhi, removeSceneInstance:true)`,超时则等桥接恢复)。
|
||||
|
||||
- [ ] **Step 4: 替换 TestRoomA 场景实例并保存**(同 Task 4 Step 4)。
|
||||
|
||||
- [ ] **Step 5: 验证 prefab 只剩 ContactChaseAbility,且挂了 EnemyLocomotion**(`unity_execute_code` 断言 `GetComponentsInChildren<EnemyAbilityBase>()` 只含 `ContactChaseAbility`;`EnemyLocomotion` 存在)。
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
git add Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs Assets/_Game/Prefabs/Enemies/E001/ENM_CaoZhi.prefab Assets/_Game/Scenes/Testings/TestRoomA.unity
|
||||
git commit -m "refactor(scaffold): PlaceE001 去三协程能力子节点,重生成 prefab"
|
||||
```
|
||||
|
||||
### Task 11: PlayMode 验证 E001 四态经 locomotion 驱动
|
||||
|
||||
**Files:** 无
|
||||
|
||||
- [ ] **Step 1: PlayMode play,等 ~3s。**
|
||||
|
||||
- [ ] **Step 2: 玩家在近处 → 断言 Chase**:`state=Chase`、`Locomotion.CurrentMode=Approach`、ContactChaseAbility running=true、接触伤害在追击时开启。
|
||||
|
||||
- [ ] **Step 3: 把敌人移到远处(80,5),等 ~3s → 断言 de-escalation**:`state=Move_Patrol`、`Locomotion.CurrentMode=Patrol`、敌人在游走(位置变化 / IsMoving)。
|
||||
|
||||
- [ ] **Step 4: 断言 AI 脚本零 actuation**:确认 `E001CaoZhiAi`/`PerceptionStateMachine` 源码不含 velocity/朝向实现(人工核对 + grep:`rg -n "velocity|MovePosition|transform\.position\s*=" Assets/_Game/Scripts/Enemies/AIBrain` 期望 0 命中)。
|
||||
|
||||
- [ ] **Step 5: console 0 报错;退出 PlayMode。**(无代码改动,无需提交。)
|
||||
|
||||
---
|
||||
|
||||
## P4 — 删 AiPhase,动画由 locomotion 驱动
|
||||
|
||||
### Task 12: EnemyLocomotion 驱动步态动画
|
||||
|
||||
**Files:**
|
||||
- Modify: `Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs`
|
||||
- 参考: `Assets/_Game/Scripts/Enemies/EnemyAnimationConfigSO.cs`(`Idle/Walk/Run/Alert` clip 字段)
|
||||
|
||||
- [ ] **Step 1: 在 EnemyLocomotion 缓存 Animancer + AnimConfig,按模式播步态**
|
||||
|
||||
Awake 里取 `_enemy.Animancer` 与 AnimConfig(经 EnemyBase 暴露的 `_animConfig` 访问器;若无访问器,在 EnemyBase 加 `public EnemyAnimationConfigSO AnimConfig => _animConfig;`)。在模式切换时播对应 clip:
|
||||
```csharp
|
||||
void PlayGait(LocomotionMode mode)
|
||||
{
|
||||
if (_animancer == null || _animConfig == null) return;
|
||||
var clip = mode switch
|
||||
{
|
||||
LocomotionMode.Idle => _animConfig.Idle,
|
||||
LocomotionMode.Patrol => _animConfig.Walk,
|
||||
LocomotionMode.Face => _animConfig.Alert,
|
||||
LocomotionMode.Approach => _animConfig.Run,
|
||||
_ => null,
|
||||
};
|
||||
if (clip != null) _animancer.Play(clip);
|
||||
}
|
||||
```
|
||||
在 `SetMode/Approach/Face/Stop` 模式变化处调用 `PlayGait(_mode)`(仅在模式真正变化时,避免每帧重播)。
|
||||
|
||||
- [ ] **Step 2: 编译门** — count=0。
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs Assets/_Game/Scripts/Enemies/EnemyBase.cs
|
||||
git commit -m "feat(enemy): EnemyLocomotion 按模式驱动步态动画(Idle/Walk/Alert/Run)"
|
||||
```
|
||||
|
||||
### Task 13: 删除 AiPhase 枚举与 SetAiPhase,迁移调用点
|
||||
|
||||
**Files:**
|
||||
- Delete: `Assets/_Game/Scripts/Enemies/AiPhase.cs`(+meta)
|
||||
- Modify: `EnemyBase.cs`(删 `SetAiPhase`/`_currentAiPhase`/`CurrentAiPhase`/`OnAiPhaseChanged`/`_autoPlayPhaseAnimation`/phase→clip switch)
|
||||
- Modify: 所有能力里的 `SetAiPhase(...)` 调用(`ContactChaseAbility`、`AppearAbility`、`AnimatedCeilingDropAbility` 等)
|
||||
- Modify: `ReceiveAlert` 的判据;gizmo/`EnemyDebugOverlay` 读 AiPhase 处
|
||||
|
||||
- [ ] **Step 1: 先移除所有 `SetAiPhase(...)` 调用**——grep 定位:`rg -n "SetAiPhase|CurrentAiPhase|AiPhase\.|OnAiPhaseChanged" Assets/_Game/Scripts`。逐处删除或替换:
|
||||
- 能力里 `_enemy.SetAiPhase(AiPhase.X)` 直接删(动画已由 locomotion/能力自身 clip 驱动)。
|
||||
- `ReceiveAlert` 中 `if (CurrentAiPhase == Chase || Combat) return;` 的"已交战不降级"判据,改为读 AI 状态:新增 `EnemyBase.IsEngaged`(由 `EnemyAiBrain` 暴露当前状态名是否为 Chase/攻击态)或简单 `bool _engaged` 由 Chase 能力 Execute/结束时置位。选后者:`ContactChaseAbility` 起始 `_enemy.SetEngaged(true)`,`CleanupChase` 里 `_enemy.SetEngaged(false)`;`EnemyBase` 加 `bool IsEngaged` + `SetEngaged`。
|
||||
- gizmo/overlay 读 phase 处改读 `EnemyAiBrain` 当前状态名(`Brain` 已暴露)或删可视化。
|
||||
|
||||
- [ ] **Step 2: 删 EnemyBase 内 AiPhase 成员与 phase→clip switch**(`SetAiPhase` 方法体、字段、事件、`_autoPlayPhaseAnimation`)。
|
||||
|
||||
- [ ] **Step 3: 删 AiPhase.cs**
|
||||
|
||||
```bash
|
||||
rm Assets/_Game/Scripts/Enemies/AiPhase.cs Assets/_Game/Scripts/Enemies/AiPhase.cs.meta
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 编译门** — count=0(把所有残留 `AiPhase` 引用清干净)。
|
||||
|
||||
- [ ] **Step 5: PlayMode 回归**:E001 四态动画正确(待机=Idle、巡逻=Walk、警觉=Alert、追击=Run),受击/死亡 clip 能压过步态;console 0 报错。
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
git add -A Assets/_Game/Scripts/Enemies
|
||||
git commit -m "refactor(enemy): 删除 AiPhase 影子层,动画统一由 locomotion/反应态/能力驱动"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## P5 — EnemyStatsSO 死字段清理 + 速度单一来源
|
||||
|
||||
### Task 14: 删死字段,速度经 Locomotion 单一来源
|
||||
|
||||
**Files:**
|
||||
- Modify: `Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs`
|
||||
- Modify: `Assets/_Game/Scripts/Enemies/EnemyMovement.cs`(删 `_config` 速度直读)
|
||||
- Modify: `Assets/_Game/Scripts/Enemies/EnemyStats.cs`(删对应 pass-through 属性)
|
||||
|
||||
- [ ] **Step 1: 先 grep 确认无运行期读取**——对每个待删字段跑:`rg -n "AttackDamage|AttackRange|DetectRange|DetectAngleDeg|EyeOffset|LOSBlockingMask|AlertDuration|InvestigateDuration|KnockbackForce|HitStunDuration|heavyHitThreshold" Assets/_Game/Scripts`。确认仅 Editor bestiary/gizmo 引用(这些一并处理或保留 gizmo 用的最小项)。
|
||||
|
||||
- [ ] **Step 2: 删字段**——从 `EnemyStatsSO.cs` 删除:`AttackDamage`、`AttackRange`、`DetectRange`、`DetectAngleDeg`、`EyeOffset`、`LOSBlockingMask`、`AlertDuration`、`InvestigateDuration`、`KnockbackForce`、`HitStunDuration`、`HitTierConfig.heavyHitThreshold`。同步删 `EnemyStats.cs` 对应 pass-through 属性(若有 reader 则改指向留存字段)。Editor 里引用被删字段处(`EnemyModule.cs` bestiary 显示)改为不显示或显示 `DamageSourceSO`/sensor 槽的权威值。
|
||||
|
||||
- [ ] **Step 3: EnemyMovement 速度单一来源**——删 `EnemyMovement` 内对 `EnemyStatsSO _config` 的速度直读(`_config.WalkSpeed`/`RunSpeed` 处),改由 `EnemyLocomotion`/`Nav.SetSpeed` 提供的 `PendingInput.MoveSpeed` 驱动;`_config` 若仅用于速度则整个移除该序列化引用。
|
||||
|
||||
- [ ] **Step 4: 编译门** — count=0。
|
||||
|
||||
- [ ] **Step 5: PlayMode 回归**:E001 巡逻速度=WalkSpeed、追击速度=RunSpeed,行为不变;console 0 报错。
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
git add -A Assets/_Game/Scripts/Enemies
|
||||
git commit -m "refactor(enemy): 删 EnemyStatsSO ~11 个死字段,速度单一来源"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## P6 — 巡逻策略补全(Pace / Waypoints)
|
||||
|
||||
### Task 15: 实现 Pace(撞墙/悬崖翻向踱步)
|
||||
|
||||
**Files:**
|
||||
- Modify: `Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs`
|
||||
|
||||
- [ ] **Step 1: 加 Pace 参数与逻辑**——参照已删 `BD_Patrol` 的踱步语义(`MoveInDirection(_dir)` 每帧,遇 `IsWallAhead || IsLedgeAhead` 翻向)。EnemyLocomotion 加 `int _paceDir = 1;` 与序列化的墙/崖检测引用(复用 `EnemyMovement` 的 `IsWallAhead`/`IsLedgeAhead` 若存在;否则用 `WallDetector`):
|
||||
```csharp
|
||||
case PatrolStrategy.Pace:
|
||||
if (_enemy.Movement != null &&
|
||||
(_enemy.Movement.IsWallAhead || _enemy.Movement.IsLedgeAhead))
|
||||
_paceDir = -_paceDir;
|
||||
_enemy.MoveInDirection(_paceDir);
|
||||
break;
|
||||
```
|
||||
(若 `EnemyBase.Movement`/`IsWallAhead`/`IsLedgeAhead`/`MoveInDirection` 命名不同,按实际签名调整——实现前先 grep 确认。)
|
||||
|
||||
- [ ] **Step 2: 编译门** — count=0。
|
||||
|
||||
- [ ] **Step 3: EditMode/PlayMode 验证**——把 E001(或一测试敌人)`_patrolStrategy=Pace`,PlayMode 观察其在平台上来回踱步、遇墙/崖翻向。
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs
|
||||
git commit -m "feat(enemy): EnemyLocomotion 巡逻策略 Pace(撞墙/悬崖翻向踱步)"
|
||||
```
|
||||
|
||||
### Task 16: 实现 Waypoints(路点序列巡逻)
|
||||
|
||||
**Files:**
|
||||
- Modify: `Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs`
|
||||
|
||||
- [ ] **Step 1: 加 Waypoints 参数与逻辑**——参照已删 `BD_PatrolWaypoints`(有序 `Transform[]`/`Vector2[]`,loop 或 ping-pong,`MoveTo(waypoint)` 到达推进 index):
|
||||
```csharp
|
||||
[Header("Waypoints 策略")]
|
||||
[SerializeField] private Transform[] _waypoints;
|
||||
[SerializeField] private bool _pingPong;
|
||||
private int _wpIndex; private int _wpDir = 1;
|
||||
// TickPatrol 的 Waypoints 分支:
|
||||
case PatrolStrategy.Waypoints:
|
||||
if (_waypoints != null && _waypoints.Length > 0)
|
||||
{
|
||||
var nav = _enemy.Nav;
|
||||
if (nav != null && !nav.IsMoving)
|
||||
{
|
||||
AdvanceWaypoint();
|
||||
_enemy.MoveTo(_waypoints[_wpIndex].position);
|
||||
}
|
||||
}
|
||||
break;
|
||||
```
|
||||
`AdvanceWaypoint()`:loop 时 `_wpIndex = (_wpIndex+1) % len`;ping-pong 时到端点翻 `_wpDir`。
|
||||
|
||||
- [ ] **Step 2: 编译门** — count=0。
|
||||
|
||||
- [ ] **Step 3: PlayMode 验证**——放置带 2-3 个路点的测试敌人,观察按序巡逻 + loop/ping-pong。
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs
|
||||
git commit -m "feat(enemy): EnemyLocomotion 巡逻策略 Waypoints(路点序列 loop/ping-pong)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 收尾
|
||||
|
||||
### Task 17: 全量自检 + 更新记忆
|
||||
|
||||
- [ ] **Step 1: 运行项目自检**(CLAUDE.md §3):`Validate All ScriptableObjects`、`Validate Address Keys`、`Physics2D Layer Matrix Check`——期望全绿。
|
||||
- [ ] **Step 2: 最终 PlayMode 全链回归**:E001 待机(伪装)→巡逻→警觉→追击(接触伤害)→死亡 + de-escalation,console 0 报错。
|
||||
- [ ] **Step 3: grep 确认清理彻底**:`rg -n "AiPhase|IMover|IdleAbility|PatrolAbility|AlertAbility|WalkRandom\(\)" Assets/_Game/Scripts` 仅剩预期(如 `Nav.WalkToRandom` 属正常)。
|
||||
- [ ] **Step 4: 更新记忆** `ai_decision_only_delegates_abilities.md` / `enemy_nav_movement_architecture.md`:记录 Model A 已被 EnemyLocomotion 取代、执行层单一化、AiPhase 已删。
|
||||
|
||||
---
|
||||
|
||||
## Self-Review(作者已核对)
|
||||
|
||||
- **Spec 覆盖**:§3.1 EnemyLocomotion→P2/Task2;§3.2 AI 用法→P3/Task8-9;§3.3 删 AiPhase→P4;§3.4 Stats→P5;§4 E002-E06 范式→文档(不实现);§5 分期→P2-P6;§6 测试→各 Task 的 EditMode/PlayMode 步骤;§7 飞行缺口→不实现(记录在案)。P1 已完成。
|
||||
- **占位符**:无 TBD/TODO;每个代码步给出实际代码;对"实现前需按实际签名 grep 确认"的动态点已显式标注(Pace/Waypoints 依赖 EnemyMovement 现有 API)。
|
||||
- **类型一致**:`IEnemyLocomotion`(Task1) 全程一致使用;`LocomotionMode` 值 Idle/Patrol/Face/Approach 贯穿;`Config` 字段 `IdleMode/PatrolMode/AlertMode/ChaseAbilityId`(Task8) 与 E001 用法(Task9) 一致;`FakeLocomotion`(Task6) 与 IAiContext.Locomotion(Task7) 匹配。
|
||||
- **已知风险点**:飞行怪走 FlyingDirectNavigator(本计划 E001 为地面怪,不触发飞行分支,飞行接入留待 E002-E06 落地时按 spec §7)。
|
||||
@@ -0,0 +1,379 @@
|
||||
# 敌人碰撞体 Sprite 驱动 + 统一 Box + 全员接触伤害区 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 让脚手架 `SceneObjectPlacerTool` 生成的每个敌人(含 Boss)的主体/HurtBox/ContactDamageZone 三者统一为 BoxCollider2D、同尺寸、底部对齐 y=0,尺寸由向导默认 Sprite 的包围盒推导(留空回退硬编码),且每个敌人都有 ContactDamageZone。
|
||||
|
||||
**Architecture:** 在 `SceneObjectPlacerTool` 新增两个静态助手(`SpriteSizeOr`、`SetupHurtAndContactBoxes`),把各 `PlaceExxx` 里参差的 Capsule/Circle HurtBox/接触伤害创建统一替换为"主体 Box(size) + 助手建 HurtBox/ContactDamageZone(Box, size, trigger, 底部对齐)";`CharacterWizardWindow` 加一个可选默认 Sprite 字段并透传。只改这两个编辑器脚本,不动任何 prefab。
|
||||
|
||||
**Tech Stack:** Unity 2022.3 (C# 9)、UnityEditor、UI Toolkit(向导)、Unity MCP(端口 7890)做编译门与放置验证。
|
||||
|
||||
**设计文档:** `Docs/superpowers/specs/2026-07-10-enemy-sprite-driven-box-colliders-design.md`
|
||||
|
||||
**全局约定:**
|
||||
- **编译门**:`unity_execute_code`(port 7890) 跑 `AssetDatabase.Refresh(); CompilationPipeline.RequestScriptCompilation(); return "ok";` → Bash `sleep 9` → `unity_get_compilation_errors` severity=error 期望 count=0。桥接超时则 `unity_editor_ping` 等恢复再重试;长会话保持 Unity 窗口前台(失焦会卡死队列)。
|
||||
- 每个 Task 结束提交一次(中文 + 类型前缀)。分支已在 `fix/e001-chase-facing-and-cd-patrol`(或按需另开)。
|
||||
- **不修改任何 prefab**(用户后续手动重生成替换)。
|
||||
- **放置验证**:`PlaceExxx` 会在活动场景创建一个敌人 GameObject。验证时先确认活动场景是 TestRoomA(或任意可写场景),放置后读该对象的碰撞体,验证完 `DestroyImmediate` 清理,避免污染场景。
|
||||
|
||||
---
|
||||
|
||||
## 关键事实(实现前必读,均已核实)
|
||||
|
||||
- `EnemyBodyColliderType { Box, Capsule, Circle }`(SceneObjectPlacerTool.cs:44);`CreateBodyCollider(GameObject, EnemyBodyColliderType, Vector2 size)` 建主体碰撞体并 `AlignColliderBottomToPivot`。
|
||||
- `AlignColliderBottomToPivot(Collider2D)`:Box/Capsule 设 `offset.y = size.y*0.5`,Circle 设 `offset.y = radius` —— 底部对齐 y=0。
|
||||
- `EnsureCollidersAreTriggers(GameObject)` 已存在:把节点全部 Collider2D 设 `isTrigger=true`。
|
||||
- `GetOrCreateChild(Transform, string)`、`GetOrAddComponent<T>(GameObject)`、`SetLayer(GameObject, string, List<string>)`、`AssignReference(Object, string, Object, List<string>)`、`FindFirstAsset(params string[])` 均已存在。
|
||||
- `SetupSpriteRenderer(GameObject) → SpriteRenderer`:若 sr.sprite 为空则填一个默认 Square;返回 sr。
|
||||
- 各敌人现有 fallback 主体尺寸(Vector2):E001=0.6×0.8、E002=0.5×0.7、E003=0.5×0.6、E004=0.8×1.2、E005=0.9×1.0、E006=0.7×1.0、ChaoFeng=1.2×2.0、PlaceEnemy(通用)=0.7×0.9、PlaceBossEnemy(通用)=1.5×2.5。
|
||||
- E001 接触伤害现有接线:`BodyContactDamage.enabled=false`(伪装期不伤,追击能力开);`ContactChaseAbility._contactDamage` 绑该 BodyContactDamage;ContactDamageZone 的 `HitBox._defaultSource` 绑 `FindFirstAsset("CMB_DS_EnemyBody","DS_EnemyBody")`。
|
||||
- 命名空间:`BaseGames.Combat`(HurtBox/HitBox)、`BaseGames.Enemies`(BodyContactDamage)。SceneObjectPlacerTool 顶部已 `using` 这些。
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 新增两个助手 `SpriteSizeOr` / `SetupHurtAndContactBoxes`
|
||||
|
||||
**Files:**
|
||||
- Modify: `Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs`(紧邻现有 `EnsureCollidersAreTriggers` 助手之后)
|
||||
|
||||
- [ ] **Step 1: 加两个助手**
|
||||
|
||||
在 `EnsureCollidersAreTriggers` 方法之后插入:
|
||||
```csharp
|
||||
/// <summary>Sprite 有则用其世界包围盒尺寸(sprite.bounds.size),否则回退。</summary>
|
||||
private static Vector2 SpriteSizeOr(Sprite s, Vector2 fallback)
|
||||
=> s != null ? (Vector2)s.bounds.size : fallback;
|
||||
|
||||
/// <summary>
|
||||
/// 建/更新 HurtBox 与 ContactDamageZone 两个子节点:均为 BoxCollider2D、同尺寸 size、
|
||||
/// 底部对齐 y=0、isTrigger=true。ContactDamageZone 带 HitBox + BodyContactDamage。
|
||||
/// contactEnabled:BodyContactDamage 初始启用状态(不需要接触伤害的敌人可传 false 或后续置节点非激活)。
|
||||
/// 返回 (hurtBox, bodyContact, contactHitBox) 供上层按需接线(如伤害源/能力引用)。
|
||||
/// </summary>
|
||||
private static (HurtBox hurt, BodyContactDamage contact, HitBox contactHitBox)
|
||||
SetupHurtAndContactBoxes(GameObject root, Vector2 size, bool contactEnabled, List<string> report)
|
||||
{
|
||||
// HurtBox 子节点
|
||||
var hurtT = GetOrCreateChild(root.transform, "HurtBox");
|
||||
SetLayer(hurtT.gameObject, "EnemyHurtBox", report);
|
||||
var hurtCol = GetOrAddComponent<BoxCollider2D>(hurtT.gameObject);
|
||||
hurtCol.size = size;
|
||||
hurtCol.isTrigger = true;
|
||||
AlignColliderBottomToPivot(hurtCol);
|
||||
var hurtBox = GetOrAddComponent<HurtBox>(hurtT.gameObject);
|
||||
EnsureCollidersAreTriggers(hurtT.gameObject);
|
||||
|
||||
// ContactDamageZone 子节点
|
||||
var contactT = GetOrCreateChild(root.transform, "ContactDamageZone");
|
||||
SetLayer(contactT.gameObject, "EnemyHitBox", report);
|
||||
var contactCol = GetOrAddComponent<BoxCollider2D>(contactT.gameObject);
|
||||
contactCol.size = size;
|
||||
contactCol.isTrigger = true;
|
||||
AlignColliderBottomToPivot(contactCol);
|
||||
var contactHitBox = GetOrAddComponent<HitBox>(contactT.gameObject);
|
||||
var bodyContact = GetOrAddComponent<BodyContactDamage>(contactT.gameObject);
|
||||
bodyContact.enabled = contactEnabled;
|
||||
EnsureCollidersAreTriggers(contactT.gameObject);
|
||||
|
||||
return (hurtBox, bodyContact, contactHitBox);
|
||||
}
|
||||
```
|
||||
|
||||
注意:`HurtBox`/`HitBox` 在 `BaseGames.Combat`,`BodyContactDamage` 在 `BaseGames.Enemies`,文件顶部已 using。`GetOrAddComponent<BoxCollider2D>` 若节点已有别的 Collider2D 不会移除——但本任务不改现有 prefab,全新放置的节点只会有这一个 Box,无残留。
|
||||
|
||||
- [ ] **Step 2: 编译门** → count=0(此时助手未被调用,只验证语法/引用)。
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
```bash
|
||||
git add Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs
|
||||
git commit -m "feat(scaffold): 新增 SpriteSizeOr / SetupHurtAndContactBoxes 助手(Box 统一 HurtBox/接触伤害区)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: E001 改用助手 + sprite 参数(参考样板)
|
||||
|
||||
**Files:**
|
||||
- Modify: `Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs`(`PlaceE001_CaoZhi`)
|
||||
|
||||
- [ ] **Step 1: 改签名加 sprite 参数**
|
||||
|
||||
把重载:
|
||||
```csharp
|
||||
public static void PlaceE001_CaoZhi() => PlaceE001_CaoZhi(EnemyBodyColliderType.Box);
|
||||
public static void PlaceE001_CaoZhi(EnemyBodyColliderType bodyCollider)
|
||||
```
|
||||
改为:
|
||||
```csharp
|
||||
public static void PlaceE001_CaoZhi() => PlaceE001_CaoZhi(EnemyBodyColliderType.Box, null);
|
||||
public static void PlaceE001_CaoZhi(EnemyBodyColliderType bodyCollider, Sprite defaultSprite = null)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 用 sprite 推导 size、赋 SpriteRenderer、走助手**
|
||||
|
||||
E001 现有片段(约 457-489 行)大致为:
|
||||
```csharp
|
||||
Collider2D body = CreateBodyCollider(go, bodyCollider, new Vector2(0.6f, 0.8f));
|
||||
...
|
||||
SpriteRenderer sr1 = SetupSpriteRenderer(visual.gameObject);
|
||||
...
|
||||
Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox");
|
||||
SetLayer(hurtBoxT.gameObject, "EnemyHurtBox", report);
|
||||
CapsuleCollider2D hurtCap = GetOrAddComponent<CapsuleCollider2D>(hurtBoxT.gameObject);
|
||||
hurtCap.isTrigger = true;
|
||||
hurtCap.size = new Vector2(0.55f, 0.75f);
|
||||
AlignColliderBottomToPivot(hurtCap);
|
||||
HurtBox hurtBox = GetOrAddComponent<HurtBox>(hurtBoxT.gameObject);
|
||||
EnsureCollidersAreTriggers(hurtBoxT.gameObject);
|
||||
|
||||
Transform contactT = GetOrCreateChild(go.transform, "ContactDamageZone");
|
||||
SetLayer(contactT.gameObject, "EnemyHitBox", report);
|
||||
CircleCollider2D contactCol = GetOrAddComponent<CircleCollider2D>(contactT.gameObject);
|
||||
contactCol.isTrigger = true;
|
||||
contactCol.radius = 0.4f;
|
||||
AlignColliderBottomToPivot(contactCol);
|
||||
HitBox contactHitBox = GetOrAddComponent<HitBox>(contactT.gameObject);
|
||||
BodyContactDamage bodyContact = GetOrAddComponent<BodyContactDamage>(contactT.gameObject);
|
||||
bodyContact.enabled = false;
|
||||
EnsureCollidersAreTriggers(contactT.gameObject);
|
||||
```
|
||||
|
||||
改为(size 用 sprite,主体 Box(size),HurtBox/ContactDamageZone 走助手;E001 接触伤害仍禁用+由能力控制):
|
||||
```csharp
|
||||
Vector2 size = SpriteSizeOr(defaultSprite, new Vector2(0.6f, 0.8f));
|
||||
Collider2D body = CreateBodyCollider(go, bodyCollider, size);
|
||||
...
|
||||
SpriteRenderer sr1 = SetupSpriteRenderer(visual.gameObject);
|
||||
if (defaultSprite != null) sr1.sprite = defaultSprite;
|
||||
...
|
||||
var (hurtBox, bodyContact, contactHitBox) =
|
||||
SetupHurtAndContactBoxes(go, size, contactEnabled: false, report); // E001 伪装期不伤,追击能力开
|
||||
```
|
||||
保留后续对 `bodyContact` / `contactHitBox` / `hurtBox` 的接线不变,即:
|
||||
- `AssignReference(enemyBase, "_hurtBox", hurtBox, report)` 若原有(用返回的 hurtBox)。
|
||||
- `AssignReference(chaseAbility, "_contactDamage", bodyContact, report)` 保留。
|
||||
- 伤害源:`Object dmgSrc = FindFirstAsset("CMB_DS_EnemyBody", "DS_EnemyBody"); if (dmgSrc != null) AssignReference(contactHitBox, "_defaultSource", dmgSrc, report);` 保留。
|
||||
|
||||
> 实现前 Read 当前 `PlaceE001_CaoZhi` 全文,把上面被替换段之外的接线原样保留(只换碰撞体创建方式与 size 来源)。`sr1` 变量名沿用现有。
|
||||
|
||||
- [ ] **Step 3: 编译门** → count=0。
|
||||
|
||||
- [ ] **Step 4: 放置验证(MCP)**
|
||||
|
||||
`unity_execute_code`(port 7890):反射调用 `PlaceE001_CaoZhi(EnemyBodyColliderType.Box, null)`(无 sprite→回退 0.6×0.8),然后校验并清理:
|
||||
```csharp
|
||||
var t = System.AppDomain.CurrentDomain.GetAssemblies().SelectMany(a=>{try{return a.GetTypes();}catch{return new System.Type[0];}}).FirstOrDefault(x=>x.Name=="SceneObjectPlacerTool");
|
||||
var m = t.GetMethod("PlaceE001_CaoZhi", new[]{ t.GetNestedType("EnemyBodyColliderType") ?? typeof(int), typeof(UnityEngine.Sprite) });
|
||||
// 若上面拿不到,改用带默认参数的 2-参重载:
|
||||
m = t.GetMethods().First(x=>x.Name=="PlaceE001_CaoZhi" && x.GetParameters().Length==2);
|
||||
var enumType = m.GetParameters()[0].ParameterType;
|
||||
m.Invoke(null, new object[]{ System.Enum.ToObject(enumType, 0), null });
|
||||
var go = UnityEngine.GameObject.Find("ENM_CaoZhi");
|
||||
var sb=new System.Text.StringBuilder();
|
||||
foreach(var name in new[]{"","HurtBox","ContactDamageZone"}){
|
||||
var node = name==""? go.transform : go.transform.Find(name);
|
||||
var col = node.GetComponent<UnityEngine.Collider2D>();
|
||||
string label = name==""? "BODY" : name;
|
||||
if(col is UnityEngine.BoxCollider2D b)
|
||||
sb.AppendLine(label+": Box size="+b.size.ToString("F2")+" isTrigger="+b.isTrigger+" offset.y="+b.offset.y.ToString("F3")+" (期望 offset.y=size.y/2="+(b.size.y*0.5f).ToString("F3")+")");
|
||||
else sb.AppendLine(label+": "+(col!=null?col.GetType().Name:"NO COLLIDER"));
|
||||
}
|
||||
sb.AppendLine("ContactDamageZone.BodyContactDamage.enabled="+go.transform.Find("ContactDamageZone").GetComponent<BaseGames.Enemies.BodyContactDamage>().enabled+" (E001 期望 false)");
|
||||
UnityEngine.Object.DestroyImmediate(go); // 清理
|
||||
return sb.ToString();
|
||||
```
|
||||
期望:BODY/HurtBox/ContactDamageZone 都是 Box、size=(0.60,0.80)、HurtBox/Contact isTrigger=True、BODY isTrigger=False、三者 offset.y=size.y/2、ContactDamage.enabled=False。
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
```bash
|
||||
git add Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs
|
||||
git commit -m "refactor(scaffold): E001 用统一助手建 Box HurtBox/接触伤害区 + sprite 尺寸参数"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: E002-E006 改用助手 + sprite 参数
|
||||
|
||||
**Files:**
|
||||
- Modify: `Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs`(`PlaceE002_HuangZhi`、`PlaceE003_YouZhi_Enemy`、`PlaceE004_ZhiMu_Enemy`、`PlaceE005_FeiZhi_Enemy`、`PlaceE006_Huan`)
|
||||
|
||||
对每个敌人应用**相同变换**(下方 fallback size 逐敌人不同):
|
||||
|
||||
- [ ] **Step 1: 每个敌人改签名加 sprite 参数**
|
||||
|
||||
例(E002):
|
||||
```csharp
|
||||
public static void PlaceE002_HuangZhi() => PlaceE002_HuangZhi(EnemyBodyColliderType.Box, null);
|
||||
public static void PlaceE002_HuangZhi(EnemyBodyColliderType bodyCollider, Sprite defaultSprite = null)
|
||||
```
|
||||
E003/E004/E005/E006 同理(保持各自方法名)。
|
||||
|
||||
- [ ] **Step 2: 每个敌人替换碰撞体创建**
|
||||
|
||||
对每个 `PlaceExxx`,Read 其当前实现,然后:
|
||||
1. 主体:`Vector2 size = SpriteSizeOr(defaultSprite, <fallback>);` 再 `CreateBodyCollider(go, bodyCollider, size);`(替换原 `new Vector2(...)` 硬编码)。
|
||||
2. SpriteRenderer:找到该敌人的 `SetupSpriteRenderer(...)` 调用(变量如 sr/sr3/sr5),其后加 `if (defaultSprite != null) <sr>.sprite = defaultSprite;`。
|
||||
3. HurtBox + ContactDamageZone:删除该敌人现有的 HurtBox(Capsule) 与(若有)hit/ContactDamageZone(Circle) 创建代码,改为:
|
||||
`var (hurtBox, bodyContact, contactHitBox) = SetupHurtAndContactBoxes(go, size, contactEnabled: true, report);`
|
||||
(E002/E004/E005 原本**没有** ContactDamageZone——现在统一补上,contactEnabled=true。)
|
||||
4. 保留该敌人原有对 `hurtBox` 的接线(如 `AssignReference(enemyBase, "_hurtBox", hurtBox, report)`;用返回的 hurtBox 替换原局部变量名)。若该敌人的某个攻击能力原本引用了旧 HurtBox 变量,改用返回的 `hurtBox`。
|
||||
5. 伤害源接线(统一加,若该敌人原本没有):`Object dmgSrc = FindFirstAsset("CMB_DS_EnemyBody", "DS_EnemyBody"); if (dmgSrc != null) AssignReference(contactHitBox, "_defaultSource", dmgSrc, report);`
|
||||
|
||||
**逐敌人 fallback size:**
|
||||
- E002 `PlaceE002_HuangZhi`:`new Vector2(0.5f, 0.7f)`
|
||||
- E003 `PlaceE003_YouZhi_Enemy`:`new Vector2(0.5f, 0.6f)`
|
||||
- E004 `PlaceE004_ZhiMu_Enemy`:`new Vector2(0.8f, 1.2f)`
|
||||
- E005 `PlaceE005_FeiZhi_Enemy`:`new Vector2(0.9f, 1.0f)`
|
||||
- E006 `PlaceE006_Huan`:`new Vector2(0.7f, 1.0f)`
|
||||
|
||||
> 注意:某些敌人(如 E002)的攻击能力有独立的 `_attackHitBox` / `_hurtBox` 引用——这些是**能力自己的 HitBox**,与本任务的 HurtBox/ContactDamageZone 无关,勿动;只替换"主体受击 HurtBox"与"接触伤害 ContactDamageZone"两处。实现前务必 Read 每个 PlaceExxx 全文分清。
|
||||
|
||||
- [ ] **Step 3: 编译门** → count=0。
|
||||
|
||||
- [ ] **Step 4: 放置验证(MCP)**
|
||||
|
||||
对 E002-E006 各放置一次并校验(同 Task 2 的验证代码,替换方法名与期望 GameObject 名 ENM_HuangZhi/ENM_YouZhi/ENM_ZhiMu/ENM_FeiZhi/ENM_Huan,期望 size=各 fallback、三者 Box+底部对齐、HurtBox/Contact isTrigger=True、ContactDamage.enabled=True)。每次验证后 `DestroyImmediate` 清理。
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
```bash
|
||||
git add Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs
|
||||
git commit -m "refactor(scaffold): E002-E006 统一 Box HurtBox/接触伤害区 + sprite 尺寸(补全缺失的接触伤害区)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: ChaoFeng(Boss) + 通用 PlaceEnemy/PlaceBossEnemy 改用助手 + sprite
|
||||
|
||||
**Files:**
|
||||
- Modify: `Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs`(`PlaceChaoFeng`、`PlaceEnemy`、`PlaceBossEnemy`)
|
||||
|
||||
- [ ] **Step 1: PlaceChaoFeng 加 sprite 参数 + 助手**
|
||||
|
||||
```csharp
|
||||
public static void PlaceChaoFeng() => PlaceChaoFeng(EnemyBodyColliderType.Box, null);
|
||||
public static void PlaceChaoFeng(EnemyBodyColliderType bodyCollider, Sprite defaultSprite = null)
|
||||
```
|
||||
体内:`Vector2 size = SpriteSizeOr(defaultSprite, new Vector2(1.2f, 2.0f));` → `CreateBodyCollider(go, bodyCollider, size);`;SpriteRenderer 后加 sprite 赋值;HurtBox 段(Capsule)替换为 `var (hurtBox, bodyContact, contactHitBox) = SetupHurtAndContactBoxes(go, size, contactEnabled: true, report);`(Boss 原**无** ContactDamageZone,现补上,默认启用,策划按需置非激活);保留 Boss 原有 hurtBox 接线;加伤害源接线(同上)。
|
||||
|
||||
- [ ] **Step 2: 通用 PlaceEnemy / PlaceBossEnemy 同样处理**
|
||||
|
||||
`PlaceEnemy(EnemyBodyColliderType, Sprite defaultSprite=null)` fallback `new Vector2(0.7f, 0.9f)`;`PlaceBossEnemy(...)` fallback `new Vector2(1.5f, 2.5f)`。各自:size 用 sprite、CreateBodyCollider(size)、sprite 赋 Sr、`SetupHurtAndContactBoxes(go, size, contactEnabled: true, report)`(若原本没有 HurtBox/ContactDamageZone 则新增;有则替换)。加无参重载 `=> PlaceXxx(EnemyBodyColliderType.Box, null)` 若需要。
|
||||
|
||||
- [ ] **Step 3: 编译门** → count=0。
|
||||
|
||||
- [ ] **Step 4: 放置验证(MCP)**:放置 ChaoFeng(ENM_ChaoFeng),校验三者 Box、size=(1.20,2.00)、HurtBox/Contact isTrigger=True、底部对齐、ContactDamageZone 存在。`DestroyImmediate` 清理。
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
```bash
|
||||
git add Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs
|
||||
git commit -m "refactor(scaffold): ChaoFeng + 通用 Place 统一 Box HurtBox/接触伤害区 + sprite 尺寸"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 向导 CharacterWizardWindow 加默认 Sprite 字段并透传
|
||||
|
||||
**Files:**
|
||||
- Modify: `Assets/_Game/Scripts/Editor/Character/CharacterWizardWindow.cs`
|
||||
|
||||
- [ ] **Step 1: 加字段**
|
||||
|
||||
在字段区(约 `_enemyBodyCollider` 声明附近)加:
|
||||
```csharp
|
||||
private Sprite _defaultSprite; // 敌人/Boss 默认外观 Sprite:碰撞体尺寸依据(可留空)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 小怪 Tab 加 ObjectField**
|
||||
|
||||
在 `RefreshEnemyTabContent` 里的 `colliderField`(EnumField "主体碰撞器类型")之后加:
|
||||
```csharp
|
||||
var spriteField = new UnityEditor.UIElements.ObjectField("默认外观 Sprite(碰撞体尺寸依据,可留空)")
|
||||
{ objectType = typeof(Sprite), allowSceneObjects = false, value = _defaultSprite };
|
||||
spriteField.RegisterValueChangedCallback(evt => _defaultSprite = evt.newValue as Sprite);
|
||||
container.Add(spriteField);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 小怪放置调用透传 sprite**
|
||||
|
||||
`PlaceSpecificEnemy(string id, EnemyBodyColliderType bodyCollider)` 改签名加 `Sprite defaultSprite`:
|
||||
```csharp
|
||||
private static void PlaceSpecificEnemy(string id, SceneObjectPlacerTool.EnemyBodyColliderType bodyCollider, Sprite defaultSprite)
|
||||
{
|
||||
switch (id)
|
||||
{
|
||||
case "E001": SceneObjectPlacerTool.PlaceE001_CaoZhi(bodyCollider, defaultSprite); break;
|
||||
case "E002": SceneObjectPlacerTool.PlaceE002_HuangZhi(bodyCollider, defaultSprite); break;
|
||||
case "E003": SceneObjectPlacerTool.PlaceE003_YouZhi_Enemy(bodyCollider, defaultSprite); break;
|
||||
case "E004": SceneObjectPlacerTool.PlaceE004_ZhiMu_Enemy(bodyCollider, defaultSprite); break;
|
||||
case "E005": SceneObjectPlacerTool.PlaceE005_FeiZhi_Enemy(bodyCollider, defaultSprite); break;
|
||||
case "E006": SceneObjectPlacerTool.PlaceE006_Huan(bodyCollider, defaultSprite); break;
|
||||
default:
|
||||
Debug.LogError($"[CharacterWizardWindow] 未注册的敌人 id '{id}'。");
|
||||
SceneObjectPlacerTool.PlaceEnemy(bodyCollider, defaultSprite);
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
并把调用处 `PlaceSpecificEnemy(id, _enemyBodyCollider)` 改为 `PlaceSpecificEnemy(id, _enemyBodyCollider, _defaultSprite)`(`RefreshEnemyTabContent` 里的 `MakeSceneButton(sceneLabel, () => PlaceSpecificEnemy(id, _enemyBodyCollider))`)。
|
||||
|
||||
- [ ] **Step 4: Boss Tab 加 ObjectField + 透传**
|
||||
|
||||
在 `BuildBossTab` 的 "放置嘲风到场景并绑定 SO" 按钮之前加同款 spriteField(绑 `_defaultSprite`);把该按钮改为 `MakeSceneButton("放置嘲风到场景并绑定 SO", () => SceneObjectPlacerTool.PlaceChaoFeng(SceneObjectPlacerTool.EnemyBodyColliderType.Box, _defaultSprite))`。
|
||||
|
||||
- [ ] **Step 5: 编译门** → count=0。
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
```bash
|
||||
git add Assets/_Game/Scripts/Editor/Character/CharacterWizardWindow.cs
|
||||
git commit -m "feat(wizard): 敌人/Boss 加默认外观 Sprite 字段并透传给脚手架(碰撞体尺寸依据)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: 端到端 Sprite 驱动验证 + 收尾
|
||||
|
||||
**Files:** 无(仅 MCP 验证)
|
||||
|
||||
- [ ] **Step 1: 用一张真实 Sprite 走一次放置**,验证碰撞体尺寸 = sprite 包围盒
|
||||
|
||||
`unity_execute_code`(port 7890):
|
||||
```csharp
|
||||
// 取任意一张 sprite 资产
|
||||
var spriteGuid = UnityEditor.AssetDatabase.FindAssets("t:Sprite").FirstOrDefault();
|
||||
var sprite = spriteGuid!=null ? UnityEditor.AssetDatabase.LoadAssetAtPath<UnityEngine.Sprite>(UnityEditor.AssetDatabase.GUIDToAssetPath(spriteGuid)) : null;
|
||||
if(sprite==null) return "no sprite asset found";
|
||||
var t = System.AppDomain.CurrentDomain.GetAssemblies().SelectMany(a=>{try{return a.GetTypes();}catch{return new System.Type[0];}}).FirstOrDefault(x=>x.Name=="SceneObjectPlacerTool");
|
||||
var m = t.GetMethods().First(x=>x.Name=="PlaceE002_HuangZhi" && x.GetParameters().Length==2);
|
||||
var enumType = m.GetParameters()[0].ParameterType;
|
||||
m.Invoke(null, new object[]{ System.Enum.ToObject(enumType, 0), sprite });
|
||||
var go = UnityEngine.GameObject.Find("ENM_HuangZhi");
|
||||
var body = go.GetComponent<UnityEngine.BoxCollider2D>();
|
||||
var sb=new System.Text.StringBuilder();
|
||||
sb.AppendLine("sprite.bounds.size="+((UnityEngine.Vector2)sprite.bounds.size).ToString("F3"));
|
||||
sb.AppendLine("body Box size="+body.size.ToString("F3")+" (期望=sprite.bounds.size)");
|
||||
sb.AppendLine("HurtBox size="+go.transform.Find("HurtBox").GetComponent<UnityEngine.BoxCollider2D>().size.ToString("F3"));
|
||||
sb.AppendLine("Contact size="+go.transform.Find("ContactDamageZone").GetComponent<UnityEngine.BoxCollider2D>().size.ToString("F3"));
|
||||
sb.AppendLine("SpriteRenderer.sprite="+(go.GetComponentInChildren<UnityEngine.SpriteRenderer>()?.sprite?.name ?? "null"));
|
||||
UnityEngine.Object.DestroyImmediate(go);
|
||||
return sb.ToString();
|
||||
```
|
||||
期望:body/HurtBox/Contact size 三者一致且 = `sprite.bounds.size`;SpriteRenderer.sprite = 该 sprite 名。
|
||||
|
||||
- [ ] **Step 2: 自检**:`BaseGames/Tools/Maintenance/Physics2D Layer Matrix/Check` 期望无新增不符(层未变)。
|
||||
|
||||
- [ ] **Step 3: grep 确认无残留 Capsule/Circle HurtBox/接触伤害创建**
|
||||
```
|
||||
rg -n "CapsuleCollider2D hurt|CircleCollider2D contact|CircleCollider2D hit" Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs
|
||||
```
|
||||
期望:0 命中(HurtBox/接触伤害区的 Capsule/Circle 创建都已换成助手;主体的 CreateBodyCollider 里 Capsule/Circle 分支保留属正常)。
|
||||
|
||||
- [ ] **Step 4: 更新记忆** `editor_scaffold_tools.md` / `scaffold_tools_required.md`:记录敌人碰撞体现由 sprite 驱动、三者统一 Box+底部对齐、全员有 ContactDamageZone。
|
||||
|
||||
---
|
||||
|
||||
## Self-Review(作者已核对)
|
||||
|
||||
- **Spec 覆盖**:§A 向导 sprite 字段→Task 5;§B 助手→Task 1、各敌人改造→Task 2/3/4(含补全 4 个缺失 ContactDamageZone、E001 contactEnabled=false 特例、伤害源接线);§C 不动 prefab→全程无 prefab 操作;§D 验证→各 Task Step 4 + Task 6。主体默认 Box→CreateBodyCollider 默认分支;底部对齐→助手内 AlignColliderBottomToPivot。
|
||||
- **占位符**:无 TBD;助手给完整代码;per-enemy 改造给"读现有→套模式+具体 fallback size+接线说明",因每个 PlaceExxx 的能力接线各异,显式要求实现前 Read 全文(属机械变换)。
|
||||
- **类型一致**:`SpriteSizeOr(Sprite,Vector2)→Vector2`、`SetupHurtAndContactBoxes(GameObject,Vector2,bool,List<string>)→(HurtBox,BodyContactDamage,HitBox)` 全程一致;`EnemyBodyColliderType` 参数名/默认值一致;`PlaceExxx(bodyCollider, defaultSprite=null)` 签名贯穿 Task 2-5。
|
||||
- **已知风险**:各 PlaceExxx 内的攻击能力自带 HitBox(如 E002 `_attackHitBox`)与本任务的主体 HurtBox/ContactDamageZone 不同,Task 3 已显式提示勿混淆——实现前必须 Read 每个方法全文。
|
||||
@@ -0,0 +1,337 @@
|
||||
# 敌人 AI 框架设计(BrainGraph)
|
||||
|
||||
- 日期:2026-07-03
|
||||
- 状态:设计已确认,待 review → 转实现计划
|
||||
- 工作名:**BrainGraph**(最终名待定)
|
||||
- 命名空间:`BaseGames.AI`(独立 asmdef,零三方依赖)
|
||||
- 目标:以一套自研、C# 声明式、可 MCP 全自动生成/调试的**分层状态机(HFSM)**决策框架,替换 Behavior Designer(Opsive)作为敌人/NPC AI 的"决策大脑"。
|
||||
|
||||
> 规范约束:本设计与最终代码的命名、注释、`[Tooltip]`/`[Header]` 中**不得出现任何参考游戏名**(项目 CLAUDE.md 第 4 条)。讨论中引用的商业游戏仅作内部设计参照,不落代码。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与动机
|
||||
|
||||
### 1.1 现状(探查结论)
|
||||
|
||||
项目采用"自研外壳 + Opsive Behavior Designer 决策层"双层设计,但决策层处于**"节点库就绪、行为树资产为空"**的半成品状态:
|
||||
|
||||
- Opsive Behavior Designer v2(GraphDesigner 内核)作为 UPM 包安装,49 个自定义 `BD_*` 任务节点已写好并编译(`GRAPH_DESIGNER` 宏开启)。
|
||||
- **但没有任何行为树图资产(graph .asset),也没有任何敌人 prefab/场景挂载 `BehaviorTree` 组件。** 决策层是空的。
|
||||
- `EnemyBase` 已写好 BT 的 Manual-Tick + 5 档 LOD 节流集成(`EnemyBase.cs:606/629`),但因无图资产而未接通。
|
||||
- 感知/状态/执行三层已完整实现且解耦良好;49 个 `BD_*` 任务全部是**薄壳**,仅调用 `EnemyBase` 门面方法,不直接碰子系统。
|
||||
|
||||
### 1.2 痛点
|
||||
|
||||
Behavior Designer 的行为树是**手绘的二进制图资产**:
|
||||
|
||||
- MCP / 代码**无法生成**,无法 diff,无法自动化调试。
|
||||
- 决策逻辑的"唯一事实来源"是二进制图,git review 不可读。
|
||||
|
||||
因替换沉没成本几乎为零(无既有图资产),现在是重构决策层的最佳时机。
|
||||
|
||||
### 1.3 核心设计取向(已与用户逐条确认)
|
||||
|
||||
1. **范式**:分层状态机(HFSM)为骨干;帧级攻击编排下沉到现有能力系统,决策层只负责"选招/编排/让位"。**不采用行为树**——对编排式、确定性、帧级精确的横版动作敌人,FSM 更可控、更好调试。
|
||||
2. **唯一事实来源**:C# 声明式(fluent builder)→ 构建成可自省的不可变 `AiGraph` 模型 →**既执行、又生成可视化**。参数(数值/冷却/距离/权重)外挂 SO 热调。
|
||||
3. **边界**:只新建"决策大脑 + 干净能力接口(`ISensor`/`IMover`/`ICombatant`/`IActorVitals`)";现有感知/移动/能力子系统保留实现,用薄适配器包接口,彻底甩开 `EnemyBase` 上帝门面。
|
||||
4. **交付面**:运行时实时调试器 + Mermaid/dot 静态导出 + MCP 运行时自省 API。
|
||||
|
||||
---
|
||||
|
||||
## 2. 架构总览
|
||||
|
||||
```
|
||||
┌─ 声明层 (C# 唯一事实来源) ───────────────────────────┐
|
||||
│ EnemyAiScript.Build(b) 用 fluent 描述状态/转换/编排 │ ← MCP 直接写/diff
|
||||
└───────────────┬─────────────────────────────────────┘
|
||||
│ 构建一次(每种敌人一份,实例间共享 = 省内存/零结构GC)
|
||||
┌─ 模型层 (不可变 AiGraph) ────────────────────────────┐
|
||||
│ States · Transitions · AbilitySequences —— 可自省 │ ← 执行 + 可视化 同源
|
||||
└───────────────┬───────────────────────┬─────────────┘
|
||||
执行 │ │ 只读投影
|
||||
┌─ 运行时 (每实例 AiRuntime) ──────┐ ┌─ 交付面 ───────────────┐
|
||||
│ 当前状态指针·黑板·计时器·序列游标 │ │ Mermaid导出 / 实时调试器 │
|
||||
│ 事件驱动转换 + LOD 节流 Tick │ │ / MCP自省API │
|
||||
│ IsControllable 门(受击让位) │ └─────────────────────────┘
|
||||
└───────────────┬─────────────────┘
|
||||
│ 只依赖接口
|
||||
┌─ 能力接口层 (甩开 EnemyBase 门面) ───────────────────┐
|
||||
│ ISensor · IMover · ICombatant · IActorVitals │
|
||||
└───────────────┬─────────────────────────────────────┘
|
||||
│ 薄适配器
|
||||
┌─ 执行层 (保留现有实现) ──────────────────────────────┐
|
||||
│ PhysicsPerceptionSystem · EnemyMovement/NavAgent · │
|
||||
│ EnemyAbilityRegistry(+子类) · EnemyStats · Poise … │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心运行时模型
|
||||
|
||||
### 3.1 `AiGraph`(不可变模型,每种敌人一份,实例共享)
|
||||
|
||||
- 声明一次即构建,包含所有 `State`、`Transition`、`AbilitySequence`。
|
||||
- 被运行时执行,也被导出器/调试器读取 —— **同一模型,图与代码绝不失同步**。
|
||||
- flyweight 共享 → 结构零 per-instance 分配、缓存友好;委托在构建期捕获一次。
|
||||
|
||||
### 3.2 `AiRuntime`(每敌人实例,极轻)
|
||||
|
||||
持有:当前状态路径、`Blackboard`、计时器、正在跑的 `AbilitySequence` 游标。热路径无 LINQ / 装箱。
|
||||
|
||||
### 3.3 状态 · 层级 · 转换
|
||||
|
||||
- **State** = 可选 `OnEnter/Tick/OnExit` + 出边转换 + 可选子状态机(层级)+ 可选能力编排 `Do(...)`。
|
||||
- **层级(HFSM)**:父状态转换始终高优先评估。"任意态→受击/死亡/被弹反"挂在父层,一处声明、全局生效,替代 BD 的 Selector 优先级堆叠。
|
||||
- **两类转换,混合驱动**:
|
||||
- **事件转换(push)**:受击、被弹反、发现玩家等信号即时触发,不靠轮询。慢速巡逻杂兵被打的瞬间也立即反应。
|
||||
- **条件转换(pull)**:谓词(`InAttackRange` / `LostFor(2s)`),仅在该状态 LOD 频率下评估。
|
||||
- 按优先级有序、首个命中即转 → 确定性、好调试。
|
||||
|
||||
### 3.4 能力编排(`AbilitySequence`,非帧级微时序)
|
||||
|
||||
**关键分工**:帧级编排(一招怎么打,HitBox 时间窗)留在现有 `EnemyAbility` 子类里,本框架**不重造**。决策层的"序列"= 能力编排:
|
||||
|
||||
```
|
||||
选招(按 SO 的 preferredRange/priority/exclusion) → Execute()
|
||||
→ while(IsAbilityRunning) 等待(可被 InterruptAll 打断)
|
||||
→ AbilityEnded → 重评估 / 接下一招
|
||||
```
|
||||
|
||||
真要新增全新攻击 → 写一个 `EnemyAbility` 子类(属能力系统职责,不归 AI 框架)。
|
||||
|
||||
### 3.5 上下文 = 类型化能力接口 + 小黑板(混合)
|
||||
|
||||
- **类型化能力接口**(见 §4):状态逻辑调用,无装箱、编译期安全、摆脱 `EnemyBase` 门面。
|
||||
- **小黑板**(具名可枚举):只放 AI 临时值(目标、最后已知位置、计时器、弹反标志、hp%…)。保留具名黑板是为了让**调试器/MCP 能通用枚举"当前黑板值"**。
|
||||
|
||||
### 3.6 受击物理 FSM 的共存(`IsControllable` 显式让位)
|
||||
|
||||
`AiPhase`(决策层)与 `EnemyStateType{Controlled,Hurt,Stagger,KnockUp,Dead}`(受击物理 FSM)正交并存。接管规则:
|
||||
|
||||
- `IActorVitals.IsControllable = (CurrentState == Controlled)`。
|
||||
- **BrainGraph 只在 `IsControllable` 为真时推进决策**;被打成 Hurt/Stagger/KnockUp 时决策层**挂起**(保留状态指针、不下发新指令;正在等待的能力早被 `TakeDamage` 内 `InterruptAll(reason)` 打断)。物理态回 `Controlled` → 原地重评估。
|
||||
- 弹反/受击/死亡额外作为**父层事件转换**(`OnParried→反应态`、`OnDied→终止`),高优先即时。
|
||||
- 好处:调试器能显式显示"决策层:已挂起(KnockUp 中)",不靠隐式 fail 猜测。
|
||||
|
||||
### 3.7 Tick 与性能
|
||||
|
||||
- **LOD 调度**:idle/离屏慢 tick、战斗每帧;中央 `AiScheduler` 时间片分摊避免帧尖峰;**事件转换不受 LOD 影响、永远即时**。
|
||||
- 共享不可变定义 + 极小实例态 → 低 GC、cache 友好。
|
||||
|
||||
### 3.8 多实例复用与对象池(同一场景多个相同敌人)
|
||||
|
||||
- **共享 `AiGraph`(每敌人类型 1 份,flyweight)**:状态图结构、转换、能力编排、条件 lambda、显式条件标签字符串,全部只构建一次、由该类型所有实例共享。场景放 N 个相同杂兵 → 结构零 per-instance 分配、无重复 GC、缓存友好。
|
||||
- **每实例 `AiRuntime`(N 份)**:仅当前状态指针、`Blackboard`、计时器、`AbilitySequence` 游标随实例独立演化。
|
||||
- **无实例捕获约束**:声明里的条件/动作 lambda 只能通过参数 `c`(上下文)访问实例,**不得闭包捕获具体敌人实例**——这是 lambda 能挂在共享图上的前提。由 `BrainBuilder` 约定保证(后续可加 Roslyn 分析器强校验)。
|
||||
- **对象池重置**:`EnemyBase : IPoolable`(池复用走 `ForceStateRespawn`)。敌人回池再生时,`EnemyAiBrain` 必须重置 `AiRuntime`:回到 Entry 状态、清空黑板/计时器/游标、清空 trace。**共享 `AiGraph` 不重置**(无状态)。挂进 `IPoolable` 的回收/取出回调。
|
||||
- **调度分摊**:`AiScheduler` 对同类多实例做 tick 时间片错峰(见 §3.7),避免 N 个相同敌人同帧集中评估造成尖峰。
|
||||
|
||||
---
|
||||
|
||||
## 4. 能力接口 ↔ 现有实现 1:1 映射
|
||||
|
||||
所有接口方法均有真实实现背书(file:line 见探查报告)。距离比较统一在 `ISensor` 内部用平方距离(对齐 `EnemyStats.SqrDistanceToPlayer`)。
|
||||
|
||||
| 接口 | 方法(节选) | 背后真实实现 |
|
||||
|---|---|---|
|
||||
| **ISensor** | `SeesPlayer()` / `HasLineOfSight` / `InRange(r)` / `SlotDetects(slot)` / `LostFor(t)` / `LastKnown` | `EnemyBase.IsPlayerVisible()`(ThreatAssessor) · `HasAnyDetection(LOS\|Sight)` · `IsPlayerInRange`(平方) · `IPerceptionSystem.HasAnyDetection(slot)` · 黑板计时 · 黑板(原 `LastKnownPlayerPosition`) |
|
||||
| **IMover** | `MoveTo(p)` / `MoveDir(d)` / `Stop()` / `Face*/FacePlayer()` / `JumpTo(p)` / `LookAround()` / `WalkRandom()` / `AtDestination` / `IsGrounded/IsNearEdge` / `ReturnHome()` | `IPathAgent.RequestMoveTo` · `EnemyMovement.MoveHorizontal` · `StopMovement` · `FaceTarget/FaceDirection` · `JumpToTarget` · `BeginLookAround` · `WalkToRandom` · `IsAtDestination` · `IsGrounded/IsNearEdge` · `HomePosition` |
|
||||
| **ICombatant** | `UseAbility(id)` / `ForceUseAbility(id)` / `CanUseAbility(id)` / `IsAbilityRunning(id?)` / `AbilityPhase(id)` / `InterruptAbilities(reason)` / `PickUsable()` / `BasicAttack(type)` / `CanAttack()` | `Registry.Get(id).Execute()/ForceExecute()` · `ability.CanUse/IsRunning/Phase` · `Registry.InterruptAll/InterruptGroup` · 按 `EnemyAbilitySO.preferred*Range/priority/exclusionGroup` 选招 · `BeginAttack(AttackType)` |
|
||||
| **ICombatant.Boss** | `UseSkill(id)` / `UseSkillWeighted()` / `IsSkillExecuting` / `CurrentPhase` / `BeginPhaseTransition(n)` / `IsPhaseTransitioning` / `SetWeakPoints(on,mult)` | `BossBase.UseBossSkill` · `UseBossSkillWeighted` · `IsBossSkillExecuting` · `CurrentPhase` · `BeginPhaseTransition` · `WeakPointSystem.SetActive` |
|
||||
| **IActorVitals** | `HpPercent` / `HpBelow(r)` / `IsAlive` / `IsInvincible` / `IsControllable` / `PhysicalState` / `HasStatusEffect(t)` / `ApplyStatusEffect` / `PoiseLevel`/`SetPoiseLevel` | `EnemyStats.CurrentHP/MaxHP` · `IsHPBelow` · `IsAlive/IsInvincible` · `CurrentState==Controlled` · `EnemyStatusEffectManager` · `EnemyPoiseComponent` |
|
||||
|
||||
### 4.1 推送信号总线(事件转换来源)
|
||||
|
||||
`AiSignal { Damaged, Parried, Staggered, KnockedUp, Died, PlayerSpotted, AbilityEnded, PhaseChanged }`
|
||||
|
||||
来源接线(多数已有 C# event):`TakeDamage`(→Damaged) · `ReceiveParry`(→Parried,取代轮询 `ConsumeParryEvent`) · `ForceState`(→Staggered/KnockedUp) · `PerformDeath`(→Died) · `ThreatAssessor` 由假变真(→PlayerSpotted) · `ability.Interrupted`/协程结束(→AbilityEnded) · `BossPhaseEvent`(→PhaseChanged)。
|
||||
|
||||
### 4.2 参数外挂(热调落到真实字段)
|
||||
|
||||
声明里的 `c.P.*` 解析到已有 SO/字段:`EnemyStatsSO`(`AttackRange/AttackCooldown/DetectRange/MaxChaseDistance/LoseLinkTimeout/AlertDuration/InvestigateDuration/HomeRadius/HitTiers…`)、各 `EnemyAbility` 子类序列化字段、`EnemyAbilitySO`(`cooldown/preferredMin/MaxRange/requiresLineOfSight/priority/exclusionGroup`)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 声明层示例(唯一事实来源)
|
||||
|
||||
```csharp
|
||||
[AiDefinition("E001")]
|
||||
public sealed class BasicMeleeGruntAi : EnemyAiScript
|
||||
{
|
||||
protected override void Build(BrainBuilder b)
|
||||
{
|
||||
b.Entry("Patrol");
|
||||
|
||||
// 父层:全局反应(事件转换,高优先即时)
|
||||
// 注意:普通受击/硬直/击飞的"让位"由 IsControllable 门自动处理,无需在此声明。
|
||||
// 父层事件转换只用于决策层需要的、超出物理动画的额外行为响应。
|
||||
b.Global()
|
||||
.To("Dead").OnEvent(AiSignal.Died) // 终止决策
|
||||
.To("Enrage").When(c => c.Vitals.HpBelow(c.P.EnrageHpRatio), "HpBelow(enrage)"); // 低血狂暴(示例)
|
||||
|
||||
b.State("Patrol")
|
||||
.Tick(c => c.Mover.WalkRandom())
|
||||
.To("Chase").When(c => c.Sensor.SeesPlayer(), "SeesPlayer");
|
||||
|
||||
b.State("Chase")
|
||||
.Tick(c => c.Mover.MoveTo(c.Blackboard.LastKnown))
|
||||
.To("Combat").When(c => c.Sensor.InRange(c.P.MeleeRange), "InRange(melee)")
|
||||
.To("Search").When(c => c.Sensor.LostFor(c.P.LoseLinkTimeout), "LostFor(timeout)");
|
||||
|
||||
b.State("Combat")
|
||||
.OnEnter(c => c.Mover.FacePlayer())
|
||||
.Do(Seq.Select( // 按 SO 的 preferredRange/priority/exclusion 选招并等待其结束
|
||||
Ability("slash").When(c => c.Sensor.InRange(c.P.MeleeRange), "InRange(melee)")))
|
||||
.To("Chase").When(c => !c.Sensor.InRange(c.P.MeleeRange) && c.Combat.NoAbilityRunning, "outOfMelee");
|
||||
|
||||
b.State("Search")
|
||||
.OnEnter(c => c.Mover.LookAround())
|
||||
.To("Chase").When(c => c.Sensor.SeesPlayer(), "SeesPlayer")
|
||||
.To("Patrol").After(c => c.P.InvestigateDuration);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 转换条件的可读标签走**显式可选字符串**(`When(cond, "SeesPlayer")`):本项目 Unity 2022.3 = C# 9,不用 C# 10 的 `[CallerArgumentExpression]`;不传标签则回退 `"cond"`。将来升级语言级别可无痛切换为自动抓取。
|
||||
- `c.P.*` = 参数视图,解析自 `EnemyStatsSO` / 能力 SO。
|
||||
|
||||
---
|
||||
|
||||
## 5b. 敌人感知 ↔ 状态模型(待机 / 警觉 / 追击 / 巡逻)
|
||||
|
||||
地面敌人共用的感知驱动状态模型。**职责边界铁律**:状态转换逻辑在 **AI 状态机层**;感知系统只回答"在不在某感知区"(纯传感器,不知道有哪些状态)。
|
||||
|
||||
### 5b.1 两种感知(感知门面 `EnemyBase`)
|
||||
|
||||
- **追逐感知 `InChaseZone()`**(内,`aggro` 槽):玩家进入 → 触发追击。
|
||||
- **视野感知 `InVisionZone()`**(外,`los`/`sight` 槽;无视野槽则回退追逐感知):合并了"警觉触发"与"追击维持"两职——未发现态进入 → 警觉;追击中脱离 → 退出追击。
|
||||
- 推荐嵌套 **视野 ⊇ 追逐**(空间滞后:追逐区触发、视野区维持,防边界抖动)。
|
||||
|
||||
### 5b.2 勾选项
|
||||
|
||||
- `EnemyStatsSO.HasAlertState`(bool):是否有警觉状态。false 时未发现态进入追逐区**直接追击**、不经警觉;视野仅作追击维持。
|
||||
|
||||
### 5b.3 统一逐帧规则(`PerceptionStateMachine.Add`)
|
||||
|
||||
```
|
||||
全局:Died → Death
|
||||
待机/巡逻(R): 在追逐区 → 追击(优先)
|
||||
否则 HasAlert 且 在视野 → 警觉
|
||||
否则 保持
|
||||
警觉: 在追逐区 → 追击
|
||||
否则 脱离视野 → R
|
||||
否则 保持警觉(朝向玩家、警觉动画+feedback)
|
||||
追击: 委托追击能力(如 e001_chase)实现移动/伤害
|
||||
脱离视野 → R(回 Idle/Patrol,**永不回警觉**);退出时 AI 中断追击能力
|
||||
```
|
||||
|
||||
**核心不对称**:警觉**只在升级路径**(未发现态→警觉→追击)出现;**降级**(追击→脱离视野)**永不经过警觉**,直接回 R(该敌人配置的静止态 Idle 或 Patrol)。回 R 后玩家**再进视野 → 会再警觉**(新一次升级,符合 E001 每次重新发现再张口)。
|
||||
|
||||
### 5b.4 缺省自动降级(一套规则覆盖所有配置)
|
||||
|
||||
- 无警觉(`HasAlert=false`):警觉分支永不触发。
|
||||
- 无视野槽:维持退化用追逐感知(脱离追逐区即退出追击)。
|
||||
- 视野被追逐包含(误配 S⊆C):等价"无视野",建议校验告警。
|
||||
|
||||
### 5b.5 复用与实现
|
||||
|
||||
- **`PerceptionStateMachine`**(`BaseGames.Enemies`):一次性声明四态+统一规则,各敌人只传 `Config`(HasAlert 由 `IEnemyActor.HasAlertState` 运行时读、警觉/追击/巡逻的 OnEnter 钩子、追击能力 id、R)。
|
||||
- **E001** = 配置:Idle=伪装、Alert=张口(`e001_alert`)、Chase=委托 `e001_chase`、R=巡逻、HasAlert=true。
|
||||
- `ContactChaseAbility` 为**纯执行器**(追击直到被 AI 中断);退出追击的决策归 AI(`OnChaseExit → Combat.InterruptAbilities()`)。
|
||||
- 动画:AI 经 `IEnemyActor.SetPhase(AiPhase)` 声明阶段,`EnemyBase.SetAiPhase` 播放(Idle/Alert/Chase/Patrol 各自 clip)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 交付面
|
||||
|
||||
### 6.1 Mermaid/dot 导出器
|
||||
|
||||
- 纯函数 `AiGraph → stateDiagram-v2` 文本;层级子机 → composite state;边标签取自转换的显式标签字符串 / 事件枚举名。
|
||||
- 入口:菜单 `BaseGames/AI/Export Graph` + 静态方法(打印 Console,供 MCP `unity_execute_code` 抓);文件写 `Docs/AI/<enemyId>.md`。
|
||||
|
||||
### 6.2 运行时实时调试器(Editor 窗口,仅编辑器)
|
||||
|
||||
- 选中敌人 → 实时高亮当前状态、最近 N 次转换(from→to + 触发原因)、黑板表、正在跑能力及 `Phase`/进度、`IsControllable` 门状态。
|
||||
- 全局 `AiScheduler.Paused` + 单步。
|
||||
- MVP:状态列表 + 实时高亮 + trace + 黑板;完整自动布局节点图归入后续"设计期只读窗口"。
|
||||
|
||||
### 6.3 MCP 运行时自省 API
|
||||
|
||||
- 运行时服务 `AiDebugService`(注册进 ServiceLocator),静态入口暴露:
|
||||
- `ListAgents()` / `GetAgent(id)` → `{ currentStatePath, isControllable, lastTransitions[], blackboard{}, runningAbility, physicalState }`
|
||||
- `ExportMermaid(enemyTypeId)`、`SetPaused(bool)`、`Step()`、`ForceState(id, stateName)`(调试驱动)
|
||||
- MCP 经 `unity_execute_code` 调用即可"问诊"任意敌人 → 生成 + 调试闭环。
|
||||
|
||||
---
|
||||
|
||||
## 7. 组件、装配与脚手架
|
||||
|
||||
### 7.1 组件
|
||||
|
||||
- 新增 `EnemyAiBrain : MonoBehaviour`:按 id 从注册表取共享 `AiGraph`、建 `AiRuntime`、`Awake` 收集兄弟子系统构造四适配器、按 LOD tick。**取代** `EnemyBase` 中 `#if GRAPH_DESIGNER` 的 BT 集成。
|
||||
- `EnemyAiScript`(抽象基类)+ `[AiDefinition("<id>")]` 特性;反射注册表 `AiDefinitionRegistry`。
|
||||
- 适配器 `SensorAdapter/MoverAdapter/CombatantAdapter/VitalsAdapter`:直接引用具体子系统(`Movement/Nav/Registry/Perception/Stats/StatusEffects/Poise`),不经门面。
|
||||
|
||||
### 7.2 Domain Reload 约束
|
||||
|
||||
项目已关 Domain/Scene Reload。`AiDefinitionRegistry`(静态注册表)与任何静态缓存须用 `RuntimeInitializeOnLoadMethod(BeforeSceneLoad)` 重置;`AiRuntime` 运行时态随 `EnemyAiBrain` 生命周期,无跨播放态残留。
|
||||
|
||||
### 7.3 脚手架接线(遵项目 CLAUDE.md 第 2 条)
|
||||
|
||||
扩展 `SceneObjectPlacerTool` / `CharacterWizardWindow`:放置敌人时自动挂 `EnemyAiBrain` + 绑定 `AiDefinition` id + 参数 SO。消除 `SceneObjectPlacerTool.cs:354/524` 的 `★ 手动挂行为树` TODO,使敌人创建全链路 MCP 可自动化。
|
||||
|
||||
---
|
||||
|
||||
## 8. 迁移路径(先共存、后退役)
|
||||
|
||||
1. 建框架 + 适配器,先在杂兵 `E001_CaoZhi` 跑通(BD 仍在、该敌人不用)。
|
||||
2. 上齐三交付面(导出 / 调试器 / MCP 自省)。
|
||||
3. 拿 Boss `ChaoFengBoss` 验证阶段 / 加权技能 / 弱点窗口。
|
||||
4. 扩展脚手架自动接线。
|
||||
5. 代表性敌人 parity 后 → 批量迁移剩余 → **最后**删除 49 个 `BD_*`、`EnemyBase` BT 集成、`com.opsive.*` 包。
|
||||
|
||||
> 试点目标(`E001_CaoZhi` / `ChaoFengBoss`)为默认假设,可在 review 时调整。
|
||||
|
||||
---
|
||||
|
||||
## 9. v1 范围
|
||||
|
||||
**纳入 v1**:
|
||||
- 核心运行时(`AiGraph` 模型 + `BrainBuilder` + `AiRuntime` + 层级 + 事件/条件转换 + 能力编排 `AbilitySequence` + `IsControllable` 门 + `AiScheduler` LOD 调度)。
|
||||
- 4 能力接口 + 适配器。
|
||||
- `AiSignal` 信号总线接线(对 `EnemyBase` 的最小改动:受击/弹反/死亡/发现玩家/能力结束/阶段变更处 raise 信号)。
|
||||
- `EnemyAiBrain` 组件 + `AiDefinitionRegistry`。
|
||||
- 三交付面(调试器取 MVP)。
|
||||
- 脚手架接线。
|
||||
- 试点:1 杂兵(`E001_CaoZhi`)+ 1 Boss(`ChaoFengBoss`)。
|
||||
|
||||
**推迟到后续**:
|
||||
- 完整节点图 GraphView 设计期只读窗口。
|
||||
- 数据驱动"新招式免写 C# 子类"(招式继续走 `EnemyAbility` 子类)。
|
||||
- 批量迁移剩余敌人 + 彻底删 BD/Opsive(parity 后单独收尾)。
|
||||
- 群体协同精修(先薄封装现有 `AlertNearby`/`ReceiveAlert`)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 主要程序集与文件(预期)
|
||||
|
||||
- 新 asmdef:`BaseGames.AI`(`Assets/_Game/Scripts/AI/`),引用 `BaseGames.Enemies` / `BaseGames.Core`;不引用 `Opsive.*`。
|
||||
- 运行时:`AiGraph.cs`、`State.cs`、`Transition.cs`、`AbilitySequence.cs`、`AiRuntime.cs`、`Blackboard.cs`、`BrainBuilder.cs`、`EnemyAiScript.cs`、`AiDefinitionRegistry.cs`、`AiScheduler.cs`、`EnemyAiBrain.cs`。
|
||||
- 接口 + 适配器:`ISensor/IMover/ICombatant/IActorVitals` + 4 个 `*Adapter.cs`。
|
||||
- 交付面:`MermaidExporter.cs`、`AiDebugService.cs`、`Editor/BrainGraphDebuggerWindow.cs`。
|
||||
- 改动:`EnemyBase.cs`(移除 BT 集成、raise `AiSignal`)、`SceneObjectPlacerTool.cs` / `CharacterWizardWindow.cs`(自动接线)。
|
||||
|
||||
---
|
||||
|
||||
## 11. 验收标准(v1)
|
||||
|
||||
1. `E001_CaoZhi` 完全由 BrainGraph 驱动,行为达到"巡逻→发现→追击→近战选招→丢失搜查→归位"闭环,且**不挂任何 `BehaviorTree` 组件**。
|
||||
2. `ChaoFengBoss` 由 BrainGraph 驱动多阶段 + 加权技能 + 弱点窗口,`BeginPhaseTransition` 无敌演出正确衔接。
|
||||
3. 受击/弹反:被打时决策层经 `IsControllable` 门正确挂起、能力被打断、恢复后重评估;弹反经父层事件转换进入 Stagger 反应。
|
||||
4. `BaseGames/AI/Export Graph` 能为上述两个敌人导出与运行行为一致的 Mermaid 图。
|
||||
5. 运行时经 MCP `unity_execute_code` 调 `AiDebugService.GetAgent(id)` 能取到当前状态/转换原因/黑板/能力/门状态。
|
||||
6. 脚手架放置敌人时自动挂 `EnemyAiBrain` 并绑定定义 + 参数 SO(无手动步骤)。
|
||||
7. **多实例复用**:同一场景放多个相同敌人时,共享同一 `AiGraph`(每类型仅构建 1 次),各自 `AiRuntime` 独立互不干扰;敌人经对象池回收再生后 `AiRuntime` 完全重置、无上一条命的状态残留。
|
||||
8. 通过项目自检:SO 校验、AddressKey 校验、Physics2D 层校验无新增错误。
|
||||
@@ -0,0 +1,143 @@
|
||||
# 敌人执行层统一 + AI/动画/属性瘦身 —— 设计文档
|
||||
|
||||
- 日期:2026-07-10
|
||||
- 分支:`feat/braingraph-enemy-integration`
|
||||
- 范围:**E001 样板 + 可复用框架**;E002-E006/Boss 的 AI 落地范式在本文件定义、后续逐个实现。
|
||||
- 关联:[[braingraph_ai_framework]]、[[ai_decision_only_delegates_abilities]]、[[enemy_nav_movement_architecture]];spec `2026-07-03-enemy-ai-framework-design.md`。
|
||||
|
||||
## 1. 背景与问题
|
||||
|
||||
敌人 AI 已从三方 Behavior Designer 迁到自研 BrainGraph。四个子系统(movement / stats / state / AI+abilities)通篇审查后暴露三类问题:
|
||||
|
||||
1. **两套 AI 并存**:~50 个 `BD_*` Behavior Designer 任务 + `BaseGames.Enemies.AI` asmdef + 三个 Opsive 包,运行期全死,只靠 `GRAPH_DESIGNER` 宏还在编译。→ **已在 P1 删除(本文件写作前完成)**。
|
||||
2. **"同一概念多实现"**:
|
||||
- **7 种巡逻**:`PatrolAbility`(nav 随机) / `IMover.WalkRandom`(nav 随机, 仅测试) / `BD_WalkRandom` / `BD_InvestigateLastKnown` 子步 / `BD_Patrol`(速度踱步, 撞墙翻向) / `BD_PatrolWaypoints`(路点) / `FlyingDirectNavigator.WalkToRandom`(MovePosition)。既有 4 份"nav 随机游走"重复,又把有玩法差异的"踱步/路点"在迁移中弄丢。
|
||||
- **4 套移动执行**:`EnemyMovement` 直设 velocity / `PendingInput` 信号 / `FlyingEnemy` 直设 velocity / `FlyingDirectNavigator` MovePosition。nav 与 AI 会在同一 FixedUpdate 写同一批 `PendingInput` 字段互相覆盖。
|
||||
- "朝向"4 入口、"停"4 入口、"设速度"跨 `EnemyStatsSO`/`TBM.movementSpeed`/`MoveInput` 三处。
|
||||
- `IMover`(含 `UseChaseSpeed/UsePatrolSpeed/LookAround/WalkRandom`)零生产调用,被能力直调 `EnemyBase` 架空。
|
||||
3. **冗余状态层**:`AiPhase`(Idle/Patrol/Alert/Chase/Combat/Investigate/ReturnHome) 与 BrainGraph 状态严格 1:1,只用来选动画、AI 从不回读;`Combat/Investigate/ReturnHome` 是死枚举值。
|
||||
4. **Model A 过度设计**:`IdleAbility`/`PatrolAbility`/`AlertAbility` 是 `while(true) yield` 常驻协程,唯一作用是占住 `IsRunning` 让 `EnsureAbility` 空转——为琐碎行为各付出「协程+注册项+SO 资产」,且借用了本为攻击设计的 `EnemyAbilitySO`(`attackSequence/telegraph/range/LOS/priority` 全是死字段)。
|
||||
5. **属性冗余**:`EnemyStatsSO` 约 11/26 字段死或与 sensor 槽 / `DamageSourceSO` 重复;速度配在 3 处;玩家/敌人难度缩放逻辑重复。
|
||||
|
||||
**现状要点**:只有 **E001** 有可用 AI(`[AiDefinition("E001")]` + `EnemyAiBrain`)。E002/E003/E006/Boss 完全无 AI;E004/E005 prefab 里的 "BehaviorTree" 只是 `_stopBehaviorTree` bool 字段,非 Opsive 组件。→ 整套 AI/能力设计只在 1 个敌人上验证过,是重构的最佳时机。
|
||||
|
||||
## 2. 设计原则(不变)
|
||||
|
||||
- **AI 只决策,不 actuate**:AI 决定"进哪个状态 / 转换条件 / 触发哪个能力 / 声明哪个移动意图";移动/朝向/停/速度/动画的**实现**不在 AI。见 [[ai_decision_only_delegates_abilities]]。
|
||||
- **根因修复,不下游兜底**:删除多余抽象本身(而非留着不调用),让"AI 无法 actuate"在类型层成立。见 CLAUDE.md §6。
|
||||
- **"能力"一词收回给战斗**:带冷却/预警/HitBox/接触伤害才叫 ability;纯移动/朝向归 locomotion。
|
||||
|
||||
## 3. 目标架构
|
||||
|
||||
三条正交轴,各自单一职责:
|
||||
|
||||
| 轴 | 组件 | 职责 | 谁改它 |
|
||||
|---|---|---|---|
|
||||
| 反应态 | `EnemyStateType` FSM(Controlled/Hurt/Stagger/KnockUp/Dead) | 受击/硬直/死亡生命周期;当 AI 的门(`IsControllable`) | 伤害/招架/死亡 |
|
||||
| 决策 | BrainGraph(`AiRuntime`/`PerceptionStateMachine`/`AiScript`) | 感知→状态;声明移动意图 + 触发攻击能力 | 感知查询 |
|
||||
| 执行 | **`EnemyLocomotion`(新)** + `EnemyAbilityBase`(攻击) | 移动/朝向/步态动画;攻击 | AI 声明意图;能力协程 |
|
||||
|
||||
### 3.1 核心组件 `EnemyLocomotion`(统一移动执行器)
|
||||
|
||||
单一移动入口,取代 4 套移动模型 + `IMover` + 三个协程能力 + `EnemyBase` 上散落的移动方法。
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Enemies
|
||||
{
|
||||
public enum LocomotionMode { Idle, Patrol, Face, Approach }
|
||||
public enum PatrolStrategy { Wander, Pace, Waypoints }
|
||||
|
||||
// 对外只暴露"声明意图"的 API(AI/能力调用),执行在 FixedUpdate 内部完成
|
||||
public interface IEnemyLocomotion
|
||||
{
|
||||
void SetMode(LocomotionMode mode); // Idle(停)/ Patrol(按配置策略游走)
|
||||
void Approach(Transform target); // 持续跟随(Chase 能力 / 调查用),派生 RunSpeed
|
||||
void MoveTo(Vector2 point); // 一次性目标点
|
||||
void Face(Vector2 lookAt); // 停 + 朝向(Alert 用)
|
||||
void Stop();
|
||||
LocomotionMode CurrentMode { get; }
|
||||
bool IsMoving { get; }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **内部双后端,作为可替换的 `IPathAgent`**:
|
||||
- **地面怪** = `EnemyNavAgent`(PathBerserker2d)。PB2d 是**表面图寻路**(`NavSurface`/`NavSegment` + 表面间 `NavLink`),agent 始终映射到可行走表面——**不支持飞行/自由空间寻路**(已核实包源码:`NavSegmentPositionPointer`/`IsOnLink`/`KeepGrounded`,零 fly/aerial 支持)。
|
||||
- **飞行怪** = `FlyingDirectNavigator`。**不走 PB2d**,直接 `Rigidbody2D.MovePosition` 向目标点**直线直飞**(含正弦悬停)。
|
||||
- `EnemyLocomotion` 是二者之上的唯一门面,按敌人类型选后端。物理原生架构(寻路只给方向、velocity 执行、碰撞体底部对齐 y=0)保持,见 [[enemy_nav_movement_architecture]]。
|
||||
- **巡逻 = `Patrol` 模式 + 每敌人序列化 `PatrolStrategy` + 参数**:一个组件三种玩法(Wander 随机 / Pace 撞墙翻向踱步 / Waypoints 路点序列),消灭 7 份实现并找回踱步/路点。
|
||||
- **速度单一来源**:由模式派生(Patrol→`WalkSpeed`、Approach→`RunSpeed`,读 `EnemyStatsSO`)。删除 `EnemyMovement` 对 `EnemyStatsSO` 的二次直读接线(`EnemyMovement._config`),删除经 `TBM.movementSpeed` 的速度中转重复。
|
||||
- **`Approach` 不改追击语义**:`ContactChaseAbility` 保留为能力;仅把内部 `_enemy.MoveTo(player)` 换成 `Locomotion.Approach(player)`;接触伤害(战斗语义)仍在能力里。
|
||||
|
||||
### 3.2 AI 决策层用法(仍"只决策")
|
||||
|
||||
`PerceptionStateMachine` 状态不再绑"能力 id",改绑 **locomotion 意图 + 可选攻击能力 id**:
|
||||
|
||||
```
|
||||
Idle_Disguise : OnEnter → Locomotion.SetMode(Idle)
|
||||
Move_Patrol : OnEnter → Locomotion.SetMode(Patrol)
|
||||
Alert : OnEnter/Tick → Locomotion.Face(player)
|
||||
Chase : OnEnter → Combat.UseAbility("e001_chase") // 能力内部 Approach(player)
|
||||
Death : 终态(死亡演出走反应态 FSM)
|
||||
```
|
||||
|
||||
- `SetMode/Face/Approach` 是**声明**(与原 `SetAiPhase` 同性质),执行在 `EnemyLocomotion`——AI 仍不碰 velocity/朝向实现。
|
||||
- **删除** `IdleAbility`/`PatrolAbility`/`AlertAbility` 三协程能力、`ABL_E001_Idle`/`ABL_E001_Patrol`/`ABL_E001_Alert` 三 SO、`IMover` 接口及其在 `EnemyBrainContext` 的实现。
|
||||
- `IAiContext` 增加 `IEnemyLocomotion Locomotion { get; }`(替换 `IMover Mover`)。`ICombatant.UseAbility/IsAbilityRunning/InterruptAbilities` 保留(攻击/Chase 用)。
|
||||
- `PerceptionStateMachine.Config` 改为每状态填 `LocomotionIntent`(模式 + 可选 target 来源)+ 可选 `abilityId`;`AddAbilityState` 换成 `AddLocomotionState`。
|
||||
|
||||
### 3.3 动画(删 `AiPhase`)
|
||||
|
||||
- `EnemyLocomotion` 按当前模式/实际速度驱动步态 clip:静止→`Idle`、Patrol 移动→`Walk`、Approach→`Run`、Face→`Alert`。映射表 = 原 `AiPhase→AnimConfig` switch,改 **key 在 `LocomotionMode`** 上(放 `EnemyLocomotion` 或一个轻量 `EnemyAnimationDriver`)。
|
||||
- 反应态(Hurt/Stagger/KnockUp/Dead)与攻击/出现能力**照旧各自播 clip**,覆盖在步态之上。
|
||||
- **删除** `AiPhase` 枚举、`EnemyBase.SetAiPhase`/`CurrentAiPhase`/`OnAiPhaseChanged`,及所有能力里的 `SetAiPhase(...)`。`ReceiveAlert` 里用 `AiPhase` 做的"已交战不降级"判断改读 AI 当前状态名或一个 `bool IsEngaged`。
|
||||
|
||||
### 3.4 属性清理(`EnemyStatsSO`)
|
||||
|
||||
删除死/重复字段:`AttackDamage`、`AttackRange`、`DetectRange`、`DetectAngleDeg`、`EyeOffset`、`LOSBlockingMask`(感知全归 sensor 槽 `PhysicsPerceptionSystem`)、`AlertDuration`、`InvestigateDuration`、`KnockbackForce`、`HitStunDuration`(击退归 `DamageSourceSO`)、`HitTierConfig.heavyHitThreshold`(硬直由 Poise `Break` vs `PoiseLevel` 决定)。
|
||||
|
||||
- 速度单一来源(删 `EnemyMovement._config` 直读,改经 `EnemyLocomotion` 读 `Stats`)。
|
||||
- (低优先,可选)玩家/敌人难度缩放抽共享 `ScalableVitals` helper(当前两边近乎逐行重复)。
|
||||
|
||||
## 4. E002-E006 / Boss 的 AI 落地范式(本文件定义,后续实现)
|
||||
|
||||
每个敌人 = **一个 `[AiDefinition("Exxx")]` `AiScript` 子类**,内容仅为:
|
||||
|
||||
1. 复用 `PerceptionStateMachine`(或 Boss 专用图),填状态名 + 每状态的 **locomotion 意图** + **攻击能力 id**。
|
||||
2. 攻击行为写成 `EnemyAbilityBase` 子类(已有一批:Melee/Projectile/Leap/Charge/CeilingDrop/…),配 `EnemyAbilitySO`(真攻击才需 cooldown/telegraph/HitBox)。
|
||||
3. 巡逻/待机/警觉/追击**不写能力**,只在状态里声明 locomotion 意图(含选 `PatrolStrategy`)。
|
||||
4. 脚手架 `SceneObjectPlacerTool.PlaceExxx` 挂 `EnemyAiBrain(_definitionId)` + `EnemyLocomotion` + 攻击能力子节点。
|
||||
|
||||
近重复能力(后续合并,不阻塞本次):`CeilingDropAbility` vs `AnimatedCeilingDropAbility`(后者注释已声明取代前者);`AppearAbility` = `PlayClipAbility` + 一行 `SetAiPhase`;`FacePlayerAbility` 与 `AlertAbility` 的"朝向"原语;多个攻击能力里手抄的 HitBox 窗口 + `IsGrounded` 射线。
|
||||
|
||||
## 5. 分期实施(每期独立可提交、可回滚)
|
||||
|
||||
- **P1 死代码清除** — ✅ **已完成**(commit `e894a9d`):删 `BD_*` + `BaseGames.Enemies.AI` asmdef + `GRAPH_DESIGNER` 宏 + 三个 `com.opsive.*` 包;恢复编译(0 错误)。
|
||||
- **P2 引入 `EnemyLocomotion`** — 建组件 + 接入 Nav/Flying 后端;先与旧 API 并存,E001 切过去 PlayMode 跑通四态 + de-escalation。
|
||||
- **P3 AI 改绑 locomotion 意图** — 删三协程能力/三 SO/`IMover`;`PerceptionStateMachine` 改 `AddLocomotionState`;`E001CaoZhiAi` 改声明意图;`ContactChaseAbility` 内部改 `Approach`。E001 验证。
|
||||
- **P4 删 `AiPhase`** — 动画改由 locomotion 驱动;迁移 `SetAiPhase` 全部调用点;`ReceiveAlert` 改判据。
|
||||
- **P5 Stats 清理** — 删死字段 + 速度单一来源(+ 可选难度缩放 helper)。
|
||||
- **P6 巡逻策略补全** — 实现 `PatrolStrategy.Pace/Waypoints`(找回丢失玩法)。
|
||||
- **文档产物** — 本文件即 E002-E06/Boss AI 落地范式;各敌人后续按范式逐个实现(独立任务)。
|
||||
|
||||
## 6. 测试
|
||||
|
||||
- **EditMode**:BrainGraph 现有测试(`Assets/Tests/EditMode/AI/`)保留;`IMover` 相关用例改用 `IEnemyLocomotion` spy 或 `ICombatant` spy 验证状态机 Enter/Tick/Exit 机制。
|
||||
- **PlayMode(TestRoomA,E001)**:每期验 待机(伪装)→巡逻→警觉(朝向)→追击(接触伤害)→死亡 全链 + 脱离感知 de-escalation;断言 AI 脚本零 `velocity/朝向` 实现、动画随 locomotion 模式切换、console 0 报错。
|
||||
- **自检**:`Validate All ScriptableObjects` / `Validate Address Keys` / `Physics2D Layer Matrix Check`(CLAUDE.md §3)。
|
||||
|
||||
## 7. 风险与缓解
|
||||
|
||||
- **飞行怪双模型**:`FlyingEnemy`/`FlyingDirectNavigator` 走 `MovePosition`,需在 `EnemyLocomotion` 内以 `IPathAgent` 后端形式收编,避免又留一套并行执行。P2 明确覆盖飞行分支。`EnemyLocomotion` API 对上层一致,`SetMode/Approach/MoveTo` 在飞行后端映射为直飞目标点。
|
||||
- **飞行绕障是已知缺口(非本次范围)**:PB2d 表面寻路给不了飞行绕障,现有 `FlyingDirectNavigator` 是**直线直飞、无障碍绕行**;当前 E002/E004/E005/E006 prefab 两种导航组件都未挂(飞行导航"有实现未接线")。真要做"能绕墙的飞行怪"时需**单独的飞行寻路方案**(如 2D 网格 A*/点图),不能靠 PB2d 或现有直飞。本次不实现;作为落地飞行怪 AI 时的前置决策记录在案。
|
||||
- **物理原生易回归**:nav 只给方向、velocity 执行、`TBM.SegmentMovement` 关、碰撞体底部 y=0——重构中必须保留,见 [[enemy_nav_movement_architecture]]。
|
||||
- **动画覆盖顺序**:反应态/攻击 clip 必须能压过步态 clip;P4 需验证受击/攻击时步态不抢播。
|
||||
- **`AiPhase` 删除波及面**:`SetAiPhase` 有多处调用点(能力、`ReceiveAlert`、gizmo/overlay);P4 逐点迁移并保留调试可视化(改读状态名)。
|
||||
- **范围克制**:本次只在 E001 落地并验证;E002-E006/Boss 仅定范式,避免一次性大改多敌人放大回归面。
|
||||
|
||||
## 8. 成功判据
|
||||
|
||||
- 编译 0 错误;E001 PlayMode 行为与重构前一致(四态 + de-escalation + 接触伤害)。
|
||||
- 代码库中"巡逻"实现 1 个(`EnemyLocomotion` + 策略)、"移动执行"入口 1 个、无 `AiPhase`、无 `IMover`、无 Idle/Patrol/Alert 协程能力与其 SO。
|
||||
- `EnemyStatsSO` 无死字段;速度单一来源。
|
||||
- E002-E006/Boss 有明确、可照抄的 AI 落地范式文档。
|
||||
@@ -0,0 +1,116 @@
|
||||
# 敌人碰撞体:Sprite 驱动 + 统一 Box + 全员接触伤害区 —— 设计文档
|
||||
|
||||
- 日期:2026-07-10
|
||||
- 范围:**只改脚手架 `SceneObjectPlacerTool` 与向导 `CharacterWizardWindow`**;不动现有 prefab(用户后续手动重生成替换)。
|
||||
|
||||
## 目标
|
||||
|
||||
脚手架生成的每个敌人(含 Boss)满足:
|
||||
1. **主体碰撞体、HurtBox、ContactDamageZone 三者统一为 `BoxCollider2D`**,尺寸完全一致、**底部对齐 y=0**(敌人原点在碰撞体底部,参考 E001;供 PathBerserker2d NavAgent 检测原点落在导航线上)。
|
||||
2. 三者尺寸由**向导指定的默认 Sprite 的包围盒**(`sprite.bounds.size`)推导;Sprite 留空则回退到各敌人现有的硬编码尺寸(不破坏无 sprite 的旧行为)。
|
||||
3. **每个敌人 + Boss 都有 ContactDamageZone**(接触伤害区);不需要接触伤害的敌人由策划把该节点置为非激活。
|
||||
|
||||
## 背景(现状)
|
||||
|
||||
- `SceneObjectPlacerTool` 的 `CreateBodyCollider(go, type, size)` 已支持 Box/Capsule/Circle + `AlignColliderBottomToPivot`(底部对齐)。主体默认 Box。
|
||||
- HurtBox 各敌人现为 `CapsuleCollider2D`(E001 已是 Box);ContactDamageZone 参差:E001=Box、E003/E006=Circle、**E002/E004/E005/ChaoFeng 缺失**。
|
||||
- 尺寸为每敌人硬编码(如 E001 body 0.6×0.8,E002 0.5×0.7…)。
|
||||
- `EnsureCollidersAreTriggers(go)` 助手已存在(把节点全部 Collider2D 设 trigger)。
|
||||
- `CharacterWizardWindow` 小怪/ Boss Tab 已有"主体碰撞器类型"EnumField,调 `PlaceSpecificEnemy(id, colliderType)` / `PlaceChaoFeng`。
|
||||
|
||||
## 设计
|
||||
|
||||
### A. 向导:默认 Sprite 字段(可选)
|
||||
|
||||
- 小怪 Tab、Boss Tab 各加一个 `UnityEditor.UIElements.ObjectField`(`objectType = typeof(Sprite)`),标签"默认外观 Sprite(碰撞体尺寸依据,可留空)",绑定 `Sprite _enemyDefaultSprite`(Boss 用同一字段或独立 `_bossDefaultSprite`,实现上用一个 `_defaultSprite` 即可,按当前 Tab 复用)。
|
||||
- 放置按钮把选中 Sprite 传入:`PlaceSpecificEnemy(id, colliderType, _defaultSprite)`、`PlaceChaoFeng(colliderType, _defaultSprite)`。
|
||||
|
||||
### B. 脚手架:Sprite 驱动 + 统一 Box + 全员接触伤害区
|
||||
|
||||
**新增两个助手:**
|
||||
|
||||
```csharp
|
||||
// Sprite 有则用其世界包围盒尺寸,否则回退。
|
||||
private static Vector2 SpriteSizeOr(Sprite s, Vector2 fallback)
|
||||
=> s != null ? (Vector2)s.bounds.size : fallback;
|
||||
|
||||
// 建 HurtBox + ContactDamageZone 两个子节点,均为 Box、同尺寸、底部对齐、isTrigger。
|
||||
// contactEnabled:BodyContactDamage 初始启用状态(不需要接触伤害的敌人可置 false 或后续置节点非激活)。
|
||||
// 返回创建的 (hurtBox, bodyContact, contactHitBox) 供上层按需接线。
|
||||
private static (HurtBox hurt, BodyContactDamage contact, HitBox contactHitBox)
|
||||
SetupHurtAndContactBoxes(GameObject root, Vector2 size, bool contactEnabled, List<string> report)
|
||||
{
|
||||
// HurtBox 子节点
|
||||
var hurtT = GetOrCreateChild(root.transform, "HurtBox");
|
||||
SetLayer(hurtT.gameObject, "EnemyHurtBox", report);
|
||||
var hurtBox2D = GetOrAddComponent<BoxCollider2D>(hurtT.gameObject);
|
||||
hurtBox2D.size = size; hurtBox2D.isTrigger = true;
|
||||
AlignColliderBottomToPivot(hurtBox2D);
|
||||
var hurtBox = GetOrAddComponent<HurtBox>(hurtT.gameObject);
|
||||
EnsureCollidersAreTriggers(hurtT.gameObject);
|
||||
|
||||
// ContactDamageZone 子节点
|
||||
var contactT = GetOrCreateChild(root.transform, "ContactDamageZone");
|
||||
SetLayer(contactT.gameObject, "EnemyHitBox", report);
|
||||
var contactBox = GetOrAddComponent<BoxCollider2D>(contactT.gameObject);
|
||||
contactBox.size = size; contactBox.isTrigger = true;
|
||||
AlignColliderBottomToPivot(contactBox);
|
||||
var contactHitBox = GetOrAddComponent<HitBox>(contactT.gameObject);
|
||||
var bodyContact = GetOrAddComponent<BodyContactDamage>(contactT.gameObject);
|
||||
bodyContact.enabled = contactEnabled;
|
||||
EnsureCollidersAreTriggers(contactT.gameObject);
|
||||
return (hurtBox, bodyContact, contactHitBox);
|
||||
}
|
||||
```
|
||||
|
||||
**每个 `PlaceExxx` 签名加可选 sprite;用它推导 size、赋给 SpriteRenderer、走统一助手:**
|
||||
|
||||
```csharp
|
||||
public static void PlaceE0xx(EnemyBodyColliderType bodyCollider = EnemyBodyColliderType.Box,
|
||||
Sprite defaultSprite = null)
|
||||
{
|
||||
...
|
||||
Vector2 size = SpriteSizeOr(defaultSprite, /*该敌人原硬编码 fallback*/);
|
||||
var sr = SetupSpriteRenderer(visual.gameObject);
|
||||
if (defaultSprite != null) sr.sprite = defaultSprite; // 视觉与碰撞体一致
|
||||
CreateBodyCollider(go, bodyCollider, size); // 主体 Box(size)、底部对齐
|
||||
var (hurtBox, bodyContact, contactHitBox) =
|
||||
SetupHurtAndContactBoxes(go, size, contactEnabled: true, report);
|
||||
// 该敌人特有接线(如 E001:contactEnabled=false + 把 bodyContact 绑给 ContactChaseAbility._contactDamage;
|
||||
// contactHitBox 绑 CMB_DS_EnemyBody 伤害源)。
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
- **主体默认 Box**(`EnemyBodyColliderType.Box`);Capsule/Circle 选项保留但默认 Box。三者尺寸都 = `size`。
|
||||
- **接触伤害默认启用**(碰到即伤):`contactEnabled: true`;**E001 例外** `contactEnabled: false`(其 ContactChaseAbility 在冲刺期开启接触伤害),并把 `bodyContact` 绑到 `ContactChaseAbility._contactDamage`。
|
||||
- ContactDamageZone 的 HitBox 绑默认伤害源 `CMB_DS_EnemyBody`(沿用 E001 现有做法)。
|
||||
- Boss `PlaceChaoFeng` 同样通过助手补 ContactDamageZone(默认启用,策划按需置非激活)。
|
||||
- 全部走 `AlignColliderBottomToPivot`(底部对齐,中心在底部)。
|
||||
|
||||
### C. 不动现有 prefab
|
||||
|
||||
现有 7 个 prefab 不由本次修改,用户用更新后的脚手架手动重生成替换。
|
||||
|
||||
## 影响文件
|
||||
|
||||
- `Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs`:新增 `SpriteSizeOr` / `SetupHurtAndContactBoxes`;改 7 个 `PlaceExxx`(E001-E006) + `PlaceChaoFeng` 用统一助手 + sprite 参数;替换原 Capsule/Circle 的 HurtBox/ContactDamageZone 创建;给缺失的 4 个敌人补 ContactDamageZone。
|
||||
- `Assets/_Game/Scripts/Editor/Character/CharacterWizardWindow.cs`:加 `_defaultSprite` 字段 + ObjectField;放置调用传 sprite。
|
||||
|
||||
## 验证
|
||||
|
||||
- 编译 0 错误。
|
||||
- 反射/直接调用各 `PlaceExxx` 生成到临时场景对象,校验:
|
||||
- 主体/HurtBox/ContactDamageZone 都是 `BoxCollider2D`。
|
||||
- HurtBox/ContactDamageZone `isTrigger=true`,主体 `isTrigger=false`。
|
||||
- 三者 `size` 一致(= sprite 包围盒 或 fallback)。
|
||||
- 三者底部对齐(`offset.y == size.y * 0.5`)。
|
||||
- 每个敌人 + Boss 均存在 ContactDamageZone(BodyContactDamage 组件在)。
|
||||
- E001:BodyContactDamage 初始 disabled 且已绑到 ContactChaseAbility。
|
||||
- 用一张测试 Sprite 走一次向导放置,确认碰撞体尺寸 = sprite 包围盒、SpriteRenderer 用了该 sprite。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不改现有 prefab(用户手动重生成)。
|
||||
- 不改敌人 AI / 移动 / 伤害逻辑。
|
||||
- 不引入 sprite 紧密网格/物理形状(用完整包围盒即可)。
|
||||
Reference in New Issue
Block a user