跳到主要内容

实时多人联机

备注

本教程介绍如何从零创建多人联机。如果更倾向于托管多人服务,也可以阅读 Colyseus 和 Photon 教程。

使用 WASD 移动玩家。如果只看到一个胶囊体,请在另一个标签页或另一台电脑上打开此页面。

本教程使用 Node.js 和 Socket.io 配置基础多人项目,重点介绍 PlayCanvas 端的实现。完成后,你将得到与上方类似的项目。教程项目在此。

配置服务器​

我们采用客户端—服务器模型。服务器接收所有客户端,即 PlayCanvas 实例的数据,再广播回各客户端。

原教程使用 Glitch 在浏览器中编写和部署免费的后端应用,并建议创建账户以便找回项目。其操作流程是创建 Node 应用,将 server.js 替换为以下内容:

const http = require('http');
const { Server } = require('socket.io');

const server = http.createServer();

// Configure Socket.IO with CORS
const io = new Server(server, {
cors: {
// If you only want to allow PlayCanvas launch domain:
// origin: "https://launch.playcanvas.com",

// Or allow all origins (less secure, but quick for testing)
origin: "*",
methods: ["GET", "POST"]
}
});

io.on('connection', (socket) => {
console.log(`New client connected: ${socket.id}`);
});

const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`Server started on port ${PORT}`);
});

按照原教程的 Glitch 工作流,每次编辑完成后服务器自动重新运行。复制代码后会报错,点击窗口底部的 Logs(日志)打开服务器控制台,可看到输出与 Error: Cannot find module 'socket.io' 错误。

打开日志

要添加依赖包,打开 package.json,点击顶部 Add Package(添加包),搜索 socket.io。

添加包

安装完成后,服务器自动重启,日志应显示 Server started on port 3000,说明服务器已成功启动。

点击左侧 Settings 可查看服务器部署域名,也可在此重命名项目。

此服务器只在有人连接时记录消息,足以让我们开始编写客户端并确认连接正常。

配置项目​

在 PlayCanvas 新建项目。首先以外部脚本形式引入 Socket.io 客户端 JavaScript 库,打开项目设置:

项目设置

找到并打开 External Scripts(外部脚本)。

外部脚本设置

将数量从 0 改为 1,添加来自 CDN 的 Socket.io 库 URL。本例使用原文编写时采用的 4.8.1 版本:

项目设置

https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.8.1/socket.io.min.js

接着创建 Network.js 处理网络逻辑。在 initialize 中加入以下代码,建立服务器连接:

this.socket = io.connect('https://playcanvas-multiplayer-server.glitch.me');

将 https://playcanvas-multiplayer-server.glitch.me 替换为你自己的服务器地址。

将网络脚本挂到 Root 实体并启动游戏,观察服务器日志。连接成功时,应显示 New client connected: 和客户端 ID。至此,项目可以与服务器收发消息。

服务器与客户端通信​

通过之前建立的 socket 连接在客户端和服务器之间传递数据。在 PlayCanvas 的 Network.js 中,使用 emit 发送数据,例如:

this.socket.emit('playerJoined', 'John');

此代码发送名为 playerJoined 的消息,数据为 John。要接收消息,在服务器的 server.js 中加入:

socket.on('playerJoined', function (name) {
console.log (name);
});

当 playerJoined 发出时,服务器会记录收到的数据。

本例的目标是让多个玩家实时共同移动,因此先创建环境。创建地面实体,添加箱形碰撞组件与静态刚体,配置应如下图:

再创建玩家胶囊体,命名为 Player,添加动态刚体和箱形碰撞组件,并按下图调整刚体属性。

复制玩家实体,重命名为 Other,取消勾选 Enabled,使其初始禁用。它将作为游戏中其他玩家的模板。

为玩家添加脚本组件,并挂载新脚本 Movement.js:

var Movement = pc.createScript('movement');

Movement.attributes.add('playerSpeed', {
type: 'number',
default: 30,
title: 'Player Speed'
});

// initialize code called once per entity
Movement.prototype.initialize = function() {
this.force = new pc.Vec3();
};

// update code called every frame
Movement.prototype.update = function(dt) {
var forward = this.entity.forward;
var right = this.entity.right;
var app = this.app;

x = 0;
z = 0;

if (app.keyboard.isPressed(pc.KEY_A)) {
x -= right.x;
z -= right.z;
}

if (app.keyboard.isPressed(pc.KEY_D)) {
x += right.x;
z += right.z;
}

if (app.keyboard.isPressed(pc.KEY_W)) {
x += forward.x;
z += forward.z;
}

if (app.keyboard.isPressed(pc.KEY_S)) {
x -= forward.x;
z -= forward.z;
}

if (x !== 0 || z !== 0) {
x *= dt;
z *= dt;

this.force.set (x, 0, z).normalize ().scale ((this.playerSpeed));
this.entity.rigidbody.applyForce (this.force);
}
};

启动游戏后,应可使用 WASD 移动玩家。若无效,请检查是否遗漏步骤或实体属性设置错误,也可尝试修改移动脚本的 speed 属性。

为实现实时多人联机,需要跟踪游戏中的所有玩家。将服务器代码替换为:

const http = require('http');
const { Server } = require('socket.io');

/**
* Class to track each connected player (id + position)
*/
class Player {
constructor(id) {
this.id = id;
this.x = 0;
this.y = 0;
this.z = 0;
}
}

const server = http.createServer();

// Configure Socket.IO with CORS
const io = new Server(server, {
cors: {
// If you only want to allow PlayCanvas launch domain:
// origin: "https://launch.playcanvas.com",

// Or allow all origins (less secure, but quick for testing)
origin: "*",
methods: ["GET", "POST"]
}
});

const players = {};

/**
* Handle new socket connections
*/
io.on('connection', (socket) => {
console.log(`New client connected: ${socket.id}`);

// Fired when the client is ready to initialize their Player object
socket.on('initialize', () => {
const newPlayer = new Player(socket.id);
players[socket.id] = newPlayer;

// Send to this client its own ID and the current list of players
socket.emit('playerData', { id: socket.id, players });

// Tell everyone else about this new player
socket.broadcast.emit('playerJoined', newPlayer);
});
});

const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`Server started on port ${PORT}`);
});

上述代码在玩家发送 initialize 消息时,返回其唯一 ID 和其他玩家数据,并通知其他人有新玩家加入。接下来将对应逻辑添加到 Network 脚本。

在 initialize 中添加:

// Your io.connect function call should be up here

this.socket.emit('initialize');
var socket = this.socket;

this.player = this.app.root.findByName('Player');
this.other = this.app.root.findByName('Other');

var self = this;
socket.on('playerData', function (data) {
self.initializePlayers (data);
});

socket.on('playerJoined', function (data) {
self.addPlayer(data);
});

然后在 Network.js 中声明以下函数:

Network.prototype.initializePlayers = function (data) {
this.players = data.players;
Network.id = data.id;

for (var id in this.players) {
if (id != Network.id) {
this.players[id].entity = this.createPlayerEntity(this.players[id]);
}
}

this.initialized = true;
console.log('initialized');
};

Network.prototype.createPlayerEntity = function (data) {
// Create a new player entity
var newPlayer = this.other.clone();
newPlayer.enabled = true;

// Add the entity to the entity hierarchy
this.other.getParent().addChild(newPlayer);

// If a location was given, teleport the new entity to the position of the connected player
if (data)
newPlayer.rigidbody.teleport(data.x, data.y, data.z);

return newPlayer;
};

Network.prototype.addPlayer = function (data) {
this.players[data.id] = data;
this.players[data.id].entity = this.createPlayerEntity(data);
};

