English translation: docs/en/JEI.md
本文档介绍 PrototypeMachinery 的 JEI/HEI(JEI 4.16 / MC 1.12.2)集成工作原理,并给出 addon 接入“VanillaTypes 之外 ingredient(气体/源质等)”的完整思路与代码示例。
关键词:
- 索引(Indexing):决定 JEI 的 U/R 查询能不能搜到你的配方。
- 展示(UI):决定 JEI 配方页面如何摆放/渲染槽位与控件。
- 本项目把「索引」与「展示」解耦:UI 由 renderer/layout 控制,索引由 wrapper + provider 控制。
对每个 PrototypeMachinery 配方页面,JEI 需要两件事:
- IIngredients(索引):告诉 JEI 这个配方的 inputs/outputs 是什么(支持多 ingredient 类型)。
- IRecipeLayout(展示):告诉 JEI 页面上有哪些 slot、每个 slot 的位置/大小,以及每个 slot 展示哪些 values。
本项目的关键设计:
PMMachineRecipeWrapper#getIngredients(IIngredients)负责写入 索引数据。PMMachineRecipeCategory#setRecipe(IRecipeLayout, wrapper, IIngredients)负责构建 UI runtime 并把 runtime 的 slot 声明转成 JEI groups。- requirement renderer 负责“一个 requirement 怎么拆成 nodes、nodes 有哪些 variants、如何声明 slots/控件”。
- addon 通过注册表把自己的新类型(kind / provider / handler / renderer / layout)接进来,不需要改 core category。
- 配方:
MachineRecipe.requirements: Map<RecipeRequirementType<*>, List<RecipeRequirementComponent>> - renderer:
PMJeiRequirementRenderer<C>split(ctx, component):一个 component 可以拆成多个PMJeiRequirementNode<C>variants/defaultVariant:节点可有多种展示尺寸/样式declareJeiSlots:声明 JEI slot(位置/大小/kind/index/role)buildWidgets:构建 ModularUI widgets(比如边框、文字、进度箭头等非 ingredient 元素)
注册点:JeiRequirementRendererRegistry。
- layout:
PMJeiMachineLayoutDefinition - 内置默认布局:
integration/jei/layout/DefaultJeiMachineLayout.kt- 物品左输入右输出
- 流体左右各 1 个大 tank + per-tick 小 tank
- 能量/并行度等文本放中间
- fallback:把“未被显式摆放的 nodes(通常来自 addon requirement)”自动网格摆放(依赖 renderer.defaultVariant)
注册点:JeiMachineLayoutRegistry
setDefault(DefaultJeiMachineLayout)在PMJeiPlugin中完成- 机器专用 layout 可通过
JeiMachineLayoutRegistry.register(machineTypeId, layout)覆盖
- slot kind:
JeiSlotKind(可扩展,核心只有一个字段:id: ResourceLocation) - slot 声明:
JeiSlot(kind, nodeId, index, role, x, y, width, height)
对于 Vanilla:
JeiSlotKinds.ITEM→VanillaItemKindHandler(使用recipeLayout.itemStacks)JeiSlotKinds.FLUID→VanillaFluidKindHandler(使用recipeLayout.fluidStacks)
对于 addon 自定义 ingredient:
- 你定义一个新的
JeiSlotKind(例如myaddon:ingredient/gas) - 同时注册一个
PMJeiIngredientKindHandler<GasStack>,负责:init(...):初始化IRecipeLayout中对应 ingredient group 的 slotset(...):把 values 塞进这个 slot
注册点:JeiIngredientKindRegistry
注意:JEI 4.16(1.12)使用的是
mezz.jei.api.recipe.IIngredientType<T>(不是mezz.jei.api.ingredients.*)。
renderer 只关心 UI 声明,并不负责索引。
索引由 provider 决定:PMJeiNodeIngredientProvider<C, T>
- 输入/输出的 role 来自 node(
PMJeiRequirementRole) - provider 只需要把某个 node 变成 JEI 可索引的 values(支持多备选)
注册点:JeiNodeIngredientProviderRegistry
integration/jei/wrapper/PMMachineRecipeWrapper.kt:
- 遍历
recipe.requirements - 使用 renderer.split 拆 nodes
- 使用 provider.getDisplayed 拿 values
- 使用 kind handler 的
ingredientType分组 - 写入:
ingredients.setInputLists(type, lists)ingredients.setOutputLists(type, lists)
这一步决定 JEI 是否能通过 U/R 搜索到你的配方。
下面按“你需要实现/注册哪些东西”来写,顺序基本就是你在 addon 里落地时的顺序。
PrototypeMachinery 这边的 registries 不会帮你把新 ingredient 类型注册到 JEI 本体。
你需要在 addon 的 JEI plugin 中按 JEI API 完成以下之一(取决于 JEI 版本/接口):
- 声明
IIngredientType<GasStack> - 注册对应的 helper/renderer(让 JEI 知道如何渲染/比较/显示名字/tooltip)
只有 JEI 认识你的 ingredient 类型后,下面的 handler/provider 才有意义。
如果你只注册了 PM 的 handler/provider,但 JEI 没注册自定义 ingredient 类型:
- 你可能拿不到
IRecipeLayout.getIngredientsGroup(type)- 或 JEI 无法正确渲染/比较你的 values
import github.kasuminova.prototypemachinery.integration.jei.api.render.JeiSlotKind
import net.minecraft.util.ResourceLocation
public object GasJeiSlotKind : JeiSlotKind {
override val id: ResourceLocation = ResourceLocation("myaddon", "ingredient/gas")
}推荐:绝大多数自定义 ingredient(气体/源质/mana 等)都属于标准的
recipeLayout.getIngredientsGroup(type)路线。 这种情况下请优先使用 PrototypeMachinery 提供的通用 adapter:
integration/jei/api/ingredient/IngredientsGroupKindHandlerAdapter.kt它把
getIngredientsGroup + init + set的样板代码封装起来,并自动处理 INPUT/OUTPUT(以及 CATALYST 作为 input)判定。 如果你提供IIngredientRenderer<T>,adapter 会使用 JEI 的 full init overload(包含 width/height),交互命中区域更准确。
import github.kasuminova.prototypemachinery.integration.jei.api.ingredient.IngredientsGroupKindHandlerAdapter
import github.kasuminova.prototypemachinery.integration.jei.api.ingredient.PMJeiIngredientKindHandler
import mezz.jei.api.recipe.IIngredientType
import mezz.jei.api.ingredients.IIngredientRenderer
// 你的自定义类型(示例)
public data class GasStack(val id: String, val amount: Int)
public object MyJeiTypes {
// 这里的 GAS 需要由 addon 按 JEI API 正确创建/注册
public lateinit var GAS: IIngredientType<GasStack>
}
// 推荐:直接使用 adapter
public object GasKindHandler : IngredientsGroupKindHandlerAdapter<GasStack>(
kind = GasJeiSlotKind,
ingredientType = MyJeiTypes.GAS,
ingredientRenderer = null, // 可选:传入 IIngredientRenderer<GasStack> 以使用 full init overload
)
// 如果你有 renderer,可以这样传入(示例:renderer 的具体实现由 addon 自己提供/注册)
public object GasKindHandlerWithRenderer : IngredientsGroupKindHandlerAdapter<GasStack>(
kind = GasJeiSlotKind,
ingredientType = MyJeiTypes.GAS,
ingredientRenderer = IIngredientRenderer { _, _, _, _ ->
// render(Minecraft, x, y, ingredient);这里仅示意
},
)注册:
import github.kasuminova.prototypemachinery.integration.jei.registry.JeiIngredientKindRegistry
JeiIngredientKindRegistry.register(GasKindHandler)你的 requirement 类型需要一个 renderer,否则:
- runtime 无法生成 node
- 默认布局也无法 fallback 自动摆放
示例(仅展示 slot 声明部分):
import github.kasuminova.prototypemachinery.integration.jei.api.render.*
import github.kasuminova.prototypemachinery.integration.jei.api.JeiRecipeContext
import github.kasuminova.prototypemachinery.integration.jei.api.ui.PMJeiWidgetCollector
public class GasRequirementComponent(/* ... */)
public object GasRequirementType /* : RecipeRequirementType<GasRequirementComponent> */
public object GasRequirementJeiRenderer : PMJeiRequirementRenderer<GasRequirementComponent> {
override val type = GasRequirementType
override fun split(ctx: JeiRecipeContext, component: GasRequirementComponent): List<PMJeiRequirementNode<GasRequirementComponent>> {
// 你可以把一个 component 拆成多个 node(比如多输入),这里略
// TODO: implement your split strategy here (e.g. split multi-input into multiple nodes)
return listOf(PMJeiRequirementNode.single(component, nodeId = "gas"))
}
override fun variants(ctx: JeiRecipeContext, node: PMJeiRequirementNode<GasRequirementComponent>): List<PMJeiRendererVariant> {
return listOf(defaultVariant(ctx, node))
}
override fun defaultVariant(ctx: JeiRecipeContext, node: PMJeiRequirementNode<GasRequirementComponent>): PMJeiRendererVariant {
return object : PMJeiRendererVariant {
override val id = net.minecraft.util.ResourceLocation("myaddon", "gas/slot_18")
override val width = 18
override val height = 18
}
}
override fun declareJeiSlots(
ctx: JeiRecipeContext,
node: PMJeiRequirementNode<GasRequirementComponent>,
variant: PMJeiRendererVariant,
x: Int,
y: Int,
out: JeiSlotCollector,
) {
val idx = out.nextIndex(GasJeiSlotKind)
out.add(
JeiSlot(
kind = GasJeiSlotKind,
nodeId = node.nodeId,
index = idx,
role = when (node.role) {
github.kasuminova.prototypemachinery.integration.jei.api.layout.PMJeiRequirementRole.INPUT,
github.kasuminova.prototypemachinery.integration.jei.api.layout.PMJeiRequirementRole.INPUT_PER_TICK -> JeiSlotRole.INPUT
github.kasuminova.prototypemachinery.integration.jei.api.layout.PMJeiRequirementRole.OUTPUT,
github.kasuminova.prototypemachinery.integration.jei.api.layout.PMJeiRequirementRole.OUTPUT_PER_TICK -> JeiSlotRole.OUTPUT
else -> JeiSlotRole.CATALYST
},
x = x,
y = y,
width = variant.width,
height = variant.height,
)
)
}
override fun buildWidgets(
ctx: JeiRecipeContext,
node: PMJeiRequirementNode<GasRequirementComponent>,
variant: PMJeiRendererVariant,
x: Int,
y: Int,
out: PMJeiWidgetCollector,
) {
// 可选:加边框/图标/文字等
}
}注册:
import github.kasuminova.prototypemachinery.integration.jei.registry.JeiRequirementRendererRegistry
JeiRequirementRendererRegistry.register(GasRequirementType, GasRequirementJeiRenderer)没有 provider 时:UI 可能有槽位,但 JEI 索引不会包含你的 gas(U/R 搜不到)。
import github.kasuminova.prototypemachinery.integration.jei.api.JeiRecipeContext
import github.kasuminova.prototypemachinery.integration.jei.api.ingredient.PMJeiNodeIngredientProvider
import github.kasuminova.prototypemachinery.integration.jei.api.render.PMJeiRequirementNode
public object GasNodeIngredientProvider : PMJeiNodeIngredientProvider<GasRequirementComponent, GasStack> {
override val type = GasRequirementType
override val kind = GasJeiSlotKind
override fun getDisplayed(ctx: JeiRecipeContext, node: PMJeiRequirementNode<GasRequirementComponent>): List<GasStack> {
// 返回可索引/可轮换展示的备选项
// 例如:tag/ore-like gas 输入可返回多个 GasStack
// TODO: extract your displayed variants here
return emptyList()
}
}注册:
import github.kasuminova.prototypemachinery.integration.jei.registry.JeiNodeIngredientProviderRegistry
JeiNodeIngredientProviderRegistry.register(GasRequirementType, GasNodeIngredientProvider)如果你的 requirement 很特殊(需要固定位置/多行展示),可以给特定机器注册 layout:
import github.kasuminova.prototypemachinery.integration.jei.registry.JeiMachineLayoutRegistry
JeiMachineLayoutRegistry.register(machineTypeId, myLayout)如果不注册:默认使用 DefaultJeiMachineLayout。
- 只要 renderer 能提供
defaultVariant,默认布局会把未摆放节点自动摆上去。
从 vNext 起(本仓库实现),你可以直接用 ZenScript 给某个 machineType 覆盖 JEI 布局,而无需写 Kotlin addon。
- 创建布局:
mods.prototypemachinery.jei.PMJEIPMJEI.createLayout()PMJEI.createLayoutSized(width, height)
- 注册布局:
mods.prototypemachinery.jei.LayoutRegistryLayoutRegistry.register(machineId, layout)LayoutRegistry.registerReplace(machineId, layout, replace)LayoutRegistry.setDefault(layout)(可选:覆盖默认布局)
Requirement type id(用于 placeFirst/placeGrid/placeAllLinear 的第一个参数):
- 物品:
prototypemachinery:item - 流体:
prototypemachinery:fluid - 能量:
prototypemachinery:energy - 并行度(文本):
prototypemachinery:parallelism
Role(第二个参数,大小写不敏感,传 null / "ANY" / "*" 表示不过滤):
INPUT/OUTPUTINPUT_PER_TICK/OUTPUT_PER_TICKOTHER
默认情况下 role 过滤是“精确匹配”的:
"INPUT"只匹配INPUT"INPUT_PER_TICK"只匹配INPUT_PER_TICK
如果你希望把 INPUT/OUTPUT 视作“同组”,让它们也包含 per-tick role,可以在 builder 上启用:
val layout = PMJEI.createLayoutSized(176, 96)
.mergePerTickRoles(true)
// 此时:"INPUT" 会同时匹配 INPUT + INPUT_PER_TICK
.placeAllLinearWithVariant("prototypemachinery:fluid", "INPUT", 80, 6, 0, 60, "prototypemachinery:tank/16x58")
;提示:即使启用了
mergePerTickRoles(true),你仍然可以通过显式传"INPUT_PER_TICK"/"OUTPUT_PER_TICK"来只匹配 per-tick 节点。
Renderer variant id(可选,用于 place*WithVariant):
- 物品 18x18:
prototypemachinery:slot/18 - 流体 tank:
prototypemachinery:tank/16x58prototypemachinery:tank/16x34prototypemachinery:tank/24x58
Decorator id(用于 addDecorator*):
- 进度箭头/循环:
prototypemachinery:decorator/progress - 耗时文本:
prototypemachinery:decorator/recipe_duration
这个例子有意写得“偏啰嗦”,方便复制后改参数。
#loader crafttweaker reloadable
import mods.prototypemachinery.jei.PMJEI;
import mods.prototypemachinery.jei.LayoutRegistry;
// 你的 machine type id
val MACHINE_ID = "prototypemachinery:example_recipe_processor";
val layout = PMJEI.createLayoutSized(176, 96)
// 左侧:输入物品 2x3
.placeGridWithVariant(
"prototypemachinery:item", "INPUT",
6, 6,
2, 3,
18, 18,
"prototypemachinery:slot/18"
)
// 右侧:输出物品 2x3
.placeGridWithVariant(
"prototypemachinery:item", "OUTPUT",
176 - 6 - 2 * 18 - 2, 6,
2, 3,
18, 18,
"prototypemachinery:slot/18"
)
// 中间:输入流体(纵向 1 列多行 tank)
// - 如果你的配方只有 1 个流体输入,也没关系:只会摆放 1 个
.placeAllLinearWithVariant(
"prototypemachinery:fluid", "INPUT",
80, 6,
0, 60,
"prototypemachinery:tank/16x58"
)
// 中间:输出流体
.placeAllLinearWithVariant(
"prototypemachinery:fluid", "OUTPUT",
100, 6,
0, 60,
"prototypemachinery:tank/16x58"
)
// 进度箭头
.addDecoratorWithData(
"prototypemachinery:decorator/progress",
(176 - 20) / 2, 40,
{
"style": "arrow",
"direction": "RIGHT",
// 可选:覆盖动画周期;不填则默认按 recipe.durationTicks
// "cycleTicks": 200
}
)
// 耗时文本
.addDecoratorWithData(
"prototypemachinery:decorator/recipe_duration",
(176 - 80) / 2, 62,
{
"width": 80,
"height": 10,
"align": "CENTER",
"template": "{ticks} t ({seconds}s)",
"shadow": true
}
)
// 可选:把未被脚本显式摆放的 nodes 自动摆放到某个区域(用于 addon requirement)
// .autoPlaceRemaining(6, 6)
;
LayoutRegistry.register(MACHINE_ID, layout);部分内置 widget 支持从 layout 里读取“placement data”(一个 Map<String, Any>),用于做每个元素的细粒度调整。
目前支持:能量条顶部 IO LED 的 Y 偏移(像素)。key:"energyLedYOffset"。
#loader crafttweaker reloadable
import mods.prototypemachinery.jei.PMJEI;
import mods.prototypemachinery.jei.LayoutRegistry;
val MACHINE_ID = "prototypemachinery:example_recipe_processor";
val layout = PMJEI.createLayoutSized(176, 96)
// 将能量条放在某处,并把 LED 往上挪 1 像素(-1)
.placeFirstWithVariantAndData(
"prototypemachinery:energy", "INPUT",
80, 6,
"prototypemachinery:energy/1x3",
{"energyLedYOffset": -1}
);
LayoutRegistry.register(MACHINE_ID, layout);如果未提供该 key,则会使用配置文件 config/prototypemachinery_jei.cfg 中的 ui.energyLedYOffset 作为默认值。
目前 ZenScript builder 支持基于“某类 node 数量”的条件:
layout.whenCountAtLeast("prototypemachinery:fluid", "INPUT", 2)
.addDecorator("prototypemachinery:decorator/progress", 10, 10)
.then();如果你开启了 mergePerTickRoles(true),那么这里的 "INPUT"/"OUTPUT" 统计同样会把 *_PER_TICK 合并计入。
后续可以在此基础上扩展更复杂的谓词(按机器 id、按是否存在某个 role、按 renderer variant 等)。
有些 JEI UI 元素并不想绑定到某个配方 requirement node,例如:
- 催化剂(Catalyst)图标
- “必须携带某个工具/模具”的提示槽位
- 纯展示用的图标位(比如升级芯片、模组图标等)
这类槽位可以用 固定值槽位 实现:
- 先注册一个 provider(
providerId→ 一组固定显示值) - 然后在布局里用
placeFixedSlot(providerId, ...)放置一个“真 JEI 槽位”(可被 JEI focus/点击)
入口:mods.prototypemachinery.jei.FixedSlotProviders
- 物品:
FixedSlotProviders.registerItem(providerId, item, replace)FixedSlotProviders.registerItems(providerId, items[], replace)
- 流体:
FixedSlotProviders.registerFluid(providerId, fluid, replace)FixedSlotProviders.registerFluids(providerId, fluids[], replace)
- 清理:
FixedSlotProviders.clear(providerId)FixedSlotProviders.clearAll()
建议:如果你的脚本是
#loader crafttweaker reloadable,为了避免重载时重复注册,可以在脚本开头先clear(providerId)或clearAll()。
布局 DSL:LayoutBuilder.placeFixedSlot(providerId, role, x, y, width, height)
providerId:你注册 provider 时用的 id(字符串,会转为ResourceLocation)role:INPUT/OUTPUT/CATALYST(默认CATALYST)width/height:用于 JEI 的渲染与鼠标交互区域(建议和 renderer 视觉尺寸一致)
大多数情况下 adapter 就够用;你可能在以下场景需要手写 handler:
- 需要根据
node的数据动态计算init参数(例如容量/比例尺/特殊背景) - 想对
IGuiIngredientGroup添加 tooltip callback 或 focus override - 你不想走
getIngredientsGroup(type),而是需要直接操作 JEI 的专用 group(类似 itemStacks/fluidStacks 那种专用 API)
手写 handler 的核心逻辑仍然是:
val group = recipeLayout.getIngredientsGroup(ingredientType)group.init(...):- 简单模式:
init(index, isInput, x, y) - 完整模式:
init(index, isInput, renderer, x, y, width, height, paddingX, paddingY)
- 简单模式:
group.set(index, values)
- 这些 registry 属于 客户端集成,推荐在 addon 的 JEI plugin(
IModPlugin)初始化阶段注册。 - 参考 PrototypeMachinery 的入口:
integration/jei/PMJeiPlugin.kt。
一个简单原则:
- 在 JEI categories / wrappers 被实例化之前(通常是
registerCategories阶段)完成注册。
- UI 有槽位,但 JEI 搜索不到
- 检查:是否实现并注册了
PMJeiNodeIngredientProvider。 - 检查:provider 返回的 values 是否为空。
- 索引写进去了,但页面不显示/显示空
- 检查:是否实现并注册了
PMJeiRequirementRenderer,并在declareJeiSlots里声明了 slot。 - 检查:kind handler 是否注册;slot.kind.id 是否能在
JeiIngredientKindRegistry找到。
- 自定义类型渲染/tooltip 崩溃或不显示
- 这是 JEI 自定义 ingredient 类型本身没有注册好(helper/renderer),不属于 PM registry 能解决的范围。
-
JEI plugin 入口:
src/main/kotlin/integration/jei/PMJeiPlugin.kt -
索引写入:
src/main/kotlin/integration/jei/wrapper/PMMachineRecipeWrapper.kt -
类别组装:
src/main/kotlin/integration/jei/category/PMMachineRecipeCategory.kt -
默认布局:
src/main/kotlin/integration/jei/layout/DefaultJeiMachineLayout.kt -
Registries:
src/main/kotlin/integration/jei/registry/JeiIngredientKindRegistry.ktsrc/main/kotlin/integration/jei/registry/JeiNodeIngredientProviderRegistry.ktsrc/main/kotlin/integration/jei/registry/JeiRequirementRendererRegistry.ktsrc/main/kotlin/integration/jei/registry/JeiMachineLayoutRegistry.ktsrc/main/kotlin/integration/jei/registry/JeiFixedSlotProviderRegistry.kt
-
ZenScript:
src/main/kotlin/integration/crafttweaker/zenclass/jei/FixedSlotProviders.kt