网页五子棋对战

网页五子棋对战

项目源码:源码地址

项目背景

实现一个网页版的五子棋对战程序,支持以下核心功能:

  • 用户模块:用户注册,用户登录,用户信息展示
  • 匹配模块:根据用户的天梯积分来决定用户的匹配对手积分
  • 对战模块:两个玩家在网页端进行五子棋对战功能

核心技术

  • Spring/SpringMVC/SpringBoot

  • WebSocket

  • MySQL

  • MyBatis

  • HTML/CSS/JavaScript

需求分析和概要设计

用户模块

主要负责用户的注册和登录,用户信息管理功能

使用MySQL数据库进行数据的存储

客户端提供一个可注册登录的页面

服务端基于 Spring和MyBatis来进行数据库的增删改查

匹配模块

用户登录成功,则进入游戏大厅页面

游戏大厅中, 能够显示用户的名字,天梯分数,比赛场数和获胜场数

同时显示一个 “匹配按钮”

点击匹配按钮则用户进入匹配队列, 并且界面上显示为 “取消匹配”

再次点击则把用户从匹配队列中删除

如果匹配成功, 则跳转进入到游戏房间页面

页面加载时和服务器建立 websocket 连接,双方通过 websocket 来传输 “开始匹配”, “取消匹配”, “匹配成功” 这样的信息

对战模块

玩家匹配成功, 则进入游戏房间页面

在游戏房间页面中, 能够显示五子棋棋盘 玩家点击棋盘上的位置实现落子功能

并且五子连珠则触发胜负判定, 显示 你赢了, 你输了

页面加载时和服务器建立 websocket 连接 双方通过 websocket 来传输 “准备就绪”, 落子位置, 胜负 这样的信息

  • 准备就绪: 两个玩家均连上游戏房间的 websocket 时, 则认为双方准备就绪
  • 落子位置: 有一方玩家落子时, 会通过 websocket 给服务器发送落子的用户信息和落子位置, 同时服务器再将这样的信息返回给房间内的双方客户端 然后客户端根据服务器的响应来绘制棋子位置
  • 胜负: 服务器判定这一局游戏的胜负关系 如果某一方玩家落子, 产生了五子连珠, 则判定胜负并返回胜负信息 或者如果某一方玩家掉线(比如关闭页面), 也会判定对方获胜,并且增加获胜方的天梯积分

项目创建

使用IDEA创建SpringBoot2.x的项目,引入以下依赖
在这里插入图片描述

最后pom.xml文件配置如下

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.12</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.example</groupId>
    <artifactId>java_gobang</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>java_gobang</name>
    <description>java_gobang</description>
    <properties>
        <java.version>1.8</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.yaml</groupId>
            <artifactId>snakeyaml</artifactId>
            <version>2.0</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-websocket</artifactId>
        </dependency>
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>2.3.1</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

配置yml

# 配置数据库的连接字符串
spring:
  datasource:
    url: jdbc:mysql://127.0.0.1/java_gobang?characterEncoding=utf8
    username: root
    password: 111111
    driver-class-name: com.mysql.cj.jdbc.Driver

# 设置 Mybatis 的 xml 保存路径
mybatis:
  mapper-locations: classpath:mapper/*Mapper.xml
  configuration: # 配置打印 MyBatis 执行的 SQL
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

# 配置打印 MyBatis 执行的 SQL
logging:
  level:
    com:
      example:
        demo: debug

实现用户模块

编写数据库代码

数据库设计

设计八个参数:

id 表示该用户的id,设置自增主键;username表示该用户用户名,设置唯一约束;password表示该用户密码,非空

score 表示该用户的天梯积分;total_count 表示该用户进行的总场次;win_count表示该用户胜利场次

create_time 表示该用户创建时间 ;update_time表示该用户被动修改时间,然后插入几个用户当做示例

create database if not exists java_gobang;

use java_gobang;

drop table if exists userinfo;

create table userinfo(
    id int primary key auto_increment,
    username varchar(50) not null unique,
    password varchar(65) not null,
    score int not null default 1000,
    total_count int not null default 0,
    win_count int not null default 0,
    create_time decimal not null default now(),
    update_time decimal not null default now()
);

insert into user values(null, '张三', '123');
insert into user values(null, '李四', '123');
insert into user values(null, '王五', '123');
insert into user values(null, '赵六', '123');
insert into user values(null, '田七', '123');
insert into user values(null, '朱八', '123');

创建实体类
package com.example.java_gobang.entity;

import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;

import java.time.LocalDateTime;

@Data
public class User {
    private int id;
    private String username;
    private String password;
    private int score;
    private int total_count;
    private int win_count;
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private LocalDateTime create_time;
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private LocalDateTime update_time;
}

创建userMapper
package com.example.java_gobang.mapper;

import com.example.java_gobang.entity.User;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

@Mapper
public interface UserMapper {

    /**
     * 添加一条用户信息
     * @param user 要添加的用户
     * @return 返回受影响的行数
     */
    int insertUser(User user);

    /**
     * 根据用户名查询用户信息, 用于登录验证
     * @param username 用户名
     * @return 返回查询到的用户信息
     */
    User selectUserByName(@Param("username") String username);

    /**
     * 通过id寻找用户
     * @param id 用户id
     * @return 返回查找到的用户
     */
    User selectUserById(@Param("id") Integer id);

    /**
     * 胜方修改数据
     * @param id 胜方用户id
     */
    void userWinUpdate(@Param("id") Integer id);

    /**
     * 败方修改数据
     * @param id 败方id
     */
    void userLoseUpdate(@Param("id") Integer id);
}

