Files
zeling_v2/Docs/Architecture/06_CombatModule.md
2026-05-08 11:04:00 +08:00

70 KiB
Raw Blame History

06 · 战斗模块

命名空间 BaseGames.CombatBaseGames.Combat.StatusEffectsBaseGames.Parry
程序集 BaseGames.CombatBaseGames.Combat.StatusEffectsBaseGames.Parry
路径 Assets/Scripts/Combat/Assets/Scripts/Parry/
依赖 BaseGames.Core.Events


目录

  1. DamageInfo 结构体
  2. DamageType / DamageCategory / DamageFlags / DamageTags / HitFxType / BreakLevel 枚举
  3. DamageSourceSO
  4. HitBox
  5. HurtBox
  6. 伤害流水线(完整时序)
  7. Projectile弹射物
  8. ParrySystem
  9. ParryConfigSO
  10. StatusEffectManager
  11. StatusEffect 基类与具体状态效果
  12. Layer 矩阵配置
  13. PoiseSystem霸体系统
  14. IBreakable — 机关/障碍物交互
  15. ClashResolver — 拼刀系统

1. DamageInfo 结构体

// 路径: Assets/Scripts/Combat/DamageInfo.cs
namespace BaseGames.Combat
{
    // Builder 工具类负责逐字段构建,构建后通过 Build() 拿到值类型快照。
    // Amount / FinalDamage 在 HurtBox/ShieldComponent 流水线内可被就地修改(局部变量)。
    [System.Serializable]
    public struct DamageInfo
    {
        public int              RawDamage;         // HitBox 设定的原始值(概念只读,由 Builder.SetRaw 写入一次)
        public int              Amount;            // 当前待处理伤害量(初始=RawDamage护盾/防御后递减)
        public int              FinalDamage;       // 经防御减免后最终 HP 扣除量HurtBox 写入)
        public Vector2          KnockbackDirection; // 归一化
        public float            KnockbackForce;
        public float            HitStunDuration;   // 秒
        public DamageType       Type;              // 元素属性Normal / Fire / Poison …
        public DamageCategory   Category;          // 来源分类NormalAttack / Skill / Trap …
        public DamageFlags      Flags;             // 行为标志Unblockable / CanBeParried …
        public DamageTags       Tags;              // 交互标签:用于机关/障碍物判定
        public Vector2          SourcePosition;    // 用于计算击退方向
        public int              SourceLayer;       // 攻击者 Layer
        public HitFxType        FxType;
        public BreakLevel       Break;
        public string           SourceId;          // DamageSourceSO.sourceId
        public string           SkillId;           // 触发此次伤害的技能 ID普通攻击为空

        // Builder 工具类(避免构造器参数爆炸)
        public class Builder
        {
            private DamageInfo _d;
            // SetRaw 同步初始化 AmountAmount 始终以 RawDamage 为起点)
            public Builder SetRaw(int v)              { _d.RawDamage = v; _d.Amount = v;        return this; }
            public Builder SetType(DamageType v)      { _d.Type = v;                            return this; }
            public Builder SetCategory(DamageCategory v) { _d.Category = v;                    return this; }
            public Builder SetFlags(DamageFlags v)    { _d.Flags = v;                           return this; }
            public Builder SetTags(DamageTags v)      { _d.Tags = v;                            return this; }
            public Builder SetSkillId(string v)       { _d.SkillId = v;                         return this; }
            public Builder SetSourceId(string v)      { _d.SourceId = v;                        return this; }
            public Builder SetKnockback(Vector2 dir, float force)
            { _d.KnockbackDirection = dir; _d.KnockbackForce = force;                           return this; }
            public Builder SetStun(float dur)         { _d.HitStunDuration = dur;               return this; }
            public Builder SetFx(HitFxType v)         { _d.FxType = v;                          return this; }
            public Builder SetBreak(BreakLevel v)     { _d.Break = v;                           return this; }
            public Builder SetSourcePos(Vector2 v)    { _d.SourcePosition = v;                  return this; }
            public Builder SetLayer(int v)            { _d.SourceLayer = v;                     return this; }
            public DamageInfo Build() => _d;
        }

        /// <summary>
        /// 零堆分配工厂(热路径首选)。直接从 <see cref="DamageSourceSO"/> 填入基础字段;
        /// KnockbackDirection / SourcePosition / SourceLayer 等运行时字段由调用方就地赋值——
        /// struct 是值类型,局部变量字段写入无任何堆分配。
        /// <br/>⚡ HitBox.OnTriggerEnter2D 等高频路径必须使用此方法;
        /// 仅在需要链式覆盖多字段的复杂场景(如 Boss 特殊相位攻击)才使用 Builder。
        /// </summary>
        public static DamageInfo From(DamageSourceSO so)
        {
            int baseAmt = Mathf.RoundToInt(so.BaseDamage * so.DamageMultiplier);
            return new DamageInfo
            {
                RawDamage       = baseAmt,
                Amount          = baseAmt,
                Type            = so.Type,
                Category        = so.Category,
                Flags           = so.Flags,
                Tags            = so.Tags,
                HitStunDuration = so.HitStunDuration,
                FxType          = so.FxType,
                Break           = so.BreakLevel,
                SourceId        = so.sourceId,
                SkillId         = so.skillId,
            };
        }
    }
}

字段语义RawDamage(初始原始值,只读) → Amount(流水线中被护盾/防御修改) → FinalDamageHurtBox 写入,最终 HP 扣除量)。
下游 IDamageable.TakeDamage 接收时,Amount == FinalDamage(已被 HurtBox 写入);EnemyBase.TakeDamageinfo.FinalDamage 正确。


2. 枚举定义

// ── 元素/物理属性(决定抗性、特效) ─────────────────────────────────────────────
public enum DamageType  { Normal, True, Fire, Poison, Ice, Lightning, Void }

// ── 来源分类(决定哪类机关/护盾/BUFF 响应) ───────────────────────────────────────
public enum DamageCategory
{
    NormalAttack  = 0,   // 武器普通攻击(连击链)
    SoulSkill     = 1,   // 魂技能(消耗灵力的主动技)
    SpiritSkill   = 2,   // 魄技能(消耗魄元的主动技)
    Projectile    = 3,   // 弹射物命中(含弹反后的投射物)
    EnvironmentTrap = 4, // 环境陷阱/机关(不属于角色主动攻击)
    StatusEffect  = 5,   // 持续状态效果(中毒、燃烧…)
    FallDamage    = 6,   // 坠落伤害
    Reflected     = 7,   // 弹反后反弹给攻击方的伤害
}

// ── 行为标志(多值叠加,控制伤害管线分支) ────────────────────────────────────────
[System.Flags]
public enum DamageFlags
{
    None            = 0,
    Unblockable     = 1 << 0,   // 无法弹反/格挡
    CanBeParried    = 1 << 1,
    IgnoreIFrame    = 1 << 2,
    PerfectParryOnly= 1 << 3,
    IsProjectile    = 1 << 4,
    CanClash        = 1 << 5,   // 近战可参与拼刀
    ForceBreak      = 1 << 6,   // 强制打断,无视霸体
    NoKnockback     = 1 << 7,
}

