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

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

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

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

Мы пользовались шаблоном проектирования Model-View-Controller (MVC), как он работает, можно почитать по этой ссылке. В нашем случае это было реализовано так:
Есть графический интерфейс, который реагирует на нажатия пользователем на конкретные места на игровом поле, после чего передает эти действия в контроллер (вызывает функции контроллера).
Действия контроллера зависят от того, играют ли пользователи по сети или локально. Если локально, то контроллер просто передает из графического интерфейса действие в удобном для модели игры формате. Если же игра осуществляется по сети, то контроллер посылает запрос через клиента. Он в свою очередь через сервер говорит игре, которая хранится только в одном экземпляре, информацию о действии, которое игрок хотел совершить.
Модель игры принимает действие от контроллера. Почти всегда контроллер передает ей полиморфный класс 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<DrawCardFromActive *>(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<Path> TTRController::get_paths() {
if (typeOfGame != type_of_game::LOCAL_CLIENT) {
return game->board.paths;
} else {
throw_exception_if_server_disconnected();
return client->get_paths();
}
}
Чтобы обрабатывать случаи, когда сервер неожиданно отключается по какой-либо причине, мы добавили исключения. Тогда пользователь не просто получит упавшее приложение, но и сообщение об ошибке и возможность вернуться в главное меню.

В то же время мы, к сожалению, не успели добавить обработку случая, когда неожиданно отключается клиент. Наш сервер не может спрашивать у клиентов никакой информации — он просто отвечает на запросы и не знает, кто к нему подключен. В целом проблема отключения игрока встает во многих играх, и с этим можно бороться по-разному:
Заканчивать игру при отключении игрока;
Подсвечивать игрока отключившимся и пропускать его ход;
Сделать ограничение на ход по времени: если игрок не делает ход, то он его пропускает.
Мы не могли реализовать первый и второй варианты в нашей архитектуре, поэтому думали насчет третьего. В итоге решили, что он будет очень неудобен для остальных игроков. Каждый ход может занимать достаточно много времени: надо продумать, какой перегон строить, как помешать соперникам и т.д., поэтому таймер пришлось бы ставить на слишком большое время, что будет сильно тормозить игру.
В текущем состоянии в наш Ticket to ride можно играть по локальной сети или с одного компьютера нескольким игрокам. К сожалению, по глобальной сети поиграть не получится. Подключиться через VPN возможно, но из-за того, что запросы достаточно объемные, а VPN чаще всего работает через протокол UDP, игра сильно тормозит, и каждый ход делается по 5-10 секунд.
Идеальным решением было бы поменять архитектуру. Например, сделать один глобальный сервер, который хранит игру, но сам не является игроком, а также имеет возможность делать запросы к подключившимся для проверки, что они в игре. Это позволило бы решить сразу несколько проблем:
Возможность делать хорошую проверку на отключение игрока.
Возможность сделать игру по глобальной сети. Для этого установим скрипт, создающий контроллеры на каком-то выделенном глобальном сервере и выдающий им их ip-адреса.
Возможность делать обновление игры более удобным: главный контроллер просто посылал бы игрокам информацию об изменении в игровом состоянии, что позволило бы рисовать только эти изменения, а не перерисовывать всю доску каждый раз. Это, вероятно, решило бы и проблему с vpn: информации передавалось бы в разы меньше, и все стало бы работать гораздо быстрее.
Но, к сожалению, продумать это вначале у нас не получилось, а изменять архитектуру после было слишком сложно, и времени нам просто бы не хватило.
Графический интерфейс
Мы выбрали библиотеку QT как наиболее распространенную и имеющую обширную документацию. Кнопки сделали при помощи класса QGraphicsRectItem:

Большинство объектов на поле — это вагончики типа 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), попробовать себя в интересной для области и, конечно же, дополнить свое резюме реальным проектом.
Исходный код можно посмотреть здесь.

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