五子棋对战(网页版)

目录

一、项目背景

用户模块

匹配模块

对战模块

二、核心技术

三、相关知识

WebSocket

原理

报文格式

代码

服务器代码

客户端代码

四、项目创建

4.1、实现用户模块

编写数据库代码

数据库设计

配置MyBatis

创建实体类

创建UserMapper

创建UserMapper接口

实现UserMapper.xml

前后端接口交互

登录接口

请求

响应

注册接口

请求

响应

获取用户信息

请求

响应

服务器开发

客户端开发

登录页面

注册页面

4.2、实现匹配模块

前后端接口交互

客户端开发

实现页面基本结构

实现匹配功能

服务器开发

创建并注册MatchAPI类

实现用户管理器

创建匹配请求/响应对象

处理上线下线状态

处理开始匹配/取消匹配请求

实现匹配器

创建房间类

实现房间管理器

实现匹配器

4.3、实现对战模块

前后端交互接口

客户端开发

实现页面基本结构

实现棋盘绘制

初始化websocket

发送落子请求

处理落子响应

服务器开发

创建落子请求/响应对象

处理连接成功

玩家下线的处理

处理落子请求

修改Room类

实现对弈功能

处理途中玩家掉线

更新玩家分数

五、部署云服务器

构造数据库中的数据

调整websocket建立连接的url

打包上传

通过外网访问

六、后续扩展功能

计时

保存棋谱/录像回放

观战功能

界面聊天

人机对战


一、项目背景

用户模块

用户的注册和登录

管理用户的天梯分数,比赛场数,获胜场数等信息

匹配模块

依据用户的天梯积分,实现匹配机制

对战模块

把两个匹配到的玩家放到一个游戏房间中,对方通过网页的形式来进行对战比赛

二、核心技术

Spring/SpringBoot/SpringMVC

WebSocket

MySQL

MyBatis

HTML/CSS/JS/Ajax

三、相关知识

WebSocket

原理

WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在WebSocket API中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。

报文格式

代码

spring内置websocket,可以直接进行使用

服务器代码

新建api.TestAPI类

用来处理websocket请求,并返回响应(websocket内置一组session,通过这个session可以给客户端返回数据,或者主动断开连接)


@Component
public class TestAPI extends TextWebSocketHandler {

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        System.out.println("连接成功");
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        System.out.println("接收消息:"+message.getPayload());
    }

    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        System.out.println("连接异常");
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        System.out.println("连接关闭");
    }
}

创建config.WebSocketConfig类

这个类用来配置请求路径和TextWebSocketHandler之间的关系


@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

    @Autowired
    private TestAPI testAPI;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(testAPI,"/test");
    }
}
客户端代码

<!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>TestAPI</title>
</head>
<body>
    <input type="text" id="message">
    <button id="submit">提交</button>

    <script>
        //创建websocket实例
        let websocket=new WebSocket("ws://127.0.0.1:8080/test");
        //给实例挂一些回调函数
        websocket.onopen=function(){
            console.log("建立连接!");
        }

        websocket.onmessage=function(e){
            console.log("收到消息!"+e.data);
        }

        websocket.onerror=function(){
            console.log("连接异常!");
        }

        websocket.onclose=function(){
            console.log("连接关闭!");
        }

        //实现点击按钮后,通过websocket发送请求
        let input=document.querySelector('#message');
        let button=document.querySelector('#submit');
        button.onclick=function(){
            console.log("发送消息"+input.value);
            websocket.send(input.value);
        }
    </script>
</body>
</html>

四、项目创建

4.1、实现用户模块

编写数据库代码

数据库设计

创建user表,表示用户信息和分数信息


create database if not exists java_gobang;

use java_gobang;

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

insert into user value(null,'baekhyun','2012',1000,0,0);
insert into user value(null,'DO','2012',1000,0,0);
insert into user value(null,'sehun','2012',1000,0,0);
insert into user value(null,'sohu','2012',1000,0,0);
insert into user value(null,'chanyeol','2012',1000,0,0);
insert into user value(null,'kai','2012',1000,0,0);
配置MyBatis

创建application.yml


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