编写userMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.java_gobang.mapper.UserMapper">

    <insert id="insertUser">
        insert into userinfo(username, password) values (#{username}, #{password})
    </insert>

    <select id="selectUserByName" resultType="com.example.java_gobang.entity.User">
        select * from userinfo where username = #{username};
    </select>

    <select id="selectUserById" resultType="com.example.java_gobang.entity.User">
        select * from userinfo where id = #{id}
    </select>

    <update id="userWinUpdate">
        update userinfo set score = score + 30, total_count = total_count + 1, win_count = win_count + 1
        where id = #{id}
    </update>

    <update id="userLoseUpdate">
        update userinfo set score = score - 30, total_count = total_count + 1
        where id = #{id}
    </update>
</mapper>

sql语句实现顺序和userMapper顺序一致


前后端接口

用户模块我们需要返回统一的格式,该格式有三个参数,分别是status表示状态码,statusMsg表示状态码描述,data是传输数据本体

代码如下:

package com.example.java_gobang.common;

import lombok.Data;

import java.io.Serializable;

@Data
public class AjaxResult implements Serializable {
    // 状态码
    private int status;
    // 状态码描述
    private String statusMsg;
    // 返回的数据
    private Object data;

    /**
     * 操作成功调用的函数
     */
    public static AjaxResult success(Object data) {
        AjaxResult ajaxResult = new AjaxResult();
        ajaxResult.setStatus(200);
        ajaxResult.setStatusMsg("");
        ajaxResult.setData(data);
        return ajaxResult;
    }

    public static AjaxResult success(int status, Object data) {
        AjaxResult ajaxResult = new AjaxResult();
        ajaxResult.setStatus(status);
        ajaxResult.setStatusMsg("");
        ajaxResult.setData(data);
        return ajaxResult;
    }

    public static AjaxResult success(int status, String statusMsg, Object data) {
        AjaxResult ajaxResult = new AjaxResult();
        ajaxResult.setStatus(status);
        ajaxResult.setStatusMsg(statusMsg);
        ajaxResult.setData(data);
        return ajaxResult;
    }

    /**
     * 失败调用的函数
     */
    public static AjaxResult fail(int status, String statusMsg) {
        AjaxResult ajaxResult = new AjaxResult();
        ajaxResult.setStatus(status);
        ajaxResult.setStatusMsg(statusMsg);
        ajaxResult.setData(null);
        return ajaxResult;
    }

    public static AjaxResult fail(int status, String statusMsg, Object data) {
        AjaxResult ajaxResult = new AjaxResult();
        ajaxResult.setStatus(status);
        ajaxResult.setStatusMsg(statusMsg);
        ajaxResult.setData(data);
        return ajaxResult;
    }
}

登录接口

请求:

POST /login HTTP/1.1
Content-Type: application/x-www-form-urlencoded

username=zhangsan&password=123

响应:data数据中1表示登录成功,0表示登录失败

HTTP/1.1 200 OK
Content-Type: application/json

{
    status:200,
    statusMsg:"",
    data: 1 | 0
}    

注册接口

请求:

POST /register HTTP/1.1
Content-Type: application/x-www-form-urlencoded

username=zhangsan&password=123

响应:data数据中1表示登录成功,0表示登录失败

HTTP/1.1 200 OK
Content-Type: application/json

{
    status:200,
    statusMsg:"",
    data: 1 | 0
}      

获取用户信息接口

接口:

POST /userInfo HTTP/1.1

响应:data数据返回一个用户的信息来显示在游戏大厅中,密码这种私密数据要设置成空字符串

HTTP/1.1 200 OK
Content-Type: application/json

{
	status:200,
    statusMsg:"",
    data: {
        id: 1,
        username: 'zhangsan',
        passsword:"",
        score: 1000,
        total_count: 10,
        win_count: 5,
        create_time: "",
        update_time: ""
    }
    
}    

服务器开发

创建 controller.userController 和 service.userService 两个文件分别为控制层代码和服务层代码

需要实现三个接口,分别是:

  • login :用来实现登录逻辑

  • register :用来实现注册逻辑

  • showuser :用来在游戏大厅中展示个人信息


控制层
package com.example.java_gobang.controller;

import com.example.java_gobang.common.AjaxResult;
import com.example.java_gobang.common.UserSessionUtils;
import com.example.java_gobang.entity.User;
import com.example.java_gobang.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;


@RestController
@RequestMapping("/user")
public class UserController {

    @Autowired
    private UserService userService;

    @PostMapping("/login")
    public AjaxResult userLogin(User user, HttpServletRequest request) {
        if (user == null || !StringUtils.hasLength(user.getUsername())
                || !StringUtils.hasLength(user.getPassword())) {
            return AjaxResult.fail(1, "参数异常");
        }
        boolean row = userService.getUserPasswordService(user.getUsername(), user.getPassword(), request);
        return AjaxResult.success(row);
    }

    @PostMapping("/register")
    public AjaxResult userRegister(User user) {
        if (user == null || !StringUtils.hasLength(user.getUsername())
                || !StringUtils.hasLength(user.getPassword())) {
            return AjaxResult.fail(1, "参数异常");
        }
        int row = userService.addUserService(user);
        return AjaxResult.success(row);
    }

    @PostMapping("/showuser")
    public AjaxResult intiShowUser(HttpServletRequest request) {
        User user = UserSessionUtils.getUser(request);
        if (user == null) {
            return AjaxResult.fail(2, "非法访问");
        }
        int userId = user.getId();
        User resultUser = userService.getUserByIdService(userId);
        return AjaxResult.success(resultUser);
    }
}


服务层
package com.example.java_gobang.service;

import com.example.java_gobang.common.AppVariable;
import com.example.java_gobang.entity.User;
import com.example.java_gobang.mapper.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;

@Service
public class UserService {

    @Autowired
    private UserMapper userMapper;

    public int addUserService(User user) {
        // TODO 需要给注册密码进行加密
        return userMapper.insertUser(user);
    }

    public boolean getUserPasswordService(String username, String password, HttpServletRequest request) {
        // TODO 数据库中的最终密码,要进行解密
        User user = userMapper.selectUserByName(username);
        if (user == null) {
            return false;
        }
        boolean isLogin = password.equals(user.getPassword());
        if (isLogin) {
            // 登录成功后添加 session
            HttpSession session = request.getSession(true);
            user.setPassword("");
            session.setAttribute(AppVariable.USER_SESSION_KEY, user);
            // TODO 每次登录成功后更新数据库密码盐值
        }
        return isLogin;
    }

    public User getUserByIdService(Integer userId) {
        return userMapper.selectUserById(userId);
    }

}

客户端开发

创建注册页面register.html 和 login.html

注册页面和登录页面的CSS:


.login-container {
    height: calc(100% - 50px);

    background-image: linear-gradient(to top, #c1dfc4 0%, #deecdd 100%);
    display: flex;
    justify-content: center;
    align-items: center;
}

.login-dialog {
    width: 400px;
    height: 400px;
    background-color: rgba(255, 255, 255, 0.8);
    border-radius: 10px;
}

/* 标题 */
.login-dialog h3 {
    text-align: center;
    padding: 50px 0;
}

/* 针对一行设置样式 */
.login-dialog .row {
    width: 100%;
    height: 50px;
    display: flex;
    align-items: center;
    justify-content: center;
}

.login-dialog .row span {
    width: 100px;
    font-weight: 700;
}

#username, #password {
    width: 200px;
    height: 40px;

    font-size: 20px;
    line-height: 40px;
    padding-left: 10px;
    border: none;
    outline: none;
    border-radius: 10px;
}

#submit {
    width: 300px;
    height: 50px;

    background-color: rgb(0, 128, 0);
    color: white;

    border: none;
    outline: none;
    border-radius: 10px;

    margin-top: 20px;
}

#submit:active {
    background-color: #666;
}

注册HTML

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>注册</title>
    <link rel="stylesheet" href="css/common.css">
    <link rel="stylesheet" href="css/login.css">
    <script src="js/jquery.min.js"></script>
</head>
<body>
<div class="nav">
    五子棋对战
</div>
<div class="login-container">
    <!-- 登录界面的对话框 -->
    <div class="login-dialog">
        <!-- 提示信息 -->
        <h3>注册</h3>
        <!-- 这个表示一行 -->
        <div class="row">
            <span>用户名</span>
            <input type="text" id="username">
        </div>
        <!-- 这是另一行 -->
        <div class="row">
            <span>密码</span>
            <input type="password" id="password">
        </div>
        <!-- 提交按钮 -->
        <div class="row">
            <button id="submit" onclick="register()">提交</button>
        </div>
    </div>
</div>

<script>
    function register() {
        // 用 jQuery 得到输入框里的值
        var username = jQuery("#username").val();
        var password = jQuery("#password").val();
        // 判断输入框里的值是否有效,无效返回
        if (username == "" || password == "") {
            alert("用户名或密码不能为空!");
            return;
        }
        jQuery.ajax({
            // 用 ajax 向后端传输数据来进行注册
            url: '/user/login',
            type: 'post',
            data: {"username": username, "password": password},
            success: function (result) {
                if (result != null && result.status == 200 && result.data == 1) {
                    // 注册成功,跳转到登录页面
                    alert("注册成功");
                    location.href = "/login.html";
                } else {
                    // 注册失败
                    alert("注册失败,请重试");
                }
            }
        });
    }
</script>
</body>
</html>

登录HTML

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>登录</title>

    <link rel="stylesheet" href="css/common.css">
    <link rel="stylesheet" href="css/login.css">
    <script src="js/jquery.min.js"></script>
</head>
<body>
<div class="nav">
    五子棋对战
</div>
<div class="login-container">
    <!-- 登录界面的对话框 -->
    <div class="login-dialog">
        <!-- 提示信息 -->
        <h3>登录</h3>
        <!-- 这个表示一行 -->
        <div class="row">
            <span>用户名</span>
            <input type="text" id="username">
        </div>
        <!-- 这是另一行 -->
        <div class="row">
            <span>密码</span>
            <input type="password" id="password">
        </div>
        <!-- 提交按钮 -->
        <div class="row">
            <button id="submit" onclick="login()">提交</button>
        </div>
    </div>

</div>

