网页五子棋对战

网页五子棋对战

项目源码:源码地址

项目背景

实现一个网页版的五子棋对战程序,支持以下核心功能:

  • 用户模块:用户注册,用户登录,用户信息展示
  • 匹配模块:根据用户的天梯积分来决定用户的匹配对手积分
  • 对战模块:两个玩家在网页端进行五子棋对战功能

核心技术

  • Spring/SpringMVC/SpringBoot

  • WebSocket

  • MySQL

  • MyBatis

  • HTML/CSS/JavaScript

需求分析和概要设计

用户模块

主要负责用户的注册和登录,用户信息管理功能

使用MySQL数据库进行数据的存储

客户端提供一个可注册登录的页面

服务端基于 Spring和MyBatis来进行数据库的增删改查

匹配模块

用户登录成功,则进入游戏大厅页面

游戏大厅中, 能够显示用户的名字,天梯分数,比赛场数和获胜场数

同时显示一个 “匹配按钮”

点击匹配按钮则用户进入匹配队列, 并且界面上显示为 “取消匹配”

再次点击则把用户从匹配队列中删除

如果匹配成功, 则跳转进入到游戏房间页面

页面加载时和服务器建立 websocket 连接,双方通过 websocket 来传输 “开始匹配”, “取消匹配”, “匹配成功” 这样的信息

对战模块

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

在游戏房间页面中, 能够显示五子棋棋盘 玩家点击棋盘上的位置实现落子功能

并且五子连珠则触发胜负判定, 显示 你赢了, 你输了

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

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

项目创建

使用IDEA创建SpringBoot2.x的项目,引入以下依赖
在这里插入图片描述

最后pom.xml文件配置如下


<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0modelVersion><parent><groupId>org.springframework.bootgroupId><artifactId>spring-boot-starter-parentartifactId><version>2.7.12version><relativePath/> parent><groupId>com.examplegroupId><artifactId>java_gobangartifactId><version>0.0.1-SNAPSHOTversion><name>java_gobangname><description>java_gobangdescription><properties><java.version>1.8java.version>properties><dependencies><dependency><groupId>org.yamlgroupId><artifactId>snakeyamlartifactId><version>2.0version>dependency><dependency><groupId>org.springframework.bootgroupId><artifactId>spring-boot-starter-webartifactId>dependency><dependency><groupId>org.springframework.bootgroupId><artifactId>spring-boot-starter-websocketartifactId>dependency><dependency><groupId>org.mybatis.spring.bootgroupId><artifactId>mybatis-spring-boot-starterartifactId><version>2.3.1version>dependency><dependency><groupId>org.springframework.bootgroupId><artifactId>spring-boot-devtoolsartifactId><scope>runtimescope><optional>trueoptional>dependency><dependency><groupId>com.mysqlgroupId><artifactId>mysql-connector-jartifactId><scope>runtimescope>dependency><dependency><groupId>org.projectlombokgroupId><artifactId>lombokartifactId><optional>trueoptional>dependency><dependency><groupId>org.springframework.bootgroupId><artifactId>spring-boot-starter-testartifactId><scope>testscope>dependency>dependencies><build><plugins><plugin><groupId>org.springframework.bootgroupId><artifactId>spring-boot-maven-pluginartifactId><configuration><excludes><exclude><groupId>org.projectlombokgroupId><artifactId>lombokartifactId>exclude>excludes>configuration>plugin>plugins>build>project>

配置yml

# 配置数据库的连接字符串
spring:datasource:url: jdbc:mysql://127.0.0.1/java_gobang?characterEncoding=utf8username: rootpassword: 111111driver-class-name: com.mysql.cj.jdbc.Driver# 设置 Mybatis 的 xml 保存路径
mybatis:mapper-locations: classpath:mapper/*Mapper.xmlconfiguration: # 配置打印 MyBatis 执行的 SQLlog-impl: org.apache.ibatis.logging.stdout.StdOutImpl# 配置打印 MyBatis 执行的 SQL
logging:level:com:example:demo: debug

实现用户模块

编写数据库代码

数据库设计

设计八个参数:

id 表示该用户的id,设置自增主键;username表示该用户用户名,设置唯一约束;password表示该用户密码,非空

score 表示该用户的天梯积分;total_count 表示该用户进行的总场次;win_count表示该用户胜利场次

create_time 表示该用户创建时间 ;update_time表示该用户被动修改时间,然后插入几个用户当做示例

create database if not exists java_gobang;use java_gobang;drop table if exists userinfo;create table userinfo(id int primary key auto_increment,username varchar(50) not null unique,password varchar(65) not null,score int not null default 1000,total_count int not null default 0,win_count int not null default 0,create_time decimal not null default now(),update_time decimal not null default now()
);insert into user values(null, '张三', '123');
insert into user values(null, '李四', '123');
insert into user values(null, '王五', '123');
insert into user values(null, '赵六', '123');
insert into user values(null, '田七', '123');
insert into user values(null, '朱八', '123');

创建实体类
package com.example.java_gobang.entity;import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;import java.time.LocalDateTime;@Data
public class User {private int id;private String username;private String password;private int score;private int total_count;private int win_count;@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")private LocalDateTime create_time;@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")private LocalDateTime update_time;
}

创建userMapper
package com.example.java_gobang.mapper;import com.example.java_gobang.entity.User;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;@Mapper
public interface UserMapper {/*** 添加一条用户信息* @param user 要添加的用户* @return 返回受影响的行数*/int insertUser(User user);/*** 根据用户名查询用户信息, 用于登录验证* @param username 用户名* @return 返回查询到的用户信息*/User selectUserByName(@Param("username") String username);/*** 通过id寻找用户* @param id 用户id* @return 返回查找到的用户*/User selectUserById(@Param("id") Integer id);/*** 胜方修改数据* @param id 胜方用户id*/void userWinUpdate(@Param("id") Integer id);/*** 败方修改数据* @param id 败方id*/void userLoseUpdate(@Param("id") Integer id);
}

编写userMapper.xml

DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.java_gobang.mapper.UserMapper"><insert id="insertUser">insert into userinfo(username, password) values (#{username}, #{password})insert><select id="selectUserByName" resultType="com.example.java_gobang.entity.User">select * from userinfo where username = #{username};select><select id="selectUserById" resultType="com.example.java_gobang.entity.User">select * from userinfo where id = #{id}select><update id="userWinUpdate">update userinfo set score = score + 30, total_count = total_count + 1, win_count = win_count + 1where id = #{id}update><update id="userLoseUpdate">update userinfo set score = score - 30, total_count = total_count + 1where id = #{id}update>
mapper>

sql语句实现顺序和userMapper顺序一致


前后端接口

用户模块我们需要返回统一的格式,该格式有三个参数,分别是status表示状态码,statusMsg表示状态码描述,data是传输数据本体

代码如下:

package com.example.java_gobang.common;import lombok.Data;import java.io.Serializable;@Data
public class AjaxResult implements Serializable {// 状态码private int status;// 状态码描述private String statusMsg;// 返回的数据private Object data;/*** 操作成功调用的函数*/public static AjaxResult success(Object data) {AjaxResult ajaxResult = new AjaxResult();ajaxResult.setStatus(200);ajaxResult.setStatusMsg("");ajaxResult.setData(data);return ajaxResult;}public static AjaxResult success(int status, Object data) {AjaxResult ajaxResult = new AjaxResult();ajaxResult.setStatus(status);ajaxResult.setStatusMsg("");ajaxResult.setData(data);return ajaxResult;}public static AjaxResult success(int status, String statusMsg, Object data) {AjaxResult ajaxResult = new AjaxResult();ajaxResult.setStatus(status);ajaxResult.setStatusMsg(statusMsg);ajaxResult.setData(data);return ajaxResult;}/*** 失败调用的函数*/public static AjaxResult fail(int status, String statusMsg) {AjaxResult ajaxResult = new AjaxResult();ajaxResult.setStatus(status);ajaxResult.setStatusMsg(statusMsg);ajaxResult.setData(null);return ajaxResult;}public static AjaxResult fail(int status, String statusMsg, Object data) {AjaxResult ajaxResult = new AjaxResult();ajaxResult.setStatus(status);ajaxResult.setStatusMsg(statusMsg);ajaxResult.setData(data);return ajaxResult;}
}
登录接口

请求:

POST /login HTTP/1.1
Content-Type: application/x-www-form-urlencodedusername=zhangsan&password=123

响应:data数据中1表示登录成功,0表示登录失败

HTTP/1.1 200 OK
Content-Type: application/json{status:200,statusMsg:"",data: 1 | 0
}    

注册接口

请求:

POST /register HTTP/1.1
Content-Type: application/x-www-form-urlencodedusername=zhangsan&password=123

响应:data数据中1表示登录成功,0表示登录失败

HTTP/1.1 200 OK
Content-Type: application/json{status:200,statusMsg:"",data: 1 | 0
}      

获取用户信息接口

接口:

POST /userInfo HTTP/1.1

响应:data数据返回一个用户的信息来显示在游戏大厅中,密码这种私密数据要设置成空字符串

HTTP/1.1 200 OK
Content-Type: application/json{status:200,statusMsg:"",data: {id: 1,username: 'zhangsan',passsword:"",score: 1000,total_count: 10,win_count: 5,create_time: "",update_time: ""}}    

服务器开发

创建 controller.userController 和 service.userService 两个文件分别为控制层代码和服务层代码

需要实现三个接口,分别是:

  • login :用来实现登录逻辑

  • register :用来实现注册逻辑

  • showuser :用来在游戏大厅中展示个人信息


控制层
package com.example.java_gobang.controller;import com.example.java_gobang.common.AjaxResult;
import com.example.java_gobang.common.UserSessionUtils;
import com.example.java_gobang.entity.User;
import com.example.java_gobang.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;import javax.servlet.http.HttpServletRequest;@RestController
@RequestMapping("/user")
public class UserController {@Autowiredprivate UserService userService;@PostMapping("/login")public AjaxResult userLogin(User user, HttpServletRequest request) {if (user == null || !StringUtils.hasLength(user.getUsername())|| !StringUtils.hasLength(user.getPassword())) {return AjaxResult.fail(1, "参数异常");}boolean row = userService.getUserPasswordService(user.getUsername(), user.getPassword(), request);return AjaxResult.success(row);}@PostMapping("/register")public AjaxResult userRegister(User user) {if (user == null || !StringUtils.hasLength(user.getUsername())|| !StringUtils.hasLength(user.getPassword())) {return AjaxResult.fail(1, "参数异常");}int row = userService.addUserService(user);return AjaxResult.success(row);}@PostMapping("/showuser")public AjaxResult intiShowUser(HttpServletRequest request) {User user = UserSessionUtils.getUser(request);if (user == null) {return AjaxResult.fail(2, "非法访问");}int userId = user.getId();User resultUser = userService.getUserByIdService(userId);return AjaxResult.success(resultUser);}
}

服务层
package com.example.java_gobang.service;import com.example.java_gobang.common.AppVariable;
import com.example.java_gobang.entity.User;
import com.example.java_gobang.mapper.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;@Service
public class UserService {@Autowiredprivate UserMapper userMapper;public int addUserService(User user) {// TODO 需要给注册密码进行加密return userMapper.insertUser(user);}public boolean getUserPasswordService(String username, String password, HttpServletRequest request) {// TODO 数据库中的最终密码,要进行解密User user = userMapper.selectUserByName(username);if (user == null) {return false;}boolean isLogin = password.equals(user.getPassword());if (isLogin) {// 登录成功后添加 sessionHttpSession session = request.getSession(true);user.setPassword("");session.setAttribute(AppVariable.USER_SESSION_KEY, user);// TODO 每次登录成功后更新数据库密码盐值}return isLogin;}public User getUserByIdService(Integer userId) {return userMapper.selectUserById(userId);}}

客户端开发

创建注册页面register.html 和 login.html

注册页面和登录页面的CSS:


.login-container {height: calc(100% - 50px);background-image: linear-gradient(to top, #c1dfc4 0%, #deecdd 100%);display: flex;justify-content: center;align-items: center;
}.login-dialog {width: 400px;height: 400px;background-color: rgba(255, 255, 255, 0.8);border-radius: 10px;
}/* 标题 */
.login-dialog h3 {text-align: center;padding: 50px 0;
}/* 针对一行设置样式 */
.login-dialog .row {width: 100%;height: 50px;display: flex;align-items: center;justify-content: center;
}.login-dialog .row span {width: 100px;font-weight: 700;
}#username, #password {width: 200px;height: 40px;font-size: 20px;line-height: 40px;padding-left: 10px;border: none;outline: none;border-radius: 10px;
}#submit {width: 300px;height: 50px;background-color: rgb(0, 128, 0);color: white;border: none;outline: none;border-radius: 10px;margin-top: 20px;
}#submit:active {background-color: #666;
}

注册HTML

DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>注册title><link rel="stylesheet" href="css/common.css"><link rel="stylesheet" href="css/login.css"><script src="js/jquery.min.js">script>
head>
<body>
<div class="nav">五子棋对战
div>
<div class="login-container"><div class="login-dialog"><h3>注册h3><div class="row"><span>用户名span><input type="text" id="username">div><div class="row"><span>密码span><input type="password" id="password">div><div class="row"><button id="submit" onclick="register()">提交button>div>div>
div><script>function register() {// 用 jQuery 得到输入框里的值var username = jQuery("#username").val();var password = jQuery("#password").val();// 判断输入框里的值是否有效,无效返回if (username == "" || password == "") {alert("用户名或密码不能为空!");return;}jQuery.ajax({// 用 ajax 向后端传输数据来进行注册url: '/user/login',type: 'post',data: {"username": username, "password": password},success: function (result) {if (result != null && result.status == 200 && result.data == 1) {// 注册成功,跳转到登录页面alert("注册成功");location.href = "/login.html";} else {// 注册失败alert("注册失败,请重试");}}});}
script>
body>
html>

登录HTML

DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>登录title><link rel="stylesheet" href="css/common.css"><link rel="stylesheet" href="css/login.css"><script src="js/jquery.min.js">script>
head>
<body>
<div class="nav">五子棋对战
div>
<div class="login-container"><div class="login-dialog"><h3>登录h3><div class="row"><span>用户名span><input type="text" id="username">div><div class="row"><span>密码span><input type="password" id="password">div><div class="row"><button id="submit" onclick="login()">提交button>div>div>div><script>function login() {// 用 jQuery 得到输入框里的值var username = jQuery("#username").val();var password = jQuery("#password").val();// 判断输入框里的值是否有效,无效返回if (username == "" || password == "") {alert("用户名或密码不能为空!");return;}jQuery.ajax({// 用 ajax 向后端传输数据来进行注册url: '/user/login',type: 'post',data: {"username": username, "password": password},success: function (result) {if(result != null && result.status == 200 && result.data) {// 登录成功,跳转到大厅页面alert("登录成功");location.href = "/game_hall.html";} else {alert("登录失败,请重试");}}});}
script>
body>
html>

实现匹配模块

前后端交互接口

WebSocket连接

ws://127.0.0.1:8080/findMatch

请求:

{message: 'startMatch' / 'stopMatch',
}

响应1:在服务器收到请求后立即返回用来改变按钮状态

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

响应2:在服务器匹配成功后返回来让两个用户进入游戏房间

{ok: true,reason: '',message: 'matchSuccess',    
}

客户端开发

创建game_hall.html

DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>游戏大厅title><link rel="stylesheet" href="css/common.css"><link rel="stylesheet" href="css/game_hall.css">
head>
<body>
<div class="nav">五子棋对战div>

<div class="container"><div><div id="screen">div><div id="match-button" onclick="match()">开始匹配div>div>
div><script src="js/jquery.min.js">script>
<script>function initUser() {// 进行游戏大厅初始化,查询该用户信息jQuery.ajax({url: '/user/showuser',type: 'post',data: {},success: function (result) {if (result != null && result.status === 200 && result.data != null) {// 查询成功,添加到页面上var information = "";var person = result.data;information += "用户名:" + person.username + " 天梯积分:" + person.score +" 
"
+ "总场次:" + person.total_count + " 胜利场次:" + person.win_count;jQuery("#screen").html(information);}}});}initUser();// websocket 连接,创建URLvar webSocketURL = "ws://" + location.host + "/findmatch";var webSocket = new WebSocket(webSocketURL);// 当和服务器创建连接时执行的方法webSocket.onopen = function () {console.log("建立连接");}// 当连接出现异常时执行的方法webSocket.onerror = function () {console.log("出现异常");}// 当连接关闭时执行的方法webSocket.onclose = function () {console.log("关闭连接");}// 浏览器直接关闭标签页时执行的方法,要手动把WebSocket关闭window.onbeforeunload = function() {webSocket.close();}// 点击大厅按钮来控制当前用户是否匹配function match() {var matchButton = document.querySelector('#match-button');if (matchButton.innerHTML === "开始匹配") {// 点击按钮后如果按钮内容是开始匹配,说明当前用户需要匹配,给服务器发送请求来开始匹配,将当前用户放入匹配队列webSocket.send(JSON.stringify({message: 'startMatch'}));} else if (matchButton.innerHTML === "正在匹配...(点击停止)") {// 点击这个按钮表明当前用户需要取消匹配,给服务器发送请求来取消匹配,将当前用户从匹配队列取出webSocket.send(JSON.stringify({message: 'stopMatch'}));} else {// 表明当前连接出现错误,直接定向到登录界面alert("连接断开,请重试!");location.replace("/login.html");}}// 处理服务器返回的响应,响应格式//{// boolean OK// String reason = "// String message = ""//}webSocket.onmessage = function (e) {var result = JSON.parse(e.data);if (!result.ok) {console.log("收到了失败响应" + result.reason);return;}// 根据服务器来修改前端显示的内容let matchButton = document.querySelector('#match-button');if (result.message === "startMatch") {// 如果返回这个参数,就表示服务器已经开始匹配,用户进入匹配队列,前端可以显示正在匹配的字样matchButton.innerHTML = '正在匹配...(点击停止)';} else if (result.message === "stopMatch") {// 返回这个参数表明服务器已经停止匹配matchButton.innerHTML = '开始匹配';} else if (result.message === "matchSuccess") {// 匹配成功,当前用户加载到房间页面中location.replace("/game_rome.html");} else if (result.message === "repeatConnection") {// 返回这个响应参数,表明当前两个相同用户同时在大厅界面或同时在房间页面,或既在大厅界面又在房间页面// 多开关闭WebSocket连接,多余的那个用户然后加载到登录界面alert(result.reason);webSocket.close();location.replace("/login.html");} else {alert("非法响应");}}
script> body> html>

game_html页面的样式CSS

#screen {width: 400px;height: 200px;font-size: 20px;background-color: gray;color: white;border-radius: 10px;text-align: center;line-height: 100px;background-image: linear-gradient(to right, #434343 0%, black 100%);
}#match-button {width: 400px;height: 50px;font-size: 20px;color: white;background-color: orange;border: none;outline: none;border-radius: 10px;text-align: center;line-height: 50px;margin-top: 20px;
}#match-button:active {background-color: gray;
}

服务端开发

创建并注册websocket

我们需要创建一个文件来配置前端URL对应后端类

创建config. WebSocketConfig.java

package com.example.java_gobang.config;import com.example.java_gobang.component.GameHandler;
import com.example.java_gobang.component.MatchHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor;@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {@Autowiredprivate MatchHandler matchHandler;@Autowiredprivate GameHandler gameHandler;// 关联前端url和后端实现类@Overridepublic void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {// 通过 .addInterceptors(new HttpSessionHandshakeInterceptor()// 这个操作来把 HttpSession 里的属性放到 WebSocket 的 session 中// 然后就可以在 WebSocket 代码中 WebSocketSession 里拿到 HttpSession 中的 attribute.// 这个是对应房间页面的操作registry.addHandler(matchHandler, "/findmatch").addInterceptors(new HttpSessionHandshakeInterceptor());// 这个是对应房间对战页面的操作registry.addHandler(gameHandler, "/game").addInterceptors(new HttpSessionHandshakeInterceptor());}
}

创建MatchHandler来作为前端findmatch的入口

该类需要继承TextWebSocketHandler实现四个接口

  • afterConnectionEstablished :表示连接建立时需要执行的方法
  • handleTextMessage :后端收到请求内容时执行的方法
  • handleTransportError :连接收到异常时执行的方法
  • afterConnectionClosed :连接关闭时执行的方法
@Component
public class MatchHandler extends TextWebSocketHandler {private final ObjectMapper objectMapper = new ObjectMapper();// 在连接成功上时执行@Overridepublic void afterConnectionEstablished(WebSocketSession session) throws Exception {}// 在收到消息时执行@Overrideprotected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {}// 在连接出现异常时执行@Overridepublic void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {}// 在连接关闭时执行@Overridepublic void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {}
}

