chore: initial commit

This commit is contained in:
2026-05-08 11:04:00 +08:00
commit f55d2a57c3
6278 changed files with 866081 additions and 0 deletions

View File

@@ -0,0 +1,680 @@
# 04 · 战斗系统
> **命名空间** `BaseGames.Combat`
> **所属文档集** [← 返回索引](./README.md) · [总览](./00_Overview.md)
> **依赖** `BaseGames.Player` · `BaseGames.Enemies` · `BaseGames.Feedback`
> **参见** [54_PoiseSystem](./54_PoiseSystem.md) — 霸体 / 打断等级系统
---
## 目录
1. [系统总览](#1-系统总览)
2. [DamageInfo — 伤害信息结构](#2-damageinfo--伤害信息结构)
3. [HitBox — 攻击判定](#3-hitbox--攻击判定)
4. [HurtBox — 受击响应](#4-hurtbox--受击响应)
5. [伤害流水线](#5-伤害流水线)
6. [击退计算](#6-击退计算)
7. [连击链与攻击属性](#7-连击链与攻击属性)
8. [伤害层级与 Layer 矩阵](#8-伤害层级与-layer-矩阵)
9. [事件频道集成](#9-事件频道集成)
10. [CombatConfigSO](#10-combatconfigso)
11. [编辑器友好设计](#11-编辑器友好设计)
12. [状态效果系统StatusEffect / DoT](#12-状态效果系统statuseffect--dot)
13. [拼刀系统Nail Clash](#13-拼刀系统nail-clash)
---
## 1. 系统总览
战斗系统的核心原则是**数据驱动 + 物理解耦**
- 伤害以 `DamageInfo` 结构体传递,不通过直接引用获取对象
- `HitBox``HurtBox` 通过 `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](./54_PoiseSystem.md)|
| `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` 初始化时,将攻击属性注入 `HitBox``SetDamageSource(DamageSourceSO)`
2. AnimationEvent `EnableHitBox` 触发 → `HitBox.Activate()`(碰撞体 enabled = true
3. `OnTriggerEnter2D` 检测碰撞层:
- **HurtBox Layer** → 构建 `DamageInfo`,传递给 `HurtBox.ReceiveDamage(DamageInfo)`
- **对立 HitBox Layer**PlayerAttack ↔ 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](./54_PoiseSystem.md)|
| `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`(弹反反馈)、`EnemySystem`Stagger|
| `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 中的 `Fire``Poison` 等属性真正生效,为战斗增加策略层次。
### 12.1 StatusEffect 基类
```csharp
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 — 燃烧
```csharp
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 — 毒素
```csharp
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 — 宿主组件
挂载在 **PlayerController****EnemyBase** 上,管理所有状态效果。
**性能注意**:状态效果列表通常不超过 45 个并发 entry`List<T>` 遍历的绝对开销极小(< 1μs/帧)。但为避免每帧 `FirstOrDefault` LINQ 分配,`ApplyEffect` / `CleanseEffect` 改用 `Dictionary<StatusEffectType, StatusEffect>` 作为 O(1) 查找结构,同时保留 `List` 作为有序更新序列:
```csharp
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.Poison``DamageInfo` |
**HurtBox 集成**(在现有伤害流水线末尾添加):
```csharp
// 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 引用并处理拼刀逻辑:
```csharp
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`,迫使玩家闪避而非硬拼。