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

26 KiB
Raw 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+Soul)、FeedbackSystem(特效)、UI(伤害数字)
OnPlayerHPChanged.asset IntEventChannelSO PlayerStats HUD(血条)、PlayerFeedback(受伤反馈)
OnEnemyDied.asset TransformEventChannelSO EnemyBase WorldSystem(掉落物)、FeedbackSystem(死亡特效)
OnParrySuccess.asset DamageInfoEventChannelSO ParrySystem PlayerStats+33 Soul)、PlayerFeedback(弹反反馈)、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 上,管理所有状态效果。

性能注意:状态效果列表通常不超过 4–5 个并发 entry,List<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>由状态效果调用,直接扣减 HP(True 伤害,绕过 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. 拼刀 HitStop(1 帧,比普通命中的 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,迫使玩家闪避而非硬拼。