Skip to content

EasyPeasyLemonSqueezy/MadCat

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 

Repository files navigation

NutEngine

NutEngine - 2D движок для разработки игр. В его основе лежит фреймворк MonoGame, отвечающий за низкоуровневые задачи, такие как создание окна, игровой цикл, отрисовка изображений и графических примитивов и воспроизведение звука и музыки.

Демо можно найти в другой ветке репозитория.
Дополнительные материалы, в том числе видео демонстрация и презентация с примерами работы.

Ниже приведен список возможностей, которыми обладает движок, каждая из которых будет рассмотрена далее:

  1. Приложение/Сцены - организация игрового цикла и управление глобальными состояниями игры (главное меню, пауза, игровой уровень).
  2. Граф сцены - управление игровыми объектами, в том числе их отрисовка.
  3. Спрайты, Анимации, Метки и Области изображений - работа с графическими игровыми объектами - узлами графа сцены.
  4. Физика - моделирование физического поведения тел, взаимодействия игровых объектов друг с другом.
  5. Компоненты - один из способов реализации логики взаимодействия между игровыми объектами.
  6. Камеры - специальные объекты, с помощью которых можно управлять видом на текущую сцену.
  7. лучшенная обработка ввода - работа с клавиатурой.
  8. Машина состояний - переходы между игровыми состояниями объектов.
  9. Упаковщик изображений NutPacker - объединяет текстуры в атласы и предоставляет доступ к отдельным текстурам внутри движка.

Для демонстрации перечисленных особенностей разработаны несколько технических демо. Каждое демонстрирует разные стороны движка и его гибкость, позволяющую создавать разнообразные игры в различных жанрах.

Приложение, сцены (Application, Scene Stack)

MonoGame предоставляет пользователю класс приложения, реализующий игровой цикл, скрывающий низкоуровневые подробности. В NutEngine введен новый класс приложения, расширяющий возможности класса Game из MonoGame.

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

Класс приложения занимается хранением и обработкой этих сцен.

Все сцены хранятся на стеке, что позволяет переходить к новой сцене, сохранив возможность вернуться к старой (например, это удобно для реализации паузы). Всегда обновляется состояние только той сцены, что находится на вершине стека.

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

alt text
Рисунок 1. Приложение, Сцена.

Граф сцены (Scene Graph)

Отрисовка графики средствами MonoGame происходит следующим образом: в коде описывается каждый вызов отрисовки в нужном порядке. Когда игровой мир состоит из большого количества элементов и отношения усложняются, поддерживать такой код становится трудно. Было бы удобнее заранее описывать, что рисовать, как рисовать и в каком порядке, а движок займется всем остальным.

Внутри каждой сцены присутствует так называемый граф сцены - это дерево элементов находящихся на сцене (изображения, анимации, текст). Узел (Node) - элемент этого дерева.

Корень этого графа в сцене называется World, и он, как и любой другой узел, может хранить в себе ссылки на другие элементы. Появляется возможность группировать объекты и применять изменения сразу к группе. При обновлении сцены происходит обход всего дерева, во время которого происходит вся отрисовка.

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

  1. Рекурсивно отрисовываются дети с Z < 0.
  2. Отрисовывается текущий узел.
  3. Рекурсивно отрисовываются дети с Z > 0.

Таким образом, дети с Z < 0 будут отрисованы до самого узла, и из-за этого они будут находиться на заднем плане.

Примером работы графа сцены может служить отрисовка персонажа с его экипировкой. Различные элементы одежды становятся “детьми” узла персонажа. Это означает, что для перемещения всех элементов достаточно переместить только самого персонажа, достаточно только задать относительные позиции экипировки. То же касается масштабирования, поворотов и любых других преобразований, применяемых к узлам.

Все преобразования используют специальный класс Matrix2D. В MonoGame присутствовали матрицы, но они были рассчитаны на трехмерные преобразования, что в двухмерном пространстве приводит к слишком большим накладным расходам, к тому же интерфейс взаимодействия с этими матрицами оставлял желать лучшего. Поэтому было решено написать свою реализацию матриц трансформации.

alt text
Рисунок 2. Матрицы.

Тем не менее, в некоторых случаях действительно проще вызывать несколько команд отрисовки (например, карты, состоящие из клеток). Такая возможность в движке остается.

Спрайты, Анимации, Метки и Области текстуры

Пользователь может создавать свои типы узлов, используя базовый класс узла, но в движке уже реализованы несколько типов для работы с изображениями:

  1. Спрайты (Sprite).
  2. Анимации (Animation).
  3. Метки (Label).

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

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

Спрайт - статичное изображение. Класс, унаследованный от узла графа сцены, позволяющий работать с изображениями.

Анимация - динамичное изображение. Класс, унаследованный от спрайта, использующий таблицу, хранящую кадры, сгенерированную с помощью NutPacker'а, о котором будет сказано ниже. Анимация выбирает текущий кадр в зависимости от времени и выбранной функции (по умолчанию - линейно) и отрисовывает нужную область изображения.

Метка - изображение текста на экране.

Подробная модель классов:
alt text
Рисунок 3. Граф сцены, Спрайт, Анимация, Область текстуры.

Физика (Physics)

При разработке даже самой простой игры, порой возникает необходимость в использовании тех или иных физических законов. Представим себе ситуацию, вы разрабатываете аналог Pinball из Windows, и тут перед вами встает огромное количество задач, которые нужно как-то решать. Вам нужна гравитация, вам нужно чтобы при столкновении шарика с препятствием он отскакивал от него, причем от разных препятствий этот шарик должен отскакивать с разной скоростью. А так же вы хотите чтобы, когда ваш шарик катится по шероховатой поверхности, он замедлялся, и многое другое. Именно реализацией этих законов и занимается данный модуль нашего движка.

Основные компоненты физики:

  1. Фигуры. (Shapes) Есть 2 типа фигур:
    • AABB - (Axis-Aligned Bounding Box) выровненный по осям прямоугольник, задается одним вектором от центра к правому нижнему краю
    • Circle - окружность, задается одним числом - радиусом

Все фигуры реализуют интерфейс IShape, в котором содержится одно поле - сектор. Сектор - это AABB который описан вокруг этой фигуры, это может быть полезно для быстрой проверки тел на столкновения, т.к. большинство объектов в вашей игре скорее всего не сталкиваются, то логично использовать быстрые проверки пересечения секторов, чем каждый раз проверять пересечения сложных фигур.

  1. Тела. (Rigid Bodies) Тело - игровой объект, который описывается:
  • Фигурой
  • Массой
  • Материалом
  • Ссылкой на владельца этого тела

И содержит в себе состояния:

  • Позицию
  • Скорость
  • Ускорение
  • Силу
  • Эпсилон

Эпсилон - определенная константа, все силы, скорости, и прочие величины меньше этого числа не воздействуют на объект. Он был внесен напрямую в тело, для того чтобы можно было его изменять для различных объектов. Это необходимо для того чтобы избежать так называемого эффекта “дрожи” у небольших тел. Этот эффект возникает из-за того, что небольшие объекты могут довольно сильно проваливаются в другие тела за одну итерацию, из-за чего они и выталкиваются. В связи с этим можно наблюдать эффект того как тело на первой итерации проваливается, а на второй выталкивается. Если мы ограничим минимально возможное смещение, минимально возможную силу применяемую к тело, мы сможем избежать этого эффекта.

Также содержит в себе 2 события:

  1. При обновлении всех тел
  2. При столкновении с другим телом

Материал - отдельный компонент движка, в нем содержатся различные характеристики тела, такие как плотность, упругость, коэффициенты трения - покоя и скольжения.

Масса является также отдельным компонентом. На самом деле внутри хранится обратная масса (1/m), а сама масса высчитывается относительно нее, это было из-за того, что все формулы используют деление на массу, таким образом, используя инвертированную массу, мы увеличиваем производительность.

Продолжим рассмотрение примера с игрой Pinball, в этой игре единственный объект, который отталкивается от других тел - это шарик. Было бы неприятно, если бы вместе с шариком отскакивающим от стенки, стенка бы тоже отталкивалась. Именно для таких ситуаций и были придуманы статические тела.

Статическое тело - тело которое может влиять на другие тела, но другие тела на него не влияют, т.е. при столкновении шарика - обычного(твердого) тела со стенкой - статическим телом, шарик - отскочит от нее, а стенка останется на месте.

Не сложно заметить, что статическим телам масса не нужна, именно поэтому для того чтобы создать статическое тело вы должны создать тело с нулевой массой.

