首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >用Node.js实现不同机器联机进行井字棋对局

用Node.js实现不同机器联机进行井字棋对局

原创
作者头像
半月无霜
发布2025-01-18 18:14:47
发布2025-01-18 18:14:47
4120
举报
文章被收录于专栏:半月无霜半月无霜

一、前言

在上一篇文章中,用Vue实现了井字棋小游戏的对局,

用Vue实现井字棋-腾讯云开发者社区-腾讯云

对局的效果还是非常好的,但还是有所不足。就是因为,这一切的前提是建立在一台机器一个网页上的,玩家双方得操控鼠标进行分别点击,这显然不太友好

那么我就在想,能否让玩家双方在不同的机器,不同的网页上进行对局

想完成这样的功能,需要什么样的知识呢?

二、SocketIO

首先,大家都应该知道,联机游戏要想实现互联,肯定是需要一个通信,用来交换彼此之间的信息

这边采用一种SocketIO,使用Node.js启动一个socket服务端,用来接收客户端信息,并进行分发客户端状态

说干就干,我们想要使用js来启动作为服务端,我们就需要装好Node.js的环境,

这边相信大家都有环境了,校验一下

代码语言:javascript
复制
node -v
npm -v

初始化一下node项目

代码语言:javascript
复制
# 初始化
node init -y
​
# 安装express和socket.io
npm install express socket.io

如此一个socket的项目环境就准备好了,我们需要先了解一下对应的代码

编码写下testSocketServer.js

代码语言:javascript
复制
const express = require('express');
const http = require('http');
const socketIo = require('socket.io');
​
const app = express();
const server = http.createServer(app);
const io = socketIo(server, {
    cors: {
        origin: "*",
        methods: ["GET", "POST"],
        allowedHeaders: [],
        credentials: true
    }
});
​
io.on('connection', (socket) => {
​
    socket.on('clientData', (data) => {
        console.log(`客户端数据:${data}`);
        socket.emit('serverData', '服务端数据');
    })
​
    socket.on('disconnect', () => {
        console.log('A user disconnected:', socket.id);
    });
});
​
​
// 启动一个socket服务,端口号为3000
const PORT = 3000;
server.listen(PORT, () => {
    console.log(`Server is running on port ${PORT}`);
});

那么这是一个单独的js文件,我们需要启动它,该如何进行

Node.js可以帮助我们在脱离浏览器的环境下启动js文件,这也是我在上文中让大家安装js的原因

我们执行命令启动testSocketServer.js

代码语言:javascript
复制
node ./testSocketServer.js

能看到控制台日志打印出Server is running on port 3000即可


上面是服务端,那么下面我们看看客户端,testSocketClient.html

代码语言:javascript
复制
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>socket测试客户端</title>
</head>
<body>
    <div id="app" v-cloak>
        <h1>socket测试客户端</h1>
        <button @click="sendData">发送数据</button>
        <div>服务端数据:{{ serverData }}</div>
    </div>
​
    <script src="js/socket.io.js"></script>
    <script src="js/vue@3.3.0.js"></script>
    <script>
        const { createApp, ref } = Vue;
        const socket = io('http://localhost:3000');
​
        const app = Vue.createApp({
            setup() {
                const serverData = ref(null);
​
                const sendData = () => {
                    socket.emit('clientData', 'hello world');
                }
​
                socket.on('serverData', (data) => {
                    console.log(`服务端数据:${data}`);
                    serverData.value = data;
                });
​
                return {
                    serverData,
                    sendData
                };
            }
        });
        app.mount("#app");
    </script>
</body>
</html>

客户端非常简单,就是一个按钮发送客户端的数据,一个数据绑定显示服务端传回来的数据

那么现在,我们启动一下客户端看看,

result
result

再看下服务端的控制台,发现有客户端的数据打印

image-20250118145627201
image-20250118145627201

那么这个socket的验证测试,就已经完成了,接下来就是如何将原来的井字棋改造,让两边玩家相互下棋进行对局

三、联机井字棋

既然要实现联机井字棋,我们不妨想想客户端需要提交给服务端的数据,是不是需要告诉服务端落子的索引位置即可。

那么服务端需返回给客户端什么数据呢?当前棋盘的落子情况,当前要进行落子的玩家是谁,当前游戏是否获胜、平局,这一系列的信息

除了上面客户端,服务端相互往来的数据,服务端要管理什么数据

  • 棋盘情况
  • 当前需要落子的玩家
  • 当前对局的获胜、平局状态

这是上面需要返回出去的,还有隐性的,也是最重要的一个信息

