fix: 修改房间逻辑

This commit is contained in:
2026-06-23 13:49:15 +08:00
parent 47e586e7f5
commit 9be461539c
4 changed files with 187 additions and 64 deletions

View File

@@ -62,6 +62,14 @@ public interface GomokuGameService {
*/
void handleDisconnect(String roomId, String userId, RoomSessionManager sessionManager);
/**
* 取消断连超时定时器(重连时调用)
*
* @param roomId 房间 ID
* @param userId 用户 ID
*/
void cancelDisconnectTimeout(String roomId, String userId);
/**
* 从走棋记录重建棋盘状态(静态工具方法)
*

View File

@@ -338,6 +338,14 @@ public class GameServiceImpl implements GameService {
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) {
@@ -360,43 +368,20 @@ public class GameServiceImpl implements GameService {
throw new BusinessException(ErrorCode.PLAYER_COUNT_INSUFFICIENT);
}
// 校验所有玩家已准备(房主除外,房主默认准备)
// 校验所有玩家已准备
for (RoomPlayerEntity player : players) {
if (!player.getReadyStatus() && !player.getUserId().equals(room.getCreatorId())) {
if (!player.getReadyStatus()) {
throw new BusinessException(ErrorCode.NOT_ALL_READY);
}
}
// 扣除房主底注
UserEntity ownerUser = userRepository.findByAccountId(ownerId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
if (ownerUser.getBalance() < room.getStakes()) {
throw new BusinessException(ErrorCode.BALANCE_INSUFFICIENT);
}
ownerUser.setBalance(ownerUser.getBalance() - room.getStakes());
userRepository.save(ownerUser);
// 扣除其他玩家底注
for (RoomPlayerEntity player : players) {
if (!player.getUserId().equals(ownerId)) {
UserEntity user = userRepository.findByAccountId(player.getUserId())
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
if (user.getBalance() < room.getStakes()) {
throw new BusinessException(ErrorCode.BALANCE_INSUFFICIENT,
"玩家 " + user.getNickName() + " 余额不足");
}
user.setBalance(user.getBalance() - room.getStakes());
userRepository.save(user);
}
}
// 更新房间状态为进行中
room.setStatus("playing");
gameRoomRepository.save(room);
log.info("[游戏服务] 游戏开始, roomId={}, 参与人数={}, 底注={}", roomId, players.size(), room.getStakes());
log.info("[游戏服务] 游戏开始, roomId={}, 参与人数={}", roomId, players.size());
// 通知房间状态更新
// 通知房间状态更新(大厅刷新)
List<RoomPlayerResponse> allPlayers = buildRoomPlayers(roomId);
GameConfigEntity gameConfig = gameConfigRepository.findByGameId(room.getGameId())
.orElse(null);
@@ -404,28 +389,6 @@ public class GameServiceImpl implements GameService {
String creatorName = getNickname(room.getCreatorId());
RoomItemResponse response = RoomItemResponse.from(room, allPlayers, icon, creatorName);
sseEventPublisher.broadcast(SseEventType.ROOM_UPDATED, response);
// 通知房间内每个玩家游戏已开始
for (RoomPlayerEntity player : players) {
sseEventPublisher.sendTo(player.getUserId(), SseEventType.GAME_STARTED,
Map.of("roomId", roomId));
}
// 通知余额变动(房主已在前面扣除了底注)
ownerUser = userRepository.findByAccountId(ownerId).orElse(null);
if (ownerUser != null) {
sseEventPublisher.sendTo(ownerId, SseEventType.BALANCE_CHG,
Map.of("balance", ownerUser.getBalance(), "delta", -room.getStakes()));
}
for (RoomPlayerEntity player : players) {
if (!player.getUserId().equals(ownerId)) {
UserEntity user = userRepository.findByAccountId(player.getUserId()).orElse(null);
if (user != null) {
sseEventPublisher.sendTo(player.getUserId(), SseEventType.BALANCE_CHG,
Map.of("balance", user.getBalance(), "delta", -room.getStakes()));
}
}
}
}
@Override

View File

@@ -1,6 +1,10 @@
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.*;
@@ -18,14 +22,22 @@ 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
@@ -37,6 +49,19 @@ public class GomokuGameServiceImpl implements GomokuGameService {
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
@@ -59,7 +84,6 @@ public class GomokuGameServiceImpl implements GomokuGameService {
List<GameMoveEntity> moves = gameMoveRepository.findByRoomIdOrderByMoveIndexAsc(roomId);
int moveCount = moves.size();
// 判定当前玩家应该下哪一方(黑先白后)
// 按座位号排序,确保黑方/白方分配与座位对应seat=1 为黑方先手)
List<RoomPlayerEntity> players = roomPlayerRepository.findByRoomId(roomId)
.stream()
@@ -139,6 +163,7 @@ public class GomokuGameServiceImpl implements GomokuGameService {
log.info("[五子棋] 准备状态变更, roomId={}, userId={}, ready={}", roomId, userId, newStatus);
// WS 广播准备状态变更(房间内实时同步)
broadcast(roomId, sessionManager, new WsMessage(WsOutboundMessage.READY_CHANGE, Map.of(
"userId", userId, "ready", newStatus
)));
@@ -186,24 +211,43 @@ public class GomokuGameServiceImpl implements GomokuGameService {
@Override
@Transactional
public void leaveRoom(String roomId, String userId, RoomSessionManager sessionManager) {
// 复用 GameService 离开逻辑,通过 GameController 的 leaveRoom 复用
// 这里简单处理:删除玩家记录
GameRoomEntity room = getRoom(roomId);
// 检查玩家是否在房间中
RoomPlayerEntity player = roomPlayerRepository.findByRoomIdAndUserId(roomId, userId)
.orElse(null);
if (player != null) {
roomPlayerRepository.delete(player);
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
)));
// 检查是否还有玩家
List<RoomPlayerEntity> remaining = roomPlayerRepository.findByRoomId(roomId);
if (remaining.isEmpty()) {
GameRoomEntity room = getRoom(roomId);
// 无人了,房间标记为结束
room.setStatus("finished");
gameRoomRepository.save(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);
}
}
@@ -228,6 +272,7 @@ public class GomokuGameServiceImpl implements GomokuGameService {
}
}
// 更新房间状态为进行中
room.setStatus("playing");
gameRoomRepository.save(room);
@@ -239,18 +284,44 @@ public class GomokuGameServiceImpl implements GomokuGameService {
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) {
// 断连不立即离开房间,等重连超时后再清理
// 首期简单处理:延迟 30 秒后如果玩家未重连则离开
log.info("[五子棋] 玩家断连, roomId={}, userId={}", roomId, userId);
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);
}
}
// ==================== 私有辅助方法 ====================
@@ -279,8 +350,49 @@ public class GomokuGameServiceImpl implements GomokuGameService {
}
}
/**
* 推送房间更新到大厅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);
} else {
log.info("[五子棋] 断线超时:等待中自动离开, roomId={}, userId={}", roomId, userId);
// 自动离开房间
leaveRoom(roomId, userId, sessionManager);
}
}
/**
* 对局结束
*
* TODO: 金币结算逻辑预留,后期处理
*/
@Transactional
public void endGame(String roomId, String winnerId, String resultType,
@@ -301,11 +413,14 @@ public class GomokuGameServiceImpl implements GomokuGameService {
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);
}
/**
@@ -346,4 +461,37 @@ public class GomokuGameServiceImpl implements GomokuGameService {
}
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("未知玩家");
}
}

View File

@@ -64,8 +64,12 @@ public class RoomWebSocketHandler extends TextWebSocketHandler {
}
String userId = loginId.toString();
// 注册会话
// 注册会话(内部会清理同用户的旧会话)
sessionManager.addSession(roomId, userId, session);
// 取消断线超时定时器(重连场景)
gomokuGameService.cancelDisconnectTimeout(roomId, userId);
log.info("[WS] 连接建立, roomId={}, userId={}, sessionId={}", roomId, userId, session.getId());
}