本篇教程由作者设定使用 CC BY-NC-SA 协议。

零、说明

YACL 是一个强大的配置文件 lib。它集成了创建、序列化配置、自动更新文件、游戏内动态更新配置项等功能,也提供了一个可视化的配置窗口。此外,模组轻量、简洁,非常适合开发者上手。

模组作者给你使用这个模组的场景进行了推荐,如果你的模组是仅客户端模组,或是双端的模组但配置项大多数仅用于客户端,使用 YACL 是一个非常不错的选择;而如果你的模组或配置项仅用于服务端,那么并不推荐你选择 YACL。

本篇教程参考了 YACL 的官方 WIKI,受 CC: BY-NC-SA 协议保护。教程的代码受 MIT 协议保护。涉及到 Minecraft 的代码则是使用官方反混淆表反编译,仅作为学习交流使用。

此教程以默认读者具备 Java 代码能力和 mod 开发的基础了解。

一、build.gradle 的配置

首先,你需要在 repositories 模块加入两个模组各自的 Maven:

repositories {
    // YACL
    maven {
        name 'Xander Maven'
        url 'https://maven.isxander.dev/releases'
    }

    // Mod Menu
    maven {
        name 'terraformersmc Maven'
        url "https://maven.terraformersmc.com"
    }
}

然后在 dependencies 模块里加入对应的模组版本。这里以 1.20.4 为例:

dependencies {
    minecraft "com.mojang:minecraft:${project.minecraft_version}"
    mappings loom.officialMojangMappings()
    modImplementation "net.fabricmc:fabric-loader:${project.loader_version}"

    modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}"

    modImplementation("dev.isxander.yacl:yet-another-config-lib-fabric:3.3.2+1.20.4")    // YACL
    modImplementation("com.terraformersmc:modmenu:9.0.0")    // ModMenu

    implementation 'com.google.code.findbugs:jsr305:3.0.2'
}

重载 gradle 项目,便可以开始了!

接下来的代码以 1.20.4 为例,更旧的版本会略有不同(如找不到 SerialEntry 时需要使用 ConfigEntry 等)。

二、Config 文件的编写

首先,开发者要明确自己模组需要哪些配置。如,杀死 boss 后某个宝物的掉落概率 treasureDropPossibility,或是杀死 boss 后玩家收到的提示信息 killBossMessage,又或是 boss 的皮肤颜色 bossSkinColor,以及 boss 是否可以重生 canBossRespawn 等。

以上文的四个配置项为例(基本覆盖了所有的 Controller,剩余的功能可以举一反三),我们介绍如何创建配置文件,以及如何创建配置 UI。

Config 类的实现

首先,我们定义一个 Config 类,用来记录所有配置项和实例化:

public final class ModClientConfig {
    public static final ConfigClassHandler<ModClientConfig> INSTANCE = ConfigClassHandler.createBuilder(ModClientConfig.class)
        .id(new ResourceLocation(MODID, "client_config"))
        .serializer(config -> GsonConfigSerializerBuilder.create(config)
                .setPath(FabricLoader.getInstance().getConfigDir().resolve("modid-client.json")).build())   // Change "modid" to your mod ID.
        .build();
    
    //TODO
}

然后,在你的 ClientModInitializer 的 onInitializeClient 中,加载这个 Config:

public final class ModClient implements ClientModInitializer {
    @Override
    public void onInitializeClient() {
        // Other things

        ModClientConfig.INSTANCE.load();
    }

    public static ModClientConfig getConfig() {
        return ModClientConfig.INSTANCE.instance();
    }
}

这样,配置文件就创建完毕了——正如我所说的,非常容易上手。

那么,如何把上述四个配置项加进 Config 文件里呢?此时就需要将它们定义为成员变量,然后参与序列化:

public final class ModClientConfig {
    // ConfigClassHandler<ModClientConfig> INSTANCE = ...
    
    @SerialEntry
    private float treasureDropPossibility = 0.85F;
    @SerialEntry
    private String killBossMessage = "Congratulations!";
    @SerialEntry
    private Color bossSkinColor = Color.RED;
    @SerialEntry
    private boolean canBossRespawn = true;
}

搞定了,就这么简单!

配置界面的实现

或许有人就会问了,这只能生成配置文件啊,如何生成一个像模像样的配置界面呢?

这时你就需要联动 Mod Menu 了。除了上文修改 build.gradle 外,你还需要额外修改 fabric.mod.json,添加 modmenu 的入口点:

"entrypoints": {
  "client": [
    "com.hexagram2021.modid.client.ModClient"
  ],
  "main": [
    "com.hexagram2021.modid.ModCommon"
  ],
  "modmenu": [
    "com.hexagram2021.modid.client.compat.ModMenuCompat"
  ]
},

然后实现 ModMenuCompat 类——这么做本身就已经做好了隔离,所以不要担心玩家没有安装 Mod Menu 会不会崩溃:

public class ModMenuCompat implements ModMenuApi {
    @Override
    public ConfigScreenFactory<?> getModConfigScreenFactory() {
       return ModClientConfig::makeScreen;
    }
}

