Всем привет. Это вторая часть разработки мультплеерной игры. Первая часть доступна здесь
Если вас не интересует статья, а нужен только код, то он доступен тут
В прошлый раз мы остановились на том что закончили серверную часть. Давайте перейдем к клиенту
Клиент
За клиент у нас будет отвечать связка 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 игроков
Добавить кнопку рестарта
Вести счет побед и поражений
Добавить бота, чтобы он играл если никто не подключается к серверу
Все это я предлагаю реализовать читателю самостоятельно, если он заинтересовался.
Спасибо за внимание. Итоговый код доступен здесь
