From 586bc4151c4e8b494e2a78d1bb293a08f72bd13a Mon Sep 17 00:00:00 2001 From: m1Riss <3081974632@qq.com> Date: Wed, 10 Jun 2026 21:27:42 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=E6=B7=BB=E5=8A=A0=E4=BA=86emi?= =?UTF-8?q?=E6=8A=98=E5=8F=A0=E5=8A=9F=E8=83=BD=EF=BC=88=E5=8F=AA=E5=90=AB?= =?UTF-8?q?=E5=B0=91=E9=83=A8=E5=88=86=E5=AE=9E=E4=BE=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # src/generated/resources/data/ctnhenergy/tags/items/p2p_attunements/eu_p2p_tunnel.json --- .gitignore | 4 + .../ses_154b374d0ffenb6pOEITn2kJq4.json | 10 + .../ses_15520a243ffe1boZ9xCFJR4DYc.json | 10 + .../ses_1561201f3ffe5FecL1zDYz3OlR.json | 10 + .../ses_1582a1b3fffeVytcZ1qXuOdHd0.json | 10 + .../ses_1582debb4ffe5n4umaN66NYAZ9.json | 10 + .../ses_15835033effetw28iiOqzf40dW.json | 10 + dependencies.gradle | 3 +- .../resources/assets/ctnhcore/lang/en_ud.json | 17 +- .../resources/assets/ctnhcore/lang/en_us.json | 17 +- .../resources/assets/ctnhcore/lang/zh_cn.json | 17 +- .../data/lang/old/ChineseLangHandler.java | 10 + .../data/lang/old/EnglishLangHandler.java | 10 + .../mixin/emi/EmiScreenManagerInputMixin.java | 245 ++++ .../emi/EmiScreenManagerScreenSpaceMixin.java | 297 +++++ .../collapsible/CTNHCollapsibleGroups.java | 1021 +++++++++++++++++ src/main/resources/ctnhcore.mixins.json | 2 + 17 files changed, 1698 insertions(+), 5 deletions(-) create mode 100644 .omo/run-continuation/ses_154b374d0ffenb6pOEITn2kJq4.json create mode 100644 .omo/run-continuation/ses_15520a243ffe1boZ9xCFJR4DYc.json create mode 100644 .omo/run-continuation/ses_1561201f3ffe5FecL1zDYz3OlR.json create mode 100644 .omo/run-continuation/ses_1582a1b3fffeVytcZ1qXuOdHd0.json create mode 100644 .omo/run-continuation/ses_1582debb4ffe5n4umaN66NYAZ9.json create mode 100644 .omo/run-continuation/ses_15835033effetw28iiOqzf40dW.json create mode 100644 src/main/java/io/github/cpearl0/ctnhcore/mixin/emi/EmiScreenManagerInputMixin.java create mode 100644 src/main/java/io/github/cpearl0/ctnhcore/mixin/emi/EmiScreenManagerScreenSpaceMixin.java create mode 100644 src/main/java/io/github/cpearl0/ctnhcore/utils/emi/collapsible/CTNHCollapsibleGroups.java diff --git a/.gitignore b/.gitignore index 5b75105f..cf2b61db 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,7 @@ src/test/* # macOS .DS_Store + +# opencode +.omo +.opencode diff --git a/.omo/run-continuation/ses_154b374d0ffenb6pOEITn2kJq4.json b/.omo/run-continuation/ses_154b374d0ffenb6pOEITn2kJq4.json new file mode 100644 index 00000000..9a4b25dc --- /dev/null +++ b/.omo/run-continuation/ses_154b374d0ffenb6pOEITn2kJq4.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_154b374d0ffenb6pOEITn2kJq4", + "updatedAt": "2026-06-09T07:34:36.095Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-06-09T07:34:36.095Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_15520a243ffe1boZ9xCFJR4DYc.json b/.omo/run-continuation/ses_15520a243ffe1boZ9xCFJR4DYc.json new file mode 100644 index 00000000..44b2a2f8 --- /dev/null +++ b/.omo/run-continuation/ses_15520a243ffe1boZ9xCFJR4DYc.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_15520a243ffe1boZ9xCFJR4DYc", + "updatedAt": "2026-06-09T05:37:52.672Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-06-09T05:37:52.672Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_1561201f3ffe5FecL1zDYz3OlR.json b/.omo/run-continuation/ses_1561201f3ffe5FecL1zDYz3OlR.json new file mode 100644 index 00000000..154a3d12 --- /dev/null +++ b/.omo/run-continuation/ses_1561201f3ffe5FecL1zDYz3OlR.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_1561201f3ffe5FecL1zDYz3OlR", + "updatedAt": "2026-06-09T01:14:05.782Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-06-09T01:14:05.782Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_1582a1b3fffeVytcZ1qXuOdHd0.json b/.omo/run-continuation/ses_1582a1b3fffeVytcZ1qXuOdHd0.json new file mode 100644 index 00000000..55eb9d92 --- /dev/null +++ b/.omo/run-continuation/ses_1582a1b3fffeVytcZ1qXuOdHd0.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_1582a1b3fffeVytcZ1qXuOdHd0", + "updatedAt": "2026-06-08T15:36:47.526Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-06-08T15:36:47.526Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_1582debb4ffe5n4umaN66NYAZ9.json b/.omo/run-continuation/ses_1582debb4ffe5n4umaN66NYAZ9.json new file mode 100644 index 00000000..36563288 --- /dev/null +++ b/.omo/run-continuation/ses_1582debb4ffe5n4umaN66NYAZ9.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_1582debb4ffe5n4umaN66NYAZ9", + "updatedAt": "2026-06-08T15:25:47.646Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-06-08T15:25:47.646Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_15835033effetw28iiOqzf40dW.json b/.omo/run-continuation/ses_15835033effetw28iiOqzf40dW.json new file mode 100644 index 00000000..8a648eb3 --- /dev/null +++ b/.omo/run-continuation/ses_15835033effetw28iiOqzf40dW.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_15835033effetw28iiOqzf40dW", + "updatedAt": "2026-06-09T07:34:08.125Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-06-09T07:34:08.125Z" + } + } +} \ No newline at end of file diff --git a/dependencies.gradle b/dependencies.gradle index 6c6ded8a..03b55aed 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -138,6 +138,5 @@ dependencies { // Immersive Aircraft modImplementation(ctnh.immersiveaircraft) - //emiaccelerator - modImplementation(ctnh.emiaccelerator) + // emiaccelerator is not defined in the version catalog. } diff --git a/src/generated/resources/assets/ctnhcore/lang/en_ud.json b/src/generated/resources/assets/ctnhcore/lang/en_ud.json index 6d975f10..c63f80b3 100644 --- a/src/generated/resources/assets/ctnhcore/lang/en_ud.json +++ b/src/generated/resources/assets/ctnhcore/lang/en_ud.json @@ -48,6 +48,21 @@ "block.ctnhcore.creative_item_input_bus": "snᗺ ʇnduI ɯǝʇI ǝʌıʇɐǝɹƆ", "block.ctnhcore.creative_laser_hatch": "ɥɔʇɐH ʇnduI ɹǝsɐꞀ ǝʌıʇɐǝɹƆ", "block.ctnhcore.cryotheum_freezer": "ɹǝzǝǝɹℲ ɯnǝɥʇoʎɹƆ", + "ctnhcore.emi.collapsible.button.collapse_all": ")s% 个分组(部全叠折:键左", + "ctnhcore.emi.collapsible.button.collapse_all.right_click": "部全叠折:键右", + "ctnhcore.emi.collapsible.button.expand_all": ")s% 个已折叠(部全开展:键左", + "ctnhcore.emi.collapsible.group.axes": "sǝxⱯ", + "ctnhcore.emi.collapsible.group.hoes": "sǝoH", + "ctnhcore.emi.collapsible.group.lingering_potions": "suoıʇoԀ ƃuıɹǝƃuıꞀ", + "ctnhcore.emi.collapsible.group.music_discs": "sɔsıᗡ ɔısnW", + "ctnhcore.emi.collapsible.group.pickaxes": "sǝxɐʞɔıԀ", + "ctnhcore.emi.collapsible.group.shovels": "sןǝʌoɥS", + "ctnhcore.emi.collapsible.group.spawn_eggs": "sƃƃƎ uʍɐdS", + "ctnhcore.emi.collapsible.group.spawners": "sɹǝuʍɐdS", + "ctnhcore.emi.collapsible.group.splash_potions": "suoıʇoԀ ɥsɐןdS", + "ctnhcore.emi.collapsible.group.swords": "spɹoʍS", + "ctnhcore.emi.collapsible.tooltip.group": "s%:组叠折", + "ctnhcore.emi.collapsible.tooltip.toggle_hint": "组此叠折/开展:键左 + tlA", "block.ctnhcore.crystallizer": "ɹǝzıןןɐʇsʎɹƆ", "block.ctnhcore.cultivationroom": "ɯooɹuoıʇɐʌıʇןnƆ", "block.ctnhcore.dark_blue_elevator_casing": "buısɐƆ ɹoʇɐʌǝןƎ ǝnןᗺ ʞɹɐᗡ", @@ -955,4 +970,4 @@ "tuff_uraninite_vein_ad": "pⱯ uıǝΛ ǝʇıuıuɐɹ∩ ɟɟn⟘", "uranium238_vein_ad": "pⱯ uıǝΛ 8Ɛᄅɯnıuɐɹ∩", "wollastonite_vein": "uıǝΛ ǝʇıuoʇsɐןןoM" -} \ No newline at end of file +} diff --git a/src/generated/resources/assets/ctnhcore/lang/en_us.json b/src/generated/resources/assets/ctnhcore/lang/en_us.json index a6fdbef7..49a55542 100644 --- a/src/generated/resources/assets/ctnhcore/lang/en_us.json +++ b/src/generated/resources/assets/ctnhcore/lang/en_us.json @@ -48,6 +48,21 @@ "block.ctnhcore.creative_item_input_bus": "Creative Item Input Bus", "block.ctnhcore.creative_laser_hatch": "Creative Laser Input Hatch", "block.ctnhcore.cryotheum_freezer": "Cryotheum Freezer", + "ctnhcore.emi.collapsible.button.collapse_all": "Left click: collapse all (%s groups)", + "ctnhcore.emi.collapsible.button.collapse_all.right_click": "Right click: collapse all", + "ctnhcore.emi.collapsible.button.expand_all": "Left click: expand all (%s collapsed)", + "ctnhcore.emi.collapsible.group.axes": "Axes", + "ctnhcore.emi.collapsible.group.hoes": "Hoes", + "ctnhcore.emi.collapsible.group.lingering_potions": "Lingering Potions", + "ctnhcore.emi.collapsible.group.music_discs": "Music Discs", + "ctnhcore.emi.collapsible.group.pickaxes": "Pickaxes", + "ctnhcore.emi.collapsible.group.shovels": "Shovels", + "ctnhcore.emi.collapsible.group.spawn_eggs": "Spawn Eggs", + "ctnhcore.emi.collapsible.group.spawners": "Spawners", + "ctnhcore.emi.collapsible.group.splash_potions": "Splash Potions", + "ctnhcore.emi.collapsible.group.swords": "Swords", + "ctnhcore.emi.collapsible.tooltip.group": "Collapsed group: %s", + "ctnhcore.emi.collapsible.tooltip.toggle_hint": "Alt + left click: expand/collapse this group", "block.ctnhcore.crystallizer": "Crystallizer", "block.ctnhcore.cultivationroom": "Cultivationroom", "block.ctnhcore.dark_blue_elevator_casing": "Dark Blue Elevator Casing", @@ -955,4 +970,4 @@ "tuff_uraninite_vein_ad": "Tuff Uraninite Vein Ad", "uranium238_vein_ad": "Uranium238 Vein Ad", "wollastonite_vein": "Wollastonite Vein" -} \ No newline at end of file +} diff --git a/src/generated/resources/assets/ctnhcore/lang/zh_cn.json b/src/generated/resources/assets/ctnhcore/lang/zh_cn.json index e68c7223..aa370919 100644 --- a/src/generated/resources/assets/ctnhcore/lang/zh_cn.json +++ b/src/generated/resources/assets/ctnhcore/lang/zh_cn.json @@ -48,6 +48,21 @@ "block.ctnhcore.creative_item_input_bus": "创造模式输入总线", "block.ctnhcore.creative_laser_hatch": "创造模式激光靶仓", "block.ctnhcore.cryotheum_freezer": "凛冰冷冻机", + "ctnhcore.emi.collapsible.button.collapse_all": "左键:折叠全部(%s 个分组)", + "ctnhcore.emi.collapsible.button.collapse_all.right_click": "右键:折叠全部", + "ctnhcore.emi.collapsible.button.expand_all": "左键:展开全部(%s 个已折叠)", + "ctnhcore.emi.collapsible.group.axes": "斧", + "ctnhcore.emi.collapsible.group.hoes": "锄", + "ctnhcore.emi.collapsible.group.lingering_potions": "滞留型药水", + "ctnhcore.emi.collapsible.group.music_discs": "音乐唱片", + "ctnhcore.emi.collapsible.group.pickaxes": "镐", + "ctnhcore.emi.collapsible.group.shovels": "锹", + "ctnhcore.emi.collapsible.group.spawn_eggs": "刷怪蛋", + "ctnhcore.emi.collapsible.group.spawners": "刷怪笼", + "ctnhcore.emi.collapsible.group.splash_potions": "喷溅型药水", + "ctnhcore.emi.collapsible.group.swords": "剑", + "ctnhcore.emi.collapsible.tooltip.group": "折叠组:%s", + "ctnhcore.emi.collapsible.tooltip.toggle_hint": "Alt + 左键:展开/折叠此组", "block.ctnhcore.crystallizer": "结晶器", "block.ctnhcore.cultivationroom": "培养室", "block.ctnhcore.dark_blue_elevator_casing": "深蓝色电梯机械方块", @@ -2058,4 +2073,4 @@ "uranium238_vein_ad": "铀238矿脉", "wollastonite_vein": "白云石矿脉", "zenith_machine_sp": "§5灵能灯塔屹立不倒!" -} \ No newline at end of file +} diff --git a/src/main/java/io/github/cpearl0/ctnhcore/data/lang/old/ChineseLangHandler.java b/src/main/java/io/github/cpearl0/ctnhcore/data/lang/old/ChineseLangHandler.java index dec79c9d..c27bacd1 100644 --- a/src/main/java/io/github/cpearl0/ctnhcore/data/lang/old/ChineseLangHandler.java +++ b/src/main/java/io/github/cpearl0/ctnhcore/data/lang/old/ChineseLangHandler.java @@ -46,6 +46,16 @@ public static void init(RegistrateCNLangProvider provider) { "缺少足够能量以启动核聚变反应"); provider.add("gtceu.recipe_modifier.coil_temperature_too_low", "线圈温度过低!"); + provider.add("ctnhcore.emi.collapsible.group.logs", "原木"); + provider.add("ctnhcore.emi.collapsible.group.stairs", "楼梯"); + provider.add("ctnhcore.emi.collapsible.group.slabs", "台阶"); + provider.add("ctnhcore.emi.collapsible.group.fences", "栅栏"); + provider.add("ctnhcore.emi.collapsible.group.fence_gates", "栅栏门"); + provider.add("ctnhcore.emi.collapsible.group.doors", "门"); + provider.add("ctnhcore.emi.collapsible.group.trapdoors", "活板门"); + provider.add("ctnhcore.emi.collapsible.group.pressure_plates", "压力板"); + provider.add("ctnhcore.emi.collapsible.group.buttons", "按钮"); + // Config provider.add("config.ctnhcore.option.ftbPlugin", "FTB相关"); provider.add("config.ctnhcore.option.kinetic", "应力相关"); diff --git a/src/main/java/io/github/cpearl0/ctnhcore/data/lang/old/EnglishLangHandler.java b/src/main/java/io/github/cpearl0/ctnhcore/data/lang/old/EnglishLangHandler.java index ce7e6750..b05eba97 100644 --- a/src/main/java/io/github/cpearl0/ctnhcore/data/lang/old/EnglishLangHandler.java +++ b/src/main/java/io/github/cpearl0/ctnhcore/data/lang/old/EnglishLangHandler.java @@ -33,6 +33,16 @@ public static void init(RegistrateLangProvider provider) { provider.add("config.jade.plugin_ctnhcore.recipe_logic_provider", "Recipe Logic Info"); provider.add("config.jade.plugin_ctnhcore.recipe_output_provider", "Recipe Output Info"); + provider.add("ctnhcore.emi.collapsible.group.logs", "Logs"); + provider.add("ctnhcore.emi.collapsible.group.stairs", "Stairs"); + provider.add("ctnhcore.emi.collapsible.group.slabs", "Slabs"); + provider.add("ctnhcore.emi.collapsible.group.fences", "Fences"); + provider.add("ctnhcore.emi.collapsible.group.fence_gates", "Fence Gates"); + provider.add("ctnhcore.emi.collapsible.group.doors", "Doors"); + provider.add("ctnhcore.emi.collapsible.group.trapdoors", "Trapdoors"); + provider.add("ctnhcore.emi.collapsible.group.pressure_plates", "Pressure Plates"); + provider.add("ctnhcore.emi.collapsible.group.buttons", "Buttons"); + // Recipe Types provider.add("gtceu.underfloor_heating_system", "Underfloor Heating"); provider.add("gtceu.astronomical_observatory", "Astronomical Observatory"); diff --git a/src/main/java/io/github/cpearl0/ctnhcore/mixin/emi/EmiScreenManagerInputMixin.java b/src/main/java/io/github/cpearl0/ctnhcore/mixin/emi/EmiScreenManagerInputMixin.java new file mode 100644 index 00000000..398d1b9e --- /dev/null +++ b/src/main/java/io/github/cpearl0/ctnhcore/mixin/emi/EmiScreenManagerInputMixin.java @@ -0,0 +1,245 @@ +package io.github.cpearl0.ctnhcore.mixin.emi; + +import io.github.cpearl0.ctnhcore.utils.emi.collapsible.CTNHCollapsibleGroups; +import io.github.cpearl0.ctnhcore.utils.emi.collapsible.CTNHCollapsibleGroups.CollapsibleGroup; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.inventory.tooltip.ClientTooltipComponent; +import net.minecraft.network.chat.Component; + +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import dev.emi.emi.api.stack.EmiIngredient; +import dev.emi.emi.api.stack.EmiStackInteraction; +import dev.emi.emi.config.SidebarType; +import dev.emi.emi.runtime.EmiDrawContext; +import dev.emi.emi.runtime.EmiSidebars; +import dev.emi.emi.screen.EmiScreenBase; +import dev.emi.emi.screen.EmiScreenManager; +import dev.emi.emi.screen.widget.EmiSearchWidget; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import java.util.ArrayList; +import java.util.List; + +/** + * 注入 EMI 的 {@link EmiScreenManager},负责折叠组的输入与提示层逻辑。 + * + *

+ * 本 mixin 不决定哪些物品属于同一组;分组数据由 {@link CTNHCollapsibleGroups} 管理。 + * 这里负责把玩家输入和 EMI 生命周期事件转发给分组管理器,包括: + *

