ColyseusJS 轻量级多人游戏服务器开发框架 - 中文手册(下)

ColyseusJS 轻量级多人游戏服务器开发框架 - 中文手册(下)
快速上手多人游戏服务器开发。后续会基于 Google Agones,更新相关 K8S 运维、大规模快速扩展专用游戏服务器的文章。拥抱??原生?? Cloud-Native!

系列

状态处理

Colyseus 中,room handlers有状态(stateful) 的。每个房间都有自己的状态。状态的突变会自动同步到所有连接的客户端。

序列化方法

  • Schema (default)

状态同步时

  • user 成功加入 room 后,他将从服务器接收到完整状态。
  • 在每个 patchRate 处,状态的二进制补丁会发送到每个客户端(默认值为50ms
  • 从服务器接收到每个补丁后,在客户端调用 onStateChange
  • 每种序列化方法都有自己处理传入状态补丁的特殊方式。

Schema

SchemaSerializer 是从 Colyseus 0.10 开始引入的,它是默认的序列化方法。

Schema 结构只用于房间的状态(可同步数据)。对于不能同步的算法中的数据,您需要使用 Schema 及其其他结构。

服务端

要使用 SchemaSerializer,你必须:

  • 有一个扩展 Schema 类的状态类
  • @type() 装饰器注释你所有的可同步属性
  • 为您的房间实例化状态(this.setState(new MyState()))
import { Schema, type } from "@colyseus/schema";

class MyState extends Schema {
    @type("string")
    currentTurn: string;
}

原始类型

这些是您可以为 @type() 装饰器提供的类型及其限制。

如果您确切地知道 number 属性的范围,您可以通过为其提供正确的原始类型来优化序列化。
否则,请使用 "number",它将在序列化过程中添加一个额外的字节来标识自己。

Type Description Limitation
"string" utf8 strings maximum byte size of 4294967295
"number" auto-detects the int or float type to be used. (adds an extra byte on output) 0 to 18446744073709551615
"boolean" true or false 0 or 1
"int8" signed 8-bit integer -128 to 127
"uint8" unsigned 8-bit integer 0 to 255
"int16" signed 16-bit integer -32768 to 32767
"uint16" unsigned 16-bit integer 0 to 65535
"int32" signed 32-bit integer -2147483648 to 2147483647
"uint32" unsigned 32-bit integer 0 to 4294967295
"int64" signed 64-bit integer -9223372036854775808 to 9223372036854775807
"uint64" unsigned 64-bit integer 0 to 18446744073709551615
"float32" single-precision floating-point number -3.40282347e+38 to 3.40282347e+38
"float64" double-precision floating-point number -1.7976931348623157e+308 to 1.7976931348623157e+308

子 schema 属性

您可以在 "root" 状态定义中定义更多自定义数据类型,如直接引用(direct reference)、映射(map)或数组(array)。

import { Schema, type } from "@colyseus/schema";

class World extends Schema {
    @type("number")
    width: number;

    @type("number")
    height: number;

    @type("number")
    items: number = 10;
}

class MyState extends Schema {
    @type(World)
    world: World = new World();
}

ArraySchema

ArraySchema 是内置 JavaScript Array 类型的可同步版本。

可以从数组中使用更多的方法。看看数组的 MDN 文档

示例:自定义 Schema 类型的数组

import { Schema, ArraySchema, type } from "@colyseus/schema";

class Block extends Schema {
    @type("number")
    x: number;

    @type("number")
    y: number;
}

class MyState extends Schema {
    @type([ Block ])
    blocks = new ArraySchema<Block>();
}

示例:基本类型的数组

您不能在数组内混合类型。

import { Schema, ArraySchema, type } from "@colyseus/schema";

class MyState extends Schema {
    @type([ "string" ])
    animals = new ArraySchema<string>();
}

array.push()

在数组的末尾添加一个或多个元素,并返回该数组的新长度。

const animals = new ArraySchema<string>();
animals.push("pigs", "goats");
animals.push("sheeps");
animals.push("cows");
// output: 4

array.pop()

从数组中删除最后一个元素并返回该元素。此方法更改数组的长度。

animals.pop();
// output: "cows"

animals.length
// output: 3

array.shift()

从数组中删除第一个元素并返回被删除的元素。这个方法改变数组的长度。

animals.shift();
// output: "pigs"

animals.length
// output: 2

array.unshift()

将一个或多个元素添加到数组的开头,并返回数组的新长度。

animals.unshift("pigeon");
// output: 3

array.indexOf()

返回给定元素在数组中的第一个下标,如果不存在则返回 -1

const itemIndex = animals.indexOf("sheeps");

array.splice()

通过删除或替换现有元素和/或在适当位置添加新元素来更改数组的内容。

// find the index of the item you‘d like to remove
const itemIndex = animals.findIndex((animal) => animal === "sheeps");

// remove it!
animals.splice(itemIndex, 1);

array.forEach()

迭代数组中的每个元素。

this.state.array1 = new ArraySchema<string>(‘a‘, ‘b‘, ‘c‘);

this.state.array1.forEach(element => {
    console.log(element);
});
// output: "a"
// output: "b"
// output: "c"

MapSchema

MapSchema 是内置 JavaScript Map 类型的一个可同步版本。

建议使用 MapsID 跟踪您的游戏实体(entities),例如玩家(players),敌人(enemies)等。

"目前仅支持字符串 key":目前,MapSchema 只允许您提供值类型。key 类型总是 string

import { Schema, MapSchema, type } from "@colyseus/schema";

class Player extends Schema {
    @type("number")
    x: number;

    @type("number")
    y: number;
}

class MyState extends Schema {
    @type({ map: Player })
    players = new MapSchema<Player>();
}

map.get()

通过 key 获取一个 map 条目:

const map = new MapSchema<string>();
const item = map.get("key");

OR

//
// NOT RECOMMENDED
//
// This is a compatibility layer with previous versions of @colyseus/schema
// This is going to be deprecated in the future.
//
const item = map["key"];

map.set()

key 设置 map 项:

const map = new MapSchema<string>();
map.set("key", "value");

OR

//
// NOT RECOMMENDED
//
// This is a compatibility layer with previous versions of @colyseus/schema
// This is going to be deprecated in the future.
//
map["key"] = "value";

map.delete()

key 删除一个 map 项:

map.delete("key");

OR

//
// NOT RECOMMENDED
//
// This is a compatibility layer with previous versions of @colyseus/schema
// This is going to be deprecated in the future.
//
delete map["key"];

map.size

返回 MapSchema 对象中的元素数量。

const map = new MapSchema<number>();
map.set("one", 1);
map.set("two", 2);

console.log(map.size);
// output: 2

map.forEach()

按插入顺序遍历 map 的每个 key/value 对。

this.state.players.forEach((value, key) => {
    console.log("key =>", key)
    console.log("value =>", value)
});

"所有 Map 方法":您可以从 Maps 中使用更多的方法。看一看 MDN 文档的 Maps

CollectionSchema

"CollectionSchema 仅用 JavaScript 实现":目前为止,CollectionSchema 只能用于 JavaScript。目前还不支持 Haxec#LUAc++ 客户端。

CollectionSchemaArraySchema 的工作方式相似,但需要注意的是您无法控制其索引。

import { Schema, CollectionSchema, type } from "@colyseus/schema";

class Item extends Schema {
    @type("number")
    damage: number;
}

class Player extends Schema {
    @type({ collection: Item })
    items = new CollectionSchema<Item>();
}

collection.add()

item 追加到 CollectionSchema 对象。

const collection = new CollectionSchema<number>();
collection.add(1);
collection.add(2);
collection.add(3);

collection.at()

获取位于指定 index 处的 item

const collection = new CollectionSchema<string>();
collection.add("one");
collection.add("two");
collection.add("three");

collection.at(1);
// output: "two"

collection.delete()

根据 item 的值删除 item

collection.delete("three");

collection.has()

返回一个布尔值,无论该 item 是否存在于 set 中。

if (collection.has("two")) {
    console.log("Exists!");
} else {
    console.log("Does not exist!");
}

collection.size

返回 CollectionSchema 对象中的元素数量。

const collection = new CollectionSchema<number>();
collection.add(10);
collection.add(20);
collection.add(30);

console.log(collection.size);
// output: 3

collection.forEach()

对于 CollectionSchema 对象中的每个 index/value 对,forEach() 方法按插入顺序执行所提供的函数一次。

collection.forEach((value, at) => {
    console.log("at =>", at)
    console.log("value =>", value)
});

SetSchema

"SetSchema 只在 JavaScript 中实现":SetSchema 目前只能在 JavaScript 中使用。目前还不支持 HaxeC#LUAC++ 客户端。

SetSchema 是内置 JavaScript Set 类型的可同步版本。

"更多":你可以从 Sets 中使用更多的方法。看一下 MDN 文档的 Sets

SetSchema 的用法与 [CollectionSchema] 非常相似,最大的区别是 Sets 保持唯一的值。Sets 没有直接访问值的方法。(如collection.at())

import { Schema, SetSchema, type } from "@colyseus/schema";

class Effect extends Schema {
    @type("number")
    radius: number;
}

class Player extends Schema {
    @type({ set: Effect })
    effects = new SetSchema<Effect>();
}

set.add()

SetSchema 对象追加一个 item

const set = new CollectionSchema<number>();
set.add(1);
set.add(2);
set.add(3);

set.at()

获取位于指定 index 处的项。

const set = new CollectionSchema<string>();
set.add("one");
set.add("two");
set.add("three");

set.at(1);
// output: "two"

set.delete()

根据项的值删除项。

set.delete("three");

set.has()

返回一个布尔值,无论该项是否存在于集合中。

if (set.has("two")) {
    console.log("Exists!");
} else {
    console.log("Does not exist!");
}

set.size

返回 SetSchema 对象中的元素数量。

const set = new SetSchema<number>();
set.add(10);
set.add(20);
set.add(30);

console.log(set.size);
// output: 3

过滤每个客户端的数据

"这个特性是实验性的":@filter()/@filterChildren() 是实验性的,可能无法针对快节奏的游戏进行优化。

过滤旨在为特定客户端隐藏状态的某些部分,以避免在玩家决定检查来自网络的数据并查看未过滤状态信息的情况下作弊。

数据过滤器是每个客户端每个字段(或每个子结构,在 @filterChildren 的情况下)都会触发的回调。如果过滤器回调返回 true,字段数据将为该特定客户端发送,否则,数据将不为该客户端发送。

请注意,如果过滤函数的依赖关系发生变化,它不会自动重新运行,但只有在过滤字段(或其子字段)被更新时才会重新运行。请参阅此问题以了解解决方法。

@filter() property decorator

@filter() 属性装饰器可以用来过滤掉整个 Schema 字段。

下面是 @filter() 签名的样子:

class State extends Schema {
    @filter(function(client, value, root) {
        // client is:
        //
        // the current client that‘s going to receive this data. you may use its
        // client.sessionId, or other information to decide whether this value is
        // going to be synched or not.

        // value is:
        // the value of the field @filter() is being applied to

        // root is:
        // the root instance of your room state. you may use it to access other
        // structures in the process of decision whether this value is going to be
        // synched or not.
    })
    @type("string") field: string;
}

@filterChildren() 属性装饰器

@filterChildren() 属性装饰器可以用来过滤出 arraysmapssets 等内部的项。它的签名与 @filter() 非常相似,只是在 value 之前增加了 key 参数 — 表示 ArraySchemaMapSchemaCollectionSchema 等中的每一项。

class State extends Schema {
    @filterChildren(function(client, key, value, root) {
        // client is:
        //
        // the current client that‘s going to receive this data. you may use its
        // client.sessionId, or other information to decide whether this value is
        // going to be synched or not.

        // key is:
        // the key of the current value inside the structure

        // value is:
        // the current value inside the structure

        // root is:
        // the root instance of your room state. you may use it to access other
        // structures in the process of decision whether this value is going to be
        // synched or not.
    })
    @type([Cards]) cards = new ArraySchema<Card>();
}

例子: 在一场纸牌游戏中,每张纸牌的相关资料只应供纸牌拥有者使用,或在某些情况下(例如纸牌已被丢弃)才可使用。

查看 @filter() 回调签名:

import { Client } from "colyseus";

class Card extends Schema {
    @type("string") owner: string; // contains the sessionId of Card owner
    @type("boolean") discarded: boolean = false;

    /**
     * DO NOT USE ARROW FUNCTION INSIDE `@filter`
     * (IT WILL FORCE A DIFFERENT `this` SCOPE)
     */
    @filter(function(
        this: Card, // the instance of the class `@filter` has been defined (instance of `Card`)
        client: Client, // the Room‘s `client` instance which this data is going to be filtered to
        value: Card[‘number‘], // the value of the field to be filtered. (value of `number` field)
        root: Schema // the root state Schema instance
    ) {
        return this.discarded || this.owner === client.sessionId;
    })
    @type("uint8") number: number;
}

向后/向前兼容性

向后/向前兼容性可以通过在现有结构的末尾声明新的字段来实现,以前的声明不被删除,但在需要时被标记为 @deprecated()

这对于原生编译的目标特别有用,比如 C#, C++, Haxe 等 — 在这些目标中,客户端可能没有最新版本的 schema 定义。

限制和最佳实践

  • 每个 Schema 结构最多可以容纳 64 个字段。如果需要更多字段,请使用嵌套的 Schema 结构。
  • NaNnull 数字被编码为 0
  • null 字符串被编码为 ""
  • Infinity 被编码为 Number.MAX_SAFE_INTEGER 的数字。
  • 不支持多维数组。了解如何将一维数组用作多维数组
  • ArraysMaps 中的项必须都是同一类型的实例。
  • @colyseus/schema 只按照指定的顺序编码字段值。
    • encoder(服务器)和decoder(客户端)必须有相同的 schema 定义。
    • 字段的顺序必须相同。

客户端

Callbacks

您可以在客户端 schema 结构中使用以下回调来处理来自服务器端的更改。

  • onAdd (instance, key)
  • onRemove (instance, key)
  • onChange (changes) (on Schema instance)
  • onChange (instance, key) (on collections: MapSchema, ArraySchema, etc.)
  • listen()

"C#, C++, Haxe":当使用静态类型语言时,需要根据 TypeScript schema 定义生成客户端 schema 文件。参见在客户端生成 schema

onAdd (instance, key)

onAdd 回调只能在 maps (MapSchema)和数组(ArraySchema)中使用。调用 onAdd 回调函数时,会使用添加的实例及其 holder 对象上的 key 作为参数。

room.state.players.onAdd = (player, key) => {
    console.log(player, "has been added at", key);

    // add your player entity to the game world!

    // If you want to track changes on a child object inside a map, this is a common pattern:
    player.onChange = function(changes) {
        changes.forEach(change => {
            console.log(change.field);
            console.log(change.value);
            console.log(change.previousValue);
        })
    };

    // force "onChange" to be called immediatelly
    player.triggerAll();
};

onRemove (instance, key)

onRemove 回调只能在 maps (MapSchema) 和 arrays (ArraySchema) 中使用。调用 onRemove 回调函数时,会使用被删除的实例及其 holder 对象上的 key 作为参数。

room.state.players.onRemove = (player, key) => {
    console.log(player, "has been removed at", key);

    // remove your player entity from the game world!
};

onChange (changes: DataChange[])

onChange 对于直接 Schema 引用和集合结构的工作方式不同。关于集合结构 (array,map 等)的 onChange,请点击这里

您可以注册 onChange 来跟踪 Schema 实例的属性更改。onChange 回调是由一组更改过的属性以及之前的值触发的。

room.state.onChange = (changes) => {
    changes.forEach(change => {
        console.log(change.field);
        console.log(change.value);
        console.log(change.previousValue);
    });
};

你不能在未与客户端同步的对象上注册 onChange 回调。


onChange (instance, key)

onChange 对于直接 Schema 引用和 collection structures 的工作方式不同。

每当 primitive 类型(string, number, boolean等)的集合更新它的一些值时,这个回调就会被触发。

room.state.players.onChange = (player, key) => {
    console.log(player, "have changes at", key);
};

如果您希望检测 non-primitive 类型(包含 Schema 实例)集合中的更改,请使用onAdd 并在它们上注册 onChange

"onChangeonAddonRemoveexclusive(独占) 的":
onAddonRemove 期间不会触发 onChange 回调。

如果在这些步骤中还需要检测更改,请考虑注册 `onAdd` 和 `onRemove`。

.listen(prop, callback)

监听单个属性更改。

.listen() 目前只适用于 JavaScript/TypeScript

参数:

  • property: 您想要监听更改的属性名。
  • callback: 当 property 改变时将被触发的回调。
state.listen("currentTurn", (currentValue, previousValue) => {
    console.log(`currentTurn is now ${currentValue}`);
    console.log(`previous value was: ${previousValue}`);
});

.listen() 方法返回一个用于注销监听器的函数:

const removeListener = state.listen("currentTurn", (currentValue, previousValue) => {
    // ...
});

// later on, if you don‘t need the listener anymore, you can call `removeListener()` to stop listening for `"currentTurn"` changes.
removeListener();

listenonChange 的区别是什么?

.listen() 方法是单个属性上的 onChange 的简写。下面是

state.onChange = function(changes) {
    changes.forEach((change) => {
        if (change.field === "currentTurn") {
            console.log(`currentTurn is now ${change.value}`);
            console.log(`previous value was: ${change.previousValue}`);
        }
    })
}

客户端 schema 生成

这只适用于使用静态类型语言(如 C#、C++ 或 Haxe)的情况。

在服务器项目中,可以运行 npx schema-codegen 自动生成客户端 schema 文件。

npx schema-codegen --help

输出:

schema-codegen [path/to/Schema.ts]

Usage (C#/Unity)
    schema-codegen src/Schema.ts --output client-side/ --csharp --namespace MyGame.Schema

Valid options:
    --output: fhe output directory for generated client-side schema files
    --csharp: generate for C#/Unity
    --cpp: generate for C++
    --haxe: generate for Haxe
    --ts: generate for TypeScript
    --js: generate for JavaScript
    --java: generate for Java

Optional:
    --namespace: generate namespace on output code

Built-in room ? Lobby Room

"大厅房间的客户端 API 将在 Colyseus 1.0.0 上更改":

  • 内置的大厅房间目前依赖于发送消息来通知客户可用的房间。当 @filter() 变得稳定时,LobbyRoom 将使用 state 代替。

服务器端

内置的 LobbyRoom 将自动通知其连接的客户端,每当房间 "realtime listing" 有更新。

import { LobbyRoom } from "colyseus";

// Expose the "lobby" room.
gameServer
  .define("lobby", LobbyRoom);

// Expose your game room with realtime listing enabled.
gameServer
  .define("your_game", YourGameRoom)
  .enableRealtimeListing();

onCreate()onJoin()onLeave()onDispose() 期间,会自动通知 LobbyRoom

如果你已经更新了你房间的metadata,并且需要触发一个 lobby 的更新,你可以在元数据更新之后调用 updateLobby()

import { Room, updateLobby } from "colyseus";

class YourGameRoom extends Room {

  onCreate() {

    //
    // This is just a demonstration
    // on how to call `updateLobby` from your Room
    //
    this.clock.setTimeout(() => {

      this.setMetadata({
        customData: "Hello world!"
      }).then(() => updateLobby(this));

    }, 5000);

  }

}

客户端

您需要通过从 LobbyRoom 发送给客户端的信息来跟踪正在添加、删除和更新的房间。

import { Client, RoomAvailable } from "colyseus.js";

const client = new Client("ws://localhost:2567");
const lobby = await client.joinOrCreate("lobby");

let allRooms: RoomAvailable[] = [];

lobby.onMessage("rooms", (rooms) => {
  allRooms = rooms;
});

lobby.onMessage("+", ([roomId, room]) => {
  const roomIndex = allRooms.findIndex((room) => room.roomId === roomId);
  if (roomIndex !== -1) {
    allRooms[roomIndex] = room;

  } else {
    allRooms.push(room);
  }
});

lobby.onMessage("-", (roomId) => {
  allRooms = allRooms.filter((room) => room.roomId !== roomId);
});

Built-in room ? Relay Room

内置的 RelayRoom 对于简单的用例非常有用,在这些用例中,除了连接到它的客户端之外,您不需要在服务器端保存任何状态。

通过简单地中继消息(将消息从客户端转发给其他所有人) — 服务器端不能验证任何消息 — 客户端应该执行验证。

RelayRoom 的源代码非常简单。一般的建议是在您认为合适的时候使用服务器端验证来实现您自己的版本。

服务器端

import { RelayRoom } from "colyseus";

// Expose your relayed room
gameServer.define("your_relayed_room", RelayRoom, {
  maxClients: 4,
  allowReconnectionTime: 120
});

客户端

请参阅如何注册来自 relayed room 的玩家加入、离开、发送和接收消息的回调。

连接到房间

import { Client } from "colyseus.js";

const client = new Client("ws://localhost:2567");

//
// Join the relayed room
//
const relay = await client.joinOrCreate("your_relayed_room", {
  name: "This is my name!"
});

在玩家加入和离开时注册回调

//
// Detect when a player joined the room
//
relay.state.players.onAdd = (player, sessionId) => {
  if (relay.sessionId === sessionId) {
    console.log("It‘s me!", player.name);

  } else {
    console.log("It‘s an opponent", player.name, sessionId);
  }
}

//
// Detect when a player leave the room
//
relay.state.players.onRemove = (player, sessionId) => {
  console.log("Opponent left!", player, sessionId);
}

//
// Detect when the connectivity of a player has changed
// (only available if you provided `allowReconnection: true` in the server-side)
//
relay.state.players.onChange = (player, sessionId) => {
  if (player.connected) {
    console.log("Opponent has reconnected!", player, sessionId);

  } else {
    console.log("Opponent has disconnected!", player, sessionId);
  }
}

发送和接收消息

//
// By sending a message, all other clients will receive it under the same name
// Messages are only sent to other connected clients, never the current one.
//
relay.send("fire", {
  x: 100,
  y: 200
});

//
// Register a callback for messages you‘re interested in from other clients.
//
relay.onMessage("fire", ([sessionId, message]) => {

  //
  // The `sessionId` from who sent the message
  //
  console.log(sessionId, "sent a message!");

  //
  // The actual message sent by the other client
  //
  console.log("fire at", message);
});

Colyseus 的最佳实践

这一部分需要改进和更多的例子!每一段都需要有自己的一页,有详尽的例子和更好的解释。

  • 保持你的 room 类尽可能小,没有游戏逻辑
  • 使可同步的数据结构尽可能小
    • 理想情况下,扩展 Schema 的每个类应该只有字段定义。
    • 自定义 getter 和 setter 方法可以实现,只要它们中没有游戏逻辑。
  • 你的游戏逻辑应该由其他结构来处理,例如:

为什么?

  • Models (@colyseus/schema) 应该只包含数据,不包含游戏逻辑。
  • Rooms 应该有尽可能少的代码,并将动作转发给其他结构

命令模式有几个优点,例如:

  • 它将调用该操作的类与知道如何执行该操作的对象解耦。
  • 它允许你通过提供一个队列系统来创建一个命令序列。
  • 实现扩展来添加一个新的命令很容易,可以在不改变现有代码的情况下完成。
  • 严格控制命令的调用方式和调用时间。
  • 由于命令简化了代码,因此代码更易于使用、理解和测试。

用法

安装

npm install --save @colyseus/command

在您的 room 实现中初始化 dispatcher

import { Room } from "colyseus";
import { Dispatcher } from "@colyseus/command";

import { OnJoinCommand } from "./OnJoinCommand";

class MyRoom extends Room<YourState> {
  dispatcher = new Dispatcher(this);

  onCreate() {
    this.setState(new YourState());
  }

  onJoin(client, options) {
    this.dispatcher.dispatch(new OnJoinCommand(), {
        sessionId: client.sessionId
    });
  }

  onDispose() {
    this.dispatcher.stop();
  }
}
const colyseus = require("colyseus");
const command = require("@colyseus/command");

const OnJoinCommand = require("./OnJoinCommand");

class MyRoom extends colyseus.Room {

  onCreate() {
    this.dispatcher = new command.Dispatcher(this);
    this.setState(new YourState());
  }

  onJoin(client, options) {
    this.dispatcher.dispatch(new OnJoinCommand(), {
        sessionId: client.sessionId
    });
  }

  onDispose() {
    this.dispatcher.stop();
  }
}

命令实现的样子:

// OnJoinCommand.ts
import { Command } from "@colyseus/command";

export class OnJoinCommand extends Command<YourState, {
    sessionId: string
}> {

  execute({ sessionId }) {
    this.state.players[sessionId] = new Player();
  }

}
// OnJoinCommand.js
const command = require("@colyseus/command");

exports.OnJoinCommand = class OnJoinCommand extends command.Command {

  execute({ sessionId }) {
    this.state.players[sessionId] = new Player();
  }

}

查看更多

Refs

中文手册同步更新在:

  • https:/colyseus.hacker-linner.com
我是为少
微信:uuhells123
公众号:黑客下午茶
加我微信(互相学习交流),关注公众号(获取更多学习资料~)

ColyseusJS 轻量级多人游戏服务器开发框架 - 中文手册(下)

上一篇:js 时间转时间戳


下一篇:fastjson和jackson