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

簡介

Superstellar 是一款開源的多人 Web 太空遊戲,很是適合入門 Golang 遊戲服務器開發。前端

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

線上試玩:http://superstellar.u2i.isgit

技術棧

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

咱們之因此選擇這個項目,主要是由於後端部分。 咱們但願它是一個能夠同時發生許多事情的地方:遊戲模擬(game simulation),客戶端網絡通訊(client network communication),統計信息(statistics),監視(monitoring)等等。 全部這些都應該並行高效地運行。所以,Go 以併發爲導向的方法和輕量級的方式彷佛是完成此工做的理想工具。github

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

在本文的其他部分中,我將討論後端部分,而客戶端應用程序將留待之後討論。web

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

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

下面是服務器工做方式的整體概述。它同時運行三種不一樣類型的動做:json

  • 偵聽來自客戶端的控制輸入
  • 運行仿真模擬(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
公衆號:黑客下午茶
加我微信(互相學習交流),關注公衆號(獲取更多學習資料~)
相關文章
相關標籤/搜索