SSM项目 —— 在线五子棋

前言:

     此项目为 ssm 项目、基于 springBoot、SpringMVC、MyBatis、websocket、html、css、js、ajax……等技术实现,如果大家有好的项目改进方法 or 附加功能,请在下方随时留言


目录


项目概览:

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

在这里插入图片描述

在这里插入图片描述


1、创建项目

在这里插入图片描述


2、用户模板

2.1、建立用户表:
create database if not exists java_gobang;

use java_gobang

drop table if not exists user;
create table user(
    userId int primary key auto_increment,
    username varchar(50) unique,
    password varchar(50),
    score int,
    totalCount int,
    winCount int
);

insert into user values(null,'zhangsan','123',1000,0,0);
insert into user values(null,'lisi','123',1000,0,0);
insert into user values(null,'wangwu','123',1000,0,0);

2.2、创建 User 实体类

注意:实体类属性名和数据库中字段名保持一致

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

2.3、在 UserMapper 中定义抽象方法、在对应 xml 文件中具体实现操作数据库语句

注意:此接口一定要使用 Mapper 注解进行修饰,这样才是 mybatis 中的定义抽象方法的接口

@Mapper
public interface UserMapper {
    //注册
    int insert(User user);
    //登录
    User selectByName(String username);
}

对应 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>
</mapper>

2.4、约定前后端交互接口:(登录功能、注册功能、查询当前登录用户的信息)
①、登录:

在这里插入图片描述

②、注册:

在这里插入图片描述

③、随时获取到用户信息:(这是是用来在游戏大厅页去展示当前登录用户的信息)

在这里插入图片描述


2.5、实现登录、注册、查询用户信息的服务器

      由于服务器要给客户端返回响应,这里通过一个类来封装响应,并且封装 session 中 key 的值、直接调用以免拼写错误:

@Data
public class ResponseBodyMessage<T> {
    private int status;
    private String message;
    private T data;

    public ResponseBodyMessage(int status, String message, T data) {
        this.status = status;
        this.message = message;
        this.data = data;
    }
}

public class Constant {
    public final static String USERINFO_SESSION_KEY = "USERINFO_SESSION_KEY";
}

因为这三个功能是需要使用 usermapper 这个类来操作数据库的,所以提前属性注入:

@Resource
private UserMapper userMapper;

Ⅰ、注册功能:这里使用到 bcrypt 对密码进行加密

①、首先引入 bcrypt 加密所需要的依赖:spring 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>

②、在启动类中添加代码:上面引入的依赖是 spring security 整个框架,对于 bcrypt 加密只需要使用到此框架中的一个类

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

③、提前引入 bcrypt 加密的类

BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();

④、具体实现:

public ResponseBodyMessage<User> userRegister(@RequestParam String username, @RequestParam String password){
        User user = userMapper.selectByName(username);

        if(user == null){
            User user1 = new User();
            user1.setUsername(username);
            String newPassword = bCryptPasswordEncoder.encode(password);
            user1.setPassword(newPassword);

            int ret = userMapper.insert(user1);

            if(ret == 1){
                user1.setPassword(null);
                return new ResponseBodyMessage<>(0,"注册成功",user1);
            }else{
                return new ResponseBodyMessage<>(-1,"注册失败",null);
            }
        }else{
            return new ResponseBodyMessage<>(-1,"当前用户存在",null);
        }
    }

    注意:bcrypt 将密码加密为 十六进制长度为 60 的字符串、由于其内部实现了随机加盐的操作,所以每次相同的密码都会加密为不同的密文、由于长度增加和随加盐的加入、使 bcrypt 破解所需要的时间更长、相对 MD5 加密更安全

Ⅱ、登录功能:

public ResponseBodyMessage<User> userLogin(HttpServletRequest request, @RequestParam String username, @RequestParam String password){
        User user = userMapper.selectByName(username);
        HttpSession session = request.getSession(true);
        if(user != null){
            String userPassword = user.getPassword();
            boolean flg = bCryptPasswordEncoder.matches(password,userPassword);

            System.out.println(userPassword);
            if(flg){
                session.setAttribute(Constant.USERINFO_SESSION_KEY,user);
                user.setPassword(null);
                return new ResponseBodyMessage<>(0,"登录成功",user);
            }else{
                return new ResponseBodyMessage<>(-1,"登录失败",null);
            }

        }else{
            return new ResponseBodyMessage<>(-1,"当前用户不存在",null);
        }
    }

    注意:此代码中使用到 加密类 match 方法,这里第一个参数是明文,第二个参事是密文,根据比较的 boolean 值来进行登录的校验、如果成功登录将此用户信息存入到 session 会话中

Ⅲ、查询当前用户信息功能:刚刚登录存人服务器的 session 取到获取用户信息即可

public ResponseBodyMessage<User> getUserInfo(HttpServletRequest request){
        HttpSession session = request.getSession(false);
        if(session == null || session.getAttribute(Constant.USERINFO_SESSION_KEY) == null){
            return new ResponseBodyMessage<>(-1,"当前尚未登录",null);
        }
        User user = (User) session.getAttribute(Constant.USERINFO_SESSION_KEY);

        return new ResponseBodyMessage<>(0,"成功获取用户信息",user);
    }

2.6、前端代码

注意:对于具体样式自己随意设计、这里只展示 js 代码:

<script>
	$(function(){
		$("#submit").click(function(){
			var username=$("#user").val();
			var password=$("#password").val();
			if(username == "" || password == ""){
				alert("用户名或密码为空");
				return;
			}
			$.ajax({
				url:"/login",//指定路径
				data:{"username":username,"password":password},
				type:"POST",
				dataType:"json",//服务器返回数据为json
				success:function (data) {
					console.log(data);
					if(data.status==0){
						alert("登录成功!");
						window.location.href="/index.html";
					}else{
						alert("用户名密码错误");
						$("#user").val("");
						$("#password").val("");
					}
				}
			})
		})
	})

	$(function(){
		$("#rsb").click(function(){
			var username = $("#newUsername").val();
			var password = $("#newPassword").val();
			var repassword = $("#repassword").val();
			var lenp = password.length;

			if(password != repassword){
				alert("两次输入的密码不一致");
				$("#newPassword").val("");
				$("#repassword").val("");
				return;
			}

			if(lenp < 6){
				alert("密码长度小于6位不安全,请设置大于6位的密码");
				$("#newPassword").val("");
				$("#repassword").val("");
				return;
			}
				
			
				$.ajax({
				url:"/register",
				type:"post",
				data:{"username":username,"password":password},
				dataType:"json",

				success:function(val){
					console.log(val);
					if(val.status == 0){
						alert("注册成功,请进行登录");
						window.location.href="login.html";
					}else{
						alert("注册失败");
						$("#newUsername").val("");
						$("#newPassword").val("");
						$("#repassword").val("");
						return;
					}
				}
			});
		})
	})

       实现流程:有登录和注册的按钮,点击按钮去给服务器发送输入框的内容、成功获取到响应后再通过响应的内容去具体判断后续操作、是跳转还是错误的 alert


