From 8cc50ce449d7d56a8e4d16cc64e99e6c417b99d4 Mon Sep 17 00:00:00 2001 From: Caadiq Date: Mon, 29 Dec 2025 15:44:48 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Shift+F=20=EB=A9=94=EB=89=B4=20GUI=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EssentialsMenuGui 추가 (스폰, 좌표, 제작대, TPA 바로가기) - Mixin으로 손 바꾸기 패킷 가로채기 (빈 손에서도 작동) - Shift+F: 손 바꾸기 차단 + 메뉴 GUI 열기 - /메뉴, /menu 명령어 추가 - 도움말에 메뉴 카테고리 추가 --- .../ServerGamePacketListenerImplMixin.java | 37 +++ .../com/beemer/essentials/Essentials.kt | 1 + .../beemer/essentials/command/HelpCommand.kt | 6 + .../beemer/essentials/command/MenuCommand.kt | 23 ++ .../beemer/essentials/event/MenuKeyHandler.kt | 44 +++ .../essentials/gui/EssentialsMenuGui.kt | 268 ++++++++++++++++++ .../resources/META-INF/accesstransformer.cfg | 3 + .../src/main/resources/essentials.mixins.json | 1 + 8 files changed, 383 insertions(+) create mode 100644 Essentials/src/main/java/com/beemer/essentials/mixin/ServerGamePacketListenerImplMixin.java create mode 100644 Essentials/src/main/kotlin/com/beemer/essentials/command/MenuCommand.kt create mode 100644 Essentials/src/main/kotlin/com/beemer/essentials/event/MenuKeyHandler.kt create mode 100644 Essentials/src/main/kotlin/com/beemer/essentials/gui/EssentialsMenuGui.kt create mode 100644 Essentials/src/main/resources/META-INF/accesstransformer.cfg diff --git a/Essentials/src/main/java/com/beemer/essentials/mixin/ServerGamePacketListenerImplMixin.java b/Essentials/src/main/java/com/beemer/essentials/mixin/ServerGamePacketListenerImplMixin.java new file mode 100644 index 0000000..38b4211 --- /dev/null +++ b/Essentials/src/main/java/com/beemer/essentials/mixin/ServerGamePacketListenerImplMixin.java @@ -0,0 +1,37 @@ +package com.beemer.essentials.mixin; + +import com.beemer.essentials.event.MenuKeyHandler; +import net.minecraft.network.protocol.game.ServerboundPlayerActionPacket; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.network.ServerGamePacketListenerImpl; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * 손 바꾸기 패킷 가로채기 + * Shift + F 조합으로 메뉴 GUI 열기 + */ +@Mixin(ServerGamePacketListenerImpl.class) +public class ServerGamePacketListenerImplMixin { + + @Shadow + public ServerPlayer player; + + @Inject(method = "handlePlayerAction", at = @At("HEAD"), cancellable = true) + private void onHandlePlayerAction(ServerboundPlayerActionPacket packet, CallbackInfo ci) { + // 손 바꾸기 액션인지 확인 + if (packet.getAction() == ServerboundPlayerActionPacket.Action.SWAP_ITEM_WITH_OFFHAND) { + // Shift를 누르고 있으면 메뉴 열기 + if (player.isShiftKeyDown()) { + // 패킷 처리 취소 (실제 손 바꾸기 방지) + ci.cancel(); + + // 메뉴 GUI 열기 + MenuKeyHandler.INSTANCE.openMenuFromPacket(player); + } + } + } +} diff --git a/Essentials/src/main/kotlin/com/beemer/essentials/Essentials.kt b/Essentials/src/main/kotlin/com/beemer/essentials/Essentials.kt index 812d52e..a5dd388 100644 --- a/Essentials/src/main/kotlin/com/beemer/essentials/Essentials.kt +++ b/Essentials/src/main/kotlin/com/beemer/essentials/Essentials.kt @@ -34,5 +34,6 @@ class Essentials(modEventBus: IEventBus) { NeoForge.EVENT_BUS.register(HeadCommand) NeoForge.EVENT_BUS.register(HelpCommand) NeoForge.EVENT_BUS.register(WorkbenchCommand) + NeoForge.EVENT_BUS.register(MenuCommand) } } diff --git a/Essentials/src/main/kotlin/com/beemer/essentials/command/HelpCommand.kt b/Essentials/src/main/kotlin/com/beemer/essentials/command/HelpCommand.kt index 00f1a3d..02950d2 100644 --- a/Essentials/src/main/kotlin/com/beemer/essentials/command/HelpCommand.kt +++ b/Essentials/src/main/kotlin/com/beemer/essentials/command/HelpCommand.kt @@ -75,6 +75,12 @@ object HelpCommand { // 제작대 sendCategory(player, "제작대", ChatFormatting.WHITE) sendCommand(player, "/제작대", "제작대 열기") + player.sendSystemMessage(Component.empty()) + + // 메뉴 + sendCategory(player, "메뉴", ChatFormatting.LIGHT_PURPLE) + sendCommand(player, "/메뉴", "메뉴 GUI 열기") + sendCommand(player, "Shift + F", "메뉴 GUI 열기 (단축키)") val footer = Component.literal("═══════════════════════════════").withStyle { diff --git a/Essentials/src/main/kotlin/com/beemer/essentials/command/MenuCommand.kt b/Essentials/src/main/kotlin/com/beemer/essentials/command/MenuCommand.kt new file mode 100644 index 0000000..49cd83c --- /dev/null +++ b/Essentials/src/main/kotlin/com/beemer/essentials/command/MenuCommand.kt @@ -0,0 +1,23 @@ +package com.beemer.essentials.command + +import com.beemer.essentials.gui.openEssentialsMenu +import net.minecraft.commands.Commands +import net.minecraft.server.level.ServerPlayer +import net.neoforged.bus.api.SubscribeEvent +import net.neoforged.neoforge.event.RegisterCommandsEvent + +object MenuCommand { + @SubscribeEvent + fun onRegisterCommands(event: RegisterCommandsEvent) { + // /메뉴, /menu - 메뉴 GUI 열기 + listOf("메뉴", "menu").forEach { command -> + event.dispatcher.register( + Commands.literal(command).executes { context -> + val player = context.source.entity as? ServerPlayer ?: return@executes 0 + openEssentialsMenu(player) + 1 + } + ) + } + } +} diff --git a/Essentials/src/main/kotlin/com/beemer/essentials/event/MenuKeyHandler.kt b/Essentials/src/main/kotlin/com/beemer/essentials/event/MenuKeyHandler.kt new file mode 100644 index 0000000..10bc90b --- /dev/null +++ b/Essentials/src/main/kotlin/com/beemer/essentials/event/MenuKeyHandler.kt @@ -0,0 +1,44 @@ +package com.beemer.essentials.event + +import com.beemer.essentials.gui.openEssentialsMenu +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import net.minecraft.server.level.ServerPlayer + +/** + * Shift + F (손 바꾸기) 패킷 감지하여 메뉴 GUI 열기 + * + * 동작 방식: + * 1. 플레이어가 Shift를 누르고 있는 상태에서 + * 2. F키(손 바꾸기)를 누르면 + * 3. 손 바꾸기 패킷을 취소하고 메뉴 GUI를 엽니다 + * + * Mixin으로 패킷을 가로채기 때문에 빈 손에서도 작동합니다. + */ +object MenuKeyHandler { + // 메뉴 열기 쿨다운 (밀리초 단위, 더블 감지 방지) + private val cooldowns = ConcurrentHashMap() + private const val COOLDOWN_MS = 500L + + /** Mixin에서 호출 - 패킷 감지 시 메뉴 열기 */ + fun openMenuFromPacket(player: ServerPlayer) { + if (isOnCooldown(player.uuid)) return + + setCooldown(player.uuid) + openEssentialsMenu(player) + } + + private fun isOnCooldown(uuid: UUID): Boolean { + val lastTime = cooldowns[uuid] ?: return false + return System.currentTimeMillis() - lastTime < COOLDOWN_MS + } + + private fun setCooldown(uuid: UUID) { + cooldowns[uuid] = System.currentTimeMillis() + } + + /** 플레이어 로그아웃 시 상태 정리 */ + fun cleanup(uuid: UUID) { + cooldowns.remove(uuid) + } +} diff --git a/Essentials/src/main/kotlin/com/beemer/essentials/gui/EssentialsMenuGui.kt b/Essentials/src/main/kotlin/com/beemer/essentials/gui/EssentialsMenuGui.kt new file mode 100644 index 0000000..d75ab8d --- /dev/null +++ b/Essentials/src/main/kotlin/com/beemer/essentials/gui/EssentialsMenuGui.kt @@ -0,0 +1,268 @@ +package com.beemer.essentials.gui + +import net.minecraft.ChatFormatting +import net.minecraft.core.component.DataComponents +import net.minecraft.nbt.CompoundTag +import net.minecraft.network.chat.Component +import net.minecraft.server.level.ServerPlayer +import net.minecraft.world.Container +import net.minecraft.world.SimpleContainer +import net.minecraft.world.SimpleMenuProvider +import net.minecraft.world.entity.player.Inventory +import net.minecraft.world.entity.player.Player +import net.minecraft.world.inventory.AbstractContainerMenu +import net.minecraft.world.inventory.ClickType +import net.minecraft.world.inventory.ContainerLevelAccess +import net.minecraft.world.inventory.CraftingMenu +import net.minecraft.world.inventory.MenuType +import net.minecraft.world.inventory.Slot +import net.minecraft.world.item.ItemStack +import net.minecraft.world.item.Items +import net.minecraft.world.item.component.CustomData +import net.minecraft.world.item.component.ItemLore + +/** Essentials 메뉴 GUI - Shift+F로 열기 스폰, 좌표, 제작대 등 주요 기능 바로가기 */ +class EssentialsMenuGui( + syncId: Int, + playerInv: Inventory, + val container: Container, + val viewer: ServerPlayer +) : AbstractContainerMenu(MenuType.GENERIC_9x3, syncId) { + + companion object { + const val CONTAINER_SIZE = 27 + + // 메뉴 아이템 슬롯 위치 + const val SLOT_SPAWN = 10 // 스폰 + const val SLOT_COORDINATES = 12 // 좌표 + const val SLOT_WORKBENCH = 14 // 제작대 + const val SLOT_TPA = 16 // TPA + } + + init { + // 컨테이너 슬롯 추가 + for (i in 0 until CONTAINER_SIZE) { + val x = 8 + (i % 9) * 18 + val y = 18 + (i / 9) * 18 + addSlot( + object : Slot(container, i, x, y) { + override fun mayPickup(player: Player): Boolean = false + override fun mayPlace(stack: ItemStack): Boolean = false + } + ) + } + + // 플레이어 인벤토리 슬롯 + for (row in 0 until 3) { + for (col in 0 until 9) { + val index = col + row * 9 + 9 + val x = 8 + col * 18 + val y = 86 + row * 18 + addSlot(Slot(playerInv, index, x, y)) + } + } + for (col in 0 until 9) { + val x = 8 + col * 18 + val y = 144 + addSlot(Slot(playerInv, col, x, y)) + } + } + + override fun clicked(slotId: Int, button: Int, clickType: ClickType, player: Player) { + if (slotId !in 0 until CONTAINER_SIZE || player !is ServerPlayer) { + super.clicked(slotId, button, clickType, player) + return + } + + val stack = slots[slotId].item + val tag = stack.get(DataComponents.CUSTOM_DATA)?.copyTag() ?: return + val action = tag.getString("action") + + when (action) { + "spawn" -> { + player.closeContainer() + // 스폰 명령어 실행 + player.server.commands.performPrefixedCommand( + player.createCommandSourceStack(), + "스폰" + ) + } + "coordinates" -> { + player.closeContainer() + // 좌표 GUI 열기 + player.server.commands.performPrefixedCommand( + player.createCommandSourceStack(), + "좌표" + ) + } + "workbench" -> { + player.closeContainer() + // 제작대 열기 + openWorkbench(player) + } + "tpa" -> { + player.closeContainer() + // TPA GUI 열기 + player.server.commands.performPrefixedCommand( + player.createCommandSourceStack(), + "tpa" + ) + } + } + } + + private fun openWorkbench(player: ServerPlayer) { + val access = + object : ContainerLevelAccess { + override fun evaluate( + function: + java.util.function.BiFunction< + net.minecraft.world.level.Level, + net.minecraft.core.BlockPos, + T> + ): java.util.Optional { + return java.util.Optional.ofNullable( + function.apply(player.level(), player.blockPosition()) + ) + } + } + + player.openMenu( + SimpleMenuProvider( + { containerId, inventory, _ -> + object : CraftingMenu(containerId, inventory, access) { + override fun stillValid(player: Player): Boolean = true + } + }, + Component.translatable("container.crafting") + ) + ) + } + + override fun stillValid(player: Player): Boolean = true + override fun quickMoveStack(player: Player, index: Int): ItemStack = ItemStack.EMPTY +} + +/** 메뉴 컨테이너 생성 */ +fun createEssentialsMenuContainer(): SimpleContainer { + val container = SimpleContainer(27) + + // 배경을 회색 유리판으로 채우기 + val fillerItem = ItemStack(Items.GRAY_STAINED_GLASS_PANE) + fillerItem.set( + DataComponents.CUSTOM_NAME, + Component.literal(" ").withStyle { it.withColor(ChatFormatting.DARK_GRAY) } + ) + for (i in 0 until 27) { + container.setItem(i, fillerItem.copy()) + } + + // 스폰 버튼 + val spawnItem = ItemStack(Items.GRASS_BLOCK) + spawnItem.set( + DataComponents.CUSTOM_NAME, + Component.literal("스폰으로 이동").withStyle { + it.withColor(ChatFormatting.GREEN).withBold(true) + } + ) + spawnItem.set( + DataComponents.LORE, + ItemLore( + listOf( + Component.literal("클릭하여 스폰으로 이동합니다.").withStyle { + it.withColor(ChatFormatting.GRAY) + } + ) + ) + ) + spawnItem.set( + DataComponents.CUSTOM_DATA, + CustomData.of(CompoundTag().apply { putString("action", "spawn") }) + ) + container.setItem(EssentialsMenuGui.SLOT_SPAWN, spawnItem) + + // 좌표 버튼 + val coordItem = ItemStack(Items.COMPASS) + coordItem.set( + DataComponents.CUSTOM_NAME, + Component.literal("저장된 좌표").withStyle { + it.withColor(ChatFormatting.YELLOW).withBold(true) + } + ) + coordItem.set( + DataComponents.LORE, + ItemLore( + listOf( + Component.literal("클릭하여 좌표 목록을 엽니다.").withStyle { + it.withColor(ChatFormatting.GRAY) + } + ) + ) + ) + coordItem.set( + DataComponents.CUSTOM_DATA, + CustomData.of(CompoundTag().apply { putString("action", "coordinates") }) + ) + container.setItem(EssentialsMenuGui.SLOT_COORDINATES, coordItem) + + // 제작대 버튼 + val workbenchItem = ItemStack(Items.CRAFTING_TABLE) + workbenchItem.set( + DataComponents.CUSTOM_NAME, + Component.literal("제작대 열기").withStyle { + it.withColor(ChatFormatting.GOLD).withBold(true) + } + ) + workbenchItem.set( + DataComponents.LORE, + ItemLore( + listOf( + Component.literal("클릭하여 제작대를 엽니다.").withStyle { + it.withColor(ChatFormatting.GRAY) + } + ) + ) + ) + workbenchItem.set( + DataComponents.CUSTOM_DATA, + CustomData.of(CompoundTag().apply { putString("action", "workbench") }) + ) + container.setItem(EssentialsMenuGui.SLOT_WORKBENCH, workbenchItem) + + // TPA 버튼 + val tpaItem = ItemStack(Items.ENDER_PEARL) + tpaItem.set( + DataComponents.CUSTOM_NAME, + Component.literal("플레이어 텔레포트").withStyle { + it.withColor(ChatFormatting.AQUA).withBold(true) + } + ) + tpaItem.set( + DataComponents.LORE, + ItemLore( + listOf( + Component.literal("클릭하여 다른 플레이어에게 이동합니다.").withStyle { + it.withColor(ChatFormatting.GRAY) + } + ) + ) + ) + tpaItem.set( + DataComponents.CUSTOM_DATA, + CustomData.of(CompoundTag().apply { putString("action", "tpa") }) + ) + container.setItem(EssentialsMenuGui.SLOT_TPA, tpaItem) + + return container +} + +/** 메뉴 GUI 열기 */ +fun openEssentialsMenu(player: ServerPlayer) { + val container = createEssentialsMenuContainer() + player.openMenu( + SimpleMenuProvider( + { windowId, inv, _ -> EssentialsMenuGui(windowId, inv, container, player) }, + Component.literal("Essentials 메뉴").withStyle { it.withBold(true) } + ) + ) +} diff --git a/Essentials/src/main/resources/META-INF/accesstransformer.cfg b/Essentials/src/main/resources/META-INF/accesstransformer.cfg new file mode 100644 index 0000000..a1320d0 --- /dev/null +++ b/Essentials/src/main/resources/META-INF/accesstransformer.cfg @@ -0,0 +1,3 @@ +# Essentials Access Transformer +# ServerGamePacketListenerImpl의 player 필드에 접근하기 위한 설정은 필요 없음 +# NeoForge에서 ServerPlayer 접근 가능 diff --git a/Essentials/src/main/resources/essentials.mixins.json b/Essentials/src/main/resources/essentials.mixins.json index fa2bde8..4d5ccd8 100644 --- a/Essentials/src/main/resources/essentials.mixins.json +++ b/Essentials/src/main/resources/essentials.mixins.json @@ -10,6 +10,7 @@ "PlayerInfoPacketMixin", "PlayerListMixin", "PlayerMixin", + "ServerGamePacketListenerImplMixin", "ServerPlayerMixin" ], "client": [],