使用 Colyseus 实现实时多人联机
选择 Create Game 创建新游戏,点击地面任意位置移动对象。
本教程将介绍
- 配置 Colyseus 服务器。
- 在服务器与客户端之间同步状态。
- 在客户端与服务器之间交换消息。
- 匹配:创建、加入和列出可用游戏。
资源
开始之前
预备知识
- PlayCanvas 基础知识,参阅 PlayCanvas 开发资源。
- JavaScript/TypeScript 基础,参阅 TypeScript 手册。
- Node.js 基础,参阅 Node.js 简介。
软件要求
创建服务器
我们将在本机运行基础服务器,保存玩家状态,并将变化同步到客户端。
在命令行运行以下指令,创建全新的 Colyseus 服务器:
npm init colyseus-app ./playcanvas-demo-server
运行 npm start,确认服务器可以在本地启动:
cd playcanvas-demo-server
npm start
成功时,命令行输出应类似:
> [email protected] 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 数量。

在新增的 URL 字段中填入 Colyseus JavaScript SDK 的 CDN 地址:
https://unpkg.com/colyseus.js@^0.16.0/dist/colyseus.js
这样,PlayCanvas 脚本就能使用 Colyseus JavaScript SDK。
建立客户端与服务器连接
新建 PlayCanvas 脚本,实例化 Colyseus.Client,具体操作见创建新脚本。
可以将脚本挂到名为 NetworkManager 的新空实体上。
- ESM
- 传统脚本
import { Script } from 'playcanvas';
export class NetworkManager extends Script {
static scriptName = "networkManager";
async initialize() {
//
// 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");
}
}
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 内部完成。
状态同步循环如下:
- 服务器将状态变化自动同步到客户端。
- 客户端在本地只读
Schema上注册回调,观察并响应状态变化。 - 客户端可以向服务器发送任意消息,服务器决定如何处理,并可能修改状态,然后返回第一步。
回到服务器代码,定义房间状态。
需要管理多个 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 社区。