Merge pull request #51 from 2390616704/feat/ctrl-q-pattern-creation

实现一个新的快捷键 Ctrl+Q,允许玩家从 JEI 中快速创建样板。feat: Add Ctrl+Q quick pattern creation from JEI.
This commit is contained in:
GaLicn 2026-02-26 15:57:02 +08:00 committed by GitHub
commit 334da679ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 631 additions and 3 deletions

View File

@ -1,8 +1,8 @@
# Done to increase the memory available to Gradle.
org.gradle.jvmargs=-Xmx1G
loom.platform = forge
org.gradle.parallel=true
# Mod properties
mod_version = 1.5.1
maven_group = com.extendedae_plus
archives_name = extendedae_plus
@ -31,3 +31,4 @@ ldlib_version=5394816
ie_version=5224387
mixin_version=0.8.4
curios_version=6418456
org.gradle.parallel=true

View File

@ -0,0 +1,33 @@
package com.extendedae_plus.client;
import com.mojang.blaze3d.platform.InputConstants;
import net.minecraft.client.KeyMapping;
import net.minecraftforge.client.settings.KeyConflictContext;
import org.lwjgl.glfw.GLFW;
/**
* ExtendedAE Plus 快捷键定义
*/
public final class ModKeybindings {
private ModKeybindings() {}
/**
* Ctrl+Q 快速创建样板快捷键
*/
public static final KeyMapping CREATE_PATTERN_KEY = new KeyMapping(
"key.extendedae_plus.create_pattern", // 翻译键
KeyConflictContext.GUI, // 仅在GUI中生效
InputConstants.Type.KEYSYM, // 键盘按键类型
GLFW.GLFW_KEY_Q, // Q
"key.categories.extendedae_plus" // 分类
);
/**
* 注册所有快捷键
*
* @param event Forge快捷键注册事件
*/
public static void register(net.minecraftforge.client.event.RegisterKeyMappingsEvent event) {
event.register(CREATE_PATTERN_KEY);
}
}

View File

