用 Unity 做個遊戲(八) - 客戶端邏輯結構和網路同步機制

Inspoy Cheng發表於2017-04-12

本文首發自inspoy的雜七雜八 | 菜雞inspoy的學習記錄

前言

距離上一篇又差不多一週多了,果然寫程式碼要比寫部落格輕鬆多了orz
經過了漫長的無聊的準備,這次終於開始正式寫遊戲邏輯相關的內容了,當然,到目前為止的程式碼可以直接拿來做任意一個遊戲,這也算是個好處吧233
斷斷續續寫了一週的程式碼,到目前為止已經基本實現了:登陸,加入戰鬥,同步移動。其中同步移動是重點,之前的坑裡就是因為這一點導致爆炸,做不下去了233

客戶端結構

這次先說客戶端,服務端下一篇再繼續

場景劃分

工程專案裡一共有3個場景

  1. SceneTitle
  2. SceneGame
  3. SceneTest
    其中SceneTest不會打包進遊戲,只是為了測試某些遊戲效果,比如測試特效,編輯UI等
    SceneTitle包含所有正式戰鬥以外的所有外圍系統,現在只做了登陸,之後還會有房間匹配,技能配置,角色成長等等,場景主要內容為各種各樣的UI,其他3D GameObject比較少
    SceneGame就是遊戲戰鬥的場景了,主體是3D場景,角色,特效,UI加以輔助

    使用者登陸

    先做了個最簡單的登陸介面:
    用 Unity 做個遊戲(八) - 客戶端邏輯結構和網路同步機制
    0801

    暫時還沒有做登陸驗證,現在只要輸入一個字串,這個字串就作為你的使用者ID來用了,而且現在也沒有做資料持久化,伺服器重啟後所有資料就清空了。
    點選登陸按鈕,嘗試連線伺服器,成功後將會自動切換場景到SceneGame
    連線伺服器:
    void onLogin(SFEvent e)
    {
     string username = m_view.txtUsername.text;
     SFUserData.instance.uid = username;
     m_infoMsg = "正在連線伺服器...";
     SFNetworkManager.instance.init();
     SFNetworkManager.instance.dispatcher.addEventListener(this, SFEvent.EVENT_NETWORK_READY, onConnectResult);
     SFNetworkManager.instance.dispatcher.addEventListener(this, SFEvent.EVENT_NETWORK_INTERRUPTED, result =>
         {
             m_infoMsg = "網路連線中斷";
             m_willReset = true;
         });
     SFNetworkManager.instance.dispatcher.addEventListener(this, SFResponseMsgUnitLogin.pName, onLoginResult);
     SFNetworkManager.instance.dispatcher.addEventListener(this, SFResponseMsgNotifyRemoteUsers.pName, onRemoteUsers);
    }複製程式碼
    連線伺服器的操作是非同步的,當有結果時回撥函式void onConnectResult(SFEvent)將會被呼叫:
    void onConnectResult(SFEvent e)
    {
     var retCode = e.data as SFSimpleEventData;
     if (retCode.intVal == 0)
     {
         m_infoMsg = "連線成功,正在登陸...";
         doLogin();
     }
     else
     {
         m_infoMsg = "無法連線到伺服器";
         m_willReset = true;
     }
    }複製程式碼
    如果成功連線到伺服器,那麼就執行登陸操作
    SFRequestMsgUnitLogin req = new SFRequestMsgUnitLogin();
    req.loginOrOut = 1;
    SFNetworkManager.instance.sendMessage(req);複製程式碼
    登陸結果的回撥函式為void onLoginResult(SFEvent),不過在這之前,伺服器將會推送給客戶端遊戲初始化所必要的資料,這些資料由協議SFResponseMsgNotifyRemoteUsers推送
    登陸成功後,切換場景:
    m_view.StartCoroutine(loadSceneGame());
    // ...
    IEnumerator loadSceneGame()
    {
     var op = SceneManager.LoadSceneAsync("SceneGame");
     yield return op;
    }複製程式碼
    切換場景的操作也是非同步載入的,如果場景比較大,還可以在update裡每幀檢查載入進度,做一個loading進度條
    然後場景就切換到了SceneGame

    戰鬥場景

    戰鬥場景是有個HUD的,不過涉及一些Unity預設UGUI不支援的控制元件,這些控制元件我還沒寫完,就先沒弄UI了。
    場景的層次結構如圖:
    用 Unity 做個遊戲(八) - 客戶端邏輯結構和網路同步機制
    0802

    幾個主要的GameObjet:
    |名稱|說明|
    |--|--|
    |SceneMgr|掛載了兩個指令碼:SceneManager和BattleController|
    |BattleField|空GO,只是為了統一管理所有遊戲物件|
    |Units|所有角色的容器|
    |Balls|所有飛行火球的容器|

