feat: 重构游戏房间架构
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.webgame.webgamebackend.common.dto.game;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 解散房间请求
|
||||
*/
|
||||
public record DisbandRoomRequest(
|
||||
@NotBlank(message = "房间ID不能为空")
|
||||
String roomId
|
||||
) {}
|
||||
@@ -20,7 +20,6 @@ public record RoomDetailResponse(
|
||||
String status,
|
||||
String creatorId,
|
||||
String creatorName,
|
||||
Boolean allowSpectate,
|
||||
List<RoomPlayerDetail> players,
|
||||
/** 对局中才有棋盘状态 */
|
||||
int[][] boardState,
|
||||
|
||||
@@ -19,8 +19,7 @@ public record RoomItemResponse(
|
||||
String statusText,
|
||||
int stakes,
|
||||
String mode,
|
||||
String creatorName,
|
||||
Boolean allowSpectate
|
||||
String creatorName
|
||||
) {
|
||||
/** 状态 → 展示文本映射 */
|
||||
private static final Map<String, String> 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 订阅这些事件并执行房间层处理(状态更新、结算等)。
|
||||
*
|
||||
* <p>房间层到游戏层的通信通过 {@link com.webgame.webgamebackend.service.engine.GameEngine}
|
||||
* 接口直接调用,不需要经过事件总线。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class RoomEventBus {
|
||||
|
||||
/** 事件类型 → 订阅者列表 */
|
||||
private final Map<String, List<Consumer<Object>>> subscribers = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 订阅事件
|
||||
*
|
||||
* @param eventType 事件类型
|
||||
* @param handler 事件处理器
|
||||
*/
|
||||
public void subscribe(String eventType, Consumer<Object> handler) {
|
||||
subscribers.computeIfAbsent(eventType, k -> new CopyOnWriteArrayList<>()).add(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布事件
|
||||
*
|
||||
* @param eventType 事件类型
|
||||
* @param payload 事件数据
|
||||
*/
|
||||
public void publish(String eventType, Object payload) {
|
||||
List<Consumer<Object>> handlers = subscribers.get(eventType);
|
||||
if (handlers == null || handlers.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (Consumer<Object> 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) {}
|
||||
}
|
||||
@@ -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(...));
|
||||
}
|
||||
}
|
||||
@@ -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<RoomListResponse> roomList(
|
||||
@RequestParam String gameId,
|
||||
@RequestParam(required = false) String status) {
|
||||
RoomListResponse result = gameService.getRoomList(gameId, status);
|
||||
return ApiResponse.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建房间
|
||||
*
|
||||
* 需登录。房主自动加入房间并设为已准备。
|
||||
*/
|
||||
@PostMapping("/room/create")
|
||||
public ApiResponse<CreateRoomResponse> createRoom(@Valid @RequestBody CreateRoomRequest request) {
|
||||
String userId = authProvider.getCurrentUserId();
|
||||
CreateRoomResponse result = gameService.createRoom(request, userId);
|
||||
return ApiResponse.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加入房间
|
||||
*
|
||||
* 需登录。公开房间无需传密码。
|
||||
*/
|
||||
@PostMapping("/room/join")
|
||||
public ApiResponse<RoomItemResponse> joinRoom(@Valid @RequestBody JoinRoomRequest request) {
|
||||
String userId = authProvider.getCurrentUserId();
|
||||
RoomItemResponse result = gameService.joinRoom(request, userId);
|
||||
return ApiResponse.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 离开房间
|
||||
*
|
||||
* 需登录。房主离开时顺位转让。无人时房间自动结束。
|
||||
*/
|
||||
@PostMapping("/room/leave")
|
||||
public ApiResponse<Void> leaveRoom(@Valid @RequestBody LeaveRoomRequest request) {
|
||||
String userId = authProvider.getCurrentUserId();
|
||||
gameService.leaveRoom(request.roomId(), userId);
|
||||
return ApiResponse.ok(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 踢出玩家
|
||||
*
|
||||
* 需登录。仅房主可操作。
|
||||
*/
|
||||
@PostMapping("/room/kick")
|
||||
public ApiResponse<Void> 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<Boolean> 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<RoomDetailResponse> roomDetail(@PathVariable String roomId) {
|
||||
RoomDetailResponse result = gameService.getRoomDetail(roomId);
|
||||
return ApiResponse.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始游戏
|
||||
*
|
||||
* 需登录。仅房主可操作。需全部玩家准备且人数 >= 2。
|
||||
* 开始后扣除所有玩家的底注。
|
||||
*/
|
||||
@PostMapping("/room/start")
|
||||
public ApiResponse<Void> startGame(@Valid @RequestBody StartGameRequest request) {
|
||||
String ownerId = authProvider.getCurrentUserId();
|
||||
gameService.startGame(request.roomId(), ownerId);
|
||||
return ApiResponse.ok(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<RoomListResponse> roomList(
|
||||
@RequestParam String gameId,
|
||||
@RequestParam(required = false) String status) {
|
||||
RoomListResponse result = roomManager.getRoomList(gameId, status);
|
||||
return ApiResponse.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取房间详情
|
||||
*/
|
||||
@GetMapping("/{roomId}")
|
||||
public ApiResponse<RoomDetailResponse> roomDetail(@PathVariable String roomId) {
|
||||
RoomDetailResponse result = roomManager.getRoomDetail(roomId);
|
||||
return ApiResponse.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建房间
|
||||
*
|
||||
* 需登录。房主自动加入房间并设为已准备。
|
||||
*/
|
||||
@PostMapping("/create")
|
||||
public ApiResponse<CreateRoomResponse> createRoom(@Valid @RequestBody CreateRoomRequest request) {
|
||||
String userId = authProvider.getCurrentUserId();
|
||||
CreateRoomResponse result = roomManager.createRoom(request, userId);
|
||||
return ApiResponse.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加入房间
|
||||
*/
|
||||
@PostMapping("/join")
|
||||
public ApiResponse<RoomItemResponse> joinRoom(@Valid @RequestBody JoinRoomRequest request) {
|
||||
String userId = authProvider.getCurrentUserId();
|
||||
RoomItemResponse result = roomManager.joinRoom(request, userId);
|
||||
return ApiResponse.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 离开房间
|
||||
*/
|
||||
@PostMapping("/leave")
|
||||
public ApiResponse<Void> leaveRoom(@Valid @RequestBody LeaveRoomRequest request) {
|
||||
String userId = authProvider.getCurrentUserId();
|
||||
roomManager.leaveRoom(request.roomId(), userId);
|
||||
return ApiResponse.ok(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解散房间(仅房主)
|
||||
*/
|
||||
@PostMapping("/disband")
|
||||
public ApiResponse<Void> disbandRoom(@Valid @RequestBody DisbandRoomRequest request) {
|
||||
String userId = authProvider.getCurrentUserId();
|
||||
roomManager.disbandRoom(request.roomId(), userId);
|
||||
return ApiResponse.ok(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 踢出玩家(仅房主)
|
||||
*/
|
||||
@PostMapping("/kick")
|
||||
public ApiResponse<Void> kickPlayer(@Valid @RequestBody KickPlayerRequest request) {
|
||||
String ownerId = authProvider.getCurrentUserId();
|
||||
roomManager.kickPlayer(request.roomId(), ownerId, request.userId());
|
||||
return ApiResponse.ok(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换准备状态
|
||||
*/
|
||||
@PostMapping("/ready")
|
||||
public ApiResponse<Boolean> toggleReady(@Valid @RequestBody ReadyRequest request) {
|
||||
String userId = authProvider.getCurrentUserId();
|
||||
boolean ready = roomManager.toggleReady(request.roomId(), userId);
|
||||
return ApiResponse.ok(ready);
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始游戏(仅房主)
|
||||
*/
|
||||
@PostMapping("/start")
|
||||
public ApiResponse<Void> startGame(@Valid @RequestBody StartGameRequest request) {
|
||||
String ownerId = authProvider.getCurrentUserId();
|
||||
roomManager.startGame(request.roomId(), ownerId);
|
||||
return ApiResponse.ok(null);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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<GameRoomEntity, String
|
||||
*/
|
||||
@Query("SELECT r FROM GameRoomEntity r WHERE r.id = :id")
|
||||
Optional<GameRoomEntity> findByIdForUpdate(@Param("id") String id);
|
||||
|
||||
/**
|
||||
* 根据状态和更新时间查询房间(用于超时清理)
|
||||
*
|
||||
* @param status 房间状态
|
||||
* @param updateTime 更新时间上限
|
||||
* @return 符合条件的房间列表
|
||||
*/
|
||||
List<GameRoomEntity> findByStatusAndUpdateTimeBefore(String status, LocalDateTime updateTime);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<GameMoveEntity> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 房间管理器 — 统一管理所有房间生命周期
|
||||
*
|
||||
* <h3>职责</h3>
|
||||
* <ul>
|
||||
* <li>房间 CRUD:创建、加入、离开、解散、踢人</li>
|
||||
* <li>状态流转:WAITING → PLAYING → FINISHED</li>
|
||||
* <li>准备/取消准备</li>
|
||||
* <li>开始游戏(委托给 GameEngine)</li>
|
||||
* <li>断线重连管理(60 秒倒计时)</li>
|
||||
* <li>超时房间清理(定时任务)</li>
|
||||
* <li>游戏结束结算(房间层负责金币变更)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>房间数据持久化到 MySQL,运行时状态(游戏引擎引用、断线定时器)
|
||||
* 缓存在内存中。</p>
|
||||
*/
|
||||
@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<String, RoomRuntime> roomRuntimes = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 房间运行时状态(不可持久化的部分)
|
||||
*/
|
||||
private static class RoomRuntime {
|
||||
/** 活跃的游戏引擎(PLAYING 状态下非 null) */
|
||||
volatile GameEngine gameEngine;
|
||||
/** 断线倒计时任务: userId → Future */
|
||||
final ConcurrentHashMap<String, ScheduledFuture<?>> 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<String> modes = JSON.parseArray(gameConfig.getModes(), String.class);
|
||||
if (!modes.contains(request.mode())) {
|
||||
throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的模式: " + request.mode());
|
||||
}
|
||||
|
||||
// 校验 stakes
|
||||
List<Integer> 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<RoomPlayerEntity> 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<RoomPlayerEntity> 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<RoomPlayerEntity> players = roomPlayerRepository.findByRoomId(roomId);
|
||||
if (players.size() < 2) {
|
||||
throw new BusinessException(ErrorCode.PLAYER_COUNT_INSUFFICIENT);
|
||||
}
|
||||
for (RoomPlayerEntity p : players) {
|
||||
if (!p.getReadyStatus()) {
|
||||
throw new BusinessException(ErrorCode.NOT_ALL_READY);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查引擎是否已注册
|
||||
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<RoomPlayerEntity> sortedPlayers = players.stream()
|
||||
.sorted(Comparator.comparingInt(RoomPlayerEntity::getSeatNumber))
|
||||
.toList();
|
||||
|
||||
List<RoomContext.PlayerInfo> 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<RoomPlayerEntity> 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<RoomPlayerEntity> roomPlayers = roomPlayerRepository.findByRoomId(room.getId());
|
||||
List<String> userIds = roomPlayers.stream().map(RoomPlayerEntity::getUserId).toList();
|
||||
Map<String, UserEntity> userMap = userRepository.findByAccountIdIn(userIds).stream()
|
||||
.collect(Collectors.toMap(u -> u.getAccount().getId(), u -> u));
|
||||
|
||||
List<RoomDetailResponse.RoomPlayerDetail> 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<RoomDetailResponse.MoveItem> moves = List.of();
|
||||
|
||||
if ("playing".equals(room.getStatus()) || "finished".equals(room.getStatus())) {
|
||||
List<GameMoveEntity> 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<GameMoveEntity> 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<GameRoomEntity> 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<RoomItemResponse> 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<GameRoomEntity> 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<GameRoomEntity> 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<RoomPlayerResponse> 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<RoomPlayerResponse> buildRoomPlayers(String roomId) {
|
||||
List<RoomPlayerEntity> roomPlayers = roomPlayerRepository.findByRoomId(roomId);
|
||||
if (roomPlayers.isEmpty()) return List.of();
|
||||
|
||||
List<String> userIds = roomPlayers.stream().map(RoomPlayerEntity::getUserId).toList();
|
||||
List<UserEntity> users = userRepository.findByAccountIdIn(userIds);
|
||||
Map<String, UserEntity> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.webgame.webgamebackend.service.engine;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
/**
|
||||
* 游戏引擎接口
|
||||
*
|
||||
* 每种游戏类型提供自己的实现,通过 {@link GameEngineRegistry} 注册。
|
||||
* 引擎只关心游戏规则,不关心房间管理(创建/加入/离开/踢人等)。
|
||||
* 房间层通过此接口与游戏层通信。
|
||||
*
|
||||
* <h3>生命周期</h3>
|
||||
* <ol>
|
||||
* <li>游戏开始时由 RoomManager 创建引擎实例</li>
|
||||
* <li>调用 {@link #init(RoomContext)} 初始化游戏</li>
|
||||
* <li>通过 {@link #handleAction} 处理玩家操作</li>
|
||||
* <li>游戏结束或被解散时调用 {@link #destroy()}</li>
|
||||
* </ol>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<String, GameEngineFactory> 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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 五子棋规则:
|
||||
* <ul>
|
||||
* <li>黑方先手(seat=1)</li>
|
||||
* <li>交替落子</li>
|
||||
* <li>五连(横/竖/对角)判胜</li>
|
||||
* <li>支持认输、求和</li>
|
||||
* <li>断线 60 秒自动判负</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>不关心房间管理(加入/离开/踢人/房主转移),只关心棋盘上的规则。</p>
|
||||
*/
|
||||
@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<String> playerIds = new ArrayList<>(); // 按座位号排序
|
||||
private boolean finished;
|
||||
|
||||
/** 断线倒计时: userId → Future */
|
||||
private final ConcurrentHashMap<String, ScheduledFuture<?>> disconnectTimers = new ConcurrentHashMap<>();
|
||||
|
||||
public GomokuEngine(GameMoveRepository moveRepository) {
|
||||
this.moveRepository = moveRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void init(RoomContext context) {
|
||||
this.context = context;
|
||||
// 按座位号排序的玩家列表,seat=1 为黑方先手
|
||||
List<RoomContext.PlayerInfo> 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<String, Object> 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<GameMoveEntity> moves = moveRepository
|
||||
.findByRoomIdOrderByMoveIndexAsc(context.getRoomId());
|
||||
for (GameMoveEntity move : moves) {
|
||||
int playerValue = (move.getMoveIndex() % 2 == 1) ? 1 : 2;
|
||||
board[move.getRowNum()][move.getColNum()] = playerValue;
|
||||
}
|
||||
|
||||
// 序列化走棋历史
|
||||
List<Map<String, Object>> moveHistory = moves.stream()
|
||||
.map(m -> Map.<String, Object>of(
|
||||
"moveIndex", m.getMoveIndex(),
|
||||
"playerId", m.getPlayerId(),
|
||||
"row", m.getRowNum(),
|
||||
"col", m.getColNum()
|
||||
)).toList();
|
||||
|
||||
Map<String, Object> 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<GameMoveEntity> 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<GameMoveEntity> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<PlayerInfo> players;
|
||||
|
||||
/** 玩法模式 */
|
||||
private final String mode;
|
||||
|
||||
/** 底注金额 */
|
||||
private final int stakes;
|
||||
|
||||
/** 广播消息到房间内所有 WebSocket 会话 */
|
||||
private final Consumer<WsMessage> broadcaster;
|
||||
|
||||
/** 私有消息:向指定用户发送消息 */
|
||||
private final PrivateMessageSender privateSender;
|
||||
|
||||
/** 游戏结束回调 */
|
||||
private final GameOverCallback onGameOver;
|
||||
|
||||
public RoomContext(String roomId, String gameId, List<PlayerInfo> players,
|
||||
String mode, int stakes,
|
||||
Consumer<WsMessage> 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<PlayerInfo> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<GameRoomEntity> 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<RoomItemResponse> list = new ArrayList<>();
|
||||
for (GameRoomEntity room : rooms) {
|
||||
List<RoomPlayerResponse> 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<String> modes = JSON.parseArray(gameConfig.getModes(), String.class);
|
||||
if (!modes.contains(request.mode())) {
|
||||
throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的模式: " + request.mode());
|
||||
}
|
||||
|
||||
// 校验 stakes 在可选列表中
|
||||
List<Integer> 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<RoomPlayerResponse> 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<RoomPlayerEntity> 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<RoomPlayerResponse> 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<RoomPlayerEntity> 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<RoomPlayerResponse> 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<RoomPlayerResponse> 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<RoomPlayerResponse> 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<RoomPlayerEntity> 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<RoomPlayerResponse> 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<RoomPlayerEntity> roomPlayers = roomPlayerRepository.findByRoomId(roomId);
|
||||
List<String> userIds = roomPlayers.stream()
|
||||
.map(RoomPlayerEntity::getUserId).toList();
|
||||
Map<String, UserEntity> userMap = userRepository.findByAccountIdIn(userIds).stream()
|
||||
.collect(Collectors.toMap(u -> u.getAccount().getId(), u -> u));
|
||||
|
||||
List<RoomDetailResponse.RoomPlayerDetail> 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<RoomDetailResponse.MoveItem> moves = List.of();
|
||||
|
||||
if ("playing".equals(room.getStatus()) || "finished".equals(room.getStatus())) {
|
||||
// 查询走棋记录
|
||||
List<GameMoveEntity> 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<RoomPlayerResponse> buildRoomPlayers(String roomId) {
|
||||
List<RoomPlayerEntity> roomPlayers = roomPlayerRepository.findByRoomId(roomId);
|
||||
if (roomPlayers.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
// 批量查询玩家用户信息
|
||||
List<String> userIds = roomPlayers.stream()
|
||||
.map(RoomPlayerEntity::getUserId)
|
||||
.toList();
|
||||
List<UserEntity> users = userRepository.findByAccountIdIn(userIds);
|
||||
Map<String, UserEntity> 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("未知玩家");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, ScheduledFuture<?>> 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<GameMoveEntity> moves = gameMoveRepository.findByRoomIdOrderByMoveIndexAsc(roomId);
|
||||
int moveCount = moves.size();
|
||||
|
||||
// 按座位号排序,确保黑方/白方分配与座位对应(seat=1 为黑方先手)
|
||||
List<RoomPlayerEntity> 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<RoomPlayerEntity> players = roomPlayerRepository.findByRoomId(roomId);
|
||||
String winnerId = players.stream()
|
||||
.filter(p -> !p.getUserId().equals(userId))
|
||||
.map(RoomPlayerEntity::getUserId)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
endGame(roomId, winnerId, "resign", room, sessionManager);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void requestDraw(String roomId, String userId, RoomSessionManager sessionManager) {
|
||||
broadcast(roomId, sessionManager, new WsMessage("draw_requested", Map.of(
|
||||
"fromUserId", userId
|
||||
)));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void respondToDraw(String roomId, String userId, boolean accept, RoomSessionManager sessionManager) {
|
||||
if (accept) {
|
||||
GameRoomEntity room = getRoom(roomId);
|
||||
endGame(roomId, null, "draw", room, sessionManager);
|
||||
} else {
|
||||
broadcast(roomId, sessionManager, new WsMessage("draw_rejected", Map.of(
|
||||
"fromUserId", userId
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void leaveRoom(String roomId, String userId, RoomSessionManager sessionManager) {
|
||||
GameRoomEntity room = getRoom(roomId);
|
||||
|
||||
// 检查玩家是否在房间中
|
||||
RoomPlayerEntity player = roomPlayerRepository.findByRoomIdAndUserId(roomId, userId)
|
||||
.orElse(null);
|
||||
if (player == null) {
|
||||
return; // 已不在房间,无需操作
|
||||
}
|
||||
|
||||
roomPlayerRepository.delete(player);
|
||||
List<RoomPlayerEntity> 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<RoomPlayerEntity> players = roomPlayerRepository.findByRoomId(roomId);
|
||||
if (players.size() < 2) {
|
||||
throw new BusinessException(ErrorCode.PLAYER_COUNT_INSUFFICIENT);
|
||||
}
|
||||
for (RoomPlayerEntity p : players) {
|
||||
if (!p.getReadyStatus()) {
|
||||
throw new BusinessException(ErrorCode.NOT_ALL_READY);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新房间状态为进行中
|
||||
room.setStatus("playing");
|
||||
gameRoomRepository.save(room);
|
||||
|
||||
// 记录对局开始
|
||||
GameRecordEntity record = new GameRecordEntity()
|
||||
.setRoomId(roomId)
|
||||
.setStartedAt(LocalDateTime.now());
|
||||
gameRecordRepository.save(record);
|
||||
|
||||
log.info("[五子棋] 对局开始, roomId={}, 玩家数={}", roomId, players.size());
|
||||
|
||||
// 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<RoomPlayerEntity> roomPlayers = roomPlayerRepository.findByRoomId(room.getId());
|
||||
List<RoomPlayerResponse> 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<RoomPlayerResponse> buildRoomPlayerResponses(List<RoomPlayerEntity> roomPlayers) {
|
||||
if (roomPlayers.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> userIds = roomPlayers.stream()
|
||||
.map(RoomPlayerEntity::getUserId)
|
||||
.toList();
|
||||
List<UserEntity> users = userRepository.findByAccountIdIn(userIds);
|
||||
Map<String, UserEntity> 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("未知玩家");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
*
|
||||
* <p>采用策略路由:根据房间当前状态(WAITING/PLAYING)将消息分发给不同的处理器。
|
||||
* WAITING 状态下只有房间管理消息(ready, leave, start)有效;
|
||||
* PLAYING 状态下游戏消息(place_stone, resign, draw_*)由 GameEngine 处理。</p>
|
||||
*/
|
||||
@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,
|
||||
|
||||
@@ -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";
|
||||
/** 错误消息 */
|
||||
|
||||
Reference in New Issue
Block a user