Оглавление


Привет! Меня зовут Даниил, я занимаюсь саунд-дизайном (sound design) и пишу музыку. Это первая моя статья и по совместительству пилотная в планируемом цикле; поэтому, вероятно, она будет содержать дополнительную информацию, которую я планирую перенести в отдельную публикацию (если данная серия получит продолжение).

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

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

Примечание: предполагается что читатель знаком с базовыми принципами работы в FMOD и Unity.

Сейчас я планирую рассмотреть наиболее нетривиальные моменты с упором на процесс имплементации аудио. В противном случае статья может незаметно превратиться N-страничный нечитабельный талмуд. Все учебные проекты, о которых будет рассказано, находятся в свободном доступе и выполнены на Unity engine (Asset Store) и Unreal engine (Unreal Engine Marketplace).

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

Часть 1: FMOD иль Wwise - вот в чем вопрос

«FMOD иль Wwise - вот в чем вопрос»
«FMOD иль Wwise - вот в чем вопрос»

Сразу сбросим маски и не будем нагнетать ненужный саспенс – FMOD (учитывая, что он указан в названии, это было бы излишне). Почему FMOD? Здесь дело немного сложнее… Изначально я хотел поработать с Wwise и Unreal Engine, но судьба распорядилась иначе:

Но не будем о грустном! Давайте лучше осмелимся чуть глубже окунутся в мутные воды и узреть свет истины. Всем наверняка известно, что FMOD популярен в «Indie» среде, в то время как Wwise активно используется в «AAA» индустрии.

Игры на FMOD:
  • Kingdom Come Delivirance

  • Scorn

  • Cult of the lamb

  • Ori and the Blind Forest

  • Hades

  • Warhammer 40,000 Inquisitor – Martyr

  • Path of Exile

  • Deus Ex: Mankind Divided

  • StarCraft II

  • Dark Souls 3

  • World Of Warcraft

Игры на Wwise:
  • The Witcher 3: Wild Hunt

  • Cyberpunk 2077

  • Plague Tale

  • Baldurs Gate 3

  • Dark and Darker

  • Mad MAX

  • Fallout 5 StarField

  • Layers of Fear

  • Diablo 4

  • Mortal Kombat 11

  • The Texas Chain Saw Massacre

Примечание: С полным списком можно ознакомится тут (FMOD) и тут (Wwise)

Почему же так сложилось?

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

Разумеется, провести более детальное исследование этой темы было бы весьма полезным. Для того чтобы сделать это на должном уровне, мне необходимо приобрести больше опыта работы с FMOD и Wwise, глубже изучить возможности встроенного аудио и тенденции в выборе звуковых движков. Другими словами, информации для ознакомления достаточно много. Поэтому, не желая быть голословным, предлагаю отложить этот вопрос на будущее, а пока будем исходить из вышеупомянутой позиции.

Часть 2: Занимательный вечер, когда оно не «заработало из коробки»

Признаюсь честно — танцы с бубном меня не особо развлекают и поэтому, устанавливая что-либо, я настраиваю свои чакры на частоты космоса, в надежде, что всё заработает без лишних телодвижений. Но в тот злополучный вечер Unity был не в духе и выдал вот это:

Вот сижу я и понимаю, что в коллекцию пазлов добавился ещё один и теперь мне предстоит разобраться в первопричинах хаоса, наблюдаемого в окне консоли. Пожалуй, не буду останавливаться на процессе реинтеграции пакетов для 2d разработки и ручного переопределения рендеринга на URP – всё это достаточно банальные и ежедневные трудности, с которыми приходится сталкиваться в подобных ситуациях.

В результате количество ошибок сократилось до 9 строк:

Assets\2DGamekit\Utilities\Editor\RuleTileEditor.cs(13,23): error CS0433: The type 'RuleTile' exists in both 'Assembly-CSharp, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null' and 'Unity.2D.Tilemap.Extras, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null'
Assets\2DGamekit\Utilities\Editor\RuleTileEditor.cs(69,10): error CS0433: The type 'RuleTile' exists in both 'Assembly-CSharp, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null' and 'Unity.2D.Tilemap.Extras, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null'
Assets\2DGamekit\Utilities\Editor\RuleTileEditor.cs(150,50): error CS0433: The type 'RuleTile' exists in both 'Assembly-CSharp, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null' and 'Unity.2D.Tilemap.Extras, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null'
Assets\2DGamekit\Utilities\Editor\RuleTileEditor.cs(238,39): error CS0433: The type 'RuleTile' exists in both 'Assembly-CSharp, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null' and 'Unity.2D.Tilemap.Extras, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null'
Assets\2DGamekit\Utilities\Editor\RuleTileEditor.cs(243,53): error CS0433: The type 'RuleTile' exists in both 'Assembly-CSharp, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null' and 'Unity.2D.Tilemap.Extras, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null'
Assets\2DGamekit\Utilities\Editor\RuleTileEditor.cs(228,11): error CS0433: The type 'RuleTile' exists in both 'Assembly-CSharp, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null' and 'Unity.2D.Tilemap.Extras, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null'
Assets\2DGamekit\Utilities\Editor\RuleTileEditor.cs(229,11): error CS0433: The type 'RuleTile' exists in both 'Assembly-CSharp, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null' and 'Unity.2D.Tilemap.Extras, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null'
Assets\2DGamekit\Utilities\Editor\RuleTileEditor.cs(231,51): error CS0433: The type 'RuleTile' exists in both 'Assembly-CSharp, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null' and 'Unity.2D.Tilemap.Extras, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null'

