Предисловие
В этой статье я хочу поговорить об абстракциях, на которых строится любой игровой движок. А конкретно — о реализации гибкой, «модульной» и без проблем расширяющейся платформы, на которой стоит вся «начинка» движка.
Примеры исходного кода я буду приводить на C++, хотя изложенные здесь концепции так или иначе могут быть переведены на любой другой C-подобный язык.
На протяжении всей статьи я предлагаю забыть про конкретные gamedev-паттерны (напр., update method, component-gameobject), графические библиотеки (OpenGL, DirectX), все различные wrapper’ы (типа SDL, GLUT), которые, несомненно, важны, но находятся на более высоком уровне абстракции в игровом движке.
Самый низкий уровень
Любой движок, каким бы сложным он ни был, по сути, выполняет три операции: считать ввод данных, обновить игру, отрисовать её. Эти операции могут быть бесконечно сложны и разделены на другие операции. Отсюда вытекает концепция простого движка:
void processInput() { ... }
void update() { ... }
void draw() { ... }
void gameloop() {
while (не нажали кнопку "выход") {
processInput();
update();
draw();
}
}
Однако такой подход нисколько не расширяем. К тому же, данные операции сильно зависят от их реализации. К примеру: что, если бы мы захотели по-другому отрисовать игру или считывать ввод данных? Нам придется копаться в корне движка. И вообще, почему вообще движок должен знать, как именно мы выполняем эти 3 операции?
Абстрагируемся
Пусть класс Core отвечает за выполнение метода gameloop(). Так как ему достаточно знать, в каком порядке выполнять операции в данном методе, мы можем избавится от конкретных реализаций и вынести их в отдельные абстрактные классы: Renderer, Updater, Inputer. Renderer будет отвечать за отрисовку объектов, Updater за обновление скриптов, а Inputer за считывание ввода.
Тогда реализация Core будет выглядеть примерно так:
class Core final { private: Renderer* _renderer; Inputer* _inputer; Updater* _updater; void gameloop() { while(true) { // условие выхода я рассмотрю чуть позже _inputer.processInput(); _updater.update(); _renderer.render(); } } public: Core(Renderer* renderer, Inputer* inputer, Updater* updater) : _renderer(renderer), _inputer(inputer), _updater(updater) {}
Однако оставим на время класс Core и поговорим об этих трех классах.
Renderer
Данный класс отвечает за отрисовку всей игры. Но, прежде чем начать что-то рисовать, необходимо инициализировать графику, допустим, в каком-нибудь методе setup(). Так же нашему абстрактному отрисовщику нужно знать частоту кадров. Только потом мы рисуем, опять же, в каком-нибудь абстрактном методе render().
Таким образом, класс будет выглядеть примерно так:
class Renderer { protected: Renderer(int targetFPS) : _targetFPS(targetFPS) {} virtual ~Renderer() {} public: virtual void setup() = 0; virtual void render() = 0; const int _targetFPS; };
В результате мы можем реализовать различные отрисовщики, работающие на разных API: в методе setup() инициализируем оболочку (напр. создаем окно, контекст OpenGL/DirectX и т.д.), а в методе render() указываем, как именно рисовать.
Inputer
Теперь поговорим о считывании ввода данных. Наша основная задача остается той же — абстрагироваться от основной реализации. Класс Inputer должен уметь считывать данные и проверять, не была ли нажата кнопка «выход (из игры)». Как это будет реализовано, ему не важно. Исходный код класса может выглядеть так:
class Inputer { protected: bool _running = true; // игра работает по умолчанию, ставим false в реализации, если кнопка "выход" была нажата public: virtual ~Inputer() {} virtual void processInput() = 0; bool quitRequested() const { return !_running; } };
Updater
Класс Updater слишком зависит от модели, на которой строится движок (будь-то Component-GameObject или др.), но его основная суть заключается в обновлении/инициализации всех задействованных в игре скриптов. Исходный код Updater здесь я приводить не буду.
Назад к Core
С определенными классами Renderer, Inputer и Updater класс Core теперь будет выглядеть примерно так:
class Core final { private: Renderer* _renderer; Inputer* _inputer; Updater* _updater; void gameloop() { while(!_inputer->quitRequested()) { _inputer.processInput(); _updater.update(); _renderer.render(); } } public: Core(Renderer* renderer, Inputer* inputer, Updater* updater) : _renderer(renderer), _inputer(inputer), _updater(updater) {} void Core::run() { _renderer->setup(); gameloop(); }
Однако на этом еще не всё. Наш игровой цикл выполняется настолько быстро, насколько может: нужно ограничить его выполнение, основываясь на значении targetFPS из класса Renderer.
Timing
Так как наша цель — независимость от конкретного API, то для обеспечения тайминга класс Core будет хранить указатель на функцию, которая отсчитывает количество миллисекунд от запуска программы. Эту функцию мы передадим в конструктор при инициализации Core, и в методе gameloop(), на основе её значений, уже реализуем сам тайминг.
Финальный вариант класса Core:
class Core final { typedef unsigned int (*timeFunc)(void); private: Renderer* _renderer; Inputer* _inputer; Updater* _updater; timeFunc _getTicks; // в миллисекундах! void gameloop() { auto currentTime = _getTicks(); ... } public: Core(Renderer* renderer, Inputer* inputer, Updater* updater, timeFunc getTicks) : _renderer(renderer), _inputer(inputer), _updater(updater), _getTicks(getTicks) {}
Итог
В результате мы получили каркас движка, к которому можно прикрутить любую библиотеку, не зависящий от реализации конкретных методов. Своеобразный вариант паттерна «Шаблонный метод».
ссылка на оригинал статьи https://habrahabr.ru/post/274623/
Добавить комментарий