頁面實現
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();
下篇文章我們來寫對戰的相關代碼