《Brick & Ball》開發總結(一)——幀鎖定同步

  歡迎參與討論,轉載請註明出處。
  本文轉載自:https://musoucrow.github.io/2018/03/09/bnb_1/

前言

  輾轉反側三個月,《Brick & Ball》(下稱BNB)的開發工作總算告一段落了,遊戲也順利地在TapTap上架以及在Github開源了。接下來將會對幀鎖定同步、服務端、遊戲性三方面進行開發的總結,預計會用三篇完成,敬請關注。
  由TapTap上的介紹可知,BNB是擁有聯機對戰模式的,而聯機對戰的重點自然在於同步,
本文便對同步的實現的相關問題做個總結。

實現思路

  在閱讀此文之前,你需要對幀鎖定同步有個大致的瞭解,關注我的博客應該知道在去年我已經對此做了個初步的探究。從現在來看當時的實現還不夠好,於是很有必要重新梳理一遍。
  若是有嘗試過實現幀鎖定同步的朋友相信對於傳統的幀鎖定同步(Lockstep,所有玩家的延遲都是延遲最差的那位)實現還好說,但對於“樂觀幀鎖定(不會等待延遲高的玩家)”的實現,則是各說紛紜。除了上文所說的那種方式(服務端主動每隔一段時間廣播,客戶端輸入數據隨時上傳),還有一種《Warcraft III》的實現(在採用傳統幀鎖定同步的基礎上,服務端設定等待時長,超時則繼續),個人認爲這種實現更爲靠譜,也通過這次實踐證明了其可行性。

幀鎖定

  顧名思義,幀鎖定同步分爲幀鎖定與同步兩大部分。所謂幀鎖定,個人認爲便是將Update部分嚴格管控起來,以此修正了玩家之間幀率不一致的問題以及收到多個輸入包時的快進處理。接下來看看其具體實現:

protected void Update() {
    this.updateTimer += Mathf.CeilToInt(Time.deltaTime * 1000);

    //DT = 17
    while (this.updateTimer >= DT) {
        this.client.Update(); //Receive packet

        if (this.playDataList.Count > 1) {
            var lateFrame = this.frame;
            this.sendInLoop = true;

            do {
                this.client.Update(); //Receive packet
                this.LockUpdate(true);
            } while(this.playDataList.Count == 1 && this.frame == lateFrame);
            //Go to latest process
        }

        this.LockUpdate();
        this.updateTimer -= DT;
    }
}

  幀鎖定的核心便在於此,在每次Update進行時間累積,只有累積到了額度(DT)後會進行真正的更新(LockUpdate),且每次更新後會扣除額度,以求最精確,這裏對計時進行毫秒化也是爲此。這種手段在Unity被稱爲FixedUpdate,當然我們的需求不僅於此,因此並沒有使用它。
  除此之外,便是收到多個輸入包進行快進的處理了。這裏的this.frame代表當前進度下的幀號,每當進入下一個進度後便會清0。於是我們只要快進到最新進度下的當前幀即可,隨後再進行一次正常的LockUpdate。當然不要忘記快進時也有必要進行接收數據包,因爲在快進時仍可能有後續輸入包的到來。

LockUpdate

  接下來便來看看LockUpdate的具體實現:

private void LockUpdate(bool inLoop=false) {
    //WAITTING_INTERVAL = 5
    if (this.online && this.frame + 1 == WAITTING_INTERVAL && this.playDataList.Count == 0) {
        return;
    }

    if (this.online) {
        this.frame++;

        if (this.frame == WAITTING_INTERVAL) {
            var data = this.playDataList[0];
            this.playDataList.RemoveAt(0);

            if (Judge.IsRunning && data.addrs != null) {
                //Apply later input
                for (int i = 0; i < data.addrs.Length; i++) {
                    Judge.Input(data.addrs[i], data.inputs[i]);
                }
            }

            this.playFrame++;
            this.frame = 0;

            if (!inLoop || (inLoop && this.sendInLoop)) {
                //Send now input
                var input = new Datas.Input() {
                    data = new InputData() {
                        movingValue = Networkmgr.MovingValue,
                        willElaste = Networkmgr.WillElaste
                    },
                    frame = this.playFrame
                };

                this.sendInLoop = false;
                Networkmgr.WillElaste = false;
                this.client.Send(EventCode.Input, input);
            }

            //Send comparison data
            var comparison = new Datas.Comparison() {
                playFrame = this.playFrame,
                content = Judge.Comparison
            };

            this.client.Send(EventCode.Comparison, comparison);
        }
    }

    //Game world update
    Networkmgr.UpdateEvent();
    Networkmgr.LateUpdateEvent();
}

  LockUpdate主要做的事情爲每隔一定幀數(WAITTING_INTERVAL)上傳當前的操作輸入,以及應用上次的操作輸入(來自服務端),如果到了關鍵幀時上次的操作輸入包仍未抵達,則會陷入等待。也就是說,幀鎖定同步其實就是一種每隔幾幀的回合制而已。具體的流程圖可參考Skywind的提供:
framelock

  當然這裏還有個細節要注意:在處於快進的時候,上傳輸入只會在初次進行,因爲在快進下實際上能響應到玩家的操作其實一開始就定下了,後續進行的上傳也只會是相同的,所以沒有意義。