此時場景中什麼都沒有,甚至連燈光都沒有,因為所有的物體都是動態載入的
切換場景後,BattleController.Start()方法被呼叫,在這裡會載入場景,角色和UI

void Start()
{
    // 載入場景
    SFUtils.log("mapId:{0}", SFBattleData.instance.enterBattle_mapId);
    string mapName = string.Format("map_{0}", SFBattleData.instance.enterBattle_mapId);
    var mapPrefab = Resources.Load("Prefabs/Maps/" + mapName) as GameObject;
    GameObject.Instantiate(mapPrefab, unitContainer.transform.parent);

    // 載入HUD
    SFSceneManager.addView("vwHUD");

    // 載入角色
    m_unitMgr.initUnits();
}複製程式碼

其中,初始化載入角色由掛載在Units上的指令碼SFUnitManager負責

public void initUnits()
{
    SFUtils.log("初始化角色...");
    // 自己
    SFUnitConf heroConf = new SFUnitConf();
    heroConf.uid = SFUserData.instance.uid;
    // ...
    m_heroController.setHero(addUnit(heroConf));

    // 其他角色
    var users = SFBattleData.instance.enterBattle_remoteUsers;
    foreach (var item in users)
    {
        SFUnitConf conf = new SFUnitConf();
        conf.uid = item.uid;
        // ...
        addUnit(conf);
    }
    SFUtils.log("初始化角色完成");
}複製程式碼

當然,addUnit(SFUnitConf)方法裡就是根據配置資訊執行例項化操作

public SFUnitController addUnit(SFUnitConf conf)
{
    if (unitPrefab != null)
    {
        var controllerGO = GameObject.Instantiate(unitPrefab, gameObject.transform.parent);
        var controller = controllerGO.GetComponent<SFUnitController>();
        controller.init(conf);
        m_controllers.Add(conf.uid, controller);
        return controller;
    }
    return null;
}複製程式碼

事實上,這種寫法還有很大的優化空間,包括後面新增火球到場景的操作也是,之後考慮換成物件池的方式來快取不需要的遊戲物件而不是直接用GameObject.Destroy()銷燬,這樣一來的話下次使用的時候就可以直接從緩衝池中取出,省去了例項化操作所需要消耗的時間

遊戲物件Units上海掛載了另外一個指令碼SFHeroController,這個指令碼監聽使用者的輸入,把操作資訊上報給伺服器

void Update()
{
    float curX = Input.GetAxis("Horizontal");
    float curY = Input.GetAxis("Vertical");
    float curRot = getCurRotation();
    if (curX != m_lastMoveX || curY != m_lastMoveY || curRot != m_lastRotation)
    {
        m_lastMoveX = curX;
        m_lastMoveY = curY;
        m_lastRotation = curRot;
        syncData();
    }
}

// 計算當前滑鼠位置所對應的旋轉角度
float getCurRotation()
{
    float posX = Input.mousePosition.x;
    float posY = Input.mousePosition.y;
    posX = Mathf.Clamp(posX, 0, m_screenWidth);
    posY = Mathf.Clamp(posY, 0, m_screenHeight);
    posX -= m_screenWidth / 2;
    posY -= m_screenHeight / 2;
    if (Mathf.Abs(posX) < m_screenWidth / 10 &&
        Mathf.Abs(posY) < m_screenHeight / 10)
    {
        return m_lastRotation;
    }
    return Mathf.Atan2(posX, posY) * Mathf.Rad2Deg;
}

// 上報伺服器
void syncData()
{
    SFRequestMsgUnitSync req = new SFRequestMsgUnitSync();
    req.moveX = m_lastMoveX;
    req.moveY = m_lastMoveY;
    req.rotation = m_lastRotation;
    SFNetworkManager.instance.sendMessage(req);
}複製程式碼

SFUnitManager監聽著協議SFResponseMsgNotifyUnitStatus,這個協議包含了當前場上所有角色的位置以及狀態資訊。收到這個協議後,onNotifyUnitStatus方法根據裡面的資訊同步狀態資訊

