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

112 KiB
Raw Blame History

Phase 2 · 核心玩法扩展

周期45 周Week 59
前置条件Phase 1 全部完成标准通过(第三方库协作验证无误)
核心目标:完整的玩家战斗/移动系统、护盾/弹反、状态效果、难度系统、AudioMixer 快照、敌人扩展
产出物:玩家拥有所有移动能力(冲刺/登墙/游泳、完整连击链、弹反机制23 种敌人类型AudioMixer 快照需求已整合


目录

  1. 实施顺序总览
  2. Week 5玩家 FSM 完整扩展
  3. Week 6护盾 + 弹反 + 战斗深化
  4. Week 7状态效果 + 动画事件 + 完整 VFX
  5. Week 8难度系统 + AudioMixer 快照
  6. Week 9敌人扩展 + BossBase 骨架
  7. 完成标准检查清单

1. 实施顺序总览

Week 5: DashState → WallSlideState → AirAttackState → DownAttack/UpAttack
          ↓
        FormController → WeaponManager → PlayerCombat 完整(含 SoulSkill 触发)
          ↓
        PlayerStats 完整SoulPower/SpiritPower/Geo/Ability 系统)

Week 6: ShieldComponent → HurtBox 修正(护盾管线)
          ↓
        ParrySystem → ParryConfigSO → ParryState
          ↓
        PoiseSystem霸体→ HurtState 更新(考虑霸体打断)
          ↓
        连击链完整3 段地面 + 2 段空中 + 下劈 + 上劈)

Week 7: AnimationEventType 枚举 → IAnimationEventHandler接口架构 24 §4
          ↓
        StatusEffect抽象基类架构 06 §11非 StatusEffectBase→ Poison / Burn / Stagger 具体实现
          ↓
        StatusEffectManager → HurtBox 集成
          ↓
        完整 VFXParryFlash / FormSwitch / StatusEffect 粒子)

Week 8: AudioMixer 快照配置BGM形态切换/战斗/平静快照)
          ↓
        DifficultyManager → DifficultyScalerSO × 4 → PlayerStats/EnemyStats 注入钩子

Week 9: EnemyBase 子类RangedEnemy远程+ FlyingEnemy飞行巡逻
          ↓
        LootResolver + LootTableSO
          ↓
        BossBase 骨架HP 分段、Phase 切换、Arena 锁定)

2. Week 5玩家 FSM 完整扩展

参考文档05_PlayerModule.md §2

2.1 新增 State 文件列表

Assets/Scripts/Player/States/
├── DashState.cs          ← 地面冲刺(位移 + 无敌帧)
├── AerialDashState.cs    ← 空中冲刺(架构 05 §12独立状态消耗 MaxAerialDashes 次数)⚠️ 与 DashState 分离
├── WallSlideState.cs     ← 蹬墙(下滑 + 蹬墙跳触发)
├── WallJumpState.cs      ← 蹬墙跳
├── AirAttackState.cs     ← 空中攻击
├── DownAttackState.cs    ← 下劈Physics2D.OverlapPoint 踩踏检测)
├── UpAttackState.cs      ← 上劈
├── HurtState.cs          ← 受击硬直
├── DeadState.cs          ← 死亡(冻结物理)
├── SpringState.cs        ← 使用灵泉(治疗动画)
└── ParryState.cs         ← 弹反预备Week 6

2.2 DashState 实现要点

public class DashState : PlayerStateBase
{
    public override void OnStateEnter()
    {
        // 1. 消耗冲刺次数Phase 2 先不加多段冲刺计数,一次冲刺)
        // 2. 开启无敌帧 BeginInvincibility(_config.DashInvincibleDuration)
        // 3. 施加冲刺速度 _player.Movement.Dash(facingDir, _config.DashSpeed)
        // 4. 启动持续时间计时器 → 到期进入 Fall 或 Idle
        // 5. 播放冲刺动画 + _player.Feedback.PlayDash()
    }
    // 冲刺期间忽略重力SetGravityScale(0)),结束时恢复
}

2.3 FormController + WeaponManager

05_PlayerModule.md §6/§7 实现:

FormConfigSO.asset × 1    ← 单一资产forms[0..2] = Sky/Earth/DeathAssets/Data/Player/Forms/ ⚠️ 非 3 个 FormConfigSO
FormSO.asset × 3          ← Sky / Earth / Death存于 FormConfigSO.forms[] 数组中,架构 05 §18
WeaponSO.asset × 3        ← 三形态对应武器数据Assets/Data/Combat/Weapons/
// ⚠️ 无 WeaponInstance PrefabHitBox 直接挂载在 Player Prefab 上PlayerCombat._hitBoxGround/Up/Down/Air架构 05 §5/§7

FormController.SwitchForm() 实现(架构 05_PlayerModule §6

  1. 更新 CurrentForm
  2. 发布事件频道 _onFormChanged.Raise(_config.GetFormIndex(newForm))UI / Save 用)
  3. 触发 C# 事件 OnFormChanged?.Invoke()⚠️ WeaponManager 在 OnEnable 自订阅,非直接调用 WeaponManager
  4. 调用 _paletteSwapSystem?.ApplyPalette(newForm.formType)⚠️FormType 枚举值,架构 18_VFXFeedbackModule §10 定义 ApplyPalette(FormType form) 接受 FormType架构 05 §6 代码片段错误地传了 newForm.formAccentColorColor与架构 18 §10 不符,以架构 18 §10 为准;FormSO 需补充 public FormType formType 字段,架构 05 §18 FormSO 定义中遗漏此字段)
  5. 调用 _skillManager?.UpdateSkillSet(newForm)⚠️ 传 FormSO 1 个参数,非 3 个 FormSkillSO架构 05 §6
  6. 发布 _onSkillSetChanged?.Raise()EVT_SkillSetChanged通知 SkillHUD 刷新,架构 09_ProgressionModule §11

PlayerCombat(按 05_PlayerModule.md §5 完整实现Phase 1 §3.4 简化版补全):

// Assets/Scripts/Player/PlayerCombat.cs
// ⚠️ 架构 05_PlayerModule §5HitBox 直接挂在 Player Prefab 上,不经过 WeaponInstance无此类
public class PlayerCombat : MonoBehaviour
{
    [SerializeField] private WeaponManager _weaponManager;
    [SerializeField] private SkillManager  _skillManager;

    // ── 玩家角色 Prefab 上的 HitBox固定挂载不依赖武器 Prefab────────────
    [SerializeField] private HitBox _hitBoxGround;
    [SerializeField] private HitBox _hitBoxUp;
    [SerializeField] private HitBox _hitBoxDown;
    [SerializeField] private HitBox _hitBoxAir;

    void OnEnable()
    {
        _weaponManager.OnWeaponChanged += RefreshWeaponData;
        if (_weaponManager.ActiveWeapon != null)
            RefreshWeaponData(_weaponManager.ActiveWeapon);
    }

    void OnDisable() => _weaponManager.OnWeaponChanged -= RefreshWeaponData;

    void RefreshWeaponData(WeaponSO weapon) { /* 缓存动画片段,刷新 HitBox DamageSource */ }

    public void SetComboSegmentSource(ComboState segment)
    {
        WeaponSO w = _weaponManager.ActiveWeapon;
        DamageSourceSO src = segment switch
        {
            ComboState.Attack1    => w?.attack1Source,
            ComboState.Attack2    => w?.attack2Source,
            ComboState.Attack3    => w?.attack3Source,
            ComboState.AirAttack  => w?.airAttackSource,
            ComboState.UpAttack   => w?.upAttackSource,
            ComboState.DownAttack => w?.downAttackSource,
            _                     => null,
        };
        if (src != null) _hitBoxGround.SetDamageSource(src);
    }

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

    // 命中回调 → 增加灵力
    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,
    };
}

WeaponSO(按 05_PlayerModule.md §7 完整实现):

// Assets/Scripts/Combat/WeaponSO.cs
// ⚠️ 纯数据 SO不含 Prefab架构 05 §7HitBox 固定在 Player Prefab 上
[CreateAssetMenu(menuName = "Combat/Weapon")]
public class WeaponSO : ScriptableObject
{
    [Header("基础信息")]
    public string     weaponId;       // 全局唯一 ID如 "Weapon_SkyBlade"
    public string     displayName;    // "天裂刃"
    public Sprite     icon;
    public WeaponType weaponType;     // ⚠️ WeaponType 枚举(架构 05 §7

    [Header("连击动画Animancer ClipTransition")]
    public ClipTransition attack1Clip;
    public ClipTransition attack2Clip;
    public ClipTransition attack3Clip;
    public ClipTransition airAttackClip;
    public ClipTransition upAttackClip;
    public ClipTransition downAttackClip;

    [Header("伤害来源(每段独立 DamageSourceSO")]
    public DamageSourceSO attack1Source;
    public DamageSourceSO attack2Source;
    public DamageSourceSO attack3Source;
    public DamageSourceSO airAttackSource;
    public DamageSourceSO upAttackSource;
    public DamageSourceSO downAttackSource;

    [Header("HitBox 配置")]
    /// <summary>零向量 = 使用 PlayerCombat 中的默认尺寸</summary>
    public Vector2 hitBoxSizeOverride;
    public Vector2 hitBoxOffsetOverride;

    [Header("武器特效")]
    public WeaponVFXConfig vfxConfig;  // ⚠️ 类型为 WeaponVFXConfig非 MMF_Player架构 05 §7

    public DamageSourceSO GetSourceByDir(AttackDirection dir) => dir switch
    {
        AttackDirection.Ground => attack1Source,
        AttackDirection.Up     => upAttackSource,
        AttackDirection.Down   => downAttackSource,
        AttackDirection.Air    => airAttackSource,
        _                      => null,
    };
}

// ⚠️ WeaponType 枚举(架构 05 §7
public enum WeaponType
{
    SkyBlade,    // 天魂:裂空刃(高频轻击)
    EarthHammer, // 地魂:地震锤(低频重击,范围大)
    LifeScythe,  // 命魂:命镰(穿透,直线斩)
    Custom,      // 护符替换或未来扩展武器
}

// ⚠️ WeaponVFXConfig内嵌类[Serializable]),架构 05 §7包含 FeedbackPresetSO 而非 MMF_Player
[Serializable]
public class WeaponVFXConfig
{
    [Tooltip("切换到此武器时播放的特效(形态切换音效/粒子)")]
    public FeedbackPresetSO onEquipFeedback;   // ⚠️ FeedbackPresetSO非 MMF_Player架构 05 §7

    [Tooltip("武器挥斩拖尾 Prefabnull = 不显示拖尾)")]
    public GameObject weaponTrailPrefab;

    public Color trailColor = Color.white;

    [Tooltip("命中特效类型覆盖null = 使用 DamageSourceSO.HitFxType")]
    public HitFxType? hitFxOverride;
}

