Об игре
Всем привет, разрабатывать игру будем довольно простую - это аналог крестиков-ноликов, только немного измененный. На этой гифке можно увидеть саму игру:

Собственно, после просмотра гифки, я и захотел сделать что-то подобное. Игра будет мультиплеерной, потому повествование я логически разделю на две части - сервер и клиент.
Если вас не интересует статья, а нужен только код, то он доступен тут
Я выбрал dart на сервере и flutter+flame на клиенте. Почему dart на сервере? Мне очень нравится технология flutter, а так как он написан на dart, то решил на сервере использовать тот же язык чтобы иметь возможность шаринга частей кода.
Я хочу чтобы играть можно было везде - desktop, mobile, web. К счастью, все это обеспечивает flutter. Единственное ограничение - у меня в списке поддерживаемых платформ есть web. Из-за этого сервер и клиент будут общаться между собой через websocket.
С инструментарием определились, давайте перейдем к самому проекту.
Подготовка
Создадим flutter проект:
flutter create tttgame
сразу же пропишем зависимости в pubspec.yaml:
dependencies: flutter: sdk: flutter logging: ^1.0.2 flame: ^1.8.1 uuid: ^3.0.7 base2e15: ^1.0.0 web_socket_channel: ^2.4.0
По порядку:
logging- для логированияflame- игровой движок для flutter. Почитать о нем можно тутuuid- генерация уникальных идентификаторов для клиентаbase2e15- с его помощью мы будем уменьшать размер сообщений между сервером и клиентом.web_socket_channel- используется на клиенте. В dart инфраструктуре есть несколько подходов к работе с websocket.dart:io- для работы в desktop иdart:html- для web . Чтобы не тащить в код проверки, на какой платформе сейчас запущен клиент и дублировать код (websocket вdart:ioотличается от websocket вdart:html), мы возьмем универсальный пакет который работает на любой из платформ.
Добавили зависимости, написали в консоли dart pub get чтобы подтянуть в проект все пакеты и мы готовы к реализации сервера протокола общения сервера и клиента.
Сервер-Клиент коммуникация
Давайте подумаем как наш сервер будет общаться с клиентом? Как уже сказано выше, протоколом будет websocket, сообщения будут шифроваться с помощью base2e15
Я вижу работу нашей игры так: клиент подключается к серверу, сервер выдает клиенту уникальный иденти��икатор и добавляет в список клиентов которые сейчас подключены. На все клиенты сервер периодически отсылает текущий статус игры. Клиент может отправить на сервер сообщение о своем ходе. И сервер отправить клиенту результат - этот ход возможен или нет. Итого, у нас три типа сообщений которые будут пересылаться от сервера к клиенту и обратно:
welcome - когда клиент подключается к серверу и сервер генерирует уникальный идентификатор для клиента, сервер должен отослать сообщение с этим идентификатором клиенту.
gameStatus - сервер периодически на все клиенты шлет текущий статус игры.
move - сообщение которое клиент шлет на сервер с информацией о своем ходе и сервер шлет обратно информацию о допустимости этого хода.
Что ж, давайте в папке lib создадим папку entity, внутри этой папки файл message.dart и напишем:
enum MessageType { welcome, gameStatus, move, }
В той же папке создадим еще несколько файлов со вспомогательными enum:
player_role.dart
enum PlayerRole { topPlayer, bottomPlayer, observer, }
Игрок может быть сверху стола, снизу стола и наблюдателем
winner.dart
enum Winner { none, top, bottom, }
Победителя может не быть, так же это может быть игрок сверху или игрок снизу
figure_type.dart
enum FigureType { small, medium, large, none, }
У нас три типа фигур: маленькая, большая или средняя. Так же, фигуры может не быть в ячейке.
Теперь надо остановиться и немного подумать. Как понять что сейчас от сервера пришло не welcome сообщение, а gameStatus? Очевидно, что первым полем следует передать тип сообщения которое я хочу передать.
Потому в файл message.dart добавим следующее:
message.dart
class Message { final MessageType type; final WelcomeMessage? welcomeMessage; final Move? move; final GameStatus? gameStatus; Message(this.type, {this.welcomeMessage, this.move, this.gameStatus}); factory Message.fromJson(Map<String, dynamic> json) { return Message( MessageType.values.firstWhere((e) => e.toString() == json['type']), gameStatus: json.containsKey('gameStatus') ? GameStatus.fromJson(json['gameStatus']) : null, welcomeMessage: json.containsKey('welcomeMessage') ? WelcomeMessage.fromJson(json['welcomeMessage']) : null, move: json.containsKey('move') ? Move.fromJson(json['move']) : null, ); } Map<String, dynamic> toJson() { final Map<String, dynamic> data = {'type': type.toString()}; if (gameStatus != null) { data['gameStatus'] = gameStatus!.toJson(); } if (welcomeMessage != null) { data['welcomeMessage'] = welcomeMessage!.toJson(); } if (move != null) { data['move'] = move!.toJson(); } return data; } String toJsonString() { final jsonString = jsonEncode(toJson()); final base2e15String = Base2e15.encode(jsonString.codeUnits); return base2e15String; } factory Message.fromJsonString(String jsonString) { final base2e15Bytes = Base2e15.decode(jsonString); final base2e15String = String.fromCharCodes(base2e15Bytes); final jsonData = jsonDecode(base2e15String); return Message.fromJson(jsonData); } }
Вообще, этот класс можно было бы сделать абстрактным с методом handle и реализацией этого метода в каждом отдельном классе. Но и в этом случае нам пришлось бы передавать тип сообщения которое мы переслали. Если есть гуру dart которые объяснят почему мой подход не правильный, с удовольствием выслушаю.
Разберемся с данным классом подробнее:
final MessageType type;- тип сообщения которое только что пришло.final WelcomeMessage? welcomeMessage;
final Move? move;
final GameStatus? gameStatus;Это опциональные поля класса. Они могут быть null. именно это мы указываем в конструкторе:
Message(this.type, {this.welcomeMessage, this.move, this.gameStatus});- мы говорим что эти поля могут быть указаны при инициализации, а могут быть пустыми.factory Message.fromJson- тут все просто. Если в сообщении был передан любой изMessageType, то мы его парсим из json структуры. Точно так же работаетtoJson()- если у нас поля не пустые, то мы превращаем данные класса в json.Функции
String toJsonString()иfactory Message.fromJsonString(String jsonString)используются для кодирования\декодирования итогового сообщения вbase2e15или строку.
Сами классы WelcomeMessage, Move, GameStatus :
welcome_message.dart
import 'player_role.dart'; class WelcomeMessage { WelcomeMessage(this.clientId, this.canPlay, this.playerRole); final String clientId; final bool canPlay; final PlayerRole playerRole; factory WelcomeMessage.fromJson(Map<String, dynamic> json) { return WelcomeMessage( json['clientId'], json['canPlay'], PlayerRole.values.byName(json['playerRole']), ); } Map<String, dynamic> toJson() => { 'clientId': clientId, 'canPlay': canPlay, 'playerRole': playerRole.name, }; }
move.dart
import 'figure_type.dart'; class Move { Move(this.clientId, this.sourceCellId, this.targetCellId, this.figureType, {this.canPut}); String clientId; int sourceCellId; int targetCellId; FigureType figureType; bool? canPut; factory Move.fromJson(Map<String, dynamic> json) => Move( json['clientId'], json['sourceCellId'], json['targetCellId'], FigureType.values.byName(json['figureType']), canPut: json.containsKey('canPut') ? json['canPut'] : null, ); Map<String, dynamic> toJson() { final Map<String, dynamic> data = { 'clientId': clientId, 'sourceCellId': sourceCellId, 'targetCellId': targetCellId, 'figureType': figureType.name, }; if (canPut != null) { data['canPut'] = canPut; } return data; } }
game_status.dart
import 'board.dart'; class GameStatus { GameStatus(this.board, {this.winnerId}); Board board; String? winnerId; factory GameStatus.fromJson(Map<String, dynamic> json) { return GameStatus( Board.fromJson(json['board']), winnerId: json.containsKey('winnerId') ? json['winnerId'] : null, ); } Map<String, dynamic> toJson() { final Map<String, dynamic> data = { 'board': board.toJson(), }; if (winnerId != null) { data['winnerId'] = winnerId; } return data; } }
Как вы могли заметить, в классе GameStatus мы используем вспомогательный класс Board. Мы к нему скоро перейдем, но сначала давайте разберем что у нас уже написано. Все классы довольно похожи. В функциях fromJson и toJson происходит парсинг полей класса из\в json формат для передачи данных по сети.
Пройдемся по полям классов:
class WelcomeMessage:final String clientId- сообщаем клиенту его уникальный идентификаторfinal bool canPlay- только сервер знает сколько сейчас клиентов подключено. И если меньше двух, то игрок очевидно не может играть.final PlayerRole playerRole;- за столом только два места - верхний игрок и нижний. Так же, есть роль наблюдателя, который не может двигать фигуры, а просто смотреть за игрой.
class Move:String clientId- так клиент сообщает серверу что сходил именно онint sourceCellId- из какой ячейки была взята фигураint targetCellId- в какую ячейку эту фигуру хочет игрок поставитьFigureType figureType- тип фигуры (маленькая, средняя, большая)bool? canPut- опциональное поле которое клиент не передает. Его передает сервер в значении true если этот ход возможен и false если невозможен.
class GameStatus:String? winnerId- опциональное поле и равноnullпока в игре нет победителя. Если победитель определился, то тут будетclientIdпобедившего игрокаBoard board- тут живет состояние поля которое хранится на сервере.
Так же у нас есть класс фигур - он очень прост и похож на предыдущие:
figure.dart
import 'figure_type.dart'; class Figure { const Figure(this.figureType, this.color, this.cellId); final FigureType figureType; final int color; final int cellId; factory Figure.fromJson(Map<String, dynamic> json) { return Figure( FigureType.values.byName(json['figureType']), json['color'], json['cellId'], ); } Map<String, dynamic> toJson() => { 'figureType': figureType.name, 'color': color, 'cellId': cellId, }; }
В следующем классе нам понадобятся константы, потому в корневой папке lib создадим папку common и внутри ее файл constants.dart :
constants.dart
import '../entity/figure.dart'; import '../entity/figure_type.dart'; abstract class Constants { static const int player1Color = 1; static const int player2Color = 2; static const int fakeCellId = 404; static const int noColor = 0; static const Figure noneFigure = Figure(FigureType.none, noColor, fakeCellId); static const List<List<int>> winnigCombinations = <List<int>>[ <int>[3, 4, 5], <int>[6, 7, 8], <int>[9, 10, 11], <int>[3, 7, 11], <int>[5, 7, 9], <int>[3, 6, 9], <int>[4, 7, 10], <int>[5, 8, 11], ]; static const int winningCount = 3; static const int port = 8080; static const int maxAttempts = 5; static const String host = 'localhost'; static const Duration reconnectDelay = Duration(seconds: 5); static const broadcastInterval = Duration(milliseconds: 100); }
Так как у нас сервер который работает в консоли и на стороне сервера цвета из materials.dart не доступны, то мы обозначаем их цифрами. Так же, нам нужны noneFigure и winningCombinations для определения победителя.
Вот мы и подошли к нашему основному классу, в котором будет вся логика игры со стороны сервера. Давайте в папке entity создадим файл board.dart и взглянем подробнее:
board.dart
import '../common/constants.dart'; import 'figure.dart'; import 'figure_type.dart'; import 'winner.dart'; class Board { List<Figure> figures; Board(this.figures); factory Board.fromJson(Map<String, dynamic> json) { final List<dynamic> figuresJson = json['figures']; final figures = figuresJson.map((figureJson) => Figure.fromJson(figureJson)).toList(); return Board(figures); } Map<String, dynamic> toJson() => { 'figures': figures.map((figure) => figure.toJson()).toList(), }; void startnewGame() { figures.clear(); figures.add(const Figure(FigureType.small, Constants.player1Color, 0)); figures.add(const Figure(FigureType.small, Constants.player1Color, 0)); figures.add(const Figure(FigureType.medium, Constants.player1Color, 1)); figures.add(const Figure(FigureType.medium, Constants.player1Color, 1)); figures.add(const Figure(FigureType.large, Constants.player1Color, 2)); figures.add(const Figure(FigureType.large, Constants.player1Color, 2)); figures.add(const Figure(FigureType.small, Constants.player2Color, 12)); figures.add(const Figure(FigureType.small, Constants.player2Color, 12)); figures.add(const Figure(FigureType.medium, Constants.player2Color, 13)); figures.add(const Figure(FigureType.medium, Constants.player2Color, 13)); figures.add(const Figure(FigureType.large, Constants.player2Color, 14)); figures.add(const Figure(FigureType.large, Constants.player2Color, 14)); } void removeFigureFromCell(int cellId) { final int index = figures .indexWhere((Figure figureServer) => figureServer.cellId == cellId); if (index != -1) { figures.removeAt(index); } } bool canPutFigure(int cellId, FigureType otherFigureType) { final Figure figureServer = figures.lastWhere( (Figure figureServer) => figureServer.cellId == cellId, orElse: () => Constants.noneFigure); if (figureServer.figureType == FigureType.none) { return true; } else if (figureServer.figureType == FigureType.large) { return false; } else { switch (otherFigureType) { case FigureType.small: return false; case FigureType.medium: if (figureServer.figureType == FigureType.small) { return true; } else { return false; } case FigureType.large: if (figureServer.figureType == FigureType.small || figureServer.figureType == FigureType.medium) { return true; } else { return false; } case FigureType.none: return true; } } } void putFigure(Figure figure) => figures.add(Figure(figure.figureType, figure.color, figure.cellId)); Winner checkWinner() { if (playerWin(Constants.player1Color)) { return Winner.top; } else if (playerWin(Constants.player2Color)) { return Winner.bottom; } return Winner.none; } bool playerWin(int playerColor) { final List<int> playerCells = <int>[]; for (final Figure pFigure in figures) { final Figure lastFigure = figures .where((Figure element) => element.cellId == pFigure.cellId) .last; if (lastFigure.color == playerColor) { playerCells.add(pFigure.cellId); } } for (final List<int> wins in Constants.winnigCombinations) { int matchCount = 0; for (final int w in wins) { if (playerCells.contains(w)) { matchCount++; } } if (matchCount == Constants.winningCount) { return true; } } return false; } }
Единственное поле которое есть у класса это List<Figure> figures - список фигур на игровом поле.
Чуть подробнее про игровое поле. Как видно на гифке в начале, все поле - это 15 ячеек. В нашей игре это будет представляться так:

В ячейках 0,1,2,12,13,14 будут изначально находиться фигуры игроков (по две штуки в каждом поле), остальные ячейки пустые. Именно основываясь на таком представлении мы можем написать функции removeFigureFromCellи startnewGame,putFigure. Функции довольно простые и я не буду останавливаться на объяснении их логики.
На вход функции canPutFigure передается ячейка в которую игрок хочет поставить фигуру и тип фигуры которую он хочет поставить. Мы смотрим есть ли вообще фигуры в указанной ячейке, а если есть, то какого они размера. Если фигура которую мы хотим поставить в эту ячейку имеет больший размер, то сервер считает это действие допустимым.
Функции checkWinner и playerWin проверяют есть ли у нас победитель после очередного хода. Мы берем winnigCombinations из констант, где перечислены все комбинации ячеек для победы и сравниваем текущее положение фигур игроков. Если комбинации совпали, то игрок победил.
Сервер
Все готово для написания сервера. Он довольно простой. Мы будем использовать библиотеку dart:io потому что сервер будет запускаться только в консоли.
Идем в папку lib, создаем папку server и внутри нее файл server.dart .
Полный код сервера:
server.dart
import 'dart:async'; import 'dart:io'; import 'package:logging/logging.dart'; import 'package:uuid/uuid.dart'; import '../common/constants.dart'; import '../entity/message.dart'; import '../entity/player_role.dart'; import '../entity/welcome_message.dart'; import '../server/game_state.dart'; class ServerManager { final Logger _log = Logger('Server'); final Map<String, WebSocket> connectedClients = {}; bool gameStated = false; GameState gameState = GameState(); void init() async { final server = await HttpServer.bind(InternetAddress.loopbackIPv4, Constants.port); _log.info( 'WebSocket started ws://${server.address.address}:${server.port}/'); await for (HttpRequest request in server) { if (WebSocketTransformer.isUpgradeRequest(request)) { WebSocketTransformer.upgrade(request).then((webSocket) { handleNewConnection(webSocket); }); broadcast(); } else { request.response.write('Pizza delivering'); await request.response.close(); } } } void handleNewConnection(WebSocket webSocket) { final clientId = const Uuid().v4(); _log.info('A new client connected: $clientId.'); connectedClients[clientId] = webSocket; bool canPlay = false; PlayerRole playerRole = PlayerRole.observer; if (connectedClients.length <= 2) { canPlay = true; if (connectedClients.length == 1) { gameState.topPlayerId = clientId; playerRole = PlayerRole.topPlayer; } else if (connectedClients.length == 2) { gameState.bottomPlayerId = clientId; playerRole = PlayerRole.bottomPlayer; } } final welcomeMessage = Message(MessageType.welcome, welcomeMessage: WelcomeMessage(clientId, canPlay, playerRole)); final messageString = welcomeMessage.toJsonString(); webSocket.add(messageString); if (connectedClients.length > 1) { if (!gameStated) { _log.info( 'A new game starting for clients: ${connectedClients.keys.toString()}'); gameState.gameStatus.board.startnewGame(); gameStated = true; } } handleMessages(webSocket, clientId); } void handleMessages(WebSocket webSocket, String clientId) { webSocket.listen((data) { final message = Message.fromJsonString(data); if (message.type == MessageType.move) { final move = message.move; gameState.moveFigure(move!); } }, onError: (error) { _log.shout('Error connection for client $clientId: $error'); connectedClients.remove(clientId); }, onDone: () { _log.warning('Connection for client $clientId closed'); connectedClients.remove(clientId); }); } void broadcast() { Timer.periodic(Constants.broadcastInterval, (timer) { final message = Message(MessageType.gameStatus, gameStatus: gameState.gameStatus); final messageString = message.toJsonString(); connectedClients.forEach((clientId, webSocket) { webSocket.add(messageString); }); }); } } void main() async { Logger.root.onRecord.listen((record) { // ignore: avoid_print print('${record.level.name}: ${record.time}: ' '${record.loggerName}: ' '${record.message}'); }); ServerManager server = ServerManager(); server.init(); }
Давайте разберем по порядку:
final Logger _log = Logger('Server');- инициируем логгер чтобы писать сообщения в консоль.final Map<String, WebSocket> connectedClients = {};- тут будет храниться список подключенных клиентов.bool gameStated = false;- так как для игры нам нужно 2 человека, а изначально у нас 0, то по умолчанию игра не началась.GameState gameState = GameState();- здесь будем хранить текущий статус игры во внутреннем представлении сервера.В функции
init()мы инициируем websocket сервер. Если запрос пришел по протоколу http, просто возвращаем текстовый ответ. Если же запрос был к websocket, начинаем работать с новым клиентом в функцииhandleNewConnectionhandleNewConnection- генерируем уникальный идентификатор для каждого нового игрока и в зависимости от того сколько клиентов подключено, выдаем ему роль либо за игровым столом, либо роль наблюдателя. Собираем сообщение типаWelcomeMessageи шлем его клиенту. В конце вызываем функциюhandleMessagesчтобы слушать канал общения с клиентом.handleMessages- от клиента мы ожидаем только сообщения типа move. Все остальные игнорируем. Вызываем методgameState.moveFigure(move!)- мы разберем этот класс чуть позже.broadcast- мы используем эту функцию для периодической отправки текущего состояния игры на клиенты. Каждые 100 миллисекунд все клиенты будут получать сообщения типаMessageType.gameStatus
Что ж, для завершения сервера, осталось написать последний класс. Давайте в папке server создадим файл game_state.dart и напишем следующее:
game_state.dart
import 'package:logging/logging.dart'; import '../entity/board.dart'; import '../entity/figure.dart'; import '../entity/figure_type.dart'; import '../entity/game_status.dart'; import '../entity/move.dart'; import '../entity/winner.dart'; import '../common/constants.dart'; class GameState { final Logger _log = Logger('Server'); late String? topPlayerId; late String? bottomPlayerId; Winner winner = Winner.none; bool bottomPlayerTurn = false; GameStatus gameStatus = GameStatus(Board(<Figure>[])); Move moveFigure(Move move) { final FigureType figureType = FigureType.values.firstWhere((FigureType e) => e == move.figureType); if (move.clientId == topPlayerId && !bottomPlayerTurn || move.clientId == bottomPlayerId && bottomPlayerTurn) { bool canPut = gameStatus.board.canPutFigure(move.targetCellId, figureType); if (canPut) { int color = Constants.player1Color; bottomPlayerTurn = true; if (move.clientId == bottomPlayerId) { color = Constants.player2Color; bottomPlayerTurn = false; } gameStatus.board .putFigure(Figure(figureType, color, move.targetCellId)); gameStatus.board.removeFigureFromCell(move.sourceCellId); winner = gameStatus.board.checkWinner(); if (winner == Winner.top) { _log.info('Player $topPlayerId win'); gameStatus.winnerId = topPlayerId; } else if (winner == Winner.bottom) { _log.info('Player $bottomPlayerId win'); gameStatus.winnerId = bottomPlayerId; } move.canPut = true; } else { move.canPut = false; } } return move; } }
Этот класс реализует внутреннее представление игры на сервере. Тут у нас есть и оба игрока (topPlayerId, bottomPlayerId) и победитель (winner) и состояние текущего хода (bottomPlayerTurn) и текущий статус игры (gameStatus). В классе есть единственная функция - она делает или не делает ход. Логика работы такова: сначала мы проверяем, чей ход и может ли игрок сделать ход. Далее смотрим, возможно ли положить фигуру на ту клетку которую хочет игрок. Если да, то ложем фигуру, удаляем фигуру из ячейки откуда эту фигуру взяли и проверяем выиграл ли какой-либо игрок после очередного хода. В итоге функция формирует класс Move с ответом для клиента - возможен ли ход который хочет сделать игрок.
Теперь можно запустить сервер. Перейдите в корневую папку проекта и напишите в консоли:
dart .\lib\server\server.dart
Мы увидим что сервер запущен:
INFO: 2023-08-08 23:34:56.358943: Server: WebSocket started ws://127.0.0.1:8080/
Вы можете с помощью curl или telnet проверить что сервер отвечает.
На этот раз все. Спасибо за внимание. Код первой части доступен здесь. В следующей части мы напишем клиент для общения с нашим сервером.
