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(NicknameCommand)
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(MenuCommand)
} }
} }

View file

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

View file

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

View file

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

View file

@ -1,13 +1,29 @@
package com.beemer.essentials.command package com.beemer.essentials.command
import net.minecraft.ChatFormatting
import net.minecraft.commands.Commands import net.minecraft.commands.Commands
import net.minecraft.network.chat.Component import net.minecraft.network.chat.Component
import net.minecraft.network.chat.TextColor
import net.minecraft.server.level.ServerPlayer import net.minecraft.server.level.ServerPlayer
import net.neoforged.bus.api.SubscribeEvent import net.neoforged.bus.api.SubscribeEvent
import net.neoforged.neoforge.event.RegisterCommandsEvent import net.neoforged.neoforge.event.RegisterCommandsEvent
object HelpCommand { 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 @SubscribeEvent
fun onRegisterCommands(event: RegisterCommandsEvent) { fun onRegisterCommands(event: RegisterCommandsEvent) {
listOf("도움말", "help", "essentials").forEach { command -> listOf("도움말", "help", "essentials").forEach { command ->
@ -24,15 +40,11 @@ object HelpCommand {
private fun showHelp(player: ServerPlayer) { private fun showHelp(player: ServerPlayer) {
val header = val header =
Component.literal("══════════ ") Component.literal("══════════ ")
.withStyle { it.withColor(ChatFormatting.DARK_GRAY) } .withStyle { it.withColor(COLOR_SEPARATOR) }
.append( .append(Component.literal("도움말").withStyle { it.withColor(COLOR_HEADER) })
Component.literal("도움말").withStyle {
it.withColor(ChatFormatting.GOLD).withBold(true)
}
)
.append( .append(
Component.literal(" ══════════").withStyle { Component.literal(" ══════════").withStyle {
it.withColor(ChatFormatting.DARK_GRAY) it.withColor(COLOR_SEPARATOR)
} }
) )
@ -40,7 +52,7 @@ object HelpCommand {
player.sendSystemMessage(Component.empty()) player.sendSystemMessage(Component.empty())
// 좌표 관리 // 좌표 관리
sendCategory(player, "좌표 관리", ChatFormatting.YELLOW) sendCategory(player, "좌표 관리", COLOR_COORDINATE)
sendCommand(player, "/좌표", "저장된 좌표 목록 (GUI)") sendCommand(player, "/좌표", "저장된 좌표 목록 (GUI)")
sendCommand(player, "/좌표추가 <이름>", "현재 위치 저장") sendCommand(player, "/좌표추가 <이름>", "현재 위치 저장")
sendCommand(player, "/좌표이동 <이름>", "해당 좌표로 이동") sendCommand(player, "/좌표이동 <이름>", "해당 좌표로 이동")
@ -48,66 +60,61 @@ object HelpCommand {
player.sendSystemMessage(Component.empty()) player.sendSystemMessage(Component.empty())
// 스폰 // 스폰
sendCategory(player, "스폰", ChatFormatting.GREEN) sendCategory(player, "스폰", COLOR_SPAWN)
sendCommand(player, "/스폰", "스폰으로 이동") sendCommand(player, "/스폰", "스폰으로 이동")
sendCommand(player, "/스폰설정", "현재 위치를 스폰으로 설정") sendCommand(player, "/스폰설정", "현재 위치를 스폰으로 설정")
sendCommand(player, "/스폰삭제", "커스텀 스폰 삭제") sendCommand(player, "/스폰삭제", "커스텀 스폰 삭제")
player.sendSystemMessage(Component.empty()) player.sendSystemMessage(Component.empty())
// 텔레포트 // 텔레포트
sendCategory(player, "텔레포트", ChatFormatting.AQUA) sendCategory(player, "텔레포트", COLOR_TELEPORT)
sendCommand(player, "/tpa", "플레이어 선택 (GUI)") sendCommand(player, "/tpa", "플레이어 선택 (GUI)")
sendCommand(player, "/back", "이전 위치로 이동") sendCommand(player, "/back", "이전 위치로 이동")
player.sendSystemMessage(Component.empty()) player.sendSystemMessage(Component.empty())
// 닉네임 // 닉네임
sendCategory(player, "닉네임", ChatFormatting.LIGHT_PURPLE) sendCategory(player, "닉네임", COLOR_NICKNAME)
sendCommand(player, "/닉네임 변경 <닉네임>", "닉네임 설정") sendCommand(player, "/닉네임 변경 <닉네임>", "닉네임 설정")
sendCommand(player, "/닉네임 초기화", "닉네임 초기화") sendCommand(player, "/닉네임 초기화", "닉네임 초기화")
player.sendSystemMessage(Component.empty()) player.sendSystemMessage(Component.empty())
// 머리 // 머리
sendCategory(player, "머리", ChatFormatting.GOLD) sendCategory(player, "머리", COLOR_HEAD)
sendCommand(player, "/머리", "내 머리 아이템 받기") sendCommand(player, "/머리", "내 머리 아이템 받기")
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 = val footer =
Component.literal("═══════════════════════════════").withStyle { Component.literal("═══════════════════════════════").withStyle {
it.withColor(ChatFormatting.DARK_GRAY) it.withColor(COLOR_SEPARATOR)
} }
player.sendSystemMessage(footer) player.sendSystemMessage(footer)
} }
private fun sendCategory(player: ServerPlayer, name: String, color: ChatFormatting) { private fun sendCategory(player: ServerPlayer, name: String, color: TextColor) {
val message = val message =
Component.literal("") Component.literal("")
.withStyle { it.withColor(color) } .withStyle { it.withColor(color) }
.append( .append(Component.literal(name).withStyle { it.withColor(color) })
Component.literal(name).withStyle {
it.withColor(color).withBold(true)
}
)
player.sendSystemMessage(message) player.sendSystemMessage(message)
} }
private fun sendCommand(player: ServerPlayer, cmd: String, desc: String) { private fun sendCommand(player: ServerPlayer, cmd: String, desc: String) {
val message = val message =
Component.literal(" ") Component.literal(" ")
.append( .append(Component.literal(cmd).withStyle { it.withColor(COLOR_COMMAND) })
Component.literal(cmd).withStyle { .append(Component.literal(" - ").withStyle { it.withColor(COLOR_DASH) })
it.withColor(ChatFormatting.WHITE) .append(Component.literal(desc).withStyle { it.withColor(COLOR_DESC) })
}
)
.append(
Component.literal(" - ").withStyle {
it.withColor(ChatFormatting.DARK_GRAY)
}
)
.append(
Component.literal(desc).withStyle {
it.withColor(ChatFormatting.GRAY)
}
)
player.sendSystemMessage(message) 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.config.PlayerConfig
import com.beemer.essentials.util.CommandUtils import com.beemer.essentials.util.CommandUtils
import com.beemer.essentials.util.DimensionUtils 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.commands.Commands
import net.minecraft.network.chat.Component
import net.neoforged.bus.api.SubscribeEvent import net.neoforged.bus.api.SubscribeEvent
import net.neoforged.neoforge.event.RegisterCommandsEvent import net.neoforged.neoforge.event.RegisterCommandsEvent
@ -23,16 +23,9 @@ object PlayerCommand {
val lastLocation = val lastLocation =
info?.lastLocation info?.lastLocation
?: run { ?: run {
player.sendSystemMessage( MessageUtils.sendError(
Component.literal( player,
"이전 위치가 존재하지 않습니다." "이전 위치가 존재하지 않습니다."
)
.withStyle {
it.withColor(
ChatFormatting
.RED
)
}
) )
return@executes 0 return@executes 0
} }
@ -43,16 +36,9 @@ object PlayerCommand {
lastLocation.dimension lastLocation.dimension
) )
?: run { ?: run {
player.sendSystemMessage( MessageUtils.sendError(
Component.literal( player,
"존재하지 않는 차원입니다." "존재하지 않는 차원입니다."
)
.withStyle {
it.withColor(
ChatFormatting
.RED
)
}
) )
return@executes 0 return@executes 0
} }
@ -65,11 +51,9 @@ object PlayerCommand {
player.yRot, player.yRot,
player.xRot player.xRot
) )
player.sendSystemMessage( // 텔레포트 사운드 재생
Component.literal("이전 위치로 이동했습니다.").withStyle { SoundUtils.playTeleport(player)
it.withColor(ChatFormatting.GOLD) MessageUtils.sendSuccess(player, "이전 위치로 이동했습니다.")
}
)
1 1
} }
) )

View file

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

View file

@ -5,116 +5,132 @@ import com.beemer.essentials.config.SpawnConfig
import com.beemer.essentials.data.Location import com.beemer.essentials.data.Location
import com.beemer.essentials.util.CommandUtils import com.beemer.essentials.util.CommandUtils
import com.beemer.essentials.util.DimensionUtils 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.commands.Commands
import net.minecraft.network.chat.Component
import net.neoforged.bus.api.SubscribeEvent import net.neoforged.bus.api.SubscribeEvent
import net.neoforged.neoforge.event.RegisterCommandsEvent import net.neoforged.neoforge.event.RegisterCommandsEvent
object SpawnCommand { object SpawnCommand {
@SubscribeEvent @SubscribeEvent
fun onRegisterCommands(event: RegisterCommandsEvent) { fun onRegisterCommands(event: RegisterCommandsEvent) {
listOf("setspawn", "스폰설정").forEach { command -> listOf("setspawn", "스폰설정").forEach { command ->
event.dispatcher.register( event.dispatcher.register(
Commands.literal(command).executes { context -> Commands.literal(command).executes { context ->
val player = val player =
CommandUtils.getPlayerOrSendFailure(context.source) CommandUtils.getPlayerOrSendFailure(context.source)
?: return@executes 0 ?: return@executes 0
val exactPos = player.position() val exactPos = player.position()
val blockPos = player.blockPosition() val blockPos = player.blockPosition()
val dimensionId = player.level().dimension().location().toString() val dimension =
val biomeId = player.level().dimension().location().toString()
player.level() val biome =
.getBiome(blockPos) player.level()
.unwrapKey() .getBiome(blockPos)
.map { it.location().toString() } .unwrapKey()
.orElse("minecraft:plains") .map { it.location().toString() }
.orElse("minecraft:plains")
val location = val location =
Location( Location(
dimension = dimensionId, dimension = dimension,
biome = biomeId, biome = biome,
x = exactPos.x, x = exactPos.x,
y = exactPos.y, y = exactPos.y,
z = exactPos.z z = exactPos.z
) )
SpawnConfig.setCustomSpawn(location) SpawnConfig.setCustomSpawn(location)
MessageUtils.sendSuccess(player, "스폰 지점이 현재 위치로 설정되었습니다.")
player.sendSystemMessage( 1
Component.literal("스폰 지점이 현재 위치로 설정되었습니다.").withStyle {
it.withColor(ChatFormatting.GOLD)
} }
) )
1 }
}
)
}
listOf("spawn", "스폰", "넴주").forEach { command -> listOf("spawn", "스폰", "넴주").forEach { command ->
event.dispatcher.register( event.dispatcher.register(
Commands.literal(command).executes { context -> Commands.literal(command).executes { context ->
val player = val player =
CommandUtils.getPlayerOrSendFailure(context.source) CommandUtils.getPlayerOrSendFailure(context.source)
?: return@executes 0 ?: return@executes 0
val target = SpawnConfig.getCustomSpawn() ?: SpawnConfig.getDefaultSpawn() val target =
SpawnConfig.getCustomSpawn()
?: SpawnConfig.getDefaultSpawn()
target?.let { t -> target?.let { t ->
val level = DimensionUtils.getLevelById(player.server, t.dimension) val level =
DimensionUtils.getLevelById(
player.server,
t.dimension
)
level?.let { l -> level?.let { l ->
val exactPos = player.position() val exactPos = player.position()
val blockPos = player.blockPosition() val blockPos = player.blockPosition()
val currentDimension = val currentDimension =
player.level().dimension().location().toString() player.level()
val currentBiome = .dimension()
player.level() .location()
.getBiome(blockPos) .toString()
.unwrapKey() val currentBiome =
.map { it.location().toString() } player.level()
.orElse("minecraft:plains") .getBiome(blockPos)
.unwrapKey()
.map {
it.location()
.toString()
}
.orElse("minecraft:plains")
val currentLocation = val currentLocation =
Location( Location(
dimension = currentDimension, dimension =
biome = currentBiome, currentDimension,
x = exactPos.x, biome = currentBiome,
y = exactPos.y, x = exactPos.x,
z = exactPos.z y = exactPos.y,
) z = exactPos.z
)
PlayerConfig.recordLastLocation(player, currentLocation) PlayerConfig.recordLastLocation(
player.teleportTo(l, t.x, t.y, t.z, player.yRot, player.xRot) player,
player.sendSystemMessage( currentLocation
Component.literal("스폰으로 이동했습니다.").withStyle { )
it.withColor(ChatFormatting.GOLD) player.teleportTo(
l,
t.x,
t.y,
t.z,
player.yRot,
player.xRot
)
// 텔레포트 사운드 재생
SoundUtils.playTeleport(player)
MessageUtils.sendSuccess(
player,
"스폰으로 이동했습니다."
)
}
} }
) 1
}
}
1
}
)
}
listOf("delspawn", "스폰삭제").forEach { command ->
event.dispatcher.register(
Commands.literal(command).executes { context ->
val player =
CommandUtils.getPlayerOrSendFailure(context.source)
?: return@executes 0
SpawnConfig.removeCustomSpawn()
player.sendSystemMessage(
Component.literal("스폰 지점이 기본 스폰 지점으로 변경되었습니다.").withStyle {
it.withColor(ChatFormatting.GOLD)
} }
) )
1 }
}
) listOf("delspawn", "스폰삭제").forEach { command ->
event.dispatcher.register(
Commands.literal(command).executes { context ->
val player =
CommandUtils.getPlayerOrSendFailure(context.source)
?: return@executes 0
SpawnConfig.removeCustomSpawn()
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.gui.TeleportGui.Companion.CONTAINER_SIZE
import com.beemer.essentials.nickname.NicknameDataStore import com.beemer.essentials.nickname.NicknameDataStore
import com.beemer.essentials.util.CommandUtils 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.beemer.essentials.util.TranslationUtils
import com.mojang.authlib.GameProfile import com.mojang.authlib.GameProfile
import net.minecraft.ChatFormatting import net.minecraft.ChatFormatting
@ -32,13 +34,22 @@ object TeleportCommand {
CommandUtils.getPlayerOrSendFailure(context.source) CommandUtils.getPlayerOrSendFailure(context.source)
?: return@executes 0 ?: return@executes 0
val container = SimpleContainer(9 * 3)
val targetPlayers = val targetPlayers =
player.server.playerList.players.filter { player.server.playerList.players.filter {
it != player it != player
} }
// 다른 플레이어가 없으면 에러 메시지 표시
if (targetPlayers.isEmpty()) {
MessageUtils.sendError(
player,
"현재 접속 중인 다른 플레이어가 없습니다."
)
return@executes 0
}
val container = SimpleContainer(9 * 3)
targetPlayers.take(CONTAINER_SIZE).forEachIndexed { targetPlayers.take(CONTAINER_SIZE).forEachIndexed {
idx, idx,
target -> target ->
@ -46,6 +57,9 @@ object TeleportCommand {
container.setItem(idx, head) container.setItem(idx, head)
} }
// 플레이어가 있을 때만 클릭 사운드 재생
SoundUtils.playClick(player)
player.openMenu( player.openMenu(
SimpleMenuProvider( SimpleMenuProvider(
{ windowId, inv, _ -> { 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 package com.beemer.essentials.event
import com.beemer.essentials.util.ChatUtils 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.minecraft.server.level.ServerPlayer
import net.neoforged.bus.api.SubscribeEvent import net.neoforged.bus.api.SubscribeEvent
import net.neoforged.neoforge.event.ServerChatEvent import net.neoforged.neoforge.event.ServerChatEvent
@ -12,13 +12,11 @@ object ChatEvents {
val player = event.player as ServerPlayer val player = event.player as ServerPlayer
val raw = event.rawText val raw = event.rawText
if (raw.startsWith("/")) if (raw.startsWith("/")) return
return
if (ChatUtils.hasIllegalCharacter(raw)) { if (ChatUtils.hasIllegalCharacter(raw)) {
event.isCanceled = true event.isCanceled = true
player.sendSystemMessage(Component.literal("채팅에 허용되지 않는 문자가 포함되어 있습니다.") MessageUtils.sendError(player, "채팅에 허용되지 않는 문자가 포함되어 있습니다.")
.withStyle { it.withColor(net.minecraft.ChatFormatting.RED) })
return 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.data.Location
import com.beemer.essentials.util.ChatUtils import com.beemer.essentials.util.ChatUtils
import com.beemer.essentials.util.DimensionUtils 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.minecraft.server.level.ServerPlayer
import net.neoforged.bus.api.SubscribeEvent import net.neoforged.bus.api.SubscribeEvent
import net.neoforged.neoforge.event.entity.player.PlayerEvent import net.neoforged.neoforge.event.entity.player.PlayerEvent
@ -35,9 +37,70 @@ object PlayerEvents {
val level = DimensionUtils.getLevelById(player.server, spawn.dimension) ?: return val level = DimensionUtils.getLevelById(player.server, spawn.dimension) ?: return
player.teleportTo(level, spawn.x, spawn.y, spawn.z, player.yRot, player.xRot) 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 @SubscribeEvent
fun onPlayerLoggedOut(event: PlayerEvent.PlayerLoggedOutEvent) { fun onPlayerLoggedOut(event: PlayerEvent.PlayerLoggedOutEvent) {
val player = event.entity as? ServerPlayer ?: return val player = event.entity as? ServerPlayer ?: return
@ -54,18 +117,22 @@ object PlayerEvents {
val pos = oldPlayer.blockPosition() val pos = oldPlayer.blockPosition()
val dimension = oldPlayer.level().dimension().location().toString() val dimension = oldPlayer.level().dimension().location().toString()
val biome = oldPlayer.level().getBiome(pos) val biome =
.unwrapKey() oldPlayer
.map { it.location().toString() } .level()
.orElse("minecraft:plains") .getBiome(pos)
.unwrapKey()
.map { it.location().toString() }
.orElse("minecraft:plains")
val lastLoc = Location( val lastLoc =
dimension = dimension, Location(
biome = biome, dimension = dimension,
x = pos.x.toDouble(), biome = biome,
y = pos.y.toDouble(), x = pos.x.toDouble(),
z = pos.z.toDouble() y = pos.y.toDouble(),
) z = pos.z.toDouble()
)
PlayerConfig.recordLastLocation(oldPlayer, lastLoc) PlayerConfig.recordLastLocation(oldPlayer, lastLoc)
} }

View file

@ -1,6 +1,7 @@
package com.beemer.essentials.gui package com.beemer.essentials.gui
import com.beemer.essentials.config.AntimobConfig import com.beemer.essentials.config.AntimobConfig
import com.beemer.essentials.util.SoundUtils
import net.minecraft.ChatFormatting import net.minecraft.ChatFormatting
import net.minecraft.core.component.DataComponents import net.minecraft.core.component.DataComponents
import net.minecraft.network.chat.Component import net.minecraft.network.chat.Component
@ -77,6 +78,7 @@ class AntimobGui(
) { ) {
if (player is ServerPlayer && slotId in 0 until CONTAINER_SIZE) { if (player is ServerPlayer && slotId in 0 until CONTAINER_SIZE) {
if (glassSlotToMob.containsKey(slotId)) { if (glassSlotToMob.containsKey(slotId)) {
playClickSound(player)
val mob = glassSlotToMob[slotId] ?: return val mob = glassSlotToMob[slotId] ?: return
AntimobConfig.toggle(mob) AntimobConfig.toggle(mob)
container.setItem(slotId, makeToggleGlass(mob)) container.setItem(slotId, makeToggleGlass(mob))
@ -94,6 +96,10 @@ class AntimobGui(
index: Int index: Int
): ItemStack = ItemStack.EMPTY ): ItemStack = ItemStack.EMPTY
private fun playClickSound(player: ServerPlayer) {
SoundUtils.playClick(player)
}
private fun makeMobHead(mob: String): ItemStack { private fun makeMobHead(mob: String): ItemStack {
val headItem = ItemStack(Items.PLAYER_HEAD) 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.config.PlayerConfig
import com.beemer.essentials.data.Location import com.beemer.essentials.data.Location
import com.beemer.essentials.nickname.NicknameDataStore 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.translateBiome
import com.beemer.essentials.util.TranslationUtils.translateDimension import com.beemer.essentials.util.TranslationUtils.translateDimension
import java.util.UUID 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.CustomData
import net.minecraft.world.item.component.ItemLore import net.minecraft.world.item.component.ItemLore
/** 좌표 GUI - 페이지 기능 포함 5줄(45개) 표시 + 6번째 줄에 이전/다음 버튼 */ /** 좌표 GUI - 페이지 기능 포함 4줄(36개) 표시 + 6번째 줄에 이전/다음 버튼 */
class Menu( class Menu(
syncId: Int, syncId: Int,
playerInv: Inventory, playerInv: Inventory,
@ -38,9 +40,9 @@ class Menu(
) : AbstractContainerMenu(MenuType.GENERIC_9x6, syncId) { ) : AbstractContainerMenu(MenuType.GENERIC_9x6, syncId) {
companion object { companion object {
const val ITEMS_PER_PAGE = 45 // 5줄 * 9 const val ITEMS_PER_PAGE = 36 // 4줄 * 9
const val PREV_BUTTON_SLOT = 45 // 6번째 줄 첫 번째 val PREV_BUTTON_SLOTS = listOf(45, 46) // 6번째 줄 이전 버튼 2칸
const val NEXT_BUTTON_SLOT = 53 // 6번째 줄 마지막 val NEXT_BUTTON_SLOTS = listOf(52, 53) // 6번째 줄 다음 버튼 2칸
} }
init { init {
@ -81,24 +83,26 @@ class Menu(
val stack = slots[slotId].item val stack = slots[slotId].item
val tag = stack.get(DataComponents.CUSTOM_DATA)?.copyTag() ?: return val tag = stack.get(DataComponents.CUSTOM_DATA)?.copyTag() ?: return
// 이전 페이지 버튼 // 이전 페이지 버튼 (슬롯 45, 46)
if (slotId == PREV_BUTTON_SLOT && if (slotId in PREV_BUTTON_SLOTS &&
tag.contains("action") && tag.contains("action") &&
tag.getString("action") == "prev" tag.getString("action") == "prev"
) { ) {
if (currentPage > 0) { if (currentPage > 0) {
playClickSound(player)
openPage(player, currentPage - 1) openPage(player, currentPage - 1)
} }
return return
} }
// 다음 페이지 버튼 // 다음 페이지 버튼 (슬롯 52, 53)
if (slotId == NEXT_BUTTON_SLOT && if (slotId in NEXT_BUTTON_SLOTS &&
tag.contains("action") && tag.contains("action") &&
tag.getString("action") == "next" tag.getString("action") == "next"
) { ) {
val totalPages = getTotalPages() val totalPages = getTotalPages()
if (currentPage < totalPages - 1) { if (currentPage < totalPages - 1) {
playClickSound(player)
openPage(player, currentPage + 1) openPage(player, currentPage + 1)
} }
return return
@ -109,6 +113,7 @@ class Menu(
val x = tag.getDouble("x") val x = tag.getDouble("x")
val y = tag.getDouble("y") val y = tag.getDouble("y")
val z = tag.getDouble("z") val z = tag.getDouble("z")
val coordName = tag.getString("name")
val dimension = val dimension =
ResourceLocation.tryParse(tag.getString("dimension"))?.let { ResourceLocation.tryParse(tag.getString("dimension"))?.let {
ResourceKey.create(Registries.DIMENSION, it) ResourceKey.create(Registries.DIMENSION, it)
@ -116,7 +121,6 @@ class Menu(
val level = dimension?.let { player.server.getLevel(it) } val level = dimension?.let { player.server.getLevel(it) }
if (level != null) { if (level != null) {
// 이전 위치 저장
val prevPos = player.blockPosition() val prevPos = player.blockPosition()
PlayerConfig.recordLastLocation( PlayerConfig.recordLastLocation(
player, player,
@ -135,20 +139,18 @@ class Menu(
) )
player.teleportTo(level, x, y, z, player.yRot, player.xRot) player.teleportTo(level, x, y, z, player.yRot, player.xRot)
player.sendSystemMessage( // 텔레포트 사운드 재생
Component.literal(tag.getString("name")) SoundUtils.playTeleport(player)
.withStyle { it.withColor(ChatFormatting.DARK_GREEN) } MessageUtils.sendSuccess(player, "{$coordName}(으)로 이동했습니다.")
.append(
Component.literal("(으)로 이동했습니다.").withStyle {
it.withColor(ChatFormatting.GOLD)
}
)
)
} }
player.closeContainer() player.closeContainer()
} }
} }
private fun playClickSound(player: ServerPlayer) {
SoundUtils.playClick(player)
}
private fun getTotalPages(): Int { private fun getTotalPages(): Int {
val totalItems = CoordinateConfig.getCoordinates().size val totalItems = CoordinateConfig.getCoordinates().size
return if (totalItems == 0) 1 else (totalItems + ITEMS_PER_PAGE - 1) / ITEMS_PER_PAGE 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) { private fun openPage(player: ServerPlayer, page: Int) {
val container = createPageContainer(player, page) 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( player.openMenu(
SimpleMenuProvider( SimpleMenuProvider(
{ windowId, inv, _ -> Menu(windowId, inv, container, page) }, { windowId, inv, _ -> Menu(windowId, inv, container, page) },
Component.literal("저장된 좌표 (${page + 1}/${getTotalPages()})").withStyle { Component.literal(customTitle).withStyle { it.withColor(0xFFFFFF) }
it.withBold(true)
}
) )
) )
} }
@ -180,10 +194,10 @@ fun createPageContainer(player: ServerPlayer, page: Int): SimpleContainer {
if (coordinates.isEmpty()) 1 if (coordinates.isEmpty()) 1
else (coordinates.size + Menu.ITEMS_PER_PAGE - 1) / Menu.ITEMS_PER_PAGE else (coordinates.size + Menu.ITEMS_PER_PAGE - 1) / Menu.ITEMS_PER_PAGE
// 좌표 아이템 추가 (5줄, 45개) // 좌표 아이템 추가 (2~5번째 줄, 슬롯 9-44)
for (i in startIdx until endIdx) { for (i in startIdx until endIdx) {
val coord = coordinates[i] val coord = coordinates[i]
val item = ItemStack(Items.PAPER) val item = ItemStack(Items.FILLED_MAP)
val tag = val tag =
CompoundTag().apply { CompoundTag().apply {
putString("name", coord.name) putString("name", coord.name)
@ -207,96 +221,131 @@ fun createPageContainer(player: ServerPlayer, page: Int): SimpleContainer {
val displayName = val displayName =
if (creatorName != null) { if (creatorName != null) {
Component.literal(coord.name) Component.literal(coord.name)
.withStyle { it.withColor(ChatFormatting.GOLD).withBold(true) } .withStyle {
it.withColor(ChatFormatting.GOLD).withBold(false).withItalic(false)
}
.append( .append(
Component.literal(" ($creatorName)").withStyle { Component.literal(" ($creatorName)").withStyle {
it.withColor(ChatFormatting.AQUA).withBold(false) it.withColor(ChatFormatting.AQUA)
.withBold(false)
.withItalic(false)
} }
) )
} else { } else {
Component.literal(coord.name).withStyle { Component.literal(coord.name).withStyle {
it.withColor(ChatFormatting.GOLD).withBold(true) it.withColor(ChatFormatting.GOLD).withBold(false).withItalic(false)
} }
} }
val loreList: List<Component> = val loreList: List<Component> =
listOf( listOf(
Component.literal("디멘션: ") Component.literal("디멘션: ")
.withStyle { it.withColor(ChatFormatting.DARK_GREEN) } .withStyle {
it.withColor(ChatFormatting.DARK_GREEN).withItalic(false)
}
.append( .append(
Component.literal(translateDimension(coord.dimension)) Component.literal(translateDimension(coord.dimension))
.withStyle { it.withColor(ChatFormatting.GRAY) } .withStyle {
it.withColor(ChatFormatting.GRAY)
.withItalic(false)
}
), ),
Component.literal("바이옴: ") Component.literal("바이옴: ")
.withStyle { it.withColor(ChatFormatting.DARK_GREEN) } .withStyle {
it.withColor(ChatFormatting.DARK_GREEN).withItalic(false)
}
.append( .append(
Component.literal(translateBiome(coord.biome)).withStyle { Component.literal(translateBiome(coord.biome)).withStyle {
it.withColor(ChatFormatting.GRAY) it.withColor(ChatFormatting.GRAY).withItalic(false)
} }
), ),
Component.literal("좌표: ") Component.literal("좌표: ")
.withStyle { it.withColor(ChatFormatting.DARK_GREEN) } .withStyle {
it.withColor(ChatFormatting.DARK_GREEN).withItalic(false)
}
.append( .append(
Component.literal( Component.literal(
"${coord.x.toInt()}, ${coord.y.toInt()}, ${coord.z.toInt()}" "${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.CUSTOM_NAME, displayName)
item.set(DataComponents.LORE, ItemLore(loreList)) item.set(DataComponents.LORE, ItemLore(loreList))
item.set(DataComponents.CUSTOM_DATA, CustomData.of(tag)) 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) // 1번째 줄 (슬롯 0-8) - 타이틀 영역 투명 처리
val fillerItem = ItemStack(Items.GRAY_STAINED_GLASS_PANE) val fillerItem = ItemStack(Items.GLASS_PANE)
fillerItem.set(DataComponents.CUSTOM_NAME, Component.empty())
fillerItem.set(DataComponents.LORE, ItemLore(listOf()))
fillerItem.set( fillerItem.set(
DataComponents.CUSTOM_NAME, DataComponents.CUSTOM_MODEL_DATA,
Component.literal(" ").withStyle { it.withColor(ChatFormatting.DARK_GRAY) } 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()) container.setItem(slot, fillerItem.copy())
} }
// 이전 페이지 버튼 (슬롯 45) // 6번째 줄 가운데 슬롯 (47-51) - 투명 처리
if (page > 0) { for (slot in 47..51) {
val prevItem = ItemStack(Items.LIME_STAINED_GLASS_PANE) container.setItem(slot, fillerItem.copy())
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)
} }
// 다음 페이지 버튼 (슬롯 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) { if (page < totalPages - 1) {
val nextItem = ItemStack(Items.LIME_STAINED_GLASS_PANE)
val nextTag = CompoundTag().apply { putString("action", "next") }
nextItem.set( nextItem.set(
DataComponents.CUSTOM_NAME, DataComponents.CUSTOM_NAME,
Component.literal("다음 페이지").withStyle { it.withColor(ChatFormatting.YELLOW) } Component.literal("다음 페이지 [${page + 1}/$totalPages]").withStyle {
it.withColor(0xA8D8D8).withItalic(false)
}
) )
nextItem.set(DataComponents.CUSTOM_DATA, CustomData.of(nextTag))
container.setItem(Menu.NEXT_BUTTON_SLOT, nextItem)
} else { } else {
val disabledItem = ItemStack(Items.RED_STAINED_GLASS_PANE) nextItem.set(DataComponents.CUSTOM_NAME, Component.empty())
disabledItem.set( nextItem.set(DataComponents.HIDE_TOOLTIP, net.minecraft.util.Unit.INSTANCE)
DataComponents.CUSTOM_NAME,
Component.literal("다음 페이지").withStyle { it.withColor(ChatFormatting.YELLOW) }
)
container.setItem(Menu.NEXT_BUTTON_SLOT, disabledItem)
} }
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 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.config.PlayerConfig
import com.beemer.essentials.data.Location import com.beemer.essentials.data.Location
import com.beemer.essentials.data.Player
import com.beemer.essentials.nickname.NicknameDataStore import com.beemer.essentials.nickname.NicknameDataStore
import net.minecraft.ChatFormatting import com.beemer.essentials.util.MessageUtils
import net.minecraft.network.chat.Component import com.beemer.essentials.util.SoundUtils
import net.minecraft.server.level.ServerPlayer import net.minecraft.server.level.ServerPlayer
import net.minecraft.world.Container import net.minecraft.world.Container
import net.minecraft.world.entity.player.Inventory import net.minecraft.world.entity.player.Inventory
@ -21,140 +20,135 @@ class TeleportGui(
val container: Container, val container: Container,
val viewer: ServerPlayer val viewer: ServerPlayer
) : AbstractContainerMenu(MenuType.GENERIC_9x3, syncId) { ) : AbstractContainerMenu(MenuType.GENERIC_9x3, syncId) {
companion object { companion object {
const val CONTAINER_COLUMNS = 9 const val CONTAINER_COLUMNS = 9
const val CONTAINER_ROWS = 3 const val CONTAINER_ROWS = 3
const val CONTAINER_SIZE = CONTAINER_COLUMNS * CONTAINER_ROWS const val CONTAINER_SIZE = CONTAINER_COLUMNS * CONTAINER_ROWS
const val SLOT_SIZE = 18 const val SLOT_SIZE = 18
const val LEFT_PADDING = 8 const val LEFT_PADDING = 8
const val TOP_PADDING = 18 const val TOP_PADDING = 18
const val PLAYER_INV_TOP = 86 const val PLAYER_INV_TOP = 86
const val HOTBAR_Y = 144 const val HOTBAR_Y = 144
}
private val targetPlayers =
viewer.server
.playerList
.players
.filterIsInstance<ServerPlayer>()
.filter { it.uuid != viewer.uuid }
.map { it.uuid }
init {
for (i in 0 until CONTAINER_SIZE) {
val x = LEFT_PADDING + (i % CONTAINER_COLUMNS) * SLOT_SIZE
val y = TOP_PADDING + (i / CONTAINER_COLUMNS) * SLOT_SIZE
addSlot(
object : Slot(container, i, x, y) {
override fun mayPickup(
player: net.minecraft.world.entity.player.Player
): Boolean = false
override fun mayPlace(stack: ItemStack): Boolean = false
}
)
} }
for (row in 0 until 3) { private val targetPlayers =
for (col in 0 until 9) { viewer.server
val index = col + row * 9 + 9 .playerList
val x = LEFT_PADDING + col * SLOT_SIZE .players
val y = PLAYER_INV_TOP + row * SLOT_SIZE .filterIsInstance<ServerPlayer>()
addSlot(Slot(playerInv, index, x, y)) .filter { it.uuid != viewer.uuid }
} .map { it.uuid }
}
for (col in 0 until 9) { init {
val x = LEFT_PADDING + col * SLOT_SIZE for (i in 0 until CONTAINER_SIZE) {
val y = HOTBAR_Y val x = LEFT_PADDING + (i % CONTAINER_COLUMNS) * SLOT_SIZE
addSlot(Slot(playerInv, col, x, y)) val y = TOP_PADDING + (i / CONTAINER_COLUMNS) * SLOT_SIZE
} addSlot(
} object : Slot(container, i, x, y) {
override fun mayPickup(
override fun clicked( player: net.minecraft.world.entity.player.Player
slotId: Int, ): Boolean = false
button: Int, override fun mayPlace(stack: ItemStack): Boolean = false
clickType: ClickType, }
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) }
if (currentTargetPlayer != null && player.uuid != currentTargetPlayer.uuid) {
val prevPos = player.blockPosition()
val prevDimension = player.level().dimension().location().toString()
val prevBiome =
player.level()
.getBiome(prevPos)
.unwrapKey()
.map { it.location().toString() }
.orElse("minecraft:plains")
val previousLocation =
Location(
dimension = prevDimension,
biome = prevBiome,
x = prevPos.x.toDouble(),
y = prevPos.y.toDouble(),
z = prevPos.z.toDouble()
) )
PlayerConfig.recordLastLocation(player, previousLocation) }
val targetLevel = currentTargetPlayer.serverLevel() for (row in 0 until 3) {
for (col in 0 until 9) {
player.teleportTo( val index = col + row * 9 + 9
targetLevel, val x = LEFT_PADDING + col * SLOT_SIZE
currentTargetPlayer.x, val y = PLAYER_INV_TOP + row * SLOT_SIZE
currentTargetPlayer.y, addSlot(Slot(playerInv, index, x, y))
currentTargetPlayer.z,
currentTargetPlayer.yRot,
currentTargetPlayer.xRot
)
// 닉네임이 있으면 닉네임 사용
val targetName =
NicknameDataStore.getNickname(currentTargetPlayer.uuid)
?: currentTargetPlayer.gameProfile.name
val playerName =
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)
}
)
)
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() for (col in 0 until 9) {
return val x = LEFT_PADDING + col * SLOT_SIZE
val y = HOTBAR_Y
addSlot(Slot(playerInv, col, x, y))
}
} }
super.clicked(slotId, button, clickType, player)
}
override fun stillValid(player: net.minecraft.world.entity.player.Player): Boolean = true override fun clicked(
slotId: Int,
button: Int,
clickType: ClickType,
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)
}
override fun quickMoveStack( // 플레이어가 없으면 무시 (빈 공간 클릭)
player: net.minecraft.world.entity.player.Player, if (currentTargetPlayer == null) {
index: Int return
): ItemStack = ItemStack.EMPTY }
val prevPos = player.blockPosition()
val prevDimension = player.level().dimension().location().toString()
val prevBiome =
player.level()
.getBiome(prevPos)
.unwrapKey()
.map { it.location().toString() }
.orElse("minecraft:plains")
val previousLocation =
Location(
dimension = prevDimension,
biome = prevBiome,
x = prevPos.x.toDouble(),
y = prevPos.y.toDouble(),
z = prevPos.z.toDouble()
)
PlayerConfig.recordLastLocation(player, previousLocation)
val targetLevel = currentTargetPlayer.serverLevel()
player.teleportTo(
targetLevel,
currentTargetPlayer.x,
currentTargetPlayer.y,
currentTargetPlayer.z,
currentTargetPlayer.yRot,
currentTargetPlayer.xRot
)
// 소리 재생 (텔레포트 후)
playClickSound(player)
// 닉네임이 있으면 닉네임 사용
val targetName =
NicknameDataStore.getNickname(currentTargetPlayer.uuid)
?: currentTargetPlayer.gameProfile.name
val playerName =
NicknameDataStore.getNickname(player.uuid)
?: player.gameProfile.name
MessageUtils.sendSuccess(player, "{$targetName}님에게 텔레포트 했습니다.")
MessageUtils.sendInfo(
currentTargetPlayer,
"{$playerName}님이 당신에게 텔레포트 했습니다."
)
player.closeContainer()
return
}
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(
player: net.minecraft.world.entity.player.Player,
index: Int
): ItemStack = ItemStack.EMPTY
} }

View file

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