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(HelpCommand)
|
||||
NeoForge.EVENT_BUS.register(WorkbenchCommand)
|
||||
NeoForge.EVENT_BUS.register(MenuCommand)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
"PlayerListMixin",
|
||||
"PlayerMixin",
|
||||
"ServerGamePacketListenerImplMixin",
|
||||
"ServerPlayerMixin"
|
||||
],
|
||||
"client": [],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue