我开发了一个基于 Beancount 的账本托管服务 HostedBeans,欢迎大家来了解纯文本复式记账并试用我的服务。
标签 #游戏开发

2018 年度小结(技术方面)

今年我完成的业余项目确实非常少,勉强算下来也就只有 DeployBeta 的 0.4 版本和 Elecpass 的 v3 版本。

所以我今年得到的一个重要的教训就是 一次要专注于一个项目,同时尽快完成一个阶段性的可用版本,尽快发布。尤其是对于我来说,还有全职工作,业余时间并不多,如果不能尽快发布,获得一些反馈,就失去了继续完善的动力,而且会摊子越铺越大,一直无法发布。

在这一点上,DeployBeta 是一个反面的例子,持续了两年的时间,但也没到可以对外发布的标准。而 Elecpass 是一个正面的例子,在 2017 年 10 月,我花了半个月的时间就发布了两个版本,之后一年多的时间我自己一直在使用,然后在今年十一月集中花了一周的时间发布了 v3 版本(筛选框、布局优化、强化编辑功能、编译 Windows 版),这个项目大部分的时候都是「已发布」状态,而不是还有功能做到一半。


在 DeployBeta v4 版本的前夕我将 DeployBeta 开源了,在 v4 和后续的未发布版本中,我实现了 MySQL、Redis、MongoDB 三种数据库的支持、重写了基于 Etcd 的 ORM。所以今年我也写了一些 Golang,今年我主要的怨念在于 Golang 中缺乏对于接口数组(或者说泛型数组)的支持,只能使用 interface{} 和反射来实现 ORM 中「获取结果数组」的功能。

这个 ORM 其实就是将 JSON 数据存储在 Etcd 中,同时提供关系和事务的简单封装。这其实和 Kubernetes 中的 api-server 做的事情差不多,但因为没有找到比较好的关于 Kubernetes 使用 Etcd 的文档,所以我没有太多地参考它。


为什么容器平台能够简化容器的管理工作呢?我认为一个重要的原因是容器平台提供了一种纯描述式的定义文件,让开发者去描述所期望的最终状态。这一点在 Kubernetes 中实现得最为彻底,我相信这也是 Kubernetes 成功的原因之一。

今年因为工作的原因,我非常深度地接触了 Kubernetes,在其基础上进行封装,来提供容器服务。Kubernetes 不仅仅是一个工具,同时也是一个平台,它以 RESTFul 风格的 API 将所有功能抽象为资源,然后由每种资源的 Controller 去将对象的实际状态同步到预期的状态。这意味着在 Kubernetes 的基础上你可以去添加自定义的资源和相应的 Controller 去拓展它的功能。

在对 Dockerfile 的抽象能力忍无可忍之后,今年我用 Node.js 为 Dockerfile 实现了一个简易的 DSL,主要是将 Dockerfile 分为多个段落,然后在每个段落中结构化地保存指令数据,以便在对 Dockerfile 的整个处理过程中随时向任何段落添加或修改指令,最后等到完成所有的处理之后再将结构化的指令数据生成真正的 Dockerfile。经过这样的过程生成的 Dockerfile 有着更规范的格式,更有利于跨应用甚至跨语言之间的缓存。

其实我们在生产环境使用容器技术已经很多年了,但很多时候只是将已有的程序跑在容器里而已,而没能做到 Container Native。例如我们实际上还有很多容器在依赖本地存储、没有有效的健康检查、不能正确地处理信号来实现平滑关闭。


我司今年发布了一个 游戏后端解决方案,它本质上是一个「消息转发服务」,帮助游戏的客户端之间来转发消息、同步状态。

但出于反作弊的需要,我们还需要提供一种在服务器端运行游戏逻辑的能力。对于暴露这种能力的方式,一开始我们内部有两种方案。我认为比较好的方案是将这种在服务器端运行的游戏逻辑也作为一个客户端去加入到消息服务中,以消息服务为中心与其他客户端进行交互。这样做的好处是:

  • 在服务器和客户端之间复用大部分的游戏逻辑
  • 单机游戏 => 动作同步 => 状态同步 的迁移过程非常平滑
  • 服务器端的游戏逻辑和消息转发服务解耦

