feat: 添加五子棋对局逻辑服务

This commit is contained in:
2026-06-23 11:07:31 +08:00
parent 6e4eb6120a
commit f85e23d545
2 changed files with 406 additions and 48 deletions

View File

@@ -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<GameMoveEntity> 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<GameMoveEntity> 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);
}
}

View File

@@ -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<GameMoveEntity> moves = gameMoveRepository.findByRoomIdOrderByMoveIndexAsc(roomId);
int moveCount = moves.size();
// 判定当前玩家应该下哪一方(黑先白后)
List<RoomPlayerEntity> 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<RoomPlayerEntity> 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<RoomPlayerEntity> 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<RoomPlayerEntity> 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;
}
}