SpringBoot+MyBatis 주사위 놀이 더블 게임 기반

1. 핵심 기능

기술:
프런트엔드: HTML + CSS + JavaScript + AJAX
백엔드:SpringBoot + MyBatis + WebSocket + MySQL 5.7

여기에 이미지 설명 삽입

2. 시연효과

여기에 이미지 설명 삽입

여기에 이미지 설명 삽입

여기에 이미지 설명 삽입
여기에 이미지 설명 삽입

3. 프로젝트 생성

여기에 이미지 설명 삽입
여기에 이미지 설명 삽입
여기에 이미지 설명 삽입
여기에 이미지 설명 삽입

4. 데이터베이스 설계

여기에 이미지 설명 삽입

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 모델 레이어

사용자 클래스 만들기

@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
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 쓰레기

리소스 아래에 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
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 컨트롤러 계층

여기에 이미지 설명 삽입
세션 문자열을 저장하는 데 사용

@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 인터셉터 추가

구성 패키지 만들기

로그인 인터셉터 클래스

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 테스트

여기에 이미지 설명 삽입

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
}    

등록에 실패하면(예: 사용자 이름이 이미 존재함) 사용자 이름이 null인 객체를 반환합니다.

6.2.2 컨트롤러 계층

@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 테스트

여기에 이미지 설명 삽입

동일하게 다시 등록하면 작동하지 않습니다.
여기에 이미지 설명 삽입

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 컨트롤러 계층

@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 테스트

여기에 이미지 설명 삽입

이 계정으로 로그인하기 전에 이 JSESSIONID를 저장했기 때문에
여기에 이미지 설명 삽입

6.4 로그아웃

6.4.1 프론트엔드와 백엔드 상호작용 인터페이스

请求
GET /logout HTTP/1.1

响应
HTTP/1.1 200

6.4.2 컨트롤러 계층

@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 테스트

내 로그인 페이지를 리디렉션했습니다
여기에 이미지 설명 삽입

7. 매칭 모듈

클라이언트는 적극적으로 서버에 요청을 시작하고 응답을 반환합니다. 클라이언트가 요청을 시작하지 않으면 서버는 클라이언트에 적극적으로 연결할 수 없습니다. 여기에서 서버가 클라이언트에 적극적으로 메시지를 보내야 하며 다음을 수행해야 합니다. 여기에서 "메시지 푸시"를 사용
여기에 이미지 설명 삽입
합니다. 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 매칭 기능의 프론트엔드 개발

여기에 이미지 설명 삽입

// 此处进行初始化 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 请求
@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의 세션에 넣을 수 있으며, 다음 코드에서 현재 사용자 정보를 얻는 것이 편리하다.

7.3.1 사용자 관리자 구현

OnlineUserManager 클래스를 생성하여 현재 사용자의 온라인 상태를 관리합니다. 본질적으로 해시 테이블 구조입니다. 키는 사용자 ID이고 값은 사용자의 WebSocketSession입니다.

이것은 다중 스레드 상태이므로 많은 사용자가 동일한 해시 테이블에 액세스할 때 스레드 안전 문제가 있으므로 여기에서 ConcurrentHashMap을 사용하여 스레드 안전 문제를 확인합니다.

이 클래스의 도움으로 한편으로는 사용자가 온라인 상태인지 여부를 확인할 수 있으며 동시에 클라이언트에게 다시 콜백하기 위한 세션을 쉽게 얻을 수 있습니다.

방에 들어갈 때 해시 테이블에 사용자를 저장하고
나갈 때 해시 테이블에서 사용자를 삭제하고
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);
    }
}

경기에 참가하는 선수들은 점수로 매칭되며, 전체 선수들은 3가지 카테고리로 나뉩니다.

보통: 점수 < 2000
높음: 점수 >= 2000 && 점수 < 3000
매우 높음: 점수 >= 3000

이 세 가지 레벨에 따라 다른 대기열이 할당됩니다. 일치하는 대기열을 지속적으로 스캔하려면 특수 스레드가 필요합니다. 일치하는 플레이어가 쌍을 형성하면 꺼내어 방에 배치됩니다.

일치하는 대기열에 플레이어를 추가하기 위해 호출할 MatchAPI 클래스에 대한 add 메소드 제공 일치하는 대기열에서 플레이어를
제거하기 위해 호출할 MatchAPI 클래스에 대한 제거 메소드 제공
동시에 Matcher는 OnlineUserManager를 찾습니다. 플레이어의 세션을 얻기 위해 녹음되었습니다.

