Godot C# - 音频设置

简单处理 显示设置(Display Settings) 之后就是另外 音频设置(Audio Settings) 相关

音频部分就相对来说比较简单, 游戏内部对音乐管理需求不高的话, 直接采用如下配置:

  • 主音量(Master Volume): 全局整体游戏总音量

  • 背景音乐(BGM Volume): 游戏当中涉及到场景/剧情等背景音乐

  • 游戏音效(SFX Volume): 游戏当中释放技能/触发交互的弹出音乐

如果是偏剧情向(GalGame)之类, 那么需要追加人物音量(Voice Volume), 甚至要单独另开配置页面来为每个角色添加音量

音量部分都是采用 0.0~1.0 的浮点数代表百分比处理(0代表静音), 所以不会专门单独定义枚举处理, 处理也就相对显示设置简单

而 Godot 内部会用到的 API 如下所示

API 全路径 方法/属性用途 参数/返回值 核心说明
AudioServer.GetBusIndex(string busName) 通过总线名获取音频总线索引 参数:string(音频总线名,如Master/BGM/SFX)
返回值:int(总线索引,-1表示未找到)
音频总线操作的基础,避免硬编码索引,通过名称匹配更灵活
AudioServer.SetBusVolumeLinear(int busIndex, float volumeDb) 设置音频总线音量 参数:int(总线索引)、float(音量浮点,如0.0~1.0)
返回值:void
Godot原生音量 0~1 浮点值修改方式
AudioServer.SetBusVolumeLinear(int busIndex) 获取音频总线当前音量 参数:int(总线索引)
返回值:float(分贝音量)
获取 0~1 浮点值音量,方便上层业务使用
Mathf.LinearToDb(float linear) 线性音量(0~1)转分贝音量(Db) 参数:float(0~1线性值,0=静音,1=最大)
返回值:float(分贝值,0对应最大,-80对应静音)
音频总线音量转换
Mathf.DbToLinear(float db) 分贝音量(Db)转线性音量(0~1) 参数:float(分贝值)
返回值:float(0~1线性值)
音频总线杜比转换
Mathf.IsEqualApprox(float a, float b) 浮点值近似相等判断 参数:float a/float b(待比较的两个浮点值)
返回值:bool(true=近似相等)
浮点型音量的重复判断核心,避免浮点数精度问题导致的重复设置
Mathf.Clamp(float value, float min, float max) 数值范围裁剪 参数:float(待裁剪值)、float(最小值)、float(最大值)
返回值:float(裁剪后的值)
保证音量始终在0~1之间,避免传入无效值导致音频异常

这里需要知道什么是 音频总线(AudioBus), 一般来说音频播放都会设置多条通道, 每条通道专门对应去播放不同音源线路

而 Godot 创建播放线路就需要在编辑器面板操作创建:

audio-bus

这里我创建了 BGMSFX 两个音源通道并将其划分到 Master 之下, 这里对应的 Master/BGM/SFX 就是主要音量总线名称

代码处理

在创建完音频管线之后, 现在就需要定义 Data 数据本地落地容器:

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
namespace P21Game.Manager.Settings.Data;

/// <summary>
/// Godot 音频设置的数据类
/// 用于 JSON 序列化保存本地
/// </summary>
public class GodotAudioSettings
{
/// <summary>
/// 主音量总线名称
/// </summary>
public string MasterVolumeName { get; set; } = "Master";

/// <summary>
/// 主音量设置, 建议设置 0.5(50%) 音量即可
/// </summary>
public float MasterVolume { get; set; } = 0.5f;


/// <summary>
/// 背景音乐总线名称
/// </summary>
public string BgmVolumeName { get; set; } = "BGM";

/// <summary>
/// 背景音乐音量, 同样建议设置 0.5(50%)
/// </summary>
public float BgmVolume { get; set; } = 0.5f;


/// <summary>
/// 游戏音效总线名称
/// </summary>
public string SfxVolumeName { get; set; } = "SFX";

/// <summary>
/// 游戏音效音量, 也是只需要 0.5(50%)
/// </summary>
public float SfxVolume { get; set; } = 0.5f;


/// <summary>
/// 输出类字符串
/// </summary>
/// <returns>格式化打印字符串</returns>
public override string ToString()
{
return $"{nameof(MasterVolumeName)}: {MasterVolume}, {nameof(BgmVolumeName)}: {BgmVolume}, {nameof(SfxVolumeName)}: {SfxVolume}";
}
}

之后在 ISettingsManager 声明通用修改方法即可, 这里我直接把之前整个设置接口搬运过来:

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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
using System;
using P21Utility.CDI;
using P21Utility.Definitions.Settings.Display;

namespace P21Game.Manager.Settings;

/// <summary>
/// 配置类功能抽象
/// 可以先放在 Game 层, 后续如果该接口可以通用就可以提升到 Utility 层
/// </summary>
public interface ISettingsManager : IManager
{
/// <summary>
/// 系统级的功能, 需要优先加载
/// </summary>
int IManager.Priority => -1;


/// <summary>
/// 加载本地配置到游戏
/// </summary>
public void LoadSettings();

/// <summary>
/// 保存配置到本地
/// </summary>
public void SaveSettings();


#region 显示设置

/// <summary>
/// 修改显示设置的回调, 用于外部回调去更新部分组件
/// </summary>
public event Action OnDisplayChanged;



/// <summary>
/// 修改显示模式
/// </summary>
public Mode DisplayMode { set; get; }


/// <summary>
/// 修改显示分辨率
/// </summary>
public Resolution DisplayResolution { set; get; }

/// <summary>
/// 修改是否启用垂直同步
/// </summary>
public bool DisplayVSync { set; get; }


/// <summary>
/// 修改运行帧率
/// </summary>
public Fps DisplayFps { set; get; }

#endregion


#region 音频设置

/// <summary>
/// 修改音频设置的回调, 用于外部回调去更新部分组件
/// </summary>
public event Action OnAudioChanged;

/// <summary>
/// 修改主音量
/// </summary>
public float MasterVolume { set; get; }

/// <summary>
/// 修改背景音量
/// </summary>
public float BgmVolume { set; get; }

/// <summary>
/// 修改游戏音效
/// </summary>
public float SfxVolume { set; get; }

#endregion

#region 游戏设置

// todo: 等待追加游戏设置功能

#endregion

#region 操作设置

// todo: 等待追加操作设置功能

#endregion
}

最后就是配置 GodotSettingsManager 具体管理器功能实现, 这里篇幅太多只挑选之前显示设置功能新增的部分:

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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.Json;
using Godot;
using P21Game.Manager.Settings.Data;
using P21Game.Manager.Settings.Extensions;
using P21Utility.Definitions.Settings.Display;
using Serilog;

namespace P21Game.Manager.Settings.Impl;

/// <summary>
/// Godot 系统配置管理器
/// </summary>
/// <param name="filename">保存的本地文件: 由外部传入</param>
public class GodotSettingsManager(string filename) : ISettingsManager
{
// 其他略

/// <summary>
/// 读取本地的配置文件
/// </summary>
public void LoadSettings()
{
// 加载本地文件是否存在配置, 不存在直接初始化简单
if (!File.Exists(filename))
{
InitSettings();
return;
}

// 加载内部的配置
var json = File.ReadAllText(filename, new UTF8Encoding(false));

// 解构成 Dict 形式
var settings = JsonSerializer.Deserialize<Dictionary<string, JsonDocument>>(json);

// 读取显示配置
try
{
LoadDisplaySettings(settings.TryGetValue(nameof(GodotDisplaySettings), out var displaySettings)
? displaySettings.Deserialize<GodotDisplaySettings>()
: new GodotDisplaySettings());
Log.Information("Loaded Godot Display Settings( {DisplaySettings} )", _displaySettings.ToString());
}
catch (Exception exception)
{
Log.Error(exception, "Failed to load Godot Display Settings");
LoadDisplaySettings(new GodotDisplaySettings());
}


// 读取音频设置
try
{
LoadAudioSettings(settings.TryGetValue(nameof(GodotAudioSettings), out var audioSettings)
? audioSettings.Deserialize<GodotAudioSettings>()
: new GodotAudioSettings());
Log.Information("Loaded Godot Audio Settings( {AudioSettings} )", _audioSettings.ToString());
}
catch (Exception exception)
{
Log.Error(exception, "Failed to load Godot Audio Settings");
LoadAudioSettings(new GodotAudioSettings());
}
}


/// <summary>
/// 加载音频设置
/// </summary>
/// <param name="audioSettings">更新的音乐设置</param>
private void LoadAudioSettings(GodotAudioSettings audioSettings)
{
_audioSettings = audioSettings;

// 主音量
var masterIdx = AudioServer.GetBusIndex(_audioSettings.MasterVolumeName);
if (masterIdx > -1) AudioServer.SetBusVolumeLinear(masterIdx, Mathf.Clamp(audioSettings.MasterVolume, 0, 1));

// 背景音量
var bgmIdx = AudioServer.GetBusIndex(_audioSettings.BgmVolumeName);
if (bgmIdx > -1) AudioServer.SetBusVolumeLinear(bgmIdx, Mathf.Clamp(audioSettings.BgmVolume, 0, 1));

// 特效音乐
var sfxIdx = AudioServer.GetBusIndex(_audioSettings.SfxVolumeName);
if (sfxIdx > -1) AudioServer.SetBusVolumeLinear(sfxIdx, Mathf.Clamp(audioSettings.SfxVolume, 0, 1));
}

/// <summary>
/// 生成原始配置
/// </summary>
private void InitSettings()
{
// 初始化视频设置
LoadDisplaySettings(new GodotDisplaySettings());
LoadAudioSettings(new GodotAudioSettings());

// todo: 其他配置
SaveSettings(); //将配置重新保存本地
}

/// <summary>
/// 保存配置文件到本地
/// </summary>
public void SaveSettings()
{
try
{
// 将对应类型保存在 KEY-VALUE 结构
Dictionary<string, object> settings = new()
{
[nameof(GodotDisplaySettings)] = _displaySettings ?? new GodotDisplaySettings(),
[nameof(GodotAudioSettings)] = _audioSettings ?? new GodotAudioSettings(),
// todo: 其他音频/游戏/操作设置
};

// 将类对象序列化成 JSON
var jsonSettings = JsonSerializer.Serialize(settings);
Log.Information("Saving Godot Settings: {JsonSettings}", jsonSettings);

// 确认目录存在
var dirname = Path.GetDirectoryName(filename);
if (!string.IsNullOrEmpty(dirname) && !Directory.Exists(dirname))
{
Directory.CreateDirectory(dirname); // 创建目录
}

// 以 UTF8-NO-BOM 形式写入文件
File.WriteAllText(filename, jsonSettings, new UTF8Encoding(false));
}
catch (Exception exception)
{
Log.Error(exception, "Failed to save Godot Settings");
}
}


#region 音量设置

/// <summary>
/// 音频设置
/// </summary>
private GodotAudioSettings _audioSettings;


/// <summary>
/// 修改音频设置的回调处理
/// </summary>
public event Action OnAudioChanged;

/// <summary>
/// 主音量
/// </summary>
public float MasterVolume
{
get => _audioSettings.MasterVolume;
set
{
// 设置只允许 0~1 之内取值
var volume = Mathf.Clamp(value, 0, 1);

// 注意: 浮点数不能用直接对等比较, 而是要采用误差比较
//if (volume == _audioSettings.MasterVolume) return;

// 比较两个浮点数的差距是否在 0.1 之中
//if (Math.Abs(volume - _audioSettings.MasterVolume) < 0.1f) return;

// 还有其他内置方法比较, Mathf.IsEqualApprox 就是专门比较两个值是否接近
if (Mathf.IsEqualApprox(volume, _audioSettings.MasterVolume)) return;

// 获取总线索引并修改总线音量
var busIdx = AudioServer.GetBusIndex(_audioSettings.MasterVolumeName);
if (busIdx == -1) return; // -1 代表没找到

// 采用 SetBusVolumeLinear 方法, 这是线性音量也就是 0~1 取值
// 而 AudioServer.SetBusVolumeDb 就是采用杜比值 -80~0 的调整
AudioServer.SetBusVolumeLinear(busIdx, volume);
_audioSettings.MasterVolume = volume;
OnAudioChanged?.Invoke();
SaveSettings();
}
}

/// <summary>
/// 背景音乐
/// </summary>
public float BgmVolume
{
get => _audioSettings.BgmVolume;
set
{
var volume = Mathf.Clamp(value, 0, 1);
if (Mathf.IsEqualApprox(volume, _audioSettings.BgmVolume)) return;

// 获取总线索引并修改总线音量
var busIdx = AudioServer.GetBusIndex(_audioSettings.BgmVolumeName);
if (busIdx == -1) return; // -1 代表没找到

// 保存设置
AudioServer.SetBusVolumeLinear(busIdx, volume);
_audioSettings.BgmVolume = volume;
OnAudioChanged?.Invoke();
SaveSettings();
}
}

/// <summary>
/// 特效音乐
/// </summary>
public float SfxVolume
{
get => _audioSettings.SfxVolume;
set
{
var volume = Mathf.Clamp(value, 0, 1);
if (Mathf.IsEqualApprox(volume, _audioSettings.SfxVolume)) return;

// 获取总线索引并修改总线音量
var busIdx = AudioServer.GetBusIndex(_audioSettings.SfxVolumeName);
if (busIdx == -1) return; // -1 代表没找到

// 保存设置
AudioServer.SetBusVolumeLinear(busIdx, volume);
_audioSettings.SfxVolume = volume;
OnAudioChanged?.Invoke();
SaveSettings();
}
}

#endregion
}

这里代码添加完成就是准备游戏 UI 方面的调整功能, 在编辑器上添加滑动节点即可( Slider 相关节点):

audio-ui

最后就是在 Main 脚本之后添加这部分监听代码即可, 一般没有什么太大难度:

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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
using Godot;
using P21Game.Manager.Settings;
using P21Game.Manager.Settings.Extensions;
using P21Utility.CDI;
using P21Utility.Definitions.Settings.Display;
using Serilog;


// 归类到 P21Game 命名空间之中
namespace P21Game;

/// <summary>
/// 挂载游戏入口节点的 C# 脚本
/// </summary>
public partial class Main : Node
{
/// <summary>
/// 下拉选择显示模式
/// </summary>
[Export] public OptionButton ModeSelect;

/// <summary>
/// 下拉选择分辨率
/// </summary>
[Export] public OptionButton ResolutionSelect;

/// <summary>
/// 保存分辨率设置
/// </summary>
[Export] public Button UpdateButton;


/// <summary>
/// 主音量节点
/// </summary>
[Export] public HSlider MasterVolumeSlider;

/// <summary>
/// 背景音乐节点
/// </summary>
[Export] public HSlider BgmVolumeSlider;

/// <summary>
/// 特效音乐节点
/// </summary>
[Export] public HSlider SfxVolumeSlider;


/// <summary>
/// 配置管理器
/// </summary>
private ISettingsManager _settingsManager;


/// <summary>
/// 初始化完成
/// </summary>
public override void _Ready()
{
// 获取全局管理器
_settingsManager = ManagerRegistry.Get<ISettingsManager>();

// 获取目前支持的显示模式加入其中
if (ModeSelect != null)
{
ModeSelect.Clear();
var active = -1;
foreach (var mode in GodotDisplayModeExtensions.GetModes())
{
var modeId = (int)mode;
ModeSelect.AddItem($"{mode} - {modeId}", modeId);

// 确认目前是否选中该值
if (_settingsManager.DisplayMode.Equals(mode)) active = modeId;
}

if (active > -1) ModeSelect.Selected = ModeSelect.GetItemIndex(active);
Log.Information("Selected Mode: {active}", active);
}


// 获取目前支持的分辨率加入其中
if (ResolutionSelect != null)
{
ResolutionSelect.Clear(); // 清空

// 遍历目前支持的分辨率
var active = -1;
foreach (var resolution in GodotDisplayResolutionExtensions.GetResolutions())
{
var resId = (int)resolution; // 转化为下拉选择 ID
var (w, h) = resolution.GetSize(); // 获取具体尺寸
ResolutionSelect.AddItem($"{w} x {h}({resolution.ToString()})", resId);

// 确认目前是否选中该值
if (_settingsManager.DisplayResolution.Equals(resolution)) active = resId;
}

if (active > -1) ResolutionSelect.Selected = ResolutionSelect.GetItemIndex(active);
Log.Information("Selected Resolution: {active}", active);
}

// 触发切换显示分辨率事件
if (UpdateButton != null)
{
UpdateButton.Pressed += UpdateDisplayResolution;
}


// 监听显示设置更新功能
_settingsManager.OnDisplayChanged += () => { Log.Information("Display Settings Updated"); };


// 主音量节点绑定
if (MasterVolumeSlider != null)
{
// 设置目前默认值, 这里用百分比值标识
MasterVolumeSlider.MinValue = 0.0f;
MasterVolumeSlider.MaxValue = 100.0f;
MasterVolumeSlider.Value = _settingsManager.MasterVolume * 100;
Log.Information("Master Volume Slider: {masterVolume}", MasterVolumeSlider.Value);

// 事件变动
MasterVolumeSlider.ValueChanged += volume => _settingsManager.MasterVolume = (float)volume/100;
}

// 背景音量节点绑定
if (BgmVolumeSlider != null)
{
BgmVolumeSlider.MinValue = 0.0f;
BgmVolumeSlider.MaxValue = 100.0f;
BgmVolumeSlider.Value = _settingsManager.BgmVolume * 100;
Log.Information("Bgm Volume Slider: {bgmVolume}", BgmVolumeSlider.Value);
BgmVolumeSlider.ValueChanged += volume => _settingsManager.BgmVolume = (float)volume/100;
}

// 特效音量节点绑定
if (SfxVolumeSlider != null)
{
SfxVolumeSlider.MinValue = 0.0f;
SfxVolumeSlider.MaxValue = 100.0f;
SfxVolumeSlider.Value = _settingsManager.SfxVolume * 100;
Log.Information("Sfx Volume Slider: {sfxVolume}", SfxVolumeSlider.Value);
SfxVolumeSlider.ValueChanged += volume => _settingsManager.SfxVolume = (float)volume/100;
}


// 变动变量设置回调
_settingsManager.OnAudioChanged += () => { Log.Information("Audio Settings Updated"); };
}


/// <summary>
/// 更新显示分辨率
/// </summary>
private void UpdateDisplayResolution()
{
// 获取显示模式
var modeId = ModeSelect.GetSelectedId();
var mode = (Mode)modeId;
_settingsManager.DisplayMode = mode;

// 切换管理器的显示尺寸
var id = ResolutionSelect.GetSelectedId();
var resolution = (Resolution)id;
_settingsManager.DisplayResolution = resolution;
}

}

这里没有涉及到 UI 等界面操作, 尽可能保证不会牵涉到太多知识点将人绕晕, 最后制作出来的效果如下所示:

auido-changed

播放音乐

配置好音量控制之后就是将音乐挂载到对应 Bus 之中播放, Godot 只需要在节点当中配置 Bus 即可(使用 AudioStreamPlayer 节点):

audio-player

这样就完成游戏音乐的设置, 但是这里主要针对的2D游戏, 对于 3d 游戏的音效复杂程度更高(场景/人物之类的不同音效处理)