WeaponManager(按 05_PlayerModule.md §7 完整实现,含护符 Override API

// Assets/Scripts/Player/WeaponManager.cs
// ⚠️ 架构 05 §7含护符替换武器的 Override 字典_overrides非简单直接切换
public class WeaponManager : MonoBehaviour
{
    [SerializeField] private FormController _formController;

    public WeaponSO ActiveWeapon { get; private set; }
    public event Action<WeaponSO> OnWeaponChanged;

    // ⚠️ 护符注入的武器覆盖Key = FormSO.formIdValue = 替换武器(架构 05 §7
    readonly Dictionary<string, WeaponSO> _overrides = new();

    void Awake()
    {
        if (_formController.CurrentForm != null)
            ApplyWeapon(_formController.CurrentForm);
    }

    void OnEnable()  => _formController.OnFormChanged += HandleFormChanged;
    void OnDisable() => _formController.OnFormChanged -= HandleFormChanged;

    void HandleFormChanged() => ApplyWeapon(_formController.CurrentForm);

    void ApplyWeapon(FormSO form)
    {
        WeaponSO next = _overrides.TryGetValue(form.formId, out var ov)
            ? ov
            : form.defaultWeapon;

        if (next == ActiveWeapon) return;
        ActiveWeapon = next;
        OnWeaponChanged?.Invoke(next);
        // ⚠️ vfxConfig.onEquipFeedback 为 FeedbackPresetSO架构 05 §7非 MMF_Player
        next?.vfxConfig.onEquipFeedback?.PlayFeedbacks(gameObject);
    }

    // ─────────── 护符 Override API由 WeaponOverrideEffect 调用,架构 05 §7───────────
    /// <summary>为指定形态设置武器覆盖。formId 为空 = 覆盖所有形态。</summary>
    public void SetOverride(string formId, WeaponSO weapon)
    {
        if (string.IsNullOrEmpty(formId))
            foreach (var f in _formController.AllForms) _overrides[f.formId] = weapon;
        else
            _overrides[formId] = weapon;
        ApplyWeapon(_formController.CurrentForm);
    }

    /// <summary>移除覆盖恢复默认武器。formId 为空 = 移除所有形态覆盖。</summary>
    public void ClearOverride(string formId)
    {
        if (string.IsNullOrEmpty(formId))
            foreach (var f in _formController.AllForms) _overrides.Remove(f.formId);
        else
            _overrides.Remove(formId);
        ApplyWeapon(_formController.CurrentForm);
    }
}

WeaponOverrideEffect(护符效果实现,按 05_PlayerModule.md §7

// Assets/Scripts/Equipment/CharmEffects/WeaponOverrideEffect.cs
// ⚠️ 实现 ICharmEffect 接口(架构 05 §7非直接继承 MonoBehaviour
namespace BaseGames.Equipment
{
    [Serializable]
    public class WeaponOverrideEffect : ICharmEffect
    {
        [Tooltip("目标形态 ID留空 = 所有形态)")]
        public string   targetFormId;
        [Tooltip("替换武器 SO")]
        public WeaponSO replacementWeapon;

        public void OnEquip(EquipmentContext ctx)
            => ctx.WeaponMgr.SetOverride(targetFormId, replacementWeapon);

        public void OnUnequip(EquipmentContext ctx)
            => ctx.WeaponMgr.ClearOverride(targetFormId);

        public string GetEffectDescription()
        {
            string formStr = string.IsNullOrEmpty(targetFormId) ? "所有形态" : targetFormId;
            string wName   = replacementWeapon != null ? replacementWeapon.displayName : "null";
            return $"{formStr}的武器替换为 [{wName}]";
        }
    }
}
// EquipmentContext 需新增 WeaponManager WeaponMgr 字段(见 17_EquipmentSystem §3

2.4 PlayerStats 完整实现

补充 Phase 1 未实现的部分:

功能 实现要点
SoulPower 击杀/命中增加;使用魂技能消耗;上限 100
SpiritPower 时间自动回复(SpiritRegenRate/秒);使用魄技能消耗
SpringCharges 击杀积分达阈值增加;存档点恢复满值
Geo 击杀掉落收集;购买消耗
UnlockedAbilities HashSet<AbilityType> 持久存储;HasAbility 供状态检查

⚠️ AddSoul vs AddSoulPower 命名不一致PlayerStats.cs API架构 05 §4定义 AddSoulPower(int amount),但 ParrySystem.HandleSuccessfulParry(架构 06 §8调用 _playerStats.AddSoul(soulGain)以架构 06 §8 为准,即 AddSoul 为正确方法名(可能是 SO 配置文件的旧名称);实现 PlayerStats.cs 时须确保两个调用名称一致。

PlayerStatsSO 完整定义(按 05_PlayerModule.md §16

// Assets/Scripts/Player/PlayerStatsSO.cs
[CreateAssetMenu(menuName = "Player/Stats")]
public class PlayerStatsSO : ScriptableObject
{
    [Header("HP")]
    public int MaxHP             = 5;    // 初始心脏容器数 × 2半格为1

    [Header("Soul Power")]
    public int MaxSoulPower      = 100;

    [Header("Spirit Power")]
    public int MaxSpiritPower    = 100;
    public int SpiritRegenRate   = 5;    // 每秒回复量

    [Header("Spring")]
    public int MaxSpringCharges  = 3;    // 初始灵泉次数上限
    public int SpringHealAmount  = 2;    // ⚠️ 每次回复 HP 量(半格),架构 05 §16
    public int SpringKillThreshold = 4; // ⚠️ 增加 1 次灵泉所需击杀点数,架构 05 §16

    [Header("Invincibility")]
    public float InvincibilityDuration = 0.6f;

    [Header("Geo")]
    public int InitialGeo        = 0;
}

PlayerMovementConfigSO 完整定义(按 05_PlayerModule.md §15,含所有 Wall 参数):

// Assets/Scripts/Player/PlayerMovementConfigSO.cs
[CreateAssetMenu(menuName = "Player/MovementConfig")]
public class PlayerMovementConfigSO : ScriptableObject
{
    [Header("Ground Movement")]
    public float RunSpeed           = 7f;
    public float Acceleration       = 50f;
    public float Deceleration       = 80f;

    [Header("Jump")]
    public float JumpForce          = 18f;
    public float CoyoteTime         = 0.12f;    // 秒
    public float FallGravityMult    = 2.5f;     // 下落时额外重力倍率
    public float MaxFallSpeed       = 20f;

    [Header("Dash")]
    public float DashSpeed          = 20f;
    public float DashDuration       = 0.18f;    // 秒
    public float DashCooldown       = 0.4f;     // 秒
    public int   MaxAerialDashes    = 1;        // ⚠️ 空中冲刺次数AerialDashState 消耗此计数)

    [Header("Wall")]
    public float WallSlideSpeed     = 2f;
    public float WallJumpForceX     = 12f;
    public float WallJumpForceY     = 16f;

    [Header("Wall — 检测")]
    public float WallRayLength      = 0.55f;  // 墙壁射线长度
    public float WallRayOffsetY     = 0.2f;   // 上/下两根射线相对中心的 Y 偏移

    [Header("Wall — WallGrab")]
    public float WallGrabMaxHeightGain = 0.5f; // ⚠️ 允许的最大垂直高度增益(防无限蹬墙),架构 05 §15
    public float WallGrabReleaseDelay  = 0.08f; // ⚠️ 离开墙面后的延迟释放(避免误判),架构 05 §15

    [Header("Wall — WallJump")]
    public float WallJumpBackForceX  = 14f;   // ⚠️ 背墙跳(同向)水平速度,架构 05 §15
    public float WallJumpAwayForceX  = 10f;   // ⚠️ 对墙跳(反向)水平速度,架构 05 §15
    public float WallJumpAwayForceY  = 18f;   // ⚠️ 对墙跳垂直速度,架构 05 §15
    public float WallJumpInputLockDuration = 0.15f; // ⚠️ 对墙跳后短暂锁定水平输入,架构 05 §15

    [Header("General")]
    public float DefaultGravityScale = 3f;
}

2.5 PlayerWallDetector + PlayerController IPoiseSource 扩展

PlayerWallDetector(独立组件,架构 05_PlayerModule.md §13

// Assets/Scripts/Player/PlayerWallDetector.cs
// ⚠️ 独立组件,不嵌入 PlayerMovement架构 05 §13
// WallSlideState 通过 _owner.WallDetector.IsTouchingWall 访问
[RequireComponent(typeof(PlayerMovement))]
public class PlayerWallDetector : MonoBehaviour
{
    [SerializeField] private PlayerMovementConfigSO _config;

    public bool IsTouchingWall  { get; private set; }
    public int  WallDirection   { get; private set; }   // +1 = 右墙,-1 = 左墙

    private void FixedUpdate()
    {
        bool rightWall = CheckSide(Vector2.right);
        bool leftWall  = CheckSide(Vector2.left);

        IsTouchingWall = rightWall || leftWall;
        WallDirection  = rightWall ? 1 : leftWall ? -1 : 0;
    }

    // ⚠️ 每侧发两根射线TopRay + BottomRay两根均命中才返回 true防卡角误判
    private bool CheckSide(Vector2 dir)
    {
        Vector2 center = transform.position;
        float   len    = _config.WallRayLength;
        float   oy     = _config.WallRayOffsetY;
        int     layer  = LayerMask.GetMask("Wall");   // Layer 10见 06_CombatModule §12

        bool top = Physics2D.Raycast(center + Vector2.up   * oy, dir, len, layer);
        bool bot = Physics2D.Raycast(center + Vector2.down * oy, dir, len, layer);
        return top && bot;
    }
}

PlayerController 补充(架构 05_PlayerModule.md §13 + 06_CombatModule §13

// PlayerController.cs 追加以下内容(与 Phase 1 骨架 merge
// ⚠️ 须实现 IPoiseSource 接口(架构 06_CombatModule §13
public partial class PlayerController : MonoBehaviour, IPoiseSource
{
    [Header("Wall Detection")]
    [SerializeField] private PlayerWallDetector _wallDetector;  // ⚠️ 独立组件引用(架构 05 §13

    public PlayerWallDetector WallDetector => _wallDetector;  // WallSlideState / WallJumpState 使用

    // ── IPoiseSource 实现(架构 06_CombatModule §13────────────────────────
    private PoiseWindowConfig _currentPoiseWindow;
    private AnimancerState    _activeState;  // Animancer 提供当前动画状态

    public void SetPoiseWindow(PoiseWindowConfig window)  // 由 AttackState / SkillState.OnStateEnter() 调用
        => _currentPoiseWindow = window;

    public void ClearPoiseWindow()                        // 由 OnStateExit() 调用
        => _currentPoiseWindow = default;

    /// <summary>IPoiseSource 实现:每帧查询当前霸体等级(攻击动画期间)</summary>
    public PoiseLevel GetCurrentPoiseLevel()
    {
        if (_currentPoiseWindow.Level == PoiseLevel.None) return PoiseLevel.None;
        if (_activeState == null) return PoiseLevel.None;

        float t = _activeState.NormalizedTime % 1f;
        bool inWindow = t >= _currentPoiseWindow.NormalizedStart
                     && t <= _currentPoiseWindow.NormalizedEnd;
        return inWindow ? _currentPoiseWindow.Level : PoiseLevel.None;
    }
}
// 资产Player Prefab Inspector 需挂载 PlayerWallDetector 组件并赋值到 _wallDetector

3. Week 6护盾 + 弹反 + 战斗深化

参考文档06_CombatModule.md §8-1320_ShieldModule.md

3.0 护盾数据层 SO 与接口

// Assets/Scripts/Player/Shield/ShieldConfigSO.cs
// ⚠️ 按 Architecture 20_ShieldModule §3 实现
[CreateAssetMenu(menuName = "Player/ShieldConfig")]
public class ShieldConfigSO : ScriptableObject
{
    [Header("基础参数")]
    [Min(1)]
    public int   MaxShieldHP            = 60;
    [Range(0f, 1f)]
    public float DamageAbsorptionRatio  = 1.0f;

    [Header("恢复")]
    [Min(0f)]
    public float RechargeDelay          = 2.5f;   // 最后受击后静默时间(秒)
    [Min(0f)]
    public float RechargeRate           = 20f;    // 每秒恢复量

    [Header("破碎惩罚")]
    [Min(0f)]
    public float BrokenPenaltyDuration  = 3.0f;  // 护盾破碎后无法恢复的时间

    [Header("存档点全量恢复")]
    public bool  FullRechargeOnSavePoint = true;

    [Header("弹反加成P1")]
    [Range(0f, 1f)]
    public float ParryRestoreRatio      = 0.3f;  // 成功格挡时恢复护盾耐久比例
}

// Assets/Scripts/Player/Shield/IShieldable.cs
// ⚠️ 按 Architecture 20_ShieldModule §5 实现
namespace BaseGames.Player.Shield
{
    public interface IShieldable
    {
        bool HasShield       { get; }
        int  CurrentShieldHP { get; }
        int  MaxShieldHP     { get; }
        int  AbsorbDamage(int incomingDamage);  // ⚠️ 返回穿透伤害剩余量0 = 全部被吸收,>0 = 穿透量(架构 20_ShieldModule §5
        void FullRecharge();
        void OnParrySuccess();
    }
}

3.1 ShieldComponent

20_ShieldModule.md 实现完整护盾系统:

// 关键实现点
public class ShieldComponent : MonoBehaviour, IShieldable
{
    public bool HasShield => _currentShieldHP > 0 && !_isBroken;  // ⚠️ 字段名 _currentShieldHP架构 20_ShieldModule §4

    public int AbsorbDamage(int incomingDamage)
    {
        _timeSinceLastHit = 0f;   // ⚠️ 重置静默计时(架构 20_ShieldModule §4

        int absorbed    = Mathf.RoundToInt(incomingDamage * _config.DamageAbsorptionRatio);
        int passThrough = incomingDamage - absorbed;

        _currentShieldHP -= absorbed;

        if (_currentShieldHP <= 0)
        {
            // 护盾破碎:多余伤害穿透(⚠️ 架构 20_ShieldModule §4
            passThrough     += Mathf.Abs(_currentShieldHP);
            _currentShieldHP = 0;
            _isBroken        = true;
            _brokenTimer     = 0f;
            _onShieldBroken.Raise();  // ⚠️ Raise(),非 RaiseEvent()(架构 02 §2
        }

        _onShieldHPChanged.Raise(_currentShieldHP);   // ⚠️ 更新 ShieldBarUI架构 20_ShieldModule §4
        return passThrough;
    }

    // ⚠️ 存档加载时恢复护盾状态,由 PlayerController.LoadFromSaveData() 调用(架构 20_ShieldModule §4
    public void SetShieldHP(int hp, bool isBroken)
    {
        _currentShieldHP  = Mathf.Clamp(hp, 0, _config.MaxShieldHP);
        _isBroken         = isBroken;
        _brokenTimer      = 0f;
        _timeSinceLastHit = 0f;
    }

    private void Update()
    {
        // 破碎冷却计时(递增到 BrokenPenaltyDuration
        if (_isBroken)
        {
            _brokenTimer += Time.deltaTime;
            if (_brokenTimer >= _config.BrokenPenaltyDuration)  // ⚠️ BrokenPenaltyDuration非 BreakPenaltyDuration
            {
                _isBroken    = false;
                _brokenTimer = 0f;
            }
            return;
        }
        // 延迟恢复_timeSinceLastHit 累计受击后静默时间,架构 20_ShieldModule §4
        if (_currentShieldHP < _config.MaxShieldHP)
        {
            _timeSinceLastHit += Time.deltaTime;
            if (_timeSinceLastHit >= _config.RechargeDelay)
                _currentShieldHP = Mathf.Min(
                    _config.MaxShieldHP,
                    _currentShieldHP + Mathf.RoundToInt(_config.RechargeRate * Time.deltaTime)
                );
        }
    }
}

3.2 ParrySystem

06_CombatModule.md §8 实现 5 阶段状态机Inactive → Startup → Active → EndLag/CounterWindow → Inactive

Inactive → [按弹反键] → Startup(0.05s) → Active(0.28s) → [命中可弹反攻击] → ParrySuccess
        ↓Startup/Active 超时)                                                    ↓
      EndLag(0.10s) → Inactive                              CounterWindow(0.5s) → Inactive
// Assets/Scripts/Parry/ParrySystem.cs
// ⚠️ 5 阶段状态机;事件频道为 ParryInfoEventChannelSO非 VoidEventChannelSO架构 06 §8

/// <summary>弹反成功时通过 OnParrySuccess 频道广播的载荷(架构 06 §8</summary>
public struct ParryInfo
{
    public DamageInfo      OriginalDamage;       // 被弹反的原始攻击信息
    public bool            IsPerfect;            // 是否为完美弹反
    public Projectile      HitProjectile;        // 若弹反了投射物,此字段非 null
    public DamageSourceSO  ReflectDamageSource;  // 反击伤害来源ParryCounterMultiplier 倍率已应用)
}

/// <summary>弹反阶段枚举(架构 06 §8</summary>
public enum ParryPhase { Inactive, Startup, Active, EndLag, CounterWindow }

public class ParrySystem : MonoBehaviour
{
    [SerializeField] private InputReaderSO     _inputReader;
    [SerializeField] private PlayerController  _controller;
    [SerializeField] private ParryConfigSO     _config;
    [SerializeField] private HurtBox           _playerHurtBox;
    [SerializeField] private PlayerStats       _playerStats;

    [Header("Event Channels - Raise")]
    // ⚠️ ParryInfoEventChannelSO 携带 ParryInfo 载荷(非 VoidEventChannelSO架构 06 §8
    [SerializeField] private ParryInfoEventChannelSO _onParrySuccess;

    // ── 运行时状态 ────────────────────────────────────────────────
    private ParryPhase _phase       = ParryPhase.Inactive;
    private float      _phaseTimer;
    private bool       _isInCounterWindow;

    public ParryPhase CurrentPhase      => _phase;
    public bool       IsInCounterWindow => _isInCounterWindow;

    private void OnEnable()  => _inputReader.ParryEvent += TryActivateParry;
    private void OnDisable() => _inputReader.ParryEvent -= TryActivateParry;

    private void Update()
    {
        if (_phase == ParryPhase.Inactive) return;

        // ⚠️ 使用 unscaledDeltaTime — 子弹时间期间计时不受影响(架构 06 §8
        _phaseTimer -= Time.unscaledDeltaTime;
        if (_phaseTimer <= 0f) AdvancePhase(success: false);
    }

    // ── 外部调用入口 ──────────────────────────────────────────────

    /// <summary>玩家按下弹反键时由 InputReader 事件触发</summary>
    private void TryActivateParry()
    {
        if (_phase != ParryPhase.Inactive) return;
        if (!_playerStats.HasAbility(AbilityType.Parry)) return;

        EnterPhase(ParryPhase.Startup, _config.StartupDuration);
        _controller.ForceState(PlayerStateType.Parry);
    }

    /// <summary>
    /// HurtBox.ReceiveDamage 在 Active 阶段调用此方法判断是否弹反成功。
    /// 返回 true 表示弹反成功,调用方应跳过普通受击流程。(架构 06 §8
    /// </summary>
    public bool TryParryDamage(DamageInfo info)
    {
        if (_phase != ParryPhase.Active) return false;
        if (info.Flags.HasFlag(DamageFlags.Unblockable)) return false;
        if (!info.Flags.HasFlag(DamageFlags.CanBeParried)) return false;
        if (info.Flags.HasFlag(DamageFlags.PerfectParryOnly) && !IsInPerfectWindow()) return false;

        bool isPerfect = IsInPerfectWindow();
        HandleSuccessfulParry(info, isPerfect);
        return true;
    }

    // ── 私有实现 ──────────────────────────────────────────────────

    private bool IsInPerfectWindow()
    {
        // Active 阶段开始后的前 PerfectParryThreshold 秒内命中为完美弹反
        float activeElapsed = _config.WindowDuration - _phaseTimer;
        return activeElapsed <= _config.PerfectParryThreshold;
    }

    /// <summary>弹反成功的统一处理入口TryParryDamage 和 ParryableProjectile 均调用此处)</summary>
    public void HandleSuccessfulParry(DamageInfo originalDamage, bool isPerfect,
                                      Projectile hitProjectile = null)
    {
        // ⚠️ _playerStats.AddSoul()(架构 06 §8 使用 AddSoul非 AddSoulPower
        int soulGain = isPerfect
            ? _config.SoulGainOnParry + _config.SoulGainOnPerfect
            : _config.SoulGainOnParry;
        _playerStats.AddSoul(soulGain);

        // 护盾恢复
        if (_controller.TryGetComponent<ShieldComponent>(out var shield))
            shield.OnParrySuccess();

        // 子弹时间(仅完美弹反)
        if (isPerfect)
            StartCoroutine(ApplyBulletTime());

        // ⚠️ _onParrySuccess 为 ParryInfoEventChannelSO携带 ParryInfo 载荷
        var parryInfo = new ParryInfo
        {
            OriginalDamage      = originalDamage,
            IsPerfect           = isPerfect,
            HitProjectile       = hitProjectile,
            ReflectDamageSource = null,  // 实际项目中设为预配置的 ReflectDamageSourceSO
        };
        _onParrySuccess.Raise(parryInfo);

        // 进入 CounterWindow
        EnterPhase(ParryPhase.CounterWindow, _config.CounterWindowDuration);
        _isInCounterWindow = true;
    }

    private void AdvancePhase(bool success)
    {
        switch (_phase)
        {
            case ParryPhase.Startup:
                EnterPhase(ParryPhase.Active, _config.WindowDuration);
                break;
            case ParryPhase.Active:
                EnterPhase(ParryPhase.EndLag, _config.EndlagDuration);
                break;
            case ParryPhase.EndLag:
            case ParryPhase.CounterWindow:
                EnterPhase(ParryPhase.Inactive, 0f);
                _isInCounterWindow = false;
                break;
        }
    }

    private void EnterPhase(ParryPhase phase, float duration)
    {
        _phase      = phase;
        _phaseTimer = duration;
    }

    private System.Collections.IEnumerator ApplyBulletTime()
    {
        Time.timeScale = _config.BulletTimeScale;
        yield return new WaitForSecondsRealtime(_config.BulletTimeDuration);
        Time.timeScale = 1f;
    }
}

ParryConfigSO(按 06_CombatModule.md §9 完整字段):

// Assets/ScriptableObjects/Combat/ParryConfig.asset
[CreateAssetMenu(menuName = "Combat/ParryConfig")]
public class ParryConfigSO : ScriptableObject
{
    [Header("阶段时长")]
    public float StartupDuration       = 0.05f;  // ⚠️ 前摇Startup时长架构 06 §9
    public float WindowDuration        = 0.28f;  // ⚠️ 弹反有效窗口Active时长
    public float EndlagDuration        = 0.10f;  // ⚠️ 后摇EndLag时长
    public float CounterWindowDuration = 0.5f;   // ⚠️ 弹反成功后反击窗口时长(秒)

    [Header("完美弹反判定")]
    public float PerfectParryThreshold = 0.05f;  // ⚠️ Active 阶段开始后的完美弹反窗口(秒)

    [Header("冷却")]
    public float ParryCooldown         = 0.3f;   // 弹反动作冷却(秒)

    [Header("灵力奖励")]
    public int   SoulGainOnParry       = 33;     // ⚠️ 普通弹反获得灵力(架构 06 §9
    public int   SoulGainOnPerfect     = 50;     // ⚠️ 完美弹反额外获得灵力(累计 +83

    [Header("反击伤害")]
    public float ParryCounterMultiplier = 3.0f;  // ⚠️ 弹反反击伤害倍率

    [Header("子弹时间(完美弹反)")]
    public float BulletTimeScale       = 0.25f;  // ⚠️ 完美弹反触发时的时间缩放
    public float BulletTimeDuration    = 0.2f;   // ⚠️ 子弹时间持续时长(秒,实际时间)

    [Header("硬直")]
    public float StaggerDuration       = 0.8f;   // ⚠️ 被弹反敌人的受击硬直时长(秒)
}

ParryState 实现要点:

  • 进入:EnterPhase(Startup) 已在 TryActivateParry() 完成State 播放弹反预备动画
  • Active 阶段HurtBox 调用 TryParryDamage(info) — 5 阶段自动推进
  • 成功:HandleSuccessfulParry() → CounterWindow → 可切换到 AttackState反击
  • 超时未命中EndLag → Inactive → 返回 IdleState

3.3 PoiseSystem霸体

架构核心机制06_CombatModule.md §13):霸体使用等级比较,而非数值耐久条。

  • 攻击方:DamageInfo.BreakBreakLevel 枚举)——此次攻击能打断多高的霸体
  • 承受方:IPoiseSource.GetCurrentPoiseLevel()PoiseLevel 枚举)——当前帧拥有多高的霸体
  • 判定公式:(int)info.Break >= (int)currentPoise → 打断成功
  • 玩家和敌人均可拥有霸体(玩家在攻击/技能动画的特定帧,敌人在超甲状态)
// 实体实现此接口以提供当前霸体等级(玩家侧由 PlayerController 实现,敌人侧由 EnemyPoiseComponent 实现)
public interface IPoiseSource
{
    /// <summary>返回当前帧的霸体等级(受时间窗口/状态机控制)</summary>
    PoiseLevel GetCurrentPoiseLevel();
}

// 地址: Assets/Scripts/Combat/PoiseWindowConfig.cs
// 描述某个状态/技能在特定动画时间段内的霸体等级
[System.Serializable]
public struct PoiseWindowConfig
{
    public PoiseLevel Level;           // 此窗口期间的霸体等级
    public float      NormalizedStart; // 动画归一化时间起点0~1
    public float      NormalizedEnd;   // 动画归一化时间终点0~1
}

// 地址: Assets/Scripts/Enemies/EnemyPoiseComponent.cs
// 敌人霸体组件Awake() 自动注入到同节点 HurtBox架构 06 §13
// ⚠️ [RequireComponent(typeof(EnemyBase))](架构 06 §13
[RequireComponent(typeof(EnemyBase))]
public class EnemyPoiseComponent : MonoBehaviour, IPoiseSource
{
    [SerializeField] private PoiseLevel _defaultPoiseLevel = PoiseLevel.None;
    private PoiseLevel _currentPoiseLevel;

    private void Awake()
    {
        _currentPoiseLevel = _defaultPoiseLevel;
        // ⚠️ Awake 自动注入到同节点 HurtBox架构 06 §13
        if (TryGetComponent<HurtBox>(out var hurtBox))
            hurtBox.SetPoiseSource(this);
    }

    // 超甲状态由 EnemyBase 或 BehaviorDesigner 任务直接调用
    public void SetPoiseLevel(PoiseLevel level) => _currentPoiseLevel = level;
    public void ResetPoiseLevel()               => _currentPoiseLevel = _defaultPoiseLevel;

    // IPoiseSource 实现
    public PoiseLevel GetCurrentPoiseLevel() => _currentPoiseLevel;
}

HurtBox 霸体判定逻辑(已在 06_CombatModule §5 HurtBox.ReceiveDamage 中实现):

// 3. 霸体检查BreakLevel vs PoiseLevel
if (!info.Flags.HasFlag(DamageFlags.ForceBreak) && _poiseSource != null)
{
    PoiseLevel curPoise = _poiseSource.GetCurrentPoiseLevel();
    if (curPoise != PoiseLevel.None && curPoise == PoiseLevel.Unbreakable) return;  // 完全无法打断
    if ((int)info.Break < (int)curPoise)
    {
        // 打断等级不足:触发受击 VFX 但跳过伤害
        _onHitConfirmed.Raise(new HitInfo { DamageInfo = info, HitPoint = transform.position });
        return;
    }
}

PoiseOverrideTableSO(精细控制资产,按 06_CombatModule.md §13

// Assets/ScriptableObjects/Combat/PoiseOverrideTable.asset
// ⚠️ 用于特殊规则:某个特定 sourceId 对某类目标的打断等级覆盖(架构 06 §13
[CreateAssetMenu(menuName = "Combat/PoiseOverrideTable")]
public class PoiseOverrideTableSO : ScriptableObject
{
    [Serializable]
    public struct OverrideEntry
    {
        public string     SourceId;        // DamageSourceSO.sourceId攻击来源
        public string     TargetTag;       // 目标 GameObject Tag如 "Boss"
        public BreakLevel OverrideBreak;   // 覆盖使用的打断等级(忽略 DamageInfo.Break
    }

    public List<OverrideEntry> Entries;

    public bool TryGetOverride(string sourceId, string targetTag, out BreakLevel result)
    {
        foreach (var e in Entries)
        {
            if (e.SourceId == sourceId && e.TargetTag == targetTag)
            { result = e.OverrideBreak; return true; }
        }
        result = default;
        return false;
    }
}

3.4 完整连击链

输入 状态 说明
攻击 × 3地面 AttackState (combo 0/1/2) 3段地面连击链
攻击(空中) AirAttackState 1段空中基础攻击
下 + 攻击(空中) DownAttackState 下劈(踩踏判定)
上 + 攻击 UpAttackState 上劈(向上击飞)
形态技能键 SoulSkillState 消耗灵力的魂技能Phase 2

3.5 ClashResolver — 拼刀系统

参考文档06_CombatModule.md §15

当玩家与敌人的近战 HitBox 同时激活并相互重叠时触发拼刀:双方武器碰撞,均不扣血,各自弹开,播放拼刀特效与音效。仅携带 CanClash 标记(DamageFlags.CanClash)的 HitBox 才参与拼刀检测。

// Assets/Scripts/Combat/ClashResolver.cs
// ⚠️ 单例服务,常驻 Persistent 场景(由 GameManager 持有),架构 06 §15
[DefaultExecutionOrder(-500)]
public class ClashResolver : MonoBehaviour
{
    public static ClashResolver Instance { get; private set; }

    [SerializeField] VoidEventChannelSO _onNailClash;
    [SerializeField] ClashConfigSO      _config;

    // 防止同一碰撞在同帧被双方 HitBox 各触发一次(去重)
    readonly HashSet<int> _processedThisFrame = new();

    void Awake()
    {
        if (Instance != null) { Destroy(gameObject); return; }
        Instance = this;
    }

    void LateUpdate() => _processedThisFrame.Clear();

    public void ResolveClash(HitBox playerHitBox, HitBox enemyHitBox)
    {
        int key = playerHitBox.GetInstanceID() ^ enemyHitBox.GetInstanceID();
        if (!_processedThisFrame.Add(key)) return;  // 本帧已处理,去重

        // 1. 拼刀 HitStop
        HitStopManager.FreezeFrames(_config.ClashFreezeFrames);
        // 2. 双方弹开
        ApplyClashKnockback(playerHitBox.OwnerRigidbody, enemyHitBox.transform.position);
        ApplyClashKnockback(enemyHitBox.OwnerRigidbody,  playerHitBox.transform.position);
        // 3. 广播事件VFX / Audio / CameraImpulse 订阅)
        _onNailClash?.Raise();
    }

    void ApplyClashKnockback(Rigidbody2D rb, Vector2 oppositePos)
    {
        if (rb == null) return;
        var dir = ((Vector2)rb.transform.position - oppositePos).normalized;
        rb.AddForce(dir * _config.ClashKnockbackForce, ForceMode2D.Impulse);
    }
}

// Assets/ScriptableObjects/Config/Combat/ClashConfig.asset
[CreateAssetMenu(menuName = "Combat/ClashConfig")]
public class ClashConfigSO : ScriptableObject
{
    [Header("HitStop")]
    public int   ClashFreezeFrames    = 1;    // 拼刀冻帧(比命中的 2 帧更短)
    [Header("弹开")]
    public float ClashKnockbackForce  = 6.0f; // 拼刀弹开力度
    [Header("Camera Impulse")]
    public float ClashImpulseStrength = 0.3f; // Cinemachine Impulse 强度(轻微)
}

拼刀判定规则

情况 结果
双方 HitBox 均激活 + 均 CanClash = true 触发拼刀,双方弹开,不扣血
仅玩家 HitBox 激活,敌人 HitBox 未激活 正常命中敌人 HurtBox
敌人攻击标记为 CanClash = false(如 Boss 重击) 不触发拼刀,正常伤害玩家
同帧多次碰撞 HashSet 去重,每对 HitBox 每帧只触发一次

3.6 BreakConditionSO + IBreakable + BreakableProp — 机关/障碍物交互

参考文档06_CombatModule.md §14

游戏中某些机关(晶石、封印门、毒液容器……)只能被特定类别/标签的攻击击碎。这类物体实现 IBreakable 而非 IDamageableHitBox.OnTriggerEnter2D 会把 DamageInfo 传给 IBreakable.TryInteract(info) 由物体本身决定是否响应。

// Assets/Scripts/Combat/BreakConditionSO.cs
// ⚠️ 数据驱动机关响应条件(架构 06 §14
[CreateAssetMenu(menuName = "Combat/BreakCondition")]
public class BreakConditionSO : ScriptableObject
{
    [Header("Category 白名单(空 = 不限类别)")]
    public DamageCategory[] AllowedCategories;

    [Header("Tags 必须位AND 逻辑)")]
    public DamageTags RequiredTags;

    [Header("Tags 禁止位AND NOT 逻辑)")]
    public DamageTags ForbiddenTags;

    [Header("DamageType 白名单(空 = 不限元素)")]
    public DamageType[] AllowedTypes;

    [Header("技能 ID 白名单(空 = 不限技能)")]
    public string[] AllowedSkillIds;

    public bool Evaluate(in DamageInfo info)
    {
        if (AllowedCategories is { Length: > 0 }
            && System.Array.IndexOf(AllowedCategories, info.Category) < 0) return false;
        if (RequiredTags != DamageTags.None && (info.Tags & RequiredTags) != RequiredTags) return false;
        if (ForbiddenTags != DamageTags.None && (info.Tags & ForbiddenTags) != 0) return false;
        if (AllowedTypes is { Length: > 0 }
            && System.Array.IndexOf(AllowedTypes, info.Type) < 0) return false;
        if (AllowedSkillIds is { Length: > 0 }
            && System.Array.IndexOf(AllowedSkillIds, info.SkillId) < 0) return false;
        return true;
    }
}

// Assets/Scripts/Combat/IBreakable.cs
// ⚠️ 机关、障碍物实现此接口(非 IDamageable架构 06 §14
public interface IBreakable
{
    bool TryInteract(in DamageInfo info);
}

// Assets/Scripts/Combat/BreakableProp.cs
// ⚠️ 通用可破坏/可交互物体(挂在机关 Prefab 上),架构 06 §14
public class BreakableProp : MonoBehaviour, IBreakable
{
    [SerializeField] private BreakConditionSO   _condition;
    [SerializeField] private int                _maxHp = 1;     // 默认单次即碎
    [SerializeField] private VoidEventChannelSO _onBroken;      // 全局广播(开门、切换场景等)
    [SerializeField] private FeedbackPresetSO   _hitFeedback;
    [SerializeField] private FeedbackPresetSO   _breakFeedback;
    [SerializeField] private FeedbackPresetSO   _rejectFeedback; // 提示玩家"需要特定能力"

    private int _currentHp;

    private void Awake() => _currentHp = _maxHp;

    public bool TryInteract(in DamageInfo info)
    {
        if (!_condition.Evaluate(info))
        {
            _rejectFeedback?.Play(transform.position);
            return false;
        }

        _currentHp -= info.FinalDamage > 0 ? info.FinalDamage : 1;
        _hitFeedback?.Play(transform.position);

        if (_currentHp <= 0)
        {
            _breakFeedback?.Play(transform.position);
            _onBroken?.Raise();
            gameObject.SetActive(false);
        }
        return true;
    }
}

机关配置示例

机关 AllowedCategories RequiredTags AllowedTypes 说明
毒液晶石 ElementPoison Poison 任何毒属性攻击
封印门 SoulSkill SkyFormOnly 仅天形魂技能
弱点晶球 NormalAttack, SoulSkill AfterParry 弹反后才能击碎
普通木箱 NormalAttack, SoulSkill, SpiritSkill MeleeHit 任意近战

4. Week 7状态效果 + 动画事件 + 完整 VFX

参考文档06_CombatModule.md §10-1116_AnimationModule.md18_VFXFeedbackModule.md

4.1 AnimationEventType 枚举 + AnimationEventBinder

参考文档24_AnimEventModule.md §2-4

⚠️ 架构约束

  • 禁止使用 Unity 传统 AnimationEvent(字符串反射)
  • 必须使用 Animancer ClipTransition.Events.SetCallback(normalizedTime, callback)
  • 所有时机值由 AnimationEventConfigSO 管理(设计师可在 Inspector 调整)

文件

Assets/Scripts/Animation/AnimationEventType.cs        ← 枚举(与架构完全一致)
Assets/Scripts/Animation/AnimationEventConfigSO.cs    ← SO 配置(每个 Clip 的事件时机)
Assets/Scripts/Animation/AnimationEventBinder.cs      ← 静态工具类(注册回调)
Assets/Scripts/Animation/IAnimationEventHandler.cs    ← 接口(⚠️ IAnimationEventHandler架构 24 §4
Assets/Scripts/Animation/PlayerAnimationEvents.cs     ← 玩家侧实现
Assets/Scripts/Animation/EnemyAnimationEvents.cs      ← 敌人侧实现
// ✅ 正确 — 完整枚举定义(对应架构 24_AnimEventModule §2
namespace BaseGames.Animation
{
    public enum AnimationEventType
    {
        // 战斗 - HitBox
        EnableHitBox,           // 开启 HitBoxpayload 字段携带 HitBox 编号或名称)
        DisableHitBox,          // 关闭 HitBox
        AttackImpact,           // 攻击命中反馈(音效/特效时机)

        // 战斗 - 弹反窗口(由 ParrySystem 监听)
        EnableParryWindow,      // 开启可弹反时间窗ParrySystem.OpenWindow()
        DisableParryWindow,     // 关闭可弹反时间窗ParrySystem.CloseWindow()

        // 玩家
        EnableIFrame,           // 开启无敌帧(翻滚/受击恢复)
        DisableIFrame,          // 关闭无敌帧
        Footstep,               // 脚步落地(⚠️ 合并 FootstepLeft/Right由 payload 区分面,架构 24 §2
        LandImpact,             // 落地震动(落地音效/特效)
        JumpLaunch,             // 起跳(跳跃音效/特效)
        EnableInteract,         // 动画帧触发互动(如 NPC 握手时机)

        // 反馈派发
        TriggerFeedback,        // 触发 MMF_Player 预设payload = Feedback 名称)
        PlaySFX,                // 播放音效payload = AudioEventSO Address key

        // 敌人
        SpawnProjectile,        // 生成弹幕(⚠️ SpawnProjectile非 SummonProjectile架构 24 §2
        RoarStart,              // 怒吼开始AI 警觉)
        RoarEnd,                // 怒吼结束
        PhaseTwoStart,          // 二阶段开始Boss 过渡)

        // 通用
        CancelWindowOpen,       // 可取消帧窗口开始(连击/取消窗口)
        CancelWindowClose,      // 可取消帧窗口结束
        StateTransition,        // 动画驱动状态机转移payload = 目标状态名)
        AnimationComplete,      // 动画播完(一次性动画通知)
    }
}
// AnimationEventConfigSO.cs — 每个 ClipTransition 的事件时机配置(设计师可在 Inspector 调整)
namespace BaseGames.Animation
{
    [CreateAssetMenu(menuName = "Animation/EventConfig")]
    public class AnimationEventConfigSO : ScriptableObject
    {
        [Serializable]
        public struct EventEntry
        {
            public AnimationEventType eventType;
            [Range(0f, 1f)]
            public float              normalizedTime;   // 触发帧在整个动画中的归一化位置
            [Tooltip("附加数据(可空):如 HitBox 编号、音频 key 等")]
            public string             data;
        }

        [Header("绑定的动画片段(类型安全引用,替代旧 clipName 字符串)")]
        public AnimationClip targetClip;   // ⚠️ AnimationClip非 string clipName架构 24_AnimEventModule §3

        [Header("事件时机列表")]
        public EventEntry[] events;

        /// <summary>按时机顺序排序,方便 Binder 批量注册。</summary>
        public IEnumerable<EventEntry> SortedEvents =>
            events.OrderBy(e => e.normalizedTime);

        /// <summary>查询指定事件类型的触发帧(工具/编辑器用)。⚠️ 架构 24 §3 定义</summary>
        public float GetNormalizedTime(AnimationEventType eventType)
        {
            foreach (var e in events)
                if (e.eventType == eventType) return e.normalizedTime;
            return -1f;  // 未找到
        }
    }
}
// AnimationEventBinder.cs — 静态工具,将 SO 配置绑定到 ClipTransition
namespace BaseGames.Animation
{
    public static class AnimationEventBinder
    {
        public static void Bind(
            ClipTransition            clip,
            AnimationEventConfigSO    config,
            IAnimationEventHandler    receiver)  // ⚠️ IAnimationEventHandler架构 24 §4
        {
            if (config == null) return;
            foreach (var entry in config.SortedEvents)
            {
                var captured = entry;
                clip.Events.SetCallback(captured.normalizedTime, () =>
                    receiver.HandleEvent(captured.eventType, captured.data));  // ⚠️ HandleEvent架构 24 §4
            }
        }
    }

    // ⚠️ 接口名为 IAnimationEventHandler方法名为 HandleEvent架构 24 §4非 IAnimEventReceiver/OnAnimationEvent
    public interface IAnimationEventHandler
    {
        void HandleEvent(AnimationEventType type, string payload);
    }
}
// PlayerAnimationEvents.cs — 挂在 [Animation] 子节点,是玩家所有动画事件的唯一派发入口
// ⚠️ 字段以架构 24_AnimEventModule §5 为准:独立组件引用,非 PlayerController 聚合
namespace BaseGames.Animation
{
    // ⚠️ 实现 IAnimationEventHandler架构 24 §5方法名 HandleEvent
    public class PlayerAnimationEvents : MonoBehaviour, IAnimationEventHandler
    {
        [Header("战斗组件")]
        [SerializeField] HitBox[]        _hitBoxes;   // 玩家身上所有 HitBox
        [SerializeField] HurtBox         _hurtBox;

        [Header("能力组件")]
        [SerializeField] PlayerStats     _playerStats;
        [SerializeField] PlayerMovement  _mover;   // ⚠️ 类型为 PlayerMovement架构 05_PlayerModule §3非 PlayerMover
        [SerializeField] ParrySystem     _parrySystem;   // ⚠️ 弹反窗口控制(架构 24 §5 / 20_ShieldModule §1

        [Header("特效/音效")]
        [SerializeField] IFeedbackPlayer _feedback;   // 通过 GetComponentInParent 注入

        [Header("事件配置(与 ClipTransition 一一对应)")]
        [SerializeField] EventBinding[] _bindings;

        [Serializable]
        struct EventBinding
        {
            public ClipTransition         clip;
            public AnimationEventConfigSO config;
        }

        void Awake()
        {
            _feedback = GetComponentInParent<IFeedbackPlayer>();
            foreach (var b in _bindings)
                AnimationEventBinder.Bind(b.clip, b.config, this);
        }

        public void HandleEvent(AnimationEventType type, string payload)  // ⚠️ HandleEvent + payload架构 24 §4
        {
            switch (type)
            {
                case AnimationEventType.EnableHitBox:
                    EnableHitBoxById(payload); break;
                case AnimationEventType.DisableHitBox:
                    DisableHitBoxById(payload); break;
                case AnimationEventType.AttackImpact:
                    _feedback?.PlayAttackWhoosh(); break;
                case AnimationEventType.EnableIFrame:
                    _hurtBox.SetInvincible(true); break;    // ⚠️ HurtBox.SetInvincible非 PlayerStats.BeginInvincibility
                case AnimationEventType.DisableIFrame:
                    _hurtBox.SetInvincible(false); break;
                case AnimationEventType.Footstep:           // ⚠️ Footstep合并左右脚payload 区分)
                    FootstepSystem.Play(_mover.CurrentSurface, transform.position); break;
                case AnimationEventType.LandImpact:
                    _feedback?.PlayLandImpact(); break;
                case AnimationEventType.JumpLaunch:
                    _feedback?.PlayJumpLaunch(); break;
                case AnimationEventType.EnableParryWindow:
                    _parrySystem?.OpenWindow(); break;  // ⚠️ 架构 24 §5ParrySystem.OpenWindow()
                case AnimationEventType.DisableParryWindow:
                    _parrySystem?.CloseWindow(); break; // ⚠️ 架构 24 §5ParrySystem.CloseWindow()
                case AnimationEventType.CancelWindowOpen:
                    _mover.SetCancelWindowOpen(true); break;    // ⚠️ PlayerMovement.SetCancelWindowOpen架构 05_PlayerModule §3非 AttackState 方法
                case AnimationEventType.CancelWindowClose:
                    _mover.SetCancelWindowOpen(false); break;
                case AnimationEventType.TriggerFeedback:
                    _feedback?.TriggerPreset(payload); break;  // ⚠️ TriggerPreset架构 18 §2 IFeedbackPlayer 接口,非 PlayFeedbackByName
                case AnimationEventType.PlaySFX:
                    _feedback?.PlaySFXById(payload); break;  // ⚠️ PlaySFXById架构 18 §2 IFeedbackPlayer 接口AudioManager 无 PlaySFXByKey 方法)
            }
        }

        void EnableHitBoxById(string id)
        {
            foreach (var hb in _hitBoxes)
                if (hb.Id == id || string.IsNullOrEmpty(id)) hb.Activate();   // ⚠️ HitBox.Activate(),无参数(架构 06 §4
        }

        void DisableHitBoxById(string id)
        {
            foreach (var hb in _hitBoxes)
                if (hb.Id == id || string.IsNullOrEmpty(id)) hb.Deactivate(); // ⚠️ HitBox.Deactivate()(架构 06 §4
        }
    }
}
// EnemyAnimationEvents.cs — 挂在 EnemyBase或其 [Animation] 子节点),敌人侧动画事件派发入口
// ⚠️ 与 PlayerAnimationEvents 结构相同(见架构 24_AnimEventModule §6
namespace BaseGames.Animation
{
    public class EnemyAnimationEvents : MonoBehaviour, IAnimationEventHandler  // ⚠️ IAnimationEventHandler架构 24 §4非 IAnimEventReceiver
    {
        [SerializeField] HitBox[]      _hitBoxes;
        [SerializeField] EnemyFeedback _feedback;
        [SerializeField] EnemyBase     _enemy;

        [SerializeField] EventBinding[] _bindings;

        [Serializable]
        struct EventBinding
        {
            public ClipTransition         clip;
            public AnimationEventConfigSO config;
        }

        void Awake()
        {
            foreach (var b in _bindings)
                AnimationEventBinder.Bind(b.clip, b.config, this);
        }

        public void HandleEvent(AnimationEventType type, string payload)  // ⚠️ HandleEvent架构 24 §6非 OnAnimationEvent
        {
            switch (type)
            {
                case AnimationEventType.EnableHitBox:
                    foreach (var hb in _hitBoxes)
                        if (hb.Id == payload || string.IsNullOrEmpty(payload)) hb.Activate();   // ⚠️ HitBox.Activate()(架构 06 §4
                    break;
                case AnimationEventType.DisableHitBox:
                    foreach (var hb in _hitBoxes)
                        if (hb.Id == payload || string.IsNullOrEmpty(payload)) hb.Deactivate(); // ⚠️ HitBox.Deactivate()(架构 06 §4
                    break;
                case AnimationEventType.SpawnProjectile:  // ⚠️ SpawnProjectile架构 24 §2非旧版 SummonProjectile
                    _enemy.SpawnProjectile(payload); break;
                case AnimationEventType.RoarStart:
                case AnimationEventType.RoarEnd:
                    _enemy.Blackboard.SetVariableValue("IsRoaring",
                        type == AnimationEventType.RoarStart);
                    break;
                case AnimationEventType.PhaseTwoStart:
                    _enemy.TriggerPhaseTwo(); break;
                case AnimationEventType.AnimationComplete:
                    _enemy.OnAnimationComplete(payload); break;
            }
        }
    }
}

配置工作流

  1. 为每个攻击动画创建 AnimationEventConfigSOInspector 设置 normalizedTime
  2. PlayerAnimationEvents.Awake 中调用 AnimationEventBinder.Bind(clip, config, this)
  3. 设计师调整时机无需修改代码,只改 SO 资产

4.1.1 EventConfigEditor — 动画事件时间轴可视化(⚠️ 架构 24 §10Phase 2 优化)

路径:Assets/Editor/Animation/EventConfigEditor.cs[CustomEditor(typeof(AnimationEventConfigSO))],设计师可在 Inspector 中直观查看/调整所有 normalizedTime 标记点,无需手动核对帧序号。

// Assets/Editor/Animation/EventConfigEditor.cs
// 需在 AnimationEventConfigSO 补充隐藏字段:
//   [HideInInspector] public float ExpectedClipLength = -1f;  // Auto-detect 写入

[CustomEditor(typeof(AnimationEventConfigSO))]
public class EventConfigEditor : UnityEditor.Editor
{
    // 事件类型颜色规则(架构 24 §10
    // HitBox=红  IFrame=绿  Footstep/SFX=蓝  ParryWindow=黄  TriggerFeedback=紫  CancelWindow=橙

    public override void OnInspectorGUI()
    {
        var config = (AnimationEventConfigSO)target;

        // ① 时间轴背景条(全宽 24px 高)+ 各事件 normalizedTime 竖线标记(按类型着色)
        DrawTimeline(config);

        // ② 默认 Inspector 字段
        DrawDefaultInspector();

        // ③ 越界保护normalizedTime < 0 || > 1 → HelpBox Error
        ValidateNormalizedTime(config);

        // ④ Clip 漂移检测:|currentLen - ExpectedClipLength| > 5帧 → HelpBox Warning
        if (config.ExpectedClipLength > 0 && config.TargetClip != null)
        {
            float frameDrift = Mathf.Abs(config.TargetClip.length - config.ExpectedClipLength)
                               * config.TargetClip.frameRate;
            if (frameDrift > 5f)
                EditorGUILayout.HelpBox(
                    $"Clip 长度偏差 {frameDrift:F1} 帧,请重新校验 normalizedTime 或点击 Auto-detect 更新基准。",
                    MessageType.Warning);
        }

        // ⑤ Auto-detect 按钮:将当前 Clip 长度写入 ExpectedClipLength
        if (GUILayout.Button("Auto-detect Clip Length"))
        {
            if (config.TargetClip != null)
            {
                config.ExpectedClipLength = config.TargetClip.length;
                EditorUtility.SetDirty(config);
            }
        }
    }

    private void DrawTimeline(AnimationEventConfigSO config) { /* ... */ }
    private void ValidateNormalizedTime(AnimationEventConfigSO config) { /* ... */ }
}

4.1.2 FootstepSystem + FootstepCatalogSO + SurfaceType

参考文档24_AnimEventModule.md §8

// Assets/Scripts/Animation/FootstepSystem.cs
// ⚠️ 静态工具类_catalog 通过 Addressables 异步加载,禁止 Resources.Load
// BootstrapLoader 在游戏启动时调用 FootstepSystem.InitAsync()
namespace BaseGames.Animation
{
    public static class FootstepSystem
    {
        static FootstepCatalogSO _catalog;

        public static async UniTask InitAsync()
        {
            _catalog = await Addressables
                .LoadAssetAsync<FootstepCatalogSO>(AddressKeys.SO_FootstepCatalog).Task;
            // AddressKeys.SO_FootstepCatalog = "Config/FootstepCatalog"
        }

        public static void Play(SurfaceType surface, Vector3 position)
        {
            var entry = _catalog.GetEntry(surface);
            if (entry == null) return;
            // ⚠️ entry.audioClip 类型为 AudioClip非 string key对应架构 11_AudioModule §2 的 PlaySFXAtPosition(AudioClip, Vector2)
            AudioManager.Instance.PlaySFXAtPosition(entry.audioClip, position);
            if (entry.dustParticlePrefab.RuntimeKeyIsValid())
                VFXPool.Instance.Play(entry.dustParticlePrefab, position);
        }
    }

    // Assets/Scripts/Animation/FootstepCatalogSO.cs
    [CreateAssetMenu(menuName = "Audio/FootstepCatalog")]
    public class FootstepCatalogSO : ScriptableObject
    {
        [Serializable]
        public struct FootstepEntry
        {
            public SurfaceType              surface;
            public AudioClip                audioClip;            // ⚠️ AudioClip 直接引用,非 string key
            public AssetReferenceGameObject dustParticlePrefab;
        }

        [SerializeField] FootstepEntry[] _entries;

        public FootstepEntry? GetEntry(SurfaceType surface)
        {
            foreach (var e in _entries)
                if (e.surface == surface) return e;
            return null;
        }
    }

    // Assets/Scripts/Animation/SurfaceType.cs
    public enum SurfaceType { Stone, Wood, Dirt, Water, Metal, Grass }
}

4.1.2 PlayerMovement Phase 2 扩展字段

参考文档05_PlayerModule.md §324_AnimEventModule.md §9

Week 7 补充:在 PlayerMovement 已有方法基础上,追加以下字段供 AnimEvent 系统使用。

// PlayerMovement.cs 中新增Phase 2 Week 7对应架构 05_PlayerModule §3
// ── 可取消帧窗口 ────────────────────────────────────────────────────
private bool _cancelWindowOpen = false;
public  bool CancelWindowOpen  => _cancelWindowOpen;
public  void SetCancelWindowOpen(bool open) => _cancelWindowOpen = open;
// 由 PlayerAnimationEvents.OnAnimationEvent(CancelWindowOpen/CancelWindowClose) 驱动

// ── 当前地面材质 ─────────────────────────────────────────────────────
public SurfaceType CurrentSurface { get; private set; }  // private set由 CheckGrounded() 检测 Tile 材质后赋值
// FootstepSystem.Play(_mover.CurrentSurface, ...) 依赖此属性

4.1.3 AttackState.GetNextState() CancelWindow 检查

参考文档24_AnimEventModule.md §9

Week 7 补充:在 Plan 02 §3.5 AttackState 基础上Phase 2 Week 7 追加 GetNextState() 重写, 确保只有在 CancelWindowOpen 为 true 时才允许 FSM 接受新输入并切换状态。

// AttackState.cs 中追加Phase 2 Week 7对应架构 24_AnimEventModule §9
// ⚠️ Phase 1 §3.5 使用 inline Events.SetCallback 的简化版Week 7 改为由 PlayerAnimationEvents 驱动
public override PlayerStateBase GetNextState()
{
    // 只有在 CancelWindowOpen 时才接受新输入转换状态
    if (!_player.Movement.CancelWindowOpen) return null;
    if (_player.Input.AttackPressed) return _nextAttackState;
    if (_player.Input.DashPressed)   return _player.DashState;
    if (_player.Input.JumpPressed)   return _player.JumpState;
    return null;
}

流程24_AnimEventModule.md §9 时序):

AttackClip 播放
  ├─ NormalizedTime = 0.6 → AnimationEventType.CancelWindowOpen → PlayerMovement._cancelWindowOpen = true
  ├─ 玩家可输入 → FSM.GetNextState() 允许取消进入其他状态
  └─ NormalizedTime = 0.9 → AnimationEventType.CancelWindowClose → PlayerMovement._cancelWindowOpen = false

4.2 StatusEffect + 具体状态效果

// Assets/Scripts/Combat/StatusEffects/StatusEffect.cs
// ⚠️ 类名为 StatusEffect非 StatusEffectBase架构 06_CombatModule §11
namespace BaseGames.Combat.StatusEffects
{
    public enum StatusEffectType { Fire, Poison, Freeze, Stun }  // ⚠️ 枚举标识符,非 string EffectId

    public abstract class StatusEffect
    {
        public abstract StatusEffectType EffectType { get; }   // ⚠️ EffectType非 EffectId string
        public abstract int              MaxStacks   { get; }   // 最大叠加层数1 = 不可叠加)

        public int   StackCount   { get; protected set; } = 1;  // ⚠️ 必须定义 StackCount架构 06 §11
        public float Duration     { get; protected set; }       // 当前剩余持续时间
        public float TickInterval { get; protected set; }       // 每次 Tick 的间隔秒数
        float _tickTimer;

        protected StatusEffectManager Owner;  // 宿主(由 OnApply 注入)

        // ⚠️ OnApply 参数为 StatusEffectManager owner非 IDamageable
        public virtual void OnApply(StatusEffectManager owner)
        {
            Owner    = owner;
            Duration = GetBaseDuration();
        }

        // ⚠️ OnStack同类型再次施加时叠层/刷新)
        public virtual void OnStack()
        {
            Duration   = GetBaseDuration();
            StackCount = Mathf.Min(StackCount + 1, MaxStacks);
        }

        // ⚠️ OnTick/OnExpire 无参数(用 Owner 字段引用宿主)
        public virtual void OnTick()   { }
        public virtual void OnExpire() { }  // ⚠️ OnExpire非 OnRemove

        public virtual bool IsExpired => Duration <= 0f;

        // ⚠️ Update(float delta)(非 Tick(IDamageable, float)
        public void Update(float delta)
        {
            Duration   -= delta;
            _tickTimer += delta;
            if (_tickTimer >= TickInterval)
            {
                _tickTimer -= TickInterval;
                OnTick();
            }
        }

        protected abstract float GetBaseDuration();
        public abstract string GetDisplayName();
    }
}

// Phase 2 实现以下 3 种(其余 Phase 3 补充) // Assets/Scripts/Combat/StatusEffects/FireEffect.cs — 燃烧DOT不可叠加触发则刷新 // Assets/Scripts/Combat/StatusEffects/PoisonEffect.cs — 中毒DOT最多 3 层叠加) // Assets/Scripts/Combat/StatusEffects/StaggerEffect.cs — 硬直(无法行动 N 秒)

4.3 StatusEffectManager

// Assets/Scripts/Combat/StatusEffects/StatusEffectManager.cs
// ⚠️ [RequireComponent(typeof(SpriteRenderer))] — 需要 SpriteRenderer 支持 Shader 参数(架构 06 §10
namespace BaseGames.Combat.StatusEffects
{
    [RequireComponent(typeof(SpriteRenderer))]
    public class StatusEffectManager : MonoBehaviour
    {
        [Header("事件频道")]
        // ⚠️ StatusEffectEventChannelSO非 VoidEventChannelSO携带 StatusEffectType 载荷(架构 06 §10
        [SerializeField] StatusEffectEventChannelSO _onStatusEffectApplied;
        [SerializeField] StatusEffectEventChannelSO _onStatusEffectExpired;

        // ⚠️ 双结构List 用于有序 Update 遍历Dictionary 用于 O(1) 查找(架构 06 §10
        readonly List<StatusEffect>                          _activeList  = new();
        readonly Dictionary<StatusEffectType, StatusEffect> _activeIndex = new();

        SpriteRenderer     _renderer;
        MaterialPropertyBlock _propBlock;

        void Awake()
        {
            _renderer  = GetComponent<SpriteRenderer>();
            _propBlock = new MaterialPropertyBlock();
        }

        void Update()
        {
            for (int i = _activeList.Count - 1; i >= 0; i--)
            {
                var effect = _activeList[i];
                effect.Update(Time.deltaTime);  // ⚠️ Update(float)(非 Tick(IDamageable, float)
                if (effect.IsExpired)
                {
                    effect.OnExpire();  // ⚠️ OnExpire()(非 OnRemove(target)
                    _activeIndex.Remove(effect.EffectType);
                    _activeList.RemoveAt(i);
                    _onStatusEffectExpired?.Raise(effect.EffectType);
                }
            }
        }

        /// <summary>施加状态效果。已有相同类型时叠层/刷新O(1) 查找)。</summary>
        public void ApplyEffect(StatusEffect newEffect)
        {
            // ⚠️ 用 Dictionary 做 O(1) 查找(非 List.Find架构 06 §10
            if (_activeIndex.TryGetValue(newEffect.EffectType, out var existing))
            {
                existing.OnStack();  // ⚠️ OnStack()叠层或刷新
            }
            else
            {
                newEffect.OnApply(this);  // ⚠️ 传 StatusEffectManager非 IDamageable
                _activeList.Add(newEffect);
                _activeIndex[newEffect.EffectType] = newEffect;
                _onStatusEffectApplied?.Raise(newEffect.EffectType);
            }
        }

        /// <summary>净化指定类型的状态效果O(1) 查找)。⚠️ 方法名为 CleanseEffect非 RemoveEffect架构 06 §10</summary>
        public void CleanseEffect(StatusEffectType type)
        {
            if (!_activeIndex.TryGetValue(type, out var effect)) return;
            effect.OnExpire();
            _activeIndex.Remove(type);
            _activeList.Remove(effect);
            _onStatusEffectExpired?.Raise(type);
        }

        /// <summary>净化所有状态效果。⚠️ 存档点激活时调用(架构 06 §10</summary>
        public void CleanseAll()
        {
            foreach (var e in _activeList) e.OnExpire();
            _activeList.Clear();
            _activeIndex.Clear();
        }

        /// <summary>由状态效果调用,直接扣 HPTrue 伤害,绕过 HurtBox。⚠ 架构 06 §10</summary>
        public void ApplyDirectDamage(DamageInfo info)
            => GetComponent<IDamageable>()?.TakeDamage(info);

        /// <summary>由状态效果调用,设置 Shader 参数MaterialPropertyBlock。⚠ 架构 06 §10</summary>
        public void SetShaderParam(string param, float value)
        {
            _renderer.GetPropertyBlock(_propBlock);
            _propBlock.SetFloat(param, value);
            _renderer.SetPropertyBlock(_propBlock);
        }

        /// <summary>检查是否有指定类型的状态效果。⚠️ 架构 06 §10</summary>
        public bool HasEffect(StatusEffectType type) => _activeIndex.ContainsKey(type);
    }
}

HurtBox 收到含 DamageType.Poison/Fire 的 DamageInfo 时,自动调用 StatusEffectManager.ApplyEffect
净化方式:进入水域调用 CleanseEffect(Fire);装备解毒护符自动净化 Poison存档点调用 CleanseAll()(架构 06 §11.2)。

4.4 完整 VFX 反馈模块

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

4.4.1 FeedbackConfigSO

// Assets/ScriptableObjects/Feedback/Feedback_Config.asset
// ⚠️ menuName = "Feedback/FeedbackConfig"(架构 18_VFXFeedbackModule §5
namespace BaseGames.Feedback
{
    [CreateAssetMenu(menuName = "Feedback/FeedbackConfig")]
    public class FeedbackConfigSO : ScriptableObject
    {
        [Header("冻帧")]
        [Range(0f, 0.2f)]
        public float FreezeFrameDuration      = 0.033f;  // 默认 2 帧60fps
        [Range(0f, 0.2f)]
        public float ParryFreezeFrameDuration = 0.067f;  // 弹反冻帧更长

        [Header("子弹时间")]
        [Range(0.01f, 1f)]
        public float BulletTimeScale    = 0.15f;
        [Range(0f, 1f)]
        public float BulletTimeDuration = 0.3f;

        [Header("镜头震动强度")]
        public float ShakeLightForce  = 0.2f;
        public float ShakeMediumForce = 0.5f;
        public float ShakeHeavyForce  = 1.0f;
        public float ShakeParryForce  = 0.8f;

        [Header("受击白闪")]
        public Color  HurtFlashColor    = Color.white;
        [Range(0f, 0.5f)]
        public float  HurtFlashDuration = 0.15f;
    }
}

4.4.2 PlayerFeedback完整实现

// Assets/Scripts/Feedback/PlayerFeedback.cs
// ⚠️ 挂在 Player Prefab 根节点下的 [Feedback] 子 GameObject 上(架构 18_VFXFeedbackModule §3
// Player Prefab 层级:[Player] → [Feedback] ← PlayerFeedback.cs + MMF_Player × 10
namespace BaseGames.Feedback
{
    public class PlayerFeedback : MonoBehaviour, IFeedbackPlayer
    {
        [Header("命中反馈")]
        [SerializeField] MMF_Player _onHitLight;
        [SerializeField] MMF_Player _onHitMedium;
        [SerializeField] MMF_Player _onHitHeavy;

        [Header("战斗反馈")]
        [SerializeField] MMF_Player _onParrySuccess;
        [SerializeField] MMF_Player _onTakeHit;
        [SerializeField] MMF_Player _onDeath;

        [Header("移动反馈")]
        [SerializeField] MMF_Player _onHeal;
        [SerializeField] MMF_Player _onLandImpact;
        [SerializeField] MMF_Player _onAttackWhoosh;
        [SerializeField] MMF_Player _onJumpLaunch;

        [Header("配置")]
        [SerializeField] FeedbackConfigSO _config;

        // 预设字典runtime用于 TriggerPreset
        Dictionary<string, MMF_Player> _presetMap;

        void Awake()
        {
            _presetMap = new Dictionary<string, MMF_Player>
            {
                { "HitLight",     _onHitLight     },
                { "HitMedium",    _onHitMedium    },
                { "HitHeavy",     _onHitHeavy     },
                { "ParrySuccess", _onParrySuccess },
                { "TakeHit",      _onTakeHit      },
                { "Death",        _onDeath        },
                { "LandImpact",   _onLandImpact   },
            };
        }

        public void PlayHit(HitWeight weight)
        {
            switch (weight)
            {
                case HitWeight.Light:  _onHitLight?.PlayFeedbacks();  break;
                case HitWeight.Medium: _onHitMedium?.PlayFeedbacks(); break;
                case HitWeight.Heavy:  _onHitHeavy?.PlayFeedbacks();  break;
            }
        }
        public void PlayParrySuccess() => _onParrySuccess?.PlayFeedbacks();
        public void PlayTakeHit()      => _onTakeHit?.PlayFeedbacks();
        public void PlayDeath()        => _onDeath?.PlayFeedbacks();
        public void PlayHeal()         => _onHeal?.PlayFeedbacks();
        public void PlayLandImpact()   => _onLandImpact?.PlayFeedbacks();
        public void PlayAttackWhoosh() => _onAttackWhoosh?.PlayFeedbacks();
        public void PlayJumpLaunch()   => _onJumpLaunch?.PlayFeedbacks();

        public void TriggerPreset(string presetId)
        {
            if (_presetMap.TryGetValue(presetId, out var player))
                player?.PlayFeedbacks();
        }

        public void PlaySFXById(string sfxId)
        {
            // sfxId → AudioClip 解析通过 SFXCatalogSO 查表后调用 AudioManager.Instance.PlaySFX(clip)
        }
    }
}

4.4.3 EnemyFeedback完整实现

// Assets/Scripts/Feedback/EnemyFeedback.cs
// ⚠️ 挂在 EnemyBase Prefab 下的 [Feedback] 子 GameObject 上(架构 18_VFXFeedbackModule §4
// EnemyBase 通过 [SerializeField] EnemyFeedback _feedback 引用
namespace BaseGames.Feedback
{
    public class EnemyFeedback : MonoBehaviour, IFeedbackPlayer
    {
        [SerializeField] MMF_Player _onTakeHit;
        [SerializeField] MMF_Player _onDeath;
        [SerializeField] MMF_Player _onHitLight;
        [SerializeField] MMF_Player _onHitMedium;
        [SerializeField] MMF_Player _onHitHeavy;

        public void PlayHit(HitWeight weight)
        {
            switch (weight)
            {
                case HitWeight.Light:  _onHitLight?.PlayFeedbacks();  break;
                case HitWeight.Medium: _onHitMedium?.PlayFeedbacks(); break;
                case HitWeight.Heavy:  _onHitHeavy?.PlayFeedbacks();  break;
            }
        }
        public void PlayTakeHit()  => _onTakeHit?.PlayFeedbacks();
        public void PlayDeath()    => _onDeath?.PlayFeedbacks();

        // 敌人未使用的接口方法(空实现)
        public void PlayParrySuccess() { }
        public void PlayHeal()         { }
        public void PlayLandImpact()   { }
        public void PlayAttackWhoosh() { }
        public void PlayJumpLaunch()   { }
        public void TriggerPreset(string id) { }
        public void PlaySFXById(string id)   { }
    }
}

4.4.4 PaletteSwapSystem + PaletteCatalogSO

// Assets/Scripts/VFX/PaletteSwapSystem.cs
// ⚠️ ApplyPalette(FormType form) 接受 FormType 枚举(架构 18_VFXFeedbackModule §10
// FormController.SwitchForm 调用_paletteSwapSystem?.ApplyPalette(newForm.formType)  ← 正确FormSO.formType 字段,架构 05 §18 遗漏,以架构 18 §10 为准)
namespace BaseGames.VFX
{
    public class PaletteSwapSystem : MonoBehaviour
    {
        [SerializeField] SpriteRenderer  _renderer;
        [SerializeField] PaletteCatalogSO _catalog;

        static readonly int PaletteTexID = Shader.PropertyToID("_PaletteTex");
        MaterialPropertyBlock _block;

        void Awake() => _block = new MaterialPropertyBlock();

        /// <summary>切换到指定形态的调色板。由 FormController 调用。</summary>
        public void ApplyPalette(FormType form)  // ⚠️ 参数为 FormType 枚举(非 Color非 FormSO
        {
            if (!_catalog.TryGetPalette(form, out var tex)) return;
            _renderer.GetPropertyBlock(_block);
            _block.SetTexture(PaletteTexID, tex);
            _renderer.SetPropertyBlock(_block);
        }
    }

    // Assets/Scripts/VFX/PaletteCatalogSO.cs
    [CreateAssetMenu(menuName = "VFX/PaletteCatalog")]
    public class PaletteCatalogSO : ScriptableObject
    {
        public PaletteEntry[] entries;

        public bool TryGetPalette(FormType form, out Texture2D tex)
        {
            foreach (var e in entries)
                if (e.form == form) { tex = e.paletteLUT; return true; }
            tex = null;
            return false;
        }
    }

    [Serializable]
    public struct PaletteEntry
    {
        public FormType  form;
        public Texture2D paletteLUT;   // 1D 查找表纹理256×1 px
    }
}

4.4.5 PostProcessManager

// Assets/Scripts/VFX/PostProcessManager.cs
// ⚠️ 挂在 Persistent 场景 [PostProcess] GameObject架构 18_VFXFeedbackModule §11
// ⚠️ DOTween 规范:.SetAutoKill(true).SetLink(gameObject)(架构 §11
namespace BaseGames.VFX
{
    public class PostProcessManager : MonoBehaviour
    {
        [Header("Volume 引用Persistent 场景内)")]
        [SerializeField] Volume _underwaterVolume;  // Priority=10
        [SerializeField] Volume _bossArenaVolume;   // Priority=10
        [SerializeField] Volume _deathVolume;       // Priority=20
        [SerializeField] Volume _victoryVolume;     // Priority=10

        [Header("事件频道")]
        [SerializeField] VoidEventChannelSO _onLiquidEntered;
        [SerializeField] VoidEventChannelSO _onLiquidExited;
        [SerializeField] VoidEventChannelSO _onBossFightStarted;
        [SerializeField] VoidEventChannelSO _onBossFightEnded;
        [SerializeField] VoidEventChannelSO _onPlayerDied;
        [SerializeField] VoidEventChannelSO _onPlayerRespawned;
        [SerializeField] VoidEventChannelSO _onBossDefeated;

        [SerializeField] float _blendDuration = 0.4f;

        private Volume[] _nonDefaultVolumes;

        private void Awake()
        {
            _nonDefaultVolumes = new[] { _underwaterVolume, _bossArenaVolume, _deathVolume, _victoryVolume };
        }

        private void OnEnable()
        {
            _onLiquidEntered.OnEventRaised    += () => BlendTo(_underwaterVolume);
            _onLiquidExited.OnEventRaised     += ResetAll;
            _onBossFightStarted.OnEventRaised += () => BlendTo(_bossArenaVolume);
            _onBossFightEnded.OnEventRaised   += ResetAll;
            _onPlayerDied.OnEventRaised       += () => BlendTo(_deathVolume);
            _onPlayerRespawned.OnEventRaised  += ResetAll;
            _onBossDefeated.OnEventRaised     += () => BlendTo(_victoryVolume);
        }

        private void OnDisable()
        {
            _onLiquidEntered.OnEventRaised    -= () => BlendTo(_underwaterVolume);
            _onLiquidExited.OnEventRaised     -= ResetAll;
            _onBossFightStarted.OnEventRaised -= () => BlendTo(_bossArenaVolume);
            _onBossFightEnded.OnEventRaised   -= ResetAll;
            _onPlayerDied.OnEventRaised       -= () => BlendTo(_deathVolume);
            _onPlayerRespawned.OnEventRaised  -= ResetAll;
            _onBossDefeated.OnEventRaised     -= () => BlendTo(_victoryVolume);
        }

        private void BlendTo(Volume target)
        {
            foreach (var v in _nonDefaultVolumes)
                DOTween.To(() => v.weight, x => v.weight = x, 0f, _blendDuration)
                       .SetAutoKill(true).SetLink(gameObject);
            DOTween.To(() => target.weight, x => target.weight = x, 1f, _blendDuration)
                   .SetAutoKill(true).SetLink(gameObject);
        }

        private void ResetAll()
        {
            foreach (var v in _nonDefaultVolumes)
                DOTween.To(() => v.weight, x => v.weight = x, 0f, _blendDuration)
                       .SetAutoKill(true).SetLink(gameObject);
        }
    }
}

Persistent 场景 [PostProcess] 子 Volume 层级

[PostProcess]
  ├── Volume_Default        Priority=0   Weight=1.0(始终生效)
  ├── Volume_Underwater     Priority=10  Weight=0进水时 blend 到 1.0
  ├── Volume_BossArena      Priority=10  Weight=0Boss 战开始时 blend 到 1.0
  ├── Volume_Death          Priority=20  Weight=0玩家死亡时 blend 到 1.0
  └── Volume_Victory        Priority=10  Weight=0Boss 击败时 blend 到 1.0
Volume Bloom Color Grading Vignette Chromatic Aberration
Default 0.3 正常 0.2 关闭
Underwater 0.1 青绿 Filter -0.3 0.45 0.4
BossArena 0.5 饱和度 +20% 0.35 0.15
Death 0 去饱和度 -80% 0.7(黑色) 0.8
Victory 0.8(白色) 亮度 +0.4 0 0

4.4.6 RegionLightController + RegionLightCatalogSO

// Assets/Scripts/VFX/RegionLightController.cs
// ⚠️ 挂在 Persistent 场景 [Lighting] GameObject架构 18_VFXFeedbackModule §12
// 监听 EVT_RegionEnteredStringEventChannelSO regionId平滑切换 Global Light 2D
namespace BaseGames.VFX
{
    public class RegionLightController : MonoBehaviour
    {
        [SerializeField] Light2D               _globalLight;
        [SerializeField] RegionLightCatalogSO  _catalog;
        [SerializeField] StringEventChannelSO  _onRegionEntered;
        [SerializeField] float                 _transitionDuration = 1.5f;

        private void OnEnable()  => _onRegionEntered.OnEventRaised += OnRegionEntered;
        private void OnDisable() => _onRegionEntered.OnEventRaised -= OnRegionEntered;

        private void OnRegionEntered(string regionId)
        {
            if (!_catalog.TryGet(regionId, out var config)) return;
            DOTween.To(() => _globalLight.color,
                       x => _globalLight.color = x,
                       config.Color, _transitionDuration)
                   .SetAutoKill(true).SetLink(gameObject);
            DOTween.To(() => _globalLight.intensity,
                       x => _globalLight.intensity = x,
                       config.Intensity, _transitionDuration)
                   .SetAutoKill(true).SetLink(gameObject);
        }
    }

    // Assets/Scripts/VFX/RegionLightCatalogSO.cs
    [CreateAssetMenu(menuName = "VFX/RegionLightCatalog")]
    public class RegionLightCatalogSO : ScriptableObject
    {
        [Serializable]
        public struct RegionLightConfig
        {
            public string regionId;
            public Color  Color;
            [Range(0f, 1f)] public float Intensity;
        }

        [SerializeField] RegionLightConfig[] _entries;

        public bool TryGet(string regionId, out RegionLightConfig cfg)
        {
            foreach (var e in _entries)
                if (e.regionId == regionId) { cfg = e; return true; }
            cfg = default;
            return false;
        }
    }
}

区域 Global Light 2D 参数速查

区域 颜色 强度
Forest扎根森林 #C8E8D0(淡绿) 0.8
Cave腐蚀洞穴 #1A0A2E(深紫) 0.2
Ruins坍塌废墟 #3D3028(暖褐) 0.5
Abyss深渊裂隙 #000820(极暗蓝) 0.1
Core核心熔炉 #4A1800(暗红橙) 0.6

4.4.7 Phase 2 追加的 VFX 资产

VFX Key 触发时机
VFX_ParryFlash 弹反成功瞬间
VFX_ShieldBreak 护盾破碎
VFX_FormSwitch_Sky 切换天魂形态
VFX_FormSwitch_Earth 切换地魂形态
VFX_FormSwitch_Death 切换命魂形态
VFX_Poison_Tick 中毒每秒跳动
VFX_Burn_Tick 燃烧 DOT
VFX_DashTrail 冲刺残影
VFX_WallSlide_Dust 蹬墙粉尘
VFX_DownAttack_Land 下劈落地冲击

5. Week 8难度系统 + AudioMixer 快照

参考文档11_AudioModule.md §3-519_DifficultyModule.md §4

⚠️ 架构说明Phase 2 音频工作是完善 Unity AudioMixer 快照和 BGM 管理;架构无 FMOD 依赖,不需要 IAudioBackend 接口层。Phase 1 实现的 AudioManager 无需重构。

5.1 AudioMixer 快照系统

四个快照(在 Assets/Audio/MainMixer.mixer 中已创建):

快照 触发条件 BGM 变化
Default 正常游戏 正常音量
Paused PauseMenu 打开 BGM 降低 + 低通滤波
Dead 玩家死亡 BGM 淡出 + 环境音压低
BossFight Boss 战开始 BGM 切换 + SFX 提升

AudioManager 快照 API 完善Week 8 补充到 Phase 1 的 AudioManager.cs

// 已在 Phase 1 AudioManager 骨架中预留Week 8 正式实现:
public void TransitionToSnapshot(string snapshotName, float transitionTime = 0.5f)  // ⚠️ TransitionToSnapshot架构 11 §2
{
    var snapshot = _mixer.FindSnapshot(snapshotName);
    snapshot?.TransitionTo(transitionTime);
}

GameManager 事件绑定

  • EVT_PauseRequestedTransitionToSnapshot("Paused", 0.3f)
  • EVT_BossFightToggled(true) → TransitionToSnapshot("BossFight", 1.0f) + BGM 切换
  • EVT_PlayerDiedTransitionToSnapshot("Dead", 0.5f)

5.1.1 AudioEventSO

参考文档11_AudioModule.md §5

// Assets/Scripts/Audio/AudioEventSO.cs
// ⚠️ 字段全大写Clips, VolumeMin/Max, PitchMin/Max方法为 Play/PlayOneShot架构 11 §5
[CreateAssetMenu(menuName = "Audio/AudioEvent")]
public class AudioEventSO : ScriptableObject
{
    public AudioClip[]  Clips;            // 随机挑选一个播放
    [Range(0f, 1f)]
    public float VolumeMin = 0.9f;
    [Range(0f, 1f)]
    public float VolumeMax = 1.0f;
    [Range(0.5f, 2f)]
    public float PitchMin = 0.95f;
    [Range(0.5f, 2f)]
    public float PitchMax = 1.05f;
    public AudioMixerGroup MixerGroup;    // ⚠️ 路由到子混音组(如 SFX_Player架构 11 §5

    public void Play(AudioSource source)
    {
        if (Clips == null || Clips.Length == 0) return;
        source.outputAudioMixerGroup = MixerGroup;  // ⚠️ 架构 11 §5
        source.clip   = Clips[Random.Range(0, Clips.Length)];
        source.volume = Random.Range(VolumeMin, VolumeMax);
        source.pitch  = Random.Range(PitchMin, PitchMax);
        source.Play();
    }

    public void PlayOneShot(AudioSource source)
    {
        if (Clips == null || Clips.Length == 0) return;
        var clip = Clips[Random.Range(0, Clips.Length)];
        source.outputAudioMixerGroup = MixerGroup;  // ⚠️ 架构 11 §5
        source.PlayOneShot(clip, Random.Range(VolumeMin, VolumeMax));
    }
}
// 资产路径Assets/ScriptableObjects/Audio/
// 命名规范AUD_{Category}_{Name}.asset例 AUD_Player_SwordSlash.asset

5.1.2 GlobalSFXPlayer

参考文档11_AudioModule.md §6

// Assets/Scripts/Audio/GlobalSFXPlayer.cs
// ⚠️ 静态入口 Play(AudioEventSO, Vector2?)Singleton 为 _instance非 Instance架构 11 §6
public class GlobalSFXPlayer : MonoBehaviour
{
    private static GlobalSFXPlayer _instance;

    [SerializeField] private AudioMixerGroup _sfxGroup;
    [SerializeField] private AudioSource     _globalSFXSource;

    private void Awake()
    {
        if (_instance != null) { Destroy(gameObject); return; }
        _instance = this;
    }

    /// <summary>在任意地方播放 SFX。worldPos != null 时使用对象池创建空间音源。</summary>
    public static void Play(AudioEventSO audioEvent, Vector2? worldPos = null)
    {
        if (worldPos.HasValue)
        {
            // 使用对象池获取临时 AudioSource GO播放后自动归还
            // (Phase 2 简化实现:直接 PlaySFXAtPosition 即可)
            AudioManager.Instance.PlaySFXAtPosition(
                audioEvent.Clips[Random.Range(0, audioEvent.Clips.Length)],
                worldPos.Value,
                Random.Range(audioEvent.VolumeMin, audioEvent.VolumeMax));
        }
        else
        {
            audioEvent.Play(_instance._globalSFXSource);
        }
    }
}

5.1.3 AudioConfigSO

参考文档11_AudioModule.md §7

⚠️ Phase 1 的 BGMController 已引用 _config.VictoryStingBGM / GetBossBGM() / GetZoneBGM()此处补全类定义P2-8 任务)。

// Assets/Scripts/Audio/AudioConfigSO.cs — 程序集 BaseGames.Audio
[CreateAssetMenu(menuName = "Audio/AudioConfig")]
public class AudioConfigSO : ScriptableObject
{
    [System.Serializable]
    public struct ZoneBGMEntry
    {
        public string    ZoneId;
        public AudioClip BGMClip;
        public float     FadeDuration;
    }

    [System.Serializable]
    public struct BossBGMEntry
    {
        public string    BossId;
        public AudioClip BGMClip;
    }

    public ZoneBGMEntry[]  ZoneBGMs;
    public BossBGMEntry[]  BossBGMs;
    public AudioClip       MainMenuBGM;
    public AudioClip       GameOverSting;            // 死亡时短音乐片段
    public AudioClip       VictoryStingBGM;          // Boss 击败后胜利音乐片段
    public float           VictoryStingDuration = 4f; // 胜利音乐播放时长(秒)

    public AudioClip GetZoneBGM(string zoneId)
    {
        foreach (var e in ZoneBGMs)
            if (e.ZoneId == zoneId) return e.BGMClip;
        return null;
    }

    public AudioClip GetBossBGM(string bossId)
    {
        foreach (var e in BossBGMs)
            if (e.BossId == bossId) return e.BGMClip;
        return null;
    }
}
// 资产路径Assets/ScriptableObjects/Audio/AUD_Config.asset

⚠️ Footstep + Underwater 音频:架构 11 §9-10 定义了 FootstepMaterial enum、FootstepAudioConfigSOFootstepMaterialMarkerUnderwaterAudioController这两个系统与玩家游泳Phase 3 LiquidZone及地面脚步相关留至 Phase 3 Week 11SwimState 完整实现时)集成实现。

19_DifficultyModule.md §3-4 实现:

// Assets/Scripts/Core/Difficulty/DifficultyScalerSO.cs
namespace BaseGames.Core
{
    [CreateAssetMenu(menuName = "Core/DifficultyScaler")]
    public class DifficultyScalerSO : ScriptableObject
    {
        [Header("标识")]
        public DifficultyLevel level;

        [Header("玩家属性缩放")]
        [Range(0.1f, 3.0f)] public float PlayerMaxHPMultiplier    = 1.0f;
        [Range(0.1f, 3.0f)] public float PlayerDamageMultiplier   = 1.0f;
        [Range(0.0f, 2.0f)] public float InvincibilityFrameScale  = 1.0f;

        [Header("敌人属性缩放")]
        [Range(0.1f, 3.0f)] public float EnemyDamageMultiplier    = 1.0f;
        [Range(0.1f, 3.0f)] public float EnemyHPMultiplier        = 1.0f;
        [Range(0.1f, 3.0f)] public float BossDamageMultiplier     = 1.0f;
        [Range(0.1f, 3.0f)] public float BossHPMultiplier         = 1.0f;

        [Header("商店价格")]
        [Range(0.5f, 2.0f)] public float ShopPriceMultiplier      = 1.0f;

        [Header("游戏规则")]
        public bool  CanReviveWithGeoLoss     = true;
        public bool  InstantDeathOnZeroHP     = false;  // SteelSoul 专用HP 归零清档
        public bool  GeoPenaltyOnDeath        = true;   // false = Easy 无 Geo 损失

        [Header("AI 行为Behavior Designer 黑板变量名)")]
        public float EnemyAttackIntervalScale = 1.0f;   // 攻击间隔倍率Hard < 1 = 更频繁)
        public float EnemyAggroRangeScale     = 1.0f;   // 感知范围倍率
        [Range(0.3f, 2.0f)]
        public float EnemyReactionTimeScale   = 1.0f;   // ⚠️ 反应时间倍率(>1 = 更慢 = 更简单),架构 19 §3
        [Range(0, 5)]
        public int   EnemyAggressionLevel     = 2;      // ⚠️ 0=被动 … 5=全力出击(影响 BT 决策权重),架构 19 §3

        [Header("掉落与奖励")]
        [Range(0.0f, 3.0f)]
        public float GeoDropMultiplier        = 1.0f;   // ⚠️ Geo 掉落量倍率Easy 可给更多),架构 19 §3
    }
}
// Assets/Scripts/Core/Difficulty/DifficultyManager.cs
namespace BaseGames.Core
{
    /// <summary>
    /// 全局难度管理器,挂在 Persistent 场景 [GameManagers] 下。
    /// 持有当前难度 ScalerSO提供静态访问入口广播难度变更事件。
    /// </summary>
    [DefaultExecutionOrder(-900)]   // ⚠️ 架构 19_DifficultyModule §4早于 PlayerStats/EnemyStats(-800) Awake 执行
    public class DifficultyManager : MonoBehaviour
    {
        // ── Inspector ────────────────────────────────────────
        [SerializeField] DifficultyScalerSO[]         _allScalers;       // 4 档资产
        [SerializeField] DifficultyChangedEventChannel _onDifficultyChanged;

        // ── Singleton ────────────────────────────────────────
        public static DifficultyManager Instance { get; private set; }

        // ── Runtime State ────────────────────────────────────
        public DifficultyLevel    CurrentLevel  { get; private set; } = DifficultyLevel.Normal;
        public DifficultyScalerSO CurrentScaler { get; private set; }

        void Awake()
        {
            Instance = this;
            // SaveData 加载后由 GameManager 调用 Apply此处初始化为 Normal
            Apply(DifficultyLevel.Normal);
        }

        /// <summary>
        /// 应用难度。新游戏开始/读档时由 GameManager 调用。
        /// </summary>
        public void Apply(DifficultyLevel level)
        {
            // SteelSoul 不可降级
            if (CurrentLevel == DifficultyLevel.SteelSoul && level != DifficultyLevel.SteelSoul)
            {
                Debug.LogWarning("[DifficultyManager] SteelSoul 模式不可降级");
                return;
            }
            CurrentLevel  = level;
            CurrentScaler = GetScaler(level);
            _onDifficultyChanged.Raise(CurrentScaler);
        }

        public DifficultyScalerSO GetScaler(DifficultyLevel level)
        {
            foreach (var s in _allScalers)
                if (s.level == level) return s;
            Debug.LogError($"[DifficultyManager] 找不到 {level} 的 ScalerSO");
            return _allScalers[0]; // fallback
        }

        /// <summary>
        /// 游戏进行中切换难度(仅允许 Easy ↔ Normal ↔ Hard不可切换到 SteelSoul
        /// </summary>
        public void ChangeDifficulty(DifficultyLevel newLevel)
        {
            if (newLevel == DifficultyLevel.SteelSoul)
            {
                Debug.LogWarning("[DifficultyManager] 游戏进行中不可切换到 SteelSoul");
                return;
            }
            Apply(newLevel);
        }
    }
}

四份 DifficultyScalerSO 资产Assets/ScriptableObjects/Core/Difficulty/

  • Difficulty_Easy.asset:玩家 HP ×1.5,敌人伤害 ×0.7GeoPenaltyOnDeath = falseGeoDropMultiplier = 1.2EnemyReactionTimeScale = 1.4EnemyAggressionLevel = 1
  • Difficulty_Normal.asset:全部 ×1.0(默认),EnemyAggressionLevel = 2
  • Difficulty_Hard.asset:玩家 HP ×0.75,敌人伤害 ×1.3,敌人 HP ×1.2EnemyAttackIntervalScale = 0.8EnemyReactionTimeScale = 0.7EnemyAggressionLevel = 3
  • Difficulty_SteelSoul.asset:玩家 HP ×1.2,敌人伤害 ×1.5InstantDeathOnZeroHP = trueEnemyReactionTimeScale = 0.6EnemyAggressionLevel = 4

⚠️ 架构 19_DifficultyModule §3 完整字段见 DifficultyScalerSO 上方的资产预设表,以上为关键差异项。

各系统注入

  • PlayerStats — 难度由 DifficultyManager._onDifficultyChanged 事件驱动(无 Initialize 参数)
  • EnemyStats.Initialize(so) — 仅传入 EnemyStatsSO(架构 07_EnemyModule §2难度缩放由 DifficultyManager.Apply 广播 DifficultyChangedEventChannel 统一驱动

6. Week 9敌人扩展 + BossBase 骨架

参考文档07_EnemyModule.md §3-5

6.1 新增敌人类型

RangedEnemy(远程攻击):

  • 继承 EnemyBase
  • BD BT在攻击距离内静止 → BeginAttack(AttackType.Ranged) → 生成 Projectile
  • Projectile 飞行中持有 HitBox,命中玩家 HurtBox 后归还对象池

FlyingEnemy(飞行巡逻):

  • 继承 EnemyBase
  • 不使用 PathBerserker2d空中无需 NavSurface
  • 使用 Vector2.MoveTowards 直线追踪玩家
  • 碰撞攻击(OnTriggerStay2D

6.1.1 Projectile 弹射物系统

参考文档06_CombatModule.md §7

// Assets/Scripts/Combat/ProjectileConfigSO.cs
// ⚠️ 弹射物静态配置(数据与运行时实例分离),架构 06 §7
[CreateAssetMenu(menuName = "Combat/ProjectileConfig")]
public class ProjectileConfigSO : ScriptableObject
{
    public DamageSourceSO DamageSource;   // 伤害来源
    [Header("运动")]
    public float speed          = 12f;   // 飞行速度 (m/s)
    public float lifetime       = 5f;    // 生存时间 (s)
    public float launchAngleDeg = 45f;   // ArcProjectile 发射角(度)
    public float gravityScale   = 1f;    // ArcProjectile 重力缩放
    public float homingStrength = 4f;    // HomingProjectile 追踪角速度(弧度/秒)
    [Header("对象池")]
    public string poolKey;               // AddressKeys 常量,用于 GlobalObjectPool 类名为 GlobalObjectPool非 ObjectPoolManager架构 13_AssetPoolModule §3
    [Header("弹反")]
    public float parrySpeedMultiplier  = 1.2f;  // 弹反后速度倍率
    public float parryDamageMultiplier = 2.0f;  // 弹反伤害倍率
}

// Assets/Scripts/Combat/Projectile.cs基类
// ⚠️ 直线 LinearProjectile、抛物线 ArcProjectile、追踪 HomingProjectile 均继承此基类(架构 06 §7
[RequireComponent(typeof(Rigidbody2D), typeof(HitBox))]
public class Projectile : MonoBehaviour
{
    [HideInInspector] public DamageInfo DamageInfo;  // 由发射方注入
    [HideInInspector] public Vector2    Direction;   // 归一化发射方向

    protected ProjectileConfigSO _config;
    protected Rigidbody2D        _rb;
    protected HitBox             _hitBox;
    protected float              _aliveTimer;

    public virtual void Initialize(ProjectileConfigSO config, DamageInfo damageInfo, Vector2 direction)
    {
        _config     = config;
        DamageInfo  = damageInfo;
        Direction   = direction.normalized;
        _aliveTimer = 0f;
        _rb         = GetComponent<Rigidbody2D>();
        _hitBox     = GetComponent<HitBox>();
        OnInitialized();
    }

    protected virtual void OnInitialized() { }

    protected virtual void Update()
    {
        _aliveTimer += Time.deltaTime;
        if (_aliveTimer >= _config.lifetime) ReturnToPool();
    }

    protected void ReturnToPool()
    {
        gameObject.SetActive(false);
        GlobalObjectPool.Instance.Despawn(_config.poolKey, gameObject);  // ⚠️ GlobalObjectPool非 ObjectPoolManager架构 13_AssetPoolModule §3
    }
}

// ── 抛物线弹射物(投石、毒液弹)────────────────────────────────────────────────
// ⚠️ ArcProjectile 继承 Projectile架构 06 §7
public class ArcProjectile : Projectile
{
    protected override void OnInitialized()
    {
        float angle = _config.launchAngleDeg * Mathf.Deg2Rad;
        _rb.velocity = new Vector2(
            Direction.x * _config.speed * Mathf.Cos(angle),
            _config.speed * Mathf.Sin(angle)
        );
        _rb.gravityScale = _config.gravityScale;
    }
}

// ── 追踪弹射物Boss 阶段特殊弹、追踪蜂群)────────────────────────────────────
// ⚠️ HomingProjectile 通过 TransformEventChannelSO 注入目标(零耦合),架构 06 §7
public class HomingProjectile : Projectile
{
    [SerializeField] TransformEventChannelSO _onPlayerSpawned;
    Transform _target;

    void OnEnable()  => _onPlayerSpawned.OnEventRaised += t => _target = t;
    void OnDisable() => _onPlayerSpawned.OnEventRaised -= t => _target = t;

    public void SetTarget(Transform t) => _target = t;

    protected override void OnInitialized() => _rb.velocity = Direction * _config.speed;

    protected override void Update()
    {
        base.Update();
        if (_target == null) return;
        Vector2 toTarget = ((Vector2)_target.position - (Vector2)transform.position).normalized;
        Vector2 newVel   = Vector2.MoveTowards(
            _rb.velocity.normalized, toTarget,
            _config.homingStrength * Time.deltaTime) * _config.speed;
        _rb.velocity = newVel;
    }
}

// ── ProjectileManager追踪弹辅助缓存常驻 Persistent 场景)─────────────────
// ⚠️ 架构 06 §7在发射追踪弹时注入已缓存的玩家 Transform
public class ProjectileManager : MonoBehaviour
{
    [SerializeField] TransformEventChannelSO _onPlayerSpawned;
    Transform _playerTransform;

    void OnEnable()  => _onPlayerSpawned.OnEventRaised += t => _playerTransform = t;
    void OnDisable() => _onPlayerSpawned.OnEventRaised -= t => _playerTransform = t;

    public Transform PlayerTransform => _playerTransform;

    public void LaunchHoming(HomingProjectile proj, Vector2 origin, Vector2 direction,
                              ProjectileConfigSO config, DamageInfo damageInfo)
    {
        proj.Initialize(config, damageInfo, direction);
        proj.SetTarget(_playerTransform);   // 直接注入缓存值
    }
}
// ⚠️ 字段名以架构 07_EnemyModule §14 为准AddressKey→ItemIdDropChance→BaseWeightGeoRange→GuaranteedGeoMin/Max
[CreateAssetMenu(menuName = "Enemies/LootTable")]
public class LootTableSO : ScriptableObject
{
    [Serializable]
    public struct LootEntry
    {
        public string ItemId;              // ⚠️ 非 AddressKey"" 表示纯 Geo 掉落
        public int    GeoAmount;           // ItemId 为空时使用
        public float  BaseWeight;          // ⚠️ 非 DropChance加权随机权重相对值
        public bool   ScaleWithDifficulty; // true → Hard 难度时权重 × 1.5
    }
    public LootEntry[] Entries;
    public int GuaranteedGeoMin;           // ⚠️ 非 GeoRange死亡必掉最低 Geo
    public int GuaranteedGeoMax;
}

public static class LootResolver
{
    public static void Resolve(LootTableSO table, Vector2 worldPosition)
    {
        if (table == null) return;
        // 1. 保底 Geo
        int geo = Random.Range(table.GuaranteedGeoMin, table.GuaranteedGeoMax + 1);
        if (geo > 0) CollectibleSpawner.SpawnGeo(worldPosition, geo);
        // 2. 按 BaseWeight 加权随机Hard 难度时 ScaleWithDifficulty=true 的条目权重 × 1.5
        float totalWeight = 0f;
        var difficulty = DifficultyManager.Instance.CurrentLevel;
        foreach (var entry in table.Entries)
        {
            float w = entry.BaseWeight;
            if (entry.ScaleWithDifficulty && difficulty >= DifficultyLevel.Hard) w *= 1.5f;
            totalWeight += w;
        }
        float roll = Random.Range(0f, totalWeight);
        float cumulative = 0f;
        foreach (var entry in table.Entries)
        {
            float w = entry.BaseWeight;
            if (entry.ScaleWithDifficulty && difficulty >= DifficultyLevel.Hard) w *= 1.5f;
            cumulative += w;
            if (roll <= cumulative)
            {
                if (!string.IsNullOrEmpty(entry.ItemId))
                    CollectibleSpawner.SpawnItem(worldPosition, entry.ItemId);
                break;
            }
        }
    }
}

6.3 BossBase 骨架

// Assets/Scripts/Enemies/Boss/BossBase.cs
public class BossBase : EnemyBase
{
    [SerializeField] protected BossOrchestrator     _orchestrator;    // ⚠️ 架构 07_EnemyModule §10
    [SerializeField] protected TelegraphSystem      _telegraph;       // ⚠️ 架构 07_EnemyModule §10

    [Header("Boss 识别")]
    [SerializeField] private string                  _bossId;              // ⚠️ 用于事件 payload无 BossConfigSO

    [Header("Event Channels")]
    [SerializeField] private BoolEventChannelSO      _onBossFightEnded;    // ⚠️ Raise 方BossBase.Die()(架构 02 §4
    [SerializeField] private BossPhaseEventChannelSO _onBossPhaseChanged;  // ⚠️ Raise 方BossBase.EnterPhase()

    public string BossId => _bossId;
    protected int _currentPhase = 0;

    // Boss 专属接口(由 BD_EnterPhase 任务调用)
    // ⚠️ 方法名为 EnterPhase非 TransitionToPhase架构 07_EnemyModule §9
    public virtual void EnterPhase(int phase)
    {
        _currentPhase = phase;
        // 1. 短暂无敌
        // 2. 播放 Phase 过渡演出动画
        _onBossPhaseChanged.Raise(new BossPhaseEvent { BossId = _bossId, Phase = phase });
    }

    // HP 阈值检查BD_IsHPBelow 任务使用,无 CheckPhaseTransition MonoBehaviour 方法)
    public bool IsHPBelow(float ratio) => (float)_stats.CurrentHP / _stats.MaxHP < ratio;

    public override void Die()
    {
        base.Die();
        _onBossFightEnded.Raise(true);    // 广播胜利
        // 触发死亡过场EventChain
    }
}

6.3.1 CameraStateController 完整实现(补充 Boss/Death 相机)

参考文档17_CameraModule.md §3
Phase 1 仅创建了基础骨架A/B 双机 + SwitchRoom + RegisterRoomCamera + TriggerImpulse。Phase 2 添加 BossBase 时须同步完善 CameraStateController,加入以下字段和处理方法:

// 补充到 CameraStateController在 Phase 1 骨架基础上追加)
[Header("特殊状态相机(⚠️ 架构 17_CameraModule §3 要求)")]
[SerializeField] CinemachineCamera  _vcamBoss;       // Priority 30Boss 战激活)
[SerializeField] CinemachineCamera  _vcamDeath;      // Priority 50死亡时激活1.0s EaseIn
[SerializeField] CinemachineCamera  _vcamCutscene;   // Priority 40Phase 4 Cutscene 时激活)

[Header("Phase 2 事件频道")]
[SerializeField] BoolEventChannelSO _onBossFightToggled;  // true=开始 false=结束(⚠️ 与 BGMController 共用同一 SO
[SerializeField] VoidEventChannelSO _onPlayerDied;         // 死亡相机接管
[SerializeField] VoidEventChannelSO _onPlayerRespawned;    // 死亡相机释放

// 补充事件订阅(在 OnEnable/OnDisable 中追加)
_onBossFightToggled.OnEventRaised += OnBossFightToggled;
_onPlayerDied.OnEventRaised       += OnPlayerDied;
_onPlayerRespawned.OnEventRaised  += OnPlayerRespawned;

// 补充处理方法
private void OnBossFightToggled(bool started)
{
    _vcamBoss.Priority = started ? 30 : 0;
    if (started) _brain.DefaultBlend =
        new CinemachineBlendDefinition(CinemachineBlendDefinition.Style.EaseIn, 0.8f);
    else _brain.DefaultBlend = new CinemachineBlendDefinition(
        _defaultConfig.DefaultBlendStyle, _defaultConfig.DefaultBlendDuration);
}

private void OnPlayerDied()
{
    _vcamDeath.Priority = 50;   // 死亡相机接管1.0s EaseIn
    _brain.DefaultBlend =
        new CinemachineBlendDefinition(CinemachineBlendDefinition.Style.EaseIn, 1.0f);
}

private void OnPlayerRespawned()
{
    _vcamDeath.Priority = 0;
    _brain.DefaultBlend = new CinemachineBlendDefinition(
        _defaultConfig.DefaultBlendStyle, _defaultConfig.DefaultBlendDuration);
}

Persistent 场景结构更新:在 [CameraRig] 下新增 [SpecialCameras] 子组,包含 VCam_BossPriority 0VCam_DeathPriority 0VCam_CutscenePriority 0三个 CinemachineCamera。VCam_Cutscene Phase 4 才正式使用,但 Phase 2 可先创建 GO 留空。


6.4 DeathShade死亡遗骸

文件Assets/Scripts/World/DeathShade.cs(架构 07_EnemyModule §12

玩家死亡时由 GameManager.HandlePlayerDied() 在最后位置生成;与之交互可回收玩家死亡时持有的 Geo。

// ⚠️ 完整实现以架构 07_EnemyModule §12 为准
// ⚠️ IInteractable 定义在 BaseGames.World 命名空间Architecture 08 §7 / 14 §1
public class DeathShade : MonoBehaviour, IInteractable
{
    [Header("Event Channel")]
    [SerializeField] private StringEventChannelSO _onGeoRecovered;  // ⚠️ payload: sceneId通知存档

    private int     _storedGeo;
    private string  _sceneId;
    private Vector2 _worldPosition;

    public bool   CanInteract    => true;
    public string InteractPrompt => "回收遗骸";

    /// <summary>由 GameManager或 DeathShadeManager在玩家死亡时调用。</summary>
    public void Initialize(int geo, string sceneId, Vector2 pos)
    {
        _storedGeo     = geo;
        _sceneId       = sceneId;
        _worldPosition = pos;
        transform.position = pos;
    }

    public void Interact(Transform player)
    {
        // ⚠️ PlayerController 无 InstanceArchitecture 05 §2通过 player 参数获取
        var pc = player.GetComponent<PlayerController>();
        if (pc != null) pc.Stats.AddGeo(_storedGeo);
        _onGeoRecovered.Raise(_sceneId);
        Destroy(gameObject);  // 或归还对象池
    }

    public void OnPlayerEnterRange(Transform player) { }
    public void OnPlayerExitRange() { }

    // 存档集成(世界状态持久化,架构 07_EnemyModule §12
    public DeathShadeSaveData GetSaveData();
    public void LoadSaveData(DeathShadeSaveData data);
}

GameManager 集成Plan 02 §4.4 HandlePlayerDied 补充):

  • HandlePlayerDied() 在切入 GameState.Dead 后,通过 [SerializeField] DeathShade _deadShadePrefab + Instantiate 在玩家当前位置生成 DeathShade并调用 Initialize(currentGeo, currentSceneName, playerPos)
  • 玩家死后 Geo 清零;新死亡覆盖旧遗骸(旧 DeathShade 自动销毁)

7. 完成标准检查清单

□ 冲刺:施加冲刺力 + 无敌帧期间不受伤 + 落地后正确切换 Idle
□ 蹬墙:接触墙壁减速下滑 + 蹬墙跳正确方向
□ 下劈:空中踩踏敌人时玩家弹起 + 地面撞击 VFX
□ 上劈:向上攻击命中敌人 + 上劈后进入 Fall 状态
□ 弹反ParryWindow 内受攻击 → 弹反成功动画 + 敌人僵直
□ 护盾:受击时护盾优先吸收 + 护盾耐久条 UI 更新 + 破碎惩罚期间无护盾
□ 状态效果Poison 每秒掉血 + StatusEffectManager 正确 Tick + 到期自动移除
□ AnimationEventType攻击动画 HitBox 激活时机与动画帧完全同步
□ EventConfigEditorAnimationEventConfigSO Inspector 时间轴可视化正常渲染Clip 漂移警告触发(偏差>5帧
□ FormController三形态切换 + 各形态使用对应武器 + 调色板切换
□ AudioMixerBGM 跨房间平滑切换Snapshot TransitionSFX 无卡顿
□ DifficultyManagerHard 模式敌人 HP 和伤害按 scaler 正确缩放
□ RangedEnemyProjectile 飞行命中玩家,玩家 HP 减少
□ BossBasePhase 切换HP 降至 50%)动画演出正确触发
□ LootResolver敌人死亡时随机掉落 Geo + 指定道具
□ Console 无 Error

Phase 2 完成后进入 Phase 3。