当前位置: 首页 > article >正文

网页五子棋——对战前端

目录

对战时序图

约定前后端交互接口

客户端实现

game_room.html

 game_room.css

board.js

setScreenText

initGame()

初始化 WebSocket

发送落子请求

处理落子响应


在本篇文章中,我们就来实现网页五子棋的最后一个模块,也是最重要的一个模块——对战模块

在进行对战时,也需要使用到 WebSocket

对战时序图

我们来理解一下 对战过程:

1. 玩家进入游戏房间后,客户端与服务器建立连接

2. 当两个玩家都进入游戏房间时,服务器推送数据准备就绪响应

3. 先手方玩家开始落子,客户端发送落子请求

4. 服务器进行相关处理,并返回落子响应

5. 客户端接收到落子响应,显示对应棋子,并判断是否有玩家胜利,若无,交换落子玩家

6. 玩家落子,客户端发送落子响应

......

7. 双方玩家持续落子,直到一方玩家胜利(假设 player1 胜利),服务器推送落子响应,并进行游戏房间销毁,修改玩家对应分数等操作

8. 客户端接接收到落子响应,显示对应棋子,此时有玩家胜利,显示游戏胜利 / 游戏失败,并显示 返回游戏大厅 按钮

约定前后端交互接口

我们首先来分析可能会用到的接口:

一个玩家进入游戏房间时,此时显示等待对手进入游戏房间

直到两个玩家都成功进入游戏房间,返回 游戏准备就绪响应,表示可以开始下棋了

当玩家点击对应位置时,发送落子请求,表示在对应位置落子,服务器进行处理后,返回落子响应

因此,我们约定连接 url 为:ws://127.0.0.1:8080/game

[连接响应]

{
    "code": 200,
    "data": {
        "roomId": "2c7446d1-5105-49e6-8b6b-c935120c25de", // 房间号
        "thisUserId": 1, // 玩家自己的 id
        "thatUserId": 2, // 对手的 id
        "whiteUserId": 1 // 先手方 id
    },
    "errorMessage": ""
}

[落子请求]

{
    "userId": 1, // 玩家自己的 id
    "row": 6, // 落子位置——行
    "col": 5 // 落子位置——列
}

[落子响应]

{
    "code": 200,
    "data": {
        "userId": 1, // 落子玩家 id
        "row": 1, // 落子位置——行
        "col": 2, // 落子位置——列
        "winner": 0 // 获胜玩家 id
    },
    "errorMessage": ""
}

接着,我们先来实现客户端相关逻辑 

客户端实现

game_room.html

我们首先创建 game_room.html 表示对战页面

game_room.html 页面主要包含两个部分:棋盘显示当前状态的 div

其中,棋盘我们使用 canvas 来进行绘制:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>游戏房间</title>
</head>
<body>
    <div class="container">
        <div>
            <div>
                <canvas id="chess" width="450px" height="450px"></canvas>
            </div>
            <div id="screen">等待对手玩家进入游戏房间...</div>
        </div>        
    </div>
</body>
</html>

 game_room.css

创建 game_room.css,先对 screen 进行设置:

#screen {
    width: 450px;
    height: 50px;
    margin-top: 10px;
    background-color: antiquewhite;
    font-size: 22px;
    line-height: 50px;
    text-align: center;
    border-radius: 10px;
}

而对于棋盘和棋子的绘制,我们统一在 board.js 中实现

board.js

首先,我们创建一个全局变量 gameInfo,表示游戏和玩家相关信息:

gameInfo = {
    roomId: null, // 房间 id
    thisUserId: null, // 当前玩家id
    thatUserId: null, // 对手玩家 id
    isWhite: true, // 是否是先手
}

setScreenText

接着,我们定义一个 setScreenText 方法,由于在落子时显示提示信息:

function setScreenText(me) {
    let screen = document.querySelector('#screen');
    if (me) {
        screen.innerHTML = "轮到你落子了!";
    } else {
        screen.innerHTML = "轮到对方落子了!";
    }
}

