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

為少發表於2021-04-08

簡介

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

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

線上試玩:http://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
公眾號:黑客下午茶
加我微信(互相學習交流),關注公眾號(獲取更多學習資料~)

相關文章