🎶

【Unity】ScenarioFlowによるシナリオ実装#3-7(BGMと効果音の再生)

2023/06/24に公開

はじめに

こんにちは.伊都アキラです.
今回の記事では,AudioClipをScenarioMethodで扱えるようにし,BGM効果音を鳴らせるようにします.

注意していただきたいのは,今回実装するのはあくまで再生に関する部分だけです.
現時点で音量調整は実装しません
音量調整機能の実装については,今後にキャラクターボイスを実装する際にまとめて行います.

実装するクラス

今回は,次のクラスを実装します.

クラス名 役割
BgmMaker BGMの再生・停止
SeMaker 効果音(SE)の再生・停止
AudioClipProvider AudioClip用のDecoderの提供

AudioClipProviderに関しては,#3-3で実装したSpriteProviderのAudioClip版です.行う処理はSpriteがAudioClipに変わっただけですし,実際にAudioClipをロード・アンロードするためのIAssetLoaderもすでに前に実装済みなので,SpriteProviderを実装した時ほどの労力は必要としないでしょう.

では早速,各クラスを実装していきます.

BgmMakerクラス

BgmMaker.cs
using Cysharp.Threading.Tasks;
using DG.Tweening;
using ScenarioFlow;
using System;
using System.Threading;
using UnityEngine;

namespace AliceStandard.Sound
{
    [ScenarioMethod("bgm")]
    public class BgmMaker : IReflectable
    {
        //BGMを再生するAudioSource
        private readonly AudioSource bgmSource;
        //BGMを徐々に再生する場合に使用するEase
        private Ease bgmEase;
        //BGMを徐々に再生する場合にかける時間
        private float bgmDuration;

        public BgmMaker(Settings settings)
        {
            this.bgmSource = settings.BgmSource ?? throw new ArgumentNullException(nameof(settings.BgmSource));
            this.bgmSource.loop = true;
            this.bgmEase = settings.BgmEase;
            this.bgmDuration = settings.BgmDuration;
        }

        [ScenarioMethod("play.immed", "BGMをすぐに再生\nPlay the BGM immediately.")]
        public void PlayBgmImmidiately(AudioClip audioClip)
        {
            bgmSource.Stop();
            bgmSource.clip = audioClip;
            bgmSource.Play();
        }

        [ScenarioMethod("play.grad", "BGMを徐々に再生\nPlay the BGM gradually.")]
        public async UniTask PlayBgmGraduallyAsync(AudioClip audioClip, CancellationToken cancellationToken)
        {
            bgmSource.Stop();
            bgmSource.clip = audioClip;
            var bgmVolume = bgmSource.volume;
            bgmSource.volume = 0.0f;
            bgmSource.Play();
            try
            {
                await bgmSource.DOFade(bgmVolume, bgmDuration).SetEase(bgmEase).ToUniTask(cancellationToken: cancellationToken);
            }
            finally
            {
                bgmSource.volume = bgmVolume;
            }
        }

        [ScenarioMethod("stop.immed", "BGMをすぐに停止\nStop the BGM immediately.")]
        public void StopBgmImmediately()
        {
            bgmSource.Stop();
        }

        [ScenarioMethod("stop.grad", "BGMを徐々に停止\nStop the BGM gradually.")]
        public async UniTask StopBgmGradually(CancellationToken cancellationToken)
        {
            var bgmVolume = bgmSource.volume;
            try
            {
                await bgmSource.DOFade(0.0f, bgmDuration).SetEase(bgmEase).ToUniTask(cancellationToken: cancellationToken);
            }
            finally
            {
                bgmSource.Stop();
                bgmSource.volume = bgmVolume;
            }
        }

        [ScenarioMethod("ease", "BGMを徐々に再生するときのEaseを設定\nSet the Ease to play BGMs gradually.")]
        public void SetEase(Ease ease)
        {
            bgmEase = ease;
        }

        [ScenarioMethod("durat", "BGMを徐々に再生するときの時間を設定\nSet the duration to play BGMs gradually.")]
        public void SetDurationi(float duration)
        {
            bgmDuration = duration;
        }

        [Serializable]
        public class Settings
        {
            public AudioSource BgmSource;
            public Ease BgmEase;
            public float BgmDuration;
        }
    }
}

受け取ったAudioClipをBGMとして再生・停止する機能を持ちますが,再生する場合も停止する場合も,即座に再生・停止するか,徐々に音量を変化させるかを選ぶことができます.

例えば,play.immedではそれを呼び出した瞬間にBGMが最大音量で再生されますが,play.gradではdurationに設定された秒数をかけて,徐々に音量が上がっていきます.

