Skip to main content

Client Engine 开发指南 · Node.js

请先阅读 Client Engine 快速入门 · Node.js你的第一个 Client Engine 小游戏来初步了解如何使用初始项目来开发游戏。本文档将在初始项目的基础上深入讲解 Client Engine SDK。

Client Engine 初始项目依赖了专门的 Client Engine SDK, Client Engine SDK 在多人在线对战 SDK 的基础上进行了封装,帮助您更好的撰写服务端游戏逻辑。您可以通过快速入门安装依赖。

组件

SDK 提供以下组件:

  • Game负责房间内游戏的具体逻辑。Client Engine 维护了许多游戏房间,每一个游戏房间都是一个 Game 实例,即每个 Game 实例对应一个唯一的 Play Room 与 MasterClient。游戏房间内的逻辑由 Game 中的代码来控制,因此房间内的游戏逻辑必须继承自该类
  • GameManager负责创建、管理及分配具体的 Game 对象。Game 的管理及销毁由 SDK 负责,不需要您再自己额外写代码。

GameManager

GameManager 实例化

GameManager 会帮您自动创建、管理并销毁 Game,因此在项目启动时,您需要实例化 GameManager。相关示例代码如下:

自定义 GameManager

首先需要自定义一个 Class 继承自 GameManager,例如示例代码中的 SampleGameManager

import { Game, GameManager, ICreateGameOptions } from "@leancloud/client-engine";
export default class SampleGameManager<T extends Game> extends GameManager<T> {

}
在 GameManager 中自定义方法

Client Engine 的核心用法之一是负责创建 Game 并返回 roomName 给客户端,因此在 SampleGameManager 这个类中,我们需要撰写创建 Game 的方法提供给 Web API 使用。例如示例项目中的快速开始创建新游戏。这里的示例代码我们以创建新游戏为例:

import { Game, GameManager, ICreateGameOptions } from "@leancloud/client-engine";
export default class SampleGameManager<T extends Game> extends GameManager<T> {
/**
* 创建一个新的游戏。
* @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 子类中的 public 方法,其参数与返回值必须是 stringnumberbooleannullObjectArray 中的一种。以上代码中可以看到 GameManagercreateGame() 方法返回的是一个 Game,不符合负载均衡的要求,因此我们在这里封装为自己的方法 createGameAndGetName()

创建 GameManager 子类对象

接下来创建 GameManager 的子类对象,在创建 SampleGameManager 的时候,需要在第一个参数内传入[自定义的 Game ](#实现自己的 Game),这里使用的是示例 Demo 猜拳游戏中的 RPSGame

import PRSGame from "./rps-game";
const gameManager = new SampleGameManager(
gameConstructor: PRSGame,
appId: {{appid}},
appKey: {{appkey}},
playServer: "https://please-replace-with-your-customized.domain.com",
concurrency: 2,
);
设置负载均衡

GameManager 需要配置负载均衡,以确保 GameManager 创建的 Game 能够尽可能均匀分配到每一个 Client Engine 实例中。负载均衡的详细文档请看下文,在这里,我们先讲解如何配置。

在这里我们创建一个负载均衡对象,然后将上面的 gameManager 绑定到负载均衡中:

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 配置负载均衡。
const loadBalancer = loadBalancerFactory.bind(gameManager, ["createGameAndGetName"]);

loadBalancerFactorybind() 方法中,第一个参数是 gameManager 对象,第二个参数是一个数组,传入需要进行负载均衡的方法名 ["createGameAndGetName"]

到这里,gameManager 的配置就完成了,您可以在自己定义的 Web API 处这样调用相关方法: gameManager.createGameAndGetName()

创建房间

GameManager 实例化这一节中,我们在子类中使用了 GameManagercreateGame() 来创建房间。

createGame() 接受以下参数:

  • playerId:发起请求的客户端在多人对战服务中的 userId
  • createGameOptions(可选):创建指定条件的房间。
    • roomName(可选):创建指定 roomName 的房间。例如您需要和好友一起玩时,可以用这个接口创建房间后,把 roomName 分享给好友。如果您不关心 roomName,可以不指定这个参数。
    • roomOptions(可选):通过这个参数,客户端在请求 Client Engine 创建房间时,可以设置 customRoomPropertiescustomRoomPropertyKeysForLobbyvisible,对这三个参数的说明请参考创建房间
    • seatCount(可选):创建房间时,指定本次游戏需要多少人,这个值需要在设置房间内玩家数量minSeatCountmaxSeatCount 之间,否则 Client Engine 会拒绝创建房间。如果不指定,则以 defaultSeatCount 为准。

例如创建一个带有匹配条件的新房间时,可以这样调用 createGame()

// 您可以从客户端发来的请求中获得 playerId 和 createGameOptions
const props = {
level: 2,
};

const roomOptions = {
customRoomPropertyKeysForLobby: ['level'],
customRoomProperties: props,
};

const createGameOptions = {
roomOptions
};

gameManager.createGame(playerId, createGameOptions);

你的第一个 Client Engine 小游戏中,reception.ts 中使用 createGame() 撰写了两个自定义方法用于「快速开始」和「创建新游戏」,并通过 index.ts 中的 Web API /reservation/game 来调用相关逻辑,如果没有自定义需求您可以直接使用示例 Demo 中的接口并传入以上参数。

获取当前可用的房间

GameManager 提供了 getAvailableGames() 方法来获取当前 GameManager 对象所在的 Client Engine 实例中的可用游戏列表。这里的可用指的是房间还有空位。使用示例代码如下:

var games = gameManager.getAvailableGames();

需要注意的是,这个方法获取的不是多人对战服务中所有的可用房间,仅限于当前所在 Client Engine 实例中的可用房间,Client Engine 多实例负载均衡请参考负载均衡

匹配

GameManager 暂时没有提供匹配机制,如果客户端只需要随机加入某个房间,可以参考示例项目中「快速开始」的实现方案。这个实现方案会在负载最低的实例中寻找可用房间或创建房间,最终返回给客户端一个可加入的房间名称。

如果您希望实现有条件的匹配,可以这样实现:

  1. 客户端向多人对战服务请求有条件的匹配,如果有空余的房间,则会触发加入成功事件。
  2. 如果的多人对战服务此时没有空余的房间,客户端会收到「加入房间失败」事件,在这个事件中,发现错误码是 4301,则向 Client Engine 请求创建房间。
  3. Client Engine 收到请求后创建房间并返回 roomName 给客户端。这一部分的逻辑可以使用示例项目中的 /game 入口。
  4. 客户端拿到 Client Engine 返回的 roomName 后加入房间,等待其他人匹配加入。

这个流程在客户端中的示例代码如下(非 Client Engine):

客户端首先向多人对战服务发起有条件的加入房间请求:

const matchProps = {level: 2};
play.joinRandomRoom({matchProperties: matchProps});

如果多人对战服务此时有可以加入的新房间,您会自动加入到新房间中,并触发加入房间成功事件:

play.on(Event.ROOM_JOINED, () => {
// TODO 可以做跳转场景之类的操作
});

如果没有可以加入的房间,会触发加入房间失败事件。在这个事件中,4301 错误码代表着没有可以加入的空房间,此时我们向 Client Engine 请求创建一个新的房间,获得新房间的 roomName 后加入新房间:

// 加入房间失败后请求 Client Engine 创建房间
play.on(Event.ROOM_JOIN_FAILED, (error) => {
if (error.code === 4301) {
// 设置创建带有匹配属性的房间
const props = {level: 2};
const options = {customRoomPropertyKeysForLobby: ['level']};
// 这里通过 HTTP 调用在 Client Engine 中实现的 `/game` 接口
const { roomName } = await (await fetch(
`${CLIENT_ENGINE_SERVER}/game`,
{
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
playerId: play.userId,
options
})
}
)).json();
// 加入房间
return play.joinRoom(roomName);
} else {
console.log(error);
}
});

Game

Game 生命周期

  1. 创建: Game 由 SDK 中的 GameManager 管理,GameManager 会在收到创建房间的请求时根据情况创建 Game
  2. 运行: 创建后,Game 的控制权从 SDK 中 GameManager 移交给 Game 本身。从这个时刻开始,会有玩家陆续加入游戏房间。
  3. 销毁: 所有玩家离开房间后,意味着游戏结束,Game 将控制权交回 GameManagerGameManager 做最后的清理工作,包括断开并销毁该房间的 masterClient、将 Game 从管理的游戏列表中删除等。

Game 通用属性

在实现游戏逻辑的过程中,Game 类提供下面这些属性来简化常见需求的实现,您可以在继承 Game 的自己的类中方便的获得以下属性:

  • room 属性:游戏对应的房间,这是一个 Play SDK 中的 Room 实例。
  • masterClient 属性:游戏对应的 masterClient,这是一个 Play SDK 中的 Play 实例。
  • players 属性:不包含 masterClient 的玩家列表。注意,如果您通过 Play SDK Room 实例的 playerList 属性获取的房间成员列表是包括 masterClient 的。

Game 通用方法

Game 类在多人对战 SDK 的基础上封装了以下方法,使得 MasterClient 可以更便利的发送自定义事件:

  • broadcast() 方法:向所有玩家广播自定义事件。示例代码请参考广播自定义事件
  • forwardToTheRests() 方法:将一个玩家发送的自定义事件转发给其他玩家。示例代码请参考转发自定义事件

实现自己的 Game

实现自己的房间内游戏逻辑时,您需要创建一个继承自 Game 的类来撰写自己的游戏逻辑,示例方法如下:

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

设置房间内玩家数量

这里的玩家数量指的是不包括 MasterClient 的玩家数量,根据多人对战服务的限制,最多不能超过 9 个人。

Game 中需要指定 defaultSeatCount 静态属性作为默认的玩家数量,Client Engine 会根据这个值向多人对战服务请求创建房间。例如斗地主需要 3 个人才能玩,可以这样设置:

export default class SampleGame extends Game {
public static defaultSeatCount = 3; // 最大不能超过 9
}

如果您的游戏需要的玩家数量在某个范围内,除了设置 defaultSeatCount 外,还需要使用 minSeatCount 静态属性限定最小玩家数量,maxSeatCount 静态属性设定最大玩家数量。例如三国杀要求至少 2 个人,最多 8 个人才能玩,默认 5 个人可以玩,可以这样设置:

export default class SampleGame extends Game {
public static minSeatCount = 2;
public static maxSeatCount = 8; // 最大不能超过 9
public static defaultSeatCount = 5;
}

创建房间的接口中,可以将客户端 request 请求中的 seatCount 参数来动态覆盖掉 defaultSeatCount

当房间人数达到 seatCount 时,您可以选择配置触发房间人满事件,如果您的客户端没有指定 seatCount,人满事件时将以 defaultSeatCount 的值为准。

加入房间事件

当客户端成功加入房间后,位于 Client Engine 的 MasterClient 会收到新玩家加入事件,如果您需要监听此事件,可以在自定义的 Game 中的 constructor() 方法中撰写监听的代码:

import { Game } from "@leancloud/client-engine";
export default class SampleGame extends Game {
constructor(room: Room, masterClient: Play) {
super(room, masterClient);
this.masterClient.on(Event.PLAYER_ROOM_JOINED, () => {
console.log('有人来了');
});
}
}

房间人满事件

当房间的人数满足设置房间内玩家数量的人满逻辑时,watchRoomFull 装饰器会让您收到 Game 抛出的 AutomaticGameEvent.ROOM_FULL 事件,您可以在这个事件中撰写相应的游戏逻辑,例如关闭房间,向客户端广播游戏开始:

import { AutomaticGameEvent, Game, watchRoomFull } from "@leancloud/client-engine";

enum Event {
GameStart = 15,
};

@watchRoomFull()
export default class SampleGame extends Game {
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.GameStart);
}
}

广播自定义事件

房间人满事件中,Game 向房间内所有成员广播了游戏开始:

enum Event {
GameStart = 15,
};
this.broadcast(Event.GameStart);

在广播事件时您还可以带有一些数据:

enum Event {
GameStart = 15,
};
const gameData = {someGameData};
this.broadcast(Event.GameStart, gameData);

此时客户端的接收自定义事件方法会被触发,如果发现是 game-start 事件,客户端可以在 UI 上展示对战开始。

转发自定义事件

MasterClient 可以转发某个客户端发来的事件给其他客户端,在转发时还可以处理数据:

enum Event {
SomeEvent = 15,
};
this.forwardToTheRests(event, (eventData) => {
// 准备要转发的数据
const actUserId = event.senderId;
const result = {actUserId};
return result;
// Event.SomeEvent 是自定义事件的 ID,如果省略则使用原 event 事件的 ID
}, Event.SomeEvent)

在这个代码中,event 参数是某个客户端发来的原始事件,eventData 是原始事件的数据,您可以在转发事件给其他客户端时处理该数据,例如抹去或增加一些信息。MasterClient 发送该事件后,客户端的接收自定义事件会被触发。

MasterClient 与客户端通信

除了上方初始项目提供的广播自定义事件转发自定义事件外,您依然可以使用多人对战服务中的自定义属性自定义事件进行通信。

除此之外,Game 还提供了以下 RxJS 方法方便您对事件进行流处理,进而精简自己的代码及逻辑:

  • getStream() 方法:获取玩家发送的自定义事件的流,这是一个 RxJS 中的 Observable 对象。接口说明请参考 API 文档
  • takeFirst() 方法:获取玩家发送的指定条件的从现在开始算的第一条自定义事件的流,返回一个 RxJS 中的 Observable 对象。接口说明请参考 API 文档

注意,以上两个方法需要您了解 RxJS 才能使用,如果您不了解 RxJS,依然可以使用多人对战服务中的事件方法进行通信。

游戏结束

当所有玩家都离开后,GameManager 会自动帮您销毁当前房间及相关的 MasterClient。此时如果您没有其他的逻辑要做,则不需要关心本节文档。如果您希望自己做一些清理工作,例如保存用户数据等,可以使用 autoDestroy 装饰器,这个装饰器会在所有玩家离开后自动触发 Game 子类中的 destroy() 方法,您可以将相关逻辑写在这个方法中。

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

@autoDestroy()
export default class SampleGame extends Game {
protected destroy() {
super.destroy();
console.log('在这里可以做额外的清理工作');
}
}

负载均衡

Client Engine 会根据整体实例负载的高低自动对实例数量进行调整。

在 Client Engine 中,需要负载均衡的情况有两种:第一种是客户端通过 REST API 发起的请求,第二种是每一个实例运行的 Game 数量的负载。对于客户端通过 REST API 发起的请求,Client Engine 会自动将请求均匀的分配给当前的所有实例,不需要我们再做任何配置工作。对于第二种情况,每个 Game 对象(每局游戏)一般都会持续存在一段时间,为了让每个实例承载的 Game 对象尽可能达到均衡,我们需要额外配置 GameManager 到负载均衡系统中。

这个特性由 SDK 提供的 LoadBalancerFactory 类实现。在 [GameManager 实例化](#GameManager 实例化)中我们可以看到,LoadBalancerFactory 通过绑定 gameManager 生成一个 LoadBalancer 的对象,每一个 Client Engine 实例中都会有这样一个对象。

当 Client Engine 的某个实例接收到来自客户端的 REST API 请求,并调用 gameManager 中的方法时,接收请求的实例中的负载均衡节点 LoadBalancer 会找出集群中承载 Game 数量最小的实例,将指定的 gameManager 的 API 调用转发给该实例的 gameManager 运行并将结果返回。在这里,LoadBalancer 只负责请求的转发,不关心如何处理请求。

API 文档

您可以在 API 文档中找到更多 SDK 的类、方法及属性说明,点击查看 Client Engine SDK API 文档