UE4塔防开发实战:士兵AI、塔防调度与伤害结算管线

📅 发布时间:2026/10/14 21:37:30
UE4塔防开发实战:士兵AI、塔防调度与伤害结算管线
简介这是一份基于Unreal Engine 4.22开发的3D塔防游戏完整工程面向希望学习UE4游戏开发、塔防玩法实现与C编程的开发者尤其适合具备一定引擎基础、想通过实战项目理解RTS与塔防机制的中级学习者。资源以zip压缩包形式提供整体约1.01GB包含工程源码、资源文件与项目配置主要涉及C逻辑脚本、UE4蓝图与3D模型贴图等类型可用于研究炮塔放置、敌人波次生成与生命值成长等核心系统。游戏玩法围绕在地图岩石上布置炮塔、抵御11波数量与血量递增的入侵士兵展开并附有播放或启动崩溃时切换地图的排错思路以及音频与敌人角色资源的来源说明。目前已有647人学习下载适合作为个人学习项目参考帮助读者掌握UE4塔防项目的目录组织、关卡搭建与基础战斗逻辑实现。1. 士兵-塔-防御-UE4这套组合到底在解决什么问题很多人第一次听到「士兵-塔-防御-UE4」这个组合脑子里浮现的是那种手机上一抓一大把的卡通塔防。但真正用 UE4 做过一版的人会告诉你这套东西的核心难点根本不在「塔」上而在「士兵」和「防御」之间的那套调度逻辑。塔是静态的士兵是动态的防御是一个持续判定的过程——三者叠在一起考验的是你对 UE4 里 Actor 生命周期、AI 行为树、导航网格和伤害结算这几块的理解深度。我见过太多人卡在同一个地方塔能建、怪能走、子弹能飞但一旦士兵开始寻路绕塔、塔开始按优先级选目标、防御波次开始叠加帧率就掉、AI 就乱、伤害就丢。这不是引擎的问题是架构没提前想清楚。这篇笔记就是把我自己踩过的这条线捋一遍从工程结构到行为树到伤害管线给一套能直接照着搭的方案。适合已经会 UE4 基础操作、想做一个能跑起来的 3D 塔防原型的开发者。2. 工程结构与核心 Actor 的职责划分2.1 为什么先定 Actor 边界而不是先摆场景新手最容易犯的错是打开 UE4 先拖地形、摆路径、放几个静态网格体当塔然后才想起来「哦塔要攻击」。这时候你会发现塔的蓝图里塞满了检测、选目标、生成子弹、结算伤害的逻辑改一个参数要翻三层。正确的顺序是先想清楚有哪些 Actor每个 Actor 负责什么再动手摆场景。我一般会把整个塔防拆成五类核心 ActorATowerBase塔基类、AEnemyUnit敌方士兵、AProjectile投射物、AWaveManager波次管理器、APlayerCommander玩家指挥入口。塔只管「选目标 开火」士兵只管「移动 受击 死亡」投射物只管「飞行 命中回调」波次管理器只管「什么时候刷、刷多少、刷什么」玩家指挥只管「建塔、升级、卖塔」。职责一旦切开后面调参数就是改一个地方的事。这里有个关键决策塔的选目标逻辑放在塔自己身上还是放在一个全局的 TargetManager 上小规模同屏 50 个敌人以内我建议放塔自己身上用 UE4 的UGameplayStatics::GetAllActorsOfClass配合距离排序就够了。大规模才需要引入全局空间划分。别一上来就上四叉树那是给自己找麻烦。2.2 用 C 基类 蓝图子类的混合模式纯蓝图做塔防到中期一定会遇到「复制粘贴改到崩溃」的问题。纯 C 又会让策划调参数变得很痛苦。我的做法是核心逻辑用 C 写基类暴露UPROPERTY(EditAnywhere)和UFUNCTION(BlueprintCallable)具体塔的类型用蓝图子类去配数值和换模型。// TowerBase.h —— 塔的 C 基类只放通用逻辑 UCLASS(Abstract, Blueprintable) class TOWERDEFENSE_API ATowerBase : public AActor { GENERATED_BODY() public: ATowerBase(); // 攻击范围蓝图里可调 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category Tower|Combat) float AttackRange 800.f; // 攻击间隔秒 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category Tower|Combat) float AttackInterval 1.2f; // 单次伤害 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category Tower|Combat) float DamagePerShot 25.f; // 当前锁定的目标 UPROPERTY(BlueprintReadOnly, Category Tower|Combat) TWeakObjectPtrAEnemyUnit CurrentTarget; protected: virtual void BeginPlay() override; // 由蓝图子类决定「怎么开火」——生成投射物还是即时结算 UFUNCTION(BlueprintImplementableEvent, Category Tower|Combat) void OnFire(AEnemyUnit* Target); // 选目标默认实现是「范围内最近的」 UFUNCTION(BlueprintCallable, Category Tower|Combat) virtual AEnemyUnit* SelectTarget(); private: FTimerHandle FireTimerHandle; void TryFire(); };这段代码的关键点有三个。第一Abstract标记让这个类不能被直接拖进场景必须用蓝图子类避免误用。第二OnFire用BlueprintImplementableEvent意思是 C 只负责「什么时候开火」具体「开火长什么样」交给蓝图这样换弹道、换特效不用重编译。第三CurrentTarget用TWeakObjectPtr而不是裸指针因为敌人随时可能死亡被销毁弱指针能避免悬空引用导致的崩溃——这是血泪经验早期用裸指针的时候敌人一死塔就崩。参数说明AttackRange单位是厘米UE4 默认单位。AttackInterval是两次开火之间的最小间隔实际开火时机由TryFire里的定时器控制。DamagePerShot是单发基础伤害后面结算时会再乘各种系数。2.3 目录结构与资源命名约定工程目录乱后期找资源能找疯。我固定用这套结构Content/ TowerDefense/ Core/ // GameMode、PlayerController、WaveManager Towers/ // 塔的蓝图与网格体 Enemies/ // 士兵蓝图、动画、行为树 Projectiles/ // 投射物 Maps/ // 关卡 UI/ // 建造菜单、血条 Data/ // DataTable、曲线资产命名上蓝图统一前缀BP_网格体SM_材质M_行为树BT_黑板BB_。塔的蓝图叫BP_Tower_Arrow、BP_Tower_Cannon敌人叫BP_Enemy_Grunt、BP_Enemy_Shield。这套约定看起来是小事但当你有三十个蓝图的时候能不能一眼找到目标就靠它。3. 士兵 AI行为树 导航网格的落地配置3.1 导航网格怎么铺才不会让士兵卡墙3D 塔防的士兵寻路绕不开 NavMesh。UE4 里你需要放一个NavMeshBoundsVolume覆盖可行走区域然后按P预览绿色可走区域。这里第一个坑是如果你的地形有高低差默认的 NavMesh 生成参数可能让士兵走不上斜坡或者直接穿过本该阻挡的塔基。关键参数在Project Settings → Navigation Mesh里。Agent Radius决定士兵能挤过多窄的缝默认 35如果你的士兵模型肩宽是 60 厘米这个值要调到 30 左右否则士兵会卡在两塔之间。Agent Height决定能不能从低矮障碍下穿过默认 144一般不用改。Cell Size默认 19调小能让寻路更精细但更吃性能我一般保持默认。还有一个隐蔽的坑塔基如果用的是静态网格体且没勾Can Ever Affect Navigation士兵会直接穿塔走过去。塔基的碰撞体要设成Blocking Volume或者勾上影响导航否则你的「防御」就是个摆设。3.2 行为树的任务节点设计士兵的 AI 我拆成三个核心任务BTTask_MoveToTarget沿路径推进、BTTask_AttackStructure攻击阻挡物、BTTask_CheckDeath死亡判定。黑板里放三个键TargetLocationVector、CurrentTargetObject、HealthFloat。行为树结构大致是Selector 根节点 → 第一个分支是「如果血量 ≤ 0 就执行死亡」→ 第二个分支是「如果前方有阻挡的塔就攻击」→ 第三个分支是「否则继续向终点移动」。这个顺序很重要死亡判定必须放最前面否则死掉的士兵还会继续走。// BTTask_MoveToTarget.cpp —— 自定义移动任务 EBTNodeResult::Type UBTTask_MoveToTarget::ExecuteTask( UBehaviorTreeComponent OwnerComp, uint8* NodeMemory) { AAIController* AIController OwnerComp.GetAIOwner(); if (!AIController) return EBTNodeResult::Failed; APawn* Pawn AIController-GetPawn(); if (!Pawn) return EBTNodeResult::Failed; // 从黑板拿终点位置 UBlackboardComponent* BB OwnerComp.GetBlackboardComponent(); FVector Destination BB-GetValueAsVector(TEXT(TargetLocation)); // 用 MoveTo 发起寻路接受半径设 50避免士兵在终点附近抖动 FAIMoveRequest MoveReq(Destination); MoveReq.SetAcceptanceRadius(50.f); MoveReq.SetUsePathfinding(true); FPathFollowingRequestResult Result AIController-MoveTo(MoveReq); // 如果寻路失败比如终点不可达返回 Failed 让行为树走其他分支 if (Result.Code EPathFollowingRequestResult::Failed) { return EBTNodeResult::Failed; } // 移动是异步的返回 InProgress等 OnMoveCompleted 再通知 return EBTNodeResult::InProgress; }逻辑说明这个任务不阻塞发起移动后立刻返回InProgress行为树会等AIController的OnMoveCompleted回调。参数上AcceptanceRadius设 50 是个经验值太小士兵会在终点反复微调位置太大又会导致士兵提前停下。SetUsePathfinding(true)确保走 NavMesh 而不是直线。3.3 士兵的受击与死亡处理士兵受击不能直接改血量然后判断要走一套统一的伤害入口。我在AEnemyUnit里定义一个ApplyDamage函数所有伤害来源塔的投射物、范围伤害、持续伤害都调它。// EnemyUnit.cpp —— 统一伤害入口 float AEnemyUnit::ApplyDamage(float RawDamage, AActor* DamageCauser, EDamageType Type) { if (bIsDead) return 0.f; // 护甲减伤简单线性护甲值直接减固定伤害 float ActualDamage FMath::Max(RawDamage - ArmorValue, RawDamage * 0.1f); // 类型克制比如穿刺打轻甲加成钝击打重甲加成 ActualDamage * GetTypeMultiplier(Type); CurrentHealth - ActualDamage; // 广播受击事件UI 和音效监听这个 OnEnemyDamaged.Broadcast(this, ActualDamage); if (CurrentHealth 0.f) { bIsDead true; OnEnemyDied.Broadcast(this, DamageCauser); // 延迟一帧销毁给死亡动画留时间 SetLifeSpan(2.0f); } return ActualDamage; }参数说明ArmorValue是固定减伤但用FMath::Max兜底保证至少造成 10% 伤害否则高护甲单位会完全免疫低伤害攻击游戏体验会很差。GetTypeMultiplier返回克制系数比如穿刺对轻甲 1.5、对重甲 0.7。SetLifeSpan(2.0f)让尸体留两秒再消失配合死亡动画。4. 塔的选目标与伤害结算管线4.1 选目标策略最近、最前、还是最弱塔的选目标策略直接决定游戏难度曲线。常见的有四种最近目标、最靠近终点的目标、血量最低的目标、仇恨值最高的目标。我一般默认用「最靠近终点」因为这样塔会优先打威胁最大的敌人玩家不会觉得塔在「摸鱼」。实现上不要每帧都遍历所有敌人。用定时器每 0.2 秒刷新一次目标列表就够了人眼根本分辨不出 0.2 秒的延迟。遍历时用UGameplayStatics::GetAllActorsOfClass拿到所有AEnemyUnit过滤掉已死亡的然后按「距离终点的路径长度」排序。注意是路径长度不是直线距离直线距离会让塔在拐角处选错目标。// TowerBase.cpp —— 选目标实现 AEnemyUnit* ATowerBase::SelectTarget() { TArrayAActor* FoundEnemies; UGameplayStatics::GetAllActorsOfClass( GetWorld(), AEnemyUnit::StaticClass(), FoundEnemies); AEnemyUnit* BestTarget nullptr; float BestProgress -1.f; for (AActor* Actor : FoundEnemies) { AEnemyUnit* Enemy CastAEnemyUnit(Actor); if (!Enemy || Enemy-IsDead()) continue; // 距离检查超出攻击范围直接跳过 float DistSq FVector::DistSquared(GetActorLocation(), Enemy-GetActorLocation()); if (DistSq AttackRange * AttackRange) continue; // 用「已走路径进度」作为排序依据进度越大越优先 float Progress Enemy-GetPathProgress(); if (Progress BestProgress) { BestProgress Progress; BestTarget Enemy; } } return BestTarget; }逻辑说明GetPathProgress()是士兵自己维护的一个 0 到 1 的浮点值表示从起点到终点走了多少。这个值在士兵沿样条线移动时更新。用平方距离比较避免开方是性能上的小优化。参数上AttackRange从基类继承蓝图子类可以覆盖。4.2 投射物与命中判定别用 Tick 做碰撞新手做投射物最容易犯的错是在Tick里做射线检测。正确做法是用 UE4 的ProjectileMovementComponent加碰撞回调。投射物飞出去之后引擎自己处理移动和碰撞你只需要在OnHit回调里结算伤害。// Projectile.cpp —— 命中回调 void AProjectile::OnHit(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult Hit) { AEnemyUnit* Enemy CastAEnemyUnit(OtherActor); if (Enemy !Enemy-IsDead()) { // 结算伤害带上伤害类型 Enemy-ApplyDamage(Damage, GetOwner(), DamageType); // 如果有范围伤害做一次球形检测 if (SplashRadius 0.f) { TArrayFOverlapResult Overlaps; FCollisionShape Sphere FCollisionShape::MakeSphere(SplashRadius); GetWorld()-OverlapMultiByChannel(Overlaps, GetActorLocation(), FQuat::Identity, ECC_Pawn, Sphere); for (auto Overlap : Overlaps) { AEnemyUnit* SplashTarget CastAEnemyUnit(Overlap.GetActor()); if (SplashTarget SplashTarget ! Enemy) { // 范围伤害衰减距离越远伤害越低 float Dist FVector::Dist(GetActorLocation(), SplashTarget-GetActorLocation()); float Falloff 1.f - FMath::Clamp(Dist / SplashRadius, 0.f, 1.f); SplashTarget-ApplyDamage(Damage * Falloff * 0.5f, GetOwner(), DamageType); } } } } // 命中后销毁自己 Destroy(); }参数说明SplashRadius为 0 表示单体伤害大于 0 触发范围伤害。范围伤害用OverlapMultiByChannel而不是GetAllActorsOfClass因为前者走物理查询更快。衰减系数用线性中心 100%、边缘 0%再乘 0.5 作为范围伤害的总体系数避免范围伤害过强。4.3 伤害结算的时序问题这里有个非常隐蔽的坑如果多个投射物在同一帧命中同一个敌人而敌人的死亡逻辑是「血量 ≤ 0 就销毁」那么第二个投射物结算时敌人可能已经被标记销毁导致ApplyDamage里的bIsDead检查生效第二个投射物的伤害被吞掉。这在视觉上表现为「明明打中了却没伤害」。解决办法是在ApplyDamage里用bIsDead而不是IsPendingKill()做判断并且销毁用SetLifeSpan延迟不要立刻Destroy()。这样同一帧内的多次伤害都能正常结算只是最后一次触发死亡。这个细节我在早期版本里被坑了整整一个下午敌人血量明明够但总是提前死。5. 避坑与排查那些让塔防原型翻车的细节5.1 士兵在塔基附近抖动或绕圈现象士兵走到塔附近时开始左右抖动或者绕着塔转圈不前进。原因NavMesh 的Agent Radius设得比士兵实际碰撞半径小导致寻路认为能过但物理碰撞过不去AI 在「能走」和「走不了」之间反复横跳。解决把Agent Radius调到略大于士兵胶囊体半径同时检查塔基的碰撞预设是不是BlockAll或BlockPawn。如果塔基是装饰性的把碰撞设成NoCollision并确保不影响导航。5.2 塔的攻击目标在敌人死亡后不切换现象敌人死了塔还在对着尸体位置开火或者干脆停火不选新目标。原因CurrentTarget是弱指针敌人销毁后指针变空但塔的开火定时器还在跑TryFire里没做空指针检查。解决在TryFire开头加if (!CurrentTarget.IsValid()) { CurrentTarget SelectTarget(); }每次开火前都验证目标有效性。另外把选目标的定时器和开火定时器分开选目标频率可以低一些。5.3 波次叠加时帧率骤降现象同时刷 100 个以上士兵时帧率从 60 掉到 20。原因每个士兵的行为树每帧都在跑GetAllActorsOfClass或者类似的全局查询100 个士兵就是 100 次全局遍历。解决把全局查询改成事件驱动。波次管理器维护一个敌人列表士兵出生时注册、死亡时注销塔选目标时直接读这个列表而不是遍历场景。另外行为树的Tick间隔可以调不是所有任务都需要每帧执行。5.4 投射物穿透多个敌人现象一发子弹打穿了排成一列的三个敌人只对第一个造成伤害。原因投射物的碰撞预设设成了Overlap而不是Block或者ProjectileMovementComponent的bShouldBounce配置导致穿透。解决投射物的碰撞体用Block响应ProjectileMovementComponent的OnHit里手动Destroy()。如果需要穿透效果用bPenetrate标记并在命中后不销毁但限制穿透次数。5.5 伤害数字与血条不同步现象血条显示还有血但敌人已经死了或者伤害数字飘出来但血量没变。原因血条 UI 绑定的是CurrentHealth的每帧读取而伤害结算是异步的两者时序不一致。解决用事件驱动而不是轮询。ApplyDamage里广播OnEnemyDamaged血条 UI 监听这个事件更新。这样伤害和显示在同一帧内完成不会出现视觉延迟。6. 进阶用数据驱动把塔和敌人做成可配置资产6.1 为什么要把数值从蓝图里抽出来当你有 8 种塔、12 种敌人、5 个关卡的时候改一个数值要打开对应蓝图、找到变量、改完保存、再编译这个流程一天能重复上百次。更麻烦的是平衡性调整你想让所有塔的攻击力整体下调 10%得一个个改。数据驱动的意义就在这把数值抽到 DataTable 或 DataAsset 里改一张表就能影响全局。我一般用FTableRowBase派生一个FTowerStatsRow字段包括攻击力、射程、攻速、造价、升级费用、克制系数。塔的蓝图只负责表现数值全部从 DataTable 读。// TowerStatsRow.h —— 数据表行结构 USTRUCT(BlueprintType) struct FTowerStatsRow : public FTableRowBase { GENERATED_BODY() UPROPERTY(EditAnywhere) float AttackRange 800.f; UPROPERTY(EditAnywhere) float AttackInterval 1.2f; UPROPERTY(EditAnywhere) float DamagePerShot 25.f; UPROPERTY(EditAnywhere) float BuildCost 100.f; UPROPERTY(EditAnywhere) float UpgradeCost 80.f; UPROPERTY(EditAnywhere) int32 MaxLevel 3; UPROPERTY(EditAnywhere) TMapEDamageType, float TypeMultipliers; };然后在塔的BeginPlay里根据TowerRowName从 DataTable 拉数据填充。这样策划改数值只需要在 CSV 或编辑器里改表不用碰蓝图。6.2 用曲线资产控制波次难度波次难度不能是线性的否则前期太简单后期太难。我一般用UCurveFloat定义「波次编号 → 难度系数」的映射然后波次管理器根据系数决定刷怪数量和血量倍率。// WaveManager.cpp —— 根据曲线计算波次参数 void AWaveManager::SpawnWave(int32 WaveIndex) { // 从曲线拿难度系数 float Difficulty DifficultyCurve ? DifficultyCurve-GetFloatValue(WaveIndex) : 1.f; // 基础数量 难度加成 int32 EnemyCount FMath::RoundToInt(BaseEnemyCount * Difficulty); // 血量倍率每波递增 8% float HealthMultiplier FMath::Pow(1.08f, WaveIndex); for (int32 i 0; i EnemyCount; i) { // 从刷怪点生成延迟 0.3 秒错开避免同帧生成卡顿 FTimerHandle Handle; GetWorldTimerManager().SetTimer(Handle, [this, HealthMultiplier]() { SpawnSingleEnemy(HealthMultiplier); }, 0.3f * i, false); } }参数说明DifficultyCurve是一条 S 形曲线前 5 波平缓、中间陡峭、后期趋近上限。HealthMultiplier用指数 1.08 而不是线性因为线性增长在后期会被塔的伤害成长甩开。刷怪用定时器错开避免同帧生成大量 Actor 导致的卡顿。6.3 一个验证数值是否合理的小技巧数值调完之后怎么验证我一般会做一个「自动对战」模式让 AI 自动建塔、自动升级跑 20 波看能不能守住。如果 20 波内必输说明难度太高如果 20 波后还有大量资源剩余说明太简单。这个模式不需要 UI纯逻辑跑几分钟就能跑完一轮比手动测试快得多。具体做法是在 GameMode 里加一个bAutoPlay开关开启后用一个简单的策略 AI 每隔几秒检查资源、建塔、升级。这个 AI 不需要聪明只要「有钱就建、有塔就升」就行它的作用是暴露数值问题不是模拟真实玩家。我自己的习惯是每次改完数值表先跑三轮自动对战看平均能守到第几波。如果三轮的方差很大说明随机性太强需要调整刷怪策略。如果三轮都卡在同一个波次说明那个波次有数值断层。这个习惯帮我省了大量手动测试的时间也避免了很多「感觉上平衡了但实际一跑就崩」的情况。希望帮到你。本文还有配套的精品资源点击获取