Golang+Protobuf+PixieJS 開發 Web 多人在線射擊遊戲(原創翻譯)

簡介

Superstellar 是一款開源的多人 Web 太空遊戲,非常適合入門 Golang 遊戲伺服器開發。

規則很簡單:摧毀移動的物體,不要被其他玩家和小行星殺死。你擁有兩種資源 — 生命值(health points)和能量值(energy points)。每次撞擊和與小行星的接觸都會讓你失去生命值。在射擊和使用提升驅動時會消耗能量值。你殺死的對象越多,你的生命值條就會越長。

線上試玩://superstellar.u2i.is

技術棧

遊戲分為兩個部分:一個中央伺服器(central server)和一個在每個客戶端的瀏覽器中運行的前端應用程式(a front end app)。

我們之所以選擇這個項目,主要是因為後端部分。 我們希望它是一個可以同時發生許多事情的地方:遊戲模擬(game simulation),客戶端網路通訊(client network communication),統計資訊(statistics),監視(monitoring)等等。 所有這些都應該並行高效地運行。因此,Go 以並發為導向的方法和輕量級的方式似乎是完成此工作的理想工具。

前端部分雖然很重要,但並不是我們的主要關注點。然而,我們也發現了一些潛在的有趣問題,如如何利用顯示卡渲染動畫或如何做客戶端預測,以使遊戲運行平穩和良好。最後我們決定嘗試包含:JavaScript, webpackPixieJS 的堆棧。

在本文的其餘部分中,我將討論後端部分,而客戶端應用程式將留待以後討論。

遊戲狀態主控模擬 – 在一個地方,而且只有一個地方

Superstellar 是一款多人遊戲,所以我們需要一個邏輯來決定遊戲世界的當前狀態及其變化。它應該了解所有客戶端的動作,並對發生的事件做出最終決定 — 例如,炮彈是否擊中目標或兩個物體碰撞的結果是什麼。我們不能讓客戶端這樣做,因為可能會發生兩個人對是否有人被槍殺的判斷不同。更不用說那些想要破解協議並獲得非法優勢的惡意玩家了。因此,存儲遊戲狀態並決定其變化的最佳位置是伺服器本身。

下面是伺服器工作方式的總體概述。它同時運行三種不同類型的動作:

  • 偵聽來自客戶端的控制輸入
  • 運行模擬模擬(simulation)以將狀態更新到下一個時間點
  • 向客戶端發送當前狀態更新

下圖顯示了飛船的狀態和用戶輸入結構的簡化版本。 用戶可以隨時發送消息,因此可以修改用戶輸入結構。模擬步驟每 20 毫秒喚醒一次,並執行兩個操作。 首先,它需要用戶輸入並更新狀態(例如,如果用戶啟用了推力,則增加加速度)。 然後,它獲取狀態(在 t 時刻)並將其轉換為時間的下一個時刻(t + 1)。 整個過程重複進行。

Go 中實現這種並行邏輯非常容易 — 多虧了它的並發特性。每個邏輯都在其自己的 goroutine 中運行,並偵聽某些通道(channel),以便從客戶端獲取數據或同步到 tickers,以定義模擬步驟(simulations steps)的速度或將更新發送回客戶端。我們也不必擔心並行性 – Go 會自動利用所有可用的 CPU 內核。goroutine 和通道(channels)的概念很簡單,但是功能強大。如果您不熟悉它們,請閱讀這篇文章。

與客戶端通訊

伺服器通過 websockets 與客戶端通訊。由於有了 Gorilla web toolkit,在 Golang 使用 websockets 既簡單又可靠。還有一個原生的 websocket 庫,但是它的官方文檔說它目前缺少一些特性,因此推薦使用 Gorilla

為了讓 websocket 運行,我們必須編寫一個 handler 函數來獲取初始的客戶端請求,建立 websocket 連接並創建一個 client 結構體:

superstellar_websocket_handler.go