Есть несколько решений, которые мне удалось найти:

  1. Удалить файл RuleTile.cs

  2. Удалить «2d Feature» - проект стабильно работает, новых проблем не возникает (как ни странно)

  3. Предполагаю что запуск на 2017.3.0 версии может помочь, однако у меня Unity выдавал ошибку. В итоге я остался на версии 2022.3.5f1

  4. Так же стоит попробовать реимпортировать все пакеты.

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

Часть 3: Играем, слушаем и делаем заметки

Теперь, когда проект пусть не бегает, но худо-бедно ковыляет, можно приступить к работе. И первым делом следует понять, с чем мы имеем дело, для этого:

  • Проанализируем имеющееся звуковое сопровождение, реализованное с помощью интегрированных средств Unity.

  • Исследуем возможные способы перехода на инструментарий, обеспечиваемый FMOD, и спроектируем логическую структуру предполагаемых событий FMOD

  • Сформируем концептуальный образ обновленного звукового дизайна.

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

Итак, следует пояснить какой смысл заложен в каждом из этих высказываний. Начну по порядку:

Первый тезис. Здесь мы постараемся не задерживаться надолго, ибо всё достаточно просто. Хочу перечислить лишь моменты, которые кажутся достаточно интересными для упоминания:

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

  2. У босса (для разнообразия буду использовать термин «Gunner») отсутствует «Idle» (воспринимайте как постоянно работающие механизмы внутри робота) и звук активного энергетического щита, хотя в файлах проекта есть заготовленное аудио.

  3. Продолжительная атака молнией активируется как «Oneshot» (мне кажется, разумнее реализовать через «Loop»)

  4. Музыкальная тема босса заменяет собой тему исследования в методе OnEnable() в скрипте MissileGolem.cs, который прикреплён к Gunner-y

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

Второй тезис. В процессе изучения игры я обдумывал возможную логику событий в FMOD, и её интеграцию с использованием C#. В любом случае мы детальнее разберём данную тему в дальнейшем.

Третий тезис. Саунд-дизайн в игре должен выполнять определённый функционал. Я бы выделил две основные задачи:

  • Создание концептуально верной атмосферы в игре

    • Когда мы обсуждаем подобные вопросы - мы задаёмся целью создать действительно проработанную и качественную игру. Формирование корректного чувственного образа требует тщательного анализа и чёткого понимания замысла игры. Так как наш проект является тренировочным, будет разумно опустить детали. Скажу лишь что во время работы я следовал некоторому ощущению, которое можно назвать моим личным видением.

  • Информирование игрока об изменяющемся состоянии окружающего мира

    • Примером такой индикации может послужить динамический «Idle» Gunner-а либо звучащий энергетический барьер. Смена в характера «Idle» может намекнуть на изменившийся уровень угрозы, а наличие звуковых пульсаций у щита может сигнализировать о необходимости избегать близкого контакта. И это только малая часть доступных возможностей.

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

Часть 4: Интеграция аудио

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

  1. Персонаж игрока («Ellen»)

    1. Ходьба / Процесс падения / Приземление

    2. Изменение реакции при получении урона в зависимости от источника

    3. Выстрел из бластера

      1. Момент выстрела

      2. Процесс полета пули и звук попадания по разным целям

  2. Босс («Gunner»)

    1. «Idle»

    2. Активация/Деактивация/«Idle» энергетического щита

  3. Аудио Эмиттеры («Emitter» / «Audio Emitter»)

    1. Кислотный бассейн

      1. Следование источника звука за игроком

      2. Флуктуация жидкости (реакции жидкости при движении объекта внутри)

Персонаж игрока («Ellen»)

Ходьба / Процесс падения / Приземление

Для начала найдем префаб «Ellen». Нам предстоит добавить в него свой собственный скрипт. Напишем его.

MyScript_01.cs
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Runtime.CompilerServices;
using UnityEngine;

using FMOD;
using FMOD.Studio;
using FMODUnity;
using FMODUnityResonance;

namespace Gamekit2D
{
    public class MyScript_01 : MonoBehaviour
    {
        private PlayerCharacter PC;             //Нам нужен доступ к m_MoveVector  

        private float RaycastDistance = 0.05f;  //Расстояние raycast
       
        private bool OnGroundWas = true;        //Был ли персонаж на земле?
        private bool IsSubmergedNow = false;    //Погружен ли персонаж в воду сейчас? 

        private int SurfaceType;                //Получаем тип поверхности
        private LayerMask LM = 1 << 31;         // 

        [SerializeField]
        private EventReference ER_Footsteps;    //Путь к событию шагов FMOD
        [SerializeField]
        private EventReference ER_Falling;      //Путь к событию падения FMOD
        [SerializeField]
        private EventReference ER_Landing;      //Путь к событию приземления FMOD

        private EventInstance Event_Footsteps;  //Событие шагов
        private EventInstance Event_Landing;    //Событие приземления
        public  EventInstance Event_Falling;    //Здесь public с целью использования в других скриптах

