diff --git a/src/main/java/com/webgame/webgamebackend/WebgameBackendApplication.java b/src/main/java/com/webgame/webgamebackend/WebgameBackendApplication.java index 5d3cf28..8c26ed1 100644 --- a/src/main/java/com/webgame/webgamebackend/WebgameBackendApplication.java +++ b/src/main/java/com/webgame/webgamebackend/WebgameBackendApplication.java @@ -3,9 +3,11 @@ package com.webgame.webgamebackend; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.EnableAspectJAutoProxy; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableAspectJAutoProxy(proxyTargetClass = true) // 启用基于 AspectJ 的自动代理功能 支持使用AOP功能 +@EnableScheduling // 启用定时任务(房间超时清理) public class WebgameBackendApplication { public static void main(String[] args) { diff --git a/src/main/java/com/webgame/webgamebackend/common/auth/AuthInterceptor.java b/src/main/java/com/webgame/webgamebackend/common/auth/AuthInterceptor.java index 00cd9df..d4c4763 100644 --- a/src/main/java/com/webgame/webgamebackend/common/auth/AuthInterceptor.java +++ b/src/main/java/com/webgame/webgamebackend/common/auth/AuthInterceptor.java @@ -75,9 +75,10 @@ public class AuthInterceptor implements HandlerInterceptor { // 游戏模块 — 查看列表/详情无需认证 new PublicRoute("/game/list", "GET"), - new PublicRoute("/game/room/list", "GET"), - // GET /game/room/{roomId} 详情页,排除 POST/PUT/DELETE 等写操作 - new PublicRoute("/game/room/**", "GET") + // 房间模块 — 查看列表/详情无需认证 + new PublicRoute("/room/list", "GET"), + // GET /room/{roomId} 详情页,排除 POST/PUT/DELETE 等写操作 + new PublicRoute("/room/*", "GET") ); @Override diff --git a/src/main/java/com/webgame/webgamebackend/common/dto/game/CreateRoomRequest.java b/src/main/java/com/webgame/webgamebackend/common/dto/game/CreateRoomRequest.java index de6b41e..e29f549 100644 --- a/src/main/java/com/webgame/webgamebackend/common/dto/game/CreateRoomRequest.java +++ b/src/main/java/com/webgame/webgamebackend/common/dto/game/CreateRoomRequest.java @@ -30,16 +30,6 @@ public record CreateRoomRequest( /** 最大玩家数 */ @NotNull(message = "最大玩家数不能为空") @Positive(message = "最大玩家数必须为正数") - Integer maxPlayers, - - /** 是否允许观战,默认 true */ - Boolean allowSpectate + Integer maxPlayers ) { - - /** - * 获取观战开关值,未传时默认为 true - */ - public Boolean allowSpectate() { - return allowSpectate != null ? allowSpectate : true; - } } diff --git a/src/main/java/com/webgame/webgamebackend/common/dto/game/DisbandRoomRequest.java b/src/main/java/com/webgame/webgamebackend/common/dto/game/DisbandRoomRequest.java new file mode 100644 index 0000000..91422bf --- /dev/null +++ b/src/main/java/com/webgame/webgamebackend/common/dto/game/DisbandRoomRequest.java @@ -0,0 +1,11 @@ +package com.webgame.webgamebackend.common.dto.game; + +import jakarta.validation.constraints.NotBlank; + +/** + * 解散房间请求 + */ +public record DisbandRoomRequest( + @NotBlank(message = "房间ID不能为空") + String roomId +) {} diff --git a/src/main/java/com/webgame/webgamebackend/common/dto/game/RoomDetailResponse.java b/src/main/java/com/webgame/webgamebackend/common/dto/game/RoomDetailResponse.java index d9b0ae8..85277a7 100644 --- a/src/main/java/com/webgame/webgamebackend/common/dto/game/RoomDetailResponse.java +++ b/src/main/java/com/webgame/webgamebackend/common/dto/game/RoomDetailResponse.java @@ -20,7 +20,6 @@ public record RoomDetailResponse( String status, String creatorId, String creatorName, - Boolean allowSpectate, List players, /** 对局中才有棋盘状态 */ int[][] boardState, diff --git a/src/main/java/com/webgame/webgamebackend/common/dto/game/RoomItemResponse.java b/src/main/java/com/webgame/webgamebackend/common/dto/game/RoomItemResponse.java index dd18a87..9129181 100644 --- a/src/main/java/com/webgame/webgamebackend/common/dto/game/RoomItemResponse.java +++ b/src/main/java/com/webgame/webgamebackend/common/dto/game/RoomItemResponse.java @@ -19,8 +19,7 @@ public record RoomItemResponse( String statusText, int stakes, String mode, - String creatorName, - Boolean allowSpectate + String creatorName ) { /** 状态 → 展示文本映射 */ private static final Map STATUS_TEXT_MAP = Map.of( @@ -54,8 +53,7 @@ public record RoomItemResponse( STATUS_TEXT_MAP.getOrDefault(room.getStatus(), "未知"), room.getStakes(), room.getMode(), - creatorName, - room.getAllowSpectate() + creatorName ); } } diff --git a/src/main/java/com/webgame/webgamebackend/common/event/RoomEventBus.java b/src/main/java/com/webgame/webgamebackend/common/event/RoomEventBus.java new file mode 100644 index 0000000..c6a0219 --- /dev/null +++ b/src/main/java/com/webgame/webgamebackend/common/event/RoomEventBus.java @@ -0,0 +1,78 @@ +package com.webgame.webgamebackend.common.event; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; + +/** + * 内部事件总线 + * + * 用于 Room ↔ Game 之间的解耦通信。 + * GameEngine 通过此总线发布游戏事件(如 GAME_OVER), + * RoomManager 订阅这些事件并执行房间层处理(状态更新、结算等)。 + * + *

房间层到游戏层的通信通过 {@link com.webgame.webgamebackend.service.engine.GameEngine} + * 接口直接调用,不需要经过事件总线。

