Альтернативный Sound Manager для мелких и средних проектов на Unity3D

в 9:38, , рубрики: C#, game development, unity3d, рефакторинг

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

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

Проблемы

Злой одиночка

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

Примеры:

static void PlayMusic(string name);
static void PlaySound(string name, bool pausable = true);

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

public void PlayFX (AudioClip clip, SoundFXChannel channel = SoundFXChannel.First, bool forceInterrupt = false) { }
public void StopFX (SoundFXChannel channel) { }

Теперь, например, кнопки (или если угодно UIManager) используя методы, должны учитывать, к какому из каналов они относятся, фактически это опять возлагается на программиста.

Слишком много доступа

Для меня всегда было странным то, что когда я в отдельном коде вызываю метод, мне возвращают тип-наследник от MonoBehaviour. Безопасно ли пускать короутины по нему? Защитил ли разработчик его от Destroy()? Или хочу ли я вообще видеть в дальнейшем в коде “using UnityEngine” или мне не нужен MonoBehaviour? Эта проблема частично относится и к предыдущему пункту о синглтоне, нам не нужна ссылка на сам экземпляр, нам достаточно API для работы с ним. Забавно, но даже если вы реализуете статический вызов таким образом:

private static SoundManager instance;
public static ISoundManager Instance { get{ return (instance as ISoundManager) }}

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

ISoundManager sm = SoundManager.Instance;

Что решает проблему лишь частично.

Вшитый путь и прямая загрузка

private AudioClip LoadClip(string name)
    {
        string path = "Sounds/" + name;
        AudioClip clip = Resources.Load<AudioClip>(path);
        return clip;
    }

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

Теперь по поводу вшитого в код пути: Опять на программиста возлагается ответственность- следить за соответствием пути при переносе этого модуля из проекта в проект. Настоящие пляски начинаются, когда приходит в команду здравая мысль: “Почему бы не сделать git субмодуль, положить туда аудио менеджер, чтобы во всех проектах, если необходимо, была бы последняя версия этого модуля?”. Поскольку путь вшит в код, мы не можем его менять, так как на остальных проектах он станет ошибочным. С другой стороны, если менять путь только локально, то гит всегда будет светить вам это изменение.

Собственное решение

Код модуля находится по адресу:https://github.com/hexgrimm/Audio
Для публикации в рамках статьи код был упрощен, я убрал большую часть тестов и абстракций для них, для того, чтобы код смотрелся понятнее. В проектах под моим руководством используется модуль с несколько большим потенциалом расширяемости и объемной конфигурацией.

Итак, для начала поговорим об архитектуре:

Данный модуль аудио считается конечным листом в графе зависимостей любой архитектуры, он не требует зависимостей ниже по графу, и ему не важно, кто его создает, но имеется ограничение: Этот модуль должен иметь стиль жизни “Singleton” (не путать с паттерном проектирования Singleton, подробнее в книге “Внедрение зависимостей в .NET” Автор: Марк Симан). Это связанно с требованием Unity3D на только один AudioListener в приложении. В случае, если вы используете внедрение зависимостей в проекте, то бинды будут выглядеть следующим образом (на примере Ninject):

binder.Bind<IAudioController, IAudioPlayer, IMusicPlayer>().To<AudioController>().InSingletonScope();

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

Как пример:

var ac = new AudioController();
IAudioController iac = ac;
IAudioPlayer iap = ac;
IMusicPlayer imp = ac;

И в дальнейшем работа и поставка всем источникам ведется только с абстракциями iac, iap, imp.

Абстракции

IAudioController, интерфейс предназначенный для общим управлением звуком (вклвыкл, общая громкость):

IAudioController

