乐趣区

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

在如何开发答题对战小游戏中给大家展示了对战开发的基础结构:拥有多个房间类型的游戏,每个房间有两个玩家的情况下,游戏过程中玩家之间的通信均通过 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,不需要我们再自己去搭建后端整体架构。

服务端逻辑控制:Client Engine

游戏内通信:实时对战服务

2. 语言选择:JavaScript(这样我们一个人就能搞定前端和后端的代码)
明确服务端及客户端的分工

服务端:托管 MasterClient 代码,控制游戏逻辑。
客户端:根据情况展示 UI

准备项目框架

服务端 Client Engine 初始项目
客户端项目

游戏逻辑开发
下面我们进入写代码的模块。
进入房间
客户端点击「快速开始」时,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 事件,客户端收到这个事件后,在界面上展示:游戏开始。
双方开始游戏
我们再看一下双方游戏的逻辑:

玩家 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 的出拳代码:
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,可以参考以下文档

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

退出移动版