@ -0,0 +1,184 @@
package com.extendedae_plus.client.event;
import appeng.api.stacks.AEItemKey;
import appeng.api.stacks.AEKey;
import com.extendedae_plus.ExtendedAEPlus;
import com.extendedae_plus.client.ModKeybindings;
import com.extendedae_plus.init.ModNetwork;
import com.extendedae_plus.integration.jei.JeiRuntimeProxy;
import com.extendedae_plus.network.pattern.CreateCtrlQPatternC2SPacket;
import com.extendedae_plus.util.RecipeFinderUtil;
import mezz.jei.api.constants.VanillaTypes;
import mezz.jei.api.ingredients.ITypedIngredient;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.crafting.CraftingRecipe;
import net.minecraft.world.item.crafting.Ingredient;
import net.minecraft.world.item.crafting.Recipe;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.client.event.ScreenEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Ctrl+Q键快速创建样板事件监听器
*
* <p>监听 Ctrl+Q 组合键自动创建样板并掉落到玩家脚下</p>
* <p>应用 JEI 书签优先级选择材料优先选择工作台配方</p>
*/
@Mod.EventBusSubscriber(modid = ExtendedAEPlus.MODID, value = Dist.CLIENT)
public class CtrlQPatternKeyHandler {
private static final Logger LOGGER = LoggerFactory.getLogger("ExtendedAE Plus - CtrlQKeyHandler");
@SubscribeEvent
public static void onScreenKeyPressed(ScreenEvent.KeyPressed event) {
Screen screen = event.getScreen();
int keyCode = event.getKeyCode();
int scanCode = event.getScanCode();
// 使用 KeyMapping 检测按键而非硬编码
if (!ModKeybindings.CREATE_PATTERN_KEY.matches(keyCode, scanCode)) {
return;
}
// 检查 Ctrl 修饰键
if (!Screen.hasControlDown()) {
return;
}
// JEI 必须可用
if (JeiRuntimeProxy.get() == null) {
LOGGER.warn("[CtrlQKeyHandler] JEI not available");
return;
}
// 获取鼠标悬浮的物品
Optional<ITypedIngredient<?>> ingredient = JeiRuntimeProxy.getIngredientUnderMouse();
if (ingredient.isEmpty()) {
LOGGER.warn("[CtrlQKeyHandler] No ingredient under mouse");
Minecraft mc = Minecraft.getInstance();
if (mc.player != null) {
mc.player.displayClientMessage(
Component.translatable("message.extendedae_plus.hover_item_first"),
true
);
}
return;
}
// 查找相关配方
Minecraft mc = Minecraft.getInstance();
List<Recipe<?>> recipes = RecipeFinderUtil.findRecipesByIngredient(
ingredient.get(),
mc.level
);
if (recipes.isEmpty()) {
LOGGER.warn("[CtrlQKeyHandler] No recipes found");
if (mc.player != null) {
mc.player.displayClientMessage(
Component.translatable("message.extendedae_plus.no_recipes_found"),
true
);
}
return;
}
// 自动选择最佳配方优先CraftingRecipe
Recipe<?> selectedRecipe = RecipeFinderUtil.selectBestRecipe(recipes);
if (selectedRecipe == null) {
LOGGER.error("[CtrlQKeyHandler] selectBestRecipe returned null");
return;
}
boolean isCraftingPattern = selectedRecipe instanceof CraftingRecipe;
// 应用JEI书签优先级选择材料
List<ItemStack> selectedIngredients = selectIngredientsWithJeiPriority(selectedRecipe);
// 发送网络包到服务器
ModNetwork.CHANNEL.sendToServer(new CreateCtrlQPatternC2SPacket(
selectedRecipe.getId(),
isCraftingPattern,
selectedIngredients
));
// 消耗事件防止传播
event.setCanceled(true);
}
/**
* 应用JEI书签优先级选择配方材料
*
* <p>对配方的每个 Ingredient选择 JEI 书签中优先级最高的物品</p>
* <p>如果没有在书签中则使用配方默认的第一个物品</p>
*
* @param recipe 配方
* @return 选择的材料列表
*/
private static List<ItemStack> selectIngredientsWithJeiPriority(Recipe<?> recipe) {
// 获取JEI书签列表并构建优先级映射
List<? extends ITypedIngredient<?>> bookmarks = JeiRuntimeProxy.getBookmarkList();
Map<AEKey, Integer> priorities = new HashMap<>();
AtomicInteger index = new AtomicInteger(Integer.MAX_VALUE);
// 构建优先级映射 (数值越小 = 优先级越高与EncodingHelperMixin逻辑一致)
for (ITypedIngredient<?> ingredient : bookmarks) {
ingredient.getIngredient(VanillaTypes.ITEM_STACK).ifPresent(itemStack ->
priorities.put(AEItemKey.of(itemStack), index.getAndDecrement())
);
}
List<ItemStack> selected = new ArrayList<>();
// 对每个 ingredient 选择优先级最高的物品
for (Ingredient ingredient : recipe.getIngredients()) {
if (ingredient.isEmpty()) {
selected.add(ItemStack.EMPTY);
continue;
}
ItemStack[] items = ingredient.getItems();
if (items.length == 0) {
selected.add(ItemStack.EMPTY);
continue;
}
// 选择优先级最高的 (如果都不在书签中选第一个)
ItemStack best = items[0];
int bestPriority = Integer.MAX_VALUE;
// 检查第一个物品的优先级
AEKey firstKey = AEItemKey.of(best);
if (priorities.containsKey(firstKey)) {
bestPriority = priorities.get(firstKey);
}
// 遍历其他选项
for (int i = 1; i < items.length; i++) {
AEKey key = AEItemKey.of(items[i]);
int priority = priorities.getOrDefault(key, Integer.MAX_VALUE);
if (priority < bestPriority) {
bestPriority = priority;
best = items[i];
}
}
selected.add(best.copy());
}
return selected;
}
}

