UE4运用C++和框架开发坦克大战教程笔记(七)(第20~22集)

20. 框架对象类

接下来我们要创建剩余的所有要用到的框架 Object 类。

选择目标模块为 DataDriven (Runtime),在 Public/DDObject/ 路径下创建以下 C++ 类:(如果有弹窗则选 No)

创建 ActorComponent,取名为 DDActorComponent
创建 AIController,取名为 DDAIController
创建 CameraActor,取名为 DDCameraActor
创建 Character,取名为 DDCharacter
创建 GameInstance,取名为 DDGameInstance
创建 GameModeBase,取名为 DDGameModeBase
创建 GameStateBase,取名 DDGameStateBase
创建 HUD,取名 DDHUD
创建 LevelScriptActor,取名 DDLevelScriptActor
创建 Pawn,取名 DDPawn
创建 PlayerCameraManager,取名 DDPlayerCameraManager
创建 PlayeController,取名 DDPlayerController
创建 PlayerState,取名 DDPlayerState
创建 SceneComponent,取名 DDSceneComponent
创建 WheeledVehicle,取名 DDWheeledVehicle

创建完以上所有类后,不用急着编译代码,为了让它们变成受框架控制的类,给它们执行如下操作:

  1. 引入 DDOO 头文件,继承 DDOO 接口。
  2. 去掉 Tick() 相关的函数,添加构造函数并在里面关闭原生 Tick(如果原本有 Tick() 相关内容的话)。
  3. 添加三个 FName 变量。
  4. BeginPlay() 里自动注册。(没有 BeginPlay() 就重写一个)
  5. 重写 DDRelease()(这一步取决于类是否需要自动销毁)。

DDActorComponent 类的示例如下:

DDActorComponent.h

#include "DDOO.h"	// 引入头文件
#include "DDActorComponent.generated.h"

UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) )
class DATADRIVEN_API UDDActorComponent : public UActorComponent, public IDDOO	// 继承接口
{
	GENERATED_BODY()

public:	
	
	UDDActorComponent();

	// 重写释放函数
	virtual void DDRelease() override;

public:

	// 模组名字,如果为空,说明要手动指定,不为空就是自动指定
	UPROPERTY(EditAnywhere, Category = "DataDriven")
	FName ModuleName;

	// 对象名字,如果为空,说明要手动指定,不为空就是自动指定
	UPROPERTY(EditAnywhere, Category = "DataDriven")
	FName ObjectName;

	// 类名字,如果为空,说明要手动指定,不为空就是自动指定
	UPROPERTY(EditAnywhere, Category = "DataDriven")
	FName ClassName;

// 去掉 TickComponent()
};

DDActorComponent.cpp

UDDActorComponent::UDDActorComponent()
{
	PrimaryComponentTick.bCanEverTick = false;	// 关闭原生 Tick

	// 设置允许销毁(只有 Component 类需要)
	bAllowAnyoneToDestroyMe = true;
}

void UDDActorComponent::BeginPlay()
{
	Super::BeginPlay();

	// 自动注册
	RegisterToModule(ModuleName, ObjectName, ClassName);
}

void UDDActorComponent::DDRelease()
{
	IDDOO::DDRelease();
	// 从组件中删除自己,并标记为准备被 gc 回收(只有 Component 类需要)
	DestroyComponent();
}

// 删除 TickComponent()

不过有些类在上面的操作上有差异,以下是注意事项:

  1. 只有 Component 类需要在构造函数里设置允许销毁;在 DDRelease() 里调用 DestroyComponent()
  2. 除了 Component 以外的类,它们的 DDRelease() 内的释放语句替换如下:
	// 能调用这个方法那么一定是注册到了框架,获取的世界一定不为空
	GetDDWorld()->DestroyActor(this);
  1. 需要重写 DDRelease() 的类:DDActorComponent、 DDCameraActor、DDCharacter、DDPawn、DDSceneComponent、DDWheeledVehicle 。其他的不需要。(需要注意的是类似 DDObject、DDUserWidget 等先前已经写过的类就不列出来了)
  2. 除了 Component 以外的类,关闭原生 Tick 的调用对象是 PrimaryActorTick,示例如下:
	PrimaryActorTick.bCanEverTick = false;
  1. DDCharacter 和 DDPawn 除了 Tick() 要去掉以外,绑定输入的 SetupPlayerInputComponent() 也要去掉,因为后续会专门写一套绑定方法。
  2. DDGameInstance 只需要引入 DDOO 头文件并继承 DDOO 接口就行了。
  3. DDPlayerController 需要开启 Tick,示例如下:

DDPlayerController.cpp

ADDPlayerController::ADDPlayerController()
{
	// 必须开启 Controller 或者 Character 的帧函数才能够检测按键,本框架只开启 Controller 的
	PrimaryActorTick.bCanEverTick = true;
}

上面的内容如果读者不放心的话建议查看 DataDriven 源码或者根据视频对比着填写。

刚刚创建的载具类依赖 PhysXVehicles 模块,但是在 4.26~4.27 版本里这个模块即将被 ChaosVehicles 取代,不过后者在 UE4 处于 Beta 版,可能会在功能上出现不稳定性。

此外,笔者发现有用户遇到过 4.26~4.27 版本使用 ChaosVehicles 时出现问题:UE4.26 UE4.27 Chaos Vehicle 车辆运动组件无法控制车轮 不能正常使用的问题
需要在源码版引擎才能解决。衡量之下笔者决定还是采用旧版的 PhysXVehicles。

来到插件的 .Build.cs 文件,添加以下依赖:

DataDriven.Build.cs

	PrivateDependencyModuleNames.AddRange(
		new string[]
		{
			"CoreUObject",
			"Engine",
			"Slate",
			"SlateCore",
			"UMG",
			// 添加下面三个模块依赖
			"AIModule",	
			"GameplayTasks",
			"PhysXVehicles",
		}
		);

因为 DataDriven 本身是一个插件,PhysXVehicles 也是插件,插件间互相引用需要在 .uplugin 添加说明代码:

DataDriven.uplugin

{

	"Modules": [
		{
			"Name": "DataDriven",
			"Type": "Runtime",
			"LoadingPhase": "Default"
		}
	],	// 记得加这个逗号
	// 添加下面这些代码
	"Plugins": [
		{
			"Name": "PhysXVehicles",
			"Enabled": true
		}
	]
}

PlayerController 经常会被调用到,我们可以把 DDPlayerController 注册到 DDCommon,以便可以在任何地方获取到。

再添加两个暂停游戏相关的方法。

DDCommon.h

class APlayerController;	// 声明类

UCLASS()
class DATADRIVEN_API UDDCommon : public UObject
{
	GENERATED_BODY()
	
public:

	void InitController(APlayerController* InController);

	APlayerController* GetController();

	// 暂停游戏
	void SetPauseGame(bool IsPause);

	// 获取是否暂停了游戏
	const bool IsPauseGame() const;

private:

	APlayerController* PlayerController;
};

DDCommon.cpp


void UDDCommon::InitController(APlayerController* InController)
{
	PlayerController = InController;
}

APlayerController* UDDCommon::GetController()
{
	return PlayerController;
}

void UDDCommon::SetPauseGame(bool IsPause)
{
	PlayerController->SetPause(IsPause);
}

const bool UDDCommon::IsPauseGame() const
{
	return PlayerController->IsPaused();
}

为了让 DDPlayerController 和 DDGameInstance 这类 Gameplay 部分能够运用到游戏中,我们需要让 Driver 来执行注册。

DDDriver.h

protected:

	// 注册 GamePlay 框架到 DataDriven
	void RegisterGamePlay();

DDDriver.cpp

// 引入头文件
#include "Kismet/GameplayStatics.h"

void ADDDriver::BeginPlay()
{
	Super::BeginPlay();

	// 注册 GamePlay 到框架
	RegisterGamePlay();
	Center->IterModuleInit(Center);
}

void ADDDriver::RegisterGamePlay()
{
	// 获取 GameInstance
	UGameInstance* GameInstance = UGameplayStatics::GetGameInstance(GetWorld());
	// 如果存在并且继承自 IDDOO,就注册进 Center,类名和对象名都是 GameInstance
	if (GameInstance && Cast<IDDOO>(GameInstance))
		// 老师这里把 GameInstance 拼写错了
		Cast<IDDOO>(GameInstance)->RegisterToModule("Center", "GameInstance", "GameInstance");

	// 获取 Controller 并且注册到 DDCommon
	APlayerController* PlayerController = UGameplayStatics::GetPlayerController(GetWorld(), 0);
	// 注册到 Common
	if (!PlayerController)
		DDH::Debug() << "No PlayerController" << DDH::Endl();
	else
		UDDCommon::Get()->InitController(PlayerController);
}