// ── 交互标签(用于机关/障碍物破坏条件判定,可多选) ─────────────────────────────────
// 机关 BreakConditionSO 指定「需要命中的 Tags 位集合」HitBox 发出的 DamageInfo.Tags
// 必须覆盖(包含)该位集合,机关才响应
[System.Flags]
public enum DamageTags : uint
{
    None            = 0,
    // 来源类型
    MeleeHit        = 1 << 0,   // 近战命中
    RangedHit       = 1 << 1,   // 远程/投射物命中
    SkillHit        = 1 << 2,   // 任意主动技能命中
    // 元素附加
    ElementFire     = 1 << 3,
    ElementPoison   = 1 << 4,
    ElementVoid     = 1 << 5,
    // 特殊能力
    AfterParry      = 1 << 6,   // 弹反后产生的攻击
    ChargedAttack   = 1 << 7,   // 蓄力攻击
    SkyFormOnly     = 1 << 8,   // 仅天形攻击
    EarthFormOnly   = 1 << 9,   // 仅地形攻击
    DeathFormOnly   = 1 << 10,  // 仅死形攻击
    // 破坏强度(与 BreakLevel 对应)
    BreakLight      = 1 << 11,   // 对应 BreakLevel.Light
    BreakMedium     = 1 << 12,   // 对应 BreakLevel.Medium
    BreakHeavy      = 1 << 13,   // 对应 BreakLevel.Heavy
    BreakBreaker    = 1 << 14,   // 对应 BreakLevel.Breaker
}

public enum HitFxType   { Spark, Slash, Blood, Magic, Heavy, Crit, Void, Heal, Parry, Fire, Ice }

// 攻击方的「打断等级」:此次攻击能打断多高的霸体
// DamageInfo.Break 字段使用此枚举HitBox / DamageSourceSO 配置
public enum BreakLevel
{
    None    = 0,   // 无打断能力(不触发霸体检查)
    Light   = 1,   // 打断 Light 以下霸体(普通小怪)
    Medium  = 2,   // 打断 Medium 以下霸体
    Heavy   = 3,   // 打断 Heavy 以下霸体Boss 普通超甲)
    Breaker = 4,   // 强力打断,仅 Unbreakable 无法抵挡
}

// 承受方的「霸体等级」:当前状态能抵抗多低的打断
// HurtBox 判定:(int)info.Break >= (int)currentPoiseLevel → 打断成功
// PlayerController攻击期间和 EnemyBase超甲状态均可激活
public enum PoiseLevel
{
    None        = 0,   // 无霸体(普通状态,任何打断均有效)
    Light       = 1,   // 轻霸体Light=0 的攻击无法打断)
    Medium      = 2,   // 中霸体Light/Medium 打断无效)
    Heavy       = 3,   // 重霸体Light/Medium/Heavy 打断无效)
    Unbreakable = 100, // 不可打断(只有特殊机制可打断)
}

3. DamageSourceSO

// 路径: Assets/Scripts/Combat/DamageSourceSO.cs
[CreateAssetMenu(menuName = "Combat/DamageSource")]
public class DamageSourceSO : ScriptableObject
{
    [Header("Identity")]
    public string          sourceId;          // 唯一标识(用于 PoiseOverrideTable
    // 触发此 Source 的技能 ID普通武器连击留空技能填入 FormSkillSO.skillId
    // 构建 DamageInfo 时自动写入 DamageInfo.SkillId
    public string          skillId;

    [Header("Base")]
    public int             BaseDamage;
    public float           DamageMultiplier = 1.0f;  // 连击倍率Attack3=×2.0, ParryCounter=×3.0
    public DamageType      Type;             // 元素属性
    public DamageCategory  Category;         // 来源分类(武器填 NormalAttack技能填 SoulSkill 等)
    public DamageFlags     Flags;            // 行为标志
    public DamageTags      Tags;             // 交互标签(决定能触发哪些机关)

    [Header("Physics")]
    public float           KnockbackForce;
    public float           HitStunDuration;
    public BreakLevel      BreakLevel;

    [Header("FX")]
    public HitFxType       FxType;

    [Header("Combo")]
    public float           ComboWindowDuration;  // 连击窗口持续时间
    public float           CancelWindowEnd;      // 动画归一化时间,到此可接受下一击输入

    // 便捷方法:根据此 SO 构建初始 DamageInfo方向/击退等由 HitBox 补全)
    // ⚡ 热路径请改用零分配工厂DamageInfo.From(sourceSO),直接返回 struct无堆分配。
    // 本方法保留用于需链式覆盖多字段的特殊场景(如 Boss 特殊相位攻击修改 Flags/Tags
    public DamageInfo.Builder CreateBuilder() => new DamageInfo.Builder()
        .SetRaw(Mathf.RoundToInt(BaseDamage * DamageMultiplier))
        .SetType(Type)
        .SetCategory(Category)
        .SetFlags(Flags)
        .SetTags(Tags)
        .SetStun(HitStunDuration)
        .SetFx(FxType)
        .SetBreak(BreakLevel)
        .SetSourceId(sourceId)
        .SetSkillId(skillId);
}

4. HitBox

寄宿规则HitBox 不挂载在角色 Prefab 本体上,而是作为武器 PrefabWPN_*.prefab)或
技能 HitBox PrefabSKL_*_HitBox.prefab)的子节点存在。

  • 武器WeaponManager.Equip() 时 Instantiate 武器 Prefab 并挂至 [WeaponSocket];卸装时 Destroy。
  • 技能SkillManager.TrySoulSkill() 等施放时 Instantiate HitBox Prefab 并挂至 [SkillSocket];持续时间结束后 Destroy。
  • 投射物Projectile 自身携带 HitBox见 §7随 Projectile 生命周期销毁。
    这样碰撞盒的形状、大小、偏移完全由各武器/技能的 Prefab 决定,角色本体无需改动。
// 路径: Assets/Scripts/Combat/HitBox.cs
[RequireComponent(typeof(Collider2D))]
public class HitBox : MonoBehaviour
{
    [SerializeField] private DamageSourceSO _defaultSource;  // Inspector 默认值
    [SerializeField] private float _hitCooldown = 0.1f;     // 同目标多帧冷却

    // 运行时注入AttackState / Projectile 覆盖默认 SO
    private DamageSourceSO _currentSource;
    private Transform      _attackerTransform;
    private bool           _isActive;

    // 命中确认委托PlayerCombat / EnemyCombat 订阅)
    public System.Action<DamageInfo> OnHitConfirmed;

    // 激活 / 关闭
    public void Activate(DamageSourceSO source = null, Transform attacker = null);
    public void Deactivate();

