fix(combat): 统一受击无敌闸门并修复 DoT 失效

将无敌判定收口到 PlayerController.TakeDamage(DamageInfo) 单一闸门并认 DamageFlags.IgnoreIFrame,PlayerStats 拆出原始扣血 ApplyDamage;新增 DamageFlags.IsDoT,持续伤害只扣血不打断硬直、不授予无敌。修复 DoT 绕过 HurtBox 时被 flag-blind 的整数 TakeDamage 静默吞没、且 FinalDamage 未结算(恒为0)的双重失效;敌人侧 DoT 标记 IsDoT 避免每 Tick 重置硬直。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 16:30:56 +08:00
parent 523f7c842a
commit 0491e3f919
8 changed files with 54 additions and 15 deletions

View File

@@ -33,6 +33,12 @@ namespace BaseGames.Combat
NoKnockback = 1 << 7, NoKnockback = 1 << 7,
/// <summary>击飞:使敌人进入 KnockUp 状态(腾空 + 落地)。仅在伤害量 &gt;= HitTierConfig.launchThreshold 时生效。</summary> /// <summary>击飞:使敌人进入 KnockUp 状态(腾空 + 落地)。仅在伤害量 &gt;= HitTierConfig.launchThreshold 时生效。</summary>
Launch = 1 << 8, Launch = 1 << 8,
/// <summary>
/// 持续伤害DoT。承伤方据此区分"持续伤害"与"一次性打击"
/// 只扣血,不触发受击硬直/打断,也不授予新的无敌帧。
/// 通常与 <see cref="IgnoreIFrame"/> 同时设置DoT 既不被无敌挡、也不刷新无敌)。
/// </summary>
IsDoT = 1 << 9,
} }
// ── 交互标签 ──────────────────────────────────────────────────────────── // ── 交互标签 ────────────────────────────────────────────────────────────

View File

@@ -32,7 +32,7 @@ namespace BaseGames.Combat.StatusEffects
var info = new DamageInfo.Builder() var info = new DamageInfo.Builder()
.SetRaw(1) .SetRaw(1)
.SetType(DamageType.True) .SetType(DamageType.True)
.SetFlags(DamageFlags.IgnoreIFrame) .SetFlags(DamageFlags.IgnoreIFrame | DamageFlags.IsDoT)
.Build(); .Build();
Owner?.ApplyDirectDamage(info); Owner?.ApplyDirectDamage(info);
} }

View File

@@ -34,7 +34,7 @@ namespace BaseGames.Combat.StatusEffects
var info = new DamageInfo.Builder() var info = new DamageInfo.Builder()
.SetRaw(StackCount) // 叠层越多伤害越高 .SetRaw(StackCount) // 叠层越多伤害越高
.SetType(DamageType.True) .SetType(DamageType.True)
.SetFlags(DamageFlags.IgnoreIFrame) .SetFlags(DamageFlags.IgnoreIFrame | DamageFlags.IsDoT)
.Build(); .Build();
Owner?.ApplyDirectDamage(info); Owner?.ApplyDirectDamage(info);
} }

View File

@@ -130,6 +130,9 @@ namespace BaseGames.Combat.StatusEffects
/// </summary> /// </summary>
public void ApplyDirectDamage(DamageInfo info) public void ApplyDirectDamage(DamageInfo info)
{ {
// DoT 绕过 HurtBox不经过其防御减免步骤需在此自行结算最终伤害。
// True 伤害不计防御,直接以 Amount 作为 FinalDamageBuilder 未设置 FinalDamage默认 0
if (info.FinalDamage <= 0) info.FinalDamage = info.Amount;
_damageable?.TakeDamage(info); _damageable?.TakeDamage(info);
} }

View File

@@ -159,6 +159,14 @@ namespace BaseGames.Enemies
return; return;
} }
// ── 持续伤害DoT只扣血与受击反馈不打断/不重置硬直状态 ──
// 否则燃烧/中毒每个 Tick 都会把敌人重新打入 Hurt 并 InterruptAll形成硬直锁。
if (info.Flags.HasFlag(DamageFlags.IsDoT))
{
OnDamageTaken(info);
return;
}
// ── 受击分级KnockUp > Stagger > Hurt────────────────────── // ── 受击分级KnockUp > Stagger > Hurt──────────────────────
PoiseLevel curPoise = _poiseSource?.GetCurrentPoiseLevel() ?? PoiseLevel.None; PoiseLevel curPoise = _poiseSource?.GetCurrentPoiseLevel() ?? PoiseLevel.None;
bool causesStagger = info.Flags.HasFlag(DamageFlags.ForceBreak) bool causesStagger = info.Flags.HasFlag(DamageFlags.ForceBreak)

