声音

声音

声音虽然不是必需的,却能让 Mod 更细腻、更有生气。Minecraft 提供多种注册和播放声音的方式,本文将逐一介绍。

术语

Minecraft 声音引擎用以下术语区分不同概念:

  • 声音事件:代码中的触发器,用于告诉声音引擎播放某个声音。注册进游戏的对象也是 SoundEvent。
  • 声音类别或声音来源:声音的大致分组,可分别调节。声音设置界面的滑块对应这些类别,例如 master、block、player 等。代码中由 SoundSource 枚举表示。
  • 声音定义:将一个声音事件映射到一个或多个声音对象,并包含可选元数据。声音定义位于命名空间的 sounds.json 文件中。
  • 声音对象:包含声音文件位置和可选元数据的 JSON 对象。
  • 声音文件:磁盘上的音频文件。Minecraft 仅支持 .ogg 音频。

重要警告 受 Minecraft 音频库 OpenAL 的实现限制,声音若需随玩家距离远近而衰减,文件必须是单声道。立体声(多声道)文件不会衰减,始终在玩家位置播放,因此适合环境音和背景音乐。另见 MC-146721。

创建 SoundEvent

SoundEvent 是注册对象,必须通过 DeferredRegister 注册到游戏,并保持为单例:

public class MySoundsClass {
    // Assuming that your mod id is examplemod
    public static final DeferredRegister<SoundEvent> SOUND_EVENTS =
            DeferredRegister.create(BuiltInRegistries.SOUND_EVENT, "examplemod");
    
    // All vanilla sounds use variable range events.
    public static final DeferredHolder<SoundEvent, SoundEvent> MY_SOUND = SOUND_EVENTS.register(
            "my_sound", // must match the resource location on the next line
            () -> SoundEvent.createVariableRangeEvent(ResourceLocation.fromNamespaceAndPath("examplemod", "my_sound"))
    );
    
    // There is a currently unused method to register fixed range (= non-attenuating) events as well:
    public static final DeferredHolder<SoundEvent, SoundEvent> MY_FIXED_SOUND = SOUND_EVENTS.register("my_fixed_sound",
            // 16 is the default range of sounds. Be aware that due to OpenAL limitations,
            // values above 16 have no effect and will be capped to 16.
            () -> SoundEvent.createFixedRangeEvent(ResourceLocation.fromNamespaceAndPath("examplemod", "my_fixed_sound"), 16)
    );
}

还需要在 Mod 构造函数中,将注册器添加到 Mod 事件总线:

public ExampleMod(IEventBus modBus) {
    MySoundsClass.SOUND_EVENTS.register(modBus);
    // other things here
}

这样就创建好了一个声音事件。

sounds.json

另见 Minecraft Wiki 上的 sounds.json 条目。

接下来需要创建声音定义,将声音事件与实际文件关联起来。命名空间内的所有声音定义都保存在其根目录的一个 sounds.json 文件(声音定义文件)中。每项声音定义将声音事件 ID(例如 my_sound)映射到 JSON 声音对象。事件 ID 不包含命名空间,因为它已经由定义文件所在的命名空间确定。例如:

{
    // Sound definition for the sound event "examplemod:my_sound"
    "my_sound": {
        // List of sound objects. If this contains more than one element, an element will be chosen randomly.
        "sounds": [
            // Only name is required, all other properties are optional.
            {
                // Location of the sound file, relative to the namespace's sounds folder.
                // This example references a sound at assets/examplemod/sounds/sound_1.ogg.
                "name": "examplemod:sound_1",
                // May be "file" or "event". "file" causes the name to refer to a sound file.
                // "event" causes the name to refer to another sound event. Defaults to "file".
                "type": "file",
                // The volume this sound will be played at. Must be between 0.0 and 1.0 (default).
                "volume": 0.8,
                // The pitch value the sound will be played at.
                // Must be between 0.0 and 2.0. Defaults to 1.0.
                "pitch": 1.1,
                // Weight of this sound when choosing a sound from the sounds list. Defaults to 1.
                "weight": 3,
                // If true, the sound will be streamed from the file instead of loaded all at once.
                // Recommended for sound files that are more than a few seconds long. Defaults to false.
                "stream": true,
                // Manual override for the attenuation distance. Defaults to 16. Ignored by fixed range sound events.
                "attenuation_distance": 8,
                // If true, the sound will be loaded into memory on pack load, instead of when the sound is played.
                // Vanilla uses this for underwater ambience sounds. Defaults to false.
                "preload": true
            },
            // Shortcut for { "name": "examplemod:sound_2" }
            "examplemod:sound_2"
        ]
    },
    "my_fixed_sound": {
        // Optional. If true, replaces sounds from other resource packs instead of adding to them.
        // See the Merging chapter below for more information.
        "replace": true,
        // The translation key of the subtitle displayed when this sound event is triggered.
        "subtitle": "examplemod.my_fixed_sound",
        "sounds": [
            "examplemod:sound_1",
            "examplemod:sound_2"
        ]
    }
}

合并

与大多数资源文件不同,sounds.json 不会直接覆盖下方资源包中的值,而会合并后作为一份完整文件解析。假设两个资源包 RP1、RP2 的 sounds.json 中定义了 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"
        ]
    }
}

游戏用于加载声音的合并结果如下(仅存在于内存中,不会写入文件):

{
    "sound_1": {
        // replace false and false: add from lower pack, then from upper pack
        "sounds": [
            "sound_5",
            "sound_1"
        ]
    },
    "sound_2": {
        // replace true in upper pack and false in lower pack: add from upper pack only
        "sounds": [
            "sound_2"
        ]
    },
    "sound_3": {
        // replace false in upper pack and true in lower pack: add from lower pack, then from upper pack
        // Would still discard values from a third resource pack sitting below RP2
        "sounds": [
            "sound_7",
            "sound_3"
        ]
    },
    "sound_4": {
        // replace true and true: add from upper pack only
        "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)
    • 将 SoundEvent 包装为 holder,选择随机种子后转发给 playSeededSound。
  • 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 相同:
      • 客户端:在指定位置向客户端玩家播放声音事件。
      • 服务端:在指定位置附近向所有人播放声音事件,但不包括调用此方法的玩家。

数据生成

声音文件本身不能通过数据生成创建,但 sounds.json 可以。继承 SoundDefinitionsProvider 并重写 registerSounds():

public class MySoundDefinitionsProvider extends SoundDefinitionsProvider {
    // Parameters can be obtained from GatherDataEvent.
    public MySoundDefinitionsProvider(PackOutput output, ExistingFileHelper existingFileHelper) {
        // Use your actual mod id instead of "examplemod".
        super(output, "examplemod", existingFileHelper);
    }

    @Override
    public void registerSounds() {
        // Accepts a Supplier<SoundEvent>, a SoundEvent, or a ResourceLocation as the first parameter.
        add(MySoundsClass.MY_SOUND, SoundDefinition.definition()
            // Add sound objects to the sound definition. Parameter is a vararg.
            .with(
                // Accepts either a string or a ResourceLocation as the first parameter.
                // The second parameter can be either SOUND or EVENT, and can be omitted if the former.
                // A type of SOUND refer to the "file" type mentioned previously
                sound("examplemod:sound_1", SoundDefinition.SoundType.SOUND)
                    // Sets the volume. Also has a double counterpart.
                    .volume(0.8f)
                    // Sets the pitch. Also has a double counterpart.
                    .pitch(1.2f)
                    // Sets the weight.
                    .weight(2)
                    // Sets the attenuation distance.
                    .attenuationDistance(8)
                    // Enables streaming.
                    // Also has a parameterless overload that defers to stream(true).
                    .stream(true)
                    // Enables preloading.
                    // Also has a parameterless overload that defers to preload(true).
                    .preload(true),
                // The shortest we can get.
                sound("examplemod:sound_2")
            )
            // Sets the subtitle.
            .subtitle("sound.examplemod.sound_1")
            // Enables replacing.
            .replace(true)
        );
    }
}

与其他数据提供器一样,不要忘记在事件中注册提供器:

@SubscribeEvent // on the mod event bus
public static void gatherData(GatherDataEvent event) {
    DataGenerator generator = event.getGenerator();
    PackOutput output = generator.getPackOutput();
    ExistingFileHelper existingFileHelper = event.getExistingFileHelper();

    // other providers here
    generator.addProvider(
        event.includeClient(),
        new MySoundDefinitionsProvider(output, existingFileHelper)
    );
}

来源: NeoForged/Documentation(1.21.1)。中文翻译依据提交 4a680cdd4d15。