Привет, Хабр!
Наверняка в фильмах или сериалах, а может быть даже на собственном опыте, вы сталкивались с игровыми автоматами. Мы тоже, и однажды у нас появилась идея создать современную версию игры, похожую на всеми любимую космическую аркаду Blasteroids. А чтобы вдвойне воплотить наш замысел в жизнь, мы сделали два режима управления игрой, один из которых — с помощью Ардуино, играющего роль маленького переносного геймпада, а другой — с помощью клавиатуры.
О нас
Мы — команда очаровательных программистов в составе Екатерины Браун, Анастасии Гореловой и Алёны Кудашевой — представляем вам свой первый и, очевидно, не последний учебный проект. Этот проект — курсовая работа по С++, которую делают все студенты бакалаврской программы «Прикладная математика и информатика» в конце первого курса под руководством опытного ментора. Нашим наставником был Тимур Исхаков — выпускник ФКН ВШЭ, сейчас работает Forward Deployed Engineer в Palantir London. Опыта создания больших проектов у нас не было, поэтому мы работали изо всех сил, чтобы создать что-то стоящее.
Поиск идеи
Для начала нужно было определиться с темой проекта. Для этого мы решили создать гугл-форму, где программисты могли поделиться темами своих студенческих проектов, а люди из других сфер рассказать, каких приложений им не хватает. Нас заинтересовал необычный комментарий одного из опрошенных — реализовать игру, причем не на ПК, а на плате с собственным процессором. Так и родилась красивая идея, чтобы запрограммировать портативную игровую консоль — маленькую коробочку с сенсорным экраном.
Аутентичная пиксельная графика. Знакомые с детства правила игры. Кстати, подробнее о них.
Правила и суть игры
По центру экрана расположен космический корабль, который безостановочно стреляет снарядами. Сверху летят астероиды. Их надо сбивать, не давая долететь до низа, иначе игрок теряет одну из трех жизней. Чтобы перемещать корабль, нужно нажимать на левую или правую часть экрана платы — корабль будет двигаться в ту же сторону. Цель — продержаться на поле как можно дольше. Кажется, что реализовать такую игру достаточно просто, но так ли это?
Платы
Нет. Идея сделать игру полностью на плате тормозила проект с самого начала. Вот с какими вопросами мы столкнулись:
На каком языке прошивается плата?
После того, как мы остановились на идее программировать микроконтроллер, в голове пролетела мысль, что большинство плат используют C. А по правилам наша курсовая должна быть написана преимущественно на С++. Этот момент нас насторожил, но мы продолжили путь к намеченной цели.
Хватит ли на плате памяти?
Знающие люди намекнули, что главная отличительная особенность микроконтроллера от компьютера — это ограниченность памяти.
Какую плату выбрать?
Мы могли осуществить первоначальную идею на Arduino, Raspberry, Teensy и множестве других микроконтроллеров.
Выбор платы
Характеристики плат:
Название |
Arduino UNO |
Raspberry Pi 4 |
Teensy 3.6 |
Arduino MEGA |
Входы/выходы |
14 |
40 |
32 |
54 |
ОЗУ |
32 КБ |
8 ГБ |
256 КБ |
256 КБ |
Габариты |
69*53 мм |
85*56 мм |
36*18 мм |
102*503 мм |
Разъем |
USB A-B |
USB |
USB |
USB A-B |
Язык |
C/C++ |
Python (и другие) |
C/C++ |
C/C++ |
Цена |
500₽ |
8000₽ |
4300₽ |
1400₽ |
Как вы уже поняли, остановились мы на Ардуино:
Во-первых, она стоила сравнительно недорого, и мы могли заказать сразу три платы.
Во-вторых, мы нашли огромное количество туториалов по работе с этим микроконтроллером.
В-третьих, к этой плате можно было с легкостью подключить сенсорный экран, который был нужен для управления кораблем.
В-четвертых, мы решили поставить перед собой еще более интересную и полезную для опыта задачу — написать игру в условиях ограниченной памяти устройства.
Новая концепция
В итоге мы заказали Arduino Mega и экран TFT LCD Touch screen 2.4. Получили. Первым делом решили познакомиться с платой и запустить Paint. Оказалось, что даже такое простое приложение смотрелось достаточно мелко на экране. Изначально мы хотели, чтобы плата выполняла роль как устройства ввода, так и устройства вывода, что оказалось бы крайне некомфортно для пользователя. Поэтому мы решили поменять стратегию и разделить управление и отрисовку на разные модули, однако по-прежнему хотели оставить в проекте фишку с Ардуино.
По новой задумке сама игра будет происходить на ПК. Так и возможностей для расширения больше, и графику можно сделать более современной, а также добавить серверную часть. Но где же тут Ардуино? Теперь плата выполняет роль сенсорного геймпада, с помощью которого идет управление игрой.
Тут стоит добавить, что дизайн мы обдумали заранее и пришли к выводу, что изображения всех объектов на поле будут нами вручную отрисованы в приложении Procreate на Ipad Pro.
Ардуино в роли геймпада
Сначала мы хотели, чтобы нажатие на определенную часть экрана было эквивалентно нажатию на компьютерную клавишу. Обычная эмуляция. Но для этого нужно было перепрошивать плату, чтобы компьютер переопределил вставленный девайс как устройство ввода. Звучит непрофессионально, о чем нам тонко намекнул ментор и предложил другой вариант, который мы и реализовали.
Есть две части кода:
Первая — внутри Ардуино, то есть этот код нужно буквально вшить в плату. Удобно это делать при помощи специальной среды Arduino IDE, в ней есть встроенный менеджер библиотек для плат компании Arduino и удобный интерфейс для прошивки девайса. Мы же выбрали PlatformIO — специальный фреймфорк для работы с любым микроконтроллером. Он более функционален и позволил нам шире разобраться в программировании плат. Установили PlatformIO мы в среду разработки Atom. Самое важное тут — это правильно выставить информацию о пинах, к которым подключен экран, силу нажатия, границы сенсоров, цветопередачу, сказать, где лево, а где право и так далее. К счастью, есть библиотека, которая позволяет помочь подобрать эти константы под ваш экран. Также в этом коде прописан вид главного меню, кнопок влево, вправо и паузы, меню паузы, меню выбора бонуса. Именно тут мы обрабатываем, на какую кнопку нажал игрок, и отправляем информацию об этом в порт. Более того, если в порт попала информация, что игрок умер и монет достаточно, на экране компьютера и на Ардуино высвечивается меню с кнопками YES/NO.
// Укороченный код обработки нажатия
if (p.z > MINPRESSURE && p.z < MAXPRESSURE) {
if (p.y < 700){
if (p.x < 570){
Serial.print("RIGHT");
Serial.print("\n");
} else {
Serial.print("LEFT");
Serial.print("\n");
}
} else {
Serial.print("PAUSE");
Serial.print("\n");
Serial.flush();
}
}
}
Вторая — в коде игры. В отдельном потоке постоянная обработка сигналов из порта. Если совершено нажатие, корабль начинает движение или игра становится на паузу. Также, если после смерти монеток достаточно, в порт посылается данная информация.
Дальше ребром встал вопрос, каким образом реализовывать графику игры, и как она будет взаимодействовать с логикой.
Графика и логика
В процессе разработки мы столкнулись с проблемой чрезмерной зависимости между модулями игры — логика была неразрывно связана с графикой. В перспективе это могло существенно затруднить перенос игры на другой графический фреймворк, а также добавление в геймплей фичей.
Поскольку ранее у нас не было опыта проектирования архитектуры программ, бОльших по объему, чем лабораторная работа, на помощь пришел ментор. Было решено ввести дополнительную сущность координатора, которую мы символически назвали God. Это наименование очень помогло нам понять основные функции данной структуры, да и просто было немного забавно иметь собственного боженьку в проекте.
Архитектура игры
В итоге мы разделили игру на несколько самостоятельных частей.
Логика. Отвечает за пересчет и хранение состояния игры. Получает запросы на изменение от координатора.
Контроллер ввода. Собирает данные от подключенного устройства управления игрой и направляет их координатору. Мы реализовали этот модуль с помощью абстрактного класса Controller in. По нашей задумке он должен представлять собой интерфейс контроллера-ввода. Сейчас от него отнаследованы два класса: Arduino controller и Key controller, которые непосредственно реализуют считывание действий игрока.
Контроллер вывода. Координатор с периодичностью передает ему набор объектов, которые изменили свое положение, и их новые координаты для перерисовки поля.
Таймеры. Своего рода будильники. Наша игра является динамической, а значит с некоторой постоянностью должны происходить забор данных с контроллеров, пересчет состояния и перерисовка объектов на поле. Для этого мы запускаем таймеры, которые 50-95 раз в секунду сообщают координатору о том, что пора провести круг изменений. Rem: количество сообщений в секунду регулирует скорость игры.
Клиент — сервер. После окончания каждой игры, а также при заходе в глобальную таблицу, координатор посылает запрос на сервер для обновления и сбора данных игроков.
Координатор. Самый главный. Общается со всеми частями и связывает их в единое целое.
Использование библиотеки Qt для графики, таймеров и не только
С переходом на десктопную версию игры появился большой выбор библиотек для реализации графики. Мы решили остановиться на достаточно популярной для разработки оконных приложений — Qt.
Для корабля, астероидов, выстрелов и подобных объектов был создан общий класс Gameobject, отнаследованный от классов QObject и QGraphicsPixmapItem. Каждому объекту задаются изображение, размеры и координаты установки на QGraphicsScene, которое и представляет собой игровое поле. Для быстрого поиска объектов на поле была введена hashmap, которая позволяет по ID обратиться к нужному Gameobject. Помимо этого на экране присутствует информационная панель и статистические данные. При создании этих частей мы использовали классы QVBoxLayout и QLabel.
Также мы добавили меню, которое позволяет просмотреть таблицы рекордов и легенду. Все окна были сделаны как QWidget, на которых располагаются текст, таблицы или кнопки. Для идентификации игрока необходимо было добавить возможность ввода имени, мы сделали это при помощи экземпляра класса QLineEdit — echoLineEdit. Тут также был задействован механизм сигналов и слотов. Нажатие на клавишу Enter после окончания записи ника было сигналом для сохранения строки, появившейся внутри echoLineEdit.
connect(echoLineEdit, &QLineEdit::returnPressed, this,
&name_enter::_line_edit);
void name_enter::_line_edit() {
name_entered((echoLineEdit->text()).toStdString());
}
Описанный выше механизм применялся достаточно часто. С помощью него устанавливалась связь нажатия на кнопки и какого-то события, также он использовался как оповещение после окончания времени на таймере.
В процессе игры также появляются и другие окна, предлагающие игроку купить дополнительную жизнь, сообщающие о возможной ошибке пользователя или позволяющие выбирать способ управления игрой.
Однако отрисовка игры была не единственным применением этой библиотеки. Мы также использовали Qt при написании контроллера-клавиатуры. С ее помощью был создан поток, в котором отслеживаются и фильтруются нажатия на клавиши. Любое событие, совершенное пользователем, фиксируется как QEvent. Затем, если это событие было подходящим (в нашем случае, нажатием на клавишу клавиатуры — QKeyEvent), оно передается дальше:
bool Key_Controller::eventFilter(QObject *obj, QEvent *event) {
if (event->type() == QEvent::KeyPress) {
auto *keyEvent = dynamic_cast<QKeyEvent *>(event);
switch (keyEvent->key()) {
case Qt::Key_Left:
modificationStore.pushed_button_left();
break;
case Qt::Key_Right:
modificationStore.pushed_button_right();
break;
case Qt::Key_Space:
modificationStore.pushed_pause_or_play();
break;
default:
break;
}
}
return QObject::eventFilter(obj, event);
}
Кроме того, самыми удобными и простыми оказались именно таймеры библиотеки Qt. Как было сказано ранее, специальными механизмами они связаны с функциями, которые должны вызываться с периодичностью. От частоты вызова функций зависит скорость смены положения объектов. Поэтому для разных типов объектов устанавливались разные таймеры.
Почти к окончанию работы над проектом мы придумали еще одну очень простую, но полезную фичу — возможность поставить игру на паузу. Это было сделано с помощью остановки, а затем повторного воспроизведения всех таймеров в игре.
Логика игры
В модуле логики присутствует достаточно много вспомогательных классов, которые определяют поведение соответствующих им объектов на игровом поле и хранят важную информацию — их ID и текущие координаты: Spaceship, Monster, Asteroid, Shot и т.д. А также главный класс Gamе, отвечающий за их взаимодействие и изменение общего состояния игры. Кроме того, присутствует универсальная структура Changes, которая используется для передачи события, произошедшего в игре: появление нового объекта, его исчезновение или передвижение по полю. Заметим, что она обязательно хранит в себе ID, ведь именно это служит ключом в hashmap для быстрого обращения к объектам на стороне графики.
Кстати, генерация объектов на поле происходит случайным образом, конечно, с некоторыми оговорками. Например, объекты не могут накладываться друг на друга, одновременно на поле не может быть больше трех астероидов и т.д. Для этого было удобно использовать функцию из C++11:
int random_number(int l, int r) {
std::mt19937 gen{std::random_device{}()};
std::uniform_int_distribution<> dist(l, r);
return dist(gen);}
Система бонусов
Основная часть игры написана. Логика работает идеально. Корабль управляется и с клавиатуры, и с Ардуино. Что делать дальше? Первое, что приходит на ум при вопросе расширения подобной игры, — система дополнительных жизней и разных ништяков, помогающих игроку во время боя.
Тогда мы добавили единый класс Bonus, внутри которого хранится тип данного бонуса:
кристалл, замедляющий таймеры;
монета, которая позволяет продлить игру в случае проигрыша;
дополнительная жизнь.
Спасать планету от астероидов стало в разы увлекательнее, к тому же подключить систему бонусов было довольно легко, ведь бонусы, как и все остальные объекты, при создании получали ID. Это значит, при поимке бонуса (его столкновении на поле с лазером корабля) достаточно было посмотреть на его тип и добавить в вектор изменений очередную структуру Changes, которая уже использовалась для обработки событий с астероидами. Напомним, эта структура содержит всю необходимую информацию: ID объекта, его координаты, а также указание, что необходимо с ним сделать. Для последнего самым простым и удобным решением оказалось использовать enum Actions:
enum Actions {
Create_ship,
Create_alien,
Create_shot,
Create_alien_shot,
Create_asteroid,
Create_coin,
Create_heart,
Create_alien_heart,
Create_diamond,
Move_object,
Delete_object,
Break_asteroid,
Slow_down_game,
Add_coin,
Add_heart,
Decrease_lives
};
Помимо бонусов, мы добавили в игру главного босса — прекрасного монстра, который вылетает с определенной периодичностью и вызывает вас на дуэль. Теперь есть два исхода: или вы отобьете его атаку, продолжив играть, или же он сотрет вас в порошок. Кстати, победить его будет не так просто, ведь он хаотично двигается в разные стороны. Все дело в том, что монстр, сделав в одну сторону определенное количество шагов, с вероятностью ½ решает, изменить ему направление или нет. Определенное количество шагов в одну сторону нужно для того, чтобы он не “дергался” на одном месте, слишком быстро меняя направление.
Таблицы рекордов
Напомним, что главная задача игрока — продержаться на игровом поле как можно дольше. В таком случае было бы неплохо наблюдать за своими результатами. Не выписывать же пользователю все цифры на бумажку!
Таблица рекордов: локальная
В главном меню мы имеем доступ к локальной таблице, где можно посмотреть все рекорды, поставленные на этом компьютере. Перед началом раунда игрок вводит свое имя, которое и отражается в таблице вместе с временем игры.
Как это работает изнутри? Введенное имя хранится в структуре Person, куда впоследствии запишется еще и время прошедшего раунда. После окончания игры из файла LocalLeaderBoard.txt, который лежит в проекте, достаются и десериализуются в вектор старые результаты. После чего в этот массив добавляется новый экземпляр структуры Person, массив сортируется, а в файл обратно сериализуются десять лучших результатов.
Таблица рекордов: глобальная. Сервер
Но ведь следить хочется не только за своими результатами: вдруг у друга получилось побить мой рекорд, а я не могу такое позволить. Теперь это можно проверить в табличке, где хранятся данные всех пользователей!
В коде игры есть клиент, который после окончания раунда посылает на сервер имя и результат в формате json. На сервере эти данные сериализуются в структуру LeaderboardRecord, где к тому же хранится uuid, чтобы избежать коллизии при совпадении имени пользователя. Результат кладется в вектор, далее всё сортируется.
При нажатии на кнопку просмотра глобальной таблицы, клиент просит у сервера информацию о лучших результатах. Сервер отправляет json объекты из вектора результатов. Далее клиент десериализует их в файл.
За все эти возможности спасибо библиотеке crow.
Обрабатываются запросы на удаленном компьютере ВШЭ, именно там запущен код нашего сервера.
Многопоточность
В связи с тем, что игра непрерывно меняет состояние на нескольких фронтах одновременно, мы также затронули тему многопоточности. Так, контроллеры запускаются в отдельном треде, общаясь с основной частью игры при помощи потокобезопасной очереди. В ней хранятся действия пользователя, совершенные между двумя итерациями. Осуществлено это было не самым сложным способом: контроллеры непрерывно анализируют действия пользователя, используя бесконечный цикл в случае Ардуино или более хитрые механизмы в случае Qt. После того, как произошло событие, они добавляют его в очередь. Затем из вектора все данные собираются координатором, обрабатываются и отправляются в логику.
Обращение к этой очереди было решено делать под std::mutex, поскольку это могло гарантировать одновременный доступ к данным лишь одного участника, а значит, избавить от возможной одновременной записи и чтения вектора.
Вот пример относительно безопасного добавления элемента в вектор:
void Modification_store::pushed_button_right() {
std::unique_lock<std::mutex> mx(mod_mx);
modifications.push_back(eclipse::kRight);
mx.unlock();
}
Заключение
Вот, что у нас получилось:
В заключение хотим сказать, что этот проект позволил нам получить опыт работы в команде и понять, что несколько умов всегда лучше, чем один. Мы столкнулись с множеством новых для нас технологий: многопоточностью, микроконтроллерами, работали с серверами и разными библиотеками. И это лишь малая часть опыта, который мы получили за полгода работы над проектом.
Посмотреть исходный код можно тут. Спасибо за внимание!