传递一个 布尔类型 的参数,若为 true,则表示当前轮到玩家落子,显示对应提示信息;若为 false,则表示当前轮到对手落子,也显示对应提示信息

initGame()

接着,我们需要对游戏进行初始化,因此,我们定义一个 initGame() 方法,来进行相关逻辑判断和棋盘/棋子的绘制操作:

function initGame() {
    // 是我下还是对方下. 根据服务器分配的先后手情况决定
    let me = gameInfo.isWhite;
    // 游戏是否结束
    let over = false;
    let chessBoard = [];
    //初始化chessBord数组(表示棋盘的数组)
    for (let i = 0; i < 15; i++) {
        chessBoard[i] = [];
        for (let j = 0; j < 15; j++) {
            chessBoard[i][j] = 0;
        }
    }
}

定义相关变量: 

me 表示当前是轮到自己落子

over 表示游戏是否结束

chessBoard 表示游戏棋盘

接着对棋盘进行初始化,定义一个 15 * 15 大小的棋盘,并将其中元素都填充为 0,这样方便我们后续进行判断当前位置是否有子(若为 0,则未落子;若不为 0,则表示当前位置已有玩家落子)

接着,为棋盘添加背景图片:

    let chess = document.querySelector('#chess');
    let context = chess.getContext('2d');
    context.strokeStyle = "#BFBFBF";
    // 背景图片
    let logo = new Image();
    logo.src = "img/chessboard.jpg";
    logo.onload = function () {
        context.drawImage(logo, 0, 0, 450, 450);
        initChessBoard();
    }

其中,getContext() 方法用于获取一个 绘图的上下文对象,获取到的对象(context)是用来进行绘图操作的工具,其中需要接收一个参数,用于指定所需的绘图上下文类型

在这里,我们传递 2d,表示 二维绘图 上下文,常用于平面图形的绘制、动画等

然后使用 strokeStyle 指定路径的颜色(棋盘网络的颜色)

然后定义一个 Image 对象,通过 src 属性指定图像的来源(图像可自行指定)

onload 是图像加载完毕时调用的方法

当图像加载完毕后,就使用 drawImage(img, dx, dy, dw, dh) 方法将绘制到 canvas,

其中:

dx 和 dy 是图像 x 坐标 和 y 坐标的起始位置

dw 和 dh 是图像绘制在 Canvas 上的宽度和高度

我们让图片充满整个画布:

    logo.onload = function () {
        context.drawImage(logo, 0, 0, 450, 450);
        initChessBoard();
    }

接着,我们在 initChessBoard 方法中,绘制棋盘网络:

    function initChessBoard() {
        for (let i = 0; i < 15; i++) {
            context.moveTo(15 + i * 30, 15);
            context.lineTo(15 + i * 30, 430);
            context.stroke();
            context.moveTo(15, 15 + i * 30);
            context.lineTo(430, 15 + i * 30);
            context.stroke();
        }
    }

通过一个 for 循环,在画布上循环画线:

其中:

context.moveTo(15 + i * 30, 15);

context.lineTo(15 + i * 30, 430);

context.stroke();

用于绘制从 (15 + i * 30, 15)  (15 + i * 30, 430) 的竖线

通过 15 + i * 30 计算出每个竖线的 x 坐标

15 和 430 分别表示竖线的 起始 和 结束 的 y 坐标

例如,i = 1,此时 x = 45:

moveTo(45, 15) 表示将画笔移动到 (45, 15)的坐标位置,不绘制任何内容:

接着 lineTo(45, 430) 表示从当前位置画一条直线到 (45, 430)坐标:

最后 stroke() 方法会让这条线生效

接着,绘制横线:

context.moveTo(15, 15 + i * 30);

context.lineTo(430, 15 + i * 30);

context.stroke();

i 控制横线的 y 坐标,通过 15 + i * 30 计算出每条横线的 y 坐标,15 和 430 是横线的 x 坐标的起始值和结束值

