Featured image of post 麦田物语开发日记(十三)

麦田物语开发日记(十三)

完成游戏音效的控制

制作游戏音效

创建声音数据结构,在不同场景出现不同音源

MainCamera中有玩家能听到的音源,因此只需要保证在对应场景能正常切换不同音源即可。

因此需要一个专门管理音源的控制器AudioManager,由于会出现环境音和背景音乐,因此在AudioManager下增加两个音源的控制器Game MusicAmbient Music,在两个音源控制器上需要一个音源组件Audio Source

由于不同动作触发的音效不同,且音调等内容都需要修改,因此需要一个特别的数据结构来存放所有音源SoundDetails

除了管理音源的数据,还需要一个数据结构SceneSound来处理不同情况下使用的音源(包括背景音乐和环境音)。

然后在切换场景的时候获得背景音等,然后调用播放函数

声音数据结构

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
[CreateAssetMenu(fileName = "SoundDetailsList_SO", menuName = "Sound/SoundDetailsList")]
public class SoundDetailsList_SO : ScriptableObject
{
    public List<SoundDetails> soundDetailsList;

    public SoundDetails GetSoundDetails(SoundName soundName)
    {
        return soundDetailsList.Find(s => s.soundName == soundName);
    }
}

[System.Serializable]
public class SoundDetails
{
    public SoundName soundName;
    public AudioClip soundClip;
    
    /* 音源的特殊设定 */
    [UnityEngine.Range(0.1f, 1.5f)]
    public float soundPitchMin;
    [UnityEngine.Range(0.1f, 1.5f)]
    public float soundPitchMax;
    [UnityEngine.Range(0.1f, 1f)]
    public float soundVolume;
}

以上数据结构用于存放每个音乐名对应的音乐信息,即声音片段

场景音乐管理数据结构

该数据结构用于管理每个场景需要的所有音乐信息

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
[CreateAssetMenu(fileName = "SceneSoundList_SO", menuName = "Sound/SceneSoundList")]
public class SceneSoundList_SO : ScriptableObject
{
    public List<SceneSoundItem> sceneSoundItemList;

    public SceneSoundItem GetSceneSoundItem(string sceneName)
    {
        return sceneSoundItemList.Find(s => s.sceneName == sceneName);
    }
}

[System.Serializable]
public class SceneSoundItem
{
    [SceneName]public string sceneName;
    public SoundName ambientMusic;
    public SoundName sceneMusic;
}

音乐管理控制器

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
public class AudioManager : MonoBehaviour
{
    [Header("音乐库")] public SoundDetailsList_SO soundDetailsData;
    public SceneSoundList_SO sceneSoundData;

    public AudioSource ambientSource;
    public AudioSource gameSource;

    private float MusicStartSecond => Random.Range(3f, 5f);  /* 播放音乐的等待时间 */
    private Coroutine _soundRoutine;
    
    private void OnEnable()
    {
        EventHandler.AfterSceneLoadEvent += OnAfterSceneLoadEvent;
    }

    private void OnDisable()
    {
        EventHandler.AfterSceneLoadEvent -= OnAfterSceneLoadEvent;
    }

    #region 注册事件

    private void OnAfterSceneLoadEvent()
    {
        /* 切换场景并获得当前场景的对应音乐 */
        string sceneName = SceneManager.GetActiveScene().name;
        SceneSoundItem sceneSound = sceneSoundData.GetSceneSoundItem(sceneName);
        if (sceneSound == null) return;

        SoundDetails ambient = soundDetailsData.GetSoundDetails(sceneSound.ambientMusic);
        SoundDetails music = soundDetailsData.GetSoundDetails(sceneSound.sceneMusic);
        
        if (_soundRoutine != null)
            StopCoroutine(_soundRoutine);
        _soundRoutine = StartCoroutine(PlaySoundRoutine(music, ambient));
  
    }

    #endregion
    

    #region 播放音乐

    private IEnumerator PlaySoundRoutine(SoundDetails music, SoundDetails ambient)
    {
        if (music != null && ambient != null)
        {
            PlayAmbientClip(ambient);
            yield return new WaitForSeconds(MusicStartSecond);
            PlayMusicClip(music);
        }
    }

