Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Работа с 2d анимациями (Actors, Unity) #2

Open
PixeyeHQ opened this issue Sep 30, 2019 · 1 comment
Open

Работа с 2d анимациями (Actors, Unity) #2

PixeyeHQ opened this issue Sep 30, 2019 · 1 comment

Comments

@PixeyeHQ
Copy link
Owner

@PixeyeHQ PixeyeHQ commented Sep 30, 2019

Трудно представить современную игру без анимаций. Сегодня я расскажу о том как работаю с 2д анимациями на Юнити через фреймворк Actors. Описанный мною подход реализуется легко на любых движках и языках. Ну а проект на юнити можно скачать отсюда.

Проблема

Зачем свое решение писать если в Unity уже есть Mecanim? Я много работаю с 2д анимациями и могу ответственно сказать, что использовать меканим для 2д это как стрелять из пушки по воробьям. Много ассетов,много абстракций, много оверхеда, много гибкости и мало смысла.

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

  1. Добавить animator на объект
  2. Сделать animator controller ( особенно жесть если анимация всего 1 или 2 ).
  3. Понять, что лучше для этих целей использовать component animation. Который Legacy. Который в общем-то не дает нам ничего кроме массива анимаций и проигрывания. Да еще и с лишними вызовами методов со стороны движка хрен знает чего и для чего.
    Допускается что мы будем использовать и Animator Controller и Component Animation и работать уже с двумя логическими единицами
  4. Нужно создать anim clip и загрузить туда свою 2д анимацию. Кто этим никогда серьезно не занимался не прелставляет какой это гемор. Спасает разве что PowerSprite Animator от Powerhoof.
  5. Настроить animator controller закончив это дело настоящим итальянским болоньезе,с кучей второстепенных скриптов и невнятной логики.
  6. Goto 1. и так пока весь проект незавалится кучей бесмысленных ассетов с animator и anim clip.

БОЛОНЬЕЗ НЯМ

БОЛОНЕЗ

Я не говорю, что mecanim плох, я говорю вот об этом:

шкала

Описываем задачу

Чертить тонны схем и из какого места должны вылазить методы не моё , но прежде чем погрузиться в код надо хотя бы примерно представлять что мы будем делать.

  1. Мы создаем систему которая будет работать БЕЗ юнити аниматора и анимклипов.
  2. Нам нужно иметь возможность загружать несколько анимаций для объекта.
  3. Нам нужен функционал позволяющий проигрывать цепочку анимаций. Хотя бы цепочку из двух анимаций.
  4. Было бы неплохо при вызове анимации получить назад время на проигрывание выбранной анимации.
  5. Нам нужно иметь возможность работать с анимациями через скрипты если это потребуется.
  6. Простое api чтобы назначать и сбрасывать анимации у сущностей.
  7. Работает из Actors разумеется.

Что мне нравится в системах анимаций так это то, что их можно сделать какими угодно сложными или простыми.

В конечном итоге у нас должно получиться что-то такое:

	sealed class ProcessorGame : Processor, ITick
	{
		public ent entity_hero;

		public ProcessorGame()
		{
                        // находим на сцене GameObject и создаем для него сущность.
			entity_hero = Entity.CreateFor("Obj Lopatnik");

                        // даем ему два компонента.
			var cAnimator = entity_hero.Set<ComponentAnimator>();
			var cRenderer = entity_hero.Set<ComponentRenderer>();

			// Component Renderer
			cRenderer.source = entity_hero.GetMono<SpriteRenderer>();

			// Component Animator
			cAnimator.map   = game.locals.animations_pawn_shovel; // анимации для лопатника
			cAnimator.guide = scripts.AnimatorGuidePawn.Instance; // animator guide для юнитов
			cAnimator.frame = 0;

		}

		public void Tick(float delta)
		{
			if (Input.GetKeyDown(KeyCode.Alpha1))
			{
                                // начнет проигрывать анимацию бесконечно
				entity_hero.animation(states.anim.walk);
			}

			if (Input.GetKeyDown(KeyCode.Alpha2))
			{
                                // начнет собирать. Проиграет анимацию 4 раза 
				entity_hero.animation(states.anim.grab, 4);
			}

			if (Input.GetKeyDown(KeyCode.Alpha3))
			{
                               // проиграет анимацию 1 раз.
				entity_hero.animation(states.anim.walk, states.anim.once);
			}

			if (Input.GetKeyDown(KeyCode.Space))
			{
                                // сбросит текущую анимацию и возьмет подходящую анимацию из
                               // animator guide
				entity_hero.animationReset();
			}
		}
	}

Кодим!

Я не следую общепринятым нотациям стиля для C#, пишите как вам больше нравится.

sequence

По-русски последовательность. Она и в Африке последовательность. Это аналог animclip
Здесь указываем массив спрайтов, ID следующей анимации если такая есть и даем возможность вытащить спрайт по индексу. Обычно я не люблю пользоваться свойствами в C#, но тут оно красиво позволяет обратиться к спрайту sequence[index]

public struct sequence
	{
		public Sprite[] sprites;
		public int animation_next;
		public ref Sprite this[int index] => ref sprites[index];
	}

sequences

Итак, у нас есть данные анимации, но нам надо их где-то хранить и обращаться к ним по ключу.
Кажется, словари C# идеальный кандидат на контейнер наших анимаций но...зачем если задачу можно решить проще? Массив с перебором гораздо компактнее и отлично подойдет для нашей несложной 2д игры. 100 анимаций никто ведь добавлять на объект все равно не будет.

	public class sequences
	{
		sequence[] elements = new sequence[3];
		int[] keys = new int[3];
		int length;

		public ref sequence this[int index]
		{
			get
			{
				for (int i = 0; i < length; i++)
				{
					if (keys[i] == index)
						return ref elements[i];
				}

				#if UNITY_EDITOR
				Debug.LogError($"there is no animation with id {index}");
				#endif

				return ref elements[0];
			}
		}

		public void Add(int key, in sequence sequence)
		{
			if (elements.Length == length)
			{
				Array.Resize(ref elements, length + 2);
				Array.Resize(ref keys, length + 2);
			}

			keys[length]       = key;
			elements[length++] = sequence;
		}
	}

anim

Нам нужны ID для анимаций. Я предпочитаю использовать константы, но это так же легко делается через enum.

	public struct anim
	{
		public const int none = 0;
		public const int idle = 1;
		public const int walk = 2;
		public const int grab = 3;
	}

ComponentAnimator

Это компонент для екс. В общем-то это то что мы будем вешать на наши сущности.
Здесь нам интересен AnimatorGuide. Именно он позволит тонко работать с анимациями и добавлять к ним "события" и "скрипты".

	public class ComponentAnimator
	{
		public AnimatorGuide guide = AnimatorGuide.Default; // логика анимаций если нужна

		public sequences map = new sequences(); // контейнер анимаций

		public int frame; // кадр
		public int animation_next; // id анимации
		public int times; // сколько раз нужно проиграть анимацию

		public float animation_time; // сколько занимает времени анимация

		public bool overriding; // включено ли принудительное проигрывание анимации
		public bool pause; // стоит ли анимация на паузе
	}

AnimatorGuide

AnimatorGuide это аналог Mecanim в коде. С помощью animator guide можно легко настроить логику для базовых анимаций. Например все юниты проигрывают idle если скорость = 0 или меняют анимацию на бег если скорость выше 0.

public abstract class AnimatorGuide
	{
		public static AnimatorGuide Default = new AnimatorGuideDefault();
		public abstract void handle(ent entity, ComponentAnimator cAnimator, float delta);
	}

	public abstract class AnimatorGuide<T> : AnimatorGuide where T : new()
	{
		public static T Instance = new T();
	}

	sealed class AnimatorGuideDefault : AnimatorGuide
	{
		public override void handle(ent entity, ComponentAnimator cAnimator, float delta)
		{
		}
	}

AnimatorGuide по умолчанию не делает ничего. Напишем AnimatorGuidePawn который прикажет всем сущностям у которых он есть стоять на месте. Каждый раз когда мы создаем новую сущность она начнет проигрывать idle. Если мы в коде игры ( например в АИ скриптах ) перезапишем анимацию, то cAnimator.overriding не даст работать методу handle в AnimatorGuide. Так же нам не придется создавать по копии класса AnimatorGuidePawn для каждого юнита. Можно будет просто повесить один и тот же AnimatorGuidePawn через Instance.

	sealed class AnimatorGuidePawn : AnimatorGuide<AnimatorGuidePawn>
	{
		public override void handle(ent entity, ComponentAnimator cAnimator, float delta)
		{
			if (cAnimator.overriding) return;
			cAnimator.animation_next = states.anim.idle;
		}
	}

ProcessorAnimator

Создаем процессор ( или еще их называют системами ) который будет обрабатывать все сущности с нашими компонентами ComponentAnimator и ComponentRenderer. ComponentRenderer просто хранит ссылку на SpriteRenderer.

game.locals.time_between_frames хранит время через которое нам надо обновлять кадры. В целях простоты мы не делаем локального времени для каждой отдельной секвенции, однако добавить это очень просто.

sealed class ProcessorAnimator : Processor<ComponentAnimator, ComponentRenderer>, ITick
	{
		float time;

		public void Tick(float delta)
		{
                       // запускаем наш "mecanim"
			foreach (ent entity in source)
			{
				var cAnimator = entity.ComponentAnimator();
				cAnimator.guide.handle(entity, cAnimator, delta);
			}

                       // меняем кадры 
			if ((time += delta) < game.locals.time_between_frames) return;
			time -= game.locals.time_between_frames;

			foreach (ent entity in source)
			{
				var cAnimator = entity.ComponentAnimator();
				var cRenderer = entity.ComponentRenderer();

				if (cAnimator.pause) continue;

				ref var sequence = ref cAnimator.map[cAnimator.animation_next];

				if (cAnimator.frame == sequence.sprites.Length)
				{
					if (--cAnimator.times <= 0)
					{
						if (sequence.animation_next != default)
						{
							cAnimator.animation_next = sequence.animation_next;
							sequence                 = ref cAnimator.map[cAnimator.animation_next];
						}
						else cAnimator.overriding = false;
					}

					cAnimator.frame = 0;
				}


				cRenderer.source.sprite = sequence.sprites[cAnimator.frame++];
			}
		}
	}

API

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

		public static float animation(in this ent entity, int animation_id, int times = anim.loop, int frame = 0)
			{
				var cAnimator = entity.ComponentAnimator();
				var cRenderer = entity.ComponentRenderer();

				cAnimator.times = times;

				if (cAnimator.animation_next == animation_id) return cAnimator.animation_time;

				ref var sequence = ref cAnimator.map[animation_id];
				cAnimator.frame          = frame == anim.random_frame ? Random.Range(0, sequence.sprites.Length) : frame;
				cRenderer.source.sprite  = sequence[cAnimator.frame];
				cAnimator.animation_next = animation_id;
				cAnimator.overriding     = true;
				cAnimator.animation_time = times * sequence.sprites.Length * game.locals.time_between_frames - cAnimator.frame * game.locals.time_between_frames;
				return cAnimator.animation_time;
			}

anim.loop и anim.random_frame просто удобные константы чтобы указать что нам нужно сыграть анимацию бесконечно раз или нам нужен случайный кадр. Эти контанты добавляем к структуре anim```

	public struct anim
	{
		public const int random_frame = -1;
		public const int once = 1;
		public const int loop = int.MaxValue;

		public const int none = 0;
		public const int idle = 1;
		public const int walk = 2;
		public const int grab = 3;
	}

Последний метод который нам понадобится это animationReset Мы просто сбрасываем кадры до нуля и говорим, что больше не перезаписываем анимацию чтобы наш animator guide мог решить сам какую анимацию нужно запустить.

	public static void animationReset(in this ent entity)
			{
				var cAnimator = entity.ComponentAnimator();

				if (!cAnimator.overriding) return;

				cAnimator.overriding = false;
				cAnimator.frame      = 0;
				cAnimator.times      = 1;
			}

Загрузка анимаций

Делаем все из кода. Просто и быстро. Box.Load это обертка Resources.Load в фреймворке с возможностью кеширования. Последняя секвенция интересная. Мы в ней пишем, что после проигрывания анимации Grab нужно проиграть анимацию Walk

static class locals
		{
			public const string path_spr = "Content/Sprites/";
			public const float time_between_frames = 0.07f;

			public static sequences animations_pawn_shovel;

			public static void setup()
			{
				animations_pawn_shovel = new sequences();

				animations_pawn_shovel.Add(states.anim.idle, new sequence()
				{
					sprites = new[]
					{
						Box.Load<Sprite>($"{path_spr}tex_digger_idle1_01"),
						Box.Load<Sprite>($"{path_spr}tex_digger_idle1_02"),
						Box.Load<Sprite>($"{path_spr}tex_digger_idle1_03"),
						Box.Load<Sprite>($"{path_spr}tex_digger_idle1_04"),
						Box.Load<Sprite>($"{path_spr}tex_digger_idle1_05"),
						Box.Load<Sprite>($"{path_spr}tex_digger_idle1_04"),
						Box.Load<Sprite>($"{path_spr}tex_digger_idle1_03"),
						Box.Load<Sprite>($"{path_spr}tex_digger_idle1_02"),
					}
				});

				animations_pawn_shovel.Add(states.anim.walk, new sequence()
				{
					sprites = new[]
					{
						Box.Load<Sprite>($"{path_spr}tex_digger_walk_01"),
						Box.Load<Sprite>($"{path_spr}tex_digger_walk_02"),
						Box.Load<Sprite>($"{path_spr}tex_digger_walk_03"),
						Box.Load<Sprite>($"{path_spr}tex_digger_walk_04"),
						Box.Load<Sprite>($"{path_spr}tex_digger_walk_05"),
						Box.Load<Sprite>($"{path_spr}tex_digger_walk_06"),
						Box.Load<Sprite>($"{path_spr}tex_digger_walk_07"),
						Box.Load<Sprite>($"{path_spr}tex_digger_walk_08"),
					}
				});


				animations_pawn_shovel.Add(states.anim.grab, new sequence()
				{
                                       // отыграет states.anim.walk после states.anim.grab.
					animation_next = states.anim.walk,
					sprites = new[]
					{
						Box.Load<Sprite>($"{path_spr}tex_digger_grab_01"),
						Box.Load<Sprite>($"{path_spr}tex_digger_grab_02"),
						Box.Load<Sprite>($"{path_spr}tex_digger_grab_03"),
						Box.Load<Sprite>($"{path_spr}tex_digger_grab_04"),
						Box.Load<Sprite>($"{path_spr}tex_digger_grab_05"),
						Box.Load<Sprite>($"{path_spr}tex_digger_grab_06"),
					}
				});
			}
		}

Результат

У нас на руках простое, легкорасширяемое решение по анимациям. Зарисуем же чтонибудь на экране. В самом начале поста я показывал как у нас это будет выглядить в скриптах игры.
Интереснее всего этот отыгрыш. После четырёх отыгрышей anim.grab отыграется anim.walk так как мы ее указали в настройках анимации для этого юнита.

if (Input.GetKeyDown(KeyCode.Alpha2))
{
        // начнет собирать. Проиграет анимацию 4 раза 
	ntity_hero.animation(states.anim.grab, 4);
}

Animation

Проект на unity можно скачать здесь.
Это не самая совершенная система, но ее не сложно дополнить нужными фишками. Но хочется особенно подчеркнуть : keep it simple, code fast.

А как справляетесь с анимациями вы? Делитесь мыслями и идеями в комментариях, будет интересно почитать :)

@PixeyeHQ PixeyeHQ changed the title Делаем Animator для 2D в Unity Пишем Animator для 2D [ Actors, Unity ] Sep 30, 2019
@PixeyeHQ PixeyeHQ changed the title Пишем Animator для 2D [ Actors, Unity ] Работа с анимациями в 2D [ Actors, Unity ] Sep 30, 2019
@PixeyeHQ PixeyeHQ changed the title Работа с анимациями в 2D [ Actors, Unity ] Работа с 2d анимациями (Actors, Unity) Sep 30, 2019
@AGulev
Copy link

@AGulev AGulev commented Sep 30, 2019

Непосредственно для спрайтовой анимации писал свои контроллеры (потом под эту же систему завернул рантайм спайна и юнити анимации). Контроллеры от юнити очень долго инициализировались, а мне нужно было иметь довольно много объектов, которые переодически нужно было отключать. Там где обойтись было нельзя, то тоже использовал legacy компонент.

А для анимаций в целом, писал свою систему похожую на actions в cocos2d. Чем удобно, что в них можно завернуть все что угодно, что происходит на протяжении некоторого времени/асинхронно. Так у меня там были твины, спрайтовые и спайновые анимации, паузы, ожидания инпута и http реквесты и т.д.
Кроме того, для этой системы легко пишутся action для последовательного исполнения, для параллельного с ожиданием последнего и т.д. В итоге получалась довольно мощная система, которая позволяла довольно просто выстраивать сложные последовательности, комбинировать работу аниматоров и процедурные анимации, что особенно важно для различных казуальных игр.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Linked pull requests

Successfully merging a pull request may close this issue.

None yet
2 participants