自定义配方

自定义配方

添加自定义配方至少需要三部分:Recipe、RecipeType 和 RecipeSerializer。根据实现内容,如果无法复用现有子类,还可能需要自定义 RecipeInput、RecipeDisplay、SlotDisplay、RecipeBookCategory 和 RecipePropertySet。

为了演示不同特性,下面实现一种由配方驱动的机制:使用特定物品右键点击世界中的某个 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 已适合许多用途。

配方类

有了输入,接着实现配方本身。它保存配方数据,处理匹配,并返回配方结果,因此通常是自定义配方中最长的类。

// 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 Recipe.CommonInfo commonInfo;
    private final RightClickBlockRecipe.BlockBookInfo bookInfo;
    private final BlockState inputState;
    private final Ingredient inputItem;
    private final ItemStackTemplate result;

    // Add a constructor that sets all properties. 
    public RightClickBlockRecipe(Recipe.CommonInfo commonInfo, RightClickBlockRecipe.BlockBookInfo bookInfo, BlockState inputState, Ingredient inputItem, ItemStackTemplate result) {
        this.commonInfo = commonInfo;
        this.bookInfo = bookInfo;
        this.inputState = inputState;
        this.inputItem = inputItem;
        this.result = result;
    }

    // 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.
    // If we needed to check the dimensions of our input, we would also do so here.
    @Override
    public boolean matches(RightClickBlockInput input, Level level) {
        return this.inputState == input.state() && this.inputItem.test(input.stack());
    }

    // Return the result of the recipe here, based on the given input. The parameter matches the generic.
    // This can be created using `ItemStackTemplate#create`.
    @Override
    public ItemStack assemble(RightClickBlockInput input) {
        return this.result.create();
    }

    // When true, will prevent the recipe from being synced within the recipe book or awarded on use/unlock.
    // This should only be true if the recipe shouldn't appear in a recipe book, such as map extending.
    // Although this recipe takes in an input state, it could still be used in a custom recipe book using
    //   the methods below.
    @Override
    public boolean isSpecial() {
        return true;
    }

    // This example outlines the most important methods. There is a number of other methods to override.
    // Some methods will be explained in the below sections as they cannot be easily compressed and understood here.
    // Check the class definition of Recipe to view them all.
}

通用信息

所有配方都有从 JSON 解析的通用信息,不过实现方式可能不同。原版为所有配方提供了 Recipe.CommonInfo。目前,它允许配方指定解锁时是否显示通知提示。CommonInfo 还提供 map codec 和 stream codec,以便与下文的 RecipeSerializer 整合。

因此,可以使用 CommonInfo 实现 Recipe 的一些方法:

public class RightClickBlockRecipe implements Recipe<RightClickBlockInput> {
    
    private final Recipe.CommonInfo commonInfo;
    // Other fields

    public RightClickBlockRecipe(Recipe.CommonInfo commonInfo, ...) {
        this.commonInfo = commonInfo;
        // Other initializations
    }

    @Override
    public boolean showNotification() {
        return this.commonInfo.showNotification();
    }

    // Other methods
}

提示 不强制使用 CommonInfo record,也不强制在 JSON 中提供 show_notification 字段。模组开发者可以自行决定是否适合使用。

配方书信息

类似通用信息,还有一些从 JSON 解析的字段与配方书有关。配方书是显示在工作台、熔炉等转换菜单中的配方 GUI。原版为这些字段提供 Recipe.BookInfo<CategoryType> 接口,其中 CategoryType 表示配方分类本身(假定它可以序列化),或可转换为分类的中间可序列化对象。与 CommonInfo 一样,它提供 map codec 和 stream codec,以便与下文的 RecipeSerializer 整合。

例如:

public class RightClickBlockRecipe implements Recipe<RightClickBlockInput> {
    
    private final RightClickBlockRecipe.BlockBookInfo bookInfo;
    // Other fields

    public RightClickBlockRecipe(RightClickBlockRecipe.BlockBookInfo bookInfo, ...) {
        this.bookInfo = bookInfo;
        // Other initializations
    }

    @Override
    public String group() {
        return this.bookInfo.group();
    }

    @Override
    public RecipeBookCategory recipeBookCategory() {
        // Convert the serializable entry to its recipe book category.
        return switch (this.bookInfo.category()) {
            case BUILDING -> RecipeBookCategories.CRAFTING_BUILDING_BLOCKS;
            case EQUIPMENT -> RecipeBookCategories.CRAFTING_EQUIPMENT;
            case REDSTONE -> RecipeBookCategories.CRAFTING_REDSTONE;
            case MISC -> RecipeBookCategories.CRAFTING_MISC;
        };
    }

    // Other methods

    public record BlockBookInfo(CraftingBookCategory category, String group) implements Recipe.BookInfo<CraftingBookCategory> {
        public static final MapCodec<BlockBookInfo> MAP_CODEC = Recipe.BookInfo.mapCodec(
            // Takes in the codec for the generic, the default generic value, and the
            // constructor of `(category, group) -> bookInfo`.
            CraftingBookCategory.CODEC, CraftingBookCategory.MISC, BlockBookInfo::new
        );
        public static final StreamCodec<RegistryFriendlyByteBuf, BlockBookInfo> STREAM_CODEC = Recipe.BookInfo.streamCodec(
            // Takes in the stream codec for the generic and the constructor of
            // `(category, group) -> bookInfo`.
            CraftingBookCategory.STREAM_CODEC, BlockBookInfo::new
        );
    }
}

提示 与 CommonInfo 一样,不强制使用 BookInfo,也不强制在 JSON 中公开这些字段。模组开发者应判断它是否适合自己的配方。例如,Recipe#isSpecial 返回 true 的配方不会出现在配方书中,因此不应使用 BookInfo。不过,Recipe#group 和 recipeBookCategory 都必须返回非 null 对象。

配方分组

分组充当键,将多个配方合并为配方书中的一个条目。如果组名为空字符串,该配方就会作为独立条目处理。

public class RightClickBlockRecipe implements Recipe<RightClickBlockInput> {
    // other stuff here

    @Override
    public String group() {
        return this.bookInfo.group();
    }
}

配方书分类

RecipeBookCategory 只是定义此配方在配方书中属于哪个组。例如,铁镐合成配方显示在 RecipeBookCategories#CRAFTING_EQUIPMENT 中,而熟鳕鱼配方显示在 #FURNANCE_FOOD 或 #SMOKER_FOOD 中。每个配方关联一个 RecipeBookCategory。原版分类位于 RecipeBookCategories。

提示 熟鳕鱼有两个配方,分别用于熔炉和烟熏炉。这两个配方属于不同的配方书分类。

如果配方不适合任何现有分类,通常是因为没有使用工作台、熔炉等现有工作站,可以创建新的 RecipeBookCategory。每个 RecipeBookCategory 都必须注册到 BuiltInRegistries#RECIPE_BOOK_CATEGORY:

/// For some DeferredRegister<RecipeBookCategory> RECIPE_BOOK_CATEGORIES
public static final Supplier<RecipeBookCategory> RIGHT_CLICK_BLOCK_CATEGORY = RECIPE_BOOK_CATEGORIES.register(
    "right_click_block", RecipeBookCategory::new
);

然后重写 #recipeBookCategory 设置分类:可以直接返回自己的分类,也可以通过配方书信息(如果已实现)映射到相应分类:

public class RightClickBlockRecipe implements Recipe<RightClickBlockInput> {
    // other stuff here

    @Override
    public RecipeBookCategory recipeBookCategory() {
        return switch (this.bookInfo.category()) {
            case BUILDING -> RecipeBookCategories.CRAFTING_BUILDING_BLOCKS;
            case EQUIPMENT -> RecipeBookCategories.CRAFTING_EQUIPMENT;
            case REDSTONE -> RecipeBookCategories.CRAFTING_REDSTONE;
            case MISC -> RIGHT_CLICK_BLOCK_CATEGORY.get();
        };
    }
}

搜索分类

从类型上看,所有 RecipeBookCategory 都是 ExtendedRecipeBookCategory。此外还有一种名为 SearchRecipeBookCategory 的 ExtendedRecipeBookCategory,用于在配方书中查看所有配方时聚合多个 RecipeBookCategory。

NeoForge 允许通过模组事件总线上的 RegisterRecipeBookSearchCategoriesEvent#register,将自己的 ExtendedRecipeBookCategory 指定为搜索分类。register 接收表示搜索分类的 ExtendedRecipeBookCategory,以及组成该分类的各个 RecipeBookCategory。作为搜索分类的 ExtendedRecipeBookCategory 无需注册到某个原版静态注册表。

// In some location
public static final ExtendedRecipeBookCategory RIGHT_CLICK_BLOCK_SEARCH_CATEGORY = new ExtendedRecipeBookCategory() {};

@SubscribeEvent // on the mod event bus
public static void registerSearchCategories(RegisterRecipeBookSearchCategoriesEvent event) {
    event.register(
        // The search category
        RIGHT_CLICK_BLOCK_SEARCH_CATEGORY,
        // All recipe categories within the search category as varargs
        RecipeBookCategories.CRAFTING_BUILDING_BLOCKS,
        RecipeBookCategories.CRAFTING_EQUIPMENT,
        RecipeBookCategories.CRAFTING_REDSTONE,
        RIGHT_CLICK_BLOCK_CATEGORY.get()
    )
}

摆放信息

