开篇:那个让我通宵三天的伤害系统
2019年,我还在做一款ARPG项目。角色受伤闪红、血条减少、死亡布娃娃——看起来很简单对吧?结果上线前一周,QA报了一个致命Bug:Boss的AOE技能有时会造成双倍伤害,有时又完全不掉血。我查了三天,最后发现是伤害事件在客户端和服务端各触发了一次,而血量的网络同步又用了RepNotify(网络复制通知),导致客户端回滚时血量计算错乱。那一周我几乎没合眼。
后来做了这么多年项目、教了这么多学员,我发现UE5的伤害系统看似简单,实则暗坑密布。今天我就把这些年踩过的坑、学员翻过的车,一次性给你排干净。如果你正准备做伤害与生命值管理,这篇指南能帮你省下至少三个通宵。

坑1:直接修改Health变量,绕过所有封装
现象
你在角色蓝图里直接Set Health = Health - Damage,测试时一切正常。但联机时,客户端血量掉得飞快,服务端却纹丝不动;或者UI血条闪一下又弹回来。
根因
UE5的网络复制(Replication)机制要求只有服务端有权威(Authority)。客户端直接改血量,服务端不认,下一次属性同步就会用服务端的值覆盖客户端——这就是血条“弹回”的原因。更糟的是,如果你在客户端也执行了死亡逻辑,会出现“客户端死了服务端还活着”的鬼畜现象。
解决方案
所有伤害与治疗必须走服务端权威。使用ApplyDamage或自定义的Server_ApplyDamage RPC(远程过程调用)。血量修改只在服务端进行,然后通过RepNotify同步到客户端。
// C++ 示例:服务端权威伤害处理
void AMyCharacter::ApplyDamage(float DamageAmount)
{
if (!HasAuthority()) return; // 客户端直接忽略
Health = FMath::Clamp(Health - DamageAmount, 0.f, MaxHealth);
OnHealthChanged(Health, MaxHealth); // 触发UI更新
if (Health <= 0.f) Die();
}
预防措施
- 在项目设置里开启“Replicate Movement”和属性复制,但别依赖它做伤害逻辑。
- 给所有修改血量的函数加
HasAuthority()检查,养成肌肉记忆。 - 用Gameplay Ability System (GAS)的话,伤害走
GameplayEffect,它自动处理网络预测和回滚。
💡 技巧:在编辑器里勾选“Show Server Debug”,运行时能看到每个角色头顶的“Server/Client”标签,一眼定位谁在改血量。
坑2:滥用Tick做持续伤害,性能雪崩
现象
中毒、燃烧等DOT(持续伤害)效果,你放在Tick里每帧减血。单人测试没问题,但20个敌人同时中毒,帧率直接从120掉到40。
根因
UE5的Tick每帧执行,默认60FPS下每秒调用60次。如果每个中毒角色都Tick,CPU要处理成百上千次浮点运算和网络同步。更可怕的是,Tick的伤害间隔不稳定——帧率波动时,每秒伤害量会漂移,导致“有时疼有时不疼”。
解决方案
用定时器(Timer)替代Tick。设置固定间隔(如0.5秒),用SetTimer循环触发伤害。这样伤害频率与帧率解耦,性能也大幅提升。
// 启动DOT定时器
GetWorldTimerManager().SetTimer(DOTTimerHandle, this, &AMyCharacter::TickDOT, 0.5f, true);
void AMyCharacter::TickDOT()
{
if (HasAuthority())
{
ApplyDamage(DOTDamagePerTick);
}
}
预防措施
- 任何周期性逻辑优先用Timer,除非必须每帧更新(如移动)。
- 如果DOT种类多,用GameplayEffect的Period,它内部也是Timer驱动,且支持堆叠和标签管理。
- 用
stat unit和stat game命令监控性能,发现Tick开销过高立即重构。
坑3:伤害数值用float,精度误差滚雪球
现象
玩家反馈:“我明明剩1滴血,被1点伤害打死了!”你检查发现,血量显示1.0,实际是0.999999。或者连续小额伤害后,总伤害对不上。
根因
浮点数(float)在计算机中是二进制近似表示,0.1+0.2≠0.3。UE5的float是32位,精度约7位小数。当血量经过多次加减(尤其是带小数的伤害),误差会累积,导致逻辑判断出错。
解决方案
伤害和血量使用整数(int32),或者用定点数(如将血量乘以100存为整数)。如果必须用浮点,在比较时加容差(Epsilon)。
// 错误:if (Health <= 0) 可能因浮点误差漏判
// 正确:
if (Health <= KINDA_SMALL_NUMBER) // UE5的极小值常量
{
Health = 0;
Die();
}
预防措施
- 项目初期就定好数值类型,推荐整数。
- 所有UI显示血量时,用
FMath::RoundToInt取整。 - 用
FMath::IsNearlyZero做浮点零值判断。
坑4:伤害事件用Actor重叠触发,重复计算
现象
子弹击中敌人,敌人掉一次血;但子弹有碰撞体,飞过时可能触发多次OnComponentBeginOverlap,导致敌人瞬间掉三四次血。或者AOE技能每帧检测重叠,每帧都造成伤害。
根因
UE5的碰撞系统会为每一次重叠事件调用回调。如果子弹没有设置Generate Overlap Events的过滤,或者没有在第一次命中后销毁/禁用碰撞,就会重复触发。
解决方案
在伤害回调中加去重逻辑:比如子弹命中后立即Destroy(),或给敌人加一个bCanBeDamaged标志,短时间内免疫同一来源。
void AMyBullet::OnHit(UPrimitiveComponent* OverlappedComp, ...)
{
if (bHasHit) return; // 去重
bHasHit = true;
UGameplayStatics::ApplyDamage(OtherActor, Damage, ...);
Destroy();
}
预防措施
- 子弹类Actor默认勾选“Generate Overlap Events”,但命中后立刻销毁。
- AOE技能用
SetTimer做伤害间隔,而不是每帧重叠。 - 用碰撞通道(Collision Channel)精细控制,避免误触发。

