有一个可以让 mod 更加融入 Minecraft 的方法叫做自定义进度。我们要怎么做呢?
请确认你已经阅读了 数据生成入门 的第一部分,有一个实现 DataGenerationEntrypoint
的类,并且了解在数据生成器发生任何修改后需要调用的 gradle 任务。
为了自定义进度,我们需要将进度生成器(Advancement Generator)连接到通用数据生成器(General Data Generator)。
可惜的是,在我们真正开始添加一些进度之前,大概有三层的间接,但是我们一步步来。首先,在我们之前创建的 DataGeneration
文件里添加一下内容:
import net.fabricmc.fabric.api.datagen.v1.DataGeneratorEntrypoint; import net.fabricmc.fabric.api.datagen.v1.FabricDataGenerator; public class DataGeneration implements DataGeneratorEntrypoint { @Override public void onInitializeDataGenerator(FabricDataGenerator generator) { /** /* 添加我们的进度生成器 **/ generator.createPack().addProvider(AdvancementsProvider::new); // .. ( 你的其他生成器 ) } }
我们向 addProvider
函数(function)传递了一个尚未创建的类(AdvancementsProvider
),所以现在我们来创建他。添加一个新的类叫做 AdvancementsProvider
, 它继承(extend)了 FabricAdvancementProvider
。代码如下:
import net.fabricmc.fabric.api.datagen.v1.FabricDataOutput; import net.fabricmc.fabric.api.datagen.v1.provider.FabricAdvancementProvider; import net.minecraft.advancement.Advancement; import java.util.function.Consumer; public class AdvancementsProvider extends FabricAdvancementProvider { protected AdvancementsProvider(FabricDataOutput dataGenerator) { super(dataGenerator); } @Override public void generateAdvancement(Consumer<Advancement> consumer) { new Advancements().accept(consumer); } }
你可以再次看到,在高亮提示的那一行,还有一个尚未创建的类。
以下是 Advancements
类的样子,我们终于开始写一些自定义的进度了。
import net.minecraft.advancement.Advancement; import java.util.function.Consumer; public class Advancements implements Consumer<Consumer<Advancement>> { @Override public void accept(Consumer<Advancement> consumer) { // // 我们将在这里创建我们的自定义进度 // } }
让我们从简单开始,然后一直到自定义标准(Custom Criterions)。我们从一个捡起泥土即可获得的进度开始,然后添加到我们的 Advancements
类里。
import net.minecraft.advancement.Advancement; import net.minecraft.advancement.AdvancementFrame; import net.minecraft.advancement.criterion.InventoryChangedCriterion; import net.minecraft.item.Items; import net.minecraft.text.Text; import net.minecraft.util.Identifier; import java.util.function.Consumer; public class Advancements implements Consumer<Consumer<Advancement>> { @Override public void accept(Consumer<Advancement> consumer) { Advancement rootAdvancement = Advancement.Builder.create() .display( Items.DIRT, // 显示的图标 Text.literal("你的第一个泥土方块!"), // 标题 Text.literal("现在做了一个 3x3 !"), // 描述 new Identifier("textures/gui/advancements/backgrounds/adventure.png"), // 使用的背景图片 AdvancementFrame.TASK, // 选项: TASK, CHALLENGE, GOAL true, // 在右上角显示 true, // 在聊天框中提示 false // 在进度页面里隐藏 ) // Criterion 中使用的第一个字符串是其他进度在需要 'requirements' 时引用的名字 .criterion("got_dirt", InventoryChangedCriterion.Conditions.items(Items.DIRT)) .build(consumer, "在这里输入你的mod_id" + "/root"); } }
在这里输入你的mod_id
字符串修改为你的 mod 的名字。(同时保留 ”/root“ 部分)
这里将会更详细地解释这些代码的意思,但是如果你现在编译你的 mod ,并且进入 Minecraft 的存档,你会发现什么都没有发生。那是因为我们现在还没有生成这些数据。我们还没有运行之前创建的 runDatagenClient
gradle任务。在每次添加、修改、删除自定义进度的时候都需要执行该任务,否则更改将不会出现在游戏中。
所以,在终端上打开项目根目录并且运行:
./gradlew runDatagenClient
如果你的编译环境是 Windows,请使用以下命令:
.\gradlew.bat runDatagenClient
在前面谈到的 generated
文件夹中,你应该能看到一个名为 root.json
的文件,它保存了我们的进度数据,就像这样:
{ "criteria": { "got_dirt": { "conditions": { "items": [ { "items": [ "minecraft:dirt" ] } ] }, "trigger": "minecraft:inventory_changed" } }, "display": { "announce_to_chat": true, "background": "minecraft:textures/gui/advancements/backgrounds/adventure.png", "description": { "text": "现在做了一个 3x3 !" }, "frame": "task", "hidden": false, "icon": { "item": "minecraft:dirt" }, "show_toast": true, "title": { "text": "你的第一个泥土方块!" } }, "requirements": [ [ "got_dirt" ] ] }
好啦!现在重新回到游戏,看看能不能通过收集泥土方块来达到这个进度。你甚至可以离开这个存档,然后回来收集另一个泥土方块,但是并没有重新触发。如果你按下 Esc
并且打开 进度
选项卡,你应该可以看到我们的自定义进度,它的标题和简介。不过是在它自己的一栏里,和原版的进度分开。
Minecraft 中的所有进度看起来都像是来自我们生成的 root.json
。实际上,它根本不需要写任何代码来取得进度,只要你的 mods 中方块,项目,武器等等,都在它给定的注册表(registry)上被注册(比如创建方块),如果他们是原版的数据包,你可以引用任何你的 mod 所添加的自定义物品,比如食物,或任何东西,并制作与它们有关的进度。但是我们仍然建议你使用这种方法,因为它比手写进度要快地多。
让我们一步步回忆一下我们创建的进度,看看我们有哪些选择。我们首先调用 Advancement.Builder.create()
并将其赋予给 rootAdvancement
变量。(我们会在后面使用到它)。
Advancement rootAdvancement = Advancement.Builder.create()
然后我们使用它调用 'display' 连锁(chain)方法,它有7个参数
.display( /** 这是被作为贴图使用的物品 (你可以使用你自己 mod 中的任意贴图,只要它已经注册了) */ Items.DIRT, /** 这是用作开头的文本 */ Text.literal("你的第一个泥土方块!"), /** 这是用作描述的文本 */ Text.literal("现在做了一个 3x3"), /** 这是被用作进度选项卡的背景的图片 */ new Identifier("textures/gui/advancements/backgrounds/adventure.png"), /** 进度的类型,选项为:TASK、 CHALLENGE、GOAL*/ AdvancementFrame.TASK, /** 布尔值 当你达成了进度的时候,是否在屏幕右上角显示公告 */ true, /** 布尔值 当你达成了进度的时候,是否在聊天框发送消息 */ true, /** 布尔值 该进度是否可以在未达成时在进度选项卡中被看到 */ false )
然后我们告诉 Minecraft 这个进度应该在什么时候被触发(比如在获得了一个物品之后,或者和我们的样例中的一样,在一个方块进入我们的物品栏之后)然后调用 criterion
函数。
.criterion("got_dirt", InventoryChangedCriterion.Conditions.items(Items.DIRT))
第一个参数是一个 字符串
类型的名称。
requirements
使用(我们可以添加到进度中的另一个属性),这使得在进度达成之前,需要首先完成需求(达成其他进度)。换句话说,给 criterion 取什么名字基本不重要。
第二个参数是 criterion。在我们样例中,我们使用 InventoryChangedCriterion
并且把我们想要用来触发进度的物品 Item.DIRT
传递给它。但是有很多标准(criterions)。Minecraft Wiki 将它们列为 “触发器列表”。但是更好的参考文件是 Minecraft 本身的源代码(如果你还没有生成源代码,请阅读本文)。你可以看看 net.minecraft.advancement.criterion
文件夹,它们都在那里,可以看看有什么可以使用的。
PlayerHurtEntityCriterion.class | ImpossibleCriterion.class | Criterion.class | AbstractCriterion.class |
PlayerInteractedWithEntityCriterion.class | InventoryChangedCriterion.class | CriterionConditions.class | |
RecipeUnlockedCriterion.class | ItemCriterion.class | CriterionProgress.class | |
ShotCrossbowCriterion.class | ItemDurabilityChangedCriterion.class | CuredZombieVillagerCriterion.class | |
SlideDownBlockCriterion.class | KilledByCrossbowCriterion.class | EffectsChangedCriterion.class | |
StartedRidingCriterion.class | LevitationCriterion.class | EnchantedItemCriterion.class | |
SummonedEntityCriterion.class | LightningStrikeCriterion.class | EnterBlockCriterion.class | |
TameAnimalCriterion.class | OnKilledCriterion.class | EntityHurtPlayerCriterion.class | |
TargetHitCriterion.class | PlacedBlockCriterion.class | FilledBucketCriterion.class | |
ThrownItemPickedUpByEntityCriterion.class | PlayerGeneratesContainerLootCriterion.class | FishingRodHookedCriterion.class | |
TickCriterion.class | TravelCriterion.class | UsedEnderEyeCriterion.class | UsedTotemCriterion.class |
然后,自定义进度最后要调用的是:
.build(consumer, "在这里输入你的mod_id" + "/root");
我们把它传递给 消费者(consumer)
,并设置进度的 id。
在这里输入你的mod_id
字符串修改为你的 mod 的名字。(同时保留 ”/root“ 部分)为了掌握窍门,我们在示例中再添加两个进度
import net.minecraft.advancement.Advancement; import net.minecraft.advancement.AdvancementFrame; import net.minecraft.advancement.criterion.InventoryChangedCriterion; import net.minecraft.item.Items; import net.minecraft.text.Text; import net.minecraft.util.Identifier; import java.util.function.Consumer; public class Advancements implements Consumer<Consumer<Advancement>> { @Override public void accept(Consumer<Advancement> consumer) { Advancement rootAdvancement = Advancement.Builder.create() .display( Items.DIRT, // 贴图 Text.literal("Your First Dirt Block"), // 标题 Text.literal("Now make a three by three"), // 描述 new Identifier("textures/gui/advancements/backgrounds/adventure.png"), // 使用的背景图片 AdvancementFrame.TASK, // 选项: TASK, CHALLENGE, GOAL true, // 在右上角显示 true, // 在聊天框公告 false // 没有达成时,不在进度页面里显示 ) // Criterion 中使用的第一个字符串是其他进度在需要 'requirements' 时引用的名字 .criterion("got_dirt", InventoryChangedCriterion.Conditions.items(Items.DIRT)) .build(consumer, "在这里输入你的mod_id" + "/root"); Advancement gotOakAdvancement = Advancement.Builder.create().parent(rootAdvancement) .display( Items.OAK_LOG, Text.literal("Your First Oak Block"), Text.literal("Bare fisted"), null, // 子进度不需要设置背景 AdvancementFrame.TASK, true, true, false ) .rewards(AdvancementRewards.Builder.experience(1000)) .criterion("got_wood", InventoryChangedCriterion.Conditions.items(Items.OAK_LOG)) .build(consumer, "在这里输入你的mod_id" + "/got_wood"); Advancement eatAppleAdvancement = Advancement.Builder.create().parent(rootAdvancement) .display( Items.APPLE, Text.literal("Apple and Beef"), Text.literal("Ate an apple and beef"), null, // 子进度不需要设置背景 AdvancementFrame.CHALLENGE, true, true, false ) .criterion("ate_apple", ConsumeItemCriterion.Conditions.item(Items.APPLE)) .criterion("ate_cooked_beef", ConsumeItemCriterion.Conditions.item(Items.COOKED_BEEF)) .build(consumer, "在这里输入你的mod_id" + "/ate_apple_and_beef"); } }
不要忘记生成数据呀。
./gradlew runDatagenClient
Windows 环境版本:
.\gradlew.bat runDatagenClient
这次我们增加了另一个进度,当你得到一个橡木原木时获得,完成后奖励1000经验值。玩家必须完成两个标准才能获得升级。一个标准是吃苹果,一个标准是吃熟牛肉。这是通过链接(chain linking)一些方法调用来实现的。重要的是,我们还为字段设置了合理的值,比如将触发苹果被吃掉的标准称为“ate_apple”。还要注意,我们保持了 “在这里输入你的mod_id” 部分不变,但是第二个字符串改变了:
.build(consumer, "在这里输入你的mod_id" + "/ate_apple_and_beef"); // .... .build(consumer, "在这里输入你的mod_id" + "/got_wood");
另一个关键的部分是调用父函数(function)并将根进度传递给它。
Advancement gotOakAdvancement = Advancement.Builder.create().parent(rootAdvancement) // .... Advancement eatAppleAdvancement = Advancement.Builder.create().parent(rootAdvancement)
当然,我们还更改了标题和描述,甚至把 ate_apple_and_beef
进度改为挑战类型。但是很明显我们的 mod 中的根进度选的不是很恰当。你会想让它成为你的 mod 中肯定会发生的事情。比如,部分 mod 通过检测玩家物品栏中的自定义书籍(基本上是教程书)来触发根进度,然后在玩家出生时将这本书放入玩家物品栏中。根进度应该比较容易,而子进度则充满挑战。
有许多已经制作好的标准(Criterion)可供选择,可能已经做了你想要的,只要你为你的的自定义 mod 注册了物品和方块,你可以在不使用任何自定义标准(Custom Criterion)的情况下做很多事情。你知道是否需要自定义标准吗?
一般的规则是,如果你的 mod 引入了一些 Minecraft 没有关注的新机制,而你想要基于它进行升级,那么就为它制定一个标准。我们来举个例子,如果你的 mod 在游戏中添加了蹦蹦跳动作,并且你想让玩家在做了100个跳开动作后达成进度,Minecraft 怎么知道这些呢?很明显,它不知道,这就是你需要制定一个自定义标准的原因。
我们的 mod 在跟踪玩家做了多少个蹦蹦跳,我们希望在玩家做完100个蹦蹦跳的时候达成进度。首先,我们要创建 JumpingJacks
类,它将继承(extend) AbstractCriterion<JumpingJacks.Condition>
,代码如下:
import com.google.gson.JsonObject; import net.minecraft.advancement.criterion.AbstractCriterion; import net.minecraft.advancement.criterion.AbstractCriterionConditions; import net.minecraft.predicate.entity.AdvancementEntityPredicateDeserializer; import net.minecraft.predicate.entity.EntityPredicate; import net.minecraft.server.network.ServerPlayerEntity; import net.minecraft.util.Identifier; public class JumpingJacks extends AbstractCriterion<JumpingJacks.Condition> { /** /* 不要忘了修改这个哦: "在这里输入你的mod_id" **/ public static final Identifier ID = new Identifier("在这里输入你的mod_id", "jumping_jacks"); @Override protected Condition conditionsFromJson(JsonObject obj, EntityPredicate.Extended playerPredicate, AdvancementEntityPredicateDeserializer predicateDeserializer) { return new Condition(); } @Override public Identifier getId() { return ID; } public void trigger(ServerPlayerEntity player) { trigger(player, condition -> { return true; }); } public static class Condition extends AbstractCriterionConditions { public Condition() { super(ID, EntityPredicate.Extended.EMPTY); } } }
你可以注意到,在类里面,有另一个名为 Condition
的类实现了 AbstractCriterionConditions
,它现在只调用了超函数(Super Function),其他什么都没有。实际上这个类什么都不做(除了创建一个 ID)。唯一有作用的函数(function)是 trigger
,它调用存在于我们继承(extend)的 AbstractCriterion
类中的 trigger
,并且在不检查任何数据的情况下,总是返回 true。这意味着无论何时触发 JumpingJacks 标准,它都会使玩家达成进度。
现在,我们就用它来创建一个进度。
import net.minecraft.advancement.Advancement; import net.minecraft.advancement.AdvancementFrame; import net.minecraft.item.Items; import net.minecraft.text.Text; import net.minecraft.util.Identifier; import java.util.function.Consumer; public class Advancements implements Consumer<Consumer<Advancement>> { @Override public void accept(Consumer<Advancement> consumer) { Advancement rootAdvancement = Advancement.Builder.create() .display( Items.BLUE_BED, Text.literal("Jumping Jacks"), Text.literal("You jumped Jack 100 times"), new Identifier("textures/gui/advancements/backgrounds/adventure.png"), AdvancementFrame.TASK, true, true, false ) .criterion("jumping_jacks", new JumpingJacks.Condition()) .build(consumer, "在这里输入你的mod_id" + "/root"); } }
因为你已经修改了源代码,所以不要忘记重新生成数据!
./gradlew runDatagenClient
再次附上 Windows 编译环境下的代码
.\gradlew.bat runDatagenClient
在此之前,使用原版提供的标准(criterion),我们在这个阶段就完成了,但因为我们创建了标准,我们需要自己调用触发器函数(trigger function),并注册它。在幕后,以吃(consume)物品为例,Minecraft 就是用这样触发的。你可以在 eat 函数中看到,每当玩家吃东西时,它都会将所吃的物品发送到吃物品标准,以检查是否应该赋予其进度。我们则需要为 mod 做同样的事情。我们负责调用触发器函数。这里有一个例子:
import net.fabricmc.api.ModInitializer; import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; import net.minecraft.advancement.criterion.Criteria; public class AdvancementTutorial implements ModInitializer { /** /* 你必须注册你的自定义标准,如下所示: */ public static JumpingJacks JUMPING_JACKS = Criteria.register(new JumpingJacks()); @Override public void onInitialize() { ServerPlayConnectionEvents.JOIN.register((handler, origin, destination) -> { if (checkedPlayerStateAndHesJumpedOneHundredTimes(handler.player)) { // // 因为我们写类的方式,所以调用触发器函数总是会给玩家进度。 // JUMPING_JACKS.trigger(handler.player); } }); } }
checkedPlayerStateAndHesJumpedOneHundredTimes
函数存在,作用为当玩家已经做了超过100个跳跃时返回true。Criteria.register
否则你的游戏不会赋予进度。(它必须在服务器端完成,这就是为什么我们在 ModInitializer
类中这样做的原因)。
如果你现在运行你的游戏(将 fake 函数改为 true
以便编译),当你登录到一个世界时,你应该会获得蹦蹦跳进度,但因为我们在这里使用的是服务器加入事件,它会在你加载之前给你你这个进度,所以你没有收到 toast 消息。但是你按下 Esc 后可以看到在进度菜单栏里,你已经达成了这个进度。
最后要做的事情是创建一个规则,在创建时接收一些数据,并在调用触发器函数时使用它。(就像消费(consume)物品规则接收一个 物品
,然后仅在特定的 物品
被消费(consume)时触发)。
我们可以想象这样 mod,你可以使用不同的元素法杖,如火,水,冰等等,这些类别中的每一个都有多个品种(2个火焰法杖,4个水系法杖,3个冰霜法杖)。假设我们想要在玩家每次使用特定类别的所有法杖(所有冰霜法杖或所有火焰法杖)时进行获得进度。我们可以利用我们目前所知道的来完成这个任务。我们将简单地制定三种标准(Criterion),火、水和冰,当我们检测到玩家已经使用了该类别中的所有魔杖时,我们就会触发它们。但是我们可以通过在创建条件时传入一点状态来节省亿点点的复制粘贴操作。
import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; import net.minecraft.advancement.criterion.AbstractCriterion; import net.minecraft.advancement.criterion.AbstractCriterionConditions; import net.minecraft.predicate.entity.AdvancementEntityPredicateDeserializer; import net.minecraft.predicate.entity.AdvancementEntityPredicateSerializer; import net.minecraft.predicate.entity.EntityPredicate; import net.minecraft.server.network.ServerPlayerEntity; import net.minecraft.util.Identifier; public class WandCategoryUsed extends AbstractCriterion<WandCategoryUsed.Condition> { public static final Identifier ID = new Identifier("在这里输入你的mod_id", "finished_wand_category"); @Override protected Condition conditionsFromJson(JsonObject obj, EntityPredicate.Extended playerPredicate, AdvancementEntityPredicateDeserializer predicateDeserializer) { JsonElement cravingTarget = obj.get("wandElement"); return new Condition(cravingTarget.getAsString()); } @Override public Identifier getId() { return ID; } public void trigger(ServerPlayerEntity player, String wandElement) { trigger(player, condition -> condition.test(wandElement)); } public static class Condition extends AbstractCriterionConditions { String wandElement; public Condition(String wandElement) { super(ID, EntityPredicate.Extended.EMPTY); this.wandElement = wandElement; } public boolean test(String wandElement) { return this.wandElement.equals(wandElement); } @Override public JsonObject toJson(AdvancementEntityPredicateSerializer predicateSerializer) { JsonObject jsonObject = super.toJson(predicateSerializer); jsonObject.add("wandElement", new JsonPrimitive(wandElement)); return jsonObject; } } }
我们的 Condition
类现在在它的构造函数中接受一个字符串,并保存它以便以后使用。Condition
类也有一个新的函数(function) test
,它接受一个字符串,如果它等于它自己的 wandElment
字符串则返回true。然后在 toJson
函数中,我们将 wandElement
转换为 json 文件,以便将其保存到硬盘。
还有要注意的是,触发器函数(trigger funcion)现在不仅返回 true
,它实际上使用 Condition
类中新建的 test
函数来查看传入的数据是否匹配。在 conditionsFromJson
中,我们将之前保存的 wandElement
json 转换回字符串。
现在,我们就可以像这样写进度:
Advancement.Builder.create() .display(Items.FIRE_WAND_1, Text.literal("使用所有的火焰法杖"), Text.literal("使用所有火焰法杖"), null, AdvancementFrame.TASK, true, true, false ) .parent(parentAdvancement) .criterion("used_all_fire_wands", new WandCategoryUsed.Condition("fire")) .build(consumer, "在这里输入你的mod_id" + "/used_all_fire_wands"); Advancement.Builder.create() .display(Items.ICE_WAND_1, Text.literal("使用所有冰霜法杖"), Text.literal("使用所有冰霜法杖"), null, AdvancementFrame.TASK, true, true, false ) .parent(parentAdvancement) .criterion("used_all_ice_wands", new WandCategoryUsed.Condition("ice")) .build(consumer, "在这里输入你的mod_id" + "/used_all_ice_wands"); Advancement.Builder.create() .display(Items.WATER_WAND_1, Text.literal("使用所有水系法杖"), Text.literal("使用所有水系法杖"), null, AdvancementFrame.TASK, true, true, false ) .parent(parentAdvancement) .criterion("used_all_water_wands", new WandCategoryUsed.Condition("water")) .build(consumer, "在这里输入你的mod_id" + "/used_all_water_wands");
然后,我们将确保注册标准(在逻辑服务器端)。
public static WandCategoryUsed WAND_USED = Criteria.register(new WandCategoryUsed());
当我们检测到玩家使用了特定类别的所有权杖时,我们就可以像这样触发进度:
if (playerUsedAllFireWands) { WAND_USED.trigger(player, "fire"); // 这里的字符串是我们用来初始化条件的。 } if (playerUsedAllWaterWands) { WAND_USED.trigger(player, "water"); // T这里的字符串是我们用来初始化条件的。 } if (playerUsedAllIceWands) { WAND_USED.trigger(player, "ice"); // 这里的字符串是我们用来初始化条件的。 }