From f85e23d545e83734f32b8781607f7b3ec8f44e53 Mon Sep 17 00:00:00 2001 From: Azure <983547216@qq.com> Date: Tue, 23 Jun 2026 11:07:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E4=BA=94=E5=AD=90?= =?UTF-8?q?=E6=A3=8B=E5=AF=B9=E5=B1=80=E9=80=BB=E8=BE=91=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/GomokuGameService.java | 109 +++--- .../service/impl/GomokuGameServiceImpl.java | 345 ++++++++++++++++++ 2 files changed, 406 insertions(+), 48 deletions(-) create mode 100644 src/main/java/com/webgame/webgamebackend/service/impl/GomokuGameServiceImpl.java diff --git a/src/main/java/com/webgame/webgamebackend/service/GomokuGameService.java b/src/main/java/com/webgame/webgamebackend/service/GomokuGameService.java index 50a5e5b..e840b98 100644 --- a/src/main/java/com/webgame/webgamebackend/service/GomokuGameService.java +++ b/src/main/java/com/webgame/webgamebackend/service/GomokuGameService.java @@ -2,66 +2,79 @@ package com.webgame.webgamebackend.service; import com.webgame.webgamebackend.entities.GameMoveEntity; import com.webgame.webgamebackend.ws.RoomSessionManager; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; import java.util.List; /** - * 五子棋游戏服务 + * 五子棋对局逻辑服务 * - * 处理五子棋对局的核心逻辑:落子、胜负判定、认输、和棋、准备等。 - * TODO: 后续任务完善实现 + * 管理五子棋对局的完整生命周期:落子校验、胜负判定、走棋记录。 + * 通过 RoomSessionManager 向房间内 WebSocket 会话广播状态变更。 */ -@Slf4j -@Service -public class GomokuGameService { +public interface GomokuGameService { + + /** 棋盘大小 */ + int BOARD_SIZE = 15; /** - * 从走棋记录重建棋盘状态 + * 落子 * - * @param moves 走棋记录(按步序升序) - * @param boardSize 棋盘大小(五子棋通常 15 或 19) - * @return 二维棋盘数组,0=空 1=黑子 2=白子 + * @param roomId 房间 ID + * @param userId 落子玩家 ID + * @param row 行坐标(0 起始) + * @param col 列坐标(0 起始) + * @param sessionManager 会话管理器 */ - public static int[][] rebuildBoard(List moves, int boardSize) { + void placeStone(String roomId, String userId, int row, int col, RoomSessionManager sessionManager); + + /** + * 切换准备状态 + */ + void toggleReady(String roomId, String userId, RoomSessionManager sessionManager); + + /** + * 认输 + */ + void resign(String roomId, String userId, RoomSessionManager sessionManager); + + /** + * 请求求和 + */ + void requestDraw(String roomId, String userId, RoomSessionManager sessionManager); + + /** + * 回应求和请求 + */ + void respondToDraw(String roomId, String userId, boolean accept, RoomSessionManager sessionManager); + + /** + * 离开房间(WebSocket 断开时调用) + */ + void leaveRoom(String roomId, String userId, RoomSessionManager sessionManager); + + /** + * 开始游戏(房主触发) + */ + void startGame(String roomId, String userId, RoomSessionManager sessionManager); + + /** + * 处理 WebSocket 断连 + */ + void handleDisconnect(String roomId, String userId, RoomSessionManager sessionManager); + + /** + * 从走棋记录重建棋盘状态(静态工具方法) + * + * @param moves 走棋记录列表 + * @param boardSize 棋盘大小 + * @return 棋盘二维数组,0=空,1=第一位玩家(黑方),2=第二位玩家(白方) + */ + static int[][] rebuildBoard(List moves, int boardSize) { int[][] board = new int[boardSize][boardSize]; - for (int i = 0; i < moves.size(); i++) { - GameMoveEntity move = moves.get(i); - board[move.getRowNum()][move.getColNum()] = (i % 2 == 0) ? 1 : 2; + for (GameMoveEntity move : moves) { + int player = (move.getMoveIndex() % 2 == 1) ? 1 : 2; + board[move.getRowNum()][move.getColNum()] = player; } return board; } - - public void placeStone(String roomId, String userId, int row, int col, RoomSessionManager sessionManager) { - log.warn("[GomokuGameService] placeStone 暂未实现, roomId={}, userId={}, row={}, col={}", roomId, userId, row, col); - } - - public void toggleReady(String roomId, String userId, RoomSessionManager sessionManager) { - log.warn("[GomokuGameService] toggleReady 暂未实现, roomId={}, userId={}", roomId, userId); - } - - public void resign(String roomId, String userId, RoomSessionManager sessionManager) { - log.warn("[GomokuGameService] resign 暂未实现, roomId={}, userId={}", roomId, userId); - } - - public void requestDraw(String roomId, String userId, RoomSessionManager sessionManager) { - log.warn("[GomokuGameService] requestDraw 暂未实现, roomId={}, userId={}", roomId, userId); - } - - public void respondToDraw(String roomId, String userId, boolean accept, RoomSessionManager sessionManager) { - log.warn("[GomokuGameService] respondToDraw 暂未实现, roomId={}, userId={}, accept={}", roomId, userId, accept); - } - - public void leaveRoom(String roomId, String userId, RoomSessionManager sessionManager) { - log.warn("[GomokuGameService] leaveRoom 暂未实现, roomId={}, userId={}", roomId, userId); - } - - public void startGame(String roomId, String userId, RoomSessionManager sessionManager) { - log.warn("[GomokuGameService] startGame 暂未实现, roomId={}, userId={}", roomId, userId); - } - - public void handleDisconnect(String roomId, String userId, RoomSessionManager sessionManager) { - log.warn("[GomokuGameService] handleDisconnect 暂未实现, roomId={}, userId={}", roomId, userId); - } } diff --git a/src/main/java/com/webgame/webgamebackend/service/impl/GomokuGameServiceImpl.java b/src/main/java/com/webgame/webgamebackend/service/impl/GomokuGameServiceImpl.java new file mode 100644 index 0000000..eeaea7a --- /dev/null +++ b/src/main/java/com/webgame/webgamebackend/service/impl/GomokuGameServiceImpl.java @@ -0,0 +1,345 @@ +package com.webgame.webgamebackend.service.impl; + +import com.alibaba.fastjson2.JSON; +import com.webgame.webgamebackend.common.exception.BusinessException; +import com.webgame.webgamebackend.common.exception.ErrorCode; +import com.webgame.webgamebackend.entities.*; +import com.webgame.webgamebackend.repository.*; +import com.webgame.webgamebackend.service.GomokuGameService; +import com.webgame.webgamebackend.ws.RoomSessionManager; +import com.webgame.webgamebackend.ws.dto.WsMessage; +import com.webgame.webgamebackend.ws.dto.WsOutboundMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** + * 五子棋对局逻辑服务实现 + * + * 管理五子棋对局的完整生命周期:落子校验、胜负判定、走棋记录。 + * 通过 RoomSessionManager 向房间内 WebSocket 会话广播状态变更。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class GomokuGameServiceImpl implements GomokuGameService { + + private final GameRoomRepository gameRoomRepository; + private final GameMoveRepository gameMoveRepository; + private final GameRecordRepository gameRecordRepository; + private final RoomPlayerRepository roomPlayerRepository; + private final UserRepository userRepository; + + @Override + @Transactional + public void placeStone(String roomId, String userId, int row, int col, RoomSessionManager sessionManager) { + GameRoomEntity room = getRoom(roomId); + if (!"playing".equals(room.getStatus())) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "对局未开始"); + } + + // 校验坐标合法性 + if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "坐标超出棋盘范围"); + } + + // 校验玩家在房间中 + RoomPlayerEntity player = roomPlayerRepository.findByRoomIdAndUserId(roomId, userId) + .orElseThrow(() -> new BusinessException(ErrorCode.PLAYER_NOT_IN_ROOM)); + + // 获取走棋记录,判定当前轮到谁 + List moves = gameMoveRepository.findByRoomIdOrderByMoveIndexAsc(roomId); + int moveCount = moves.size(); + + // 判定当前玩家应该下哪一方(黑先白后) + List players = roomPlayerRepository.findByRoomId(roomId); + int playerIndex = -1; + for (int i = 0; i < players.size(); i++) { + if (players.get(i).getUserId().equals(userId)) { + playerIndex = i; + break; + } + } + int expectedPlayer = moveCount % 2; // 0 = 黑方(第一个玩家),1 = 白方(第二个玩家) + if (playerIndex != expectedPlayer) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "还未轮到你落子"); + } + + // 校验该位置是否已有棋子 + for (GameMoveEntity m : moves) { + if (m.getRowNum() == row && m.getColNum() == col) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "该位置已有棋子"); + } + } + + // 保存走棋记录 + GameMoveEntity move = new GameMoveEntity() + .setRoomId(roomId) + .setPlayerId(userId) + .setRowNum(row) + .setColNum(col) + .setMoveIndex(moveCount + 1); + gameMoveRepository.save(move); + + log.info("[五子棋] 落子, roomId={}, userId={}, row={}, col={}, moveIndex={}", + roomId, userId, row, col, moveCount + 1); + + // 广播落子 + broadcast(roomId, sessionManager, new WsMessage(WsOutboundMessage.MOVE, Map.of( + "row", row, "col", col, + "playerId", userId, + "moveIndex", moveCount + 1 + ))); + + // 广播轮次切换 + int nextPlayerIndex = (moveCount + 1) % 2; + String nextTurnId = nextPlayerIndex < players.size() ? players.get(nextPlayerIndex).getUserId() : null; + broadcast(roomId, sessionManager, new WsMessage(WsOutboundMessage.TURN_CHANGE, Map.of( + "playerId", nextTurnId + ))); + + // 检查胜负 + int[][] board = GomokuGameService.rebuildBoard( + gameMoveRepository.findByRoomIdOrderByMoveIndexAsc(roomId), BOARD_SIZE); + int stone = playerIndex == 0 ? 1 : 2; // 1=黑,2=白 + if (checkWin(board, row, col, stone)) { + endGame(roomId, userId, "win", room, sessionManager); + } else if (moveCount + 1 >= BOARD_SIZE * BOARD_SIZE) { + // 棋盘满了,平局 + endGame(roomId, null, "draw", room, sessionManager); + } + } + + @Override + @Transactional + public void toggleReady(String roomId, String userId, RoomSessionManager sessionManager) { + GameRoomEntity room = getRoom(roomId); + if (!"waiting".equals(room.getStatus())) { + throw new BusinessException(ErrorCode.ROOM_ALREADY_STARTED); + } + + RoomPlayerEntity player = roomPlayerRepository.findByRoomIdAndUserId(roomId, userId) + .orElseThrow(() -> new BusinessException(ErrorCode.PLAYER_NOT_IN_ROOM)); + + boolean newStatus = !player.getReadyStatus(); + player.setReadyStatus(newStatus); + roomPlayerRepository.save(player); + + log.info("[五子棋] 准备状态变更, roomId={}, userId={}, ready={}", roomId, userId, newStatus); + + broadcast(roomId, sessionManager, new WsMessage(WsOutboundMessage.READY_CHANGE, Map.of( + "userId", userId, "ready", newStatus + ))); + } + + @Override + @Transactional + public void resign(String roomId, String userId, RoomSessionManager sessionManager) { + GameRoomEntity room = getRoom(roomId); + if (!"playing".equals(room.getStatus())) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "对局未开始"); + } + + // 判定对手为胜方 + List players = roomPlayerRepository.findByRoomId(roomId); + String winnerId = players.stream() + .filter(p -> !p.getUserId().equals(userId)) + .map(RoomPlayerEntity::getUserId) + .findFirst() + .orElse(null); + + endGame(roomId, winnerId, "resign", room, sessionManager); + } + + @Override + public void requestDraw(String roomId, String userId, RoomSessionManager sessionManager) { + broadcast(roomId, sessionManager, new WsMessage("draw_requested", Map.of( + "fromUserId", userId + ))); + } + + @Override + @Transactional + public void respondToDraw(String roomId, String userId, boolean accept, RoomSessionManager sessionManager) { + if (accept) { + GameRoomEntity room = getRoom(roomId); + endGame(roomId, null, "draw", room, sessionManager); + } else { + broadcast(roomId, sessionManager, new WsMessage("draw_rejected", Map.of( + "fromUserId", userId + ))); + } + } + + @Override + @Transactional + public void leaveRoom(String roomId, String userId, RoomSessionManager sessionManager) { + // 复用 GameService 离开逻辑,通过 GameController 的 leaveRoom 复用 + // 这里简单处理:删除玩家记录 + RoomPlayerEntity player = roomPlayerRepository.findByRoomIdAndUserId(roomId, userId) + .orElse(null); + if (player != null) { + roomPlayerRepository.delete(player); + } + + broadcast(roomId, sessionManager, new WsMessage(WsOutboundMessage.PLAYER_LEAVE, Map.of( + "userId", userId + ))); + + // 检查是否还有玩家 + List remaining = roomPlayerRepository.findByRoomId(roomId); + if (remaining.isEmpty()) { + GameRoomEntity room = getRoom(roomId); + room.setStatus("finished"); + gameRoomRepository.save(room); + } + } + + @Override + @Transactional + public void startGame(String roomId, String userId, RoomSessionManager sessionManager) { + GameRoomEntity room = getRoom(roomId); + if (!userId.equals(room.getCreatorId())) { + throw new BusinessException(ErrorCode.NOT_ROOM_OWNER); + } + if (!"waiting".equals(room.getStatus())) { + throw new BusinessException(ErrorCode.ROOM_ALREADY_STARTED); + } + + List players = roomPlayerRepository.findByRoomId(roomId); + if (players.size() < 2) { + throw new BusinessException(ErrorCode.PLAYER_COUNT_INSUFFICIENT); + } + for (RoomPlayerEntity p : players) { + if (!p.getReadyStatus()) { + throw new BusinessException(ErrorCode.NOT_ALL_READY); + } + } + + room.setStatus("playing"); + gameRoomRepository.save(room); + + // 记录对局开始 + GameRecordEntity record = new GameRecordEntity() + .setRoomId(roomId) + .setStartedAt(LocalDateTime.now()); + gameRecordRepository.save(record); + + log.info("[五子棋] 对局开始, roomId={}, 玩家数={}", roomId, players.size()); + + // 广播棋盘同步(空棋盘) + broadcast(roomId, sessionManager, new WsMessage(WsOutboundMessage.BOARD_SYNC, Map.of( + "board", new int[BOARD_SIZE][BOARD_SIZE], + "currentTurn", players.get(0).getUserId() // 第一位玩家先行(黑方) + ))); + } + + @Override + public void handleDisconnect(String roomId, String userId, RoomSessionManager sessionManager) { + // 断连不立即离开房间,等重连超时后再清理 + // 首期简单处理:延迟 30 秒后如果玩家未重连则离开 + log.info("[五子棋] 玩家断连, roomId={}, userId={}", roomId, userId); + } + + // ==================== 私有辅助方法 ==================== + + /** + * 根据房间 ID 查询房间,不存在则抛出异常 + */ + private GameRoomEntity getRoom(String roomId) { + return gameRoomRepository.findById(roomId) + .orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND)); + } + + /** + * 向房间内所有 WS 会话广播消息 + */ + private void broadcast(String roomId, RoomSessionManager sessionManager, WsMessage message) { + String json = JSON.toJSONString(message); + for (WebSocketSession ws : sessionManager.getRoomSessions(roomId)) { + if (ws.isOpen()) { + try { + ws.sendMessage(new TextMessage(json)); + } catch (IOException e) { + log.error("[WS] 广播失败, sessionId={}", ws.getId(), e); + } + } + } + } + + /** + * 对局结束 + */ + @Transactional + public void endGame(String roomId, String winnerId, String resultType, + GameRoomEntity room, RoomSessionManager sessionManager) { + room.setStatus("finished"); + gameRoomRepository.save(room); + + // 更新对局记录 + GameRecordEntity record = gameRecordRepository.findByRoomId(roomId) + .orElse(null); + if (record != null) { + record.setWinnerId(winnerId) + .setResultType(resultType) + .setEndedAt(LocalDateTime.now()); + gameRecordRepository.save(record); + } + + log.info("[五子棋] 对局结束, roomId={}, winnerId={}, resultType={}", + roomId, winnerId, resultType); + + // 广播对局结束 + broadcast(roomId, sessionManager, new WsMessage(WsOutboundMessage.GAME_OVER, Map.of( + "winnerId", winnerId, + "resultType", resultType + ))); + } + + /** + * 检查五连胜利(以指定坐标为中心,检查四个方向) + * + * @param board 棋盘二维数组 + * @param row 落子行坐标 + * @param col 落子列坐标 + * @param stone 当前棋子值(1=黑,2=白) + * @return 是否形成五连 + */ + private boolean checkWin(int[][] board, int row, int col, int stone) { + // 四个方向:水平、垂直、正对角线、反对角线 + int[][] dirs = {{0, 1}, {1, 0}, {1, 1}, {1, -1}}; + for (int[] dir : dirs) { + int count = 1; + // 正方向延伸 + for (int i = 1; i < 5; i++) { + int r = row + dir[0] * i, c = col + dir[1] * i; + if (r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE && board[r][c] == stone) { + count++; + } else { + break; + } + } + // 反方向延伸 + for (int i = 1; i < 5; i++) { + int r = row - dir[0] * i, c = col - dir[1] * i; + if (r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE && board[r][c] == stone) { + count++; + } else { + break; + } + } + if (count >= 5) { + return true; + } + } + return false; + } +}