        void Start()
        {
            PC = GetComponent<PlayerCharacter>();   //Получаем первый существующий на сцене обьект класса PlayerCharacter

            Event_Footsteps = FMODUnity.RuntimeManager.CreateInstance(ER_Footsteps);

            Event_Falling = RuntimeManager.CreateInstance(ER_Falling);
            RuntimeManager.AttachInstanceToGameObject(Event_Falling, transform, PC.GetComponent<CapsuleCollider2D>());
        }

        //Что происходит в FixedUpdate() ?
        //Проверка состояния игрока: находится ли он в воздухе или погружен в жидкость
        //Это нужно для остановки звука падения и корректного проигрывания звука приземления
        void FixedUpdate() 
        {                  

            IsJustLanded();

            OnGroundWas = IsOnGroundNow(); 
            IsOnGroundNow();

            if (IsOnGroundNow()) IsSubmergedNow = false;

            IsFallingNow();
        }

        void OnDestroy() //Выгружаем экземпляры
        {
            Event_Footsteps.stop(FMOD.Studio.STOP_MODE.ALLOWFADEOUT);
            Event_Footsteps.release();
           
            Event_Falling.stop(FMOD.Studio.STOP_MODE.ALLOWFADEOUT);
            Event_Falling.release();

            Event_Landing.stop(FMOD.Studio.STOP_MODE.ALLOWFADEOUT);
            Event_Landing.release();
        }

        //Здесь происходит проверка типа поверхности с помощью raycast
        //RCD это модификатор, прибавляемый к RaycastDistance - применяется при проверке типа поверхности для звука приземления
        void SurfaceTypeCheck(float RCD) 
        {
            RaycastHit2D hit;
  
            hit = Physics2D.Raycast(transform.position, Vector2.down, RCD, LM); 
           
            if (hit.collider)
            {
                if (hit.collider.tag == "Brick")
                    SurfaceType = 2;
                else if (hit.collider.tag == "Grass")
                    SurfaceType = 3;
                else SurfaceType = 0;
            }
        }

        void IsJustLanded() //Проверяет приземлился ли игрок: !(на земле = true) И (находится сейчас на земле = true)
        {
            /*  (Если не был на земле) И (сейчас на земле) и |перемещение по y > 3|
                Для чего нужна проверка перемещения по y?
                Чтобы избежать многочисленных вызовов события при персечении платформ снизу вверх
                Чтобы избежать многочисленных вызовов события при приземлении 
            */
            if (!OnGroundWas && IsOnGroundNow() && PC.GetMoveVector().y < -3) 
            {

                Event_Landing = RuntimeManager.CreateInstance(ER_Landing); //Создаем экземпляр падения
                RuntimeManager.AttachInstanceToGameObject(Event_Landing, transform, PC.GetComponent<Rigidbody2D>());

                SurfaceTypeCheck(RaycastDistance + 1f); //Проверяем тип поверхности на расстоянии +1.5f 
                Event_Landing.setParameterByName("Velocity", -PC.GetMoveVector().y ); //Ставим «-» так как данный параметр в FMOD использует положительные значения
                Event_Landing.setParameterByName("SurfaceType", SurfaceType); //Задаем тип поверхности
               
                Event_Landing.start(); //Проигрываем 
                Event_Falling.stop(FMOD.Studio.STOP_MODE.ALLOWFADEOUT); //Останавливаем
                Event_Landing.release(); //Выгружаем событие
            }
        }

        bool IsOnGroundNow() //Сейчас на земле?   
        {
                return Physics2D.Raycast(transform.position, Vector2.down, RaycastDistance, LM);
        }

        void IsFallingNow() 
        {
            PLAYBACK_STATE PB_State;    //Локальная переменная содержащая в себе текущий статус события (Event_Falling) 
            Event_Falling.getPlaybackState(out PB_State);

            //Аналогично условию, которое использовалось при проверке нахождения игрока на земле
            //Здесь нам наоборот нужно условие: (Если сейчас не на земле) И |перемещение по y > 3| И (Не погружен в жидкость)
            if (!IsOnGroundNow() && PC.GetMoveVector().y < -3 && !IsSubmergedNow)
            {
                Event_Falling.setParameterByName("Velocity", -PC.GetMoveVector().y);

                if (PB_State != PLAYBACK_STATE.PLAYING) //Запускаем событие если оно еще не запущено
                {
                    Event_Falling.start();
                    Event_Falling.getPlaybackState(out PB_State);    
                }
            }
            else if ((IsOnGroundNow() || IsSubmergedNow)) Event_Falling.getPlaybackState(out PB_State);
        }

        void PlayEvent_Footsteps()
        {
            SurfaceTypeCheck(RaycastDistance); //Проверяем тип поверхности
            Event_Footsteps.setParameterByName("SurfaceType", SurfaceType); //Задаем тип поверхности

            RuntimeManager.AttachInstanceToGameObject(Event_Footsteps, transform, PC.GetComponent<CapsuleCollider2D>()); 
            Event_Footsteps.start(); //Проигрываем

        }
    
        public void SetIsSubmergedNow (bool IsSubmergedNow) //задаем IsSubmergedNow
        {
             this.IsSubmergedNow = IsSubmergedNow;
        }
    }
}

