Всем привет. Это вторая часть разработки мультплеерной игры. Первая часть доступна здесь Если вас не интересует статья, а нужен только код, то он доступен тут В прошлый раз мы остановились на том что закончили серверную часть. Давайте перейдем к клиенту За клиент у нас будет отвечать связка flutter+flame. Flutter будет отвечать за мультиплатформу, а flame - игровой движок. Честно говоря, игра очень простая и можно было бы обойтись только flutter, но так как это pet project и вообще мы учимся, почему бы не попробовать заодно и flame? Начнем с самого простого - с игрового поля. У нас есть поле - в нем три квадрата по горизонтали и пять по вертикали: В папке lib  создадим папку client, в ней папку ui и в ней файл grid.dart  Взглянем на код: первое что бросается в глаза - import '../my_game.dart' и каких-то новых констант import '../client_constants.dart' . Вы можете спросить зачем нам новый файл с константами, если у нас уже есть один? Ответ довольно прост - в этих константах у нас будут цвета, разделители фигур на поле и все прочее что относится только к клиенту. Кроме того, цвета мы берем из библиотеки flutter/material.dart, а она просто не доступна в dart на котором написан сервер. О my_game.dart мы поговорим позже.  Взглянем на код дальше. Тут есть строчка Она говорит о следующем: Grid - это объект который имеет свою позицию в игре (PositionComponent из пакета flame), а HasGameRef<MyGame> говорит о том, что мы хотим иметь доступ к инстансу самой игры внутри нашего объекта. Доступ к инстансу игры нам нужен чтобы реагировать на изменение размера окна игры. Взглянем на метод onLoad - здесь в строках мы высчитываем размер ячеек которые будем отображать в зависимости от того какой размер окна при открытии игры. Во flame есть несколько методов которые можно переопределить и onLoad один из них. Этот метод вызывается первым при запуске игры.  Следующий метод onGameResize похож на onLoad и он так же доступен нам из flame. С помощью него мы реагируем на изменение размеров окна уже в процессе игры. В методе render мы рисуем нашу сетку. По сути это просто 8 линий - 4 по вертикали и 4 по горизонтали. В итоге у нас получится вот такая картинка: С сеткой разобрались. В папке client создадим файл client_constants.dart и напишем следующее:  Тут все довольно очевидно. Раз уж мы начали с визуальной составляющей игры, давайте ее и продолжим. У клиента будет своя реализация board. Да, большую часть мы сможем позаимствовать из серверной части, но на сервере нет ни рендера, ни рисования фигур. Потому давайте в папке ui создадим файл client_board.dart и напишем там следующее:  Как видно по импортам: мы используем тот же код который писали для сервера. Это очень удобно писать и сервер и клиент на одном и том же языке программирования. Пройдемся по функциям класса: void render(Canvas canvas) - отображаем все фигуры которые есть на доске, потом вызываем рендер класса Grid void drawFigure(double devider, Figure figure, Canvas canvas) - метод для отрисовки кружков на нашем поле. Так же поверх каждого круга мы пишем какое количество кругов осталось в распоряжении. Делаем мы это только для стартовых позиций фигур, чтобы не загромождать остальное поле цифрами. Offset cellCenterById(int cellId) - используется в предыдущем методе для отрисовки фигур int cellIdByCoordinates(Vector2 coordinates) - этот метод мы будем использовать далее для определения номера ячейки в которую игрок хочет положить фигуру Figure getFigureByCellId(int cellId) - используется для определения какую именно фигуру взял в руку игрок. Давайте закончим с визуалом и с папке client создадим файл my_game.dart  Тут довольно много кода. Давайте разбираться. Класс определен так: Мы расширяем базовый класс FlameGame пакета flame и говорим что мы должны реагировать на нажатия с помощью TapDetector Перейдем к полям класса: ClientBoard clientBoard = ClientBoard() - для отображения поля и фигур в игре PlayerRole playerRole = PlayerRole.observer - по умолчанию новый клиент получает роль наблюдателя. Figure figureInHand = ClientConstants.noneFigure - в начале игры в руке нет никаких фигур int sourceCellId = Constants.fakeCellId - опять же, в начале игры никакую фигуру мы в руку не взяли, потому sourceCellId имеет значение на fakeCellId bool canPut = true - индикатор может ли игрок взять фигуру. Таким образом мы запрещаем игроку играть фигурами противника и переставлять те фигуры которые он уже поставил на поле  bool canPlay = false- очередность хода. Игра у нас пошаговая и каждый игрок делает ход по очереди String? winner - clientId победителя. Изначально равен null late SocketManager socketManager - когда мы будем обрабатывать сообщения от сервера, мы должны будем менять существующие поля класса игры, потому в класс SocketManager надо будет передавать инстанс MyGame. Именно поэтому мы не можем инициировать класс сейчас, мы сделаем это в методе onLoad, который будет вызван первым при запуске игры late String clientId - клиент получает свой уникальный идентификатор при первом обращении к серверу. На данный момент взять его неоткуда. Именно поэтому мы используем late Что ж, с полями все понятно, посмотрим на методы. В методе FutureOr<void> onLoad() async мы инициируем подключение к серверу. Класс SocketManager  рассмотрим далее. Обратите внимание, что мы в игре не используем спрайты. Если вы захотите в игру добавить спрайты, то в методе onLoad вам обязательно надо будет добавить их в игру. Подробнее в документации.  void onTapDown(TapDownInfo info) обрабатываем клики игрока. Если у игрока не роль наблюдателя и он может ходить, то он может взять какую-нибудь фигуру. Может он это сделать или нет, определяем в дополнительной функции bool clickedCellAction(int clickedCell), а если у игрока в руке уже есть какая-нибудь фигура, то проверяем, может ли игрок положить ее в ячейку в методе void render(Canvas canvas) просто вызываем уже готовые методы отрисовки сетки и фигур void onGameResize(Vector2 size) - меняем размеры поля и фигур void tryPutFigure(int clickedCellId) - для проверки может ли игрок положить фигуру в ячейку, вызываем тот же метод что мы написали для сервера (board.canPutFigure(clickedCellId, figureInHand.figureType)), если да, то отправляем серверу сообщение класса Move , а потом считаем что в руке у нас уже ничего нет в последнем методе класса void printText(Canvas canvas, String text, Offset offset) мы печатаем текст на экране. На данный момент этот метод используется только для донесения игроков информации о результатах игры С визуальной составляющей мы почти закончили. Осталась самая малость - функция main.  Давайте откроем файл lib/main.dart, удалим все что там есть и напишем вот это:  Все что мы сделали - это инициировали логгер и запустили приложение с помощь flutter (runApp) и flame (GameWidget). Теперь если вы закомментируете все строки которые относятся к SocketManager, вы можете запустить игру. Давайте запустим и посмотрим что получилось: или  или вы можете указать любую другую платформу по вашему желанию. Запустив игру, вы увидите что отображается только сетка: Это потому что вся логика игры у нас на сервере. И пока от сервера не пришло разрешение, игра не начнется. Давайте напишем коммуникацию с сервером. В папке client создадим файл socket_manager.dart  и напишем следующее:  Как я говорил в первой части этого гайда, на клиенте мы используем пакет web_socket_channel для работы с websocket. Делаем мы это потому что это универсальный способ работать с websocket на каждой платформе. Если бы мы взяли пакеты которые доступны из коробки - dart:html и dart:io, то нам пришлось бы определять на какой платформе запущен клиент и писать разный код для коммуникации с сервером потому что websocket в dart:html отличается от websocket в dart:io. В методе connectWithReconnect реализован простейший реконнект к серверу если вдруг связь оборвалась. Делаем 5 попыток с 5 секундами ожидания между попытками. В методе handleMessage мы обрабатываем все возможные типы сообщений от сервера и меняем соответствующие поля класса MyGame ну и метод send отправляет сообщение серверу. Теперь мы готовы запустить игру. Из корневой папки запускаем сервер: Из той же папки запускам клиента: И видим что ничего не поменялось. Все та же сетка даже без фигур. Это потому что сервер ожидает подключение еще одного игрока чтобы начать игру. Давайте запустим второй клиент: И вот теперь игра началась. Вы можете запустить еще несколько клиентов и проверить как работает режим наблюдателя. Да, в игре предстоит сделать еще достаточно много вещей: Добавить обработку ничьи Отображать очередность хода Добавить таймер, чтобы ход игрока не длился бесконечно Сделать отображение ников или хотя бы id игроков Добавить кнопку рестарта Вести счет побед и поражений Добавить бота, чтобы он играл если никто не подключается к серверу Все это я предлагаю реализовать читателю самостоятельно, если он заинтересовался.  Спасибо за внимание. Итоговый код доступен здесь