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

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

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

Клиент

За клиент у нас будет отвечать связка 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 игроков

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

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

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

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

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

Комментарии (0)