# 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 间隔"。