现在加入游戏时,客户端会通知服务器,服务器返回玩家及其位置列表。游戏为每个已连接玩家创建实体并定位。但服务器还不知道所有玩家的实时位置,因此需要每帧发送自身位置。

在 Network.js 的 initialize 中加入:

socket.on('playerMoved', function (data) {
self.movePlayer(data);
});

将 update 替换为:

Network.prototype.update = function (dt) {
this.updatePosition();
};

再在 Network.js 中声明以下函数:

Network.prototype.movePlayer = function (data) {
if (this.initialized && !this.players[data.id].deleted) {
this.players[data.id].entity.rigidbody.teleport(data.x, data.y, data.z);
}
};

Network.prototype.updatePosition = function () {
if (this.initialized) {
var pos = this.player.getPosition();
Network.socket.emit('positionUpdate', {id: Network.id, x: pos.x, y: pos.y, z: pos.z});
}
};

回到服务器,添加新事件处理玩家发送的位置:

// Update player position
socket.on('positionUpdate', (data) => {
if (!players[socket.id]) return;
players[socket.id].x = data.x;
players[socket.id].y = data.y;
players[socket.id].z = data.z;

// Broadcast updated position to all other players
socket.broadcast.emit('playerMoved', {
id: socket.id,
x: data.x,
y: data.y,
z: data.z
});
});

最后,通过监听 socket 的 disconnect 事件处理玩家断开连接。

// Handle disconnections
socket.on('disconnect', () => {
console.log(`Client disconnected: ${socket.id}`);
if (!players[socket.id]) return;
delete players[socket.id];
// Notify other players to remove this player
socket.broadcast.emit('killPlayer', socket.id);
});

最终完整服务器代码如下:

const http = require('http');
const { Server } = require('socket.io');

/**
* Class to track each connected player (id + position)
*/
class Player {
constructor(id) {
this.id = id;
this.x = 0;
this.y = 0;
this.z = 0;
}
}

const server = http.createServer();

// Configure Socket.IO with CORS
const io = new Server(server, {
cors: {
// If you only want to allow PlayCanvas launch domain:
// origin: "https://launch.playcanvas.com",

// Or allow all origins (less secure, but quick for testing)
origin: "*",
methods: ["GET", "POST"]
}
});

const players = {};

/**
* Handle new socket connections
*/
io.on('connection', (socket) => {
console.log(`New client connected: ${socket.id}`);

// Fired when the client is ready to initialize their Player object
socket.on('initialize', () => {
const newPlayer = new Player(socket.id);
players[socket.id] = newPlayer;

// Send to this client its own ID and the current list of players
socket.emit('playerData', { id: socket.id, players });

// Tell everyone else about this new player
socket.broadcast.emit('playerJoined', newPlayer);
});

// Update player position
socket.on('positionUpdate', (data) => {
if (!players[socket.id]) return;
players[socket.id].x = data.x;
players[socket.id].y = data.y;
players[socket.id].z = data.z;

// Broadcast updated position to all other players
socket.broadcast.emit('playerMoved', {
id: socket.id,
x: data.x,
y: data.y,
z: data.z
});
});

// Handle disconnections
socket.on('disconnect', () => {
console.log(`Client disconnected: ${socket.id}`);
if (!players[socket.id]) return;
delete players[socket.id];
// Notify other players to remove this player
socket.broadcast.emit('killPlayer', socket.id);
});
});

const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`Server started on port ${PORT}`);
});

后续练习​

基础功能已经完成!可以继续尝试以下想法:

  • 玩家关闭游戏后移除其角色。
  • 玩家掉出地面边缘时重新生成。

这只是最基础的多人实现。制作较大的多人游戏时,应考虑使用权威服务器,而非让客户端处理全部游戏逻辑。更深入的 Socket.io 与 JavaScript 多人开发教程见此处。

原教程的完整服务器代码位于 Glitch,可作为进一步扩展的参考。