3、匹配功能

先了解:消息推送机制 ——> 服务器要去联系客户端

在这里插入图片描述

      这里使用 websocket 传输数据是因为:我们通常前后端传输数据是通过 HTTP 的,这时候就存在一个问题,匹配机制中是需要两个玩家的,如果一个客户端发送请求后,HTTP 的做法是客户端不断的向服务器发送请求,去询问服务器是否匹配成功,这里请求中有大半是无用,浪费带宽和服务器资源。所以这里基于 websocket 实现,这里就可以在客户端发送请求后,待第二个客户端连接来后,服务器主动告知客户端匹配成功,这样就可以避免大部分无用请求


3.1、约定匹配功能的前后端交互接口:

(基于 websocket 实现),此处设计为通过 websocket 传输 json 格式的文本格式

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

       匹配流程:客户端点击开始匹配按钮,此时触发点击事件向后端服务器发送请求。服务器在接收到客户端的请求后会想给一个响应代表服务器这边收到了客户端发来的请求,后再服务器再等待第二个客户端发来请求,此时具备两个玩家再经过一系列匹配机制后,如果二者可以匹配,服务器再返回一个响应,告知客户端匹配成功。


3.2、实现前端发送请求

前端样式随意设计,这里只展示核心代码:js 代码

Ⅰ、先初始化 websocket,websocketURL 是前端这里请求发送的路径,并且初始化几个 websocket 操作的回调函数

//初始化 websocket
    //此处的路径必须写作 /findMatch
    let websocketUrl = 'ws://' + location.host + '/findMatch';
    let websocket = new WebSocket(websocketUrl);

    websocket.onopen = function(){
      console.log("onopen");
    }

    websocket.onerror = function(){
      console.log("onerror");
    }

    //页面关闭之前手动调用 close 方法
    window.onbeforeunload = function(){
      websocket.close();
    }

    websocket.onclose = function(){
      console.log("onclose");
    }
//处理服务器返回的响应
    websocket.onmessage = function(e){
    //这里先忽略处理请求的逻辑
     console.log(e)
    }

Ⅱ、通过页面中的 button 按钮,来触发点击事件,如果按钮中内容是 “开始匹配”,那么构造 JSON 字符串发送给服务器,发送内容为:‘startMatch’,如果按钮中内容是:“匹配中……点击停止”,那么构造 JSON 字符串发送给服务器,发送内容为:‘stopMatch’

let button = document.querySelector("#button");
    button.onclick = function(){
      if(websocket.readyState == websocket.OPEN){
          //如果当前的为 open 状态 连接成功
          //这里发送的数据有两种可能,开始或者停止匹配

          if(button.innerHTML == '开始匹配'){
            console.log("开始匹配");
            websocket.send(JSON.stringify({
                message:'startMatch',
              }));
          }else if(button.innerHTML == '匹配中……点击停止'){
            console.log("停止匹配");
            websocket.send(JSON.stringify({
              message:'stopMatch',
            }));
          }
        }else{
          //连接当前是异常的情况
          alert("当前您的连接已经断开,请重新登录");
          window.location.href = "login.html";
        }
      }

Ⅲ、实现具体的处理服务器响应的代码:

      这里返回的是 JSON 字符串,需要转换为 js 对象,获取到返回内容后,进行解析,如果返回的是 startMatch 证明服务器收到 开始匹配 的请求,修改按钮中的内容为 “匹配中……点击停止” ,后面一致,进行请求具体内容的判断,最后如果返回的是 MatchSuccess 证明匹配成功,跳转到 “游戏房间页面”

websocket.onmessage = function(e){
      //处理响应
      let resp = JSON.parse(e.data);
      let button = document.querySelector("#button");
      if(!resp.ok){
        console.log("接收到失败响应" + resp.reason);
        return;
      }

      if(resp.message == 'startMatch'){
        //开始匹配发送成功
        console.log("进入匹配队列");
        button.innerHTML = '匹配中……点击停止';
      }else if(resp.message == 'stopMatch'){
        //结束匹配发送成功
        console.log("离开匹配队列");
        button.innerHTML = '开始匹配';
      }else if(resp.message == 'MatchSuccess'){
        //已经匹配到对手
        console.log("匹配到对手跳转到房间");
        window.location.href = "room.html";
      }else{
        console.log("收到了非法响应" + resp.message);
      }
    }

3.3、实现服务器处理请求、返回响应

       实现服务器大致需要四个类,分别用来:处理 websocket 请求、维护用户登录状态、实现匹配算法、让 spring 识别这个配置 socket 类,在合适的时机进行调用,将这个类放入到 spring 中:(注解:@EnableWebSocket 让spring 框架认识这个类是配置 socket 类 进一步找到 **API 这个类)

Ⅰ、创建 WebsocketConfig 类将 处理 websocket 请求的类注册到 spring 中,并且可以在合适的时机调用

先进行属性注入,处理 websocket 的类(MatchAPI)后面具体实现

@Autowired
    private MatchAPI matchAPI;

将 MatchAPI 注册到 spring 中,并绑定路径(前端通过此路径来发送请求),最后把 HttpSession(登录时存储到服务器的 session)搞过来,类似于借来使用的意思,此时的 session 是 HttpSessionHandshakeInterceptor() 层级会话

@Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry webSocketHandlerRegistry) {
        webSocketHandlerRegistry.addHandler(matchAPI,"/findMatch")
                .addInterceptors(new HttpSessionHandshakeInterceptor());
        //带上前面准备好的 httpsession 搞过来、HttpSessionHandshakeInterceptor()层级的会话

    }

在这个类上添加注解,让 spring 知道这是一个配置 socket 类,从而具体去找到 MatchAPI (处理 websocket 请求)这个类,前提是这个类也已经被注入了 spring 中

@Configuration
@EnableWebSocket

Ⅱ、定义 OnlineUserMananger 类来管理用户的登录状态:

  1. 这个类采取 哈希表来实现,其键值分别为:用户 Id,该用户对于的 session ,可以管理用户的加入(add 方法)和用户退出(remove 方法),还可以通过 userId 去获取到对应用户的 session(哈希表的 get 方法)
  2. 由于同时发来请求的客户端请求会用多个,并发情况下普通的 哈希表 会发生线程安全的问题,所以这里采用的是 线程安全 的 ConcurrentHashMap
  3. 具体实现比较简单,就是哈希表插入删除查找元素的基本操作。
@Component
public class OnlineUserManager {
    //当前用户在游戏大厅的状态
    private ConcurrentHashMap<Integer, WebSocketSession> gameHall = new ConcurrentHashMap<>();

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