View File

@@ -116,7 +116,8 @@ namespace BaseGames.Enemies.StatusEffects
Amount = (int)_damagePerTick, Amount = (int)_damagePerTick,
FinalDamage = (int)_damagePerTick, FinalDamage = (int)_damagePerTick,
Type = DamageType.Fire, Type = DamageType.Fire,
Flags = DamageFlags.None, // IsDoT持续伤害只扣血不让每个 Tick 都把敌人重新打入 Hurt/InterruptAll避免硬直锁
Flags = DamageFlags.IsDoT,
}; };
enemy.TakeDamage(info); enemy.TakeDamage(info);
} }

View File

@@ -192,9 +192,23 @@ namespace BaseGames.Player
/// <summary>Debug开启/关闭无敌模式(不计入无敌帧,永久生效直至关闭)。</summary> /// <summary>Debug开启/关闭无敌模式(不计入无敌帧,永久生效直至关闭)。</summary>
public void SetGodMode(bool v) { _isGodMode = v; } public void SetGodMode(bool v) { _isGodMode = v; }
/// <summary>
/// 受伤入口(含无敌帧判定)。供绕过 HurtBox 的简单环境伤害(如 World 危险区 HazardZone直接调用。
/// 经 HurtBox / PlayerController 流水线的伤害已在上游完成 flag 感知的无敌判定,应调用 <see cref="ApplyDamage"/>。
/// </summary>
public void TakeDamage(int amount) public void TakeDamage(int amount)
{ {
if (_isGodMode || IsInvincible || !IsAlive || amount <= 0) return; if (IsInvincible) return;
ApplyDamage(amount);
}
/// <summary>
/// 原始扣血不做无敌判定。无敌是否生效由上游单一闸门PlayerController.TakeDamage(DamageInfo)
/// 依据 DamageFlags.IgnoreIFrame 决定此处只负责扣血与广播。GodMode 仍然豁免。
/// </summary>
public void ApplyDamage(int amount)
{
if (_isGodMode || !IsAlive || amount <= 0) return;
CurrentHP = Mathf.Max(0, CurrentHP - amount); CurrentHP = Mathf.Max(0, CurrentHP - amount);
_onHPChanged?.Raise(CurrentHP); _onHPChanged?.Raise(CurrentHP);
OnDamaged?.Invoke(); OnDamaged?.Invoke();

View File

@@ -94,23 +94,30 @@ namespace BaseGames.Player.States
public void TakeDamage(DamageInfo info) public void TakeDamage(DamageInfo info)
{ {
if (_stats == null) return; if (_stats == null || !_stats.IsAlive) return;
_stats.TakeDamage(info.FinalDamage);
// 当前状态标记为无敌(如旧版冲刺状态),或 Stats 层无敌窗口仍激活 // ── 唯一无敌闸门(认 flag──────────────────────────────────────
// (冲刺无敌帧 DashInvincibilityDuration 内:跳过受击硬直;窗口过期后可被打断) // 状态级无敌(冲刺)或 Stats 级无敌窗口激活时,普通伤害被挡;
if (_currentState?.IsInvincible == true || (_stats != null && _stats.IsInvincible)) return; // 携带 IgnoreIFrame 的伤害DoT / 陷阱 / 环境)穿透无敌。
bool invincible = (_currentState?.IsInvincible == true) || _stats.IsInvincible;
if (invincible && !info.Flags.HasFlag(DamageFlags.IgnoreIFrame)) return;
if (_stats.IsAlive) // 原始扣血(无敌判定已在上面完成,避免下游再做 flag-blind 拦截)
{ _stats.ApplyDamage(info.FinalDamage);
GetState<HurtState>()?.Initialize(info);
TransitionTo(GetState<HurtState>()); if (!_stats.IsAlive)
}
else
{ {
TransitionTo(GetState<DeadState>()); TransitionTo(GetState<DeadState>());
_onPlayerDied?.Raise(); _onPlayerDied?.Raise();
return;
} }
// ── 持续伤害DoT只扣血不打断硬直、不授予无敌帧 ──────────
if (info.Flags.HasFlag(DamageFlags.IsDoT)) return;
// 一次性打击进入受击硬直HurtState.OnStateEnter 内授予慈悲无敌帧)
GetState<HurtState>()?.Initialize(info);
TransitionTo(GetState<HurtState>());
} }
// ── IPoiseSource 实现(架构 06_CombatModule §13───────────────────── // ── IPoiseSource 实现(架构 06_CombatModule §13─────────────────────