void onNotifyUnitStatus(SFEvent e)
{
    var data = e.data as SFResponseMsgNotifyUnitStatus;
    var infos = data.infos;
    foreach (var item in infos)
    {
        foreach (var controller in m_controllers)
        {
            if (controller.Key == item.uid)
            {
                controller.Value.updateStatus(item);
                break;
            }
        }
    }
}複製程式碼

場景中的角色GameObject都掛載了一個SFUnitController指令碼,這個指令碼來控制各自角色,互不干擾

網路同步機制

之前就是因為網路同步的機制不夠合理導致完全改不動了orz
這次一開始就得考慮好了
首先,客戶端只負責顯示服務端傳回的結果,客戶端玩家做出的操作也不會影響到場景中角色的狀態,而是把操作上傳至服務端,服務端根據各個客戶端上傳的操作資訊對整個戰場進行模擬,然後定期向所有客戶端同步大家的位置以及狀態資訊。
這樣做的好處就是,避免客戶端各自模擬導致有可能出現由網路延遲導致的不同步,比如客戶端A模擬的結果顯示甲打中了乙,而客戶端B模擬的結果可能完全相反,這樣一來攻擊判定以誰的為準就不好說了,所以我要把所有的判定全部交給服務端來處理,這樣就保證了結果唯一,所有的客戶端看到的結果都一定是相同的。

用 Unity 做個遊戲(八) - 客戶端邏輯結構和網路同步機制
0803

其實這樣做還是存在一定的缺陷,最主要的就是操作延遲,因為客戶端按下了按鈕,訊息傳給服務端,服務端計算,推送位置變化,角色移動。所以玩家看到角色開始移動一定會慢半拍,有個解決方案:客戶端先根據操作預測接下來的運動,這樣就消除了操作延遲,然後下次服務端推送狀態的時候,如果服務端推送的實際位置和客戶端自己預測的位置相符或者差距很小,那就不管,如果差距過大則再進行糾錯,強制把角色移動到實際的位置。

我的做法是:
因為這個遊戲移動不是馬上就達到最大速度的,一定會有一個加速過程,所以客戶端按下按鈕後到實際動起來的操作延遲和原本規則上的操作延遲的感覺是差不多的,所以客戶端就算不先預測運動,也不會對體驗有什麼不好的影響。
其次,因為服務端推送的週期(暫定50ms)一定和遊戲渲染的幀率(60fps)不一致,再加上網路傳輸,客戶端收到的同步訊息一定是不均勻的,如果每次收到訊息就直接同步的話會顯得角色的運動特別突兀,所以必須對其進行差值,同時客戶端也自己根據上一次服務端傳回的速度資訊進行運動模擬,如果發現誤差過大則快速移動到應該在的位置(也通過差值讓其平滑移動)。
詳見程式碼

// 服務端同步位置和狀態資訊
public void updateStatus(SFMsgDataUserSyncInfo info)
{
    m_curPosX = info.posX;
    m_curPosY = info.posY;
    m_curRotation = info.rotation;
    m_curSpeedX = info.speedX;
    m_curSpeedY = info.speedY;
}

// 每幀更新
void Update()
{
    // 本地模擬的運動
    m_curPosX += m_curSpeedX * Time.deltaTime;
    m_curPosY += m_curSpeedY * Time.deltaTime;

    // 位置如果差距不大則不改變,較大差距快速緩動
    float distance = Vector3.Distance(gameObject.transform.position, new Vector3(m_curPosX, 0, m_curPosY));
    if (distance > SFCommonConf.instance.syncPosThrehold)
    {
        Vector3 realPos = gameObject.transform.position;
        Vector3 posDiff = new Vector3(m_curPosX - realPos.x, 0, m_curPosY - realPos.z);
        realPos += posDiff * Time.deltaTime * MOVE_ACC;
        transform.position = realPos;
    }
}

// 相關引數
// 位置修正加速度
const int MOVE_ACC = 20;
// 實際值與參考值的差小於這個閾值就不做位置修正了
float SFCommonConf.instance.syncPosThrehold = 0.1f;複製程式碼

關於服務端,因為服務端幾乎承擔了所有遊戲邏輯的計算,也挺複雜的。。下一篇文章再詳細介紹

完整程式碼

上面貼出的程式碼片段由於篇幅限制只保留了關鍵部分,完整的程式碼可在我的github上找到

相關文章