实现用户管理器

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

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

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

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

package com.example.java_gobang.component;import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketSession;import java.util.HashMap;@Component
public class OnlineUserState {// 维护用户在游戏大厅的状态private final HashMap<Integer, WebSocketSession> userGameHall = new HashMap<>();// 维护用户在游戏界面的状态private final HashMap<Integer, WebSocketSession> userGameRoom = new HashMap<>();// 进入大厅就添加sessionpublic void enterSessionHall(Integer id, WebSocketSession session) {userGameHall.put(id, session);}// 退出大厅就删除sessionpublic void exitSessionHall(Integer id) {userGameHall.remove(id);}// 根据用户 id 得到sessionpublic WebSocketSession getSessionHall(Integer id) {return userGameHall.get(id);}// 进入房间就添加sessionpublic void enterSessionRoom(Integer id, WebSocketSession session) {userGameRoom.put(id, session);}// 退出房间就删除sessionpublic void exitSessionRoom(Integer id) {userGameRoom.remove(id);}// 根据用户 id 得到sessionpublic WebSocketSession getSessionRoom(Integer id) {return userGameRoom.get(id);}}

MatchHandler 注册OnlineUserState

@Component
public class MatchHandler extends TextWebSocketHandler {@Autowiredprivate OnlineUserState onlineUserState;
}

创建匹配请求,响应对象

请求对象来接收前端传来的json,响应对象来给前端传递json字符串

请求对象:前端只传递一个属性,所以只有一个message

package com.example.java_gobang.entity;import lombok.Data;@Data
public class MatchRequest {private String message;
}

响应对象:进行稍微封装

package com.example.java_gobang.entity;import lombok.Data;@Data
public class MatchResponse {private boolean ok;private String reason;private String message;public static MatchResponse success(boolean ok, String reason, String message) {MatchResponse matchResponse = new MatchResponse();matchResponse.setOk(ok);matchResponse.setReason(reason);matchResponse.setMessage(message);return matchResponse;}public static MatchResponse success(boolean ok, String reason) {MatchResponse matchResponse = new MatchResponse();matchResponse.setOk(ok);matchResponse.setReason(reason);matchResponse.setMessage("");return matchResponse;}public static MatchResponse success(String message) {MatchResponse matchResponse = new MatchResponse();matchResponse.setOk(true);matchResponse.setReason("");matchResponse.setMessage(message);return matchResponse;}public static MatchResponse fail(boolean ok, String reason) {MatchResponse matchResponse = new MatchResponse();matchResponse.setOk(ok);matchResponse.setReason(reason);matchResponse.setMessage("");return matchResponse;}public static MatchResponse fail(boolean ok, String reason, String message) {MatchResponse matchResponse = new MatchResponse();matchResponse.setOk(ok);matchResponse.setReason(reason);matchResponse.setMessage(message);return matchResponse;}
}

创建公共常量类

创建common.AppVariable,用来存放公共常量

package com.example.java_gobang.common;public class AppVariable {// 用来存放session哈希表的key常量public static final String USER_SESSION_KEY = "USER_SESSION_KEY";
}

处理连接成功

实现afterConnectionEstablished 方法

