基于SpringBoot+MyBatis 五子棋双人对战

2年前 (2022) 程序员胖胖胖虎阿
377 0 0

  • 1. 核心功能
  • 2. 演示效果
  • 3. 创建项目
  • 4. 数据库设计
  • 5. 配置文件
  • 6. 用户模块
    • 6.1 登录实现
      • 6.1.1 前后端交互接口
      • 6.1.2 model 层
      • 6.1.3 mapper 层
      • 6.1.4 xml 层
      • 6.1.5 service 层
      • 6.1.6 controller 层
      • 6.1.7 使用 BCrypt 进行密码加密
      • 6.1.8 添加拦截器
      • 6.1.9 测试
    • 6.2 注册实现
      • 6.2.1 前后端交互接口
      • 6.2.2 controller 层
      • 6.2.3 测试
    • 6.3. 获取用户信息
      • 6.3.1 前后端交互接口
      • 6.3.2 controller 层
      • 6.3.3 测试
    • 6.4 退出登录
      • 6.4.1 前后端交互接口
      • 6.4.2 controller 层
      • 6.4.3 测试
  • 7. 匹配模块
    • 7.1 前后端交互接口
    • 7.2 匹配功能前端开发
    • 7.3 匹配功能后端开发
      • 7.3.1 实现用户管理器
      • 7.3.2 实现房间类
      • 7.3.3 实现房间管理器
      • 7.3.4 controller 层
  • 8. 对战模块
    • 8.1 前后端交互接口
    • 8.2 客户端开发
      • 8.2.1 初始化 websocket
      • 8.2.2 发送落子请求
      • 8.2.3 处理落子响应
    • 8.3 服务器开发
      • 8.3.1 创建落子请求/响应对象
      • 8.3.2 处理连接成功
      • 8.3.3 玩家下线处理
      • 8.3.4 Room 类添加棋盘代码
      • 8.3.5 处理落子请求
      • 8.3.6 实现 putChess 方法
      • 8.3.7 实现打印棋盘的逻辑
      • 8.3.8 实现胜负判定
      • 8.3.9 玩家中途退出处理

1. 核心功能

技术:
前端: HTML + CSS + JavaScript + AJAX
后端: SpringBoot + MyBatis + WebSocket + MySQL 5.7

基于SpringBoot+MyBatis 五子棋双人对战

2. 演示效果

基于SpringBoot+MyBatis 五子棋双人对战

基于SpringBoot+MyBatis 五子棋双人对战

基于SpringBoot+MyBatis 五子棋双人对战
基于SpringBoot+MyBatis 五子棋双人对战

3. 创建项目

基于SpringBoot+MyBatis 五子棋双人对战
基于SpringBoot+MyBatis 五子棋双人对战
基于SpringBoot+MyBatis 五子棋双人对战
基于SpringBoot+MyBatis 五子棋双人对战

4. 数据库设计

基于SpringBoot+MyBatis 五子棋双人对战

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(255),
    score int,        -- 天梯积分
    totalCount int,   -- 比赛总场数
    winCount int      -- 获胜场数
);

insert into user values(null,"cm","$2a$10$Bs4wNEkledVlGZa6wSfX7eCSD7wRMO0eUwkJH0WyhXzKQJrnk85li",1000,0,0);

5. 配置文件

application.yml

debug: true
logging:
    level:
        com:
            example: DEBUG
            example.onlinemusic.mapper: debug
        druid:
            sql:
                Statement: DEBUG
        root: INFO
spring:
    datasource:
        driver-class-name: com.mysql.cj.jdbc.Driver
        password: root
        url: jdbc:mysql://localhost:3306/java_gobang?characterEncoding=utf8&serverTimezone=UTC
        username: root

