自定义合成配方(Custom Recipes)
要添加自定义合成配方(recipe),我们至少需要三个部分:一个 Recipe、一个 RecipeType,以及一个 RecipeSerializer。根据你的具体实现需求,如果无法复用已有的子类,你还可能需要自定义 RecipeInput、RecipeDisplay、SlotDisplay、RecipeBookCategory 和 RecipePropertySet。
为了举例说明,并展示多种不同的特性,我们将实现一种基于配方的机制:玩家需要手持特定物品右键点击一个 BlockState,该 BlockState 会被破坏,并掉落结果物品。
配方输入(The Recipe Input)
我们先来定义配方需要的输入内容。需要注意的是,配方输入(recipe input)指的是玩家当 前实际使用的输入。因此,这里不使用标签(tag)或原料(ingredient),而是直接使用我们手头的物品堆(item stack)和方块状态(blockstate)。
// 我们的输入包括一个 BlockState 和一个 ItemStack。
public record RightClickBlockInput(BlockState state, ItemStack stack) implements RecipeInput {
// 获取指定槽位上的物品的方法。由于我们只有一个物品堆,也没有槽位的概念,因此假设
// 槽位 0 保存我们的物品,其他槽位直接抛出异常。(代码参考自 SingleRecipeInput#getItem。)
@Override
public ItemStack getItem(int slot) {
if (slot != 0) throw new IllegalArgumentException("No item for index " + slot);
return this.stack();
}
// 输入所需的槽位数量。同样地,我们没有槽位的概念,只涉及一个物品堆,所以返回 1。
// 如果输入有多个物品,这里应返回实际数量。
@Override
public int size() {
return 1;
}
}
配方输入(recipe input)无需注册或序列化,因为它们是在需要时动态创建的。通常不必自己实现,许多场景下可以直接使用原版的实现(CraftingInput、SingleRecipeInput 和 SmithingRecipeInput)。
此外,NeoForge 提供了 RecipeWrapper 输入类型,它会根据构造函数传入的 IItemHandler,自动包装 #getItem 和 #size 方法。简单来说,任何基于网格的物品栏(如箱子)都可以通过包装为 RecipeWrapper,作为配方输入使用。
配方类(The Recipe Class)
现在我们已经有了输入,接下来实现配方本身。配方类用于保存配方数据,并负责匹配输入和返回配方结果。通常,这是自定义配方中代码量最多的部分。
// Recipe<T> 的泛型参数是我们上文定义的 RightClickBlockInput。
public class RightClickBlockRecipe implements Recipe<RightClickBlockInput> {
// 用于在代码中表示配方(recipe)数据的对象。这里可以根据需要定义为任何类型。
// 常见的做法是添加一个处理时间(processing time)整数,或者经验奖励(experience reward)。
// 注意,这里我们使用了 Ingredient 作为输入,而不是 ItemStack。
private final BlockState inputState;
private final Ingredient inputItem;
private final ItemStack result;
// 添加一个构造函数,用于设置所有属性。
public RightClickBlockRecipe(BlockState inputState, Ingredient inputItem, ItemStack result) {
this.inputState = inputState;
this.inputItem = inputItem;
this.result = result;
}
// 检查给定的输入是否匹配此配方。第一个参数与泛型类型一致。
// 我们会检查方块状态(blockstate)和物品堆(item stack),只有两者都匹配时才返回 true。
// 如果需要检查输入的维度(dimensions),也应在这里处理。
@Override
public boolean matches(RightClickBlockInput input, Level level) {
return this.inputState == input.state() && this.inputItem.test(input.stack());
}
// 根据给定的输入,返回该配方的输出结果。第一个参数与泛型类型一致。
// 重要提示:如果使用已有的结果,一定要调用 .copy()!否则会出现问题,
// 因为每个配方只存在一个结果对象,但每次合成时都需要创建新的物品堆(stack)。
@Override
public ItemStack assemble(RightClickBlockInput input, HolderLookup.Provider registries) {
return this.result.copy();
}
// 返回 true 时,将阻止该配方在配方书(recipe book)中同步或在使用/解锁时被授予。
// 只有当该配方不应出现在配方书中时(例如地图扩展),才应返回 true。
// 虽然该配方需要输入方块状态(input state),但仍可通过下 方的方法在自定义配方书中使用。
@Override
public boolean isSpecial() {
return true;
}
// 此示例展示了最重要的方法。还有许多其他方法可以重写(override)。
// 部分方法会在下文章节详细说明,因为它们难以在这里简要解释清楚。
// 建议查看 Recipe 的类定义以了解所有可用方法。
}
配方书分类(Recipe Book Categories)
RecipeBookCategory(配方书分类)用于定义在配方书中显示该配方的分组。例如,铁镐的合成配方会显示在 RecipeBookCategories#CRAFTING_EQUIPMENT,而熟鳕鱼的配方则显示在 #FURNANCE_FOOD 或 #SMOKER_FOOD。每个配方都关联有一个 RecipeBookCategory。原版分 类可以在 RecipeBookCategories 中找到。
有两种烹饪鳕鱼的配方(recipe),一种用于熔炉(furnace),另一种用于烟熏炉(smoker)。熔炉和烟熏炉的配方分别属于不同的配方书分类(book categories)。
如果你的配方(recipe)不适用于现有的某个分类,通常是因为该配方没有使用现有的合成站(crafting station)(例如工作台(crafting table)、熔炉(furnace)),那么你可以创建一个新的 RecipeBookCategory。每个 RecipeBookCategory 都必须注册到 BuiltInRegistries#RECIPE_BOOK_CATEGORY:
/// 对于某个 DeferredRegister<RecipeBookCategory> RECIPE_BOOK_CATEGORIES
public static final Supplier<RecipeBookCategory> RIGHT_CLICK_BLOCK_CATEGORY = RECIPE_BOOK_CATEGORIES.register(
"right_click_block", RecipeBookCategory::new
);
然后,要设置分类,我们需要像下面这样重写(override) #recipeBookCategory 方法:
public class RightClickBlockRecipe implements Recipe<RightClickBlockInput> {
// 这里还有其他内容
@Override
public RecipeBookCategory recipeBookCategory() {
return RIGHT_CLICK_BLOCK_CATEGORY.get();
}
}
搜索分类(Search Categories)
所有的 RecipeBookCategory 实际上都是 ExtendedRecipeBookCategory。还有一种名为 SearchRecipeBookCategory 的 ExtendedRecipeBookCategory,用于在配方书中查看所有配方时聚合多个 RecipeBookCategory。
NeoForge 允许用户通过在模组事件总线(mod event bus)上的 RegisterRecipeBookSearchCategoriesEvent#register 方法,指定自定义的 ExtendedRecipeBookCategory 作为搜索分类(search category)。register 方法接收一个代表搜索分类的 ExtendedRecipeBookCategory,以及构成该搜索分类的所有 RecipeBookCategory。这个 ExtendedRecipeBookCategory 搜索分类不需要注册到某个静态的原版注册表(vanilla registry)。
// 在某个位置
public static final ExtendedRecipeBookCategory RIGHT_CLICK_BLOCK_SEARCH_CATEGORY = new ExtendedRecipeBookCategory() {};
// 在模组事件总线(mod event bus)上
@SubscribeEvent
public static void registerSearchCategories(RegisterRecipeBookSearchCategoriesEvent event) {
event.register(
// 搜索分类
RIGHT_CLICK_BLOCK_SEARCH_CATEGORY,
// 该搜索分类下包含的所有配方分类,以可变参数形式传入
RIGHT_CLICK_BLOCK_CATEGORY.get()
)
}
放置信息(Placement Info)
PlacementInfo 用于定义配方使用者(recipe consumer)所需的合成条件,以及该配方是否/如何能被放入其关联的合成站(crafting station)(例如工作台(crafting table)、熔炉(furnace))。PlacementInfo 只适用于物品型(item)配料(ingredient),如果你需要其他类型的配料(例如流体(fluid)、方块(block)),则需要自行实现相关逻辑。在这种情况下,可以将配方标记为不可放置(not placeable),并通过 PlacementInfo#NOT_PLACEABLE 表明这一点。不过,如果你的配方中至少包含一个类似物品的对象,建议你创建一个 PlacementInfo。
可以通过 create 方法创建一个 PlacementInfo,该方法接受一个或多个配料(ingredient),也可以通过 createFromOptionals 方法创建,后者接受一个可选配料(optional ingredient)列表。如果你的合成配方中包含了空槽(empty slot)的表示,那么应当使用 createFromOptionals,为每个空槽传递一个空的 optional:
public class RightClickBlockRecipe implements Recipe<RightClickBlockInput> {
// 这里省略了其他内容
private PlacementInfo info;
@Override
public PlacementInfo placementInfo() {
// 这个代理是为了防止此时配料尚未完全初始化
// 标签(tags)和配方(recipes)是同时加载的,这就是可能出现这种情况的原因。
if (this.info == null) {
// 使用可选配料,因为方块状态(block state)可能有物品(item)表示
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));
// 创建放置信息(placement info)
this.info = PlacementInfo.createFromOptionals(ingredients);
}
return this.info;
}
}
槽位显示(Slot Displays)
SlotDisplay 表示在合成配方消费者(例如配方书)查看 时,哪个槽位应该渲染什么内容的信息。一个 SlotDisplay 有两个方法。首先是 resolve,它接受一个包含可用注册表(registries)和燃料值(fuel values)的 ContextMap(如 SlotDisplayContext 所示);以及当前的 DisplayContentsFactory,该工厂用于接收要在此槽位显示的内容;并返回经过转换后的内容列表作为输出。其次是 type,它持有用于编码和解码显示内容的 MapCodec 和 StreamCodec。
SlotDisplay 通常由 Ingredient 通过 #display 方法,或者模组配料(modded ingredient)通过 ICustomIngredient#display 方法 实现;然而,在某些情况下,输入值可能不是配料(ingredient),这时就需要使用已有的 SlotDisplay,或者新建一个。
以下是 Vanilla 和 NeoForge 提供的可用槽位显示类型:
SlotDisplay.Empty:一个表示空内容的槽位。SlotDisplay.ItemSlotDisplay:一个表示物品的槽位。SlotDisplay.ItemStackSlotDisplay:一个表示物品堆的槽位。SlotDisplay.TagSlotDisplay:一个表示物品标签(tag)的槽位。SlotDisplay.WithRemainder:一个表示有合成剩余物的输入槽位。SlotDisplay.AnyFuel:一个表示所有燃料物品的槽位。SlotDisplay.Composite:一个表示其他槽位显示组合的槽位。SlotDisplay.SmithingTrimDemoSlotDisplay:一个表示在某个基底上应用给定材料的随机锻造装饰(smithing trim)的槽位。FluidSlotDisplay:一个表示流体的槽位。FluidStackSlotDisplay:一个表示流体堆的槽位。FluidTagSlotDisplay:一个表示流体标签(tag)的槽位。
我们的配方(recipe)中有三个“槽位(slot)”:BlockState 输入、Ingredient 输入,以及 ItemStack 输出。Ingredient 输入已经有了对应的 SlotDisplay,而 ItemStack 可以用 SlotDisplay.ItemStackSlotDisplay 来表示。至于 BlockState,则需要自定义一个 SlotDisplay 和 DisplayContentsFactory,因为现有的槽位显示只接受物品堆(item stacks),而本例中我们需要以不同方式处理方块状态(block states)。
首先来看 DisplayContentsFactory,它的作用是将某种类型转换为目标内容显示类型。可用的工厂(factory)有:
DisplayContentsFactory.ForStacks:一个接收ItemStack的转换器。DisplayContentsFactory.ForRemainders:一个接收输入对象和剩余对象列表的转换器。DisplayContentsFactory.ForFluidStacks:一个接收FluidStack的转换器。
通过这些工厂,DisplayContentsFactory 可以实现将提供的对象转换为所需的输出。例如,SlotDisplay.ItemStackContentsFactory 使用 ForStacks 转换器,将输入的内容转换为 ItemStack。
对于我们的 BlockState,我们将创建一个接收状态的工厂,并实现一个简单的版本,直接输出该状态本身。
// 一个用于方块状态(block states)的基础转换器
public interface ForBlockStates<T> extends DisplayContentsFactory<T> {
// 委托方法
default forState(Holder<Block> block) {
return this.forState(block.value());
}
default forState(Block block) {
return this.forState(block.defaultBlockState());
}
// 传入方块状态并转换为所需输出
T forState(BlockState state);
}
// 针对方块状态输出的实现
public class BlockStateContentsFactory implements ForBlockStates<BlockState> {
// 单例实例
public static final BlockStateContentsFactory INSTANCE = new BlockStateContentsFactory();
private BlockStateContentsFactory() {}
@Override
public BlockState forState(BlockState state) {
return state;
}
}
// 针对物品堆栈(item stack)输出的实现
public class BlockStateStackContentsFactory implements ForBlockStates<ItemStack> {
// 单例实例
public static final BlockStateStackContentsFactory INSTANCE = new BlockStateStackContentsFactory();
private BlockStateStackContentsFactory() {}
@Override
public ItemStack forState(BlockState state) {
return new ItemStack(state.getBlock());
}
}
有了这些之后,我们就可以创建一个新的 SlotDisplay。SlotDisplay.Type 必须先进行 注册:
// 一个简单的槽位展示
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) {
// 检查我们的内容工厂,并在必要时进行转换
case ForBlockStates<T> states -> Stream.of(states.forState(this.state));
// 如果你希望根据内容展示的不同方式处理内容,
// 那么你可以像下面这样对其他展示类型进行匹配
case ForStacks<T> stacks -> Stream.of(stacks.forStack(state.getBlock().asItem()));
// 如果没有匹配的工厂,则在转换后的流中不返回任何内容
default -> Stream.empty();
}
}
@Override
public SlotDisplay.Type<? extends SlotDisplay> type() {
// 返回下面注册的类型
return BLOCK_STATE_SLOT_DISPLAY.get();
}
}
// 在某个注册类中
/// 对于某个 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)
);
配方展示(Recipe Display)
RecipeDisplay 和 SlotDisplay 类似,不同之处在于它表示的是整个配方(recipe)。默认接口只记录了配方的 result(结果)以及代表配方应用工作台的 craftingStation(工作台)。RecipeDisplay 还拥有一个 type,其中包含用于编码/解码展示的 MapCodec 和 StreamCodec。不过,现有的 RecipeDisplay 子类型都不包含在客户端正确渲染我们的配方所需的全部信息。因此,我们需要自定义自己的 RecipeDisplay。
所有的槽位和材料都应该用 SlotDisplay 来表示。任何限制(比如网格大小)都可以由开发者自行决定如何提供。
// 一个简单的配方展示
public record RightClickBlockRecipeDisplay(
SlotDisplay inputState,
SlotDisplay inputItem,
SlotDisplay result, // 实现了 RecipeDisplay#result
SlotDisplay craftingStation // 实现了 RecipeDisplay#craftingStation
) implements RecipeDisplay {
public static final MapCodec<RightClickBlockRecipeDisplay> MAP_CODEC = RecordCodecBuilder.mapCodec(
instance -> instance.group(
SlotDisplay.CODEC.fieldOf("inputState").forGetter(RightClickBlockRecipeDisplay::inputState),
SlotDisplay.CODEC.fieldOf("inputState").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 RIGHT_CLICK_BLOCK_RECIPE_DISPLAY.get();
}
}
// 在某 个注册器类中
/// 针对某个 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> {
// 这里省略其他内容
@Override
public List<RecipeDisplay> display() {
// 对于同一个配方,你可以有多个不同的展示方式
// 但本示例只使用一种方式,和其他配方类似。
return List.of(
// 使用指定的槽位添加我们的配方展示
new RightClickBlockRecipeDisplay(
new BlockStateSlotDisplay(this.inputState),
this.inputItem.display(),
new SlotDisplay.ItemStackSlotDisplay(this.result),
new SlotDisplay.ItemSlotDisplay(Items.GRASS_BLOCK)
)
)
}
}
配方类型(The Recipe Type)
接下来是我们的配方类型(recipe type)。这部分相对简单,因为配方类型除了一个名称外没有其他数据。它们是配方系统中两个需要注册的部分之一。因此,和其他注册表一样,我们需要创建一个 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",
// 由于泛型的特性,这里需要显式指定泛型类型。
registryName -> new RecipeType<RightClickBlockRecipe> {
@Override
public String toString() {
return registryName.toString();
}
}
);
在注册完我们的配方类型后,必须在配方类中重写 #getType 方法,如下所示:
public class RightClickBlockRecipe implements Recipe<RightClickBlockInput> {
// 这里可以写其他内容
@Override
public RecipeType<? extends Recipe<RightClickBlockInput>> getType() {
return RIGHT_CLICK_BLOCK_TYPE.get();
}
}
配方序列化器(Recipe Serializer)
配方序列化器(recipe serializer)提供了两个 codec:一个是 map codec,一个是 stream codec,分别用于 JSON 的序列化/反序列化和网络数据的序列化/反序列化。本节不会深入讲解 codec 的工作原理,详情请参考 映射 Codec 和 流式 Codec。
由于配方序列化器本身可能会变得很庞大,原版通常将它们单独放在一个类中。推荐(但不是强制)也采用这种做法 —— 对于较小的序列化器,通常可以直接在配方类的字段中以匿名类的方式定义。为了遵循最佳实践,我们将为 codec 创建一个独立的类:
// 泛型参数是我们的配方(recipe)类。
// 注意:这里假设 simple RightClickBlockRecipe#getInputState、#getInputItem 和 #getResult 这几个 getter 方法是可用的,
// 它们在上面的代码中被省略了。
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
);
// 返回我们的 map codec。
@Override
public MapCodec<RightClickBlockRecipe> codec() {
return CODEC;
}
// 返回我们的 stream codec。
@Override
public StreamCodec<RegistryFriendlyByteBuf, RightClickBlockRecipe> streamCodec() {
return STREAM_CODEC;
}
}
和类型(type)一样,我们需要注册我们的序列化器(serializer):
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", RightClickBlockRecipeSerializer::new);
同样地,我们还必须在我们的配方(recipe)类中重写 #getSerializer 方法,如下所示:
public class RightClickBlockRecipe implements Recipe<RightClickBlockInput> {
// 其它内容
@Override
public RecipeSerializer<? extends Recipe<RightClickBlockInput>> getSerializer() {
return RIGHT_CLICK_BLOCK.get();
}
}
合成机制(The Crafting Mechanic)
现在你的配方(recipe)的所有部分都已完成,你可以编写一些配方 JSON 文件(参见 数据生成 部分),然后像上面那样通过配方管理器(recipe manager)查询你的配方。至于如何处理这些配方,就取决于你的需求了。一个常见的用例是实现一个可以处理这些配方的机器,并将当前激活的配方作为一个字段进行存储。 在我们的案例中,我们希望在物品被右键点击方块时应用该配方(recipe)。我们将通过一个 事件处理器 来实现这一点。请注意,这只是一个示例实现,你可以根据自己的需求进行任何修改(只要确保在服务端运行即可)。由于我们希望客户端和服务端的交互状态保持一致,还需要 在网络中同步所有相关的输入状态。
我们可以通过如下方式,设置一个简单的网络实现来同步配方输入:
// 一个基础的数据包(packet)类,必须进行注册。
public record ClientboundRightClickBlockRecipesPayload(
Set<BlockState> inputStates, Set<Holder<Item>> inputItems
) implements CustomPacketPayload {
// ...
}
// 数据包将数据存储在实例类中。
// 同时存在于服务端和客户端,用于初步匹配。
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());
}
}
// 服务端资源监听器,这样在配方(recipes)重载时可以自动刷新。
public class ServerRightClickBlockRecipeInputs implements ResourceManagerReloadListener, RightClickBlockRecipeInputs {
private final RecipeManager recipeManager;
private Set<BlockState> inputStates;
private Set<Holder<Item>> inputItems;
public ServerRightClickBlockRecipeInputs(RecipeManager recipeManager) {
this.recipeManager = recipeManager;
}
// 在此设置输入,因为 #apply 会根据监听器注册顺序同步触发。
// 配方(recipes)总是会最先应用。
@Override
public void onResourceManagerReload(ResourceManager manager) {
MinecraftServer server = ServerLifecycleHooks.getCurrentServer();
if (server != null) { // 理论上永远不会为 null
// 填充输入集合
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.unmodifiableSet(inputStates);
this.inputItems = Set.unmodifiableSet(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;
}
}
// 客户端实现,用于保存输入集合。
public record ClientRightClickBlockRecipeInputs(
Set<BlockState> inputStates, Set<Holder<Item>> inputItems
) implements RightClickBlockRecipeInputs {
public ClientRightClickBlockRecipeInputs(Set<BlockState> inputStates, Set<Holder<Item>> inputItems) {
this.inputStates = Set.unmodifiableSet(inputStates);
this.inputItems = Set.unmodifiableSet(inputItems);
}
}
// 根据不同端(side)处理配方(recipe)实例。
public class ServerRightClickBlockRecipes {
private static ServerRightClickBlockRecipeInputs inputs;
public static RightClickBlockRecipeInputs inputs() {
return ServerRightClickBlockRecipes.inputs;
}
// 在游戏事件总线上
@SubscribeEvent
public static void addListener(AddReloadListenerEvent event) {
// 注册服务端资源重载监听器
ServerRightClickBlockRecipes.inputs = new ServerRightClickBlockRecipeInputs(
event.getServerResources().getRecipeManager()
);
event.addListener(ServerRightClickBlockRecipes.inputs);
}
// 在游戏事件总线上
@SubscribeEvent
public static void datapackSync(OnDatapackSyncEvent event) {
// 向客户端发送数据
ServerRightClickBlockRecipes.inputs.syncToClient(event.getRelevantPlayers());
}
}
public class ClientRightClickBlockRecipes {
private static ClientRightClickBlockRecipeInputs inputs;
public static RightClickBlockRecipeInputs inputs() {
return ClientRightClickBlockRecipes.inputs;
}
// 处理收到的数据包
public static void handle(final ClientboundRightClickBlockRecipesPayload data, final IPayloadContext context) {
// 在主线程中处理数据
ClientRightClickBlockRecipes.inputs = new ClientRightClickBlockRecipeInputs(
data.inputStates(), data.inputItems()
);
}
}
public class RightClickBlockRecipes {
// 提供代理方法以便正确访问
public static RightClickBlockRecipeInputs inputs(Level level) {
return level.isClientSide
? ClientRightClickBlockRecipes.inputs()
: ServerRightClickBlockRecipes.inputs();
}
}
然后,利用同步后的输入,我们可以在游戏中检查所用的输入:
// 在游戏事件总线上
@SubscribeEvent
public static void useItemOnBlock(UseItemOnBlockEvent event) {
// 如果当前不处于由方块控制的事件阶段,则跳过。详情请参考该事件的 javadoc。
if (event.getUsePhase() != UseItemOnBlockEvent.UsePhase.BLOCK) return;
// 首先获取参数以检查输入
Level level = event.getLevel();
BlockPos pos = event.getPos();
BlockState blockState = level.getBlockState(pos);
ItemStack itemStack = event.getItemStack();
// 检查输入在客户端和服务端是否都能匹配到配方(recipe)
if (!RightClickBlockRecipes.inputs(level).test(blockState, itemStack)) return;
// 如果可以,并且当前在服务端,则继续检查配方
if (!level.isClientSide() && level instanceof ServerLevel serverLevel) {
// 创建输入对象并查询配方。
RightClickBlockInput input = new RightClickBlockInput(blockState, itemStack);
Optional<RecipeHolder<? extends Recipe<CraftingInput>>> optional = serverLevel.recipeAccess().getRecipeFor(
// 配方类型
RIGHT_CLICK_BLOCK_TYPE.get(),
input,
level
);
ItemStack result = optional
.map(RecipeHolder::value)
.map(e -> e.assemble(input, level.registryAccess()))
.orElse(ItemStack.EMPTY);
// 如果有结果,则破坏方块并在世界中掉落结果物品。
if (!result.isEmpty()) {
level.removeBlock(pos, false);
ItemEntity entity = new ItemEntity(level,
// 方块位置的中心点
pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5,
result);
level.addFreshEntity(entity);
}
}
// 取消事件,无论客户端还是服务端都停止后续交互流程。
// 已经确保可能有配方结果。
event.cancelWithResult(InteractionResult.SUCCESS_SERVER);
}
数据生成(Data Generation)
要为你自己的配方序列化器(recipe serializer)创建配方构建器(recipe builder),你需要实现 RecipeBuilder 及其方法。以下是一个常见的实现方式,部分内容参考了原版(vanilla)代码:
// 这个类是抽象类,因为每种配方序列化器(recipe-serializer)都有大量特定逻辑。
// 它的作用是展示所有(原版)配方构建器(recipe builder)共有的部分。
public abstract class SimpleRecipeBuilder implements RecipeBuilder {
// 将字段设置为 protected,以便子类可以使用它们。
protected final ItemStack result;
protected final Map<String, Criterion<?>> criteria = new LinkedHashMap<>();
@Nullable
protected final String group;
// 构造函数通常会接收结果物品堆(item stack)。
// 当然,也可以考虑用静态构建方法(static builder methods)。
public SimpleRecipeBuilder(ItemStack result) {
this.result = result;
}
// 该方法为配方进度(advancement)添加条件(criterion)。
@Override
public SimpleRecipeBuilder unlockedBy(String name, Criterion<?> criterion) {
this.criteria.put(name, criterion);
return this;
}
// 该方法添加配方书分组(recipe book group)。如果你不打算使用配方书分组,
// 可以移除 this.group 字段,并让这个方法无操作(no-op),即直接 return this。
@Override
public SimpleRecipeBuilder group(@Nullable String group) {
this.group = group;
return this;
}
// 原版这里需要的是 Item,而不是 ItemStack。你依然可以、也应该用 ItemStack
// 来序列化配方。
@Override
public Item getResult() {
return this.result.getItem();
}
}
我们已经为配方构建器(recipe builder)打好了基础。现在,在继续与配方序列化器(recipe serializer)相关的部分之前,我们应当先考虑如何实现我们的配方工厂(recipe factory)。在本例中,直接使用构造函数是合理的做法。而在其他情况下,使用静态辅助方法(static helper)或一个小型函数式接口(functional interface)会更合适。特别是当你用一个构建器服务于多个配方类时,这种做法尤为重要。
利用 RightClickBlockRecipe::new 作为我们的配方工厂(recipe factory),并复用上面的 SimpleRecipeBuilder 类,我们可以为 RightClickBlockRecipe 创建如下配方构建器:
public class RightClickBlockRecipeBuilder extends SimpleRecipeBuilder {
private final BlockState inputState;
private final Ingredient inputItem;
// 由于每种输入只有一个,我们直接通过构造函数传递它们。
// 对于拥有成分列表的配方(recipe)序列化器(serializer)的 builder,通常会初始化一个空列表,
// 并提供 #addIngredient 或类似方法来添加成分。
public RightClickBlockRecipeBuilder(ItemStack result, BlockState inputState, Ingredient inputItem) {
super(result);
this.inputState = inputState;
this.inputItem = inputItem;
}
// 使用给定的 RecipeOutput 和 key 保存配方。此方法在 RecipeBuilder 接口中定义。
@Override
public void save(RecipeOutput output, ResourceKey<Recipe<?>> key) {
// 构建进度(advancement)。
Advancement.Builder advancement = output.advancement()
.addCriterion("has_the_recipe", RecipeUnlockedTrigger.unlocked(key))
.rewards(AdvancementRewards.Builder.recipe(key))
.requirements(AdvancementRequirements.Strategy.OR);
this.criteria.forEach(advancement::addCriterion);
// 我们的工厂参数包括结果(result)、方块状态(block state)和成分(ingredient)。
RightClickBlockRecipe recipe = new RightClickBlockRecipe(this.inputState, this.inputItem, this.result);
// 将 id、配方(recipe)和配方进度(advancement)传递给 RecipeOutput。
output.accept(key, recipe, advancement.build(key.location().withPrefix("recipes/")));
}
}
现在,在 数据生成 期间,你可以像调用其他配方 builder 一样调用你自己的配方 builder:
@Override
protected void buildRecipes(RecipeOutput output) {
new RightClickRecipeBuilder(
// 构造函数参数。此示例演示了广受欢迎的泥土变钻石(dirt -> diamond)转换。
new ItemStack(Items.DIAMOND),
Blocks.DIRT.defaultBlockState(),
Ingredient.of(Items.APPLE)
)
.unlockedBy("has_apple", this.has(Items.APPLE))
.save(output);
// 这里可以添加其他配方 builder
}
你也可以将 SimpleRecipeBuilder 合并进 RightClickBlockRecipeBuilder(或你自己的配方 builder)中,特别是当你只需要一两个配方 builder 的时候。这里的抽象主要是为了展示哪些部分是与具体配方相关的,哪些是通用的。