这里调用了 ModClientConfig 的 makeScreen 函数,来生成一个可视化配置界面。可是,从头实现一个 Screen 是一件很复杂的事——可是,我们有万能的 YACL 啊!

YACL 内置了许多的控件(Controller),如 FloatSliderControllerBuilder 是用于配置一个浮点变量的滑动条控件,而 DropdownStringControllerBuilder 则是用来配置一个(约束的)字符串变量的输入框控件。对于上述四种情况,这里给出一个可行的控件使用方法:

public static Screen makeScreen(Screen parent) {
    return YetAnotherConfigLib.create(INSTANCE, (defaults, config, builder) -> builder
            .title(Component.translatable("config.modid.title"))
            .category(ConfigCategory.createBuilder()
                    .name(Component.translatable("config.modid.title"))
                    .option(Option.<Float>createBuilder()
                            .name(Component.translatable("config.modid.option.treasureDropPossibility"))
                            .description(OptionDescription.of(Component.translatable("config.modid.option.treasureDropPossibility.desc")))
                            .binding(
                                    0.85F,
                                    () -> config.treasureDropPossibility,
                                    value -> config.treasureDropPossibility = value
                            )
                            .controller(opt -> FloatSliderControllerBuilder.create(opt).range(0.0F, 1.0F).step(0.01F)
                                    .valueFormatter(val -> Component.literal(val * 100.0F + "%")))
                            .build())
                    .option(Option.<String>createBuilder()
                            .name(Component.translatable("config.modid.option.killBossMessage"))
                            .description(OptionDescription.of(Component.translatable("config.modid.option.killBossMessage.desc")))
                            .binding(
                                    "Congratulations!",
                                    () -> config.killBossMessage,
                                    value -> config.killBossMessage = value
                            )
                            .controller(opt -> StringControllerBuilder::create)
                            .build())
                    .option(Option.<Color>createBuilder()
                            .name(Component.translatable("config.modid.option.bossSkinColor"))
                            .description(OptionDescription.of(Component.translatable("config.modid.option.bossSkinColor.desc")))
                            .binding(
                                    Color.RED,
                                    () -> config.bossSkinColor,
                                    value -> config.bossSkinColor = value
                            )
                            .controller(opt -> ColorControllerBuilder.create(opt).allowAlpha(true))
                            .build())
                    .option(Option.<Boolean>createBuilder()
                            .name(Component.translatable("config.modid.option.canBossRespawn"))
                            .description(OptionDescription.of(Component.translatable("config.modid.option.canBossRespawn.desc")))
                            .binding(
                                    true,
                                    () -> config.canBossRespawn,
                                    value -> config.canBossRespawn = value
                            )
                            .controller(opt -> BooleanControllerBuilder.create(opt)
                                    .valueFormatter(val -> Component.literal(val ? "Respawn": "Never Respawn"))
                                    .coloured(true))
                            .build())
                    .build()))
            .generateScreen(parent);
}

其中 BooleanController 还可以被替换成更加简洁的 TickBoxController 用来描述一个布尔变量。所有 Controller 都可以在这里找到,可以对照选择自己最需要的控件。

另外不得不提一个特殊的选项:ListOption。这个选项提供了一个可增加删除和排序的列表,可以嵌套其它控件使用,例如,你想制作一个颜色的序列,你可以这样使用它:

@SerialEntry
private List<Color> customColors = Lists.newArrayList(
        Color.RED, Color.ORANGE, Color.YELLOW, Color.GREEN, Color.CYAN
);

public static Screen makeScreen(Screen parent) {
    return YetAnotherConfigLib.create(INSTANCE, (defaults, config, builder) -> builder
            .title(Component.translatable("config.modid.title"))
            .category(ConfigCategory.createBuilder()
                    .name(Component.translatable("config.modid.title"))
                    .option(ListOption.<Color>createBuilder()
                            .name(Component.translatable("config.modid.option.customColors"))
                            .binding(
                                    List.of(Color.RED, Color.ORANGE, Color.YELLOW, Color.GREEN, Color.CYAN),
                                    () -> config.customColors,
                                    value -> config.customColors = value
                            )
                            .controller(opt -> ColorControllerBuilder.create(opt).allowAlpha(true))
                            .initial(Color.BLACK)
                            .build())
                    .build()))
            .generateScreen(parent);
}

三、结论

通过这种方法完成的模组,将会以 YACL 为必要前置、Mod Menu 为可选前置(联动)。

请记得不要过早调用 ModClientConfig.INSTANCE.instance() 或 ModClient.getConfig(),因为 ConfigClassHandler 的原理有点像 Forge 的 RegistryObject,并不是声明之时就已经实例化了一个 Config,而是先占位,然后在模组生命周期里的一个特定的节点完成的实例化。不过在游戏过程中,你可以尽情使用它获取配置文件,因为此时它已经完成了实例化。

以上则是笔者关于 YACL 和 Mod Menu 模组用法和教程的拙见,如有不完美或不详细之处,敬请指出。如有其他需要更新的内容,笔者也会尽快修改!