ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

UE4塔防工程实战:C++与蓝图混合开发核心系统拆解

UE4塔防工程实战:C++与蓝图混合开发核心系统拆解

简介:这是一份基于Unreal Engine 4.22开发的3D塔防游戏完整工程,面向希望学习UE4游戏开发、C++编程与塔防玩法实现的学生和独立开发者。项目以个人学习作品的形式呈现,核心玩法是在地图岩石上布置炮塔抵御入侵士兵,敌人每波数量与生命值递增,玩家需撑过11波进攻而不失守,涵盖关卡设计、波次调度与基础战斗逻辑等实践内容。压缩包约1.01GB,上游未提供文件总数与类型明细,从工程属性看应包含UE4项目源码、地图与蓝图资源、敌人角色网格及音频素材,其中敌人角色网来自Epic Games,音频为外部获取资源。资源页显示已有649人浏览学习,作者还附带了播放或启动崩溃时切换地图再重选的排错思路,便于读者快速跑通工程、理解塔防项目的组织方式与常见问题定位方法。

1. 士兵-塔-防御-UE4:一份能跑起来的 3D 塔防工程到底长什么样

拿到一个标着「士兵-塔-防御-UE4」的工程包,多数人第一反应是双击 uproject 看能不能编译过,然后拖进场景里点 Play,看塔会不会自动打怪。这个反应没错,但真正决定这份资源值不值得留的,是它有没有把塔防最核心的三件事做完整:路径与寻路、目标选择与攻击节奏、资源与波次循环。这三件事在 UE4 里分别对应导航网格与 Spline、Actor 的 Tick 与定时器、GameMode 与数据表,缺一个都只能算演示 Demo。

这份工程是 C++ 与蓝图混合的 UE4 塔防项目,关键词里带 unreal-engine-4、UnrealEngineC++、tower-defense-game,说明它的骨架是 C++ 类,表现层交给蓝图。适合两类人:一是想学 UE4 C++ 怎么组织游戏逻辑的初中级开发者,二是手里有个塔防玩法原型、需要一份可对照的工程结构来抄作业的从业者。下面按「工程怎么读 → 核心系统怎么实现 → 哪里会翻车」的顺序拆,能直接照着复现。

2. 工程结构与 C++ 类骨架:先搞清楚谁继承谁

2.1 目录分层与模块依赖

UE4 工程的坑一半在编译前就埋好了。拿到包先别急着打开编辑器,用文件管理器看三层:Source 下的模块名、Config 下的 DefaultEngine.ini、Content 下的文件夹命名。塔防类工程常见的模块划分是游戏主模块加一个可选的编辑器模块,主模块的 Build.cs 里必须显式依赖 Engine、Core、CoreUObject、InputCore,用到导航就加 NavigationSystem,用到 UMG 就加 UMG。少一个都会在链接阶段报 unresolved external symbol,而不是在写代码时报错,新手很容易在这里卡半天。

我一般会先跑一遍生成工程文件,确认模块能被正确识别:

# 在 uproject 同级目录执行,路径按本机 UE4 安装位置改 "C:\Program Files\Epic Games\UE_4.27\Engine\Build\BatchFiles\GenerateProjectFiles.bat" ^ "SoldierTowerDefense.uproject" -game -rocket -progress

这条命令做的是让 UnrealBuildTool 读取 uproject 里的 Modules 段,生成 Visual Studio 的 sln。参数-game表示按游戏项目而非引擎源码生成,-rocket是安装版引擎的标记,-progress只是让控制台有进度输出。生成完打开 sln,先编译 Development Editor 配置,能过再谈别的。如果这一步就报模块找不到,八成是 uproject 里的模块名和 Source 下的文件夹名对不上,大小写也要一致。

2.2 核心类的继承关系

塔防工程的类骨架通常长这样:AUnitBase 作为所有可放置单位的基类,ATowerBase 和 AEnemyBase 各自继承它,AGameModeBase 的子类管波次和资源,APlayerController 的子类管放置与选中。C++ 里把这些类声明成 UCLASS,暴露给蓝图的属性用 UPROPERTY(EditAnywhere, BlueprintReadWrite),函数用 UFUNCTION(BlueprintCallable)。这一步的意义在于:蓝图子类可以继承 C++ 父类,把数值调参放在蓝图里,逻辑留在 C++,改平衡不用重编译。

// TowerBase.h 关键片段 UCLASS() class ATowerBase : public AUnitBase { GENERATED_BODY() public: ATowerBase(); // 攻击范围,蓝图里可调 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Combat") float AttackRange = 800.f; // 每秒攻击次数 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Combat") float FireRate = 1.2f; protected: virtual void BeginPlay() override; // 由定时器驱动,避免每帧扫描 UFUNCTION() void AcquireAndFire(); private: FTimerHandle FireTimer; TArray<AEnemyBase*> EnemiesInRange; };