Теперь надо вызвать наш PlayEvent_Footsteps(). Для этого в «Ellen» заходим в Animation / Ellen_Run и добавляем событие анимации, в нем указываем наш метод.

Раскрою некоторые моменты:

  • Стоит понимать что я задал различным блокам собственный тег: «Brick» и «Grass»

    • Для этого переходим по пути: Edit / Project Settings / Tags and Layers и добавляем нужные теги

  • LayerMask LM = 1 << 31

    • В проекте определено 32 слоя (от 0 до 31 включительно)

    • На 31 месте находится слой «Platform» (32 по счету)

    • Мы сдвигаем бит влево 31 раз, таким образом указываем что нам нужен 31 слой

Примечание: я знаю что в PlayerCharacter.cs есть необходимый код и нам остается только найти точки для внедрения вызовов FMOD событий. Сейчас мы предполагаем что у нас нет такого функционала и нам нужно написать его самим.

Логика FMOD:

  • Шаг:

    • Параметр «SurfaceType» типа «Labeled» - от его значения зависит какой звук проигрывать

    • Три «Multi Instrument»

      • Brick / Grass: проигрывается только один канал, выбор происходит основываясь на значении «SurfaceType»

      • Equipment: проигрывается всегда как дополнительный слой

  • Падение:

    • Параметр «Velocity» типа «Continuous» - от его значения зависит громкость аудио. Он автоматизирует «Fader» канала.

    • Зацикленный «Single Instrument»

    • Наличие «AHDSR» на мастер фейдере

  • Приземление:

    • Два уже известных параметра: «Velocity» и «SurfaceType»

    • 4 Вложенных события, каждое по отдельности - это «Multi Instrument»

    • Логика схожа с предыдущими двумя событиями:

      • Brick / Grass: проигрывается так же один из двух единовременно

      • Voice / Equipment: проигрываются всегда

      • От «Velocity» зависит раскрытие звука (чем больше - тем ярче звук падения) - тут простая реализация так же через мастер-фейдер

Различная реакция на получение урона от различных источников

Найдем скрипт PlayerCharacter.cs и внесем изменения.

  • В пространство имен добавим:

using FMOD;
using FMOD.Studio;
using FMODUnity;
using FMODUnityResonance;
  • Обьявим ссылку на событие и ссылку на экземпляр события FMOD

[SerializeField]
EventReference ER_Hurt;
EventInstance EI_Hurt;
Обновленный onHurt ()
public void OnHurt(Damager damager, Damageable damageable)
        {
          
            //if the player don't have control, we shouldn't be able to be hurt as this wouldn't be fair
            if (!PlayerInput.Instance.HaveControl)
                return;

            UpdateFacing(damageable.GetDamageDirection().x >s 0f);
            damageable.EnableInvulnerability();

            m_Animator.SetTrigger(m_HashHurtPara);

            //we only force respawn if helath > 0, otherwise both forceRespawn & Death trigger are set in the animator, messing with each other.
            if (damageable.CurrentHealth > 0 && damager.forceRespawn)
                m_Animator.SetTrigger(m_HashForcedRespawnPara);

            m_Animator.SetBool(m_HashGroundedPara, false);

            EI_Hurt = RuntimeManager.CreateInstance(ER_Hurt);
            RuntimeManager.AttachInstanceToGameObject(EI_Hurt, transform, GetComponent<Rigidbody2D>());
            EI_Hurt.setParameterByName("Number", damager.name == "Acid" ? 3 : 2); //Здесь и происходит выбор звука

            EI_Hurt.start();
            EI_Hurt.release();


            //if the health is < 0, mean die callback will take care of respawn
          if (damager.forceRespawn && damageable.CurrentHealth > 0)
            {
                StartCoroutine(DieRespawnCoroutine(false, true));
            }
        }

Логика FMOD:

  • Логика схожа с шагами:

    • «Number» заменяет «SurfaceType» - функционал тот же

    • etc /Drowning: проигрывается так же один из двух

  • 4 Вложенных события, каждое по отдельности это «Multi Instrument»

Примечание: все остальные источники урона мы можем реализовать по этому же принципу

Выстрел из бластера
  • Момент выстрела

    • Находим Spawn Bullet() в player Character.cs и добавляем следующую строку:

      FMODUnity.RuntimeManager.PlayOneShot("EventPath", GetComponent<Transform>().position);

      Она позволит проиграть однократно событие по пути "EventPath"

  • Процесс полета пули и звук попадания по разным целям

    • Находим префаб «Bullet» и одноименный скрипт Bullet.cs и вносим изменения:

Bullet.cs
using System.IO;
using UnityEngine;
using UnityEngine.Tilemaps;

using FMOD;
using FMOD.Studio;
using FMODUnity;
using FMODUnityResonance;

namespace Gamekit2D
{
    [RequireComponent(typeof(Rigidbody2D))]
    [RequireComponent(typeof(Damager))]
    public class Bullet : MonoBehaviour
    {

        [SerializeField]
        private EventReference ER_ProjectileFlight;
        [SerializeField]
        public EventReference ER_ProjectileImpact;

        EventInstance EI_ProjectileFlight;
        EventInstance EI_ProjectileImpact;
        EventDescription ED_ProjectileFlight; //Обьявляем описание события