如果编译通过则说明没有问题(笔者尝试的时候编译了两次以上才成功)。后面的课程我们会使用现有的框架内容搭建游戏内容。

21. 模组反射方法调用

下图截取自梁迪老师的 DataDriven 文档。

反射事件系统概述

上面的反射事件调用过程参考之前 FrameCourse 项目里 ADecActor::RunFunThree() 里面写的逻辑。

接下来我们构建这样一套反射事件系统,先试试让框架对象调用所属模组的方法。

来到 DDTypes,定义一个枚举,代表调用反射事件的结果。

再声明三个结构体,一个用来作为通信时携带参数和调用结果的载体,另外两个分别是模组和对象的通信协议。

DDTypes.h

// 调用结果,项目开发时请确保每次都能调用成功
UENUM()
enum class ECallResult : uint8
{
	NoModule = 0,	// 缺失模组
	LackObject,		// 缺失部分对象(不要改 No)
	NoFunction,		// 缺失方法
	Succeed			// 调用成功
};

// 通信参数结构体基类
struct DDParam
{
public:

	// 调用结果
	ECallResult CallResult;

	// 参数指针
	void* ParamPtr;
};

// 通信协议,Module 方法
struct DDModuleAgreement
{
public:
	// 模组 ID
	int32 ModuleIndex;

	// 方法名
	FName FunctionName;
};

// 通信协议,DDOO 方法
struct DDObjectAgreement
{
public:

	// 模组 ID
	int32 ModuleIndex;

	// 协议类型
	EAgreementType AgreementType;
	
	// 对象组名
	TArray<FName> ObjectGroup;

	// 方法名
	FName FunctionName;
};

在 DDModule 添加一个方法用于执行模组内的对应方法。

顺便添加一个测试用的方法,供对象调用。

DDModule.h

public:

	// 调用模组方法
	void ExecuteFunction(DDModuleAgreement Agreement, DDParam* Param);

	// 临时方法,测试反射事件系统
	// bool 传引用的意义是充当返回值,后面才会用到
	UFUNCTION()
	void TestReflect(int32 Counter, FString InfoStr, bool& BackResult);

DDModule.cpp

void UDDModule::ExecuteFunction(DDModuleAgreement Agreement, DDParam* Param)
{
	// 调用 Module 的 UFunction
	UFunction* ExeFunc = FindFunction(Agreement.FunctionName);
	// 如果方法存在
	if (ExeFunc) {
		// 设置调用成功
		Param->CallResult = ECallResult::Succeed;
		// 调用方法
		ProcessEvent(ExeFunc, Param->ParamPtr);
	}
	else {
		// 设置方法不存在
		Param->CallResult = ECallResult::NoFunction;
	}
}

void UDDModule::TestReflect(int32 Counter, FString InfoStr, bool& BackResult)
{
	DDH::Debug() << Counter << " ; " << InfoStr << " ; " << GetFName() << DDH::Endl();
}

对象通过反射调用所属模组的方法

给接口类 DDOO 添加一个方法,只要对象接收到传过来的模组序号跟自己的所属模组序号一样,就传递执行所属模组的对应方法。

DDOO.h

protected:

	// 执行反射方法
	void ExecuteFunction(DDModuleAgreement Agreement, DDParam* Param);

DDOO.cpp

void IDDOO::ExecuteFunction(DDModuleAgreement Agreement, DDParam* Param)
{
	if (Agreement.ModuleIndex == ModuleIndex)
		IModule->ExecuteFunction(Agreement, Param);
}

来到 LifeCallActor,定义一个继承自 DDParam 的结构体,在里面定义一个 “包含调用方法所需的变量” 的匿名结构体后,在结构体的构造函数中让继承自父结构体 DDParam 的 void* 参数指针指向这个匿名结构体,使 ParamPtr 作为参数载体。

再定义一个方法来装载各种变量,最后通过 DDOO 传递方法执行,然后销毁临时的 Param 对象。

LifeCallActor.h

