【UE5】UE5 Dedicated Server专用服务器与网络同步

这篇文章是个人在学习UE4的网络同步方面的内容的一些记录,因为UE5出来了,顺带也一起熟悉熟悉UE5,所以也就直接使用UE5来实践了。说句实在话,UE4网络同步这块确实挺难的,我自己在网上搜索的博客的内容要么停留在很浅层面只搭建一个ds服务器,要么就直接深入源码去剖析UE4的网络机制,处在中间层面的内容少之又少,不得不自己好一阵研究,强烈推荐这几位博主的博文,我在学习的过程中也从中受益匪浅。

Ken_An风蚀之月刘东无敌还有我师傅推荐给我的一本关于UE4网络的PDF


一、什么是Dedicated Server

UE5除了NaniteLumen两个比较大的技术改动之外,其他的部分实际上和UE4基本是一致的,所以虽说使用的是UE5来搭建Dedicated Server,实际操作和UE4没什么区别。

事实上UE5有提供两种服务器Dedicated Server和Listen Server,Dedicated Server是专用服务器,服务器和客户端是剥离开来的,而Listen Server则是类似局域网联机一般的,使用其中某一个客户端作为服务器的模式。

这里就谈到游戏网络同步的方式了,游戏网络同步的方式一般分为两种方式,P2P和C/S,P2P即每一台客户端之间互相直连,这种方式的同步极为复杂,对联机游戏来说基本已经淘汰了。C/S即经典的客户端/服务器模式,C/S又可细分为两种,一种是由一个客户端做主机,其他的客户端连接这个主机来进行网络同步,主机即拥有服务器功能也拥有客户端功能,这种模式多用于局域网联机,这种模式中每一个客户端都可以成为主机;一种是单独剥离服务器的功能,服务端只处理服务器相关的业务,所有的客户端都通过连接服务器来进行网络同步,同时客户端只留下客户端相关的业务,这就是网游中最常用的模式了。

首先我们需要明确一个认知—UE5的客户端代码和服务器代码是一体的,即两端的代码实际上是混杂在一起的。我们经常听到一种说法叫“前后端分离”,就是服务器干服务器的,客户端干客户端的,二者各为两个工程互不干扰,互相之间通过TCP或者UDP进行通信。而UE5的模式则是客户端和服务器不分离,同在一个工程之中,互相之间的代码使用宏来区分。两种方式各有各的好处。

我们这里要学习的ds服务器就是从这个一体的工程中单独剥离出来的一个服务器,为什么要剥离呢?如果不剥离,网络同步就变成了C/S模式的第一种情况了,但是大多数情况下服务器是不需要场景渲染,人物控制这些客户端业务的,剥离出来可以减轻服务器的运行压力。

Dedicated Server服务器简称ds服务器,是UE5用于解决FPS同步问题的一种专用服务器,UE5在UDP上自己做了一层根据游戏特点专门优化的网络协议(epic不愧是fps起家的😂),专门用于ds通信,ds服务器的一大特点就是客户端和服务器共享一份代码,因为高性能的同步需要各种客户端预测和回溯的方法,可以很好的解决同步延迟问题。但是UE5ds服务器承载量不高,不适合用于需要连接大量客户端的场景。


二、搭建DS服务器

暂时我们先搭一个最简单的,只有客户端连接功能的ds服务器,后面我们再慢慢迭代的往里面加功能。

1.使用源码编译UE5引擎

下载源码

直接zip下载

git克隆

git clone记得要切换分支到ue5-early-access这是抢先体验版的源码。

下载依赖项

运行Setup.bat,要下载挺多东西的,得等一段时间。

生成VS工程文件

运行GenerateProjectFiles.bat生成VS工程文件

需要注意的是,UE5只支持VS2019,所以编译源码之前得先把VS2019安装好。

这时有可能会报路径过长的错误,只需要把引擎剪贴到盘符根目录去就行了。

跑完之后工程目录下就会出现一个UE5.sln文件,使用VS2019打开即可编译了。

2.为什么需要使用源码编译的UE5

因为我们编译ds服务器需要使用Development Server模式编译,而使用epic启动器下载的UE5创建的工程没有这个模式,只有自己使用源码编译的UE5创建的工程才有这个模式

3.编译DS服务器

如果用UE5创建C++工程报.net版本过低,就去VS把对应的.net版本下下来即可。

我们需要分别为Server端,Client端和过渡时分别创建一个默认地图,所以我们创建三个地图

然后再Project Setting/Map&Mod中分别如下配置

在关卡蓝图的tick事件上分别编写一些字符串打印,以便后面观察地图的切换。

我这里直接打印了地图名字:

顺带提一句,如果创建工程时不是创建的C++工程,而是空工程或者纯蓝图工程则需要随便创建一个C++类来打开VS工程。

据说如果不创建一个C++类就直接去编译ds可能编译不过,我这里是直接创建了一个GameInstance再编译的,所以也就没有碰到这个问题,想想UE5应该把这个bug解决了吧,如果碰到了这个问题就去创建一个类再编译吧。

在VS里把编译模式更换成Development Server,然后手动添加一个xx.Target.cs文件,目前内容比较简单,我们只需要拷贝一份UE5自动生成xxEditor.Target.cs文件更名为xxServer.Target.cs,然后把里面的Editor字样都换成Server就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
using UnrealBuildTool;
using System.Collections.Generic;

public class LSPTetrisClientServerTarget : TargetRules
{
public LSPTetrisClientServerTarget( TargetInfo Target) : base(Target)
{
Type = TargetType.Editor;
DefaultBuildSettings = BuildSettingsVersion.V2;
ExtraModuleNames.AddRange( new string[] { "LSPTetrisClient" } );
}
}

然后我们手动将文件添加到工程里。

最后编译,编译事件比较长,需要耐心等待一下,编译完之后我们就可以在工程目录Binaries\Win64下看到编译出来的exe文件了。

如果编译出来没有的话,就重新选择一下Switch Unreal Engine version,然后再编译。

此时的exe还是不能运行的,因为它需要依赖一些客户端的代码,所以需要等客户端打包出来之后,放到客户端的文件夹下运行。

4.打包客户端

把VS编译模式更改成Development Editor,可以直接从vs启动引擎,也可以通过uproject启动引擎,然后打包工程,顺带一提,UE5把打包选项更换到了这个位置

到这一步就可以把之前编译出来的ds服务器的exe拷贝到打包出来的客户端目录里了..\Binaries\Windows\LSPTetrisClient\Binaries\Win64

直接双击启动ds服务器,服务器是没有界面的,会直接在后台运行,如果想要关闭,就得去任务管理器关闭了,想要有界面有两种方式启动,其一,通过cmd启动,给exe传入log参数

1
start LSPTetrisClientServer.exe -log

如果没有进入xxServer.exe所在文件夹记得使用绝对路径;

其二,为xxServer.exe创建快捷方式,在右键/属性/快捷方式/目标,后面加-log,需要用空格隔开,通过快捷方式启动。

5.客户端连接ds服务器

启动ds服务器,再启动客户端,这里我在局域网里用两台电脑测试,做服务器的主机ip为10.14.32.50,做客户端的主机ip为10.14.99.156。

将打出来的包拷贝到另一台电脑里,如果直接运行Windows\LSPTetrisClient\Binaries\Win64下的客户端exe,提示缺少dll无法运行的话,UE5还在包的根目录贴心的准备了一个安装运行环境的exe,双击把环境安装好就可以运行了。

测试结果:

启动ds服务器时,可以看到服务器加载了默认的ServerMap

然后我们再另一台电脑上启动客户端,此时客户端加载了在项目中配置的默认地图

由于我们没有在地图中加任何Actor,所以地图是一片黑,后面我们再往里面加东西,我们也没有写连接ds的逻辑,所以这里我们使用控制台来连接ds,在客户端窗口按下~这个键,输入

1
open 10.14.32.50//这是我的运行ds服务器主机的IP

然后我们就可以看到客户端同步到服务器的地图了

并且在ds服务器中也能看到有客户端连接进来

6.实现人物同步

现在我们开始迭代下一个版本,最基础的人物控制的同步。首先向工程中添加第三人称模板进来。