PlacementInfo 用于定义配方使用方所需的合成要求,以及是否、如何将原料放入关联工作站(如工作台或熔炉)。PlacementInfo 只面向物品原料;若要使用流体、方块等其他原料,必须自行实现周边逻辑。这时可以将配方标记为不可摆放,使用 PlacementInfo#NOT_PLACEABLE 表达。不过,只要配方至少包含一个类似物品的对象,就应创建 PlacementInfo。

可以通过 create 创建 PlacementInfo,它接收单个原料或原料列表;也可以使用 createFromOptionals,它接收可选原料列表。如果配方中需要表示空槽位,应使用 createFromOptionals,为空槽传入空的 optional:

public class RightClickBlockRecipe implements Recipe<RightClickBlockInput> {
    // other stuff here
    private PlacementInfo info;

    @Override
    public PlacementInfo placementInfo() {
        // This delegate is in case the ingredient is not fully populated at this point in time
        // Tags and recipes are loaded at the same time, which is why this might be the case.
        if (this.info == null) {
            // Use optional ingredient as the block state may have an item representation
            List<Optional<Ingredient>> ingredients = new ArrayList<>();
            Item stateItem = this.inputState.getBlock().asItem();
            ingredients.add(stateItem != Items.AIR ? Optional.of(Ingredient.of(stateItem)): Optional.empty());
            ingredients.add(Optional.of(this.inputItem));

            // Create placement info
            this.info = PlacementInfo.createFromOptionals(ingredients);
        }

        return this.info;
    }
}

槽位显示

SlotDisplay 表示配方书等配方使用方查看配方时,各槽位应渲染什么内容。SlotDisplay 有两个方法。首先是 resolve:它接收包含可用注册表和燃料值的 ContextMap(见 SlotDisplayContext),以及接收本槽位显示内容的当前 DisplayContentsFactory,并返回将内容列表转换成所需输出后的结果。其次是 type,保存编码、解码显示对象所用的 MapCodec 和 StreamCodec。

SlotDisplay 通常在 Ingredient 的 #display,或模组原料的 ICustomIngredient#display 中实现。但某些输入并非原料,这时需要使用已有 SlotDisplay,或创建新的实现。

原版和 NeoForge 提供以下槽位显示类型:

  • SlotDisplay.Empty:表示没有任何内容的槽位。
  • SlotDisplay.ItemSlotDisplay:表示物品的槽位。
  • SlotDisplay.ItemStackSlotDisplay:表示物品堆叠模板的槽位。
  • SlotDisplay.TagSlotDisplay:表示物品标签的槽位。
  • SlotDisplay.OnlyWithComponent:过滤另一显示对象,只保留具有给定数据组件的物品。
  • SlotDisplay.WithAnyPotion:表示具有随机 DataComponents#POTION_CONTENTS 值的输入。
  • SlotDisplay.WithRemainder:表示带合成剩余物的输入。
  • SlotDisplay.AnyFuel:表示所有燃料物品。
  • SlotDisplay.Composite:表示多个其他槽位显示的组合。
  • SlotDisplay.DyedSlotDemo:表示将染料应用到某个目标,并设置 DataComponents#DYED_COLOR。
  • SlotDisplay.SmithingTrimDemoSlotDisplay:表示使用给定材料向基础物品应用随机锻造纹饰。
  • FluidSlotDisplay:表示流体。
  • FluidStackSlotDisplay:表示流体堆叠。
  • FluidTagSlotDisplay:表示流体标签。

本例配方有三个“槽位”:BlockState 输入、Ingredient 输入和 ItemStack 结果。Ingredient 输入已有对应 SlotDisplay;ItemStack 可由 SlotDisplay.ItemStackSlotDisplay 表示。但 BlockState 需要自定义 SlotDisplay 和 DisplayContentsFactory,因为现有实现只接受物品堆叠,而本例以不同方式处理方块状态。

先看 DisplayContentsFactory:它负责将某种类型转换为所需的内容显示类型。可用工厂包括:

  • DisplayContentsFactory.ForStacks:接收 ItemStack 的转换器。
  • DisplayContentsFactory.ForRemainders:接收输入对象及剩余物对象列表的转换器。
  • ForFluidStacks:接收 FluidStack 的转换器。

这样,可以实现 DisplayContentsFactory,将给定对象转换为所需输出。例如,SlotDisplay.ItemStackContentsFactory 使用 ForStacks 转换器,将堆叠转换为 ItemStack。

对于本例的 BlockState,创建一个接收状态的工厂,再提供一个直接输出状态本身的基础实现。

// A basic transformer for block states
public interface ForBlockStates<T> extends DisplayContentsFactory<T> {

    // Delegate methods
    default forState(Holder<Block> block) {
        return this.forState(block.value());
    }

    default forState(Block block) {
        return this.forState(block.defaultBlockState());
    }

    // The block state to take in and transform to the desired output
    T forState(BlockState state);
}

// An implementation for a block state output
public class BlockStateContentsFactory implements ForBlockStates<BlockState> {
    // Singleton instance
    public static final BlockStateContentsFactory INSTANCE = new BlockStateContentsFactory();