接着,我们通过 onStep 方法,对棋子进行绘制:

    function oneStep(row, col, isWhite) {
        context.beginPath();
        context.arc(15 + col * 30, 15 + row * 30, 13, 0, 2 * Math.PI);
        context.closePath();
        var gradient = context.createRadialGradient(15 + col * 30 + 2, 15 + row * 30 - 2, 13, 15 + col * 30 + 2, 15 + row * 30 - 2, 0);
        if (!isWhite) {
            gradient.addColorStop(0, "#0A0A0A");
            gradient.addColorStop(1, "#636766");
        } else {
            gradient.addColorStop(0, "#D1D1D1");
            gradient.addColorStop(1, "#F9F9F9");
        }
        context.fillStyle = gradient;
        context.fill();
    }

 该方法需要传递 3 个参数:

row:棋子所在的行,在棋盘上对应 y 坐标

col:棋子所在的列,在棋盘上对应 x 坐标

isWhite:决定棋子是白色(true)还是黑色(false)

首先 context.beginPath() 表示开始绘制一个新的路径,所有接下来的绘图操作都会被视为这个路径的一部分,直到路径被关闭或填充

接着调用 arc 方法,绘制一个圆形:

context.arc(15 + col * 30, 15 + row * 30, 13, 0, 2 * Math.PI)

15 + col * 30 和 15 + row * 30 :圆心的x 坐标 和 y 坐标,15 为偏移量,col * 30 表示每列之间的水平间距是 30 px,通过这个计算方式,每个圆会水平间隔 30 像素(同样的,row * 30 表示每行之间的垂直间距是 30 像素。每个圆会垂直间隔 30 像素)

13 :表示圆的半径为 13 px

0 和  2 * Math.PI:分别表示圆弧的的起始角度和结束角度。由于起始角度为 0,结束角度为  2 * Math.PI(360 度),这意味着这个弧线将覆盖完整的圆

接着,创建渐变色

createRadialGradient(x1, y1, r1, x2, y2, r2)

var gradient = context.createRadialGradient(15 + col * 30 + 2, 15 + row * 30 - 2, 13, 15 + col * 30 + 2, 15 + row * 30 - 2, 0)

 (15 + col * 30 + 2, 15 + row * 30 - 2):渐变的起始点坐标,也就是渐变的中心位置

13:起始半径

(15 + col * 30 + 2, 15 + row * 30 - 2):渐变的结束点坐标,也就是渐变的结束位置,终止点的坐标和起始点的坐标相同,也就是渐变的终点和起点位于同一位置

0:结束半径

由于 13 是起始半径,0 为结束半径,这样就形成了一个从圆心到边缘渐变的效果,即渐变从一个较大的半径逐渐过渡到一个完全没有半径的点

创建径向渐变是为了在圆中实现颜色从中心向外扩展的效果。将这个渐变应用到棋子的填充,从而创造出从圆心逐渐改变颜色的视觉效果

接着,根据 isWhite 添加渐变色的颜色停顿点:

if (!isWhite) {
    gradient.addColorStop(0, "#0A0A0A");
    gradient.addColorStop(1, "#636766");
} else {
    gradient.addColorStop(0, "#D1D1D1");
    gradient.addColorStop(1, "#F9F9F9");
}

若是黑色棋子(!isWhite)),则从深色 #0A0A0A(黑色)到浅灰色 #636766

如果是白色棋子(isWhite),则从浅灰色 #D1D1D1 到接近白色的 #F9F9F9

最后,设置填充样式并填充棋子:

        context.fillStyle = gradient;

        context.fill();

当玩家点击棋盘对应位置时,就需要绘制对应棋子(先手为白子,后手为黑子)

接下来,我们就来添加对应的点击事件:

    chess.onclick = function (e) {
        // 判断游戏是否结束
        if (over) {
            return;
        }
        // 当前是否轮到 我 落子
        if (!me) {
            return;
        }
        // 获取棋子的 x 和 y 坐标
        let x = e.offsetX;
        let y = e.offsetY;
        // 注意, 横坐标是列, 纵坐标是行
        let col = Math.floor(x / 30);
        let row = Math.floor(y / 30);
        if (chessBoard[row][col] == 0) {
            // TODO 发送坐标给服务器
        }
    }

