Compare commits

..

8 commits

Author SHA1 Message Date
Caadiq
2d42669545 style: 도움말 및 웰컴 메시지 색상 커스터마이징
- 볼드체 모두 제거
- 카테고리별 고유 커스텀 RGB 색상 적용
- 도움말: 좌표(노랑), 스폰(연두), 텔레포트(하늘), 닉네임(보라), 머리(주황), 제작대(라임), 메뉴(핑크)
- 웰컴: 구분선(회색), 환영(하늘), 카테고리(노랑)
2025-12-31 18:57:29 +09:00
Caadiq
2bf161bf15 refactor: SoundUtils 유틸리티로 사운드 재생 로직 중앙화
- 새 파일: SoundUtils.kt - 모든 사운드 재생 함수 통합
- 새 파일: LocationUtils.kt - 플레이어 위치 변환 유틸리티
- 10개 파일에서 중복 사운드 재생 코드 제거 (약 150줄)
- GUI: EssentialsMenuGui, CoordinateGui, TeleportGui, AntimobGui
- Command: SpawnCommand, PlayerCommand, CoordinateCommand, TeleportCommand
- Event/Util: PlayerEvents, MessageUtils
2025-12-30 19:40:36 +09:00
Caadiq
fd4540c9e7 feat: 커스텀 사운드 시스템 추가
- 클릭 사운드 (custom.click) - GUI 버튼 클릭 시
- 에러 사운드 (custom.error) - 오류 메시지 시 자동 재생
- 알림 사운드 (custom.notification) - 신규 플레이어 환영 시
- 텔레포트 사운드 (custom.teleport) - 스폰, 좌표이동, TPA, back
- 메뉴 사운드 (custom.menu) - 메뉴 GUI 열기 시

기타 변경:
- 닉네임 변경 시 동일 닉네임 예외처리 추가
- TPA 오류 메시지 빨간색으로 변경
2025-12-30 19:26:10 +09:00
Caadiq
4dba2a4803 feat: GUI 클릭 소리 및 개선
- 모든 GUI에 bell 클릭 소리 추가 (playNotifySound로 해당 플레이어만 듣도록)
- TeleportGui: 다른 플레이어 없으면 메시지만 표시, 빈 공간 클릭 무시
- EssentialsMenuGui: 아이템 이름 간소화 (스폰/좌표/제작대/텔레포트)
- 설명 텍스트에서 '클릭하여' 제거, 좌표에 '저장된' 추가
2025-12-29 17:27:49 +09:00
Caadiq
791d6f2e9f feat: 신규 플레이어 도움말 표시
- 처음 접속한 플레이어에게 환영 메시지 표시
- 기본 명령어 안내 (/도움말, /메뉴, /스폰)
- 단축키 안내 (Shift + F)
2025-12-29 16:32:01 +09:00
Caadiq
8cc50ce449 feat: Shift+F 메뉴 GUI 구현
- EssentialsMenuGui 추가 (스폰, 좌표, 제작대, TPA 바로가기)
- Mixin으로 손 바꾸기 패킷 가로채기 (빈 손에서도 작동)
- Shift+F: 손 바꾸기 차단 + 메뉴 GUI 열기
- /메뉴, /menu 명령어 추가
- 도움말에 메뉴 카테고리 추가
2025-12-29 15:44:48 +09:00
Caadiq
ee66dddd99 style: MessageUtils 도입 - 전체 채팅 스타일 개선
- MessageUtils 유틸리티 추가 (청록색 기본 + 흰색 강조)
- 모든 명령어 및 GUI에 통일된 메시지 스타일 적용
- 수정 파일: SpawnCommand, NicknameCommand, CoordinateCommand, HeadCommand, PlayerCommand, ProtectFarmlandCommand, ChatCommand, TeleportGui, CoordinateGui, ChatEvents
2025-12-29 15:13:37 +09:00
Caadiq
d0d8d0650f feat: /제작대, /workbench 명령어 추가 - 제작대 블록 없이 제작대 GUI 열기 2025-12-29 14:47:15 +09:00
25 changed files with 1207 additions and 526 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

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

View file

@ -2,11 +2,10 @@ package com.beemer.essentials.command
import com.beemer.essentials.config.ChatConfig
import com.beemer.essentials.util.ChatUtils
import com.beemer.essentials.util.MessageUtils
import com.mojang.brigadier.exceptions.CommandSyntaxException
import net.minecraft.ChatFormatting
import net.minecraft.commands.CommandSourceStack
import net.minecraft.commands.Commands
import net.minecraft.network.chat.Component
import net.minecraft.server.level.ServerPlayer
import net.neoforged.bus.api.SubscribeEvent
import net.neoforged.neoforge.event.RegisterCommandsEvent
@ -31,11 +30,7 @@ object ChatCommand {
private fun hasPermissionOrSend(player: ServerPlayer?, requiredLevel: Int = 2): Boolean {
if (player != null && !player.hasPermissions(requiredLevel)) {
player.sendSystemMessage(
Component.literal("해당 명령어를 실행할 권한이 없습니다.").withStyle {
it.withColor(ChatFormatting.RED)
}
)
MessageUtils.sendError(player, "해당 명령어를 실행할 권한이 없습니다.")
return false
}
return true
@ -51,15 +46,15 @@ object ChatCommand {
ChatConfig.loadConfig()
val success =
Component.literal("채팅 형식을 새로고침했습니다.").withStyle {
it.withColor(ChatFormatting.GOLD)
if (player != null) {
MessageUtils.sendSuccess(player, "채팅 형식을 새로고침했습니다.")
} else {
context.source.sendSuccess(
{ MessageUtils.success("채팅 형식을 새로고침했습니다.") },
false
)
}
if (player == null)
context.source.sendSuccess({ success }, false)
else player.sendSystemMessage(success)
1
}
)
@ -81,9 +76,7 @@ object ChatCommand {
ChatUtils.clearChatForAll(allPlayers)
server.playerList.broadcastSystemMessage(
Component.literal("\u00A0\u00A0채팅창을 비웠습니다.").withStyle {
it.withColor(ChatFormatting.AQUA)
},
MessageUtils.info("채팅창을 비웠습니다."),
false
)

View file

@ -7,8 +7,9 @@ import com.beemer.essentials.data.Location
import com.beemer.essentials.gui.Menu
import com.beemer.essentials.gui.createPageContainer
import com.beemer.essentials.util.DimensionUtils
import com.beemer.essentials.util.MessageUtils
import com.beemer.essentials.util.SoundUtils
import com.mojang.brigadier.arguments.StringArgumentType
import net.minecraft.ChatFormatting
import net.minecraft.commands.Commands
import net.minecraft.network.chat.Component
import net.minecraft.server.level.ServerPlayer
@ -19,7 +20,7 @@ import net.neoforged.neoforge.event.RegisterCommandsEvent
object CoordinateCommand {
@SubscribeEvent
fun onRegisterCommands(event: RegisterCommandsEvent) {
// /좌표 - GUI 열기 (페이지 0부터 시작)
// /좌표 - GUI 열기
event.dispatcher.register(
Commands.literal("좌표").executes { context ->
val player = context.source.playerOrException as ServerPlayer
@ -28,7 +29,7 @@ object CoordinateCommand {
}
)
// /좌표이동 <장소> - 바로 이동
// /좌표이동 <장소>
event.dispatcher.register(
Commands.literal("좌표이동")
.then(
@ -70,16 +71,9 @@ object CoordinateCommand {
)
if (CoordinateConfig.isExist(name)) {
player.sendSystemMessage(
Component.literal(
"$name 좌표가 이미 존재합니다."
)
.withStyle {
it.withColor(
ChatFormatting
.RED
)
}
MessageUtils.sendError(
player,
"{$name} 좌표가 이미 존재합니다."
)
return@executes 0
}
@ -116,16 +110,9 @@ object CoordinateCommand {
)
CoordinateConfig.addCoordinate(coordinate)
player.sendSystemMessage(
Component.literal(
"$name 좌표를 추가했습니다."
)
.withStyle {
it.withColor(
ChatFormatting
.GOLD
)
}
MessageUtils.sendSuccess(
player,
"{$name} 좌표를 추가했습니다."
)
1
}
@ -155,31 +142,17 @@ object CoordinateCommand {
)
if (!CoordinateConfig.isExist(name)) {
player.sendSystemMessage(
Component.literal(
"$name 좌표가 존재하지 않습니다."
)
.withStyle {
it.withColor(
ChatFormatting
.RED
)
}
MessageUtils.sendError(
player,
"{$name} 좌표가 존재하지 않습니다."
)
return@executes 0
}
CoordinateConfig.removeCoordinate(name)
player.sendSystemMessage(
Component.literal(
"$name 좌표를 제거했습니다."
)
.withStyle {
it.withColor(
ChatFormatting
.GOLD
)
}
MessageUtils.sendSuccess(
player,
"{$name} 좌표를 제거했습니다."
)
1
}
@ -194,12 +167,22 @@ object CoordinateCommand {
else (coordinates.size + Menu.ITEMS_PER_PAGE - 1) / Menu.ITEMS_PER_PAGE
val container = createPageContainer(player, page)
// 버튼 상태에 따른 이미지 선택
val buttonImage =
when {
page == 0 && page >= totalPages - 1 -> "\uE010" // 둘 다 비활성
page == 0 -> "\uE012" // 이전만 비활성
page >= totalPages - 1 -> "\uE011" // 다음만 비활성
else -> "\uE013" // 둘 다 활성
}
// 커스텀 버튼 이미지만 타이틀에 표시 (타이틀 텍스트는 이미지에 포함)
val customTitle = "\uF808$buttonImage"
player.openMenu(
SimpleMenuProvider(
{ windowId, inv, _ -> Menu(windowId, inv, container, page) },
Component.literal("저장된 좌표 (${page + 1}/$totalPages)").withStyle {
it.withBold(true)
}
Component.literal(customTitle).withStyle { it.withColor(0xFFFFFF) }
)
)
}
@ -207,21 +190,13 @@ object CoordinateCommand {
private fun teleportToCoordinate(player: ServerPlayer, name: String): Int {
val coord = CoordinateConfig.getCoordinate(name)
if (coord == null) {
player.sendSystemMessage(
Component.literal("$name 좌표가 존재하지 않습니다.").withStyle {
it.withColor(ChatFormatting.RED)
}
)
MessageUtils.sendError(player, "{$name} 좌표가 존재하지 않습니다.")
return 0
}
val level = DimensionUtils.getLevelById(player.server, coord.dimension)
if (level == null) {
player.sendSystemMessage(
Component.literal("존재하지 않는 차원입니다.").withStyle {
it.withColor(ChatFormatting.RED)
}
)
MessageUtils.sendError(player, "존재하지 않는 차원입니다.")
return 0
}
@ -244,11 +219,9 @@ object CoordinateCommand {
)
player.teleportTo(level, coord.x, coord.y, coord.z, player.yRot, player.xRot)
player.sendSystemMessage(
Component.literal("$name (으)로 이동했습니다.").withStyle {
it.withColor(ChatFormatting.GOLD)
}
)
// 텔레포트 사운드 재생
SoundUtils.playTeleport(player)
MessageUtils.sendSuccess(player, "{$name}(으)로 이동했습니다.")
return 1
}
}

View file

@ -2,6 +2,7 @@ package com.beemer.essentials.command
import com.beemer.essentials.config.PlayerConfig
import com.beemer.essentials.nickname.NicknameDataStore
import com.beemer.essentials.util.MessageUtils
import com.mojang.brigadier.arguments.StringArgumentType
import java.util.Optional
import java.util.UUID
@ -30,8 +31,6 @@ object HeadCommand {
StringArgumentType.greedyString()
)
.suggests { _, builder ->
// 서버에 접속한 적 있는 플레이어만 제안
// 닉네임이 있으면 닉네임, 없으면 원래 이름
val suggestions =
mutableListOf<String>()
PlayerConfig.getAllPlayers()
@ -93,11 +92,7 @@ object HeadCommand {
// 3. 서버에 접속한 적 없는 플레이어면 거부
if (targetUuid == null || !PlayerConfig.isKnownPlayer(targetUuid)) {
player.sendSystemMessage(
Component.literal("해당 플레이어는 서버에 접속한 적이 없습니다.").withStyle {
it.withColor(ChatFormatting.RED)
}
)
MessageUtils.sendError(player, "해당 플레이어는 서버에 접속한 적이 없습니다.")
return 0
}
@ -130,21 +125,13 @@ object HeadCommand {
val added = player.inventory.add(headItem)
if (added) {
player.sendSystemMessage(
Component.literal(displayName)
.withStyle { it.withColor(ChatFormatting.GREEN) }
.append(
Component.literal("의 머리가 지급되었습니다.").withStyle {
it.withColor(ChatFormatting.GOLD)
}
)
)
MessageUtils.sendSuccess(player, "{$displayName}의 머리가 지급되었습니다.")
} else {
// 인벤토리가 가득 찬 경우 바닥에 드랍
player.drop(headItem, false)
player.sendSystemMessage(
Component.literal("인벤토리가 가득 차서 ${displayName}의 머리를 바닥에 떨어뜨렸습니다.")
.withStyle { it.withColor(ChatFormatting.YELLOW) }
MessageUtils.sendWarning(
player,
"인벤토리가 가득 차서 {$displayName}의 머리를 바닥에 떨어뜨렸습니다."
)
}

View file

@ -1,13 +1,29 @@
package com.beemer.essentials.command
import net.minecraft.ChatFormatting
import net.minecraft.commands.Commands
import net.minecraft.network.chat.Component
import net.minecraft.network.chat.TextColor
import net.minecraft.server.level.ServerPlayer
import net.neoforged.bus.api.SubscribeEvent
import net.neoforged.neoforge.event.RegisterCommandsEvent
object HelpCommand {
// 커스텀 색상 정의
private val COLOR_HEADER = TextColor.fromRgb(0xFFD700) // 골드
private val COLOR_SEPARATOR = TextColor.fromRgb(0x555555) // 어두운 회색
private val COLOR_COMMAND = TextColor.fromRgb(0xFFFFFF) // 흰색
private val COLOR_DESC = TextColor.fromRgb(0xAAAAAA) // 밝은 회색
private val COLOR_DASH = TextColor.fromRgb(0x666666) // 회색
// 카테고리별 색상
private val COLOR_COORDINATE = TextColor.fromRgb(0xFFE066) // 밝은 노랑
private val COLOR_SPAWN = TextColor.fromRgb(0x7FD88C) // 연두색
private val COLOR_TELEPORT = TextColor.fromRgb(0x7DD3FC) // 하늘색
private val COLOR_NICKNAME = TextColor.fromRgb(0xE879F9) // 보라색
private val COLOR_HEAD = TextColor.fromRgb(0xFBBF24) // 주황색
private val COLOR_WORKBENCH = TextColor.fromRgb(0xA3E635) // 라임색
private val COLOR_MENU = TextColor.fromRgb(0xF472B6) // 핑크색
@SubscribeEvent
fun onRegisterCommands(event: RegisterCommandsEvent) {
listOf("도움말", "help", "essentials").forEach { command ->
@ -24,15 +40,11 @@ object HelpCommand {
private fun showHelp(player: ServerPlayer) {
val header =
Component.literal("══════════ ")
.withStyle { it.withColor(ChatFormatting.DARK_GRAY) }
.append(
Component.literal("도움말").withStyle {
it.withColor(ChatFormatting.GOLD).withBold(true)
}
)
.withStyle { it.withColor(COLOR_SEPARATOR) }
.append(Component.literal("도움말").withStyle { it.withColor(COLOR_HEADER) })
.append(
Component.literal(" ══════════").withStyle {
it.withColor(ChatFormatting.DARK_GRAY)
it.withColor(COLOR_SEPARATOR)
}
)
@ -40,7 +52,7 @@ object HelpCommand {
player.sendSystemMessage(Component.empty())
// 좌표 관리
sendCategory(player, "좌표 관리", ChatFormatting.YELLOW)
sendCategory(player, "좌표 관리", COLOR_COORDINATE)
sendCommand(player, "/좌표", "저장된 좌표 목록 (GUI)")
sendCommand(player, "/좌표추가 <이름>", "현재 위치 저장")
sendCommand(player, "/좌표이동 <이름>", "해당 좌표로 이동")
@ -48,66 +60,61 @@ object HelpCommand {
player.sendSystemMessage(Component.empty())
// 스폰
sendCategory(player, "스폰", ChatFormatting.GREEN)
sendCategory(player, "스폰", COLOR_SPAWN)
sendCommand(player, "/스폰", "스폰으로 이동")
sendCommand(player, "/스폰설정", "현재 위치를 스폰으로 설정")
sendCommand(player, "/스폰삭제", "커스텀 스폰 삭제")
player.sendSystemMessage(Component.empty())
// 텔레포트
sendCategory(player, "텔레포트", ChatFormatting.AQUA)
sendCategory(player, "텔레포트", COLOR_TELEPORT)
sendCommand(player, "/tpa", "플레이어 선택 (GUI)")
sendCommand(player, "/back", "이전 위치로 이동")
player.sendSystemMessage(Component.empty())
// 닉네임
sendCategory(player, "닉네임", ChatFormatting.LIGHT_PURPLE)
sendCategory(player, "닉네임", COLOR_NICKNAME)
sendCommand(player, "/닉네임 변경 <닉네임>", "닉네임 설정")
sendCommand(player, "/닉네임 초기화", "닉네임 초기화")
player.sendSystemMessage(Component.empty())
// 머리
sendCategory(player, "머리", ChatFormatting.GOLD)
sendCategory(player, "머리", COLOR_HEAD)
sendCommand(player, "/머리", "내 머리 아이템 받기")
sendCommand(player, "/머리 <닉네임>", "해당 플레이어 머리 받기")
player.sendSystemMessage(Component.empty())
// 제작대
sendCategory(player, "제작대", COLOR_WORKBENCH)
sendCommand(player, "/제작대", "제작대 열기")
player.sendSystemMessage(Component.empty())
// 메뉴
sendCategory(player, "메뉴", COLOR_MENU)
sendCommand(player, "/메뉴", "메뉴 GUI 열기")
sendCommand(player, "Shift + F", "메뉴 GUI 열기 (단축키)")
val footer =
Component.literal("═══════════════════════════════").withStyle {
it.withColor(ChatFormatting.DARK_GRAY)
it.withColor(COLOR_SEPARATOR)
}
player.sendSystemMessage(footer)
}
private fun sendCategory(player: ServerPlayer, name: String, color: ChatFormatting) {
private fun sendCategory(player: ServerPlayer, name: String, color: TextColor) {
val message =
Component.literal("")
.withStyle { it.withColor(color) }
.append(
Component.literal(name).withStyle {
it.withColor(color).withBold(true)
}
)
.append(Component.literal(name).withStyle { it.withColor(color) })
player.sendSystemMessage(message)
}
private fun sendCommand(player: ServerPlayer, cmd: String, desc: String) {
val message =
Component.literal(" ")
.append(
Component.literal(cmd).withStyle {
it.withColor(ChatFormatting.WHITE)
}
)
.append(
Component.literal(" - ").withStyle {
it.withColor(ChatFormatting.DARK_GRAY)
}
)
.append(
Component.literal(desc).withStyle {
it.withColor(ChatFormatting.GRAY)
}
)
.append(Component.literal(cmd).withStyle { it.withColor(COLOR_COMMAND) })
.append(Component.literal(" - ").withStyle { it.withColor(COLOR_DASH) })
.append(Component.literal(desc).withStyle { it.withColor(COLOR_DESC) })
player.sendSystemMessage(message)
}
}

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

@ -3,9 +3,9 @@ package com.beemer.essentials.command
import com.beemer.essentials.config.PlayerConfig
import com.beemer.essentials.util.CommandUtils
import com.beemer.essentials.util.DimensionUtils
import net.minecraft.ChatFormatting
import com.beemer.essentials.util.MessageUtils
import com.beemer.essentials.util.SoundUtils
import net.minecraft.commands.Commands
import net.minecraft.network.chat.Component
import net.neoforged.bus.api.SubscribeEvent
import net.neoforged.neoforge.event.RegisterCommandsEvent
@ -23,17 +23,10 @@ object PlayerCommand {
val lastLocation =
info?.lastLocation
?: run {
player.sendSystemMessage(
Component.literal(
MessageUtils.sendError(
player,
"이전 위치가 존재하지 않습니다."
)
.withStyle {
it.withColor(
ChatFormatting
.RED
)
}
)
return@executes 0
}
@ -43,17 +36,10 @@ object PlayerCommand {
lastLocation.dimension
)
?: run {
player.sendSystemMessage(
Component.literal(
MessageUtils.sendError(
player,
"존재하지 않는 차원입니다."
)
.withStyle {
it.withColor(
ChatFormatting
.RED
)
}
)
return@executes 0
}
@ -65,11 +51,9 @@ object PlayerCommand {
player.yRot,
player.xRot
)
player.sendSystemMessage(
Component.literal("이전 위치로 이동했습니다.").withStyle {
it.withColor(ChatFormatting.GOLD)
}
)
// 텔레포트 사운드 재생
SoundUtils.playTeleport(player)
MessageUtils.sendSuccess(player, "이전 위치로 이동했습니다.")
1
}
)

View file

@ -2,9 +2,8 @@ package com.beemer.essentials.command
import com.beemer.essentials.config.ProtectFarmlandConfig
import com.beemer.essentials.util.CommandUtils
import net.minecraft.ChatFormatting
import com.beemer.essentials.util.MessageUtils
import net.minecraft.commands.Commands
import net.minecraft.network.chat.Component
import net.neoforged.bus.api.SubscribeEvent
import net.neoforged.neoforge.event.RegisterCommandsEvent
@ -19,22 +18,9 @@ object ProtectFarmlandCommand {
?: return@executes 0
val enabled = ProtectFarmlandConfig.toggle()
val status = if (enabled) "활성화" else "비활성화"
player.sendSystemMessage(
Component.literal("밭 보호를 ")
.withStyle { it.withColor(ChatFormatting.GOLD) }
.append(
Component.literal(if (enabled) "활성화" else "비활성화")
.withStyle {
it.withColor(ChatFormatting.DARK_GREEN)
}
)
.append(
Component.literal("했습니다.").withStyle {
it.withColor(ChatFormatting.GOLD)
}
)
)
MessageUtils.sendSuccess(player, "밭 보호를 {$status}했습니다.")
1
}
)

View file

@ -5,9 +5,9 @@ import com.beemer.essentials.config.SpawnConfig
import com.beemer.essentials.data.Location
import com.beemer.essentials.util.CommandUtils
import com.beemer.essentials.util.DimensionUtils
import net.minecraft.ChatFormatting
import com.beemer.essentials.util.MessageUtils
import com.beemer.essentials.util.SoundUtils
import net.minecraft.commands.Commands
import net.minecraft.network.chat.Component
import net.neoforged.bus.api.SubscribeEvent
import net.neoforged.neoforge.event.RegisterCommandsEvent
@ -23,8 +23,9 @@ object SpawnCommand {
val exactPos = player.position()
val blockPos = player.blockPosition()
val dimensionId = player.level().dimension().location().toString()
val biomeId =
val dimension =
player.level().dimension().location().toString()
val biome =
player.level()
.getBiome(blockPos)
.unwrapKey()
@ -33,20 +34,15 @@ object SpawnCommand {
val location =
Location(
dimension = dimensionId,
biome = biomeId,
dimension = dimension,
biome = biome,
x = exactPos.x,
y = exactPos.y,
z = exactPos.z
)
SpawnConfig.setCustomSpawn(location)
player.sendSystemMessage(
Component.literal("스폰 지점이 현재 위치로 설정되었습니다.").withStyle {
it.withColor(ChatFormatting.GOLD)
}
)
MessageUtils.sendSuccess(player, "스폰 지점이 현재 위치로 설정되었습니다.")
1
}
)
@ -59,38 +55,62 @@ object SpawnCommand {
CommandUtils.getPlayerOrSendFailure(context.source)
?: return@executes 0
val target = SpawnConfig.getCustomSpawn() ?: SpawnConfig.getDefaultSpawn()
val target =
SpawnConfig.getCustomSpawn()
?: SpawnConfig.getDefaultSpawn()
target?.let { t ->
val level = DimensionUtils.getLevelById(player.server, t.dimension)
val level =
DimensionUtils.getLevelById(
player.server,
t.dimension
)
level?.let { l ->
val exactPos = player.position()
val blockPos = player.blockPosition()
val currentDimension =
player.level().dimension().location().toString()
player.level()
.dimension()
.location()
.toString()
val currentBiome =
player.level()
.getBiome(blockPos)
.unwrapKey()
.map { it.location().toString() }
.map {
it.location()
.toString()
}
.orElse("minecraft:plains")
val currentLocation =
Location(
dimension = currentDimension,
dimension =
currentDimension,
biome = currentBiome,
x = exactPos.x,
y = exactPos.y,
z = exactPos.z
)
PlayerConfig.recordLastLocation(player, currentLocation)
player.teleportTo(l, t.x, t.y, t.z, player.yRot, player.xRot)
player.sendSystemMessage(
Component.literal("스폰으로 이동했습니다.").withStyle {
it.withColor(ChatFormatting.GOLD)
}
PlayerConfig.recordLastLocation(
player,
currentLocation
)
player.teleportTo(
l,
t.x,
t.y,
t.z,
player.yRot,
player.xRot
)
// 텔레포트 사운드 재생
SoundUtils.playTeleport(player)
MessageUtils.sendSuccess(
player,
"스폰으로 이동했습니다."
)
}
}
@ -107,11 +127,7 @@ object SpawnCommand {
?: return@executes 0
SpawnConfig.removeCustomSpawn()
player.sendSystemMessage(
Component.literal("스폰 지점이 기본 스폰 지점으로 변경되었습니다.").withStyle {
it.withColor(ChatFormatting.GOLD)
}
)
MessageUtils.sendInfo(player, "스폰 지점이 기본 스폰 지점으로 변경되었습니다.")
1
}
)

View file

@ -4,6 +4,8 @@ import com.beemer.essentials.gui.TeleportGui
import com.beemer.essentials.gui.TeleportGui.Companion.CONTAINER_SIZE
import com.beemer.essentials.nickname.NicknameDataStore
import com.beemer.essentials.util.CommandUtils
import com.beemer.essentials.util.MessageUtils
import com.beemer.essentials.util.SoundUtils
import com.beemer.essentials.util.TranslationUtils
import com.mojang.authlib.GameProfile
import net.minecraft.ChatFormatting
@ -32,13 +34,22 @@ object TeleportCommand {
CommandUtils.getPlayerOrSendFailure(context.source)
?: return@executes 0
val container = SimpleContainer(9 * 3)
val targetPlayers =
player.server.playerList.players.filter {
it != player
}
// 다른 플레이어가 없으면 에러 메시지 표시
if (targetPlayers.isEmpty()) {
MessageUtils.sendError(
player,
"현재 접속 중인 다른 플레이어가 없습니다."
)
return@executes 0
}
val container = SimpleContainer(9 * 3)
targetPlayers.take(CONTAINER_SIZE).forEachIndexed {
idx,
target ->
@ -46,6 +57,9 @@ object TeleportCommand {
container.setItem(idx, head)
}
// 플레이어가 있을 때만 클릭 사운드 재생
SoundUtils.playClick(player)
player.openMenu(
SimpleMenuProvider(
{ windowId, inv, _ ->

View file

@ -0,0 +1,66 @@
package com.beemer.essentials.command
import java.util.Optional
import java.util.function.BiFunction
import net.minecraft.ChatFormatting
import net.minecraft.commands.Commands
import net.minecraft.core.BlockPos
import net.minecraft.network.chat.Component
import net.minecraft.server.level.ServerPlayer
import net.minecraft.world.SimpleMenuProvider
import net.minecraft.world.entity.player.Player
import net.minecraft.world.inventory.ContainerLevelAccess
import net.minecraft.world.inventory.CraftingMenu
import net.minecraft.world.level.Level
import net.neoforged.bus.api.SubscribeEvent
import net.neoforged.neoforge.event.RegisterCommandsEvent
object WorkbenchCommand {
@SubscribeEvent
fun onRegisterCommands(event: RegisterCommandsEvent) {
// /제작대, /workbench - 제작대 GUI 열기
listOf("제작대", "workbench").forEach { command ->
event.dispatcher.register(
Commands.literal(command).executes { context ->
val player = context.source.entity as? ServerPlayer
if (player == null) {
context.source.sendFailure(
Component.literal("플레이어만 사용할 수 있는 명령어입니다.")
.withStyle(ChatFormatting.RED)
)
return@executes 0
}
openWorkbench(player)
1
}
)
}
}
/** 항상 유효한 ContainerLevelAccess 생성 */
private fun createAlwaysValidAccess(level: Level, pos: BlockPos): ContainerLevelAccess {
return object : ContainerLevelAccess {
override fun <T : Any> evaluate(function: BiFunction<Level, BlockPos, T>): Optional<T> {
return Optional.ofNullable(function.apply(level, pos))
}
}
}
/** 플레이어에게 제작대 GUI 열기 */
private fun openWorkbench(player: ServerPlayer) {
val access = createAlwaysValidAccess(player.level(), player.blockPosition())
player.openMenu(
SimpleMenuProvider(
{ containerId, inventory, _ ->
object : CraftingMenu(containerId, inventory, access) {
// stillValid를 오버라이드하여 항상 true 반환 (거리 제한 없음)
override fun stillValid(player: Player): Boolean = true
}
},
Component.translatable("container.crafting")
)
)
}
}

View file

@ -1,7 +1,7 @@
package com.beemer.essentials.event
import com.beemer.essentials.util.ChatUtils
import net.minecraft.network.chat.Component
import com.beemer.essentials.util.MessageUtils
import net.minecraft.server.level.ServerPlayer
import net.neoforged.bus.api.SubscribeEvent
import net.neoforged.neoforge.event.ServerChatEvent
@ -12,13 +12,11 @@ object ChatEvents {
val player = event.player as ServerPlayer
val raw = event.rawText
if (raw.startsWith("/"))
return
if (raw.startsWith("/")) return
if (ChatUtils.hasIllegalCharacter(raw)) {
event.isCanceled = true
player.sendSystemMessage(Component.literal("채팅에 허용되지 않는 문자가 포함되어 있습니다.")
.withStyle { it.withColor(net.minecraft.ChatFormatting.RED) })
MessageUtils.sendError(player, "채팅에 허용되지 않는 문자가 포함되어 있습니다.")
return
}

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

@ -5,6 +5,8 @@ import com.beemer.essentials.config.SpawnConfig
import com.beemer.essentials.data.Location
import com.beemer.essentials.util.ChatUtils
import com.beemer.essentials.util.DimensionUtils
import com.beemer.essentials.util.SoundUtils
import net.minecraft.network.chat.Component
import net.minecraft.server.level.ServerPlayer
import net.neoforged.bus.api.SubscribeEvent
import net.neoforged.neoforge.event.entity.player.PlayerEvent
@ -35,8 +37,69 @@ object PlayerEvents {
val level = DimensionUtils.getLevelById(player.server, spawn.dimension) ?: return
player.teleportTo(level, spawn.x, spawn.y, spawn.z, player.yRot, player.xRot)
// 신규 플레이어 도움말 (약간의 딜레이 후 표시)
player.server.execute {
sendWelcomeGuide(player)
// 알림 사운드 재생
SoundUtils.playNotification(player)
}
}
}
/** 신규 플레이어 도움말 */
private fun sendWelcomeGuide(player: ServerPlayer) {
// 커스텀 색상 정의
val colorSeparator = net.minecraft.network.chat.TextColor.fromRgb(0x555555)
val colorWelcome = net.minecraft.network.chat.TextColor.fromRgb(0x7DD3FC)
val colorCategory = net.minecraft.network.chat.TextColor.fromRgb(0xFFE066)
val colorCommand = net.minecraft.network.chat.TextColor.fromRgb(0xFFFFFF)
val colorDesc = net.minecraft.network.chat.TextColor.fromRgb(0xAAAAAA)
val separator =
Component.literal("═══════════════════════════════").withStyle {
it.withColor(colorSeparator)
}
player.sendSystemMessage(Component.empty())
player.sendSystemMessage(separator)
player.sendSystemMessage(
Component.literal("🎉 서버에 오신 것을 환영합니다!").withStyle { it.withColor(colorWelcome) }
)
player.sendSystemMessage(Component.empty())
// 기본 명령어 안내
player.sendSystemMessage(
Component.literal("▸ 기본 명령어").withStyle { it.withColor(colorCategory) }
)
sendGuideItem(player, "/도움말", "명령어 목록 보기", colorCommand, colorDesc)
sendGuideItem(player, "/메뉴", "메뉴 GUI 열기", colorCommand, colorDesc)
sendGuideItem(player, "/스폰", "스폰으로 이동", colorCommand, colorDesc)
player.sendSystemMessage(Component.empty())
// 단축키 안내
player.sendSystemMessage(
Component.literal("▸ 단축키").withStyle { it.withColor(colorCategory) }
)
sendGuideItem(player, "Shift + F", "메뉴 GUI 열기", colorCommand, colorDesc)
player.sendSystemMessage(separator)
player.sendSystemMessage(Component.empty())
}
private fun sendGuideItem(
player: ServerPlayer,
key: String,
desc: String,
colorKey: net.minecraft.network.chat.TextColor,
colorDesc: net.minecraft.network.chat.TextColor
) {
player.sendSystemMessage(
Component.literal(" ")
.append(Component.literal(key).withStyle { it.withColor(colorKey) })
.append(Component.literal(" - $desc").withStyle { it.withColor(colorDesc) })
)
}
@SubscribeEvent
fun onPlayerLoggedOut(event: PlayerEvent.PlayerLoggedOutEvent) {
@ -54,12 +117,16 @@ object PlayerEvents {
val pos = oldPlayer.blockPosition()
val dimension = oldPlayer.level().dimension().location().toString()
val biome = oldPlayer.level().getBiome(pos)
val biome =
oldPlayer
.level()
.getBiome(pos)
.unwrapKey()
.map { it.location().toString() }
.orElse("minecraft:plains")
val lastLoc = Location(
val lastLoc =
Location(
dimension = dimension,
biome = biome,
x = pos.x.toDouble(),

View file

@ -1,6 +1,7 @@
package com.beemer.essentials.gui
import com.beemer.essentials.config.AntimobConfig
import com.beemer.essentials.util.SoundUtils
import net.minecraft.ChatFormatting
import net.minecraft.core.component.DataComponents
import net.minecraft.network.chat.Component
@ -77,6 +78,7 @@ class AntimobGui(
) {
if (player is ServerPlayer && slotId in 0 until CONTAINER_SIZE) {
if (glassSlotToMob.containsKey(slotId)) {
playClickSound(player)
val mob = glassSlotToMob[slotId] ?: return
AntimobConfig.toggle(mob)
container.setItem(slotId, makeToggleGlass(mob))
@ -94,6 +96,10 @@ class AntimobGui(
index: Int
): ItemStack = ItemStack.EMPTY
private fun playClickSound(player: ServerPlayer) {
SoundUtils.playClick(player)
}
private fun makeMobHead(mob: String): ItemStack {
val headItem = ItemStack(Items.PLAYER_HEAD)

View file

@ -4,6 +4,8 @@ import com.beemer.essentials.config.CoordinateConfig
import com.beemer.essentials.config.PlayerConfig
import com.beemer.essentials.data.Location
import com.beemer.essentials.nickname.NicknameDataStore
import com.beemer.essentials.util.MessageUtils
import com.beemer.essentials.util.SoundUtils
import com.beemer.essentials.util.TranslationUtils.translateBiome
import com.beemer.essentials.util.TranslationUtils.translateDimension
import java.util.UUID
@ -29,7 +31,7 @@ import net.minecraft.world.item.Items
import net.minecraft.world.item.component.CustomData
import net.minecraft.world.item.component.ItemLore
/** 좌표 GUI - 페이지 기능 포함 5줄(45개) 표시 + 6번째 줄에 이전/다음 버튼 */
/** 좌표 GUI - 페이지 기능 포함 4줄(36개) 표시 + 6번째 줄에 이전/다음 버튼 */
class Menu(
syncId: Int,
playerInv: Inventory,
@ -38,9 +40,9 @@ class Menu(
) : AbstractContainerMenu(MenuType.GENERIC_9x6, syncId) {
companion object {
const val ITEMS_PER_PAGE = 45 // 5줄 * 9
const val PREV_BUTTON_SLOT = 45 // 6번째 줄 첫 번째
const val NEXT_BUTTON_SLOT = 53 // 6번째 줄 마지막
const val ITEMS_PER_PAGE = 36 // 4줄 * 9
val PREV_BUTTON_SLOTS = listOf(45, 46) // 6번째 줄 이전 버튼 2칸
val NEXT_BUTTON_SLOTS = listOf(52, 53) // 6번째 줄 다음 버튼 2칸
}
init {
@ -81,24 +83,26 @@ class Menu(
val stack = slots[slotId].item
val tag = stack.get(DataComponents.CUSTOM_DATA)?.copyTag() ?: return
// 이전 페이지 버튼
if (slotId == PREV_BUTTON_SLOT &&
// 이전 페이지 버튼 (슬롯 45, 46)
if (slotId in PREV_BUTTON_SLOTS &&
tag.contains("action") &&
tag.getString("action") == "prev"
) {
if (currentPage > 0) {
playClickSound(player)
openPage(player, currentPage - 1)
}
return
}
// 다음 페이지 버튼
if (slotId == NEXT_BUTTON_SLOT &&
// 다음 페이지 버튼 (슬롯 52, 53)
if (slotId in NEXT_BUTTON_SLOTS &&
tag.contains("action") &&
tag.getString("action") == "next"
) {
val totalPages = getTotalPages()
if (currentPage < totalPages - 1) {
playClickSound(player)
openPage(player, currentPage + 1)
}
return
@ -109,6 +113,7 @@ class Menu(
val x = tag.getDouble("x")
val y = tag.getDouble("y")
val z = tag.getDouble("z")
val coordName = tag.getString("name")
val dimension =
ResourceLocation.tryParse(tag.getString("dimension"))?.let {
ResourceKey.create(Registries.DIMENSION, it)
@ -116,7 +121,6 @@ class Menu(
val level = dimension?.let { player.server.getLevel(it) }
if (level != null) {
// 이전 위치 저장
val prevPos = player.blockPosition()
PlayerConfig.recordLastLocation(
player,
@ -135,20 +139,18 @@ class Menu(
)
player.teleportTo(level, x, y, z, player.yRot, player.xRot)
player.sendSystemMessage(
Component.literal(tag.getString("name"))
.withStyle { it.withColor(ChatFormatting.DARK_GREEN) }
.append(
Component.literal("(으)로 이동했습니다.").withStyle {
it.withColor(ChatFormatting.GOLD)
}
)
)
// 텔레포트 사운드 재생
SoundUtils.playTeleport(player)
MessageUtils.sendSuccess(player, "{$coordName}(으)로 이동했습니다.")
}
player.closeContainer()
}
}
private fun playClickSound(player: ServerPlayer) {
SoundUtils.playClick(player)
}
private fun getTotalPages(): Int {
val totalItems = CoordinateConfig.getCoordinates().size
return if (totalItems == 0) 1 else (totalItems + ITEMS_PER_PAGE - 1) / ITEMS_PER_PAGE
@ -156,12 +158,24 @@ class Menu(
private fun openPage(player: ServerPlayer, page: Int) {
val container = createPageContainer(player, page)
val totalPages = getTotalPages()
// 버튼 상태에 따른 이미지 선택
val buttonImage =
when {
page == 0 && page >= totalPages - 1 -> "\uE010" // 둘 다 비활성
page == 0 -> "\uE012" // 이전만 비활성
page >= totalPages - 1 -> "\uE011" // 다음만 비활성
else -> "\uE013" // 둘 다 활성
}
// 커스텀 버튼 이미지만 타이틀에 표시 (타이틀 텍스트는 이미지에 포함)
val customTitle = "\uF808$buttonImage"
player.openMenu(
SimpleMenuProvider(
{ windowId, inv, _ -> Menu(windowId, inv, container, page) },
Component.literal("저장된 좌표 (${page + 1}/${getTotalPages()})").withStyle {
it.withBold(true)
}
Component.literal(customTitle).withStyle { it.withColor(0xFFFFFF) }
)
)
}
@ -180,10 +194,10 @@ fun createPageContainer(player: ServerPlayer, page: Int): SimpleContainer {
if (coordinates.isEmpty()) 1
else (coordinates.size + Menu.ITEMS_PER_PAGE - 1) / Menu.ITEMS_PER_PAGE
// 좌표 아이템 추가 (5줄, 45개)
// 좌표 아이템 추가 (2~5번째 줄, 슬롯 9-44)
for (i in startIdx until endIdx) {
val coord = coordinates[i]
val item = ItemStack(Items.PAPER)
val item = ItemStack(Items.FILLED_MAP)
val tag =
CompoundTag().apply {
putString("name", coord.name)
@ -207,96 +221,131 @@ fun createPageContainer(player: ServerPlayer, page: Int): SimpleContainer {
val displayName =
if (creatorName != null) {
Component.literal(coord.name)
.withStyle { it.withColor(ChatFormatting.GOLD).withBold(true) }
.withStyle {
it.withColor(ChatFormatting.GOLD).withBold(false).withItalic(false)
}
.append(
Component.literal(" ($creatorName)").withStyle {
it.withColor(ChatFormatting.AQUA).withBold(false)
it.withColor(ChatFormatting.AQUA)
.withBold(false)
.withItalic(false)
}
)
} else {
Component.literal(coord.name).withStyle {
it.withColor(ChatFormatting.GOLD).withBold(true)
it.withColor(ChatFormatting.GOLD).withBold(false).withItalic(false)
}
}
val loreList: List<Component> =
listOf(
Component.literal("디멘션: ")
.withStyle { it.withColor(ChatFormatting.DARK_GREEN) }
.withStyle {
it.withColor(ChatFormatting.DARK_GREEN).withItalic(false)
}
.append(
Component.literal(translateDimension(coord.dimension))
.withStyle { it.withColor(ChatFormatting.GRAY) }
.withStyle {
it.withColor(ChatFormatting.GRAY)
.withItalic(false)
}
),
Component.literal("바이옴: ")
.withStyle { it.withColor(ChatFormatting.DARK_GREEN) }
.withStyle {
it.withColor(ChatFormatting.DARK_GREEN).withItalic(false)
}
.append(
Component.literal(translateBiome(coord.biome)).withStyle {
it.withColor(ChatFormatting.GRAY)
it.withColor(ChatFormatting.GRAY).withItalic(false)
}
),
Component.literal("좌표: ")
.withStyle { it.withColor(ChatFormatting.DARK_GREEN) }
.withStyle {
it.withColor(ChatFormatting.DARK_GREEN).withItalic(false)
}
.append(
Component.literal(
"${coord.x.toInt()}, ${coord.y.toInt()}, ${coord.z.toInt()}"
)
.withStyle { it.withColor(ChatFormatting.GRAY) }
.withStyle {
it.withColor(ChatFormatting.GRAY)
.withItalic(false)
}
)
)
item.set(DataComponents.CUSTOM_NAME, displayName)
item.set(DataComponents.LORE, ItemLore(loreList))
item.set(DataComponents.CUSTOM_DATA, CustomData.of(tag))
container.setItem(i - startIdx, item)
item.set(
DataComponents.CUSTOM_MODEL_DATA,
net.minecraft.world.item.component.CustomModelData(1)
)
container.setItem(9 + (i - startIdx), item) // 슬롯 9부터 시작 (1줄 건너뛰기)
}
// 맨 아래줄 빈 공간을 회색 유리판으로 채우기 (슬롯 46-52)
val fillerItem = ItemStack(Items.GRAY_STAINED_GLASS_PANE)
// 1번째 줄 (슬롯 0-8) - 타이틀 영역 투명 처리
val fillerItem = ItemStack(Items.GLASS_PANE)
fillerItem.set(DataComponents.CUSTOM_NAME, Component.empty())
fillerItem.set(DataComponents.LORE, ItemLore(listOf()))
fillerItem.set(
DataComponents.CUSTOM_NAME,
Component.literal(" ").withStyle { it.withColor(ChatFormatting.DARK_GRAY) }
DataComponents.CUSTOM_MODEL_DATA,
net.minecraft.world.item.component.CustomModelData(1)
)
for (slot in 46..52) {
fillerItem.set(DataComponents.HIDE_TOOLTIP, net.minecraft.util.Unit.INSTANCE)
for (slot in 0..8) {
container.setItem(slot, fillerItem.copy())
}
// 이전 페이지 버튼 (슬롯 45)
if (page > 0) {
val prevItem = ItemStack(Items.LIME_STAINED_GLASS_PANE)
val prevTag = CompoundTag().apply { putString("action", "prev") }
prevItem.set(
DataComponents.CUSTOM_NAME,
Component.literal("이전 페이지").withStyle { it.withColor(ChatFormatting.YELLOW) }
)
prevItem.set(DataComponents.CUSTOM_DATA, CustomData.of(prevTag))
container.setItem(Menu.PREV_BUTTON_SLOT, prevItem)
} else {
val disabledItem = ItemStack(Items.RED_STAINED_GLASS_PANE)
disabledItem.set(
DataComponents.CUSTOM_NAME,
Component.literal("이전 페이지").withStyle { it.withColor(ChatFormatting.YELLOW) }
)
container.setItem(Menu.PREV_BUTTON_SLOT, disabledItem)
// 6번째 줄 가운데 슬롯 (47-51) - 투명 처리
for (slot in 47..51) {
container.setItem(slot, fillerItem.copy())
}
// 다음 페이지 버튼 (슬롯 53)
// 이전 페이지 버튼 (슬롯 45, 46) - 페이지 정보 툴팁
val prevItem = ItemStack(Items.GLASS_PANE)
val prevTag = CompoundTag().apply { putString("action", if (page > 0) "prev" else "") }
if (page > 0) {
prevItem.set(
DataComponents.CUSTOM_NAME,
Component.literal("이전 페이지 [${page + 1}/$totalPages]").withStyle {
it.withColor(0xA8D8D8).withItalic(false)
}
)
} else {
prevItem.set(DataComponents.CUSTOM_NAME, Component.empty())
prevItem.set(DataComponents.HIDE_TOOLTIP, net.minecraft.util.Unit.INSTANCE)
}
prevItem.set(DataComponents.LORE, ItemLore(listOf()))
prevItem.set(DataComponents.CUSTOM_DATA, CustomData.of(prevTag))
prevItem.set(
DataComponents.CUSTOM_MODEL_DATA,
net.minecraft.world.item.component.CustomModelData(1)
)
Menu.PREV_BUTTON_SLOTS.forEach { container.setItem(it, prevItem.copy()) }
// 다음 페이지 버튼 (슬롯 52, 53) - 페이지 정보 툴팁
val nextItem = ItemStack(Items.GLASS_PANE)
val nextTag =
CompoundTag().apply { putString("action", if (page < totalPages - 1) "next" else "") }
if (page < totalPages - 1) {
val nextItem = ItemStack(Items.LIME_STAINED_GLASS_PANE)
val nextTag = CompoundTag().apply { putString("action", "next") }
nextItem.set(
DataComponents.CUSTOM_NAME,
Component.literal("다음 페이지").withStyle { it.withColor(ChatFormatting.YELLOW) }
)
nextItem.set(DataComponents.CUSTOM_DATA, CustomData.of(nextTag))
container.setItem(Menu.NEXT_BUTTON_SLOT, nextItem)
} else {
val disabledItem = ItemStack(Items.RED_STAINED_GLASS_PANE)
disabledItem.set(
DataComponents.CUSTOM_NAME,
Component.literal("다음 페이지").withStyle { it.withColor(ChatFormatting.YELLOW) }
)
container.setItem(Menu.NEXT_BUTTON_SLOT, disabledItem)
Component.literal("다음 페이지 [${page + 1}/$totalPages]").withStyle {
it.withColor(0xA8D8D8).withItalic(false)
}
)
} else {
nextItem.set(DataComponents.CUSTOM_NAME, Component.empty())
nextItem.set(DataComponents.HIDE_TOOLTIP, net.minecraft.util.Unit.INSTANCE)
}
nextItem.set(DataComponents.LORE, ItemLore(listOf()))
nextItem.set(DataComponents.CUSTOM_DATA, CustomData.of(nextTag))
nextItem.set(
DataComponents.CUSTOM_MODEL_DATA,
net.minecraft.world.item.component.CustomModelData(1)
)
Menu.NEXT_BUTTON_SLOTS.forEach { container.setItem(it, nextItem.copy()) }
return container
}

View file

@ -0,0 +1,268 @@
package com.beemer.essentials.gui
import com.beemer.essentials.util.SoundUtils
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_9x6, syncId) {
companion object {
const val CONTAINER_SIZE = 54 // 9x6 = 54
// 메뉴 아이템 슬롯 위치 (9x6 기준)
const val SLOT_SPAWN = 19 // 스폰
const val SLOT_COORDINATES = 21 // 좌표
const val SLOT_WORKBENCH = 23 // 제작대
const val SLOT_TPA = 25 // 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) {
// 허용된 버튼 슬롯 목록
val buttonSlots =
setOf(
// 스폰
18,
19,
20,
27,
28,
29,
// 제작대 (순서 변경)
21,
22,
23,
30,
31,
32,
// 좌표 (순서 변경)
24,
25,
26,
33,
34,
35,
// TPA
36,
37,
38,
45,
46,
47
)
// 버튼 슬롯이 아니면 기본 처리
if (slotId !in buttonSlots || 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")
if (action.isEmpty()) return
when (action) {
"spawn" -> {
player.closeContainer()
player.server.commands.performPrefixedCommand(
player.createCommandSourceStack(),
"스폰"
)
// 스폰 명령어에서 이미 사운드 재생하므로 여기서는 제거
}
"coordinates" -> {
playClickSound(player)
player.closeContainer()
player.server.commands.performPrefixedCommand(
player.createCommandSourceStack(),
"좌표"
)
}
"workbench" -> {
player.closeContainer()
openWorkbench(player)
playClickSound(player)
}
"tpa" -> {
// TPA 명령어에서 사운드 재생하므로 여기서는 제거
player.closeContainer()
player.server.commands.performPrefixedCommand(
player.createCommandSourceStack(),
"tpa"
)
}
}
}
private fun playClickSound(player: ServerPlayer) {
SoundUtils.playClick(player)
}
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(54) // 9x6 = 54
// 버튼 생성 헬퍼 함수 (툴팁 포함)
fun createButton(action: String, name: String, description: String): ItemStack {
val item = ItemStack(Items.GLASS_PANE)
// 이름 설정 (하늘색)
item.set(
DataComponents.CUSTOM_NAME,
Component.literal(name).withStyle {
it.withColor(0xA8D8D8).withItalic(false)
}
)
// 설명 설정 (회색)
item.set(
DataComponents.LORE,
ItemLore(
listOf(
Component.literal(description).withStyle {
it.withColor(ChatFormatting.GRAY).withItalic(false)
}
)
)
)
item.set(
DataComponents.CUSTOM_DATA,
CustomData.of(CompoundTag().apply { putString("action", action) })
)
// CustomModelData를 1로 설정하여 텍스처 제거
item.set(
DataComponents.CUSTOM_MODEL_DATA,
net.minecraft.world.item.component.CustomModelData(1)
)
return item
}
// 스폰 버튼 (3x2 영역: 18,19,20,27,28,29)
val spawnSlots = listOf(18, 19, 20, 27, 28, 29)
val spawnItem = createButton("spawn", "스폰", "스폰 지점으로 이동합니다")
spawnSlots.forEach { container.setItem(it, spawnItem.copy()) }
// 제작대 버튼 (3x2 영역: 21,22,23,30,31,32)
val workbenchSlots = listOf(21, 22, 23, 30, 31, 32)
val workbenchItem = createButton("workbench", "제작대", "제작대를 엽니다")
workbenchSlots.forEach { container.setItem(it, workbenchItem.copy()) }
// 좌표 버튼 (3x2 영역: 24,25,26,33,34,35)
val coordSlots = listOf(24, 25, 26, 33, 34, 35)
val coordItem = createButton("coordinates", "좌표", "저장된 좌표 목록을 엽니다")
coordSlots.forEach { container.setItem(it, coordItem.copy()) }
// TPA 버튼 (3x2 영역: 36,37,38,45,46,47)
val tpaSlots = listOf(36, 37, 38, 45, 46, 47)
val tpaItem = createButton("tpa", "텔레포트", "다른 플레이어에게 이동합니다")
tpaSlots.forEach { container.setItem(it, tpaItem.copy()) }
return container
}
/** 메뉴 GUI 열기 */
fun openEssentialsMenu(player: ServerPlayer) {
val container = createEssentialsMenuContainer()
// 메뉴 오픈 사운드 재생
SoundUtils.playMenu(player)
// 커스텀 GUI 이미지를 위한 유니코드 문자
// \uF808 = -8 (왼쪽으로 8px 이동)
// \uE000 = 메뉴 GUI 이미지
val customGuiPrefix = "\uF808\uE000"
player.openMenu(
SimpleMenuProvider(
{ windowId, inv, _ -> EssentialsMenuGui(windowId, inv, container, player) },
Component.literal(customGuiPrefix).withStyle { it.withColor(0xFFFFFF) }
)
)
}

View file

@ -2,10 +2,9 @@ package com.beemer.essentials.gui
import com.beemer.essentials.config.PlayerConfig
import com.beemer.essentials.data.Location
import com.beemer.essentials.data.Player
import com.beemer.essentials.nickname.NicknameDataStore
import net.minecraft.ChatFormatting
import net.minecraft.network.chat.Component
import com.beemer.essentials.util.MessageUtils
import com.beemer.essentials.util.SoundUtils
import net.minecraft.server.level.ServerPlayer
import net.minecraft.world.Container
import net.minecraft.world.entity.player.Inventory
@ -79,10 +78,17 @@ class TeleportGui(
player: net.minecraft.world.entity.player.Player
) {
if (slotId in 0 until CONTAINER_SIZE && player is ServerPlayer) {
// 해당 슬롯에 플레이어가 있는지 확인
val currentTargetPlayer =
targetPlayers.getOrNull(slotId)?.let { player.server.playerList.getPlayer(it) }
targetPlayers.getOrNull(slotId)?.let {
player.server.playerList.getPlayer(it)
}
// 플레이어가 없으면 무시 (빈 공간 클릭)
if (currentTargetPlayer == null) {
return
}
if (currentTargetPlayer != null && player.uuid != currentTargetPlayer.uuid) {
val prevPos = player.blockPosition()
val prevDimension = player.level().dimension().location().toString()
val prevBiome =
@ -112,38 +118,22 @@ class TeleportGui(
currentTargetPlayer.xRot
)
// 소리 재생 (텔레포트 후)
playClickSound(player)
// 닉네임이 있으면 닉네임 사용
val targetName =
NicknameDataStore.getNickname(currentTargetPlayer.uuid)
?: currentTargetPlayer.gameProfile.name
val playerName =
NicknameDataStore.getNickname(player.uuid) ?: player.gameProfile.name
NicknameDataStore.getNickname(player.uuid)
?: player.gameProfile.name
player.sendSystemMessage(
Component.literal(targetName)
.withStyle { it.withColor(ChatFormatting.AQUA) }
.append(
Component.literal("님에게 텔레포트 했습니다.").withStyle {
it.withColor(ChatFormatting.GOLD)
}
MessageUtils.sendSuccess(player, "{$targetName}님에게 텔레포트 했습니다.")
MessageUtils.sendInfo(
currentTargetPlayer,
"{$playerName}님이 당신에게 텔레포트 했습니다."
)
)
currentTargetPlayer.sendSystemMessage(
Component.literal(playerName)
.withStyle { it.withColor(ChatFormatting.AQUA) }
.append(
Component.literal("님이 당신에게 텔레포트 했습니다.").withStyle {
it.withColor(ChatFormatting.GOLD)
}
)
)
} else {
player.sendSystemMessage(
Component.literal("해당 플레이어는 현재 오프라인입니다.").withStyle {
it.withColor(ChatFormatting.RED)
}
)
}
player.closeContainer()
return
@ -151,6 +141,10 @@ class TeleportGui(
super.clicked(slotId, button, clickType, player)
}
private fun playClickSound(player: ServerPlayer) {
SoundUtils.playTeleport(player)
}
override fun stillValid(player: net.minecraft.world.entity.player.Player): Boolean = true
override fun quickMoveStack(

View file

@ -1,9 +1,8 @@
package com.beemer.essentials.nickname
import com.beemer.essentials.util.MessageUtils
import com.mojang.brigadier.arguments.StringArgumentType
import net.minecraft.ChatFormatting
import net.minecraft.commands.Commands
import net.minecraft.network.chat.Component
import net.minecraft.server.level.ServerPlayer
import net.neoforged.bus.api.SubscribeEvent
import net.neoforged.neoforge.event.RegisterCommandsEvent
@ -29,7 +28,6 @@ object NicknameCommand {
.entity as?
ServerPlayer
?: return@executes 0
val nickname =
StringArgumentType
.getString(
@ -46,7 +44,6 @@ object NicknameCommand {
val player =
context.source.entity as? ServerPlayer
?: return@executes 0
executeReset(player)
}
)
@ -69,7 +66,6 @@ object NicknameCommand {
.entity as?
ServerPlayer
?: return@executes 0
val nickname =
StringArgumentType
.getString(
@ -86,7 +82,6 @@ object NicknameCommand {
val player =
context.source.entity as? ServerPlayer
?: return@executes 0
executeReset(player)
}
)
@ -96,65 +91,41 @@ object NicknameCommand {
private fun executeSet(player: ServerPlayer, nickname: String): Int {
// 유효성 검사: 길이
if (nickname.length < 2 || nickname.length > 16) {
player.sendSystemMessage(
Component.literal("닉네임은 2~16자 사이여야 합니다.").withStyle {
it.withColor(ChatFormatting.RED)
MessageUtils.sendError(player, "닉네임은 2~16자 사이여야 합니다.")
return 0
}
)
// 유효성 검사: 현재 닉네임과 동일
val currentNickname = NicknameDataStore.getNickname(player.uuid)
if (currentNickname == nickname) {
MessageUtils.sendError(player, "현재 사용 중인 닉네임과 동일합니다.")
return 0
}
// 유효성 검사: 중복
if (NicknameDataStore.isNicknameTaken(nickname, player.uuid)) {
player.sendSystemMessage(
Component.literal("이미 사용 중인 닉네임입니다.").withStyle {
it.withColor(ChatFormatting.RED)
}
)
MessageUtils.sendError(player, "이미 사용 중인 닉네임입니다.")
return 0
}
// 닉네임 저장 및 적용 (gameProfile.name = 실제 마인크래프트 이름)
// 닉네임 저장 및 적용
NicknameDataStore.setNickname(player.uuid, player.gameProfile.name, nickname)
NicknameManager.applyNickname(player, nickname)
player.sendSystemMessage(
Component.literal("닉네임이 ")
.withStyle { it.withColor(ChatFormatting.GOLD) }
.append(
Component.literal(nickname).withStyle {
it.withColor(ChatFormatting.AQUA)
}
)
.append(
Component.literal("(으)로 변경되었습니다.").withStyle {
it.withColor(ChatFormatting.GOLD)
}
)
)
MessageUtils.sendSuccess(player, "닉네임이 {$nickname}(으)로 변경되었습니다.")
return 1
}
private fun executeReset(player: ServerPlayer): Int {
if (!NicknameDataStore.hasNickname(player.uuid)) {
player.sendSystemMessage(
Component.literal("설정된 닉네임이 없습니다.").withStyle {
it.withColor(ChatFormatting.RED)
}
)
MessageUtils.sendError(player, "설정된 닉네임이 없습니다.")
return 0
}
NicknameDataStore.removeNickname(player.uuid)
NicknameManager.removeNickname(player)
player.sendSystemMessage(
Component.literal("닉네임이 초기화되었습니다.").withStyle {
it.withColor(ChatFormatting.GOLD)
}
)
MessageUtils.sendSuccess(player, "닉네임이 초기화되었습니다.")
return 1
}
}

View file

@ -0,0 +1,23 @@
package com.beemer.essentials.util
import com.beemer.essentials.data.Location
import net.minecraft.server.level.ServerPlayer
/** 위치 관련 유틸리티 플레이어 위치를 Location 객체로 변환하는 공통 로직 */
object LocationUtils {
/** 플레이어의 현재 위치를 Location 객체로 변환 */
fun fromPlayer(player: ServerPlayer): Location {
val pos = player.position()
val blockPos = player.blockPosition()
val level = player.level()
val dimension = level.dimension().location().toString()
val biome =
level.getBiome(blockPos)
.unwrapKey()
.map { it.location().toString() }
.orElse("minecraft:plains")
return Location(dimension = dimension, biome = biome, x = pos.x, y = pos.y, z = pos.z)
}
}

View file

@ -0,0 +1,125 @@
package com.beemer.essentials.util
import net.minecraft.network.chat.Component
import net.minecraft.network.chat.MutableComponent
import net.minecraft.network.chat.Style
import net.minecraft.network.chat.TextColor
import net.minecraft.server.level.ServerPlayer
/** 메시지 스타일 유틸리티 기본 텍스트: 밝은 청록색 (#A8D8D8) 강조 텍스트: 흰색 (#FFFFFF) */
object MessageUtils {
// 기본 색상 (더 밝게)
private const val BASE_COLOR = 0xA8D8D8 // 밝은 청록색 (기본 텍스트)
private const val ACCENT_COLOR = 0xFFFFFF // 흰색 (강조)
private const val ERROR_COLOR = 0xFF8888 // 밝은 빨간색 (오류)
private const val WARNING_COLOR = 0xFFE066 // 밝은 노란색 (경고)
/**
* 기본 스타일 메시지 생성
* @param text 텍스트 (강조 부분은 {중괄호} 감싸기) : "스폰으로 이동했습니다." 또는 "{비머}님에게 텔레포트 요청을 보냈습니다."
*/
fun styled(text: String): MutableComponent {
return parseStyledText(text, BASE_COLOR, ACCENT_COLOR)
}
/** 성공 메시지 (아이콘 없이 기본 스타일) */
fun success(text: String): MutableComponent {
return styled(text)
}
/** 정보 메시지 (아이콘 없이 기본 스타일) */
fun info(text: String): MutableComponent {
return styled(text)
}
/** 오류 메시지 (빨간색 스타일) */
fun error(text: String): MutableComponent {
return parseStyledText(text, ERROR_COLOR, ACCENT_COLOR)
}
/** 경고 메시지 (노란색 스타일) */
fun warning(text: String): MutableComponent {
return parseStyledText(text, WARNING_COLOR, ACCENT_COLOR)
}
// === 플레이어에게 직접 전송 ===
fun sendSuccess(player: ServerPlayer, text: String) {
player.sendSystemMessage(success(text))
}
fun sendInfo(player: ServerPlayer, text: String) {
player.sendSystemMessage(info(text))
}
fun sendError(player: ServerPlayer, text: String) {
player.sendSystemMessage(error(text))
SoundUtils.playError(player)
}
fun sendWarning(player: ServerPlayer, text: String) {
player.sendSystemMessage(warning(text))
}
/** 알림 메시지 (사운드 포함) - 서버 접속 시 등 */
fun sendNotification(player: ServerPlayer, text: String) {
player.sendSystemMessage(info(text))
SoundUtils.playNotification(player)
}
/** 스타일 텍스트 파싱 {중괄호} 안의 텍스트는 강조 색상으로 표시 */
private fun parseStyledText(text: String, baseColor: Int, accentColor: Int): MutableComponent {
val result: MutableComponent = Component.empty()
var i = 0
val sb = StringBuilder()
while (i < text.length) {
when {
text[i] == '{' -> {
// 이전 텍스트 추가 (기본 색상)
if (sb.isNotEmpty()) {
result.append(
Component.literal(sb.toString())
.withStyle(
Style.EMPTY.withColor(TextColor.fromRgb(baseColor))
)
)
sb.clear()
}
// 강조 텍스트 찾기
val endIdx = text.indexOf('}', i)
if (endIdx != -1) {
val accentText = text.substring(i + 1, endIdx)
result.append(
Component.literal(accentText)
.withStyle(
Style.EMPTY.withColor(
TextColor.fromRgb(accentColor)
)
)
)
i = endIdx + 1
} else {
sb.append(text[i])
i++
}
}
else -> {
sb.append(text[i])
i++
}
}
}
// 남은 텍스트 추가
if (sb.isNotEmpty()) {
result.append(
Component.literal(sb.toString())
.withStyle(Style.EMPTY.withColor(TextColor.fromRgb(baseColor)))
)
}
return result
}
}

View file

@ -0,0 +1,44 @@
package com.beemer.essentials.util
import net.minecraft.resources.ResourceLocation
import net.minecraft.server.level.ServerPlayer
import net.minecraft.sounds.SoundEvent
import net.minecraft.sounds.SoundSource
/** 사운드 재생 유틸리티 모든 커스텀 사운드 재생 로직을 중앙화 */
object SoundUtils {
// 기본 볼륨 (20%)
private const val DEFAULT_VOLUME = 0.2f
private const val DEFAULT_PITCH = 1.0f
/** 클릭 사운드 - GUI 버튼 클릭 시 */
fun playClick(player: ServerPlayer) {
playSound(player, "minecraft:custom.click")
}
/** 텔레포트 사운드 - 스폰, 좌표이동, TPA, back 시 */
fun playTeleport(player: ServerPlayer) {
playSound(player, "minecraft:custom.teleport")
}
/** 에러 사운드 - 오류 메시지 시 */
fun playError(player: ServerPlayer) {
playSound(player, "minecraft:custom.error")
}
/** 알림 사운드 - 서버 접속 시 */
fun playNotification(player: ServerPlayer) {
playSound(player, "minecraft:custom.notification")
}
/** 메뉴 오픈 사운드 - 메뉴 GUI 열기 시 */
fun playMenu(player: ServerPlayer) {
playSound(player, "minecraft:custom.menu")
}
/** 공통 사운드 재생 */
private fun playSound(player: ServerPlayer, soundId: String) {
val sound = SoundEvent.createVariableRangeEvent(ResourceLocation.parse(soundId))
player.playNotifySound(sound, SoundSource.MASTER, DEFAULT_VOLUME, DEFAULT_PITCH)
}
}

View file

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

View file

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