UnrealEngine的实现
本文将会使用UnrealEngine 5来完成上一节中的客户端实现。
UE版本
本教程中使用的版本为UnrealEngine 5.6,C++语言。
先附上客户端UE5的源代码:点我下载(里面包含了对应的服务端源代码)。
UnrealEngine 5是Epic Games推出的一款强大的实时3D创作工具,广泛用于游戏、影视、建筑可视化等领域。本教程使用C++进行开发。
第一步:生成客户端SDK
KBEngine提供了专属的SDK生成器,它是为了方便开发者而专门制作的工具,使开发者面对不同的客户端引擎时都可以游刃有余。
引擎提供的SDK生成器会自动根据引擎开发过程中涉及的通讯协议、数据结构(包括自定义的数据结构)、Entity实体定义等方面与客户端SDK进行一一对应,保证高度一致性。
介绍完SDK生成器,我们来看看如何修改配置,使其对应UnrealEngine。
1. 修改SDK生成路径:
在本项目的资产库"getstarted_assets"下,找到gensdk.bat,使用编辑工具或记事本打开,找到最后一行:
start %KBE_BIN_PATH%/kbcmd.exe --clientsdk=cxx --outpath=%curpath%/kbe_cxx_plugins其中: clientsdk:指定输出SDK的客户端类型,这里填写cxx。UE使用的是C++ SDK,与C++控制台使用同一套SDK,生成器会自动选择合适的生成模板和逻辑进行生成。
outpath:指定SDK的输出路径。请确保路径是在你UE工程的对应目录下。
TIP
如果引擎脚本开发工程师和客户端工程师是同一人,或者为了立刻验证SDK的结果是否如预期,我们建议outpath设置成UE工程下的对应目录,如:your_project/Source/your_project/kbe_cxx_plugins。每次生成SDK后可直接切回客户端进行测试,减少了重复粘贴复制的操作。
2. 执行工具,生成SDK代码:
编辑完成后保存退出,双击执行gensdk.bat。
等待生成完毕,进入客户端对应的文件夹查看。
3. 文件夹结构介绍:
生成完成后,kbe_cxx_plugins文件夹下包含以下核心文件:
| 文件/模块 | 说明 |
|---|---|
KBEngine.h/.cpp | 核心引擎入口,KBEngineApp单例,管理连接、实体生命周期、消息分发 |
KBEMain.h/.cpp | SDK入口封装类,负责初始化配置和登录流程 |
KBEngineArgs.h/.cpp | 初始化参数(IP、端口、客户端类型、加密方式等) |
NetworkInterfaceTCP.h/.cpp / NetworkInterfaceKCP.h/.cpp | TCP和KCP双网络后端 |
Entity.h/.cpp / EntityCall.h/.cpp | 实体基类和远程调用机制 |
EntityDef.h/.cpp | 数据类型、实体方法、属性的自动生成定义 |
Messages.h/.cpp | 协议消息ID和处理器映射 |
KBEvent.h/.cpp / KBEventTypes.h/.cpp | 事件系统 |
MemoryStream.h/.cpp / Bundle.h/.cpp | 二进制序列化和消息打包 |
FirstEntityBase.h/.cpp | FirstEntity实体的抽象基类(自动生成) |
EntityCallFirstEntityBase.h/.cpp | FirstEntity的远程调用代理(自动生成) |
ikcp.c/.h | KCP可靠UDP协议实现 |
生成规则:
1、服务端定义了有客户端部分的实体(声明了hasClient="true"的),则会生成类似实体名+Base.h/.cpp的文件,它是一个抽象类,我们只需继承它、实现它,并使用实体名为类名即可,如这里的FirstEntityBase,该类中会包含def中声明的客户端方法,如本教程中的onEnter、onSay;
2、被生成的实体,会对应包含一个类似EntityCall+实体名+Base.h/.cpp的文件,该文件是对应实体的EntityCall的实现。该类中会包含该实体的被暴露给客户端的通讯方法(被设置了Exposed标签的),如FirstEntity在def中声明的say方法。
4. 集成SDK到UE项目
将生成的kbe_cxx_plugins文件夹放置到Source/<YourProject>/目录下,然后在<YourProject>.Build.cs中添加头文件路径:
PublicIncludePaths.AddRange(new string[] {
"demo_ue5_cxx",
"demo_ue5_cxx/kbe_cxx_plugins"
});
PrivateIncludePaths.AddRange(new string[] {
"demo_ue5_cxx",
"demo_ue5_cxx/kbe_cxx_plugins"
});第二步:实现Client部分
1. 客户端设计概述
服务器回顾:
先来回顾下本教程的服务器设计,我们把FirstEntity与账户入口关联,使得客户端一旦连接服务器并通过登录认证后就会创建出FirstEntity实体,此时该实体的客户端部分也会被创建。一旦创建完毕后会被立即传送到FirstSpace所在空间中去,完成后会通过onEnter的远程方法通知客户端。接着,客户端向服务器发出say请求后,服务器会进行广播,并通过客户端的onSay方法告知所有在同一空间的客户端。
客户端设计:
我们分为两个关卡(Level),一个叫做Lvl_Start起始关卡,默认打开,负责与服务器连接、登录认证。一旦成功登录并进入空间后,服务器会调用FirstEntity的客户端远程方法onEnter,从该方法的实现中让客户端进入另一个关卡Lvl_World,其负责say的发送以及处理onSay的远程调用。
UE中KBEngine SDK通过GameInstanceSubsystem机制运行——UGameKBEMain子系统在游戏启动时自动初始化,通过FTicker每帧驱动SDK的process(),确保所有逻辑在游戏线程中执行。
好了,让我们开始动手吧!
2. 实现登录场景
2.1 实现GameKBEMain(SDK入口)
首先创建一个继承自UGameInstanceSubsystem的类UGameKBEMain,作为SDK的核心管理器:
GameKBEMain.h:
#pragma once
#include "CoreMinimal.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "kbe_cxx_plugins/KBEvent.h"
#include "GameKBEMain.generated.h"
UCLASS()
class DEMO_UE5_CXX_API UGameKBEMain : public UGameInstanceSubsystem
{
GENERATED_BODY()
public:
virtual void Initialize(FSubsystemCollectionBase& Collection) override;
virtual void Deinitialize() override;
void InitWithConfig(const FString& IP, int32 Port);
// 事件回调
void addSpaceGeometryMapping(const std::shared_ptr<UKBEventData> pEventData);
void onDisconnected(const std::shared_ptr<UKBEventData>& Shared);
void onKicked(const std::shared_ptr<UKBEventData>& Shared);
void onLoginSuccessfully(const std::shared_ptr<UKBEventData>& Shared);
private:
bool Tick(float DeltaTime);
FTSTicker::FDelegateHandle TickerHandle;
};GameKBEMain.cpp(核心方法):
#include "GameKBEMain.h"
#include "kbe_cxx_plugins/KBEngine.h"
#include "kbe_cxx_plugins/KBEMain.h"
#include "kbe_cxx_plugins/KBEngineArgs.h"
#include "Kismet/GameplayStatics.h"
void UGameKBEMain::Initialize(FSubsystemCollectionBase& Collection)
{
Super::Initialize(Collection);
// 创建每帧Ticker驱动SDK的process()
TickerHandle = FTSTicker::GetCoreTicker().AddTicker(
FTickerDelegate::CreateUObject(this, &UGameKBEMain::Tick), 0.0f);
// 注册事件
KBENGINE_REGISTER_EVENT(KBEngine::KBEventTypes::addSpaceGeometryMapping,
addSpaceGeometryMapping);
KBENGINE_REGISTER_EVENT(KBEngine::KBEventTypes::onKicked, onKicked);
KBENGINE_REGISTER_EVENT(KBEngine::KBEventTypes::onDisconnected, onDisconnected);
KBENGINE_REGISTER_EVENT("onLoginSuccessfully", onLoginSuccessfully);
}
void UGameKBEMain::InitWithConfig(const FString& IP, int32 Port)
{
auto kbemain = std::make_shared<KBEMain>();
kbemain->ip = TCHAR_TO_UTF8(*IP);
kbemain->port = Port;
kbemain->clientType = EKCLIENT_TYPE::CLIENT_TYPE_WINDOWS;
kbemain->disableMainLoop = true; // 由我们自己驱动process
kbemain->init();
}
bool UGameKBEMain::Tick(float DeltaTime)
{
KBEngine::KBEngineApp::getSingleton().process();
return true; // 持续执行
}
void UGameKBEMain::addSpaceGeometryMapping(
const std::shared_ptr<UKBEventData> pEventData)
{
// 暂停事件系统,加载关卡
KBENGINE_EVENT_PAUSE();
UGameplayStatics::OpenLevel(GetWorld(), FName("/Game/Lvl_World"));
}
void UGameKBEMain::onLoginSuccessfully(
const std::shared_ptr<UKBEventData>& Shared)
{
GEngine->AddOnScreenDebugMessage(-1, 3.f, FColor::Green,
TEXT("login is successfully!(登陆成功!)"));
}要点说明:
UGameKBEMain继承UGameInstanceSubsystem,在游戏启动时自动初始化,无需手动创建。- 通过
FTSTicker每帧调用KBEngineApp::getSingleton().process(),确保SDK逻辑在游戏线程执行。 KBEMain是SDK的入口封装,设置disableMainLoop = true表示由我们自己驱动process(),而不是SDK内部开线程。addSpaceGeometryMapping回调中需要暂停事件系统(KBENGINE_EVENT_PAUSE()),加载完关卡后再恢复。
2.2 实现LoginWidget(登录UI)
使用UMG创建登录界面,挂载ULoginWidget:
LoginWidget.h:
#pragma once
#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "Components/EditableTextBox.h"
#include "Components/Button.h"
#include "LoginWidget.generated.h"
UCLASS()
class ULoginWidget : public UUserWidget
{
GENERATED_BODY()
public:
static ULoginWidget* Instance;
UPROPERTY(meta = (BindWidget))
UEditableTextBox* UsernameBox;
UPROPERTY(meta = (BindWidget))
UEditableTextBox* PasswordBox;
UPROPERTY(meta = (BindWidget))
UButton* LoginBtn;
virtual void NativeConstruct() override;
UFUNCTION()
void OnLoginBtnClicked();
};LoginWidget.cpp:
#include "UI/LoginWidget.h"
#include "kbe_cxx_plugins/KBEngine.h"
ULoginWidget* ULoginWidget::Instance = nullptr;
void ULoginWidget::NativeConstruct()
{
Super::NativeConstruct();
Instance = this;
// 绑定登录按钮点击
LoginBtn->OnClicked.AddDynamic(this, &ULoginWidget::OnLoginBtnClicked);
}
void ULoginWidget::OnLoginBtnClicked()
{
FString username = UsernameBox->GetText().ToString();
FString password = PasswordBox->GetText().ToString();
// 通过事件系统触发登录
auto pEventData = std::make_shared<UKBEventData_login>();
pEventData->username = TCHAR_TO_UTF8(*username);
pEventData->password = TCHAR_TO_UTF8(*password);
pEventData->datas = "kbengine_unity3d_demo";
KBENGINE_EVENT_FIRE_IN(KBEngine::KBEventTypes::login, pEventData);
}3. 实现FirstEntity
按照刚才的设计,登录成功后,FirstEntity的客户端部分会被创建,我们来实现FirstEntity的客户端部分:
FirstEntity.h:
#pragma once
#include "kbe_cxx_plugins/FirstEntityBase.h"
namespace KBEngine
{
class FirstEntity : public FirstEntityBase
{
public:
FirstEntity();
virtual ~FirstEntity();
virtual void __init__() override;
virtual void onEnter() override;
virtual void onSay(const KBString& content) override;
};
}FirstEntity.cpp:
#include "FirstEntity.h"
#include "kbe_cxx_plugins/KBEngine.h"
#include "kbe_cxx_plugins/KBDebug.h"
#include "kbe_cxx_plugins/EntityFactory.h"
#include "Kismet/GameplayStatics.h"
namespace KBEngine
{
FirstEntity::FirstEntity() :
FirstEntityBase()
{
}
FirstEntity::~FirstEntity()
{
}
void FirstEntity::__init__()
{
FirstEntityBase::__init__();
}
void FirstEntity::onEnter()
{
DEBUG_MSG("FirstEntity::onEnter");
// 当进入空间后,加载世界关卡
KBENGINE_EVENT_PAUSE();
// 使用AsyncTask切换到游戏线程操作
AsyncTask(ENamedThreads::GameThread, []()
{
UGameplayStatics::OpenLevel(
GEngine->GetWorld(),
FName("/Game/Lvl_World")
);
});
}
void FirstEntity::onSay(const KBString& content)
{
DEBUG_MSG("FirstEntity::onSay: %s", content.c_str());
// 在屏幕上显示收到的消息
AsyncTask(ENamedThreads::GameThread, [content]()
{
FString Msg = FString::Printf(
TEXT("onSay: %s"), UTF8_TO_TCHAR(content.c_str()));
GEngine->AddOnScreenDebugMessage(-1, 3.f, FColor::Green, Msg);
});
}
}
// 静态注册实体类型
namespace {
const bool registered = []() {
EntityFactory::instance().registerType("FirstEntity", []() {
return new KBEngine::FirstEntity();
});
return true;
}();
}onEnter:该函数名和服务端FirstEntity实体的DEF配置文件中的client部分定义的一模一样!SDK生成器在FirstEntityBase类中使用虚函数定义了该方法,并由SDK内部进行了通讯对应,我们只需在继承类中重写即可。进入空间后切换到世界关卡。
onSay:该方法也是和DEF配置文件中的client部分一样,连方法签名也一致!这里使用AddOnScreenDebugMessage在屏幕上显示收到的消息。
注意
每个被指定有Client部分的实体,必须通过EntityFactory::instance().registerType进行静态注册,且类名和实体名字一致。否则SDK无法通过实体名找到对应实现类。
4. 实现世界关卡
4.1 关卡制作
在Lvl_World关卡中,放置一个UMG Widget到视口中,包含一个Button和一个TextBlock。
4.2 实现HelloWorld Widget
void UHelloWorldWidget::OnHelloBtnClicked()
{
// 通过API:player()获得账户自己的实体
KBEngine::FirstEntity* entity =
dynamic_cast<KBEngine::FirstEntity*>(
KBEngine::KBEngineApp::getSingleton().player());
if (entity)
{
// 由于say方法是在cell上的远程方法,使用pCellEntityCall调用
entity->pCellEntityCall->say("hello world");
}
}这里主要调用了API中的player()方法获取到客户端自身的账户实体,并转成了FirstEntity类型。pCellEntityCall是对应的Cell远程调用代理,调用say方法向服务器发送请求。
对!刚才的onEnter、onSay,包括这里的pCellEntityCall->say,与服务端一一对应的这一切事情,都是由SDK生成器帮你完成的!
接下来,让我们迎来激动人心的时刻!服务器和客户端的联通验证!
第三步:验证
1. 启动引擎
在本项目的资产库"getstarted_assets"下,找到start_server.bat,并双击运行。
等待所有服务器组件的窗口都出现"Found all the components!"字样,就说明成功启动了。
2. 运行客户端
2.1 使用Visual Studio或Rider编译UE项目。
2.2 在UE编辑器中打开Lvl_Start关卡。
2.3 点击编辑器工具栏的Play按钮,启动游戏。
3. Hello world
1、启动后,出现登录窗口,随意输入账号和密码(长度都要大于4位),点击Login按钮,就会向服务器发出登录请求。
Tips
本教程中,服务端没有对账户验证做处理,所以任意的账号密码都可以登录成功。 整个过程细节,可以查看UE的Output Log窗口。
2、一旦登录成功,会切换至Lvl_World关卡,里面只有一个UI——HelloWorld按钮。
3、点击hello world按钮,会向服务端发起say的远程调用。
4、收到onSay的通知后,会在屏幕上显示出文字。
TIP
显示内容格式为"Entity: " + self.id + content,这是服务端FirstEntity中定义好的格式。
恭喜你,UnrealEngine的客户端实现已完成!
通过UnrealEngine客户端的实现,我们利用FirstEntity实体的Client部分与服务器建立了连接,并立即进入了FirstSpace所在的空间中,接着,在空间内我们向第一个实体FirstEntity进行了say的操作,并收到了onSay的广播。
这是GetStarted章节中客户端UE5的源代码:点我下载(里面包含了对应的服务端源代码)
开发者肯定对整个的通讯过程、业务流程还存在一些疑问,让我们进入下一节《GetStarted总结》中进行回顾、梳理和总结吧。
