很多刚开始研究 UE5 RTS 的同学,通常会把第一个 Demo 放在“单位移动”和“兵种战斗”上,结果做到中期才发现:真正把整个玩法串起来的,其实是资源采集这一条非常基础又非常容易被小看的链路。单位走到矿脉、采集、带回基地、更新 HUD、节点资源扣减、满载调度……任何一个环节没想清楚,后面做大系统时都会回来返工。
本文将围绕“虚幻引擎5 游戏开发实战:打造 RTS 资源采集系统”这条主线,拆开一个最小可用但结构清晰的资源采集原型。文章会覆盖 UE5 的 Gameplay 框架、Actor 数据设计、C++ 类与蓝图配合方式、右键命令与自动提交资源等关键内容。适合有一定 UE 基础、想从零搭建 RTS 单局玩法的开发者阅读;如果你已经有 Unity 或 Godot 项目经验,也可以当作跨引擎思路对照。
1. 为什么先把 RTS 资源采集系统单独拿出来做
1.1 RTS 资源采集到底在做什么
RTS 游戏的资源采集可以抽象成一句话:让某个单位把场景中的资源点数据,搬运到属于玩家的存储容器中。看起来只有“采集”和“提交”两个动作,但实际展开之后会涉及:
- 资源种类划分,例如 Gold、Mineral、Food;
- 资源节点的当前余量与总量显示;
- 采集单位的状态切换;
- 单位寻路到达资源点;
- 到达后按节奏抽取资源;
- 采集单位携带容量满后寻找提交点;
- 提交后把资源加到玩家或队伍的总量中;
- HUD 实时刷新资源数值。
如果只是临时写死一串数值,Demo 当然很快。但一旦加入第二个资源类型、多个阵营、多个采集单位,你会发现代码结构中“资源数值存在哪里”“谁有权添加资源”“节点剩余量由谁扣减”会成为主要矛盾。
这也是 UE5 游戏开发中资源管理话题一直讨论度很高的原因。很多人会把大量精力放在资产和打包资源上,但从玩法设计角度看,运行时资源数值的归属与流转同样重要。
1.2 UE5 落地时容易卡住的点
使用 UE5 实现 RTS 资源采集,经常遇到的困惑包括:
- 选中单位后,如何右键让单位去采集;
- 单位到达资源点后,以什么频率扣减资源;
- 玩家持有的资源应该保存在 GameMode、PlayerState 还是某个全局单例中;
- 资源节点余量变了,UI 怎么感知;
- 单位满载后,是立刻返回基地还是继续占用资源节点;
- C++ 写的数值变更,蓝图界面如何订阅。
这些问题如果从一开始就明确了,后面再接多单位框选、行为树 AI、联网同步都会顺很多。
1.3 本文实现的功能边界
本文不会强行实现完整的 RTS 框架,而是聚焦下面这条可以跑通的闭环:
左键选中采集单位 → 右键点击资源节点 → 单位寻路到节点附近 → 开始按时间间隔采集 → 背包满或节点枯竭后回到最近提交点 → 资源写入玩家 PlayerState → HUD 刷新这套结构足够支撑一个可演示的 UE5 RTS 原型,同时也方便后续扩展。
2. 技术方案与演示环境
2.1 引擎与工程
本文使用的核心引擎为 Unreal Engine 5 系列。UE5.0 之后 Gameplay 框架整体变化不大,后续给的代码在 UE5.1、UE5.2、UE5.3 或更高版本中思路通用。
由于不同版本默认输入系统差异较大,文章在输入响应部分会同时说明传统 BindKey 与 Enhanced Input 的对接思路。你可以根据自己工程创建时的模板选择合适方式。
2.2 准备一个第三人称或空白工程
如果你已经有一个 UE5 C++ 工程,可以直接在现有 Module 中添加代码。如果是从零开始,建议在启动器中选择“游戏”分类下的 Blank 或 Third Person 模板,并勾选“包含初学者内容包”。
初学者内容包里的基础几何体和材质可以用来快速搭场景。
2.3 Gameplay 框架的类划分
在这个资源采集 Demo 中,我建议按 UE5 的标准 Gameplay 框架划分职责:
| 类 | 职责 |
|---|---|
| ARTSResourceNode | 场景中的资源节点,负责扣减自身余量 |
| ARTSHarvester | 玩家控制的采集单位,负责移动、采集、提交 |
| ARTSResourceDropOffPoint | 基地提交点,提交后资源进入玩家账户 |
| ARTSPlayerState | 保存当前玩家的资源数量 |
| ARTSPlayerController | 处理鼠标点击选中与右键命令 |
| HUD/UMG | 显示玩家资源数值 |
这里有一个很重要的设计观点:不要把所有逻辑都塞进 GameMode。GameMode 更适合控制规则流程,不适合保存某个玩家的个人资源数据。多人模式下每个客户端对应不同 PlayerState,把资源数量放在 PlayerState 中是最符合 UE 习惯的做法。
3. 第一步:先设计资源数据,而不是先摆场景
3.1 创建基础资源类型
在 UE5 中,资源类型最简单的表达方式是枚举。为了能暴露给蓝图,我们使用 UENUM。
文件路径:Source/RTSSystem/RTSResourceTypes.h
#pragma once #include "CoreMinimal.h" #include "RTSResourceTypes.generated.h" UENUM(BlueprintType) enum class ERTSResourceType : uint8 { None UMETA(DisplayName = "None"), Gold UMETA(DisplayName = "Gold"), Mineral UMETA(DisplayName = "Mineral"), Energy UMETA(DisplayName = "Energy") };这样设计的好处是:
- 枚举名称可读;
- 蓝图下拉框能直接选择;
- 后续需要扩展资源类型时,只需要增加枚举项;
- 可以像普通整数一样用 TMap 做映射存储。
3.2 把资源数值放在哪一层
很多 RTS 新手会把“资源数量”直接写死在资源节点 Actor 上,同时在 HUD 中通过 GetActorLocation 之类的接口拿数值。这样在单人演示中没有明显问题,但当资源节点由服务端管理、玩家资源由客户端的玩家存档管理时,就会混淆。
推荐的做法是:
- 场景中的
ARTSResourceNode只关心“这个节点的剩余量”; - 玩家持有量统一放到
ARTSPlayerState; - 采集单位不直接调用 PlayerState 修改资源,而是把资源送到提交点后,由提交逻辑统一结算。
在这个模型下,资源节点的数值与玩家的资源数值是两套数据,中间通过采集单位的“携带量”作为临时传递,而不是一边采集一边直接加到玩家总资源里。这样实现自动施法、科技加速、资源减半等玩法改动时会更灵活。
3.3 采集阶段的定义
我们把单位要做的事情定义成枚举状态。虽然在小型 Demo 中可以用 bool 标记代替,但状态枚举在调试时更有价值。
UENUM(BlueprintType) enum class EHarvesterPhase : uint8 { Idle, MovingToNode, Harvesting, MovingToDropOff, Depositing };有了这个枚举,单位在 Tick 中只需要根据当前状态做对应判断,逻辑会清晰很多,后续接入调试菜单时也能一眼看出单位正在做什么。
4. 实现资源节点 Actor
4.1 创建 ARTSResourceNode 类
资源节点是整个资源采集的源头。它需要暴露:
- 资源类型;
- 初始总量;
- 当前余量;
- 被采集时可以扣减的接口;
- 资源变化的事件回调。
文件路径:Source/RTSSystem/RTSResourceNode.h
#pragma once #include "CoreMinimal.h" #include "GameFramework/Actor.h" #include "RTSResourceTypes.h" #include "RTSResourceNode.generated.h" class UStaticMeshComponent; class UWidgetComponent; DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams( FOnResourceAmountChanged, ARTSResourceNode*, Node, float, NewAmount ); UCLASS(Blueprintable) class ARTSResourceNode : public AActor { GENERATED_BODY() public: ARTSResourceNode(); virtual void BeginPlay() override; UPROPERTY(VisibleAnywhere, Category = "Components") TObjectPtr<UStaticMeshComponent> MeshComponent; UPROPERTY(VisibleAnywhere, Category = "Components") TObjectPtr<UWidgetComponent> InfoWidget; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "RTS|Resource") ERTSResourceType ResourceType = ERTSResourceType::Gold; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "RTS|Resource") float TotalAmount = 1000.0f; UPROPERTY(BlueprintReadOnly, Category = "RTS|Resource") float CurrentAmount = 0.0f; UPROPERTY(BlueprintAssignable, Category = "RTS|Event") FOnResourceAmountChanged OnResourceAmountChanged; UFUNCTION(BlueprintCallable, Category = "RTS|Resource") float Extract(float RequestedAmount); UFUNCTION(BlueprintPure, Category = "RTS|Resource") bool IsDepleted() const; protected: void UpdateWidget(); UPROPERTY(EditDefaultsOnly, Category = "RTS|UI") TSubclassOf<UUserWidget> InfoWidgetClass; };有一个细节要提前说清楚:CurrentAmount不能在构造函数里直接复制TotalAmount,因为蓝图放置到场景后,设计者可能修改TotalAmount的默认值。更稳妥的方式是在BeginPlay中重置。
对应实现文件:
文件路径:Source/RTSSystem/RTSResourceNode.cpp
#include "RTSResourceNode.h" #include "Blueprint/UserWidget.h" #include "Components/StaticMeshComponent.h" #include "Components/WidgetComponent.h" ARTSResourceNode::ARTSResourceNode() { PrimaryActorTick.bCanEverTick = false; MeshComponent = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("MeshComponent")); SetRootComponent(MeshComponent); InfoWidget = CreateDefaultSubobject<UWidgetComponent>(TEXT("InfoWidget")); InfoWidget->SetupAttachment(MeshComponent); InfoWidget->SetWidgetSpace(EWidgetSpace::World); InfoWidget->SetDrawSize(FVector2D(220.0f, 80.0f)); InfoWidget->SetRelativeLocation(FVector(0.0f, 0.0f, 160.0f)); } void ARTSResourceNode::BeginPlay() { Super::BeginPlay(); CurrentAmount = TotalAmount; UpdateWidget(); } float ARTSResourceNode::Extract(float RequestedAmount) { if (IsDepleted() || RequestedAmount <= 0.0f) { return 0.0f; } const float ActualAmount = FMath::Min(RequestedAmount, CurrentAmount); CurrentAmount -= ActualAmount; UpdateWidget(); OnResourceAmountChanged.Broadcast(this, CurrentAmount); return ActualAmount; } bool ARTSResourceNode::IsDepleted() const { return CurrentAmount <= 0.0f; } void ARTSResourceNode::UpdateWidget() { if (!InfoWidget || !InfoWidgetClass) { return; } if (!InfoWidget->GetWidget()) { InfoWidget->SetWidgetClass(InfoWidgetClass); } }4.2 给资源节点添加可见 Mesh
代码中只创建了UStaticMeshComponent,并没有指定具体的 Static Mesh,因为引擎的 C++ 构造函数不推荐直接硬编码资源路径。正确做法是:
- 基于
ARTSResourceNode创建蓝图子类; - 在蓝图里把 MeshComponent 的 Static Mesh 指定为立方体或自定义矿脉模型;
- 给 MeshComponent 设置合适的碰撞预设,默认 BlockAll 即可;
- 调整 InfoWidget 的渲染位置。
我建议使用 Cube 并缩放成长条形矿脉,颜色上用暖色与普通地形区分。这样右键点击时碰撞检测更容易命中。
4.3 节点 UI 的更新思路
InfoWidget使用的是 WidgetComponent,它是一个世界空间 UI。若要显示剩余量,可以在ARTSResourceNode蓝图子类中创建一个 UMG Widget,让 UI 里的进度条监听OnResourceAmountChanged事件并刷新显示。
由于数据变更事件是动态多播,蓝图里可以把事件绑定到 UI 的函数节点,不需要频繁轮询 Tick。
5. 实现采集单位 ARTSHarvester
5.1 Harvester 类声明
采集单位继承 ACharacter 比较方便,因为不需要自己实现角色移动组件,可以直接使用 CharacterMovement 在 NavMesh 上移动。
文件路径:Source/RTSSystem/RTSHarvester.h
#pragma once #include "CoreMinimal.h" #include "GameFramework/Character.h" #include "RTSResourceTypes.h" #include "RTSHarvester.generated.h" class ARTSResourceNode; class ARTSResourceDropOffPoint; class UStaticMeshComponent; UENUM(Blueprint