<script>
    function login() {
        // 用 jQuery 得到输入框里的值
        var username = jQuery("#username").val();
        var password = jQuery("#password").val();
        // 判断输入框里的值是否有效,无效返回
        if (username == "" || password == "") {
            alert("用户名或密码不能为空!");
            return;
        }
        jQuery.ajax({
            // 用 ajax 向后端传输数据来进行注册
            url: '/user/login',
            type: 'post',
            data: {"username": username, "password": password},
            success: function (result) {
                if(result != null && result.status == 200 && result.data) {
                    // 登录成功,跳转到大厅页面
                    alert("登录成功");
                    location.href = "/game_hall.html";
                } else {
                    alert("登录失败,请重试");
                }
            }
        });
    }
</script>
</body>
</html>

实现匹配模块

前后端交互接口

WebSocket连接

ws://127.0.0.1:8080/findMatch

请求:

{
    message: 'startMatch' / 'stopMatch',
}

响应1:在服务器收到请求后立即返回用来改变按钮状态

{
    ok: true,                // 是否成功. 比如用户 id 不存在, 则返回 false
    reason: '',                // 错误原因
    message: 'startMatch' / 'stopMatch'
}

响应2:在服务器匹配成功后返回来让两个用户进入游戏房间

{
    ok: true,
    reason: '',
    message: 'matchSuccess',    
}

客户端开发

创建game_hall.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>游戏大厅</title>
    <link rel="stylesheet" href="css/common.css">
    <link rel="stylesheet" href="css/game_hall.css">
</head>
<body>
<div class="nav">五子棋对战</div>
<!-- 整个页面的容器元素 -->
<div class="container">
    <!-- 这个 div 在 container 中是处于垂直水平居中这样的位置的 -->
    <div>
        <!-- 展示用户信息 -->
        <div id="screen"></div>
        <!-- 匹配按钮 -->
        <div id="match-button" onclick="match()">开始匹配</div>
    </div>
</div>

<script src="js/jquery.min.js"></script>
<script>
    function initUser() {
        // 进行游戏大厅初始化,查询该用户信息
        jQuery.ajax({
            url: '/user/showuser',
            type: 'post',
            data: {},
            success: function (result) {
                if (result != null && result.status === 200 && result.data != null) {
                    // 查询成功,添加到页面上
                    var information = "";
                    var person = result.data;
                    information += "用户名:" + person.username + " 天梯积分:" + person.score +
                        " <br>" + "总场次:" + person.total_count + " 胜利场次:" + person.win_count;
                    jQuery("#screen").html(information);
                }
            }
        });
    }

    initUser();
	
    // websocket 连接,创建URL
    var webSocketURL = "ws://" + location.host + "/findmatch";
    var webSocket = new WebSocket(webSocketURL);

    // 当和服务器创建连接时执行的方法
    webSocket.onopen = function () {
        console.log("建立连接");
    }

    // 当连接出现异常时执行的方法
    webSocket.onerror = function () {
        console.log("出现异常");
    }

    // 当连接关闭时执行的方法
    webSocket.onclose = function () {
        console.log("关闭连接");
    }

    // 浏览器直接关闭标签页时执行的方法,要手动把WebSocket关闭
    window.onbeforeunload = function() {
        webSocket.close();
    }
    
    // 点击大厅按钮来控制当前用户是否匹配
    function match() {
        var matchButton = document.querySelector('#match-button');
        if (matchButton.innerHTML === "开始匹配") {
            // 点击按钮后如果按钮内容是开始匹配,说明当前用户需要匹配,给服务器发送请求来开始匹配,将当前用户放入匹配队列
            webSocket.send(JSON.stringify({
                message: 'startMatch'
            }));
        } else if (matchButton.innerHTML === "正在匹配...(点击停止)") {
            // 点击这个按钮表明当前用户需要取消匹配,给服务器发送请求来取消匹配,将当前用户从匹配队列取出
            webSocket.send(JSON.stringify({
                message: 'stopMatch'
            }));
        } else {
            // 表明当前连接出现错误,直接定向到登录界面
            alert("连接断开,请重试!");
            location.replace("/login.html");
        }
    }

    // 处理服务器返回的响应,响应格式
    //{
    //  boolean OK
    //  String reason  = "
    //  String message = ""
    //}
    webSocket.onmessage = function (e) {
        var result = JSON.parse(e.data);

        if (!result.ok) {
            console.log("收到了失败响应" + result.reason);
            return;
        }

        // 根据服务器来修改前端显示的内容
        let matchButton = document.querySelector('#match-button');
        if (result.message === "startMatch") {
            // 如果返回这个参数,就表示服务器已经开始匹配,用户进入匹配队列,前端可以显示正在匹配的字样
            matchButton.innerHTML = '正在匹配...(点击停止)';
        } else if (result.message === "stopMatch") {
            // 返回这个参数表明服务器已经停止匹配
            matchButton.innerHTML = '开始匹配';
        } else if (result.message === "matchSuccess") {
            // 匹配成功,当前用户加载到房间页面中
            location.replace("/game_rome.html");
        } else if (result.message === "repeatConnection") {
            // 返回这个响应参数,表明当前两个相同用户同时在大厅界面或同时在房间页面,或既在大厅界面又在房间页面
            // 多开关闭WebSocket连接,多余的那个用户然后加载到登录界面
            alert(result.reason);
            webSocket.close();
            location.replace("/login.html");
        } else {
            alert("非法响应");
        }
    }

</script>
</body>
</html>

game_html页面的样式CSS

#screen {
    width: 400px;
    height: 200px;
    font-size: 20px;
    background-color: gray;
    color: white;
    border-radius: 10px;

    text-align: center;
    line-height: 100px;
    background-image: linear-gradient(to right, #434343 0%, black 100%);
}

#match-button {
    width: 400px;
    height: 50px;
    font-size: 20px;
    color: white;
    background-color: orange;
    border: none;
    outline: none;
    border-radius: 10px;

    text-align: center;
    line-height: 50px;
    margin-top: 20px;
}

#match-button:active {
    background-color: gray;
}

服务端开发

创建并注册websocket

我们需要创建一个文件来配置前端URL对应后端类

创建config. WebSocketConfig.java

package com.example.java_gobang.config;

import com.example.java_gobang.component.GameHandler;
import com.example.java_gobang.component.MatchHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor;

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

    @Autowired
    private MatchHandler matchHandler;

    @Autowired
    private GameHandler gameHandler;

    // 关联前端url和后端实现类
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        // 通过 .addInterceptors(new HttpSessionHandshakeInterceptor()
        // 这个操作来把 HttpSession 里的属性放到 WebSocket 的 session 中
        // 然后就可以在 WebSocket 代码中 WebSocketSession 里拿到 HttpSession 中的 attribute.
        // 这个是对应房间页面的操作
        registry.addHandler(matchHandler, "/findmatch")
                .addInterceptors(new HttpSessionHandshakeInterceptor());
        // 这个是对应房间对战页面的操作
        registry.addHandler(gameHandler, "/game")
                .addInterceptors(new HttpSessionHandshakeInterceptor());
    }
}


创建MatchHandler来作为前端findmatch的入口

该类需要继承TextWebSocketHandler实现四个接口

  • afterConnectionEstablished :表示连接建立时需要执行的方法
  • handleTextMessage :后端收到请求内容时执行的方法
  • handleTransportError :连接收到异常时执行的方法
  • afterConnectionClosed :连接关闭时执行的方法
@Component
public class MatchHandler extends TextWebSocketHandler {

    private final ObjectMapper objectMapper = new ObjectMapper();

    // 在连接成功上时执行
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {

    }

    // 在收到消息时执行
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        
    }


    // 在连接出现异常时执行
    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        
    }

    // 在连接关闭时执行
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        
    }
}

实现用户管理器

创建 OnlineUserManager 类, 用于管理当前用户的在线状态. 本质上是 哈希表 的结构. key 为用户 id, value 为用户的 WebSocketSession.

