Разработка мультиплеерной игры на Dart+Flutter
Об игре
Всем привет, разрабатывать игру будем довольно простую - это аналог крестиков-ноликов, только немного измененный. На этой гифке можно увидеть саму игру:
Собственно, после просмотра гифки, я и захотел сделать что-то подобное. Игра будет мультиплеерной, потому повествование я логически разделю на две части - сервер и клиент.
Если вас не интересует статья, а нужен только код, то он доступен тут
Я выбрал 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
проверить что сервер отвечает.
На этот раз все. Спасибо за внимание. Код первой части доступен здесь. В следующей части мы напишем клиент для общения с нашим сервером.