这时可能会添加不了或者添加进去了有一些东西找不到,这是因为我们在编译引擎时只编译了引擎没有编译Programs下面那一堆的东西,直接再编译一次整个解决方案就好了。

向场景中添加内容

现在我们的场景还是一片黑,因为我们的场景里面啥都没有,打开预设面板

搜索BP_Sky_SphereDirectionalLight,将天空球和平行光添加到场景里,现在场景就变成了我们熟悉的界面了。分别把ClientMap和ServerMap都布置好,TransitionMap就不需要了,ServerMap里面还需要添加一个Plane以便人物生成之后有地方站立,和一个PlayerStart控制人物的生成位置。

编写连接ds服务器的逻辑

然后我们在GameInstance里面编写连接ds服务器的逻辑,逻辑也很简单,直接调用一个内置api就好了。

1
2
3
4
void ULSPTetrisGameInstance::ConnectDs(FString ip)
{
GetWorld()->GetFirstPlayerController()->ClientTravel(ip, TRAVEL_Absolute);
}

ClientTravel是PlayerController里的一个函数,所以需要先通过World获取PlayerController来调用。

ClientTravel有四种连接模式,我们这里使用绝对网址即IP,其他的模式可以直接去官方文档了解。

然后我们再创建一个UMG来输入IP地址并可以点击登录。

SetFullscreenMode设置游戏以窗口模式运行。

然后UMG的创建就直接放在ClientMap的关卡蓝图里了。

然后我们再重新编译一个ds服务器和客户端,如果没有更改服务器相关的代码是不需要重新编译ds服务器的,这里保险起见我还是都重新编了一个。

运行效果

机器有点不行,跑起来卡卡的。


三、Actor同步

1.普通Actor的网络同步

使用第三人称模板之所以这么容易就把人物同步给实现了,是因为里面很多设置模板都已经给我们设置好了,那么现在我们继续迭代,实现我们自定义的Actor的网络同步。

比如我们按下E键在角色前方创建一个物理方块,如何将这个物理方块从创建到掉落的整个过程在所有客户端同步?

首先我们为方块创建一个出生点,我在人物下面添加一个Sphere组件,去除Sphere的staticmesh使它不显示,然后将相对位置移动到人物前上方用于作为生成方块的位置,

然后我们创建一个Actor,命名为Cube,给与一个cube的static mesh,给予一个box collision组件,box collision和static mesh都要勾选Simulate Physics和Mass给与Cube物理效果和重力效果。最重要的一步——–勾选Replication/Replicates,使Cube可以在各个客户端之间复制,否则Actor是无法进行同步的。

在Cube里只做一件事,就是生成后3秒自动销毁,给一个自定义事件执行销毁逻辑,并在构造函数里调用。

image-20210802215806819

image-20210802215735373

然后我们在ThridPersonCharacter中也添加一个自定义事件,用于执行创建Cube的逻辑,然后在按键E的检测事件中调用这个事件。

image-20210802220238583

蓝图的属性复制

为什么一定要将Cube的创建封装在一个自定义事件里?因为只有自定义事件才可以选择是事件的执行位置。

image-20210802220402817

  • Not Replicated:不进行网络复制;
  • Multicast:由服务器调用,在服务器和所有客户端执行;
  • Run on Server:在客户端中调用,在服务器中执行;
  • Run on owning Client:只在有所有权的客户端执行,执行结果不会同步到其他客户端。

执行效果

这里加了一个人物触碰时更改cube颜色的效果,以便同步时显示的更明显。

Actor的所有权—ROLE

UE将Actor的控制权分成了三类,分别是:

  • ROLE_None:这个None就是我们平常理解的None,不属于下面三种的都是None

  • ROLE_Authority:服务器拥有所有Actor的控制权,即所有的Actor在服务器端的控制权都是ROLE_Authority

  • ROLE_AutonomousProxy:客户端对本地Actor拥有这个控制权

  • ROLE_SimulatedProxy:客户端对网络Actor,即其他端的Actor,拥有这个控制权

