1351 lines
55 KiB
Markdown
1351 lines
55 KiB
Markdown
# Phase 1 · 垂直切片 MVP
|
||
|
||
> **周期**:3–4 周(Week 1-4)
|
||
> **前置条件**:Phase 0 全部完成标准通过
|
||
> **核心目标**:一个可玩的测试房间;验证所有第三方库(Animancer / PathBerserker2d / BD / Cinemachine / Feel / Addressables)协作无冲突
|
||
> **产出物**:玩家能移动/跳跃/攻击 → 敌人寻路并反击 → 死亡 → 存档复活,VFX/SFX 全流程响应
|
||
|
||
> **进度更新(2026-05-07)**:Week 1–3 代码层完成,Week 4 未开始。
|
||
>
|
||
> **Week 1 完成项**:InputReaderSO、InputBuffer、PlayerMovement、PlayerStats、PlayerController(Idle/Run/Jump/Fall)、CameraStateController、RoomVisibleArea、CameraTriggerZone、CameraConfigSO、CameraBlendProfileSO。
|
||
>
|
||
> **Week 2 完成项**:DamageInfo/DamageType/DamageFlags/DamageTags/DamageCategory/HitFxType/BreakLevel、HitBox、HurtBox、DamageSourceSO、WeaponManager、PlayerCombat、AttackState、EnemyBase、EnemyStats、EnemyNavAgent、BD 基础行为树任务(BD_Patrol / BD_MoveToPlayer / BD_Attack / BD_IsPlayerInRange)。
|
||
>
|
||
> **Week 3 完成项**:SaveManager(完整 SaveAsync/LoadAsync/Checksum/SlotSummary)、ISaveable、SaveData、LocalFileStorage、SaveMigrator、IInteractable、SavePoint(含 ISaveable 集成)、EmergencySaveService、GameManager(DeathFlow + RequestTransition)、DeathRespawnService、UIManager、HUDController(HP/灵力/Geo/弹簧/形态/交互提示)、DeathScreenController。PlayerStats.FullHeal() 已补全;BaseGames.World.asmdef 已加 Player.States/Core.Events/Core.Save 引用;BaseGames.UI.asmdef 已加 BaseGames.Core 引用。
|
||
|
||
---
|
||
|
||
## 目录
|
||
|
||
1. [实施顺序总览](#1-实施顺序总览)
|
||
2. [Week 1:输入 + 玩家骨架 + 相机](#2-week-1输入--玩家骨架--相机)
|
||
3. [Week 2:战斗基础 + 敌人骨架](#3-week-2战斗基础--敌人骨架)
|
||
4. [Week 3:存档完善 + 死亡复活流 + UI](#4-week-3存档完善--死亡复活流--ui)
|
||
5. [Week 4:VFX/音效 + 集成验证](#5-week-4vfx音效--集成验证)
|
||
6. [第三方集成检查点](#6-第三方集成检查点)
|
||
7. [完成标准检查清单](#7-完成标准检查清单)
|
||
|
||
---
|
||
|
||
## 1. 实施顺序总览
|
||
|
||
```
|
||
Week 1:
|
||
InputReaderSO + InputBuffer
|
||
↓
|
||
PlayerMovement + PlayerStats(Rigidbody2D 物理验证)
|
||
↓
|
||
PlayerController(仅 Idle/Run/Jump/Fall 状态)
|
||
↓
|
||
CameraStateController + CinemachineConfiner2D + PixelPerfect 验证
|
||
|
||
Week 2:
|
||
DamageInfo + DamageType 枚举 + DamageCategory/Flags/Tags
|
||
↓
|
||
HitBox + HurtBox + IDamageable
|
||
↓
|
||
AttackState(Animancer 状态机 首次集成)
|
||
↓
|
||
EnemyBase + EnemyStats + EnemyNavAgent(PathBerserker2d 首次集成)
|
||
↓
|
||
Behavior Designer 基础 BT(Patrol→Chase→Attack)
|
||
|
||
Week 3:
|
||
SaveManager 完整实现 + SavePoint + ISaveable + EmergencySaveService
|
||
↓
|
||
GameManager 死亡/复活流完整 Coroutine
|
||
↓
|
||
UIManager + HUDController(HP 条、灵力条)+ DeathScreenController(⚠️ 非 HUDPanel/DeathPanel,架构 10 §3/§6)
|
||
|
||
Week 4:
|
||
VFXPool + HitFXSpawner + HurtFlashController + VFXCatalogSO
|
||
↓
|
||
AudioManager(Unity AudioMixer + 双 AudioSource 交叉淡入淡出)
|
||
↓
|
||
IFeedbackPlayer + PlayerFeedback / EnemyFeedback(Feel MMF_Player)
|
||
↓
|
||
集成验证(Phase 1 全链路)
|
||
```
|
||
|
||
---
|
||
|
||
## 2. Week 1:输入 + 玩家骨架 + 相机
|
||
|
||
**参考文档**:`04_InputModule.md`、`05_PlayerModule.md §1-4`、`17_CameraModule.md`
|
||
|
||
### 2.1 InputReaderSO
|
||
|
||
**文件**:`Assets/Scripts/Input/InputReaderSO.cs`
|
||
|
||
创建要点:
|
||
- `IInputActionCollection2` 接口(通过 Input Actions 代码生成类自动满足)
|
||
- `OnEnable` / `OnDisable` 中 Enable / Disable ActionMap
|
||
- 所有 Action 委托在 `OnEnable` 中绑定到内部 `_actions` 上,`OnDisable` 取消绑定
|
||
- 区分两个 ActionMap:`Gameplay`、`UI`,通过专用方法切换(⚠️ 方法名以架构 `04_InputModule §2` 为准,不存在 `SetActionMap(ActionMapType)`;对话中由 DialogueManager 调用 `EnableUIInput()`,无独立 Dialogue Map)
|
||
|
||
```csharp
|
||
// 最小可验证 API(Phase 1 需要)
|
||
public event Action<Vector2> MoveEvent;
|
||
public event Action JumpStartedEvent;
|
||
public event Action JumpCancelledEvent;
|
||
public event Action AttackEvent;
|
||
public event Action PauseEvent;
|
||
|
||
public void EnableGameplayInput(); // 启用 Gameplay Map,禁用 UI Map
|
||
public void EnableUIInput(); // 启用 UI Map,禁用 Gameplay Map
|
||
public void DisableAllInput(); // 全部禁用(过场/加载中)
|
||
```
|
||
|
||
**资产**:`Assets/Data/Player/PLY_InputReader.asset` — 在 Inspector 中 Create Asset,供全局使用。
|
||
|
||
**PlayerInputActions.inputactions**:在 `Assets/Settings/Input/` 创建 Input Actions 资产,配置:
|
||
|
||
| ActionMap | Action | Binding |
|
||
|-----------|--------|---------|
|
||
| Gameplay | Move | WASD / 左摇杆 |
|
||
| Gameplay | Jump | Space / 南键 |
|
||
| Gameplay | Attack | Z / 西键 |
|
||
| Gameplay | Pause | Escape / Start |
|
||
| UI | Navigate | 方向键 / 左摇杆 |
|
||
| UI | Submit | Return / 南键 |
|
||
| UI | Cancel | Escape / 东键 |
|
||
|
||
### 2.2 InputBuffer
|
||
|
||
**文件**:`Assets/Scripts/Input/InputBuffer.cs`
|
||
|
||
```csharp
|
||
// 挂在 Player Prefab 上,与 InputReaderSO 配合
|
||
public class InputBuffer : MonoBehaviour
|
||
{
|
||
[SerializeField] private InputReaderSO _inputReader; // ⚠️ 非 _input(架构 04_InputModule §4)
|
||
[SerializeField] private float _jumpBufferDuration = 0.15f; // 跳跃宻d容窗口(架构 04_InputModule §4)
|
||
[SerializeField] private float _attackBufferDuration = 0.12f; // 攻击接续连段
|
||
[SerializeField] private float _dashBufferDuration = 0.10f; // 冲刺小量容错
|
||
|
||
private float _jumpBuffer;
|
||
private float _attackBuffer;
|
||
private float _dashBuffer;
|
||
|
||
// 查询接口(消费型,查到即清除)
|
||
public bool ConsumeJump();
|
||
public bool ConsumeAttack();
|
||
public bool ConsumeDash();
|
||
|
||
// 内部:OnEnable 订阅 InputReader 事件,设定定时清除
|
||
}
|
||
```
|
||
|
||
### 2.3 PlayerMovement
|
||
|
||
**文件**:`Assets/Scripts/Player/PlayerMovement.cs`
|
||
|
||
Phase 1 实现范围(不含 Dash/WallSlide/Swim):
|
||
|
||
| 方法 | Phase 1 实现 |
|
||
|------|--------------|
|
||
| `Move(float speedX)` | ✅ 完整 |
|
||
| `Jump(bool isVariable)` | ✅ 完整(CoyoteTime + VariableJump)|
|
||
| `CutJump()` | ✅ 完整 |
|
||
| `ApplyKnockback` | ✅ 完整 |
|
||
| `CheckGrounded()` | ✅ `Physics2D.OverlapBox` |
|
||
| `CheckWalls()` | ✅ Raycast |
|
||
| `Dash(Vector2 dir, float speed)` | ⏳ Phase 2 |
|
||
| `DropThroughPlatform()` | ⏳ Phase 2 |
|
||
|
||
### 2.4 PlayerStats
|
||
|
||
**文件**:`Assets/Scripts/Player/PlayerStats.cs`
|
||
|
||
Phase 1 仅实现:
|
||
|
||
```csharp
|
||
public int MaxHP { get; private set; }
|
||
public int CurrentHP { get; private set; }
|
||
public int MaxSpringCharges { get; private set; } // ⚠️ 非 MaxSpring(架构 05 §4)
|
||
public int CurrentSpringCharges { get; private set; } // ⚠️ 非 CurrentSpring
|
||
|
||
public void TakeDamage(int amount); // 减 HP → 发布 EVT_HPChanged;=0 时发布 EVT_PlayerDied
|
||
public void HealHP(int amount); // ⚠️ 非 Heal;回血(上限 MaxHP,架构 05 §4)
|
||
// ⚠️ 无 Initialize(PlayerStatsSO) 方法;PlayerStatsSO _config 通过 Inspector [SerializeField] 设置
|
||
// 存档恢复使用 LoadSaveData(PlayerSaveData data)(架构 05 §4)
|
||
```
|
||
|
||
**资产**:`Assets/Data/Player/PLY_Stats_Normal.asset`(PlayerStatsSO)
|
||
|
||
### 2.5 PlayerController + 4 个基础状态
|
||
|
||
**文件**:
|
||
- `Assets/Scripts/Player/PlayerController.cs`(主控制器,Phase 1 骨架)
|
||
- `Assets/Scripts/Player/States/PlayerStateBase.cs`(抽象基类)
|
||
- `Assets/Scripts/Player/States/IdleState.cs`
|
||
- `Assets/Scripts/Player/States/RunState.cs`
|
||
- `Assets/Scripts/Player/States/JumpState.cs`
|
||
- `Assets/Scripts/Player/States/FallState.cs`
|
||
|
||
Animancer 集成要点:
|
||
- `AnimancerComponent` 挂在 Player Prefab 根节点
|
||
- 每个 State 的 `AnimationClip` 定义在 `PlayerAnimationConfigSO` 里,Phase 1 直接引用(⚠️ 字段类型为 AnimationClip,非 ClipTransition,架构 05_PlayerModule §17)
|
||
- 进入 State 时调用 `_player.Animancer.Play(_config.Idle)`(⚠️ 字段名为 Idle,非 IdleClip,架构 05_PlayerModule §17)
|
||
- **验证点**:切换状态时无 `MissingReferenceException`,动画无卡顿
|
||
|
||
```csharp
|
||
// PlayerController.Awake 最小初始化(Phase 1)
|
||
void Awake()
|
||
{
|
||
_movement = GetComponent<PlayerMovement>();
|
||
_stats = GetComponent<PlayerStats>();
|
||
_inputBuffer = GetComponent<InputBuffer>();
|
||
_animancer = GetComponent<AnimancerComponent>();
|
||
|
||
_idleState = new IdleState(this);
|
||
_runState = new RunState(this);
|
||
_jumpState = new JumpState(this);
|
||
_fallState = new FallState(this);
|
||
_attackState = new AttackState(this); // Week 2
|
||
|
||
TryTransitionState(_idleState);
|
||
}
|
||
```
|
||
|
||
### 2.6 CameraStateController
|
||
|
||
**参考文档**:`17_CameraModule.md §2–§10`
|
||
|
||
**文件**:`Assets/Scripts/Camera/CameraStateController.cs`
|
||
|
||
Phase 1 实现范围:
|
||
|
||
```csharp
|
||
// 全局双机 A/B 切换(架构 17_CameraModule §3)
|
||
namespace BaseGames.Camera
|
||
{
|
||
public class CameraStateController : MonoBehaviour
|
||
{
|
||
[SerializeField] CinemachineCamera _vcamA;
|
||
[SerializeField] CinemachineCamera _vcamB;
|
||
[SerializeField] CinemachineBrain _brain;
|
||
[SerializeField] CameraConfigSO _defaultConfig;
|
||
|
||
CinemachineCamera _activeCam; // runtime
|
||
CinemachineCamera _inactiveCam;
|
||
RoomCamera _currentRoomCam;
|
||
|
||
public static CameraStateController Instance { get; private set; }
|
||
|
||
void Awake()
|
||
{
|
||
Instance = this;
|
||
_activeCam = _vcamA;
|
||
_inactiveCam = _vcamB;
|
||
}
|
||
|
||
// 由 CameraTriggerZone.OnTriggerEnter2D 调用
|
||
public void SwitchRoom(RoomCameraData data);
|
||
// 由 RoomCamera.OnEnable 调用
|
||
public void RegisterRoomCamera(RoomCamera rc);
|
||
// 由 RoomCamera.OnDisable 调用
|
||
public void UnregisterRoomCamera(RoomCamera rc);
|
||
// 由 IFeedbackPlayer 调用(架构 17_CameraModule §9)
|
||
public void TriggerImpulse(CameraShakePreset preset);
|
||
}
|
||
|
||
public struct RoomCameraData
|
||
{
|
||
public Collider2D ConfinerCollider;
|
||
public Vector3 CameraOffset;
|
||
public CameraBlendProfileSO BlendProfile;
|
||
}
|
||
}
|
||
|
||
// Persistent 场景 CameraRig Prefab 组装
|
||
// ├── Main Camera (CinemachineBrain + PixelPerfectCamera + CinemachinePixelPerfect)
|
||
// ├── VCam_Global_A(CinemachineCamera + CinemachinePositionComposer + CinemachineConfiner2D
|
||
// │ + CinemachineImpulseListener + CinemachinePixelPerfect,Priority: 10,active)
|
||
// ├── VCam_Global_B(同上,Priority: 9,standby)
|
||
// └── CameraStateController.cs(挂在根节点)
|
||
```
|
||
|
||
#### 2.6.1 RoomVisibleArea
|
||
|
||
**文件**:`Assets/Scripts/Camera/RoomVisibleArea.cs`(架构 17_CameraModule §4)
|
||
|
||
```csharp
|
||
[ExecuteAlways]
|
||
[RequireComponent(typeof(PolygonCollider2D))]
|
||
public class RoomVisibleArea : MonoBehaviour
|
||
{
|
||
[SerializeField] private Vector2 _roomSize = new(20f, 11.25f);
|
||
[SerializeField] private Vector2 _viewportSize = new(20f, 11.25f);
|
||
|
||
/// <summary>roomSize ≈ viewportSize 时为固定镜头(无滚动)。</summary>
|
||
public bool IsFixedCamera { get; }
|
||
|
||
/// <summary>返回 PolygonCollider2D,供 CameraTriggerZone 传入 CinemachineConfiner2D。</summary>
|
||
public Collider2D Collider { get; private set; }
|
||
|
||
private void Awake() => Collider = GetComponent<PolygonCollider2D>();
|
||
// 每次 Inspector 变更重算 Confiner = Max(0, roomSize - viewportSize)
|
||
private void OnValidate() => RebuildCollider();
|
||
private void RebuildCollider() { /* 重建 PolygonCollider2D 形状 */ }
|
||
}
|
||
// + RoomVisibleAreaEditor(自定义 Inspector 拖拽句柄,[ExecuteAlways])
|
||
```
|
||
|
||
#### 2.6.2 CameraTriggerZone
|
||
|
||
**文件**:`Assets/Scripts/Camera/CameraTriggerZone.cs`(架构 17_CameraModule §5)
|
||
|
||
```csharp
|
||
[ExecuteAlways]
|
||
[RequireComponent(typeof(BoxCollider2D))]
|
||
public class CameraTriggerZone : MonoBehaviour
|
||
{
|
||
[SerializeField] private Vector2 _center = Vector2.zero;
|
||
[SerializeField] private Vector2 _size = new(0.5f, 4f);
|
||
[SerializeField] private RoomVisibleArea _targetRoom; // 目标房间的 RoomVisibleArea
|
||
[SerializeField] private Vector3 _cameraOffset;
|
||
[SerializeField] private CameraBlendProfileSO _blendOverride; // null = 使用默认
|
||
[SerializeField] private bool _triggerOnce = false;
|
||
|
||
private bool _triggered;
|
||
|
||
private void OnTriggerEnter2D(Collider2D other)
|
||
{
|
||
if (!other.CompareTag("Player")) return;
|
||
if (_triggerOnce && _triggered) return;
|
||
_triggered = true;
|
||
CameraStateController.Instance.SwitchRoom(new RoomCameraData
|
||
{
|
||
ConfinerCollider = _targetRoom.Collider,
|
||
CameraOffset = _cameraOffset,
|
||
BlendProfile = _blendOverride,
|
||
});
|
||
}
|
||
}
|
||
// + CameraTriggerZoneEditor(场景拖拽句柄,_center/_size)
|
||
```
|
||
|
||
#### 2.6.3 CameraConfigSO
|
||
|
||
**文件**:`Assets/Scripts/Camera/CameraConfigSO.cs`(架构 17_CameraModule §7)
|
||
|
||
```csharp
|
||
[CreateAssetMenu(menuName = "Camera/CameraConfig")]
|
||
public class CameraConfigSO : ScriptableObject
|
||
{
|
||
[Header("跟随")]
|
||
public Vector3 DefaultFollowOffset = new(0f, 1f, -10f);
|
||
public float HorizontalDamping = 0.5f;
|
||
public float VerticalDamping = 0.3f;
|
||
|
||
[Header("默认混合")]
|
||
public float DefaultBlendDuration = 0.5f;
|
||
public CinemachineBlendDefinition.Style DefaultBlendStyle
|
||
= CinemachineBlendDefinition.Style.EaseInOut;
|
||
|
||
[Header("Pixel Perfect")]
|
||
public int ReferenceResolutionX = 320;
|
||
public int ReferenceResolutionY = 180;
|
||
public int PixelsPerUnit = 16;
|
||
public bool CropFrameX = false;
|
||
public bool CropFrameY = false;
|
||
|
||
/// <summary>相机视口世界单位尺寸(320×180 / 16PPU = 20×11.25)。</summary>
|
||
public Vector2 ViewportSizeInWorldUnits
|
||
=> new Vector2((float)ReferenceResolutionX / PixelsPerUnit,
|
||
(float)ReferenceResolutionY / PixelsPerUnit);
|
||
}
|
||
// 资产路径:Assets/ScriptableObjects/Camera/Camera_Config.asset
|
||
```
|
||
|
||
#### 2.6.4 CameraBlendProfileSO
|
||
|
||
**文件**:`Assets/Scripts/Camera/CameraBlendProfileSO.cs`(架构 17_CameraModule §8)
|
||
|
||
```csharp
|
||
[CreateAssetMenu(menuName = "Camera/BlendProfile")]
|
||
public class CameraBlendProfileSO : ScriptableObject
|
||
{
|
||
public float Duration = 0.5f;
|
||
public CinemachineBlendDefinition.Style Style
|
||
= CinemachineBlendDefinition.Style.EaseInOut;
|
||
|
||
public CinemachineBlendDefinition ToBlendDefinition()
|
||
=> new CinemachineBlendDefinition(Style, Duration);
|
||
}
|
||
// 内置预设(Assets/ScriptableObjects/Camera/Blends/):
|
||
// Blend_Default.asset(0.5s)、Blend_Instant.asset(0s)、
|
||
// Blend_Slow.asset(1.0s)、Blend_BossExit.asset(0.8s)
|
||
```
|
||
|
||
#### 2.6.5 RoomCamera(可选)
|
||
|
||
**文件**:`Assets/Scripts/Camera/RoomCamera.cs`(架构 17_CameraModule §6,优先级 15 的房间专属机位)
|
||
|
||
```csharp
|
||
namespace BaseGames.Camera
|
||
{
|
||
public class RoomCamera : MonoBehaviour
|
||
{
|
||
[SerializeField] CinemachineCamera _vcam;
|
||
[SerializeField] RoomVisibleArea _visibleArea;
|
||
[SerializeField] CameraBlendProfileSO _enterBlend; // 可留空
|
||
|
||
void OnEnable()
|
||
{
|
||
_vcam.Priority = 15;
|
||
CameraStateController.Instance.RegisterRoomCamera(this);
|
||
}
|
||
|
||
void OnDisable()
|
||
{
|
||
_vcam.Priority = 0;
|
||
CameraStateController.Instance.UnregisterRoomCamera(this);
|
||
}
|
||
|
||
public CinemachineCamera Vcam => _vcam;
|
||
public RoomVisibleArea VisibleArea => _visibleArea;
|
||
public CameraBlendProfileSO EnterBlend => _enterBlend;
|
||
}
|
||
}
|
||
```
|
||
|
||
**Pixel Perfect 验证**:Play Mode 下检查 `PixelPerfectCamera.upscaleRT = true`,角色移动时像素网格对齐无抖动。
|
||
|
||
---
|
||
|
||
## 3. Week 2:战斗基础 + 敌人骨架
|
||
|
||
**参考文档**:`06_CombatModule.md`、`07_EnemyModule.md`
|
||
|
||
### 3.1 战斗数据结构
|
||
|
||
按顺序创建(后者依赖前者):
|
||
|
||
```
|
||
DamageType.cs ← 枚举(Normal/Fire/Poison/Ice/Lightning/Void/True)
|
||
DamageCategory.cs ← 枚举(NormalAttack/SoulSkill/...)
|
||
DamageFlags.cs ← [Flags] 枚举(Unblockable/CanBeParried/...)
|
||
DamageTags.cs ← [Flags] 枚举(MeleeHit/RangedHit/SkillHit/ElementFire/ElementPoison/ElementVoid/AfterParry/ChargedAttack/SkyFormOnly/EarthFormOnly/DeathFormOnly/BreakLight/BreakMedium/BreakHeavy/BreakBreaker,架构 06_CombatModule §2)
|
||
HitFxType.cs ← 枚举(Spark/Slash/Blood/Magic/Heavy/Crit/Void/Heal/Parry/Fire/Ice,架构 06_CombatModule §2)
|
||
BreakLevel.cs ← 枚举(None=0/Light=1/Medium=2/Heavy=3/Breaker=4,架构 06_CombatModule §2)
|
||
DamageInfo.cs ← struct + Builder(⚠️ 非 readonly struct;Builder 需就地赋値字段,架构 06_CombatModule §1)
|
||
DamageSourceSO.cs ← [CreateAssetMenu] SO 包装器
|
||
IDamageable.cs ← 接口(bool IsInvincible / int Defense / void TakeDamage(DamageInfo info),三成员,架构 06_CombatModule §5)
|
||
```
|
||
|
||
### 3.2 HitBox
|
||
|
||
**文件**:`Assets/Scripts/Combat/HitBox.cs`
|
||
|
||
```csharp
|
||
// 挂在武器 / 攻击判定子节点上(Collider2D 设 IsTrigger=true,Layer=PlayerHitBox 或 EnemyHitBox)
|
||
// ⚠️ 完整实现以架构 06_CombatModule §4 为准
|
||
[RequireComponent(typeof(Collider2D))]
|
||
public class HitBox : MonoBehaviour
|
||
{
|
||
[SerializeField] private DamageSourceSO _defaultSource; // Inspector 默认值
|
||
[SerializeField] private float _hitCooldown = 0.1f; // 同目标多帧冷却
|
||
|
||
// 运行时注入(AttackState / Projectile 覆盖默认 SO)
|
||
private DamageSourceSO _currentSource;
|
||
private Transform _attackerTransform;
|
||
private bool _isActive;
|
||
|
||
// 命中确认委托(PlayerCombat / EnemyCombat 订阅)
|
||
public System.Action<DamageInfo> OnHitConfirmed;
|
||
|
||
// ⚠️ 参数 source/attacker 均可选(架构 06_CombatModule §4);不存在 Activate(float duration)
|
||
public void Activate(DamageSourceSO source = null, Transform attacker = null)
|
||
{
|
||
_currentSource = source ?? _defaultSource;
|
||
_attackerTransform = attacker ?? transform;
|
||
_isActive = true;
|
||
}
|
||
public void Deactivate() => _isActive = false;
|
||
|
||
private void OnTriggerEnter2D(Collider2D other)
|
||
{
|
||
if (!_isActive) return;
|
||
if (!CheckCooldown(other)) return;
|
||
|
||
var knockDir = ((Vector2)other.bounds.center - (Vector2)_attackerTransform.position).normalized;
|
||
var info = _currentSource.CreateBuilder()
|
||
.SetKnockback(knockDir, _currentSource.KnockbackForce)
|
||
.SetSourcePos(_attackerTransform.position)
|
||
.SetLayer(_attackerTransform.gameObject.layer)
|
||
.Build();
|
||
|
||
// ① 命中 HurtBox(敌人/玩家受击)
|
||
var hurtBox = other.GetComponent<HurtBox>();
|
||
if (hurtBox != null)
|
||
{
|
||
hurtBox.ReceiveDamage(info); // ⚠️ 必须用 ReceiveDamage(见架构 06_CombatModule §5)
|
||
OnHitConfirmed?.Invoke(info);
|
||
return;
|
||
}
|
||
|
||
// ② 命中 IBreakable(机关/障碍物)
|
||
var breakable = other.GetComponent<IBreakable>();
|
||
breakable?.TryInteract(info);
|
||
}
|
||
|
||
private Dictionary<Collider2D, float> _hitCooldownTimers = new();
|
||
private bool CheckCooldown(Collider2D other)
|
||
{
|
||
float now = Time.time;
|
||
if (_hitCooldownTimers.TryGetValue(other, out float last) && now - last < _hitCooldown)
|
||
return false;
|
||
_hitCooldownTimers[other] = now;
|
||
return true;
|
||
}
|
||
}
|
||
```
|
||
|
||
**Layer 设置**(Physics 2D Layer Collision Matrix):
|
||
- `PlayerHitBox` 与 `EnemyHurtBox` 碰撞
|
||
- `EnemyHitBox` 与 `PlayerHurtBox` 碰撞
|
||
- 其他组合全部关闭
|
||
|
||
### 3.3 HurtBox
|
||
|
||
**文件**:`Assets/Scripts/Combat/HurtBox.cs`
|
||
|
||
```csharp
|
||
[RequireComponent(typeof(Collider2D))]
|
||
public class HurtBox : MonoBehaviour
|
||
{
|
||
private IDamageable _owner; // 由 Awake 的 GetComponentInParent 注入
|
||
private IShieldable _shieldable; // 由 PlayerController.Awake() 注入
|
||
private bool _isHurtBoxInvincible; // 动画事件 EnableIFrame/DisableIFrame 控制
|
||
private bool _isActive = true; // HurtBox 整体开关(⚠️ 架构 06_CombatModule §5)
|
||
|
||
// 弹反系统(仅玩家侧注入;Phase 2 由 PlayerController.Awake() 调用 SetParrySystem)
|
||
private ParrySystem _parrySystem; // null = 无弹反(Phase 1 默认 null,Phase 2 注入)
|
||
|
||
// 霸体来源(EnemyPoiseComponent / PlayerController 实现;Phase 2 注入)
|
||
private IPoiseSource _poiseSource; // null = 无霸体检查(Phase 1 默认 null,Phase 2 注入)
|
||
|
||
[SerializeField] private DamageInfoEventChannelSO _onDamageDealt; // EVT_DamageDealt(AnalyticsManager)
|
||
[SerializeField] private HitConfirmedEventChannelSO _onHitConfirmed; // EVT_HitConfirmed(VFX/Audio/Feedback,架构 06_CombatModule §5)
|
||
|
||
public void SetShieldable(IShieldable shieldable) => _shieldable = shieldable; // ⚠️ 参数为 IShieldable 接口(非 ShieldComponent 具体类型)
|
||
public void SetInvincible(bool value) => _isHurtBoxInvincible = value; // 由 PlayerAnimationEvents 的 EnableIFrame/DisableIFrame 事件调用
|
||
public void SetParrySystem(ParrySystem ps) => _parrySystem = ps; // 由 PlayerController.Awake() 注入(Phase 2)
|
||
public void SetPoiseSource(IPoiseSource src) => _poiseSource = src; // 由 EnemyBase.Awake() 注入(Phase 2)
|
||
public void SetActive(bool value) => _isActive = value; // 整体开关(架构 06_CombatModule §5)
|
||
|
||
// ⚠️ 方法名必须与架构 06_CombatModule §5 完全一致;全 8 步流水线
|
||
public void ReceiveDamage(DamageInfo info)
|
||
{
|
||
if (!_isActive) return; // HurtBox 被禁用时忽略
|
||
|
||
// 1. 无敌帧检查(架构:_owner.IsInvincible || _isHurtBoxInvincible)
|
||
if ((_owner.IsInvincible || _isHurtBoxInvincible) && !info.Flags.HasFlag(DamageFlags.IgnoreIFrame)) return;
|
||
|
||
// 2. 弹反检查(须在伤害计算前;仅玩家侧 HurtBox 注入了 _parrySystem;Phase 1 skip)
|
||
if (_parrySystem != null && info.Flags.HasFlag(DamageFlags.CanBeParried))
|
||
if (_parrySystem.TryParryDamage(info)) return;
|
||
|
||
// 3. 霸体检查(BreakLevel vs PoiseLevel;Phase 1 _poiseSource == null 跳过)
|
||
if (!info.Flags.HasFlag(DamageFlags.ForceBreak) && _poiseSource != null)
|
||
{
|
||
PoiseLevel curPoise = _poiseSource.GetCurrentPoiseLevel();
|
||
if (curPoise == PoiseLevel.Unbreakable) return;
|
||
if ((int)info.Break < (int)curPoise)
|
||
{
|
||
_onHitConfirmed.Raise(new HitInfo { DamageInfo = info, HitPoint = transform.position });
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 4. 护盾层拦截(玩家专属,在防御减免前)
|
||
if (_shieldable != null && _shieldable.HasShield)
|
||
{
|
||
int passThrough = _shieldable.AbsorbDamage(info.Amount); // ⚠️ 返回穿透量(int),非 ref 参数(架构 20_ShieldModule §5)
|
||
if (passThrough <= 0) return; // 全部被护盾吸收
|
||
info.Amount = passThrough; // 穿透量继续走防御减免流程
|
||
}
|
||
|
||
// 5. 计算 FinalDamage(防御减免,最低 1)
|
||
int finalDamage = Mathf.Max(1, info.Amount - _owner.Defense);
|
||
info.Amount = finalDamage;
|
||
info.FinalDamage = finalDamage; // ⚠️ 同步写入 FinalDamage(架构 06_CombatModule §5 步骤 5)
|
||
|
||
// 6. 调用 _owner.TakeDamage
|
||
_owner.TakeDamage(info);
|
||
|
||
// 7. 全局广播(两个频道,架构 06_CombatModule §5)
|
||
_onDamageDealt.Raise(info);
|
||
_onHitConfirmed.Raise(new HitInfo { DamageInfo = info, HitPoint = transform.position });
|
||
|
||
// 8. 状态效果触发(DoT — Fire/Poison;Phase 1 StatusEffectManager 可能尚未挂载)
|
||
if (_owner is MonoBehaviour mb)
|
||
{
|
||
var sem = mb.GetComponent<StatusEffectManager>();
|
||
if (sem != null)
|
||
{
|
||
if (info.Type == DamageType.Fire) sem.ApplyEffect(new FireEffect());
|
||
else if (info.Type == DamageType.Poison) sem.ApplyEffect(new PoisonEffect());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.4 WeaponManager + PlayerCombat(Week 2 新增)
|
||
|
||
> **架构约束(05_PlayerModule.md §5/§7)**:HitBox **挂载在 Player Prefab 本体上**(由 `PlayerCombat` 管理),**不使用独立武器 Prefab**。WeaponSO 是纯数据 SO,不含 Prefab 引用。⚠️ `WeaponInstance` 类不存在。
|
||
|
||
**文件**:
|
||
- `Assets/Scripts/Player/WeaponManager.cs`
|
||
- `Assets/Scripts/Player/PlayerCombat.cs`(Phase 1 简化版;完整版见 Phase 2 §2.3)
|
||
|
||
```csharp
|
||
// WeaponManager.cs — 挂在 PlayerController 上
|
||
// ⚠️ 架构 05_PlayerModule §7:ActiveWeapon(WeaponSO),OnWeaponChanged 事件,无 Equip() 方法,无 WeaponInstance
|
||
// Phase 1: _startingWeapon 在 Inspector 直接指定;Phase 2 §2.3 接入 FormController.OnFormChanged
|
||
public class WeaponManager : MonoBehaviour
|
||
{
|
||
[SerializeField] private WeaponSO _startingWeapon; // Phase 1 直接指定,Phase 2 改为由 FormController 驱动
|
||
|
||
public WeaponSO ActiveWeapon { get; private set; }
|
||
public event Action<WeaponSO> OnWeaponChanged;
|
||
|
||
void Start()
|
||
{
|
||
if (_startingWeapon != null)
|
||
{
|
||
ActiveWeapon = _startingWeapon;
|
||
OnWeaponChanged?.Invoke(ActiveWeapon);
|
||
}
|
||
}
|
||
|
||
// Phase 2 §2.3 补全:SetOverride / ClearOverride + FormController 订阅(架构 05_PlayerModule §7)
|
||
}
|
||
|
||
// PlayerCombat.cs — Phase 1 简化版(只含 HitBox 激活,无 RefreshWeaponData / SetComboSegmentSource)
|
||
// ⚠️ 架构 05_PlayerModule §5:HitBox 直接挂在 Player Prefab 上,不经过 WeaponInstance
|
||
public class PlayerCombat : MonoBehaviour
|
||
{
|
||
[SerializeField] private WeaponManager _weaponManager;
|
||
|
||
// ── 玩家角色 Prefab 上的 HitBox(固定挂载,不依赖武器 Prefab)────────────
|
||
[SerializeField] private HitBox _hitBoxGround;
|
||
[SerializeField] private HitBox _hitBoxUp;
|
||
[SerializeField] private HitBox _hitBoxDown;
|
||
[SerializeField] private HitBox _hitBoxAir;
|
||
|
||
// ── HitBox 激活(供 AttackState / AnimationEvent 调用)─────────────────
|
||
public void EnableWeaponHitBox(AttackDirection dir)
|
||
=> GetHitBox(dir)?.Activate(_weaponManager.ActiveWeapon?.GetSourceByDir(dir), transform);
|
||
|
||
public void DisableWeaponHitBox(AttackDirection dir)
|
||
=> GetHitBox(dir)?.Deactivate();
|
||
|
||
public void DisableAllWeaponHitBoxes()
|
||
{
|
||
_hitBoxGround?.Deactivate();
|
||
_hitBoxUp?.Deactivate();
|
||
_hitBoxDown?.Deactivate();
|
||
_hitBoxAir?.Deactivate();
|
||
}
|
||
|
||
// 命中回调 → 增加灵力(Phase 2 §2.3 补全)
|
||
internal void OnHitConfirmed(DamageInfo info) { /* 增加灵力 */ }
|
||
|
||
private HitBox GetHitBox(AttackDirection dir) => dir switch
|
||
{
|
||
AttackDirection.Ground => _hitBoxGround,
|
||
AttackDirection.Up => _hitBoxUp,
|
||
AttackDirection.Down => _hitBoxDown,
|
||
AttackDirection.Air => _hitBoxAir,
|
||
_ => null
|
||
};
|
||
}
|
||
```
|
||
|
||
**Player Prefab 关键子节点(Week 2 补充)**:
|
||
```
|
||
[Player]
|
||
├── ... (其余同架构层级)
|
||
├── [HitBoxGround] ← 碰撞体,挂 HitBox 组件(PlayerCombat._hitBoxGround)
|
||
├── [HitBoxUp] ← 碰撞体,挂 HitBox 组件(PlayerCombat._hitBoxUp)
|
||
├── [HitBoxDown] ← 碰撞体,挂 HitBox 组件(PlayerCombat._hitBoxDown)
|
||
├── [HitBoxAir] ← 碰撞体,挂 HitBox 组件(PlayerCombat._hitBoxAir)
|
||
└── [SkillSocket] ← 空 Transform,技能 HitBox 挂点(Phase 2 技能系统使用)
|
||
```
|
||
|
||
**AttackState 说明**:`AttackState` 通过 `_player.Combat.EnableWeaponHitBox(dir)` / `DisableAllWeaponHitBoxes()` 激活/关闭 HitBox(见 §3.5)。⚠️ 不存在 `WeaponInstance` 或 `ActiveWeaponInstance` API。
|
||
|
||
### 3.5 AttackState(Animancer 集成关键)
|
||
|
||
**文件**:`Assets/Scripts/Player/States/AttackState.cs`
|
||
|
||
```csharp
|
||
// Animancer 状态机集成要点
|
||
public class AttackState : PlayerStateBase
|
||
{
|
||
// PlayerAnimationConfigSO 中定义 AnimationClip[] GroundAttacks(3段连击,⚠️ 字段名 GroundAttacks,非 AttackChainClips,架构 05_PlayerModule §17)
|
||
private int _comboIndex;
|
||
|
||
public override void OnStateEnter()
|
||
{
|
||
_comboIndex = 0;
|
||
PlayAttackClip();
|
||
_player.Input.AttackEvent += OnAttackInput; // 监听连击输入
|
||
}
|
||
|
||
private void PlayAttackClip()
|
||
{
|
||
var clip = _player.AnimConfig.GroundAttacks[_comboIndex]; // ⚠️ 字段名 GroundAttacks(架构 05_PlayerModule §17)
|
||
var state = _player.Animancer.Play(clip);
|
||
state.Events.OnEnd = OnClipEnd;
|
||
// HitBox 激活由 Animancer 帧事件驱动(架构 05_PlayerModule §5)
|
||
state.Events.SetCallback(0.3f, () => _player.Combat.EnableWeaponHitBox(AttackDirection.Ground));
|
||
state.Events.SetCallback(0.6f, () => _player.Combat.DisableAllWeaponHitBoxes());
|
||
}
|
||
|
||
private void OnClipEnd()
|
||
{
|
||
_player.Input.AttackEvent -= OnAttackInput;
|
||
_player.Combat.DisableAllWeaponHitBoxes(); // 确保关闭
|
||
_player.TryTransitionState(_player.IdleState);
|
||
}
|
||
|
||
// 连击窗口内再次按攻击键 → 进入下一段
|
||
private void OnAttackInput()
|
||
{
|
||
if (_comboIndex < 2) { _comboIndex++; PlayAttackClip(); }
|
||
}
|
||
}
|
||
```
|
||
|
||
**验证点**:3 段连击动画顺序播放无帧跳,最终段结束后正确回到 Idle。PlayerCombat HitBox 在正确帧激活/关闭。
|
||
|
||
### 3.5 EnemyBase + EnemyStats + EnemyNavAgent
|
||
|
||
**文件**:
|
||
- `Assets/Scripts/Enemies/EnemyBase.cs`
|
||
- `Assets/Scripts/Enemies/EnemyStats.cs`
|
||
- `Assets/Scripts/Enemies/Navigation/EnemyNavAgent.cs`
|
||
|
||
Phase 1 实现范围:
|
||
|
||
| 组件 | Phase 1 实现 |
|
||
|------|-------------|
|
||
| `EnemyBase` | Awake、TakeDamage、Die(播放动画+延迟销毁)|
|
||
| `EnemyStats` | MaxHP/CurrentHP、TakeDamage、DistanceToPlayer(每帧更新)|
|
||
| `EnemyNavAgent` | `SetDestination`、`Stop`、PathBerserker2d `NavAgent` 封装 |
|
||
|
||
**PathBerserker2d 集成要点**:
|
||
- 在测试房间 Tilemap 上烘焙 `NavSurface2D`
|
||
- `EnemyNavAgent` 持有 `PathBerserker2d.NavAgent` 组件,`SetDestination` 直接调用其 API
|
||
- **验证点**:敌人能在平台间寻路,不陷入地面,停止时无滑动
|
||
|
||
### 3.6 Behavior Designer 基础行为树
|
||
|
||
**文件**:`Assets/Scripts/Enemies/AI/BasicEnemyBT.cs`(BD 外部行为树,非 MonoBehaviour 直接编写)
|
||
|
||
Phase 1 行为树结构(BD Editor 中组装):
|
||
|
||
```
|
||
Selector
|
||
├── Sequence (Chase & Attack)
|
||
│ ├── Conditional: IsPlayerInAttackRange(1.5f)
|
||
│ └── Action: MeleeAttack → BeginAttack(AttackType.Melee)
|
||
│
|
||
├── Sequence (Chase)
|
||
│ ├── Conditional: IsPlayerVisible
|
||
│ └── Action: MoveToPlayer → MoveTo(playerPos)
|
||
│
|
||
└── Action: Patrol → MoveInDirection(patrolDir) + FlipOnBoundary
|
||
```
|
||
|
||
**BD + Animancer 冲突验证**:BD 调用 `EnemyBase.BeginAttack` → Animancer 切换攻击动画,确认无 `MissingAnimancerComponent` 异常。
|
||
|
||
---
|
||
|
||
## 4. Week 3:存档完善 + 死亡复活流 + UI
|
||
|
||
**参考文档**:`12_SaveModule.md`、`03_CoreModule.md §4-6`、`10_UIModule.md`
|
||
|
||
### 4.1 SaveManager 完整实现
|
||
|
||
Phase 0 仅实现骨架,Week 3 补全:
|
||
|
||
```csharp
|
||
// 补充实现(⚠️ 返回类型为 Task,与架构 12_SaveModule §4 一致;非 UniTask)
|
||
public async Task SaveAsync(int slot = -1)
|
||
{
|
||
// 1. 遍历 _saveables 列表,调用 s.OnSave(_current) 收集数据
|
||
// (直接迭代,非事件广播)
|
||
// 2. LocalFileStorage.WriteAsync(slot, json)
|
||
// (⚠️ 类名为 LocalFileStorage,非 LocalFileSaveStorage)
|
||
// 3. 计算 Checksum、更新 LastCheckpointScene / LastCheckpointSpawnId
|
||
}
|
||
|
||
public async Task<bool> LoadAsync(int slot)
|
||
{
|
||
// 1. LocalFileStorage.ReadAsync(slot) → 反序列化 SaveData
|
||
// 2. ValidateChecksum → 失败返回 false
|
||
// 3. SaveMigrator.Migrate(data)
|
||
// 4. 遍历 _saveables 列表,调用 s.OnLoad(_current) 恢复状态
|
||
}
|
||
```
|
||
|
||
**ISaveable 接口**:
|
||
|
||
```csharp
|
||
// Assets/Scripts/Core/Save/ISaveable.cs
|
||
// ⚠️ 方法名必须与架构 12_SaveModule §3 完全一致
|
||
public interface ISaveable
|
||
{
|
||
void OnSave(SaveData data); // 收集并填充对应字段(存档时调用)
|
||
void OnLoad(SaveData data); // 从存档数据恢复自身状态(读档时调用)
|
||
}
|
||
```
|
||
|
||
**PlayerController** 实现 `ISaveable`:
|
||
- `OnSave`:填充 `data.Player.PosX/PosY/CurrentHP/CurrentSpring`
|
||
- `OnLoad`:设置 Position、HP、Spring
|
||
|
||
### 4.2 SavePoint
|
||
|
||
**文件**:`Assets/Scripts/World/SavePoint.cs`
|
||
|
||
```csharp
|
||
// ⚠️ Interact 参数为 Transform(架构 14_NarrativeModule §1 IInteractable 定义)
|
||
public class SavePoint : MonoBehaviour, IInteractable
|
||
{
|
||
[SerializeField] private string _savePointId; // 全局唯一 ID
|
||
[SerializeField] private bool _restoreSpring = true;
|
||
[SerializeField] private StringEventChannelSO _onSavePointActivated;
|
||
[SerializeField] private VoidEventChannelSO _onFastTravelOpen; // 快速旅行 UI
|
||
|
||
private bool _isActivated = false;
|
||
|
||
public bool CanInteract => true;
|
||
public string InteractPrompt => _isActivated ? "休息" : "激活";
|
||
|
||
public void Interact(Transform player) // 参数为 Transform(架构 14_NarrativeModule §1 / 08_WorldModule §7 IInteractable 定义)
|
||
{
|
||
// ⚠️ PlayerController 无 Instance(Architecture 05 §2);通过 player 参数获取
|
||
var pc = player.GetComponent<PlayerController>();
|
||
_isActivated = true;
|
||
if (_restoreSpring && pc != null) pc.Stats.RestoreSpringCharges();
|
||
_onSavePointActivated.Raise(_savePointId); // → GameManager 调用 SaveManager.SaveAsync
|
||
_onFastTravelOpen.Raise();
|
||
PlayActivateAnimation();
|
||
}
|
||
|
||
// ISaveable.OnLoad:从 WorldSaveData.ActivatedSavePoints 恢复激活状态
|
||
public void OnSave(SaveData data) { if (_isActivated) data.World.ActivatedSavePoints.Add(_savePointId); }
|
||
public void OnLoad(SaveData data) { _isActivated = data.World.ActivatedSavePoints.Contains(_savePointId); }
|
||
public void OnPlayerEnterRange(Transform player) { }
|
||
public void OnPlayerExitRange() { }
|
||
}
|
||
```
|
||
|
||
### 4.3 EmergencySaveService
|
||
|
||
```csharp
|
||
// Assets/Scripts/Core/Save/EmergencySaveService.cs
|
||
// 定期自动保存到独立紧急存档槽(slot 99),用于崩溃/异常退出后恢复进度
|
||
// ⚠️ 与架构 12_SaveModule §9 完全对应;非"死亡时保存"机制
|
||
public class EmergencySaveService : MonoBehaviour
|
||
{
|
||
private const int EmergencySlot = 99; // 独立槽,不显示在存档选择界面
|
||
|
||
[SerializeField] private float _intervalSeconds = 120f; // 每 2 分钟
|
||
[SerializeField] private SaveManager _saveManager;
|
||
[SerializeField] private BoolEventChannelSO _onGameplayActive; // 仅 Gameplay 状态下保存
|
||
|
||
private bool _gameplayActive;
|
||
private float _timer;
|
||
|
||
private void OnEnable() => _onGameplayActive.OnEventRaised += v => _gameplayActive = v;
|
||
private void OnDisable() => _onGameplayActive.OnEventRaised -= v => _gameplayActive = v;
|
||
|
||
private void Update()
|
||
{
|
||
if (!_gameplayActive) return;
|
||
_timer += Time.deltaTime;
|
||
if (_timer >= _intervalSeconds)
|
||
{
|
||
_timer = 0f;
|
||
_ = _saveManager.SaveAsync(EmergencySlot); // fire-and-forget
|
||
}
|
||
}
|
||
|
||
// 判断是否存在未读的紧急存档(启动时由 MainMenuController 检查)
|
||
public bool HasEmergencySave() => _saveManager.SlotExists(EmergencySlot);
|
||
|
||
// 将紧急存档提升为指定主存档槽(玩家确认恢复后调用)
|
||
public async Task PromoteToSlot(int targetSlot)
|
||
{
|
||
// 读取 slot 99 → 写入 targetSlot → 删除 slot 99
|
||
// 由 LocalFileStorage(ISaveStorage 实现)执行
|
||
}
|
||
}
|
||
```
|
||
|
||
### 4.4 GameManager 死亡/复活完整流
|
||
|
||
补充 `03_CoreModule.md §8` 中定义的死亡/复活流程:
|
||
|
||
```csharp
|
||
// ⚠️ Architecture 03 §8:HandlePlayerDied() 在协程启动前先调用 TransitionTo(GameState.Dead)
|
||
// private void HandlePlayerDied()
|
||
// {
|
||
// TransitionTo(GameState.Dead); ← 协程外,HandlePlayerDied() 中直接调用
|
||
// StartCoroutine(DeathSequenceCoroutine());
|
||
// }
|
||
|
||
private IEnumerator DeathSequenceCoroutine()
|
||
{
|
||
// 1. 等待死亡动画完成(约 1.5s)
|
||
yield return new WaitForSeconds(1.5f);
|
||
// 2. DeathScreenController 已通过 EVT_PlayerDied 订阅并延迟 1.5s 自动显示;GameManager 无需直接调用 UI
|
||
// ⚠️ 不调用 UIManager.ShowPanel("DeathScreen"):Architecture 10 §2 UIManager 无该方法(API 为 OpenPanel(GameObject))
|
||
// 3. 等待玩家按下"重试"(监听 EVT_DeathScreenConfirmed)
|
||
}
|
||
|
||
private IEnumerator RespawnCoroutine()
|
||
{
|
||
// 1. TransitionTo(GameState.LoadingScene)
|
||
// 2. _sceneLoader.RequestLoad(new SceneLoadRequest
|
||
// {
|
||
// SceneName = _currentSceneName,
|
||
// EntryTransitionId = _lastSavePointId,
|
||
// ShowLoadingScreen = false,
|
||
// IsRespawn = true
|
||
// });
|
||
// → SceneLoader 内部依次:淡出 → 卸载 → 加载 → SaveManager 恢复 → 淡入
|
||
// ⚠️ GameManager 不额外调用 SaveManager.LoadAsync(),恢复由 SceneLoader 流程内部处理
|
||
// 3. TransitionTo(GameState.Gameplay)
|
||
// 4. _onPlayerRespawned.Raise()
|
||
yield break;
|
||
}
|
||
```
|
||
|
||
### 4.5 HUDController(Phase 1 最小集)+ DeathScreenController
|
||
|
||
**文件**:
|
||
- `Assets/Scripts/UI/UIManager.cs`(Phase 1 仅占位;完整实现见 Plan 05 §4.1)
|
||
- `Assets/Scripts/UI/HUD/HUDController.cs` ⚠️ 路径/类名以 Architecture 10 §3 为准(非 HUDPanel / UI/Panels/)
|
||
- `Assets/Scripts/UI/Menus/DeathScreenController.cs` ⚠️ 路径/类名以 Architecture 10 §6 为准(非 DeathPanel / UI/Panels/)
|
||
|
||
**Phase 1 HUDController 最小集**(完整字段见 Architecture 10 §3):
|
||
|
||
```csharp
|
||
// Assets/Scripts/UI/HUD/HUDController.cs
|
||
// Phase 1 仅实现 HP + Soul;其余字段 Phase 2+ 填充
|
||
public class HUDController : MonoBehaviour
|
||
{
|
||
[Header("HP")]
|
||
[SerializeField] private Transform _hpContainer;
|
||
[SerializeField] private GameObject _hpCellPrefab;
|
||
|
||
[Header("Gauges")]
|
||
[SerializeField] private Image _soulGaugeFill;
|
||
[SerializeField] private TMP_Text _geoText;
|
||
|
||
[Header("Event Channels - Subscribe")]
|
||
[SerializeField] private IntEventChannelSO _onHPChanged;
|
||
[SerializeField] private IntEventChannelSO _onMaxHPChanged;
|
||
[SerializeField] private IntEventChannelSO _onSoulPowerChanged;
|
||
[SerializeField] private IntEventChannelSO _onGeoChanged;
|
||
|
||
private void OnEnable()
|
||
{
|
||
_onHPChanged.OnEventRaised += UpdateHP;
|
||
_onMaxHPChanged.OnEventRaised += RebuildHPCells;
|
||
_onSoulPowerChanged.OnEventRaised += val => _soulGaugeFill.fillAmount = val / 100f;
|
||
_onGeoChanged.OnEventRaised += val => _geoText.text = val.ToString();
|
||
}
|
||
private void OnDisable()
|
||
{
|
||
_onHPChanged.OnEventRaised -= UpdateHP;
|
||
_onMaxHPChanged.OnEventRaised -= RebuildHPCells;
|
||
_onSoulPowerChanged.OnEventRaised -= val => _soulGaugeFill.fillAmount = val / 100f;
|
||
_onGeoChanged.OnEventRaised -= val => _geoText.text = val.ToString();
|
||
}
|
||
|
||
private void UpdateHP(int current); // HP 格子激活/灰化
|
||
private void RebuildHPCells(int max); // MaxHP 改变时重建格子列表
|
||
}
|
||
```
|
||
|
||
**DeathScreenController**(完整见 Architecture 10 §6):
|
||
- 文件:`Assets/Scripts/UI/Menus/DeathScreenController.cs`
|
||
- 订阅 `EVT_PlayerDied`,`WaitForSeconds(1.5f)` 后调用 `Show()`
|
||
- "继续"按钮点击后调用 `_onDeathScreenConfirmed.Raise()`
|
||
|
||
---
|
||
|
||
## 5. Week 4:VFX/音效 + 集成验证
|
||
|
||
**参考文档**:`18_VFXFeedbackModule.md`
|
||
|
||
### 5.1 VFXPool
|
||
|
||
```csharp
|
||
// Assets/Scripts/VFX/VFXPool.cs
|
||
// ❗ API 以架构 18_VFXFeedbackModule §6 为准,不使用 SpawnAt
|
||
public class VFXPool : MonoBehaviour
|
||
{
|
||
public static VFXPool Instance { get; private set; }
|
||
|
||
// 在世界坐标播放一次特效。Fire-and-forget(UniTask 自动回池)
|
||
// ❗ vfxRef 类型为 AssetReferenceGameObject(非 string key)
|
||
public async UniTaskVoid Play(AssetReferenceGameObject vfxRef,
|
||
Vector3 position,
|
||
Quaternion rotation = default);
|
||
|
||
// 预热:预先创建若干实例避免首次播放媁顿
|
||
public async UniTask WarmupAsync(AssetReferenceGameObject vfxRef, int count);
|
||
}
|
||
```
|
||
|
||
### 5.2 VFXCatalogSO
|
||
|
||
```csharp
|
||
// ❗ 结构名、字段名、数组名以架构 18_VFXFeedbackModule §7 为准
|
||
[CreateAssetMenu(menuName = "VFX/VFXCatalog")]
|
||
public class VFXCatalogSO : ScriptableObject
|
||
{
|
||
[Header("命中特效映射")]
|
||
public VFXEntry[] hitEffects; // ❗ 数组名 hitEffects(非 Entries);HitFxType → VFX Prefab
|
||
|
||
[Header("预热配置")]
|
||
public VFXWarmupEntry[] warmups; // ❗ 预热配置数组(不可缺少)
|
||
|
||
// ❗ 方法名为 TryGetHitFX(非 GetKey);返回 bool + out(架构 18_VFXFeedbackModule §7)
|
||
public bool TryGetHitFX(HitFxType type, out AssetReferenceGameObject vfxRef);
|
||
}
|
||
|
||
[Serializable]
|
||
public struct VFXEntry
|
||
{
|
||
public HitFxType type; // ❗ 字段名 type(非 FxType),小写开头
|
||
public AssetReferenceGameObject vfxRef; // ❗ 字段名 vfxRef(非 VfxRef),camelCase
|
||
}
|
||
|
||
[Serializable]
|
||
public struct VFXWarmupEntry
|
||
{
|
||
public AssetReferenceGameObject vfxRef;
|
||
[Min(1)] public int warmupCount; // 建议 3~5
|
||
}
|
||
```
|
||
|
||
**Phase 1 需要的 VFX 资产**(最小集):
|
||
- `VFX_HitSlashLight`:轻攻击命中
|
||
- `VFX_HitSlashHeavy`:重攻击命中
|
||
- `VFX_PlayerDust`:落地/跑步尘土
|
||
- `VFX_EnemyDie`:敌人死亡
|
||
|
||
### 5.3 HitFXSpawner
|
||
|
||
```csharp
|
||
// Assets/Scripts/VFX/HitFXSpawner.cs
|
||
// 挂在 Persistent 场景,监听 EVT_HitConfirmed 事件频道,驱动 VFXPool
|
||
// ❗ 完整实现以架构 18_VFXFeedbackModule §8 为准
|
||
public class HitFXSpawner : MonoBehaviour
|
||
{
|
||
[SerializeField] private HitConfirmedEventChannelSO _onHitConfirmed;
|
||
[SerializeField] private VFXCatalogSO _catalog; // ❗ 字段为 _catalog(非 _vfxPool)
|
||
|
||
private void OnEnable() => _onHitConfirmed.OnEventRaised += HandleHit;
|
||
private void OnDisable() => _onHitConfirmed.OnEventRaised -= HandleHit;
|
||
|
||
private void HandleHit(HitInfo info) // ❗ 参数类型 HitInfo(非 HitEventData)
|
||
{
|
||
// ⚠️ DamageInfo 中字段名为 FxType(架构 06_CombatModule §1),非 HitFxType
|
||
if (_catalog.TryGetHitFX(info.DamageInfo.FxType, out var vfxRef))
|
||
VFXPool.Instance.Play(vfxRef, info.HitPoint).Forget();
|
||
}
|
||
}
|
||
```
|
||
|
||
❗ `HitInfo`(DamageInfo + HitPoint)和 `HitConfirmedEventChannelSO` 均已在 Day 2 §3.1 创建;`VFXCatalogSO` 中的 Entry.VfxRef 为 `AssetReferenceGameObject`。`DamageInfo.FxType` 为字段名(`HitFxType` 为类型名,字段名是 `FxType`,架构 06_CombatModule §1)。
|
||
|
||
### 5.4 HurtFlashController
|
||
|
||
```csharp
|
||
// Assets/Scripts/VFX/HurtFlashController.cs
|
||
// 挂在 Player / Enemy SpriteRenderer 同一 GameObject 上
|
||
// ❗ 字段与架构 18_VFXFeedbackModule §9 对齐:单个 _renderer + FeedbackConfigSO _config(非数组+裸float)
|
||
// ❗ Flash() 为 async UniTaskVoid(非 Coroutine),使用 UniTask.Delay(架构 18 §9)
|
||
public class HurtFlashController : MonoBehaviour
|
||
{
|
||
[SerializeField] SpriteRenderer _renderer; // ❗ 单个(非 SpriteRenderer[] _renderers)
|
||
[SerializeField] FeedbackConfigSO _config; // ❗ 时长/颜色来自 FeedbackConfigSO(非 float _flashDuration)
|
||
|
||
static readonly int FlashColorID = Shader.PropertyToID("_FlashColor"); // ❗ 颜色属性(不可缺少)
|
||
static readonly int FlashAmountID = Shader.PropertyToID("_FlashAmount"); // 混合强度
|
||
|
||
MaterialPropertyBlock _block;
|
||
|
||
void Awake() => _block = new MaterialPropertyBlock();
|
||
|
||
/// <summary>
|
||
/// 触发一次受击白闪;由 IFeedbackPlayer.PlayTakeHit 调用。
|
||
/// ⚠️ 返回 async UniTaskVoid(非 IEnumerator Coroutine),使用 UniTask.Delay(架构 18_VFXFeedbackModule §9)
|
||
/// </summary>
|
||
public async UniTaskVoid Flash(CancellationToken ct = default)
|
||
{
|
||
_renderer.GetPropertyBlock(_block);
|
||
_block.SetColor(FlashColorID, _config.HurtFlashColor);
|
||
_block.SetFloat(FlashAmountID, 1f);
|
||
_renderer.SetPropertyBlock(_block);
|
||
|
||
await UniTask.Delay(
|
||
TimeSpan.FromSeconds(_config.HurtFlashDuration),
|
||
cancellationToken: ct);
|
||
|
||
_block.SetFloat(FlashAmountID, 0f);
|
||
_renderer.SetPropertyBlock(_block);
|
||
}
|
||
// Shader 需支持 _FlashColor(Color)和 _FlashAmount(float 0~1)(URP Sprite-Lit-Flash 变体)
|
||
}
|
||
```
|
||
|
||
### 5.5 AudioManager + BGMController + AudioZone
|
||
|
||
**参考文档**:`11_AudioModule.md`
|
||
|
||
> **⚠️ 架构说明**:架构使用 **Unity AudioMixer + 双 AudioSource 交叉淡入**,无 FMOD 依赖。Phase 2 无需"切换后端"。
|
||
|
||
**文件**:
|
||
- `Assets/Scripts/Audio/AudioManager.cs`
|
||
- `Assets/Scripts/Audio/BGMController.cs`
|
||
- `Assets/Scripts/Audio/AudioZone.cs`
|
||
- `Assets/Scripts/Audio/AudioMixerKeys.cs`
|
||
- `Assets/Scripts/Audio/AudioConfigSO.cs`
|
||
|
||
```csharp
|
||
// Assets/Scripts/Audio/AudioMixerKeys.cs
|
||
public static class AudioMixerKeys
|
||
{
|
||
public const string Master = "MasterVolume";
|
||
public const string BGM = "BGMVolume";
|
||
public const string SFX = "SFXVolume";
|
||
public const string Ambient = "AmbientVolume";
|
||
}
|
||
|
||
// Assets/Scripts/Audio/AudioManager.cs
|
||
[DefaultExecutionOrder(-500)]
|
||
public class AudioManager : MonoBehaviour
|
||
{
|
||
[Header("AudioMixer")]
|
||
[SerializeField] private AudioMixer _mixer;
|
||
|
||
[Header("BGM Sources(双 Source 交叉淡入淡出)")]
|
||
[SerializeField] private AudioSource _bgmSourceA;
|
||
[SerializeField] private AudioSource _bgmSourceB;
|
||
|
||
[Header("SFX")]
|
||
[SerializeField] private AudioSource _globalSFXSource;
|
||
|
||
[Header("Event Channels - Subscribe")]
|
||
[SerializeField] private GameStateEventChannelSO _onGameStateChanged;
|
||
[SerializeField] private StringEventChannelSO _onBossFightStarted; // ⚠️ bossId: string(架构 11 §2)
|
||
|
||
// 私有状态(双 Source 交叉淡入淡出)
|
||
private AudioSource _activeBGMSource;
|
||
private AudioSource _inactiveBGMSource;
|
||
private Coroutine _crossfadeCoroutine;
|
||
|
||
// BGM 播放(独立淡出/淡入时长)
|
||
public void PlayBGM(AudioClip clip, float fadeOutDur = 1f, float fadeInDur = 1f); // ⚠️ 两个独立参数(架构 11 §2)
|
||
public void StopBGM(float fadeDuration = 1f);
|
||
|
||
// SFX 一次性播放
|
||
public void PlaySFX(AudioClip clip, float volumeScale = 1f);
|
||
public void PlaySFXAtPosition(AudioClip clip, Vector2 pos, float volumeScale = 1f); // 空间音效
|
||
|
||
// 音量设置(SettingsManager 调用)
|
||
public void Initialize(); // 读取 GlobalSettings 并应用音量
|
||
/// <summary>唯一音量写入入口。exposedParam = AudioMixerKeys.* 常量。</summary>
|
||
public void SetVolume(string exposedParam, float linear)
|
||
=> _mixer.SetFloat(exposedParam, LinearToDecibel(linear));
|
||
|
||
// 快照切换(Pause/BossFight 状态)
|
||
public void TransitionToSnapshot(string snapshotName, float transitionTime = 0.5f); // ⚠️ TransitionToSnapshot(架构 11 §2)
|
||
|
||
private IEnumerator CrossfadeCoroutine(AudioClip newClip, float fadeOutDur, float fadeInDur); // ⚠️ 两个 float 参数
|
||
|
||
private static float LinearToDecibel(float linear)
|
||
=> linear > 0.0001f ? 20f * Mathf.Log10(linear) : -80f;
|
||
}
|
||
|
||
// BGMController.cs — 订阅 Boss/Zone 事件,驱动 AudioManager 切换 BGM
|
||
public class BGMController : MonoBehaviour
|
||
{
|
||
[SerializeField] private AudioManager _audioManager;
|
||
[SerializeField] private AudioConfigSO _config;
|
||
|
||
[Header("Event Channels - Subscribe")]
|
||
[SerializeField] private GameStateEventChannelSO _onGameStateChanged;
|
||
[SerializeField] private BoolEventChannelSO _onBossFightToggled; // ⚠️ 单一频道:true=开始, false=结束(架构 11 §3)
|
||
[SerializeField] private StringEventChannelSO _onRegionEntered; // ⚠️ _onRegionEntered(非 _onAudioZoneEntered)
|
||
|
||
private MusicState _musicState = MusicState.Exploration;
|
||
private string _currentRegion = "Forest";
|
||
|
||
private void OnEnable()
|
||
{
|
||
_onBossFightToggled.OnEventRaised += OnBossFightToggled;
|
||
_onRegionEntered.OnEventRaised += OnRegionEntered;
|
||
_onGameStateChanged.OnEventRaised += HandleStateChanged;
|
||
}
|
||
|
||
private void OnDisable()
|
||
{
|
||
_onBossFightToggled.OnEventRaised -= OnBossFightToggled;
|
||
_onRegionEntered.OnEventRaised -= OnRegionEntered;
|
||
_onGameStateChanged.OnEventRaised -= HandleStateChanged;
|
||
}
|
||
|
||
private void OnBossFightToggled(bool started) // ⚠️ OnBossFightToggled,非 StartBossBGM/EndBossBGM 两个方法
|
||
{
|
||
if (started)
|
||
{
|
||
_musicState = MusicState.Boss;
|
||
var clip = _config.GetBossBGM(_currentRegion);
|
||
_audioManager.PlayBGM(clip, fadeOutDur: 1f, fadeInDur: 0.5f);
|
||
_audioManager.TransitionToSnapshot("BossFight", 0.5f);
|
||
}
|
||
else
|
||
{
|
||
StartCoroutine(PlayVictoryThenRestore());
|
||
}
|
||
}
|
||
|
||
private IEnumerator PlayVictoryThenRestore()
|
||
{
|
||
_musicState = MusicState.Victory;
|
||
_audioManager.PlayBGM(_config.VictoryStingBGM, fadeOutDur: 0.3f, fadeInDur: 0.1f);
|
||
yield return new WaitForSecondsRealtime(_config.VictoryStingDuration);
|
||
_musicState = MusicState.Exploration;
|
||
OnRegionEntered(_currentRegion);
|
||
_audioManager.TransitionToSnapshot("Default", 1.0f);
|
||
}
|
||
|
||
private void OnRegionEntered(string regionId) // ⚠️ OnRegionEntered,非 SwitchZoneBGM
|
||
{
|
||
if (regionId == _currentRegion) return;
|
||
_currentRegion = regionId;
|
||
if (_musicState == MusicState.Exploration)
|
||
{
|
||
var clip = _config.GetZoneBGM(regionId);
|
||
_audioManager.PlayBGM(clip, fadeOutDur: 1f, fadeInDur: 1f);
|
||
}
|
||
}
|
||
|
||
private void HandleStateChanged(GameStateId state)
|
||
{
|
||
// ⚠️ GameStateId 是 struct,不能用 switch;使用 if/else + GameStates 常量(架构 03_CoreModule §2)
|
||
if (state == GameStates.MainMenu)
|
||
_audioManager.PlayBGM(_config.MainMenuBGM, fadeOutDur: 0.5f, fadeInDur: 1.0f); // ⚠️ 架构 11 §3
|
||
else if (state == GameStates.Paused)
|
||
_audioManager.TransitionToSnapshot("Paused", 0.2f);
|
||
else if (state == GameStates.Dead)
|
||
_audioManager.TransitionToSnapshot("Dead", 1.5f); // ⚠️ 1.5f(架构 11 §3),非 0.5f
|
||
else if (state == GameStates.Gameplay)
|
||
_audioManager.TransitionToSnapshot("Default", 0.3f); // ⚠️ 0.3f(架构 11 §3),非 0.2f
|
||
}
|
||
}
|
||
```
|
||
|
||
**Phase 1 资产**:`Assets/Audio/MainMixer.mixer`(Master/BGM/SFX/Ambient 四组 + Default/Paused/Dead/BossFight 快照)
|
||
|
||
### 5.6 IFeedbackPlayer + PlayerFeedback + EnemyFeedback
|
||
|
||
**参考文档**:`18_VFXFeedbackModule.md §2–§4`
|
||
|
||
```csharp
|
||
// Assets/Scripts/Feedback/IFeedbackPlayer.cs
|
||
// ⚠️ Phase 1 必须定义此接口;GameLogic 依赖接口不依赖 Feel
|
||
public interface IFeedbackPlayer
|
||
{
|
||
void PlayHit(HitWeight weight); // 命中反馈(Light/Medium/Heavy)
|
||
void PlayParrySuccess(); // 弹反成功
|
||
void PlayTakeHit(); // 受击
|
||
void PlayDeath(); // 死亡演出
|
||
void PlayHeal(); // 治疗
|
||
void PlayLandImpact(); // 落地冲击
|
||
void PlayAttackWhoosh(); // 攻击挥动音效
|
||
void PlayJumpLaunch(); // 起跳
|
||
void TriggerPreset(string presetId); // 通过 ID 触发预设(AnimEvent 用)
|
||
void PlaySFXById(string sfxId); // 通过 ID 播放音效(AnimEvent 用)
|
||
}
|
||
|
||
public enum HitWeight { Light, Medium, Heavy }
|
||
|
||
/// <summary>空实现,用于测试和无反馈需求场景。</summary>
|
||
public class NullFeedbackPlayer : IFeedbackPlayer { /* 全部空实现 */ }
|
||
```
|
||
|
||
```csharp
|
||
// PlayerFeedback 聚合以下 MMF_Player 实例(Inspector 连线):
|
||
// ⚠️ 字段名以架构 18_VFXFeedbackModule §3 为准(_on* 前缀)
|
||
// ├── _onHitLight → MMF_Player(CameraShake Light + HitStop 0.05s)
|
||
// ├── _onHitMedium → MMF_Player(CameraShake Medium)
|
||
// ├── _onHitHeavy → MMF_Player(CameraShake Heavy + HitStop 0.1s)
|
||
// ├── _onParrySuccess → MMF_Player(弹反成功)
|
||
// ├── _onTakeHit → MMF_Player(HurtFlash + Haptic Light)
|
||
// ├── _onDeath → MMF_Player(SlowMotion 0.3s + SFX)
|
||
// ├── _onHeal → MMF_Player(ParticleSystem spawn + SFX)
|
||
// ├── _onLandImpact → MMF_Player(落地震动)
|
||
// ├── _onAttackWhoosh → MMF_Player(攻击挥动音效)
|
||
// └── _onJumpLaunch → MMF_Player(起跳)
|
||
```
|
||
|
||
**Phase 1 最小实现**:仅连线 `_onHitLight`、`_onHitHeavy`、`_onTakeHit`、`_onDeath`、`_onHeal`,其余 Phase 2 补全。
|
||
|
||
**Feel 集成验证**:命中敌人时屏幕轻震 + 0.05s 冻帧;玩家受击时白闪 + 手柄震动(若有)。
|
||
|
||
---
|
||
|
||
## 6. 第三方集成检查点
|
||
|
||
所有检查点必须在 Phase 1 结束前通过:
|
||
|
||
| 库 | 检查项 | 通过标准 |
|
||
|----|--------|---------|
|
||
| **Animancer Pro** | 玩家 FSM 状态切换(Idle/Run/Jump/Attack) | 无 `MissingReferenceException`;动画无帧跳 |
|
||
| **Animancer Pro** | Animancer + BD 同时运行(敌人既用 Animancer 又有 BD) | 无竞争报错,敌人攻击动画正常播放 |
|
||
| **PathBerserker2d** | 敌人在 NavSurface2D 上寻路 | 能到达玩家附近;无陷入地面;停止时无滑动 |
|
||
| **Behavior Designer** | 基础 BT(Patrol→Chase→Attack)运行 | 状态切换符合预期;BT 条件读取 `EnemyStats` 数据正确 |
|
||
| **Cinemachine 3** | 双机 A/B 切换 + Confiner2D | 无相机跳变;切换时有混合过渡 |
|
||
| **PixelPerfect Camera** | 角色移动时像素对齐 | 无亚像素抖动(放大观察精灵边缘) |
|
||
| **Addressables** | 场景加载/卸载 + 资产加载/释放 | 无内存泄漏(Profiler 观察 GC Alloc);场景切换时 `AssetReleaseTracker` 自动清理旧场景对象池 |
|
||
| **Feel / MMF_Player** | 命中冻帧 + 相机震动 | `Time.timeScale` 短暂降低;相机有位移 |
|
||
| **NewtonSoft.Json** | SaveData 序列化/反序列化 | 存档文件内容字段完整;加载后数据与保存前一致 |
|
||
|
||
---
|
||
|
||
## 7. 完成标准检查清单
|
||
|
||
```
|
||
□ 玩家移动/跳跃/攻击无 Animancer 卡顿(测试:快速连续输入10次)
|
||
□ 3 段连击动画顺序播放,最终段后回到 Idle
|
||
□ 敌人在 NavSurface2D 上寻路到玩家位置(直线距离和跨平台)
|
||
□ BD 行为树 Patrol→Chase→Attack 切换正确
|
||
□ 玩家攻击命中敌人:HP 减少 + HitFX 播放 + Feel 冻帧 0.05s
|
||
□ 敌人攻击命中玩家:HP 减少 + HurtFlash + HUD HP Bar 更新
|
||
□ 玩家 HP ≤ 0 → 死亡动画 → DeathScreenController 显示(⚠️ 非 DeathPanel,架构 10 §6)
|
||
□ 按确认键 → FadeOut → LoadAsync 恢复存档 → FadeIn → 玩家在 SavePoint 位置复活
|
||
□ 相机跟随玩家,像素对齐无抖动
|
||
□ 相机进入触发区域后切换到目标 Confiner 边界(测试用两个 Confiner 区域)
|
||
□ SavePoint 激活 → 自动存档 → 关闭 Play Mode → 重开 → 数据与存档一致
|
||
□ Addressables 场景加载/卸载后 Profiler 无持续增长内存
|
||
□ Console 无 Error(Warning 可记录 TODO 但不阻塞)
|
||
```
|
||
|
||
**Phase 1 完成后进入 Phase 2。**
|