    private BlockStateContentsFactory() {}

    @Override
    public BlockState forState(BlockState state) {
        return state;
    }
}

// An implementation for an item stack output
public class BlockStateStackContentsFactory implements ForBlockStates<ItemStack> {
    // Singleton instance
    public static final BlockStateStackContentsFactory INSTANCE = new BlockStateStackContentsFactory();

    private BlockStateStackContentsFactory() {}

    @Override
    public ItemStack forState(BlockState state) {
        return new ItemStack(state.getBlock());
    }
}

有了它,就可以创建新的 SlotDisplay。SlotDisplay.Type 必须注册:

// A simple slot display
public record BlockStateSlotDisplay(BlockState state) implements SlotDisplay {
    public static final MapCodec<BlockStateSlotDisplay> CODEC = BlockState.CODEC.fieldOf("state")
        .xmap(BlockStateSlotDisplay::new, BlockStateSlotDisplay::state);
    public static final StreamCodec<RegistryFriendlyByteBuf, BlockStateSlotDisplay> STREAM_CODEC =
        StreamCodec.composite(
            ByteBufCodecs.idMapper(Block.BLOCK_STATE_REGISTRY), BlockStateSlotDisplay::state,
            BlockStateSlotDisplay::new
        );
    
    @Override
    public <T> Stream<T> resolve(ContextMap context, DisplayContentsFactory<T> factory) {
        return switch (factory) {
            // Check for our contents factory and transform if necessary
            case ForBlockStates<T> states -> Stream.of(states.forState(this.state));
            // If you want the contents to be handled differently depending on contents display
            //   then you can case on other displays like so
            case ForStacks<T> stacks -> Stream.of(stacks.forStack(state.getBlock().asItem()));
            // If no factories match, then do not return anything in the transformed stream
            default -> Stream.empty();
        }
    }

    @Override
    public SlotDisplay.Type<? extends SlotDisplay> type() {
        // Return the registered type from below
        return BLOCK_STATE_SLOT_DISPLAY.get();
    }
}

// In some registrar class
/// For some DeferredRegister<SlotDisplay.Type<?>> SLOT_DISPLAY_TYPES
public static final Supplier<SlotDisplay.Type<BlockStateSlotDisplay>> BLOCK_STATE_SLOT_DISPLAY = SLOT_DISPLAY_TYPES.register(
    "block_state",
    () -> new SlotDisplay.Type<>(BlockStateSlotDisplay.CODEC, BlockStateSlotDisplay.STREAM_CODEC)
);

配方显示

RecipeDisplay 类似 SlotDisplay,不过表示整个配方。默认接口仅记录配方的 result 和表示所用工作台的 craftingStation。RecipeDisplay 也有一个 type,保存编码、解码显示对象所需的 MapCodec 和 StreamCodec。不过,现有 RecipeDisplay 子类型都无法包含在客户端正确渲染本例配方所需的全部信息,因此需要创建自己的 RecipeDisplay。

所有槽位和原料都应表示为 SlotDisplay。网格大小等限制,可以采用开发者自行选择的任意方式提供。

// A simple recipe display
public record RightClickBlockRecipeDisplay(
    SlotDisplay inputState,
    SlotDisplay inputItem,
    SlotDisplay result, // Implements RecipeDisplay#result
    SlotDisplay craftingStation // Implements RecipeDisplay#craftingStation
) implements RecipeDisplay {
    public static final MapCodec<RightClickBlockRecipeDisplay> MAP_CODEC = RecordCodecBuilder.mapCodec(
        instance -> instance.group(
                    SlotDisplay.CODEC.fieldOf("input_state").forGetter(RightClickBlockRecipeDisplay::inputState),
                    SlotDisplay.CODEC.fieldOf("input_item").forGetter(RightClickBlockRecipeDisplay::inputItem),
                    SlotDisplay.CODEC.fieldOf("result").forGetter(RightClickBlockRecipeDisplay::result),
                    SlotDisplay.CODEC.fieldOf("crafting_station").forGetter(RightClickBlockRecipeDisplay::craftingStation)
                )
                .apply(instance, RightClickBlockRecipeDisplay::new)
    );
    public static final StreamCodec<RegistryFriendlyByteBuf, RightClickBlockRecipeDisplay> STREAM_CODEC = StreamCodec.composite(
        SlotDisplay.STREAM_CODEC,
        RightClickBlockRecipeDisplay::inputState,
        SlotDisplay.STREAM_CODEC,
        RightClickBlockRecipeDisplay::inputItem,
        SlotDisplay.STREAM_CODEC,
        RightClickBlockRecipeDisplay::result,
        SlotDisplay.STREAM_CODEC,
        RightClickBlockRecipeDisplay::craftingStation,
        RightClickBlockRecipeDisplay::new
    );

    @Override
    public RecipeDisplay.Type<? extends RecipeDisplay> type() {
        // Return the registered type from below
        return RIGHT_CLICK_BLOCK_RECIPE_DISPLAY.get();
    }
}

