声音(Sounds)
虽然声音不是必须的,但它们能够让一个模组(mod)显得更加细致和生动。Minecraft 提供了多种方式来注册和播放声音,本文将对这些方式进行介绍。
术语(Terminology)
Minecraft 的声音引擎使用了多种术语来指代不同的内容:
- 声音事件(Sound event):声音事件是在代码中触发的,用于通知声音引擎播放某个特定声音的机制。
SoundEvent也是你需要注册到游戏中的对象。 - 声音类别(Sound category)或声音源(sound source):声音类别是对各种声音的大致分组,可以单独切换。声音选项界面中的滑块对应的就是这些类别,比如:
master、block、player等。在代码中,它们可以在SoundSource枚举(enum)中找到。 - 声音定义(Sound definition):将一个声音事件映射到一个或多个声音对象,并可包含一些可选元数据。声音定义位于命名空间下的
sounds.json文件 中。 - 声音对象(Sound object):一个包含声音文件位置以及可选元数据的 JSON 对象。
- 声音文件(Sound file):磁盘上的声音文件。Minecraft 只支持
.ogg格式的声音文件。
由于 OpenAL(Minecraft 的音频库)的实现方式,如果你希望声音具有衰减效果——即根据玩家与声音的距离变得更大声或更小声——你的声音文件必须是单声道(mono,单通道)。立体声(stereo,多通道)声音文件不会受到衰减影响,并且始终在玩家位置播放,非常适合用作环境音或背景音乐。详见 MC-146721。
创建 SoundEvent(Creating SoundEvents)
SoundEvent 是注册对象,这意味着它们必须通过 DeferredRegister 注册到游戏中,并且是单例的:
public class MySoundsClass {
// 假设你的 mod id 是 examplemod
public static final DeferredRegister<SoundEvent> SOUND_EVENTS =
DeferredRegister.create(BuiltInRegistries.SOUND_EVENT, "examplemod");
// 所有原版声音都使用可变范围事件(variable range events)。
public static final Holder<SoundEvent> MY_SOUND = SOUND_EVENTS.register(
"my_sound",
// 传入注册表名称
SoundEvent::createVariableRangeEvent
);
// 目前还有一种未被广泛使用的方法,可以注册固定范围(即无衰减)的事件:
public static final Holder<SoundEvent> MY_FIXED_SOUND = SOUND_EVENTS.register(
"my_fixed_sound",
// 16 是声音的默认范围。注意,由于 OpenAL 的限制,
// 超过 16 的数值不会生效,且会被限制为 16。
registryName -> SoundEvent.createFixedRangeEvent(registryName, 16f)
);
}
当然,别忘了在 模组事件总线(mod event bus) 中的模组构造函数(mod constructor)里添加你的注册表:
public ExampleMod(IEventBus modBus) {
MySoundsClass.SOUND_EVENTS.register(modBus);
// 这里可以注册其他内容
}
这样,你就拥有了一个声音事件!
sounds.json
另请参见:sounds.json(Minecraft Wiki 上的 Minecraft Wiki)
现在,为了将你的声音事件(sound event)与实际的音频文件关联起来,我们需要创建声音定义(sound definitions)。某个命名空间(namespace)下的所有声音定义都存储在命名空间根目录下的一个名为 sounds.json 的文件中,这个文件也被称为声音定义文件(sound definitions file)。每一个声音定义都是将声音事件 ID(例如 my_sound)映射到一个 JSON 声音对象(sound object)。注意,声音事件 ID 不需要指定命名空间,因为该命名空间已经由声音定义文件所在的位置决定。一个示例 sounds.json 文件如下所示:
{
// 为声音事件 "examplemod:my_sound" 定义的声音
"my_sound": {
// 声音对象的列表。如果包含多个元素,将会随机选择其中一个播放。
"sounds": [
// 只需要指定 name,其他属性都是可选的。
{
// 声音文件的位置,相对于命名空间的 sounds 文件夹。
// 本例引用的是 assets/examplemod/sounds/sound_1.ogg 下的声音文件。
"name": "examplemod:sound_1",
// 可以为 "sound" 或 "event"。"sound" 表示 name 指向一个声音文件,
// "event" 表示 name 指向另一个声音事件。默认为 "sound"。
"type": "sound",
// 播放该声音的音量,取值范围为 0.0 到 1.0(默认值)。
"volume": 0.8,
// 播放该声音时的音调。
// 取值范围为 0.0 到 2.0,默认值为 1.0。
"pitch": 1.1,
// 从 sounds 列表中选择声音时该声音的权重,默认为 1。
"weight": 3,
// 如果为 true,则声音将以流式方式从文件读取,而不是一次性加载到内存。
// 建议较长的声音文件使用此选项。默认为 false。
"stream": true,
// 手动指定衰减距离,默认为 16。对于固定距离的声音事件,该选项会被忽略。
"attenuation_distance": 8,
// 如果为 true,则在资源包加载时将声音预加载到内存,而不是在播放时加载。
// 原版通常在水下环境音效中使用此选项。默认为 false。
"preload": true
},
// 等同于 { "name": "examplemod:sound_2" } 的简写方式
"examplemod:sound_2"
]
},
"my_fixed_sound": {
// 可选。如果为 true,则会替换其他资源包中的同名声音,而不是与其合并。
// 详见下方的 Merging(合并)章节获取更多信息。
"replace": true,
// 当该声音事件被触发时显示的字幕翻译键。
"subtitle": "examplemod.my_fixed_sound",
"sounds": [
"examplemod:sound_1",
"examplemod:sound_2"
]
}
}
合并(Merging)
与大多数其他资源文件不同,sounds.json 并不会直接覆盖下层资源包中的值。相反,它们会被合并,然后作为一个整体的 sounds.json 文件进行解析。假设有 sound_1、sound_2、sound_3 和 sound_4 这四个声音,分别在两个不同的资源包 RP1 和 RP2 的 sounds.json 文件中定义,其中 RP2 位于 RP1 之下:
RP1 中的 sounds.json:
{
"sound_1": {
"sounds": [
"sound_1"
]
},
"sound_2": {
"replace": true,
"sounds": [
"sound_2"
]
},
"sound_3": {
"sounds": [
"sound_3"
]
},
"sound_4": {
"replace": true,
"sounds": [
"sound_4"
]
}
}
RP2 中的 sounds.json:
{
"sound_1": {
"sounds": [
"sound_5"
]
},
"sound_2": {
"sounds": [
"sound_6"
]
},
"sound_3": {
"replace": true,
"sounds": [
"sound_7"
]
},
"sound_4": {
"replace": true,
"sounds": [
"sound_8"
]
}
}
最终游戏会用来加载音效的合并后(merged)sounds.json 文件大致如下(仅存在于内存中,这个文件实际上不会被写到任何地方):
{
"sound_1": {
// replace 都为 false:先添加下层资源包的,再添加上层资源包的
"sounds": [
"sound_5",
"sound_1"
]
},
"sound_2": {
// 上层资源包 replace 为 true,下层为 false:只添加上层资源包的
"sounds": [
"sound_2"
]
},
"sound_3": {
// 上层资源包 replace 为 false,下层为 true:先添加下层资源包的,再添加上层资源包的
// 如果还有第三个资源包位于 RP2 下方,其定义会被丢弃
"sounds": [
"sound_7",
"sound_3"
]
},
"sound_4": {
// replace 都为 true:只添加上层资源包的
"sounds": [
"sound_8"
]
}
}
播放音效(Playing Sounds)
Minecraft 提供了多种播放音效的方法,有时候选择哪种方法可能并不直观。所有方法都接受一个 SoundEvent 参数,这个参数既可以是你自己定义的,也可以是原版(vanilla)的(原版音效事件定义在 SoundEvents 类中)。在接下来的方法说明中,client 和 server 分别指的是 逻辑客户端和逻辑服务端。
Level
-
playSeededSound(Player player, double x, double y, double z, Holder<SoundEvent> soundEvent, SoundSource soundSource, float volume, float pitch, long seed)- 客户端行为:如果传入的
player是本地玩家,则在指定位置为该玩家播放音效事件,否则不执行任何操作。 - 服务端行为:向除传入的玩家之外的所有玩家发送一个数据包,指示其在指定位置播放该音效事件。
- 用法:适用于由客户端发起、会在客户端和服务端同时运行的代码调用。服务端不会为发起的玩家播放音效,避免该玩家听到两次同样的音效。或者,你可以在服务端发起的代码(例如某个 [方块实体][be])中传入
null作为玩家参数,这样会让所有玩家都听到该音效。
- 客户端行为:如果传入的
-
playSound(Player player, double x, double y, double z, SoundEvent soundEvent, SoundSource soundSource, float volume, float pitch)- 实际调用
playSeededSound,使用随机种子,并将SoundEvent包装为 holder。
- 实际调用
-
playSound(Player player, BlockPos pos, SoundEvent soundEvent, SoundSource soundSource, float volume, float pitch)- 调用上面的方法,
x、y和z的值分别为pos.getX() + 0.5、pos.getY() + 0.5和pos.getZ() + 0.5。
- 调用上面的方法,
-
playLocalSound(double x, double y, double z, SoundEvent soundEvent, SoundSource soundSource, float volume, float pitch, boolean distanceDelay)- 客户端行为:在指定位置为玩家本地播放音效,不会向服务端发送任何信息。如果
distanceDelay为true,则根据玩家与音效位置的距离延迟播放。 - 服务端行为:不执行任何操作。
- 用法:通常由服务端自定义数据包触发。原版主要用于雷声效果。
- 客户端行为:在指定位置为玩家本地播放音效,不会向服务端发送任何信息。如果
ClientLevel
playLocalSound(BlockPos pos, SoundEvent soundEvent, SoundSource soundSource, float volume, float pitch, boolean distanceDelay)- 实际调用
Level#playLocalSound,x、y和z的值分别为pos.getX() + 0.5、pos.getY() + 0.5和pos.getZ() + 0.5。
- 实际调用
Entity
playSound(SoundEvent soundEvent, float volume, float pitch)- 实际调用
Level#playSound,玩家参数为null,音源类型使用Entity#getSoundSource,位置为实体自身的坐标,其他参数保持不变。
- 实际调用
Player
playSound(SoundEvent soundEvent, float volume, float pitch)(重写了Entity中的方法)- 实际调用
Level#playSound,玩家参数为this,音源类型为SoundSource.PLAYER,位置为玩家自身的坐标,其他参数保持不变。因此,其客户端/服务端行为与Level#playSound一致:- 客户端行为:在指定位置为本地玩家播放音效事件。
- 服务端行为:在指定位置为附近所有玩家播放音效,除了调用该方法的玩家本人。
- 实际调用
数据生成(Datagen)
当然,声音文件本身无法通过 数据生成,但 sounds.json 文件是可以的。为此,你需要继承 SoundDefinitionsProvider 并重写 registerSounds() 方法:
public class MySoundDefinitionsProvider extends SoundDefinitionsProvider {
// 参数可以通过 `GatherDataEvent.Client` 获取。
public MySoundDefinitionsProvider(PackOutput output) {
// 使用你的实际模组 id 替换 "examplemod"。
super(output, "examplemod");
}
@Override
public void registerSounds() {
// 第一个参数可以是 Supplier<SoundEvent>、SoundEvent 或 ResourceLocation。
add(MySoundsClass.MY_SOUND, SoundDefinition.definition()
// 向声音定义中添加声音对象。参数为可变参数。
.with(
// 第一个参数可以是字符串或 ResourceLocation。
// 第二个参数可以是 SOUND 或 EVENT,如果是前者可以省略。
sound("examplemod:sound_1", SoundDefinition.SoundType.SOUND)
// 设置音量。也可以使用 double 类型的重载方法。
.volume(0.8f)
// 设置音高。也可以使用 double 类型的重载方法。
.pitch(1.2f)
// 设置权重。
.weight(2)
// 设置衰减距离。
.attenuationDistance(8)
// 启用流式播放。
// 也有一个无参数的重载,等同于 stream(true)。
.stream(true)
// 启用预加载。
// 也有一个无参数的重载,等同于 preload(true)。
.preload(true),
// 最简写法。
sound("examplemod:sound_2")
)
// 设置字幕。
.subtitle("sound.examplemod.sound_1")
// 启用替换。
.replace(true)
);
}
}
和所有数据提供器一样,别忘了将你的 provider 注册到事件中:
@SubscribeEvent
public static void gatherData(GatherDataEvent.Client event) {
event.createProvider(MySoundDefinitionsProvider::new);
}