这个三个属性是UE设计Actor时就为Actor设计好的固有属性,可以用于判断一个Actor所在位置,因为UE的服务器代码和客户端代码是一体的,所以Actor设计这个属性是十分必要的。

我们可以用一个示例来具体观摩一下,我们将项目继续迭代,现在我们在角色的头顶添加一个TextRender,然后把GetLocalRole的控制权名称设置到TextRender上。

然后运行起来

可以看到,在服务器端的窗口上所有的人物都显示的是ROLE_Authority,而在两个客户端的窗口上只有自己控制的人物显示ROLE_AutonomousProxy,其他的都显示ROLE_SimulatedProxy,尽管其中有一个人物是服务器生成的,但对于这个客户端来说它也属于其他端的Actor。

2.自定义Pawn的网络同步

关于自定义Pawn的网络同步,在网上的资料简直少得可怜,项目迭代到这里,也是磕磕绊绊搞了好一阵子。

这里为了和第三人称模板完全脱离关系,我们创建一些自己的GamePlay。

  • 创建一个自己的GameMode:LSPGameMode
  • 创建一个自己的PlayerController:LSPPlayerController
  • 创建一个自己的可控制的Pawn:Ball

然后把GameMode换成LSPGameMode,把LSPGameMode的PlayerControllerClass换成LSPPlayerController,把DefaultPawnClass换成Ball。

实现一个可控制滚动的Pawn

这里推荐跟着UE4中的Rolling模板来做,在UE5中这个模板已经被移除了。

Pawn的结构

这里为了看起来滚动明显一点就给了一个Cube的Mesh。

Pawn的蓝图

这里PlayerController的硬件输入配置还是沿用第三人称模板预定义好的那些配置。

这里单独把角色控制封装在一个事件里也是为了让控制Pawn滚动的蓝图跑在服务器上,这样服务器才能把一个客户端的运动同步到其他的客户端。

这里Speed和JumpImpulse的值要给大一点,否则滚跳起来没什么效果。

在实现可滚动的方块时,出现了摄像机和方块一起滚动的问题,这是我们需要把SpringArm下的这些勾都取消掉。

到这里一个可控制的滚动方块就成了,下面是运行效果

然后打包客户端,按理来说ds服务器是不需要再编译了的,因为服务器也是用的客户端的代码,编译出来的exe也就是提供一个服务器的入口,如果不行的话建议也再编译一次服务器。

运行效果

四、Actor的属性同步

1.简单属性的同步

前面我们都是停留在角色移动的网络同步上,而实际游戏中除了最基础移动同步外,还有很多其他的属性数据也需要同步,比如当某一个客户端的角色换了一个皮肤之后,其他的客户端里这个角色也应该同步显示新的皮肤。

如果要实现客户端的属性发生改变的同时属性值同步到其他客户端中,我们需要铭记两点:

  • 属性的Replication应勾选Replicated或RepNotify;
  • 修改属性的代码或蓝图必须运行在服务器上。

Actor的Replication

Replication是任何派生于UObject的类的变量的固有属性,用于标识这个变量是否允许网络同步。在蓝图中Replication属性有三种可选值

  • None:属性不允许网络同步;
  • Replication:属性允许网络同步;
  • RepNotify:属性允许网络同步,同时绑定一个回调函数,属性发生变化时回调,在蓝图中回调函数会自动创建在FUNCTION中并以OnRep_开头,以属性名结尾,如属性pos的回调函数为OnRep_pos

现在我们继续迭代项目,我们在自定义Pawn中添加一个整型变量Time,这里我选择将Time的Repliction属性设置成RepNotify,以便Time发生变化时修改TextRender的Text。然后在Pawn中添加一个TextRender用于显示Time的值,我们把TextRender放在SpringArm下,这样文字就不会跟着方块滚动。

然后在Ball中添加如下蓝图:

这里要注意的一点就是Time值得修改要运行在服务器上,所以SetTime节点我用来一个运行在服务器上的自定义事件封装。

OnRep_Time函数

Client Widget的蓝图

这里使用的是Editable Text并添加了一个On Text Commited事件,这个事件在输入完成后按下Enter键触发。

然后我们跑起来看一下效果,为了方便起见,这里我就直接使用Listen Server模式来运行了