为了验证这个方案的可行性,我花了一些时间制作了一个 Demo,实现了一个 简单的回合制卡牌游戏,这种模式后来也被我司发布为了正式的产品:Client Engine

在这个过程中我其实是第一次接触游戏后端的开发,其实我并没有去了解既有的游戏框架,但在不知不觉中也重新发明了一些轮子,例如「动作」和「状态」的概念,感觉去探索游戏的开发过程还是挺有意思的一键事情。


今年年初的时候我尝试为云引擎加上了 任务队列 的功能,因为云引擎本来已经有了基于 HTTP 的云函数功能,所以我想这个任务队列只提供一种调度的能力,而不提供计算资源,依然通过 HTTP 来调用原本的云函数。我认为这样的形式可以减少引入的新概念,降低介入的成本。

但低调公布之后的效果并不是很好,我觉得其中一个原因是是我自己就比较少用任务队列,所以比较难站在用户的角度去考虑他们需要任务队列有怎样的功能、希望超时和并发的控制是怎样的。在新的一年里我还会继续改进这个功能,去参考其他类似的云服务如何设计任何队列的功能。

大概是因为我的服务器端编程经验都在 Node.js 上,Node.js 中异步任务的成本很低,所以不需要出于减少线程开销的考虑去使用任务队列;同时我会通过 Redis 来维护一些关键状态,消除单点、保证因应用重启而中断的任务可以恢复,所以也几乎不需要任务队列去保证任务执行的连续性。

在这个功能的实现上我重度地使用了 Redis:使用 Redis 存储所有的状态、提供一致性保证,用 Node.js 去实现 Worker,调用 Lua Script 去实现原子操作。说起来 Redis 是我用过最好的服务器端软件之一,我认为 Redis 找到了一个非常好的切入点、找准了自己的定位,才使得它的设计看起来那么简单。

应该说任务队列的需求是非常多样化的,每种业务可能都会对任务队列有不同的需求,再加上很大程度上又是语言相关的,所以我觉得在这个方面如果能做一些开源项目也会有比较大的空间,例如我就觉得 Redis 5 中的 Stream 类型就是为任务队列设计的,想去写一个充分利用 Stream 特性的任务队列。

Play Cards: 探索通用的游戏后端方案

当我们公司决定推出一个「多人在线游戏后端解决方案」的时候,我其实很疑惑会有一个「通用」的方案可以解决所有在线游戏的后端需求么?

于是在前一段时间,我尝试开发了一个「多人在线卡牌对战游戏」,支持了「斗地主」游戏规则的一个子集,可以在 play-cards.leanapp.cn 访问到,源代码在 jysperm/play-cards。不过请不要指望能够匹配到其他玩家 —— 你需要自己开三个窗口,通过左右手互搏的方式来体验游戏,下面是一个演示视频:

https://streamable.com/belpq

动作同步和状态同步

其实这个游戏的关键就是在多个客户端之间来同步数据、实现联机游戏,为此我先了解了一下业界总结出的两种同步模型:「动作同步」和「状态同步」。

在动作同步(帧同步)中:

  • 客户端发送操作,服务器只转发客户端的操作
  • 游戏逻辑主要在客户端运行(通常客户端需要掌握所有数据)
  • 延迟低,适合 RTS、MOBA、FPS
  • 可以让所有客户端有一个完全一致的时间轴

游戏状态的计算必须是确定的,不能有随机数,这样才能保证不同的客户端在应用相同的一系列动作之后能够得到相同的状态(游戏画面)。

在状态同步(C/S 同步)中:

  • 客户端发送操作,服务器转发计算后的游戏状态
  • 游戏逻辑主要在服务器运行(可以只向客户端发送部分数据)
  • 易于反作弊,适合 MMORPG、回合制(卡牌)游戏

因为游戏逻辑需要运行在服务器,所以服务器程序还是必不可少的。

选型

因为这是一个实验性质的项目,因此我想同时实现这两种同步模式来进行对比。在这里我挑选了一个回合制、属于非对称博弈的卡牌游戏作为游戏的内容。

我首先实现了动作同步,在动作同步中服务器几乎不需要做什么事情,只是作为一个「消息服务」来转发客户端的动作。于是我在这里使用了 LeanCloud Play 来完成这个消息转发的工作,每局游戏对应 Play 中的一个 Room,Play 允许客户端在房间中向其他玩家广播消息。

