[TOC]
# 武器系统
## 功能分析
Shooter Game的武器系统,提供的一套FPS武器的基本功能,列表如下:
1. 装备/取消装备武器
2. 开火
3. 上子弹
4. 拾取子弹
而一个武器的基本状态包括如下:
![](https://box.kancloud.cn/d3db9c67bcee1722fca95013d6b00c44_603x372.png)
## 功能总体设计
请注意,这仅仅只是一个“示意图”。ShooterGame的武器状态挺复杂,所以并没有采用标准的状态机模式,而是使用了一组状态变量(bPendingReload、bWantsToFire等)和一个专用的状态转换决策函数DetermineWeaponState共同实现。
Shooter Game的武器状态声明如下:
~~~
namespace EWeaponState
{
enum Type
{
Idle,
Firing,
Reloading,
Equipping,
};
}
~~~
状态转换函数DetermineWeaponState如下:
~~~
void AShooterWeapon::DetermineWeaponState()
{
EWeaponState::Type NewState = EWeaponState::Idle;
if (bIsEquipped)
{
if( bPendingReload )
{
if( CanReload() == false )
{
NewState = CurrentState;
}
else
{
NewState = EWeaponState::Reloading;
}
}
else if ( (bPendingReload == false ) && ( bWantsToFire == true ) && ( CanFire() == true ))
{
NewState = EWeaponState::Firing;
}
}
else if (bPendingEquip)
{
NewState = EWeaponState::Equipping;
}
SetWeaponState(NewState);
}
~~~
这并不符合标准的状态机模式实现,可能是Shooter Game出于复杂度的考虑。以及笔者也尝试过整理状态转换,但是发现转换太过复杂,相比之下Shooter Game的实现倒是很简洁。另外,UT3也不是采用标准状态机模式来实现的。
武器系统的基本逻辑实际上很好表述:
* 一般有一组进入状态和退出状态函数,如:StartFire和StopFire;StartReload和StopReload。
1. 由于状态是持续性的过程,而函数是瞬间完成的调用,因此势必需要三个元素:开始、持续过程更新函数、结束。
2. 以最重要的开火函数为例,其实际上走了这样的调用:
1. StartFire实质上是一个“请求开火”的函数,其设置bWantsToFire为真后,交给DetermineWeaponState决定武器真正的状态。**请求开火不一定能够开火成功**
2. DetermineWeaponState函数决定了真正状态后,会通过SetWeaponState函数,真正设置武器状态
3. 在设置时检测,如果前一状态为武器尚未开火,现在状态为开火,则调用OnBurstStarted函数
1. OnBrustStarted函数根据武器开火间隔,设置一个计时器,定时调用HandleFiring函数。这就是持续更新函数案例。
2. HandleFiring函数真正进行武器开火操作,包括:
* 产生开火特效
* 调用FireWeapon函数进行武器开火计算
* 减少弹药
* 决定是否需要重新装弹
4. 当StopFire被调用时,设置bWantsToFire为假,交给DetermineWeaponState决定武器真正的状态。
* 状态转换的条件均为数值属性,能够持久存储。这样设计的原因将会在下文阐述网络同步时进行解释。
* 子类通过扩展FireWeapon函数以具体处理武器开火的核心计算
## 武器系统简单总结
由于下文分析网络同时,会再次分析武器系统,故此处只是简单介绍了武器的整体逻辑。
>Shooter Game告诉我们,FPS类型的武器系统设计可以考虑一个轻量级的状态机实现,状态机的条件变量以布尔存储,然后用一个公用的状态切换函数在状态间进行切换。
# 虚幻引擎网络同步模型简述
虚幻引擎的网络同步模式相对来说比较好理解,此处简单叙述。如果读者朋友有兴趣,可以参考官方文档:[虚幻引擎网络联机与同步文档](https://docs.unrealengine.com/latest/CHN/Gameplay/Networking/index.html)
1. 服务端客户端模式:
1. 非P2P联机,在网络中存在一个服务端以及若干个客户端。
2. 服务端为权威,客户端是服务端的拙劣模仿
3. 服务端可以是一个普通的游戏实例,也可以是一个纯命令行的游戏进程(dedicated Server)
2. 客户端和服务端使用RPC进行消息传递,使用状态拷贝完成同步
1. 客户端和服务端之间可以互相发送RPC消息
* RPC:远程过程调用。允许像调用一个普通函数一样,在本地主机调用位于远程主机上的某个函数。
2. 服务端权威,客户端只能从服务端获取对象状态并更新本地状态
1. 服务端的对象如果成员变量有所变化,差异的部分将会被传输到各个客户端,更新客户端状态
3. 客户端是服务端的拙劣模仿
1. 客户端始终将输入发送到服务端,以更新服务端状态
2. 服务端将当前状态同步到客户端
3. 客户端根据服务端状态同步内容,**选用有效的方式修正以向服务端此时可能的状态靠近**
# Shooter Game的网络同步设计
回顾前一节,分析Shooter Game的网络同步设计的核心在于以下问题:
1. 假定存在一个客户端主机和一个客户端主机
2. 客户端操作如何通过RPC调用通知服务端
3. 服务端如何响应当前操作
4. 服务端如何同步状态到客户端
而虚幻引擎推荐的网络设计流程是:
1. 先考虑在本地的逻辑(单机模式下的逻辑)
2. 再考虑跨越网络的数据同步
接下来我也将按照这样的范式进行分析。
## 人物网络同步设计
### 跑步
#### 逻辑
如果我们不考虑网络同步,则逻辑如下图,非常简单明了
![](https://box.kancloud.cn/9c1790f1d81211a136efd7ab04aa40f4_847x515.png)
人物跑步实际上通过两个部分进行处理:
1. 玩家按下Shift键时,设置布尔值bWantsToRun为真
2. 在需要获取跑步速度时,由继承自UMovementComponent的UShooterCharacterMovement从Character调用IsRunning,实质上是根据之前设置的布尔值,获得当前是否应当跑动的信息,然后更新属于Movement组件的MaxSpeed。
但是这个过程中我们需要注意以下几个问题:
1. Character的状态被记录了:
1. 玩家是否跑步的状态被布尔值记录
2. 玩家对Character的操作是对状态的改变。
有些朋友会询问,那这不是废话吗?我们不妨考虑另一种设计:
* * * * *
1. 玩家按下Shift,将当前速度乘以二
2. 玩家松开Shift,将当前速度除以二
* * * * *
请回忆前文中对同步系统的描述:虚幻引擎需要同步状态,因此我们不能够采用上文所述的设计。
那么,在有网络同步的状态下,情况会变成什么样子呢?应该按照什么样的方式将逻辑更新?
简单来说,就是:
1. 通过RPC在服务器端改变状态
2. 通过复制机制,将服务端状态复制到本地
回顾跑步本地图,则步骤1是指“更新bWantsToRun”这个函数调用,即SetRunning函数;步骤2则不需要任何操作,虚幻引擎将会直接帮助你完成。新的图如下:
![](https://box.kancloud.cn/aaf3a409148e8b288fb0b8e0dd8f45c7_1866x723.png)
此图中的重点在于:
1. 当客户端的Character收到Shift键按下的消息时,不仅更新了本地的bWantsToRun这些布尔值,还通过RPC请求,向服务端要求更新这几个布尔值。图中有一段网络时延,就是为了强调RPC调用中的数据延迟。
2. 在接下来的数据同步时机到来时,服务端(UShooterCharacter_server)会将这个布尔值复制到除了源客户端的Character以外的其他几个客户端的Character身上。
3. 于是在接下来的更新过程中,每个客户端看到的源客户端的Character,其移动速度都是一致的
从实际角度来说,有一个更容易想到的思维模式,即源客户端不再本地直接更新bWantsToRun,而是直接通过RPC请求服务端更新,等待服务端复制状态到本地。过程如下图:
![](https://box.kancloud.cn/0524db12810da94586c92a687938c7b8_1866x698.png)
这两种有何不同呢?为何Shooter Game采用了前一张图而不是后一张图的模式?
原因很简单, 注意消息编号。前一张图的模式中,3号消息更新完毕后,本地的速度已经得到了更新;而后一张图中,必须要等到5号消息完成时,本地的速度才能得到更新。故前者在高延迟状况下,本地的体验会更好(玩家一按跑步就已经跑了起来)。
#### 实现
那么接下来我们要分析的是,前文所述的机制,Shooter Game 是如何实现的。
实际上虚幻引擎的网络同步框架将会帮我们完成绝大多数的工作。
1. 首先我们需要标记需要进行同步的状态变量:
UPROPERTY的Replicated标记表示当前变量将会被从服务端拷贝到本地端
~~~
/** current running state */
UPROPERTY(Transient, Replicated)
uint8 bWantsToRun : 1;
~~~
2. 接下来我们需要设定变量的拷贝方式:
我们需要重载GetLifetimeReplicatedProps函数,然后在其中通过DOREPLIFETIME和DOREPLIFETIME_CONDITION宏,设置当前变量的拷贝模式。对于当前变量,我们只需要拷贝到其他除了源客户端外的客户端Character中。
~~~
void AShooterCharacter::GetLifetimeReplicatedProps(TArray< FLifetimeProperty > & OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
//...
DOREPLIFETIME_CONDITION(AShooterCharacter, bWantsToRun, COND_SkipOwner);
//...
}
~~~
此处的DOREPLIFETIME_CONDITION即表示条件拷贝,而 COND_SkipOwner表示除了主人以外的客户端均拷贝。Owner的概念在这里指的是“主人”,想象一场CS,除了当前操控的那个人物,我们具有话语权以外,其他的人物实际上都是布偶,是从服务端那里获取状态,然后在本地演戏陪你的布偶,我们不具有对他们的控制权。
通过这两个步骤,bWantsToRun变量在服务端更新后,将会拷贝到源客户端以外的其他客户端。
随后我们需要设置RPC函数,以完成前文图中的客户端向服务端更新的过程。具体方式如下:
1. 标记函数:
~~~
UFUNCTION(reliable, server, WithValidation)
void ServerSetRunning(bool bNewRunning, bool bToggle);
~~~
当前函数被标记:在服务端调用、可靠、带有验证。
被这样标记的函数,必须要提供两个函数:验证函数ServerSetRunning_Validate,实现函数ServerSetRunning_Implementation。真正的函数体将会由虚幻引擎自动进行生成。
验证函数返回值将会决定这次RPC调用的结果是否被采用,此处可以做各种验证以避免玩家作弊;
实现函数用于在服务端做出具体的工作
~~~
bool AShooterCharacter::ServerSetRunning_Validate(bool bNewRunning, bool bToggle)
{
return true;
}
void AShooterCharacter::ServerSetRunning_Implementation(bool bNewRunning, bool bToggle)
{
SetRunning(bNewRunning, bToggle);
}
~~~
2. 调用函数:
当玩家按下Shift键开始奔跑时,首先在本地设置状态变量(3号过程),然后通过RPC向服务端请求状态更新(4号过程),具体函数如下:
~~~
void AShooterCharacter::SetRunning(bool bNewRunning, bool bToggle)
{
bWantsToRun = bNewRunning;
bWantsToRunToggled = bNewRunning && bToggle;
if (Role < ROLE_Authority) //是否在服务端的状态判断
{
ServerSetRunning(bNewRunning, bToggle);
}
}
~~~
此处判断是否在服务端的代码非常有意思,方法是判断Role是否小于ROLE_Authority。关于Role的更多信息,请查看文档[Actor的Roles](https://docs.unrealengine.com/latest/CHN/Gameplay/Networking/Actors/Roles/index.html)。
简单而言,Role是判断“复制与同步”的。一个对象拥有Role和RemoteRole,分别表示本地和远端。如果当前Actor的Role是ROLE_Authority,表示当前Actor处于服务端,是“权威”,是各地客户端的“木偶”们模仿的对象,会不断将自身状态同步到各个客户端。而此时当前Actor的RemoteRole将会是ROLE_SimulatedProxy或者ROLE_AutonomousProxy,表示远端的Actor是通过模拟来模仿当前Actor的状态。
这是因为虚幻引擎并非简单进行状态复制,其同样拥有插值和预测机制,本地端的木偶们在获得服务端的权威数据后,会试图**猜测**服务端状态,并**修正**自身的状态向服务端靠拢。
对于这些机制的探讨已经超过了本文的范围,有兴趣的读者可以进一步阅读分析源码。
概略性地总结以下,对于像跑步这样的需要立刻反馈的操作,Shooter Game推荐的思路是:
1. 本地端首先更新状态
2. 通过RPC请求服务端更新状态
3. 服务端向非源客户端的其他客户端复制状态