커스텀 레시피 타입 — Crusher Recipe
MapCodec + StreamCodec으로 CrusherRecipe 커스텀 레시피 타입을 설계하고, RecipeSerializer·RecipeType을 DeferredRegister로 등록하여 데이터팩 JSON에서 레시피를 정의합니다.
커스텀 레시피 타입 — Crusher Recipe
이 챕터에서는 분쇄기 전용 커스텀 레시피 타입을 만듭니다. NeoForge 26.1의 Recipe<RecipeInput> 인터페이스와 MapCodec + StreamCodec 이중 직렬화 구조를 사용하여, 데이터팩 JSON으로 레시피를 정의하고 RecipeManager가 자동으로 로드·동기화합니다.
1. CrusherRecipe 레코드
Java record는 불변 데이터 클래스에 최적입니다. 레시피는 한 번 생성되면 변경되지 않으므로 record로 선언합니다.
// examplemod-master-projects/machine/src/main/java/com/example/master/machine/recipe/CrusherRecipe.java
package com.example.master.machine.recipe;
import com.example.master.machine.MasterMachineMod;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.ItemStackTemplate;
import net.minecraft.world.item.crafting.Ingredient;
import net.minecraft.world.item.crafting.PlacementInfo;
import net.minecraft.world.item.crafting.Recipe;
import net.minecraft.world.item.crafting.RecipeBookCategory;
import net.minecraft.world.item.crafting.RecipeSerializer;
import net.minecraft.world.item.crafting.RecipeType;
import net.minecraft.world.item.crafting.SingleRecipeInput;
import net.minecraft.world.level.Level;
public record CrusherRecipe(Ingredient input, ItemStackTemplate output, float chance)
implements Recipe<SingleRecipeInput> {
@Override
public boolean matches(SingleRecipeInput input, Level level) {
return this.input.test(input.item());
}
@Override
public ItemStack assemble(SingleRecipeInput input) {
return output.create();
}
@Override
public boolean showNotification() {
return false;
}
@Override
public String group() {
return "";
}
@Override
public PlacementInfo placementInfo() {
return PlacementInfo.create(input);
}
@Override
public RecipeBookCategory recipeBookCategory() {
return new RecipeBookCategory();
}
@Override
public RecipeSerializer<? extends Recipe<SingleRecipeInput>> getSerializer() {
return MasterMachineMod.CRUSHER_RECIPE_SERIALIZER.get();
}
@Override
public RecipeType<? extends Recipe<SingleRecipeInput>> getType() {
return MasterMachineMod.CRUSHER_RECIPE_TYPE.get();
}
}핵심 포인트
| 메서드 | 역할 |
|---|---|
matches() | Ingredient.test()로 슬롯 아이템이 재료와 일치하는지 확인 |
assemble() | ItemStackTemplate.create()로 결과물 ItemStack을 새로 생성 — 원본 변형 방지 |
placementInfo() | PlacementInfo.create(input)로 재료 배치 정보 제공 (레시피북용) |
getSerializer() / getType() | 등록된 Supplier를 .get()으로 반환 |
2. CrusherRecipeSerializer
레시피 직렬화기는 두 개의 코덱을 제공합니다. NeoForge 26.1.2에서 RecipeSerializer는 구현해야 할 인터페이스가 아니라 두 코덱을 받는 record이므로, 이 클래스는 코덱 상수만 보관하고 등록 시점(섹션 3)에 new RecipeSerializer<>(CODEC, STREAM_CODEC)로 조립합니다.
MapCodec— 데이터팩 JSON 파일 파싱StreamCodec— 서버→클라이언트 네트워크 동기화
// examplemod-master-projects/machine/src/main/java/com/example/master/machine/recipe/CrusherRecipeSerializer.java
package com.example.master.machine.recipe;
import com.mojang.serialization.Codec;
import com.mojang.serialization.MapCodec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.world.item.ItemStackTemplate;
import net.minecraft.world.item.crafting.Ingredient;
public final class CrusherRecipeSerializer {
public static final MapCodec<CrusherRecipe> CODEC = RecordCodecBuilder.mapCodec(builder ->
builder.group(
Ingredient.CODEC.fieldOf("input").forGetter(CrusherRecipe::input),
ItemStackTemplate.CODEC.fieldOf("output").forGetter(CrusherRecipe::output),
Codec.FLOAT.optionalFieldOf("chance", 1.0f).forGetter(CrusherRecipe::chance)
).apply(builder, CrusherRecipe::new));
public static final StreamCodec<RegistryFriendlyByteBuf, CrusherRecipe> STREAM_CODEC =
StreamCodec.composite(
Ingredient.CONTENTS_STREAM_CODEC, CrusherRecipe::input,
ItemStackTemplate.STREAM_CODEC, CrusherRecipe::output,
ByteBufCodecs.FLOAT, CrusherRecipe::chance,
CrusherRecipe::new);
private CrusherRecipeSerializer() {}
}MapCodec vs StreamCodec
데이터팩 로드 흐름:
data/master_machine/recipe/iron_to_dust.json
└─ Gson 파싱 → MapCodec.decode() → CrusherRecipe 인스턴스
네트워크 동기화 흐름:
서버 RecipeManager
└─ StreamCodec.encode() → 바이트 버퍼 → 클라이언트
StreamCodec.decode() → 클라이언트 RecipeManager
optionalFieldOf("chance", 1.0f)는 JSON에서 "chance" 키가 없으면 기본값 1.0을 사용합니다.
3. RecipeType + RecipeSerializer 등록
MasterMachineMod.java에 두 개의 DeferredRegister를 추가합니다.
// MasterMachineMod.java (발췌)
public static final DeferredRegister<RecipeType<?>> RECIPE_TYPES =
DeferredRegister.create(Registries.RECIPE_TYPE, MOD_ID);
public static final DeferredRegister<RecipeSerializer<?>> RECIPE_SERIALIZERS =
DeferredRegister.create(Registries.RECIPE_SERIALIZER, MOD_ID);
public static final Supplier<RecipeType<CrusherRecipe>> CRUSHER_RECIPE_TYPE =
RECIPE_TYPES.register("crusher", () -> new RecipeType<>() {
@Override
public String toString() { return "master_machine:crusher"; }
});
public static final Supplier<RecipeSerializer<CrusherRecipe>> CRUSHER_RECIPE_SERIALIZER =
RECIPE_SERIALIZERS.register("crusher",
() -> new RecipeSerializer<>(CrusherRecipeSerializer.CODEC, CrusherRecipeSerializer.STREAM_CODEC));
// 생성자에서 등록
public MasterMachineMod(IEventBus modEventBus, ModContainer container) {
// ... 기존 등록 ...
RECIPE_TYPES.register(modEventBus);
RECIPE_SERIALIZERS.register(modEventBus);
}RecipeType의 toString()은 디버그 출력에 사용되며 "modid:name" 형식을 따릅니다.
4. 레시피 JSON 작성
데이터팩 레시피 파일은 src/main/resources/data/<modid>/recipe/ 경로에 둡니다.
// examplemod-master-projects/machine/src/main/resources/data/master_machine/recipe/iron_to_dust.json
{
"type": "master_machine:crusher",
"input": "minecraft:iron_ingot",
"output": {
"id": "minecraft:redstone",
"count": 4
},
"chance": 1
}"type" 키가 어떤 RecipeSerializer를 사용할지 결정합니다. MapCodec의 필드명("input", "output", "chance")과 JSON 키가 정확히 일치해야 합니다. Ingredient.CODEC은 단일 아이템을 "minecraft:iron_ingot"처럼 문자열로 받고, 태그는 "#minecraft:..." 형식을 씁니다. 결과물은 ItemStackTemplate.CODEC이 파싱하므로 "id" + "count" 형식으로 작성합니다.
5. BlockEntity tick에서 레시피 조회
// CrusherBlockEntity.java — tick 메서드 (발췌)
public static void tick(Level level, BlockPos pos, BlockState state, CrusherBlockEntity be) {
if (level.isClientSide()) return;
ItemStack input = be.inventory.getStackInSlot(0);
if (input.isEmpty()) { be.progress = 0; return; }
Optional<RecipeHolder<CrusherRecipe>> recipe = ((ServerLevel) level).recipeAccess()
.getRecipeFor(
MasterMachineMod.CRUSHER_RECIPE_TYPE.get(),
new SingleRecipeInput(input),
level);
if (recipe.isPresent()) {
be.progress++;
if (be.progress >= 200) {
be.progress = 0;
be.inventory.extractItem(0, 1, false);
ItemStack result = recipe.get().value().assemble(
new SingleRecipeInput(input));
be.inventory.insertItem(1, result, false);
}
}
}getRecipeFor()는 해당 RecipeType의 레시피 목록을 순회하며 matches()가 true인 첫 번째 레시피를 반환합니다. 200틱(10초)마다 가공이 완료됩니다.
⚠️ 성능 안티패턴 — 매 tick 레시피 조회
매 tick
getRecipeFor호출은 피하세요
// ❌ 매 tick 호출 — HashMap 조회 + Ingredient 매칭이 반복됨
public static void tick(...) {
var recipe = ((ServerLevel) level).recipeAccess().getRecipeFor(...); // 매 tick 실행
}
// ✅ 캐시 — input이 변경될 때만 다시 조회
private RecipeHolder<CrusherRecipe> cachedRecipe;
private ItemStack lastInput = ItemStack.EMPTY;
// tick 내부:
ItemStack currentInput = be.inventory.getStackInSlot(0);
if (!ItemStack.isSameItemSameComponents(be.lastInput, currentInput)) {
be.cachedRecipe = ((ServerLevel) level).recipeAccess()
.getRecipeFor(MasterMachineMod.CRUSHER_RECIPE_TYPE.get(),
new SingleRecipeInput(currentInput), level)
.orElse(null);
be.lastInput = currentInput.copy();
}
if (be.cachedRecipe != null) { /* 가공 진행 */ }Ingredient.test()는 태그 조회를 포함하므로 비용이 높습니다. input 슬롯이 변경될 때만 재조회하고, 이외엔 캐시를 사용하세요.
정리
| 클래스 | 역할 |
|---|---|
CrusherRecipe | 레시피 데이터 홀더 (record) + Recipe 인터페이스 구현 |
CrusherRecipeSerializer | JSON → MapCodec, 네트워크 → StreamCodec |
MasterMachineMod.CRUSHER_RECIPE_TYPE | RecipeManager 조회 키 |
MasterMachineMod.CRUSHER_RECIPE_SERIALIZER | 직렬화기 레지스트리 항목 |
data/.../recipe/iron_to_dust.json | 실제 레시피 정의 (데이터팩) |