LockBehaviour

  由LockUpdate可知兩行代碼Networkmgr.UpdateEvent();Networkmgr.LateUpdateEvent();,它們是兩個event,負責綁定執行整個遊戲涉及到幀鎖定的Update,畢竟Unity並不存在主宰一切的主Update,所以只好用這種方式實現。爲此專門涉及了LockBehaviour:

public class LockBehaviour : MonoBehaviour {
    public enum OrderType {
        Normal,
        Late
    }

    [SerializeField]
    protected OrderType orderType = OrderType.Normal;

    protected void Awake() {
        if (this.orderType == OrderType.Normal) {
            Networkmgr.UpdateEvent += this.LockUpdateWrap;
        }
        else {
            Networkmgr.LateUpdateEvent += this.LockUpdateWrap;
        }
    }

    protected void OnDestroy() {
        if (this.orderType == OrderType.Normal) {
            Networkmgr.UpdateEvent -= this.LockUpdateWrap;
        }
        else {
            Networkmgr.LateUpdateEvent -= this.LockUpdateWrap;
        }
    }

    private void LockUpdateWrap() {
        if (this.isActiveAndEnabled) {
            this.LockUpdate();
        }
    }

    protected virtual void LockUpdate() {}
}

  LockBehaviour繼承於MonoBehaviour,且設立了LockUpdate函數,啓動後便會對UpdateEvent進行註冊,同理銷燬後便會去除。如此一來涉及到幀同步的組件只要繼承LockBehaviour並將業務寫在LockUpdate便可。當然由此可以看出,Unity官方實現的組件並不喫這套,所以爲此我專門引入了一款第三方物理引擎——Jitter

同步優化

  由於BNB的操作方式並非點擊鼠標、按下鍵盤這種間歇性操作,而是最爲不適合用於聯機的拖動。所以在正常情況下的表現效果很差(每隔5幀瞬移一下,形成頓頓的感覺),於是PVP模式下采取了與PVE不同的拖動表現:賦予拖動表現爲流暢變速的運動,當然在那短短的時間裏是不可能做出流暢的運動表現的,所以選擇運動的時間基準其實更長(0.25秒)。當收到新的輸入時便會直接完成運動(直接到目的座標)且繼續新的運動。使用這種方式達到了相對不錯的效果,當然代價便是磚塊的運動相應並非實時性的,變相增加了操作難度。這也是無奈之舉,好在這實際上是公平的(雙方皆如此)。
  除此之外便是爲向上拖動做了緩存處理,只要你曾進行了此行爲,便會標記你做了該行爲,在下一個進度時生效。這樣比之到了關鍵幀時才響應操作要好多了,增加了操作的命中率。

浮點數處理

  幀鎖定同步的一大心病便是不同設備下浮點數的處理結果不一致導致的不同步,著名的解決方案有定點數和尾數截斷。而BNB採用的方式爲尾數截斷,其實現方式爲:

public static float ToFixed(this float value) {
    return Mathf.Floor(Mathf.Abs(value * 1000)) * 0.001f * value.ToDirection();
}

public static int ToDirection(this float value) {
    return value >= 0 ? 1 : -1;
}

  這種尾數截斷的方式便是主動限制小數點範圍,以減少精度的方式阻止錯誤的發生。如此運用在各種涉及到同步方面的浮點數進行處理即可。而在這方面最大的敵人便是物理引擎了,衆所周知物理引擎擁有自己的生態環境,牽涉內容甚多,經過一番艱苦嘗試後最終選擇了放棄修改其內核,而是改爲自己實現物理運動。畢竟物理引擎的兩大組成爲運動和判定,如此只使用其判定即可。
  當然這浮點數的處理總體而言仍是涉及甚廣,所以需要進行專門的監控調試。LockUpdate裏的comparison即是爲此,其涉及屬性Judge.Comparison內容爲:

public static string Comparison {
    get {
        var sb = new StringBuilder();
        var pos = Ball.Position;
        var vel = Ball.Velocity;

        sb.Append(pos.x + ",");
        sb.Append(pos.y + ",");
        sb.Append(pos.z + ",");
        sb.Append(vel.x + ",");
        sb.Append(vel.y + ",");
        sb.Append(vel.z + ",");
        sb.Append(INSTANCE.teamA.brick.transform.localScale.x + ",");
        sb.Append(INSTANCE.teamA.brick.transform.position.x + ",");
        sb.Append(INSTANCE.teamA.brick.transform.position.z + ",");
        sb.Append(INSTANCE.teamB.brick.transform.localScale.x + ",");
        sb.Append(INSTANCE.teamB.brick.transform.position.x + ",");
        sb.Append(INSTANCE.teamB.brick.transform.position.z + ",");
        sb.Append(INSTANCE.teamA.wall.transform.position.z + ",");
        sb.Append(INSTANCE.teamB.wall.transform.position.z + ",");

        return sb.ToString();
    }
}

  是的,很粗暴的處理方式,將遊戲影響同步的相關數據進行文本化,在每個關鍵幀都將其上傳令服務器進行匹配,當然也可以選擇做成MD5碼,當然這樣便無法知曉具體哪個部分出了問題,故直接上傳。

後記

  總體來說幀鎖定同步涉及的內容還是挺多的,另外還有諸如防作弊之類的問題沒有探討,因爲我認爲BNB沒有做反作弊的必要(小遊戲)。具體許多細節還是要親力親爲去實踐一遍方可出真知。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章