借助这个类, 一方面可以判定用户是否是在线, 同时也可以进行方便的获取到 Session 从而给客户端回话.

  • 当玩家建立好 websocket 连接, 则将键值对加入 OnlineUserManager 中.
  • 当玩家断开 websocket 连接, 则将键值对从 OnlineUserManager 中删除.
  • 在玩家连接好的过程中, 随时可以通过 userId 来查询到对应的会话, 以便向客户端返回数据.

由于存在两个页面, 游戏大厅和游戏房间, 使用两个 哈希表 来分别存储两部分的会话.

package com.example.java_gobang.component;

import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketSession;

import java.util.HashMap;

@Component
public class OnlineUserState {
    // 维护用户在游戏大厅的状态
    private final HashMap<Integer, WebSocketSession> userGameHall = new HashMap<>();
    // 维护用户在游戏界面的状态
    private final HashMap<Integer, WebSocketSession> userGameRoom = new HashMap<>();

    // 进入大厅就添加session
    public void enterSessionHall(Integer id, WebSocketSession session) {
        userGameHall.put(id, session);
    }

    // 退出大厅就删除session
    public void exitSessionHall(Integer id) {
        userGameHall.remove(id);
    }

    // 根据用户 id 得到session
    public WebSocketSession getSessionHall(Integer id) {
        return userGameHall.get(id);
    }

	// 进入房间就添加session
    public void enterSessionRoom(Integer id, WebSocketSession session) {
        userGameRoom.put(id, session);
    }

    // 退出房间就删除session
    public void exitSessionRoom(Integer id) {
        userGameRoom.remove(id);
    }

    // 根据用户 id 得到session
    public WebSocketSession getSessionRoom(Integer id) {
        return userGameRoom.get(id);
    }

}

MatchHandler 注册OnlineUserState

@Component
public class MatchHandler extends TextWebSocketHandler {

    @Autowired
    private OnlineUserState onlineUserState;
}

创建匹配请求,响应对象

请求对象来接收前端传来的json,响应对象来给前端传递json字符串

请求对象:前端只传递一个属性,所以只有一个message

package com.example.java_gobang.entity;

import lombok.Data;

@Data
public class MatchRequest {

    private String message;
}

响应对象:进行稍微封装

package com.example.java_gobang.entity;

import lombok.Data;

@Data
public class MatchResponse {

    private boolean ok;
    private String reason;
    private String message;

    public static MatchResponse success(boolean ok, String reason, String message) {
        MatchResponse matchResponse = new MatchResponse();
        matchResponse.setOk(ok);
        matchResponse.setReason(reason);
        matchResponse.setMessage(message);
        return matchResponse;
    }

    public static MatchResponse success(boolean ok, String reason) {
        MatchResponse matchResponse = new MatchResponse();
        matchResponse.setOk(ok);
        matchResponse.setReason(reason);
        matchResponse.setMessage("");
        return matchResponse;
    }

    public static MatchResponse success(String message) {
        MatchResponse matchResponse = new MatchResponse();
        matchResponse.setOk(true);
        matchResponse.setReason("");
        matchResponse.setMessage(message);
        return matchResponse;
    }

    public static MatchResponse fail(boolean ok, String reason) {
        MatchResponse matchResponse = new MatchResponse();
        matchResponse.setOk(ok);
        matchResponse.setReason(reason);
        matchResponse.setMessage("");
        return matchResponse;
    }

    public static MatchResponse fail(boolean ok, String reason, String message) {
        MatchResponse matchResponse = new MatchResponse();
        matchResponse.setOk(ok);
        matchResponse.setReason(reason);
        matchResponse.setMessage(message);
        return matchResponse;
    }
}


创建公共常量类

创建common.AppVariable,用来存放公共常量

package com.example.java_gobang.common;

public class AppVariable {

    // 用来存放session哈希表的key常量
    public static final String USER_SESSION_KEY = "USER_SESSION_KEY";
}


处理连接成功

实现afterConnectionEstablished 方法

  • 通过参数中的 session 对象, 拿到之前登录时设置的 User 信息
  • 使用 onlineUserState来管理用户的在线状态
  • 先判定用户是否是已经在线, 如果在线则直接返回出错 (禁止同一个账号多开)
  • 设置玩家的上线状态
	@Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        // 从 websocketsession中 判断是否有没有改用户数据,无就是未登录,如果已经加入准备状态hash中
        // 那就是多开了,就要禁止,这两种以外的情况我们才把该用户放入准备hash中
        User user = (User) session.getAttributes().get(AppVariable.USER_SESSION_KEY);
        if (user == null) {
            // 未登录的状态
            MatchResponse matchResponse = MatchResponse.fail(false, "您当前还未登录,不能进行后续匹配操作");
            session.sendMessage(new TextMessage(objectMapper.writeValueAsString(matchResponse)));
            return;
        }
		// 多开的情况
        if (onlineUserState.getSessionHall(user.getId()) != null
            || onlineUserState.getSessionRoom(user.getId()) != null) {
            MatchResponse matchResponse = MatchResponse.fail(true, "游戏禁止多开", "repeatConnection");
            session.sendMessage(new TextMessage(objectMapper.writeValueAsString(matchResponse)));
            return;
        }
		// 除了上诉情况,就说明当前玩家是正常的,就可以把该玩家放入大厅hash管理类中存储
        onlineUserState.enterSessionHall(user.getId(), session);
        System.out.println("玩家 + " + user.getUsername() + "进入准备状态");
    }

处理连接关闭和出现异常

实现handleTransportErrorafterConnectionClosed方法

这两个方法逻辑相近,就在一起实现

  • 主要的工作就是把玩家从 onlineUserState中退出
  • 退出的时候要注意判定, 当前玩家是否是多开的情况(一个id, 对应到两个 websocket 连接). 如果一个玩家开启了第二个 websocket 连接, 那么这第二个 websocket 连接不会影响到玩家从 onlineUserState中退出
  • 如果玩家当前在匹配队列中, 则直接从匹配队列里移除
	// 在连接出现异常时执行
	@Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        // 出现异常, 需要从准备hash中删除session
        User user = (User) session.getAttributes().get(AppVariable.USER_SESSION_KEY);
        if (user == null) {
            // 未登录的状态,直接返回
            return;
        }
		// 删除session表明该玩家不是正常的状态
        WebSocketSession tempsession = onlineUserState.getSessionHall(user.getId());
        if (tempsession == session) {
            // 当根据用户id从准备hash查到的session和前端传来的session相同时,才从准备hash中删除,表明
            // 这就是当前用户的操作
            onlineUserState.exitSessionHall(user.getId());
        }
        // 从匹配队列中删除该玩家
        matchQueue.removeUserFromQueue(user);
    }

    // 在连接关闭时执行
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        // 连接断开, 需要从准备hash中删除session
        User user = (User) session.getAttributes().get(AppVariable.USER_SESSION_KEY);
        if (user == null) {
            // 未登录的状态,直接返回
            return;
        }

        WebSocketSession tempsession = onlineUserState.getSessionHall(user.getId());
        if (tempsession == session) {
            // 当根据用户id从准备hash查到的session和前端传来的session相同时,才从准备hash中删除,表明
            // 这就是当前用户的操作
            onlineUserState.exitSessionHall(user.getId());
        }
        // 从匹配队列中删除该玩家
        matchQueue.removeUserFromQueue(user);
    }

处理开始匹配,取消匹配