View File

@ -170,4 +170,25 @@ public final class ModConfig {
ConfigParsingUtils.reload();
}
}
// ==================== Ctrl+Q 快速样板配置 ====================
@Configurable
@Configurable.Comment(value = {
"Ctrl+Q创建样板是否消耗空白样板",
"true: 从玩家背包或AE网络消耗空白样板",
"false: 不消耗空白样板,直接创建(整合包/服务器管理员可配置)"
})
@Configurable.Synchronized
public boolean ctrlQConsumeBlankPattern = true;
@Configurable
@Configurable.Comment(value = {
"Ctrl+Q创建样板是否优先从AE网络提取空白样板",
"true: 优先从AE网络提取网络无货才从背包消耗",
"false: 仅从玩家背包消耗",
"注意需要玩家持有或装备无线终端才能访问AE网络"
})
@Configurable.Synchronized
public boolean ctrlQExtractFromNetwork = true;
}

View File

@ -6,6 +6,7 @@ import com.extendedae_plus.network.crafting.CraftingMonitorJumpC2SPacket;
import com.extendedae_plus.network.crafting.CraftingMonitorOpenProviderC2SPacket;
import com.extendedae_plus.network.crafting.OpenCraftFromJeiC2SPacket;
import com.extendedae_plus.network.meInterface.InterfaceAdjustConfigAmountC2SPacket;
import com.extendedae_plus.network.pattern.CreateCtrlQPatternC2SPacket;
import com.extendedae_plus.network.provider.*;
import com.extendedae_plus.network.upload.EncodeWithShiftFlagC2SPacket;
import net.minecraft.resources.ResourceLocation;
@ -168,6 +169,12 @@ public final class ModNetwork {
.decoder(LabelNetworkListS2CPacket::decode)
.consumerNetworkThread(LabelNetworkListS2CPacket::handle)
.add();
CHANNEL.messageBuilder(CreateCtrlQPatternC2SPacket.class, nextId(), NetworkDirection.PLAY_TO_SERVER)
.encoder(CreateCtrlQPatternC2SPacket::encode)
.decoder(CreateCtrlQPatternC2SPacket::decode)
.consumerNetworkThread(CreateCtrlQPatternC2SPacket::handle)
.add();
}
private static int nextId() { return id++; }

View File