protected:

	struct TestReflectParam : DDParam
	{
		struct
		{
			int32 Counter;
			FString InfoStr;
			bool BackResult;
		} Parameter;
		int32 Counter() { return Parameter.Counter; }
		FString InfoStr() { return Parameter.InfoStr; }
		bool BackResult() { return Parameter.BackResult; }
		TestReflectParam() { ParamPtr = &Parameter; }
	};
	// 笔者编译时 ModuleIndex 这个名字跟 DDOO 的变量重名了,无法通过编译,所以作此修改
	void TestReflect(int32 ModuleIndex1, FName FunctionName, int32 Counter, FString InfoStr, bool BackResult)
	{
		DDModuleAgreement Agreement;
		Agreement.ModuleIndex = ModuleIndex1;	// 作修改,后面笔记的都是如此
		Agreement.FunctionName = FunctionName;
		TestReflectParam* Param = new TestReflectParam();
		Param->Parameter.Counter = Counter;
		Param->Parameter.InfoStr = InfoStr;
		Param->Parameter.BackResult = BackResult;
		ExecuteFunction(Agreement, Param);	// 调用 DDOO 的方法
		delete Param;
	}

准备工作都做好了,直接在 DDEnable() 这里调用来测试一下。

LifeCallActor.cpp

void ALifeCallActor::DDEnable()
{

	TestReflect(ModuleIndex, "TestReflect", 13, "Happy", true);
}

编译后运行游戏,可以看到左上角 Debug 语句,DDEnable 之后输出了 ALifeCallActor::DDEnable() 提供的参数,说明对象通过反射调用所属模组的方法成功了。

注册成功

22. 模组反射系统宏定义

通过引用形参来充当返回值

前面说到 bool 的传引用是为了充当返回值,因为通过反射调用的模组的方法是不可以带返回值的,不过我们可以在方法里面直接对引用形参进行修改。

给 DDModule 的这个测试方法结尾添加一个对 bool 引用的赋值语句。

DDModule.cpp

void UDDModule::TestReflect(int32 Counter, FString InfoStr, bool& BackResult)
{
	DDH::Debug() << Counter << " ; " << InfoStr << " ; " << GetFName() << DDH::Endl();

	BackResult = false;		// 赋值
}

来到 LifeCallActor,定义一个带返回值的方法。

LifeCallActor.h

protected:

	// 跟上一个方法的区别在于有返回类型,以及最后是返回 Param 而不是删除
	TestReflectParam* TestReflectRT(int32 ModuleIndex1, FName FunctionName, int32 Counter, FString InfoStr, bool BackResult)
	{
		DDModuleAgreement Agreement;
		Agreement.ModuleIndex = ModuleIndex1;
		Agreement.FunctionName = FunctionName;
		TestReflectParam* Param = new TestReflectParam();
		Param->Parameter.Counter = Counter;
		Param->Parameter.InfoStr = InfoStr;
		Param->Parameter.BackResult = BackResult;
		ExecuteFunction(Agreement, Param);
		return Param;
	}

既然是带返回值的,那就声明一个变量来接收。

传入 bool 值为 true,并且添加一条 Debug 语句方便查看效果。并且最后要删除掉接收的变量,释放资源。

LifeCallActor.cpp

void ALifeCallActor::DDEnable()
{

	// 修改原来的调用语句如下
	TestReflectParam* ResultParam = TestReflectRT(ModuleIndex, "TestReflect", 13, "Happy", true);

	DDH::Debug() << "ResultParam --> " << ResultParam->BackResult() << DDH::Endl();

	delete ResultParam;
}

编译后运行,可见最后的输出结果是 false,说明用引用来充当返回值的方法成功了。

在这里插入图片描述

对象调用所属模组以外的模组的方法

前面我们尝试的两个情况都是 “对象调用所属模组的方法”,接下来我们试一下通过指定目标模组来调用它的方法。

LifeCallActor.cpp

void ALifeCallActor::DDEnable()
{

	// 修改原来的调用语句如下
	// 通过枚举值转整型来选中目标模组 HUD
	TestReflectParam* ResultParam = TestReflectRT((int32)ERCGameModule::HUD, "TestReflect", 13, "Happy", true);

	DDH::Debug() << "ResultParam --> " << ResultParam->BackResult() << DDH::Endl();

	delete ResultParam;
}

对象只属于一个模组,如果要让对象调用模组的同级,那就要经过上一层的中央模组来作中介。不过接口 DDOO 只保存了所属模组和 Driver,所以我们可以通过 Driver 调用中央模组,再调用目标模组的方法。

来到中央模组,声明一个执行反射过程的方法。

DDCenterModule.h