#
mybatis:
  mapper-locations: classpath:mapper/**Mapper.xml
创建实体类

public class User {
    private int userId;
    private String username;
    private String password;
    private int score;
    private int totalCount;
    private int winCount;


    public int getUserId() {
        return userId;
    }

    public void setUserId(int userId) {
        this.userId = userId;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public int getScore() {
        return score;
    }

    public void setScore(int score) {
        this.score = score;
    }

    public int getTotalCount() {
        return totalCount;
    }

    public void setTotalCount(int totalCount) {
        this.totalCount = totalCount;
    }

    public int getWinCount() {
        return winCount;
    }

    public void setWinCount(int winCount) {
        this.winCount = winCount;
    }
}
创建UserMapper
创建UserMapper接口

package com.example.java_gobang.model;

@Mapper
public interface UserMapper {

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

    //往数据库里插入一个用户,用于注册功能
    void insert(User user);
}
实现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.model.UserMapper">

    <select id="selectByName" resultType="com.example.java_gobang.model.User">
        select * from user where username=#{username};
    </select>

    <insert id="insert">
        insert into user values(null,#{username},#{password},1000,0,0);
    </insert>
    
</mapper>

前后端接口交互

登录接口
请求
POST /login HTTP/ 1.1
Content-Type: application/x-www-form-urlencoded
username=baekhyun&password=2012
响应
HTTP/ 1.1 200 OK
Content-Type: application/json

{
userId: 1,
username: 'baekhyun',
score: 1000,
totalCount: 0,
winCount: 0
}

如果登录失败, 返回的是一个无效的user对象

注册接口
请求
POST /register HTTP/ 1.1
Content-Type: application/x-www-form-urlencoded
username=baekhyun&password=2012
响应
HTTP/ 1.1 200 OK
Content-Type: application/json

{
userId: 1,
username: 'baekhyun',
score: 1000,
totalCount: 0,
winCount: 0
}
获取用户信息
请求
GET /userInfo HTTP/ 1.1
响应
HTTP/ 1.1 200 OK
Content-Type: application/json
{
userId: 1,
username: 'baekhyun',
score: 1000,
totalCount: 0,
winCount:0
}

服务器开发

实现三种方法:

  1. login:用来实现登录逻辑;

  1. register:用来实现注册逻辑;

  1. getUserInfo:用来实现登录成功后显示用户分数的信息


@RestController
public class UserAPI {

    @Resource
    private UserMapper userMapper;

    @PostMapping("/login")
    @ResponseBody
    public Object login(String username, String password, HttpServletRequest req){
        //根据username在数据库中进行查询
        //如果找到匹配的用户,并且密码也一致,就认为登录成功
        User user= userMapper.selectByName(username);
        System.out.println("[login] username="+username);
        if (user==null || !user.getPassword().equals(password)){
            System.out.println("登录失败!");
            return new User();
        }
        HttpSession httpsession=req.getSession(true);
        httpsession.setAttribute("user",user);
        return user;
    }

    @PostMapping("/register")
    @ResponseBody
    public Object register(String username,String password){
        try {
            User user=new User();
            user.setUsername(username);
            user.setPassword(password);
            userMapper.insert(user);
            return user;
        }catch (org.springframework.dao.DuplicateKeyException){
            User user=new User();
            return user;
        }
    }

    @GetMapping("/userinfo")
    @ResponseBody
    public Object getUserInfo(HttpServletRequest req){
            try {
                HttpSession httpSession=req.getSession(false);
                User user=(User) httpSession.getAttribute("user");
                return user;
            }catch (NullPointerException e){
                return new User();
            }
    }
}

客户端开发

登录页面

login.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">
</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">提交</button> 
            </div>
        </div>  
    </div>
</body>
</html>

common.css


* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

html, body {
    height: 100%;
    background-image: url(../image/1.png);
    background-repeat: no-repeat;
    background-position: center;
    background-size: cover;
}

.nav {
    width: 100%;
    height: 50px;
    background-color: rgb(51, 51, 51);
    color: white;

    display: flex;
    align-items: center;
    line-height: 50px;
    padding-left: 20px;
}

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

    display: flex;
    justify-content: center;
    align-items: center;
    background-color: rgba(255, 255, 255, 0.7);
}

login.css


.login-container {
    width: 100%;
    height: calc(100% - 50px);
    display: flex;
    justify-content: center;
    align-items: center;
}

.login-dialog {
    width: 400px;
    height: 320px;
    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;
    justify-content: center;
    align-items: center;
}

.login-dialog .row span {
    display: block;
    /* 设置固定宽度, 能让文字和后面的输入框之间有间隙 */
    width: 100px;
    font-weight: 700;
}

.login-dialog #username,
.login-dialog #password {
    width: 200px;
    height: 40px;
    font-size: 20px;
    text-indent: 10px;
    border-radius: 10px;
    border: none;
    outline: none;
}

.login-dialog .submit-row {
    margin-top: 10px;
}

.login-dialog #submit {
    width: 300px;
    height: 50px;
    color: white;
    background-color: rgb(133, 23, 23);
    border: none;
    border-radius: 10px;
    font-size: 20px;
}

.login-dialog #submit:active {
    background-color: #666;
}