handlerMatch 는 별도의 쓰레드에서 호출되기 때문에 쓰레드에 대한 안전성을 고려해야 하며, 락을 추가해야 하며
, 각 Queue는 Queue 객체 자체를 락으로 사용할 수 있다.
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 Room 클래스 구현

경기가 성공하면 두 플레이어는 같은 방 개체에 배치해야 합니다.

  1. 방은 UUID를 방의 고유 식별자로 사용하는 방 ID를 포함해야 합니다.
  2. 게임에 있는 두 선수의 정보는 방에 기록되어야 합니다.
  3. 퍼스트 무버의 ID를 기록하십시오.
  4. 게임의 체스판으로 2차원 배열을 기록합니다.
  5. 나중에 클라이언트와 상호 작용할 수 있도록 OnlineUserManager를 기록합니다.
  6. 물론 json을 처리하기 위해서는 ObjectMapper가 필수적입니다.
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 룸 매니저 구현하기

많은 방 개체가 있을 것입니다. 게임의 모든 두 플레이어는 방 개체에 해당합니다. 모든 방을 관리하려면 관리자 개체가 필요합니다.

  1. 해시 테이블을 사용하여 모든 방 개체를 저장합니다. 키는 roomId, 값은 방 개체입니다.
  2. 그런 다음 Hash 테이블을 사용하여 userId -> roomId의 매핑을 저장하면 플레이어에 따라 방을 찾는 데 편리합니다.
  3. 추가, 삭제, 확인을 위한 API를 제공합니다.(Checking은 방 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 컨트롤러 계층

websocket에는 4가지 방법이 있습니다.

一: 实现 afterConnectionEstablished 方法.

  1. 매개변수의 세션 객체를 통해 로그인하기 전에 설정한 사용자 정보를 가져옵니다.
  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. 일치하는 실제 논리를 처리하려면 여기에서 matcher 개체를 구현해야 합니다.
@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, 두 개의 웹 소켓 연결에 해당) 플레이어가 두 번째 웹 소켓 연결을 열면 두 번째 웹 소켓 연결은 플레이어에 영향을 미치지 않습니다. 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 클라이언트 개발

여기 캔버스는 체스판을 그리는 데 사용되며,
여기에 이미지 설명 삽입

js 파일:

  1. 이 부분은 캔버스 API를 기반으로 하고 있으므로 이 부분을 이해할 필요는 없으며 다음 코드를 직접 복사하여 붙여넣기 하면 됩니다.
  2. 2차원 배열을 사용하여 체스판을 표현합니다. 결과는 서버에서 결정되지만 클라이언트의 체스판은 "한 위치에서 반복적으로 이동"하는 상황을 피할 수 있습니다.
  3. oneStep 함수의 효과는 지정된 위치에 체스 말을 그리는 것입니다. 흰색 문자를 그릴지 검은색 말을 그릴지 구분할 수 있습니다. 매개 변수는 가로축과 세로축으로 각각 열과 행에 해당합니다.
  4. onclick을 사용하여 사용자 클릭 이벤트를 처리합니다. 사용자가 클릭할 때 이 함수는 폰의 그리기를 제어하는 ​​데 사용됩니다.
  5. me 변수는 내가 이동할 차례인지 여부를 나타내는 변수이고 over 변수는 게임의 끝을 나타내는 변수입니다.
  6. 이 코드는 이미지 디렉토리에 배치할 수 있는 배경 이미지(sky.jpg)를 사용합니다.
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 웹 소켓 초기화

프론트엔드 코드에서:

  1. 먼저 원래의 initGame 함수 호출을 삭제하고 서버로부터 ready 응답을 받은 후 보드를 초기화합니다.
  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 서버 개발

Websocket 요청을 처리하는 GameController 클래스 생성

@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. 먼저 사용자의 로그인 상태를 감지해야 하며 세션에서 현재 사용자 정보를 가져옵니다.
  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 주석을 사용할 수 없습니다. 컨텍스트를 사용해야 합니다.

시작 클래스 수정

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 제출 요청 처리

핸들텍스트메시지 구현

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

рекомендация

отblog.csdn.net/chenbaifan/article/details/126527270
рекомендация