实现handleTextMessage方法

  • 先从会话中拿到当前玩家的信息
  • 解析客户端发来的请求
  • 判定请求的类型, 如果是 startMatch, 则把用户对象加入到匹配队列. 如果是 stopMatch, 则把用户对象从匹配队列中删除
  • 此处需要实现一个 匹配器 对象, 来处理匹配的实际逻辑
	// 在收到消息时执行
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        // 收到消息就要进行处理
        User user = (User) session.getAttributes().get(AppVariable.USER_SESSION_KEY);
        if (user == null) {
            // 未登录的状态,返回
            MatchResponse matchResponse = MatchResponse.fail(false, "您当前还未登录,不能进行后续匹配操作");
            session.sendMessage(new TextMessage(objectMapper.writeValueAsString(matchResponse)));
            return;
        }
        // 当前这个数据载荷是一个 JSON 格式对象, 就需要把它转成 Java 对象. MatchRequest
        MatchRequest matchRequest = objectMapper.readValue(message.getPayload(), MatchRequest.class);
        // 提前创建一个匹配响应用于返回
        MatchResponse matchResponse;
        if ("startMatch".equals(matchRequest.getMessage())) {
            // 进行匹配,需要加入匹配队列, 然后返回通知
            matchQueue.addUserToQueue(user);
            // 用于改变前端开始匹配后界面
            matchResponse = MatchResponse.success("startMatch");
        } else if ("stopMatch".equals(matchRequest.getMessage())) {
            // 取消匹配,移除匹配队列
            matchQueue.removeUserFromQueue(user);
            // 用于改变前端取消匹配后的界面
            matchResponse = MatchResponse.success("stopMatch");
        } else {
            matchResponse = MatchResponse.fail(false, "非法请求");
        }
        // 返回响应
        session.sendMessage(new TextMessage(objectMapper.writeValueAsString(matchResponse)));
    }

实现匹配器

创建MatchQueue

  • 在 MatchQueue中创建三个队列 (队列中存储 User 对象), 分别表示不同的段位的玩家. (此处约定 <2000 一档, 2000-3000 一档, >3000 一档)

  • 提供 add 方法, 供 MatchHandler类来调用, 用来把玩家加入匹配队列

  • 提供 remove 方法, 供 MatchHandler类来调用, 用来把玩家移出匹配队列

  • 同时 Matcher 找那个要记录 onlineUserState, 来获取到玩家的 Session

还需要处理其中的多线程问题

package com.example.java_gobang.component;

import com.example.java_gobang.entity.MatchResponse;
import com.example.java_gobang.entity.Room;
import com.example.java_gobang.entity.User;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;

import java.io.IOException;
import java.util.LinkedList;
import java.util.Queue;

@Component
public class MatchQueue {

    @Autowired
    private OnlineUserState onlineUserState;

    private final ObjectMapper objectMapper = new ObjectMapper();

    // 天梯积分小于2000
    private final Queue<User> normalQueue = new LinkedList<>();
    // 天梯积分大于等于2000,小于3000
    private final Queue<User> highQueue = new LinkedList<>();
    // 天梯积分大于等于3000
    private final Queue<User> veryHighQueue = new LinkedList<>();

    public void addUserToQueue(User user) {
        if (user.getScore() < 2000) {
            synchronized (normalQueue) {
                normalQueue.offer(user);
                normalQueue.notify();
            }
        } else if (user.getScore() < 3000) {
            synchronized (highQueue) {
                highQueue.offer(user);
                highQueue.notify();
            }
        } else {
            synchronized (veryHighQueue) {
                veryHighQueue.offer(user);
                veryHighQueue.notify();
            }
        }
    }

    public void removeUserFromQueue(User user) {
        if (user.getScore() < 2000) {
            synchronized (normalQueue) {
                normalQueue.remove(user);
            }
        } else if (user.getScore() < 3000) {
            synchronized (highQueue) {
                highQueue.remove(user);
            }
        } else {
            synchronized (veryHighQueue) {
                veryHighQueue.remove(user);
            }
        }
    }

    public MatchQueue() {
        // 创建三个线程,分别循环查找三个队列进行匹配
        Thread thread1 = new Thread() {
            @Override
            public void run() {
                // 扫描highQueue
                while (true) {
                    handlerMatch(highQueue);
                }
            }
        };
        thread1.start();
        Thread thread2 = new Thread() {
            @Override
            public void run() {
                // 扫描veryHighQueue
                while (true) {
                    handlerMatch(veryHighQueue);
                }
            }
        };
        thread2.start();
        Thread thread3 = new Thread() {
            @Override
            public void run() {
                // 扫描normalQueue
                while (true) {
                    handlerMatch(normalQueue);
                }
            }
        };
        thread3.start();
    }

