Об игре

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

вдохновила эта гифка

Собственно, после просмотра гифки, я и захотел сделать что-то подобное. Игра будет мультиплеерной, потому повествование я логически разделю на две части - сервер и клиент.

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

Я выбрал 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 проверить что сервер отвечает.

На этот раз все. Спасибо за внимание. Код первой части доступен здесь. В следующей части мы напишем клиент для общения с нашим сервером.