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

Собственно, после просмотра гифки, я и захотел сделать что-то подобное. Игра будет мультиплеерной, потому повествование я логически разделю на две части - сервер и клиент.
Если вас не интересует статья, а нужен только код, то он доступен тут
Я выбрал 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, начинаем работать с новым клиентом в функции- handleNewConnection
- handleNewConnection- генерируем уникальный идентификатор для каждого нового игрока и в зависимости от того сколько клиентов подключено, выдаем ему роль либо за игровым столом, либо роль наблюдателя. Собираем сообщение типа- 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 проверить что сервер отвечает. 
На этот раз все. Спасибо за внимание. Код первой части доступен здесь. В следующей части мы напишем клиент для общения с нашим сервером.
 
          