在上一篇文章已經(jīng)介紹完在服務(wù)端控制的物體通過把狀態(tài)發(fā)到客戶端,客戶端去"追趕"服務(wù)器的狀態(tài)來實(shí)現(xiàn)同步的,現(xiàn)在來談?wù)勅绾卧诳蛻舳俗霰镜仡A(yù)表現(xiàn).
1.什么要本地預(yù)表現(xiàn)?為什么要本地預(yù)表現(xiàn)?
本地預(yù)表現(xiàn)(本地預(yù)測(cè)),就是玩家操作游戲角色時(shí),按下按鍵立刻得到操作的反饋.
有些競(jìng)技游戲尤其FPS游戲,講究及時(shí)的操作響應(yīng)性,試想,如果沒有本地預(yù)表現(xiàn),那么玩家按下一個(gè)按鍵想要釋放技能,卻要等待服務(wù)器的回包之后才釋放得出來,由于網(wǎng)絡(luò)波動(dòng)延遲的影響,回包的時(shí)間還不確定,如果延遲很低的話可能還可以接受,對(duì)于延遲很高的玩家就比較難受了.為了優(yōu)化這樣的用戶體驗(yàn),最好是能實(shí)現(xiàn)客戶端的本地預(yù)表現(xiàn).
2.客戶端生成操作指令并且本地模擬.向服務(wù)器發(fā)送操作指令
對(duì)于需要本地預(yù)表現(xiàn)的單位來說,當(dāng)它得到了操作輸入指令(CommandInput)的時(shí)候,應(yīng)該立即把這個(gè)指令拿去執(zhí)行,而不需要等服務(wù)器的回包.
// 每個(gè)模擬幀要執(zhí)行的方法
public void Simulate()
{
OnSimulateBefore();
if(isLocalPredicted) //如果是需要本地預(yù)測(cè)的單位,獲取指令,直接執(zhí)行指令即可
{
Command cmd = new Command ();
cmd.input = CollectCommandInput(); // 獲取指令
ExecuteCommand(cmd); // 執(zhí)行指令
}
OnSimulateAfter();
}
這樣客戶端就是一直獲取操作輸入,然后執(zhí)行操作指令,然后就需要把操作指令上傳到服務(wù)端,客戶端發(fā)包應(yīng)該也有一個(gè)發(fā)包頻率(ClientSendRate),因?yàn)榭蛻舳酥桓?wù)器通信,所以它可以比服務(wù)器的發(fā)包頻率快.
因?yàn)楸镜氐哪M頻率是60幀/秒,相當(dāng)于每秒產(chǎn)生了60個(gè)Command,客戶端需要按ClientSendRate把指令上傳到服務(wù)端,所以需要把Command緩存進(jìn)隊(duì)列.
// 每個(gè)模擬幀要執(zhí)行的方法
public void Simulate()
{
OnSimulateBefore();
if(isLocalPredicted) //如果是需要本地預(yù)測(cè)的單位,獲取指令,直接執(zhí)行指令即可
{
Command cmd = new Command ();
cmd.input = CollectCommandInput(); // 獲取指令
ExecuteCommand(cmd); // 執(zhí)行指令
cmd.flags |= CommandFlags.HAS_EXECUTED; //標(biāo)記這個(gè)命令執(zhí)行過了
commandQueue.Enqueue(cmd); //已經(jīng)執(zhí)行過的指令,需要緩存
}
OnSimulateAfter();
}
客戶端執(zhí)行過的操作指令都緩存在隊(duì)列里,然后就要隊(duì)列指令都發(fā)送給服務(wù)端了.
public void PackInput(Packet packet)
{
packet.Write(entity.commandQueue.Count);
foreach(Command cmd in entity.commandQueue)
{
cmd.PackInput(packet); //將本地模擬過的操作輸入寫入消息包
}
}
3.服務(wù)器接收到客戶端的操作指令并且逐幀模擬.向客戶端發(fā)送模擬結(jié)果
private void ReadInput(Packet packet)
{
int count = packet.ReadInt();
for(int i = 0; i < count; i++)
{
Command command = new Command();
command.ReadInput(packet);
entity.commandQueue.Enqueue(command); //將客戶端的指令存入指令隊(duì)列
}
}
服務(wù)器拿到客戶端的操作輸入之后.接下來就要為客戶端模擬輸入指令.
// 服務(wù)器為客戶端執(zhí)行指令(每個(gè)模擬幀執(zhí)行一次)
private int ExecuteCommandsFromClient()
{
foreach(Command cmd in commandQueue)
{
if (!(cmd.flags & CommandFlags.HAS_EXECUTED)) //如果這個(gè)指令未執(zhí)行過
{
ExecuteCommand(cmd); //服務(wù)器執(zhí)行這個(gè)指令,執(zhí)行的邏輯兩端應(yīng)該是一致的
cmd.flags |= CommandFlags.HAS_EXECUTED; //標(biāo)記這個(gè)命令執(zhí)行過了
break;
}
}
}
服務(wù)器把客戶端的指令模擬完了以后,模擬的結(jié)果還是緩存在commandQueue中的(因?yàn)镃ommand類包含了Input和Result),那么在服務(wù)器向客戶端發(fā)包的時(shí)候,就需要把Result給發(fā)送到客戶端了.
// 服務(wù)器打包操作結(jié)果
public void PackResult(Packet packet)
{
packet.Write(entity.commandQueue.Count);
foreach(Command cmd in entity.commandQueue)
{
cmd.PackResult(packet); //將本地模擬過的操作結(jié)果寫入消息包
}
}
4.客戶端與服務(wù)端發(fā)來的模擬結(jié)果對(duì)比
// 客戶端收到操作結(jié)果
public void ReadResult(Packet packet)
{
int count = packet.ReadInt();
List<Command> cmdsFromServer = new List<Command>();
for(int i = 0; i < count; i++)
{
Command command = new Command();
command.ReadResult(packet); //從消息包中取出Result
cmdsFromServer.Add(command);
}
}
終于到這里了,因?yàn)榭蛻舳艘簿S護(hù)了一個(gè)指令隊(duì)列(commandQueue),它包含了客戶端本地預(yù)表現(xiàn)的所有執(zhí)行過的指令輸入和結(jié)果,當(dāng)客戶端收到了服務(wù)器下發(fā)的指令結(jié)果以后,就可以本地模擬的結(jié)果和服務(wù)器模擬的結(jié)果做對(duì)比.在如何實(shí)現(xiàn)確定性的網(wǎng)絡(luò)同步中,定義的Command類中是有個(gè)sequence變量來表示指令序號(hào)的.
// 客戶端收到操作結(jié)果
public void ReadResult(Packet packet)
{
int count = packet.ReadInt();
List<Command> cmdsFromServer = new List<Command>();
for(int i = 0; i < count; i++)
{
Command command = new Command();
command.ReadResult(packet); //從消息包中取出Result
cmdsFromServer.Add(command);
}
Command lastFromserver = cmdsFromServer[cmdsFromServer.Count - 1]; //服務(wù)器最后模擬的指令
foreach(Command localCmd in entity.commandQueue)
{
if(localCmd.sequence <= lastFromserver.sequence) //如果客戶端的指令序號(hào) 小于等于 服務(wù)器最后一個(gè)指令序號(hào)
{
localCmd.flags |= CommandFlags.VERIFIED; //標(biāo)記這個(gè)指令服務(wù)器已經(jīng)確認(rèn)過
}
}
}
那么現(xiàn)在,客戶端的指令隊(duì)列(commandQueue)中包含了很多指令,因?yàn)樯弦黄恼?a href="http://www.itdecent.cn/p/6c1b37735c85" target="_blank">服務(wù)器將狀態(tài)同步給客戶端說明了,服務(wù)器會(huì)將狀態(tài)發(fā)給客戶端,對(duì)于本地模擬的客戶端來說,收到的狀態(tài)包可以直接設(shè)置,這里就會(huì)出現(xiàn)一個(gè)問題了,如果直接設(shè)置的話,因?yàn)榭蛻舳吮镜仡A(yù)表現(xiàn)了,收到的狀態(tài)是舊的.直接設(shè)置不就造成抖動(dòng)了嗎?所以解決的辦法就是客戶端在一幀把之前所有的執(zhí)行過的指令(除了服務(wù)器驗(yàn)證過的)重新執(zhí)行一遍.
守望先鋒的文章也是這樣說明的:
客戶端是一股腦的盡快接受玩家輸入,盡可能地貼近現(xiàn)在時(shí)刻.
一旦從服務(wù)器回包發(fā)現(xiàn)預(yù)測(cè)失敗,我們把你的全部輸入都重播一遍直至追上當(dāng)前時(shí)刻。
當(dāng)客戶端收到描述角色狀態(tài)的數(shù)據(jù)包時(shí),我們基本上就得把移動(dòng)狀態(tài)及時(shí)恢復(fù)到最近一次經(jīng)過服務(wù)器驗(yàn)證過狀態(tài)上去,而且必須重新計(jì)算之后所有的輸入操作,直至追上當(dāng)前時(shí)刻。
// 每個(gè)模擬幀要執(zhí)行的方法
public void Simulate()
{
OnSimulateBefore();
if(isLocalPredicted) //如果是需要本地預(yù)測(cè)的單位,獲取指令,直接執(zhí)行指令即可
{
foreach (Command cmd in commandQueue)
{
if((cmd.flags & CommandFlags.HAS_EXECUTED) && !(cmd.flags & CommandFlags.VERIFIED)) //本地已經(jīng)執(zhí)行過 且 沒有被服務(wù)確認(rèn)過的指令
{
ExecuteCommand(cmd);
}
}
Command cmd = new Command ();
cmd.input = CollectCommandInput(); // 獲取指令
ExecuteCommand(cmd); // 執(zhí)行指令
cmd.flags |= CommandFlags.HAS_EXECUTED; //標(biāo)記這個(gè)命令執(zhí)行過了
commandQueue.Enqueue(cmd); //已經(jīng)執(zhí)行過的指令,需要緩存
}
OnSimulateAfter();
}
對(duì)于預(yù)表現(xiàn)的客戶端,需要在模擬之前OnSimulateBefore()的時(shí)候直接應(yīng)用服務(wù)器下發(fā)的狀態(tài),每個(gè)模擬幀,客戶端都把本地已經(jīng)執(zhí)行過而且沒有被服務(wù)確認(rèn)過的指令都執(zhí)行一遍,然后再生成新的指令.如此,預(yù)表現(xiàn)的實(shí)現(xiàn)就基本完成了.

總的流程應(yīng)該是這樣:

5.小結(jié)
對(duì)于客戶端的預(yù)表現(xiàn),核心在于要遵循確定性的原則,一個(gè)狀態(tài) + N個(gè)指令 = 新的狀態(tài),客戶端跟服務(wù)器的模擬結(jié)果應(yīng)該是一致的.這樣就能保持穩(wěn)定的同步.
對(duì)于丟包導(dǎo)致的預(yù)測(cè)失敗,需要在客戶端做丟包重發(fā)的機(jī)制,而服務(wù)器也可以適當(dāng)?shù)膹闹暗闹噶顏硗茰y(cè)客戶端操作來模擬,以緩和丟包的情況.