Анимация

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

Что такое анимация?

Анимация — это набор кадров, проигрываемых через определённые интервалы. Именно это и создаёт иллюзию движения. Почти как видео (видео — тоже просто последовательность изображений), только с гораздо более низкой частотой кадров.

Например, чтобы заставить игрока моргать, нам нужно три кадра в анимации:

  1. игрок с открытыми глазами,
  2. игрок с прикрытыми глазами,
  3. игрок с закрытыми глазами.

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

Но есть пара загвоздок:

  • Наборы анимаций должны создаваться с учётом задуманной частоты кадров — в нашем случае это будет 250 миллисекунд, то есть нам нужно 4 кадра в секунду.
  • Анимации должны сочетаться друг с другом. Представьте, что у нас есть два игрока с разным цветом глаз. Мы должны убедиться, что упомянутые выше три кадра из разных наборов сочетаются друг с другом, иначе наши игроки будут моргать с разной частотой.
  • Разработка анимации с большим количеством кадров — тяжёлая работа, поэтому мы будем использовать только ключевые кадры, чтобы сделать её простой.

Как это будет работать?

Так как это будет работать в нашей игре? Нам нужно:

  1. Переписать отрисовываемый компонент, чтобы использовать множество кадров. Мы можем создать новый компонент, который поддерживает отрисовку анимаций, и оставить тот, что уже есть, для статичных изображений, но пока объединим эти две логики в одном.
  2. Изменить содержимое сущности игрока, чтобы иметь возможность обрабатывать множество кадров.
  3. Добавить контроль времени в наш цикл отрисовки — позже мы обсудим это в деталях, поэтому не волнуйтесь, если что-то непонятно.
  4. Заставить нашу систему отрисовки учитывать общее число кадров и время, а также кадр, который должен в нужное время быть отрисован.

Наборы

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

Player 1 Player 2 Player 3

├── resources
│   └── images
│       ├── box_blue.png
│       ├── box_red.png
│       ├── box_spot_blue.png
│       ├── box_spot_red.png
│       ├── floor.png
│       ├── player_1.png
│       ├── player_2.png
│       ├── player_3.png
│       └── wall.png

Отрисовка

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

Также давайте добавим две новые функции, чтобы создать два типа отрисовываемых объектов: с одним путём и с несколькими. Эти две функции — ассоциированные функции, потому что они ассоциированы со структурой Renderable. Они являются эквивалентом статических функций в других языках, потому что не оперируют экземплярами объектов. Вы заметили, что эти функции не используют &self или &mut self в качестве первого аргумента? Это значит, что мы можем вызвать их прямо из контекста структуры, а не из её экземпляра. Они также похожи на функции-фабрики, потому что скрывают логику и требуют валидации перед тем, как создать объект.

ЕЩЁ: Узнать больше про ассоциированные функции вы можете здесь.


#![allow(unused)]
fn main() {
// components.rs
pub enum RenderableKind {
    Static,
    Animated,
}
// ANCHOR_END: renderable_kind

// ANCHOR: renderable_impl
impl Renderable {
    // ANCHOR: renderable_new_fn
    pub fn new_static(path: &str) -> Self {
        Self {
            paths: vec![path.to_string()],
        }
    }
        }
}

Далее, нам нужен способ, чтобы определять, анимированный это объект или статичный. Мы можем оставить переменные путей публичными и позволить системе отрисовки подсчитывать длину пути — и, основываясь на нем, делать какие-то выводы. Но есть более правильный способ. Мы можем создать перечисление для типов отрисовки и добавить метод получения этого типа в объект. Таким образом мы скрываем логику типа отрисовки в объекте отрисовки и можем держать переменные путей приватными. Вы можете добавить эти строчки куда угодно в components.rs, но лучше это сделать следом за объявлением renderable.


#![allow(unused)]
fn main() {
// components.rs
    paths: Vec<String>,
}
// ANCHOR_END: renderable

// ANCHOR: renderable_kind
}

Теперь давайте добавим функцию, которая будет определять вид отрисовываемого объекта, основываясь на внутреннем пути.


#![allow(unused)]
fn main() {
// components.rs
// ANCHOR: renderable_impl
impl Renderable {
    // ANCHOR: renderable_new_fn
    pub fn new_static(path: &str) -> Self {
        Self {
            paths: vec![path.to_string()],
        }
    }

    pub fn new_animated(paths: Vec<&str>) -> Self {
        Self {
            paths: paths.iter().map(|p| p.to_string()).collect(),
        }
    }
    // ANCHOR_END: renderable_new_fn
    // ANCHOR_END: renderable_impl
        }
}

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


