摄像机区域的架构改动

This commit is contained in:
2026-05-15 14:47:24 +08:00
parent 1b37297585
commit f264329751
3591 changed files with 1687228 additions and 446503 deletions

View File

@@ -0,0 +1,97 @@
# Zeling v2 · 测试方案总索引
> **文档版本**1.0
> **创建日期**2026-05-13
> **覆盖范围**`Assets/Scripts/` 全量代码
> **Unity 版本**2022.3 LTS
> **测试框架**Unity Test FrameworkNUnit
---
## 一、测试策略概述
本项目测试分为两大类:
### 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 → BakeScene 视图显示蓝绿网格 |
| 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 手动 |
---
## 三、运行单元测试
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 | 开放/修复 |

View File

@@ -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` |

View File

@@ -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.02.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 迁移 |

View File

@@ -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=1MutualExclusions 含 FreezeOnStack 刷新持续时间EffectType=Fire |
| `PoisonEffect` | MaxStacks=3MutualExclusions 为空OnStack 增加 StackCount超限截断EffectType=Poison |
| `StaggerEffect` | MaxStacks=1BlockedBy 含 StunEffectType=Stagger |
| `StatusEffect` 基类 | IsExpired 在 Duration 耗尽后为 trueUpdate 正确减少剩余时间 |
| `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 无报错

View File

@@ -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` | 值相等性structToString 输出 |
| `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` |

View File

@@ -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-02Addressables 资产加载](#mt-core-02addressables-资产加载)
4. [MT-CORE-03GlobalObjectPool 对象池](#mt-core-03globalobjectpool-对象池)
5. [MT-CORE-04GameManager 状态转换](#mt-core-04gamemanager-状态转换)
6. [MT-CORE-05SceneLoader 异步加载](#mt-core-05sceneloader-异步加载)
7. [MT-CORE-06DifficultyManager 难度缩放](#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-02Addressables 资产加载
**目的**:验证 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/ConfigSO 资产)** | `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-03GlobalObjectPool 对象池
**目的**:验证对象池 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-04GameManager 状态转换
**目的**:验证 `GameManager` 的状态机在游戏事件触发时正确转换。
**步骤:**
### 暂停/恢复流程
1. 进入 Play ModeGameplay 状态)
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-05SceneLoader 异步加载
**目的**:验证场景异步加载/卸载无卡顿,过渡动画正确播放。
> **🔧 资源准备(一键)**
>
> 需要至少一个 Room 场景注册到 Build Settings
>
> **步骤**
>
> 1. `File → New Scene → Empty` 创建空场景,保存为 `Assets/_Game/Scenes/Room_A.unity`
> 2. 添加地面 GameObjectLayer=GroundBoxCollider2D 宽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 场景 GameObjectGameManager、ServiceRegistrar 等)在切换后**不**重复 | 单例不重复创建 | ☐ |
---
## MT-CORE-06DifficultyManager 难度缩放
**目的**:验证难度切换后敌人属性缩放正确应用。
**步骤:**
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 可见)
- 存档后重载,难度设置与存档一致

View File

@@ -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** | 在场景中放置带完整组件的玩家 GameObjectPlayerController、PlayerStats、HurtBox 等) | `BaseGames → Scene → Place → Player` |
| **Place Ground Platform** | 放置地面平台BoxCollider2DLayer=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`DynamicFreezeRotationInterpolate
> - `CapsuleCollider2D`尺寸参考美术图层isTrigger=false
> - `AnimancerComponent`(从 Kybernetik.Animancer 包)
> - `PlayerController``BaseGames.Player`
> - `PlayerMovement``BaseGames.Player`
> - `StatusEffectManager``BaseGames.Combat.StatusEffects`
> 3. 创建子 GameObject `HurtBox`isTrigger=trueLayer=PlayerHurtBox并添加 `HurtBox` 组件
> 4. 创建子 GameObject `HitBox_Sword`isTrigger=trueLayer=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、超时重置、空中攻击、下劈/上劈。
### 步骤
**步骤 A3 段地面连击**
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 | ☐ |

View File

@@ -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-02HitBox 激活时序](#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-02HitBox 激活时序
**目的**:验证 `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 == 0ShieldIsBroken == 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 个 | ☐ |

