Fabric Mod多版本兼容极简方案思路

Introduction

对于Mod开发者来说,最头疼的事情之一大概就是跨版本兼容的问题。对于我正在开发的Mod – Sniffer来说,这个问题尤为突出。作为一个数据包开发的辅助mod,Sniffer显然需要照顾到存在于各个版本的数据包开发者。就目前的命令系统来说,在1.20.2添加了宏以后,基本上没有什么大的变动了,这也意味着,Sniffer的整体基本逻辑实现代码从1.20.2到26.1都是不怎么变化的。如果对每个版本都设置一个新的 git 分支,就会相当的繁琐且不利于对所有版本进行统一的功能更新,可能做着做着就懒得维护一些版本了。而社区也提供了一些不错的解决方案,比如说ReplayMod的preprocessor,使用了类似c语言中宏的语法,根据指定的mc版本对源代码进行预处理,然后再编译。这种方案虽然看起来优雅,但是大量使用注释,导致很多地方都失去了高亮和语法检查,对于我这种重度IDE依赖者来说是不能忍受的。

在和GPT大战五分钟后,我尝试找到了一种更加简洁的方案,在保证IDE的各种语法功能正常的情况下,实现Mod开发的跨版本支持。

Shared Module & Pre-version Module

它的基础是一个非常常规的开发思路,也就是将原来的项目拆分为多个子模块,其中多个版本之间公用的模块作为shared子模块,而每个版本的具体实现各自再分为子模块,比如说1.21.91.21.11。但是一般来说,shared子模块不应该依赖Minecraft或者Fabric API的某个具体的版本,也就是说它应该是 “pure java library”。然而,大部分情况下,比如对于我自己的项目 Sniffer,我找了一圈基本上没有找到和Minecraft无关的内容。这个时候就会想要,把和Minecraft或者Fabric相关的类抽象为接口,在shared模块中使用接口写代码,而每个版本对于的子模块中提供对接口的实现。但是很快我就发现这是不现实的,因为涉及了太多太繁杂的类,如果真的要一个一个用接口实现完全是一个费力不讨好的工作。而且其实每个版本之间的api变动并不是很大,没有必要把所有的Minecraft类都抽象为一个接口,一般来说只需要把发生变动的部分抽象出来就可以了,其他部分应该直接使用Minecraft的类。

那么,有没有办法能让shared模块又可以不依赖于某个具体的Minecraft版本,又可以在其中使用Minecraft或者Fabric API的类呢?(我们假设这些被使用的类在不同版本之间没有变化,发生变动的类当然还是要写接口之类的东西来适配啦)

这个方案就能完美的实现我们的需求。

首先,还是让我们把项目给拆分开来。假设项目是一个标准的Fabric项目(项目结构和模板一样),那就新建shared子模块作为共享代码,以及1.21.111.21.9子模块作为版本特有的代码。随后,先把所有的源码都挪到shared中,两个版本子模块中不实现任何代码。

接下来,对gradle文件进行一些简单的配置。把原来的build.gradlesettings.gradlegradle.property都复制到各个版本子模块中替换自动生成的gradle配置文件,原来项目中的build.gradle可以清空。然后settings.gradle也可以复制到shared中替换了。

现在我们来调整版本子模块中的build.gradle让它把shared模块加入源码集。只需要在其中添加如下内容:

sourceSets {
	main {
		java.srcDirs += project(':shared').file('src/main/java')
		kotlin.srcDirs += project(':shared').file('src/main/kotlin')
	}
	client {
		java.srcDirs += project(':shared').file('src/client/java')
	}
	test {
		java.srcDirs += project(':shared').file('src/test/java')
	}
}

因为我使用了kotlin所以还添加了kotlin有关的配置。当你重载gradle以后,你应该可以看到shared模块下的文件夹已经被标记为了Mod的源代码目录。但是此时我们打开shared中的代码,可以看到一片红。因为shared模块还并没有声明任何依赖,所以此时IDE是找不到Minecraft或者Fabric中的类的。当然,在编译运行的时候已经不会发生任何问题,但是这显然对开发来说非常不友好,所以我们需要配置一下shared模块来让IDE识别到依赖。

build.gradle配置如下:

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile


plugins {
	id "org.jetbrains.kotlin.jvm" version "2.1.0"
}

repositories {
	maven { url "https://maven.shedaniel.me/" }
	maven { url "https://maven.terraformersmc.com/releases/" }
	maven { url "https://repo.essential.gg/repository/maven-public/" }
	maven { url 'https://maven.fabricmc.net/' }
}

dependencies {
    compileOnly project(':1.21.9')
}

tasks.withType(KotlinCompile).all {
	kotlinOptions {
		jvmTarget = 21
	}
}

tasks.withType(JavaCompile).configureEach {
	it.options.release = 21
}