坑5:死亡逻辑写在Tick里,尸体还在跑
现象
角色血量归零,播放死亡动画,但尸体还能被子弹击中,甚至还能移动(因为移动组件没关)。或者死亡后血条还在,UI没更新。
根因
死亡是一个状态,不是瞬间事件。很多人在Tick里检查Health <= 0,然后调用Die(),但忘了禁用碰撞、停止移动、取消所有Timer、通知UI。结果就是“活死人”状态。
解决方案
在Die()函数里做完整的清理:
void AMyCharacter::Die()
{
if (bIsDead) return;
bIsDead = true;
// 禁用碰撞
GetCapsuleComponent()->SetCollisionEnabled(ECollisionEnabled::NoCollision);
// 停止移动
GetCharacterMovement()->DisableMovement();
// 清除定时器
GetWorldTimerManager().ClearAllTimersForObject(this);
// 通知UI
OnDeath.Broadcast();
// 播放动画
PlayAnimMontage(DeathMontage);
}
预防措施
- 用状态机管理角色生死,避免在Tick里做状态判断。
- 死亡后设置
bIsDead标志,所有输入和伤害函数第一行检查它。 - 用Gameplay Tags标记“Dead”状态,方便其他系统查询。
坑6:UI血条直接绑定属性,每帧刷新
现象
血条UI在Tick里每帧读取血量并更新进度条。结果UI开销巨大,而且血量变化时没有动画过渡,显得很生硬。
根因
UMG的Tick也是每帧执行,如果多个UI元素都这样,CPU和GPU都会浪费。而且直接SetPercent没有插值,视觉上不流畅。
解决方案
用事件驱动更新UI:血量变化时触发OnHealthChanged委托,UI绑定该委托。进度条用插值动画(如FMath::FInterpTo)平滑过渡。
// 在UI蓝图中绑定事件
void UMyHealthBar::NativeConstruct()
{
Super::NativeConstruct();
if (AMyCharacter* Char = Cast(GetOwningPlayerPawn()))
{
Char->OnHealthChanged.AddDynamic(this, &UMyHealthBar::UpdateHealth);
}
}
void UMyHealthBar::UpdateHealth(float NewHealth, float MaxHealth)
{
TargetPercent = NewHealth / MaxHealth;
// 在Tick里插值到TargetPercent
}
预防措施
- UI只响应事件,不主动轮询。
- 用Material参数控制血条填充,比SetPercent更高效。
- 对于频繁更新的数值,用Invalidation Box优化。
坑7:伤害类型用字符串硬编码,扩展噩梦
现象
你写if (DamageType == “Fire”),后来要加“冰”、“毒”、“物理”,代码里全是字符串比较。改一个类型名,几十处要改,还容易拼错。
根因
字符串比较慢且易错,没有编译期检查。枚举(Enum)或Gameplay Tags才是正道。
解决方案
用Gameplay Tags定义伤害类型,如Damage.Type.Fire。它支持层级、网络复制,还能在编辑器中配置抗性表。
// 定义Tag
FGameplayTag FireTag = FGameplayTag::RequestGameplayTag(FName("Damage.Type.Fire"));
// 应用伤害时传入Tag
UGameplayStatics::ApplyDamage(Target, Damage, ..., FireTag);
预防措施
- 在项目设置里集中管理Gameplay Tags。
- 用DataTable配置伤害类型与抗性的关系,方便策划调整。
- 避免在核心逻辑中使用
FString比较。
坑8:忽略伤害来源,无法做仇恨和击杀奖励
现象
敌人死亡后,你不知道是谁杀死的,无法给玩家奖励;或者Boss的仇恨列表混乱,因为伤害没有记录来源。
根因
ApplyDamage函数有DamageCauser参数,但很多人传nullptr或者传错。导致伤害事件丢失了“谁打的”信息。
解决方案
始终传递正确的DamageCauser(通常是武器Actor或技能Actor)和InstigatedBy(通常是Controller)。在TakeDamage中记录最后伤害来源。
// 造成伤害时
UGameplayStatics::ApplyDamage(
Target,
Damage,
GetInstigatorController(),
this, // DamageCauser
DamageType
);
// 接收伤害时
float AMyCharacter::TakeDamage(float Damage, ...)
{
LastDamageCauser = DamageCauser;
// 记录到仇恨列表
}
预防措施
- 用伤害事件结构体封装所有信息:来源、类型、数值、时间戳。
- 在GAS中,用
GameplayEffectContext传递来源。 - 死亡时通过
LastDamageCauser找到击杀者,发放奖励。

总结:伤害系统不是“减血”那么简单
这8个坑,每一个我都亲眼见过项目翻车。总结下来,核心就三点:网络权威、性能意识、数据驱动。伤害系统是游戏战斗的基石,它必须稳定、高效、可扩展。如果你在自学UE5,我强烈建议你从第一天就按这套规范来,别等上线前才通宵填坑。
关于火星人教育:创办24年来,我们一直专注数字艺术与AIGC实战职业技能培训。在UE5游戏特效、AIGC视觉设计、AI漫剧创作、室内全屋定制、MAYA三维动画、C4D动态设计、工业设计、NUKE影视后期、摄影摄像实训等课程中,我们坚持项目化实训教学,让学员在真实案例中掌握像伤害系统这样的核心架构。北京、深圳双校区开设线下面授班,面向美术生、在校大学生及意向转行的职场人群,夯实专业技能,助力就业与职业提升。如果你也想少踩坑、多出作品,欢迎咨询:010-65918670。








评论(0)