diff --git a/IconExporter/.gitignore b/IconExporter/.gitignore new file mode 100644 index 0000000..ba2f60c --- /dev/null +++ b/IconExporter/.gitignore @@ -0,0 +1,19 @@ +# Gradle +.gradle/ +build/ +bin/ + +# IDE +.idea/ +*.iml +*.ipr +*.iws +.eclipse/ +.settings/ +.project +.classpath +.kotlin/ + +# 런타임 생성 파일 +run/ +logs/ diff --git a/IconExporter/README.md b/IconExporter/README.md new file mode 100644 index 0000000..14869b3 --- /dev/null +++ b/IconExporter/README.md @@ -0,0 +1,110 @@ +# 🖼️ IconExporter + +마인크래프트 아이템/블록 아이콘을 PNG 파일로 추출하는 클라이언트 전용 NeoForge 모드입니다. + +![NeoForge](https://img.shields.io/badge/NeoForge-21.1.194-orange?logo=curseforge) +![Minecraft](https://img.shields.io/badge/Minecraft-1.21.1-green) +![Kotlin](https://img.shields.io/badge/Kotlin-2.0-7F52FF?logo=kotlin) + +--- + +## ✨ 주요 기능 + +- 🎨 **고품질 아이콘 추출** - 게임 내 실제 렌더링과 동일한 품질 +- 📦 **모드 아이템 지원** - 바닐라 + 설치된 모든 모드 아이템 추출 +- ⚡ **대량 추출** - 틱 기반 큐 시스템으로 게임 프리즈 없이 대량 처리 +- 🖼️ **투명 배경** - PNG 알파 채널 지원 +- 📏 **크기 조절** - 16x16 ~ 512x512 커스텀 크기 + +--- + +## 💻 사용법 + +### 커맨드 목록 + +| 커맨드 | 설명 | +| ------------------------------------ | --------------------------- | +| `/iconexport all` | 모든 아이템 추출 | +| `/iconexport mod ` | 특정 모드 아이템만 추출 | +| `/iconexport id ` | 단일 아이템 추출 | +| `/iconexport size <16-512>` | 아이콘 크기 설정 (기본: 64) | +| `/iconexport overwrite ` | 덮어쓰기 설정 | +| `/iconexport listmods` | 등록된 모드 목록 확인 | +| `/iconexport status` | 현재 상태 확인 | +| `/iconexport cancel` | 진행 중인 작업 취소 | + +### 예시 + +```bash +# Create 모드 아이템 추출 (64x64) +/iconexport mod create + +# 128x128 크기로 변경 후 바닐라 아이템 추출 +/iconexport size 128 +/iconexport mod minecraft +``` + +--- + +## 📁 출력 구조 + +추출된 아이콘은 `.minecraft/icons/` 폴더에 저장됩니다. + +``` +.minecraft/ +└── icons/ + ├── minecraft/ + │ ├── diamond.png + │ ├── iron_ingot.png + │ └── ... + └── create/ + ├── mechanical_press.png + ├── brass_ingot.png + └── ... +``` + +--- + +## 🛠️ 기술 스택 + +| 기술 | 설명 | +| -------------------- | --------------------- | +| **NeoForge** | Minecraft 모딩 플랫폼 | +| **Kotlin** | 주 개발 언어 | +| **Kotlin for Forge** | NeoForge Kotlin 지원 | +| **FBO Rendering** | 오프스크린 렌더링 | + +--- + +## 📁 구조 + +``` +IconExporter/ +├── src/main/ +│ ├── kotlin/com/beemer/iconexporter/ +│ │ ├── command/ # 커맨드 처리 +│ │ ├── export/ # 추출 관리자 +│ │ ├── render/ # FBO 렌더러 +│ │ └── IconExporter.kt # 메인 모드 +│ └── resources/ +│ └── META-INF/ # 모드 메타데이터 +└── build.gradle +``` + +--- + +## ⚠️ 주의사항 + +- **클라이언트 전용** - 서버에서는 동작하지 않습니다 +- **월드 필요** - 싱글플레이어 월드에 접속한 상태에서 사용해야 합니다 +- **렌더링 부하** - 대량 추출 시 일시적인 FPS 저하가 있을 수 있습니다 + +--- + +## 🚀 빌드 + +```bash +./gradlew build +``` + +빌드된 JAR: `build/libs/iconexporter-1.0.0.jar` diff --git a/IconExporter/build.gradle b/IconExporter/build.gradle new file mode 100644 index 0000000..4637a45 --- /dev/null +++ b/IconExporter/build.gradle @@ -0,0 +1,108 @@ +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" } + } + mavenCentral() +} + +base { + archivesName = mod_id +} + +java.toolchain.languageVersion = JavaLanguageVersion.of(21) +kotlin.jvmToolchain(21) + +neoForge { + version = project.neo_version + + parchment { + mappingsVersion = project.parchment_mappings_version + minecraftVersion = project.parchment_minecraft_version + } + + runs { + client { + client() + systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id + } + + server { + server() + programArgument '--nogui' + systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id + } + + configureEach { + systemProperty 'forge.logging.markers', 'REGISTRIES' + logLevel = org.slf4j.event.Level.DEBUG + } + } + + mods { + "${mod_id}" { + sourceSet(sourceSets.main) + } + } +} + +sourceSets.main.resources { srcDir 'src/generated/resources' } + +dependencies { + // Kotlin for Forge + implementation 'thedarkcolour:kotlinforforge-neoforge:5.3.0' +} + +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" +} + +sourceSets.main.resources.srcDir generateModMetadata +neoForge.ideSyncTask generateModMetadata + +publishing { + publications { + register('mavenJava', MavenPublication) { + from components.java + } + } + repositories { + maven { + url "file://${project.projectDir}/repo" + } + } +} + +idea { + module { + downloadSources = true + downloadJavadoc = true + } +} diff --git a/IconExporter/gradle.properties b/IconExporter/gradle.properties new file mode 100644 index 0000000..36fb944 --- /dev/null +++ b/IconExporter/gradle.properties @@ -0,0 +1,24 @@ +# Gradle 기본 설정 +org.gradle.jvmargs=-Xmx2G +org.gradle.daemon=true +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.configuration-cache=true + +## 환경 설정 +minecraft_version=1.21.1 +minecraft_version_range=[1.21.1,1.22) +neo_version=21.1.194 +neo_version_range=[21,) +loader_version_range=[4,) +parchment_minecraft_version=1.21.1 +parchment_mappings_version=2024.11.17 + +## 모드 설정 +mod_id=iconexporter +mod_name=IconExporter +mod_license=MIT +mod_version=1.0.0 +mod_group_id=com.beemer +mod_authors=beemer +mod_description=Export item icons as PNG files from the game client diff --git a/IconExporter/gradle/wrapper/gradle-wrapper.jar b/IconExporter/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e644113 Binary files /dev/null and b/IconExporter/gradle/wrapper/gradle-wrapper.jar differ diff --git a/IconExporter/gradle/wrapper/gradle-wrapper.properties b/IconExporter/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a441313 --- /dev/null +++ b/IconExporter/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/IconExporter/gradlew b/IconExporter/gradlew new file mode 100755 index 0000000..b740cf1 --- /dev/null +++ b/IconExporter/gradlew @@ -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" "$@" diff --git a/IconExporter/gradlew.bat b/IconExporter/gradlew.bat new file mode 100644 index 0000000..7101f8e --- /dev/null +++ b/IconExporter/gradlew.bat @@ -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 diff --git a/IconExporter/settings.gradle b/IconExporter/settings.gradle new file mode 100644 index 0000000..94f87dd --- /dev/null +++ b/IconExporter/settings.gradle @@ -0,0 +1,13 @@ +pluginManagement { + repositories { + mavenLocal() + gradlePluginPortal() + maven { url = 'https://maven.neoforged.net/releases' } + } +} + +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '0.8.0' +} + +rootProject.name = 'IconExporter' diff --git a/IconExporter/src/main/kotlin/com/beemer/iconexporter/IconExporter.kt b/IconExporter/src/main/kotlin/com/beemer/iconexporter/IconExporter.kt new file mode 100644 index 0000000..fd5bba6 --- /dev/null +++ b/IconExporter/src/main/kotlin/com/beemer/iconexporter/IconExporter.kt @@ -0,0 +1,57 @@ +package com.beemer.iconexporter + +import com.beemer.iconexporter.command.IconExportCommand +import com.beemer.iconexporter.export.IconExportManager +import net.neoforged.bus.api.IEventBus +import net.neoforged.fml.common.Mod +import net.neoforged.fml.loading.FMLEnvironment +import net.neoforged.neoforge.client.event.ClientTickEvent +import net.neoforged.neoforge.client.event.RegisterClientCommandsEvent +import net.neoforged.neoforge.common.NeoForge +import org.slf4j.LoggerFactory + +/** + * IconExporter 모드 메인 클래스 + * + * 이 모드는 클라이언트에서만 동작하며, 인벤토리에서 보이는 아이템 아이콘을 PNG 파일로 추출하는 기능을 제공합니다. + * + * 핵심 기능: + * - /iconexport 커맨드로 아이템 아이콘 추출 + * - 오프스크린 FBO 렌더링으로 게임 화면에 영향 없이 렌더링 + * - 틱 기반 큐 처리로 대량 추출 시에도 게임 프리즈 방지 + */ +@Mod(IconExporter.MOD_ID) +class IconExporter(modEventBus: IEventBus) { + companion object { + const val MOD_ID = "iconexporter" + val LOGGER = LoggerFactory.getLogger(MOD_ID)!! + } + + init { + // 클라이언트 전용 모드 - 서버에서는 아무 것도 하지 않음 + if (!FMLEnvironment.dist.isClient) { + LOGGER.info("IconExporter는 클라이언트 전용 모드입니다. 서버에서는 비활성화됩니다.") + } else { + LOGGER.info("IconExporter 초기화 중...") + + // 커맨드 등록 이벤트 구독 + NeoForge.EVENT_BUS.addListener(::onRegisterClientCommands) + + // 클라이언트 틱 이벤트 구독 (큐 처리용) + NeoForge.EVENT_BUS.addListener(::onClientTick) + + LOGGER.info("IconExporter 초기화 완료!") + } + } + + /** 클라이언트 커맨드 등록 */ + private fun onRegisterClientCommands(event: RegisterClientCommandsEvent) { + IconExportCommand.register(event.dispatcher) + LOGGER.info("IconExporter 커맨드 등록 완료") + } + + /** 클라이언트 틱 이벤트 핸들러 */ + private fun onClientTick(event: ClientTickEvent.Post) { + IconExportManager.tick() + } +} diff --git a/IconExporter/src/main/kotlin/com/beemer/iconexporter/command/IconExportCommand.kt b/IconExporter/src/main/kotlin/com/beemer/iconexporter/command/IconExportCommand.kt new file mode 100644 index 0000000..42a2699 --- /dev/null +++ b/IconExporter/src/main/kotlin/com/beemer/iconexporter/command/IconExportCommand.kt @@ -0,0 +1,274 @@ +package com.beemer.iconexporter.command + +import com.beemer.iconexporter.IconExporter +import com.beemer.iconexporter.export.IconExportManager +import com.mojang.brigadier.CommandDispatcher +import com.mojang.brigadier.arguments.IntegerArgumentType +import com.mojang.brigadier.arguments.StringArgumentType +import com.mojang.brigadier.context.CommandContext +import net.minecraft.commands.CommandSourceStack +import net.minecraft.commands.Commands +import net.minecraft.core.registries.BuiltInRegistries +import net.minecraft.network.chat.Component +import net.minecraft.resources.ResourceLocation +import net.minecraft.world.item.Items + +/** + * IconExport 커맨드 시스템 + * + * 제공되는 커맨드: + * - /iconexport all : 모든 아이템 추출 + * - /iconexport mod : 특정 모드의 아이템만 추출 + * - /iconexport id : 단일 아이템 추출 + * - /iconexport size <32|64|128|256> : 아이콘 크기 설정 + * - /iconexport overwrite : 덮어쓰기 설정 + * - /iconexport listmods : 등록된 모드 목록 + * - /iconexport cancel : 진행 중인 작업 취소 + * - /iconexport status : 현재 상태 확인 + */ +object IconExportCommand { + + fun register(dispatcher: CommandDispatcher) { + dispatcher.register( + Commands.literal("iconexport") + // /iconexport all - 모든 아이템 추출 + .then(Commands.literal("all") + .executes(::exportAll)) + + // /iconexport mod - 특정 모드 아이템 추출 + .then(Commands.literal("mod") + .then(Commands.argument("modid", StringArgumentType.word()) + .suggests { _, builder -> + getRegisteredNamespaces().forEach { builder.suggest(it) } + builder.buildFuture() + } + .executes(::exportMod))) + + // /iconexport id - 단일 아이템 추출 + .then(Commands.literal("id") + .then(Commands.argument("itemid", StringArgumentType.greedyString()) + .executes(::exportSingleItem))) + + // /iconexport size - 아이콘 크기 설정 + .then(Commands.literal("size") + .then(Commands.argument("size", IntegerArgumentType.integer(16, 512)) + .executes(::setSize))) + + // /iconexport overwrite - 덮어쓰기 설정 + .then(Commands.literal("overwrite") + .then(Commands.literal("true") + .executes { ctx -> setOverwrite(ctx, true) }) + .then(Commands.literal("false") + .executes { ctx -> setOverwrite(ctx, false) })) + + // /iconexport listmods - 모드 목록 출력 + .then(Commands.literal("listmods") + .executes(::listMods)) + + // /iconexport cancel - 작업 취소 + .then(Commands.literal("cancel") + .executes(::cancelExport)) + + // /iconexport status - 상태 확인 + .then(Commands.literal("status") + .executes(::showStatus)) + + // /iconexport (기본) - 도움말 표시 + .executes(::showHelp) + ) + } + + /** + * 모든 아이템 추출 + */ + private fun exportAll(context: CommandContext): Int { + if (IconExportManager.isRunning) { + sendMessage(context, "§c이미 추출 작업이 진행 중입니다. /iconexport cancel로 취소하거나 완료를 기다려주세요.") + return 0 + } + + // 모든 아이템 수집 (air 제외) + val items = BuiltInRegistries.ITEM.filter { it != Items.AIR } + + IconExportManager.startExport(items, null) + sendMessage(context, "§a총 ${items.size}개 아이템 추출을 시작합니다...") + + return 1 + } + + /** + * 특정 모드의 아이템만 추출 + */ + private fun exportMod(context: CommandContext): Int { + val modId = StringArgumentType.getString(context, "modid") + + if (IconExportManager.isRunning) { + sendMessage(context, "§c이미 추출 작업이 진행 중입니다.") + return 0 + } + + // 해당 모드의 아이템만 필터링 + val items = BuiltInRegistries.ITEM.filter { item -> + val key = BuiltInRegistries.ITEM.getKey(item) + key != null && key.namespace == modId && item != Items.AIR + } + + if (items.isEmpty()) { + sendMessage(context, "§c'$modId' 네임스페이스에 해당하는 아이템이 없습니다.") + sendMessage(context, "§7/iconexport listmods 로 사용 가능한 모드 목록을 확인하세요.") + return 0 + } + + IconExportManager.startExport(items, modId) + sendMessage(context, "§a[$modId] 총 ${items.size}개 아이템 추출을 시작합니다...") + + return 1 + } + + /** + * 단일 아이템 추출 + */ + private fun exportSingleItem(context: CommandContext): Int { + val itemIdStr = StringArgumentType.getString(context, "itemid") + + if (IconExportManager.isRunning) { + sendMessage(context, "§c이미 추출 작업이 진행 중입니다.") + return 0 + } + + // 아이템 ID 파싱 + val itemId = try { + ResourceLocation.parse(itemIdStr) + } catch (e: Exception) { + sendMessage(context, "§c잘못된 아이템 ID 형식입니다: $itemIdStr") + sendMessage(context, "§7올바른 형식: minecraft:diamond, create:mechanical_press") + return 0 + } + + // 아이템 조회 + if (!BuiltInRegistries.ITEM.containsKey(itemId)) { + sendMessage(context, "§c아이템을 찾을 수 없습니다: $itemId") + return 0 + } + + val item = BuiltInRegistries.ITEM.get(itemId) + if (item == Items.AIR) { + sendMessage(context, "§cair 아이템은 추출할 수 없습니다.") + return 0 + } + + IconExportManager.startExport(listOf(item), null) + sendMessage(context, "§a아이템 추출 중: $itemId") + + return 1 + } + + /** + * 아이콘 크기 설정 + */ + private fun setSize(context: CommandContext): Int { + val size = IntegerArgumentType.getInteger(context, "size") + IconExportManager.iconSize = size + sendMessage(context, "§a아이콘 크기가 ${size}x${size}로 설정되었습니다.") + return 1 + } + + /** + * 덮어쓰기 설정 + */ + private fun setOverwrite(context: CommandContext, overwrite: Boolean): Int { + IconExportManager.overwrite = overwrite + val status = if (overwrite) "§a활성화" else "§c비활성화" + sendMessage(context, "§f덮어쓰기가 $status§f되었습니다.") + return 1 + } + + /** + * 등록된 모드 목록 출력 + */ + private fun listMods(context: CommandContext): Int { + val namespaces = getRegisteredNamespaces() + + sendMessage(context, "§6=== 등록된 네임스페이스 (${namespaces.size}개) ===") + + // 네임스페이스별 아이템 수 계산 + for (ns in namespaces) { + val count = BuiltInRegistries.ITEM.count { item -> + val key = BuiltInRegistries.ITEM.getKey(item) + key != null && key.namespace == ns && item != Items.AIR + } + sendMessage(context, "§7- §f$ns §7(${count}개)") + } + + sendMessage(context, "§6=================================") + sendMessage(context, "§7사용법: /iconexport mod ") + + return 1 + } + + /** + * 작업 취소 + */ + private fun cancelExport(context: CommandContext): Int { + if (!IconExportManager.isRunning) { + sendMessage(context, "§c진행 중인 작업이 없습니다.") + return 0 + } + + IconExportManager.cancel() + sendMessage(context, "§e추출 작업이 취소되었습니다.") + + return 1 + } + + /** + * 현재 상태 확인 + */ + private fun showStatus(context: CommandContext): Int { + sendMessage(context, "§6=== IconExporter 상태 ===") + sendMessage(context, "§7아이콘 크기: §f${IconExportManager.iconSize}x${IconExportManager.iconSize}") + sendMessage(context, "§7덮어쓰기: ${if (IconExportManager.overwrite) "§a활성화" else "§c비활성화"}") + sendMessage(context, "§7진행 중: ${if (IconExportManager.isRunning) "§a예" else "§c아니오"}") + + if (IconExportManager.isRunning) { + sendMessage(context, "§7진행률: §f${IconExportManager.processedCount}/${IconExportManager.totalCount} (${String.format("%.1f", IconExportManager.progress * 100)}%)") + } + + sendMessage(context, "§6========================") + + return 1 + } + + /** + * 도움말 표시 + */ + private fun showHelp(context: CommandContext): Int { + sendMessage(context, "§6=== IconExporter 도움말 ===") + sendMessage(context, "§e/iconexport all §7- 모든 아이템 추출") + sendMessage(context, "§e/iconexport mod §7- 특정 모드 아이템 추출") + sendMessage(context, "§e/iconexport id §7- 단일 아이템 추출") + sendMessage(context, "§e/iconexport size <16-512> §7- 아이콘 크기 설정 (기본: 64)") + sendMessage(context, "§e/iconexport overwrite §7- 덮어쓰기 설정") + sendMessage(context, "§e/iconexport listmods §7- 모드 목록 확인") + sendMessage(context, "§e/iconexport status §7- 현재 상태 확인") + sendMessage(context, "§e/iconexport cancel §7- 진행 중인 작업 취소") + sendMessage(context, "§6============================") + return 1 + } + + /** + * 등록된 모든 네임스페이스 조회 + */ + private fun getRegisteredNamespaces(): Set { + return BuiltInRegistries.ITEM.keySet() + .map { it.namespace } + .toSortedSet() + } + + /** + * 채팅 메시지 전송 헬퍼 + */ + private fun sendMessage(context: CommandContext, message: String) { + context.source.sendSystemMessage(Component.literal(message)) + } +} diff --git a/IconExporter/src/main/kotlin/com/beemer/iconexporter/export/IconExportManager.kt b/IconExporter/src/main/kotlin/com/beemer/iconexporter/export/IconExportManager.kt new file mode 100644 index 0000000..1bc8057 --- /dev/null +++ b/IconExporter/src/main/kotlin/com/beemer/iconexporter/export/IconExportManager.kt @@ -0,0 +1,259 @@ +package com.beemer.iconexporter.export + +import com.beemer.iconexporter.IconExporter +import com.beemer.iconexporter.render.IconRenderer +import java.io.IOException +import java.nio.file.Files +import java.util.* +import kotlin.io.path.exists +import net.minecraft.client.Minecraft +import net.minecraft.core.registries.BuiltInRegistries +import net.minecraft.network.chat.Component +import net.minecraft.world.item.Item +import net.minecraft.world.item.ItemStack + +/** + * 아이콘 추출 관리자 + * + * 대량의 아이템을 처리할 때 게임 프리즈를 방지하기 위해 틱 기반 큐 시스템으로 아이템을 순차적으로 처리합니다. + */ +object IconExportManager { + // 틱당 처리할 아이템 수 (너무 높으면 랙, 너무 낮으면 느림) + private const val ITEMS_PER_TICK = 5 + + // 설정 값 + var iconSize: Int = 64 + var overwrite: Boolean = false + + // 작업 상태 + private val itemQueue: Queue = LinkedList() + private var currentModFilter: String? = null + var totalCount: Int = 0 + private set + var processedCount: Int = 0 + private set + private var successCount: Int = 0 + private var skipCount: Int = 0 + private val failedItems: MutableList = mutableListOf() + private var startTime: Long = 0 + var isRunning: Boolean = false + private set + + val progress: Double + get() = if (totalCount > 0) processedCount.toDouble() / totalCount else 0.0 + + /** + * 추출 작업 시작 + * + * @param items 추출할 아이템 목록 + * @param modFilter 모드 필터 (로그용, null이면 전체) + */ + fun startExport(items: List, modFilter: String?) { + if (isRunning) { + IconExporter.LOGGER.warn("이미 추출 작업이 진행 중입니다.") + return + } + + // 상태 초기화 + itemQueue.clear() + itemQueue.addAll(items) + currentModFilter = modFilter + totalCount = items.size + processedCount = 0 + successCount = 0 + skipCount = 0 + failedItems.clear() + startTime = System.currentTimeMillis() + isRunning = true + + val target = modFilter ?: "전체" + IconExporter.LOGGER.info( + "아이콘 추출 시작: {} ({} 개 아이템, {}x{} 크기)", + target, + totalCount, + iconSize, + iconSize + ) + } + + /** 매 틱마다 호출되어 큐에서 아이템을 처리 */ + fun tick() { + if (!isRunning || itemQueue.isEmpty()) { + return + } + + val mc = Minecraft.getInstance() + + // 렌더링은 메인 스레드에서만 가능 + if (!mc.isSameThread) { + return + } + + // 월드가 로드되지 않았으면 대기 + if (mc.level == null) { + return + } + + // 이번 틱에 처리할 아이템 수 + var processed = 0 + + while (itemQueue.isNotEmpty() && processed < ITEMS_PER_TICK) { + val item = itemQueue.poll() + if (item != null) { + processItem(item) + processed++ + processedCount++ + } + } + + // 진행률 로그 (10% 단위로 출력) + val progressPercent = (progress * 100).toInt() + if (processedCount == 1 || progressPercent % 10 == 0 || itemQueue.isEmpty()) { + logProgress() + } + + // 모든 작업 완료 + if (itemQueue.isEmpty()) { + finishExport() + } + } + + /** 단일 아이템 처리 */ + private fun processItem(item: Item) { + val itemId = BuiltInRegistries.ITEM.getKey(item) + if (itemId == null) { + failedItems.add("unknown_item") + return + } + + try { + // 저장 경로 생성 + val outputPath = getOutputPath(itemId.namespace, itemId.path) + + // 이미 파일이 존재하고 덮어쓰기가 비활성화된 경우 스킵 + if (outputPath.exists() && !overwrite) { + skipCount++ + return + } + + // 부모 디렉토리 생성 + Files.createDirectories(outputPath.parent) + + // 아이템 스택 생성 (기본 NBT) + val stack = ItemStack(item) + + // 오프스크린 렌더링 + val image = IconRenderer.renderItem(stack, iconSize) + + if (image != null) { + // PNG로 저장 + image.writeToFile(outputPath) + image.close() + successCount++ + } else { + failedItems.add(itemId.toString()) + } + } catch (e: IOException) { + IconExporter.LOGGER.error("아이템 저장 실패: {} - {}", itemId, e.message) + failedItems.add(itemId.toString()) + } catch (e: Exception) { + IconExporter.LOGGER.error("아이템 처리 중 예외 발생: {} - {}", itemId, e.message) + failedItems.add(itemId.toString()) + } + } + + /** 아이템 ID로 출력 경로 생성 */ + private fun getOutputPath(namespace: String, path: String): java.nio.file.Path { + val mc = Minecraft.getInstance() + val gameDir = mc.gameDirectory.toPath() + + // icons//.png + return gameDir.resolve("icons").resolve(namespace).resolve("$path.png") + } + + /** 진행률 로그 출력 */ + private fun logProgress() { + val elapsed = System.currentTimeMillis() - startTime + + // 예상 남은 시간 계산 + val eta = + if (progress > 0.01) { + val estimatedTotal = (elapsed / progress).toLong() + val remaining = estimatedTotal - elapsed + formatTime(remaining) + } else { + "계산 중..." + } + + val message = + "§7[IconExporter] §f$processedCount§7/§f$totalCount §7(§a${String.format("%.1f", progress * 100)}%%§7) - 경과: ${formatTime(elapsed)}, 남은 시간: $eta" + + // 게임 내 채팅에 표시 + Minecraft.getInstance().player?.displayClientMessage(Component.literal(message), true) + + IconExporter.LOGGER.info("진행률: {}/{} ({:.1f}%)", processedCount, totalCount, progress * 100) + } + + /** 추출 작업 완료 처리 */ + private fun finishExport() { + isRunning = false + val elapsed = System.currentTimeMillis() - startTime + + val summary = + """ + §a=== 추출 완료 === + §7총 소요 시간: §f${formatTime(elapsed)} + §7성공: §a${successCount}§7개 / 스킵: §e${skipCount}§7개 / 실패: §c${failedItems.size}§7개 + """.trimIndent() + + IconExporter.LOGGER.info( + "추출 완료: 성공 {}, 스킵 {}, 실패 {}, 소요 시간 {}", + successCount, + skipCount, + failedItems.size, + formatTime(elapsed) + ) + + // 실패 목록 로깅 + if (failedItems.isNotEmpty()) { + IconExporter.LOGGER.warn("실패한 아이템 목록:") + failedItems.forEach { IconExporter.LOGGER.warn(" - {}", it) } + } + + // 게임 내 메시지 + val mc = Minecraft.getInstance() + mc.player?.let { player -> + player.displayClientMessage(Component.literal(summary), false) + + // 저장 위치 안내 + val outputDir = mc.gameDirectory.toPath().resolve("icons") + player.displayClientMessage( + Component.literal("§7저장 위치: §f${outputDir.toAbsolutePath()}"), + false + ) + } + } + + /** 작업 취소 */ + fun cancel() { + if (!isRunning) return + + itemQueue.clear() + isRunning = false + + IconExporter.LOGGER.info("추출 작업 취소됨 (처리됨: {}/{})", processedCount, totalCount) + } + + /** 밀리초를 읽기 쉬운 형식으로 변환 */ + private fun formatTime(ms: Long): String { + return when { + ms < 1000 -> "${ms}ms" + ms < 60000 -> "${String.format("%.1f", ms / 1000.0)}초" + else -> { + val minutes = ms / 60000 + val seconds = (ms % 60000) / 1000 + "${minutes}분 ${seconds}초" + } + } + } +} diff --git a/IconExporter/src/main/kotlin/com/beemer/iconexporter/render/IconRenderer.kt b/IconExporter/src/main/kotlin/com/beemer/iconexporter/render/IconRenderer.kt new file mode 100644 index 0000000..17042fe --- /dev/null +++ b/IconExporter/src/main/kotlin/com/beemer/iconexporter/render/IconRenderer.kt @@ -0,0 +1,139 @@ +package com.beemer.iconexporter.render + +import com.beemer.iconexporter.IconExporter +import com.mojang.blaze3d.pipeline.TextureTarget +import com.mojang.blaze3d.platform.GlStateManager +import com.mojang.blaze3d.platform.Lighting +import com.mojang.blaze3d.platform.NativeImage +import com.mojang.blaze3d.systems.RenderSystem +import com.mojang.blaze3d.vertex.VertexSorting +import net.minecraft.client.Minecraft +import net.minecraft.client.gui.GuiGraphics +import net.minecraft.world.item.ItemStack +import org.joml.Matrix4f +import org.lwjgl.opengl.GL11 + +/** + * 아이템 아이콘 오프스크린 렌더러 + * + * FBO(Frame Buffer Object)를 사용하여 화면에 표시하지 않고 아이템을 렌더링한 후 NativeImage로 변환합니다. + * + * 렌더링 파이프라인: + * 1. TextureTarget(FBO) 생성 및 바인딩 + * 2. 투명 배경으로 클리어 + * 3. GUI Projection 설정 + * 4. GUI 라이팅 설정 (인벤토리와 동일) + * 5. ItemRenderer로 아이템 렌더링 + * 6. 픽셀 데이터를 NativeImage로 복사 + * 7. FBO 자원 정리 + */ +object IconRenderer { + + /** + * 아이템을 오프스크린으로 렌더링하여 NativeImage 반환 + * + * @param stack 렌더링할 아이템 스택 + * @param size 출력 이미지 크기 (정사각형) + * @return 렌더링된 이미지 (호출자가 close() 해야 함) + */ + fun renderItem(stack: ItemStack, size: Int): NativeImage? { + val mc = Minecraft.getInstance() + + // 렌더 스레드에서만 실행 가능 + if (!RenderSystem.isOnRenderThread()) { + IconExporter.LOGGER.error("렌더 스레드가 아닌 곳에서 renderItem 호출됨") + return null + } + + var renderTarget: TextureTarget? = null + var image: NativeImage? = null + + try { + // 1. FBO 생성 (TextureTarget = RenderTarget 구현) + renderTarget = TextureTarget(size, size, true, false) + renderTarget.setClearColor(0f, 0f, 0f, 0f) + renderTarget.clear(false) + + // 2. FBO 바인딩 + renderTarget.bindWrite(true) + + // 3. 뷰포트 설정 + RenderSystem.viewport(0, 0, size, size) + + // 4. 투명 배경 클리어 + RenderSystem.clearColor(0f, 0f, 0f, 0f) + RenderSystem.clear(GL11.GL_COLOR_BUFFER_BIT or GL11.GL_DEPTH_BUFFER_BIT, false) + + // 5. 블렌딩 활성화 (투명도 지원) + RenderSystem.enableBlend() + RenderSystem.blendFuncSeparate( + GlStateManager.SourceFactor.SRC_ALPHA, + GlStateManager.DestFactor.ONE_MINUS_SRC_ALPHA, + GlStateManager.SourceFactor.ONE, + GlStateManager.DestFactor.ONE_MINUS_SRC_ALPHA + ) + + // 6. GUI Projection 설정 + val projectionMatrix = + Matrix4f().ortho(0f, size.toFloat(), size.toFloat(), 0f, -1000f, 3000f) + RenderSystem.setProjectionMatrix(projectionMatrix, VertexSorting.ORTHOGRAPHIC_Z) + + // 7. GUI 조명 설정 (인벤토리 슬롯과 동일한 조명) + Lighting.setupForFlatItems() + + // 8. GuiGraphics 생성 및 아이템 렌더링 + val bufferSource = mc.renderBuffers().bufferSource() + val graphics = GuiGraphics(mc, bufferSource) + + // 아이템 스케일 및 위치 조정 + val scale = size / 16.0f + + // GuiGraphics의 자체 PoseStack을 사용해서 변환 적용 + graphics.pose().pushPose() + graphics.pose().scale(scale, scale, 1.0f) + + // (0, 0)에 렌더링하면 스케일 적용 후 전체 이미지 채움 + graphics.renderItem(stack, 0, 0) + + graphics.pose().popPose() + + // 버퍼 플러시 + graphics.flush() + bufferSource.endBatch() + + // 10. 조명 복원 + Lighting.setupFor3DItems() + + // 11. 블렌딩 비활성화 + RenderSystem.disableBlend() + + // 12. FBO 언바인딩 + renderTarget.unbindWrite() + + // 13. NativeImage 생성 및 픽셀 읽기 + image = NativeImage(size, size, false) + + // FBO 텍스처에서 픽셀 읽기 + RenderSystem.bindTexture(renderTarget.colorTextureId) + image.downloadTexture(0, false) + + // OpenGL은 좌표계가 뒤집혀 있으므로 Y축 반전 + image.flipY() + + // 주 프레임버퍼로 복원 + mc.mainRenderTarget.bindWrite(true) + + return image + } catch (e: Exception) { + IconExporter.LOGGER.error("아이템 렌더링 중 예외: {}", e.message, e) + image?.close() + return null + } finally { + // FBO 자원 정리 + renderTarget?.destroyBuffers() + + // 메인 렌더 타겟 복원 + mc.mainRenderTarget.bindWrite(true) + } + } +} diff --git a/IconExporter/src/main/templates/META-INF/neoforge.mods.toml b/IconExporter/src/main/templates/META-INF/neoforge.mods.toml new file mode 100644 index 0000000..10653b1 --- /dev/null +++ b/IconExporter/src/main/templates/META-INF/neoforge.mods.toml @@ -0,0 +1,25 @@ +modLoader="javafml" +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="CLIENT" + +[[dependencies.${mod_id}]] + modId="minecraft" + type="required" + versionRange="${minecraft_version_range}" + ordering="NONE" + side="CLIENT" diff --git a/README.md b/README.md index 652ccbe..278035c 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ NeoForge 1.21.1 기반 마인크래프트 서버 모드 모음입니다. | [Essentials](./Essentials/) | 서버 필수 기능 (좌표 관리, 닉네임 등) | | [ServerStatus](./ServerStatus/) | HTTP API로 서버 상태 제공 | | [DiscordBot](./DiscordBot/) | Discord 웹훅으로 서버 이벤트 전송 | +| [IconExporter](./IconExporter/) | 아이템 아이콘 PNG 추출 (클라이언트) | --- @@ -47,6 +48,7 @@ minecraft-mod/ ├── Essentials/ # 서버 필수 기능 모드 ├── ServerStatus/ # 서버 상태 API 모드 ├── DiscordBot/ # Discord 연동 모드 +├── IconExporter/ # 아이콘 추출 모드 (클라이언트) └── .gitignore ```