# 06 · 战斗模块
> **命名空间** `BaseGames.Combat`、`BaseGames.Combat.StatusEffects`、`BaseGames.Parry`
> **程序集** `BaseGames.Combat`、`BaseGames.Combat.StatusEffects`、`BaseGames.Parry`
> **路径** `Assets/Scripts/Combat/`、`Assets/Scripts/Parry/`
> **依赖** `BaseGames.Core.Events`
---
## 目录
1. [DamageInfo 结构体](#1-damageinfo-结构体)
2. [DamageType / DamageCategory / DamageFlags / DamageTags / HitFxType / BreakLevel 枚举](#2-枚举定义)
3. [DamageSourceSO](#3-damagesourceso)
4. [HitBox](#4-hitbox)
5. [HurtBox](#5-hurtbox)
6. [伤害流水线(完整时序)](#6-伤害流水线)
7. [Projectile(弹射物)](#7-projectile)
8. [ParrySystem](#8-parrysystem)
9. [ParryConfigSO](#9-parryconfigso)
10. [StatusEffectManager](#10-statuseffectmanager)
11. [StatusEffect 基类与具体状态效果](#11-statuseffect-基类与具体状态效果)
12. [Layer 矩阵配置](#12-layer-矩阵配置)
13. [PoiseSystem(霸体系统)](#13-poisesystem)
14. [IBreakable — 机关/障碍物交互](#14-ibreakable--机关障碍物交互系统)
15. [ClashResolver — 拼刀系统](#15-clashresolver--拼刀系统)
---
## 1. DamageInfo 结构体
```csharp
// 路径: 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 同步初始化 Amount(Amount 始终以 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;
}
///
/// 零堆分配工厂(热路径首选)。直接从 填入基础字段;
/// KnockbackDirection / SourcePosition / SourceLayer 等运行时字段由调用方就地赋值——
/// struct 是值类型,局部变量字段写入无任何堆分配。
///
⚡ HitBox.OnTriggerEnter2D 等高频路径必须使用此方法;
/// 仅在需要链式覆盖多字段的复杂场景(如 Boss 特殊相位攻击)才使用 Builder。
///
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`(流水线中被护盾/防御修改) → `FinalDamage`(HurtBox 写入,最终 HP 扣除量)。
> 下游 IDamageable.TakeDamage 接收时,`Amount == FinalDamage`(已被 HurtBox 写入);`EnemyBase.TakeDamage` 用 `info.FinalDamage` 正确。
---
## 2. 枚举定义
```csharp
// ── 元素/物理属性(决定抗性、特效) ─────────────────────────────────────────────
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
```csharp
// 路径: 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 本体上**,而是作为**武器 Prefab**(`WPN_*.prefab`)或
> **技能 HitBox Prefab**(`SKL_*_HitBox.prefab`)的子节点存在。
> - **武器**:`WeaponManager.Equip()` 时 Instantiate 武器 Prefab 并挂至 `[WeaponSocket]`;卸装时 Destroy。
> - **技能**:`SkillManager.TrySoulSkill()` 等施放时 Instantiate HitBox Prefab 并挂至 `[SkillSocket]`;持续时间结束后 Destroy。
> - **投射物**:`Projectile` 自身携带 HitBox(见 §7),随 Projectile 生命周期销毁。
> 这样碰撞盒的**形状、大小、偏移**完全由各武器/技能的 Prefab 决定,角色本体无需改动。
```csharp
// 路径: 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 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;
// ⚡ 零 GC:struct 工厂返回值类型,就地修改运行时字段,无堆分配
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();
if (hurtBox != null)
{
hurtBox.ReceiveDamage(info);
OnHitConfirmed?.Invoke(info);
return;
}
// ② 命中 IBreakable(机关/障碍物)——Category/Tags 满足条件才响应
var breakable = other.GetComponent();
breakable?.TryInteract(info);
// 注意:机关命中不触发 OnHitConfirmed(不给灵力),如需特殊处理可扩展
}
private Dictionary _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
```csharp
// 路径: 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_DamageDealt(AnalyticsManager)
[SerializeField] private HitConfirmedEventChannelSO _onHitConfirmed; // EVT_HitConfirmed(VFX/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 返回穿透量(int):0 表示全部被护盾吸收,>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();
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
{
/// 返回当前帧的霸体等级(受时间窗口/状态机控制)
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.Flags` 含 `FixedKnockback` 或由 DamageSourceSO 配置)| 直接使用 `DamageInfo.KnockbackDirection`,不动态重算 |
---
## 7. Projectile
```csharp
// 路径: 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();
_hitBox = GetComponent();
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
```
```csharp
// 路径: Assets/Scripts/Parry/ParrySystem.cs
/// 弹反成功时通过 OnParrySuccess 频道广播的载荷,供反击伤害计算、VFX 使用
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,避免循环依赖。
// HurtBox(Combat 层)主动调用 ConsumeParry() 查询状态,无需传入 DamageInfo。
// Phase 2:若需完美弹反判断,提取 BaseGames.Combat.Data(含 DamageInfo)
// 作为共享子程序集,Parry 和 Combat 同时引用,无循环。
public class ParrySystem : MonoBehaviour
{
/// 当前是否处于弹反激活窗口。Phase 2 由输入/动画事件写入。
public bool IsParrying { get; private set; }
///
/// 查询并消费一次弹反机会。
/// 若处于弹反窗口则返回 true 并关闭窗口;否则返回 false。
///
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
```csharp
[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
```csharp
// 路径: 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 _activeList = new();
readonly Dictionary _activeIndex = new();
SpriteRenderer _renderer;
MaterialPropertyBlock _propBlock;
void Awake()
{
_renderer = GetComponent();
_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);
}
}
}
/// 施加状态效果。已有相同类型时叠层/刷新(O(1) 查找)。
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);
}
}
/// 净化指定类型的状态效果(O(1) 查找)。
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();
}
/// 由状态效果调用,直接扣 HP(True 伤害,绕过 HurtBox)。
public void ApplyDirectDamage(DamageInfo info)
=> GetComponent()?.TakeDamage(info);
/// 由状态效果调用,设置 Shader 参数(MaterialPropertyBlock,不修改共享材质)。
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 基类与具体状态效果
```csharp
// 路径: 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();
}
/// 同类型效果再次施加时(叠层/刷新)。
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 Settings`(`ProjectSettings/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.Break`(`BreakLevel` 枚举)——此次攻击能打断多高的霸体
> - 承受方:`IPoiseSource.GetCurrentPoiseLevel()`(`PoiseLevel` 枚举)——当前帧拥有多高的霸体
> - 判定公式:`(int)info.Break >= (int)currentPoise` → 打断成功
> - **玩家和敌人均可拥有霸体**(玩家在攻击/技能动画的特定帧,敌人在超甲状态)
---
### PoiseWindowConfig(时间窗口霸体)
```csharp
// 路径: Assets/Scripts/Combat/PoiseWindowConfig.cs
// 描述某个状态/技能在特定动画时间段内的霸体等级
[System.Serializable]
public struct PoiseWindowConfig
{
public PoiseLevel Level; // 此窗口期间的霸体等级
public float NormalizedStart; // 动画归一化时间起点(0~1)
public float NormalizedEnd; // 动画归一化时间终点(0~1)
}
```
---
### PoiseOverrideTableSO(精细控制资产)
```csharp
// 路径: 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 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
```csharp
// 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(敌人霸体)
```csharp
// 路径: 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(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` 而非 `IDamageable`,`HitBox.OnTriggerEnter2D` 会把 `DamageInfo`
> 传给 `IBreakable.TryInteract(info)` 而非 `HurtBox`,由物体本身决定是否响应。
### BreakConditionSO
```csharp
// 路径: 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 接口
```csharp
// 路径: Assets/Scripts/Combat/IBreakable.cs
// 机关、障碍物实现此接口,而非 IDamageable
// Layer 建议: "Breakable"(见 §12 Layer 矩阵)
public interface IBreakable
{
// 由 HitBox.OnTriggerEnter2D 调用
// 返回 true 表示成功触发,false 表示条件不满足(静默忽略)
bool TryInteract(in DamageInfo info);
}
```
### BreakableProp(通用实现)
```csharp
// 路径: 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(other, out rivalHitBox)
└─ rivalHitBox.IsActive
&& rivalHitBox.CanClash (Flags.HasFlag CanClash)
&& this.CanClash
└─ ClashResolver.Instance.ResolveClash(this, rivalHitBox)
→ 中止伤害,触发拼刀效果
```
### ClashResolver 类
```csharp
// 路径: 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 _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. 拼刀 HitStop(1 帧,比普通命中的 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
```csharp
// 路径: 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
```csharp
// 路径: Assets/Scripts/Editor/Combat/HitBoxEditor.cs
// 程序集: BaseGames.Editor(Editor 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();
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
```csharp
// 路径: 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();
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
```csharp
// StatusEffectManager 扩展(原 §10 实现基础上新增 Tick 集中调度)
// 路径: Assets/Scripts/Combat/StatusEffectManager.cs
// ──────────────────────────────────────────────────────────────────
// 每帧由 StatusEffectManager.Update() 统一推进,StatusEffect 子类
// 自身不挂 MonoBehaviour,不持有 Update/Coroutine
// ──────────────────────────────────────────────────────────────────
public class StatusEffectManager : MonoBehaviour, IDamageable
{
// 运行中的效果列表(复用 §10 的 _activeEffects)
private readonly List _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` 字段更名为 `RemainingDuration`(Manager 负责倒计时);`TickInterval` 字段保持不变,含义从"协程 WaitForSeconds 参数"转为"Manager Tick 间隔"。