Initial commit: Essentials and ServerStatus mods
This commit is contained in:
commit
8f2c2c7941
71 changed files with 5292 additions and 0 deletions
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Gradle
|
||||
.gradle/
|
||||
build/
|
||||
bin/
|
||||
run/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.eclipse/
|
||||
.vscode/
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.kotlin/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
66
Essentials/README.md
Normal file
66
Essentials/README.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# ⚙️ Essentials
|
||||
|
||||
마인크래프트 서버 필수 기능을 제공하는 NeoForge 모드입니다.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
## ✨ 주요 기능
|
||||
|
||||
- 📍 **좌표 관리** - GUI 기반 좌표 저장 및 텔레포트
|
||||
- 🏷️ **닉네임 시스템** - 플레이어 닉네임 설정
|
||||
- 🔧 **서버 유틸리티** - 다양한 관리 명령어
|
||||
|
||||
---
|
||||
|
||||
## 🎮 명령어
|
||||
|
||||
| 명령어 | 설명 |
|
||||
| ------------------ | -------------------- |
|
||||
| `/좌표` | 저장된 좌표 목록 GUI |
|
||||
| `/좌표추가 <이름>` | 현재 위치 저장 |
|
||||
| `/좌표삭제 <이름>` | 저장된 좌표 삭제 |
|
||||
| `/닉네임 <이름>` | 닉네임 설정 |
|
||||
| `/닉네임초기화` | 닉네임 초기화 |
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 기술 스택
|
||||
|
||||
| 기술 | 설명 |
|
||||
| -------------------- | --------------------- |
|
||||
| **NeoForge** | Minecraft 모딩 플랫폼 |
|
||||
| **Kotlin** | 주 개발 언어 |
|
||||
| **Kotlin for Forge** | NeoForge Kotlin 지원 |
|
||||
| **Java 21** | JVM 버전 |
|
||||
|
||||
---
|
||||
|
||||
## 📁 구조
|
||||
|
||||
```
|
||||
Essentials/
|
||||
├── src/main/
|
||||
│ ├── kotlin/com/beemer/essentials/
|
||||
│ │ ├── command/ # 커맨드 핸들러
|
||||
│ │ ├── config/ # 설정 관리
|
||||
│ │ ├── data/ # 데이터 저장
|
||||
│ │ ├── gui/ # GUI 메뉴
|
||||
│ │ └── util/ # 유틸리티
|
||||
│ └── resources/
|
||||
│ └── META-INF/ # 모드 메타데이터
|
||||
└── build.gradle
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 빌드
|
||||
|
||||
```bash
|
||||
./gradlew build
|
||||
```
|
||||
|
||||
빌드된 JAR: `build/libs/essentials-1.0.0.jar`
|
||||
176
Essentials/build.gradle
Normal file
176
Essentials/build.gradle
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
plugins {
|
||||
id 'java-library'
|
||||
id 'maven-publish'
|
||||
id 'idea'
|
||||
id 'net.neoforged.moddev' version '2.0.80'
|
||||
id 'org.jetbrains.kotlin.jvm' version '2.0.0'
|
||||
}
|
||||
|
||||
version = mod_version
|
||||
group = mod_group_id
|
||||
|
||||
repositories {
|
||||
mavenLocal()
|
||||
maven {
|
||||
name = 'Kotlin for Forge'
|
||||
url = 'https://thedarkcolour.github.io/KotlinForForge/'
|
||||
content { includeGroup "thedarkcolour" }
|
||||
}
|
||||
}
|
||||
|
||||
base {
|
||||
archivesName = mod_id
|
||||
}
|
||||
|
||||
java.toolchain.languageVersion = JavaLanguageVersion.of(21)
|
||||
kotlin.jvmToolchain(21)
|
||||
|
||||
neoForge {
|
||||
// Specify the version of NeoForge to use.
|
||||
version = project.neo_version
|
||||
|
||||
parchment {
|
||||
mappingsVersion = project.parchment_mappings_version
|
||||
minecraftVersion = project.parchment_minecraft_version
|
||||
}
|
||||
|
||||
// This line is optional. Access Transformers are automatically detected
|
||||
// accessTransformers.add('src/main/resources/META-INF/accesstransformer.cfg')
|
||||
|
||||
// Default run configurations.
|
||||
// These can be tweaked, removed, or duplicated as needed.
|
||||
runs {
|
||||
client {
|
||||
client()
|
||||
|
||||
// Comma-separated list of namespaces to load gametests from. Empty = all namespaces.
|
||||
systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id
|
||||
}
|
||||
|
||||
server {
|
||||
server()
|
||||
programArgument '--nogui'
|
||||
systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id
|
||||
}
|
||||
|
||||
// This run config launches GameTestServer and runs all registered gametests, then exits.
|
||||
// By default, the server will crash when no gametests are provided.
|
||||
// The gametest system is also enabled by default for other run configs under the /test command.
|
||||
gameTestServer {
|
||||
type = "gameTestServer"
|
||||
systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id
|
||||
}
|
||||
|
||||
data {
|
||||
data()
|
||||
|
||||
// example of overriding the workingDirectory set in configureEach above, uncomment if you want to use it
|
||||
// gameDirectory = project.file('run-data')
|
||||
|
||||
// Specify the modid for data generation, where to output the resulting resource, and where to look for existing resources.
|
||||
programArguments.addAll '--mod', project.mod_id, '--all', '--output', file('src/generated/resources/').getAbsolutePath(), '--existing', file('src/main/resources/').getAbsolutePath()
|
||||
}
|
||||
|
||||
// applies to all the run configs above
|
||||
configureEach {
|
||||
// Recommended logging data for a userdev environment
|
||||
// The markers can be added/remove as needed separated by commas.
|
||||
// "SCAN": For mods scan.
|
||||
// "REGISTRIES": For firing of registry events.
|
||||
// "REGISTRYDUMP": For getting the contents of all registries.
|
||||
systemProperty 'forge.logging.markers', 'REGISTRIES'
|
||||
|
||||
// Recommended logging level for the console
|
||||
// You can set various levels here.
|
||||
// Please read: https://stackoverflow.com/questions/2031163/when-to-use-the-different-log-levels
|
||||
logLevel = org.slf4j.event.Level.DEBUG
|
||||
}
|
||||
}
|
||||
|
||||
mods {
|
||||
// define mod <-> source bindings
|
||||
// these are used to tell the game which sources are for which mod
|
||||
// mostly optional in a single mod project
|
||||
// but multi mod projects should define one per mod
|
||||
"${mod_id}" {
|
||||
sourceSet(sourceSets.main)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Include resources generated by data generators.
|
||||
sourceSets.main.resources { srcDir 'src/generated/resources' }
|
||||
|
||||
|
||||
dependencies {
|
||||
implementation 'thedarkcolour:kotlinforforge-neoforge:5.3.0'
|
||||
|
||||
// Example mod dependency with JEI
|
||||
// The JEI API is declared for compile time use, while the full JEI artifact is used at runtime
|
||||
// compileOnly "mezz.jei:jei-${mc_version}-common-api:${jei_version}"
|
||||
// compileOnly "mezz.jei:jei-${mc_version}-forge-api:${jei_version}"
|
||||
// runtimeOnly "mezz.jei:jei-${mc_version}-forge:${jei_version}"
|
||||
|
||||
// Example mod dependency using a mod jar from ./libs with a flat dir repository
|
||||
// This maps to ./libs/coolmod-${mc_version}-${coolmod_version}.jar
|
||||
// The group id is ignored when searching -- in this case, it is "blank"
|
||||
// implementation "blank:coolmod-${mc_version}:${coolmod_version}"
|
||||
|
||||
// Example mod dependency using a file as dependency
|
||||
// implementation files("libs/coolmod-${mc_version}-${coolmod_version}.jar")
|
||||
|
||||
// Example project dependency using a sister or child project:
|
||||
// implementation project(":myproject")
|
||||
|
||||
// For more info:
|
||||
// http://www.gradle.org/docs/current/userguide/artifact_dependencies_tutorial.html
|
||||
// http://www.gradle.org/docs/current/userguide/dependency_management.html
|
||||
}
|
||||
|
||||
// This block of code expands all declared replace properties in the specified resource targets.
|
||||
// A missing property will result in an error. Properties are expanded using ${} Groovy notation.
|
||||
var generateModMetadata = tasks.register("generateModMetadata", ProcessResources) {
|
||||
var replaceProperties = [minecraft_version : minecraft_version,
|
||||
minecraft_version_range: minecraft_version_range,
|
||||
neo_version : neo_version,
|
||||
neo_version_range : neo_version_range,
|
||||
loader_version_range : loader_version_range,
|
||||
mod_id : mod_id,
|
||||
mod_name : mod_name,
|
||||
mod_license : mod_license,
|
||||
mod_version : mod_version,
|
||||
mod_authors : mod_authors,
|
||||
mod_description : mod_description]
|
||||
inputs.properties replaceProperties
|
||||
expand replaceProperties
|
||||
from "src/main/templates"
|
||||
into "build/generated/sources/modMetadata"
|
||||
}
|
||||
|
||||
// Include the output of "generateModMetadata" as an input directory for the build
|
||||
// this works with both building through Gradle and the IDE.
|
||||
sourceSets.main.resources.srcDir generateModMetadata
|
||||
// To avoid having to run "generateModMetadata" manually, make it run on every project reload
|
||||
neoForge.ideSyncTask generateModMetadata
|
||||
|
||||
// Example configuration to allow publishing using the maven-publish plugin
|
||||
publishing {
|
||||
publications {
|
||||
register('mavenJava', MavenPublication) {
|
||||
from components.java
|
||||
}
|
||||
}
|
||||
repositories {
|
||||
maven {
|
||||
url "file://${project.projectDir}/repo"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IDEA no longer automatically downloads sources/javadoc jars for dependencies, so we need to explicitly enable the behavior.
|
||||
idea {
|
||||
module {
|
||||
downloadSources = true
|
||||
downloadJavadoc = true
|
||||
}
|
||||
}
|
||||
40
Essentials/gradle.properties
Normal file
40
Essentials/gradle.properties
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Sets default memory used for gradle commands. Can be overridden by user or command line properties.
|
||||
org.gradle.jvmargs=-Xmx2G
|
||||
org.gradle.daemon=true
|
||||
org.gradle.parallel=true
|
||||
org.gradle.caching=true
|
||||
org.gradle.configuration-cache=true
|
||||
## Environment Properties
|
||||
# You can find the latest versions here: https://projects.neoforged.net/neoforged/neoforge
|
||||
# The Minecraft version must agree with the Neo version to get a valid artifact
|
||||
minecraft_version=1.21.1
|
||||
# The Minecraft version range can use any release version of Minecraft as bounds.
|
||||
# Snapshots, pre-releases, and release candidates are not guaranteed to sort properly
|
||||
# as they do not follow standard versioning conventions.
|
||||
minecraft_version_range=[1.21.1,1.22)
|
||||
# The Neo version must agree with the Minecraft version to get a valid artifact
|
||||
neo_version=21.1.194
|
||||
# The Neo version range can use any version of Neo as bounds
|
||||
neo_version_range=[21,)
|
||||
# The loader version range can only use the major version of FML as bounds
|
||||
loader_version_range=[5.3,)
|
||||
parchment_minecraft_version=1.21.1
|
||||
parchment_mappings_version=2024.11.17
|
||||
## Mod Properties
|
||||
# The unique mod identifier for the mod. Must be lowercase in English locale. Must fit the regex [a-z][a-z0-9_]{1,63}
|
||||
# Must match the String constant located in the main mod class annotated with @Mod.
|
||||
mod_id=essentials
|
||||
# The human-readable display name for the mod.
|
||||
mod_name=Essentials
|
||||
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
|
||||
mod_license=MIT
|
||||
# The mod version. See https://semver.org/
|
||||
mod_version=1.0.0
|
||||
# The group ID for the mod. It is only important when publishing as an artifact to a Maven repository.
|
||||
# This should match the base package used for the mod sources.
|
||||
# See https://maven.apache.org/guides/mini/guide-naming-conventions.html
|
||||
mod_group_id=com.beemer
|
||||
# The authors of the mod. This is a simple text string that is used for display purposes in the mod list.
|
||||
mod_authors=
|
||||
# The description of the mod. This is a simple multiline text string that is used for display purposes in the mod list.
|
||||
mod_description=
|
||||
BIN
Essentials/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
Essentials/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
Essentials/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
Essentials/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
249
Essentials/gradlew
vendored
Executable file
249
Essentials/gradlew
vendored
Executable file
|
|
@ -0,0 +1,249 @@
|
|||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
92
Essentials/gradlew.bat
vendored
Normal file
92
Essentials/gradlew.bat
vendored
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
11
Essentials/settings.gradle
Normal file
11
Essentials/settings.gradle
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
pluginManagement {
|
||||
repositories {
|
||||
mavenLocal()
|
||||
gradlePluginPortal()
|
||||
maven { url = 'https://maven.neoforged.net/releases' }
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.8.0'
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package com.beemer.essentials.mixin;
|
||||
|
||||
import com.beemer.essentials.config.AntimobConfig;
|
||||
import net.minecraft.world.entity.EntityType;
|
||||
import net.minecraft.world.entity.PowerableMob;
|
||||
import net.minecraft.world.entity.monster.Creeper;
|
||||
import net.minecraft.world.entity.monster.Monster;
|
||||
import net.minecraft.world.level.Level;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.ModifyArg;
|
||||
|
||||
@Mixin(Creeper.class)
|
||||
public abstract class CreeperMixin extends Monster implements PowerableMob {
|
||||
|
||||
protected CreeperMixin(EntityType<? extends Monster> entityType, Level level) {
|
||||
super(entityType, level);
|
||||
}
|
||||
|
||||
@ModifyArg(
|
||||
method = "explodeCreeper",
|
||||
at = @At(
|
||||
value = "INVOKE",
|
||||
target = "Lnet/minecraft/world/level/Level;explode(Lnet/minecraft/world/entity/Entity;DDDFLnet/minecraft/world/level/Level$ExplosionInteraction;)Lnet/minecraft/world/level/Explosion;"
|
||||
),
|
||||
index = 5
|
||||
)
|
||||
private Level.ExplosionInteraction replaceInteraction(Level.ExplosionInteraction original) {
|
||||
if (AntimobConfig.get("크리퍼")) {
|
||||
return Level.ExplosionInteraction.NONE;
|
||||
}
|
||||
return original;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package com.beemer.essentials.mixin;
|
||||
|
||||
import com.beemer.essentials.config.AntimobConfig;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
|
||||
@Mixin(targets = "net.minecraft.world.entity.monster.EnderMan$EndermanTakeBlockGoal")
|
||||
public abstract class EndermanTakeBlockGoalMixin {
|
||||
|
||||
@Inject(method = "canUse", at = @At("HEAD"), cancellable = true)
|
||||
private void onCanUse(CallbackInfoReturnable<Boolean> cir) {
|
||||
if (AntimobConfig.get("엔더맨")) {
|
||||
cir.setReturnValue(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package com.beemer.essentials.mixin;
|
||||
|
||||
import com.beemer.essentials.config.ProtectFarmlandConfig;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.block.FarmBlock;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.neoforged.neoforge.common.CommonHooks;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Redirect;
|
||||
|
||||
@Mixin(FarmBlock.class)
|
||||
public class FarmBlockMixin {
|
||||
|
||||
@Redirect(method = "fallOn", at = @At(value = "INVOKE", target = "Lnet/neoforged/neoforge/common/CommonHooks;onFarmlandTrample(Lnet/minecraft/world/level/Level;Lnet/minecraft/core/BlockPos;Lnet/minecraft/world/level/block/state/BlockState;FLnet/minecraft/world/entity/Entity;)Z"))
|
||||
private boolean redirectOnFarmlandTrample(Level level, BlockPos pos, BlockState dirtState, float fallDistance, Entity entity) {
|
||||
if (ProtectFarmlandConfig.isEnabled()) {
|
||||
return false;
|
||||
}
|
||||
return CommonHooks.onFarmlandTrample(level, pos, dirtState, fallDistance, entity);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package com.beemer.essentials.mixin;
|
||||
|
||||
import com.beemer.essentials.config.AntimobConfig;
|
||||
import net.minecraft.world.entity.projectile.LargeFireball;
|
||||
import net.minecraft.world.level.Level;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.ModifyArg;
|
||||
|
||||
@Mixin(LargeFireball.class)
|
||||
public abstract class LargeFireballMixin {
|
||||
|
||||
@ModifyArg(
|
||||
method = "onHit(Lnet/minecraft/world/phys/HitResult;)V",
|
||||
at = @At(
|
||||
value = "INVOKE",
|
||||
target = "Lnet/minecraft/world/level/Level;explode(Lnet/minecraft/world/entity/Entity;DDDFZLnet/minecraft/world/level/Level$ExplosionInteraction;)Lnet/minecraft/world/level/Explosion;"
|
||||
),
|
||||
index = 5
|
||||
)
|
||||
private boolean disableFire(boolean original) {
|
||||
if (AntimobConfig.get("가스트")) {
|
||||
return false;
|
||||
}
|
||||
return original;
|
||||
}
|
||||
|
||||
@ModifyArg(
|
||||
method = "onHit(Lnet/minecraft/world/phys/HitResult;)V",
|
||||
at = @At(
|
||||
value = "INVOKE",
|
||||
target = "Lnet/minecraft/world/level/Level;explode(Lnet/minecraft/world/entity/Entity;DDDFZLnet/minecraft/world/level/Level$ExplosionInteraction;)Lnet/minecraft/world/level/Explosion;"
|
||||
),
|
||||
index = 6
|
||||
)
|
||||
private Level.ExplosionInteraction preventBlockBreak(Level.ExplosionInteraction original) {
|
||||
if (AntimobConfig.get("가스트")) {
|
||||
return Level.ExplosionInteraction.NONE;
|
||||
}
|
||||
return original;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
package com.beemer.essentials.mixin;
|
||||
|
||||
import com.beemer.essentials.nickname.NicknameDataStore;
|
||||
import com.mojang.authlib.GameProfile;
|
||||
import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket;
|
||||
import org.spongepowered.asm.mixin.Final;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.Mutable;
|
||||
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;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* ClientboundPlayerInfoUpdatePacket 믹스인
|
||||
* 패킷 전송 시 GameProfile 이름을 커스텀 닉네임으로 변경
|
||||
*/
|
||||
@Mixin(ClientboundPlayerInfoUpdatePacket.class)
|
||||
public class PlayerInfoPacketMixin {
|
||||
|
||||
@Shadow
|
||||
@Final
|
||||
@Mutable
|
||||
private List<ClientboundPlayerInfoUpdatePacket.Entry> entries;
|
||||
|
||||
/**
|
||||
* write 메서드 전에 entries 수정
|
||||
*/
|
||||
@Inject(method = "write", at = @At("HEAD"))
|
||||
private void onWrite(CallbackInfo ci) {
|
||||
modifyEntries();
|
||||
}
|
||||
|
||||
/**
|
||||
* entries 리스트 수정
|
||||
*/
|
||||
private void modifyEntries() {
|
||||
if (this.entries == null || this.entries.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<ClientboundPlayerInfoUpdatePacket.Entry> modifiedEntries = new ArrayList<>();
|
||||
boolean modified = false;
|
||||
|
||||
for (ClientboundPlayerInfoUpdatePacket.Entry entry : this.entries) {
|
||||
UUID playerUuid = entry.profileId();
|
||||
String customNickname = NicknameDataStore.getNickname(playerUuid);
|
||||
|
||||
if (customNickname != null && entry.profile() != null) {
|
||||
// 새 GameProfile 생성 (UUID 유지, 이름만 변경)
|
||||
GameProfile originalProfile = entry.profile();
|
||||
GameProfile newProfile = new GameProfile(playerUuid, customNickname);
|
||||
|
||||
// 스킨 텍스처 복사
|
||||
newProfile.getProperties().putAll(originalProfile.getProperties());
|
||||
|
||||
// 새 Entry 생성
|
||||
ClientboundPlayerInfoUpdatePacket.Entry newEntry = new ClientboundPlayerInfoUpdatePacket.Entry(
|
||||
playerUuid,
|
||||
newProfile,
|
||||
entry.listed(),
|
||||
entry.latency(),
|
||||
entry.gameMode(),
|
||||
entry.displayName(),
|
||||
entry.chatSession());
|
||||
modifiedEntries.add(newEntry);
|
||||
modified = true;
|
||||
} else {
|
||||
modifiedEntries.add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
this.entries = modifiedEntries;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package com.beemer.essentials.mixin;
|
||||
|
||||
import net.minecraft.network.Connection;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.server.network.CommonListenerCookie;
|
||||
import net.minecraft.server.players.PlayerList;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
@Mixin(PlayerList.class)
|
||||
public class PlayerListMixin {
|
||||
|
||||
@Inject(method = "broadcastSystemMessage(Lnet/minecraft/network/chat/Component;Z)V", at = @At("HEAD"), cancellable = true)
|
||||
private void suppressJoinQuit(Component message, boolean overlay, CallbackInfo ci) {
|
||||
String exceptMessage = message.getString();
|
||||
|
||||
if (exceptMessage.contains("joined the game") || exceptMessage.contains("left the game")) {
|
||||
ci.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@Inject(method = "placeNewPlayer", at = @At("TAIL"))
|
||||
private void onPlaceNewPlayer(Connection connection, ServerPlayer player, CommonListenerCookie cookie, CallbackInfo ci) {
|
||||
player.refreshDisplayName();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package com.beemer.essentials.mixin;
|
||||
|
||||
import com.beemer.essentials.nickname.NicknameDataStore;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.world.entity.EntityType;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.level.Level;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
|
||||
/**
|
||||
* Player 믹스인
|
||||
* getName()과 getDisplayName()을 후킹하여 커스텀 닉네임 반환
|
||||
*/
|
||||
@Mixin(Player.class)
|
||||
public abstract class PlayerMixin extends LivingEntity {
|
||||
protected PlayerMixin(EntityType<? extends LivingEntity> entityType, Level level) {
|
||||
super(entityType, level);
|
||||
}
|
||||
|
||||
@Inject(at = @At("HEAD"), method = "getName", cancellable = true)
|
||||
private void onGetName(CallbackInfoReturnable<Component> cir) {
|
||||
if (!this.level().isClientSide()) {
|
||||
String nickname = NicknameDataStore.getNickname(this.getUUID());
|
||||
if (nickname != null && !nickname.isEmpty()) {
|
||||
cir.setReturnValue(Component.literal(nickname));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Inject(at = @At("HEAD"), method = "getDisplayName", cancellable = true)
|
||||
private void onGetDisplayName(CallbackInfoReturnable<Component> cir) {
|
||||
if (!this.level().isClientSide()) {
|
||||
String nickname = NicknameDataStore.getNickname(this.getUUID());
|
||||
if (nickname != null && !nickname.isEmpty()) {
|
||||
cir.setReturnValue(Component.literal(nickname));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.beemer.essentials.mixin;
|
||||
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
@Mixin(ServerPlayer.class)
|
||||
public class ServerPlayerMixin {
|
||||
@Inject(method = "restoreFrom(Lnet/minecraft/server/level/ServerPlayer;Z)V", at = @At("TAIL"))
|
||||
private void onRestoreFrom(ServerPlayer oldPlayer, boolean keepEverything, CallbackInfo ci) {
|
||||
|
||||
// 이 Mixin이 주입된 'this' 객체가 바로 리스폰/차원 이동으로
|
||||
// 생성된 '새로운' ServerPlayer 객체입니다.
|
||||
ServerPlayer newPlayer = (ServerPlayer) (Object) this;
|
||||
|
||||
// 이전 플레이어의 데이터가 모두 복사된 직후(TAIL),
|
||||
// 닉네임을 새로고침합니다.
|
||||
newPlayer.refreshDisplayName();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package com.beemer.essentials
|
||||
|
||||
import com.beemer.essentials.command.*
|
||||
import com.beemer.essentials.event.ChatEvents
|
||||
import com.beemer.essentials.event.ModEvents
|
||||
import com.beemer.essentials.event.PlayerEvents
|
||||
import com.beemer.essentials.event.ServerEvents
|
||||
import com.beemer.essentials.nickname.NicknameCommand
|
||||
import net.neoforged.bus.api.IEventBus
|
||||
import net.neoforged.fml.common.Mod
|
||||
import net.neoforged.neoforge.common.NeoForge
|
||||
|
||||
@Mod(Essentials.ID)
|
||||
class Essentials(modEventBus: IEventBus) {
|
||||
companion object {
|
||||
const val ID = "essentials"
|
||||
}
|
||||
|
||||
init {
|
||||
modEventBus.addListener(ModEvents::onServerSetup)
|
||||
|
||||
NeoForge.EVENT_BUS.register(ServerEvents)
|
||||
NeoForge.EVENT_BUS.register(PlayerEvents)
|
||||
NeoForge.EVENT_BUS.register(ChatEvents)
|
||||
|
||||
NeoForge.EVENT_BUS.register(SpawnCommand)
|
||||
NeoForge.EVENT_BUS.register(PlayerCommand)
|
||||
NeoForge.EVENT_BUS.register(TeleportCommand)
|
||||
NeoForge.EVENT_BUS.register(ChatCommand)
|
||||
NeoForge.EVENT_BUS.register(ProtectFarmlandCommand)
|
||||
NeoForge.EVENT_BUS.register(AntimobCommand)
|
||||
NeoForge.EVENT_BUS.register(CoordinateCommand)
|
||||
NeoForge.EVENT_BUS.register(NicknameCommand)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
package com.beemer.essentials.command
|
||||
|
||||
import com.beemer.essentials.config.AntimobConfig
|
||||
import com.beemer.essentials.gui.AntimobGui
|
||||
import com.beemer.essentials.util.CommandUtils
|
||||
import net.minecraft.ChatFormatting
|
||||
import net.minecraft.commands.Commands
|
||||
import net.minecraft.network.chat.Component
|
||||
import net.minecraft.world.SimpleContainer
|
||||
import net.minecraft.world.SimpleMenuProvider
|
||||
import net.neoforged.bus.api.SubscribeEvent
|
||||
import net.neoforged.neoforge.event.RegisterCommandsEvent
|
||||
|
||||
object AntimobCommand {
|
||||
@SubscribeEvent
|
||||
fun onRegisterCommands(event: RegisterCommandsEvent) {
|
||||
listOf("antimob", "안티몹").forEach { command ->
|
||||
event.dispatcher.register(
|
||||
Commands.literal(command).executes { context ->
|
||||
val player = CommandUtils.getPlayerOrSendFailure(context.source) ?: return@executes 0
|
||||
|
||||
AntimobConfig.loadConfig()
|
||||
|
||||
val container = SimpleContainer(9 * 2)
|
||||
|
||||
player.openMenu(
|
||||
SimpleMenuProvider({ windowId, inv, _ ->
|
||||
AntimobGui(
|
||||
windowId,
|
||||
inv,
|
||||
container,
|
||||
player
|
||||
)
|
||||
}, Component.literal("안티몹 설정").withStyle { it.withColor(ChatFormatting.DARK_GRAY) })
|
||||
)
|
||||
1
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
package com.beemer.essentials.command
|
||||
|
||||
import com.beemer.essentials.config.ChatConfig
|
||||
import com.beemer.essentials.util.ChatUtils
|
||||
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
|
||||
|
||||
object ChatCommand {
|
||||
@SubscribeEvent
|
||||
fun onRegisterCommands(event: RegisterCommandsEvent) {
|
||||
registerReloadCommand("chat", "reload", event)
|
||||
registerReloadCommand("채팅", "새로고침", event)
|
||||
|
||||
registerClearCommand("chat", "clear", event)
|
||||
registerClearCommand("채팅", "비우기", event)
|
||||
}
|
||||
|
||||
private fun getPlayerOrNull(source: CommandSourceStack): ServerPlayer? {
|
||||
return try {
|
||||
source.player
|
||||
} catch (_: CommandSyntaxException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasPermissionOrSend(player: ServerPlayer?, requiredLevel: Int = 2): Boolean {
|
||||
if (player != null && !player.hasPermissions(requiredLevel)) {
|
||||
player.sendSystemMessage(
|
||||
Component.literal("해당 명령어를 실행할 권한이 없습니다.")
|
||||
.withStyle { it.withColor(ChatFormatting.RED) }
|
||||
)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun registerReloadCommand(root: String, sub: String, event: RegisterCommandsEvent) {
|
||||
event.dispatcher.register(
|
||||
Commands.literal(root).then(
|
||||
Commands.literal(sub).executes { context ->
|
||||
val player = getPlayerOrNull(context.source)
|
||||
if (!hasPermissionOrSend(player)) return@executes 0
|
||||
|
||||
ChatConfig.loadConfig()
|
||||
|
||||
val success = Component.literal("채팅 형식을 새로고침했습니다.")
|
||||
.withStyle { it.withColor(ChatFormatting.GOLD) }
|
||||
|
||||
if (player == null)
|
||||
context.source.sendSuccess({ success }, false)
|
||||
else
|
||||
player.sendSystemMessage(success)
|
||||
|
||||
1
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun registerClearCommand(root: String, sub: String, event: RegisterCommandsEvent) {
|
||||
event.dispatcher.register(
|
||||
Commands.literal(root).then(
|
||||
Commands.literal(sub).executes { context ->
|
||||
val player = getPlayerOrNull(context.source)
|
||||
if (!hasPermissionOrSend(player)) return@executes 0
|
||||
|
||||
val server = context.source.server
|
||||
val allPlayers = server.playerList.players.filterIsInstance<ServerPlayer>()
|
||||
ChatUtils.clearChatForAll(allPlayers)
|
||||
|
||||
server.playerList.broadcastSystemMessage(
|
||||
Component.literal("📢")
|
||||
.append(Component.literal("\u00A0\u00A0채팅창을 비웠습니다.")
|
||||
.withStyle { it.withColor(ChatFormatting.AQUA) }
|
||||
),
|
||||
false
|
||||
)
|
||||
|
||||
1
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
package com.beemer.essentials.command
|
||||
|
||||
import com.beemer.essentials.config.CoordinateConfig
|
||||
import com.beemer.essentials.config.PlayerConfig
|
||||
import com.beemer.essentials.data.Coordinate
|
||||
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.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.minecraft.world.SimpleMenuProvider
|
||||
import net.neoforged.bus.api.SubscribeEvent
|
||||
import net.neoforged.neoforge.event.RegisterCommandsEvent
|
||||
|
||||
object CoordinateCommand {
|
||||
@SubscribeEvent
|
||||
fun onRegisterCommands(event: RegisterCommandsEvent) {
|
||||
// /좌표 - GUI 열기 (페이지 0부터 시작)
|
||||
event.dispatcher.register(
|
||||
Commands.literal("좌표").executes { context ->
|
||||
val player = context.source.playerOrException as ServerPlayer
|
||||
openCoordinateGui(player, 0)
|
||||
1
|
||||
}
|
||||
)
|
||||
|
||||
// /좌표이동 <장소> - 바로 이동
|
||||
event.dispatcher.register(
|
||||
Commands.literal("좌표이동")
|
||||
.then(
|
||||
Commands.argument("장소", StringArgumentType.greedyString())
|
||||
.suggests { _, builder ->
|
||||
CoordinateConfig.getCoordinates().forEach {
|
||||
builder.suggest(it.name)
|
||||
}
|
||||
builder.buildFuture()
|
||||
}
|
||||
.executes { context ->
|
||||
val player =
|
||||
context.source.playerOrException as
|
||||
ServerPlayer
|
||||
val name =
|
||||
StringArgumentType.getString(
|
||||
context,
|
||||
"장소"
|
||||
)
|
||||
teleportToCoordinate(player, name)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
// /좌표추가 <이름>
|
||||
event.dispatcher.register(
|
||||
Commands.literal("좌표추가")
|
||||
.then(
|
||||
Commands.argument("이름", StringArgumentType.greedyString())
|
||||
.executes { context ->
|
||||
val player =
|
||||
context.source.entity as?
|
||||
ServerPlayer
|
||||
?: return@executes 0
|
||||
val name =
|
||||
StringArgumentType.getString(
|
||||
context,
|
||||
"이름"
|
||||
)
|
||||
|
||||
if (CoordinateConfig.isExist(name)) {
|
||||
player.sendSystemMessage(
|
||||
Component.literal(
|
||||
"$name 좌표가 이미 존재합니다."
|
||||
)
|
||||
.withStyle {
|
||||
it.withColor(
|
||||
ChatFormatting
|
||||
.RED
|
||||
)
|
||||
}
|
||||
)
|
||||
return@executes 0
|
||||
}
|
||||
|
||||
val exactPos = player.position()
|
||||
val blockPos = player.blockPosition()
|
||||
val level = player.level()
|
||||
|
||||
val coordinate =
|
||||
Coordinate(
|
||||
name = name,
|
||||
dimension =
|
||||
level.dimension()
|
||||
.location()
|
||||
.toString(),
|
||||
biome =
|
||||
level.getBiome(
|
||||
blockPos
|
||||
)
|
||||
.unwrapKey()
|
||||
.map {
|
||||
it.location()
|
||||
.toString()
|
||||
}
|
||||
.orElse(
|
||||
"알 수 없음"
|
||||
),
|
||||
x = exactPos.x,
|
||||
y = exactPos.y,
|
||||
z = exactPos.z,
|
||||
creatorUuid =
|
||||
player.uuid
|
||||
.toString()
|
||||
)
|
||||
|
||||
CoordinateConfig.addCoordinate(coordinate)
|
||||
player.sendSystemMessage(
|
||||
Component.literal(
|
||||
"$name 좌표를 추가했습니다."
|
||||
)
|
||||
.withStyle {
|
||||
it.withColor(
|
||||
ChatFormatting
|
||||
.GOLD
|
||||
)
|
||||
}
|
||||
)
|
||||
1
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
// /좌표제거 <이름>
|
||||
event.dispatcher.register(
|
||||
Commands.literal("좌표제거")
|
||||
.then(
|
||||
Commands.argument("이름", StringArgumentType.greedyString())
|
||||
.suggests { _, builder ->
|
||||
CoordinateConfig.getCoordinates().forEach {
|
||||
builder.suggest(it.name)
|
||||
}
|
||||
builder.buildFuture()
|
||||
}
|
||||
.executes { context ->
|
||||
val player =
|
||||
context.source.entity as?
|
||||
ServerPlayer
|
||||
?: return@executes 0
|
||||
val name =
|
||||
StringArgumentType.getString(
|
||||
context,
|
||||
"이름"
|
||||
)
|
||||
|
||||
if (!CoordinateConfig.isExist(name)) {
|
||||
player.sendSystemMessage(
|
||||
Component.literal(
|
||||
"$name 좌표가 존재하지 않습니다."
|
||||
)
|
||||
.withStyle {
|
||||
it.withColor(
|
||||
ChatFormatting
|
||||
.RED
|
||||
)
|
||||
}
|
||||
)
|
||||
return@executes 0
|
||||
}
|
||||
|
||||
CoordinateConfig.removeCoordinate(name)
|
||||
player.sendSystemMessage(
|
||||
Component.literal(
|
||||
"$name 좌표를 제거했습니다."
|
||||
)
|
||||
.withStyle {
|
||||
it.withColor(
|
||||
ChatFormatting
|
||||
.GOLD
|
||||
)
|
||||
}
|
||||
)
|
||||
1
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun openCoordinateGui(player: ServerPlayer, page: Int) {
|
||||
val coordinates = CoordinateConfig.getCoordinates()
|
||||
val totalPages =
|
||||
if (coordinates.isEmpty()) 1
|
||||
else (coordinates.size + Menu.ITEMS_PER_PAGE - 1) / Menu.ITEMS_PER_PAGE
|
||||
val container = createPageContainer(player, page)
|
||||
|
||||
player.openMenu(
|
||||
SimpleMenuProvider(
|
||||
{ windowId, inv, _ -> Menu(windowId, inv, container, page) },
|
||||
Component.literal("저장된 좌표 (${page + 1}/$totalPages)").withStyle {
|
||||
it.withBold(true)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
val level = DimensionUtils.getLevelById(player.server, coord.dimension)
|
||||
if (level == null) {
|
||||
player.sendSystemMessage(
|
||||
Component.literal("존재하지 않는 차원입니다.").withStyle {
|
||||
it.withColor(ChatFormatting.RED)
|
||||
}
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
// 이전 위치 저장
|
||||
val prevPos = player.position()
|
||||
PlayerConfig.recordLastLocation(
|
||||
player,
|
||||
Location(
|
||||
dimension = player.level().dimension().location().toString(),
|
||||
biome =
|
||||
player.level()
|
||||
.getBiome(player.blockPosition())
|
||||
.unwrapKey()
|
||||
.map { it.location().toString() }
|
||||
.orElse("minecraft:plains"),
|
||||
x = prevPos.x,
|
||||
y = prevPos.y,
|
||||
z = prevPos.z
|
||||
)
|
||||
)
|
||||
|
||||
player.teleportTo(level, coord.x, coord.y, coord.z, player.yRot, player.xRot)
|
||||
player.sendSystemMessage(
|
||||
Component.literal("$name (으)로 이동했습니다.").withStyle {
|
||||
it.withColor(ChatFormatting.GOLD)
|
||||
}
|
||||
)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
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 net.minecraft.commands.Commands
|
||||
import net.minecraft.network.chat.Component
|
||||
import net.neoforged.bus.api.SubscribeEvent
|
||||
import net.neoforged.neoforge.event.RegisterCommandsEvent
|
||||
|
||||
object PlayerCommand {
|
||||
@SubscribeEvent
|
||||
fun onRegisterCommands(event: RegisterCommandsEvent) {
|
||||
listOf("back", "백").forEach { command ->
|
||||
event.dispatcher.register(
|
||||
Commands.literal(command).executes { context ->
|
||||
val player =
|
||||
CommandUtils.getPlayerOrSendFailure(context.source)
|
||||
?: return@executes 0
|
||||
|
||||
val info = PlayerConfig.getPlayer(player)
|
||||
val lastLocation =
|
||||
info?.lastLocation
|
||||
?: run {
|
||||
player.sendSystemMessage(
|
||||
Component.literal(
|
||||
"이전 위치가 존재하지 않습니다."
|
||||
)
|
||||
.withStyle {
|
||||
it.withColor(
|
||||
ChatFormatting
|
||||
.RED
|
||||
)
|
||||
}
|
||||
)
|
||||
return@executes 0
|
||||
}
|
||||
|
||||
val level =
|
||||
DimensionUtils.getLevelById(
|
||||
player.server,
|
||||
lastLocation.dimension
|
||||
)
|
||||
?: run {
|
||||
player.sendSystemMessage(
|
||||
Component.literal(
|
||||
"존재하지 않는 차원입니다."
|
||||
)
|
||||
.withStyle {
|
||||
it.withColor(
|
||||
ChatFormatting
|
||||
.RED
|
||||
)
|
||||
}
|
||||
)
|
||||
return@executes 0
|
||||
}
|
||||
|
||||
player.teleportTo(
|
||||
level,
|
||||
lastLocation.x,
|
||||
lastLocation.y,
|
||||
lastLocation.z,
|
||||
player.yRot,
|
||||
player.xRot
|
||||
)
|
||||
player.sendSystemMessage(
|
||||
Component.literal("이전 위치로 이동했습니다.").withStyle {
|
||||
it.withColor(ChatFormatting.GOLD)
|
||||
}
|
||||
)
|
||||
1
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package com.beemer.essentials.command
|
||||
|
||||
import com.beemer.essentials.config.ProtectFarmlandConfig
|
||||
import com.beemer.essentials.util.CommandUtils
|
||||
import net.minecraft.ChatFormatting
|
||||
import net.minecraft.commands.Commands
|
||||
import net.minecraft.network.chat.Component
|
||||
import net.neoforged.bus.api.SubscribeEvent
|
||||
import net.neoforged.neoforge.event.RegisterCommandsEvent
|
||||
|
||||
object ProtectFarmlandCommand {
|
||||
@SubscribeEvent
|
||||
fun onRegisterCommands(event: RegisterCommandsEvent) {
|
||||
listOf("protectfarmland", "밭보호").forEach { command ->
|
||||
event.dispatcher.register(
|
||||
Commands.literal(command).executes { context ->
|
||||
val player = CommandUtils.getPlayerOrSendFailure(context.source) ?: return@executes 0
|
||||
|
||||
val enabled = ProtectFarmlandConfig.toggle()
|
||||
|
||||
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) })
|
||||
)
|
||||
1
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
package com.beemer.essentials.command
|
||||
|
||||
import com.beemer.essentials.config.PlayerConfig
|
||||
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 net.minecraft.commands.Commands
|
||||
import net.minecraft.network.chat.Component
|
||||
import net.neoforged.bus.api.SubscribeEvent
|
||||
import net.neoforged.neoforge.event.RegisterCommandsEvent
|
||||
|
||||
object SpawnCommand {
|
||||
@SubscribeEvent
|
||||
fun onRegisterCommands(event: RegisterCommandsEvent) {
|
||||
listOf("setspawn", "스폰설정").forEach { command ->
|
||||
event.dispatcher.register(
|
||||
Commands.literal(command).executes { context ->
|
||||
val player = CommandUtils.getPlayerOrSendFailure(context.source) ?: return@executes 0
|
||||
|
||||
val playerLocation = player.blockPosition()
|
||||
val dimensionId = player.level().dimension().location().toString()
|
||||
val biomeId = player.level().getBiome(playerLocation)
|
||||
.unwrapKey()
|
||||
.map { it.location().toString() }
|
||||
.orElse("minecraft:plains")
|
||||
|
||||
val location = Location(
|
||||
dimension = dimensionId,
|
||||
biome = biomeId,
|
||||
x = playerLocation.x.toDouble(),
|
||||
y = playerLocation.y.toDouble(),
|
||||
z = playerLocation.z.toDouble()
|
||||
)
|
||||
|
||||
SpawnConfig.setCustomSpawn(location)
|
||||
|
||||
player.sendSystemMessage(Component.literal("스폰 지점이 현재 위치로 설정되었습니다.").withStyle { it.withColor(ChatFormatting.GOLD) })
|
||||
1
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
listOf("spawn", "스폰", "넴주").forEach { command ->
|
||||
event.dispatcher.register(
|
||||
Commands.literal(command).executes { context ->
|
||||
val player = CommandUtils.getPlayerOrSendFailure(context.source) ?: return@executes 0
|
||||
|
||||
val target = SpawnConfig.getCustomSpawn() ?: SpawnConfig.getDefaultSpawn()
|
||||
|
||||
target?.let { t ->
|
||||
val level = DimensionUtils.getLevelById(player.server, t.dimension)
|
||||
|
||||
level?.let { l ->
|
||||
val currentPos = player.blockPosition()
|
||||
val currentDimension = player.level().dimension().location().toString()
|
||||
val currentBiome = player.level().getBiome(currentPos)
|
||||
.unwrapKey()
|
||||
.map { it.location().toString() }
|
||||
.orElse("minecraft:plains")
|
||||
|
||||
val currentLocation = Location(
|
||||
dimension = currentDimension,
|
||||
biome = currentBiome,
|
||||
x = currentPos.x.toDouble(),
|
||||
y = currentPos.y.toDouble(),
|
||||
z = currentPos.z.toDouble()
|
||||
)
|
||||
|
||||
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) })
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
package com.beemer.essentials.command
|
||||
|
||||
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.TranslationUtils
|
||||
import com.mojang.authlib.GameProfile
|
||||
import net.minecraft.ChatFormatting
|
||||
import net.minecraft.commands.Commands
|
||||
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.SimpleContainer
|
||||
import net.minecraft.world.SimpleMenuProvider
|
||||
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
|
||||
import net.minecraft.world.item.component.ResolvableProfile
|
||||
import net.neoforged.bus.api.SubscribeEvent
|
||||
import net.neoforged.neoforge.event.RegisterCommandsEvent
|
||||
|
||||
object TeleportCommand {
|
||||
@SubscribeEvent
|
||||
fun onRegisterCommands(event: RegisterCommandsEvent) {
|
||||
listOf("tpa", "텔레포트", "텔포").forEach { command ->
|
||||
event.dispatcher.register(
|
||||
Commands.literal(command).executes { context ->
|
||||
val player =
|
||||
CommandUtils.getPlayerOrSendFailure(context.source)
|
||||
?: return@executes 0
|
||||
|
||||
val container = SimpleContainer(9 * 3)
|
||||
|
||||
val targetPlayers =
|
||||
player.server.playerList.players.filter {
|
||||
it != player
|
||||
}
|
||||
|
||||
targetPlayers.take(CONTAINER_SIZE).forEachIndexed {
|
||||
idx,
|
||||
target ->
|
||||
val head = makePlayerHead(target)
|
||||
container.setItem(idx, head)
|
||||
}
|
||||
|
||||
player.openMenu(
|
||||
SimpleMenuProvider(
|
||||
{ windowId, inv, _ ->
|
||||
TeleportGui(
|
||||
windowId,
|
||||
inv,
|
||||
container,
|
||||
player
|
||||
)
|
||||
},
|
||||
Component.literal("텔레포트 대상 선택").withStyle {
|
||||
it.withColor(
|
||||
ChatFormatting.DARK_GRAY
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
1
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun makePlayerHead(player: ServerPlayer): ItemStack {
|
||||
val headItem = ItemStack(Items.PLAYER_HEAD)
|
||||
val nbtData = CompoundTag()
|
||||
|
||||
// 플레이어의 기존 프로필 사용 (fetchProfile 호출 제거로 속도 개선)
|
||||
val resolvableProfile =
|
||||
try {
|
||||
ResolvableProfile(player.gameProfile)
|
||||
} catch (e: NoSuchMethodError) {
|
||||
val ctor =
|
||||
ResolvableProfile::class.java.getDeclaredConstructor(
|
||||
GameProfile::class.java
|
||||
)
|
||||
ctor.isAccessible = true
|
||||
ctor.newInstance(player.gameProfile)
|
||||
}
|
||||
|
||||
val x = player.x.toInt()
|
||||
val y = player.y.toInt()
|
||||
val z = player.z.toInt()
|
||||
|
||||
val dimension = player.level().dimension().location().toString()
|
||||
val biome =
|
||||
player.level()
|
||||
.getBiome(player.blockPosition())
|
||||
.unwrapKey()
|
||||
.map { it.location().toString() }
|
||||
.orElse("minecraft:plains")
|
||||
|
||||
val loreList =
|
||||
listOf(
|
||||
Component.literal("디멘션: ")
|
||||
.withStyle { it.withColor(ChatFormatting.DARK_GREEN) }
|
||||
.append(
|
||||
Component.literal(
|
||||
TranslationUtils.translateDimension(
|
||||
dimension
|
||||
)
|
||||
)
|
||||
.withStyle {
|
||||
it.withColor(ChatFormatting.GRAY)
|
||||
}
|
||||
),
|
||||
Component.literal("바이옴: ")
|
||||
.withStyle { it.withColor(ChatFormatting.DARK_GREEN) }
|
||||
.append(
|
||||
Component.literal(
|
||||
TranslationUtils.translateBiome(
|
||||
biome
|
||||
)
|
||||
)
|
||||
.withStyle {
|
||||
it.withColor(ChatFormatting.GRAY)
|
||||
}
|
||||
),
|
||||
Component.literal("좌표: ")
|
||||
.withStyle { it.withColor(ChatFormatting.DARK_GREEN) }
|
||||
.append(
|
||||
Component.literal("$x, $y, $z").withStyle {
|
||||
it.withColor(ChatFormatting.GRAY)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
// 닉네임이 있으면 닉네임 사용
|
||||
val displayName =
|
||||
NicknameDataStore.getNickname(player.uuid) ?: player.gameProfile.name
|
||||
headItem.set(DataComponents.CUSTOM_NAME, Component.literal(displayName))
|
||||
headItem.set(DataComponents.LORE, ItemLore(loreList))
|
||||
headItem.set(DataComponents.CUSTOM_DATA, CustomData.of(nbtData))
|
||||
headItem.set(DataComponents.PROFILE, resolvableProfile)
|
||||
|
||||
return headItem
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
package com.beemer.essentials.config
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import net.neoforged.fml.loading.FMLPaths
|
||||
import org.apache.logging.log4j.LogManager
|
||||
import org.apache.logging.log4j.Logger
|
||||
import java.io.IOException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.StandardOpenOption
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
object AntimobConfig {
|
||||
private const val MOD_ID = "essentials"
|
||||
private val LOGGER: Logger = LogManager.getLogger(MOD_ID)
|
||||
|
||||
private val BASE_CONFIG_DIR: Path = FMLPaths.CONFIGDIR.get()
|
||||
private val CONFIG_DIR: Path = BASE_CONFIG_DIR.resolve(MOD_ID)
|
||||
private val FILE_DIR: Path = CONFIG_DIR.resolve("antimob.json")
|
||||
|
||||
private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
|
||||
|
||||
private val mobs = mapOf(
|
||||
"엔더맨" to false,
|
||||
"크리퍼" to false,
|
||||
"가스트" to false
|
||||
)
|
||||
|
||||
private val map: MutableMap<String, Boolean> = ConcurrentHashMap()
|
||||
|
||||
fun loadConfig() {
|
||||
try {
|
||||
Files.createDirectories(CONFIG_DIR)
|
||||
if (Files.exists(FILE_DIR)) {
|
||||
val json = Files.readString(FILE_DIR)
|
||||
val type = object : TypeToken<Map<String, Boolean>>() {}.type
|
||||
val parsed: Map<String, Boolean> = try {
|
||||
gson.fromJson(json, type) ?: emptyMap()
|
||||
} catch (e: Exception) {
|
||||
LOGGER.warn("[Essentials] antimob.json 파싱 실패, 기본값으로 초기화합니다.", e)
|
||||
emptyMap()
|
||||
}
|
||||
|
||||
map.clear()
|
||||
mobs.forEach { (k, v) ->
|
||||
map[k] = parsed[k] ?: v
|
||||
}
|
||||
} else {
|
||||
map.clear()
|
||||
map.putAll(mobs)
|
||||
saveConfig()
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
LOGGER.error("[Essentials] antimob 설정 불러오는 중 오류", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveConfig() {
|
||||
try {
|
||||
Files.createDirectories(CONFIG_DIR)
|
||||
val toWrite = LinkedHashMap<String, Boolean>()
|
||||
|
||||
mobs.keys.forEach { key ->
|
||||
toWrite[key] = map.getOrDefault(key, mobs[key] ?: false)
|
||||
}
|
||||
val json = gson.toJson(toWrite)
|
||||
Files.writeString(FILE_DIR, json)
|
||||
} catch (e: IOException) {
|
||||
LOGGER.error("[Essentials] antimob 설정 저장 실패", e)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun get(mob: String): Boolean = map.getOrDefault(mob, mobs[mob] ?: false)
|
||||
|
||||
fun toggle(mob: String): Boolean {
|
||||
val newState = !get(mob)
|
||||
map[mob] = newState
|
||||
saveConfig()
|
||||
return newState
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
package com.beemer.essentials.config
|
||||
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import net.neoforged.fml.loading.FMLPaths
|
||||
import org.apache.logging.log4j.LogManager
|
||||
import org.apache.logging.log4j.Logger
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
|
||||
object ChatConfig {
|
||||
private const val MOD_ID = "essentials"
|
||||
private val LOGGER: Logger = LogManager.getLogger(MOD_ID)
|
||||
|
||||
private val BASE_CONFIG_DIR: Path = FMLPaths.CONFIGDIR.get()
|
||||
private val CONFIG_DIR: Path = BASE_CONFIG_DIR.resolve(MOD_ID)
|
||||
private val FILE_DIR: Path = CONFIG_DIR.resolve("chat_format.json")
|
||||
|
||||
private val gson = GsonBuilder().setPrettyPrinting().create()
|
||||
|
||||
private val defaultFormats = mapOf(
|
||||
"first-time-join" to "&f[&eNEW&f]&r %name%",
|
||||
"join" to "&f[&a+&f]&r %name%",
|
||||
"quit" to "&f[&c-&f]&r %name%",
|
||||
"chat" to "%name% &7&l: &f%message%"
|
||||
)
|
||||
|
||||
private var chatFormats: MutableMap<String, String> = mutableMapOf()
|
||||
|
||||
fun loadConfig() {
|
||||
try {
|
||||
Files.createDirectories(CONFIG_DIR)
|
||||
|
||||
if (Files.exists(FILE_DIR)) {
|
||||
val json = Files.readString(FILE_DIR)
|
||||
val type = object : TypeToken<MutableMap<String, String>>() {}.type
|
||||
val loaded: MutableMap<String, String> = try {
|
||||
gson.fromJson(json, type)
|
||||
} catch (e: Exception) {
|
||||
LOGGER.error("[Essentials] 채팅 설정 파일을 읽는 중 오류가 발생했습니다. 기본값으로 초기화합니다.", e)
|
||||
mutableMapOf()
|
||||
}
|
||||
|
||||
chatFormats = defaultFormats.toMutableMap().apply {
|
||||
putAll(loaded)
|
||||
}
|
||||
} else {
|
||||
chatFormats = defaultFormats.toMutableMap()
|
||||
}
|
||||
|
||||
saveConfig()
|
||||
} catch (e: Exception) {
|
||||
LOGGER.error("[Essentials] 채팅 설정을 불러오는 중 오류가 발생했습니다.", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveConfig() {
|
||||
try {
|
||||
val jsonOut = gson.toJson(chatFormats)
|
||||
Files.writeString(FILE_DIR, jsonOut)
|
||||
} catch (e: Exception) {
|
||||
LOGGER.error("[Essentials] 채팅 설정을 저장하는 중 오류가 발생했습니다.", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun getFormat(key: String): String? = chatFormats[key]
|
||||
|
||||
fun setFormat(key: String, format: String) {
|
||||
chatFormats[key] = format
|
||||
saveConfig()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
package com.beemer.essentials.config
|
||||
|
||||
import com.beemer.essentials.data.Coordinate
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.exists
|
||||
import net.minecraft.nbt.CompoundTag
|
||||
import net.minecraft.nbt.ListTag
|
||||
import net.minecraft.nbt.NbtAccounter
|
||||
import net.minecraft.nbt.NbtIo
|
||||
import net.neoforged.fml.loading.FMLPaths
|
||||
import org.apache.logging.log4j.LogManager
|
||||
import org.apache.logging.log4j.Logger
|
||||
|
||||
object CoordinateConfig {
|
||||
private const val MOD_ID = "essentials"
|
||||
private val LOGGER: Logger = LogManager.getLogger(MOD_ID)
|
||||
|
||||
private val BASE_CONFIG_DIR: Path = FMLPaths.CONFIGDIR.get()
|
||||
private val CONFIG_DIR: Path = BASE_CONFIG_DIR.resolve(MOD_ID)
|
||||
private val FILE_DIR: Path = CONFIG_DIR.resolve("coordinates.dat")
|
||||
|
||||
private var coordinates = mutableListOf<Coordinate>()
|
||||
|
||||
fun saveConfig() {
|
||||
try {
|
||||
Files.createDirectories(CONFIG_DIR)
|
||||
|
||||
val compound = CompoundTag()
|
||||
val list = ListTag()
|
||||
|
||||
coordinates.forEach { coord ->
|
||||
val coordTag = CompoundTag()
|
||||
coordTag.putString("name", coord.name)
|
||||
coordTag.putString("dimension", coord.dimension)
|
||||
coordTag.putString("biome", coord.biome)
|
||||
coordTag.putDouble("x", coord.x)
|
||||
coordTag.putDouble("y", coord.y)
|
||||
coordTag.putDouble("z", coord.z)
|
||||
coord.creatorUuid?.let { coordTag.putString("creatorUuid", it) }
|
||||
list.add(coordTag)
|
||||
}
|
||||
|
||||
compound.put("Coordinates", list)
|
||||
|
||||
FILE_DIR.toFile().outputStream().use { fos -> NbtIo.writeCompressed(compound, fos) }
|
||||
} catch (e: Exception) {
|
||||
LOGGER.error("[Essentials] 좌표 데이터를 저장하는 중 오류가 발생했습니다.", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadConfig() {
|
||||
if (!CONFIG_DIR.exists()) {
|
||||
CONFIG_DIR.toFile().mkdirs()
|
||||
}
|
||||
|
||||
coordinates.clear()
|
||||
|
||||
if (FILE_DIR.exists()) {
|
||||
try {
|
||||
val compound =
|
||||
NbtIo.readCompressed(
|
||||
FILE_DIR.toFile().inputStream(),
|
||||
NbtAccounter.unlimitedHeap()
|
||||
)
|
||||
val list = compound.getList("Coordinates", 10)
|
||||
|
||||
list.forEach { tag ->
|
||||
if (tag is CompoundTag) {
|
||||
val name = tag.getString("name")
|
||||
val dimension = tag.getString("dimension")
|
||||
val biome = tag.getString("biome")
|
||||
|
||||
// 하위 호환: Int로 저장된 경우 Double로 변환
|
||||
val x =
|
||||
if (tag.contains("x", 6)) tag.getDouble("x")
|
||||
else tag.getInt("x").toDouble() + 0.5
|
||||
val y =
|
||||
if (tag.contains("y", 6)) tag.getDouble("y")
|
||||
else tag.getInt("y").toDouble()
|
||||
val z =
|
||||
if (tag.contains("z", 6)) tag.getDouble("z")
|
||||
else tag.getInt("z").toDouble() + 0.5
|
||||
|
||||
val creatorUuid =
|
||||
if (tag.contains("creatorUuid")) tag.getString("creatorUuid")
|
||||
else null
|
||||
|
||||
coordinates.add(Coordinate(name, dimension, biome, x, y, z, creatorUuid))
|
||||
}
|
||||
}
|
||||
|
||||
LOGGER.info("[Essentials] 총 ${coordinates.size}개의 좌표 데이터를 불러왔습니다.")
|
||||
} catch (e: Exception) {
|
||||
LOGGER.error("[Essentials] 좌표 데이터를 불러오지 못했습니다.", e)
|
||||
}
|
||||
} else {
|
||||
LOGGER.info("[Essentials] ${FILE_DIR} 파일이 존재하지 않아, 빈 파일을 생성합니다.")
|
||||
}
|
||||
}
|
||||
|
||||
fun addCoordinate(coord: Coordinate) {
|
||||
coordinates.add(coord)
|
||||
saveConfig()
|
||||
}
|
||||
|
||||
fun removeCoordinate(name: String) {
|
||||
coordinates.removeIf { it.name == name }
|
||||
saveConfig()
|
||||
}
|
||||
|
||||
fun getCoordinates(): List<Coordinate> {
|
||||
// 자연 정렬: 숫자 부분을 숫자로 비교
|
||||
return coordinates.sortedWith { a, b -> naturalCompare(a.name, b.name) }
|
||||
}
|
||||
|
||||
// 자연 정렬 비교 함수: "테스트1" < "테스트2" < "테스트10"
|
||||
private fun naturalCompare(str1: String, str2: String): Int {
|
||||
val regex = Regex("(\\d+)")
|
||||
val parts1 = regex.split(str1)
|
||||
val parts2 = regex.split(str2)
|
||||
val nums1 = regex.findAll(str1).map { it.value.toLong() }.toList()
|
||||
val nums2 = regex.findAll(str2).map { it.value.toLong() }.toList()
|
||||
|
||||
var i = 0
|
||||
var numIdx = 0
|
||||
while (i < parts1.size || i < parts2.size) {
|
||||
val p1 = parts1.getOrNull(i) ?: ""
|
||||
val p2 = parts2.getOrNull(i) ?: ""
|
||||
val cmp = p1.compareTo(p2)
|
||||
if (cmp != 0) return cmp
|
||||
|
||||
if (numIdx < nums1.size || numIdx < nums2.size) {
|
||||
val n1 = nums1.getOrNull(numIdx) ?: 0L
|
||||
val n2 = nums2.getOrNull(numIdx) ?: 0L
|
||||
if (n1 != n2) return n1.compareTo(n2)
|
||||
numIdx++
|
||||
}
|
||||
i++
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
fun getCoordinate(name: String): Coordinate? {
|
||||
return coordinates.find { it.name == name }
|
||||
}
|
||||
|
||||
fun isFull(): Boolean = false // 페이지 기능으로 제한 해제
|
||||
|
||||
fun isExist(name: String): Boolean = coordinates.any { it.name == name }
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
package com.beemer.essentials.config
|
||||
|
||||
import com.beemer.essentials.data.Location
|
||||
import com.beemer.essentials.data.Player
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.time.Duration
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeParseException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import net.minecraft.nbt.CompoundTag
|
||||
import net.minecraft.nbt.NbtAccounter
|
||||
import net.minecraft.nbt.NbtIo
|
||||
import net.minecraft.server.level.ServerPlayer
|
||||
import net.neoforged.fml.loading.FMLPaths
|
||||
import org.apache.logging.log4j.LogManager
|
||||
import org.apache.logging.log4j.Logger
|
||||
|
||||
object PlayerConfig {
|
||||
private const val MOD_ID = "essentials"
|
||||
private val LOGGER: Logger = LogManager.getLogger(MOD_ID)
|
||||
|
||||
private val BASE_CONFIG_DIR: Path = FMLPaths.CONFIGDIR.get()
|
||||
private val CONFIG_DIR: Path = BASE_CONFIG_DIR.resolve(MOD_ID)
|
||||
private val FILE_DIR: Path = CONFIG_DIR.resolve("players.dat")
|
||||
|
||||
private val players: MutableMap<String, Player> = ConcurrentHashMap()
|
||||
|
||||
fun loadConfig() {
|
||||
try {
|
||||
Files.createDirectories(CONFIG_DIR)
|
||||
players.clear()
|
||||
|
||||
if (Files.exists(FILE_DIR)) {
|
||||
val root: CompoundTag =
|
||||
FILE_DIR.toFile().inputStream().use { fis ->
|
||||
NbtIo.readCompressed(fis, NbtAccounter.unlimitedHeap())
|
||||
}
|
||||
|
||||
for (uuid in root.allKeys) {
|
||||
val tag = root.getCompound(uuid)
|
||||
val name = tag.getString("name")
|
||||
val firstJoin =
|
||||
try {
|
||||
LocalDateTime.parse(tag.getString("firstJoin"))
|
||||
} catch (_: DateTimeParseException) {
|
||||
LocalDateTime.now()
|
||||
}
|
||||
val lastJoin =
|
||||
try {
|
||||
LocalDateTime.parse(tag.getString("lastJoin"))
|
||||
} catch (_: DateTimeParseException) {
|
||||
firstJoin
|
||||
}
|
||||
val playTime = tag.getLong("playTime")
|
||||
val lastLocation =
|
||||
if (tag.contains("lastLocation")) {
|
||||
val loc = tag.getCompound("lastLocation")
|
||||
Location(
|
||||
dimension = loc.getString("dimension"),
|
||||
biome = loc.getString("biome"),
|
||||
x = loc.getDouble("x"),
|
||||
y = loc.getDouble("y"),
|
||||
z = loc.getDouble("z")
|
||||
)
|
||||
} else null
|
||||
|
||||
players[uuid] = Player(name, firstJoin, lastJoin, playTime, lastLocation)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LOGGER.error("[Essentials] 플레이어 데이터를 불러오는 중 오류가 발생했습니다.", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveConfig() {
|
||||
try {
|
||||
Files.createDirectories(CONFIG_DIR)
|
||||
val root = CompoundTag()
|
||||
|
||||
for ((uuid, info) in players) {
|
||||
val tag =
|
||||
CompoundTag().apply {
|
||||
putString("name", info.name)
|
||||
putString("firstJoin", info.firstJoin.toString())
|
||||
putString("lastJoin", info.lastJoin.toString())
|
||||
putLong("playTime", info.playTime)
|
||||
info.lastLocation?.let { loc ->
|
||||
val locTag =
|
||||
CompoundTag().apply {
|
||||
putString("dimension", loc.dimension)
|
||||
putString("biome", loc.biome)
|
||||
putDouble("x", loc.x)
|
||||
putDouble("y", loc.y)
|
||||
putDouble("z", loc.z)
|
||||
}
|
||||
put("lastLocation", locTag)
|
||||
}
|
||||
}
|
||||
root.put(uuid, tag)
|
||||
}
|
||||
|
||||
FILE_DIR.toFile().outputStream().use { fos -> NbtIo.writeCompressed(root, fos) }
|
||||
} catch (e: Exception) {
|
||||
LOGGER.error("[Essentials] 플레이어 데이터를 저장하는 중 오류가 발생했습니다.", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getOrCreate(uuid: String, currentName: String): Player {
|
||||
val now = LocalDateTime.now()
|
||||
return players.compute(uuid) { _, existing ->
|
||||
if (existing == null) {
|
||||
Player(
|
||||
name = currentName,
|
||||
firstJoin = now,
|
||||
lastJoin = now,
|
||||
playTime = 0,
|
||||
lastLocation = null
|
||||
)
|
||||
} else {
|
||||
if (existing.name != currentName) existing.name = currentName
|
||||
existing
|
||||
}
|
||||
}!!
|
||||
}
|
||||
|
||||
fun recordLogin(player: ServerPlayer) {
|
||||
val uuid = player.uuid.toString()
|
||||
val name = player.gameProfile.name
|
||||
val playerInfo = getOrCreate(uuid, name)
|
||||
playerInfo.lastJoin = LocalDateTime.now()
|
||||
saveConfig()
|
||||
}
|
||||
|
||||
fun recordLogout(player: ServerPlayer) {
|
||||
val uuid = player.uuid.toString()
|
||||
val now = LocalDateTime.now()
|
||||
val info = players[uuid] ?: return
|
||||
val delta = Duration.between(info.lastJoin, now).toMillis()
|
||||
if (delta > 0) {
|
||||
info.playTime += delta
|
||||
}
|
||||
info.lastJoin = now
|
||||
saveConfig()
|
||||
}
|
||||
|
||||
fun recordLastLocation(player: ServerPlayer, location: Location) {
|
||||
val uuid = player.uuid.toString()
|
||||
val info = players[uuid] ?: return
|
||||
info.lastLocation = location
|
||||
saveConfig()
|
||||
}
|
||||
|
||||
fun getPlayer(player: ServerPlayer): Player? {
|
||||
val uuid = player.uuid.toString()
|
||||
return players[uuid]
|
||||
}
|
||||
|
||||
fun getAccumulatedPlayTime(player: ServerPlayer): Long {
|
||||
val uuid = player.uuid.toString()
|
||||
val info = players[uuid] ?: return 0L
|
||||
val now = LocalDateTime.now()
|
||||
val extra = Duration.between(info.lastJoin, now).toMillis().coerceAtLeast(0)
|
||||
return info.playTime + extra
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
package com.beemer.essentials.config
|
||||
|
||||
import com.beemer.essentials.data.ProtectFarmland
|
||||
import net.minecraft.nbt.CompoundTag
|
||||
import net.minecraft.nbt.NbtIo
|
||||
import net.minecraft.nbt.NbtAccounter
|
||||
import net.neoforged.fml.loading.FMLPaths
|
||||
import org.apache.logging.log4j.LogManager
|
||||
import org.apache.logging.log4j.Logger
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
|
||||
object ProtectFarmlandConfig {
|
||||
private const val MOD_ID = "essentials"
|
||||
private val LOGGER: Logger = LogManager.getLogger(MOD_ID)
|
||||
|
||||
private val BASE_CONFIG_DIR: Path = FMLPaths.CONFIGDIR.get()
|
||||
private val CONFIG_DIR: Path = BASE_CONFIG_DIR.resolve(MOD_ID)
|
||||
private val FILE_DIR: Path = CONFIG_DIR.resolve("farmland_protection.dat")
|
||||
|
||||
private var state: ProtectFarmland = ProtectFarmland()
|
||||
|
||||
fun loadConfig() {
|
||||
try {
|
||||
Files.createDirectories(CONFIG_DIR)
|
||||
|
||||
if (Files.exists(FILE_DIR)) {
|
||||
val root: CompoundTag = FILE_DIR.toFile().inputStream().use { fis ->
|
||||
NbtIo.readCompressed(fis, NbtAccounter.unlimitedHeap())
|
||||
}
|
||||
val enabled = if (root.contains("protectFarmland")) {
|
||||
root.getBoolean("protectFarmland")
|
||||
} else {
|
||||
false
|
||||
}
|
||||
state = ProtectFarmland(protectFarmland = enabled)
|
||||
} else {
|
||||
saveConfig()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LOGGER.error("[Essentials] 밭 보호 설정을 불러오는 중 오류가 발생했습니다.", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveConfig() {
|
||||
try {
|
||||
Files.createDirectories(CONFIG_DIR)
|
||||
val root = CompoundTag().apply {
|
||||
putBoolean("protectFarmland", state.protectFarmland)
|
||||
}
|
||||
FILE_DIR.toFile().outputStream().use { fos ->
|
||||
NbtIo.writeCompressed(root, fos)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LOGGER.error("[Essentials] 밭 보호 설정을 저장하는 중 오류가 발생했습니다.", e)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun isEnabled(): Boolean = state.protectFarmland
|
||||
|
||||
fun toggle(): Boolean {
|
||||
state = state.copy(protectFarmland = !state.protectFarmland)
|
||||
saveConfig()
|
||||
return state.protectFarmland
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
package com.beemer.essentials.config
|
||||
|
||||
import com.beemer.essentials.data.Location
|
||||
import net.minecraft.nbt.CompoundTag
|
||||
import net.minecraft.nbt.NbtAccounter
|
||||
import net.minecraft.nbt.NbtIo
|
||||
import net.neoforged.fml.loading.FMLPaths
|
||||
import org.apache.logging.log4j.LogManager
|
||||
import org.apache.logging.log4j.Logger
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
|
||||
object SpawnConfig {
|
||||
private const val MOD_ID = "essentials"
|
||||
private val LOGGER: Logger = LogManager.getLogger(MOD_ID)
|
||||
|
||||
private val BASE_CONFIG_DIR: Path = FMLPaths.CONFIGDIR.get()
|
||||
private val CONFIG_DIR: Path = BASE_CONFIG_DIR.resolve(MOD_ID)
|
||||
private val FILE_DIR: Path = CONFIG_DIR.resolve("spawn.dat")
|
||||
|
||||
private var location = mutableMapOf<String, Location>()
|
||||
|
||||
private const val DEFAULT_SPAWN_NAME = "DefaultSpawn"
|
||||
private const val CUSTOM_SPAWN_NAME = "CustomSpawn"
|
||||
|
||||
fun saveConfig() {
|
||||
try {
|
||||
Files.createDirectories(CONFIG_DIR)
|
||||
|
||||
val compound = CompoundTag()
|
||||
location.forEach { (name, loc) ->
|
||||
val entry = CompoundTag().apply {
|
||||
putString("dimension", loc.dimension)
|
||||
putString("biome", loc.biome)
|
||||
putDouble("x", loc.x)
|
||||
putDouble("y", loc.y)
|
||||
putDouble("z", loc.z)
|
||||
}
|
||||
compound.put(name, entry)
|
||||
}
|
||||
|
||||
FILE_DIR.toFile().outputStream().use { fos ->
|
||||
NbtIo.writeCompressed(compound, fos)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LOGGER.error("[Essentials] 스폰 데이터를 저장하는 중 오류가 발생했습니다.", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadConfig() {
|
||||
try {
|
||||
Files.createDirectories(CONFIG_DIR)
|
||||
location.clear()
|
||||
|
||||
if (Files.exists(FILE_DIR)) {
|
||||
val compound: CompoundTag = FILE_DIR.toFile().inputStream().use { fis ->
|
||||
NbtIo.readCompressed(fis, NbtAccounter.unlimitedHeap())
|
||||
}
|
||||
|
||||
compound.allKeys.forEach { name ->
|
||||
val entry = compound.getCompound(name)
|
||||
val location = Location(
|
||||
dimension = entry.getString("dimension"),
|
||||
biome = entry.getString("biome"),
|
||||
x = entry.getDouble("x"),
|
||||
y = entry.getDouble("y"),
|
||||
z = entry.getDouble("z")
|
||||
)
|
||||
SpawnConfig.location[name] = location
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LOGGER.error("[Essentials] 스폰 데이터를 불러오는 중 오류가 발생했습니다.", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun setDefaultSpawn(location: Location) = setSpawnLocation(DEFAULT_SPAWN_NAME, location)
|
||||
fun getDefaultSpawn(): Location? = getSpawnLocation(DEFAULT_SPAWN_NAME)
|
||||
|
||||
fun setCustomSpawn(location: Location) = setSpawnLocation(CUSTOM_SPAWN_NAME, location)
|
||||
fun getCustomSpawn(): Location? = getSpawnLocation(CUSTOM_SPAWN_NAME)
|
||||
|
||||
private fun setSpawnLocation(name: String, location: Location) {
|
||||
SpawnConfig.location[name] = location
|
||||
saveConfig()
|
||||
}
|
||||
|
||||
private fun getSpawnLocation(name: String): Location? = location[name]
|
||||
|
||||
fun removeCustomSpawn() {
|
||||
if (location.remove(CUSTOM_SPAWN_NAME) != null) {
|
||||
saveConfig()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.beemer.essentials.data
|
||||
|
||||
/**
|
||||
* 좌표 데이터 클래스
|
||||
* @param name 장소 이름
|
||||
* @param dimension 차원 ID
|
||||
* @param biome 바이옴 ID
|
||||
* @param x X 좌표 (정밀 위치)
|
||||
* @param y Y 좌표 (정밀 위치)
|
||||
* @param z Z 좌표 (정밀 위치)
|
||||
* @param creatorUuid 저장한 플레이어 UUID (닉네임 변경에도 대응)
|
||||
*/
|
||||
data class Coordinate(
|
||||
val name: String,
|
||||
val dimension: String,
|
||||
val biome: String,
|
||||
val x: Double,
|
||||
val y: Double,
|
||||
val z: Double,
|
||||
val creatorUuid: String? = null
|
||||
)
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package com.beemer.essentials.data
|
||||
|
||||
data class Location(
|
||||
val dimension: String,
|
||||
val biome: String,
|
||||
val x: Double,
|
||||
val y: Double,
|
||||
val z: Double
|
||||
)
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.beemer.essentials.data
|
||||
|
||||
import java.time.LocalDateTime
|
||||
|
||||
data class Player(
|
||||
var name: String,
|
||||
val firstJoin: LocalDateTime,
|
||||
var lastJoin: LocalDateTime,
|
||||
var playTime: Long,
|
||||
var lastLocation: Location? = null
|
||||
)
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.beemer.essentials.data
|
||||
|
||||
data class ProtectFarmland(
|
||||
val protectFarmland: Boolean = false
|
||||
)
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package com.beemer.essentials.event
|
||||
|
||||
import com.beemer.essentials.util.ChatUtils
|
||||
import net.minecraft.network.chat.Component
|
||||
import net.minecraft.server.level.ServerPlayer
|
||||
import net.neoforged.bus.api.SubscribeEvent
|
||||
import net.neoforged.neoforge.event.ServerChatEvent
|
||||
|
||||
object ChatEvents {
|
||||
@SubscribeEvent
|
||||
fun onServerChat(event: ServerChatEvent) {
|
||||
val player = event.player as ServerPlayer
|
||||
val raw = event.rawText
|
||||
|
||||
if (raw.startsWith("/"))
|
||||
return
|
||||
|
||||
if (ChatUtils.hasIllegalCharacter(raw)) {
|
||||
event.isCanceled = true
|
||||
player.sendSystemMessage(Component.literal("채팅에 허용되지 않는 문자가 포함되어 있습니다.")
|
||||
.withStyle { it.withColor(net.minecraft.ChatFormatting.RED) })
|
||||
return
|
||||
}
|
||||
|
||||
ChatUtils.broadcastChat(player, raw)
|
||||
|
||||
event.isCanceled = true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.beemer.essentials.event
|
||||
|
||||
import com.beemer.essentials.config.*
|
||||
import com.beemer.essentials.nickname.NicknameDataStore
|
||||
import com.beemer.essentials.util.TranslationUtils
|
||||
import net.neoforged.fml.event.lifecycle.FMLDedicatedServerSetupEvent
|
||||
|
||||
object ModEvents {
|
||||
fun onServerSetup(event: FMLDedicatedServerSetupEvent) {
|
||||
SpawnConfig.loadConfig()
|
||||
PlayerConfig.loadConfig()
|
||||
ProtectFarmlandConfig.loadConfig()
|
||||
ChatConfig.loadConfig()
|
||||
AntimobConfig.loadConfig()
|
||||
CoordinateConfig.loadConfig()
|
||||
NicknameDataStore.load()
|
||||
|
||||
TranslationUtils.loadTranslations()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
package com.beemer.essentials.event
|
||||
|
||||
import com.beemer.essentials.config.PlayerConfig
|
||||
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 net.minecraft.server.level.ServerPlayer
|
||||
import net.neoforged.bus.api.SubscribeEvent
|
||||
import net.neoforged.neoforge.event.entity.player.PlayerEvent
|
||||
|
||||
object PlayerEvents {
|
||||
@SubscribeEvent
|
||||
fun onPlayerRespawn(event: PlayerEvent.PlayerRespawnEvent) {
|
||||
val player = event.entity as? ServerPlayer ?: return
|
||||
val target = SpawnConfig.getCustomSpawn() ?: SpawnConfig.getDefaultSpawn() ?: return
|
||||
val level = DimensionUtils.getLevelById(player.server, target.dimension) ?: return
|
||||
|
||||
player.teleportTo(level, target.x, target.y, target.z, player.yRot, player.xRot)
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
fun onPlayerLoggedIn(event: PlayerEvent.PlayerLoggedInEvent) {
|
||||
val player = event.entity as? ServerPlayer ?: return
|
||||
|
||||
val existing = PlayerConfig.getPlayer(player)
|
||||
val isFirstJoin = existing == null
|
||||
|
||||
ChatUtils.broadcastJoin(player, isFirstJoin)
|
||||
|
||||
PlayerConfig.recordLogin(player)
|
||||
|
||||
if (isFirstJoin) {
|
||||
val spawn = SpawnConfig.getCustomSpawn() ?: SpawnConfig.getDefaultSpawn() ?: return
|
||||
val level = DimensionUtils.getLevelById(player.server, spawn.dimension) ?: return
|
||||
|
||||
player.teleportTo(level, spawn.x, spawn.y, spawn.z, player.yRot, player.xRot)
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
fun onPlayerLoggedOut(event: PlayerEvent.PlayerLoggedOutEvent) {
|
||||
val player = event.entity as? ServerPlayer ?: return
|
||||
|
||||
ChatUtils.broadcastQuit(player)
|
||||
|
||||
PlayerConfig.recordLogout(player)
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
fun onPlayerClone(event: PlayerEvent.Clone) {
|
||||
if (!event.isWasDeath) return
|
||||
val oldPlayer = event.original as? ServerPlayer ?: return
|
||||
|
||||
val pos = oldPlayer.blockPosition()
|
||||
val dimension = oldPlayer.level().dimension().location().toString()
|
||||
val biome = oldPlayer.level().getBiome(pos)
|
||||
.unwrapKey()
|
||||
.map { it.location().toString() }
|
||||
.orElse("minecraft:plains")
|
||||
|
||||
val lastLoc = Location(
|
||||
dimension = dimension,
|
||||
biome = biome,
|
||||
x = pos.x.toDouble(),
|
||||
y = pos.y.toDouble(),
|
||||
z = pos.z.toDouble()
|
||||
)
|
||||
|
||||
PlayerConfig.recordLastLocation(oldPlayer, lastLoc)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
package com.beemer.essentials.event
|
||||
|
||||
import com.beemer.essentials.config.PlayerConfig
|
||||
import com.beemer.essentials.config.SpawnConfig
|
||||
import com.beemer.essentials.data.Location
|
||||
import com.beemer.essentials.nickname.NicknameDataStore
|
||||
import net.minecraft.ChatFormatting
|
||||
import net.minecraft.core.BlockPos
|
||||
import net.minecraft.core.registries.Registries
|
||||
import net.minecraft.network.chat.Component
|
||||
import net.minecraft.resources.ResourceKey
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
import net.minecraft.server.level.ServerPlayer
|
||||
import net.neoforged.bus.api.SubscribeEvent
|
||||
import net.neoforged.neoforge.event.entity.player.PlayerEvent
|
||||
import net.neoforged.neoforge.event.server.ServerStartedEvent
|
||||
import net.neoforged.neoforge.event.server.ServerStoppingEvent
|
||||
|
||||
object ServerEvents {
|
||||
@SubscribeEvent
|
||||
fun onServerStarted(event: ServerStartedEvent) {
|
||||
if (SpawnConfig.getDefaultSpawn() != null) return
|
||||
|
||||
val server = event.server
|
||||
|
||||
val overworldRL =
|
||||
ResourceLocation.tryParse("minecraft:overworld")
|
||||
?: run {
|
||||
return
|
||||
}
|
||||
val overworldKey = ResourceKey.create(Registries.DIMENSION, overworldRL)
|
||||
val overworld = server.getLevel(overworldKey) ?: return
|
||||
|
||||
val spawnPos: BlockPos = overworld.sharedSpawnPos
|
||||
|
||||
val dimensionId = overworld.dimension().location().toString()
|
||||
val biomeId =
|
||||
overworld
|
||||
.getBiome(spawnPos)
|
||||
.unwrapKey()
|
||||
.map { it.location().toString() }
|
||||
.orElse("minecraft:plains")
|
||||
|
||||
val location =
|
||||
Location(
|
||||
dimension = dimensionId,
|
||||
biome = biomeId,
|
||||
x = spawnPos.x.toDouble(),
|
||||
y = spawnPos.y.toDouble(),
|
||||
z = spawnPos.z.toDouble()
|
||||
)
|
||||
|
||||
SpawnConfig.setDefaultSpawn(location)
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
fun onServerStopping(event: ServerStoppingEvent) {
|
||||
val server = event.server
|
||||
server.playerList.players.filterIsInstance<ServerPlayer>().forEach {
|
||||
PlayerConfig.recordLogout(it)
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
fun onPlayerLoggedIn(event: PlayerEvent.PlayerLoggedInEvent) {
|
||||
val player = event.entity
|
||||
player.refreshDisplayName()
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
fun onPlayerNameFormat(event: PlayerEvent.NameFormat) {
|
||||
val player = event.entity
|
||||
val nickname = NicknameDataStore.getNickname(player.uuid)
|
||||
|
||||
if (nickname != null && event.displayname.string == nickname) {
|
||||
event.displayname =
|
||||
Component.literal(nickname).withStyle { it.withColor(ChatFormatting.GOLD) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
package com.beemer.essentials.gui
|
||||
|
||||
import com.beemer.essentials.config.AntimobConfig
|
||||
import com.mojang.authlib.GameProfile
|
||||
import net.minecraft.ChatFormatting
|
||||
import net.minecraft.core.component.DataComponents
|
||||
import net.minecraft.network.chat.Component
|
||||
import net.minecraft.server.level.ServerPlayer
|
||||
import net.minecraft.world.Container
|
||||
import net.minecraft.world.entity.player.Inventory
|
||||
import net.minecraft.world.inventory.AbstractContainerMenu
|
||||
import net.minecraft.world.inventory.ClickType
|
||||
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.ResolvableProfile
|
||||
import java.util.*
|
||||
|
||||
class AntimobGui(syncId: Int, playerInv: Inventory, val container: Container, private val viewer: ServerPlayer) : AbstractContainerMenu(MenuType.GENERIC_9x2, syncId) {
|
||||
companion object {
|
||||
const val CONTAINER_COLUMNS = 9
|
||||
const val CONTAINER_ROWS = 2
|
||||
const val CONTAINER_SIZE = CONTAINER_COLUMNS * CONTAINER_ROWS
|
||||
|
||||
const val SLOT_SIZE = 18
|
||||
const val LEFT_PADDING = 8
|
||||
const val TOP_PADDING = 18
|
||||
|
||||
const val PLAYER_INV_TOP = 58
|
||||
const val HOTBAR_Y = 116
|
||||
}
|
||||
|
||||
private val headSlotToMob = mapOf(0 to "크리퍼", 1 to "가스트", 2 to "엔더맨")
|
||||
private val glassSlotToMob = mapOf(9 to "크리퍼", 10 to "가스트", 11 to "엔더맨")
|
||||
|
||||
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) {
|
||||
for (col in 0 until 9) {
|
||||
val index = col + row * 9 + 9
|
||||
val x = LEFT_PADDING + col * SLOT_SIZE
|
||||
val y = PLAYER_INV_TOP + row * SLOT_SIZE
|
||||
addSlot(Slot(playerInv, index, x, y))
|
||||
}
|
||||
}
|
||||
|
||||
for (col in 0 until 9) {
|
||||
val x = LEFT_PADDING + col * SLOT_SIZE
|
||||
val y = HOTBAR_Y
|
||||
addSlot(Slot(playerInv, col, x, y))
|
||||
}
|
||||
|
||||
refreshContainerContents()
|
||||
}
|
||||
|
||||
override fun clicked(slotId: Int, button: Int, clickType: ClickType, player: net.minecraft.world.entity.player.Player) {
|
||||
if (player is ServerPlayer && slotId in 0 until CONTAINER_SIZE) {
|
||||
if (glassSlotToMob.containsKey(slotId)) {
|
||||
val mob = glassSlotToMob[slotId] ?: return
|
||||
AntimobConfig.toggle(mob)
|
||||
container.setItem(slotId, makeToggleGlass(mob))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
super.clicked(slotId, button, clickType, 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
|
||||
|
||||
private fun makeMobHead(mob: String, player: ServerPlayer): ItemStack {
|
||||
val headItem = ItemStack(Items.PLAYER_HEAD)
|
||||
|
||||
val uuid = when (mob) {
|
||||
"크리퍼" -> UUID.fromString("057b1c47-1321-4863-a6fe-8887f9ec265f")
|
||||
"가스트" -> UUID.fromString("063085a6-797f-4785-be1a-21cd7580f752")
|
||||
"엔더맨" -> UUID.fromString("40ffb372-12f6-4678-b3f2-2176bf56dd4b")
|
||||
else -> UUID.fromString("c06f8906-4c8a-4911-9c29-ea1dbd1aab82")
|
||||
}
|
||||
|
||||
val filledProfile: GameProfile = try {
|
||||
player.server.sessionService.fetchProfile(uuid, true)?.profile ?: player.gameProfile
|
||||
} catch (_: Exception) {
|
||||
player.gameProfile
|
||||
}
|
||||
|
||||
val resolvableProfile = try {
|
||||
ResolvableProfile(filledProfile)
|
||||
} catch (_: NoSuchMethodError) {
|
||||
val ctor = ResolvableProfile::class.java.getDeclaredConstructor(GameProfile::class.java)
|
||||
ctor.isAccessible = true
|
||||
ctor.newInstance(filledProfile)
|
||||
}
|
||||
|
||||
headItem.set(DataComponents.CUSTOM_NAME, Component.literal(mob).withStyle { it.withColor(ChatFormatting.YELLOW) })
|
||||
headItem.set(DataComponents.PROFILE, resolvableProfile)
|
||||
return headItem
|
||||
}
|
||||
|
||||
private fun makeToggleGlass(mob: String): ItemStack {
|
||||
val enabled = AntimobConfig.get(mob)
|
||||
val glass = if (enabled) ItemStack(Items.GREEN_STAINED_GLASS_PANE) else ItemStack(Items.RED_STAINED_GLASS_PANE)
|
||||
val statusText = if (enabled) "활성화" else "비활성화"
|
||||
glass.set(DataComponents.CUSTOM_NAME, Component.literal(statusText).withStyle { it.withColor(if (enabled) ChatFormatting.GREEN else ChatFormatting.RED) })
|
||||
return glass
|
||||
}
|
||||
|
||||
private fun refreshContainerContents() {
|
||||
headSlotToMob.forEach { (slot, mob) ->
|
||||
container.setItem(slot, makeMobHead(mob, viewer))
|
||||
}
|
||||
glassSlotToMob.forEach { (slot, mob) ->
|
||||
container.setItem(slot, makeToggleGlass(mob))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,302 @@
|
|||
package com.beemer.essentials.gui
|
||||
|
||||
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.TranslationUtils.translateBiome
|
||||
import com.beemer.essentials.util.TranslationUtils.translateDimension
|
||||
import java.util.UUID
|
||||
import net.minecraft.ChatFormatting
|
||||
import net.minecraft.core.component.DataComponents
|
||||
import net.minecraft.core.registries.Registries
|
||||
import net.minecraft.nbt.CompoundTag
|
||||
import net.minecraft.network.chat.Component
|
||||
import net.minecraft.resources.ResourceKey
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
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.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
|
||||
|
||||
/** 좌표 GUI - 페이지 기능 포함 5줄(45개) 표시 + 6번째 줄에 이전/다음 버튼 */
|
||||
class Menu(
|
||||
syncId: Int,
|
||||
playerInv: Inventory,
|
||||
val container: Container,
|
||||
private var currentPage: Int = 0
|
||||
) : 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번째 줄 마지막
|
||||
}
|
||||
|
||||
init {
|
||||
// 54개 슬롯 추가
|
||||
for (i in 0 until 54) {
|
||||
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 = 140 + row * 18
|
||||
addSlot(Slot(playerInv, index, x, y))
|
||||
}
|
||||
}
|
||||
for (col in 0 until 9) {
|
||||
val x = 8 + col * 18
|
||||
val y = 198
|
||||
addSlot(Slot(playerInv, col, x, y))
|
||||
}
|
||||
}
|
||||
|
||||
override fun clicked(slotId: Int, button: Int, clickType: ClickType, player: Player) {
|
||||
if (slotId !in 0 until container.containerSize || player !is ServerPlayer) {
|
||||
super.clicked(slotId, button, clickType, player)
|
||||
return
|
||||
}
|
||||
|
||||
val stack = slots[slotId].item
|
||||
val tag = stack.get(DataComponents.CUSTOM_DATA)?.copyTag() ?: return
|
||||
|
||||
// 이전 페이지 버튼
|
||||
if (slotId == PREV_BUTTON_SLOT &&
|
||||
tag.contains("action") &&
|
||||
tag.getString("action") == "prev"
|
||||
) {
|
||||
if (currentPage > 0) {
|
||||
openPage(player, currentPage - 1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 다음 페이지 버튼
|
||||
if (slotId == NEXT_BUTTON_SLOT &&
|
||||
tag.contains("action") &&
|
||||
tag.getString("action") == "next"
|
||||
) {
|
||||
val totalPages = getTotalPages()
|
||||
if (currentPage < totalPages - 1) {
|
||||
openPage(player, currentPage + 1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 좌표 아이템 클릭 시 텔레포트
|
||||
if (tag.contains("name") && tag.contains("x")) {
|
||||
val x = tag.getDouble("x")
|
||||
val y = tag.getDouble("y")
|
||||
val z = tag.getDouble("z")
|
||||
val dimension =
|
||||
ResourceLocation.tryParse(tag.getString("dimension"))?.let {
|
||||
ResourceKey.create(Registries.DIMENSION, it)
|
||||
}
|
||||
val level = dimension?.let { player.server.getLevel(it) }
|
||||
|
||||
if (level != null) {
|
||||
// 이전 위치 저장
|
||||
val prevPos = player.blockPosition()
|
||||
PlayerConfig.recordLastLocation(
|
||||
player,
|
||||
Location(
|
||||
dimension = player.level().dimension().location().toString(),
|
||||
biome =
|
||||
player.level()
|
||||
.getBiome(prevPos)
|
||||
.unwrapKey()
|
||||
.map { it.location().toString() }
|
||||
.orElse("minecraft:plains"),
|
||||
x = prevPos.x.toDouble(),
|
||||
y = prevPos.y.toDouble(),
|
||||
z = prevPos.z.toDouble()
|
||||
)
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
player.closeContainer()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getTotalPages(): Int {
|
||||
val totalItems = CoordinateConfig.getCoordinates().size
|
||||
return if (totalItems == 0) 1 else (totalItems + ITEMS_PER_PAGE - 1) / ITEMS_PER_PAGE
|
||||
}
|
||||
|
||||
private fun openPage(player: ServerPlayer, page: Int) {
|
||||
val container = createPageContainer(player, page)
|
||||
player.openMenu(
|
||||
SimpleMenuProvider(
|
||||
{ windowId, inv, _ -> Menu(windowId, inv, container, page) },
|
||||
Component.literal("저장된 좌표 (${page + 1}/${getTotalPages()})").withStyle {
|
||||
it.withBold(true)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun stillValid(player: Player): Boolean = true
|
||||
override fun quickMoveStack(player: Player, index: Int): ItemStack = ItemStack.EMPTY
|
||||
}
|
||||
|
||||
/** 페이지별 컨테이너 생성 */
|
||||
fun createPageContainer(player: ServerPlayer, page: Int): SimpleContainer {
|
||||
val container = SimpleContainer(54)
|
||||
val coordinates = CoordinateConfig.getCoordinates()
|
||||
val startIdx = page * Menu.ITEMS_PER_PAGE
|
||||
val endIdx = minOf(startIdx + Menu.ITEMS_PER_PAGE, coordinates.size)
|
||||
val totalPages =
|
||||
if (coordinates.isEmpty()) 1
|
||||
else (coordinates.size + Menu.ITEMS_PER_PAGE - 1) / Menu.ITEMS_PER_PAGE
|
||||
|
||||
// 좌표 아이템 추가 (5줄, 45개)
|
||||
for (i in startIdx until endIdx) {
|
||||
val coord = coordinates[i]
|
||||
val item = ItemStack(Items.PAPER)
|
||||
val tag =
|
||||
CompoundTag().apply {
|
||||
putString("name", coord.name)
|
||||
putString("dimension", coord.dimension)
|
||||
putDouble("x", coord.x)
|
||||
putDouble("y", coord.y)
|
||||
putDouble("z", coord.z)
|
||||
}
|
||||
|
||||
val creatorName =
|
||||
coord.creatorUuid?.let { uuidStr ->
|
||||
try {
|
||||
val uuid = UUID.fromString(uuidStr)
|
||||
NicknameDataStore.getNickname(uuid)
|
||||
?: player.server.playerList.getPlayer(uuid)?.gameProfile?.name
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val displayName =
|
||||
if (creatorName != null) {
|
||||
Component.literal(coord.name)
|
||||
.withStyle { it.withColor(ChatFormatting.GOLD).withBold(true) }
|
||||
.append(
|
||||
Component.literal(" ($creatorName)").withStyle {
|
||||
it.withColor(ChatFormatting.AQUA).withBold(false)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Component.literal(coord.name).withStyle {
|
||||
it.withColor(ChatFormatting.GOLD).withBold(true)
|
||||
}
|
||||
}
|
||||
|
||||
val loreList: List<Component> =
|
||||
listOf(
|
||||
Component.literal("디멘션: ")
|
||||
.withStyle { it.withColor(ChatFormatting.DARK_GREEN) }
|
||||
.append(
|
||||
Component.literal(translateDimension(coord.dimension))
|
||||
.withStyle { it.withColor(ChatFormatting.GRAY) }
|
||||
),
|
||||
Component.literal("바이옴: ")
|
||||
.withStyle { it.withColor(ChatFormatting.DARK_GREEN) }
|
||||
.append(
|
||||
Component.literal(translateBiome(coord.biome)).withStyle {
|
||||
it.withColor(ChatFormatting.GRAY)
|
||||
}
|
||||
),
|
||||
Component.literal("좌표: ")
|
||||
.withStyle { it.withColor(ChatFormatting.DARK_GREEN) }
|
||||
.append(
|
||||
Component.literal(
|
||||
"${coord.x.toInt()}, ${coord.y.toInt()}, ${coord.z.toInt()}"
|
||||
)
|
||||
.withStyle { it.withColor(ChatFormatting.GRAY) }
|
||||
)
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 맨 아래줄 빈 공간을 회색 유리판으로 채우기 (슬롯 46-52)
|
||||
val fillerItem = ItemStack(Items.GRAY_STAINED_GLASS_PANE)
|
||||
fillerItem.set(
|
||||
DataComponents.CUSTOM_NAME,
|
||||
Component.literal(" ").withStyle { it.withColor(ChatFormatting.DARK_GRAY) }
|
||||
)
|
||||
for (slot in 46..52) {
|
||||
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)
|
||||
}
|
||||
|
||||
// 다음 페이지 버튼 (슬롯 53)
|
||||
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)
|
||||
}
|
||||
|
||||
return container
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
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 net.minecraft.server.level.ServerPlayer
|
||||
import net.minecraft.world.Container
|
||||
import net.minecraft.world.entity.player.Inventory
|
||||
import net.minecraft.world.inventory.AbstractContainerMenu
|
||||
import net.minecraft.world.inventory.ClickType
|
||||
import net.minecraft.world.inventory.MenuType
|
||||
import net.minecraft.world.inventory.Slot
|
||||
import net.minecraft.world.item.ItemStack
|
||||
|
||||
class TeleportGui(
|
||||
syncId: Int,
|
||||
playerInv: Inventory,
|
||||
val container: Container,
|
||||
val viewer: ServerPlayer
|
||||
) : AbstractContainerMenu(MenuType.GENERIC_9x3, syncId) {
|
||||
companion object {
|
||||
const val CONTAINER_COLUMNS = 9
|
||||
const val CONTAINER_ROWS = 3
|
||||
const val CONTAINER_SIZE = CONTAINER_COLUMNS * CONTAINER_ROWS
|
||||
|
||||
const val SLOT_SIZE = 18
|
||||
const val LEFT_PADDING = 8
|
||||
const val TOP_PADDING = 18
|
||||
|
||||
const val PLAYER_INV_TOP = 86
|
||||
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) {
|
||||
for (col in 0 until 9) {
|
||||
val index = col + row * 9 + 9
|
||||
val x = LEFT_PADDING + col * SLOT_SIZE
|
||||
val y = PLAYER_INV_TOP + row * SLOT_SIZE
|
||||
addSlot(Slot(playerInv, index, x, y))
|
||||
}
|
||||
}
|
||||
|
||||
for (col in 0 until 9) {
|
||||
val x = LEFT_PADDING + col * SLOT_SIZE
|
||||
val y = HOTBAR_Y
|
||||
addSlot(Slot(playerInv, col, x, y))
|
||||
}
|
||||
}
|
||||
|
||||
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) }
|
||||
|
||||
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()
|
||||
|
||||
player.teleportTo(
|
||||
targetLevel,
|
||||
currentTargetPlayer.x,
|
||||
currentTargetPlayer.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()
|
||||
return
|
||||
}
|
||||
super.clicked(slotId, button, clickType, 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
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
|
||||
package com.beemer.essentials.nickname
|
||||
|
||||
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
|
||||
|
||||
/**
|
||||
* 닉네임 명령어
|
||||
* /닉네임 변경 <닉네임>
|
||||
* /닉네임 초기화
|
||||
*/
|
||||
object NicknameCommand {
|
||||
@SubscribeEvent
|
||||
fun onRegisterCommands(event: RegisterCommandsEvent) {
|
||||
// 한글 명령어
|
||||
event.dispatcher.register(
|
||||
Commands.literal("닉네임")
|
||||
.then(
|
||||
Commands.literal("변경")
|
||||
.then(
|
||||
Commands.argument("닉네임", StringArgumentType.greedyString())
|
||||
.executes { context ->
|
||||
val player = context.source.entity as? ServerPlayer
|
||||
?: return@executes 0
|
||||
|
||||
val nickname = StringArgumentType.getString(context, "닉네임").trim()
|
||||
executeSet(player, nickname)
|
||||
}
|
||||
)
|
||||
)
|
||||
.then(
|
||||
Commands.literal("초기화")
|
||||
.executes { context ->
|
||||
val player = context.source.entity as? ServerPlayer
|
||||
?: return@executes 0
|
||||
|
||||
executeReset(player)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
// 영어 명령어
|
||||
event.dispatcher.register(
|
||||
Commands.literal("nickname")
|
||||
.then(
|
||||
Commands.literal("set")
|
||||
.then(
|
||||
Commands.argument("name", StringArgumentType.greedyString())
|
||||
.executes { context ->
|
||||
val player = context.source.entity as? ServerPlayer
|
||||
?: return@executes 0
|
||||
|
||||
val nickname = StringArgumentType.getString(context, "name").trim()
|
||||
executeSet(player, nickname)
|
||||
}
|
||||
)
|
||||
)
|
||||
.then(
|
||||
Commands.literal("reset")
|
||||
.executes { context ->
|
||||
val player = context.source.entity as? ServerPlayer
|
||||
?: return@executes 0
|
||||
|
||||
executeReset(player)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
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) }
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
// 유효성 검사: 중복
|
||||
if (NicknameDataStore.isNicknameTaken(nickname, player.uuid)) {
|
||||
player.sendSystemMessage(
|
||||
Component.literal("이미 사용 중인 닉네임입니다.")
|
||||
.withStyle { it.withColor(ChatFormatting.RED) }
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
// 닉네임 저장 및 적용
|
||||
NicknameDataStore.setNickname(player.uuid, 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) })
|
||||
)
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
private fun executeReset(player: ServerPlayer): Int {
|
||||
if (!NicknameDataStore.hasNickname(player.uuid)) {
|
||||
player.sendSystemMessage(
|
||||
Component.literal("설정된 닉네임이 없습니다.")
|
||||
.withStyle { it.withColor(ChatFormatting.RED) }
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
NicknameDataStore.removeNickname(player.uuid)
|
||||
NicknameManager.removeNickname(player)
|
||||
|
||||
player.sendSystemMessage(
|
||||
Component.literal("닉네임이 초기화되었습니다.")
|
||||
.withStyle { it.withColor(ChatFormatting.GOLD) }
|
||||
)
|
||||
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
package com.beemer.essentials.nickname
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import net.neoforged.fml.loading.FMLPaths
|
||||
import org.apache.logging.log4j.LogManager
|
||||
import org.apache.logging.log4j.Logger
|
||||
|
||||
/** 닉네임 데이터 저장소 JSON 파일로 닉네임 저장/로드 */
|
||||
object NicknameDataStore {
|
||||
private const val MOD_ID = "essentials"
|
||||
private val LOGGER: Logger = LogManager.getLogger(MOD_ID)
|
||||
|
||||
private val BASE_CONFIG_DIR: Path = FMLPaths.CONFIGDIR.get()
|
||||
private val CONFIG_DIR: Path = BASE_CONFIG_DIR.resolve(MOD_ID)
|
||||
private val FILE_PATH: Path = CONFIG_DIR.resolve("nicknames.json")
|
||||
|
||||
private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
|
||||
|
||||
// UUID -> 닉네임 매핑
|
||||
private val nicknames: MutableMap<String, String> = ConcurrentHashMap()
|
||||
|
||||
/** 닉네임 데이터 로드 */
|
||||
fun load() {
|
||||
try {
|
||||
Files.createDirectories(CONFIG_DIR)
|
||||
|
||||
if (Files.exists(FILE_PATH)) {
|
||||
val json = Files.readString(FILE_PATH)
|
||||
val type = object : TypeToken<Map<String, String>>() {}.type
|
||||
val loaded: Map<String, String> = gson.fromJson(json, type) ?: emptyMap()
|
||||
nicknames.clear()
|
||||
nicknames.putAll(loaded)
|
||||
LOGGER.info("[Essentials] 닉네임 데이터 로드 완료: ${nicknames.size}개")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LOGGER.error("[Essentials] 닉네임 데이터 로드 실패", e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 닉네임 데이터 저장 */
|
||||
fun save() {
|
||||
try {
|
||||
Files.createDirectories(CONFIG_DIR)
|
||||
val json = gson.toJson(nicknames)
|
||||
Files.writeString(FILE_PATH, json)
|
||||
} catch (e: Exception) {
|
||||
LOGGER.error("[Essentials] 닉네임 데이터 저장 실패", e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 닉네임 설정 */
|
||||
fun setNickname(uuid: UUID, nickname: String) {
|
||||
nicknames[uuid.toString()] = nickname
|
||||
save()
|
||||
}
|
||||
|
||||
/** 닉네임 제거 */
|
||||
fun removeNickname(uuid: UUID) {
|
||||
nicknames.remove(uuid.toString())
|
||||
save()
|
||||
}
|
||||
|
||||
/** 닉네임 조회 */
|
||||
@JvmStatic
|
||||
fun getNickname(uuid: UUID): String? {
|
||||
return nicknames[uuid.toString()]
|
||||
}
|
||||
|
||||
/** 닉네임 중복 확인 */
|
||||
fun isNicknameTaken(nickname: String, excludeUUID: UUID? = null): Boolean {
|
||||
val target = nickname.trim()
|
||||
if (target.isEmpty()) return false
|
||||
return nicknames.entries.any {
|
||||
it.value.equals(target, ignoreCase = true) &&
|
||||
(excludeUUID == null || it.key != excludeUUID.toString())
|
||||
}
|
||||
}
|
||||
|
||||
/** 플레이어에게 닉네임이 있는지 확인 */
|
||||
fun hasNickname(uuid: UUID): Boolean {
|
||||
return nicknames.containsKey(uuid.toString())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
package com.beemer.essentials.nickname
|
||||
|
||||
import java.util.EnumSet
|
||||
import net.minecraft.network.protocol.game.ClientboundPlayerInfoRemovePacket
|
||||
import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket
|
||||
import net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket
|
||||
import net.minecraft.server.level.ServerPlayer
|
||||
import org.apache.logging.log4j.LogManager
|
||||
import org.apache.logging.log4j.Logger
|
||||
|
||||
/** 닉네임 관리 유틸리티 패킷 재전송 및 엔티티 재추적을 통한 닉네임 즉시 반영 */
|
||||
object NicknameManager {
|
||||
private val LOGGER: Logger = LogManager.getLogger("essentials")
|
||||
|
||||
/** 닉네임 적용 */
|
||||
fun applyNickname(player: ServerPlayer, nickname: String) {
|
||||
val server = player.server
|
||||
|
||||
server.execute {
|
||||
refreshPlayerInfo(player)
|
||||
respawnPlayerEntity(player)
|
||||
}
|
||||
|
||||
LOGGER.info("[Essentials] [${player.gameProfile.name}] 닉네임 적용: $nickname")
|
||||
}
|
||||
|
||||
/** 닉네임 제거 */
|
||||
fun removeNickname(player: ServerPlayer) {
|
||||
val server = player.server
|
||||
|
||||
server.execute {
|
||||
refreshPlayerInfo(player)
|
||||
respawnPlayerEntity(player)
|
||||
}
|
||||
|
||||
LOGGER.info("[Essentials] [${player.gameProfile.name}] 닉네임 초기화")
|
||||
}
|
||||
|
||||
/** 플레이어 정보 갱신 (탭 목록) */
|
||||
private fun refreshPlayerInfo(player: ServerPlayer) {
|
||||
val server = player.server
|
||||
|
||||
val removePacket = ClientboundPlayerInfoRemovePacket(listOf(player.uuid))
|
||||
val addPacket =
|
||||
ClientboundPlayerInfoUpdatePacket(
|
||||
EnumSet.of(
|
||||
ClientboundPlayerInfoUpdatePacket.Action.ADD_PLAYER,
|
||||
ClientboundPlayerInfoUpdatePacket.Action.INITIALIZE_CHAT,
|
||||
ClientboundPlayerInfoUpdatePacket.Action.UPDATE_GAME_MODE,
|
||||
ClientboundPlayerInfoUpdatePacket.Action.UPDATE_LISTED,
|
||||
ClientboundPlayerInfoUpdatePacket.Action.UPDATE_LATENCY,
|
||||
ClientboundPlayerInfoUpdatePacket.Action.UPDATE_DISPLAY_NAME
|
||||
),
|
||||
listOf(player)
|
||||
)
|
||||
|
||||
server.playerList.players.forEach { recipient ->
|
||||
recipient.connection.send(removePacket)
|
||||
recipient.connection.send(addPacket)
|
||||
}
|
||||
}
|
||||
|
||||
/** 플레이어 엔티티 재전송 (머리 위 이름 갱신) */
|
||||
private fun respawnPlayerEntity(player: ServerPlayer) {
|
||||
val server = player.server
|
||||
val level = player.serverLevel()
|
||||
val chunkMap = level.chunkSource.chunkMap
|
||||
|
||||
val removePacket = ClientboundRemoveEntitiesPacket(player.id)
|
||||
|
||||
server.playerList.players.forEach { recipient ->
|
||||
if (recipient != player) {
|
||||
recipient.connection.send(removePacket)
|
||||
}
|
||||
}
|
||||
|
||||
// 리플렉션으로 엔티티 재추적
|
||||
try {
|
||||
val chunkMapClass = chunkMap::class.java
|
||||
|
||||
val removeMethod =
|
||||
chunkMapClass.getDeclaredMethod(
|
||||
"removeEntity",
|
||||
net.minecraft.world.entity.Entity::class.java
|
||||
)
|
||||
removeMethod.isAccessible = true
|
||||
removeMethod.invoke(chunkMap, player)
|
||||
|
||||
val addMethod =
|
||||
chunkMapClass.getDeclaredMethod(
|
||||
"addEntity",
|
||||
net.minecraft.world.entity.Entity::class.java
|
||||
)
|
||||
addMethod.isAccessible = true
|
||||
addMethod.invoke(chunkMap, player)
|
||||
} catch (e: Exception) {
|
||||
LOGGER.warn("[Essentials] 엔티티 재추적 실패: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** 플레이어 접속 시 호출 */
|
||||
fun onPlayerJoin(player: ServerPlayer) {
|
||||
val nickname = NicknameDataStore.getNickname(player.uuid)
|
||||
if (nickname != null) {
|
||||
LOGGER.info("[Essentials] [${player.gameProfile.name}] 저장된 닉네임 적용: $nickname")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
package com.beemer.essentials.util
|
||||
|
||||
import com.beemer.essentials.config.ChatConfig
|
||||
import com.beemer.essentials.nickname.NicknameDataStore
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import net.minecraft.network.chat.Component
|
||||
import net.minecraft.server.level.ServerPlayer
|
||||
|
||||
object ChatUtils {
|
||||
private const val SECTION = '\u00a7'
|
||||
private val legacyPattern = Regex("&([0-9A-FK-ORa-fk-or])")
|
||||
private val hexPattern = Regex("&#([A-Fa-f0-9]{6})")
|
||||
private val timeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
|
||||
|
||||
private fun translateChatColors(text: String?): String {
|
||||
if (text.isNullOrEmpty()) return ""
|
||||
|
||||
var result = text
|
||||
|
||||
result =
|
||||
hexPattern.replace(result) { match ->
|
||||
val hex = match.groupValues[1].lowercase()
|
||||
val builder = StringBuilder()
|
||||
builder.append(SECTION).append('x')
|
||||
for (c in hex) {
|
||||
builder.append(SECTION).append(c)
|
||||
}
|
||||
builder.toString()
|
||||
}
|
||||
|
||||
result =
|
||||
legacyPattern.replace(result) { match ->
|
||||
val code = match.groupValues[1].lowercase()
|
||||
"$SECTION$code"
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private fun currentTime(): String {
|
||||
return ZonedDateTime.now(ZoneId.of("Asia/Seoul")).format(timeFormatter)
|
||||
}
|
||||
|
||||
fun applyPlaceholders(template: String, name: String, message: String): String {
|
||||
return template.replace("%time%", currentTime())
|
||||
.replace("%name%", name)
|
||||
.replace("%message%", message)
|
||||
}
|
||||
|
||||
fun chatFormatting(template: String, player: ServerPlayer, message: String): String {
|
||||
// 닉네임이 있으면 닉네임 사용, 없으면 원본 이름
|
||||
val name = NicknameDataStore.getNickname(player.uuid) ?: player.gameProfile.name
|
||||
val withPlaceholders = applyPlaceholders(template, name, message)
|
||||
return translateChatColors(withPlaceholders)
|
||||
}
|
||||
|
||||
fun broadcastJoin(player: ServerPlayer, firstTime: Boolean) {
|
||||
val key = if (firstTime) "first-time-join" else "join"
|
||||
val template = ChatConfig.getFormat(key) ?: defaultJoinTemplate(firstTime)
|
||||
val formatted = chatFormatting(template, player, "")
|
||||
player.server.playerList.broadcastSystemMessage(Component.literal(formatted), false)
|
||||
}
|
||||
|
||||
fun broadcastQuit(player: ServerPlayer) {
|
||||
val template = ChatConfig.getFormat("quit") ?: "&f[&c-&f]&r %name%"
|
||||
val formatted = chatFormatting(template, player, "")
|
||||
player.server.playerList.broadcastSystemMessage(Component.literal(formatted), false)
|
||||
}
|
||||
|
||||
fun broadcastChat(player: ServerPlayer, rawMessage: String) {
|
||||
val template = ChatConfig.getFormat("chat") ?: "%name% &7&l: &f%message%"
|
||||
val formatted = chatFormatting(template, player, rawMessage)
|
||||
player.server.playerList.broadcastSystemMessage(Component.literal(formatted), false)
|
||||
}
|
||||
|
||||
private fun defaultJoinTemplate(firstTime: Boolean): String =
|
||||
if (firstTime) "&f[&eNEW&f]&r %name%" else "&f[&a+&f]&r %name%"
|
||||
|
||||
fun hasIllegalCharacter(message: String): Boolean {
|
||||
for (c in message) {
|
||||
if (c < 32.toChar() || c == 127.toChar()) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun clearChat(player: ServerPlayer, lines: Int = 100) {
|
||||
repeat(lines) { player.sendSystemMessage(Component.literal(" ")) }
|
||||
}
|
||||
|
||||
fun clearChatForAll(players: Collection<ServerPlayer>, lines: Int = 100) {
|
||||
for (p in players) clearChat(p, lines)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.beemer.essentials.util
|
||||
|
||||
import net.minecraft.commands.CommandSourceStack
|
||||
import net.minecraft.network.chat.Component
|
||||
import net.minecraft.server.level.ServerPlayer
|
||||
|
||||
object CommandUtils {
|
||||
fun getPlayerOrSendFailure(source: CommandSourceStack, failureMessage: String = "해당 명령어는 플레이어만 사용할 수 있습니다."): ServerPlayer? {
|
||||
return try {
|
||||
source.playerOrException ?: run {
|
||||
source.sendFailure(Component.literal(failureMessage))
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
source.sendFailure(Component.literal(failureMessage))
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.beemer.essentials.util
|
||||
|
||||
import net.minecraft.core.registries.Registries
|
||||
import net.minecraft.resources.ResourceKey
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
import net.minecraft.server.MinecraftServer
|
||||
import net.minecraft.server.level.ServerLevel
|
||||
import net.minecraft.world.level.Level
|
||||
|
||||
object DimensionUtils {
|
||||
fun getLevelById(server: MinecraftServer, dimensionId: String): ServerLevel? {
|
||||
val rl = ResourceLocation.tryParse(dimensionId) ?: return null
|
||||
val key: ResourceKey<Level> = ResourceKey.create(Registries.DIMENSION, rl)
|
||||
return server.getLevel(key)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.beemer.essentials.util
|
||||
|
||||
object TimeUtils {
|
||||
fun formatPlayTimeMillis(ms: Long): String {
|
||||
var seconds = ms / 1000
|
||||
val days = seconds / (24 * 3600)
|
||||
seconds %= (24 * 3600)
|
||||
val hours = seconds / 3600
|
||||
seconds %= 3600
|
||||
val minutes = seconds / 60
|
||||
seconds %= 60
|
||||
|
||||
return buildString {
|
||||
if (days > 0) append("${days}일 ")
|
||||
if (hours > 0) append("${hours}시간 ")
|
||||
if (minutes > 0 || hours > 0 || days > 0) append("${minutes}분 ")
|
||||
append("${seconds}초")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package com.beemer.essentials.util
|
||||
|
||||
import com.google.common.reflect.TypeToken
|
||||
import com.google.gson.Gson
|
||||
import java.io.InputStreamReader
|
||||
|
||||
object TranslationUtils {
|
||||
private val translations = mutableMapOf<String, String>()
|
||||
private val gson = Gson()
|
||||
private val mapType = object : TypeToken<Map<String, String>>() {}.type
|
||||
|
||||
fun loadTranslations() {
|
||||
try {
|
||||
this::class.java.getResourceAsStream("/assets/essentials/lang/ko_kr.json")?.use { stream ->
|
||||
InputStreamReader(stream).use { reader ->
|
||||
val items: Map<String, String> = gson.fromJson(reader, mapType)
|
||||
translations.putAll(items)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun translate(prefix: String, id: String): String {
|
||||
val key = "$prefix.${id.replace(':', '.')}"
|
||||
return translations[key] ?: id
|
||||
}
|
||||
|
||||
fun translateBiome(id: String) = translate("biome", id)
|
||||
|
||||
fun translateDimension(id: String) = translate("dimension", id)
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
{
|
||||
"dimension.minecraft.overworld": "오버월드",
|
||||
"dimension.minecraft.the_nether": "네더",
|
||||
"dimension.minecraft.the_end": "엔드",
|
||||
"biome.minecraft.badlands": "악지",
|
||||
"biome.minecraft.bamboo_jungle": "대나무 정글",
|
||||
"biome.minecraft.basalt_deltas": "현무암 삼각주",
|
||||
"biome.minecraft.beach": "해변",
|
||||
"biome.minecraft.birch_forest": "자작나무 숲",
|
||||
"biome.minecraft.cherry_grove": "벚나무 숲",
|
||||
"biome.minecraft.cold_ocean": "차가운 바다",
|
||||
"biome.minecraft.crimson_forest": "진홍빛 숲",
|
||||
"biome.minecraft.dark_forest": "어두운 숲",
|
||||
"biome.minecraft.deep_cold_ocean": "깊고 차가운 바다",
|
||||
"biome.minecraft.deep_dark": "깊은 어둠",
|
||||
"biome.minecraft.deep_frozen_ocean": "깊고 얼어붙은 바다",
|
||||
"biome.minecraft.deep_lukewarm_ocean": "깊고 미지근한 바다",
|
||||
"biome.minecraft.deep_ocean": "깊은 바다",
|
||||
"biome.minecraft.desert": "사막",
|
||||
"biome.minecraft.dripstone_caves": "점적석 동굴",
|
||||
"biome.minecraft.end_barrens": "엔드 불모지",
|
||||
"biome.minecraft.end_highlands": "엔드 고지",
|
||||
"biome.minecraft.end_midlands": "엔드 중지",
|
||||
"biome.minecraft.eroded_badlands": "침식된 악지",
|
||||
"biome.minecraft.flower_forest": "꽃 숲",
|
||||
"biome.minecraft.forest": "숲",
|
||||
"biome.minecraft.frozen_ocean": "얼어붙은 바다",
|
||||
"biome.minecraft.frozen_peaks": "얼어붙은 봉우리",
|
||||
"biome.minecraft.frozen_river": "얼어붙은 강",
|
||||
"biome.minecraft.grove": "산림",
|
||||
"biome.minecraft.ice_spikes": "역고드름",
|
||||
"biome.minecraft.jagged_peaks": "뾰족한 봉우리",
|
||||
"biome.minecraft.jungle": "정글",
|
||||
"biome.minecraft.lukewarm_ocean": "미지근한 바다",
|
||||
"biome.minecraft.lush_caves": "무성한 동굴",
|
||||
"biome.minecraft.mangrove_swamp": "맹그로브 늪",
|
||||
"biome.minecraft.meadow": "목초지",
|
||||
"biome.minecraft.mushroom_fields": "버섯 들판",
|
||||
"biome.minecraft.nether_wastes": "네더 황무지",
|
||||
"biome.minecraft.ocean": "바다",
|
||||
"biome.minecraft.old_growth_birch_forest": "자작나무 원시림",
|
||||
"biome.minecraft.old_growth_pine_taiga": "소나무 원시 타이가",
|
||||
"biome.minecraft.old_growth_spruce_taiga": "가문비나무 원시 타이가",
|
||||
"biome.minecraft.plains": "평원",
|
||||
"biome.minecraft.river": "강",
|
||||
"biome.minecraft.savanna": "사바나",
|
||||
"biome.minecraft.savanna_plateau": "사바나 고원",
|
||||
"biome.minecraft.small_end_islands": "작은 엔드 섬",
|
||||
"biome.minecraft.snowy_beach": "눈 덮인 해변",
|
||||
"biome.minecraft.snowy_plains": "눈 덮인 평원",
|
||||
"biome.minecraft.snowy_slopes": "눈 덮인 비탈",
|
||||
"biome.minecraft.snowy_taiga": "눈 덮인 타이가",
|
||||
"biome.minecraft.soul_sand_valley": "영혼 모래 골짜기",
|
||||
"biome.minecraft.sparse_jungle": "듬성듬성한 정글",
|
||||
"biome.minecraft.stony_peaks": "돌 봉우리",
|
||||
"biome.minecraft.stony_shore": "돌 해안",
|
||||
"biome.minecraft.sunflower_plains": "해바라기 평원",
|
||||
"biome.minecraft.swamp": "늪",
|
||||
"biome.minecraft.taiga": "타이가",
|
||||
"biome.minecraft.the_end": "엔드",
|
||||
"biome.minecraft.the_void": "공허",
|
||||
"biome.minecraft.warm_ocean": "따뜻한 바다",
|
||||
"biome.minecraft.warped_forest": "뒤틀린 숲",
|
||||
"biome.minecraft.windswept_forest": "바람이 세찬 숲",
|
||||
"biome.minecraft.windswept_gravelly_hills": "바람이 세찬 자갈투성이 언덕",
|
||||
"biome.minecraft.windswept_hills": "바람이 세찬 언덕",
|
||||
"biome.minecraft.windswept_savanna": "바람이 세찬 사바나",
|
||||
"biome.minecraft.wooded_badlands": "나무가 우거진 악지"
|
||||
}
|
||||
19
Essentials/src/main/resources/essentials.mixins.json
Normal file
19
Essentials/src/main/resources/essentials.mixins.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"required": true,
|
||||
"package": "com.beemer.essentials.mixin",
|
||||
"compatibilityLevel": "JAVA_21",
|
||||
"mixins": [
|
||||
"CreeperMixin",
|
||||
"EndermanTakeBlockGoalMixin",
|
||||
"FarmBlockMixin",
|
||||
"LargeFireballMixin",
|
||||
"PlayerInfoPacketMixin",
|
||||
"PlayerListMixin",
|
||||
"PlayerMixin",
|
||||
"ServerPlayerMixin"
|
||||
],
|
||||
"client": [],
|
||||
"injectors": {
|
||||
"defaultRequire": 1
|
||||
}
|
||||
}
|
||||
79
Essentials/src/main/templates/META-INF/neoforge.mods.toml
Normal file
79
Essentials/src/main/templates/META-INF/neoforge.mods.toml
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# This is an example mods.toml file. It contains the data relating to the loading mods.
|
||||
# There are several mandatory fields (#mandatory), and many more that are optional (#optional).
|
||||
# The overall format is standard TOML format, v0.5.0.
|
||||
# Note that there are a couple of TOML lists in this file.
|
||||
# Find more information on toml format here: https://github.com/toml-lang/toml
|
||||
# The name of the mod loader type to load - for regular FML @Mod mods it should be javafml
|
||||
modLoader = "kotlinforforge" #mandatory
|
||||
# A version range to match for said mod loader - for regular FML @Mod it will be the the FML version. This is currently 47.
|
||||
loaderVersion = "${loader_version_range}" #mandatory
|
||||
# The license for you mod. This is mandatory metadata and allows for easier comprehension of your redistributive properties.
|
||||
# Review your options at https://choosealicense.com/. All rights reserved is the default copyright stance, and is thus the default here.
|
||||
license = "${mod_license}"
|
||||
# A URL to refer people to when problems occur with this mod
|
||||
#issueTrackerURL="https://change.me.to.your.issue.tracker.example.invalid/" #optional
|
||||
# A list of mods - how many allowed here is determined by the individual mod loader
|
||||
[[mods]] #mandatory
|
||||
# The modid of the mod
|
||||
modId = "${mod_id}" #mandatory
|
||||
# The version number of the mod
|
||||
version = "${mod_version}" #mandatory
|
||||
# A display name for the mod
|
||||
displayName = "${mod_name}" #mandatory
|
||||
# A URL to query for updates for this mod. See the JSON update specification https://docs.neoforge.net/docs/misc/updatechecker/
|
||||
#updateJSONURL="https://change.me.example.invalid/updates.json" #optional
|
||||
# A URL for the "homepage" for this mod, displayed in the mod UI
|
||||
#displayURL="https://change.me.to.your.mods.homepage.example.invalid/" #optional
|
||||
# A file name (in the root of the mod JAR) containing a logo for display
|
||||
#logoFile="essentials.png" #optional
|
||||
# A text field displayed in the mod UI
|
||||
#credits="" #optional
|
||||
# A text field displayed in the mod UI
|
||||
authors = "${mod_authors}" #optional
|
||||
|
||||
# The description text for the mod (multi line!) (#mandatory)
|
||||
description = '''${mod_description}'''
|
||||
|
||||
# The [[mixins]] block allows you to declare your mixin config to FML so that it gets loaded.
|
||||
[[mixins]]
|
||||
config="${mod_id}.mixins.json"
|
||||
|
||||
# The [[accessTransformers]] block allows you to declare where your AT file is.
|
||||
# If this block is omitted, a fallback attempt will be made to load an AT from META-INF/accesstransformer.cfg
|
||||
#[[accessTransformers]]
|
||||
#file="META-INF/accesstransformer.cfg"
|
||||
|
||||
# The coremods config file path is not configurable and is always loaded from META-INF/coremods.json
|
||||
|
||||
# A dependency - use the . to indicate dependency for a specific modid. Dependencies are optional.
|
||||
[[dependencies."${mod_id}"]] #optional
|
||||
# the modid of the dependency
|
||||
modId = "neoforge" #mandatory
|
||||
# The type of the dependency. Can be one of "required", "optional", "incompatible" or "discouraged" (case insensitive).
|
||||
# 'required' requires the mod to exist, 'optional' does not
|
||||
# 'incompatible' will prevent the game from loading when the mod exists, and 'discouraged' will show a warning
|
||||
type = "required" #mandatory
|
||||
# Optional field describing why the dependency is required or why it is incompatible
|
||||
# reason="..."
|
||||
# The version range of the dependency
|
||||
versionRange = "${neo_version_range}" #mandatory
|
||||
# An ordering relationship for the dependency.
|
||||
# BEFORE - This mod is loaded BEFORE the dependency
|
||||
# AFTER - This mod is loaded AFTER the dependency
|
||||
ordering = "NONE"
|
||||
# Side this dependency is applied on - BOTH, CLIENT, or SERVER
|
||||
side = "BOTH"
|
||||
# Here's another dependency
|
||||
[[dependencies."${mod_id}"]]
|
||||
modId = "minecraft"
|
||||
type = "required"
|
||||
# This version range declares a minimum of the current minecraft version up to but not including the next major version
|
||||
versionRange = "${minecraft_version_range}"
|
||||
ordering = "NONE"
|
||||
side = "BOTH"
|
||||
|
||||
# Features are specific properties of the game environment, that you may want to declare you require. This example declares
|
||||
# that your mod requires GL version 3.2 or higher. Other features will be added. They are side aware so declaring this won't
|
||||
# stop your mod loading on the server for example.
|
||||
#[features."${mod_id}"]
|
||||
#openGLVersion="[3.2,)"
|
||||
55
README.md
Normal file
55
README.md
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# ⛏️ Minecraft Mods
|
||||
|
||||
NeoForge 1.21.1 기반 마인크래프트 서버 모드 모음입니다.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
## 📦 모드 목록
|
||||
|
||||
| 모드 | 설명 |
|
||||
| ------------------------------- | ------------------------------------- |
|
||||
| [Essentials](./Essentials/) | 서버 필수 기능 (좌표 관리, 닉네임 등) |
|
||||
| [ServerStatus](./ServerStatus/) | HTTP API로 서버 상태 제공 |
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 기술 스택
|
||||
|
||||
| 기술 | 설명 |
|
||||
| -------------------- | --------------------- |
|
||||
| **NeoForge** | Minecraft 모딩 플랫폼 |
|
||||
| **Kotlin** | 주 개발 언어 |
|
||||
| **Kotlin for Forge** | NeoForge Kotlin 지원 |
|
||||
| **Java 21** | JVM 버전 |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 빌드 방법
|
||||
|
||||
```bash
|
||||
# 각 모드 디렉토리에서
|
||||
./gradlew build
|
||||
```
|
||||
|
||||
빌드된 JAR 파일은 `build/libs/`에 생성됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 📁 프로젝트 구조
|
||||
|
||||
```
|
||||
minecraft-mod/
|
||||
├── Essentials/ # 서버 필수 기능 모드
|
||||
├── ServerStatus/ # 서버 상태 API 모드
|
||||
└── .gitignore
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📄 라이선스
|
||||
|
||||
MIT License
|
||||
79
ServerStatus/README.md
Normal file
79
ServerStatus/README.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# 🌐 ServerStatus
|
||||
|
||||
HTTP API로 마인크래프트 서버 상태를 제공하는 NeoForge 모드입니다.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
## ✨ 주요 기능
|
||||
|
||||
- 📊 **서버 상태 API** - 실시간 서버 정보 제공
|
||||
- 👥 **플레이어 목록** - 접속 중인 플레이어 정보
|
||||
- ⚡ **TPS 모니터링** - 서버 성능 지표
|
||||
- 🗺️ **월드 정보** - 로드된 월드 및 청크 정보
|
||||
|
||||
---
|
||||
|
||||
## 📡 API 엔드포인트
|
||||
|
||||
기본 포트: **25580**
|
||||
|
||||
### 서버 상태
|
||||
|
||||
```
|
||||
GET /api/status
|
||||
```
|
||||
|
||||
응답 예시:
|
||||
|
||||
```json
|
||||
{
|
||||
"online": true,
|
||||
"players": {
|
||||
"online": 5,
|
||||
"max": 20,
|
||||
"list": ["Player1", "Player2"]
|
||||
},
|
||||
"tps": 20.0,
|
||||
"version": "1.21.1"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 기술 스택
|
||||
|
||||
| 기술 | 설명 |
|
||||
| ------------------------- | --------------------- |
|
||||
| **NeoForge** | Minecraft 모딩 플랫폼 |
|
||||
| **Kotlin** | 주 개발 언어 |
|
||||
| **Java HttpServer** | 내장 HTTP 서버 |
|
||||
| **kotlinx.serialization** | JSON 직렬화 |
|
||||
|
||||
---
|
||||
|
||||
## 📁 구조
|
||||
|
||||
```
|
||||
ServerStatus/
|
||||
├── src/main/
|
||||
│ ├── kotlin/co/caadiq/serverstatus/
|
||||
│ │ ├── api/ # HTTP API 핸들러
|
||||
│ │ └── ServerStatus.kt
|
||||
│ └── resources/
|
||||
│ └── META-INF/ # 모드 메타데이터
|
||||
└── build.gradle.kts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 빌드
|
||||
|
||||
```bash
|
||||
./gradlew build
|
||||
```
|
||||
|
||||
빌드된 JAR: `build/libs/serverstatus-1.0.0.jar`
|
||||
77
ServerStatus/build.gradle.kts
Normal file
77
ServerStatus/build.gradle.kts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
plugins {
|
||||
id("net.neoforged.moddev") version "2.0.80"
|
||||
kotlin("jvm") version "2.0.0"
|
||||
kotlin("plugin.serialization") version "2.0.0"
|
||||
}
|
||||
|
||||
version = project.property("mod_version") as String
|
||||
group = project.property("mod_group_id") as String
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
maven {
|
||||
name = "Kotlin for Forge"
|
||||
url = uri("https://thedarkcolour.github.io/KotlinForForge/")
|
||||
}
|
||||
}
|
||||
|
||||
base {
|
||||
archivesName.set(project.property("mod_id") as String)
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of(21))
|
||||
}
|
||||
}
|
||||
|
||||
neoForge {
|
||||
version = project.property("neo_version") as String
|
||||
|
||||
runs {
|
||||
create("server") {
|
||||
server()
|
||||
}
|
||||
}
|
||||
|
||||
mods {
|
||||
create(project.property("mod_id") as String) {
|
||||
sourceSet(sourceSets.main.get())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Kotlin for Forge (kotlinx-serialization 포함)
|
||||
implementation("thedarkcolour:kotlinforforge-neoforge:${project.property("kotlin_for_forge_version")}")
|
||||
// 외부 의존성 없음 - Java 내장 HttpServer 사용
|
||||
}
|
||||
|
||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<JavaCompile>().configureEach {
|
||||
options.encoding = "UTF-8"
|
||||
}
|
||||
|
||||
tasks.processResources {
|
||||
val replaceProperties = mapOf(
|
||||
"mod_id" to project.property("mod_id"),
|
||||
"mod_name" to project.property("mod_name"),
|
||||
"mod_license" to project.property("mod_license"),
|
||||
"mod_version" to project.property("mod_version"),
|
||||
"mod_authors" to project.property("mod_authors"),
|
||||
"mod_description" to "Provides server status via HTTP API",
|
||||
"neo_version_range" to project.property("neo_version_range"),
|
||||
"loader_version_range" to project.property("loader_version_range"),
|
||||
"minecraft_version_range" to project.property("minecraft_version_range")
|
||||
)
|
||||
inputs.properties(replaceProperties)
|
||||
|
||||
filesMatching(listOf("META-INF/neoforge.mods.toml")) {
|
||||
expand(replaceProperties)
|
||||
}
|
||||
}
|
||||
24
ServerStatus/gradle.properties
Normal file
24
ServerStatus/gradle.properties
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Mod 기본 설정
|
||||
mod_id=serverstatus
|
||||
mod_name=Server Status
|
||||
mod_license=MIT
|
||||
mod_version=1.0.0
|
||||
mod_group_id=co.caadiq.serverstatus
|
||||
mod_authors=Caadiq
|
||||
|
||||
# NeoForge 버전
|
||||
minecraft_version=1.21.1
|
||||
minecraft_version_range=[1.21.1,1.22)
|
||||
neo_version=21.1.77
|
||||
neo_version_range=[21.1.0,)
|
||||
loader_version_range=[4,)
|
||||
|
||||
# Kotlin 버전
|
||||
kotlin_version=2.0.0
|
||||
kotlin_for_forge_version=5.6.0
|
||||
|
||||
# Gradle
|
||||
org.gradle.jvmargs=-Xmx3G
|
||||
org.gradle.daemon=true
|
||||
org.gradle.parallel=true
|
||||
org.gradle.caching=true
|
||||
BIN
ServerStatus/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
ServerStatus/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
ServerStatus/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
ServerStatus/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
39
ServerStatus/gradlew
vendored
Executable file
39
ServerStatus/gradlew
vendored
Executable file
|
|
@ -0,0 +1,39 @@
|
|||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Gradle start up script for POSIX
|
||||
#
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
while [ -h "$app_path" ]; do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in
|
||||
/*) app_path=$link ;;
|
||||
*) app_path=$( dirname "$app_path" )/$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
APP_HOME=$( cd "$( dirname "$app_path" )" && pwd -P )
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=$( basename "$0" )
|
||||
|
||||
# Add default JVM options here
|
||||
DEFAULT_JVM_OPTS="-Xmx64m -Xms64m"
|
||||
|
||||
# Classpath
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# Find Java
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
else
|
||||
JAVACMD=java
|
||||
fi
|
||||
|
||||
# Execute
|
||||
exec "$JAVACMD" $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
16
ServerStatus/settings.gradle.kts
Normal file
16
ServerStatus/settings.gradle.kts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
pluginManagement {
|
||||
repositories {
|
||||
mavenLocal()
|
||||
gradlePluginPortal()
|
||||
maven {
|
||||
name = "NeoForged"
|
||||
url = uri("https://maven.neoforged.net/releases")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0"
|
||||
}
|
||||
|
||||
rootProject.name = "ServerStatus"
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
package co.caadiq.serverstatus
|
||||
|
||||
import co.caadiq.serverstatus.config.ModConfig
|
||||
import co.caadiq.serverstatus.config.PlayerDataStore
|
||||
import co.caadiq.serverstatus.data.PlayerTracker
|
||||
import co.caadiq.serverstatus.data.ServerDataCollector
|
||||
import co.caadiq.serverstatus.data.WorldDataCollector
|
||||
import co.caadiq.serverstatus.data.PlayerStatsCollector
|
||||
import co.caadiq.serverstatus.network.HttpApiServer
|
||||
import net.neoforged.bus.api.IEventBus
|
||||
import net.neoforged.fml.ModContainer
|
||||
import net.neoforged.fml.common.Mod
|
||||
import net.neoforged.fml.event.lifecycle.FMLDedicatedServerSetupEvent
|
||||
import net.neoforged.neoforge.common.NeoForge
|
||||
import org.apache.logging.log4j.LogManager
|
||||
|
||||
/**
|
||||
* 메인 모드 클래스
|
||||
* 서버 상태 정보를 HTTP API로 제공
|
||||
*/
|
||||
@Mod(ServerStatusMod.MOD_ID)
|
||||
class ServerStatusMod(modBus: IEventBus, container: ModContainer) {
|
||||
|
||||
companion object {
|
||||
const val MOD_ID = "serverstatus"
|
||||
val LOGGER = LogManager.getLogger(MOD_ID)
|
||||
|
||||
lateinit var config: ModConfig
|
||||
private set
|
||||
lateinit var playerDataStore: PlayerDataStore
|
||||
private set
|
||||
lateinit var serverDataCollector: ServerDataCollector
|
||||
private set
|
||||
lateinit var worldDataCollector: WorldDataCollector
|
||||
private set
|
||||
lateinit var playerStatsCollector: PlayerStatsCollector
|
||||
private set
|
||||
lateinit var httpApiServer: HttpApiServer
|
||||
private set
|
||||
}
|
||||
|
||||
init {
|
||||
LOGGER.info("[$MOD_ID] 모드 초기화 중...")
|
||||
|
||||
// 설정 초기화
|
||||
config = ModConfig.load()
|
||||
playerDataStore = PlayerDataStore.load()
|
||||
|
||||
// 서버 전용 이벤트 등록
|
||||
modBus.addListener(::onServerSetup)
|
||||
|
||||
// 플레이어 이벤트 리스너 등록
|
||||
NeoForge.EVENT_BUS.register(PlayerTracker)
|
||||
|
||||
LOGGER.info("[$MOD_ID] 모드 초기화 완료")
|
||||
}
|
||||
|
||||
/**
|
||||
* 전용 서버 설정 이벤트
|
||||
*/
|
||||
private fun onServerSetup(event: FMLDedicatedServerSetupEvent) {
|
||||
LOGGER.info("[$MOD_ID] 서버 설정 중...")
|
||||
|
||||
// 데이터 수집기 초기화
|
||||
serverDataCollector = ServerDataCollector()
|
||||
worldDataCollector = WorldDataCollector()
|
||||
playerStatsCollector = PlayerStatsCollector()
|
||||
|
||||
// HTTP API 서버 시작
|
||||
httpApiServer = HttpApiServer(config.httpPort)
|
||||
httpApiServer.start()
|
||||
|
||||
LOGGER.info("[$MOD_ID] HTTP API 서버 시작됨 (포트: ${config.httpPort})")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
package co.caadiq.serverstatus.config
|
||||
|
||||
import co.caadiq.serverstatus.ServerStatusMod
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import net.neoforged.fml.loading.FMLPaths
|
||||
import java.nio.file.Files
|
||||
import kotlin.io.path.exists
|
||||
import kotlin.io.path.readText
|
||||
import kotlin.io.path.writeText
|
||||
|
||||
/**
|
||||
* 모드 설정 클래스
|
||||
* config/serverstatus/ 폴더에 저장
|
||||
*/
|
||||
@Serializable
|
||||
data class ModConfig(
|
||||
val httpPort: Int = 8080
|
||||
) {
|
||||
companion object {
|
||||
private val json = Json {
|
||||
prettyPrint = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
// config/serverstatus/ 폴더에 저장
|
||||
private val configDir = FMLPaths.CONFIGDIR.get().resolve(ServerStatusMod.MOD_ID)
|
||||
private val configPath = configDir.resolve("config.json")
|
||||
|
||||
/**
|
||||
* 설정 파일 로드 (없으면 기본값으로 생성)
|
||||
*/
|
||||
fun load(): ModConfig {
|
||||
return try {
|
||||
if (configPath.exists()) {
|
||||
json.decodeFromString<ModConfig>(configPath.readText())
|
||||
} else {
|
||||
// 기본 설정 생성
|
||||
val default = ModConfig()
|
||||
save(default)
|
||||
default
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ServerStatusMod.LOGGER.error("설정 로드 실패, 기본값 사용: ${e.message}")
|
||||
ModConfig()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 저장
|
||||
*/
|
||||
fun save(config: ModConfig) {
|
||||
try {
|
||||
Files.createDirectories(configDir)
|
||||
configPath.writeText(json.encodeToString(serializer(), config))
|
||||
} catch (e: Exception) {
|
||||
ServerStatusMod.LOGGER.error("설정 저장 실패: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
package co.caadiq.serverstatus.config
|
||||
|
||||
import co.caadiq.serverstatus.ServerStatusMod
|
||||
import co.caadiq.serverstatus.data.PlayerStats
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import kotlinx.serialization.json.Json
|
||||
import net.neoforged.fml.loading.FMLPaths
|
||||
import java.nio.file.Files
|
||||
import kotlin.io.path.exists
|
||||
import kotlin.io.path.readText
|
||||
import kotlin.io.path.writeText
|
||||
|
||||
/**
|
||||
* 플레이어 데이터 저장소
|
||||
* 첫 접속, 마지막 접속, 플레이타임, 저장된 통계 등을 자체 관리
|
||||
* config/serverstatus/ 폴더에 저장
|
||||
*/
|
||||
@Serializable
|
||||
data class PlayerData(
|
||||
val uuid: String,
|
||||
var name: String,
|
||||
var firstJoin: Long = System.currentTimeMillis(),
|
||||
var lastJoin: Long = System.currentTimeMillis(),
|
||||
var lastLeave: Long = 0L,
|
||||
var totalPlayTimeMs: Long = 0L,
|
||||
// 마지막 로그아웃 시 저장된 통계
|
||||
var savedStats: PlayerStats? = null,
|
||||
@Transient
|
||||
var isOnline: Boolean = false // 현재 접속 상태 (저장 안 함)
|
||||
) {
|
||||
/**
|
||||
* 현재 세션 플레이타임 (ms) - 접속 중일 때만 계산
|
||||
*/
|
||||
fun getCurrentSessionMs(): Long {
|
||||
return if (isOnline) {
|
||||
System.currentTimeMillis() - lastJoin
|
||||
} else {
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 실시간 총 플레이타임 (ms) - 누적 + 현재 세션
|
||||
*/
|
||||
fun getRealTimeTotalMs(): Long {
|
||||
return totalPlayTimeMs + getCurrentSessionMs()
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class PlayerDataStore(
|
||||
val players: MutableMap<String, PlayerData> = mutableMapOf()
|
||||
) {
|
||||
companion object {
|
||||
private val json = Json {
|
||||
prettyPrint = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
// config/serverstatus/ 폴더에 저장
|
||||
private val configDir = FMLPaths.CONFIGDIR.get().resolve(ServerStatusMod.MOD_ID)
|
||||
private val dataPath = configDir.resolve("players.json")
|
||||
|
||||
/**
|
||||
* 플레이어 데이터 로드
|
||||
*/
|
||||
fun load(): PlayerDataStore {
|
||||
return try {
|
||||
if (dataPath.exists()) {
|
||||
json.decodeFromString<PlayerDataStore>(dataPath.readText())
|
||||
} else {
|
||||
PlayerDataStore()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ServerStatusMod.LOGGER.error("플레이어 데이터 로드 실패: ${e.message}")
|
||||
PlayerDataStore()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 저장
|
||||
*/
|
||||
fun save() {
|
||||
try {
|
||||
Files.createDirectories(configDir)
|
||||
dataPath.writeText(json.encodeToString(serializer(), this))
|
||||
} catch (e: Exception) {
|
||||
ServerStatusMod.LOGGER.error("플레이어 데이터 저장 실패: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 플레이어 입장 처리
|
||||
*/
|
||||
fun onPlayerJoin(uuid: String, name: String) {
|
||||
val now = System.currentTimeMillis()
|
||||
val player = players.getOrPut(uuid) {
|
||||
PlayerData(uuid = uuid, name = name, firstJoin = now)
|
||||
}
|
||||
player.name = name
|
||||
player.lastJoin = now
|
||||
player.isOnline = true
|
||||
save()
|
||||
}
|
||||
|
||||
/**
|
||||
* 플레이어 퇴장 처리 (통계 저장 포함)
|
||||
*/
|
||||
fun onPlayerLeave(uuid: String, stats: PlayerStats?) {
|
||||
val now = System.currentTimeMillis()
|
||||
players[uuid]?.let { player ->
|
||||
player.lastLeave = now
|
||||
// 이번 세션 플레이타임을 누적에 추가
|
||||
player.totalPlayTimeMs += (now - player.lastJoin)
|
||||
player.isOnline = false
|
||||
// 통계 저장
|
||||
if (stats != null) {
|
||||
player.savedStats = stats
|
||||
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] 플레이어 통계 저장됨: ${player.name}")
|
||||
}
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 플레이어 정보 조회
|
||||
*/
|
||||
fun getPlayer(uuid: String): PlayerData? = players[uuid]
|
||||
|
||||
/**
|
||||
* 전체 플레이어 목록 조회
|
||||
*/
|
||||
fun getAllPlayers(): List<PlayerData> = players.values.toList().sortedBy { it.name }
|
||||
|
||||
/**
|
||||
* 플레이어 온라인 상태 확인
|
||||
*/
|
||||
fun isPlayerOnline(uuid: String): Boolean = players[uuid]?.isOnline ?: false
|
||||
|
||||
/**
|
||||
* 저장된 통계 조회
|
||||
*/
|
||||
fun getSavedStats(uuid: String): PlayerStats? = players[uuid]?.savedStats
|
||||
}
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
package co.caadiq.serverstatus.data
|
||||
|
||||
import co.caadiq.serverstatus.ServerStatusMod
|
||||
import java.util.UUID
|
||||
import kotlinx.serialization.Serializable
|
||||
import net.minecraft.core.registries.BuiltInRegistries
|
||||
import net.minecraft.stats.Stats
|
||||
import net.neoforged.neoforge.server.ServerLifecycleHooks
|
||||
|
||||
/** 플레이어 통계 수집기 모든 블록과 엔티티의 통계를 동적으로 수집 */
|
||||
class PlayerStatsCollector {
|
||||
|
||||
/** 플레이어 통계 수집 */
|
||||
fun collectStats(uuid: String): PlayerStats? {
|
||||
val server = ServerLifecycleHooks.getCurrentServer() ?: return null
|
||||
|
||||
return try {
|
||||
val playerUuid = UUID.fromString(uuid)
|
||||
val player = server.playerList.getPlayer(playerUuid) ?: return null
|
||||
val statsManager = server.playerList.getPlayerStats(player)
|
||||
|
||||
// 일반 통계
|
||||
val general =
|
||||
GeneralStats(
|
||||
playTime = statsManager.getValue(Stats.CUSTOM.get(Stats.PLAY_TIME)),
|
||||
deaths = statsManager.getValue(Stats.CUSTOM.get(Stats.DEATHS)),
|
||||
mobKills = statsManager.getValue(Stats.CUSTOM.get(Stats.MOB_KILLS)),
|
||||
playerKills =
|
||||
statsManager.getValue(Stats.CUSTOM.get(Stats.PLAYER_KILLS)),
|
||||
damageDealt =
|
||||
statsManager.getValue(Stats.CUSTOM.get(Stats.DAMAGE_DEALT)),
|
||||
damageTaken =
|
||||
statsManager.getValue(Stats.CUSTOM.get(Stats.DAMAGE_TAKEN)),
|
||||
jumps = statsManager.getValue(Stats.CUSTOM.get(Stats.JUMP)),
|
||||
distanceWalked =
|
||||
statsManager.getValue(Stats.CUSTOM.get(Stats.WALK_ONE_CM)) /
|
||||
100,
|
||||
distanceFlown =
|
||||
(statsManager.getValue(Stats.CUSTOM.get(Stats.FLY_ONE_CM)) +
|
||||
statsManager.getValue(
|
||||
Stats.CUSTOM.get(Stats.AVIATE_ONE_CM)
|
||||
)) / 100,
|
||||
distanceSwum =
|
||||
statsManager.getValue(Stats.CUSTOM.get(Stats.SWIM_ONE_CM)) / 100
|
||||
)
|
||||
|
||||
// 아이템 통계 (블록/아이템별로 채굴, 설치, 획득, 제작)
|
||||
val itemStats = mutableMapOf<String, ItemStat>()
|
||||
|
||||
// 블록 채굴 통계
|
||||
BuiltInRegistries.BLOCK.forEach { block ->
|
||||
val mined = statsManager.getValue(Stats.BLOCK_MINED.get(block))
|
||||
if (mined > 0) {
|
||||
val blockId = BuiltInRegistries.BLOCK.getKey(block)?.path ?: return@forEach
|
||||
itemStats.getOrPut(blockId) { ItemStat() }.mined = mined
|
||||
}
|
||||
}
|
||||
|
||||
// 아이템 획득 통계
|
||||
BuiltInRegistries.ITEM.forEach { item ->
|
||||
val picked = statsManager.getValue(Stats.ITEM_PICKED_UP.get(item))
|
||||
if (picked > 0) {
|
||||
val itemId = BuiltInRegistries.ITEM.getKey(item)?.path ?: return@forEach
|
||||
itemStats.getOrPut(itemId) { ItemStat() }.pickedUp = picked
|
||||
}
|
||||
}
|
||||
|
||||
// 아이템 사용(설치) 통계
|
||||
BuiltInRegistries.ITEM.forEach { item ->
|
||||
val used = statsManager.getValue(Stats.ITEM_USED.get(item))
|
||||
if (used > 0) {
|
||||
val itemId = BuiltInRegistries.ITEM.getKey(item)?.path ?: return@forEach
|
||||
itemStats.getOrPut(itemId) { ItemStat() }.used = used
|
||||
}
|
||||
}
|
||||
|
||||
// 아이템 제작 통계
|
||||
BuiltInRegistries.ITEM.forEach { item ->
|
||||
val crafted = statsManager.getValue(Stats.ITEM_CRAFTED.get(item))
|
||||
if (crafted > 0) {
|
||||
val itemId = BuiltInRegistries.ITEM.getKey(item)?.path ?: return@forEach
|
||||
itemStats.getOrPut(itemId) { ItemStat() }.crafted = crafted
|
||||
}
|
||||
}
|
||||
|
||||
// 몹 통계 (죽인것, 죽은것)
|
||||
val mobStats = mutableMapOf<String, MobStat>()
|
||||
|
||||
// 엔티티 처치 통계
|
||||
BuiltInRegistries.ENTITY_TYPE.forEach { entityType ->
|
||||
try {
|
||||
val killed = statsManager.getValue(Stats.ENTITY_KILLED.get(entityType))
|
||||
if (killed > 0) {
|
||||
val entityId =
|
||||
BuiltInRegistries.ENTITY_TYPE.getKey(entityType)?.path
|
||||
?: return@forEach
|
||||
mobStats.getOrPut(entityId) { MobStat() }.killed = killed
|
||||
}
|
||||
} catch (e: Exception) {}
|
||||
}
|
||||
|
||||
// 엔티티에게 죽은 통계
|
||||
BuiltInRegistries.ENTITY_TYPE.forEach { entityType ->
|
||||
try {
|
||||
val killedBy = statsManager.getValue(Stats.ENTITY_KILLED_BY.get(entityType))
|
||||
if (killedBy > 0) {
|
||||
val entityId =
|
||||
BuiltInRegistries.ENTITY_TYPE.getKey(entityType)?.path
|
||||
?: return@forEach
|
||||
mobStats.getOrPut(entityId) { MobStat() }.killedBy = killedBy
|
||||
}
|
||||
} catch (e: Exception) {}
|
||||
}
|
||||
|
||||
// Map을 직렬화 가능한 형태로 변환
|
||||
val itemStatsSerializable =
|
||||
itemStats.mapValues { (_, stat) ->
|
||||
mapOf(
|
||||
"mined" to stat.mined,
|
||||
"used" to stat.used,
|
||||
"pickedUp" to stat.pickedUp,
|
||||
"crafted" to stat.crafted
|
||||
)
|
||||
}
|
||||
|
||||
val mobStatsSerializable =
|
||||
mobStats.mapValues { (_, stat) ->
|
||||
mapOf("killed" to stat.killed, "killedBy" to stat.killedBy)
|
||||
}
|
||||
|
||||
PlayerStats(
|
||||
uuid = uuid,
|
||||
general = general,
|
||||
items = itemStatsSerializable,
|
||||
mobs = mobStatsSerializable
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
ServerStatusMod.LOGGER.error("플레이어 통계 수집 오류: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** 플레이어 도전 과제 수집 (추후 구현) */
|
||||
fun collectAdvancements(uuid: String): AdvancementsInfo? {
|
||||
// NeoForge 1.21.1 API 호환성 문제로 추후 구현
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 내부 헬퍼 클래스
|
||||
class ItemStat {
|
||||
var mined: Int = 0
|
||||
var used: Int = 0
|
||||
var pickedUp: Int = 0
|
||||
var crafted: Int = 0
|
||||
}
|
||||
|
||||
class MobStat {
|
||||
var killed: Int = 0
|
||||
var killedBy: Int = 0
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class PlayerStats(
|
||||
val uuid: String,
|
||||
val general: GeneralStats,
|
||||
val items: Map<String, Map<String, Int>>, // 아이템 ID -> {mined, used, pickedUp, crafted}
|
||||
val mobs: Map<String, Map<String, Int>> // 엔티티 ID -> {killed, killedBy}
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GeneralStats(
|
||||
val playTime: Int, // 틱
|
||||
val deaths: Int,
|
||||
val mobKills: Int,
|
||||
val playerKills: Int,
|
||||
val damageDealt: Int,
|
||||
val damageTaken: Int,
|
||||
val jumps: Int,
|
||||
val distanceWalked: Int, // 미터
|
||||
val distanceFlown: Int,
|
||||
val distanceSwum: Int
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AdvancementsInfo(
|
||||
val uuid: String,
|
||||
val total: Int,
|
||||
val completed: Int,
|
||||
val inProgress: Int,
|
||||
val completedList: List<AdvancementInfo>,
|
||||
val inProgressList: List<AdvancementInfo>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AdvancementInfo(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val completed: Boolean
|
||||
)
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package co.caadiq.serverstatus.data
|
||||
|
||||
import co.caadiq.serverstatus.ServerStatusMod
|
||||
import net.neoforged.bus.api.SubscribeEvent
|
||||
import net.neoforged.neoforge.event.entity.player.PlayerEvent
|
||||
|
||||
/**
|
||||
* 플레이어 이벤트 추적기
|
||||
* 입장/퇴장 이벤트를 감지하여 데이터 저장
|
||||
*/
|
||||
object PlayerTracker {
|
||||
|
||||
/**
|
||||
* 플레이어 입장 이벤트
|
||||
*/
|
||||
@SubscribeEvent
|
||||
fun onPlayerJoin(event: PlayerEvent.PlayerLoggedInEvent) {
|
||||
val player = event.entity
|
||||
val uuid = player.stringUUID
|
||||
val name = player.name.string
|
||||
|
||||
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] 플레이어 입장: $name ($uuid)")
|
||||
|
||||
// 플레이어 데이터 저장
|
||||
ServerStatusMod.playerDataStore.onPlayerJoin(uuid, name)
|
||||
}
|
||||
|
||||
/**
|
||||
* 플레이어 퇴장 이벤트
|
||||
*/
|
||||
@SubscribeEvent
|
||||
fun onPlayerLeave(event: PlayerEvent.PlayerLoggedOutEvent) {
|
||||
val player = event.entity
|
||||
val uuid = player.stringUUID
|
||||
val name = player.name.string
|
||||
|
||||
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] 플레이어 퇴장: $name ($uuid)")
|
||||
|
||||
// 통계 수집 후 저장
|
||||
val stats = try {
|
||||
ServerStatusMod.playerStatsCollector.collectStats(uuid)
|
||||
} catch (e: Exception) {
|
||||
ServerStatusMod.LOGGER.error("[${ServerStatusMod.MOD_ID}] 통계 수집 실패: ${e.message}")
|
||||
null
|
||||
}
|
||||
|
||||
// 플레이어 데이터 및 통계 저장
|
||||
ServerStatusMod.playerDataStore.onPlayerLeave(uuid, stats)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
package co.caadiq.serverstatus.data
|
||||
|
||||
import co.caadiq.serverstatus.ServerStatusMod
|
||||
import co.caadiq.serverstatus.network.ModInfo
|
||||
import co.caadiq.serverstatus.network.OnlinePlayer
|
||||
import co.caadiq.serverstatus.network.PlayersInfo
|
||||
import co.caadiq.serverstatus.network.ServerStatus
|
||||
import net.minecraft.server.MinecraftServer
|
||||
import net.minecraft.world.Difficulty
|
||||
import net.neoforged.fml.ModList
|
||||
import net.neoforged.neoforge.server.ServerLifecycleHooks
|
||||
|
||||
/** 서버 데이터 수집기 서버 상태, 버전, 난이도, 게임 규칙 등을 수집 */
|
||||
class ServerDataCollector {
|
||||
|
||||
// 서버 시작 시간 기록
|
||||
private val serverStartTime = System.currentTimeMillis()
|
||||
|
||||
/** 서버 인스턴스 가져오기 */
|
||||
private fun getServer(): MinecraftServer? = ServerLifecycleHooks.getCurrentServer()
|
||||
|
||||
/** 전체 서버 상태 수집 */
|
||||
fun collectStatus(): ServerStatus {
|
||||
val server = getServer()
|
||||
|
||||
return ServerStatus(
|
||||
online = server != null,
|
||||
version = getMinecraftVersion(),
|
||||
modLoader = getModLoaderInfo(),
|
||||
difficulty = getDifficulty(),
|
||||
uptimeMinutes = getUptimeMinutes(),
|
||||
players = getPlayersInfo(),
|
||||
gameRules = getGameRules(),
|
||||
mods = getModsList()
|
||||
)
|
||||
}
|
||||
|
||||
/** 마인크래프트 버전 */
|
||||
private fun getMinecraftVersion(): String {
|
||||
return try {
|
||||
net.minecraft.SharedConstants.getCurrentVersion().name
|
||||
} catch (e: Exception) {
|
||||
"Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
/** 모드 로더 정보 */
|
||||
private fun getModLoaderInfo(): String {
|
||||
return try {
|
||||
val neoforge = ModList.get().getModContainerById("neoforge")
|
||||
if (neoforge.isPresent) {
|
||||
"NeoForge ${neoforge.get().modInfo.version}"
|
||||
} else {
|
||||
"NeoForge"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
"NeoForge"
|
||||
}
|
||||
}
|
||||
|
||||
/** 게임 난이도 */
|
||||
private fun getDifficulty(): String {
|
||||
val server = getServer() ?: return "알 수 없음"
|
||||
return when (server.worldData.difficulty) {
|
||||
Difficulty.PEACEFUL -> "평화로움"
|
||||
Difficulty.EASY -> "쉬움"
|
||||
Difficulty.NORMAL -> "보통"
|
||||
Difficulty.HARD -> "어려움"
|
||||
else -> "알 수 없음"
|
||||
}
|
||||
}
|
||||
|
||||
/** 서버 가동 시간 (분) */
|
||||
private fun getUptimeMinutes(): Long {
|
||||
return (System.currentTimeMillis() - serverStartTime) / 1000 / 60
|
||||
}
|
||||
|
||||
/** 플레이어 정보 */
|
||||
private fun getPlayersInfo(): PlayersInfo {
|
||||
val server = getServer() ?: return PlayersInfo(0, 20, emptyList())
|
||||
val playerList = server.playerList
|
||||
|
||||
val onlinePlayers =
|
||||
playerList.players.map { player ->
|
||||
OnlinePlayer(
|
||||
name = player.name.string,
|
||||
uuid = player.stringUUID,
|
||||
isOp = playerList.isOp(player.gameProfile)
|
||||
)
|
||||
}
|
||||
|
||||
return PlayersInfo(
|
||||
current = playerList.playerCount,
|
||||
max = playerList.maxPlayers,
|
||||
online = onlinePlayers
|
||||
)
|
||||
}
|
||||
|
||||
/** 게임 규칙 목록 */
|
||||
private fun getGameRules(): Map<String, Boolean> {
|
||||
val server = getServer() ?: return emptyMap()
|
||||
val gameRules = mutableMapOf<String, Boolean>()
|
||||
|
||||
try {
|
||||
val level = server.overworld() ?: return emptyMap()
|
||||
val rules = level.gameRules
|
||||
|
||||
// Boolean 타입 게임 규칙만 수집
|
||||
net.minecraft.world.level.GameRules.visitGameRuleTypes(
|
||||
object : net.minecraft.world.level.GameRules.GameRuleTypeVisitor {
|
||||
override fun <T : net.minecraft.world.level.GameRules.Value<T>> visit(
|
||||
key: net.minecraft.world.level.GameRules.Key<T>,
|
||||
type: net.minecraft.world.level.GameRules.Type<T>
|
||||
) {
|
||||
try {
|
||||
val value = rules.getRule(key)
|
||||
if (value is net.minecraft.world.level.GameRules.BooleanValue) {
|
||||
gameRules[key.id] = value.get()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// 개별 규칙 수집 실패 시 무시
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
ServerStatusMod.LOGGER.debug("게임 규칙 수집 중 오류 (서버 시작 중일 수 있음): ${e.message}")
|
||||
}
|
||||
|
||||
return gameRules
|
||||
}
|
||||
|
||||
/** 설치된 모드 목록 */
|
||||
private fun getModsList(): List<ModInfo> {
|
||||
return try {
|
||||
ModList.get().mods.filter { it.modId != "minecraft" && it.modId != "neoforge" }.map {
|
||||
ModInfo(it.modId, it.version.toString())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
package co.caadiq.serverstatus.data
|
||||
|
||||
import co.caadiq.serverstatus.ServerStatusMod
|
||||
import kotlinx.serialization.Serializable
|
||||
import net.minecraft.world.level.Level
|
||||
import net.neoforged.neoforge.server.ServerLifecycleHooks
|
||||
|
||||
/**
|
||||
* 월드 정보 수집기
|
||||
*/
|
||||
class WorldDataCollector {
|
||||
|
||||
/**
|
||||
* 모든 월드 정보 수집
|
||||
*/
|
||||
fun collectWorlds(): List<WorldInfo> {
|
||||
val server = ServerLifecycleHooks.getCurrentServer() ?: return emptyList()
|
||||
|
||||
return try {
|
||||
server.allLevels.map { level ->
|
||||
val dimensionKey = level.dimension().location().toString()
|
||||
val worldData = level.levelData
|
||||
|
||||
// 해당 월드에 접속 중인 플레이어 목록 (위치 포함)
|
||||
val playersInWorld = level.players().map { player ->
|
||||
PlayerInWorld(
|
||||
uuid = player.uuid.toString(),
|
||||
name = player.name.string,
|
||||
x = player.x.toInt(),
|
||||
y = player.y.toInt(),
|
||||
z = player.z.toInt()
|
||||
)
|
||||
}
|
||||
|
||||
WorldInfo(
|
||||
dimension = dimensionKey,
|
||||
displayName = getDimensionDisplayName(dimensionKey),
|
||||
weather = getWeatherInfo(level),
|
||||
time = TimeInfo(
|
||||
dayTime = (worldData.dayTime % 24000L),
|
||||
day = (worldData.dayTime / 24000L).toInt() + 1
|
||||
),
|
||||
playerCount = level.players().size,
|
||||
players = playersInWorld
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ServerStatusMod.LOGGER.error("월드 정보 수집 오류: ${e.message}")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDimensionDisplayName(key: String): String {
|
||||
return when (key) {
|
||||
"minecraft:overworld" -> "오버월드"
|
||||
"minecraft:the_nether" -> "네더"
|
||||
"minecraft:the_end" -> "엔드"
|
||||
else -> key.substringAfter(":")
|
||||
}
|
||||
}
|
||||
|
||||
private fun getWeatherInfo(level: Level): WeatherInfo {
|
||||
val isRaining = level.isRaining
|
||||
val isThundering = level.isThundering
|
||||
|
||||
val type = when {
|
||||
isThundering -> "thunderstorm"
|
||||
isRaining -> "rain"
|
||||
else -> "clear"
|
||||
}
|
||||
|
||||
val displayName = when (type) {
|
||||
"thunderstorm" -> "뇌우"
|
||||
"rain" -> "비"
|
||||
else -> "맑음"
|
||||
}
|
||||
|
||||
return WeatherInfo(type, displayName)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class WorldInfo(
|
||||
val dimension: String,
|
||||
val displayName: String,
|
||||
val weather: WeatherInfo,
|
||||
val time: TimeInfo,
|
||||
val playerCount: Int,
|
||||
val players: List<PlayerInWorld>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PlayerInWorld(
|
||||
val uuid: String,
|
||||
val name: String,
|
||||
val x: Int,
|
||||
val y: Int,
|
||||
val z: Int
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class WeatherInfo(
|
||||
val type: String,
|
||||
val displayName: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TimeInfo(
|
||||
val dayTime: Long, // 0-24000 (틱)
|
||||
val day: Int // 몇 일차
|
||||
)
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
package co.caadiq.serverstatus.network
|
||||
|
||||
import co.caadiq.serverstatus.ServerStatusMod
|
||||
import co.caadiq.serverstatus.data.AdvancementsInfo
|
||||
import co.caadiq.serverstatus.data.PlayerStats
|
||||
import co.caadiq.serverstatus.data.WorldInfo
|
||||
import com.sun.net.httpserver.HttpExchange
|
||||
import com.sun.net.httpserver.HttpHandler
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.net.InetSocketAddress
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* HTTP API 서버
|
||||
* Java 내장 HttpServer 사용 - 외부 의존성 없음
|
||||
*/
|
||||
class HttpApiServer(private val port: Int) {
|
||||
|
||||
private val json = Json {
|
||||
prettyPrint = false
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
private var server: HttpServer? = null
|
||||
|
||||
/**
|
||||
* 서버 시작
|
||||
*/
|
||||
fun start() {
|
||||
try {
|
||||
server = HttpServer.create(InetSocketAddress(port), 0)
|
||||
server?.executor = Executors.newFixedThreadPool(4)
|
||||
|
||||
// 엔드포인트 등록
|
||||
server?.createContext("/status", StatusHandler())
|
||||
server?.createContext("/players", PlayersHandler())
|
||||
server?.createContext("/player", PlayerHandler())
|
||||
server?.createContext("/worlds", WorldsHandler())
|
||||
|
||||
server?.start()
|
||||
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] HTTP API 서버 시작: http://0.0.0.0:$port")
|
||||
} catch (e: Exception) {
|
||||
ServerStatusMod.LOGGER.error("[${ServerStatusMod.MOD_ID}] HTTP 서버 시작 실패: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버 종료
|
||||
*/
|
||||
fun stop() {
|
||||
server?.stop(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* CORS 헤더 추가 및 응답 전송
|
||||
*/
|
||||
private fun sendJsonResponse(exchange: HttpExchange, response: String, statusCode: Int = 200) {
|
||||
exchange.responseHeaders.add("Content-Type", "application/json; charset=utf-8")
|
||||
exchange.responseHeaders.add("Access-Control-Allow-Origin", "*")
|
||||
exchange.responseHeaders.add("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
exchange.responseHeaders.add("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
val bytes = response.toByteArray(StandardCharsets.UTF_8)
|
||||
exchange.sendResponseHeaders(statusCode, bytes.size.toLong())
|
||||
exchange.responseBody.use { it.write(bytes) }
|
||||
}
|
||||
|
||||
/**
|
||||
* PlayerData를 PlayerDetail로 변환
|
||||
*/
|
||||
private fun toPlayerDetail(player: co.caadiq.serverstatus.config.PlayerData): PlayerDetail {
|
||||
return PlayerDetail(
|
||||
uuid = player.uuid,
|
||||
name = player.name,
|
||||
firstJoin = player.firstJoin,
|
||||
lastJoin = player.lastJoin,
|
||||
lastLeave = player.lastLeave,
|
||||
isOnline = player.isOnline,
|
||||
currentSessionMs = player.getCurrentSessionMs(),
|
||||
totalPlayTimeMs = player.totalPlayTimeMs
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /status - 서버 상태
|
||||
*/
|
||||
private inner class StatusHandler : HttpHandler {
|
||||
override fun handle(exchange: HttpExchange) {
|
||||
if (exchange.requestMethod == "OPTIONS") {
|
||||
sendJsonResponse(exchange, "", 204)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val status = ServerStatusMod.serverDataCollector.collectStatus()
|
||||
val response = json.encodeToString(status)
|
||||
sendJsonResponse(exchange, response)
|
||||
} catch (e: Exception) {
|
||||
ServerStatusMod.LOGGER.error("상태 조회 오류: ${e.message}")
|
||||
sendJsonResponse(exchange, """{"error": "Internal server error"}""", 500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /players - 전체 플레이어 목록
|
||||
*/
|
||||
private inner class PlayersHandler : HttpHandler {
|
||||
override fun handle(exchange: HttpExchange) {
|
||||
if (exchange.requestMethod == "OPTIONS") {
|
||||
sendJsonResponse(exchange, "", 204)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val players = ServerStatusMod.playerDataStore.getAllPlayers().map { toPlayerDetail(it) }
|
||||
val response = json.encodeToString(AllPlayersResponse(players))
|
||||
sendJsonResponse(exchange, response)
|
||||
} catch (e: Exception) {
|
||||
ServerStatusMod.LOGGER.error("플레이어 목록 조회 오류: ${e.message}")
|
||||
sendJsonResponse(exchange, """{"error": "Internal server error"}""", 500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /player/{uuid} - 특정 플레이어 정보
|
||||
* GET /player/{uuid}/stats - 플레이어 통계
|
||||
* GET /player/{uuid}/advancements - 플레이어 도전과제
|
||||
*/
|
||||
private inner class PlayerHandler : HttpHandler {
|
||||
override fun handle(exchange: HttpExchange) {
|
||||
if (exchange.requestMethod == "OPTIONS") {
|
||||
sendJsonResponse(exchange, "", 204)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val path = exchange.requestURI.path
|
||||
val parts = path.removePrefix("/player/").split("/")
|
||||
val uuid = parts.getOrNull(0)?.takeIf { it.isNotBlank() }
|
||||
val subPath = parts.getOrNull(1)
|
||||
|
||||
if (uuid == null) {
|
||||
sendJsonResponse(exchange, """{"error": "UUID required"}""", 400)
|
||||
return
|
||||
}
|
||||
|
||||
when (subPath) {
|
||||
"stats" -> {
|
||||
// 온라인이면 실시간 통계, 오프라인이면 저장된 통계
|
||||
val isOnline = ServerStatusMod.playerDataStore.isPlayerOnline(uuid)
|
||||
val stats = if (isOnline) {
|
||||
ServerStatusMod.playerStatsCollector.collectStats(uuid)
|
||||
} else {
|
||||
ServerStatusMod.playerDataStore.getSavedStats(uuid)
|
||||
}
|
||||
if (stats != null) {
|
||||
sendJsonResponse(exchange, json.encodeToString(stats))
|
||||
} else {
|
||||
sendJsonResponse(exchange, """{"error": "Player stats not available"}""", 404)
|
||||
}
|
||||
}
|
||||
"advancements" -> {
|
||||
val advancements = ServerStatusMod.playerStatsCollector.collectAdvancements(uuid)
|
||||
if (advancements != null) {
|
||||
sendJsonResponse(exchange, json.encodeToString(advancements))
|
||||
} else {
|
||||
sendJsonResponse(exchange, """{"error": "Player not online or not found"}""", 404)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// 기본: 플레이어 정보
|
||||
val player = ServerStatusMod.playerDataStore.getPlayer(uuid)
|
||||
if (player != null) {
|
||||
sendJsonResponse(exchange, json.encodeToString(toPlayerDetail(player)))
|
||||
} else {
|
||||
sendJsonResponse(exchange, """{"error": "Player not found"}""", 404)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ServerStatusMod.LOGGER.error("플레이어 조회 오류: ${e.message}")
|
||||
sendJsonResponse(exchange, """{"error": "Internal server error"}""", 500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /worlds - 월드 정보
|
||||
*/
|
||||
private inner class WorldsHandler : HttpHandler {
|
||||
override fun handle(exchange: HttpExchange) {
|
||||
if (exchange.requestMethod == "OPTIONS") {
|
||||
sendJsonResponse(exchange, "", 204)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val worlds = ServerStatusMod.worldDataCollector.collectWorlds()
|
||||
val response = json.encodeToString(WorldsResponse(worlds))
|
||||
sendJsonResponse(exchange, response)
|
||||
} catch (e: Exception) {
|
||||
ServerStatusMod.LOGGER.error("월드 조회 오류: ${e.message}")
|
||||
sendJsonResponse(exchange, """{"error": "Internal server error"}""", 500)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class WorldsResponse(
|
||||
val worlds: List<WorldInfo>
|
||||
)
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
package co.caadiq.serverstatus.network
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* 서버 상태
|
||||
*/
|
||||
@Serializable
|
||||
data class ServerStatus(
|
||||
val online: Boolean,
|
||||
val version: String,
|
||||
val modLoader: String,
|
||||
val difficulty: String,
|
||||
val uptimeMinutes: Long,
|
||||
val players: PlayersInfo,
|
||||
val gameRules: Map<String, Boolean>,
|
||||
val mods: List<ModInfo>
|
||||
)
|
||||
|
||||
/**
|
||||
* 플레이어 정보
|
||||
*/
|
||||
@Serializable
|
||||
data class PlayersInfo(
|
||||
val current: Int,
|
||||
val max: Int,
|
||||
val online: List<OnlinePlayer>
|
||||
)
|
||||
|
||||
/**
|
||||
* 접속 중인 플레이어
|
||||
*/
|
||||
@Serializable
|
||||
data class OnlinePlayer(
|
||||
val name: String,
|
||||
val uuid: String,
|
||||
val isOp: Boolean
|
||||
)
|
||||
|
||||
/**
|
||||
* 모드 정보
|
||||
*/
|
||||
@Serializable
|
||||
data class ModInfo(
|
||||
val id: String,
|
||||
val version: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 플레이어 상세 정보 (전체 플레이어용)
|
||||
*/
|
||||
@Serializable
|
||||
data class PlayerDetail(
|
||||
val uuid: String,
|
||||
val name: String,
|
||||
val firstJoin: Long,
|
||||
val lastJoin: Long,
|
||||
val lastLeave: Long,
|
||||
val isOnline: Boolean,
|
||||
val currentSessionMs: Long, // 현재 세션 플레이타임 (접속 중일 때만)
|
||||
val totalPlayTimeMs: Long // 누적 플레이타임 (저장된 값)
|
||||
)
|
||||
|
||||
/**
|
||||
* 전체 플레이어 목록 응답
|
||||
*/
|
||||
@Serializable
|
||||
data class AllPlayersResponse(
|
||||
val players: List<PlayerDetail>
|
||||
)
|
||||
|
||||
/**
|
||||
* 플레이어 입장 이벤트
|
||||
*/
|
||||
@Serializable
|
||||
data class PlayerJoinEvent(
|
||||
val uuid: String,
|
||||
val name: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 플레이어 퇴장 이벤트
|
||||
*/
|
||||
@Serializable
|
||||
data class PlayerLeaveEvent(
|
||||
val uuid: String,
|
||||
val name: String
|
||||
)
|
||||
32
ServerStatus/src/main/resources/META-INF/neoforge.mods.toml
Normal file
32
ServerStatus/src/main/resources/META-INF/neoforge.mods.toml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
modLoader="kotlinforforge"
|
||||
loaderVersion="${loader_version_range}"
|
||||
license="${mod_license}"
|
||||
issueTrackerURL=""
|
||||
|
||||
[[mods]]
|
||||
modId="${mod_id}"
|
||||
version="${mod_version}"
|
||||
displayName="${mod_name}"
|
||||
authors="${mod_authors}"
|
||||
description='''${mod_description}'''
|
||||
|
||||
[[dependencies.${mod_id}]]
|
||||
modId="neoforge"
|
||||
type="required"
|
||||
versionRange="${neo_version_range}"
|
||||
ordering="NONE"
|
||||
side="SERVER"
|
||||
|
||||
[[dependencies.${mod_id}]]
|
||||
modId="minecraft"
|
||||
type="required"
|
||||
versionRange="${minecraft_version_range}"
|
||||
ordering="NONE"
|
||||
side="SERVER"
|
||||
|
||||
[[dependencies.${mod_id}]]
|
||||
modId="kotlinforforge"
|
||||
type="required"
|
||||
versionRange="[5.0.0,)"
|
||||
ordering="NONE"
|
||||
side="SERVER"
|
||||
Loading…
Add table
Reference in a new issue