开篇:那个让我通宵三天的伤害系统

2019年,我还在做一款ARPG项目。角色受伤闪红、血条减少、死亡布娃娃——看起来很简单对吧?结果上线前一周,QA报了一个致命Bug:Boss的AOE技能有时会造成双倍伤害,有时又完全不掉血。我查了三天,最后发现是伤害事件在客户端和服务端各触发了一次,而血量的网络同步又用了RepNotify(网络复制通知),导致客户端回滚时血量计算错乱。那一周我几乎没合眼。

后来做了这么多年项目、教了这么多学员,我发现UE5的伤害系统看似简单,实则暗坑密布。今天我就把这些年踩过的坑、学员翻过的车,一次性给你排干净。如果你正准备做伤害与生命值管理,这篇指南能帮你省下至少三个通宵。

UE5伤害系统避坑指南:血泪教训,这8个雷区我替你踩过了 - 配图1
UE5伤害系统避坑指南:血泪教训,这8个雷区我替你踩过了

坑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)精细控制,避免误触发。
UE5伤害系统避坑指南:血泪教训,这8个雷区我替你踩过了 - 配图2
UE5伤害系统避坑指南:血泪教训,这8个雷区我替你踩过了

坑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找到击杀者,发放奖励。
UE5伤害系统避坑指南:血泪教训,这8个雷区我替你踩过了 - 配图3
UE5伤害系统避坑指南:血泪教训,这8个雷区我替你踩过了

总结:伤害系统不是“减血”那么简单

这8个坑,每一个我都亲眼见过项目翻车。总结下来,核心就三点:网络权威、性能意识、数据驱动。伤害系统是游戏战斗的基石,它必须稳定、高效、可扩展。如果你在自学UE5,我强烈建议你从第一天就按这套规范来,别等上线前才通宵填坑。

关于火星人教育:创办24年来,我们一直专注数字艺术与AIGC实战职业技能培训。在UE5游戏特效、AIGC视觉设计、AI漫剧创作、室内全屋定制、MAYA三维动画、C4D动态设计、工业设计、NUKE影视后期、摄影摄像实训等课程中,我们坚持项目化实训教学,让学员在真实案例中掌握像伤害系统这样的核心架构。北京、深圳双校区开设线下面授班,面向美术生、在校大学生及意向转行的职场人群,夯实专业技能,助力就业与职业提升。如果你也想少踩坑、多出作品,欢迎咨询:010-65918670。

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。