admin管理员组

文章数量:1590356

文章目录

  • 一、核心功能及技术
  • 二、效果演示
  • 三、创建项目
    • 扩展:WebSocket 框架知识
  • 四、需求分析和概要设计
  • 五、数据库设计与配置 Mybatis
  • 六、实现用户模块功能
    • 6.1 数据库代码编写
    • 6.2 前后端交互接口
    • 6.3 服务器开发
    • 6.4 客户端开发
  • 七、实现匹配模块功能
    • 7.1 前后端交互接口
    • 7.2 客户端开发
    • 7.3 服务器开发
  • 八、实现对战模块功能
    • 8.1 前后端交互接口
    • 8.2 客户端开发
    • 8.3 服务器开发
  • 九、添加拦截器
  • 十、项目拓展方向

一、核心功能及技术

核心功能:

  1. 注册,登录,退出
  2. 大厅记录用户相关信息,如天梯分数
  3. 将分数相差不大的同水平选手进行匹配
  4. 大厅开始匹配,成功后两名玩家进入同一游戏房间,可随时取消匹配
  5. 多玩家可同时在线,两两玩家随机对弈
  6. 实时记录玩家游戏信息,管理游戏场数,分数等
  7. 无论哪一方玩家对弈时退出或掉线,另一方自动获胜

核心技术:

  1. Spring/SpringBoot/SpringMVC
  2. WebSocket
  3. MySQL
  4. MyBatis
  5. HTML/CSS/JS/AJAX(canvas API)

注意: WebSocket 和 canvas API 是实现本项目的两个核心技术,前者我们后续会稍许讲解,后者涉及到前端知识,我们就不多赘述,大家可参考以下链接去了解更多关于 canvas 的知识点!https://developer.mozilla/zh-CN/docs/Web/API/Canvas_API


二、效果演示

  1. 登录

  2. 注册

  3. 游戏大厅

  4. 游戏房间,及分出胜负后的效果


三、创建项目

创建一个springboot项目,具体步骤与前面的博客记录一样,这里就不再重复赘述,大家自行参考以下博客:

1.springboot项目的基本创建

2.添加mybatis框架支持

注意: 除开以前的老几样框架,本次项目我们还引入了一个新的知识点 WebSocket 如下:

扩展:WebSocket 框架知识

参考另一篇博客 https://editor.csdn/md/?articleId=130700875


四、需求分析和概要设计

整个项目分成以下模块:

  1. 用户模块
  2. 匹配模块
  3. 对战模块

用户模块

  1. 用户模块主要负责用户的注册, 登录,退出,分数记录功能.

  2. 使用 MySQL 数据库存储用户数据.

  3. 客户端提供一个登录页面+注册页面.

  4. 服务器端基于 Spring + MyBatis 来实现数据库的增删改查.

匹配模块

  1. 注册后,用户登录成功, 则进入游戏大厅页面.

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

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

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

  5. 再次点击取消匹配“”则把用户从匹配队列中删除.

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

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

对战模块

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

  2. 每两个玩家在同一个游戏房间中.

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

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

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

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


五、数据库设计与配置 Mybatis

创建如下:

create database if not exists java_gobang DEFAULT CHARACTER SET utf8;

use java_gobang;

drop table if exists user;
create table user(
                     userId int primary key auto_increment,
                     username varchar(50) unique,
                     password varchar(500),
                     score int, -- 天梯分数
                     totalCount int, -- 比赛总场次
                     winCount int -- 获胜场次
);

注意: 为了避免部署项目时云服务器数据库不支持中文,创建数据库时先提前设置好字符集。并且我们会将密码进行加密,所以密码的长度我们设置大一点

配置 Mybatis:

spring:
  datasource:
    url: jdbc:mysql://127.0.0.1:3306/java_gobang?characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8
    username: root
    password: 123456
    driver-class-name: com.mysql.cj.jdbc.Driver

