我开发了一个基于 Beancount 的账本托管服务 HostedBeans,欢迎大家来了解纯文本复式记账并试用我的服务。
归档 2018 年 11 月

22 岁的我在想些什么

蛋黄总说我过去二十几年过得顺风顺水,想想也是,虽然有小的波折,但并没有什么让我后悔的事情。我觉得这当然有运气的成分,但和我个人的选择也是分不开的。

在大家抱怨学校生活时,我就想找一个彻底的方式来摆脱学校的限制,于是后来我退学了;在大家抱怨工作不称心的时候,我就想着一定要去一家能让我开心地工作的公司,于是我找到了番茄土豆和 LeanCloud;现在我仍在听着大家抱怨着生活中的种种,于是我也在准备着一个能够彻底摆脱这些问题的计划。

对于这个选择我的心情非常复杂,既有期待也有不安;既怪自己行动得太晚了,但也明白是遇到了蛋黄才让我有了勇气和决心来准备移民。在我们刚刚在一起的时候其实就已经有了这个想法,也在陆续做着准备,在过去的一年中,整个路线逐渐清晰了起来,我们也越来越期待成功的那一天,这将会是我下一个重要的选择。

国内就总喜欢黑高福利国家人们不好好工作、人力成本高的要死,黑民主国家办事效率低、办不成大事。但我现在越来越觉得,这也许才是一个稳定的国家的常态,充分保障个人的自由、甚至说保障人们「不求上进」的自由,平衡各方的利益,这样才能带来稳定。

随着一开始的「矜持」被放下,这一年中我们吵得确实比刚在一起的几个月要多得多。但我和蛋黄已经为未来做了这么多规划了,虽然大大小小的分歧吵闹不断,但我还是相信我们会一直走下去。年初我在知乎上回答了一个 关于完美女朋友的问题,也为自己找到了「完美女朋友」而高兴 —— 当然我现在也是。但还有一点我比较关心的是,蛋黄还没找到工作,我觉得工作是生活中很重要的一部分,我希望她也能找到一个愿意为之投入,也能获取合理的收入的工作。另外我觉得人在开始工作之后可能对于很多事情的看法会有一个比较大的转变,对这种转变我还是期待更多一点,我觉得那时她会更能够理解我的一些想法。

在 LeanCloud 工作三年了,期间也有人问我要不要考虑其他的工作机会,我想了一下,继续留在 LeanCloud 大概是因为这里的工作既「稳定」又「充满挑战」。这么说也许看似矛盾,但我说的稳定是指,这里的工作全部围绕着 LeanCloud 这个云平台 —— 更具体来说是围绕着云引擎,它本身已经是一个相对稳定的系统了,有着不小的用户量,需要以严肃的态度去对待每一个改动;充满挑战是指围绕这个大的方向,还是可以做很多有趣的尝试的,工作的方向和内容并没有受到限制,而且我会感觉到我个人的决定,对公司的产品是有着直接的影响的。

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.