    private void handlerMatch(Queue<User> queue) {
        synchronized (queue) {
            try {
                while (queue.size() < 2) {
                    queue.wait();
                }
                User player1 = queue.poll();
                User player2 = queue.poll();
                System.out.println("两个玩家 " + player1.getUsername() + " " + player2.getUsername());

                // 从状态hash中取出websocketsession 来通知客户端比配到了对局,来跳转页面进行对局
                WebSocketSession session1 = onlineUserState.getSessionHall(player1.getId());
                WebSocketSession session2 = onlineUserState.getSessionHall(player2.getId());
                if (session1 == null) {
                    // 如果取出来的session1为空,说明用户1不在状态hash中,就需要把用户2放回去
                    queue.offer(player2);
                    return;
                }
                if (session2 == null) {
                    queue.offer(player1);
                    return;
                }
                if (session1 == session2) {
                    // 说明两个人是同一个人,不能进行对局,放回去
                    queue.offer(player1);
                    return;
                }

                // 匹配完成后就需要放入一个房间进行对战,并放入房间管理器中维护
                Room room = new Room();
                roomManager.addRoom(room, player1.getId(), player2.getId());

                // 给玩家反馈消息匹配成功,返回matchSuccess
                MatchResponse matchResponse1 = MatchResponse.success("matchSuccess");
                MatchResponse matchResponse2 = MatchResponse.success("matchSuccess");
                session1.sendMessage(new TextMessage(objectMapper.writeValueAsString(matchResponse1)));
                session2.sendMessage(new TextMessage(objectMapper.writeValueAsString(matchResponse2)));
            } catch (IOException | InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

}



创建房间类

匹配成功之后, 需要把对战的两个玩家放到同一个房间对象中

创建 Room

  • 一个房间要包含一个房间 ID, 使用 UUID 作为房间的唯一身份标识
  • 房间内要记录对弈的玩家双方信息
  • 记录先手方的 ID
  • 记录一个 二维数组 , 作为对弈的棋盘
  • 记录一个 onlineUserState, 以备后面和客户端进行交互
  • 还有ObjectMapper 来处理 json
@Data
public class Room {
    private String roomId;

    // 一个房间里的两个用户
    private User user1;
    private User user2;

    // 谁是先手
    private int firstUserId;

    private static final int MAX_ROW = 15;
    private static final int MAX_COL = 15;

    // 有三种状态,0 代表没人下,1表示用户1下的,2表示用户2下的
    private int[][] broad = new int[MAX_ROW][MAX_COL];
    
    private ObjectMapper objectMapper = new ObjectMapper();

    public Room() {
        // 创建roomId
        roomId = UUID.randomUUID().toString();
    }
}

创建房间管理器

Room 对象会存在很多. 每两个对弈的玩家, 都对应一个 Room 对象.

需要一个管理器对象来管理所有的 Room

创建 RoomManager

  • 使用一个 Hash 表, 保存所有的房间对象, key 为 roomId, value 为 Room 对象
  • 再使用一个 Hash 表, 保存 userId -> roomId 的映射, 方便根据玩家来查找所在的房间
  • 提供增, 删, 查的 API. (查包含两个版本, 基于房间 ID 的查询和基于用户 ID 的查询)
package com.example.java_gobang.component;

import com.example.java_gobang.entity.Room;
import org.springframework.stereotype.Component;

import java.util.HashMap;

@Component
public class RoomManager {
    // 有两个映射,一个是房间id和房间的映射,一个是用户id和房间id的映射
    private final HashMap<String, Room> rooms = new HashMap<>();
    private final HashMap<Integer, String> userIdToRoomId = new HashMap<>();

    public void addRoom(Room room, int userId1, int userId2) {
        rooms.put(room.getRoomId(), room);
        userIdToRoomId.put(userId1, room.getRoomId());
        userIdToRoomId.put(userId2, room.getRoomId());
    }

    public void removeRoom(String roomId, int userId1, int userId2) {
        rooms.remove(roomId);
        userIdToRoomId.remove(userId1);
        userIdToRoomId.remove(userId2);
    }

    public Room getRoomByRoomId(String roomId) {
        return rooms.get(roomId);
    }

    public Room getRoomByUserId(Integer userId) {
        String roomId = userIdToRoomId.get(userId);
        if (roomId == null) {
            return null;
        }
        return rooms.get(roomId);
    }
}

注册到MatchQueue类中

@Component
public class MatchQueue {

    @Autowired
    private RoomManager roomManager;
}

实现对战模块

前后端交互接口

连接:

ws://127.0.0.1:8080/game

连接响应:当两个玩家都进入房间,准备好时,返回该响应

{
	message: 'gameReady', // 游戏就绪
	ok: true,	// 是否成功
	reason: '', // 失败原因
	roomId:	// 进入的房间id
	thisUserId, // 玩家自己id
	thatUserId, // 对手id
	whiteUserId // 先手玩家id
}

落子请求:每次落子发送的请求

{
	message: 'putChess', // 请求特征
	userId: 1, // 下子的玩家id
	row: 1, // 下子的行
	col : 1 // 下子的列
}

落子响应:落子返回的响应, 用于前端的棋子绘制

{
	message: 'putChess', // 响应特征
	userId: 1, // 下子玩家id
	row: 0, // 棋子的行
	col: 1, // 棋子的列
	winUserId: // 获胜玩家id,没有人获胜就返回0
}

客户端开发

创建game_root.html , script.js , game_root.css 分别是房间格式,房间的运行逻辑,房间样式

game_room.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>游戏房间</title>
  <link rel="stylesheet" href="css/common.css">
  <link rel="stylesheet" href="css/game_room.css">
  <script src="js/script.js"></script>
</head>
<body>
<div class="nav">五子棋对战</div>
<div class="container">
  <div>
    <!-- 棋盘区域, 需要基于 canvas 进行实现 -->
    <canvas id="chess" width="450px" height="450px">

    </canvas>
    <!-- 显示区域 -->
    <div id="screen"> 等待玩家连接中... </div>
  </div>
</div>
</body>
</html>

game_room.css:

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


script.js:

// 存储游戏信息的对象,由玩家都进入房间准备就绪后返回的响应赋值
let gameInfo = {
    roomId: null, // 该玩家游戏房间id
    thisUserId: null, // 该玩家自己id
    thatUserId: null, // 对手玩家id
    isWhite: true, // 是否为自己先手,true就是自己先手,false就不是自己先手
}

// websocket 连接
var webSocketURL = "ws://" + location.host + "/game";
var webSocket = new WebSocket(webSocketURL);

// 连接成功执行的方法
webSocket.onopen = function () {
    console.log("游戏连接成功");
}

// 连接发生错误执行的方法
webSocket.onerror = function() {
    console.log("连接发生错误");
}

// 连接关闭执行的方法
webSocket.onclose = function () {
    console.log("连接关闭");
}

// 浏览器窗口关闭来主动关闭WebSocket连接
window.onbeforeunload = function () {
    webSocket.close();
}

// WebSocket连接收到响应后执行的方法,event为收到的响应,需要将负载转换成js对象
webSocket.onmessage = function (event) {
    var result = JSON.parse(event.data);
    // 连接失败
    if (!result.ok) {
        console.log("连接失败,请重试!原因:" + result.reason);
        return;
    }
	// 判断响应特征
    if (result.message === "gameReady") {
        // 初始化信息
        gameInfo.roomId = result.roomId;
        gameInfo.thisUserId = result.thisUserId;
        gameInfo.thatUserId = result.thatUserId;
        // 如果返回的先手玩家id是自己,就赋值true,反之赋值false
        gameInfo.isWhite = (result.firstUserId === result.thisUserId);
        // 初始化棋盘
        initGame();
        // 显示提示栏信息
        setScreenText(gameInfo.isWhite);
    } else if (result.message === "repeatConnection") {
        // 说明玩家多开,加载到登录页面
        alert(result.reason);
        location.replace("/login.html");
    } 

}


// 设定界面显示相关操作,来修改显示栏提示
function setScreenText(me) {
    let screen = document.querySelector('#screen');
    if (me) {
        screen.innerHTML = "轮到你落子了!";
    } else {
        screen.innerHTML = "轮到对方落子了!";
    }
}

// 初始化一局游戏
function initGame() {
    // 是我下还是对方下. 根据服务器分配的先后手情况决定
    let me = gameInfo.isWhite;
    // 游戏是否结束
    let over = false;
    let chessBoard = [];
    //初始化chessBord数组(表示棋盘的数组),如果为0表示当前位置可以落子,为1表示当前位置已经有棋子了
    for (let i = 0; i < 15; i++) {
        chessBoard[i] = [];
        for (let j = 0; j < 15; j++) {
            chessBoard[i][j] = 0;
        }
    }
    // 使用chess标签来绘制棋盘
    let chess = document.querySelector('#chess');
    let context = chess.getContext('2d');
    context.strokeStyle = "#090909";
    // 背景图片
    let logo = new Image();
    logo.src = "img/logo.png";
    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(435, 15 + i * 30);
            context.stroke();
        }
    }

    // 绘制一个棋子
    function oneStep(i, j, isWhite) {
        context.beginPath();
        context.arc(15 + i * 30, 15 + j * 30, 13, 0, 2 * Math.PI);
        context.closePath();
        var gradient = context.createRadialGradient(15 + i * 30 + 2, 15 + j * 30 - 2, 13, 15 + i * 30 + 2, 15 + j * 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;
        }
        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) {
            // 发送坐标给服务器, 服务器要返回结果
            send(row, col);
        }
    }

	// 发送落子请求
    function send(row, col) {
        var request = {
            message: "putChess",
            userId: gameInfo.thisUserId,
            row: row,
            col: col
        };
		
        webSocket.send(JSON.stringify(request));
    }
	// 上面的onmessage方法是玩家准备好时需要初始化页面执行的方法,现在需要修改该方法来处理落子响应
    webSocket.onmessage = function(e) {
        // 处理成js对象
        var result = JSON.parse(e.data);
        
        if (result.message === "No Message") {
            // 落子请求失败,提醒重试
            alert("落子失败,请重试!");
            return;
        }
        if (result.message !== "putChess") {
            console.log("响应类型错误");
            return;
        }
        // 如果落子响应显示落子玩家是自己就绘制自己颜色棋子,不是就绘制对手颜色棋子
        if (result.userId === gameInfo.thisUserId) {
            // 自己下子绘制棋子
            oneStep(result.col, result.row, gameInfo.isWhite);
        } else if (result.userId === gameInfo.thatUserId) {
            // 对面下子绘制棋子
            oneStep(result.col, result.row, !gameInfo.isWhite);
        } else {
            // 发生错误
            console.log("发生错误,绘制棋子错误");
            return;
        }
        // 在前端数组上标记该地方已经落子
        chessBoard[result.row][result.col] = 1;
        // 交换双方落子,并修改提示框信息
        me = !me;
        setScreenText(me);

        // 判定游戏是否结束
        let screenDiv = document.querySelector('#screen');
        if (result.winUserId !== 0) {
            // 如果胜利玩家id是自己就提示栏显示你赢了,反之显示你输了
            if (result.winUserId === gameInfo.thisUserId) {
                screenDiv.innerHTML = '你赢了!';
            } else if (result.winUserId === gameInfo.thatUserId) {
                screenDiv.innerHTML = '你输了!';
            } else {
                alert("winner 字段错误! " + result.winUserId);
            }
		
            // 增加一个按钮, 让玩家点击之后, 再回到游戏大厅~
            let backBtn = document.createElement('button');
            backBtn.innerHTML = '回到大厅';
            backBtn.onclick = function() {
                location.replace('/game_hall.html');
            }
            let fatherDiv = document.querySelector('.container>div');
            fatherDiv.appendChild(backBtn);
        }
    }
}

