如何用Adonis.js构建一个Websocket驱动的井字形游戏

649 阅读5分钟

用Adonis.js构建Websocket驱动的井字游戏

基于浏览器的多人游戏需要玩家之间的瞬间交流。Tic-Tac-Toe也不例外。玩家1需要在一秒钟之内看到玩家2的动作。在本教程中,我们将使用WebSocket API来实现这种通信速度。

简介

本文中,我们将在后端使用Adonis.js,它是一个Node.js MVC框架。Adonis.js在客户端和服务器端都实现了web sockets。你可以在这里查看完成的应用程序。

涉及的技术

  1. Node.js
  2. Adonis.js
  3. Redis

先决条件

Node.js

nodejs-logo.png

首先,检查你的Node.js版本是否>=10。

$ node -v
# v10.19.0

如果你的Node.js版本低于10,从官方网站安装最新的LTS版本。

Redis

Redis Logo

我们将使用Redis来处理游戏状态。横向扩展是现代软件开发的一个普遍要求。我们应该能够随意地旋转和拆解服务器。这意味着我们不应该在我们的应用程序中存储状态,因为实例将不能共享状态。

Redis将允许我们为数百万的游戏玩家维护游戏状态。Redis提供了比传统DB更快的读写操作,所以它是游戏的理想选择。

什么是WebSocket?

根据维基百科的说法。

WebSocket是一种计算机通信协议,通过单个TCP连接提供全双工通信通道。

WebSocket是一种允许双向通信的通信渠道。排放和广播取代了HTTP中的请求-响应机制。

Client to server

一个服务器可以同时向几个连接的客户端广播。

Server to two clients

客户端向服务器发射信息,而不等待响应。这样,通信就通过客户端设置的监听器发生。

项目架构

该项目遵循这个架构。

  • 一个用户设置一个用户名。

set-username.gif

  • 用户生成一个游戏代码。

generate-game-code.gif

  • 用户与一个朋友分享游戏代码。

  • 该朋友设置一个用户名并使用游戏代码来初始化游戏。

joining-game-with-code.gif

  • 两个用户都被重定向到游戏中,在那里他们进行轮番游戏。

开始使用

我们不会去研究标记和前端Javascript的细节。相反,我们将研究Adonis.js中Websockets的实现。

第1步:克隆 repo。

$ git clone https://github.com/vicradon/tic-tac-toe-tut

第2步:安装依赖项。

$ npm i

第3步:启动Redis服务器。

$ redis-server

第4步:在另一个终端,启动开发服务器。

$ npm run dev

# or
$ npm run dev

现在一切都设置好了,我们将看看游戏核心部分的逻辑,并学习更多关于WebSockets的知识。

Adonis.js中的WebSockets

Adonis.js使用通道来组织WebSocket业务逻辑。对于这个游戏,我们在start/socket.js ,创建了一个通道,即井字形通道。客户端可以订阅不同的通道,以监听这些通道上的事件。

const Ws = use("Ws");

Ws.channel("tic-tac-toe", "TicTacToeController");

客户端还可以通过向事件发射来与通道上的其他客户端通信。

// in the browser
ws.getSubscription("tic-tac-toe").emit("checkForExistingGame");

如果我们想在我们的游戏中加入聊天,我们将创建另一个通道,名为chat 。我们还将为聊天频道创建一个不同的控制器。

Ws.channel("chat", "ChatController");

控制器被表示为类。

class TicTacToeController {
  constructor({ socket, request }) {
    this.socket = socket;
    this.request = request;
  }
  async onClose() {}
}

每个连接的客户端都链接到这个类的一个实例。这样,一个客户端就可以与服务器环境中的其他客户端进行通信。连接的客户端的套接字是this.socket 。我们还可以在这里使用this.request ,访问请求对象。这意味着我们可以访问WebSocket控制器中的cookies。这将在我们进一步研究时证明是有用的。

// get all cookies
this.request.cookies();

处理用户注册

为了识别用户,我们在注册时将他们的用户名设置在一个加密的httpOnly cookie中。

// register method in UserController.js
session.flash({ success: "Account created successfully" });
response.cookie("username", username);

由于我们没有使用会话或JWT认证,这些cookie充当了Psuedo-auth。如果重载后用户名cookie存在,我们会返回一个经过认证的视图。这方面的一个例子是在home.edge 中显示Hi {{username}}

@if(username)
<div class="col-md-6 mb-5">
  <h2>Hi {{ username }}</h2>
  ...
</div>
@else ... @endif

索引方法HomeController.js ,在home.edge 视图中传递变量。

// index method in HomeController.js
const game_code = await Redis.hget(request.cookie("username"), "game_code");
const username = request.cookie("username");
return view.render("home", { game_code, username });

如何处理连接的客户端

当主页或游戏页面加载时,浏览器会尝试与服务器建立连接。一个API调用触发了UserController.js 中的setSocketId 方法。这时,游戏者的socket-id与他们创建的Redis地图相连接。

const username = request.cookie("username");
await Redis.hset(username, "socket_id", socket_id);