通过 jQuery 中的 AJAX 和服务器进行交互(在login.html中写js)


    <script src="./js/jquery.min.js"></script>
    <script>
        let usernameInput=document.querySelector("#username");
        let passwordInput=document.querySelector("#password");
        let submitButton=document.querySelector("#submit");
        submitButton.onclick=function(){
            $.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>
注册页面

register.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">
</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">提交</button> 
            </div>
        </div>  
    </div>
</body>
</html>

4.2、实现匹配模块

前后端接口交互

连接

ws://127.0.0.1:8080/findMatch

请求

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

响应1(收到请求后立即响应)

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

响应2(匹配成功后的响应)

{
ok: true, // 是否成功. 比如用户 id 不存在, 则返回 false
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>
            <!--展示用户信息-->
            <div id="screen"></div>
            <!--匹配按钮-->
            <div id="match-button">开始匹配</div>
        </div>
    </div>
</body>
</html>

game_hall.css




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

}

#match-button {
    width: 400px;
    height: 50px;
    font-size: 20px;
    line-height: 50px;
    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;
}

编写js代码来实现用户的信息


    <script src="js/jquery.min.js"></script>
    <script>
        $.ajax({
            type:'get',
            url:'/userInfo',
            success:function(body){
                let screenDiv=document.querySelector('#screen');
                screenDiv.innerHTML='玩家:'+body.username+"分数:"+body.score+"<br> 比赛场次:"+body.totalCount+"获胜次数:"+body.winCount
            },
            error:function(){
                alert("获取用户信息失败!");
            }
        });
    </script>
实现匹配功能

点击匹配按钮,就会进入匹配逻辑,同时按钮上提示“匹配中...(点击取消)”

再次点击匹配按钮,则会取消匹配

当匹配成功后,服务器会返回匹配成功响应,页面跳转到游戏房间


        //初始化websockrt,并且实现前端的匹配逻辑
        let websocket=new WebSocket('ws://127.0.0.1:8080/findMatch');
        websocket.onopen=function(){
            console.log("onopen");
        }
        websocket.onclose=function(){
            console.log("onclose");
        }
        websocket.onerror=function(){
            console.log("onerror");
        }

        //监听页面关闭事件,在页面关闭之前,手动调用这里的websocket的close方法
        window.onbeforeload=function(){
            websocket.close();
        }

        //处理服务器返回的响应
        websocket.onmessage=function(e){
            //针对服务器返回的响应数据,这个响应就是针对“开始匹配”/“结束匹配”来对应的
            //解析得到的响应对象,返回的数据是一个JSON字符串,解析成js对象
            let resp=JSON.parse(e.data);
            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='开始匹配';
            }else if(resp.message=='matchSuccess'){
                // 匹配到了对手
                console.log("匹配成功!进入游戏界面!");
                location.assign("/game_room.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 == '开始匹配') {
                    console.log('开始匹配!');
                    websocket.send(JSON.stringify({
                        message: 'startMatch',
                    }));
                } else if (matchbutton.innerHTML == '匹配中...(点击停止)') {
                    console.log('停止匹配!');
                    websocket.send(JSON.stringify({
                        message: 'stopMatch',
                    }));
                }
            }else{
                //连接是异常状态
                alert("当前您的连接已经断开!请重新登录!");
                location.assign('/login.html');
            }
        }
    </script>

服务器开发

创建并注册MatchAPI类

创建MatchAPI


@Component
public class MatchAPI extends TextWebSocketHandler {
        
        private ObjectMapper objectMapper=new ObjectMapper();

        @Override
        public void afterConnectionEstablished(WebSocketSession session) throws Exception {
            super.afterConnectionEstablished(session);
        }

        @Override
        protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
            super.handleTextMessage(session, message);
        }

        @Override
        public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
            super.handleTransportError(session, exception);
        }

        @Override
        public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
            super.afterConnectionClosed(session, status);
        }
}

修改WebSocketConfig

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


@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

    @Autowired
    private TestAPI testAPI;

    @Autowired
    private MatchAPI matchAPI;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(testAPI,"/test");
        registry.addHandler(matchAPI,"/findMatch")
                .addInterceptors(new HttpSessionHandshakeInterceptor());
    }
}
实现用户管理器

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

  • 当玩家建立好 websocket 连接, 则将键值对加入 OnlineUserManager 中.

  • 当玩家断开 websocket 连接, 则将键值对从 OnlineUserManager 中删除.

  • 在玩家连接好的过程中, 随时可以通过 userId 来查询到对应的会话, 以便向客户端返回数据.


@Component
public class OnlineUserManager {
    //这个哈希表用来表示当前用户在游戏大厅的在线状态
    private HashMap<Integer, WebSocketSession> gameHall=new HashMap<>();

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

给 MatchAPI 注入 OnlineUserManager


        @Autowired
        private OnlineUserManager onlineUserManager;
创建匹配请求/响应对象

创建MatchRequest类


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

    public String getMessage() {
        return message;
    }

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

创建MatchResponse类


//表示一个websocket的匹配响应
public class MatchResponse {

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

    public boolean isOk() {
        return ok;
    }

    public void setOk(boolean ok) {
        this.ok = ok;
    }

    public String getReason() {
        return reason;
    }

    public void setReason(String reason) {
        this.reason = reason;
    }

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }
}
处理上线下线状态