    public void exitGameHall(int userId){
        gameHall.remove(userId);
    }

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

Ⅲ、定义 Matcher 类来实现匹配算法:

  1. 在数据库表中每个 user 带有一个 socre 字段,此字段为当前用户的积分,根据此积分把用户等级分为三个阶段。
  2. 同时定义三个队列(对应三个积分阶段)来存放用户(注意:这里队列需要实现的是用户的存放和用户的删除,这里同时操作一个队列的线程可能有多个,所以需要注意线程安全问题,在对应方法使用 synchronized 对方法加锁,当一个线程调用方法完毕释放锁,其他线程才能获取到锁进而进行add / remove 操作)
  3. 同时使用三个线程去扫描每个队列(三个线程扫描不同的队列,互不干扰),直到扫描到队列中有两个用户(注意:这里存在 “忙等” 问题,匹配时需要两个用户,当用户数量小于 2 时线程会来回的反复横跳【不断进出线程】,这样会就会感觉时间过去了但什么也没干,这是浪费系统资源的,所以这里在 用户数量判断小于 2 时,加入 wait 阻塞等待,当队列 add 时 notify 唤醒再次检查是否同一队列中存在两个以上用户),如果还是 2 个 以下继续进行阻塞等待。
  4. 当用户数大于 2 取出队首的两个用户,通过 session 二次判断(这里会在 MatchAPI 中进行检查,所以这里是二次,保证程序更加安全)是否此二者都是在线的,如果二者都是在线的,把这两个用户放入到同一房间内,这里需要单独实现一个房间的实体类,和房间的管理

①、房间的实体类:

@Data
public class Room {
    //使用字符串 方便生成唯一值
    private String roomId;

    //两个玩家
    private User user1;
    private User user2;

    //使用 UUID 管理房间号 唯一身份标识 一组 16进制 表示的数字 两次调用此算法 生成的字符串都是不相同的
    public Room() {
        roomId = UUID.randomUUID().toString();
    }
}

②、房间管理:

  1. 同样有可能多个线程去操作房间管理(多个队列中成功匹配),所以需要注意线程安全问题
  2. 所以同样采用线程安全的 ConcurrentHashMap ,维护两张哈希表,可以实现房间号和房间的映射和用户 id 和房间号的映射
public class RoomService {
    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 getRoomById(String roomId){
        return rooms.get(roomId);
    }

    public Room getRoomIdBYUserId(int userId){
        String roomId = userIdToRoomId.get(userId);
        if(roomId == null){
            return null;
        }

        return rooms.get(roomId);
    }
}

Ⅳ、具体匹配算法的实现:分别依据分数构造三个队列、 每次有玩家进来匹配,依据玩家的分数将玩家入到不同的队列中,同时创建三个线程去循环扫描三个队列,(此时只有一个玩家加入时,此次线程会反复横跳,不断的退出进入队列,但是这样的操作其实是无效的,此处被称为忙等,所以采取 wait 和 notify 组合方式,当人数 < 2 时,线程阻塞等待,当有其他用户连接进来唤醒线程,判断当前用户人数是否大于 2 ,依据此数值,决定线程是否继续阻塞。此时在向队列中加入、删除玩家的操作可能是多个玩家进行同时的操作,所以会发生线程安全问题,这里针对 添加 和 删除操作进行上锁,一个玩家操作完释放锁后其他玩家才能进行操作)当有两个玩家时,就证明匹配成功,期间需要检测一些特殊情况,多开以及下线问题,如果二者匹配成功,就创建新的房间把两个玩家放入房间中。最后构造响应,返回给客户端告知其 MatchSuccess ——> 匹配成功

@Component
public class Matcher {
    private Queue<User> normalQueue = new LinkedList<>();//低于 2000 分
    private Queue<User> highQueue = new LinkedList<>();
    private Queue<User> veryHighQueue = new LinkedList<>();

    @Autowired
    private OnlineUserManager onlineUserManager;

    private ObjectMapper objectMapper = new ObjectMapper();

    @Autowired
    private RoomService roomService;

    //操作匹配队列的方法
    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(){
            @SneakyThrows
            @Override
            public void run() {
                //扫描 normalQueue
                while (true){
                    handlerMatch(normalQueue);
                }
            }
        };

        t1.start();

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

        t2.start();

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

        t3.start();
    }

    private void handlerMatch(Queue<User> matchQueue) throws IOException, InterruptedException {
        //队列里没有两个人 直接退出本轮扫描
        synchronized (matchQueue){
            while (matchQueue.size() < 2){
                matchQueue.wait();
            }

            //取出两个玩家
            User player1 = matchQueue.poll();
            User player2 = matchQueue.poll();

            System.out.println("匹配出两个玩家" + player1.getUsername() + player2.getUsername());

            //获取到玩家的 websocket 会话 为了告诉玩家你排到了
            WebSocketSession session1 = onlineUserManager.getFromGameHall(player1.getUserId());
            WebSocketSession session2 = onlineUserManager.getFromGameHall(player2.getUserId());

            //理论上匹配队列中的玩家一定是在线的
            if(session1 == null){
                //把玩家 2 重新放回匹配队列
                matchQueue.offer(player2);
                return;
            }

            //如果玩家 2 下线了 把 1 重新放回去
            if(session2 == null){
                matchQueue.offer(player1);
                return;
            }

            //排到的是一个人 但是理论上是不存在的 双开被禁止了 双重判定
            if(session1 == session2){
                //把其中的一个玩家放到匹配队列中
                matchQueue.offer(player1);
                return;
            }

            //把玩家放到一个房间里
            Room room = new Room();
            roomService.add(room,player1.getUserId(),player2.getUserId());

            //通过 websocket 返回一个 message 为 匹配成功 信息

            MatchResponse response1 = new MatchResponse();
            response1.setOk(true);
            response1.setMessage("MatchSuccess");

            session1.sendMessage(new TextMessage(objectMapper.writeValueAsString(response1)));

            MatchResponse response2 = new MatchResponse();
            response2.setOk(true);
            response2.setMessage("MatchSuccess");

            session2.sendMessage(new TextMessage(objectMapper.writeValueAsString(response2)));
        }
    }
}