mybatis:
    mapper-locations: classpath:mybatis/**Mapper.xml
server:
    port: 8081

mybatis.xml

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

   
</mapper>

6. 用户模块

6.1 登录实现

6.1.1 前后端交互接口

每一次我们设计都需要先设计前后端交互接口

请求
POST /login HTTP/1.1
{username: "",password: ""}

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

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

6.1.2 model 层

创建 User 类

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

}

6.1.3 mapper 层

@Mapper 注解不要忘了

@Mapper
public interface UserMapper {

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

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

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

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

}

6.1.4 xml 层

resources 底下创建 mybatis 包,在创建UserMapper.xml

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

    <!-- 新增用户 -->
    <insert id="insert">
        insert into user values (null,#{username},#{password},1000,0,0);
    </insert>


    <!-- 根据用户名查找用户用户 -->
    <select id="selectByName" resultType="com.example.java_gobang.model.User">
        select * from user where username=#{username};
    </select>

    <update id="userWin">
        update user set totalCount = totalCount + 1, winCount = winCount + 1, score = score + 30
        where userId = #{userId}
    </update>

    <update id="userLose">
        update user set totalCount = totalCount + 1, score = score - 30
        where userId = #{userId}
    </update>

</mapper>

6.1.5 service 层

调用 mapper 层的方法

@Service
public class UserService {

    @Resource
    private UserMapper userMapper;

    //往数据里插入一个用户,用于注册功能
    public int insert(User user){
        return userMapper.insert(user);
    }

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

    // 总比赛场数 + 1, 获胜场数 + 1, 天梯分数 + 30
    public int userWin(int userId){
        return userMapper.userWin(userId);
    }

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

6.1.6 controller 层

基于SpringBoot+MyBatis 五子棋双人对战
用来存储 session 字符串

@RestController
public class UserController {

    @Autowired
    private UserService userService;

    @Resource
    private BCryptPasswordEncoder bCryptPasswordEncoder;


    @RequestMapping("/login")
    @ResponseBody
    public Object login(String username, String password, HttpServletRequest request){

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

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

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

6.1.7 使用 BCrypt 进行密码加密

<!-- security依赖包 (加密)-->
		<dependency>
			<groupId>org.springframework.security</groupId>
			<artifactId>spring-security-web</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.security</groupId>
			<artifactId>spring-security-config</artifactId>
		</dependency>

启动类添加注解

@SpringBootApplication(exclude = {org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration.class})

6.1.8 添加拦截器

创建 config 包

LoginInterceptor 类

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

AppConfig 类

@Override
    public void addInterceptors(InterceptorRegistry registry) {
        LoginInterceptor loginInterceptor = new LoginInterceptor();
        registry.addInterceptor(loginInterceptor)
                .addPathPatterns("/**")
                .excludePathPatterns("/**/login.html")
                .excludePathPatterns("/**/register.html")
                .excludePathPatterns("/**/css/**.css")
                .excludePathPatterns("/**/images/**")
                .excludePathPatterns("/**/js/**.js")
                .excludePathPatterns("/**/login")
                .excludePathPatterns("/**/register")
                .excludePathPatterns("/**/logout");
    }

6.1.9 测试

基于SpringBoot+MyBatis 五子棋双人对战

6.2 注册实现

6.2.1 前后端交互接口

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

username=cm&password=123456

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

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

注册失败(比如说用户名已经存在了),就返回 username 为 null 的对象

6.2.2 controller 层