#![allow(unused)]
fn main() {
// components.rs
// ANCHOR: renderable_impl

    //...

    // ANCHOR: renderable_kind_fn
    pub fn kind(&self) -> RenderableKind {
        match self.paths.len() {
            0 => panic!("invalid renderable"),
            1 => RenderableKind::Static,
            _ => RenderableKind::Animated,
        }
}

Создание сущностей

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


#![allow(unused)]
fn main() {
// entities.rs
            "/images/player_3.png",
        ]),
        Player {},
        Movable {},
    ))
}

pub fn create_gameplay(world: &mut World) -> Entity {
    world.spawn((Gameplay::default(),))
}

// ANCHOR: create_time
pub fn create_time(world: &mut World) -> Entity {
}

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


#![allow(unused)]
fn main() {
// entities.rs
    world.spawn((
        Position { z: 10, ..position },
        Renderable::new_static("/images/wall.png"),
        Wall {},
        Immovable {},
    ))
}

pub fn create_floor(world: &mut World, position: Position) -> Entity {
    world.spawn((
}

Время

Ещё один компонент, который нам понадобится, — это отслеживание времени. При чем здесь время и как оно влияет на частоту кадров? Главная идея вот в чем: ggez контролирует частоту вызовов системы отрисовки, а это зависит от частоты кадров — которая, в свою очередь, зависит от того, как много работы мы выполняем в каждой итерации цикла игры. Это нам контролировать не под силу, и в секунду у нас может выйти 60 итераций, или 57, или даже 30. Это значит, что у нас не получится использовать для нашей анимации частоту кадров — вместо этого нам нужно сделать её зависимой от времени.

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

ЕЩЁ: Узнать больше о дельте времени, частоте кадров и цикле игры можно здесь, здесь или здесь.

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


#![allow(unused)]
fn main() {
// resources.rs
{{#include ../../../code/rust-sokoban-c03-02/src/resources.rs:45:48}}
}

И не забудьте его зарегистрировать.


#![allow(unused)]
fn main() {
// resources.rs
{{#include ../../../code/rust-sokoban-c03-02/src/resources.rs:12:16}}
}

А теперь обновим это время в главном цикле игры. К счастью, ggez предоставляет функцию получения дельты времени, так что всё, что нам нужно делать, — просто накапливать её.


#![allow(unused)]
fn main() {
// main.rs

// ANCHOR: handler
impl event::EventHandler<ggez::GameError> for Game {
    // ANCHOR: update
    fn update(&mut self, context: &mut Context) -> GameResult {
        // Run input system
        {
            systems::input::run_input(&self.world, context);
        }

        // Run gameplay state
        {
            systems::gameplay::run_gameplay_state(&self.world);
        }

        // Get and update time resource
        {
            let mut query = self.world.query::<&mut crate::components::Time>();
            let time = query.iter().next().unwrap().1;
            time.delta += context.time.delta();
        }

}

Система отрисовки

Теперь изменим систему отрисовки. Мы будем получать тип из отрисовываемого объекта. Если он статичен, то мы просто используем первый кадр — иначе мы определяем, какой кадр нужен, основываясь на дельте времени.

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


#![allow(unused)]
fn main() {
// rendering_system.rs
{{#include ../../../code/rust-sokoban-c03-02/src/systems/rendering_system.rs:17}}
    //...
{{#include ../../../code/rust-sokoban-c03-02/src/systems/rendering_system.rs:34:54}}
}

И затем используем новую функцию get_image внутри функции run (нам также нужно добавить время в определение SystemData и в несколько импортов, но на этом — всё).


#![allow(unused)]
fn main() {
// rendering_system.rs
{{#include ../../../code/rust-sokoban-c03-02/src/systems/rendering_system.rs:57:81}}

            //...
            
{{#include ../../../code/rust-sokoban-c03-02/src/systems/rendering_system.rs:88}}

        //...

{{#include ../../../code/rust-sokoban-c03-02/src/systems/rendering_system.rs:97}}
{{#include ../../../code/rust-sokoban-c03-02/src/systems/rendering_system.rs:98}}
}

Анимация коробки

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

Box red 1 Box red 2 Box blue 1 Box blue 2

Подведём итоги

Это была большая глава, но я надеюсь, что вы не заскучали! Сейчас наша игра должна выглядеть так:

Sokoban animations

КОД: Увидеть весь код из данной главы можно здесь.