1.复杂属性的同步

按理来说,将属性的Replication设置为Replicated或RepNotify,然后在服务器上修改属性值,属性就应该由服务器广播到所有客户端同步,然而我在测试同步一个UObject类作为Actor的属性时,发现仅仅通过上面的步骤无法在所有的客户端同步这个属性。如果将修改操作封装在一个Run on Server事件中,那么只有服务器中对应的Actor的值被修改了却没有同步到其他的客户端,如果将修改操作封装在一个Multicast事件中,则只有在服务器中做的修改才会被同步。我这里通过两层封装解决了这个问题,至于这个问题具体是什么原因造成的,暂时还没弄明白。

具体迭代步骤:

创建一个继承至UObject的类—BallData用于存储自定义Pawn—Ball的数据。

在BallData中创建一个FString类型的数组—SpeekStr,初始化三组数据,并设置Replication为Replicated。

在Ball中创建一个BallData类型的变量,设置Replication为Replicated。

在Ball中增加如下蓝图

我在BeginPlay中构建BallData对象,并启动每3秒读取一次数组值的循环事件。

这里我们要同步的是BallData类型变量Data中的SpeekStr数组,对数组的元素增加操作我使用两成封装,第一层直接将操作封装在一个Multicast事件中,然后将Multicast事件封装在一个Run on Server事件中,这样MultiSpeek将运行在服务器中,然后再有服务器调用广播事件AddSpeek,将对数组的元素增加操作广播到所有的客户端中。

执行效果

五、C++中的网络同步

C++的网络同步在函数层面主要就是UFUNCTION宏的三个参数Server、Client和NetMulticast,而在变量层面主要UPROPERTY宏的参数Replicated。

一般情况下我们的网络同步都是在Actor中进行,这里我直接深一个层次,在继承自UObject的UActorComponent组件中来进行网络同步。其实大部分操作和Actor基本是一样的,只有一小部分的区别。

这里我创建一个继承自UActorComponent的组件UChatComponent,并把ChatComponent组件添加到Ball身上。

然后我这里先上一份源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
//ChatComponent.h
#pragma once
#include "Components/TextRenderComponent.h"
#include "Blueprint/UserWidget.h"
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "ChatComponent.generated.h"


UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) )
class LSPTETRISCLIENT_API UChatComponent : public UActorComponent
{
GENERATED_BODY()

public:
UChatComponent();
UPROPERTY(Replicated)
int cubeCount = 0;
UPROPERTY(ReplicatedUsing = OnRep_cubeCountTotal, EditAnywhere, BlueprintReadWrite)
int cubeCountTotal = 20;
UPROPERTY(EditAnywhere,BlueprintReadWrite)
FColor TextColor = FColor(0);
private:
APlayerController* playerPtr;
UClass* Cube;
protected:
virtual void BeginPlay() override;

public:
virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;

UFUNCTION(Server,Reliable)
void SpwanCube();
UFUNCTION(Client,Reliable,BlueprintCallable)
void SwitchTextRenderColor();
UFUNCTION(NetMulticast, Reliable)
void NetMulticastSetTextRenderColor(UTextRenderComponent* textRender);
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
UFUNCTION()
void OnRep_cubeCountTotal();
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
//ChatComponent.cpp
#include "Replication/ChatComponent.h"
#include "InputCoreTypes.h"
#include "Net/UnrealNetwork.h"

UChatComponent::UChatComponent()
{
PrimaryComponentTick.bCanEverTick = true;
SetIsReplicated(true);
}

void UChatComponent::BeginPlay()
{
Super::BeginPlay();
playerPtr = GetWorld()->GetFirstPlayerController();
Cube = LoadClass<AActor>(NULL, TEXT("Blueprint'/Game/Map/Cube.Cube_C'"));
}

void UChatComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);

if (playerPtr->IsInputKeyDown(EKeys::LeftMouseButton))
{
SpwanCube();
}
SwitchTextRenderColor();
}

