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

55 KiB
Raw Blame History

Phase 1 · 垂直切片 MVP

周期34 周Week 1-4
前置条件Phase 0 全部完成标准通过
核心目标一个可玩的测试房间验证所有第三方库Animancer / PathBerserker2d / BD / Cinemachine / Feel / Addressables协作无冲突
产出物:玩家能移动/跳跃/攻击 → 敌人寻路并反击 → 死亡 → 存档复活VFX/SFX 全流程响应

进度更新2026-05-07Week 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. 实施顺序总览
  2. Week 1输入 + 玩家骨架 + 相机
  3. Week 2战斗基础 + 敌人骨架
  4. Week 3存档完善 + 死亡复活流 + UI
  5. Week 4VFX/音效 + 集成验证
  6. 第三方集成检查点
  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.md05_PlayerModule.md §1-417_CameraModule.md

2.1 InputReaderSO

文件Assets/Scripts/Input/InputReaderSO.cs

创建要点:

  • IInputActionCollection2 接口(通过 Input Actions 代码生成类自动满足)
  • OnEnable / OnDisable 中 Enable / Disable ActionMap
  • 所有 Action 委托在 OnEnable 中绑定到内部 _actions 上,OnDisable 取消绑定
  • 区分两个 ActionMapGameplayUI,通过专用方法切换(⚠️ 方法名以架构 04_InputModule §2 为准,不存在 SetActionMap(ActionMapType);对话中由 DialogueManager 调用 EnableUIInput(),无独立 Dialogue Map
// 最小可验证 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

// 挂在 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 仅实现:

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.assetPlayerStatsSO

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 定义在 PlayerAnimationConfigSOPhase 1 直接引用(⚠️ 字段类型为 AnimationClip非 ClipTransition架构 05_PlayerModule §17
  • 进入 State 时调用 _player.Animancer.Play(_config.Idle)⚠️ 字段名为 Idle非 IdleClip架构 05_PlayerModule §17
  • 验证点:切换状态时无 MissingReferenceException,动画无卡顿
// 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 实现范围:

// 全局双机 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

[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

[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

[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

[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 的房间专属机位)

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.md07_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

// 挂在武器 / 攻击判定子节点上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

  • PlayerHitBoxEnemyHurtBox 碰撞
  • EnemyHitBoxPlayerHurtBox 碰撞
  • 其他组合全部关闭

3.3 HurtBox

文件Assets/Scripts/Combat/HurtBox.cs

[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/§7HitBox 挂载在 Player Prefab 本体上(由 PlayerCombat 管理),不使用独立武器 Prefab。WeaponSO 是纯数据 SO不含 Prefab 引用。⚠️ WeaponInstance 类不存在。

文件

  • Assets/Scripts/Player/WeaponManager.cs
  • Assets/Scripts/Player/PlayerCombat.csPhase 1 简化版;完整版见 Phase 2 §2.3
// 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)。⚠️ 不存在 WeaponInstanceActiveWeaponInstance API。

3.5 AttackStateAnimancer 集成关键)

文件Assets/Scripts/Player/States/AttackState.cs

// 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 SetDestinationStop、PathBerserker2d NavAgent 封装

PathBerserker2d 集成要点

  • 在测试房间 Tilemap 上烘焙 NavSurface2D
  • EnemyNavAgent 持有 PathBerserker2d.NavAgent 组件,SetDestination 直接调用其 API
  • 验证点:敌人能在平台间寻路,不陷入地面,停止时无滑动

3.6 Behavior Designer 基础行为树

文件Assets/Scripts/Enemies/AI/BasicEnemyBT.csBD 外部行为树,非 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.md03_CoreModule.md §4-610_UIModule.md

4.1 SaveManager 完整实现

Phase 0 仅实现骨架Week 3 补全:

// 补充实现(⚠️ 返回类型为 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 接口

// 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

// ⚠️ 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

// 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 中定义的死亡/复活流程:

// ⚠️ 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.csPhase 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

// 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_PlayerDiedWaitForSeconds(1.5f) 后调用 Show()
  • "继续"按钮点击后调用 _onDeathScreenConfirmed.Raise()

5. Week 4VFX/音效 + 集成验证

参考文档18_VFXFeedbackModule.md

5.1 VFXPool

// 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

// ❗ 结构名、字段名、数组名以架构 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

// 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();
    }
}

HitInfoDamageInfo + HitPointHitConfirmedEventChannelSO 均已在 Day 2 §3.1 创建;VFXCatalogSO 中的 Entry.VfxRef 为 AssetReferenceGameObjectDamageInfo.FxType 为字段名(HitFxType 为类型名,字段名是 FxType,架构 06_CombatModule §1

5.4 HurtFlashController

// 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
// 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.mixerMaster/BGM/SFX/Ambient 四组 + Default/Paused/Dead/BossFight 快照)

5.6 IFeedbackPlayer + PlayerFeedback + EnemyFeedback

参考文档18_VFXFeedbackModule.md §2§4

// 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 { /* 全部空实现 */ }
// 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。