Что можно делать с телами:

  1. Можно напрямую работать со свойствами класса и изменять скорость, силу и ускорение, в таком случае эти действия будут применены сразу, в отличии от методов, но делать этого не рекомендуется.
  2. Можно придавать телам импульс (ApplyImpulse), в таком случае скорость тела изменяется сразу, а во обновление позиции будет происходить при обновлении всех тел.
  3. Или можно применять силы(ApplyForce), в этом случае скорость тела не будет изменяться, т.е. ApplyForce лишь накапливает силы, которые будут применены к телу при вызове функции IntegrateForces.
  4. Также есть возможность изменять положение тел еще до обновления, с помощью функции IntegrateVelocity.

При желании вы можете создавать свои типы тел, которые будут реализовать интерфейс IBody, например для того чтобы была возможность переопределить поведение некоторых функций - если вам захочется сделать игру с совсем нестандартной физикой, но вы не хотите разбираться во всех тонкостях работы физического движка, вы можете лишь переопределить методы внутри тела, например, для изменения поведения тел, при применении сил.

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

  1. Менеджер тел. (Bodies Manager) Менеджер тел, как понятно из названия, управляет всеми телами, т.е. если вы хотите чтобы на ваши тела действовала физика - добавьте их в менеджер тел.

Менеджер тел выполняет следующие функции, именно в таком порядке ко всем телам: * Проверяет столкновения (CalculateCollisions) * Применяет силы (IntegrateForces) * Изменяет скорости у столкнувшихся объектов (их направления) (разрешение коллизий) (ResolveCollisions) * Уменьшает скорость при трении (тоже в ResolveCollisions) * Регулирует их позицию (выталкивает тела друг из друга если они пересеклись) (PositionAdjustment) * Вызывает нужные события у тел (OnUpdateAll, OnCollisionAll)

Проверку на коллизии(столкновение тел) можно разбить на два этапа - две фазы:

  • Широкая фаза (Broad Phase) - Составление пар объектов, которые могут сталкиваться. В отличие от большинства других реализаций, благодаря использованию отдельного объекта - менеджера тел, мы можем делать это на этапе добавления тел, а не на каждой итерации цикла. Т.е. когда вы добавляете тело, вы автоматически составляете все возможные пары элементов.
  • Узкая фаза (Narrow Phase) - Уже конкретная проверка на то сталкиваются ли эти объекты.
  1. Коллайдер.
    Коллайдер в нашем движке, это механизм проверки коллизий между телами. Здесь стоит упомянуть про 3 основных компонента: Область пересечения (IntersectionArea) - Объект описывающий пересечение тел: глубину пересечения и нормаль от первого объекта к второму. Коллизия (Collision) - Объект хранящий в себе 2 тела и их область пересечения. Именно здесь находятся методы для разрешения самой коллизии и корректировки позиции, а также вызываются события объектов при коллизии Сам коллайдер - статический класс в котором содержатся методы Collide для различных фигур, принимающие 2 тела и возвращающие область пересечения через параметр, а также методы для быстрой проверки тел на пересечение, которые не генерируют область пересечения.

Таким образом, при обновлении тел, а именно при проверке на коллизии и генерации областей пересечения, мы вызываем функцию Collide, с параметрами IBody, а внутри нее уже вызывается конкретная функция для данного типа фигур.

Подробная модель классов:
alt text
Рисунок 4. Физика.

Компоненты (Components)

При разработке более-менее сложной игры в какой-то момент количество разных типов объектов становится очень большим, причем между ними много общего. Большинство объектов обладают позицией, многие из могут двигаться, сталкиваться и так далее. Реализовывать одни и те же вещи в каждом классе было бы неэффективно.

Обычно при объектном подходе эту проблему решают, используя наследование. Строится общая иерархия классов для всех типов объектов. Наследование в данном случае не является лучшим решением, так как иерархия наследования получается очень запутанной и глубокой.

Вместо этого целесообразно использовать композицию, то есть собирать объектов из небольших готовых частей, которые называются компонентами, причем делать это во время работы программы. Такой подход называется “Компонентная модель”, и опыт показывает, что в играх такой подход работает лучше.

Создавая новые компоненты, каждый из которых выполняет некоторую небольшую задачу (например, обновление позиции, проигрывание анимации), мы получаем возможность легко собирать новые объекты из уже готовых частей.

Поскольку собираются объекты на этапе работы программы, возникает возможность по ходу игры удалять/добавлять компоненты и, таким образом, менять свойства объекта прямо во время игры, что придает большей гибкости.

За обновление, хранение, удаление объектов, состоящих из компонентов, отвечает менеджер объектов. Он делает работу с объектами более удобной и безопасной. Упрощается добавление и удаление объектов.

Часто некоторые компоненты должны обновляться раньше других. Например, сначала нужно обновить позицию, а потом уже изображение, иначе возникнет отставание отрисовки от игрового процесса на кадр. В некоторых случаях это может быть критично.

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

Таким образом, если у нас имеется граф:
alt text
Рисунок 5. Граф зависимостей компонентов.

То нам нужно упорядочить вершины следующим образом:

alt text
Рисунок 6. Граф зависимостей компонентов, с упорядоченными вершинами.

Гарантируется, что компоненты, от которых зависит некоторый компонент, будут обновлены в первую очередь.

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

Компонентами может быть, что угодно - скорость, позиция, коллайдер, здоровье.

Подробная модель:
alt text
Рисунке 7. Компонентная модель.

Камеры (Camera)

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

Существуют два типа камер, которые отличаются порядком применения трансформаций и, соответственно, поведением:

  1. SRT (OrthographicSRTCamera) - сперва происходит масштабирование (Scale), затем поворот (Rotate), а затем перемещение (Translate) Это полностью копирует поведение реальной камеры из жизни.
  2. TRS (OrthographicTRSCamera) - сперва происходит перемещение (Translate), затем поворот (Rotate), а затем масштабирование (Scale). В отличие от предыдущего типа, этот объект уже ведет себя не так как обычная камера. Т.к. сперва выполняется перемещение, все операции будут происходить относительно центра объекта(мира), а не относительно камеры.

Различие будет проще пояснить на примере: предположим, что вы опускаете камеру и в это же время поворачиваете ее, в таком случае:

  • SRT - сперва произойдет поворот, т.е. где бы не находилось тело, оно будет вращаться относительно центра камеры.
  • TRS - сперва произойдет перемещение, и поворот произойдет относительно центра самого объекта, т.е. он будет вращаться вокруг своей оси.

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

Подробная модель классов:
alt text
Рисунок 8. Камеры.

Улучшенная обработка ввода (Input)

В MonoGame присутствует базовая обработка ввода игрока, но в ней не хватает некоторых деталей, которые упрощают работу. Например, нет возможности узнать, произошло ли нажатие или отжатие(release) клавиши в конкретный момент, можно только узнать, нажата ли она. В нашей реализации эти проблемы были решены. Также в MonoGame нет возможности узнать предыдущее состояние, что заставляет разработчиков писать один и тот же код постоянно.

Тем не менее в MonoGame были реализованы базовые классы состояния (KeyboardState) и структура Key, которые и были взяты за основу.

Этот модуль состоит из двух связанных между собой объектов:

  1. Клавиатура (Keyboard), состоит из:
  • Метод Update - обновляющий состояние клавиатуры, этот метод вызывается автоматически при обновлении класса приложения. Здесь изменяется текущее состояние клавиатуры.
  • Свойство State - текущее состояние клавиатуры
  1. Состояние клавиатуры (KeyboardState).
    Содержит в себе 2 свойства:
  • Текущее состояние клавиатуры (CurrentState)
  • Предыдущее состояние клавиатуры (PrevState)

А также несколько методов:

  • Проверка нажата ли данная клавиша или несколько клавиш в данный момент (IsKeyDown)
  • Проверка отпущена ли клавиша или несколько клавиш в данный момент (IsKeyUp)
  • Можно получить массив из всех нажатых клавиш (GetPressedKeys)
  • Проверка была ли нажата определенная клавиша на этой итерации игрового цикла (т.е. до этого она была не нажата) (IsKeyPressedRightNow)
  • Проверка была ли отпущена определенная клавиша на этой итерации игрового цикла (т.е. до этого она была нажата) (IsKeyReleasedRightNow)

Также присутствует индексатор - оператор квадратные скобки. Позволяющий по клавише узнать нажата ли она сейчас.

Подробная модель класса:
alt text
Рисунок 9. Обработка ввода.

Конечный автомат (StateMachine)

Зачастую в играх присутствуют объекты, у которых можно четко выделить определенные состояния. Например, персонажи. Они могут стоять, бегать, прыгать и выполнять прочие действия. Существуют четкие правила перехода между этими состояниями (в состоянии прыжка не получается сделать подкат). Состояния и переходы между ними можно представить в виде конечного автомата или же в виде конечного автомата.

