Всем привет. Это вторая часть разработки мультплеерной игры. Первая часть доступна здесь
Если вас не интересует статья, а нужен только код, то он доступен тут
В прошлый раз мы остановились на том что закончили серверную часть. Давайте перейдем к клиенту
Клиент
За клиент у нас будет отвечать связка flutter+flame.
Flutter будет отвечать за мультиплатформу, а flame - игровой движок. Честно говоря, игра очень простая и можно было бы обойтись только flutter, но так как это pet project и вообще мы учимся, почему бы не попробовать заодно и flame?
Начнем с самого простого - с игрового поля. У нас есть поле - в нем три квадрата по горизонтали и пять по вертикали:

В папке lib создадим папку client, в ней папку ui и в ней файл grid.dart
grid.dart
import 'dart:async'; import 'dart:ui'; import 'package:flame/components.dart'; import '../client_constants.dart'; import '../my_game.dart'; class Grid extends PositionComponent with HasGameRef<MyGame> { late double cellSize; late double xStartOffset; @override FutureOr<void> onLoad() { cellSize = calculateCellSize(gameRef.canvasSize.x, gameRef.canvasSize.y); xStartOffset = gameRef.canvasSize.x / 2 - cellSize * 1.5; return super.onLoad(); } @override void onGameResize(Vector2 size) { cellSize = calculateCellSize(size.x, size.y); xStartOffset = size.x / 2 - cellSize * 1.5; super.onGameResize(size); } @override void render(Canvas canvas) { const Color color = Color(ClientConstants.gridColor); for (int i = 0; i <= 3; i++) { canvas.drawLine( Offset(xStartOffset + i * cellSize, 0), Offset(xStartOffset + i * cellSize, cellSize * 5), Paint()..color = color); } for (int i = 1; i <= 5; i++) { canvas.drawLine( Offset(xStartOffset, cellSize * i), Offset(xStartOffset + cellSize * 3, cellSize * i), Paint()..color = color); } super.render(canvas); } double calculateCellSize(double x, double y) { return y / 5 <= x / 3 ? y / 5 : x / 3; } }
Взглянем на код: первое что бросается в глаза - import '../my_game.dart' и каких-то новых констант import '../client_constants.dart' . Вы можете спросить зачем нам новый файл с константами, если у нас уже есть один? Ответ довольно прост - в этих константах у нас будут цвета, разделители фигур на поле и все прочее что относится только к клиенту. Кроме того, цвета мы берем из библиотеки flutter/material.dart, а она просто не доступна в dart на котором написан сервер. О my_game.dart мы поговорим позже.
Взглянем на код дальше. Тут есть строчка
class Grid extends PositionComponent with HasGameRef<MyGame>
Она говорит о следующем: Grid - это объект который имеет свою позицию в игре (PositionComponent из пакета flame), а HasGameRef<MyGame> говорит о том, что мы хотим иметь доступ к инстансу самой игры внутри нашего объекта. Доступ к инстансу игры нам нужен чтобы реагировать на изменение размера окна игры. Взглянем на метод onLoad - здесь в строках
cellSize = calculateCellSize(gameRef.canvasSize.x, gameRef.canvasSize.y); xStartOffset = gameRef.canvasSize.x / 2 - cellSize * 1.5;
мы высчитываем размер ячеек которые будем отображать в зависимости от того какой размер окна при открытии игры.
Во flame есть несколько методов которые можно переопределить и onLoad один из них. Этот метод вызывается первым при запуске игры.
Следующий метод onGameResize похож на onLoad и он так же доступен нам из flame. С помощью него мы реагируем на изменение размеров окна уже в процессе игры.
В методе render мы рисуем нашу сетку. По сути это просто 8 линий - 4 по вертикали и 4 по горизонтали. В итоге у нас получится вот такая картинка:

С сеткой разобрались.
В папке client создадим файл client_constants.dart и напишем следующее:
client_constants.dart
import 'package:flutter/material.dart'; import '../common/constants.dart'; import '../entity/figure.dart'; import '../entity/figure_type.dart'; abstract class ClientConstants { static const int gridColor = 0xfff7f1e3; static const Color backgoundColor = Color.fromARGB(255, 134, 198, 138); static const Map<int, MaterialColor> colorMap = {0: Colors.green, 1: Colors.red, 2: Colors.blue}; static const Figure noneFigure = Figure(FigureType.none, 0, Constants.fakeCellId); static const double smallFigureDevider = 5; static const double mediumFigureDevider = 3; static const double largeFigureDevider = 2.1; static const Color textColor = Colors.white; static const double fontSize = 24.0; }
Тут все довольно очевидно.
Раз уж мы начали с визуальной составляющей игры, давайте ее и продолжим. У клиента будет своя реализация board. Да, большую часть мы сможем позаимствовать из серверной части, но на сервере нет ни рендера, ни рисования фигур. Потому давайте в папке ui создадим файл client_board.dart и напишем там следующее:
client_board.dart
import 'package:flame/components.dart'; import 'package:flutter/material.dart'; import '../../entity/board.dart'; import '../../entity/figure.dart'; import '../../entity/figure_type.dart'; import '../client_constants.dart'; import 'grid.dart'; class ClientBoard extends PositionComponent { Grid grid = Grid(); Board board = Board(<Figure>[]); @override void render(Canvas canvas) { for (final Figure figure in board.figures) { switch (figure.figureType) { case FigureType.small: drawFigure(ClientConstants.smallFigureDevider, figure, canvas); break; case FigureType.medium: drawFigure(ClientConstants.mediumFigureDevider, figure, canvas); break; case FigureType.large: drawFigure(ClientConstants.largeFigureDevider, figure, canvas); break; case FigureType.none: break; } } grid.render(canvas); super.render(canvas); } void drawFigure(double devider, Figure figure, Canvas canvas) { final Offset cellCenter = cellCenterById(figure.cellId); canvas.drawCircle( cellCenter, grid.cellSize / devider, Paint()..color = ClientConstants.colorMap[figure.color]!); switch (figure.cellId) { case 0: case 1: case 2: case 12: case 13: case 14: final int count = board.figures .where((Figure element) => element.cellId == figure.cellId) .toList() .length; final TextSpan span = TextSpan( style: const TextStyle(color: ClientConstants.textColor, fontSize: ClientConstants.fontSize), text: 'x${count.toString()}'); final TextPainter tp = TextPainter( text: span, textAlign: TextAlign.center, textDirection: TextDirection.ltr); tp.layout(); tp.paint(canvas, Offset(cellCenter.dx - ClientConstants.fontSize / 2, cellCenter.dy - ClientConstants.fontSize / 2)); } } Offset cellCenterById(int cellId) { final int xFactor = cellId % 3; final int yFactor = cellId ~/ 3; return Offset( xFactor * grid.cellSize + (grid.cellSize / 2) + grid.xStartOffset, yFactor * grid.cellSize + (grid.cellSize / 2)); } int cellIdByCoordinates(Vector2 coordinates) { final int x = (coordinates.x - grid.xStartOffset) ~/ grid.cellSize; final int y = coordinates.y ~/ grid.cellSize; return y * 3 + x; } Figure getFigureByCellId(int cellId) => board.figures.firstWhere((Figure figure) => figure.cellId == cellId, orElse: () => ClientConstants.noneFigure); }
Как видно по импортам:
import '../../entity/board.dart'; import '../../entity/figure.dart'; import '../../entity/figure_type.dart';
мы используем тот же код который писали для сервера. Это очень удобно писать и сервер и клиент на одном и том же языке программирования. Пройдемся по функциям класса:
void render(Canvas canvas)- отображаем все фигуры которые есть на доске, потом вызываем рендер классаGridvoid drawFigure(double devider, Figure figure, Canvas canvas)- метод для отрисовки кружков на нашем поле. Так же поверх каждого круга мы пишем какое количество кругов осталось в распоряжении. Делаем мы это только для стартовых позиций фигур, чтобы не загромождать остальное поле цифрами.Offset cellCenterById(int cellId)- используется в предыдущем методе для отрисовки фигурint cellIdByCoordinates(Vector2 coordinates)- этот метод мы будем использовать далее для определения номера ячейки в которую игрок хочет положить фигуруFigure getFigureByCellId(int cellId)- используется для определения какую именно фигуру взял в руку игрок.Давайте закончим с визуалом и с папке
clientсоздадим файлmy_game.dart
my_game.dart
import 'dart:async'; import 'package:flame/game.dart'; import 'package:flame/input.dart'; import 'package:flutter/material.dart'; import '../common/constants.dart'; import '../entity/figure.dart'; import '../entity/figure_type.dart'; import '../entity/message.dart'; import '../entity/move.dart'; import '../entity/player_role.dart'; import 'client_constants.dart'; import 'socket_manager.dart'; import 'ui/client_board.dart'; class MyGame extends FlameGame with TapDetector { ClientBoard clientBoard = ClientBoard(); PlayerRole playerRole = PlayerRole.observer; Figure figureInHand = ClientConstants.noneFigure; int sourceCellId = Constants.fakeCellId; bool canPut = true; bool canPlay = false; String? winner; late SocketManager socketManager; late String clientId; @override Color backgroundColor() => ClientConstants.backgoundColor; @override FutureOr<void> onLoad() async { socketManager = SocketManager(this, Constants.port); await socketManager.connectWithReconnect( Uri.parse('ws://${Constants.host}:${Constants.port}'), Constants.maxAttempts, Constants.reconnectDelay); return super.onLoad(); } @override void onTapDown(TapDownInfo info) { super.onTapDown(info); if (playerRole != PlayerRole.observer && canPlay) { if (info.eventPosition.game.x >= clientBoard.grid.xStartOffset && info.eventPosition.game.x <= clientBoard.grid.xStartOffset + clientBoard.grid.cellSize * 3) { final int clickedCellId = clientBoard.cellIdByCoordinates(info.eventPosition.game); final bool wrongCellId = clickedCellAction(clickedCellId); if (wrongCellId) { return; } if (figureInHand.figureType != FigureType.none) { tryPutFigure(clickedCellId); } } } } @override void render(Canvas canvas) { super.render(canvas); clientBoard.render(canvas); if (winner != null) { if (winner == clientId) { printText(canvas, 'You WIN!', Offset(size.x / 2, size.y / 2)); } else { printText(canvas, 'You LOST!', Offset(size.x / 2, size.y / 2)); } } } @override void onGameResize(Vector2 size) { clientBoard.grid.onGameResize(size); clientBoard.onGameResize(size); super.onGameResize(size); } bool clickedCellAction(int clickedCell) { bool wrongCellId = false; switch (clickedCell) { case 0: case 1: case 2: if (playerRole != PlayerRole.topPlayer) { wrongCellId = true; } else { wrongCellId = false; figureInHand = clientBoard.getFigureByCellId(clickedCell); sourceCellId = clickedCell; } break; case 12: case 13: case 14: if (playerRole != PlayerRole.bottomPlayer) { wrongCellId = true; } else { wrongCellId = false; figureInHand = clientBoard.getFigureByCellId(clickedCell); sourceCellId = clickedCell; } break; default: break; } return wrongCellId; } void tryPutFigure(int clickedCellId) { bool clientCanPut = clientBoard.board.canPutFigure(clickedCellId, figureInHand.figureType); if (clientCanPut) { Move move = Move(clientId, sourceCellId, clickedCellId, figureInHand.figureType); Message message = Message(MessageType.move, move: move); socketManager.send(message); if (canPut) { figureInHand = ClientConstants.noneFigure; sourceCellId = Constants.fakeCellId; canPut = false; } } } void printText(Canvas canvas, String text, Offset offset) { final TextSpan span = TextSpan( style: const TextStyle( color: ClientConstants.textColor, fontSize: ClientConstants.fontSize), text: text); final TextPainter tp = TextPainter( text: span, textAlign: TextAlign.center, textDirection: TextDirection.ltr); tp.layout(); tp.paint(canvas, offset); } }
Тут довольно много кода. Давайте разбираться. Класс определен так:
class MyGame extends FlameGame with TapDetector
Мы расширяем базовый класс FlameGame пакета flame и говорим что мы должны реагировать на нажатия с помощью TapDetector
Перейдем к полям класса:
ClientBoard clientBoard = ClientBoard()- для отображения поля и фигур в игреPlayerRole playerRole = PlayerRole.observer- по умолчанию новый клиент получает роль наблюдателя.Figure figureInHand = ClientConstants.noneFigure- в начале игры в руке нет никаких фигурint sourceCellId = Constants.fakeCellId- опять же, в начале игры никакую фигуру мы в руку не взяли, потомуsourceCellIdимеет значение наfakeCellIdbool canPut = true- индикатор может ли игрок взять фигуру. Таким образом мы запрещаем игроку играть фигурами противника и переставлять те фигуры которые он уже поставил на полеbool canPlay = false- очередность хода. Игра у нас пошаговая и каждый игрок делает ход по очередиString? winner- clientId победителя. Изначально равен nulllate 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, удалим все что там есть и напишем вот это:
main.dart
import 'package:flame/game.dart'; import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; import 'client/my_game.dart'; void main() { Logger.root.onRecord.listen((record) { // ignore: avoid_print print('${record.level.name}: ${record.time}: ' '${record.loggerName}: ' '${record.message}'); }); runApp( GameWidget( game: MyGame(), ), ); }
Все что мы сделали - это инициировали логгер и запустили приложение с помощь flutter (runApp) и flame (GameWidget).
Теперь если вы закомментируете все строки которые относятся к SocketManager, вы можете запустить игру. Давайте запустим и посмотрим что получилось:
flutter run -d chrome
или
flutter run -d windows
или вы можете указать любую другую платформу по вашему желанию.
Запустив игру, вы увидите что отображается только сетка:

Это потому что вся логика игры у нас на сервере. И пока от сервера не пришло разрешение, игра не начнется. Давайте напишем коммуникацию с сервером.
В папке client создадим файл socket_manager.dart и напишем следующее:
socket_manager.dart
import 'dart:async'; import 'package:logging/logging.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import '../entity/message.dart'; import 'my_game.dart'; class SocketManager { SocketManager(this.myGame, this.port); static final Logger _log = Logger('Client'); final MyGame myGame; final int port; late WebSocketChannel? socket; bool connected = false; Future<void> connectWithReconnect( Uri wsUrl, int maxAttempts, Duration reconnectDelay) async { var attempts = 0; bool isConnected = false; StreamSubscription? subscription; while (!isConnected && attempts < maxAttempts) { try { socket = WebSocketChannel.connect(wsUrl); isConnected = true; _log.info('Connected to Server'); subscription?.cancel(); subscription = socket?.stream.listen((data) { handleMessage(data); }, onError: (error) { _log.shout('Connection closed with error: $error'); connectWithReconnect(wsUrl, maxAttempts, reconnectDelay); }, onDone: () { _log.warning('Connection closed.'); }); } on Exception { attempts++; _log.warning( "Cannot connect to the server. Attempt $attempts if $maxAttempts..."); await Future.delayed(reconnectDelay); } } if (!isConnected) { _log.shout( 'Failed to connect to the server. The maximum number of attempts has been exhausted.'); subscription?.cancel(); return; } } void handleMessage(data) { final message = Message.fromJsonString(data); if (message.type == MessageType.gameStatus) { myGame.clientBoard.board = message.gameStatus!.board; myGame.winner = message.gameStatus!.winnerId; if (myGame.winner != null) { myGame.canPlay = false; } } else if (message.type == MessageType.welcome) { myGame.clientId = message.welcomeMessage!.clientId; myGame.canPlay = message.welcomeMessage!.canPlay; myGame.playerRole = message.welcomeMessage!.playerRole; _log.info('My client id is: ${message.welcomeMessage!.clientId}'); } else if (message.type == MessageType.move) { myGame.canPut = message.move!.canPut!; } } void send(Message message) { socket?.sink.add(message.toJsonString()); } }
Как я говорил в первой части этого гайда, на клиенте мы используем пакет web_socket_channel для работы с websocket. Делаем мы это потому что это универсальный способ работать с websocket на каждой платформе. Если бы мы взяли пакеты которые доступны из коробки - dart:html и dart:io, то нам пришлось бы определять на какой платформе запущен клиент и писать разный код для коммуникации с сервером потому что websocket в dart:html отличается от websocket в dart:io.
В методе connectWithReconnect реализован простейший реконнект к серверу если вдруг связь оборвалась. Делаем 5 попыток с 5 секундами ожидания между попытками. В методе handleMessage мы обрабатываем все возможные типы сообщений от сервера и меняем соответствующие поля класса MyGame ну и метод send отправляет сообщение серверу.
Теперь мы готовы запустить игру. Из корневой папки запускаем сервер:
dart .\lib\server\server.dart
Из той же папки запускам клиента:
flutter run -d chrome
И видим что ничего не поменялось. Все та же сетка даже без фигур. Это потому что сервер ожидает подключение еще одного игрока чтобы начать игру. Давайте запустим второй клиент:
flutter run -d chrome
И вот теперь игра началась.
Вы можете запустить еще несколько клиентов и проверить к��к работает режим наблюдателя.
Да, в игре предстоит сделать еще достаточно много вещей:
Добавить обработку ничьи
Отображать очередность хода
Добавить таймер, чтобы ход игрока не длился бесконечно
Сделать отображение ников или хотя бы id игроков
Добавить кнопку рестарта
Вести счет побед и поражений
Добавить бота, чтобы он играл если никто не подключается к серверу
Все это я предлагаю реализовать читателю самостоятельно, если он заинтересовался.
Спасибо за внимание. Итоговый код доступен здесь