其中,使用 Math.floor 向下取整,让每次点击操作对应到网格线上

为什么是 / 30 ?

这是因为整个棋盘的大小是 450 * 450,而棋盘上是 15 行,15 列

因此,每行每列占用 30 px

当玩家未点击到网格线上时:

此时就需要将其对应到对应的网格线上:

点击之后,若当前位置没有玩家落子,就可以将坐标发送给服务器,服务器进行对应处理(后续实现)

再绘制棋子,并将当前位置标记为 1 (表示这个位置已经有棋子了)

initGame() 方法完整代码:

function initGame() {
    // 是我下还是对方下. 根据服务器分配的先后手情况决定
    let me = gameInfo.isWhite;
    // 游戏是否结束
    let over = false;
    let chessBoard = [];
    //初始化chessBord数组(表示棋盘的数组)
    for (let i = 0; i < 15; i++) {
        chessBoard[i] = [];
        for (let j = 0; j < 15; j++) {
            chessBoard[i][j] = 0;
        }
    }
    let chess = document.querySelector('#chess');
    let context = chess.getContext('2d');
    context.strokeStyle = "#BFBFBF";
    // 背景图片
    let logo = new Image();
    logo.src = "img/chessboard.jpg";
    logo.onload = function () {
        context.drawImage(logo, 0, 0, 450, 450);
        initChessBoard();
    }

    // 绘制棋盘网格
    function initChessBoard() {
        for (let i = 0; i < 15; i++) {
            context.moveTo(15 + i * 30, 15);
            context.lineTo(15 + i * 30, 430);
            context.stroke();
            context.moveTo(15, 15 + i * 30);
            context.lineTo(430, 15 + i * 30);
            context.stroke();
        }
    }

    // 绘制一个棋子
    /**
     * 
     * @param {*} row 行 对应 y 坐标
     * @param {*} col 列 对应 x 坐标
     * @param {*} isWhite
     */
    function oneStep(row, col, isWhite) {
        context.beginPath();
        context.arc(15 + col * 30, 15 + row * 30, 13, 0, 2 * Math.PI);
        context.closePath();
        var gradient = context.createRadialGradient(15 + col * 30 + 2, 15 + row * 30 - 2, 13, 15 + col * 30 + 2, 15 + row * 30 - 2, 0);
        if (!isWhite) {
            gradient.addColorStop(0, "#0A0A0A");
            gradient.addColorStop(1, "#636766");
        } else {
            gradient.addColorStop(0, "#D1D1D1");
            gradient.addColorStop(1, "#F9F9F9");
        }
        context.fillStyle = gradient;
        context.fill();
    }

    chess.onclick = function (e) {
        // 判断游戏是否结束
        if (over) {
            return;
        }
        // 当前是否轮到 我 落子
        if (!me) {
            return;
        }
        // 获取棋子的 x 和 y 坐标
        let x = e.offsetX;
        let y = e.offsetY;
        // 注意, 横坐标是列, 纵坐标是行
        let col = Math.floor(x / 30);
        let row = Math.floor(y / 30);
        if (chessBoard[row][col] == 0) {
            // TODO 发送坐标给服务器, 服务器要返回结果
            oneStep(row, col, gameInfo.isWhite);
            chessBoard[row][col] = 1;
        }
    }

}

初始化 WebSocket

接着,我们在 board.js 中加入 WebSocket 连接代码,实现前后端交互:

创建 WebSocket 对象,并挂载回调函数:

// 初始化 websocket
let webSocketUrl = "ws://127.0.0.1:8080/game";
let webSocket = new WebSocket(webSocketUrl);
// 处理游戏就绪
webSocket.onmessage = function(e) {
    console.log("游戏就绪");
}

// 监听页面关闭事件,在页面关闭之前,手动调用 webSocket 的 close 方法
// 防止连接还没断开就关闭窗口
window.onbeforeunload = function() {
    webSocket.close();
}
// 连接发生错误时,回到游戏大厅
webSocket.onerror = function() {
    alert("连接异常!即将回到游戏大厅!");
    location.replace("/game_hall.html");
}