@RequestMapping("/register")
    @ResponseBody
    public Object register(String username,String password){

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

6.2.3 测试

基于SpringBoot+MyBatis 五子棋双人对战

再注册相同的就不行了
基于SpringBoot+MyBatis 五子棋双人对战

6.3. 获取用户信息

6.3.1 前后端交互接口

请求
GET /userinfo HTTP/1.1

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

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

6.3.2 controller 层

@RequestMapping("/userinfo")
    @ResponseBody
    public Object getUserInfo(HttpServletRequest request){
            try{
                HttpSession session = request.getSession(false);
                User user = (User)session.getAttribute("user");
                User newUser = userService.selectByName(user.getUsername());
                return newUser;
            }catch (NullPointerException e){
                System.out.println("没有该用户");
                return new User();
            }

    }

6.3.3 测试

基于SpringBoot+MyBatis 五子棋双人对战

因为我们之前用这个账户登录的已经保存了这个 JSESSIONID
基于SpringBoot+MyBatis 五子棋双人对战

6.4 退出登录

6.4.1 前后端交互接口

请求
GET /logout HTTP/1.1

响应
HTTP/1.1 200

6.4.2 controller 层

@RequestMapping("/logout")
public void logout(HttpServletRequest request, HttpServletResponse response) throws IOException {
    HttpSession session = request.getSession(false);
    // 拦截器的拦截, 所以不可能出现session为空的情况
    session.removeAttribute(Constant.USER_SESSION_KEY);
    response.sendRedirect("login.html");

}

6.4.3 测试

重定向了我的登录页面
基于SpringBoot+MyBatis 五子棋双人对战

7. 匹配模块

客户端主动向服务器发起请求,返回一个响应,如果客户端不主动发起请求,服务器不能主动联系客户端,在这里,我们需要服务器主动给客户端发消息,就要用到"消息推送"
基于SpringBoot+MyBatis 五子棋双人对战
这里就要约定前后端交互接口了,也都是基于 websocket 来展开的,他可以传输文本数据,也能传输二进制数据,这里就设计成传输 json 格式的文本数据

7.1 前后端交互接口

连接(URL)

ws://127.0.0.1:8080/findMatch

匹配请求

{
    message: 'startMatch' / 'stopMatch', // 开始/结束匹配
}

这里匹配是登录之后,已经拿到了用户信息,保存到了 HttpSession 中了

匹配响应1(这一个响应是发送请求后,服务器立即返回的匹配响应)

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

匹配响应2(匹配到了对手,服务器主动推送回消息,匹配到的对手不需要在响应中体现,仍然都放到了服务器这边)

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

7.2 匹配功能前端开发

基于SpringBoot+MyBatis 五子棋双人对战

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

    // 一会重点来实现, 要处理服务器返回的响应
    websocket.onmessage = function(e) {
        // 处理服务器返回的响应数据. 这个响应就是针对 "开始匹配" / "结束匹配" 来对应的
        // 解析得到的响应对象. 返回的数据是一个 JSON 字符串, 解析成 js 对象
        let resp = JSON.parse(e.data);
        let matchButton = document.querySelector('#match-button');
        if (!resp.ok) {
            console.log("游戏大厅中接收到了失败响应! " + resp.reason);
            return;
        }
        if (resp.message == 'startMatch') {
            // 开始匹配请求发送成功
            console.log("进入匹配队列成功!");
            matchButton.innerHTML = '匹配中...(点击停止)'
        } else if (resp.message == 'stopMatch') {
            // 结束匹配请求发送成功
            console.log("离开匹配队列成功!");
            matchButton.innerHTML = '开始匹配';
        } else if (resp.message == 'matchSuccess') {
            // 已经匹配到对手了.
            console.log("匹配到对手! 进入游戏房间!");
            // location.assign("/game_room.html");
            location.replace("/game_room.html");
        } else if (resp.message == 'repeatConnection') {
            alert("当前检测到多开! 请使用其他账号登录!");
            location.replace("/login.html");
        } else {
            console.log("收到了非法的响应! message=" + resp.message);
        }
    }

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

7.3 匹配功能后端开发

创建 MatchController 类,处理 websocket 请求的入口类

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

    @Autowired
    private OnlineUserManager onlineUserManager;

    @Autowired
    private Matcher matcher;

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

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

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

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

AppConfig中添加触发url的响应地址

@Configuration
@EnableWebSocket
public class AppConfig implements WebSocketConfigurer {
    @Autowired
    private MatchAPI matchAPI;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(testAPI, "/test");
        // 通过 .addInterceptors(new HttpSessionHandshakeInterceptor() 这个操作来把 HttpSession 里的属性放到 WebSocket 的 session 中
        // 参考: https://docs.spring.io/spring-framework/docs/5.0.7.RELEASE/spring-framework-reference/web.html#websocket-server-handshake
        // 然后就可以在 WebSocket 代码中 WebSocketSession 里拿到 HttpSession 中的 attribute.
        registry.addHandler(matchAPI, "/findMatch")
                .addInterceptors(new HttpSessionHandshakeInterceptor());
    }
}

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

7.3.1 实现用户管理器

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

由于这里是 多线程的状态下, 很多用户访问同一个哈希表就会出现线程安全的问题, 所以这里就使用 ConcurrentHashMap, 确保了线程安全问题.

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

进入房间时,将用户存入哈希表中
退出时,将用户从哈希表中删除
通过 userId 来查询到对应的会话, 以便向客户端返回数据.

后面还有一个对战界面,都先创好

@Component
public class OnlineUserManager {
    private ConcurrentHashMap<Integer, WebSocketSession> gameHall = new ConcurrentHashMap<>();
    private ConcurrentHashMap<Integer, WebSocketSession> gameRoom = new ConcurrentHashMap<>();

    public void enterGameHall(int userId, WebSocketSession session) {
        gameHall.put(userId, session);
    }

    // 只有当前页面退出的时候, 能销毁自己的 session
    // 避免当一个 userId 打开两次 游戏页面, 错误的删掉之前的会话的问题.
    public void exitGameHall(int userId) {
        gameHall.remove(userId);
    }

    public WebSocketSession getSessionFromGameHall(int userId) {
        return gameHall.get(userId);
    }

    public void enterGameRoom(int userId, WebSocketSession session) {
        gameRoom.put(userId, session);
    }

    public void exitGameRoom(int userId) {
        gameRoom.remove(userId);
    }

    public WebSocketSession getSessionFromGameRoom(int userId) {
        return gameRoom.get(userId);
    }
}

在匹配当中的玩家我们用分数来进行匹配,把整个玩家分成了三类

Nomal: score < 2000
High: score >= 2000 && score < 3000
VeryHigh: score >= 3000

根据这三个等级,分配不同的队列,要搞一个专门的线程,不停地扫描这个匹配队列,匹配的玩家凑成了一对,就把他们取出来,放到一个房间中

提供 add 方法, 供 MatchAPI 类来调用, 用来把玩家加入匹配队列.
提供 remove 方法, 供 MatchAPI 类来调用, 用来把玩家移出匹配队列.
同时 Matcher 找那个要记录 OnlineUserManager, 来获取到玩家的 Session.

由于 handlerMatch 在单独的线程中调用. 因此要考虑到访问队列的线程安全问题. 需要加上锁.
每个队列分别使用队列对象本身作为锁即可.
在入口处使用 wait 来等待, 直到队列中达到 2 个元素及其以上, 才唤醒线程消费队列.

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

    @Autowired
    private OnlineUserManager onlineUserManager;

    @Autowired
    private RoomManager roomManager;

    private ObjectMapper objectMapper = new ObjectMapper();


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

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

    }

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

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

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

    private void handlerMatch(Queue<User> matchQueue) {
        synchronized (matchQueue){
            try{
                // 1. 检测队列中元素个数是否达到 2
                // 队列的初始情况可能是 空
                // 如果往队列中添加一个元素,这个时候,仍然是不能进行后续匹配操作的
                // 因此在这里使用 while 循环检查是更合理的
                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.getSessionFromGameHall(player1.getUserId());
                WebSocketSession session2 = onlineUserManager.getSessionFromGameHall(player2.getUserId());
                //理论上来说,匹配队列中的玩家一定是在线的状态
                // 因为前面的逻辑里进行了处理,当玩家断开连接的时候把玩家从匹配队列中移除
                // 但是此处仍然进行一次判定
                if(session1 == null){
                    // 如果玩家1 现在不在线,就把玩家2 重新放回到匹配队列中
                    matchQueue.offer(player2);
                    return;
                }
                if(session2 == null){
                    // 如果玩家2 现在下线,就把玩家1 重新放回到匹配队列
                    matchQueue.offer(player1);
                    return;
                }
                // 当前能否排到两个玩家是同一个用户的情况嘛? 一个玩家入队列了两次??
                // 理论上也不会存在~~
                // 1) 如果玩家下线, 就会对玩家移出匹配队列
                // 2) 又禁止了玩家多开.
                // 但是仍然在这里多进行一次判定, 以免前面的逻辑出现 bug 时带来严重的后果.
                if (session1 == session2) {
                    // 把其中的一个玩家放回匹配队列.
                    matchQueue.offer(player1);
                    return;
                }


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

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

                MatchResponse response2 = new MatchResponse();
                response2.setOk(true);
                response2.setMessage("matchSuccess");
                String json2 = objectMapper.writeValueAsString(response2);
                session2.sendMessage(new TextMessage(json2));



            }catch (IOException | InterruptedException e){
                e.printStackTrace();
            }
        }
    }

}

7.3.2 实现房间类

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

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

    private ObjectMapper objectMapper = new ObjectMapper();

    private OnlineUserManager onlineUserManager;

    public Room() {
        // 使用 uuid 作为唯一身份标识
        roomId = UUID.randomUUID().toString();
    }

    // getter / setter 方法略
}

7.3.3 实现房间管理器

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

  1. 使用一个 Hash 表, 保存所有的房间对象, key 为 roomId, value 为 Room 对象
  2. 再使用一个 Hash 表, 保存 userId -> roomId 的映射, 方便根据玩家来查找所在的房间.
  3. 提供增, 删, 查的 API. (查包含两个版本, 基于房间 ID 的查询和基于用户 ID 的查询).
