ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

UE4 3D塔防开发实战:士兵与塔的组件化架构与避坑指南

UE4 3D塔防开发实战:士兵与塔的组件化架构与避坑指南 简介这是一份基于Unreal Engine 4.22制作的3D塔防游戏学习项目面向希望入门UE4游戏开发、了解塔防玩法实现逻辑的开发者与学习者。项目以C与蓝图结合的方式搭建核心玩法是在地图周围的岩石上布置炮塔抵御一波波数量与生命值不断增长的入侵士兵共需渡过11个波次方可获胜涉及炮塔放置、敌人寻路、波次调度与胜负判定等典型RTS与塔防机制。资源以zip压缩包形式提供整体约1.01GB内容以UE4工程文件、C源码及游戏资产为主可直接导入引擎运行与二次修改。作者在描述中特别提示了播放或启动时可能崩溃的排错思路即通过项目设置中的地图与模式切换地图后再重新选择正确地图并说明音频与敌人角色网格分别来自外部素材与Epic Games。目前已有647人学习适合想通过完整小项目理解UE4塔防架构、积累实战经验的读者参考。1. 士兵-塔-防御-UE4一套能跑起来的3D塔防最小闭环长什么样很多人第一次听到「用UE4做3D塔防」脑子里浮现的是塔防那套经典玩法——建塔、刷怪、升级、守家。但真正动手之后才发现难点根本不在玩法设计上而在「士兵」和「塔」这两个核心对象怎么在3D空间里被正确感知、正确攻击、正确结算伤害。2D塔防里一个格子就是一个坐标点敌人沿着路径走塔只需要判断距离就行。到了3D敌人有了碰撞体、有了导航网格、有了动画状态机塔的攻击判定要处理射线检测、弹道飞行时间、AOE范围重叠士兵还要在寻路和攻击之间来回切换状态。这套东西如果一开始架构没搭对后面加波次、加塔类型、加升级系统的时候就会像滚雪球一样越改越乱。这篇内容面向的是已经会用UE4基础操作、想做一个能实际跑起来的3D塔防原型的开发者。我会从项目结构怎么拆、士兵AI怎么用行为树驱动、塔的攻击逻辑怎么用Actor组件解耦、伤害结算怎么走GameplayEffect这条线一路讲到打包前必须检查的几个参数。中间会给出可以直接抄的C代码和蓝图配置思路也会把我在做这套东西时踩过的坑按「现象→原因→解决」的格式列出来。如果你正在用UE4做塔防或者想把一个半成品塔防项目整理成可扩展的架构下面的内容应该能帮你省掉不少返工时间。2. 士兵与塔的对象拆分从Actor继承树到组件化攻击逻辑2.1 为什么不用一个Pawn搞定所有单位新手最容易犯的错误是把士兵和塔都做成继承自同一个基类的Actor然后在基类里塞一堆bool变量来区分「这是塔还是士兵」。这种做法在只有两种单位的时候看起来没问题但当你加入飞行敌人、加入可移动的塔、加入士兵的巡逻和驻守两种模式之后基类会膨胀到没人敢改。我一般会按「移动能力」和「攻击能力」两个维度来拆士兵继承自ACharacter因为它需要CharacterMovement组件来处理导航寻路和碰撞塔继承自AActor因为它不需要移动只需要一个静态网格体和一个攻击组件。攻击逻辑不放在单位类里而是做成UActorComponent。这样塔可以挂一个UProjectileAttackComponent士兵可以挂一个UMeleeAttackComponent两者共享同一个伤害结算接口。后面如果要加一个「能发射弹道也能近战」的混合单位只需要同时挂两个组件就行不需要改继承树。// AttackComponent.h UCLASS(Abstract, Blueprintable, meta (BlueprintSpawnableComponent)) class TOWERDEFENSE_API UAttackComponent : public UActorComponent { GENERATED_BODY() public: UAttackComponent(); // 攻击范围塔和士兵共用这个参数 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category Attack) float AttackRange 800.f; // 攻击间隔单位秒 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category Attack) float AttackCooldown 1.2f; // 单次伤害值 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category Attack) float DamagePerHit 25.f; // 由子类实现具体的攻击方式 virtual void ExecuteAttack(AActor* Target); protected: virtual void BeginPlay() override; // 冷却计时器句柄 FTimerHandle CooldownTimerHandle; // 当前锁定的目标 TWeakObjectPtrAActor CurrentTarget; // 检查目标是否还在范围内 bool IsTargetValid() const; };这段代码的关键点在于AttackRange、AttackCooldown、DamagePerHit这三个参数全部暴露给蓝图策划可以直接在编辑器里调。ExecuteAttack声明为virtual子类ProjectileAttackComponent里用SpawnActor生成弹道ActorMeleeAttackComponent里直接调用目标身上的UHealthComponent扣血。CurrentTarget用TWeakObjectPtr而不是裸指针避免目标被销毁后组件还持有野指针导致崩溃。2.2 士兵的行为树怎么和攻击组件配合士兵的AI用行为树驱动但行为树不应该直接管攻击冷却。常见做法是行为树里放一个「攻击」任务节点这个节点在Tick里检查UAttackComponent的冷却是否就绪就绪就调用ExecuteAttack没就绪就返回Running让行为树下一帧继续。这样攻击节奏由组件控制行为树只负责决策「打谁」和「什么时候追」。行为树的任务节点用C写比用蓝图写更稳因为蓝图任务节点在频繁Tick的时候性能开销明显。下面是一个简化版的BTTask_AttackTarget// BTTask_AttackTarget.cpp EBTNodeResult::Type UBTTask_AttackTarget::ExecuteTask(UBehaviorTreeComponent OwnerComp, uint8* NodeMemory) { AAIController* AIController OwnerComp.GetAIOwner(); if (!AIController) return EBTNodeResult::Failed; APawn* Pawn AIController-GetPawn(); if (!Pawn) return EBTNodeResult::Failed; UAttackComponent* AttackComp Pawn-FindComponentByClassUAttackComponent(); if (!AttackComp) return EBTNodeResult::Failed; // 从黑板获取当前目标 UBlackboardComponent* Blackboard OwnerComp.GetBlackboardComponent(); AActor* Target CastAActor(Blackboard-GetValueAsObject(TEXT(TargetActor))); if (!Target) return EBTNodeResult::Failed; // 检查目标是否在攻击范围内 float Distance FVector::Dist(Pawn-GetActorLocation(), Target-GetActorLocation()); if (Distance AttackComp-AttackRange) { return EBTNodeResult::Failed; // 超出范围让行为树重新选择追击 } // 执行攻击内部会检查冷却 AttackComp-ExecuteAttack(Target); return EBTNodeResult::Succeeded; }黑板里的TargetActor由前面的「寻找最近敌人」服务节点写入。服务节点每隔0.5秒扫描一次场景中所有带UHealthComponent的Actor按距离排序取最近的。这里有个性能注意点不要每帧扫描用SetTimer或者行为树的TickInterval来控制频率。我一般把扫描间隔设成0.3到0.5秒既不会让士兵反应迟钝也不会在几十个士兵同时运行时把CPU吃满。2.3 塔的弹道Actor和伤害结算塔的攻击如果做成瞬间命中视觉上会很奇怪尤其是3D视角下玩家能看到弹道飞行。所以塔一般用弹道Actor生成后朝目标飞行命中时调用目标的UHealthComponent扣血。弹道Actor的移动用ProjectileMovementComponent碰撞预设设成自定义的「Projectile」通道只和敌人碰撞不和地面、塔、玩家碰撞。伤害结算走UGameplayEffect是UE4里比较规范的做法但如果你不想引入GASGameplayAbilitySystem的复杂度直接在UHealthComponent里写一个ApplyDamage函数也够用。我倾向于在原型阶段用简单方案等数值策划要配复杂buff的时候再迁移到GAS。UHealthComponent的核心逻辑// HealthComponent.cpp void UHealthComponent::ApplyDamage(float DamageAmount, AActor* DamageCauser) { if (bIsDead) return; CurrentHealth FMath::Clamp(CurrentHealth - DamageAmount, 0.f, MaxHealth); // 广播血量变化UI和血条监听这个事件 OnHealthChanged.Broadcast(CurrentHealth, MaxHealth); if (CurrentHealth 0.f) { bIsDead true; OnDeath.Broadcast(DamageCauser); // 通知GameMode敌人死亡用于波次结算 if (ATowerDefenseGameMode* GM GetWorld()-GetAuthGameModeATowerDefenseGameMode()) { GM-OnEnemyKilled(GetOwner()); } } }OnDeath广播让死亡表现动画、粒子、掉落和逻辑解耦GameMode监听这个事件来扣减当前波次的剩余敌人数。注意GetOwner()返回的是挂载这个组件的Actor也就是敌人本身传给GameMode用于后续的击杀奖励计算。3. 波次系统与导航网格让敌人沿着路径走到终点3.1 用SplineComponent定义敌人路径3D塔防的敌人路径如果只用导航网格让AI自己寻路会出现敌人走最短路径而不是你设计的路径导致塔的摆放位置失去意义。常见做法是用SplineComponent画一条路径敌人沿着Spline的点移动同时用导航网格处理敌人之间的避让。在关卡里放一个APathActor上面挂一个USplineComponent把路径点拖成你想要的形状。敌人Spawn的时候获取这条Spline的引用然后每帧用GetLocationAtDistanceAlongSpline来更新位置。敌人的移动速度由Spline的移动速率控制而不是由CharacterMovement的MaxWalkSpeed控制这样能保证所有敌人严格按路径走。// EnemyMovementComponent.cpp 中的路径跟随逻辑 void UEnemyMovementComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) { Super::TickComponent(DeltaTime, TickType, ThisTickFunction); if (!PathSpline || bReachedEnd) return; // 沿路径前进的距离 DistanceAlongSpline MoveSpeed * DeltaTime; // 获取路径上的位置和切线方向 FVector NewLocation PathSpline-GetLocationAtDistanceAlongSpline(DistanceAlongSpline, ESplineCoordinateSpace::World); FVector Direction PathSpline-GetDirectionAtDistanceAlongSpline(DistanceAlongSpline, ESplineCoordinateSpace::World); // 更新Actor位置和朝向 GetOwner()-SetActorLocation(NewLocation); GetOwner()-SetActorRotation(Direction.Rotation()); // 到达终点 if (DistanceAlongSpline PathSpline-GetSplineLength()) { bReachedEnd true; OnReachedEnd.Broadcast(); } }MoveSpeed这个参数我一般设成300到500之间具体看路径长度和塔的攻击范围。如果敌人走太快塔来不及输出走太慢波次节奏拖沓。一个经验值是路径总长度除以敌人移动速度得到的秒数应该略大于「玩家建造第一座塔并开始输出」所需的时间。3.2 导航网格和Spline的冲突怎么处理敌人沿Spline移动的时候如果同时启用了CharacterMovement的导航寻路会出现两个力互相拉扯敌人走起来一抖一抖的。解决办法是在敌人沿Spline移动期间把CharacterMovement的MovementMode设成MOVE_None完全由Spline驱动位置。但这样敌人之间就没有避让了会重叠在一起。要解决避让可以在Spline驱动的基础上加一个简单的分离力每帧检测周围一定半径内的其他敌人如果距离小于阈值就施加一个反向的偏移。这个偏移量不要太大否则敌人会被推出路径。我一般把分离半径设成80到120偏移力设成移动速度的0.3倍左右。// 在TickComponent里追加分离逻辑 TArrayFOverlapResult Overlaps; FCollisionShape Sphere FCollisionShape::MakeSphere(SeparationRadius); GetWorld()-OverlapMultiByChannel(Overlaps, GetOwner()-GetActorLocation(), FQuat::Identity, ECC_Pawn, Sphere); FVector SeparationForce FVector::ZeroVector; for (const FOverlapResult Overlap : Overlaps) { AActor* Other Overlap.GetActor(); if (Other GetOwner()) continue; FVector Away GetOwner()-GetActorLocation() - Other-GetActorLocation(); Away.Z 0.f; // 只在水平面分离 float Dist Away.Size(); if (Dist 0.f Dist SeparationRadius) { SeparationForce Away.GetSafeNormal() * (1.f - Dist / SeparationRadius); } } // 把分离力叠加到最终位置上 FVector FinalLocation NewLocation SeparationForce * SeparationStrength * DeltaTime; GetOwner()-SetActorLocation(FinalLocation);SeparationRadius和SeparationStrength这两个参数需要根据敌人碰撞体大小来调。如果敌人碰撞体半径是40分离半径设80到100比较合适。分离强度太大会让敌人像弹球一样乱跳太小又起不到避让效果建议从0.2开始试。3.3 波次配置表怎么设计波次数据不要硬编码在C里用DataTable配。每一行代表一波字段包括波次编号、敌人类型、敌人数量、生成间隔、波次间隔、是否Boss波。GameMode在BeginPlay的时候读取DataTable按顺序生成波次。字段名类型说明WaveIndexint32波次序号从1开始EnemyClassTSoftClassPtr敌人蓝图类引用软引用避免加载所有敌人Countint32本波敌人总数SpawnIntervalfloat每个敌人之间的生成间隔单位秒WaveIntervalfloat本波结束后到下一波开始的等待时间IsBossWavebool是否Boss波用于UI特殊提示EnemyClass用TSoftClassPtr而不是TSubclassOf因为TSubclassOf会在DataTable加载时就把所有敌人蓝图加载进内存。用软引用的话只有真正要生成这个敌人时才加载对内存友好。生成的时候用LoadSynchronous或者异步加载都可以原型阶段用LoadSynchronous简单直接。波次生成的核心逻辑放在GameMode里用Timer驱动。每波开始时先检查当前存活敌人数如果还有上一波的敌人没死完可以有两种策略一种是等清完再刷下一波另一种是直接刷。我一般用「等清完再刷」因为塔防的节奏需要给玩家喘息时间。但Boss波可以例外Boss波允许和下一波重叠增加压力。4. 避坑与排查士兵不动、塔不攻击、打包后路径丢失4.1 士兵生成后站在原地不移动现象敌人在Spawn点生成后动画在播但位置不动行为树显示Running但看不到位移。原因最常见的是导航网格没有覆盖Spawn点和路径区域。UE4的AIController在MoveTo的时候如果目标点不在NavMesh上会直接返回Failed但行为树可能还在跑其他分支看起来就像卡住了。另一个原因是敌人的CharacterMovement组件被设成了MOVE_None而Spline驱动逻辑没有正确初始化PathSpline引用。解决在编辑器里按P键显示导航网格确认绿色区域覆盖了Spawn点和整条路径。如果Spawn点在NavMesh外把RecastNavMesh的AgentRadius调小或者把Spawn点往NavMesh内移。PathSpline引用在Enemy的BeginPlay里通过FindActorByClass或者Tag查找确保关卡里确实放了PathActor并且Tag匹配。4.2 塔的攻击范围明明够却打不到现象敌人在塔的攻击范围内塔的炮管也转向了敌人但就是不发射弹道。原因攻击组件里的IsTargetValid函数用了错误的距离判断。常见错误是用GetActorLocation比较时没有忽略Z轴而塔的炮口位置和敌人脚底位置在Z轴上差了好几百导致实际3D距离超出AttackRange。另一个原因是弹道Actor的碰撞通道设错了Spawn出来就撞到塔自己的碰撞体然后销毁了。解决距离判断用FVector::Dist2D而不是FVector::Dist忽略Z轴差异。弹道Actor的碰撞预设里把Pawn通道设成Ignore把自定义的Projectile通道设成Overlap或Block敌人。如果弹道总是撞到塔自己检查SpawnTransform的起始位置是不是在塔的碰撞体内部把生成点往前偏移50到100单位。4.3 打包后敌人不沿路径走编辑器里正常现象在编辑器PIE模式下一切正常打包成Windows可执行文件后敌人全部走直线Spline路径完全没生效。原因PathActor在关卡里是通过Editor Only的引用被敌人找到的打包后这些引用被Cook掉了。或者PathSpline的引用在敌人蓝图里是硬引用但PathActor没有被任何打包设置包含进去。解决把PathActor的引用改成通过GameMode在运行时动态查找而不是在蓝图里直接拖引用。GameMode在BeginPlay时用UGameplayStatics::GetActorOfClass找到PathActor然后通过接口或者事件把Spline引用传给所有生成的敌人。另外在Project Settings的Packaging里把包含PathActor的关卡显式加到「Additional Levels to Cook」列表里。4.4 波次DataTable在打包后读不到现象编辑器里波次正常刷打包后第一波都不出日志报「Failed to load DataTable」。原因DataTable资产没有被正确Cook进打包文件。如果DataTable是通过软引用路径在代码里加载的而该路径没有出现在任何硬引用链里Cooker会认为这个资产不需要打包。解决在Project Settings的Packaging里把DataTable的路径加到「Additional Asset Directories to Cook」列表。或者更稳妥的做法是在GameMode里用一个UPROPERTY(EditDefaultsOnly)的TSoftObjectPtr指向DataTable这样编辑器里设置的引用会被Cooker识别为硬引用确保打包进去。4.5 敌人死亡后碰撞体还在后续敌人被挡住现象敌人血量归零动画播放死亡但后续敌人走到这个位置会被挡住像是撞到了空气墙。原因UHealthComponent的OnDeath广播后Actor被Destroy了但Destroy有延迟在当前帧结束前碰撞体仍然存在。如果死亡动画期间碰撞体没有禁用后续敌人就会撞上。解决在OnDeath广播之前先把敌人的碰撞体设成NoCollision或者把CollisionProfile改成「Ragdoll」这种不阻挡Pawn的预设。如果用了Ragdoll物理确保死亡后0.5到1秒内把Actor销毁不要让它一直躺在地上。5. 从原型到可玩用重定向和Linux打包做快速验证5.1 用重定向快速替换士兵和塔的模型原型阶段用的模型和最终美术资源往往不是同一套骨骼。UE4的IK Retargeting可以把一套骨骼的动画重定向到另一套骨骼上但前提是两套骨骼的层级结构要大致对应。我一般会先建一个「重定向姿势」资产把源骨骼和目标骨骼的关键点对齐——主要是骨盆、脊柱、左右脚、左右手这几个位置。操作路径是在Content Browser里右键选择「Animation IK Retargeter」指定源IK Rig和目标IK Rig然后在Retarget Pose面板里调整目标骨骼的姿势让它的关键点位置和源骨骼匹配。匹配好之后把源动画的Retarget Chain映射到目标骨骼的对应链上导出重定向后的动画序列。这里有个血泪经验如果重定向后动画出现「模型断开」——也就是某些骨骼飞到很远的地方——大概率是某个骨骼的Retarget Chain没有正确映射或者目标骨骼的层级里多了一个源骨骼没有的中间骨骼。解决办法是在IK Rig里检查骨骼树把多余的中间骨骼的Retarget Chain设成None或者把它合并到父级链里。5.2 用Linux打包做服务器端快速验证如果你需要验证多人同步下的塔防逻辑用Linux打包做Dedicated Server是最省事的。UE4支持交叉编译在Windows上就能打出Linux Server的可执行文件。在Project Settings的Platforms Linux里把「Enable Cross-Compiling」打开然后安装Linux Toolchain。打包命令用RunUAT.bat# 在UE4安装目录的Engine/Build/BatchFiles下执行 RunUAT.bat BuildCookRun -projectD:/Projects/TowerDefense/TowerDefense.uproject -noP4 -platformLinux -clientconfigDevelopment -serverconfigDevelopment -cook -server -serverplatformLinux -noclient -build -stage -pak -archive -archivedirectoryD:/Builds/TowerDefenseServer参数说明-server表示只打包服务器-noclient表示不打包客户端-serverplatformLinux指定服务器平台。打包出来的文件在D:/Builds/TowerDefenseServer/LinuxServer目录下是一个可以直接运行的二进制文件。把它扔到Linux机器上用chmod x赋予执行权限然后./TowerDefenseServer就能启动。验证的时候客户端连上服务器观察塔的攻击是否在所有客户端上表现一致。常见问题是塔的攻击在服务器上结算了但客户端没有播放弹道特效这是因为弹道Actor没有设置bReplicates。把弹道Actor的bReplicates设成truebAlwaysRelevant设成true然后在OnRep函数里触发特效播放。5.3 一个容易被忽略的参数NetUpdateFrequency塔防游戏里敌人数量多的时候网络同步会成为瓶颈。默认的NetUpdateFrequency是100意味着每个Actor每秒同步100次。对于沿Spline移动的敌人来说位置变化是连续的不需要这么高的频率。把敌人的NetUpdateFrequency降到10到20同时把NetPriority调低可以大幅减少带宽占用。但塔的攻击频率不能降因为攻击是离散事件漏一次同步就会导致客户端看到敌人没掉血。塔的NetUpdateFrequency保持默认或者更高但塔的数量通常比敌人少所以总体带宽还是可控的。我一般会在敌人的BeginPlay里根据当前波次的敌人数量动态调整NetUpdateFrequency敌人少于10个时用2010到30个时用10超过30个时用5。这个逻辑放在GameMode里统一管理避免每个敌人自己算。5.4 最后说一个我踩过的坑做这套塔防的时候我一开始把塔的攻击逻辑写在了Tick里每帧检测范围内所有敌人。结果塔一多帧率直接从120掉到40。后来改成用事件驱动敌人进入塔的范围时触发OnOverlapBegin把敌人加入塔的目标列表敌人离开时触发OnOverlapEnd从列表移除。塔只在攻击冷却就绪时才遍历目标列表而不是每帧遍历。这个改动让帧率回到了100以上。所以如果你也在做UE4塔防记住一个原则能用事件驱动就不要用Tick轮询。塔的攻击范围检测用碰撞体Overlap事件敌人的死亡用委托广播波次切换用Timer这样整个系统的性能会好很多。希望帮到你。本文还有配套的精品资源点击获取
返回列表