这段头文件里有两个设计决定值得说。第一,攻击范围用 float 而不是碰撞体半径,因为塔防的索敌是逻辑判定,用距离平方比较比物理重叠查询便宜得多。第二,开火用 FTimerHandle 而不是 Tick,FireRate 换算成1.f / FireRate秒的定时器间隔,塔多了以后 Tick 的开销会线性堆上去,定时器则只在触发时执行。BeginPlay 里做GetWorldTimerManager().SetTimer(FireTimer, this, &ATowerBase::AcquireAndFire, 1.f / FireRate, true),这就是整个攻击循环的入口。

2.3 蓝图与 C++ 的边界怎么划

混合工程最容易烂的地方是边界模糊:有人在蓝图里又写一遍索敌逻辑,有人在 C++ 里硬编码 UI 文本。我的划分习惯是,C++ 负责状态机、数值计算、对象生命周期,蓝图负责动画、特效、音效、UI 绑定。判断标准很简单:这段逻辑会不会影响胜负判定?会,放 C++;只是让画面好看,放蓝图。按这个标准,塔的索敌、伤害结算、波次生成、金币增减全在 C++,塔的炮口旋转、命中特效、血条动画全在蓝图里用事件驱动。

3. 塔防三大核心系统:寻路、索敌、波次循环

3.1 路径与导航:Spline 定路线,NavMesh 兜底

塔防的敌人移动有两种主流做法:沿 Spline 走固定路线,或者用 NavMesh 让 AI 自己找路。这份工程如果带 RTS 标签,大概率是前者,因为固定路线才能保证「塔放在哪、覆盖哪段路」的博弈成立。Spline 的做法是在关卡里放一个 Spline Actor,定义若干控制点,敌人沿曲线采样点移动,用GetLocationAtDistanceAlongSpline按当前距离取位置,速度乘以 DeltaTime 累加距离。

// EnemyBase.cpp 沿 Spline 移动 void AEnemyBase::MoveAlongPath(float DeltaTime) { if (!PathSpline) return; DistanceAlongSpline += MoveSpeed * DeltaTime; const float SplineLength = PathSpline->GetSplineLength(); if (DistanceAlongSpline >= SplineLength) { OnReachGoal(); // 扣血、销毁、通知 GameMode return; } const FVector NewLoc = PathSpline->GetLocationAtDistanceAlongSpline( DistanceAlongSpline, ESplineCoordinateSpace::World); SetActorLocation(NewLoc, false); }

MoveSpeed是每秒移动的厘米数,UE4 默认单位是厘米,所以 300 表示 3 米每秒,调参时别按米填。SetActorLocation第二个参数传 false 是关闭扫掠,敌人之间不做物理碰撞,否则一群怪会互相顶住卡在拐角。导航网格在这里的作用是给「放置预览」做合法性检测,判断塔位是否压在路上,而不是给敌人寻路用。这两者别搞混,混了就会出现敌人绕开塔走、路线设计失效的问题。

3.2 索敌与攻击节奏:距离平方比较与目标优先级

索敌的常见写法是每座塔在开火定时器里遍历一遍敌人数组,算距离平方,取最近的或血量最高的。遍历全量敌人数组在怪少时没问题,怪上百以后要考虑空间划分,但塔防单波怪量通常可控,先不优化。关键是目标优先级要可配置,否则玩家会觉得塔「乱打」。

