五子棋对战(网页版)

news2024/11/28 14:51:09

目录

一、项目背景

用户模块

匹配模块

对战模块

二、核心技术

三、相关知识

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 功能, 实现人机对战.

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1670729.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

深度解析循环购模式:让消费更有价值

大家好&#xff0c;我是吴军&#xff0c;今天我非常高兴能和大家分享一个充满活力和创新的商业模式——循环购模式。可能大家都听过消费达到一定金额就有现金返还的活动&#xff0c;但这种返还通常都伴随着各种条件和限制。而循环购模式&#xff0c;它不仅仅是一个简单的返利机…

Excel 两层分类后的行转列

例题描述 Excel 文件中有下图所示的数据&#xff0c;同 Name 的物品可能有多种颜色。 现在想要把数据列出下图的形式&#xff0c;每种Type一行&#xff0c;其后依次列出每种Name及其Color。 实现方法 使用 Excel 插件 SPL XLL 在空白单元格写入公式&#xff1a; spl("…

模型 洋葱模型(组织文化方向)

系列文章 分享 模型&#xff0c;了解更多&#x1f449; 模型_思维模型目录。层层深入&#xff0c;探索核心。 1 洋葱模型的应用 1.1 洋葱模型用于职业规划 有一个名叫李明的大学生&#xff0c;他最近感到迷茫和压力&#xff0c;因为他即将毕业并面临职业选择。李明决定寻求心…

光伏EPC管理软件都有哪些功能和作用?

光伏EPC管理软件是用于光伏工程项目管理的综合性工具&#xff0c;它涵盖了从项目策划、设计、采购、施工到运维的各个环节。 1、项目总览 管理所有项目计划&#xff0c;包括项目类型、项目容量等。 调整和优化项目计划&#xff0c;以应对不可预见的情况。 2、施工管理 制定…

JVM调优:JVM运行时数据区详解

一、前言 Java运行时数据区域划分&#xff0c;Java虚拟机在执行Java程序时&#xff0c;将其所管理的内存划分为不同的数据区域&#xff0c;每个区域都有特定的用途和创建销毁的时间。 其中&#xff0c;有些区域在虚拟机进程启动时就存在&#xff0c;而有些区域则是随着用户线程…

【数据结构】二叉排序树(查找+插入+删除+效率分析)完整代码+解析

3.1 二叉排序树 3.1.1 定义 二叉排序树的定义 又称二叉查找树&#xff08;BST&#xff0c;Binary Search Tree&#xff09; 二叉排序树是具有以下性质的二叉树&#xff1a; 左子树结点值<根结点值<右子树结点值 进行中序遍历&#xff0c;可以得到一个递增的有序序列。 3…

代码随想录算法训练营Day 38| 动态规划part01 | 理论基础、509. 斐波那契数、70. 爬楼梯、746. 使用最小花费爬楼梯

代码随想录算法训练营Day 38| 动态规划part01 | 理论基础、509. 斐波那契数、70. 爬楼梯、746. 使用最小花费爬楼梯 文章目录 代码随想录算法训练营Day 38| 动态规划part01 | 理论基础、509. 斐波那契数、70. 爬楼梯、746. 使用最小花费爬楼梯理论基础一、常规题目二、解题步骤…

JWT token demo

1、pom依赖 <parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>2.2.9.RELEASE</version> </parent><dependencies><dependency><groupId>…

【JavaWeb】Day74.Spring——AOP进阶(连接点)

连接点 连接点可以简单理解为可以被AOP控制的方法。我们目标对象当中所有的方法不是都是可以被AOP控制的方法。而在SpringAOP当中&#xff0c;连接点又特指方法的执行。 在Spring中用JoinPoint抽象了连接点&#xff0c;用它可以获得方法执行时的相关信息&#xff0c;如目标类名…

【树】简要理解树的概念

P. S.&#xff1a;以下代码均在VS2019环境下测试&#xff0c;不代表所有编译器均可通过。 P. S.&#xff1a;测试代码均未展示头文件stdio.h的声明&#xff0c;使用时请自行添加。 目录 1、树的概念2、树的相关概念3、结语 1、树的概念 树是一种非线性的数据结构&#xff0c;它…