Ⅳ、具体实现前端发来的 websocket 请求:

  1. 该类需要继承 TextWebSocketHandler 类,并重写四个方法:
  2. 四个方法分别代表的是:连接建立之后、处理请求返回响应、处理异常、连接关闭之后
  3. 连接之后需要做的是:当用客户端发来请求,检测当前用户是否登录过,没登录,返回尚未登陆信息。登录,检测当前用户是否存在于 在线管理类 中的哈希表中(也就是看看当前用户是不是在线,我们要在这里禁止多开,一个账户只能开一个客户端,再有同账号登录会返回端开连接跳转后登录页面),如果一切正常,那么打印日志显示当前用户进入游戏大厅
  4. 处理请求返回响应:拿到前端发送来的 json 字符串,转换为 Java 中的请求类(这里使用单独的类来封装响应和请求),拿到请求中的信息,比对具体信息是什么,再给前端返回不同的信息,来告知后端接收到了前端发送来的请求
  5. 处理异常:先检查登录,正确登录后,拿到在线管理类中 哈希表中 对应 userId 的 session 值观察和当前类自带的 session 是否一致,如果一致证明退出和登录是一个人,这样正常执行退出操作
  6. 连接关闭之后:和处理异常代码一致
  7. 这里采用两个单独的类来表示匹配功能的请求和响应,这里请求和响应的格式严格按照上面约定的前后端交互方式进行定义

①、匹配请求:

@Data
public class MatchRequest {
    private String message = "";
}

②、匹配响应:

@Data
public class MatchResponse {
    private boolean ok;
    private String reason;
    private String message;
}

③、具体实现:

@Component
public class MatchAPI extends TextWebSocketHandler {
    //维护用户状态、目的是在代码中比较方便的获取到某个用户当前的 websocket 会话从而可以通过这个会话给这个客户端发送消息
    //同时也可以感知他 在线/离线 状态 使用哈希表保存用户的在线状态 key 用户 id value 是当前用户使用的会话
    private ObjectMapper objectMapper = new ObjectMapper();

    @Autowired
    private OnlineUserManager onlineUserManager;

    @Autowired
    private Matcher matcher;

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        //连接之后 代表用户上线

        //1、先获取到 用户信息 是谁上线了
        //可以使用到 session 全靠注册 websocket 时候加上的 .addInterceptors(new HttpSessionHandshakeInterceptor());
        //这个逻辑 把 httpsession 中的 session 全部都拿到了 WebSocketSession 中
        //此处的用户可能为空 用户直接通过 url 来访问
        try{
            User user = (User) session.getAttributes().get(Constant.USERINFO_SESSION_KEY);
            //先判断当前用户是否已经登录过
            WebSocketSession socketSession = onlineUserManager.getFromGameHall(user.getUserId());

            if(socketSession != null){
                //当前用户不为空 已经登录 需要告知客户端重复登录
                MatchResponse matchResponse = new MatchResponse();
                matchResponse.setOk(false);
                matchResponse.setReason("禁止多开");

                session.sendMessage(new TextMessage(objectMapper.writeValueAsString(matchResponse)));
                session.close();
                return;
            }
            //2、拿到了身份信息之后,就可以把玩家信息设置为在线状态
            onlineUserManager.enterGameHall(user.getUserId(),session);
            System.out.println(user.getUsername() + "进入游戏大厅");
        }catch (NullPointerException e){
            e.printStackTrace();
            //出现空指针异常 当前用户身份信息未登录
            MatchResponse matchResponse = new MatchResponse();
            matchResponse.setOk(false);
            matchResponse.setReason("尚未登录");
            //先转换为 json 字符串 再包装上一层TextMessage进行传输 文本格式 websocket 数据报
            session.sendMessage(new TextMessage(objectMapper.writeValueAsString(matchResponse)));
        }
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        //处理匹配和停止匹配信息

        User user = (User) session.getAttributes().get(Constant.USERINFO_SESSION_KEY);

        //获取到客户端发送给服务器的数据
        String playLoad = message.getPayload();

        MatchRequest request = objectMapper.readValue(playLoad, MatchRequest.class);

        MatchResponse matchResponse = new MatchResponse();

        if(request.getMessage().equals("startMatch")){
            //创建匹配队列
            matcher.add(user);
            matchResponse.setOk(true);
            matchResponse.setMessage("startMatch");
        }else if(request.getMessage().equals("stopMatch")){
            //退出匹配队列
            matcher.remove(user);
            matchResponse.setOk(true);
            matchResponse.setMessage("stopMatch");
        }else{
            //非法情况
            matcher.remove(user);
            matchResponse.setOk(false);
            matchResponse.setReason("非法的匹配");
        }

        session.sendMessage(new TextMessage(objectMapper.writeValueAsString(matchResponse)));
    }

    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        //发生异常 代表用户下线

        try{
            User user = (User) session.getAttributes().get(Constant.USERINFO_SESSION_KEY);

            WebSocketSession socketSession = onlineUserManager.getFromGameHall(user.getUserId());
            if(socketSession == session){
                //退出和登录的是一个人
                onlineUserManager.exitGameHall(user.getUserId());
                System.out.println(user.getUsername() + "退出游戏大厅");
            }

            //如果玩家正在匹配中 连接断开了 就应该移除匹配
            matcher.remove(user);
        }catch (NullPointerException e){
            e.printStackTrace();
            //出现空指针异常 当前用户身份信息未登录
            MatchResponse matchResponse = new MatchResponse();
            matchResponse.setOk(false);
            matchResponse.setReason("尚未登录");
            //先转换为 json 字符串 再包装上一层TextMessage进行传输 文本格式 websocket 数据报
            session.sendMessage(new TextMessage(objectMapper.writeValueAsString(matchResponse)));
        }
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        //连接关闭之后 代表用户下线
        try{
            User user = (User) session.getAttributes().get(Constant.USERINFO_SESSION_KEY);
            WebSocketSession socketSession = onlineUserManager.getFromGameHall(user.getUserId());
            if(socketSession == session){
                //退出和登录的是一个人
                onlineUserManager.exitGameHall(user.getUserId());
                System.out.println(user.getUsername() + "退出游戏大厅");
            }
            //如果玩家正在匹配中 连接断开了 就应该移除匹配
            matcher.remove(user);
        }catch (NullPointerException e){
            e.printStackTrace();
            //出现空指针异常 当前用户身份信息未登录
            MatchResponse matchResponse = new MatchResponse();
            matchResponse.setOk(false);
            matchResponse.setReason("尚未登录");
            //先转换为 json 字符串 再包装上一层TextMessage进行传输 文本格式 websocket 数据报
            session.sendMessage(new TextMessage(objectMapper.writeValueAsString(matchResponse)));
        }
    }
}

3.4、图解前后端交互

在这里插入图片描述


4、对战功能

4.1、前后端交互接口:

       Ⅰ、对战功能是在游戏房间页中实现的,在匹配功能中,在两个玩家匹配成功后,会将两个玩家都移除匹配队列,并且关闭 websocket 连接,所以这里的游戏房间页需要重新建立 websocket 连接,请求这里不做规定,只去规定响应,服务器去返回一些游戏的初始信息

在这里插入图片描述
针对这里的连接响应,这里采用单独的类封装响应:按照上面的约定去定义属性

