• Spring项目-在线五子棋


    网页版在线五子棋

    项目介绍

    实现一个网页版在线对战五子棋

    支持以下功能:

    • 用户模块:用户注册、用户登录、用户天梯积分记录、用户比赛场数记录
    • 匹配模块:根据玩家天梯积分进行匹配
    • 对战模块:实现1v1的实时对战功能

    核心技术:

    • Spring/SpringBoot/SpringMVC
    • Websocket
    • MyBatis
    • MySQL
    • HTML/CSS/JS/AJAX

    项目演示

    请添加图片描述

    前置知识

    WebSocket

    如果你了解过Http协议,那么应该知道Http协议是无状态、无连接、单向的应用层协议。它采用了请求-响应模式,由客户端发送一个请求,由服务端返回一个响应。它有一个弊端就是服务端无法主动向客户端发起消息。这样就导致客户端想要获取服务端连续的状态变化很困难,大多是web程序将通过频繁的异步JavaScript和XML(AJAX)请求实现长轮询。轮询的效率低,非常浪费资源(因为必须不停连接,或者 HTTP 连接始终打开)。

    举个例子,我们在餐馆点餐后,有两种选择:
    ①时不时跑到前台询问老板我的菜做好没,老板说没有,我溜达一圈后又来问菜做好没……循环直到我的菜做好了,我端着菜找个位置坐下用餐
    ②我直接找个位置坐下,等菜做好后,老板端着菜过来递给我然后用餐
    第一种做法(轮询)就是使用客户端(我)一直向服务器(老板)发送请求,检查数据是否发生了变化(菜做好没)。
    第二种做法(websocket)就是服务器(老板)直接向客户端(我)发送消息(菜做好了)

    为了建立一个 WebSocket 连接,客户端浏览器首先要向服务器发起一个 HTTP 请求,这个请求和通常的 HTTP 请求不同,包含了一些附加头信息,通过这个附加头信息完成握手过程.

    请求头

    在这里插入图片描述

    返回头

    在这里插入图片描述

    代码示例

    Spring中内置了websocket,我们可以直接使用。

    服务器代码

    创建TestAPI类:

    这个类用来处理websocket请求,并返回响应。

    每个方法中都带有一个 session 对象, 这个 session 和 Servlet 的 session 并不相同, 而是 WebSocket 内部搞的另外一组 Session.

    通过这个 Session 可以给客户端返回数据, 或者主动断开连接.

    @Component
    /**
     * 这是一个测试类
     * 继承自TextWebSocketHandler的类是一个webSocket消息处理类
     */
    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());
            session.sendMessage(new TextMessage("我收到了你的消息" + 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("关闭连接");
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31

    创建WebSocketConfig类:

    @Configuration
    @EnableWebSocket//这个注释可以让Spring知道这是一个WebSocket配置类
    public class WebSocketConfig implements WebSocketConfigurer {
    
        @Autowired
        private TestAPI testAPI;
    
        @Override
        public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
            //这个方法可以将一个消息处理器和一个路由关联上,访问这个路由后将使用testAPI的方法进行消息处理
            registry.addHandler(testAPI,"/test");
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    客户端代码

    创建test.html

    <body>
        <input type="text" id = "message">
        <input type="button" id = "submit" value="提交">
    
        <script>
    
            /* 创建一个websocket实例 */
            let url = "ws://127.0.0.1:8080/test"
            let websocket = new WebSocket(url)
    
            /* 给实例挂载一些回调函数 */
            websocket.onopen = function() {
                console.log("建立连接");
            }
    
            websocket.onmessage = function(e) {
                console.log("收到消息" + e.date);
            }
    
            websocket.onerror = function() {
                console.log("连接异常");
            }
    
            websocket.onclose = function() {
                console.log("连接关闭");
            }
    
            let input = document.querySelector('#message');
            let button = document.querySelector('#submit')
            button.onclick = function() {
                console.log("发送消息" + input.value);
                websocket.send(input.value);
            }
    
        script>
    body>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36

    启动服务器,观察效果:
    在这里插入图片描述
    在这里插入图片描述

    这样服务器和客户端就实现了交互~

    需求分析和概要设计

    整个项目分成以下三个模块

    • 用户模块
    • 匹配模块
    • 对战模块

    用户模块

    该模块主要用于用户登录、注册、记录一些用户比赛信息。

    用MySQL存储数据。

    客户端提供登录注册页面。

    服务器基于Spring + MyBatis来实现增删查改。

    匹配模块

    用户登录成功,进入游戏大厅,大厅里显示玩家的比赛信息。

    同时显示一个匹配按钮,当玩家按下开始匹配,将玩家加入匹配队列,同时开始匹配变为匹配中……(点击停止)停止匹配后从队列中将玩家移除。

    如果匹配成功,将进入游戏房间。

    通过websocket实现通讯“开始匹配”、“停止匹配”、“匹配成功”。

    对战模块

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

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

    在游戏房间中显示棋盘,玩家点解棋盘实现落子功能

    当五子连珠时,显示你赢了/你输了

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

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

    项目创建

    使用idea创建一个SpringBoot项目

    引入SpringBoot / Spring MVC / MyBatis /lombok依赖

    实现用户模块

    编写数据库代码

    数据库设计

    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 values(null,'zhangsan','123',1000,0,0);
    insert into user values(null,'lisi','123',1000,0,0);
    insert into user values(null,'wangwu','123',1000,0,0);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    配置MyBatis

    编写application.yml

    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
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    创建实体类

    @Data
    public class User {
        private int userId;
        private String username;
        private String password;
        private int score;
        private int totalCount;
        private int winCount;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    创建UserMapper

    此类主要提供4个方法:

    • selectByName : 根据用户名查找用户信息,实现登录
    • insert :根据信息新增用户,用于注册
    • userWin :给获胜者修改游戏分数
    • userLose:给失败者修改游戏分数
    @Mapper
    public interface UserMapper {
    
        int insert(User user);
    
        User selectByName(String name);
    
        void userWin(int userId);
    
        void userLose(int userId);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    实现UserMapper.xml

    
    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">
        <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>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    约定前后端交互

    在这里插入图片描述

    服务器开发

    创建controller.UserController

    实现三个方法:

    • login :实现用户登录逻辑
    • register : 实现用户注册逻辑
    • userInfo:实现登录成功后查找用户分数逻辑
    @RestController
    //这个类用来实现三个方法
    //①注册  ②登录   ③获取用户信息
    public class UserController {
    
        @Autowired
        private UserMapper userMapper;
    
        @PostMapping("/login")
        public Object login(String username, String password, HttpServletRequest req){
            User user = userMapper.selectByName(username);
            if(user == null || !user.getPassword().equals(password)){
                return new User();
            }
            System.out.println("登录" + username);
            HttpSession session = req.getSession(true);
            session.setAttribute("user",user);
            return user;
        }
    
        @PostMapping("/register")
        public Object register(String username,String password){
            User user = null;
            try {
                user = new User();
                user.setUsername(username);
                user.setPassword(password);
                System.out.println("register" + username);
                int ret = userMapper.insert(user);
                System.out.println("受影响的行数" + ret);
                //可能会触发一个主键重复的异常
            }catch (org.springframework.dao.DuplicateKeyException e){
                user = new User();
                //System.out.println("用户名重复");
            }
            return user;
        }
    
    
        @GetMapping("/userInfo")
        public Object getUserInfo(HttpServletRequest req){
            try{
                HttpSession session = req.getSession(false);
                User user = (User) session.getAttribute("user");
                //保证用户的分数是数据库中最新的数据
                User newUser = userMapper.selectByName(user.getUsername());
                return newUser;
            }catch (NullPointerException e){
                return new User();
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52

    客户端开发

    登录页面

    创建login.html

    
        <div class="nav">
          <span>五子棋对战span>
        div>
    
        <div class="login-container">
          <div class="login-dialog">
            
            <h2>登录h2>
    
            <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">
              <button id="submit">提交button>
            div>
    
            <div class="register">
              <a href="register.html">注册a>
            div>
          div>
        div>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29

    创建css.common.css

    html,body {
        height: 100%;
        background-image: url(../img/背景.jpg);
        background-size: cover;
        background-repeat: no-repeat;
        background-position: center;
    }
    
    .nav{
        width: 100%;
        height: 50px;
        display: flex;
        background-color: rgba(51, 51, 51,0.4);
        color: white;
        padding-left: 20px;
        align-items: center;
    }
    
    .container{
        height: calc(100% - 50px);
        display: flex;
        align-items: center;
        justify-content: center;
        width: 100%;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    创建css.login.css

    .login-container{
        height: calc(100% - 50px);
        display: flex;
        align-items: center;
        justify-content: center;
        width: 100%;
    }
    
    .login-dialog{
        width: 400px;
        height: 320px;
        background-color: rgba(255,255,255,0.8);
        border-radius: 10px;
    }
    
    .login-dialog h2{
        text-align: center;
        padding: 20px 0;
    }
    
    .login-dialog .row{
        width: 100%;
        height: 50px;
        align-items: center;
        justify-content: center;
        display: flex;
    }
    
    .login-dialog span{
        width: 100px;
        display: block;
        /* 字体加粗 */
        font-weight: 700;
    }
    
    .row #username,#password{
        outline: none;
        border: none;
        width: 200px;
        height: 40px;
        font-size: 20px;
        text-indent: 10px;
        border-radius: 10px;
    }
    
    .login-dialog .row-button{
        margin-top: 10px;
    }
    
    .row-button #submit{
        width: 300px;
        border: none;
        height: 50px;
        color: white;
        background-color: rgb(0, 128, 0);
        font-size: 20px;
        border-radius: 10px;
        margin-left: 50px;
    }
    
    .register a{
        align-items: center;
        margin-left: 50px;
        text-decoration: none;
    }
    
    #submit:active{
        background-color: #666;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69

    login.html中编写js代码,实现交互

    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
    
        <script>
          let submit = document.querySelector('#submit');
          submit.onclick = function(){
            let username = document.querySelector('#username').value;
            let password = document.querySelector('#password').value;
            $.ajax({
              method:"post",
              url:"/login",
              data:{
                username : username,
                password : password
              },
              success: function(data){
                console.log(JSON.stringify(data));
                if(data && data.userId > 0){
                  alert("登录成功");
                  location.assign('game_hall.html');
                }else{
                  alert("登录失败! 用户名或密码错误!")
                }
              }
            })
          }
        </script>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    在这里插入图片描述

    注册页面

    创建register.html

    
        <div class="nav">
            <span>五子棋对战span>
          div>
      
          <div class="register-container">
            <div class="register-dialog">
              
              <h2>注册h2>
    
              <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">
                <button id="submit">提交button>
              div>
            div>
          div>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    css部分可以使用css.common.css部分

    register.html中编写js代码实现交互

    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
    
          <script>
              let submit = document.querySelector('#submit');
              submit.onclick = function(){
                  let username = document.querySelector('#username').value;
                  let password = document.querySelector('#password').value;
                  $.ajax({
                      method:"post",
                      url:"/register",
                      data:{
                          username: username,
                          password: password
                      },
                      success: function(data){
                          console.log(JSON.stringify(data));
                        if(data && data.username){
                            alert("注册成功");
                            location.assign('login.html');
                        }else{
                            alert("注册失败")
                        }
                      }
                  })
              }
          </script>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    在这里插入图片描述

    实现匹配模块

    约定前后端交互接口

    在这里插入图片描述

    客户端开发

    实现页面基本属性

    创建 game_hall.html

    screen用于显示玩家分数

    button作为匹配按钮

    <div class="nav">
            五子棋对战
        div>
    
        <div class="container">
            
            <div>
                <div id="screen">div>
                <button id="match-button">开始匹配button>
            div>
        div>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    创建game_hall.css

    #screen {
        width: 400px;
        height: 200px;
        font-size: 20px;
        background-color: gray;
        color: white;
        border-radius: 10px;
    
        text-align: center;
        line-height: 100px;
    }
    
    #match-button {
        width: 400px;
        height: 50px;
        font-size: 20px;
        color: white;
        background-color: orange;
        border: none;
        outline: none;
        border-radius: 10px;
    
        text-align: center;
        line-height: 50px;
        margin-top: 20px;
    }
    
    #match-button:active {
        background-color: gray;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30

    编写js代码获取用户信息

    <script src="http://libs.baidu.com/jquery/2.0.0/jquery.min.js"></script>
        <script>
            /* 获取用户信息 */
            $.ajax({
                method: 'get',
                url: '/userInfo',
                success: function(data) {
                    let screen = document.querySelector('#screen');
                    if(data.username == null){
                        alert("当前尚未登录,请先登录!");
                        location.replace("/login.html");
                    }
                    screen.innerHTML = '玩家: ' + data.username + ', 分数: ' + data.score + "
    比赛场次: "
    + data.totalCount + ", 获胜场次: " + data.winCount; } });
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    在这里插入图片描述

    实现匹配功能

    编辑 game_hall.html 的 js 部分代码.

    • 点击匹配按钮, 就会进入匹配逻辑. 同时按钮上提示 “匹配中……(点击停止)” 字样.
    • 再次点击匹配按钮, 则会取消匹配.
    • 当匹配成功后, 服务器会返回匹配成功响应, 页面跳转到 game_room.html
    /* 处理匹配功能 */
            let url = 'ws://' + location.host + '/findMatch';
            let websocket = new WebSocket(url);
            let button = document.querySelector('#match-button');
            /* 点击开始匹配 */
            button.onclick = function(){
                /* 这个可以判断websocket是否处于连接状态
                OPEN是一个常数1 ,readstate=1代表连接状态 */
                if(websocket.readyState == websocket.OPEN){
                    if(button.innerHTML == '开始匹配'){
                        console.log("开始匹配");
                        /* JSON对象转为字符串 */
                        websocket.send(JSON.stringify({
                            message:'startMatch',
                        }));
                    }else if(button.innerHTML == '匹配中……(点击停止)'){
                        console.log("停止匹配");
                        websocket.send(JSON.stringify({
                            message:'stopMatch',
                        }));
                    }
                }else{
                    console.log("当前你的连接已经断开,请重新连接");
                    location.replace('/login.html');
                }
            }
    
            /* 处理服务器的响应 */
            /* 这个函数是当收到来自服务器的消息时调用的 */
            websocket.onmessage = function(e){
                /* 字符串转为JSON对象 */
                let resp = JSON.parse(e.data);
                if(!resp.ok){
                    console.log("游戏大厅发生错误" + resp.reason);
                    location.replace('/login.html');
                    return;
                }
                if(resp.message == 'startMatch'){
                    console.log("进入匹配队列成功");
                    button.innerHTML = '匹配中……(点击停止)'
                }else if(resp.message == 'stopMatch'){
                    console.log("移除匹配队列成功");
                    button.innerHTML = '开始匹配';
                }else if(resp.message == 'MatchSuccess'){
                    console.log("匹配成功,进入游戏界面");
                    location.replace('/game_room.html');
                }else if(resp.message == 'repeatConnection'){
                    alert("检测到当前为多开,请使用其他账号登录");
                    location.replace("/login.html");
                }else{
                    console.log("非法的message" + resp.message);
                }
            }
            
            /* 监听窗口关闭事件,当窗口关闭时,主动断开websocket链接,防止还没断开链接就关闭窗口server报错 */
            window.onbeforeunload = function () {
                websocket.close();
            }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58

    在这里插入图片描述

    服务器开发

    创建并注册MatchAPI类

    创建 api.MatchAPI, 继承自 TextWebSocketHandler 作为处理 websocket 请求的入口类.

    @Component
    public class MatchAPI extends TextWebSocketHandler {
        private ObjectMapper objectMapper = new ObjectMapper();
    
        @Component
        public class MatchAPI extends TextWebSocketHandler {
        }
    
        @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
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    修改 config.WebSocketConfig, 把 MatchAPI 注册进去.

    @Configuration
    @EnableWebSocket//这个注释可以让Spring知道这是一个WebSocket配置类
    public class WebSocketConfig implements WebSocketConfigurer {
    
        @Autowired
        private TestAPI testAPI;
    
        @Autowired
        private MatchAPI matchAPI;
    
        @Autowired
        private GameAPI gameAPI;
    
        @Override
        public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
            //这个方法可以将一个消息处理器和一个路由关联上,访问这个路由后将使用testAPI的方法进行消息处理
            registry.addHandler(testAPI,"/test");
            //拦截器,可以获取到HttpSession中的session供webSocket中的session使用
            registry.addHandler(matchAPI,"/findMatch").
                    addInterceptors(new HttpSessionHandshakeInterceptor());
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    实现用户管理类

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

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

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

    涉及线程安全使用ConcurrentHashMap哈希表

    @Component
    //这个类用来管理用户的在线状态
    public class OnlineUserManager {
        private ConcurrentHashMap<Integer, WebSocketSession> game_hall = new ConcurrentHashMap<>();
        private ConcurrentHashMap<Integer, WebSocketSession> game_room = new ConcurrentHashMap<>();
    
        //用户进入游戏大厅
        public void enterGameHall(int userId,WebSocketSession session){
            game_hall.put(userId,session);
        }
    
        //用户离开游戏大厅
        public void exitGameHall(int userId){
            game_hall.remove(userId);
        }
    
        //获取用户信息
        public WebSocketSession getGameHallSession(int userId){
            return game_hall.get(userId);
        }
    
        //用户进入游戏房间
        public void enterGameRoom(int userId,WebSocketSession session){
            game_room.put(userId,session);
        }
    
        //用户离开游戏房间
        public void exitGameRoom(int userId){
            game_room.remove(userId);
        }
    
        //获取用户信息
        public WebSocketSession getGameRoomSession(int userId){
            return game_room.get(userId);
        }
    
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37

    创建匹配请求/响应对象

    创建 game.MatchRequest

    @Data
    public class MatchRequest {
        private String message = "";
    }
    
    • 1
    • 2
    • 3
    • 4

    创建 game.MatchResponse

    @Data
    public class MatchResponse {
        private boolean ok = true;
        private String reason = "";
        private String message = "";
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    处理连接成功

    实现MatchAPI中的afterConnectionEstablished方法

    @Override
        //处理用户连接
        public void afterConnectionEstablished(WebSocketSession session) throws Exception {
            //session.getAttributes()获取到的是一个map,里面存放了了HttpSession中的getAttribute里的所有对象
            User user = (User) session.getAttributes().get("user");
            if(user == null){
                //玩家还未登陆就进入游戏大厅了
                MatchResponse response = new MatchResponse();
                response.setOk(false);
                response.setReason("[afterConnectionEstablished]玩家尚未登录!");
                session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
                return;
            }
            //检查玩家的上线状态(是否多开)
            //在给玩家设置上线状态时,需要先判断之前玩家是否已经登录过了
            if (onlineUserManager.getGameHallSession(user.getUserId()) != null
            || onlineUserManager.getGameRoomSession(user.getUserId()) != null){
                MatchResponse response = new MatchResponse();
                response.setOk(true);
                response.setReason("当前游戏禁止多开");
                response.setMessage("repeatConnection");
                session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
                return;
            }
            //当玩家获取到身份信息后,就可以给玩家设置上线状态了
            onlineUserManager.enterGameHall(user.getUserId(),session);
            System.out.println("当前玩家" + user.getUsername() + "进入游戏大厅");
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28

    处理开始匹配/取消匹配

    实现MatchAPI中的 handleTextMessage

    @Override
        //处理开始/取消匹配
        protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
            User user = (User) session.getAttributes().get("user");
            if(user == null){
                System.out.println("[handleTextMessage]玩家尚未登录");
                return;
            }
            System.out.println("开始匹配" + user.getUserId() + "message" + message.toString());
            //将解析得到的JSON请求数据转换为一个MatchRequest对象
            MatchRequest request = objectMapper.readValue(message.getPayload(),MatchRequest.class);
            MatchResponse response = new MatchResponse();
            if(request.getMessage().equals("startMatch")){
                //加入匹配器中
                //TODO
                match.add(user);
                response.setMessage("startMatch");
            }else if(request.getMessage().equals("stopMatch")){
                //从匹配器中移除
                //TODO
                match.remove(user);
                response.setMessage("stopMatch");
            }else{
                response.setOk(false);
                response.setReason("非法的匹配请求");
            }
            session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28

    实现匹配器

    创建 game.Matcher 类.

    涉及线程安全需处理

    @Component
    //匹配器
    public class Match {
        @Autowired
        private OnlineUserManager onlineUserManager;
    
        @Autowired
        private RoomManager roomManager;
    
        private ObjectMapper objectMapper = new ObjectMapper();
    
        //游戏玩家分为三档
        //第一档://2000以下(不含2000)
        //第二档://2000-3000(不含3000)
        //第三档://3000以上
        private Queue<User> normalQueue = new LinkedList<>();
        private Queue<User> highQueue = new LinkedList<>();
        private Queue<User> veryHighQueue = new LinkedList<>();
    
        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");
            }
        }
    
        //启动三个线程循环调用各自的队列
        private Match(){
            new Thread(){
                @Override
                public void run() {
                    while(true){
                        handlerMatch(normalQueue);
                    }
                }
            }.start();
    
            new Thread(){
                @Override
                public void run() {
                    while(true){
                        handlerMatch(highQueue);
                    }
                }
            }.start();
    
            new Thread(){
                @Override
                public void run() {
                    while(true){
                        handlerMatch(veryHighQueue);
                    }
                }
            }.start();
        }
    
        private void handlerMatch(Queue<User> matchQueue){
            synchronized (matchQueue){
                try{
                    //五子棋需要两个人,当队列中人数少于2时等待
                    while(matchQueue.size() < 2){
                        matchQueue.wait();
                    }
                    User user1 = matchQueue.poll();
                    User user2 = matchQueue.poll();
    
                    System.out.println("匹配出两个玩家" + user1.getUsername() +" " + user2.getUsername());
                    WebSocketSession session1 = onlineUserManager.getGameHallSession(user1.getUserId());
                    WebSocketSession session2 = onlineUserManager.getGameHallSession(user2.getUserId());
                    if(session1 == null){
                        matchQueue.offer(user2);
                        return;
                    }
                    if(session2 == null){
                        matchQueue.offer(user1);
                        return;
                    }
                    //防止多开
                    if (session1 == session2){
                        matchQueue.add(user1);
                    }
    
                    // 将两个玩家加入对战房间
                    Room room = new Room();
                    roomManager.add(user1.getUserId(),user2.getUserId(),room);
    
    
                    //给玩家1发送匹配成功的信息
                    MatchResponse response1 = new MatchResponse();
                    response1.setOk(true);
                    response1.setMessage("MatchSuccess");
                    session1.sendMessage(new TextMessage(objectMapper.writeValueAsString(response1)));
                    //给玩家2发送匹配成功的信息
                    MatchResponse response2 = new MatchResponse();
                    response2.setOk(true);
                    response2.setMessage("MatchSuccess");
                    session2.sendMessage(new TextMessage(objectMapper.writeValueAsString(response2)));
                }catch (IOException | InterruptedException e){
                    e.printStackTrace();
                }
            }
    
    
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133
    • 134
    • 135
    • 136
    • 137
    • 138
    • 139
    • 140

    实现房间类

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

    创建 game.Room

    • 一个房间要包含一个房间 ID, 使用 UUID 作为房间的唯一身份标识.
    • 房间内要记录对弈的玩家双方信息.
    • 记录先手方的 ID
    • 记录一个 二维数组 , 作为对弈的棋盘.
    • 记录一个 OnlineUserManager, 以备后面和客户端进行交互.
    @Data
    public class Room {
    
        //由于Room不能是唯一的,所以不能注入到Spring中,从而也不可以用 Autowired注入这三个bean
        //因此我们需要手动注入这三个bean后续会说怎么处理
        private OnlineUserManager onlineUserManager;
        private RoomManager roomManager;
        private UserMapper userMapper;
    
        private ObjectMapper objectMapper = new ObjectMapper();
    
        private String roomId;
        private User user1;
        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];
    		public Room() {
            // 使用 uuid 作为唯一身份标识
            roomId = UUID.randomUUID().toString();
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    实现房间管理器类

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

    创建 game.RoomManager

    • 使用一个 Hash 表, 保存所有的房间对象, key 为 roomId, value 为 Room 对象
    • 再使用一个 Hash 表, 保存 userId -> roomId 的映射, 方便根据玩家来查找所在的房间.
    • 提供增, 删, 查的 API. (查包含两个版本, 基于房间 ID 的查询和基于用户 ID 的查询).
    @Component
    public class RoomManager {
    
        //存储所有的Room房间
        ConcurrentHashMap<String,Room> rooms = new ConcurrentHashMap<>();
        //存储用户和房间的关联关系
        ConcurrentHashMap<Integer ,String> userIdToRoomId = new ConcurrentHashMap<>();
    
        public void add(int user1Id,int user2Id,Room room){
            rooms.put(room.getRoomId(),room);
            userIdToRoomId.put(user1Id,room.getRoomId());
            userIdToRoomId.put(user2Id,room.getRoomId());
        }
    
        public void remove(int user1Id,int userId2,String roomId){
            rooms.remove(roomId);
            userIdToRoomId.remove(user1Id);
            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){
                return null;
            }
            return getRoomByRoomId(roomId);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32

    处理连接关闭/异常

    实现MatchAPI中的afterConnectionClosed

        @Override
        //异常连接处理
        public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
            User user = (User) session.getAttributes().get("user");
            try{
                WebSocketSession tmpSession = onlineUserManager.getGameHallSession(user.getUserId());
                if(tmpSession == session){
                    onlineUserManager.exitGameHall(user.getUserId());
                }
                //TODO 从匹配器中移除
                match.remove(user);
                System.out.println("玩家"+ user.getUsername() +"离开游戏大厅");
            }catch (NullPointerException e){
                System.out.println("[handleTransportError]当前用户尚未登录");
            }
        }
    
        @Override
        //处理玩家断开连接
        public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
            User user = (User) session.getAttributes().get("user");
            try{
                WebSocketSession tmpSession = onlineUserManager.getGameHallSession(user.getUserId());
                if(tmpSession == session){
                    onlineUserManager.exitGameHall(user.getUserId());
                }
                //TODO 从匹配器中移除
                match.remove(user);
                System.out.println("玩家"+ user.getUsername() +"离开游戏大厅");
            }catch (NullPointerException e){
                System.out.println("[afterConnectionClosed]当前用户尚未登录");
            }
    
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34

    实现对战模块

    约定前后端交互

    在这里插入图片描述

    客户端开发

    创建 game_room.html, 表示对战页面.

    <div class="nav">
            联机五子棋
        div>
        <div class="container">
            <div>
                <canvas id="chess" width="450px" height="450px">canvas>
                <div id="screen">等待玩家连接中...div>
            div>
        div>
    
        <script src="js/script.js">script>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    创建 css/game_room.css

    #screen {
        font-size: 22px;
        text-align: center;
        background-color: rgba(255,255,255,0,7);
        color: yellow;
        margin-bottom: 20px;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    实现棋盘/棋子绘制

    创建 js/script

    这段代码可以直接复制粘贴,不需要深究其中含义

    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
    //
    // TODO
    
    //
    // 初始化一局游戏
    //
    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/sky.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;
            }
        }
    
        // TODO 实现发送落子请求逻辑, 和处理落子响应逻辑. 
    }
    
    initGame();
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106

    初始化websocket

    在刚才代码中加入websocket

    //使用location.host 是为了后续部署到云服务器上做准备的
    //也可写作127.0.0.1:8080
    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.areplacessign("/game_hall.html");
            return;
        }
    
        if (resp.message == 'readyGame') {
            gameInfo.roomId = resp.roomId;
            gameInfo.thisUserId = resp.thisUserId;
            gameInfo.thatUserId = resp.thatUserId;
            gameInfo.isWhite = (resp.whiteUserId == resp.thisUserId);
    
            // 初始化棋盘
            initGame();
            // 设置显示区域的内容
            setScreenText(gameInfo.isWhite);
        } else if (resp.message == 'repeatConnection') {
            alert("检测到游戏多开! 请使用其他账号登录!");
            location.replace("/login.html");
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48

    发送落子请求

    修改刚刚的onclick方法

    注释掉原有的 onStep 和 修改 chessBoard 的操作, 放到接收落子响应时处理.

    实现 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
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31

    处理落子响应

    在 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);
            } 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('你赢了!');
                    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.style.backgroundColor = "green";
                backBtn.style.width = "450px";
                backBtn.style.height = "50px";
                backBtn.style.border = "none";
                backBtn.style.borderRadius = "10px";
                backBtn.onclick = function() {
                    location.replace('/game_hall.html');
                }
                let fatherDiv = document.querySelector('.container>div');
                fatherDiv.appendChild(backBtn);
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60

    服务器开发

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

    @Component
    public class GameAPI extends TextWebSocketHandler {
        private ObjectMapper objectMapper = new ObjectMapper();
        @Autowired
        private RoomManager roomManager;
        // 这个是管理 game 页面的会话
        @Autowired
        private OnlineUserManager onlineUserManager;
    
        @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
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    修改 WebSocketConfig, 将 GameAPI 进行注册.

    @Configuration
    @EnableWebSocket//这个注释可以让Spring知道这是一个WebSocket配置类
    public class WebSocketConfig implements WebSocketConfigurer {
    
        @Autowired
        private TestAPI testAPI;
    
        @Autowired
        private MatchAPI matchAPI;
    
        @Autowired
        private GameAPI gameAPI;
    
        @Override
        public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
            //这个方法可以将一个消息处理器和一个路由关联上,访问这个路由后将使用testAPI的方法进行消息处理
            registry.addHandler(testAPI,"/test");
            //拦截器,可以获取到HttpSession中的session供webSocket中的session使用
            registry.addHandler(matchAPI,"/findMatch").
                    addInterceptors(new HttpSessionHandshakeInterceptor());
    
            registry.addHandler(gameAPI,"/game").
                    addInterceptors(new HttpSessionHandshakeInterceptor());
        }
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    创建落子请求/响应对象

    创建game.GameRequest

    @Data
    public class GameRequest {
        private String message = "pusChess";
        private int userId;
        private int row;
        private int col;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    创建game.GameResponse

    @Data
    public class GameResponse {
        private String message = "putChess";
        private int userId;
        private int row;
        private int col;
        private int winner;//获胜者id
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    创建 game.GameReadyResponse

    @Data
    public class GameReadyResponse {
        private String message = "readyGame";
        private boolean ok = true;
        private String reason;
        private String roomId;
        private int thisUserId = 0;
        private int thatUserId = 0;
        private int whiteUserId = 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    处理连接成功

    实现 GameAPI 的 afterConnectionEstablished 方法.

    @Override
        //处理用户连接房间成功
        public void afterConnectionEstablished(WebSocketSession session) throws Exception {
            GameReadyResponse resp = new GameReadyResponse();
            User user = (User) session.getAttributes().get("user");
            if(user == null){
                resp.setOk(false);
                resp.setReason("[afterConnectionEstablished]当前用户尚未登录");
                session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
                return;
            }
            Room room = roomManager.getRoomByUserId(user.getUserId());
            if(room == null){
                resp.setOk(false);
                resp.setReason("用户匹配尚未成功,不能开始游戏");
                session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
                return;
            }
            System.out.println("游戏连接 roomId = " + room.getRoomId() + " userID = " + user.getUserId());
            //判断游戏是否多开
            if(onlineUserManager.getGameHallSession(user.getUserId()) != null ||
                onlineUserManager.getGameRoomSession(user.getUserId()) != null){
                resp.setOk(false);
                resp.setReason("当前游戏禁止多开");
                session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
                return;
            }
    
            //更新用户会话
            //游戏大厅和游戏房间的会话是不一样的
            onlineUserManager.enterGameRoom(user.getUserId(),session);
            //一个房间有两个玩家,因此使用时需要考虑到线程安全
            synchronized (room){
                //设置use1为先手
                if(room.getUser1() == null){
                    room.setUser1(user);
                    room.setWhiteUserId(user.getUserId());
                    System.out.println("玩家1" + user.getUsername() + "准备就绪");
                    return;
                }
                if(room.getUser2() == null){
                    room.setUser2(user);
                    System.out.println("玩家2" + user.getUsername() + "准备就绪");
    
                    //通知玩家1\2\游戏就绪了
                    notifyGameReady(room,room.getUser1().getUserId(),room.getUser2().getUserId());
                    notifyGameReady(room,room.getUser2().getUserId(),room.getUser1().getUserId());
                    return;
                }
                resp.setOk(true);
                resp.setReason("房间已经满了");
                session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
            }
    
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55

    实现通知玩家就绪

    private void notifyGameReady(Room room,int thisUserId,int thatUserId) throws IOException {
            GameReadyResponse response = new GameReadyResponse();
            response.setOk(true);
            response.setThisUserId(thisUserId);
            response.setThatUserId(thatUserId);
            response.setWhiteUserId(room.getWhiteUserId());
            WebSocketSession session = onlineUserManager.getGameRoomSession(thisUserId);
            session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    玩家下线处理

    也要注意多开

    @Override
        public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
            User user = (User) session.getAttributes().get("user");
            if(user == null){
                return;
            }
            WebSocketSession session1 = onlineUserManager.getGameRoomSession(user.getUserId());
            if(session1 != session){
                System.out.println("当前会话不是游戏中玩家的会话");
                return;
            }
            System.out.println("连接出错 userId = " + user.getUserId());
            onlineUserManager.exitGameRoom(user.getUserId());
            noticeThatUserWin(user);
        }
    
        @Override
        public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
            User user = (User) session.getAttributes().get("user");
            if(user == null){
                return;
            }
            WebSocketSession session1 = onlineUserManager.getGameRoomSession(user.getUserId());
            if(session1 != session){
                System.out.println("当前会话不是游戏中玩家的会话");
                return;
            }
            System.out.println("用户退出 userId = " + user.getUserId());
            onlineUserManager.exitGameRoom(user.getUserId());
            noticeThatUserWin(user);
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31

    手动注入bean

    在启动类中加入这个
    在这里插入图片描述修改room

    在这里插入图片描述

    处理落子请求

    @Override
        //落子请求
        protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
            User user = (User) session.getAttributes().get("user");
            if(user == null){
                return;
            }
            Room room = roomManager.getRoomByUserId(user.getUserId());
            room.putChess(message.getPayload());
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    实现对弈功能

    实现 room 中的 putChess 方法.

    //用这个方法实现落子响应
        public void putChess(String message) throws IOException {
            GameRequest request = new GameRequest();
            GameResponse response = new GameResponse();
            request = objectMapper.readValue(message,GameRequest.class);
            int row = request.getRow();
            int col = request.getCol();
            //判断是谁下的字
            //做出约定:
            //①如果是玩家一,则下的子为1,
            //②是玩家而,则下的子是2
            int chess = request.getUserId() == user1.getUserId() ? 1 : 2;
    
            if(chessBoard[row][col] != 0){
                System.out.println("下的子有误" + request);
                return;
            }
    
            //1.进行落子
            chessBoard[row][col] = chess;
            printBoard();
            //2.检查游戏是否结束
            int winner = checkWinner(chess,row,col);
            System.out.println(winner);
            //3.把响应写回给玩家
            response.setUserId(request.getUserId());
            response.setRow(row);
            response.setCol(col);
            response.setWinner(winner);
            //4.检查玩家的在线状态
            WebSocketSession session1 = onlineUserManager.getGameRoomSession(user1.getUserId());
            WebSocketSession session2 = onlineUserManager.getGameRoomSession(user2.getUserId());
    
            if(session1 == null){
                //玩家1掉线,玩家2自动获胜
                response.setWinner(user2.getUserId());
                System.out.println("玩家1掉线");
            }
    
            if(session2 == null){
                //玩家2掉线,玩家1自动获胜
                response.setWinner(user1.getUserId());
                System.out.println("玩家2掉线");
            }
    
            //传回响应
            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){
                //更新数据
                userMapper.userWin(response.getWinner() == user1.getUserId() ? user1.getUserId() : user2.getUserId());
                userMapper.userLose(response.getWinner() == user1.getUserId() ? user2.getUserId() : user1.getUserId());
                //销毁房间
                roomManager.remove(user1.getUserId(),user2.getUserId(),roomId);
                System.out.println("游戏结束,房间已销毁 roomId" + roomId + "获胜方" + response.getWinner());
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64

    打印棋盘

    实现room中的PrintBoard

    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(chessBoard[r][c] + " ");
                }
                System.out.println();
            }
            System.out.println("------------------------");
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    判决胜负

    实现room中的checkWinner

    这个方法其实很简单

    (假设为行,其余三种也是一样)当出现五子连珠时,这最后一步肯定在这个五个子中

    的一个,那么我们只需判断每次落子后左边4个和右边4个是否和自己颜色一样即可。

    在这里插入图片描述

    private int checkWinner(int chess, int row, int col) {
            // 以 row, col 为中心
            for (int c = col - 4; c <= col; c++) {
                // 针对其中的一种情况, 来判定这五个子是不是连在一起了~
                // 不光是这五个子得连着, 而且还得和玩家落的子是一样~~ (才算是获胜)
                try {
                    if (chessBoard[row][c] == chess
                            && chessBoard[row][c + 1] == chess
                            && chessBoard[row][c + 2] == chess
                            && chessBoard[row][c + 3] == chess
                            && chessBoard[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 (chessBoard[r][col] == chess
                            && chessBoard[r + 1][col] == chess
                            && chessBoard[r + 2][col] == chess
                            && chessBoard[r + 3][col] == chess
                            && chessBoard[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 (chessBoard[r][c] == chess
                            && chessBoard[r + 1][c + 1] == chess
                            && chessBoard[r + 2][c + 2] == chess
                            && chessBoard[r + 3][c + 3] == chess
                            && chessBoard[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 (chessBoard[r][c] == chess
                            && chessBoard[r + 1][c - 1] == chess
                            && chessBoard[r + 2][c - 2] == chess
                            && chessBoard[r + 3][c - 3] == chess
                            && chessBoard[r + 4][c - 4] == chess) {
                        return chess == 1 ? user1.getUserId() : user2.getUserId();
                    }
                } catch (ArrayIndexOutOfBoundsException e) {
                    continue;
                }
            }
    
            // 胜负未分, 就直接返回 0 了.
            return 0;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67

    处理玩家中途退出

    在 GameAPI 中

    //如果玩家掉线通知对手获胜
        private void noticeThatUserWin(User user) throws IOException {
            Room room = roomManager.getRoomByUserId(user.getUserId());
            if(room == null){
                System.out.println("房间已经释放,无需通知");
                return;
            }
            User thatUser = room.getUser1() == user ? room.getUser2() : room.getUser1();
            WebSocketSession session = onlineUserManager.getGameRoomSession(thatUser.getUserId());
            if(session == null){
                //这情况意味着对手也掉线了
                System.out.println("该玩家已掉线,无需通知");
                return;
            }
            //发送响应通知对手
            GameResponse response = new GameResponse();
            response.setUserId(thatUser.getUserId());
            response.setWinner(thatUser.getUserId());
            session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
    
            //更新玩家分数
            userMapper.userWin(thatUser.getUserId());
            userMapper.userLose(user.getUserId());
            //销毁房间
            roomManager.remove(user.getUserId(),thatUser.getUserId(),room.getRoomId());
            System.out.println("游戏结束,房间已销毁 roomId" + room.getRoomId() + "获胜方" + user.getUserId());
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    在这里插入图片描述

    部署到云服务器上

    增添数据库

    将我们写的db.sql直接复制到云服务器上。

    微调代码

    在这里插入图片描述

    打包

    通过maven打包
    在这里插入图片描述

    在这里插入图片描述

    在这里插入图片描述
    在这里插入图片描述

    运行

    使用java -jar + 包名即可
    在这里插入图片描述

    验证

    在这里插入图片描述

    总结

    此项目中包含了许多问题,如多开账号的处理、玩家突然掉线的处理、玩家按了回退之后的处理、多线程下线程安全的问题……但是作为一个项目来说,功能还是不太全面,后续预计将进行改善增添功能如:玩家观战、生成对局回放、生成AI对手等等,现在时间较紧迫,只能先做出这几个功能。

  • 相关阅读:
    文件操作(个人学习笔记黑马学习)
    SpringBoot图片文件上传
    讲解IaaS,PaaS,SaaS技术
    Servlet
    如何做一个较为完善的软件架构设计
    Unity接入PS5手柄和Xbox手柄以及Android平台的(以及不同平台分析)
    python接口自动化测试 —— unittest框架suite、runner详细使用
    Keil工程忽略文件.gitignore、自动删除脚本:keilkilll.bat、自动生成目录文件列表脚本
    ssm基于Java web的校园滴滴代驾管理系统毕业设计源码260839
    安卓循环遍历计时器
  • 原文地址:https://blog.csdn.net/qq_59689127/article/details/126236543