public:

	// 执行反射方法
	void AllotExecuteFunction(DDModuleAgreement Agreement, DDParam* Param);

DDCenterModule.cpp

void UDDCenterModule::AllotExecuteFunction(DDModuleAgreement Agreement, DDParam* Param)
{
	// 如果传进来的模组序号 不超过 中央模组保存的模组数量 并且 目标模组存在
	if (Agreement.ModuleIndex < ModuleGroup.Num() && ModuleGroup[Agreement.ModuleIndex])
		ModuleGroup[Agreement.ModuleIndex]->ExecuteFunction(Agreement, Param);
	// 否则就返回调用结果为 缺失模组
	else
		Param->CallResult = ECallResult::NoModule;
}

来到 Driver 添加一个传递执行反射的方法。

DDDriver.h

public:

	// 执行反射方法
	void ExecuteFunction(DDModuleAgreement Agreement, DDParam* Param);

DDDriver.cpp

void ADDDriver::ExecuteFunction(DDModuleAgreement Agreement, DDParam* Param)
{
	Center->AllotExecuteFunction(Agreement, Param);
}

在 DDOO 补充执行反射方法的逻辑:如果传进来的模组不是所属模组,就让 Driver 调用执行反射的方法。

DDOO.cpp

void IDDOO::ExecuteFunction(DDModuleAgreement Agreement, DDParam* Param)
{
	if (Agreement.ModuleIndex == ModuleIndex)
		IModule->ExecuteFunction(Agreement, Param);
	// 补充
	else
		IDriver->ExecuteFunction(Agreement, Param);
}

编译后运行游戏,可以看见原来 Debug 输出的 “13 ; Happy ; Center” 变成了 “13 ; Happy ; HUD”。说明对象通过 Driver -> 中央模组 -> 目标模组 的反射调用成功了。

定义更加通用的宏来优化反射方法

前面我们在 LifeCallActor 里专门声明了一个结构体和方法来支持执行反射方法,看起来还是比较繁琐的。所以我们接下来尝试利用宏来让这个过程变得更通用。

来到 DDDefine 里面定义这个宏。

DDDefine.h

UCLASS()
class DATADRIVEN_API UDDDefine : public UObject
{
	GENERATED_BODY()
};

// 宏定义必须是一行的,为了不至于太长,可以在每行结尾都加个反斜杠来承接下一行
// FuncName 代表函数名称,ParamType 代表参数类型,ParamName 代表参数名字
// FuncName##Param 代表函数名跟这个 Param 连接起来,如果 FuncName 是 AddOn 那等同于 AddOnParam
#define DDMODFUNC_THREE(FuncName, ParamType1, ParamName1, ParamType2, ParamName2, ParamType3, ParamName3);	\
	struct FuncName##Param : DDParam	\
	{	\
		struct	\
		{	\
			ParamType1 ParamName1;	\
			ParamType2 ParamName2;	\
			ParamType3 ParamName3;	\
		} Parameter;	\
		ParamType1 ParamName1() { return Parameter.ParamName1; }	\
		ParamType2 ParamName2() { return Parameter.ParamName2; }	\
		ParamType3 ParamName3() { return Parameter.ParamName3; }	\
		FuncName##Param() { ParamPtr = &Parameter; }	\
	};	\
	FuncName##Param* FuncName##RT(int32 ModuleIndex1, FName FunctionName, ParamType1 ParamName1, ParamType2 ParamName2, ParamType3 ParamName3)	\
	{	\
		DDModuleAgreement Agreement;	\
		Agreement.ModuleIndex = ModuleIndex1;	\
		Agreement.FunctionName = FunctionName;	\
		FuncName##Param* Param = new FuncName##Param();	\
		Param->Parameter.ParamName1 = ParamName1;	\
		Param->Parameter.ParamName2 = ParamName2;	\
		Param->Parameter.ParamName3 = ParamName3;	\
		ExecuteFunction(Agreement, Param);	\
		return Param;	\
	}	\
	void FuncName(int32 ModuleIndex1, FName FunctionName, ParamType1 ParamName1, ParamType2 ParamName2, ParamType3 ParamName3)	\
	{	\
		DDModuleAgreement Agreement;	\
		Agreement.ModuleIndex = ModuleIndex1;	\
		Agreement.FunctionName = FunctionName;	\
		FuncName##Param* Param = new FuncName##Param();	\
		Param->Parameter.ParamName1 = ParamName1;	\
		Param->Parameter.ParamName2 = ParamName2;	\
		Param->Parameter.ParamName3 = ParamName3;	\
		ExecuteFunction(Agreement, Param);	\
		delete Param;	\
	}