@Data
public class GameReadyResponse {
    private String message;
    private boolean ok;
    private String reason;
    private String roomId;
    private int thisUserId;
    private int thatUserId;
    private int isWhite;
}

Ⅱ、针对落子功能,需要规定请求和响应,客户端向服务器发送当前用户的 id 和 落子的行和列,服务器同样去返回落子的行和列,以及输赢的判定(winner 为哪个玩家的 用户 id 哪个玩家就赢了)
在这里插入图片描述
针对这里落子响应和请求依旧使用单独的类来进行表示:

①、落子请求:

@Data
public class GameRequest {
    private String message;
    private int userId;
    private int row;
    private int col;
}

②、落子响应

@Data
public class GameResponse {
    private String message;
    private int userId;
    private int row;
    private int col;
    private int winner;
}

4.2、实现游戏房间页面:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>游戏房间</title>
    <link rel="stylesheet" href="css/common.css">
    <link rel="stylesheet" href="css/game_room.css">
</head>
<body>
    <div class="nav">五子棋对战</div>
    <div class="container">
        <div>
            <!-- 棋盘区域, 需要基于 canvas 进行实现 -->
            <canvas id="chess" width="450px" height="450px">

            </canvas>
            <!-- 显示区域 -->
            <div id="screen" style="border-radius: 0.5em;"> 等待玩家连接中... </div>
        </div>
    </div>
    <script src="js/script.js"></script>

    <style>
        button{
            margin-left: 170px;
            margin-top: 40px;
            width: 100px;
            height: 50px;
            border-radius: 0.5em;
            border: none;
        }
    </style>
</body>
</html>

4.3、实现棋盘棋子的绘制:

这里采用 一段 js 代码实现:

  • 1、gameInfo用于去接受服务器返回连接响应信息的一个类
  • 2、setScreenText用于提示双方落子的次序
  • 3、initGame用于初始化游戏背景……,初始化棋盘大小,大小为 15 * 15
  • 4、oneStep用于绘制棋子
  • 5、最后通过点击事件,计算出用户点击位置,用户所用棋子颜色,传给 oneStep 函数在对应位置画出对应颜色的棋子
let 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();


4.4、实现客户端连接游戏房间
  • 1、在这步需要做的是完成客户端 websocket 的初始化
  • 2、设置 websocket 传输数据的路径
  • 3、前三个回调函数:分别处理正常开启 websocket 、关闭 websocket 连接、和异常的情况
  • 4、第四个回调函数是在关闭前手动调用 websocket 的关闭函数
  • 5、最后一个回调函数是处理接受到的客户端响应的,处理流程为:先将 json 数据转换为 js 对象,判断响应数据中的 ok 属性,如果因为异常或者其他原因导致未成功连接到服务器,ok 属性会被设置为 false 取反后代表如果连接失败,就回退到游戏大厅页面
  • 6、再去判断响应中的 ,message 信息具体是什么,如果服务器返回的是 “gameReady”(准备就绪),就将响应中的数据依次赋值到 js 对象中。如果返回的是 “repeatConnection” 证明在游戏房间页检测到了账号的多开,直接跳转到登录页面,重写进行登录
let websocketUrl = 'ws://' + location.host + '/game';
let websocket = new WebSocket(websocketUrl);

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

websocket.onclose = function(){
    console.log("和游戏服务器断开连接");
}

websocket.onerror = function(){
    console.log("和服务器的连接出现异常");
}

window.onbeforeunload = function(){
    websocket.close();
}

websocket.onmessage = function(e){
    console.log("[handlerGameReady]" + e.data);
    let resp = JSON.parse(e.data);

    if(!resp.ok){
        alert("连接游戏失败! reason:"+resp.reason);
        //出现连接失败的情况 回到游戏大厅页面
        location.replace("index.html");
        return;
    }

    if(resp.message == 'gameReady'){
        gameInfo.roomId = resp.roomId;
        gameInfo.thisUserId = resp.thisUserId;
        gameInfo.thatUserId = resp.thatUserId;
        gameInfo.isWhite = (resp.isWhite == resp.thisUserId);
        
        initGame();
        //设置显示区域内容
        setScreenText(gameInfo.isWhite);

    }else if(resp.message == 'repeatConnection'){
        alert("当前检测到多开,请使用其他账号登录");
        location.replace("gblogin.html");
    }
}

4.5、服务器实现连接游戏房间
这里使用一个类来处理 前端 发送来的 websocket 请求
  • 1、创建一个名为 GameAPI 的类并继承 TextWebSocketHandler 这个类,并将这个类注册到 spring 中,同时将 GameAPI 放入到配置 socket 的类中并带上 HttpSession 中的所有 session 信息
  • 2、重写 TextWebSocketHandler 的四个方法:连接建立后、处理请求、连接异常、连接关闭后
  • 3、实现连接建立后的逻辑:
    • 3.1、先获取到存储用户信息的 session,从中取出 user 用户,检测这个 user 用户是不是空,如果是空,证明当前没有登录,构造响应只需要初始化一个 ok 属性和 reason 属性(告知客户端当前尚未登录)
    • 3.2、判定当前用户是否进入到房间中 使用房间管理类来进行查询,之前在房间管理类中实现了通过用户 id 去查询房间对象的方法,通过此方法获取到当前用户对应的房间对象,如果此房间对象为空,则证明当前用户还没有匹配到队手,返回前端响应提示当前用户没有匹配到队手
    • 3.3、在线管理类中添加一个方法去检测用户在游戏房间页是否为登录状态,维护一张哈希表实现用户 id 和 其 session 的映射,通过 用户 id 去查询到 对应的 websession(看看当前用户 id 是不是存在于这张哈希表中),如果存在证明当前客户端已经在线了(多开了)返回前端提示当前用户多开
    • 3.4、哈希表没有这个 user 的 id 就把当前用户设置上线状态(将当前用户的 id 加入哈希表中)
    • 3.5、通过上面拿到的 room 去通过 get 方法拿到房间中的用户,如果第一个用户为空,就把当前用户设置为空,设置先进房间的用户是先手白棋,如果通过 get 方法发现第二个用户为空,就把当前用户设置为第二个用户。同时这里需要注意线程安全问题,不同用户在不同房间进行操作不存在线程安全问题,但是在同一房间就是存在问题的,两个用户加入到房间完全有可能是并发的,所以这里需要针对 room 对象上锁,一个用户操作过后,另一个才能继续操作
    • 3.6、第二个用户被加入后,房间已满,分别提示给两个客户端(单独一个方式实现通知的内容,即设置响应的内容),通知客户端 “gameReady” 并初始化其他属性 返回给客户端
    • 3.7、处理客户端的 websocket 请求(这里处理的就是 客户端 的落子请求),单独使用一个 putChess 方法实现
    • 3.8、连接关闭 and 连接异常 先比对一下当前用户的 session 会话 和 在线管理类中哈希表维护的房间页的在线状态的 session 是不是一样的(就是去比对是不是一个人)是一个人后,将此 session 中从哈希表移除
    • 3.9、这里规定当前用户如果退出,其对应的队手直接获胜,通过一个单独的方法通知前端游戏已经分出胜负,把响应的 winner 属性直接赋值为当前掉线用户的队手,最后销毁此游戏房间
    • 3.10、在 usermapper 中添加两个方法、更新失败方、胜利方的总场次、分数、获胜场次,这里由于浏览器会通过栈的方式存储刚刚访问的页面,当完成一局游戏后退出到游戏大厅页,此时由于缓存,不会改变数值,所以这里的前端跳转统一使用:location.replace(" ");
