你的第一个 Client Engine 小游戏 · Node.js
该文档帮助您快速上手,通过 Client Engine 实现一个剪刀石头布的猜拳小游戏。完成本文档教程后,您会对 Client Engine 的基础使用流程有初步的理解。
准备初始项目
这个小游戏分为服务端和客户端两部分,其中服务端使用 Client Engine 来实现,客户端则是一个简单的 Web 页面。在这个教程中我们着重教您一步一步写 Client Engine 中的代码,客户端的代码请您查看示例项目。
Client Engine 项目
请先阅读 Client Engine 快速入门:运行及部署项目 获得初始项目,了解如何本地运行及部署项目。
./src
中主要源文件及用途如下:
├── configs.ts // 配置文件
├── index.ts // 项目入口
├── reception.ts // Reception 类实现文件,GameManager 的子类,负责管理 Game,在这个文件中撰写了创建 Game 的自定义方法
└── rps-game.ts // RPSGame 类实现文件,Game 的子类,在这个文件中撰写了具体猜拳游戏的逻辑
该项目中的 Game
及 GameManager
使用的是 Client Engine SDK 的功能,关于 SDK 的详细用法请参考 Client Engine 开发指南。
您可以从 index.ts
文件入手来了解整个项目,该文件是项目启动的入口,它通过 express 框架定义了名为 /reservation
的 Web API,供客户端快速开始时为客户端下发新的房间名称。
reception.ts
及 rps-game.ts
里面有本教程的全部代码。您可以选择备份这两个文件,清空这两个文件后根据本文档撰写自己的代码,同时也可以查看已经写好的代码以做对比。
客户端项目
点击下载客户端项目。打开 ./src
中的 config.ts
,将 appId 和 appKey 修改为自己应用的信息,按照 README 启动项目后观察界面的变化。游戏相关的逻辑位于 ./src/components
下的文件中,在有需要的时候您可以打开这里的文件查看代码。
核心流程
在多人对战服务中,房间的创建者为 MasterClient,因此在这个小游戏中,每一个房间都是由 Client Engine 管理的 MasterClient 调用在线对战服务相关的接口来创建的。Client Engine 中会有多个 MasterClient,每一个 MasterClient 管理着自己房间内的游戏逻辑。
这个小游戏的核心逻辑为:Client Engine 中的 MasterClient 及客户端玩家 Client 加入到同一个房间,在通信过程中由 MasterClient 控制游戏内的逻辑。具体拆解步骤如下:
- 玩家客户端连接多人在线对战服务,向 Client Engine 提供的
/reservation
接口请求快速开始游戏。 - Client Engine 每次收到请求后会检查是否有可用的房间,如果有则返回已有的 roomName 给客户端;如果没有则创建新的 MasterClient 并创建一个新的房间,返回 roomName 给客户端。
- 客户端通过 Client Engine 返回的 roomName 加入房间。
- MasterClient 和客户端在同一房间内,每次客户端出拳时会将消息发送给 MasterClient,MasterClient 将消息转发给其他客户端,并最终判定游戏结果。
- MasterClient 判定游戏结束,客户端离开房间,Client Engine 销毁游戏。
代码开发
自定义 Game
我们的目标是让 MasterClient 和客户端 Client 进入同一个房间,第一步在 Client Engine 中我们先准备好房间。在 Client Engine SDK 中,每一个房间都对应一个 Game
对象,每一个 Game
对象都对应一个自己的 MasterClient。接下来我们创建一个继承 Game
的子类 RPSGame
,在 RPSGame
中撰写猜拳小游戏的房间内逻辑。
在 rpg-game.ts
文件中初始化自定义的 RPSGame
:
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);
}
}
管理 Game
Client Engine SDK 中,GameManager
负责 Game
的创建及销毁,具体的原理及结构介绍请参考 Client Engine 开发指南。在这篇文档中,我们通过简单的配置就可以使用 GameManager
的管理功能。
自定义 GameManager
首先创建一个子类 Reception
继承自 GameManager
,在这个子类中我们就可以使用 GameManager
提供的方法来帮我们撰写自己的逻辑。
在 reception.ts
文件中初始化自定义的 Reception
:
import { Game, GameManager, ICreateGameOptions } from "@leancloud/client-engine";
export default class Reception<T extends Game> extends GameManager<T> {
}
这个自定义的类Reception
用于管理 T 类型的 Game
对象,在实际游戏中会是您自定义的 Game
类型的实例。接下来,我们在 reception
中使用 GameManager
的方法来实现自己的自定义逻辑:快速开始。
实现逻辑:「快速开始」
这里我们要实现的快速开始的逻辑是:随便找一个有空位的房间返回给客户端,如果当前的 Client Engine 实例没有可用的房间,那么就创建一个房间返回给客户端。我们在 Reception
类中撰写名为 makeReservation()
的自定义方法来实现这个逻辑并供[入口 API ](#入口 API:快速开始)调用。
import { Game, GameManager, ICreateGameOptions } from "@leancloud/client-engine";
export default class Reception<T extends Game> extends GameManager<T> {
/**
* 为指定玩家预约游戏,如果没有可用的游戏会创建一个新的游戏。
* @param playerId 预约的玩家 ID
* @return 预约成功的游戏的房间 name
*/
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;
}
}
在这段代码中,我们调用了 GameManager
的 getAvailableGames()
来获得当前 Client Engine 实例管理的 Game
:
- 如果有房间内还有空位的
Game
,使用GameManager
的reserveSeats()
方法为玩家占位并返回 roomName。 - 如果所有
Game
房间都已经满员了,使用GameManager
的createGame()
方法创建一个新的房间并返回 roomName。
实现逻辑:「创建新游戏」
如果您希望自己先创建房间,再邀请朋友加入该房间,可以在 reception
中写一个创建新游戏的方法供供[入口 API ](#入口 API:创建新游戏)调用。同样我们自定义的 createGameAndGetName()
方法内用到的 createGame()
是由 SDK 中的 GameManager
提供的。
export default class Reception<T extends Game> extends GameManager<T> {
public async makeReservation(playerId: string) {
......
}
/**
* 创建一个新的游戏。
* @param playerId 预约的玩家 ID
* @param options 创建新游戏时可以指定的一些配置项
* @return 创建的游戏的房间 name
*/
public async createGameAndGetName(playerId: string, options?: ICreateGameOptions) {
const game = await this.createGame(playerId, options);
return game.room.name;
}
}
绑定 GameManager 及 Game
当 GameManager
的子类 Reception
及 Game
的子类 RPSGame
都准备好后,我们要在整个项目入口把 RPSGame
给到 Reception
,由 Reception
来管理 RPSGame
。
在 index.ts
文件创建 Reception
对象的方法中可以看到,第一个参数已经传入了 RPSGame
,如果您的自定义 Game
使用的是其他的名字,可以将 RPSGame
换成您自定义的 Game
类。
import PRSGame from "./rps-game";
const reception = new Reception(
PRSGame,
APP_ID,
APP_KEY,
{
concurrency: 2,
},
);
在这里配置完成后,reception
会在合适的时机创建并管理 PRSGame
和对应的 MasterClient。
配置负载均衡
由于 GameManager
中的逻辑会直接被外部请求所调用,因此需要为在入口处为 GameManager
配置负载均衡。关于负载均衡详细的介绍可以参考 Client Engine 开发指南,在这里我们先简单的查看 index.ts
文件中的这些代码,了解如何配置即可:
import { ICreateGameOptions,LoadBalancerFactory } from "@leancloud/client-engine";
// 创建负责负载均衡的对象,此处代码不需要改动,只需要复制粘贴即可
const loadBalancerFactory = new LoadBalancerFactory({
poolId: `${APP_ID.slice(0, 5)}-${process.env.LEANCLOUD_APP_ENV || "development"}`,
redisUrl: process.env.REDIS_URL__CLIENT_ENGINE,
});
// 将 reception 及我们自定义的方法 makeReservation 配置负载均衡。
loadBalancerFactory.bind(reception, ["makeReservation", "createGameAndGetName"])
到这里,管理 RPSGame
的 reception
我们已经准备完成,接下来开始撰写具体的房间内游戏逻辑。
设定房间内玩家数量
在这个猜拳小游戏中,我们设定只允许两个玩家玩,满两个玩家后就不允许新的玩家再进入房间,可以这样设置 Game
的静态属性 defaultSeatCount
:
export default class RPSGame extends Game {
public static defaultSeatCount = 2;
}
在这里配置完成后,Client Engine 初始项目每次请求多人对战服务创建房间时,都会根据这里的值限定房间内的玩家数量。
对设置房间内玩家数量的详细讲解请参考 Client Engine 开发指南。
MasterClient 及客户端进入同一房间
在完成 Game 的基础配置之后,MasterClient 和客户端就可以准备加入同一个房间了。
入口 API:快速开始
index.ts
文件的入口 API /reservation
,当客户端调用这个接口时,该接口会调用 Reception
自定义的 makeReservation()
方法来帮助客户端快速开始游戏。
app.post("/reservation", async (req, res, next) => {
try {
const {
playerId,
} = req.body as {
playerId: any
};
if (typeof playerId !== "string") {
throw new Error("Missing playerId");
}
debug(`Making reservation for player[${playerId}]`);
// 调用我们在 Reception 类中准备好的 makeReservation() 方法
const roomName = await reception.makeReservation(playerId);
debug(`Seat reserved, room: ${roomName}`);
return res.json({
roomName,
});
} catch (error) {
next(error);
}
});
客户端可以调用这个 API 来快速开始,用该接口的示例代码如下 (非 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);
当客户端调用 /reservation
并加入房间成功后,意味着客户端 Client 及 MasterClient 进入了同一个房间内,当房间人数足够时,就可以开始游戏了。
客户端项目中已经帮您写好了调用 /reservation
的代码,无需您自己再写代码,您可以在 /src/components/Lobby.vue
中查看相关代码。
入口 API:创建新游戏
该入口 API 撰写方式与「快速开始」相同,不再重复说明,可以参考 index.ts 文件中的 /game
方法。
宣布游戏开始
在这个小游戏中,人满后我们就可以开始游戏了。我们可以在 Game 的人满事件中宣布游戏开始:
import { AutomaticGameEvent, Game, watchRoomFull } from "@leancloud/client-engine";
import { Play, Room } from "@leancloud/play";
enum Event {
Start = 10,
};
@watchRoomFull()
export default class RPSGame extends Game {
public static defaultSeatCount = 2;
constructor(room: Room, masterClient: Play) {
super(room, masterClient);
// 监听 ROOM_FULL 事件,收到此事件后调用 `start() 方法`
this.once(AutomaticGameEvent.ROOM_FULL, this.start);
}
protected start = async () => {
// 标记房间不再可加入
this.masterClient.setRoomOpened(false);
// 向客户端广播游戏开始事件
this.broadcast(Event.Start);
}
}
在这段代码中,watchRoomFull
装饰器在人满时会使 Game
抛出 AutomaticGameEvent.ROOM_FULL
事件,在这个事件中我们选择调用自定义的 start
方法。在 start
方法中我们将房间打开,然后向所有客户端广播游戏开始。
到了这一步,您可以启动当前 Client Engine 项目,启动客户端并开启两个客户端 Web 页面,在界面上点击「快速开始」,可以观察到第一个点击「快速开始」的界面显示出了日志:xxxx 加入了房间
。
猜拳逻辑
接下来我们开始开发具体的游戏中逻辑。具体步骤分为以下几步:
- 玩家 A 选择手势,发送出拳事件给 MasterClient。
- MasterClient 收到事件,转发事件给玩家 B。
- 玩家 B 收到 MasterClient 转发来的事件,界面展示:对方已选择。
- 玩家 B 选择手势,发送出拳事件给 MasterClient。
- MasterClient 收到事件,转发事件给玩家 A。
- 玩家 A 收到 MasterClient 转发来的事件,界面展示:对方已选择。
- MasterClient 发现双方都已经出拳,判断结果,公布答案并宣布游戏结束。
这三者之间的交互可以用这张图来表示:
接下来我们对每一步进行拆解并撰写代码:
玩家 A 选择手势,发送出拳事件给 MasterClient
这一部分代码是客户端的,不需要您写在 Client Engine 中,您可以在客户端项目中 ./src/components/Game.vue
找到相关代码。
enum Event {
Start = 10,
Play = 11,
};
choices = ["✊", "✌️", "✋"];
// 当用户选择时,我们把对应选项的 index 发送给服务端
play.sendEvent(Event.Play, {index}, {receiverGroup: ReceiverGroup.MasterClient});
MasterClient 收到事件,转发事件给玩家 B
这部分代码写在 Client Engine 中,您可以根据下方的示例代码写在自己的 RPSGame
中。我们在 start
方法中注册自定义事件,并在收到 play
事件后,将玩家 A 的动作内容抹去,转发给玩家 B。
protected start = async () => {
......
// 接收自定义事件
this.masterClient.on(PlayEvent.CUSTOM_EVENT, ({ eventId, eventData, senderId }) => {
if (eventId === Event.Play) {
// 收到其他玩家的事件,转发事件
this.forwardToTheRests({ eventId, eventData, senderId }, (eventData) => {
return {}
})
}
});
}
在这段代码中,Game 中的 MasterClient 对象注册了多人对战的自定义事件,当玩家 A 发送 play
事件给 MasterClient 时,这个事件会被触发。我们在这个事件中使用了 Game
的转发事件方法 forwardToTheRests()
,这个方法第一个参数是原始的事件,第二个参数是原始事件的 eventData 数据处理,我们将原始的 eventData 数据,也就是玩家 A 发来的 {index}
,修改为空数据 {}
,这样当玩家 B 收到事件后无法获知玩家 A 的详细动作。
玩家 B 收到 MasterClient 转发来的事件,界面展示:对方已选择
这部分代码是客户端的,不需要您写在 Client Engine 中,您可以在客户端项目中 ./src/components/Game.vue
找到相关代码。
play.on(PlayEvent.CUSTOM_EVENT, ({ eventId, eventData, senderId }) => {
......
switch (eventId) {
......
case Event.Play:
this.log(`对手已选择`);
break;
.....
}
});
玩家 B 选择手势,发送出拳事件给 MasterClient
这部分逻辑和上文「玩家 A 选择手势,发送出拳事件给 MasterClient」相同,使用的也是相同部分的代码,您可以在客户端项目中 ./src/components/Game.vue
找到相关代码。
MasterClient 收到事件,转发事件给玩家 A
这部分逻辑和上文「MasterClient 收到事件,转发事件给玩家 B」相同,使用的也是相同部分的代码,不需要再额外在 Client Engine 中写代码。
玩家 A 收到 MasterClient 转发来的事件,界面展示:对方已选择
这部分逻辑和上文「玩家 A 收到 MasterClient 转发来的事件,界面展示:对方已选择」相同,使用的也是相同部分的代码,您可以在客户端项目中 ./src/components/Game.vue
找到相关代码。
到这一步时,您可以运行项目,打开两个界面猜拳,观察双方的动作同步到各自的界面中,但各自分别不知道对方选了什么。
MasterClient 发现双方都已经出拳,判断结果,公布答案并宣布游戏结束
每次 MasterClient 收到玩家选择事件时,我们要把玩家的选择存起来,并判断两位玩家是不是都已经做出选择了:
protected start = async () => {
......
// [this.player[0] 的选择, this.player[1] 的选择]。当两个玩家都没有选择时,假定双方的选择都为 -1
const choices = [-1, -1];
this.masterClient.on(PlayEvent.CUSTOM_EVENT, ({ eventId, eventData, senderId }) => {
if (eventId === Event.Play) {
// 收到其他玩家的事件,转发事件
......
// 存储当前玩家的选择
if (this.players[0].actorId === senderId) {
// 如果是 player[0] 就存储到 choices[0]中
choices[0] = eventData.index;
} else {
// 如果是 player[1] 就存储到 choices[1]中
choices[1] = eventData.index;
}
}
});
}
在上面这段代码中,我们构造了一个 Array 类型的 choice 来存储玩家的选择,当收到出拳事件时会将用户的选择存储起来,接下来我们判断两个玩家是不是都做出选择了,如果做出选择了则广播游戏结果,并广播游戏结束:
enum Event {
Start = 10,
Play = 11,
Over = 20,
};
......
protected start = async () => {
......
// [this.player[0] 的选择, this.player[1] 的选择]。当两个玩家都没有选择时,假定双方的选择都为 -1
const choices = [-1, -1];
this.masterClient.on(PlayEvent.CUSTOM_EVENT, ({ eventId, eventData, senderId }) => {
if (eventId === Event.Play) {
// 收到其他玩家的事件,转发事件
......
// 存储当前玩家的选择
......
// 检查两个玩家是不是都已经做出选择了
if (choices.every((choice) => choice > 0)) {
// 两个玩家都已经做出选择,游戏结束,向客户端广播游戏结果
const winner = this.getWinner(choices);
this.broadcast(Event.Over, {
choices,
winnerId: winner ? winner.userId : null,
});
}
}
});
}
在上面的代码中,使用了 getWinner()
方法来获取游戏结果,这个是我们自定义的判断胜负的方法,您可以直接复制粘贴下方的代码到自己的 RPSGame
文件中:
// 客户端的出拳数组为:[✊, ✌️, ✋],
// 出 ✊ (index 为 0 )时,赢 ✌️(index 为 1),因此 wins[0] = 1,以此类推
const wins = [1, 2, 0];
@watchRoomFull()
export default class RPSGame extends Game {
......
/**
* 根据玩家的选择计算赢家
* @return 返回胜利的 Player,或者 null 表示平局
*/
private getWinner([player1Choice, player2Choice]: number[]) {
if (player1Choice === player2Choice) { return null; }
if (wins[player1Choice] === player2Choice) { return this.players[0]; }
return this.players[1];
}
}
客户端在收到 MasterClient 广播结束事件后在界面上做相应的结果展示。到这里基础逻辑都已经开发完成,您可以运行项目,打开两个页面,愉快的开始自己和自己的对战了。
离开房间
当所有客户端离开房间后,GameManager
会帮助我们把空房间销毁,因此在我们这个小游戏的代码中不需要再额外写这部分的代码。
RxJS
当您查看示例 Demo 时,会发现代码和本文档中的代码相比更精简一些,原因是示例 Demo 中使用了 RxJS。如果您感兴趣,可以自行研究 RxJS 及相关接口的 API 文档。
开发指南
当您按照本文档的说明一步一步开发出猜拳小游戏后,对 Client Engine SDK 和初始项目一定有了初步的感受,接下来您可以参考 Client Engine 开发指南更深入的了解整体结构及用法。