当前是使用HashMap来存储用户的在线状态的,如果是多线程访问一个HashMap,容易出现线程安全问题,所以针对HashMap进行修改


    private ConcurrentHashMap<Integer, WebSocketSession> gameHall=new ConcurrentHashMap<>();

实现 afterConnectionEstablished 方法.

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

使用 onlineUserManager 来管理用户的在线状态.

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

设置玩家的上线状态.


//通过这个类来处理匹配功能中的websocket请求
@Component
public class MatchAPI extends TextWebSocketHandler {

        private ObjectMapper objectMapper=new ObjectMapper();

        @Autowired
        private OnlineUserManager onlineUserManager;

        @Autowired
        private Matcher matcher;

        @Override
        public void afterConnectionEstablished(WebSocketSession session) throws Exception {
            //玩家上线,加入到onlineUserManager中

            //1、先获取到当前用户的身份信息(谁在游戏大厅中,建立的连接)
            //由于在注册webSocket时加上了.addInterceptors(new HttpSessionHandshakeInterceptor(),能够getAttributes()
            //这个逻辑就是把HttpSession中的Attribute拿到WebSocketSession中了
            //在Http登录逻辑中,往HttpSession中存入了User数据,httpsession.setAttribute("user",user)
            //此时就可以在WebSocketSession中把之前HttpSession里存的User对象给拿到了
            try {
                User user=(User) session.getAttributes().get("user");

                //2、先判定当前用户是否已经登录过(是在线状态),如果已经在线,不进行后续逻辑
                WebSocketSession tmpSession=onlineUserManager.getFromGameHall(user.getUserId());
                if (tmpSession!=null){
                    //当前已经登录过了,告知客户端重复登录了
                    MatchResponse response=new MatchResponse();
                    response.setOk(false);
                    response.setReason("当前禁止多开!");
                    session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
                    session.close();
                    return;
                }

                //3、拿到身份信息之后,就可以把玩家设置为在线状态
                onlineUserManager.enterGameHall(user.getUserId(), session);
                System.out.println("玩家"+user.getUsername()+"进入游戏大厅!");
            }catch (NullPointerException e){
                e.printStackTrace();
                //出现空指针异常,说明当前用户的身份信息为空,用户未登录
                //把当前用户尚未登录这个信息返回回去
                MatchResponse response=new MatchResponse();
                response.setOk(false);
                response.setReason("您尚未登录!不能进行玩家匹配功能!");
                session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
            }

        } 

        @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){
                e.printStackTrace();
                MatchResponse response=new MatchResponse();
                response.setOk(false);
                response.setReason("您尚未登录!不能进行玩家匹配功能!");
                session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
            }
        }

        @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){
                e.printStackTrace();
                MatchResponse response=new MatchResponse();
                response.setOk(false);
                response.setReason("您尚未登录!不能进行玩家匹配功能!");
                session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
            }
        }
}
处理开始匹配/取消匹配请求

实现 handleTextMessage

先从会话中拿到当前玩家的信息.

解析客户端发来的请求

判定请求的类型, 如果是 startMatch, 则把用户对象加入到匹配队列. 如果是 stopMatch, 则把用户对象从匹配队列中删除.

此处需要实现一个 匹配器 对象, 来处理匹配的实际逻辑.


        @Override
        protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
            //实现处理开始匹配请求和处理停止匹配请求
            User user=(User) session.getAttributes().get("user");
            //获取到客户端给服务器发送的数据
            String payload=message.getPayload();
            //当前这个数据是一个JSON格式的字符串,需要转成java对象
            MatchRequest request=objectMapper.readValue(payload,MatchRequest.class);
            MatchResponse response=new MatchResponse();
            if (request.getMessage().equals("startMatch")){
                //进入匹配队列
                //先创建一个类表示匹配队列,把当前用户加进去
                //把玩家信息放入匹配队列之后,就可以返回一个响应给客户端
                response.setOk(true);
                response.setMessage("startMatch");
            }else if (request.getMessage().equals("stopMatch")){
                //退出匹配队列
                //先创建一个类表示匹配队列,把当前用户取进去
                //把玩家信息放入匹配队列之后,就可以返回一个响应给客户端
                response.setOk(true);
                response.setMessage("stopMatch");
            }else{
                //非法情况
                response.setOk(false);
                response.setReason("非法的匹配请求");
            }
        }
实现匹配器

创建 game.Matcher 类.

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

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

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

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


//这个类表示匹配器,通过这个类完成整个匹配功能
@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;

    //操作匹配队列的方法
    //把玩家放入到匹配队列中
    public void add(User user){
        if (user.getScore()<2000){
            synchronized (normalQueue){
                normalQueue.offer(user);
            }
            System.out.println("把玩家"+user.getUsername()+"加入到了normalQueue中!");
        }else if (user.getScore()>=2000 && user.getScore()<3000){
            synchronized (highQueue){
                highQueue.offer(user);
            }
            System.out.println("把玩家"+user.getUsername()+"加入到了highQueue中!");
        }else {
            synchronized (veryHighQueue){
                veryHighQueue.offer(user);
            }
            System.out.println("把玩家"+user.getUsername()+"加入到了veryHighQueue中!");
        }
    }

    //当玩家点击停止匹配时,就需要把玩家从匹配队列中删除
    public void remove(User user){
        if (user.getScore()<2000){
            normalQueue.remove(user);
        }else if (user.getScore()>=2000 && user.getScore()<3000){
            highQueue.remove(user);
        }else {
            veryHighQueue.remove();
        }
    }

}

修改 game.Matcher , 实现匹配逻辑.

在 Matcher 的构造方法中, 创建一个线程, 使用该线程扫描每个队列, 把每个队列的头两个元素取出来, 匹配到一组中.


    public Matcher(){
        //创建三个线程,分别针对三个匹配队列进行操作
        Thread t1=new Thread(){
            @Override
            public void run() {
                //扫描normalQueue
                while (true){
                    handlerMatch(normalQueue);
                }
            }
        };
        t1.start();

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

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

实现 handlerMatch

由于 handlerMatch 在单独的线程中调用. 因此要考虑到访问队列的线程安全问题. 需要加上锁.

每个队列分别使用队列对象本身作为锁即可.

在入口处使用 wait 来等待, 直到队列中达到 2 个元素及其以上, 才唤醒线程消费队列.


    private void handlerMatch(Queue<User> matchQueue) {
        synchronized (matchQueue){
            try {
                //1、检测队列中元素个数是否达到2
                while (matchQueue.size()<2){
                    matchQueue.wait();
                }
                //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){
                    matchQueue.offer(player1);
                    return;
                }
                if (session1==session2){
                    matchQueue.offer(player1);
                    return;
                }
                //4、把这两个玩家放到同一个房间

                //5、给玩家反馈匹配成功的信息
                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 (InterruptedException | IOException e){
                e.printStackTrace();
            }
        }

需要给上面的插入队列元素, 删除队列元素也加上锁.


    //操作匹配队列的方法
    //把玩家放入到匹配队列中
    public void add(User user){
        if (user.getScore()<2000){
            synchronized (normalQueue){
                normalQueue.offer(user);
                normalQueue.notify();
            }
            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中!");
        }
    }
创建房间类

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

创建 game.Room 类

一个房间要包含一个房间 ID, 使用 UUID 作为房间的唯一身份标识;房间内要记录对弈的玩家双方信息


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

    private User user1;
    private User user2;

    public String getRoomId() {
        return roomId;
    }

    public void setRoomId(String roomId) {
        this.roomId = roomId;
    }

    public User getUser1() {
        return user1;
    }

    public void setUser1(User user1) {
        this.user1 = user1;
    }

    public User getUser2() {
        return user2;
    }

    public void setUser2(User user2) {
        this.user2 = user2;
    }

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

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

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

创建 game.RoomManager

使用一个 Hash 表, 保存所有的房间对象, key 为 roomId, value 为 Room 对象

再使用一个 Hash 表, 保存 userId -> roomId 的映射, 方便根据玩家来查找所在的房间.

提供增, 删, 查的 API. (查包含两个版本, 基于房间 ID 的查询和基于用户 ID 的查询).


//房间管理器
//这个类也有唯一实例
@Component
public class RoomManager {
    private ConcurrentHashMap<String,Room> rooms=new ConcurrentHashMap<>();
    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);
    }

    public Room getRoomByUserId(int userId){
        String roomId=userIdToRoomId.get(userId);
        if (roomId==null){
            //userId--》roomId映射关系不存在
            return null;
        }
        return rooms.get(roomId);
    }

}
实现匹配器

给 Matcher 找注入 RoomManager 对象,修改 Matcher.handlerMatch


    @Autowired
    private RoomManager roomManager;

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

4.3、实现对战模块

前后端交互接口

建立连接

ws://127.0.0.1:8080/game

连接响应

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

客户端开发

实现页面基本结构

创建 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">
</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>
实现棋盘绘制

创建script.js

使用一个二维数组来表示棋盘. 虽然胜负是通过服务器判定的, 但是客户端的棋盘可以避免 "一个位置重复落子" 这样的情况

oneStep 函数起到的效果是在一个指定的位置上绘制一个棋子. 可以区分出绘制白字还是黑子. 参数是横坐标和纵坐标, 分别对应列和行.

用 onclick 来处理用户点击事件. 当用户点击的时候通过这个函数来控制绘制棋子.

me 变量用来表示当前是否轮到我落子. over 变量用来表示游戏结束.


gameInfo={
    roomId:null,
    thisUserId:null,
    thatUserId:null,
    isWhite:true,
}

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