        public bool destroyWhenOutOfView = true;
        public bool spriteOriginallyFacesLeft;

        [Tooltip("If -1 never auto destroy, otherwise bullet is return to pool when that time is reached")]
        public float timeBeforeAutodestruct = -1.0f;
    
        [HideInInspector]
        public BulletObject bulletPoolObject;
        [HideInInspector]
        public Camera mainCamera;

        protected SpriteRenderer m_SpriteRenderer;
        static readonly int VFX_HASH = VFXController.StringToHash("BulletImpact");

        const float k_OffScreenError = 0.01f;

        protected float m_Timer;

        private void OnEnable()
        {
            m_SpriteRenderer = GetComponent<SpriteRenderer>();
            m_Timer = 0.0f;

            //Записываем параметры события по пути ER_ProjectileFlight в ED_ProjectileFlight
            RuntimeManager.StudioSystem.getEvent(ER_ProjectileFlight.Path, out ED_ProjectileFlight);

            //Создаем ивент с параметрами ED_ProjectileFlight - теперь мы можем контролировать его из любого места, что необходимо для корректной остановки
            ED_ProjectileFlight.createInstance(out EI_ProjectileFlight); 
            RuntimeManager.AttachInstanceToGameObject(EI_ProjectileFlight,transform,GetComponent<Rigidbody2D>());
          
            EI_ProjectileFlight.start();
            EI_ProjectileFlight.release();

            EI_ProjectileImpact = RuntimeManager.CreateInstance(ER_ProjectileImpact);
        }

        public void ReturnToPool ()
        {
            bulletPoolObject.ReturnToPool ();
        }

        void FixedUpdate () //Мы должны остановить звук полета когда пуля возрващается в пулл.
        {
            if (destroyWhenOutOfView)
            {
                Vector3 screenPoint = mainCamera.WorldToViewportPoint(transform.position);
              
                bool onScreen = screenPoint.z > 0 && screenPoint.x > -k_OffScreenError &&
                                screenPoint.x < 1 + k_OffScreenError && screenPoint.y > -k_OffScreenError &&
                                screenPoint.y < 1 + k_OffScreenError;

                if (!onScreen)
                {
                    bulletPoolObject.ReturnToPool();
                    EI_ProjectileFlight.stop(FMOD.Studio.STOP_MODE.ALLOWFADEOUT); //Остановка тут
                }
            }

            if (timeBeforeAutodestruct > 0)
            {
                m_Timer += Time.deltaTime;
                if (m_Timer > timeBeforeAutodestruct)
                {
                    bulletPoolObject.ReturnToPool();
                    EI_ProjectileFlight.stop(FMOD.Studio.STOP_MODE.ALLOWFADEOUT); //Остановка тут
                }
            }
        }

        public void OnHitDamageable(Damager origin, Damageable damageable) //Здесь проверки условий при контакте с повреждаемой сущностью
        {
            FindSurface(origin.LastHit);
           
            EI_ProjectileFlight.stop(FMOD.Studio.STOP_MODE.IMMEDIATE); //Остановка полета

            EI_ProjectileImpact.setParameterByName("ImpactType", damageable.name == "GunnerShield" ? 4: 3); //Проверяем во что попали: щит Gunner-a 4, все остальное 3
            RuntimeManager.AttachInstanceToGameObject(EI_ProjectileImpact, damageable.transform, damageable.GetComponent<Rigidbody2D>());
            EI_ProjectileImpact.start();                    
            EI_ProjectileImpact.release();
        }

        public void OnHitNonDamageable(Damager origin) //Здесь проверки условий при контакте с неповреждаемой сущностью
        {
            FindSurface(origin.LastHit);
           
            EI_ProjectileFlight.stop(FMOD.Studio.STOP_MODE.IMMEDIATE); //Остановка полета

            EI_ProjectileImpact.setParameterByName("ImpactType", 2); //Присваиваем 2 
            RuntimeManager.AttachInstanceToGameObject(EI_ProjectileImpact, transform, GetComponent<Rigidbody2D>());
            EI_ProjectileImpact.start();                                  
            EI_ProjectileImpact.release(); 
        }

        protected void FindSurface(Collider2D collider)
        {
            Vector3 forward = spriteOriginallyFacesLeft ? Vector3.left : Vector3.right;
            if (m_SpriteRenderer.flipX) forward.x = -forward.x;

            TileBase surfaceHit = PhysicsHelper.FindTileForOverride(collider, transform.position, forward);

            VFXController.Instance.Trigger(VFX_HASH, transform.position, 0, m_SpriteRenderer.flipX, null, surfaceHit);
        }
    }
}

Раскрою некоторые моменты:

  • Вызов OnHitDamageable() / OnHitNonDamageable() происходит в скрипте Damager.cs который так же прикреплен к префабу «Bullet»

  • Может происходить воспроизведение звука при старте игры

    • Чтобы этого избежать можно установить задержку или сделать префаб «Bullet» по умолчанию неактивным

Логика FMOD:

  • Момент выстрела - это просто мульти-инструмент без какой-либо логики

  • Процесс полета пули - это просто Loop без какой-либо логики

  • Импакт от столкновения

    • Параметр «ImpactType» типа «Labeled»

    • Три «Multi Instrument»: единовременно проигрывается только один канал, выбор происходит, основываясь на значении «ImpactType»

