[TOC]
# 武器系統
## 功能分析
Shooter Game的武器系統,提供的一套FPS武器的基本功能,列表如下:
1. 裝備/取消裝備武器
2. 開火
3. 上子彈
4. 拾取子彈
而一個武器的基本狀態包括如下:

## 功能總體設計
請注意,這僅僅只是一個“示意圖”。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. 再考慮跨越網絡的數據同步
接下來我也將按照這樣的范式進行分析。
## 人物網絡同步設計
### 跑步
#### 邏輯
如果我們不考慮網絡同步,則邏輯如下圖,非常簡單明了

人物跑步實際上通過兩個部分進行處理:
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則不需要任何操作,虛幻引擎將會直接幫助你完成。新的圖如下:

此圖中的重點在于:
1. 當客戶端的Character收到Shift鍵按下的消息時,不僅更新了本地的bWantsToRun這些布爾值,還通過RPC請求,向服務端要求更新這幾個布爾值。圖中有一段網絡時延,就是為了強調RPC調用中的數據延遲。
2. 在接下來的數據同步時機到來時,服務端(UShooterCharacter_server)會將這個布爾值復制到除了源客戶端的Character以外的其他幾個客戶端的Character身上。
3. 于是在接下來的更新過程中,每個客戶端看到的源客戶端的Character,其移動速度都是一致的
從實際角度來說,有一個更容易想到的思維模式,即源客戶端不再本地直接更新bWantsToRun,而是直接通過RPC請求服務端更新,等待服務端復制狀態到本地。過程如下圖:

這兩種有何不同呢?為何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. 服務端向非源客戶端的其他客戶端復制狀態