feat: Shift+F 메뉴 GUI 구현
- EssentialsMenuGui 추가 (스폰, 좌표, 제작대, TPA 바로가기) - Mixin으로 손 바꾸기 패킷 가로채기 (빈 손에서도 작동) - Shift+F: 손 바꾸기 차단 + 메뉴 GUI 열기 - /메뉴, /menu 명령어 추가 - 도움말에 메뉴 카테고리 추가
This commit is contained in:
parent
ee66dddd99
commit
8cc50ce449
8 changed files with 383 additions and 0 deletions
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Essentials Access Transformer
|
||||||
|
# ServerGamePacketListenerImpl의 player 필드에 접근하기 위한 설정은 필요 없음
|
||||||
|
# NeoForge에서 ServerPlayer 접근 가능
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
"PlayerInfoPacketMixin",
|
"PlayerInfoPacketMixin",
|
||||||
"PlayerListMixin",
|
"PlayerListMixin",
|
||||||
"PlayerMixin",
|
"PlayerMixin",
|
||||||
|
"ServerGamePacketListenerImplMixin",
|
||||||
"ServerPlayerMixin"
|
"ServerPlayerMixin"
|
||||||
],
|
],
|
||||||
"client": [],
|
"client": [],
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue