Многопользовательская сетевая игра Ticket to Ride

Привет, Хабр! Мы — Тимофей Василевский, Сергей Дымашевский и Максим Чайка — только что окончили первый курс бакалавриата «Прикладная математика и информатика» в Питерской Вышке. В качестве семестрового проекта по C++ мы написали симулятор всем известной настольной игры Ticket to ride. Что у нас получилось, а что нет, читайте под катом.

Наша версия игры. Снизу карты игрока, колода и дополнительные маршруты, справа — карты на столе и информация об игроках.Наша версия игры. Снизу карты игрока, колода и дополнительные маршруты, справа — карты на столе и информация об игроках.

Правила игры

Суть игры заключается в том, чтобы проходить железнодорожные перегоны между городами и выполнять маршруты. Каждый перегон имеет свой цвет, и, чтобы его построить, необходимо иметь достаточно карточек вагончиков такого же цвета. Построенный перегон приносит вам в конце игры какое-то количество очков, а сам маршрут считается выполненным, если существует непрерывный путь из ваших вагончиков из одного города в другой.

Вот, что происходит у нас в игре при постройке перегона: красный игрок построил перегон между верхними станциями, а желтый — между нижней левой и верхней правой:

image-loader.svgБолее подробные правила игры

В Ticket to ride могут играть от двух до пяти игроков. Каждый ход игрок выполняет одно из четырех действий: он может построить перегон, взять карты вагончиков со стола или из колоды, построить станцию или взять новые маршруты. Существуют специальные вагончики — локомотивы, их можно использовать вместо вагончика любого цвета, а также они требуются для построения некоторых перегонов. Станции строятся в городах и используются так: вы можете считать, что один из смежных к станции путей ваших соперников построен вами, и учитывать его, когда прокладываете маршрут.

В конце игры подсчитываются очки: за каждый построенный маршрут вы получаете столько очков, сколько на нем написано, за каждый непостроенный — теряете столько же. Игрок, построивший самый длинный непрерывный маршрут, получает дополнительные очки.

Полные правила смотри тут.

Варианты двух маршрутов между Варшавой и ПарижемВарианты двух маршрутов между Варшавой и Парижем

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

Архитектура приложения

image-loader.svg

Мы пользовались шаблоном проектирования Model-View-Controller (MVC), как он работает, можно почитать по этой ссылке. В нашем случае это было реализовано так:

  1. Есть графический интерфейс, который реагирует на нажатия пользователем на конкретные места на игровом поле, после чего передает эти действия в контроллер (вызывает функции контроллера).

  2. Действия контроллера зависят от того, играют ли пользователи по сети или локально. Если локально, то контроллер просто передает из графического интерфейса действие в удобном для модели игры формате. Если же игра осуществляется по сети, то контроллер посылает запрос через клиента. Он в свою очередь через сервер говорит игре, которая хранится только в одном экземпляре, информацию о действии, которое игрок хотел совершить.

  3. Модель игры принимает действие от контроллера. Почти всегда контроллер передает ей полиморфный класс Turn:

struct Turn {
public:
	static inline int num = 0;
	static void increase_num();
	virtual ~Turn() = default;
};

Он имеет четырех наследников, каждый из которых соответствует своему типу хода:

struct DrawCardFromDeck final : virtual Turn {
public:
	explicit DrawCardFromDeck();
	~DrawCardFromDeck() override = default;
};

В модель игры передается один из наследников Turn:

void Game::make_move(Turn *t);
Дальше происходит обработка, используя удобную конструкцию языка C++:
if (auto *p = dynamic_cast(t); p) {
	get_wagon_card_from_active_cards(p->number);
}

Модель игры

Сама модель игры устроена так, что там есть множество вспомогательных классов: Board, Deck, Player, Algo и т.д., каждый из которых отвечают за свою смысловую часть, а также главный класс Game, который связывает их между собой.

Также модель содержит в себе бота, с которым при желании можно поиграть. Бот, как ни странно, делает ходы автоматически. Он высчитывает наилучшую стратегию, используя графовые алгоритмы, например, алгоритм Дейкстры. В будущем мы планируем обучать бот с помощью алгоритмов машинного обучения — так мы сможем значительно повысить уровень его игры.

Сервер

Мы реализовывали серверную часть, используя библиотеку gRPC, которая позволяет компоновать запросы с помощью «protocol buffers» и после этого передавать их буквально за пару команд. Конечно, на каком-то этапе у нас возникла проблема, потому что классы, которые нужны для передачи запросов, часто похожи на классы самой игры. Возможно, нужно было использовать именно эти классы, избежав парсинга одних классов в других. Однако мы все же решили оставить свои классы и переводить их в классы gRPC, потому что у наших собственных классов интерфейс гораздо более понятен и приспособлен к обработке логики, а перевести один класс в другой не так уж сложно.

ttr::Route parse_to_grpc_route(const Route &route) {
	ttr::Route n_route;
	n_route.set_begin(route.city1);
	n_route.set_end(route.city2);
	n_route.set_points(route.points_for_passing);
	return n_route;
}

Как работает наш сервер и клиент

Сервер внутри себя имеет указатель на контроллер, который является главным в текущей игровой сессии. Именно он обрабатывает запросы напрямую, не передавая их дальше. У сервера есть несколько методов, которые вызываются у клиента для получения какой-либо информации: make_turn, get_board_state, get_player_state, start_game и get_score — и еще несколько вспомогательных методов, которые в основном нужны для реализации этих. Также есть конструктор, который запускает сервер.

