配方
配方是在 Minecraft 世界内将一组对象转换为其他对象的机制。虽然 Minecraft 只用它转换物品,但该系统的设计允许转换任意类型的对象,包括方块、实体等。几乎所有配方都使用配方数据文件;除非明确说明,本文的“配方”均指数据驱动的配方。
配方数据文件位于 data/<namespace>/recipe/<path>.json。例如,配方 minecraft:diamond_block 位于 data/minecraft/recipe/diamond_block.json。
术语
- 配方 JSON 或配方文件:由
RecipeManager加载并存储的 JSON 文件,包含配方类型、输入、输出,以及处理时间等额外信息。 Recipe:保存所有 JSON 字段在代码中的表示,并包含匹配逻辑(“此输入是否匹配配方?”)和其他一些属性。RecipeInput:向配方提供输入的类型,有多个子类,例如CraftingInput或用于熔炉等场景的SingleRecipeInput。- 配方原料,简称原料:配方的单项输入(而
RecipeInput通常表示需要与配方原料进行匹配的一组输入)。原料系统功能很强,因此有专门的文章介绍。 RecipeManager:服务端的单例字段,保存所有已加载配方。RecipeSerializer:本质上是MapCodec和StreamCodec的包装,两者均用于序列化。RecipeType:与Recipe对应的已注册类型,主要用于按类型查找配方。通常,不同合成容器应使用不同RecipeType。例如,minecraft:crafting配方类型涵盖minecraft:crafting_shaped、minecraft:crafting_shapeless及特殊合成序列化器。- 配方进度:负责在配方书中解锁配方的进度。它并非必需,玩家通常更倾向使用配方查看模组,因而往往忽略它。不过,配方数据提供器会为你生成它,建议保留。
RecipeBuilder:在数据生成期间用于创建配方 JSON。- 配方工厂:用于从
RecipeBuilder创建Recipe的方法引用。可以是构造函数引用、静态构建方法,或专门为此创建的函数式接口(通常命名为Factory)。
JSON 规范
配方文件内容随所选类型有很大差异。所有配方文件共同拥有 type 和 neoforge:conditions 属性:
{
// The recipe type. This maps to an entry in the recipe serializer registry.
"type": "minecraft:crafting_shaped",
// A list of data load conditions. Optional, NeoForge-added. See the article linked above for more information.
"neoforge:conditions": [ /*...*/ ]
}
Minecraft 提供的完整类型列表见内置配方类型。模组也可以定义自己的配方类型。
使用配方
配方通过 RecipeManager 类加载、存储和获取。该类可通过 ServerLevel#getRecipeManager 获取;没有 ServerLevel 时,可以使用 ServerLifecycleHooks.getCurrentServer()#getRecipeManager。注意,客户端虽然有一份用于展示的完整 RecipeManager 副本,但配方逻辑始终应在服务端运行,以避免同步问题。
最简单的获取方式是通过 ID:
RecipeManager recipes = serverLevel.getRecipeManager();
// RecipeHolder<?> is a record of the recipe id and the recipe itself.
Optional<RecipeHolder<?>> optional = recipes.byId(ResourceLocation.withDefaultNamespace("diamond_block"));
optional.map(RecipeHolder::value).ifPresent(recipe -> {
// Do whatever you want to do with the recipe here. Be aware that the recipe may be of any type.
});
更实用的方法是构造 RecipeInput,并尝试获取匹配配方。本例使用 CraftingInput#of 创建包含一个钻石块的 CraftingInput。这会创建无序输入;有序输入应使用 CraftingInput#ofPositioned;其他输入则使用其他 RecipeInput(例如熔炉配方通常使用 new SingleRecipeInput)。
RecipeManager recipes = serverLevel.getRecipeManager();
// Construct a RecipeInput, as required by the recipe. For example, construct a CraftingInput for a crafting recipe.
// The parameters are width, height and items, respectively.
CraftingInput input = CraftingInput.of(1, 1, List.of(Items.DIAMOND_BLOCK));
// The generic wildcard on the recipe holder should then extend Recipe<CraftingInput>.
// This allows for more type safety later on.
Optional<RecipeHolder<? extends Recipe<CraftingInput>>> optional = recipes.getRecipeFor(
// The recipe type to get the recipe for. In our case, we use the crafting type.
RecipeTypes.CRAFTING,
// Our recipe input.
input,
// Our level context.
serverLevel
);
// This returns the diamond block -> 9 diamonds recipe (unless a datapack changes that recipe).
optional.map(RecipeHolder::value).ifPresent(recipe -> {
// Do whatever you want here. Note that the recipe is now a Recipe<CraftingInput> instead of a Recipe<?>.
});
也可以获取所有匹配输入的配方列表,该列表可能为空。合理预期会有多个匹配配方时,这尤其有用:
RecipeManager recipes = serverLevel.getRecipeManager();
CraftingInput input = CraftingInput.of(1, 1, List.of(Items.DIAMOND_BLOCK));
// These are not Optionals, and can be used directly. However, the list may be empty, indicating no matching recipes.
List<RecipeHolder<? extends Recipe<CraftingInput>>> list = recipes.getRecipesFor(
// Same parameters as above.
RecipeTypes.CRAFTING, input, serverLevel
);
取得正确配方输入后,还需要获取输出,调用 Recipe#assemble 即可:
RecipeManager recipes = serverLevel.getRecipeManager();
CraftingInput input = CraftingInput.of(...);
Optional<RecipeHolder<? extends Recipe<CraftingInput>>> optional = recipes.getRecipeFor(...);
// Use ItemStack.EMPTY as a fallback.
ItemStack result = optional
.map(RecipeHolder::value)
.map(e -> e.assemble(input, serverLevel.registryAccess()))
.orElse(ItemStack.EMPTY);
必要时也可以遍历某类型的所有配方:
RecipeManager recipes = serverLevel.getRecipeManager();
// Like before, pass the desired recipe type.
List<RecipeHolder<?>> list = recipes.getAllRecipesFor(RecipeTypes.CRAFTING);
其他配方机制
原版中的一些机制通常被视为配方,但代码实现不同。这通常源于历史原因,或因为这些“配方”是根据标签等其他数据构建的。
警告 配方查看模组通常不会自动识别这些配方,需要手动添加支持。详情请参阅对应模组的文档。
铁砧配方
铁砧有两个输入槽和一个输出槽。原版仅用于工具修复、合并和重命名,这些用途都需要特殊处理,因此没有配方文件。不过,可以通过 AnvilUpdateEvent 扩展该系统。此事件允许获取输入(左侧输入槽)和材料(右侧输入槽),并设置输出物品堆叠、经验消耗和材料消耗数量。也可以通过取消事件阻止整个过程。
// This example allows repairing a stone pickaxe with a full stack of dirt, consuming half the stack, for 3 levels.
@SubscribeEvent // on the game event bus
public static void onAnvilUpdate(AnvilUpdateEvent event) {
ItemStack left = event.getLeft();
ItemStack right = event.getRight();
if (left.is(Items.STONE_PICKAXE) && right.is(Items.DIRT) && right.getCount() >= 64) {
event.setOutput(Items.STONE_PICKAXE);
event.setMaterialCost(32);
event.setCost(3);
}
}
酿造
自定义配方
添加自定义配方至少需要三部分:Recipe、RecipeType 和 RecipeSerializer。根据实现内容,如果无法复用现有子类,还可能需要自定义 RecipeInput。
为了演示不同特性,下面实现一种由配方驱动的机制:使用特定物品右键点击世界中的某个 BlockState,破坏该方块状态,并掉落结果物品。
配方输入
先定义要向配方提供什么。配方输入表示玩家此刻实际使用的输入,这一点很重要。因此这里不用标签或原料,而是使用实际存在的物品堆叠和方块状态。
// Our inputs are a BlockState and an ItemStack.
public record RightClickBlockInput(BlockState state, ItemStack stack) implements RecipeInput {
// Method to get an item from a specific slot. We have one stack and no concept of slots, so we just assume
// that slot 0 holds our item, and throw on any other slot. (Taken from SingleRecipeInput#getItem.)
@Override
public ItemStack getItem(int slot) {
if (slot != 0) throw new IllegalArgumentException("No item for index " + slot);
return this.stack();
}
// The slot size our input requires. Again, we don't really have a concept of slots, so we just return 1
// because we have one item stack involved. Inputs with multiple items should return the actual count here.
@Override
public int size() {
return 1;
}
}
配方输入按需创建,无需注册或以任何方式序列化。也不总是需要自定义输入,原版的 CraftingInput、SingleRecipeInput 和 SmithingRecipeInput 已适合许多用途。
此外,NeoForge 提供 RecipeWrapper 输入,它将 #getItem 和 #size 调用委托给构造函数传入的 IItemHandler。这意味着箱子等任何基于网格的物品栏,都可以用 RecipeWrapper 包装后作为配方输入。
配方类
有了输入,接着实现配方本身。它保存配方数据,处理匹配,并返回配方结果,因此通常是自定义配方中最长的类。
// The generic parameter for Recipe<T> is our RightClickBlockInput from above.
public class RightClickBlockRecipe implements Recipe<RightClickBlockInput> {
// An in-code representation of our recipe data. This can be basically anything you want.
// Common things to have here is a processing time integer of some kind, or an experience reward.
// Note that we now use an ingredient instead of an item stack for the input.
private final BlockState inputState;
private final Ingredient inputItem;
private final ItemStack result;
// Add a constructor that sets all properties.
public RightClickBlockRecipe(BlockState inputState, Ingredient inputItem, ItemStack result) {
this.inputState = inputState;
this.inputItem = inputItem;
this.result = result;
}
// A list of our ingredients. Does not need to be overridden if you have no ingredients
// (the default implementation returns an empty list here). It makes sense to cache larger lists in a field.
@Override
public NonNullList<Ingredient> getIngredients() {
NonNullList<Ingredient> list = NonNullList.create();
list.add(this.inputItem);
return list;
}
// Grid-based recipes should return whether their recipe can fit in the given dimensions.
// We don't have a grid, so we just return if any item can be placed in there.
@Override
public boolean canCraftInDimensions(int width, int height) {
return width * height >= 1;
}
// Check whether the given input matches this recipe. The first parameter matches the generic.
// We check our blockstate and our item stack, and only return true if both match.
@Override
public boolean matches(RightClickBlockInput input, Level level) {
return this.inputState == input.state() && this.inputItem.test(input.stack());
}
// Return an UNMODIFIABLE version of your result here. The result of this method is mainly intended
// for the recipe book, and commonly used by JEI and other recipe viewers as well.
@Override
public ItemStack getResultItem(HolderLookup.Provider registries) {
return this.result;
}
// Return the result of the recipe here, based on the given input. The first parameter matches the generic.
// IMPORTANT: Always call .copy() if you use an existing result! If you don't, things can and will break,
// as the result exists once per recipe, but the assembled stack is created each time the recipe is crafted.
@Override
public ItemStack assemble(RightClickBlockInput input, HolderLookup.Provider registries) {
return this.result.copy();
}
// This example outlines the most important methods. There is a number of other methods to override.
// Check the class definition of Recipe to view them all.
}
配方类型
接下来是配方类型。这部分很简单,因为除了名称,配方类型没有其他关联数据。它是配方系统中两个需要注册的部分之一。因此与其他注册表一样,创建 DeferredRegister 并注册:
public static final DeferredRegister<RecipeType<?>> RECIPE_TYPES =
DeferredRegister.create(Registries.RECIPE_TYPE, ExampleMod.MOD_ID);
public static final Supplier<RecipeType<RightClickBlockRecipe>> RIGHT_CLICK_BLOCK =
RECIPE_TYPES.register(
"right_click_block",
// We need the qualifying generic here due to generics being generics.
() -> RecipeType.<RightClickBlockRecipe>simple(ResourceLocation.fromNamespaceAndPath(ExampleMod.MOD_ID, "right_click_block"))
);
注册配方类型后,必须在配方中重写 #getType:
public class RightClickBlockRecipe implements Recipe<RightClickBlockInput> {
// other stuff here
@Override
public RecipeType<?> getType() {
return RIGHT_CLICK_BLOCK.get();
}
}
配方序列化器
配方序列化器提供两个 codec:map codec 和 stream codec,分别用于 JSON 与网络的双向序列化。本节不深入介绍 codec 的工作方式,更多信息见 Map Codec 和 Stream Codec。
配方序列化器可能很大,因此原版将它们移到独立类中。建议遵循此惯例,但并非强制;较小的序列化器经常以匿名类形式定义在配方类的字段中。这里遵循这一做法,创建独立的类保存 codec:
// The generic parameter is our recipe class.
// Note: This assumes that simple RightClickBlockRecipe#getInputState, #getInputItem and #getResult getters
// are available, which were omitted from the code above.
public class RightClickBlockRecipeSerializer implements RecipeSerializer<RightClickBlockRecipe> {
public static final MapCodec<RightClickBlockRecipe> CODEC = RecordCodecBuilder.mapCodec(inst -> inst.group(
BlockState.CODEC.fieldOf("state").forGetter(RightClickBlockRecipe::getInputState),
Ingredient.CODEC.fieldOf("ingredient").forGetter(RightClickBlockRecipe::getInputItem),
ItemStack.CODEC.fieldOf("result").forGetter(RightClickBlockRecipe::getResult)
).apply(inst, RightClickBlockRecipe::new));
public static final StreamCodec<RegistryFriendlyByteBuf, RightClickBlockRecipe> STREAM_CODEC =
StreamCodec.composite(
ByteBufCodecs.idMapper(Block.BLOCK_STATE_REGISTRY), RightClickBlockRecipe::getInputState,
Ingredient.CONTENTS_STREAM_CODEC, RightClickBlockRecipe::getInputItem,
ItemStack.STREAM_CODEC, RightClickBlockRecipe::getResult,
RightClickBlockRecipe::new
);
// Return our map codec.
@Override
public MapCodec<RightClickBlockRecipe> codec() {
return CODEC;
}
// Return our stream codec.
@Override
public StreamCodec<RegistryFriendlyByteBuf, RightClickBlockRecipe> streamCodec() {
return STREAM_CODEC;
}
}
与类型一样,注册序列化器:
public static final DeferredRegister<RecipeSerializer<?>> RECIPE_SERIALIZERS =
DeferredRegister.create(Registries.RECIPE_SERIALIZER, ExampleMod.MOD_ID);
public static final Supplier<RecipeSerializer<RightClickBlockRecipe>> RIGHT_CLICK_BLOCK =
RECIPE_SERIALIZERS.register("right_click_block", RightClickBlockRecipeSerializer::new);
同样,还必须在配方中重写 #getSerializer:
public class RightClickBlockRecipe implements Recipe<RightClickBlockInput> {
// other stuff here
@Override
public RecipeSerializer<?> getSerializer() {
return RIGHT_CLICK_BLOCK.get();
}
}
合成机制
配方各部分齐备后,可以创建一些配方 JSON(见数据生成一节),再如上文所示通过配方管理器查询。之后如何使用配方由你决定。常见用途是创建处理配方的机器,并将当前配方保存为字段。
本例中,我们希望玩家用物品右键方块时应用配方,因此使用事件处理器。这只是示例实现,可以按需修改,但必须在服务端执行。
@SubscribeEvent // on the game event bus
public static void useItemOnBlock(UseItemOnBlockEvent event) {
// Skip if we are not in the block-dictated phase of the event. See the event's javadocs for details.
if (event.getUsePhase() != UseItemOnBlockEvent.UsePhase.BLOCK) return;
// Get the parameters we need.
UseOnContext context = event.getUseOnContext();
Level level = context.getLevel();
BlockPos pos = context.getClickedPos();
BlockState blockState = level.getBlockState(pos);
ItemStack itemStack = context.getItemInHand();
RecipeManager recipes = level.getRecipeManager();
// Create an input and query the recipe.
RightClickBlockInput input = new RightClickBlockInput(blockState, itemStack);
Optional<RecipeHolder<? extends Recipe<CraftingInput>>> optional = recipes.getRecipeFor(
// The recipe type.
RIGHT_CLICK_BLOCK,
input,
level
);
ItemStack result = optional
.map(RecipeHolder::value)
.map(e -> e.assemble(input, level.registryAccess()))
.orElse(ItemStack.EMPTY);
// If there is a result, break the block and drop the result in the world.
if (!result.isEmpty()) {
level.removeBlock(pos, false);
// If the level is not a server level, don't spawn the entity.
if (!level.isClientSide()) {
ItemEntity entity = new ItemEntity(level,
// Center of pos.
pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5,
result);
level.addFreshEntity(entity);
}
// Cancel the event to stop the interaction pipeline.
event.cancelWithResult(ItemInteractionResult.sidedSuccess(level.isClientSide));
}
}
扩大合成网格
ShapedRecipePattern 类负责保存有序合成配方的内存表示,硬编码了 3×3 槽位的上限。这妨碍了希望添加更大工作台、同时复用原版有序配方类型的模组。为解决此问题,NeoForge 添加了静态方法 ShapedRecipePattern#setCraftingSize(int width, int height),允许提高上限。应在 FMLCommonSetupEvent 期间调用。各维度采用最大的值;例如,一个模组添加 4×6 工作台,另一个添加 6×5 工作台,最终上限为 6×6。
重要警告
ShapedRecipePattern#setCraftingSize并非线程安全,必须包装在event#enqueueWork调用中。
数据生成
与多数其他 JSON 文件一样,配方可以通过数据生成创建。继承 RecipeProvider 并重写 #buildRecipes:
public class MyRecipeProvider extends RecipeProvider {
// Get the parameters from GatherDataEvent.
public MyRecipeProvider(PackOutput output, CompletableFuture<HolderLookup.Provider> lookupProvider) {
super(output, lookupProvider);
}
@Override
protected void buildRecipes(RecipeOutput output) {
// Add your recipes here.
}
}
需要注意 #buildRecipes 的 RecipeOutput 参数。Minecraft 使用这个对象自动为你生成配方进度。此外,NeoForge 向 RecipeOutput 注入了条件支持,可通过 #withConditions 使用。
配方本身通常通过 RecipeBuilder 的子类添加。列出全部原版构建器超出本文范围(它们在内置配方类型一文中介绍),下文则会说明如何创建自己的构建器。
与其他数据提供器一样,必须在 GatherDataEvent 中注册配方提供器:
@SubscribeEvent // on the mod event bus
public static void gatherData(GatherDataEvent event) {
DataGenerator generator = event.getGenerator();
PackOutput output = generator.getPackOutput();
CompletableFuture<HolderLookup.Provider> lookupProvider = event.getLookupProvider();
// other providers here
generator.addProvider(
event.includeServer(),
new MyRecipeProvider(output, lookupProvider)
);
}
配方提供器还提供常见场景的辅助方法,例如 twoByTwoPacker(2×2 方块配方)、threeByThreePacker(3×3 方块配方)或 nineBlockStorageRecipes(3×3 方块配方和将一个方块拆成九个物品的配方)。
自定义配方的数据生成
为自己的配方序列化器创建构建器时,需要实现 RecipeBuilder 及其方法。以下是一种部分参考原版代码的常见实现:
// This class is abstract because there is a lot of per-recipe-serializer logic.
// It serves the purpose of showing the common part of all (vanilla) recipe builders.
public abstract class SimpleRecipeBuilder implements RecipeBuilder {
// Make the fields protected so our subclasses can use them.
protected final ItemStack result;
protected final Map<String, Criterion<?>> criteria = new LinkedHashMap<>();
@Nullable
protected String group;
// It is common for constructors to accept the result item stack.
// Alternatively, static builder methods are also possible.
public SimpleRecipeBuilder(ItemStack result) {
this.result = result;
}
// This method adds a criterion for the recipe advancement.
@Override
public SimpleRecipeBuilder unlockedBy(String name, Criterion<?> criterion) {
this.criteria.put(name, criterion);
return this;
}
// This method adds a recipe book group. If you do not want to use recipe book groups,
// remove the this.group field and make this method no-op (i.e. return this).
@Override
public SimpleRecipeBuilder group(@Nullable String group) {
this.group = group;
return this;
}
// Vanilla wants an Item here, not an ItemStack. You still can and should use the ItemStack
// for serializing the recipes.
@Override
public Item getResult() {
return this.result.getItem();
}
}
这样就有了配方构建器的基础。继续实现依赖具体序列化器的部分之前,先考虑使用什么作为配方工厂。本例直接使用构造函数较合适。其他情况下,可以使用静态辅助方法或小型函数式接口;一个构建器服务于多个配方类时,这一点尤其重要。
使用 RightClickBlockRecipe::new 作为配方工厂,并复用上面的 SimpleRecipeBuilder 类,可以为 RightClickBlockRecipe 创建以下构建器:
public class RightClickBlockRecipeBuilder extends SimpleRecipeBuilder {
private final BlockState inputState;
private final Ingredient inputItem;
// Since we have exactly one of each input, we pass them to the constructor.
// Builders for recipe serializers that have ingredient lists of some sort would usually
// initialize an empty list and have #addIngredient or similar methods instead.
public RightClickBlockRecipeBuilder(ItemStack result, BlockState inputState, Ingredient inputItem) {
super(result);
this.inputState = inputState;
this.inputItem = inputItem;
}
// Saves a recipe using the given RecipeOutput and id. This method is defined in the RecipeBuilder interface.
@Override
public void save(RecipeOutput output, ResourceLocation id) {
// Build the advancement.
Advancement.Builder advancement = output.advancement()
.addCriterion("has_the_recipe", RecipeUnlockedTrigger.unlocked(id))
.rewards(AdvancementRewards.Builder.recipe(id))
.requirements(AdvancementRequirements.Strategy.OR);
this.criteria.forEach(advancement::addCriterion);
// Our factory parameters are the result, the block state, and the ingredient.
RightClickBlockRecipe recipe = new RightClickBlockRecipe(this.inputState, this.inputItem, this.result);
// Pass the id, the recipe, and the recipe advancement into the RecipeOutput.
output.accept(id, recipe, advancement.build(id.withPrefix("recipes/")));
}
}
现在可以在数据生成时像其他构建器一样调用它:
@Override
protected void buildRecipes(RecipeOutput output) {
new RightClickRecipeBuilder(
// Our constructor parameters. This example adds the ever-popular dirt -> diamond conversion.
new ItemStack(Items.DIAMOND),
Blocks.DIRT.defaultBlockState(),
Ingredient.of(Items.APPLE)
)
.unlockedBy("has_apple", has(Items.APPLE))
.save(output);
// other recipe builders here
}
提示 也可以将
SimpleRecipeBuilder合并到RightClickBlockRecipeBuilder(或自己的配方构建器)中,尤其是只有一两个构建器时。这里使用抽象,是为了展示构建器中哪些部分依赖配方、哪些部分不依赖。
来源: NeoForged/Documentation(1.21.1)。中文翻译依据提交
4a680cdd4d15。