本篇教程的视频

(待发布)

本篇教程的源代码

(待发布)

本篇教程目标

为方块实体添加自定义的配方类型,不再直接使用硬编码的形式

介绍

还记得当时在配方那期教程中讲的type吗?每个方块实体能识别的配方就是由这个type决定的

那么在上期教程中,我们已经初步完成了一个方块实体,但是它的工作方式是由我们直接硬编码的,它只能识别钻石矿,而且也只能产出矿石

显然,这种做法太不优雅了,如果要用硬编码的形式来写,不仅会产生一堆史山代码,还不利于玩家拓展(或者是整合包修改)

所以,接下来我们就来为方块实体配置自定义的配方类型

配方类

相比较于前面的方块实体,写配方类倒是简单一点了,但仍然有很多地方容易出错,尤其是序列化反序列化阶段

新建类

我们先创建一个OreRigRecipe,继承自Recipe,泛型为SimpleInventory

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class OreRigRecipe implements Recipe<SimpleInventory> {
@Override
public boolean matches(SimpleInventory inventory, World world) {
return false;
}

@Override
public ItemStack craft(SimpleInventory inventory, DynamicRegistryManager registryManager) {
return null;
}

@Override
public boolean fits(int width, int height) {
return false;
}

@Override
public ItemStack getOutput(DynamicRegistryManager registryManager) {
return null;
}

@Override
public Identifier getId() {
return null;
}

@Override
public RecipeSerializer<?> getSerializer() {
return null;
}

@Override
public RecipeType<?> getType() {
return null;
}
}

这里要重写比较多的方法,不过也不是很复杂,我们一个个来看

添加字段

我们先添加一些必要的字段,并创建一个构造函数

1
2
3
4
5
6
7
8
9
private final Identifier id;
private final Ingredient input;
private final ItemStack output;

public OreRigRecipe(Identifier id, Ingredient input, ItemStack output) {
this.id = id;
this.input = input;
this.output = output;
}

这里我们添加了idinputoutput三个字段,分别对应配方类型id输入物品输出物品

那么为什么这里的输入和输出一个是Ingredient,一个是ItemStack呢?实际上这是为了贴合原版,这样我们就可以复用原版的一些序列化反序列化方法了,就不用自己搓了,实际上是差不多的

当然,也可能有人会问,不是说这个案例是无输入单输出吗,咋还有输入呢?这里的输入实际上指的是矿机下方的方块,这个输入的概念和常规的塞个物品进方块实体的输入不太一样

重写 matches

1
2
3
4
5
@Override
public boolean matches(SimpleInventory inventory, World world) {
if (world.isClient()) return false;
return input.test(inventory.getStack(0));
}

这个方法是用来判断配方是否匹配,一般我们会选择用输入的物品来判断,毕竟输入对了输出才能对应

重写 craft

1
2
3
4
@Override
public ItemStack craft(SimpleInventory inventory, DynamicRegistryManager registryManager) {
return output.copy();
}

这个方法会返回配方的输出物品,这里我们返回output.copy()

问题来了,为什么还要额外加一个copy()呢?这是为了避免共享同一个可变的ItemStack实例,防止数据污染,避免一些莫名其妙的bug(有时这些bug还难以复现)

重写 fits

1
2
3
4
@Override
public boolean fits(int width, int height) {
return true;
}

这个方法是判断配方能否放进一个给定尺寸的物品槽网格中(比如工作台3×3的网格),主要用于原生的合成界面和配方书的过滤

不过对于我们这种自定义配方实际上不会有什么作用,返回true是常见的做法(更何况我们这里还是单槽)

重写 getOutput

1
2
3
4
@Override
public ItemStack getOutput(DynamicRegistryManager registryManager) {
return output;
}

这个方法会返回配方的输出物品,这里我们返回output

回去看我们上面的craft方法,可以进一步说明为什么要使用output.copy(),如果两个方法同时返回output,这样会导致引用的是同一个ItemStack实例,有可能就会出问题

重写 getId

1
2
3
4
@Override
public Identifier getId() {
return id;
}

这个很简单,我就不说了

新建内部类 Serializer

在重写getSerializer方法之前,我们需要创建一个Serializer,这个Serializer会负责序列化反序列化这个配方

新建类

这个类要继承RecipeSerializer,泛型为OreRigRecipe

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static class Serializer implements RecipeSerializer<OreRigRecipe> {

@Override
public OreRigRecipe read(Identifier id, JsonObject json) {
return null;
}

@Override
public OreRigRecipe read(Identifier id, PacketByteBuf buf) {
return null;
}

@Override
public void write(PacketByteBuf buf, OreRigRecipe recipe) {

}
}

添加字段

这里我们还需要添加两个字段

1
2
public static final Serializer INSTANCE = new Serializer();
public static final String ID = "ore_rig";

它们之后注册需要使用,然后getSerializer也要返回这个INSTANCE

重写第一个 read

第一个read会从Json中读取配方

1
2
3
4
5
6
@Override
public OreRigRecipe read(Identifier id, JsonObject json) {
Ingredient input = Ingredient.fromJson(json.get("input"));
ItemStack output = ShapedRecipe.outputFromJson(JsonHelper.getObject(json, "output"));
return new OreRigRecipe(id, input, output);
}