    // 内部
    private void OnTriggerEnter2D(Collider2D other)
    {
        if (!_isActive) return;
        if (!CheckCooldown(other)) return;

        var knockDir = ((Vector2)other.bounds.center - (Vector2)_attackerTransform.position).normalized;
        // ⚡ 零 GCstruct 工厂返回值类型,就地修改运行时字段,无堆分配
        var info = DamageInfo.From(_currentSource);
        info.KnockbackDirection = knockDir;
        info.KnockbackForce     = _currentSource.KnockbackForce;
        info.SourcePosition     = _attackerTransform.position;
        info.SourceLayer        = _attackerTransform.gameObject.layer;

        // ① 命中 HurtBox敌人/玩家受击)
        var hurtBox = other.GetComponent<HurtBox>();
        if (hurtBox != null)
        {
            hurtBox.ReceiveDamage(info);
            OnHitConfirmed?.Invoke(info);
            return;
        }

        // ② 命中 IBreakable机关/障碍物——Category/Tags 满足条件才响应
        var breakable = other.GetComponent<IBreakable>();
        breakable?.TryInteract(info);
        // 注意:机关命中不触发 OnHitConfirmed不给灵力如需特殊处理可扩展
    }

    private Dictionary<Collider2D, float> _hitCooldownTimers = new();
    private bool CheckCooldown(Collider2D other)
    {
        float now = Time.time;
        if (_hitCooldownTimers.TryGetValue(other, out float last) && now - last < _hitCooldown)
            return false;
        _hitCooldownTimers[other] = now;
        return true;
    }
}

5. HurtBox

// 路径: Assets/Scripts/Combat/HurtBox.cs
[RequireComponent(typeof(Collider2D))]
public class HurtBox : MonoBehaviour
{
    // 伤害接受方的引用(接口,避免直接依赖具体类型)
    private IDamageable _owner;   // PlayerController 或 EnemyBase 实现
    private IShieldable _shieldable;    // 可选,由 PlayerController.Awake() 注入

    [SerializeField] private DamageInfoEventChannelSO    _onDamageDealt;    // EVT_DamageDealtAnalyticsManager
    [SerializeField] private HitConfirmedEventChannelSO  _onHitConfirmed;   // EVT_HitConfirmedVFX/Audio/Feedback

    // 由 PlayerController.Awake() 调用,注入护盾层
    public void SetShieldable(IShieldable shieldable) => _shieldable = shieldable;

    // 由 PlayerController.Awake() 调用,注入弹反系统(仅玩家侧 HurtBox
    private ParrySystem _parrySystem;
    public void SetParrySystem(ParrySystem ps) => _parrySystem = ps;

    // 由 PlayerAnimationEvents 的 EnableIFrame / DisableIFrame 动画事件调用
    // (见 24_AnimEventModule §5
    private bool _isHurtBoxInvincible;
    public void SetInvincible(bool value) => _isHurtBoxInvincible = value;

    // PoiseSystem 引用(由 EnemyBase.Awake() 注入;玩家侧由 PlayerController 实现 IPoiseSource
    private IPoiseSource _poiseSource;   // 可选:实体若实现 IPoiseSource 则持有此引用
    public void SetPoiseSource(IPoiseSource src) => _poiseSource = src;

    // 由 HitBox 直接调用
    public void ReceiveDamage(DamageInfo info)
    {
        if (!_isActive) return;   // HurtBox 被禁用时忽略

        // 1. 无敌帧检查
        if ((_owner.IsInvincible || _isHurtBoxInvincible) && !info.Flags.HasFlag(DamageFlags.IgnoreIFrame)) return;

        // 2. 弹反检查(须在伤害计算前;仅玩家侧 HurtBox 注入了 _parrySystem
        // Phase 1状态查询模式 — ConsumeParry() 不感知 DamageInfo避免 Parry ↔ Combat 循环依赖。
        // Phase 2若需完美弹反判断等提取 BaseGames.Combat.Data 子程序集。
        if (_parrySystem != null && info.Flags.HasFlag(DamageFlags.CanBeParried))
            if (_parrySystem.ConsumeParry()) return;

        // 3. 霸体检查BreakLevel vs PoiseLevel
        //    若实体当前有霸体且攻击等级不足,仅播放受击 VFX不扣血不打断
        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;
            }
        }

        // 4. 护盾层拦截(玩家专属,在防御减免之前)
        //    AbsorbDamage 返回穿透量int0 表示全部被护盾吸收,>0 表示穿透量继续走后续 TakeDamage 流程
        if (_shieldable != null && _shieldable.HasShield)
        {
            int passThrough = _shieldable.AbsorbDamage(info.Amount);
            if (passThrough <= 0) return;  // 全部被护盾吸收,终止后续伤害
            info.Amount = passThrough;      // 穿透量继续走防御减免 → TakeDamage
        }

        // 5. 计算 FinalDamage防御减免最低 1
        int finalDamage = Mathf.Max(1, info.Amount - _owner.Defense);
        info.Amount     = finalDamage;
        info.FinalDamage = finalDamage;

        // 6. 调用 _owner.TakeDamage
        _owner.TakeDamage(info);

        // 7. 全局广播
        _onDamageDealt.Raise(info);
        _onHitConfirmed.Raise(new HitInfo { DamageInfo = info, HitPoint = transform.position });

        // 8. 状态效果触发DoT — Fire / Poison
        if (_owner is MonoBehaviour mb)
        {
            var sem = mb.GetComponent<StatusEffectManager>();
            if (sem != null)
            {
                if (info.Type == DamageType.Fire)        sem.ApplyEffect(new FireEffect());
                else if (info.Type == DamageType.Poison) sem.ApplyEffect(new PoisonEffect());
            }
        }
    }

    // HurtBox 激活状态(用于 IFrame 动画事件之外的整体开关)
    private bool _isActive = true;
    public void SetActive(bool value) => _isActive = value;
}

// 所有可受击对象实现的接口
public interface IDamageable
{
    bool   IsInvincible { get; }
    int    Defense      { get; }   // 用于 FinalDamage 计算
    void   TakeDamage(DamageInfo info);
}

// 可持有霸体的实体实现此接口PlayerController 攻击期间、EnemyBase 超甲状态)
// HurtBox 持有 IPoiseSource 引用,在 ReceiveDamage 中做等级比较
// 参见 §13 PoiseSystem 和 Design/54_PoiseSystem.md
public interface IPoiseSource
{
    /// <summary>返回当前帧的霸体等级(受时间窗口/状态机控制)</summary>
    PoiseLevel GetCurrentPoiseLevel();
}

6. 伤害流水线

[AttackState.OnStateEnter]
    → PlayerCombat.EnableWeaponHitBox(dir)
        → HitBox.Activate(source, attackerTransform)
            → Collider2D enabled = true

[Physics2D OnTriggerEnter2D: HitBox → HurtBox]
    → HitBox.OnTriggerEnter2D(hurtBoxCollider)
        → 检查 hitCooldown
        → 构建 DamageInfo
            knockDir = (target.pos - attacker.pos).normalized  [见下方特殊情况处理]
        → HurtBox.ReceiveDamage(info)
            → 1. 检查无敌帧IgnoreIFrame flag
            → 2. 检查弹反CanBeParried && _parrySystem.ConsumeParry() → return
            → 3. 检查霸体BreakLevel vs Poise
            → 4. 护盾层拦截(仅玩家)
            → 5. 计算 FinalDamage = RawDamage - Defense最低 1
            → 6. _owner.TakeDamage(finalInfo)
                → PlayerStats.TakeDamage(amt) → EVT_HPChanged
                → ForceState(HurtState) [if not DashState]
            → 7. _onDamageDealt.Raise(finalInfo) [全局广播]
                ← PlayerStats.AddSoulPower(+10) [if player hit enemy]
                ← EnemyFeedback.OnHit(info)
                ← AnalyticsManager.OnDamage(info)
            → 8. DoT 触发Fire/Poison → StatusEffectManager.ApplyEffect

[AttackState.OnStateExit]
    → PlayerCombat.DisableAllWeaponHitBoxes()

击退特殊情况处理

\vec{Knockback} = \text{normalize}(\text{HurtPos} - \text{SourcePos}) \times \text{KnockbackForce}

NoKnockback 标记的攻击直接设为 Vector2.zero

情况 处理方式
玩家被正下方攻击地刺SourcePos.y < HurtPos.y 且水平偏差 < 阈值) 方向强制为 (0, 1) 向上
玩家贴墙被打(Physics2D.Raycast 向击退水平方向检测到墙壁) 水平分量减为 50%,保留垂直分量
Boss 固定击退(DamageInfo.FlagsFixedKnockback 或由 DamageSourceSO 配置) 直接使用 DamageInfo.KnockbackDirection,不动态重算

7. Projectile

// 路径: Assets/Scripts/Combat/ProjectileConfigSO.cs
// 弹射物静态配置(数据与运行时实例分离)
[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 常量,用于 ObjectPoolManager
    [Header("弹反")]
    public float          parrySpeedMultiplier   = 1.2f;  // 弹反后速度倍率
    public float          parryDamageMultiplier  = 2.0f;  // 弹反伤害倍率(对攻击者)
}

// 路径: Assets/Scripts/Combat/Projectile.cs
[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;

    // 对象池取出时调用(替代 Awake/Start
    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 virtual void OnTriggerEnter2D(Collider2D other)
    {
        // 碰到地面 / 墙壁也消失
        if (other.gameObject.layer == LayerMask.NameToLayer("Ground"))
            ReturnToPool();
    }

    protected void ReturnToPool()
    {
        gameObject.SetActive(false);
        ObjectPoolManager.Instance.Despawn(_config.poolKey, gameObject);
    }
}

// ── 直线弹射物(默认实现)────────────────────────────────────────────────────────
public class LinearProjectile : Projectile
{
    protected override void OnInitialized()
        => _rb.velocity = Direction * _config.speed;
}

// ── 抛物线弹射物 ─────────────────────────────────────────────────────────────────
// 用途:投石、毒液弹、炸弹投掷。
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;
    }
}

// ── 追踪弹射物 ──────────────────────────────────────────────────────────────────
// 追踪目标通过 TransformEventChannelSO 注入,零耦合(不使用 FindGameObjectWithTag
// 用途追踪蜂群、Boss 阶段特殊弹。
public class HomingProjectile : Projectile
{
    [SerializeField] TransformEventChannelSO _onPlayerSpawned;

    Transform _target;

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

    // 发射方可通过 ProjectileManager.LaunchHoming 直接注入已缓存的 Transform
    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 场景;在发射追踪弹时注入已缓存的玩家 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);   // 直接注入缓存值
    }
}

// ── 可被弹反的弹射物 ─────────────────────────────────────────────────────────────
// ParrySystem.HandleSuccessfulParry 直接调用 OnParried(),不通过事件频道间接回调。
public class ParryableProjectile : LinearProjectile
{
    bool _isParried = false;

    // 由 ParrySystem.HandleSuccessfulParry() 直接调用
    public void OnParried(Transform parryer)
    {
        if (_isParried) return;
        _isParried = true;

        // 1. 方向反转速度提升parrySpeedMultiplier
        Direction    = -Direction;
        _rb.velocity = Direction * _config.speed * _config.parrySpeedMultiplier;

        // 2. 更新 DamageInfo攻击者变为玩家伤害乘以反弹倍率
        DamageInfo = new DamageInfo.Builder()
            .SetRaw(Mathf.RoundToInt(DamageInfo.RawDamage * _config.parryDamageMultiplier))
            .SetFlags(DamageFlags.IsProjectile | DamageFlags.Unblockable)
            .SetKnockback(Direction, DamageInfo.KnockbackForce * _config.parrySpeedMultiplier)
            .Build();

        // 3. 切换碰撞层:现在只伤害敌人
        gameObject.layer = LayerMask.NameToLayer("Projectile");
    }
}

8. ParrySystem

弹反系统使用 5 阶段状态机(参见 Design/05_ParrySystem.md

Inactive → [按弹反键] → Startup(0.05s) → Active(0.28s) → [命中可弹反攻击] → ParrySuccess
        ↓Startup/Active 超时)                                                    ↓
      EndLag(0.10s) → Inactive                              CounterWindow(0.5s) → Inactive
// 路径: Assets/Scripts/Parry/ParrySystem.cs

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

public enum ParryPhase { Inactive, Startup, Active, EndLag, CounterWindow }  // Phase 2 预留

// ──────────────────────────────────────────────────────────────────
// Phase 1 实现(当前):状态查询模式
// ──────────────────────────────────────────────────────────────────
// 架构决策Parry 程序集不引用 BaseGames.Combat避免循环依赖。
// HurtBoxCombat 层)主动调用 ConsumeParry() 查询状态,无需传入 DamageInfo。
// Phase 2若需完美弹反判断提取 BaseGames.Combat.Data含 DamageInfo
//          作为共享子程序集Parry 和 Combat 同时引用,无循环。

public class ParrySystem : MonoBehaviour
{
    /// <summary>当前是否处于弹反激活窗口。Phase 2 由输入/动画事件写入。</summary>
    public bool IsParrying { get; private set; }

    /// <summary>
    /// 查询并消费一次弹反机会。
    /// 若处于弹反窗口则返回 true 并关闭窗口;否则返回 false。
    /// </summary>
    public bool ConsumeParry()
    {
        if (!IsParrying) return false;
        IsParrying = false;
        return true;
    }

    // Phase 2由动画事件 / InputReader 调用以开启弹反窗口
    public void OpenParryWindow()  => IsParrying = true;
    public void CloseParryWindow() => IsParrying = false;
}

// ──────────────────────────────────────────────────────────────────
// Phase 2 设计规划(待实现)
// ──────────────────────────────────────────────────────────────────
// ParrySystem Phase 2 扩展:
//   - 接收 InputReader.ParryEvent管理 Startup → Active → EndLag/CounterWindow 阶段
//   - Active 窗口内 ConsumeParry() 升级为 TryParryDamage(DamageInfo)(需 Combat.Data
//   - 完美弹反判断PerfectParryThreshold、灵力奖励、子弹时间、反击窗口
//   - 广播 _onParrySuccess 事件VFX / Audio / 连击展开)
//   - ParryConfigSO 控制各阶段时长与参数(见 §9

