概观
在这一章中,你将被介绍一些重要的多人游戏概念,以便使用虚幻引擎 4 的网络框架为你的游戏增加多人游戏支持。
到本章结束时,您将了解基本的多人游戏概念,如服务器-客户端体系结构、连接和参与者所有权,以及角色和变量复制。您将能够实现这些概念来创建一个自己的多人游戏。您还可以制作一个 2D 混合空间,它允许您在 2D 网格中的动画之间进行混合。最后,您将学习如何在运行时使用Transform (Modify) Bone
节点来控制骨骼网格骨骼。
在前一章中,我们完成了SuperSideScroller
游戏,并使用了 1D 混合空间、动画蓝图和动画蒙太奇。在本章中,我们将在这些知识的基础上学习如何使用虚幻引擎为游戏添加多人游戏功能。
多人游戏在过去的十年里发展了很多。诸如堡垒之夜、PUBG、传奇联盟、火箭联盟、Overwatch 和 CS: GO 等游戏在游戏社区获得了大量的人气,并取得了巨大的成功。如今,几乎所有的游戏都需要某种多人游戏体验,才能更加贴切和成功。
究其原因,是它在现有游戏玩法的基础上增加了一层新的可能性,比如可以在合作模式(也叫 co-op 模式)下和朋友一起玩,或者和来自世界各地的人对战,大大增加了一款游戏的寿命和价值。
在下一个主题中,我们将讨论多人游戏的基础。
你可能在玩游戏的时候经常听到多人游戏这个术语,但是它对游戏开发者来说意味着什么呢?多人游戏,在现实中,只是服务器和其连接的客户端之间通过网络(互联网或局域网)发送的一组指令,目的是给玩家一种共享世界的错觉。
要做到这一点,服务器需要能够与客户端对话,但也需要能够与客户端对话(客户端到服务器)。这是因为客户端通常会影响游戏世界,所以他们需要一种方法能够在玩游戏时通知服务器他们的意图。
服务器和客户端之间这种来回通信的一个例子是当玩家在游戏中试图发射武器时。请看下图,它显示了客户端-服务器交互:
图 16.1:当玩家想要在多人游戏中发射武器时的客户端-服务器交互
我们来看看图 16.1 中显示了什么:
- 玩家按住鼠标左键,该玩家的客户端告诉服务器想要发射武器。
- 服务器通过检查以下内容来验证玩家是否可以发射武器:
- 如果玩家还活着
- 如果玩家装备了武器
- 如果玩家有足够的弹药
- 如果所有验证都有效,则服务器将执行以下操作:
- 运行逻辑来扣除弹药
- 在服务器上生成投射执行元,它会自动发送给所有客户端
- 在所有客户端中的角色实例上播放 fire 动画,以确保所有客户端之间的同步性,这有助于推销这是同一个世界的想法,尽管事实并非如此
- 如果任何验证失败,服务器会告诉特定的客户端该做什么:
- 玩家死了,不要做任何事
- 玩家没有装备武器–不要做任何事情
- 玩家没有足够的弹药-播放空的咔哒声
请记住,如果您希望您的游戏支持多人游戏,那么强烈建议您在开发周期中尽快这样做。如果你尝试运行一个多人模式的单人项目,你会注意到有些功能可能只是工作,但可能大部分功能都不能正常工作或如预期的那样。
其原因是,当你在单人模式下执行游戏时,代码会在本地即时运行,但当你将多人模式加入到等式中时,你就加入了外部因素,比如一个权威服务器,它会在网络上与客户端进行具有延迟的对话,正如你在图 16.1 中看到的那样。
为了让一切正常工作,您需要将现有代码分解为以下内容:
- 只在服务器上运行的代码
- 只在客户端运行的代码
- 运行在两者上的代码,这可能需要很多时间,这取决于你的单人游戏的复杂性
为了给游戏增加多人支持,虚幻引擎 4 自带了一个已经内置的非常强大且带宽高效的网络框架,采用了权威的服务器-客户端架构。
下面是它的工作原理:
图 16.2:虚幻引擎 4 中的服务器-客户端架构
在图 16.2 中,可以看到服务器-客户端架构在虚幻引擎 4 中是如何工作的。每个玩家控制一个客户端,该客户端使用双向连接与服务器通信。服务器以一种游戏模式(只存在于服务器中)运行一个特定的关卡,控制信息流,让客户端在游戏世界中可以看到并相互交互。
注意
多人游戏可能是一个非常高级的话题,所以接下来的几章将作为一个介绍来帮助你理解要点,但它不会是一个深入的了解。因此,为了简单起见,可能会省略一些概念。
在下一节中,我们将研究服务器。
服务器是架构中最关键的部分,因为它负责处理大部分工作并做出重要决策。
以下是服务器主要职责的概述:
-
创建和管理共享世界实例:服务器在特定的级别和游戏模式下运行自己的游戏实例(这将在后面的章节中介绍),并将作为所有连接的客户端之间的共享世界。正在使用的级别可以在任何时间点更改,如果适用,服务器可以自动将所有连接的客户端一起带来。
-
Handling client join and leave requests: If a client wants to connect to a server, it needs to ask for permission. To do this, the client sends a join request to the server, through a direct IP connection (explained in the next section) or an online subsystem such as Steam. Once the join request reaches the server, it will perform some validations to determine whether the request is accepted or rejected.
但是,您应该知道服务器拒绝加入游戏的请求有几个原因。最常见的情况是服务器已经满负荷,不能再接收更多的客户端,或者客户端使用的是过时的游戏版本。如果服务器接受请求,那么具有连接的玩家控制器被分配给客户端,并且游戏模式中的
PostLogin
功能被调用。从那时起,客户端将进入游戏,并成为共享世界的一部分,玩家将能够看到并与其他客户端互动。如果一个客户端在任何时间点断开连接,那么将通知所有其他客户端,并调用游戏模式下的Logout
功能。 -
Spawning the actors that all of the clients need to know about: If you want to spawn an actor that exists in all of the clients, then you need to do that on the server. The reason for this is the server has the authority and is the only one that can tell each client to create its own instance of that actor.
这是多人游戏中产生角色的最常见方式,因为大多数角色需要存在于所有客户端中。这方面的一个例子是加电,所有客户端都可以看到并与之交互。
-
运行关键玩法逻辑:为了保证游戏对所有客户端都是公平的,关键玩法逻辑只需要在服务器端执行即可。如果客户端负责处理生命值的扣除,那将是非常可利用的,因为玩家可以使用一个工具在内存中一直将当前生命值更改为 100%,这样玩家就永远不会在游戏中死亡。
-
处理变量复制:如果您有一个复制的变量(在本章中介绍),那么它的值应该只在服务器上更改。这将确保所有客户端的值都自动更新。您仍然可以更改客户端上的值,但它将始终被服务器上的最新值替换,以防止作弊并确保所有客户端同步。
-
处理来自客户端的 RPC:服务器需要处理来自客户端的远程过程调用(第 17 章、远程过程调用)。
现在您已经知道了服务器的功能,我们可以讨论一下在虚幻引擎 4 中创建服务器的两种不同方式。
专用服务器仅运行服务器逻辑,因此您不会看到游戏运行时的典型窗口,在该窗口中,您作为本地玩家控制角色。此外,如果您使用-log
命令提示符运行专用服务器,您将有一个控制台窗口,记录服务器上发生的相关信息,例如客户端是否已连接或断开连接等。作为开发人员,您也可以使用UE_LOG
宏记录自己的信息。
使用专用服务器是为多人游戏创建服务器的一种非常常见的方式,由于它比监听服务器(下一节中介绍的更轻量级),您可以将其托管在服务器堆栈上,并保持其运行。
要在虚幻引擎 4 中启动专用服务器,可以使用以下命令参数:
-
Run the following command to start a dedicated server inside an editor through a shortcut or Command Prompt:
<UE4 Install Folder>\Engine\Binaries\Win64\UE4Editor.exe <UProject Location> <Map Name> -server -game -log
这里有一个例子:
C:\Program Files\Epic Games\UE_4.24\Engine\Binaries\Win64\UE4Editor.exe D:\TestProject\TestProject.uproject TestMap -server -game -log
-
A packaged project requires a special build of the project built specifically to serve as a dedicated server.
注意
您可以通过访问https://allars blog . com/2015/11/06/support-special-servers/和https://www . ue4 community . wiki/special _ Server _ Guide _(Windows)了解更多关于设置打包专用服务器的信息。
监听服务器同时充当服务器和客户端,因此您也将有一个窗口,在这里您可以作为客户端使用这种服务器类型玩游戏。它还有一个优势,那就是它是让服务器运行的最快方式,但是它没有专用服务器那么轻量级,所以可以同时连接的客户端数量会受到限制。
要启动侦听服务器,可以使用以下命令参数:
-
Run the following command to start a dedicated server inside an editor through a shortcut or Command Prompt:
<UE4 Install Folder>\Engine\Binaries\Win64\UE4Editor.exe <UProject Location> <Map Name>?Listen -game
这里有一个例子:
C:\Program Files\Epic Games\UE_4.24\Engine\Binaries\Win64\UE4Editor.exe D:\TestProject\TestProject.uproject TestMap?Listen -game
-
A packaged project (development builds only) requires a special build of the project built specifically to serve as a dedicated server:
<Project Name>.exe <Map Name>?Listen -game
这里有一个例子:
D:\Packaged\TestProject\TestProject.exe TestMap?Listen –game
在下一节中,我们将讨论客户。
客户端是架构中最简单的部分,因为大多数参与者在服务器上都有权限,所以在这些情况下,工作将在服务器上完成,客户端只需服从它的命令。
以下是客户主要职责的概述:
-
从服务器强制执行变量复制:服务器通常对客户端知道的所有参与者拥有权限,因此当服务器上复制变量的值发生变化时,客户端也需要强制执行该值。
-
处理来自服务器的 RPC:客户端需要处理从服务器发送的远程过程调用(包含在第 17 章、远程过程调用中)。
-
模拟时预测运动:当客户端模拟一个演员时(将在本章后面的中介绍),它需要根据演员的速度本地预测它将会在哪里。
-
Spawning the actors that only a client needs to know about: If you want to spawn an actor that only exists on a client, then you need to do that on that specific client.
这是生成参与者的最不常见的方式,因为很少有希望参与者只存在于客户端的情况。这方面的一个例子是你在多人生存游戏中看到的放置预览演员,玩家控制一面半透明版本的墙,其他玩家在实际放置之前看不到它。
客户端可以通过不同的方式加入服务器。以下是最常见的方法列表:
-
Using the Unreal Engine 4 console (by default is the ` key) to open it and type:
Open <Server IP Address>
例如:
Open 194.56.23.4
-
Using the
Execute Console Command
Blueprint node. An example is as follows:
图 16.3:使用执行控制台命令节点加入一个带有示例 IP 的服务器
-
Using the
ConsoleCommand
function inAPlayerController
as follows:PlayerController->ConsoleCommand("Open <Server IP Address>");
这里有一个例子:
PlayerController->ConsoleCommand("Open 194.56.23.4");
-
Using the editor executable through a shortcut or Command Prompt:
<UE4 Install Folder>\Engine\Binaries\Win64\UE4Editor.exe <UProject Location> <Server IP Address> -game
这里有一个例子:
C:\Program Files\Epic Games\UE_4.24\Engine\Binaries\Win64\UE4Editor.exe D:\TestProject\TestProject.uproject 194.56.23.4 -game
-
Using a packaged development build through a shortcut or Command Prompt:
<Project Name>.exe <Server IP Address>
这里有一个例子:
D:\Packaged\TestProject\TestProject.exe 194.56.23.4
在下面的练习中,我们将在多人游戏中测试虚幻引擎 4 附带的第三人称模板。
在本练习中,我们将创建一个第三人称模板项目,并在多人游戏中使用。
以下步骤将帮助您完成练习。
-
Create a new
Third Person
template project usingBlueprints
calledTestMultiplayer
and save it to a location of your choosing.一旦创建了项目,它就应该打开编辑器。我们现在将在多人游戏中测试这个项目,看看它的表现:
-
在编辑器中,
Play
按钮的右侧,有一个箭头指向下方的选项。点击它,你会看到一个选项列表。在Multiplayer Options
部分,您可以配置您想要使用多少个客户端,以及您是否想要一个专用服务器。 -
不勾选
Run Dedicated Server
,将Number of Players
改为3
,点击New Editor Window (PIE)
。 -
You should see three windows on top of each other representing the three clients:
图 16.4:使用监听服务器启动三个客户端窗口
如您所见,这有点混乱,所以让我们更改窗口的大小。按下键盘上的 Esc 停止播放。
-
再次点击
Play
按钮旁边的向下箭头,选择最后一个选项Advanced Settings
。 -
搜索
Game Viewport Settings
部分。将New Viewport Resolution
更改为640x480
并关闭Editor Preferences
标签。 -
Play the game again and you should see the following:
图 16.5:使用 640x480 分辨率和监听服务器启动三个客户端窗口
一旦你开始玩,你会注意到窗口的标题栏写着Server
、Client 1
和Client 2
。由于您可以在Server
窗口中控制一个字符,这意味着我们正在运行一个监听服务器,其中服务器和客户端在同一个窗口中运行。当这种情况发生时,您应该将窗口标题解释为Server + Client 0
,而不仅仅是Server
,以避免混淆。
完成本练习后,您现在有了一个运行一个服务器和三个客户端的设置(Client 0
、Client 1
和Client 2
)。
注意
当多个窗口同时运行时,您会注意到一次只能将输入焦点放在一个窗口上。要将焦点转移到另一个窗口,只需按 Shift + F1 即可失去当前输入焦点,然后只需点击您想要关注的新窗口即可。
如果你在其中一个窗口玩游戏,你会注意到你可以移动和跳跃,其他客户端也可以看到。
一切正常的原因是,角色类附带的角色移动组件会自动为您复制位置、旋转和下落状态(用于显示您是否在跳跃)。如果你想添加一个自定义行为,比如一个攻击动画,你不能只告诉客户端在按下一个键的时候在本地播放一个动画,因为这对其他客户端不起作用。这就是为什么你需要服务器,作为一个中介,告诉所有的客户端在一个客户端按键的时候播放动画。
一旦你完成了这个项目,最好打包它(如前几章所述的*,这样我们就有了一个不使用虚幻引擎编辑器的纯独立版本,它将运行得更快,更轻量级。*
以下步骤将帮助您创建打包版本的练习 16.01 ,测试多人文件中的第三人模板:
-
前往
File
- >Package Project
- >Windows
- >Windows (64-bit)
。 -
选择一个文件夹来放置打包的构建,并等待它完成。
-
转到选中的文件夹,打开里面的
WindowsNoEditor
文件夹。 -
右键点击
TestMultiplayer.exe
上的,选择Create Shortcut
。 -
重命名新快捷方式
Run Server
。 -
右键点击,选择
Properties
。 -
在目标上,追加
ThirdPersonExampleMap?Listen -server
,这将使用ThirdPersonExampleMap
创建一个监听服务器。你应该以这个结束:"<Path>\WindowsNoEditor\TestMultiplayer.exe" ThirdPersonExampleMap?Listen -server
-
点击
OK
运行快捷方式。 -
你应该得到一个 Windows 防火墙提示,所以允许它。
-
让服务器保持运行,回到文件夹,从
TestMultiplayer.exe
创建另一个快捷方式。 -
改名
Run Client
。 -
右键点击,选择
Properties
。 -
在目标上,附加
127.0.0.1
,这是您的本地服务器的 IP。你应该以"<Path>\WindowsNoEditor\TestMultiplayer.exe" 127.0.0.1
结束。 -
点击
OK
运行快捷方式。 -
您现在已连接到侦听服务器,因此可以看到彼此的角色。
-
每次点击
Run Client
快捷方式,都会给服务器增加一个新的客户端,这样就可以让几个客户端在同一台机器上运行。
在下一节中,我们将关注连接和所有权。
在虚幻引擎中使用多人游戏时,需要理解的一个重要概念是连接。当一个客户端加入一个服务器时,它将获得一个新的玩家控制器,并有一个与之相关的连接。
如果一个参与者没有与服务器的有效连接,那么该参与者将不能执行复制操作,例如变量复制(本章后面的*)或调用 RPC(在第 17 章, 远程过程调用)。*
*如果玩家控制器是唯一拥有连接的参与者,那么这是否意味着它是唯一可以执行复制操作的地方?不,这就是AActor
中定义的GetNetConnection
功能发挥作用的地方。
在对一个 actor 进行复制操作(比如变量复制或者调用 RPC)时,虚幻框架会通过调用其上的GetNetConnection()
函数来获取 actor 的连接。如果连接有效,则复制操作将被处理,如果无效,则不会发生任何事情。GetNetConnection()
最常见的实现来自APawn
和AActor
。
让我们来看看APawn
类是如何实现GetNetConnection()
函数的,该函数通常用于字符:
class UNetConnection* APawn::GetNetConnection() const
{
// if have a controller, it has the net connection
if ( Controller )
{
return Controller->GetNetConnection();
}
return Super::GetNetConnection();
}
前面的实现是虚幻引擎 4 源代码的一部分,它将首先检查棋子是否有有效的控制器。如果控制器有效,那么它将使用它的连接。如果控制器无效,那么它将使用GetNetConnection()
功能的父实现,在AActor
上:
UNetConnection* AActor::GetNetConnection() const
{
return Owner ? Owner->GetNetConnection() : nullptr;
}
前面的实现也是虚幻引擎 4 源代码的一部分,它将检查参与者是否有有效的所有者。如果有,它会使用所有者的连接;如果没有,它将返回一个无效的连接。那么这个Owner
变量是什么呢?每个演员都有一个名为Owner
的变量(可以通过调用SetOwner
函数来设置其值),该变量显示哪个演员拥有它,因此您可以将其视为父演员。
在GetNetConnection()
的这个实现中使用所有者的连接将像一个层次结构一样工作。如果在所有者层次结构中向上移动时,它发现某个所有者是播放器控制器或由播放器控制器控制,那么它将具有有效的连接,并且能够处理复制操作。请看下面的例子。
注意
在监听服务器中,由客户端控制的角色的连接总是无效的,因为该客户端已经是服务器的一部分,因此不需要连接。
想象一个武器演员被放在世界上,它只是坐在那里。在这种情况下,武器不会有拥有者,所以如果武器试图做任何复制操作,比如变量复制或调用 RPC,什么都不会发生。
但是,如果客户拿起武器,用角色的值在服务器上调用SetOwner
,那么武器现在将有一个有效的连接。这样做的原因是因为武器是一个行动者,所以为了得到它的连接,它会使用GetNetConnection()
的AActor
实现,这个实现会返回它的拥有者的连接。既然业主是客户的性格,那就用APawn
的GetNetConnection()
实现。角色有一个有效的播放器控制器,所以这是函数返回的连接。
这里有一个图表来帮助你理解这个逻辑:
图 16.6:武器角色的连接和所有权示例
让我们理解无效所有者的要素:
AWeapon
不会覆盖GetNetConnection
功能,所以要获取武器的连接,会调用找到的第一个实现,也就是AActor::GetNetConnection
。AActor::GetNetConnection
的实现调用其所有者GetNetConnection
。由于没有所有者,连接无效。
有效的所有者包括以下要素:
-
AWeapon
不覆盖GetNetConnection
函数,所以要得到它的连接,它会调用找到的第一个实现,也就是AActor::GetNetConnection
。 -
AActor::GetNetConnection
的实现调用其所有者GetNetConnection
。既然主人是拿起武器的人物,那上面就会叫GetNetConnection
。 -
ACharacter
不覆盖GetNetConnection
函数,所以要得到它的连接,它会调用找到的第一个实现,也就是APawn::GetNetConnection
。 -
The implementation of
APawn::GetNetConnection
uses the connection from the owning player controller. Since the owning player controller is valid, then it will use that connection for the weapon.注意
为了使
SetOwner
按预期工作,它需要在授权上执行,在大多数情况下,授权意味着服务器。如果只在客户端执行SetOwner
,它仍然无法执行复制操作。
当您在服务器上生成一个执行元时,将在服务器上创建一个执行元版本,在每个客户端上创建一个执行元版本。由于同一个演员在游戏的不同实例上有不同的版本(Server
、Client 1
、Client 2
等等),所以知道哪个版本的演员是哪个很重要。这将允许我们知道在这些实例中可以执行什么逻辑。
为了帮助解决这种情况,每个参与者都有以下两个变量:
- 本地角色:演员在当前游戏实例中的角色。例如,如果角色是在服务器上产生的,而当前的游戏实例也是服务器,那么这个版本的角色就有权限,所以你可以在上面运行更关键的游戏逻辑。通过调用
GetLocalRole()
函数来访问。 - 远程角色:演员在远程游戏实例上的角色。例如,如果当前的游戏实例是服务器,那么它返回参与者在客户端的角色,反之亦然。通过调用
GetRemoteRole()
函数来访问。
GetLocalRole()
和GetRemoteRole()
函数的返回类型是ENetRole
,这是一个枚举,可以有以下可能的值:
ROLE_None
:演员没有角色,因为没有被复制。ROLE_SimulatedProxy
:当前游戏实例没有对参与者的权限,也没有通过玩家控制器来控制它。这意味着它的运动将通过使用演员速度的最后值来模拟/预测。ROLE_AutonomousProxy
:当前游戏实例没有对角色的权限,但是它由玩家控制器控制。这意味着我们可以根据玩家的输入向服务器发送更准确的运动信息,而不仅仅是使用演员速度的最后一个值。ROLE_Authority
:当前游戏实例对演员拥有完全的权限。这意味着,如果执行元在服务器上,对执行元的复制变量所做的更改将被视为每个客户端需要通过变量复制强制执行的值。
让我们看看下面的示例代码片段:
ENetRole MyLocalRole = GetLocalRole();
ENetRole MyRemoteRole = GetRemoteRole();
FString String;
if(MyLocalRole == ROLE_Authority)
{
if(MyRemoteRole == ROLE_AutonomousProxy)
{
String = «This version of the actor is the authority and
it›s being controlled by a player on its client»;
}
else if(MyRemoteRole == ROLE_SimulatedProxy)
{
String = «This version of the actor is the authority but
it›s not being controlled by a player on its client»;
}
}
else String = "This version of the actor isn't the authority";
GEngine->AddOnScreenDebugMessage(-1, 0.0f, FColor::Red, String);
前面的代码片段将本地角色和远程角色的值分别存储到MyLocalRole
和MyRemoteRole
中。之后,它将在屏幕上打印不同的消息,这取决于该版本的演员是权威还是由客户端的玩家控制。
注意
重要的是要明白,如果一个演员有一个ROLE_Authority
的本地角色,并不意味着它在服务器上;这意味着它是在最初产生演员的游戏实例上,因此对其拥有权限。
如果一个客户端产生了一个演员,即使服务器和其他客户端不知道,它的本地角色仍然是ROLE_Authority
。多人游戏中的大部分演员将由服务器产生;这就是为什么很容易误解权威总是指服务器。
以下表格有助于您理解演员在不同场景中的角色:
图 16.7:演员在不同场景中可以扮演的角色
在上表中,您可以看到演员在不同场景中的角色。
让我们分析每个场景,并解释为什么演员有这个角色:
服务器上产生的演员
演员在服务器上繁衍,所以服务器版本的那个演员会有ROLE_Authority
的本地角色和ROLE_SimulatedProxy
的远程角色,也就是客户端版本的演员的本地角色。对于客户端版本的演员,其本地角色将是ROLE_SimulatedProxy
,远程角色将是ROLE_Authority
,这是服务器演员版本的本地角色。
客户端产生的演员
演员是在客户端上衍生出来的,所以客户端版本的那个演员会有ROLE_Authority
的本地角色和ROLE_SimulatedProxy
的远程角色。由于该执行元没有在服务器上产生,因此它将只存在于产生它的客户端上,因此在服务器和其他客户端上不会有该执行元的版本。
服务器上产生的玩家拥有的棋子
棋子是在服务器上产生的,因此服务器版本的棋子将具有本地角色ROLE_Authority
和远程角色ROLE_AutonomousProxy
,后者是客户端版本棋子的本地角色。对于客户端版本的棋子,其本地角色将是ROLE_AutonomousProxy
,因为它由PlayerController
和远程角色ROLE_Authority
控制,后者是服务器棋子版本的本地角色。
客户端上产生的玩家拥有的棋子
棋子是在客户端上产生的,因此客户端版本的棋子将具有本地角色ROLE_Authority
和远程角色ROLE_SimulatedProxy
。因为棋子没有在服务器上产生,所以它将只存在于产生它的客户机上,所以在服务器和其他客户机上不会有这个棋子的版本。
在本练习中,我们将创建一个使用第三人称模板作为基础的 C++ 项目。
创建一个名为OwnershipTestActor
的新参与者,它有一个静态网格组件作为根组件,在每一个勾号上,它将执行以下操作:
- 在权限上,它将检查在某个半径内哪个字符最接近它(由名为
OwnershipRadius
的EditAnywhere
变量配置),并将该字符设置为其所有者。当半径内无人物时,则拥有者为nullptr
。 - 显示其本地角色、远程角色、所有者和连接。
- 编辑
OwnershipRolesCharacter
并覆盖Tick
功能,使其显示本地角色、远程角色、所有者和连接。 - 创建一个名为
OwnershipRoles.h
的新头文件,该文件包含ROLE_TO_String
宏,该宏将ENetRole
转换为Fstring
变量。
以下步骤将帮助您完成练习:
-
使用名为
OwnershipRoles
的C++
创建一个新的Third Person
模板项目,并将其保存到您选择的位置。 -
一旦创建了项目,它就应该打开编辑器和 Visual Studio 解决方案。
-
使用编辑器,创建一个名为
OwnershipTestActor
的新 C++ 类,该类从Actor
派生。 -
一旦编译完成,Visual Studio 应该会弹出新创建的
.h
和.cpp
文件。 -
关闭编辑器并返回到 Visual Studio。
-
In Visual Studio, open the
OwnershipRoles.h
file and add the following macro:#define ROLE_TO_STRING(Value) FindObject<UEnum>(ANY_PACKAGE, TEXT("ENetRole"), true)->GetNameStringByIndex((int32)Value)
这个宏将把我们从
GetLocalRole()
函数和GetRemoteRole()
得到的ENetRole
枚举转换成FString
。它的工作方式是通过虚幻引擎的反射系统找到ENetRole
枚举类型,然后将Value
参数转换成FString
变量,这样就可以在屏幕上打印出来。 -
现在,打开
OwnershipTestActor.h
文件。 -
Declare the protected variables for the static mesh component and the ownership radius as shown in the following code snippet:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Ownership Test Actor") UStaticMeshComponent* Mesh; UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Ownership Test Actor") float OwnershipRadius = 400.0f;
在前面的代码片段中,我们声明了静态网格组件和
OwnershipRadius
变量,这允许您配置所有权的半径。 -
接下来,删除
BeginPlay
的声明,将构造函数和Tick
函数声明移到保护区。 -
Now, open the
OwnershipTestActor.cpp
file and add the required header files as mentioned in the following code snippet:
```cpp
#include "DrawDebugHelpers.h"
#include "OwnershipRoles.h"
#include "OwnershipRolesCharacter.h"
#include "Components/StaticMeshComponent.h"
#include "Kismet/GameplayStatics.h"
```
在前面的代码片段中,我们包含了`DrawDebugHelpers.h`,因为我们将调用`DrawDebugSphere`和`DrawDebugString`函数。我们包括`OwnershipRoles.h`、`OwnershipRolesCharacter.h`和`StaticMeshComponent.h`,以便`.cpp`文件了解这些类。我们最后包含`GameplayStatics.h`,因为我们将调用`GetAllActorsOfClass`函数。
- 在构造函数定义中,创建静态网格组件,并将其设置为根组件:
```cpp
Mesh = CreateDefaultSubobject<UStaticMeshComponent>("Mesh");
RootComponent = Mesh;
```
- 仍然在构造函数中,将
bReplicates
设置为true
来告诉虚幻引擎,该参与者复制并且应该存在于所有客户端中:
```cpp
bReplicates = true;
```
- 删除
BeginPlay
功能定义。 - 在
Tick
函数中,绘制一个调试球来帮助可视化所有权半径,如下面的代码片段所示:
```cpp
DrawDebugSphere(GetWorld(), GetActorLocation(), OwnershipRadius, 32, FColor::Yellow);
```
- 仍然在
Tick
功能中,创建将在所有权半径内获得最近的AOwnershipRolesCharacter
的权限特定逻辑,如果与当前不同,则将其设置为所有者:
```cpp
if (HasAuthority())
{
AActor* NextOwner = nullptr;
float MinDistance = OwnershipRadius;
TArray<AActor*> Actors;
UGameplayStatics::GetAllActorsOfClass(this, AOwnershipRolesCharacter::StaticClass(), Actors);
for (AActor* Actor : Actors)
{
const float Distance = GetDistanceTo(Actor);
if (Distance <= MinDistance)
{
MinDistance = Distance;
NextOwner = Actor;
}
}
if (GetOwner() != NextOwner)
{
SetOwner(NextOwner);
}
}
```
- 仍然在
Tick
函数中,转换本地/远程角色的值(使用我们之前创建的ROLE_TO_STRING
宏)、当前所有者以及与字符串的连接:
```cpp
const FString LocalRoleString = ROLE_TO_STRING(GetLocalRole());
const FString RemoteRoleString = ROLE_TO_STRING(GetRemoteRole());
const FString OwnerString = GetOwner() != nullptr ? GetOwner()- >GetName() : TEXT("No Owner");
const FString ConnectionString = GetNetConnection() != nullptr ? TEXT("Valid Connection") : TEXT("Invalid Connection");
```
- To finalize the
Tick
function, useDrawDebugString
to display onscreen the strings we converted in the previous step:
```cpp
const FString Values = FString::Printf(TEXT("LocalRole = %s\nRemoteRole = %s\nOwner = %s\nConnection = %s"), *LocalRoleString, *RemoteRoleString, *OwnerString, *ConnectionString);
DrawDebugString(GetWorld(), GetActorLocation(), Values, nullptr, FColor::White, 0.0f, true);
```
注意
您可以使用`AActor`中定义的`HasAuthority()`助手函数,而不是不断使用`GetLocalRole() == ROLE_Authority`来检查参与者是否有权限。
- 接下来,打开
OwnershipRolesCharacter.h
并将Tick
功能声明为受保护:
```cpp
virtual void Tick(float DeltaTime) override;
```
- 现在,打开
OwnershipRolesCharacter.cpp
并包含头文件,如下面的代码片段所示:
```cpp
#include "DrawDebugHelpers.h"
#include "OwnershipRoles.h"
```
- 实现
Tick
功能:
```cpp
void AOwnershipRolesCharacter::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
}
```
- 将本地/远程角色的值(使用我们之前创建的
ROLE_TO_STRING
宏)、当前所有者和连接转换为字符串:
```cpp
const FString LocalRoleString = ROLE_TO_STRING(GetLocalRole());
const FString RemoteRoleString = ROLE_TO_STRING(GetRemoteRole());
const FString OwnerString = GetOwner() != nullptr ? GetOwner()- >GetName() : TEXT("No Owner");
const FString ConnectionString = GetNetConnection() != nullptr ? TEXT("Valid Connection") : TEXT("Invalid Connection");
```
- Use
DrawDebugString
to display onscreen the strings we converted in the previous step:
```cpp
const FString Values = FString::Printf(TEXT("LocalRole = %s\nRemoteRole = %s\nOwner = %s\nConnection = %s"), *LocalRoleString, *RemoteRoleString, *OwnerString, *ConnectionString);
DrawDebugString(GetWorld(), GetActorLocation(), Values, nullptr, FColor::White, 0.0f, true);
```
最后,我们可以测试这个项目。
- 运行代码,等待编辑器完全加载。
- 在从
OwnershipTestActor
派生的Content
文件夹中创建一个名为OwnershipTestActor_BP
的新蓝图。设置Mesh
使用立方体网格,并在世界上放置它的一个实例。 - 转到
Multiplayer Options
,将客户端数量设置为2
。 - 将窗口大小设置为
800x600
。 - Play using
New Editor Window (PIE)
.
您应该会得到以下输出:
图 16.8:服务器和客户端 1 窗口的预期结果
通过完成本练习,您将更好地了解连接和所有权是如何工作的。这些都是需要了解的重要概念,因为与复制相关的一切都依赖于它们。
下次当您看到一个参与者没有执行复制操作时,您将知道您需要首先检查它是否有一个有效连接和一个所有者。
现在,让我们分析服务器和客户端窗口中显示的值。
看看上一个练习中Server
窗口的输出截图:
图 16.9:服务器窗口
注意
上面写着Server Character
、Client 1 Character
、Ownership Test Actor
的文字不是原截图的一部分,添加是为了帮助大家理解哪个角色哪个演员。
在前面的截图中,可以看到Server Character
、Client 1 Character
,以及Ownership Test
立方体的演员。
我们先来分析一下Server Character
的数值。
这是监听服务器正在控制的角色。与该字符相关的值如下:
LocalRole = ROLE_Authority
:因为这个角色是在服务器上衍生出来的,是当前的游戏实例。RemoteRole = ROLE_SimulatedProxy
:因为这个角色是服务器上衍生出来的,所以其他客户端应该只模拟它。Owner = PlayerController_0
:因为这个角色是由监听服务器的客户端控制的,监听服务器使用第一个PlayerController
实例PlayerController_0
。Connection = Invalid Connection
:因为我们是监听服务器的客户端,所以不需要连接。
接下来,我们将在同一个窗口中查看Client 1 Character
。
这就是Client 1
所控制的人物。与该字符相关的值如下:
LocalRole = ROLE_Authority
:因为这个角色是在服务器上衍生出来的,是当前的游戏实例。RemoteRole = ROLE_AutonomousProxy
:因为这个角色是在服务器上产生的,但是被另一个客户端控制了。Owner = PlayerController_1
:因为这个角色正在被另一个客户端控制,这个客户端使用了第二个PlayerController
实例PlayerController_1
。Connection = Valid Connection
:因为这个角色正在被另一个客户端控制,所以需要连接到服务器。
接下来,我们将在同一个窗口中观察OwnershipTest
演员。
这是将它的所有者设置为某个所有权半径内最接近的角色的多维数据集执行元。与此参与者关联的值如下:
LocalRole = ROLE_Authority
:因为这个演员是放在关卡中,在服务器上衍生出来的,是当前的游戏实例。RemoteRole = ROLE_SimulatedProxy
:因为这个演员是在服务器中衍生出来的,但是它没有被任何客户端控制。Owner
和Connection
的值将基于最接近的字符。如果所有权半径内没有角色,则他们将具有No Owner
和Invalid Connection
的值。
现在,让我们看看Client 1
窗口:
图 1 6.10:客户端 1 窗口
Client 1
窗口的值将与Server
窗口完全相同,除了LocalRole
和RemoteRole
的值将被反转,因为它们总是相对于您所在的游戏实例。
另一个例外是,服务器角色没有所有者,其他连接的客户端没有有效的连接。原因是客户端不存储玩家控制器和其他客户端的连接,只有服务器存储,但这将在第 18 章、多人游戏中的游戏框架类中有更深入的介绍。
在下一节中,我们将研究变量复制。
服务器保持客户端同步的方法之一是使用变量复制。它的工作方式是,服务器中的变量复制系统每秒每特定次数(在AActor::NetUpdateFrequency
变量中为每个参与者定义,该变量也暴露给蓝图)将检查客户端中是否有任何需要用最新值更新的复制变量(下一节中解释的*)。*
如果变量满足所有复制条件,则服务器将向客户端发送更新并强制实施新值。
例如,如果您有一个复制的Health
变量,并且客户端使用黑客工具将该变量的值从10
设置为100
,则复制系统将从服务器强制执行真实值,并将其更改回10
,这将使黑客无效。
只有在以下情况下,才会将变量发送到客户端进行更新:
- 该变量被设置为复制。
- 服务器上的值已更改。
- 客户端上的值不同于服务器上的值。
- 该参与者已启用复制。
- 参与者是相关的,并且满足所有复制条件。
需要考虑的重要一点是,决定变量是否应该复制的逻辑每秒只执行AActor::NetUpdateFrequency
次。换句话说,在您更改服务器上的变量值后,服务器不会立即向客户端发送更新请求。它将仅在变量复制系统执行时发送该请求,这是每秒AActor::NetUpdateFrequency
次,并且它已经确定来自客户端的值不同于来自服务器的值。
例如,如果有一个整数复制一个名为Test
的变量,该变量的默认值为5
。如果您在服务器上调用一个将Test
设置为3
的函数,并在下一行将其更改为8
,那么只有后一个更改会向客户端发送更新请求。原因是这两个变化是在NetUpdateFrequency
间隔之间进行的,所以当变量复制系统执行时,当前值是8
,由于它不同于客户端的值(仍然是5
,所以它会更新它们。如果不将其设置为8
,而是将其设置回5
,则不会向客户端发送任何更改。
在虚幻引擎中,任何可以使用UPROPERTY
宏的变量都可以设置为复制,你可以使用两个说明符来实现。
复制
如果你只想说一个变量被复制了,那么你就用Replicated
说明符。
请看下面的例子:
UPROPERTY(Replicated)
float Health = 100.0f;
在前面的代码片段中,我们声明了一个名为Health
的浮点变量,就像我们通常做的那样。不同的是,我们添加了UPROPERTY(Replicated)
来告诉虚幻引擎Health
变量将被复制。
重新通知
如果你想说一个变量被复制并在每次更新时调用一个函数,那么你可以使用ReplicatedUsing=<Function Name>
说明符。请看下面的例子:
UPROPERTY(ReplicatedUsing=OnRep_Health)
float Health = 100.0f;
UFUNCTION()
void OnRep_Health()
{
UpdateHUD();
}
在前面的代码片段中,我们声明了一个名为Health
的浮点变量。不同的是,我们增加了UPROPERTY(ReplicatedUsing=OnRep_Health)
来告诉虚幻引擎这个变量将被复制,并且每次更新它都会调用OnRep_Health
函数,在这个特定的例子中,它会调用一个函数来更新HUD
。
通常,回调函数的命名方案是OnRepNotify_<Variable Name>
或OnRep_<Variable Name>
。
注意
ReplicatingUsing
说明符中使用的函数需要标记为UFUNCTION()
。
获取终身复制产品
除了将变量标记为复制之外,您还需要在参与者的cpp
文件中实现GetLifetimeReplicatedProps
函数。需要考虑的一点是,一旦您至少有一个复制变量,这个函数就在内部声明,所以您不应该在参与者的头文件中声明它。这个函数的目的是告诉你每个复制的变量应该如何复制。您可以通过在每个要复制的变量上使用DOREPLIFETIME
宏及其变体来实现这一点。
doreplitime
此宏告诉复制系统,复制的变量(作为参数输入)将复制到所有没有复制条件的客户端。
以下是它的语法:
DOREPLIFETIME(<Class Name>, <Replicated Variable Name>);
请看下面的例子:
void AVariableReplicationActor::GetLifetimeReplicatedProps(TArray< FLifetimeProperty >& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(AVariableReplicationActor, Health);
}
在前面的代码片段中,我们使用DOREPLIFETIME
宏告诉复制系统,AVariableReplicationActor
类中的Health
变量将在没有额外条件的情况下复制。
doreplitime _ CONDITION
此宏告诉复制系统,复制的变量(作为参数输入)将只复制到满足条件(作为参数输入)的客户端。
以下是语法:
DOREPLIFETIME_CONDITION(<Class Name>, <Replicated Variable Name>, <Condition>);
条件参数可以是以下值之一:
COND_InitialOnly
:变量只会复制一次,与初始复制一样。COND_OwnerOnly
:变量只会复制给行为人的所有者。COND_SkipOwner
:变量不会复制给行为人的所有者。COND_SimulatedOnly
:变量只会复制给正在模拟的演员。COND_AutonomousOnly
:变量只会复制给自主的行动者。COND_SimulatedOrPhysics
:该变量将只复制给正在模拟的演员或bRepPhysics
设置为真的演员。COND_InitialOrOwner
:变量只会复制一次,初始复制还是复制给行为人的所有者。COND_Custom
:只有当变量的SetCustomIsActiveOverride
布尔条件(用于AActor::PreReplication
函数)为真时,该变量才会复制。
请看下面的例子:
void AVariableReplicationActor::GetLifetimeReplicatedProps(TArray< FLifetimeProperty >& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME_CONDITION(AVariableReplicationActor, Health, COND_OwnerOnly);
}
在前面的代码片段中,我们使用DOREPLIFETIME_CONDITION
宏来告诉复制系统,AVariableReplicationActor
类中的Health
变量将只为该参与者的所有者复制。
注意
还有更多DOREPLIFETIME
宏可用,但本书不会涉及。要查看所有变体,请查看虚幻引擎 4 源代码中的UnrealNetwork.h
文件。请参见以下网址的说明:。
在本练习中,我们将创建一个 C++ 项目,该项目使用第三人称模板作为基础,并向角色添加两个变量,这两个变量以以下方式复制:
- 变量
A
是一个将使用Replicated UPROPERTY
说明符和DOREPLIFETIME
宏的浮点数。 - 变量
B
是一个整数,将使用ReplicatedUsing UPROPERTY
说明符和DOREPLIFETIME_CONDITION
宏。
以下步骤将帮助您完成练习:
-
使用名为
VariableReplication
的C++
创建一个新的Third Person
模板项目,并将其保存到您选择的位置。 -
一旦创建了项目,它就应该打开编辑器和 Visual Studio 解决方案。
-
关闭编辑器并返回到 Visual Studio。
-
打开
VariableReplicationCharacter.h
文件。 -
接下来,在
VariableReplicationCharacter.generated.h
之前包含UnrealNetwork.h
头文件,它有我们将要使用的DOREPLIFETIME
宏的定义:#include "Net/UnrealNetwork.h"
-
使用各自的复制说明符
UPROPERTY(Replicated) float A = 100.0f; UPROPERTY(ReplicatedUsing = OnRepNotify_B) int32 B;
,将受保护变量
A
和B
声明为UPROPERTY
-
宣布
Tick
功能受保护:virtual void Tick(float DeltaTime) override;
-
既然我们已经将变量
B
声明为ReplicatedUsing = OnRepNotify_B
,那么我们还需要将受保护的OnRepNotify_B
回调函数声明为UFUNCTION
:UFUNCTION() void OnRepNotify_B();
-
现在,打开
VariableReplicationCharacter.cpp
文件,包括标题Engine.h
,这样我们就可以使用AddOnScreenDebugMessage
功能,以及DrawDebugHelpers.h
,这样我们就可以使用DrawDebugString
功能:#include "Engine/Engine.h" #include "DrawDebugHelpers.h"
-
实现
GetLifetimeReplicatedProps
功能:
```cpp
void AVariableReplicationCharacter::GetLifetimeReplicatedProps(TArray< FLifetimeProperty >& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
}
```
- 将其设置为
A
变量,该变量将在没有任何额外条件的情况下复制:
```cpp
DOREPLIFETIME(AVariableReplicationCharacter, A);
```
- 将其设置为
B
变量,该变量将只复制给该参与者的所有者:
```cpp
DOREPLIFETIME_CONDITION(AVariableReplicationCharacter, B, COND_OwnerOnly);
```
- 实现
Tick
功能:
```cpp
void AVariableReplicationCharacter::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
}
```
- Next, run the authority-specific logic that adds
1
toA
andB
:
```cpp
if (HasAuthority())
{
A++ ;
B++ ;
}
```
因为这个字符会在服务器上产生,所以只有服务器会执行这个逻辑。
- 在字符位置显示
A
和B
的值:
```cpp
const FString Values = FString::Printf(TEXT("A = %.2f B = %d"), A, B);
DrawDebugString(GetWorld(), GetActorLocation(), Values, nullptr, FColor::White, 0.0f, true);
```
- Implement the
RepNotify
function for variableB
, which displays on the screen a message saying that theB
variable was changed to a new value:
```cpp
void AVariableReplicationCharacter::OnRepNotify_B()
{
const FString String = FString::Printf(TEXT("B was changed by the server and is now %d!"), B);
GEngine->AddOnScreenDebugMessage(-1, 0.0f, FColor::Red,String);
}
```
最后,您可以测试项目:
- 运行代码,等待编辑器完全加载。
- 转到
Multiplayer Options
,将客户端数量设置为2
。 - 将窗口大小设置为
800x600
。 - 使用
New Editor Window (PIE)
播放。
完成本练习后,您将能够在每个客户端上进行游戏,您会注意到角色正在显示各自的A
和B
值。
现在,让我们分析一下Server
和Client 1
窗口中显示的值。
在Server
窗口中,你有Server Character
的值,它是由服务器控制的角色,在后台,你有Client 1 Character
的值:
图 16 .11:服务器窗口
可以观察到的输出如下:
Server``Character
–A = 674.00 B = 574
Client 1``Character
–A = 670.00 B = 570
在这个特定的时间点上,Server
Character
的值为674
代表A``574
代表B
。之所以A
和B
数值不同,是因为A
从100
开始,B
从0
开始,这是A++
和B++
的574
刻度之后的正确数值。
至于为什么Client 1
Character
和服务器角色没有相同的值,那是因为Client 1
是在服务器之后稍微创建的,所以在这种情况下,计数会被A++
和B++
的4
滴答关闭。
接下来,我们将看到Client 1
窗口。
在Client 1
窗口中,你有Client 1 Character
的值,它是由Client 1
控制的角色,在后台,你有Server Character
的值:
图 16.12:客户端 1 窗口
可以观察到的输出如下:
Server``Character
–A = 674.00 B = 0
Client 1``Character
–A = 670.00 B = 570
Client 1 Character
具有来自服务器的正确值,因此变量复制正在按预期工作。如果看Server Character
,A
就是674
,没错,但是B
就是0
。原因是A
使用的是DOREPLIFETIME
,没有添加任何额外的复制条件,所以每次服务器上的变量发生变化,它都会复制变量,让客户端保持最新。
另一方面,变量B
将DOREPLIFETIME_CONDITION
与COND_OwnerOnly
一起使用,由于Client 1
不是拥有Server Character
的客户端(监听服务器的客户端是),因此该值不会被复制,并且与0
的默认值保持不变。
如果你回到代码,将B
的复制条件改为使用COND_SimulatedOnly
而不是COND_OwnerOnly
,你会注意到结果会在Client 1 window
反转。B
的价值会为Server Character
复制,但不会为自己的性格复制。
注意
之所以在Server
窗口而不是客户端窗口显示RepNotify
消息,是因为在编辑器中播放时,两个窗口共享相同的过程,因此在屏幕上打印文本不会准确。为了获得正确的行为,您需要运行游戏的打包版本。
在第 2 章、使用虚幻引擎中,我们创建了一个 1D 混合空间,根据速度轴的值在角色的运动状态(空闲、行走和奔跑)之间进行混合。对于那个特定的例子,它工作得非常好,因为你只需要一个轴,但是如果我们希望角色也能够扫射,那么我们就不能真正做到这一点。
为了探索这种情况,虚幻引擎允许您创建 2D 混合空间。概念几乎完全相同;唯一的区别是你有一个额外的动画轴,所以你不仅可以在水平方向上混合,也可以在垂直方向上混合。
在本练习中,我们将创建一个使用两个轴而不是一个轴的混合空间。纵轴为Speed
,在0
和800
之间。横轴为Direction
,代表棋子的速度和旋转/前进矢量之间的相对角度(-180 to 180
)。
下图将帮助您计算本练习中的方向:
图 16.13:基于前向矢量和速度之间角度的方向值
在上图中,您可以看到如何计算方向。前向矢量表示角色当前面对的方向,数字表示如果前向矢量指向该方向,它将与速度矢量形成的角度。如果角色朝某个方向看,并且你按了一个键将角色向右移动,那么速度向量将垂直于向前的向量。这意味着角度是 90 度,这就是我们的方向。
如果我们按照这个逻辑设置我们的 2D 混合空间,我们可以根据角色的移动角度使用正确的动画。
以下步骤将帮助您完成练习:
- 使用名为
Blendspace2D
的Blueprints
创建一个新的Third Person
模板项目,并将其保存到您选择的位置。 - 一旦创建了项目,它就应该打开编辑器。
- 接下来,您将导入运动动画。在编辑器中,转到
Content\Mannequin\Animations
文件夹。 - 点击
Import
按钮。 - 进入
Chapter16\Exercise16.04\Assets
文件夹,选择所有fbx
文件,点击Open
按钮。 - 在导入对话框中,确保选择角色的骨骼并点击
Import All
按钮。 - 将所有新文件保存在
Assets
文件夹中。 - 点击
Add New
按钮,选择Animation -> Blend Space
。 - 接下来,选择角色的骨骼。
- 重命名混合空间
BS_Movement
并将其打开。 - Create the horizontal
Direction
axis(-180 to 180)
and the verticalSpeed
axis(0 to 800)
as shown in the following figure:

图 16.14: 2D 混合空间轴设置
- 将
Idle_Rifle_Ironsights
动画拖到5
网格条目上,其中Speed
是0
。 - 拖动
Walk_Fwd_Rifle_Ironsights
动画,其中Speed
是800
,Direction
是0
。 - 拖动
Walk_Lt_Rifle_Ironsights
动画,其中Speed
是800
,Direction
是-90
。 - Drag the
Walk_Rt_Rifle_Ironsights
animation whereSpeed
is800
andDirection
is90
.
您应该会得到一个混合空间,可以通过按住*移动*并移动鼠标来预览。
- 现在,在
Asset Details
面板上,将Target Weight Interpolation Speed Per Sec
变量设置为5
,使插值更加平滑。 - 保存并关闭混合空间。
- 现在,更新动画蓝图以使用新的混合空间。
- 转到
Content\Mannequin\Animations
并打开第三人称模板附带的文件–ThirdPerson_AnimBP
。 - 接下来,转到事件图,创建一个名为
Direction
的新浮点变量。 - Set the value of
Direction
with the result of theCalculate Direction
function, which calculates the angle (-180º to 180º) between the pawn'svelocity
androtation
:

图 16.15:计算用于 2D 混合空间的速度和方向
注意
您可以在以下链接找到前面的全分辨率截图,以便更好地查看:[https://packt.live/3pAbbAl](https://packt.live/3pAbbAl)。
- In
AnimGraph
, go to theIdle/Run
state where the old 1D Blend Space is being used, as shown in the following screenshot:

图 16.16:动画中的空闲/运行状态
- Replace that Blend Space with
BS_Movement
and use theDirection
variable like so:

图 16.17: 1D 混合空间已被新的 2D 混合空间取代
- 保存并关闭动画蓝图。现在你需要更新角色。
- 转到
Content\ThirdPersonBP\Blueprints
文件夹,打开ThirdPersonCharacter
。 - 在角色的
Details
面板上,将Use Controller Rotation Yaw
设置为true
,这将使角色的Yaw
旋转始终面向控制旋转的偏航。 - 转到角色移动组件,将
Max Walk Speed
设置为800
。 - 将
Orient Rotation to Movement
设置为false
,这将防止角色向运动方向旋转。 - 保存并关闭角色蓝图。
如果你现在用两个客户端玩游戏,移动角色,它会前后走动,但也会扫射,如下图截图所示:
图 16.18:服务器和客户端 1 窗口上的预期输出
通过完成本练习,您将更好地理解如何创建 2D 混合空间,它们是如何工作的,以及与仅使用常规 1D 混合空间相比它们所提供的优势。
在下一节中,我们将研究如何变换角色的骨骼,以便我们可以根据相机的俯仰来上下旋转玩家的躯干。
在我们继续之前,有一个非常有用的节点可以在动画中使用,叫做Transform (Modify) Bone
节点,它允许你在运行时平移、旋转和缩放骨骼。
您可以在AnimGraph
中添加它,方法是在空白区域用鼠标右键单击,键入transform modify
,然后从列表中选择该节点。如果你点击Transform (Modify) Bone
节点,你会在Details
面板上有很多选项。
这里解释了每个选项的作用。
-
The
Bone to Modify
option will tell the node what bone is going to be transformed.在该选项之后,有三个部分代表每个变换操作(
Translation
、Rotation
和Scale
)。在每个部分中,您可以执行以下操作: -
Translation, Rotation, Scale
: This option will tell the node how much of that specific transform operation you want to apply. The final result will depend on the mode (covered in the next section) you have selected.有两种方法可以设置该值:
-
设置一个恒定值,如(
X=0.0,Y=0.0,Z=0.0
) -
使用变量,因此它可以在运行时更改。要启用此功能,您需要采取以下步骤(此示例针对
Rotation
,但相同的概念适用于Translation
和Scale
):
-
Click the checkbox next to the constant value and make sure it is checked. Once you do that, the text boxes for the constant value will disappear.
图 16.19:选中复选框
Transform (Modify) Bone
将添加一个输入,以便您可以插入您的变量:
图 16.20:用作变换(修改)骨骼节点输入的变量
设置模式
这将告诉节点如何处理该值。您可以从以下三个选项中选择一个:
-
Ignore
:不要用提供的值做任何事情。 -
Add to Existing
:抓取骨骼的当前值,并将提供的值加入其中。 -
Replace Existing
: Replace the current value of the bone with the supplied value.设置空间
这将定义节点应用转换的空间。您可以从以下四个选项中选择一个:
-
World Space
:变换会发生在世界空间。 -
Component Space
:变换将发生在骨骼网格组件空间。 -
Parent Bone Space
:变换将发生在所选骨骼的父骨骼空间。 -
Bone Space
:变换会发生在选中骨骼的空间。
最后但同样重要的是,您有Alpha
,这是一个允许您控制要应用的变换量的值。例如,如果您将Alpha
值作为一个浮点值,那么您将有以下不同值的行为:
- 如果
Alpha
为 0.0,则不应用任何变换。 - 如果
Alpha
是 0.5,那么它只会应用一半的变换。 - 如果
Alpha
为 1.0,则应用整个变换。
在下一个练习中,我们将使用Transform (Modify) Bone
节点来启用来自练习 16.04 、创建运动 2D 混合空间的角色,以基于相机的旋转上下查看。
在本练习中,我们将复制来自练习 16.04 、的项目,创建一个运动 2D 混合空间,并使角色能够基于相机的旋转上下查看。为了实现这一点,我们将使用Transform (Modify) Bone
节点,根据相机的音高旋转组件空间中的spine_03
骨骼。
以下步骤将帮助您完成练习:
-
首先,您需要从练习 16.04 、复制并重命名项目,创建运动 2D 混合空间。
-
从练习 16.04 、复制
Blendspace2D
项目文件夹,创建运动 2D 混合空间,将其粘贴到新文件夹中,并将其重命名为TransformModifyBone
。 -
Open the new project folder, rename the
Blendspace2D.uproject
fileTransformModifyBone.uproject
, and open it.接下来,您将更新动画蓝图。
-
转到
Content\Mannequin\Animations
打开ThirdPerson_AnimBP
。 -
Go to the
Event Graph
, create a float variable calledPitch
, and set it with the Pitch of the subtraction (or delta) between the pawn's rotation and the base aim rotation, as shown in the following figure:图 16.21:计算间距
作为使用
Break Rotator
节点的替代方法,您可以在Return Value
上的上单击鼠标右键*,然后选择Split Struct Pin
。*注意
Break Rotator
节点允许您将一个Rotator
变量分成三个浮动变量,分别代表Pitch
、Yaw
和Roll
。当您想要访问每个单独组件的值时,或者如果您只想处理一个或两个组件,而不想处理整个旋转时,这非常有用。考虑到
Split Struct Pin
选项只有在Return
Value
没有连接到任何东西时才会出现。一旦您进行了拆分,它将为Roll
、Pitch
和Yaw
创建三条单独的线,就像断开一样,但没有额外的节点。您应该会得到以下结果:
图 16.22:使用拆分结构引脚选项计算要查找的间距
这个逻辑利用棋子的旋转,从摄像头的旋转中减去,得到
Pitch
中的差值,如下图所示:图 16.23:如何计算增量间距
-
Next, go to
AnimGraph
and add aTransform (Modify) Bone
node with the following settings:图 16.24:变换(修改)骨骼节点的设置
在前面的截图中,我们已经将
Bone to Modify
设置为spine_03
,因为那是我们想要旋转的骨骼。我们还将Rotation Mode
设置为Add to Existing
,因为我们希望保留动画中的原始旋转,并为其添加偏移。其余选项需要有默认值。 -
Connect the
Transform (Modify) Bone
node to theState Machine
and theOutput Pose
, as shown in the following screenshot:
图 16.25:变换(修改)连接到输出姿势的骨骼
在上图中,您看到了完整的AnimGraph
,这将允许角色通过基于相机间距旋转spine_03
骨骼来上下查看。State Machine
将是起点,从那里需要转换成组件空间,以便能够使用Transform (Modify) Bone
节点,该节点在转换回本地空间后将连接到Output Pose
节点。
注意
我们将Pitch
变量连接到Roll
的原因是骨骼中的骨骼是这样内部旋转的。您也可以在输入参数上使用Split Struct Pin
,因此您不必添加Make Rotator
节点。
如果你用两个客户端测试项目,将鼠标上移和下移到其中一个角色上,你会注意到它会上下俯仰,如下图截图所示:
图 16.26:基于相机旋转的角色网格上下俯仰
通过完成最后的练习,您将了解如何使用动画蓝图中的Transform (Modify) Bone
节点在运行时修改骨骼。这个节点可以在各种场景中使用,因此它可能对您非常有用。
在下一个活动中,您将通过创建我们将用于多人 FPS 项目的角色来测试所学的一切。
在本活动中,您将为我们将在接下来几章中构建的多人 FPS 项目创建角色。角色会有一些不同的机制,但是对于这个活动,你只需要创建一个可以行走、跳跃、上下看的角色,并且有两个复制的属性:生命值和护甲。
以下步骤将帮助您完成活动:
-
创建一个名为
MultiplayerFPS
的Blank C++
项目,不包含起始内容。 -
从
Activity16.01\Assets folder
导入骨骼网格和动画,并将它们分别放置在Content\Player\Mesh
和Content\Player\Animations
文件夹中。 -
将以下声音从
Activity16.01\Assets
文件夹导入Content\Player\Sounds
:Jump.wav
:用Play Sound
动画通知在Jump_From_Stand_Ironsights
动画上播放这个声音。Footstep.wav
:使用Play Sound
anim notify,在每次行走动画中,每次脚踩地板时播放此声音。Spawn.wav
:在字符中的SpawnSound
变量上使用。
-
通过重新定位骨骼并创建一个名为
Camera
的插座来设置骨骼网格,该插座是头部骨骼的子节点,其相对位置为(X=7.88, Y=4.73, Z=-10.00
)。 -
在名为
BS_Movement
的Content\Player\Animations
中创建一个 2D 混合空间,使用导入的运动动画和5
的Target Weight Interpolation Speed Per Sec
。 -
使用在第 4 章、玩家输入中获得的知识,在
Project Settings
中创建输入映射:- 跳转(动作映射)–空格键
- 向前移动(轴映射)–W(刻度
1.0
)和 S (刻度-1.0
) - 向右移动(轴映射)–A(刻度
-1.0
)和 D (刻度1.0
) - 旋转(轴映射)–鼠标 X (缩放
1.0
) - 向上看(轴映射)–鼠标 Y (缩放
-1.0
)
-
创建一个名为
FPSCharacter
的 C++ 类,它执行以下操作:-
源自
Character
类。 -
在
Camera
插座上有一个连接到骨骼网格的相机组件,并将pawn control rotation
设置为true
。 -
对于
health
和armor
有只复制给所有者的变量。 -
有最大
health
和armor
的变量,以及护甲吸收伤害的百分比。 -
有一个构造器,用于初始化摄像机,禁用嘀嗒声,并将
Max Walk Speed
设置为800
,将Jump Z Velocity
设置为600
。 -
在
BeginPlay
上,播放产卵声音,有权限的话用max health
初始化health
。 -
创建并绑定函数来处理输入操作和轴。
-
具有添加/删除/设置健康的功能。这也保证了角色死亡的情况。
-
Has functions to add/set/absorb armor. The armor absorption reduces the armor based on the
ArmorAbsorption
variable and changes the damage value based on the formula:伤害=(伤害 (1 -装甲吸收))-FMath::Min(remaininggarmor,0);*
-
-
在
Content\Player\Animations
中创建一个名为ABP_Player
的动画蓝图,它有一个带有以下状态的State Machine
:-
Idle/Run
:将BS_Movement
与Speed
和Direction
变量一起使用 -
Jump
: Plays the jump animation and transitions from theIdle/Run
states when theIs Jumping
variable istrue
它还使用
Transform (Modify) Bone
根据相机的音高使角色上下俯仰。
-
-
使用在第 15 章、收藏品、电源和皮卡中获得的知识,在
Content\UI
中创建一个名为UI_HUD
的UMG
小部件,以Health: 100
和Armor: 100
的格式显示人物的Health
和Armor
。 -
在
Content\Player
中创建一个从FPSCharacter
派生的名为BP_Player
的蓝图,并将网格组件设置为具有以下值: * 使用SK_Mannequin
骨骼网格 * 使用ABP_Player
动画蓝图 * 设置Location
等于( X=0.0,Y=0.0,Z=-88.0 ) * SetRotation
to be equal to (X=0.0, Y=0.0, Z=-90.0)同样,在
Begin Play
事件上,它需要创建UI_HUD
的小部件实例,并将其添加到视口中。 -
在
Content\Blueprints
中创建一个从MultiplayerFPSGameModeBase
派生的名为BP_GameMode
的蓝图,它将使用BP_Player
作为DefaultPawn
类。 -
在
Content\Maps
中创建一个名为DM-Test
的测试地图,并将其设置为Project Settings
中的默认地图。
预期产出:
结果应该是一个项目,其中每个客户将有一个第一人称角色,可以移动,跳跃,并环顾四周。这些动作也将被复制,因此每个客户端将能够看到另一个客户端的角色正在做什么。
每个客户端还会有一个显示生命值和护甲值的平视显示器。
图 16.27:预期产出
注意
这个活动的解决方案可以在:https://packt.live/338jEBx找到。
最终的结果应该是两个可以看到彼此移动、跳跃和四处张望的角色。每个客户端还显示其角色的生命值和护甲值。
通过完成本练习,您应该对服务器-客户端体系结构、变量复制、角色、2D 混合空间和Transform (Modify) Bone
节点的工作方式有了一个很好的了解。
在本章中,我们了解了一些关键的多人游戏概念,例如服务器-客户端架构如何工作、服务器和客户端的职责、监听服务器如何比专用服务器更快地设置,但不像轻量级服务器那样快、所有权和连接、角色和变量复制。
我们还学习了一些有用的动画技术,例如如何使用 2D 混合空间,它允许您有一个双轴网格在动画之间混合,以及变换(修改)骨骼节点,它能够在运行时修改骨骼网格的骨骼。为了完成这一章,我们创建了一个第一人称多人游戏项目,在这个项目中,你可以看到可以行走、看和跳跃的角色,这将是我们在接下来几章中将要研究的多人第一人称射击游戏项目的基础。
在下一章中,我们将学习如何使用 RPC,它允许客户端和服务器在彼此上执行功能。我们还将介绍如何在编辑器中使用枚举,以及如何使用双向循环数组索引,这允许您在数组中前后循环,当超出限制时循环返回。*