从0到1开发网页版五子棋:我的Java实战之旅
目录
一、项⽬背景
二、关键技术
三、WebSocket
1.引入
2.websocket握手过程(建立连接的过程)
四、需求分析和概要设计
1.用户模块
2.匹配模块
3.对战模块
五、项目实现
1.创建项目
2.用户模块
设计数据库
配置 MyBatis
创建实体类
创建 UserMapper
前后端交互接⼝
客户端开发
服务器开发
3.匹配模块
前后端交互接口
客户端开发
服务器开发
1.创建并注册 MatchAPI 类
2.实现⽤户管理器
3.创建匹配请求/响应对象
4.处理连接成功
5.处理开始匹配/取消匹配请求
6.创建房间类
7.创建房间管理器
8.处理连接关闭
9.处理连接异常
4.对战模块
前后端交互接口
客户端开发
服务器开发
1.创建并注册 GameAPI 类
2.创建落⼦请求/响应对象
3.处理连接成功
4.玩家下线的处理
5.修改 Room 类
6.处理落⼦请求
7.实现对弈功能
8.实现打印棋盘的逻辑
9.实现胜负判定
10.处理玩家中途退出
六、总结
一、项⽬背景
为了实现五子棋在线对战功能,我使用 Java 开发了一款低延迟、易上手的网页版五子棋游戏。目标是让用户打开浏览器即可秒匹配对手,享受流畅的对战体验,并能够记录战绩,在不断对弈中提升棋艺。
⽀持以下核⼼功能:
• ⽤户模块: ⽤户注册, ⽤户登录, ⽤户天梯分数记录, ⽤户⽐赛场次记录。
• 匹配模块: 按照⽤户的天梯分数实现匹配机制。
• 对战模块: 实现两个玩家在⽹⻚端进⾏五⼦棋对战的功能。
二、关键技术
Java,Spring/Spring Boot/Spring MVC,HTML/CSS/JS/AJAX,MySQL/MyBatis,WebSocket
三、WebSocket
1.引入
之前学的服务器开发模型大部分:客户端主动向服务器发送请求,服务器收到之后返回一个响应,如果客户端不主动发起请求,服务器不能主动联系客户端。我们也需要服务器主动给客户端发消息这样的场景-------"消息推送" (WebSocket)。
当前已有的知识,主要是HTTP,HTTP自身难以实现这种消息推送的效果的, HTTP想要实现这种效果,就需要基于"轮询"的机制。
很明显,像这样的轮询操作,开销是比较大的,成本也是比较高的。 如果轮询间隔时间长,玩家1落子之后,玩家2不能及时的拿到结果。如果轮询间隔时间短,虽然即时性得到改善,但是玩家2不得不浪费更多的机器资源(尤其是带宽)。
所以我引入了WebSocket协议,它就像在客户端和服务器之间架了一条「专用高速路」:
-
一次连接,持续通信 :连接建立后可以双向实时传消息,延迟轻松控制在100ms内。
-
支持主动推送 :服务器能直接给客户端发消息(比如对手落子了),不用等客户端来问。
2.websocket握手过程(建立连接的过程)
在网页端尝试与服务器建立 WebSocket 连接时,首先会向服务器发送一个 HTTP 请求。这个请求中包含两个特殊的请求头:
Connection: Upgrade
Upgrade: WebSocket
这两个请求头的作用是告知服务器:客户端希望将当前连接从 HTTP 协议升级为 WebSocket 协议。
如果服务器支持 WebSocket,就会返回一个状态码为 101 Switching Protocols 的响应,表示同意协议切换。自此,客户端与服务器之间便通过 WebSocket 进行双向通信,实现实时数据传输。
四、需求分析和概要设计
整个项⽬分成以下模块:⽤户模块、匹配模块、对战模块
1.用户模块
用户模块主要负责用户的注册、登录和分数记录功能。客户端提供一个统一的登录与注册页面,方便用户进行身份验证和信息管理。服务器端基于 Spring + MyBatis 技术栈实现数据库的增删改查操作,并使用 MySQL 数据库存储用户数据,确保用户信息的安全性和完整性。
2.匹配模块
匹配模块在用户成功登录后启动,用户将进入游戏大厅页面,在这里可以看到自己的名字、天梯分数、比赛场数和获胜场数等信息。页面上有一个“匹配按钮”,点击该按钮后,用户会被加入匹配队列,界面上显示为“取消匹配”。再次点击则从匹配队列中移除。如果匹配成功,用户将被跳转至游戏房间页面。页面加载时会与服务器建立 WebSocket 连接,双方通过 WebSocket 传输“开始匹配”、“取消匹配”、“匹配成功”等信息,确保实时通信的顺畅。
3.对战模块
对战模块在玩家匹配成功后启动,用户将进入游戏房间页面,每两个玩家共享同一个游戏房间。在游戏房间页面中,能够显示五子棋棋盘,玩家通过点击棋盘上的位置实现落子功能。当出现五子连珠时,系统自动触发胜负判定,并显示“你赢了”或“你输了”的提示信息。页面加载时同样与服务器建立 WebSocket 连接,双方通过 WebSocket 传输“准备就绪”、“落子位置”、“胜负”等信息,确保对局过程中的实时同步和流畅体验。
五、项目实现
1.创建项目
使⽤ IDEA 创建 SpringBoot 项⽬。引⼊依赖如下:依赖都是常规的 SpringBoot / Spring MVC / MyBatis 等, 没啥特别的依赖。
2.用户模块
设计数据库
用户模块的数据库设计主要围绕 user
表展开,用于存储用户的基本信息和战绩数据。表中包含用户的唯一标识 userId
(主键,自增),用户名 username
(唯一)、密码 password
,以及天梯分数 score
、比赛总场次 totalCount
和获胜场次 winCount
。这些字段能够支持登录注册、匹配积分、胜负统计等核心功能,结构清晰、扩展性强,为后续实现排行榜等功能打下良好基础。
CREATE TABLE user (userId INT PRIMARY KEY AUTO_INCREMENT,username VARCHAR(50) UNIQUE,password VARCHAR(50),score INT, -- 天梯分数totalCount INT, -- 比赛总场次winCount INT -- 获胜场次
);
配置 MyBatis
连接并且操作数据库,修改Spring的配置文件,使得数据库可以被连接上。
创建实体类
public class User {private int userId;private String userName;private String password;private int score;private int totalCount;private int winCount;
}
创建 UserMapper
创建 model.UserMapper 接⼝。
此处主要提供四个⽅法:
• selectByName: 根据⽤户名查找⽤户信息. ⽤于实现登录
• insert: 新增⽤户. ⽤户实现注册
• userWin: ⽤于给获胜玩家修改分数
• userLose: ⽤户给失败玩家修改分数
@Mapper
public interface UserMapper {User selectByName(String username);int insert(User user);void userWin(User user); void userLose(User user);
}
根据此创建UserMapper.xml,实现具体的数据库的相关操作。
前后端交互接⼝
需要明确⽤户模块的前后端交互接⼝.。这⾥主要涉及到三个部分,登录接口,注册接口,获取用户信息接口。
以登录接口为例
请求:post/login HTTP/1.1Content-Type:application/x-www-form-urlencodedusername=zhangsan&password=123响应:HTTP/1.1 200 OK //如果登录失败,就返回一个无效的user对象,{ //比如,这里的每个属性都是空着的,像userIdusrId:1,username:'zhangsan',score:1000,totalCount:0,winCount:0}
客户端向服务器发送 POST 请求至 /login
接口,请求头中指定了 Content-Type: application/x-www-form-urlencoded
,表示以表单形式提交数据,请求体为 username=zhangsan&password=123
,用于用户登录验证。服务器接收到请求后会校验用户名和密码,若验证成功,则返回状态码 200 和包含用户信息的 JSON 数据,如用户 ID、用户名、天梯分数、比赛总场次和获胜场次等;如果登录失败,则同样返回 200 状态码,但在响应的 JSON 中返回一个“无效”的 User 对象,所有字段为空或默认值,表示登录未成功。
这个前后端交互的接口,在约定的时候,是有多种交互方式的,这里约定好了之后,后续的后端/前端代码,都要严格遵守这个约定来写代码。
客户端开发
登录界面
注册界面
服务器开发
主要实现三个⽅法:
• login: ⽤来实现登录逻辑
public Object login(String username, String password, HttpServletRequest req) {User user = userMapper.selectByName(username);System.out.println("login! user=" + user);if (user == null || !user.getPassword().equals(password)) {return new User();}HttpSession session = req.getSession(true);session.setAttribute("user", user);return user;}
• register: ⽤来实现注册逻辑
public Object register(String username, String password) {User user = null;try {user = new User();user.setUsername(username);user.setPassword(password);System.out.println("register! user=" + user);int ret = userMapper.insert(user);System.out.println("ret: " + ret);} catch (org.springframework.dao.DuplicateKeyException e) {user = new User();}return user;}
• getUserInfo: ⽤来实现登录成功后显⽰⽤⼾分数的信息
public Object getUserInfo(HttpServletRequest req) {// 从 session 中拿到用户信息HttpSession session = req.getSession(false);if (session == null) {return new User();}User user = (User) session.getAttribute("user");if (user == null) {return new User();}return user;}
3.匹配模块
让多个用户在游戏大厅内进行匹配,系统会把实力相近的两个玩家凑成一桌,进行对战。
前后端交互接口
匹配这样的功能,也是依赖消息推送机制的。
当玩家点击匹配按钮时,客户端会立即向服务器发送匹配请求。由于匹配成功的时间不确定,服务器无法在请求发送后立即返回结果,因此需要依赖 WebSocket 建立的实时通信机制,由服务器在匹配成功后主动推送消息给客户端。整个过程采用 JSON 格式的文本数据通过 WebSocket 传输,前后端交互清晰高效,确保了匹配结果的实时通知和良好的用户体验。
匹配请求:
客户端通过websocket给服务器发送一个json格式的文本数据
ws://127.0.0.1:8080/findMatch
{message:'startMatch'/'stopMatch',//开始/结束匹配
}
/*在通过websocket传输请求数据时,数据中是不必带有用户身份信息,当前用户的身份信息,在前面登录完成之后,就已经保存到HttpSession中了,websocket里,也是能拿到之前登录好的Httpsession中的信息的*/
匹配响应1:
ws://127.0.0.1:8080/findMatch
{OK:true,//匹配成功reason:'',//匹配如果失败,失败原因的信息message:'startMatch'/'stopMatch',
}
/*这个响应是客户端给服务器发送服务匹配请求后,服务器立刻返回的匹配响应*/
匹配响应2:
ws://127.0.0.1:8080/findMatch
{OK:true,//匹配成功reason:'',//匹配如果失败,失败原因的信息message:'matchSuccess',
}
/*这个是真正匹配到对手之后,服务器主动推送回来的消息
匹配到的对手不需要在这个响应中体现,仍然都放到服务器这边保存即可*/
客户端开发
游戏大厅
实现匹配功能
• 点击匹配按钮,就会进⼊匹配逻辑.。同时按钮上提⽰ "匹配中...(点击取消)" 字样。
• 再次点击匹配按钮,则会取消匹配。
• 当匹配成功后,服务器会返回匹配成功响应,⻚⾯跳转到 游戏房间 。
服务器开发
1.创建并注册 MatchAPI 类
创建 api.MatchAPI,继承⾃ TextWebSocketHandler 作为处理 websocket 请求的⼊⼝类。同时准备好⼀个 ObjectMapper,后续⽤来处理 JSON 数据。
@Component
public class MatchAPI extends TextWebSocketHandler {private 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 {}
}
2.实现⽤户管理器
⽤于管理当前⽤户的在线状态。本质上是 哈希表 的结构。key为⽤户 id,value 为⽤户的 WebSocketSession。借助这个类,⼀⽅⾯可以判定⽤户是否是在线,同时也可以进⾏⽅便的获取到 Session 从⽽给客户端回话。
• 当玩家建⽴好 websocket 连接,则将键值对加⼊ OnlineUserManager 中。
• 当玩家断开 websocket 连接,则将键值对从 OnlineUserManager 中删除。
• 在玩家连接好的过程中,随时可以通过 userId 来查询到对应的会话,以便向客⼾端返回数据。
由于存在两个⻚⾯,游戏⼤厅和游戏房间,使⽤两个 哈希表 来分别存储两部分的会话。
@Component
public class OnlineUserManager {private ConcurrentHashMap<Integer, WebSocketSession> gameHall = new ConcurrentHashMap<>();private ConcurrentHashMap<Integer, WebSocketSession> gameRoom = new ConcurrentHashMap<>();
public void enterGameHall(int userId, WebSocketSession session) {gameHall.put(userId, session);}
// 只有当前页面退出的时候,能销毁自己的 session// 避免当一个 userId 打开两次游戏页面,错误的删掉之前的会话的问题.public void exitGameHall(int userId) {gameHall.remove(userId);}
public WebSocketSession getSessionFromGameHall(int userId) {return gameHall.get(userId);}
public void enterGameRoom(int userId, WebSocketSession session) {gameRoom.put(userId, session);}
public void exitGameRoom(int userId) {gameRoom.remove(userId);}
public WebSocketSession getSessionFromGameRoom(int userId) {return gameRoom.get(userId);}
}
// 给 MatchAPI 注入 OnlineUserManager
@Component
public class MatchAPI extends TextWebSocketHandler {@Autowiredprivate OnlineUserManager onlineUserManager;
}
3.创建匹配请求/响应对象
//创建 game.MatchRequest 类
public class MatchRequest {private String message = "";
}
// 创建 game.MatchResponse 类
public class MatchResponse {private boolean ok = true;private String reason = "";private String message = "";
}
4.处理连接成功
• 通过参数中的 session 对象, 拿到之前登录时设置的 User 信息。
• 使⽤ onlineUserManager 来管理⽤⼾的在线状态。
• 先判定⽤户是否是已经在线,如果在线则直接返回出错 (禁⽌同⼀个账号多开)。
• 设置玩家的上线状态。
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {// 1. 拿到用户信息.User user = (User) session.getAttributes().get("user");if (user == null) {// 拿不到用户的登录信息,说明玩家未登录就进入游戏大厅了.// 则返回错误信息并关闭连接MatchResponse response = new MatchResponse();response.setOk(false);response.setReason("玩家尚未登录!");session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));return;}// 2. 检查玩家的上线状态if (onlineUserManager.getSessionFromGameHall(user.getUserId()) != null || onlineUserManager.getSessionFromGameRoom(user.getUserId()) != null) {MatchResponse response = new MatchResponse();response.setOk(false);response.setReason("禁止多开游戏大厅页面!");session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));return;}// 3. 设置玩家上线状态onlineUserManager.enterGameHall(user.getUserId(), session);System.out.println("玩家进入匹配页面: " + user.getUserId());
}
5.处理开始匹配/取消匹配请求
a.实现 handleTextMessage
• 先从会话中拿到当前玩家的信息。
• 解析客⼾端发来的请求。
• 判定请求的类型,如果是 startMatch,则把⽤⼾对象加⼊到匹配队列。如果是 stopMatch,则把⽤⼾对象从匹配队列中删除。
• 此处需要实现⼀个 匹配器 对象,来处理匹配的实际逻辑。
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {//实现处理开始匹配请求和处理停止匹配请求User user = (User) session.getAttributes().get("user");//获取到客户端给服务器发送的数据String payload = message.getPayload();MatchRequset requset = objectMapper.readValue(payload, MatchRequset.class);MatchResponse response = new MatchResponse();if (requset.getMessage().equals("startMatch")) {//进入匹配队列//TODO 先创建一个类表示匹配队列,把当前用户加进去matcher.add(user);//把玩家信息放入匹配队列之后,就可以返回一个响应给客户端了response.setOk(true);response.setMessage("startMatch");} else if (requset.getMessage().equals("stopMatch")) {//退出匹配队列//TODO 先创建一个类表示匹配队列,把当前用户移除matcher.remove(user);//把玩家信息放入匹配队列之后,就可以返回一个响应给客户端了response.setOk(true);response.setMessage("stopMatch");} else {//非法情况response.setOk(false);response.setReason("非法的匹配请求");}String jsonString = objectMapper.writeValueAsString(response);session.sendMessage(new TextMessage(jsonString));}
b.实现匹配器
• 在 Matcher 中创建三个队列 (队列中存储 User 对象),分别表⽰不同的段位的玩家。(此处约定 <2000⼀档、2000-3000⼀档、3000⼀档>)。
//创建三个匹配队列private Queue<User> normalQueue = new LinkedList<>();private Queue<User> highQueue = new LinkedList<>();private Queue<User> veryHighQueue = new LinkedList<>();
• 提供 add ⽅法,供 MatchAPI 类来调⽤,⽤来把玩家加⼊匹配队列。
//操作匹配队列的方法//把玩家放到匹配队列中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 中!");}}
• 提供 remove ⽅法,供 MatchAPI 类来调⽤,⽤来把玩家移出匹配队列。
//当玩家点击停止匹配是,就需要将玩家从匹配队列中删除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 中删除!");}}
• 同时 Matcher 找那个要记录 OnlineUserManager, 来获取到玩家的 Session。
• 在 Matcher 的构造⽅法中,创建⼀个线程,使⽤该线程扫描每个队列,把每个队列的头两个元素取出来,匹配到⼀组中。
public Matcher() {//创建三个线程,分别针对三个匹配队列,进行操作Thread t1 = new Thread() {@Overridepublic void run() {//扫描normalQueuewhile (true) {handlermatch(normalQueue);}}};t1.start();Thread t2 = new Thread() {@Overridepublic void run() {//扫描highQueuewhile (true) {handlermatch(highQueue);}}};t2.start();Thread t3 = new Thread() {@Overridepublic void run() {//扫描veryHighQueuewhile (true) {handlermatch(veryHighQueue);}}};t3.start();}
c.实现 handlerMatch
• 由于 handlerMatch 在单独的线程中调⽤。因此要考虑到访问队列的线程安全问题。需要加上锁。
• 每个队列分别使⽤队列对象本⾝作为锁即可。
• 在⼊⼝处使⽤ wait 来等待,直到队列中达到 2 个元素及其以上,才唤醒线程消费队列。
private void handlermatch(Queue<User> matchQueue) {synchronized (matchQueue) {try {//1.检测队列中元素个数是否达到2//队列的初始情况可能是空。// 如果往队列中添加一个元素,这个时候,仍然是不能进行后续匹配操作的。// 因此在这里使用while循环检查是更合理的~while (matchQueue.size() < 2) {matchQueue.wait();}//2.尝试从队列中取出两个玩家User player1 = matchQueue.poll();User player2 = matchQueue.poll();System.out.println("匹配出两个玩家: " + player1.getUserName() + "," + player2.getUserName());
//3.获取到玩家的websocket的会话//获取到会话的目的是为了告诉玩家,你排到了WebSocketSession session1 = onlineUserManager.getFromGameHall(player1.getUserId());WebSocketSession session2 = onlineUserManager.getFromGameHall(player2.getUserId());//理伦上来说,匹配队列中的元素一定处于在线的状态//我们前面的逻辑已经判断过,当玩家断开连接的时候就已经把他从匹配队列移除了//但是仍然进行一次判定if (session1 == null) {//如果玩家1现在不在线,就把玩家2重新放回到匹配队列matchQueue.offer(player2);return;}if (session2 == null) {//如果玩家2现在不在线,就把玩家1重新放回到匹配队列matchQueue.offer(player1);return;}//当前能否排到两个玩家是同一个用户的情况嘛?一个玩家入队列了两次?理论上也不会存在~~//1)如果玩家下线,就会对玩家移出匹配队列。//2)又禁止写玩家多开//但是仍然这里多进行一次判定,以免前面的逻辑出现bug是带来严重的后果if (session1 == session2) {//把其中的一个玩家返回匹配队列matchQueue.offer(player1);return;}//4. 把这两个玩家放到一个游戏房间中Room room = new Room();roomManager.add(room, player1.getUserId(), player2.getUserId());//5.给玩家反馈信息,通过websocket返回一个message为'matchSuccess'这样的响应//此处是要给两个玩家都返回"匹配成功"这样的信息,需要返回两次MatchResponse response1 = new MatchResponse();response1.setOk(true);response1.setMessage("matchSuccess");session1.sendMessage(new TextMessage(objectMapper.writeValueAsString(response1)));
MatchResponse response2 = new MatchResponse();response2.setOk(true);response2.setMessage("matchSuccess");session2.sendMessage(new TextMessage(objectMapper.writeValueAsString(response2)));} catch (IOException | InterruptedException e) {e.printStackTrace();}}}
注意:需要给上⾯的插⼊队列元素,删除队列元素等也加上锁,插⼊成功后要通知唤醒上⾯的等待逻辑。
6.创建房间类
UUID表示"世界上唯一的身份标识"。通过一系列的算法,能够生成一串字符串(一组十六进制表示的数字)。两次调用这个算法,生成的这个字符串都是不相同的。任意次调用,每次得到的结果都不相同,UUID内部具体如何实现的(算法实现细节)不去深究,Java中直接有现成的类,可以帮我们一下就生成一个 UUID。
//这个类就表示一个游戏房间
public class Room {//使用字符串类型来表示,方便生成唯一值.private String roomId;private User user1;private User user2;public Room() {//构造room得时候生成唯一字符串来表示房间id//使用UUID来作为房间idroomId = UUID.randomUUID().toString();}
}
7.创建房间管理器
Room 对象会存在很多,每两个对弈的玩家,都对应⼀个 Room 对象。需要⼀个管理器对象来管理所有的 Room,创建 game.RoomManager。
• 使⽤⼀个 Hash 表,保存所有的房间对象,key 为 roomId,value 为 Room 对象。
• 再使⽤⼀个 Hash 表,保存 userId -> roomId 的映射,⽅便根据玩家来查找所在的房间。
• 提供增、删、查的 API。(查包含两个版本,基于房间 ID 的查询和基于⽤⼾ ID 的查询)。
//房间管理器类,这个类也希望有唯一实例
@Component
public class RoomManager {private ConcurrentHashMap<String, Room> rooms = new ConcurrentHashMap<>();private ConcurrentHashMap<Integer, String> userIdToRoomId = new ConcurrentHashMap<>();//添加public void add(Room room,int userId1,int userId2) {rooms.put(room.getRoomId(), room);userIdToRoomId.put(userId1,room.getRoomId());userIdToRoomId.put(userId2,room.getRoomId());}//删除public void remove(String roomId,int userId1,int userId2) {rooms.remove(roomId);userIdToRoomId.remove(userId1);userIdToRoomId.remove(userId2);}//查找roomid获取roompublic Room getRoomByRoomId(String roomId) {return rooms.get(roomId);}//查找userid获取roompublic Room getRoomByUserId(int userId) {String roomId = userIdToRoomId.get(userId);if (roomId == null) {//userid->roomid映射关系不存在,直接返回nullreturn null;}return rooms.get(roomId);}
}
8.处理连接关闭
实现 afterConnectionClosed
• 主要的⼯作就是把玩家从 onlineUserManager 中退出。
• 退出的时候要注意判定,当前玩家是否是多开的情况(⼀个userId,对应到两个 websocket 连接)。 如果⼀个玩家开启了第⼆个 websocket 连接,那么这第⼆个 websocket 连接不会影响到玩家从OnlineUserManager 中退出。
• 如果玩家当前在匹配队列中,则直接从匹配队列⾥移除。
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {//玩家下线,从onlineUserManager中删除try {User user = (User) session.getAttributes().get("user");WebSocketSession tmpSession = onlineUserManager.getFromGameHall(user.getUserId());if(tmpSession == session) {onlineUserManager.exitGameHall(user.getUserId());}//如果玩家正在匹配中,而websocket连接断开了,就应该移除匹配对列matcher.remove(user);//System.out.println("玩家 " + user.getUserName() + " 退出了游戏大厅!");} catch (NullPointerException e) {System.out.println("[MatchAPI.afterConnectionClosed] 当前用户未登录!");//e.printStackTrace();//出现空指针异常,说明当前用户的身份信息为空,用户未登录//返回信息,用户尚未登录//以下代码不应该在连接关闭之后,还尝试发送消息给客户端//MatchResponse response = new MatchResponse();//response.setOk(false);//response.setReason("您尚未登录,不能进行后续的匹配功能!");//session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));}}
9.处理连接异常
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {//玩家下线,从onlineUserManager中删除try {User user = (User) session.getAttributes().get("user");WebSocketSession tmpSession = onlineUserManager.getFromGameHall(user.getUserId());if(tmpSession == session) {onlineUserManager.exitGameHall(user.getUserId());}//如果玩家正在匹配中,而websocket连接断开了,就应该移除匹配对列matcher.remove(user);//System.out.println("玩家 " + user.getUserName() + " 退出了游戏大厅!");} catch (NullPointerException e) {System.out.println("[MatchAPI.handleTransportError] 当前用户未登录!");//e.printStackTrace();//出现空指针异常,说明当前用户的身份信息为空,用户未登录//返回信息,用户尚未登录//MatchResponse response = new MatchResponse();//response.setOk(false);//response.setReason("您尚未登录,不能进行后续的匹配功能!");//session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));}}
4.对战模块
前后端交互接口
1.建立连接响应
服务器要生成一些游戏的初始信息,通过这个响应告诉客户端。
2.针对落子的请求和响应
请求:{ //建议大家使用 行 和 列 而不要用 x 和 ymessage:'putChess', row => yuserId:1, col => xrow:0, //后面的代码中需要使用二维数组col:0, //来表示这个棋盘,通过下标取二维数组(row,col)//如果使用x,y就变成了(y,x)}响应:{message:'putChess',userId:1,row:0,col:0,winner:0}
客户端开发
对战房间
其中的棋盘代码基于 canvas API(找资料所得)。其中的发送落子请求,处理落子响应等在这里不做过多介绍。
服务器开发
1.创建并注册 GameAPI 类
创建 api.GameAPI,处理 websocket 请求。
• 这⾥准备好⼀个 ObjectMapper
• 同时注⼊⼀个 RoomManager 和 OnlineUserMananger
@Component
public class GameAPI extends TextWebSocketHandler {private ObjectMapper objectMapper = new ObjectMapper();
@Autowiredprivate RoomManager roomManager;
// 这个是管理 game 页面的会话@Autowiredprivate OnlineUserManager onlineUserManager;
@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 {}
}
2.创建落⼦请求/响应对象
这部分内容要和约定的前后端交互接⼝匹配。
GameReadyResponse 类
public class GameReadyResponse {private String message = "gameReady";private boolean ok = true;private String reason = "";private String roomId = "";private int thisUserId = 0;private int thatUserId = 0;private int whiteUserId = 0;
}
GameRequest 类
public class GameRequest {private String message = "putChess";private int userId;private int row;private int col;
}
GameResponse 类
public class GameResponse {private String message = "putChess";private int userId;private int row;private int col;private int winner; // 胜利玩家的 userId
}
注意,为了使 message
字段能够被 Jackson 正确序列化,需要为它提供相应的 getter 和 setter 方法。
3.处理连接成功
实现 GameAPI 的 afterConnectionEstablished ⽅法
• ⾸先需要检测⽤⼾的登录状态,从 Session 中拿到当前⽤⼾信息。
• 然后要判定当前玩家是否是在房间中。
• 接下来进⾏多开判定,如果玩家已经在游戏中,则不能再次连接。
• 把两个玩家放到对应的房间对象中,当两个玩家都建⽴了连接,房间就放满了.这个时候通知两个玩家双⽅都准备就绪。
• 如果有第三个玩家尝试也想加⼊房间,则给出⼀个提⽰,房间已经满了。
public void afterConnectionEstablished(WebSocketSession session) throws Exception {GameReadResponse resp = new GameReadResponse();//1.先获取到用户的身份信息(从HttpSession里拿到当前用户的对象)User user = (User) session.getAttributes().get("user");if (user == null) {resp.setOk(false);resp.setReason("用户尚未登录");session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));return;}//2.当前用户是否已经在房间(拿着房间管理器进行查询)Room room = roomManager.getRoomByUserId(user.getUserId());if (room == null) {//如果为空说明当前没有对应的房间,该玩家还没有匹配resp.setOk(false);resp.setReason("该用户尚未匹配");session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));return;}//3.判定当前是不是多开(用户是不是已经在其他页面)//前面准备了一个OnlineUserManagerif (onlineUserManager.getFromGameHall(user.getUserId()) != null|| onlineUserManager.getFromGameRoom(user.getUserId()) != null) {//如果一个账号,一个在游戏大厅,一个在游戏房间,也是为多开resp.setOk(true);resp.setReason("禁止多开游戏页面");resp.setMessage("repeatConnection");session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));return;}//4.设置当前玩家上线onlineUserManager.enterGameRoom(user.getUserId(), session);//5.把两个玩家加入到匹配队列中//当前这个逻辑是在game_room.html页面加载的时候进行的//前面的创建房间匹配过程,是在game_hall.html页面完成的//因此前面在匹配上队手之后,需要经过页面跳转,来到game_room.html才算正式进入游戏房间//才算玩家准备就绪//执行到当前逻辑,说明玩家已经跳转成功了//页面跳转,很有可能出现失败的情况synchronized (room) {if (room.getUser1() == null) {//第一个玩家还尚未加入房间//就把当前连上的websocket的玩家作为玩家1,加入到房间中room.setUser1(user);//先连接进入房间的玩家作为先手room.setWhiteUser(user.getUserId());System.out.println("玩家 " + user.getUserName() + " 已经准备就绪 作为玩家1");return;}if (room.getUser2() == null) {//第二个玩家还尚未加入房间//就把当前连上的websocket的玩家作为玩家2,加入到房间中room.setUser2(user);System.out.println("玩家 " + user.getUserName() + " 已经准备就绪 作为玩家2");//当两个玩家都加入成功之后,就让服务器,给这两个玩家返回websocket的响应数据//通知这两个玩家游戏双方都已经准备好了//通知玩家1noticeGameReady(room,room.getUser1(),room.getUser2());//通知玩家2noticeGameReady(room,room.getUser2(),room.getUser1());return;}}//6.此时如果又用玩家尝试连接,就提示报错//这种情况理论上是不存在的,为了让程序更加健壮,还是给一个判定和提示resp.setOk(false);resp.setReason("当前房间已满,您不能加入");session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));}
4.玩家下线的处理
下线的时候要注意针对多开情况的判定
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {User user = (User) session.getAttributes().get("user");if (user == null) {//此处我们简单处理在断开连接的时候就不给客户端返回响应了return;}WebSocketSession exitSession = onlineUserManager.getFromGameRoom(user.getUserId());if (exitSession == session) {//加上这个判定,目的是为了避免再多开的情况下,第二个用户退出连接动作,导致我们第一个用户的会话被删除onlineUserManager.exitGameRoom(user.getUserId());}System.out.println("当前用户 " + user.getUserName() + " 游戏房间连接异常!");
//通知对手获胜了noticeThatUserWin(user);}
5.修改 Room 类
给 Room 类⾥加上 RoomManager 实例 和 UserMapper 实例
• Room 类内部要在游戏结束的时候销毁房间,需要⽤到 RoomManager。
• Room 类内部要修改玩家的分数,需要⽤到 UserMapper。
• 由于我们的 Room 并没有通过 Spring 来管理,因此内部就⽆法通过 @Autowired 来⾃动注⼊。需要⼿动的通过 SpringBoot 的启动类来获取⾥⾯的对象。
6.处理落⼦请求
实现 handleTextMessage
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {//1.先从session里拿到当前用户的身份信息User user = (User) session.getAttributes().get("user");if (user == null) {System.out.println("[handleTextMessage] 当前玩家尚未登录! ");return;}//2.根据玩家id获取到房间对象Room room = roomManager.getRoomByUserId(user.getUserId());//3.通过room对象处理这次具体请求room.putChess(message.getPayload());
}
7.实现对弈功能
实现 room 中的 putChess ⽅法.
• 先把请求解析成请求对象。
• 根据请求对象中的信息,往棋盘上落⼦。
• 落⼦完毕之后,为了⽅便调试,可以打印出棋盘的当前状况。
• 检查游戏是否结束。
• 构造落⼦响应,写回给每个玩家。
• 写回的时候如果发现某个玩家掉线,则判定另⼀⽅为获胜。
• 如果游戏胜负已分,则修改玩家的分数,并销毁房间。
//通过这个方法处理一次落子操作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;//2打印出当前的棋盘信息,方便来观察局势,也方便后面验证胜负关系的判定printBoard();//3.进行胜负判定int winner = checkWinner(row, col, chess);//4.给房间中的所有客户端都返回响应response.setMessage("putChess");response.setUserId(request.getUserId());response.setRow(row);response.setCol(col);response.setWinner(winner);
//要想给用户放送websocket数据,就需要获取到这个用户的WebSocketSessionWebSocketSession session1 = onlineUserManager.getFromGameRoom(user1.getUserId());WebSocketSession session2 = onlineUserManager.getFromGameRoom(user2.getUserId());//万一当前查到的会话为空(玩家已经下线了) 特殊处理一下if (session1 == null) {// 玩家1 掉线, 直接认为玩家2 获胜response.setWinner(user2.getUserId());System.out.println("玩家1 掉线!");}if (session2 == null) {// 玩家2 掉线, 直接认为玩家1 获胜response.setWinner(user1.getUserId());System.out.println("玩家2 掉线!");}//把响应构成的json字符串,通过session进行传输String respJson = objectMapper.writeValueAsString(response);if (session1 != null) {session1.sendMessage(new TextMessage(respJson));}if (session2 != null) {session2.sendMessage(new TextMessage(respJson));}
// 5. 如果玩家胜负已分, 就把 room 从管理器中销毁if (response.getWinner() != 0) {//胜负已分System.out.println("游戏结束, 房间即将销毁! roomId: " + roomId + " 获胜⽅为: " + response.getWinner());//更新获胜方和失败方的信息int winUserId = response.getWinner();int loseUserId = (response.getWinner() == user1.getUserId()) ? user2.getUserId() : user1.getUserId();userMapper.userWin(winUserId);userMapper.userLose(loseUserId);
roomManager.remove(roomId, user1.getUserId(), user2.getUserId());}}
8.实现打印棋盘的逻辑
private void printBoard() {System.out.println("打印棋盘信息: " + roomId);System.out.println("===========================");for (int r = 0; r < MAX_ROW; r++) {for (int c = 0; c < MAX_COL; c++) {//针对一行的若干列,不要打印换行System.out.print(board[r][c] + " ");}System.out.println();}System.out.println("===========================");}
9.实现胜负判定
• 如果游戏分出胜负,则返回玩家的 id。如果未分出胜负,则返回 0。
• 棋盘中值为 1 表⽰是玩家 1 的落⼦,值为 2 表⽰是玩家 2 的落⼦。
• 检查胜负的时候,以当前落⼦位置为中⼼,检查所有相关的⾏、列、对⻆线即可。不必遍历整个棋盘。
private int checkWinner(int row, int col, int chess) {//TODO 一会在实现,使用这个方法.//以row, col为中⼼// 1. 检查所有的⾏(循环五次)for (int c = col - 4; c <= col; c++) {//针对其中一种情况,来判定五子是不是连在一起了//不光这五个子得连着,颜色还得一致try {if (board[row][c] == chess&& board[row][c + 1] == chess&& board[row][c + 2] == chess&& board[row][c + 3] == chess&& board[row][c + 4] == chess) {return chess == 1 ? user1.getUserId() : user2.getUserId();}} catch (ArrayIndexOutOfBoundsException e) {//如果出现数组下标越界得情况,就在这里直接忽略这个异常continue;}
}
//2.检查所有列for (int r = row - 4; r <= row; r++) {//针对其中一种情况,来判定五子是不是连在一起了//不光这五个子得连着,颜色还得一致try {if (board[r][col] == chess&& board[r + 1][col] == chess&& board[r + 2][col] == chess&& board[r + 3][col] == chess&& board[r + 4][col] == chess) {return chess == 1 ? user1.getUserId() : user2.getUserId();}} catch (ArrayIndexOutOfBoundsException e) {//如果出现数组下标越界得情况,就在这里直接忽略这个异常continue;}
}
//3.左对角线for (int r = row - 4, c = col - 4; r <= row && c <= col; r++, c++) {//针对其中一种情况,来判定五子是不是连在一起了//不光这五个子得连着,颜色还得一致try {if (board[r][c] == chess&& board[r + 1][c + 1] == chess&& board[r + 2][c + 2] == chess&& board[r + 3][c + 3] == chess&& board[r + 4][c + 4] == chess) {return chess == 1 ? user1.getUserId() : user2.getUserId();}} catch (ArrayIndexOutOfBoundsException e) {//如果出现数组下标越界得情况,就在这里直接忽略这个异常continue;}
}//4.右对角线for (int r = row - 4, c = col + 4; r <= row && c >= col; r++, c--) {//针对其中一种情况,来判定五子是不是连在一起了//不光这五个子得连着,颜色还得一致try {if (board[r][c] == chess&& board[r + 1][c - 1] == chess&& board[r + 2][c - 2] == chess&& board[r + 3][c - 3] == chess&& board[r + 4][c - 4] == chess) {return chess == 1 ? user1.getUserId() : user2.getUserId();}} catch (ArrayIndexOutOfBoundsException e) {//如果出现数组下标越界得情况,就在这里直接忽略这个异常continue;}
}
//胜负未分,返回0return 0;}
10.处理玩家中途退出
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {User user = (User) session.getAttributes().get("user");if (user == null) {//此处我们简单处理在断开连接的时候就不给客户端返回响应了return;}WebSocketSession exitSession = onlineUserManager.getFromGameRoom(user.getUserId());if (exitSession == session) {//加上这个判定,目的是为了避免再多开的情况下,第二个用户退出连接动作,导致我们第一个用户的会话被删除onlineUserManager.exitGameRoom(user.getUserId());}System.out.println("当前用户 " + user.getUserName() + " 游戏房间连接异常!");
//通知对手获胜了noticeThatUserWin(user);}public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {User user = (User) session.getAttributes().get("user");if (user == null) {//此处我们简单处理在断开连接的时候就不给客户端返回响应了return;}WebSocketSession exitSession = onlineUserManager.getFromGameRoom(user.getUserId());if (exitSession == session) {//加上这个判定,目的是为了避免再多开的情况下,第二个用户退出连接动作,导致我们第一个用户的会话被删除onlineUserManager.exitGameRoom(user.getUserId());}System.out.println("当前用户 " + user.getUserName() + " 离开游戏房间!");
//通知对手获胜了noticeThatUserWin(user);}
六、总结
本项目是一款基于 Java 的网页版五子棋在线对战游戏,实现了用户注册、登录、天梯匹配、实时对战和战绩记录等核心功能。后端采用 Spring Boot 框架整合 MyBatis 进行数据持久化管理,通过 WebSocket 实现低延迟的实时通信,保证了玩家在匹配和对战过程中的流畅体验。项目结构清晰、扩展性强,适合作为在线棋类游戏的技术基础。