Для решения этой проблемы в движке есть классы конечного автомата и состояния.

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

Когда все состояния реализованы, остается создать конечный автомат, соответствующую конкретному объекту и передать ей первое состояние.

alt text
Рисунок 10. Конечный автомат (StateMachine).

Упаковщик текстур - NutPacker

Упаковка текстур - вероятно, наиболее важный этап в оптимизации графических приложений. Представим себе ситуацию, у нас есть простая 2D игра, с небольшим количеством текстур. Даже несмотря на то, что этих текстур мало, при инициализации игры, загрузка всех ваших текстур в оперативную память будет происходить чрезвычайно долго. И это еще не все, все эти текстуры будут храниться далеко не в одном участке памяти, что не позволит процессору использовать кэш для хранения этих текстур.

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

Эффективное(занимающее как можно меньшее место) склеивание таких текстур называется упаковкой.

Обычно, в качестве генерируемой таблицы используют обычные текстовые файлы и форматы: xml/json, но это не совсем удобно по нескольким причинам:

  1. При загрузке этих текстур вам придется парсить этот файл, что при большом количестве - довольно медленная операция.
  2. В таком случае совершенно отсутствует какая-либо проверка на правильность имен, т.е. если при выборе текстуры вы ошибетесь в названии, вы узнаете об этом только в момент выполнения.

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

Также мы можем не только генерировать код, но и сразу компилировать его в динамическую библиотеку (dll), что позволяет вам не тратить время даже на компиляцию полученных файлов, достаточно просто добавить в проект ссылки на них.

Для упаковки текстур в NutPacker был использован сторонний проект SpriteSheetPacker, который тоже был написан на C#, что позволило легко интегрировать его в наше приложение.

NutPacker имеет консольный интерфейс и позволяет генерировать иерархии из таблиц, помечая их как таблицу спрайтов (ISpriteSheet), которые используются внутри анимаций, как множество изображений/тайлов (ITileSet) или как группу спрайтов (ISpriteGroup) с определенными именами. Рассмотрим какая категория для чего используется:

  1. ISpriteSheet: Используется для формирования анимаций, содержит в себе:
    • Статический массив прямоугольников - областей изображения
    • Свойство с количеством этих прямоугольников (Length)
    • Индексатор, для обращения к этому прямоугольнику по индексу.
  2. ITileSet содержит в себе множество статических свойств - прямоугольников, или же ITileSet, т.е. множества таких текстур могут быть вложенными, используется внутри спрайта.
  3. ISpriteGroup - класс содержащий внутри себя определения классов таблиц спрайтов, аналогично может быть вложенным.

Также можно накладывать дополнительные ограничения на итоговый атлас:

  • Максимальная высота/ширина (по умолчанию - 216)
  • Квадратное изображение
  • Требование чтобы итоговый размер был степенью двойки
  • Отступ между текстурами внутри (по умолчанию отсутствует)

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

Также для каждого класса генерируется специальный атрибут - OriginalNameAttribute, в котором хранится одна строка с именем оригинального файла или папки. Это может быть удобно при использовании различных текстур в зависимости от номера уровня или любых других условий, в этом случае у вас есть возможность найти все нужные вам классы с помощью этих атрибутов.

Список литературы

  1. “An Introduction to Physically Based Modeling: Rigid Body Simulation II - Nonpenetration Constraints”, by David Baraff
  2. “Physics for Game Programmers”, by Grant Palmer
  3. “An Introduction to the Collision Detection Algorithms”, by Francisco Madera
  4. “Real-Time Collision Detection”, by Christer Ericson
  5. “Game Coding Complete”, by Mike McShaffry, David Graham
  6. “Computational Geometry”, by Mark de Berg, Otfried Cheong, Marc van Kreveld, Mark Overmar https://opengl-tutorial.org/assets/faq_quaternions/index.html
  7. https://gameprogrammingpatterns.com
  8. https://monogame.net/documentation & https://github.com/MonoGame/MonoGame
  9. https://github.com/craftworkgames/MonoGame.Extended
  10. https://randygaul.net & https://github.com/RandyGaul/tinyheaders & https://github.com/RandyGaul/ImpulseEngine
  11. spritesheetpacker.codeplex.com
  12. https://github.com/libgdx/libgdx

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Contributors 3

  •  
  •  
  •  

Languages