На вопрос Палыча ты отвечаешь: на Rust, с движком Bevy. Палыч кивает так, будто понял, и наливает себе чаю из термоса. Ты ставишь ноутбук на крышку «Ракетки», набираешь одну команду, и вентилятор начинает гудеть. Через минуту он гудит громче.
Чтобы ответить, надо знать, из чего собрано приложение на Bevy. В прошлом уроке ты разложил автомат на три слоя и увидел, что программист пишет только механику. Сегодня у механики появится дом: ты поставишь Rust и шаблон курса, дождёшься первой сборки, откроешь окно с белым квадратом и прочитаешь его код строка за строкой. Главная мысль урока: игра и картинка живут в разных местах, и на этом держится весь курс.
Bevy это игровой движок на Rust. Он берёт на себя окно, время, клавиатуру, отрисовку и порядок, в котором всё это крутится. Тебе остаются правила игры.
Вопрос веб-разработчика: почему не браузер, canvas и requestAnimationFrame? Можно и так, и два раздела курса пойдут на TypeScript в браузере. Но каждый автомат курс проверяет тестом: кадр за кадром, без окна, с часами, которые двигает сам тест. Bevy умеет запускаться без окна, и проверка в следующем уроке стоит ровно на этом.
Поставь Rust и шаблон
-
Нужен Rust 1.97 или новее. Если Rust ещё не стоит, на Linux и macOS его ставит одна команда, на Windows установщик с сайта rustup.rs:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | shЕсли Rust уже есть, обнови его командой
rustup updateи проверь версию:rustc --version. Как устроены rustup и cargo, разобрано в первом уроке курса Rust. -
На Linux Bevy нужны системные библиотеки окна, звука и ввода. На Debian и Ubuntu:
sudo apt install pkg-config libasound2-dev libudev-dev libwayland-dev libxkbcommon-devБез них сборка остановится на полпути со строкой вроде этой:
The system library `wayland-client` required by crate `wayland-sys` was not found. -
Скачай шаблон курса и запусти первое окно:
git clone https://github.com/bondiano/komet-template cd komet-template cargo run -p hello
Шаблон komet-template это воркспейс: семь крейтов под одним общим Cargo.toml. Флаг -p hello говорит cargo, какой из них запускать. Подробно воркспейсы разобраны в уроке про Cargo.
| Крейт | Что это |
|---|---|
hello | первое окно с белым квадратом (этот урок) и проверка без окна (следующий) |
raketka, kirpichnik, nashestvie | код автоматов: сюда ты будешь класть src/ своего этапа с сайта |
raketka-app, kirpichnik-app, nashestvie-app | окна автоматов: рисуют то, что есть в коде этапа |
Почему первая сборка долгая
Пока cargo собирает, на экране бегут строки Compiling .... Каждая строка это один крейт.
Под капотом
hello зависит от одного крейта, bevy. Но Bevy собран из десятков своих крейтов: окно, время, математика, отрисовка, звук, шрифты, ввод. Только у самого Bevy их шестьдесят семь. Каждый из них тянет чужие: работу с видеокартой, с системным окном, с файлами. На Linux для hello набирается около четырёхсот крейтов, и в первый раз cargo компилирует каждый из исходников. В шаблоне на машине с 32 ядрами это заняло около двух с половиной минут. На ноутбуке выйдет дольше, иногда в несколько раз.
Скомпилированное cargo складывает в папку target/ и второй раз не трогает. Поменяешь строку в своём коде, и пересоберётся только твой крейт: секунды, а не минуты.
В корневом Cargo.toml шаблона есть ещё одна настройка:
# Bevy в отладочной сборке медленный; зависимости собираем с оптимизацией,
# а свой код без неё, чтобы он пересобирался быстро.
[profile.dev.package."*"]
opt-level = 2
Без оптимизации Bevy считает кадр медленно, и окно дёргается. Поэтому зависимости собираются с оптимизацией: это один раз и надолго. Свой код остаётся без неё и пересобирается быстро.
В раннере сайта та же история, только сборку там сделали заранее. Образ раннера хранит Bevy, уже скомпилированный с одним набором зависимостей и фич. Поэтому Cargo.toml и Cargo.lock в каждой задаче курса закрыты для правки. Добавишь свою зависимость или фичу, и cargo начнёт собирать Bevy с нуля, а у проверки на это есть тридцать секунд.
Окно
Сборка кончилась, и открылось окно: тёмно-серое, почти чёрное поле и белый квадрат посередине. Квадрат медленно едет вправо и секунд через одиннадцать уходит за край окна. Больше ничего не происходит. Закрой окно, и программа кончится.
Клавиш игра пока не слушает: управления в ней нет. Его ты напишешь в двенадцатом уроке, когда за «Ракеткой» встанут двое. Сейчас разберёмся, кто двигает квадрат и кто его рисует.
Что в main.rs
Вот hello/src/main.rs, вся программа с окном:
use bevy::prelude::*;
use hello::{HelloPlugin, Square};
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_plugins(HelloPlugin)
.add_systems(Startup, |mut commands: Commands| {
commands.spawn(Camera2d);
})
.add_observer(|add: On<Add<Square>>, mut commands: Commands| {
commands.entity(add.entity).insert(Sprite::from_color(Color::WHITE, Vec2::splat(64.0)));
})
.run();
}
По строкам:
App::new()создаёт пустое приложение. В нём нет ни окна, ни часов, ни одной системы. Похоже наexpress(), к которому ещё не подключили ни одного обработчика..add_plugins(DefaultPlugins)подключает набор стандартных плагинов: окно, отрисовку, ввод, звук, загрузку файлов, время и ещё несколько десятков..add_plugins(HelloPlugin)подключает саму игру. Она лежит вlib.rs, к ней вернёмся ниже..add_systems(Startup, ...)один раз при запуске создаёт камеру,Camera2d. Без камеры окно не покажет ничего..add_observer(...)ставит наблюдателя: как только сущность получает компонентSquare, наблюдатель вешает на неё белый спрайт 64 на 64. Спрайт это картинка, которую отрисовка ставит туда, где стоит сущность..run()отдаёт управление окну. Дальше окно само крутит кадры, пока ты его не закроешь.
Обрати внимание: про квадрат в main.rs нет ни слова, кроме того, как его нарисовать. Где он стоит и куда едет, решает игра.
Игра без картинки
Вот hello/src/lib.rs, вся игра:
//! Первое окно (урок 03) и первый стенд (урок 04): белый квадрат едет вправо.
use bevy::prelude::*;
/// Квадрат едет вдоль x со скоростью `speed` единиц в секунду.
#[derive(Component, Debug)]
pub struct Square {
pub speed: f32,
}
pub fn spawn_square(mut commands: Commands) {
commands.spawn((Square { speed: 60.0 }, Transform::default()));
}
pub fn drive(time: Res<Time>, mut squares: Query<(&mut Transform, &Square)>) {
for (mut transform, square) in &mut squares {
transform.translation.x += square.speed * time.delta_secs();
}
}
/// Игра без окна: её запускает и `main.rs` с окном, и тест без окна.
pub struct HelloPlugin;
impl Plugin for HelloPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, spawn_square).add_systems(Update, drive);
}
}
В этом файле четыре новых слова: сущность, компонент, система и расписание. Всё остальное в курсе собрано из них.
Квадрат это сущность: номер в мире игры, к которому прикреплены компоненты, структуры с данными. У квадрата их два: свой Square со скоростью и встроенный в Bevy Transform с позицией, поворотом и масштабом. commands.spawn((Square { .. }, Transform::default())) создаёт сущность с этими двумя компонентами в точке (0, 0), в центре мира.
spawn_square и drive это системы: обычные функции, которые вызывает Bevy. Их параметры читаются как список того, что системе нужно. Commands нужен, чтобы создавать и удалять сущности. Res<Time> даёт часы. Query<(&mut Transform, &Square)> даёт все сущности, у которых есть оба компонента, причём Transform можно менять, а Square только читать. Ты не вызываешь drive сам и не передаёшь ей аргументы: Bevy смотрит на типы параметров и подаёт нужное.
Когда вызывать систему, говорит расписание. Startup отрабатывает один раз при запуске: там квадрат создаётся. Update отрабатывает каждый кадр: там квадрат едет. За кадр он сдвигается на скорость, умноженную на длину прошлого кадра в секундах, time.delta_secs(). Так скорость выходит в единицах в секунду, а не в единицах за кадр. Почему без этого множителя игра бегает по-разному на разных мониторах, разберёшь в шестом уроке.
Плагин ты уже встречал в main.rs, а HelloPlugin показывает его изнутри: структура с методом build, который добавляет в приложение две системы, каждую в своё расписание. DefaultPlugins устроен так же, только плагинов в нём десятки.
И главное: в lib.rs нет ни камеры, ни спрайта, ни цвета. Игра знает, что квадрат едет вправо со скоростью 60. Как он выглядит, она не знает.
В лоб и как в игре
В лоб. Зачем два файла? Положи спрайт прямо туда, где квадрат создаётся, а камеру рядом:
use bevy::prelude::*;
#[derive(Component, Debug)]
pub struct Square {
pub speed: f32,
}
pub fn spawn_square(mut commands: Commands) {
commands.spawn(Camera2d);
commands.spawn((
Square { speed: 60.0 },
Sprite::from_color(Color::WHITE, Vec2::splat(64.0)),
Transform::default(),
));
}
У тебя это соберётся и нарисует то же окно. А в раннере сайта не соберётся:
error[E0425]: cannot find value `Camera2d` in this scope
--> src/lib.rs:9:20
|
9 | commands.spawn(Camera2d);
| ^^^^^^^^ not found in this scope
error[E0433]: cannot find type `Sprite` in this scope
--> src/lib.rs:12:9
|
12 | Sprite::from_color(Color::WHITE, Vec2::splat(64.0)),
| ^^^^^^ use of undeclared type `Sprite`
error[E0433]: cannot find type `Color` in this scope
--> src/lib.rs:12:28
|
12 | Sprite::from_color(Color::WHITE, Vec2::splat(64.0)),
| ^^^^^ use of undeclared type `Color`
Раннер собирает Bevy без окна и без отрисовки: в нём нет ни камеры, ни спрайтов, ни цветов. Видеокарты у сервера нет, а с одними только типами спрайтов образ раннера тяжелее на 1,3 гигабайта и каждая проверка втрое дороже по памяти. Код, который смешал игру с картинкой, раннер проверить не может.
Как в игре. Игра лежит в плагине и знает только правила и числа. Картинку добавляет отдельная программа: main.rs в hello, крейты raketka-app и kirpichnik-app для автоматов. Наблюдатель в ней вешает спрайт на каждую сущность с нужным компонентом. Тогда одна и та же игра работает в двух местах: в окне у тебя и на проверке без окна.
DefaultPlugins и MinimalPlugins
Без окна Bevy запускается с другим набором плагинов:
DefaultPlugins | MinimalPlugins | |
|---|---|---|
| Что внутри | окно, отрисовка, ввод, звук, файлы, время и ещё несколько десятков | четыре плагина: пул задач, счётчик кадров, время, цикл расписаний |
| Кто крутит кадры | окно, пока оно открыто | ScheduleRunnerPlugin, в цикле без окна |
| Где ты его встретишь | cargo run -p hello | раннер сайта и cargo test -p hello |
Ту же игру можно запустить и без окна:
use bevy::prelude::*;
use hello::HelloPlugin;
fn main() {
App::new().add_plugins((MinimalPlugins, HelloPlugin)).run();
}
Эта программа запустится и будет крутить кадры с полной скоростью, пока не нажмёшь Ctrl+C. Квадрат в ней едет, но этого никто не видит. Как проверить то, чего не видно, разберёшь в следующем уроке.
Ресурс: одна вещь на весь мир
В drive есть параметр, который не про сущности: Res<Time>. Часы в мире одни, они не прикреплены ни к квадрату, ни к камере. Такая вещь в одном экземпляре называется ресурсом.
Свой ресурс заводится так же, как компонент. У каждого автомата Аркадьича в корпусе стоял счётчик моточасов, и Палыч по нему знает, сколько «Ракетка» отработала. Вот такой счётчик для hello:
/// Сколько секунд автомат включён, как счётчик моточасов.
#[derive(Resource, Debug, Default)]
pub struct Uptime(pub f32);
pub fn tick_uptime(time: Res<Time>, mut uptime: ResMut<Uptime>) {
uptime.0 += time.delta_secs();
}
А регистрация в плагине становится такой:
impl Plugin for HelloPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<Uptime>()
.add_systems(Startup, spawn_square)
.add_systems(Update, (drive, tick_uptime));
}
}
#[derive(Resource)] разрешает типу жить в мире ресурсом. Default даёт начальное значение, ноль секунд, а init_resource кладёт ресурс в мир с этим значением. Res даёт ресурс для чтения, ResMut для изменения; число лежит в поле .0. Кортеж (drive, tick_uptime) регистрирует две системы в Update одним вызовом.
Разминка
Счётчик кадров устроен так же, как счётчик моточасов, только прибавляет не время, а единицу. Допиши систему count_frames и поставь её в расписание, которое идёт каждый кадр. В MinimalPlugins уже есть свой счётчик кадров, FrameCountPlugin: здесь ты пишешь собственный, чтобы руками собрать систему, ресурс и расписание.
Практика
Глубже в архиве
Система, которая пишет в параметрах, что ей нужно, а получает это от движка, это внедрение зависимостей. Урок «Внедрение зависимостей в Rust» показывает тот же приём в axum и в Bevy рядом и объясняет, как Bevy узнаёт, что подать в функцию.
Дальше
Квадрат давно уехал за край. Ты закрываешь окно и думаешь вслух: в раннере окна нет, как же он узнает, что квадрат едет со скоростью 60, а не 59?
В следующем уроке Палыч достанет из подсобки старый стенд, а ты соберёшь такой же для своего кода: тест, который двигает время руками и смотрит, где стоит квадрат.