就是服务端需要维护这么一个数据

  • 需要记录哪个socket client代表X玩家,哪个玩家代表O

那么,我们画一个结构图,来展示一下他们之间的关系

上面逻辑梳理完毕,那么接下来就是编码时间

首先,就是将棋盘、落子等逻辑搬到了服务端,再把必要的状态返回给客户端

代码语言:javascript
复制
const e = require('express');
const express = require('express');
const http = require('http');
const socketIo = require('socket.io');
​
const PORT = 3000;
const app = express();
const server = http.createServer(app);
const io = socketIo(server, {
    cors: {
        origin: "*",
        methods: ["GET", "POST"],
        allowedHeaders: [],
        credentials: true
    }
});
​
// 存储游戏状态
let gameState = {
    // 棋盘
    board: Array(9).fill(null),
    // 游戏状态,0: 未开始,1: 进行中,2: 已结束
    status: 0,
    // 当前落子玩家
    currentPlayer: 'X',
    // 胜利玩家
    winner: null,
};
​
let gameOtherState = {
    // 落子历史
    moveHistory: [],
    // 存储当前连接的玩家
    players: new Map()
}
​
// 处理客户端连接
io.on('connection', (socket) => {
    
    if (gameOtherState.players.size === 2 && gameOtherState.players.has(socket.id)) {
        socket.disconnectSockets(true);
    } else {
        // 客户端连上后,需要确认它的棋子是哪个
        if (gameOtherState.players.size === 0) {
            gameOtherState.players.set(socket.id, 'X');
        } else if (gameOtherState.players.size === 1) {
            let player = gameOtherState.players.values().next().value;
            gameOtherState.players.set(socket.id, player === 'X' ? 'O' : 'X');
        }
        // 如果当前玩家已满2人,即可开始游戏
        if (gameOtherState.players.size === 2 && gameState.status === 0) {
            let allClients = io.sockets.sockets;
            gameState.status = 1;
            for (let [id, player] of gameOtherState.players) {
                allClients.get(id).emit('serverData', {
                    player: player,
                    chessSet: gameState.currentPlayer === player,
                    removingIndex: gameOtherState.moveHistory.length === 6 ? gameOtherState.moveHistory[0] : null,
                    ...gameState
                });
            }
        }
    }
​
    // 处理客户端发送的落子请求
    socket.on('makeMove', (data) => {
        const { index } = data;
        const allClients = io.sockets.sockets;
        let player = gameOtherState.players.get(socket.id);
        // 简单验证落子是否合法
        if (gameState.status === 1 && !gameState.board[index] && !gameState.winner && player === gameState.currentPlayer) {
            // 如果是第七次落子,移除最开始落子
            if (gameOtherState.moveHistory.length === 6) {
                const firstMoveIndex = gameOtherState.moveHistory.shift();
                gameState.board[firstMoveIndex] = null;
            }
            // 更新棋盘
            gameState.board[index] = gameState.currentPlayer;
            gameOtherState.moveHistory.push(index);
            // 检查是否有获胜者
            gameState.winner = checkWinner(gameState.board);
            gameState.winner ? gameState.status = 2 : gameState.status = 1;
            // 切换玩家
            gameState.currentPlayer = gameState.currentPlayer === 'X' ? 'O' : 'X';
​
            for (let [id, player] of gameOtherState.players) {
                allClients.get(id).emit('serverData', {
                    player: player,
                    chessSet: gameState.currentPlayer === player,
                    removingIndex: gameOtherState.moveHistory.length === 6 ? gameOtherState.moveHistory[0] : null,
                    ...gameState
                });
            }
        }
    });
​
    // 处理客户端断开连接
    socket.on('disconnect', () => {
        let xo = gameOtherState.players.get(socket.id);
        gameOtherState.players.delete(socket.id);
        console.log(`玩家${xo}退出了连接...`);
        let allClients = io.sockets.sockets;
        gameState = {
            // 棋盘
            board: Array(9).fill(null),
            // 游戏状态,0: 未开始,1: 进行中,2: 已结束
            status: 0,
            // 当前落子玩家
            currentPlayer: 'X',
            // 胜利玩家
            winner: null,
        }
        gameOtherState.moveHistory = [];
        for (let [id, player] of gameOtherState.players) {
            allClients.get(id).emit('serverData', {
                player: player,
                chessSet: gameState.currentPlayer === player,
                removingIndex: gameOtherState.moveHistory.length === 6 ? gameOtherState.moveHistory[0] : null,
                ...gameState
            });
        }
​
    });
});
​
// 启动服务器
server.listen(PORT, () => {
    console.log(`Server is running on port ${PORT}`);
});
​
// 检查获胜者的函数
function checkWinner(board) {
    const lines = [
        [0, 1, 2],
        [3, 4, 5],
        [6, 7, 8],
        [0, 3, 6],
        [1, 4, 7],
        [2, 5, 8],
        [0, 4, 8],
        [2, 4, 6],
    ];
    for (let line of lines) {
        const [a, b, c] = line;
        if (board[a] && board[a] === board[b] && board[a] === board[c]) {
            return board[a];
        }
    }
    return null;
}