void ATowerBase::AcquireAndFire() { float BestScore = TNumericLimits<float>::Max(); AEnemyBase* BestTarget = nullptr; for (AEnemyBase* Enemy : EnemiesInRange) { if (!IsValid(Enemy)) continue; const float DistSq = FVector::DistSquared(GetActorLocation(), Enemy->GetActorLocation()); if (DistSq > FMath::Square(AttackRange)) continue; // 优先级:先看配置,默认打最靠近终点的 const float Score = (TargetPriority == ETargetPriority::Closest) ? DistSq : -Enemy->GetDistanceAlongSpline(); if (Score < BestScore) { BestScore = Score; BestTarget = Enemy; } } if (BestTarget) { FireAt(BestTarget); // 生成投射物或直接结算伤害 } }

DistSquared比Dist少一次开方,循环里能省则省。TargetPriority用枚举暴露到蓝图,玩家或策划可以切「最近」「最靠前」「血最多」三种策略。FireAt里如果是投射物塔,生成弹丸 Actor 并给它一个目标引用;如果是激光塔,直接结算伤害加一条 Beam 特效。两种塔共用同一套索敌,差别只在 FireAt 的实现,这是继承带来的好处。

3.3 波次与资源循环:数据表驱动,别硬编码

波次配置硬编码在 C++ 里是自找麻烦,改一次数值重编译一次。合格做法是用 DataTable,每行一个波次,列包括敌人类型、数量、生成间隔、波次间隔、奖励金币。GameMode 在 BeginPlay 读表,按当前波次索引取行,用定时器分批 Spawn。

// TowerGameMode.cpp 波次推进 void ATowerGameMode::StartNextWave() { if (CurrentWaveIndex >= WaveTable->GetRowMap().Num()) { OnAllWavesCleared(); return; } const FWaveRow* Row = WaveTable->FindRow<FWaveRow>( FName(*FString::FromInt(CurrentWaveIndex)), TEXT("WaveLookup")); if (!Row) return; RemainingSpawnCount = Row->EnemyCount; GetWorldTimerManager().SetTimer(SpawnTimer, this, &ATowerGameMode::SpawnOneEnemy, Row->SpawnInterval, true); } void ATowerGameMode::SpawnOneEnemy() { // 生成、绑定死亡回调、递减计数,计数归零后停定时器并排下一波 }

FindRow的行名用波次索引转字符串,简单直接;正式项目里建议用语义化行名如Wave_01,可读性更好。SpawnInterval控制同一波内出怪节奏,RemainingSpawnCount归零后要停掉 SpawnTimer,再起一个波次间隔定时器调 StartNextWave,两个定时器别共用一个句柄,否则会出现上一波的残留定时器把下一波提前触发。金币结算放在敌人死亡回调里,OnEnemyKilled加钱、广播事件给 UI,UI 只监听不计算,这条链路要单向。

4. 避坑与排查:编译过不代表能玩

4.1 现象:编辑器能 Play,打包后塔不攻击

原因通常是定时器在打包版本里没启动,或者 DataTable 的资产引用在打包时被裁剪。C++ 里用ConstructorHelpers::FObjectFinder加载的资产如果路径写死且没加进打包列表,编辑器里能读到是因为资产还在磁盘上,打包后就被剔了。解决方式是把 DataTable 和蓝图类引用改成 UPROPERTY(EditDefaultsOnly) 在蓝图里指定,或者把资产目录加进 DefaultGame.ini 的 DirectoriesToAlwaysCook。

4.2 现象:敌人走到拐角卡住或抖动

原因是 Spline 控制点太稀疏,采样点之间直线插值导致拐角处位置跳变,或者SetActorLocation开了扫掠和别的碰撞体互顶。解决是把拐角处加密控制点,Spline 的 Tangents 调成 Curve 而非 Linear,同时确认敌人胶囊体的碰撞预设不阻挡其他敌人。抖动还有一种可能是移动和动画根运动同时改位置,把根运动关掉即可。

4.3 现象:塔的攻击范围看起来对,实际打不到

血泪经验:AttackRange是按厘米填的,但场景里量距离时人脑按米估,800 写成 80 就变成 0.8 米。另一个隐蔽原因是塔的发射点(Muzzle 插槽)和塔根位置差很远,索敌用根位置算距离,投射物从枪口出,视觉上就有偏差。统一用塔根位置做索敌判定,投射物只负责表现,命中判定在发射瞬间用逻辑结算,别依赖弹丸物理碰撞。

4.4 现象:波次越到后面越卡

每波结束没清理死亡敌人的引用,EnemiesInRange数组里堆着一堆 IsValid 为 false 的指针,每帧遍历越来越长。解决是在敌人 Destroy 时从所有塔的数组里移除,或者用弱引用TWeakObjectPtr,再定期压缩数组。另一个来源是投射物 Actor 没设生命周期,飞出地图也不销毁,给弹丸设InitialLifeSpan兜底。

4.5 现象:C++ 改了数值,蓝图子类不生效

蓝图子类里如果手动改过某个继承来的属性,它会存一份覆盖值,C++ 默认值再改也不影响这个子类。排查时在蓝图里看属性是否显示为「已修改」状态,是的话点重置回继承。这个坑在调平衡时特别烦,建议数值全部走 DataTable,C++ 里只留结构不留默认值。

5. 进阶:把塔防做成可扩展的验证清单

工程能跑之后,判断它是否值得作为长期底子,我会过一遍这份清单。第一,新增一种塔是否只需要新建一个蓝图子类加一行数据表,不需要动 C++;第二,新增一种敌人是否只需要配行为参数和模型;第三,波次表能否在不重编译的情况下改出十波不同节奏;第四,塔的索敌策略能否在运行时切换。四条里过三条,这份工程就有改造价值,过不了说明耦合太重,只适合当学习样本。

验证时可以用一个最小改动测试:把 FireRate 从 1.2 改成 0.5,看是否只改蓝图就能生效;再往 DataTable 里加一行 Wave_11,看 GameMode 是否自动接上。这两个动作能暴露大部分硬编码问题。另外建议给 GameMode 加一个控制台命令直接跳波,调试时不用从头打:

// 控制台命令:JumpWave 5 static FAutoConsoleCommand JumpWaveCmd( TEXT("JumpWave"), TEXT("Skip to specified wave index"), FConsoleCommandWithArgsDelegate::CreateLambda([](const TArray<FString>& Args) { if (Args.Num() > 0) { // 取 GameMode 并调用跳波逻辑 } }));

FAutoConsoleCommand在 Development 包里可用,Shipping 包会被裁掉,正好符合调试需求。参数从 Args 数组取,记得做空判断和类型转换的异常保护,控制台命令崩了比游戏崩了更难查。

从那以后我每次拆这类工程,都强制先跑一遍「改一个数值、加一行数据、跳一次波」这三步,过不了就不往下读代码,省得在错误的地基上盖楼。希望这份拆解帮到你,拿到包能少走几个弯路。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进