// 房间管理器
// 也要唯一实例
    @Component
public class RoomManager {
    private ConcurrentHashMap<String, Room> rooms = new ConcurrentHashMap<>();
    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 映射关系不存在, 直接返回 null
            return null;
        }
        return rooms.get(roomId);
    }

}

7.3.4 controller 层

websocket 有4个方法:

一: 实现 afterConnectionEstablished 方法.

  1. 通过参数中的 session 对象, 拿到之前登录时设置的 User 信息.
  2. 使用 onlineUserManager 来管理用户的在线状态.
  3. 先判定用户是否是已经在线, 如果在线则直接返回出错 (禁止同一个账号多开).
  4. 设置玩家的上线状态.
@Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        // 玩家上线, 加入到 OnlineUserManager 中

        // 1. 先获取到当前用户的身份信息(谁在游戏大厅中, 建立的连接)
        try {
            User user = (User) session.getAttributes().get(Constant.USER_SESSION_KEY);

            // 2. 先判定当前用户是否已经登录过(已经是在线状态), 如果是已经在线, 就不该继续进行后续逻辑.
            if (onlineUserManager.getSessionFromGameHall(user.getUserId()) != null
                    || onlineUserManager.getSessionFromGameRoom(user.getUserId()) != null) {
                // 当前用户已经登录了!!
                // 针对这个情况要告知客户端, 你这里重复登录了.
                MatchResponse response = new MatchResponse();
                response.setOk(true);
                response.setReason("当前禁止多开!");
                response.setMessage("repeatConnection");
                session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
                // 此处直接关闭有些太激进了, 还是返回一个特殊的 message , 供客户端来进行判定, 由客户端负责进行处理
                // session.close();
                return;
            }

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

二: 实现 handleTextMessage 方法

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

三: 实现 afterConnectionClosed 方法

  1. 主要的工作就是把玩家从 onlineUserManager 中退出.
  2. 退出的时候要注意判定, 当前玩家是否是多开的情况(一个userId, 对应到两个 websocket 连接). 如果一个玩家开启了第二个 websocket 连接, 那么这第二个 websocket 连接不会影响到玩家从 OnlineUserManager 中退出.
  3. 如果玩家当前在匹配队列中, 则直接从匹配队列里移除.
@Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        try {
            // 玩家下线, 从 OnlineUserManager 中删除
            User user = (User) session.getAttributes().get(Constant.USER_SESSION_KEY);
            WebSocketSession tmpSession = onlineUserManager.getSessionFromGameHall(user.getUserId());
            if (tmpSession == session) {
                onlineUserManager.exitGameHall(user.getUserId());
            }
            // 如果玩家正在匹配中, 而 websocket 连接断开了, 就应该移除匹配队列
            matcher.remove(user);
        } catch (NullPointerException e) {
            System.out.println("[MatchAPI.afterConnectionClosed] 当前用户未登录!");
            // e.printStackTrace();
        }
    }

四: 实现 handleTransportError 方法

同 afterConnectionClosed 一样逻辑,异常关闭的情况, 获取用户的信息, 然后设置在线状态为不在线, 然后删除匹配队列中的用户

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

8. 对战模块

8.1 前后端交互接口

连接

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
}

8.2 客户端开发

这里的 canvas 是用来绘制棋盘的,
基于SpringBoot+MyBatis 五子棋双人对战

js文件:

  1. 这部分代码基于 canvas API. 我们不需要理解这部分内容. 只需要直接复制粘贴下列代码即可.
  2. 使用一个二维数组来表示棋盘. 虽然胜负是通过服务器判定的, 但是客户端的棋盘可以避免 “一个位置重复落子” 这样的情况
  3. oneStep 函数起到的效果是在一个指定的位置上绘制一个棋子. 可以区分出绘制白字还是黑子. 参数是横坐标和纵坐标, 分别对应列和行.
  4. 用 onclick 来处理用户点击事件. 当用户点击的时候通过这个函数来控制绘制棋子.
  5. me 变量用来表示当前是否轮到我落子. over 变量用来表示游戏结束.
  6. 这个代码中会用到一个背景图(sky.jpg), 放到 image 目录中即可.
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();