然后我又尝试将这个游戏改为了状态同步 —— 我引入一个运行在服务器上的特殊客户端(称为 MasterClient)。这个客户端中同样连入 Play 的消息服务,其中运行着完整的游戏逻辑,它将其他真正玩家的动作作为输入,然后输出游戏状态给其他真正玩家的客户端来展示,防止客户端作弊。

动作和状态

前面我提到业界已经总结出了两种同步模式,即动作同步和状态同步,「动作(Action)」和「状态(State)」这两个概念会贯穿这篇文章。

动作即玩家对于游戏的「输入」,可以是按键、点击,或者更抽象的动作,例如在这个游戏中我定义了两种动作 —— 出牌和放弃出牌:

type GameAction = PlayCardsAction | PassAction

interface PlayCardsAction {
  action: 'playCards'
  player: Player
  cards: Card[]
}

interface PassAction {
  action: 'pass'
  player: Player
}

状态即用来表示整个游戏局势所需的所有数据,例如在这个游戏中:

export interface GameState {
  players: Player[]
  playersCardsCount: PlayersCardsCount

  myCards: Card[]

  previousCards: Card[]
  previousCardsPlayer?: Player
  currentPlayer?: Player

  winer?: Player
}

游戏抽象

有了动作和状态的概念,我们就可以对游戏进行一个抽象了,我设计了一个 Game 类,是对一局游戏整个生命周期的封装,这个类将会同时运行于客户端和服务器:

// 事件:action(当前玩家的动作)、stateChanged(游戏状态变化)、error
class Game extends EventEmitter {
  constructor(seed: string, players: Player[])

  // 获取游戏状态(供 UI 调用)
  public getState(player: Player): GameState
  // 设置游戏状态(状态同步时),会触发 stateChanged 事件
  public setState(player: Player, state: GameState)

  // 当前玩家执行动作,会触发 action 事件
  public performAction(action: GameAction)
  // 应用其他玩家的动作(动作同步时)
  public applyAction(action: GameAction)
}

在客户端中,当 UI 捕捉到用户的输入时,执行 Game.performAction,动作(Action)的执行会改变状态(State),触发 stateChanged 事件,UI 收到这个事件后根据新的游戏状态来重绘 UI。

至于其他游戏逻辑则主要是关于「一组牌能够管得上另一组牌」的判断,在此不再罗列。

结构

在这个游戏中,我们将消息转发(由 LeanCloud Play 提供)视作一项服务、视作一个中心。所有玩家的客户端都连接到消息转发服务上,同时每局游戏我们还需加入一个运行在服务器上的 MasterClient 来提供特殊的管理能力。

为了将两种同步模式做成可简单替换的,我其实将服务器程序作为了一个必选组件(master-client 目录),但这个服务器程序在动作同步中只是负责创建房间(可以移到客户端),并不参与游戏逻辑。

我的代码分为 3 个部分:

common
├── game.ts
└── types.ts
browser-client
├── app.tsx
└── client-sync.ts
master-client
├── server-sync.ts
└── server.ts
  • common 部分会同时运行在服务器和客户端,包含游戏的核心逻辑
  • browser-client 是运行在浏览器中的客户端,包含 UI
  • master-client 是运行在服务器端中的 MasterClient

动作同步

我首先实现的是动作同步(client-sync.tsserver-sync.ts 中的 actionSyncController),这种模式下客户端发送动作(Action),服务器只转发动作,游戏逻辑主要在客户端运行,客户端掌握所有的数据(包括其他玩家的手牌)。

客户端的工作:

  • game.on('action') 时(表示用户在 UI 上进行了一个动作),通过 Play 将动作广播给其他客户端play.sendEvent(action)
  • play.on('customEvent') 时(表示收到其他客户端广播的动作),应用其他玩家的动作 game.applyAction(action)

而服务器端几乎没有什么工作,只是帮助客户端创建一个房间而已。

状态同步

在状态同步(client-sync.tsserver-sync.ts 中的 statusSyncContorller)中,客户端发送动作(Action),服务器运行游戏逻辑后,转发计算后的游戏状态(State),游戏逻辑主要在服务器运行,客户端只做展现,只知道自己的手牌。