这里的读取方法实际上就是原版的方法,通过这些方法去获取json文件中对应字段的输入输出

重写第二个 read

第二个read会从PacketByteBuf中读取配方,看这个参数大家应该知道了,这是客户端读取服务端配方的方法

因为配方属于服务端数据,客户端要使用时,需要服务端将数据包发给客户端,客户端读取之后才能用

1
2
3
4
5
6
@Override
public OreRigRecipe read(Identifier id, PacketByteBuf buf) {
Ingredient input = Ingredient.fromPacket(buf);
ItemStack output = buf.readItemStack();
return new OreRigRecipe(id, input, output);
}

还是读取输入和输出,不过用的是PacketByteBuf的方法

重写 write

这个方法其实就是第二个read的逆向操作,将输入和输出写入PacketByteBuf中,供服务端发给客户端使用

1
2
3
4
5
@Override
public void write(PacketByteBuf buf, OreRigRecipe recipe) {
recipe.input.write(buf);
buf.writeItemStack(recipe.output);
}

这里需要严格注意顺序,写入和读取的顺序必须一致,如果说你的写入顺序是“输入”、“输出”,那么读取顺序一定是“输入”、“输出”,否则就会出问题

如果说你的写入顺序是“输入”、“输出”,而读取顺序写成了是“输出”、“输入”,那么客户端和服务端之间就无法正确通讯,而且这个问题会在多人游戏时出现(当你朋友想加入游戏时,屏幕上会有一堆乱码),单人游戏倒是没这个情况

重写 getSerializer

写完这个序列化器之后,我们就可以重写getSerializer方法了

1
2
3
4
@Override
public RecipeSerializer<?> getSerializer() {
return Serializer.INSTANCE;
}

新建内部类 Type

同样的,在重写getType方法之前,我们需要创建一个Type

这个类要继承RecipeType,泛型为OreRigRecipe

1
2
3
4
public static class Type implements RecipeType<OreRigRecipe>{
public static final Type INSTANCE = new Type();
public static final String ID = "ore_rig";
}

这里就没有什么方法需要重写了,就这两句话

重写 getType

1
2
3
4
@Override
public RecipeType<?> getType() {
return Type.INSTANCE;
}

注册配方

接下来我们就要来注册配方了,首先我们需要新建一个ModRecipes

1
2
3
public class ModRecipes {

}

然后我们再写一个register方法来注册

1
2
3
4
5
6
public static void register() {
Registry.register(Registries.RECIPE_SERIALIZER, new Identifier(TutorialModRe.MOD_ID, OreRigRecipe.Serializer.ID),
OreRigRecipe.Serializer.INSTANCE);
Registry.register(Registries.RECIPE_TYPE, new Identifier(TutorialModRe.MOD_ID, OreRigRecipe.Type.ID),
OreRigRecipe.Type.INSTANCE);
}

注意,需要注册两个东西,一个是Serializer,一个是Type

另外还要到主类中调用这个方法

1
ModRecipes.register();

使用配方

接下来我们要改写之前的方块实体了,让方块实体使用我们的配方,而不再使用硬编码

新建 getMatchRecipe

首先我们来创建一个方法,用来获取匹配的配方

1
2
3
4
5
6
7
8
9
10
private Optional<OreRigRecipe> getMatchRecipe(World world) {
SimpleInventory inv = new SimpleInventory(1);
BlockState belowState = world.getBlockState(this.pos.down());
ItemStack belowStack = belowState.getBlock().asItem().getDefaultStack();
inv.setStack(0, belowStack);

return world.getRecipeManager()
.getFirstMatch(OreRigRecipe.Type.INSTANCE, inv, world)
.map(recipe -> (OreRigRecipe) recipe);
}

这里的主要逻辑是:

  1. 构造一个临时物品槽;
  2. 把方块实体正下方的方块转换成对应的物品堆栈放入该槽(模拟配方的输入物品);
  3. 询问世界的配方管理器(因为配方是由世界统一管理的)是否存在匹配的自定义配方类型;
  4. 如果找到匹配配方,就把它返回;否则返回空,也就是没有匹配的配方。

总的来说还是比较简单的吧

重写 hasCorrectRecipe

接下来我们用上面的方法重写hasCorrectRecipe

1
2
3
4
5
private boolean hasCorrectRecipe(World world) {
return getMatchRecipe(world)
.map(recipe -> canOutputAccept(recipe.getOutput(world.getRegistryManager())))
.orElse(false);
}

之前的canOutputAccept方法只是被我们搬进了配方判断中

如果该配方的产物能被输出槽接受(放得下),那么就返回true,否则返回false

重写 craftItem

1
2
3
4
5
6
7
private void craftItem(World world) {
getMatchRecipe(world).ifPresent(recipe -> {
ItemStack result = recipe.getOutput(world.getRegistryManager());
ItemStack out = outputInv.getStack(0);
outputInv.setStack(0, new ItemStack(result.getItem(), out.getCount() + result.getCount()));
});
}