8.2.1 初始化 websocket

在前端代码中:

  1. 先删掉原来的 initGame 函数的调用. 一会在获取到服务器反馈的就绪响应之后, 再初始化棋盘.
  2. 创建 websocket 对象, 并注册 onopen/onclose/onerror 函数. 其中在 onerror 中做一个跳转到游戏大厅的逻辑. 当网络异常断开, 则回到大厅.
  3. 实现 onmessage 方法. onmessage 先处理游戏就绪响应.
// 注意, 路径要写作 /game 不要写作 /game/
websocket = new WebSocket("ws://127.0.0.1:8080/game");
//连接成功建立的回调方法
websocket.onopen = function (event) {
    console.log("open");
}
//连接关闭的回调方法
websocket.onclose = function () {
    console.log("close");
}
//连接发生错误的回调方法
websocket.onerror = function () {
    console.log("error");
    alert('和服务器连接断开! 返回游戏大厅!')
    location.assign('/game_hall.html')
};
//监听窗口关闭事件,当窗口关闭时,主动去关闭websocket连接,防止连接还没断开就关闭窗口,server端会抛异常。
window.onbeforeunload = function () {
    websocket.close();
}

websocket.onmessage = function (event) {
    console.log('handlerGameReady: ' + event.data);

    let response = JSON.parse(event.data);
    if (response.message != 'gameReady') {
        console.log('响应类型错误!');
        return;
    }
    if (!response.ok) {
        alert('连接游戏失败! reason: ' + response.reason);
        location.assign('/game_hall.html')
        return;
    }
    // 初始化游戏信息
    gameInfo.roomId = response.roomId;
    gameInfo.thisUserId = response.thisUserId;
    gameInfo.thatUserId = response.thatUserId;
    gameInfo.isWhite = (response.whiteUserId == gameInfo.thisUserId);
    console.log('[gameReady] ' + JSON.stringify(gameInfo));
    // 初始化棋盘
    initGame();
    // 设置 #screen 的显示
    setScreenText(gameInfo.isWhite);
}

8.2.2 发送落子请求

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

  1. 注释掉原有的 onStep 和 修改 chessBoard 的操作, 放到接收落子响应时处理.
  2. 实现 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;
        // me = !me; 
    }
}

function send(row, col) {
    console.log("send");
    let request = {
        message: "putChess",
        userId: gameInfo.thisUserId,
        row: row,
        col: col,
    }
    websocket.send(JSON.stringify(request));
}

8.2.3 处理落子响应

在 initGame 中, 修改 websocket 的 onmessage

  1. 在 initGame 之前, 处理的是游戏就绪响应, 在收到游戏响应之后, 就改为接收落子响应了.
  2. 在处理落子响应中要处理胜负手.
websocket.onmessage = function (event) {
    console.log('handlerPutChess: ' + event.data);

    let response = JSON.parse(event.data);
    if (response.message != 'putChess') {
        console.log('响应类型错误!');
        return;
    }

    // 1. 判断 userId 是自己的响应还是对方的响应, 
    //    以此决定当前这个子该画啥颜色的
    if (response.userId == gameInfo.thisUserId) {
        oneStep(response.col, response.row, gameInfo.isWhite);
    } else if (response.userId == gameInfo.thatUserId) {
        oneStep(response.col, response.row, !gameInfo.isWhite);
    } else {
        console.log('[putChess] response userId 错误! response=' + JSON.stringify(response));
        return;
    }
    chessBoard[response.row][response.col] = 1;
    me = !me; // 接下来该下个人落子了. 

    // 2. 判断游戏是否结束
    if (response.winner != 0) {
        // 胜负已分
        if (response.winner == gameInfo.thisUserId) {
            alert("你赢了!");
        } else {
            alert("你输了");
        }
        // 如果游戏结束, 则关闭房间, 回到游戏大厅. 
        location.assign('/game_hall.html')
    }

    // 3. 更新界面显示
    setScreenText(me);
}

8.3 服务器开发

创建 GameController 类,用来处理websocket 请求

@Component
public class GameController 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 {
    }
}

8.3.1 创建落子请求/响应对象

GameReadyResponse 类

public class GameReadyResponse {
    private String message = "gameReady";
    private boolean ok = true;
    private String reason = "";
    private String roomId = "";
    private int thisUserId = 0;
    private int thatUserId = 0;
    private int whiteUserId = 0;
}

