标准项目-----网页五子棋(4)-----游戏大厅+匹配+房间代码
页面实现
hall.html
<!DOCTYPE html>
<html lang="ch">
<head><meta charset="UTF-8"><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/hall.css">
</head>
<body><div class="nav">五子棋匹配大厅</div><div class="container"><div class="dialog"><!-- 展示用户信息 --><div id="screen"></div><!-- 开始匹配 --><button id="match" onclick="findMatch()">开始匹配</button></div></div><script src="js/jquery.min.js"></script></body>
</html>
hall.html
.container {height: calc(100% - 50px);display: flex;justify-content: center;align-items: center;
}.container .dialog {height: 350px;width: 299px;background-color: white;border-radius: 20px;padding-top: 30px;display: flex;justify-content: center;/* align-items: center; */flex-wrap: wrap
}.dialog *{display: flex;justify-content: center;align-items: center;
}.dialog #screen {width: 250px;height: 150px;background-color: wheat;border-radius: 10px;
}.dialog #match {width: 150px;height: 40px;background-color: rgb(255, 159, 33);border-radius: 10px;
}.dialog #match:active {background-color: rgb(204, 128, 21);
}
获取用户信息接口
当用户进入 游戏大厅时,就应该获取到登录用户的信息显示到页面上,我们使用js代码从访问后端接口获取信息
<script src="js/jquery.min.js"></script><script> $.ajax({url:"/user/getUserInfo",type:"get",success: function(result) {if(result.username != null) {let screen = document.querySelector("#screen");screen.innerHTML = '当前玩家:' + result.username + '<br>天梯积分:' + result.score + '<br>比赛场次:' + result.totalCount + '<br>获胜场次:' + result.winCount;}else{alert("获取用户信息失败,请重新登录");location.href = "/login.html";}},error: function() {alert("获取用户信息失败");}})</script>
WebSocket前端代码
当用户点击匹配按钮时,需要告知服务器该用户要进行匹配,服务器如果接收到则立即回复表示正在匹配,当匹配成功服务器则又需要发送匹配信息给客户端。这里涉及到服务器主动给客户端发送消息的场景,所以我们使用websocket实现
初始化websocket
var webSocket= new WebSocket("ws://localhost:8080/game"); webSocket.onopen = function() {console.log("连接成功");}webSocket.onclose = function() {console.log("连接关闭");}webSocket.onerror = function() {console.log("error");}//页面关闭时释放webSocketwindow.onbeforeunload = function() {webSocket.close();}//处理服务器发送的消息webSocket.onmessage = function(e) {}
实现findMatch()方法
点击开始匹配按钮后就会执行findMatch方法,进入匹配状态,此时我们可以把开始匹配按钮替换成取消匹配按钮,再次点击则会向服务器发送取消匹配请求
function findMatch() {//检查websocket连接if(webSocket.readyState == webSocket.OPEN) {if($("#match").text() == '开始匹配') {console.log("开始匹配");webSocket.send(JSON.stringify({message: 'startMatch' //约定startMatch表示开始匹配}));}else if($("#match").text() == '匹配中...') {console.log("停止匹配");webSocket.send(JSON.stringify({message: 'stopMatch' //约定stopMatch表示停止匹配}));}}else{alert("连接断开,请重新登录");location.href = "/login.html";}}
实现onmessage
我们约定服务器返回的响应为包含以下三个字段的json:
ok: true/false, //表示请求成功还是失败
errMsg: "错误信息", //请求失败返回错误信息
message: 'startMatch' 开始匹配 / 'stopMatch' 停止匹配/ 'success' 匹配成功 / 'no_login' 用户未登录 / ’repeat_login'该账号重复登录
webSocket.onmessage = function(e) {//解析json字符串为js对象let resp = JSON.parse(e.data);if(resp.message == 'startMatch') {//开始匹配请求发送成功正在匹配//替换按钮描述$("#match").text("匹配中...");}else if(resp.message == 'stopMatch') {//取消匹配请求发送成功已取消匹配//替换按钮描述$("#match").text("开始匹配");}else if(resp.message == 'success'){//匹配成功console.log("匹配成功! 进入游戏房间");location.assign("/room.html");console.log("进入游戏房间");}else if(resp.message == 'repeat_login') {alert("该账号已在别处登录");location.href = "/login.html";}else if(resp.message == 'no_login') {alert("当前还未登录");location.href = "/login.html";}else {alert("非法响应 errMsg:" + resp.errMsg);}}
WebSocket后端代码
注册websocket
创建TextWebSocketHandler子类,重写如下方法:
package org.ting.j20250110_gobang.websocket;import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;@Component
public class MatchWebSocket extends TextWebSocketHandler {//连接成功后执行@Overridepublic void afterConnectionEstablished(WebSocketSession session) throws Exception {super.afterConnectionEstablished(session);}//接收到请求后执行@Overrideprotected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {super.handleTextMessage(session, message);}//连接异常时执行@Overridepublic void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {super.handleTransportError(session, exception);}//连接正常断开后执行@Overridepublic void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {super.afterConnectionClosed(session, status);}
}
注册socket:
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {@Autowiredprivate TextWebSocketHandler textWebSocketHandler;@Autowiredprivate MatchWebSocket matchWebSocket;@Overridepublic void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {registry.addHandler(textWebSocketHandler, "/test");registry.addHandler(matchWebSocket, "/findMatch") //注意路径和前端对应//添加拦截器获取到session,方便获取session中的用户信息.addInterceptors(new HttpSessionHandshakeInterceptor());}
}
维护在线用户
在用户登录成功后,我们可以维护好用户的websocket会话,把用户表示为在线状态,方便获取到用户的websocket会话
@Component
public class OnlineUserManager {//使用ConcurrentHashMap保证线程安全private Map<Integer, WebSocketSession> onlineUser = new ConcurrentHashMap<>();public void enterGameHall(int userId, WebSocketSession session) {//用户上线onlineUser.put(userId, session);}public void exitGameHall(int userId) {//用户下线onlineUser.remove(userId);}public WebSocketSession getFromHall(int userId) {//获取用户的websocket会话return onlineUser.get(userId);}
}
实现webSocket相关方法
上期我们定义了webSocket的处理类,但是并没有完成重写的方法,接下来我们借助维护的在线用户具体实现如下方法
在实现这些方法之前,我们还需要按照上期约定好的信息交互形式定义两个实体类,代表请求和响应:
public class MatchRequest {private String message;public String getMessage() {return message;}public void setMessage(String message) {this.message = message;}
}
@Data
public class MatchResponse {private boolean ok;private String errMsg;private String message;}
连接成功
public void afterConnectionEstablished(WebSocketSession session) throws Exception {try {User user = (User) session.getAttributes().get("user");if (onlineUserManager.getFromHall(user.getUserId()) != null) {MatchResponse response = new MatchResponse();response.setOk(false);response.setErrMsg("已经在别处登录");response.setMessage("repeat_login");session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));// 此处直接关闭有些太激进了, 还是返回一个特殊的 message , 供客户端来进行判定, 由客户端负责进行处理// session.close();return;} else {onlineUserManager.enterGameHall(user.getUserId(), session);System.out.println("用户:" + user.getUsername() + " 已上线");}}catch (NullPointerException e){System.out.println("[MatchAPI.afterConnectionEstablished] 当前用户未登录!");// e.printStackTrace();// 出现空指针异常, 说明当前用户的身份信息是空, 用户未登录呢.// 把当前用户尚未登录这个信息给返回回去~~MatchResponse response = new MatchResponse();response.setOk(false);response.setErrMsg("您尚未登录! 不能进行后续匹配功能!");response.setMessage("no_login");session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));}}
连接断开
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {try {User user = (User)session.getAttributes().get("user");//防止重复登录时删除正常登录的在线信息if(onlineUserManager.getFromHall(user.getUserId()).equals(session)) {onlineUserManager.exitGameHall(user.getUserId());System.out.println("用户:" + user.getUsername() + " 已下线");}}catch (NullPointerException e) {System.out.println("[MatchAPI.handleTransportError] 当前用户未登录!");MatchResponse response = new MatchResponse();response.setOk(false);response.setErrMsg("用户未登录");response.setMessage("no_login");session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));}}//连接正常断开后执行@Overridepublic void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {try {User user = (User)session.getAttributes().get("user");//防止重复登录时删除正常登录的在线信息if(onlineUserManager.getFromHall(user.getUserId()).equals(session)) {onlineUserManager.exitGameHall(user.getUserId());System.out.println("用户:" + user.getUsername() + " 已下线");}}catch (NullPointerException e) {System.out.println("[MatchAPI.afterConnectionClosed] 当前用户未登录!");MatchResponse response = new MatchResponse();response.setOk(false);response.setErrMsg("用户未登录");session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));}}
}
定义Mather类
@Component
public class Matcher {// 创建三个匹配队列private Queue<User> normalQueue = new LinkedList<>();private Queue<User> highQueue = new LinkedList<>();private Queue<User> veryHighQueue = new LinkedList<>();@Autowiredprivate OnlineUserManager onlineUserManager;private ObjectMapper objectMapper = new ObjectMapper();// 操作匹配队列的方法.// 把玩家放到匹配队列中public void add(User user) {if (user.getScore() < 2000) {synchronized (normalQueue) {normalQueue.offer(user);normalQueue.notify();}System.out.println("把玩家 " + user.getUsername() + " 加入到了 normalQueue 中!");} else if (user.getScore() >= 2000 && user.getScore() < 3000) {synchronized (highQueue) {highQueue.offer(user);highQueue.notify();}System.out.println("把玩家 " + user.getUsername() + " 加入到了 highQueue 中!");} else {synchronized (veryHighQueue) {veryHighQueue.offer(user);veryHighQueue.notify();}System.out.println("把玩家 " + user.getUsername() + " 加入到了 veryHighQueue 中!");}}// 当玩家点击停止匹配的时候, 就需要把玩家从匹配队列中删除public void remove(User user) {if (user.getScore() < 2000) {synchronized (normalQueue) {normalQueue.remove(user);}System.out.println("把玩家 " + user.getUsername() + " 移除了 normalQueue!");} else if (user.getScore() >= 2000 && user.getScore() < 3000) {synchronized (highQueue) {highQueue.remove(user);}System.out.println("把玩家 " + user.getUsername() + " 移除了 highQueue!");} else {synchronized (veryHighQueue) {veryHighQueue.remove(user);}System.out.println("把玩家 " + user.getUsername() + " 移除了 veryHighQueue!");}}}
处理匹配请求
@Overrideprotected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {User user = (User) session.getAttributes().get("user");// 获取到客户端给服务器发送的数据String payload = message.getPayload();// 当前这个数据载荷是一个 JSON 格式的字符串, 就需要把它转成 Java 对象. MatchRequestMatchRequest request = objectMapper.readValue(payload, MatchRequest.class);MatchResponse response = new MatchResponse();if (request.getMessage().equals("startMatch")) {// 进入匹配队列matcher.add(user);// 把玩家信息放入匹配队列之后, 就可以返回一个响应给客户端了.response.setOk(true);response.setMessage("startMatch");} else if (request.getMessage().equals("stopMatch")) {// 退出匹配队列matcher.remove(user);// 移除之后, 就可以返回一个响应给客户端了.response.setOk(true);response.setMessage("stopMatch");} else {response.setOk(false);response.setErrMsg("非法的匹配请求");}String jsonString = objectMapper.writeValueAsString(response);session.sendMessage(new TextMessage(jsonString));}
游戏房间实体类
package com.example.demo;import com.example.demo.dao.User;import java.util.UUID;
//每个房间都是不通的,所以不能给spring管理
public class Room {private String roomId;private User user1;private User user2;// 先手方的玩家 idprivate int whiteUser;public int getWhiteUser() {return whiteUser;}public void setWhiteUser(int whiteUser) {this.whiteUser = whiteUser;}public Room() {roomId = UUID.randomUUID().toString();}public String getRoomId() {return roomId;}public void setRoomId(String roomId) {this.roomId = roomId;}public User getUser1() {return user1;}public void setUser1(User user1) {this.user1 = user1;}public User getUser2() {return user2;}public void setUser2(User user2) {this.user2 = user2;}
}
实现匹配功能
创建线程扫描队列
我们为每个匹配队列创建一个线程,用来实现匹配功能,我们在构造方法中创建线程:
public Matcher() {// 创建三个线程, 分别针对这三个匹配队列, 进行操作.Thread t1 = new Thread() {@Overridepublic void run() {// 扫描 normalQueuewhile (true) {handlerMatch(normalQueue);}}};t1.start();Thread t2 = new Thread(){@Overridepublic void run() {while (true) {handlerMatch(highQueue);}}};t2.start();Thread t3 = new Thread() {@Overridepublic void run() {while (true) {handlerMatch(veryHighQueue);}}};t3.start();}
实现handlerMatch()方法进行匹配
public void handlerMatch(Queue<User> matchQueue) {try {//对操作的队列加锁保证线程安全synchronized (matchQueue) {//1.检测队列中是否有两个元素while(matchQueue.size() < 2) {matchQueue.wait();}//2.从队列中取出两个玩家User user1 = matchQueue.poll();User user2 = matchQueue.poll();//3.获取到两个玩家的会话信息WebSocketSession session1 = onlineUserManager.getFromHall(user1.getUserId());WebSocketSession session2 = onlineUserManager.getFromHall(user2.getUserId());//4.todo 把两个玩家放到一个游戏房间中//5.给用户返回匹配成功的响应MatchResponse response = new MatchResponse();response.setOk(true);response.setMessage("success");String json = objectMapper.writeValueAsString(response);session1.sendMessage(new TextMessage(json));session2.sendMessage(new TextMessage(json));}}catch (IOException | InterruptedException e) {e.printStackTrace();}}
修改websocket后端代码
@Component
public class MatchWebSocket extends TextWebSocketHandler {@Autowiredprivate Matcher matcher;@Autowiredprivate OnlineUserManager onlineUserManager;ObjectMapper objectMapper=new ObjectMapper();//连接成功后执行@Overridepublic void afterConnectionEstablished(WebSocketSession session) throws Exception {try {User user = (User) session.getAttributes().get("user");if (onlineUserManager.getFromHall(user.getUserId()) != null) {MatchResponse response = new MatchResponse();response.setOk(true);response.setErrMsg("已经在别处登录");response.setMessage("repeat_login");session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));// 此处直接关闭有些太激进了, 还是返回一个特殊的 message , 供客户端来进行判定, 由客户端负责进行处理// session.close();return;} else {onlineUserManager.enterGameHall(user.getUserId(), session);System.out.println("用户:" + user.getUsername() + " 已上线");}}catch (NullPointerException e){System.out.println("[MatchAPI.afterConnectionEstablished] 当前用户未登录!");// e.printStackTrace();// 出现空指针异常, 说明当前用户的身份信息是空, 用户未登录呢.// 把当前用户尚未登录这个信息给返回回去~~MatchResponse response = new MatchResponse();response.setOk(true);response.setErrMsg("您尚未登录! 不能进行后续匹配功能!");response.setMessage("no_login");session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));}}//接收到请求后执行@Overrideprotected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {User user = (User) session.getAttributes().get("user");// 获取到客户端给服务器发送的数据String payload = message.getPayload();// 当前这个数据载荷是一个 JSON 格式的字符串, 就需要把它转成 Java 对象. MatchRequestMatchRequest request = objectMapper.readValue(payload, MatchRequest.class);MatchResponse response = new MatchResponse();if (request.getMessage().equals("startMatch")) {// 进入匹配队列matcher.add(user);// 把玩家信息放入匹配队列之后, 就可以返回一个响应给客户端了.response.setOk(true);response.setMessage("startMatch");} else if (request.getMessage().equals("stopMatch")) {// 退出匹配队列matcher.remove(user);// 移除之后, 就可以返回一个响应给客户端了.response.setOk(true);response.setMessage("stopMatch");} else {response.setOk(false);response.setErrMsg("非法的匹配请求");}String jsonString = objectMapper.writeValueAsString(response);session.sendMessage(new TextMessage(jsonString));}//连接异常时执行@Overridepublic void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {try {User user = (User)session.getAttributes().get("user");//防止重复登录时删除正常登录的在线信息if(onlineUserManager.getFromHall(user.getUserId()).equals(session)) {onlineUserManager.exitGameHall(user.getUserId());System.out.println("用户:" + user.getUsername() + " 已下线");matcher.remove(user);}}catch (NullPointerException e) {System.out.println("[MatchAPI.handleTransportError] 当前用户未登录!");MatchResponse response = new MatchResponse();response.setOk(false);response.setErrMsg("用户未登录");response.setMessage("no_login");session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));}}//连接正常断开后执行@Overridepublic void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {try {User user = (User)session.getAttributes().get("user");//防止重复登录时删除正常登录的在线信息if(onlineUserManager.getFromHall(user.getUserId()).equals(session)) {onlineUserManager.exitGameHall(user.getUserId());System.out.println("用户:" + user.getUsername() + " 已下线");matcher.remove(user);}}catch (NullPointerException e) {System.out.println("[MatchAPI.afterConnectionClosed] 当前用户未登录!");MatchResponse response = new MatchResponse();response.setOk(false);response.setErrMsg("用户未登录");session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));}}}
房间管理器实体类
这里我们创建了两个哈希表,一个维护房间id到游戏房间的映射,一个维护用户id到游戏房间的映射,此时我们就可以通过add方法把两个用户加入一个游戏房间内
package com.example.demo;import org.springframework.stereotype.Component;import java.util.concurrent.ConcurrentHashMap;@Component
public class RoomManager {//通过房间id来获得房间private ConcurrentHashMap<String, Room> roomIdToRoom = new ConcurrentHashMap<>();//通过用户id来获取房间id,然后再获取房间private ConcurrentHashMap<Integer, String> userIdToRoomId = new ConcurrentHashMap<>();public void add(String roomId, Room room, Integer userId1, Integer userId2) {roomIdToRoom.put(roomId, room);userIdToRoomId.put(userId1, roomId);userIdToRoomId.put(userId2, roomId);}public void remove(String roomId, int userId1, int userId2) {roomIdToRoom.remove(roomId);userIdToRoomId.remove(userId1);userIdToRoomId.remove(userId2);}public Room getRoomByRoomId(String roomId) {return roomIdToRoom.get(roomId);}public Room getRoomByUserId(Integer userId) {return roomIdToRoom.get(userIdToRoomId.get(userId));}
}
进入房间代码
Room room = new Room();
roomManager.add(room.getRoomId(), room, user1.getUserId(), user2.getUserId());
游戏房间前端代码
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">
</head>
<body><div class="nav">五子棋对战</div><div class="container"><div><!-- 棋盘区域, 需要基于 canvas 进行实现 --><canvas id="chess" width="450px" height="450px"></canvas><!-- 显示区域 --><div id="screen"> 等待玩家连接中... </div></div></div><script src="js/script.js"></script></body>
</html>
common.css
/* 公共样式 */* {margin: 0;padding: 0;box-sizing: border-box;
}html, body {height: 100%;background-image: url(../img/kk.png);background-repeat: no-repeat;background-position: center;background-size: cover;
}.nav {height: 50px;background-color: gray;color: white;font-size: 20px;display: flex;justify-content: center;align-items: center;
}
.container {width: 100%;height: calc(100% - 50px);display: flex;align-items: center;justify-content: center;
}
game_room.css
#screen {width: 450px;height: 50px;margin-top: 10px;background-color: #fff;font-size: 22px;line-height: 50px;text-align: center;
}.return-btn {width: 450px;height: 50px;margin-top: 5px;background-color: orange;color: #fff;font-size: 22px;line-height: 50px;text-align: center;
}
script.js
gameInfo = {roomId: null,thisUserId: null,thatUserId: null,isWhite: true,
}//////////////////////////////////////////////////
// 设定界面显示相关操作
//////////////////////////////////////////////////function setScreenText(me) {let screen = document.querySelector('#screen');if (me) {screen.innerHTML = "轮到你落子了!";} else {screen.innerHTML = "轮到对方落子了!";}
}//////////////////////////////////////////////////
// 初始化 websocket
//////////////////////////////////////////////////
// TODO//////////////////////////////////////////////////
// 初始化一局游戏
//////////////////////////////////////////////////
function initGame() {// 是我下还是对方下. 根据服务器分配的先后手情况决定let me = gameInfo.isWhite;// 游戏是否结束let over = false;let chessBoard = [];//初始化chessBord数组(表示棋盘的数组)for (let i = 0; i < 15; i++) {chessBoard[i] = [];for (let j = 0; j < 15; j++) {chessBoard[i][j] = 0;}}let chess = document.querySelector('#chess');let context = chess.getContext('2d');context.strokeStyle = "#BFBFBF";// 背景图片let logo = new Image();logo.src = "img/sky.jpeg";logo.onload = function () {context.drawImage(logo, 0, 0, 450, 450);initChessBoard();}// 绘制棋盘网格function initChessBoard() {for (let i = 0; i < 15; i++) {context.moveTo(15 + i * 30, 15);context.lineTo(15 + i * 30, 430);context.stroke();context.moveTo(15, 15 + i * 30);context.lineTo(435, 15 + i * 30);context.stroke();}}// 绘制一个棋子, me 为 truefunction oneStep(i, j, isWhite) {context.beginPath();context.arc(15 + i * 30, 15 + j * 30, 13, 0, 2 * Math.PI);context.closePath();var gradient = context.createRadialGradient(15 + i * 30 + 2, 15 + j * 30 - 2, 13, 15 + i * 30 + 2, 15 + j * 30 - 2, 0);if (!isWhite) {gradient.addColorStop(0, "#0A0A0A");gradient.addColorStop(1, "#636766");} else {gradient.addColorStop(0, "#D1D1D1");gradient.addColorStop(1, "#F9F9F9");}context.fillStyle = gradient;context.fill();}chess.onclick = function (e) {if (over) {return;}if (!me) {return;}let x = e.offsetX;let y = e.offsetY;// 注意, 横坐标是列, 纵坐标是行let col = Math.floor(x / 30);let row = Math.floor(y / 30);if (chessBoard[row][col] == 0) {// TODO 发送坐标给服务器, 服务器要返回结果oneStep(col, row, gameInfo.isWhite);chessBoard[row][col] = 1;}}
}initGame();
下篇文章我们来写对战的相关代码