//初始化websocket
//初始化一局游戏
function initGame(){
    //根据服务器分配的先后手情况决定谁先下
    let me=gameInfo.isWhite;
    //游戏是否结束
    let over=false;
    let chessBoard=[];
    //初始化chessBoard数组(表示棋盘的数组)
    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/ee.jpeg";
    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) {
            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) {
                // TODO 发送坐标给服务器, 服务器要返回结果
    
                oneStep(col, row, gameInfo.isWhite);
                chessBoard[row][col] = 1;
            }
        }


}

initGame();
初始化websocket

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

先删掉原来的 initGame 函数的调用. 一会在获取到服务器反馈的就绪响应之后, 再初始化棋盘.

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

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


//初始化websocket
let websocket=new WebSocket("ws://127.0.0.1:8080/game");

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

websocket.onclose=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.message!='gameReady'){
        console.log("响应类型错误!");
        return;
    }
    if(!resp.ok){
        alert("游戏连接失败!reason="+resp.reason);
        //如果出现连接失败的情况,回到游戏大厅
        location.assign("/game_hall.html");
        return;
    }

    //初始化游戏信息
    gameInfo.roomId=resp.roomId;
    gameInfo.thisUserId=resp.thisUserId;
    gameInfo.thatUserId=resp.thatUserId;
    gameInfo.isWhite=resp.isWhite;
    //初始化棋盘
    initGame();
    //设置显示区域内容
    setScreenText(gameInfo.isWhite);
}
发送落子请求

修改 onclick 函数, 在落子操作时加入发送请求的逻辑


        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) {
                // TODO 发送坐标给服务器, 服务器要返回结果
                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));
        }
处理落子响应

在 initGame 中, 修改 websocket 的 onmessage

在 initGame 之前, 处理的是游戏就绪响应, 在收到游戏响应之后, 就改为接收落子响应了;在处理落子响应中要处理胜负手.


//在 initGame 之前, 处理的是游戏就绪响应, 在收到游戏响应之后, 就改为接收落子响应了
        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);
            }else if(resp.userId==gameInfo.thatUserId){
                //对手落的子
                oneStep(resp.col,resp.row,!gameInfo.isWhite);
            }else{
                //响应错误,userId有问题
                console.log('[handlerPutChess] resp UserId错误!');
                return;
            }

            //给对应的位置设为1,方便后续逻辑判定当前位置是否已经有子
            chessBoard[row][col]=1;
            //交换双方的落子轮次
            me=!me;
            setScreenText(me);

            //判定游戏是否结束
            if(resp.winner!=0){
                if(resp.winner==gameInfo.thisUserId){
                    alert("你赢了!");
                }else if(resp.winner==gameInfo.thatUserId){
                    alert("你输了!");
                }else{
                    alert("winner字段错误!"+resp.winner);
                }
                //回到游戏大厅
                location.assign('/game_hall.html');
            }
        }

服务器开发

创建并注册GameAPI类

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


@Component
public class GameAPI extends TextWebSocketHandler {

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

    }
}

修改 WebSocketConfig, 将 GameAPI 进行注册


    @Autowired
    private GameAPI gameAPI;

    registry.addHandler(gameAPI,"/game")
                .addInterceptors(new HttpSessionHandshakeInterceptor());
创建落子请求/响应对象

创建 game.GameReadyResponse 类


//客户端连接到游戏房间后,服务器返回的响应
public class GameReadyResponse {
    private String message;
    private boolean ok;
    private String reason;
    private String roomId;
    private int thisUserId;
    private int thatUserId;
    private int whiteUser;

    public String getMessage() {
        return message;
    }

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

    public boolean isOk() {
        return ok;
    }

    public void setOk(boolean ok) {
        this.ok = ok;
    }

    public String getReason() {
        return reason;
    }

    public void setReason(String reason) {
        this.reason = reason;
    }

    public String getRoomId() {
        return roomId;
    }

    public void setRoomId(String roomId) {
        this.roomId = roomId;
    }

    public int getThisUserId() {
        return thisUserId;
    }

    public void setThisUserId(int thisUserId) {
        this.thisUserId = thisUserId;
    }

    public int getThatUserId() {
        return thatUserId;
    }

    public void setThatUserId(int thatUserId) {
        this.thatUserId = thatUserId;
    }

    public int getWhiteUser() {
        return whiteUser;
    }

    public void setWhiteUser(int whiteUser) {
        this.whiteUser = whiteUser;
    }
}

创建 game.GameRequest 类


//落子请求
public class GameRequest {
    private String message;
    private int userId;
    private int row;
    private int col;

    public String getMessage() {
        return message;
    }

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

    public int getUserId() {
        return userId;
    }

    public void setUserId(int userId) {
        this.userId = userId;
    }

    public int getRow() {
        return row;
    }

    public void setRow(int row) {
        this.row = row;
    }

    public int getCol() {
        return col;
    }

    public void setCol(int col) {
        this.col = col;
    }
}

创建 game.GameResponse 类


//落子响应
public class GameResponse {
    private String message;
    private int userId;
    private int row;
    private int col;
    private int winner;

