Golang+Protobuf+PixieJS 开发 Web 多人在线射击游戏(原创翻译)

简介: Golang+Protobuf+PixieJS 开发 Web 多人在线射击游戏(原创翻译)

简介



Superstellar 是一款开源的多人 Web 太空游戏,非常适合入门 Golang 游戏服务器开发。


规则很简单:摧毁移动的物体,不要被其他玩家和小行星杀死。你拥有两种资源 — 生命值(health points)和能量值(energy points)。每次撞击和与小行星的接触都会让你失去生命值。在射击和使用提升驱动时会消耗能量值。你杀死的对象越多,你的生命值条就会越长。


微信图片_20220611155530.png


线上试玩:http://superstellar.u2i.is


技术栈



游戏分为两个部分:一个中央服务器(central server)和一个在每个客户端的浏览器中运行的前端应用程序(a front end app)。


我们之所以选择这个项目,主要是因为后端部分。我们希望它是一个可以同时发生许多事情的地方:游戏模拟(game simulation),客户端网络通信(client network communication),统计信息(statistics),监视(monitoring)等等。所有这些都应该并行高效地运行。因此,Go 以并发为导向的方法和轻量级的方式似乎是完成此工作的理想工具。


前端部分虽然很重要,但并不是我们的主要关注点。然而,我们也发现了一些潜在的有趣问题,如如何利用显卡渲染动画或如何做客户端预测,以使游戏运行平稳和良好。最后我们决定尝试包含:JavaScript, webpackPixieJS 的堆栈。

在本文的其余部分中,我将讨论后端部分,而客户端应用程序将留待以后讨论。


游戏状态主控模拟 - 在一个地方,而且只有一个地方



Superstellar 是一款多人游戏,所以我们需要一个逻辑来决定游戏世界的当前状态及其变化。它应该了解所有客户端的动作,并对发生的事件做出最终决定 — 例如,炮弹是否击中目标或两个物体碰撞的结果是什么。我们不能让客户端这样做,因为可能会发生两个人对是否有人被枪杀的判断不同。更不用说那些想要破解协议并获得非法优势的恶意玩家了。因此,存储游戏状态并决定其变化的最佳位置是服务器本身。


下面是服务器工作方式的总体概述。它同时运行三种不同类型的动作:


  • 侦听来自客户端的控制输入
  • 运行仿真模拟(simulation)以将状态更新到下一个时间点
  • 向客户端发送当前状态更新

微信图片_20220611155609.pngimage.gif

下图显示了飞船的状态和用户输入结构的简化版本。用户可以随时发送消息,因此可以修改用户输入结构。仿真步骤每 20 毫秒唤醒一次,并执行两个操作。首先,它需要用户输入并更新状态(例如,如果用户启用了推力,则增加加速度)。然后,它获取状态(在 t 时刻)并将其转换为时间的下一个时刻(t + 1)。整个过程重复进行。

微信图片_20220611155601.png

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 客户机代码。幸运的是,只有基本的移动逻辑需要重新实现,因为其他更复杂的事件会触发即时更新。


微信图片_20220611155658.png


一旦我们这么做了,我们的网络流量就会显著下降。这样我们也可以减轻网络延迟的影响。如果消息在 Internet 上的某个地方卡住了,每个客户机都可以简单地进行自己的模拟,最终,当数据到达时,赶上并相应地更新模拟的状态。


从一个程序包到事件调度程序



设计应用程序的代码结构也是一个有趣的例子。在第一种方法中,我们创建了一个 Go 包,并将所有逻辑放入其中。如果需要用一种新的编程语言创建一个兴趣项目,大多数人可能都会这么做。然而,随着我们的代码库越来越大,我们意识到这不再是一个好主意了。因此,我们将代码划分为几个包,而没有花太多时间思考如何正确地做到这一点。它很快就咬了我们一口(报错):


$ go build
import cycle not allowed


事实证明,Go 不允许包循环地相互依赖。这实际上是一件好事,因为它迫使程序员仔细思考他们的应用程序的结构。所以,在没有其他选择的情况下,我们坐在白板前,写下每一块内容,并想出一个想法,即引入一个单独的模块,在系统的其他部分之间传递信息。我们将其称为事件分派器(您也可以将其称为事件总线)。


微信图片_20220611155718.png


