Введение
Недавно решил поучиться разработке игр. Я программировал уже некоторое время на Rust, но у меня нет опыта разработки игр. Я всегда был заинтересован в создании игр, но нашел некоторые аспекты игроделанья пугающими. В частности, воспринимаемая сложность используемой математики и уровень оптимизации, необходимый для создания нетривиальных игр. Однако, недавно сев за Rust и желая что-то построить, решил прыгнуть в игровую разработку.
Это будет первая в серии статей (если я не потеряю интерес), где я буду строить простую игру как введение в разработку игр. Эта статья направлена к другим начинающих в Rust, которые могут быть заинтересованы в разработке игр, но не знают близко ничего о теме, как я. Я постараюсь предоставить подробные (возможно, слишком подробные) объяснения общих концепций и терминологии разработки.
Игра
Мы создадим очень простой 2D платформер/боковой скроллер. Цель будет для игрока двигаться через уровень, прыжками с платформы на платформу. Уровень будет бесконечным, и мы будем «процедурно» генерировать остальные платформы, когда игрок движется вправо. Если игрок падает в низ, игра заканчивается. Оценка игрока будет основана на прогрессе.
Это, наверное, самая простая игра, которую вы можете построить, но я думаю, что это хороший пример, чтобы начать как новичок.
Стек
Как упоминалось выше, мы будем строить это в Rust, поэтому некоторые предшествующие знания требуются, но вы не должны быть экспертом по Rust, чтобы следовать за мной, так как игровой движок, который мы будем использовать, имеет очень удобный интерфейс API для разработчиков, который не зависит от «сложных» функций Rust.
Мы будем использовать Bevy для построения кода нашей игры. Bevy является одним из более популярных движков игр на Rust, его действительно удобно использовать. Хотя, вероятно, это не правильный выбор (пока), если вы хотите заняться «серьезно» разработкой игр по сравнению с движками, такими как Unity, Unreal и Godot.
Эти движки существуют уже давно и имеют большие сообщества и богатые экосистемы, а также действительно мощные инструменты на основе графического интерфейса пользователя, которые помогают создавать большие игры. Я никогда не использовал ни один из этих движков прежде, но я предполагаю, что игра, которую мы строим, может быть построена очень быстро с помощью их GUI-инструментов, просто перетаскивая компоненты на экран без необходимости писать строки кода (вероятно).
Однако моя цель состоит не в том, чтобы построить игру, а в том, чтобы научиться разработке игр, так что я хотел бы понять некоторые из деталей реализации где инструменты обычно абстрагируются.
Объект, компонент и система (ECS)
Bevy - дата-ориентированный игровой движок, основанный на парадигме ECS. ECS (Entity Component System) - это парадигма проектирования, разделяющая игру на три части:
- Entities: Это все вещи, которые существуют в вашем игровом мире. Это не только ваши играбельные персонажи или враги, но и все, включая здания, пол, фон и даже камеру - сущность в ECS. Вы можете думать о них, как структуры или классы, которые вы обычно создаете при разработке чего-либо.
- Components: Компоненты, проще говоря, являются атрибутами, которые присоединены к объекту. Такие вещи, как здоровье персонажа и его положение на экране, определяются с помощью компонентов. Вы можете думать о них, как о полях, которые вы бы поместили в структуру для вашей сущности. Если вы знакомы с наследованием, вы можете быть склонны думать, что могут быть различные типы сущностей (т.е. вражеские сущности или NPC сущности), однако это не так, сущности только одного типа и они дифференцированы на основе компонентов, которыми они обладают.
- Systems: Это место, где будет жить вся логика игры, и это место, где вы будете проводить большую часть своего времени. Системы выполняют игровую логику для групп объектов и их компонентов.
В Bevy сущности представлены только их числовым идентификатором, и мы, как правило, больше заинтересованы в их компонентах, которые создаются с помощью структур и перечислений. Системы в Bevy определяются с помощью функций.
Все это должно стать более ясным по мере развития нашей игры.
Настройка
Хватит слов, время кодировать. Во-первых, нам необходимо создать среду для развития. Вам потребуется достаточно недавняя версия Rust, установленная для работы (просто запустите обновление rustup), я использую 1.69.0 для справки.
Начнем с создания двоичного пакета Rust и установки Bevy как зависимости:
cargo new bevy_platformer
cd bevy_platformer/
cargo add bevy
Примечание о производительности
По мере того, как мы разрабатываем нашу игру и добавляем функции, мы будем запускать ее (в режиме dev) для тестирования вещей, сборки dev в Rust неоптимизированы, чтобы позволять более быстрое время сборки, это обычно нормально при разработке других типов систем, но отсутствие оптимизации может вызвать ломкость и отставание в играх. Чтобы предотвратить это, мы настроим некоторые оптимизации для наших сборок для разработчиков.
Мы включим оптимизацию уровня 1 для нашего собственного кода и оптимизацию уровня 3 для всех наших зависимостей. Добавьте к своему Cargo.toml
:
[profile.dev]
opt-level = 1
[profile.dev.package."*"]
opt-level = 3
Эти оптимизации идут за счет времени сборки, поэтому нам также потребуется добавить некоторые конфигурации для ускорения компиляции. Полный список конфигураций описан в официальной документации Bevy, но я буду использовать только некоторые. Не стесняйтесь использовать другие предложеные варианты, если ваши сборки медленные.
Обновите файл Cargo.toml, чтобы включить функцию динамического связывания Bevy:
[dependencies]
bevy = { version = "0.10.1", features = ["dynamic_linking"] }
С этими настройками я могу получить практически мгновенные сборки, которые не страдают отставанием или ломкостью. Я запускаю это на M2 MacBook Pro 2023, и ваша скорость сборки может варьироваться, следует включить более быстрые конфигурации компиляции, если вы сталкиваетесь с медленной сборкой.
Начало работы
Начнем с создания нашего игрового приложения и добавления простой системы:
use bevy::prelude::*;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_startup_system(setup)
.run();
}
fn setup() {
println!("Привет мир!")
}
Здесь мы инициализировали приложение Bevy и добавили к нему две вещи:
- Плагины по умолчанию: Это набор встроенных плагинов, которые добавляют кучу функций, которые нужны каждой игре. Это включает в себя: настройку игрового окна и множество систем, которые помогают с рендерингом и другими вещами.
- Система запуска: Система запуска похожа на обычную систему, за исключением того, что она работает один раз в начале игры. Это полезно для закладки основ вашего игрового мира. Здесь мы создали простую функцию под названием
setup
, которая просто печатает на экране, затем мы добавили ее в приложение с помощью.add_startup_system (setup)
.
Теперь, если вы запускаете его с помощью cargo run
, вы должны увидеть пустое игровое окно (так как мы еще ничего не визуализировали) и «Привет мир!» напечатанное на вашей консоли среди других логов.
Обратите внимание, что первая сборка может занять продолжительное время, поскольку мы строим целый игровой движок! однако последующие сборки должны быть намного быстрее.
Рендеринг платформ
В конце концов, мы хотим, чтобы наша игра выглядела как настоящая игра с красивыми спрайтами; однако пока мы будем использовать одноцветные фигуры для представления объектов. Начнем с использования нашей функции настройки, чтобы создать платформу в виде прямоугольника.
fn setup(mut commands: Commands) {
commands.spawn(SpriteBundle {
sprite: Sprite {
color: Color::LIME_GREEN,
..Default::default()
},
transform: Transform {
translation: Vec3::new(0.0, 0.0, 0.0),
scale: Vec3::new(50.0, 100.0, 1.0),
..Default::default()
},
..Default::default()
});
}
Здесь происходит пара вещей, так что давайте объясню их.
Во-первых, обратите внимание, что наша система теперь принимает аргумент. Системы, в Bevy, могут иметь аргументы и эти аргументы составляют основу того, как системы взаимодействуют с игровым миром. Аргументы должны быть типов, которые понимает Bevy (в противном случае мы получим ошибки компиляции), и когда Bevy запустит эти системы, он предоставит соответствующие параметры, например, инжекцию зависимостей. Здесь мы принимаем только один аргумент, но вы можете написать системы, которые требуют несколько аргументов (до разумного предела), и Bevy будет заполнять их значения во время выполнения. Эти аргументы также являются тем, как система «запрашивает» игровой мир для списка сущностей и компонентов, которыми она хочет оперировать.
Здесь требуется аргумент типа Commands
, который является типом, позволяющим нам ставить изменения в очередь в игровой мир, как порождения объектов.
Затем мы используем метод spawn()
, чтобы создать сущность. Можно подумать, что мы создаем объект типа SpriteBundle
, но это неправильно, как упоминалось в предыдущем разделе: все объекты одного типа (Entity), который является просто оберткой вокруг их числового идентификатора, и они дифференцируются на основе их компонентов. То, что мы на самом деле делаем здесь, порождает сущность (которая по существу прозрачна для нас), прикрепляет к ней компоненты, которые находятся внутри SpriteBundle
.
Чтобы понять это, рассмотрим следующее:
commands.spawn(Transform::from_xyz(0.0, 0.0, 0.0));
Transform является компонентом, и в приведенном выше фрагменте мы создаем объект, к которому присоединен компонент Transform
. Мы также можем присоединить несколько компонентов к объекту, используя кортеж, такой как:
commands.spawn((
Transform::from_xyz(0.0, 0.0, 0.0),
Sprite {
color: Color::LIME_GREEN,
..Default::default()
},
));
Теперь мы создаем сущность с двумя присоединенными компонентами, один из которых имеет тип Transform
, а другой - тип Sprite
. Вы можете присоединить к объекту столько компонентов, сколько хотите, но использование кортежей становится громоздким, поэтому Bevy имеет концепцию «связки», которая позволяет организовать группы компонентов более эргономично.
Данный пакет представляет собой SpriteBundle
, который представляет собой группу компонентов, связанных со спрайтами рендеринга. Единственными компонентами, представляющими интерес, являются компоненты Sprite
и Transform
(для всего остального мы используем значения по умолчанию). Компонент Sprite
позволяет нам визуализировать некоторые текстуры на экране, пока мы используем его только для рендеринга цвета (лайм-зеленый) и установки всех остальных по умолчанию.
Чтобы понять компонент Transform
, необходимо сначала понять систему координат Bevy. Bevy имеет трёхмерную декартову систему координат, которая имеет следующие свойства:
- Для 2D начало координат (X = 0,0; Y = 0,0) находится в центре экрана по умолчанию.
- Ось X проходит слева направо (+ точки X вправо).
- Ось Y проходит снизу вверх (+ точки Y вверх).
- Ось Z проходит от дальнего до ближнего (+ Z указывает на вас, вне экрана).
Это «правая» система, если вы положите заднюю часть руки через экран, как показано ниже.
Bevy использует трёхмерную систему даже для 2D игр, в 2D мире можно представить Z-координату в терминах слоев или как стопку листов, где Z-координата описывает порядок листов в стопке.
Компонент Transform
позволяет расположить и масштабировать объект в этой системе координат. Свойство Transform.translation
имеет тип Vec3
который является вектором из трех элементов, представляющих координаты (x, y, z), для которых должен быть визуализирован объект. Transform.scale
также имеет тип Vec3
и позволяет нам масштабировать нашу сущность, чтобы изменить ее размер. По умолчанию спрайт визуализируется как прямоугольник 1x1x1, используя свойство scale
, мы можем масштабировать его (путем умножения) вдоль любой оси.
Вооружившись этими знаниями, мы теперь сможем понять этот фрагмент:
commands.spawn(SpriteBundle {
sprite: Sprite {
color: Color::LIME_GREEN,
..Default::default()
},
transform: Transform {
translation: Vec3::new(0.0, 0.0, 0.0),
scale: Vec3::new(50.0, 100.0, 1.0),
..Default::default()
},
..Default::default()
});
Этот фрагмент кода порождает сущность с несколькими присоединенными компонентами. Компонент Sprite
используется для создания пиксельного прямоугольника 1x1x1 (технически это куб, однако так как мы визуализируем в 2D я назову его прямоугольником) и установить его цвет на зеленый. Компонент Transform
используется для визуализации фигуры в начальной точке (0, 0, 0 - центр экрана) и масштабирования ее размера следующим образом:
- На 50 вдоль оси X (т.е. 50 * 1 пиксел = 50 пикселей)
- На 100 вдоль оси Y (т.е. 100 * 1 пиксел = 100 пикселей)
- На 1 вдоль оси Z (т.е. 1 * 1 пиксель = 1 пиксель)
Теперь, если вы выполните cargo run вы увидите… ничего, это потому, что мы не добавили камеру в наше приложение для просмотра визуализированных объектов. Давайте сделаем это сейчас:
fn setup(mut commands: Commands) {
commands.spawn(SpriteBundle {
sprite: Sprite {
color: Color::LIME_GREEN,
..Default::default()
},
transform: Transform {
translation: Vec3::new(0.0, 0.0, 0.0),
scale: Vec3::new(50.0, 100.0, 1.0),
..Default::default()
},
..Default::default()
});
commands.spawn(Camera2dBundle::default());
}
Последняя строка предписывает Bevy создать объект с компонентами из встроенной группы камер (Camera2dBundle
), мы используем здесь значения по умолчанию, которые порождают 2D камеру с ортогональной проекцией (в отличие от перспективной проекции), подробнее об этом ниже.
Теперь, если вы запустите игру, вы должны увидеть следующее:
Это не так много что бы показывать, но мы рассмотрели некоторые важные основы, которые будут полезны в будущем. Прежде чем двигаться дальше, обратите внимание на одно: прямоугольник центрирован на экране (помните, что мы установили координаты 0, 0, 0), это означает, что точка привязки в системе преобразования Bevy находится в центре, по умолчанию. Bevy центрирует наши объекты в точке, координаты которой мы предоставляем, затем визуализирует половину ширины на левой и правой сторонах этой точки и половину высоты выше и ниже этой точки:
Точки привязки варьируются от движка к движку, поэтому это важно помнить. Теперь поговорим о проекциях.
Проекции
Когда мы создаем наш игровой мир и порождаем сущности, то, что игрок видит на своем экране, зависит от нашей камеры и от того, какую проекцию он использует. Проекция - это просто способ отображения 3D пространства (игрового мира) на 2D плоскость (экран пользователя). Существуют различные проекции, но две общие категории:
- Перспективные проекции
- Ортогональные проекции
Перспективная проекция похожа на то, что делает человеческий глаз, где глубина (расстояние от глаза) объекта способствует тому, насколько большим или маленьким он кажется.
С другой стороны, ортогональная проекция полностью игнорирует глубину. Вы можете думать об этом, как если бы ваш глаз был экраном, и все «лучи света» от объектов (проекционных линий) движутся параллельно друг другу (и ортогонально вам), чтобы достичь вашего «экранного глаза», и, таким образом, все кажется своим первоначальным размером независимо от того, как далеко он находится от вашего «экранного глаза».
Поскольку мы строим двухмерный боковой скроллер, мы хотим игнорировать глубину, поэтому ортогональная проекция - это именно то, что нам нужно, и группа камер, которую мы только что инициализировали, обеспечивает это по умолчанию. Bevy также поддерживает перспективную проекцию, которую можно настроить при использовании комплекта 3D-камер (документация).
Позиционирующие платформы
Далее мы хотим породить кучу платформ и расположить их на «полу», но сначала нужно определить, где на самом деле находится пол. Очевидно, что это должно быть по нижней части нашего игрового окна. Чтобы все было просто, мы также исправим размер окна. Давайте определим некоторые константы, чтобы представить это:
const WINDOW_WIDTH: f32 = 1024.0;
const WINDOW_HEIGHT: f32 = 720.0;
const WINDOW_BOTTOM_Y: f32 = WINDOW_HEIGHT / -2.0;
const WINDOW_LEFT_X: f32 = WINDOW_WIDTH / -2.0;
Помните, что в системе координат Bevy начало координат находится в центре экрана, поэтому если мы пройдем половину высоты окна вниз (отрицательный Y), мы достигнем нижней части окна. Эта же логика может также использоваться для получения крайней левой точки окна.
Теперь давайте исправим размер окна, используя следующие константы:
fn main() {
App::new()
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "Bevy Platformer".to_string(),
resolution: WindowResolution::new(WINDOW_WIDTH, WINDOW_HEIGHT),
resizable: false,
..Default::default()
}),
..Default::default()
}))
.add_startup_system(setup)
.run();
}
Здесь мы настраиваем WindowsPlugin
, который был автоматически добавлен как часть подключаемых модулей по умолчанию, чтобы исправить размер окна, запретить пользователю изменять его размер и задать пользовательский заголовок.
Далее, давайте изменим нашу систему запуска, чтобы породить три платформы разных размеров в разных горизонтальных положениях по нижней части экрана:
fn setup(mut commands: Commands) {
commands.spawn(SpriteBundle {
sprite: Sprite {
color: Color::LIME_GREEN,
..Default::default()
},
transform: Transform {
translation: Vec3::new(-100.0, WINDOW_BOTTOM_Y + (200.0 / 2.0), 0.0),
scale: Vec3::new(75.0, 200.0, 1.0),
..Default::default()
},
..Default::default()
});
commands.spawn(SpriteBundle {
sprite: Sprite {
color: Color::LIME_GREEN,
..Default::default()
},
transform: Transform {
translation: Vec3::new(100.0, WINDOW_BOTTOM_Y + (350.0 / 2.0), 0.0),
scale: Vec3::new(50.0, 350.0, 1.0),
..Default::default()
},
..Default::default()
});
commands.spawn(SpriteBundle {
sprite: Sprite {
color: Color::LIME_GREEN,
..Default::default()
},
transform: Transform {
translation: Vec3::new(350.0, WINDOW_BOTTOM_Y + (250.0 / 2.0), 0.0),
scale: Vec3::new(150.0, 250.0, 1.0),
..Default::default()
},
..Default::default()
});
commands.spawn(Camera2dBundle::default());
}
Обратите внимание на то, как мы разместили каждую платформу, смещая ее координату Y на половину ее высоты от нижней части окна. Это происходит потому, что точка привязки находится в центре. Если мы просто установим координату Y в нижней части окна, половина платформы будет находиться под экраном.
У нас есть некоторое дублирование кода сейчас, но мы поправим это в будущем. Теперь, когда вы запускаете игру, вы должны видеть следующее:
Несмотря на то, что мы в конечном итоге будем делать реальные спрайты, давайте сделаем вещи немного более приятными, чтобы посмотреть, пока мы не доберемся туда. Начните с определения следующих констант для цветов, которые мы будем использовать:
const COLOR_BACKGROUND: Color = Color::rgb(0.29, 0.31, 0.41);
const COLOR_PLATFORM: Color = Color::rgb(0.13, 0.13, 0.23);
const COLOR_PLAYER: Color = Color::rgb(0.60, 0.55, 0.60);
Здесь мы просто определяем три цвета, используя их компоненты RGB (от 0-1 вместо 0-255). Затем измените цвет каждой из наших платформ, изменив конфигурацию компонента Sprite:
commands.spawn(SpriteBundle {
sprite: Sprite {
color: COLOR_PLATFORM, // цвет изменен
..Default::default()
},
transform: Transform {
translation: Vec3::new(-100.0, WINDOW_BOTTOM_Y + (200.0 / 2.0), 0.0),
scale: Vec3::new(75.0, 200.0, 1.0),
..Default::default()
},
..Default::default()
});
Теперь измените конфигурацию приложения, чтобы изменить цвет фона окна:
fn main() {
App::new()
.insert_resource(ClearColor(COLOR_BACKGROUND)) // resource added
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "Bevy Platformer".to_string(),
resolution: WindowResolution::new(WINDOW_WIDTH, WINDOW_HEIGHT),
resizable: false,
..Default::default()
}),
..Default::default()
}))
.add_startup_system(setup)
.run();
}
Здесь мы вставляем ресурс в приложение для достижения этой цели. Ресурсы - важная концепция Bevy, которую я буду освещать в будущих статьях, пока можно просто добавить строку, зная, что она просто меняет цвет фона. Теперь, если вы запускаете приложение, вы должны увидеть наши новые цвета в действии.
Наконец, давайте породим мяч, чтобы представлять нашего игрока. Измените систему setup() следующим образом:
fn setup(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
) {
// код для платформ и скрытая камера для краткости
commands.spawn(MaterialMesh2dBundle {
mesh: meshes.add(shape::Circle::default().into()).into(),
material: materials.add(ColorMaterial::from(COLOR_PLAYER)),
transform: Transform {
translation: Vec3::new(WINDOW_LEFT_X + 100.0, WINDOW_BOTTOM_Y + 30.0, 0.0),
scale: Vec3::new(30.0, 30.0, 1.0),
..Default::default()
},
..default()
});
}
Наша сигнатура системы немного изменилась, теперь мы принимаем еще два аргумента, которые являются различными типами ресурсов, и используем их для рендеринга шара на экран, порождая сущность с компонентами из MaterialMesh2dBundle
(компонент Transform
, с которым вы должны быть знакомы сейчас, я выбрал произвольное начальное расположение для мяча в левой части экрана). Эти два ресурса связаны с визуализацией сетки (шарика) на экране и нанесением на него материала (цвета). Я не буду вдаваться в детали сеток и материалов по двум причинам:
- Я сам их не так хорошо понимаю.
- Мы только визуализируем мяч, пока мы не добавляем спрайты, так что это по существу местозаполнитель.
Однако ресурсы весьма актуальны и будут покрываться за счет будущих размещений. Теперь, если вы запустите игру, вы должны увидеть следующее:
Отличная работа! Думаю, это хорошее место, где можно остановиться. Наш мяч как бы висит в воздухе немного, но мы исправим это в следующей статье, когда мы добавим физику в нашу игру. Надеюсь увидимся.
Весь код для этой части
use bevy::{prelude::*, sprite::MaterialMesh2dBundle, window::WindowResolution};
const WINDOW_WIDTH: f32 = 1024.0;
const WINDOW_HEIGHT: f32 = 720.0;
const WINDOW_BOTTOM_Y: f32 = WINDOW_HEIGHT / -2.0;
const WINDOW_LEFT_X: f32 = WINDOW_WIDTH / -2.0;
const COLOR_BACKGROUND: Color = Color::rgb(0.13, 0.13, 0.23);
const COLOR_PLATFORM: Color = Color::rgb(0.29, 0.31, 0.41);
const COLOR_PLAYER: Color = Color::rgb(0.60, 0.55, 0.60);
fn main() {
App::new()
.insert_resource(ClearColor(COLOR_BACKGROUND))
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "Bevy Platformer".to_string(),
resolution: WindowResolution::new(WINDOW_WIDTH, WINDOW_HEIGHT),
resizable: true,
..Default::default()
}),
..Default::default()
}))
.add_startup_system(setup)
.run();
}
fn setup(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
) {
commands.spawn(SpriteBundle {
sprite: Sprite {
color: COLOR_PLATFORM,
..Default::default()
},
transform: Transform {
translation: Vec3::new(-100.0, WINDOW_BOTTOM_Y + (200.0 / 2.0), 0.0),
scale: Vec3::new(75.0, 200.0, 1.0),
..Default::default()
},
..Default::default()
});
commands.spawn(SpriteBundle {
sprite: Sprite {
color: COLOR_PLATFORM,
..Default::default()
},
transform: Transform {
translation: Vec3::new(100.0, WINDOW_BOTTOM_Y + (350.0 / 2.0), 0.0),
scale: Vec3::new(50.0, 350.0, 1.0),
..Default::default()
},
..Default::default()
});
commands.spawn(SpriteBundle {
sprite: Sprite {
color: COLOR_PLATFORM,
..Default::default()
},
transform: Transform {
translation: Vec3::new(350.0, WINDOW_BOTTOM_Y + (250.0 / 2.0), 0.0),
scale: Vec3::new(150.0, 250.0, 1.0),
..Default::default()
},
..Default::default()
});
commands.spawn(Camera2dBundle::default());
commands.spawn(MaterialMesh2dBundle {
mesh: meshes.add(shape::Circle::default().into()).into(),
material: materials.add(ColorMaterial::from(COLOR_PLAYER)),
transform: Transform {
translation: Vec3::new(WINDOW_LEFT_X + 100.0, WINDOW_BOTTOM_Y + 30.0, 0.0),
scale: Vec3::new(30.0, 30.0, 1.0),
..Default::default()
},
..default()
});
}