声音(Sounds)
声音(Sounds)
声音虽然不是必需的,但它们可以使模组感觉更加细腻和生动。Minecraft 提供了多种注册和播放声音的方式,本文将详细介绍这些内容。
术语
Minecraft 的声音引擎使用了许多术语来指代不同的事物:
- 声音事件(Sound Event) :声音事件是一个代码触发器,告诉声音引擎播放某个声音。
SoundEvent
也是你注册到游戏中的内容。 - 声音类别或声音源(Sound Category/Sound Source) :声音类别是声音的粗略分组,可以单独切换。声音选项 GUI 中的滑块代表这些类别:
master
、block
、player
等。在代码中,它们可以在SoundSource
枚举中找到。 - 声音定义(Sound Definition) :将声音事件映射到一个或多个声音对象,加上一些可选的元数据。声音定义位于命名空间的
sounds.json
文件中。 - 声音对象(Sound Object) :一个 JSON 对象,包含声音文件的位置以及一些可选的元数据。
- 声音文件(Sound File) :磁盘上的声音文件。Minecraft 仅支持
.ogg
格式的声音文件。
危险 由于 OpenAL(Minecraft 的音频库)的实现,为了使你的声音具有衰减(即根据玩家与声音的距离使其变弱或变强),你的声音文件必须是单声道(单通道)的。立体声(多通道)声音文件不会受到衰减的影响,并且始终在玩家的位置播放,因此它们非常适合环境声音和背景音乐。参见 MC-146721。
创建 SoundEvent
SoundEvent
是注册对象,这意味着它们必须通过 DeferredRegister
注册到游戏中,并且是单例的:
public class MySoundsClass {
// 假设你的模组 ID 是 examplemod
public static final DeferredRegister<SoundEvent> SOUND_EVENTS =
DeferredRegister.create(BuiltInRegistries.SOUND_EVENT, "examplemod");
// 所有原版声音都使用可变范围事件。
public static final DeferredHolder<SoundEvent, SoundEvent> MY_SOUND = SOUND_EVENTS.register(
"my_sound", // 必须与下一行的资源位置匹配
() -> SoundEvent.createVariableRangeEvent(ResourceLocation.fromNamespaceAndPath("examplemod", "my_sound"))
);
// 还有一个当前未使用的方法来注册固定范围(= 无衰减)事件:
public static final DeferredHolder<SoundEvent, SoundEvent> MY_FIXED_SOUND = SOUND_EVENTS.register("my_fixed_sound",
// 16 是声音的默认范围。请注意,由于 OpenAL 的限制,
// 大于 16 的值无效,并将被限制为 16。
() -> SoundEvent.createFixedRangeEvent(ResourceLocation.fromNamespaceAndPath("examplemod", "my_fixed_sound"), 16)
);
}
当然,别忘了在模组构造函数中将你的注册表添加到模组事件总线:
public ExampleMod(IEventBus modBus) {
MySoundsClass.SOUND_EVENTS.register(modBus);
// 其他内容
}
就这样,你已经创建了一个声音事件!
sounds.json
参见:Minecraft Wiki 上的 sounds.json
现在,为了将你的声音事件与实际的声音文件关联起来,我们需要创建声音定义。一个命名空间的所有声音定义都存储在一个名为 sounds.json
的文件中,也称为声音定义文件,直接位于命名空间的根目录中。每个声音定义都是声音事件 ID(例如 my_sound
)到 JSON 声音对象的映射。请注意,声音事件 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,
// 从声音列表中选择声音时的权重。默认为 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,替换其他资源包中的声音,而不是添加到它们。
// 有关更多信息,请参见下面的合并章节。
"replace": true,
// 触发此声音事件时显示的字幕的翻译键。
"subtitle": "examplemod.my_fixed_sound",
"sounds": [
"examplemod:sound_1",
"examplemod:sound_2"
]
}
}
合并(Merging)
与大多数其他资源文件不同,sounds.json
不会覆盖位于其下方的资源包中的值。相反,它们会被合并在一起,然后作为一个组合的 sounds.json
文件进行解释。考虑在两个不同的资源包 RP1
和 RP2
中定义的声音 sound_1
、sound_2
、sound_3
和 sound_4
,其中 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"
]
}
}
然后,游戏将使用组合(合并)的 sounds.json
文件来加载声音,该文件可能如下所示(仅在内存中,此文件不会写入任何地方):
{
"sound_1": {
// replace false 和 false:从下层包添加,然后从上层包添加
"sounds": [
"sound_5",
"sound_1"
]
},
"sound_2": {
// 上层包中 replace true,下层包中 replace false:仅从上层包添加
"sounds": [
"sound_2"
]
},
"sound_3": {
// 上层包中 replace false,下层包中 replace true:从下层包添加,然后从上层包添加
// 仍然会丢弃位于 RP2 下方的第三个资源包中的值
"sounds": [
"sound_7",
"sound_3"
]
},
"sound_4": {
// replace true 和 true:仅从上层包添加
"sounds": [
"sound_8"
]
}
}
播放声音
Minecraft 提供了多种播放声音的方法,有时不清楚应该使用哪种方法。所有方法都接受一个 SoundEvent
,它可以是你自己的,也可以是原版的(原版声音事件可以在 SoundEvents
类中找到)。对于以下方法描述,客户端和服务器分别指逻辑客户端和逻辑服务器。
Level
playSeededSound(Player player, double x, double y, double z, Holder<SoundEvent> soundEvent, SoundSource soundSource, float volume, float pitch, long seed)
- 客户端行为:如果传入的玩家是本地玩家,则在给定位置向玩家播放声音事件,否则无操作。
- 服务器行为:向除传入玩家之外的所有玩家发送一个数据包,指示客户端在给定位置播放声音事件。
- 用法:从将在双方运行的客户端启动代码中调用。服务器不向启动玩家播放声音事件,以防止向他们播放两次声音。或者,从服务器启动代码(例如方块实体)中调用,传入
null
作为玩家,以向所有人播放声音。
playSound(Player player, double x, double y, double z, SoundEvent soundEvent, SoundSource soundSource, float volume, float pitch)
- 转发到
playSeededSound
,使用随机选择的种子并将Holder
包装在SoundEvent
周围。
- 转发到
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
,player
为null
,SoundSource
为Entity#getSoundSource
,x
/y
/z
为实体的位置,其他参数传入。
- 转发到
Player
playSound(SoundEvent soundEvent, float volume, float pitch)
(覆盖Entity
中的方法)转发到
Level#playSound
,player
为this
,SoundSource
为SoundSource.PLAYER
,x
/y
/z
为玩家的位置,其他参数传入。因此,客户端/服务器行为模仿Level#playSound
:- 客户端行为:在给定位置向客户端玩家播放声音事件。
- 服务器行为:在给定位置向除调用此方法的玩家之外的所有人播放声音事件。
数据生成(Datagen)
声音文件本身当然不能生成数据,但 sounds.json
文件可以。为此,我们扩展 SoundDefinitionsProvider
并覆盖 registerSounds()
方法:
public class MySoundDefinitionsProvider extends SoundDefinitionsProvider {
// 参数可以从 GatherDataEvent 获取。
public MySoundDefinitionsProvider(PackOutput output, ExistingFileHelper existingFileHelper) {
// 使用你的实际模组 ID 而不是 "examplemod"。
super(output, "examplemod", existingFileHelper);
}
@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)
);
}
}
与所有数据提供者一样,别忘了将提供者注册到事件中:
@SubscribeEvent
public static void gatherData(GatherDataEvent event) {
DataGenerator generator = event.getGenerator();
PackOutput output = generator.getPackOutput();
ExistingFileHelper existingFileHelper = event.getExistingFileHelper();
// 其他提供者
generator.addProvider(
event.includeClient(),
new MySoundDefinitionsProvider(output, existingFileHelper)
);
}