GameRequest 类

public class GameRequest {
    // 如果不给 message 设置 getter / setter, 则不会被 jackson 序列化
    private String message = "putChess";
    private int userId;
    private int row;
    private int col;
}

GameResponse 类

public class GameResponse {
    // 如果不给 message 设置 getter / setter, 则不会被 jackson 序列化
    private String message = "putChess";
    private int userId;
    private int row;
    private int col;
    private int winner; // 胜利玩家的 userId
}

8.3.2 处理连接成功

  1. 首先需要检测用户的登录状态. 从 Session 中拿到当前用户信息.
  2. 然后要判定当前玩家是否是在房间中.
  3. 接下来进行多开判定.如果玩家已经在游戏中, 则不能再次连接.
  4. 把两个玩家放到对应的房间对象中. 当两个玩家都建立了连接, 房间就放满了.这个时候通知两个玩家双方都准备就绪.
  5. 如果有第三个玩家尝试也想加入房间, 则给出一个提示, 房间已经满了.
@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("用户尚未登录!");
        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.getSessionFromGameHall(user.getUserId()) != null
        || onlineUserManager.getSessionFromGameRoom(user.getUserId()) != null) {
        resp.setOk(false);
        resp.setReason("禁止多开游戏页面!");
        session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
        return;
    }
    // 更新会话
    onlineUserManager.enterGameRoom(user.getUserId(), session);

    // 同一个房间的两个玩家, 同时连接时要考虑线程安全问题.
    synchronized (room) {
        if (room.getUser1() == null) {
            room.setUser1(user);
            // 设置 userId1 为先手方
            room.setWhiteUserId(user.getUserId());
            System.out.println("userId=" + user.getUserId() + " 玩家1准备就绪!");
            return;
        }
        if (room.getUser2() == null) {
            room.setUser2(user);
            System.out.println("userId=" + user.getUserId() + " 玩家2准备就绪!");

            // 通知玩家1 就绪
            noticeGameReady(room, room.getUser1().getUserId(), room.getUser2().getUserId());
            // 通知玩家2 就绪
            noticeGameReady(room, room.getUser2().getUserId(), room.getUser1().getUserId());
            return;
        }
    }
    // 房间已经满了!
    resp.setOk(false);
    String log = "roomId=" + room.getRoomId() + " 已经满了! 连接游戏失败!";
    resp.setReason(log);
    session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
    System.out.println(log);
}

8.3.3 玩家下线处理

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

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

8.3.4 Room 类添加棋盘代码

这里Room要注入Spring对象, 不能使用@Autowired @Resource注解. 需要使用context

修改启动类

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);
      userService = JavaGobangApplication.context.getBean(UserService.class);
    }

8.3.5 处理落子请求

实现 handleTextMessage

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

8.3.6 实现 putChess 方法

  1. 先把请求解析成请求对象.
    2. 根据请求对象中的信息, 往棋盘上落子.
    3. 落子完毕之后, 为了方便调试, 可以打印出棋盘的当前状况.
    4. 检查游戏是否结束.
    5. 构造落子响应, 写回给每个玩家.
    6. 写回的时候如果发现某个玩家掉线, 则判定另一方为获胜.
    7. 如果游戏胜负已分, 则修改玩家的分数, 并销毁房间.