+ */ +@Slf4j +@Component +public class RoomEventBus { + + /** 事件类型 → 订阅者列表 */ + private final Map>> subscribers = new ConcurrentHashMap<>(); + + /** + * 订阅事件 + * + * @param eventType 事件类型 + * @param handler 事件处理器 + */ + public void subscribe(String eventType, Consumer handler) { + subscribers.computeIfAbsent(eventType, k -> new CopyOnWriteArrayList<>()).add(handler); + } + + /** + * 发布事件 + * + * @param eventType 事件类型 + * @param payload 事件数据 + */ + public void publish(String eventType, Object payload) { + List> handlers = subscribers.get(eventType); + if (handlers == null || handlers.isEmpty()) { + return; + } + for (Consumer handler : handlers) { + try { + handler.accept(payload); + } catch (Exception e) { + log.error("[事件总线] 处理事件异常, eventType={}", eventType, e); + } + } + } + + // ==================== 预定义事件类型 ==================== + + /** 游戏结束事件,payload 为 GameOverEvent */ + public static final String GAME_OVER = "game:over"; + + /** 游戏中玩家弃权(断线超时/被踢),payload 为 PlayerForfeitEvent */ + public static final String PLAYER_FORFEIT = "game:player_forfeit"; + + // ==================== 事件数据类 ==================== + + /** + * 游戏结束事件数据 + */ + public record GameOverEvent(String roomId, String winnerId, String resultType) {} + + /** + * 玩家弃权事件数据 + */ + public record PlayerForfeitEvent(String roomId, String userId, String reason) {} +} diff --git a/src/main/java/com/webgame/webgamebackend/config/EngineRegistrationConfig.java b/src/main/java/com/webgame/webgamebackend/config/EngineRegistrationConfig.java new file mode 100644 index 0000000..f8250ee --- /dev/null +++ b/src/main/java/com/webgame/webgamebackend/config/EngineRegistrationConfig.java @@ -0,0 +1,35 @@ +package com.webgame.webgamebackend.config; + +import com.webgame.webgamebackend.repository.GameMoveRepository; +import com.webgame.webgamebackend.service.engine.GameEngineRegistry; +import com.webgame.webgamebackend.service.engine.GomokuEngine; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Configuration; + +/** + * 游戏引擎注册配置 + * + * 在 Spring 启动后,将所有已实现的 GameEngine 注册到 GameEngineRegistry。 + * 新增游戏只需在此处添加一行注册即可。 + */ +@Slf4j +@Configuration +@RequiredArgsConstructor +public class EngineRegistrationConfig { + + private final GameEngineRegistry registry; + private final GameMoveRepository moveRepository; + + @PostConstruct + public void registerEngines() { + // 注册五子棋引擎 + registry.register("gomoku", context -> new GomokuEngine(moveRepository)); + log.info("[引擎注册] 已注册五子棋引擎, gameId=gomoku"); + + // TODO: 后续新游戏在此注册,例如: + // registry.register("snake", context -> new SnakeEngine(...)); + // registry.register("tetris", context -> new TetrisEngine(...)); + } +} diff --git a/src/main/java/com/webgame/webgamebackend/controller/GameController.java b/src/main/java/com/webgame/webgamebackend/controller/GameController.java index 32ab761..f63ddb6 100644 --- a/src/main/java/com/webgame/webgamebackend/controller/GameController.java +++ b/src/main/java/com/webgame/webgamebackend/controller/GameController.java @@ -1,27 +1,25 @@ package com.webgame.webgamebackend.controller; -import com.webgame.webgamebackend.common.auth.AuthenticationProvider; import com.webgame.webgamebackend.common.dto.ApiResponse; -import com.webgame.webgamebackend.common.dto.game.*; +import com.webgame.webgamebackend.common.dto.game.GameListResponse; import com.webgame.webgamebackend.service.GameService; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; /** * 游戏控制器 * - * 管理游戏列表查询、房间的创建/加入/离开/踢人/准备/开始等操作。 + * 管理游戏配置查询(游戏列表、游戏详情等)。 + * 房间相关操作已迁移到 {@link RoomController}。 */ -@Validated @RestController @RequestMapping("/game") @RequiredArgsConstructor public class GameController { private final GameService gameService; - private final AuthenticationProvider authProvider; /** * 获取游戏列表 @@ -33,104 +31,4 @@ public class GameController { GameListResponse result = gameService.getGameList(); return ApiResponse.ok(result); } - - /** - * 获取房间列表 - * - * 支持按状态筛选。无需登录即可调用。 - * - * @param gameId 游戏 ID(必填) - * @param status 状态筛选(可选) - */ - @GetMapping("/room/list") - public ApiResponse roomList( - @RequestParam String gameId, - @RequestParam(required = false) String status) { - RoomListResponse result = gameService.getRoomList(gameId, status); - return ApiResponse.ok(result); - } - - /** - * 创建房间 - * - * 需登录。房主自动加入房间并设为已准备。 - */ - @PostMapping("/room/create") - public ApiResponse createRoom(@Valid @RequestBody CreateRoomRequest request) { - String userId = authProvider.getCurrentUserId(); - CreateRoomResponse result = gameService.createRoom(request, userId); - return ApiResponse.ok(result); - } - - /** - * 加入房间 - * - * 需登录。公开房间无需传密码。 - */ - @PostMapping("/room/join") - public ApiResponse joinRoom(@Valid @RequestBody JoinRoomRequest request) { - String userId = authProvider.getCurrentUserId(); - RoomItemResponse result = gameService.joinRoom(request, userId); - return ApiResponse.ok(result); - } - - /** - * 离开房间 - * - * 需登录。房主离开时顺位转让。无人时房间自动结束。 - */ - @PostMapping("/room/leave") - public ApiResponse leaveRoom(@Valid @RequestBody LeaveRoomRequest request) { - String userId = authProvider.getCurrentUserId(); - gameService.leaveRoom(request.roomId(), userId); - return ApiResponse.ok(null); - } - - /** - * 踢出玩家 - * - * 需登录。仅房主可操作。 - */ - @PostMapping("/room/kick") - public ApiResponse kickPlayer(@Valid @RequestBody KickPlayerRequest request) { - String ownerId = authProvider.getCurrentUserId(); - gameService.kickPlayer(request.roomId(), ownerId, request.userId()); - return ApiResponse.ok(null); - } - - /** - * 切换准备状态 - * - * 需登录。在等待中的房间里切换准备/取消准备。 - */ - @PostMapping("/room/ready") - public ApiResponse toggleReady(@Valid @RequestBody ReadyRequest request) { - String userId = authProvider.getCurrentUserId(); - boolean ready = gameService.toggleReady(request.roomId(), userId); - return ApiResponse.ok(ready); - } - - /** - * 获取房间详情 - * - * 无需登录即可查看(观战者也可查看)。 - */ - @GetMapping("/room/{roomId}") - public ApiResponse roomDetail(@PathVariable String roomId) { - RoomDetailResponse result = gameService.getRoomDetail(roomId); - return ApiResponse.ok(result); - } - - /** - * 开始游戏 - * - * 需登录。仅房主可操作。需全部玩家准备且人数 >= 2。 - * 开始后扣除所有玩家的底注。 - */ - @PostMapping("/room/start") - public ApiResponse startGame(@Valid @RequestBody StartGameRequest request) { - String ownerId = authProvider.getCurrentUserId(); - gameService.startGame(request.roomId(), ownerId); - return ApiResponse.ok(null); - } } diff --git a/src/main/java/com/webgame/webgamebackend/controller/RoomController.java b/src/main/java/com/webgame/webgamebackend/controller/RoomController.java new file mode 100644 index 0000000..30809c6 --- /dev/null +++ b/src/main/java/com/webgame/webgamebackend/controller/RoomController.java @@ -0,0 +1,121 @@ +package com.webgame.webgamebackend.controller; + +import com.webgame.webgamebackend.common.auth.AuthenticationProvider; +import com.webgame.webgamebackend.common.dto.ApiResponse; +import com.webgame.webgamebackend.common.dto.game.*; +import com.webgame.webgamebackend.service.RoomManager; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +/** + * 房间控制器 + * + * 管理房间的创建、加入、离开、解散、踢人、准备、开始等操作。 + * 所有房间内实时操作通过 WebSocket 处理,此控制器仅提供 REST 接口。 + */ +@Validated +@RestController +@RequestMapping("/room") +@RequiredArgsConstructor +public class RoomController { + + private final RoomManager roomManager; + private final AuthenticationProvider authProvider; + + /** + * 获取房间列表 + * + * @param gameId 游戏 ID(必填) + * @param status 状态筛选(可选) + */ + @GetMapping("/list") + public ApiResponse roomList( + @RequestParam String gameId, + @RequestParam(required = false) String status) { + RoomListResponse result = roomManager.getRoomList(gameId, status); + return ApiResponse.ok(result); + } + + /** + * 获取房间详情 + */ + @GetMapping("/{roomId}") + public ApiResponse roomDetail(@PathVariable String roomId) { + RoomDetailResponse result = roomManager.getRoomDetail(roomId); + return ApiResponse.ok(result); + } + + /** + * 创建房间 + * + * 需登录。房主自动加入房间并设为已准备。 + */ + @PostMapping("/create") + public ApiResponse createRoom(@Valid @RequestBody CreateRoomRequest request) { + String userId = authProvider.getCurrentUserId(); + CreateRoomResponse result = roomManager.createRoom(request, userId); + return ApiResponse.ok(result); + } + + /** + * 加入房间 + */ + @PostMapping("/join") + public ApiResponse joinRoom(@Valid @RequestBody JoinRoomRequest request) { + String userId = authProvider.getCurrentUserId(); + RoomItemResponse result = roomManager.joinRoom(request, userId); + return ApiResponse.ok(result); + } + + /** + * 离开房间 + */ + @PostMapping("/leave") + public ApiResponse leaveRoom(@Valid @RequestBody LeaveRoomRequest request) { + String userId = authProvider.getCurrentUserId(); + roomManager.leaveRoom(request.roomId(), userId); + return ApiResponse.ok(null); + } + + /** + * 解散房间(仅房主) + */ + @PostMapping("/disband") + public ApiResponse disbandRoom(@Valid @RequestBody DisbandRoomRequest request) { + String userId = authProvider.getCurrentUserId(); + roomManager.disbandRoom(request.roomId(), userId); + return ApiResponse.ok(null); + } + + /** + * 踢出玩家(仅房主) + */ + @PostMapping("/kick") + public ApiResponse kickPlayer(@Valid @RequestBody KickPlayerRequest request) { + String ownerId = authProvider.getCurrentUserId(); + roomManager.kickPlayer(request.roomId(), ownerId, request.userId()); + return ApiResponse.ok(null); + } + + /** + * 切换准备状态 + */ + @PostMapping("/ready") + public ApiResponse toggleReady(@Valid @RequestBody ReadyRequest request) { + String userId = authProvider.getCurrentUserId(); + boolean ready = roomManager.toggleReady(request.roomId(), userId); + return ApiResponse.ok(ready); + } + + /** + * 开始游戏(仅房主) + */ + @PostMapping("/start") + public ApiResponse startGame(@Valid @RequestBody StartGameRequest request) { + String ownerId = authProvider.getCurrentUserId(); + roomManager.startGame(request.roomId(), ownerId); + return ApiResponse.ok(null); + } +} diff --git a/src/main/java/com/webgame/webgamebackend/entities/GameRoomEntity.java b/src/main/java/com/webgame/webgamebackend/entities/GameRoomEntity.java index 3d164dc..72dd8c5 100644 --- a/src/main/java/com/webgame/webgamebackend/entities/GameRoomEntity.java +++ b/src/main/java/com/webgame/webgamebackend/entities/GameRoomEntity.java @@ -62,17 +62,11 @@ public class GameRoomEntity extends UUIDBaseEntity { private Integer stakes; /** - * 房间密码(bcrypt 哈希),null 表示公开房间 + * 房间密码(明文),null 表示公开房间 */ @Column(name = "password", length = 64) private String password; - /** - * 是否允许观战 - */ - @Column(name = "allow_spectate", nullable = false) - private Boolean allowSpectate = true; - /** * 房间状态:waiting / playing / finished */ diff --git a/src/main/java/com/webgame/webgamebackend/repository/GameRoomRepository.java b/src/main/java/com/webgame/webgamebackend/repository/GameRoomRepository.java index 333751e..7cabca5 100644 --- a/src/main/java/com/webgame/webgamebackend/repository/GameRoomRepository.java +++ b/src/main/java/com/webgame/webgamebackend/repository/GameRoomRepository.java @@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -47,4 +48,13 @@ public interface GameRoomRepository extends JpaRepository findByIdForUpdate(@Param("id") String id); + + /** + * 根据状态和更新时间查询房间(用于超时清理) + * + * @param status 房间状态 + * @param updateTime 更新时间上限 + * @return 符合条件的房间列表 + */ + List findByStatusAndUpdateTimeBefore(String status, LocalDateTime updateTime); } diff --git a/src/main/java/com/webgame/webgamebackend/service/GameService.java b/src/main/java/com/webgame/webgamebackend/service/GameService.java index 12f0763..1672fd7 100644 --- a/src/main/java/com/webgame/webgamebackend/service/GameService.java +++ b/src/main/java/com/webgame/webgamebackend/service/GameService.java @@ -1,9 +1,12 @@ package com.webgame.webgamebackend.service; -import com.webgame.webgamebackend.common.dto.game.*; +import com.webgame.webgamebackend.common.dto.game.GameListResponse; /** * 游戏服务接口 + * + * 管理游戏配置相关的查询。 + * 房间管理已迁移到 {@link RoomManager}。 */ public interface GameService { @@ -13,73 +16,4 @@ public interface GameService { * @return 所有启用的游戏信息 */ GameListResponse getGameList(); - - /** - * 获取指定游戏的房间列表 - * - * @param gameId 游戏标识 - * @param status 状态筛选(可选),不传返回所有未结束的房间 - * @return 房间列表 - */ - RoomListResponse getRoomList(String gameId, String status); - - /** - * 创建房间 - * - * @param request 创建房间请求 - * @param creatorId 房主 account_id - * @return 新建的房间信息 - */ - CreateRoomResponse createRoom(CreateRoomRequest request, String creatorId); - - /** - * 加入房间 - * - * @param request 加入房间请求 - * @param userId 用户 account_id - * @return 房间信息 - */ - RoomItemResponse joinRoom(JoinRoomRequest request, String userId); - - /** - * 离开房间 - * - * @param roomId 房间 ID - * @param userId 用户 account_id - */ - void leaveRoom(String roomId, String userId); - - /** - * 房主踢出玩家 - * - * @param roomId 房间 ID - * @param ownerId 房主 account_id - * @param targetUserId 目标用户 account_id - */ - void kickPlayer(String roomId, String ownerId, String targetUserId); - - /** - * 切换准备状态 - * - * @param roomId 房间 ID - * @param userId 用户 account_id - * @return 切换后的准备状态 - */ - boolean toggleReady(String roomId, String userId); - - /** - * 开始游戏 - * - * @param roomId 房间 ID - * @param ownerId 房主 account_id - */ - void startGame(String roomId, String ownerId); - - /** - * 获取房间详情(进入房间时获取初始快照) - * - * @param roomId 房间 ID - * @return 房间详情 - */ - RoomDetailResponse getRoomDetail(String roomId); } diff --git a/src/main/java/com/webgame/webgamebackend/service/GomokuGameService.java b/src/main/java/com/webgame/webgamebackend/service/GomokuGameService.java deleted file mode 100644 index 3274581..0000000 --- a/src/main/java/com/webgame/webgamebackend/service/GomokuGameService.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.webgame.webgamebackend.service; - -import com.webgame.webgamebackend.entities.GameMoveEntity; -import com.webgame.webgamebackend.ws.RoomSessionManager; - -import java.util.List; - -/** - * 五子棋对局逻辑服务 - * - * 管理五子棋对局的完整生命周期:落子校验、胜负判定、走棋记录。 - * 通过 RoomSessionManager 向房间内 WebSocket 会话广播状态变更。 - */ -public interface GomokuGameService { - - /** 棋盘大小 */ - int BOARD_SIZE = 15; - - /** - * 落子 - * - * @param roomId 房间 ID - * @param userId 落子玩家 ID - * @param row 行坐标(0 起始) - * @param col 列坐标(0 起始) - * @param sessionManager 会话管理器 - */ - 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 roomId 房间 ID - * @param userId 用户 ID - */ - void cancelDisconnectTimeout(String roomId, String userId); - - /** - * 从走棋记录重建棋盘状态(静态工具方法) - * - * @param moves 走棋记录列表 - * @param boardSize 棋盘大小 - * @return 棋盘二维数组,0=空,1=第一位玩家(黑方),2=第二位玩家(白方) - */ - static int[][] rebuildBoard(List moves, int boardSize) { - int[][] board = new int[boardSize][boardSize]; - for (GameMoveEntity move : moves) { - int player = (move.getMoveIndex() % 2 == 1) ? 1 : 2; - board[move.getRowNum()][move.getColNum()] = player; - } - return board; - } -} diff --git a/src/main/java/com/webgame/webgamebackend/service/RoomManager.java b/src/main/java/com/webgame/webgamebackend/service/RoomManager.java new file mode 100644 index 0000000..e40812a --- /dev/null +++ b/src/main/java/com/webgame/webgamebackend/service/RoomManager.java @@ -0,0 +1,989 @@ +package com.webgame.webgamebackend.service; + +import com.alibaba.fastjson2.JSON; +import com.webgame.webgamebackend.common.dto.game.*; +import com.webgame.webgamebackend.common.dto.game.RoomDetailResponse.RoomPlayerDetail; +import com.webgame.webgamebackend.common.event.RoomEventBus; +import com.webgame.webgamebackend.common.event.SseEventPublisher; +import com.webgame.webgamebackend.common.event.SseEventType; +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.engine.*; +import com.webgame.webgamebackend.ws.RoomSessionManager; +import com.webgame.webgamebackend.ws.dto.WsMessage; +import com.webgame.webgamebackend.ws.dto.WsOutboundMessage; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.*; +import java.util.stream.Collectors; + +/** + * 房间管理器 — 统一管理所有房间生命周期 + * + *

职责

+ *
    + *
  • 房间 CRUD:创建、加入、离开、解散、踢人
  • + *
  • 状态流转:WAITING → PLAYING → FINISHED
  • + *
  • 准备/取消准备
  • + *
  • 开始游戏(委托给 GameEngine)
  • + *
  • 断线重连管理(60 秒倒计时)
  • + *
  • 超时房间清理(定时任务)
  • + *
  • 游戏结束结算(房间层负责金币变更)
  • + *
+ * + *

房间数据持久化到 MySQL,运行时状态(游戏引擎引用、断线定时器) + * 缓存在内存中。

+ */ +@Slf4j +@Component +public class RoomManager { + + private final GameConfigRepository gameConfigRepository; + private final GameRoomRepository gameRoomRepository; + private final RoomPlayerRepository roomPlayerRepository; + private final UserRepository userRepository; + private final GameRecordRepository gameRecordRepository; + private final GameMoveRepository gameMoveRepository; + private final RoomSessionManager sessionManager; + private final GameEngineRegistry engineRegistry; + private final SseEventPublisher sseEventPublisher; + private final RoomEventBus eventBus; + + // ==================== 内存状态 ==================== + + /** 断线重连超时时间(秒) */ + private static final long DISCONNECT_TIMEOUT_SECONDS = 60; + + /** 断线重连定时器线程池 */ + private final ScheduledExecutorService reconnectScheduler = + Executors.newSingleThreadScheduledExecutor(r -> + new Thread(r, "room-reconnect")); + + /** 房间运行时状态缓存: roomId → RoomRuntime */ + private final ConcurrentHashMap roomRuntimes = new ConcurrentHashMap<>(); + + /** + * 房间运行时状态(不可持久化的部分) + */ + private static class RoomRuntime { + /** 活跃的游戏引擎(PLAYING 状态下非 null) */ + volatile GameEngine gameEngine; + /** 断线倒计时任务: userId → Future */ + final ConcurrentHashMap> disconnectTimers = new ConcurrentHashMap<>(); + + RoomRuntime() {} + RoomRuntime(GameEngine gameEngine) { this.gameEngine = gameEngine; } + } + + public RoomManager(GameConfigRepository gameConfigRepository, + GameRoomRepository gameRoomRepository, + RoomPlayerRepository roomPlayerRepository, + UserRepository userRepository, + GameRecordRepository gameRecordRepository, + GameMoveRepository gameMoveRepository, + RoomSessionManager sessionManager, + GameEngineRegistry engineRegistry, + SseEventPublisher sseEventPublisher, + RoomEventBus eventBus) { + this.gameConfigRepository = gameConfigRepository; + this.gameRoomRepository = gameRoomRepository; + this.roomPlayerRepository = roomPlayerRepository; + this.userRepository = userRepository; + this.gameRecordRepository = gameRecordRepository; + this.gameMoveRepository = gameMoveRepository; + this.sessionManager = sessionManager; + this.engineRegistry = engineRegistry; + this.sseEventPublisher = sseEventPublisher; + this.eventBus = eventBus; + } + + // ==================== 房间 CRUD ==================== + + /** + * 创建房间 + * + * @param request 创建房间请求 + * @param creatorId 房主 accountId + * @return 创建结果 + */ + public CreateRoomResponse createRoom(CreateRoomRequest request, String creatorId) { + // 校验游戏存在且启用 + GameConfigEntity gameConfig = gameConfigRepository.findByGameId(request.gameId()) + .orElseThrow(() -> new BusinessException(ErrorCode.GAME_NOT_FOUND)); + if (!gameConfig.getEnabled()) { + throw new BusinessException(ErrorCode.GAME_NOT_FOUND); + } + + // 校验 mode + List modes = JSON.parseArray(gameConfig.getModes(), String.class); + if (!modes.contains(request.mode())) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的模式: " + request.mode()); + } + + // 校验 stakes + List stakesOptions = JSON.parseArray(gameConfig.getStakesOptions(), Integer.class); + if (!stakesOptions.contains(request.stakes())) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的底注: " + request.stakes()); + } + + // 检查用户是否已在其他房间 + if (roomPlayerRepository.existsByUserId(creatorId)) { + throw new BusinessException(ErrorCode.ALREADY_IN_ROOM); + } + + // 生成桌号 + Integer maxRoomNumber = gameRoomRepository.findMaxRoomNumberByGameId(request.gameId()); + int nextRoomNumber = (maxRoomNumber == null) ? 1 : maxRoomNumber + 1; + + // 生成桌名 + String roomName = (request.name() != null && !request.name().isBlank()) + ? request.name() + : "桌 " + String.format("%02d", nextRoomNumber); + + // 创建房间实体 + GameRoomEntity room = new GameRoomEntity() + .setGameId(request.gameId()) + .setRoomNumber(nextRoomNumber) + .setName(roomName) + .setMaxPlayers(request.maxPlayers()) + .setMode(request.mode()) + .setStakes(request.stakes()) + .setStatus("waiting") + .setCreatorId(creatorId); + + // 密码明文存储 + if (request.password() != null && !request.password().isBlank()) { + room.setPassword(request.password()); + } + + gameRoomRepository.save(room); + + // 房主自动加入(座位 1,已准备) + RoomPlayerEntity creatorPlayer = new RoomPlayerEntity() + .setRoomId(room.getId()) + .setUserId(creatorId) + .setSeatNumber(1) + .setReadyStatus(true) + .setJoinTime(LocalDateTime.now()); + roomPlayerRepository.save(creatorPlayer); + + log.info("[房间管理] 创建房间, roomId={}, gameId={}, creatorId={}", room.getId(), request.gameId(), creatorId); + + // 通知大厅 + RoomItemResponse response = buildRoomItemResponse(room); + sseEventPublisher.broadcast(SseEventType.ROOM_CREATED, response); + + return new CreateRoomResponse(response); + } + + /** + * 加入房间 + * + * @param request 加入房间请求 + * @param userId 用户 accountId + * @return 房间信息 + */ + public RoomItemResponse joinRoom(JoinRoomRequest request, String userId) { + GameRoomEntity room = gameRoomRepository.findById(request.roomId()) + .orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND)); + + if (!"waiting".equals(room.getStatus())) { + throw new BusinessException(ErrorCode.ROOM_ALREADY_STARTED); + } + + // 密码校验(明文比对) + if (room.getPassword() != null && !room.getPassword().isBlank()) { + if (request.password() == null || request.password().isBlank()) { + throw new BusinessException(ErrorCode.ROOM_PASSWORD_ERROR, "请输入房间密码"); + } + if (!room.getPassword().equals(request.password())) { + throw new BusinessException(ErrorCode.ROOM_PASSWORD_ERROR); + } + } + + // 检查房间是否已满 + int currentCount = roomPlayerRepository.countByRoomId(room.getId()); + if (currentCount >= room.getMaxPlayers()) { + throw new BusinessException(ErrorCode.ROOM_FULL); + } + + // 检查用户是否已在其他房间 + if (roomPlayerRepository.existsByUserId(userId)) { + throw new BusinessException(ErrorCode.ALREADY_IN_ROOM); + } + + // 分配座位号 + List existing = roomPlayerRepository.findByRoomId(room.getId()); + int nextSeat = 1; + if (!existing.isEmpty()) { + int maxSeat = existing.stream().mapToInt(RoomPlayerEntity::getSeatNumber).max().orElse(0); + nextSeat = maxSeat + 1; + } + + RoomPlayerEntity player = new RoomPlayerEntity() + .setRoomId(room.getId()) + .setUserId(userId) + .setSeatNumber(nextSeat) + .setReadyStatus(false) + .setJoinTime(LocalDateTime.now()); + roomPlayerRepository.save(player); + + log.info("[房间管理] 玩家加入, roomId={}, userId={}, seat={}", room.getId(), userId, nextSeat); + + // WS 广播玩家加入 + RoomPlayerDetail detail = buildPlayerDetail(userId, nextSeat, false); + broadcastToRoom(room.getId(), new WsMessage(WsOutboundMessage.PLAYER_JOIN, detail)); + + // SSE 通知大厅 + RoomItemResponse response = buildRoomItemResponse(room); + sseEventPublisher.broadcast(SseEventType.ROOM_UPDATED, response); + + return response; + } + + /** + * 离开房间 + * + * @param roomId 房间 ID + * @param userId 用户 accountId + */ + public void leaveRoom(String roomId, String userId) { + GameRoomEntity room = gameRoomRepository.findById(roomId) + .orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND)); + + RoomPlayerEntity player = roomPlayerRepository.findByRoomIdAndUserId(roomId, userId) + .orElseThrow(() -> new BusinessException(ErrorCode.PLAYER_NOT_IN_ROOM)); + + // 如果游戏进行中,通知引擎玩家离开(视为认输) + RoomRuntime runtime = roomRuntimes.get(roomId); + if ("playing".equals(room.getStatus()) && runtime != null && runtime.gameEngine != null) { + runtime.gameEngine.onPlayerLeave(userId); + } + + // 取消断线倒计时 + cancelDisconnectTimer(roomId, userId); + + roomPlayerRepository.delete(player); + List remaining = roomPlayerRepository.findByRoomId(roomId); + + if (remaining.isEmpty()) { + // 无人了,清理并删除房间 + cleanupRoom(roomId); + gameRoomRepository.delete(room); + log.info("[房间管理] 房间无人,已删除, roomId={}", roomId); + sseEventPublisher.broadcast(SseEventType.ROOM_REMOVED, Map.of("roomId", roomId)); + } else if (userId.equals(room.getCreatorId())) { + // 房主离开,顺位转让 + RoomPlayerEntity nextOwner = remaining.get(0); + room.setCreatorId(nextOwner.getUserId()); + nextOwner.setReadyStatus(true); + roomPlayerRepository.save(nextOwner); + gameRoomRepository.save(room); + log.info("[房间管理] 房主转让, roomId={}, newOwner={}", roomId, nextOwner.getUserId()); + // 通知更新 + broadcastToRoom(roomId, new WsMessage(WsOutboundMessage.PLAYER_LEAVE, Map.of("userId", userId))); + pushRoomUpdatedSse(room); + } else { + broadcastToRoom(roomId, new WsMessage(WsOutboundMessage.PLAYER_LEAVE, Map.of("userId", userId))); + pushRoomUpdatedSse(room); + } + + log.info("[房间管理] 玩家离开, roomId={}, userId={}", roomId, userId); + } + + /** + * 解散房间(仅房主) + * + * @param roomId 房间 ID + * @param ownerId 房主 accountId + */ + public void disbandRoom(String roomId, String ownerId) { + GameRoomEntity room = gameRoomRepository.findById(roomId) + .orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND)); + + if (!ownerId.equals(room.getCreatorId())) { + throw new BusinessException(ErrorCode.NOT_ROOM_OWNER); + } + + // 如果游戏进行中,销毁引擎 + RoomRuntime runtime = roomRuntimes.get(roomId); + if (runtime != null && runtime.gameEngine != null) { + runtime.gameEngine.destroy(); + } + + // 通知房间内所有人被踢 + broadcastToRoom(roomId, new WsMessage("room_disbanded", Map.of("roomId", roomId))); + + // 关闭房间内所有 WS 连接 + for (WebSocketSession ws : sessionManager.getRoomSessions(roomId)) { + try { ws.close(); } catch (Exception ignored) {} + } + + // 清理并删除 + cleanupRoom(roomId); + roomPlayerRepository.deleteAll(roomPlayerRepository.findByRoomId(roomId)); + gameRoomRepository.delete(room); + + log.info("[房间管理] 房间已解散, roomId={}", roomId); + sseEventPublisher.broadcast(SseEventType.ROOM_REMOVED, Map.of("roomId", roomId)); + } + + /** + * 踢出玩家(仅房主) + * + * @param roomId 房间 ID + * @param ownerId 房主 accountId + * @param targetUserId 目标用户 accountId + */ + public void kickPlayer(String roomId, String ownerId, String targetUserId) { + GameRoomEntity room = gameRoomRepository.findById(roomId) + .orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND)); + + if (!ownerId.equals(room.getCreatorId())) { + throw new BusinessException(ErrorCode.NOT_ROOM_OWNER); + } + if (ownerId.equals(targetUserId)) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "不能踢出自己,请使用离开房间"); + } + + RoomPlayerEntity target = roomPlayerRepository.findByRoomIdAndUserId(roomId, targetUserId) + .orElseThrow(() -> new BusinessException(ErrorCode.PLAYER_NOT_IN_ROOM)); + + // 如果游戏进行中,通知引擎 + RoomRuntime runtime = roomRuntimes.get(roomId); + if ("playing".equals(room.getStatus()) && runtime != null && runtime.gameEngine != null) { + runtime.gameEngine.onPlayerLeave(targetUserId); + } + + cancelDisconnectTimer(roomId, targetUserId); + roomPlayerRepository.delete(target); + + log.info("[房间管理] 踢出玩家, roomId={}, targetUserId={}", roomId, targetUserId); + + // WS 通知 + broadcastToRoom(roomId, new WsMessage(WsOutboundMessage.PLAYER_LEAVE, Map.of("userId", targetUserId))); + + // SSE 通知被踢者 + sseEventPublisher.sendTo(targetUserId, SseEventType.KICKED, Map.of("roomId", roomId)); + + // 大厅更新 + pushRoomUpdatedSse(room); + } + + /** + * 切换准备状态 + * + * @param roomId 房间 ID + * @param userId 用户 accountId + * @return 新的准备状态 + */ + public boolean toggleReady(String roomId, String userId) { + GameRoomEntity room = gameRoomRepository.findById(roomId) + .orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND)); + + 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); + + // WS 广播 + broadcastToRoom(roomId, new WsMessage(WsOutboundMessage.READY_CHANGE, Map.of( + "userId", userId, "ready", newStatus + ))); + + // SSE 大厅更新 + pushRoomUpdatedSse(room); + + return newStatus; + } + + /** + * 开始游戏(仅房主) + * + * @param roomId 房间 ID + * @param userId 操作者 accountId(必须是房主) + */ + public void startGame(String roomId, String userId) { + GameRoomEntity room = gameRoomRepository.findById(roomId) + .orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND)); + + 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); + } + } + + // 检查引擎是否已注册 + if (!engineRegistry.isRegistered(room.getGameId())) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "该游戏尚未实现引擎: " + room.getGameId()); + } + + // 更新房间状态 + room.setStatus("playing"); + gameRoomRepository.save(room); + + // 创建游戏记录 + GameRecordEntity record = new GameRecordEntity() + .setRoomId(roomId) + .setStartedAt(LocalDateTime.now()); + gameRecordRepository.save(record); + + // 构建房间上下文 + List sortedPlayers = players.stream() + .sorted(Comparator.comparingInt(RoomPlayerEntity::getSeatNumber)) + .toList(); + + List playerInfos = sortedPlayers.stream() + .map(p -> new RoomContext.PlayerInfo(p.getUserId(), p.getSeatNumber())) + .toList(); + + RoomContext context = new RoomContext( + roomId, + room.getGameId(), + playerInfos, + room.getMode(), + room.getStakes(), + msg -> broadcastToRoom(roomId, msg), + (targetUserId, msg) -> sendToUser(roomId, targetUserId, msg), + (winnerId, resultType) -> onGameOver(roomId, winnerId, resultType) + ); + + // 创建并初始化游戏引擎 + GameEngine engine = engineRegistry.create(room.getGameId(), context); + engine.init(context); + + // 缓存运行时 + RoomRuntime runtime = roomRuntimes.computeIfAbsent(roomId, k -> new RoomRuntime()); + runtime.gameEngine = engine; + + log.info("[房间管理] 游戏开始, roomId={}, gameId={}, 玩家数={}", roomId, room.getGameId(), players.size()); + + // SSE 大厅更新 + pushRoomUpdatedSse(room); + } + + // ==================== 断线重连 ==================== + + /** + * 处理 WebSocket 断线 + * + * 启动 60 秒重连倒计时,超时后根据房间状态处理: + * - WAITING:自动离开房间 + * - PLAYING:通知 GameEngine(默认处理:自动认输后离开) + * + * @param roomId 房间 ID + * @param userId 断线用户 accountId + */ + public void handleDisconnect(String roomId, String userId) { + RoomRuntime runtime = roomRuntimes.get(roomId); + if (runtime == null) { + return; + } + + // 避免重复启动定时器 + if (runtime.disconnectTimers.containsKey(userId)) { + return; + } + + String taskKey = roomId + ":" + userId; + log.info("[房间管理] 玩家断线,启动{}秒重连倒计时, roomId={}, userId={}", + DISCONNECT_TIMEOUT_SECONDS, roomId, userId); + + ScheduledFuture task = reconnectScheduler.schedule(() -> { + runtime.disconnectTimers.remove(userId); + try { + onDisconnectTimeout(roomId, userId); + } catch (Exception e) { + log.error("[房间管理] 断线超时处理异常, roomId={}, userId={}", roomId, userId, e); + } + }, DISCONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + runtime.disconnectTimers.put(userId, task); + + // 通知游戏引擎 + if (runtime.gameEngine != null) { + runtime.gameEngine.onPlayerDisconnect(userId); + } + + // WS 广播断线 + broadcastToRoom(roomId, new WsMessage("player_disconnected", Map.of("userId", userId))); + } + + /** + * 处理 WebSocket 重连 + * + * 取消断线倒计时,推送完整状态给重连玩家。 + * + * @param roomId 房间 ID + * @param userId 重连用户 accountId + * @return 完整房间状态快照,用于前端恢复 + */ + public RoomDetailResponse handleReconnect(String roomId, String userId) { + GameRoomEntity room = gameRoomRepository.findById(roomId) + .orElse(null); + if (room == null) { + return null; + } + + // 取消倒计时 + cancelDisconnectTimer(roomId, userId); + + // 通知游戏引擎 + RoomRuntime runtime = roomRuntimes.get(roomId); + if (runtime != null && runtime.gameEngine != null) { + runtime.gameEngine.onPlayerReconnect(userId); + } + + // WS 广播重连 + broadcastToRoom(roomId, new WsMessage("player_reconnected", Map.of("userId", userId))); + + log.info("[房间管理] 玩家重连, roomId={}, userId={}", roomId, userId); + + // 返回完整房间详情 + return buildRoomDetail(room); + } + + /** + * 取消断线倒计时 + */ + private void cancelDisconnectTimer(String roomId, String userId) { + RoomRuntime runtime = roomRuntimes.get(roomId); + if (runtime == null) return; + + ScheduledFuture task = runtime.disconnectTimers.remove(userId); + if (task != null) { + task.cancel(false); + log.info("[房间管理] 取消断线倒计时, roomId={}, userId={}", roomId, userId); + } + } + + /** + * 断线超时处理 + */ + private void onDisconnectTimeout(String roomId, String userId) { + GameRoomEntity room = gameRoomRepository.findById(roomId).orElse(null); + if (room == null) return; + + boolean stillInRoom = roomPlayerRepository.findByRoomIdAndUserId(roomId, userId).isPresent(); + if (!stillInRoom) return; + + if ("playing".equals(room.getStatus())) { + log.info("[房间管理] 断线超时:对局中自动判负, roomId={}, userId={}", roomId, userId); + // 通知引擎,引擎内部处理认输 + RoomRuntime runtime = roomRuntimes.get(roomId); + if (runtime != null && runtime.gameEngine != null) { + runtime.gameEngine.onPlayerLeave(userId); + } + // 从房间移除 + leaveRoomSilent(roomId, userId); + } else { + log.info("[房间管理] 断线超时:等待中自动离开, roomId={}, userId={}", roomId, userId); + leaveRoomSilent(roomId, userId); + } + } + + /** + * 静默离开(不触发额外的游戏事件,超时专用) + */ + private void leaveRoomSilent(String roomId, String userId) { + RoomPlayerEntity player = roomPlayerRepository.findByRoomIdAndUserId(roomId, userId).orElse(null); + if (player == null) return; + + cancelDisconnectTimer(roomId, userId); + roomPlayerRepository.delete(player); + GameRoomEntity room = gameRoomRepository.findById(roomId).orElse(null); + if (room == null) return; + + List remaining = roomPlayerRepository.findByRoomId(roomId); + + if (remaining.isEmpty()) { + cleanupRoom(roomId); + gameRoomRepository.delete(room); + sseEventPublisher.broadcast(SseEventType.ROOM_REMOVED, Map.of("roomId", roomId)); + } else if (userId.equals(room.getCreatorId())) { + RoomPlayerEntity nextOwner = remaining.get(0); + room.setCreatorId(nextOwner.getUserId()); + nextOwner.setReadyStatus(true); + roomPlayerRepository.save(nextOwner); + gameRoomRepository.save(room); + broadcastToRoom(roomId, new WsMessage(WsOutboundMessage.PLAYER_LEAVE, Map.of("userId", userId))); + pushRoomUpdatedSse(room); + } else { + broadcastToRoom(roomId, new WsMessage(WsOutboundMessage.PLAYER_LEAVE, Map.of("userId", userId))); + pushRoomUpdatedSse(room); + } + } + + // ==================== 游戏结束回调 ==================== + + /** + * 游戏结束回调(由 GameEngine 通过 RoomContext 调用) + * + * 房间层负责: + * 1. 更新房间状态为 FINISHED + * 2. 保存对局记录(胜负结果) + * 3. 金币结算(TODO) + * 4. 广播 game_over + SSE 推送 + * + * @param roomId 房间 ID + * @param winnerId 胜者 accountId(null 表示平局) + * @param resultType 结果类型(win/draw/resign) + */ + public void onGameOver(String roomId, String winnerId, String resultType) { + GameRoomEntity room = gameRoomRepository.findById(roomId).orElse(null); + if (room == null) return; + + 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); + } + + // TODO: 金币结算 — 房间层从玩家余额扣除/增加 stakes + + log.info("[房间管理] 游戏结束, roomId={}, winnerId={}, resultType={}", roomId, winnerId, resultType); + + // WS 广播游戏结束 + broadcastToRoom(roomId, new WsMessage(WsOutboundMessage.GAME_OVER, Map.of( + "winnerId", winnerId != null ? winnerId : "", + "resultType", resultType + ))); + + // SSE 大厅更新 + pushRoomUpdatedSse(room); + + // 发布事件(供其他模块订阅) + eventBus.publish(RoomEventBus.GAME_OVER, new RoomEventBus.GameOverEvent(roomId, winnerId, resultType)); + } + + /** + * 执行游戏动作(委托给 GameEngine) + * + * 由 WebSocket Handler 调用,RoomManager 查找对应房间的活跃引擎并转发动作。 + * + * @param roomId 房间 ID + * @param userId 操作者 accountId + * @param action 动作类型 + * @param data 动作数据(可为 null) + */ + public void executeGameAction(String roomId, String userId, String action, Object data) { + RoomRuntime runtime = roomRuntimes.get(roomId); + if (runtime == null || runtime.gameEngine == null) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "游戏尚未开始"); + } + + // 将 data 转换为 JsonNode + com.fasterxml.jackson.databind.JsonNode jsonData = null; + if (data != null) { + String jsonStr = JSON.toJSONString(data); + try { + jsonData = new com.fasterxml.jackson.databind.ObjectMapper().readTree(jsonStr); + } catch (Exception e) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "无效的游戏数据格式"); + } + } + + runtime.gameEngine.handleAction(action, jsonData, userId); + } + + // ==================== 查询 ==================== + + /** + * 获取房间完整详情(进入房间时调用) + */ + public RoomDetailResponse buildRoomDetail(GameRoomEntity room) { + if (room == null) { + room = gameRoomRepository.findById(room.getId()) + .orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND)); + } + + GameConfigEntity gameConfig = gameConfigRepository.findByGameId(room.getGameId()) + .orElseThrow(() -> new BusinessException(ErrorCode.GAME_NOT_FOUND)); + + List roomPlayers = roomPlayerRepository.findByRoomId(room.getId()); + List userIds = roomPlayers.stream().map(RoomPlayerEntity::getUserId).toList(); + Map userMap = userRepository.findByAccountIdIn(userIds).stream() + .collect(Collectors.toMap(u -> u.getAccount().getId(), u -> u)); + + List playerDetails = roomPlayers.stream() + .map(rp -> { + UserEntity user = userMap.get(rp.getUserId()); + return new RoomDetailResponse.RoomPlayerDetail( + rp.getUserId(), + user != null ? user.getAvatar() : "", + user != null ? user.getNickName() : "未知玩家", + rp.getSeatNumber(), + rp.getReadyStatus() + ); + }).toList(); + + String creatorName = getNickname(room.getCreatorId()); + + // 对局中或有对局记录时,查询棋盘状态 + int[][] boardState = null; + String currentTurn = null; + List moves = List.of(); + + if ("playing".equals(room.getStatus()) || "finished".equals(room.getStatus())) { + List moveEntities = gameMoveRepository.findByRoomIdOrderByMoveIndexAsc(room.getId()); + moves = moveEntities.stream() + .map(m -> new RoomDetailResponse.MoveItem(m.getMoveIndex(), m.getPlayerId(), m.getRowNum(), m.getColNum())) + .toList(); + + // 从走棋记录重建棋盘 + boardState = rebuildBoard(moveEntities, 15); + } + + return new RoomDetailResponse( + room.getId(), room.getName(), room.getGameId(), + gameConfig.getIcon(), gameConfig.getName(), + room.getMaxPlayers(), room.getMode(), room.getStakes(), + room.getStatus(), room.getCreatorId(), creatorName, + playerDetails, boardState, currentTurn, moves + ); + } + + /** 棋盘大小(五子棋默认 15) */ + private static final int BOARD_SIZE = 15; + + /** + * 从走棋记录重建棋盘 + */ + private int[][] rebuildBoard(List moves, int boardSize) { + int[][] board = new int[boardSize][boardSize]; + for (GameMoveEntity move : moves) { + int player = (move.getMoveIndex() % 2 == 1) ? 1 : 2; + board[move.getRowNum()][move.getColNum()] = player; + } + return board; + } + + /** + * 获取房间列表 + */ + public RoomListResponse getRoomList(String gameId, String status) { + GameConfigEntity gameConfig = gameConfigRepository.findByGameId(gameId) + .orElseThrow(() -> new BusinessException(ErrorCode.GAME_NOT_FOUND)); + if (!gameConfig.getEnabled()) { + throw new BusinessException(ErrorCode.GAME_NOT_FOUND); + } + + List rooms; + if (status != null && !status.isBlank()) { + rooms = gameRoomRepository.findByGameIdAndStatus(gameId, status); + } else { + rooms = gameRoomRepository.findByGameId(gameId).stream() + .filter(r -> !"finished".equals(r.getStatus())) + .toList(); + } + + List list = rooms.stream() + .map(this::buildRoomItemResponse) + .toList(); + + return new RoomListResponse(list); + } + + // ==================== 定时任务 ==================== + + /** + * 超时房间清理 + * + * 每 5 分钟执行一次: + * - 清理 WAITING 超过 30 分钟无变化的房间 + * - 清理 FINISHED 超过 5 分钟的房间 + */ + @Scheduled(fixedRate = 300_000) + public void cleanupStaleRooms() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime waitingDeadline = now.minusMinutes(30); + LocalDateTime finishedDeadline = now.minusMinutes(5); + + // 清理超时等待中的房间 + List staleWaiting = gameRoomRepository + .findByStatusAndUpdateTimeBefore("waiting", waitingDeadline); + for (GameRoomEntity room : staleWaiting) { + log.info("[房间清理] 清理超时等待房间, roomId={}", room.getId()); + cleanupRoom(room.getId()); + roomPlayerRepository.deleteAll(roomPlayerRepository.findByRoomId(room.getId())); + gameRoomRepository.delete(room); + sseEventPublisher.broadcast(SseEventType.ROOM_REMOVED, Map.of("roomId", room.getId())); + } + + // 清理已结束的房间 + List staleFinished = gameRoomRepository + .findByStatusAndUpdateTimeBefore("finished", finishedDeadline); + for (GameRoomEntity room : staleFinished) { + log.info("[房间清理] 清理已结束房间, roomId={}", room.getId()); + cleanupRoom(room.getId()); + roomPlayerRepository.deleteAll(roomPlayerRepository.findByRoomId(room.getId())); + gameRoomRepository.delete(room); + sseEventPublisher.broadcast(SseEventType.ROOM_REMOVED, Map.of("roomId", room.getId())); + } + + if (!staleWaiting.isEmpty() || !staleFinished.isEmpty()) { + log.info("[房间清理] 清理完成, 等待超时={}, 已结束={}", + staleWaiting.size(), staleFinished.size()); + } + } + + // ==================== 私有辅助方法 ==================== + + /** + * 清理房间运行时资源(引擎、定时器) + */ + private void cleanupRoom(String roomId) { + RoomRuntime runtime = roomRuntimes.remove(roomId); + if (runtime != null) { + // 销毁游戏引擎 + if (runtime.gameEngine != null) { + runtime.gameEngine.destroy(); + } + // 取消所有断线定时器 + for (ScheduledFuture timer : runtime.disconnectTimers.values()) { + timer.cancel(false); + } + runtime.disconnectTimers.clear(); + } + } + + /** + * 广播 WS 消息到房间内所有人 + */ + private void broadcastToRoom(String roomId, 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); + } + } + } + } + + /** + * 向房间内指定用户发送 WS 消息(私有消息) + */ + private void sendToUser(String roomId, String userId, WsMessage message) { + String json = JSON.toJSONString(message); + for (WebSocketSession ws : sessionManager.getRoomSessions(roomId)) { + if (ws.isOpen() && userId.equals(sessionManager.getUserId(ws))) { + try { + ws.sendMessage(new TextMessage(json)); + } catch (IOException e) { + log.error("[WS] 发送私有消息失败, userId={}", userId, e); + } + } + } + } + + /** + * 构建房间列表项响应 + */ + private RoomItemResponse buildRoomItemResponse(GameRoomEntity room) { + List players = buildRoomPlayers(room.getId()); + String creatorName = getNickname(room.getCreatorId()); + String icon = gameConfigRepository.findByGameId(room.getGameId()) + .map(GameConfigEntity::getIcon).orElse(""); + return RoomItemResponse.from(room, players, icon, creatorName); + } + + /** + * 构建单个玩家详情 + */ + private RoomPlayerDetail buildPlayerDetail(String userId, int seatNumber, boolean ready) { + UserEntity user = userRepository.findByAccountId(userId).orElse(null); + return new RoomPlayerDetail( + userId, + user != null ? user.getAvatar() : "", + user != null ? user.getNickName() : "未知玩家", + seatNumber, + ready + ); + } + + /** + * 组装房间玩家列表 + */ + private List buildRoomPlayers(String roomId) { + List roomPlayers = roomPlayerRepository.findByRoomId(roomId); + if (roomPlayers.isEmpty()) return List.of(); + + List userIds = roomPlayers.stream().map(RoomPlayerEntity::getUserId).toList(); + List users = userRepository.findByAccountIdIn(userIds); + Map userMap = users.stream() + .collect(Collectors.toMap(u -> u.getAccount().getId(), u -> u)); + + return roomPlayers.stream() + .map(rp -> { + UserEntity user = userMap.get(rp.getUserId()); + String avatar = user != null ? user.getAvatar() : ""; + String nickname = user != null ? user.getNickName() : "未知玩家"; + return new RoomPlayerResponse(avatar, nickname); + }).toList(); + } + + /** + * 获取用户昵称 + */ + private String getNickname(String accountId) { + return userRepository.findByAccountId(accountId) + .map(UserEntity::getNickName) + .orElse("未知玩家"); + } + + /** + * SSE 推送房间更新 + */ + private void pushRoomUpdatedSse(GameRoomEntity room) { + RoomItemResponse response = buildRoomItemResponse(room); + sseEventPublisher.broadcast(SseEventType.ROOM_UPDATED, response); + } + + // ==================== 公共查询方法(供 Controller 使用) ==================== + + /** + * 获取房间详情 + */ + public RoomDetailResponse getRoomDetail(String roomId) { + GameRoomEntity room = gameRoomRepository.findById(roomId) + .orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND)); + return buildRoomDetail(room); + } +} diff --git a/src/main/java/com/webgame/webgamebackend/service/engine/GameEngine.java b/src/main/java/com/webgame/webgamebackend/service/engine/GameEngine.java new file mode 100644 index 0000000..c5bc5d8 --- /dev/null +++ b/src/main/java/com/webgame/webgamebackend/service/engine/GameEngine.java @@ -0,0 +1,79 @@ +package com.webgame.webgamebackend.service.engine; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * 游戏引擎接口 + * + * 每种游戏类型提供自己的实现,通过 {@link GameEngineRegistry} 注册。 + * 引擎只关心游戏规则,不关心房间管理(创建/加入/离开/踢人等)。 + * 房间层通过此接口与游戏层通信。 + * + *

生命周期

+ *
    + *
  1. 游戏开始时由 RoomManager 创建引擎实例
  2. + *
  3. 调用 {@link #init(RoomContext)} 初始化游戏
  4. + *
  5. 通过 {@link #handleAction} 处理玩家操作
  6. + *
  7. 游戏结束或被解散时调用 {@link #destroy()}
  8. + *
+ */ +public interface GameEngine { + + /** + * 初始化游戏 + * + * @param context 房间上下文(玩家列表、配置、广播回调、结束回调) + */ + void init(RoomContext context); + + /** + * 处理玩家游戏动作 + * + * @param action 动作类型(如 "place_stone", "resign", "draw_request") + * @param data 动作数据 + * @param userId 操作者 accountId + */ + void handleAction(String action, JsonNode data, String userId); + + /** + * 房间通知:玩家 WebSocket 断线 + * + * 游戏可以选择如何处理(如五子棋:启动断线倒计时,超时后自动认输) + * + * @param userId 断线玩家 accountId + */ + void onPlayerDisconnect(String userId); + + /** + * 房间通知:玩家重连成功 + * + * 游戏应取消断线倒计时,并通过 RoomContext 的 broadcaster 推送 + * 当前游戏状态给重连玩家 + * + * @param userId 重连玩家 accountId + */ + void onPlayerReconnect(String userId); + + /** + * 房间通知:玩家主动离开房间 + * + * 游戏中离开视为认输 + * + * @param userId 离开玩家 accountId + */ + void onPlayerLeave(String userId); + + /** + * 销毁引擎,清理资源(取消定时器、释放引用等) + */ + void destroy(); + + /** + * 获取游戏完整状态快照 + * + * 用于重连时向前端推送当前游戏状态,前端据此恢复 UI。 + * + * @return 游戏状态(棋盘、回合、走棋历史等) + */ + GameState getState(); +} diff --git a/src/main/java/com/webgame/webgamebackend/service/engine/GameEngineFactory.java b/src/main/java/com/webgame/webgamebackend/service/engine/GameEngineFactory.java new file mode 100644 index 0000000..173d9a7 --- /dev/null +++ b/src/main/java/com/webgame/webgamebackend/service/engine/GameEngineFactory.java @@ -0,0 +1,19 @@ +package com.webgame.webgamebackend.service.engine; + +/** + * 游戏引擎工厂接口 + * + * 每次游戏开始(房间从 WAITING → PLAYING)时调用 {@link #create} 创建新引擎实例。 + * 每个房间拥有独立的引擎实例,房间之间不共享。 + */ +@FunctionalInterface +public interface GameEngineFactory { + + /** + * 创建游戏引擎实例 + * + * @param context 房间上下文 + * @return 新创建的引擎实例 + */ + GameEngine create(RoomContext context); +} diff --git a/src/main/java/com/webgame/webgamebackend/service/engine/GameEngineRegistry.java b/src/main/java/com/webgame/webgamebackend/service/engine/GameEngineRegistry.java new file mode 100644 index 0000000..5802528 --- /dev/null +++ b/src/main/java/com/webgame/webgamebackend/service/engine/GameEngineRegistry.java @@ -0,0 +1,66 @@ +package com.webgame.webgamebackend.service.engine; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 游戏引擎注册表 + * + * 管理 gameId → GameEngineFactory 的映射。 + * Spring Boot 启动时,各游戏模块通过 {@link #register} 注册自己的引擎工厂。 + */ +@Slf4j +@Component +public class GameEngineRegistry { + + /** gameId → 引擎工厂 */ + private final Map factories = new ConcurrentHashMap<>(); + + /** + * 注册游戏引擎工厂 + * + * @param gameId 游戏标识(与 game_config 表中的 game_id 对应) + * @param factory 引擎工厂 + */ + public void register(String gameId, GameEngineFactory factory) { + if (gameId == null || gameId.isBlank()) { + throw new IllegalArgumentException("gameId 不能为空"); + } + if (factory == null) { + throw new IllegalArgumentException("factory 不能为 null"); + } + factories.put(gameId, factory); + log.info("[引擎注册] 注册游戏引擎, gameId={}", gameId); + } + + /** + * 创建游戏引擎实例 + * + * @param gameId 游戏标识 + * @param context 房间上下文 + * @return 游戏引擎实例 + * @throws IllegalArgumentException 如果 gameId 未注册 + */ + public GameEngine create(String gameId, RoomContext context) { + GameEngineFactory factory = factories.get(gameId); + if (factory == null) { + throw new IllegalArgumentException("未注册的游戏类型: " + gameId); + } + GameEngine engine = factory.create(context); + log.info("[引擎注册] 创建引擎实例, gameId={}, roomId={}", gameId, context.getRoomId()); + return engine; + } + + /** + * 检查游戏类型是否已注册 + * + * @param gameId 游戏标识 + * @return 是否已注册 + */ + public boolean isRegistered(String gameId) { + return factories.containsKey(gameId); + } +} diff --git a/src/main/java/com/webgame/webgamebackend/service/engine/GameState.java b/src/main/java/com/webgame/webgamebackend/service/engine/GameState.java new file mode 100644 index 0000000..7251d2f --- /dev/null +++ b/src/main/java/com/webgame/webgamebackend/service/engine/GameState.java @@ -0,0 +1,51 @@ +package com.webgame.webgamebackend.service.engine; + +/** + * 游戏状态快照 + * + * 由 GameEngine.getState() 返回,用于重连时同步完整游戏状态给前端。 + * 具体游戏引擎可以扩展此类,添加游戏特定的状态字段。 + */ +public class GameState { + + /** 游戏是否已结束 */ + private boolean finished; + + /** 胜者 ID(null 表示平局或未结束) */ + private String winnerId; + + /** 结果类型(win / draw / resign) */ + private String resultType; + + /** 当前轮到谁(accountId) */ + private String currentTurn; + + /** 游戏特定数据(JSON 字符串),由各引擎自行填充 */ + private String gameData; + + public GameState() {} + + public GameState(boolean finished, String winnerId, String resultType, + String currentTurn, String gameData) { + this.finished = finished; + this.winnerId = winnerId; + this.resultType = resultType; + this.currentTurn = currentTurn; + this.gameData = gameData; + } + + public boolean isFinished() { return finished; } + public void setFinished(boolean finished) { this.finished = finished; } + + public String getWinnerId() { return winnerId; } + public void setWinnerId(String winnerId) { this.winnerId = winnerId; } + + public String getResultType() { return resultType; } + public void setResultType(String resultType) { this.resultType = resultType; } + + public String getCurrentTurn() { return currentTurn; } + public void setCurrentTurn(String currentTurn) { this.currentTurn = currentTurn; } + + public String getGameData() { return gameData; } + public void setGameData(String gameData) { this.gameData = gameData; } +} diff --git a/src/main/java/com/webgame/webgamebackend/service/engine/GomokuEngine.java b/src/main/java/com/webgame/webgamebackend/service/engine/GomokuEngine.java new file mode 100644 index 0000000..ed3705c --- /dev/null +++ b/src/main/java/com/webgame/webgamebackend/service/engine/GomokuEngine.java @@ -0,0 +1,398 @@ +package com.webgame.webgamebackend.service.engine; + +import com.alibaba.fastjson2.JSON; +import com.fasterxml.jackson.databind.JsonNode; +import com.webgame.webgamebackend.common.exception.BusinessException; +import com.webgame.webgamebackend.common.exception.ErrorCode; +import com.webgame.webgamebackend.entities.GameMoveEntity; +import com.webgame.webgamebackend.repository.GameMoveRepository; +import com.webgame.webgamebackend.ws.dto.WsMessage; +import com.webgame.webgamebackend.ws.dto.WsOutboundMessage; +import lombok.extern.slf4j.Slf4j; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.*; + +/** + * 五子棋游戏引擎 + * + * 实现标准的 15×15 五子棋规则: + *
    + *
  • 黑方先手(seat=1)
  • + *
  • 交替落子
  • + *
  • 五连(横/竖/对角)判胜
  • + *
  • 支持认输、求和
  • + *
  • 断线 60 秒自动判负
  • + *
+ * + *

不关心房间管理(加入/离开/踢人/房主转移),只关心棋盘上的规则。

+ */ +@Slf4j +public class GomokuEngine implements GameEngine { + + /** 棋盘大小 */ + private static final int BOARD_SIZE = 15; + + /** 断线自动认输超时(秒) */ + private static final long DISCONNECT_FORFEIT_SECONDS = 60; + + /** 断线定时器线程池(每个引擎实例独立,避免影响其他房间) */ + private final ScheduledExecutorService disconnectScheduler = + Executors.newSingleThreadScheduledExecutor(r -> + new Thread(r, "gomoku-disconnect-" + System.identityHashCode(this))); + + private final GameMoveRepository moveRepository; + + // ==================== 运行时状态 ==================== + + private RoomContext context; + private String currentTurnUserId; + private final List playerIds = new ArrayList<>(); // 按座位号排序 + private boolean finished; + + /** 断线倒计时: userId → Future */ + private final ConcurrentHashMap> disconnectTimers = new ConcurrentHashMap<>(); + + public GomokuEngine(GameMoveRepository moveRepository) { + this.moveRepository = moveRepository; + } + + @Override + public void init(RoomContext context) { + this.context = context; + // 按座位号排序的玩家列表,seat=1 为黑方先手 + List players = context.getPlayers(); + if (players.size() < 2) { + throw new BusinessException(ErrorCode.PLAYER_COUNT_INSUFFICIENT); + } + this.playerIds.clear(); + for (RoomContext.PlayerInfo p : players) { + this.playerIds.add(p.userId()); + } + this.currentTurnUserId = playerIds.get(0); // 黑方先手 + this.finished = false; + + log.info("[五子棋引擎] 初始化, roomId={}, 玩家={}", context.getRoomId(), playerIds); + + // 广播空棋盘 + 先手 + context.broadcast(new WsMessage(WsOutboundMessage.BOARD_SYNC, Map.of( + "board", new int[BOARD_SIZE][BOARD_SIZE], + "currentTurn", currentTurnUserId + ))); + } + + @Override + public void handleAction(String action, JsonNode data, String userId) { + if (finished) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "对局已结束"); + } + + switch (action) { + case "place_stone" -> { + int row = data.get("row").asInt(); + int col = data.get("col").asInt(); + placeStone(userId, row, col); + } + case "resign" -> resign(userId); + case "draw_request" -> requestDraw(userId); + case "draw_response" -> { + boolean accept = data.get("accept").asBoolean(); + respondToDraw(userId, accept); + } + default -> throw new BusinessException(ErrorCode.BAD_REQUEST, "未知游戏动作: " + action); + } + } + + @Override + public void onPlayerDisconnect(String userId) { + // 启动断线倒计时 + if (disconnectTimers.containsKey(userId)) { + return; // 已有定时器 + } + + log.info("[五子棋引擎] 玩家断线,{}秒后自动判负, roomId={}, userId={}", + DISCONNECT_FORFEIT_SECONDS, context.getRoomId(), userId); + + ScheduledFuture task = disconnectScheduler.schedule(() -> { + disconnectTimers.remove(userId); + if (!finished) { + log.info("[五子棋引擎] 断线超时,自动判负, roomId={}, userId={}", + context.getRoomId(), userId); + forfeit(userId); + } + }, DISCONNECT_FORFEIT_SECONDS, TimeUnit.SECONDS); + + disconnectTimers.put(userId, task); + } + + @Override + public void onPlayerReconnect(String userId) { + // 取消断线倒计时 + ScheduledFuture task = disconnectTimers.remove(userId); + if (task != null) { + task.cancel(false); + log.info("[五子棋引擎] 玩家重连,取消断线倒计时, roomId={}, userId={}", + context.getRoomId(), userId); + } + + // 推送当前游戏状态给重连玩家 + GameState state = getState(); + String gameDataJson = state.getGameData(); + Map boardSyncData; + if (gameDataJson != null) { + boardSyncData = JSON.parseObject(gameDataJson, Map.class); + } else { + boardSyncData = Map.of("board", new int[BOARD_SIZE][BOARD_SIZE]); + } + boardSyncData.put("currentTurn", currentTurnUserId); + + context.sendToUser(userId, new WsMessage(WsOutboundMessage.BOARD_SYNC, boardSyncData)); + } + + @Override + public void onPlayerLeave(String userId) { + // 取消断线倒计时 + ScheduledFuture task = disconnectTimers.remove(userId); + if (task != null) { + task.cancel(false); + } + + if (!finished) { + // 玩家离开 = 认输 + log.info("[五子棋引擎] 玩家离开,自动认输, roomId={}, userId={}", + context.getRoomId(), userId); + forfeit(userId); + } + } + + @Override + public void destroy() { + finished = true; + // 取消所有断线计时器 + for (ScheduledFuture timer : disconnectTimers.values()) { + timer.cancel(false); + } + disconnectTimers.clear(); + disconnectScheduler.shutdownNow(); + log.info("[五子棋引擎] 销毁, roomId={}", context != null ? context.getRoomId() : "unknown"); + } + + @Override + public GameState getState() { + // 重建棋盘 + int[][] board = new int[BOARD_SIZE][BOARD_SIZE]; + List moves = moveRepository + .findByRoomIdOrderByMoveIndexAsc(context.getRoomId()); + for (GameMoveEntity move : moves) { + int playerValue = (move.getMoveIndex() % 2 == 1) ? 1 : 2; + board[move.getRowNum()][move.getColNum()] = playerValue; + } + + // 序列化走棋历史 + List> moveHistory = moves.stream() + .map(m -> Map.of( + "moveIndex", m.getMoveIndex(), + "playerId", m.getPlayerId(), + "row", m.getRowNum(), + "col", m.getColNum() + )).toList(); + + Map gameData = Map.of( + "board", board, + "moves", moveHistory, + "boardSize", BOARD_SIZE + ); + + GameState state = new GameState(); + state.setFinished(finished); + state.setCurrentTurn(currentTurnUserId); + state.setGameData(JSON.toJSONString(gameData)); + return state; + } + + // ==================== 游戏逻辑 ==================== + + /** + * 落子 + */ + private void placeStone(String userId, int row, int col) { + // 校验坐标 + if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "坐标超出棋盘范围"); + } + + // 校验是否轮到自己 + if (!userId.equals(currentTurnUserId)) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "还未轮到你落子"); + } + + // 校验位置是否已有棋子 + List moves = moveRepository + .findByRoomIdOrderByMoveIndexAsc(context.getRoomId()); + for (GameMoveEntity m : moves) { + if (m.getRowNum() == row && m.getColNum() == col) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "该位置已有棋子"); + } + } + + int moveIndex = moves.size() + 1; + + // 保存走棋记录 + GameMoveEntity move = new GameMoveEntity() + .setRoomId(context.getRoomId()) + .setPlayerId(userId) + .setRowNum(row) + .setColNum(col) + .setMoveIndex(moveIndex); + moveRepository.save(move); + + log.info("[五子棋引擎] 落子, roomId={}, userId={}, row={}, col={}, moveIndex={}", + context.getRoomId(), userId, row, col, moveIndex); + + // 广播落子 + context.broadcast(new WsMessage(WsOutboundMessage.MOVE, Map.of( + "row", row, + "col", col, + "playerId", userId, + "moveIndex", moveIndex + ))); + + // 检查胜负 + int[][] board = rebuildBoard(); + int playerIndex = playerIds.indexOf(userId); + int stone = (playerIndex == 0) ? 1 : 2; + + if (checkWin(board, row, col, stone)) { + endGame(userId, "win"); + return; + } + + if (moveIndex >= BOARD_SIZE * BOARD_SIZE) { + endGame(null, "draw"); + return; + } + + // 切换回合 + int nextPlayerIndex = (moveIndex) % 2; + currentTurnUserId = playerIds.get(nextPlayerIndex); + context.broadcast(new WsMessage(WsOutboundMessage.TURN_CHANGE, Map.of( + "playerId", currentTurnUserId + ))); + } + + /** + * 认输 + */ + private void resign(String userId) { + if (!playerIds.contains(userId)) { + throw new BusinessException(ErrorCode.PLAYER_NOT_IN_ROOM); + } + // 对手获胜 + String winnerId = playerIds.stream() + .filter(id -> !id.equals(userId)) + .findFirst() + .orElse(null); + endGame(winnerId, "resign"); + } + + /** + * 断线超时/主动离开导致弃权 + */ + private void forfeit(String userId) { + if (!playerIds.contains(userId)) { + return; + } + String winnerId = playerIds.stream() + .filter(id -> !id.equals(userId)) + .findFirst() + .orElse(null); + endGame(winnerId, "resign"); + } + + /** + * 求和请求 + */ + private void requestDraw(String userId) { + context.broadcast(new WsMessage("draw_requested", Map.of( + "fromUserId", userId + ))); + } + + /** + * 回应求和 + */ + private void respondToDraw(String userId, boolean accept) { + if (accept) { + endGame(null, "draw"); + } else { + context.broadcast(new WsMessage("draw_rejected", Map.of( + "fromUserId", userId + ))); + } + } + + /** + * 结束对局 + * + * @param winnerId 胜者 ID(null 表示平局) + * @param resultType 结果类型 + */ + private void endGame(String winnerId, String resultType) { + if (finished) return; + finished = true; + + log.info("[五子棋引擎] 对局结束, roomId={}, winnerId={}, resultType={}", + context.getRoomId(), winnerId, resultType); + + // 取消所有断线定时器 + for (ScheduledFuture timer : disconnectTimers.values()) { + timer.cancel(false); + } + disconnectTimers.clear(); + + // 通知房间层:游戏已结束 + context.gameOver(winnerId, resultType); + } + + /** + * 重建棋盘 + */ + private int[][] rebuildBoard() { + int[][] board = new int[BOARD_SIZE][BOARD_SIZE]; + List moves = moveRepository + .findByRoomIdOrderByMoveIndexAsc(context.getRoomId()); + for (GameMoveEntity move : moves) { + int playerValue = (move.getMoveIndex() % 2 == 1) ? 1 : 2; + board[move.getRowNum()][move.getColNum()] = playerValue; + } + return board; + } + + /** + * 检查五连胜利 + */ + 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; + } +} diff --git a/src/main/java/com/webgame/webgamebackend/service/engine/RoomContext.java b/src/main/java/com/webgame/webgamebackend/service/engine/RoomContext.java new file mode 100644 index 0000000..7a81e92 --- /dev/null +++ b/src/main/java/com/webgame/webgamebackend/service/engine/RoomContext.java @@ -0,0 +1,117 @@ +package com.webgame.webgamebackend.service.engine; + +import com.webgame.webgamebackend.ws.dto.WsMessage; + +import java.util.List; +import java.util.function.Consumer; + +/** + * 房间上下文 + * + * 游戏引擎初始化时由 RoomManager 传入,包含引擎所需的房间信息和回调接口。 + * 引擎不应持有对 RoomManager 的直接引用,所有与房间层的交互通过此上下文完成。 + */ +public class RoomContext { + + /** 房间 ID */ + private final String roomId; + + /** 游戏 ID */ + private final String gameId; + + /** 参与对局的玩家列表(按座位号排序) */ + private final List players; + + /** 玩法模式 */ + private final String mode; + + /** 底注金额 */ + private final int stakes; + + /** 广播消息到房间内所有 WebSocket 会话 */ + private final Consumer broadcaster; + + /** 私有消息:向指定用户发送消息 */ + private final PrivateMessageSender privateSender; + + /** 游戏结束回调 */ + private final GameOverCallback onGameOver; + + public RoomContext(String roomId, String gameId, List players, + String mode, int stakes, + Consumer broadcaster, + PrivateMessageSender privateSender, + GameOverCallback onGameOver) { + this.roomId = roomId; + this.gameId = gameId; + this.players = List.copyOf(players); // 防御性拷贝 + this.mode = mode; + this.stakes = stakes; + this.broadcaster = broadcaster; + this.privateSender = privateSender; + this.onGameOver = onGameOver; + } + + public String getRoomId() { return roomId; } + public String getGameId() { return gameId; } + public List getPlayers() { return players; } + public String getMode() { return mode; } + public int getStakes() { return stakes; } + + /** + * 广播消息到房间内所有人 + */ + public void broadcast(WsMessage message) { + broadcaster.accept(message); + } + + /** + * 向指定用户发送私有消息(如重连时的状态同步) + */ + public void sendToUser(String userId, WsMessage message) { + privateSender.send(userId, message); + } + + /** + * 游戏结束,通知房间层 + * + * @param winnerId 胜者 accountId,null 表示平局 + * @param resultType 结果类型(win / draw / resign) + */ + public void gameOver(String winnerId, String resultType) { + onGameOver.onGameOver(winnerId, resultType); + } + + /** + * 房间内玩家简要信息 + */ + public record PlayerInfo(String userId, int seatNumber) {} + + /** + * 游戏结束回调接口 + */ + @FunctionalInterface + public interface GameOverCallback { + /** + * 游戏结束通知 + * + * @param winnerId 胜者 accountId,null 表示平局 + * @param resultType 结果类型 + */ + void onGameOver(String winnerId, String resultType); + } + + /** + * 私有消息发送接口 + */ + @FunctionalInterface + public interface PrivateMessageSender { + /** + * 向指定用户的所有 WebSocket 会话发送消息 + * + * @param userId 目标用户 accountId + * @param message 消息 + */ + void send(String userId, WsMessage message); + } +} diff --git a/src/main/java/com/webgame/webgamebackend/service/impl/GameServiceImpl.java b/src/main/java/com/webgame/webgamebackend/service/impl/GameServiceImpl.java index 836750d..545e902 100644 --- a/src/main/java/com/webgame/webgamebackend/service/impl/GameServiceImpl.java +++ b/src/main/java/com/webgame/webgamebackend/service/impl/GameServiceImpl.java @@ -1,29 +1,20 @@ package com.webgame.webgamebackend.service.impl; -import com.alibaba.fastjson2.JSON; -import com.webgame.webgamebackend.common.dto.game.*; -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.common.event.SseEventPublisher; -import com.webgame.webgamebackend.common.event.SseEventType; +import com.webgame.webgamebackend.common.dto.game.GameItemResponse; +import com.webgame.webgamebackend.common.dto.game.GameListResponse; +import com.webgame.webgamebackend.entities.GameConfigEntity; +import com.webgame.webgamebackend.repository.GameConfigRepository; import com.webgame.webgamebackend.service.GameService; -import com.webgame.webgamebackend.service.GomokuGameService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; -import java.util.ArrayList; import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; /** * 游戏服务实现 + * + * 仅保留游戏配置查询。房间管理已迁移到 {@link com.webgame.webgamebackend.service.RoomManager}。 */ @Slf4j @Service @@ -31,12 +22,6 @@ import java.util.stream.Collectors; public class GameServiceImpl implements GameService { private final GameConfigRepository gameConfigRepository; - private final GameRoomRepository gameRoomRepository; - private final RoomPlayerRepository roomPlayerRepository; - private final UserRepository userRepository; - private final BCryptPasswordEncoder passwordEncoder; - private final SseEventPublisher sseEventPublisher; - private final GameMoveRepository gameMoveRepository; @Override public GameListResponse getGameList() { @@ -47,449 +32,4 @@ public class GameServiceImpl implements GameService { log.info("[游戏服务] 获取游戏列表, 共 {} 款", list.size()); return new GameListResponse(list); } - - @Override - public RoomListResponse getRoomList(String gameId, String status) { - // 校验游戏存在且启用 - GameConfigEntity gameConfig = gameConfigRepository.findByGameId(gameId) - .orElseThrow(() -> new BusinessException(ErrorCode.GAME_NOT_FOUND)); - if (!gameConfig.getEnabled()) { - throw new BusinessException(ErrorCode.GAME_NOT_FOUND); - } - - // 查询房间:有状态筛选则按状态查,否则查所有 - List rooms; - if (status != null && !status.isBlank()) { - rooms = gameRoomRepository.findByGameIdAndStatus(gameId, status); - } else { - // 默认不返回已结束的房间 - rooms = gameRoomRepository.findByGameId(gameId).stream() - .filter(r -> !"finished".equals(r.getStatus())) - .toList(); - } - - // 组装每个房间的玩家信息和房主昵称 - List list = new ArrayList<>(); - for (GameRoomEntity room : rooms) { - List players = buildRoomPlayers(room.getId()); - String creatorName = getNickname(room.getCreatorId()); - list.add(RoomItemResponse.from(room, players, gameConfig.getIcon(), creatorName)); - } - - log.info("[游戏服务] 获取房间列表, gameId={}, status={}, 共 {} 个", gameId, status, list.size()); - return new RoomListResponse(list); - } - - @Override - @Transactional - public CreateRoomResponse createRoom(CreateRoomRequest request, String creatorId) { - // 校验游戏存在且启用 - GameConfigEntity gameConfig = gameConfigRepository.findByGameId(request.gameId()) - .orElseThrow(() -> new BusinessException(ErrorCode.GAME_NOT_FOUND)); - if (!gameConfig.getEnabled()) { - throw new BusinessException(ErrorCode.GAME_NOT_FOUND); - } - - // 校验 mode 在可选列表中 - List modes = JSON.parseArray(gameConfig.getModes(), String.class); - if (!modes.contains(request.mode())) { - throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的模式: " + request.mode()); - } - - // 校验 stakes 在可选列表中 - List stakesOptions = JSON.parseArray(gameConfig.getStakesOptions(), Integer.class); - if (!stakesOptions.contains(request.stakes())) { - throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的底注: " + request.stakes()); - } - - // 检查用户是否已在其他房间中 - if (roomPlayerRepository.existsByUserId(creatorId)) { - throw new BusinessException(ErrorCode.ALREADY_IN_ROOM); - } - - // 生成桌号 - Integer maxRoomNumber = gameRoomRepository.findMaxRoomNumberByGameId(request.gameId()); - int nextRoomNumber = (maxRoomNumber == null) ? 1 : maxRoomNumber + 1; - - // 生成桌名 - String roomName = (request.name() != null && !request.name().isBlank()) - ? request.name() - : "桌 " + String.format("%02d", nextRoomNumber); - - // 创建房间 - GameRoomEntity room = new GameRoomEntity() - .setGameId(request.gameId()) - .setRoomNumber(nextRoomNumber) - .setName(roomName) - .setMaxPlayers(request.maxPlayers()) - .setMode(request.mode()) - .setStakes(request.stakes()) - .setStatus("waiting") - .setCreatorId(creatorId) - .setAllowSpectate(request.allowSpectate()); - - // 密码加密(如果提供了密码) - if (request.password() != null && !request.password().isBlank()) { - room.setPassword(passwordEncoder.encode(request.password())); - } - - gameRoomRepository.save(room); - - // 房主自动加入房间(座位 1,已准备) - RoomPlayerEntity creatorPlayer = new RoomPlayerEntity() - .setRoomId(room.getId()) - .setUserId(creatorId) - .setSeatNumber(1) - .setReadyStatus(true) - .setJoinTime(LocalDateTime.now()); - roomPlayerRepository.save(creatorPlayer); - - // 组装响应 - List players = buildRoomPlayers(room.getId()); - String creatorName = getNickname(creatorId); - RoomItemResponse roomResponse = RoomItemResponse.from(room, players, gameConfig.getIcon(), creatorName); - - log.info("[游戏服务] 创建房间成功, roomId={}, gameId={}, creatorId={}", room.getId(), request.gameId(), creatorId); - - // 通知所有在线用户:新房间创建 - sseEventPublisher.broadcast(SseEventType.ROOM_CREATED, roomResponse); - - return new CreateRoomResponse(roomResponse); - } - - @Override - @Transactional - public RoomItemResponse joinRoom(JoinRoomRequest request, String userId) { - GameRoomEntity room = gameRoomRepository.findById(request.roomId()) - .orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND)); - - // 检查房间状态 - if (!"waiting".equals(room.getStatus())) { - throw new BusinessException(ErrorCode.ROOM_ALREADY_STARTED); - } - - // 检查密码 - if (room.getPassword() != null && !room.getPassword().isBlank()) { - if (request.password() == null || request.password().isBlank()) { - throw new BusinessException(ErrorCode.ROOM_PASSWORD_ERROR, "请输入房间密码"); - } - if (!passwordEncoder.matches(request.password(), room.getPassword())) { - throw new BusinessException(ErrorCode.ROOM_PASSWORD_ERROR); - } - } - - // 检查房间是否已满 - int currentCount = roomPlayerRepository.countByRoomId(room.getId()); - if (currentCount >= room.getMaxPlayers()) { - throw new BusinessException(ErrorCode.ROOM_FULL); - } - - // 检查用户是否已在其他房间 - if (roomPlayerRepository.existsByUserId(userId)) { - throw new BusinessException(ErrorCode.ALREADY_IN_ROOM); - } - - // 分配座位号 - List existingPlayers = roomPlayerRepository.findByRoomId(room.getId()); - int nextSeat = 1; - if (!existingPlayers.isEmpty()) { - int maxSeat = existingPlayers.stream() - .mapToInt(RoomPlayerEntity::getSeatNumber) - .max() - .orElse(0); - nextSeat = maxSeat + 1; - } - - // 加入房间 - RoomPlayerEntity player = new RoomPlayerEntity() - .setRoomId(room.getId()) - .setUserId(userId) - .setSeatNumber(nextSeat) - .setReadyStatus(false) - .setJoinTime(LocalDateTime.now()); - roomPlayerRepository.save(player); - - // 组装响应 - GameConfigEntity gameConfig = gameConfigRepository.findByGameId(room.getGameId()) - .orElseThrow(() -> new BusinessException(ErrorCode.GAME_NOT_FOUND)); - List players = buildRoomPlayers(room.getId()); - String creatorName = getNickname(room.getCreatorId()); - - log.info("[游戏服务] 玩家加入房间, roomId={}, userId={}", room.getId(), userId); - - RoomItemResponse response = RoomItemResponse.from(room, players, gameConfig.getIcon(), creatorName); - // 通知所有在线用户:房间信息更新 - sseEventPublisher.broadcast(SseEventType.ROOM_UPDATED, response); - return response; - } - - @Override - @Transactional - public void leaveRoom(String roomId, String userId) { - GameRoomEntity room = gameRoomRepository.findById(roomId) - .orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND)); - - // 检查玩家是否在房间中 - RoomPlayerEntity player = roomPlayerRepository.findByRoomIdAndUserId(roomId, userId) - .orElseThrow(() -> new BusinessException(ErrorCode.PLAYER_NOT_IN_ROOM)); - - roomPlayerRepository.delete(player); - - // remaining 需要在后面的 SSE 推送中用到,提取到方法级 - List remaining = roomPlayerRepository.findByRoomId(roomId); - - if (remaining.isEmpty()) { - // 无人了,物理删除房间(对局记录和走棋记录保留用于历史查询) - gameRoomRepository.delete(room); - log.info("[游戏服务] 房间无人,已删除, roomId={}", roomId); - } else if (userId.equals(room.getCreatorId())) { - // 房主离开,顺位转让给下一个玩家 - RoomPlayerEntity nextOwner = remaining.get(0); - room.setCreatorId(nextOwner.getUserId()); - // 新房主自动设为已准备 - nextOwner.setReadyStatus(true); - roomPlayerRepository.save(nextOwner); - gameRoomRepository.save(room); - log.info("[游戏服务] 房主离开,转让给 userId={}, roomId={}", nextOwner.getUserId(), roomId); - } - - log.info("[游戏服务] 玩家离开房间, roomId={}, userId={}", roomId, userId); - - // 获取剩余玩家重新组装房间信息并推送事件 - if (remaining.isEmpty()) { - sseEventPublisher.broadcast(SseEventType.ROOM_REMOVED, - Map.of("roomId", roomId)); - } else { - List players = buildRoomPlayers(roomId); - GameConfigEntity gameConfig = gameConfigRepository.findByGameId(room.getGameId()) - .orElse(null); - String icon = gameConfig != null ? gameConfig.getIcon() : ""; - String creatorName = getNickname(room.getCreatorId()); - RoomItemResponse response = RoomItemResponse.from(room, players, icon, creatorName); - sseEventPublisher.broadcast(SseEventType.ROOM_UPDATED, response); - } - } - - @Override - @Transactional - public void kickPlayer(String roomId, String ownerId, String targetUserId) { - GameRoomEntity room = gameRoomRepository.findById(roomId) - .orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND)); - - // 校验操作者是房主 - if (!ownerId.equals(room.getCreatorId())) { - throw new BusinessException(ErrorCode.NOT_ROOM_OWNER); - } - - // 不能踢自己 - if (ownerId.equals(targetUserId)) { - throw new BusinessException(ErrorCode.BAD_REQUEST, "不能踢出自己,请使用离开房间"); - } - - // 检查目标玩家是否在房间中 - RoomPlayerEntity target = roomPlayerRepository.findByRoomIdAndUserId(roomId, targetUserId) - .orElseThrow(() -> new BusinessException(ErrorCode.PLAYER_NOT_IN_ROOM)); - - roomPlayerRepository.delete(target); - log.info("[游戏服务] 房主踢出玩家, roomId={}, targetUserId={}", roomId, targetUserId); - - // 通知被踢玩家 - sseEventPublisher.sendTo(targetUserId, SseEventType.KICKED, - Map.of("roomId", roomId)); - // 通知房间内其他玩家:房间更新 - List players = buildRoomPlayers(roomId); - GameConfigEntity gameConfig = gameConfigRepository.findByGameId(room.getGameId()) - .orElse(null); - String icon = gameConfig != null ? gameConfig.getIcon() : ""; - String creatorName = getNickname(room.getCreatorId()); - RoomItemResponse response = RoomItemResponse.from(room, players, icon, creatorName); - sseEventPublisher.broadcast(SseEventType.ROOM_UPDATED, response); - } - - @Override - @Transactional - public boolean toggleReady(String roomId, String userId) { - GameRoomEntity room = gameRoomRepository.findById(roomId) - .orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND)); - - 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={}", newStatus ? "" : "取消", roomId, userId); - - // 通知房间更新 - List players = buildRoomPlayers(roomId); - GameConfigEntity gameConfig = gameConfigRepository.findByGameId(room.getGameId()) - .orElse(null); - String icon = gameConfig != null ? gameConfig.getIcon() : ""; - String creatorName = getNickname(room.getCreatorId()); - RoomItemResponse response = RoomItemResponse.from(room, players, icon, creatorName); - sseEventPublisher.broadcast(SseEventType.ROOM_UPDATED, response); - - return newStatus; - } - - /** - * 开始游戏(HTTP 端点保留,实际开始游戏由 WebSocket 处理) - * - * WS 的 "start" 消息会触发 GomokuGameServiceImpl.startGame(), - * 包含完整校验 + GameRecord 创建 + WS BOARD_SYNC + SSE ROOM_UPDATED。 - * - * TODO: 金币结算逻辑预留,后期处理 - */ - @Override - @Transactional - public void startGame(String roomId, String ownerId) { - GameRoomEntity room = gameRoomRepository.findById(roomId) - .orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND)); - - // 校验操作者是房主 - if (!ownerId.equals(room.getCreatorId())) { - throw new BusinessException(ErrorCode.NOT_ROOM_OWNER); - } - - // 校验房间状态 - if (!"waiting".equals(room.getStatus())) { - throw new BusinessException(ErrorCode.ROOM_ALREADY_STARTED); - } - - // 校验玩家人数 >= 2 - List players = roomPlayerRepository.findByRoomId(roomId); - if (players.size() < 2) { - throw new BusinessException(ErrorCode.PLAYER_COUNT_INSUFFICIENT); - } - - // 校验所有玩家已准备 - for (RoomPlayerEntity player : players) { - if (!player.getReadyStatus()) { - throw new BusinessException(ErrorCode.NOT_ALL_READY); - } - } - - // 更新房间状态为进行中 - room.setStatus("playing"); - gameRoomRepository.save(room); - - log.info("[游戏服务] 游戏开始, roomId={}, 参与人数={}", roomId, players.size()); - - // 通知房间状态更新(大厅刷新) - List allPlayers = buildRoomPlayers(roomId); - GameConfigEntity gameConfig = gameConfigRepository.findByGameId(room.getGameId()) - .orElse(null); - String icon = gameConfig != null ? gameConfig.getIcon() : ""; - String creatorName = getNickname(room.getCreatorId()); - RoomItemResponse response = RoomItemResponse.from(room, allPlayers, icon, creatorName); - sseEventPublisher.broadcast(SseEventType.ROOM_UPDATED, response); - } - - @Override - public RoomDetailResponse getRoomDetail(String roomId) { - GameRoomEntity room = gameRoomRepository.findById(roomId) - .orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND)); - - GameConfigEntity gameConfig = gameConfigRepository.findByGameId(room.getGameId()) - .orElseThrow(() -> new BusinessException(ErrorCode.GAME_NOT_FOUND)); - - // 组装玩家详情(含座位号和准备状态) - List roomPlayers = roomPlayerRepository.findByRoomId(roomId); - List userIds = roomPlayers.stream() - .map(RoomPlayerEntity::getUserId).toList(); - Map userMap = userRepository.findByAccountIdIn(userIds).stream() - .collect(Collectors.toMap(u -> u.getAccount().getId(), u -> u)); - - List playerDetails = roomPlayers.stream() - .map(rp -> { - UserEntity user = userMap.get(rp.getUserId()); - return new RoomDetailResponse.RoomPlayerDetail( - rp.getUserId(), - user != null ? user.getAvatar() : "", - user != null ? user.getNickName() : "未知玩家", - rp.getSeatNumber(), - rp.getReadyStatus() - ); - }).toList(); - - String creatorName = getNickname(room.getCreatorId()); - - // 对局中或有对局记录时,查询棋盘状态 - int[][] boardState = null; - String currentTurn = null; - List moves = List.of(); - - if ("playing".equals(room.getStatus()) || "finished".equals(room.getStatus())) { - // 查询走棋记录 - List moveEntities = gameMoveRepository.findByRoomIdOrderByMoveIndexAsc(roomId); - moves = moveEntities.stream() - .map(m -> new RoomDetailResponse.MoveItem( - m.getMoveIndex(), m.getPlayerId(), m.getRowNum(), m.getColNum())) - .toList(); - - // 从走棋记录重建棋盘 - boardState = GomokuGameService.rebuildBoard(moveEntities, GomokuGameService.BOARD_SIZE); - // currentTurn 由服务端运行时管理,HTTP 快照中暂不返回 - } - - log.info("[游戏服务] 获取房间详情, roomId={}, status={}, 玩家数={}", - roomId, room.getStatus(), playerDetails.size()); - - return new RoomDetailResponse( - room.getId(), room.getName(), room.getGameId(), - gameConfig.getIcon(), gameConfig.getName(), - room.getMaxPlayers(), room.getMode(), room.getStakes(), - room.getStatus(), room.getCreatorId(), creatorName, - room.getAllowSpectate(), playerDetails, boardState, currentTurn, moves - ); - } - - // ==================== 私有辅助方法 ==================== - - /** - * 组装房间内的玩家信息列表 - * - * @param roomId 房间 ID - * @return 玩家响应列表(含头像和昵称) - */ - private List buildRoomPlayers(String roomId) { - List roomPlayers = roomPlayerRepository.findByRoomId(roomId); - if (roomPlayers.isEmpty()) { - return List.of(); - } - - // 批量查询玩家用户信息 - List userIds = roomPlayers.stream() - .map(RoomPlayerEntity::getUserId) - .toList(); - List users = userRepository.findByAccountIdIn(userIds); - Map userMap = users.stream() - .collect(Collectors.toMap(u -> u.getAccount().getId(), u -> u)); - - return roomPlayers.stream() - .map(rp -> { - UserEntity user = userMap.get(rp.getUserId()); - String avatar = (user != null) ? user.getAvatar() : ""; - String nickname = (user != null) ? user.getNickName() : "未知玩家"; - return new RoomPlayerResponse(avatar, nickname); - }) - .toList(); - } - - /** - * 获取用户昵称 - * - * @param accountId 账号 ID - * @return 昵称,查不到则返回 "未知玩家" - */ - private String getNickname(String accountId) { - return userRepository.findByAccountId(accountId) - .map(UserEntity::getNickName) - .orElse("未知玩家"); - } } diff --git a/src/main/java/com/webgame/webgamebackend/service/impl/GomokuGameServiceImpl.java b/src/main/java/com/webgame/webgamebackend/service/impl/GomokuGameServiceImpl.java deleted file mode 100644 index de98a9f..0000000 --- a/src/main/java/com/webgame/webgamebackend/service/impl/GomokuGameServiceImpl.java +++ /dev/null @@ -1,505 +0,0 @@ -package com.webgame.webgamebackend.service.impl; - -import com.alibaba.fastjson2.JSON; -import com.webgame.webgamebackend.common.dto.game.RoomItemResponse; -import com.webgame.webgamebackend.common.dto.game.RoomPlayerResponse; -import com.webgame.webgamebackend.common.event.SseEventPublisher; -import com.webgame.webgamebackend.common.event.SseEventType; -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.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; - -/** - * 五子棋对局逻辑服务实现 - * - * 管理五子棋对局的完整生命周期:落子校验、胜负判定、走棋记录、断线处理。 - * 通过 RoomSessionManager 向房间内 WebSocket 会话广播状态变更。 - * 通过 SseEventPublisher 向大厅推送房间状态变更。 - */ -@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; - private final GameConfigRepository gameConfigRepository; - private final SseEventPublisher sseEventPublisher; - - /** 断线超时时间(秒) */ - private static final long DISCONNECT_TIMEOUT_SECONDS = 60; - - /** 断线超时定时器线程池 */ - private final ScheduledExecutorService disconnectScheduler = - Executors.newSingleThreadScheduledExecutor(r -> - new Thread(r, "disconnect-timeout")); - - /** 断线超时任务映射: "roomId:userId" → ScheduledFuture */ - private final ConcurrentHashMap> disconnectTasks = new ConcurrentHashMap<>(); - - @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(); - - // 按座位号排序,确保黑方/白方分配与座位对应(seat=1 为黑方先手) - List players = roomPlayerRepository.findByRoomId(roomId) - .stream() - .sorted(java.util.Comparator.comparingInt(RoomPlayerEntity::getSeatNumber)) - .toList(); - 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); - - // WS 广播准备状态变更(房间内实时同步) - 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) { - GameRoomEntity room = getRoom(roomId); - - // 检查玩家是否在房间中 - RoomPlayerEntity player = roomPlayerRepository.findByRoomIdAndUserId(roomId, userId) - .orElse(null); - if (player == null) { - return; // 已不在房间,无需操作 - } - - roomPlayerRepository.delete(player); - List remaining = roomPlayerRepository.findByRoomId(roomId); - - // WS 广播玩家离开 - broadcast(roomId, sessionManager, new WsMessage(WsOutboundMessage.PLAYER_LEAVE, Map.of( - "userId", userId - ))); - - if (remaining.isEmpty()) { - // 无人了,关闭对局记录(如果进行中)并删除房间 - if ("playing".equals(room.getStatus())) { - gameRecordRepository.findByRoomId(roomId).ifPresent(record -> { - record.setEndedAt(LocalDateTime.now()); - record.setResultType("abandoned"); - gameRecordRepository.save(record); - }); - } - gameRoomRepository.delete(room); - log.info("[五子棋] 房间无人,已删除, roomId={}", roomId); - // SSE: 大厅移除房间 - sseEventPublisher.broadcast(SseEventType.ROOM_REMOVED, Map.of("roomId", roomId)); - } else if (userId.equals(room.getCreatorId())) { - // 房主离开,顺位转让给剩余第一个玩家 - RoomPlayerEntity nextOwner = remaining.get(0); - room.setCreatorId(nextOwner.getUserId()); - nextOwner.setReadyStatus(true); - roomPlayerRepository.save(nextOwner); - gameRoomRepository.save(room); - log.info("[五子棋] 房主离开,转让给 userId={}, roomId={}", nextOwner.getUserId(), roomId); - // SSE: 大厅更新房间信息 - pushRoomUpdated(room); - } else { - // SSE: 大厅更新房间信息 - pushRoomUpdated(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()); - - // WS: 广播棋盘同步(空棋盘 + 黑方先行) - broadcast(roomId, sessionManager, new WsMessage(WsOutboundMessage.BOARD_SYNC, Map.of( - "board", new int[BOARD_SIZE][BOARD_SIZE], - "currentTurn", players.get(0).getUserId() // 第一位玩家先行(黑方) - ))); - - // SSE: 大厅更新房间状态(waiting → playing) - pushRoomUpdated(room); - } - - @Override - public void handleDisconnect(String roomId, String userId, RoomSessionManager sessionManager) { - String taskKey = roomId + ":" + userId; - log.info("[五子棋] 玩家断连,启动{}秒超时定时器, roomId={}, userId={}", - DISCONNECT_TIMEOUT_SECONDS, roomId, userId); - - // 启动断线超时定时器 - ScheduledFuture task = disconnectScheduler.schedule(() -> { - disconnectTasks.remove(taskKey); - try { - onDisconnectTimeout(roomId, userId, sessionManager); - } catch (Exception e) { - log.error("[五子棋] 断线超时处理异常, roomId={}, userId={}", roomId, userId, e); - } - }, DISCONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS); - - disconnectTasks.put(taskKey, task); - } - - @Override - public void cancelDisconnectTimeout(String roomId, String userId) { - String taskKey = roomId + ":" + userId; - ScheduledFuture task = disconnectTasks.remove(taskKey); - if (task != null) { - boolean cancelled = task.cancel(false); - log.info("[五子棋] 玩家重连,取消断线超时定时器, roomId={}, userId={}, cancelled={}", - roomId, userId, cancelled); - } - } - - // ==================== 私有辅助方法 ==================== - - /** - * 根据房间 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); - } - } - } - } - - /** - * 推送房间更新到大厅(SSE) - */ - private void pushRoomUpdated(GameRoomEntity room) { - List roomPlayers = roomPlayerRepository.findByRoomId(room.getId()); - List players = buildRoomPlayerResponses(roomPlayers); - String creatorName = getNickname(room.getCreatorId()); - String icon = gameConfigRepository.findByGameId(room.getGameId()) - .map(GameConfigEntity::getIcon) - .orElse(""); - RoomItemResponse response = RoomItemResponse.from(room, players, icon, creatorName); - sseEventPublisher.broadcast(SseEventType.ROOM_UPDATED, response); - } - - /** - * 断线超时处理:对局中自动认输,等待中自动离开 - */ - @Transactional - public void onDisconnectTimeout(String roomId, String userId, RoomSessionManager sessionManager) { - GameRoomEntity room = getRoom(roomId); - - // 检查玩家是否还在房间中(可能已被踢出或已离开) - boolean stillInRoom = roomPlayerRepository.findByRoomIdAndUserId(roomId, userId).isPresent(); - if (!stillInRoom) { - log.info("[五子棋] 断线超时:玩家已不在房间, roomId={}, userId={}", roomId, userId); - return; - } - - if ("playing".equals(room.getStatus())) { - log.info("[五子棋] 断线超时:对局中自动判负, roomId={}, userId={}", roomId, userId); - // 自动认输 - resign(roomId, userId, sessionManager); - // 移除断线玩家记录(对手仍留在房间,离开时触发删除) - leaveRoom(roomId, userId, sessionManager); - } else { - log.info("[五子棋] 断线超时:等待中自动离开, roomId={}, userId={}", roomId, userId); - // 自动离开房间 - leaveRoom(roomId, userId, sessionManager); - } - } - - /** - * 对局结束 - * - * TODO: 金币结算逻辑预留,后期处理 - */ - @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); - - // WS: 广播对局结束 - broadcast(roomId, sessionManager, new WsMessage(WsOutboundMessage.GAME_OVER, Map.of( - "winnerId", winnerId, - "resultType", resultType - ))); - - // SSE: 大厅更新房间状态 - pushRoomUpdated(room); - } - - /** - * 检查五连胜利(以指定坐标为中心,检查四个方向) - * - * @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; - } - - /** - * 组装房间玩家响应列表(含头像和昵称) - */ - private List buildRoomPlayerResponses(List roomPlayers) { - if (roomPlayers.isEmpty()) { - return List.of(); - } - List userIds = roomPlayers.stream() - .map(RoomPlayerEntity::getUserId) - .toList(); - List users = userRepository.findByAccountIdIn(userIds); - Map userMap = users.stream() - .collect(Collectors.toMap(u -> u.getAccount().getId(), u -> u)); - - return roomPlayers.stream() - .map(rp -> { - UserEntity user = userMap.get(rp.getUserId()); - String avatar = user != null ? user.getAvatar() : ""; - String nickname = user != null ? user.getNickName() : "未知玩家"; - return new RoomPlayerResponse(avatar, nickname); - }) - .toList(); - } - - /** - * 获取用户昵称 - */ - private String getNickname(String accountId) { - return userRepository.findByAccountId(accountId) - .map(UserEntity::getNickName) - .orElse("未知玩家"); - } -} diff --git a/src/main/java/com/webgame/webgamebackend/ws/RoomWebSocketHandler.java b/src/main/java/com/webgame/webgamebackend/ws/RoomWebSocketHandler.java index 1a6a6aa..3a0f04d 100644 --- a/src/main/java/com/webgame/webgamebackend/ws/RoomWebSocketHandler.java +++ b/src/main/java/com/webgame/webgamebackend/ws/RoomWebSocketHandler.java @@ -3,7 +3,12 @@ package com.webgame.webgamebackend.ws; import com.webgame.webgamebackend.common.auth.AuthenticationProvider; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; -import com.webgame.webgamebackend.service.GomokuGameService; +import com.webgame.webgamebackend.entities.GameRoomEntity; +import com.webgame.webgamebackend.repository.GameRoomRepository; +import com.webgame.webgamebackend.service.RoomManager; +import com.webgame.webgamebackend.service.engine.GameEngine; +import com.webgame.webgamebackend.service.engine.GameEngineRegistry; +import com.webgame.webgamebackend.service.engine.RoomContext; import com.webgame.webgamebackend.ws.dto.WsInboundMessage; import com.webgame.webgamebackend.ws.dto.WsMessage; import com.webgame.webgamebackend.ws.dto.WsOutboundMessage; @@ -23,9 +28,14 @@ import java.util.Map; /** * 房间 WebSocket 处理器 * - * 处理客户端连接、断开与消息路由。消息类型通过 WsMessageRouter 注册表分发, - * 新增消息类型只需注册对应的 WsMessageHandler,无需修改路由逻辑。 - * 通过 URL 参数传递 roomId 和 token 完成认证与房间绑定。 + * 重构后的职责: + * - 连接建立:认证 + 注册会话 + 取消断线倒计时(通过 RoomManager) + * - 消息路由:房间管理消息 → RoomManager,游戏消息 → GameEngine + * - 连接关闭:启动断线倒计时(通过 RoomManager) + * + *

采用策略路由:根据房间当前状态(WAITING/PLAYING)将消息分发给不同的处理器。 + * WAITING 状态下只有房间管理消息(ready, leave, start)有效; + * PLAYING 状态下游戏消息(place_stone, resign, draw_*)由 GameEngine 处理。

*/ @Slf4j @Component @@ -33,62 +43,69 @@ import java.util.Map; public class RoomWebSocketHandler extends TextWebSocketHandler { private final RoomSessionManager sessionManager; - private final GomokuGameService gomokuGameService; + private final RoomManager roomManager; + private final GameEngineRegistry engineRegistry; + private final GameRoomRepository gameRoomRepository; private final WsMessageRouter router; private final AuthenticationProvider authProvider; - /** - * 注册所有消息类型与对应处理器 - * - * 新增消息类型只需在此方法中添加一行 router.register(...) 即可。 - */ @PostConstruct public void registerHandlers() { - // 落子:需从 data 中提取 row, col + // ===== 房间管理消息(所有状态) ===== + + // 准备/取消准备 + router.register(WsInboundMessage.READY, + (roomId, userId, data, sm) -> + roomManager.toggleReady(roomId, userId)); + + // 离开房间 + router.register(WsInboundMessage.LEAVE, + (roomId, userId, data, sm) -> + roomManager.leaveRoom(roomId, userId)); + + // 开始游戏(仅房主) + router.register(WsInboundMessage.START, + (roomId, userId, data, sm) -> + roomManager.startGame(roomId, userId)); + + // ===== 游戏消息(PLAYING 状态) ===== + + // 落子 router.register(WsInboundMessage.PLACE_STONE, (roomId, userId, data, sm) -> { - int row = data.getIntValue("row"); - int col = data.getIntValue("col"); - gomokuGameService.placeStone(roomId, userId, row, col, sm); + GameRoomEntity room = gameRoomRepository.findById(roomId).orElse(null); + if (room != null && "playing".equals(room.getStatus())) { + // 通过 RoomManager 获取引擎的 getState 不行,需要直接找到引擎 + // 改为委托模式:RoomManager 提供 executeGameAction 方法 + roomManager.executeGameAction(roomId, userId, "place_stone", data); + } + }); + + // 认输 + router.register(WsInboundMessage.RESIGN, (roomId, userId, data, sm) -> { + roomManager.executeGameAction(roomId, userId, "resign", null); + }); + + // 求和请求 + router.register(WsInboundMessage.DRAW_REQUEST, (roomId, userId, data, sm) -> { + roomManager.executeGameAction(roomId, userId, "draw_request", null); + }); + + // 回应求和 + router.register(WsInboundMessage.DRAW_RESPONSE, (roomId, userId, data, sm) -> { + roomManager.executeGameAction(roomId, userId, "draw_response", data); }); // 聊天 router.register(WsInboundMessage.CHAT, (roomId, userId, data, sm) -> { - String content = data.getString("content"); + String content = data != null ? data.getString("content") : null; handleChat(roomId, userId, content); }); - // 准备/取消准备 - router.register(WsInboundMessage.READY, - (roomId, userId, data, sm) -> gomokuGameService.toggleReady(roomId, userId, sm)); - - // 认输 - router.register(WsInboundMessage.RESIGN, - (roomId, userId, data, sm) -> gomokuGameService.resign(roomId, userId, sm)); - - // 求和请求 - router.register(WsInboundMessage.DRAW_REQUEST, - (roomId, userId, data, sm) -> gomokuGameService.requestDraw(roomId, userId, sm)); - - // 回应求和:需从 data 中提取 accept - router.register(WsInboundMessage.DRAW_RESPONSE, (roomId, userId, data, sm) -> { - boolean accept = data.getBooleanValue("accept"); - gomokuGameService.respondToDraw(roomId, userId, accept, sm); - }); - - // 离开房间 - router.register(WsInboundMessage.LEAVE, - (roomId, userId, data, sm) -> gomokuGameService.leaveRoom(roomId, userId, sm)); - - // 开始游戏(仅房主) - router.register(WsInboundMessage.START, - (roomId, userId, data, sm) -> gomokuGameService.startGame(roomId, userId, sm)); - log.info("[WS] 已注册 {} 个消息处理器", router.size()); } @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { - // 从 URL 参数中提取 roomId 和 token URI uri = session.getUri(); if (uri == null) { session.close(CloseStatus.BAD_DATA); @@ -116,11 +133,11 @@ public class RoomWebSocketHandler extends TextWebSocketHandler { return; } - // 注册会话(内部会清理同用户的旧会话) + // 注册会话 sessionManager.addSession(roomId, userId, session); - // 取消断线超时定时器(重连场景) - gomokuGameService.cancelDisconnectTimeout(roomId, userId); + // 处理重连 + roomManager.handleReconnect(roomId, userId); log.info("[WS] 连接建立, roomId={}, userId={}, sessionId={}", roomId, userId, session.getId()); } @@ -144,7 +161,6 @@ public class RoomWebSocketHandler extends TextWebSocketHandler { Object rawData = json.get("data"); JSONObject data = (rawData instanceof JSONObject) ? (JSONObject) rawData : null; - // 通过路由器查找处理器 WsMessageHandler handler = router.get(type); if (handler == null) { sendError(session, "未知消息类型: " + type); @@ -166,8 +182,8 @@ public class RoomWebSocketHandler extends TextWebSocketHandler { roomId, userId, session.getId(), status); if (userId != null && roomId != null) { - // 通知游戏服务玩家断开 - gomokuGameService.handleDisconnect(roomId, userId, sessionManager); + // 通知 RoomManager 处理断线 + roomManager.handleDisconnect(roomId, userId); } sessionManager.removeSession(session); } @@ -191,15 +207,9 @@ public class RoomWebSocketHandler extends TextWebSocketHandler { private static final int CHAT_MAX_LENGTH = 500; /** - * 聊天消息处理(暂不持久化,直接广播) - * - * 校验规则: - * - content 不能为 null 或空字符串 - * - content 长度不能超过 CHAT_MAX_LENGTH(500 字符) - * - 移除 HTML 标签防止 XSS + * 聊天消息处理 */ private void handleChat(String roomId, String userId, String content) { - // 内容为空或超长时直接丢弃 if (content == null || content.isBlank()) { return; } @@ -207,12 +217,10 @@ public class RoomWebSocketHandler extends TextWebSocketHandler { content = content.substring(0, CHAT_MAX_LENGTH); } - // 简易 XSS 防护:移除 HTML 标签 String sanitized = content .replace("<", "<") .replace(">", ">"); - // TODO: 后续可增加消息持久化 broadcastToRoom(roomId, new WsMessage(WsOutboundMessage.CHAT_BROADCAST, Map.of( "userId", userId, "content", sanitized, diff --git a/src/main/java/com/webgame/webgamebackend/ws/dto/WsOutboundMessage.java b/src/main/java/com/webgame/webgamebackend/ws/dto/WsOutboundMessage.java index 33054bb..e7b2801 100644 --- a/src/main/java/com/webgame/webgamebackend/ws/dto/WsOutboundMessage.java +++ b/src/main/java/com/webgame/webgamebackend/ws/dto/WsOutboundMessage.java @@ -23,10 +23,6 @@ public final class WsOutboundMessage { public static final String PLAYER_LEAVE = "player_leave"; /** 玩家准备状态变更 */ public static final String READY_CHANGE = "ready_change"; - /** 观战者加入 */ - public static final String SPECTATOR_JOIN = "spectator_join"; - /** 观战者离开 */ - public static final String SPECTATOR_LEAVE = "spectator_leave"; /** 走棋记录同步 */ public static final String MOVE_HISTORY = "move_history"; /** 错误消息 */