View File

@@ -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-07Boss 战切换流程](#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 位置发出
- 命中玩家触发伤害
**步骤 CLOS视线检测**
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 RBgravityScale=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-07Boss 战切换流程
**目的**:验证 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 击败状态记录 | ☐ |

View File

@@ -0,0 +1,350 @@
# 手动测试 09 · 世界与场景系统
> **测试类型**Unity Editor 手动测试Play Mode
> **覆盖模块**`BaseGames.World`、`BaseGames.World.Map`
> **依赖组件**`RoomController`、`RoomTransition`、`SavePoint`、`DeathShade`、`WorldStateRegistry`
> **场景要求**:含多房间连接(至少 2 个 Room SceneAbilityGateMovingPlatformSavePoint
---
## 快速工具
| 工具 | 用途 | 菜单路径 |
|------|------|----------|
| **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 + CompositeCollider2DLayer=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 仍存在 | ☐ |

View File

@@ -0,0 +1,326 @@
# 手动测试 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输出验证结果含 SkillTreeSO、QuestDatabaseSO 等) | `BaseGames → Tools → Validate All ScriptableObjects` |
> **注意**PlayModeDebugOverlay 已移除。Geo 注入、技能点测试等请通过 Inspector 直接修改对应 Manager 字段,或临时编写 Editor 脚本触发。
> Tab 10 的场景摆放工具Add Quest Trigger、Add Shop NPC已不再提供请参照下方各节**手动步骤**手工创建对应对象。
**典型工作流**
1. 测试前:`BaseGames → Tools → Validate All ScriptableObjects` 一键确认 SO 存在若有缺失Console 给出路径提示。
2. `MT-PROG-01` 技能:进入 Play Mode通过 Inspector 直接修改 `SkillManager._skillPoints` 字段,在技能树 UI 解锁目标技能。
3. `MT-PROG-03` 任务:手动放置 `QuestGiver` NPC见下方步骤`QuestSO` 拖入 InspectorPlay 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. 前置数据检查
| 资产 | 路径(示例) | 必要性 | ✓ |
|------|------------|--------|---|
| SkillTreeSO | `Assets/Data/Skills/SkillTree.asset` | 技能测试必须 | ☐ |
| 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 — 创建尚未覆盖的数据资产(手动)**
>
> | 资产 | 创建方法 |
> |------|----------|
> | `SkillTreeSO` | Project 右键 → Create → BaseGames → Skills → Skill Tree保存到 `Assets/_Game/Data/Skills/` |
> | `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 前):
> - 找到 `SkillManager` GameObject → Inspector → `_skillTree` 字段拖入 `SkillTree.asset`
> - 找到 `ShopNPC` GameObjectTab 10 → 添加商店 NPC→ `_inventory` 字段拖入 `ShopInventory_Test.asset`
---
## MT-PROG-01技能解锁与使用
**目的**:验证 `SkillManager` 的技能解锁、技能使用、冷却管理、MP 消耗。
> **🔧 资源准备**
> 1. 确认 `SkillTree.asset` 已绑定到 `SkillManager._skillTree` 字段
> 2. 在 Inspector 中直接将 `SkillManager._skillPoints` 设为 5Play Mode 下修改字段值)快速获取技能点(跳过进程解锁流程)
> 3. 若 `SkillManager` 不在场景中,手动在空 GameObject 上挂载 `SkillManager` 组件并绑定 `SkillTree.asset`
### 步骤
**步骤 A技能解锁**
1. 打开技能树 UI默认 P 键)
2. 确认有足够的技能点(`SkillPoints > 0`
3. 点击未解锁的技能节点,确认解锁
**预期**
- 技能节点视觉变为已解锁状态
- `SkillPoints - skillCost`
- `SkillManager.IsUnlocked(skillId) == true`
- 技能快捷键绑定生效
**步骤 B技能使用主动技能**
1. 进入 Play Mode 战斗场景
2. 按已绑定技能快捷键(如 1/2/3 或 Q/E/R
**预期**
- 技能动画播放(`AnimancerComponent` 播放技能动画片段)
- MP 消耗(`CurrentMP -= mpCost`
- 技能效果触发(伤害、范围、特效)
- 进入冷却(`cooldown` 秒内无法再次使用)
**步骤 CMP 不足时**
1. 使 `CurrentMP < skill.mpCost`
2. 按技能键
**预期**技能无法释放UI 技能图标显示 MP 不足提示(灰色)。
**步骤 D技能冷却**
1. 使用技能后立即再次按技能键
**预期**冷却期内不触发UI 冷却进度条显示。
| 检查点 | 期望 | ✓ |
|--------|------|---|
| 解锁技能 | 技能节点标记已解锁SkillPoints 减少 | ☐ |
| 技能释放 | 动画播放效果触发MP 扣减 | ☐ |
| MP 不足 | 无法释放UI 提示 | ☐ |
| 冷却 | 冷却期无法再次使用,进度条显示 | ☐ |
---
## 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 数量更新
**步骤 CGeo 不足**
1. 选择价格超过当前 Geo 的物品
**预期**:购买失败,提示"Geo 不足"Geo 不变。
**步骤 D售出物品**
1. 在商店卖出背包中的物品
**预期**
- `CurrentGeo += item.sellPrice`
- 物品从背包移除
- HUD Geo 数量更新
| 检查点 | 期望 | ✓ |
|--------|------|---|
| 商店 UI 打开 | 物品列表正确显示 | ☐ |
| 购买成功 | Geo 减少,物品进背包 | ☐ |
| Geo 不足 | 购买失败Geo 不变 | ☐ |
| 售出 | Geo 增加,物品移除 | ☐ |
---
## MT-PROG-06能力解锁AbilityUnlock
**目的**验证特殊能力DoubleJump、WallSlide、Dash 等)的解锁与 `AbilityGate` 联动。
### 步骤
1. 确认某能力(如 DoubleJump当前**未解锁**
2. 找到对应的能力解锁点Boss 击败后掉落,或特定区域触发)
3. 触发解锁
**预期**
- `EVT_AbilityUnlocked(abilityId)` 触发
- `SkillManager.HasAbility(abilityId) == true`
- 存档中 `SaveData.Abilities` 包含该 abilityId
- 对应 `AbilityGate` 自动开启(若当前场景有联动门)
4. 测试新解锁的能力(如 DoubleJump跳跃后再次跳跃
**预期**:能力生效(二段跳可用)。
| 检查点 | 期望 | ✓ |
|--------|------|---|
| 解锁事件 | EVT_AbilityUnlocked 触发 | ☐ |
| HasAbility == true | SkillManager 返回 true | ☐ |
| AbilityGate 开启 | 对应能力门自动开启 | ☐ |
| 能力可用 | 新能力实际可使用 | ☐ |
| 持久化 | 存档中有该 abilityId | ☐ |

View File

@@ -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、AudioMixerFeel 的 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-01HUD 实时数据绑定](#mt-ui-01hud-实时数据绑定)
3. [MT-UI-02UI 面板管理PanelManager](#mt-ui-02ui-面板管理panelmanager)
4. [MT-UI-03AudioManager 混音器快照](#mt-ui-03audiomanager-混音器快照)
5. [MT-UI-04VFX 目录VFXCatalogSO](#mt-ui-04vfx-目录vfxcatalogso)
6. [MT-UI-05Feel 反馈链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-01HUD 实时数据绑定
**目的**:验证 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-02UI 面板管理PanelManager
**目的**:验证多个 UI 面板(技能树/装备/任务/暂停)的互斥打开和关闭逻辑。
### 步骤
1. 按技能树快捷键P打开技能树面板
**预期**:技能树面板打开,游戏暂停(`Time.timeScale = 0`)。
2. 按装备快捷键Tab在技能树面板开启的情况下
**预期**:技能树关闭,装备面板打开(面板互斥,不同时显示多个菜单面板)。
3. 按 ESC 键
**预期**:当前打开的任意面板关闭,游戏恢复(`Time.timeScale = 1`)。
4. 打开暂停菜单ESC 键)
**预期**:暂停菜单显示,`EVT_GamePaused` 事件触发,其他 UI 面板不显示。
| 检查点 | 期望 | ✓ |
|--------|------|---|
| 面板互斥 | 同时只有一个主面板开启 | ☐ |
| ESC 关闭面板 | ESC 关闭当前面板 | ☐ |
| 暂停时 timeScale=0 | 面板打开时游戏暂停 | ☐ |
| 关闭后 timeScale=1 | 面板关闭后游戏恢复 | ☐ |
---
## MT-UI-03AudioManager 混音器快照
**目的**:验证 `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 或静音正确切换
**步骤 CBoss 战快照**
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-04VFX 目录VFXCatalogSO
**目的**:验证 `VFXCatalogSO` 在各种触发条件下正确播放对应 VFX Prefab通过对象池
> **🔧 资源准备VFXCatalogSO 条目填写)**
>
> 1. 运行 `BaseGames → Tools → Validate All ScriptableObjects`Console 显示空引用条目
> 2. 找到 `VFXCatalog.asset`(自定义路径),在 Inspector 展开 `_entries`
> 3. 为每个条目填入对应的 VFX PrefabParticleSystem 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-05Feel 反馈链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 关闭,输入恢复 | ☐ |
| 事件触发 | 对话触发的事件正确执行 | ☐ |

View File

@@ -0,0 +1,472 @@
# 手动测试 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
组件: CinemachineBrain ← 实际渲染相机(随 Main Camera 放置)
组件: CinemachineImpulseSource ← 屏幕抖动信号源
├── VCamA
│ 组件: CinemachineCamera Follow = Player/CameraFollowTarget
│ 组件: CinemachineConfiner2D ← 由 CameraStateController 动态更新 BoundingShape2D
└── VCamB
组件: CinemachineCamera Follow = Player/CameraFollowTarget
组件: CinemachineConfiner2D ← 由 CameraStateController 动态更新 BoundingShape2D
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 优先级至 0ping-pongCinemachine Brain 自动触发混合过渡
- **有专有 VCam**:提升 `_dedicatedCamera` 优先级至 `_dedicatedPriority`(默认 20高于全局 VCamCinemachine 自动切换
---
## 2. Persistent 场景配置CameraStateController
### 2.1 组件放置
在 Persistent 场景的 `[Camera]` 下建立以下节点结构:
| GameObject | 挂载组件 | 说明 |
|-----------|---------|------|
| `[CameraController]` | `CameraStateController``CinemachineBrain``CinemachineImpulseSource` | ExecutionOrder = -100 |
| `VCamA` | `CinemachineCamera``CinemachineConfiner2D` | 全局虚拟相机 A拖入 `CameraStateController._vcamA` |
| `VCamB` | `CinemachineCamera``CinemachineConfiner2D` | 全局虚拟相机 B拖入 `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) | ● 已绑定 |
| `_impulseSource` (CinemachineImpulseSource) | ◌ 可选;用于屏幕抖动 |
| `_defaultBlendProfile` (CameraBlendProfileSO) | ◌ 可选;未设置则无混合过渡 |
---
## 3. 关卡场景配置CameraArea + CameraTriggerZone
### 3.1 添加 CameraArea
**方式 A使用快速放置工具**(推荐)
1. 菜单 `BaseGames → Scene → Place → Camera Area`
2. 工具自动创建以下节点结构:
```
CameraArea
├── CameraArea组件_confinerCollider 已绑定)
└── PolygonCollider2D默认矩形 24×12isTrigger = 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"
└── BoxCollider2DisTrigger = 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 下的 `CameraFollowTarget` 子节点**,而非 Player 根节点本身。
使用 `Place Player` 工具放置 Player 时,`CameraFollowTarget` 子节点会被自动创建(`localPosition = 0`)。
**方法**
- **自动**:打开 **Camera Area Setup** 窗口 → CameraStateController 区域 → 点击 **为全局 VCam 赋值 Follow 目标**(场景中必须已有 tag=Player 对象)
- **手动**:分别选中 `VCamA` / `VCamB` → CinemachineCamera 组件 → `Follow` 字段拖入 `Player/CameraFollowTarget` Transform
---
### 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
**创建路径**`Assets → Create → BaseGames → Camera → CameraConfig`
| 字段 | 说明 | 典型值 |
|------|------|--------|
| `FollowDamping` | 跟随阻尼(越大越迟钝) | `0.15` |
| `LookAheadTime` | 朝向预见时间(秒) | `0.3` |
| `DeadZoneSize` | 死区尺寸(玩家在此范围内移动相机不动) | `(1, 0.5)` |
| `SoftZoneSize` | 软区尺寸(慢速追赶) | `(2.5, 2)` |
| `LookDownOffset` | 俯视偏移(负值向下) | `-1.5` |
| `LookUpOffset` | 仰视偏移(正值向上) | `1.5` |
| `DefaultImpulseStrength` | 默认震屏强度 | `0.3` |
> `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`(必填)、`_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-03CinemachineConfiner2D 边界限位
**目的**:验证 `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.20.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` 未绑定 | 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 已满足) |

View File

@@ -317,9 +317,9 @@
| **Event Bus Monitor** | `BaseGames → Tools → Event Bus Monitor` | `Ctrl+Shift+E` | Play 模式下实时查看所有 SO 事件触发记录 |
| **Create Event Channel Assets** | `BaseGames → Tools → Create Event Channel Assets` | — | 一键生成全局事件频道资产 |
| **Reimport Event Channel Assets** | `BaseGames → Tools → Reimport Event Channel Assets` | — | 批量重导入 `Assets/Data/Events` 下的事件资产 |
| **Validate Address Keys** | `BaseGames → Tools → Validate Address Keys` | — | 手动校验 AddressKeys 常量与 Addressable 分组一致性 |
| **Validate Address Keys** | `BaseGames → Addressables → Validate Address Keys` | — | 手动校验 AddressKeys 常量与 Addressable 分组一致性 |
| **Scaffold Persistent Scene** | `BaseGames → Tools → Scaffold Persistent Scene` | — | 一键生成 Persistent 场景基础层级与核心组件骨架 |
| **Scaffold Test Room** | `BaseGames → Tools → Scaffold Test Room` | — | 一键生成 TestRoom 基础层级、Player/Enemy/Camera/SavePoint 骨架 |
| **Place Player / Enemy / Platform…** | `BaseGames → Scene → Place → …` | — | 单独放置玩家、敌人、地面、相机、存档点等场景对象(替代 Scaffold Test Room |
| **Apply Script Execution Order Preset** | `BaseGames → Tools → Apply Script Execution Order Preset` | — | 一键写入推荐的脚本执行顺序 |
| **Validate Script Execution Order Preset** | `BaseGames → Tools → Validate Script Execution Order Preset` | — | 校验当前执行顺序是否符合推荐值 |
| **Animancer Window** | `Window → Animation → Animancer` | — | 查看当前播放的动画状态和混合树 |
@@ -393,7 +393,7 @@ ISceneService: SceneService
#### 步骤 1手动触发验证
1. 菜单 `Tools → Validate AddressKeys`
1. 菜单 `BaseGames → Addressables → Validate Address Keys`
2. 查看 Console
**预期结果**