package com.example.java_gobang.api;

import com.example.java_gobang.game.OnlineUserManager;
import com.example.java_gobang.game.Room;
import com.example.java_gobang.mapper.UserMapper;
import com.example.java_gobang.model.User;
import com.example.java_gobang.service.RoomService;
import com.example.java_gobang.tools.Constant;
import com.example.java_gobang.tools.GameReadyResponse;
import com.example.java_gobang.tools.GameResponse;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;

import java.io.IOException;

/**
 * Created with IntelliJ IDEA.
 * Description:
 * User: Lenovo
 * Date: 2022-08-17
 * Time: 15:59
 */
@Component
public class GameAPI extends TextWebSocketHandler {
    private ObjectMapper objectMapper = new ObjectMapper();

    @Autowired
    private RoomService roomService;

    @Autowired
    private OnlineUserManager onlineUserManager;

    @Autowired
    private UserMapper userMapper;

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        GameReadyResponse gameReadyResponse = new GameReadyResponse();
        //1、获取道用户的身份信息 httpsession

        User user = (User) session.getAttributes().get(Constant.USERINFO_SESSION_KEY);
        if(user == null){
            gameReadyResponse.setOk(false);
            gameReadyResponse.setReason("用户尚未登录");
            session.sendMessage(new TextMessage(objectMapper.writeValueAsString(gameReadyResponse)));
            return;
        }

        //2.判定当前用户是否已经进入房间 房间管理器进行查询
        Room room = roomService.getRoomBYUserId(user.getUserId());
        if(room == null){
            //如果为空 当前没有找到对应房间 该玩家还没有匹配道
            gameReadyResponse.setOk(false);
            gameReadyResponse.setReason("当前用户尚未匹配到");
            session.sendMessage(new TextMessage(objectMapper.writeValueAsString(gameReadyResponse)));
            return;
        }

        //3.判定当前用户是不是多开 该用户是否在其他地方进入游戏
        //通过 onlineMananger 如果一个账号 一边是在游戏大厅 一边是在游戏房间

        if(onlineUserManager.getFromGameHall(user.getUserId()) != null
                || onlineUserManager.getFromGameRoom(user.getUserId()) != null){
            gameReadyResponse.setOk(true);
            gameReadyResponse.setReason("禁止游戏多开");
            gameReadyResponse.setMessage("repeatConnection");
            session.sendMessage(new TextMessage(objectMapper.writeValueAsString(gameReadyResponse)));
            return;
        }

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

        //5.把两个玩家加入房间中
        //执行到当前逻辑证明页面跳转成功
        //两个客户端并发的连入服务器
        synchronized (room){
            if(room.getUser1() == null){
                //第一个玩家尚未加入房间
                //就把当前连上的 websocket 玩家作为 user 加入到房间中
                room.setUser1(user);
                //先进入房间的是先手
                room.setWhiteUser(user.getUserId());
                System.out.println("玩家" + user.getUsername() + "已经准备就绪");

                return;
            }

            if(room.getUser2() == null){
                //玩家1已经加入房间 把当前玩家当作 玩家2
                room.setUser2(user);
                System.out.println("玩家" + user.getUsername() + "已经准备就绪");

                //当两个玩家都加入成功后 服务器给这两个玩家 返回 websocket 响应数据通知这两个玩家游戏双方都已经准备好了

                //通知玩家1
                noticeGameReady(room,room.getUser1(),room.getUser2());

                //通知玩家2
                noticeGameReady(room,room.getUser2(),room.getUser1());

                return;
            }
        }

        //6.如果又有玩家尝试连接同一个房间 提示出错 这种情况理论上是不存在的 为了让程序更加健壮
        gameReadyResponse.setOk(false);
        gameReadyResponse.setReason("当前房间已满,您不能加入");

        session.sendMessage(new TextMessage(objectMapper.writeValueAsString(gameReadyResponse)));
    }

    private void noticeGameReady(Room room, User thisUser, User thatUser) throws IOException {
        GameReadyResponse gameReadyResponse = new GameReadyResponse();
        gameReadyResponse.setOk(true);
        gameReadyResponse.setReason("");
        gameReadyResponse.setMessage("gameReady");
        gameReadyResponse.setRoomId(room.getRoomId());
        gameReadyResponse.setThisUserId(thisUser.getUserId());
        gameReadyResponse.setThatUserId(thatUser.getUserId());
        gameReadyResponse.setIsWhite(room.getWhiteUser());

        WebSocketSession socketSession = onlineUserManager.getFromGameRoom(thisUser.getUserId());
        socketSession.sendMessage(new TextMessage(objectMapper.writeValueAsString(gameReadyResponse)));
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        User user = (User) session.getAttributes().get(Constant.USERINFO_SESSION_KEY);

        if(user == null){
            System.out.println("当前玩家尚未登录");
            return;
        }

        //根据玩家 id 获取到房间 对象
        Room room = roomService.getRoomBYUserId(user.getUserId());
        room.putChess(message.getPayload());
    }

    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        User user = (User) session.getAttributes().get(Constant.USERINFO_SESSION_KEY);

        if(user == null){
            return;
        }

        WebSocketSession exitSession = onlineUserManager.getFromGameRoom(user.getUserId());
        //避免在多开后 第二个退出登录 第一个会话被删除
        if(exitSession == session){
            onlineUserManager.exitGameRoom(user.getUserId());
        }
        System.out.println("当前用户" + user.getUsername() + "游戏房间连接异常");
        noticeThatUserWin(user);
    }


    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        User user = (User) session.getAttributes().get(Constant.USERINFO_SESSION_KEY);

        if(user == null){
            return;
        }

        WebSocketSession exitSession = onlineUserManager.getFromGameRoom(user.getUserId());
        //避免在多开后 第二个退出登录 第一个会话被删除
        if(exitSession == session){
            onlineUserManager.exitGameRoom(user.getUserId());
        }
        System.out.println("当前用户" + user.getUsername() + "离开游戏房间");
        noticeThatUserWin(user);
    }

    private void noticeThatUserWin(User user) throws IOException {
        //根据当前玩家找到房间
        Room room = roomService.getRoomBYUserId(user.getUserId());

        if(room == null){
            //当前房间已被释放 即没有队手
            System.out.println("当前房间已经释放,无需通知队手");
            return;
        }

        //根据房间找到队手
        User thatUser = (user == room.getUser1()) ? room.getUser2() : room.getUser1();

        //找到队手的在线状态
        WebSocketSession socketSession = onlineUserManager.getFromGameRoom(thatUser.getUserId());
        if(socketSession == null){
            //双方都掉线了
            System.out.println("队手也已经掉线");
            return;
        }

        //构造响应你是获胜方

        GameResponse response = new GameResponse();
        response.setMessage("outOfRoom");
        response.setUserId(thatUser.getUserId());
        response.setWinner(thatUser.getUserId());

        socketSession.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
        //更新玩家信息
        int winUserId = thatUser.getUserId();
        int loseUserId = user.getUserId();

        userMapper.userLose(loseUserId);
        userMapper.userWin(winUserId);

        //释放房间
        roomService.remove(room.getRoomId(),room.getUser1().getUserId(),room.getUser2().getUserId());
    }
}