    public String getMessage() {
        return message;
    }

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

    public int getUserId() {
        return userId;
    }

    public void setUserId(int userId) {
        this.userId = userId;
    }

    public int getRow() {
        return row;
    }

    public void setRow(int row) {
        this.row = row;
    }

    public int getCol() {
        return col;
    }

    public void setCol(int col) {
        this.col = col;
    }

    public int getWinner() {
        return winner;
    }

    public void setWinner(int winner) {
        this.winner = winner;
    }
}
处理连接成功

实现 GameAPI 的 afterConnectionEstablished 方法.

首先需要检测用户的登录状态. 从 Session 中拿到当前用户信息.

然后要判定当前玩家是否是在房间中.

接下来进行多开判定.如果玩家已经在游戏中, 则不能再次连接.


    @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)));
            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、判断是不是多开
        if (onlineUserManager.getFromGameHall(user.getUserId())!=null
            || onlineUserManager.getFromGameRoom(user.getUserId())!=null){
            //如果一个账号,一边是在游戏大厅,一边是在游戏房间,也是为多开
            resp.setOk(false);
            resp.setReason("禁止多开游戏界面");
            session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
            return;
        }

        //4、设置当前玩家上线
        onlineUserManager.enterGameRoom(user.getUserId(), session);

        //5、把两个玩家加入到游戏房间
        if (room.getUser1()==null){
            //第一个玩家还尚未加入房间
            room.setUser1(user);
            //把先连入房间的玩家设为先手方
            room.setWhiteUser(user.getUserId());
            System.out.println("玩家"+user.getUsername()+"已经准备就绪!作为玩家1");
            return;
        }
        if (room.getUser2()==null){
            //玩家1已经进入房间
            room.setUser2(user);
            System.out.println("玩家"+user.getUsername()+"已经准备就绪!作为玩家2");
            //当两个玩家都加入成功之后,就要让服务器,给这两个玩家都返回websocket的响应数据
            //通知这两个玩家,游戏双方都准备好了
            //通知玩家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)));
    }
玩家下线的处理

    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        User user=(User) session.getAttributes().get("user");
        if (user==null){
            //在断开连接的时候就不给客户端返回响应了
            return;
        }
        WebSocketSession exitSession=onlineUserManager.getFromGameRoom(user.getUserId());
        if (session==exitSession){
            //避免在多开的情况下,第二个用户退出连接动作
            onlineUserManager.exitGameRoom(user.getUserId());
        }
        System.out.println("当前用户"+user.getUsername()+"游戏房间连接异常");
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        User user=(User) session.getAttributes().get("user");
        if (user==null){
            //在断开连接的时候就不给客户端返回响应了
            return;
        }
        WebSocketSession exitSession=onlineUserManager.getFromGameRoom(user.getUserId());
        if (session==exitSession){
            //避免在多开的情况下,第二个用户退出连接动作
            onlineUserManager.exitGameRoom(user.getUserId());
        }
        System.out.println("当前用户"+user.getUsername()+"离开游戏房间");
    }
处理落子请求

实现 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对象来处理这次的具体请求
        room.putChess(message.getPayload());
    }
修改Room类

由于我们的 Room 并没有通过 Spring 来管理. 因此内部就无法通过 @Autowired 来自动注入.

需要手动的通过 SpringBoot 的启动类来获取里面的对象.


@SpringBootApplication
public class JavaGobangApplication {
    public static ConfigurableApplicationContext context;

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

}

    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);
    }
实现对弈功能