@ -0,0 +1,228 @@
package com.extendedae_plus.network.pattern;
import appeng.api.crafting.PatternDetailsHelper;
import appeng.api.stacks.AEItemKey;
import appeng.api.stacks.GenericStack;
import appeng.core.definitions.AEItems;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.crafting.CraftingRecipe;
import net.minecraft.world.item.crafting.Recipe;
import net.minecraft.world.item.crafting.RecipeManager;
import net.minecraftforge.network.NetworkEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Supplier;
/**
* C2S: Ctrl+Q快速创建样板数据包
*
* <p>从客户端发送配方ID和选择的材料到服务器服务器消耗空白样板并创建编码样板掉落到玩家脚下</p>
*/
public class CreateCtrlQPatternC2SPacket {
private static final Logger LOGGER = LoggerFactory.getLogger("ExtendedAE Plus - CtrlQPattern");
private final ResourceLocation recipeId;
private final boolean isCraftingPattern;
private final List<ItemStack> selectedIngredients;
public CreateCtrlQPatternC2SPacket(ResourceLocation recipeId, boolean isCraftingPattern, List<ItemStack> selectedIngredients) {
this.recipeId = recipeId;
this.isCraftingPattern = isCraftingPattern;
this.selectedIngredients = selectedIngredients;
}
public static void encode(CreateCtrlQPatternC2SPacket msg, FriendlyByteBuf buf) {
buf.writeResourceLocation(msg.recipeId);
buf.writeBoolean(msg.isCraftingPattern);
buf.writeInt(msg.selectedIngredients.size());
for (ItemStack stack : msg.selectedIngredients) {
buf.writeItem(stack);
}
}
public static CreateCtrlQPatternC2SPacket decode(FriendlyByteBuf buf) {
ResourceLocation recipeId = buf.readResourceLocation();
boolean isCraftingPattern = buf.readBoolean();
int count = buf.readInt();
List<ItemStack> ingredients = new ArrayList<>();
for (int i = 0; i < count; i++) {
ingredients.add(buf.readItem());
}
return new CreateCtrlQPatternC2SPacket(recipeId, isCraftingPattern, ingredients);
}
public static void handle(CreateCtrlQPatternC2SPacket msg, Supplier<NetworkEvent.Context> ctxSupplier) {
var ctx = ctxSupplier.get();
ctx.enqueueWork(() -> {
ServerPlayer player = ctx.getSender();
if (player == null) {
LOGGER.warn("[CtrlQPattern] No sender found");
return;
}
// 1. 验证配方存在
RecipeManager recipeManager = player.level().getRecipeManager();
var recipeOpt = recipeManager.byKey(msg.recipeId);
if (recipeOpt.isEmpty()) {
LOGGER.error("[CtrlQPattern] Recipe not found: {}", msg.recipeId);
player.displayClientMessage(
Component.translatable("message.extendedae_plus.recipe_not_found"),
false
);
return;
}
Recipe<?> recipe = recipeOpt.get();
// 2. 消耗空白样板
if (!consumeBlankPattern(player)) {
LOGGER.warn("[CtrlQPattern] No blank pattern found in inventory");
player.displayClientMessage(
Component.translatable("message.extendedae_plus.no_blank_pattern"),
false
);
return;
}
// 3. 创建样板
ItemStack pattern = createPattern(recipe, msg.isCraftingPattern, msg.selectedIngredients, player);
if (pattern.isEmpty()) {
LOGGER.error("[CtrlQPattern] Pattern creation failed");
// 创建失败退还空白样板
player.getInventory().add(AEItems.BLANK_PATTERN.stack());
player.displayClientMessage(
Component.translatable("message.extendedae_plus.pattern_creation_failed"),
false
);
return;
}
// 4. 根据样板类型选择交付方式
if (msg.isCraftingPattern) {
// 合成样板始终掉落到玩家脚下
player.drop(pattern, false);
} else {
// 处理样板优先放入背包满了再掉落
boolean added = player.getInventory().add(pattern);
if (added) {
} else {
player.drop(pattern, false);
}
}
// 5. 移除成功消息仅失败时提示
});
ctx.setPacketHandled(true);
}
/**
* 消耗玩家背包中的一个空白样板
*
* @param player 玩家
* @return 是否成功消耗
*/
private static boolean consumeBlankPattern(ServerPlayer player) {
Inventory inventory = player.getInventory();
// 遍历背包查找空白样板
for (int i = 0; i < inventory.getContainerSize(); i++) {
ItemStack stack = inventory.getItem(i);
if (stack.is(AEItems.BLANK_PATTERN.asItem())) {
stack.shrink(1); // 消耗一个
return true;
}
}
return false; // 未找到
}
/**
* 从配方创建样板
*
* @param recipe 配方
* @param isCrafting 是否为合成样板
* @param selectedIngredients 客户端选择的材料应用JEI优先级后
* @param player 玩家
* @return 编码的样板物品
*/
private static ItemStack createPattern(Recipe<?> recipe, boolean isCrafting, List<ItemStack> selectedIngredients, ServerPlayer player) {
try {
if (isCrafting && recipe instanceof CraftingRecipe craftingRecipe) {
// ===== 合成样板创建路径 =====
// 准备9格工作台输入3x3布局
ItemStack[] inputs = new ItemStack[9];
for (int i = 0; i < 9; i++) {
if (i < selectedIngredients.size()) {
inputs[i] = selectedIngredients.get(i).copy();
} else {
inputs[i] = ItemStack.EMPTY;
}
}
// 准备输出
ItemStack output = recipe.getResultItem(player.level().registryAccess()).copy();
// 使用 encodeCraftingPattern 创建合成样板
// 直接传递 CraftingRecipe 对象而非 RecipeHolder
ItemStack encodedPattern = PatternDetailsHelper.encodeCraftingPattern(
craftingRecipe,
inputs,
output,
true, // allowSubstitutes - 允许替代材料
false // allowFluidSubstitutes - 不允许流体替代
);
return encodedPattern;
} else {
// ===== 处理样板创建路径 =====
List<GenericStack> inputs = new ArrayList<>();
List<GenericStack> outputs = new ArrayList<>();
// 处理输入 - 使用客户端传入的材料选择
for (ItemStack item : selectedIngredients) {
if (!item.isEmpty()) {
inputs.add(new GenericStack(
AEItemKey.of(item),
item.getCount()
));
}
}
// 处理输出
ItemStack result = recipe.getResultItem(player.level().registryAccess());
if (!result.isEmpty()) {
outputs.add(new GenericStack(
AEItemKey.of(result),
result.getCount()
));
}
// 使用 encodeProcessingPattern 创建处理样板
ItemStack encodedPattern = PatternDetailsHelper.encodeProcessingPattern(
inputs.toArray(new GenericStack[0]),
outputs.toArray(new GenericStack[0])
);
return encodedPattern;
}
} catch (Exception e) {
LOGGER.error("[CtrlQPattern] Exception during pattern creation", e);
return ItemStack.EMPTY;
}
}
}