每次浏览器重新加载时都会发生这种情况,所以连接总是建立的。

服务器排放

由你的客户端发送至服务器的事件可以触发排放到。

  1. 你自己使用this.socket.emit
  2. 使用t=his.socket.emitTo 的几个连接的客户端。
  3. 所有其他连接的客户端,除了你自己使用this.socket.broadcast
  4. 所有连接的客户端,包括你自己,使用this.socket.broadcastToAll

这个游戏利用了1和2。例如,当玩家1在棋盘上做了一个标记("X"),他们会发送棋盘的索引。然后服务器将玩家1的标记("X")和棋盘索引发送给玩家2。

this.socket.emitTo(
  "otherPlayerMove",
  { cell, other_player_mark: current_player_mark },
  [`tic-tac-toe#${next_player_socket_id}`]
);

这是一个我们不能依赖客户端输入的例子。我们不指望玩家1不把他们的标记从 "X "改为 "M"。客户端可以做很多事情来破坏我们的游戏,所以我们必须限制他们的权力。

我们如何处理棋手的移动

当一个棋手下棋时,如果该棋手是有效的,我们就执行该回合的逻辑。

轮次逻辑

当棋手下棋时,游戏可能会被解析为以下两种情况之一。

  1. 赢的状态
  2. 平局状态

赢的状态

转折逻辑包括在一个数组上设置玩家的标记。数组元素的索引与棋盘相对应。

const board = JSON.parse(gameObject.board);
const { mark: current_player_mark } = JSON.parse(
  gameObject[`${username}_stats`]
);
board[Number(cell)] = current_player_mark;

然后检查棋盘是否有一个有效的胜利状态。

// inside the `executeTurnLogic` method
const { status, player, sequence } = this.checkForWinner(board);

checkForWinner 方法中,我们遍历一组获胜序列。

const winningSequences = [
  [0, 1, 2],
  [3, 4, 5],
  [6, 7, 8],
  [0, 3, 6],
  [1, 4, 7],
  [2, 5, 8],
  [0, 4, 8],
  [2, 4, 6],
];

我们把映射到序列的棋盘单元表示为boardSequence 。第一个获胜序列看起来像。

[0, 1, 2]
["O", "X", "X"]

我们检查当前序列映射中的所有元素是否有相同的值。"O "或 "X"。如果是 "X",则玩家1获胜,否则玩家2获胜。

for (let sequence of winningSequences) {
  const boardSequence = [
    board[sequence[0]],
    board[sequence[1]],
    board[sequence[2]],
  ];
  const player1IsWinner = boardSequence.every((value) => value === "X");
  if (player1IsWinner) {
    return { status: "win", player: "player1", sequence };
  }

  const player2IsWinner = boardSequence.every((value) => value === "O");
  if (player2IsWinner) {
    return { status: "win", player: "player2", sequence };
  }
}

下面是一个关于第7步棋的迭代的可视化。

Winning Sequence Visualization

平局状态

在游戏开始时,棋盘是一个8大小的空字符串数组。

const board = ["", "", "", "", "", "", "", ""];

如果数组中没有空字符串存在,游戏结果为平局。双方棋手都能要求重赛。

# Example of draw state
["X", "O", "X", "X", "X", "O", "O", "X", "O"]

X | O | X
X | X | O
O | X | O

客户端排放和监听器

由于WebSockets支持双向通信,我们可以同时进行客户端和服务器的通信。一个客户端不能向其他客户端发射事件,它只能向服务器发射,而服务器再向其他客户端发射。

客户端上的棋盘移动

当我们向服务器发射一个boardMove 事件。服务器必须想办法处理这个事件,并向双方棋手发送一个 "回应"。

function makeMove(cell) {
  ws.getSubscription("tic-tac-toe").emit("boardMove", {
    cell,
  });
  updateTurnNotification(`It's ${other_player}'s turn`);
}

我们可以通过像这样设置监听器来接收来自服务器的消息。

game.on("otherPlayerMove", (response) => {
  updateBoard(response);
  canMove = true;
  updateTurnNotification(`It's your turn`);
});

我们在game.js 中的subscribeToChannel 函数中设置了所有的监听器。这些监听器就像JavaScript事件监听器。它们在注册后一直处理它们所监听的事件。

安全考虑

大部分的游戏逻辑都生活在服务器上。网络浏览器上的每个数据存储都是可以被Javascript改变的。Javascript不能读取或改变加密的cookies。这给了他们最高的安全性。

处理WebSockets时的最佳做法

  • 只有在HTTP、轮询和服务器发送事件(SSE)不适合问题时才使用Websockets。例如,一个实时仪表盘,很少或没有从客户端发送的事件,应该使用SSE。
  • 在Redis或Memcached这样的缓存中保持状态。你不会想为时间敏感的操作进行DB调用。

总结

在本文中,我们了解了WebSocket协议。我们看到了在AdonisJS中使用WebSockets构建Tic-Tac-Toe的细节。最后,我们了解了构建Websocket应用程序时应遵循的最佳做法。