Раздел 47 · Геймдев: зал «Комета»

Стенд без окна

middle~35 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

В подсобке пахнет канифолью. Палыч снимает с верхней полки деревянный ящик и ставит его на верстак. Внутри гнездо под плату, пучок щупов на проводах, ряд маленьких лампочек и две ручки: кнопка «ТАКТ» и переключатель с тремя положениями, «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 не пропустит.

Практика

Дальше

Палыч смотрит на зелёную строку, хмыкает и убирает стенд Аркадьича обратно на полку. В подсобку заходит Варя. Она ставит на верстак латунный жетон с кометой.

В следующем уроке начнётся «Ракетка»: ты разберёшь, из чего состоит кадр игры, и поставишь на поле две ракетки и мяч.