实现 room 中的 putChess 方法.


    //二维数组用来表示棋盘
    //使用0表示当前位置未落子
    //使用1表示user1的落子位置
    //使用2表示user2的落子位置
    private int[][] board=new int[15][15];

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

    @Autowired
    private OnlineUserManager onlineUserManager;

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

    //通过这个方法来处理一次落子操作
    public void putChess(String reqJson) throws IOException {
        //1、记录当前落子的情况
        GameRequest request=objectMapper.readValue(reqJson,GameRequest.class);
        GameResponse response=new GameResponse();
        //判断当前是玩家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、进行胜负判定
        int winner=checkWinner(row,col);
        //3、给客户端返回响应
        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){
            response.setWinner(user2.getUserId());
            System.out.println("玩家1掉线!!!");
        }
        if (session2==null){
            response.setWinner(user1.getUserId());
            System.out.println("玩家2掉线!!!");
        }
        //把响应构造成Json字符串,通过session进行传输
        String respJson=objectMapper.writeValueAsString(response);
        if (session1!=null){
            session1.sendMessage(new TextMessage(respJson));
        }
        if (session2!=null){
            session2.sendMessage(new TextMessage(respJson));
        }
        //4、如果当前胜负已分,就把room从管理器中销毁
        if (response.getWinner()!=0){
            System.out.println("游戏结束!房间即将销毁!roomId="+roomId+"获胜方为"+response.getWinner());
            //销毁房间
            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
    //胜负未分返回0
    private int checkWinner(int row, int col,int chess) {
        //1、检查所有的行
        //先遍历这五种情况
        for (int c=col-4;c<=col;c++){
            //针对其中一种情况,来判断这五个子是不是连在一起
            try {
                if (board[row][c]==chess
                        && board[row][c+1]==chess
                        && board[row][c+2]==chess
                        && board[row][c+3]==chess
                        && board[row][c+4]==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;
            }
        }
        return 0;
    }
处理途中玩家掉线

在GameAPI中的handleTransportError和afterConnectionClosed添加noticeThatUserWin()方法


    private void noticeThatUserWin(User user) throws IOException {
        //1、根据当前玩家,找到玩家所在的房间
        Room room=roomManager.getRoomByUserId(user.getUserId());
        if (room==null){
            //该房间已经被释放,没有“对手”
            System.out.println("当前房间已经被释放,无需通知对手!");
            return;
        }
        //2、根据房间找对手
        User thatUser=(user==room.getUser1())?room.getUser2():room.getUser1();
        //3、找到对手的在线状态
        WebSocketSession webSocketSession=onlineUserManager.getFromGameRoom(thatUser.getUserId());
        if (webSocketSession==null){
            //意味着对手掉线了
            System.out.println("对手也已经掉线了,无需通知!");
            return;
        }
        //4、构造一个响应,来通知对手,你是获胜方
        GameResponse resp=new GameResponse();
        resp.setMessage("putChess");
        resp.setUserId(thatUser.getUserId());
        resp.setWinner(thatUser.getUserId());
        webSocketSession.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
        //5、释放房间对象
        roomManager.remove(room.getRoomId(),room.getUser1().getUserId(),room.getUser2().getUserId());
    }
更新玩家分数

修改UserMapper和UserMapper.xml


@Mapper
public interface UserMapper {

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

    //往数据库里插入一个用户,用于注册功能
    void insert(User user);

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

    //总比赛场数+1,获胜场数不变,天梯分数-30
    void userLose(int userId);
}

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

修改putChess方法


    //通过这个方法来处理一次落子操作
    public void putChess(String reqJson) throws IOException {
        //1、记录当前落子的情况
        GameRequest request=objectMapper.readValue(reqJson,GameRequest.class);
        GameResponse response=new GameResponse();
        //判断当前是玩家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){
            response.setWinner(user2.getUserId());
            System.out.println("玩家1掉线!!!");
        }
        if (session2==null){
            response.setWinner(user1.getUserId());
            System.out.println("玩家2掉线!!!");
        }
        //把响应构造成Json字符串,通过session进行传输
        String respJson=objectMapper.writeValueAsString(response);
        if (session1!=null){
            session1.sendMessage(new TextMessage(respJson));
        }
        if (session2!=null){
            session2.sendMessage(new TextMessage(respJson));
        }
        //5、如果当前胜负已分,就把room从管理器中销毁
        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());
        }
    }

修改GameAPI中noticeThatUserWin方法


    private void noticeThatUserWin(User user) throws IOException {
        //1、根据当前玩家,找到玩家所在的房间
        Room room=roomManager.getRoomByUserId(user.getUserId());
        if (room==null){
            //该房间已经被释放,没有“对手”
            System.out.println("当前房间已经被释放,无需通知对手!");
            return;
        }
        //2、根据房间找对手
        User thatUser=(user==room.getUser1())?room.getUser2():room.getUser1();
        //3、找到对手的在线状态
        WebSocketSession webSocketSession=onlineUserManager.getFromGameRoom(thatUser.getUserId());
        if (webSocketSession==null){
            //意味着对手掉线了
            System.out.println("对手也已经掉线了,无需通知!");
            return;
        }
        //4、构造一个响应,来通知对手,你是获胜方
        GameResponse resp=new GameResponse();
        resp.setMessage("putChess");
        resp.setUserId(thatUser.getUserId());
        resp.setWinner(thatUser.getUserId());
        webSocketSession.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
        //5、更新玩家信息
        int winUserId=thatUser.getUserId();
        int loseUserId=user.getUserId();
        userMapper.userWin(winUserId);
        userMapper.userLose(loseUserId);
        //6、释放房间对象
        roomManager.remove(room.getRoomId(),room.getUser1().getUserId(),room.getUser2().getUserId());
    }

五、部署云服务器

构造数据库中的数据

调整websocket建立连接的url


        let websocketUrl='ws://'+ location.host+'/findMatch';
        let websocket=new WebSocket(websocketUrl);

打包上传

通过外网访问

五子棋实战

六、后续扩展功能

计时

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

保存棋谱/录像回放

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

观战功能

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

界面聊天

同一个房间中的选手之间可以发送文本消息,或者在对战中可接受到游戏大厅好友的消息

人机对战

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

根据以上扩展功能,后续将对此项目进行扩充,敬请期待!

  • 23
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值