当连接异常时,跳转到游戏大厅页面

在页面关闭之前,调用 close 方法关闭 WebSocket 连接

接着,我们实现 onmessage 方法,处理游戏就绪响应:

若 code != 200,则响应出现异常,我们先不进行处理,后续再进行补充

若 code = 200,则响应成功,对 gameInfo 和 棋盘进行初始化,并设置显示内容

webSocket.onmessage = function(e) {
    console.log("游戏就绪");
    let resp = JSON.parse(e.data);
    if (resp.code != 200) {
        alert("异常情况:" + resp.errorMessage);
        return;
    }
    let readyResult = resp.data;
    gameInfo.roomId =  readyResult.roomId;
    gameInfo.thisUserId = readyResult.thisUserId;
    gameInfo.thatUserId = readyResult.thatUserId;
    gameInfo.isWhite = (readyResult.whiteUserId == gameInfo.thisUserId);
    // 初始化棋盘
    initGame();
    // 设置显示区域内容
    setScreenText(gameInfo.isWhite);
}

发送落子请求

我们修改 onclick 函数,在点击落子时发送落子响应

而对于 绘制棋子 和 修改 chessBoard 操作,我们则将其放到接收到落子响应时进行处理

实现 send 方法,用于发送落子请求:

    function send(row, col) {
        let req = {
            userId: gameInfo.thisUserId,
            row: row,
            col: col
        }
        webSocket.send(JSON.stringify(req));
    }

 若当前位置未落子,发送落子请求:

        if (chessBoard[row][col] == 0) {
            send(row, col);
        }

处理落子响应

在前面的 onmessage 方法中,主要是针对 游戏就绪响应 来进行处理的,而在初始化之后,就只需要对落子响应进行处理了

因此,我们可以直接在 initGame 方法中修改 webSocket.onmessage 方法,让方法内部主要是针对落子响应进行处理:

同样的:

若 code != 200,则响应出现异常,我们先不进行处理,后续再进行补充

若 code = 200,则响应成功,进行后续逻辑处理

落子响应处理:

1. 判断 code 是否为 200,若不为,则打印异常情况,并返回

2. 判断落的子是自己的还是对手的

3. 绘制对应颜色的棋子

4. 标记当前位置有子

5. 交换落子轮次

6. 判定胜负 (winner 不为 0 时,游戏结束)

    webSocket.onmessage = function(e) {
        let resp = JSON.parse(e.data);
        if (resp.code != 200) {
            alert("异常情况:" + resp.errorMessage);
            return;
        }
        let gameRes = resp.data;
        if (gameInfo.thisUserId == gameRes.userId) {
            // 自己落的子,绘制自己颜色的子
            oneStep(gameRes.row, gameRes.col, gameInfo.isWhite);
        } else if (gameInfo.thatUserId == gameRes.userId) {
            // 对手的子,绘制对手颜色的子
            oneStep(gameRes.row, gameRes.col, !gameInfo.isWhite);
        } else {
            console.log("落子异常");
            return;
        }
        // 标记此处有棋子
        chessBoard[gameRes.row][gameRes.col] = 1;
        // 交换轮次
        me = !me;
        let screenDiv = document.querySelector('#screen');
        // 判定胜负
        if(gameRes.winner != 0) {
            if(gameRes.winner == gameInfo.thisUserId) {
                screenDiv.innerHTML = "恭喜你!你赢了!";
            } else if(gameRes.winner == gameInfo.thatUserId) {
                screenDiv.innerHTML = "很遗憾!你输了..."
            } else if(gameRes.winner == -1) {
                screenDiv.innerHTML = "平局";
            } else {
                console.log("字段错误!" + gameRes.winner);
            }
        } else {
            setScreenText(me);
        }
    }

其中,无论是自己落子还是对手落子,我们都将落子位置标记为1,这是因为在客户端不需要进行获胜的相关业务逻辑判断,也就不需要区分棋子