那么客户端需要处理的就是,将棋盘,对局提示等信息展示出来,再加点事件动作调用到服务端的接口即可

代码语言:javascript
复制
<!DOCTYPE html>
<html lang="zh">
​
<head>
    <meta charset="UTF-8">
    <title>井字棋</title>
    <style>
        .tic-tac-toe {
            text-align: center;
            display: flex;
            flex-direction: column;
            align-items: center;
        }
        .board {
            display: grid;
            grid-template-columns: repeat(3, 100px);
            grid-gap: 5px;
        }
        .cell {
            width: 100px;
            height: 100px;
            border: 1px solid #000;
            display: flex;
            align-items: center;
            justify-content: center;
            font-size: 2em;
            cursor: pointer;
        }
        .cell.removing {
            color: red;
        }
        .winner {
            color: green;
            margin-top: 20px;
        }
        .resetGame {
            margin: 20px;
        }
    </style>
</head>
​
<body>
    <div id="app" v-cloak>
        <div class="tic-tac-toe">
            <h1>井字棋</h1>
            <div>{{ tips }}</div>
            <div class="board">
                <div v-for="(cell, index) in board" :key="index" class="cell" :class="{ removing: isRemoving(index) }" @click="makeMove(index)">
                    {{ cell }}
                </div>
            </div>
            <div v-if="winner" class="winner">
                {{ winner }} wins!
            </div>
        </div>
    </div>
​
    <script src="js/socket.io.js"></script>
    <script src="js/vue@3.3.0.js"></script>
    <script>
        const { createApp, ref } = Vue;
        const socket = io('http://localhost:3000');
​
        const app = Vue.createApp({
            setup() {
                const board = ref(Array(9).fill(null));
                const player = ref('X');
                const winner = ref(null);
                const tips = ref("等待对局开始...");
                const removingIndex = ref(null);
                const chessSetRef = ref(false);
​
                const makeMove = (index) => {
                    if (chessSetRef.value) {
                        socket.emit('makeMove', { index });
                    }
                };
​
                const isRemoving = (index) => {
                    removingIndex.value = index;
                };
​
                // 接收服务端数据
                socket.on('serverData', (data) => {
                    let { status, chessSet } = data;
                    board.value = data.board;
                    player.value = data.player;
                    if (status === 0) {
                        tips.value = `等待对局开始...`;
                    } else if (status === 1) {
                        chessSetRef.value = chessSet;
                        if (chessSet) {
                            tips.value = `对局进行中,请落子`;
                        } else {
                            tips.value = `对局进行中,等待对方落子`;
                        }
                    } else if (status === 2) {
                        winner.value = data.winner;
                        if (winner === null) {
                            tips.value = `对局结束,平局!`;
                        } else {
                            tips.value = `对局结束,${winner.value} 获胜!`;
                        }
                    }
                    if (data.removingIndex !== null) {
                        removingIndex.value = data.removingIndex;
                    }
                });
​
                return {
                    board,
                    tips,
                    winner,
                    makeMove,
                    isRemoving
                };
            },
        });
        app.mount("#app");
    </script>
</body>
​
</html>

细节我就不讲了,我们启动服务端,并打开两个浏览器网页作为客户端,效果如下

result
result

四、最后

通过本个案例,是否能够窥见网络游戏逻辑的一角。

网络游戏分为客户端与服务端,服务端负责计算,客户端负责展示与交付

服务端与客户端之间负责数据的同步与交互,只需要传输必要的数据即可。如果网络环境不好,即时对战类型的网游的体验就不好。

写到了这里,我就突然想到了以前非常火的一款游戏,叫做绝地求生PUBG,我们叫做吃鸡。

还记得游戏中,有个锁血挂,这一定是客户端维护了人物自己的血条,再上传到服务端;那么外挂只需要串改本地文件,一直给服务端提供自己固定血量的信息即可。

所以对于这种关键信息的计算,一定要交给服务端来进行。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、前言
  • 二、SocketIO
  • 三、联机井字棋
  • 四、最后
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档