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