操作数据库语句:
//总场次 + 1 获胜 + 1 天梯分数 + 30
    void userWin(int userId);

    //总场次 + 1 获胜不变 天梯分数 - 30
    void userLose(int userId);
<update id="userWin">
        update user set totalCount = totalCount + 1, winCount = winCount + 1, score = score + 30 where userId = #{userId}

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

4.6、落子请求

依旧通过 websocket 在前端发送落子请求,这里需要调整刚刚的绘制 棋盘 的 js 代码:当前端检测到棋盘对应的点击位置为空,先向后端发送请求,后通过响应再去进行棋子的绘制

①、客户端发送 ——> 落子请求:

chess.onclick = function (e) {
        if (over) {
            return;
        }
        if (!me) {
            return;
        }
        let x = e.offsetX;
        let y = e.offsetY;
        // 注意, 横坐标是列, 纵坐标是行
        let col = Math.floor(x / 30);
        let row = Math.floor(y / 30);
        if (chessBoard[row][col] == 0) {
            // TODO 发送坐标给服务器, 服务器要返回结果
            send(row, col);
            //留到浏览器收到落子响应的时候再处理(收到响应再来画棋子)
            // oneStep(col, row, gameInfo.isWhite);
            // chessBoard[row][col] = 1;
        }
    }

    function send(row, col){
        let req = {
            message:'putChess',
            userId:gameInfo.thisUserId,
            row:row,
            col:col
        };

        websocket.send(JSON.stringify(req));
    }

②、客户端处理 ——> 落子响应:

  • 1、转换 json 字符串为 js 对象
  • 2、判断 服务器 返回的是什么信息,如果是 putChess 证明可以正常的落子下棋,即进行棋子的绘制,判断响应中的 userid 和 自己的 id 是否一致 如果一致 绘制自己颜色的棋子,通过自己的 isWhite 属性去判断是黑还是白,返回的是对手的 id 就对 isWhite 属性取反即可(不是白就是黑),然后把对应的棋盘位置设置为 1,证明当前位置有子,取反 me 传给 setScreenText 证明自己下完棋了,该队手下棋
  • 3、当 winner 属性不再是 0 就证明已经分出胜负,看 winner 属性是谁的 id 分别修改对应 客户端 的提示语,并生成一个按钮,通过此按钮可以回到游戏大厅页
websocket.onmessage = function(event){
        console.log("响应" + event.data);

        let resp = JSON.parse(event.data);

        //先判断落子响应是自己落得子还是对方落得子

        if(resp.message == 'putChess'){
            if(resp.userId == gameInfo.thisUserId){
                //根据自己得子颜色 绘制一个棋子
                oneStep(resp.col, resp.row, gameInfo.isWhite);
            }else if(resp.userId == gameInfo.thatUserId){
                oneStep(resp.col, resp.row, !gameInfo.isWhite);
            }else{
                console.log("resp.userId 错误");
                return;
            }

            //方便后续逻辑判定当前位置是否有子
            chessBoard[resp.row][resp.col] = 1;

            //交换双方得落子轮次
            me = !me;
            setScreenText(me);
        }else if(resp.message == 'outOfRoom'){
            console.log(resp);
        }else{
            console.log("响应格式错误");
        }
        //判定游戏是否结束
        let screenDiv = document.querySelector("#screen")
        if(resp.winner != 0){
            if(resp.winner == gameInfo.thisUserId){
                // alert("你赢了")
                screenDiv.innerHTML = "你赢了!!!"
            }else if(resp.winner == gameInfo.thatUserId){
                // alert("你输了")
                screenDiv.innerHTML = "你输了!!!"
            }else{
                alert("winner 错误");
            }

            // window.location.href = "index.html";
            let backBtn = document.createElement('button');
            backBtn.innerHTML = "回到大厅";

            backBtn.onclick = function(){
                location.replace("index.html");
            }
            let fatherDiv = document.querySelector(".container>div");
            fatherDiv.appendChild(backBtn);
        }
    }

4.7、落子响应

最后实现服务器的落子响应:这里使用单独一个 putChess 方法来进行实现:

  • 1、这里 putChess 接收的是前端发送来的落子请求数据,将其转换为 GameRequest 类
  • 2、获取到当前子是谁下的,判断请求中的 userId 和哪个玩家的 userId 匹配
  • 3、从请求中获取到玩家落子的位置(行和列),判断对应的二维数是不是 0 如果不是 0 证明当前位置有子,如果是 0 即当前子可以落在对应位置
  • 4、这里打印棋盘信息,方便观察落子位置是否正确
  • 5、落子后,就去检查当前是否有玩家获胜,使用单独的 checkWinner 方法来来检查是否有玩家获胜,这里需要检查四种情况,横竖方向、左右对角线方向,最后根据当前子的编号是 1 / 2 返回获胜方 id
  • 6、获取到玩家在游戏房间的在线状态,这里在 GameAPI 中已经检测过这样的情况,这里检测是为了程序的健壮性,更好的保证程序的安全性。获取到玩家的 session 会话,检测是否为空,哪一方为空即对方胜利即可
  • 7、构造响应,发送给服务器
  • 8、最后判断刚刚的获胜判断是否有获胜方,即 winner 属性不再等于 0,获取到获胜的 userid 另一个 id 就是失败方的 id,传入到操作数据库的方法中,更新对应玩家的场次、分数、获胜场次
  • 9、最后销毁房间
package com.example.java_gobang.game;

import com.example.java_gobang.JavaGobangApplication;
import com.example.java_gobang.mapper.UserMapper;
import com.example.java_gobang.model.User;
import com.example.java_gobang.service.RoomService;
import com.example.java_gobang.tools.GameRequest;
import com.example.java_gobang.tools.GameResponse;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.Data;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;

import java.io.IOException;
import java.util.UUID;

/**
 * Created with IntelliJ IDEA.
 * Description:
 * User: Lenovo
 * Date: 2022-08-16
 * Time: 12:13
 */
//表示一个游戏房间
@Data
public class Room {
    //使用字符串 方便生成唯一值
    private String roomId;

    //两个玩家
    private User user1;
    private User user2;

    //棋盘 约定使用 0 代表当前位置未落子 初始化好的二维数组为 全0 使用1代表user1落子位置 使用2代表user2落子位置
    //服务器这里的二维数组有三个功能:进行判定胜负 玩家1和玩家2的子都在哪

    //客户端的二维数组有两个功能:判断有没有子 避免重复落子 画子

    //一般游戏的关键信息的判定是交给后端的


    private int[][] board = new int[15][15];

    private static final int maxRow = 15;
    private static final int maxCol = 15;

    //先手方的玩家 id
    private int whiteUser;
    //使用 UUID 管理房间号 唯一身份标识 一组 16进制 表示的数字 两次调用此算法 生成的字符串都是不相同的

    //通过入口计入的 context 就可以手动注入
    public Room() {
        roomId = UUID.randomUUID().toString();
        onlineUserManager = JavaGobangApplication.context.getBean(OnlineUserManager.class);
        roomService = JavaGobangApplication.context.getBean(RoomService.class);
        userMapper = JavaGobangApplication.context.getBean(UserMapper.class);
    }

    private ObjectMapper objectMapper = new ObjectMapper();

    private UserMapper userMapper;


    private OnlineUserManager onlineUserManager;


    private RoomService roomService;
    //处理落子操作
    //记录当前落子的位置,进行胜负判定,给客户端返回响应
    public void putChess(String Reqjson) throws IOException {
        //1、记录当前落子位置
        GameRequest request = objectMapper.readValue(Reqjson,GameRequest.class);
        GameResponse response = new GameResponse();

        //当前这个子是玩家1落  还是玩家2 落的 往数组里写 1 还是 2
        int chess = request.getUserId() == user1.getUserId() ? 1 : 2;

        int row = request.getRow();
        int col = request.getCol();

        if(board[row][col] != 0){
            //此处为了程序更加稳定 在服务器重复判定一次
            System.out.println("当前位置:"+ row +","+ col + "已经有子了");
            return;
        }


        board[row][col] = chess;

        //+++ 打印棋盘信息
        printBoard();

        //2、进行胜负判定
        int winner = checkWinner(row,col,chess);

        //3.给房间中的所有客户端都返回响应数据
        response.setMessage("putChess");
        response.setUserId(request.getUserId());
        response.setRow(row);
        response.setCol(col);
        response.setWinner(winner);

        WebSocketSession session1 = onlineUserManager.getFromGameRoom(user1.getUserId());
        WebSocketSession session2 = onlineUserManager.getFromGameRoom(user2.getUserId());

        //判定当前会话是否为空 玩家下线

        if(session1 == null){
            //玩家 1 下线 直接玩家 2 获胜
            response.setWinner(user2.getUserId());
            System.out.println("玩家2获胜");
        }

        if(session2 == null){
            response.setWinner(user1.getUserId());
            System.out.println("玩家1获胜");
        }
        String respJson = objectMapper.writeValueAsString(response);
        if(session1 != null){
            session1.sendMessage(new TextMessage(respJson));
        }

        if(session2 != null){
            session2.sendMessage(new TextMessage(respJson));
        }

        //胜负已分
        if(response.getWinner() != 0){
            System.out.println("游戏即将结束!房间即将销毁!roomId" + roomId + "获胜方:" + response.getWinner());
            //更新获胜方和失败方的信息

            int winUserId = response.getWinner();
            int loseUserId = response.getWinner() == user1.getUserId() ? user2.getUserId() : user1.getUserId();

            userMapper.userWin(winUserId);
            userMapper.userLose(loseUserId);

            //销毁房间
            roomService.remove(roomId,user1.getUserId(),user2.getUserId());
        }
    }

    //打印出棋盘
    private void printBoard() {
        System.out.println("打印棋盘信息");
        System.out.println("=========================================");
        for (int r = 0; r < maxRow; r++) {
            for (int c = 0; c < maxCol; c++) {
                System.out.print(board[r][c] + " ");
            }

            System.out.println();
        }
        System.out.println("=========================================");
    }

    //TODO:
    //使用这个方法判断是否分出胜负 玩家1胜返回玩家1 id 反之返回 玩家2 的id 胜负未分:返回 0
    private int checkWinner(int row, int col, int chess) {
        //检查所有的行

        //先遍历这五种情况 判断这个五个子是不是连在一起 不光是五个子是连着的 而且是当前玩家下的
        for (int c = col - 4; c <= col ; c++) {
            try {
                if(board[row][c] == chess
                  && board[row][c+1] == chess
                  && board[row][c+2] == chess
                  && board[row][c+3] == chess
                  && board[row][c+4] == chess){
                    //胜负已经分出来了
                    return chess == 1 ? user1.getUserId() : user2.getUserId();
                }
            }catch (ArrayIndexOutOfBoundsException e){
                //出现越界直接忽略
                continue;
            }
        }

        //检查所有列
        for (int r = row - 4; r <= row ; r++) {
            try{
                if(board[r][col] == chess
                && board[r+1][col] == chess
                && board[r+2][col] == chess
                && board[r+3][col] == chess
                && board[r+4][col] == chess){
                    return chess == 1 ? user1.getUserId() : user2.getUserId();
                }
            }catch (ArrayIndexOutOfBoundsException e){
                continue;
            }
        }
        
        //检查左对角线
        for (int c = col - 4, r = row - 4; c <= col && r <= row ;r++, c++) {
            try{
                if(board[r][c] == chess
                && board[r+1][c+1] == chess
                && board[r+2][c+2] == chess
                && board[r+3][c+3] == chess
                && board[r+4][c+4] == chess){
                    return chess == 1 ? user1.getUserId() : user2.getUserId();
                }
            }catch (ArrayIndexOutOfBoundsException e){
                continue;
            }
        }
        
        //检查右对角线
        for (int c = col + 4, r = row - 4; c >= col && r <= row ;r++, c--) {
            try{
                if(board[r][c] == chess
                  && board[r+1][c-1] == chess
                  && board[r+2][c-2] == chess
                  && board[r+3][c-3] == chess
                  && board[r+4][c-4] == chess){
                    return chess == 1 ? user1.getUserId() : user2.getUserId();
                }
            }catch (ArrayIndexOutOfBoundsException e){
                continue;
            }
        }

        //胜负未分 返回 0
        return 0;
    }
}




  • 13
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 7
    评论
评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

梦の澜

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值