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