また,徐々に音量を変化させて再生させる際はDOTweenのDOFadeを利用します.ここに設定するdurationeaseは,それぞれbgm.easebgm.duratで変更することができます.

SeMakerクラス

SeMaker.cs
using Cysharp.Threading.Tasks;
using ScenarioFlow;
using System;
using System.Threading;
using UnityEngine;

namespace AliceStandard.Sound
{
    [ScenarioMethod("se")]
    public class SeMaker : IReflectable
    {
        //SEを再生するAudioSource
        private readonly AudioSource seSource;

        public SeMaker(Settings settings)
        {
            this.seSource = settings.SeSource ?? throw new ArgumentNullException(nameof(settings.SeSource));
        }

        [ScenarioMethod("play", "SEを再生\nPlay the SE.")]
        public void PlaySe(AudioClip audioClip)
        {
            seSource.PlayOneShot(audioClip);
        }

        [ScenarioMethod("await", "SEを再生し終わるまで待機\nPlay the SE and wait until it is finished.")]
        public async UniTask PlaySeAndAwaitAsync(AudioClip audioClip, CancellationToken cancellationToken)
        {
            seSource.PlayOneShot(audioClip);
            try
            {
                await UniTask.Delay(TimeSpan.FromSeconds(audioClip.length), cancellationToken: cancellationToken);
            }
            finally
            {
                seSource.Stop();
            }
        }

        [ScenarioMethod("stop", "SEを停止\nStop the SE.")]
        public void StopSe()
        {
            seSource.Stop();
        }

        [Serializable]
        public class Settings
        {
            public AudioSource SeSource;
        }
    }
}

SeManagerクラスでは,BGMではなく効果音の再生・停止を行います.
ただし,BgmMakerと全く同じわけではないことに注意してください.

SeMakerでは効果音の再生・停止を行いますが,BgmMakerとは異なり音量を徐々に変化させるような機能はありません

そして,そのような機能がない代わりに,SeMakerでは効果音を再生して放置するか,効果音の再生終了を待機するかを選ぶことができます.
se.playでは渡されたAudioClipを再生したまま処理が終了しますが,se.awaitでは渡されたAudioClipの再生時間だけ待機し,最後には効果音の再生を停止することを保証します(キャンセルされたときも再生が停止する).

AudioClipProviderクラス

AudioClipProvider.cs
using Cysharp.Threading.Tasks;
using Cysharp.Threading.Tasks.Linq;
using ScenarioFlow;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using UnityEngine;

namespace AliceStandard.Decoder
{
    [ScenarioMethod("clip")]
    public class AudioClipProvider : IReflectable
    {
        private readonly IAssetLoader assetLoader;

        //ロードされたクリップ
        private readonly Dictionary<string, AudioClip> audioClipDictionary = new Dictionary<string, AudioClip>();
        //LoadAudioClipAsyncによってロードされたクリップ
        private readonly Dictionary<string, string> audioClipNameDictionary = new Dictionary<string, string>();
        //LoadAllAudioClipsAsyncによってロードされたグループに所属するクリップ
        private readonly Dictionary<string, string[]> audioClipNamesInGroupDictionary = new Dictionary<string, string[]>();

        public AudioClipProvider(IAssetLoader assetLoader)
        {
            this.assetLoader = assetLoader ?? throw new ArgumentNullException(nameof(assetLoader));
        }

        [ScenarioMethod("load", "1つのAudioClipをロード\nLoad an audio clip.")]
        public async UniTask LoadAudioClipAsync(string audioClipPath, CancellationToken cancellationToken)
        {
            //ロード済みパスでないかチェック
            if (audioClipNameDictionary.ContainsKey(audioClipPath))
            {
                throw new ArgumentException($"The audio clip at '{audioClipPath}' is loaded already.");
            }
            //ロード
            var audioClip = await assetLoader.LoadAssetAsync<AudioClip>(audioClipPath, cancellationToken);
            //同名のクリップがロード済みで内科チェック
            if (audioClipDictionary.ContainsKey(audioClip.name))
            {
                throw new ArgumentException($"Audio clip '{audioClip.name}' exists already.");
            }
            //クリップを登録
            audioClipNameDictionary.Add(audioClipPath, audioClip.name);
            audioClipDictionary.Add(audioClip.name, audioClip);
        }