当游戏结束时,我们为其添加一个按钮,能够返回游戏大厅:

            // 增加一个按钮,让玩家点击按钮后再返回到游戏大厅
            let backBtn = document.createElement('button');
            backBtn.id = "back-button";
            backBtn.innerHTML = '回到大厅';
            backBtn.onclick = function() {
                location.replace("/game_hall.html");
            }
            let fatherDiv = document.querySelector('.container>div');
            fatherDiv.appendChild(backBtn);

在 game_room.css 中添加对应样式: 

#back-button {
    width: 450px;
    height: 50px;
    background-color: rgb(237, 169, 79);
    font-size: 20px;
    color: gray;
    border-radius: 10px;
    text-align: center;
    line-height: 50px;
    margin-top: 10px;
    border: none;
}

处理落子响应完整代码:

    webSocket.onmessage = function(e) {
        let resp = JSON.parse(e.data);
        if (resp.code != 200) {
            alert("异常情况:" + resp.errorMessage);
            return;
        }
        let gameRes = resp.data;
        if (gameInfo.thisUserId == gameRes.userId) {
            // 自己落的子,绘制自己颜色的子
            oneStep(gameRes.row, gameRes.col, gameInfo.isWhite);
        } else if (gameInfo.thatUserId == gameRes.userId) {
            // 对手的子,绘制对手颜色的子
            oneStep(gameRes.row, gameRes.col, !gameInfo.isWhite);
        } else {
            console.log("落子异常");
            return;
        }
        // 标记此处有棋子
        chessBoard[gameRes.row][gameRes.col] = 1;
        // 交换轮次
        me = !me;
        let screenDiv = document.querySelector('#screen');
        // 判定胜负
        if(gameRes.winner != 0) {
            if(gameRes.winner == gameInfo.thisUserId) {
                screenDiv.innerHTML = "恭喜你!你赢了!";
            } else if(gameRes.winner == gameInfo.thatUserId) {
                screenDiv.innerHTML = "很遗憾!你输了..."
            } else if(gameRes.winner == -1) {
                screenDiv.innerHTML = "平局";
            } else {
                console.log("字段错误!" + gameRes.winner);
            }
            // 增加一个按钮,让玩家点击按钮后再返回到游戏大厅
            let backBtn = document.createElement('button');
            backBtn.id = "back-button";
            backBtn.innerHTML = '回到大厅';
            backBtn.onclick = function() {
                location.replace("/game_hall.html");
            }
            let fatherDiv = document.querySelector('.container>div');
            fatherDiv.appendChild(backBtn);
        } else {
            setScreenText(me);
        }
    }

至此,关于对战模块的前端代码我们就基本实现完毕了,更多的内容我们在实现服务器端逻辑时继续补充


http://www.kler.cn/a/553419.html

相关文章:

  • Swupdate升级不强制依赖version字段
  • 软考高级《系统架构设计师》知识点(七)
  • 全局动态组件uniapp(vue)
  • Qt常用控件之复选按钮QCheckBox
  • Spring Bean的生命周期执行流程
  • 解决 Mac 只显示文件大小,不显示目录大小
  • Python--数据类型(中)
  • 【数据挖掘】数据仓库
  • 《深度学习》——自然语言处理(NLP)
  • DeepSeek-R1:使用KTransformers部署(保姆级教程)
  • 月之暗面-KIMI-发布最新架构MoBA
  • 实现历史数据的插入、更新和版本管理-拉链算法
  • 我的2025年计划
  • 红外图像与可见光图像在目标检测时的区别
  • 【数据分析】通过个体和遗址层面的遗传相关性网络分析
  • 浪潮信息元脑R1服务器重塑大模型推理新标准
  • 【核心算法篇十四】《深度解密DeepSeek量子机器学习:VQE算法加速的黑科技与工程实践》
  • MySQL 多表查询技巧和高阶操作实例1
  • Coze扣子怎么使用更强大doubao1.5模型
  • Brave132编译指南 MacOS篇 - 构筑开发环境(二)