關於網路游戲地圖中人物的移動的同步一直以來的都是mmo,moba,fps等游戲的核心邏輯,也是開發的重點和難點,其實現的質量直接影響的玩家的體驗。一個高效,實時,平滑的同步往往需要后端和前端相互配合才能達到最佳效果。下面將結合前端代碼 uinty 代碼及后端 c 代碼詳細講解同步算法的實現。
題外話
關於同步顯示的需求最早始於網游誕生之前,美軍戰斗機上的雷達屏幕需要顯示多機編隊的位置,是實上在網游上有大量的這樣minimap,在幾十年前這絕對屬於高精尖。
同步包括下面幾個部分:
1.服務器與客戶端時間同步
- 步進一致性
服務器與客戶端的當前時間是不一直的,即使雙方都使用遠程原子鍾校准,也會不可抗拒的受本地系統負載,原子鍾服務器負載,網絡延時等因素的影響。但是時間的步進可以趨近於一致的,當前系統可以拿到步進(tick)可以達到 100 ns(納秒)級 (10 ticks = microseconds 微秒 ,s(秒)、ms(毫秒)、μs(微秒)、ns(納秒),其中:1s=1000ms,1 ms=1000μs,1μs=1000ns),而我們做同步用到的 ms(毫秒)就足夠了,所以我們有理由把服務器與客戶端的時間步進當做是同步的來處理,也就是不管服務器與客戶端的時間點是否同步,但隨着時間的推移,服務器時間每增加一毫秒的單位,客戶端的時間也增加一毫秒的單位。有了“步進一致”的基礎后我們就可以完成下面的工作。

- 基准網絡延時的估算(請求返回時間延時量)
服務器與客戶端相互通信,網絡延時是不可避免的,雖然在物理層,電子流動速度的逼近光速,但信號是需要電平高低的變化才能完成傳遞,加上鏈路層復雜的硬件連接,物理地址尋址、數據的成幀、流量控制、數據的檢錯、重發,各個模塊的緩沖,控制總線的邏輯消耗,tcp協議棧為保證數據可靠性傳輸對數據的層層包裹與尋址操作,以及上下行帶寬,物理服的節點連同距離,各個節點負載,數據的在網絡傳輸的延時就不可避免的產生了。其次網絡傳輸速率受雙方系統負載以及拓普網絡負載變化的影響存在不可避免的波動性,所以延時量本身也在不斷變化中。而我們需要做的第一件事情就是估算一個客戶端到服務器加上服務器到客戶端的延時,也就是客戶端一個請求到服務器返回的時間,這個時間可以由客戶端多少向服務端請求並返回的值進行求平均而來,我們這里實現的12次連續請求返回,每次保存請求返回的時間間隔,在完成12次后進行排序,去掉頭尾,對剩下的10個求平均,這樣在一定程度上排除網絡波動的影響。
網絡延時(請求+返回)獲取的具體細節如下
1.客戶端記錄當前時間戳,作為發送時間戳
2.客戶端向服務器發送測試消息,這個消息的數量很小能夠標識類型就足夠了,我們的實現4字節長度頭加兩字節標識。
3.服務器向客戶端回射測試消息(消息內容同客戶端請求)
4.客戶端收到返回后,再當前時間戳-發送時間戳得到網絡延時(請求+返回)
5.連續執行以上步驟12次,對網絡延時數據集合進行排序,去掉頭尾,對剩下的10個求平均

- 時間差計算
通過上個步驟我們拿到了網絡延時量(請求+返回),這個值拿到后我們就可以推算出客戶端與服務端的時間差,有了時間差由於“步進一致”性,只要消息中帶上各自的時間戳,我們就可以推算出當前消息的延時量了,計算方式為 時間差 = 服務器時間 + 延時量/2 - 客戶端時間
1.客戶端向服務器請求服務器當前系統時間utc
2.服務器向客戶端返回服務器當前系統時間utc
3.客戶端拿到服務器當前系統時間utc后 + 延時量/2 - 客戶端當前系統時間utc ,得到一個精確到毫秒的時間差,客戶端保存該值
4.客戶端向服務器發送 時間差
5.服務器保存 時間差 到相應客戶端數據中

有了時間差,客戶端可以做一個封裝,GetServerTime() 獲取當前服務器時間,在以后服務器向服務端發消息時帶上自己當前時間戳,到達后我們根據 GetServerTime() 的值就可以判斷出本次消息延時量從而對同步對象的速度有有依據的增加,同樣服務端也可以封裝 GetClientTime()
客戶端
long GetServerTime(){
//客戶端當前系統時間utc + 時間差
}
服務端
long GetClientTime() {
//服務端當前系統時間utc - 時間差
}
2.同步協議處理
一個同步協議在非p2p架構下需要以下步驟
客戶端 A ——> 服務器——> 客戶端 (N) N等於不包含A的其他客戶端
着其中包含了 客戶端 A的上行延時 + 客戶端 (N) 下行延時,而之前我們保存的時間差可以用來判斷當前具體消息的延時量,有了延時量我們就可以對坐標進行修正。
發送時間(客戶端),方向,速度,發送時坐標
1.客戶端 A ———————————————————————> 服務器
2.服務器根據 發送時間(客戶端),時間差 ,方向和速度將發送時坐標修正即時坐標
發送時間(服務器),方向,速度,服務器即時坐標
3.服務器 ———————————————————————> 客戶端 (N)
3.客戶端 (N)根據發送時間(服務器),方向,速度,服務器即時坐標再次修正即時坐標

3.客戶端邏輯
- 移動發起端 (player)
1.用戶在操作方向遙感時(方向遙感發生位移),把當前方向,速度,坐標發給服務器
2.方向遙感的角度變化時再次把當前方向,速度,坐標發給服務器
3.用戶離開方向遙感后把當前方向,速度,坐標發給服務器,
這三個操作都放在 update()中執行

