Files
zeling_v2/Docs/Design/04_CombatSystem.md
2026-05-08 11:04:00 +08:00

26 KiB
Raw Permalink Blame History

04 · 战斗系统

命名空间 BaseGames.Combat
所属文档集 ← 返回索引 · 总览
依赖 BaseGames.Player · BaseGames.Enemies · BaseGames.Feedback
参见 54_PoiseSystem — 霸体 / 打断等级系统


目录

  1. 系统总览
  2. DamageInfo — 伤害信息结构
  3. HitBox — 攻击判定
  4. HurtBox — 受击响应
  5. 伤害流水线
  6. 击退计算
  7. 连击链与攻击属性
  8. 伤害层级与 Layer 矩阵
  9. 事件频道集成
  10. CombatConfigSO
  11. 编辑器友好设计
  12. 状态效果系统StatusEffect / DoT
  13. 拼刀系统Nail Clash

1. 系统总览

战斗系统的核心原则是数据驱动 + 物理解耦

  • 伤害以 DamageInfo 结构体传递,不通过直接引用获取对象
  • HitBoxHurtBox 通过 Collider2D 触发器交互,互不直接引用对方 Owner
  • 伤害路由全部通过 OnHitConfirmed 事件频道广播
  • 击退方向、硬直时长、格挡属性全部封装在 DamageInfo
攻击方                                    受击方
──────                                    ──────
AttackState
  └─ AnimationEvent: EnableHitBox
       └─ HitBox.Activate()
            └─ OnTriggerEnter2D(HurtBox)
                 └─ HurtBox.ReceiveDamage(DamageInfo)
                      ├─ 本地响应HP扣减、硬直
                      └─ 广播 OnHitConfirmed 频道
                           ├─ PlayerStats.AddSoul(+10)
                           ├─ EnemyFeedback.OnHit()
                           └─ (其他侦听者)

2. DamageInfo — 伤害信息结构

DamageInfo只读值类型Struct在整个伤害流水线中传递不可修改

字段 类型 说明
RawDamage int 基础伤害量
FinalDamage int 经过加成/减免后的最终伤害(由 HurtBox 计算)
KnockbackDirection Vector2 归一化击退方向
KnockbackForce float 击退力度
HitStunDuration float 受击硬直时长(秒)
DamageType DamageType 伤害类型Normal / Fire / Poison / True
DamageFlags DamageFlags 属性标记BitFlags见下方
SourcePosition Vector2 攻击来源世界位置(用于计算击退方向)
SourceLayer int 发起攻击的 GameObject Layer敌人/玩家/环境)
HitFxType HitFxType 期望产生的命中特效类型Spark / Blood / Magic 等)
BreakLevel BreakLevel 此次攻击的打断等级(决定能打断多高的霸体,见 54_PoiseSystem
SourceId string 攻击来源 IDDamageSourceSO.sourceId用于 PoiseOverrideTable 精细规则查询

DamageFlags位标记

Flag 说明
Unblockable 无法被弹反、无法被格挡
CanBeParried 可被弹反
IgnoreIFrame 忽略无敌帧
PerfectParryOnly 只能被完美弹反(高难度攻击)
IsProjectile 来自弹射物(影响部分弹反判定)
CanClash 近战武器攻击可参与拼刀检测弹射物、环境伤害、DoT 无此标记
ForceBreak 强制打断目标当前动作,无视霸体等级(Unbreakable 除外)
NoKnockback 打断时不施加击退,仅进入 HurtState 硬直

DamageType 说明

类型 说明
Normal 物理攻击,可弹反
True 真实伤害,绕过所有减免,不可弹反
Fire 持续灼烧P1
Poison 持续中毒P1

3. HitBox — 攻击判定

HitBox 是挂在攻击者身上的触发器组件,在 AnimationEvent 驱动下激活/关闭:

组件结构

[HitBox_Ground]
├── Collider2D (BoxCollider2DIsTrigger = true)
│   └── Layer: "PlayerAttack" 或 "EnemyAttack"
├── HitBox.cs
│   ├── _damageSource: DamageSourceSO     ← 攻击配置(基础伤害/属性)
│   ├── _attackerTransform: Transform     ← 用于计算击退方向
│   ├── _hitCooldown: float              ← 同一 HitBox 对同一目标的命中冷却(防多帧重复)
│   └── _canClash: bool                  ← 是否参与拼刀检测(近战武器 = true弹射物/环境 = false
└── (由 PlayerCombat 调用 Activate/Deactivate

HitBox 工作流程

  1. AttackState 初始化时,将攻击属性注入 HitBoxSetDamageSource(DamageSourceSO)
  2. AnimationEvent EnableHitBox 触发 → HitBox.Activate()(碰撞体 enabled = true
  3. OnTriggerEnter2D 检测碰撞层:
    • HurtBox Layer → 构建 DamageInfo,传递给 HurtBox.ReceiveDamage(DamageInfo)
    • 对立 HitBox LayerPlayerAttack ↔ EnemyAttack且对方 _canClash == true → 路由到 ClashResolver.ResolveClash(this, other),中止伤害处理
  4. 记录命中目标,在冷却时间内不重复命中
  5. AnimationEvent DisableHitBox 触发 → HitBox.Deactivate()(碰撞体 enabled = false

DamageSourceSO — 攻击属性配置

每种攻击有独立 DamageSourceSO,存放于 Assets/ScriptableObjects/Combat/DamageSources/

字段 类型 示例值 说明
BaseDamage int 5 基础伤害
DamageMultiplier float 1.0 连击倍率Attack3 = 2.0
KnockbackForce float 8.0 击退力度
HitStunDuration float 0.3s 目标硬直时长
DamageType DamageType Normal 伤害类型
DamageFlags DamageFlags CanBeParried 属性标记
HitFxType HitFxType Spark 命中特效
breakLevel BreakLevel Light 此攻击的霸体打断等级(见 54_PoiseSystem
forceBreak bool false 强制打断(无视霸体等级,Unbreakable 除外)

4. HurtBox — 受击响应

HurtBox 挂在可受击单位身上,接收 DamageInfo 并触发本地响应:

组件结构

[HurtBox]
├── Collider2D (BoxCollider2DIsTrigger = true)
│   └── Layer: "PlayerHurtBox" 或 "EnemyHurtBox"
├── HurtBox.cs
│   ├── _owner: MonoBehaviour            ← 宿主PlayerController 或 EnemyBase
│   ├── _defenseStat: int               ← 防御减免0 = 无减免)
│   └── UnityEvent<DamageInfo> OnHurt   ← 本地事件,供 Inspector 直接绑定反馈
└── 调用链: HurtBox → _owner.TakeDamage(DamageInfo)

伤害减免计算

\text{FinalDamage} = \max(1,\ \text{RawDamage} - \text{DefenseStat})

True 类型伤害跳过此计算,FinalDamage = RawDamage


5. 伤害流水线

AnimationEvent: EnableHitBox
        │
        ▼
HitBox.OnTriggerEnter2D(HurtBox)
        │
        ├─ 构建 DamageInfo
        │   ├─ RawDamage   = DamageSourceSO.BaseDamage × DamageMultiplier
        │   ├─ Direction   = (HurtBox.position - Attacker.position).normalized
        │   ├─ Knockback   = DamageSourceSO.KnockbackForce
        │   └─ HitStun     = DamageSourceSO.HitStunDuration
        │
        ▼
HurtBox.ReceiveDamage(DamageInfo info)
        │
        ├─ [检查无敌帧] → 若 IsInvincible && !IgnoreIFrame → 直接返回
        ├─ [检查弹反]   → 若 CanBeParried && ParrySystem.IsInParryWindow → 触发弹反流程
        ├─ [计算最终伤害] FinalDamage = max(1, RawDamage - DefenseStat)
        ├─ [霸体打断检查] → 查询 PoiseOverrideTable / ForceBreak / 数值比较 → 决定是否打断(见 54_PoiseSystem §5
        ├─ 调用 Owner.TakeDamage(FinalDamage)
        ├─ 广播 OnHitConfirmed 事件频道DamageInfoEventChannelSO
        ├─ 若打断Owner.ApplyKnockback(info) + Owner.ForceState(HurtState)
        ├─ 若不打断Owner.PlayHitFlash()(仅视觉反馈,动作不中断)
        └─ 触发本地 UnityEvent<DamageInfo> OnHurt

6. 击退计算

击退方向由 DamageInfo.SourcePosition 和受击位置共同决定:

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

特殊情况处理

情况 处理方式
玩家被正下方攻击(如地刺) 方向强制为 (0, 1) 向上
玩家贴墙被打 水平分量减为 50%,保留垂直分量
Boss 击退 使用 DamageInfo 中的固定方向,不动态计算

7. 连击链与攻击属性

攻击属性 SO 资产列表

存放于 Assets/ScriptableObjects/Combat/DamageSources/Player/

资产名 BaseDamage Multiplier KnockbackForce Flags
DS_Player_Attack1.asset 5 ×1.0 5 CanBeParried
DS_Player_Attack2.asset 5 ×1.0 5 CanBeParried
DS_Player_Attack3.asset 5 ×2.0 10 CanBeParried
DS_Player_AirAttack.asset 5 ×1.0 6 CanBeParried
DS_Player_UpAttack.asset 5 ×1.0 8 CanBeParried
DS_Player_ParryCounter.asset 5 ×3.0 15 Unblockable

弹反反击 ParryCounter 标记为 Unblockable,不可再被弹反,且击退力度最大。

连击命中音效与特效

HitFxType 枚举控制命中时产生的特效类型,由 PlayerFeedback 中对应 MMF_Player 响应:

HitFxType 特效 音效 说明
Spark 金属火花 Particle SFX_Hit_Spark 金属敌人
Slash 挥斩光轨特效 SFX_Hit_Slash 通用普攻
Heavy 大范围冲击波 SFX_Hit_Heavy Attack3 / ParryCounter
Magic 魔法闪光 SFX_Hit_Magic 魔法攻击P1

8. 伤害层级与 Layer 矩阵

Physics 2D Layer Collision Matrix 配置ProjectSettings → Physics2D

攻击方 Layer 可命中 Layer 不可命中 Layer
PlayerAttack EnemyHurtBox, BreakableObject, EnemyAttack(拼刀) PlayerHurtBox, PlayerAttack
EnemyAttack PlayerHurtBox, PlayerAttack(拼刀) EnemyHurtBox, EnemyAttack
Projectile_Player EnemyHurtBox, Wall, BreakableObject PlayerHurtBox
Projectile_Enemy PlayerHurtBox, Wall EnemyHurtBox
Environment PlayerHurtBox, EnemyHurtBox

9. 事件频道集成

战斗系统发布/订阅的 SO 事件频道:

频道资产 类型 发布方 主要订阅方
OnHitConfirmed.asset DamageInfoEventChannelSO HurtBox PlayerStats+SoulFeedbackSystem(特效)、UI(伤害数字)
OnPlayerHPChanged.asset IntEventChannelSO PlayerStats HUD(血条)、PlayerFeedback(受伤反馈)
OnEnemyDied.asset TransformEventChannelSO EnemyBase WorldSystem(掉落物)、FeedbackSystem(死亡特效)
OnParrySuccess.asset DamageInfoEventChannelSO ParrySystem PlayerStats+33 SoulPlayerFeedback(弹反反馈)、EnemySystemStagger
OnNailClash.asset VoidEventChannelSO ClashResolver PlayerFeedback(拼刀特效/音效)、EnemyFeedback(拼刀反馈)、CameraSystem(轻微 Impulse

10. CombatConfigSO

存放于 Assets/ScriptableObjects/Config/Combat/CombatConfigSO.asset

参数 类型 推荐值 说明
HitCooldownPerTarget float 0.05s HitBox 对同一目标命中冷却
ComboWindowDuration float 0.5s 连击窗口(攻击动画结束后)
ComboResetDelay float 0.3s 连击链完成后重置延迟
SoulOnHitEnemy int 10 命中普通敌人获得 Soul
SoulOnHitBoss int 5 命中 Boss 获得 Soul
HitFreezeFrames int 2 命中暂停帧数Hitstop以帧为单位
HitFreezeDuration float 0.033s 命中暂停时长(约 2 帧)

11. 编辑器友好设计

HitBox Scene 视图 Gizmos

  • 激活时:绘制橙色实心矩形(不透明度 50%
  • 未激活时:绘制橙色虚线矩形(不透明度 20%
  • 鼠标悬停时,显示浮窗:BaseDamage: 5 | KnockbackForce: 8 | Flags: CanBeParried

HurtBox Scene 视图 Gizmos

  • 正常状态:绘制绿色半透明矩形
  • 无敌帧中:绘制黄色闪烁矩形
  • 受击时:绘制红色矩形(持续 0.1s

DamageSourceSO 自定义 Inspector

区域 内容
基础伤害预览 显示 BaseDamage × Multiplier = FinalDamage 计算结果
击退预览 绘制箭头示意击退方向和力度
属性标记 颜色标签显示 DamageFlags
伤害测试 Play Mode 下"模拟命中"按钮,立即触发一次伤害流水线

12. 状态效果系统StatusEffect / DoT

命名空间 BaseGames.Combat.StatusEffects

状态效果DoTDamage over Time让 DamageType 中的 FirePoison 等属性真正生效,为战斗增加策略层次。

12.1 StatusEffect 基类

namespace BaseGames.Combat.StatusEffects
{
    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;   // 宿主PlayerStats 或 EnemyStats

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

        public virtual void OnStack()                            // 已有时再次施加 → 刷新/叠层
        {
            Duration = GetBaseDuration();                        // 默认刷新持续时间
            StackCount = Mathf.Min(StackCount + 1, MaxStacks);
        }

        public virtual void OnTick() { }                        // 每次 TickInterval 触发一次

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

    public enum StatusEffectType { Fire, Poison, Freeze, Stun }
}

12.2 FireEffect — 燃烧

public class FireEffect : StatusEffect
{
    public override StatusEffectType EffectType => StatusEffectType.Fire;
    public override int              MaxStacks   => 1;           // 不可叠加,触发时刷新持续时间

    protected override float GetBaseDuration() => 3.0f;         // 燃烧 3 秒
    // TickInterval = 0.5f → 每秒 2 次 Tick每次 1 点伤害 = 2 DPS

    public FireEffect() => TickInterval = 0.5f;

    public override void OnApply(StatusEffectManager owner)
    {
        base.OnApply(owner);
        owner.SetShaderParam("_FireGlow", 1f);                   // 橙色光晕 Shader 参数
    }

    public override void OnTick()
    {
        // 构造 True 类型伤害(跳过防御,不触发 IFrame
        var info = new DamageInfo(
            attacker:    null,
            damage:      1,
            type:        DamageType.True,
            flags:       DamageFlags.IgnoreIFrame | DamageFlags.IsDoT
        );
        Owner.ApplyDirectDamage(info);
    }

    public override void OnExpire()
        => Owner.SetShaderParam("_FireGlow", 0f);                // 关闭光晕

    public override string GetDisplayName() => "燃烧";
}

12.3 PoisonEffect — 毒素

public class PoisonEffect : StatusEffect
{
    public override StatusEffectType EffectType => StatusEffectType.Poison;
    public override int              MaxStacks   => 3;           // 最多叠加 3 层

    protected override float GetBaseDuration() => 5.0f;         // 每层独立计时 5 秒
    // TickInterval = 1.0f → 1 DPS3 层叠加时 3 DPS

    public PoisonEffect() => TickInterval = 1.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(
            attacker:    null,
            damage:      StackCount,        // 伤害随叠层数增加
            type:        DamageType.True,
            flags:       DamageFlags.IgnoreIFrame | DamageFlags.IsDoT
        );
        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}";
}

12.4 StatusEffectManager — 宿主组件

挂载在 PlayerControllerEnemyBase 上,管理所有状态效果。

性能注意:状态效果列表通常不超过 45 个并发 entryList<T> 遍历的绝对开销极小(< 1μs/帧)。但为避免每帧 FirstOrDefault LINQ 分配,ApplyEffect / CleanseEffect 改用 Dictionary<StatusEffectType, StatusEffect> 作为 O(1) 查找结构,同时保留 List 作为有序更新序列:

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()
        {
            // 倒序遍历 List避免 RemoveAt 移位问题
            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);       // List.Remove 是 O(n),但列表极短,可接受
            _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.FinalDamage);

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

        /// <summary>O(1) 查找。</summary>
        public bool HasEffect(StatusEffectType type)
            => _activeIndex.ContainsKey(type);
    }
}

12.5 状态效果触发方式

状态效果由以下几种方式触发:

触发方式 说明
DamageInfo.DamageType == Fire/Poison 伤害流水线在 HurtBox.ReceiveDamage 中检测,触发 StatusEffectManager.ApplyEffect
OnHitEffect(魅力效果) 命中时以概率施加(见 17_EquipmentSystem.md §4.3
环境接触 HazardZone 配置为 DoT 类型时,每帧发布带 DamageType.PoisonDamageInfo

HurtBox 集成(在现有伤害流水线末尾添加):

// HurtBox.ReceiveDamage 末尾追加:
if (info.DamageType == DamageType.Fire)
    Owner.GetComponent<StatusEffectManager>()?.ApplyEffect(new FireEffect());
else if (info.DamageType == DamageType.Poison)
    Owner.GetComponent<StatusEffectManager>()?.ApplyEffect(new PoisonEffect());

12.6 净化条件

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

12.7 视觉效果汇总

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

Shader 参数通过 MaterialPropertyBlock 修改,不影响共享材质,场景中多个同材质角色各自独立显示。


13. 拼刀系统Nail Clash

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

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

13.1 检测架构

Physics Layer 配置:
  PlayerAttack Layer ↔ EnemyAttack Layer → 开启碰撞检测

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

13.2 ClashResolver

ClashResolver 是场景中的单例服务(GameManager 持有),接收两个 HitBox 引用并处理拼刀逻辑:

public class ClashResolver : MonoBehaviour
{
    [SerializeField] VoidEventChannelSO _onNailClash;
    [SerializeField] ClashConfigSO      _config;

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

    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. 广播事件
        _onNailClash.Raise();
    }

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

13.3 ClashConfigSO

存放于 Assets/ScriptableObjects/Config/Combat/ClashConfigSO.asset

参数 类型 推荐值 说明
ClashFreezeFrames int 1 拼刀冻帧(比命中的 2 帧更短,避免卡顿感)
ClashKnockbackForce float 6.0 拼刀弹开力度
ClashImpulseStrength float 0.3 Cinemachine Impulse 强度(轻微)

13.4 拼刀判定规则

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

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