如何通过服务端控制游戏逻辑

GAD 2018-12-28


上一期,我们分享了如何开发答题对战小游戏,通过这个小游戏给大家展示了对战开发的基础结构:拥有多个房间类型的游戏,每个房间有两个玩家的情况下,游戏过程中玩家之间的通信均通过 LeanCloud Play 实时对战转发。


游戏中,我们还使用了 MasterClient ,作为一个裁判或上帝视角的角色,用于出题及判断每个玩家的分数。Play 实时对战默认房间的创建者为 MasterClient,也就是说,创建房间的 Client 会有两种身份,一个是普通的玩家,另一个是 MasterClient 裁判角色。


MasterClient 除了在答题小游戏中出题之外,还可以在卡牌类游戏中洗牌、控制刷怪的时机或等级、判断游戏胜负等等。它掌握着房间内整个游戏逻辑。


既然 MasterClient 是个这么重要的角色,那么我们把他放在客户端就会有一个重要问题:安全隐患。例如客户端的代码被破解之后,MasterClient 身份的玩家可以篡改游戏数据,指定本该输掉的人胜利等。


为了解决这个问题,我们把控制游戏逻辑的 MasterClient 从客户端移到服务端,这样从客户端就拿不到游戏逻辑代码,进而也无法控制游戏逻辑。我们把每个房间的 MasterClient 托管在一个叫 Client Engine 的后端服务上,MasterClient 在 Client Engine 中通过实时对战后端服务和客户端进行交互。产生了新的架构:


这里 Client Engine 和实时对战云都是 LeanCloud 的服务,同在 LeanCloud 的后端内网中。

实战开发目标 Demo
下面我们感受下如何基于这种架构开发小游戏,在这次分享中我们的目标是开发一个剪刀石头布对战小游戏。你可以用两个浏览器打开这个页面,感受下整个小游戏。


在这个小游戏中,两个客户端点击「快速开始」,进入到同一个房间内,游戏开始后进行猜拳,一轮猜拳后判断胜负,游戏结束。

游戏逻辑
我们把游戏逻辑拆解为以下步骤:

1.进入房间:客户端点击「快速开始」时,MasterClient 及玩家客户端 A 和 B 进入同一房间
2.双方开始游戏:
  • 玩家 A 选择手势
  • 玩家 B 界面展示:对方已选择
  • 玩家 B 选择手势
  • 玩家 A 界面展示:对方已选择
  • 玩家 A 及 B 的界面展示结果

3.游戏结束,双方离开房间,房间销毁。

服务和语言
1.服务选择:选择已经搭建好的后端服务 LeanCloud Play,不需要我们再自己去搭建后端整体架构。

2.语言选择:JavaScript(这样我们一个人就能搞定前端和后端的代码)

明确服务端及客户端的分工
  • 客户端:根据情况展示 UI

准备项目框架
游戏逻辑开发
下面我们进入写代码的模块。

进入房间
客户端点击「快速开始」时,MasterClient 及玩家客户端 A 和 B 进入同一房间
  • Client Engine 服务端:维护 MasterClient 并创建房间,下发 roomName 给客户端
  • 客户端:加入服务端创建的房间中

我们先看一下 Client Engine 中的逻辑:

Client Engine 负责维护 MasterClient 并创建房间,通过一个名为 /reservation 的自定义 API 接口为客户端提供 roomName,在这个接口中我们实现逻辑「快速开始」。「快速开始」中创建房间的功能是使用 Client Engine SDK 中的 GameManager 来实现的。
在 Client Engine 中,我们使用到的 Client Engine SDK 提供以下两个组件。
  • Game:每个房间对应一个 Game 实例,Client Engine 中有 N 个 Game。
  • GameManager:GameManager 负责创建、管理、销毁 Game。

我们只需要根据情况组合这两个组件的功能就可以实现自己的需求。