LifeCallActor.h

protected:

	DDMODFUNC_THREE(TestReflect, int32, Counter, FString, InfoStr, bool, BackResult);
	// 去掉原来的一大段反射调用代码

编译后运行游戏,如果左上角输出跟上一次运行游戏一样,就说明代码无误,利用宏简化执行反射方法成功。

接下来我们试一下定义一个不传参数的宏。

DDDefine.h

#define DDMODFUNC(FuncName);	\
	struct FuncName##Param : DDParam	\
	{	\
		FuncName##Param() { ParamPtr = NULL; }	\
	};	\
	FuncName##Param* FuncName##RT(int32 ModuleIndex1, FName FunctionName)	\
	{	\
		DDModuleAgreement Agreement;	\
		Agreement.ModuleIndex = ModuleIndex1;	\
		Agreement.FunctionName = FunctionName;	\
		FuncName##Param* Param = new FuncName##Param();	\
		ExecuteFunction(Agreement, Param);	\
		return Param;	\
	}	\
	void FuncName(int32 ModuleIndex1, FName FunctionName)	\
	{	\
		DDModuleAgreement Agreement;	\
		Agreement.ModuleIndex = ModuleIndex1;	\
		Agreement.FunctionName = FunctionName;	\
		FuncName##Param* Param = new FuncName##Param();	\
		ExecuteFunction(Agreement, Param);	\
		delete Param;	\
	}

在模组类添加一个不带参数的、供调用的方法。

DDModule.h

public:

	UFUNCTION()
	void TestNoParam();

DDModule.cpp

void UDDModule::TestNoParam()
{
	DDH::Debug() << "No Param" << DDH::Endl();
}

在 LifeCallActor 来调用这个宏。

LifeCallActor.h

protected:

	DDMODFUNC(HappyFunc);

LifeCallActor.cpp

void ALifeCallActor::DDEnable()
{

	//TestReflectParam* ResultParam = TestReflectRT((int32)ERCGameModule::HUD, "TestReflect", 13, "Happy", true);

	//DDH::Debug() << "ResultParam --> " << ResultParam->BackResult() << DDH::Endl();

	//delete ResultParam;

	HappyFunc((int32)ERCGameModule::HUD, "TestNoParam");
}

编译后运行游戏,可以看到原来 Debug 语句输出 “13 ; Happy ; HUD” 的位置变成了 “No Param”,说明通过反射调用不带参数的方法也成功了。

给 DDDefine 添加剩余的代码。不过内容太多,笔者这里就不贴出来了。建议是复制老师的 Github 项目代码 或者是备份项目里的代码。复制完毕后大概是 547 行左右。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
UE4(Unreal Engine 4)是由Epic Games开发的一款领先的游戏开发引擎。对于初学者来说,UE4为其提供了一系列易于理解和学习的教程和资源。 首先,Epic Games官方网站上有专门针对初学者的入门教程和学习路径。你可以通过他们的学习资源,了解UE4的基础知识、工作流程和常用工具等。这些教程包含了视频教程、文档和范例项目等,可以帮助你快速入门。 其次,Epic Games的学习资源库中有大量的免费教学资源和示例项目。这些资源涵盖了不同类型的游戏开发,如虚幻关卡设计、角色和动画、蓝图脚本编程等。你可以通过这些资源学习到UE4的各个方面,提高自己的技能。 此外,还有许多在线教育平台提供关于UE4教程课程。例如,Udemy、Coursera和Pluralsight等平台上都有丰富的UE4开发课程。这些课程由经验丰富的教育者和开发者提供,可以根据自己的需求选择适合的课程进行学习。 最后,UE4社区是一个非常有价值的资源,你可以在其中与其他开发者交流和分享经验。在论坛和社交媒体中,你可以提问、寻找答案,还可以参与讨论和分享你自己的项目。社区不仅可以扩展你的知识,还可以帮助你建立人脉并获得反馈。 总之,UE4开发教程推荐的资源很多,包括官方教程、免费资源、在线课程和社区交流等。通过系统学习和实践,你可以逐步掌握UE4开发技能,并在游戏开发领域中展开你的创作。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值