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

681 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`,迫使玩家闪避而非硬拼。