配方

配方

配方是在 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。