接下来我们写「快速开始」的逻辑:随机为客户端找到一个房间,如果没有空房间,就创建一个新房间。

  1. import { Game, GameManager, ICreateGameOptions } from "@leancloud/client-engine";

  2. export default class Reception<T extends Game> extends GameManager<T> {

  3.   public async makeReservation(playerId: string) {
  4.     let game: T;
  5.     const availableGames = this.getAvailableGames();
  6.     if (availableGames.length > 0) {
  7.       game = availableGames[0];
  8.       this.reserveSeats(game, playerId);
  9.     } else {
  10.       game = await this.createGame(playerId);
  11.     }
  12.     return game.room.name;
  13.   }

复制代码

在这段代码中,我们创建了一个 Reception 类继承自 GameManager 来管理 Game。在这个类中,我们写了一个 public 方法 makeReservation 实现「快速开始」:

首先调用 GameManager 自身的 getAvailableGames() 方法查看有没有可用的空房间,如果有,就取第一个空房间,返回 roomName ;如果没有空房间,则使用 GameManager 的 createGame() 方法创建一个新房间,返回新房间的 roomName。

从上面的代码中我们还可以看到,Reception 管理了一个 T 类型的 Game 对象,因此我们还需要为 Reception 准备 Game。下面我们继续自定义一个自己的 Game :

  1. import { Game } from "@leancloud/client-engine";
  2. import { Event, Play, Room } from "@leancloud/play";
  3. export default class RPSGame extends Game {
  4.   constructor(room: Room, masterClient: Play) {
  5.     super(room, masterClient);
  6.   }
复制代码

在这段代码中,我们自定义了一个名为 RPSGame 的类继承自 Game,之后会在 RPSGame 中撰写房间内的游戏逻辑,在这里我们先简单的将这个类构造出来。

接下来我们把这个类给到 Reception,让 Reception 来管理这个类。

  1. import PRSGame from "./rps-game";
  2. const reception = new Reception(
  3.   PRSGame,
  4.   APP_ID,
  5.   APP_KEY,
  6.   {concurrency: 2}
  7. );
复制代码

在这段代码中,我们创建了一个 reception 对象,在创建对象的第一个参数中,我们传入了刚才创建的 RPSGame,这样 Reception 就可以管理 RPSGame 了,到现在为止「快速开始」的逻辑就可以跑起来了。下面我们写一个 API 接口来提供「快速开始」功能:

  1. app.post("/reservation", async (req, res, next) => {
  2.   try {
  3.     const {playerId} = req.body as {playerId: any};
  4.     // 调用我们在 Reception 类中准备好的 makeReservation() 方法
  5.     const roomName = await reception.makeReservation(playerId);
  6.     return res.json({roomName});
  7.   } catch (error) {
  8.     next(error);
  9.   }
复制代码

到这里,服务端「快速开始」就准备好了,当客户端调用该 /reservation 接口时,服务端会执行快速开始的逻辑,给客户端随便返回一个有空位的房间。

客户端调用 /reservation 的示例代码如下:

  1. // 向 Client Engine 请求快速开始。
  2. // 这里通过 HTTP 调用在 Client Engine 中实现的 `/reservation` 接口
  3. const { roomName } = await (await fetch(
  4.   `${CLIENT_ENGINE_SERVER}/reservation`,
  5.   {
  6.     method: "POST",
  7.     headers: {"Content-Type": "application/json"},
  8.     body: JSON.stringify({
  9.       playerId: play.userId
  10.     })
  11.   }
  12.   )).json();
  13.   // 加入房间
  14. return play.joinRoom(roomName);
复制代码

当客户端 A 和 客户端 B 都运行加入房间的代码,进入同一个房间后,就可以开始游戏了,接下来是实现房间内的逻辑。

自定义游戏逻辑
限定房间人数

  1. export default class RPSGame extends Game {
  2.   public static defaultSeatCount = 2;
复制代码

在这段代码中,我们给 RPSGame 设定一个静态属性 defaultSeatCount = 2,当房间玩家数量为两个人时,GameManager 会认为房间已满,不再是可用房间;GameManager 管理的 MasterClient 向实时对战服务请求创建新房间时,也会以这里的数量为标准,限定房间最大玩家数量是 2 个人,满 2 个人时不得有新玩家再加入房间。

房间人满,广播游戏开始
当房间内的玩家数量等于 defaultSeatCount 时,我们可以通过以下代码来监听房间人满事件:

  1. @watchRoomFull()
  2. export default class RPSGame extends Game {
  3.   public static defaultSeatCount = 2;

  4.   constructor(room: Room, masterClient: Play) {
  5.     super(room, masterClient);
  6.     // 游戏创建后立刻监听房间人满事件
  7.     this.once(AutomaticGameEvent.ROOM_FULL, this.start);
  8.   }

  9.   protected start = async () => {
  10.     // 标记房间不再可加入
  11.     this.masterClient.setRoomOpened(false);
  12.     // 向客户端广播游戏开始事件
  13.     this.broadcast("game-start");
  14.     ……
  15.   }
复制代码


在这段代码中,@watchRoomFull 装饰器会让 Game 在人满时抛出 ROOM_FULL 事件,我们在 constructor() 方法中监听到这个事件后,调用了自己的 start 方法。在 start 方法中,我们将房间关闭,然后向客户端广播 game-start 事件,客户端收到这个事件后,在界面上展示:游戏开始。

双方开始游戏
我们再看一下双方游戏的逻辑:
  • 玩家 A 选择手势
  • 玩家 B 界面展示:对方已选择
  • 玩家 B 选择手势
  • 玩家 A 界面展示:对方已选择
  • 玩家 A 及 B 的界面展示结果

将游戏逻辑对应到开发逻辑上,过程如下图所示:

从图中可以看到,这里涉及到三方:客户端 A 、客户端 B、处在 Client Engine 中的 MasterClient。

  • 当客户端 A 出拳时,发送一个名为 play 的事件给 MasterClient,MasterClient 接收事件后,记录下来客户端 A 的选项,然后抹掉选项数据将事件转发给客户端 B,这样客户端 B 只知道客户端 A 出拳,但是并不知道具体手势是什么。
  • 接着客户端 B 出拳发送 play 事件,MasterClient 转发给客户端 A。
  • 这时 MasterClient 发现双方都已经出拳了,判定游戏结果,并通过广播 game-over 事件通知双方客户端游戏结束。

首先我们看一下客户端 A 的出拳代码:

  1. play.sendEvent("play", {index}, {receiverGroup: ReceiverGroup.MasterClient});
复制代码

在这段代码中,客户端 A 使用实时对战 SDK 发送了 play 事件,在事件中附带了手势数据 {index},指定这个事件的接收对象为 MasterClient。

处在 Client Engine 中的 MasterClient 收到 play 事件后转发事件给客户端 B:

  1. this.masterClient.on(Event.CUSTOM_EVENT, event => {
  2.   const eventId = event.eventId;
  3.   if (eventId === 'play') {
  4.     this.forwardToTheRests(event, (eventData) => {});
  5.   }
  6. });
复制代码

在这段代码中,我们使用了 SDK 中 Game 提供的 forwardToTheRests() 方法,这个方法会转发事件给房间内其他人,第一个参数是原始事件 event,在第二个参数中,我们修改了原始 event 中的数据,将 eventData 设置为了空数据,这样客户端 B 收到事件时无法知道具体的手势信息。
当客户端 B 收到事件后,就可以在界面上展示:对方已选择。相关代码如下:

  1. play.on(Event.CUSTOM_EVENT, ({ eventId, eventData, senderId }) => {
  2.   const eventId = event.eventId;
  3.   if (eventId === 'play') {
  4.     //这里写客户端 UI 展示的代码
  5.   }
  6. });
复制代码

接着游戏逻辑是,客户端 B 选择手势,MasterClient 转发手势给客户端 A,这里的逻辑和上面的一样,不再赘述,我们直接跳到判断游戏胜负并广播游戏结束。相关代码如下:

  1. this.masterClient.on(Event.CUSTOM_EVENT, event => {
  2.   const eventId = event.eventId;
  3.   if (eventId === 'play') {
  4.     ……
  5.     if (answerArray.length === 2) {
  6.       const winner = this.getWinner(answerArray);
  7.       this.broadcast("game-over", {winnerId: winner.userId});
  8.     }
  9.   }
  10. });
复制代码

在这段代码中可以看到,每次 MasterClient 收到 play 事件时,都会保存玩家的手势,当发现两个玩家都出拳后,根据两个玩家的出拳结果判断胜负,然后广播 game-over 事件,在 game-over 事件中告诉所有人胜负。客户端收到 game-over 事件后,在界面上展示游戏结束。客户端相关代码如下:

  1. play.on(Event.CUSTOM_EVENT, ({ eventId, eventData, senderId }) => {
  2.   const eventId = event.eventId;
  3.   if (eventId === 'play') {
  4.     ……
  5.   }
  6.   if (eventId === 'game-over') {
  7.     //展示游戏结束
  8.   }
  9. });
复制代码


离开房间
当两个客户端都离开房间后,房间会被 GameManager 自动销毁,不需要我们再写额外的代码。

总结
在本次分享中,我们把负责游戏逻辑的 MasterClient 放在服务端来保证安全性。MasterClient 被托管到 Client Engine 中,通过实时对战后端云与同房间内的客户端传递消息,保证游戏正常运行。

参考资料
如果你希望有更详细的资料来帮助你一步一步开发猜拳小游戏,或更进一步了解 Client Engine,可以参考以下文档



补充

增加倒计时
可以自己尝试为剪刀石头布游戏增加倒计时功能,例如某个客户端在限定时间内没有做出选择,则输掉本局比赛。

RxJS
如果希望对事件有更好的代码组织方式,可以学习下 RxJS

Q & A1.Client Engine SDK 和 Play SDK 有什么不一样?
Play SDK 指的是实时对战 SDK,玩家客户端和处在 Client Engine 中的 MasterClient 都要使用这个 SDK 与实时对战服务交互,进而互相传递消息。为了方便大家撰写 Client Engine 中的代码,Client Engine SDK 提供了两方面的功能:
  • 对 Play SDK 更进一步的封装,提供了作为 MasterClient 便利的方法:广播、转发消息等。
  • 额外提供了 GameManager 及 Game,方便对多个房间进行管理。

2.使用 Client Engine 开发游戏逻辑,和在客户端开发游戏逻辑相比,各自有什么优缺点。
在一开始的时候有讲到,将代码放到 Client Engine 中会更安全,避免客户端被破解,进而篡改游戏逻辑。可能有的同学认为有一个缺点是需要部署并运维服务端,但 Client Engine 的使用方式十分便捷,全部交给 LeanCloud 来部署运维,自己只需要写游戏逻辑就可以,所以不存在自己部署以及运维困难的问题。

3.如今都原生支持异步的情况下,还需要学习 RxJS 吗?
RxJS 会将异步及事件组合为一个流式操作,在大型项目上逻辑性会更好,对工程师要求的抽象水平更高,代码也会更加简洁。参考资料中《你的第一个 Client Engine 小游戏》使用的是 Play SDK 事件代码,github 的 repo 中使用了 Client Engine 封装的 RxJS 的方法,建议自己亲自动手写一写代码,会感受到其中的不同。

来源:GAD
地址:http://gad.qq.com/article/detail/288941



最新评论
暂无评论
参与评论

商务合作 查看更多

编辑推荐 查看更多