运行效果如下:

image-20230610150953440

左侧为一个用户匹配进入房间,右侧是另一个用户匹配进入房间

落子效果:

image-20230610151235475

服务端开发

创建gameHandler

重写四个方法: afterConnectionEstablished, handleTextMessage, handleTransportError, afterConnectionClosed并注册到WebSocketConfig中来与前端**/game** 对应

注入房间管理器类RoomManager管理房间

注入 OnlineUserState 管理玩家状态

注入UserMapper管理游戏完成后玩家信息的修改

创建ObjectMapper处理json

@Component
public class GameHandler extends TextWebSocketHandler {

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Autowired
    private RoomManager roomManager;

    @Autowired
    private OnlineUserState onlineUserState;

    @Autowired
    private UserMapper userMapper;

    // 连接成功后执行的方法
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        
    }
	
    // 收到请求后处理请求
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        
    }
    
	// 程序异常执行的方法
    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        
    }

    // 程序退出执行的方法
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        
    }
}

创建连接响应

连接响应来返回前端存储必要的游戏信息,来进行初始化棋盘

package com.example.java_gobang.entity;

import lombok.Data;

@Data
public class ConnectResponse {
    private String message;
    private boolean ok;
    private String reason;
    private String roomId;
    private int thisUserId;
    private int thatUserId;
    // 先手用户id
    private int firstUserId;
}


创建落子请求
package com.example.java_gobang.entity;

import lombok.Data;

@Data
public class DropsRequest {
    private String message;
    private int userId;
    private int row;
    private int col;
}


创建落子响应
package com.example.java_gobang.entity;

import lombok.Data;

@Data
public class DropsResponse {
    private String message;
    private int userId;
    private int row;
    private int col;
    // 没有玩家胜利就是 0,玩家一胜利就是 玩家1 id,玩家二胜利就是 玩家2 id
    private int winUserId;
}


处理连接成功
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        User user = (User) session.getAttributes().get(AppVariable.USER_SESSION_KEY);
        // 先创建一个连接响应对象,后面进行属性赋值然后返回给前端
        ConnectResponse connectResponse = new ConnectResponse();
        if (user == null) {
            // 用户没有登录,返回信息
            connectResponse.setOk(false);
            connectResponse.setReason("您当前没有登录,无法进行对战");
            String response = objectMapper.writeValueAsString(connectResponse);
            session.sendMessage(new TextMessage(response));
            return;
        }
		// 一个用户登录大厅页面,或登录房间页面,或既然登录大厅页面又登录房间页面就是多开,禁止
        if (onlineUserState.getSessionHall(user.getId()) != null
                || onlineUserState.getSessionRoom(user.getId()) != null) {
            // 游戏多开
            connectResponse.setOk(true);
            connectResponse.setReason("游戏禁止多开");
            connectResponse.setMessage("repeatConnection");
            String response = objectMapper.writeValueAsString(connectResponse);
            session.sendMessage(new TextMessage(response));
            return;
        }
        // 用户状态正常才把用户管理在房间哈希表中
        onlineUserState.enterSessionRoom(user.getId(), session);
		
        Room room = roomManager.getRoomByUserId(user.getId());
        if (room == null) {
            // 判断房间是否存在,没有就说明匹配的时候没有让房间和用户id关联起来,也就说明没有匹配成功
            connectResponse.setOk(false);
            connectResponse.setReason("当前没有匹配成功,不能进入游戏");
            String response = objectMapper.writeValueAsString(connectResponse);
            session.sendMessage(new TextMessage(response));
            return;
        }
		// 注意多线程问题
        synchronized (room) {
            if (room.getUser1() == null) {
                // 房间里用户1是空的,那就添加进去,并设置为先手
                room.setUser1(user);
                room.setFirstUserId(user.getId());
                System.out.println("玩家一准备就绪" + user.getUsername());
                return;
            }
            if (room.getUser2() == null) {
                room.setUser2(user);
                System.out.println("玩家二准备就绪" + user.getUsername());
                // 两个用户都添加进去后,就通知两个人就绪
                // 第一个通知用户一,thisUser是user1,thatUser是user2
                noticeGameReady(room, room.getUser1().getId(), room.getUser2().getId());
                // 第二个通知用户二,thisUser是user2,thatUser是user2
                noticeGameReady(room, room.getUser2().getId(), room.getUser1().getId());
                return;
            }
        }

        // 用户一和用户二都不为空就是房间满了,需要返回信息提醒用户三
        connectResponse.setOk(false);
        connectResponse.setReason("房间满了,当前不能对战");
        String response = objectMapper.writeValueAsString(connectResponse);
        session.sendMessage(new TextMessage(response));
        
    }

    @SneakyThrows
    private void noticeGameReady(Room room, int thisUserId, int thatUserId) {
        ConnectResponse connectResponse = new ConnectResponse();
        connectResponse.setOk(true);
        connectResponse.setMessage("gameReady");
        connectResponse.setRoomId(room.getRoomId());
        connectResponse.setThisUserId(thisUserId);
        connectResponse.setThatUserId(thatUserId);
        connectResponse.setFirstUserId(room.getFirstUserId());
        String response = objectMapper.writeValueAsString(connectResponse);
        WebSocketSession session = onlineUserState.getSessionRoom(thisUserId);
        session.sendMessage(new TextMessage(response));

    }

处理程序出现异常和连接关闭
	@Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        // 程序异常,需要删除玩家在状态hash中的session
        User user = (User) session.getAttributes().get(AppVariable.USER_SESSION_KEY);
        if (user == null) {
            // 得不到就直接返回
            return;
        }
        WebSocketSession tmpSession = onlineUserState.getSessionRoom(user.getId());
        if (tmpSession == session) {
            // 只有从状态hash中得到的session和从前端传来的session相等时,才删除状态hash中的session
            onlineUserState.exitSessionRoom(user.getId());
        }
		// 通知对手获胜
        onticeThatWin(user);
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        // 程序异常,需要删除玩家在状态hash中的session
        User user = (User) session.getAttributes().get(AppVariable.USER_SESSION_KEY);
        if (user == null) {
            return;
        }
        WebSocketSession tmpSession = onlineUserState.getSessionRoom(user.getId());
        if (tmpSession == session) {
            // 只有从状态hash中得到的session和从前端传来的session相等时,才删除状态hash中的session
            onlineUserState.exitSessionRoom(user.getId());
        }
		// 通知对手获胜
        onticeThatWin(user);
    }

    @SneakyThrows
    private void onticeThatWin(User user) {
        // 通过掉线用户得到房间
        Room room = roomManager.getRoomByUserId(user.getId());
        if (room == null) {
            // 房间已经销毁
            System.out.println("房间已经销毁");
            return;
        }
        // 通过房间来得到对手
        User thatUser = (user == room.getUser1() ? room.getUser2() : room.getUser1());
        WebSocketSession session = onlineUserState.getSessionRoom(thatUser.getId());
        if (session == null) {
            // 说明对手也掉线了,当前对局作废
            System.out.println("当前对局作废");
            return;
        }

        // 因为是落子期间掉线,所以构建一个落子响应
        DropsResponse dropsResponse = new DropsResponse();
        dropsResponse.setMessage("putChess");
        // 胜利的是掉线玩家的对手
        dropsResponse.setWinUserId(thatUser.getId());
        // 返回给掉线玩家对手页面展示
        dropsResponse.setUserId(thatUser.getId());
        String response = objectMapper.writeValueAsString(dropsResponse);
        session.sendMessage(new TextMessage(response));

        // 胜方败方数据修改
        int winId = thatUser.getId();
        int loseId = user.getId();
        userMapper.userWinUpdate(winId);
        userMapper.userLoseUpdate(loseId);
        // 从房间管理器中删除该房间
        roomManager.removeRoom(room.getRoomId(), room.getUser1().getId(), room.getUser2().getId());
    }

