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
公众号:黑客下午茶
加我微信(互相学习交流),关注公众号(获取更多学习资料~)