handler := func(w http.ResponseWriter, r *http.Request) {
  conn, err := s.upgrader.Upgrade(w, r, nil)
  
  if err != nil {
    log.Println(err)
    return
  }

  client := NewClient(conn, … //other attributes)
  client.Listen()
}

然後,客戶端邏輯僅運行兩個循環 – 一個循環進行寫入(writing),一個循環進行讀取(reading)。 因為它們必須並行運行,所以我們必須在單獨的 goroutine 中運行其中之一。 使用語言關鍵字 go,也非常容易:

superstellar_websocket_listen.go

func (c *Client) Listen() {
  go c.listenWrite()
  c.listenRead()
}

下面是 read 函數的簡化版本,作為參考。它只是阻塞 ReadMessage() 調用並等待來自特定客戶端的新數據:

superstellar_websocket_listen_loop.go

func (c *Client) listenRead() {
  for {
    messageType, data, err := c.conn.ReadMessage()

    if err != nil {
      log.Println(err)
    } else if messageType == websocket.BinaryMessage {
      // unmarshall and handle the data
    }
  }
}

如您所見,每個讀取或寫入循環都在其自己的 goroutine 中運行。因為 goroutines 是語言原生的,並且創建起來很便宜,所以我們可以很輕鬆地輕鬆實現高級別的並發性和並行性。 我們沒有測試並發客戶端的最大可能數量,但是擁有 200 個並發客戶端時,伺服器運行良好,具有很多備用計算能力。 最終在該負載下出現問題的部分是前端 – 瀏覽器似乎並沒有趕上渲染所有對象的步伐。 因此,我們將玩家人數限制為 50 人。

當我們建立低級通訊機制時,我們需要選擇雙方都將用來交換遊戲消息的協議。 事實證明不是那麼明顯。

通訊-協議必須小巧輕便

我們的第一選擇是 JSON,因為它在 Golang 和(當然) JavaScript 中運行得很流暢。它是人類可讀的,這將使調試過程更容易。感謝 Gostruct 標籤,我們可以像這樣簡單的實現它:

superstellar_json_structs.go

type Spaceship struct {
  Position          *types.Vector `json:"position"`
  Velocity          *types.Vector `json:"velocity"`
  Facing            *types.Vector `json:"facing"`
  AngularVelocity   float64       `json:"thrust"`
}

結構中的每個欄位都由引用的 JSON 屬性名來描述。這種將結構序列化為 JSON 的方式很簡單:

superstellar_json_marshall.go

bytes, err := json.Marshal(spaceship)

但是事實證明,JSON 太大了,我們通過網路發送了太多數據。 原因是 JSON 被序列化為包含整個模式的字元串表示形式,以及每個對象的欄位名稱。 此外,每個值也都轉換為字元串,因此,一個簡單的 4 位元組整數可以變成 10 位元組長的 「2147483647」(並且使用浮點數會變得更糟)。 由於我們的簡單方法假設我們將所有太空飛船的狀態發送給所有客戶端,因此這意味著伺服器的網路流量會隨著客戶端數量的增加而成倍增長。

一旦我們意識到這一點,我們就切換到 protobuf ,這是一個二進位協議,它保存數據,但不保存模式。為了能夠正確地對數據進行序列化和反序列化,雙方仍然需要知道數據的格式,但這一次他們將其保留在應用程式程式碼中。Protobuf 附帶了自己的 DSL 來定義消息格式,還有一個編譯器,可以將定義翻譯成許多程式語言的本地程式碼(多虧了一個獨立的庫,可以翻譯成本地程式碼和 JavaScript)。因此,您可以準備好 struct 以填充數據。

以下是 protobuf 對飛船結構定義的簡化版本:

superstellar_spaceship.proto

message Spaceship {
  uint32  id              = 1;
  Point   position        = 2;
  Vector  velocity        = 3;
  double  facing          = 4;
  double  angularVelocity = 5;
  ...
}

下面這個函數將我們的域對象轉換為 protobuf 的中間結構:

superstellar_spaceship_to_proto.go

func (s *Spaceship) ToProto() *pb.Spaceship {
  return &pb.Spaceship {
    Id: s.Id(),
    Position: s.Position().ToProto(),
    Velocity: s.Velocity().ToProto(),
    Facing: s.Facing(),
    AngularVelocity: s.AngularVelocity(),
    ...
  }
}

最後序列化為原始位元組:

superstellar_proto_marshal.go

bytes, err := proto.Marshal(message)

現在,我們可以簡單地通過網路以最小的開銷將這些位元組發送給客戶端。

移動平滑和連接滯後補償

一開始,我們試圖在每個模擬幀上發送整個世界的狀態。這樣,客戶端只會在接收到伺服器消息時重新繪製螢幕。然而,這種方法導致了大量的網路流量—我們不得不將遊戲中每個對象的細節每秒發送50次給所有的客戶端,以使動畫流暢。太多的數據了!

然而,我們很快意識到沒有必要發送每一個模擬幀。我們應該只發送那些發生輸入變化或有趣事件(如碰撞、撞擊或用戶控制的改變)的幀。其他幀可以在客戶端根據之前的幀進行預測。所以我們別無選擇,只能教客戶如何自己模擬。這意味著我們需要將模擬邏輯從伺服器複製到 JavaScript 客戶機程式碼。幸運的是,只有基本的移動邏輯需要重新實現,因為其他更複雜的事件會觸發即時更新。

一旦我們這麼做了,我們的網路流量就會顯著下降。這樣我們也可以減輕網路延遲的影響。如果消息在 Internet 上的某個地方卡住了,每個客戶機都可以簡單地進行自己的模擬,最終,當數據到達時,趕上並相應地更新模擬的狀態。

從一個程式包到事件調度程式

設計應用程式的程式碼結構也是一個有趣的例子。在第一種方法中,我們創建了一個 Go 包,並將所有邏輯放入其中。如果需要用一種新的程式語言創建一個興趣項目,大多數人可能都會這麼做。然而,隨著我們的程式碼庫越來越大,我們意識到這不再是一個好主意了。因此,我們將程式碼劃分為幾個包,而沒有花太多時間思考如何正確地做到這一點。它很快就咬了我們一口(報錯):

$ go build
import cycle not allowed

事實證明,Go 不允許包循環地相互依賴。這實際上是一件好事,因為它迫使程式設計師仔細思考他們的應用程式的結構。所以,在沒有其他選擇的情況下,我們坐在白板前,寫下每一塊內容,並想出一個想法,即引入一個單獨的模組,在系統的其他部分之間傳遞資訊。我們將其稱為事件分派器(您也可以將其稱為事件匯流排)。

事件調度程式是一個概念,它允許我們將伺服器上發生的所有事情打包成所謂的事件。例如:客戶端連接(client joins)、離開(leaves)、發送輸入消息(sends an input message)或該運行模擬步驟了。在這些情況下,我們使用dispatcher 創建並觸發相應的事件。在另一端,每個結構體都可以將自己註冊為偵聽器,並了解什麼時候發生了有趣的事情。這種方法只會讓有問題的包只依賴事件包,而不依賴彼此,這就解決了我們的循環依賴問題。

下面是一個示例,說明我們如何使用事件調度程式來傳播模擬更新時間間隔。首先,我們需要創建一個能夠監聽事件的結構:

superstellar_eventdisp_create.go

type Updater struct {}

func (updater *Updater) HandleTimeTick(*events.TimeTick) {
  // do something with the event
}

然後我們需要實例化它,並將它註冊到事件調度程式中:

superstellar_eventdisp_time_tick.go

updater := Updater{}
 
eventDispatcher := events.NewEventDispatcher()
eventDispatcher.RegisterTimeTickListener(updater)

現在,我們需要一些程式碼來運行 ticker 並觸發事件:

superstellar_eventdisp_time_tick_loop.go

for range time.Tick(constants.PhysicsFrameDuration) {
  event := &events.TimeTick{}
  eventDispatcher.FireTimeTick(event)
}

通過這種方式,我們可以定義任何事件並註冊儘可能多的監聽器。事件調度程式在循環中運行,因此我們需要記住不要將長時間運行的任務放在處理函數中。相反,我們可以創建一個新的 goroutine,在那裡做繁重的計算。

不幸的是,Go 不支援泛型(將來可能會改變),所以為了實現許多不同的事件類型,我們使用了該語言的另一個特性—程式碼生成。事實證明,這是解決這個問題的一個非常有效的方法,至少在我們這樣規模的項目中是這樣。

從長遠來看,我們意識到實現事件調度程式是一件很有價值的事情。因為 Go 迫使我們避免循環依賴,所以我們在開發的早期階段就想到了它。否則我們可能不會這麼做。

結論

實現多人瀏覽器遊戲非常有趣,也是學習 Go 的一種很好的方法。 我們可以使用其最佳功能,例如並發工具,簡單性和高性能。 因為它的語法類似於動態類型的語言,所以我們可以快速編寫程式碼,但又不犧牲靜態類型的安全性。這非常有用,尤其是在像我們這樣編寫低級應用程式伺服器時。

我們還了解了在創建實時多人遊戲時必須面對的問題。 客戶端和伺服器之間的通訊量可能非常大,必須付出很多努力來降低它。 您也不會忘記不可避免地會出現的滯後和網路問題。

最後值得一提的是,創建一個簡單的在線遊戲也需要大量的工作,無論是在內部實現方面還是在您想使其變得有趣且可玩時。 我們花了無休止的時間討論要在遊戲中放入哪種武器,資源或其他功能,只是意識到要實際實現需要多少工作。 但是,當您嘗試做一些對您來說是全新的事情時,即使您設法製造出最小的東西也能給您帶來很多滿足感。

Refs

我是為少
微信:uuhells123
公眾號:黑客下午茶
加我微信(互相學習交流),關注公眾號(獲取更多學習資料~)