💎一站式轻松地调用各大LLM模型接口,支持GPT4、智谱、星火、月之暗面及文生图 广告
[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. 服务端向非源客户端的其他客户端复制状态