跳到主要内容

使用 Colyseus 实现实时多人联机

选择 Create Game 创建新游戏,点击地面任意位置移动对象。

本教程将介绍​

  • 配置 Colyseus 服务器。
  • 在服务器与客户端之间同步状态。
  • 在客户端与服务器之间交换消息。
  • 匹配:创建、加入和列出可用游戏。

资源​

开始之前​

预备知识​

软件要求​

创建服务器​

我们将在本机运行基础服务器,保存玩家状态,并将变化同步到客户端。

在命令行运行以下指令,创建全新的 Colyseus 服务器:

npm init colyseus-app ./playcanvas-demo-server

运行 npm start,确认服务器可以在本地启动:

cd playcanvas-demo-server
npm start

成功时,命令行输出应类似:

> ts-node-dev --respawn --transpile-only src/index.ts

✅ development.env loaded.
✅ Express initialized
🏟 Your Colyseus App
⚔️ Listening on ws://localhost:2567

引入 Colyseus JavaScript SDK​

现在需要在 PlayCanvas 中添加 Colyseus JavaScript SDK。

可以通过项目设置中的外部脚本引入。

打开 Menu → Settings(菜单 → 设置):

设置

展开 External Scripts,增加 URLs 数量。

CDN 设置

在新增的 URL 字段中填入 Colyseus JavaScript SDK 的 CDN 地址:

https://unpkg.com/colyseus.js@^0.16.0/dist/colyseus.js

这样,PlayCanvas 脚本就能使用 Colyseus JavaScript SDK。

建立客户端与服务器连接​

新建 PlayCanvas 脚本,实例化 Colyseus.Client,具体操作见创建新脚本。

可以将脚本挂到名为 NetworkManager 的新空实体上。

var NetworkManager = pc.createScript('networkManager');

NetworkManager.prototype.initialize = async function () {
//
// instantiate the SDK
// (no connection is established at this point)
//
this.app.colyseus = new Colyseus.Client("ws://localhost:2567");

//
// request to join or create a room "my_room"
// (establish connection with the server)
//
this.room = await this.app.colyseus.joinOrCreate("my_room");
}

这里使用本地端点 ws://localhost:2567。要与其他人在线游玩,需要将服务器部署到公网。原教程也提供了 Glitch 托管示例。

现在点击 Launch 启动项目,客户端会连接服务器,服务器按需创建 my_room 房间。

my_room 是基础 Colyseus 服务器的默认房间标识符,可以并建议在 arena.config.ts 中修改。

服务器日志将显示以下消息,表示客户端成功加入房间:

19U8WkmoK joined!

房间状态与 Schema​

Colyseus 通过 Schema 结构定义共享数据。

Schema 是 Colyseus 的特殊数据类型,可以增量编码自身变化。编码和解码由框架及 SDK 内部完成。

状态同步循环如下:

  1. 服务器将状态变化自动同步到客户端。
  2. 客户端在本地只读 Schema 上注册回调,观察并响应状态变化。
  3. 客户端可以向服务器发送任意消息,服务器决定如何处理,并可能修改状态,然后返回第一步。

回到服务器代码,定义房间状态。

需要管理多个 Player 实例,每个具有 x、y、z 坐标:

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

export class Player extends Schema {
@type("number") x: number;
@type("number") y: number;
@type("number") z: number;
}

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

更多信息见 Schema 结构。

仍在服务器端,修改 onJoin(),每当新连接加入房间时创建 Player 实例。

// MyRoom.ts
// ...
onJoin(client: Client, options: any) {
console.log(client.sessionId, "joined!");

// create Player instance
const player = new Player();

// place Player at a random position
const FLOOR_SIZE = 4;
player.x = -(FLOOR_SIZE/2) + (Math.random() * FLOOR_SIZE);
player.y = 1.031;
player.z = -(FLOOR_SIZE/2) + (Math.random() * FLOOR_SIZE);

// place player in the map of players by its sessionId
// (client.sessionId is unique per connection!)
this.state.players.set(client.sessionId, player);
}
// ...
}

客户端断开连接时,从玩家映射中移除该玩家:

// MyRoom.ts
// ...
onLeave(client: Client, consented: boolean) {
console.log(client.sessionId, "left!");

this.state.players.delete(client.sessionId);
}
// ...

服务器执行的状态修改可在客户端观察到,下一节将实现这一点。

配置用于同步的场景​

本例需要在场景中创建两个对象:

  • 表示地面的平面。
  • 表示玩家的胶囊体,每个新玩家加入房间时克隆一次。

创建平面​

创建缩放为 8 的平面。

平面

创建玩家​

创建缩放为 1 的 Player 胶囊体。

确保取消勾选 Enabled。在与服务器建立有效连接前,不启用任何玩家实例。

玩家

监听状态变化​

与房间建立连接后,客户端就可以监听状态变化,并为服务器数据创建可视化表示。

添加新玩家​

