Разработка мультиплеерной игры на Dart+Flutter. Часть 2

от автора

Всем привет. Это вторая часть разработки мультплеерной игры. Первая часть доступна здесь

Если вас не интересует статья, а нужен только код, то он доступен тут

В прошлый раз мы остановились на том что закончили серверную часть. Давайте перейдем к клиенту

Клиент

За клиент у нас будет отвечать связка 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) — отображаем все фигуры которые есть на доске, потом вызываем рендер класса 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

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 имеет значение на 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, удалим все что там есть и напишем вот это:

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 игроков

  • Добавить кнопку рестарта

  • Вести счет побед и поражений

  • Добавить бота, чтобы он играл если никто не подключается к серверу

Все это я предлагаю реализовать читателю самостоятельно, если он заинтересовался.

Спасибо за внимание. Итоговый код доступен здесь


ссылка на оригинал статьи https://habr.com/ru/articles/753636/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *