Анимация
В этом разделе мы попробуем добавить анимации в нашу игру. Мы начнём с пары простых — но не стесняйтесь экспериментировать с более сложными. В этом вам помогут идеи из этого туториала. Мы добавим две анимации: моргания игрока и небольшого дрожания коробок на месте.
Что такое анимация?
Анимация — это набор кадров, проигрываемых через определённые интервалы. Именно это и создаёт иллюзию движения. Почти как видео (видео — тоже просто последовательность изображений), только с гораздо более низкой частотой кадров.
Например, чтобы заставить игрока моргать, нам нужно три кадра в анимации:
- игрок с открытыми глазами,
- игрок с прикрытыми глазами,
- игрок с закрытыми глазами.
Если мы проиграем эти три кадра один за другим, то заметим, что персонаж действительно будто моргает. Можете попробовать это прямо сейчас: откройте картинки и начните быстро между ними переключаться.
Но есть пара загвоздок:
- Наборы анимаций должны создаваться с учётом задуманной частоты кадров — в нашем случае это будет 250 миллисекунд, то есть нам нужно 4 кадра в секунду.
- Анимации должны сочетаться друг с другом. Представьте, что у нас есть два игрока с разным цветом глаз. Мы должны убедиться, что упомянутые выше три кадра из разных наборов сочетаются друг с другом, иначе наши игроки будут моргать с разной частотой.
- Разработка анимации с большим количеством кадров — тяжёлая работа, поэтому мы будем использовать только ключевые кадры, чтобы сделать её простой.
Как это будет работать?
Так как это будет работать в нашей игре? Нам нужно:
- Переписать отрисовываемый компонент, чтобы использовать множество кадров. Мы можем создать новый компонент, который поддерживает отрисовку анимаций, и оставить тот, что уже есть, для статичных изображений, но пока объединим эти две логики в одном.
- Изменить содержимое сущности игрока, чтобы иметь возможность обрабатывать множество кадров.
- Добавить контроль времени в наш цикл отрисовки — позже мы обсудим это в деталях, поэтому не волнуйтесь, если что-то непонятно.
- Заставить нашу систему отрисовки учитывать общее число кадров и время, а также кадр, который должен в нужное время быть отрисован.
Наборы
Добавим новые наборы анимаций для игрока. Они должны выглядеть так. Обратите внимание, что мы создали конвенцию именований для последовательности кадров. Это не обязательно, однако в дальнейшем облегчит задачу контроля последовательности кадров.
├── 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}} }
Анимация коробки
Теперь, когда мы узнали, как это сделать, применим тот же метод и для анимации коробок. Всё, что нам нужно, — это добавить новые наборы и поправить создание сущностей. После этого всё должно работать как надо. Вот набор, который использовала я, — не бойтесь переделать его на свой вкус!
Подведём итоги
Это была большая глава, но я надеюсь, что вы не заскучали! Сейчас наша игра должна выглядеть так:
КОД: Увидеть весь код из данной главы можно здесь.