Примечание: концептуально любой выстрел работает идентично и реализовать выстрелы NPC можно будет по схожей логике.

Босс («Gunner»)

Босс представлен как набор перфабов на сцене (Zone5). Все изменения будут производится в скрипте MissleGolem.cs, который прикреплен к «GunnerMaster»

Нам нужно найти Gunner/ GunnerMaster.

«Idle»
  • В пространство имен добавим:

using FMOD;
using FMOD.Studio;
using FMODUnity;
using FMODUnityResonance;
  • Стандартно добавим: ссылку на событие, ссылку на экземпляр события и описания события

    [SerializeField]
    private EventReference ER_Idle;
    private EventInstance EI_Idle;
    private EventDescription ED_Idle;
Изменим: Awake()
RuntimeManager.StudioSystem.getEvent(ER_Idle.Path, out ED_Idle);

Изменим: OnEnable()
    void OnEnable()
    {
        ED_Idle.createInstance(out EI_Idle); //Создаем событие с хар-ками ED_Idle
        RuntimeManager.AttachInstanceToGameObject(EI_Idle, transform, GetComponent<Rigidbody2D>());

        EI_Idle.start();
        EI_Idle.setParameterByName("Number", 02); //задаем базовое аудио
    }

Изменим: NextRound()
    void NextRound()
    {
        damageable.SetHealth(rounds[round].bossHP);
        damageable.EnableInvulnerability(true);
        foreach (var p in rounds[round].platforms)
        {
            p.gameObject.SetActive(true);
            p.speed = rounds[round].platformSpeed;
        }
        foreach (var g in rounds[round].enableOnProgress)
        {
            g.SetActive(true);
        }
        round++;

        if (round == 2)
        {
            Event_Idle.setParameterByName("Number", 03); //Добавляем строку смены параметра чтобы изменилось проигрываемое аудио

            roundDeathSource.clip = startRound2Clip;
            roundDeathSource.loop = true;
            roundDeathSource.Play();
        }
        else if (round == 3)
        {
            Event_Idle.setParameterByName("Number", 04) //Добавляем строку смены параметра чтобы изменилось проигрываемое аудио
            roundDeathSource.clip = startRound3Clip;
            roundDeathSource.loop = true;
            roundDeathSource.Play();
        }
    }

Логика FMOD:

  • Параметр «Number» типа «Labeled»

    • Играет один из трех каналов единовременно

    • Как только «Number» меняет значение - меняется проигрываемая дорожка

    • Это «Loop»

Активация/Деактивация/«Idle» энергетического щита
  • Изменения все еще производятся в MissleGolem.cs. В пространство имен добавим:

    [SerializeField]
    private EventReference ER_Shield;
    private EventInstance EI_Shield;
    private EventDescription ED_Shield;
Вносим коррективы в ActivateShield()
    void ActivateShield()
    {
        shieldUpAudioPlayer.PlayRandomSound();

        shield.SetActive(true);
        shield.transform.localScale = Vector3.one * 0.01f;

        shieldSlider.GetComponent<Animator>().Play("BossShieldActivate");

        Damageable shieldDamageable = shield.GetComponent<Damageable>();

        //need to be set after enabled happen, otherwise enable reset health. That why we use round - 1, round was already advance at that point
        shieldDamageable.SetHealth(rounds[round - 1].shieldHP);
        shieldSlider.maxValue = rounds[round - 1].shieldHP;
        shieldSlider.value = shieldSlider.maxValue;

        RuntimeManager.StudioSystem.getEvent(ER_Shield.Path, out ED_Shield);
        ED_Shield.createInstance(out EI_Shield);

        RuntimeManager.AttachInstanceToGameObject(EI_Shield, transform, GetComponent<Rigidbody2D>());
        EI_Shield.start();
    }
Вносим коррективы с ActivateShield()
    void ActivateShield()
    {
        shieldUpAudioPlayer.PlayRandomSound();

        shield.SetActive(true);
        shield.transform.localScale = Vector3.one * 0.01f;

        shieldSlider.GetComponent<Animator>().Play("BossShieldActivate");

        Damageable shieldDamageable = shield.GetComponent<Damageable>();

        //need to be set after enabled happen, otherwise enable reset health. That why we use round - 1, round was already advance at that point
        shieldDamageable.SetHealth(rounds[round - 1].shieldHP);
        shieldSlider.maxValue = rounds[round - 1].shieldHP;
        shieldSlider.value = shieldSlider.maxValue;

        RuntimeManager.StudioSystem.getEvent(ER_Shield.Path, out ED_Shield);
        ED_Shield.createInstance(out EI_Shield);

        RuntimeManager.AttachInstanceToGameObject(EI_Shield, transform, GetComponent<Rigidbody2D>());
        EI_Shield.start();
    }

Вносим изменения в Shield Down()
    public void ShieldDown()
    {
        //shieldDownAudioPlayer.PlayRandomSound();
        damageable.DisableInvulnerability();

        EI_Shield.stop(FMOD.Studio.STOP_MODE.ALLOWFADEOUT);
        EI_Shield.release();
    }

