Привет, Хабр! Мы — Тимофей Василевский, Сергей Дымашевский и Максим Чайка — только что окончили первый курс бакалавриата «Прикладная математика и информатика» в Питерской Вышке. В качестве семестрового проекта по C++ мы написали симулятор всем известной настольной игры Ticket to ride. Что у нас получилось, а что нет, читайте под катом.
![Наша версия игры. Снизу карты игрока, колода и дополнительные маршруты, справа — карты на столе и информация об игроках. Наша версия игры. Снизу карты игрока, колода и дополнительные маршруты, справа — карты на столе и информация об игроках.](https://habrastorage.org/getpro/habr/upload_files/3e1/3ef/dc8/3e13efdc8832ff524474a27f98dad063.png)
Правила игры
Суть игры заключается в том, чтобы проходить железнодорожные перегоны между городами и выполнять маршруты. Каждый перегон имеет свой цвет, и, чтобы его построить, необходимо иметь достаточно карточек вагончиков такого же цвета. Построенный перегон приносит вам в конце игры какое-то количество очков, а сам маршрут считается выполненным, если существует непрерывный путь из ваших вагончиков из одного города в другой.
Вот, что происходит у нас в игре при постройке перегона: красный игрок построил перегон между верхними станциями, а желтый — между нижней левой и верхней правой:
![](https://habrastorage.org/getpro/habr/upload_files/d89/ef3/cfb/d89ef3cfb843eb82ed29865dfa281c52.png)
Более подробные правила игры
В Ticket to ride могут играть от двух до пяти игроков. Каждый ход игрок выполняет одно из четырех действий: он может построить перегон, взять карты вагончиков со стола или из колоды, построить станцию или взять новые маршруты. Существуют специальные вагончики — локомотивы, их можно использовать вместо вагончика любого цвета, а также они требуются для построения некоторых перегонов. Станции строятся в городах и используются так: вы можете считать, что один из смежных к станции путей ваших соперников построен вами, и учитывать его, когда прокладываете маршрут.
В конце игры подсчитываются очки: за каждый построенный маршрут вы получаете столько очков, сколько на нем написано, за каждый непостроенный — теряете столько же. Игрок, построивший самый длинный непрерывный маршрут, получает дополнительные очки.
Полные правила смотри тут.
![Варианты двух маршрутов между Варшавой и Парижем Варианты двух маршрутов между Варшавой и Парижем](https://habrastorage.org/getpro/habr/upload_files/013/2b0/7dc/0132b07dc5df076d72b007434ac402d8.png)
Идея написать именно такой проект казалась нам хорошей, потому что он вмещает в себя сложную логику и архитектуру, взаимодействие с интерфейсом, серверное взаимодействие. Собственно, так мы и поделили обязанности внутри проекта.
Архитектура приложения
![](https://habrastorage.org/getpro/habr/upload_files/4b0/dd6/ed7/4b0dd6ed7965ad3c4fc372b606f2b57c.png)
Мы пользовались шаблоном проектирования 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();
}
}
Чтобы обрабатывать случаи, когда сервер неожиданно отключается по какой-либо причине, мы добавили исключения. Тогда пользователь не просто получит упавшее приложение, но и сообщение об ошибке и возможность вернуться в главное меню.
![](https://habrastorage.org/getpro/habr/upload_files/bba/e55/c27/bbae55c27ab08e5126b9f0eb9a114b14.png)
В то же время мы, к сожалению, не успели добавить обработку случая, когда неожиданно отключается клиент. Наш сервер не может спрашивать у клиентов никакой информации — он просто отвечает на запросы и не знает, кто к нему подключен. В целом проблема отключения игрока встает во многих играх, и с этим можно бороться по-разному:
Заканчивать игру при отключении игрока;
Подсвечивать игрока отключившимся и пропускать его ход;
Сделать ограничение на ход по времени: если игрок не делает ход, то он его пропускает.
Мы не могли реализовать первый и второй варианты в нашей архитектуре, поэтому думали насчет третьего. В итоге решили, что он будет очень неудобен для остальных игроков. Каждый ход может занимать достаточно много времени: надо продумать, какой перегон строить, как помешать соперникам и т.д., поэтому таймер пришлось бы ставить на слишком большое время, что будет сильно тормозить игру.
В текущем состоянии в наш Ticket to ride можно играть по локальной сети или с одного компьютера нескольким игрокам. К сожалению, по глобальной сети поиграть не получится. Подключиться через VPN возможно, но из-за того, что запросы достаточно объемные, а VPN чаще всего работает через протокол UDP, игра сильно тормозит, и каждый ход делается по 5-10 секунд.
Идеальным решением было бы поменять архитектуру. Например, сделать один глобальный сервер, который хранит игру, но сам не является игроком, а также имеет возможность делать запросы к подключившимся для проверки, что они в игре. Это позволило бы решить сразу несколько проблем:
Возможность делать хорошую проверку на отключение игрока.
Возможность сделать игру по глобальной сети. Для этого установим скрипт, создающий контроллеры на каком-то выделенном глобальном сервере и выдающий им их ip-адреса.
Возможность делать обновление игры более удобным: главный контроллер просто посылал бы игрокам информацию об изменении в игровом состоянии, что позволило бы рисовать только эти изменения, а не перерисовывать всю доску каждый раз. Это, вероятно, решило бы и проблему с vpn: информации передавалось бы в разы меньше, и все стало бы работать гораздо быстрее.
Но, к сожалению, продумать это вначале у нас не получилось, а изменять архитектуру после было слишком сложно, и времени нам просто бы не хватило.
Графический интерфейс
Мы выбрали библиотеку QT как наиболее распространенную и имеющую обширную документацию. Кнопки сделали при помощи класса QGraphicsRectItem:
![](https://habrastorage.org/getpro/habr/upload_files/962/20a/219/96220a2190d9bc815081bd5b535596aa.png)
Большинство объектов на поле — это вагончики типа QGraphicsPolygonItem, которые заданы координатами в специальном файле, что дает возможность сделать другую карту без изменения кода.
Остальные элементы интерфейса — это кнопки.
Весь текст принадлежит классу QGraphicsTextItem.
Станции — это эллипс с совпадающими центрами и радиусами. К сожалению, они находятся на сцене не как объект, а как картинка. Чтобы построить станцию, на них нужно кликнуть дважды, так как проверяются все станции посредством определения координат даблклика.
В основном интерфейс просто вызывает определенные функции у контроллера. Например, можно посмотреть свои маршруты, нажав на блоки слева от карты, при этом маршруты других игроков будут видны лишь по окончании партии.
![На картинке ход красного игрока, и он может увидеть свои маршруты На картинке ход красного игрока, и он может увидеть свои маршруты](https://habrastorage.org/getpro/habr/upload_files/d9b/68a/5da/d9b68a5da18bdae1ff7f63a04f613c3b.png)
Справа от них на столе расположены карты, которые можно взять в руку. При нажатии на них будет видна небольшая анимация перемещения карты вниз экрана.
Во время работы офлайн-игры код выполняется более-менее линейно. Когда же мы начали реализовывать часть для взаимодействия по сети, оказалось, что нужно получать изменения с сервера. Это не очень удобно, поскольку сервер не умеет просто сигнализировать всем клиентам, что произошли изменения. Решением этой проблемы стало обновление всего игрового поля у всех клиентов:
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), попробовать себя в интересной для области и, конечно же, дополнить свое резюме реальным проектом.
Исходный код можно посмотреть здесь.
![](https://habrastorage.org/getpro/habr/upload_files/88f/2f7/38c/88f2f738c9148056a67bb8b8cbe59f41.png)
Другие материалы из нашего блога о проектах студентов младших курсов: