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

发表于2018-12-25
评论5 8.5k浏览

上一期,我们分享了如何开发答题对战小游戏,通过这个小游戏给大家展示了对战开发的基础结构:拥有多个房间类型的游戏,每个房间有两个玩家的情况下,游戏过程中玩家之间的通信均通过 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(这样我们一个人就能搞定前端和后端的代码)

明确服务端及客户端的分工
  • 服务端:托管 MasterClient 代码,控制游戏逻辑。

  • 客户端:根据情况展示 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。

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

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

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

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

  public async makeReservation(playerId: string) {
    let game: T;
    const availableGames = this.getAvailableGames();
    if (availableGames.length > 0) {
      game = availableGames[0];
      this.reserveSeats(game, playerId);
    } else {
      game = await this.createGame(playerId);
    }
    return game.room.name;
  }

}

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

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

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

import { Game } from "@leancloud/client-engine";
import { Event, Play, Room } from "@leancloud/play";
export default class RPSGame extends Game {
  constructor(room: Room, masterClient: Play) {
    super(room, masterClient);
  }
}

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

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

import PRSGame from "./rps-game";
const reception = new Reception(
  PRSGame,
  APP_ID,
  APP_KEY,
  {concurrency: 2}
);

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

app.post("/reservation", async (req, res, next) => {
  try {
    const {playerId} = req.body as {playerId: any};
    // 调用我们在 Reception 类中准备好的 makeReservation() 方法
    const roomName = await reception.makeReservation(playerId);
    return res.json({roomName});
  } catch (error) {
    next(error);
  }
}

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

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

// 向 Client Engine 请求快速开始。
// 这里通过 HTTP 调用在 Client Engine 中实现的 `/reservation` 接口
const { roomName } = await (await fetch(
  `${CLIENT_ENGINE_SERVER}/reservation`,
  {
    method: "POST",
    headers: {"Content-Type": "application/json"},
    body: JSON.stringify({
      playerId: play.userId
    })
  }
  )).json();
  // 加入房间
return play.joinRoom(roomName);

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

自定义游戏逻辑

限定房间人数
export default class RPSGame extends Game {
  public static defaultSeatCount = 2;
}

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

房间人满,广播游戏开始

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

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

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

  protected start = async () => {
    // 标记房间不再可加入
    this.masterClient.setRoomOpened(false);
    // 向客户端广播游戏开始事件
    this.broadcast("game-start");
    ……
  }
}

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

双方开始游戏

我们再看一下双方游戏的逻辑:

  1. 玩家 A 选择手势
  2. 玩家 B 界面展示:对方已选择
  3. 玩家 B 选择手势
  4. 玩家 A 界面展示:对方已选择
  5. 玩家 A 及 B 的界面展示结果

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

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

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

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

play.sendEvent("play", {index}, {receiverGroup: ReceiverGroup.MasterClient});

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

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

this.masterClient.on(Event.CUSTOM_EVENT, event => {
  const eventId = event.eventId;
  if (eventId === 'play') {
    this.forwardToTheRests(event, (eventData) => {});
  }
});

在这段代码中,我们使用了 SDK 中 Game 提供的 forwardToTheRests() 方法,这个方法会转发事件给房间内其他人,第一个参数是原始事件 event,在第二个参数中,我们修改了原始 event 中的数据,将 eventData 设置为了空数据,这样客户端 B 收到事件时无法知道具体的手势信息。

当客户端 B 收到事件后,就可以在界面上展示:对方已选择。相关代码如下:

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

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

this.masterClient.on(Event.CUSTOM_EVENT, event => {
  const eventId = event.eventId;
  if (eventId === 'play') {
    ……
    if (answerArray.length === 2) {
      const winner = this.getWinner(answerArray);
      this.broadcast("game-over", {winnerId: winner.userId});
    }
  }
});

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

play.on(Event.CUSTOM_EVENT, ({ eventId, eventData, senderId }) => {
  const eventId = event.eventId;
  if (eventId === 'play') {
    ……
  }
  if (eventId === 'game-over') {
    //展示游戏结束
  }
});
离开房间

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

总结

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

参考资料

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

补充

增加倒计时

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

RxJS

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

Q & A

1.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 的方法,建议自己亲自动手写一写代码,会感受到其中的不同。

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引