  • 通过参数中的 session 对象, 拿到之前登录时设置的 User 信息
  • 使用 onlineUserState来管理用户的在线状态
  • 先判定用户是否是已经在线, 如果在线则直接返回出错 (禁止同一个账号多开)
  • 设置玩家的上线状态
	@Overridepublic void afterConnectionEstablished(WebSocketSession session) throws Exception {// 从 websocketsession中 判断是否有没有改用户数据,无就是未登录,如果已经加入准备状态hash中// 那就是多开了,就要禁止,这两种以外的情况我们才把该用户放入准备hash中User user = (User) session.getAttributes().get(AppVariable.USER_SESSION_KEY);if (user == null) {// 未登录的状态MatchResponse matchResponse = MatchResponse.fail(false, "您当前还未登录,不能进行后续匹配操作");session.sendMessage(new TextMessage(objectMapper.writeValueAsString(matchResponse)));return;}// 多开的情况if (onlineUserState.getSessionHall(user.getId()) != null|| onlineUserState.getSessionRoom(user.getId()) != null) {MatchResponse matchResponse = MatchResponse.fail(true, "游戏禁止多开", "repeatConnection");session.sendMessage(new TextMessage(objectMapper.writeValueAsString(matchResponse)));return;}// 除了上诉情况,就说明当前玩家是正常的,就可以把该玩家放入大厅hash管理类中存储onlineUserState.enterSessionHall(user.getId(), session);System.out.println("玩家 + " + user.getUsername() + "进入准备状态");}

处理连接关闭和出现异常

实现handleTransportErrorafterConnectionClosed方法

这两个方法逻辑相近,就在一起实现

  • 主要的工作就是把玩家从 onlineUserState中退出
  • 退出的时候要注意判定, 当前玩家是否是多开的情况(一个id, 对应到两个 websocket 连接). 如果一个玩家开启了第二个 websocket 连接, 那么这第二个 websocket 连接不会影响到玩家从 onlineUserState中退出
  • 如果玩家当前在匹配队列中, 则直接从匹配队列里移除
	// 在连接出现异常时执行@Overridepublic void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {// 出现异常, 需要从准备hash中删除sessionUser user = (User) session.getAttributes().get(AppVariable.USER_SESSION_KEY);if (user == null) {// 未登录的状态,直接返回return;}// 删除session表明该玩家不是正常的状态WebSocketSession tempsession = onlineUserState.getSessionHall(user.getId());if (tempsession == session) {// 当根据用户id从准备hash查到的session和前端传来的session相同时,才从准备hash中删除,表明// 这就是当前用户的操作onlineUserState.exitSessionHall(user.getId());}// 从匹配队列中删除该玩家matchQueue.removeUserFromQueue(user);}// 在连接关闭时执行@Overridepublic void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {// 连接断开, 需要从准备hash中删除sessionUser user = (User) session.getAttributes().get(AppVariable.USER_SESSION_KEY);if (user == null) {// 未登录的状态,直接返回return;}WebSocketSession tempsession = onlineUserState.getSessionHall(user.getId());if (tempsession == session) {// 当根据用户id从准备hash查到的session和前端传来的session相同时,才从准备hash中删除,表明// 这就是当前用户的操作onlineUserState.exitSessionHall(user.getId());}// 从匹配队列中删除该玩家matchQueue.removeUserFromQueue(user);}

处理开始匹配,取消匹配

实现handleTextMessage方法

  • 先从会话中拿到当前玩家的信息
  • 解析客户端发来的请求
  • 判定请求的类型, 如果是 startMatch, 则把用户对象加入到匹配队列. 如果是 stopMatch, 则把用户对象从匹配队列中删除
  • 此处需要实现一个 匹配器 对象, 来处理匹配的实际逻辑
	// 在收到消息时执行@Overrideprotected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {// 收到消息就要进行处理User user = (User) session.getAttributes().get(AppVariable.USER_SESSION_KEY);if (user == null) {// 未登录的状态,返回MatchResponse matchResponse = MatchResponse.fail(false, "您当前还未登录,不能进行后续匹配操作");session.sendMessage(new TextMessage(objectMapper.writeValueAsString(matchResponse)));return;}// 当前这个数据载荷是一个 JSON 格式对象, 就需要把它转成 Java 对象. MatchRequestMatchRequest matchRequest = objectMapper.readValue(message.getPayload(), MatchRequest.class);// 提前创建一个匹配响应用于返回MatchResponse matchResponse;if ("startMatch".equals(matchRequest.getMessage())) {// 进行匹配,需要加入匹配队列, 然后返回通知matchQueue.addUserToQueue(user);// 用于改变前端开始匹配后界面matchResponse = MatchResponse.success("startMatch");} else if ("stopMatch".equals(matchRequest.getMessage())) {// 取消匹配,移除匹配队列matchQueue.removeUserFromQueue(user);// 用于改变前端取消匹配后的界面matchResponse = MatchResponse.success("stopMatch");} else {matchResponse = MatchResponse.fail(false, "非法请求");}// 返回响应session.sendMessage(new TextMessage(objectMapper.writeValueAsString(matchResponse)));}

实现匹配器

创建MatchQueue

  • 在 MatchQueue中创建三个队列 (队列中存储 User 对象), 分别表示不同的段位的玩家. (此处约定 <2000 一档, 2000-3000 一档, >3000 一档)

  • 提供 add 方法, 供 MatchHandler类来调用, 用来把玩家加入匹配队列

  • 提供 remove 方法, 供 MatchHandler类来调用, 用来把玩家移出匹配队列

  • 同时 Matcher 找那个要记录 onlineUserState, 来获取到玩家的 Session

还需要处理其中的多线程问题

package com.example.java_gobang.component;import com.example.java_gobang.entity.MatchResponse;
import com.example.java_gobang.entity.Room;
import com.example.java_gobang.entity.User;
import com.fasterxml.jackson.databind.ObjectMapper;
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.LinkedList;
import java.util.Queue;@Component
public class MatchQueue {@Autowiredprivate OnlineUserState onlineUserState;private final ObjectMapper objectMapper = new ObjectMapper();// 天梯积分小于2000private final Queue<User> normalQueue = new LinkedList<>();// 天梯积分大于等于2000,小于3000private final Queue<User> highQueue = new LinkedList<>();// 天梯积分大于等于3000private final Queue<User> veryHighQueue = new LinkedList<>();public void addUserToQueue(User user) {if (user.getScore() < 2000) {synchronized (normalQueue) {normalQueue.offer(user);normalQueue.notify();}} else if (user.getScore() < 3000) {synchronized (highQueue) {highQueue.offer(user);highQueue.notify();}} else {synchronized (veryHighQueue) {veryHighQueue.offer(user);veryHighQueue.notify();}}}public void removeUserFromQueue(User user) {if (user.getScore() < 2000) {synchronized (normalQueue) {normalQueue.remove(user);}} else if (user.getScore() < 3000) {synchronized (highQueue) {highQueue.remove(user);}} else {synchronized (veryHighQueue) {veryHighQueue.remove(user);}}}public MatchQueue() {// 创建三个线程,分别循环查找三个队列进行匹配Thread thread1 = new Thread() {@Overridepublic void run() {// 扫描highQueuewhile (true) {handlerMatch(highQueue);}}};thread1.start();Thread thread2 = new Thread() {@Overridepublic void run() {// 扫描veryHighQueuewhile (true) {handlerMatch(veryHighQueue);}}};thread2.start();Thread thread3 = new Thread() {@Overridepublic void run() {// 扫描normalQueuewhile (true) {handlerMatch(normalQueue);}}};thread3.start();}private void handlerMatch(Queue<User> queue) {synchronized (queue) {try {while (queue.size() < 2) {queue.wait();}User player1 = queue.poll();User player2 = queue.poll();System.out.println("两个玩家 " + player1.getUsername() + " " + player2.getUsername());// 从状态hash中取出websocketsession 来通知客户端比配到了对局,来跳转页面进行对局WebSocketSession session1 = onlineUserState.getSessionHall(player1.getId());WebSocketSession session2 = onlineUserState.getSessionHall(player2.getId());if (session1 == null) {// 如果取出来的session1为空,说明用户1不在状态hash中,就需要把用户2放回去queue.offer(player2);return;}if (session2 == null) {queue.offer(player1);return;}if (session1 == session2) {// 说明两个人是同一个人,不能进行对局,放回去queue.offer(player1);return;}// 匹配完成后就需要放入一个房间进行对战,并放入房间管理器中维护Room room = new Room();roomManager.addRoom(room, player1.getId(), player2.getId());// 给玩家反馈消息匹配成功,返回matchSuccessMatchResponse matchResponse1 = MatchResponse.success("matchSuccess");MatchResponse matchResponse2 = MatchResponse.success("matchSuccess");session1.sendMessage(new TextMessage(objectMapper.writeValueAsString(matchResponse1)));session2.sendMessage(new TextMessage(objectMapper.writeValueAsString(matchResponse2)));} catch (IOException | InterruptedException e) {e.printStackTrace();}}}}

创建房间类

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

创建 Room

  • 一个房间要包含一个房间 ID, 使用 UUID 作为房间的唯一身份标识
  • 房间内要记录对弈的玩家双方信息
  • 记录先手方的 ID
  • 记录一个 二维数组 , 作为对弈的棋盘
  • 记录一个 onlineUserState, 以备后面和客户端进行交互
  • 还有ObjectMapper 来处理 json
@Data
public class Room {private String roomId;// 一个房间里的两个用户private User user1;private User user2;// 谁是先手private int firstUserId;private static final int MAX_ROW = 15;private static final int MAX_COL = 15;// 有三种状态,0 代表没人下,1表示用户1下的,2表示用户2下的private int[][] broad = new int[MAX_ROW][MAX_COL];private ObjectMapper objectMapper = new ObjectMapper();public Room() {// 创建roomIdroomId = UUID.randomUUID().toString();}
}

创建房间管理器

Room 对象会存在很多. 每两个对弈的玩家, 都对应一个 Room 对象.

需要一个管理器对象来管理所有的 Room

创建 RoomManager

  • 使用一个 Hash 表, 保存所有的房间对象, key 为 roomId, value 为 Room 对象
  • 再使用一个 Hash 表, 保存 userId -> roomId 的映射, 方便根据玩家来查找所在的房间
  • 提供增, 删, 查的 API. (查包含两个版本, 基于房间 ID 的查询和基于用户 ID 的查询)
package com.example.java_gobang.component;import com.example.java_gobang.entity.Room;
import org.springframework.stereotype.Component;import java.util.HashMap;@Component
public class RoomManager {// 有两个映射,一个是房间id和房间的映射,一个是用户id和房间id的映射private final HashMap<String, Room> rooms = new HashMap<>();private final HashMap<Integer, String> userIdToRoomId = new HashMap<>();public void addRoom(Room room, int userId1, int userId2) {rooms.put(room.getRoomId(), room);userIdToRoomId.put(userId1, room.getRoomId());userIdToRoomId.put(userId2, room.getRoomId());}public void removeRoom(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(Integer userId) {String roomId = userIdToRoomId.get(userId);if (roomId == null) {return null;}return rooms.get(roomId);}
}

注册到MatchQueue类中

@Component
public class MatchQueue {@Autowiredprivate RoomManager roomManager;
}

实现对战模块

前后端交互接口

连接:

ws://127.0.0.1:8080/game

连接响应:当两个玩家都进入房间,准备好时,返回该响应

{message: 'gameReady', // 游戏就绪ok: true,	// 是否成功reason: '', // 失败原因roomId:	// 进入的房间idthisUserId, // 玩家自己idthatUserId, // 对手idwhiteUserId // 先手玩家id
}

落子请求:每次落子发送的请求

{message: 'putChess', // 请求特征userId: 1, // 下子的玩家idrow: 1, // 下子的行col : 1 // 下子的列
}

落子响应:落子返回的响应, 用于前端的棋子绘制

{message: 'putChess', // 响应特征userId: 1, // 下子玩家idrow: 0, // 棋子的行col: 1, // 棋子的列winUserId: // 获胜玩家id,没有人获胜就返回0
}

客户端开发

创建game_root.html , script.js , game_root.css 分别是房间格式,房间的运行逻辑,房间样式

game_room.html:

DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>游戏房间title><link rel="stylesheet" href="css/common.css"><link rel="stylesheet" href="css/game_room.css"><script src="js/script.js">script>
head>
<body>
<div class="nav">五子棋对战div>
<div class="container"><div><canvas id="chess" width="450px" height="450px">canvas><div id="screen"> 等待玩家连接中... div>div>
div>
body>
html>

game_room.css:

#screen {width: 450px;height: 50px;margin-top: 10px;background-color: #fff;font-size: 22px;line-height: 50px;text-align: center;
}

script.js:

// 存储游戏信息的对象,由玩家都进入房间准备就绪后返回的响应赋值
let gameInfo = {roomId: null, // 该玩家游戏房间idthisUserId: null, // 该玩家自己idthatUserId: null, // 对手玩家idisWhite: true, // 是否为自己先手,true就是自己先手,false就不是自己先手
}// websocket 连接
var webSocketURL = "ws://" + location.host + "/game";
var webSocket = new WebSocket(webSocketURL);// 连接成功执行的方法
webSocket.onopen = function () {console.log("游戏连接成功");
}// 连接发生错误执行的方法
webSocket.onerror = function() {console.log("连接发生错误");
}// 连接关闭执行的方法
webSocket.onclose = function () {console.log("连接关闭");
}// 浏览器窗口关闭来主动关闭WebSocket连接
window.onbeforeunload = function () {webSocket.close();
}// WebSocket连接收到响应后执行的方法,event为收到的响应,需要将负载转换成js对象
webSocket.onmessage = function (event) {var result = JSON.parse(event.data);// 连接失败if (!result.ok) {console.log("连接失败,请重试!原因:" + result.reason);return;}// 判断响应特征if (result.message === "gameReady") {// 初始化信息gameInfo.roomId = result.roomId;gameInfo.thisUserId = result.thisUserId;gameInfo.thatUserId = result.thatUserId;// 如果返回的先手玩家id是自己,就赋值true,反之赋值falsegameInfo.isWhite = (result.firstUserId === result.thisUserId);// 初始化棋盘initGame();// 显示提示栏信息setScreenText(gameInfo.isWhite);} else if (result.message === "repeatConnection") {// 说明玩家多开,加载到登录页面alert(result.reason);location.replace("/login.html");} }// 设定界面显示相关操作,来修改显示栏提示
function setScreenText(me) {let screen = document.querySelector('#screen');if (me) {screen.innerHTML = "轮到你落子了!";} else {screen.innerHTML = "轮到对方落子了!";}
}// 初始化一局游戏
function initGame() {// 是我下还是对方下. 根据服务器分配的先后手情况决定let me = gameInfo.isWhite;// 游戏是否结束let over = false;let chessBoard = [];//初始化chessBord数组(表示棋盘的数组),如果为0表示当前位置可以落子,为1表示当前位置已经有棋子了for (let i = 0; i < 15; i++) {chessBoard[i] = [];for (let j = 0; j < 15; j++) {chessBoard[i][j] = 0;}}// 使用chess标签来绘制棋盘let chess = document.querySelector('#chess');let context = chess.getContext('2d');context.strokeStyle = "#090909";// 背景图片let logo = new Image();logo.src = "img/logo.png";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();}}// 绘制一个棋子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) {// 发送坐标给服务器, 服务器要返回结果send(row, col);}}// 发送落子请求function send(row, col) {var request = {message: "putChess",userId: gameInfo.thisUserId,row: row,col: col};webSocket.send(JSON.stringify(request));}// 上面的onmessage方法是玩家准备好时需要初始化页面执行的方法,现在需要修改该方法来处理落子响应webSocket.onmessage = function(e) {// 处理成js对象var result = JSON.parse(e.data);if (result.message === "No Message") {// 落子请求失败,提醒重试alert("落子失败,请重试!");return;}if (result.message !== "putChess") {console.log("响应类型错误");return;}// 如果落子响应显示落子玩家是自己就绘制自己颜色棋子,不是就绘制对手颜色棋子if (result.userId === gameInfo.thisUserId) {// 自己下子绘制棋子oneStep(result.col, result.row, gameInfo.isWhite);} else if (result.userId === gameInfo.thatUserId) {// 对面下子绘制棋子oneStep(result.col, result.row, !gameInfo.isWhite);} else {// 发生错误console.log("发生错误,绘制棋子错误");return;}// 在前端数组上标记该地方已经落子chessBoard[result.row][result.col] = 1;// 交换双方落子,并修改提示框信息me = !me;setScreenText(me);// 判定游戏是否结束let screenDiv = document.querySelector('#screen');if (result.winUserId !== 0) {// 如果胜利玩家id是自己就提示栏显示你赢了,反之显示你输了if (result.winUserId === gameInfo.thisUserId) {screenDiv.innerHTML = '你赢了!';} else if (result.winUserId === gameInfo.thatUserId) {screenDiv.innerHTML = '你输了!';} else {alert("winner 字段错误! " + result.winUserId);}// 增加一个按钮, 让玩家点击之后, 再回到游戏大厅~let backBtn = document.createElement('button');backBtn.innerHTML = '回到大厅';backBtn.onclick = function() {location.replace('/game_hall.html');}let fatherDiv = document.querySelector('.container>div');fatherDiv.appendChild(backBtn);}}
}

运行效果如下:

image-20230610150953440

左侧为一个用户匹配进入房间,右侧是另一个用户匹配进入房间

落子效果:

image-20230610151235475

服务端开发

创建gameHandler

重写四个方法: afterConnectionEstablished, handleTextMessage, handleTransportError, afterConnectionClosed并注册到WebSocketConfig中来与前端**/game** 对应

注入房间管理器类RoomManager管理房间

注入 OnlineUserState 管理玩家状态

注入UserMapper管理游戏完成后玩家信息的修改

创建ObjectMapper处理json

@Component
public class GameHandler extends TextWebSocketHandler {private final ObjectMapper objectMapper = new ObjectMapper();@Autowiredprivate RoomManager roomManager;@Autowiredprivate OnlineUserState onlineUserState;@Autowiredprivate UserMapper userMapper;// 连接成功后执行的方法@Overridepublic void afterConnectionEstablished(WebSocketSession session) throws Exception {}// 收到请求后处理请求@Overrideprotected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {}// 程序异常执行的方法@Overridepublic void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {}// 程序退出执行的方法@Overridepublic void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {}
}
创建连接响应

连接响应来返回前端存储必要的游戏信息,来进行初始化棋盘

package com.example.java_gobang.entity;import lombok.Data;@Data
public class ConnectResponse {private String message;private boolean ok;private String reason;private String roomId;private int thisUserId;private int thatUserId;// 先手用户idprivate int firstUserId;
}

创建落子请求
package com.example.java_gobang.entity;import lombok.Data;@Data
public class DropsRequest {private String message;private int userId;private int row;private int col;
}

创建落子响应
package com.example.java_gobang.entity;import lombok.Data;@Data
public class DropsResponse {private String message;private int userId;private int row;private int col;// 没有玩家胜利就是 0,玩家一胜利就是 玩家1 id,玩家二胜利就是 玩家2 idprivate int winUserId;
}

处理连接成功
    @Overridepublic void afterConnectionEstablished(WebSocketSession session) throws Exception {User user = (User) session.getAttributes().get(AppVariable.USER_SESSION_KEY);// 先创建一个连接响应对象,后面进行属性赋值然后返回给前端ConnectResponse connectResponse = new ConnectResponse();if (user == null) {// 用户没有登录,返回信息connectResponse.setOk(false);connectResponse.setReason("您当前没有登录,无法进行对战");String response = objectMapper.writeValueAsString(connectResponse);session.sendMessage(new TextMessage(response));return;}// 一个用户登录大厅页面,或登录房间页面,或既然登录大厅页面又登录房间页面就是多开,禁止if (onlineUserState.getSessionHall(user.getId()) != null|| onlineUserState.getSessionRoom(user.getId()) != null) {// 游戏多开connectResponse.setOk(true);connectResponse.setReason("游戏禁止多开");connectResponse.setMessage("repeatConnection");String response = objectMapper.writeValueAsString(connectResponse);session.sendMessage(new TextMessage(response));return;}// 用户状态正常才把用户管理在房间哈希表中onlineUserState.enterSessionRoom(user.getId(), session);Room room = roomManager.getRoomByUserId(user.getId());if (room == null) {// 判断房间是否存在,没有就说明匹配的时候没有让房间和用户id关联起来,也就说明没有匹配成功connectResponse.setOk(false);connectResponse.setReason("当前没有匹配成功,不能进入游戏");String response = objectMapper.writeValueAsString(connectResponse);session.sendMessage(new TextMessage(response));return;}// 注意多线程问题synchronized (room) {if (room.getUser1() == null) {// 房间里用户1是空的,那就添加进去,并设置为先手room.setUser1(user);room.setFirstUserId(user.getId());System.out.println("玩家一准备就绪" + user.getUsername());return;}if (room.getUser2() == null) {room.setUser2(user);System.out.println("玩家二准备就绪" + user.getUsername());// 两个用户都添加进去后,就通知两个人就绪// 第一个通知用户一,thisUser是user1,thatUser是user2noticeGameReady(room, room.getUser1().getId(), room.getUser2().getId());// 第二个通知用户二,thisUser是user2,thatUser是user2noticeGameReady(room, room.getUser2().getId(), room.getUser1().getId());return;}}// 用户一和用户二都不为空就是房间满了,需要返回信息提醒用户三connectResponse.setOk(false);connectResponse.setReason("房间满了,当前不能对战");String response = objectMapper.writeValueAsString(connectResponse);session.sendMessage(new TextMessage(response));}@SneakyThrowsprivate void noticeGameReady(Room room, int thisUserId, int thatUserId) {ConnectResponse connectResponse = new ConnectResponse();connectResponse.setOk(true);connectResponse.setMessage("gameReady");connectResponse.setRoomId(room.getRoomId());connectResponse.setThisUserId(thisUserId);connectResponse.setThatUserId(thatUserId);connectResponse.setFirstUserId(room.getFirstUserId());String response = objectMapper.writeValueAsString(connectResponse);WebSocketSession session = onlineUserState.getSessionRoom(thisUserId);session.sendMessage(new TextMessage(response));}

处理程序出现异常和连接关闭
	@Overridepublic void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {// 程序异常,需要删除玩家在状态hash中的sessionUser user = (User) session.getAttributes().get(AppVariable.USER_SESSION_KEY);if (user == null) {// 得不到就直接返回return;}WebSocketSession tmpSession = onlineUserState.getSessionRoom(user.getId());if (tmpSession == session) {// 只有从状态hash中得到的session和从前端传来的session相等时,才删除状态hash中的sessiononlineUserState.exitSessionRoom(user.getId());}// 通知对手获胜onticeThatWin(user);}@Overridepublic void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {// 程序异常,需要删除玩家在状态hash中的sessionUser user = (User) session.getAttributes().get(AppVariable.USER_SESSION_KEY);if (user == null) {return;}WebSocketSession tmpSession = onlineUserState.getSessionRoom(user.getId());if (tmpSession == session) {// 只有从状态hash中得到的session和从前端传来的session相等时,才删除状态hash中的sessiononlineUserState.exitSessionRoom(user.getId());}// 通知对手获胜onticeThatWin(user);}@SneakyThrowsprivate void onticeThatWin(User user) {// 通过掉线用户得到房间Room room = roomManager.getRoomByUserId(user.getId());if (room == null) {// 房间已经销毁System.out.println("房间已经销毁");return;}// 通过房间来得到对手User thatUser = (user == room.getUser1() ? room.getUser2() : room.getUser1());WebSocketSession session = onlineUserState.getSessionRoom(thatUser.getId());if (session == null) {// 说明对手也掉线了,当前对局作废System.out.println("当前对局作废");return;}// 因为是落子期间掉线,所以构建一个落子响应DropsResponse dropsResponse = new DropsResponse();dropsResponse.setMessage("putChess");// 胜利的是掉线玩家的对手dropsResponse.setWinUserId(thatUser.getId());// 返回给掉线玩家对手页面展示dropsResponse.setUserId(thatUser.getId());String response = objectMapper.writeValueAsString(dropsResponse);session.sendMessage(new TextMessage(response));// 胜方败方数据修改int winId = thatUser.getId();int loseId = user.getId();userMapper.userWinUpdate(winId);userMapper.userLoseUpdate(loseId);// 从房间管理器中删除该房间roomManager.removeRoom(room.getRoomId(), room.getUser1().getId(), room.getUser2().getId());}

处理收到落子请求
	@Overrideprotected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {User user = (User) session.getAttributes().get(AppVariable.USER_SESSION_KEY);ConnectResponse connectResponse = new ConnectResponse();if (user == null) {// 玩家不在房间页面了connectResponse.setOk(false);connectResponse.setReason("用户未登录");String response = objectMapper.writeValueAsString(connectResponse);session.sendMessage(new TextMessage(response));return;}// 通过该玩家id寻找房间Room room = roomManager.getRoomByUserId(user.getId());if (room == null) {// 房间为空,说明没有创建房间在房间管理器中,System.out.println("房间已空");return;}// 进行下棋room.putChess(message.getPayload());}

修改Room

因为在游戏中有很多房间实例,所以我们不能再Room中使用自动注入,因为这样也要在Room类上添加注入注解,所以我们使用手

动注入来注入

JavaGobangApplication启动类代码

package com.example.java_gobang;import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;@SpringBootApplication
public class JavaGobangApplication {public static ConfigurableApplicationContext context;public static void main(String[] args) {context = SpringApplication.run(JavaGobangApplication.class, args);}}
@Data
public class Room {// 房间idprivate String roomId;// 一个房间里的两个用户private User user1;private User user2;// 谁是先手private int firstUserId;// 棋盘的  行 列private static final int MAX_ROW = 15;private static final int MAX_COL = 15;// 有三种状态,0 代表没人下,1表示用户1下的,2表示用户2下的private int[][] broad = new int[MAX_ROW][MAX_COL];private ObjectMapper objectMapper = new ObjectMapper();// 三个手动注入的属性private OnlineUserState onlineUserState;private RoomManager roomManager;private UserMapper userMapper;@SneakyThrowspublic void putChess(String requestJSON) {// 处理json字符串成落子请求对象DropsRequest dropsRequest = objectMapper.readValue(requestJSON, DropsRequest.class);// 检查请求特征if ("putChess".equals(dropsRequest.getMessage())) {// 得到当前棋子的状态,如果是玩家一下的,棋盘上就赋值 1, 玩家二下的,棋盘上就赋值 2,用来区分不同玩家的棋子int chess = dropsRequest.getUserId() == user1.getId() ? 1 : 2;int row = dropsRequest.getRow();int col = dropsRequest.getCol();// 当前玩家已经有棋子了if (broad[row][col] != 0) {System.out.println("当前位置已经有子了 + row: " + row + " col: " + col);return;}broad[row][col] = chess;// 检查胜利信息,0 代表无人胜利,int winner = checkBroadSuccess(row, col, chess);// 返回棋盘打印棋子信息WebSocketSession session1 = onlineUserState.getSessionRoom(user1.getId());WebSocketSession session2 = onlineUserState.getSessionRoom(user2.getId());// 创建一个落子响应DropsResponse dropsResponse = new DropsResponse();dropsResponse.setMessage("putChess");dropsResponse.setRow(row);dropsResponse.setCol(col);// 胜利人iddropsResponse.setWinUserId(winner);dropsResponse.setUserId(dropsRequest.getUserId());if (session1 == null) {// 玩家一掉线,判断玩家二胜利dropsResponse.setWinUserId(user1.getId());}if (session2 == null) {// 玩家二掉线,判断玩家一胜利dropsResponse.setWinUserId(user2.getId());}if (session1 != null) {// 如果玩家一在线就发给玩家一String response = objectMapper.writeValueAsString(dropsResponse);session1.sendMessage(new TextMessage(response));}if (session2 != null) {// 玩家二在下就发给玩家二String response = objectMapper.writeValueAsString(dropsResponse);session2.sendMessage(new TextMessage(response));}if (dropsResponse.getWinUserId() != 0) {// 游戏分出胜负,房间销毁 ,修改胜方败方数据roomManager.removeRoom(roomId, user1.getId(), user2.getId());int winId = dropsResponse.getWinUserId();int loseId = dropsResponse.getWinUserId() == user2.getId() ? user1.getId() : user2.getId();userMapper.userWinUpdate(winId);userMapper.userLoseUpdate(loseId);}} else {// 请求不是putChess,发生错误,提醒客户端WebSocketSession session = onlineUserState.getSessionRoom(dropsRequest.getUserId());if (session == null) {// 掉线return;}DropsResponse dropsResponse = new DropsResponse();dropsResponse.setMessage("No Message");session.sendMessage(new TextMessage(objectMapper.writeValueAsString(dropsResponse)));}}private int checkBroadSuccess(int row, int col, int chess) {// 首先判定横着五个点for (int c = col - 4; c <= col; c++) {try {if (broad[row][c] == chess&& broad[row][c + 1] == chess&& broad[row][c + 2] == chess&& broad[row][c + 3] == chess&& broad[row][c + 4] == chess) {return chess == 1 ? user1.getId() : user2.getId();}} catch (ArrayIndexOutOfBoundsException e) {continue;}}// 判断五个竖着的for (int r = row - 4; r <= row; r++) {try {if (broad[r][col] == chess&& broad[r + 1][col] == chess&& broad[r + 2][col] == chess&& broad[r + 3][col] == chess&& broad[r + 4][col] == chess) {return chess == 1? user1.getId() : user2.getId();}} catch (ArrayIndexOutOfBoundsException e) {continue;}}// 判断五个\这样的for (int r = row - 4, c = col - 4; r <= row && c <= col; r++, c++) {try {if (broad[r][c] == chess&& broad[r + 1][c + 1] == chess&& broad[r + 2][c + 2] == chess&& broad[r + 3][c + 3] == chess&& broad[r + 4][c + 4] == chess) {return chess == 1? user1.getId() : user2.getId();}} catch (ArrayIndexOutOfBoundsException e) {continue;}}// 判断五个/这样的for (int r = row + 4, c = col - 4; r <= row && c <= col; r++, c++) {try {if (broad[r][c] == chess&& broad[r + 1][c - 1] == chess&& broad[r + 2][c - 2] == chess&& broad[r + 3][c - 3] == chess&& broad[r + 4][c - 4] == chess) {return chess == 1? user1.getId() : user2.getId();}} catch (ArrayIndexOutOfBoundsException e) {continue;}}// 当前没有人获胜return 0;}public Room() {// 创建roomIdroomId = UUID.randomUUID().toString();// 手动注入属性onlineUserState = JavaGobangApplication.context.getBean(OnlineUserState.class);roomManager = JavaGobangApplication.context.getBean(RoomManager.class);userMapper = JavaGobangApplication.context.getBean(UserMapper.class);}
}

匹配,对战落子及一方胜利样例

游戏大厅界面
在这里插入图片描述

其中一方正在匹配
在这里插入图片描述
另一方进行匹配后进入房间,中途落子

在这里插入图片描述

一方胜利
在这里插入图片描述

回到大厅个人信息变化

在这里插入图片描述


本文来自互联网用户投稿,文章观点仅代表作者本人,不代表本站立场,不承担相关法律责任。如若转载,请注明出处。 如若内容造成侵权/违法违规/事实不符,请点击【内容举报】进行投诉反馈!

相关文章

立即
投稿

微信公众账号

微信扫一扫加关注

返回
顶部