事件调度程序是一个概念,它允许我们将服务器上发生的所有事情打包成所谓的事件。例如:客户端连接(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 的一种很好的方法。我们可以使用其最佳功能,例如并发工具,简单性和高性能。因为它的语法类似于动态类型的语言,所以我们可以快速编写代码,但又不牺牲静态类型的安全性。这非常有用,尤其是在像我们这样编写低级应用程序服务器时。


我们还了解了在创建实时多人游戏时必须面对的问题。客户端和服务器之间的通信量可能非常大,必须付出很多努力来降低它。您也不会忘记不可避免地会出现的滞后和网络问题。


最后值得一提的是,创建一个简单的在线游戏也需要大量的工作,无论是在内部实现方面还是在您想使其变得有趣且可玩时。我们花了无休止的时间讨论要在游戏中放入哪种武器,资源或其他功能,只是意识到要实际实现需要多少工作。但是,当您尝试做一些对您来说是全新的事情时,即使您设法制造出最小的东西也能给您带来很多满足感。

相关文章
|
1月前
|
设计模式 前端开发 数据库
Python Web开发:Django框架下的全栈开发实战
【10月更文挑战第27天】本文介绍了Django框架在Python Web开发中的应用,涵盖了Django与Flask等框架的比较、项目结构、模型、视图、模板和URL配置等内容,并展示了实际代码示例,帮助读者快速掌握Django全栈开发的核心技术。
174 45
|
17天前
|
前端开发 安全 JavaScript
2025年,Web3开发学习路线全指南
本文提供了一条针对Dapp应用开发的学习路线,涵盖了Web3领域的重要技术栈,如区块链基础、以太坊技术、Solidity编程、智能合约开发及安全、web3.js和ethers.js库的使用、Truffle框架等。文章首先分析了国内区块链企业的技术需求,随后详细介绍了每个技术点的学习资源和方法,旨在帮助初学者系统地掌握Dapp开发所需的知识和技能。
2025年,Web3开发学习路线全指南
|
24天前
|
存储 前端开发 JavaScript
如何在项目中高效地进行 Web 组件化开发
高效地进行 Web 组件化开发需要从多个方面入手,通过明确目标、合理规划、规范开发、加强测试等一系列措施,实现组件的高效管理和利用,从而提高项目的整体开发效率和质量,为用户提供更好的体验。
27 7
|
28天前
|
开发框架 搜索推荐 数据可视化
Django框架适合开发哪种类型的Web应用程序?
Django 框架凭借其强大的功能、稳定性和可扩展性,几乎可以适应各种类型的 Web 应用程序开发需求。无论是简单的网站还是复杂的企业级系统,Django 都能提供可靠的支持,帮助开发者快速构建高质量的应用。同时,其活跃的社区和丰富的资源也为开发者在项目实施过程中提供了有力的保障。
|
28天前
|
开发框架 JavaScript 前端开发
TypeScript 是一种静态类型的编程语言,它扩展了 JavaScript,为 Web 开发带来了强大的类型系统、组件化开发支持、与主流框架的无缝集成、大型项目管理能力和提升开发体验等多方面优势
TypeScript 是一种静态类型的编程语言,它扩展了 JavaScript,为 Web 开发带来了强大的类型系统、组件化开发支持、与主流框架的无缝集成、大型项目管理能力和提升开发体验等多方面优势。通过明确的类型定义,TypeScript 能够在编码阶段发现潜在错误,提高代码质量;支持组件的清晰定义与复用,增强代码的可维护性;与 React、Vue 等框架结合,提供更佳的开发体验;适用于大型项目,优化代码结构和性能。随着 Web 技术的发展,TypeScript 的应用前景广阔,将继续引领 Web 开发的新趋势。
36 2
|
1月前
|
前端开发 API 开发者
Python Web开发者必看!AJAX、Fetch API实战技巧,让前后端交互如丝般顺滑!
在Web开发中,前后端的高效交互是提升用户体验的关键。本文通过一个基于Flask框架的博客系统实战案例,详细介绍了如何使用AJAX和Fetch API实现不刷新页面查看评论的功能。从后端路由设置到前端请求处理,全面展示了这两种技术的应用技巧,帮助Python Web开发者提升项目质量和开发效率。
52 1
|
1月前
|
XML 安全 PHP
PHP与SOAP Web服务开发:基础与进阶教程
本文介绍了PHP与SOAP Web服务的基础和进阶知识,涵盖SOAP的基本概念、PHP中的SoapServer和SoapClient类的使用方法,以及服务端和客户端的开发示例。此外,还探讨了安全性、性能优化等高级主题,帮助开发者掌握更高效的Web服务开发技巧。
|
3月前
|
Go
Golang语言之管道channel快速入门篇
这篇文章是关于Go语言中管道(channel)的快速入门教程,涵盖了管道的基本使用、有缓冲和无缓冲管道的区别、管道的关闭、遍历、协程和管道的协同工作、单向通道的使用以及select多路复用的详细案例和解释。
140 4
Golang语言之管道channel快速入门篇
|
3月前
|
Go
Golang语言文件操作快速入门篇
这篇文章是关于Go语言文件操作快速入门的教程,涵盖了文件的读取、写入、复制操作以及使用标准库中的ioutil、bufio、os等包进行文件操作的详细案例。
71 4
Golang语言文件操作快速入门篇
|
3月前
|
Go
Golang语言之gRPC程序设计示例
这篇文章是关于Golang语言使用gRPC进行程序设计的详细教程,涵盖了RPC协议的介绍、gRPC环境的搭建、Protocol Buffers的使用、gRPC服务的编写和通信示例。
116 3
Golang语言之gRPC程序设计示例