ホーム < ゲームつくろー! < Unity/サウンド編

サウンド編
その4 SoundPlayerでBGM再生


 前章でSoundPlayerというどこからでも呼べる便利な音再生装置を作りました。今の所再生できるのは登録したSEだけです。この章ではBGM再生の機能を追加します。



@ BGMは基本1つ

 SEはガンガン重なるのが前提ですが、BGMは基本的には1つのみ再生となります。ただし、次のBGMを再生する時にクロスフェードが入る事もあるため、2つ以上の同時再生も一応サポートしておいた方が良いでしょう。

 インターフェイスはSoundPlayer.playBGMとし、SEとは明確に区別します。これはSEとBGMで使用上の性質がまるで違うからです。SEは沢山重ねても一応OK。一方BGMは重ねる数もタイミングも異なります。



A BGM再生に必要な要素

 BGMを鳴らすには最低でも「再生(playBGM)」「一時停止(pauseBGM)」「停止(stopBGM)」の機能が必要です。

 再生(PlayBGM)は呼ばれた時に指定のBGMを再生します。この時、すでに鳴っているBGMは消してしまいます。この消し方として、いきなりズバッと切ってしまう事もあれば、クロスフェードで徐々に消していくというのもあります。そのため、PlayBGMの引数にはフェードタイム(fadeTime)を渡せるようにします。再生には、また一時停止させている曲を再生するという別の仕事もあります。

 一時停止(pauseBGM)は再生されているBGMを一時的に止めます。通常一時停止時にフェードする事はありません。一時停止した曲を再生させるには再生(playBGM)を呼びます。

 停止(stopBGM)は曲の再生をやめてしまいます。一度やめると再生してもまた最初からです。曲をやめる際もフェードアウトが入る事がありますので、やはりフェードタイム(fadeTime)を引数で渡します。


 このBGMの再生機構ですが、実は結構な状態遷移を起こします。例えば再生中に再びplayBGMが呼ばれるとか、クロスフェード中に一時停止させた後に別の曲再生を指示された場合など、かなり様々な状態が考えられます。これは遷移図をしっかり書いて、各遷移での振る舞いを決めておかないと実装がごちゃごちゃになってしまいます。ただ、1曲について再生、一時停止、停止の処理ができれば、2曲目以降も同じ機構を扱えますので、まずは1曲の再生の状態遷移について見て行きましょう。


○ 待機中遷移

 待機中遷移(wait)は、全く何も再生されていない状態を指します。この段階で各メソッドが呼ばれた時の処理は次の通り:

呼び出しメソッド 遷移先
playBGM フェードイン遷移(FadeIn)
再生中遷移(Playing)
指定のBGMをフェードインで再生
フェードインしない場合はすぐに再生
pauseBGM 何もしない
stopBGM 何もしない

playBGMが呼ばれた時のみ考えればOK。



○ フェードイン遷移

 BGMがフェードイン(fadeIn)している最中を指します。少なくともこの状態遷移では音が鳴っているはずです:

呼び出しメソッド 遷移先
playBGM 何もしない(再生維持)
pauseBGM ポーズ遷移(pause) 一時停止する
stopBGM フェードアウト遷移(stop) フェードアウト

フェードインは時間制なので、一定時間経過すると再生中遷移に移行します。



○ 再生中遷移

 BGMが100%の音量で鳴っている遷移です:

呼び出しメソッド 遷移先
playBGM 何もしない(再生維持)
pauseBGM ポーズ遷移(pause) 一時停止する
stopBGM フェードアウト遷移(stop) フェードアウト



○ ポーズ中遷移

 BGMを一時的に止めている遷移です。再生するにはplayBGMを呼び出します:

呼び出しメソッド 遷移先
playBGM 再生中遷移 止めた位置から再生
pauseBGM 何もしない(一時停止維持)
stopBGM 待機中遷移 即時終了