客户端的工作:

  • game.on('action') 时(表示玩家在 UI 上进行了一个动作),通过 Play 将动作单独发送给 MasterClient play.sendEvent(action)(需加参数 {receiverGroup: ReceiverGroup.MasterClient}
  • play.on('customEvent') 时(表示收到 MasterClient 广播的最新状态),从服务器覆盖游戏状态 game.setState(state)

MasterClient 在服务器的工作:

  • 为每局游戏创建一个 Game 对象。
  • play.on('customEvent') 时(表示玩家进行了一个操作),在游戏对象上执行动作 game.performAction(action)
  • game.on('stateChanged') 时,给每一个玩家发送最新的游戏状态 play.sendEvent(state)

复用代码

在完成这个实验性质的项目后,我们需要来思考一下,哪些代码是可以复用的。

  • Game 类为一个游戏的过程提供了一个非常基本的抽象(即通过动作去改变状态),也为数据同步提供了基础的支持(action 事件和 stateChanged 事件)。
  • MasterClient 中对房间的管理是通用的,并且还有很大的改善空间,比如支持多实例允许并实现负载均衡等。

我将我使用的这种开发多人在线游戏的方式称之为 MasterClient 模式,它的好处是:

  • 在服务器和客户端之间复用大部分的游戏逻辑 如果你像我一样使用 JavaScript 的话,那么可以在服务器和客户端运行完全相同的代码。
  • 单机游戏 => 动作同步 => 状态同步 的迁移过程非常平滑 只要一开始能够区分好动作和状态,那么这个迁移的过程中只需改动少数代码。
  • 服务器端的游戏逻辑和消息转发服务解耦 消息转发服务可以更加稳定;MasterClient 则可以更快速地迭代。
  • 符合开发者直觉 至少我作为一个游戏开发的小白是觉得挺符合直觉的

Play & Client Engine

在我完成这个项目的过程中,我也不断地与公司的同事保持着沟通,经过几个月的努力,LeanCloud 在 Play 基础上发布了 Client Engine —— 一个用于托管 MasterClient 的容器平台(类似于云引擎),同时我们也提供了一个项目骨架,集成了我前面提到的功能,帮助开发者实现服务器端逻辑:

  • 游戏抽象 提供了一个类似的 Game 类来帮助开发者管理房间和玩家、填充游戏逻辑,在 Play 的基础上提供 RxJS 风格的高层次 API 来操作游戏动作和状态。
  • 负载均衡 提供了一个 GameManager 类来管理房间的创建,允许 Master Client 以多实例的集群模式运行,以便进行横向扩展,消除容量瓶颈。
  • 平滑部署 当你部署新版本的时候,旧实例会等待已有的房间完成游戏再退出,你可以在任何时候部署新的版本而不必担心影响用户的游戏。

我的这个项目早于 Client Engine 成型,是对通用游戏后端的一个实验。如果你希望编写类似的游戏,请阅读 Client Engine 的文档,并基于 Client Engine 的脚手架来进行开发,而不要直接基于本项目修改。

小结

本文探索的是一种「通用游戏后端」的解决方案,在这个小项目中我们用到了两个 LeanCloud 的服务:Play 和 Client Engine。

  • LeanCloud Play 扮演的是一个「消息转发服务」,它会维持与所有客户端(包括 Master Client)的长链接,允许客户端之间广播或单发消息。
  • LeanCloud Client Engine 提供了一个可信的服务器端环境来运行客户端 —— 在本文的例子中是 MasterClient,可以将游戏逻辑运行在服务器端来实现反作弊。

借助这两个服务,我们将对游戏服务器的开发需求降到了最低,只需编写运行于服务器端的游戏逻辑即可,而不必关心链接的保持、消息的转发和服务器环境和扩容等问题。

1

精子生于 1995 年,英文 ID jysperm.

订阅推送

通过 Telegram Channel 订阅我的博客日志、产品和项目的动态:

王子亭的博客 @ Telegram


通过邮件订阅订阅我的博客日志、产品和项目的动态(历史邮件):

该博客使用基于  Hexo  的  simpleblock  主题。博客内容使用  CC BY-NC-ND  授权发布。最后生成于 2023-12-20.