处理收到落子请求
	@Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        User user = (User) session.getAttributes().get(AppVariable.USER_SESSION_KEY);
        ConnectResponse connectResponse = new ConnectResponse();
        if (user == null) {
            // 玩家不在房间页面了
            connectResponse.setOk(false);
            connectResponse.setReason("用户未登录");
            String response = objectMapper.writeValueAsString(connectResponse);
            session.sendMessage(new TextMessage(response));
            return;
        }
        // 通过该玩家id寻找房间
        Room room = roomManager.getRoomByUserId(user.getId());
        if (room == null) {
            // 房间为空,说明没有创建房间在房间管理器中,
            System.out.println("房间已空");
            return;
        }
        // 进行下棋
        room.putChess(message.getPayload());
    }

修改Room

因为在游戏中有很多房间实例,所以我们不能再Room中使用自动注入,因为这样也要在Room类上添加注入注解,所以我们使用手

动注入来注入

JavaGobangApplication启动类代码

package com.example.java_gobang;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;

@SpringBootApplication
public class JavaGobangApplication {

    public static ConfigurableApplicationContext context;

    public static void main(String[] args) {
        context = SpringApplication.run(JavaGobangApplication.class, args);
    }

}
@Data
public class Room {
    // 房间id
    private String roomId;

    // 一个房间里的两个用户
    private User user1;
    private User user2;

    // 谁是先手
    private int firstUserId;

    // 棋盘的  行 列
    private static final int MAX_ROW = 15;
    private static final int MAX_COL = 15;

    // 有三种状态,0 代表没人下,1表示用户1下的,2表示用户2下的
    private int[][] broad = new int[MAX_ROW][MAX_COL];

    private ObjectMapper objectMapper = new ObjectMapper();

    // 三个手动注入的属性
    private OnlineUserState onlineUserState;
    private RoomManager roomManager;
    private UserMapper userMapper;

    @SneakyThrows
    public void putChess(String requestJSON) {
        // 处理json字符串成落子请求对象
        DropsRequest dropsRequest = objectMapper.readValue(requestJSON, DropsRequest.class);
        // 检查请求特征
        if ("putChess".equals(dropsRequest.getMessage())) {
            // 得到当前棋子的状态,如果是玩家一下的,棋盘上就赋值 1, 玩家二下的,棋盘上就赋值 2,用来区分不同玩家的棋子
            int chess = dropsRequest.getUserId() == user1.getId() ? 1 : 2;
            int row = dropsRequest.getRow();
            int col = dropsRequest.getCol();
            // 当前玩家已经有棋子了
            if (broad[row][col] != 0) {
                System.out.println("当前位置已经有子了 + row: " + row + " col: " + col);
                return;

            }
            broad[row][col] = chess;
            // 检查胜利信息,0 代表无人胜利,
            int winner = checkBroadSuccess(row, col, chess);
            // 返回棋盘打印棋子信息
            WebSocketSession session1 = onlineUserState.getSessionRoom(user1.getId());
            WebSocketSession session2 = onlineUserState.getSessionRoom(user2.getId());
            // 创建一个落子响应
            DropsResponse dropsResponse = new DropsResponse();
            dropsResponse.setMessage("putChess");
            dropsResponse.setRow(row);
            dropsResponse.setCol(col);
            // 胜利人id
            dropsResponse.setWinUserId(winner);
            dropsResponse.setUserId(dropsRequest.getUserId());
            if (session1 == null) {
                 // 玩家一掉线,判断玩家二胜利
                 dropsResponse.setWinUserId(user1.getId());
            }
            if (session2 == null) {
                // 玩家二掉线,判断玩家一胜利
                dropsResponse.setWinUserId(user2.getId());
            }
            if (session1 != null) {
                // 如果玩家一在线就发给玩家一
                String response = objectMapper.writeValueAsString(dropsResponse);
                session1.sendMessage(new TextMessage(response));
            }
            if (session2 != null) {
                // 玩家二在下就发给玩家二
                String response = objectMapper.writeValueAsString(dropsResponse);
                session2.sendMessage(new TextMessage(response));
            }

            if (dropsResponse.getWinUserId() != 0) {
                // 游戏分出胜负,房间销毁 ,修改胜方败方数据
                roomManager.removeRoom(roomId, user1.getId(), user2.getId());
                int winId = dropsResponse.getWinUserId();
                int loseId = dropsResponse.getWinUserId() == user2.getId() ? user1.getId() : user2.getId();
                userMapper.userWinUpdate(winId);
                userMapper.userLoseUpdate(loseId);
            }
        } else {
            // 请求不是putChess,发生错误,提醒客户端
            WebSocketSession session = onlineUserState.getSessionRoom(dropsRequest.getUserId());
            if (session == null) {
                // 掉线
                return;
            }
            DropsResponse dropsResponse = new DropsResponse();
            dropsResponse.setMessage("No Message");
            session.sendMessage(new TextMessage(objectMapper.writeValueAsString(dropsResponse)));
        }

    }

    private int checkBroadSuccess(int row, int col, int chess) {

        // 首先判定横着五个点
        for (int c = col - 4; c <= col; c++) {
            try {
                if (broad[row][c] == chess
                        && broad[row][c + 1] == chess
                        && broad[row][c + 2] == chess
                        && broad[row][c + 3] == chess
                        && broad[row][c + 4] == chess) {
                    return chess == 1 ? user1.getId() : user2.getId();
                }
            } catch (ArrayIndexOutOfBoundsException e) {
                continue;
            }
        }

        // 判断五个竖着的
        for (int r = row - 4; r <= row; r++) {
            try {
                if (broad[r][col] == chess
                    && broad[r + 1][col] == chess
                    && broad[r + 2][col] == chess
                    && broad[r + 3][col] == chess
                    && broad[r + 4][col] == chess) {
                    return chess == 1? user1.getId() : user2.getId();
                }
            } catch (ArrayIndexOutOfBoundsException e) {
                continue;
            }
        }

        // 判断五个\这样的
        for (int r = row - 4, c = col - 4; r <= row && c <= col; r++, c++) {
            try {
                if (broad[r][c] == chess
                    && broad[r + 1][c + 1] == chess
                    && broad[r + 2][c + 2] == chess
                    && broad[r + 3][c + 3] == chess
                    && broad[r + 4][c + 4] == chess) {
                    return chess == 1? user1.getId() : user2.getId();
                }
            } catch (ArrayIndexOutOfBoundsException e) {
                continue;
            }

        }

        // 判断五个/这样的
        for (int r = row + 4, c = col - 4; r <= row && c <= col; r++, c++) {
            try {
                if (broad[r][c] == chess
                    && broad[r + 1][c - 1] == chess
                    && broad[r + 2][c - 2] == chess
                    && broad[r + 3][c - 3] == chess
                    && broad[r + 4][c - 4] == chess) {
                    return chess == 1? user1.getId() : user2.getId();
                }
            } catch (ArrayIndexOutOfBoundsException e) {
                continue;
            }
        }
        // 当前没有人获胜
        return 0;
    }

    public Room() {
        // 创建roomId
        roomId = UUID.randomUUID().toString();
        // 手动注入属性
        onlineUserState = JavaGobangApplication.context.getBean(OnlineUserState.class);
        roomManager = JavaGobangApplication.context.getBean(RoomManager.class);
        userMapper = JavaGobangApplication.context.getBean(UserMapper.class);
    }
}

匹配,对战落子及一方胜利样例

游戏大厅界面
在这里插入图片描述

其中一方正在匹配
在这里插入图片描述
另一方进行匹配后进入房间,中途落子

在这里插入图片描述

一方胜利
在这里插入图片描述

回到大厅个人信息变化

在这里插入图片描述

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值