    private void PlayMusicClip(SoundDetails soundDetails)
    {
        gameSource.clip = soundDetails.soundClip;
        if (gameSource.isActiveAndEnabled)
            gameSource.Play();
    }

    private void PlayAmbientClip(SoundDetails soundDetails)
    {
        ambientSource.clip = soundDetails.soundClip;
        if (ambientSource.isActiveAndEnabled)
            ambientSource.Play();
    }

    #endregion

在该管理代码中,主要是需要通过获得当前的场景信息来获取场景需要的音乐文件,然后将这些音乐文件赋值给音源管理组件,然后执行播放功能。

在该部分使用了协程来保证切换场景后声音的等待时间出现。在此处如果要关闭一个特定的协程,需要先定义一个协程变量,然后关闭该协程,而生成则是直接赋值。

1
2
3
if (_soundRoutine != null)
 StopCoroutine(_soundRoutine);
_soundRoutine = StartCoroutine(PlaySoundRoutine(music, ambient));

后续将通过额外的功能来无缝衔接音乐

使用AudioMixer实现音效控制和转换

打开AudioMixer面板

Window栏->AudioMixer

创建AudioMixer

在项目窗口中右键创建

AudioMixer内容

image-20230227194043065

Snapshots可以作为某组音量内容的整体方案

Group用于管理音轨,子音轨收到父音轨管理

如果需要通过代码控制音轨,则需要在音轨的Inspector中将音轨暴露出来,然后再音源中选择指定的音轨输出。

然后在AudioManager.cs中使用AudioMixer组件来控制具体的音轨的数值

image-20230227204247717

代码修改部分

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
private void PlayMusicClip(SoundDetails soundDetails, float transitionTime)
{
    gameAudioMixer.SetFloat("MusicVolume", ConvertSoundVolume(soundDetails.soundVolume));
    gameSource.clip = soundDetails.soundClip;
    if (gameSource.isActiveAndEnabled)
        gameSource.Play();
    /* 切换到当前快照需要的时间 */
    normalSnapshot.TransitionTo(transitionTime);
}

private void PlayAmbientClip(SoundDetails soundDetails, float transitionTime)
{
    gameAudioMixer.SetFloat("AmbientVolume", ConvertSoundVolume(soundDetails.soundVolume));
    ambientSource.clip = soundDetails.soundClip;
    if (ambientSource.isActiveAndEnabled)
        ambientSource.Play();
    ambientSnapshot.TransitionTo(transitionTime);
}

单个音效的增加

为了增加单个音效的,不能为每一个prefab添加音源,因此建立一个能够存放音源GO的线程池,每当得到一个GO的时候播放特定的音源。

由于目前Unity自带对象池get音源prefab的方式有BUG,必须第二次get才能播放音效,因此需要使用传统的创建对象池的方法,即自己编写代码获取线程

  • 基础方法创建线程池

创建音源预制体

创建音源预制体后,为其增加初始化控制代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
[RequireComponent(typeof(AudioSource))]
public class Sound : MonoBehaviour
{
    [SerializeField] private AudioSource audioSource;