// In some registrar class
/// For some DeferredRegister<RecipeDisplay.Type<?>> RECIPE_DISPLAY_TYPES
public static final Supplier<RecipeDisplay.Type<RightClickBlockRecipeDisplay>> RIGHT_CLICK_BLOCK_RECIPE_DISPLAY = RECIPE_DISPLAY_TYPES.register(
    "right_click_block",
    () -> new RecipeDisplay.Type<>(RightClickBlockRecipeDisplay.CODEC, RightClickBlockRecipeDisplay.STREAM_CODEC)
);

然后重写 #display,为配方创建显示对象:

public class RightClickBlockRecipe implements Recipe<RightClickBlockInput> {
    // other stuff here

    @Override
    public List<RecipeDisplay> display() {
        // You can have many different displays for the same recipe
        // But this example will only use one like the other recipes.
        return List.of(
            // Add our recipe display with the specified slots
            new RightClickBlockRecipeDisplay(
                new BlockStateSlotDisplay(this.inputState),
                this.inputItem.display(),
                new SlotDisplay.ItemStackSlotDisplay(this.result),
                new SlotDisplay.ItemSlotDisplay(Items.GRASS_BLOCK)
            )
        )
    }
}

配方类型

接下来是配方类型。这部分很简单,因为除了名称,配方类型没有其他关联数据。它是配方系统中两个需要注册的部分之一。因此与其他注册表一样,创建 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_TYPE =
        RECIPE_TYPES.register(
                "right_click_block",
                // Creates the recipe type, setting `toString` to the registry name of the type
                RecipeType::simple
        );

注册配方类型后,必须在配方中重写 #getType:

public class RightClickBlockRecipe implements Recipe<RightClickBlockInput> {
    // other stuff here

    @Override
    public RecipeType<? extends Recipe<RightClickBlockInput>> getType() {
        return RIGHT_CLICK_BLOCK_TYPE.get();
    }
}

配方序列化器

配方序列化器提供两个 codec:map codec 和 stream codec,分别用于 JSON 与网络的双向序列化。本节不深入介绍 codec 的工作方式,更多信息见 Map Codec 和 Stream Codec。

在配方类内部创建 map codec 和 stream codec。

