UE4塔防工程实战:C++与蓝图分工及核心模块拆解
简介:这是一份基于Unreal Engine 4.22开发的3D塔防游戏完整工程,面向希望学习UE4游戏开发、C++脚本编写与塔防玩法实现的学生和独立开发者。项目以个人学习作品的形式呈现,核心玩法是在地图岩石上布置炮塔抵御一波波入侵士兵,敌人每波数量与生命值递增,玩家需撑过11波攻势且不丢失塔防阵地。工程涵盖炮塔放置、敌人AI寻路、波次调度与胜负判定等模块,并附有地图切换的崩溃规避说明,便于读者排查类似引擎异常。压缩包约1.01GB,上游未提供文件总数与类型明细,从工程体量看应包含UE4项目源码、地图与美术资源、音频及敌人角色网格等资产。目前已有649人学习下载,适合作为塔防玩法原型参考与UE4 C++实践案例,帮助读者理解关卡设计、波次系统与资源组织方式。
1. 从一份 UE4 塔防工程说起:它到底能跑出什么
第一次打开这个「士兵-塔-防御-UE4」工程时,我原本以为只是个摆几座塔、刷几波怪的教学 Demo,结果翻到Source目录才发现,它把塔防最核心的三件事——路径推进、波次调度、塔的索敌与开火——都拆成了独立的 C++ 类,蓝图只负责表现层。换句话说,这不是一个只能看不能改的展示品,而是一个能让你把「塔防」这套玩法逻辑真正吃透的骨架工程。它适合两类人:一类是刚接触 Unreal Engine 4、想找一个完整 3D 项目照着跑通的新手;另一类是已经会摆蓝图、但想搞清楚 C++ 与蓝图如何分工的熟手。下面我就按「这项目是什么 → 怎么跑起来 → 核心代码怎么读 → 怎么改」的顺序,把它拆开讲。
2. 工程结构与技术选型:C++ 打底、蓝图收口的 3D 塔防骨架
2.1 为什么是 UE4 + C++ 而不是纯蓝图
塔防这类玩法的特点是「规则稳定、表现多变」。敌人沿固定路径走、塔按冷却时间索敌开火、波次按配置表刷新,这些是规则,用 C++ 写一次就能复用;而塔的模型、开火特效、UI 血条这些是表现,用蓝图改起来最快。这个工程正是按这条线切的:Source下是玩法逻辑,Content下是蓝图子类和美术资源。纯蓝图做塔防,前期爽,等到敌人数量上百、每帧要做索敌距离判断时,蓝图节点的开销就会变成玄学般的卡顿;纯 C++ 又会让美术和策划没法快速调表现。所以「C++ 打底、蓝图收口」是这个场景下最稳妥的选型,也是我一般会推荐给二次开发者的结构。
工程的核心模块大致可以按下面这张表来理解,实际目录名可能略有出入,但职责划分是清晰的:
| 模块 | 典型位置 | 职责 |
|---|---|---|
| 游戏模式 | Source/*/GameMode* | 管理波次、胜负判定、玩家状态 |
| 敌人单位 | Source/*/Enemy* | 沿路径移动、血量、到达终点扣血 |
| 防御塔 | Source/*/Tower* | 索敌、冷却、生成弹丸或直接结算伤害 |
| 路径系统 | Source/*/Path*或样条组件 | 提供路径点序列给敌人查询 |
| 弹丸/伤害 | Source/*/Projectile* | 命中判定与伤害结算 |
| 蓝图表现 | Content/Blueprints/* | 塔与敌人的可视化子类、UI、特效 |
2.2 目录结构与构建文件怎么读
拿到工程先别急着点运行,先看根目录。一个标准的 UE4 C++ 工程会有<项目名>.uproject、Source/、Content/、Config/这几块。.uproject里会声明引擎版本和模块列表,Source/<项目名>.Target.cs与Source/<项目名>Editor.Target.cs决定编译目标,Source/<项目名>/<项目名>.Build.cs决定依赖哪些模块。塔防工程通常只需要默认模块,但如果用到了样条路径或 AI 导航,Build.cs里会多出AIModule、NavigationSystem之类的依赖。
// Source/SoldierTowerDefense.Target.cs 典型结构 using UnrealBuildTool; using System.Collections.Generic; public class SoldierTowerDefenseTarget : TargetRules { public SoldierTowerDefenseTarget(TargetInfo Target) : base(Target) { Type = TargetType.Game; // 打包运行时用的目标 ExtraModuleNames.Add("SoldierTowerDefense"); } }这段是运行时目标的构建脚本,Type = TargetType.Game表示这是给最终游戏用的;编辑器目标则会把Type设为Editor。ExtraModuleNames里列出的模块名必须和Source下的模块目录名一致,否则编译时会报找不到模块。很多人第一次编译失败就是这里名字对不上,改的时候要连带.uproject里的Modules段一起核对。
2.3 路径与波次:塔防的两条主线
塔防的「骨架」其实就是两条线:敌人怎么走、敌人什么时候来。路径一般用USplineComponent挂在关卡里的一个 Actor 上,敌人启动时拿到样条,按距离沿曲线采样位置;波次则由 GameMode 维护一个波次数组,每波包含敌人类型、数量、间隔。这两块逻辑放在 C++ 里,好处是数值可以走配置、表现可以走蓝图。下面这段是敌人沿样条推进的典型写法:
// 敌人每帧沿样条推进 void AEnemyBase::Tick(float DeltaSeconds) { Super::Tick(DeltaSeconds); if (!PathSpline) return; // 按移动速度累加已走过的距离 DistanceAlongSpline += MoveSpeed * DeltaSeconds; // 超过样条总长说明到达终点 const float SplineLength = PathSpline->GetSplineLength(); if (DistanceAlongSpline >= SplineLength) { OnReachGoal(); // 通知 GameMode 扣玩家血量 Destroy(); return; } // 采样当前位置与朝向 const FVector NewLocation = PathSpline->GetLocationAtDistanceAlongSpline(DistanceAlongSpline, ESplineCoordinateSpace::World); const FRotator NewRotation = PathSpline->GetRotationAtDistanceAlongSpline(DistanceAlongSpline, ESplineCoordinateSpace::World); SetActorLocationAndRotation(NewLocation, NewRotation); }DistanceAlongSpline是「已走距离」,用速度乘 DeltaSeconds 累加,这样帧率高低都不影响实际移动速度;GetLocationAtDistanceAlongSpline按世界坐标采样,保证敌人在关卡里位置正确;OnReachGoal是留给 GameMode 的钩子,扣血、判负都在那边处理。这里有个细节:样条长度每帧都取一次其实可以缓存,敌人多的时候能省一点开销,属于后期优化项。
3. 把工程跑起来:从编译到第一波敌人出现
3.1 环境准备与首次编译
跑这个工程的前提是装好对应大版本的 Unreal Engine 4(工程.uproject里会写明版本,右键「Switch Unreal Engine version」可以切换)。装好之后,右键.uproject选「Generate Visual Studio project files」,生成.sln,再用 Visual Studio 打开编译Development Editor配置。第一次编译时间会比较长,因为要编译整个模块。
# 命令行方式生成工程文件并编译(Windows 示例) # 路径按自己机器上的引擎安装位置调整 "C:\Program Files\Epic Games\UE_4.27\Engine\Build\BatchFiles\GenerateProjectFiles.bat" ^ -project="D:\Projects\SoldierTowerDefense\SoldierTowerDefense.uproject" -game -rocket -progress # 编译 Editor 目标 "C:\Program Files\Epic Games\UE_4.27\Engine\Build\BatchFiles\Build.bat" ^ SoldierTowerDefenseEditor Win64 Development ^ -project="D:\Projects\SoldierTowerDefense\SoldierTowerDefense.uproject" -waitmutexGenerateProjectFiles.bat负责把.uproject翻译成 IDE 能认的工程;Build.bat的四个参数分别是目标名、平台、配置和工程路径,-waitmutex是防止多个编译进程打架。编译报错时先看输出里第一个 error,后面的往往是连锁反应。常见的是引擎版本不匹配或缺少模块依赖,按提示补Build.cs即可。
3.2 关卡、GameMode 与波次配置
编译通过后打开编辑器,先确认当前关卡的 World Settings 里 GameMode 指向的是工程自带的那个。塔防的波次数据一般有两种放法:一种是写在 GameMode 的TArray<FWaveConfig>里,在蓝图子类里填;另一种是读 DataTable。这个工程更可能是前者,因为结构简单、改起来直观。启动后 GameMode 的BeginPlay会启动第一波,按间隔逐个 Spawn 敌人。
// GameMode 启动波次的简化逻辑 void ATowerDefenseGameMode::StartNextWave() { if (CurrentWaveIndex >= Waves.Num()) { OnAllWavesCleared(); // 全部清完,判胜 return; } const FWaveConfig& Wave = Waves[CurrentWaveIndex]; for (int32 i = 0; i < Wave.EnemyCount; ++i) { // 按间隔延迟生成,避免一帧内全刷出来 GetWorldTimerManager().SetTimer( SpawnTimerHandle, this, &ATowerDefenseGameMode::SpawnSingleEnemy, Wave.SpawnInterval, false, i * Wave.SpawnInterval); } ++CurrentWaveIndex; }Waves是波次配置数组,CurrentWaveIndex记录进度;SetTimer的最后一个参数是首次延迟,用i * SpawnInterval让每个敌人错开生成。这里用SetTimer而不是Tick里数帧,是因为波次间隔通常以秒计,定时器更精确也更省。改波次难度时,调EnemyCount和SpawnInterval就够了,不用动逻辑。
3.3 塔的放置与索敌链路
塔的放置一般走「点击地面 → 射线检测 → 判断是否在可建造区域 → Spawn 塔蓝图」这条链路。索敌则是塔在Tick里遍历场景中的敌人,找距离最近且在射程内的目标,冷却到了就开火。这条链路是塔防手感的核心,射程、冷却、伤害三个参数直接决定平衡性。
// 塔的索敌与开火 void ATowerBase::Tick(float DeltaSeconds) { Super::Tick(DeltaSeconds); CooldownRemaining -= DeltaSeconds; if (CooldownRemaining > 0.f) return; AEnemyBase* Target = FindNearestEnemyInRange(); if (!Target) return; FireAt(Target); CooldownRemaining = FireCooldown; // 重置冷却 } AEnemyBase* ATowerBase::FindNearestEnemyInRange() const { AEnemyBase* Best = nullptr; float BestDistSq = FMath::Square(Range); for (TActorIterator<AEnemyBase> It(GetWorld()); It; ++It) { const float DistSq = FVector::DistSquared(GetActorLocation(), It->GetActorLocation()); if (DistSq < BestDistSq) { BestDistSq = DistSq; Best = *It; } } return Best; }CooldownRemaining是剩余冷却,减到 0 才允许开火;FindNearestEnemyInRange用平方距离比较,省掉开方;Range是射程,FireCooldown是冷却。敌人数量多时,每帧全场景遍历会有压力,后期可以换成用碰撞球体做重叠查询,只检查射程内的敌人,这是很典型的优化点。
4. 核心模块拆解:敌人、塔、弹丸与伤害结算
4.1 敌人单位的生命周期
敌人从 Spawn 到死亡,中间要处理移动、受击、死亡三件事。血量归零时播放死亡表现并通知 GameMode 减少存活计数;到达终点时则扣玩家血量。这两条路径要分开,否则会出现「敌人走到终点还掉金币」的错乱。
// 受击与死亡 void AEnemyBase::ApplyDamage(float Amount) { CurrentHealth -= Amount; if (CurrentHealth <= 0.f) { OnDeath(); // 蓝图里接死亡特效、掉金币 Destroy(); } } void AEnemyBase::OnReachGoal() { // 到达终点:扣玩家血量,不给奖励 if (ATowerDefenseGameMode* GM = GetWorld()->GetAuthGameMode<ATowerDefenseGameMode>()) { GM->PlayerLoseHealth(GoalDamage); } }ApplyDamage里先扣血再判断,OnDeath是蓝图可重载的事件,用来接特效和奖励;OnReachGoal单独走扣血逻辑,和死亡奖励彻底分开。GoalDamage是漏怪扣血量,调难度时这个值比敌人血量更敏感。
4.2 弹丸与命中判定
如果塔是发射弹丸而不是直接结算伤害,弹丸需要自己处理飞行和命中。常见做法是给弹丸一个碰撞体,OnComponentHit里判断撞到的是不是敌人,是就结算伤害并销毁自己。这种方式表现好,但要注意弹丸速度太快时可能穿透,需要开连续碰撞检测。
// 弹丸命中回调 void AProjectile::OnHit(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit) { if (AEnemyBase* Enemy = Cast<AEnemyBase>(OtherActor)) { Enemy->ApplyDamage(Damage); } Destroy(); // 命中任何东西都销毁,避免残留 }Cast<AEnemyBase>保证只有敌人受伤;Damage由发射它的塔传入。Destroy()放在判断外面,意味着撞墙也会消失,这是塔防里可以接受的行为。如果要做穿透弹,就得改成计数而不是立即销毁。
4.3 胜负判定与玩家状态
胜负判定集中在 GameMode:所有波次清完且场上无敌人判胜;玩家血量归零判负。这里的关键是「场上无敌人」的判断时机,不能在最后一波刚刷完就判,要等存活计数归零。
// 敌人销毁时回调,检查是否该判胜 void ATowerDefenseGameMode::OnEnemyRemoved() { --AliveEnemyCount; if (AliveEnemyCount <= 0 && CurrentWaveIndex >= Waves.Num()) { OnVictory(); } }AliveEnemyCount在 Spawn 时加一、销毁时减一;CurrentWaveIndex >= Waves.Num()表示没有下一波了。两个条件同时满足才判胜,缺一个都会提前结束。
5. 避坑与常见问题:编译、运行、表现三层排查
5.1 编译报「找不到模块」或版本不匹配
现象是生成工程文件后打开.sln编译,报模块名找不到或引擎版本不一致。原因通常是.uproject里写的引擎版本和本机装的不一样,或者Build.cs里的模块名和目录名对不上。解决办法是右键.uproject切换引擎版本,再核对ExtraModuleNames与Source下的目录名,改完重新生成工程文件。
5.2 运行后敌人不动或原地抖动
现象是敌人 Spawn 出来但不沿路径走,或者在起点附近抖。原因多半是样条引用没在蓝图里赋值,或者DistanceAlongSpline被重复初始化。检查敌人蓝图里PathSpline是否指向关卡里的样条 Actor,再确认BeginPlay里没有每帧重置距离。
5.3 塔不开火但冷却在走
现象是塔的冷却时间在减少,但从不发射。原因通常是索敌返回空——射程太小、敌人碰撞通道不对、或者TActorIterator遍历时敌人还没生成。先把Range调大验证,再检查敌人的碰撞预设是否被塔的查询命中。
5.4 波次刷完不判胜
现象是最后一波敌人清完,游戏没结束。原因是AliveEnemyCount计数不准,比如敌人在OnReachGoal里Destroy但没走OnEnemyRemoved。统一在EndPlay或销毁回调里减计数,别在两个地方各减一次。
5.5 打包后蓝图资源丢失
现象是编辑器里正常,打包后塔或敌人变成默认 Actor。原因是蓝图子类引用的 C++ 类被重命名或移动了目录,打包时引用断裂。改类名要谨慎,改完在编辑器里重新指定父类并保存所有蓝图。
6. 二次开发:把这份骨架改成你自己的塔防
拿到这个工程后,最值得先动的是「数据与逻辑分离」这件事。现在的波次和塔参数大概率是写在蓝图默认值里的,二次开发时我建议把它们抽到 DataTable:波次表放敌人类型、数量、间隔,塔表放射程、冷却、伤害。这样调平衡不用重新编译,策划也能自己改。抽的时候注意结构体要用USTRUCT(BlueprintType)标记,字段加UPROPERTY(EditAnywhere),否则 DataTable 里看不到。
// 可配置的波次结构体 USTRUCT(BlueprintType) struct FWaveConfig { GENERATED_BODY() UPROPERTY(EditAnywhere, BlueprintReadWrite) TSubclassOf<AEnemyBase> EnemyClass; // 本波敌人类型 UPROPERTY(EditAnywhere, BlueprintReadWrite) int32 EnemyCount = 10; UPROPERTY(EditAnywhere, BlueprintReadWrite) float SpawnInterval = 1.0f; };TSubclassOf让蓝图里能直接选敌人蓝图;三个字段都标了EditAnywhere,DataTable 行里就能填。改完之后 GameMode 从 DataTable 读Waves,逻辑一行不用动。
第二个改造点是索敌性能。敌人上百之后,每座塔每帧全场景遍历会明显掉帧。我一般会改成塔身上挂一个USphereComponent作为射程检测体,用GetOverlappingActors只拿射程内的敌人,再在里面找最近的。这个改动不大,但帧率提升很直观。
第三个是扩展性:想加「减速塔」「范围伤害塔」,不用改基类,继承ATowerBase重写FireAt就行;想加「飞行敌人」,给敌人基类加一个bCanFly标志,路径系统按标志选不同样条。验证方法很简单:每改一处,先在编辑器里用Play跑一波,看日志有没有空指针,再打包一次确认资源引用没断。
血泪经验是:改 C++ 类名或移动目录后,一定要在编辑器里全量重新保存相关蓝图,否则打包时引用断裂,编辑器里却一切正常,这种问题排查起来最费时间。从那以后我每次动类结构,都强制走一遍「编译 → 编辑器重存蓝图 → 打包验证」的流程,不再偷懒。希望帮到你。
本文还有配套的精品资源,点击获取