    public void SetAudio(SoundDetails soundDetails)
    {
        audioSource.clip = soundDetails.soundClip;
        audioSource.volume = AudioManager.Instance.ConvertSoundVolume(soundDetails.soundVolume);
        audioSource.pitch = Random.Range(soundDetails.soundPitchMin, soundDetails.soundPitchMax);
    }
}

此时的audioSource是当前预制体自己的音源组件

然后将该GO作为预制体存入PoolManager中,然后使用一个新的事件当触发播放音效的时候就获得线程池中的GO,并初始化。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// PoolManager.cs
/* 有BUG的方法 */
private void InitSoundEffect(ParticleEffectType effectType, SoundDetails soundDetails)
{
    if (_poolEffectDic.ContainsKey(effectType.ToString()))
    {
        ObjectPool<GameObject> soundPool = _poolEffectDic[effectType.ToString()];
        var soundObj = soundPool.Get();
        soundObj.GetComponent<Sound>().SetAudio(soundDetails);
        StartCoroutine(ReleaseSoundRoutine(soundPool, soundObj, soundDetails));
    }
}

private IEnumerator ReleaseSoundRoutine(ObjectPool<GameObject> pool,GameObject soundObj, SoundDetails soundDetails)
{
    yield return new WaitForSeconds(soundDetails.soundClip.length);    // 音乐长度
    pool.Release(soundObj);
}

设置事件触发

建立事件

1
2
3
4
5
6
7
8
// EventHandle.cs
public static event Action<ParticleEffectType, SoundDetails> InitSoundEffectEvent;


public static void CallInitSoundEffectEvent(ParticleEffectType particleEffectType, SoundDetails soundDetails)
{
    InitSoundEffectEvent?.Invoke(particleEffectType, soundDetails);
}

Cropdetails设置音效名称,然后通过SoundManager.cs获得音效具体数据

1
2
// dataCollection.cs
[Space] [Header("音效名称")] public SoundName soundName;

为每一种作物添加音效(由于作物,石头,杂草的处理是分开的,因此添加音效也要在不同地方

1
2
3
4
5
6
7
8
// Crop.cs
/*音效*/
if (currentCropDetails.soundName != SoundName.None)
{
    // 更新后的内容 EventHandler.CallPlaySoundEvent(currentCropDetails.soundName);
    var soundDetails = AudioManager.Instance.soundDetailsData.GetSoundDetails(currentCropDetails.soundName);
    EventHandler.CallInitSoundEffectEvent(ParticleEffectType.Sound, soundDetails);
}

代码完成创建线程池的工作(解决Unity自带线程池问题)

使用队列的方式,预先建造一个含有20个音源对象的队列,每次都从该队列中获取音源预制体

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/* 使用队列自建线程池 */
/*初始化对象池*/
private void CreatSoundPool()
{
    var soundParent = new GameObject(ParticleEffectType.Sound.ToString()).transform;
    soundParent.SetParent(transform);
    for (int i = 0; i < 20; i++)
    {
        GameObject obj = Instantiate(prefabList.Find(p => p.name == ParticleEffectType.Sound.ToString()), soundParent);
        obj.SetActive(false);
        _soundQueue.Enqueue(obj);
    }
}

/*对象元素获取方法*/
private GameObject GetSoundPoolObj()
{
    if (_soundQueue.Count < 2)
        CreatSoundPool();
    return _soundQueue.Dequeue();
}

private void InitSoundEffect(ParticleEffectType effectType, SoundDetails soundDetails)
{
    if (_poolEffectDic.ContainsKey(effectType.ToString()))
    {
        var soundObj = GetSoundPoolObj();
        soundObj.GetComponent<Sound>().SetAudio(soundDetails);
        soundObj.SetActive(true);
        StartCoroutine(ReleaseSoundObj(soundObj, soundDetails.soundClip.length));

    }
}

private IEnumerator ReleaseSoundObj(GameObject obj, float duration)
{
    yield return new WaitForSeconds(duration);
    obj.SetActive(false);
    _soundQueue.Enqueue(obj);
}
  • 增加脚步音效

为人物动画帧添加事件,然后该事件调用一个单独播放音效的事件,该事件由AudioManager注册,在该注册函数中会获取当前单独播放音效事件声音名称,然后获取音效内容,并呼叫真正的音效初始化事件(PoolManager注册)

人物的动画事件

1
2
3
4
5
6
7
public class AnimationEvent : MonoBehaviour
{
    public void FootStepSound()
    {
        EventHandler.CallPlaySoundEvent(SoundName.FootStepSoft);
    }
}

直接播放音效事件的注册,获得音效名称后,调用CallInitSoundEffectEvent事件,让PoolManager创建音效事件。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// AudioManager.cs
/// <summary>
/// 直接播放音效事件
/// </summary>
/// <param name="soundName">音效名称</param>
private void OnPlaySoundEvent(SoundName soundName)
{
    SoundDetails soundDetails = soundDetailsData.GetSoundDetails(soundName);
    if (soundDetails is null)
        return;
    EventHandler.CallInitSoundEffectEvent(ParticleEffectType.Sound, soundDetails);
    
}
Built with Hugo
Theme Stack designed by Jimmy