Эскизный дизайн
Подобно соревновательной игре-викторине: пользователь случайным образом выбирает соперника, и обе стороны начинают отвечать на вопросы одновременно, пока обе стороны не завершат ответ и игра не закончится. Основная логика такая, если есть другие потребности, то можно расширить на ее основе
С этим ясно, идеи развития описаны ниже. Для каждого пользователя формулируются четыре онлайн-состояния, а именно: ожидание, совпадение, игра и игра окончена. Ниже представлена блок-схема Процесс пользователя ограничен правилами, и состояние изменяется вместе с процессом.
Добавьте в процесс следующее:
- Пользователь входит в соответствующий зал (как конкретный эффект отражается клиентом), и статус пользователя устанавливается на соответствие
- Пользователь начинает сопоставление, и статус пользователя устанавливается на соответствие.Система ищет других пользователей, которые также соответствуют.Во время этого процесса пользователь может отменить сопоставление и вернуться в зал сопоставления.В это время статус пользователя сбросить до ожидания сопоставления. Матч прошел успешно, сохраните информацию о матче и установите статус пользователя в игре.
- По сохраненной информации о матче пользователь может получить информацию о сопернике. При положительном ответе каждый раз, когда обновляется оценка пользователя, обновленная оценка также будет передаваться противнику.
- После того, как пользователь завершит ответ, подождите, пока оппонент тоже завершит ответ. Обе стороны завершили ответ, статус пользователя установлен на конец игры, и отображается результат игры
детальный дизайн
Для идей, предложенных в эскизном проекте, нам необходимо подумать над следующими вопросами:
- Как сохранить связь между клиентом и сервером?
- Как спроектировать взаимодействие сообщений между клиентом и сервером?
- Как сохранить и изменить состояние пользователя?
Давайте решим это один за другим
1. Как сохранить подключение пользователей к серверу?
В прошлом мы использовали Http для запроса сервера и получения ответной информации. Однако у Http есть изъян, то есть связь может инициировать только клиент, а сервер не может активно передавать информацию клиенту. Согласно эскизному проекту, мы знаем, что серверу необходимо передать счет оппонента в реальном времени клиенту, поэтому здесь не подходит использование Http, но выбран WebSocket. Самая большая особенность WebSocket заключается в том, что сервер может активно передавать информацию клиенту, а клиент также может активно отправлять информацию серверу, что представляет собой настоящий двусторонний равноправный диалог.
Для получения информации об интеграции WebSocket в SpringBoot вы можете обратиться к этому блогу:blog.CSDN.net/QQ_35387940…
2. Как спроектировать взаимодействие сообщений между клиентом и сервером?
В соответствии с требованиями механизма сопоставления сообщения делятся на ADD_USER (присоединение пользователя), MATCH_USER (соответствие противнику), CANCEL_MATCH (отмена сопоставления), PLAY_GAME (начало игры), GAME_OVER (конец игры).
public enum MessageTypeEnum {
/**
* 用户加入
*/
ADD_USER,
/**
* 匹配对手
*/
MATCH_USER,
/**
* 取消匹配
*/
CANCEL_MATCH,
/**
* 游戏开始
*/
PLAY_GAME,
/**
* 游戏结束
*/
GAME_OVER,
}
Используя WebSocket, клиент может отправлять сообщения на сервер, а сервер также может отправлять сообщения клиенту. Сообщение делится на разные типы в соответствии с требованиями: клиент отправляет сообщение определенного типа, сервер оценивает его после получения, обрабатывает в соответствии с типом и, наконец, возвращает результат обработки клиенту. Отличие клиентского соединения WebSocket заключается в переданном от клиента userId, который хранится в HashMap
@Component
@Slf4j
@ServerEndpoint(value = "/game/match/{userId}")
public class ChatWebsocket {
private Session session;
private String userId;
static QuestionSev questionSev;
static MatchCacheUtil matchCacheUtil;
static Lock lock = new ReentrantLock();
static Condition matchCond = lock.newCondition();
@Autowired
public void setMatchCacheUtil(MatchCacheUtil matchCacheUtil) {
ChatWebsocket.matchCacheUtil = matchCacheUtil;
}
@Autowired
public void setQuestionSev(QuestionSev questionSev) {
ChatWebsocket.questionSev = questionSev;
}
@OnOpen
public void onOpen(@PathParam("userId") String userId, Session session) {
log.info("ChatWebsocket open 有新连接加入 userId: {}", userId);
this.userId = userId;
this.session = session;
matchCacheUtil.addClient(userId, this);
log.info("ChatWebsocket open 连接建立完成 userId: {}", userId);
}
@OnError
public void onError(Session session, Throwable error) {
log.error("ChatWebsocket onError 发生了错误 userId: {}, errorMessage: {}", userId, error.getMessage());
matchCacheUtil.removeClinet(userId);
matchCacheUtil.removeUserOnlineStatus(userId);
matchCacheUtil.removeUserFromRoom(userId);
matchCacheUtil.removeUserMatchInfo(userId);
log.info("ChatWebsocket onError 连接断开完成 userId: {}", userId);
}
@OnClose
public void onClose()
{
log.info("ChatWebsocket onClose 连接断开 userId: {}", userId);
matchCacheUtil.removeClinet(userId);
matchCacheUtil.removeUserOnlineStatus(userId);
matchCacheUtil.removeUserFromRoom(userId);
matchCacheUtil.removeUserMatchInfo(userId);
log.info("ChatWebsocket onClose 连接断开完成 userId: {}", userId);
}
@OnMessage
public void onMessage(String message, Session session) {
log.info("ChatWebsocket onMessage userId: {}, 来自客户端的消息 message: {}", userId, message);
JSONObject jsonObject = JSON.parseObject(message);
MessageTypeEnum type = jsonObject.getObject("type", MessageTypeEnum.class);
log.info("ChatWebsocket onMessage userId: {}, 来自客户端的消息类型 type: {}", userId, type);
if (type == MessageTypeEnum.ADD_USER) {
addUser(jsonObject);
} else if (type == MessageTypeEnum.MATCH_USER) {
matchUser(jsonObject);
} else if (type == MessageTypeEnum.CANCEL_MATCH) {
cancelMatch(jsonObject);
} else if (type == MessageTypeEnum.PLAY_GAME) {
toPlay(jsonObject);
} else if (type == MessageTypeEnum.GAME_OVER) {
gameover(jsonObject);
} else {
throw new GameServerException(GameServerError.WEBSOCKET_ADD_USER_FAILED);
}
log.info("ChatWebsocket onMessage userId: {} 消息接收结束", userId);
}
/**
* 群发消息
*/
private void sendMessageAll(MessageReply<?> messageReply) {
log.info("ChatWebsocket sendMessageAll 消息群发开始 userId: {}, messageReply: {}", userId, JSON.toJSONString(messageReply));
Set<String> receivers = messageReply.getChatMessage().getReceivers();
for (String receiver : receivers) {
ChatWebsocket client = matchCacheUtil.getClient(receiver);
client.session.getAsyncRemote().sendText(JSON.toJSONString(messageReply));
}
log.info("ChatWebsocket sendMessageAll 消息群发结束 userId: {}", userId);
}
// 出于减少篇幅的目的,业务处理方法暂不贴出...
}
3. Как сохранить и изменить статус пользователя?
Создайте класс перечисления, который определяет состояние пользователя
/**
* 用户状态
* @author yeeq
*/
public enum StatusEnum {
/**
* 待匹配
*/
IDLE,
/**
* 匹配中
*/
IN_MATCH,
/**
* 游戏中
*/
IN_GAME,
/**
* 游戏结束
*/
GAME_OVER,
;
public static StatusEnum getStatusEnum(String status) {
switch (status) {
case "IDLE":
return IDLE;
case "IN_MATCH":
return IN_MATCH;
case "IN_GAME":
return IN_GAME;
case "GAME_OVER":
return GAME_OVER;
default:
throw new GameServerException(GameServerError.MESSAGE_TYPE_ERROR);
}
}
public String getValue() {
return this.name();
}
}
Выберите Redis, чтобы сохранить состояние пользователя или создать класс перечисления. Данные, хранящиеся в Redis, идентифицируются уникальным ключом. Поэтому ключ в Redis определяется здесь, и они представлены следующим образом:
- USER_STATUS: ключ для хранения статуса пользователя, тип хранилища — Map
, где userId — ключ, а онлайн-статус пользователя — значение. - USER_MATCH_INFO: когда пользователь находится в игре, нам нужно записать информацию о пользователе, такую как очки и т. д. Эту информацию не нужно записывать в базу данных, и она будет обновляться в любое время, поместите ее в кеш для быстрого доступа
- ROOM: это можно понимать как создание комнаты для двух совпадающих пользователей. Конкретная реализация хранится в парах ключ-значение. Например, если пользователь A и пользователь B совпадают, userId пользователя A — это A, а userId пользователя B — B, затем в Redis записывается как {A -- B}, {B -- A}
public enum EnumRedisKey {
/**
* userOnline 在线状态
*/
USER_STATUS,
/**
* userOnline 对局信息
*/
USER_IN_PLAY,
/**
* userOnline 匹配信息
*/
USER_MATCH_INFO,
/**
* 房间
*/
ROOM;
public String getKey() {
return this.name();
}
}
Создайте служебный класс для управления данными в Redis.
@Component
public class MatchCacheUtil {
/**
* 用户 userId 为 key,ChatWebsocket 为 value
*/
private static final Map<String, ChatWebsocket> CLIENTS = new HashMap<>();
/**
* key 是标识存储用户在线状态的 EnumRedisKey,value 为 map 类型,其中用户 userId 为 key,用户在线状态 为 value
*/
@Resource
private RedisTemplate<String, Map<String, String>> redisTemplate;
/**
* 添加客户端
*/
public void addClient(String userId, ChatWebsocket websocket) {
CLIENTS.put(userId, websocket);
}
/**
* 移除客户端
*/
public void removeClinet(String userId) {
CLIENTS.remove(userId);
}
/**
* 获取客户端
*/
public ChatWebsocket getClient(String userId) {
return CLIENTS.get(userId);
}
/**
* 移除用户在线状态
*/
public void removeUserOnlineStatus(String userId) {
redisTemplate.opsForHash().delete(EnumRedisKey.USER_STATUS.getKey(), userId);
}
/**
* 获取用户在线状态
*/
public StatusEnum getUserOnlineStatus(String userId) {
Object status = redisTemplate.opsForHash().get(EnumRedisKey.USER_STATUS.getKey(), userId);
if (status == null) {
return null;
}
return StatusEnum.getStatusEnum(status.toString());
}
/**
* 设置用户为 IDLE 状态
*/
public void setUserIDLE(String userId) {
removeUserOnlineStatus(userId);
redisTemplate.opsForHash().put(EnumRedisKey.USER_STATUS.getKey(), userId, StatusEnum.IDLE.getValue());
}
/**
* 设置用户为 IN_MATCH 状态
*/
public void setUserInMatch(String userId) {
removeUserOnlineStatus(userId);
redisTemplate.opsForHash().put(EnumRedisKey.USER_STATUS.getKey(), userId, StatusEnum.IN_MATCH.getValue());
}
/**
* 随机获取处于匹配状态的用户(除了指定用户外)
*/
public String getUserInMatchRandom(String userId) {
Optional<Map.Entry<Object, Object>> any = redisTemplate.opsForHash().entries(EnumRedisKey.USER_STATUS.getKey())
.entrySet().stream().filter(entry -> entry.getValue().equals(StatusEnum.IN_MATCH.getValue()) && !entry.getKey().equals(userId))
.findAny();
return any.map(entry -> entry.getKey().toString()).orElse(null);
}
/**
* 设置用户为 IN_GAME 状态
*/
public void setUserInGame(String userId) {
removeUserOnlineStatus(userId);
redisTemplate.opsForHash().put(EnumRedisKey.USER_STATUS.getKey(), userId, StatusEnum.IN_GAME.getValue());
}
/**
* 设置处于游戏中的用户在同一房间
*/
public void setUserInRoom(String userId1, String userId2) {
redisTemplate.opsForHash().put(EnumRedisKey.ROOM.getKey(), userId1, userId2);
redisTemplate.opsForHash().put(EnumRedisKey.ROOM.getKey(), userId2, userId1);
}
/**
* 从房间中移除用户
*/
public void removeUserFromRoom(String userId) {
redisTemplate.opsForHash().delete(EnumRedisKey.ROOM.getKey(), userId);
}
/**
* 从房间中获取用户
*/
public String getUserFromRoom(String userId) {
return redisTemplate.opsForHash().get(EnumRedisKey.ROOM.getKey(), userId).toString();
}
/**
* 设置处于游戏中的用户的对战信息
*/
public void setUserMatchInfo(String userId, String userMatchInfo) {
redisTemplate.opsForHash().put(EnumRedisKey.USER_MATCH_INFO.getKey(), userId, userMatchInfo);
}
/**
* 移除处于游戏中的用户的对战信息
*/
public void removeUserMatchInfo(String userId) {
redisTemplate.opsForHash().delete(EnumRedisKey.USER_MATCH_INFO.getKey(), userId);
}
/**
* 设置处于游戏中的用户的对战信息
*/
public String getUserMatchInfo(String userId) {
return redisTemplate.opsForHash().get(EnumRedisKey.USER_MATCH_INFO.getKey(), userId).toString();
}
/**
* 设置用户为游戏结束状态
*/
public synchronized void setUserGameover(String userId) {
removeUserOnlineStatus(userId);
redisTemplate.opsForHash().put(EnumRedisKey.USER_STATUS.getKey(), userId, StatusEnum.GAME_OVER.getValue());
}
}
4. Как подобрать пользователей?
Идея сопоставления пользователей была упомянута ранее, чтобы не блокировать соединение WebSocket между клиентом и сервером, создайте поток специально для сопоставления пользователей, и, если совпадение будет успешным, отправьте сообщение клиенту.
При совпадении пользователя с оппонентом соблюдается принцип: когда пользователь А находит пользователя Б, за всю работу отвечает пользователь А, то есть пользователь А выполняет все операции по созданию данных соответствия и сохранению их в кэш. Стоит отметить, что при сопоставлении следует следить за тем, чтобы состояние менялось:
- Текущий пользователь сопоставляется другими пользователями при сопоставлении с противником, тогда текущий пользователь должен остановить операцию сопоставления.
- Текущий пользователь сопоставляется с оппонентом, но оппонент сопоставляется другими пользователями, тогда текущий пользователь должен снова найти нового оппонента
Процесс сопоставления пользователей с оппонентами должен быть атомарным, с использованием блокировок Java для обеспечения
/**
* 用户随机匹配对手
*/
@SneakyThrows
private void matchUser(JSONObject jsonObject) {
log.info("ChatWebsocket matchUser 用户随机匹配对手开始 message: {}, userId: {}", jsonObject.toJSONString(), userId);
MessageReply<GameMatchInfo> messageReply = new MessageReply<>();
ChatMessage<GameMatchInfo> result = new ChatMessage<>();
result.setSender(userId);
result.setType(MessageTypeEnum.MATCH_USER);
lock.lock();
try {
// 设置用户状态为匹配中
matchCacheUtil.setUserInMatch(userId);
matchCond.signal();
} finally {
lock.unlock();
}
// 创建一个异步线程任务,负责匹配其他同样处于匹配状态的其他用户
Thread matchThread = new Thread(() -> {
boolean flag = true;
String receiver = null;
while (flag) {
// 获取除自己以外的其他待匹配用户
lock.lock();
try {
// 当前用户不处于待匹配状态
if (matchCacheUtil.getUserOnlineStatus(userId).compareTo(StatusEnum.IN_GAME) == 0
|| matchCacheUtil.getUserOnlineStatus(userId).compareTo(StatusEnum.GAME_OVER) == 0) {
log.info("ChatWebsocket matchUser 当前用户 {} 已退出匹配", userId);
return;
}
// 当前用户取消匹配状态
if (matchCacheUtil.getUserOnlineStatus(userId).compareTo(StatusEnum.IDLE) == 0) {
// 当前用户取消匹配
messageReply.setCode(MessageCode.CANCEL_MATCH_ERROR.getCode());
messageReply.setDesc(MessageCode.CANCEL_MATCH_ERROR.getDesc());
Set<String> set = new HashSet<>();
set.add(userId);
result.setReceivers(set);
result.setType(MessageTypeEnum.CANCEL_MATCH);
messageReply.setChatMessage(result);
log.info("ChatWebsocket matchUser 当前用户 {} 已退出匹配", userId);
sendMessageAll(messageReply);
return;
}
receiver = matchCacheUtil.getUserInMatchRandom(userId);
if (receiver != null) {
// 对手不处于待匹配状态
if (matchCacheUtil.getUserOnlineStatus(receiver).compareTo(StatusEnum.IN_MATCH) != 0) {
log.info("ChatWebsocket matchUser 当前用户 {}, 匹配对手 {} 已退出匹配状态", userId, receiver);
} else {
matchCacheUtil.setUserInGame(userId);
matchCacheUtil.setUserInGame(receiver);
matchCacheUtil.setUserInRoom(userId, receiver);
flag = false;
}
} else {
// 如果当前没有待匹配用户,进入等待队列
try {
log.info("ChatWebsocket matchUser 当前用户 {} 无对手可匹配", userId);
matchCond.await();
} catch (InterruptedException e) {
log.error("ChatWebsocket matchUser 匹配线程 {} 发生异常: {}",
Thread.currentThread().getName(), e.getMessage());
}
}
} finally {
lock.unlock();
}
}
UserMatchInfo senderInfo = new UserMatchInfo();
UserMatchInfo receiverInfo = new UserMatchInfo();
senderInfo.setUserId(userId);
senderInfo.setScore(0);
receiverInfo.setUserId(receiver);
receiverInfo.setScore(0);
matchCacheUtil.setUserMatchInfo(userId, JSON.toJSONString(senderInfo));
matchCacheUtil.setUserMatchInfo(receiver, JSON.toJSONString(receiverInfo));
GameMatchInfo gameMatchInfo = new GameMatchInfo();
List<Question> questions = questionSev.getAllQuestion();
gameMatchInfo.setQuestions(questions);
gameMatchInfo.setSelfInfo(senderInfo);
gameMatchInfo.setOpponentInfo(receiverInfo);
messageReply.setCode(MessageCode.SUCCESS.getCode());
messageReply.setDesc(MessageCode.SUCCESS.getDesc());
result.setData(gameMatchInfo);
Set<String> set = new HashSet<>();
set.add(userId);
result.setReceivers(set);
result.setType(MessageTypeEnum.MATCH_USER);
messageReply.setChatMessage(result);
sendMessageAll(messageReply);
gameMatchInfo.setSelfInfo(receiverInfo);
gameMatchInfo.setOpponentInfo(senderInfo);
result.setData(gameMatchInfo);
set.clear();
set.add(receiver);
result.setReceivers(set);
messageReply.setChatMessage(result);
sendMessageAll(messageReply);
log.info("ChatWebsocket matchUser 用户随机匹配对手结束 messageReply: {}", JSON.toJSONString(messageReply));
}, CommonField.MATCH_TASK_NAME_PREFIX + userId);
matchThread.start();
}
Дисплей проекта
Код проекта выглядит следующим образом:GitHub.com/Yee-Q/match…
После запуска используйте websocket-client для тестирования. Откройте его в браузере и просмотрите сообщение в консоли.
Введите случайное число в качестве идентификатора пользователя в поле ввода подключения, нажмите «Подключиться», и клиент установит соединение WebSocket с сервером.
Нажав кнопку «Присоединиться к пользователю», пользователь «входит в соответствующее лобби».
Нажмите кнопку случайного совпадения, чтобы начать матч, затем отмените матч.
Выполните предыдущие шаги, чтобы установить другое пользовательское соединение, нажмите кнопку случайного совпадения, сопоставление будет успешным, и сервер вернет ответную информацию.
Когда счет пользователя будет обновлен, введите новый счет в поле ввода, например 6, нажмите кнопку обновления в реальном времени, и противник получит последние новости о счете.
Когда обе стороны нажимают кнопку окончания игры, игра заканчивается.