public interface IAudioController : IDisposable
    {
        /// <summary>
        /// Enabled or disables all sounds in game. All music sources sets volume to = 0 and stops their playback;
        /// </summary>
        bool SoundEnabled { get; set; }
        /// <summary>
        /// Enables or disables all musics in game. All music sources sets volume to = 0 or MusicVolume value;
        /// </summary>
        bool MusicEnabled { get; set; }
        /// <summary>
        /// Sound volume range 1 - 0
        /// </summary>
        float SoundVolume { get; set; }
        /// <summary>
        /// Music volume in range 1 - 0
        /// </summary>
        float MusicVolume { get; set; }
    }

IAudioPlayer, интерфейс предназначен для воспроизведения 2д и 3д звуков, и дальнейшего их контроля.

IAudioPlayer

public interface IAudioPlayer
    {
        /// <summary>
        /// plays audio clip if sound enabled.
        /// </summary>
        /// <param name="clip">Audio clip to play.</param>
        /// <param name="volumeProportion">volume in range 1 - 0, when plays its also affected by global volume setting.</param>
        /// <param name="looped">should clip play be looped</param>
        /// <returns> returns code for this sound call to control playback for concrete clip played.</returns>
        int PlayAudioClip2D(AudioClip clip, float volumeProportion = 1f, bool looped = false);

        /// <summary>
        /// Plays audio clip in concrete 3d position
        /// </summary>
        /// <param name="clip">Audio clip to play</param>
        /// <param name="position">world position of audio source.</param>
        /// <param name="maxSoundDistance">parameter seted to audioSource.MaxDistance</param>
        /// <param name="volumeProportion">volume in range 1 - 0, when plays its also affected by global volume setting.</param>
        /// <param name="looped">should clip play be looped</param>
        /// <returns></returns>
        int PlayAudioClip3D(AudioClip clip, Vector3 position, float maxSoundDistance, float volumeProportion = 1f, bool looped = false);
        /// <summary>
        /// stop playing concrete clip.
        /// </summary>
        /// <param name="audioCode">code, recived from methods PlayAudioClip2D or PlayAudioClip3D</param>
        void StopPlayingClip(int audioCode);
        /// <summary>
        /// Returns true if audio code contains in player and can be controlled.
        /// </summary>
        /// <param name="audioCode">audio code</param>
        /// <returns></returns>
        bool IsAudioClipCodePlaying(int audioCode);
        /// <summary>
        /// Sets global audio listener to concrete position
        /// </summary>
        /// <param name="position">v3 in world coordinates</param>
        void SetAudioListenerToPosition(Vector3 position);
        /// <summary>
        /// Set position of source if source exist.
        /// </summary>
        /// <param name="audioCode">code of source</param>
        /// <param name="destinationPos">target position in world coordinates</param>
        void SetSourcePositionTo(int audioCode, Vector3 destinationPos);
    }

IMusicPlayer, воспроизведение музыки и контроль.

IMusicPlayer

public interface IMusicPlayer
    {
        /// <summary>
        /// plays music clip as 2d sound with concrete volume padding.
        /// </summary>
        /// <param name="clip">music clip</param>
        /// <param name="volumeProportion">volume proportions of sound in range of 1 - 0. Its also affected by global music volume settings</param>
        /// <returns>concrete music playback code for future control</returns>
        int PlayMusicClip(AudioClip clip, float volumeProportion = 1f);
        /// <summary>
        /// stops playing music clip and clear data for this code.
        /// </summary>
        /// <param name="audioCode">audio code to find audio clip playback</param>
        void StopPlayingMusicClip(int audioCode);
        /// <summary>
        /// Pauses concrete music clip play, it could be resumed.
        /// </summary>
        /// <param name="audioCode"></param>
        void PausePlayingClip(int audioCode);
        /// <summary>
        /// Resumes concrete music clip play if it was paused before.
        /// </summary>
        /// <param name="audioCode"></param>
        void ResumeClipIfInPause(int audioCode);
        /// <summary>
        /// Returns true if audio code contains in player and can be controlled.
        /// </summary>
        /// <param name="audioCode">audio code</param>
        /// <returns></returns>
        bool IsMusicClipCodePlaying(int audioCode);
    }

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

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

Отдельным методом стоит:

SetAudioListenerToPosition(Vector3 position);

В случае 3d звука и движущегося слушателя необходимо предоставить доступ к контролю его позиции.

Вы могли заметить, что одним из аргументов вызова воспроизведения является тип AudioClip, по моему мнению, логика хранения или ассоциации клипов и источников звука не должна находиться в самом контроллере, поэтому я просто вынес эти полномочия за модуль, тем самым позволяя потребителю модуля решать, создавать ли базу хранения звуков или ассоциировать клипы непосредственно с источниками (в большинстве наших случаев так и происходит. Различные юниты имеют женские и мужские голоса, эта информация — неотъемлемая часть юнитов, какого бы рода инкапсуляция бы не применялась; и именно юнит поставляет эту информацию, используя интерфейс IAudioPlayer).

Так же вы могли заметить, что IAudioController наследуется от IDisposable. Это сделано намеренно и обосновано ограничениями, которые накладывает Unity3D. В методе Dispose удаляются объекты юнити, созданные для обеспечения работоспособности модуля, на мой взгляд, относительно модуля объекты сцены являются “отдельно-управляемыми” ресурсами, и поскольку AudioController это не MonoBehaviour, мы не можем вызвать Destroy(). А сборщик мусора не сможет очистить ссылки, так как управляемые юнити ссылки будут живы. Вызывая метод Dispose, мы гарантируем, что все ресурсы и ссылки, связанные с юнити, были очищены. Хотя в маленьких проектах жизненный цикл аудио модуля по длине всегда схож с циклом работы приложения, так что возможно вам не стоит заморачиваться.

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

source.pitch = 1 + Random.Range(-0.1f, 0.1f);

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

Отдельно скажу пару слов про класс SavableValue<>. Служебный класс для хранения любых сериализуемых типов в Prefs пришлось продублировать в этом модуле, чтобы не тянуть отдельный namespace Utils. Мне не известно, как хорошо работает BinaryFormatter на отличных от мобильных платформах.

Что получилось в итоге

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

IAudioPlayer mock = Substitute.For<IAudioPlayer >();
var testClass = new Class(mock);

Доступ к классам ограничен интерфейсами, ничего лишнего с ними сделать не получится (если не учитывать абуз с неверными audioCode). Никаких лишних зависимостей, кроме namespace HexGrimmDev.Audio не тянется. Как и в рекомендациях Марка Симона, вся лишняя ответственность вынесена за класс и по необходимости может передаваться через конструктор. Нет никаких внешних логических связей, можно распространять модуль как git-submodule.

Я понимаю, что не все изоляции одинаково полезные, но в данном случае для создания шва лишнего времени много не потребовалось. Для большего воодушевления предлагаю ознакомиться с лекцией Олега Чумакова на тему “Почему ваш Unity проект должен работать в консоли?”.

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

Функциональный перечень в примере кода:

  • Воспроизведение и контроль 2d и 3d звуков а так же музыки.
  • Балансировка звука. (передается float аргумент с 0-1 диапазоном для точной балансировки отдельных звуков) (учитывается при изменении громкости)
  • Возможность зацикливания.
  • Изменение позиции слушателя для 3d звуков.
  • Есть случайный сдвиг pitch +-0.1f для всех звуков кроме музыки. (для примера)
  • Пауза и возобновление для музыки.

Из конкретных особенностей:

  • AudioMixer не используется.
  • В коде много магических чисел, подлежит рефакторингу перед использованием.
  • Нет плавного перехода между музыкальными клипами, можно реализовать множеством способов.
  • Из-за урезания кода и после удаления тестов есть вероятность что что-то работает не корректно, код является в первую очередь примером, а не средством.
  • Для написания тестов рекомендуется ввести шов между компонентами юнити и AudioController, и работать с AudioSource и AudioListener через дополнительные абстракции, а в тесте заменять абстракции на пустышки. К тому же так тест будет выполняться за минимум времени.

Автор: HexGrimm

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js