9. ParryConfigSO

[CreateAssetMenu(menuName = "Combat/ParryConfig")]
public class ParryConfigSO : ScriptableObject
{
    [Header("阶段时长")]
    public float StartupDuration       = 0.05f;  // 前摇Startup时长
    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;     // 普通弹反获得灵力
    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;   // 被弹反敌人的受击硬直时长(秒)
}

10. StatusEffectManager

// 路径: Assets/Scripts/Combat/StatusEffects/StatusEffectManager.cs
namespace BaseGames.Combat.StatusEffects
{
    [RequireComponent(typeof(SpriteRenderer))]
    public class StatusEffectManager : MonoBehaviour
    {
        [Header("事件频道")]
        [SerializeField] StatusEffectEventChannelSO _onStatusEffectApplied;
        [SerializeField] StatusEffectEventChannelSO _onStatusEffectExpired;

        // 双结构List 用于有序 Update 遍历Dictionary 用于 O(1) 查找
        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);
                if (effect.IsExpired)
                {
                    effect.OnExpire();
                    _activeIndex.Remove(effect.EffectType);
                    _activeList.RemoveAt(i);
                    _onStatusEffectExpired?.Raise(effect.EffectType);
                }
            }
        }

        /// <summary>施加状态效果。已有相同类型时叠层/刷新O(1) 查找)。</summary>
        public void ApplyEffect(StatusEffect newEffect)
        {
            if (_activeIndex.TryGetValue(newEffect.EffectType, out var existing))
            {
                existing.OnStack();
            }
            else
            {
                newEffect.OnApply(this);
                _activeList.Add(newEffect);
                _activeIndex[newEffect.EffectType] = newEffect;
                _onStatusEffectApplied?.Raise(newEffect.EffectType);
            }
        }

        /// <summary>净化指定类型的状态效果O(1) 查找)。</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);
        }

        public void CleanseAll()
        {
            foreach (var e in _activeList) e.OnExpire();
            _activeList.Clear();
            _activeIndex.Clear();
        }

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

        /// <summary>由状态效果调用,设置 Shader 参数MaterialPropertyBlock不修改共享材质。</summary>
        public void SetShaderParam(string param, float value)
        {
            _renderer.GetPropertyBlock(_propBlock);
            _propBlock.SetFloat(param, value);
            _renderer.SetPropertyBlock(_propBlock);
        }

        public bool HasEffect(StatusEffectType type) => _activeIndex.ContainsKey(type);
    }
}

11. StatusEffect 基类与具体状态效果

// 路径: Assets/Scripts/Combat/StatusEffects/StatusEffect.cs
namespace BaseGames.Combat.StatusEffects
{
    public enum StatusEffectType { Fire, Poison, Freeze, Stun }

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

        public int   StackCount   { get; protected set; } = 1;
        public float Duration     { get; protected set; }       // 当前剩余持续时间
        public float TickInterval { get; protected set; }       // 每次 Tick 的间隔秒数
        float _tickTimer;

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

        public virtual void OnApply(StatusEffectManager owner)
        {
            Owner    = owner;
            Duration = GetBaseDuration();
        }

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

        public virtual void OnTick()  { }
        public virtual void OnExpire() { }

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

        public void Update(float delta)
        {
            Duration   -= delta;
            _tickTimer += delta;
            if (_tickTimer >= TickInterval)
            {
                _tickTimer -= TickInterval;
                OnTick();
            }
        }

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

    // ── 燃烧效果 ────────────────────────────────────────────────────────────────
    // 3 秒 · 0.5s / Tick · 1 点 True 伤害/Tick = 2 DPS不可叠加触发时刷新
    public class FireEffect : StatusEffect
    {
        public override StatusEffectType EffectType => StatusEffectType.Fire;
        public override int              MaxStacks   => 1;

        public FireEffect() => TickInterval = 0.5f;
        protected override float GetBaseDuration() => 3.0f;

        public override void OnApply(StatusEffectManager owner)
        {
            base.OnApply(owner);
            owner.SetShaderParam("_FireGlow", 1f);
        }

        public override void OnTick()
        {
            var info = new DamageInfo.Builder()
                .SetRaw(1)
                .SetType(DamageType.True)
                .SetFlags(DamageFlags.IgnoreIFrame)
                .Build();
            Owner.ApplyDirectDamage(info);
        }

        public override void OnExpire() => Owner.SetShaderParam("_FireGlow", 0f);
        public override string GetDisplayName() => "燃烧";
    }

    // ── 中毒效果 ────────────────────────────────────────────────────────────────
    // 5 秒 / 层 · 1s / Tick · 1×StackCount 点 True 伤害/Tick最多叠 3 层 → 3 DPS
    public class PoisonEffect : StatusEffect
    {
        public override StatusEffectType EffectType => StatusEffectType.Poison;
        public override int              MaxStacks   => 3;

        public PoisonEffect() => TickInterval = 1.0f;
        protected override float GetBaseDuration() => 5.0f;

        public override void OnApply(StatusEffectManager owner)
        {
            base.OnApply(owner);
            UpdateShader();
        }

        public override void OnStack() { base.OnStack(); UpdateShader(); }

        public override void OnTick()
        {
            var info = new DamageInfo.Builder()
                .SetRaw(StackCount)          // 叠层越多伤害越高
                .SetType(DamageType.True)
                .SetFlags(DamageFlags.IgnoreIFrame)
                .Build();
            Owner.ApplyDirectDamage(info);
        }

        public override void OnExpire()
        {
            StackCount = 0;
            Owner.SetShaderParam("_PoisonGlow", 0f);
        }

        void UpdateShader()
            => Owner.SetShaderParam("_PoisonGlow", StackCount / (float)MaxStacks);

        public override string GetDisplayName() => $"中毒 x{StackCount}";
    }
}

11.1 视觉效果汇总

状态 Shader 参数 效果描述
Fire _FireGlow: 0→1 角色轮廓橙红色自发光,强度随时间衰减
Poison x1 _PoisonGlow: 0.33 轻微绿色色调叠加
Poison x3 _PoisonGlow: 1.0 强绿色,角色变色明显
Stun _StunFlash: 1 黄白色闪烁0.1s 间隔)

Shader 参数通过 MaterialPropertyBlock 修改,不影响共享材质

11.2 净化条件

效果 净化方式
Fire 进入水域(WaterZone 触发 CleanseEffect(Fire)
Poison 装备「解毒魅」(OnEquip 时订阅 OnStatusEffectApplied,自动触发净化)
所有效果 存档点激活时,SavePoint.OnInteract 调用 CleanseAll()

12. Layer 矩阵配置

参见 Design/57_PhysicsLayerMatrix.md — 完整 Layer ID 表与规则说明
Physics2D SettingsProjectSettings/Physics2D.asset)中配置碰撞矩阵。

Layer 定义总表(固定分配,禁止随意挪用)