void UChatComponent::SwitchTextRenderColor_Implementation()
{
if (GetWorld()->IsServer())
{
if (cubeCount >= cubeCountTotal)
{
cubeCount = 0;
TArray<UTextRenderComponent*> comps;
GetOwner()->GetComponents(comps);
if (comps.Num() != 1)
{
return;
}
UTextRenderComponent* textRender = comps[0];
NetMulticastSetTextRenderColor(textRender);
}
}
}

void UChatComponent::SpwanCube_Implementation()
{
if (Cube && GetWorld())
{
GetWorld()->SpawnActor<AActor>(Cube, GetOwner()->GetActorTransform());
cubeCount++;
}
}

void UChatComponent::NetMulticastSetTextRenderColor_Implementation(UTextRenderComponent* textRender)
{
textRender->SetTextRenderColor(TextColor);
}

void UChatComponent::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(UChatComponent, cubeCount);
}

void UChatComponent::OnRep_cubeCountTotal()
{
if (cubeCountTotal >= 40)
{
FString msg = FString::FromInt(cubeCountTotal);
GEngine->AddOnScreenDebugMessage(-1, 5, FColor::Red, *msg);
}
}

下面我们一行一行的来解析

1.UNFUNCTION(Server, Reliable)

Server表示这个函数在客户端调用在服务器执行,和前面的Run on Server蓝图事件对应。

2.UFUNCTION(Client, Reliable)

Client表示这个函数在服务器调用在拥有这个Actor或UObject的客户端执行,函数执行的结果只在执行函数的客户端显示,不会同步到其他的客户端,和前面的Run on owning Client蓝图事件对应。

3.UFUNCTION(NetMulticast, Reliable)

NetMulticast表示这个函数在服务器调用并在服务器和所有与服务器连接并拥有这个Actor或Object的客户端上执行,和前面的Multicast蓝图事件对应。

4.Reliable

Server,Client和Multicast都需要搭配Reliable使用,否则编译无法通过,Reliable表示函数可以在网络空间进行复制,并会忽略带宽或网络错误而被确保送达,这保证了RPC的安全性。

5.RPC函数的实现

当一个函数被标识了Server,Client或Multicast宏后,函数的实现就和普通函数不一样了,函数实现的函数名和函数定义的函数名发生了变化,函数实现的函数名需要加一个_Implementation后缀,否则编译会报错。

6.重写GetLifetimeReplicatedProps函数

在UE5中,Actor是默认拥有网络同步能力的,但是UObject没有默认拥有网络同步,所以如果我们不重写GetLifetimeReplicatedProps函数,尽管成员函数标识了Server,Client或NetMulticast宏,UObject也依旧不具备网络同步的能力。

7.DOREPLIFETIME

除了DOREPLIFETIME宏UE5还有一个DOREPLIFETIME_CONDITION宏,二者的实机作用都是一样的,都是用于注册属性的条件复制的,当我们类里的变量

这里我在BeginPlay里加载了一个在前面第三节创建的Cube蓝图类,然后在SpawnCube函数中实现Cube的创建过程,这样Cube的创建过程就跑在了服务器上,而Cube勾选了Replicated,这样Cube的创建过程就可以通过服务器同步到所有的客户端中了。

然后我在SwitchTextRenderColor函数中修改Ball下面的UTextRenderComponent组件的字体颜色。由于SwitchTextRenderColor是在服务器调用在客户端执行,所以函数里面使用了GetWorld()->IsServer()来判断执行端是否是服务器,如果不做判断的话,函数会在服务器上调用但是却会在所有拥有UChatComponent组件的Ball上执行,而我要的效果是当服务器中Ball发射了超过20个Cube时只修改服务器上的Ball的Textrender的字体颜色,并把这个效果同步到其他客户端中对应的Ball上。

所以我在SwitchTextRenderColor中有调用了一个NetMulticast函数NetMulticastSetTextRenderColor以将这个执行结果的变化同步到其他的客户端中。

我们来看一下效果

可以看到只有在服务器中Ball执行的时候颜色才会发生变化,并将变化同步到了所有的客户算中。

这里在UI里加了一个可以更改cubeCountTotal值的蓝图,和颜色修改后自动恢复过来的蓝图,蓝图比较简单就不贴出来了。


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!