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