Логика FMOD:

В данном случае логика несколько завуалированна

  • Канал On / Loop : проигрывается при условии «NotStopping»

  • Канал Off: проигрывается при переходе события в состояние «Stopping»

  • Цикличное проигрывание региона так же срабатывает только если событие «NotStopping»

    Примечание: как только событие запускается, мы проигрываем канал On, затем входим в область цикла, где проигрывается канал Loop. Как только мы останавливаем событие мы выходим из цикла, Loop прекращает свое воспроизведение, а канал Off наоборот проигрывается.

Аудио Эмиттеры (Кислотный бассейн)

Следование эммитера за игроком

Нам нужно найти префаб «Acid» и снова написать уже второй собственный скрипт. Напомню, что все пояснения уже даны в коде.

MyScript_02.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Gamekit2D;
using FMOD;
using FMOD.Studio;
using FMODUnity;
using FMODUnityResonance;

public class MyScript_02 : MonoBehaviour
{
    private WaterArea CurrentWaterArea; //Для получения размера бассейна с жидкостью | «WaterArea.cs»  это скрпит на префабе «Acid» 
    private Transform Player_Transform; //Информация о координатах игрока

    //Максимальное и минмальное растояние по оси X на которое может перемещатся Эмиттер
    private float Collider_X_MAX;
    private float Collider_X_MIN;
    //Максимальное и минмальное растояние по оси Y на которое может перемещатся Эмиттер
    private float Collider_Y_MAX;
    private float Collider_Y_MIN;

    private Vector3 Emmiter_Postion; //Позиция эмиттера на сцене

    [SerializeField]
    private EventReference ER_AcidPool; 
    private EventInstance EI_AcidPool;

    // Start is called before the first frame update
    void Start()
    {
        //получаем экземпляры «WaterArea.cs»  / «PlayerCharacter.cs»
        CurrentWaterArea = GetComponent<WaterArea>(); 
        Player_Transform = FindObjectOfType<PlayerCharacter>().transform;

        //В мировых координатах получаем границы коллайдера бассейна с кислотой и записываем в соответствующие переменные
        Collider_X_MAX = CurrentWaterArea.boxCollider2D.bounds.max.x;
        Collider_X_MIN = CurrentWaterArea.boxCollider2D.bounds.min.x;
        Collider_Y_MAX = CurrentWaterArea.boxCollider2D.bounds.max.y;
        Collider_Y_MIN = CurrentWaterArea.boxCollider2D.bounds.min.y;

        EI_AcidPool = RuntimeManager.CreateInstance(ER_AcidPool);
        EI_AcidPool.start();
        EI_AcidPool.release();
    }

    void FixedUpdate() //Здесь обновляем положение источника звука 
    {
        //Таким образом мы получаем координаты источника по X / Y осям соответсвенно:
        //Передаем в Mathf.Clamp положение игрока по оси X. На выходе получаем значение в пределах между Collider_X_MIN и Collider_X_MAX
        float EMITTER_X_POS = Mathf.Clamp(Player_Transform.position.x, Collider_X_MIN, Collider_X_MAX);

        //Передаем в Mathf.Clamp положение игрока по оси Y. На выходе получаем значение пределах между Collider_Y_MIN и Collider_Y_MAX
        float EMITTER_Y_POS = Mathf.Clamp(Player_Transform.position.y, Collider_Y_MIN, Collider_Y_MAX);

        Emmiter_Postion = new Vector3(EMITTER_X_POS, EMITTER_Y_POS, transform.position.z); //Записываем в Emmiter_Postion новые координаты X/Y, но оставляем Z неизменной
        EI_AcidPool.set3DAttributes(RuntimeUtils.To3DAttributes(Emmiter_Postion)); //Обновляем положение Эмиттера
    }

    private void OnDestroy()
    {
        EI_AcidPool.stop(FMOD.Studio.STOP_MODE.IMMEDIATE);
    }
}

Логика FMOD:

  • Представляет собой простой «Loop»

Флуктуация жидкости

В игре есть обьекты, которые способны плавать в воде. Префаб «PushableBox» является таким обьектом. Нам нужно найти скрипт Pushable.cs и внести небольшие изменения в него.

  • Добавим ссылку ну экземпляр события и на состояние события

        private FMOD.Studio.EventInstance EI_InAcidSubmerged;
        private FMOD.Studio.PLAYBACK_STATE PS_InAcidSubmerged;
  • Вносим изменения в FixedUpdate():

Обновленный FixedUpdate():
        void FixedUpdate()
        {
            Vector2 velocity = m_Rigidbody2D.velocity;
            velocity.x = 0f;
            m_Rigidbody2D.velocity = velocity;

            EI_InAcidSubmerged.set3DAttributes(RuntimeUtils.To3DAttributes(transform, GetComponent<Rigidbody2D>()));

            for (int i = 0; i < m_WaterColliders.Length; i++) 
            {
                if (m_Rigidbody2D.IsTouching(m_WaterColliders[i])) //Если мы погружены в жидкость тогда выполняем
                {
                    m_Rigidbody2D.constraints |= RigidbodyConstraints2D.FreezePositionX;

                    EI_InAcidSubmerged.getPlaybackState(out PS_InAcidSubmerged);

                    if (PS_InAcidSubmerged != PLAYBACK_STATE.PLAYING)
                        EI_InAcidSubmerged.start();

                    //Здесь нам нужно проигрвать звук воды если куб движется по оси Y
                    //Чем изменение положения по оси Y больше - тем громче звук
                    if (velocity.y < 0)
                        EI_InAcidSubmerged.setParameterByName("Velocity", -velocity.y * 20); //Коррекция Velocity - частный случай, у меня лучше всего работает когда * на 20
                    else if (velocity.y > 0)
                        EI_InAcidSubmerged.setParameterByName("Velocity", velocity.y * 20); //Коррекция Velocity - частный случай, у меня лучше всего работает когда * на 20
                }
            }
        }

