# 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 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(); _stats = GetComponent(); _inputBuffer = GetComponent(); _animancer = GetComponent(); _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); /// roomSize ≈ viewportSize 时为固定镜头(无滚动)。 public bool IsFixedCamera { get; } /// 返回 PolygonCollider2D,供 CameraTriggerZone 传入 CinemachineConfiner2D。 public Collider2D Collider { get; private set; } private void Awake() => Collider = GetComponent(); // 每次 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; /// 相机视口世界单位尺寸(320×180 / 16PPU = 20×11.25)。 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 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(); if (hurtBox != null) { hurtBox.ReceiveDamage(info); // ⚠️ 必须用 ReceiveDamage(见架构 06_CombatModule §5) OnHitConfirmed?.Invoke(info); return; } // ② 命中 IBreakable(机关/障碍物) var breakable = other.GetComponent(); breakable?.TryInteract(info); } private Dictionary _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(); 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 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 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(); _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(); /// /// 触发一次受击白闪;由 IFeedbackPlayer.PlayTakeHit 调用。 /// ⚠️ 返回 async UniTaskVoid(非 IEnumerator Coroutine),使用 UniTask.Delay(架构 18_VFXFeedbackModule §9) /// 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 并应用音量 /// 唯一音量写入入口。exposedParam = AudioMixerKeys.* 常量。 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 } /// 空实现,用于测试和无反馈需求场景。 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。**