Files
zeling_v2/Docs/Plan/02_Phase1_VerticalSlice.md
2026-05-08 11:04:00 +08:00

1351 lines
55 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 1 · 垂直切片 MVP
> **周期**34 周Week 1-4
> **前置条件**Phase 0 全部完成标准通过
> **核心目标**一个可玩的测试房间验证所有第三方库Animancer / PathBerserker2d / BD / Cinemachine / Feel / Addressables协作无冲突
> **产出物**:玩家能移动/跳跃/攻击 → 敌人寻路并反击 → 死亡 → 存档复活VFX/SFX 全流程响应
> **进度更新2026-05-07**Week 13 代码层完成Week 4 未开始。
>
> **Week 1 完成项**InputReaderSO、InputBuffer、PlayerMovement、PlayerStats、PlayerControllerIdle/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、GameManagerDeathFlow + RequestTransition、DeathRespawnService、UIManager、HUDControllerHP/灵力/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 4VFX/音效 + 集成验证](#5-week-4vfx音效--集成验证)
6. [第三方集成检查点](#6-第三方集成检查点)
7. [完成标准检查清单](#7-完成标准检查清单)
---
## 1. 实施顺序总览
```
Week 1:
InputReaderSO + InputBuffer
PlayerMovement + PlayerStatsRigidbody2D 物理验证)
PlayerController仅 Idle/Run/Jump/Fall 状态)
CameraStateController + CinemachineConfiner2D + PixelPerfect 验证
Week 2:
DamageInfo + DamageType 枚举 + DamageCategory/Flags/Tags
HitBox + HurtBox + IDamageable
AttackStateAnimancer 状态机 首次集成)
EnemyBase + EnemyStats + EnemyNavAgentPathBerserker2d 首次集成)
Behavior Designer 基础 BTPatrol→Chase→Attack
Week 3:
SaveManager 完整实现 + SavePoint + ISaveable + EmergencySaveService
GameManager 死亡/复活流完整 Coroutine
UIManager + HUDControllerHP 条、灵力条)+ DeathScreenController 非 HUDPanel/DeathPanel架构 10 §3/§6
Week 4:
VFXPool + HitFXSpawner + HurtFlashController + VFXCatalogSO
AudioManagerUnity AudioMixer + 双 AudioSource 交叉淡入淡出)
IFeedbackPlayer + PlayerFeedback / EnemyFeedbackFeel 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
// 最小可验证 APIPhase 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_ACinemachineCamera + CinemachinePositionComposer + CinemachineConfiner2D
// │ + CinemachineImpulseListener + CinemachinePixelPerfectPriority: 10active
// ├── VCam_Global_B同上Priority: 9standby
// └── 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.asset0.5s、Blend_Instant.asset0s
// Blend_Slow.asset1.0s、Blend_BossExit.asset0.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 structBuilder 需就地赋値字段,架构 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=trueLayer=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 默认 nullPhase 2 注入)
// 霸体来源EnemyPoiseComponent / PlayerController 实现Phase 2 注入)
private IPoiseSource _poiseSource; // null = 无霸体检查Phase 1 默认 nullPhase 2 注入)
[SerializeField] private DamageInfoEventChannelSO _onDamageDealt; // EVT_DamageDealtAnalyticsManager
[SerializeField] private HitConfirmedEventChannelSO _onHitConfirmed; // EVT_HitConfirmedVFX/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 注入了 _parrySystemPhase 1 skip
if (_parrySystem != null && info.Flags.HasFlag(DamageFlags.CanBeParried))
if (_parrySystem.TryParryDamage(info)) return;
// 3. 霸体检查BreakLevel vs PoiseLevelPhase 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/PoisonPhase 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 + PlayerCombatWeek 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 §7ActiveWeaponWeaponSOOnWeaponChanged 事件,无 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 §5HitBox 直接挂在 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 AttackStateAnimancer 集成关键)
**文件**`Assets/Scripts/Player/States/AttackState.cs`
```csharp
// Animancer 状态机集成要点
public class AttackState : PlayerStateBase
{
// PlayerAnimationConfigSO 中定义 AnimationClip[] GroundAttacks3段连击 字段名 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 无 InstanceArchitecture 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
// 由 LocalFileStorageISaveStorage 实现)执行
}
}
```
### 4.4 GameManager 死亡/复活完整流
补充 `03_CoreModule.md §8` 中定义的死亡/复活流程:
```csharp
// ⚠️ Architecture 03 §8HandlePlayerDied() 在协程启动前先调用 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 HUDControllerPhase 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 4VFX/音效 + 集成验证
**参考文档**`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-forgetUniTask 自动回池)
// ❗ 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非 EntriesHitFxType → 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非 VfxRefcamelCase
}
[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 需支持 _FlashColorColor和 _FlashAmountfloat 0~1URP 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_PlayerCameraShake Light + HitStop 0.05s
// ├── _onHitMedium → MMF_PlayerCameraShake Medium
// ├── _onHitHeavy → MMF_PlayerCameraShake Heavy + HitStop 0.1s
// ├── _onParrySuccess → MMF_Player弹反成功
// ├── _onTakeHit → MMF_PlayerHurtFlash + Haptic Light
// ├── _onDeath → MMF_PlayerSlowMotion 0.3s + SFX
// ├── _onHeal → MMF_PlayerParticleSystem 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** | 基础 BTPatrol→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 无 ErrorWarning 可记录 TODO 但不阻塞)
```
**Phase 1 完成后进入 Phase 2。**