+ * + */ +@Mixin(value = EmiScreenManager.class, remap = false) +public class EmiScreenManagerInputMixin { + + /** EMI 原生搜索框实例,用于把 G 按钮定位到搜索框右侧。 */ + @Shadow + public static EmiSearchWidget search; + + /** G 按钮边长,和 EMI 侧栏单格尺寸保持接近。 */ + @Unique + private static final int TOGGLE_BUTTON_SIZE = 16; + + /** G 按钮与搜索框之间的水平间距。 */ + @Unique + private static final int TOGGLE_BUTTON_GAP = 4; + + /** G 按钮左上角 X 坐标;-1 表示当前没有可点击按钮。 */ + @Unique + private static int ctnhcore$toggleBtnX = -1; + + /** G 按钮左上角 Y 坐标;-1 表示当前没有可点击按钮。 */ + @Unique + private static int ctnhcore$toggleBtnY = -1; + + /** 鼠标当前是否悬停在 G 按钮上,用于按钮高亮和 tooltip。 */ + @Unique + private static boolean ctnhcore$hoveredToggleBtn = false; + + /** + * 处理折叠组相关鼠标点击。 + * + *

+ * 注入在 {@code mouseClicked} 开头,先于 EMI 原生点击逻辑执行。这样当玩家点击 G 按钮 + * 或 Alt + 左键点击分组项时,可以消费本次事件,避免 EMI 同时打开配方或执行其他默认动作。 + *

+ */ + @Inject(method = "mouseClicked", at = @At("HEAD"), cancellable = true) + private static void ctnhcore$handleMouseClicked(double mouseX, double mouseY, int button, + CallbackInfoReturnable cir) { + if (!CTNHCollapsibleGroups.isEnabled()) return; + if (CTNHCollapsibleGroups.needsRebuild()) return; + if (!CTNHCollapsibleGroups.hasGroups()) return; + + int mx = (int) mouseX; + int my = (int) mouseY; + + // 先检查 G 按钮,避免按钮区域被误判为侧栏物品点击。 + if (ctnhcore$toggleBtnX >= 0 && ctnhcore$toggleBtnY >= 0 && mx >= ctnhcore$toggleBtnX && + mx < ctnhcore$toggleBtnX + TOGGLE_BUTTON_SIZE && my >= ctnhcore$toggleBtnY && + my < ctnhcore$toggleBtnY + TOGGLE_BUTTON_SIZE) { + if (button == 0) { + CTNHCollapsibleGroups.toggleAll(false); + } else if (button == 1) { + CTNHCollapsibleGroups.toggleAll(true); + } + EmiScreenManager.repopulatePanels(SidebarType.INDEX); + cir.setReturnValue(true); + return; + } + + // Alt + 左键点击 INDEX 侧栏中的分组项时,切换该项所属分组。 + if (button == 0 && Screen.hasAltDown()) { + EmiStackInteraction interaction = EmiScreenManager.getHoveredStack(mx, my, false); + EmiIngredient hovered = interaction.getStack(); + if (!hovered.isEmpty()) { + if (CTNHCollapsibleGroups.toggleGroup(hovered)) { + EmiScreenManager.repopulatePanels(SidebarType.INDEX); + cir.setReturnValue(true); + } + } + } + } + + /** + * 在 EMI 小部件渲染结束后绘制 G 按钮。 + * + *

+ * 按钮位于实时搜索框右侧。左键为智能展开/折叠全部:存在折叠组时展开全部, + * 否则折叠全部;右键始终折叠全部。没有有效分组时会隐藏按钮并清空 hover 状态。 + *

+ */ + @Inject(method = "renderWidgets", at = @At("TAIL")) + private static void ctnhcore$renderToggleButton(EmiDrawContext context, int mouseX, int mouseY, + float delta, EmiScreenBase base, + CallbackInfo ci) { + if (!CTNHCollapsibleGroups.isEnabled()) return; + if (CTNHCollapsibleGroups.needsRebuild()) return; + if (!CTNHCollapsibleGroups.hasGroups()) { + ctnhcore$toggleBtnX = -1; + ctnhcore$toggleBtnY = -1; + ctnhcore$hoveredToggleBtn = false; + return; + } + + if (base == null || search == null) return; + ctnhcore$toggleBtnX = search.getX() + search.getWidth() + TOGGLE_BUTTON_GAP; + ctnhcore$toggleBtnY = search.getY(); + + int x = ctnhcore$toggleBtnX; + int y = ctnhcore$toggleBtnY; + + GuiGraphics graphics = context.raw(); + + ctnhcore$hoveredToggleBtn = mouseX >= x && mouseX < x + TOGGLE_BUTTON_SIZE && mouseY >= y && + mouseY < y + TOGGLE_BUTTON_SIZE; + + int bgColor = ctnhcore$hoveredToggleBtn ? 0xFF444444 : 0xFF333333; + graphics.fill(x, y, x + TOGGLE_BUTTON_SIZE, y + TOGGLE_BUTTON_SIZE, bgColor); + + int borderColor = ctnhcore$hoveredToggleBtn ? 0xFF888888 : 0xFF555555; + graphics.fill(x, y, x + TOGGLE_BUTTON_SIZE, y + 1, borderColor); + graphics.fill(x, y + TOGGLE_BUTTON_SIZE - 1, x + TOGGLE_BUTTON_SIZE, y + TOGGLE_BUTTON_SIZE, borderColor); + graphics.fill(x, y, x + 1, y + TOGGLE_BUTTON_SIZE, borderColor); + graphics.fill(x + TOGGLE_BUTTON_SIZE - 1, y, x + TOGGLE_BUTTON_SIZE, y + TOGGLE_BUTTON_SIZE, borderColor); + + int collapsedCount = CTNHCollapsibleGroups.collapsedGroupCount(); + int textColor = collapsedCount > 0 ? 0xFF88FF88 : 0xFF888888; + graphics.drawString(Minecraft.getInstance().font, "G", x + 4, y + 4, textColor, false); + + if (ctnhcore$hoveredToggleBtn) { + int totalCount = CTNHCollapsibleGroups.totalGroupCount(); + if (collapsedCount > 0) { + graphics.renderComponentTooltip(Minecraft.getInstance().font, + List.of( + Component.translatable("ctnhcore.emi.collapsible.button.expand_all", collapsedCount), + Component.translatable("ctnhcore.emi.collapsible.button.collapse_all.right_click")), + x, y + TOGGLE_BUTTON_SIZE + 4); + } else { + graphics.renderComponentTooltip(Minecraft.getInstance().font, + List.of( + Component.translatable("ctnhcore.emi.collapsible.button.collapse_all", totalCount), + Component.translatable("ctnhcore.emi.collapsible.button.collapse_all.right_click")), + x, y + TOGGLE_BUTTON_SIZE + 4); + } + } + } + + /** + * 在 EMI 获取搜索来源后触发折叠组重建。 + * + *

+ * 这里故意使用 {@link EmiSidebars#getStacks(SidebarType)} 的 INDEX 完整来源,而不是搜索过滤后的 + * 返回值。这样分组成员来自完整 EMI 列表,搜索时再由投影逻辑只显示当前搜索结果中存在的成员。 + *

+ */ + @Inject(method = "getSearchSource", at = @At("RETURN")) + private static void ctnhcore$rebuildOnSearch(CallbackInfoReturnable> cir) { + if (!CTNHCollapsibleGroups.isEnabled()) return; + if (CTNHCollapsibleGroups.needsRebuild()) { + List source = EmiSidebars.getStacks(SidebarType.INDEX); + if (source != null && !source.isEmpty()) { + CTNHCollapsibleGroups.rebuild(source); + } + } + } + + /** + * 当 EMI 可见性切换时标记分组为脏。 + * + *

+ * 可见性切换常伴随 EMI 重载、关闭或重新打开。标记 dirty 后,下次获取搜索来源时会重新扫描列表, + * 避免继续使用旧的 ingredient 对象身份映射。 + *

+ */ + @Inject(method = "toggleVisibility", at = @At("HEAD")) + private static void ctnhcore$markDirtyOnToggle(boolean notify, CallbackInfo ci) { + CTNHCollapsibleGroups.markDirty(); + } + + /** + * 包装 EMI 原生 tooltip,给折叠代表项追加分组信息。 + * + *

+ * 只有当前 ingredient 是折叠代表项时才追加文本。展开后的普通成员不追加,避免 tooltip 噪音。 + * 追加内容包括分组显示名和 Alt + 左键展开/折叠提示。 + *

+ */ + @WrapOperation(method = "renderCurrentTooltip", + at = @At(value = "INVOKE", + target = "Ldev/emi/emi/api/stack/EmiIngredient;getTooltip()Ljava/util/List;")) + private static List ctnhcore$wrapGroupTooltip(EmiIngredient instance, + Operation> original) { + List list = original.call(instance); + if (CTNHCollapsibleGroups.needsRebuild() || !CTNHCollapsibleGroups.isEnabled()) return list; + if (CTNHCollapsibleGroups.collapsedGroupCount() == 0) return list; + + CollapsibleGroup group = CTNHCollapsibleGroups.getGroup(instance); + if (group != null && CTNHCollapsibleGroups.isCollapsedRepresentative(instance)) { + List modified = new ArrayList<>(list.size() + 2); + modified.addAll(list); + modified.add(ClientTooltipComponent.create( + Component.translatable("ctnhcore.emi.collapsible.tooltip.group", group.displayName) + .getVisualOrderText())); + modified.add(ClientTooltipComponent.create( + Component.translatable("ctnhcore.emi.collapsible.tooltip.toggle_hint").getVisualOrderText())); + return modified; + } + return list; + } +} diff --git a/src/main/java/io/github/cpearl0/ctnhcore/mixin/emi/EmiScreenManagerScreenSpaceMixin.java b/src/main/java/io/github/cpearl0/ctnhcore/mixin/emi/EmiScreenManagerScreenSpaceMixin.java new file mode 100644 index 00000000..79d00446 --- /dev/null +++ b/src/main/java/io/github/cpearl0/ctnhcore/mixin/emi/EmiScreenManagerScreenSpaceMixin.java @@ -0,0 +1,297 @@ +package io.github.cpearl0.ctnhcore.mixin.emi; + +import io.github.cpearl0.ctnhcore.utils.emi.collapsible.CTNHCollapsibleGroups; +import io.github.cpearl0.ctnhcore.utils.emi.collapsible.CTNHCollapsibleGroups.CollapsibleGroup; + +import net.minecraft.client.gui.GuiGraphics; + +import dev.emi.emi.api.stack.EmiIngredient; +import dev.emi.emi.config.SidebarType; +import dev.emi.emi.runtime.EmiDrawContext; +import dev.emi.emi.screen.EmiScreenManager; +import dev.emi.emi.screen.StackBatcher; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.Redirect; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import com.mojang.blaze3d.systems.RenderSystem; + +import java.util.ArrayList; +import java.util.List; + +/** + * 注入 EMI 侧栏空间 {@link EmiScreenManager.ScreenSpace},负责折叠组的显示层改写。 + * + *

+ * 本 mixin 只作用于 INDEX 搜索侧栏。它会在 EMI 读取待显示列表时调用 + * {@link CTNHCollapsibleGroups#project(List)},把已折叠分组压缩成一个代表项;随后在渲染阶段 + * 给折叠代表项和展开成员绘制边框,帮助玩家看出这些物品属于同一个折叠组。 + *

+ */ +@Mixin(value = EmiScreenManager.ScreenSpace.class, remap = false) +public abstract class EmiScreenManagerScreenSpaceMixin { + + /** EMI 原字段:当前 ScreenSpace 是否处于搜索/索引侧栏模式。 */ + @Shadow + @Final + public boolean search; + + /** EMI 原方法:返回该侧栏空间的类型,例如 INDEX 或 FAVORITES。 */ + @Shadow + public abstract SidebarType getType(); + + /** EMI 原字段:侧栏渲染起点 X。当前类保留 shadow 以匹配原布局上下文。 */ + @Shadow + @Final + public int tx; + + /** EMI 原字段:侧栏渲染起点 Y。当前类保留 shadow 以匹配原布局上下文。 */ + @Shadow + @Final + public int ty; + + /** EMI 原字段:侧栏网格高度,按 18x18 单元格行数计算。 */ + @Shadow + @Final + public int th; + + /** EMI 原字段:每行可显示的单元格宽度数组。 */ + @Shadow + @Final + public int[] widths; + + /** EMI 原方法:取得指定行可显示的单元格数量。 */ + @Shadow + public abstract int getWidth(int y); + + /** EMI 原方法:把网格列/行坐标转换成屏幕 X 坐标。 */ + @Shadow + public abstract int getX(int x, int y); + + /** EMI 原方法:把网格列/行坐标转换成屏幕 Y 坐标。 */ + @Shadow + public abstract int getY(int x, int y); + + /** EMI 原字段:当前页最多渲染多少个 ingredient。 */ + @Shadow + @Final + public int pageSize; + + /** EMI 原方法:返回当前侧栏空间要显示的 ingredient 列表。 */ + @Shadow + public abstract List getStacks(); + + /** EMI 原字段:侧栏使用的批量物品渲染器。 */ + @Shadow + @Final + public StackBatcher batcher; + + /** EMI 侧栏单格尺寸。 */ + @Unique + private static final int ENTRY_SIZE = 18; + + /** 展开分组成员的外边框颜色。GuiGraphics.fill 使用 ABGR 格式。 */ + @Unique + private static final int GROUP_BORDER_COLOR = 0xCC3344AA; + + /** 展开分组成员的半透明背景颜色。 */ + @Unique + private static final int GROUP_BG_COLOR = 0x44113377; + + /** 折叠代表项边框颜色。这里只画边框,不覆盖 EMI 原本的物品图标。 */ + @Unique + private static final int COLLAPSED_BORDER_COLOR = 0xCC4466CC; + + /** 后方叠层图标透明度。 */ + @Unique + private static final float STACKED_BACK_ICON_ALPHA = 0.45F; + + /** 后方叠层图标遮罩,让不吃 shader alpha 的 item 渲染路径也能显得更靠后。 */ + @Unique + private static final int STACKED_BACK_ICON_DIM_COLOR = 0x77000000; + + /** + * 在 EMI 返回侧栏列表后替换为折叠投影列表。 + * + *

+ * 只处理 INDEX 搜索侧栏,避免影响收藏夹等其他侧栏。分组尚未重建或没有有效分组时, + * 保持 EMI 原始列表不变。 + *

+ */ + @Inject(method = "getStacks", at = @At("RETURN"), cancellable = true) + private void ctnhcore$projectGetStacks(CallbackInfoReturnable> cir) { + if (!CTNHCollapsibleGroups.isEnabled()) return; + if (search && getType() == SidebarType.INDEX) { + List original = cir.getReturnValue(); + if (original == null || original.isEmpty()) return; + if (!CTNHCollapsibleGroups.needsRebuild() && CTNHCollapsibleGroups.hasGroups()) { + cir.setReturnValue(CTNHCollapsibleGroups.project(original)); + } + } + } + + /** + * 折叠代表项由本 mixin 在批量绘制完成后手动画双层图标。 + * + *

+ * 跳过 EMI 原始的单图标绘制,避免“原图标 + 双层图标”三层重叠。 + *

+ */ + @Redirect(method = "render", + at = @At(value = "INVOKE", + target = "Ldev/emi/emi/screen/StackBatcher;render(Ldev/emi/emi/api/stack/EmiIngredient;Lnet/minecraft/client/gui/GuiGraphics;IIF)V")) + private void ctnhcore$skipCollapsedRepresentativeOriginalIcon(StackBatcher instance, EmiIngredient stack, + GuiGraphics draw, int x, int y, float delta) { + if (CTNHCollapsibleGroups.isEnabled() && search && getType() == SidebarType.INDEX && + !CTNHCollapsibleGroups.needsRebuild() && CTNHCollapsibleGroups.isCollapsedRepresentative(stack)) { + return; + } + instance.render(stack, draw, x, y, delta); + } + + /** + * 在 EMI 批量绘制物品图标后绘制折叠组叠加层。 + * + *

+ * 折叠代表项只画蓝色边框,保留 EMI 原本渲染出的代表物品图标。展开成员会绘制淡色背景, + * 并根据相邻格子是否属于同一组来决定哪些边需要画,从而形成连贯的分组区域。 + *

+ */ + @Inject(method = "render", + at = @At(value = "INVOKE", + target = "Ldev/emi/emi/screen/StackBatcher;draw()V", + shift = At.Shift.AFTER)) + private void ctnhcore$renderGroupOverlays(EmiDrawContext context, int mouseX, int mouseY, + float delta, int startIndex, CallbackInfo ci) { + if (!CTNHCollapsibleGroups.isEnabled()) return; + if (!search || getType() != SidebarType.INDEX) return; + if (CTNHCollapsibleGroups.needsRebuild()) return; + + List stacks = getStacks(); + if (stacks == null || stacks.isEmpty()) return; + + GuiGraphics graphics = context.raw(); + int endIndex = Math.min(startIndex + pageSize, stacks.size()); + + List expandedXos = new ArrayList<>(); + List expandedYos = new ArrayList<>(); + List expandedCxs = new ArrayList<>(); + List expandedCys = new ArrayList<>(); + List expandedGroupGuids = new ArrayList<>(); + + int ri = startIndex; + outer: + for (int yo = 0; yo < th; yo++) { + for (int xo = 0; xo < getWidth(yo); xo++) { + if (ri >= endIndex) break outer; + EmiIngredient stack = stacks.get(ri); + ri++; + + CollapsibleGroup group = CTNHCollapsibleGroups.getGroup(stack); + if (group == null) continue; + + int cx = getX(xo, yo); + int cy = getY(xo, yo); + + if (CTNHCollapsibleGroups.isCollapsedRepresentative(stack)) { + drawCollapsedGroupStack(context, stack, cx, cy); + drawCollapsedGroupOverlay(graphics, cx, cy); + } else { + expandedXos.add(xo); + expandedYos.add(yo); + expandedCxs.add(cx); + expandedCys.add(cy); + expandedGroupGuids.add(group.guid); + } + } + } + + for (int index = 0; index < expandedCxs.size(); index++) { + drawExpandedMemberCell(graphics, index, expandedXos, expandedYos, expandedCxs, expandedCys, + expandedGroupGuids); + } + } + + /** 为折叠代表项绘制单格边框。 */ + @Unique + private void drawCollapsedGroupOverlay(GuiGraphics graphics, int cx, int cy) { + graphics.fill(cx, cy, cx + ENTRY_SIZE, cy + 1, COLLAPSED_BORDER_COLOR); + graphics.fill(cx, cy + ENTRY_SIZE - 1, cx + ENTRY_SIZE, cy + ENTRY_SIZE, COLLAPSED_BORDER_COLOR); + graphics.fill(cx, cy, cx + 1, cy + ENTRY_SIZE, COLLAPSED_BORDER_COLOR); + graphics.fill(cx + ENTRY_SIZE - 1, cy, cx + ENTRY_SIZE, cy + ENTRY_SIZE, COLLAPSED_BORDER_COLOR); + } + + /** 为折叠代表项补画第二层图标,接近 GTNH NEI 的堆叠视觉。 */ + @Unique + private void drawCollapsedGroupStack(EmiDrawContext context, EmiIngredient representative, int cx, int cy) { + EmiIngredient secondary = CTNHCollapsibleGroups.getSecondaryRepresentative(representative); + if (secondary == null || secondary.isEmpty()) return; + + context.raw().pose().pushPose(); + context.raw().pose().translate(0, 0, -50); + RenderSystem.enableBlend(); + RenderSystem.defaultBlendFunc(); + RenderSystem.setShaderColor(1.0F, 1.0F, 1.0F, STACKED_BACK_ICON_ALPHA); + context.drawStack(secondary, cx + 2, cy - 2, EmiIngredient.RENDER_ICON); + RenderSystem.setShaderColor(1.0F, 1.0F, 1.0F, 1.0F); + context.raw().fill(cx + 2, cy - 2, cx + 18, cy + 14, STACKED_BACK_ICON_DIM_COLOR); + context.raw().pose().popPose(); + context.drawStack(representative, cx - 1, cy + 1, EmiIngredient.RENDER_ICON); + } + + /** + * 为展开状态下的单个成员绘制背景和外边框。 + * + *

+ * 如果上下左右存在同组相邻成员,则省略共享边,视觉上形成连续的分组块。 + *

+ */ + @Unique + private void drawExpandedMemberCell(GuiGraphics graphics, int index, List xos, List yos, + List cxs, List cys, List groupGuids) { + int cx = cxs.get(index); + int cy = cys.get(index); + graphics.fill(cx + 1, cy + 1, cx + ENTRY_SIZE - 1, cy + ENTRY_SIZE - 1, GROUP_BG_COLOR); + + if (!hasNeighbor(index, xos, yos, groupGuids, 0, -1)) { + graphics.fill(cx, cy, cx + ENTRY_SIZE, cy + 1, GROUP_BORDER_COLOR); + } + if (!hasNeighbor(index, xos, yos, groupGuids, 0, 1)) { + graphics.fill(cx, cy + ENTRY_SIZE - 1, cx + ENTRY_SIZE, cy + ENTRY_SIZE, GROUP_BORDER_COLOR); + } + if (!hasNeighbor(index, xos, yos, groupGuids, -1, 0)) { + graphics.fill(cx, cy, cx + 1, cy + ENTRY_SIZE, GROUP_BORDER_COLOR); + } + if (!hasNeighbor(index, xos, yos, groupGuids, 1, 0)) { + graphics.fill(cx + ENTRY_SIZE - 1, cy, cx + ENTRY_SIZE, cy + ENTRY_SIZE, GROUP_BORDER_COLOR); + } + } + + /** + * 判断指定方向上是否存在同组相邻格子。 + * + * @param index 当前成员在临时坐标列表中的索引 + * @param dx 横向偏移,-1 表示左邻居,1 表示右邻居 + * @param dy 纵向偏移,-1 表示上邻居,1 表示下邻居 + * @return true 表示相邻格子存在且属于同一分组 + */ + @Unique + private boolean hasNeighbor(int index, List xos, List yos, List groupGuids, int dx, + int dy) { + int nx = xos.get(index) + dx; + int ny = yos.get(index) + dy; + String groupGuid = groupGuids.get(index); + for (int other = 0; other < xos.size(); other++) { + if (xos.get(other) == nx && yos.get(other) == ny && groupGuids.get(other).equals(groupGuid)) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/io/github/cpearl0/ctnhcore/utils/emi/collapsible/CTNHCollapsibleGroups.java b/src/main/java/io/github/cpearl0/ctnhcore/utils/emi/collapsible/CTNHCollapsibleGroups.java new file mode 100644 index 00000000..f7f02ecb --- /dev/null +++ b/src/main/java/io/github/cpearl0/ctnhcore/utils/emi/collapsible/CTNHCollapsibleGroups.java @@ -0,0 +1,1021 @@ +package io.github.cpearl0.ctnhcore.utils.emi.collapsible; + +import io.github.cpearl0.ctnhcore.CTNHCore; + +import net.minecraft.core.registries.Registries; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.tags.TagKey; +import net.minecraft.world.item.BlockItem; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.block.Block; +import net.minecraftforge.fml.loading.FMLPaths; +import net.minecraftforge.registries.ForgeRegistries; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import dev.emi.emi.api.stack.EmiIngredient; +import dev.emi.emi.api.stack.EmiStack; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +/** + * EMI 侧栏折叠组的核心管理类。 + * + *

+ * 本类负责三件事: + *

+ *
    + *
  • 根据 EMI 侧栏提供的 {@link EmiIngredient} 列表注册折叠组。
  • + *
  • 在侧栏渲染前把原始列表投影成“折叠代表项”或“展开成员列表”。
  • + *
  • 把每个分组的展开/折叠状态保存到配置目录,保证重启后状态仍然保留。
  • + *
+ * + *

+ * 分组规则从 config/ctnhcore/emi_collapsible_groups.json 读取,支持 item/block tag、物品 id 正则和 + * 一组接近 GTNH NEI collapsibleitems.cfg 的简化 item filter 语法。 + *

+ */ +public class CTNHCollapsibleGroups { + + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + /** 折叠组展开状态的持久化文件,位于 config/ctnhcore/collapsible_emi_groups.json。 */ + private static final Path STATE_FILE = FMLPaths.CONFIGDIR.get().resolve("ctnhcore/collapsible_emi_groups.json"); + + /** 折叠组规则定义文件,位于 config/ctnhcore/emi_collapsible_groups.json。 */ + private static final Path RULE_FILE = FMLPaths.CONFIGDIR.get().resolve("ctnhcore/emi_collapsible_groups.json"); + + /** 兼容 GTNH/NEI collapsibleitems.cfg 风格的一行一组配置。 */ + private static final Path LEGACY_RULE_FILE = FMLPaths.CONFIGDIR.get().resolve("ctnhcore/collapsibleitems.cfg"); + + /** 总开关。关闭后不会重建、投影或响应折叠组交互。 */ + private static boolean enabled = true; + + /** 标记 EMI 来源列表是否需要重新扫描并重建分组。 */ + private static boolean dirty = true; + + /** 防止每次重建都重复读取状态文件。 */ + private static boolean statesLoaded = false; + + /** guid -> 分组对象。使用 LinkedHashMap 保持注册顺序,从而稳定投影顺序。 */ + private static final Map GROUPS = Collections.synchronizedMap(new LinkedHashMap<>()); + + /** EMI ingredient 对象身份 -> 分组 guid。这里依赖 EMI 当前侧栏列表中的对象身份。 */ + private static final Map STACK_TO_GROUP = new IdentityHashMap<>(); + + /** 分组 guid -> 是否展开。没有记录时默认视为折叠。 */ + private static final Map EXPANDED_STATE = new HashMap<>(); + + /** 当前投影列表中的折叠代表项 -> 分组 guid,用于 tooltip、边框和点击切换。 */ + private static final IdentityHashMap REPRESENTATIVE_TO_GROUP = new IdentityHashMap<>(); + + /** 从 JSON/legacy 配置读取并编译后的分组注册器。 */ + private static List configuredProviders = List.of(); + + /** 防止每次重建都重复读取规则文件。 */ + private static boolean rulesLoaded = false; + + /** + * 一个可折叠的 EMI 侧栏分组。 + * + *

+ * 分组保存显示名、成员列表、代表项以及展开状态。实际渲染仍由 EMI 完成, + * 本对象只描述“哪些 ingredient 属于同一组”和“当前应该折叠还是展开”。 + *

+ */ + public static class CollapsibleGroup { + + /** 分组唯一标识,同时作为持久化状态的 key。 */ + public final String guid; + + /** tooltip 中显示的分组名称。新增分组时应使用 Component.translatable。 */ + public Component displayName; + + /** 当前 EMI 来源列表中属于该分组的成员,顺序沿用 EMI 侧栏原始顺序。 */ + public final List members; + + /** 分组的主代表项,通常是成员列表中的第一个 ingredient。 */ + public EmiIngredient primaryRepresentative; + + /** 分组的次代表项,折叠渲染时用于画出类似 GTNH NEI 的叠层图标。 */ + public EmiIngredient secondaryRepresentative; + + /** + * 创建一个空分组。 + * + * @param guid 分组唯一标识,建议使用命名空间格式,例如 ctnhcore:tools/swords + */ + public CollapsibleGroup(String guid) { + this.guid = guid; + this.members = new ArrayList<>(); + this.displayName = Component.literal(guid); + this.primaryRepresentative = null; + this.secondaryRepresentative = null; + } + + /** + * 查询分组是否处于展开状态。 + * + * @return true 表示投影时显示所有成员,false 表示只显示一个代表项 + */ + public boolean isExpanded() { + Boolean state = EXPANDED_STATE.get(guid); + return state != null && state; + } + + /** + * 设置分组展开状态,并立即保存到配置文件。 + * + * @param expanded true 为展开,false 为折叠 + */ + public void setExpanded(boolean expanded) { + EXPANDED_STATE.put(guid, expanded); + saveStates(); + } + + /** + * 返回成员数量,用于 UI 统计或判断分组是否值得折叠。 + */ + public int memberCount() { + return members.size(); + } + } + + // ---- 公共 API:供 EMI mixin 查询状态、触发重建和响应交互 ---- + + /** 返回折叠功能总开关是否启用。 */ + public static boolean isEnabled() { + return enabled; + } + + /** 设置折叠功能总开关。 */ + public static void setEnabled(boolean e) { + enabled = e; + } + + /** 返回当前 EMI 来源列表是否需要重新扫描。 */ + public static boolean needsRebuild() { + return dirty; + } + + /** 标记分组需要重建,通常在 EMI 可见性变化或列表来源变化时调用。 */ + public static void markDirty() { + dirty = true; + } + + /** + * 根据 EMI 侧栏完整来源列表重建所有折叠组。 + * + *

+ * 重建会清空旧的对象身份映射,再按当前列表重新注册成员。这样能保证搜索、重载、 + * EMI 刷新后,{@link IdentityHashMap} 中保存的都是当前侧栏正在使用的 ingredient 对象。 + *

+ * + * @param stacks EMI INDEX 侧栏的完整 ingredient 来源列表 + */ + public static void rebuild(List stacks) { + synchronized (GROUPS) { + loadStates(); + GROUPS.clear(); + STACK_TO_GROUP.clear(); + REPRESENTATIVE_TO_GROUP.clear(); + if (!enabled || stacks == null || stacks.isEmpty()) { + dirty = false; + return; + } + + loadRules(); + registerConfiguredGroups(stacks); + dirty = false; + } + } + + /** + * 注册 JSON 配置中声明的折叠分组。 + * + *

+ * 每个 ingredient 只会进入最高优先级的命中组;同优先级时保持 provider 注册顺序。 + *

+ */ + private static void registerConfiguredGroups(List stacks) { + List validProviders = new ArrayList<>(); + for (CollapsibleGroupProvider provider : configuredProviders) { + if (provider.priority() < 0) { + CTNHCore.LOGGER.warn("Skipping EMI collapsible group {} because priority must be non-negative", + provider.guid()); + continue; + } + validProviders.add(provider); + } + + Map groups = new IdentityHashMap<>(); + for (CollapsibleGroupProvider provider : validProviders) { + CollapsibleGroup group = provider.createGroup(); + groups.put(provider, group); + } + + for (EmiIngredient ingredient : stacks) { + CollapsibleGroupProvider bestProvider = null; + for (CollapsibleGroupProvider provider : validProviders) { + if (!provider.matches(ingredient)) continue; + if (bestProvider == null || provider.priority() > bestProvider.priority()) { + bestProvider = provider; + } + } + if (bestProvider != null) { + groups.get(bestProvider).members.add(ingredient); + } + } + + for (CollapsibleGroupProvider provider : validProviders) { + registerGroup(groups.get(provider)); + } + } + + /** + * 根据分组 guid 生成显示名。 + * + *

+ * 默认工具组沿用已有语言 key,例如 ctnhcore:tools/swords 会尝试读取 + * ctnhcore.emi.collapsible.group.swords。自定义组没有语言条目时显示 guid 本身。 + *

+ */ + private static Component displayNameForGroup(String guid) { + CollapsibleGroupProvider provider = findProvider(guid); + if (provider != null) return provider.createDisplayName(); + return fallbackDisplayName(guid); + } + + private static Component fallbackDisplayName(String guid) { + ResourceLocation id = ResourceLocation.tryParse(guid); + String fallback = guid; + String path = id == null ? guid : id.getPath(); + int slash = path.lastIndexOf('/'); + String name = slash >= 0 ? path.substring(slash + 1) : path; + return Component.translatableWithFallback("ctnhcore.emi.collapsible.group." + name, fallback); + } + + /** + * 将构造完成的分组写入全局索引。 + * + *

+ * 少于两个成员的分组没有折叠价值,因此不会注册。注册成功后会建立 member -> guid 映射, + * 后续投影、tooltip 和点击逻辑都依赖这个映射。 + *

+ */ + private static void registerGroup(CollapsibleGroup group) { + if (group.members.size() < 2) return; + + group.primaryRepresentative = group.members.get(0); + if (group.members.size() > 1) { + group.secondaryRepresentative = group.members.get(1); + } + GROUPS.put(group.guid, group); + for (EmiIngredient member : group.members) { + STACK_TO_GROUP.put(member, group.guid); + } + } + + /** + * 将 EMI 原始侧栏列表投影成实际显示列表。 + * + *

+ * 未分组项会原样通过;展开分组会显示所有仍存在于当前 source 中的成员; + * 折叠分组只显示当前遍历到的第一个成员,并把它记录为本轮投影的代表项。 + *

+ * + * @param source EMI 当前要渲染的原始列表,可能已经受搜索过滤影响 + * @return 投影后的列表,交给 EMI 继续渲染 + */ + public static List project(List source) { + if (!enabled || dirty || GROUPS.isEmpty()) { + return source; + } + synchronized (GROUPS) { + List result = new ArrayList<>(); + Set projectedGroups = new HashSet<>(); + Set sourceStacks = Collections.newSetFromMap(new IdentityHashMap<>()); + sourceStacks.addAll(source); + REPRESENTATIVE_TO_GROUP.clear(); + + for (EmiIngredient stack : source) { + String guid = STACK_TO_GROUP.get(stack); + if (guid == null) { + result.add(stack); + continue; + } + CollapsibleGroup group = GROUPS.get(guid); + if (group == null) { + result.add(stack); + continue; + } + if (group.members.size() < 2) { + result.add(stack); + } else if (group.isExpanded()) { + if (projectedGroups.add(guid)) { + for (EmiIngredient member : group.members) { + if (sourceStacks.contains(member)) { + result.add(member); + } + } + } + } else { + if (projectedGroups.add(guid)) { + REPRESENTATIVE_TO_GROUP.put(stack, guid); + result.add(stack); + } + } + } + return result; + } + } + + /** + * 根据 ingredient 查询所属分组。 + * + *

+ * 该 ingredient 可以是普通成员,也可以是当前投影生成的折叠代表项。 + *

+ */ + @Nullable + public static CollapsibleGroup getGroup(EmiIngredient ingredient) { + String guid = STACK_TO_GROUP.get(ingredient); + if (guid == null) guid = REPRESENTATIVE_TO_GROUP.get(ingredient); + if (guid == null) return null; + return GROUPS.get(guid); + } + + /** 判断 ingredient 是否是当前投影中的折叠代表项。 */ + public static boolean isCollapsedRepresentative(EmiIngredient ingredient) { + return REPRESENTATIVE_TO_GROUP.containsKey(ingredient); + } + + /** 判断 ingredient 是否属于任意已注册分组。 */ + public static boolean isInGroup(EmiIngredient ingredient) { + return STACK_TO_GROUP.containsKey(ingredient); + } + + /** 返回折叠代表项对应的次代表项,用于叠层绘制。 */ + @Nullable + public static EmiIngredient getSecondaryRepresentative(EmiIngredient ingredient) { + CollapsibleGroup group = getGroup(ingredient); + if (group == null || !isCollapsedRepresentative(ingredient)) return null; + return group.secondaryRepresentative; + } + + /** + * 按 guid 切换单个分组的展开状态。 + * + * @param guid 分组唯一标识 + */ + public static void toggleGroup(String guid) { + CollapsibleGroup group = GROUPS.get(guid); + if (group != null) { + group.setExpanded(!group.isExpanded()); + } + } + + /** + * 从配置文件读取所有分组的展开状态。 + * + *

+ * 读取失败只记录警告,不阻止 EMI 打开;无法读取时所有分组按默认折叠处理。 + *

+ */ + private static void loadStates() { + if (statesLoaded) return; + statesLoaded = true; + if (!Files.isRegularFile(STATE_FILE)) return; + + try (Reader reader = Files.newBufferedReader(STATE_FILE)) { + JsonObject states = JsonParser.parseReader(reader).getAsJsonObject(); + for (String key : states.keySet()) { + EXPANDED_STATE.put(key, states.get(key).getAsBoolean()); + } + } catch (RuntimeException | IOException e) { + CTNHCore.LOGGER.warn("Failed to load EMI collapsible group states from {}", STATE_FILE, e); + } + } + + /** + * 将当前展开状态写回配置文件。 + * + *

+ * 只有状态文件已经完成初次读取后才会保存,避免初始化阶段写出不完整状态。 + *

+ */ + private static void saveStates() { + if (!statesLoaded) return; + + try { + Files.createDirectories(STATE_FILE.getParent()); + JsonObject states = new JsonObject(); + for (Map.Entry entry : EXPANDED_STATE.entrySet()) { + states.addProperty(entry.getKey(), entry.getValue()); + } + try (Writer writer = Files.newBufferedWriter(STATE_FILE)) { + GSON.toJson(states, writer); + } + } catch (IOException e) { + CTNHCore.LOGGER.warn("Failed to save EMI collapsible group states to {}", STATE_FILE, e); + } + } + + /** + * 从 JSON 配置文件读取分组规则。 + * + *

+ * 文件不存在时会写出接近 GTNH 默认项的配置。读取或解析失败不会阻止 EMI 打开,只会回退为空规则。 + *

+ */ + private static void loadRules() { + if (rulesLoaded) return; + rulesLoaded = true; + ensureDefaultRuleFile(); + + List definitions = new ArrayList<>(); + addBuiltInTagGroups(definitions); + try (Reader reader = Files.newBufferedReader(RULE_FILE)) { + JsonElement root = JsonParser.parseReader(reader); + if (!root.isJsonObject()) { + CTNHCore.LOGGER.warn("EMI collapsible group rule file {} must be a JSON object", RULE_FILE); + configuredProviders = List.of(); + return; + } + + JsonObject groups = root.getAsJsonObject(); + for (Map.Entry entry : groups.entrySet()) { + RuleGroupDefinition definition = parseGroupDefinition(entry.getKey(), entry.getValue()); + if (definition != null && !definition.rules().isEmpty()) { + definitions.add(definition); + } + } + } catch (RuntimeException | IOException e) { + CTNHCore.LOGGER.warn("Failed to load EMI collapsible group rules from {}", RULE_FILE, e); + } + definitions.addAll(loadLegacyRuleFile()); + configuredProviders = List.copyOf(definitions); + } + + /** 注册整合包内置的标签型折叠组。 */ + private static void addBuiltInTagGroups(List definitions) { + definitions.add(tagGroup("ctnhcore:blocks/logs", "ctnhcore.emi.collapsible.group.logs", "Logs", + "minecraft:logs")); + definitions.add(tagGroup("ctnhcore:blocks/stairs", "ctnhcore.emi.collapsible.group.stairs", "Stairs", + "minecraft:stairs")); + definitions.add(tagGroup("ctnhcore:blocks/slabs", "ctnhcore.emi.collapsible.group.slabs", "Slabs", + "minecraft:slabs")); + definitions.add(tagGroup("ctnhcore:blocks/fences", "ctnhcore.emi.collapsible.group.fences", "Fences", + "minecraft:fences")); + definitions.add(tagGroup("ctnhcore:blocks/fence_gates", "ctnhcore.emi.collapsible.group.fence_gates", + "Fence Gates", "minecraft:fence_gates")); + definitions.add(tagGroup("ctnhcore:blocks/doors", "ctnhcore.emi.collapsible.group.doors", "Doors", + "minecraft:doors")); + definitions.add(tagGroup("ctnhcore:blocks/trapdoors", "ctnhcore.emi.collapsible.group.trapdoors", + "Trapdoors", "minecraft:trapdoors")); + definitions.add(tagGroup("ctnhcore:blocks/pressure_plates", + "ctnhcore.emi.collapsible.group.pressure_plates", "Pressure Plates", "minecraft:pressure_plates")); + definitions.add(tagGroup("ctnhcore:blocks/buttons", "ctnhcore.emi.collapsible.group.buttons", "Buttons", + "minecraft:buttons")); + } + + private static CollapsibleGroupProvider tagGroup(String guid, String translationKey, String fallbackName, + String tagId) { + ResourceLocation id = ResourceLocation.tryParse(tagId); + if (id == null) { + throw new IllegalArgumentException("Invalid built-in EMI collapsible tag id: " + tagId); + } + return new TagGroupDefinition(guid, translationKey, fallbackName, TagKey.create(Registries.ITEM, id), + TagKey.create(Registries.BLOCK, id)); + } + + /** 写出接近 GTNH NEI 默认折叠项的规则文件。 */ + private static void ensureDefaultRuleFile() { + if (Files.isRegularFile(RULE_FILE)) return; + + try { + Files.createDirectories(RULE_FILE.getParent()); + JsonObject defaults = new JsonObject(); + defaults.add("ctnhcore:spawn_eggs", + defaultGroup("ctnhcore.emi.collapsible.group.spawn_eggs", "regex:minecraft:.*_spawn_egg")); + defaults.add("ctnhcore:spawners", + defaultGroup("ctnhcore.emi.collapsible.group.spawners", "minecraft:spawner")); + defaults.add("ctnhcore:music_discs", + defaultGroup("ctnhcore.emi.collapsible.group.music_discs", "regex:minecraft:music_disc_.*")); + defaults.add("ctnhcore:splash_potions", + defaultGroup("ctnhcore.emi.collapsible.group.splash_potions", "minecraft:splash_potion")); + defaults.add("ctnhcore:lingering_potions", + defaultGroup("ctnhcore.emi.collapsible.group.lingering_potions", "minecraft:lingering_potion")); + defaults.add("ctnhcore:tools/swords", + defaultGroup("ctnhcore.emi.collapsible.group.swords", "#minecraft:swords", "#forge:tools/swords")); + defaults.add("ctnhcore:tools/pickaxes", + defaultGroup("ctnhcore.emi.collapsible.group.pickaxes", "#minecraft:pickaxes", "#forge:tools/pickaxes")); + defaults.add("ctnhcore:tools/axes", + defaultGroup("ctnhcore.emi.collapsible.group.axes", "#minecraft:axes", "#forge:tools/axes")); + defaults.add("ctnhcore:tools/shovels", + defaultGroup("ctnhcore.emi.collapsible.group.shovels", "#minecraft:shovels", "#forge:tools/shovels")); + defaults.add("ctnhcore:tools/hoes", + defaultGroup("ctnhcore.emi.collapsible.group.hoes", "#minecraft:hoes", "#forge:tools/hoes")); + try (Writer writer = Files.newBufferedWriter(RULE_FILE)) { + GSON.toJson(defaults, writer); + } + } catch (IOException e) { + CTNHCore.LOGGER.warn("Failed to create default EMI collapsible group rule file {}", RULE_FILE, e); + } + } + + /** 构造默认规则对象。 */ + private static JsonObject defaultGroup(String translationKey, String... rulesIn) { + JsonObject group = new JsonObject(); + group.addProperty("translationKey", translationKey); + group.addProperty("priority", 0); + JsonArray rules = new JsonArray(); + for (String rule : rulesIn) { + rules.add(rule); + } + group.add("rules", rules); + return group; + } + + /** 读取可选的 GTNH/NEI 风格 collapsibleitems.cfg。 */ + private static List loadLegacyRuleFile() { + if (!Files.isRegularFile(LEGACY_RULE_FILE)) return List.of(); + + List definitions = new ArrayList<>(); + try { + List lines = Files.readAllLines(LEGACY_RULE_FILE); + JsonObject settings = new JsonObject(); + int index = 0; + for (String rawLine : lines) { + String line = rawLine.trim(); + if (line.isEmpty() || line.startsWith("#")) continue; + + if (line.startsWith(";")) { + String json = line.substring(1).trim(); + if (!json.isEmpty()) { + JsonElement element = JsonParser.parseString(json); + if (element.isJsonObject()) { + settings = element.getAsJsonObject(); + } + } + continue; + } + + String guid = "ctnhcore:legacy/" + index++; + JsonObject object = new JsonObject(); + if (settings.has("displayName")) object.add("displayName", settings.get("displayName")); + if (settings.has("expanded")) object.add("expanded", settings.get("expanded")); + JsonArray rules = new JsonArray(); + rules.add(line); + object.add("rules", rules); + RuleGroupDefinition definition = parseGroupDefinition(guid, object); + if (definition != null) definitions.add(definition); + settings = new JsonObject(); + } + } catch (RuntimeException | IOException e) { + CTNHCore.LOGGER.warn("Failed to load legacy EMI collapsible group rules from {}", LEGACY_RULE_FILE, e); + } + return definitions; + } + + @Nullable + private static CollapsibleGroupProvider findProvider(String guid) { + for (CollapsibleGroupProvider provider : configuredProviders) { + if (provider.guid().equals(guid)) return provider; + } + return null; + } + + /** 解析单个 groupname: rule 定义。 */ + @Nullable + private static RuleGroupDefinition parseGroupDefinition(String guid, JsonElement element) { + List rules = new ArrayList<>(); + String displayName = null; + String translationKey = null; + Boolean expanded = null; + int priority = 0; + if (element.isJsonPrimitive() && element.getAsJsonPrimitive().isString()) { + addRule(guid, element.getAsString(), rules); + } else if (element.isJsonArray()) { + for (JsonElement ruleElement : element.getAsJsonArray()) { + if (ruleElement.isJsonPrimitive() && ruleElement.getAsJsonPrimitive().isString()) { + addRule(guid, ruleElement.getAsString(), rules); + } else { + CTNHCore.LOGGER.warn("Ignoring non-string EMI collapsible rule in group {}", guid); + } + } + } else if (element.isJsonObject()) { + JsonObject object = element.getAsJsonObject(); + if (object.has("displayName")) displayName = object.get("displayName").getAsString(); + if (object.has("translationKey")) translationKey = object.get("translationKey").getAsString(); + if (object.has("expanded")) expanded = object.get("expanded").getAsBoolean(); + if (object.has("priority")) { + priority = object.get("priority").getAsInt(); + if (priority < 0) { + CTNHCore.LOGGER.warn("Ignoring EMI collapsible group {} because priority must be non-negative", + guid); + return null; + } + } + JsonElement rulesElement = object.get("rules"); + if (rulesElement == null) { + CTNHCore.LOGGER.warn("Ignoring EMI collapsible group {} because it has no rules", guid); + } else if (rulesElement.isJsonPrimitive() && rulesElement.getAsJsonPrimitive().isString()) { + addRule(guid, rulesElement.getAsString(), rules); + } else if (rulesElement.isJsonArray()) { + for (JsonElement ruleElement : rulesElement.getAsJsonArray()) { + if (ruleElement.isJsonPrimitive() && ruleElement.getAsJsonPrimitive().isString()) { + addRule(guid, ruleElement.getAsString(), rules); + } else { + CTNHCore.LOGGER.warn("Ignoring non-string EMI collapsible rule in group {}", guid); + } + } + } else { + CTNHCore.LOGGER.warn("Ignoring EMI collapsible group {} because rules is not a string or string array", + guid); + } + } else { + CTNHCore.LOGGER.warn( + "Ignoring EMI collapsible group {} because its rule is not a string, string array, or object", + guid); + } + + if (rules.isEmpty()) return null; + if (expanded != null && !EXPANDED_STATE.containsKey(guid)) { + EXPANDED_STATE.put(guid, expanded); + } + return new RuleGroupDefinition(guid, displayName, translationKey, priority, List.copyOf(rules)); + } + + /** 编译一个规则字符串。 */ + private static void addRule(String guid, String rule, List rules) { + rule = rule.trim(); + if (rule.isEmpty()) return; + if (rule.startsWith("#")) { + ResourceLocation tagId = ResourceLocation.tryParse(rule.substring(1)); + if (tagId == null) { + CTNHCore.LOGGER.warn("Ignoring invalid EMI collapsible tag rule {} in group {}", rule, guid); + return; + } + rules.add(tagRule(tagId)); + return; + } + + if (rule.startsWith("regex:")) { + String pattern = rule.substring("regex:".length()); + try { + rules.add(new RegexRule(Pattern.compile(pattern))); + } catch (PatternSyntaxException e) { + CTNHCore.LOGGER.warn("Ignoring invalid EMI collapsible regex rule {} in group {}", rule, guid, e); + } + return; + } + + try { + rules.add(parseExpressionRule(rule)); + } catch (IllegalArgumentException e) { + CTNHCore.LOGGER.warn( + "Ignoring EMI collapsible rule {} in group {}; expected #tag, regex:, or item filter", + rule, guid, e); + } + } + + private static GroupRule parseExpressionRule(String rule) { + List alternatives = new ArrayList<>(); + for (String part : rule.split("\\|")) { + String trimmed = part.trim(); + if (!trimmed.isEmpty()) { + alternatives.add(parseAllRule(trimmed)); + } + } + if (alternatives.isEmpty()) throw new IllegalArgumentException("Empty rule"); + if (alternatives.size() == 1) return alternatives.get(0); + return new AnyRule(List.copyOf(alternatives)); + } + + private static GroupRule parseAllRule(String rule) { + String[] tokens = rule.split("\\s+"); + List rules = new ArrayList<>(); + for (String token : tokens) { + if (!token.isBlank()) { + rules.add(parseTokenRule(token)); + } + } + if (rules.isEmpty()) throw new IllegalArgumentException("Empty rule"); + if (rules.size() == 1) return rules.get(0); + return new AllRule(List.copyOf(rules)); + } + + private static GroupRule parseTokenRule(String token) { + List alternatives = new ArrayList<>(); + for (String part : token.split(",")) { + String trimmed = part.trim(); + if (!trimmed.isEmpty()) { + alternatives.add(parseSingleRule(trimmed)); + } + } + if (alternatives.isEmpty()) throw new IllegalArgumentException("Empty token"); + if (alternatives.size() == 1) return alternatives.get(0); + return new AnyRule(List.copyOf(alternatives)); + } + + private static GroupRule parseSingleRule(String token) { + if (token.startsWith("!")) { + return new NotRule(parseSingleRule(token.substring(1))); + } + if (token.startsWith("$")) { + ResourceLocation tagId = ResourceLocation.tryParse("forge:" + token.substring(1).toLowerCase(Locale.ROOT)); + if (tagId == null) throw new IllegalArgumentException("Invalid ore/tag token " + token); + return tagRule(tagId); + } + if (token.startsWith("#")) { + ResourceLocation tagId = ResourceLocation.tryParse(token.substring(1)); + if (tagId == null) throw new IllegalArgumentException("Invalid tag token " + token); + return tagRule(tagId); + } + if (token.startsWith("r/") && token.endsWith("/") && token.length() > 3) { + return new RegexRule(Pattern.compile(token.substring(2, token.length() - 1))); + } + if (token.matches("\\d+(?:-\\d+)?")) { + return parseDamageRule(token); + } + + ResourceLocation id = ResourceLocation.tryParse(token); + if (id == null) { + throw new IllegalArgumentException("Invalid item id " + token); + } + return new ItemIdRule(id); + } + + private static TagRule tagRule(ResourceLocation tagId) { + return new TagRule(TagKey.create(Registries.ITEM, tagId), TagKey.create(Registries.BLOCK, tagId)); + } + + private static GroupRule parseDamageRule(String token) { + if (token.contains("-")) { + String[] parts = token.split("-", 2); + return new DamageRule(Integer.parseInt(parts[0]), Integer.parseInt(parts[1])); + } + int damage = Integer.parseInt(token); + return new DamageRule(damage, damage); + } + + /** + * 根据鼠标悬停到的 ingredient 切换所属分组。 + * + *

+ * 输入通常来自 EMI 的 hover 检测,可能是折叠代表项,也可能是展开后的普通成员。 + *

+ * + * @return true 表示找到并切换了分组,调用方应刷新 EMI 面板 + */ + public static boolean toggleGroup(EmiIngredient representative) { + String guid = REPRESENTATIVE_TO_GROUP.get(representative); + if (guid == null) guid = STACK_TO_GROUP.get(representative); + if (guid != null) { + toggleGroup(guid); + return true; + } + return false; + } + + /** + * 批量切换所有有效分组。 + * + *

+ * 左键使用智能模式:只要还有任意折叠组,就展开全部;否则折叠全部。 + * 右键会传入 forceCollapse=true,始终折叠全部。 + *

+ */ + public static void toggleAll(boolean forceCollapse) { + boolean anyCollapsed = false; + synchronized (GROUPS) { + for (CollapsibleGroup group : GROUPS.values()) { + if (group.members.size() >= 2 && !group.isExpanded()) { + anyCollapsed = true; + break; + } + } + boolean expand = !forceCollapse && anyCollapsed; + for (CollapsibleGroup group : GROUPS.values()) { + if (group.members.size() >= 2) { + group.setExpanded(expand); + } + } + } + } + + /** 统计当前处于折叠状态、且成员数不少于 2 的分组数量。 */ + public static int collapsedGroupCount() { + int count = 0; + synchronized (GROUPS) { + for (CollapsibleGroup group : GROUPS.values()) { + if (group.members.size() >= 2 && !group.isExpanded()) { + count++; + } + } + } + return count; + } + + /** 统计所有有效分组数量。有效分组指成员数不少于 2,确实能被折叠的分组。 */ + public static int totalGroupCount() { + int count = 0; + synchronized (GROUPS) { + for (CollapsibleGroup group : GROUPS.values()) { + if (group.members.size() >= 2) count++; + } + } + return count; + } + + /** 判断当前是否存在至少一个有效折叠组。 */ + public static boolean hasGroups() { + if (GROUPS.isEmpty()) return false; + synchronized (GROUPS) { + for (CollapsibleGroup group : GROUPS.values()) { + if (group.members.size() >= 2) return true; + } + } + return false; + } + + /** 创建折叠组并注册其中物品的通用接口。 */ + private interface CollapsibleGroupProvider { + + String guid(); + + Component createDisplayName(); + + default int priority() { + return 0; + } + + boolean matches(EmiIngredient ingredient); + + default CollapsibleGroup createGroup() { + CollapsibleGroup group = new CollapsibleGroup(guid()); + group.displayName = createDisplayName(); + return group; + } + } + + /** 基于配置规则的折叠组定义。 */ + private record RuleGroupDefinition(String guid, @Nullable String displayName, @Nullable String translationKey, + int priority, List rules) implements CollapsibleGroupProvider { + + @Override + public Component createDisplayName() { + if (translationKey != null && !translationKey.isBlank()) { + return Component.translatableWithFallback(translationKey, guid); + } + if (displayName != null && !displayName.isBlank()) { + return Component.literal(displayName); + } + return fallbackDisplayName(guid); + } + + @Override + public int priority() { + return priority; + } + + @Override + public boolean matches(EmiIngredient ingredient) { + for (EmiStack stack : ingredient.getEmiStacks()) { + ItemStack itemStack = stack.getItemStack(); + if (!itemStack.isEmpty() && matchesItem(itemStack)) { + return true; + } + } + return false; + } + + private boolean matchesItem(ItemStack stack) { + for (GroupRule rule : rules) { + if (rule.matches(stack)) return true; + } + return false; + } + } + + /** 基于物品或方块标签的代码型折叠组定义。 */ + private record TagGroupDefinition(String guid, String translationKey, String fallbackName, + TagKey itemTag, TagKey blockTag) implements CollapsibleGroupProvider { + + @Override + public Component createDisplayName() { + return Component.translatableWithFallback(translationKey, fallbackName); + } + + @Override + public boolean matches(EmiIngredient ingredient) { + for (EmiStack stack : ingredient.getEmiStacks()) { + ItemStack itemStack = stack.getItemStack(); + if (!itemStack.isEmpty() && matchesTag(itemStack, itemTag, blockTag)) { + return true; + } + } + return false; + } + } + + /** 单条分组规则。 */ + private interface GroupRule { + + boolean matches(ItemStack stack); + } + + private record AnyRule(List rules) implements GroupRule { + + @Override + public boolean matches(ItemStack stack) { + for (GroupRule rule : rules) { + if (rule.matches(stack)) return true; + } + return false; + } + } + + private record AllRule(List rules) implements GroupRule { + + @Override + public boolean matches(ItemStack stack) { + for (GroupRule rule : rules) { + if (!rule.matches(stack)) return false; + } + return true; + } + } + + private record NotRule(GroupRule rule) implements GroupRule { + + @Override + public boolean matches(ItemStack stack) { + return !rule.matches(stack); + } + } + + /** item/block tag 规则。 */ + private record TagRule(TagKey itemTag, TagKey blockTag) implements GroupRule { + + @Override + public boolean matches(ItemStack stack) { + return matchesTag(stack, itemTag, blockTag); + } + } + + private static boolean matchesTag(ItemStack stack, TagKey itemTag, TagKey blockTag) { + if (stack.is(itemTag)) return true; + return stack.getItem() instanceof BlockItem blockItem && blockItem.getBlock().defaultBlockState().is(blockTag); + } + + /** 物品注册 id 正则规则。 */ + private record RegexRule(Pattern pattern) implements GroupRule { + + @Override + public boolean matches(ItemStack stack) { + ResourceLocation id = ForgeRegistries.ITEMS.getKey(stack.getItem()); + return id != null && pattern.matcher(id.toString()).matches(); + } + } + + /** 物品 id 规则;如果完整 id 不存在,则按注册 id 前缀匹配,兼容 GTNH 的 minecraft:record_ 风格。 */ + private record ItemIdRule(ResourceLocation id) implements GroupRule { + + @Override + public boolean matches(ItemStack stack) { + ResourceLocation stackId = ForgeRegistries.ITEMS.getKey(stack.getItem()); + if (stackId == null || !Objects.equals(stackId.getNamespace(), id.getNamespace())) return false; + if (Objects.equals(stackId, id)) return true; + + Item item = ForgeRegistries.ITEMS.getValue(id); + return item == null || item == Items.AIR ? stackId.getPath().startsWith(id.getPath()) : false; + } + } + + /** 物品 damage 规则。1.20 中很多物品不再使用 damage 子类型,但保留该语法以接近 GTNH 配置。 */ + private record DamageRule(int min, int max) implements GroupRule { + + @Override + public boolean matches(ItemStack stack) { + int damage = stack.getDamageValue(); + return damage >= min && damage <= max; + } + } +} diff --git a/src/main/resources/ctnhcore.mixins.json b/src/main/resources/ctnhcore.mixins.json index 256fe20d..dca393f4 100644 --- a/src/main/resources/ctnhcore.mixins.json +++ b/src/main/resources/ctnhcore.mixins.json @@ -28,7 +28,9 @@ "emi.CreateJEIMixin$CategoryBuilderMixin", "emi.EmiApiTagExpandMixin", "emi.EmiRecipesMixin", + "emi.EmiScreenManagerInputMixin", "emi.EmiScreenManagerMixin", + "emi.EmiScreenManagerScreenSpaceMixin", "emi.EmiSearchMixin", "emi.EmiTagsMixin", "emi.GTRecipeEMICategoryMixin", From 0f444421cbaa04d4ed508660224316510d039606 Mon Sep 17 00:00:00 2001 From: m1Riss <3081974632@qq.com> Date: Tue, 16 Jun 2026 23:59:20 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=E6=B7=BB=E5=8A=A0=E4=BA=86emi?= =?UTF-8?q?=E6=8A=98=E5=8F=A0=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/assets/ctnhcore/lang/en_ud.json | 18 +- .../resources/assets/ctnhcore/lang/en_us.json | 18 +- .../resources/assets/ctnhcore/lang/zh_cn.json | 18 +- .../data/lang/old/ChineseLangHandler.java | 12 +- .../data/lang/old/EnglishLangHandler.java | 12 +- .../mixin/emi/EmiScreenManagerInputMixin.java | 90 +-- .../emi/EmiScreenManagerScreenSpaceMixin.java | 184 ++----- .../collapsible/CTNHCollapsibleGroups.java | 517 ++++-------------- .../ctnhcore/emi/emi_collapsible_groups.json | 231 ++++++++ 9 files changed, 403 insertions(+), 697 deletions(-) create mode 100644 src/main/resources/assets/ctnhcore/emi/emi_collapsible_groups.json diff --git a/src/generated/resources/assets/ctnhcore/lang/en_ud.json b/src/generated/resources/assets/ctnhcore/lang/en_ud.json index c63f80b3..799f8d52 100644 --- a/src/generated/resources/assets/ctnhcore/lang/en_ud.json +++ b/src/generated/resources/assets/ctnhcore/lang/en_ud.json @@ -48,21 +48,6 @@ "block.ctnhcore.creative_item_input_bus": "snᗺ ʇnduI ɯǝʇI ǝʌıʇɐǝɹƆ", "block.ctnhcore.creative_laser_hatch": "ɥɔʇɐH ʇnduI ɹǝsɐꞀ ǝʌıʇɐǝɹƆ", "block.ctnhcore.cryotheum_freezer": "ɹǝzǝǝɹℲ ɯnǝɥʇoʎɹƆ", - "ctnhcore.emi.collapsible.button.collapse_all": ")s% 个分组(部全叠折:键左", - "ctnhcore.emi.collapsible.button.collapse_all.right_click": "部全叠折:键右", - "ctnhcore.emi.collapsible.button.expand_all": ")s% 个已折叠(部全开展:键左", - "ctnhcore.emi.collapsible.group.axes": "sǝxⱯ", - "ctnhcore.emi.collapsible.group.hoes": "sǝoH", - "ctnhcore.emi.collapsible.group.lingering_potions": "suoıʇoԀ ƃuıɹǝƃuıꞀ", - "ctnhcore.emi.collapsible.group.music_discs": "sɔsıᗡ ɔısnW", - "ctnhcore.emi.collapsible.group.pickaxes": "sǝxɐʞɔıԀ", - "ctnhcore.emi.collapsible.group.shovels": "sןǝʌoɥS", - "ctnhcore.emi.collapsible.group.spawn_eggs": "sƃƃƎ uʍɐdS", - "ctnhcore.emi.collapsible.group.spawners": "sɹǝuʍɐdS", - "ctnhcore.emi.collapsible.group.splash_potions": "suoıʇoԀ ɥsɐןdS", - "ctnhcore.emi.collapsible.group.swords": "spɹoʍS", - "ctnhcore.emi.collapsible.tooltip.group": "s%:组叠折", - "ctnhcore.emi.collapsible.tooltip.toggle_hint": "组此叠折/开展:键左 + tlA", "block.ctnhcore.crystallizer": "ɹǝzıןןɐʇsʎɹƆ", "block.ctnhcore.cultivationroom": "ɯooɹuoıʇɐʌıʇןnƆ", "block.ctnhcore.dark_blue_elevator_casing": "buısɐƆ ɹoʇɐʌǝןƎ ǝnןᗺ ʞɹɐᗡ", @@ -606,6 +591,9 @@ "ctnhcore.ctnhitems.heavy_plate_t3.tooltip": "Ɛ⟘ㄥ§", "ctnhcore.ctnhitems.heavy_plate_t4.tooltip": "ㄣ⟘ㄥ§", "ctnhcore.ctnhitems.radioactive_waste.tooltip": "ʇuǝıɔıɟɟnsuı sı pǝǝds ǝɥʇ uǝɥʍ ǝʇsɐʍ ǝɔnpoɹd ןןıʍ ɹoʇɐʌıʇɔⱯ uoɹʇnǝNㄥ§", + "ctnhcore.emi.collapsible.button.collapse_all": ")s% 个分组(部全叠折:键左", + "ctnhcore.emi.collapsible.button.collapse_all.right_click": "部全叠折:键右", + "ctnhcore.emi.collapsible.button.expand_all": ")s% 个已折叠(部全开展:键左", "ctnhcore.hugeitembuspartmachine.refund_item.tooltip": "ʇuoɹɟ uı ɹǝuıɐʇuoɔ ǝɥʇ oʇ sɯǝʇı ןɐ suɹnʇǝᴚ", "ctnhcore.largenaquadahreactormachine.power": "%s :ɹǝʍoԀ", "ctnhcore.machine.digital_miner.tooltip.0": "sǝɹo buıuıɯ ʎןuo 'sǝdıd buıuıɯ ou puɐ pǝǝds ɹǝʇsɐɟ 'sbuıɥ⟘W⟘⅁ ɯoɹℲㄥ§", diff --git a/src/generated/resources/assets/ctnhcore/lang/en_us.json b/src/generated/resources/assets/ctnhcore/lang/en_us.json index 49a55542..e26520ba 100644 --- a/src/generated/resources/assets/ctnhcore/lang/en_us.json +++ b/src/generated/resources/assets/ctnhcore/lang/en_us.json @@ -48,21 +48,6 @@ "block.ctnhcore.creative_item_input_bus": "Creative Item Input Bus", "block.ctnhcore.creative_laser_hatch": "Creative Laser Input Hatch", "block.ctnhcore.cryotheum_freezer": "Cryotheum Freezer", - "ctnhcore.emi.collapsible.button.collapse_all": "Left click: collapse all (%s groups)", - "ctnhcore.emi.collapsible.button.collapse_all.right_click": "Right click: collapse all", - "ctnhcore.emi.collapsible.button.expand_all": "Left click: expand all (%s collapsed)", - "ctnhcore.emi.collapsible.group.axes": "Axes", - "ctnhcore.emi.collapsible.group.hoes": "Hoes", - "ctnhcore.emi.collapsible.group.lingering_potions": "Lingering Potions", - "ctnhcore.emi.collapsible.group.music_discs": "Music Discs", - "ctnhcore.emi.collapsible.group.pickaxes": "Pickaxes", - "ctnhcore.emi.collapsible.group.shovels": "Shovels", - "ctnhcore.emi.collapsible.group.spawn_eggs": "Spawn Eggs", - "ctnhcore.emi.collapsible.group.spawners": "Spawners", - "ctnhcore.emi.collapsible.group.splash_potions": "Splash Potions", - "ctnhcore.emi.collapsible.group.swords": "Swords", - "ctnhcore.emi.collapsible.tooltip.group": "Collapsed group: %s", - "ctnhcore.emi.collapsible.tooltip.toggle_hint": "Alt + left click: expand/collapse this group", "block.ctnhcore.crystallizer": "Crystallizer", "block.ctnhcore.cultivationroom": "Cultivationroom", "block.ctnhcore.dark_blue_elevator_casing": "Dark Blue Elevator Casing", @@ -606,6 +591,9 @@ "ctnhcore.ctnhitems.heavy_plate_t3.tooltip": "§7T3", "ctnhcore.ctnhitems.heavy_plate_t4.tooltip": "§7T4", "ctnhcore.ctnhitems.radioactive_waste.tooltip": "§7Neutron Activator will produce waste when the speed is insufficient", + "ctnhcore.emi.collapsible.button.collapse_all": "Left click: collapse all (%s groups)", + "ctnhcore.emi.collapsible.button.collapse_all.right_click": "Right click: collapse all", + "ctnhcore.emi.collapsible.button.expand_all": "Left click: expand all (%s collapsed)", "ctnhcore.hugeitembuspartmachine.refund_item.tooltip": "Returns al items to the container in front", "ctnhcore.largenaquadahreactormachine.power": "Power: %s", "ctnhcore.machine.digital_miner.tooltip.0": "§7From GTMThings, faster speed and no mining pipes, only mining ores", diff --git a/src/generated/resources/assets/ctnhcore/lang/zh_cn.json b/src/generated/resources/assets/ctnhcore/lang/zh_cn.json index aa370919..530186f0 100644 --- a/src/generated/resources/assets/ctnhcore/lang/zh_cn.json +++ b/src/generated/resources/assets/ctnhcore/lang/zh_cn.json @@ -48,21 +48,6 @@ "block.ctnhcore.creative_item_input_bus": "创造模式输入总线", "block.ctnhcore.creative_laser_hatch": "创造模式激光靶仓", "block.ctnhcore.cryotheum_freezer": "凛冰冷冻机", - "ctnhcore.emi.collapsible.button.collapse_all": "左键:折叠全部(%s 个分组)", - "ctnhcore.emi.collapsible.button.collapse_all.right_click": "右键:折叠全部", - "ctnhcore.emi.collapsible.button.expand_all": "左键:展开全部(%s 个已折叠)", - "ctnhcore.emi.collapsible.group.axes": "斧", - "ctnhcore.emi.collapsible.group.hoes": "锄", - "ctnhcore.emi.collapsible.group.lingering_potions": "滞留型药水", - "ctnhcore.emi.collapsible.group.music_discs": "音乐唱片", - "ctnhcore.emi.collapsible.group.pickaxes": "镐", - "ctnhcore.emi.collapsible.group.shovels": "锹", - "ctnhcore.emi.collapsible.group.spawn_eggs": "刷怪蛋", - "ctnhcore.emi.collapsible.group.spawners": "刷怪笼", - "ctnhcore.emi.collapsible.group.splash_potions": "喷溅型药水", - "ctnhcore.emi.collapsible.group.swords": "剑", - "ctnhcore.emi.collapsible.tooltip.group": "折叠组:%s", - "ctnhcore.emi.collapsible.tooltip.toggle_hint": "Alt + 左键:展开/折叠此组", "block.ctnhcore.crystallizer": "结晶器", "block.ctnhcore.cultivationroom": "培养室", "block.ctnhcore.dark_blue_elevator_casing": "深蓝色电梯机械方块", @@ -829,6 +814,9 @@ "ctnhcore.ctnhitems.heavy_plate_t3.tooltip": "§73阶", "ctnhcore.ctnhitems.heavy_plate_t4.tooltip": "§74阶", "ctnhcore.ctnhitems.radioactive_waste.tooltip": "§7中子活化器在速度不达标时运行配方会产生废料", + "ctnhcore.emi.collapsible.button.collapse_all": "左键:折叠全部(%s 个分组)", + "ctnhcore.emi.collapsible.button.collapse_all.right_click": "右键:折叠全部", + "ctnhcore.emi.collapsible.button.expand_all": "左键:展开全部(%s 个已折叠)", "ctnhcore.hugeitembuspartmachine.refund_item.tooltip": "返还所有物品到面前的容器中", "ctnhcore.largenaquadahreactormachine.power": "发电倍率: %s", "ctnhcore.machine.digital_miner.tooltip.0": "§7来自GTMThings的挖矿黑科技,速度更快且无采矿管道,仅挖取矿石", diff --git a/src/main/java/io/github/cpearl0/ctnhcore/data/lang/old/ChineseLangHandler.java b/src/main/java/io/github/cpearl0/ctnhcore/data/lang/old/ChineseLangHandler.java index c27bacd1..7b839163 100644 --- a/src/main/java/io/github/cpearl0/ctnhcore/data/lang/old/ChineseLangHandler.java +++ b/src/main/java/io/github/cpearl0/ctnhcore/data/lang/old/ChineseLangHandler.java @@ -46,15 +46,9 @@ public static void init(RegistrateCNLangProvider provider) { "缺少足够能量以启动核聚变反应"); provider.add("gtceu.recipe_modifier.coil_temperature_too_low", "线圈温度过低!"); - provider.add("ctnhcore.emi.collapsible.group.logs", "原木"); - provider.add("ctnhcore.emi.collapsible.group.stairs", "楼梯"); - provider.add("ctnhcore.emi.collapsible.group.slabs", "台阶"); - provider.add("ctnhcore.emi.collapsible.group.fences", "栅栏"); - provider.add("ctnhcore.emi.collapsible.group.fence_gates", "栅栏门"); - provider.add("ctnhcore.emi.collapsible.group.doors", "门"); - provider.add("ctnhcore.emi.collapsible.group.trapdoors", "活板门"); - provider.add("ctnhcore.emi.collapsible.group.pressure_plates", "压力板"); - provider.add("ctnhcore.emi.collapsible.group.buttons", "按钮"); + provider.add("ctnhcore.emi.collapsible.button.expand_all", "左键:展开全部(%s 个已折叠)"); + provider.add("ctnhcore.emi.collapsible.button.collapse_all", "左键:折叠全部(%s 个分组)"); + provider.add("ctnhcore.emi.collapsible.button.collapse_all.right_click", "右键:折叠全部"); // Config provider.add("config.ctnhcore.option.ftbPlugin", "FTB相关"); diff --git a/src/main/java/io/github/cpearl0/ctnhcore/data/lang/old/EnglishLangHandler.java b/src/main/java/io/github/cpearl0/ctnhcore/data/lang/old/EnglishLangHandler.java index b05eba97..7cf50d2d 100644 --- a/src/main/java/io/github/cpearl0/ctnhcore/data/lang/old/EnglishLangHandler.java +++ b/src/main/java/io/github/cpearl0/ctnhcore/data/lang/old/EnglishLangHandler.java @@ -33,15 +33,9 @@ public static void init(RegistrateLangProvider provider) { provider.add("config.jade.plugin_ctnhcore.recipe_logic_provider", "Recipe Logic Info"); provider.add("config.jade.plugin_ctnhcore.recipe_output_provider", "Recipe Output Info"); - provider.add("ctnhcore.emi.collapsible.group.logs", "Logs"); - provider.add("ctnhcore.emi.collapsible.group.stairs", "Stairs"); - provider.add("ctnhcore.emi.collapsible.group.slabs", "Slabs"); - provider.add("ctnhcore.emi.collapsible.group.fences", "Fences"); - provider.add("ctnhcore.emi.collapsible.group.fence_gates", "Fence Gates"); - provider.add("ctnhcore.emi.collapsible.group.doors", "Doors"); - provider.add("ctnhcore.emi.collapsible.group.trapdoors", "Trapdoors"); - provider.add("ctnhcore.emi.collapsible.group.pressure_plates", "Pressure Plates"); - provider.add("ctnhcore.emi.collapsible.group.buttons", "Buttons"); + provider.add("ctnhcore.emi.collapsible.button.expand_all", "Left click: expand all (%s collapsed)"); + provider.add("ctnhcore.emi.collapsible.button.collapse_all", "Left click: collapse all (%s groups)"); + provider.add("ctnhcore.emi.collapsible.button.collapse_all.right_click", "Right click: collapse all"); // Recipe Types provider.add("gtceu.underfloor_heating_system", "Underfloor Heating"); diff --git a/src/main/java/io/github/cpearl0/ctnhcore/mixin/emi/EmiScreenManagerInputMixin.java b/src/main/java/io/github/cpearl0/ctnhcore/mixin/emi/EmiScreenManagerInputMixin.java index 398d1b9e..b37caa96 100644 --- a/src/main/java/io/github/cpearl0/ctnhcore/mixin/emi/EmiScreenManagerInputMixin.java +++ b/src/main/java/io/github/cpearl0/ctnhcore/mixin/emi/EmiScreenManagerInputMixin.java @@ -1,16 +1,12 @@ package io.github.cpearl0.ctnhcore.mixin.emi; import io.github.cpearl0.ctnhcore.utils.emi.collapsible.CTNHCollapsibleGroups; -import io.github.cpearl0.ctnhcore.utils.emi.collapsible.CTNHCollapsibleGroups.CollapsibleGroup; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.screens.Screen; -import net.minecraft.client.gui.screens.inventory.tooltip.ClientTooltipComponent; import net.minecraft.network.chat.Component; -import com.llamalad7.mixinextras.injector.wrapoperation.Operation; -import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; import dev.emi.emi.api.stack.EmiIngredient; import dev.emi.emi.api.stack.EmiStackInteraction; import dev.emi.emi.config.SidebarType; @@ -27,22 +23,10 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; -import java.util.ArrayList; import java.util.List; /** - * 注入 EMI 的 {@link EmiScreenManager},负责折叠组的输入与提示层逻辑。 - * - *

- * 本 mixin 不决定哪些物品属于同一组;分组数据由 {@link CTNHCollapsibleGroups} 管理。 - * 这里负责把玩家输入和 EMI 生命周期事件转发给分组管理器,包括: - *

- *
    - *
  • Alt + 左键点击单个分组代表项时展开或折叠该组。
  • - *
  • 在搜索框旁绘制 G 按钮,用于批量展开或折叠所有分组。
  • - *
  • 在 EMI 搜索来源刷新时触发分组重建。
  • - *
  • 给折叠代表项 tooltip 追加分组名和操作提示。
  • - *
+ * 处理 EMI 折叠组的输入:单组切换、全部切换按钮和列表重建。 */ @Mixin(value = EmiScreenManager.class, remap = false) public class EmiScreenManagerInputMixin { @@ -71,25 +55,16 @@ public class EmiScreenManagerInputMixin { @Unique private static boolean ctnhcore$hoveredToggleBtn = false; - /** - * 处理折叠组相关鼠标点击。 - * - *

- * 注入在 {@code mouseClicked} 开头,先于 EMI 原生点击逻辑执行。这样当玩家点击 G 按钮 - * 或 Alt + 左键点击分组项时,可以消费本次事件,避免 EMI 同时打开配方或执行其他默认动作。 - *

- */ + /** 处理 G 按钮点击,以及 Alt + 左键切换单个分组。 */ @Inject(method = "mouseClicked", at = @At("HEAD"), cancellable = true) private static void ctnhcore$handleMouseClicked(double mouseX, double mouseY, int button, CallbackInfoReturnable cir) { - if (!CTNHCollapsibleGroups.isEnabled()) return; if (CTNHCollapsibleGroups.needsRebuild()) return; if (!CTNHCollapsibleGroups.hasGroups()) return; int mx = (int) mouseX; int my = (int) mouseY; - // 先检查 G 按钮,避免按钮区域被误判为侧栏物品点击。 if (ctnhcore$toggleBtnX >= 0 && ctnhcore$toggleBtnY >= 0 && mx >= ctnhcore$toggleBtnX && mx < ctnhcore$toggleBtnX + TOGGLE_BUTTON_SIZE && my >= ctnhcore$toggleBtnY && my < ctnhcore$toggleBtnY + TOGGLE_BUTTON_SIZE) { @@ -103,7 +78,6 @@ public class EmiScreenManagerInputMixin { return; } - // Alt + 左键点击 INDEX 侧栏中的分组项时,切换该项所属分组。 if (button == 0 && Screen.hasAltDown()) { EmiStackInteraction interaction = EmiScreenManager.getHoveredStack(mx, my, false); EmiIngredient hovered = interaction.getStack(); @@ -116,19 +90,11 @@ public class EmiScreenManagerInputMixin { } } - /** - * 在 EMI 小部件渲染结束后绘制 G 按钮。 - * - *

- * 按钮位于实时搜索框右侧。左键为智能展开/折叠全部:存在折叠组时展开全部, - * 否则折叠全部;右键始终折叠全部。没有有效分组时会隐藏按钮并清空 hover 状态。 - *

- */ + /** 在搜索框右侧绘制 G 按钮:左键智能切换,右键全部折叠。 */ @Inject(method = "renderWidgets", at = @At("TAIL")) private static void ctnhcore$renderToggleButton(EmiDrawContext context, int mouseX, int mouseY, float delta, EmiScreenBase base, CallbackInfo ci) { - if (!CTNHCollapsibleGroups.isEnabled()) return; if (CTNHCollapsibleGroups.needsRebuild()) return; if (!CTNHCollapsibleGroups.hasGroups()) { ctnhcore$toggleBtnX = -1; @@ -180,17 +146,9 @@ public class EmiScreenManagerInputMixin { } } - /** - * 在 EMI 获取搜索来源后触发折叠组重建。 - * - *

- * 这里故意使用 {@link EmiSidebars#getStacks(SidebarType)} 的 INDEX 完整来源,而不是搜索过滤后的 - * 返回值。这样分组成员来自完整 EMI 列表,搜索时再由投影逻辑只显示当前搜索结果中存在的成员。 - *

- */ + /** EMI 刷新搜索来源时,用 INDEX 完整列表重建分组。 */ @Inject(method = "getSearchSource", at = @At("RETURN")) private static void ctnhcore$rebuildOnSearch(CallbackInfoReturnable> cir) { - if (!CTNHCollapsibleGroups.isEnabled()) return; if (CTNHCollapsibleGroups.needsRebuild()) { List source = EmiSidebars.getStacks(SidebarType.INDEX); if (source != null && !source.isEmpty()) { @@ -199,47 +157,9 @@ public class EmiScreenManagerInputMixin { } } - /** - * 当 EMI 可见性切换时标记分组为脏。 - * - *

- * 可见性切换常伴随 EMI 重载、关闭或重新打开。标记 dirty 后,下次获取搜索来源时会重新扫描列表, - * 避免继续使用旧的 ingredient 对象身份映射。 - *

- */ + /** EMI 开关或重载后,下一次搜索刷新时重新扫描列表。 */ @Inject(method = "toggleVisibility", at = @At("HEAD")) private static void ctnhcore$markDirtyOnToggle(boolean notify, CallbackInfo ci) { CTNHCollapsibleGroups.markDirty(); } - - /** - * 包装 EMI 原生 tooltip,给折叠代表项追加分组信息。 - * - *

- * 只有当前 ingredient 是折叠代表项时才追加文本。展开后的普通成员不追加,避免 tooltip 噪音。 - * 追加内容包括分组显示名和 Alt + 左键展开/折叠提示。 - *

- */ - @WrapOperation(method = "renderCurrentTooltip", - at = @At(value = "INVOKE", - target = "Ldev/emi/emi/api/stack/EmiIngredient;getTooltip()Ljava/util/List;")) - private static List ctnhcore$wrapGroupTooltip(EmiIngredient instance, - Operation> original) { - List list = original.call(instance); - if (CTNHCollapsibleGroups.needsRebuild() || !CTNHCollapsibleGroups.isEnabled()) return list; - if (CTNHCollapsibleGroups.collapsedGroupCount() == 0) return list; - - CollapsibleGroup group = CTNHCollapsibleGroups.getGroup(instance); - if (group != null && CTNHCollapsibleGroups.isCollapsedRepresentative(instance)) { - List modified = new ArrayList<>(list.size() + 2); - modified.addAll(list); - modified.add(ClientTooltipComponent.create( - Component.translatable("ctnhcore.emi.collapsible.tooltip.group", group.displayName) - .getVisualOrderText())); - modified.add(ClientTooltipComponent.create( - Component.translatable("ctnhcore.emi.collapsible.tooltip.toggle_hint").getVisualOrderText())); - return modified; - } - return list; - } } diff --git a/src/main/java/io/github/cpearl0/ctnhcore/mixin/emi/EmiScreenManagerScreenSpaceMixin.java b/src/main/java/io/github/cpearl0/ctnhcore/mixin/emi/EmiScreenManagerScreenSpaceMixin.java index 79d00446..d29e95b1 100644 --- a/src/main/java/io/github/cpearl0/ctnhcore/mixin/emi/EmiScreenManagerScreenSpaceMixin.java +++ b/src/main/java/io/github/cpearl0/ctnhcore/mixin/emi/EmiScreenManagerScreenSpaceMixin.java @@ -10,7 +10,6 @@ import dev.emi.emi.runtime.EmiDrawContext; import dev.emi.emi.screen.EmiScreenManager; import dev.emi.emi.screen.StackBatcher; -import org.spongepowered.asm.mixin.Final; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Shadow; import org.spongepowered.asm.mixin.Unique; @@ -20,83 +19,42 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; -import com.mojang.blaze3d.systems.RenderSystem; - import java.util.ArrayList; import java.util.List; -/** - * 注入 EMI 侧栏空间 {@link EmiScreenManager.ScreenSpace},负责折叠组的显示层改写。 - * - *

- * 本 mixin 只作用于 INDEX 搜索侧栏。它会在 EMI 读取待显示列表时调用 - * {@link CTNHCollapsibleGroups#project(List)},把已折叠分组压缩成一个代表项;随后在渲染阶段 - * 给折叠代表项和展开成员绘制边框,帮助玩家看出这些物品属于同一个折叠组。 - *

- */ +/** 改写 EMI INDEX 侧栏显示列表,并绘制折叠组的边框和双层图标。 */ @Mixin(value = EmiScreenManager.ScreenSpace.class, remap = false) public abstract class EmiScreenManagerScreenSpaceMixin { - /** EMI 原字段:当前 ScreenSpace 是否处于搜索/索引侧栏模式。 */ @Shadow - @Final public boolean search; - /** EMI 原方法:返回该侧栏空间的类型,例如 INDEX 或 FAVORITES。 */ @Shadow public abstract SidebarType getType(); - /** EMI 原字段:侧栏渲染起点 X。当前类保留 shadow 以匹配原布局上下文。 */ - @Shadow - @Final - public int tx; - - /** EMI 原字段:侧栏渲染起点 Y。当前类保留 shadow 以匹配原布局上下文。 */ - @Shadow - @Final - public int ty; - - /** EMI 原字段:侧栏网格高度,按 18x18 单元格行数计算。 */ @Shadow - @Final public int th; - /** EMI 原字段:每行可显示的单元格宽度数组。 */ - @Shadow - @Final - public int[] widths; - - /** EMI 原方法:取得指定行可显示的单元格数量。 */ @Shadow public abstract int getWidth(int y); - /** EMI 原方法:把网格列/行坐标转换成屏幕 X 坐标。 */ @Shadow public abstract int getX(int x, int y); - /** EMI 原方法:把网格列/行坐标转换成屏幕 Y 坐标。 */ @Shadow public abstract int getY(int x, int y); - /** EMI 原字段:当前页最多渲染多少个 ingredient。 */ @Shadow - @Final public int pageSize; - /** EMI 原方法:返回当前侧栏空间要显示的 ingredient 列表。 */ @Shadow public abstract List getStacks(); - /** EMI 原字段:侧栏使用的批量物品渲染器。 */ - @Shadow - @Final - public StackBatcher batcher; - /** EMI 侧栏单格尺寸。 */ @Unique private static final int ENTRY_SIZE = 18; - /** 展开分组成员的外边框颜色。GuiGraphics.fill 使用 ABGR 格式。 */ + /** 展开分组成员的外边框颜色。GuiGraphics.fill 使用 ARGB 格式。 */ @Unique private static final int GROUP_BORDER_COLOR = 0xCC3344AA; @@ -104,29 +62,31 @@ public abstract class EmiScreenManagerScreenSpaceMixin { @Unique private static final int GROUP_BG_COLOR = 0x44113377; - /** 折叠代表项边框颜色。这里只画边框,不覆盖 EMI 原本的物品图标。 */ + /** GTNH NEI 默认折叠背景色 0x335555EE,GuiGraphics 使用 ARGB。 */ + @Unique + private static final int COLLAPSED_BG_COLOR = 0x335555EE; + + /** GTNH NEI 会把同色背景的 alpha 加深 2/5 作为边框色:0x33 + 0x66 = 0x99。 */ + @Unique + private static final int COLLAPSED_BORDER_COLOR = 0x995555EE; + + /** GTNH NEI 折叠背景物品偏移:rect.offset(1, -1)。 */ + @Unique + private static final int COLLAPSED_BACK_X_OFFSET = 1; + @Unique - private static final int COLLAPSED_BORDER_COLOR = 0xCC4466CC; + private static final int COLLAPSED_BACK_Y_OFFSET = -1; - /** 后方叠层图标透明度。 */ + /** GTNH NEI 折叠前景物品偏移:rect.offset(-2, 2)。 */ @Unique - private static final float STACKED_BACK_ICON_ALPHA = 0.45F; + private static final int COLLAPSED_FRONT_X_OFFSET = -2; - /** 后方叠层图标遮罩,让不吃 shader alpha 的 item 渲染路径也能显得更靠后。 */ @Unique - private static final int STACKED_BACK_ICON_DIM_COLOR = 0x77000000; - - /** - * 在 EMI 返回侧栏列表后替换为折叠投影列表。 - * - *

- * 只处理 INDEX 搜索侧栏,避免影响收藏夹等其他侧栏。分组尚未重建或没有有效分组时, - * 保持 EMI 原始列表不变。 - *

- */ + private static final int COLLAPSED_FRONT_Y_OFFSET = 2; + + /** 只在 INDEX 侧栏把 EMI 原列表替换为折叠投影。 */ @Inject(method = "getStacks", at = @At("RETURN"), cancellable = true) private void ctnhcore$projectGetStacks(CallbackInfoReturnable> cir) { - if (!CTNHCollapsibleGroups.isEnabled()) return; if (search && getType() == SidebarType.INDEX) { List original = cir.getReturnValue(); if (original == null || original.isEmpty()) return; @@ -136,40 +96,26 @@ public abstract class EmiScreenManagerScreenSpaceMixin { } } - /** - * 折叠代表项由本 mixin 在批量绘制完成后手动画双层图标。 - * - *

- * 跳过 EMI 原始的单图标绘制,避免“原图标 + 双层图标”三层重叠。 - *

- */ + /** 折叠代表项稍后手动画双层图标,这里跳过 EMI 原图标。 */ @Redirect(method = "render", at = @At(value = "INVOKE", target = "Ldev/emi/emi/screen/StackBatcher;render(Ldev/emi/emi/api/stack/EmiIngredient;Lnet/minecraft/client/gui/GuiGraphics;IIF)V")) private void ctnhcore$skipCollapsedRepresentativeOriginalIcon(StackBatcher instance, EmiIngredient stack, GuiGraphics draw, int x, int y, float delta) { - if (CTNHCollapsibleGroups.isEnabled() && search && getType() == SidebarType.INDEX && + if (search && getType() == SidebarType.INDEX && !CTNHCollapsibleGroups.needsRebuild() && CTNHCollapsibleGroups.isCollapsedRepresentative(stack)) { return; } instance.render(stack, draw, x, y, delta); } - /** - * 在 EMI 批量绘制物品图标后绘制折叠组叠加层。 - * - *

- * 折叠代表项只画蓝色边框,保留 EMI 原本渲染出的代表物品图标。展开成员会绘制淡色背景, - * 并根据相邻格子是否属于同一组来决定哪些边需要画,从而形成连贯的分组区域。 - *

- */ + /** EMI 画完物品后,补画折叠组背景、边框和双层图标。 */ @Inject(method = "render", at = @At(value = "INVOKE", target = "Ldev/emi/emi/screen/StackBatcher;draw()V", shift = At.Shift.AFTER)) private void ctnhcore$renderGroupOverlays(EmiDrawContext context, int mouseX, int mouseY, float delta, int startIndex, CallbackInfo ci) { - if (!CTNHCollapsibleGroups.isEnabled()) return; if (!search || getType() != SidebarType.INDEX) return; if (CTNHCollapsibleGroups.needsRebuild()) return; @@ -179,11 +125,7 @@ public abstract class EmiScreenManagerScreenSpaceMixin { GuiGraphics graphics = context.raw(); int endIndex = Math.min(startIndex + pageSize, stacks.size()); - List expandedXos = new ArrayList<>(); - List expandedYos = new ArrayList<>(); - List expandedCxs = new ArrayList<>(); - List expandedCys = new ArrayList<>(); - List expandedGroupGuids = new ArrayList<>(); + List expandedCells = new ArrayList<>(); int ri = startIndex; outer: @@ -200,24 +142,26 @@ public abstract class EmiScreenManagerScreenSpaceMixin { int cy = getY(xo, yo); if (CTNHCollapsibleGroups.isCollapsedRepresentative(stack)) { + drawCollapsedGroupBackground(graphics, cx, cy); drawCollapsedGroupStack(context, stack, cx, cy); drawCollapsedGroupOverlay(graphics, cx, cy); - } else { - expandedXos.add(xo); - expandedYos.add(yo); - expandedCxs.add(cx); - expandedCys.add(cy); - expandedGroupGuids.add(group.guid); + } else if (group.isExpanded()) { + expandedCells.add(new ExpandedCell(xo, yo, cx, cy, group.guid)); } } } - for (int index = 0; index < expandedCxs.size(); index++) { - drawExpandedMemberCell(graphics, index, expandedXos, expandedYos, expandedCxs, expandedCys, - expandedGroupGuids); + for (int index = 0; index < expandedCells.size(); index++) { + drawExpandedMemberCell(graphics, expandedCells, index); } } + /** 为折叠代表项绘制 GTNH NEI 风格半透明背景。 */ + @Unique + private void drawCollapsedGroupBackground(GuiGraphics graphics, int cx, int cy) { + graphics.fill(cx, cy, cx + ENTRY_SIZE, cy + ENTRY_SIZE, COLLAPSED_BG_COLOR); + } + /** 为折叠代表项绘制单格边框。 */ @Unique private void drawCollapsedGroupOverlay(GuiGraphics graphics, int cx, int cy) { @@ -235,63 +179,47 @@ private void drawCollapsedGroupStack(EmiDrawContext context, EmiIngredient repre context.raw().pose().pushPose(); context.raw().pose().translate(0, 0, -50); - RenderSystem.enableBlend(); - RenderSystem.defaultBlendFunc(); - RenderSystem.setShaderColor(1.0F, 1.0F, 1.0F, STACKED_BACK_ICON_ALPHA); - context.drawStack(secondary, cx + 2, cy - 2, EmiIngredient.RENDER_ICON); - RenderSystem.setShaderColor(1.0F, 1.0F, 1.0F, 1.0F); - context.raw().fill(cx + 2, cy - 2, cx + 18, cy + 14, STACKED_BACK_ICON_DIM_COLOR); + context.drawStack(secondary, cx + COLLAPSED_BACK_X_OFFSET, cy + COLLAPSED_BACK_Y_OFFSET, + EmiIngredient.RENDER_ICON); context.raw().pose().popPose(); - context.drawStack(representative, cx - 1, cy + 1, EmiIngredient.RENDER_ICON); + context.drawStack(representative, cx + COLLAPSED_FRONT_X_OFFSET, cy + COLLAPSED_FRONT_Y_OFFSET, + EmiIngredient.RENDER_ICON); } - /** - * 为展开状态下的单个成员绘制背景和外边框。 - * - *

- * 如果上下左右存在同组相邻成员,则省略共享边,视觉上形成连续的分组块。 - *

- */ + /** 展开成员有相邻同组格子时省略共享边,形成连续区域。 */ @Unique - private void drawExpandedMemberCell(GuiGraphics graphics, int index, List xos, List yos, - List cxs, List cys, List groupGuids) { - int cx = cxs.get(index); - int cy = cys.get(index); + private void drawExpandedMemberCell(GuiGraphics graphics, List cells, int index) { + ExpandedCell cell = cells.get(index); + int cx = cell.cx(); + int cy = cell.cy(); graphics.fill(cx + 1, cy + 1, cx + ENTRY_SIZE - 1, cy + ENTRY_SIZE - 1, GROUP_BG_COLOR); - if (!hasNeighbor(index, xos, yos, groupGuids, 0, -1)) { + if (!hasNeighbor(cells, index, 0, -1)) { graphics.fill(cx, cy, cx + ENTRY_SIZE, cy + 1, GROUP_BORDER_COLOR); } - if (!hasNeighbor(index, xos, yos, groupGuids, 0, 1)) { + if (!hasNeighbor(cells, index, 0, 1)) { graphics.fill(cx, cy + ENTRY_SIZE - 1, cx + ENTRY_SIZE, cy + ENTRY_SIZE, GROUP_BORDER_COLOR); } - if (!hasNeighbor(index, xos, yos, groupGuids, -1, 0)) { + if (!hasNeighbor(cells, index, -1, 0)) { graphics.fill(cx, cy, cx + 1, cy + ENTRY_SIZE, GROUP_BORDER_COLOR); } - if (!hasNeighbor(index, xos, yos, groupGuids, 1, 0)) { + if (!hasNeighbor(cells, index, 1, 0)) { graphics.fill(cx + ENTRY_SIZE - 1, cy, cx + ENTRY_SIZE, cy + ENTRY_SIZE, GROUP_BORDER_COLOR); } } - /** - * 判断指定方向上是否存在同组相邻格子。 - * - * @param index 当前成员在临时坐标列表中的索引 - * @param dx 横向偏移,-1 表示左邻居,1 表示右邻居 - * @param dy 纵向偏移,-1 表示上邻居,1 表示下邻居 - * @return true 表示相邻格子存在且属于同一分组 - */ @Unique - private boolean hasNeighbor(int index, List xos, List yos, List groupGuids, int dx, - int dy) { - int nx = xos.get(index) + dx; - int ny = yos.get(index) + dy; - String groupGuid = groupGuids.get(index); - for (int other = 0; other < xos.size(); other++) { - if (xos.get(other) == nx && yos.get(other) == ny && groupGuids.get(other).equals(groupGuid)) { + private boolean hasNeighbor(List cells, int index, int dx, int dy) { + ExpandedCell cell = cells.get(index); + int nx = cell.xo() + dx; + int ny = cell.yo() + dy; + for (ExpandedCell other : cells) { + if (other.xo() == nx && other.yo() == ny && other.groupGuid().equals(cell.groupGuid())) { return true; } } return false; } + + private record ExpandedCell(int xo, int yo, int cx, int cy, String groupGuid) {} } diff --git a/src/main/java/io/github/cpearl0/ctnhcore/utils/emi/collapsible/CTNHCollapsibleGroups.java b/src/main/java/io/github/cpearl0/ctnhcore/utils/emi/collapsible/CTNHCollapsibleGroups.java index f7f02ecb..7d33ede8 100644 --- a/src/main/java/io/github/cpearl0/ctnhcore/utils/emi/collapsible/CTNHCollapsibleGroups.java +++ b/src/main/java/io/github/cpearl0/ctnhcore/utils/emi/collapsible/CTNHCollapsibleGroups.java @@ -3,7 +3,6 @@ import io.github.cpearl0.ctnhcore.CTNHCore; import net.minecraft.core.registries.Registries; -import net.minecraft.network.chat.Component; import net.minecraft.resources.ResourceLocation; import net.minecraft.tags.TagKey; import net.minecraft.world.item.BlockItem; @@ -16,7 +15,6 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; -import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; @@ -25,31 +23,18 @@ import org.jetbrains.annotations.Nullable; import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; import java.io.Reader; import java.io.Writer; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.*; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; -/** - * EMI 侧栏折叠组的核心管理类。 - * - *

- * 本类负责三件事: - *

- *
    - *
  • 根据 EMI 侧栏提供的 {@link EmiIngredient} 列表注册折叠组。
  • - *
  • 在侧栏渲染前把原始列表投影成“折叠代表项”或“展开成员列表”。
  • - *
  • 把每个分组的展开/折叠状态保存到配置目录,保证重启后状态仍然保留。
  • - *
- * - *

- * 分组规则从 config/ctnhcore/emi_collapsible_groups.json 读取,支持 item/block tag、物品 id 正则和 - * 一组接近 GTNH NEI collapsibleitems.cfg 的简化 item filter 语法。 - *

- */ +/** 管理 EMI 侧栏折叠组:读取规则、匹配成员、投影显示列表并保存展开状态。 */ public class CTNHCollapsibleGroups { private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); @@ -57,14 +42,8 @@ public class CTNHCollapsibleGroups { /** 折叠组展开状态的持久化文件,位于 config/ctnhcore/collapsible_emi_groups.json。 */ private static final Path STATE_FILE = FMLPaths.CONFIGDIR.get().resolve("ctnhcore/collapsible_emi_groups.json"); - /** 折叠组规则定义文件,位于 config/ctnhcore/emi_collapsible_groups.json。 */ - private static final Path RULE_FILE = FMLPaths.CONFIGDIR.get().resolve("ctnhcore/emi_collapsible_groups.json"); - - /** 兼容 GTNH/NEI collapsibleitems.cfg 风格的一行一组配置。 */ - private static final Path LEGACY_RULE_FILE = FMLPaths.CONFIGDIR.get().resolve("ctnhcore/collapsibleitems.cfg"); - - /** 总开关。关闭后不会重建、投影或响应折叠组交互。 */ - private static boolean enabled = true; + /** 折叠组规则定义文件。新增折叠组统一添加到该 assets JSON。 */ + private static final String DEFAULT_RULE_RESOURCE = "/assets/ctnhcore/emi/emi_collapsible_groups.json"; /** 标记 EMI 来源列表是否需要重新扫描并重建分组。 */ private static boolean dirty = true; @@ -81,40 +60,27 @@ public class CTNHCollapsibleGroups { /** 分组 guid -> 是否展开。没有记录时默认视为折叠。 */ private static final Map EXPANDED_STATE = new HashMap<>(); - /** 当前投影列表中的折叠代表项 -> 分组 guid,用于 tooltip、边框和点击切换。 */ + /** 当前投影列表中的折叠代表项 -> 分组 guid,用于边框和点击切换。 */ private static final IdentityHashMap REPRESENTATIVE_TO_GROUP = new IdentityHashMap<>(); - /** 从 JSON/legacy 配置读取并编译后的分组注册器。 */ - private static List configuredProviders = List.of(); + /** 当前投影列表中的折叠代表项 -> 背景代表项。 */ + private static final IdentityHashMap REPRESENTATIVE_TO_SECONDARY = new IdentityHashMap<>(); + + /** 从 JSON 读取并编译后的分组规则。 */ + private static List configuredRules = List.of(); /** 防止每次重建都重复读取规则文件。 */ private static boolean rulesLoaded = false; - /** - * 一个可折叠的 EMI 侧栏分组。 - * - *

- * 分组保存显示名、成员列表、代表项以及展开状态。实际渲染仍由 EMI 完成, - * 本对象只描述“哪些 ingredient 属于同一组”和“当前应该折叠还是展开”。 - *

- */ + /** 一个已匹配完成的折叠组。 */ public static class CollapsibleGroup { /** 分组唯一标识,同时作为持久化状态的 key。 */ public final String guid; - /** tooltip 中显示的分组名称。新增分组时应使用 Component.translatable。 */ - public Component displayName; - /** 当前 EMI 来源列表中属于该分组的成员,顺序沿用 EMI 侧栏原始顺序。 */ public final List members; - /** 分组的主代表项,通常是成员列表中的第一个 ingredient。 */ - public EmiIngredient primaryRepresentative; - - /** 分组的次代表项,折叠渲染时用于画出类似 GTNH NEI 的叠层图标。 */ - public EmiIngredient secondaryRepresentative; - /** * 创建一个空分组。 * @@ -123,51 +89,23 @@ public static class CollapsibleGroup { public CollapsibleGroup(String guid) { this.guid = guid; this.members = new ArrayList<>(); - this.displayName = Component.literal(guid); - this.primaryRepresentative = null; - this.secondaryRepresentative = null; } - /** - * 查询分组是否处于展开状态。 - * - * @return true 表示投影时显示所有成员,false 表示只显示一个代表项 - */ + /** true 表示显示全部成员,false 表示只显示一个代表项。 */ public boolean isExpanded() { Boolean state = EXPANDED_STATE.get(guid); return state != null && state; } - /** - * 设置分组展开状态,并立即保存到配置文件。 - * - * @param expanded true 为展开,false 为折叠 - */ + /** 更新展开状态并保存。 */ public void setExpanded(boolean expanded) { EXPANDED_STATE.put(guid, expanded); saveStates(); } - - /** - * 返回成员数量,用于 UI 统计或判断分组是否值得折叠。 - */ - public int memberCount() { - return members.size(); - } } // ---- 公共 API:供 EMI mixin 查询状态、触发重建和响应交互 ---- - /** 返回折叠功能总开关是否启用。 */ - public static boolean isEnabled() { - return enabled; - } - - /** 设置折叠功能总开关。 */ - public static void setEnabled(boolean e) { - enabled = e; - } - /** 返回当前 EMI 来源列表是否需要重新扫描。 */ public static boolean needsRebuild() { return dirty; @@ -178,23 +116,15 @@ public static void markDirty() { dirty = true; } - /** - * 根据 EMI 侧栏完整来源列表重建所有折叠组。 - * - *

- * 重建会清空旧的对象身份映射,再按当前列表重新注册成员。这样能保证搜索、重载、 - * EMI 刷新后,{@link IdentityHashMap} 中保存的都是当前侧栏正在使用的 ingredient 对象。 - *

- * - * @param stacks EMI INDEX 侧栏的完整 ingredient 来源列表 - */ + /** 用 EMI INDEX 完整列表重建分组和对象身份映射。 */ public static void rebuild(List stacks) { synchronized (GROUPS) { loadStates(); GROUPS.clear(); STACK_TO_GROUP.clear(); REPRESENTATIVE_TO_GROUP.clear(); - if (!enabled || stacks == null || stacks.isEmpty()) { + REPRESENTATIVE_TO_SECONDARY.clear(); + if (stacks == null || stacks.isEmpty()) { dirty = false; return; } @@ -205,113 +135,54 @@ public static void rebuild(List stacks) { } } - /** - * 注册 JSON 配置中声明的折叠分组。 - * - *

- * 每个 ingredient 只会进入最高优先级的命中组;同优先级时保持 provider 注册顺序。 - *

- */ + /** 每个 ingredient 进入最高优先级命中的组;同优先级保持 JSON 顺序。 */ private static void registerConfiguredGroups(List stacks) { - List validProviders = new ArrayList<>(); - for (CollapsibleGroupProvider provider : configuredProviders) { - if (provider.priority() < 0) { - CTNHCore.LOGGER.warn("Skipping EMI collapsible group {} because priority must be non-negative", - provider.guid()); - continue; - } - validProviders.add(provider); - } - - Map groups = new IdentityHashMap<>(); - for (CollapsibleGroupProvider provider : validProviders) { - CollapsibleGroup group = provider.createGroup(); - groups.put(provider, group); + Map groups = new IdentityHashMap<>(); + for (RuleGroupDefinition rule : configuredRules) { + groups.put(rule, new CollapsibleGroup(rule.guid())); } for (EmiIngredient ingredient : stacks) { - CollapsibleGroupProvider bestProvider = null; - for (CollapsibleGroupProvider provider : validProviders) { - if (!provider.matches(ingredient)) continue; - if (bestProvider == null || provider.priority() > bestProvider.priority()) { - bestProvider = provider; + RuleGroupDefinition bestRule = null; + for (RuleGroupDefinition rule : configuredRules) { + if (!rule.matches(ingredient)) continue; + if (bestRule == null || rule.priority() > bestRule.priority()) { + bestRule = rule; } } - if (bestProvider != null) { - groups.get(bestProvider).members.add(ingredient); + if (bestRule != null) { + groups.get(bestRule).members.add(ingredient); } } - for (CollapsibleGroupProvider provider : validProviders) { - registerGroup(groups.get(provider)); + for (RuleGroupDefinition rule : configuredRules) { + registerGroup(groups.get(rule)); } } - /** - * 根据分组 guid 生成显示名。 - * - *

- * 默认工具组沿用已有语言 key,例如 ctnhcore:tools/swords 会尝试读取 - * ctnhcore.emi.collapsible.group.swords。自定义组没有语言条目时显示 guid 本身。 - *

- */ - private static Component displayNameForGroup(String guid) { - CollapsibleGroupProvider provider = findProvider(guid); - if (provider != null) return provider.createDisplayName(); - return fallbackDisplayName(guid); - } - - private static Component fallbackDisplayName(String guid) { - ResourceLocation id = ResourceLocation.tryParse(guid); - String fallback = guid; - String path = id == null ? guid : id.getPath(); - int slash = path.lastIndexOf('/'); - String name = slash >= 0 ? path.substring(slash + 1) : path; - return Component.translatableWithFallback("ctnhcore.emi.collapsible.group." + name, fallback); - } - - /** - * 将构造完成的分组写入全局索引。 - * - *

- * 少于两个成员的分组没有折叠价值,因此不会注册。注册成功后会建立 member -> guid 映射, - * 后续投影、tooltip 和点击逻辑都依赖这个映射。 - *

- */ + /** 少于两个成员的组没有折叠价值,直接丢弃。 */ private static void registerGroup(CollapsibleGroup group) { if (group.members.size() < 2) return; - group.primaryRepresentative = group.members.get(0); - if (group.members.size() > 1) { - group.secondaryRepresentative = group.members.get(1); - } GROUPS.put(group.guid, group); for (EmiIngredient member : group.members) { STACK_TO_GROUP.put(member, group.guid); } } - /** - * 将 EMI 原始侧栏列表投影成实际显示列表。 - * - *

- * 未分组项会原样通过;展开分组会显示所有仍存在于当前 source 中的成员; - * 折叠分组只显示当前遍历到的第一个成员,并把它记录为本轮投影的代表项。 - *

- * - * @param source EMI 当前要渲染的原始列表,可能已经受搜索过滤影响 - * @return 投影后的列表,交给 EMI 继续渲染 - */ + /** 把 EMI 当前列表转换为实际显示列表:展开组显示成员,折叠组按当前搜索结果折叠。 */ public static List project(List source) { - if (!enabled || dirty || GROUPS.isEmpty()) { + if (dirty || GROUPS.isEmpty()) { return source; } synchronized (GROUPS) { List result = new ArrayList<>(); Set projectedGroups = new HashSet<>(); + Map> visibleMembers = visibleMembersByGroup(source); Set sourceStacks = Collections.newSetFromMap(new IdentityHashMap<>()); sourceStacks.addAll(source); REPRESENTATIVE_TO_GROUP.clear(); + REPRESENTATIVE_TO_SECONDARY.clear(); for (EmiIngredient stack : source) { String guid = STACK_TO_GROUP.get(stack); @@ -336,7 +207,11 @@ public static List project(List visible = visibleMembers.getOrDefault(guid, List.of()); + if (visible.size() > 1) { + REPRESENTATIVE_TO_GROUP.put(stack, guid); + REPRESENTATIVE_TO_SECONDARY.put(stack, visible.get(1)); + } result.add(stack); } } @@ -345,13 +220,18 @@ public static List project(List - * 该 ingredient 可以是普通成员,也可以是当前投影生成的折叠代表项。 - *

- */ + private static Map> visibleMembersByGroup(List source) { + Map> visibleMembers = new HashMap<>(); + for (EmiIngredient stack : source) { + String guid = STACK_TO_GROUP.get(stack); + if (guid != null) { + visibleMembers.computeIfAbsent(guid, ignored -> new ArrayList<>()).add(stack); + } + } + return visibleMembers; + } + + /** 查询普通成员或折叠代表项所属的组。 */ @Nullable public static CollapsibleGroup getGroup(EmiIngredient ingredient) { String guid = STACK_TO_GROUP.get(ingredient); @@ -365,38 +245,20 @@ public static boolean isCollapsedRepresentative(EmiIngredient ingredient) { return REPRESENTATIVE_TO_GROUP.containsKey(ingredient); } - /** 判断 ingredient 是否属于任意已注册分组。 */ - public static boolean isInGroup(EmiIngredient ingredient) { - return STACK_TO_GROUP.containsKey(ingredient); - } - /** 返回折叠代表项对应的次代表项,用于叠层绘制。 */ @Nullable public static EmiIngredient getSecondaryRepresentative(EmiIngredient ingredient) { - CollapsibleGroup group = getGroup(ingredient); - if (group == null || !isCollapsedRepresentative(ingredient)) return null; - return group.secondaryRepresentative; + return REPRESENTATIVE_TO_SECONDARY.get(ingredient); } - /** - * 按 guid 切换单个分组的展开状态。 - * - * @param guid 分组唯一标识 - */ - public static void toggleGroup(String guid) { + private static void toggleGroup(String guid) { CollapsibleGroup group = GROUPS.get(guid); if (group != null) { group.setExpanded(!group.isExpanded()); } } - /** - * 从配置文件读取所有分组的展开状态。 - * - *

- * 读取失败只记录警告,不阻止 EMI 打开;无法读取时所有分组按默认折叠处理。 - *

- */ + /** 从 config/ctnhcore/collapsible_emi_groups.json 读取展开状态。 */ private static void loadStates() { if (statesLoaded) return; statesLoaded = true; @@ -412,13 +274,7 @@ private static void loadStates() { } } - /** - * 将当前展开状态写回配置文件。 - * - *

- * 只有状态文件已经完成初次读取后才会保存,避免初始化阶段写出不完整状态。 - *

- */ + /** 保存展开状态。 */ private static void saveStates() { if (!statesLoaded) return; @@ -436,177 +292,56 @@ private static void saveStates() { } } - /** - * 从 JSON 配置文件读取分组规则。 - * - *

- * 文件不存在时会写出接近 GTNH 默认项的配置。读取或解析失败不会阻止 EMI 打开,只会回退为空规则。 - *

- */ + /** 从 assets JSON 读取并编译分组规则。 */ private static void loadRules() { if (rulesLoaded) return; rulesLoaded = true; - ensureDefaultRuleFile(); - - List definitions = new ArrayList<>(); - addBuiltInTagGroups(definitions); - try (Reader reader = Files.newBufferedReader(RULE_FILE)) { - JsonElement root = JsonParser.parseReader(reader); - if (!root.isJsonObject()) { - CTNHCore.LOGGER.warn("EMI collapsible group rule file {} must be a JSON object", RULE_FILE); - configuredProviders = List.of(); - return; - } - JsonObject groups = root.getAsJsonObject(); - for (Map.Entry entry : groups.entrySet()) { - RuleGroupDefinition definition = parseGroupDefinition(entry.getKey(), entry.getValue()); - if (definition != null && !definition.rules().isEmpty()) { - definitions.add(definition); - } - } - } catch (RuntimeException | IOException e) { - CTNHCore.LOGGER.warn("Failed to load EMI collapsible group rules from {}", RULE_FILE, e); - } - definitions.addAll(loadLegacyRuleFile()); - configuredProviders = List.copyOf(definitions); - } - - /** 注册整合包内置的标签型折叠组。 */ - private static void addBuiltInTagGroups(List definitions) { - definitions.add(tagGroup("ctnhcore:blocks/logs", "ctnhcore.emi.collapsible.group.logs", "Logs", - "minecraft:logs")); - definitions.add(tagGroup("ctnhcore:blocks/stairs", "ctnhcore.emi.collapsible.group.stairs", "Stairs", - "minecraft:stairs")); - definitions.add(tagGroup("ctnhcore:blocks/slabs", "ctnhcore.emi.collapsible.group.slabs", "Slabs", - "minecraft:slabs")); - definitions.add(tagGroup("ctnhcore:blocks/fences", "ctnhcore.emi.collapsible.group.fences", "Fences", - "minecraft:fences")); - definitions.add(tagGroup("ctnhcore:blocks/fence_gates", "ctnhcore.emi.collapsible.group.fence_gates", - "Fence Gates", "minecraft:fence_gates")); - definitions.add(tagGroup("ctnhcore:blocks/doors", "ctnhcore.emi.collapsible.group.doors", "Doors", - "minecraft:doors")); - definitions.add(tagGroup("ctnhcore:blocks/trapdoors", "ctnhcore.emi.collapsible.group.trapdoors", - "Trapdoors", "minecraft:trapdoors")); - definitions.add(tagGroup("ctnhcore:blocks/pressure_plates", - "ctnhcore.emi.collapsible.group.pressure_plates", "Pressure Plates", "minecraft:pressure_plates")); - definitions.add(tagGroup("ctnhcore:blocks/buttons", "ctnhcore.emi.collapsible.group.buttons", "Buttons", - "minecraft:buttons")); - } - - private static CollapsibleGroupProvider tagGroup(String guid, String translationKey, String fallbackName, - String tagId) { - ResourceLocation id = ResourceLocation.tryParse(tagId); - if (id == null) { - throw new IllegalArgumentException("Invalid built-in EMI collapsible tag id: " + tagId); - } - return new TagGroupDefinition(guid, translationKey, fallbackName, TagKey.create(Registries.ITEM, id), - TagKey.create(Registries.BLOCK, id)); + List definitions = new ArrayList<>(); + addRuleDefinitions(DEFAULT_RULE_RESOURCE, defaultRuleRoot(), definitions); + configuredRules = List.copyOf(definitions); } - /** 写出接近 GTNH NEI 默认折叠项的规则文件。 */ - private static void ensureDefaultRuleFile() { - if (Files.isRegularFile(RULE_FILE)) return; - - try { - Files.createDirectories(RULE_FILE.getParent()); - JsonObject defaults = new JsonObject(); - defaults.add("ctnhcore:spawn_eggs", - defaultGroup("ctnhcore.emi.collapsible.group.spawn_eggs", "regex:minecraft:.*_spawn_egg")); - defaults.add("ctnhcore:spawners", - defaultGroup("ctnhcore.emi.collapsible.group.spawners", "minecraft:spawner")); - defaults.add("ctnhcore:music_discs", - defaultGroup("ctnhcore.emi.collapsible.group.music_discs", "regex:minecraft:music_disc_.*")); - defaults.add("ctnhcore:splash_potions", - defaultGroup("ctnhcore.emi.collapsible.group.splash_potions", "minecraft:splash_potion")); - defaults.add("ctnhcore:lingering_potions", - defaultGroup("ctnhcore.emi.collapsible.group.lingering_potions", "minecraft:lingering_potion")); - defaults.add("ctnhcore:tools/swords", - defaultGroup("ctnhcore.emi.collapsible.group.swords", "#minecraft:swords", "#forge:tools/swords")); - defaults.add("ctnhcore:tools/pickaxes", - defaultGroup("ctnhcore.emi.collapsible.group.pickaxes", "#minecraft:pickaxes", "#forge:tools/pickaxes")); - defaults.add("ctnhcore:tools/axes", - defaultGroup("ctnhcore.emi.collapsible.group.axes", "#minecraft:axes", "#forge:tools/axes")); - defaults.add("ctnhcore:tools/shovels", - defaultGroup("ctnhcore.emi.collapsible.group.shovels", "#minecraft:shovels", "#forge:tools/shovels")); - defaults.add("ctnhcore:tools/hoes", - defaultGroup("ctnhcore.emi.collapsible.group.hoes", "#minecraft:hoes", "#forge:tools/hoes")); - try (Writer writer = Files.newBufferedWriter(RULE_FILE)) { - GSON.toJson(defaults, writer); + private static JsonObject defaultRuleRoot() { + try (InputStream stream = CTNHCollapsibleGroups.class.getResourceAsStream(DEFAULT_RULE_RESOURCE)) { + if (stream == null) { + CTNHCore.LOGGER.warn("Missing default EMI collapsible group rule resource {}", DEFAULT_RULE_RESOURCE); + return new JsonObject(); } - } catch (IOException e) { - CTNHCore.LOGGER.warn("Failed to create default EMI collapsible group rule file {}", RULE_FILE, e); - } - } - - /** 构造默认规则对象。 */ - private static JsonObject defaultGroup(String translationKey, String... rulesIn) { - JsonObject group = new JsonObject(); - group.addProperty("translationKey", translationKey); - group.addProperty("priority", 0); - JsonArray rules = new JsonArray(); - for (String rule : rulesIn) { - rules.add(rule); - } - group.add("rules", rules); - return group; - } - - /** 读取可选的 GTNH/NEI 风格 collapsibleitems.cfg。 */ - private static List loadLegacyRuleFile() { - if (!Files.isRegularFile(LEGACY_RULE_FILE)) return List.of(); - - List definitions = new ArrayList<>(); - try { - List lines = Files.readAllLines(LEGACY_RULE_FILE); - JsonObject settings = new JsonObject(); - int index = 0; - for (String rawLine : lines) { - String line = rawLine.trim(); - if (line.isEmpty() || line.startsWith("#")) continue; - - if (line.startsWith(";")) { - String json = line.substring(1).trim(); - if (!json.isEmpty()) { - JsonElement element = JsonParser.parseString(json); - if (element.isJsonObject()) { - settings = element.getAsJsonObject(); - } - } - continue; + try (Reader reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) { + JsonElement root = JsonParser.parseReader(reader); + if (root.isJsonObject()) { + return root.getAsJsonObject(); } - - String guid = "ctnhcore:legacy/" + index++; - JsonObject object = new JsonObject(); - if (settings.has("displayName")) object.add("displayName", settings.get("displayName")); - if (settings.has("expanded")) object.add("expanded", settings.get("expanded")); - JsonArray rules = new JsonArray(); - rules.add(line); - object.add("rules", rules); - RuleGroupDefinition definition = parseGroupDefinition(guid, object); - if (definition != null) definitions.add(definition); - settings = new JsonObject(); + CTNHCore.LOGGER.warn("Default EMI collapsible group rule resource {} must be a JSON object", + DEFAULT_RULE_RESOURCE); } } catch (RuntimeException | IOException e) { - CTNHCore.LOGGER.warn("Failed to load legacy EMI collapsible group rules from {}", LEGACY_RULE_FILE, e); + CTNHCore.LOGGER.warn("Failed to read default EMI collapsible group rules from {}", DEFAULT_RULE_RESOURCE, + e); } - return definitions; + return new JsonObject(); } - @Nullable - private static CollapsibleGroupProvider findProvider(String guid) { - for (CollapsibleGroupProvider provider : configuredProviders) { - if (provider.guid().equals(guid)) return provider; + private static void addRuleDefinitions(String sourceName, JsonElement root, + List definitions) { + if (!root.isJsonObject()) { + CTNHCore.LOGGER.warn("EMI collapsible group rule file {} must be a JSON object", sourceName); + return; + } + + for (Map.Entry entry : root.getAsJsonObject().entrySet()) { + RuleGroupDefinition definition = parseGroupDefinition(entry.getKey(), entry.getValue()); + if (definition != null && !definition.rules().isEmpty()) { + definitions.add(definition); + } } - return null; } - /** 解析单个 groupname: rule 定义。 */ + /** 解析单个 JSON 分组。 */ @Nullable private static RuleGroupDefinition parseGroupDefinition(String guid, JsonElement element) { List rules = new ArrayList<>(); - String displayName = null; - String translationKey = null; Boolean expanded = null; int priority = 0; if (element.isJsonPrimitive() && element.getAsJsonPrimitive().isString()) { @@ -621,8 +356,6 @@ private static RuleGroupDefinition parseGroupDefinition(String guid, JsonElement } } else if (element.isJsonObject()) { JsonObject object = element.getAsJsonObject(); - if (object.has("displayName")) displayName = object.get("displayName").getAsString(); - if (object.has("translationKey")) translationKey = object.get("translationKey").getAsString(); if (object.has("expanded")) expanded = object.get("expanded").getAsBoolean(); if (object.has("priority")) { priority = object.get("priority").getAsInt(); @@ -632,9 +365,9 @@ private static RuleGroupDefinition parseGroupDefinition(String guid, JsonElement return null; } } - JsonElement rulesElement = object.get("rules"); + JsonElement rulesElement = object.has("key") ? object.get("key") : object.get("rules"); if (rulesElement == null) { - CTNHCore.LOGGER.warn("Ignoring EMI collapsible group {} because it has no rules", guid); + CTNHCore.LOGGER.warn("Ignoring EMI collapsible group {} because it has no key/rules", guid); } else if (rulesElement.isJsonPrimitive() && rulesElement.getAsJsonPrimitive().isString()) { addRule(guid, rulesElement.getAsString(), rules); } else if (rulesElement.isJsonArray()) { @@ -646,7 +379,8 @@ private static RuleGroupDefinition parseGroupDefinition(String guid, JsonElement } } } else { - CTNHCore.LOGGER.warn("Ignoring EMI collapsible group {} because rules is not a string or string array", + CTNHCore.LOGGER.warn( + "Ignoring EMI collapsible group {} because key/rules is not a string or string array", guid); } } else { @@ -659,7 +393,7 @@ private static RuleGroupDefinition parseGroupDefinition(String guid, JsonElement if (expanded != null && !EXPANDED_STATE.containsKey(guid)) { EXPANDED_STATE.put(guid, expanded); } - return new RuleGroupDefinition(guid, displayName, translationKey, priority, List.copyOf(rules)); + return new RuleGroupDefinition(guid, priority, List.copyOf(rules)); } /** 编译一个规则字符串。 */ @@ -855,48 +589,10 @@ public static boolean hasGroups() { return false; } - /** 创建折叠组并注册其中物品的通用接口。 */ - private interface CollapsibleGroupProvider { - - String guid(); - - Component createDisplayName(); - - default int priority() { - return 0; - } - - boolean matches(EmiIngredient ingredient); - - default CollapsibleGroup createGroup() { - CollapsibleGroup group = new CollapsibleGroup(guid()); - group.displayName = createDisplayName(); - return group; - } - } - - /** 基于配置规则的折叠组定义。 */ - private record RuleGroupDefinition(String guid, @Nullable String displayName, @Nullable String translationKey, - int priority, List rules) implements CollapsibleGroupProvider { - - @Override - public Component createDisplayName() { - if (translationKey != null && !translationKey.isBlank()) { - return Component.translatableWithFallback(translationKey, guid); - } - if (displayName != null && !displayName.isBlank()) { - return Component.literal(displayName); - } - return fallbackDisplayName(guid); - } + /** 一条 JSON 分组定义和它编译后的匹配规则。 */ + private record RuleGroupDefinition(String guid, int priority, List rules) { - @Override - public int priority() { - return priority; - } - - @Override - public boolean matches(EmiIngredient ingredient) { + boolean matches(EmiIngredient ingredient) { for (EmiStack stack : ingredient.getEmiStacks()) { ItemStack itemStack = stack.getItemStack(); if (!itemStack.isEmpty() && matchesItem(itemStack)) { @@ -914,27 +610,6 @@ private boolean matchesItem(ItemStack stack) { } } - /** 基于物品或方块标签的代码型折叠组定义。 */ - private record TagGroupDefinition(String guid, String translationKey, String fallbackName, - TagKey itemTag, TagKey blockTag) implements CollapsibleGroupProvider { - - @Override - public Component createDisplayName() { - return Component.translatableWithFallback(translationKey, fallbackName); - } - - @Override - public boolean matches(EmiIngredient ingredient) { - for (EmiStack stack : ingredient.getEmiStacks()) { - ItemStack itemStack = stack.getItemStack(); - if (!itemStack.isEmpty() && matchesTag(itemStack, itemTag, blockTag)) { - return true; - } - } - return false; - } - } - /** 单条分组规则。 */ private interface GroupRule { diff --git a/src/main/resources/assets/ctnhcore/emi/emi_collapsible_groups.json b/src/main/resources/assets/ctnhcore/emi/emi_collapsible_groups.json new file mode 100644 index 00000000..a1cfc17b --- /dev/null +++ b/src/main/resources/assets/ctnhcore/emi/emi_collapsible_groups.json @@ -0,0 +1,231 @@ +{ + "ctnhcore:spawn_eggs": { + "key": "regex:minecraft:.*_spawn_egg", + "priority": 0 + }, + "ctnhcore:spawners": { + "key": "minecraft:spawner" + }, + "ctnhcore:music_discs": { + "key": "regex:minecraft:music_disc_.*" + }, + "ctnhcore:splash_potions": { + "key": "minecraft:splash_potion" + }, + "ctnhcore:lingering_potions": { + "key": "minecraft:lingering_potion" + }, + "ctnhcore:tools/swords": { + "key": [ + "#minecraft:swords", + "#forge:tools/swords" + ] + }, + "ctnhcore:tools/pickaxes": { + "key": [ + "#minecraft:pickaxes", + "#forge:tools/pickaxes" + ] + }, + "ctnhcore:tools/axes": { + "key": [ + "#minecraft:axes", + "#forge:tools/axes" + ] + }, + "ctnhcore:tools/shovels": { + "key": [ + "#minecraft:shovels", + "#forge:tools/shovels" + ] + }, + "ctnhcore:tools/hoes": { + "key": [ + "#minecraft:hoes", + "#forge:tools/hoes" + ] + }, + "ctnhcore:blocks/logs": { + "key": "#minecraft:logs" + }, + "ctnhcore:blocks/planks": { + "key": "#minecraft:planks" + }, + "ctnhcore:blocks/wool": { + "key": "#minecraft:wool" + }, + "ctnhcore:blocks/glazed_terracotta": { + "key": "regex:minecraft:.*_glazed_terracotta$" + }, + "ctnhcore:blocks/stairs": { + "key": "#minecraft:stairs" + }, + "ctnhcore:blocks/slabs": { + "key": "#minecraft:slabs" + }, + "ctnhcore:blocks/fences": { + "key": "#minecraft:fences" + }, + "ctnhcore:blocks/fence_gates": { + "key": "#minecraft:fence_gates" + }, + "ctnhcore:blocks/doors": { + "key": "#minecraft:doors" + }, + "ctnhcore:blocks/trapdoors": { + "key": "#minecraft:trapdoors" + }, + "ctnhcore:blocks/pressure_plates": { + "key": "#minecraft:pressure_plates" + }, + "ctnhcore:blocks/buttons": { + "key": "#minecraft:buttons" + }, + "ctnhcore:blocks/glass": { + "key": "#forge:glass" + }, + "ctnhcore:blocks/glass_panes": { + "key": "#forge:glass_panes" + }, + "ctnhcore:blocks/beds": { + "key": "#minecraft:beds" + }, + "ctnhcore:blocks/shulker_boxes": { + "key": "#forge:shulker_boxes" + }, + "ctnhcore:blocks/candles": { + "key": "#minecraft:candles" + }, + "ctnhcore:blocks/banners": { + "key": "#minecraft:banners" + }, + "ctnhcore:blocks/concretes": { + "key": "#forge:concretes" + }, + "ctnhcore:blocks/concrete_powders": { + "key": "#forge:concrete_powders" + }, + "ctnhcore:blocks/ores": { + "key": "#forge:ores" + }, + "ctnhcore:blocks/saplings": { + "key": "#minecraft:saplings" + }, + "ctnhcore:blocks/mushrooms": { + "key": "#forge:mushrooms" + }, + "ctnhcore:blocks/flowers": { + "key": "#minecraft:flowers" + }, + "ctnhcore:blocks/corals": { + "key": "#forge:corals" + }, + "ctnhcore:items/painting": { + "key": "minecraft:painting" + }, + "ctnhcore:items/signs": { + "key": "#minecraft:signs" + }, + "ctnhcore:items/hanging_signs": { + "key": "#minecraft:hanging_signs" + }, + "ctnhcore:items/boats": { + "key": "#minecraft:boats" + }, + "ctnhcore:items/goat_horn": { + "key": "minecraft:goat_horn" + }, + "ctnhcore:items/potion_charm": { + "key": "apotheosis:potion_charm" + }, + "ctnhcore:items/helmets": { + "key": "#forge:armors/helmets" + }, + "ctnhcore:items/chestplates": { + "key": "#forge:armors/chestplates" + }, + "ctnhcore:items/leggings": { + "key": "#forge:armors/leggings" + }, + "ctnhcore:items/boots": { + "key": "#forge:armors/boots" + }, + "ctnhcore:items/gloves": { + "key": "#aether:accessories_gloves" + }, + "ctnhcore:items/arrows": { + "key": "#minecraft:arrows" + }, + "ctnhcore:items/suspicious_stew": { + "key": "minecraft:suspicious_stew" + }, + "ctnhcore:items/potions": { + "key": "minecraft:potion" + }, + "ctnhcore:items/enchanted_books": { + "key": "minecraft:enchanted_book" + }, + "ctnhcore:items/decorated_pot_sherds": { + "key": "#minecraft:decorated_pot_sherds" + }, + "ctnhcore:items/trim_templates": { + "key": "#minecraft:trim_templates" + }, + "ctnhcore:items/dyes": { + "key": "#forge:dyes" + }, + "ctnhcore:items/sophisticatedstorage_barrels": { + "key": [ + "sophisticatedstorage:barrel", + "sophisticatedstorage:copper_barrel", + "sophisticatedstorage:iron_barrel", + "sophisticatedstorage:gold_barrel", + "sophisticatedstorage:diamond_barrel", + "sophisticatedstorage:netherite_barrel", + "sophisticatedstorage:limited_barrel_1", + "sophisticatedstorage:limited_barrel_2", + "sophisticatedstorage:limited_barrel_3", + "sophisticatedstorage:limited_barrel_4", + "sophisticatedstorage:limited_copper_barrel_1", + "sophisticatedstorage:limited_copper_barrel_2", + "sophisticatedstorage:limited_copper_barrel_3", + "sophisticatedstorage:limited_copper_barrel_4", + "sophisticatedstorage:limited_iron_barrel_1", + "sophisticatedstorage:limited_iron_barrel_2", + "sophisticatedstorage:limited_iron_barrel_3", + "sophisticatedstorage:limited_iron_barrel_4", + "sophisticatedstorage:limited_gold_barrel_1", + "sophisticatedstorage:limited_gold_barrel_2", + "sophisticatedstorage:limited_gold_barrel_3", + "sophisticatedstorage:limited_gold_barrel_4", + "sophisticatedstorage:limited_diamond_barrel_1", + "sophisticatedstorage:limited_diamond_barrel_2", + "sophisticatedstorage:limited_diamond_barrel_3", + "sophisticatedstorage:limited_diamond_barrel_4", + "sophisticatedstorage:limited_netherite_barrel_1", + "sophisticatedstorage:limited_netherite_barrel_2", + "sophisticatedstorage:limited_netherite_barrel_3", + "sophisticatedstorage:limited_netherite_barrel_4" + ] + }, + "ctnhcore:items/sophisticatedstorage_chests": { + "key": [ + "sophisticatedstorage:chest", + "sophisticatedstorage:copper_chest", + "sophisticatedstorage:iron_chest", + "sophisticatedstorage:gold_chest", + "sophisticatedstorage:diamond_chest", + "sophisticatedstorage:netherite_chest" + ] + }, + "ctnhcore:items/sophisticatedstorage_shulker_boxes": { + "key": [ + "sophisticatedstorage:shulker_box", + "sophisticatedstorage:copper_shulker_box", + "sophisticatedstorage:iron_shulker_box", + "sophisticatedstorage:gold_shulker_box", + "sophisticatedstorage:diamond_shulker_box", + "sophisticatedstorage:netherite_shulker_box" + ] + } +}