public static final MapCodec<RightClickBlockRecipe> CODEC = RecordCodecBuilder.mapCodec(inst -> inst.group(
        Recipe.CommonInfo.MAP_CODEC.forGetter(recipe -> recipe.commonInfo),
        RightClickBlockRecipe.BlockBookInfo.MAP_CODEC.forGetter(recipe -> recipe.bookInfo),
        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(
        Recipe.CommonInfo.STREAM_CODEC, recipe -> recipe.commonInfo,
        RightClickBlockRecipe.BlockBookInfo.STREAM_CODEC, recipe -> recipe.bookInfo,
        ByteBufCodecs.idMapper(Block.BLOCK_STATE_REGISTRY), RightClickBlockRecipe::getInputState,
        Ingredient.CONTENTS_STREAM_CODEC, RightClickBlockRecipe::getInputItem,
        ItemStack.STREAM_CODEC, RightClickBlockRecipe::getResult,
        RightClickBlockRecipe::new
);

与类型一样,创建并注册序列化器:

public static final DeferredRegister<RecipeType<?>> 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", ()-> new RecipeSerializer<>(RightClickBlockRecipe.CODEC, RightClickBlockRecipe.STREAM_CODEC));

同样,还必须在配方中重写 #getSerializer:

public class RightClickBlockRecipe implements Recipe<RightClickBlockInput> {
    // other stuff here

    @Override
    public RecipeSerializer<? extends Recipe<RightClickBlockInput>> getSerializer() {
        return RIGHT_CLICK_BLOCK.get();
    }
}

合成机制

配方各部分齐备后,可以创建一些配方 JSON(见数据生成一节),再如上文所示通过配方管理器查询。之后如何使用配方由你决定。常见用途是创建处理配方的机器,并将当前配方保存为字段。

本例希望玩家用物品右键方块时应用配方,因此使用重载监听器和事件处理器。这只是示例实现,可以按需修改,但必须在服务端运行。为了让客户端和服务端的交互状态一致,还需通过网络同步相关输入状态。

可以建立简单的网络实现来同步配方输入:

// A basic packet class, must be registered.
public record ClientboundRightClickBlockRecipesPayload(
    Set<BlockState> inputStates, Set<Holder<Item>> inputItems
) implements CustomPacketPayload {

    // ...
}

// Packet stores data in an instance class.
// Present on both server and client to do initial matching.
public interface RightClickBlockRecipeInputs {

    Set<BlockState> inputStates();
    Set<Holder<Item>> inputItems();

    default boolean test(BlockState state, ItemStack stack) {
        return this.inputStates().contains(state) && this.inputItems().contains(stack.getItemHolder());
    }
}

// Server resource listener so it can be reloaded when recipes are.
public class ServerRightClickBlockRecipeInputs implements ResourceManagerReloadListener, RightClickBlockRecipeInputs {

    public static final Identifier ID = Identifier.fromNamespaceAndPath("examplemod", "block_recipe_inputs");

    private final RecipeManager recipeManager;

    private Set<BlockState> inputStates;
    private Set<Holder<Item>> inputItems;

    public RightClickBlockRecipeInputs(RecipeManager recipeManager) {
        this.recipeManager = recipeManager;
    }

    // Set inputs here as #apply is fired synchronously based on listener registration order.
    // Recipes are always applied first.
    @Override
    public void onResourceManagerReload(ResourceManager manager) {
        MinecraftServer server = ServerLifecycleHooks.getCurrentServer();
        if (server == null) return; // Should never be null

        // Populate inputs
        Set<BlockState> inputStates = new HashSet<>();
        Set<Holder<Item>> inputItems = new HashSet<>();

        this.recipeManager.recipeMap().byType(RIGHT_CLICK_BLOCK_TYPE.get())
            .forEach(holder -> {
                var recipe = holder.value();
                inputStates.add(recipe.getInputState());
                inputItems.addAll(recipe.getInputItem().items());
            });
        
        this.inputStates = Set.copyOf(inputStates);
        this.inputItems = Set.copyOf(inputItems);
    }

    public void syncToClient(Stream<ServerPlayer> players) {
        ClientboundRightClickBlockRecipesPayload payload =
            new ClientboundRightClickBlockRecipesPayload(this.inputStates, this.inputItems);
        players.forEach(player -> PacketDistributor.sendToPlayer(player, payload));
    }

    @Override
    public Set<BlockState> inputStates() {
        return this.inputStates;
    }

    @Override
    public Set<Holder<Item>> inputItems() {
        return this.inputItems;
    }
}

// Client implementation to hold the inputs.
public record ClientRightClickBlockRecipeInputs(
    Set<BlockState> inputStates, Set<Holder<Item>> inputItems
) implements RightClickBlockRecipeInputs {

    public ClientRightClickBlockRecipeInputs(Set<BlockState> inputStates, Set<Holder<Item>> inputItems) {
        this.inputStates = Set.copyOf(inputStates);
        this.inputItems = Set.copyOf(inputItems);
    }
}

// Handling the recipe instance depending on side.
public class ServerRightClickBlockRecipes {

    private static ServerRightClickBlockRecipeInputs inputs;

    public static RightClickBlockRecipeInputs inputs() {
        return ServerRightClickBlockRecipes.inputs;
    }

    @SubscribeEvent // on the game event bus
    public static void addListener(AddServerReloadListenersEvent event) {
        // Register server reload listener
        ServerRightClickBlockRecipes.inputs = new ServerRightClickBlockRecipeInputs(
            event.getServerResources().getRecipeManager()
        );
        event.addListener(ServerRightClickBlockRecipeInputs.ID, ServerRightClickBlockRecipes.inputs);
        // Make sure it runs after recipes
        event.addDependency(VanillaServerListeners.RECIPES, ServerRightClickBlockRecipeInputs.ID);
    }

    @SubscribeEvent // on the game event bus
    public static void datapackSync(OnDatapackSyncEvent event) {
        // Send to client
        ServerRightClickBlockRecipes.inputs.syncToClient(event.getRelevantPlayers());
    }
}

public class ClientRightClickBlockRecipes {

    private static ClientRightClickBlockRecipeInputs inputs;

    public static RightClickBlockRecipeInputs inputs() {
        return ClientRightClickBlockRecipes.inputs;
    }

    // Handling the sent packet
    public static void handle(final ClientboundRightClickBlockRecipesPayload data, final IPayloadContext context) {
        // Do something with the data, on the main thread
        ClientRightClickBlockRecipes.inputs = new ClientRightClickBlockRecipeInputs(
            data.inputStates(), data.inputItems()
        );
    }

    @SubscribeEvent // on the game event bus only on the physical client
    public static void clientLogOut(ClientPlayerNetworkEvent.LoggingOut event) {
        // Clear the stored inputs on world log out
        ClientRightClickBlockRecipes.inputs = null;
    }
}

public class RightClickBlockRecipes {
    // Make proxy method to access properly
    public static RightClickBlockRecipeInputs inputs(Level level) {
        return level.isClientSide()
            ? ClientRightClickBlockRecipes.inputs()
            : ServerRightClickBlockRecipes.inputs();
    }
}

也可以改为向客户端同步完整配方:

// Present on both server and client to do initial matching.
public interface RightClickBlockRecipeInputs {

    Set<BlockState> inputStates();
    Set<Holder<Item>> inputItems();

    default boolean test(BlockState state, ItemStack stack) {
        return this.inputStates().contains(state) && this.inputItems().contains(stack.getItemHolder());
    }
}

// Server resource listener so it can be reloaded when recipes are.
public class ServerRightClickBlockRecipeInputs implements ResourceManagerReloadListener, RightClickBlockRecipeInputs {

    public static final Identifier ID = Identifier.fromNamespaceAndPath("examplemod", "block_recipe_inputs");

    private final RecipeManager recipeManager;

    private Set<BlockState> inputStates;
    private Set<Holder<Item>> inputItems;

    public RightClickBlockRecipeInputs(RecipeManager recipeManager) {
        this.recipeManager = recipeManager;
    }

    // Set inputs here as #apply is fired synchronously based on listener registration order.
    // Recipes are always applied first.
    @Override
    public void onResourceManagerReload(ResourceManager manager) {
        MinecraftServer server = ServerLifecycleHooks.getCurrentServer();
        if (server != null) { // Should never be null
            // Populate inputs
            Set<BlockState> inputStates = new HashSet<>();
            Set<Holder<Item>> inputItems = new HashSet<>();

            this.recipeManager.recipeMap().byType(RIGHT_CLICK_BLOCK_TYPE.get())
                .forEach(holder -> {
                    var recipe = holder.value();
                    inputStates.add(recipe.getInputState());
                    inputItems.addAll(recipe.getInputItem().items());
                });
            
            this.inputStates = Set.copyOf(inputStates);
            this.inputItems = Set.copyOf(inputItems);
        }
    }

    @Override
    public Set<BlockState> inputStates() {
        return this.inputStates;
    }

    @Override
    public Set<Holder<Item>> inputItems() {
        return this.inputItems;
    }
}

// Client implementation to hold the inputs.
public record ClientRightClickBlockRecipeInputs(
    Set<BlockState> inputStates, Set<Holder<Item>> inputItems
) implements RightClickBlockRecipeInputs {

    public ClientRightClickBlockRecipeInputs(Set<BlockState> inputStates, Set<Holder<Item>> inputItems) {
        this.inputStates = Set.copyOf(inputStates);
        this.inputItems = Set.copyOf(inputItems);
    }
}

// Handling the recipe instance depending on side.
public class ServerRightClickBlockRecipes {

    private static ServerRightClickBlockRecipeInputs inputs;

    public static RightClickBlockRecipeInputs inputs() {
        return ServerRightClickBlockRecipes.inputs;
    }

    @SubscribeEvent // on the game event bus
    public static void addListener(AddServerReloadListenersEvent event) {
        // Register server reload listener
        ServerRightClickBlockRecipes.inputs = new ServerRightClickBlockRecipeInputs(
            event.getServerResources().getRecipeManager()
        );
        event.addListener(ServerRightClickBlockRecipeInputs.ID, ServerRightClickBlockRecipes.inputs);
        // Make sure it runs after recipes
        event.addDependency(VanillaServerListeners.RECIPES, ServerRightClickBlockRecipeInputs.ID);
    }

    @SubscribeEvent // on the game event bus
    public static void datapackSync(OnDatapackSyncEvent event) {
        // Specify what recipe types to sync to the client
        event.sendRecipes(RIGHT_CLICK_BLOCK_TYPE.get());
    }
}

public class ClientRightClickBlockRecipes {

    private static ClientRightClickBlockRecipeInputs inputs;

    public static RightClickBlockRecipeInputs inputs() {
        return ClientRightClickBlockRecipes.inputs;
    }

    @SubscribeEvent // on the game event bus only on the physical client
    public static void recipesReceived(RecipesReceivedEvent event) {
        // Store the recipes
        Set<BlockState> inputStates = new HashSet<>();
        Set<Holder<Item>> inputItems = new HashSet<>();

        event.getRecipeMap().byType(RIGHT_CLICK_BLOCK_TYPE.get())
            .forEach(holder -> {
                var recipe = holder.value();
                inputStates.add(recipe.getInputState());
                inputItems.addAll(recipe.getInputItem().items());
            });
        
        ClientRightClickBlockRecipes.inputs = new ClientRightClickBlockRecipeInputs(
            inputStates, inputItems
        );
    }

    @SubscribeEvent // on the game event bus only on the physical client
    public static void clientLogOut(ClientPlayerNetworkEvent.LoggingOut event) {
        // Clear the stored inputs on world log out
        ClientRightClickBlockRecipes.inputs = null;
    }
}

public class RightClickBlockRecipes {
    // Make proxy method to access properly
    public static RightClickBlockRecipeInputs inputs(Level level) {
        return level.isClientSide()
            ? ClientRightClickBlockRecipes.inputs()
            : ServerRightClickBlockRecipes.inputs();
    }
}

然后使用已同步的输入,检查游戏中实际使用的输入:

@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 parameters to check input first
    Level level = event.getLevel();
    BlockPos pos = event.getPos();
    BlockState blockState = level.getBlockState(pos);
    ItemStack itemStack = event.getItemStack();

    // Check if the input can result in a recipe on both sides
    if (!RightClickBlockRecipes.inputs(level).test(blockState, itemStack)) return;

    // If so, make sure on server before checking recipe
    if (!level.isClientSide() && level instanceof ServerLevel serverLevel) {
        // Create an input and query the recipe.
        RightClickBlockInput input = new RightClickBlockInput(blockState, itemStack);
        Optional<RecipeHolder<? extends Recipe<CraftingInput>>> optional = serverLevel.recipeAccess().getRecipeFor(
            // The recipe type.
            RIGHT_CLICK_BLOCK_TYPE.get(),
            input,
            level
        );
        ItemStack result = optional
            .map(RecipeHolder::value)
            .map(e -> e.assemble(input))
            .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);
            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 regardless of side.
    // Already made sure that there could be a result.
    event.cancelWithResult(InteractionResult.SUCCESS_SERVER);
}

数据生成

为自己的配方序列化器创建构建器时,需要实现 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 ItemStackTemplate result;
    protected String group = "";
    protected boolean showNotification = true;

    // Provides a common way to build the recipe unlock advancement.
    // If used, the builder must also specify a `RecipeCategory` to determine
    // the output folder.
    protected final RecipeUnlockAdvancementBuilder advancementBuilder;
    protected final RecipeCategory category;

    // It is common for constructors to accept the result item stack template.
    // Alternatively, static builder methods are also possible.
    public SimpleRecipeBuilder(ItemStackTemplate result, RecipeCategory category) {
        this.result = result;
        this.category = category;
        this.advancementBuilder = new RecipeUnlockAdvancementBuilder();
    }

    // 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 = Objects.requireNonNullElse(group, "");
        return this;
    }

    // This method sets whether to show the notification toast when unlocking. If you want
    // this value to be hardcoded, remove the this.showNotification field and this method.
    public SimpleRecipeBuilder showNotification(boolean showNotification) {
        this.showNotification = showNotification;
        return this;
    }

    // Returns the id of the recipe when using `#save(RecipeOutput)`.
    @Override
    public ResourceKey<Recipe<?>> defaultId() {
        // If the result is not an `ItemStackTemplate`, you will need to manually
        // construct the `ResourceKey` using the result.
        return RecipeBuilder.getDefaultRecipeId(this.result);
    }
}

