通过制作多人游戏来学习 Socket.io 的基础知识
先决条件
项目
总结
资源
随着当今世界对多人游戏的需求日益增长,开发者必须关注打造这种充满乐趣和沉浸感的游戏所需的技术,同时牢记随之而来的挑战。在创建多人游戏时,玩家之间的实时数据通信至关重要,并且有各种各样的库能够处理其中涉及的复杂问题。Socket.io 就是这样一个流行的库,它主要用于创建聊天应用程序、实时协作环境、游戏等等。
于是,我们决定尝试一些基础的多人游戏,让它既能吸引大众,又能简单易懂地编写代码。就在这时,我们萌生了重新创作《蛇梯棋》的想法。这款经典的棋盘游戏,我们很多人在成长过程中都曾花费无数时间玩过。
先决条件
由于游戏代码将从头开始编写,因此无需任何先决条件。不过,如果您具备一些在Node.js上搭建Express服务器的基础知识以及一些Vanilla JS知识,将有助于您全面理解本文涵盖的主题。
项目
为了清晰起见并分离关注点,整个项目被分为以下几个部分:
我们将要做什么
让我们来规划一下为了达到预期结果需要做什么。首先,我们需要一个功能最基本的服务器,用于向所有连接的客户端发送请求。我们需要建立套接字连接以实现实时通信。最后,我们需要一些前端 HTML、CSS 和 Vanilla JS 代码来实现游戏逻辑。
下载启动项目
我们提供了项目的初始代码,以便您可以直接着手编写重要内容,而无需费力将所有游戏资源和文件整理到相应的文件夹中。css
我们还提供了一个完整的代码文件,让您无需html
从头开始设计组件样式,因为这与本文的目的无直接关系。您可以随时添加自己的自定义代码css
,但这不是必需的。您可以点击此处下载初始项目。
安装必要的软件包
下载完启动文件后,你需要安装必要的软件包。在主文件夹中,你可以找到该package.json
文件。运行以下命令安装所需的软件包,即express、socket.io和http:
npm install
您必须安装 Node.js 才能运行该命令。如果尚未安装 Node.js,请访问上面链接的 Node.js 官方网站,下载适用于您所需操作系统的最新版本。下载并安装后,再次运行该命令。
设置服务器
我们首先设置我们的 express 服务器和 socket.io。在server.js
文件中写入以下代码:
const express = require("express");
const socket = require("socket.io");
const http = require("http");
const app = express();
const PORT = 3000 || process.env.PORT;
const server = http.createServer(app);
// Set static folder
app.use(express.static("public"));
// Socket setup
const io = socket(server);
server.listen(PORT, () => console.log(`Server running on port ${PORT}`));
骷髅
Node.js 和 Express 项目的所有前端代码通常都放在一个public
文件夹中,我们已经在 中指定了该文件夹server.js
。在继续编写游戏逻辑之前,重要的是创建一个html
包含必要组件的文件,以便用户能够与游戏进行交互。继续在文件夹index.html
内的文件中包含以下代码public
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Snakes and Ladders</title>
<link
href="https://fonts.googleapis.com/css?family=Roboto"
rel="stylesheet"
/>
<link rel="stylesheet" href="css/styles.css" />
</head>
<body>
<div class="board"></div>
<img src="images/red_piece.png" alt="" hidden="true" id="red-piece" />
<img src="images/blue_piece.png" alt="" hidden="true" id="blue-piece" />
<img src="images/yellow_piece.png" alt="" hidden="true" id="yellow-piece" />
<img src="images/green_piece.png" alt="" hidden="true" id="green-piece" />
<div class="container">
<canvas id="canvas"></canvas>
</div>
<div class="info-box">
<div class="form-group">
<input
type="text"
class="form-input"
id="name"
placeholder="Your name"
required
/>
<button class="btn draw-border" id="start-btn">Join</button>
</div>
</div>
<div id="players-box">
<h3>Players currently online:</h3>
<br>
<table id="players-table"></table>
</div>
<div id="current-player"></div>
<button class="btn draw-border" id="roll-button" hidden>Roll</button>
<div class="dice">
<img src="./images/dice/dice1.png" alt="" id="dice" />
</div>
<button class="btn draw-border" id="restart-btn" hidden>Restart</button>
<script src="/socket.io/socket.io.js"></script>
<script src="js/index.js"></script>
</body>
</html>
该index.html
文件将包含一个非常特殊的元素,即canvas
,我们的游戏将通过它来呈现。该canvas
标签用于使用 JavaScript 绘制图形。它内置了绘制简单形状(例如圆弧、矩形和直线)的函数。它还可以显示文本和图像。
为了使socket.io能够从前端与后端express服务器进行通信,我们添加了以下script
标签:
<script src="/socket.io/socket.io.js"></script>
最后,我们使用另一个script
指向index.js
文件的标签,该文件将保存游戏逻辑以及跨客户端套接字通信的代码。
设置套接字连接
Socket.io 的工作方式非常简单。本质上,客户端会发出某些事件,服务器可以监听这些事件,然后将这些事件传递给所有或部分需要使用该信息的客户端。为了建立连接,我们需要在文件中的对象中添加连接事件监听器,如下所示:io
server.js
io.on("connection", (socket) => {
console.log("Made socket connection", socket.id);
});
这告诉服务器与所有客户端建立套接字连接,并id
在连接建立后立即显示套接字信息。该console.log
语句用于确保在出现问题时连接成功。
同时,在index.js
该文件夹下的文件里面public
,添加如下代码:
const socket = io.connect("http://localhost:3000");
这告诉套接字连接到客户端的前端,该前端可通过上述 URL 访问。
游戏逻辑
现在,我们将把重点转移到游戏逻辑上。我们将把所有代码都写在这个index.js
文件中。整个逻辑可以分为以下几个子类别:
- 初始化——我们声明以下全局变量:
let canvas = document.getElementById("canvas");
canvas.width = document.documentElement.clientHeight * 0.9;
canvas.height = document.documentElement.clientHeight * 0.9;
let ctx = canvas.getContext("2d");
let players = []; // All players in the game
let currentPlayer; // Player object for individual players
const redPieceImg = "../images/red_piece.png";
const bluePieceImg = "../images/blue_piece.png";
const yellowPieceImg = "../images/yellow_piece.png";
const greenPieceImg = "../images/green_piece.png";
const side = canvas.width / 10;
const offsetX = side / 2;
const offsetY = side / 2 + 20;
const images = [redPieceImg, bluePieceImg, yellowPieceImg, greenPieceImg];
const ladders = [
[2, 23],
[4, 68],
[6, 45],
[20, 59],
[30, 96],
[52, 72],
[57, 96],
[71, 92],
];
const snakes = [
[98, 40],
[84, 58],
[87, 49],
[73, 15],
[56, 8],
[50, 5],
[43, 17],
];
首先,我们设置的大小canvas
以匹配游戏板的尺寸,并获取的context
,canvas
这将是绘制玩家针所必需的。此后,我们声明一个集合players
,它将需要跟踪游戏中的当前玩家,以及一个currentPlayer
存储在特定前端客户端上玩游戏的玩家的引用。然后我们存储对四个玩家针(即红色,蓝色,黄色和绿色)的引用。我们初始化变量side
,offsetX
和,offsetY
这将是调整玩家针在画布上的位置所必需的。最后,初始化变量ladders
和snakes
,它们是分别存储由梯子和蛇连接的点集的集合,如游戏板上所示。这将是在玩家落在有梯子或蛇的方块上时改变玩家针的位置所必需的。
- 玩家类- 我们希望使用面向对象编程 (OOP) 范式来表示玩家,以便更轻松地分配相关属性和函数。该类
Player
的模型如下:
class Player {
constructor(id, name, pos, img) {
this.id = id;
this.name = name;
this.pos = pos;
this.img = img;
}
draw() {
let xPos =
Math.floor(this.pos / 10) % 2 == 0
? (this.pos % 10) * side - 15 + offsetX
: canvas.width - ((this.pos % 10) * side + offsetX + 15);
let yPos = canvas.height - (Math.floor(this.pos / 10) * side + offsetY);
let image = new Image();
image.src = this.img;
ctx.drawImage(image, xPos, yPos, 30, 40);
}
updatePos(num) {
if (this.pos + num <= 99) {
this.pos += num;
this.pos = this.isLadderOrSnake(this.pos + 1) - 1;
}
}
isLadderOrSnake(pos) {
let newPos = pos;
for (let i = 0; i < ladders.length; i++) {
if (ladders[i][0] == pos) {
newPos = ladders[i][1];
break;
}
}
for (let i = 0; i < snakes.length; i++) {
if (snakes[i][0] == pos) {
newPos = snakes[i][1];
break;
}
}
return newPos;
}
}
每个Player
对象都需要一个id
、一个name
、棋盘上的位置(表示为)pos
和一个大头针图像(表示为) img
。然后我们分别编写函数draw
、updatePos
和isLadderOrSnake
来绘制和更新玩家的位置,并确定玩家在棋盘上的方块中是否有梯子或蛇。该updatePos
方法仅pos
使用玩家刚刚在骰子上掷出的数字进行更新,并检查阻止玩家超越棋盘上第 100 个方块的条件。这里要注意的一点是,玩家的位置虽然从 1 开始,但用 0 表示,这使得绘制逻辑更简单。该isLadderOrSnake
函数将玩家的位置作为参数,并将其与集合中的方块进行比较ladders
,snakes
并相应地返回玩家在棋盘上的新位置。这个draw
函数可能看起来有点复杂,但它所做的就是在棋盘上的正确方块上绘制玩家大头针。该函数负责跨行交替的左右移动和跨列的向上移动。
- 实用函数——除了我们在类内部编写的函数之外
Player
,我们还需要编写另外两个实用函数,如下所示:
function rollDice() {
const number = Math.ceil(Math.random() * 6);
return number;
}
function drawPins() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
players.forEach((player) => {
player.draw();
});
}
该rollDice
函数返回 1 到 6 之间的随机数,同时该drawPins
函数循环遍历players
集合并使用其函数绘制相应的玩家引脚draw
。
- 触发套接字事件并处理它们——到目前为止,我们已经编写了游戏实体的代码。但是,如果我们不从前端触发套接字事件,所有玩家将无法相互通信他们的位置和其他数据。首先,在文件
io.connect
中的函数下方添加以下代码行index.js
:
socket.emit("joined");
然后在相关元素上添加如下事件监听器,html
如下所示:
document.getElementById("start-btn").addEventListener("click", () => {
const name = document.getElementById("name").value;
document.getElementById("name").disabled = true;
document.getElementById("start-btn").hidden = true;
document.getElementById("roll-button").hidden = false;
currentPlayer = new Player(players.length, name, 0, images[players.length]);
document.getElementById(
"current-player"
).innerHTML = `<p>Anyone can roll</p>`;
socket.emit("join", currentPlayer);
});
document.getElementById("roll-button").addEventListener("click", () => {
const num = rollDice();
currentPlayer.updatePos(num);
socket.emit("rollDice", {
num: num,
id: currentPlayer.id,
pos: currentPlayer.pos,
});
});
document.getElementById("restart-btn").addEventListener("click", () => {
socket.emit("restart");
});
套接字发出的事件joined
会将已加入游戏的玩家信息告知刚加入游戏的新玩家,即他们的位置和大头针图像。这就是为什么新用户加入时会立即触发该事件的原因。接下来,我们click
为开始按钮、掷骰子按钮和重启按钮添加了三个事件监听器。开始按钮采用新加入玩家的名称并创建一个新currentPlayer
对象。接下来,操作一些html
标签来传达游戏状态,然后join
发出事件,将新加入的玩家通知给服务器。掷骰子按钮事件监听器只是掷骰子并更新的位置currentPlayer
,并将掷出的数字及其和一起发送id
。name
顾名思义,重启按钮会restart
从前端触发事件。
我们还需要能够在服务器端接收这些事件。在object3connection
的事件监听器中编写如下代码io
:
socket.on("join", (data) => {
users.push(data);
io.sockets.emit("join", data);
});
socket.on("joined", () => {
socket.emit("joined", users);
});
socket.on("rollDice", (data) => {
users[data.id].pos = data.pos;
const turn = data.num != 6 ? (data.id + 1) % users.length : data.id;
io.sockets.emit("rollDice", data, turn);
});
socket.on("restart", () => {
users = [];
io.sockets.emit("restart");
});
});
后端具有相同的事件监听器以及一个users
集合,用于存储和传递正在玩游戏的玩家的信息。
我们还需要能够在前端处理这些事件,其代码如下:
socket.on("join", (data) => {
players.push(new Player(players.length, data.name, data.pos, data.img));
drawPins();
document.getElementById(
"players-table"
).innerHTML += `<tr><td>${data.name}</td><td><img src=${data.img} height=50 width=40></td></tr>`;
});
socket.on("joined", (data) => {
data.forEach((player, index) => {
players.push(new Player(index, player.name, player.pos, player.img));
console.log(player);
document.getElementById(
"players-table"
).innerHTML += `<tr><td>${player.name}</td><td><img src=${player.img}></td></tr>`;
});
drawPins();
});
socket.on("rollDice", (data, turn) => {
players[data.id].updatePos(data.num);
document.getElementById("dice").src = `./images/dice/dice${data.num}.png`;
drawPins();
if (turn != currentPlayer.id) {
document.getElementById("roll-button").hidden = true;
document.getElementById(
"current-player"
).innerHTML = `<p>It's ${players[turn].name}'s turn</p>`;
} else {
document.getElementById("roll-button").hidden = false;
document.getElementById(
"current-player"
).innerHTML = `<p>It's your turn</p>`;
}
let winner;
for (let i = 0; i < players.length; i++) {
if (players[i].pos == 99) {
winner = players[i];
break;
}
}
if (winner) {
document.getElementById(
"current-player"
).innerHTML = `<p>${winner.name} has won!</p>`;
document.getElementById("roll-button").hidden = true;
document.getElementById("dice").hidden = true;
document.getElementById("restart-btn").hidden = false;
}
});
socket.on("restart", () => {
window.location.reload();
});
大多数套接字事件监听器都相当简单,仔细查看函数语句就会发现,我们在这里所做的就是通过禁用和启用必要的元素来显示游戏的当前状态html
。
完成
现在一切就绪,是时候启动终端并运行了,这会将 Node.js 服务器暴露给localhostnode server.js
的 3000 端口。之后,你就可以在多个浏览器窗口访问http://localhost:3000并测试游戏了。
总结
该项目旨在为探索多人游戏和套接字通信领域无限可能提供一个切入点。我们旨在在此解释一些基本原理,但仍有很大的改进空间。例如,目前游戏仅允许 4 名玩家同时游戏,但实际上,这类游戏应该有专门的房间供玩家加入,从而允许多名玩家同时访问游戏。您还可以添加游戏内聊天框,让玩家在游戏过程中互相聊天。玩家图钉的移动canvas
也是瞬时的,这一点并不吸引人。强烈建议您尝试在游戏中添加此类功能,以更好地掌握底层技术细节。
资源
主分支包含已完成的项目,而启动分支提供起始代码。
本文由以下人士共同撰写:
希望您觉得本文对您有所帮助。
欢迎访问我们的网站了解更多信息,并关注我们的平台:
另外,如果您有兴趣了解更多关于使用 JavaScript 进行游戏开发的知识,请不要忘记在下方点赞和评论。您可以自由地提出疑问并提出改进建议。
在那之前,
请保持安全,愿源头与你同在!