Сервер ответственен за то, чтобы каждый игрок понимал, кто он есть: при подключении игрок получает себе уникальный номер и цвет, после чего, посылая запросы, указывает в них этот номер, из чего сервер делает вывод, может ли сейчас ходить данный игрок.

Клиент, в свою очередь, тоже имеет несколько методов, которые можно вызывать из контроллера, чтобы удобно получать информацию и делать ходы. Внутри себя он конвертирует необходимую информацию в запрос, посылает его на сервер, после конвертирует ответ обратно и возвращает. Таким образом, внутри контроллера все выглядит очень просто и удобно, а вся неприятная часть с компоновкой запросов остается спрятана внутри кода клиента и сервера.

std::vector TTRController::get_paths() {
   if (typeOfGame != type_of_game::LOCAL_CLIENT) {
       return game->board.paths;
   } else {
       throw_exception_if_server_disconnected();
       return client->get_paths();
   }
}

Чтобы обрабатывать случаи, когда сервер неожиданно отключается по какой-либо причине, мы добавили исключения. Тогда пользователь не просто получит упавшее приложение, но и сообщение об ошибке и возможность вернуться в главное меню.

image-loader.svg

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

  1. Заканчивать игру при отключении игрока;

  2. Подсвечивать игрока отключившимся и пропускать его ход;

  3. Сделать ограничение на ход по времени: если игрок не делает ход, то он его пропускает.

Мы не могли реализовать первый и второй варианты в нашей архитектуре, поэтому думали насчет третьего. В итоге решили, что он будет очень неудобен для остальных игроков. Каждый ход может занимать достаточно много времени: надо продумать, какой перегон строить, как помешать соперникам и т.д., поэтому таймер пришлось бы ставить на слишком большое время, что будет сильно тормозить игру.

В текущем состоянии в наш Ticket to ride можно играть по локальной сети или с одного компьютера нескольким игрокам. К сожалению, по глобальной сети поиграть не получится. Подключиться через VPN возможно, но из-за того, что запросы достаточно объемные, а VPN чаще всего работает через протокол UDP, игра сильно тормозит, и каждый ход делается по 5–10 секунд.

Идеальным решением было бы поменять архитектуру. Например, сделать один глобальный сервер, который хранит игру, но сам не является игроком, а также имеет возможность делать запросы к подключившимся для проверки, что они в игре. Это позволило бы решить сразу несколько проблем:

  1. Возможность делать хорошую проверку на отключение игрока.

  2. Возможность сделать игру по глобальной сети. Для этого установим скрипт, создающий контроллеры на каком-то выделенном глобальном сервере и выдающий им их ip-адреса.

  3. Возможность делать обновление игры более удобным: главный контроллер просто посылал бы игрокам информацию об изменении в игровом состоянии, что позволило бы рисовать только эти изменения, а не перерисовывать всю доску каждый раз. Это, вероятно, решило бы и проблему с vpn: информации передавалось бы в разы меньше, и все стало бы работать гораздо быстрее.

Но, к сожалению, продумать это вначале у нас не получилось, а изменять архитектуру после было слишком сложно, и времени нам просто бы не хватило.

Графический интерфейс

Мы выбрали библиотеку QT как наиболее распространенную и имеющую обширную документацию. Кнопки сделали при помощи класса QGraphicsRectItem:

image-loader.svg

Большинство объектов на поле — это вагончики типа QGraphicsPolygonItem, которые заданы координатами в специальном файле, что дает возможность сделать другую карту без изменения кода.

Остальные элементы интерфейса — это кнопки.

Весь текст принадлежит классу QGraphicsTextItem.

Станции — это эллипс с совпадающими центрами и радиусами. К сожалению, они находятся на сцене не как объект, а как картинка. Чтобы построить станцию, на них нужно кликнуть дважды, так как проверяются все станции посредством определения координат даблклика.

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

На картинке ход красного игрока, и он может увидеть свои маршрутыНа картинке ход красного игрока, и он может увидеть свои маршруты

Справа от них на столе расположены карты, которые можно взять в руку. При нажатии на них будет видна небольшая анимация перемещения карты вниз экрана.

Во время работы офлайн-игры код выполняется более-менее линейно. Когда же мы начали реализовывать часть для взаимодействия по сети, оказалось, что нужно получать изменения с сервера. Это не очень удобно, поскольку сервер не умеет просто сигнализировать всем клиентам, что произошли изменения. Решением этой проблемы стало обновление всего игрового поля у всех клиентов:

void View::timed_redraw() {
   draw_board();
      redrawble = true;
   QTimer *timer = new QTimer();
   timer->setSingleShot(false);
   timer->setInterval(5000);
   connect(timer, &QTimer::timeout, [=]() {
             if(redrawble) {
                    draw_board();
                    // timed_redraw();
                    timer->start();
             }
   });
   timer->start();
}

Заключение

Проекты на первом курсе — одна из самых содержательных и полезных вещей. Мы смогли ощутить на себе все прелести командной разработки (и попрактиковаться в использовании git«a), попробовать себя в интересной для области и, конечно же, дополнить свое резюме реальным проектом.

Исходный код можно посмотреть здесь.

image-loader.svg

Другие материалы из нашего блога о проектах студентов младших курсов:

© Habrahabr.ru