资讯详情

UE4塔防游戏C++核心实现:对象池、路径AI与数据驱动波次管理解析

📅 2026/10/15 23:20:58 | 华诺云谱 👁 阅读
UE4塔防游戏C++核心实现:对象池、路径AI与数据驱动波次管理解析
简介这款基于UE4.22引擎开发的3D塔防游戏工程是作者的个人学习项目面向想从零接触虚幻引擎与C游戏开发的学习者。玩法是在地图周围的岩石区域布置炮塔抵御连续11波士兵进攻每波敌人数量与生命值都会逐步提升玩家需综合决定建塔顺序和点位整体考验防守策略。工程文件以zip压缩包形式发布大小约1.01GB其中包含项目场景、音频素材和来自Epic Games的敌人角色网格体等资源。说明文档中附带了启动崩溃的处理经验可先切换默认地图再重新选回能规避一些不稳定的编辑环境问题。目前已有647人学习浏览对想理解塔防波次控制、炮塔交互、AI敌人生成及虚幻引擎C项目结构的人而言这是一份完整可直接运行的实例工程便于在此基础上进行二次修改与毕业设计参考。1. 拿到一个 UE4 塔防工程先看它的 C 骨架值不值得沾手做游戏开发的都知道塔防是最容易“看着简单、改着上火”的品类。最近拆了一份 UE4 塔防资源标题写的是“士兵-塔-防御-UE4”用 C 实现的 3D 塔防游戏工程里包含完整的敌人 AI、防御塔攻击逻辑、波次刷怪、以及一套基于 Unreal Engine 4 的 RTS 式操作框架。我第一反应是这玩意儿八成是教学 Demo打开工程扫了一圈才发现它把塔防该有的模块都做成了可扩展的 C 类不是把逻辑全焊死在蓝图里。对想学 UE4 C 塔防实现的开发者或者打算拿塔防做毕业设计、游戏 Demo 的人来说这份资源能省掉从零搭框架的时间。它适合两类人一类是刚啃完 UE4 C 基础、想在具体玩法里把 Pawn、AIController、碰撞、对象池这些概念串起来的新手另一类是手里有玩法想法、但不想从建工程开始写塔防骨架的独立开发者。下文我按自己拆项目时的顺序把类结构、生成机制、AI 与波次配置、高频踩坑依次过一遍。2. 塔防核心机制的实现拆解C 类结构、攻击逻辑与对象池2.1 从这五个核心类看懂工程的架构思路任何 UE4 塔防项目都逃不开几个固定角色防御塔、敌人、炮弹/投射物、玩家控制器、以及刷怪波次的管理器。这份资源里对应的 C 类分别是基于 ACharacter 的敌人基类、基于 AActor 的防御塔基类、投射物类、PlayerController 和波次管理器。我拆工程的习惯是先看 .h 文件里的 UCLASS 声明和 UFUNCTION 标记因为这些直接决定哪些逻辑暴露给蓝图、哪些是纯 C 内部实现。敌人基类的核心设计值得先说。它的移动不是简单用 CharacterMovement 的直走而是在 BeginPlay 里从全局寻路系统拿到路径点数组用 Spline 或者逐个 PathPoint 的方式推进。我实际看到的写法是每个敌人持有一个路径点索引Tick 里朝目标点移动距离小于阈值就切换下一个点。这套设计的优点是不依赖 NavMesh 也能跑缺点是路径点一变所有敌人的索引都要重新计算所以工程里把路径管理单独抽了一个类专门负责广播路径变化。防御塔基类则是典型的“检测-索敌-攻击”三段式。每座塔在 Tick 里遍历攻击范围内的 Actor用 GetOverlappingActors 或者手动 SphereOverlap筛选出存活的敌人然后旋转炮管、生成投射物。这种写在 C 里的方式比纯蓝图性能好不少但也要求你理解 Actor 的 Tick 频率和碰撞通道。资源里提供了一个距离排序函数索敌时会优先攻击离终点最近的敌人这个逻辑很实用能避免塔“谁近打谁”导致漏怪的问题。2.2 投射物与伤害计算的 C 实现别把伤害写死在碰撞事件里很多新手做塔防投射物喜欢在 OnActorHit 里直接写 ApplyDamage但这会导致伤害类型、护甲减伤这些后续扩展很难加。这份资源里的投射物类做了一个简洁的分层投射物只负责飞行、检测碰撞、然后把自己的伤害值和伤害类型传给目标的接收函数。敌人基类里有一个虚函数 CalculateDamage子类可以重写它来加入护甲、抗性、暴击等逻辑。我一般会建议把伤害计算独立成函数而不是直接调 ApplyDamage就是考虑到后期加各种 buff 时不需要翻投射物的代码。投射物的飞行用的是 ProjectileMovement 组件初始速度、最大速度都在构造函数里用 UPROPERTY 暴露到蓝图。资源里的默认值分别是 2000 和 2500这两个值在 3D 塔防地图里手感比较合适。有一点要注意ProjectileMovement 的初始速度方向默认是 Actor 的前向所以生成投射物时务必先调整塔的朝向否则炮弹会横着飞。常见做法是用塔的 GetActorForwardVector 给 ProjectileMovement 赋值。下面给出投射物类的构造和初始化逻辑示例AProjectileBase::AProjectileBase() { PrimaryActorTick.bCanEverTick true; RootComp CreateDefaultSubobjectUSceneComponent(TEXT(Root)); RootComponent RootComp; MeshComp CreateDefaultSubobjectUStaticMeshComponent(TEXT(Mesh)); MeshComp-SetupAttachment(RootComp); MeshComp-SetCollisionProfileName(TEXT(Projectile)); MoveComp CreateDefaultSubobjectUProjectileMovementComponent(TEXT(ProjectileMove)); MoveComp-InitialSpeed 2000.f; MoveComp-MaxSpeed 2500.f; MoveComp-ProjectileGravityScale 0.f; MoveComp-bRotationFollowsVelocity true; } void AProjectileBase::InitProjectile(FVector Direction, float DamageValue, TSubclassOfUDamageType DmgType) { MoveComp-Velocity Direction * MoveComp-InitialSpeed; Damage DamageValue; DamageType DmgType; } void AProjectileBase::OnHit(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult Hit) { AEnemyBase* Enemy CastAEnemyBase(OtherActor); if (Enemy !Enemy-IsDead()) { Enemy-TakeDamageWithType(Damage, DamageType); } Destroy(); }逻辑说明构造函数里关掉重力让炮弹走直线这对塔防比抛物线更容易控制命中节奏InitProjectile 在生成时被塔调用设置方向和伤害OnHit 里只判断是否为敌人再把伤害转发给敌人的伤害接收函数。参数说明DamageValue 每座塔在蓝图里配置DamageType 是 UDamageType 子类用来区分物理、火焰等实战中建议至少做两种否则后期做抗性没地方挂。2.3 对象池敌人刷多了不掉帧的底气塔防的资源密集点不在塔而在敌人。后期一波动辄三四十个敌人同时在场如果每波都 SpawnActor 再 Destroy引擎的 GC 压力和碰撞重建会让帧率肉眼可见地掉。这份资源里的对象池设计不算花哨但很实用波次管理器在游戏开始时预生成一批敌人标记为 Inactive刷怪时只激活、重置属性敌人死亡或到达终点则 Deactivate 回池子。池子的核心是链表和索引栈。我用 TArray 存储所有实例再用一个 TArray 存当前空闲索引。Activate 时从空闲栈 Pop 一个Deactivate 时 Push 回去。这样避免线性查找敌人数量到一两百时依然能保持稳定。给一段池子的核心实现void AEnemyPool::InitializePool(int32 PoolSize, TSubclassOfAEnemyBase EnemyClass) { for (int32 i 0; i PoolSize; i) { AEnemyBase* Enemy GetWorld()-SpawnActorAEnemyBase(EnemyClass, FVector::ZeroVector, FRotator::ZeroRotator); Enemy-SetActorHiddenInGame(true); Enemy-SetActorEnableCollision(false); Enemy-SetActorTickEnabled(false); Enemy-PoolIndex i; AllEnemies.Add(Enemy); FreeIndices.Push(i); } } AEnemyBase* AEnemyPool::GetEnemy(FVector SpawnLocation) { if (FreeIndices.Num() 0) { ExpandPool(10); } int32 Index FreeIndices.Pop(); AEnemyBase* Enemy AllEnemies[Index]; Enemy-SetActorLocation(SpawnLocation); Enemy-SetActorHiddenInGame(false); Enemy-SetActorEnableCollision(true); Enemy-SetActorTickEnabled(true); Enemy-ResetEnemy(); return Enemy; }逻辑说明SetActorHiddenInGame 和 SetActorEnableCollision 双开关是重点只隐藏不关碰撞的话投射物会被“看不见的敌人”挡住这是新手最容易忽略的。ExpandPool 在池子不够时按 10 个一批扩容避免极端波次下刷怪失败。参数说明PoolSize 建议按单屏最大同时存活敌人数乘以 1.5 来配太大会浪费内存太小会中途扩容产生卡顿。3. 敌人 AI 与波次配置的落地方式从 AIController 到数据驱动刷怪3.1 敌人不用行为树路径点推进这套 AI 更适合塔防塔防的敌人说到底是“沿路走到终点”所以这里完全没有引入行为树和黑板而是用一个轻量的 AIController 配合路径点数组。AIController 的职责只有两个从路径管理器拿到路径点列表然后控制 Pawn 依次移动。相比 UE4 的 BehaviorTree这种方案的启动开销小逻辑直观而且不容易出现行为树节点执行顺序导致的“敌人原地发呆”问题。实现上AIController 在 Possess 后调用一次 MoveToNextPoint 函数用 MoveToActor 或者直接给 Pawn 的 CharacterMovement 设置目标。资源里用的是手动计算方向加 AddMovementInput理由是 MoveToActor 在路径点转弯处有转向延迟敌人多的时候看起来会“排队拐弯”影响节奏。手动方案加上一个转向插值能让敌人更贴路径。下面是敌人 AI 控制器的核心逻辑void AEnemyAIController::Tick(float DeltaTime) { Super::Tick(DeltaTime); if (!CurrentTargetPoint || bReachedEnd) { return; } AEnemyBase* Enemy CastAEnemyBase(GetPawn()); if (!Enemy) return; FVector EnemyLoc Enemy-GetActorLocation(); FVector TargetLoc CurrentTargetPoint-GetActorLocation(); float Distance FVector::Dist(EnemyLoc, TargetLoc); if (Distance ReachThreshold) { int32 NextIndex CurrentPathIndex 1; if (NextIndex PathPoints.Num()) { bReachedEnd true; Enemy-ReachEnd(); return; } SetNextPathPoint(NextIndex); } FVector MoveDir TargetLoc - EnemyLoc; MoveDir.Z 0.f; MoveDir.Normalize(); Enemy-AddMovementInput(MoveDir); }逻辑说明整个移动靠高度归零的方向向量驱动避免敌人飞行时往地下钻ReachThreshold 设为 60 时转弯平滑设为 10 时转弯生硬但贴路精准实战里根据地图形状调整。参数说明PathPoints 在 BeginPlay 时从路径管理器拷贝而来每次 SetNextPathPoint 会重新赋值 CurrentTargetPoint。3.2 波次配置的数据驱动用 DataAsset 管理刷怪表拆这份资源时最让我满意的部分是波次管理器的设计。它没有把波次数据 hardcode 在 C 里而是用一个继承自 UPrimaryDataAsset 的配置类里面定义了一个结构体数组。每个结构体包含敌人类型、数量、生成间隔、两次生成之间的延迟、以及这一波结束后的奖励金币数。这意味着调关卡难度只需要在内容浏览器里改数据资产不需要碰一行 C 或蓝图逻辑。波次管理器本身是个 Actor持有当前的波次索引和剩余敌人计数。每波开始前先读取 DataAsset 里的敌人类型并通过敌人工厂的 CreateEnemy 函数生成敌人。敌人死亡时通过回调通知管理器计数归零后自动启动下一波。这种数据驱动的好处是平衡性调整可以由策划或你自己直接改数据不用版本管理冲突。给出波次数据结构定义USTRUCT(BlueprintType) struct FWaveSpawnInfo { GENERATED_BODY() UPROPERTY(EditAnywhere, BlueprintReadOnly) TSubclassOfAEnemyBase EnemyClass; UPROPERTY(EditAnywhere, BlueprintReadOnly) int32 EnemyCount 10; UPROPERTY(EditAnywhere, BlueprintReadOnly) float SpawnInterval 1.5f; UPROPERTY(EditAnywhere, BlueprintReadOnly) float PreDelay 2.0f; UPROPERTY(EditAnywhere, BlueprintReadOnly) int32 RewardGold 50; }; UCLASS(BlueprintType) class UWaveConfig : public UPrimaryDataAsset { GENERATED_BODY() public: UPROPERTY(EditAnywhere, BlueprintReadOnly) TArrayFWaveSpawnInfo Waves; UPROPERTY(EditAnywhere, BlueprintReadOnly) int32 MaxWave 15; };逻辑说明EnemyClass 是 TSubclassOf 而不是硬编码类名资源里每种敌人单独建了一个蓝图层敌人外观和数值可以在蓝图里覆盖。PreDelay 的作用是给玩家看到“下一波敌人来了”的预警时间我一般建议至少给 2 秒。参数说明RewardGold 走玩家手里的全局变量塔的费用在防御塔蓝图里配置经济循环是否紧张完全看这两个数值的比值——这也是塔防里最花时间的调参环节。3.3 一波多类型敌人混刷从顺序循环到权重随机资源里默认的刷怪逻辑是“先刷完这一类再刷下一类”也就是顺序遍历 WaveSpawnInfo。这种逻辑简单、可预测但玩到后面会显得呆板因为敌人总是按固定顺序出场。我拆过不少塔防工程发现多数开发者最后都会改成权重随机某波次里配置多种敌人每种给一个权重值刷怪时按权重随机抽取。这个改动在编程上很小但游戏体验提升非常明显。AEnemyBase* AWaveManager::SpawnEnemyByWeight(const FWaveSpawnInfo WaveInfo) { int32 TotalWeight 0; for (const auto SpawnEntry : WaveInfo.SpawnEntries) { TotalWeight SpawnEntry.Weight; } int32 RandomValue FMath::RandRange(1, TotalWeight); int32 AccumulatedWeight 0; for (const auto SpawnEntry : WaveInfo.SpawnEntries) { AccumulatedWeight SpawnEntry.Weight; if (RandomValue AccumulatedWeight) { return CreateEnemy(SpawnEntry.EnemyClass); } } return nullptr; }逻辑说明先把所有权重累加再取一个 1 到总权重的随机数逐个减权重定位到具体类型。注意 FMath::RandRange 的第二个参数是闭区间这和其他语言的 rand 到 n-1 的惯例不同容易写错。参数说明SpawnEntries 是该波次里不同敌人的配置数组Weight 越大出现概率越高这个方案适合做“普通怪多、精英怪偶尔出”的混合波次。4. UE4 塔防实装避坑五个高频问题与排查思路4.1 敌人批量死亡时引擎崩溃原因在池子回收时碰撞没关现象某个波次敌人同时被多座塔集火然后引擎直接崩溃日志报碰撞相关错误。原因检查后发现问题不出在伤害计算而在对象池回收逻辑。我的池子 Deactivate 函数里只调用了 SetActorHiddenInGame忘了关碰撞。敌人虽然隐藏了但碰撞体还挂在世界中投射物仍然会命中它们而命中回调尝试访问已经被重置的敌人数据导致空指针。印象里大部分池子类崩溃都是这个原因。解决Deactivate 里把 SetActorEnableCollision(false) 和 SetActorTickEnabled(false) 都补上与激活时的状态严格对应两条缺一不可。另外还要在 OnHit 里加 IsDead 判断双保险。4.2 防御塔不攻击Tick 里检测的碰撞通道不对现象塔周围明明有敌人但塔就是不动放置之后完全是个装饰。原因塔的攻击范围检测用了 Overlap 事件但敌人的碰撞通道没有响应塔的查询通道。我在默认的 Pawn 碰撞通道上设置的是“Block”而塔的索敌查询只检测了“Overlap”两者对不上。解决把敌人的碰撞预设改成 CustomObjectType 设为 PawnResponse 里把 WorldDynamic 设为 Overlap同时塔的检测组件也要勾上对应的碰撞预设。排查步骤先在游戏里运行时按打开控制台输入show collision 可视化碰撞体确认敌人碰撞体是否存在、通道是否正确。4.3 敌人寻路卡在拐角Clearance 和目的地判定同时出问题现象敌人走到某些障碍物的拐角处会来回震荡像在原地打转偶尔能绕过去大部分时候卡死。原因我用的手动方向推进没有处理“到达障碍物时的绕行”同时 ReachThreshold 太小敌人已经贴到障碍物边缘但始终离目标点差那么一点于是两个逻辑互相拉扯。解决两个方向一起修。一是把 ReachThreshold 从 20 调到 80给转弯留出容错空间二是在关卡里检查路径点是不是放在了障碍物内部最常见的原因是路径点贴地面太近被地面的碰撞盒挡了一部分把路径点 Z 轴抬高 30 到 50 就能解决。4.4 蓝图子类覆盖 C 函数不生效缺了 BlueprintNativeEvent 或 BlueprintCallable现象我在 C 里写的敌人死亡函数在蓝图里重写之后完全不执行C 版的逻辑照样跑。原因C 函数没有标记 BlueprintNativeEvent。在 UE4 里只有标记了 BlueprintNativeEvent 或 BlueprintImplementableEvent 的函数才能被蓝图覆写普通 UFUNCTION 即使暴露出来蓝图也只能调用不能重写。解决把死亡函数声明改成 BlueprintNativeEvent在 C 里实现为函数名加 _Implementation 后缀。注意别在蓝图里直接盖掉 C 逻辑正确姿势是蓝图侧调用 Parent 节点后再写自己的逻辑。4.5 编译后蓝图变量丢失UPROPERTY 没加 EditAnywhere 只加了 BlueprintReadOnly现象资源里的防御塔蓝图升级 C 后所有配置好的变量值全部变回默认得重新填一遍。原因UPROPERTY 只写了 BlueprintReadOnly没有 EditAnywhere蓝图里能读不能改所以序列化时不会保存改动。这个在所有 UE4 工程里都会发生动不动上百个属性要重新配置挺折磨人。解决凡是需要在蓝图实例里配置的数值属性必须写UPROPERTY(EditAnywhere, BlueprintReadOnly)。只读的计算属性才用 BlueprintReadOnly 配 VisibleAnywhere。我后来形成习惯写完类第一件事就是检查所有 UPROPERTY 的编辑标记避免升级后丢配置。5. 用日志与数据驱动验证塔防数值从“能刷怪”到“能通关”5.1 用 UE4 的 Log 系统做击杀记录与伤害复盘除非做完整的单测框架否则塔防数值平衡的验证基本都是靠打日志。UE4 自带的 UE_LOG 配合 LogTemp 分类可以把每次击杀的时间、敌人类型、造成伤害记录下来。我拆完这份资源后建议的做法是在 WaveManager 的每个敌人死亡回调里输出一条带波次号、敌人类型、当前时间的日志打几局之后把日志导出来跑一遍文本统计看每一波的平均存活时间是否正常增长。UE_LOG(LogTemp, Warning, TEXT([Wave %d] Enemy killed at time %.2f, Type: %s, TotalKilled: %d), CurrentWaveIndex, GetWorld()-GetTimeSeconds(), *Enemy-GetEnemyTypeName(), TotalKilledCount);逻辑说明多打一条 Wave 信息能快速看出某一波是不是难度跳跃太大。参数说明GetEnemyTypeName 是敌人基类里返回 FString 的虚函数射击手、重甲兵等子类各自覆写即可。如果日志里某波的击杀时间比上一波翻了三倍基本就是数值出了问题不用等到玩家来骂。5.2 数值动态调整的验证路径改数据资产不比改代码更轻松数据驱动的好处是改东西快但调完我没法立刻知道是好是坏必须反复跑同一波然后盯着伤害数字和时间点判断。我一般的节奏是单波测试关只放一座塔、一种敌人且塔的升级链路开到最大看这座塔的击杀时间曲线。如果后几级曲线突然平了说明攻击力上升跟不上敌人血量增长要么塔的成长系数太高要么敌人血量曲线太陡。我用到的另一个验证技巧是“双倍时间录像法”在同一地图同一波次配置下分别记录一个高配置玩家的通关时间和一个最低配置玩家的通关时间两者差距控制在两倍以内说明难度梯度可以接受。这份资源里还额外给了世界总金币量和塔的总消耗额这两项数据可以拿来做收支曲线图曲线上升说明玩家有钱攒塔下降说明数值偏紧。5.3 进阶扩展把技能系统挂到现有塔基类上如果要把这份资源继续往上走最顺手的扩展点是防御塔基类。它已经分离了“检测-索敌-攻击”三个阶段所以加技能系统时不需要重写整座塔只要在攻击阶段之前插入一次技能检测。比如给塔加一个减速光环做法是新建一个技能组件在 Tick 里对攻击范围内的敌人施加减速 buffer再把这个组件挂到塔的蓝图上。void UTowerSkillComponent::ApplySlowToEnemies(float Range, float SlowFactor) { TArrayAActor* OverlappedActors; GetOwner()-GetOverlappingActors(OverlappedActors, AEnemyBase::StaticClass()); for (AActor* Actor : OverlappedActors) { AEnemyBase* Enemy CastAEnemyBase(Actor); if (Enemy) { Enemy-ApplySpeedModifier(SlowFactor, 2.0f); } } }逻辑说明技能组件完全复用塔已有的 Overlap 范围检测不需要额外写范围判断敌人身上的 ApplySpeedModifier 负责管理减速的持续时间与叠加规则。参数说明SlowFactor 取 0.5 表示移速减半2.0 是持续时间秒数。这个组件独立于塔的攻击逻辑放在任何一类的防御塔蓝图里都能工作扩展成本很低。我后来在另一个项目里复用这套骨架最大的收获是明白了塔防工程真正难的不是“在路口放一座塔然后开火”而是“敌人池稳定、波次可配置、数值可调整”这三件事同时做好。从那以后我每次接手 UE4 游戏资源都会先花一个晚上摸清类结构和数据资产的关系再动手改任何逻辑。这份资源给了你一个可靠的起点剩下的就是往上面叠你自己的玩法。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