Логика в FMOD:

  • Обычный «Loop»

  • Единственное отличие: мне захотелось перенаправить Layer 01 на канал Bus

Часть 5: Приятный бонус

Смена слушателя в катсценах

Вновь нам понадобится написать собственный скрипт:

MyScript_03.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

using FMOD;
using FMOD.Studio;
using FMODUnity;
using FMODUnityResonance;

public class MyScript_03 : MonoBehaviour
{
    void Start()
    {
        //1 - Ellen / 0 - camera
        RuntimeManager.StudioSystem.setListenerWeight(1, 1f);     
        RuntimeManager.StudioSystem.setListenerWeight(0, 0f);
    }

    public void SwitchToCameraListener()
    {
        RuntimeManager.StudioSystem.setListenerWeight(0, 1f);
        RuntimeManager.StudioSystem.setListenerWeight(1, 0f);
    }

    public void SwitchToPlayerListener()
    {
       RuntimeManager.StudioSystem.setListenerWeight(0, 0f);
       RuntimeManager.StudioSystem.setListenerWeight(1, 1f);
    }
}

Раскрою некоторые моменты:

  • В игре есть Zone 4 где присутствует целых две катсцены. По умоланию «Listener» (слушатель) прикреплен к «Ellen», а так как камера в момент проигрывания катсцены будет перемещаться к точке интереса, нам хотелось бы слышать то, что она показывает.

  • Праметр «weight» в FMOD отвечает за «важность» слушателя.

    • Может задаваться от 0 до 1

    • Если вес равен 1 - все аудио проходит через слушателя

    • Если вес равен 0 - слушатель неактивен

Чтобы все заработало:

  • Для начала надо прикрепить «FMOD Studio Listener» к «MainCamera»

  • Находим префаб «MainCamera», добавляем пустой обьект (у меня он назван FMOD Studio Listener) и крепим на него «FMOD Studio Listener»

  • Находим оба триггера («Cutscene1Trigger» / «Cutscene2Trigger»)

    • Нам нужно указать запуск соответствующего метода в нашем MyScript_03.cs

Примечание: я специально реализовал переключение через параметр «weight» а не через функционал Unity

Микшер, роутинг и дополнительный слайдер в меню настроек

Теперь займемся панелью настроек в игре. Однако для начала надо организовать наш микс

  • Сгруппируем для начала в 3 основные группы наши события

  • После этого я создам одноименные VCA каналы для каждой шины

Панель настроек в игре

У нас уже есть 3 готовых слайдера. Однако я бы добавил еще один:

  • Нам нужно найти префаб «OptionsCanvas», в нем «AudioCanvas»

  • Затем добавить туда наш новый ползунок

Теперь пишем MyScript_04.cs

код
using FMOD.Studio;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

using FMOD;
using FMOD.Studio;
using FMODUnity;
using FMODUnityResonance;

public class FMOD_VCA_Controller : MonoBehaviour
{

    private VCA VCA_Controller;
    private Slider Slider;

    [SerializeField] 
    private float VCA_CurrentVolume; //Записываем сюда громкость
    
    [SerializeField]
    private string VCA_name; //Задаем имя VCA который будет контролироваться

    void Start()
    {
        VCA_Controller = RuntimeManager.GetVCA("VCA:/"+VCA_name); //Получаем VCA из FMOD с именем VCA_name
       
        Slider = GetComponent<Slider>(); //Получаем наш слайдер
        VCA_Controller.getVolume(out VCA_CurrentVolume); //Записываем в VCA_CurrentVolume значение по умолчанию из FMOD
        
        Slider.value = VCA_CurrentVolume; //Чтобы слайдер не возращася в 0
    }

    public void SetVolume(float newvolume) //принимаем на вход newvolume 
    {
        VCA_Controller.setVolume(newvolume); //Обновляем громкость VCA, присваивая значение из newvolume
        VCA_Controller.getVolume(out VCA_CurrentVolume); //Записываем в VCA_CurrentVolume обновленную громкость
    }

}

Главное не забыть установить вызов метода SetVolume() при каждом изменении значения слайдера (это проделать с каждым ползунком)

Послесловие

ссылка на художника

И вот первый отрезок пути остался позади, статья готова, и трудно представить что ждет нас дальше!

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

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

Спасибо за внимание, которое вы уделили моему труду! Всегда рад получить обратную связь. Оставляйте комментарии, делитесь собственными мыслями и идеями. Мы всегда учимся, и тот, кто не учится, остается позади.

До новых встреч!

в начало

Комментарии (0)