为什么大家都说it行业要死了?

年后开始找工作开始还不敢投简历怕准备不充分。怕错过了心仪的公司。 花了2周自己死磕了一下自我介绍&#xff0c;和工作经历。然后信心满满就开始投了&#xff0c;结果发现一堆人打招呼一阵欣喜的。可是一查全是外包公司。于是开始了自我怀疑。难道是我能力不行&#xff1f;难…

SQL Server服务启动缓慢或崩溃问题分析

关键词&#xff1a;SQL Server故障解决、服务启动缓慢、服务崩溃、日志文件清理 阅读建议&#xff1a;如果您遇到的是SQL Server服务启动缓慢或无法响应的问题&#xff0c;可以重点阅读文章的第一部分&#xff1b;如果是服务崩溃或重启的问题&#xff0c;可以重点关注第二部分。…

【其他学习参考文档记录】

交叉编译学习参考 nodejs 交叉编译-cliff工作室

经典文献阅读之--D-Map(无需射线投射的高分辨率激光雷达传感器的占据栅格地图)

0. 简介 占用地图是机器人系统中推理环境未知和已知区域的基本组成部分。《Occupancy Grid Mapping without Ray-Casting for High-resolution LiDAR Sensors》介绍了一种高分辨率LiDAR传感器的高效占用地图框架&#xff0c;称为D-Map。该框架引入了三个主要创新来解决占用地图…

UE4_照亮环境_光束light beam

学习笔记&#xff0c;不喜勿喷&#xff0c;侵权立删&#xff01;祝愿生活越来越好&#xff01; 光束&#xff1a;模拟大气中散射的光线。利用定向光源模拟真实曙暮光效果或大气散射的阴影&#xff0c;即可生成 光束 。这些光线为场景添加深度和真实度。 一&#xff1a;一些参数…

(四十一)第 6 章 树和二叉树(包含双亲的树的孩子链表存储)

1. 背景说明 2. 示例代码 1) errorRecord.h // 记录错误宏定义头文件#ifndef ERROR_RECORD_H #define ERROR_RECORD_H#include <stdio.h> #include <string.h> #include <stdint.h>// 从文件路径中提取文件名 #define FILE_NAME(X) strrchr(X, \\) ? strrch…

中学数学研究杂志中学数学研究杂志社中学数学研究编辑部2024年第4期目录

教学纵横 高中数学选择性必修课程函数主线分析 柳双;吴立宝; 1-4 贯彻新课程理念 促学习能力提升——以“三角函数诱导公式”教学为例 陆雨轩; 4-6《中学数学研究》投稿&#xff1a;cn7kantougao163.com 对高中数学新课标教材新增知识点的价值分析 钱伟风;刘瑞美; …

每个开发者都应该拥有的-云服务器!

每个开发者都应该拥有的-云服务器&#xff01; 第一台云服务器ECS言归正传云服务器妙用 每个开发者都该拥有一台云服务器 作为开发者&#xff0c;总是会时不时的跟服务器打交道&#xff0c;不管你是部署应用程序&#xff0c;还是在服务器上面搭建安装基础环境&#xff0c;比如J…

品牌设计理念和logo设计方法

一 品牌设计的目的 设计是为了传播&#xff0c;让传播速度更快&#xff0c;传播效率更高&#xff0c;减少宣传成本 二 什么是好的品牌设计 好的设计是为了让消费者更容易看懂、记住的设计&#xff0c; 从而辅助传播&#xff0c; 即 看得懂、记得住。 1 看得懂 就是让别人看懂…

vue3中使用cherry-markdown

附cherry-markdown官网及api使用示例 官网:https://github.com/Tencent/cherry-markdown/blob/main/README.CN.md api:Cherry Markdown API 考虑到复用性,我在插件的基础上做了二次封装,步骤如下: 1.下载 (一定要指定版本0.8.22,否则会报错: [vitel Internal server e…