○ フェードアウト遷移

 停止処理を行った時に音をじょじょに小さくする遷移です。この状態からplayBGMをしてもまだ再生中ではあるので無効とします:

呼び出しメソッド 遷移先
playBGM 何もしない
pauseBGM ポーズ中遷移 一時停止
stopBGM 待機中遷移 即時終了

フェードアウト遷移は時間制なので、一定時間経過して音が完全に消えた段階で待機中遷移に移行します。


 という事で、各遷移の間にそれぞれのメソッドが呼ばれると、その時々で適切な遷移に移動する事になります。これをうま〜く実装するにはどうしたら良いか?各遷移の中では同じメソッドを処理しているのに注目です。なので例えば「State」という親クラスを作り、それぞれの遷移の子クラスを実装し、子クラス間で遷移できるようにしたら良さそうな気がしますね。そういう設計でいってみましょうか。



B BGMPlayerクラス

 BGMPlayerクラスは先の状態遷移(State)を内部に持って管理するクラスです。また外部の人ともやりとりする仲介人でもあって、playBGMとかpauseBGMとかstopBGMメソッドなどを提供しています。BGMPlayerはそのメソッドが呼び出されたら、それをそっくり現在のState派生クラスのオブジェクトに投げます。

 実装はこんな感じでしょうか:

BGMPlayer.cs
class BGMPlayer {
    // State
    class State {
        protected BGMPlayer bgmPlayer;
        public State( BGMPlayer bgmPlayer ) {
            this.bgmPlayer = bgmPlayer;
        }
        public virtual void playBGM() {}
        public virtual void pauseBGM() {}
        public virtual void stopBGM() {}
        public virtual void update() {}
    }

    /*
    ...ここにStateの派生クラスが並ぶ〜...
    */

    GameObject obj;
    AudioSource source;
    State state;
    float fadeInTime = 0.0f;
    float fadeOutTime = 0.0f;

    public BGMPlayer() {}

    public BGMPlayer( string bgmFileName ) {
       AudioClip clip = (AudioClip)Resources.Load( bgmFileName );
    if ( clip != null ) {
        obj = new GameObject( "BGMPlayer" );
        source = obj.AddComponent<AudioSource>();
        source.clip = clip;
        state = new Wait( this );
    } else
        Debug.LogWarning( "BGM " + bgmFileName + " is not found." );
    }

    public void destory() {
        if ( source != null )
            GameObject.Destroy( obj );
    }

    public void playBGM() {
        if ( source != null ) {
            state.playBGM();
        }
    }

    public void playBGM( float fadeTime ) {
        if ( source != null ) {
            this.fadeInTime = fadeTime;
            state.playBGM();
        }
    }

    public void pauseBGM() {
        if ( source != null )
            state.pauseBGM();
    }

    public void stopBGM( float fadeTime ) {
        if ( source != null ) {
            fadeOutTime = fadeTime;
            state.stopBGM();
        }
    }

    public void update() {
        if ( source != null )
            state.update();
    }
}

 色々ありますが、まずメンバ変数に注目です。ここにはGameObjectとAudioSourceがあります。BGMPlayerさんは単独で動くようにしたかったので、内部でGameObjectを作り、それにAudioSourceをくっつけます。それをしているのがコンストラクタです。コンストラクタにはBGMのファイル名(=リソース名)を直接渡しています。それをロードしてAudioClipを作っています。

 Stateも持っていますよね(state)。で、これをplayBGMとかstopBGMメソッドなどで呼んでいるのが分かると思います。外部と仲介しているけど、仕事は委譲していて、具体的に何が起こるのか検知していないわけです。

 fadeInTimeとfadeOutTimeはその名前の通りフェードインもしくはフェードアウトする時間です。メンバとして保持するのは、フェードインを担当するStateがそれを参照しなければならないためです。再生パラメータと考えればBGMPlayerさんが持っているのが妥当かなと。

 地味に大切なのがdestroyメソッド。これはBGMPlayerが不必要になった時に呼び出します。呼ぶとGameObjectであるobjを削除するため、AudioSourceは直ちに無くなり、オブジェクト自体もヒエラルキから無くなります。逆に言えば、これを呼び出さないとゾンビオブジェクトが残る事になります。「デストラクタに書けば?」と思うかもしれませんが、Unityでデストラクタを書くと怒られます。C#もデストラクタの呼び出しは曖昧で、あてにならない感があります。

 さて、この管理クラスであるBGMPlayerさんの具体的な振る舞いを記述するのがState派生クラス群です。一つずつ見て行きましょう。



○ 待機中遷移クラス(Wait)

BGMPlayer.Wait
class Wait : State {

    public Wait( BGMPlayer bgmPlayer ) : base( bgmPlayer ) {}

    public override void playBGM() {
        if ( bgmPlayer.fadeInTime > 0.0f )
            bgmPlayer.state = new FadeIn( bgmPlayer );
        else
            bgmPlayer.state = new Playing( bgmPlayer );
    }
}
呼び出しメソッド 遷移先
playBGM フェードイン遷移(FadeIn)
再生中遷移(Playing)
指定のBGMをフェードインで再生
フェードインしない場合はすぐに再生
pauseBGM 何もしない
stopBGM 何もしない

まずコンストラクタに管理人を渡します。これで管理人のメンバを触り放題です。待機中遷移は、上の表を見るとplayBGMだけが有効です。なのでコードもそれだけを実装しています。ここではフェードインがある場合はフェードイン遷移に、フェードインしない場合は再生中遷移に移行するとあります。bgmPlayerさんはfadeInTimeを持っていますので、この数値を見て次の遷移オブジェクトを作っています。



○ フェードイン遷移クラス(FadeIn)

BGMPlayer.FadeIn
class FadeIn : State {

    float t = 0.0f;

    public FadeIn( BGMPlayer bgmPlayer ) : base( bgmPlayer ) {
        bgmPlayer.source.Play();
        bgmPlayer.source.volume = 0.0f;
    }

    public override void pauseBGM() {
        bgmPlayer.state = new Pause( bgmPlayer, this );
    }

    public override void stopBGM() {
        bgmPlayer.state = new FadeOut( bgmPlayer );
    }

    public override void update() {
        t += Time.deltaTime;
        bgmPlayer.source.volume = t / bgmPlayer.fadeInTime;
        if ( t >= bgmPlayer.fadeInTime ) {
           bgmPlayer.source.volume = 1.0f;
           bgmPlayer.state = new Playing( bgmPlayer );
        }
    }
}
呼び出しメソッド 遷移先
playBGM 何もしない(再生維持)
pauseBGM ポーズ遷移(pause) 一時停止する
stopBGM フェードアウト遷移(stop) フェードアウト

フェードインではポーズ指示が来た時と停止指示が来た時の処理を記述します。コンストラクタではbgnPlayerが持っている音を再生して、ボリュームを0に初期化しています。またフェードインタイムを計測するためにbgnPlayer.tも0に初期化です。

 ポーズの指示が来た時には状態を一時停止状態に移行させます(new Pause())。止める時はFadeOutオブジェクトを作って状態遷移です。

 フェードインは毎フレームボリューム値を変更する必要があるためupdateメソッドがオーバーライドされています。この中では現在時刻tを差分時間Time.deltaTimeだけ追加しています。ボリュームは0〜1の間になるため、tをフェードイン時間で割って標準化しています。フェードイン時間が完了したら、状態を再生中(Playing)に移行しています。



○ 再生中遷移クラス(Playing)

BGMPlayer.Playing
class Playing : State {

    public Playing( BGMPlayer bgmPlayer ) : base( bgmPlayer ) {
        if ( bgmPlayer.source.isPlaying == false ) {
            bgmPlayer.source.volume = 1.0f;
            bgmPlayer.source.Play();
        }
    }

    public override void pauseBGM() {
        bgmPlayer.state = new Pause( bgmPlayer, this );
    }

    public override void stopBGM() {
        bgmPlayer.state = new FadeOut( bgmPlayer );
    }
}
呼び出しメソッド 遷移先
playBGM 何もしない(再生維持)
pauseBGM ポーズ遷移(pause) 一時停止する
stopBGM フェードアウト遷移(stop) フェードアウト

 再生中遷移に移った時には、コンストラクタで現在再生中かをチェックします。もし止まっていたら再生します。再生中は基本的にぼ〜っとしていて良いので、状態遷移が起こった時だけ振る舞えばOK。そのため、実装もポーズへ移行する時はPauseオブジェクトを作り、停止したい時にはFadeOut遷移を作って状態遷移を起こしています。簡単(^-^)



○ ポーズ中遷移クラス(Pause)

BGMPlayer.Pause
class Pause : State {

    State preState;

    public Pause( BGMPlayer bgmPlayer, State preState ) : base( bgmPlayer ) {
        this.preState = preState;
        bgmPlayer.source.Pause();
    }

    public override void stopBGM() {
        bgmPlayer.source.Stop();
        bgmPlayer.state = new Wait( bgmPlayer );
    }

    public override void playBGM() {
        bgmPlayer.state = preState;
        bgmPlayer.source.Play();
    }
}
呼び出しメソッド 遷移先
playBGM 再生中遷移 止めた位置から再生
pauseBGM 何もしない(一時停止維持)
stopBGM 待機中遷移 即時終了

ポーズ中遷移クラスにはメンバ変数としてpreStateがいます。これはポーズを呼び出される前の遷移オブジェクトです。ポーズは解除された時に前の状態に戻す必要があるためこれを保持しています。これがあるおかげで、ポーズクラス自体の処理は再生時に状態を戻し、停止時に停止させるだけになっています。



○ フェードアウト遷移クラス(FadeOut)

BGMPlayer.FadeOut
class FadeOut : State {
    float initVolume;
    float t = 0.0f;

    public FadeOut( BGMPlayer bgmPlayer ) : base( bgmPlayer ) {
        initVolume = bgmPlayer.source.volume;
    }

    public override void pauseBGM() {
        bgmPlayer.state = new Pause( bgmPlayer, this );
    }

    public override void update() {
        t += Time.deltaTime;
        bgmPlayer.source.volume = initVolume * ( 1.0f - t / bgmPlayer.fadeOutTime );
        if ( t >= bgmPlayer.fadeOutTime ) {
            bgmPlayer.source.volume = 0.0f;
            bgmPlayer.source.Stop();
            bgmPlayer.state = new Wait( bgmPlayer );
        }
    }
}
呼び出しメソッド 遷移先
playBGM 何もしない
pauseBGM ポーズ中遷移 一時停止
stopBGM 待機中遷移 即時終了

 フェードアウトは現在のボリュームを時間と共に0にしていく作業なので初期ボリューム(initVolume)を保持しておく必要があります。後はフェードインと同じような処理をするだけです。フェードアウトが完全に終了したら、音声もStopで止めて(これ大事です)、状態を待機中(Wait)にして終了です。フェードアウト中にポーズが掛かる可能性もありますので、pauseBGMメソッドもオーバーライドしています。


 以上をBGMPlayerクラスの中に収めると、BGMPlayerクラスが出来あがります。



C テストしてみましょう

 BGMPlayerさんがちゃんと動くかどうかテストしてみましょう。

 まず、BGMPlayerクラスはBGMをロードする事を前提にしているため、BGMとなるリソース(AudioClip)をProject内のResourcesフォルダ下に置きます。ここに無いとリソースはロードできません:

 次にヒエラルキに空のGameObjectを置き「Test」とでも改名しておきましょう。またテスト用のスクリプトとしてBGMPlayerTest.csを作り、Testオブジェクトにアタッチしておきましょう:

 BGMPlayerTestコンポーネントは簡単なサウンドプレイヤーのようなものにしようかと思います。Inspector上には再生したいBGMのAudioClipの名前を文字列で指定するようにします。またフェードイン、フェードアウト時間もそこで指定します。後はゲーム画面に「Play(再生)」「Pause(一時停止)」「Stop(停止)」ボタンを設置します。

 BGMPlayerTestクラスの実装はこんな感じです:

BGMPlayerTest.cs
using UnityEngine;
using System.Collections;

public class BGMPlayerTest : MonoBehaviour {

    public string audioClipName = "";
    public float fadeInTime = 0.0f;
    public float fadeOutTime = 0.0f;

    BGMPlayer player;

    void OnGUI() {
        // Play
        if ( GUI.Button( new Rect( 20, 20, 60, 45 ), "Play" ) == true ) {
            if ( player == null ) {
                player = new BGMPlayer( audioClipName );
                player.playBGM( fadeInTime );
            } else
                player.playBGM();
        }

        // Pause
        if ( GUI.Button( new Rect( 80, 20, 60, 45 ), "Pause" ) == true ) {
            if ( player != null )
                player.pauseBGM();
        }

        // Stop
        if ( GUI.Button( new Rect( 140, 20, 60, 45 ), "Stop" ) == true ) {
            if ( player != null )
                player.stopBGM( fadeOutTime );
        }

        // Delete
        if ( GUI.Button( new Rect( 200, 20, 60, 45 ), "Delete" ) == true ) {
            if ( player != null ) {
                player.destory();
                player = null;
            }
        }
    }

    // Update is called once per frame
    void Update () {
        if ( player != null )
            player.update();
    }
}

 意外とメンドクサ…。

 これでTestゲームオブジェクトのInspectorの[Audio Clip Name]に"Encounter_loop"と入れてゲームを実行すると、こんな画面が出てきて、サウンドプレイヤーになります:

実際に動作するのはこちらです: UNI_SND_No4_Test01.html

※仕様曲提供サイト様
Wingless Seraph: http://wingless-seraph.net/
BGM: Encount (Encounter_loop.ogg)

ふ〜、さ、この章も終了…じゃなかった、この章の目標はSoundPlayerクラスでBGMを鳴らせるようにする事でした。もう少し頑張ります(^-^;



D BGMPlayerをSoundPlayerに組み込む

 SoundPlayerクラスは現在登録した名前のSEを鳴らす事ができます。この仕組みを踏襲してBGMも鳴らせるようにしましょう。

 まず、最終的にゲームのスクリプト内でBGMを鳴らすためのSoundPlayerの呼び出し方法はこんな一行です:

Singleton<SoundPlayer>.instance.playBGM( "bgm001", 3.0f );

"bgm001"というプログラム内でのBGM名な音をフェードイン(クロスフェード)3.0秒で鳴らします。これを目指します。

 ここで重要なのが「クロスフェード」。つまり現在なっている曲をフェードアウトしつつ、指定された曲をフェードインで鳴らします。クロスフェードが起こっている間は2つの曲が同時に存在している事になります。そしてフェードアウトしきったAudioSourceはヒエラルキから無くならないといけません。この辺りが、んーメンドクサイ。

 SoundPlayerの中には2つのBGMPlayerオブジェクトが存在します:

SoundPlayer.cs
public class SoundPlayer {
    BGMPlayer curBGMPlayer;
    BGMPlayer fadeOutBGMPlayer;
}

curBGMPlayerが今なっている音で、fadeOutBGMPlayerにはフェードアウト中の音が一時的に格納されます。

 SoundPlayer.playBGMメソッドでクロスフェードを行わせます:

SoundPlayer.cs
public void playBGM( string bgmName, float fadeTime ) {
    // destory old BGM
    if ( fadeOutBGMPlayer != null )
        fadeOutBGMPlayer.destory();

    // change to fade out for current BGM
    if ( curBGMPlayer != null ) {
        curBGMPlayer.stopBGM( fadeTime );
        fadeOutBGMPlayer = curBGMPlayer;
    }

    // play new BGM
    if ( audioClips.ContainsKey( bgmName ) == false ) {
        // null BGM
        curBGMPlayer = new BGMPlayer();
    } else {
        curBGMPlayer = new BGMPlayer( audioClips[ bgmName ].resourceName );
        curBGMPlayer.playBGM( fadeTime );
    }
}

 「bgmNameを再生して〜」と命令が来た時、まず現在フェードアウトしている曲は強制的に消してしまいます。これは3重再生をサポートしないからです。

 もし現在再生している曲が存在していたら(curBGMPlayer != null)、フェードアウト指示して(stopBGM)、フェードアウトBGM側へ移します。

 要求された曲を次に新規で鳴らします。もしその曲が無ければ空っぽのBGMPlayerオブジェクトにします。あればそれを再生するだけです。BGMPlayerはオブジェクトごとにフェードイン、フェードアウトをサポートしているので、これだけで勝手にクロスフェードしてくれます。

 SoundPlayerには後pauseBGMとstopBGMメソッドがあればOK。これらのメソッドの中では現在の曲及びフェードアウト中の曲のstopBGMメソッドを呼ぶだけです:

SoundPlayer.cs
public void playBGM() {
    if ( curBGMPlayer != null )
        curBGMPlayer.playBGM();
    if ( fadeOutBGMPlayer != null )
        fadeOutBGMPlayer.playBGM();
}

public void pauseBGM() {
    if ( curBGMPlayer != null )
        curBGMPlayer.pauseBGM();
    if ( fadeOutBGMPlayer != null )
        fadeOutBGMPlayer.pauseBGM();
}

public void stopBGM( float fadeTime ) {
    if ( curBGMPlayer != null )
        curBGMPlayer.stopBGM( fadeTime );
    if ( fadeOutBGMPlayer != null )
        fadeOutBGMPlayer.stopBGM( fadeTime );
}

nullチェックが面倒くさいのですが、やらないとまずいのでしてます(-_-;。

 さて、この実装をしていて一つまずい事に気が付きました。一度ポーズして再生する時(playBGM)に、現在の曲とフェードアウト中の曲を両方とも再度再生させているのですが、フェードアウトしきった曲の場合、それが再度最初から鳴ってしまうんです。

 これはフェードアウトしきった曲が「待機中遷移(Wait)」に戻ってしまうためです。待機中遷移で再生指示が来たら、その曲は再生されてしまうんです。これは、仕様上の不備と言えば不備です。

 ではどうするか?フェードアウトしきった曲をplayBGMすると再生するという機能自体はそのままにしておきたいので、フェードアウトしきったかどうかを判定するフラグをBGMPlayerさんに追加する事にしましょう。

 実装は簡単で、フェードアウトしきった瞬間はFadeOut.updateメソッド内で取れるので、ここを通過したらフラグをtrueに、待機中状態から再生する時にそのフラグを下します。で、BGMPlayerにはhadFadeOutメソッドを追加してフラグを取得します。

 これを用いてSoundPlayer.playBGM()を修正するとこうなります:

SoundPlayer.cs
public void playBGM() {
    if ( curBGMPlayer != null && curBGMPlayer.hadFadeOut() == false )
        curBGMPlayer.playBGM();
    if ( fadeOutBGMPlayer != null && fadeOutBGMPlayer.hadFadeOut() == false )
        fadeOutBGMPlayer.playBGM();
}

これでばっちり(^-^)。

 テストはこちら:UNI_SND_No4_Test02.html

※仕様曲提供サイト様
Wingless Seraph: http://wingless-seraph.net/
BGM: Encount (Encounter_loop.ogg)

BGM: The Justice and Lost Emotion (Lost_Emotion_loop.ogg)


 SoundPlayerを使ってSEとBGMを鳴らせるようになりました。ボリュームコントロールや音データの登録など追加する事は色々ありますが、基盤としては十分です。