Layer ID 名称 用途
0 Default 无物理需求的装饰物
8 Ground 地面/实体平台
9 OneWayPlatform 单向平台(可从下方穿越)
10 Wall 垂直可抓附墙壁
11 Hazard 伤害区域(荆棘/熔岩),仅 Trigger
12 Player 玩家主体 Collider
13 PlayerHitBox 玩家攻击判定HitBox
14 PlayerHurtBox 玩家受击区HurtBox
15 Enemy 敌人主体 Collider
16 EnemyHitBox 敌人攻击判定HitBox
17 EnemyHurtBox 敌人受击区HurtBox
18 Projectile 玩家弹射物
19 EnemyProjectile 敌人弹射物
20 ParryTarget 可弹反的弹射物
21 Interactable 可交互物件触发区
22 LiquidZone 液态区域 Trigger
23 AbilityGate 能力门触发区
24 Pickup 掉落物(靠近自动吸附)
25 Room 房间边界(触发场景加载)
26 CameraZone Cinemachine 约束区域
27 VFX 粒子特效,不参与碰撞
28 NavMesh PathBerserker2d 寻路专属
29 MagicWall 魔法障壁Ghost 层忽略)
30 Ghost 太虚斩/地行术激活期间的玩家层
31 PhantomBody 残阴术灵体层

碰撞矩阵( = 检测,─ = 忽略;(T) = 仅 Trigger

Ground OWPlat Wall Hazard Player PlayerHB PlayerHurtB Enemy EnemyHB EnemyHurtB Proj EnemyProj ParryTarget Interactable LiquidZone Pickup Room
Ground
OWPlat
Wall
Hazard (T)
Player (T) (T) (T) (T) (T) (T) (T)
PlayerHitBox (T)
PlayerHurtBox (T) (T) (T)
Enemy (T)
EnemyHitBox (T)
EnemyHurtBox (T) (T)
Projectile (T) (T)
EnemyProj (T) (T)
ParryTarget

Ghost / MagicWall / PhantomBody 补充矩阵

Ground OWPlat Wall MagicWall Interactable PhantomInteractable
Ghost ─ 忽略 (T) (T)
MagicWall
PhantomBody ─ 忽略 ─ 忽略 ─ 忽略 ─ 忽略 (T)
  • Ghost:太虚斩/地行术激活期间玩家切换到此层,穿越 MagicWall,但仍与地面/墙壁碰撞(地行术需站在地面遁行)
  • PhantomBody:残阴术灵体,完全忽略地面/墙壁,仅触发 PhantomInteractable
  • SoftTerrain 不需要独立物理层:使用 Ground 层,通过检查 SoftTerrain 组件来判断地形类型

13. PoiseSystem霸体系统

参见 Design/54_PoiseSystem.md — 完整规则说明
核心机制:霸体使用等级比较,而非数值耐久条。

  • 攻击方:DamageInfo.BreakBreakLevel 枚举)——此次攻击能打断多高的霸体
  • 承受方:IPoiseSource.GetCurrentPoiseLevel()PoiseLevel 枚举)——当前帧拥有多高的霸体
  • 判定公式:(int)info.Break >= (int)currentPoise → 打断成功
  • 玩家和敌人均可拥有霸体(玩家在攻击/技能动画的特定帧,敌人在超甲状态)

PoiseWindowConfig时间窗口霸体

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

PoiseOverrideTableSO精细控制资产

// 路径: Assets/ScriptableObjects/Combat/PoiseOverrideTable.asset
// 用于特殊规则:某个特定 sourceId 对某类目标的打断规则覆盖(无视常规等级比较)
[CreateAssetMenu(menuName = "Combat/PoiseOverrideTable")]
public class PoiseOverrideTableSO : ScriptableObject
{
    [System.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;
    }
}

资产路径Assets/ScriptableObjects/Combat/PoiseOverrideTable.asset


PlayerController 实现 IPoiseSource

// PlayerController 在攻击/技能特定动画帧激活霸体
// 各 AttackState / SkillState 在 OnStateEnter 时调用 SetPoiseWindow()
public partial class PlayerController : MonoBehaviour, IPoiseSource
{
    private PoiseWindowConfig _currentPoiseWindow;
    private Animancer.AnimancerState _activeState; // 由 Animancer 提供当前动画状态

    public void SetPoiseWindow(PoiseWindowConfig window)
        => _currentPoiseWindow = window;

    public void ClearPoiseWindow()
        => _currentPoiseWindow = default;

    // IPoiseSource 实现:每帧查询当前霸体等级
    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;
    }
}

EnemyPoiseComponent敌人霸体

// 路径: Assets/Scripts/Enemies/EnemyPoiseComponent.cs
// 挂在 EnemyBase 上,实现 IPoiseSource
// 敌人的霸体通常由行为树 Task如 SuperArmorTask全局开启/关闭
[RequireComponent(typeof(EnemyBase))]
public class EnemyPoiseComponent : MonoBehaviour, IPoiseSource
{
    [SerializeField] private PoiseLevel _defaultPoiseLevel = PoiseLevel.None;

    private PoiseLevel _currentPoiseLevel;

    private void Awake()
    {
        _currentPoiseLevel = _defaultPoiseLevel;
        // 向同节点 HurtBox 注入自身
        if (TryGetComponent<HurtBox>(out var hurtBox))
            hurtBox.SetPoiseSource(this);
    }

    // 由行为树 Task 或 Boss 状态机调用
    public void SetPoiseLevel(PoiseLevel level) => _currentPoiseLevel = level;

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

HurtBox 中的霸体判定调用(已在 §5 实现,此处说明流程)

HurtBox.ReceiveDamage(info)
  ↓
  2. 霸体检查
     ├─ _poiseSource == null → 跳过(无霸体实体)
     ├─ info.Flags has ForceBreak → 强制打断,跳过检查
     ├─ info.Break == BreakLevel.None → 不触发霸体检查
     ├─ _overrideTable.TryGetOverride(info.SourceId, tag, out lvl) → 使用覆盖等级
     └─ (int)effectiveBreak >= (int)currentPoise → 打断,继续受击流程
        (int)effectiveBreak  < (int)currentPoise → 霸体生效,播放受击 VFX 但跳出

14. IBreakable — 机关 / 障碍物交互系统

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

BreakConditionSO

// 路径: Assets/Scripts/Combat/BreakConditionSO.cs
// 一个 ScriptableObject 描述"哪些攻击能触发此机关"
// 可共享(多个机关引用同一 Condition也可单独配置
[CreateAssetMenu(menuName = "Combat/BreakCondition")]
public class BreakConditionSO : ScriptableObject
{
    [Header("Category 白名单(空 = 不限类别)")]
    // DamageInfo.Category 必须在此集合中
    public DamageCategory[] AllowedCategories;

    [Header("Tags 必须位(位掩码 AND")]
    // DamageInfo.Tags 必须包含以下所有标签AND 逻辑)
    public DamageTags RequiredTags;

    [Header("Tags 禁止位(位掩码 AND NOT")]
    // DamageInfo.Tags 包含以下任意标签时拒绝
    public DamageTags ForbiddenTags;

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

