第一部分
在幀同步模型中劫侧,每個客戶端都會對整個游戲世界進行模擬瞬内。這種方法的好處在于減少了需要發(fā)送的信息卓箫。幀同步只需要發(fā)送用戶的輸入信息载矿,而對于反過來的中心服務器模型來說,單位的信息則發(fā)送越頻繁越好烹卒。
比如說你在游戲世界中移動角色闷盔。在中心服務器模型中,物理模擬只會在服務器執(zhí)行旅急》旯矗客戶端告訴服務器,角色要往哪個方向移動藐吮。服務器會執(zhí)行尋路而且開始移動角色溺拱。服務器緊接著就會盡可能頻繁地告知每個客戶端該角色的位置逃贝。對于游戲世界中的每個角色都要運行這樣的過程。對于實時策略游戲來說迫摔,同步成千上萬的單位在中心服務器模型中幾乎是不可能的任務沐扳。
在幀同步模型中,在用戶決定移動角色之后,就會告訴所有客戶端。每個客戶端都會執(zhí)行尋路以及更新角色位置阔拳。只有用戶輸入的時候才需要通知每個客戶端,然后每個客戶端都會自己更新物理以及位置杨拐。
這個模型帶來了一些問題。每個客戶端的模擬都必須執(zhí)行得一模一樣擂啥。這意味著哄陶,物理模擬必須執(zhí)行同樣的更新次數(shù)而且每個動作都需要同樣的順序執(zhí)行。如果不這么做哺壶,其中一個客戶端就會跑在其他客戶端之前或者之后屋吨,然后在新的命令發(fā)出之后,跑得太快或者太慢的客戶端走出的路徑就會不同变骡。這些不同會根據(jù)不同的游戲玩法而不同离赫。
另一個問題就是跨不同的機器和平臺的確定性問題。計算上很小的不同都會對游戲造成蝴蝶效應塌碌。這個問題會在后續(xù)的文章中講到。
這里的實現(xiàn)方案靈感來自于這篇文章:《1500個弓箭手》旬盯。每個玩家命令都會在后續(xù)的兩個回合中執(zhí)行台妆。在發(fā)送動作與處理動作之間存在延遲有助于對抗網(wǎng)絡延遲。這個實現(xiàn)還給我們留下了根據(jù)延遲以及機器性能動態(tài)調整每回合時長的空間胖翰。這部分在這里先不討論接剩,會在后續(xù)文章再說。
對于這個實現(xiàn)萨咳,我們有如下定義:
幀同步回合:幀同步回合可以由多個游戲回合組成懊缺。玩家在一個幀同步回合執(zhí)行一個動作。幀同步回合長度會根據(jù)性能調整培他。目前硬編碼為200ms鹃两。
游戲回合:游戲回合就是游戲邏輯和物理模擬的更新。每個幀同步回合擁有的游戲回合次數(shù)是由性能控制的舀凛。目前硬編碼為50ms俊扳,也就是每次幀同步回合有4次游戲回合。也就是每秒有20次游戲回合猛遍。
動作:一個動作就是玩家發(fā)起的一個命令馋记。比如說在某個區(qū)域內選中單位号坡,或者移動選中單位到目的地。
注意:我們將不使用unity3d的物理引擎梯醒。而是使用一個確定性的自定義引擎宽堆。在后續(xù)文章中會有實現(xiàn)。
游戲主循環(huán)
Unity3d的循環(huán)是運行在單線程下的茸习∪蒸铮可以通過在這兩個函數(shù)插入自定義代碼:
Update()
FixedUpdate()
Unity3d的主循環(huán)每次遍歷更新都會調用Update()。主循環(huán)會以最快速度運行逮光,除非設置了固定的幀率代箭。FixedUpdate()會根據(jù)設置每秒執(zhí)行固定次數(shù)。在主循環(huán)遍歷中涕刚,它會被調用零次或多次嗡综,取決于上次遍歷所花費的時間。FixedUpdate()有著我們想要的行為杜漠,就是每次幀同步回合都執(zhí)行固定時長极景。但是,F(xiàn)ixedUpdate()的頻率只能在運行之前設置好驾茴。而我們希望可以根據(jù)性能調節(jié)我們的游戲幀率盼樟。
游戲幀回合
這個實現(xiàn)有著與FixedUpdate()在Update()函數(shù)中執(zhí)行所類似的邏輯。主要不同的地方在于锈至,我們可以調整頻率晨缴。這是通過增加”累計時間”來完成的。每次調用Update()函數(shù)峡捡,上次遍歷所花費的時間會添加到其中击碗。這就是Time.deltaTime。如果累計時間大于我們的固定游戲回合幀率(50ms)们拙,那么我們就會調用gameframe()稍途。我們每次調用gameframe()都會在累計時間上減去50ms,所以我們一直調用砚婆,知道累計時間小于50ms械拍。
private?float?AccumilatedTime?=?0f;
private?float?FrameLength?=?0.05f;?//50?miliseconds
//called?once?per?unity?frame
public?void?Update()?{
//Basically?same?logic?as?FixedUpdate,?but?we?can?scale?it?by?adjusting?FrameLength
AccumilatedTime?=?AccumilatedTime?+?Time.deltaTime;
//in?case?the?FPS?is?too?slow,?we?may?need?to?update?the?game?multiple?times?a?frame
while(AccumilatedTime?>?FrameLength)?{
GameFrameTurn?();
AccumilatedTime?=?AccumilatedTime?-?FrameLength;
}
}
我們跟蹤當前幀同步回合中游戲幀的數(shù)量。每當我們在幀同步回合中達到我們想要的游戲回合次數(shù)装盯,我們就會更新幀同步回合到下一輪坷虑。如果幀同步還不能到下一輪,我們就不能增加游戲幀验夯,而且我們會在下一次同樣執(zhí)行幀同步檢查猖吴。
private?void?GameFrameTurn()?{
//first?frame?is?used?to?process?actions
if(GameFrame?==?0)?{
if(LockStepTurn())?{
GameFrame++;
}
}?else?{
//update?game
//...
GameFrame++;
if(GameFrame?==?GameFramesPerLocksetpTurn)?{
GameFrame?=?0;
}
}
}
在游戲回合中,物理模擬會更新而且我們的游戲邏輯也會更新挥转。游戲邏輯是通過接口(IHasGameFrame)來實現(xiàn)的海蔽,而且添加這個對象到集合中共屈,然后我們就可以進行遍歷。
private?void?GameFrameTurn()?{????//first?frame?is?used?to?process?actions????if(GameFrame?==?0)?{????????if(LockStepTurn())?{????????????GameFrame++;????????}????}?else?{????????//update?game????????SceneManager.Manager.TwoDPhysics.Update?(GameFramesPerSecond);?????????????????Listfinished?=?new?List();
foreach(IHasGameFrame?obj?in?SceneManager.Manager.GameFrameObjects)?{
obj.GameFrameTurn(GameFramesPerSecond);
if(obj.Finished)?{
finished.Add?(obj);
}
}
foreach(IHasGameFrame?obj?in?finished)?{
SceneManager.Manager.GameFrameObjects.Remove?(obj);
}
GameFrame++;
if(GameFrame?==?GameFramesPerLocksetpTurn)?{
GameFrame?=?0;
}
}
}
IHasGameFrame接口有一個方法叫做GameFrameTurn党窜,它以當前每秒游戲幀的個數(shù)為參數(shù)拗引。一個具體的帶游戲邏輯的對象應該基于GameFramesPerSecond來計算。比如說幌衣,如果一個單位正在攻擊另一個單位矾削,而且他攻擊頻率為每秒鐘10點傷害,你可能會通過將它除以GameFramesPerSecond來添加傷害豁护。而GameFramesPerSecond會根據(jù)性能進行調整哼凯。
IHasGameFrame接口也有屬性標記著結束。這使得實現(xiàn)IHasGameFrame的對象可以通知游戲幀循環(huán)自己已經(jīng)結束楚里。一個例子就是一個對象跟著路徑行走断部,而在到達目的地之后,這個對象就不再需要了班缎。
幀同步回合
為了與其他客戶端保持同步蝴光,每次幀同步回合我們都要問以下問題:
我們已經(jīng)收到了所有客戶端的下一輪動作了嗎?
每個客戶端都確認得到我們的動作了嗎达址?
我們有兩個對象蔑祟,ConfirmedActions和PendingActions。這兩個都有各自可能收到消息的集合沉唠。在我們進入下一個回合之前疆虚,我們會檢查這兩個對象。
private?bool?NextTurn()?{
if(confirmedActions.ReadyForNextTurn()?&&?pendingActions.ReadyForNextTurn())?{
//increment?the?turn?ID
LockStepTurnID++;
//move?the?confirmed?actions?to?next?turn
confirmedActions.NextTurn();
//move?the?pending?actions?to?this?turn
pendingActions.NextTurn();
return?true;
}
return?false;
}
動作
動作右冻,也就是命令装蓬,都通過實現(xiàn)IAction接口來通信。有著一個無參數(shù)函數(shù)叫做ProcessAction()纱扭。這個類必須為Serializable。這意味著這個對象的所有字段也是Serializable的儡遮。當用戶與UI交互乳蛾,動作的實例就會創(chuàng)建,然后發(fā)送到我們的幀同步管理器的隊列中鄙币。隊列通常在游戲太慢而用戶在一個幀同步回合中發(fā)送多于一個命令的時候用到肃叶。雖然每次只能發(fā)送一個命令,但沒有一個會忽略十嘿。
當發(fā)送動作到其他玩家的時候因惭,動作實例會序列化為字節(jié)數(shù)組,然后被其他玩家反序列化绩衷。一個默認的”非動作”對象會在用戶沒有執(zhí)行任何操作的時候發(fā)送蹦魔。而其他則會根據(jù)特定游戲邏輯而定激率。這里是一個創(chuàng)建新單位的動作:
using?System;
using?UnityEngine;
[Serializable]
public?class?CreateUnit?:?IAction
{
int?owningPlayer;
int?buildingID;
public?CreateUnit?(int?owningPlayer,?int?buildingID)?{
this.owningPlayer?=?owningPlayer;
this.buildingID?=?buildingID;
}
public?void?ProcessAction()?{
Building?b?=?SceneManager.Manager.GamePieceManager.GetBuilding(owningPlayer,?buildingID);
b.SpawnUnit();
}
}
這個動作會依賴于SceneManager的靜態(tài)引用。如果你不喜歡這個實現(xiàn)勿决,可以修改IAction接口乒躺,使得ProcessAction接收一個SceneManager實例。
實例代碼可以在這里找到:Bitbucket – Sample Lockstep
第二部分
概覽
在上次實現(xiàn)的幀同步模型當中低缩,游戲幀率和通信頻率(也就是幀同步長度)長度是固定間隔的嘉冒。但實際上,每個玩家的延遲和性能都不同的咆繁。在update中會跟蹤兩個變量讳推。第一個是玩家通信的時長。第二個則是游戲的性能時長玩般。
移動平均數(shù)
為了處理延遲上的波動银觅,我們想快速增加幀同步回合的時長,同時也想在低延遲的時候減少壤短。如果游戲更新的節(jié)奏能夠根據(jù)延遲的測量結果自動調節(jié)设拟,而不是固定值的話,會使得游戲玩起來更加順暢久脯。我們可以累加所有的過去信息得到”移動平均數(shù)”纳胧,然后根據(jù)它作為調節(jié)的權重。
每當一個新值大于平均數(shù)帘撰,我們會設置平均數(shù)為新值跑慕。這會得到快速增加延遲的行為。當值小于當前平均值摧找,我們會通過權重處理該值核行,我們有以下公式:
newAverage=currentAverage?(1–w)+newValue?(w)
其中0
在我的實現(xiàn)中,我設置w=0.1蹬耘。而且還會跟蹤每個玩家的平均數(shù)芝雪,而且總是使用所有玩家當中的最大值。這里是增加新值的方法:
public?void?Add(int?newValue,?int?playerID)?{
if(newValue?>?playerAverages[playerID])?{
//rise?quickly
playerAverages[playerID]?=?newValue;
}?else?{
//slowly?fall?down
playerAverages[playerID]?=?(playerAverages[playerID]?*?(9)?+?newValue?*?(1))?/?10;
}
}
為了保證計算結果的確定性综苔,計算只使用整數(shù)惩系。因此公式調整如下:
newAverage=(currentAverage?(10–w)+newValue?(w))/10
其中0
而在我的例子中,w=1如筛。
運行時間平均數(shù)
每次游戲幀更新的時間是由運行時間平均數(shù)決定的堡牡。如果游戲幀要變得更長,那么我們需要降低每次幀同步回合更新游戲幀的次數(shù)杨刨。另一方面晤柄,如果游戲幀執(zhí)行得更快了,每次幀同步回合可以更新游戲幀的次數(shù)也多了妖胀。對于每次幀同步回合芥颈,最長的游戲幀會被添加到平均數(shù)中惠勒。每次幀同步回合的第一個游戲幀都包含了處理動作的時間。這里使用Stopwatch來計算流逝的時間浇借。
private?void?ProcessActions()?{
//process?action?should?be?considered?in?runtime?performance
gameTurnSW.Start?();
...
//finished?processing?actions?for?this?turn,?stop?the?stopwatch
gameTurnSW.Stop?();
}
private?void?GameFrameTurn()?{
...
//start?the?stop?watch?to?determine?game?frame?runtime?performance
gameTurnSW.Start();
//update?game
...
GameFrame++;
if(GameFrame?==?GameFramesPerLockstepTurn)?{
GameFrame?=?0;
}
//stop?the?stop?watch,?the?gameframe?turn?is?over
gameTurnSW.Stop?();
//update?only?if?it's?larger?-?we?will?use?the?game?frame?that?took?the?longest?in?this?lockstep?turn
long?runtime?=?Convert.ToInt32?((Time.deltaTime?*?1000))/*deltaTime?is?in?secounds,?convert?to?milliseconds*/?+?gameTurnSW.ElapsedMilliseconds;
if(runtime?>?currentGameFrameRuntime)?{
currentGameFrameRuntime?=?runtime;
}
//clear?for?the?next?frame
gameTurnSW.Reset();
}
注意到我們也用到了Time.deltaTime捉撮。使用這個可能會在游戲以固定幀率執(zhí)行的情況下與上一幀時間重疊。但是妇垢,我們需要用到它巾遭,這使得Unity為我們所做的渲染以及其他事情都是可測量的。這個重疊是可接受的闯估,因為只是需要更大的緩沖區(qū)而已灼舍。
網(wǎng)絡平均數(shù)
拿什么作為網(wǎng)絡平均數(shù)在這里不太明確。我最終使用了Stopwatch計算從玩家發(fā)送數(shù)據(jù)包到玩家確認動作的時間涨薪。這個幀同步模型發(fā)送的動作會在未來兩個回合中執(zhí)行骑素。為了結束幀同步回合,我們需要所有玩家都確認了這個動作刚夺。在這之后献丑,我們可能會有兩個動作等待對方確認。為了解決這個問題侠姑,用到了兩個Stopwatch创橄。一個用于當前動作,另一個用于上一個動作莽红。這被封裝在ConfirmActions類當中妥畏。當幀同步回合往下走,上一個動作的Stopwatch會成為這一個動作的Stopwatch安吁,而舊的”當前動作Stopwatch”會被復用作為新的”上一個動作Stopwatch”醉蚁。
public?class?ConfirmedActions
{
...
public?void?NextTurn()?{
...
Stopwatch?swapSW?=?priorSW;
//last?turns?actions?is?now?this?turns?prior?actions
...
priorSW?=?currentSW;
//set?this?turns?confirmation?actions?to?the?empty?array
...
currentSW?=?swapSW;
currentSW.Reset?();
}
}
每當有確認進來,我們會確認我們接收了所有的確認鬼店,如果接收到了网棍,那么就暫停Stopwatch。
public?void?ConfirmAction(int?confirmingPlayerID,?int?currentLockStepTurn,?int?confirmedActionLockStepTurn)?{
if(confirmedActionLockStepTurn?==?currentLockStepTurn)?{
//if?current?turn,?add?to?the?current?Turn?Confirmation
confirmedCurrent[confirmingPlayerID]?=?true;
confirmedCurrentCount++;
//if?we?recieved?the?last?confirmation,?stop?timer
//this?gives?us?the?length?of?the?longest?roundtrip?message
if(confirmedCurrentCount?==?lsm.numberOfPlayers)?{
currentSW.Stop?();
}
}?else?if(confirmedActionLockStepTurn?==?currentLockStepTurn?-1)?{
//if?confirmation?for?prior?turn,?add?to?the?prior?turn?confirmation
confirmedPrior[confirmingPlayerID]?=?true;
confirmedPriorCount++;
//if?we?recieved?the?last?confirmation,?stop?timer
//this?gives?us?the?length?of?the?longest?roundtrip?message
if(confirmedPriorCount?==?lsm.numberOfPlayers)?{
priorSW.Stop?();
}
}?else?{
//TODO:?Error?Handling
log.Debug?("WARNING!!!!?Unexpected?lockstepID?Confirmed?:?"?+?confirmedActionLockStepTurn?+?"?from?player:?"?+?confirmingPlayerID);
}
}
發(fā)送平均數(shù)
為了讓一個客戶端向其他客戶端發(fā)送平均數(shù)妇智,Action接口修改為一個有兩個字段的抽象類确沸。
[Serializable]
public?abstract?class?Action
{
public?int?NetworkAverage?{?get;?set;?}
public?int?RuntimeAverage?{?get;?set;?}
public?virtual?void?ProcessAction()?{}
}
每當處理動作,這些數(shù)字會加到運行平均數(shù)俘陷。然后幀同步回合以及游戲幀回合開始更新
private?void?UpdateGameFrameRate()?{
//log.Debug?("Runtime?Average?is?"?+?runtimeAverage.GetMax?());
//log.Debug?("Network?Average?is?"?+?networkAverage.GetMax?());
LockstepTurnLength?=?(networkAverage.GetMax?()?*?2/*two?round?trips*/)?+?1/*minimum?of?1?ms*/;
GameFrameTurnLength?=?runtimeAverage.GetMax?();
//lockstep?turn?has?to?be?at?least?as?long?as?one?game?frame
if(GameFrameTurnLength?>?LockstepTurnLength)?{
LockstepTurnLength?=?GameFrameTurnLength;
}
GameFramesPerLockstepTurn?=?LockstepTurnLength?/?GameFrameTurnLength;
//if?gameframe?turn?length?does?not?evenly?divide?the?lockstep?turn,?there?is?extra?time?left?after?the?last
//game?frame.?Add?one?to?the?game?frame?turn?length?so?it?will?consume?it?and?recalculate?the?Lockstep?turn?length
if(LockstepTurnLength?%?GameFrameTurnLength?>?0)?{
GameFrameTurnLength++;
LockstepTurnLength?=?GameFramesPerLockstepTurn?*?GameFrameTurnLength;
}
LockstepsPerSecond?=?(1000?/?LockstepTurnLength);
if(LockstepsPerSecond?==?0)?{?LockstepsPerSecond?=?1;?}?//minimum?per?second
GameFramesPerSecond?=?LockstepsPerSecond?*?GameFramesPerLockstepTurn;
PerformanceLog.LogGameFrameRate(LockStepTurnID,?networkAverage,?runtimeAverage,?GameFramesPerSecond,?LockstepsPerSecond,?GameFramesPerLockstepTurn);
}
更新:支持單個玩家
自從本文發(fā)出以來,增加了單人模式得支持观谦。
特別感謝redstinggames.com的Dan提供拉盾。可以在以下看到修改:Single Player Update diff
源代碼:Source code on bitbucket – Dynamic Lockstep Sample
原文: