Dungeon Raiders - Дневник разработчика 1. Анимация или дьявол в мелочах

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

Аннотация: Ваш покорный слуга исповедует принцип свободной информации. Я работаю на бесплатном движке, пишу на бесплатном языке в бесплатной среде программирования, рисую модельки в бесплатной среде моделирования и учился всему на бесплатных туториалах на бесплатном YouTube. Лучшее, что я могу сделать, чтобы отблагодарить сообщество - не быть снобом и делиться своим опытом. Все ассеты - код, модели и т. д. и т. п. - доступны в репозитории на GitHub. Пользуйтесь, заимствуйте, только не списывайте точь-в-точь, чтобы не спалили :)

Само собой речь пойдет НЕ о расширении движка - под системой анимации я подразумеваю исключительно архитектуру классов и логику их связей, которая кажется мне довольно универсальной, так как помогает при реализации разных проектов. Ведь иногда в погоне за красивой картинкой у вас может получиться такой вот Супер-Босс:

<i>И это еще лайтовая версия!​</i>
И это еще лайтовая версия!​

Так что давайте разберемся с ним по фазам...

Вариативность анимации

Я верю, что львиная доля положительных впечатлений от медиа-продукта (каковым является и видео-игра) зависит от деталей, на которые, на первый взгляд, никто не будет обращать внимание. Например, повтор анимации. Поэтому в каждом проекте я стараюсь добиваться разнообразия:

С точки зрения игровой логики происходит одно и то же событие, но для Игрока то, что Герой чередует ноги при прыжке - уже выглядит живее.

Техническая задача здесь заключается в том, чтобы правильно разделить ответственность: Герой должен прыгнуть и ему все равно, какая из двух (трех, четырех, пятидесяти (sic!)) вариаций анимации будет проиграна.

Dungeon Raiders - Дневник разработчика 1. Анимация или дьявол в мелочах

Я реализую такой подход в следующем:

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

Класс UnitAnimationHandler - напротив, мало что знает о самой боевой единице, но за то в курсе состояния Animator'а и умеет воспроизводить и обрабатывать события анимации.

public void PlayAnimation(string tag) { AnimationClipGroup clips; switch (tag) { case "jumpStart": clips = jumpClips; break; default: clips = null; break; } if (clips != null) { int index; if (clips.allowRepeat) { index = Random.Range(0, clips.count); } else { var indexes = new List<int>(); for (int i = 0; i < clips.count; i++) if (i != clips.excluded) indexes.Add(i); index = indexes[Random.Range(0, indexes.Count)]; clips.excluded = index; } animator.SetInteger("index", index); } animator.SetTrigger(tag); }

AnimationClipGroup - это вспомогательный класс-контейнер, который хранит информацию о том, сколько вариаций анимации для данного действия существует и можно ли повторять эти вариации подряд?

[System.Serializable] public class AnimationClipGroup : object { [Range(1,5)][Tooltip("Количество анимационных клипов в группе")] public int count; [Tooltip("Повторы одинаковых анимаций разрешены?")] public bool allowRepeat; [HideInInspector] // Индекс последней проигранной анимации из этой группы public int excluded = -1; }

Таким образом, мы можем определить сколь угодно много "групп" - помимо прыжков это может быть атака и бездействие (idle).

События анимации

Признаться, я был счастлив, когда узнал о существовании событий анимации в Unity. Этот инструмент, казалось, развязывает руки. Ни о какой интерактивности и отзывчивости не может идти и речи, если вы пытаетесь отмерить воспроизведение звука атаки и вызов метода нанесения урона по времени "на глаз" - то ли дело привязка этих событий к конкретному кадру!

Dungeon Raiders - Дневник разработчика 1. Анимация или дьявол в мелочах

Но очень скоро я столкнулся с архитектурной проблемой: с точки зрения игровой логики Атака - лишь одна из десятков (возможно, сотен) способностей - а значит, нужно завести классу AnimHandler столько же десятков (тысяч) методов-событий анимации? Конечно же нет.

Вместо этого я создал универсальные события, которые подходят для описания процесса применения любой способности:

public class UnitAnimationHandler : MonoBehaviour { public bool animEventStart = false; public bool animEventCastStart = false; public bool animEventCast= false; public bool animEventCastEnd = false; public bool animEventEnd = false; public void EventStart() { animEventStart = true; } public void EventCastStart() { animEventCastStart = true; } public void EventCast() { animEventCast = true; } public void EventCastEnd() { animEventCastEnd = true; } public void EventEnd() { animEventEnd = true; } }

Как видно на фрагменте кода - события анимации просто устанавливают некие логические переменные ("флаги", если пользоваться терминами классического программирования). Эти флаги "прослушивают" Способности:

public class Skill : MonoBehaviourExtended { protected virtual void OnUpdate() { switch (state) { //... case SkillStates.casting: castTimer += Time.deltaTime; if (CatchAnimationFlag(AnimationEvents.castStart)) CastStarted(); if (CatchAnimationFlag(AnimationEvents.cast)) Cast(); //... break; } } }

Таким образом получается универсальная слабосвязанная система:

  1. Юнит (по команде Игрока или ИИ) приводит в действие Способность.
  2. Способность запрашивает проигрывание анимации по названию триггера.
  3. При проигрывании анимации устанавливаются универсальные флаги.
  4. Активная Способность мониторит наличие флагов и реагирует при их установке.

Вариации внешнего вида

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

Так модель зомби имеет 3 варианта внешнего вида головы, которые являются отдельными игровыми объектами:

Информация о вариациях внешнего вида хранится с помощью 2-ух вспомогательных классов:

[System.Serializable] public class MeshVariationGroup { public string label = "Body Variation"; public List<MeshVariation> meshes; } [System.Serializable] public class MeshVariation { public string label = "A"; public GameObject meshObject; [Range(0,1)] public float basicChance; }

Что выглядит в Инспекторе примерно так:

Dungeon Raiders - Дневник разработчика 1. Анимация или дьявол в мелочах

В итоге, при инициализации модели боевой единицы все объекты в группе мешей (кроме случайно выбранного), удаляются:

public class UnitAnimationHandler : MonoBehaviour { void RandomizeAppearance() { foreach (var group in randomMeshGroups) { var chances = new List<float>(); foreach (var variation in group.meshes) chances.Add(variation.basicChance); var index = MathUtilities.GetRandomIndexFromListOfChances(chances); foreach (var variation in group.meshes) if (variation == group.meshes[index] && variation.meshObject != null) variation.meshObject.SetActive(true); else if (variation.meshObject != null) Destroy(variation.meshObject); } } }

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

Когда-то и меня вела дорога приключений...

Игра ощущается живой, интерактивной и понятной, когда объекты ведут себя так же, как мы бы ожидали от них в реальном мире. Но при реализации игровых механик часто приходится идти на компромисс. Например, мы привыкли, что снаряды появляются в момент выстрела и исчезают при столкновении с целью.

"Но что если..." - подумал я - "...стрелы будут застревать в Герое, как в Скайриме?" Такие мелочи не свойственны казуальным играм, к каковым относится и данный проект, но я решил, что это будет красивой деталью.

Чтобы задать точки, к которым могут "прилепать" снаряды я создал класс-контейнер (т. е. класс, не содержащий логики):

public class StickPoint : MonoBehaviour { public Vector3 randomRotationDeltas; public Vector3 randomPositionDeltas; }

И пару списков для хранения ссылок на эти точки:

public class UnitAnimationHandler : MonoBehaviour { [Tooltip("Points to which missiles stick when unit is defended by shield for example")] public List<StickPoint> defendStickPoints; [Tooltip("Points to which missiles stick")] public List<StickPoint> defaultStickPoints; public List<Transform> stickedMissiles; }

Теперь можно сделать так, чтобы модель любого снаряда "прилипала" к юниту при нанесении урона:

public class Missile : MonoBehaviour { public float speed = 3; public float damage = 1; public float lifetime = 5; public GameObject hitEffect; public AudioClip hitSound; public GameObject model; public bool sticks; public float stickSizeDecrease = 0.15f; [HideInInspector] public Unit caster; private void OnTriggerEnter(Collider other) { var unit = other.GetComponent<Unit>(); if (unit) if (unit.isAlive && unit != caster) { Player.PlaySound(hitSound); var effect = Instantiate(hitEffect, unit.GetUnitPoint(UnitBodyPoints.chest).position, transform.rotation); if (sticks) StickTo(unit); unit.TakeDamage(damage, caster); Destroy(gameObject); } } void StickTo(Unit unit) { List<StickPoint> stickPoints = null; if (unit.isDefending) { if (unit.animHandler.defendStickPoints.Count > 0) stickPoints = unit.animHandler.defendStickPoints; } else { if (unit.animHandler.defaultStickPoints.Count > 0) stickPoints = unit.animHandler.defaultStickPoints; } if (stickPoints != null) { int index = Random.Range(0, stickPoints.Count); var targetPoint = stickPoints[index]; DecreaseSize(model); model.transform.SetParent(targetPoint.transform); model.transform.position = targetPoint.transform.position; AddRandomRotation(model, targetPoint.randomRotationDeltas); SetRandomPosition(model, targetPoint.randomPositionDeltas); unit.animHandler.stickedMissiles.Add(model.transform); } } }

Как видно в последней строчке - AnimationHandler сохраняет ссылки на Transform прилипших снарядов. Это нужно для того чтобы при исцелении персонажа зельем все эти стрелы отваливались:

public class Unit : MonoBehaviour { public void Heal(float amount) { AddHealth(amount); animHandler.ReleaseSticked(); } } public class UnitAnimationHandler : MonoBehaviour { public void ReleaseSticked() { foreach (var sticked in stickedMissiles) { sticked.SetParent(unit.block.transform); var body = sticked.GetComponent<Rigidbody>(); if (body) body.isKinematic = false; } stickedMissiles.Clear(); } }
88
3 комментария

спасибо за статью, советую, чтоб аниматор не превращался в такую ужасную вещь, стоит использовать BlendTrees (чтоб не писать логику переходов по int переменной, а заставить юнити сделать это за вас) и Sub-State Machines (объединить похожие состояния(такие как в вашем случае Jump-Float-JumpEnd) в одно состояние (допустим Jump), и в других местах продублировать сабстейтмашину и подменить на другие анимации), и тогда оно будет и выглядеть приятнее и настраивать станет удобнее

1
Ответить

Спасибо! Я обязательно попробую :)

Ответить

спасибо!

Ответить