Skip to content

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,使用编辑工具或记事本打开,找到最后一行:

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/.cppSDK入口封装类,负责初始化配置和登录流程
KBEngineArgs.h/.cpp初始化参数(IP、端口、客户端类型、加密方式等)
NetworkInterfaceTCP.h/.cpp / NetworkInterfaceKCP.h/.cppTCP和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/.cppFirstEntity实体的抽象基类(自动生成)
EntityCallFirstEntityBase.h/.cppFirstEntity的远程调用代理(自动生成)
ikcp.c/.hKCP可靠UDP协议实现

生成规则:

1、服务端定义了有客户端部分的实体(声明了hasClient="true"的),则会生成类似实体名+Base.h/.cpp的文件,它是一个抽象类,我们只需继承它、实现它,并使用实体名为类名即可,如这里的FirstEntityBase,该类中会包含def中声明的客户端方法,如本教程中的onEnteronSay

2、被生成的实体,会对应包含一个类似EntityCall+实体名+Base.h/.cpp的文件,该文件是对应实体的EntityCall的实现。该类中会包含该实体的被暴露给客户端的通讯方法(被设置了Exposed标签的),如FirstEntity在def中声明的say方法。

4. 集成SDK到UE项目

将生成的kbe_cxx_plugins文件夹放置到Source/<YourProject>/目录下,然后在<YourProject>.Build.cs中添加头文件路径:

csharp
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:

cpp
#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(核心方法):

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:

cpp
#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:

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:

cpp
#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:

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

cpp
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方法向服务器发送请求。

对!刚才的onEnteronSay,包括这里的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总结》中进行回顾、梳理和总结吧。

点我进入《GetStarted总结》