目录
一、项⽬背景
为了实现五子棋在线对战功能,我使用 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.1
Content-Type:application/x-www-form-urlencoded
username=zhangsan&password=123
响应:HTTP/1.1 200 OK //如果登录失败,就返回一个无效的user对象,
{ //比如,这里的每个属性都是空着的,像userId
usrId: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();
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
}
}
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 {
@Autowired
private 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() {
@Override
public void run() {
//扫描normalQueue
while (true) {
handlermatch(normalQueue);
}
}
};
t1.start();
Thread t2 = new Thread() {
@Override
public void run() {
//扫描highQueue
while (true) {
handlermatch(highQueue);
}
}
};
t2.start();
Thread t3 = new Thread() {
@Override
public void run() {
//扫描veryHighQueue
while (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来作为房间id
roomId = 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获取room
public Room getRoomByRoomId(String roomId) {
return rooms.get(roomId);
}
//查找userid获取room
public Room getRoomByUserId(int userId) {
String roomId = userIdToRoomId.get(userId);
if (roomId == null) {
//userid->roomid映射关系不存在,直接返回null
return 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 和 y
message:'putChess', row => y
userId:1, col => x
row: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();
@Autowired
private RoomManager roomManager;
// 这个是管理 game 页面的会话
@Autowired
private OnlineUserManager onlineUserManager;
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
}
}
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.判定当前是不是多开(用户是不是已经在其他页面)
//前面准备了一个OnlineUserManager
if (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的响应数据
//通知这两个玩家游戏双方都已经准备好了
//通知玩家1
noticeGameReady(room,room.getUser1(),room.getUser2());
//通知玩家2
noticeGameReady(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数据,就需要获取到这个用户的WebSocketSession
WebSocketSession 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;
}
}
//胜负未分,返回0
return 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 实现低延迟的实时通信,保证了玩家在匹配和对战过程中的流畅体验。项目结构清晰、扩展性强,适合作为在线棋类游戏的技术基础。