        [ScenarioMethod("load.all", "グループに属する複数のクリップをロード\nLoad all the audio clips in the group.")]
        public async UniTask LoadAllAudioClipsAsync(string groupPath, CancellationToken cancellationToken)
        {
            //グループ名が登録済みでないかチェック
            if (audioClipNamesInGroupDictionary.ContainsKey(groupPath))
            {
                throw new ArgumentException($"The audio clips at '{groupPath}' is loaded already.");
            }
            //ロード
            var audioClips = await assetLoader.LoadAllAssetsAsync<AudioClip>(groupPath, cancellationToken);
            //ロード済みのクリップと重複が発生していないかチェック
            foreach(var name in audioClips.Select(audioClip => audioClip.name))
            {
                if (audioClipDictionary.ContainsKey(name))
                {
                    throw new ArgumentException($"Audio clip '{name}' in the group '{groupPath}' can not be added because the audio clip that has the same name is loaded already.");
                }
            }
            //グループ内のクリップの名前を登録
            audioClipNamesInGroupDictionary.Add(groupPath, audioClips.Select(audioClip => audioClip.name).ToArray());
            //クリップを登録
            foreach (var audioClip in audioClips)
            {
                audioClipDictionary.Add(audioClip.name, audioClip);
            }
        }

        [ScenarioMethod("unload", "一つのクリップをアンロード\nUnload an audio clip.")]
        public void UnloadAudioClip(string audioClipPath)
        {
            //クリップが存在するかチェック
            if (!audioClipNameDictionary.ContainsKey(audioClipPath))
            {
                throw new ArgumentException($"The audio clip at '{audioClipPath}' does not exist.");
            }
            //Dictionaryから抹消
            audioClipDictionary.Remove(audioClipNameDictionary[audioClipPath]);
            audioClipNameDictionary.Remove(audioClipPath);
            //アンロード
            assetLoader.UnloadAsset(audioClipPath);
        }

        [ScenarioMethod("unload.all", "グループに属する複数のクリップをアンロード\nUnload all the audio clips in the group.")]
        public void UnloadAllAudioClips(string groupPath)
        {
            //グループ名が存在するかチェック
            if (!audioClipNamesInGroupDictionary.ContainsKey(groupPath))
            {
                throw new ArgumentException($"The audio clips at '{groupPath}' does not exist.");
            }
            //Dictionaryから抹消
            foreach(var audioClipName in audioClipNamesInGroupDictionary[groupPath])
            {
                audioClipDictionary.Remove(audioClipName);
            }
            audioClipNamesInGroupDictionary.Remove(groupPath);
            //アンロード
            assetLoader.UnloadAllAssets(groupPath);
        }

        [ScenarioMethod("clear", "ロードされたすべてのクリップをアンロード\nUnload all the loaded audio clips.")]
        public void ClearAllAudioClips()
        {
            //グループに属するクリップをアンロード
            foreach(var groupPath in audioClipNamesInGroupDictionary.Keys)
            {
                assetLoader.UnloadAllAssets(groupPath);
            }
            //グループに属さないクリップをアンロード
            foreach(var audioClipPath in audioClipNameDictionary.Keys)
            {
                assetLoader.UnloadAllAssets(audioClipPath);
            }
            //Dictionaryをクリア
            audioClipDictionary.Clear();
            audioClipNameDictionary.Clear();
            audioClipNamesInGroupDictionary.Clear();
        }

        //クリップを取得
        [Decoder]
        public AudioClip GetAudioClip(string audioClipName)
        {
            return audioClipDictionary.TryGetValue(audioClipName, out var audioClip) ?
                audioClip :
                throw new ArgumentException($"Audio clip '{audioClipName}' does not exist.");
        }
    }
}

長いコードですが,行っていることは前に作成したSpriteProviderクラスと同じで,違いはSpriteがAudioClipになっているということだけです.

IAssetLoaderに渡されたクラスによって,適切なパスを渡すことでAudioClipを登録・削除することができます.
ResourcesAssetLoaderならResourcesフォルダを基準とした相対パス,AddressablesAssetLoaderなら任意に設定したアセットパスやタグです.

AudioSourceの配置

BGMと効果音を鳴らすためのAudioSourceをシーン上に配置します.
以下の手順で二つのAudioSourceを配置してください.

  • CreateEmptyから空のオブジェクトを作る.名前はAudioSourceParentとする.
  • AudioSourceParentの子として,Audio/Audio Sourceにより2つのAudioSourceを作成する.名前はそれぞれBgmSourceSeSourceとする.

音楽の準備

実際にシナリオ進行で流す音楽を準備します.

BGMは,甘茶の音楽工房より,ラブリーフラワー夏休みの探検を利用させていただきます.

https://amachamusic.chagasi.com/

効果音は,効果音ラボより砂利の上を歩く砂利の上を走るを利用させていただきます.

https://soundeffect-lab.info/sound/various/

これらの音楽をインポートし,Resourcesフォルダ直下音楽フォルダに入れてください.

ファイル名はそれぞれ,「ラブリーフラワー」,「夏休みの探検」,「砂利の上を歩く」,「砂利の上を走る」,としています.

GameMangerの変更と設定

今回作成したクラスのScenarioMethodを使えるようにするため,GameManagerクラスに変更を加えます.

GameManager.cs
//略
using AliceStandard.Sound;

public class GameManager : MonoBehaviour
{
    //------各設定
    //略
    [SerializeField]
    private BgmMaker.Settings bgmMakerSettings;

    [SerializeField]
    private SeMaker.Settings seMakerSettings;
    //------
    //略
    private async UniTaskVoid Start()
    {
        //略
        var excelScenarioPublisher = new ExcelScenarioPublisher(
            new ScenarioMethodSearcher(
                new IReflectable[]
                {
                    //------使用するScenarioMethodとDecoderをここに
                    //------------Decoder
                    //略
                    new AudioClipProvider(resourcesAssetLoader),
                    //------------
                    //------------ScenarioMethod
                    //略
                    new BgmMaker(bgmMakerSettings),
                    new SeMaker(seMakerSettings),
                    //------------
                    //------
                }));
        //------
        //略
    }
    //略
}
GameManager.cs全文
GameManager.cs
using AliceStandard.Background;
using AliceStandard.Branch;
using AliceStandard.Character;
using AliceStandard.Decoder;
using AliceStandard.Line;
using AliceStandard.Loader;
using AliceStandard.Progressor;
using AliceStandard.Sound;
using AliceStandard.Task;
using Cysharp.Threading.Tasks;
using ScenarioFlow;
using ScenarioFlow.ExcelFlow;
using ScenarioFlow.TaskFlow;
using System;
using UnityEngine;

public class GameManager : MonoBehaviour
{
    //------各設定

    [SerializeField]
    private LineWriter.Settings lineWriterSettings;

    [SerializeField]
    private KeyProgressor.Settings keyProgressorSettings;

    [SerializeField]
    private ButtonProgressor.Settings buttonProgressorSettings;

    [SerializeField]
    private BackgroundAnimator.Settings backgroundAnimatorSettings;

    [SerializeField]
    private ButtonBranchSelector.Settings buttonBranchSelectorSettings;

    [SerializeField]
    private BgmMaker.Settings bgmMakerSettings;

    [SerializeField]
    private SeMaker.Settings seMakerSettings;

    //------

    //実行したいソースファイル
    [SerializeField]
    private ExcelAsset excelAsset;

    //Disposable
    private IDisposable[] disposables;

    private async UniTaskVoid Start()
    {
        //CancellationTokenを取得
        var cancellationToken = this.GetCancellationTokenOnDestroy();

        //------Progressorの準備
        var keyProgressor = new KeyProgressor(keyProgressorSettings);
        var buttonProgressor = new ButtonProgressor(buttonProgressorSettings);
        //使用するNextProgressorをここに
        INextProgressor nextProgressor = new CompositeAnyNextProgressor(
            new INextProgressor[]
            {
                keyProgressor,
                buttonProgressor,
            });
        //使用するCancellationProgressorをここに
        ICancellationProgressor cancellationProgressor = new CompositeAnyCancellationProgressor(
            new ICancellationProgressor[]
            {
                keyProgressor,
                buttonProgressor,
            });
        //------

        //------ScenarioBookReaderの準備
        var tokenCodeHolder = new TokenCodeHolder();

        var scenarioTaskExecuter = new ScenarioTaskExecuter(tokenCodeHolder);

        var scenarioBookReader = new ScenarioBookReader(
            new ScenarioTaskExecuterTokenCodeDecorator(
                scenarioTaskExecuter,
                nextProgressor,
                tokenCodeHolder));
        //------
        //------CancellationToken用のDecoderの準備
        var cancellationTokenDecoder = new CancellationTokenDecoder(tokenCodeHolder);

        var cancellationTokenDecoderTokenCodeDecorator = new CancellationTokenDecoderTokenCodeDecorator(
            cancellationTokenDecoder,
            new CancellationProgressorTokenCodeDecorator(cancellationProgressor, tokenCodeHolder),
            tokenCodeHolder);
        //-----

        //------ScenarioPublisherの準備
        var resourcesAssetLoader = new ResourcesAssetLoader();
        var channelMediator = new ChannelMediator();

        var excelScenarioPublisher = new ExcelScenarioPublisher(
            new ScenarioMethodSearcher(
                new IReflectable[]
                {
                    //------使用するScenarioMethodとDecoderをここに
                    //------------Decoder
                    new PrimitiveDecoder(),
                    new AsyncDecoder(cancellationTokenDecoderTokenCodeDecorator),
                    new DOTweenDecoder(),
                    new VectorProvider(),
                    new SpriteProvider(resourcesAssetLoader),
                    new ActorProvider(),
                    new AudioClipProvider(resourcesAssetLoader),
                    //------------
                    //------------ScenarioMethod
                    new LineWriter(lineWriterSettings),
                    new BackgroundAnimator(backgroundAnimatorSettings),
                    new ActorAnimator(),
                    new ActorConfigurator(),
                    new DelayGenerator(),
                    new ScenarioTaskDealer(scenarioTaskExecuter, cancellationTokenDecoder),
                    new BranchRecorder(channelMediator, new ButtonBranchSelector(buttonBranchSelectorSettings)),
                    new BranchMaker(channelMediator, scenarioBookReader),
                    new BgmMaker(bgmMakerSettings),
                    new SeMaker(seMakerSettings),
                    //------------
                    //------
                }));
        //------

        //Disposableの登録
        disposables = new IDisposable[]
        {
            cancellationTokenDecoder,
            cancellationTokenDecoderTokenCodeDecorator,
            resourcesAssetLoader,
        };

        //ScenarioBookの作成
        var scenarioBook = excelScenarioPublisher.Publish(excelAsset);
        //ScenarioBookを実行
        await scenarioBookReader.ReadScenarioBookAsync(scenarioBook, cancellationToken);
    }

    //最後にDisposableをDispose
    private void OnDestroy()
    {
        foreach (var disposable in disposables)
        {
            disposable.Dispose();
        }
    }
}

次に,シーン上のGameManagerオブジェクトにBgmMakerSeMakerの各設定を割り当てます.
シーン上に配置したAudioSourceを登録しましょう.

Excel Assetに割り当てられているAliceSample3-7-1は,次のソースファイルです.

ソースファイルコピペ用
AliceSample3-7-1
ScenarioMethod	Param1	Param2	Param3	Param4
<Page>				
sprite.load.all	背景	forced.fluent		
sprite.load.all	アリス	forced.fluent		
clip.load.all	音楽	forced.fluent		
line.erase	forced.fluent			
bg.change.immed	広場_昼			
actor.fade.durat	0.8			
actor.fade.ease	InOutQuad			
actor.replace.durat	0.5			
actor.replace.ease	OutQuart			
actor.add	アリス			
actor.pos	アリス	12_-1.5		
actor.spr	アリス	アリス_通常		
actor.layer	アリス	1		
actor.alpha	アリス	1		
bgm.ease	InCubic			
bgm.durat	5			
bgm.play.grad	ラブリーフラワー	lovelyflower		
se.await	砂利の上を歩く	walk		
actor.move.rel	アリス	-12_0	3	fluent
task.cancel	walk			
task.accept	lovelyflower	fluent		
line.write	アリス	こんにちは!	std	
bgm.stop.immed				
line.write	アリス	音楽を変えてみようか。	std	
bgm.play.immed	夏休みの探検			
actor.replace	アリス	アリス_笑顔	paral	
line.write	アリス	どうかな?	std	
line.write	アリス	じゃあ、またね!	std	
bgm.ease	Linear			
bgm.durat	10			
bgm.stop.grad	stop			
se.play	砂利の上を走る			
actor.move.rel	アリス	12_0	1	fluent
task.accept	stop	fluent		
clip.clear				
</Page>				

シナリオの実行

GameManagerの設定が終わったら,シナリオを実行してみましょう.

狙ったBGM及び効果音が再生されていて,徐々に音量を変える機能も正常に働いていることが確認できるかと思います.


このときは「夏休みの探検」が流れている

おわりに

今回は,BGMと効果音を流すためのプログラムを作成しました.
適切な音楽を流すことによって,物語への没入感も高まることでしょう.

しかし,現時点では音量をプレイヤーが調整するような機能はつけていません.
これに関しては,今後,キャラクターボイスを実装した後でまとめて解説したいと考えています.

そのため,次回にでもキャラクターボイスの実装を行いたいところですが,説明の都合で次回は自動進行(オート進行)を実装する予定です.

流れとしては,自動進行,キャラクターボイス,音量調整の順で解説していくことになると思います.

Discussion