当配方匹配成功(getMatchRecipe返回的不是empty),就去执行相应的逻辑:

  1. 获取配方的产物;
  2. 获取当前输出槽中的物品堆栈;
  3. 根据配方产物设置输出槽的物品堆栈。

这样,我们的方块实体就可以识别配方了

自定义配方数据生成器

由于我们是自定义的配方类型,原版的数据生成方法不适用于我们的配方,而如果你不想自己手搓json的话,那么我们就来写一个自己的生成器吧

创建 OreRigRecipeBuilder

1
2
3
public class OreRigRecipeBuilder {

}

添加字段

接下来我们添加一些字段

1
2
3
4
5
6
7
8
9
private final ItemConvertible input;
private final ItemConvertible output;
private final int outputCount;

public OreRigRecipeBuilder(ItemConvertible input, ItemConvertible output, int outputCount) {
this.input = input;
this.output = output;
this.outputCount = outputCount;
}

这里的字段很简单,input是输入物品,output是输出物品,outputCount是输出物品的数量

创建 create 方法

1
2
3
4
5
6
7
public static OreRigRecipeBuilder create(ItemConvertible input, ItemConvertible output, int tier) {
return new OreRigRecipeBuilder(input, output, 1);
}

public static OreRigRecipeBuilder create(ItemConvertible input, ItemConvertible output, int outputCount, int tier) {
return new OreRigRecipeBuilder(input, output, outputCount);
}

这里我们创建了两个方法,一个用于创建没有指定输出数量的配方,一个用于创建指定输出数量的配方,供后面的数据生成类调用

创建 offerTo 方法

上面的方法只是创建一个你要什么的配方,这个方法是实际用来写入数据文件的(可参考之前我们写的那些)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public void offerTo(Consumer<RecipeJsonProvider> exporter, Identifier id) {
exporter.accept(new RecipeJsonProvider() {

@Override
public void serialize(JsonObject json) {

}

@Override
public Identifier getRecipeId() {
return null;
}

@Override
public RecipeSerializer<?> getSerializer() {
return null;
}

@Override
public @Nullable JsonObject toAdvancementJson() {
return null;
}

@Override
public @Nullable Identifier getAdvancementId() {
return null;
}
});
}

其中我们再写exporter.accept,里面创建一个RecipeJsonProvider,并重写相应的方法

重写 serialize 方法

1
2
3
4
5
6
7
8
9
10
11
12
@Override
public void serialize(JsonObject json) {
json.addProperty("type", TutorialModRe.MOD_ID + ":ore_rig");
JsonObject inputJson = new JsonObject();
inputJson.addProperty("item", Registries.ITEM.getId(input.asItem()).toString());
json.add("input", inputJson);

JsonObject outputJson = new JsonObject();
outputJson.addProperty("item", Registries.ITEM.getId(output.asItem()).toString());
outputJson.addProperty("count", outputCount);
json.add("output", outputJson);
}

这个方法就是将数据写入json

输入和输出物品都是一个JsonObject,总体上差不多,只是输出物品多了一个count字段

重写 getRecipeId 方法

1
2
3
4
@Override
public Identifier getRecipeId() {
return id;
}

重写 getSerializer 方法

1
2
3
4
@Override
public RecipeSerializer<?> getSerializer() {
return OreRigRecipe.Serializer.INSTANCE;
}

这个方法是返回我们配方本身的序列化器

后面两个方法用于生成和这个配方相关的进度,但因为我尚未研究过,所以就先空着吧

数据生成

接下来就要由我们的数据生成类调用这个生成器了

1
2
3
4
5
6
7
8
OreRigRecipeBuilder.create(Blocks.DIAMOND_ORE, Items.DIAMOND)
.offerTo(exporter, Identifier.of(TutorialModRe.MOD_ID, "diamond_ore_rig"));
OreRigRecipeBuilder.create(Blocks.DEEPSLATE_DIAMOND_ORE, Items.DIAMOND)
.offerTo(exporter, Identifier.of(TutorialModRe.MOD_ID, "deepslate_diamond_ore_rig"));
OreRigRecipeBuilder.create(Blocks.IRON_ORE, Items.RAW_IRON)
.offerTo(exporter, Identifier.of(TutorialModRe.MOD_ID, "iron_ore_rig"));
OreRigRecipeBuilder.create(Blocks.DEEPSLATE_IRON_ORE, Items.RAW_IRON)
.offerTo(exporter, Identifier.of(TutorialModRe.MOD_ID, "deepslate_iron_ore_rig"));

这里我们创建了4个配方,分别对应钻石和铁的矿石

当然,这里的生成器显然还不够高级,大家也可以根据自己的需求,去创建可以传入tag的生成器

之后我们生成的文件在data/tutorialmod/recipes下,一个参考文件如下:

1
2
3
4
5
6
7
8
9
10
{
"type": "tutorial-mod-re:ore_rig",
"input": {
"item": "minecraft:diamond_ore"
},
"output": {
"count": 1,
"item": "minecraft:diamond"
}
}

然后我们就可以进入游戏进行测试了