【Unity】サウンド管理クラスの設計

## はじめに
Unityでサウンドを再生する場合、サウンドリソースをゲームオブジェクトにアタッチする方法が一般的なのかもしれません。ですが毎回オブジェクトにアタッチするのは面倒です。なのでサウンド管理クラスを作ってみました。実装方法は「[ゲームつくろー！さんの『Unity編 > サウンド編』ところに書かれている内容](http://marupeke296.com/UNI_main.html "ゲームつくろー！さんの『Unity編 > サウンド編』ところに書かれている内容")」をかなり参考にしていますが、チャンネル指定の再生など少しだけアレンジをしています。

## 使い方
まずは使い方です。手順は以下のとおりとなります。

1. サウンドリソースを登録
2. ロード処理を記述
3. 再生処理を記述

### 1. サウンドリソースを登録
サウンドファイルをProjectビューの「Assets/Resources/Sound」フォルダにドラッグ＆ドロップします。
![sound01.png](https://qiita-image-store.s3.amazonaws.com/0/43772/d3299c06-9b96-4a05-1a6b-47cb00637330.png)

このモジュールはこのフォルダからサウンドリソースを読み込むようにしています。
あとInspectorビューのサウンド設定から「3D Sound」のチェックを外しておきます。
![sound02.png](https://qiita-image-store.s3.amazonaws.com/0/43772/d07b2775-651f-d113-6946-e8d6a6b4da28.png)

3Dサウンドが必要な場合もありますが、たいていの場合は不要なのかなと思います。


### 2. ロード処理を記述
サウンドをロードするには、`Sound.LoadBgm` / `Sound.LoadSe` を使用します。それぞれBGMとSEのロード処理となり、以下は例となります。

```csharp:GameMgr.cs
    // サウンドをロード
    // "bgm01"をロード。キーは"bgm"とする
    Sound.LoadBgm("bgm", "bgm01");
    // "damage"をロード。キーは"damage"とする
    Sound.LoadSe("damage", "damage");
    // "destroy"をロード。キーは"destroy"とする
    Sound.LoadSe("destroy", "destroy");
```

第一引数が再生するための「キー」、第二引数が実際のリソースとなります。再生する処理はそのままでリソース名だけ変更したい、なんてことがゲーム開発中にはよくあるので、キー名とリソース名は別にしています。

### 3. 再生処理を記述
BGMの再生と停止は以下のように行います。

```csharp:GameMgr.cs
/// ゲーム管理
public class GameMgr : MonoBehaviour {
  /// 開始
  void Start() {
    // BGM再生開始
    Sound.PlayBgm("bgm");
  }

  /// 破棄
  void OnDestoy() {
    // BGM停止
    Sound.StopBgm();
  }
}
```

SEの再生は以下のように行います。

```csharp:Player.cs
public class Player :  {
  /// 破棄
  void OnDestroy() {
    // やられSE再生
    Sound.PlaySe("damage");
  }
}
```

ただ通常のSE再生は、連続で同じSEを再生した場合も重なって再生するようになっています。そのため、例えばボスの爆破演出の際に、連続で爆発SEを再生すると、音が重なって音量が大きくなりノイズ音となります。
それを回避するにはチャンネル指定で再生を行います。

```csharp:Boss
  // 爆破演出SE再生 (チェンネル「0」版を使って再生）
  Sound.PlaySe("bomb", 0);
```

これを使って再生すると、「ドーン」という音を7回連続で再生した場合「ドドドドドドドーン」と再生されるようになります。

## ソースコード解説
サウンド管理クラスのソースコード解説です。
### クラス定義・メンバ変数
サウンド管理クラスのクラス定義とメンバ変数は以下のとおりです。

```csharp:Sound.cs
/// サウンド管理
public class Sound {

  /// SEチャンネル数
  const int SE_CHANNEL = 4;

  /// サウンド種別
  enum eType {
    Bgm, // BGM
    Se,  // SE
  }

  // シングルトン
  static Sound _singleton = null;
  // インスタンス取得
  public static Sound GetInstance()
  {
    return _singleton ?? (_singleton = new Sound());
  }

  // サウンド再生のためのゲームオブジェクト
  GameObject _object = null;
  // サウンドリソース
  AudioSource _sourceBgm = null; // BGM
  AudioSource _sourceSeDefault = null; // SE (デフォルト)
  AudioSource[] _sourceSeArray; // SE (チャンネル)
  // BGMにアクセスするためのテーブル
  Dictionary<string, _Data> _poolBgm = new Dictionary<string, _Data>();
  // SEにアクセスするためのテーブル 
  Dictionary<string, _Data> _poolSe = new Dictionary<string, _Data>();

  ……

```

サウンド管理クラスは、シングルトンでどこからでもアクセスできるようにしています。そしてサウンド再生には、ゲームオブジェクトが必要となります（正確にはゲームオブジェクトが持つ `AudioSource` コンポーネント）。そのため、ゲームオブジェクトをメンバ変数として保持しています。それと、サウンドのキー名とそれに対応するリソース名のテーブルを持っています。

### キー名とリソース名のテーブル
キー名とリソース名のクラスは以下のように定義しています。

```csharp:Sound.cs
  /// 保持するデータ
  class _Data {
    /// アクセス用のキー
    public string Key;
    /// リソース名
    public string ResName;
    /// AudioClip
    public AudioClip Clip;

    /// コンストラクタ
    public _Data(string key, string res) {
      Key = key;
      ResName = "Sounds/" + res;
      // AudioClipの取得
      Clip = Resources.Load(ResName) as AudioClip;
    }
  }
```

指定されたリソース名からサウンドリソースを取得し、メンバ変数の `Clip` に格納しています。

### コンストラクタ
Soundのコンストラクタは、SEチャンネルのバッファを格納しているだけとなります。

```csharp:Sound.cs
  /// コンストラクタ
  public Sound() {
    // チャンネル確保
    _sourceSeArray = new AudioSource[SE_CHANNEL];
  }

```

### サウンドのロード
LoadBgmを見てみます。

```csharp:Sound.cs
  void _LoadBgm(string key, string resName) {
    if (_poolBgm.ContainsKey(key))
    {
      // すでに登録済みなのでいったん消す
      _poolBgm.Remove(key);
    }
    _poolBgm.Add(key, new _Data(key, resName));
  }

```

ロード関数が呼び出されたタイミングで、_Dataクラスにキー名とリソース名を渡し、ディクショナリにそのデータを登録しています。

### BGMの再生
BGMの再生は以下のとおりです。

```csharp:Sound.cs
  bool _PlayBgm(string key) {
    if(_poolBgm.ContainsKey(key) == false) {
      // 対応するキーがない
      return false;
    }

    // いったん止める
    _StopBgm();

    // リソースの取得
    var _data = _poolBgm[key];

    // 再生
    var source = _GetAudioSource(eType.Bgm);
    source.loop = true;
    source.clip = _data.Clip;
    source.Play();

    return true;
  }

```

指定されたBGMのキー名に対応するデータを取り出し、BGMを再生しています。AudioSourceを取り出す`_GetAudioSource`という関数がキモとなるので、これについて説明をします。

```csharp:Sound.cs
  /// AudioSourceを取得する
  AudioSource _GetAudioSource(eType type, int channel=-1) {
    if(_object == null) {
      // GameObjectがなければ作る
      _object = new GameObject("Sound");
      // 破棄しないようにする
      GameObject.DontDestroyOnLoad(_object);
      // AudioSourceを作成
      _sourceBgm = _object.AddComponent<AudioSource>();
      _sourceSeDefault = _object.AddComponent<AudioSource>();
      for (int i = 0; i < SE_CHANNEL; i++)
      {
        _sourceSeArray[i] = _object.AddComponent<AudioSource>();
      }
    }

    if(type == eType.Bgm) {
      // BGM
      return _sourceBgm;
    }
    else {
      // SE
      if (0 <= channel && channel < SE_CHANNEL)
      {
        // チャンネル指定
        return _sourceSeArray[channel];
      }
      else
      {
        // デフォルト
        return _sourceSeDefault;
      }
    }
  }
```

AudioSourceを取得するには、ゲームオブジェクトを生成する必要があります。なので、メンバ変数の「_object」がnullであればゲームオブジェクトを生成して、そこから必要なAudioSourceのコンポーネントを追加しています。指定されたサウンド種別が「BGM」であればBGMのAudioSourceを取り出し、「SE」であればSEのAudioSourceを取り出しています。

### SEの再生
SE再生は以下のとおりです。

```csharp:Sound.cs
  bool _PlaySe(string key, int channel=-1) {
    if(_poolSe.ContainsKey(key) == false) {
      // 対応するキーがない
      return false;
    }

    // リソースの取得
    var _data = _poolSe[key];

    if (0 <= channel && channel < SE_CHANNEL)
    {
      // チャンネル指定
      var source = _GetAudioSource(eType.Se, channel);
      source.clip = _data.Clip;
      source.Play();
    }
    else
    {
      // デフォルトで再生
      var source = _GetAudioSource(eType.Se);
      source.PlayOneShot(_data.Clip);
    }

    return true;
  }
```

チャンネル指定があった場合は、対応する番号のAuidioSourceで再生を行います。チャンネル指定がない場合は、AudioSource.PlayOneShotでサウンド再生を行い、複数の音を同時に再生できるようにしています。

## ソースコード
サウンド管理クラス(Sound.cs)のコードは以下のとおりとなります。

* [Sound.cs](https://gist.github.com/syun77/69f2b992c97860b5b94e "Sound.cs")

```csharp:Sound.cs
using UnityEngine;
using System.Collections;
using System.Collections.Generic;

/// サウンド管理
public class Sound {

  /// SEチャンネル数
  const int SE_CHANNEL = 4;

  /// サウンド種別
  enum eType {
    Bgm, // BGM
    Se,  // SE
  }

  // シングルトン
  static Sound _singleton = null;
  // インスタンス取得
  public static Sound GetInstance()
  {
    return _singleton ?? (_singleton = new Sound());
  }

  // サウンド再生のためのゲームオブジェクト
  GameObject _object = null;
  // サウンドリソース
  AudioSource _sourceBgm = null; // BGM
  AudioSource _sourceSeDefault = null; // SE (デフォルト)
  AudioSource[] _sourceSeArray; // SE (チャンネル)
  // BGMにアクセスするためのテーブル
  Dictionary<string, _Data> _poolBgm = new Dictionary<string, _Data>();
  // SEにアクセスするためのテーブル 
  Dictionary<string, _Data> _poolSe = new Dictionary<string, _Data>();

  /// 保持するデータ
  class _Data {
    /// アクセス用のキー
    public string Key;
    /// リソース名
    public string ResName;
    /// AudioClip
    public AudioClip Clip;

    /// コンストラクタ
    public _Data(string key, string res) {
      Key = key;
      ResName = "Sounds/" + res;
      // AudioClipの取得
      Clip = Resources.Load(ResName) as AudioClip;
    }
  }

  /// コンストラクタ
  public Sound() {
    // チャンネル確保
    _sourceSeArray = new AudioSource[SE_CHANNEL];
  }

  /// AudioSourceを取得する
  AudioSource _GetAudioSource(eType type, int channel=-1) {
    if(_object == null) {
      // GameObjectがなければ作る
      _object = new GameObject("Sound");
      // 破棄しないようにする
      GameObject.DontDestroyOnLoad(_object);
      // AudioSourceを作成
      _sourceBgm = _object.AddComponent<AudioSource>();
      _sourceSeDefault = _object.AddComponent<AudioSource>();
      for (int i = 0; i < SE_CHANNEL; i++)
      {
        _sourceSeArray[i] = _object.AddComponent<AudioSource>();
      }
    }

    if(type == eType.Bgm) {
      // BGM
      return _sourceBgm;
    }
    else {
      // SE
      if (0 <= channel && channel < SE_CHANNEL)
      {
        // チャンネル指定
        return _sourceSeArray[channel];
      }
      else
      {
        // デフォルト
        return _sourceSeDefault;
      }
    }
  }

  // サウンドのロード
  // ※Resources/Soundsフォルダに配置すること
  public static void LoadBgm(string key, string resName) {
    GetInstance()._LoadBgm(key, resName);
  }
  public static void LoadSe(string key, string resName) {
    GetInstance()._LoadSe(key, resName);
  }
  void _LoadBgm(string key, string resName) {
    if (_poolBgm.ContainsKey(key))
    {
      // すでに登録済みなのでいったん消す
      _poolBgm.Remove(key);
    }
    _poolBgm.Add(key, new _Data(key, resName));
  }
  void _LoadSe(string key, string resName) {
    if (_poolSe.ContainsKey(key))
    {
      // すでに登録済みなのでいったん消す
      _poolSe.Remove(key);
    }
    _poolSe.Add(key, new _Data(key, resName));
  }

  /// BGMの再生
  /// ※事前にLoadBgmでロードしておくこと
  public static bool PlayBgm(string key) {
    return GetInstance()._PlayBgm(key);
  }
  bool _PlayBgm(string key) {
    if(_poolBgm.ContainsKey(key) == false) {
      // 対応するキーがない
      return false;
    }

    // いったん止める
    _StopBgm();

    // リソースの取得
    var _data = _poolBgm[key];

    // 再生
    var source = _GetAudioSource(eType.Bgm);
    source.loop = true;
    source.clip = _data.Clip;
    source.Play();

    return true;
  }
  /// BGMの停止
  public static bool StopBgm() {
    return GetInstance()._StopBgm();
  }
  bool _StopBgm() {
    _GetAudioSource(eType.Bgm).Stop();

    return true;
  }

  /// SEの再生
  /// ※事前にLoadSeでロードしておくこと
  public static bool PlaySe(string key, int channel=-1) {
    return GetInstance()._PlaySe(key, channel);
  }
  bool _PlaySe(string key, int channel=-1) {
    if(_poolSe.ContainsKey(key) == false) {
      // 対応するキーがない
      return false;
    }

    // リソースの取得
    var _data = _poolSe[key];

    if (0 <= channel && channel < SE_CHANNEL)
    {
      // チャンネル指定
      var source = _GetAudioSource(eType.Se, channel);
      source.clip = _data.Clip;
      source.Play();
    }
    else
    {
      // デフォルトで再生
      var source = _GetAudioSource(eType.Se);
      source.PlayOneShot(_data.Clip);
    }

    return true;
  }
}
```

## 参考

* [その２ トリガー駆動のタイミングでSEを鳴らす](http://marupeke296.com/UNI_SND_No2_Trigger.html "その２ トリガー駆動のタイミングでSEを鳴らす")
* [その３ スクリプト内だけで音を鳴らす](http://marupeke296.com/UNI_SND_No3_SoundPlayer.html "その３ スクリプト内だけで音を鳴らす")
* [その４ SoundPlayerでBGM再生](http://marupeke296.com/UNI_SND_No4_BGM.html "その４ SoundPlayerでBGM再生")