这样就有了配方构建器的基础。继续实现依赖具体序列化器的部分之前,先考虑使用什么作为配方工厂。本例直接使用构造函数较合适。其他情况下,可以使用静态辅助方法或小型函数式接口;一个构建器服务于多个配方类时,这一点尤其重要。

使用 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(ItemStackTemplate result, RecipeCategory category, BlockState inputState, Ingredient inputItem) {
        super(result, category);
        this.inputState = inputState;
        this.inputItem = inputItem;
    }

    // Saves a recipe using the given RecipeOutput and key. This method is defined in the RecipeBuilder interface.
    @Override
    public void save(RecipeOutput output, ResourceKey<Recipe<?>> key) {
        // Create the recipe.
        RightClickBlockRecipe recipe = new RightClickBlockRecipe(
            RecipeBuilder.createCraftingCommonInfo(this.showNotification),
            new RightClickBlockRecipe.BlockBookInfo(
                RecipeBuilder.determineCraftingBookCategory(this.category),
                this.group
            ),
            this.inputState,
            this.inputItem,
            this.result
        );

        // Pass the id, recipe, and the recipe advancement into the RecipeOutput.
        output.accept(key, recipe, this.advancementBuilder.build(output, key, this.category));
    }
}

现在可以在数据生成时,像其他构建器一样调用它:

@Override
protected void buildRecipes(RecipeOutput output) {
    new RightClickRecipeBuilder(
            // Our constructor parameters. This example adds the ever-popular dirt -> diamond conversion.
            new ItemStackTemplate(Items.DIAMOND),
            RecipeCategory.MISC,
            Blocks.DIRT.defaultBlockState(),
            Ingredient.of(Items.APPLE)
    )
            .unlockedBy("has_apple", this.has(Items.APPLE))
            .save(output);
    // other recipe builders here
}

提示 也可以将 SimpleRecipeBuilder 合并到 RightClickBlockRecipeBuilder(或自己的配方构建器)中,尤其是只有一两个构建器时。这里使用抽象,是为了展示构建器中哪些部分依赖配方、哪些部分不依赖。


来源: NeoForged/Documentation(26.1)。中文翻译依据提交 4a680cdd4d15。