feat: Shift+F 메뉴 GUI 구현

- EssentialsMenuGui 추가 (스폰, 좌표, 제작대, TPA 바로가기)
- Mixin으로 손 바꾸기 패킷 가로채기 (빈 손에서도 작동)
- Shift+F: 손 바꾸기 차단 + 메뉴 GUI 열기
- /메뉴, /menu 명령어 추가
- 도움말에 메뉴 카테고리 추가
This commit is contained in:
Caadiq 2025-12-29 15:44:48 +09:00
parent ee66dddd99
commit 8cc50ce449
8 changed files with 383 additions and 0 deletions

View file

@ -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);
}
}
}
}

View file

@ -34,5 +34,6 @@ class Essentials(modEventBus: IEventBus) {
NeoForge.EVENT_BUS.register(HeadCommand) NeoForge.EVENT_BUS.register(HeadCommand)
NeoForge.EVENT_BUS.register(HelpCommand) NeoForge.EVENT_BUS.register(HelpCommand)
NeoForge.EVENT_BUS.register(WorkbenchCommand) NeoForge.EVENT_BUS.register(WorkbenchCommand)
NeoForge.EVENT_BUS.register(MenuCommand)
} }
} }

View file

@ -75,6 +75,12 @@ object HelpCommand {
// 제작대 // 제작대
sendCategory(player, "제작대", ChatFormatting.WHITE) sendCategory(player, "제작대", ChatFormatting.WHITE)
sendCommand(player, "/제작대", "제작대 열기") sendCommand(player, "/제작대", "제작대 열기")
player.sendSystemMessage(Component.empty())
// 메뉴
sendCategory(player, "메뉴", ChatFormatting.LIGHT_PURPLE)
sendCommand(player, "/메뉴", "메뉴 GUI 열기")
sendCommand(player, "Shift + F", "메뉴 GUI 열기 (단축키)")
val footer = val footer =
Component.literal("═══════════════════════════════").withStyle { Component.literal("═══════════════════════════════").withStyle {

View file

@ -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
}
)
}
}
}

View file

@ -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<UUID, Long>()
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)
}
}

View file

@ -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 <T : Any> evaluate(
function:
java.util.function.BiFunction<
net.minecraft.world.level.Level,
net.minecraft.core.BlockPos,
T>
): java.util.Optional<T> {
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) }
)
)
}

View file

@ -0,0 +1,3 @@
# Essentials Access Transformer
# ServerGamePacketListenerImpl의 player 필드에 접근하기 위한 설정은 필요 없음
# NeoForge에서 ServerPlayer 접근 가능

View file

@ -10,6 +10,7 @@
"PlayerInfoPacketMixin", "PlayerInfoPacketMixin",
"PlayerListMixin", "PlayerListMixin",
"PlayerMixin", "PlayerMixin",
"ServerGamePacketListenerImplMixin",
"ServerPlayerMixin" "ServerPlayerMixin"
], ],
"client": [], "client": [],