java {
	sourceCompatibility = JavaVersion.VERSION_21
	targetCompatibility = JavaVersion.VERSION_21
}

除了常规的配置以外,我们需要在repositories中添加必要的仓库,避免IDE找不到依赖,此外在dependencies中添加了compileOnly project(':1.21.9'),表明这个项目依赖于子模块1.21.9,但是不会在构建过程中把这个打包进去。这里的1.21.9只是一个随意选择的版本,仅仅用来为我们的shared模块提供依赖类型支持,让IDE不会飘红。

现在打开shared模块中的代码,应该已经不会出现大面积飘红的现象了,因为我本来就是基于1.21.9开发的,所以这个时候应该不会出现任何报错。如果把shared依赖的模块改为1.21.11,就可以发现有一些地方因为api的变动导致了报错,这样也可以有利于我们去寻找版本之间的变化对我们模组的破坏性,找到哪些地方是需要抽象出逻辑,让各个版本进行适配的。

现在运行gradle :1.21.9:runClient,就会以1.21.9的Minecraft和对应的Fabric去编译shared中的内容,并打开游戏。可以看到一切正常。

Class Adapter

在搭建好工程结构以后,剩下的就是对逻辑的抽象以及在各个版本的子模块中写对应的Adapter进行适配了。这些内容应该都是在社区都有相当多的经验和解决方案的,这里给出我的一些解决办法作为例子。

在1.21.10中用于注册命令的代码大概是这样子的:

CommandRegistrationCallback.EVENT.register { dispatcher, _, _ ->
            dispatcher.register(
                literal<CommandSourceStack?>("assert")
                    .requires{it.hasPermission(2)}
                    //...

而到了1.21.11,其中有关权限的代码变成了这样:

CommandRegistrationCallback.EVENT.register { dispatcher, _, _ ->
            dispatcher.register(
                literal<CommandSourceStack?>("assert")
                    .requires(Commands.hasPermission(LEVEL_GAMEMASTERS))
                    //...

如何处理这个变动呢?

首先在shared中声明一个接口,用于实现逻辑的抽象

package dev.yourmod.compat;

import java.util.function.Predicate;

public interface CommandPermissionsProvider {
      Predicate<Object> require(int level);
}

然后再声明一个类,用于自动获取每个版本子模块对这个接口的实现,这样就不用去做注册什么的了

package dev.yourmod.compat;

import java.util.ServiceLoader;
import java.util.function.Predicate;

public final class CompatCommandPermissions {
    private static final CommandPermissionsProvider PROVIDER =
        ServiceLoader.load(CommandPermissionsProvider.class)
                 .findFirst()
                 .orElseThrow(() -> new IllegalStateException("No CommandPermissionsProvider found"));

    public static Predicate<Object> require(int level) {
        return PROVIDER.require(level);
    }

    // unchecked cast helper: avoids referencing CommandSourceStack in shared sources
    @SuppressWarnings("unchecked")
    public static <T> Predicate<T> castPredicate(Predicate<Object> p) {
        return (Predicate<T>) (Object) p;
    }
}

shared模块中有关命令注册的实现更改为如下:

dispatcher.register(
      literal("assert")
        .requires(CompatCommandPermissions.castPredicate(CompatCommandPermissions.require(2)))
        // ...
);

现在在版本子模块中进行实现。

1.21.10:

package dev.yourmod.compat;

import java.util.function.Predicate;
import net.minecraft.commands.CommandSourceStack;

public final class CommandPermissionsProviderImpl implements CommandPermissionsProvider {
    @Override
    public Predicate<Object> require(int level) {
        // implement using NMS type, cast to Predicate<Object>
        Predicate<CommandSourceStack> p = src -> src.hasPermission(level);
        return (Predicate<Object>) (Object) p;
    }
}

1.21.11

package dev.yourmod.compat;

import java.util.function.Predicate;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.commands.CommandPermission; // adjust actual names

public final class CommandPermissionsProviderImpl implements CommandPermissionsProvider {
    public Predicate<Object> require(int level) {
        // map numeric levels to the new CommandPermission constants as appropriate
        switch (level) {
            case 4: return (Predicate<Object>) (Object) Commands.hasPermission(CommandPermission.LEVEL_GAMEMASTERS);
            case 3: return (Predicate<Object>) (Object) Commands.hasPermission(CommandPermission.LEVEL_ADMINS);
            case 2: return (Predicate<Object>) (Object) Commands.hasPermission(CommandPermission.LEVEL_MODERATORS);
            case 1: return (Predicate<Object>) (Object) Commands.hasPermission(CommandPermission.LEVEL_PLAYERS);
            default: throw new Exception(); // throw an exception or handle this case
        }
    }
}

类似文章

发表回复