1713 lines
70 KiB
Markdown
1713 lines
70 KiB
Markdown
# 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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 零堆分配工厂(热路径首选)。直接从 <see cref="DamageSourceSO"/> 填入基础字段;
|
||
/// KnockbackDirection / SourcePosition / SourceLayer 等运行时字段由调用方就地赋值——
|
||
/// struct 是值类型,局部变量字段写入无任何堆分配。
|
||
/// <br/>⚡ HitBox.OnTriggerEnter2D 等高频路径必须使用此方法;
|
||
/// 仅在需要链式覆盖多字段的复杂场景(如 Boss 特殊相位攻击)才使用 Builder。
|
||
/// </summary>
|
||
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<DamageInfo> 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<HurtBox>();
|
||
if (hurtBox != null)
|
||
{
|
||
hurtBox.ReceiveDamage(info);
|
||
OnHitConfirmed?.Invoke(info);
|
||
return;
|
||
}
|
||
|
||
// ② 命中 IBreakable(机关/障碍物)——Category/Tags 满足条件才响应
|
||
var breakable = other.GetComponent<IBreakable>();
|
||
breakable?.TryInteract(info);
|
||
// 注意:机关命中不触发 OnHitConfirmed(不给灵力),如需特殊处理可扩展
|
||
}
|
||
|
||
private Dictionary<Collider2D, float> _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<StatusEffectManager>();
|
||
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
|
||
{
|
||
/// <summary>返回当前帧的霸体等级(受时间窗口/状态机控制)</summary>
|
||
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<Rigidbody2D>();
|
||
_hitBox = GetComponent<HitBox>();
|
||
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
|
||
|
||
/// <summary>弹反成功时通过 OnParrySuccess 频道广播的载荷,供反击伤害计算、VFX 使用</summary>
|
||
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
|
||
{
|
||
/// <summary>当前是否处于弹反激活窗口。Phase 2 由输入/动画事件写入。</summary>
|
||
public bool IsParrying { get; private set; }
|
||
|
||
/// <summary>
|
||
/// 查询并消费一次弹反机会。
|
||
/// 若处于弹反窗口则返回 true 并关闭窗口;否则返回 false。
|
||
/// </summary>
|
||
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<StatusEffect> _activeList = new();
|
||
readonly Dictionary<StatusEffectType, StatusEffect> _activeIndex = new();
|
||
|
||
SpriteRenderer _renderer;
|
||
MaterialPropertyBlock _propBlock;
|
||
|
||
void Awake()
|
||
{
|
||
_renderer = GetComponent<SpriteRenderer>();
|
||
_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);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <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);
|
||
_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);
|
||
|
||
/// <summary>由状态效果调用,设置 Shader 参数(MaterialPropertyBlock,不修改共享材质)。</summary>
|
||
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();
|
||
}
|
||
|
||
/// <summary>同类型效果再次施加时(叠层/刷新)。</summary>
|
||
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<OverrideEntry> 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<HurtBox>(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<HitBox>(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<int> _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<Collider2D>();
|
||
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<Collider2D>();
|
||
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<StatusEffect> _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 间隔"。
|
||
|