    [Header("技能 ID 白名单(空 = 不限技能)")]
    // 若填写则 DamageInfo.SkillId 必须在此列表中
    public string[] AllowedSkillIds;

    // 核心判定DamageInfo 是否满足本条件
    public bool Evaluate(in DamageInfo info)
    {
        // 1. Category 检查
        if (AllowedCategories is { Length: > 0 }
            && System.Array.IndexOf(AllowedCategories, info.Category) < 0)
            return false;

        // 2. RequiredTags 检查(必须全包含)
        if (RequiredTags != DamageTags.None
            && (info.Tags & RequiredTags) != RequiredTags)
            return false;

        // 3. ForbiddenTags 检查(不能包含任何禁止标签)
        if (ForbiddenTags != DamageTags.None
            && (info.Tags & ForbiddenTags) != 0)
            return false;

        // 4. DamageType 检查
        if (AllowedTypes is { Length: > 0 }
            && System.Array.IndexOf(AllowedTypes, info.Type) < 0)
            return false;

        // 5. SkillId 检查
        if (AllowedSkillIds is { Length: > 0 }
            && System.Array.IndexOf(AllowedSkillIds, info.SkillId) < 0)
            return false;

        return true;
    }
}

IBreakable 接口

// 路径: Assets/Scripts/Combat/IBreakable.cs
// 机关、障碍物实现此接口,而非 IDamageable
// Layer 建议: "Breakable"(见 §12 Layer 矩阵)
public interface IBreakable
{
    // 由 HitBox.OnTriggerEnter2D 调用
    // 返回 true 表示成功触发false 表示条件不满足(静默忽略)
    bool TryInteract(in DamageInfo info);
}

BreakableProp通用实现

// 路径: Assets/Scripts/Combat/BreakableProp.cs
// 通用可破坏/可交互物体(挂在机关 Prefab 上)
// 满足 BreakCondition 时扣血HP ≤ 0 时触发 Break 事件
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);   // 或 Destroy / 动画
        }
        return true;
    }
}

机关配置示例(数据驱动)

机关 AllowedCategories RequiredTags AllowedTypes 说明
毒液晶石 ElementPoison Poison 任何毒属性攻击
封印门 SoulSkill SkyFormOnly 仅天形魂技能
弱点晶球 NormalAttack, SoulSkill AfterParry 弹反后才能击碎
普通木箱 NormalAttack, SoulSkill, SpiritSkill MeleeHit 任意近战
冰封机关 ElementFire Fire 任何火属性
地形炸弹 BreakHeavy | BreakSuper 需要重击/超级破

15. ClashResolver — 拼刀系统

玩家与敌人的近战 HitBox 同时激活并相互重叠时触发拼刀:双方武器碰撞,均不扣血,各自弹开,播放拼刀特效与音效。

仅携带 CanClash 标记(DamageFlags.CanClash)的 HitBox 才参与拼刀检测。弹射物、环境伤害、DoT 无此标记,永远不触发拼刀。

检测流程

HitBox.OnTriggerEnter2D(Collider2D other)
    │
    ├─ other.Layer == HurtBox → 正常伤害流水线
    │
    └─ other.Layer == 对立 HitBox Layer
          └─ TryGetComponent<HitBox>(other, out rivalHitBox)
                └─ rivalHitBox.IsActive
                   && rivalHitBox.CanClash (Flags.HasFlag CanClash)
                   && this.CanClash
                      └─ ClashResolver.Instance.ResolveClash(this, rivalHitBox)
                           → 中止伤害,触发拼刀效果

ClashResolver 类

// 路径: Assets/Scripts/Combat/ClashResolver.cs
// 单例服务,常驻 Persistent 场景(由 GameManager 持有)
[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. 拼刀 HitStop1 帧,比普通命中的 2 帧更短)
        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);
    }
}

ClashConfigSO

// 路径: Assets/ScriptableObjects/Config/Combat/ClashConfigSO.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 每帧只触发一次

设计意图:普通近战攻击默认 CanClash = true让拼刀自然发生。Boss 特殊重击设 CanClash = false,迫使玩家闪避而非硬拼。


16. HitBox / HurtBox 编辑器可视化

痛点HitBox 和 HurtBox 都依赖运行时 Collider2D 启用/禁用Scene 视图中看不到当前有效的攻击范围和伤害接受范围,策划调整数值时必须靠猜。通过自定义 [CustomEditor] 在 Scene View 中绘制彩色标注,显著降低调整迭代成本。

HitBoxEditor

// 路径: Assets/Scripts/Editor/Combat/HitBoxEditor.cs
// 程序集: BaseGames.EditorEditor Only
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;

namespace BaseGames.Editor.Combat
{
    [CustomEditor(typeof(HitBox))]
    [CanEditMultipleObjects]
    public class HitBoxEditor : UnityEditor.Editor
    {
        private static readonly Color ActiveColor   = new(1f, 0.2f, 0.2f, 0.35f);   // 红色(激活)
        private static readonly Color InactiveColor = new(1f, 0.2f, 0.2f, 0.10f);   // 红色(非激活,淡显)
        private static readonly Color OutlineColor  = new(1f, 0.0f, 0.0f, 0.90f);

        public override void OnInspectorGUI()
        {
            DrawDefaultInspector();

            var hb = (HitBox)target;
            if (hb.IsActive)
            {
                EditorGUILayout.HelpBox("HitBox 当前激活中", MessageType.None);
            }
        }

        // Scene View 绘制
        private void OnSceneGUI()
        {
            var hb  = (HitBox)target;
            var col = hb.GetComponent<Collider2D>();
            if (col == null) return;

            Color fill    = hb.IsActive ? ActiveColor : InactiveColor;
            Color outline = OutlineColor;
            outline.a     = hb.IsActive ? 1f : 0.4f;

            Handles.color = fill;

            if (col is BoxCollider2D box)
            {
                Vector3 center = hb.transform.TransformPoint(box.offset);
                Vector3 size   = new Vector3(box.size.x, box.size.y, 0f);
                size = Vector3.Scale(size, hb.transform.lossyScale);
                Handles.DrawSolidRectangleWithOutline(
                    GetBoxVerts(center, size, hb.transform.rotation), fill, outline);
            }
            else if (col is CircleCollider2D circle)
            {
                Vector3 center = hb.transform.TransformPoint(circle.offset);
                float   radius = circle.radius * Mathf.Max(hb.transform.lossyScale.x,
                                                            hb.transform.lossyScale.y);
                Handles.DrawSolidArc(center, Vector3.forward, Vector3.right, 360f, radius);
            }

            // 伤害数值标注(在 Scene View 中叠加显示)
            var dmgSrc = hb.CurrentDamageSource;
            if (dmgSrc != null)
            {
                Vector3 labelPos = hb.transform.position + Vector3.up * 0.4f;
                Handles.Label(labelPos,
                    $"⚔ {dmgSrc.BaseDamage}  Break:{dmgSrc.BreakLevel}",
                    new GUIStyle(GUI.skin.label)
                    {
                        fontSize        = 10,
                        normal          = { textColor = Color.red },
                        fontStyle       = FontStyle.Bold,
                    });
            }
        }