// 玩家落子
public void putChess(String message) throws IOException {
    GameRequest req = objectMapper.readValue(message, GameRequest.class);
    GameResponse response = new GameResponse();
    // 1. 进行落子
    int chess = req.getUserId() == user1.getUserId() ? 1 : 2;
    int row = req.getRow();
    int col = req.getCol();
    if (chessBoard[row][col] != 0) {
        System.out.println("落子位置有误! " + req);
        return;
    }
    chessBoard[row][col] = chess;
    printChessBoard();
    // 2. 检查游戏结束
    //    返回的 winner 为玩家的 userId
    int winner = checkWinner(chess, row, col);
    // 3. 把响应写回给玩家
    response.setUserId(req.getUserId());
    response.setRow(row);
    response.setCol(col);
    response.setWinner(winner);
    WebSocketSession session1 = onlineUserManager.getSessionFromGameRoom(user1.getUserId());
    WebSocketSession session2 = onlineUserManager.getSessionFromGameRoom(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 responseJson = objectMapper.writeValueAsString(response);
    if (session1 != null) {
        session1.sendMessage(new TextMessage(responseJson));
    }
    if (session2 != null) {
        session2.sendMessage(new TextMessage(responseJson));
    }
    // 4. 如果玩家胜负已分, 就把 room 从管理器中销毁
    if (response.getWinner() != 0) {
        userMapper.userWin(response.getWinner() == user1.getUserId() ? user1 : user2);
        userMapper.userLose(response.getWinner() == user1.getUserId() ? user2 : user1);
        roomManager.removeRoom(roomId, user1.getUserId(), user2.getUserId());
        System.out.println("游戏结束, 房间已经销毁! roomId: " + roomId + " 获胜方为: " + response.getWinner());
    }
}

8.3.7 实现打印棋盘的逻辑

private void printChessBoard() {
    System.out.println("打印棋盘信息: ");
    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("===========================");
}

8.3.8 实现胜负判定

  1. 如果游戏分出胜负, 则返回玩家的 id. 如果未分出胜负,则返回 0.
  2. 棋盘中值为 1 表示是玩家 1 的落子, 值为 2 表示是玩家 2 的落子.
  3. 检查胜负的时候, 以当前落子位置为中心, 检查所有相关的行,列, 对角线即可. 不必遍历整个棋盘.
// 判定棋盘形式, 找出胜利的玩家.
// 如果游戏分出胜负, 则返回玩家的 id.
// 如果未分出胜负, 则返回 0
// chess 值为 1 表示玩家1 的落子. 为 2 表示玩家2 的落子
private int checkWinner(int chess, int row, int col) {
    // 以 row, col 为中心
    boolean done = false;
    // 1. 检查所有的行(循环五次)
    for (int c = col - 4; c <= col; c++) {
        if (c < 0 || c >= MAX_COL) {
            continue;
        }
        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) {
            done = true;
        }
    }
    // 2. 检查所有的列(循环五次)
    for (int r = row - 4; r <= row; r++) {
        if (r < 0 || r >= MAX_ROW) {
            continue;
        }
        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) {
            done = true;
        }
    }
    // 3. 检查左对角线
    for (int r = row - 4, c = col - 4; r <= row && c <= col; r++, c++) {
        if (r < 0 || r >= MAX_ROW || c < 0 || c >= MAX_COL) {
            continue;
        }
        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) {
            done = true;
        }
    }
    // 4. 检查右对角线
    for (int r = row - 4, c = col + 4; r <= row && c >= col; r++, c--) {
        if (r < 0 || r >= MAX_ROW || c < 0 || c >= MAX_COL) {
            continue;
        }
        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) {
            done = true;
        }
    }
    if (!done) {
        return 0;
    }
    return chess == 1 ? user1.getUserId() : user2.getUserId();
}

8.3.9 玩家中途退出处理

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

@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
    User user = (User) session.getAttributes().get("user");
    if (user == null) {
        return;
    }
    WebSocketSession existSession = onlineUserManager.getSessionFromGameRoom(user.getUserId());
    if (existSession != session) {
        System.out.println("当前的会话不是玩家游戏中的会话, 不做任何处理!");
        return;
    }
    System.out.println("用户退出! userId=" + user.getUserId());
    onlineUserManager.exitGameRoom(user.getUserId());
    
    // [代码加在这里]
    noticeThatUserWin(user);
}
// 通知另外一个玩家直接获胜!
private void noticeThatUserWin(User user) throws IOException {
    Room room = roomManager.getRoomByUserId(user.getUserId());
    if (room == null) {
        System.out.println("房间已经释放, 无需通知!");
        return;
    }
    User thatUser = (user == room.getUser1() ? room.getUser2() : room.getUser1());
    WebSocketSession session = onlineUserManager.getSessionFromGameRoom(thatUser.getUserId());
    if (session == null) {
        System.out.println(thatUser.getUserId() + " 该玩家已经下线, 无需通知!");
        return;
    }
    GameResponse resp = new GameResponse();
    resp.setUserId(thatUser.getUserId());
    resp.setWinner(thatUser.getUserId());
    session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
}

版权声明:程序员胖胖胖虎阿 发表于 2022年9月15日 上午2:32。
转载请注明:基于SpringBoot+MyBatis 五子棋双人对战 | 胖虎的工具箱-编程导航

相关文章

暂无评论

暂无评论...