View File

@ -0,0 +1,134 @@
package com.extendedae_plus.util;
import mezz.jei.api.constants.VanillaTypes;
import mezz.jei.api.ingredients.ITypedIngredient;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.crafting.CraftingRecipe;
import net.minecraft.world.item.crafting.Recipe;
import net.minecraft.world.level.Level;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.List;
/**
* 配方查找工具类
*
* <p>根据物品查找相关配方优先返回工作台配方CraftingRecipe</p>
*/
public class RecipeFinderUtil {
private static final Logger LOGGER = LoggerFactory.getLogger("ExtendedAE Plus - RecipeFinder");
/**
* 根据JEI物品查找相关配方
*
* @param ingredient JEI物品
* @param level 当前世界
* @return 相关配方列表
*/
public static List<Recipe<?>> findRecipesByIngredient(ITypedIngredient<?> ingredient, Level level) {
if (ingredient.getType() == VanillaTypes.ITEM_STACK) {
ItemStack stack = (ItemStack) ingredient.getIngredient();
return findRecipesByItem(stack, level);
}
LOGGER.warn("[RecipeFinder] Unsupported ingredient type: {}", ingredient.getType());
// TODO: Support fluids, chemicals, and other AE2-compatible types
return List.of();
}
/**
* 根据物品查找相关配方
*
* @param item 目标物品
* @param level 当前世界
* @return 配方列表
*/
private static List<Recipe<?>> findRecipesByItem(ItemStack item, Level level) {
List<Recipe<?>> results = new ArrayList<>();
int totalRecipes = level.getRecipeManager().getRecipes().size();
// 1. 查找以该物品为输出的配方
int outputMatches = 0;
for (Recipe<?> recipe : level.getRecipeManager().getRecipes()) {
if (matchesOutput(recipe, item)) {
results.add(recipe);
outputMatches++;
}
}
// 2. 如果按住Shift也查找以该物品为输入的配方
if (Screen.hasShiftDown()) {
int inputMatches = 0;
for (Recipe<?> recipe : level.getRecipeManager().getRecipes()) {
if (matchesInput(recipe, item) && !results.contains(recipe)) {
results.add(recipe);
inputMatches++;
}
}
}
// 3. 优先级排序: CraftingRecipe优先
results.sort((r1, r2) -> {
boolean isCrafting1 = r1 instanceof CraftingRecipe;
boolean isCrafting2 = r2 instanceof CraftingRecipe;
if (isCrafting1 && !isCrafting2) return -1; // r1优先
if (!isCrafting1 && isCrafting2) return 1; // r2优先
return 0; // 保持原顺序
});
return results;
}
/**
* 选择最佳配方优先选择工作台配方
*
* @param recipes 配方列表
* @return 最佳配方如果列表为空返回null
*/
public static Recipe<?> selectBestRecipe(List<Recipe<?>> recipes) {
if (recipes.isEmpty()) {
return null;
}
// 优先返回CraftingRecipe
for (Recipe<?> recipe : recipes) {
if (recipe instanceof CraftingRecipe) {
return recipe;
}
}
// 没有工作台配方返回第一个
return recipes.get(0);
}
/**
* 检查配方输出是否匹配目标物品
*/
private static boolean matchesOutput(Recipe<?> recipe, ItemStack target) {
try {
ItemStack result = recipe.getResultItem(null);
boolean matches = ItemStack.isSameItemSameTags(result, target);
return matches;
} catch (Exception e) {
LOGGER.warn("[RecipeFinder] Exception in matchesOutput for recipe {}: {}", recipe.getId(), e.getMessage());
return false;
}
}
/**
* 检查配方输入是否包含目标物品
*/
private static boolean matchesInput(Recipe<?> recipe, ItemStack target) {
try {
boolean matches = recipe.getIngredients().stream()
.anyMatch(ingredient -> ingredient.test(target));
return matches;
} catch (Exception e) {
LOGGER.warn("[RecipeFinder] Exception in matchesInput for recipe {}: {}", recipe.getId(), e.getMessage());
return false;
}
}
}

