Анимация

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

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

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

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

  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
#[derive(Component)]
#[storage(VecStorage)]
pub struct Renderable {
    paths: Vec<String>,
}

impl Renderable {
    pub fn new_static(path: String) -> Self {
        Self { paths: vec![path] }
    }

    pub fn new_animated(paths: Vec<String>) -> Self {
        Self { paths }
    }
}
}

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


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

}

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


#![allow(unused)]
fn main() {
// components.rs
impl Renderable {
    pub fn new_static(path: String) -> Self {
        Self { paths: vec![path] }
    }

    pub fn new_animated(paths: Vec<String>) -> Self {
        Self { paths }
    }

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

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


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

    //...

    pub fn path(&self, path_index: usize) -> String {
        // If we get asked for a path that is larger than the
        // number of paths we actually have, we simply mod the index
        // with the length to get an index that is in range.
        self.paths[path_index % self.paths.len()].clone()
    }
}
}

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

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


#![allow(unused)]
fn main() {
// entities.rs
pub fn create_player(world: &mut World, position: Position) {
    world
        .create_entity()
        .with(Position { z: 10, ..position })
        .with(Renderable::new_animated(vec![
            "/images/player_1.png".to_string(),
            "/images/player_2.png".to_string(),
            "/images/player_3.png".to_string(),
        ]))
        .with(Player {})
        .with(Movable)
        .build();
}
}

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


#![allow(unused)]
fn main() {
// entities.rs
pub fn create_wall(world: &mut World, position: Position) {
    world
        .create_entity()
        .with(Position { z: 10, ..position })
        .with(Renderable::new_static("/images/wall.png".to_string()))
        .with(Wall {})
        .with(Immovable)
        .build();
}

}

Время

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

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

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

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


#![allow(unused)]
fn main() {
// resources.rs
#[derive(Default)]
pub struct Time {
    pub delta: Duration,
}
}

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


#![allow(unused)]
fn main() {
// resources.rs
pub fn register_resources(world: &mut World) {
    world.insert(InputQueue::default());
    world.insert(Gameplay::default());
    world.insert(Time::default());
}
}

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


#![allow(unused)]
fn main() {
// main.rs
impl event::EventHandler<ggez::GameError> for Game {
    fn update(&mut self, context: &mut Context) -> GameResult {
        // Run input system
        {
            let mut is = InputSystem {};
            is.run_now(&self.world);
        }

        // Run gameplay state system
        {
            let mut gss = GameplayStateSystem {};
            gss.run_now(&self.world);
        }

        // Get and update time resource
        {
            let mut time = self.world.write_resource::<Time>();
            time.delta += timer::delta(context);
        }

        Ok(())
    }
}

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

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

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


#![allow(unused)]
fn main() {
// rendering_system.rs
impl RenderingSystem<'_> {
    //...
    pub fn get_image(&mut self, renderable: &Renderable, delta: Duration) -> Image {
        let path_index = match renderable.kind() {
            RenderableKind::Static => {
                // We only have one image, so we just return that
                0
            }
            RenderableKind::Animated => {
                // If we have multiple, we want to select the right one based on the delta time.
                // First we get the delta in milliseconds, we % by 1000 to get the milliseconds
                // only and finally we divide by 250 to get a number between 0 and 4. If it's 4
                // we technically are on the next iteration of the loop (or on 0), but we will let
                // the renderable handle this logic of wrapping frames.
                ((delta.as_millis() % 1000) / 250) as usize
            }
        };

        let image_path = renderable.path(path_index);

        Image::new(self.context, image_path).expect("expected image")
    }
}
}

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


#![allow(unused)]
fn main() {
// rendering_system.rs
impl<'a> System<'a> for RenderingSystem<'a> {
    // Data
    type SystemData = (
        Read<'a, Gameplay>,
        Read<'a, Time>,
        ReadStorage<'a, Position>,
        ReadStorage<'a, Renderable>,
    );

    fn run(&mut self, data: Self::SystemData) {
        let (gameplay, time, positions, renderables) = data;

        // Clearing the screen (this gives us the backround colour)
        graphics::clear(self.context, graphics::Color::new(0.95, 0.95, 0.95, 1.0));

        // Get all the renderables with their positions and sort by the position z
        // This will allow us to have entities layered visually.
        let mut rendering_data = (&positions, &renderables).join().collect::<Vec<_>>();
        rendering_data.sort_by_key(|&k| k.0.z);

        // Iterate through all pairs of positions & renderables, load the image
        // and draw it at the specified position.
        for (position, renderable) in rendering_data.iter() {
            // Load the image
            let image = self.get_image(renderable, time.delta);

            //...
            
        }

        //...

    }
}
}

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

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

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

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

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

Sokoban animations

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