- 移動同步端 (OtherPlayer)
客戶端收到同步協議並完成 服務器即時坐標到即時坐標的修正后,我們需要操作顯示對象移動到指定位置,此時當前位置到目標位置必然存在一個距離差,我們需要將顯示對象平滑的移動到目標位置,如果速度不為0,我們還要讓顯示對象繼續沿當前方向移動移動 ,所以這里需要同步狀態機做切換來控制顯示對象移動規則,同步消息可以在任何時候打斷當前狀態機,重新執行狀態機流轉過程
enum SceneObjectOtherMoveableRunState{
idle_stop,//待機狀態
to_target_position_before_run,//當前對象在有速度,移動到同步位置過程
to_target_position_stop,//當前對象停止,移動到同步位置過程
by_direction_run//根據速度和方向進行移動
}

計算網絡延時的核心代碼:
byte [] checkTimeDelayMsgBuff = new byte[6];
int checkTimeDelayMsgBuffIndex = 4;
int checkTimeDelayMsgLen = 2;
byte [] checkTimeDelayMsgLenBytes = new byte[4];
long checkTimeDelaySend;
/* 樣本數組 */
long []checkTimeDelaySample = new long[12];
/* 樣本數組下標 */
int checkTimeDelaySampleIndex = 0;
bool checkTimeDelayLoop = false;
public delegate void OnCheckTimeDelayCompleteMethod();
public OnCheckTimeDelayCompleteMethod onCheckTimeDelayComplete;
public void CheckTimeDelayRunOnce(OnCheckTimeDelayCompleteMethod onCheckTimeDelayCompletCallBack = null){
onCheckTimeDelayComplete += onCheckTimeDelayCompletCallBack;
checkTimeDelayLoop = false;
CheckTimeDelay ();
}
public void CheckTimeDelayRun(OnCheckTimeDelayCompleteMethod onCheckTimeDelayCompletCallBack = null){
onCheckTimeDelayComplete += onCheckTimeDelayCompletCallBack;
checkTimeDelaySampleIndex = 0;
checkTimeDelayLoop = true;
CheckTimeDelay ();
}
public bool IsCheckTimeDelayComplete(){
return checkTimeDelaySampleIndex == checkTimeDelaySample.Length;
}
void CheckTimeDelay(){
if (m_ClientSocket != null){
/* 數據包長度 **/
checkTimeDelayMsgLenBytes = BitConverter.GetBytes (checkTimeDelayMsgLen);
checkTimeDelayMsgBuff [0] = checkTimeDelayMsgLenBytes [0];
checkTimeDelayMsgBuff [1] = checkTimeDelayMsgLenBytes [1];
checkTimeDelayMsgBuff [2] = checkTimeDelayMsgLenBytes [2];
checkTimeDelayMsgBuff [3] = checkTimeDelayMsgLenBytes [3];
/* 數據類型表示,這個可以自定 **/
checkTimeDelayMsgBuff [4] = 0;
checkTimeDelayMsgBuff [5] = 1;
m_ClientSocket.Client.Send (checkTimeDelayMsgBuff, 0, 6, SocketFlags.None);
/* 保存發送時間戳(毫秒 **/
checkTimeDelaySend = (DateTime.UtcNow.ToUniversalTime ().Ticks - 621355968000000000) / 10000;
}
}
void CheckTimeDelayReturn(){
/* 延時時間戳樣本 當前時間戳(毫秒) - 發送時間戳(毫秒) **/
checkTimeDelaySample [checkTimeDelaySampleIndex++] = (DateTime.UtcNow.ToUniversalTime ().Ticks - 621355968000000000) / 10000 - checkTimeDelaySend;
if (checkTimeDelaySampleIndex < checkTimeDelaySample.Length) {
if(checkTimeDelayLoop) CheckTimeDelay ();
}else if(onCheckTimeDelayComplete!=null){
// CheckTimeDelay Complete
checkTimeDelayLoop = false;
onCheckTimeDelayComplete();
}
}
public long GetCurTimeDelay(){
return checkTimeDelaySample[checkTimeDelaySampleIndex];
}
public long GetMeanTimeDelay(){
long temp = 0;
int size = checkTimeDelaySample.Length;
/* 排序 */
for(int i = 0 ; i < size-1; i ++)
{
for(int j = 0 ;j < size-1-i ; j++)
{
if(checkTimeDelaySample[j] > checkTimeDelaySample[j+1]) //交換兩數位置
{
temp = checkTimeDelaySample[j];
checkTimeDelaySample[j] = checkTimeDelaySample[j+1];
checkTimeDelaySample[j+1] = temp;
}
}
}
long sum = 0;
/* 去掉頭尾求均值 */
for (int i = 1; i < size - 1; i++) {
sum += checkTimeDelaySample [i];
}
return(sum / (checkTimeDelaySample.Length - 2));
}
public void CheckTimeDelayPrint(){
string str = "";
for (int i = 0; i < checkTimeDelaySample.Length; i++) {
str += checkTimeDelaySample [i] + ",";
}
Debug.LogFormat ("{0},GetMeanTimeDelay {1}",str,GetMeanTimeDelay());
}
服務端 c 獲取時間戳
#include <sys/time.h>
struct timeval tv;
gettimeofday(&tv,NULL);
printf("time seconds:%ld microsecond:%d\n",tv.tv_sec,tv.tv_usec);
//線程休眠,測試延時
usleep(16666);//16.666 ms = 16666 microsecond
希望這篇文章能對您的工作有所啟發!510982172@qq.com thc 2017.10.7