如房间状态与 Schema所述,每当服务器接受新连接,onJoin() 就在状态中创建玩家实例。

现在在客户端监听这个变化:

//
// In order to attach callbacks to the state, we need to use the `getStateCallbacks()` method
//
const $ = Colyseus.getStateCallbacks(this.room);

// ...
$(this.room.state).players.onAdd((player, sessionId) => {
//
// A player has joined!
//
console.log("A player has joined! Their unique session id is", sessionId);
});
// ...

运行场景时,每有新客户端加入房间,浏览器控制台都应显示消息。

为了显示玩家,需要克隆 Player 对象,并按 sessionId 保存克隆的本地引用,以便后续操作:

// ...

// we will assign each player visual representation here
// by their `sessionId`
this.playerEntities = {};

// listen for new players
$(this.room.state).players.onAdd((player, sessionId) => {
// find the base Player representation (not enabled)
const playerEntityToClone = this.app.root.findByName("Player");

// clone the Player representation, and enabled it!
const entity = playerEntityToClone.clone();
entity.enabled = true;

// set position based on server data
entity.setPosition(player.x, player.y, player.z);

// add clone to the Scene
playerEntityToClone.parent.addChild(entity);

// assign visual representation by their `sessionId`
this.playerEntities[sessionId] = entity;
});
// ...

当前玩家​

将 sessionId 与当前连接的 room.sessionId 比较,即可保存当前玩家对象的特殊引用:

// ...
$(this.room.state).players.onAdd((player, sessionId) => {
// ...
if (this.room.sessionId === sessionId) {
this.currentPlayerEntity = this.playerEntities[sessionId];
}
// ...
});

移除断开的玩家​

服务器端 onLeave() 将玩家从状态移除时,客户端也必须移除对应的显示对象。

// ...
$(this.room.state).players.onRemove((player, sessionId) => {
// destroy entity
this.playerEntities[sessionId].destroy();

// clear local reference
delete this.playerEntities[sessionId];
});
// ...

移动玩家​

向服务器发送新位置​

监听鼠标按下事件,通过射线检测确定玩家应移动到的准确 Vec3 位置,并将其作为消息发送给服务器。

// ...
this.app.mouse.on(pc.EVENT_MOUSEDOWN, (event) => {
// Create the "bounding box" for the floor
const boundingBox = new pc.BoundingBox(new pc.Vec3(0, 0, 0), new pc.Vec3(4, 0.001, 4));;

// Initialize the ray and work out the direction of the ray
// from the a screen position
const ray = new pc.Ray();
const targetPosition = new pc.Vec3();

const cameraEntity = this.app.root.findByName("Camera");
cameraEntity.camera.screenToWorld(event.x, event.y, cameraEntity.camera.farClip, ray.direction);
ray.origin.copy(cameraEntity.getPosition());
ray.direction.sub(ray.origin).normalize();

// Test the ray against the ground
const result = boundingBox.intersectsRay(ray, targetPosition);

if (result) {
// Adjust position height
targetPosition.y = 1.031;

//
// Send new target player position to server.
//
this.room.send("updatePosition", {
x: targetPosition.x,
y: targetPosition.y,
z: targetPosition.z,
});
}
});

服务器接收消息​

服务器收到 "updatePosition" 消息后,根据发送方的 sessionId 修改该玩家状态。

// MyRoom.ts
// ...
onCreate(options: any) {
this.setState(new MyRoomState());

this.onMessage("updatePosition", (client, data) => {
const player = this.state.players.get(client.sessionId);
player.x = data.x;
player.y = data.y;
player.z = data.z;
});
}
// ...

更新玩家显示对象​

服务器状态变化后,客户端可通过 $(player).onChange() 或 $(player).listen() 检测:

  • $(player).onChange() 按 Schema 实例触发。
  • $(player).listen(prop) 按属性变化触发。

这里使用 .onChange(),因为无论是否只有一个坐标改变,都需要一次获取全部新坐标。

// ...
$(this.room.state).players.onAdd((player, sessionId) => {
// ...
$(player).onChange(() => {
this.playerEntities[sessionId].setPosition(player.x, player.y, player.z);
});

// Alternative, listening to individual properties:
// $(player).listen("x", (newX, prevX) => console.log(newX, prevX));
// $(player).listen("y", (newY, prevY) => console.log(newY, prevY));
// $(player).listen("z", (newZ, prevZ) => console.log(newZ, prevZ));
});

更多信息见 Schema 回调。

附加内容:监控房间与连接​

Colyseus 提供可选监控面板,对游戏开发很有帮助。

访问本地服务器的 http://localhost:2567/colyseus 即可查看。

监控面板

面板可以查看并操作所有已创建房间和活动客户端连接。

更多信息见监控面板文档。

更多资源​

希望本教程对你有所帮助。进一步了解 Colyseus,请阅读 Colyseus 文档,并加入 Colyseus Discord 社区。