View File

@ -227,5 +227,15 @@
"extendedae_plus.command.server_side_only": "This command must be run on server side",
"extendedae_plus.command.storage_manager_not_initialized": "InfinityStorageManager is not initialized",
"extendedae_plus.command.gave_infinity_disks": "Gave %s infinity disks",
"extendedae_plus.command.error": "Error: %s"
"extendedae_plus.command.error": "Error: %s",
"message.extendedae_plus.hover_item_first": "Please hover over an item first",
"message.extendedae_plus.no_recipes_found": "No recipes found for this item",
"message.extendedae_plus.no_blank_pattern": "No blank pattern in inventory",
"message.extendedae_plus.recipe_not_found": "Recipe not found",
"message.extendedae_plus.pattern_creation_failed": "Pattern creation failed",
"message.extendedae_plus.pattern_created": "Created pattern: %s",
"key.extendedae_plus.create_pattern": "Create Pattern from JEI",
"key.categories.extendedae_plus": "ExtendedAE Plus"
}

View File

@ -226,5 +226,15 @@
"extendedae_plus.command.server_side_only": "此命令必须在服务器端执行",
"extendedae_plus.command.storage_manager_not_initialized": "InfinityStorageManager未初始化",
"extendedae_plus.command.gave_infinity_disks": "已发放 %s 个无限磁盘",
"extendedae_plus.command.error": "错误: %s"
"extendedae_plus.command.error": "错误: %s",
"message.extendedae_plus.hover_item_first": "请先将鼠标悬浮在物品上",
"message.extendedae_plus.no_recipes_found": "未找到该物品的配方",
"message.extendedae_plus.no_blank_pattern": "背包中没有空白样板",
"message.extendedae_plus.recipe_not_found": "配方未找到",
"message.extendedae_plus.pattern_creation_failed": "样板创建失败",
"message.extendedae_plus.pattern_created": "已创建样板: %s",
"key.extendedae_plus.create_pattern": "从JEI创建样板",
"key.categories.extendedae_plus": "ExtendedAE Plus"
}