Кастомный движок, окно

Кастомный движок, окно

Всем привет. 8 месяцев прошло с момента публикации последнго девлога, а это значить, что пора пилить ещё один! Да, я обещал, что выложу следующую часть бытрее, и кто-то может сказать, что я обманщик… Но давайте рассуждать так: 16-ричная система счисления не чужда нам, технически подкованным людям, а число 30 (дней в месяце) в такой системе означало бы 48 дней в десятичной.

цифра 10 в 16-ричной системе счисления получается так:

Данная инфографика наглядно показывает количественный состав числа 10 в 16ти-ричной системе счисления в Тоддах Говардах.
Данная инфографика наглядно показывает количественный состав числа 10 в 16ти-ричной системе счисления в Тоддах Говардах.

Таким образом 8 месяцев в 16-ричной системе составляло бы 48x8 = 384 дня! В реальности же прошло всего ~240. Я никого не обманывал, материал вышел почти в 1.6 раз быстрее!

В прошлый раз мы подобрали подходящие библиотеки и в итоге набросали встраиваемый в C++ приложение UI. И так, давайте же им воспользуемся и создадим себе сподручный класс окна, который будет:

- принимать и хранить ввод пользователя

- предоставлять другим частям программы доступ к этому вводу

— Хранить данные о том, был ли инпут в текущем кадре

— <...> изменилось ли состояние кнопки

- предоставлять on-screen лог

- предоставлять консольный лог

Поехали.

Начнём с топ-левел дизайна. Так как мы всё-таки делаем игровой движок, в нём с вероятностью 100% будет иметь место вот такой "геймплейный цикл":

Кастомный движок, окно

У этого цикла есть "начало" и "конец" в практическом смысле - "взять инпут игрока" и "нарисовать фрейм". Для нашего окна это значит, что имеет смысл завести 2 функции:

void onFrameStrat(); void onFrameEnd();

Первая будет отвечать за все, что окну требуется в начале "цикла фрейма", вторя - соответственно, за конец. Они нам понадобятся!

Ввод пользователя.

Для хранения объявим такую структуру:

struct ButtonState { bool pressed = false; std::pair<void*, std::function<void(void*)>> pressCallback; std::pair<void*, std::function<void(void*)>> releaseCallback; // checks, if button become pressed at this exact frame bool checkPress() { if (!pressed) checkPressFlag = false; if (pressed && !checkPressFlag) { checkPressFlag = true; return true; } return false; } private: bool checkPressFlag = false; };

Держа в уме наш "цикл фрейма", мы объявлем коллбэки для событий нажатия и отпускания кнопки - их нашей программе будет присылать glfw, которая получит их от ОС - нам при этом не важно, на какой именно ОС выполняется наш код, чудеса прослоек!

bool pressed = false; bool checkPress(); bool checkPressFlag = false;

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

// проверяет, была ли нажата кнопка именно в этом кадре из "отжатого" состояния if (window.keyboard[key].checkPress()) { doMyStuff(); } // проверяет, нажата ли кнопка вообще вот прям сейчас if (window.keyboard[key].checkPress()) { doMyStuff(); }

Так как мы занимаемся разработкой игор, то полезно посмотерть на концепт модели выполнения, стоящий за checkPress():

Оно в играх пригодится, в играх без этого никуда...
Оно в играх пригодится, в играх без этого никуда...

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

Погуглите, что такое предикат. Только не в C++. В C++ это коллабл.
Погуглите, что такое предикат. Только не в C++. В C++ это коллабл.

Можно использовать его как У.Е., или баранов, или мотоциклы - считать их, смотерть время между ними, думать о "предыдущем" или "следующем" фрейме, все вот это вот. Как же ведут себя наши флаги между фреймами? А вот так вот:

Кастомный движок, окно

В некотором фрейме, где кнопка не была нажата, клиентский код, вызывающий checkPress(), заставляет флаг checkPressFlag принять положение false. В том фрейме, когда кнопка меняет положение на "нажата", checkPress() избегает сетап флага checkPressFlag в false, выражение pressed && !checkPressFlag становится верным, в checkPressFlag пишется true, а клиентский код получает добро на запуск, чего там нужно было запустить! В следующем фрейме, когда кнопка все ещё не была отпущена, pressed = true предотвратит сетап checkPressFlag = false и checkPress() вернёт false, тк кнопка не изменяла свого положения именно в этом кадре.

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

Поотвлекались немного, и поехали дальше. А что же это за window.keyboard? Вот он:

struct Keyboard { // true if press happened in current frame bool pressFlag = false; std::unordered_map<int, ButtonState> keys; ButtonState & getLastPressedKey() { assert(lastPressedKey != -1); return keys[lastPressedKey]; } ButtonState & operator[](int keycode){ return keys[keycode]; } private: int lastPressedKey = -1; };

Ничего сверхъестественного - контейнер для кнопок, и немного ситаксического сахара в виде оператора []. Так же есть возможность обратиться к последней нажатой кнопке:

ButtonState & getLastPressedKey();

Похожую структуру мы будем использовать для мышки, но она так же будет хранить текущие координаты курсора, дельту перемещения и предыдущие координаты. Может пригодится, кто знает:

struct Mouse { double x = 0.0, dX = 0.0, prevX = 0.0, y = 0.0, dY = 0.0, prevY = 0.0; std::unordered_map<int, ButtonState> keys; ButtonState & operator[](int keycode){ return keys[keycode]; } };

Геймпад. Геймпад - это просто клавиатура со стиками

struct Gamepad : public Keyboard { enum AXES { lx, ly, rx, ry }; std::unordered_map<int, float> sticks_input; };
Да правда, чел, клавиатура со стиками, так оно и есть!
Да правда, чел, клавиатура со стиками, так оно и есть!

Итого, наш класс окна будет содержать все 3 структуры:

struct Window { Keyboard keyboard; Mouse mouse; Gamepad joystick_state[MAX_GAMEPADS]; }

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

void window_iconify_callback(GLFWwindow* window, int focused); void window_focus_callback(GLFWwindow* window, int focused); void mouse_button_callback(GLFWwindow* window, int button, int action, int mods); void framebuffer_size_callback(GLFWwindow* window, int width, int height); void window_size_callback(GLFWwindow* window, int width, int height); void window_close_callback(GLFWwindow* window); void key_callback(GLFWwindow* window, int key, int scancode, int action, int mods); void cursor_position_callback(GLFWwindow* window, double xpos, double ypos); void scroll_callback(GLFWwindow* window, double xoffset, double yoffset); void char_callback(GLFWwindow* window, unsigned int codepoint); void joystick_callback(int jid, int _event);

Логгирование.

Возможность печатать текст на экране - это неотъемлимая часть любого игрового движка, которая не только позволяет вывести нужную информацию в реальном времени, но и потешить своё эго личностным ростом, ведь до этого текст выводился только в консоль. Консоль, кстати, тоже нужна, но в готовой игре линковать с ней - дурной тон, так что она тоже будет встроенная. Используя редактор UI из предыдущего девлога, создадим 2 виджета - экранный лог, который будет очищаться в каждом кадре, и экранную консоль, которая будет хранить сообщения на протяжении всей "жизни" программы.

Кастомный движок, окно

Я не буду останавливаться подробно на создании виджетов и особенностях работы с ними - можно посмотреть на сам редактор на гитхабе, в нём есть примеры. В самом же классе окна нам нужно хранить только имена виджетов:

std::string systemLogName, screenLogName, logLineName, sysLogLineName;

С консольным логом всё просто - кроме его создания, окну надо добавлять элементы-строки в UIMap динамически. Так как имя элемента - это тоже символьная строка, будем создавать его из заранее согласованного префикса и номера строки, которая добавляется клиентским кодом:

std::string system_log_line = "_system_log_line_"; void Window::printInLog(const std::string & line) { std::string currLineName = sysLogLineName + std::to_string(system_log_lines_total); WIDGET & systemLog = GUI.widgets[name][systemLogName]; UI_elements_map & UIMap = GUI.UIMaps[name]; UIMap.addElement(currLineName, UI_STRING_LABEL, &systemLog); UIMap[currLineName].label = line; system_log_lines_total++; }

Добавление строк в экранный лог выглядит точно так же, но в отличие от консольного, он должен очищаться между фреймами:

void Window::clearScreenLog() { if ( screen_log_lines_total == 0 ) return; std::string currLineName; UI_elements_map & UIMap = GUI.UIMaps[name]; for (int i = 0; i < screen_log_lines_total; i++){ currLineName = logLineName + std::to_string(i); UIMap[currLineName].label = ""; } screen_log_lines_taken = 0; }

Кстати, возвращаясь к началу нашего повествования - очистка экранного лога это одно из того, что должна делать onFrameEnd()!

Разное, техническое.

Менее интересные вопросы, которое окно как-то должно орабатывать, но которые от нас будут требовать только написания делегирующего "пайпланового" кода, включают в себя:

- полноэкранный режим

- вертикальную синхронизацию

- языковую локаль

Кроме этих вопросов остался ещё счетчик fps - его мы напишем сами. Думаю, используя концепты времени и фрейма, читатель догадается, как оно должно работать (в вызове onFrameEnd()):

framesCounter++; auto currentTime = std::chrono::high_resolution_clock::now(); duration = std::chrono::duration_cast<std::chrono::microseconds>(currentTime - frameStartTime).count(); frameStartTime = currentTime; timeCounter += duration; if (timeCounter > 1000000.f){ fps = framesCounter; framesCounter = 0; // use last measured frametime frametime = duration; timeCounter = 0.0; }

Для локали и полноэкранного режима нам потребуются следующе функции, которые будут использоваться кодом игр:

void restore(); void goFullscreen(); void toggleVsync();

Внутри они просто будут вызывать соответствующие функции glfw.

То, что не хэндлится glfw - это системная локаль! Настройка, отвечающая за текущий язык в системе. Раз уж платформо-независимый код недоступен, придётся вооружится стрым добрым ifdef и написать платформо-зависимый код, который будет компилироваться по-разному для разных ОС:

#ifdef _WIN32 // get locale if (GetUserDefaultLCID() == 0x0419){ locale = LANG_LOCALE::rus; } else { locale = LANG_LOCALE::eng; } #endif

Игра (или приложение, использующее окно) сможет проверить locale и выбрать нужный язык уже платформо-независимо.

Пожалуй, на этом можно закончить! Пощупать получившееся окно и посмотреть пропущенные в статье моменты можно на гитхабе:

Там же есть приложение-пример, использущий класс окна. На этом всё. Надеюсь, скоро увидимся.

Кастомный движок, окно
2020
1 комментарий

В куче того говна что я вижу каждый день на дтф - это очень интересно. Автор, продолжай, удачи.

Ответить