В подсобке пахнет канифолью. Палыч снимает с верхней полки деревянный ящик и ставит его на верстак. Внутри гнездо под плату, пучок щупов на проводах, ряд маленьких лампочек и две ручки: кнопка «ТАКТ» и переключатель с тремя положениями, «10 мс», «0,1 с», «1 с». Палыч вставляет в гнездо старую плату и жмёт кнопку. Лампочки мигают и замирают в новом узоре. Он жмёт ещё раз, и узор сдвигается.
В прошлом уроке игра переехала в HelloPlugin и перестала знать про картинку. Окно теперь отдельно, а в раннере сайта окна нет вовсе. Чтобы проверить игру без экрана, нужно то же, что было у Аркадьича: делать шаг по команде, знать, сколько этот шаг длится, и заглядывать внутрь.
В вебе ты делаешь так же. Редьюсер не тестируют в браузере: дают состояние и действие, проверяют новое состояние. Здесь то же самое, только вместо действия кадр, а вместо состояния компоненты в мире игры. Такая проверка называется стендом без окна, и на ней держится каждая задача курса.
Стенд: App без окна
Стенд начинается с приложения, которое ты уже знаешь, только без окна: App::new(), потом add_plugins((MinimalPlugins, HelloPlugin)). MinimalPlugins дают часы и цикл расписаний, но не окно. HelloPlugin это игра из прошлого урока. Дальше тест сам жмёт на кнопку: app.update() это «ТАКТ»: один вызов прогоняет один кадр со всеми его расписаниями и возвращает управление тесту. Первый вызов заодно отрабатывает Startup, и в мире возникает квадрат.
Почему не app.run(), как в main.rs? Без окна кадры крутит ScheduleRunnerPlugin, в бесконечном цикле. Тест, который вызвал run(), обратно управления не получит. update() делает ровно один шаг и ждёт следующей команды, как плата на стенде.
В лоб: часы машины
Тест хочет проверить одно: за секунду квадрат проезжает 60. Наивный стенд часов не трогает: собирает приложение, прогоняет пять кадров и ждёт, что квадрат проехал свою секунду. Сколько длятся эти пять кадров, тест не знает:
use bevy::prelude::*;
use hello::{HelloPlugin, Square};
#[test]
fn square_drives_its_speed_in_a_second() {
let mut app = App::new();
app.add_plugins((MinimalPlugins, HelloPlugin));
app.update();
for _ in 0..4 {
app.update();
}
let world = app.world_mut();
let x = world.query_filtered::<&Transform, With<Square>>().single(world).unwrap().translation.x;
assert!((x - 60.0).abs() < 1e-3, "за секунду квадрат проезжает 60, а проехал {x}");
}
Запускаешь три раза подряд:
за секунду квадрат проезжает 60, а проехал 0.00487434
за секунду квадрат проезжает 60, а проехал 0.00526134
за секунду квадрат проезжает 60, а проехал 0.00502434
Три запуска, три разных числа, и все далеки от 60. Часы Bevy по умолчанию идут по часам машины, а без окна кадры ничто не сдерживает. Пять кадров на стенде пролетели за восемьдесят с небольшим микросекунд, и за это время квадрат сдвинулся на пять тысячных. На загруженном сервере кадры выйдут длиннее, на свободном короче. Можно вставить в цикл sleep, но тест станет медленным и всё равно будет зависеть от машины. Тест, который на одном и том же коде даёт разные числа, ничего не проверяет.
Как на стенде: время руками
У Аркадьича длину такта задавал переключатель, а не настроение платы. У Bevy переключатель такой:
.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_millis(250)));
TimeUpdateStrategy это ресурс, который говорит часам, откуда брать время. По умолчанию он стоит в Automatic: время машины. ManualDuration(d) значит, что каждый кадр длится ровно d, с какой бы скоростью ни работал процессор. Вот стенд из шаблона целиком, hello/tests/stand.rs:
//! Стенд без окна, как в раннере курса: `cargo test -p hello`.
use std::time::Duration;
use bevy::prelude::*;
use bevy::time::TimeUpdateStrategy;
use hello::{HelloPlugin, Square};
#[test]
fn square_drives_its_speed_in_a_second() {
let mut app = App::new();
app.add_plugins((MinimalPlugins, HelloPlugin))
.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_millis(250)));
app.update(); // первый кадр: Startup, время ещё не пошло
for _ in 0..4 {
app.update();
}
let world = app.world_mut();
let x = world.query_filtered::<&Transform, With<Square>>().single(world).unwrap().translation.x;
assert!((x - 60.0).abs() < 1e-3, "за секунду квадрат проезжает 60, а проехал {x}");
}
Отличие от теста «в лоб» одна строка с insert_resource. Теперь кадр длится четверть секунды, четыре кадра дают секунду, и квадрат со скоростью 60 проезжает 60. Сравнение идёт с допуском 1e-3, а не через ==: числа с плавающей точкой после сложений расходятся в последних знаках.
Запусти в шаблоне:
cargo test -p hello
running 1 test
test square_drives_its_speed_in_a_second ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Первая зелёная галочка. Запусти ещё десять раз, и ответ будет тем же: время теперь задаёт тест, а не машина. В тестах на TypeScript ты делал то же самое через vi.useFakeTimers(): часы стоят, пока тест не сдвинет их сам.
Под капотом
Положи рядом, в hello/tests/clock.rs, стенд без игры, который печатает часы после каждого кадра:
use std::time::Duration;
use bevy::prelude::*;
use bevy::time::TimeUpdateStrategy;
#[test]
fn clock() {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_millis(250)));
for frame in 1..=5 {
app.update();
let time = app.world().resource::<Time>();
println!("кадр {frame}: delta {:?}, elapsed {:?}", time.delta(), time.elapsed());
}
}
Обычно cargo прячет то, что печатают тесты. Флаг --nocapture показывает напечатанное:
cargo test -p hello -- --nocapture
кадр 1: delta 0ns, elapsed 0ns
кадр 2: delta 250ms, elapsed 250ms
кадр 3: delta 250ms, elapsed 500ms
кадр 4: delta 250ms, elapsed 750ms
кадр 5: delta 250ms, elapsed 1s
delta это длина прошлого кадра, elapsed сколько прошло всего. Чтобы узнать длину кадра, часам нужны две отметки: начало и конец. В первом кадре отметка одна, часы её запоминают, а длину ставят в ноль. Поэтому первый app.update() время не двигает, и секунда на стенде это первый кадр плюс четыре по 250 мс.
Отсюда и ответ на второе упражнение прошлого урока. Startup отрабатывает в первом кадре, а там delta ноль. Система drive в Startup умножает скорость на ноль, и квадрат не сдвигается ни на пиксель.
Запрос в тесте
Лампочки стенда у тебя это строка, которая достаёт позицию квадрата:
let world = app.world_mut();
let x = world.query_filtered::<&Transform, With<Square>>().single(world).unwrap().translation.x;
У теста нет параметров, которые подал бы Bevy, как системе. Поэтому тест сам спрашивает мир. query_filtered::<&Transform, With<Square>>() собирает запрос: дай Transform у тех сущностей, у которых есть Square. Это тот же Query, что в системе drive, только написанный руками. Запрос сначала регистрируется в мире, поэтому нужен world_mut(), а не world(). single(world) возвращает Result: Ok, если такая сущность ровно одна, и ошибку, если их ноль или больше.
Второй способ нужен, когда сущность создаёт сам тест. Тогда он запоминает её номер, Entity, и потом спрашивает компонент по номеру. Допиши этот тест в hello/tests/stand.rs, рядом с первым: все use там уже есть.
#[test]
fn second_square_from_test() {
let mut app = App::new();
app.add_plugins((MinimalPlugins, HelloPlugin))
.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_millis(250)));
let back = app.world_mut().spawn((Square { speed: -30.0 }, Transform::from_xyz(100.0, 0.0, 0.0))).id();
app.update();
for _ in 0..8 {
app.update();
}
let x = app.world().get::<Transform>(back).unwrap().translation.x;
assert!((x - 40.0).abs() < 1e-3, "за две секунды квадрат доезжает до 40, а доехал до {x}");
}
spawn(..).id() создаёт сущность прямо в мире и возвращает её номер. Квадрат стоит на x = 100 и едет влево со скоростью 30, за две секунды он доезжает до 40. get::<Transform>(back) возвращает Option: компонента у сущности может не оказаться.
Заметь: в этом мире теперь два квадрата, свой тест создал один, а Startup игры второй. Запрос single на таком мире падает:
called `Result::unwrap()` on an `Err` value: MultipleEntities("<Enable the debug feature to see the name>")
Поэтому сущность, которую создал тест, ищут по номеру, а не перебором.
Кот нашёл дыру
Четыре кадра по четверти секунды выглядят расточительно. Почему не один кадр на целую секунду?
Повтори за котом в коде:
use std::time::Duration;
use bevy::prelude::*;
use bevy::time::TimeUpdateStrategy;
use hello::{HelloPlugin, Square};
#[test]
fn square_drives_its_speed_in_a_second() {
let mut app = App::new();
app.add_plugins((MinimalPlugins, HelloPlugin))
.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs(1)));
app.update(); // первый кадр: Startup, время ещё не пошло
app.update(); // вся секунда одним кадром
let world = app.world_mut();
let x = world.query_filtered::<&Transform, With<Square>>().single(world).unwrap().translation.x;
assert!((x - 60.0).abs() < 1e-3, "за секунду квадрат проезжает 60, а проехал {x}");
}
за секунду квадрат проезжает 60, а проехал 15
Пятнадцать, четверть от шестидесяти. Тест с кадрами по 250 мс проходит, тест с одним кадром в секунду падает, хотя секунда в обоих одна.
Дыра в том, что у Bevy двое часов. Time<Real> идут по-настоящему. Time<Virtual> это игровые часы, их и получают системы через Res<Time>. Игровые часы за один кадр проходят не больше max_delta, а по умолчанию это 250 мс. Кадр длиннее Bevy обрезает: секунда на стенде прошла как четверть.
Зачем движку врать? Представь, что ноутбук уснул на час с открытой игрой. Проснувшись, честные часы скажут: кадр длился час. Мяч за такой кадр улетит за пределы поля, сквозь все стены. Bevy предпочитает потерять это время и сделать вид, что прошла четверть секунды. Для стенда отсюда правило: секунда набирается кадрами не длиннее 250 мс. Поменять предел можно через set_max_delta, но стенды курса его не трогают.
Число в сообщении
Красный тест на стенде отличается от красного теста «в лоб» одним: число в сообщении каждый раз одно и то же, и по нему можно рассуждать. Стенд из шаблона пишет в сообщение, сколько проехал квадрат, и это число стоит прочитать раньше, чем код. Три таких числа ты уже видел за урок, четвёртое встретишь, если забудешь про первый кадр:
| Квадрат проехал | О чём это говорит |
|---|---|
| около 0,005, каждый раз по-разному | часы идут по машине: забыт TimeUpdateStrategy |
| 0 | система ни разу не отработала на кадре ненулевой длины: стоит в Startup или не зарегистрирована |
| 15 вместо 60 | кадр длиннее 250 мс, и Bevy его обрезал |
| 45 вместо 60 | кадров на один меньше: забыто, что первый кадр время не двигает |
Что проверяет раннер, а что ты у себя
Раннер сайта собирает такой же стенд для каждой задачи: MinimalPlugins, твой плагин, время руками. Дальше тест двигает кадры и смотрит на компоненты и ресурсы. Клавиатуры у раннера нет, нажатия тест кладёт в ресурс сам, и это будет в двенадцатом уроке. Случайность в автоматах идёт от генератора с зерном, которое задаёт тест, чтобы матч каждый раз игрался одинаково.
| Раннер на сайте | Ты у себя |
|---|---|
| где стоит и куда едет каждая сущность | как это выглядит |
| счёт, жизни, состояние игры | плавно ли едет картинка |
| гол засчитан ровно один раз | слышен ли удар |
| матч с одним зерном играется одинаково | интересно ли играть |
Левая колонка это механика из второго урока: то, что записано в коде и проверяется числами. Правая колонка ближе к эстетике, и её никакой тест не поймает. Поэтому после каждого этапа ты запускаешь автомат у себя, а в конце раздела в него играют завсегдатаи. cargo test -p hello в шаблоне гоняет у тебя тот же стенд, что раннер на сайте.
Разминка
На стенде стоит тележка Cart со своей скоростью. Допиши систему drive: каждая тележка едет вдоль x на speed единиц в секунду. Тест ставит стенд из этого урока и набирает секунду кадрами по 250 и по 100 мс: длиннее Bevy не пропустит.
Практика
Дальше
Палыч смотрит на зелёную строку, хмыкает и убирает стенд Аркадьича обратно на полку. В подсобку заходит Варя. Она ставит на верстак латунный жетон с кометой.
В следующем уроке начнётся «Ракетка»: ты разберёшь, из чего состоит кадр игры, и поставишь на поле две ракетки и мяч.