        private static Vector3[] GetBoxVerts(Vector3 center, Vector3 size, Quaternion rot)
        {
            Vector3 half = size * 0.5f;
            var verts = new Vector3[]
            {
                center + rot * new Vector3(-half.x, -half.y),
                center + rot * new Vector3( half.x, -half.y),
                center + rot * new Vector3( half.x,  half.y),
                center + rot * new Vector3(-half.x,  half.y),
            };
            return verts;
        }
    }
}
#endif

HurtBoxEditor

// 路径: Assets/Scripts/Editor/Combat/HurtBoxEditor.cs
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;

namespace BaseGames.Editor.Combat
{
    [CustomEditor(typeof(HurtBox))]
    [CanEditMultipleObjects]
    public class HurtBoxEditor : UnityEditor.Editor
    {
        private static readonly Color ActiveColor   = new(0.2f, 0.5f, 1f, 0.30f);   // 蓝色(正常)
        private static readonly Color IFrameColor   = new(0.2f, 1f, 0.5f, 0.30f);   // 绿色(无敌帧)
        private static readonly Color OutlineColor  = new(0.0f, 0.3f, 1.0f, 0.90f);

        public override void OnInspectorGUI()
        {
            DrawDefaultInspector();
            var hb = (HurtBox)target;
            if (hb.IsInvincible)
                EditorGUILayout.HelpBox("⚡ 无敌帧激活中", MessageType.Warning);
        }

        private void OnSceneGUI()
        {
            var hb  = (HurtBox)target;
            var col = hb.GetComponent<Collider2D>();
            if (col == null) return;

            Color fill    = hb.IsInvincible ? IFrameColor : ActiveColor;
            Color outline = hb.IsInvincible
                ? new Color(0.2f, 1f, 0.5f, 0.9f) : OutlineColor;

            Handles.color = fill;

            if (col is BoxCollider2D box)
            {
                Vector3 center = hb.transform.TransformPoint(box.offset);
                Vector3 size   = Vector3.Scale(
                    new Vector3(box.size.x, box.size.y, 0f), hb.transform.lossyScale);
                Handles.DrawSolidRectangleWithOutline(
                    GetBoxVerts(center, size, hb.transform.rotation), fill, outline);
            }
            else if (col is CircleCollider2D circle)
            {
                Vector3 center = hb.transform.TransformPoint(circle.offset);
                float   radius = circle.radius * Mathf.Max(hb.transform.lossyScale.x,
                                                            hb.transform.lossyScale.y);
                Handles.DrawSolidArc(center, Vector3.forward, Vector3.right, 360f, radius);
            }

            // 状态标签
            if (hb.IsInvincible)
                Handles.Label(hb.transform.position + Vector3.up * 0.5f, "I-Frame",
                    new GUIStyle { fontSize = 9, normal = { textColor = Color.green },
                                   fontStyle = FontStyle.Bold });
        }

        private static Vector3[] GetBoxVerts(Vector3 center, Vector3 size, Quaternion rot)
        {
            Vector3 half = size * 0.5f;
            return new Vector3[]
            {
                center + rot * new Vector3(-half.x, -half.y),
                center + rot * new Vector3( half.x, -half.y),
                center + rot * new Vector3( half.x,  half.y),
                center + rot * new Vector3(-half.x,  half.y),
            };
        }
    }
}
#endif

使用说明:两个 Editor 脚本放入 Assets/Scripts/Editor/Combat/,对应的 asmdef BaseGames.Editor 需将 Editor Only 勾选。HitBox 激活时显示红色实心框 + 伤害数值标注HurtBox 无敌帧期间变为绿色;未激活/非无敌帧时半透明浅色显示。


17. StatusEffectManager — 统一 Tick 驱动

架构决策2026-05:原设计未明确 StatusEffect 的 Tick 调用方,如果各 Tick 逻辑分散在各 MonoBehaviour.Update 内,当单帧存在数十个状态效果实例时,引擎调度开销(每帧数十次 MonoBehaviour.Update 虚调用)将显著优于统一 Tick 的批处理。

驱动模式Manager 集中 Tick

// StatusEffectManager 扩展(原 §10 实现基础上新增 Tick 集中调度)
// 路径: Assets/Scripts/Combat/StatusEffectManager.cs

// ──────────────────────────────────────────────────────────────────
// 每帧由 StatusEffectManager.Update() 统一推进StatusEffect 子类
// 自身不挂 MonoBehaviour不持有 Update/Coroutine
// ──────────────────────────────────────────────────────────────────

public class StatusEffectManager : MonoBehaviour, IDamageable
{
    // 运行中的效果列表(复用 §10 的 _activeEffects
    private readonly List<StatusEffect> _activeEffects = new();

    // ── 集中 Tick替代各 Effect 自持 Coroutine────────────────────
    private void Update()
    {
        float dt = Time.deltaTime;
        // 倒序遍历,方便 RemoveAt避免索引越界
        for (int i = _activeEffects.Count - 1; i >= 0; i--)
        {
            var effect = _activeEffects[i];
            effect.RemainingDuration -= dt;
            effect.TickTimer         -= dt;

            if (effect.TickTimer <= 0f)
            {
                effect.TickTimer += effect.TickInterval;
                effect.OnTick();
            }

            if (effect.RemainingDuration <= 0f)
            {
                effect.OnExpire();
                _activeEffects.RemoveAt(i);
            }
        }
    }

    // ApplyEffect / RemoveEffect / Clear 与 §10 相同,此处不重复
}

// StatusEffect 基类需将计时字段改为 Manager 驱动
public abstract class StatusEffect
{
    public StatusEffectType EffectType        { get; }
    public abstract int     MaxStacks         { get; }
    public int              StackCount        { get; protected set; } = 1;

    // 由 Manager.Update 驱动,子类只读
    public float RemainingDuration { get; set; }
    public float TickInterval      { get; protected set; } = 1.0f;  // 默认 1s/Tick
    public float TickTimer         { get; set; }   // 与 TickInterval 对齐,倒计时

    // 生命周期钩子(由 Manager 调用,非 MonoBehaviour
    public abstract void OnApply(StatusEffectManager owner);
    public virtual  void OnStack() => StackCount++;
    public abstract void OnTick();
    public abstract void OnExpire();
    public abstract string GetDisplayName();

    protected StatusEffectManager Owner { get; private set; }

    // Manager 内部调用,注入宿主引用
    internal void Bind(StatusEffectManager owner)
    {
        Owner      = owner;
        TickTimer  = TickInterval;   // 首次 Tick 将在 TickInterval 秒后触发
    }
}

迁移说明:原 FireEffect/PoisonEffect 等子类中移除所有 Coroutine 调用,将 duration 字段更名为 RemainingDurationManager 负责倒计时);TickInterval 字段保持不变,含义从"协程 WaitForSeconds 参数"转为"Manager Tick 间隔"。