mybatis:
  mapper-locations: classpath:mapper/**Mapper.xml

logging:
  pattern:
    console: "[%-5level] - %msg%n"

创建 mapper 目录,保存 .xml 文件,xml文件配置如下:

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

<mapper>

六、实现用户模块功能

前面已经配置好了Mybatis文件,下面我们具体来实现 !!

注意: 用户模块不会涉及到消息推送,我们还是通过AJAX向后端发送请求处理后端响应即可 !

6.1 数据库代码编写

  1. 创建实体类
    创建 model 目录,添加 User 实体类,添加 @Data 注解,提供get,set方法
@Data
public class User {
    private int userId;
    private String username;
    private String password;
    private int score;
    private int totalCount;
    private int winCount;
 }
  1. 创建 UserMapper 接口
    创建 mapper 接口目录,添加 UserMapper 接口类

此处主要提供四个方法:

  1. selectByName: 根据用户名查找用户信息. 用于实现登录.
  2. insert: 新增用户. 用户实现注册.
  3. userWin: 用于给获胜玩家修改分数.
  4. userLose: 用户给失败玩家修改分数.
package com.example.java_gobang.mapper;

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

@Mapper
public interface UserMapper {
    // 往数据库里插入一个用户. 用于注册功能.
    void insert(User user);

    // 根据用户名, 来查询用户的详细信息. 用于登录功能
    User selectByName(String username);

    // 总比赛场数 + 1, 获胜场数 + 1, 天梯分数 + 30
    void userWin(int userId);

    // 总比赛场数 + 1, 获胜场数 不变, 天梯分数 - 30
    void userLose(int userId);
}
  1. .xml文件实现UserMapper 接口
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis//DTD Mapper 3.0//EN" "http://mybatis/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.java_gobang.mapper.UserMapper">
    <insert id="insert">
        insert into user values(null, #{username}, #{password}, 1000, 0, 0);
    </insert>

    <select id="selectByName" resultType="com.example.java_gobang.model.User">
        select * from user where username = #{username};
    </select>
    
    <update id="userWin">
        update user set totalCount = totalCount + 1, winCount = winCount + 1, score = score + 30
        where userId = #{userId}
    </update>
    
    <update id="userLose">
        update user set totalCount = totalCount + 1, score = score - 30
        where userId = #{userId}
    </update>
</mapper>

6.2 前后端交互接口

需要明确用户模块的前后端交互接口. 这里主要涉及到三个部分.

  1. 登录接口

请求:
POST /login HTTP/1.1
Content-Type: application/x-www-form-urlencoded
username=zhangsan&password=123
响应:
HTTP/1.1 200 OK
Content-Type: application/json
{
userId: 1,
username: ‘zhangsan’,
score: 1000,
totalCount: 10,
winCount: 5
}
如果登录失败, 返回的是一个 userId 为 0 的对象.

  1. 注册接口

请求:
POST /register HTTP/1.1
Content-Type: application/x-www-form-urlencoded
username=zhangsan&password=123
响应:
HTTP/1.1 200 OK
Content-Type: application/json
{
userId: 1,
username: ‘zhangsan’,
score: 1000,
totalCount: 10,
winCount: 5
}
如果注册失败(比如用户名重复), 返回的是一个 userId 为 0 的对象.

  1. 获取用户信息

请求:
GET /userInfo HTTP/1.1
响应:
HTTP/1.1 200 OK
Content-Type: application/json
{
userId: 1,
username: ‘zhangsan’,
score: 1000,
totalCount: 10,
winCount: 5
}


6.3 服务器开发

创建 api.UserAPI 类

主要实现四个方法:

  1. login: 用来实现登录逻辑.
  2. register: 用来实现注册逻辑.
  3. getUserInfo: 用来实现登录成功进入大厅后显示用户信息.
  4. logout:用来实现退出游戏功能

代码如下:

@RestController
public class UserAPI {

    @Resource
    private UserMapper userMapper;

    //需要提前添加 BCrypt 依赖
    BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();

    //登录更新 加上了密码加密
    @PostMapping("/login")
    @ResponseBody
    public Object login(String username, String password, HttpServletRequest request){

        // 查询用户是否在数据库中存在
        User user = userMapper.selectByName(username);

        // 没有查到
        if(user == null) {
            System.out.println("登录失败!");
            return new User();
        }else {

            //查到了,但密码不一样
            if(!bCryptPasswordEncoder.matches(password,user.getPassword())) {
                return new User();
            }
            // 匹配成功,创建 session
            request.getSession().setAttribute("user",user);
            return user;
        }
    }


    //注册更新,加上了密码加密
    @PostMapping("/register")
    @ResponseBody
    public Object register(String username,String password){

        User user1 = userMapper.selectByName(username);
        if(user1 != null){
            System.out.println("当前用户已存在");
            return new User();
        }else{
            User user2 = new User();
            user2.setUsername(username);
            String password1 = bCryptPasswordEncoder.encode(password);
            user2.setPassword(password1);
            userMapper.insert(user2);
            return user2;
        }
    }

    @RequestMapping("/logout")
    public void logout(HttpServletRequest request, HttpServletResponse response) throws IOException {
        HttpSession session = request.getSession(false);
        try{
            session.removeAttribute("user");
            response.sendRedirect("login.html");
        }catch (NullPointerException e){
            System.out.println("session.removeAttribute()这里没有设置拦截器,直接访问logout页面退出会空指针异常");
        }
    }


    @GetMapping("/userInfo")
    @ResponseBody
    public Object getUserInfo(HttpServletRequest req) {
        try {
            HttpSession httpSession = req.getSession(false);
            User user = (User) httpSession.getAttribute("user");//拿到的是登录的用户信息
            // 拿着这个 user 对象, 去数据库中找, 找到最新的数据
            User newUser = userMapper.selectByName(user.getUsername());
            return newUser;
        } catch (NullPointerException e) {
            return new User();
        }
    }
}

注意: 上述逻辑实现,以及密码加密解密等操作和在线音乐平台的实现一样,若不懂大家可以去参考音乐博客 !!


6.4 客户端开发

  1. login.html 登录页面
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>登录页面</title>
    <link rel="stylesheet" href="css/common.css">
    <link rel="stylesheet" href="css/login.css">
</head>
<body id="body">
<div class="nav">
    <img src="image/touxiang.png" alt="">
    <span>五子棋对战平台</span>
    <!-- 空白元素, 用来占位置 -->

</div>

<div class="login-container">
    <div class="login-dialog">
        <!-- 登录界面的对话框 -->
        <h3>用户登录</h3>
        <!-- 这个表示一行 -->
        <div class="row">
            <span>用户名</span>
            <input type="text" id="username" placeholder="请输入用户名">
        </div>
        <div class="row">
            <span>密码</span>
            <input type="password" id="password" placeholder="请输入密码">
        </div>
        <!-- 提交按钮 -->
        <div class="submit-row1">
            <button id="submit">登录</button>
            &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
            <input type="button" id="submit2" value="注册" onclick="toregister()">
        </div>
    </div>
</div>

<script src="./js/jquery.min.js"></script>

<style>
    #body {
        background-image: url("image/roombeijing.png");
        background-size:100% 100%;
        background-attachment: fixed;
    }
</style>

<script type="text/javascript">
    function toregister() {
        window.location.href = "register.html";
    }
</script>
<script>

    let submitButton = document.querySelector("#submit");
    submitButton.onclick = function (){
        let usernameInput = document.querySelector("#username");
        let passwordInput = document.querySelector("#password");

        if(usernameInput.value.trim() == ""){
            alert("请输入用户名!");
            usernameInput.focus();
            return;
        }
        if(passwordInput.value.trim() == ""){
            alert('请输入密码!');
            passwordInput.focus();
            return;
        }

        //通过 jQuery 中的 AJAX 和服务器进行交互
        $.ajax({
            type: 'post',
            url: '/login',
            data: {
                username: usernameInput.value,
                password: passwordInput.value,
            },
            success: function (body){
                //请求执行成功之后的回调函数
                //判定当前是否登录成功
                // 登录成功返回 当前的 User对象,失败返回一个空的 User 对象
                if(body && body.userId > 0){
                    // alert("登录成功!");
                    location.assign('/game_hall.html');
                }else{
                    alert("用户名或密码错误!")
                }
            },
            error: function (){
                //请求执行失败之后的回调函数
                alert("登录失败!")
            }
        });
    }
</script>

</body>
</html>
  1. register.html 注册页面
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>注册页面</title>
    <link rel="stylesheet" href="css/common.css">
    <link rel="stylesheet" href="css/login.css">
</head>
<body>
<div class="nav">
    <img src="image/touxiang.png" alt="">
    <span>五子棋对战平台</span>
</div>

<!-- 版心 -->
<div class="login-container">
    <!-- 中间的登陆框 -->
    <div class="login-dialog">
        <h3>用户注册</h3>
        <div class="row">
            <span>用户名</span>
            <input type="text" id="username" placeholder="请输入用户名">
        </div>
        <div class="row">
            <span>密码</span>
            <input type="password" id="password" placeholder="请输入密码">
        </div>
        <div class="row">
            <span>确认密码</span>
            <input type="password" id="password2" placeholder="请确认密码">
        </div>
        <div class="submit-row2">
            <button id="submit">注册</button>
            &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
            <input type="button" id="submit2" value="返回" onclick="toregister()">
        </div>
    </div>
</div>

<script src="./js/jquery.min.js"></script>

<script type="text/javascript">
    function toregister() {
        window.location.href = "login.html";
    }
</script>

<script>

    let submitButton = document.querySelector("#submit");

    submitButton.onclick = function () {
        let username = document.querySelector("#username");
        let password1 = document.querySelector("#password");
        let password2 = document.querySelector("#password2");
        if (username.value.trim() == "") {
            alert("请输入用户名!");
            username.focus();
            return;
        }
        if (password1.value.trim() == "") {
            alert('请输入密码!');
            password1.focus();
            return;
        }
        if (password2.value.trim() == "") {
            alert('请再次输入密码!');
            password2.focus();
            return;
        }
        if (password1.value.trim() != password2.value.trim()) {
            alert('两次输入的密码不同!');
            passwrod1.value = "";
            password2.value = "";
            return;
        }

        $.ajax({
            type: 'post',
            url: '/register',
            data: {
                username: username.value,
                password: password1.value,
            },
            success: function (body) {
                //请求执行成功之后的回调函数
                if (body && body.username) {
                    alert("注册成功!");
                    location.assign('/login.html');
                } else {
                    alert("注册失败!当前用户已经存在!")
                }
            },
            error: function () {
                //请求执行失败之后的回调函数
                alert("注册失败!")
            }
        });
    }
</script>

</body>
</html>

七、实现匹配模块功能

7.1 前后端交互接口

先通过 WebSocket 将前后端连接起来

前端初始化 websocket ,连接:

ws://127.0.0.1:8080/findMatch

后端创建匹配类 MatchAPI,并创建 WebSocketConfig 类实现WebSocketConfigurer接口,来连接匹配类 MatchAPI 和 前端,实现如下:

package com.example.java_gobang.config;

import com.example.java_gobang.api.GameAPI;
import com.example.java_gobang.api.MatchAPI;
import com.example.java_gobang.api.TestAPI;
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 TestAPI testAPI;

    @Autowired
    private MatchAPI matchAPI;

    @Autowired
    private GameAPI gameAPI;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry webSocketHandlerRegistry) {
        webSocketHandlerRegistry.addHandler(testAPI, "/test");
        webSocketHandlerRegistry.addHandler(matchAPI, "/findMatch")
                //在注册websocket API的时候,就需要把前面准备的 Httpsession(用户登录会给Httpsession保存用户信息) 搞过来(搞到websocket的session中)
                //因为在匹配中,你需要把用户相关的信息发送给服务器,服务器根据此信息进行水平匹配
                .addInterceptors(new HttpSessionHandshakeInterceptor());
        webSocketHandlerRegistry.addHandler(gameAPI, "/game")
                .addInterceptors(new HttpSessionHandshakeInterceptor());
    }
}

注意: 第一个参数为后端匹配的类,第二个参数也必须和前端规定的路径保持一致 !!

在 addHandler 之后, 再加上一个 .addInterceptors(new HttpSessionHandshakeInterceptor()) 代码, 这样可以把之前登录过程中往 HttpSession 中存放的数据(主要是 User 对象), 放到 WebSocket 的 session 中. 方便后面的代码中获取到当前用户信息.

实现连接后,我们来看具体的请求和响应 !!


请求:
{
message: ‘startMatch’ / ‘stopMatch’,
}

响应1: (收到请求后立即响应)
{
ok: true, // 是否成功. 比如用户 id 不存在, 则返回 false
reason: ‘’, // 错误原因
message: ‘startMatch’ / ‘stopMatch’
}

响应2: (匹配成功后的响应)
{
ok: true, // 是否成功. 比如用户 id 不存在, 则返回 false
reason: ‘’, // 错误原因
message: ‘matchSuccess’,
}

注意:

  1. 页面这端拿到匹配响应之后, 就跳转到游戏房间.
  2. 如果返回的响应 ok 为 false, 则弹框的方式显示错误原因, 并跳转到登录页面

7.2 客户端开发

创建 game_hall.html 游戏大厅页面, 主要包含:

  1. #screen 用于显示玩家的分数信息
  2. button#match-button 作为匹配按钮.

game_hall.html 的 js 部分代码功能:

  1. 点击匹配按钮, 就会进入匹配逻辑. 同时按钮上提示 “匹配中…(点击取消)” 字样.
  2. 再次点击匹配按钮, 则会取消匹配.
  3. 当匹配成功后, 服务器会返回匹配成功响应, 页面跳转到 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_hall.css">
</head>
<body>
<div class="nav">
    <img src="image/touxiang.png" alt="">
    <span>五子棋对战大厅</span>
    <div class="spacer"></div>
    <a href="logout">退出登录[Logout]</a>

</div>
    <!-- 整个页面的容器元素 -->
    <div class="container">
        <!-- 这个 div 在 container 中是处于垂直水平居中这样的位置的 -->
        <div>
            <!-- 展示用户信息 -->
            <div id="screen"></div>
            <!-- 匹配按钮 -->
            <div id="match-button">开 始 匹 配 ( Play Game )</div>
        </div>
    </div>


    <script src="js/jquery.min.js"></script>
    <script>
        $.ajax({
            type: 'get',
            url: '/userInfo',
            success: function(body) {
                let screenDiv = document.querySelector('#screen');
                screenDiv.innerHTML = '您的信息如下:' + '<br> 姓名: ' + body.username + "," + "天梯分数: " + body.score
                    + "<br> 比赛场次: " + body.totalCount + "," + "获胜场数: " + body.winCount
            },
            error: function() {
                alert("获取用户信息失败!");
            }
        });

        // 此处进行初始化 websocket, 并且实现前端的匹配逻辑. 
        // 此处的路径必须写作 /findMatch, 千万不要写作 /findMatch/ 
        let websocketUrl = 'ws://' + location.host + '/findMatch';//location.host访问 game_hall 同样的IP和端口号,不在此处写死,更加灵活
        let websocket = new WebSocket(websocketUrl);
        websocket.onopen = function() {
            console.log("onopen");
        }
        websocket.onclose = function() {
            console.log("onclose");
        }
        websocket.onerror = function() {
            console.log("onerror");
        }
        // 监听页面关闭事件. 在页面关闭之前, 手动调用这里的 websocket 的 close 方法. 
        window.onbeforeunload = function() {
            websocket.close();
        }

        // 一会重点来实现, 要处理服务器返回的响应
        websocket.onmessage = function(e) {
            // 处理服务器返回的响应数据. 这个响应就是针对 "开始匹配" / "结束匹配" 来对应的
            // 解析得到的响应对象. 返回的数据是一个 JSON 字符串, 解析成 js 对象 JSON.stringify反之
            //扩展:JSON 转换为 Java对象:objectMapper.readValue  反之:writeValueAsString
            let resp = JSON.parse(e.data);
            let matchButton = document.querySelector('#match-button');
            if (!resp.ok) {
                console.log("游戏大厅中接收到了失败响应! " + resp.reason);
                return;
            }
            if (resp.message == 'startMatch') {
                // 开始匹配请求发送成功
                console.log("进入匹配队列成功!");
                matchButton.innerHTML = '匹 配 中 ! ! ( 点 击 停 止 )'
            } else if (resp.message == 'stopMatch') {
                // 结束匹配请求发送成功
                console.log("离开匹配队列成功!");
                matchButton.innerHTML = '开 始 匹 配 ( Play Game )';
            } else if (resp.message == 'matchSuccess') {
                // 已经匹配到对手了. 
                console.log("匹配到对手! 进入游戏房间!");
                // location.assign("/game_room.html");
                location.replace("/game_room.html");//避免用户在浏览器使用回退功能造成逻辑出错,我们在此使用 replace,不会回退到上一历史页面
            } else if (resp.message == 'repeatConnection') {
                alert("同一账号禁止多开! 请使用其他账号登录!");
                location.replace("/login.html");
            } else {
                console.log("收到了非法的响应! message=" + resp.message);
            }
        }

        // 给匹配按钮添加一个点击事件
        let matchButton = document.querySelector('#match-button');
        matchButton.onclick = function() {
            // 在触发 websocket 请求之前, 先确认下 websocket 连接是否好着呢~~ 
            if (websocket.readyState == websocket.OPEN) {
                // 如果当前 readyState 处在 OPEN 状态, 说明连接好着的~
                // 这里发送的数据有两种可能, 开始匹配/停止匹配~
                if (matchButton.innerHTML == '开 始 匹 配 ( Play Game )') {
                    console.log("开 始 匹 配 ( Play Game )");
                    websocket.send(JSON.stringify({
                        message: 'startMatch',
                    }));
                } else if (matchButton.innerHTML == '匹 配 中 ! ! ( 点 击 停 止 )') {
                    console.log("停止匹配");
                    websocket.send(JSON.stringify({
                        message: 'stopMatch',
                    }));
                }
            } else {
                // 这是说明连接当前是异常的状态
                alert("当前您的连接已经断开! 请重新登录!");
                location.replace('/login.html');
            }
        }
    </script>
</body>
</html>

7.3 服务器开发

创建 api.MatchAPI, 继承自 TextWebSocketHandler 作为处理 websocket 请求的入口类,具体如何实现与前端实现 websocket 连接,前面已经写好了的

// 通过这个类来处理匹配功能中的 websocket 请求
@Component
public class MatchAPI extends TextWebSocketHandler {
    //处理JSON格式
    private ObjectMapper objectMapper = new ObjectMapper();

    @Autowired
    private OnlineUserManager onlineUserManager;

    @Autowired
    private Matcher matcher;

    @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 {
    
    }
}

下面我们就来考虑如何实现这几个重写方法 !!!先做好其他准备

  1. 实现用户管理器

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

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

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

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

代码实现如下:

package com.example.java_gobang.game;

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

import java.util.HashMap;
import java.util.concurrent.ConcurrentHashMap;

//这个类用来管理 用户在大厅和游戏房间里的状态
@Component
public class OnlineUserManager {
    // 这个哈希表就用来表示当前用户在游戏大厅在线状态.
    // 避免同时有多个用户并发和服务器建立/断开连接,这里采用线程安全的 ConcurrentHashMap
    private ConcurrentHashMap<Integer, WebSocketSession> gameHall = new ConcurrentHashMap<>();
    // 这个哈希表就用来表示当前用户在游戏房间的在线状态.
    private ConcurrentHashMap<Integer, WebSocketSession> gameRoom = new ConcurrentHashMap<>();


    //进入游戏大厅
    public void enterGameHall(int userId, WebSocketSession webSocketSession) {
        gameHall.put(userId, webSocketSession);
    }

    //离开游戏大厅
    public void exitGameHall(int userId) {
        gameHall.remove(userId);
    }

    //从游戏大厅找到用户
    public WebSocketSession getFromGameHall(int userId) {
        return gameHall.get(userId);
    }

    //进入游戏房间
    public void enterGameRoom(int userId, WebSocketSession webSocketSession) {
        gameRoom.put(userId, webSocketSession);
    }

    //退出游戏房间
    public void exitGameRoom(int userId) {
        gameRoom.remove(userId);
    }

    //从游戏房间找到用户
    public WebSocketSession getFromGameRoom(int userId) {
        return gameRoom.get(userId);
    }
}

  1. 创建匹配请求/响应对象

创建 game.MatchRequest 类

package com.example.java_gobang.game;

// 这是表示一个 websocket 的匹配请求
public class MatchRequest {
    private String message = "";

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }
}

创建 game.MatchResponse 类

// 这是表示一个 websocket 的匹配响应
@Data
public class MatchResponse {
    private boolean ok;
    private String reason;
    private String message;
 }
  1. 处理连接成功

实现 afterConnectionEstablished 方法.

  1. 通过参数中的 session 对象, 拿到之前登录时设置的 User 信息.

  2. 先判定用户是否是已经在线, 如果在线则直接返回出错 (禁止同一个账号多开).

  3. 使用 onlineUserManager 来管理用户的在线状态,设置玩家的上线状态.

代码实现如下:

@Override
 public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        // 玩家上线进入到游戏大厅, 将用户信息加入到 OnlineUserManager 中

        // 1. 先获取到当前用户的身份信息(谁在游戏大厅中, 建立的连接)
        //    此处的代码, 之所以能够 getAttributes, 全靠了在注册 Websocket 的时候,
        //    加上的 .addInterceptors(new HttpSessionHandshakeInterceptor());
        //    这个逻辑就把 HttpSession 中的 Attribute 都给拿到 WebSocketSession 中了
        //    在 Http 登录逻辑中, 往 HttpSession 中存了 User 数据: httpSession.setAttribute("user", user);
        //    此时就可以在 WebSocketSession 中把之前 HttpSession 里存的 User 对象给拿到了.

        //    注意, 此处拿到的 user, 是有可能为空的!!
        //    如果之前用户压根就没有通过 HTTP 来进行登录, 直接就通过 /game_hall.html 这个 url 来访问游戏大厅页面
        //    此时就会出现 user 为 null 的情况
        try {
            User user = (User) session.getAttributes().get("user");

            // 2. 先判定当前用户是否已经登录过(已经是在线状态), 如果是已经在线, 就不该继续进行后续逻辑.禁止多开(同一账号登录多处)
            // 如果使用浏览器多开,会使用户的状态在hash表中同一个key对应两个value,而后一个value会将前一个value覆盖,
            // 所以第一个浏览器的连接就会失效,拿不到websocketsession,也就无法给它推送数据了
            if (onlineUserManager.getFromGameHall(user.getUserId()) != null
                    || onlineUserManager.getFromGameRoom(user.getUserId()) != null) {
                // 当前用户已经登录了!!
                // 针对这个情况要告知客户端, 你这里重复登录了.
                MatchResponse response = new MatchResponse();
                response.setOk(true);
                response.setReason("您已登录,当前禁止多开!");
                response.setMessage("repeatConnection");
                // TestMessage 表示一个文本格式的 websocket 数据包
                session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
                // 此处直接关闭有些太激进了, 还是返回一个特殊的 message response.setMessage("repeatConnection"), 供客户端来进行判定, 由客户端负责进行处理
                // 并且这里的直接关闭会触发后面的 afterConnectionClosed,通过用户id使先登录的浏览器也会断开websocket连接,不科学,我们只需要禁止重复登录就行
                // session.close();
                return;
            }

            // 3. 拿到了身份信息之后, 就可以把玩家设置成在线状态了
            onlineUserManager.enterGameHall(user.getUserId(), session);
            System.out.println("玩家 " + user.getUsername() + " 进入游戏大厅!");
        } catch (NullPointerException e) {
            System.out.println("[MatchAPI.afterConnectionEstablished] 当前用户未登录!");
            // e.printStackTrace(); 不直接打印异常调用栈了,我们在控制台自定义日志输出
            // 出现空指针异常, 说明当前用户的身份信息是空, 用户未登录呢.
            // 把当前用户尚未登录这个信息给返回回去~~
            MatchResponse response = new MatchResponse();
            response.setOk(false);
            response.setReason("您尚未登录! 不能进行后续匹配功能!");

            session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
        }
    }
  1. 处理开始匹配/取消匹配请求

实现 handleTextMessage 方法

  1. 先从会话中拿到当前玩家的信息.
  2. 解析客户端发来的请求
  3. 判定请求的类型, 如果是 startMatch, 则把用户对象加入到匹配队列. 如果是 stopMatch, 则把用户对象从匹配队列中删除.
  4. 此处需要实现一个 匹配器类Matcher, 来处理匹配的实际逻辑.
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        // 实现处理开始匹配请求和处理停止匹配请求.
        User user = (User) session.getAttributes().get("user");
        // 获取到客户端给服务器发送的数据
        String payload = message.getPayload();
        // 当前这个数据载荷是一个 JSON 格式的字符串, 就需要把它转成 Java 对象. MatchRequest
        MatchRequest request = objectMapper.readValue(payload, MatchRequest.class);
        MatchResponse response = new MatchResponse();
        if (request.getMessage().equals("startMatch")) {
            // 进入匹配队列
                matcher.add(user);
            // 把玩家信息放入匹配队列之后, 就可以返回一个响应给客户端了.
            response.setOk(true);
            response.setMessage("startMatch");
        } else if (request.getMessage().equals("stopMatch")) {
            // 退出匹配队列
                matcher.remove(user);
            // 移除之后, 就可以返回一个响应给客户端了.
            response.setOk(true);
            response.setMessage("stopMatch");
        } else {
            response.setOk(false);
            response.setReason("非法的匹配请求");
        }
        String jsonString = objectMapper.writeValueAsString(response);
        session.sendMessage(new TextMessage(jsonString));
    }
  1. 实现匹配器

创建 game.Matcher 类.并按照如下要求实现

  1. 在 Matcher 中创建三个队列 (队列中存储 User 对象), 分别表示不同的段位的玩家.
    (此处约定 <2000 一档, 2000-3000 一档, >3000 一档)
  2. 提供 add 方法, 供 MatchAPI 类来调用, 用来把玩家加入匹配队列.
  3. 提供 remove 方法, 供 MatchAPI 类来调用, 用来把玩家移出匹配队列.
  4. 同时 Matcher 找那个要记录 OnlineUserManager, 来获取到玩家的 Session.
  5. 在 Matcher 的构造方法中, 创建一个线程, 使用该线程扫描每个队列, 把每个队列的头两个元素取出来, 匹配到一组中.
  6. 实现 handlerMatch 处理匹配方法

1.由于 handlerMatch 在单独的线程中调用. 因此要考虑到多用户访问队列的线程安全问题. 需要加上锁.
2.每个队列分别使用队列对象本身作为锁即可.
3.在入口处使用 wait 来等待, 直到队列中达到 2 个元素及其以上, 才唤醒线程消费队列.插入成功后要通知唤醒上面的等待逻辑.
4.需要给上面的插入队列元素, 删除队列元素也加上锁

代码实现如下:

// 这个类表示 "匹配器", 通过这个类负责完成整个匹配功能
@Component
public class Matcher {
    // 创建三个匹配队列
    private Queue<User> normalQueue = new LinkedList<>();
    private Queue<User> highQueue = new LinkedList<>();
    private Queue<User> veryHighQueue = new LinkedList<>();

    @Autowired
    private OnlineUserManager onlineUserManager;

    @Autowired
    private RoomManager roomManager;

    private ObjectMapper objectMapper = new ObjectMapper();

    // 操作匹配队列的方法.
    // 把玩家放到匹配队列中
    //对于不同队列同时进行添加和移除操作是不会产生线程不安全的,而是对同一队列进行,所以要对同一队列对象进行加锁
    public void add(User user) {
        if (user.getScore() < 2000) {
            synchronized (normalQueue) {
                normalQueue.offer(user);
                normalQueue.notify();//有玩家进入匹配队列时,就唤醒该队列 ,对应handlerMatch方法中的 wait
            }
            System.out.println("把玩家 " + user.getUsername() + " 加入到了 normalQueue匹配队列中!");
        } else if (user.getScore() >= 2000 && user.getScore() < 3000) {
            synchronized (highQueue) {
                highQueue.offer(user);
                highQueue.notify();
            }
            System.out.println("把玩家 " + user.getUsername() + " 加入到了 highQueue匹配队列中!");
        } else {
            synchronized (veryHighQueue) {
                veryHighQueue.offer(user);
                veryHighQueue.notify();
            }
            System.out.println("把玩家 " + user.getUsername() + " 加入到了 veryHighQueue匹配队列中!");
        }
    }

    // 当玩家点击停止匹配的时候, 就需要把玩家从匹配队列中删除
    public void remove(User user) {
        if (user.getScore() < 2000) {
            synchronized (normalQueue) {
                normalQueue.remove(user);
            }
            System.out.println("把玩家 " + user.getUsername() + " 移除了 normalQueue匹配队列!");
        } else if (user.getScore() >= 2000 && user.getScore() < 3000) {
            synchronized (highQueue) {
                highQueue.remove(user);
            }
            System.out.println("把玩家 " + user.getUsername() + " 移除了 highQueue匹配队列!");
        } else {
            synchronized (veryHighQueue) {
                veryHighQueue.remove(user);
            }
            System.out.println("把玩家 " + user.getUsername() + " 移除了 veryHighQueue匹配队列!");
        }
    }

    public Matcher() {
        // 创建三个线程, 分别针对这三个匹配队列, 进行操作.
        Thread t1 = new Thread() {
            @Override
            public void run() {
                // 扫描 normalQueue
                while (true) { //在这里循环速度极快,一进入handlerMatch就快速返回,但是当队列中的个数小于2时,这样快速的扫描就没有什么意义,且CPU占用率很高,即出现了忙等
                    handlerMatch(normalQueue);
                    //针对上面的忙等,可以在调用完 handlerMatch 后,进行sleep
                    //这样做可以,但是不完美,比如玩家已经匹配到对手,却还要等待休眠结束后,才能进行游戏
                }
            }
        };
        t1.start();

        Thread t2 = new Thread(){
            @Override
            public void run() {
                while (true) {
                    handlerMatch(highQueue);
                }
            }
        };
        t2.start();

        Thread t3 = new Thread() {
            @Override
            public void run() {
                while (true) {
                    handlerMatch(veryHighQueue);
                }
            }
        };
        t3.start();
    }

    private void handlerMatch(Queue<User> matchQueue) {
        synchronized (matchQueue) {
            try {
                // 1. 检测队列中元素个数是否达到 2
                //    队列的初始情况可能是 空
                //    如果往队列中添加一个元素, 这个时候, 仍然是不能进行后续匹配操作的.
                //    因此在这里使用 while 循环检查是更合理的~~ 只有当 size大于2后,才能进行后续操作
                while (matchQueue.size() < 2) {
                    matchQueue.wait();//队列中的数目一直小于2时,即一直还没有玩家加入队列直到数目达到2以上,就进行线程等待
                }
                // 2. 尝试从队列中取出两个玩家
                User player1 = matchQueue.poll();
                User player2 = matchQueue.poll();
                System.out.println("匹配出两个玩家: " + player1.getUsername() + ", " + player2.getUsername());
                // 3. 获取到玩家的 websocket 的会话
                //    获取到会话的目的是为了告诉玩家, 你排到了~~
                WebSocketSession session1 = onlineUserManager.getFromGameHall(player1.getUserId());
                WebSocketSession session2 = onlineUserManager.getFromGameHall(player2.getUserId());
                // 理论上来说, 匹配队列中的玩家一定是在线的状态.
                // 因为前面的逻辑里进行了处理, 当玩家断开连接的时候就把玩家从匹配队列中移除了.
                // 但是此处仍然进行一次判定~~
                if (session1 == null) {
                    // 如果玩家1 现在不在线了, 就把玩家2 重新放回到匹配队列中
                    matchQueue.offer(player2);
                    return;
                }
                if (session2 == null) {
                    // 如果玩家2 现在下线了, 就把玩家1 重新放回匹配队列中
                    matchQueue.offer(player1);
                    return;
                }
                // 当前能否排到两个玩家是同一个用户的情况嘛? 一个玩家入队列了两次??
                // 理论上也不会存在~~
                // 1) 如果玩家下线, 就会对玩家移出匹配队列
                // 2) 又禁止了玩家多开.
                // 但是仍然在这里多进行一次判定, 以免前面的逻辑出现 bug 时带来严重的后果.
                if (session1 == session2) {
                    // 把其中的一个玩家放回匹配队列.
                    matchQueue.offer(player1);
                    return;
                }

                // 4. 把这两个玩家放到一个游戏房间中.
                Room room = new Room();
                roomManager.add(room, player1.getUserId(), player2.getUserId());

                // 5. 给玩家反馈信息: 你匹配到对手了~
                //    通过 websocket 返回一个 message 为 'matchSuccess' 这样的响应
                //    此处是要给两个玩家都返回 "匹配成功" 这样的信息.
                //    因此就需要返回两次
                MatchResponse response1 = new MatchResponse();
                response1.setOk(true);
                response1.setMessage("matchSuccess");
                String json1 = objectMapper.writeValueAsString(response1);
                session1.sendMessage(new TextMessage(json1));

                MatchResponse response2 = new MatchResponse();
                response2.setOk(true);
                response2.setMessage("matchSuccess");
                String json2 = objectMapper.writeValueAsString(response2);
                session2.sendMessage(new TextMessage(json2));
            } catch (IOException | InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

通过上述匹配器进行玩家匹配,成功匹配出两名玩家后,我们就将玩家加入到一个游戏房间中进行后续具体的游戏操作。所以接下来我们需要完成游戏房间的设置

  1. 创建房间类

创建 game.Room 类

  1. 一个房间要包含一个房间 ID, 使用 UUID 作为房间的唯一身份标识.
  2. 房间内要记录对弈的玩家双方信息.
  3. 记录先手方的 ID
  4. 记录一个 二维数组 , 作为对弈的棋盘.
  5. 记录一个 OnlineUserManager, 以备后面和客户端进行交互.
  6. 当然, 少不了 ObjectMapper 来处理 json
@Data
public class Room {
    private String roomId;
    // 玩家1
    private User user1;
    // 玩家2
    private User user2;
    // 先手方的用户 id
    private int whiteUserId = 0;
    // 棋盘, 数字 0 表示未落子位置. 数字 1 表示玩家 1 的落子. 数字 2 表示玩家 2 的落子
    private static final int MAX_ROW = 15;
    private static final int MAX_COL = 15;
    private int[][] chessBoard = new int[MAX_ROW][MAX_COL];

    private ObjectMapper objectMapper = new ObjectMapper();

    private OnlineUserManager onlineUserManager;



    public Room() {
        // 构造 Room 的时候生成一个唯一的字符串表示房间 id.
        // 使用 UUID 来作为房间 id
        roomId = UUID.randomUUID().toString();

        // 通过入口类中记录的 context 来手动获取到前面的 RoomManager 和 OnlineUserManager
        onlineUserManager = JavaGobangApplication.context.getBean(OnlineUserManager.class);
        roomManager = JavaGobangApplication.context.getBean(RoomManager.class);
        userMapper = JavaGobangApplication.context.getBean(UserMapper.class);
    }
}

具体的游戏操作,我们在后面的对战模块进行补写 !!

  1. 实现房间管理器

Room 对象会存在很多. 每两个对弈的玩家, 都对应一个 Room 对象.需要一个管理器对象RoomMannager 来管理所有的 Room.

创建 game.RoomManager

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

代码实现如下:

// 房间管理器类.
// 这个类也希望有唯一实例.
@Component
public class RoomManager {
    private ConcurrentHashMap<String, Room> rooms = new ConcurrentHashMap<>();

    //将玩家id 和 游戏房间id 的映射关系存储起来
    private ConcurrentHashMap<Integer, String> userIdToRoomId = new ConcurrentHashMap<>();

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

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

    public Room getRoomByRoomId(String roomId) {
        return rooms.get(roomId);
    }
    
    //通过玩家id 找到玩家所在的游戏房间
    public Room getRoomByUserId(int userId) {
        String roomId = userIdToRoomId.get(userId);
        if (roomId == null) {
            // userId -> roomId 映射关系不存在, 直接返回 null
            return null;
        }
        return rooms.get(roomId);
    }
}
  1. 处理连接关闭

实现 afterConnectionClosed

  1. 主要的工作就是把玩家从 onlineUserManager 中退出.
  2. 退出的时候要注意判定, 当前玩家是否是多开的情况(一个userId, 对应到两个 websocket 连接). 如果一个玩家开启了第二个 websocket 连接, 那么这第二个 websocket 连接不会影响到玩家从 OnlineUserManager 中退出.
  3. 如果玩家当前在匹配队列中, 则直接从匹配队列里移除.

代码实现如下:

@Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        try {
            // 玩家下线, 从 OnlineUserManager 中删除
            User user = (User) session.getAttributes().get("user");
            WebSocketSession tmpSession = onlineUserManager.getFromGameHall(user.getUserId());
            if (tmpSession == session) {
                onlineUserManager.exitGameHall(user.getUserId());
            }
            // 如果玩家正在匹配中, 而 websocket 连接断开了, 就应该移除匹配队列
            matcher.remove(user);
        } catch (NullPointerException e) {
            System.out.println("[MatchAPI.afterConnectionClosed] 当前用户未登录!");
            // e.printStackTrace();
            // 不应该在连接关闭之后, 还尝试发送消息给客户端
//            MatchResponse response = new MatchResponse();
//            response.setOk(false);
//            response.setReason("您尚未登录! 不能进行后续匹配功能!");
//            session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
        }
    }
}
  1. 理连接异常

实现 handleTransportError. 逻辑同上.

@Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        try {
            // 玩家下线, 从 OnlineUserManager 中删除
            User user = (User) session.getAttributes().get("user");
            WebSocketSession tmpSession = onlineUserManager.getFromGameHall(user.getUserId());
            if (tmpSession == session) {
                onlineUserManager.exitGameHall(user.getUserId());
            }
            // 如果玩家正在匹配中, 而 websocket 连接断开了, 就应该移除匹配队列
            matcher.remove(user);
        } catch (NullPointerException e) {
            System.out.println("[MatchAPI.handleTransportError] 当前用户未登录!");
//            e.printStackTrace();

//            MatchResponse response = new MatchResponse();
//            response.setOk(false);
//            response.setReason("您尚未登录! 不能进行后续匹配功能!");
//            session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));

            //上述websocket连接都已经断开了,还怎么能发送响应呢 !!!
        }
    }

八、实现对战模块功能

至此,上述逻辑能成功实现当多个玩家开始匹配游戏,两两玩家能被分配到一个游戏房间中进行游戏,至于如何实现游戏逻辑,我们接着来看 !!!

8.1 前后端交互接口

游戏房间页面与后端建立 websocket 连接:

ws://127.0.0.1:8080/game

连接响应:

当两个玩家都连接好了, 则给双方都返回一个数据表示就绪,即双方玩家在游戏大厅点击开始匹配后,匹配成功后,玩家从大厅页面跳转到游戏房间页面,此时房间页面就会与后端GameAPI建立连接,连接成功后就会给双方玩家返回响应

{
message: ‘gameReady’, // 游戏就绪
ok: true, // 是否成功.
reason: ‘’, // 错误原因
roomId: ‘abcdef’, // 房间号. 用来辅助调试.
thisUserId: 1, // 玩家自己的 id
thatUserId: 2, // 对手的 id
whiteUser: 1, // 先手方的 id
}

落子请求:

{
message: ‘putChess’,
userId: 1, //玩家
row: 0, //行
col: 0 //列
}

落子响应:

{
message: ‘putChess’,
userId: 1,
row: 0,
col: 0,
winner: 0
}


8.2 客户端开发

  1. 实现页面基本结构

创建 game_room.html, 表示对战页面即游戏房间

  1. 此处引入了 canvas 标签.这个是 HTML5 引入的 “画布”. 后续的棋盘和棋子的绘制, 就依赖这个画布功能.想了解更多的可自行查看前面提到的链接

  2. #screen 用于显示当前的状态. 例如 “等待玩家连接中…”, “轮到你落子”, “轮到对方落子” 等.

<body id="body">
<div class="nav">
    <img src="image/touxiang.png" alt="">
    <span>五子棋对战房间</span>
</div>

<div class="container">
    <div>
        <!-- 棋盘区域, 需要基于 canvas 进行实现 -->
        <!-- canvas 标签有一组配套的canvas API,通过这些API就可以实现一些画画的效果,如棋盘就是在上面画很多直线,棋子就是画一个圆圈再填充颜色 -->
        <canvas id="chess" width="450px" height="450px">

        </canvas>
        <!-- 显示区域 -->
        <div id="screen"> 等待玩家连接中...</div>
    </div>
</div>
<!-- 直接引入棋盘,不自己深究 -->
<script src="js/script.js"></script>

<style>
    #body {
        background-image: url("image/wuziqi.png");
        background-size: 100% 100%;
        background-attachment: fixed;
    }
</style>
</body>
  1. 实现棋盘/棋子绘制

创建 js/script.js

  1. 这部分代码基于 canvas API. 我们不需要理解这部分内容. 只需要直接复制粘贴下列代码即可.
  2. 使用一个二维数组来表示棋盘. 虽然胜负是通过服务器判定的, 但是客户端的棋盘可以避免 “一个位置重复落子” 这样的情况
  3. oneStep 函数起到的效果是在一个指定的位置上绘制一个棋子. 可以区分出绘制白字还是黑子. 参数是横坐标和纵坐标, 分别对应列和行.
  4. 用 onclick 来处理用户点击事件. 当用户点击的时候通过这个函数来控制绘制棋子.
  5. me 变量用来表示当前是否轮到我落子. over 变量用来表示游戏结束.
let gameInfo = {
    roomId: null,
    thisUserId: null,
    thatUserId: null,
    isWhite: true,
}

//
// 设定界面显示相关操作
//

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数组(表示棋盘的数组)
    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 = "image/qipan.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();
        }
    }

    // 绘制一个棋子, me 为 true
    function oneStep(i, j, isWhite) {//通过 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();
    }
}
  1. 初始化 websocket

在 game_room.html 中, 加入 websocket 的连接代码, 实现前后端交互

  1. 创建 websocket 对象, 并注册 onopen/onclose/onerror 函数. 其中在 onerror 中做一个跳转到游戏大厅的逻辑. 当网络异常断开, 则回到大厅.

  2. 实现 onmessage 方法. onmessage 先处理游戏就绪响应.

// 此处写的路径要写作 /game, 不要写作 /game/
let websocketUrl = "ws://" + location.host + "/game";
let websocket = new WebSocket(websocketUrl);

websocket.onopen = function() {
    console.log("连接游戏房间成功!");
}

websocket.close = function() {
    console.log("和游戏服务器断开连接!");
}

websocket.onerror = function() {
    console.log("和服务器的连接出现异常!");
}

window.onbeforeunload = function() {
    websocket.close();
}

// 处理服务器返回的响应数据  连接到游戏房间后返回的响应
websocket.onmessage = function(event) {
    console.log("[handlerGameReady] " + event.data);
    let resp = JSON.parse(event.data);

    if (!resp.ok) {
        alert("连接游戏失败! reason: " + resp.reason);
        // 如果出现连接失败的情况, 回到游戏大厅
        location.assign("/game_hall.html");
        return;
    }

    //收到后端传来的数据 为gameReady,说明玩家双方都已进入房间,前端为双方玩家设置各自的棋盘显示
    if (resp.message == 'gameReady') {
        gameInfo.roomId = resp.roomId;
        gameInfo.thisUserId = resp.thisUserId;
        gameInfo.thatUserId = resp.thatUserId;
        gameInfo.isWhite = (resp.whiteUser == resp.thisUserId);//如果响应中的 whiteUser 和 thisUserId相等,就说明我自己就是先手方

        // 初始化棋盘
        initGame();
        // 设置显示区域的内容
        setScreenText(gameInfo.isWhite);
    } else if (resp.message == 'repeatConnection') {
        alert("检测到游戏多开! 请使用其他账号登录!");
        location.assign("/login.html");
    }
}
  1. 发送落子请求

在落子操作时加入发送请求的逻辑,实现 send , 通过 websocket 发送落子请求.

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);

            // 留到浏览器收到落子响应的时候再处理(收到响应再来画棋子),考虑网络的原因,如果一发送落子请求就将棋子画下,
            // 假设落子请求并没有传递到服务器,但是又将子落下了,那不就尴尬了
            // oneStep(col, row, gameInfo.isWhite);
            // chessBoard[row][col] = 1;
        }
    }

    function send(row, col) {
        let req = {
            message: 'putChess',
            userId: gameInfo.thisUserId,
            row: row,
            col: col
        };

        websocket.send(JSON.stringify(req));
    }
  1. 处理落子响应

在 initGame 中, 修改 websocket 的 onmessage

  1. 在 initGame 之前, 处理的是游戏就绪响应, 在收到游戏响应之后, 就改为接收落子响应了.
  2. 在处理落子响应中要处理胜负手.
// 之前 websocket.onmessage 主要是用来处理了游戏就绪响应. 在游戏就绪之后, 初始化完毕之后, 也就不再有这个游戏就绪响应了. 
    // 就在这个 initGame 内部, 修改 websocket.onmessage 方法~~, 让这个方法里面针对落子响应进行处理!
    websocket.onmessage = function(event) {
        console.log("[handlerPutChess] " + event.data);

        let resp = JSON.parse(event.data);
        if (resp.message != 'putChess') {
            console.log("响应类型错误!");
            return;
        }
        // 先判定当前这个响应是自己落的子, 还是对方落的子.
        if (resp.userId == gameInfo.thisUserId) {
            // 我自己落的子
            // 根据我自己棋子的颜色, 来绘制一个棋子
            oneStep(resp.col, resp.row, gameInfo.isWhite);//oneStep是先在内存缓冲区中进行绘制,还没来得及刷新到前端界面,此时alert就看不到落子效果了
        } else if (resp.userId == gameInfo.thatUserId) {
            // 我的对手落的子
            oneStep(resp.col, resp.row, !gameInfo.isWhite);
        } else {
            // 响应错误! userId 是有问题的!
            console.log('[handlerPutChess] resp userId 错误!');
            return;
        }

        // 给对应的位置设为 1, 方便后续逻辑判定当前位置是否已经有棋子了.
        chessBoard[resp.row][resp.col] = 1;

        // 交换双方的落子轮次
        me = !me;
        setScreenText(me);

        // 判定游戏是否结束
        let screenDiv = document.querySelector('#screen');
        if (resp.winner != 0) {
            if (resp.winner == gameInfo.thisUserId) {
                // alert('你赢了!');  所以这里我们不用模态对话框,他会把整个页面阻塞,导致onStep来不及将落子效果刷新到界面,此时界面就已经被阻塞住了!!
                screenDiv.innerHTML = '恭 喜 ! 你 赢 了'; //直接在页面中显示胜负,不使用弹窗
            } else if (resp.winner = gameInfo.thatUserId) {
                // alert('你输了!');
                screenDiv.innerHTML = '抱 歉 ! 你 输 了';
            } else {
                alert("winner 字段错误! " + resp.winner);
            }
            // 回到游戏大厅
            // location.assign('/game_hall.html');
            // 增加一个按钮, 让玩家点击之后, 再回到游戏大厅~   避免玩家还没看到游戏胜负就快速跳转到大厅页面
            let backBtn = document.createElement('button');
            backBtn.innerHTML = '回 到 游 戏 大 厅';
            backBtn.className= "backBtn"
            backBtn.onclick = function() {
                location.replace('/game_hall.html');
            }
            let fatherDiv = document.querySelector('.container>div');
            fatherDiv.appendChild(backBtn);
        }
    }
}

8.3 服务器开发

  1. 创建并注册 GameAPI 类

创建 api.GameAPI , 处理 websocket 请求.

  1. 准备好一个 ObjectMapper
  2. 注入一个 RoomManager 和 OnlineUserMananger
@Component
public class GameAPI extends TextWebSocketHandler {
    private ObjectMapper objectMapper = new ObjectMapper();
    @Autowired
    private RoomManager roomManager;
    // 这个是管理 game 页面的会话
    @Autowired
    private OnlineUserManager onlineUserManager;

    @Resource
    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 {
    }
}

记得将 GameAPI和前面一样进行注册 !!!

  1. 创建落子请求/响应对象

  1. 处理连接成功

实现 GameAPI 的 afterConnectionEstablished 方法.

  1. 首先需要检测用户的登录状态. 从 Session 中拿到当前用户信息.
  2. 然后要判定当前玩家是否是在房间中.
  3. 接下来进行多开判定.如果玩家已经在游戏中, 则不能再次连接.
  4. 把两个玩家放到对应的房间对象中. 当两个玩家都建立了连接, 房间就放满了.这个时候通知两个玩家双方都准备就绪.
  5. 如果有第三个玩家尝试也想加入房间, 则给出一个提示, 房间已经满了.
 @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        GameReadyResponse resp = new GameReadyResponse();

        // 1. 先获取到用户的身份信息. (从 HttpSession 里拿到当前用户的对象)
        User user = (User) session.getAttributes().get("user");
        if (user == null) {
            resp.setOk(false);
            resp.setReason("用户尚未登录!");
            session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));//将java对象转换成json格式字符串
            return;
        }

        // 2. 判定当前用户是否已经进入房间. (拿着房间管理器进行查询),只有在房间中才能进行后续的游戏对战逻辑
        Room room = roomManager.getRoomByUserId(user.getUserId());
        if (room == null) {
            // 如果为 null, 当前没有找到对应的房间. 该玩家还没有匹配到.
            resp.setOk(false);
            resp.setReason("用户尚未匹配到对手,还未进入游戏房间!");
            session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
            return;
        }

        // 3. 判定当前是不是多开 (该用户是不是已经在其他地方进入游戏了)
        //    前面准备了一个 OnlineUserManager,不仅要查看用户在大厅的状态 还要查看在游戏房间的状态
        if (onlineUserManager.getFromGameHall(user.getUserId()) != null
                || onlineUserManager.getFromGameRoom(user.getUserId()) != null) {
            // 如果一个账号, 一边是在游戏大厅, 一边是在游戏房间, 也视为多开~~
            resp.setOk(true);
            resp.setReason("禁止多开游戏页面");
            resp.setMessage("repeatConnection");
            session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
            return;
        }

        // 4. 设置当前玩家上线,进入游戏房间!
        onlineUserManager.enterGameRoom(user.getUserId(), session);


        // 5. 把两个玩家加入到游戏房间中.
        //    前面的创建房间/匹配过程, 是在 game_hall.html 页面中完成的.
        //    因此前面匹配到对手之后, 需要经过页面跳转, 来到 game_room.html 才算正式进入游戏房间(才算玩家准备就绪)
        //    当前这个逻辑是在 game_room.html 页面加载的时候进行的.
        //    执行到当前逻辑, 说明玩家已经页面跳转成功了!!
        //    页面跳转, 其实是个大活~~ (很有可能出现 "失败" 的情况的)
        synchronized (room) {
            //在这个逻辑里,两个同水平玩家同时访问/修改同一个room对象,可能同时执行到这个逻辑,都想第一个进入房间成为先手玩家
            //所以只需要针对 room 加锁,当玩家1访问修改room时,给room上锁,玩家2就不能访问到。注意:不同水平的玩家是不会存在竞争同一个room的
            if (room.getUser1() == null) {
                // 第一个玩家还尚未加入房间.
                // 就把当前连上 websocket 的玩家作为 user1, 加入到房间中.
                room.setUser1(user);
                // 把先连入房间的玩家作为先手方.  先手规则多种多样可自行设置,如谁的天梯分高谁先
                room.setWhiteUser(user.getUserId());
                System.out.println("玩家: " + user.getUsername() + " 已经准备就绪! 作为玩家1");
                return;
            }
            if (room.getUser2() == null) {
                // 如果进入到这个逻辑, 说明玩家1 已经加入房间, 现在要给当前玩家作为玩家2 了
                room.setUser2(user);
                System.out.println("玩家: " + user.getUsername() + " 已经准备就绪! 作为玩家2");

                // 当两个玩家都加入成功之后, 就要让服务器, 给这两个玩家都返回 websocket 的响应数据.
                // 通知这两个玩家说, 游戏双方都已经准备好了.前端接收到gameReady信息后,为双方玩家布置游戏房间页面

                // 通知玩家1
                noticeGameReady(room, room.getUser1(), room.getUser2());
                // 通知玩家2
                noticeGameReady(room, room.getUser2(), room.getUser1());
                return;
            }
        }

        // 6. 此处如果又有玩家尝试连接同一个房间, 就提示报错.
        //    这种情况理论上是不存在的, 为了让程序更加的健壮, 还是做一个判定和提示.
        resp.setOk(false);
        resp.setReason("当前房间已满, 您不能加入房间");
        session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
    }

实现通知玩家就绪:

//两玩家都加入游戏房间成功后的通知方法,前端接收到信息后,为双方玩家布置游戏房间页面
    private void noticeGameReady(Room room, User thisUser, User thatUser) throws IOException {
        GameReadyResponse resp = new GameReadyResponse();
        resp.setMessage("gameReady");
        resp.setOk(true);
        resp.setReason("");
        resp.setRoomId(room.getRoomId());
        resp.setThisUserId(thisUser.getUserId());
        resp.setThatUserId(thatUser.getUserId());
        resp.setWhiteUser(room.getWhiteUser());
        // 把当前的响应数据传回给玩家.
        WebSocketSession webSocketSession = onlineUserManager.getFromGameRoom(thisUser.getUserId());
        webSocketSession.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
    }
  1. 玩家下线的处理

处理玩家中途退出

下线注意要针对多开的情况进行判定

@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
    User user = (User) session.getAttributes().get("user");
    if (user == null) {
        return;
    }
    WebSocketSession existSession = onlineUserManager.getSessionFromGameRoom(user.getUserId());
    if (existSession != session) {
        System.out.println("当前的会话不是玩家游戏中的会话, 不做任何处理!");
        return;
    }
    System.out.println("连接出错! userId=" + user.getUserId());
    onlineUserManager.exitGameRoom(user.getUserId());
}

@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
    User user = (User) session.getAttributes().get("user");
    if (user == null) {
        return;
    }
    WebSocketSession existSession = onlineUserManager.getSessionFromGameRoom(user.getUserId());
    if (existSession != session) {
        System.out.println("当前的会话不是玩家游戏中的会话, 不做任何处理!");
        return;
    }
    System.out.println("用户退出! userId=" + user.getUserId());
    onlineUserManager.exitGameRoom(user.getUserId());
}
  1. 处理落子请求

实现 handleTextMessage 方法

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        // 1. 先从 session 里拿到当前用户的身份信息
        User user = (User) session.getAttributes().get("user");
        if (user == null) {
            System.out.println("[handleTextMessage] 当前玩家尚未登录! ");
            return;
        }

        // 2. 根据玩家 id 获取到房间对象
        Room room = roomManager.getRoomByUserId(user.getUserId());
        // 3. 通过 room 对象来处理 websocket 具体的落子请求
        room.putChess(message.getPayload());
    }

处理前端发来的落子请求,调用room.putChess 方法进行落子逻辑

  1. 对游戏房间 room 整体的修改

1、给 Room 类里加上 RoomManager 实例 和 UserMapper 实例,Room 类内部要在游戏结束的时候销毁房间, 需要用到 RoomManager,Room 类内部要修改玩家的分数, 需要用到 UserMapper

2、由于我们的 Room 并没有通过 Spring 来管理. 因此内部就无法通过 @Autowired 来自动注入.
需要手动的通过 SpringBoot 的启动类来获取里面的对象.

3、实现 room 中的 putChess 方法.

  1. 先把请求解析成请求对象.
  2. 根据请求对象中的信息, 往棋盘上落子.
  3. 落子完毕之后, 为了方便调试, 可以打印出棋盘的当前状况.
  4. 检查游戏是否结束.
  5. 构造落子响应, 写回给每个玩家.
  6. 写回的时候如果发现某个玩家掉线, 则判定另一方为获胜.
  7. 如果游戏胜负已分, 则修改玩家的分数, 并销毁房间.

4、实现胜负判定

  1. 如果游戏分出胜负, 则返回玩家的 id. 如果未分出胜负,则返回 0.
  2. 棋盘中值为 1 表示是玩家 1 的落子, 值为 2 表示是玩家 2 的落子.
  3. 检查胜负的时候, 以当前落子位置为中心, 检查所有相关的行,列, 对角线即可. 不必遍历整个棋盘.

具体代码实现如下:

// 这个类就表示一个游戏房间
@Data
public class Room {
    // 使用字符串类型来表示, 方便生成唯一值.
    private String roomId;

    private User user1;
    private User user2;

    // 先手方的玩家 id
    private int whiteUser;

    private static final int MAX_ROW = 15;
    private static final int MAX_COL = 15;
    // 这个二维数组用来表示棋盘
    // 约定:
    // 1) 使用 0 表示当前位置未落子. 初始化好的 int 二维数组, 就相当于是 全 0
    // 2) 使用 1 表示 user1 的落子位置
    // 3) 使用 2 表示 user2 的落子位置
    private int[][] board = new int[MAX_ROW][MAX_COL];

    // 创建 ObjectMapper 用来转换 JSON
    private ObjectMapper objectMapper = new ObjectMapper();

    // 引入 OnlineUserManager
    // @Autowired
    private OnlineUserManager onlineUserManager;

    // 引入 RoomManager, 用于房间销毁
    // @Autowired
    private RoomManager roomManager;
    //@Autowired
    private UserMapper userMapper;

    //说明:onlineUserManager roomManager userMapper 不能通过注解的方式注入,想使用注解就必须将 Room 类添加注解加入到spring容器中
    //而添加注解后,Room 就成了单例,又Room room 会有很多实例,即很多游戏房间,所以必须要多例,spring容器也可以实现多例,但我们换另一种方法
    //直接在Room 的构造方法中 手动注入 !!!


    // 通过这个方法来处理一次落子操作.
    public void putChess(String reqJson) throws IOException {
        // 1. 记录当前落子的位置.
        GameRequest request = objectMapper.readValue(reqJson, GameRequest.class);
        GameResponse response = new GameResponse();
        // 当前这个子是玩家1 落的还是玩家2 落的. 根据这个玩家1 和 玩家2 来决定往数组中是写 1 还是 2
        int chess = request.getUserId() == user1.getUserId() ? 1 : 2;
        int row = request.getRow();
        int col = request.getCol();
        if (board[row][col] != 0) {
            // 在客户端已经针对重复落子进行过判定了. 此处为了程序更加稳健, 在服务器再判定一次.
            System.out.println("当前位置 (" + row + ", " + col + ") 已经有子了!");
            return;
        }
        board[row][col] = chess;
        // 2. 打印出当前的棋盘信息, 方便来观察局势. 也方便后面验证胜负关系的判定.
        printBoard();

        // 3. 每一次落子后都要进行胜负判定
        int winner = checkWinner(row, col, chess);

        // 4. 将每一次的落子信息响应返回给房间中的所有客户端.
        response.setMessage("putChess");
        response.setUserId(request.getUserId());
        response.setRow(row);
        response.setCol(col);
        response.setWinner(winner);

        // 要想给用户发送 websocket 数据, 就需要获取到这个用户的 WebSocketSession
        WebSocketSession session1 = onlineUserManager.getFromGameRoom(user1.getUserId());
        WebSocketSession session2 = onlineUserManager.getFromGameRoom(user2.getUserId());
        // 万一当前查到的会话为空(玩家已经下线了) 特殊处理一下
        if (session1 == null) {
            // 玩家1 已经下线了. 直接认为玩家2 获胜!
            response.setWinner(user2.getUserId());
            System.out.println("玩家1 掉线! 玩家2获胜");
        }
        if (session2 == null) {
            // 玩家2 已经下线. 直接认为玩家1 获胜!
            response.setWinner(user1.getUserId());
            System.out.println("玩家2 掉线! 玩家1获胜");
        }

        // 把响应构造成 JSON 字符串, 通过 session 进行传输.
        String respJson = objectMapper.writeValueAsString(response);

        if (session1 != null) {
            session1.sendMessage(new TextMessage(respJson));
        }
        if (session2 != null) {
            session2.sendMessage(new TextMessage(respJson));
        }

        // 5. 如果当前胜负已分, 此时这个房间就失去存在的意义了. 就可以直接销毁房间. (把房间从房间管理器中给移除)
        if (response.getWinner() != 0) {
            // 胜负已分
            System.out.println("游戏结束! 房间即将销毁! roomId=" + roomId + " 获胜方为: " + response.getWinner());
            // 更新获胜方和失败方的数据库信息.
            int winUserId = response.getWinner();
            int loseUserId = response.getWinner() == user1.getUserId() ? user2.getUserId() : user1.getUserId();
            userMapper.userWin(winUserId);
            userMapper.userLose(loseUserId);
            // 销毁房间
            roomManager.remove(roomId, user1.getUserId(), user2.getUserId());
        }
    }

    private void printBoard() {
        // 打印出棋盘
        System.out.println("[打印棋盘信息] " + roomId);
        System.out.println("=====================================================================");
        for (int r = 0; r < MAX_ROW; r++) {
            for (int c = 0; c < MAX_COL; c++) {
                // 针对一行之内的若干列, 不要打印换行
                System.out.print(board[r][c] + " ");
            }
            // 每次遍历完一行之后, 再打印换行.
            System.out.println();
        }
        System.out.println("=====================================================================");
    }

    // 使用这个方法来判定当前落子是否分出胜负.
    // 约定如果玩家1 获胜, 就返回玩家1 的 userId
    // 如果玩家2 获胜, 就返回玩家2 的 userId
    // 如果胜负未分, 就返回 0
    private int checkWinner(int row, int col, int chess) {
        //注意:五子连珠,肯定是当第五颗子落下时,才会有效果!所以我们只需要以五子连珠的最后一颗子为中心来判断
        // 1. 检查所有的行

        //外层循坏有五种情况: (left,1,1,1,Last)、(left,1,1,Lsat,1),(left,1,Last,1,1).......
        //我们先判断五子连珠最左侧的这颗子的列范围,再根据它来推出其余子
        //如下:col为五子连珠最后一颗子的列,c为从最左侧往最后一颗子中间的子
        for (int c = col - 4; c <= col; c++) {
            // 针对其中的一种情况, 来判定这五个子是不是连在一起了~
            // 不光是这五个子得连着, 而且还得和玩家落的子是一样~~ (才算是获胜)
            try {
                if (board[row][c] == chess              //最左侧的子的chess 和 最后下的一颗子的chess一样
                        && board[row][c + 1] == chess   //最左侧的右边第一个..
                        && board[row][c + 2] == chess   //.....
                        && board[row][c + 3] == chess
                        && board[row][c + 4] == chess) {
                    // 五个位置的chess都一样,构成了五子连珠! 胜负已分!
                    return chess == 1 ? user1.getUserId() : user2.getUserId();
                }
            } catch (ArrayIndexOutOfBoundsException e) {
                // 如果出现数组下标越界的情况, 就在这里直接忽略这个异常.
                continue;
            }
        }

        // 2. 检查所有列
        for (int r = row - 4; r <= row; r++) {
            try {
                if (board[r][col] == chess
                    && board[r + 1][col] == chess
                    && board[r + 2][col] == chess
                    && board[r + 3][col] == chess
                    && board[r + 4][col] == chess) {
                    return chess == 1 ? user1.getUserId() : user2.getUserId();
                }
            } catch (ArrayIndexOutOfBoundsException e) {
                continue;
            }
        }

        // 3. 检查左对角线
        for (int r = row - 4, c = col - 4; r <= row && c <= col; r++, c++) {
            try {
                if (board[r][c] == chess
                    && board[r + 1][c + 1] == chess
                    && board[r + 2][c + 2] == chess
                    && board[r + 3][c + 3] == chess
                    && board[r + 4][c + 4] == chess) {
                    return chess == 1 ? user1.getUserId() : user2.getUserId();
                }
            } catch (ArrayIndexOutOfBoundsException e) {
                continue;
            }
        }

        // 4. 检查右对角线
        for (int r = row - 4, c = col + 4; r <= row && c >= col; r++, c--) {
            try {
                if (board[r][c] == chess
                    && board[r + 1][c - 1] == chess
                    && board[r + 2][c - 2] == chess
                    && board[r + 3][c - 3] == chess
                    && board[r + 4][c - 4] == chess) {
                    return chess == 1 ? user1.getUserId() : user2.getUserId();
                }
            } catch (ArrayIndexOutOfBoundsException e) {
                continue;
            }
        }

        // 胜负未分, 就直接返回 0 了.
        return 0;
    }

    public Room() {
        // 构造 Room 的时候生成一个唯一的字符串表示房间 id.
        // 使用 UUID 来作为房间 id
        roomId = UUID.randomUUID().toString();

        // 通过入口类中记录的 context 来手动获取到前面的 RoomManager 和 OnlineUserManager
        onlineUserManager = JavaGobangApplication.context.getBean(OnlineUserManager.class);
        roomManager = JavaGobangApplication.context.getBean(RoomManager.class);
        userMapper = JavaGobangApplication.context.getBean(UserMapper.class);
    }
}

九、添加拦截器

创建 LoginInterceptor 拦截器类,实现HandlerInterceptor 接口,重写 preHandle方法

package com.example.java_gobang.config;

import org.springframework.web.servlet.HandlerInterceptor;

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

public class LoginInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        HttpSession session = request.getSession(false);
        if (session != null && session.getAttribute("user") != null){
            return true;
        }
        response.sendRedirect("/login.html");
        return false;
    }
}

将拦截器注入到系统配置中

package com.example.java_gobang.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class AppConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        LoginInterceptor loginInterceptor = new LoginInterceptor();
        registry.addInterceptor(loginInterceptor)
                .addPathPatterns("/**")
                .excludePathPatterns("/**/login.html")
                .excludePathPatterns("/**/register.html")
                .excludePathPatterns("/**/css/**.css")
                .excludePathPatterns("/**/image/**")
                .excludePathPatterns("/**/js/**.js")
                .excludePathPatterns("/**/login")
                .excludePathPatterns("/**/register");
    }

}

十、项目拓展方向

  1. 实现局时, 步时

局时: 一局游戏中玩家能思考的总时间.

步时: 一步落子过程中, 玩家能思考的时间.

例如, 给每一局游戏设定 10 分钟局时, 1 分钟步时.
在页面上使用 JS 中的定时器, 来实时的显示当前剩余时间.
如果某玩家超时, 则直接判定对方获胜.

  1. 保存棋谱&录像回放

首先需要在数据库中创建一个新的表, 用来表示每个玩家的游戏房间编号.
服务器把每一局对局, 玩家轮流落子的位置都记录下来(比如保存到一个文本文件中).
然后玩家可以选定某个曾经的比萨, 在页面上回放出对局的过程.

  1. 观战功能

在游戏大厅除了显示匹配按钮之外, 还能显示当前所有的对局房间.玩家可以选中某个房间, 以观众的形式加入到房间中. 同时能实时的看到选手的对局情况.

  1. 聊天功能

同一个房间中的选手之间可以发送文本消息.

  1. 人机对战

支持 AI 功能, 实现人机对战.

  1. 虚拟对手

如果当前长时间匹配不到选手, 则自动分配一个 AI 对手.

本文标签: 五子对战平台javagobang