commit 3820e45f0a2c6de8c1b0c4d367c76f8a313f4e79 Author: Patrick <147879351+WinniePatGG@users.noreply.github.com> Date: Fri May 1 18:54:57 2026 +0200 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4708c8d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.gradle +.idea +build +run +backend \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4b40450 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 WinniePatGG + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4402869 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# Mal schauen bin lwk zu faul ne neue zu machen \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..786767d --- /dev/null +++ b/build.gradle @@ -0,0 +1,122 @@ +plugins { + id 'fabric-loom' version '1.8-SNAPSHOT' + id 'maven-publish' + id 'java' + id 'com.github.johnrengelman.shadow' version '8.1.1' +} + +version = project.mod_version +group = project.maven_group + +base { + archivesName = project.archives_base_name +} + +repositories { + mavenCentral() + gradlePluginPortal() + maven { url = "https://maven.fabricmc.net/" } + maven { url = 'https://maven.wispforest.io/releases/' } + maven { url = 'https://repo.u-team.info' } + maven { url = 'https://jitpack.io' } + maven { url = "https://maven.terraformersmc.com/releases/" } + maven { url = "https://maven.izzel.io" } + maven { url = "https://raw.githubusercontent.com/Fuzss/modresources/main/maven/" } + maven { url "https://dl.cloudsmith.io/public/geckolib3/geckolib/maven/" } +} + +dependencies { + minecraft "com.mojang:minecraft:1.21.1" + mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2" + + modImplementation "net.fabricmc:fabric-loader:0.16.14" + modImplementation "net.fabricmc.fabric-api:fabric-api:0.105.0+1.21.1" + modImplementation "io.wispforest:owo-lib:0.12.15.4+1.21" + + include(implementation("org.reflections:reflections:0.10.2")) + include(implementation("com.google.guava:guava:32.1.2-jre")) + include(implementation("org.javassist:javassist:3.29.2-GA")) + include(implementation("org.slf4j:slf4j-api:2.0.9")) + include(implementation("org.apache.httpcomponents:httpclient:4.5.13")) + include(implementation("com.google.code.gson:gson:2.10.1")) + include(implementation("io.socket:socket.io-client:2.1.0")) + + implementation "com.github.JnCrMx:discord-game-sdk4j:v0.5.5" + include(implementation("net.hycrafthd:minecraft_authenticator:3.0.6")) + + implementation "io.github.jaredmdobson:concentus:1.0.2" + implementation "org.java-websocket:Java-WebSocket:1.5.6" + + implementation "icyllis.modernui:ModernUI-Core:3.12.0" + implementation "icyllis.modernui:ModernUI-Markflow:3.12.0" + modImplementation("icyllis.modernui:ModernUI-Fabric:1.21.1-3.12.0.2") + modImplementation "fuzs.forgeconfigapiport:forgeconfigapiport-fabric:21.1.0" + + modImplementation "software.bernie.geckolib:geckolib-fabric-1.21:4.5.8" + + modImplementation "com.terraformersmc:modmenu:11.0.3" +} + +processResources { + inputs.property "version", "1.0.0" + inputs.property "loader_version", "0.16.14" + inputs.property "minecraft_version", "1.21.1" + + filesMatching("fabric.mod.json") { + expand( + "version": "1.0.0", + "loader_version": "0.16.14", + "minecraft_version": "1.21.1" + ) + } +} + +def targetJavaVersion = 21 +tasks.withType(JavaCompile).configureEach { + it.options.encoding = "UTF-8" + if (targetJavaVersion >= 10 || JavaVersion.current().isJava10Compatible()) { + it.options.release.set(targetJavaVersion) + } +} + +java { + def javaVersion = JavaVersion.toVersion(targetJavaVersion) + if (JavaVersion.current() < javaVersion) { + toolchain.languageVersion = JavaLanguageVersion.of(targetJavaVersion) + } + withSourcesJar() +} + +remapJar { + archiveClassifier.set("") +} +build.dependsOn remapJar + +jar { +} + +sourceSets { + main { + java { + srcDirs = ['src/main/java', 'src/main/kotlin'] + } + resources { + srcDirs = ['src/main/resources'] + } + } +} + +loom { + mixin { + defaultRefmapName = "citrus.refmap.json" + } +} + +publishing { + publications { + create("mavenJava", MavenPublication) { + artifactId = project.archives_base_name + from components.java + } + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..22c09f7 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,19 @@ +# Done to increase the memory available to Gradle. +org.gradle.jvmargs=-Xmx16G + +# Fabric Properties + # check these on https://modmuss50.me/fabric.html + minecraft_version=1.21.1 + yarn_mappings=1.21.1+build.3 + loader_version=0.18.4 + + owo_version=0.12.15.4+1.21 + +# Mod Properties + mod_version = 0.1 + maven_group = de.winniepat.citrus + archives_base_name = Citrus + +# Dependencies + # check this on https://modmuss50.me/fabric.html + fabric_version=0.105.0+1.21.1 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..a4b76b9 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e2847c8 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..f5feea6 --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# 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 -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || 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/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@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 +@rem SPDX-License-Identifier: Apache-2.0 +@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/settings.gradle b/settings.gradle new file mode 100644 index 0000000..e7afecb --- /dev/null +++ b/settings.gradle @@ -0,0 +1,8 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + maven { url = "https://maven.fabricmc.net/" } + } +} +rootProject.name = "Citrus" diff --git a/src/main/java/de/winniepat/citrus/Client.java b/src/main/java/de/winniepat/citrus/Client.java new file mode 100644 index 0000000..849e51c --- /dev/null +++ b/src/main/java/de/winniepat/citrus/Client.java @@ -0,0 +1,164 @@ +package de.winniepat.citrus; + +import de.winniepat.citrus.beta.*; +import de.winniepat.citrus.cosmetics.*; +import de.winniepat.citrus.cosmetics.capes.*; +import de.winniepat.citrus.cosmetics.capes.sync.*; +import de.winniepat.citrus.client.*; +import de.winniepat.citrus.cosmetics.halo.HaloFeatureRenderer; +import de.winniepat.citrus.cosmetics.hat.HatFeatureRenderer; +import de.winniepat.citrus.cosmetics.wings.WingsFeatureRenderer; +import de.winniepat.citrus.cosmetics.wings.sync.*; +import de.winniepat.citrus.draggui.*; +import de.winniepat.citrus.gui.*; +import de.winniepat.citrus.managers.*; +import de.winniepat.citrus.utils.*; +import icyllis.modernui.mc.MuiModApi; +import net.fabricmc.fabric.api.client.rendering.v1.LivingEntityFeatureRendererRegistrationCallback; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.client.render.entity.PlayerEntityRenderer; +import org.slf4j.*; + +import net.fabricmc.fabric.api.client.event.lifecycle.v1.*; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; +import net.minecraft.client.MinecraftClient; +import net.minecraft.text.Text; + +import java.io.*; + +public class Client { + + public static final Logger logger = LoggerFactory.getLogger(Client.class); + + public static final String CLIENT_BUILD = "0.1"; + public static final String clientInfo = "Citrus 1.21-" + CLIENT_BUILD + " | Minecraft 1.21"; + + public static final File CONFIG_DIR = new File(FabricLoader.getInstance().getGameDir().toFile(),"citrus"); + + public static final boolean debug = true; + public static final boolean betaFeaturesEnabled = false; + + public static final String API_URL = "https://api.citrus-client.de"; + public static final String CDN_URL = "https://cdn.citrus-client.de"; + public static final String BETA_URL = "https://beta.citrus-client.de"; + + private static BetaManager betaManager; + + protected void init() throws IOException { + + betaManager = BetaManager.getInstance(); + + betaManager.initialize(); + + ClientCommands.register(); + ClientKeybinds.register(); + CosmeticCommands.register(); + + CapeHandler.init(); + ConfigManager.load(); + HudConfig.load(); + HudRenderer.register(); + TestScreen.registerRenderer(); + + CapeSyncManager.init(); + WingSyncManager.init(); + WingSyncManager.startAutoSync(); + WallpaperManager.init(); + + ClientRegistrationManager.register(); + FriendManager.init(); + + ChatSound.init(); + + debugLog(SecurityUtils.getDailyKey()); + + RefreshUtil.start(); + + ClientPlayConnectionEvents.JOIN.register((handler, sender, client) -> { + ClientRegistrationManager.register(); + client.execute(FriendManager::updateServerStatus); + }); + + ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> FriendManager.updateServerStatus()); + + ClientLifecycleEvents.CLIENT_STOPPING.register(client -> ClientRegistrationManager.unregister()); + + ClientTickEvents.END_CLIENT_TICK.register(client -> { + if (client.world == null || client.player == null) return; + + PlayerCapeTracker.tick(); + + while (ClientKeybinds.OPEN_CAPE_SCREEN.wasPressed() && client.player != null) { + CapeHandler.reloadCapes(); + client.setScreen(new CapeSelectionScreen()); + } + + while (ClientKeybinds.OPEN_COSMETICS_SCREEN.wasPressed() && client.player != null) { + client.setScreen(new CosmeticScreen()); + + if (client.world != null || client.player != null){ + PlayerCosmeticTracker.tick(); + } + } + + while (ClientKeybinds.OPEN_FRIENDS_SCREEN.wasPressed() && client.player != null) { + FriendManager.refresh(); + client.setScreen(MuiModApi.get().createScreen(new FriendsFragment(), null, client.currentScreen)); + } + + while (ClientKeybinds.OPEN_DRAGGUI.wasPressed()) { + client.setScreen(MuiModApi.get().createScreen(new HudEditorFragment(), null, client.currentScreen)); + } + + while (ClientKeybinds.REFRESH_CLIENT.wasPressed() && client.player != null) { + RankManager.refreshRankIfChanged(); + CapeHandler.reloadCapes(); + CapeHandler.retryFailedCapes(); + assert MinecraftClient.getInstance().player != null; + MinecraftClient.getInstance().player.sendMessage(Text.literal("Refreshed Client"), false); + } + + while (ClientKeybinds.TOGGLE_FULLBRIGHT.wasPressed()) { + FullbrightManager.toggle(client); + } + + while (ClientKeybinds.OPEN_MODULETOGGLE.wasPressed()) { + client.setScreen(new ModuleToggleScreen()); + } + + while (ClientKeybinds.REFRESH_CLIENT.wasPressed()) { + ClientRegistrationManager.forceUpdateOnlineUsers(); + CapeHandler.refreshCapeList(); + CapeHandler.retryFailedCapes(); + } + + if (client.world != null && client.player != null) { + PlayerWingTracker.tick(); + } + + ZoomManager.setZooming(ClientKeybinds.ZOOM_KEY.isPressed()); + }); + + LivingEntityFeatureRendererRegistrationCallback.EVENT.register((entityType, entityRenderer, registrationHelper, context) -> { + if (entityRenderer instanceof PlayerEntityRenderer playerRenderer) { + registrationHelper.register(new WingsFeatureRenderer(playerRenderer)); + registrationHelper.register(new HatFeatureRenderer(playerRenderer)); + registrationHelper.register(new HaloFeatureRenderer(playerRenderer)); + + Client.debugLog("Registered cosmetic renderers for player"); + } + }); + + CosmeticSyncManager.init(); + } + + public static void debugLog(String message) { + if (debug) { + logger.info("[Citrus] [DEBUG] {}", message); + } + } + + public static BetaManager getBetaManager() { + return betaManager; + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/ClientEntrypoint.java b/src/main/java/de/winniepat/citrus/ClientEntrypoint.java new file mode 100644 index 0000000..9dff30b --- /dev/null +++ b/src/main/java/de/winniepat/citrus/ClientEntrypoint.java @@ -0,0 +1,40 @@ +package de.winniepat.citrus; + +import net.fabricmc.api.*; + +import java.io.IOException; + +public class ClientEntrypoint implements ModInitializer, ClientModInitializer { + + /* + * + * Hier nichts reinschreiben, sonst gibt es Cockschelle! + * Wenn ihr was initializen wollt macht das in der Instance direkt: + * Client.java in der init() + * + * Hier ist nur Client instance!! + * + */ + + private static Client client; + + @Override + public void onInitialize() { + System.out.println("Mod initialized"); + client = new Client(); + } + + @Override + public void onInitializeClient() { + try { + client.init(); + } catch (IOException e) { + throw new RuntimeException(e); + } + System.out.println("Client initialized"); + } + + protected static Client getClient() { + return client; + } +} diff --git a/src/main/java/de/winniepat/citrus/ModConfig.java b/src/main/java/de/winniepat/citrus/ModConfig.java new file mode 100644 index 0000000..7af3d94 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/ModConfig.java @@ -0,0 +1,26 @@ +package de.winniepat.citrus; + +public class ModConfig { + public boolean fullbrightEnabled = false; + public String mainmenuWallpaper = "default.png"; + + public BetaConfig betaConfig = new BetaConfig(); + + public static class BetaConfig { + public String betaKey = ""; + public String verificationToken = ""; + public long tokenExpiresAt = 0; + public boolean firstLaunch = true; + public String apiKey = "ClientF8CjVGn7VTOAhROa65Ot"; + public String apiKeyProd = ""; + + public BetaConfig() {} + + public String getActiveApiKey() { + return (apiKeyProd != null && !apiKeyProd.trim().isEmpty()) ? + apiKeyProd : apiKey; + } + } + + public ModConfig() {} +} diff --git a/src/main/java/de/winniepat/citrus/beta/BetaKeyScreen.java b/src/main/java/de/winniepat/citrus/beta/BetaKeyScreen.java new file mode 100644 index 0000000..15a68d8 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/beta/BetaKeyScreen.java @@ -0,0 +1,217 @@ +package de.winniepat.citrus.beta; + +import de.winniepat.citrus.Client; +import de.winniepat.citrus.gui.MainMenuScreen; +import de.winniepat.citrus.managers.ConfigManager; +import de.winniepat.citrus.ModConfig; +import icyllis.modernui.mc.MuiModApi; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.gui.widget.TextFieldWidget; +import net.minecraft.text.Text; +import net.minecraft.util.Util; + +import java.util.concurrent.CompletableFuture; + +public class BetaKeyScreen extends Screen { + private TextFieldWidget keyField; + private ButtonWidget verifyButton; + + private String statusMessage = ""; + private int statusColor = 0xFFFFFF; + private boolean isVerifying = false; + + private String linkedUsername = null; + + public BetaKeyScreen() { + super(Text.literal("Citrus Beta Program")); + } + + @Override + protected void init() { + super.init(); + + int centerX = width / 2; + int centerY = height / 2; + + keyField = new TextFieldWidget( + textRenderer, + centerX - 150, + centerY - 30, + 220, + 20, + Text.literal("Enter Beta Key") + ); + keyField.setMaxLength(20); + keyField.setChangedListener(text -> updateButtonState()); + + ModConfig.BetaConfig betaConfig = ConfigManager.getBetaConfig(); + if (betaConfig != null && !betaConfig.betaKey.isEmpty()) { + keyField.setText(betaConfig.betaKey); + } + + addDrawableChild(keyField); + + ButtonWidget pasteButton = ButtonWidget.builder( + Text.literal("Paste"), + button -> pasteFromClipboard() + ) + .dimensions(centerX + 75, centerY - 30, 75, 20) + .build(); + addDrawableChild(pasteButton); + + verifyButton = ButtonWidget.builder( + Text.literal("Verify Key"), + button -> verifyKey() + ) + .dimensions(centerX - 150, centerY + 10, 145, 20) + .build(); + addDrawableChild(verifyButton); + + ButtonWidget getKeyButton = ButtonWidget.builder( + Text.literal("Get Beta Key"), + button -> Util.getOperatingSystem().open("https://dc.citrus-client.de") + ) + .dimensions(centerX + 5, centerY + 10, 145, 20) + .build(); + addDrawableChild(getKeyButton); + + ButtonWidget exitButton = ButtonWidget.builder( + Text.literal("Exit Game"), + button -> MinecraftClient.getInstance().scheduleStop() + ) + .dimensions(centerX - 75, centerY + 45, 150, 20) + .build(); + addDrawableChild(exitButton); + + updateButtonState(); + } + + private void updateButtonState() { + if (verifyButton == null) return; + verifyButton.active = !keyField.getText().trim().isEmpty() && !isVerifying; + } + + private void pasteFromClipboard() { + String clipboard = MinecraftClient.getInstance().keyboard.getClipboard(); + if (clipboard != null) { + keyField.setText(clipboard.trim().toUpperCase()); + } + } + + private void verifyKey() { + String key = keyField.getText().trim().toUpperCase(); + + if (!key.matches("CITRUS-[A-Z0-9]{12}")) { + statusMessage = "Invalid format. Use: CITRUS-XXXXXXXXXXXX"; + statusColor = 0xFF5555; + return; + } + + isVerifying = true; + statusMessage = "Verifying..."; + statusColor = 0xFFFF55; + updateButtonState(); + + VerificationClient verificationClient = VerificationClient.getInstance(); + HardwareIdentifier hardwareId = HardwareIdentifier.getInstance(); + + CompletableFuture future = CompletableFuture.supplyAsync(() -> + verificationClient.verifyKey(key, hardwareId.getHardwareId()) + ); + + future.thenAccept(result -> MinecraftClient.getInstance().execute(() -> { + isVerifying = false; + + if (result.isSuccess()) { + ModConfig.BetaConfig betaConfig = ConfigManager.getBetaConfig(); + betaConfig.betaKey = key; + betaConfig.verificationToken = result.getToken(); + betaConfig.tokenExpiresAt = System.currentTimeMillis() + + (result.getExpiresIn() * 1000); + betaConfig.firstLaunch = false; + ConfigManager.save(); + + linkedUsername = result.getLinkedUsername(); + if (linkedUsername != null && !linkedUsername.isEmpty()) { + statusMessage = "✓ Verified! Linked to: " + linkedUsername; + } else { + statusMessage = "✓ Verification successful!"; + } + statusColor = 0x55FF55; + + CompletableFuture.runAsync(() -> { + try { + Thread.sleep(2000); + } catch (InterruptedException ignored) {} + }).thenRun(() -> MinecraftClient.getInstance().execute(() -> { + MinecraftClient client = MinecraftClient.getInstance(); + + if (client.currentScreen == this) { + client.setScreen( + MuiModApi.get().createScreen(new MainMenuScreen(), null, null) + ); + } + })); + + } else { + statusMessage = "✗ " + result.getMessage(); + statusColor = 0xFF5555; + updateButtonState(); + } + })).exceptionally(throwable -> { + Client.logger.error("Verification error", throwable); + MinecraftClient.getInstance().execute(() -> { + isVerifying = false; + statusMessage = "✗ Connection error. Please try again."; + statusColor = 0xFF5555; + updateButtonState(); + }); + return null; + }); + } + + @Override + public void render(DrawContext context, int mouseX, int mouseY, float delta) { + renderBackground(context, 0, 0, 0); + + int centerX = width / 2; + int centerY = height / 2; + + context.drawCenteredTextWithShadow( + textRenderer, Text.literal("Citrus Beta Access"), + centerX, 50, 0xFFA500 + ); + + context.drawCenteredTextWithShadow( + textRenderer, "This version of Citrus requires a beta key", + centerX, 80, 0xAAAAAA + ); + + context.drawCenteredTextWithShadow( + textRenderer, "Enter your key below or visit our Discord", + centerX, 95, 0xAAAAAA + ); + + context.drawCenteredTextWithShadow( + textRenderer, "Format: CITRUS-XXXXXXXXXXXX", + centerX, centerY - 50, 0x888888 + ); + + if (!statusMessage.isEmpty()) { + context.drawCenteredTextWithShadow( + textRenderer, statusMessage, + centerX, centerY + 75, statusColor + ); + } + + super.render(context, mouseX, mouseY, delta); + } + + @Override + public boolean shouldCloseOnEsc() { + return false; + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/beta/BetaManager.java b/src/main/java/de/winniepat/citrus/beta/BetaManager.java new file mode 100644 index 0000000..aacc427 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/beta/BetaManager.java @@ -0,0 +1,108 @@ +package de.winniepat.citrus.beta; + +import de.winniepat.citrus.Client; +import de.winniepat.citrus.managers.ConfigManager; +import de.winniepat.citrus.ModConfig; +import net.minecraft.client.MinecraftClient; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +public class BetaManager { + private static BetaManager instance; + + private final VerificationClient verificationClient; + private boolean isVerified = false; + private ScheduledExecutorService scheduler; + + private BetaManager() { + this.verificationClient = VerificationClient.getInstance(); + setupPeriodicValidation(); + } + + public static synchronized BetaManager getInstance() { + if (instance == null) { + instance = new BetaManager(); + } + return instance; + } + + public void initialize() { + Client.debugLog("Initializing Beta Verification System"); + + ConfigManager.load(); + + checkVerificationOnStartup(); + } + + public void checkVerificationOnStartup() { + Executors.newSingleThreadExecutor().submit(() -> { + if (!validateSession()) { + Client.debugLog("No valid beta session found"); + + ModConfig.BetaConfig betaConfig = ConfigManager.getBetaConfig(); + if (betaConfig.firstLaunch || (!hasBetaKey() && !hasValidToken())) { + MinecraftClient.getInstance().execute(() -> { + MinecraftClient client = MinecraftClient.getInstance(); + if (client.currentScreen == null) { + client.setScreen(new BetaKeyScreen()); + } + }); + } + } else { + Client.debugLog("Beta verification successful"); + } + }); + } + + public boolean validateSession() { + ModConfig.BetaConfig betaConfig = ConfigManager.getBetaConfig(); + + if (!ConfigManager.hasValidToken()) { + return false; + } + + VerificationResult result = verificationClient.validateSession( + betaConfig.verificationToken + ); + + if (result.isSuccess()) { + isVerified = true; + return true; + } else { + isVerified = false; + betaConfig.verificationToken = ""; + betaConfig.tokenExpiresAt = 0; + ConfigManager.save(); + return false; + } + } + + private void setupPeriodicValidation() { + scheduler = Executors.newScheduledThreadPool(1); + scheduler.scheduleAtFixedRate(() -> { + if (isVerified) { + validateSession(); + } + }, 5, 5, TimeUnit.MINUTES); + } + + public void shutdown() { + if (scheduler != null) { + scheduler.shutdown(); + } + } + + public boolean isVerified() { + return isVerified; + } + + public boolean hasBetaKey() { + return ConfigManager.hasBetaKey(); + } + + public boolean hasValidToken() { + return ConfigManager.hasValidToken(); + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/beta/HardwareIdentifier.java b/src/main/java/de/winniepat/citrus/beta/HardwareIdentifier.java new file mode 100644 index 0000000..fb2ce90 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/beta/HardwareIdentifier.java @@ -0,0 +1,62 @@ +package de.winniepat.citrus.beta; + +import de.winniepat.citrus.Client; +import net.minecraft.client.MinecraftClient; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.UUID; + +public class HardwareIdentifier { + private static HardwareIdentifier instance; + private String hardwareId; + + private HardwareIdentifier() { + this.hardwareId = generateHardwareId(); + } + + public static synchronized HardwareIdentifier getInstance() { + if (instance == null) { + instance = new HardwareIdentifier(); + } + return instance; + } + + private String generateHardwareId() { + try { + StringBuilder sb = new StringBuilder(); + + sb.append(System.getProperty("os.name")); + sb.append(System.getProperty("os.version")); + sb.append(System.getProperty("os.arch")); + sb.append(System.getProperty("user.name")); + sb.append(System.getProperty("java.version")); + sb.append(System.getProperty("user.country")); + sb.append(Runtime.getRuntime().availableProcessors()); + + try { + if (MinecraftClient.getInstance() != null) { + String uuid = String.valueOf(MinecraftClient.getInstance().getSession().getUuidOrNull()); + sb.append(uuid); + } + } catch (Exception e) { + Client.logger.warn("Failed to generate hardware id", e); + } + + String combined = sb.toString(); + String hash = Base64.getEncoder().encodeToString( + combined.getBytes(StandardCharsets.UTF_8) + ); + + return hash.substring(0, Math.min(32, hash.length())); + + } catch (Exception e) { + Client.logger.warn("Failed to generate hardware ID, using UUID fallback", e); + return UUID.randomUUID().toString().replace("-", "").substring(0, 16); + } + } + + public String getHardwareId() { + return hardwareId; + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/beta/VerificationClient.java b/src/main/java/de/winniepat/citrus/beta/VerificationClient.java new file mode 100644 index 0000000..c7bb5fa --- /dev/null +++ b/src/main/java/de/winniepat/citrus/beta/VerificationClient.java @@ -0,0 +1,197 @@ +package de.winniepat.citrus.beta; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import de.winniepat.citrus.Client; +import de.winniepat.citrus.managers.ConfigManager; +import de.winniepat.citrus.ModConfig; +import de.winniepat.citrus.utils.SecurityUtils; +import net.minecraft.client.MinecraftClient; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +public class VerificationClient { + private static VerificationClient instance; + private static final Gson GSON = new Gson(); + + private String getApiKey() { + ModConfig.BetaConfig betaConfig = ConfigManager.getBetaConfig(); + if (betaConfig == null) { + Client.logger.error("Beta config is null, using fallback API key"); + return "ClientF8CjVGn7VTOAhROa65Ot"; + } + + String apiKey = betaConfig.getActiveApiKey(); + + if (apiKey == null || apiKey.trim().isEmpty()) { + Client.logger.error("API key is empty in config, using fallback"); + return "ClientF8CjVGn7VTOAhROa65Ot"; + } + + return apiKey; + } + + private VerificationClient() {} + + public static synchronized VerificationClient getInstance() { + if (instance == null) { + instance = new VerificationClient(); + } + return instance; + } + + public VerificationResult verifyKey(String key, String hardwareId) { + try { + MinecraftClient client = MinecraftClient.getInstance(); + String playerUUID = String.valueOf(client.getSession().getUuidOrNull()); + String playerName = client.getSession().getUsername(); + + String apiUrl = Client.BETA_URL + "/api/verify"; + String apiKey = getApiKey(); + + Client.debugLog("Verifying key at: " + apiUrl); + Client.debugLog("Using API key: " + (apiKey.length() > 8 ? + apiKey.substring(0, 8) + "..." : apiKey)); + + URL url = new URL(apiUrl); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + setupConnection(conn, apiKey); + + JsonObject request = new JsonObject(); + request.addProperty("key", key); + request.addProperty("playerUUID", playerUUID); + request.addProperty("playerName", playerName); + request.addProperty("hardwareFingerprint", hardwareId); + request.addProperty("ipAddress", "127.0.0.1"); + + Client.debugLog("Sending verification request for key: " + key); + Client.debugLog("Player: " + playerName + " (" + playerUUID + ")"); + + try (OutputStream os = conn.getOutputStream()) { + byte[] input = request.toString().getBytes(StandardCharsets.UTF_8); + os.write(input, 0, input.length); + } + + int responseCode = conn.getResponseCode(); + Client.debugLog("Server response code: " + responseCode); + + if (responseCode == 200) { + try (BufferedReader br = new BufferedReader( + new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { + String responseText = br.readLine(); + JsonObject response = GSON.fromJson(responseText, JsonObject.class); + + Client.debugLog("Response: " + responseText); + + if (response.get("valid").getAsBoolean()) { + Client.debugLog("Key verification successful!"); + String linkedUsername = response.has("linkedUsername") && !response.get("linkedUsername").isJsonNull() + ? response.get("linkedUsername").getAsString() : null; + + // Validate that the linked username matches the current player + if (linkedUsername != null && !linkedUsername.isEmpty() && !linkedUsername.equalsIgnoreCase(playerName)) { + Client.logger.warn("Username mismatch! Key is linked to: {}, but current player is: {}", linkedUsername, playerName); + return VerificationResult.failure("This key is linked to " + linkedUsername + ", not " + playerName); + } + + return VerificationResult.success( + response.get("token").getAsString(), + response.get("expiresIn").getAsLong(), + key, + playerUUID, + linkedUsername + ); + } else { + String reason = response.has("reason") ? + response.get("reason").getAsString() : "Unknown error"; + Client.logger.warn("Key verification failed: {}", reason); + return VerificationResult.failure(reason); + } + } + } else if (responseCode == 401) { + Client.logger.error("API key rejected by server. Check your API key in config."); + return VerificationResult.failure("Invalid API key"); + } else if (responseCode == 404) { + Client.logger.error("Server endpoint not found. Check API URL in config. URL: {}", url); + return VerificationResult.failure("Server endpoint not found"); + } else { + Client.logger.error("Verification failed with HTTP code: {}", responseCode); + return VerificationResult.failure("Server error: " + responseCode); + } + } catch (Exception e) { + Client.logger.error("Error verifying key: {}", e.getMessage(), e); + return VerificationResult.failure("Connection error: " + e.getMessage()); + } + } + + public VerificationResult validateSession(String token) { + try { + MinecraftClient client = MinecraftClient.getInstance(); + String currentPlayerName = client.getSession().getUsername(); + + String apiUrl = Client.BETA_URL + "/validate-session"; + String apiKey = getApiKey(); + + URL url = new URL(apiUrl); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + setupConnection(conn, apiKey); + + JsonObject request = new JsonObject(); + request.addProperty("token", token); + + try (OutputStream os = conn.getOutputStream()) { + byte[] input = request.toString().getBytes(StandardCharsets.UTF_8); + os.write(input, 0, input.length); + } + + int responseCode = conn.getResponseCode(); + if (responseCode == 200) { + try (BufferedReader br = new BufferedReader( + new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { + JsonObject response = GSON.fromJson(br, JsonObject.class); + + if (response.get("valid").getAsBoolean()) { + String linkedUsername = response.has("playerName") && !response.get("playerName").isJsonNull() + ? response.get("playerName").getAsString() : null; + + // Validate that the linked username matches the current player + if (linkedUsername != null && !linkedUsername.isEmpty() && !linkedUsername.equalsIgnoreCase(currentPlayerName)) { + Client.logger.warn("Session username mismatch! Session is linked to: {}, but current player is: {}", linkedUsername, currentPlayerName); + return VerificationResult.failure("Session is linked to " + linkedUsername + ", not " + currentPlayerName); + } + + return VerificationResult.success( + token, + (response.get("expiresAt").getAsLong() - System.currentTimeMillis()) / 1000, + "SESSION", + response.get("playerUUID").getAsString(), + linkedUsername + ); + } else { + return VerificationResult.failure("Invalid session"); + } + } + } else { + return VerificationResult.failure("Server error: " + responseCode); + } + } catch (Exception e) { + Client.logger.error("Error validating session", e); + return VerificationResult.failure("Connection error"); + } + } + + private void setupConnection(HttpURLConnection conn, String apiKey) throws Exception { + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("x-api-key", apiKey); + conn.setRequestProperty("x-daily-key", SecurityUtils.getDailyKey()); + conn.setDoOutput(true); + conn.setConnectTimeout(10000); + conn.setReadTimeout(10000); + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/beta/VerificationResult.java b/src/main/java/de/winniepat/citrus/beta/VerificationResult.java new file mode 100644 index 0000000..d0dc752 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/beta/VerificationResult.java @@ -0,0 +1,58 @@ +package de.winniepat.citrus.beta; + +public class VerificationResult { + private final boolean success; + private final String message; + private final String token; + private final long expiresIn; + private final String betaKey; + private final String playerUUID; + private final String linkedUsername; + + private VerificationResult(boolean success, String message, String token, + long expiresIn, String betaKey, String playerUUID, String linkedUsername) { + this.success = success; + this.message = message; + this.token = token; + this.expiresIn = expiresIn; + this.betaKey = betaKey; + this.playerUUID = playerUUID; + this.linkedUsername = linkedUsername; + } + + public static VerificationResult success(String token, long expiresIn, String betaKey, String playerUUID, String linkedUsername) { + return new VerificationResult(true, "Verification successful", token, expiresIn, betaKey, playerUUID, linkedUsername); + } + + public static VerificationResult failure(String message) { + return new VerificationResult(false, message, null, 0, null, null, null); + } + + public boolean isSuccess() { + return success; + } + + public String getMessage() { + return message; + } + + public String getToken() { + return token; + } + + public long getExpiresIn() { + return expiresIn; + } + + public String getBetaKey() { + return betaKey; + } + + public String getPlayerUUID() { + return playerUUID; + } + + public String getLinkedUsername() { + return linkedUsername; + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/client/ClientCommands.java b/src/main/java/de/winniepat/citrus/client/ClientCommands.java new file mode 100644 index 0000000..5ca0b5c --- /dev/null +++ b/src/main/java/de/winniepat/citrus/client/ClientCommands.java @@ -0,0 +1,195 @@ +package de.winniepat.citrus.client; + +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.builder.*; +import de.winniepat.citrus.Client; +import de.winniepat.citrus.cosmetics.capes.*; +import de.winniepat.citrus.cosmetics.capes.sync.CapeSyncManager; +import de.winniepat.citrus.cosmetics.wings.ClientWingManager; +import de.winniepat.citrus.managers.ClientRegistrationManager; +import de.winniepat.citrus.gui.*; +import icyllis.modernui.mc.MuiModApi; +import net.fabricmc.fabric.api.client.command.v2.*; +import net.minecraft.client.MinecraftClient; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.text.Text; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.UUID; + +public class ClientCommands { + + public static void register() { + ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> { + + LiteralArgumentBuilder citrusCommand = + LiteralArgumentBuilder.literal("citrus") + .executes(context -> { + context.getSource().sendFeedback(Text.literal("§f#### §aCitrus §7- by WinniePatGG §f####")); + context.getSource().sendFeedback(Text.literal("§7Version: §7" + Client.CLIENT_BUILD)); + context.getSource().sendFeedback(Text.literal("")); + context.getSource().sendFeedback(Text.literal("§7Available commands:")); + context.getSource().sendFeedback(Text.literal("§e/citrus cape set §7- Set your cape")); + context.getSource().sendFeedback(Text.literal("§e/citrus cape reload §7- Reload available capes from cdn")); + context.getSource().sendFeedback(Text.literal("")); + context.getSource().sendFeedback(Text.literal("§e/citrus capesync clearcache §7- Clear cape sync cache")); + context.getSource().sendFeedback(Text.literal("§e/citrus capesync forcefetch §7- Force fetch capes for all players")); + context.getSource().sendFeedback(Text.literal("")); + context.getSource().sendFeedback(Text.literal("§e/citrus wings toggle §7- Toggle wings visibility")); + context.getSource().sendFeedback(Text.literal("§e/citrus wings set §7- Set your wing type")); + context.getSource().sendFeedback(Text.literal("§e/citrus wings list §7- List available wing types")); + context.getSource().sendFeedback(Text.literal("")); + context.getSource().sendFeedback(Text.literal("§e/citrus status §7- Show Citrus status")); + context.getSource().sendFeedback(Text.literal("§e/citrus screen §7- Open a client screen")); + return 1; + }); + + citrusCommand.then(LiteralArgumentBuilder.literal("screen") + .then(RequiredArgumentBuilder + .argument("name", StringArgumentType.word()) + .suggests((context, builder) -> { + builder.suggest("showcase"); + builder.suggest("friends"); + builder.suggest("profiles"); + builder.suggest("hud"); + builder.suggest("menu"); + return builder.buildFuture(); + }) + .executes(context -> { + String name = StringArgumentType.getString(context, "name").toLowerCase(); + MinecraftClient client = MinecraftClient.getInstance(); + + client.execute(() -> { + switch (name) { + case "friends" -> client.setScreen(MuiModApi.get().createScreen(new FriendsFragment(), null, client.currentScreen)); + case "profiles" -> client.setScreen(MuiModApi.get().createScreen(new ProfileScreen(), null, client.currentScreen)); + case "hud" -> client.setScreen(MuiModApi.get().createScreen(new HudEditorFragment(), null, client.currentScreen)); + case "menu" -> client.setScreen(MuiModApi.get().createScreen(new MainMenuScreen(), null, null)); + case "showcase" -> client.setScreen(MuiModApi.get().createScreen(new TestScreen(), null, client.currentScreen)); + default -> context.getSource().sendError(Text.literal("§cUnknown screen: §e" + name)); + } + }); + + return 1; + }) + ) + ); + + citrusCommand.then(LiteralArgumentBuilder.literal("cape") + .then(LiteralArgumentBuilder.literal("set") + .then(RequiredArgumentBuilder + .argument("cape_name", StringArgumentType.string()) + .suggests((context, builder) -> { + builder.suggest("none"); + builder.suggest("auto"); + for (String cape : CapeHandler.getAvailableCapes()) { + builder.suggest(cape); + } + return builder.buildFuture(); + }) + .executes(context -> { + String capeName = StringArgumentType.getString(context, "cape_name"); + MinecraftClient client = MinecraftClient.getInstance(); + + if (client.player == null) { + context.getSource().sendError(Text.literal("§cYou must be in-game!")); + return 0; + } + + if (CapeHandler.capeExists(capeName)) { + CapeHandler.setLocalPlayerCape(capeName, false); + context.getSource().sendFeedback(Text.literal("§aCape set to: §e" + capeName)); + } else { + context.getSource().sendError(Text.literal("§cUnknown cape: " + capeName)); + } + return 1; + }) + ) + ) + .then(LiteralArgumentBuilder.literal("reload") + .executes(context -> { + context.getSource().sendFeedback(Text.literal("§aReloading capes...")); + CapeHandler.reloadCapes(); + return 1; + }) + ) + ); + + citrusCommand.then(LiteralArgumentBuilder.literal("capesync") + .then(LiteralArgumentBuilder.literal("clearcache") + .executes(context -> { + CapeSyncManager.clearAllCache(); + context.getSource().sendFeedback(Text.literal("§aCache cleared.")); + return 1; + }) + ) + .then(LiteralArgumentBuilder.literal("forcefetch") + .executes(context -> { + MinecraftClient client = MinecraftClient.getInstance(); + + if (client.player == null || client.world == null) { + context.getSource().sendError(Text.literal("§cYou must be in-game!")); + return 0; + } + + List playerUUIDs = client.world.getPlayers().stream() + .filter(p -> p != client.player) + .map(PlayerEntity::getUuid) + .toList(); + + CapeSyncManager.batchFetchCapes(playerUUIDs); + context.getSource().sendFeedback(Text.literal( + "§aFetching capes for §e" + playerUUIDs.size() + "§a players..." + )); + return 1; + }) + ) + ); + + citrusCommand.then(LiteralArgumentBuilder.literal("status") + .executes(context -> { + context.getSource().sendFeedback(Text.literal("--- §aCitrus Status §7---")); + context.getSource().sendFeedback(Text.literal("")); + + boolean healthy = CapeSyncManager.isIsHealthy(); + String serverHealth = healthy + ? "§ahealthy" + : "§cexperiencing issues or is offline"; + context.getSource().sendFeedback(Text.literal("CapeSync Server: " + serverHealth)); + + int capesAvailable = CapeHandler.getAvailableCapes().size(); + context.getSource().sendFeedback(Text.literal("§aCapes available: §e" + capesAvailable)); + + MinecraftClient client = MinecraftClient.getInstance(); + if (client.player != null) { + String activeWings = de.winniepat.citrus.cosmetics.wings.ClientWingManager.getActiveWings(client.player.getUuid()); + boolean wingsVisible = ClientWingManager.areWingsVisible(client.player.getUuid()); + String wingStatus = "§aWing type: §e" + activeWings + "§a, " + (wingsVisible ? "§avisible" : "§chidden"); + context.getSource().sendFeedback(Text.literal(wingStatus)); + } + + String registrationStatus = getRegistrationStatus(healthy); + context.getSource().sendFeedback(Text.literal("Registration: " + registrationStatus)); + + return 1; + }) + ); + + dispatcher.register(citrusCommand); + }); + } + + private static @NotNull String getRegistrationStatus(boolean healthy) { + boolean registered = ClientRegistrationManager.isRegistered(); + + String registrationStatus = registered + ? "§aYou are registered with the Citrus API" + : "§cYou are not registered with the Citrus API"; + + if (!healthy && registered) { + registrationStatus += " §7(§eStatus may be inaccurate because Backend has issues or is offline§7)"; + } + return registrationStatus; + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/client/ClientKeybinds.java b/src/main/java/de/winniepat/citrus/client/ClientKeybinds.java new file mode 100644 index 0000000..e0b73ba --- /dev/null +++ b/src/main/java/de/winniepat/citrus/client/ClientKeybinds.java @@ -0,0 +1,44 @@ +package de.winniepat.citrus.client; + +import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper; +import net.minecraft.client.option.KeyBinding; +import net.minecraft.client.util.InputUtil; +import org.lwjgl.glfw.GLFW; + +public class ClientKeybinds { + + private static final String CATEGORY = "category.citrus.general"; + + public static KeyBinding OPEN_DRAGGUI; + public static KeyBinding OPEN_CAPE_SCREEN; + public static KeyBinding REFRESH_CLIENT; + public static KeyBinding TOGGLE_FULLBRIGHT; + public static KeyBinding ZOOM_KEY; + public static KeyBinding OPEN_MODULETOGGLE; + public static KeyBinding OPEN_FRIENDS_SCREEN; + public static KeyBinding OPEN_COSMETICS_SCREEN; + + public static KeyBinding OPEN_BETA_KEY_SCREEN; + + public static void register() { + OPEN_DRAGGUI = new KeyBinding("key.citrus.overlay_editor", InputUtil.Type.KEYSYM, GLFW.GLFW_KEY_RIGHT_SHIFT, CATEGORY); + OPEN_CAPE_SCREEN = new KeyBinding("key.citrus.open_cape_screen", InputUtil.Type.KEYSYM, GLFW.GLFW_KEY_K, CATEGORY); + REFRESH_CLIENT = new KeyBinding("key.citrus.refresh_client", InputUtil.Type.KEYSYM, GLFW.GLFW_KEY_J, CATEGORY); + TOGGLE_FULLBRIGHT = new KeyBinding("key.citrus.toggle_fullbright", InputUtil.Type.KEYSYM, GLFW.GLFW_KEY_G, CATEGORY); + ZOOM_KEY = new KeyBinding("key.citrus.zoom", InputUtil.Type.KEYSYM, GLFW.GLFW_KEY_C, CATEGORY); + OPEN_MODULETOGGLE = new KeyBinding("key.citrus.module_toggle", InputUtil.Type.KEYSYM, GLFW.GLFW_KEY_RIGHT_ALT, CATEGORY); + OPEN_FRIENDS_SCREEN = new KeyBinding("key.citrus.open_friends_screen", InputUtil.Type.KEYSYM, GLFW.GLFW_KEY_H, CATEGORY); + OPEN_COSMETICS_SCREEN = new KeyBinding("key.citrus.open_cosmetics_screen", InputUtil.Type.KEYSYM, GLFW.GLFW_KEY_L, CATEGORY); + OPEN_BETA_KEY_SCREEN = new KeyBinding("key.citrus.open_beta_key_screen", InputUtil.Type.KEYSYM, GLFW.GLFW_KEY_B, CATEGORY); + + KeyBindingHelper.registerKeyBinding(OPEN_DRAGGUI); + KeyBindingHelper.registerKeyBinding(OPEN_CAPE_SCREEN); + KeyBindingHelper.registerKeyBinding(REFRESH_CLIENT); + KeyBindingHelper.registerKeyBinding(TOGGLE_FULLBRIGHT); + KeyBindingHelper.registerKeyBinding(ZOOM_KEY); + KeyBindingHelper.registerKeyBinding(OPEN_MODULETOGGLE); + KeyBindingHelper.registerKeyBinding(OPEN_FRIENDS_SCREEN); + KeyBindingHelper.registerKeyBinding(OPEN_COSMETICS_SCREEN); + KeyBindingHelper.registerKeyBinding(OPEN_BETA_KEY_SCREEN); + } +} diff --git a/src/main/java/de/winniepat/citrus/client/CosmeticCommands.java b/src/main/java/de/winniepat/citrus/client/CosmeticCommands.java new file mode 100644 index 0000000..01f20f8 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/client/CosmeticCommands.java @@ -0,0 +1,87 @@ +package de.winniepat.citrus.client; + +import com.mojang.brigadier.Command; +import com.mojang.brigadier.arguments.StringArgumentType; +import de.winniepat.citrus.cosmetics.ClientCosmeticManager; +import de.winniepat.citrus.cosmetics.CosmeticScreen; +import net.fabricmc.fabric.api.client.command.v2.ClientCommandManager; +import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback; +import net.minecraft.text.Text; + +import java.util.UUID; + +public class CosmeticCommands { + public static void register() { + ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> dispatcher.register(ClientCommandManager.literal("cosmetic") + .then(ClientCommandManager.literal("wings") + .then(ClientCommandManager.literal("toggle") + .executes(context -> { + UUID uuid = context.getSource().getPlayer().getUuid(); + ClientCosmeticManager.toggleWings(uuid); + context.getSource().sendFeedback(Text.of("Toggled wings visibility")); + return Command.SINGLE_SUCCESS; + }) + ) + .then(ClientCommandManager.literal("set") + .then(ClientCommandManager.argument("type", StringArgumentType.string()) + .executes(context -> { + UUID uuid = context.getSource().getPlayer().getUuid(); + String type = StringArgumentType.getString(context, "type"); + ClientCosmeticManager.setActiveWings(uuid, type); + context.getSource().sendFeedback(Text.of("Set wings to: " + type)); + return Command.SINGLE_SUCCESS; + }) + ) + ) + ) + .then(ClientCommandManager.literal("hat") + .then(ClientCommandManager.literal("toggle") + .executes(context -> { + UUID uuid = context.getSource().getPlayer().getUuid(); + ClientCosmeticManager.toggleHat(uuid); + context.getSource().sendFeedback(Text.of("Toggled hat visibility")); + return Command.SINGLE_SUCCESS; + }) + ) + .then(ClientCommandManager.literal("set") + .then(ClientCommandManager.argument("type", StringArgumentType.string()) + .executes(context -> { + UUID uuid = context.getSource().getPlayer().getUuid(); + String type = StringArgumentType.getString(context, "type"); + ClientCosmeticManager.setActiveHat(uuid, type); + context.getSource().sendFeedback(Text.of("Set hat to: " + type)); + return Command.SINGLE_SUCCESS; + }) + ) + ) + ) + .then(ClientCommandManager.literal("halo") + .then(ClientCommandManager.literal("toggle") + .executes(context -> { + UUID uuid = context.getSource().getPlayer().getUuid(); + ClientCosmeticManager.toggleHalo(uuid); + context.getSource().sendFeedback(Text.of("Toggled halo visibility")); + return Command.SINGLE_SUCCESS; + }) + ) + .then(ClientCommandManager.literal("set") + .then(ClientCommandManager.argument("type", StringArgumentType.string()) + .executes(context -> { + UUID uuid = context.getSource().getPlayer().getUuid(); + String type = StringArgumentType.getString(context, "type"); + ClientCosmeticManager.setActiveHalo(uuid, type); + context.getSource().sendFeedback(Text.of("Set halo to: " + type)); + return Command.SINGLE_SUCCESS; + }) + ) + ) + ) + .then(ClientCommandManager.literal("gui") + .executes(context -> { + context.getSource().getClient().setScreen(new CosmeticScreen()); + return Command.SINGLE_SUCCESS; + }) + ) + )); + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/cosmetics/ClientCosmeticManager.java b/src/main/java/de/winniepat/citrus/cosmetics/ClientCosmeticManager.java new file mode 100644 index 0000000..0f9ae75 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/cosmetics/ClientCosmeticManager.java @@ -0,0 +1,161 @@ +package de.winniepat.citrus.cosmetics; + +import com.google.gson.*; +import de.winniepat.citrus.Client; + +import java.io.*; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +public class ClientCosmeticManager { + private static final File CONFIG_FILE = new File(Client.CONFIG_DIR, "citrus_cosmetics.json"); + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private static Map cosmeticDataMap = new HashMap<>(); + + public static class PlayerCosmeticData { + public String activeWings = "none"; + public boolean wingsVisible = true; + public String activeHat = "none"; + public boolean hatVisible = true; + public String activeHalo = "none"; + public boolean haloVisible = true; + } + + static { + load(); + } + + private static void load() { + if (CONFIG_FILE.exists()) { + try (FileReader reader = new FileReader(CONFIG_FILE)) { + JsonObject json = JsonParser.parseReader(reader).getAsJsonObject(); + cosmeticDataMap.clear(); + + for (Map.Entry entry : json.entrySet()) { + UUID uuid = UUID.fromString(entry.getKey()); + JsonObject data = entry.getValue().getAsJsonObject(); + PlayerCosmeticData cosmeticData = new PlayerCosmeticData(); + + if (data.has("activeWings")) { + cosmeticData.activeWings = data.get("activeWings").getAsString(); + } + if (data.has("wingsVisible")) { + cosmeticData.wingsVisible = data.get("wingsVisible").getAsBoolean(); + } + if (data.has("activeHat")) { + cosmeticData.activeHat = data.get("activeHat").getAsString(); + } + if (data.has("hatVisible")) { + cosmeticData.hatVisible = data.get("hatVisible").getAsBoolean(); + } + if (data.has("activeHalo")) { + cosmeticData.activeHalo = data.get("activeHalo").getAsString(); + } + if (data.has("haloVisible")) { + cosmeticData.haloVisible = data.get("haloVisible").getAsBoolean(); + } + + cosmeticDataMap.put(uuid, cosmeticData); + } + } catch (Exception e) { + System.err.println("Failed to load cosmetic data: " + e.getMessage()); + } + } + } + + public static void save() { + try { + if (!CONFIG_FILE.getParentFile().exists()) { + CONFIG_FILE.getParentFile().mkdirs(); + } + + try (FileWriter writer = new FileWriter(CONFIG_FILE)) { + JsonObject json = new JsonObject(); + for (Map.Entry entry : cosmeticDataMap.entrySet()) { + JsonObject data = new JsonObject(); + data.addProperty("activeWings", entry.getValue().activeWings); + data.addProperty("wingsVisible", entry.getValue().wingsVisible); + data.addProperty("activeHat", entry.getValue().activeHat); + data.addProperty("hatVisible", entry.getValue().hatVisible); + data.addProperty("activeHalo", entry.getValue().activeHalo); + data.addProperty("haloVisible", entry.getValue().haloVisible); + json.add(entry.getKey().toString(), data); + } + GSON.toJson(json, writer); + } + } catch (Exception e) { + System.err.println("Failed to save cosmetic data: " + e.getMessage()); + } + } + + public static PlayerCosmeticData getData(UUID playerUUID) { + return cosmeticDataMap.computeIfAbsent(playerUUID, uuid -> new PlayerCosmeticData()); + } + + public static String getActiveWings(UUID playerUUID) { + return getData(playerUUID).activeWings; + } + + public static boolean areWingsVisible(UUID playerUUID) { + return getData(playerUUID).wingsVisible; + } + + public static void setActiveWings(UUID playerUUID, String wingId) { + PlayerCosmeticData data = getData(playerUUID); + data.activeWings = wingId.toLowerCase(); + save(); + CosmeticSyncManager.sendCosmeticsToServer(playerUUID, data); + } + + public static void toggleWings(UUID playerUUID) { + PlayerCosmeticData data = getData(playerUUID); + data.wingsVisible = !data.wingsVisible; + save(); + CosmeticSyncManager.sendCosmeticsToServer(playerUUID, data); + } + + public static String getActiveHat(UUID playerUUID) { + return getData(playerUUID).activeHat; + } + + public static boolean isHatVisible(UUID playerUUID) { + return getData(playerUUID).hatVisible; + } + + public static void setActiveHat(UUID playerUUID, String hatId) { + PlayerCosmeticData data = getData(playerUUID); + data.activeHat = hatId.toLowerCase(); + save(); + CosmeticSyncManager.sendCosmeticsToServer(playerUUID, data); + } + + public static void toggleHat(UUID playerUUID) { + PlayerCosmeticData data = getData(playerUUID); + data.hatVisible = !data.hatVisible; + save(); + CosmeticSyncManager.sendCosmeticsToServer(playerUUID, data); + } + + public static String getActiveHalo(UUID playerUUID) { + return getData(playerUUID).activeHalo; + } + + public static boolean isHaloVisible(UUID playerUUID) { + return getData(playerUUID).haloVisible; + } + + public static void setActiveHalo(UUID playerUUID, String haloId) { + PlayerCosmeticData data = getData(playerUUID); + data.activeHalo = haloId.toLowerCase(); + save(); + CosmeticSyncManager.sendCosmeticsToServer(playerUUID, data); + } + + public static void toggleHalo(UUID playerUUID) { + PlayerCosmeticData data = getData(playerUUID); + data.haloVisible = !data.haloVisible; + save(); + CosmeticSyncManager.sendCosmeticsToServer(playerUUID, data); + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/cosmetics/CosmeticManager.java b/src/main/java/de/winniepat/citrus/cosmetics/CosmeticManager.java new file mode 100644 index 0000000..6ad067a --- /dev/null +++ b/src/main/java/de/winniepat/citrus/cosmetics/CosmeticManager.java @@ -0,0 +1,134 @@ +package de.winniepat.citrus.cosmetics; + +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.nbt.NbtCompound; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +public class CosmeticManager { + private static final Map playerData = new HashMap<>(); + + public static class PlayerCosmeticData { + private String activeWings = "none"; + private boolean wingsVisible = true; + private String activeHat = "none"; + private boolean hatVisible = true; + private String activeHalo = "none"; + private boolean haloVisible = true; + + public void setActiveWings(String wingId) { + this.activeWings = wingId; + } + + public String getActiveWings() { + return activeWings; + } + + public void setWingsVisible(boolean visible) { + this.wingsVisible = visible; + } + + public boolean areWingsVisible() { + return wingsVisible; + } + + public void setActiveHat(String hatId) { + this.activeHat = hatId; + } + + public String getActiveHat() { + return activeHat; + } + + public void setHatVisible(boolean visible) { + this.hatVisible = visible; + } + + public boolean isHatVisible() { + return hatVisible; + } + + public void setActiveHalo(String haloId) { + this.activeHalo = haloId; + } + + public String getActiveHalo() { + return activeHalo; + } + + public void setHaloVisible(boolean visible) { + this.haloVisible = visible; + } + + public boolean isHaloVisible() { + return haloVisible; + } + + public NbtCompound toNbt() { + NbtCompound nbt = new NbtCompound(); + nbt.putString("activeWings", activeWings); + nbt.putBoolean("wingsVisible", wingsVisible); + nbt.putString("activeHat", activeHat); + nbt.putBoolean("hatVisible", hatVisible); + nbt.putString("activeHalo", activeHalo); + nbt.putBoolean("haloVisible", haloVisible); + return nbt; + } + + public static PlayerCosmeticData fromNbt(NbtCompound nbt) { + PlayerCosmeticData data = new PlayerCosmeticData(); + if (nbt.contains("activeWings")) { + data.activeWings = nbt.getString("activeWings"); + } + if (nbt.contains("wingsVisible")) { + data.wingsVisible = nbt.getBoolean("wingsVisible"); + } + if (nbt.contains("activeHat")) { + data.activeHat = nbt.getString("activeHat"); + } + if (nbt.contains("hatVisible")) { + data.hatVisible = nbt.getBoolean("hatVisible"); + } + if (nbt.contains("activeHalo")) { + data.activeHalo = nbt.getString("activeHalo"); + } + if (nbt.contains("haloVisible")) { + data.haloVisible = nbt.getBoolean("haloVisible"); + } + return data; + } + } + + public static PlayerCosmeticData getData(PlayerEntity player) { + return playerData.computeIfAbsent(player.getUuid(), uuid -> new PlayerCosmeticData()); + } + + public static void setWings(PlayerEntity player, String wingId) { + getData(player).setActiveWings(wingId); + } + + public static void toggleWings(PlayerEntity player) { + PlayerCosmeticData data = getData(player); + data.setWingsVisible(!data.areWingsVisible()); + } + + public static void setHat(PlayerEntity player, String hatId) { + getData(player).setActiveHat(hatId); + } + + public static void toggleHat(PlayerEntity player) { + PlayerCosmeticData data = getData(player); + data.setHatVisible(!data.isHatVisible()); + } + + public static void setHalo(PlayerEntity player, String haloId) { + getData(player).setActiveHalo(haloId); + } + + public static void toggleHalo(PlayerEntity player) { + PlayerCosmeticData data = getData(player); + data.setHaloVisible(!data.isHaloVisible()); + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/cosmetics/CosmeticScreen.java b/src/main/java/de/winniepat/citrus/cosmetics/CosmeticScreen.java new file mode 100644 index 0000000..9cabc7f --- /dev/null +++ b/src/main/java/de/winniepat/citrus/cosmetics/CosmeticScreen.java @@ -0,0 +1,246 @@ +package de.winniepat.citrus.cosmetics; + +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; + +import java.util.UUID; + +public class CosmeticScreen extends Screen { + private static final int BUTTON_WIDTH = 200; + private static final int BUTTON_HEIGHT = 20; + private static final int PADDING = 5; + + private UUID playerUUID; + + public CosmeticScreen() { + super(Text.of("Cosmetics")); + MinecraftClient client = MinecraftClient.getInstance(); + this.playerUUID = client.player != null ? client.player.getUuid() : null; + } + + @Override + protected void init() { + if (playerUUID == null) { + this.close(); + return; + } + + int centerX = this.width / 2; + int y = this.height / 4; + + this.addDrawableChild(ButtonWidget.builder( + Text.of("§6§lCOSMETICS").copy().formatted(Formatting.BOLD), + button -> {} + ).dimensions(centerX - 100, y - 30, 200, 20).build()); + + y += 40; + + this.addDrawableChild(ButtonWidget.builder( + Text.of("§e§lWINGS").copy().formatted(Formatting.BOLD), + button -> {} + ).dimensions(centerX - 100, y, 200, 20).build()); + y += 25; + + String wingsStatus = ClientCosmeticManager.areWingsVisible(playerUUID) ? "§aVisible" : "§cHidden"; + this.addDrawableChild(ButtonWidget.builder( + Text.of("Toggle Wings: " + wingsStatus), + button -> { + ClientCosmeticManager.toggleWings(playerUUID); + this.clearAndInit(); + } + ).dimensions(centerX - 100, y, 200, BUTTON_HEIGHT).build()); + y += 25; + + this.addDrawableChild(ButtonWidget.builder( + Text.of("Select Wing Type"), + button -> { + MinecraftClient.getInstance().setScreen(new WingSelectionScreen()); + } + ).dimensions(centerX - 100, y, 200, BUTTON_HEIGHT).build()); + + y += 40; + + this.addDrawableChild(ButtonWidget.builder( + Text.of("§e§lHAT").copy().formatted(Formatting.BOLD), + button -> {} + ).dimensions(centerX - 100, y, 200, 20).build()); + y += 25; + + String hatStatus = ClientCosmeticManager.isHatVisible(playerUUID) ? "§aVisible" : "§cHidden"; + this.addDrawableChild(ButtonWidget.builder( + Text.of("Toggle Hat: " + hatStatus), + button -> { + ClientCosmeticManager.toggleHat(playerUUID); + this.clearAndInit(); + } + ).dimensions(centerX - 100, y, 200, BUTTON_HEIGHT).build()); + y += 25; + + this.addDrawableChild(ButtonWidget.builder( + Text.of("Select Hat Type"), + button -> { + MinecraftClient.getInstance().setScreen(new HatSelectionScreen()); + } + ).dimensions(centerX - 100, y, 200, BUTTON_HEIGHT).build()); + + y += 40; + + this.addDrawableChild(ButtonWidget.builder( + Text.of("§e§lHALO").copy().formatted(Formatting.BOLD), + button -> {} + ).dimensions(centerX - 100, y, 200, 20).build()); + y += 25; + + String haloStatus = ClientCosmeticManager.isHaloVisible(playerUUID) ? "§aVisible" : "§cHidden"; + this.addDrawableChild(ButtonWidget.builder( + Text.of("Toggle Halo: " + haloStatus), + button -> { + ClientCosmeticManager.toggleHalo(playerUUID); + this.clearAndInit(); + } + ).dimensions(centerX - 100, y, 200, BUTTON_HEIGHT).build()); + y += 25; + + this.addDrawableChild(ButtonWidget.builder( + Text.of("Select Halo Type"), + button -> { + MinecraftClient.getInstance().setScreen(new HaloSelectionScreen()); + } + ).dimensions(centerX - 100, y, 200, BUTTON_HEIGHT).build()); + + y += 50; + + this.addDrawableChild(ButtonWidget.builder( + Text.of("§cClose"), + button -> this.close() + ).dimensions(centerX - 50, y, 100, BUTTON_HEIGHT).build()); + } + + @Override + public void render(DrawContext context, int mouseX, int mouseY, float delta) { + this.renderBackground(context, mouseX, mouseY, delta); + super.render(context, mouseX, mouseY, delta); + + context.drawCenteredTextWithShadow( + this.textRenderer, + Text.of("§6§lCosmetics Menu").copy().formatted(Formatting.BOLD), + this.width / 2, + 30, + 0xFFFFFF + ); + } + + @Override + public boolean shouldPause() { + return false; + } +} + +class WingSelectionScreen extends Screen { + private final de.winniepat.citrus.cosmetics.wings.WingType[] wingTypes; + + public WingSelectionScreen() { + super(Text.of("Select Wings")); + wingTypes = de.winniepat.citrus.cosmetics.wings.WingType.getAvailableTypes(); + } + + @Override + protected void init() { + int centerX = this.width / 2; + int y = 60; + + for (de.winniepat.citrus.cosmetics.wings.WingType type : wingTypes) { + if (type == de.winniepat.citrus.cosmetics.wings.WingType.NONE) continue; + + this.addDrawableChild(ButtonWidget.builder( + Text.of("Select: " + type.getId()), + button -> { + UUID uuid = MinecraftClient.getInstance().player.getUuid(); + ClientCosmeticManager.setActiveWings(uuid, type.getId()); + this.close(); + } + ).dimensions(centerX - 100, y, 200, 20).build()); + y += 25; + } + + y += 20; + this.addDrawableChild(ButtonWidget.builder( + Text.of("§cBack"), + button -> this.close() + ).dimensions(centerX - 50, y, 100, 20).build()); + } +} + +class HatSelectionScreen extends Screen { + private final de.winniepat.citrus.cosmetics.hat.HatType[] hatTypes; + + public HatSelectionScreen() { + super(Text.of("Select Hat")); + hatTypes = de.winniepat.citrus.cosmetics.hat.HatType.getAvailableTypes(); + } + + @Override + protected void init() { + int centerX = this.width / 2; + int y = 60; + + for (de.winniepat.citrus.cosmetics.hat.HatType type : hatTypes) { + if (type == de.winniepat.citrus.cosmetics.hat.HatType.NONE) continue; + + this.addDrawableChild(ButtonWidget.builder( + Text.of("Select: " + type.getId()), + button -> { + UUID uuid = MinecraftClient.getInstance().player.getUuid(); + ClientCosmeticManager.setActiveHat(uuid, type.getId()); + this.close(); + } + ).dimensions(centerX - 100, y, 200, 20).build()); + y += 25; + } + + y += 20; + this.addDrawableChild(ButtonWidget.builder( + Text.of("§cBack"), + button -> this.close() + ).dimensions(centerX - 50, y, 100, 20).build()); + } +} + +class HaloSelectionScreen extends Screen { + private final de.winniepat.citrus.cosmetics.halo.HaloType[] haloTypes; + + public HaloSelectionScreen() { + super(Text.of("Select Halo")); + haloTypes = de.winniepat.citrus.cosmetics.halo.HaloType.getAvailableTypes(); + } + + @Override + protected void init() { + int centerX = this.width / 2; + int y = 60; + + for (de.winniepat.citrus.cosmetics.halo.HaloType type : haloTypes) { + if (type == de.winniepat.citrus.cosmetics.halo.HaloType.NONE) continue; + + this.addDrawableChild(ButtonWidget.builder( + Text.of("Select: " + type.getId()), + button -> { + UUID uuid = MinecraftClient.getInstance().player.getUuid(); + ClientCosmeticManager.setActiveHalo(uuid, type.getId()); + this.close(); + } + ).dimensions(centerX - 100, y, 200, 20).build()); + y += 25; + } + + y += 20; + this.addDrawableChild(ButtonWidget.builder( + Text.of("§cBack"), + button -> this.close() + ).dimensions(centerX - 50, y, 100, 20).build()); + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/cosmetics/CosmeticSyncManager.java b/src/main/java/de/winniepat/citrus/cosmetics/CosmeticSyncManager.java new file mode 100644 index 0000000..e9c05db --- /dev/null +++ b/src/main/java/de/winniepat/citrus/cosmetics/CosmeticSyncManager.java @@ -0,0 +1,433 @@ +package de.winniepat.citrus.cosmetics; + +import com.google.gson.*; +import de.winniepat.citrus.Client; +import de.winniepat.citrus.managers.ClientRegistrationManager; +import de.winniepat.citrus.utils.SecurityUtils; +import net.minecraft.client.MinecraftClient; +import net.minecraft.util.Util; + +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; + +public class CosmeticSyncManager { + private static final String API_BASE_URL = Client.API_URL; + private static final Gson GSON = new GsonBuilder().create(); + + public static final Map SYNCED_COSMETIC_CACHE = new ConcurrentHashMap<>(); + private static final Map LAST_SYNC_TIME = new ConcurrentHashMap<>(); + private static final Map PENDING_UPDATES = new ConcurrentHashMap<>(); + private static final Set PENDING_FETCHES = ConcurrentHashMap.newKeySet(); + + private static boolean hasSentInitialCosmetics = false; + private static boolean isInitializing = false; + private static boolean autoSyncEnabled = true; + private static Timer syncTimer; + + public static class SyncedCosmeticData { + public String wingType = "none"; + public boolean wingsVisible = true; + public String hatType = "none"; + public boolean hatVisible = true; + public String haloType = "none"; + public boolean haloVisible = true; + + public JsonObject toJson() { + JsonObject json = new JsonObject(); + json.addProperty("wingType", wingType); + json.addProperty("wingsVisible", wingsVisible); + json.addProperty("hatType", hatType); + json.addProperty("hatVisible", hatVisible); + json.addProperty("haloType", haloType); + json.addProperty("haloVisible", haloVisible); + return json; + } + + public static SyncedCosmeticData fromJson(JsonObject json) { + SyncedCosmeticData data = new SyncedCosmeticData(); + if (json.has("wingType")) data.wingType = json.get("wingType").getAsString(); + if (json.has("wingsVisible")) data.wingsVisible = json.get("wingsVisible").getAsBoolean(); + if (json.has("hatType")) data.hatType = json.get("hatType").getAsString(); + if (json.has("hatVisible")) data.hatVisible = json.get("hatVisible").getAsBoolean(); + if (json.has("haloType")) data.haloType = json.get("haloType").getAsString(); + if (json.has("haloVisible")) data.haloVisible = json.get("haloVisible").getAsBoolean(); + return data; + } + + public static SyncedCosmeticData fromClientCosmeticData(ClientCosmeticManager.PlayerCosmeticData clientData) { + SyncedCosmeticData data = new SyncedCosmeticData(); + data.wingType = clientData.activeWings; + data.wingsVisible = clientData.wingsVisible; + data.hatType = clientData.activeHat; + data.hatVisible = clientData.hatVisible; + data.haloType = clientData.activeHalo; + data.haloVisible = clientData.haloVisible; + return data; + } + + public ClientCosmeticManager.PlayerCosmeticData toClientCosmeticData() { + ClientCosmeticManager.PlayerCosmeticData data = new ClientCosmeticManager.PlayerCosmeticData(); + data.activeWings = this.wingType; + data.wingsVisible = this.wingsVisible; + data.activeHat = this.hatType; + data.hatVisible = this.hatVisible; + data.activeHalo = this.haloType; + data.haloVisible = this.haloVisible; + return data; + } + } + + public static void init() { + Client.debugLog("Initializing cosmetic sync..."); + isInitializing = true; + startAutoSync(); + scheduleInitialSync(); + } + + private static void scheduleInitialSync() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client.player != null) { + performInitialSync(); + } else { + Util.getMainWorkerExecutor().execute(() -> { + try { + Thread.sleep(2000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + performInitialSync(); + }); + } + } + + private static void performInitialSync() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client.player == null) { + isInitializing = false; + return; + } + + UUID uuid = client.player.getUuid(); + String playerName = client.player.getName().getString(); + + Client.debugLog("Performing initial cosmetic sync for " + playerName); + fetchCosmeticsFromServer(uuid, playerName, true).thenAccept(serverData -> { + if (serverData != null) { + applyServerCosmeticsToLocal(uuid, serverData); + } else { + ClientCosmeticManager.PlayerCosmeticData localData = ClientCosmeticManager.getData(uuid); + if (localData != null) { + SyncedCosmeticData syncData = SyncedCosmeticData.fromClientCosmeticData(localData); + sendCosmeticsToServer(uuid, syncData, playerName, true); + } + } + hasSentInitialCosmetics = true; + isInitializing = false; + }); + } + + public static SyncedCosmeticData getSyncedCosmetics(UUID uuid, String playerName) { + if (!ClientRegistrationManager.isPlayerOnlineWithCitrus(uuid)) { + return null; + } + + if (isInitializing && isLocalPlayer(uuid)) { + return null; + } + + if (SYNCED_COSMETIC_CACHE.containsKey(uuid)) { + long lastSync = LAST_SYNC_TIME.getOrDefault(uuid, 0L); + if (System.currentTimeMillis() - lastSync < 300000) { + return SYNCED_COSMETIC_CACHE.get(uuid); + } + } + + if (isLocalPlayer(uuid) && !hasSentInitialCosmetics && !isInitializing) { + performInitialSync(); + return null; + } + + if (!PENDING_FETCHES.contains(uuid)) { + fetchCosmeticsFromServer(uuid, playerName, false); + } + + return null; + } + + private static CompletableFuture fetchCosmeticsFromServer(UUID uuid, String playerName, boolean isInitial) { + PENDING_FETCHES.add(uuid); + return CompletableFuture.supplyAsync(() -> { + try { + String urlString = API_BASE_URL + "/cosmetics/" + uuid.toString(); + URL url = new URI(urlString).toURL(); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(10000); + conn.setReadTimeout(10000); + conn.setRequestProperty("x-daily-key", SecurityUtils.getDailyKey()); + + if (conn.getResponseCode() == 200) { + try (InputStream is = conn.getInputStream()) { + String responseBody = new String(is.readAllBytes(), StandardCharsets.UTF_8); + JsonObject json = GSON.fromJson(responseBody, JsonObject.class); + SyncedCosmeticData cosmeticData = SyncedCosmeticData.fromJson(json); + + SYNCED_COSMETIC_CACHE.put(uuid, cosmeticData); + LAST_SYNC_TIME.put(uuid, System.currentTimeMillis()); + return cosmeticData; + } + } + } catch (Exception e) { + Client.logger.error("Exception fetching cosmetics: {}", e.getMessage()); + } finally { + PENDING_FETCHES.remove(uuid); + } + return null; + }, Util.getMainWorkerExecutor()); + } + + public static void sendCosmeticsToServer(UUID uuid, SyncedCosmeticData cosmeticData, String playerName, boolean isInitial) { + CompletableFuture.runAsync(() -> { + try { + URL url = new URI(API_BASE_URL + "/cosmetics").toURL(); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("x-daily-key", SecurityUtils.getDailyKey()); + conn.setDoOutput(true); + conn.setConnectTimeout(3000); + conn.setReadTimeout(3000); + + JsonObject json = new JsonObject(); + json.addProperty("uuid", uuid.toString()); + json.addProperty("playerName", playerName); + json.add("cosmetics", cosmeticData.toJson()); + + try (OutputStream os = conn.getOutputStream()) { + os.write(GSON.toJson(json).getBytes()); + } + + if (conn.getResponseCode() == 200) { + if (isInitial) hasSentInitialCosmetics = true; + SYNCED_COSMETIC_CACHE.put(uuid, cosmeticData); + LAST_SYNC_TIME.put(uuid, System.currentTimeMillis()); + PENDING_UPDATES.remove(uuid); + Client.debugLog("Successfully synced cosmetics to server for " + playerName); + } else { + PENDING_UPDATES.put(uuid, cosmeticData); + Client.logger.error("Failed to sync cosmetics. HTTP {}", conn.getResponseCode()); + } + } catch (Exception e) { + Client.logger.error("Failed to send cosmetics to server", e); + PENDING_UPDATES.put(uuid, cosmeticData); + } + }, Util.getMainWorkerExecutor()); + } + + public static void sendCosmeticsToServer(UUID uuid, ClientCosmeticManager.PlayerCosmeticData clientData) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client.player != null) { + sendCosmeticsToServer(uuid, clientData, client.player.getName().getString()); + } + } + + public static void sendCosmeticsToServer(UUID uuid, ClientCosmeticManager.PlayerCosmeticData clientData, String playerName) { + SyncedCosmeticData syncData = SyncedCosmeticData.fromClientCosmeticData(clientData); + sendCosmeticsToServer(uuid, syncData, playerName, false); + } + + private static void applyServerCosmeticsToLocal(UUID uuid, SyncedCosmeticData cosmeticData) { + MinecraftClient.getInstance().execute(() -> { + ClientCosmeticManager.PlayerCosmeticData localData = ClientCosmeticManager.getData(uuid); + ClientCosmeticManager.PlayerCosmeticData serverData = cosmeticData.toClientCosmeticData(); + + localData.activeWings = serverData.activeWings; + localData.wingsVisible = serverData.wingsVisible; + localData.activeHat = serverData.activeHat; + localData.hatVisible = serverData.hatVisible; + localData.activeHalo = serverData.activeHalo; + localData.haloVisible = serverData.haloVisible; + + ClientCosmeticManager.save(); + Client.debugLog("Applied server cosmetics for " + uuid); + }); + } + + public static void batchFetchCosmetics(List uuids) { + if (uuids.isEmpty()) return; + + List citrusUsers = uuids.stream() + .filter(ClientRegistrationManager::isPlayerOnlineWithCitrus) + .toList(); + + if (citrusUsers.isEmpty()) return; + + CompletableFuture.runAsync(() -> { + try { + URL url = new URI(API_BASE_URL + "/cosmetics/batch").toURL(); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("x-daily-key", SecurityUtils.getDailyKey()); + conn.setDoOutput(true); + conn.setConnectTimeout(5000); + conn.setReadTimeout(5000); + + JsonObject json = new JsonObject(); + JsonArray uuidArray = new JsonArray(); + for (UUID uuid : citrusUsers) { + uuidArray.add(uuid.toString()); + } + json.add("uuids", uuidArray); + + try (OutputStream os = conn.getOutputStream()) { + os.write(GSON.toJson(json).getBytes()); + } + + if (conn.getResponseCode() == 200) { + try (InputStream is = conn.getInputStream()) { + String response = new String(is.readAllBytes(), StandardCharsets.UTF_8); + JsonObject result = GSON.fromJson(response, JsonObject.class); + + for (String uuidStr : result.keySet()) { + try { + UUID uuid = UUID.fromString(uuidStr); + JsonObject cosmeticDataJson = result.getAsJsonObject(uuidStr); + SyncedCosmeticData cosmeticData = SyncedCosmeticData.fromJson(cosmeticDataJson); + + SYNCED_COSMETIC_CACHE.put(uuid, cosmeticData); + LAST_SYNC_TIME.put(uuid, System.currentTimeMillis()); + } catch (Exception e) { + Client.logger.error("Invalid UUID in cosmetics response: {}", uuidStr); + } + } + + Client.debugLog("Batch fetched cosmetics for " + result.size() + " users"); + } + } + } catch (Exception e) { + Client.logger.error("Batch cosmetics fetch failed", e); + } + }, Util.getMainWorkerExecutor()); + } + + private static void startAutoSync() { + if (syncTimer != null) { + syncTimer.cancel(); + } + + syncTimer = new Timer("CosmeticAutoSync", true); + syncTimer.scheduleAtFixedRate(new TimerTask() { + @Override + public void run() { + if (autoSyncEnabled && MinecraftClient.getInstance().player != null) { + performAutoSync(); + } + } + }, 5000, 10000); + } + + private static void performAutoSync() { + syncLocalPlayerCosmetics(); + retryPendingUpdates(); + } + + private static void syncLocalPlayerCosmetics() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client.player == null) return; + + UUID uuid = client.player.getUuid(); + ClientCosmeticManager.PlayerCosmeticData localData = ClientCosmeticManager.getData(uuid); + + if (localData != null) { + SyncedCosmeticData cachedData = SYNCED_COSMETIC_CACHE.get(uuid); + if (cachedData == null || + !cachedData.wingType.equals(localData.activeWings) || + cachedData.wingsVisible != localData.wingsVisible || + !cachedData.hatType.equals(localData.activeHat) || + cachedData.hatVisible != localData.hatVisible || + !cachedData.haloType.equals(localData.activeHalo) || + cachedData.haloVisible != localData.haloVisible) { + + sendCosmeticsToServer(uuid, localData); + } + } + } + + static void retryPendingUpdates() { + if (PENDING_UPDATES.isEmpty()) return; + + MinecraftClient client = MinecraftClient.getInstance(); + if (client.player == null) return; + + for (Map.Entry entry : PENDING_UPDATES.entrySet()) { + String playerName = entry.getKey().equals(client.player.getUuid()) + ? client.player.getName().getString() + : entry.getKey().toString(); + + sendCosmeticsToServer(entry.getKey(), entry.getValue(), playerName, false); + } + } + + private static boolean isLocalPlayer(UUID uuid) { + MinecraftClient client = MinecraftClient.getInstance(); + return client.player != null && client.player.getUuid().equals(uuid); + } + + public static void clearCache(UUID uuid) { + SYNCED_COSMETIC_CACHE.remove(uuid); + LAST_SYNC_TIME.remove(uuid); + } + + public static void clearAllCache() { + SYNCED_COSMETIC_CACHE.clear(); + LAST_SYNC_TIME.clear(); + PENDING_UPDATES.clear(); + PENDING_FETCHES.clear(); + hasSentInitialCosmetics = false; + isInitializing = false; + } + + public static boolean hasInitialSyncCompleted() { + return hasSentInitialCosmetics && !isInitializing; + } + + public static boolean isInitializing() { + return isInitializing; + } + + public static void forceInitialSync() { + if (!hasSentInitialCosmetics) { + isInitializing = true; + performInitialSync(); + } + } + + public static boolean isAutoSyncEnabled() { + return autoSyncEnabled; + } + + public static void setAutoSyncEnabled(boolean enabled) { + autoSyncEnabled = enabled; + if (enabled) { + startAutoSync(); + } else if (syncTimer != null) { + syncTimer.cancel(); + syncTimer = null; + } + } + + public static String getSyncStatus() { + if (!autoSyncEnabled) return "Disabled"; + if (isInitializing) return "Initializing..."; + if (hasSentInitialCosmetics) return "Synced"; + return "Not synced"; + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/cosmetics/CosmeticType.java b/src/main/java/de/winniepat/citrus/cosmetics/CosmeticType.java new file mode 100644 index 0000000..d40aa64 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/cosmetics/CosmeticType.java @@ -0,0 +1,7 @@ +package de.winniepat.citrus.cosmetics; + +public enum CosmeticType { + WINGS, + HAT, + HALO +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/cosmetics/PlayerCosmeticTracker.java b/src/main/java/de/winniepat/citrus/cosmetics/PlayerCosmeticTracker.java new file mode 100644 index 0000000..57d7e0d --- /dev/null +++ b/src/main/java/de/winniepat/citrus/cosmetics/PlayerCosmeticTracker.java @@ -0,0 +1,59 @@ +package de.winniepat.citrus.cosmetics; + +import de.winniepat.citrus.managers.ClientRegistrationManager; +import net.minecraft.client.MinecraftClient; +import net.minecraft.entity.player.PlayerEntity; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +public class PlayerCosmeticTracker { + private static final Map LAST_SEEN_PLAYERS = new ConcurrentHashMap<>(); + private static final long SYNC_INTERVAL = 30000; + + public static void tick() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client.world == null || client.player == null) return; + + List citrusUsersToSync = new ArrayList<>(); + long currentTime = System.currentTimeMillis(); + + for (PlayerEntity player : client.world.getPlayers()) { + UUID uuid = player.getUuid(); + + if (player == client.player) continue; + + if (!ClientRegistrationManager.isPlayerOnlineWithCitrus(uuid)) { + continue; + } + + Long lastSeen = LAST_SEEN_PLAYERS.get(uuid); + if (lastSeen == null || currentTime - lastSeen > SYNC_INTERVAL) { + citrusUsersToSync.add(uuid); + LAST_SEEN_PLAYERS.put(uuid, currentTime); + } + } + + if (!citrusUsersToSync.isEmpty()) { + CosmeticSyncManager.batchFetchCosmetics(citrusUsersToSync); + } + + Iterator> iterator = LAST_SEEN_PLAYERS.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + UUID uuid = entry.getKey(); + + boolean stillPresent = client.world.getPlayers().stream() + .anyMatch(p -> p.getUuid().equals(uuid) && p != client.player); + + if (!stillPresent && currentTime - entry.getValue() > 60000) { + iterator.remove(); + CosmeticSyncManager.clearCache(uuid); + } + } + + if (currentTime % 60000 < 50) { + CosmeticSyncManager.retryPendingUpdates(); + } + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/cosmetics/capes/CapeHandler.java b/src/main/java/de/winniepat/citrus/cosmetics/capes/CapeHandler.java new file mode 100644 index 0000000..33c293a --- /dev/null +++ b/src/main/java/de/winniepat/citrus/cosmetics/capes/CapeHandler.java @@ -0,0 +1,543 @@ +package de.winniepat.citrus.cosmetics.capes; + +import de.winniepat.citrus.Client; +import de.winniepat.citrus.cosmetics.capes.sync.CapeSyncManager; +import de.winniepat.citrus.managers.ClientRegistrationManager; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.network.AbstractClientPlayerEntity; +import net.minecraft.client.texture.*; +import net.minecraft.client.util.SkinTextures; +import net.minecraft.util.*; +import org.w3c.dom.*; + +import javax.xml.parsers.*; +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URL; +import java.util.*; +import java.util.concurrent.*; + +public class CapeHandler { + private static final Map CAPE_CACHE = new ConcurrentHashMap<>(); + private static final Map PLAYER_CAPES = new ConcurrentHashMap<>(); + private static final Set LOADING_CAPES = ConcurrentHashMap.newKeySet(); + private static final Set FAILED_CAPES = ConcurrentHashMap.newKeySet(); + private static String localCapeOverride = null; + private static boolean overrideFromSync = false; + + private static final String CDN_BASE_URL = Client.CDN_URL + "/capes/"; + private static List AVAILABLE_CAPES = new ArrayList<>(); + private static boolean capesListFetched = false; + private static boolean isInitialized = false; + + private static final String MOD_ID = "citrus"; + + private static class CapeEntry { + final Identifier textureId; + final NativeImageBackedTexture texture; + final long loadTime; + + CapeEntry(Identifier textureId, NativeImageBackedTexture texture) { + this.textureId = textureId; + this.texture = texture; + this.loadTime = System.currentTimeMillis(); + } + } + + public static void fetchCapeListFromCDN() { + Client.debugLog("Fetching cape list from CDN..."); + + CompletableFuture.runAsync(() -> { + try { + URL url = new URI(CDN_BASE_URL).toURL(); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(10000); + connection.setReadTimeout(10000); + + if (connection.getResponseCode() == 200) { + try (InputStream stream = connection.getInputStream()) { + byte[] xmlData = stream.readAllBytes(); + parseCapeListFromXML(xmlData); + } + } else { + Client.logger.error("Failed to fetch cape list. HTTP {}", connection.getResponseCode()); + loadDefaultCapes(); + } + + connection.disconnect(); + + } catch (Exception e) { + Client.logger.error("Error fetching cape list", e); + loadDefaultCapes(); + } + }, Util.getMainWorkerExecutor()); + } + + private static void parseCapeListFromXML(byte[] xmlData) { + try { + List discoveredCapes = new ArrayList<>(); + + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + DocumentBuilder builder = factory.newDocumentBuilder(); + Document doc = builder.parse(new ByteArrayInputStream(xmlData)); + + NodeList keyNodes = doc.getElementsByTagName("Key"); + + for (int i = 0; i < keyNodes.getLength(); i++) { + Node node = keyNodes.item(i); + if (node.getNodeType() == Node.ELEMENT_NODE) { + String keyValue = node.getTextContent().trim(); + + if (keyValue.toLowerCase().endsWith(".png")) { + String capeName = keyValue.substring(0, keyValue.length() - 4); + discoveredCapes.add(capeName); + + Client.debugLog("Found cape: " + capeName + " (file: " + keyValue + ")"); + } + } + } + + if (discoveredCapes.isEmpty()) { + Client.logger.error("No PNG files found in CDN response"); + loadDefaultCapes(); + } else { + AVAILABLE_CAPES = discoveredCapes; + capesListFetched = true; + + Client.debugLog("Successfully loaded " + discoveredCapes.size() + " capes from CDN"); + + preloadLocalPlayerCape(); + } + + } catch (Exception e) { + Client.logger.error("Error parsing cape list from XML", e); + loadDefaultCapes(); + } + } + + private static void loadDefaultCapes() { + Client.debugLog("Using default cape list"); + + AVAILABLE_CAPES = Arrays.asList("strick", "cat"); + capesListFetched = true; + preloadLocalPlayerCape(); + } + + private static void ensureCapeListFetched() { + if (!capesListFetched && !isInitialized) { + fetchCapeListFromCDN(); + } + } + + public static SkinTextures getCapes(SkinTextures original, AbstractClientPlayerEntity player) { + if (player == null) return original; + + if (!ClientRegistrationManager.isPlayerOnlineWithCitrus(player.getUuid())) { + return original; + } + + UUID playerUUID = player.getUuid(); + String playerName = player.getName().getString(); + + String capeType = getCapeForPlayer(playerUUID, playerName); + + if (capeType == null || "none".equals(capeType) || FAILED_CAPES.contains(capeType)) { + return original; + } + + CapeEntry capeEntry = CAPE_CACHE.get(capeType); + if (capeEntry != null) { + return new SkinTextures( + original.texture(), + original.textureUrl(), + capeEntry.textureId, + capeEntry.textureId, + original.model(), + original.secure() + ); + } else { + if (!LOADING_CAPES.contains(capeType) && !FAILED_CAPES.contains(capeType)) { + startCapeLoading(capeType); + } + return original; + } + } + + private static String getCapeForPlayer(UUID uuid, String name) { + if (!ClientRegistrationManager.isPlayerOnlineWithCitrus(uuid)) { + return null; + } + + MinecraftClient client = MinecraftClient.getInstance(); + if (client.player != null && client.player.getUuid().equals(uuid) && localCapeOverride != null) { + return localCapeOverride.equals("none") ? null : localCapeOverride; + } + + if (PLAYER_CAPES.containsKey(uuid)) { + return PLAYER_CAPES.get(uuid); + } + + String syncedCape = CapeSyncManager.getSyncedCape(uuid, name); + if (syncedCape != null) { + PLAYER_CAPES.put(uuid, syncedCape); + return syncedCape; + } + + if (AVAILABLE_CAPES.isEmpty()) { + ensureCapeListFetched(); + return null; + } + + int hash = Math.abs((uuid + name).hashCode()); + int index = hash % AVAILABLE_CAPES.size(); + String capeType = AVAILABLE_CAPES.get(index); + + PLAYER_CAPES.put(uuid, capeType); + return capeType; + } + + private static void startCapeLoading(String capeType) { + if (LOADING_CAPES.contains(capeType)) return; + + LOADING_CAPES.add(capeType); + + CompletableFuture.runAsync(() -> { + try { + byte[] imageData = downloadCapeImage(capeType); + + if (imageData.length == 0) { + Client.logger.error("Empty image data for cape {}", capeType); + markCapeAsFailed(capeType, "Empty data"); + return; + } + + if (!isValidPng(imageData)) { + Client.logger.error("Invalid PNG signature for cape {}", capeType); + Client.logger.error("First 8 bytes: {}", bytesToHex(imageData, 8)); + markCapeAsFailed(capeType, "Invalid PNG signature"); + return; + } + + NativeImage nativeImage = null; + try { + nativeImage = NativeImage.read(new ByteArrayInputStream(imageData)); + + if (nativeImage.getWidth() > 512 || nativeImage.getHeight() > 512) { + + Client.logger.error("Cape image too large: {} ({}x{})", capeType, nativeImage.getWidth(), nativeImage.getHeight()); + nativeImage.close(); + markCapeAsFailed(capeType, "Image too large"); + return; + } + + scheduleTextureCreation(capeType, nativeImage); + + } catch (Exception e) { + Client.logger.error("Failed to parse PNG for cape {}", capeType, e); + if (nativeImage != null) { + nativeImage.close(); + } + markCapeAsFailed(capeType, "PNG parsing failed: " + e.getMessage()); + } + + } catch (Exception e) { + Client.logger.error("Failed to load cape {}", capeType, e); + markCapeAsFailed(capeType, "Download failed: " + e.getMessage()); + } + }, Util.getMainWorkerExecutor()); + } + + private static byte[] downloadCapeImage(String capeType) throws Exception { + String capeUrl = CDN_BASE_URL + capeType + ".png"; + + Client.debugLog("Downloading cape from: " + capeUrl); + + URL url = new URI(capeUrl).toURL(); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestProperty("User-Agent", "Minecraft Mod"); + connection.setConnectTimeout(10000); + connection.setReadTimeout(10000); + connection.setInstanceFollowRedirects(true); + + try { + int responseCode = connection.getResponseCode(); + + Client.debugLog("Response code for " + capeType + ": " + responseCode); + + if (responseCode != 200) { + try (InputStream errorStream = connection.getErrorStream()) { + if (errorStream != null) { + byte[] errorBytes = errorStream.readAllBytes(); + Client.logger.error("Error response {}", new String(errorBytes)); + } + } + throw new IOException("HTTP " + responseCode + " for " + capeUrl); + } + + try (InputStream stream = connection.getInputStream()) { + return stream.readAllBytes(); + } + + } finally { + connection.disconnect(); + } + } + + private static boolean isValidPng(byte[] data) { + if (data.length < 8) return false; + + return (data[0] & 0xFF) == 0x89 && + data[1] == 0x50 && + data[2] == 0x4E && + data[3] == 0x47 && + data[4] == 0x0D && + data[5] == 0x0A && + data[6] == 0x1A && + data[7] == 0x0A; + } + + private static String bytesToHex(byte[] bytes, int length) { + StringBuilder hex = new StringBuilder(); + for (int i = 0; i < Math.min(bytes.length, length); i++) { + hex.append(String.format("%02X ", bytes[i] & 0xFF)); + } + return hex.toString().trim(); + } + + private static void markCapeAsFailed(String capeType, String reason) { + FAILED_CAPES.add(capeType); + LOADING_CAPES.remove(capeType); + Client.logger.error("Marked cape '{}' as failed: {}", capeType, reason); + } + + private static void scheduleTextureCreation(String capeType, NativeImage nativeImage) { + MinecraftClient.getInstance().execute(() -> { + try { + if (CAPE_CACHE.containsKey(capeType)) { + nativeImage.close(); + LOADING_CAPES.remove(capeType); + return; + } + + Identifier textureId = Identifier.of(MOD_ID, "capes/" + capeType); + NativeImageBackedTexture texture = new NativeImageBackedTexture(nativeImage); + MinecraftClient.getInstance().getTextureManager().registerTexture(textureId, texture); + + CAPE_CACHE.put(capeType, new CapeEntry(textureId, texture)); + FAILED_CAPES.remove(capeType); + + Client.debugLog("Successfully loaded cape: " + capeType + " (" + nativeImage.getWidth() + "x" + nativeImage.getHeight() + ")"); + + } catch (Exception e) { + Client.logger.error("Failed to create texture for cape {}", capeType, e); + nativeImage.close(); + markCapeAsFailed(capeType, "Texture creation failed: " + e.getMessage()); + } finally { + LOADING_CAPES.remove(capeType); + } + }); + } + + private static void preloadLocalPlayerCape() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client.player != null) { + String capeType = getCapeForPlayer(client.player.getUuid(), client.player.getName().getString()); + if (capeType != null && !capeType.equals("none")) { + startCapeLoading(capeType); + } + } + } + + public static List getAvailableCapes() { + ensureCapeListFetched(); + return new ArrayList<>(AVAILABLE_CAPES); + } + + public static boolean isCapeLoaded(String capeType) { + return CAPE_CACHE.containsKey(capeType); + } + + public static void setLocalPlayerCape(String capeType, boolean fromSync) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client.player == null) return; + + UUID uuid = client.player.getUuid(); + String playerName = client.player.getName().getString(); + + if (fromSync) { + overrideFromSync = true; + localCapeOverride = capeType; + PLAYER_CAPES.put(uuid, capeType); + } else { + overrideFromSync = false; + localCapeOverride = capeType; + + CapeSyncManager.sendCapeToServer(uuid, capeType, playerName); + + PLAYER_CAPES.put(uuid, capeType); + } + + if (capeType != null && !capeType.equals("none") && !CAPE_CACHE.containsKey(capeType)) { + startCapeLoading(capeType); + } + + client.player.getSkinTextures(); + } + + public static void clearLocalPlayerOverride() { + localCapeOverride = null; + MinecraftClient client = MinecraftClient.getInstance(); + if (client.player != null) { + PLAYER_CAPES.remove(client.player.getUuid()); + client.player.getSkinTextures(); + } + } + + public static String getCurrentCapeForLocalPlayer() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client.player == null) return null; + + if (localCapeOverride != null) { + return localCapeOverride.equals("none") ? null : localCapeOverride; + } + + return PLAYER_CAPES.get(client.player.getUuid()); + } + + public static String getAssignedCapeForLocalPlayer() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client.player == null) return null; + + UUID uuid = client.player.getUuid(); + if (PLAYER_CAPES.containsKey(uuid)) { + return PLAYER_CAPES.get(uuid); + } + + ensureCapeListFetched(); + if (AVAILABLE_CAPES.isEmpty()) return null; + + String name = client.player.getName().getString(); + int hash = Math.abs((uuid.toString() + name).hashCode()); + int index = hash % AVAILABLE_CAPES.size(); + return AVAILABLE_CAPES.get(index); + } + + public static boolean capeExists(String capeName) { + ensureCapeListFetched(); + return AVAILABLE_CAPES.contains(capeName); + } + + public static void preloadSpecificCapes(Collection capeTypes) { + for (String capeType : capeTypes) { + if (capeType != null && + !capeType.equals("none") && + !CAPE_CACHE.containsKey(capeType) && + !LOADING_CAPES.contains(capeType) && + !FAILED_CAPES.contains(capeType)) { + startCapeLoading(capeType); + } + } + } + + public static void reloadCapes() { + clearCache(); + capesListFetched = false; + fetchCapeListFromCDN(); + } + + public static void retryFailedCapes() { + List failed = new ArrayList<>(FAILED_CAPES); + FAILED_CAPES.clear(); + for (String capeType : failed) { + startCapeLoading(capeType); + } + } + + public static void clearCache() { + for (CapeEntry entry : CAPE_CACHE.values()) { + entry.texture.close(); + } + CAPE_CACHE.clear(); + PLAYER_CAPES.clear(); + LOADING_CAPES.clear(); + FAILED_CAPES.clear(); + } + + public static String getCapeStatus(String capeType) { + if (CAPE_CACHE.containsKey(capeType)) { + return "loaded"; + } else if (LOADING_CAPES.contains(capeType)) { + return "loading"; + } else if (FAILED_CAPES.contains(capeType)) { + return "failed"; + } else { + return "not_loaded"; + } + } + + public static void init() { + if (isInitialized) return; + + Client.debugLog("Initializing dynamic cape system..."); + isInitialized = true; + fetchCapeListFromCDN(); + } + + public static void refreshCapeList() { + capesListFetched = false; + fetchCapeListFromCDN(); + } + + public static String getLocalCapeOverride() { + return localCapeOverride; + } + + public static String getSyncedCapeForPlayer(UUID uuid, String name) { + String syncedCape = CapeSyncManager.getSyncedCape(uuid, name); + if (syncedCape != null) { + return syncedCape; + } + + MinecraftClient client = MinecraftClient.getInstance(); + if (client.player != null && client.player.getUuid().equals(uuid) && localCapeOverride != null) { + return localCapeOverride.equals("none") ? null : localCapeOverride; + } + + if (AVAILABLE_CAPES.isEmpty()) { + return null; + } + + int hash = Math.abs((uuid.toString() + name).hashCode()); + int index = hash % AVAILABLE_CAPES.size(); + String capeType = AVAILABLE_CAPES.get(index); + + PLAYER_CAPES.put(uuid, capeType); + return capeType; + } + + public static void forceCacheCape(UUID uuid, String capeType) { + if (capeType == null || "none".equals(capeType)) { + PLAYER_CAPES.remove(uuid); + } else { + PLAYER_CAPES.put(uuid, capeType); + } + } + + public static void cachePlayerCape(UUID playerUUID, String capeType) { + if (capeType == null || "none".equals(capeType)) { + PLAYER_CAPES.put(playerUUID, null); + } else { + PLAYER_CAPES.put(playerUUID, capeType); + } + + MinecraftClient client = MinecraftClient.getInstance(); + if (client.player != null && client.player.getUuid().equals(playerUUID)) { + localCapeOverride = capeType; + overrideFromSync = true; + } + + Client.debugLog("Cached cape for " + playerUUID + ": " + (capeType == null ? "none" : capeType)); + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/cosmetics/capes/sync/CapeSyncManager.java b/src/main/java/de/winniepat/citrus/cosmetics/capes/sync/CapeSyncManager.java new file mode 100644 index 0000000..0057765 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/cosmetics/capes/sync/CapeSyncManager.java @@ -0,0 +1,542 @@ +package de.winniepat.citrus.cosmetics.capes.sync; + +import com.google.gson.*; +import de.winniepat.citrus.Client; +import de.winniepat.citrus.cosmetics.capes.CapeHandler; +import de.winniepat.citrus.managers.ClientRegistrationManager; +import de.winniepat.citrus.utils.SecurityUtils; +import net.minecraft.client.MinecraftClient; +import net.minecraft.util.Util; + +import java.io.*; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.*; + +public class CapeSyncManager { + private static final String API_BASE_URL = Client.API_URL; + private static final Gson GSON = new GsonBuilder().create(); + + public static final Map SYNCED_CAPE_CACHE = new ConcurrentHashMap<>(); + private static final Map LAST_SYNC_TIME = new ConcurrentHashMap<>(); + + private static final Map PENDING_UPDATES = new ConcurrentHashMap<>(); + + private static final Set PENDING_FETCHES = ConcurrentHashMap.newKeySet(); + + private static boolean hasSentInitialCape = false; + private static boolean isInitializing = false; + + private static boolean serverReachable = true; + private static long lastServerCheck = 0; + private static final long SERVER_CHECK_INTERVAL = 30000; + + private static boolean autoSyncEnabled = true; + private static long lastAutoSyncTime = 0; + private static final long AUTO_SYNC_INTERVAL = 5000; + + private static boolean serverHealthy = false; + + private static Timer syncTimer; + + public static void init() { + Client.debugLog("Initializing cape sync..."); + isInitializing = true; + + startAutoSync(); + + checkServerHealth().thenAccept(healthy -> { + serverReachable = healthy; + if (healthy) { + Client.debugLog("Cape sync server is online"); + scheduleInitialSync(); + } else { + Client.logger.error("Cape sync server is offline"); + isInitializing = false; + } + }); + } + + private static void scheduleInitialSync() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client.player != null) { + performInitialSync(); + } else { + Util.getMainWorkerExecutor().execute(() -> { + try { + Thread.sleep(2000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + performInitialSync(); + }); + } + } + + private static void performInitialSync() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client.player == null) { + isInitializing = false; + return; + } + + UUID uuid = client.player.getUuid(); + String playerName = client.player.getName().getString(); + + Client.debugLog("Performing initial cape sync for " + playerName + " (" + uuid + ")"); + + fetchCapeFromServer(uuid, playerName, true).thenAccept(serverCape -> { + if (serverCape != null) { + Client.debugLog("Server has cape: " + serverCape); + applyServerCapeToLocal(uuid, serverCape); + } else { + String localCape = CapeHandler.getCurrentCapeForLocalPlayer(); + if (localCape != null && !"none".equals(localCape)) { + Client.debugLog("Sending local cape to server: " + localCape); + sendCapeToServer(uuid, localCape, playerName, true).thenAccept(success -> { + if (success) { + Client.debugLog("Initial cape sync completed"); + } + }); + } else { + Client.debugLog("No local cape to sync"); + } + } + hasSentInitialCape = true; + isInitializing = false; + }); + } + + public static String getSyncedCape(UUID uuid, String playerName) { + + if (!ClientRegistrationManager.isPlayerOnlineWithCitrus(uuid)) { + return null; + } + + if (isInitializing && isLocalPlayer(uuid)) { + return null; + } + + if (SYNCED_CAPE_CACHE.containsKey(uuid)) { + long lastSync = LAST_SYNC_TIME.getOrDefault(uuid, 0L); + if (System.currentTimeMillis() - lastSync < 300000) { + return SYNCED_CAPE_CACHE.get(uuid); + } + } + + if (isLocalPlayer(uuid) && !hasSentInitialCape && !isInitializing) { + performInitialSync(); + return null; + } + + if (!PENDING_FETCHES.contains(uuid)) { + fetchCapeFromServer(uuid, playerName, false); + } + + return null; + } + + private static CompletableFuture fetchCapeFromServer(UUID uuid, String playerName, boolean isInitial) { + PENDING_FETCHES.add(uuid); + + return CompletableFuture.supplyAsync(() -> { + HttpURLConnection conn = null; + try { + String urlString = API_BASE_URL + "/cape/" + uuid.toString(); + Client.debugLog("Fetching from: " + urlString); + + URL url = new URI(urlString).toURL(); + conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(10000); + conn.setReadTimeout(10000); + + conn.setRequestProperty("User-Agent", "Minecraft/Citrus"); + conn.setRequestProperty("Accept", "application/json"); + conn.setRequestProperty("Accept-Charset", "UTF-8"); + conn.setRequestProperty("x-daily-key", SecurityUtils.getDailyKey()); + + conn.setRequestProperty("Connection", "close"); + + Client.debugLog("Opening connection with headers: " + conn.getRequestProperties()); + + int responseCode = conn.getResponseCode(); + Client.debugLog("Response code: " + responseCode); + + if (responseCode == 200) { + serverReachable = true; + try (InputStream is = conn.getInputStream()) { + byte[] response = is.readAllBytes(); + String responseBody = new String(response, StandardCharsets.UTF_8); + Client.debugLog("Response: " + responseBody); + + JsonObject json = GSON.fromJson(responseBody, JsonObject.class); + + if (json.has("cape")) { + String cape = json.get("cape").getAsString(); + Client.debugLog("Successfully got cape: " + cape); + + if (!"none".equals(cape)) { + SYNCED_CAPE_CACHE.put(uuid, cape); + LAST_SYNC_TIME.put(uuid, System.currentTimeMillis()); + return cape; + } + } + } + } else { + try (InputStream es = conn.getErrorStream()) { + if (es != null) { + String errorBody = new String(es.readAllBytes(), StandardCharsets.UTF_8); + Client.logger.error("Error response {}", errorBody); + } + } + serverReachable = false; + Client.logger.error("Server returned error code {}", responseCode); + } + + } catch (Exception e) { + serverReachable = false; + Client.logger.error("Exception type {}: {}", e.getClass().getName(), e); + + switch (e) { + case ConnectException connectException -> + Client.logger.error("Connection refused. Is the server running?"); + case SocketTimeoutException socketTimeoutException -> + Client.logger.error("Connection timeout. Check firewall/network."); + case UnknownHostException unknownHostException -> + Client.logger.error("Unknown host. Check DNS/network."); + default -> Client.logger.error("Error Conneccting to Cape Server {}", String.valueOf(e)); + } + + } finally { + if (conn != null) { + conn.disconnect(); + } + PENDING_FETCHES.remove(uuid); + } + return null; + }, Util.getMainWorkerExecutor()); + } + + private static CompletableFuture sendCapeToServer(UUID uuid, String cape, String playerName, boolean isInitial) { + return CompletableFuture.supplyAsync(() -> { + try { + URL url = new URI(API_BASE_URL + "/cape").toURL(); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("User-Agent", "Citrus/1.0"); + conn.setRequestProperty("x-daily-key", SecurityUtils.getDailyKey()); + conn.setDoOutput(true); + conn.setConnectTimeout(3000); + conn.setReadTimeout(3000); + + JsonObject json = new JsonObject(); + json.addProperty("uuid", uuid.toString()); + json.addProperty("cape", cape == null ? "none" : cape); + json.addProperty("playerName", playerName); + + try (OutputStream os = conn.getOutputStream()) { + os.write(GSON.toJson(json).getBytes()); + os.flush(); + } + + int responseCode = conn.getResponseCode(); + + if (responseCode == 200) { + serverReachable = true; + if (isInitial) { + hasSentInitialCape = true; + } + + SYNCED_CAPE_CACHE.put(uuid, cape); + LAST_SYNC_TIME.put(uuid, System.currentTimeMillis()); + + PENDING_UPDATES.remove(uuid); + + if (!isInitial) { + Client.debugLog("Successfully synced cape to server: " + cape); + } + return true; + } else { + serverReachable = false; + if (!isInitial) { + Client.logger.error("Failed to sync cape to server. HTTP {}", responseCode); + } + PENDING_UPDATES.put(uuid, cape); + return false; + } + } catch (Exception e) { + serverReachable = false; + if (!isInitial) { + Client.logger.error("Failed to send cape to server", e); + } + PENDING_UPDATES.put(uuid, cape); + return false; + } + }, Util.getMainWorkerExecutor()); + } + + public static void sendCapeToServer(UUID uuid, String cape, String playerName) { + sendCapeToServer(uuid, cape, playerName, false); + } + + private static void applyServerCapeToLocal(UUID uuid, String cape) { + MinecraftClient.getInstance().execute(() -> { + CapeHandler.setLocalPlayerCape(cape, true); + Client.debugLog("Applied server cape: " + cape); + }); + } + + public static CompletableFuture checkServerHealth() { + lastServerCheck = System.currentTimeMillis(); + + return CompletableFuture.supplyAsync(() -> { + try { + URL url = new URI(API_BASE_URL + "/health").toURL(); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setRequestProperty("x-daily-key", SecurityUtils.getDailyKey()); + conn.setConnectTimeout(3000); + + boolean healthy = conn.getResponseCode() == 200; + serverHealthy = conn.getResponseCode() == 200; + conn.disconnect(); + return healthy; + } catch (Exception e) { + serverHealthy = false; + return false; + } + }, Util.getMainWorkerExecutor()); + } + + private static boolean shouldTryServer() { + if (!serverReachable) { + long timeSinceLastCheck = System.currentTimeMillis() - lastServerCheck; + return timeSinceLastCheck > SERVER_CHECK_INTERVAL; + } + return true; + } + + private static boolean isLocalPlayer(UUID uuid) { + MinecraftClient client = MinecraftClient.getInstance(); + return client.player != null && client.player.getUuid().equals(uuid); + } + + public static void batchFetchCapes(List uuids) { + if (uuids.isEmpty()) return; + + List citrusUsers = uuids.stream() + .filter(ClientRegistrationManager::isPlayerOnlineWithCitrus) + .toList(); + + if (citrusUsers.isEmpty()) return; + + CompletableFuture.runAsync(() -> { + try { + URL url = new URI(API_BASE_URL + "/capes/batch").toURL(); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("x-daily-key", SecurityUtils.getDailyKey()); + conn.setDoOutput(true); + conn.setConnectTimeout(5000); + conn.setReadTimeout(5000); + + List uuidStrings = new ArrayList<>(); + for (UUID uuid : uuids) { + uuidStrings.add(uuid.toString()); + } + + JsonObject json = new JsonObject(); + json.add("uuids", GSON.toJsonTree(uuidStrings)); + + try (OutputStream os = conn.getOutputStream()) { + os.write(GSON.toJson(json).getBytes()); + os.flush(); + } + + if (conn.getResponseCode() == 200) { + serverReachable = true; + try (InputStream is = conn.getInputStream()) { + byte[] response = is.readAllBytes(); + JsonObject result = GSON.fromJson(new String(response), JsonObject.class); + + for (String uuidStr : result.keySet()) { + try { + UUID uuid = UUID.fromString(uuidStr); + JsonObject capeData = result.getAsJsonObject(uuidStr); + String cape = capeData.get("cape").getAsString(); + + if (!"none".equals(cape)) { + CapeHandler.cachePlayerCape(uuid, cape); + } + } catch (Exception e) { + Client.logger.error("Invalid UUID in response: {}", uuidStr, e); + } + } + + Client.debugLog("Fetched capes for " + result.size() + " Citrus users"); + } + } + + conn.disconnect(); + } catch (Exception e) { + Client.logger.error("Batch fetch failed", e); + } + }, Util.getMainWorkerExecutor()); + } + + public static void retryPendingUpdates() { + if (PENDING_UPDATES.isEmpty()) return; + + MinecraftClient client = MinecraftClient.getInstance(); + if (client.player == null) return; + + for (Map.Entry entry : PENDING_UPDATES.entrySet()) { + String playerName = entry.getKey().equals(client.player.getUuid()) + ? client.player.getName().getString() + : entry.getKey().toString(); + + sendCapeToServer(entry.getKey(), entry.getValue(), playerName, false); + } + } + + public static void clearCache(UUID uuid) { + SYNCED_CAPE_CACHE.remove(uuid); + LAST_SYNC_TIME.remove(uuid); + } + + public static void clearAllCache() { + SYNCED_CAPE_CACHE.clear(); + LAST_SYNC_TIME.clear(); + PENDING_UPDATES.clear(); + PENDING_FETCHES.clear(); + hasSentInitialCape = false; + isInitializing = false; + } + + public static Map getAllCapeAssignments() { + return new HashMap<>(SYNCED_CAPE_CACHE); + } + + public static boolean hasInitialSyncCompleted() { + return hasSentInitialCape && !isInitializing; + } + + public static void forceInitialSync() { + if (!hasSentInitialCape) { + isInitializing = true; + performInitialSync(); + } + } + + private static void startAutoSync() { + if (syncTimer != null) { + syncTimer.cancel(); + } + + syncTimer = new Timer("CapeAutoSync", true); + syncTimer.scheduleAtFixedRate(new TimerTask() { + @Override + public void run() { + if (autoSyncEnabled && System.currentTimeMillis() - lastAutoSyncTime >= AUTO_SYNC_INTERVAL) { + performAutoSync(); + } + } + }, 2000, AUTO_SYNC_INTERVAL); + } + + private static void performAutoSync() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client.player == null || client.world == null) { + return; + } + + lastAutoSyncTime = System.currentTimeMillis(); + + syncLocalPlayerCape(); + + syncNearbyPlayers(); + + retryPendingUpdates(); + } + + private static void syncLocalPlayerCape() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client.player == null) return; + + UUID uuid = client.player.getUuid(); + String playerName = client.player.getName().getString(); + String currentCape = CapeHandler.getCurrentCapeForLocalPlayer(); + + if (currentCape != null && !"none".equals(currentCape)) { + Long lastSync = LAST_SYNC_TIME.get(uuid); + if (lastSync == null || System.currentTimeMillis() - lastSync > 30000) { + sendCapeToServer(uuid, currentCape, playerName, false); + } + } + } + + private static void syncNearbyPlayers() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client.world == null || client.player == null) return; + + List nearbyCitrusUsers = new ArrayList<>(); + + for (net.minecraft.entity.player.PlayerEntity player : client.world.getPlayers()) { + if (player == client.player) { + continue; + } + + if (ClientRegistrationManager.isPlayerOnlineWithCitrus(player.getUuid())) { + nearbyCitrusUsers.add(player.getUuid()); + } + } + + if (!nearbyCitrusUsers.isEmpty()) { + batchFetchCapes(nearbyCitrusUsers); + } + } + + public static void setAutoSyncEnabled(boolean enabled) { + autoSyncEnabled = enabled; + if (enabled) { + startAutoSync(); + Client.debugLog("Auto-sync enabled (every 5 seconds)"); + } else { + if (syncTimer != null) { + syncTimer.cancel(); + syncTimer = null; + } + Client.debugLog("Auto-sync disabled"); + } + } + + public static boolean isAutoSyncEnabled() { + return autoSyncEnabled; + } + + public static void forceSyncNow() { + lastAutoSyncTime = System.currentTimeMillis() - AUTO_SYNC_INTERVAL; + performAutoSync(); + } + + public static String getAutoSyncStatus() { + if (!autoSyncEnabled) return "disabled"; + + long timeSinceLastSync = System.currentTimeMillis() - lastAutoSyncTime; + long timeUntilNextSync = Math.max(0, AUTO_SYNC_INTERVAL - timeSinceLastSync); + + if (timeSinceLastSync < 1000) { + return "just synced"; + } else { + return String.format("next sync in %.1fs", timeUntilNextSync / 1000.0); + } + } + + public static boolean isIsHealthy() { + return serverHealthy; + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/cosmetics/capes/sync/PlayerCapeTracker.java b/src/main/java/de/winniepat/citrus/cosmetics/capes/sync/PlayerCapeTracker.java new file mode 100644 index 0000000..c07eda6 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/cosmetics/capes/sync/PlayerCapeTracker.java @@ -0,0 +1,59 @@ +package de.winniepat.citrus.cosmetics.capes.sync; + +import de.winniepat.citrus.managers.ClientRegistrationManager; +import net.minecraft.client.MinecraftClient; +import net.minecraft.entity.player.PlayerEntity; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +public class PlayerCapeTracker { + private static final Map LAST_SEEN_PLAYERS = new ConcurrentHashMap<>(); + private static final long SYNC_INTERVAL = 30000; + + public static void tick() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client.world == null || client.player == null) return; + + List citrusUsersToSync = new ArrayList<>(); + long currentTime = System.currentTimeMillis(); + + for (PlayerEntity player : client.world.getPlayers()) { + UUID uuid = player.getUuid(); + + if (player == client.player) continue; + + if (!ClientRegistrationManager.isPlayerOnlineWithCitrus(uuid)) { + continue; + } + + Long lastSeen = LAST_SEEN_PLAYERS.get(uuid); + if (lastSeen == null || currentTime - lastSeen > SYNC_INTERVAL) { + citrusUsersToSync.add(uuid); + LAST_SEEN_PLAYERS.put(uuid, currentTime); + } + } + + if (!citrusUsersToSync.isEmpty()) { + CapeSyncManager.batchFetchCapes(citrusUsersToSync); + } + + Iterator> iterator = LAST_SEEN_PLAYERS.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + UUID uuid = entry.getKey(); + + boolean stillPresent = client.world.getPlayers().stream() + .anyMatch(p -> p.getUuid().equals(uuid) && p != client.player); + + if (!stillPresent && currentTime - entry.getValue() > 60000) { + iterator.remove(); + CapeSyncManager.clearCache(uuid); + } + } + + if (currentTime % 60000 < 50) { + CapeSyncManager.retryPendingUpdates(); + } + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/cosmetics/halo/CosmeticHalo.java b/src/main/java/de/winniepat/citrus/cosmetics/halo/CosmeticHalo.java new file mode 100644 index 0000000..9e9eda6 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/cosmetics/halo/CosmeticHalo.java @@ -0,0 +1,77 @@ +package de.winniepat.citrus.cosmetics.halo; + +import de.winniepat.citrus.cosmetics.CosmeticSyncManager; +import net.minecraft.entity.player.PlayerEntity; +import software.bernie.geckolib.animatable.GeoAnimatable; +import software.bernie.geckolib.animatable.instance.AnimatableInstanceCache; +import software.bernie.geckolib.animation.*; +import software.bernie.geckolib.util.GeckoLibUtil; + +public class CosmeticHalo implements GeoAnimatable { + private final AnimatableInstanceCache cache = GeckoLibUtil.createInstanceCache(this); + private final PlayerEntity player; + + public CosmeticHalo(PlayerEntity player) { + this.player = player; + } + + public PlayerEntity getPlayer() { + return this.player; + } + + public HaloType getHaloType() { + CosmeticSyncManager.SyncedCosmeticData syncedData = + CosmeticSyncManager.getSyncedCosmetics( + player.getUuid(), + player.getName().getString() + ); + + if (syncedData != null && CosmeticSyncManager.hasInitialSyncCompleted()) { + return HaloType.fromId(syncedData.haloType); + } + + String haloId = de.winniepat.citrus.cosmetics.ClientCosmeticManager.getActiveHalo(player.getUuid()); + return HaloType.fromId(haloId); + } + + public boolean shouldRender() { + HaloType type = getHaloType(); + if (type == HaloType.NONE) { + return false; + } + + CosmeticSyncManager.SyncedCosmeticData syncedData = + CosmeticSyncManager.getSyncedCosmetics( + player.getUuid(), + player.getName().getString() + ); + + if (syncedData != null && CosmeticSyncManager.hasInitialSyncCompleted()) { + return syncedData.haloVisible && !player.isInvisible(); + } + + return de.winniepat.citrus.cosmetics.ClientCosmeticManager.isHaloVisible(player.getUuid()) && !player.isInvisible(); + } + + @Override + public void registerControllers(AnimatableManager.ControllerRegistrar controllers) { + controllers.add(new AnimationController<>(this, "controller", 0, state -> { + HaloType type = state.getAnimatable().getHaloType(); + if (type == HaloType.NONE) { + return null; + } + + return state.setAndContinue(RawAnimation.begin().thenLoop("animation.halo.float")); + })); + } + + @Override + public AnimatableInstanceCache getAnimatableInstanceCache() { + return this.cache; + } + + @Override + public double getTick(Object object) { + return this.player.age; + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/cosmetics/halo/HaloFeatureRenderer.java b/src/main/java/de/winniepat/citrus/cosmetics/halo/HaloFeatureRenderer.java new file mode 100644 index 0000000..6df6bf7 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/cosmetics/halo/HaloFeatureRenderer.java @@ -0,0 +1,69 @@ +package de.winniepat.citrus.cosmetics.halo; + +import net.minecraft.client.network.AbstractClientPlayerEntity; +import net.minecraft.client.render.RenderLayer; +import net.minecraft.client.render.VertexConsumerProvider; +import net.minecraft.client.render.entity.feature.FeatureRenderer; +import net.minecraft.client.render.entity.feature.FeatureRendererContext; +import net.minecraft.client.render.entity.model.PlayerEntityModel; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.RotationAxis; +import software.bernie.geckolib.renderer.GeoObjectRenderer; + +import java.util.WeakHashMap; + +public class HaloFeatureRenderer extends FeatureRenderer> { + private final GeoObjectRenderer haloRenderer; + private static final WeakHashMap HALO_CACHE = new WeakHashMap<>(); + + public HaloFeatureRenderer(FeatureRendererContext> context) { + super(context); + this.haloRenderer = new GeoObjectRenderer<>(new HaloModel()); + } + + @Override + public void render(MatrixStack matrices, VertexConsumerProvider vertexConsumers, int light, AbstractClientPlayerEntity player, float limbAngle, float limbDistance, float tickDelta, float animationProgress, float headYaw, float headPitch) { + + CosmeticHalo halo = HALO_CACHE.computeIfAbsent(player, CosmeticHalo::new); + boolean shouldRender = halo.shouldRender(); + HaloType type = halo.getHaloType(); + if (!shouldRender) return; + + Identifier texture = this.haloRenderer.getGeoModel().getTextureResource(halo); + if (texture == null) return; + + matrices.push(); + + matrices.multiply(RotationAxis.POSITIVE_Y.rotationDegrees(headYaw * 0.3f)); + matrices.multiply(RotationAxis.POSITIVE_X.rotationDegrees(headPitch * 0.3f)); + + matrices.translate(0, -2.0, 0); + + matrices.translate(type.getPosX(), type.getPosY(), type.getPosZ()); + + float scale = type.getScale(); + matrices.scale(scale, scale, scale); + + matrices.multiply(RotationAxis.POSITIVE_X.rotationDegrees(180f)); + + try { + this.haloRenderer.render( + matrices, halo, vertexConsumers, + RenderLayer.getEntityTranslucent(texture), + vertexConsumers.getBuffer(RenderLayer.getEntityTranslucent(texture)), + 0xF000F0, + tickDelta + ); + } catch (Exception e) { + System.err.println("Failed to render halo: " + e.getMessage()); + } finally { + matrices.pop(); + } + } + + public static void invalidateHaloCache(PlayerEntity player) { + HALO_CACHE.remove(player); + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/cosmetics/halo/HaloModel.java b/src/main/java/de/winniepat/citrus/cosmetics/halo/HaloModel.java new file mode 100644 index 0000000..a12b295 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/cosmetics/halo/HaloModel.java @@ -0,0 +1,56 @@ +package de.winniepat.citrus.cosmetics.halo; + +import de.winniepat.citrus.Client; +import software.bernie.geckolib.model.GeoModel; +import net.minecraft.util.Identifier; + +public class HaloModel extends GeoModel { + private static final Identifier DEFAULT_HALO_ANIMATION = Identifier.of("citrus", "animations/halo/default_halo.animation.json"); + private static final Identifier DEFAULT_HALO_MODEL = Identifier.of("citrus", "geo/halo/default_halo.geo.json"); + private static final Identifier DEFAULT_HALO_TEXTURE = Identifier.of("citrus", "textures/cosmetics/halo/default_halo.png"); + + @Override + public Identifier getModelResource(CosmeticHalo halo) { + HaloType type = halo.getHaloType(); + if (type == HaloType.NONE) return null; + + Identifier modelId = type.getModelId(); + if (modelId == null) { + Client.debugLog("Halo model is null for type: " + type.getId()); + return DEFAULT_HALO_MODEL; + } + return modelId; + } + + @Override + public Identifier getTextureResource(CosmeticHalo halo) { + HaloType type = halo.getHaloType(); + if (type == HaloType.NONE) return null; + + Identifier textureId = type.getTextureId(); + if (textureId == null) { + Client.debugLog("Halo texture is null for type: " + type.getId()); + return DEFAULT_HALO_TEXTURE; + } + return textureId; + } + + @Override + public Identifier getAnimationResource(CosmeticHalo halo) { + HaloType type = halo.getHaloType(); + if (type == HaloType.NONE) { + Client.debugLog("Animation resource is null for NONE halo type"); + return null; + } + + Identifier anim = type.getAnimationResource(); + if (anim == null) { + Client.debugLog("Animation resource is null for halo type: " + type.getId()); + Client.debugLog("Falling back to default halo animation"); + return DEFAULT_HALO_ANIMATION; + } + + Client.debugLog("Halo animation resource: " + anim.toString() + " for type: " + type.getId()); + return anim; + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/cosmetics/halo/HaloType.java b/src/main/java/de/winniepat/citrus/cosmetics/halo/HaloType.java new file mode 100644 index 0000000..47e507d --- /dev/null +++ b/src/main/java/de/winniepat/citrus/cosmetics/halo/HaloType.java @@ -0,0 +1,81 @@ +package de.winniepat.citrus.cosmetics.halo; + +import net.minecraft.util.Identifier; + +public enum HaloType { + NONE("none", null, null, null, 1.0f, 0.0, 0.0, 0.0), + ANGEL_HALO("angel_halo", "angel_halo", "angel_halo.png", "angel_halo", 10f, 0.0, 1.2, 0.1); + + private final String id; + private final String modelName; + private final String textureName; + private final String animationName; + private final float scale; + private final double posX, posY, posZ; + + HaloType(String id, String modelName, String textureName, String animationName, + float scale, double posX, double posY, double posZ) { + this.id = id; + this.modelName = modelName; + this.textureName = textureName; + this.animationName = animationName; + this.scale = scale; + this.posX = posX; + this.posY = posY; + this.posZ = posZ; + } + + public String getId() { + return id; + } + + public String getModelName() { + return modelName; + } + + public Identifier getModelId() { + if (modelName == null || NONE.id.equals(id)) return null; + return Identifier.of("citrus", "geo/halo/" + modelName + ".geo.json"); + } + + public Identifier getTextureId() { + if (textureName == null || NONE.id.equals(id)) return null; + return Identifier.of("citrus", "textures/cosmetics/halo/" + textureName); + } + + public Identifier getAnimationResource() { + if (animationName == null || NONE.id.equals(id)) return null; + return Identifier.of("citrus", "animations/halo/" + animationName + ".animation.json"); + } + + public float getScale() { + return scale; + } + + public double getPosX() { + return posX; + } + + public double getPosY() { + return posY; + } + + public double getPosZ() { + return posZ; + } + + public static HaloType fromId(String id) { + if (id == null || id.equals("none") || id.isEmpty()) return NONE; + + for (HaloType type : values()) { + if (type.id.equalsIgnoreCase(id)) { + return type; + } + } + return NONE; + } + + public static HaloType[] getAvailableTypes() { + return values(); + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/cosmetics/hat/CosmeticHat.java b/src/main/java/de/winniepat/citrus/cosmetics/hat/CosmeticHat.java new file mode 100644 index 0000000..82b6505 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/cosmetics/hat/CosmeticHat.java @@ -0,0 +1,77 @@ +package de.winniepat.citrus.cosmetics.hat; + +import de.winniepat.citrus.cosmetics.CosmeticSyncManager; +import net.minecraft.entity.player.PlayerEntity; +import software.bernie.geckolib.animatable.GeoAnimatable; +import software.bernie.geckolib.animatable.instance.AnimatableInstanceCache; +import software.bernie.geckolib.animation.*; +import software.bernie.geckolib.util.GeckoLibUtil; + +public class CosmeticHat implements GeoAnimatable { + private final AnimatableInstanceCache cache = GeckoLibUtil.createInstanceCache(this); + private final PlayerEntity player; + + public CosmeticHat(PlayerEntity player) { + this.player = player; + } + + public PlayerEntity getPlayer() { + return this.player; + } + + public HatType getHatType() { + CosmeticSyncManager.SyncedCosmeticData syncedData = + CosmeticSyncManager.getSyncedCosmetics( + player.getUuid(), + player.getName().getString() + ); + + if (syncedData != null && CosmeticSyncManager.hasInitialSyncCompleted()) { + return HatType.fromId(syncedData.hatType); + } + + String hatId = de.winniepat.citrus.cosmetics.ClientCosmeticManager.getActiveHat(player.getUuid()); + return HatType.fromId(hatId); + } + + public boolean shouldRender() { + HatType type = getHatType(); + if (type == HatType.NONE) { + return false; + } + + CosmeticSyncManager.SyncedCosmeticData syncedData = + CosmeticSyncManager.getSyncedCosmetics( + player.getUuid(), + player.getName().getString() + ); + + if (syncedData != null && CosmeticSyncManager.hasInitialSyncCompleted()) { + return syncedData.hatVisible && !player.isInvisible(); + } + + return de.winniepat.citrus.cosmetics.ClientCosmeticManager.isHatVisible(player.getUuid()) && !player.isInvisible(); + } + + @Override + public void registerControllers(AnimatableManager.ControllerRegistrar controllers) { + controllers.add(new AnimationController<>(this, "controller", 0, state -> { + HatType type = state.getAnimatable().getHatType(); + if (type == HatType.NONE) { + return null; + } + + return state.setAndContinue(RawAnimation.begin().thenLoop("animation.hat.idle")); + })); + } + + @Override + public AnimatableInstanceCache getAnimatableInstanceCache() { + return this.cache; + } + + @Override + public double getTick(Object object) { + return this.player.age; + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/cosmetics/hat/HatFeatureRenderer.java b/src/main/java/de/winniepat/citrus/cosmetics/hat/HatFeatureRenderer.java new file mode 100644 index 0000000..92c398f --- /dev/null +++ b/src/main/java/de/winniepat/citrus/cosmetics/hat/HatFeatureRenderer.java @@ -0,0 +1,69 @@ +package de.winniepat.citrus.cosmetics.hat; + +import net.minecraft.client.network.AbstractClientPlayerEntity; +import net.minecraft.client.render.RenderLayer; +import net.minecraft.client.render.VertexConsumerProvider; +import net.minecraft.client.render.entity.feature.FeatureRenderer; +import net.minecraft.client.render.entity.feature.FeatureRendererContext; +import net.minecraft.client.render.entity.model.PlayerEntityModel; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.RotationAxis; +import software.bernie.geckolib.renderer.GeoObjectRenderer; + +import java.util.WeakHashMap; + +public class HatFeatureRenderer extends FeatureRenderer> { + private final GeoObjectRenderer hatRenderer; + private static final WeakHashMap HAT_CACHE = new WeakHashMap<>(); + + public HatFeatureRenderer(FeatureRendererContext> context) { + super(context); + this.hatRenderer = new GeoObjectRenderer<>(new HatModel()); + } + + @Override + public void render(MatrixStack matrices, VertexConsumerProvider vertexConsumers, int light, AbstractClientPlayerEntity player, float limbAngle, float limbDistance, float tickDelta, float animationProgress, float headYaw, float headPitch) { + + CosmeticHat hat = HAT_CACHE.computeIfAbsent(player, CosmeticHat::new); + boolean shouldRender = hat.shouldRender(); + HatType type = hat.getHatType(); + if (!shouldRender) return; + + Identifier texture = this.hatRenderer.getGeoModel().getTextureResource(hat); + if (texture == null) return; + + matrices.push(); + + matrices.multiply(RotationAxis.POSITIVE_Y.rotationDegrees(headYaw * 0.3f)); + matrices.multiply(RotationAxis.POSITIVE_X.rotationDegrees(headPitch * 0.3f)); + + matrices.translate(0, -1.6, 0); + + matrices.translate(type.getPosX(), type.getPosY(), type.getPosZ()); + + float scale = type.getScale(); + matrices.scale(scale, scale, scale); + + matrices.multiply(RotationAxis.POSITIVE_X.rotationDegrees(180f)); + + try { + this.hatRenderer.render( + matrices, hat, vertexConsumers, + RenderLayer.getEntityTranslucent(texture), + vertexConsumers.getBuffer(RenderLayer.getEntityTranslucent(texture)), + 0xF000F0, + tickDelta + ); + } catch (Exception e) { + System.err.println("Failed to render hat: " + e.getMessage()); + } finally { + matrices.pop(); + } + } + + public static void invalidateHatCache(PlayerEntity player) { + HAT_CACHE.remove(player); + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/cosmetics/hat/HatModel.java b/src/main/java/de/winniepat/citrus/cosmetics/hat/HatModel.java new file mode 100644 index 0000000..4ce1a77 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/cosmetics/hat/HatModel.java @@ -0,0 +1,56 @@ +package de.winniepat.citrus.cosmetics.hat; + +import de.winniepat.citrus.Client; +import software.bernie.geckolib.model.GeoModel; +import net.minecraft.util.Identifier; + +public class HatModel extends GeoModel { + private static final Identifier DEFAULT_HAT_ANIMATION = Identifier.of("citrus", "animations/hat/default_hat.animation.json"); + private static final Identifier DEFAULT_HAT_MODEL = Identifier.of("citrus", "geo/hat/default_hat.geo.json"); + private static final Identifier DEFAULT_HAT_TEXTURE = Identifier.of("citrus", "textures/cosmetics/hat/default_hat.png"); + + @Override + public Identifier getModelResource(CosmeticHat hat) { + HatType type = hat.getHatType(); + if (type == HatType.NONE) return null; + + Identifier modelId = type.getModelId(); + if (modelId == null) { + Client.debugLog("Hat model is null for type: " + type.getId()); + return DEFAULT_HAT_MODEL; + } + return modelId; + } + + @Override + public Identifier getTextureResource(CosmeticHat hat) { + HatType type = hat.getHatType(); + if (type == HatType.NONE) return null; + + Identifier textureId = type.getTextureId(); + if (textureId == null) { + Client.debugLog("Hat texture is null for type: " + type.getId()); + return DEFAULT_HAT_TEXTURE; + } + return textureId; + } + + @Override + public Identifier getAnimationResource(CosmeticHat hat) { + HatType type = hat.getHatType(); + if (type == HatType.NONE) { + Client.debugLog("Animation resource is null for NONE hat type"); + return null; + } + + Identifier anim = type.getAnimationResource(); + if (anim == null) { + Client.debugLog("Animation resource is null for hat type: " + type.getId()); + Client.debugLog("Falling back to default hat animation"); + return DEFAULT_HAT_ANIMATION; + } + + Client.debugLog("Hat animation resource: " + anim.toString() + " for type: " + type.getId()); + return anim; + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/cosmetics/hat/HatType.java b/src/main/java/de/winniepat/citrus/cosmetics/hat/HatType.java new file mode 100644 index 0000000..ad4848b --- /dev/null +++ b/src/main/java/de/winniepat/citrus/cosmetics/hat/HatType.java @@ -0,0 +1,81 @@ +package de.winniepat.citrus.cosmetics.hat; + +import net.minecraft.util.Identifier; + +public enum HatType { + NONE("none", null, null, null, 1.0f, 0.0, 0.0, 0.0), + TEST("test_hat", "test_hat", "test_hat.png", "test_hat", 1.0f, 2.0, -5.0, -2.0); + + private final String id; + private final String modelName; + private final String textureName; + private final String animationName; + private final float scale; + private final double posX, posY, posZ; + + HatType(String id, String modelName, String textureName, String animationName, + float scale, double posX, double posY, double posZ) { + this.id = id; + this.modelName = modelName; + this.textureName = textureName; + this.animationName = animationName; + this.scale = scale; + this.posX = posX; + this.posY = posY; + this.posZ = posZ; + } + + public String getId() { + return id; + } + + public String getModelName() { + return modelName; + } + + public Identifier getModelId() { + if (modelName == null || NONE.id.equals(id)) return null; + return Identifier.of("citrus", "geo/hat/" + modelName + ".geo.json"); + } + + public Identifier getTextureId() { + if (textureName == null || NONE.id.equals(id)) return null; + return Identifier.of("citrus", "textures/cosmetics/hat/" + textureName); + } + + public Identifier getAnimationResource() { + if (animationName == null || NONE.id.equals(id)) return null; + return Identifier.of("citrus", "animations/hat/" + animationName + ".animation.json"); + } + + public float getScale() { + return scale; + } + + public double getPosX() { + return posX; + } + + public double getPosY() { + return posY; + } + + public double getPosZ() { + return posZ; + } + + public static HatType fromId(String id) { + if (id == null || id.equals("none") || id.isEmpty()) return NONE; + + for (HatType type : values()) { + if (type.id.equalsIgnoreCase(id)) { + return type; + } + } + return NONE; + } + + public static HatType[] getAvailableTypes() { + return values(); + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/cosmetics/wings/ClientWingManager.java b/src/main/java/de/winniepat/citrus/cosmetics/wings/ClientWingManager.java new file mode 100644 index 0000000..4c6a30d --- /dev/null +++ b/src/main/java/de/winniepat/citrus/cosmetics/wings/ClientWingManager.java @@ -0,0 +1,120 @@ +package de.winniepat.citrus.cosmetics.wings; + +import com.google.gson.*; +import de.winniepat.citrus.Client; +import de.winniepat.citrus.cosmetics.wings.sync.WingSyncManager; +import net.minecraft.client.MinecraftClient; + +import java.io.*; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +public class ClientWingManager { + private static final File CONFIG_FILE = new File(Client.CONFIG_DIR, "citrus_wings.json"); + + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private static Map wingDataMap = new HashMap<>(); + + public static class WingData { + public String activeWings = "none"; + public boolean wingsVisible = true; + } + + static { + load(); + } + + private static void load() { + if (CONFIG_FILE.exists()) { + try (FileReader reader = new FileReader(CONFIG_FILE)) { + JsonObject json = JsonParser.parseReader(reader).getAsJsonObject(); + wingDataMap.clear(); + + for (Map.Entry entry : json.entrySet()) { + UUID uuid = UUID.fromString(entry.getKey()); + JsonObject data = entry.getValue().getAsJsonObject(); + WingData wingData = new WingData(); + + if (data.has("activeWings")) { + wingData.activeWings = data.get("activeWings").getAsString(); + } + if (data.has("wingsVisible")) { + wingData.wingsVisible = data.get("wingsVisible").getAsBoolean(); + } + + wingDataMap.put(uuid, wingData); + } + } catch (Exception e) { + System.err.println("Failed to load wing data: " + e.getMessage()); + } + } + } + + public static void save() { + try { + if (!CONFIG_FILE.getParentFile().exists()) { + CONFIG_FILE.getParentFile().mkdirs(); + } + + try (FileWriter writer = new FileWriter(CONFIG_FILE)) { + JsonObject json = new JsonObject(); + for (Map.Entry entry : wingDataMap.entrySet()) { + JsonObject data = new JsonObject(); + data.addProperty("activeWings", entry.getValue().activeWings); + data.addProperty("wingsVisible", entry.getValue().wingsVisible); + json.add(entry.getKey().toString(), data); + } + GSON.toJson(json, writer); + } + } catch (Exception e) { + System.err.println("Failed to save wing data: " + e.getMessage()); + } + } + + public static WingData getData(UUID playerUUID) { + return wingDataMap.computeIfAbsent(playerUUID, uuid -> new WingData()); + } + + public static String getActiveWings(UUID playerUUID) { + return getData(playerUUID).activeWings; + } + + public static boolean areWingsVisible(UUID playerUUID) { + return getData(playerUUID).wingsVisible; + } + + public static void setActiveWings(UUID playerUUID, String wingId) { + WingData data = getData(playerUUID); + data.activeWings = wingId.toLowerCase(); + save(); + + if (MinecraftClient.getInstance().player != null) { + String playerName = MinecraftClient.getInstance().player.getName().getString(); + WingSyncManager.sendWingsToServer(playerUUID, data, playerName); + } + } + + + public static void toggleWings(UUID playerUUID) { + WingData data = getData(playerUUID); + data.wingsVisible = !data.wingsVisible; + save(); + + if (MinecraftClient.getInstance().player != null) { + String playerName = MinecraftClient.getInstance().player.getName().getString(); + de.winniepat.citrus.cosmetics.wings.sync.WingSyncManager.sendWingsToServer(playerUUID, data, playerName); + } + } + + public static void setWingsVisible(UUID playerUUID, boolean visible) { + WingData data = getData(playerUUID); + data.wingsVisible = visible; + save(); + + if (MinecraftClient.getInstance().player != null) { + String playerName = MinecraftClient.getInstance().player.getName().getString(); + WingSyncManager.sendWingsToServer(playerUUID, data, playerName); + } + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/cosmetics/wings/CosmeticWings.java b/src/main/java/de/winniepat/citrus/cosmetics/wings/CosmeticWings.java new file mode 100644 index 0000000..a246c1b --- /dev/null +++ b/src/main/java/de/winniepat/citrus/cosmetics/wings/CosmeticWings.java @@ -0,0 +1,80 @@ +package de.winniepat.citrus.cosmetics.wings; + +import de.winniepat.citrus.cosmetics.wings.sync.WingSyncManager; +import de.winniepat.citrus.cosmetics.wings.sync.WingSyncManager.SyncedWingData; +import net.minecraft.entity.player.PlayerEntity; +import software.bernie.geckolib.animatable.GeoAnimatable; +import software.bernie.geckolib.animatable.instance.AnimatableInstanceCache; +import software.bernie.geckolib.animation.*; +import software.bernie.geckolib.util.GeckoLibUtil; + +public class CosmeticWings implements GeoAnimatable { + private final AnimatableInstanceCache cache = GeckoLibUtil.createInstanceCache(this); + private final PlayerEntity player; + + public CosmeticWings(PlayerEntity player) { + this.player = player; + } + + public PlayerEntity getPlayer() { + return this.player; + } + + public WingType getWingType() { + SyncedWingData syncedData = + WingSyncManager.getSyncedWings( + player.getUuid(), + player.getName().getString() + ); + + if (syncedData != null && WingSyncManager.hasInitialSyncCompleted()) { + return WingType.fromId(syncedData.wingType); + } + + String wingId = ClientWingManager.getActiveWings(player.getUuid()); + return WingType.fromId(wingId); + } + + public boolean shouldRender() { + WingType type = getWingType(); + if (type == WingType.NONE) { + return false; + } + + SyncedWingData syncedData = + WingSyncManager.getSyncedWings( + player.getUuid(), + player.getName().getString() + ); + + if (syncedData != null && WingSyncManager.hasInitialSyncCompleted()) { + return syncedData.wingsVisible && !player.isInvisible(); + } + + return ClientWingManager.areWingsVisible(player.getUuid()) && !player.isInvisible(); + } + + @Override + public void registerControllers(AnimatableManager.ControllerRegistrar controllers) { + controllers.add(new AnimationController<>(this, "controller", 0, state -> { + WingType type = state.getAnimatable().getWingType(); + if (type == WingType.NONE) { + return null; + } + + PlayerEntity p = state.getAnimatable().getPlayer(); + + return state.setAndContinue(RawAnimation.begin().thenLoop("animation.wings.idle")); + })); + } + + @Override + public AnimatableInstanceCache getAnimatableInstanceCache() { + return this.cache; + } + + @Override + public double getTick(Object object) { + return this.player.age; + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/cosmetics/wings/WingType.java b/src/main/java/de/winniepat/citrus/cosmetics/wings/WingType.java new file mode 100644 index 0000000..0d4bb0d --- /dev/null +++ b/src/main/java/de/winniepat/citrus/cosmetics/wings/WingType.java @@ -0,0 +1,110 @@ +package de.winniepat.citrus.cosmetics.wings; + +import net.minecraft.util.Identifier; + +public enum WingType { + /* + Weil ich Alzheimer hab: ~Winnie + + posX: Größer = weiter links + posY: Größer = höher + posZ: Größer = weiter vorn + */ + + NONE("none", null, null, null, 1f, 0, 0, 0), + + TEST1("wings", "wings", "wings.png", "wings", + 1f, + 0.2, + 0.9, + 0 + ), + + TEST2("wings2", "wings2", "wings2.png", "wings2", + 1.8f, + -0.2, + -0.4, + -0.1 + ), + + TEST3("wings3", "angel_wings", "angel_wings.png", "angel_wings", + 1.8f, + -0.2, + -0.4, + -0.1 + ); + + private final String id; + private final String modelName; + private final String textureName; + private final String animationName; + private final float scale; + private final double posX, posY, posZ; + + WingType(String id, String modelName, String textureName, String animationName, float scale, double posX, double posY, double posZ) { + this.id = id; + this.modelName = modelName; + this.textureName = textureName; + this.animationName = animationName; + this.scale = scale; + this.posX = posX; + this.posY = posY; + this.posZ = posZ; + } + + public String getId() { + return id; + } + + public String getModelName() { + return modelName; + } + + public Identifier getModelId() { + if (modelName == null) return null; + return Identifier.of("citrus", "geo/" + modelName + ".geo.json"); + } + + public Identifier getTextureId() { + if (textureName == null) return null; + return Identifier.of("citrus", "textures/cosmetics/" + textureName); + } + + public Identifier getAnimationResource() { + if (animationName == null || NONE.id.equals(id)) return null; + return Identifier.of("citrus", "animations/" + animationName + ".animation.json"); + } + + public float getScale() { + return scale; + } + + public double getPosX() { + return posX; + } + + public double getPosY() { + return posY; + } + + public double getPosZ() { + return posZ; + } + + public static WingType fromId(String id) { + if (id == null || id.equals("none") || id.isEmpty()) { + return NONE; + } + + for (WingType type : values()) { + if (type.id.equalsIgnoreCase(id)) { + return type; + } + } + return NONE; + } + + public static WingType[] getAvailableTypes() { + return values(); + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/cosmetics/wings/WingsFeatureRenderer.java b/src/main/java/de/winniepat/citrus/cosmetics/wings/WingsFeatureRenderer.java new file mode 100644 index 0000000..b7914c2 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/cosmetics/wings/WingsFeatureRenderer.java @@ -0,0 +1,73 @@ +package de.winniepat.citrus.cosmetics.wings; + +import net.minecraft.client.network.AbstractClientPlayerEntity; +import net.minecraft.client.render.RenderLayer; +import net.minecraft.client.render.VertexConsumerProvider; +import net.minecraft.client.render.entity.feature.FeatureRenderer; +import net.minecraft.client.render.entity.feature.FeatureRendererContext; +import net.minecraft.client.render.entity.model.PlayerEntityModel; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.RotationAxis; +import software.bernie.geckolib.renderer.GeoObjectRenderer; + +import java.util.WeakHashMap; + +public class WingsFeatureRenderer extends FeatureRenderer> { + private final GeoObjectRenderer wingRenderer; + private static final WeakHashMap WING_CACHE = new WeakHashMap<>(); + + public WingsFeatureRenderer(FeatureRendererContext> context) { + super(context); + this.wingRenderer = new GeoObjectRenderer<>(new WingsModel()); + } + + @Override + public void render(MatrixStack matrices, VertexConsumerProvider vertexConsumers, int light, AbstractClientPlayerEntity player, float limbAngle, float limbDistance, float tickDelta, float animationProgress, float headYaw, float headPitch) { + if (player.isInvisible()) return; + + CosmeticWings wings = WING_CACHE.computeIfAbsent(player, CosmeticWings::new); + if (!wings.shouldRender()) return; + + WingType type = wings.getWingType(); + if (type == WingType.NONE) return; + + Identifier texture = this.wingRenderer.getGeoModel().getTextureResource(wings); + if (texture == null) { + System.err.println("Wing texture is null for type: " + type.getId()); + return; + } + + matrices.push(); + + this.getContextModel().body.rotate(matrices); + + matrices.multiply(RotationAxis.POSITIVE_Z.rotationDegrees(180f)); + matrices.multiply(RotationAxis.POSITIVE_Y.rotationDegrees(180f)); + + matrices.translate(-0.7, -2.8, 0.5); + + matrices.translate(type.getPosX(), type.getPosY(), type.getPosZ()); + + matrices.scale(type.getScale(), type.getScale(), -1.0f); + + try { + this.wingRenderer.render( + matrices, wings, vertexConsumers, + RenderLayer.getEntityCutoutNoCull(texture), + vertexConsumers.getBuffer(RenderLayer.getEntityCutoutNoCull(texture)), + light, tickDelta + ); + } catch (Exception e) { + System.err.println("Failed to render wings: " + e.getMessage()); + e.printStackTrace(); + } finally { + matrices.pop(); + } + } + + public static void invalidateWingCache(PlayerEntity player) { + WING_CACHE.remove(player); + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/cosmetics/wings/WingsModel.java b/src/main/java/de/winniepat/citrus/cosmetics/wings/WingsModel.java new file mode 100644 index 0000000..f945f4f --- /dev/null +++ b/src/main/java/de/winniepat/citrus/cosmetics/wings/WingsModel.java @@ -0,0 +1,56 @@ +package de.winniepat.citrus.cosmetics.wings; + +import de.winniepat.citrus.Client; +import software.bernie.geckolib.model.GeoModel; +import net.minecraft.util.Identifier; + +public class WingsModel extends GeoModel { + private static final Identifier DEFAULT_ANIMATION = Identifier.of("citrus", "animations/wings.animation.json"); + private static final Identifier DEFAULT_MODEL = Identifier.of("citrus", "geo/wings.geo.json"); + private static final Identifier DEFAULT_TEXTURE = Identifier.of("citrus", "textures/cosmetics/wings.png"); + + @Override + public Identifier getModelResource(CosmeticWings wings) { + WingType type = wings.getWingType(); + if (type == WingType.NONE) return null; + + Identifier modelId = type.getModelId(); + if (modelId == null) { + Client.debugLog("Wing model is null for type: " + type.getId()); + return DEFAULT_MODEL; + } + return modelId; + } + + @Override + public Identifier getTextureResource(CosmeticWings wings) { + WingType type = wings.getWingType(); + if (type == WingType.NONE) return null; + + Identifier textureId = type.getTextureId(); + if (textureId == null) { + Client.debugLog("Wing texture is null for type: " + type.getId()); + return DEFAULT_TEXTURE; + } + return textureId; + } + + @Override + public Identifier getAnimationResource(CosmeticWings wings) { + WingType type = wings.getWingType(); + if (type == WingType.NONE) { + Client.debugLog("Animation resource is null for NONE wing type"); + return null; + } + + Identifier anim = type.getAnimationResource(); + if (anim == null) { + Client.debugLog("Animation resource is null for wing type: " + type.getId()); + Client.debugLog("Falling back to default animation"); + return DEFAULT_ANIMATION; + } + + Client.debugLog("Wing animation resource: " + anim.toString() + " for type: " + type.getId()); + return anim; + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/cosmetics/wings/sync/PlayerWingTracker.java b/src/main/java/de/winniepat/citrus/cosmetics/wings/sync/PlayerWingTracker.java new file mode 100644 index 0000000..8a8d286 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/cosmetics/wings/sync/PlayerWingTracker.java @@ -0,0 +1,59 @@ +package de.winniepat.citrus.cosmetics.wings.sync; + +import de.winniepat.citrus.managers.ClientRegistrationManager; +import net.minecraft.client.MinecraftClient; +import net.minecraft.entity.player.PlayerEntity; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +public class PlayerWingTracker { + private static final Map LAST_SEEN_PLAYERS = new ConcurrentHashMap<>(); + private static final long SYNC_INTERVAL = 30000; + + public static void tick() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client.world == null || client.player == null) return; + + List citrusUsersToSync = new ArrayList<>(); + long currentTime = System.currentTimeMillis(); + + for (PlayerEntity player : client.world.getPlayers()) { + UUID uuid = player.getUuid(); + + if (player == client.player) continue; + + if (!ClientRegistrationManager.isPlayerOnlineWithCitrus(uuid)) { + continue; + } + + Long lastSeen = LAST_SEEN_PLAYERS.get(uuid); + if (lastSeen == null || currentTime - lastSeen > SYNC_INTERVAL) { + citrusUsersToSync.add(uuid); + LAST_SEEN_PLAYERS.put(uuid, currentTime); + } + } + + if (!citrusUsersToSync.isEmpty()) { + WingSyncManager.batchFetchWings(citrusUsersToSync); + } + + Iterator> iterator = LAST_SEEN_PLAYERS.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + UUID uuid = entry.getKey(); + + boolean stillPresent = client.world.getPlayers().stream() + .anyMatch(p -> p.getUuid().equals(uuid) && p != client.player); + + if (!stillPresent && currentTime - entry.getValue() > 60000) { + iterator.remove(); + WingSyncManager.clearCache(uuid); + } + } + + if (currentTime % 60000 < 50) { + WingSyncManager.retryPendingUpdates(); + } + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/cosmetics/wings/sync/WingSyncManager.java b/src/main/java/de/winniepat/citrus/cosmetics/wings/sync/WingSyncManager.java new file mode 100644 index 0000000..efb8576 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/cosmetics/wings/sync/WingSyncManager.java @@ -0,0 +1,547 @@ +package de.winniepat.citrus.cosmetics.wings.sync; + +import com.google.gson.*; +import de.winniepat.citrus.Client; +import de.winniepat.citrus.managers.ClientRegistrationManager; +import de.winniepat.citrus.utils.SecurityUtils; +import de.winniepat.citrus.cosmetics.wings.ClientWingManager; +import net.minecraft.client.MinecraftClient; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.util.Util; + +import java.io.*; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.*; + +public class WingSyncManager { + private static final String API_BASE_URL = Client.API_URL; + private static final Gson GSON = new GsonBuilder().create(); + public static final Map SYNCED_WING_CACHE = new ConcurrentHashMap<>(); + private static final Map LAST_SYNC_TIME = new ConcurrentHashMap<>(); + private static final Map PENDING_UPDATES = new ConcurrentHashMap<>(); + private static final Set PENDING_FETCHES = ConcurrentHashMap.newKeySet(); + + private static boolean hasSentInitialWings = false; + private static boolean isInitializing = false; + private static boolean serverReachable = true; + private static final long SERVER_CHECK_INTERVAL = 30000; + + private static boolean autoSyncEnabled = true; + private static long lastAutoSyncTime = 0; + private static final long AUTO_SYNC_INTERVAL = 5000; + + private static boolean serverHealthy = false; + private static Timer syncTimer; + + public static class SyncedWingData { + public String wingType = "none"; + public boolean wingsVisible = true; + + public JsonObject toJson() { + JsonObject json = new JsonObject(); + json.addProperty("wingType", wingType); + json.addProperty("wingsVisible", wingsVisible); + return json; + } + + public static SyncedWingData fromJson(JsonObject json) { + SyncedWingData data = new SyncedWingData(); + if (json.has("wingType")) { + data.wingType = json.get("wingType").getAsString(); + } + if (json.has("wingsVisible")) { + data.wingsVisible = json.get("wingsVisible").getAsBoolean(); + } + return data; + } + + public static SyncedWingData fromClientWingData(ClientWingManager.WingData clientData) { + SyncedWingData data = new SyncedWingData(); + data.wingType = clientData.activeWings; + data.wingsVisible = clientData.wingsVisible; + return data; + } + + public ClientWingManager.WingData toClientWingData() { + ClientWingManager.WingData data = new ClientWingManager.WingData(); + data.activeWings = this.wingType; + data.wingsVisible = this.wingsVisible; + return data; + } + } + + public static void init() { + Client.debugLog("Initializing wing sync..."); + isInitializing = true; + + startAutoSync(); + + checkServerHealth().thenAccept(healthy -> { + serverReachable = healthy; + if (healthy) { + Client.debugLog("Wing sync server is online"); + scheduleInitialSync(); + } else { + Client.logger.error("Wing sync server is offline"); + isInitializing = false; + } + }); + } + + private static void scheduleInitialSync() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client.player != null) { + performInitialSync(); + } else { + Util.getMainWorkerExecutor().execute(() -> { + try { + Thread.sleep(2000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + performInitialSync(); + }); + } + } + + private static void performInitialSync() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client.player == null) { + isInitializing = false; + return; + } + + UUID uuid = client.player.getUuid(); + String playerName = client.player.getName().getString(); + + Client.debugLog("Performing initial wing sync for " + playerName + " (" + uuid + ")"); + + fetchWingsFromServer(uuid, playerName, true).thenAccept(serverData -> { + if (serverData != null) { + Client.debugLog("Server has wings: " + serverData.wingType + ", visible: " + serverData.wingsVisible); + applyServerWingsToLocal(uuid, serverData); + } else { + ClientWingManager.WingData localData = ClientWingManager.getData(uuid); + if (localData != null && (!"wings".equals(localData.activeWings) || !localData.wingsVisible)) { + Client.debugLog("Sending local wings to server: " + localData.activeWings + ", visible: " + localData.wingsVisible); + SyncedWingData syncData = SyncedWingData.fromClientWingData(localData); + sendWingsToServer(uuid, syncData, playerName, true).thenAccept(success -> { + if (success) { + Client.debugLog("Initial wing sync completed"); + } + }); + } else { + Client.debugLog("No local wing changes to sync"); + } + } + hasSentInitialWings = true; + isInitializing = false; + }); + } + + public static SyncedWingData getSyncedWings(UUID uuid, String playerName) { + if (!ClientRegistrationManager.isPlayerOnlineWithCitrus(uuid)) { + return null; + } + + if (isInitializing && isLocalPlayer(uuid)) { + return null; + } + + if (SYNCED_WING_CACHE.containsKey(uuid)) { + long lastSync = LAST_SYNC_TIME.getOrDefault(uuid, 0L); + if (System.currentTimeMillis() - lastSync < 300000) { + return SYNCED_WING_CACHE.get(uuid); + } + } + + if (isLocalPlayer(uuid) && !hasSentInitialWings && !isInitializing) { + performInitialSync(); + return null; + } + + if (!PENDING_FETCHES.contains(uuid)) { + fetchWingsFromServer(uuid, playerName, false); + } + + return null; + } + + private static CompletableFuture fetchWingsFromServer(UUID uuid, String playerName, boolean isInitial) { + PENDING_FETCHES.add(uuid); + + return CompletableFuture.supplyAsync(() -> { + HttpURLConnection conn = null; + try { + String urlString = API_BASE_URL + "/wings/" + uuid.toString(); + Client.debugLog("Fetching wings from: " + urlString); + + URL url = new URI(urlString).toURL(); + conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(10000); + conn.setReadTimeout(10000); + + conn.setRequestProperty("User-Agent", "Minecraft/Citrus"); + conn.setRequestProperty("Accept", "application/json"); + conn.setRequestProperty("Accept-Charset", "UTF-8"); + conn.setRequestProperty("x-daily-key", SecurityUtils.getDailyKey()); + + int responseCode = conn.getResponseCode(); + Client.debugLog("Wings response code: " + responseCode); + + if (responseCode == 200) { + serverReachable = true; + try (InputStream is = conn.getInputStream()) { + byte[] response = is.readAllBytes(); + String responseBody = new String(response, StandardCharsets.UTF_8); + Client.debugLog("Wings response: " + responseBody); + + JsonObject json = GSON.fromJson(responseBody, JsonObject.class); + + SyncedWingData wingData = new SyncedWingData(); + wingData.wingType = json.get("wingType").getAsString(); + wingData.wingsVisible = json.get("wingsVisible").getAsBoolean(); + + Client.debugLog("Successfully got wings: " + wingData.wingType + ", visible: " + wingData.wingsVisible); + + SYNCED_WING_CACHE.put(uuid, wingData); + LAST_SYNC_TIME.put(uuid, System.currentTimeMillis()); + return wingData; + } + } else { + serverReachable = false; + Client.logger.error("Wing server returned error code {}", responseCode); + } + + } catch (Exception e) { + serverReachable = false; + Client.logger.error("Exception fetching wings: {}", e.getMessage(), e); + } finally { + if (conn != null) { + conn.disconnect(); + } + PENDING_FETCHES.remove(uuid); + } + return null; + }, Util.getMainWorkerExecutor()); + } + + private static CompletableFuture sendWingsToServer(UUID uuid, SyncedWingData wingData, String playerName, boolean isInitial) { + return CompletableFuture.supplyAsync(() -> { + try { + URL url = new URI(API_BASE_URL + "/wings").toURL(); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("User-Agent", "Citrus/1.0"); + conn.setRequestProperty("x-daily-key", SecurityUtils.getDailyKey()); + conn.setDoOutput(true); + conn.setConnectTimeout(3000); + conn.setReadTimeout(3000); + + JsonObject json = new JsonObject(); + json.addProperty("uuid", uuid.toString()); + json.addProperty("wingType", wingData.wingType); + json.addProperty("wingsVisible", wingData.wingsVisible); + json.addProperty("playerName", playerName); + + try (OutputStream os = conn.getOutputStream()) { + os.write(GSON.toJson(json).getBytes()); + os.flush(); + } + + int responseCode = conn.getResponseCode(); + + if (responseCode == 200) { + serverReachable = true; + if (isInitial) { + hasSentInitialWings = true; + } + + SYNCED_WING_CACHE.put(uuid, wingData); + LAST_SYNC_TIME.put(uuid, System.currentTimeMillis()); + + PENDING_UPDATES.remove(uuid); + + if (!isInitial) { + Client.debugLog("Successfully synced wings to server: " + wingData.wingType); + } + return true; + } else { + serverReachable = false; + if (!isInitial) { + Client.logger.error("Failed to sync wings to server. HTTP {}", responseCode); + } + PENDING_UPDATES.put(uuid, wingData); + return false; + } + } catch (Exception e) { + serverReachable = false; + if (!isInitial) { + Client.logger.error("Failed to send wings to server", e); + } + PENDING_UPDATES.put(uuid, wingData); + return false; + } + }, Util.getMainWorkerExecutor()); + } + + public static void sendWingsToServer(UUID uuid, SyncedWingData wingData, String playerName) { + sendWingsToServer(uuid, wingData, playerName, false); + } + + public static void sendWingsToServer(UUID uuid, ClientWingManager.WingData clientWingData, String playerName) { + SyncedWingData wingData = SyncedWingData.fromClientWingData(clientWingData); + sendWingsToServer(uuid, wingData, playerName, false); + } + + public static void sendWingsToServer(UUID uuid, String wingType, boolean wingsVisible, String playerName) { + SyncedWingData wingData = new SyncedWingData(); + wingData.wingType = wingType; + wingData.wingsVisible = wingsVisible; + sendWingsToServer(uuid, wingData, playerName, false); + } + + private static void applyServerWingsToLocal(UUID uuid, SyncedWingData wingData) { + MinecraftClient.getInstance().execute(() -> { + ClientWingManager.WingData localData = ClientWingManager.getData(uuid); + localData.activeWings = wingData.wingType; + localData.wingsVisible = wingData.wingsVisible; + ClientWingManager.save(); + Client.debugLog("Applied server wings: " + wingData.wingType + ", visible: " + wingData.wingsVisible); + }); + } + + public static void batchFetchWings(List uuids) { + if (uuids.isEmpty()) return; + + List citrusUsers = uuids.stream() + .filter(ClientRegistrationManager::isPlayerOnlineWithCitrus) + .toList(); + + if (citrusUsers.isEmpty()) return; + + CompletableFuture.runAsync(() -> { + try { + URL url = new URI(API_BASE_URL + "/wings/batch").toURL(); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("x-daily-key", SecurityUtils.getDailyKey()); + conn.setDoOutput(true); + conn.setConnectTimeout(5000); + conn.setReadTimeout(5000); + + List uuidStrings = new ArrayList<>(); + for (UUID uuid : uuids) { + uuidStrings.add(uuid.toString()); + } + + JsonObject json = new JsonObject(); + json.add("uuids", GSON.toJsonTree(uuidStrings)); + + try (OutputStream os = conn.getOutputStream()) { + os.write(GSON.toJson(json).getBytes()); + os.flush(); + } + + if (conn.getResponseCode() == 200) { + serverReachable = true; + try (InputStream is = conn.getInputStream()) { + byte[] response = is.readAllBytes(); + JsonObject result = GSON.fromJson(new String(response), JsonObject.class); + + for (String uuidStr : result.keySet()) { + try { + UUID uuid = UUID.fromString(uuidStr); + JsonObject wingDataJson = result.getAsJsonObject(uuidStr); + SyncedWingData wingData = SyncedWingData.fromJson(wingDataJson); + + SYNCED_WING_CACHE.put(uuid, wingData); + LAST_SYNC_TIME.put(uuid, System.currentTimeMillis()); + + if (!isLocalPlayer(uuid)) { + ClientWingManager.WingData clientData = wingData.toClientWingData(); + ClientWingManager.getData(uuid).activeWings = clientData.activeWings; + ClientWingManager.getData(uuid).wingsVisible = clientData.wingsVisible; + } + } catch (Exception e) { + Client.logger.error("Invalid UUID in wings response: {}", uuidStr, e); + } + } + + Client.debugLog("Fetched wings for " + result.size() + " Citrus users"); + } + } + + conn.disconnect(); + } catch (Exception e) { + Client.logger.error("Batch wings fetch failed", e); + } + }, Util.getMainWorkerExecutor()); + } + + public static void startAutoSync() { + if (syncTimer != null) { + syncTimer.cancel(); + } + + syncTimer = new Timer("WingAutoSync", true); + syncTimer.scheduleAtFixedRate(new TimerTask() { + @Override + public void run() { + if (autoSyncEnabled && System.currentTimeMillis() - lastAutoSyncTime >= AUTO_SYNC_INTERVAL) { + performAutoSync(); + System.out.println("Synced wings"); + } + } + }, 2000, AUTO_SYNC_INTERVAL); + } + + private static void performAutoSync() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client.player == null || client.world == null) { + return; + } + + lastAutoSyncTime = System.currentTimeMillis(); + + syncLocalPlayerWings(); + syncNearbyPlayersWings(); + retryPendingUpdates(); + } + + private static void syncLocalPlayerWings() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client.player == null) return; + + UUID uuid = client.player.getUuid(); + String playerName = client.player.getName().getString(); + ClientWingManager.WingData localData = ClientWingManager.getData(uuid); + + if (localData != null) { + Long lastSync = LAST_SYNC_TIME.get(uuid); + if (lastSync == null || System.currentTimeMillis() - lastSync > 30000) { + SyncedWingData syncData = SyncedWingData.fromClientWingData(localData); + sendWingsToServer(uuid, syncData, playerName, false); + } + } + } + + private static void syncNearbyPlayersWings() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client.world == null || client.player == null) return; + + List nearbyCitrusUsers = new ArrayList<>(); + + for (PlayerEntity player : client.world.getPlayers()) { + if (player == client.player) { + continue; + } + + if (ClientRegistrationManager.isPlayerOnlineWithCitrus(player.getUuid())) { + nearbyCitrusUsers.add(player.getUuid()); + } + } + + if (!nearbyCitrusUsers.isEmpty()) { + batchFetchWings(nearbyCitrusUsers); + } + } + + static void retryPendingUpdates() { + if (PENDING_UPDATES.isEmpty()) return; + + MinecraftClient client = MinecraftClient.getInstance(); + if (client.player == null) return; + + for (Map.Entry entry : PENDING_UPDATES.entrySet()) { + String playerName = entry.getKey().equals(client.player.getUuid()) + ? client.player.getName().getString() + : entry.getKey().toString(); + + sendWingsToServer(entry.getKey(), entry.getValue(), playerName, false); + } + } + + public static CompletableFuture checkServerHealth() { + long lastServerCheck = System.currentTimeMillis(); + + return CompletableFuture.supplyAsync(() -> { + try { + URL url = new URI(API_BASE_URL + "/health").toURL(); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setRequestProperty("x-daily-key", SecurityUtils.getDailyKey()); + conn.setConnectTimeout(3000); + + boolean healthy = conn.getResponseCode() == 200; + serverHealthy = conn.getResponseCode() == 200; + conn.disconnect(); + return healthy; + } catch (Exception e) { + serverHealthy = false; + return false; + } + }, Util.getMainWorkerExecutor()); + } + + private static boolean isLocalPlayer(UUID uuid) { + MinecraftClient client = MinecraftClient.getInstance(); + return client.player != null && client.player.getUuid().equals(uuid); + } + + public static void clearCache(UUID uuid) { + SYNCED_WING_CACHE.remove(uuid); + LAST_SYNC_TIME.remove(uuid); + } + + public static void clearAllCache() { + SYNCED_WING_CACHE.clear(); + LAST_SYNC_TIME.clear(); + PENDING_UPDATES.clear(); + PENDING_FETCHES.clear(); + hasSentInitialWings = false; + isInitializing = false; + } + + public static boolean hasInitialSyncCompleted() { + return hasSentInitialWings && !isInitializing; + } + + public static boolean isInitializing() { + return isInitializing; + } + + public static void forceInitialSync() { + if (!hasSentInitialWings) { + isInitializing = true; + performInitialSync(); + } + } + + public static boolean isAutoSyncEnabled() { + return autoSyncEnabled; + } + + public static void setAutoSyncEnabled(boolean enabled) { + autoSyncEnabled = enabled; + if (enabled) { + startAutoSync(); + } else if (syncTimer != null) { + syncTimer.cancel(); + syncTimer = null; + } + } + + public static String getSyncStatus() { + if (!autoSyncEnabled) return "Disabled"; + if (isInitializing) return "Initializing..."; + if (hasSentInitialWings) return "Synced"; + return "Not synced"; + } +} + diff --git a/src/main/java/de/winniepat/citrus/draggui/HudConfig.java b/src/main/java/de/winniepat/citrus/draggui/HudConfig.java new file mode 100644 index 0000000..1052201 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/draggui/HudConfig.java @@ -0,0 +1,78 @@ +package de.winniepat.citrus.draggui; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import de.winniepat.citrus.Client; +import net.fabricmc.loader.api.FabricLoader; + +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; + +public class HudConfig { + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private static final File CUSTOM_DIR = new File(FabricLoader.getInstance().getGameDir().toFile(), "citrus"); + private static final File FILE = new File(CUSTOM_DIR, "hud.json"); + + public int fpsX = 10, fpsY = 10; + public int pingX = 10, pingY = 25; + public int serverX = 10, serverY = 40; + + public int keyX = 10, keyY = 60; + public int armorX = 10, armorY = 120; + public int potionX = 10, potionY = 180; + + public int fpsColor = 0xFFFFFF; + public int pingColor = 0xFFFFFF; + public int serverColor = 0xFFFFFF; + public int keyColor = 0xFFFFFF; + public int armorColor = 0xFFFFFF; + public int potionColor = -1; // -1 ist einfach rot, wenn schlecht und grün, wenn gut + + public float uiScale = 1.0f; + + public boolean fpsEnabled = true; + public boolean pingEnabled = true; + public boolean serverEnabled = true; + public boolean keyEnabled = true; + public boolean armorEnabled = true; + public boolean potionEnabled = true; + + public boolean isModuleEnabled(String label) { + return switch (label) { + case "FPS" -> fpsEnabled; + case "Ping" -> pingEnabled; + case "Server" -> serverEnabled; + case "Keys" -> keyEnabled; + case "Armor" -> armorEnabled; + case "Potion" -> potionEnabled; + default -> false; + }; + } + + public static HudConfig INSTANCE = new HudConfig(); + + public static void load() { + if (!CUSTOM_DIR.exists()) CUSTOM_DIR.mkdirs(); + if (FILE.exists()) { + try (FileReader reader = new FileReader(FILE)) { + INSTANCE = GSON.fromJson(reader, HudConfig.class); + Client.debugLog("Loaded from " + FILE.getAbsolutePath()); + } catch (Exception e) { + Client.logger.error("Error while loading config", e); + } + } + } + + public static void save() { + try { + if (!CUSTOM_DIR.exists()) CUSTOM_DIR.mkdirs(); + try (FileWriter writer = new FileWriter(FILE)) { + GSON.toJson(INSTANCE, writer); + } + } catch (Exception e) { + Client.logger.error("Error while saving config", e); + } + } + +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/draggui/HudEditorScreen.java b/src/main/java/de/winniepat/citrus/draggui/HudEditorScreen.java new file mode 100644 index 0000000..88557f5 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/draggui/HudEditorScreen.java @@ -0,0 +1,397 @@ +package de.winniepat.citrus.draggui; + +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.*; +import net.minecraft.client.network.PlayerListEntry; +import net.minecraft.entity.effect.StatusEffectInstance; +import net.minecraft.item.ItemStack; +import net.minecraft.text.Text; +import net.minecraft.util.math.MathHelper; +import java.awt.Color; +import java.util.Collection; + +public class HudEditorScreen extends Screen { + private enum SelectedModule { + NONE, + FPS, + PING, + SERVER, + KEYS, + ARMOR, + POTIONS + } + + private SelectedModule dragging = SelectedModule.NONE; + private SelectedModule pickingColorFor = SelectedModule.NONE; + + private int dragOffsetX, dragOffsetY; + private TextFieldWidget hexInput, rInput, gInput, bInput; + private ButtonWidget resetButton; + + private float currentHue = 0f, currentSat = 0f, currentBri = 0f; + private boolean isDraggingHue = false, isDraggingSB = false; + private boolean ignoreUpdates = false; + + private final int windowW = 185, windowH = 185; + private final int boxW = 120, boxH = 100, sliderW = 12; + + public HudEditorScreen() { super(Text.literal("HUD Editor")); } + + @Override + protected void init() { + int cx = this.width / 2; + int cy = this.height / 2; + + hexInput = new TextFieldWidget(textRenderer, cx - 80, cy + 55, 55, 16, Text.literal("Hex")); + hexInput.setMaxLength(7); + hexInput.setChangedListener(this::onHexChanged); + + rInput = createRGBField(cx - 20, cy + 55, "R"); + gInput = createRGBField(cx + 15, cy + 55, "G"); + bInput = createRGBField(cx + 50, cy + 55, "B"); + + resetButton = ButtonWidget.builder(Text.literal("Reset Default"), button -> { + updateAll(0xFFFFFFFF, true); + }).dimensions(cx - 80, cy + 78, 160, 20).build(); + + this.addDrawableChild(hexInput); + this.addDrawableChild(rInput); + this.addDrawableChild(gInput); + this.addDrawableChild(bInput); + this.addDrawableChild(resetButton); + } + + private TextFieldWidget createRGBField(int x, int y, String label) { + TextFieldWidget tf = new TextFieldWidget(textRenderer, x, y, 30, 16, Text.literal(label)); + tf.setMaxLength(3); + tf.setChangedListener(s -> onRGBChanged()); + return tf; + } + + @Override + public void render(DrawContext context, int mouseX, int mouseY, float delta) { + this.renderBackground(context, mouseX, mouseY, delta); + HudConfig c = HudConfig.INSTANCE; + MinecraftClient client = MinecraftClient.getInstance(); + + String fpsStr = client.getCurrentFps() + " FPS"; + renderModule(context, c.fpsX, c.fpsY, textRenderer.getWidth(fpsStr), 10, fpsStr, c.fpsColor, dragging == SelectedModule.FPS); + + int ping = 0; + if (client.getNetworkHandler() != null && client.player != null) { + PlayerListEntry entry = client.getNetworkHandler().getPlayerListEntry(client.player.getUuid()); + if (entry != null) ping = entry.getLatency(); + } + String pingStr = ping + "ms"; + renderModule(context, c.pingX, c.pingY, Math.max(textRenderer.getWidth(pingStr), 30), 10, pingStr, c.pingColor, dragging == SelectedModule.PING); + + String srv = client.getCurrentServerEntry() != null ? client.getCurrentServerEntry().address : "Singleplayer"; + renderModule(context, c.serverX, c.serverY, textRenderer.getWidth(srv), 10, srv, c.serverColor, dragging == SelectedModule.SERVER); + + drawLiveKeys(context, c.keyX, c.keyY, c.keyColor, dragging == SelectedModule.KEYS); + + drawLiveArmor(context, c.armorX, c.armorY, dragging == SelectedModule.ARMOR); + + drawLivePotions(context, c.potionX, c.potionY, c.potionColor, dragging == SelectedModule.POTIONS); + + if (pickingColorFor != SelectedModule.NONE) { + renderColorPicker(context, mouseX, mouseY, delta); + } + } + + private void drawLiveKeys(DrawContext ctx, int x, int y, int color, boolean drag) { + MinecraftClient client = MinecraftClient.getInstance(); + if (drag) ctx.fill(x - 2, y - 2, x + 66, y + 88, 0x4400FF00); + + drawKey(ctx, x + 22, y, 20, 20, "W", client.options.forwardKey.isPressed(), color); + drawKey(ctx, x, y + 22, 20, 20, "A", client.options.leftKey.isPressed(), color); + drawKey(ctx, x + 22, y + 22, 20, 20, "S", client.options.backKey.isPressed(), color); + drawKey(ctx, x + 44, y + 22, 20, 20, "D", client.options.rightKey.isPressed(), color); + drawKey(ctx, x, y + 44, 31, 20, "LMB", client.options.attackKey.isPressed(), color); + drawKey(ctx, x + 33, y + 44, 31, 20, "RMB", client.options.useKey.isPressed(), color); + drawKey(ctx, x, y + 66, 31, 20, "Shf", client.options.sneakKey.isPressed(), color); + drawKey(ctx, x + 33, y + 66, 31, 20, "Jmp", client.options.jumpKey.isPressed(), color); + } + + private void drawKey(DrawContext ctx, int x, int y, int w, int h, String label, boolean active, int color) { + ctx.fill(x, y, x + w, y + h, active ? 0x88FFFFFF : 0x44000000); + ctx.drawCenteredTextWithShadow(textRenderer, label, x + w / 2, y + (h / 2 - 4), color); + } + + private void drawLivePotions(DrawContext ctx, int x, int y, int color, boolean drag) { + assert client != null; + assert client.player != null; + Collection effects = client.player.getStatusEffects(); + + if (effects.isEmpty()) { + String placeholder = "[Potion Effects]"; + ctx.fill(x - 2, y - 2, x + textRenderer.getWidth(placeholder) + 2, y + 10, drag ? 0x8800FF00 : 0x44FFFFFF); + ctx.drawText(textRenderer, placeholder, x, y, color, true); + return; + } + + int offset = 0; + for (StatusEffectInstance effect : effects) { + int durationSeconds = effect.getDuration() / 20; + String time = String.format("%d:%02d", durationSeconds / 60, durationSeconds % 60); + String name = Text.translatable(effect.getTranslationKey()).getString(); + String level = effect.getAmplifier() > 0 ? " " + (effect.getAmplifier() + 1) : ""; + String fullText = name + level + " (" + time + ")"; + + if (drag && offset == 0) ctx.fill(x - 2, y - 2, x + textRenderer.getWidth(fullText) + 2, y + (effects.size() * 11), 0x4400FF00); + ctx.drawText(textRenderer, fullText, x, y + offset, color, true); + offset += 11; + } + } + + private void drawLiveArmor(DrawContext ctx, int x, int y, boolean drag) { + if (drag) ctx.fill(x - 2, y - 2, x + 20, y + 82, 0x4400FF00); + int i = 0; + boolean hasArmor = false; + assert client != null; + assert client.player != null; + for (ItemStack stack : client.player.getInventory().armor) { + if (!stack.isEmpty()) { + ctx.drawItem(stack, x, y + (3 - i) * 20); + hasArmor = true; + } + i++; + } + if (!hasArmor) ctx.drawBorder(x, y, 18, 78, 0x88FFFFFF); + } + + private void renderColorPicker(DrawContext context, int mouseX, int mouseY, float delta) { + int x = (this.width - windowW) / 2; + int y = (this.height - windowH) / 2; + + context.fill(x, y, x + windowW, y + windowH, 0xEE111111); + context.drawBorder(x, y, windowW, windowH, 0xFFFFFFFF); + + int bx = x + 10, by = y + 10; + context.fillGradient(bx, by, bx + boxW, by + boxH, Color.HSBtoRGB(currentHue, 1f, 1f), 0xFF000000); + + for (int i = 0; i < boxH; i++) { + context.fill(bx + boxW + 10, by + i, bx + boxW + 10 + sliderW, by + i + 1, 0xFF000000 | Color.HSBtoRGB(i / (float) boxH, 1f, 1f)); + } + + if (isDraggingHue) { + currentHue = MathHelper.clamp((mouseY - by) / (float) boxH, 0f, 1f); syncFromHSB(); + } else if (isDraggingSB) { + currentSat = MathHelper.clamp((mouseX - bx) / (float) boxW, 0f, 1f); + currentBri = MathHelper.clamp(1 - (mouseY - by) / (float) boxH, 0f, 1f); + syncFromHSB(); + } + + hexInput.render(context, mouseX, mouseY, delta); + rInput.render(context, mouseX, mouseY, delta); + gInput.render(context, mouseX, mouseY, delta); + bInput.render(context, mouseX, mouseY, delta); + resetButton.render(context, mouseX, mouseY, delta); + + context.drawText(textRenderer, "Hex", hexInput.getX(), hexInput.getY() - 10, 0xFFAAAAAA, false); + } + + private void renderModule(DrawContext ctx, int x, int y, int w, int h, String text, int col, boolean drag) { + ctx.fill(x - 2, y - 2, x + w + 2, y + h + 2, drag ? 0x8800FF00 : 0x44FFFFFF); + ctx.drawText(textRenderer, text, x, y, col, true); + } + + @Override + public boolean mouseClicked(double mx, double my, int b) { + if (pickingColorFor != SelectedModule.NONE) { + if (super.mouseClicked(mx, my, b)) return true; + this.setFocused(null); + + int x = (this.width - windowW) / 2 + 10; + int y = (this.height - windowH) / 2 + 10; + + if (isHovering(mx, my, x + boxW + 10, y, sliderW, boxH)) { + isDraggingHue = true; + return true; + } + + if (isHovering(mx, my, x, y, boxW, boxH)) { + isDraggingSB = true; + return true; + } + + if (!isHovering(mx, my, (this.width-windowW)/2, (this.height-windowH)/2, windowW, windowH)) { + pickingColorFor = SelectedModule.NONE; + } + return true; + } + SelectedModule hovered = getHoveredModule(mx, my); + if (hovered != SelectedModule.NONE) { + if (b == 0) { + dragging = hovered; + dragOffsetX = (int)mx - getX(hovered); + dragOffsetY = (int)my - getY(hovered); + } else if (b == 1) { + pickingColorFor = hovered; + updateAll(getModuleColor(hovered), true); + } + return true; + } + return super.mouseClicked(mx, my, b); + } + + @Override + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + return super.keyPressed(keyCode, scanCode, modifiers); + } + + @Override + public boolean charTyped(char chr, int modifiers) { + return super.charTyped(chr, modifiers); + } + + private SelectedModule getHoveredModule(double mx, double my) { + HudConfig c = HudConfig.INSTANCE; + if (isHovering(mx, my, c.fpsX, c.fpsY, 50, 10)) return SelectedModule.FPS; + if (isHovering(mx, my, c.pingX, c.pingY, 40, 10)) return SelectedModule.PING; + if (isHovering(mx, my, c.serverX, c.serverY, 80, 10)) return SelectedModule.SERVER; + if (isHovering(mx, my, c.keyX, c.keyY, 66, 88)) return SelectedModule.KEYS; + if (isHovering(mx, my, c.armorX, c.armorY, 20, 80)) return SelectedModule.ARMOR; + if (isHovering(mx, my, c.potionX, c.potionY, 100, 40)) return SelectedModule.POTIONS; + return SelectedModule.NONE; + } + + private void onHexChanged(String hex) { + if (!ignoreUpdates && (hex.length() == 7 || hex.length() == 6)) { + try { + String h = hex.startsWith("#") ? hex.substring(1) : hex; + updateAll(Integer.parseInt(h, 16) | 0xFF000000, false); + } catch (Exception ignored) {} + } + } + + private void onRGBChanged() { + if (!ignoreUpdates) { + try { + updateAll(new Color(Integer.parseInt( + rInput.getText()), + Integer.parseInt(gInput.getText()), + Integer.parseInt(bInput.getText())).getRGB(), + false); + } catch (Exception ignored) { + + } + } + } + + private void syncFromHSB() { + updateAll(Color.HSBtoRGB(currentHue, currentSat, currentBri), false); + } + + private void updateAll(int rgb, boolean force) { + ignoreUpdates = true; + int color = rgb | 0xFF000000; + HudConfig c = HudConfig.INSTANCE; + switch (pickingColorFor) { + case FPS->c.fpsColor=color; + case PING->c.pingColor=color; + case SERVER->c.serverColor=color; + case KEYS->c.keyColor=color; + case ARMOR->c.armorColor=color; + case POTIONS->c.potionColor=color; + } + + float[] hsb = Color.RGBtoHSB((color>>16)&0xFF, (color>>8)&0xFF, color&0xFF, null); + currentHue = hsb[0]; currentSat = hsb[1]; currentBri = hsb[2]; + if (force || !hexInput.isFocused()) hexInput.setText(String.format("#%06X", (0xFFFFFF & color))); + if (force || !rInput.isFocused()) rInput.setText(String.valueOf((color>>16)&0xFF)); + if (force || !gInput.isFocused()) gInput.setText(String.valueOf((color>>8)&0xFF)); + if (force || !bInput.isFocused()) bInput.setText(String.valueOf(color&0xFF)); + ignoreUpdates = false; + } + + private boolean isHovering(double mx, double my, int x, int y, int w, int h) { + return mx >= x && mx <= x + w && my >= y && my <= y + h; + } + + private int getX(SelectedModule m) { + HudConfig c = HudConfig.INSTANCE; + return switch(m) { + case FPS->c.fpsX; + case PING->c.pingX; + case SERVER->c.serverX; + case KEYS->c.keyX; + case ARMOR->c.armorX; + case POTIONS->c.potionX; + default->0; + }; + } + + private int getY(SelectedModule m) { + HudConfig c = HudConfig.INSTANCE; + return switch(m) { + case FPS->c.fpsY; + case PING->c.pingY; + case SERVER->c.serverY; + case KEYS->c.keyY; + case ARMOR->c.armorY; + case POTIONS->c.potionY; + default->0; + }; + } + + private int getModuleColor(SelectedModule m) { + HudConfig c = HudConfig.INSTANCE; + return switch(m) { + case FPS->c.fpsColor; + case PING->c.pingColor; + case SERVER->c.serverColor; + case KEYS->c.keyColor; + case ARMOR->c.armorColor; + case POTIONS->c.potionColor; + default->-1; + }; + } + + @Override public boolean mouseDragged(double mx, double my, int b, double dx, double dy) { + if (dragging != SelectedModule.NONE) { + HudConfig c = HudConfig.INSTANCE; int nx = (int)mx - dragOffsetX, ny = (int)my - dragOffsetY; + switch(dragging) { + case FPS->{ + c.fpsX=nx; + c.fpsY=ny; + } + case PING->{ + c.pingX=nx; + c.pingY=ny; + } + case SERVER->{ + c.serverX=nx; + c.serverY=ny; + } + case KEYS->{ + c.keyX=nx; + c.keyY=ny; + } + case ARMOR->{ + c.armorX=nx; + c.armorY=ny; + } + case POTIONS->{ + c.potionX=nx; + c.potionY=ny; + } + } + return true; + } + return super.mouseDragged(mx, my, b, dx, dy); + } + + @Override public boolean mouseReleased(double mx, double my, int b) { + isDraggingHue = isDraggingSB = false; + dragging = SelectedModule.NONE; + return super.mouseReleased(mx, my, b); + } + + @Override public void close() { + HudConfig.save(); + super.close(); + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/draggui/HudRenderer.java b/src/main/java/de/winniepat/citrus/draggui/HudRenderer.java new file mode 100644 index 0000000..5cfde9d --- /dev/null +++ b/src/main/java/de/winniepat/citrus/draggui/HudRenderer.java @@ -0,0 +1,143 @@ +package de.winniepat.citrus.draggui; + +import net.fabricmc.fabric.api.client.rendering.v1.HudRenderCallback; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.entity.effect.*; +import net.minecraft.item.ItemStack; +import net.minecraft.text.Text; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +public class HudRenderer { + + public static void register() { + HudRenderCallback.EVENT.register((context, tickCounter) -> { + MinecraftClient client = MinecraftClient.getInstance(); + if (client.player == null || client.options.hudHidden) return; + + HudConfig c = HudConfig.INSTANCE; + float scale = c.uiScale <= 0 ? 1.0f : c.uiScale; + + if (scale != 1.0f) { + context.getMatrices().push(); + context.getMatrices().scale(scale, scale, 1f); + } + + if (c.fpsEnabled) { + renderFPS(client, context, c.fpsX, c.fpsY, c.fpsColor); + } + if (c.pingEnabled) { + renderPing(client, context, c.pingX, c.pingY, c.pingColor); + } + if (c.serverEnabled) { + renderServer(client, context, c.serverX, c.serverY, c.serverColor); + } + if (c.keyEnabled) { + renderKeystrokes(client, context, c.keyX, c.keyY, c.keyColor); + } + if (c.armorEnabled) { + renderArmorStatus(client, context, c.armorX, c.armorY, c.armorColor); + } + if (c.potionEnabled) { + renderPotionEffects(client, context, c.potionX, c.potionY, c.potionColor); + } + + if (scale != 1.0f) { + context.getMatrices().pop(); + } + + }); + } + + private static void renderKeystrokes(MinecraftClient client, DrawContext context, int x, int y, int color) { + drawKey(context, x + 22, y, "W", client.options.forwardKey.isPressed(), color); + drawKey(context, x, y + 22, "A", client.options.leftKey.isPressed(), color); + drawKey(context, x + 22, y + 22, "S", client.options.backKey.isPressed(), color); + drawKey(context, x + 44, y + 22, "D", client.options.rightKey.isPressed(), color); + + drawKeyCustom(context, x, y + 44, 31, 20, "LMB", client.options.attackKey.isPressed(), color); + drawKeyCustom(context, x + 33, y + 44, 31, 20, "RMB", client.options.useKey.isPressed(), color); + + drawKeyCustom(context, x, y + 66, 31, 20, "Shf", client.options.sneakKey.isPressed(), color); + drawKeyCustom(context, x + 33, y + 66, 31, 20, "Jmp", client.options.jumpKey.isPressed(), color); + } + + private static void drawKey(DrawContext context, int x, int y, String key, boolean pressed, int color) { + int bgColor = pressed ? 0x99FFFFFF : 0x44000000; + int textColor = pressed ? 0xFF000000 : color; + context.fill(x, y, x + 20, y + 20, bgColor); + context.drawText(MinecraftClient.getInstance().textRenderer, key, x + 7, y + 6, textColor, false); + } + + private static void drawKeyCustom(DrawContext context, int x, int y, int w, int h, String name, boolean down, int color) { + int bgColor = down ? 0x99FFFFFF : 0x44000000; + int textColor = down ? 0xFF000000 : color; + context.fill(x, y, x + w, y + h, bgColor); + int labelWidth = MinecraftClient.getInstance().textRenderer.getWidth(name); + context.drawText(MinecraftClient.getInstance().textRenderer, name, x + (w / 2) - (labelWidth / 2), y + (h / 2) - 4, textColor, false); + } + + private static void renderFPS(MinecraftClient client, DrawContext context, int x, int y, int color) { + context.drawText(client.textRenderer, client.getCurrentFps() + " FPS", x, y, color, true); + } + + private static void renderPing(MinecraftClient client, DrawContext context, int x, int y, int color) { + int ping = 0; + if (client.getNetworkHandler() != null) { + var entry = client.getNetworkHandler().getPlayerListEntry(client.player.getUuid()); + if (entry != null) ping = entry.getLatency(); + } + context.drawText(client.textRenderer, ping + "ms", x, y, color, true); + } + + private static void renderServer(MinecraftClient client, DrawContext context, int x, int y, int color) { + String server = client.getCurrentServerEntry() != null ? client.getCurrentServerEntry().address : "Singleplayer"; + context.drawText(client.textRenderer, server, x, y, color, true); + } + + private static void renderArmorStatus(MinecraftClient client, DrawContext context, int x, int y, int color) { + int offset = 0; + boolean hasArmor = false; + List armor = new ArrayList<>(); + client.player.getArmorItems().forEach(armor::add); + + for (int i = 3; i >= 0; i--) { + ItemStack stack = armor.get(i); + if (stack.isEmpty()) continue; + hasArmor = true; + context.drawItem(stack, x, y + offset); + context.drawItemInSlot(client.textRenderer, stack, x, y + offset); + offset += 18; + } + if (!hasArmor) { + context.drawText(client.textRenderer, "[Armor Status]", x, y, color, true); + } + } + + private static void renderPotionEffects(MinecraftClient client, DrawContext context, int x, int y, int configColor) { + assert client.player != null; + Collection effects = client.player.getStatusEffects(); + if (effects.isEmpty()) { + context.drawText(client.textRenderer, "[Potion Effects]", x, y, configColor, true); + } else { + int potionOffset = 0; + for (var instance : effects) { + int durationSeconds = instance.getDuration() / 20; + String time = String.format("%d:%02d", durationSeconds / 60, durationSeconds % 60); + String name = Text.translatable(instance.getTranslationKey()).getString(); + String level = instance.getAmplifier() > 0 ? " " + (instance.getAmplifier() + 1) : ""; + var effect = instance.getEffectType().value(); + String fullText = name + level + " (" + time + ")"; + + int color = (configColor == -1 || configColor == 0xFFFFFF) ? + (effect.getCategory() == StatusEffectCategory.BENEFICIAL ? 0x55FF55 : 0xFF5555) : configColor; + + context.drawText(client.textRenderer, fullText, x, y + potionOffset, color, true); + potionOffset += 12; + } + } + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/draggui/HudUtils.java b/src/main/java/de/winniepat/citrus/draggui/HudUtils.java new file mode 100644 index 0000000..50c0fc0 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/draggui/HudUtils.java @@ -0,0 +1,61 @@ +package de.winniepat.citrus.draggui; + +import io.wispforest.owo.ui.component.Components; +import io.wispforest.owo.ui.container.Containers; +import io.wispforest.owo.ui.container.FlowLayout; +import io.wispforest.owo.ui.core.*; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; + +import java.util.function.Consumer; + +public class HudUtils { + public static Component createButton(String label, String texture, String tooltip, boolean initialState, Consumer onToggle) { + var textureComponent = Components.texture(Identifier.of("citrus", "textures/modules/" + texture + ".png"), 0, 0, 48, 48, 48, 48); + + var wrapper = Containers.verticalFlow(Sizing.content(), Sizing.content()); + wrapper.child(textureComponent); + wrapper.padding(Insets.of(2)); + + wrapper.mouseDown().subscribe((x, y, b) -> { + if (b != 0) return false; + boolean newState = !HudConfig.INSTANCE.isModuleEnabled(label); + onToggle.accept(newState); + updateWrapperVisuals(wrapper, newState); + HudConfig.save(); + return true; + }); + + wrapper.tooltip(Text.literal(tooltip)); + wrapper.cursorStyle(CursorStyle.HAND); + updateWrapperVisuals(wrapper, initialState); + return wrapper; + } + + private static void updateWrapperVisuals(FlowLayout wrapper, boolean enabled) { + int color = enabled ? Color.ofArgb(0xFF00FF00).argb() : Color.ofArgb(0xFFFF0000).argb(); + wrapper.surface((context, component) -> { + int x = component.x(); + int y = component.y(); + int w = component.width(); + int h = component.height(); + + context.fill(x + 4, y, x + w - 4, y + 1, color); + context.fill(x + 4, y + h - 1, x + w - 4, y + h, color); + context.fill(x, y + 4, x + 1, y + h - 4, color); + context.fill(x + w - 1, y + 4, x + w, y + h - 4, color); + + context.fill(x + 2, y + 1, x + 4, y + 2, color); + context.fill(x + 1, y + 2, x + 2, y + 4, color); + + context.fill(x + w - 4, y + 1, x + w - 2, y + 2, color); + context.fill(x + w - 2, y + 2, x + w - 1, y + 4, color); + + context.fill(x + 2, y + h - 2, x + 4, y + h - 1, color); + context.fill(x + 1, y + h - 4, x + 2, y + h - 2, color); + + context.fill(x + w - 4, y + h - 2, x + w - 2, y + h - 1, color); + context.fill(x + w - 2, y + h - 4, x + w - 1, y + h - 2, color); + }); + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/gui/CapeSelectionScreen.java b/src/main/java/de/winniepat/citrus/gui/CapeSelectionScreen.java new file mode 100644 index 0000000..3a7f485 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/gui/CapeSelectionScreen.java @@ -0,0 +1,245 @@ +package de.winniepat.citrus.gui; + +import de.winniepat.citrus.cosmetics.capes.*; +import de.winniepat.citrus.cosmetics.capes.sync.CapeSyncManager; +import de.winniepat.citrus.utils.toasts.CustomToast; +import io.wispforest.owo.ui.base.BaseOwoScreen; +import io.wispforest.owo.ui.component.*; +import io.wispforest.owo.ui.container.*; +import io.wispforest.owo.ui.core.*; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.text.Text; +import net.minecraft.util.*; +import org.jetbrains.annotations.NotNull; +import org.lwjgl.glfw.GLFW; + +import java.util.*; + +public class CapeSelectionScreen extends BaseOwoScreen { + + private final Map capeButtons = new HashMap<>(); + + private LabelComponent statusLabel; + private LabelComponent currentCapeLabel; + private LabelComponent capeCountLabel; + private FlowLayout capeGrid; + private float updateTimer = 0; + + @Override + protected @NotNull OwoUIAdapter createAdapter() { + return OwoUIAdapter.create(this, Containers::verticalFlow); + } + + @Override + protected void build(FlowLayout root) { + root.alignment(HorizontalAlignment.CENTER,VerticalAlignment.CENTER); + + FlowLayout main = (FlowLayout) Containers.verticalFlow(Sizing.fixed(350), Sizing.fixed(420)) + .padding(Insets.of(10)).alignment(HorizontalAlignment.CENTER,VerticalAlignment.CENTER); + + FlowLayout content = (FlowLayout) Containers.verticalFlow(Sizing.fill(100), Sizing.content()) + .gap(6) + .padding(Insets.of(8)) + .surface(Surface.DARK_PANEL); + + FlowLayout currentRow = (FlowLayout) Containers.horizontalFlow(Sizing.fill(100), Sizing.content()) + .gap(6) + .horizontalAlignment(HorizontalAlignment.CENTER); + currentRow.child(Components.label(Text.literal("Current:").formatted(Formatting.BOLD))); + currentCapeLabel = Components.label(Text.literal("None")); + currentRow.child(currentCapeLabel); + content.child(currentRow); + + FlowLayout statusRow = (FlowLayout) Containers.horizontalFlow(Sizing.fill(100), Sizing.content()) + .gap(6) + .horizontalAlignment(HorizontalAlignment.CENTER); + statusRow.child(Components.label(Text.literal("Status:").formatted(Formatting.BOLD))); + statusLabel = Components.label(Text.literal("Checking...")); + statusRow.child(statusLabel); + content.child(statusRow); + + FlowLayout countRow = (FlowLayout) Containers.horizontalFlow(Sizing.fill(100), Sizing.content()) + .gap(6) + .horizontalAlignment(HorizontalAlignment.CENTER); + countRow.child(Components.label(Text.literal("Available:").formatted(Formatting.BOLD))); + capeCountLabel = Components.label(Text.literal("0")); + countRow.child(capeCountLabel); + content.child(countRow); + + + capeGrid = (FlowLayout) Containers.verticalFlow(Sizing.fill(100), Sizing.content()) + .gap(6) + .padding(Insets.of(4)); + + var scroll = Containers.verticalScroll( + Sizing.fill(100), + Sizing.fixed(180), + capeGrid + ); + + content.child(scroll); + main.child(content); + root.child(main); + + updateStatus(); + populateCapeGrid(); + } + + private void populateCapeGrid() { + capeGrid.clearChildren(); + capeButtons.clear(); + + var capes = CapeHandler.getAvailableCapes(); + var client = MinecraftClient.getInstance(); + if (client.world == null || client.player == null) return; + + capeCountLabel.text(Text.literal(String.valueOf(capes.size()))); + + if (capes.isEmpty()) { + capeGrid.child(Components.label(Text.literal("No capes available")).horizontalSizing(Sizing.fill(100))); + return; + } + + int perRow = 3; + FlowLayout row = null; + int i = 0; + + for (String cape : capes) { + if (i++ % perRow == 0) { + row = Containers.horizontalFlow(Sizing.fill(100), Sizing.content()).gap(8); + capeGrid.child(row); + } + + FlowLayout capeEntry = Containers.verticalFlow(Sizing.fixed(100), Sizing.content()); + capeEntry + .gap(4) + .horizontalAlignment(HorizontalAlignment.CENTER) + .padding(Insets.bottom(20)); + + Identifier textureId = Identifier.of("citrus", "capes/" + cape); + + var preview = Components.texture(textureId, 0, 0, 64, 32, 64, 32) + .sizing(Sizing.fixed(80), Sizing.fixed(40)); + + preview.tooltip(Text.literal(cape)); + + ButtonComponent button = (ButtonComponent) Components.button( + Text.literal(capitalizeFirst(formatCapeName(cape))), + b -> equipCape(cape) + ).horizontalSizing(Sizing.fixed(100)).verticalSizing(Sizing.fixed(20)); + + capeButtons.put(cape, button.renderer( + ButtonComponent.Renderer.flat( + 0x5FFFFFFF, + 0x8FFFFFFF, + 0x1FFFFFFF + )) + ); + + capeEntry.child(preview); + capeEntry.child(button); + row.child(capeEntry); + + if (!CapeHandler.isCapeLoaded(cape)) { + CapeHandler.preloadSpecificCapes(Collections.singletonList(cape)); + } + } + + updateAllButtonStates(); + } + + @Override + public void render(DrawContext context, int mouseX, int mouseY, float delta) { + super.render(context, mouseX, mouseY, delta); + + updateTimer += delta; + if (updateTimer > 10) { + updateAllButtonStates(); + updateTimer = 0; + } + } + + private String formatCapeName(String name) { + name = name.replace("_", " "); + return name.length() > 10 ? name.substring(0, 10) + ".." : name; + } + + private void equipCape(String cape) { + if (!CapeHandler.capeExists(cape)) return; + + if ("failed".equals(CapeHandler.getCapeStatus(cape))) { + setStatusMessage("§cFailed to load cape!"); + return; + } + + CapeHandler.setLocalPlayerCape(cape, false); + updateCurrentCapeDisplay(); + CustomToast.sendCustomToast("Equipped Cape", 0xFF55FF55, "You equipped cape " + capitalizeFirst(cape), 0xFFBBBBBB); + updateAllButtonStates(); + } + + private void updateStatus() { + CapeSyncManager.checkServerHealth().thenAccept(ok -> + MinecraftClient.getInstance().execute(() -> + setStatusMessage(ok ? "Server online" : "Server offline") + ) + ); + updateCurrentCapeDisplay(); + } + + private void updateCurrentCapeDisplay() { + String cape = CapeHandler.getCurrentCapeForLocalPlayer(); + currentCapeLabel.text(Text.literal( + cape == null || "none".equals(cape) ? "None" : + "auto".equals(cape) ? "Auto" : cape + )); + } + + private void updateAllButtonStates() { + String current = CapeHandler.getCurrentCapeForLocalPlayer(); + + for (var entry : capeButtons.entrySet()) { + String capeName = entry.getKey(); + var status = CapeHandler.getCapeStatus(capeName); + + boolean isEquipped = capeName.equals(current); + + entry.getValue().active(!isEquipped); + + if ("failed".equals(status)) { + entry.getValue().setMessage(Text.literal("Error").formatted(Formatting.RED)); + } else if ("loading".equals(status)) { + entry.getValue().setMessage(Text.literal("Loading...").formatted(Formatting.YELLOW)); + } else { + entry.getValue().setMessage(Text.literal(capitalizeFirst(formatCapeName(capeName)))); + } + } + } + + private void setStatusMessage(String msg) { + statusLabel.text(Text.literal(msg)); + } + + public static String capitalizeFirst(String text) { + return text == null || text.isEmpty() + ? text + : text.substring(0, 1).toUpperCase(Locale.ROOT) + + text.substring(1).toLowerCase(Locale.ROOT); + } + + @Override + public boolean keyPressed(int key, int scancode, int mods) { + if (key == GLFW.GLFW_KEY_ESCAPE) { + close(); + return true; + } + return super.keyPressed(key, scancode, mods); + } + + @Override + public void close() { + assert client != null; + client.setScreen(null); + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/gui/CosmeticsFragment.java b/src/main/java/de/winniepat/citrus/gui/CosmeticsFragment.java new file mode 100644 index 0000000..5ba1a35 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/gui/CosmeticsFragment.java @@ -0,0 +1,391 @@ +package de.winniepat.citrus.gui; + +import de.winniepat.citrus.cosmetics.capes.CapeHandler; +import de.winniepat.citrus.cosmetics.wings.ClientWingManager; +import de.winniepat.citrus.cosmetics.wings.WingType; +import de.winniepat.citrus.draggui.HudConfig; +import icyllis.modernui.core.Context; +import icyllis.modernui.fragment.Fragment; +import icyllis.modernui.graphics.Color; +import icyllis.modernui.graphics.drawable.ImageDrawable; +import icyllis.modernui.graphics.drawable.ShapeDrawable; +import icyllis.modernui.util.DataSet; +import icyllis.modernui.view.Gravity; +import icyllis.modernui.view.LayoutInflater; +import icyllis.modernui.view.View; +import icyllis.modernui.view.ViewGroup; +import icyllis.modernui.widget.*; +import net.minecraft.client.MinecraftClient; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.UUID; + +public class CosmeticsFragment extends Fragment { + private LinearLayout contentArea; + private TextView contentTitle; + private Button capesButton; + private Button wingsButton; + private ShapeDrawable highlighted; + + @Override + public View onCreateView(@NotNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable DataSet savedInstanceState) { + Context context = requireContext(); + HudConfig.load(); + + FrameLayout wrapper = new FrameLayout(context); + wrapper.setLayoutParams(new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + )); + + LinearLayout mainLayout = new LinearLayout(context); + mainLayout.setOrientation(LinearLayout.HORIZONTAL); + mainLayout.setGravity(Gravity.CENTER_HORIZONTAL); + + FrameLayout.LayoutParams mainParams = new FrameLayout.LayoutParams( + dp(1200), + dp(800) + ); + mainParams.gravity = Gravity.CENTER; + mainLayout.setLayoutParams(mainParams); + + mainLayout.setPadding(dp(10), dp(10), dp(10), dp(10)); + ShapeDrawable background = new ShapeDrawable(); + background.setShape(ShapeDrawable.RECTANGLE); + background.setColor(0x50000000); + background.setCornerRadius(20); + background.setStroke(dp(10), 0x305d5b5f); + background.setCornerRadii(dp(20), 20, dp(20), 20); + mainLayout.setBackground(background); + + LinearLayout sidebar = new LinearLayout(context); + sidebar.setOrientation(LinearLayout.VERTICAL); + sidebar.setPadding(dp(16), dp(16), dp(16), dp(16)); + LinearLayout.LayoutParams sidebarParams = new LinearLayout.LayoutParams( + dp(200), + ViewGroup.LayoutParams.MATCH_PARENT + ); + sidebar.setLayoutParams(sidebarParams); + + ShapeDrawable sidebarBackground = new ShapeDrawable(); + sidebarBackground.setShape(ShapeDrawable.RECTANGLE); + sidebarBackground.setColor(0x80000000); + sidebarBackground.setCornerRadii(10,10,10,10); + sidebar.setBackground(sidebarBackground); + + TextView sidebarTitle = new TextView(context); + sidebarTitle.setText("Menu"); + sidebarTitle.setTextSize(20); + sidebarTitle.setTextColor(Color.WHITE); + sidebarTitle.setPadding(dp(8), dp(8), dp(8), dp(16)); + sidebar.addView(sidebarTitle); + + capesButton = new Button(context); + capesButton.setText("Capes"); + capesButton.setTextColor(Color.WHITE); + LinearLayout.LayoutParams buttonParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + dp(40) + ); + buttonParams.setMargins(0, dp(4), 0, dp(4)); + capesButton.setLayoutParams(buttonParams); + capesButton.setOnClickListener(v -> showCapesTab()); + sidebar.addView(capesButton); + + wingsButton = new Button(context); + wingsButton.setText("Wings"); + wingsButton.setTextColor(Color.WHITE); + wingsButton.setOnClickListener(v -> showWingsTab()); + LinearLayout.LayoutParams wingsParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + dp(40) + ); + wingsParams.setMargins(0, dp(4), 0, dp(4)); + wingsButton.setLayoutParams(wingsParams); + sidebar.addView(wingsButton); + + mainLayout.addView(sidebar); + + contentArea = new LinearLayout(context); + contentArea.setOrientation(LinearLayout.VERTICAL); + contentArea.setLayoutParams(new LinearLayout.LayoutParams( + 0, + ViewGroup.LayoutParams.MATCH_PARENT, + 1.0f + )); + + mainLayout.addView(contentArea); + wrapper.addView(mainLayout); + + showCapesTab(); + + return wrapper; + } + + private void showCapesTab() { + System.out.println("Switching to Capes tab"); + Context context = requireContext(); + contentArea.removeAllViews(); + + updateButtonStates(true); + + LinearLayout root = new LinearLayout(context); + root.setOrientation(LinearLayout.VERTICAL); + root.setPadding(dp(16), dp(16), dp(16), dp(16)); + + contentTitle = new TextView(context); + contentTitle.setText("Select a Cape"); + contentTitle.setTextSize(24); + contentTitle.setTextColor(Color.WHITE); + root.addView(contentTitle); + + List capes = CapeHandler.getAvailableCapes(); + int perRow = 3; + int i = 0; + LinearLayout row = null; + + for (String cape : capes) { + if (i % perRow == 0) { + row = new LinearLayout(context); + row.setGravity(Gravity.CENTER_HORIZONTAL); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setLayoutParams(new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + )); + root.addView(row); + } + + LinearLayout buttonContainer = new LinearLayout(context); + buttonContainer.setOrientation(LinearLayout.VERTICAL); + buttonContainer.setGravity(Gravity.CENTER); + + ShapeDrawable customShape = new ShapeDrawable(); + customShape.setShape(ShapeDrawable.RECTANGLE); + customShape.setStroke(dp(4), 0x605d5b5f); + customShape.setCornerRadii(dp(10), 10, dp(10), 10); + buttonContainer.setBackground(customShape); + + ImageView capeImage = new ImageView(context); + ImageDrawable imageDrawable = new ImageDrawable("citrus", "capes/" + cape + ".png"); + capeImage.setImageDrawable(imageDrawable); + + LinearLayout.LayoutParams imageParams = new LinearLayout.LayoutParams( + dp(64), + dp(80) + ); + imageParams.setMargins(dp(4), dp(4), dp(4), dp(4)); + capeImage.setLayoutParams(imageParams); + buttonContainer.addView(capeImage); + + TextView nameView = new TextView(context); + nameView.setText(cape); + nameView.setTextColor(Color.WHITE); + nameView.setTextSize(12); + nameView.setGravity(Gravity.CENTER); + nameView.setPadding(dp(4), dp(4), dp(4), dp(4)); + buttonContainer.addView(nameView); + + buttonContainer.setOnClickListener(v -> { + CapeHandler.setLocalPlayerCape(cape, false); + contentTitle.setText("Equipped: " + cape); + }); + + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + dp(90), + dp(120) + ); + params.setMargins(dp(4), dp(4), dp(4), dp(4)); + buttonContainer.setLayoutParams(params); + + row.addView(buttonContainer); + i++; + } + + ScrollView scrollView = new ScrollView(context); + FrameLayout.LayoutParams scrollParams = new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ); + scrollParams.gravity = Gravity.CENTER; + scrollView.setLayoutParams(scrollParams); + + scrollView.addView(root); + contentArea.addView(scrollView); + } + + private void showWingsTab() { + System.out.println("Switching to Wings tab"); + Context context = requireContext(); + contentArea.removeAllViews(); + + updateButtonStates(false); + + MinecraftClient client = MinecraftClient.getInstance(); + if (client.player == null) { + TextView error = new TextView(context); + error.setText("You must be in-game!"); + error.setTextColor(0xFFFF5555); + error.setGravity(Gravity.CENTER); + contentArea.addView(error); + return; + } + + UUID playerUuid = client.player.getUuid(); + + LinearLayout root = new LinearLayout(context); + root.setOrientation(LinearLayout.VERTICAL); + root.setPadding(dp(16), dp(16), dp(16), dp(16)); + + TextView title = new TextView(context); + title.setText("Wings Configuration"); + title.setTextSize(24); + title.setTextColor(Color.WHITE); + LinearLayout.LayoutParams titleParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + titleParams.setMargins(0, 0, 0, dp(20)); + root.addView(title, titleParams); + + LinearLayout toggleRow = new LinearLayout(context); + toggleRow.setOrientation(LinearLayout.HORIZONTAL); + toggleRow.setGravity(Gravity.CENTER_VERTICAL); + LinearLayout.LayoutParams toggleParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + toggleParams.setMargins(0, 0, 0, dp(20)); + + TextView toggleLabel = new TextView(context); + toggleLabel.setText("Show Wings:"); + toggleLabel.setTextSize(16); + toggleLabel.setTextColor(Color.WHITE); + + Switch wingsToggle = new Switch(context); + wingsToggle.setChecked(ClientWingManager.areWingsVisible(playerUuid)); + wingsToggle.setOnCheckedChangeListener((buttonView, isChecked) -> { + if (isChecked != ClientWingManager.areWingsVisible(playerUuid)) { + ClientWingManager.toggleWings(playerUuid); + } + }); + + toggleRow.addView(toggleLabel); + LinearLayout.LayoutParams switchParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + switchParams.setMargins(dp(10), 0, 0, 0); + toggleRow.addView(wingsToggle, switchParams); + root.addView(toggleRow, toggleParams); + + TextView typeLabel = new TextView(context); + typeLabel.setText("Wing Type:"); + typeLabel.setTextSize(18); + typeLabel.setTextColor(Color.WHITE); + LinearLayout.LayoutParams typeLabelParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + typeLabelParams.setMargins(0, 0, 0, dp(10)); + root.addView(typeLabel, typeLabelParams); + + String currentWingType = ClientWingManager.getActiveWings(playerUuid); + + int perRow = 3; + int i = 0; + LinearLayout row = null; + + for (WingType wingType : WingType.getAvailableTypes()) { + if (i % perRow == 0) { + row = new LinearLayout(context); + row.setGravity(Gravity.CENTER_HORIZONTAL); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setLayoutParams(new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + )); + root.addView(row); + } + + LinearLayout wingContainer = new LinearLayout(context); + wingContainer.setOrientation(LinearLayout.VERTICAL); + wingContainer.setGravity(Gravity.CENTER); + + boolean isSelected = wingType.getId().equals(currentWingType); + ShapeDrawable wingShape = new ShapeDrawable(); + wingShape.setShape(ShapeDrawable.RECTANGLE); + wingShape.setStroke(dp(4), isSelected ? 0xFF55FF55 : 0x605d5b5f); + wingShape.setCornerRadii(dp(10), 10, dp(10), 10); + wingContainer.setBackground(wingShape); + + ImageView wingImage = new ImageView(context); + ImageDrawable imageDrawable = new ImageDrawable("citrus", "textures/cosmetics/" + wingType.getId() + ".png"); + wingImage.setImageDrawable(imageDrawable); + + LinearLayout.LayoutParams imageParams = new LinearLayout.LayoutParams( + dp(64), + dp(80) + ); + imageParams.setMargins(dp(4), dp(4), dp(4), dp(4)); + wingImage.setLayoutParams(imageParams); + wingContainer.addView(wingImage); + + TextView wingName = new TextView(context); + wingName.setText(formatWingTypeName(wingType.getId())); + wingName.setTextColor(Color.WHITE); + wingName.setTextSize(12); + wingName.setGravity(Gravity.CENTER); + wingName.setPadding(dp(4), dp(4), dp(4), dp(4)); + wingContainer.addView(wingName); + + wingContainer.setOnClickListener(v -> { + ClientWingManager.setActiveWings(playerUuid, wingType.getId()); + showWingsTab(); + }); + + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + dp(90), + dp(120) + ); + params.setMargins(dp(4), dp(4), dp(4), dp(4)); + wingContainer.setLayoutParams(params); + + row.addView(wingContainer); + i++; + } + + ScrollView scrollView = new ScrollView(context); + scrollView.addView(root); + contentArea.addView(scrollView); + } + + private void updateButtonStates(boolean capesActive) { + if (capesActive) { + capesButton.setBackground(highlighted); + wingsButton.setBackground(null); + } else { + capesButton.setBackground(null); + wingsButton.setBackground(highlighted); + } + } + + private String formatWingTypeName(String id) { + String[] parts = id.split("_"); + StringBuilder formatted = new StringBuilder(); + for (String part : parts) { + if (formatted.length() > 0) { + formatted.append(" "); + } + formatted.append(part.substring(0, 1).toUpperCase()) + .append(part.substring(1).toLowerCase()); + } + return formatted.toString(); + } + + private int dp(int value) { + return (int) (value * getContext().getResources().getDisplayMetrics().density); + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/gui/FriendsFragment.java b/src/main/java/de/winniepat/citrus/gui/FriendsFragment.java new file mode 100644 index 0000000..e2843fb --- /dev/null +++ b/src/main/java/de/winniepat/citrus/gui/FriendsFragment.java @@ -0,0 +1,732 @@ +package de.winniepat.citrus.gui; + +import de.winniepat.citrus.managers.FriendManager; +import de.winniepat.citrus.managers.FriendManager.Friend; +import de.winniepat.citrus.managers.FriendManager.FriendRequest; +import de.winniepat.citrus.managers.WallpaperManager; +import de.winniepat.citrus.managers.ConfigManager; +import de.winniepat.citrus.utils.texture.CdnTextureDrawable; +import de.winniepat.citrus.utils.toasts.CustomToast; +import icyllis.modernui.animation.*; +import icyllis.modernui.core.Context; +import icyllis.modernui.fragment.Fragment; +import icyllis.modernui.graphics.drawable.*; +import icyllis.modernui.mc.*; +import icyllis.modernui.text.InputFilter; +import icyllis.modernui.text.TextPaint; +import icyllis.modernui.util.DataSet; +import icyllis.modernui.view.*; +import icyllis.modernui.widget.*; +import net.minecraft.client.MinecraftClient; +import org.jetbrains.annotations.*; + +import java.util.ArrayList; +import java.util.List; + +public class FriendsFragment extends Fragment implements ScreenCallback { + + private LinearLayout mFriendsListContainer; + private LinearLayout mRequestsListContainer; + private EditText mAddFriendInput; + private CdnTextureDrawable mBackground1Drawable; + private final List mStaggeredViews = new ArrayList<>(); + + private float getGuiScale() { + return (float) MinecraftClient.getInstance().getWindow().getScaleFactor(); + } + + private void setFixedTextSize(TextView tv, float px) { + float scale = getGuiScale(); + tv.setTextSize(px / scale); + } + + @Override + public View onCreateView(@NotNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable DataSet savedInstanceState) { + Context context = requireContext(); + + FrameLayout root = new FrameLayout(context); + root.setLayoutParams(new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + )); + + FrameLayout mBackgroundView = new FrameLayout(context); + mBackgroundView.setLayoutParams(new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + )); + mBackgroundView.setScaleX(1.1f); + mBackgroundView.setScaleY(1.1f); + + View mBackgroundView1 = new View(context); + mBackgroundView1.setLayoutParams(new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + )); + mBackgroundView.addView(mBackgroundView1); + + root.addView(mBackgroundView); + + mBackground1Drawable = new CdnTextureDrawable(); + mBackgroundView1.setBackground(mBackground1Drawable); + + String currentWallpaper = ConfigManager.config.mainmenuWallpaper; + if (currentWallpaper == null || currentWallpaper.isEmpty()) { + currentWallpaper = "default.png"; + } + WallpaperManager.loadWallpaperWithData(currentWallpaper, imageData -> { + MuiModApi.postToUiThread(() -> mBackground1Drawable.setImageData(imageData)); + }); + + ObjectAnimator bgPulseX = ObjectAnimator.ofFloat(mBackgroundView, View.SCALE_X, 1.1f, 1.15f); + ObjectAnimator bgPulseY = ObjectAnimator.ofFloat(mBackgroundView, View.SCALE_Y, 1.1f, 1.15f); + bgPulseX.setDuration(15000); + bgPulseY.setDuration(15000); + bgPulseX.setRepeatCount(ValueAnimator.INFINITE); + bgPulseY.setRepeatCount(ValueAnimator.INFINITE); + bgPulseX.setRepeatMode(ValueAnimator.REVERSE); + bgPulseY.setRepeatMode(ValueAnimator.REVERSE); + bgPulseX.start(); + bgPulseY.start(); + + FrameLayout contentWrapper = new FrameLayout(context); + contentWrapper.setLayoutParams(new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + )); + root.addView(contentWrapper); + + LinearLayout leftPanel = new LinearLayout(context); + leftPanel.setOrientation(LinearLayout.VERTICAL); + FrameLayout.LayoutParams leftPanelLp = new FrameLayout.LayoutParams( + 300, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + leftPanelLp.gravity = Gravity.CENTER_VERTICAL; + leftPanelLp.setMargins(60, 0, 0, 0); + leftPanel.setLayoutParams(leftPanelLp); + + TextView title = new TextView(context); + title.setText("FRIENDS"); + setFixedTextSize(title, 96); + title.getPaint().setTextStyle(TextPaint.BOLD); + title.setTextColor(0xFFFFFFFF); + leftPanel.addView(title); + mStaggeredViews.add(title); + + TextView subtitle = new TextView(context); + subtitle.setText("Manage your friends list"); + setFixedTextSize(subtitle, 32); + subtitle.setTextColor(0xCCFFFFFF); + LinearLayout.LayoutParams subtitleLp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + subtitleLp.setMargins(0, 0, 0, dp(30)); + subtitle.setLayoutParams(subtitleLp); + leftPanel.addView(subtitle); + mStaggeredViews.add(subtitle); + + LinearLayout addSection = new LinearLayout(context); + addSection.setOrientation(LinearLayout.VERTICAL); + LinearLayout.LayoutParams addSectionLp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + addSectionLp.setMargins(0, 0, 0, dp(20)); + addSection.setLayoutParams(addSectionLp); + + TextView addLabel = new TextView(context); + addLabel.setText("Add a Friend"); + setFixedTextSize(addLabel, 28); + addLabel.setTextColor(0x88FFFFFF); + addLabel.setPadding(0, 0, 0, dp(8)); + addSection.addView(addLabel); + + mAddFriendInput = new EditText(context); + mAddFriendInput.setHint("Enter player name..."); + setFixedTextSize(mAddFriendInput, 32); + mAddFriendInput.setTextColor(0xFFFFFFFF); + mAddFriendInput.setHintTextColor(0x66FFFFFF); + mAddFriendInput.setPadding(dp(16), dp(12), dp(16), dp(12)); + mAddFriendInput.setSingleLine(true); + mAddFriendInput.setFilters(new InputFilter.LengthFilter(16)); + ShapeDrawable inputBg = new ShapeDrawable(); + inputBg.setShape(ShapeDrawable.RECTANGLE); + inputBg.setColor(0x26FFFFFF); + inputBg.setCornerRadius(dp(10)); + mAddFriendInput.setBackground(inputBg); + LinearLayout.LayoutParams inputLp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + inputLp.setMargins(0, 0, 0, dp(8)); + mAddFriendInput.setLayoutParams(inputLp); + addSection.addView(mAddFriendInput); + + leftPanel.addView(addSection); + mStaggeredViews.add(addSection); + + addNavButton(leftPanel, "Send Request", "multiplayer_icon", v -> sendFriendRequest()); + addNavButton(leftPanel, "Refresh", "settings_icon", v -> refreshData()); + addNavButton(leftPanel, "Back to Menu", "door_icon", v -> { + MinecraftClient.getInstance().execute(() -> { + MinecraftClient.getInstance().setScreen(MuiModApi.get().createScreen(new MainMenuScreen(), null, null)); + }); + }); + + contentWrapper.addView(leftPanel); + + LinearLayout rightPanel = new LinearLayout(context); + rightPanel.setOrientation(LinearLayout.HORIZONTAL); + rightPanel.setGravity(Gravity.TOP); + FrameLayout.LayoutParams rightPanelLp = new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + rightPanelLp.gravity = Gravity.CENTER_VERTICAL | Gravity.RIGHT; + rightPanelLp.setMargins(0, 0, 60, 0); + rightPanel.setLayoutParams(rightPanelLp); + + LinearLayout friendsPanel = createListPanel(context, "FRIENDS", true); + LinearLayout.LayoutParams friendsPanelLp = new LinearLayout.LayoutParams( + 320, + 450 + ); + friendsPanelLp.setMargins(0, 0, dp(20), 0); + friendsPanel.setLayoutParams(friendsPanelLp); + rightPanel.addView(friendsPanel); + mStaggeredViews.add(friendsPanel); + + LinearLayout requestsPanel = createListPanel(context, "REQUESTS", false); + LinearLayout.LayoutParams requestsPanelLp = new LinearLayout.LayoutParams( + 320, + 450 + ); + requestsPanel.setLayoutParams(requestsPanelLp); + rightPanel.addView(requestsPanel); + mStaggeredViews.add(requestsPanel); + + contentWrapper.addView(rightPanel); + + TextView infoText = new TextView(context); + infoText.setText("Add your friends and see what they are up to • Online status updates every minute"); + setFixedTextSize(infoText, 24); + infoText.setTextColor(0x66FFFFFF); + FrameLayout.LayoutParams infoLp = new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + infoLp.gravity = Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL; + infoLp.setMargins(0, 0, 0, dp(20)); + infoText.setLayoutParams(infoLp); + contentWrapper.addView(infoText); + mStaggeredViews.add(infoText); + + refreshLists(); + refreshData(); + animateEntrance(); + + return root; + } + + private LinearLayout createListPanel(Context context, String title, boolean isFriendsList) { + LinearLayout panel = new LinearLayout(context); + panel.setOrientation(LinearLayout.VERTICAL); + panel.setPadding(dp(16), dp(16), dp(16), dp(16)); + + ShapeDrawable panelBg = new ShapeDrawable(); + panelBg.setShape(ShapeDrawable.RECTANGLE); + panelBg.setColor(0x33000000); + panelBg.setCornerRadius(dp(16)); + panel.setBackground(panelBg); + + TextView titleTv = new TextView(context); + titleTv.setText(title); + setFixedTextSize(titleTv, 48); + titleTv.getPaint().setTextStyle(TextPaint.BOLD); + titleTv.setTextColor(isFriendsList ? 0xFF55FF55 : 0xFFFFD700); + titleTv.setGravity(Gravity.CENTER); + LinearLayout.LayoutParams titleLp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + titleLp.setMargins(0, 0, 0, dp(12)); + titleTv.setLayoutParams(titleLp); + panel.addView(titleTv); + + ScrollView scrollView = new ScrollView(context); + LinearLayout.LayoutParams scrollLp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + 0, 1f + ); + scrollView.setLayoutParams(scrollLp); + scrollView.setScrollBarSize(dp(6)); + + LinearLayout listContainer = new LinearLayout(context); + listContainer.setOrientation(LinearLayout.VERTICAL); + + if (isFriendsList) { + mFriendsListContainer = listContainer; + } else { + mRequestsListContainer = listContainer; + } + + scrollView.addView(listContainer); + panel.addView(scrollView); + + return panel; + } + + private void addNavButton(LinearLayout parent, String text, String iconPath, View.OnClickListener listener) { + Context context = requireContext(); + + FrameLayout btnWrapper = new FrameLayout(context); + LinearLayout.LayoutParams wrapperLp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + 56 + ); + wrapperLp.setMargins(0, 0, 0, 8); + btnWrapper.setLayoutParams(wrapperLp); + + ShapeDrawable btnBg = new ShapeDrawable(); + btnBg.setShape(ShapeDrawable.RECTANGLE); + btnBg.setColor(0x1AFFFFFF); + btnBg.setCornerRadius(12); + btnWrapper.setBackground(btnBg); + + LinearLayout content = new LinearLayout(context); + content.setOrientation(LinearLayout.HORIZONTAL); + content.setGravity(Gravity.CENTER_VERTICAL); + content.setPadding(20, 0, 20, 0); + + ImageDrawable icon = new ImageDrawable("citrus", "startscreen/" + iconPath + ".png"); + icon.setBounds(0, 0, 24, 24); + + TextView tv = new TextView(context); + tv.setText(text); + setFixedTextSize(tv, 36); + tv.setTextColor(0xFFFFFFFF); + tv.setCompoundDrawables(icon, null, null, null); + tv.setCompoundDrawablePadding(16); + + content.addView(tv); + btnWrapper.addView(content); + + btnWrapper.setClickable(true); + btnWrapper.setFocusable(true); + btnWrapper.setOnClickListener(listener); + + btnWrapper.setOnHoverListener((v, event) -> { + if (event.getAction() == MotionEvent.ACTION_HOVER_ENTER) { + ObjectAnimator animator = ObjectAnimator.ofFloat(v, View.TRANSLATION_X, 0f, (float) dp(15)); + animator.setDuration(250); + animator.setInterpolator(TimeInterpolator.DECELERATE); + animator.start(); + btnBg.setColor(0x33FFFFFF); + return true; + } else if (event.getAction() == MotionEvent.ACTION_HOVER_EXIT) { + ObjectAnimator animator = ObjectAnimator.ofFloat(v, View.TRANSLATION_X, (float) dp(15), 0f); + animator.setDuration(250); + animator.setInterpolator(TimeInterpolator.DECELERATE); + animator.start(); + btnBg.setColor(0x1AFFFFFF); + return true; + } + return false; + }); + + parent.addView(btnWrapper); + mStaggeredViews.add(btnWrapper); + } + + private void animateEntrance() { + long delay = 100; + for (View v : mStaggeredViews) { + v.setAlpha(0); + float startX = -dp(40); + + ViewGroup.LayoutParams lp = v.getLayoutParams(); + if (lp instanceof FrameLayout.LayoutParams flp) { + if ((flp.gravity & Gravity.RIGHT) == Gravity.RIGHT) { + startX = dp(40); + } + } + + v.setTranslationX(startX); + + ObjectAnimator alpha = ObjectAnimator.ofFloat(v, View.ALPHA, 0f, 1f); + ObjectAnimator trans = ObjectAnimator.ofFloat(v, View.TRANSLATION_X, startX, 0f); + + AnimatorSet set = new AnimatorSet(); + set.playTogether(alpha, trans); + set.setDuration(800); + set.setStartDelay(delay); + set.setInterpolator(TimeInterpolator.DECELERATE); + set.start(); + + delay += 60; + } + } + + private void sendFriendRequest() { + String name = mAddFriendInput.getText().toString().trim(); + if (name.isEmpty()) { + CustomToast.sendCustomToast("Error", 0xFFFF5555, "Please enter a player name", 0xFFFFFFFF); + return; + } + + FriendManager.sendRequest(name).thenAccept(error -> { + MuiModApi.postToUiThread(() -> { + if (error == null) { + CustomToast.sendCustomToast("Success", 0xFF55FF55, "Friend request sent to " + name, 0xFFBBBBBB); + mAddFriendInput.setText(""); + } else { + CustomToast.sendCustomToast("Error", 0xFFFF5555, error, 0xFFFFFFFF); + } + }); + }); + } + + private void refreshData() { + FriendManager.refresh().thenAccept(v -> { + MuiModApi.postToUiThread(this::refreshLists); + }); + } + + private void refreshLists() { + if (mFriendsListContainer == null || mRequestsListContainer == null) return; + + Context context = requireContext(); + + mFriendsListContainer.removeAllViews(); + List friends = FriendManager.getFriends(); + + if (friends.isEmpty()) { + mFriendsListContainer.addView(createEmptyState(context, "No friends yet", "Add someone using the input on the left!")); + } else { + for (Friend friend : friends) { + mFriendsListContainer.addView(createFriendCard(context, friend)); + } + } + + mRequestsListContainer.removeAllViews(); + List requests = FriendManager.getIncomingRequests(); + + if (requests.isEmpty()) { + mRequestsListContainer.addView(createEmptyState(context, "No requests", "Pending requests will appear here")); + } else { + for (FriendRequest request : requests) { + mRequestsListContainer.addView(createRequestCard(context, request)); + } + } + } + + private View createEmptyState(Context context, String title, String subtitle) { + LinearLayout container = new LinearLayout(context); + container.setOrientation(LinearLayout.VERTICAL); + container.setGravity(Gravity.CENTER); + container.setPadding(dp(16), dp(40), dp(16), dp(40)); + + TextView titleTv = new TextView(context); + titleTv.setText(title); + setFixedTextSize(titleTv, 32); + titleTv.setTextColor(0x88FFFFFF); + titleTv.setGravity(Gravity.CENTER); + container.addView(titleTv); + + TextView subtitleTv = new TextView(context); + subtitleTv.setText(subtitle); + setFixedTextSize(subtitleTv, 24); + subtitleTv.setTextColor(0x55FFFFFF); + subtitleTv.setGravity(Gravity.CENTER); + subtitleTv.setPadding(0, dp(4), 0, 0); + container.addView(subtitleTv); + + return container; + } + + private View createFriendCard(Context context, Friend friend) { + LinearLayout card = new LinearLayout(context); + card.setOrientation(LinearLayout.VERTICAL); + card.setPadding(dp(12), dp(10), dp(12), dp(10)); + LinearLayout.LayoutParams cardLp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + cardLp.setMargins(0, 0, 0, dp(8)); + card.setLayoutParams(cardLp); + + ShapeDrawable cardBg = new ShapeDrawable(); + cardBg.setShape(ShapeDrawable.RECTANGLE); + cardBg.setColor(0x20FFFFFF); + cardBg.setCornerRadius(dp(10)); + card.setBackground(cardBg); + + LinearLayout topRow = new LinearLayout(context); + topRow.setOrientation(LinearLayout.HORIZONTAL); + topRow.setGravity(Gravity.CENTER_VERTICAL); + + View statusDot = new View(context); + LinearLayout.LayoutParams dotLp = new LinearLayout.LayoutParams(dp(8), dp(8)); + dotLp.setMargins(0, 0, dp(8), 0); + statusDot.setLayoutParams(dotLp); + ShapeDrawable dotBg = new ShapeDrawable(); + dotBg.setShape(ShapeDrawable.CIRCLE); + dotBg.setColor(friend.isOnline() ? 0xFF55FF55 : 0xFF666666); + statusDot.setBackground(dotBg); + topRow.addView(statusDot); + + String name = friend.player_name != null ? friend.player_name : "Unknown"; + TextView nameTv = new TextView(context); + nameTv.setText(name); + setFixedTextSize(nameTv, 30); + nameTv.setTextColor(0xFFFFFFFF); + LinearLayout.LayoutParams nameLp = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f); + nameTv.setLayoutParams(nameLp); + topRow.addView(nameTv); + + TextView statusTv = new TextView(context); + statusTv.setText(friend.isOnline() ? "Online" : "Offline"); + setFixedTextSize(statusTv, 22); + statusTv.setTextColor(friend.isOnline() ? 0xFF55FF55 : 0x88FFFFFF); + topRow.addView(statusTv); + + card.addView(topRow); + + String serverDisplay = friend.getServerDisplay(); + if (serverDisplay != null) { + LinearLayout serverRow = new LinearLayout(context); + serverRow.setOrientation(LinearLayout.HORIZONTAL); + serverRow.setGravity(Gravity.CENTER_VERTICAL); + LinearLayout.LayoutParams serverRowLp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + serverRowLp.setMargins(0, dp(6), 0, 0); + serverRow.setLayoutParams(serverRowLp); + + View serverIcon = new View(context); + LinearLayout.LayoutParams iconLp = new LinearLayout.LayoutParams(dp(6), dp(20)); + iconLp.setMargins(0, 0, dp(10), 0); + serverIcon.setLayoutParams(iconLp); + ShapeDrawable iconBg = new ShapeDrawable(); + iconBg.setShape(ShapeDrawable.RECTANGLE); + iconBg.setColor(0xFF4a90d9); + iconBg.setCornerRadius(dp(3)); + serverIcon.setBackground(iconBg); + serverRow.addView(serverIcon); + + TextView serverTv = new TextView(context); + serverTv.setText("Playing on: " + serverDisplay); + setFixedTextSize(serverTv, 28); + serverTv.setTextColor(0xFF4a90d9); + LinearLayout.LayoutParams serverTvLp = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f); + serverTv.setLayoutParams(serverTvLp); + serverRow.addView(serverTv); + + TextView joinBtn = new TextView(context); + joinBtn.setText("Copy IP"); + setFixedTextSize(joinBtn, 24); + joinBtn.setTextColor(0xFFFFFFFF); + joinBtn.setPadding(dp(12), dp(6), dp(12), dp(6)); + ShapeDrawable joinBg = new ShapeDrawable(); + joinBg.setShape(ShapeDrawable.RECTANGLE); + joinBg.setColor(0xFF4a90d9); + joinBg.setCornerRadius(dp(6)); + joinBtn.setBackground(joinBg); + joinBtn.setClickable(true); + String finalServerDisplay = serverDisplay; + joinBtn.setOnClickListener(v -> { + MinecraftClient.getInstance().keyboard.setClipboard(finalServerDisplay); + CustomToast.sendCustomToast("Copied!", 0xFF4a90d9, "Server IP copied to clipboard", 0xFFBBBBBB); + }); + joinBtn.setOnHoverListener((v, event) -> { + if (event.getAction() == MotionEvent.ACTION_HOVER_ENTER) { + joinBg.setColor(0xFF6ab0f9); + } else if (event.getAction() == MotionEvent.ACTION_HOVER_EXIT) { + joinBg.setColor(0xFF4a90d9); + } + return false; + }); + serverRow.addView(joinBtn); + + card.addView(serverRow); + } + + LinearLayout bottomRow = new LinearLayout(context); + bottomRow.setOrientation(LinearLayout.HORIZONTAL); + bottomRow.setGravity(Gravity.RIGHT); + LinearLayout.LayoutParams bottomRowLp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + bottomRowLp.setMargins(0, dp(8), 0, 0); + bottomRow.setLayoutParams(bottomRowLp); + + TextView removeBtn = createSmallActionButton(context, "Remove", 0xFFd9534f); + removeBtn.setOnClickListener(v -> { + FriendManager.removeFriend(friend.getUuid()).thenAccept(error -> { + MuiModApi.postToUiThread(() -> { + if (error == null) { + refreshLists(); + CustomToast.sendCustomToast("Removed", 0xFFFF9955, name + " was removed from friends", 0xFFBBBBBB); + } else { + CustomToast.sendCustomToast("Error", 0xFFFF5555, error, 0xFFFFFFFF); + } + }); + }); + }); + bottomRow.addView(removeBtn); + + card.addView(bottomRow); + + return card; + } + + private View createRequestCard(Context context, FriendRequest request) { + LinearLayout card = new LinearLayout(context); + card.setOrientation(LinearLayout.VERTICAL); + card.setPadding(dp(12), dp(10), dp(12), dp(10)); + LinearLayout.LayoutParams cardLp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + cardLp.setMargins(0, 0, 0, dp(8)); + card.setLayoutParams(cardLp); + + ShapeDrawable cardBg = new ShapeDrawable(); + cardBg.setShape(ShapeDrawable.RECTANGLE); + cardBg.setColor(0x20FFFFFF); + cardBg.setCornerRadius(dp(10)); + card.setBackground(cardBg); + + View accentBar = new View(context); + LinearLayout.LayoutParams accentLp = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(3)); + accentLp.setMargins(0, 0, 0, dp(8)); + accentBar.setLayoutParams(accentLp); + ShapeDrawable accentBg = new ShapeDrawable(); + accentBg.setShape(ShapeDrawable.RECTANGLE); + accentBg.setColor(0xFFFFD700); + accentBg.setCornerRadius(dp(2)); + accentBar.setBackground(accentBg); + card.addView(accentBar); + + String name = request.player_name != null ? request.player_name : "Unknown"; + TextView nameTv = new TextView(context); + nameTv.setText(name); + setFixedTextSize(nameTv, 30); + nameTv.setTextColor(0xFFFFFFFF); + card.addView(nameTv); + + TextView labelTv = new TextView(context); + labelTv.setText("wants to be your friend"); + setFixedTextSize(labelTv, 22); + labelTv.setTextColor(0x88FFFFFF); + card.addView(labelTv); + + LinearLayout actionsRow = new LinearLayout(context); + actionsRow.setOrientation(LinearLayout.HORIZONTAL); + actionsRow.setGravity(Gravity.RIGHT); + LinearLayout.LayoutParams actionsLp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + actionsLp.setMargins(0, dp(10), 0, 0); + actionsRow.setLayoutParams(actionsLp); + + TextView declineBtn = createSmallActionButton(context, "Decline", 0xFFd9534f); + declineBtn.setOnClickListener(v -> { + FriendManager.removeFriend(request.getUuid()).thenAccept(error -> { + MuiModApi.postToUiThread(() -> { + if (error == null) { + refreshLists(); + } else { + CustomToast.sendCustomToast("Error", 0xFFFF5555, error, 0xFFFFFFFF); + } + }); + }); + }); + LinearLayout.LayoutParams declineLp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + declineLp.setMargins(0, 0, dp(8), 0); + declineBtn.setLayoutParams(declineLp); + actionsRow.addView(declineBtn); + + TextView acceptBtn = createSmallActionButton(context, "Accept", 0xFF5cb85c); + acceptBtn.setOnClickListener(v -> { + FriendManager.acceptRequest(request.getUuid()).thenAccept(error -> { + MuiModApi.postToUiThread(() -> { + if (error == null) { + refreshLists(); + CustomToast.sendCustomToast("Success", 0xFF55FF55, "You are now friends with " + name, 0xFFBBBBBB); + } else { + CustomToast.sendCustomToast("Error", 0xFFFF5555, error, 0xFFFFFFFF); + } + }); + }); + }); + actionsRow.addView(acceptBtn); + + card.addView(actionsRow); + + return card; + } + + private TextView createSmallActionButton(Context context, String text, int color) { + TextView btn = new TextView(context); + btn.setText(text); + setFixedTextSize(btn, 24); + btn.setTextColor(0xFFFFFFFF); + btn.setPadding(dp(14), dp(6), dp(14), dp(6)); + btn.setGravity(Gravity.CENTER); + + ShapeDrawable bg = new ShapeDrawable(); + bg.setShape(ShapeDrawable.RECTANGLE); + bg.setColor(color); + bg.setCornerRadius(dp(6)); + btn.setBackground(bg); + btn.setClickable(true); + + btn.setOnHoverListener((v, event) -> { + if (event.getAction() == MotionEvent.ACTION_HOVER_ENTER) { + bg.setColor(lightenColor(color, 0.2f)); + } else if (event.getAction() == MotionEvent.ACTION_HOVER_EXIT) { + bg.setColor(color); + } + return false; + }); + + return btn; + } + + private int lightenColor(int color, float factor) { + int a = (color >> 24) & 0xFF; + int r = Math.min(255, (int) (((color >> 16) & 0xFF) * (1 + factor))); + int g = Math.min(255, (int) (((color >> 8) & 0xFF) * (1 + factor))); + int b = Math.min(255, (int) ((color & 0xFF) * (1 + factor))); + return (a << 24) | (r << 16) | (g << 8) | b; + } + + private int dp(int value) { + return (int) (value * getContext().getResources().getDisplayMetrics().density); + } + + @Override + public boolean isPauseScreen() { + return false; + } + + @Override + public boolean shouldBlurBackground() { + return false; + } + + @Override + public boolean hasDefaultBackground() { + return false; + } +} + diff --git a/src/main/java/de/winniepat/citrus/gui/HudEditorFragment.java b/src/main/java/de/winniepat/citrus/gui/HudEditorFragment.java new file mode 100644 index 0000000..71f2c1b --- /dev/null +++ b/src/main/java/de/winniepat/citrus/gui/HudEditorFragment.java @@ -0,0 +1,801 @@ +package de.winniepat.citrus.gui; + +import de.winniepat.citrus.draggui.HudConfig; +import icyllis.modernui.animation.*; +import icyllis.modernui.core.Context; +import icyllis.modernui.fragment.Fragment; +import icyllis.modernui.graphics.drawable.*; +import icyllis.modernui.mc.*; +import icyllis.modernui.util.DataSet; +import icyllis.modernui.view.*; +import icyllis.modernui.widget.*; +import net.minecraft.client.MinecraftClient; +import org.jetbrains.annotations.*; +import de.winniepat.citrus.cosmetics.capes.CapeHandler; + +import java.util.*; + +public class HudEditorFragment extends Fragment implements ScreenCallback { + + private FrameLayout mModuleContainer; + private final Map mModuleCards = new LinkedHashMap<>(); + private final List mStaggeredViews = new ArrayList<>(); + + private ModuleCard mSelectedModule = null; + private float mDragOffsetX, mDragOffsetY; + + private float mScaleFactor = 1.0f; + private float mUiScale = HudConfig.INSTANCE.uiScale; + + @Override + public View onCreateView(@NotNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable DataSet savedInstanceState) { + Context context = requireContext(); + HudConfig.load(); + + FrameLayout root = new FrameLayout(context); + root.setLayoutParams(new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + )); + + root.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { + if (mScaleFactor == 1.0f && right > 0) { + MinecraftClient mc = MinecraftClient.getInstance(); + int mcScaledWidth = mc.getWindow().getScaledWidth(); + int muiWidth = right - left; + mScaleFactor = (float) muiWidth / (float) mcScaledWidth; + + repositionAllCards(); + updateAllCardSizes(); + } + }); + + root.setBackground(new ColorDrawable(0x88000000)); + + mModuleContainer = new FrameLayout(context); + mModuleContainer.setLayoutParams(new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + )); + root.addView(mModuleContainer); + + createModuleCard("FPS", HudConfig.INSTANCE.fpsX, HudConfig.INSTANCE.fpsY, HudConfig.INSTANCE.fpsEnabled, HudConfig.INSTANCE.fpsColor); + createModuleCard("Ping", HudConfig.INSTANCE.pingX, HudConfig.INSTANCE.pingY, HudConfig.INSTANCE.pingEnabled, HudConfig.INSTANCE.pingColor); + createModuleCard("Server", HudConfig.INSTANCE.serverX, HudConfig.INSTANCE.serverY, HudConfig.INSTANCE.serverEnabled, HudConfig.INSTANCE.serverColor); + createModuleCard("Keys", HudConfig.INSTANCE.keyX, HudConfig.INSTANCE.keyY, HudConfig.INSTANCE.keyEnabled, HudConfig.INSTANCE.keyColor); + createModuleCard("Armor", HudConfig.INSTANCE.armorX, HudConfig.INSTANCE.armorY, HudConfig.INSTANCE.armorEnabled, HudConfig.INSTANCE.armorColor); + createModuleCard("Potion", HudConfig.INSTANCE.potionX, HudConfig.INSTANCE.potionY, HudConfig.INSTANCE.potionEnabled, HudConfig.INSTANCE.potionColor); + + applyScaleToModules(mUiScale); + + LinearLayout mSidePanel = createSidePanel(context); + root.addView(mSidePanel); + + TextView title = new TextView(context); + title.setText("HUD Editor"); + title.setTextSize(24); + title.setTextColor(0xFFFFFFFF); + FrameLayout.LayoutParams titleLp = new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + titleLp.gravity = Gravity.TOP | Gravity.CENTER_HORIZONTAL; + titleLp.setMargins(0, dp(20), 0, 0); + title.setLayoutParams(titleLp); + root.addView(title); + mStaggeredViews.add(title); + + TextView instructions = new TextView(context); + instructions.setText("Drag modules to reposition • Use panel on right to toggle & customize"); + instructions.setTextSize(12); + instructions.setTextColor(0x88FFFFFF); + FrameLayout.LayoutParams instrLp = new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + instrLp.gravity = Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL; + instrLp.setMargins(0, 0, 0, dp(20)); + instructions.setLayoutParams(instrLp); + root.addView(instructions); + mStaggeredViews.add(instructions); + + animateEntrance(); + + return root; + } + + private void createModuleCard(String name, int x, int y, boolean enabled, int color) { + Context context = requireContext(); + + ModuleCard card = new ModuleCard(context, name, enabled, color); + FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + lp.leftMargin = mcToMui(x); + lp.topMargin = mcToMui(y); + card.setLayoutParams(lp); + applyModuleCardSize(name, card); + + card.setOnTouchListener((v, event) -> { + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN -> { + mSelectedModule = card; + mDragOffsetX = event.getX(); + mDragOffsetY = event.getY(); + card.setSelected(true); + animateSelect(card); + updateSidePanelForModule(card); + return true; + } + case MotionEvent.ACTION_MOVE -> { + if (mSelectedModule == card) { + FrameLayout.LayoutParams params = (FrameLayout.LayoutParams) card.getLayoutParams(); + params.leftMargin = (int) (event.getRawX() - mDragOffsetX); + params.topMargin = (int) (event.getRawY() - mDragOffsetY); + card.setLayoutParams(params); + updateConfigPosition(name, params.leftMargin, params.topMargin); + return true; + } + } + case MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + if (mSelectedModule == card) { + animateDeselect(card); + HudConfig.save(); + } + return true; + } + } + return false; + }); + + mModuleContainer.addView(card); + mModuleCards.put(name, card); + mStaggeredViews.add(card); + } + + private void updateConfigPosition(String name, int x, int y) { + int mcX = muiToMc(x); + int mcY = muiToMc(y); + switch (name) { + case "FPS" -> { HudConfig.INSTANCE.fpsX = mcX; HudConfig.INSTANCE.fpsY = mcY; } + case "Ping" -> { HudConfig.INSTANCE.pingX = mcX; HudConfig.INSTANCE.pingY = mcY; } + case "Server" -> { HudConfig.INSTANCE.serverX = mcX; HudConfig.INSTANCE.serverY = mcY; } + case "Keys" -> { HudConfig.INSTANCE.keyX = mcX; HudConfig.INSTANCE.keyY = mcY; } + case "Armor" -> { HudConfig.INSTANCE.armorX = mcX; HudConfig.INSTANCE.armorY = mcY; } + case "Potion" -> { HudConfig.INSTANCE.potionX = mcX; HudConfig.INSTANCE.potionY = mcY; } + } + } + + private LinearLayout createSidePanel(Context context) { + LinearLayout panel = new LinearLayout(context); + panel.setOrientation(LinearLayout.VERTICAL); + panel.setPadding(dp(16), dp(16), dp(16), dp(16)); + + FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(dp(280), ViewGroup.LayoutParams.MATCH_PARENT); + lp.gravity = Gravity.RIGHT; + panel.setLayoutParams(lp); + + ShapeDrawable bg = new ShapeDrawable(); + bg.setShape(ShapeDrawable.RECTANGLE); + bg.setColor(0xE6181818); + bg.setCornerRadii(dp(16), 0, 0, dp(16)); + panel.setBackground(bg); + + TextView panelTitle = new TextView(context); + panelTitle.setText("Module Settings"); + panelTitle.setTextSize(18); + panelTitle.setTextColor(0xFFFFFFFF); + panelTitle.setPadding(0, 0, 0, dp(16)); + panel.addView(panelTitle); + + ScrollView scroll = new ScrollView(context); + scroll.setLayoutParams(new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + )); + + LinearLayout content = new LinearLayout(context); + content.setOrientation(LinearLayout.VERTICAL); + content.setId(View.generateViewId()); + + for (String moduleName : new String[]{"FPS", "Ping", "Server", "Keys", "Armor", "Potion"}) { + content.addView(createModuleSettingItem(context, moduleName)); + } + + View separator = new View(context); + separator.setLayoutParams(new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(1))); + separator.setBackground(new ColorDrawable(0x33FFFFFF)); + LinearLayout.LayoutParams sepLp = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(1)); + sepLp.setMargins(0, dp(16), 0, dp(16)); + separator.setLayoutParams(sepLp); + content.addView(separator); + + content.addView(createSliderSetting(context, "UI Scale", 50, 150, (int) (HudConfig.INSTANCE.uiScale * 100), value -> { + float scale = value / 100f; + applyScaleToModules(scale); + HudConfig.save(); + })); + + Button resetBtn = new Button(context); + resetBtn.setText("Reset Positions"); + LinearLayout.LayoutParams resetLp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + dp(44) + ); + + LinearLayout list = new LinearLayout(context); + list.setOrientation(LinearLayout.VERTICAL); + + for (String cape : CapeHandler.getAvailableCapes()) { + + Button btn = new Button(context); + btn.setText((cape)); + + btn.setOnClickListener(v -> { + CapeHandler.setLocalPlayerCape(cape, false); + }); + + LinearLayout.LayoutParams blp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + dp(44) + ); + blp.setMargins(0, dp(6), 0, 0); + btn.setLayoutParams(blp); + + list.addView(btn); + } + + resetLp.setMargins(0, dp(16), 0, 0); + resetBtn.setLayoutParams(resetLp); + resetBtn.setOnClickListener(v -> resetPositions()); + content.addView(resetBtn); + + scroll.addView(content); + panel.addView(scroll); + + mStaggeredViews.add(panel); + return panel; + } + + private LinearLayout createModuleSettingItem(Context context, String moduleName) { + LinearLayout item = new LinearLayout(context); + item.setOrientation(LinearLayout.VERTICAL); + item.setPadding(dp(12), dp(12), dp(12), dp(12)); + LinearLayout.LayoutParams itemLp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + itemLp.setMargins(0, 0, 0, dp(8)); + item.setLayoutParams(itemLp); + + ShapeDrawable itemBg = new ShapeDrawable(); + itemBg.setShape(ShapeDrawable.RECTANGLE); + itemBg.setColor(0x1AFFFFFF); + itemBg.setCornerRadius(dp(8)); + item.setBackground(itemBg); + + LinearLayout header = new LinearLayout(context); + header.setOrientation(LinearLayout.HORIZONTAL); + header.setGravity(Gravity.CENTER_VERTICAL); + + TextView nameTv = new TextView(context); + nameTv.setText(moduleName); + nameTv.setTextSize(14); + nameTv.setTextColor(0xFFFFFFFF); + nameTv.setLayoutParams(new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); + header.addView(nameTv); + + CheckBox toggle = new CheckBox(context); + toggle.setChecked(HudConfig.INSTANCE.isModuleEnabled(moduleName)); + toggle.setOnCheckedChangeListener((buttonView, isChecked) -> { + setModuleEnabled(moduleName, isChecked); + ModuleCard card = mModuleCards.get(moduleName); + if (card != null) { + card.setModuleEnabled(isChecked); + animateVisibility(card, isChecked); + } + }); + header.addView(toggle); + + item.addView(header); + + TextView colorLabel = new TextView(context); + colorLabel.setText("Color"); + colorLabel.setTextSize(11); + colorLabel.setTextColor(0x88FFFFFF); + colorLabel.setPadding(0, dp(8), 0, dp(4)); + item.addView(colorLabel); + + final int initialColor = getModuleColor(moduleName) | 0xFF000000; + + LinearLayout colorRow = new LinearLayout(context); + colorRow.setOrientation(LinearLayout.HORIZONTAL); + colorRow.setGravity(Gravity.CENTER_VERTICAL); + + SeekBar colorSlider = new SeekBar(context); + colorSlider.setMax(360); + colorSlider.setProgress(getHueFromColor(initialColor)); + colorSlider.setLayoutParams(new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); + + Button minus = new Button(context); + minus.setText("-"); + minus.setOnClickListener(v -> { + int step = Math.max(0, colorSlider.getProgress() - 5); + colorSlider.setProgress(step); + }); + + + Button plus = new Button(context); + plus.setText("+"); + plus.setOnClickListener(v -> { + int step = Math.min(360, colorSlider.getProgress() + 5); + colorSlider.setProgress(step); + }); + + View colorPreview = new View(context); + LinearLayout.LayoutParams previewLp = new LinearLayout.LayoutParams(dp(28), dp(20)); + previewLp.setMargins(dp(8), 0, 0, 0); + colorPreview.setLayoutParams(previewLp); + colorPreview.setBackground(new ColorDrawable(initialColor)); + + colorRow.addView(minus); + colorRow.addView(colorSlider); + colorRow.addView(plus); + colorRow.addView(colorPreview); + item.addView(colorRow); + + LinearLayout hexRow = new LinearLayout(context); + hexRow.setOrientation(LinearLayout.HORIZONTAL); + hexRow.setGravity(Gravity.CENTER_VERTICAL); + hexRow.setPadding(0, dp(6), 0, 0); + + EditText hexInput = new EditText(context); + hexInput.setSingleLine(true); + hexInput.setText(formatColor(initialColor)); + LinearLayout.LayoutParams hexLp = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f); + hexLp.setMargins(0, 0, dp(8), 0); + hexInput.setLayoutParams(hexLp); + + Button resetColor = new Button(context); + resetColor.setText("White"); + resetColor.setOnClickListener(v -> { + int white = 0xFFFFFFFF; + setModuleColor(moduleName, white); + colorSlider.setProgress(getHueFromColor(white)); + ModuleCard card = mModuleCards.get(moduleName); + if (card != null) { + card.updateColor(white); + } + colorPreview.setBackground(new ColorDrawable(white)); + hexInput.setText(formatColor(white)); + HudConfig.save(); + }); + + hexRow.addView(hexInput); + hexRow.addView(resetColor); + item.addView(hexRow); + + colorSlider.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + int color = java.awt.Color.HSBtoRGB(progress / 360f, 0.8f, 1f) | 0xFF000000; + setModuleColor(moduleName, color); + ModuleCard card = mModuleCards.get(moduleName); + if (card != null) { + card.updateColor(color); + } + colorPreview.setBackground(new ColorDrawable(color)); + hexInput.setText(formatColor(color)); + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + HudConfig.save(); + } + }); + + return item; + } + + private LinearLayout createSliderSetting(Context context, String label, int min, int max, int defaultVal, SliderCallback callback) { + LinearLayout container = new LinearLayout(context); + container.setOrientation(LinearLayout.VERTICAL); + container.setPadding(0, dp(8), 0, dp(8)); + + TextView labelTv = new TextView(context); + labelTv.setText(label); + labelTv.setTextSize(14); + labelTv.setTextColor(0xFFFFFFFF); + container.addView(labelTv); + + LinearLayout sliderRow = new LinearLayout(context); + sliderRow.setOrientation(LinearLayout.HORIZONTAL); + sliderRow.setGravity(Gravity.CENTER_VERTICAL); + + SeekBar slider = new SeekBar(context); + slider.setMax(max - min); + slider.setProgress(defaultVal - min); + slider.setLayoutParams(new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); + + TextView valueTv = new TextView(context); + valueTv.setText(defaultVal + "%"); + valueTv.setTextSize(12); + valueTv.setTextColor(0xAAFFFFFF); + valueTv.setMinWidth(dp(40)); + valueTv.setGravity(Gravity.RIGHT); + + slider.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + int value = progress + min; + valueTv.setText(value + "%"); + callback.onValueChanged(value); + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + HudConfig.save(); + } + }); + + sliderRow.addView(slider); + sliderRow.addView(valueTv); + container.addView(sliderRow); + + return container; + } + + private void resetPositions() { + HudConfig.INSTANCE.fpsX = 10; HudConfig.INSTANCE.fpsY = 10; + HudConfig.INSTANCE.pingX = 10; HudConfig.INSTANCE.pingY = 25; + HudConfig.INSTANCE.serverX = 10; HudConfig.INSTANCE.serverY = 40; + HudConfig.INSTANCE.keyX = 10; HudConfig.INSTANCE.keyY = 60; + HudConfig.INSTANCE.armorX = 10; HudConfig.INSTANCE.armorY = 120; + HudConfig.INSTANCE.potionX = 10; HudConfig.INSTANCE.potionY = 180; + + for (Map.Entry entry : mModuleCards.entrySet()) { + ModuleCard card = entry.getValue(); + int targetX = 10, targetY = 10; + switch (entry.getKey()) { + case "Ping" -> targetY = 25; + case "Server" -> targetY = 40; + case "Keys" -> targetY = 60; + case "Armor" -> targetY = 120; + case "Potion" -> targetY = 180; + } + + final int finalTargetX = mcToMui(targetX); + final int finalTargetY = mcToMui(targetY); + + FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) card.getLayoutParams(); + final int startX = lp.leftMargin; + final int startY = lp.topMargin; + + ValueAnimator animator = ValueAnimator.ofFloat(0f, 1f); + animator.setDuration(300); + animator.setInterpolator(MotionEasingUtils.MOTION_EASING_EMPHASIZED); + animator.addUpdateListener(animation -> { + float fraction = (float) animation.getAnimatedValue(); + FrameLayout.LayoutParams params = (FrameLayout.LayoutParams) card.getLayoutParams(); + params.leftMargin = (int) (startX + (finalTargetX - startX) * fraction); + params.topMargin = (int) (startY + (finalTargetY - startY) * fraction); + card.setLayoutParams(params); + }); + animator.start(); + } + HudConfig.save(); + } + + private void animateSelect(View v) { + ObjectAnimator scaleX = ObjectAnimator.ofFloat(v, View.SCALE_X, 1f, 1.05f); + ObjectAnimator scaleY = ObjectAnimator.ofFloat(v, View.SCALE_Y, 1f, 1.05f); + AnimatorSet set = new AnimatorSet(); + set.playTogether(scaleX, scaleY); + set.setDuration(150); + set.setInterpolator(MotionEasingUtils.MOTION_EASING_EMPHASIZED); + set.start(); + } + + private void animateDeselect(View v) { + ObjectAnimator scaleX = ObjectAnimator.ofFloat(v, View.SCALE_X, 1.05f, 1f); + ObjectAnimator scaleY = ObjectAnimator.ofFloat(v, View.SCALE_Y, 1.05f, 1f); + AnimatorSet set = new AnimatorSet(); + set.playTogether(scaleX, scaleY); + set.setDuration(150); + set.setInterpolator(MotionEasingUtils.MOTION_EASING_EMPHASIZED); + set.start(); + } + + private void animateVisibility(View v, boolean visible) { + float targetAlpha = visible ? 1f : 0.3f; + ObjectAnimator alpha = ObjectAnimator.ofFloat(v, View.ALPHA, v.getAlpha(), targetAlpha); + alpha.setDuration(200); + alpha.setInterpolator(MotionEasingUtils.LINEAR_OUT_SLOW_IN); + alpha.start(); + } + + private void animateEntrance() { + long delay = 0; + for (View v : mStaggeredViews) { + v.setAlpha(0); + v.setTranslationY(dp(30)); + + ObjectAnimator alpha = ObjectAnimator.ofFloat(v, View.ALPHA, 0f, 1f); + ObjectAnimator trans = ObjectAnimator.ofFloat(v, View.TRANSLATION_Y, dp(30), 0f); + + AnimatorSet set = new AnimatorSet(); + set.playTogether(alpha, trans); + set.setDuration(400); + set.setStartDelay(delay); + set.setInterpolator(MotionEasingUtils.MOTION_EASING_EMPHASIZED_DECELERATE); + set.start(); + + delay += 50; + } + } + + private void updateSidePanelForModule(ModuleCard card) { + + } + + private void setModuleEnabled(String name, boolean enabled) { + switch (name) { + case "FPS" -> HudConfig.INSTANCE.fpsEnabled = enabled; + case "Ping" -> HudConfig.INSTANCE.pingEnabled = enabled; + case "Server" -> HudConfig.INSTANCE.serverEnabled = enabled; + case "Keys" -> HudConfig.INSTANCE.keyEnabled = enabled; + case "Armor" -> HudConfig.INSTANCE.armorEnabled = enabled; + case "Potion" -> HudConfig.INSTANCE.potionEnabled = enabled; + } + HudConfig.save(); + } + + private void setModuleColor(String name, int color) { + switch (name) { + case "FPS" -> HudConfig.INSTANCE.fpsColor = color; + case "Ping" -> HudConfig.INSTANCE.pingColor = color; + case "Server" -> HudConfig.INSTANCE.serverColor = color; + case "Keys" -> HudConfig.INSTANCE.keyColor = color; + case "Armor" -> HudConfig.INSTANCE.armorColor = color; + case "Potion" -> HudConfig.INSTANCE.potionColor = color; + } + } + + private int getModuleColor(String name) { + return switch (name) { + case "FPS" -> HudConfig.INSTANCE.fpsColor; + case "Ping" -> HudConfig.INSTANCE.pingColor; + case "Server" -> HudConfig.INSTANCE.serverColor; + case "Keys" -> HudConfig.INSTANCE.keyColor; + case "Armor" -> HudConfig.INSTANCE.armorColor; + case "Potion" -> HudConfig.INSTANCE.potionColor; + default -> 0xFFFFFFFF; + }; + } + + private int getHueFromColor(int color) { + float[] hsb = java.awt.Color.RGBtoHSB((color >> 16) & 0xFF, (color >> 8) & 0xFF, color & 0xFF, null); + return (int) (hsb[0] * 360); + } + + private void repositionAllCards() { + for (Map.Entry entry : mModuleCards.entrySet()) { + ModuleCard card = entry.getValue(); + int mcX = 0, mcY = 0; + switch (entry.getKey()) { + case "FPS" -> { mcX = HudConfig.INSTANCE.fpsX; mcY = HudConfig.INSTANCE.fpsY; } + case "Ping" -> { mcX = HudConfig.INSTANCE.pingX; mcY = HudConfig.INSTANCE.pingY; } + case "Server" -> { mcX = HudConfig.INSTANCE.serverX; mcY = HudConfig.INSTANCE.serverY; } + case "Keys" -> { mcX = HudConfig.INSTANCE.keyX; mcY = HudConfig.INSTANCE.keyY; } + case "Armor" -> { mcX = HudConfig.INSTANCE.armorX; mcY = HudConfig.INSTANCE.armorY; } + case "Potion" -> { mcX = HudConfig.INSTANCE.potionX; mcY = HudConfig.INSTANCE.potionY; } + } + FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) card.getLayoutParams(); + lp.leftMargin = mcToMui(mcX); + lp.topMargin = mcToMui(mcY); + card.setLayoutParams(lp); + } + } + + private void updateAllCardSizes() { + for (Map.Entry entry : mModuleCards.entrySet()) { + applyModuleCardSize(entry.getKey(), entry.getValue()); + } + } + + private void applyModuleCardSize(String name, ModuleCard card) { + MinecraftClient mc = MinecraftClient.getInstance(); + int width = mcToMui(80); + int height = mcToMui(mc.textRenderer.fontHeight + 4); + + switch (name) { + case "Keys" -> { + width = mcToMui(64); + height = mcToMui(86); + } + case "Armor" -> { + int armorCount = 0; + if (mc.player != null) { + for (var stack : mc.player.getArmorItems()) { + if (!stack.isEmpty()) armorCount++; + } + } + int rows = Math.max(1, armorCount); + width = mcToMui(50); + height = mcToMui(18 * rows); + } + case "Potion" -> { + int lines = Math.max(1, mc.player != null ? mc.player.getStatusEffects().size() : 1); + width = mcToMui(100); + height = mcToMui(12 * lines); + } + default -> { + String text = card.getCurrentDisplayText(); + width = mcToMui(mc.textRenderer.getWidth(text) + 6); + height = mcToMui(mc.textRenderer.fontHeight + 4); + } + } + + FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) card.getLayoutParams(); + if (lp != null) { + lp.width = Math.max(width, dp(40)); + lp.height = Math.max(height, dp(20)); + card.setLayoutParams(lp); + } + } + + private int mcToMui(int mcCoord) { + return (int) (mcCoord * mScaleFactor * mUiScale); + } + + private int muiToMc(int muiCoord) { + return (int) (muiCoord / (mScaleFactor * mUiScale)); + } + + private int dp(int value) { + return (int) (value * getContext().getResources().getDisplayMetrics().density); + } + + @Override + public void onPause() { + super.onPause(); + HudConfig.save(); + } + + @Override + public boolean isPauseScreen() { + return false; + } + + @Override + public boolean shouldBlurBackground() { + return false; + } + + @Override + public boolean hasDefaultBackground() { + return false; + } + + private interface SliderCallback { + void onValueChanged(int value); + } + + private void applyScaleToModules(float scale) { + mUiScale = scale; + HudConfig.INSTANCE.uiScale = scale; + repositionAllCards(); + updateAllCardSizes(); + } + + private String formatColor(int color) { + return String.format(Locale.ROOT, "#%06X", color & 0xFFFFFF); + } + + private int parseColorInput(String input, int fallback) { + if (input == null) return fallback; + String cleaned = input.trim(); + if (cleaned.startsWith("#")) cleaned = cleaned.substring(1); + if (cleaned.length() == 6) { + try { + return (int) (0xFF000000L | Long.parseLong(cleaned, 16)); + } catch (NumberFormatException ignored) { + } + } + return fallback; + } + + private static class ModuleCard extends FrameLayout { + private final String mName; + private final TextView mLabel; + private final ShapeDrawable mBackground; + private int mColor; + private boolean mModuleEnabled; + + public ModuleCard(Context context, String name, boolean enabled, int color) { + super(context); + mName = name; + mColor = color; + mModuleEnabled = enabled; + + setPadding(dp(12), dp(8), dp(12), dp(8)); + setMinimumWidth(dp(80)); + + mBackground = new ShapeDrawable(); + mBackground.setShape(ShapeDrawable.RECTANGLE); + mBackground.setCornerRadius(dp(8)); + updateBackgroundColor(false); + setBackground(mBackground); + + mLabel = new TextView(context); + mLabel.setText(getDisplayText()); + mLabel.setTextSize(14); + mLabel.setTextColor(color); + mLabel.setGravity(Gravity.CENTER); + addView(mLabel); + + setAlpha(enabled ? 1f : 0.3f); + + setOnHoverListener((v, event) -> { + if (event.getAction() == MotionEvent.ACTION_HOVER_ENTER) { + updateBackgroundColor(true); + } else if (event.getAction() == MotionEvent.ACTION_HOVER_EXIT) { + if (!isSelected()) { + updateBackgroundColor(false); + } + } + return false; + }); + } + + public String getCurrentDisplayText() { + return getDisplayText(); + } + + private String getDisplayText() { + MinecraftClient client = MinecraftClient.getInstance(); + return switch (mName) { + case "FPS" -> client.getCurrentFps() + " FPS"; + case "Ping" -> { + int ping = 0; + if (client.getNetworkHandler() != null && client.player != null) { + var entry = client.getNetworkHandler().getPlayerListEntry(client.player.getUuid()); + if (entry != null) ping = entry.getLatency(); + } + yield ping + "ms"; + } + case "Server" -> client.getCurrentServerEntry() != null ? + client.getCurrentServerEntry().address : "Singleplayer"; + case "Keys" -> "[WASD]"; + case "Armor" -> "[Armor]"; + case "Potion" -> "[Potions]"; + default -> mName; + }; + } + + public void updateColor(int color) { + mColor = color; + mLabel.setTextColor(color); + } + + public void setModuleEnabled(boolean enabled) { + mModuleEnabled = enabled; + } + + private void updateBackgroundColor(boolean hovered) { + if (hovered || isSelected()) { + mBackground.setColor(0x44FFFFFF); + } else { + mBackground.setColor(0x22FFFFFF); + } + invalidate(); + } + + @Override + public void setSelected(boolean selected) { + super.setSelected(selected); + updateBackgroundColor(selected); + } + } +} diff --git a/src/main/java/de/winniepat/citrus/gui/MainMenuScreen.java b/src/main/java/de/winniepat/citrus/gui/MainMenuScreen.java new file mode 100644 index 0000000..45a6303 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/gui/MainMenuScreen.java @@ -0,0 +1,759 @@ +package de.winniepat.citrus.gui; + +import de.winniepat.citrus.Client; +import de.winniepat.citrus.managers.WallpaperManager; +import de.winniepat.citrus.utils.texture.CdnTextureDrawable; +import de.winniepat.citrus.managers.ConfigManager; +import de.winniepat.citrus.managers.ClientRegistrationManager; +import de.winniepat.citrus.managers.RankManager; +import icyllis.modernui.animation.*; +import icyllis.modernui.core.Context; +import icyllis.modernui.fragment.Fragment; +import icyllis.modernui.graphics.drawable.*; +import icyllis.modernui.markflow.Markflow; +import icyllis.modernui.mc.*; +import icyllis.modernui.text.TextPaint; +import icyllis.modernui.util.DataSet; +import icyllis.modernui.view.*; +import icyllis.modernui.widget.*; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.screen.multiplayer.MultiplayerScreen; +import net.minecraft.client.gui.screen.option.OptionsScreen; +import net.minecraft.client.gui.screen.world.SelectWorldScreen; +import net.minecraft.util.Util; +import org.jetbrains.annotations.*; + +import com.google.gson.*; +import java.net.URI; +import java.net.http.*; +import java.util.*; +import java.util.concurrent.CompletableFuture; + +public class MainMenuScreen extends Fragment implements ScreenCallback { + + private FrameLayout mBackgroundView; + private View mBackgroundView1; + private View mBackgroundView2; + private CdnTextureDrawable mBackground1Drawable; + private CdnTextureDrawable mBackground2Drawable; + private boolean mIsUsingView1 = true; + private TextView mOnlineCountTv; + private View mRankIconView; + private LinearLayout mNewsContainer; + private LinearLayout mWpOptionsContainer; + private TextView mCurrentWpTv; + private final List mStaggeredViews = new ArrayList<>(); + private final String[] mSplashes = { + "100% freshly squeezed!", + "Optimized to the core.", + "Latency? Never heard of it.", + "Taste the difference!", + "Still fresh.", + "Fresh since 1.0!", + "Powered by WinniePat." + }; + + private float getGuiScale() { + return (float) MinecraftClient.getInstance().getWindow().getScaleFactor(); + } + + private void setFixedTextSize(TextView tv, float px) { + float scale = getGuiScale(); + tv.setTextSize(px / scale); + } + + @Override + public View onCreateView(@NotNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable DataSet savedInstanceState) { + Context context = requireContext(); + + FrameLayout root = new FrameLayout(context); + root.setLayoutParams(new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + )); + + mBackgroundView = new FrameLayout(context); + mBackgroundView.setLayoutParams(new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + )); + mBackgroundView.setScaleX(1.1f); + mBackgroundView.setScaleY(1.1f); + + mBackgroundView1 = new View(context); + mBackgroundView1.setLayoutParams(new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + )); + mBackgroundView.addView(mBackgroundView1); + + mBackgroundView2 = new View(context); + mBackgroundView2.setLayoutParams(new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + )); + mBackgroundView2.setAlpha(0f); + mBackgroundView.addView(mBackgroundView2); + + root.addView(mBackgroundView); + + WallpaperManager.init(); + + mBackground1Drawable = new CdnTextureDrawable(); + mBackground2Drawable = new CdnTextureDrawable(); + mBackgroundView1.setBackground(mBackground1Drawable); + mBackgroundView2.setBackground(mBackground2Drawable); + + String currentWallpaper = ConfigManager.config.mainmenuWallpaper; + if (currentWallpaper == null || currentWallpaper.isEmpty()) { + currentWallpaper = "default.png"; + ConfigManager.config.mainmenuWallpaper = currentWallpaper; + } + + loadInitialWallpaper(currentWallpaper); + + ObjectAnimator bgPulseX = ObjectAnimator.ofFloat(mBackgroundView, View.SCALE_X, 1.1f, 1.15f); + ObjectAnimator bgPulseY = ObjectAnimator.ofFloat(mBackgroundView, View.SCALE_Y, 1.1f, 1.15f); + bgPulseX.setDuration(15000); + bgPulseY.setDuration(15000); + bgPulseX.setRepeatCount(ValueAnimator.INFINITE); + bgPulseY.setRepeatCount(ValueAnimator.INFINITE); + bgPulseX.setRepeatMode(ValueAnimator.REVERSE); + bgPulseY.setRepeatMode(ValueAnimator.REVERSE); + bgPulseX.start(); + bgPulseY.start(); + + startParallaxLoop(); + + FrameLayout contentWrapper = new FrameLayout(context); + contentWrapper.setLayoutParams(new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + )); + root.addView(contentWrapper); + + View logoView = new View(context); + FrameLayout.LayoutParams logoLp = new FrameLayout.LayoutParams(150, 150); + logoLp.gravity = Gravity.TOP | Gravity.CENTER_HORIZONTAL; + logoLp.setMargins(0, dp(30), 0, 0); + logoView.setLayoutParams(logoLp); + ImageDrawable logoDrawable = new ImageDrawable("citrus", "startscreen/logo.png"); + logoDrawable.setGravity(Gravity.FILL); + logoView.setBackground(logoDrawable); + contentWrapper.addView(logoView); + mStaggeredViews.add(logoView); + + LinearLayout titleLayout = new LinearLayout(context); + titleLayout.setOrientation(LinearLayout.VERTICAL); + FrameLayout.LayoutParams titleLp = new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + titleLp.setMargins(60,50, 0, 0); + titleLayout.setLayoutParams(titleLp); + + TextView title = new TextView(context); + title.setText("CITRUS"); + setFixedTextSize(title,186); + title.getPaint().setTextStyle(TextPaint.BOLD); + title.setTextColor(0xFFFFFFFF); + titleLayout.addView(title); + + TextView subtitle = new TextView(context); + subtitle.setText("made by WinniePatGG, FullRiskk and AAirCrafter"); + setFixedTextSize(subtitle,36); + subtitle.setTextColor(0xCCFFFFFF); + titleLayout.addView(subtitle); + + TextView splash = new TextView(context); + splash.setText(mSplashes[(int) (Math.random() * mSplashes.length)]); + setFixedTextSize(splash,30); + splash.setTextColor(0xFFFFFF00); + splash.getPaint().setTextStyle(TextPaint.ITALIC); + titleLayout.addView(splash); + + contentWrapper.addView(titleLayout); + mStaggeredViews.add(titleLayout); + + LinearLayout navLayout = new LinearLayout(context); + navLayout.setOrientation(LinearLayout.VERTICAL); + FrameLayout.LayoutParams navLp = new FrameLayout.LayoutParams( + 280, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + navLp.gravity = Gravity.CENTER_VERTICAL; + navLp.setMargins(60, 0, 0, 0); + navLayout.setLayoutParams(navLp); + + addNavButton(navLayout, "Singleplayer", "singleplayer_icon", v -> { + MinecraftClient.getInstance().execute(() -> { + MinecraftClient.getInstance().setScreen(new SelectWorldScreen(MinecraftClient.getInstance().currentScreen)); + }); + }); + addNavButton(navLayout, "Multiplayer", "multiplayer_icon", v -> { + MinecraftClient.getInstance().execute(() -> { + MinecraftClient.getInstance().setScreen(new MultiplayerScreen(MinecraftClient.getInstance().currentScreen)); + }); + }); + addNavButton(navLayout, "Friends", "multiplayer_icon", v -> { + MinecraftClient.getInstance().execute(() -> { + MinecraftClient.getInstance().setScreen(MuiModApi.get().createScreen(new FriendsFragment(), null, MinecraftClient.getInstance().currentScreen)); + }); + }); + addNavButton(navLayout, "Profiles", "multiplayer_icon", v -> { + MinecraftClient.getInstance().execute(() -> { + MinecraftClient.getInstance().setScreen(MuiModApi.get().createScreen(new ProfileScreen(), null, MinecraftClient.getInstance().currentScreen)); + }); + }); + addNavButton(navLayout, "Settings", "settings_icon", v -> { + MinecraftClient.getInstance().execute(() -> { + MinecraftClient.getInstance().setScreen(new OptionsScreen(MinecraftClient.getInstance().currentScreen, MinecraftClient.getInstance().options)); + }); + }); + addNavButton(navLayout, "test", "settings_icon", v -> { + MinecraftClient.getInstance().execute(() -> { + MinecraftClient.getInstance().setScreen(MuiModApi.get().createScreen(new TestScreen(), null, MinecraftClient.getInstance().currentScreen)); + }); + }); + addNavButton(navLayout, "Discord", "discord_icon", v -> { + Util.getOperatingSystem().open("https://dc.citrus-client.de"); + }); + addNavButton(navLayout, "Quit", "door_icon", v -> { + MinecraftClient.getInstance().scheduleStop(); + }); + + contentWrapper.addView(navLayout); + + mNewsContainer = new LinearLayout(context); + mNewsContainer.setOrientation(LinearLayout.VERTICAL); + mNewsContainer.setPadding(20, 20, 20, 20); + + LinearLayout newsSection = new LinearLayout(context); + newsSection.setOrientation(LinearLayout.VERTICAL); + FrameLayout.LayoutParams newsSectionLp = new FrameLayout.LayoutParams( + 360, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + newsSectionLp.gravity = Gravity.CENTER_VERTICAL | Gravity.RIGHT; + newsSectionLp.setMargins(0, 0, 50, 0); + newsSection.setLayoutParams(newsSectionLp); + + TextView newsLabel = new TextView(context); + newsLabel.setText("NEWS"); + newsLabel.setTextSize(36); + setFixedTextSize(newsLabel,72); + newsLabel.getPaint().setTextStyle(TextPaint.BOLD); + newsLabel.setTextColor(0xFFFFFFFF); + newsLabel.setGravity(Gravity.CENTER); + LinearLayout.LayoutParams newsLabelLp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + newsLabelLp.setMargins(0, 0, 0, 12); + newsLabel.setLayoutParams(newsLabelLp); + newsSection.addView(newsLabel); + + ScrollView newsScroll = new ScrollView(context); + LinearLayout.LayoutParams newsScrollLp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + 400 + ); + newsScroll.setScrollBarSize(10); + newsScroll.setLayoutParams(newsScrollLp); + + ShapeDrawable newsBg = new ShapeDrawable(); + newsBg.setShape(ShapeDrawable.RECTANGLE); + newsBg.setColor(0x33000000); + newsBg.setCornerRadius(16); + newsScroll.setBackground(newsBg); + + newsScroll.addView(mNewsContainer); + newsSection.addView(newsScroll); + + contentWrapper.addView(newsSection); + mStaggeredViews.add(newsSection); + + mOnlineCountTv = new TextView(context); + mOnlineCountTv.setText("Online Users: -"); + setFixedTextSize(mOnlineCountTv,30); + mOnlineCountTv.setTextColor(0x88FFFFFF); + FrameLayout.LayoutParams onlineLp = new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + onlineLp.gravity = Gravity.BOTTOM | Gravity.RIGHT; + onlineLp.setMargins(0, 0, 60, 20); + mOnlineCountTv.setLayoutParams(onlineLp); + contentWrapper.addView(mOnlineCountTv); + mStaggeredViews.add(mOnlineCountTv); + + LinearLayout profileLayout = new LinearLayout(context); + profileLayout.setOrientation(LinearLayout.HORIZONTAL); + profileLayout.setGravity(Gravity.CENTER_VERTICAL); + FrameLayout.LayoutParams profileLp = new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + profileLp.gravity = Gravity.TOP | Gravity.RIGHT; + profileLp.setMargins(0, 40, 60, 0); + profileLayout.setLayoutParams(profileLp); + + mRankIconView = new View(context); + LinearLayout.LayoutParams rankIconLp = new LinearLayout.LayoutParams(20, 20); + rankIconLp.setMargins(0, 0, 4, 0); + mRankIconView.setLayoutParams(rankIconLp); + UUID currentUuid = MinecraftClient.getInstance().getSession().getUuidOrNull(); + if (currentUuid != null) { + String rank = RankManager.getRank(currentUuid); + if (rank.equals("none") || rank.equals("loading")) { + mRankIconView.setVisibility(View.GONE); + } else { + mRankIconView.setVisibility(View.VISIBLE); + ImageDrawable rankIcon = new ImageDrawable("citrus", "ranks/" + rank + ".png"); + rankIcon.setGravity(Gravity.FILL); + mRankIconView.setBackground(rankIcon); + } + } + profileLayout.addView(mRankIconView); + + TextView playerName = new TextView(context); + playerName.setText(MinecraftClient.getInstance().getSession().getUsername()); + playerName.setTextSize(16); + setFixedTextSize(playerName,42); + playerName.setTextColor(0xFFFFFFFF); + playerName.setPadding(0, 0, 12, 0); + profileLayout.addView(playerName); + + contentWrapper.addView(profileLayout); + mStaggeredViews.add(profileLayout); + + LinearLayout wallpaperLayout = new LinearLayout(context); + wallpaperLayout.setOrientation(LinearLayout.VERTICAL); + FrameLayout.LayoutParams wallpaperLp = new FrameLayout.LayoutParams( + 160, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + wallpaperLp.gravity = Gravity.BOTTOM | Gravity.LEFT; + wallpaperLp.setMargins(60, 0, 0, 40); + wallpaperLayout.setLayoutParams(wallpaperLp); + + TextView wallpaperLabel = new TextView(context); + wallpaperLabel.setText("Select Wallpaper"); + setFixedTextSize(wallpaperLabel,28); + wallpaperLabel.setTextColor(0x88FFFFFF); + wallpaperLabel.setPadding(0, 0, 0, 4); + wallpaperLayout.addView(wallpaperLabel); + + mCurrentWpTv = new TextView(context); + String displayWallpaper = ConfigManager.config.mainmenuWallpaper; + if (displayWallpaper == null || displayWallpaper.isEmpty()) { + displayWallpaper = "default.png"; + } + mCurrentWpTv.setText(displayWallpaper); + setFixedTextSize(mCurrentWpTv,36); + mCurrentWpTv.setTextColor(0xFFFFFFFF); + mCurrentWpTv.setPadding(12, 8, 12, 9); + + ShapeDrawable wpBg = new ShapeDrawable(); + wpBg.setShape(ShapeDrawable.RECTANGLE); + wpBg.setColor(0x26FFFFFF); + wpBg.setCornerRadius(dp(8)); + mCurrentWpTv.setBackground(wpBg); + mCurrentWpTv.setClickable(true); + + wallpaperLayout.addView(mCurrentWpTv); + + ScrollView wpScroll = new ScrollView(context); + LinearLayout.LayoutParams wpScrollLp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + 120 + ); + wpScrollLp.setMargins(0, 4, 0, 0); + wpScroll.setLayoutParams(wpScrollLp); + wpScroll.setVisibility(View.GONE); + + mWpOptionsContainer = new LinearLayout(context); + mWpOptionsContainer.setOrientation(LinearLayout.VERTICAL); + mWpOptionsContainer.setPadding(4, 4, 4, 4); + ShapeDrawable optionsBg = new ShapeDrawable(); + optionsBg.setShape(ShapeDrawable.RECTANGLE); + optionsBg.setColor(0xCC000000); + optionsBg.setCornerRadius(8); + mWpOptionsContainer.setBackground(optionsBg); + + mCurrentWpTv.setOnClickListener(v -> { + wpScroll.setVisibility(wpScroll.getVisibility() == View.VISIBLE ? View.GONE : View.VISIBLE); + }); + + populateWallpaperOptions(wpScroll); + + wpScroll.addView(mWpOptionsContainer); + wallpaperLayout.addView(wpScroll); + contentWrapper.addView(wallpaperLayout); + mStaggeredViews.add(wallpaperLayout); + + animateEntrance(); + + fetchNews(); + updatePlayerInfo(); + + return root; + } + + private void updatePlayerInfo() { + CompletableFuture.runAsync(() -> { + try { + int count = ClientRegistrationManager.getOnlineCitrusUsers().size(); + MuiModApi.postToUiThread(() -> { + if (mOnlineCountTv != null) { + mOnlineCountTv.setText("Online Users: " + count); + } + }); + } catch (Exception ignored) {} + }); + + UUID uuid = MinecraftClient.getInstance().getSession().getUuidOrNull(); + if (uuid != null) { + CompletableFuture.runAsync(() -> { + String rank = RankManager.getRank(uuid); + int attempts = 0; + while (rank.equals("loading") && attempts < 10) { + try { + Thread.sleep(500); + } catch (InterruptedException e) { + break; + } + rank = RankManager.getRank(uuid); + attempts++; + } + + String finalRank = rank; + MuiModApi.postToUiThread(() -> { + if (mRankIconView != null) { + if (finalRank.equals("none") || finalRank.equals("loading")) { + mRankIconView.setVisibility(View.GONE); + } else { + mRankIconView.setVisibility(View.VISIBLE); + ImageDrawable rankIcon = new ImageDrawable("citrus", "ranks/" + finalRank + ".png"); + rankIcon.setGravity(Gravity.FILL); + mRankIconView.setBackground(rankIcon); + } + } + }); + }); + } + } + + private void addNavButton(LinearLayout parent, String text, String iconPath, View.OnClickListener listener) { + Context context = requireContext(); + + FrameLayout btnWrapper = new FrameLayout(context); + LinearLayout.LayoutParams wrapperLp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + 50 + ); + wrapperLp.setMargins(0, 6, 0, 6); + btnWrapper.setLayoutParams(wrapperLp); + + ShapeDrawable btnBg = new ShapeDrawable(); + btnBg.setShape(ShapeDrawable.RECTANGLE); + btnBg.setColor(0x1AFFFFFF); + btnBg.setCornerRadius(10); + btnWrapper.setBackground(btnBg); + + LinearLayout content = new LinearLayout(context); + content.setOrientation(LinearLayout.HORIZONTAL); + content.setGravity(Gravity.CENTER_VERTICAL); + content.setPadding(20, 0, 20, 0); + + ImageDrawable icon = new ImageDrawable("citrus", "startscreen/" + iconPath + ".png"); + icon.setBounds(0, 0, 24, 24); + + TextView tv = new TextView(context); + tv.setText(text); + setFixedTextSize(tv,36); + tv.setTextColor(0xFFFFFFFF); + tv.setCompoundDrawables(icon, null, null, null); + tv.setCompoundDrawablePadding(16); + + content.addView(tv); + btnWrapper.addView(content); + + btnWrapper.setClickable(true); + btnWrapper.setFocusable(true); + btnWrapper.setOnClickListener(listener); + + btnWrapper.setOnHoverListener((v, event) -> { + if (event.getAction() == MotionEvent.ACTION_HOVER_ENTER) { + ObjectAnimator animator = ObjectAnimator.ofFloat(v, View.TRANSLATION_X, 0f, (float) dp(15)); + animator.setDuration(250); + animator.setInterpolator(MotionEasingUtils.LINEAR_OUT_SLOW_IN); + animator.start(); + btnBg.setColor(0x33FFFFFF); + return true; + } else if (event.getAction() == MotionEvent.ACTION_HOVER_EXIT) { + ObjectAnimator animator = ObjectAnimator.ofFloat(v, View.TRANSLATION_X, (float) dp(15), 0f); + animator.setDuration(250); + animator.setInterpolator(MotionEasingUtils.LINEAR_OUT_SLOW_IN); + animator.start(); + btnBg.setColor(0x1AFFFFFF); + return true; + } + return false; + }); + + parent.addView(btnWrapper); + mStaggeredViews.add(btnWrapper); + } + + private void animateEntrance() { + long delay = 200; + for (View v : mStaggeredViews) { + v.setAlpha(0); + float startX = -dp(40); + + ViewGroup.LayoutParams lp = v.getLayoutParams(); + if (lp instanceof FrameLayout.LayoutParams flp) { + if ((flp.gravity & Gravity.RIGHT) == Gravity.RIGHT) { + startX = dp(40); + } + } + + v.setTranslationX(startX); + + ObjectAnimator alpha = ObjectAnimator.ofFloat(v, View.ALPHA, 0f, 1f); + ObjectAnimator trans = ObjectAnimator.ofFloat(v, View.TRANSLATION_X, startX, 0f); + + AnimatorSet set = new AnimatorSet(); + set.playTogether(alpha, trans); + set.setDuration(1000); + set.setStartDelay(delay); + set.setInterpolator(MotionEasingUtils.MOTION_EASING_EMPHASIZED_DECELERATE); + set.start(); + + delay += 80; + } + } + + private void fetchNews() { + CompletableFuture.runAsync(() -> { + try { + HttpClient client = HttpClient.newHttpClient(); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(Client.API_URL + "/news")) + .build(); + + var response = client.send(request, HttpResponse.BodyHandlers.ofString()); + JsonObject json = new Gson().fromJson(response.body(), JsonObject.class); + + if (json.get("success").getAsBoolean()) { + JsonArray array = json.getAsJsonArray("news"); + + MuiModApi.postToUiThread(() -> { + if (mNewsContainer == null) return; + mNewsContainer.removeAllViews(); + for (var element : array) { + JsonObject obj = element.getAsJsonObject(); + mNewsContainer.addView(createNewsCard( + obj.get("title").getAsString(), + obj.get("content").getAsString(), + obj.get("date").getAsString() + )); + } + }); + } + } catch (Exception e) { + Client.logger.error("Error while fetching news", e); + } + }); + } + + private View createNewsCard(String title, String content, String date) { + LinearLayout card = new LinearLayout(requireContext()); + card.setOrientation(LinearLayout.VERTICAL); + card.setPadding(12, 12, 12, 12); + + LinearLayout.LayoutParams cardLp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + cardLp.setMargins(0, 0, 0, 15); + card.setLayoutParams(cardLp); + + ShapeDrawable cardBg = new ShapeDrawable(); + cardBg.setShape(ShapeDrawable.RECTANGLE); + cardBg.setColor(0x26FFFFFF); + cardBg.setCornerRadius(8); + card.setBackground(cardBg); + + TextView titleTv = new TextView(requireContext()); + titleTv.setText(title); + setFixedTextSize(titleTv,33); + titleTv.setTextColor(0xFFFFFFFF); + titleTv.getPaint().setTextStyle(TextPaint.BOLD); + card.addView(titleTv); + + TextView dateTv = new TextView(requireContext()); + dateTv.setText(date); + setFixedTextSize(dateTv,22); + dateTv.setTextColor(0x88FFFFFF); + dateTv.setPadding(0, 2, 0, 6); + card.addView(dateTv); + + TextView contentTv = new TextView(requireContext()); + Markflow.create(requireContext()).setMarkdown(contentTv, content); + setFixedTextSize(contentTv,30); + contentTv.setTextColor(0xCCFFFFFF); + card.addView(contentTv); + + return card; + } + + private int dp(int value) { + return (int) (value * requireContext().getResources().getDisplayMetrics().density); + } + + @Override + public boolean isPauseScreen() { + return false; + } + + @Override + public boolean shouldBlurBackground() { + return false; + } + + @Override + public boolean hasDefaultBackground() { + return false; + } + + private void startParallaxLoop() { + if (mBackgroundView == null) return; + mBackgroundView.postOnAnimation(new Runnable() { + @Override + public void run() { + if (mBackgroundView != null && mBackgroundView.isAttachedToWindow() && isAdded()) { + MinecraftClient mc = MinecraftClient.getInstance(); + double mouseX = mc.mouse.getX(); + double mouseY = mc.mouse.getY(); + int width = mc.getWindow().getWidth(); + int height = mc.getWindow().getHeight(); + + if (width > 0 && height > 0) { + float maxMove = dp(20); + float moveX = (float) ((mouseX - width / 2.0) / (width / 2.0) * maxMove); + float moveY = (float) ((mouseY - height / 2.0) / (height / 2.0) * maxMove); + + mBackgroundView.setTranslationX(-moveX); + mBackgroundView.setTranslationY(-moveY); + } + mBackgroundView.postOnAnimation(this); + } + } + }); + } + + private void loadInitialWallpaper(String wallpaper) { + WallpaperManager.loadWallpaperWithData(wallpaper, imageData -> { + if (imageData != null) { + MuiModApi.postToUiThread(() -> { + mBackground1Drawable.setImageData(imageData); + mBackgroundView1.invalidate(); + }); + } + }); + } + + private void populateWallpaperOptions(ScrollView wpScroll) { + Context context = requireContext(); + + if (WallpaperManager.isWallpapersListLoaded()) { + addWallpaperOptionsToContainer(context, wpScroll); + } else { + TextView loadingTv = new TextView(context); + loadingTv.setText("Loading wallpapers..."); + setFixedTextSize(loadingTv, 24); + loadingTv.setTextColor(0x88FFFFFF); + loadingTv.setPadding(dp(10), dp(6), dp(10), dp(6)); + mWpOptionsContainer.addView(loadingTv); + + WallpaperManager.onWallpapersLoaded(() -> { + MuiModApi.postToUiThread(() -> { + mWpOptionsContainer.removeAllViews(); + addWallpaperOptionsToContainer(context, wpScroll); + }); + }); + } + } + + private void addWallpaperOptionsToContainer(Context context, ScrollView wpScroll) { + List wallpapers = WallpaperManager.getAvailableWallpapers(); + + for (String wp : wallpapers) { + TextView option = new TextView(context); + option.setText(wp); + setFixedTextSize(option, 24); + option.setTextColor(0xFFFFFFFF); + option.setPadding(dp(10), dp(6), dp(10), dp(6)); + option.setClickable(true); + option.setOnClickListener(v_opt -> { + ConfigManager.config.mainmenuWallpaper = wp; + ConfigManager.save(); + mCurrentWpTv.setText(wp); + updateBackground(wp); + wpScroll.setVisibility(View.GONE); + }); + option.setOnHoverListener((v_opt, event) -> { + if (event.getAction() == MotionEvent.ACTION_HOVER_ENTER) { + option.setBackground(new ColorDrawable(0x33FFFFFF)); + } else if (event.getAction() == MotionEvent.ACTION_HOVER_EXIT) { + option.setBackground(null); + } + return false; + }); + mWpOptionsContainer.addView(option); + } + } + + private void updateBackground(String wallpaper) { + View activeView = mIsUsingView1 ? mBackgroundView1 : mBackgroundView2; + View nextView = mIsUsingView1 ? mBackgroundView2 : mBackgroundView1; + CdnTextureDrawable nextDrawable = mIsUsingView1 ? mBackground2Drawable : mBackground1Drawable; + + WallpaperManager.loadWallpaperWithData(wallpaper, imageData -> { + MuiModApi.postToUiThread(() -> { + if (imageData != null) { + nextDrawable.setImageData(imageData); + nextView.invalidate(); + } + + ObjectAnimator fadeIn = ObjectAnimator.ofFloat(nextView, View.ALPHA, 0f, 1f); + ObjectAnimator fadeOut = ObjectAnimator.ofFloat(activeView, View.ALPHA, 1f, 0f); + fadeIn.setDuration(800); + fadeOut.setDuration(800); + fadeIn.start(); + fadeOut.start(); + + mIsUsingView1 = !mIsUsingView1; + }); + }); + } + + private void toggleWallpaperDropdown(ScrollView wpScroll, boolean show) { + if (show) { + wpScroll.setVisibility(View.VISIBLE); + wpScroll.setAlpha(0f); + wpScroll.setTranslationY(-dp(10)); + ObjectAnimator.ofFloat(wpScroll, View.ALPHA, 0f, 1f).setDuration(250).start(); + ObjectAnimator.ofFloat(wpScroll, View.TRANSLATION_Y, -dp(10), 0f).setDuration(250).start(); + } else { + ObjectAnimator.ofFloat(wpScroll, View.ALPHA, 1f, 0f).setDuration(250).start(); + ObjectAnimator.ofFloat(wpScroll, View.TRANSLATION_Y, 0f, -dp(10)).setDuration(250).start(); + CompletableFuture.delayedExecutor(250, java.util.concurrent.TimeUnit.MILLISECONDS).execute(() -> { + MuiModApi.postToUiThread(() -> wpScroll.setVisibility(View.GONE)); + }); + } + } + +} diff --git a/src/main/java/de/winniepat/citrus/gui/ModuleToggleScreen.java b/src/main/java/de/winniepat/citrus/gui/ModuleToggleScreen.java new file mode 100644 index 0000000..257b404 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/gui/ModuleToggleScreen.java @@ -0,0 +1,53 @@ +package de.winniepat.citrus.gui; + +import de.winniepat.citrus.draggui.HudConfig; +import de.winniepat.citrus.draggui.HudUtils; +import de.winniepat.citrus.utils.text.TextComponent; +import io.wispforest.owo.ui.base.BaseOwoScreen; +import io.wispforest.owo.ui.container.Containers; +import io.wispforest.owo.ui.container.FlowLayout; +import io.wispforest.owo.ui.core.*; +import net.minecraft.text.Text; +import org.jetbrains.annotations.NotNull; + +public class ModuleToggleScreen extends BaseOwoScreen { + + @Override + protected @NotNull OwoUIAdapter createAdapter() { return OwoUIAdapter.create(this, Containers::verticalFlow); } + + @Override + protected void build(FlowLayout root) { + root.horizontalAlignment(HorizontalAlignment.CENTER) + .verticalAlignment(VerticalAlignment.CENTER); + + FlowLayout content = (FlowLayout) Containers.verticalFlow(Sizing.content(), Sizing.content()) + .padding(Insets.of(12)) + .surface(Surface.DARK_PANEL) + .horizontalAlignment(HorizontalAlignment.CENTER); + content.gap(3); + + var mainTitle = new TextComponent(Text.literal("Module Toggles")).scale(1.8f).horizontalSizing(Sizing.fixed(140)).margins(Insets.of(10)); + + content.child(mainTitle).gap(10); + + FlowLayout firstRow = Containers.horizontalFlow(Sizing.content(), Sizing.content()); + firstRow.gap(6); + content.child(firstRow); + + FlowLayout secondRow = Containers.horizontalFlow(Sizing.content(), Sizing.content()); + secondRow.gap(6); + content.child(secondRow); + + HudConfig c = HudConfig.INSTANCE; + + firstRow.child(HudUtils.createButton("FPS", "fps", "Frames per second", c.fpsEnabled, state -> c.fpsEnabled = state)); + firstRow.child(HudUtils.createButton("Ping", "ping", "Your latency", c.pingEnabled, state -> c.pingEnabled = state)); + firstRow.child(HudUtils.createButton("Server", "server", "Server address", c.serverEnabled, state -> c.serverEnabled = state)); + + secondRow.child(HudUtils.createButton("Keys", "key", "Keystrokes", c.keyEnabled, state -> c.keyEnabled = state)); + secondRow.child(HudUtils.createButton("Armor", "armor", "Armor status", c.armorEnabled, state -> c.armorEnabled = state)); + secondRow.child(HudUtils.createButton("Potion", "potion", "Active effects", c.potionEnabled, state -> c.potionEnabled = state)); + + root.child(content); + } +} diff --git a/src/main/java/de/winniepat/citrus/gui/ProfileScreen.java b/src/main/java/de/winniepat/citrus/gui/ProfileScreen.java new file mode 100644 index 0000000..0fab234 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/gui/ProfileScreen.java @@ -0,0 +1,935 @@ +package de.winniepat.citrus.gui; + +import de.winniepat.citrus.managers.ProfileManager; +import de.winniepat.citrus.managers.ProfileManager.PlayerProfile; +import de.winniepat.citrus.managers.ProfileManager.SearchResult; +import de.winniepat.citrus.managers.WallpaperManager; +import de.winniepat.citrus.managers.ConfigManager; +import de.winniepat.citrus.utils.texture.CdnTextureDrawable; +import icyllis.modernui.animation.*; +import icyllis.modernui.core.Context; +import icyllis.modernui.fragment.Fragment; +import icyllis.modernui.graphics.drawable.*; +import icyllis.modernui.mc.*; +import icyllis.modernui.text.InputFilter; +import icyllis.modernui.text.TextPaint; +import icyllis.modernui.util.DataSet; +import icyllis.modernui.view.*; +import icyllis.modernui.widget.*; +import net.minecraft.client.MinecraftClient; +import org.jetbrains.annotations.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +public class ProfileScreen extends Fragment implements ScreenCallback { + + private LinearLayout mContentContainer; + private LinearLayout mSearchResultsContainer; + private EditText mSearchInput; + private boolean mIsEditMode = false; + private PlayerProfile mCurrentProfile; + private UUID mViewingUuid; + private CdnTextureDrawable mBackground1Drawable; + private final List mStaggeredViews = new ArrayList<>(); + private EditText mBioEdit; + private EditText mDiscordEdit; + private EditText mTwitterEdit; + private EditText mYoutubeEdit; + private EditText mTwitchEdit; + private EditText mInstagramEdit; + private EditText mGithubEdit; + private EditText mWebsiteEdit; + + private float getGuiScale() { + return (float) MinecraftClient.getInstance().getWindow().getScaleFactor(); + } + + private void setFixedTextSize(TextView tv, float px) { + float scale = getGuiScale(); + tv.setTextSize(px / scale); + } + + @Override + public View onCreateView(@NotNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable DataSet savedInstanceState) { + Context context = requireContext(); + + FrameLayout root = new FrameLayout(context); + root.setLayoutParams(new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + )); + + FrameLayout backgroundView = new FrameLayout(context); + backgroundView.setLayoutParams(new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + )); + backgroundView.setScaleX(1.1f); + backgroundView.setScaleY(1.1f); + + View backgroundView1 = new View(context); + backgroundView1.setLayoutParams(new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + )); + backgroundView.addView(backgroundView1); + + root.addView(backgroundView); + + mBackground1Drawable = new CdnTextureDrawable(); + backgroundView1.setBackground(mBackground1Drawable); + + String currentWallpaper = ConfigManager.config.mainmenuWallpaper; + if (currentWallpaper == null || currentWallpaper.isEmpty()) { + currentWallpaper = "default.png"; + } + WallpaperManager.loadWallpaperWithData(currentWallpaper, imageData -> { + MuiModApi.postToUiThread(() -> mBackground1Drawable.setImageData(imageData)); + }); + + ObjectAnimator bgPulseX = ObjectAnimator.ofFloat(backgroundView, View.SCALE_X, 1.1f, 1.15f); + ObjectAnimator bgPulseY = ObjectAnimator.ofFloat(backgroundView, View.SCALE_Y, 1.1f, 1.15f); + bgPulseX.setDuration(15000); + bgPulseY.setDuration(15000); + bgPulseX.setRepeatCount(ValueAnimator.INFINITE); + bgPulseY.setRepeatCount(ValueAnimator.INFINITE); + bgPulseX.setRepeatMode(ValueAnimator.REVERSE); + bgPulseY.setRepeatMode(ValueAnimator.REVERSE); + bgPulseX.start(); + bgPulseY.start(); + + FrameLayout contentWrapper = new FrameLayout(context); + contentWrapper.setLayoutParams(new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + )); + root.addView(contentWrapper); + + LinearLayout leftPanel = new LinearLayout(context); + leftPanel.setOrientation(LinearLayout.VERTICAL); + FrameLayout.LayoutParams leftPanelLp = new FrameLayout.LayoutParams( + 320, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + leftPanelLp.gravity = Gravity.CENTER_VERTICAL; + leftPanelLp.setMargins(60, 0, 0, 0); + leftPanel.setLayoutParams(leftPanelLp); + + TextView title = new TextView(context); + title.setText("PROFILES"); + setFixedTextSize(title, 96); + title.getPaint().setTextStyle(TextPaint.BOLD); + title.setTextColor(0xFFFFFFFF); + leftPanel.addView(title); + mStaggeredViews.add(title); + + TextView subtitle = new TextView(context); + subtitle.setText("View and edit player profiles"); + setFixedTextSize(subtitle, 32); + subtitle.setTextColor(0xCCFFFFFF); + LinearLayout.LayoutParams subtitleLp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + subtitleLp.setMargins(0, 0, 0, dp(30)); + subtitle.setLayoutParams(subtitleLp); + leftPanel.addView(subtitle); + mStaggeredViews.add(subtitle); + + LinearLayout searchSection = new LinearLayout(context); + searchSection.setOrientation(LinearLayout.VERTICAL); + LinearLayout.LayoutParams searchSectionLp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + searchSectionLp.setMargins(0, 0, 0, dp(20)); + searchSection.setLayoutParams(searchSectionLp); + + TextView searchLabel = new TextView(context); + searchLabel.setText("Search Players"); + setFixedTextSize(searchLabel, 28); + searchLabel.setTextColor(0x88FFFFFF); + searchLabel.setPadding(0, 0, 0, dp(8)); + searchSection.addView(searchLabel); + + mSearchInput = new EditText(context); + mSearchInput.setHint("Enter player name..."); + setFixedTextSize(mSearchInput, 32); + mSearchInput.setTextColor(0xFFFFFFFF); + mSearchInput.setHintTextColor(0x66FFFFFF); + mSearchInput.setPadding(dp(16), dp(12), dp(16), dp(12)); + mSearchInput.setSingleLine(true); + ShapeDrawable inputBg = new ShapeDrawable(); + inputBg.setShape(ShapeDrawable.RECTANGLE); + inputBg.setColor(0x26FFFFFF); + inputBg.setCornerRadius(dp(10)); + mSearchInput.setBackground(inputBg); + LinearLayout.LayoutParams inputLp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + inputLp.setMargins(0, 0, 0, dp(8)); + mSearchInput.setLayoutParams(inputLp); + searchSection.addView(mSearchInput); + + leftPanel.addView(searchSection); + mStaggeredViews.add(searchSection); + + addNavButton(leftPanel, "Search", "multiplayer_icon", v -> performSearch()); + addNavButton(leftPanel, "My Profile", "settings_icon", v -> loadOwnProfile()); + addNavButton(leftPanel, "Back to Menu", "door_icon", v -> { + MinecraftClient.getInstance().execute(() -> { + MinecraftClient.getInstance().setScreen(MuiModApi.get().createScreen(new MainMenuScreen(), null, null)); + }); + }); + + contentWrapper.addView(leftPanel); + + LinearLayout rightPanel = new LinearLayout(context); + rightPanel.setOrientation(LinearLayout.VERTICAL); + FrameLayout.LayoutParams rightPanelLp = new FrameLayout.LayoutParams( + 500, + 500 + ); + rightPanelLp.gravity = Gravity.CENTER_VERTICAL | Gravity.RIGHT; + rightPanelLp.setMargins(0, 0, 60, 0); + rightPanel.setLayoutParams(rightPanelLp); + + mSearchResultsContainer = new LinearLayout(context); + mSearchResultsContainer.setOrientation(LinearLayout.VERTICAL); + mSearchResultsContainer.setVisibility(View.GONE); + LinearLayout.LayoutParams resultsLp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + resultsLp.setMargins(0, 0, 0, dp(12)); + mSearchResultsContainer.setLayoutParams(resultsLp); + + ShapeDrawable resultsBg = new ShapeDrawable(); + resultsBg.setShape(ShapeDrawable.RECTANGLE); + resultsBg.setColor(0x33000000); + resultsBg.setCornerRadius(dp(16)); + mSearchResultsContainer.setBackground(resultsBg); + mSearchResultsContainer.setPadding(dp(16), dp(16), dp(16), dp(16)); + + rightPanel.addView(mSearchResultsContainer); + + LinearLayout profilePanel = new LinearLayout(context); + profilePanel.setOrientation(LinearLayout.VERTICAL); + profilePanel.setPadding(dp(16), dp(16), dp(16), dp(16)); + LinearLayout.LayoutParams profilePanelLp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + 0, 1f + ); + profilePanel.setLayoutParams(profilePanelLp); + + ShapeDrawable panelBg = new ShapeDrawable(); + panelBg.setShape(ShapeDrawable.RECTANGLE); + panelBg.setColor(0x33000000); + panelBg.setCornerRadius(dp(16)); + profilePanel.setBackground(panelBg); + + TextView panelTitle = new TextView(context); + panelTitle.setText("PROFILE"); + setFixedTextSize(panelTitle, 48); + panelTitle.getPaint().setTextStyle(TextPaint.BOLD); + panelTitle.setTextColor(0xFF4a90d9); + panelTitle.setGravity(Gravity.CENTER); + LinearLayout.LayoutParams panelTitleLp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + panelTitleLp.setMargins(0, 0, 0, dp(12)); + panelTitle.setLayoutParams(panelTitleLp); + profilePanel.addView(panelTitle); + + ScrollView scrollView = new ScrollView(context); + LinearLayout.LayoutParams scrollLp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + 0, 1f + ); + scrollView.setLayoutParams(scrollLp); + scrollView.setScrollBarSize(dp(6)); + + mContentContainer = new LinearLayout(context); + mContentContainer.setOrientation(LinearLayout.VERTICAL); + + scrollView.addView(mContentContainer); + profilePanel.addView(scrollView); + + rightPanel.addView(profilePanel); + mStaggeredViews.add(rightPanel); + + contentWrapper.addView(rightPanel); + + TextView infoText = new TextView(context); + infoText.setText("Share your profile with friends • Add your socials to connect"); + setFixedTextSize(infoText, 24); + infoText.setTextColor(0x66FFFFFF); + FrameLayout.LayoutParams infoLp = new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + infoLp.gravity = Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL; + infoLp.setMargins(0, 0, 0, dp(20)); + infoText.setLayoutParams(infoLp); + contentWrapper.addView(infoText); + mStaggeredViews.add(infoText); + + loadOwnProfile(); + animateEntrance(); + + return root; + } + + private void addNavButton(LinearLayout parent, String text, String iconPath, View.OnClickListener listener) { + Context context = requireContext(); + + FrameLayout btnWrapper = new FrameLayout(context); + LinearLayout.LayoutParams wrapperLp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + 56 + ); + wrapperLp.setMargins(0, 0, 0, 8); + btnWrapper.setLayoutParams(wrapperLp); + + ShapeDrawable btnBg = new ShapeDrawable(); + btnBg.setShape(ShapeDrawable.RECTANGLE); + btnBg.setColor(0x1AFFFFFF); + btnBg.setCornerRadius(12); + btnWrapper.setBackground(btnBg); + + LinearLayout content = new LinearLayout(context); + content.setOrientation(LinearLayout.HORIZONTAL); + content.setGravity(Gravity.CENTER_VERTICAL); + content.setPadding(20, 0, 20, 0); + + ImageDrawable icon = new ImageDrawable("citrus", "startscreen/" + iconPath + ".png"); + icon.setBounds(0, 0, 24, 24); + + TextView tv = new TextView(context); + tv.setText(text); + setFixedTextSize(tv, 36); + tv.setTextColor(0xFFFFFFFF); + tv.setCompoundDrawables(icon, null, null, null); + tv.setCompoundDrawablePadding(16); + + content.addView(tv); + btnWrapper.addView(content); + + btnWrapper.setClickable(true); + btnWrapper.setFocusable(true); + btnWrapper.setOnClickListener(listener); + + btnWrapper.setOnHoverListener((v, event) -> { + if (event.getAction() == MotionEvent.ACTION_HOVER_ENTER) { + ObjectAnimator animator = ObjectAnimator.ofFloat(v, View.TRANSLATION_X, 0f, (float) dp(15)); + animator.setDuration(250); + animator.setInterpolator(TimeInterpolator.DECELERATE); + animator.start(); + btnBg.setColor(0x33FFFFFF); + return true; + } else if (event.getAction() == MotionEvent.ACTION_HOVER_EXIT) { + ObjectAnimator animator = ObjectAnimator.ofFloat(v, View.TRANSLATION_X, (float) dp(15), 0f); + animator.setDuration(250); + animator.setInterpolator(TimeInterpolator.DECELERATE); + animator.start(); + btnBg.setColor(0x1AFFFFFF); + return true; + } + return false; + }); + + parent.addView(btnWrapper); + mStaggeredViews.add(btnWrapper); + } + + private void animateEntrance() { + long delay = 100; + for (View v : mStaggeredViews) { + v.setAlpha(0); + float startX = -dp(40); + + ViewGroup.LayoutParams lp = v.getLayoutParams(); + if (lp instanceof FrameLayout.LayoutParams flp) { + if ((flp.gravity & Gravity.RIGHT) == Gravity.RIGHT) { + startX = dp(40); + } + } + + v.setTranslationX(startX); + + ObjectAnimator alpha = ObjectAnimator.ofFloat(v, View.ALPHA, 0f, 1f); + ObjectAnimator trans = ObjectAnimator.ofFloat(v, View.TRANSLATION_X, startX, 0f); + + AnimatorSet set = new AnimatorSet(); + set.playTogether(alpha, trans); + set.setDuration(800); + set.setStartDelay(delay); + set.setInterpolator(TimeInterpolator.DECELERATE); + set.start(); + + delay += 60; + } + } + + private void loadOwnProfile() { + mSearchResultsContainer.setVisibility(View.GONE); + showLoading(); + + UUID uuid = MinecraftClient.getInstance().getSession().getUuidOrNull(); + if (uuid == null) { + showError("Not logged in"); + return; + } + + mViewingUuid = uuid; + ProfileManager.getOwnProfile().thenAccept(profile -> { + MuiModApi.postToUiThread(() -> { + if (profile != null) { + mCurrentProfile = profile; + showProfileView(profile, true); + } else { + mCurrentProfile = new PlayerProfile( + uuid.toString(), + MinecraftClient.getInstance().getSession().getUsername() + ); + showProfileEdit(mCurrentProfile); + } + }); + }); + } + + private void loadProfile(UUID uuid) { + mSearchResultsContainer.setVisibility(View.GONE); + showLoading(); + + mViewingUuid = uuid; + UUID ownUuid = MinecraftClient.getInstance().getSession().getUuidOrNull(); + boolean isOwn = ownUuid != null && ownUuid.equals(uuid); + + ProfileManager.getProfile(uuid).thenAccept(profile -> { + MuiModApi.postToUiThread(() -> { + if (profile != null) { + mCurrentProfile = profile; + showProfileView(profile, isOwn); + } else { + showError("Profile not found"); + } + }); + }); + } + + private void performSearch() { + String query = mSearchInput.getText().toString().trim(); + if (query.isEmpty()) return; + + mSearchResultsContainer.removeAllViews(); + mSearchResultsContainer.setVisibility(View.VISIBLE); + + TextView searching = new TextView(requireContext()); + searching.setText("Searching..."); + setFixedTextSize(searching, 28); + searching.setTextColor(0x88FFFFFF); + mSearchResultsContainer.addView(searching); + + ProfileManager.searchProfiles(query).thenAccept(results -> { + MuiModApi.postToUiThread(() -> { + mSearchResultsContainer.removeAllViews(); + + if (results.isEmpty()) { + TextView noResults = new TextView(requireContext()); + noResults.setText("No profiles found"); + setFixedTextSize(noResults, 28); + noResults.setTextColor(0x88FFFFFF); + mSearchResultsContainer.addView(noResults); + } else { + TextView resultsLabel = new TextView(requireContext()); + resultsLabel.setText("Results (" + results.size() + ")"); + setFixedTextSize(resultsLabel, 32); + resultsLabel.getPaint().setTextStyle(TextPaint.BOLD); + resultsLabel.setTextColor(0xFFFFD700); + resultsLabel.setPadding(0, 0, 0, dp(10)); + mSearchResultsContainer.addView(resultsLabel); + + for (SearchResult result : results) { + mSearchResultsContainer.addView(createSearchResultCard(result)); + } + } + }); + }); + } + + private View createSearchResultCard(SearchResult result) { + Context context = requireContext(); + + LinearLayout card = new LinearLayout(context); + card.setOrientation(LinearLayout.HORIZONTAL); + card.setGravity(Gravity.CENTER_VERTICAL); + card.setPadding(dp(12), dp(10), dp(12), dp(10)); + LinearLayout.LayoutParams cardLp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + cardLp.setMargins(0, 0, 0, dp(8)); + card.setLayoutParams(cardLp); + + ShapeDrawable cardBg = new ShapeDrawable(); + cardBg.setShape(ShapeDrawable.RECTANGLE); + cardBg.setColor(0x20FFFFFF); + cardBg.setCornerRadius(dp(10)); + card.setBackground(cardBg); + card.setClickable(true); + + card.setOnClickListener(v -> { + try { + UUID uuid = UUID.fromString(result.uuid); + loadProfile(uuid); + } catch (Exception ignored) {} + }); + + card.setOnHoverListener((v, event) -> { + if (event.getAction() == MotionEvent.ACTION_HOVER_ENTER) { + cardBg.setColor(0x33FFFFFF); + } else if (event.getAction() == MotionEvent.ACTION_HOVER_EXIT) { + cardBg.setColor(0x20FFFFFF); + } + return false; + }); + + TextView nameTv = new TextView(context); + nameTv.setText(result.playerName); + setFixedTextSize(nameTv, 30); + nameTv.setTextColor(0xFFFFFFFF); + LinearLayout.LayoutParams nameLp = new LinearLayout.LayoutParams( + 0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f + ); + nameTv.setLayoutParams(nameLp); + card.addView(nameTv); + + if (result.hasSocials) { + TextView socialsTag = new TextView(context); + socialsTag.setText("Socials"); + setFixedTextSize(socialsTag, 22); + socialsTag.setTextColor(0xFF5cb85c); + socialsTag.setPadding(dp(8), dp(4), dp(8), dp(4)); + ShapeDrawable tagBg = new ShapeDrawable(); + tagBg.setShape(ShapeDrawable.RECTANGLE); + tagBg.setColor(0x335cb85c); + tagBg.setCornerRadius(dp(4)); + socialsTag.setBackground(tagBg); + card.addView(socialsTag); + } + + return card; + } + + private void showLoading() { + mContentContainer.removeAllViews(); + + LinearLayout loadingContainer = new LinearLayout(requireContext()); + loadingContainer.setOrientation(LinearLayout.VERTICAL); + loadingContainer.setGravity(Gravity.CENTER); + loadingContainer.setPadding(dp(20), dp(60), dp(20), dp(60)); + + TextView loading = new TextView(requireContext()); + loading.setText("Loading profile..."); + setFixedTextSize(loading, 32); + loading.setTextColor(0x88FFFFFF); + loading.setGravity(Gravity.CENTER); + loadingContainer.addView(loading); + + mContentContainer.addView(loadingContainer); + } + + private void showError(String message) { + mContentContainer.removeAllViews(); + + LinearLayout errorContainer = new LinearLayout(requireContext()); + errorContainer.setOrientation(LinearLayout.VERTICAL); + errorContainer.setGravity(Gravity.CENTER); + errorContainer.setPadding(dp(20), dp(60), dp(20), dp(60)); + + TextView error = new TextView(requireContext()); + error.setText(message); + setFixedTextSize(error, 32); + error.setTextColor(0xFFFF6B6B); + error.setGravity(Gravity.CENTER); + errorContainer.addView(error); + + mContentContainer.addView(errorContainer); + } + + private void showProfileView(PlayerProfile profile, boolean isOwn) { + mIsEditMode = false; + mContentContainer.removeAllViews(); + Context context = requireContext(); + + LinearLayout header = new LinearLayout(context); + header.setOrientation(LinearLayout.HORIZONTAL); + header.setGravity(Gravity.CENTER_VERTICAL); + LinearLayout.LayoutParams headerLp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + headerLp.setMargins(0, 0, 0, dp(16)); + header.setLayoutParams(headerLp); + + TextView nameTv = new TextView(context); + nameTv.setText(profile.playerName); + setFixedTextSize(nameTv, 42); + nameTv.getPaint().setTextStyle(TextPaint.BOLD); + nameTv.setTextColor(0xFFFFFFFF); + LinearLayout.LayoutParams nameLp = new LinearLayout.LayoutParams( + 0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f + ); + nameTv.setLayoutParams(nameLp); + header.addView(nameTv); + + if (isOwn) { + TextView editBtn = createSmallActionButton(context, "Edit", 0xFF4a90d9); + editBtn.setOnClickListener(v -> showProfileEdit(profile)); + header.addView(editBtn); + } + + mContentContainer.addView(header); + + if (profile.bio != null && !profile.bio.trim().isEmpty()) { + mContentContainer.addView(createSectionLabel(context, "About")); + TextView bioTv = new TextView(context); + bioTv.setText(profile.bio); + setFixedTextSize(bioTv, 28); + bioTv.setTextColor(0xCCFFFFFF); + bioTv.setPadding(0, 0, 0, dp(16)); + mContentContainer.addView(bioTv); + } + + boolean hasSocials = profile.hasSocialLinks(); + if (hasSocials) { + mContentContainer.addView(createSectionLabel(context, "Social Links")); + + LinearLayout socialsGrid = new LinearLayout(context); + socialsGrid.setOrientation(LinearLayout.VERTICAL); + + if (!isEmpty(profile.discord)) { + socialsGrid.addView(createSocialLink(context, "Discord", profile.discord, 0xFF5865F2)); + } + if (!isEmpty(profile.twitter)) { + socialsGrid.addView(createSocialLink(context, "Twitter/X", profile.twitter, 0xFF1DA1F2)); + } + if (!isEmpty(profile.youtube)) { + socialsGrid.addView(createSocialLink(context, "YouTube", profile.youtube, 0xFFFF0000)); + } + if (!isEmpty(profile.twitch)) { + socialsGrid.addView(createSocialLink(context, "Twitch", profile.twitch, 0xFF9146FF)); + } + if (!isEmpty(profile.instagram)) { + socialsGrid.addView(createSocialLink(context, "Instagram", profile.instagram, 0xFFE1306C)); + } + if (!isEmpty(profile.github)) { + socialsGrid.addView(createSocialLink(context, "GitHub", profile.github, 0xFFFFFFFF)); + } + if (!isEmpty(profile.website)) { + socialsGrid.addView(createSocialLink(context, "Website", profile.website, 0xFF4a90d9)); + } + + mContentContainer.addView(socialsGrid); + } + + if (!hasSocials && (profile.bio == null || profile.bio.trim().isEmpty())) { + LinearLayout emptyContainer = new LinearLayout(context); + emptyContainer.setOrientation(LinearLayout.VERTICAL); + emptyContainer.setGravity(Gravity.CENTER); + emptyContainer.setPadding(dp(16), dp(40), dp(16), dp(40)); + + TextView emptyTitle = new TextView(context); + emptyTitle.setText(isOwn ? "Profile Empty" : "No Info"); + setFixedTextSize(emptyTitle, 32); + emptyTitle.setTextColor(0x88FFFFFF); + emptyTitle.setGravity(Gravity.CENTER); + emptyContainer.addView(emptyTitle); + + TextView emptySubtitle = new TextView(context); + emptySubtitle.setText(isOwn ? "Click 'Edit' to add your info!" : "This player hasn't added info yet."); + setFixedTextSize(emptySubtitle, 24); + emptySubtitle.setTextColor(0x55FFFFFF); + emptySubtitle.setGravity(Gravity.CENTER); + emptySubtitle.setPadding(0, dp(4), 0, 0); + emptyContainer.addView(emptySubtitle); + + mContentContainer.addView(emptyContainer); + } + } + + private void showProfileEdit(PlayerProfile profile) { + mIsEditMode = true; + mContentContainer.removeAllViews(); + Context context = requireContext(); + + TextView headerTv = new TextView(context); + headerTv.setText("Edit Your Profile"); + setFixedTextSize(headerTv, 36); + headerTv.getPaint().setTextStyle(TextPaint.BOLD); + headerTv.setTextColor(0xFFFFFFFF); + headerTv.setPadding(0, 0, 0, dp(16)); + mContentContainer.addView(headerTv); + + mContentContainer.addView(createInputLabel(context, "Bio")); + mBioEdit = createEditText(context, profile.bio, "Tell others about yourself...", false); + mBioEdit.setMinLines(2); + mBioEdit.setMaxLines(4); + mBioEdit.setFilters(new InputFilter[]{new InputFilter.LengthFilter(500)}); + mContentContainer.addView(mBioEdit); + + mContentContainer.addView(createSectionLabel(context, "Social Links")); + + mContentContainer.addView(createInputLabel(context, "Discord")); + mDiscordEdit = createEditText(context, profile.discord, "username", true); + mContentContainer.addView(mDiscordEdit); + + mContentContainer.addView(createInputLabel(context, "Twitter/X")); + mTwitterEdit = createEditText(context, profile.twitter, "@username", true); + mContentContainer.addView(mTwitterEdit); + + mContentContainer.addView(createInputLabel(context, "YouTube")); + mYoutubeEdit = createEditText(context, profile.youtube, "Channel URL", true); + mContentContainer.addView(mYoutubeEdit); + + mContentContainer.addView(createInputLabel(context, "Twitch")); + mTwitchEdit = createEditText(context, profile.twitch, "username", true); + mContentContainer.addView(mTwitchEdit); + + mContentContainer.addView(createInputLabel(context, "Instagram")); + mInstagramEdit = createEditText(context, profile.instagram, "@username", true); + mContentContainer.addView(mInstagramEdit); + + mContentContainer.addView(createInputLabel(context, "GitHub")); + mGithubEdit = createEditText(context, profile.github, "username", true); + mContentContainer.addView(mGithubEdit); + + mContentContainer.addView(createInputLabel(context, "Website")); + mWebsiteEdit = createEditText(context, profile.website, "https://...", true); + mContentContainer.addView(mWebsiteEdit); + + LinearLayout buttonRow = new LinearLayout(context); + buttonRow.setOrientation(LinearLayout.HORIZONTAL); + buttonRow.setGravity(Gravity.CENTER); + LinearLayout.LayoutParams buttonRowLp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + buttonRowLp.setMargins(0, dp(16), 0, 0); + buttonRow.setLayoutParams(buttonRowLp); + + TextView cancelBtn = createSmallActionButton(context, "Cancel", 0xFF666666); + cancelBtn.setOnClickListener(v -> showProfileView(profile, true)); + LinearLayout.LayoutParams cancelLp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + cancelLp.setMargins(0, 0, dp(10), 0); + cancelBtn.setLayoutParams(cancelLp); + buttonRow.addView(cancelBtn); + + TextView saveBtn = createSmallActionButton(context, "Save", 0xFF5cb85c); + saveBtn.setOnClickListener(v -> saveProfile()); + buttonRow.addView(saveBtn); + + mContentContainer.addView(buttonRow); + } + + private void saveProfile() { + PlayerProfile updated = new PlayerProfile(); + updated.uuid = mCurrentProfile.uuid; + updated.playerName = MinecraftClient.getInstance().getSession().getUsername(); + updated.bio = mBioEdit.getText().toString().trim(); + updated.discord = mDiscordEdit.getText().toString().trim(); + updated.twitter = mTwitterEdit.getText().toString().trim(); + updated.youtube = mYoutubeEdit.getText().toString().trim(); + updated.twitch = mTwitchEdit.getText().toString().trim(); + updated.instagram = mInstagramEdit.getText().toString().trim(); + updated.github = mGithubEdit.getText().toString().trim(); + updated.website = mWebsiteEdit.getText().toString().trim(); + + showLoading(); + + ProfileManager.updateProfile(updated).thenAccept(error -> { + MuiModApi.postToUiThread(() -> { + if (error == null) { + mCurrentProfile = updated; + ProfileManager.invalidateCache(UUID.fromString(updated.uuid)); + showProfileView(updated, true); + } else { + showError("Failed to save: " + error); + } + }); + }); + } + + private View createSocialLink(Context context, String platform, String value, int color) { + LinearLayout row = new LinearLayout(context); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setGravity(Gravity.CENTER_VERTICAL); + row.setPadding(dp(10), dp(8), dp(10), dp(8)); + LinearLayout.LayoutParams rowLp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + rowLp.setMargins(0, 0, 0, dp(6)); + row.setLayoutParams(rowLp); + + ShapeDrawable rowBg = new ShapeDrawable(); + rowBg.setShape(ShapeDrawable.RECTANGLE); + rowBg.setColor(0x20FFFFFF); + rowBg.setCornerRadius(dp(8)); + row.setBackground(rowBg); + + View colorIndicator = new View(context); + colorIndicator.setLayoutParams(new LinearLayout.LayoutParams(dp(4), dp(20))); + ShapeDrawable indicatorBg = new ShapeDrawable(); + indicatorBg.setShape(ShapeDrawable.RECTANGLE); + indicatorBg.setColor(color); + indicatorBg.setCornerRadius(dp(2)); + colorIndicator.setBackground(indicatorBg); + row.addView(colorIndicator); + + TextView platformTv = new TextView(context); + platformTv.setText(platform); + setFixedTextSize(platformTv, 26); + platformTv.getPaint().setTextStyle(TextPaint.BOLD); + platformTv.setTextColor(0xFFFFFFFF); + platformTv.setPadding(dp(10), 0, dp(8), 0); + row.addView(platformTv); + + TextView valueTv = new TextView(context); + valueTv.setText(value); + setFixedTextSize(valueTv, 24); + valueTv.setTextColor(0xCCFFFFFF); + LinearLayout.LayoutParams valueLp = new LinearLayout.LayoutParams( + 0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f + ); + valueTv.setLayoutParams(valueLp); + row.addView(valueTv); + + TextView copyBtn = new TextView(context); + copyBtn.setText("Copy"); + setFixedTextSize(copyBtn, 22); + copyBtn.setTextColor(0xFF4a90d9); + copyBtn.setPadding(dp(8), dp(4), dp(8), dp(4)); + copyBtn.setClickable(true); + copyBtn.setOnClickListener(v -> { + MinecraftClient.getInstance().keyboard.setClipboard(value); + copyBtn.setText("✓"); + v.postDelayed(() -> copyBtn.setText("Copy"), 2000); + }); + row.addView(copyBtn); + + return row; + } + + private TextView createSmallActionButton(Context context, String text, int color) { + TextView btn = new TextView(context); + btn.setText(text); + setFixedTextSize(btn, 26); + btn.setTextColor(0xFFFFFFFF); + btn.setPadding(dp(14), dp(8), dp(14), dp(8)); + btn.setGravity(Gravity.CENTER); + + ShapeDrawable bg = new ShapeDrawable(); + bg.setShape(ShapeDrawable.RECTANGLE); + bg.setColor(color); + bg.setCornerRadius(dp(8)); + btn.setBackground(bg); + btn.setClickable(true); + + btn.setOnHoverListener((v, event) -> { + if (event.getAction() == MotionEvent.ACTION_HOVER_ENTER) { + bg.setColor(lightenColor(color, 0.2f)); + } else if (event.getAction() == MotionEvent.ACTION_HOVER_EXIT) { + bg.setColor(color); + } + return false; + }); + + return btn; + } + + private TextView createSectionLabel(Context context, String text) { + TextView label = new TextView(context); + label.setText(text); + setFixedTextSize(label, 30); + label.getPaint().setTextStyle(TextPaint.BOLD); + label.setTextColor(0xFFFFFFFF); + label.setPadding(0, dp(8), 0, dp(8)); + return label; + } + + private TextView createInputLabel(Context context, String text) { + TextView label = new TextView(context); + label.setText(text); + setFixedTextSize(label, 24); + label.setTextColor(0xAAFFFFFF); + label.setPadding(0, dp(6), 0, dp(4)); + return label; + } + + private EditText createEditText(Context context, String value, String hint, boolean singleLine) { + EditText edit = new EditText(context); + if (value != null) edit.setText(value); + edit.setHint(hint); + setFixedTextSize(edit, 26); + edit.setTextColor(0xFFFFFFFF); + edit.setHintTextColor(0x66FFFFFF); + edit.setPadding(dp(12), dp(10), dp(12), dp(10)); + edit.setSingleLine(singleLine); + + ShapeDrawable bg = new ShapeDrawable(); + bg.setShape(ShapeDrawable.RECTANGLE); + bg.setColor(0x26FFFFFF); + bg.setCornerRadius(dp(8)); + edit.setBackground(bg); + + LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + lp.setMargins(0, 0, 0, dp(4)); + edit.setLayoutParams(lp); + + return edit; + } + + private boolean isEmpty(String s) { + return s == null || s.trim().isEmpty(); + } + + private int lightenColor(int color, float factor) { + int a = (color >> 24) & 0xFF; + int r = Math.min(255, (int) (((color >> 16) & 0xFF) * (1 + factor))); + int g = Math.min(255, (int) (((color >> 8) & 0xFF) * (1 + factor))); + int b = Math.min(255, (int) ((color & 0xFF) * (1 + factor))); + return (a << 24) | (r << 16) | (g << 8) | b; + } + + private int dp(int value) { + return (int) (value * getContext().getResources().getDisplayMetrics().density); + } + + @Override + public boolean isPauseScreen() { + return false; + } + + @Override + public boolean shouldBlurBackground() { + return false; + } + + @Override + public boolean hasDefaultBackground() { + return false; + } +} + diff --git a/src/main/java/de/winniepat/citrus/gui/TestScreen.java b/src/main/java/de/winniepat/citrus/gui/TestScreen.java new file mode 100644 index 0000000..200b78e --- /dev/null +++ b/src/main/java/de/winniepat/citrus/gui/TestScreen.java @@ -0,0 +1,447 @@ +package de.winniepat.citrus.gui; + +import icyllis.modernui.core.Context; +import icyllis.modernui.fragment.Fragment; +import icyllis.modernui.graphics.Canvas; +import icyllis.modernui.graphics.Paint; +import icyllis.modernui.graphics.drawable.ShapeDrawable; +import icyllis.modernui.mc.ScreenCallback; +import icyllis.modernui.util.DataSet; +import icyllis.modernui.view.*; +import icyllis.modernui.widget.*; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.ingame.InventoryScreen; +import net.minecraft.entity.LivingEntity; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Test screen that renders a spinnable player preview with all cosmetics + * (capes, wings, halos, and hats). + * + * The player entity is rendered with all cosmetics automatically because + * the cosmetic feature renderers (WingsFeatureRenderer, HatFeatureRenderer, + * HaloFeatureRenderer) are already registered and will render on any player + * entity rendering. Capes are handled via the SkinTextures modification. + */ +public class TestScreen extends Fragment implements ScreenCallback { + + private static TestScreen activeInstance = null; + + private float mPlayerRotationY = 0f; + private float mPlayerRotationX = 0f; + private float mLastTouchX = 0f; + private float mLastTouchY = 0f; + private boolean mIsDragging = false; + private boolean mAutoSpinning = false; + private PlayerPreviewView mPlayerPreview; + + // Store the preview bounds for Minecraft rendering + private int mPreviewCenterX = 0; + private int mPreviewCenterY = 0; + private int mPreviewSize = 80; + private boolean mPreviewBoundsSet = false; + + @Override + public void onCreate(@Nullable DataSet savedInstanceState) { + super.onCreate(savedInstanceState); + activeInstance = this; + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (activeInstance == this) { + activeInstance = null; + mPreviewBoundsSet = false; + } + } + + @Override + public View onCreateView(@NotNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable DataSet savedInstanceState) { + Context context = requireContext(); + + FrameLayout root = new FrameLayout(context); + root.setLayoutParams(new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + )); + + // Semi-transparent background + ShapeDrawable backgroundDrawable = new ShapeDrawable(); + backgroundDrawable.setShape(ShapeDrawable.RECTANGLE); + backgroundDrawable.setColor(0x80000000); + root.setBackground(backgroundDrawable); + + // Main content container + LinearLayout contentContainer = new LinearLayout(context); + contentContainer.setOrientation(LinearLayout.VERTICAL); + contentContainer.setGravity(Gravity.CENTER); + FrameLayout.LayoutParams contentParams = new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ); + contentContainer.setLayoutParams(contentParams); + + // Title + TextView title = new TextView(context); + title.setText("Player Preview"); + title.setTextSize(28); + title.setTextColor(0xFFFFFFFF); + title.setGravity(Gravity.CENTER); + LinearLayout.LayoutParams titleParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + titleParams.setMargins(0, dp(20), 0, dp(10)); + title.setLayoutParams(titleParams); + contentContainer.addView(title); + + // Subtitle + TextView subtitle = new TextView(context); + subtitle.setText("Drag to rotate the player - Shows capes, wings, halos & hats"); + subtitle.setTextSize(14); + subtitle.setTextColor(0xAAFFFFFF); + subtitle.setGravity(Gravity.CENTER); + LinearLayout.LayoutParams subtitleParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + subtitleParams.setMargins(0, 0, 0, dp(20)); + subtitle.setLayoutParams(subtitleParams); + contentContainer.addView(subtitle); + + // Player preview container with border + FrameLayout previewContainer = new FrameLayout(context); + ShapeDrawable previewBackground = new ShapeDrawable(); + previewBackground.setShape(ShapeDrawable.RECTANGLE); + previewBackground.setColor(0x40000000); + previewBackground.setCornerRadius(dp(10)); + previewBackground.setStroke(dp(2), 0x60FFFFFF); + previewContainer.setBackground(previewBackground); + + LinearLayout.LayoutParams previewContainerParams = new LinearLayout.LayoutParams( + dp(300), + dp(400) + ); + previewContainerParams.gravity = Gravity.CENTER; + previewContainer.setLayoutParams(previewContainerParams); + + // Custom player preview view + mPlayerPreview = new PlayerPreviewView(context); + mPlayerPreview.setLayoutParams(new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + )); + previewContainer.addView(mPlayerPreview); + + contentContainer.addView(previewContainer); + + // Buttons container + LinearLayout buttonsContainer = new LinearLayout(context); + buttonsContainer.setOrientation(LinearLayout.HORIZONTAL); + buttonsContainer.setGravity(Gravity.CENTER); + LinearLayout.LayoutParams buttonsParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + buttonsParams.setMargins(0, dp(20), 0, 0); + buttonsContainer.setLayoutParams(buttonsParams); + + // Reset rotation button + Button resetButton = new Button(context); + resetButton.setText("Reset Rotation"); + resetButton.setTextSize(14); + ShapeDrawable buttonBackground = new ShapeDrawable(); + buttonBackground.setShape(ShapeDrawable.RECTANGLE); + buttonBackground.setColor(0x80FFFFFF); + buttonBackground.setCornerRadius(dp(5)); + resetButton.setBackground(buttonBackground); + resetButton.setOnClickListener(v -> { + mPlayerRotationY = 0f; + mPlayerRotationX = 0f; + }); + LinearLayout.LayoutParams resetParams = new LinearLayout.LayoutParams( + dp(150), + dp(40) + ); + resetParams.setMargins(0, 0, dp(10), 0); + resetButton.setLayoutParams(resetParams); + buttonsContainer.addView(resetButton); + + // Spin button (auto-rotate) + Button spinButton = new Button(context); + spinButton.setText("Auto Spin"); + spinButton.setTextSize(14); + ShapeDrawable spinBackground = new ShapeDrawable(); + spinBackground.setShape(ShapeDrawable.RECTANGLE); + spinBackground.setColor(0x8055AAFF); + spinBackground.setCornerRadius(dp(5)); + spinButton.setBackground(spinBackground); + spinButton.setOnClickListener(v -> { + mAutoSpinning = !mAutoSpinning; + spinButton.setText(mAutoSpinning ? "Stop Spin" : "Auto Spin"); + if (mAutoSpinning) { + startAutoSpin(); + } + }); + LinearLayout.LayoutParams spinParams = new LinearLayout.LayoutParams( + dp(150), + dp(40) + ); + spinParams.setMargins(dp(10), 0, 0, 0); + spinButton.setLayoutParams(spinParams); + buttonsContainer.addView(spinButton); + + contentContainer.addView(buttonsContainer); + + // Close button + Button closeButton = new Button(context); + closeButton.setText("Close"); + closeButton.setTextSize(14); + ShapeDrawable closeBackground = new ShapeDrawable(); + closeBackground.setShape(ShapeDrawable.RECTANGLE); + closeBackground.setColor(0x80FF5555); + closeBackground.setCornerRadius(dp(5)); + closeButton.setBackground(closeBackground); + closeButton.setOnClickListener(v -> + MinecraftClient.getInstance().execute(() -> + MinecraftClient.getInstance().setScreen(null) + ) + ); + LinearLayout.LayoutParams closeParams = new LinearLayout.LayoutParams( + dp(150), + dp(40) + ); + closeParams.gravity = Gravity.CENTER; + closeParams.setMargins(0, dp(10), 0, dp(20)); + closeButton.setLayoutParams(closeParams); + contentContainer.addView(closeButton); + + root.addView(contentContainer); + + return root; + } + + private void startAutoSpin() { + if (mPlayerPreview != null) { + mPlayerPreview.post(new Runnable() { + @Override + public void run() { + if (activeInstance == TestScreen.this && mAutoSpinning) { + mPlayerRotationY += 2f; + if (mPlayerRotationY > 360f) mPlayerRotationY -= 360f; + mPlayerPreview.postDelayed(this, 16); // ~60 FPS + } + } + }); + } + } + + /** + * Custom view that renders the player entity with cosmetics. + * Handles touch events for rotation. + */ + private class PlayerPreviewView extends View { + + public PlayerPreviewView(Context context) { + super(context); + } + + @Override + public boolean onTouchEvent(@NotNull MotionEvent event) { + float x = event.getX(); + float y = event.getY(); + + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + mIsDragging = true; + mLastTouchX = x; + mLastTouchY = y; + return true; + + case MotionEvent.ACTION_MOVE: + if (mIsDragging) { + float deltaX = x - mLastTouchX; + float deltaY = y - mLastTouchY; + + // Horizontal drag rotates around Y axis + mPlayerRotationY += deltaX * 0.5f; + + // Vertical drag rotates around X axis (limited range) + mPlayerRotationX += deltaY * 0.3f; + mPlayerRotationX = Math.max(-30f, Math.min(30f, mPlayerRotationX)); + + mLastTouchX = x; + mLastTouchY = y; + invalidate(); + } + return true; + + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + mIsDragging = false; + return true; + } + + return super.onTouchEvent(event); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + + // Calculate the center position in screen coordinates for Minecraft rendering + int[] location = new int[2]; + getLocationOnScreen(location); + + MinecraftClient client = MinecraftClient.getInstance(); + float guiScale = (float) client.getWindow().getScaleFactor(); + + // Convert ModernUI coordinates to Minecraft screen coordinates + int viewWidth = right - left; + int viewHeight = bottom - top; + + // Calculate position - the location is in window pixels, need to convert to scaled GUI coordinates + mPreviewCenterX = (int) ((location[0] + viewWidth / 2.0f) / guiScale); + mPreviewCenterY = (int) ((location[1] + viewHeight * 0.7f) / guiScale); + mPreviewSize = (int) (Math.min(viewWidth, viewHeight) / guiScale / 2.5f); + mPreviewBoundsSet = true; + + System.out.println("[TestScreen] Layout: location=" + location[0] + "," + location[1] + + " size=" + viewWidth + "x" + viewHeight + + " guiScale=" + guiScale + + " preview: center=" + mPreviewCenterX + "," + mPreviewCenterY + " size=" + mPreviewSize); + } + + @Override + protected void onDraw(@NotNull Canvas canvas) { + super.onDraw(canvas); + + // Draw background + Paint paint = Paint.obtain(); + paint.setColor(0x15FFFFFF); + canvas.drawRect(0, 0, getWidth(), getHeight(), paint); + paint.recycle(); + + // Keep redrawing for smooth updates + MinecraftClient client = MinecraftClient.getInstance(); + if (client.player != null) { + postInvalidateDelayed(16); + } + } + } + + /** + * Renders the player entity at the specified position with rotation. + * This method renders the player with all cosmetics (capes, wings, halos, hats) + * because the cosmetic feature renderers are already registered in the game. + */ + public static void renderPlayerEntity(DrawContext context, int x, int y, int size, + float rotationX, float rotationY, LivingEntity entity) { + if (entity == null) return; + + // Save original rotation values + float originalBodyYaw = entity.bodyYaw; + float originalYaw = entity.getYaw(); + float originalPitch = entity.getPitch(); + float originalPrevHeadYaw = entity.prevHeadYaw; + float originalHeadYaw = entity.headYaw; + + // Set rotation based on user input + entity.bodyYaw = 180.0F + rotationY; + entity.setYaw(180.0F + rotationY); + entity.setPitch(-rotationX); + entity.headYaw = entity.getYaw(); + entity.prevHeadYaw = entity.getYaw(); + + // Calculate scale based on entity height + float entityScale = entity.getHeight() > 2.0F ? 2.0F / entity.getHeight() : 1.0F; + + // Use InventoryScreen's drawEntity method for proper rendering + // This will automatically render all cosmetics (capes, wings, halos, hats) + // because the feature renderers are registered globally + InventoryScreen.drawEntity( + context, + x - size / 2, + y - size, + x + size / 2, + y + size / 4, + (int) (size * 0.8f * entityScale), + 0.0625F, + 0, 0, // mouseX, mouseY - not following mouse + entity + ); + + // Restore original rotation values + entity.bodyYaw = originalBodyYaw; + entity.setYaw(originalYaw); + entity.setPitch(originalPitch); + entity.prevHeadYaw = originalPrevHeadYaw; + entity.headYaw = originalHeadYaw; + } + + /** + * Get the current active TestScreen instance. + * Used by the render callback to access rotation values. + */ + public static TestScreen getActiveInstance() { + return activeInstance; + } + + public float getPlayerRotationY() { + return mPlayerRotationY; + } + + public float getPlayerRotationX() { + return mPlayerRotationX; + } + + public int getPreviewCenterX() { + return mPreviewCenterX; + } + + public int getPreviewCenterY() { + return mPreviewCenterY; + } + + public int getPreviewSize() { + return mPreviewSize; + } + + public boolean isPreviewBoundsSet() { + return mPreviewBoundsSet; + } + + private int dp(int value) { + Context ctx = getContext(); + if (ctx == null) return value; + return (int) (value * ctx.getResources().getDisplayMetrics().density); + } + + @Override + public boolean isPauseScreen() { + return false; + } + + @Override + public boolean shouldBlurBackground() { + return true; + } + + @Override + public boolean hasDefaultBackground() { + return false; + } + + /** + * Initialize the render callback for player preview rendering. + * Note: Rendering is now handled by ScreenRenderMixin. + * This method is kept for backward compatibility. + */ + public static void registerRenderer() { + // Rendering is now handled by ScreenRenderMixin + // This method is kept for backward compatibility + } +} diff --git a/src/main/java/de/winniepat/citrus/managers/ClientRegistrationManager.java b/src/main/java/de/winniepat/citrus/managers/ClientRegistrationManager.java new file mode 100644 index 0000000..3bd5556 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/managers/ClientRegistrationManager.java @@ -0,0 +1,411 @@ +package de.winniepat.citrus.managers; + +import com.google.gson.*; +import de.winniepat.citrus.Client; +import de.winniepat.citrus.utils.SecurityUtils; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.session.Session; +import net.minecraft.util.Util; + +import java.io.OutputStream; +import java.net.*; +import java.util.*; +import java.util.concurrent.*; + +public class ClientRegistrationManager { + private static final String API_BASE_URL = Client.API_URL; + private static final Gson GSON = new GsonBuilder().create(); + + private static boolean isRegistered = false; + private static boolean isShuttingDown = false; + private static Thread heartbeatThread; + + private static final Set ONLINE_CITRUS_USERS = new HashSet<>(); + private static long lastOnlineListUpdate = 0; + private static final long ONLINE_LIST_CACHE_TIME = 30000; + + public static void register() { + MinecraftClient client = MinecraftClient.getInstance(); + + Session session = client.getSession(); + if (session == null) { + Client.logger.error("Cannot register: No session available"); + return; + } + + String playerName = session.getUsername(); + UUID playerUUID = session.getUuidOrNull(); + + if (playerUUID == null) { + Client.logger.error("Cannot register: No UUID in session"); + return; + } + + Client.debugLog("Registering as online user: " + playerName + " (" + playerUUID + ")"); + + CompletableFuture.runAsync(() -> { + try { + URL url = new URI(API_BASE_URL + "/client/register").toURL(); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("x-daily-key", SecurityUtils.getDailyKey()); + conn.setDoOutput(true); + conn.setConnectTimeout(5000); + conn.setReadTimeout(5000); + + JsonObject json = new JsonObject(); + json.addProperty("uuid", playerUUID.toString()); + json.addProperty("name", playerName); + + try (OutputStream os = conn.getOutputStream()) { + os.write(GSON.toJson(json).getBytes()); + os.flush(); + } + + if (conn.getResponseCode() == 200) { + isRegistered = true; + + Client.debugLog("Successfully registered as online user"); + + startHeartbeat(); + + updateOnlineUsersList(); + } else { + Client.logger.error("Failed to register: HTTP {}", conn.getResponseCode()); + } + + conn.disconnect(); + } catch (Exception e) { + Client.logger.error("Registration failed: ", e); + } + }, Util.getMainWorkerExecutor()); + } + + public static void updatePlayerInfo() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client.player == null) return; + + if (isRegistered) { + UUID worldUUID = client.player.getUuid(); + String worldName = client.player.getName().getString(); + + CompletableFuture.runAsync(() -> { + try { + URL url = new URI(API_BASE_URL + "/client/register").toURL(); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("x-daily-key", SecurityUtils.getDailyKey()); + conn.setDoOutput(true); + conn.setConnectTimeout(3000); + + JsonObject json = new JsonObject(); + json.addProperty("uuid", worldUUID.toString()); + json.addProperty("name", worldName); + + try (OutputStream os = conn.getOutputStream()) { + os.write(GSON.toJson(json).getBytes()); + os.flush(); + } + + if (conn.getResponseCode() == 200) { + Client.debugLog("Updated player info for world: " + worldName); + } + + conn.disconnect(); + } catch (Exception e) { + Client.logger.error("Error updating player info: ", e); + } + }); + } + } + + private static void startHeartbeat() { + if (heartbeatThread != null && heartbeatThread.isAlive()) { + heartbeatThread.interrupt(); + } + + heartbeatThread = new Thread(() -> { + while (!isShuttingDown && isRegistered) { + try { + Thread.sleep(30000); + sendHeartbeat(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + }, "Citrus-Heartbeat"); + + heartbeatThread.setDaemon(true); + heartbeatThread.start(); + } + + private static void sendHeartbeat() { + if (!isRegistered) return; + + UUID uuid = getCurrentUUID(); + if (uuid == null) return; + + CompletableFuture.runAsync(() -> { + try { + URL url = new URI(API_BASE_URL + "/client/heartbeat").toURL(); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("x-daily-key", SecurityUtils.getDailyKey()); + conn.setDoOutput(true); + conn.setConnectTimeout(3000); + conn.setReadTimeout(3000); + + JsonObject json = new JsonObject(); + json.addProperty("uuid", uuid.toString()); + + try (OutputStream os = conn.getOutputStream()) { + os.write(GSON.toJson(json).getBytes()); + os.flush(); + } + + if (conn.getResponseCode() != 200) { + Client.logger.error("Heartbeat failed: HTTP {} {}", conn.getResponseCode(), conn.getResponseMessage()); + } + + conn.disconnect(); + + } catch (Exception e) { + Client.logger.error("Heartbeat failed: ", e); + } + }); + } + + public static void unregister() { + if (!isRegistered) return; + + isShuttingDown = true; + + UUID uuid = getCurrentUUID(); + if (uuid == null) return; + + Client.debugLog("Unregistering from online users..."); + + CompletableFuture.runAsync(() -> { + try { + URL url = new URI(API_BASE_URL + "/client/unregister").toURL(); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("x-daily-key", SecurityUtils.getDailyKey()); + conn.setDoOutput(true); + conn.setConnectTimeout(3000); + conn.setReadTimeout(3000); + + JsonObject json = new JsonObject(); + json.addProperty("uuid", uuid.toString()); + + try (OutputStream os = conn.getOutputStream()) { + os.write(GSON.toJson(json).getBytes()); + os.flush(); + } + + Client.debugLog("Unregistered successfully"); + + conn.disconnect(); + + } catch (Exception e) { + Client.logger.error("Unregistration failed: ", e); + } + }); + } + + private static UUID getCurrentUUID() { + MinecraftClient client = MinecraftClient.getInstance(); + + if (client.player != null) { + return client.player.getUuid(); + } + + Session session = client.getSession(); + if (session != null) { + return session.getUuidOrNull(); + } + + return null; + } + + public static List getOnlineCitrusUsers() { + if (System.currentTimeMillis() - lastOnlineListUpdate >= ONLINE_LIST_CACHE_TIME) { + updateOnlineUsersList(); + } + + synchronized (ONLINE_CITRUS_USERS) { + return new ArrayList<>(ONLINE_CITRUS_USERS); + } + } + + private static void updateOnlineUsersList() { + CompletableFuture.runAsync(() -> { + try { + URL url = new URI(API_BASE_URL + "/client/online").toURL(); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setRequestProperty("x-daily-key", SecurityUtils.getDailyKey()); + conn.setConnectTimeout(5000); + conn.setReadTimeout(5000); + + if (conn.getResponseCode() == 200) { + String response = new String(conn.getInputStream().readAllBytes()); + JsonObject[] users = GSON.fromJson(response, JsonObject[].class); + + List newList = new ArrayList<>(); + if (users != null) { + for (JsonObject user : users) { + try { + UUID uuid = UUID.fromString(user.get("player_uuid").getAsString()); + newList.add(uuid); + } catch (Exception e) { + Client.logger.error("Invalid UUID in online list: {}", user); + } + } + } + + synchronized (ONLINE_CITRUS_USERS) { + ONLINE_CITRUS_USERS.clear(); + ONLINE_CITRUS_USERS.addAll(newList); + } + + lastOnlineListUpdate = System.currentTimeMillis(); + + Client.debugLog("Updated online users list: " + newList.size() + " users"); + } + + conn.disconnect(); + } catch (Exception e) { + Client.logger.error("Failed to update online users list: {}", String.valueOf(e)); + } + }); + } + + public static List filterOnlineUsers(List playerUUIDs) { + if (playerUUIDs.isEmpty()) return new ArrayList<>(); + + CompletableFuture> future = CompletableFuture.supplyAsync(() -> { + try { + URL url = new URI(API_BASE_URL + "/client/online-check").toURL(); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("x-daily-key", SecurityUtils.getDailyKey()); + conn.setDoOutput(true); + conn.setConnectTimeout(5000); + conn.setReadTimeout(5000); + + List uuidStrings = new ArrayList<>(); + for (UUID uuid : playerUUIDs) { + uuidStrings.add(uuid.toString()); + } + + JsonObject json = new JsonObject(); + json.add("uuids", GSON.toJsonTree(uuidStrings)); + + try (OutputStream os = conn.getOutputStream()) { + os.write(GSON.toJson(json).getBytes()); + os.flush(); + } + + if (conn.getResponseCode() == 200) { + String response = new String(conn.getInputStream().readAllBytes()); + String[] onlineUUIDs = GSON.fromJson(response, String[].class); + + List result = new ArrayList<>(); + for (String uuidStr : onlineUUIDs) { + try { + result.add(UUID.fromString(uuidStr)); + } catch (Exception e) { + Client.logger.error("Invalid UUID in filter response: {}", uuidStr); + } + } + + return result; + } + + conn.disconnect(); + } catch (Exception e) { + Client.logger.error("Failed to filter online users: ", e); + } + + return new ArrayList<>(); + }, Util.getMainWorkerExecutor()); + + try { + return future.get(3, TimeUnit.SECONDS); + } catch (Exception e) { + Client.logger.error("Timeout filtering online users: ", e); + return new ArrayList<>(); + } + } + + public static boolean isPlayerOnlineWithCitrus(UUID uuid) { + if (uuid == null) return false; + + MinecraftClient client = MinecraftClient.getInstance(); + if (client.player != null && client.player.getUuid().equals(uuid)) { + return isRegistered(); + } + + if (System.currentTimeMillis() - lastOnlineListUpdate >= ONLINE_LIST_CACHE_TIME) { + updateOnlineUsersList(); + } + + synchronized (ONLINE_CITRUS_USERS) { + return ONLINE_CITRUS_USERS.contains(uuid); + } + } + + public static List getCitrusUsersInWorld() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client.world == null) return new ArrayList<>(); + + List result = new ArrayList<>(); + + for (net.minecraft.entity.player.PlayerEntity player : client.world.getPlayers()) { + if (isPlayerOnlineWithCitrus(player.getUuid())) { + result.add(player.getUuid()); + } + } + + return result; + } + + public static boolean shouldSyncWithPlayer(UUID uuid) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client.player != null && client.player.getUuid().equals(uuid)) { + return false; + } + + return isPlayerOnlineWithCitrus(uuid); + } + + public static void forceUpdateOnlineUsers() { + updateOnlineUsersList(); + } + + public static boolean isRegistered() { + return isRegistered; + } + + public static String getCurrentPlayerName() { + MinecraftClient client = MinecraftClient.getInstance(); + + if (client.player != null) { + return client.player.getName().getString(); + } + + Session session = client.getSession(); + if (session != null) { + return session.getUsername(); + } + + return "Unknown"; + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/managers/ConfigManager.java b/src/main/java/de/winniepat/citrus/managers/ConfigManager.java new file mode 100644 index 0000000..b31982f --- /dev/null +++ b/src/main/java/de/winniepat/citrus/managers/ConfigManager.java @@ -0,0 +1,84 @@ +package de.winniepat.citrus.managers; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import de.winniepat.citrus.Client; +import de.winniepat.citrus.ModConfig; +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; + +public class ConfigManager { + private static final Gson GSON = new GsonBuilder() + .setPrettyPrinting() + .create(); + private static final File CONFIG_FILE = new File(Client.CONFIG_DIR, "settings.json"); + + public static ModConfig config = new ModConfig(); + + public static void load() { + if (!CONFIG_FILE.exists()) { + Client.debugLog("Config file not found, creating default"); + save(); + return; + } + + try (FileReader reader = new FileReader(CONFIG_FILE)) { + config = GSON.fromJson(reader, ModConfig.class); + + if (config.betaConfig == null) { + config.betaConfig = new ModConfig.BetaConfig(); + Client.debugLog("Initialized new beta config for existing user"); + } + + Client.debugLog("Config loaded successfully"); + + } catch (IOException e) { + Client.logger.error("Error while loading config", e); + config = new ModConfig(); + } + } + + public static void save() { + if (!Client.CONFIG_DIR.exists()) { + Client.CONFIG_DIR.mkdirs(); + } + + try (FileWriter writer = new FileWriter(CONFIG_FILE)) { + GSON.toJson(config, writer); + Client.logger.debug("Config saved successfully"); + } catch (IOException e) { + Client.logger.error("Error while saving config", e); + } + } + + public static ModConfig.BetaConfig getBetaConfig() { + return config.betaConfig; + } + + public static void updateBetaConfig(ModConfig.BetaConfig newBetaConfig) { + config.betaConfig = newBetaConfig; + save(); + } + + public static void saveBetaToken(String token, long expiresAt) { + config.betaConfig.verificationToken = token; + config.betaConfig.tokenExpiresAt = expiresAt; + save(); + } + + public static boolean hasBetaKey() { + return config.betaConfig != null && + config.betaConfig.betaKey != null && + !config.betaConfig.betaKey.trim().isEmpty(); + } + + public static boolean hasValidToken() { + if (config.betaConfig == null || config.betaConfig.verificationToken == null || config.betaConfig.verificationToken.trim().isEmpty()) { + return false; + } + + return System.currentTimeMillis() < (config.betaConfig.tokenExpiresAt - (5 * 60 * 1000)); + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/managers/FriendManager.java b/src/main/java/de/winniepat/citrus/managers/FriendManager.java new file mode 100644 index 0000000..51d5db3 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/managers/FriendManager.java @@ -0,0 +1,282 @@ +package de.winniepat.citrus.managers; + +import com.google.gson.*; +import de.winniepat.citrus.Client; +import de.winniepat.citrus.utils.SecurityUtils; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.network.ServerInfo; +import net.minecraft.util.Util; + +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.CompletableFuture; + +public class FriendManager { + private static final String API_BASE_URL = Client.API_URL; + private static final Gson GSON = new GsonBuilder().create(); + + private static final List FRIENDS = Collections.synchronizedList(new ArrayList<>()); + private static final List INCOMING_REQUESTS = Collections.synchronizedList(new ArrayList<>()); + + private static long lastRefresh = 0; + private static final long REFRESH_INTERVAL = 60000; + + public static void init() { + refresh(); + } + + public static String getCurrentServerIp() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client.getCurrentServerEntry() != null) { + ServerInfo serverInfo = client.getCurrentServerEntry(); + return serverInfo.address; + } + return null; + } + + public static CompletableFuture updateServerStatus() { + UUID uuid = getCurrentUuid(); + if (uuid == null) return CompletableFuture.completedFuture(null); + + String serverIp = getCurrentServerIp(); + + return CompletableFuture.runAsync(() -> { + try { + JsonObject json = new JsonObject(); + json.addProperty("uuid", uuid.toString()); + json.addProperty("serverIp", serverIp != null ? serverIp : ""); + + URL url = new URI(API_BASE_URL + "/friends/server").toURL(); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8"); + conn.setRequestProperty("x-daily-key", SecurityUtils.getDailyKey()); + conn.setDoOutput(true); + conn.setConnectTimeout(5000); + conn.setReadTimeout(5000); + + try (OutputStream os = conn.getOutputStream()) { + os.write(GSON.toJson(json).getBytes(StandardCharsets.UTF_8)); + } + + int responseCode = conn.getResponseCode(); + if (responseCode == 200) { + Client.debugLog("Updated server status: " + (serverIp != null ? serverIp : "none")); + } + conn.disconnect(); + } catch (Exception e) { + Client.logger.error("Failed to update server status", e); + } + }, Util.getMainWorkerExecutor()); + } + + public static CompletableFuture refresh() { + UUID uuid = getCurrentUuid(); + if (uuid == null) return CompletableFuture.completedFuture(null); + + lastRefresh = System.currentTimeMillis(); + + return CompletableFuture.allOf(fetchFriends(uuid), fetchIncomingRequests(uuid)); + } + + private static UUID getCurrentUuid() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client.player != null) return client.player.getUuid(); + if (client.getSession() != null) return client.getSession().getUuidOrNull(); + return null; + } + + private static CompletableFuture fetchFriends(UUID uuid) { + return CompletableFuture.runAsync(() -> { + try { + URL url = new URI(API_BASE_URL + "/friends/list/" + uuid.toString()).toURL(); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setRequestProperty("x-daily-key", SecurityUtils.getDailyKey()); + conn.setConnectTimeout(5000); + conn.setReadTimeout(5000); + + if (conn.getResponseCode() == 200) { + String response = new String(conn.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + Friend[] friendsArray = GSON.fromJson(response, Friend[].class); + + if (friendsArray != null) { + synchronized (FRIENDS) { + FRIENDS.clear(); + Collections.addAll(FRIENDS, friendsArray); + } + Client.debugLog("Fetched " + FRIENDS.size() + " friends"); + } + } + conn.disconnect(); + } catch (Exception e) { + Client.logger.error("Failed to fetch friends for {}", uuid, e); + } + }, Util.getMainWorkerExecutor()); + } + + private static CompletableFuture fetchIncomingRequests(UUID uuid) { + return CompletableFuture.runAsync(() -> { + try { + URL url = new URI(API_BASE_URL + "/friends/requests/" + uuid.toString()).toURL(); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setRequestProperty("x-daily-key", SecurityUtils.getDailyKey()); + conn.setConnectTimeout(5000); + conn.setReadTimeout(5000); + + if (conn.getResponseCode() == 200) { + String response = new String(conn.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + FriendRequest[] requests = GSON.fromJson(response, FriendRequest[].class); + + if (requests != null) { + synchronized (INCOMING_REQUESTS) { + INCOMING_REQUESTS.clear(); + Collections.addAll(INCOMING_REQUESTS, requests); + } + Client.debugLog("Fetched " + INCOMING_REQUESTS.size() + " incoming requests"); + } + } + conn.disconnect(); + } catch (Exception e) { + Client.logger.error("Failed to fetch friend requests for {}", uuid, e); + } + }, Util.getMainWorkerExecutor()); + } + + private static CompletableFuture postJson(String endpoint, JsonObject json) { + return CompletableFuture.supplyAsync(() -> { + try { + URL url = new URI(API_BASE_URL + endpoint).toURL(); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8"); + conn.setRequestProperty("x-daily-key", SecurityUtils.getDailyKey()); + conn.setDoOutput(true); + conn.setConnectTimeout(5000); + conn.setReadTimeout(5000); + + try (OutputStream os = conn.getOutputStream()) { + os.write(GSON.toJson(json).getBytes(StandardCharsets.UTF_8)); + } + + int responseCode = conn.getResponseCode(); + if (responseCode == 200) { + conn.disconnect(); + return null; + } else { + String errorMsg = "Server error (" + responseCode + ")"; + try (InputStream es = (responseCode >= 400 ? conn.getErrorStream() : conn.getInputStream())) { + if (es != null) { + String response = new String(es.readAllBytes(), StandardCharsets.UTF_8); + JsonObject obj = GSON.fromJson(response, JsonObject.class); + if (obj != null && obj.has("error")) { + errorMsg = obj.get("error").getAsString(); + } + } + } + Client.debugLog("Friend Action Failed: " + errorMsg); + conn.disconnect(); + return errorMsg; + } + } catch (Exception e) { + Client.debugLog("Friend Action Exception: " + e.getMessage()); + return "Connection failed: " + e.getMessage(); + } + }, Util.getMainWorkerExecutor()); + } + + public static CompletableFuture sendRequest(String friendName) { + UUID uuid = getCurrentUuid(); + if (uuid == null) return CompletableFuture.completedFuture("Not logged in"); + + JsonObject json = new JsonObject(); + json.addProperty("uuid", uuid.toString()); + json.addProperty("friendName", friendName); + + return postJson("/friends/request", json); + } + + public static CompletableFuture acceptRequest(UUID friendUuid) { + UUID uuid = getCurrentUuid(); + if (uuid == null) return CompletableFuture.completedFuture("Not logged in"); + + JsonObject json = new JsonObject(); + json.addProperty("uuid", uuid.toString()); + json.addProperty("friendUuid", friendUuid.toString()); + + return postJson("/friends/accept", json).thenCompose(error -> { + if (error == null) return refresh().thenApply(v -> null); + return CompletableFuture.completedFuture(error); + }); + } + + public static CompletableFuture removeFriend(UUID friendUuid) { + UUID uuid = getCurrentUuid(); + if (uuid == null) return CompletableFuture.completedFuture("Not logged in"); + + JsonObject json = new JsonObject(); + json.addProperty("uuid", uuid.toString()); + json.addProperty("friendUuid", friendUuid.toString()); + + return postJson("/friends/remove", json).thenCompose(error -> { + if (error == null) return refresh().thenApply(v -> null); + return CompletableFuture.completedFuture(error); + }); + } + + public static List getFriends() { + if (System.currentTimeMillis() - lastRefresh > REFRESH_INTERVAL) { + refresh(); + } + synchronized (FRIENDS) { + return new ArrayList<>(FRIENDS); + } + } + + public static List getIncomingRequests() { + synchronized (INCOMING_REQUESTS) { + return new ArrayList<>(INCOMING_REQUESTS); + } + } + + public static class Friend { + public String player_uuid; + public String player_name; + public int is_online; + public String server_ip; + + public UUID getUuid() { + return UUID.fromString(player_uuid); + } + + public boolean isOnline() { + return this.is_online == 1; + } + + public String getServerDisplay() { + if (!isOnline() || server_ip == null || server_ip.isEmpty()) { + return null; + } + String display = server_ip; + if (display.endsWith(":25565")) { + display = display.substring(0, display.length() - 6); + } + return display; + } + } + + public static class FriendRequest { + public String player_uuid; + public String player_name; + + public UUID getUuid() { + return UUID.fromString(player_uuid); + } + } +} diff --git a/src/main/java/de/winniepat/citrus/managers/FullbrightManager.java b/src/main/java/de/winniepat/citrus/managers/FullbrightManager.java new file mode 100644 index 0000000..bb7e257 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/managers/FullbrightManager.java @@ -0,0 +1,28 @@ +package de.winniepat.citrus.managers; + +import net.minecraft.client.MinecraftClient; +import net.minecraft.text.Text; + +public class FullbrightManager { + private static final double BRIGHT_VALUE = 15.0; + private static final double NORMAL_VALUE = 1.0; + + public static void toggle(MinecraftClient client) { + ConfigManager.config.fullbrightEnabled = !ConfigManager.config.fullbrightEnabled; + ConfigManager.save(); + + apply(client); + + if (client.player != null) { + String status = ConfigManager.config.fullbrightEnabled ? "§aON" : "§cOFF"; + client.player.sendMessage(Text.literal("Fullbright: " + status), true); + } + } + + public static void apply(MinecraftClient client) { + if (client == null || client.options == null) return; + + double target = ConfigManager.config.fullbrightEnabled ? BRIGHT_VALUE : NORMAL_VALUE; + client.options.getGamma().setValue(target); + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/managers/ProfileManager.java b/src/main/java/de/winniepat/citrus/managers/ProfileManager.java new file mode 100644 index 0000000..0ad62d9 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/managers/ProfileManager.java @@ -0,0 +1,212 @@ +package de.winniepat.citrus.managers; + +import com.google.gson.*; +import de.winniepat.citrus.Client; +import de.winniepat.citrus.utils.SecurityUtils; +import net.minecraft.client.MinecraftClient; +import net.minecraft.util.Util; + +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; + +public class ProfileManager { + private static final String API_BASE_URL = Client.API_URL; + private static final Gson GSON = new GsonBuilder().create(); + + private static final Map PROFILE_CACHE = new ConcurrentHashMap<>(); + private static final long CACHE_EXPIRY = 300000; + private static final Map CACHE_TIMESTAMPS = new ConcurrentHashMap<>(); + + public static class PlayerProfile { + public String uuid; + public String playerName; + public String bio; + public String discord; + public String twitter; + public String youtube; + public String twitch; + public String instagram; + public String github; + public String website; + public String createdAt; + public String updatedAt; + + public PlayerProfile() {} + + public PlayerProfile(String uuid, String playerName) { + this.uuid = uuid; + this.playerName = playerName; + this.bio = ""; + this.discord = ""; + this.twitter = ""; + this.youtube = ""; + this.twitch = ""; + this.instagram = ""; + this.github = ""; + this.website = ""; + } + + public boolean hasSocialLinks() { + return !isEmpty(discord) || !isEmpty(twitter) || !isEmpty(youtube) + || !isEmpty(twitch) || !isEmpty(instagram) || !isEmpty(github) || !isEmpty(website); + } + + private boolean isEmpty(String s) { + return s == null || s.trim().isEmpty(); + } + } + + public static class SearchResult { + public String uuid; + public String playerName; + public String bio; + public boolean hasSocials; + } + + public static CompletableFuture getProfile(UUID uuid) { + if (PROFILE_CACHE.containsKey(uuid)) { + Long timestamp = CACHE_TIMESTAMPS.get(uuid); + if (timestamp != null && System.currentTimeMillis() - timestamp < CACHE_EXPIRY) { + return CompletableFuture.completedFuture(PROFILE_CACHE.get(uuid)); + } + } + + return CompletableFuture.supplyAsync(() -> { + try { + URL url = new URI(API_BASE_URL + "/profiles/" + uuid.toString()).toURL(); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setRequestProperty("x-daily-key", SecurityUtils.getDailyKey()); + conn.setConnectTimeout(5000); + conn.setReadTimeout(5000); + + if (conn.getResponseCode() == 200) { + String response = new String(conn.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + JsonObject json = GSON.fromJson(response, JsonObject.class); + + if (json.has("success") && json.get("success").getAsBoolean()) { + PlayerProfile profile = GSON.fromJson(json.get("profile"), PlayerProfile.class); + PROFILE_CACHE.put(uuid, profile); + CACHE_TIMESTAMPS.put(uuid, System.currentTimeMillis()); + return profile; + } + } else if (conn.getResponseCode() == 404) { + return null; + } + conn.disconnect(); + } catch (Exception e) { + Client.logger.error("Failed to fetch profile for {}", uuid, e); + } + return null; + }, Util.getMainWorkerExecutor()); + } + + public static CompletableFuture getOwnProfile() { + UUID uuid = getCurrentUuid(); + if (uuid == null) return CompletableFuture.completedFuture(null); + return getProfile(uuid); + } + + public static CompletableFuture updateProfile(PlayerProfile profile) { + return CompletableFuture.supplyAsync(() -> { + try { + UUID uuid = getCurrentUuid(); + if (uuid == null) return "Not logged in"; + + URL url = new URI(API_BASE_URL + "/profiles/update").toURL(); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8"); + conn.setRequestProperty("x-daily-key", SecurityUtils.getDailyKey()); + conn.setDoOutput(true); + conn.setConnectTimeout(5000); + conn.setReadTimeout(5000); + + JsonObject payload = new JsonObject(); + payload.addProperty("uuid", uuid.toString()); + payload.addProperty("playerName", MinecraftClient.getInstance().getSession().getUsername()); + payload.addProperty("bio", profile.bio); + payload.addProperty("discord", profile.discord); + payload.addProperty("twitter", profile.twitter); + payload.addProperty("youtube", profile.youtube); + payload.addProperty("twitch", profile.twitch); + payload.addProperty("instagram", profile.instagram); + payload.addProperty("github", profile.github); + payload.addProperty("website", profile.website); + + try (OutputStream os = conn.getOutputStream()) { + os.write(GSON.toJson(payload).getBytes(StandardCharsets.UTF_8)); + } + + if (conn.getResponseCode() == 200) { + String response = new String(conn.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + JsonObject json = GSON.fromJson(response, JsonObject.class); + + if (json.has("success") && json.get("success").getAsBoolean()) { + PROFILE_CACHE.put(uuid, profile); + CACHE_TIMESTAMPS.put(uuid, System.currentTimeMillis()); + return null; + } else { + return json.has("error") ? json.get("error").getAsString() : "Unknown error"; + } + } else { + return "Server error: " + conn.getResponseCode(); + } + } catch (Exception e) { + Client.logger.error("Failed to update profile", e); + return "Failed to update profile: " + e.getMessage(); + } + }, Util.getMainWorkerExecutor()); + } + + public static CompletableFuture> searchProfiles(String query) { + return CompletableFuture.supplyAsync(() -> { + List results = new ArrayList<>(); + try { + String encodedQuery = java.net.URLEncoder.encode(query, StandardCharsets.UTF_8); + URL url = new URI(API_BASE_URL + "/profiles/search?q=" + encodedQuery).toURL(); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setRequestProperty("x-daily-key", SecurityUtils.getDailyKey()); + conn.setConnectTimeout(5000); + conn.setReadTimeout(5000); + + if (conn.getResponseCode() == 200) { + String response = new String(conn.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + JsonObject json = GSON.fromJson(response, JsonObject.class); + + if (json.has("success") && json.get("success").getAsBoolean()) { + JsonArray array = json.getAsJsonArray("results"); + for (JsonElement element : array) { + SearchResult result = GSON.fromJson(element, SearchResult.class); + results.add(result); + } + } + } + conn.disconnect(); + } catch (Exception e) { + Client.logger.error("Failed to search profiles", e); + } + return results; + }, Util.getMainWorkerExecutor()); + } + + public static void invalidateCache(UUID uuid) { + PROFILE_CACHE.remove(uuid); + CACHE_TIMESTAMPS.remove(uuid); + } + + private static UUID getCurrentUuid() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client.player != null) return client.player.getUuid(); + if (client.getSession() != null) return client.getSession().getUuidOrNull(); + return null; + } +} + diff --git a/src/main/java/de/winniepat/citrus/managers/RankManager.java b/src/main/java/de/winniepat/citrus/managers/RankManager.java new file mode 100644 index 0000000..9f19e27 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/managers/RankManager.java @@ -0,0 +1,98 @@ +package de.winniepat.citrus.managers; + +import de.winniepat.citrus.Client; +import de.winniepat.citrus.utils.SecurityUtils; +import net.minecraft.client.MinecraftClient; + +import java.net.URI; +import java.net.http.*; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; + +public class RankManager { + public static final ConcurrentHashMap rankCache = new ConcurrentHashMap<>(); + private static final HttpClient client = HttpClient.newHttpClient(); + + public static String getRankIcon(UUID uuid) { + String rank = rankCache.get(uuid); + + if (rank == null) { + rankCache.put(uuid, "loading"); + fetchRank(uuid); + return " "; + } + + if (rank.equals("loading")) return " "; + + return switch (rank) { + case "owner" -> "\uE001"; + case "admin" -> "\uE002"; + case "vip" -> "\uE003"; + case "dev" -> "\uE004"; + case "bughunter" -> "\uE005"; + case "default" -> "\uE099"; + default -> ""; + }; + } + + public static String getRank(UUID uuid) { + String rank = rankCache.get(uuid); + if (rank == null) { + rankCache.put(uuid, "loading"); + fetchRank(uuid); + return "loading"; + } + return rank; + } + + private static void fetchRank(UUID uuid) { + CompletableFuture.runAsync(() -> { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(Client.API_URL + "/rank/" + uuid.toString())) + .header("x-daily-key", SecurityUtils.getDailyKey()) + .GET() + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() == 200) { + String rawRank = response.body().trim().toLowerCase(); + + if (rawRank.equals(rankCache.get(uuid))) { + return; + } + + Client.debugLog("Received Rank Content: '" + rawRank + "' (Length: " + rawRank.length() + ")"); + + rankCache.put(uuid, rawRank); + + MinecraftClient.getInstance().execute(() -> { + var world = MinecraftClient.getInstance().world; + if (world != null) { + var player = world.getPlayerByUuid(uuid); + if (player != null) { + player.setCustomNameVisible(player.isCustomNameVisible()); + Client.debugLog("Refreshed nametag for: " + player.getName().getString()); + } + } + }); + } else { + rankCache.put(uuid, "none"); + } + } catch (Exception e) { + Client.debugLog("Failed to fetch rank: " + e.getMessage()); + rankCache.put(uuid, "none"); + } + }); + } + + public static void refreshRankIfChanged() { + if (MinecraftClient.getInstance().getNetworkHandler() == null) return; + + MinecraftClient.getInstance().getNetworkHandler().getPlayerList().forEach(playerListEntry -> { + fetchRank(playerListEntry.getProfile().getId()); + }); + } +} diff --git a/src/main/java/de/winniepat/citrus/managers/WallpaperManager.java b/src/main/java/de/winniepat/citrus/managers/WallpaperManager.java new file mode 100644 index 0000000..a165249 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/managers/WallpaperManager.java @@ -0,0 +1,415 @@ +package de.winniepat.citrus.managers; + +import de.winniepat.citrus.Client; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.texture.NativeImage; +import net.minecraft.client.texture.NativeImageBackedTexture; +import net.minecraft.util.Identifier; +import net.minecraft.util.Util; +import org.w3c.dom.*; + +import javax.xml.parsers.*; +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URL; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.function.Consumer; + +public class WallpaperManager { + private static final String CDN_WALLPAPERS_URL = Client.CDN_URL + "/wallpapers"; + private static final String MOD_ID = "citrus"; + private static final String CACHE_DIR_NAME = "citrus/wallpapers"; + + private static final Map WALLPAPER_CACHE = new ConcurrentHashMap<>(); + private static final Map WALLPAPER_DATA_CACHE = new ConcurrentHashMap<>(); + private static final Set LOADING_WALLPAPERS = ConcurrentHashMap.newKeySet(); + private static final Set FAILED_WALLPAPERS = ConcurrentHashMap.newKeySet(); + private static final List AVAILABLE_WALLPAPERS = new CopyOnWriteArrayList<>(); + + private static boolean wallpapersListFetched = false; + private static boolean isInitialized = false; + private static final List onWallpapersLoadedCallbacks = new CopyOnWriteArrayList<>(); + + private static Path cacheDir; + + public static void init() { + if (isInitialized) return; + isInitialized = true; + + initCacheDirectory(); + + fetchWallpaperListFromCDN(); + } + + private static void initCacheDirectory() { + try { + Path runDir = MinecraftClient.getInstance().runDirectory.toPath(); + cacheDir = runDir.resolve(CACHE_DIR_NAME); + Files.createDirectories(cacheDir); + Client.debugLog("Wallpaper cache directory: " + cacheDir); + } catch (Exception e) { + Client.logger.error("Failed to create wallpaper cache directory", e); + cacheDir = null; + } + } + + private static boolean isWallpaperCachedOnDisk(String wallpaperName) { + if (cacheDir == null) return false; + Path cachedFile = cacheDir.resolve(wallpaperName); + return Files.exists(cachedFile) && Files.isRegularFile(cachedFile); + } + + private static byte[] loadWallpaperFromDisk(String wallpaperName) { + if (cacheDir == null) return null; + try { + Path cachedFile = cacheDir.resolve(wallpaperName); + if (Files.exists(cachedFile)) { + byte[] data = Files.readAllBytes(cachedFile); + Client.debugLog("Loaded wallpaper from disk cache: " + wallpaperName + " (" + data.length + " bytes)"); + return data; + } + } catch (Exception e) { + Client.logger.error("Failed to load wallpaper from disk cache: {}", wallpaperName, e); + } + return null; + } + + private static void saveWallpaperToDisk(String wallpaperName, byte[] data) { + if (cacheDir == null || data == null || data.length == 0) return; + try { + Path cachedFile = cacheDir.resolve(wallpaperName); + Files.write(cachedFile, data); + Client.debugLog("Saved wallpaper to disk cache: " + wallpaperName + " (" + data.length + " bytes)"); + } catch (Exception e) { + Client.logger.error("Failed to save wallpaper to disk cache: {}", wallpaperName, e); + } + } + + public static void fetchWallpaperListFromCDN() { + Client.debugLog("Fetching wallpaper list from CDN..."); + + CompletableFuture.runAsync(() -> { + try { + URL url = new URI(CDN_WALLPAPERS_URL).toURL(); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(10000); + connection.setReadTimeout(10000); + + if (connection.getResponseCode() == 200) { + try (InputStream stream = connection.getInputStream()) { + byte[] xmlData = stream.readAllBytes(); + parseWallpaperListFromXML(xmlData); + } + } else { + Client.logger.error("Failed to fetch wallpaper list. HTTP {}", connection.getResponseCode()); + loadDefaultWallpapers(); + } + + connection.disconnect(); + + } catch (Exception e) { + Client.logger.error("Error fetching wallpaper list", e); + loadDefaultWallpapers(); + } + }, Util.getMainWorkerExecutor()); + } + + private static void parseWallpaperListFromXML(byte[] xmlData) { + try { + List discoveredWallpapers = new ArrayList<>(); + + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + DocumentBuilder builder = factory.newDocumentBuilder(); + Document doc = builder.parse(new ByteArrayInputStream(xmlData)); + + NodeList keyNodes = doc.getElementsByTagName("Key"); + + for (int i = 0; i < keyNodes.getLength(); i++) { + Node node = keyNodes.item(i); + if (node.getNodeType() == Node.ELEMENT_NODE) { + String keyValue = node.getTextContent().trim(); + + if (keyValue.toLowerCase().endsWith(".png")) { + String wallpaperName = keyValue; + discoveredWallpapers.add(wallpaperName); + + Client.debugLog("Found wallpaper: " + wallpaperName); + } + } + } + + if (discoveredWallpapers.isEmpty()) { + Client.debugLog("No wallpapers found in CDN, using defaults"); + loadDefaultWallpapers(); + } else { + AVAILABLE_WALLPAPERS.clear(); + AVAILABLE_WALLPAPERS.addAll(discoveredWallpapers); + wallpapersListFetched = true; + Client.debugLog("Loaded " + discoveredWallpapers.size() + " wallpapers from CDN"); + + for (Runnable callback : onWallpapersLoadedCallbacks) { + try { + callback.run(); + } catch (Exception e) { + Client.logger.error("Error in wallpaper loaded callback", e); + } + } + } + + } catch (Exception e) { + Client.logger.error("Error parsing wallpaper list XML", e); + loadDefaultWallpapers(); + } + } + + private static void loadDefaultWallpapers() { + AVAILABLE_WALLPAPERS.clear(); + AVAILABLE_WALLPAPERS.add("default.png"); + wallpapersListFetched = true; + + for (Runnable callback : onWallpapersLoadedCallbacks) { + try { + callback.run(); + } catch (Exception e) { + Client.logger.error("Error in wallpaper loaded callback", e); + } + } + } + + public static void onWallpapersLoaded(Runnable callback) { + if (wallpapersListFetched) { + callback.run(); + } else { + onWallpapersLoadedCallbacks.add(callback); + } + } + + public static List getAvailableWallpapers() { + return new ArrayList<>(AVAILABLE_WALLPAPERS); + } + + public static boolean isWallpapersListLoaded() { + return wallpapersListFetched; + } + + public static Identifier getWallpaperTexture(String wallpaperName) { + return WALLPAPER_CACHE.get(wallpaperName); + } + + public static boolean isWallpaperLoaded(String wallpaperName) { + return WALLPAPER_CACHE.containsKey(wallpaperName); + } + + public static boolean isWallpaperLoading(String wallpaperName) { + return LOADING_WALLPAPERS.contains(wallpaperName); + } + + public static boolean hasWallpaperFailed(String wallpaperName) { + return FAILED_WALLPAPERS.contains(wallpaperName); + } + + public static byte[] getWallpaperImageData(String wallpaperName) { + return WALLPAPER_DATA_CACHE.get(wallpaperName); + } + + public static void loadWallpaperWithData(String wallpaperName, Consumer onLoaded) { + if (WALLPAPER_DATA_CACHE.containsKey(wallpaperName)) { + if (onLoaded != null) { + onLoaded.accept(WALLPAPER_DATA_CACHE.get(wallpaperName)); + } + return; + } + + if (isWallpaperCachedOnDisk(wallpaperName)) { + byte[] cachedData = loadWallpaperFromDisk(wallpaperName); + if (cachedData != null && cachedData.length > 0) { + WALLPAPER_DATA_CACHE.put(wallpaperName, cachedData); + if (onLoaded != null) { + onLoaded.accept(cachedData); + } + return; + } + } + + if (FAILED_WALLPAPERS.contains(wallpaperName)) { + if (onLoaded != null) { + onLoaded.accept(null); + } + return; + } + + if (LOADING_WALLPAPERS.contains(wallpaperName)) { + CompletableFuture.runAsync(() -> { + while (LOADING_WALLPAPERS.contains(wallpaperName)) { + try { + Thread.sleep(100); + } catch (InterruptedException e) { + break; + } + } + if (onLoaded != null) { + onLoaded.accept(WALLPAPER_DATA_CACHE.get(wallpaperName)); + } + }, Util.getMainWorkerExecutor()); + return; + } + + LOADING_WALLPAPERS.add(wallpaperName); + + CompletableFuture.runAsync(() -> { + try { + byte[] imageData = downloadWallpaperImage(wallpaperName); + if (imageData.length == 0) { + Client.logger.error("Empty image data for wallpaper {}", wallpaperName); + markWallpaperAsFailed(wallpaperName); + if (onLoaded != null) { + onLoaded.accept(null); + } + LOADING_WALLPAPERS.remove(wallpaperName); + return; + } + + WALLPAPER_DATA_CACHE.put(wallpaperName, imageData); + FAILED_WALLPAPERS.remove(wallpaperName); + + saveWallpaperToDisk(wallpaperName, imageData); + + Client.debugLog("Successfully loaded wallpaper data: " + wallpaperName + " (" + imageData.length + " bytes)"); + + if (onLoaded != null) { + onLoaded.accept(imageData); + } + } catch (Exception e) { + Client.logger.error("Failed to download wallpaper {}", wallpaperName, e); + markWallpaperAsFailed(wallpaperName); + if (onLoaded != null) { + onLoaded.accept(null); + } + } finally { + LOADING_WALLPAPERS.remove(wallpaperName); + } + }, Util.getMainWorkerExecutor()); + } + + public static void loadWallpaper(String wallpaperName, Consumer onLoaded) { + if (WALLPAPER_CACHE.containsKey(wallpaperName)) { + if (onLoaded != null) { + onLoaded.accept(WALLPAPER_CACHE.get(wallpaperName)); + } + return; + } + + if (FAILED_WALLPAPERS.contains(wallpaperName)) { + if (onLoaded != null) { + onLoaded.accept(null); + } + return; + } + + if (LOADING_WALLPAPERS.contains(wallpaperName)) { + CompletableFuture.runAsync(() -> { + while (LOADING_WALLPAPERS.contains(wallpaperName)) { + try { + Thread.sleep(100); + } catch (InterruptedException e) { + break; + } + } + if (onLoaded != null) { + MinecraftClient.getInstance().execute(() -> { + onLoaded.accept(WALLPAPER_CACHE.get(wallpaperName)); + }); + } + }, Util.getMainWorkerExecutor()); + return; + } + + LOADING_WALLPAPERS.add(wallpaperName); + + CompletableFuture.runAsync(() -> { + try { + byte[] imageData = downloadWallpaperImage(wallpaperName); + if (imageData.length == 0) { + Client.logger.error("Empty image data for wallpaper {}", wallpaperName); + markWallpaperAsFailed(wallpaperName); + if (onLoaded != null) { + MinecraftClient.getInstance().execute(() -> onLoaded.accept(null)); + } + return; + } + + saveWallpaperToDisk(wallpaperName, imageData); + + NativeImage nativeImage = NativeImage.read(new ByteArrayInputStream(imageData)); + + MinecraftClient.getInstance().execute(() -> { + try { + NativeImageBackedTexture texture = new NativeImageBackedTexture(nativeImage); + String safeName = wallpaperName.replace(".png", "").replaceAll("[^a-zA-Z0-9_]", "_"); + Identifier textureId = Identifier.of(MOD_ID, "wallpapers/" + safeName); + MinecraftClient.getInstance().getTextureManager().registerTexture(textureId, texture); + WALLPAPER_CACHE.put(wallpaperName, textureId); + FAILED_WALLPAPERS.remove(wallpaperName); + Client.debugLog("Successfully loaded wallpaper: " + wallpaperName + " (" + nativeImage.getWidth() + "x" + nativeImage.getHeight() + ")"); + + if (onLoaded != null) { + onLoaded.accept(textureId); + } + } catch (Exception e) { + Client.logger.error("Failed to create texture for wallpaper {}", wallpaperName, e); + nativeImage.close(); + markWallpaperAsFailed(wallpaperName); + if (onLoaded != null) { + onLoaded.accept(null); + } + } finally { + LOADING_WALLPAPERS.remove(wallpaperName); + } + }); + + } catch (Exception e) { + Client.logger.error("Failed to download wallpaper {}", wallpaperName, e); + markWallpaperAsFailed(wallpaperName); + LOADING_WALLPAPERS.remove(wallpaperName); + if (onLoaded != null) { + MinecraftClient.getInstance().execute(() -> onLoaded.accept(null)); + } + } + }, Util.getMainWorkerExecutor()); + } + + private static byte[] downloadWallpaperImage(String wallpaperName) throws Exception { + String downloadUrl = CDN_WALLPAPERS_URL + "/" + wallpaperName; + Client.debugLog("Downloading wallpaper from: " + downloadUrl); + + URL url = new URI(downloadUrl).toURL(); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(30000); + connection.setReadTimeout(30000); + + if (connection.getResponseCode() != 200) { + throw new IOException("Failed to download wallpaper: HTTP " + connection.getResponseCode()); + } + + try (InputStream stream = connection.getInputStream()) { + return stream.readAllBytes(); + } finally { + connection.disconnect(); + } + } + + private static void markWallpaperAsFailed(String wallpaperName) { + FAILED_WALLPAPERS.add(wallpaperName); + LOADING_WALLPAPERS.remove(wallpaperName); + } + + public static void preloadWallpaper(String wallpaperName) { + loadWallpaper(wallpaperName, null); + } +} + diff --git a/src/main/java/de/winniepat/citrus/managers/ZoomManager.java b/src/main/java/de/winniepat/citrus/managers/ZoomManager.java new file mode 100644 index 0000000..bddc58f --- /dev/null +++ b/src/main/java/de/winniepat/citrus/managers/ZoomManager.java @@ -0,0 +1,14 @@ +package de.winniepat.citrus.managers; + +public class ZoomManager { + private static boolean zooming = false; + private static double zoomMultiplier = 4.0; + + public static void setZooming(boolean value) { zooming = value; } + public static boolean isZooming() { return zooming; } + public static double getZoomMultiplier() { return zoomMultiplier; } + + public static void changeZoom(double amount) { + zoomMultiplier = Math.max(1.0, Math.min(40.0, zoomMultiplier + amount)); + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/mixins/AbstractClientPlayerEntityMixin.java b/src/main/java/de/winniepat/citrus/mixins/AbstractClientPlayerEntityMixin.java new file mode 100644 index 0000000..d931f8f --- /dev/null +++ b/src/main/java/de/winniepat/citrus/mixins/AbstractClientPlayerEntityMixin.java @@ -0,0 +1,30 @@ +package de.winniepat.citrus.mixins; + +import com.llamalad7.mixinextras.injector.ModifyReturnValue; +import de.winniepat.citrus.cosmetics.capes.CapeHandler; +import de.winniepat.citrus.managers.RankManager; +import net.minecraft.client.network.*; +import net.minecraft.client.util.*; +import org.spongepowered.asm.mixin.*; +import org.spongepowered.asm.mixin.injection.At; + +import java.io.IOException; + +@Mixin(AbstractClientPlayerEntity.class) +public class AbstractClientPlayerEntityMixin { + + @Shadow + private PlayerListEntry playerListEntry; + + @ModifyReturnValue(method = "getSkinTextures", at = @At("RETURN")) + public SkinTextures handleCapes(SkinTextures original) throws IOException { + AbstractClientPlayerEntity player = (AbstractClientPlayerEntity) (Object) this; + RankManager.getRankIcon(player.getUuid()); + + if (playerListEntry == null) { + return DefaultSkinHelper.getSkinTextures(((AbstractClientPlayerEntity) (Object) this).getUuid()); + } else { + return CapeHandler.getCapes(original, (AbstractClientPlayerEntity) (Object) this); + } + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/mixins/CapeTransparencyMixin.java b/src/main/java/de/winniepat/citrus/mixins/CapeTransparencyMixin.java new file mode 100644 index 0000000..c763bb9 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/mixins/CapeTransparencyMixin.java @@ -0,0 +1,19 @@ +package de.winniepat.citrus.mixins; + +import net.minecraft.client.render.RenderLayer; +import net.minecraft.client.render.entity.feature.CapeFeatureRenderer; +import net.minecraft.util.Identifier; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.*; + +@Mixin(CapeFeatureRenderer.class) +public class CapeTransparencyMixin { + + @Redirect( + method = "render*", + at = @At(value = "INVOKE", target = "Lnet/minecraft/client/render/RenderLayer;getEntitySolid(Lnet/minecraft/util/Identifier;)Lnet/minecraft/client/render/RenderLayer;") + ) + private RenderLayer swapToTranslucent(Identifier texture) { + return RenderLayer.getEntityTranslucent(texture); + } +} diff --git a/src/main/java/de/winniepat/citrus/mixins/CreateWorldScreenMixin.java b/src/main/java/de/winniepat/citrus/mixins/CreateWorldScreenMixin.java new file mode 100644 index 0000000..be7e527 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/mixins/CreateWorldScreenMixin.java @@ -0,0 +1,26 @@ +package de.winniepat.citrus.mixins; + +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.world.CreateWorldScreen; +import net.minecraft.util.Identifier; +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(CreateWorldScreen.class) +public class CreateWorldScreenMixin { + @Inject(method = "render", at = @At("HEAD")) + private void onRender(DrawContext context, int mouseX, int mouseY, float delta, CallbackInfo ci) { + int screenWidth = context.getScaledWindowWidth(); + int screenHeight = context.getScaledWindowHeight(); + + context.drawTexture( + Identifier.of("citrus", "textures/startscreen/wallpaper.png"), + 0, 0, + 0.0f, 0.0f, + screenWidth, screenHeight, + screenWidth, screenHeight + ); + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/mixins/GameRendererMixin.java b/src/main/java/de/winniepat/citrus/mixins/GameRendererMixin.java new file mode 100644 index 0000000..80407fb --- /dev/null +++ b/src/main/java/de/winniepat/citrus/mixins/GameRendererMixin.java @@ -0,0 +1,18 @@ +package de.winniepat.citrus.mixins; + +import de.winniepat.citrus.managers.ZoomManager; +import net.minecraft.client.render.*; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.*; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(GameRenderer.class) +public class GameRendererMixin { + @Inject(method = "getFov", at = @At("RETURN"), cancellable = true) + private void onGetFov(Camera camera, float tickDelta, boolean changingFov, CallbackInfoReturnable info) { + if (ZoomManager.isZooming()) { + double originalFov = info.getReturnValue(); + info.setReturnValue(originalFov / ZoomManager.getZoomMultiplier()); + } + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/mixins/GammaBypassMixin.java b/src/main/java/de/winniepat/citrus/mixins/GammaBypassMixin.java new file mode 100644 index 0000000..d7a893f --- /dev/null +++ b/src/main/java/de/winniepat/citrus/mixins/GammaBypassMixin.java @@ -0,0 +1,17 @@ +package de.winniepat.citrus.mixins; + +import net.minecraft.client.option.SimpleOption; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.*; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import java.util.Optional; + +@Mixin(SimpleOption.DoubleSliderCallbacks.class) +public class GammaBypassMixin { + + @Inject(method = "validate(Ljava/lang/Object;)Ljava/util/Optional;", at = @At("HEAD"), cancellable = true) + private void onValidate(Object value, CallbackInfoReturnable> cir) { + cir.setReturnValue(Optional.of(value)); + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/mixins/MouseMixin.java b/src/main/java/de/winniepat/citrus/mixins/MouseMixin.java new file mode 100644 index 0000000..60ffd6a --- /dev/null +++ b/src/main/java/de/winniepat/citrus/mixins/MouseMixin.java @@ -0,0 +1,27 @@ +package de.winniepat.citrus.mixins; + +import de.winniepat.citrus.managers.ZoomManager; +import net.minecraft.client.Mouse; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.*; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(Mouse.class) +public class MouseMixin { + @Inject(method = "onMouseScroll", at = @At("HEAD"), cancellable = true) + private void onScroll(long window, double horizontal, double vertical, CallbackInfo info) { + if (ZoomManager.isZooming()) { + ZoomManager.changeZoom(vertical); + + info.cancel(); + } + } + + @ModifyVariable(method = "updateMouse", at = @At("STORE"), ordinal = 2) + private double adjustSensitivity(double value) { + if (ZoomManager.isZooming()) { + return value / ZoomManager.getZoomMultiplier() * 3; + } + return value; + } +} diff --git a/src/main/java/de/winniepat/citrus/mixins/MultiplayerScreenMixin.java b/src/main/java/de/winniepat/citrus/mixins/MultiplayerScreenMixin.java new file mode 100644 index 0000000..4c256bb --- /dev/null +++ b/src/main/java/de/winniepat/citrus/mixins/MultiplayerScreenMixin.java @@ -0,0 +1,26 @@ +package de.winniepat.citrus.mixins; + +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.multiplayer.MultiplayerScreen; +import net.minecraft.util.Identifier; +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(MultiplayerScreen.class) +public class MultiplayerScreenMixin { + @Inject(method = "render", at = @At("HEAD")) + private void onRender(DrawContext context, int mouseX, int mouseY, float delta, CallbackInfo ci) { + int screenWidth = context.getScaledWindowWidth(); + int screenHeight = context.getScaledWindowHeight(); + + context.drawTexture( + Identifier.of("citrus", "textures/startscreen/wallpaper.png"), + 0, 0, + 0.0f, 0.0f, + screenWidth, screenHeight, + screenWidth, screenHeight + ); + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/mixins/OptionsScreenMixin.java b/src/main/java/de/winniepat/citrus/mixins/OptionsScreenMixin.java new file mode 100644 index 0000000..5fd6eb6 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/mixins/OptionsScreenMixin.java @@ -0,0 +1,28 @@ +package de.winniepat.citrus.mixins; + +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.util.Identifier; +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(Screen.class) +public class OptionsScreenMixin { + @Inject(method = "renderBackground", at = @At("HEAD"), cancellable = true) + private void renderCustomBackground(DrawContext context, int mouseX, int mouseY, float delta, CallbackInfo ci) { + int width = context.getScaledWindowWidth(); + int height = context.getScaledWindowHeight(); + + context.drawTexture( + Identifier.of("citrus", "textures/startscreen/wallpaper.png"), + 0, 0, + 0.0f, 0.0f, + width, height, + width, height + ); + + ci.cancel(); + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/mixins/PlayerListEntryMixin.java b/src/main/java/de/winniepat/citrus/mixins/PlayerListEntryMixin.java new file mode 100644 index 0000000..0fdb8c0 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/mixins/PlayerListEntryMixin.java @@ -0,0 +1,11 @@ +package de.winniepat.citrus.mixins; + +import net.minecraft.client.network.PlayerListEntry; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(PlayerListEntry.class) +public interface PlayerListEntryMixin { + @Accessor("latency") + int getLatency(); +} diff --git a/src/main/java/de/winniepat/citrus/mixins/PlayerNametagMixin.java b/src/main/java/de/winniepat/citrus/mixins/PlayerNametagMixin.java new file mode 100644 index 0000000..58f364d --- /dev/null +++ b/src/main/java/de/winniepat/citrus/mixins/PlayerNametagMixin.java @@ -0,0 +1,40 @@ +package de.winniepat.citrus.mixins; + +import de.winniepat.citrus.managers.*; +import net.minecraft.client.render.entity.EntityRenderer; +import net.minecraft.entity.Entity; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.text.*; +import net.minecraft.util.Identifier; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.*; + +import java.util.UUID; + +@Mixin(EntityRenderer.class) +public abstract class PlayerNametagMixin { + + @ModifyVariable(method = "renderLabelIfPresent", at = @At("HEAD"), argsOnly = true) + private Text addRankIcon(Text text, Entity entity) { + if (entity instanceof PlayerEntity player) { + UUID uuid = player.getUuid(); + + if (!ClientRegistrationManager.isPlayerOnlineWithCitrus(uuid)) { + return text; + } + + String iconChar = RankManager.getRankIcon(uuid); + + if (!iconChar.trim().isEmpty()) { + MutableText icon = Text.literal(iconChar).setStyle(Style.EMPTY.withFont(Identifier.of("citrus", "icon_font"))); + + MutableText nameWithSpace = Text.literal(" ") + .append(text) + .setStyle(Style.EMPTY.withFont(Identifier.of("minecraft", "default"))); + + return icon.append(nameWithSpace); + } + } + return text; + } +} diff --git a/src/main/java/de/winniepat/citrus/mixins/ScreenRenderMixin.java b/src/main/java/de/winniepat/citrus/mixins/ScreenRenderMixin.java new file mode 100644 index 0000000..e8ce23f --- /dev/null +++ b/src/main/java/de/winniepat/citrus/mixins/ScreenRenderMixin.java @@ -0,0 +1,41 @@ +package de.winniepat.citrus.mixins; + +import de.winniepat.citrus.gui.TestScreen; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.render.DiffuseLighting; +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 to render the player entity in the TestScreen. + * This hooks into Screen.render to draw on the active UI frame. + */ +@Mixin(Screen.class) +public class ScreenRenderMixin { + + @Inject(method = "render", at = @At("TAIL")) + private void citrus$renderPlayerPreview(DrawContext context, int mouseX, int mouseY, float delta, CallbackInfo ci) { + TestScreen testScreen = TestScreen.getActiveInstance(); + if (testScreen == null || !testScreen.isPreviewBoundsSet()) return; + + MinecraftClient client = MinecraftClient.getInstance(); + if (client.player == null || client.currentScreen == null) return; + + // Ensure UI-friendly lighting for entity rendering. + DiffuseLighting.enableGuiDepthLighting(); + + TestScreen.renderPlayerEntity( + context, + testScreen.getPreviewCenterX(), + testScreen.getPreviewCenterY(), + testScreen.getPreviewSize(), + testScreen.getPlayerRotationX(), + testScreen.getPlayerRotationY(), + client.player + ); + } +} diff --git a/src/main/java/de/winniepat/citrus/mixins/ShowOwnNametagMixin.java b/src/main/java/de/winniepat/citrus/mixins/ShowOwnNametagMixin.java new file mode 100644 index 0000000..d4249b7 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/mixins/ShowOwnNametagMixin.java @@ -0,0 +1,19 @@ +package de.winniepat.citrus.mixins; + +import com.llamalad7.mixinextras.injector.ModifyExpressionValue; +import net.minecraft.client.render.entity.LivingEntityRenderer; +import net.minecraft.entity.Entity; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +@Mixin(LivingEntityRenderer.class) +public abstract class ShowOwnNametagMixin { + @ModifyExpressionValue( + method = "hasLabel(Lnet/minecraft/entity/LivingEntity;)Z", + at = @At(value = "INVOKE", target = "Lnet/minecraft/client/MinecraftClient;getCameraEntity()Lnet/minecraft/entity/Entity;") + ) + + public Entity obfuscateCameraEntity(Entity original){ + return null; + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/mixins/SingleplayerScreenMixin.java b/src/main/java/de/winniepat/citrus/mixins/SingleplayerScreenMixin.java new file mode 100644 index 0000000..4037065 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/mixins/SingleplayerScreenMixin.java @@ -0,0 +1,26 @@ +package de.winniepat.citrus.mixins; + +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.world.SelectWorldScreen; +import net.minecraft.util.Identifier; +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(SelectWorldScreen.class) +public class SingleplayerScreenMixin { + @Inject(method = "render", at = @At("HEAD")) + private void onRender(DrawContext context, int mouseX, int mouseY, float delta, CallbackInfo ci) { + int screenWidth = context.getScaledWindowWidth(); + int screenHeight = context.getScaledWindowHeight(); + + context.drawTexture( + Identifier.of("citrus", "textures/startscreen/wallpaper.png"), + 0, 0, + 0.0f, 0.0f, + screenWidth, screenHeight, + screenWidth, screenHeight + ); + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/mixins/TabListMixin.java b/src/main/java/de/winniepat/citrus/mixins/TabListMixin.java new file mode 100644 index 0000000..ed03592 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/mixins/TabListMixin.java @@ -0,0 +1,40 @@ +package de.winniepat.citrus.mixins; + +import de.winniepat.citrus.managers.*; +import net.minecraft.client.gui.hud.PlayerListHud; +import net.minecraft.client.network.PlayerListEntry; +import net.minecraft.text.*; +import net.minecraft.util.Identifier; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.*; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import java.util.UUID; + +@Mixin(PlayerListHud.class) +public abstract class TabListMixin { + + @Inject(method = "getPlayerName", at = @At("RETURN"), cancellable = true) + private void addRankIconToTab(PlayerListEntry entry, CallbackInfoReturnable cir) { + UUID uuid = entry.getProfile().getId(); + + if (!ClientRegistrationManager.isPlayerOnlineWithCitrus(uuid)) { + return; + } + + String iconChar = RankManager.getRankIcon(uuid); + + if (!iconChar.trim().isEmpty()) { + Text currentName = cir.getReturnValue() != null ? cir.getReturnValue() : Text.literal(entry.getProfile().getName()); + + MutableText icon = Text.literal(iconChar) + .setStyle(Style.EMPTY.withFont(Identifier.of("citrus", "icon_font"))); + + MutableText nameWithSpace = Text.literal(" ") + .append(currentName) + .setStyle(Style.EMPTY.withFont(Identifier.of("minecraft", "default"))); + + cir.setReturnValue(icon.append(nameWithSpace)); + } + } +} diff --git a/src/main/java/de/winniepat/citrus/mixins/TitleScreenMixin.java b/src/main/java/de/winniepat/citrus/mixins/TitleScreenMixin.java new file mode 100644 index 0000000..315c9be --- /dev/null +++ b/src/main/java/de/winniepat/citrus/mixins/TitleScreenMixin.java @@ -0,0 +1,38 @@ +package de.winniepat.citrus.mixins; + +import de.winniepat.citrus.Client; +import de.winniepat.citrus.beta.BetaKeyScreen; +import de.winniepat.citrus.gui.MainMenuScreen; +import icyllis.modernui.mc.MuiModApi; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.screen.TitleScreen; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.*; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(TitleScreen.class) +public abstract class TitleScreenMixin { + @Inject(method = "init", at = @At("HEAD"), cancellable = true) + private void onInit(CallbackInfo ci) { + MinecraftClient client = MinecraftClient.getInstance(); + + if (!Client.betaFeaturesEnabled) { + client.setScreen(MuiModApi.get().createScreen(new MainMenuScreen(), null, null)); + ci.cancel(); + return; + } + + if (!Client.getBetaManager().isVerified()) { + if ("WinniePat".equals(client.getSession().getUsername())) { + Client.logger.info("Welcome back! You have bypass from the Beta Restriction :)"); + } else { + client.setScreen(new BetaKeyScreen()); + ci.cancel(); + return; + } + } + + client.setScreen(MuiModApi.get().createScreen(new MainMenuScreen(), null, null)); + ci.cancel(); + } +} diff --git a/src/main/java/de/winniepat/citrus/mixins/WindowTitleMixin.java b/src/main/java/de/winniepat/citrus/mixins/WindowTitleMixin.java new file mode 100644 index 0000000..8f63aad --- /dev/null +++ b/src/main/java/de/winniepat/citrus/mixins/WindowTitleMixin.java @@ -0,0 +1,15 @@ +package de.winniepat.citrus.mixins; + +import de.winniepat.citrus.Client; +import net.minecraft.client.MinecraftClient; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.*; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(MinecraftClient.class) +public abstract class WindowTitleMixin { + @Inject(method = "getWindowTitle", at = @At("HEAD"), cancellable = true) + private void injectCustomWindowTitle(CallbackInfoReturnable cir) { + cir.setReturnValue(Client.clientInfo); + } +} diff --git a/src/main/java/de/winniepat/citrus/utils/ChatSound.java b/src/main/java/de/winniepat/citrus/utils/ChatSound.java new file mode 100644 index 0000000..821e3ca --- /dev/null +++ b/src/main/java/de/winniepat/citrus/utils/ChatSound.java @@ -0,0 +1,41 @@ +package de.winniepat.citrus.utils; + +import net.fabricmc.fabric.api.client.message.v1.ClientReceiveMessageEvents; +import net.minecraft.client.MinecraftClient; +import net.minecraft.sound.SoundEvents; + +public class ChatSound { + + public static void init() { + ClientReceiveMessageEvents.CHAT.register((message, signedMessage, sender, params, receptionTimestamp) -> checkForMention(message.getString())); + + ClientReceiveMessageEvents.GAME.register((message, overlay) -> { + if (!overlay) { + checkForMention(message.getString()); + } + }); + } + + private static void checkForMention(String message) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client == null || client.getSession() == null) return; + + String myName = client.getSession().getUsername(); + String cleanMessage = message.replaceAll("§.", ""); + + if (cleanMessage.toLowerCase().contains(myName.toLowerCase())) { + playMentionSound(); + } + } + + private static void playMentionSound() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.player != null) { + client.player.playSound( + SoundEvents.ENTITY_EXPERIENCE_ORB_PICKUP, + 0.67F, + 1.0F + ); + } + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/utils/ModMenuIntegration.java b/src/main/java/de/winniepat/citrus/utils/ModMenuIntegration.java new file mode 100644 index 0000000..3265f09 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/utils/ModMenuIntegration.java @@ -0,0 +1,11 @@ +package de.winniepat.citrus.utils; + +import com.terraformersmc.modmenu.api.*; +import de.winniepat.citrus.gui.CapeSelectionScreen; + +public class ModMenuIntegration implements ModMenuApi { + @Override + public ConfigScreenFactory getModConfigScreenFactory() { + return parent -> new CapeSelectionScreen(); + } +} diff --git a/src/main/java/de/winniepat/citrus/utils/RefreshUtil.java b/src/main/java/de/winniepat/citrus/utils/RefreshUtil.java new file mode 100644 index 0000000..d2356b6 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/utils/RefreshUtil.java @@ -0,0 +1,73 @@ +package de.winniepat.citrus.utils; + +import de.winniepat.citrus.Client; +import de.winniepat.citrus.cosmetics.capes.*; +import de.winniepat.citrus.cosmetics.capes.sync.CapeSyncManager; +import de.winniepat.citrus.cosmetics.wings.sync.WingSyncManager; +import de.winniepat.citrus.managers.*; +import net.minecraft.client.MinecraftClient; + +import java.util.Timer; +import java.util.TimerTask; + +public class RefreshUtil { + + public static void startPeriodicRankRefresh() { + Timer timer = new Timer("Citrus-PeriodicRankRefresh", true); + timer.scheduleAtFixedRate(new TimerTask() { + @Override + public void run() { + try { + MinecraftClient mc = MinecraftClient.getInstance(); + if (mc.getNetworkHandler() != null && mc.player != null) { + Client.debugLog("Background Rank Refresh Triggered"); + RankManager.refreshRankIfChanged(); + } + } catch (Exception e) { + Client.debugLog("Error during Rank refresh: " + e.getMessage()); + } + } + }, 10000, 30000); + } + + public static void startPeriodicRegisterCheck() { + + if (ClientRegistrationManager.isRegistered()) return; + + Timer timer = new Timer("Citrus-PeriodicRegisterCheck", true); + timer.scheduleAtFixedRate(new TimerTask() { + @Override + public void run() { + try { + ClientRegistrationManager.register(); + } catch (Exception e) { + Client.debugLog("Error during Registration refresh: " + e.getMessage()); + } + } + }, 10000, 30000); + } + + public static void startPeriodicCapeSync() { + Timer timer = new Timer("Citrus-PeriodicCapeSync", true); + timer.scheduleAtFixedRate(new TimerTask() { + @Override + public void run() { + try { + CapeSyncManager.forceSyncNow(); + CapeHandler.retryFailedCapes(); + CapeSyncManager.SYNCED_CAPE_CACHE.clear(); + Client.debugLog("Background Cape sync triggered"); + } catch (Exception e) { + Client.logger.error("Error during Cape sync: {}", e.getMessage()); + } + } + }, 10000, 30000); + } + + public static void start() { + startPeriodicRankRefresh(); + startPeriodicRegisterCheck(); + startPeriodicCapeSync(); + } + +} diff --git a/src/main/java/de/winniepat/citrus/utils/SecurityUtils.java b/src/main/java/de/winniepat/citrus/utils/SecurityUtils.java new file mode 100644 index 0000000..33f597e --- /dev/null +++ b/src/main/java/de/winniepat/citrus/utils/SecurityUtils.java @@ -0,0 +1,38 @@ +package de.winniepat.citrus.utils; + +import de.winniepat.citrus.Client; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.time.ZoneOffset; + +public class SecurityUtils { + private static final String MASTER_SECRET = "xhIc7PAz0whLGm8t8EsIK574mDqvuqGs6oWLm0pWynkSnpuTU14iML1FDHdlXOJhHQBww65mcehps0fYBUnVJe5m4GR7HHlnBjQxQQ4crqhw4R3AbegwByr7aEg6XvwP"; + + public static String getDailyKey() { + try { + String date = java.time.LocalDate.now(ZoneOffset.UTC).toString(); + + SecretKeySpec signingKey = new SecretKeySpec( + MASTER_SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256" + ); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(signingKey); + + byte[] rawHmac = mac.doFinal(date.getBytes(StandardCharsets.UTF_8)); + return bytesToHex(rawHmac); + } catch (Exception e) { + Client.logger.error("Error while getting daily key", e); + return ""; + } + } + + private static String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } +} diff --git a/src/main/java/de/winniepat/citrus/utils/text/TextComponent.java b/src/main/java/de/winniepat/citrus/utils/text/TextComponent.java new file mode 100644 index 0000000..d6e2c9e --- /dev/null +++ b/src/main/java/de/winniepat/citrus/utils/text/TextComponent.java @@ -0,0 +1,69 @@ +package de.winniepat.citrus.utils.text; + +import io.wispforest.owo.ui.component.LabelComponent; +import io.wispforest.owo.ui.core.*; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.text.*; +import net.minecraft.util.math.MathHelper; + +public class TextComponent extends LabelComponent { + float scale = 1f; + + public TextComponent(Text text) { + super(text); + } + + public TextComponent scale(float scale) { + this.scale = scale; + this.notifyParentIfMounted(); + return this; + } + + @Override + protected int determineHorizontalContentSize(Sizing sizing) { + return MathHelper.ceil(super.determineHorizontalContentSize(sizing) * this.scale); + } + + @Override + protected int determineVerticalContentSize(Sizing sizing) { + return MathHelper.ceil(super.determineVerticalContentSize(sizing) * this.scale); + } + + @Override + public void draw(OwoUIDrawContext context, int mouseX, int mouseY, float partialTicks, float delta) { + MatrixStack matrices = context.getMatrices(); + matrices.push(); + + matrices.scale(this.scale, this.scale, this.scale); + + int baseScreenY = this.y; + + switch (this.verticalTextAlignment) { + case CENTER -> baseScreenY += (this.height - this.textHeight()) / 2; + case BOTTOM -> baseScreenY += this.height - this.textHeight(); + } + + int x = (int) (this.x / this.scale); + int y = (int) (baseScreenY / this.scale); + + context.draw(() -> { + int currenY = y; + for (OrderedText renderText : this.wrappedText) { + int renderX = x; + + switch (this.horizontalTextAlignment) { + case CENTER -> renderX = x + (int) (((this.width / this.scale) - this.textRenderer.getWidth(renderText)) / 2f); + case RIGHT -> renderX = x + (int) (this.width / this.scale - this.textRenderer.getWidth(renderText)); + } + + int renderY = currenY + (int) ((this.lineHeight() - 9) / this.scale); + + context.drawText(this.textRenderer, renderText, renderX, renderY, this.color.get().argb(),this.shadow); + + currenY += (int) ((this.lineHeight() + this.lineSpacing()) / this.scale); + } + }); + + matrices.pop(); + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/utils/texture/CdnTextureDrawable.java b/src/main/java/de/winniepat/citrus/utils/texture/CdnTextureDrawable.java new file mode 100644 index 0000000..685d0c2 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/utils/texture/CdnTextureDrawable.java @@ -0,0 +1,90 @@ +package de.winniepat.citrus.utils.texture; + +import icyllis.modernui.graphics.*; +import icyllis.modernui.graphics.drawable.Drawable; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.NotNull; + +import java.io.ByteArrayInputStream; + +public class CdnTextureDrawable extends Drawable { + + private Identifier textureId; + private Image mImage; + private byte[] mImageData; + + public CdnTextureDrawable() { + this.textureId = null; + this.mImage = null; + this.mImageData = null; + } + + public CdnTextureDrawable(Identifier textureId) { + this.textureId = textureId; + this.mImage = null; + this.mImageData = null; + } + + public void setTextureId(Identifier textureId) { + this.textureId = textureId; + invalidateSelf(); + } + + public void setImageData(byte[] imageData) { + this.mImageData = imageData; + this.mImage = null; + invalidateSelf(); + } + + private void createImageFromData() { + if (mImageData == null || mImageData.length == 0) { + return; + } + + try { + Bitmap bitmap = BitmapFactory.decodeStream(new ByteArrayInputStream(mImageData)); + mImage = Image.createTextureFromBitmap(bitmap); + } catch (Exception e) { + mImage = null; + } + } + + public Identifier getTextureId() { + return textureId; + } + + public boolean hasTexture() { + return textureId != null || mImage != null || mImageData != null; + } + + @Override + public void draw(@NotNull Canvas canvas) { + if (mImage == null && mImageData != null) { + createImageFromData(); + } + + if (mImage == null) { + return; + } + + Rect bounds = getBounds(); + if (bounds.isEmpty()) { + return; + } + + Paint paint = Paint.obtain(); + canvas.drawImage(mImage, null, bounds, paint); + paint.recycle(); + } + + @Override + public int getIntrinsicWidth() { + return mImage != null ? mImage.getWidth() : -1; + } + + @Override + public int getIntrinsicHeight() { + return mImage != null ? mImage.getHeight() : -1; + } +} + diff --git a/src/main/java/de/winniepat/citrus/utils/texture/IconLoaderComponent.java b/src/main/java/de/winniepat/citrus/utils/texture/IconLoaderComponent.java new file mode 100644 index 0000000..f7cbd52 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/utils/texture/IconLoaderComponent.java @@ -0,0 +1,63 @@ + +package de.winniepat.citrus.utils.texture; + +import de.winniepat.citrus.Client; +import de.winniepat.citrus.utils.SecurityUtils; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.texture.*; +import net.minecraft.util.Identifier; + +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URL; +import java.util.*; +import java.util.concurrent.*; + +public class IconLoaderComponent { + + private static final String MOD_ID = "citrus"; + + private static final Map CACHE = new ConcurrentHashMap<>(); + + public static CompletableFuture getRankTexture(String uuid) { + return CompletableFuture.supplyAsync(() -> { + try { + URL rankUrl = new URI(Client.API_URL + "/rank/" + uuid).toURL(); + HttpURLConnection conn = (HttpURLConnection) rankUrl.openConnection(); + conn.setRequestMethod("GET"); + conn.setRequestProperty("x-daily-key", SecurityUtils.getDailyKey()); + + String rank; + try (InputStream is = conn.getInputStream(); + Scanner scanner = new Scanner(is)) { + rank = scanner.useDelimiter("\\A").next().trim().toLowerCase(); + } + + conn.disconnect(); + + if (CACHE.containsKey(rank)) { + return CACHE.get(rank); + } + + URL iconUrl = new URI(Client.CDN_URL + "/ranks/" + rank + ".png").toURL(); + try (InputStream is = iconUrl.openStream()) { + NativeImage image = NativeImage.read(is); + NativeImageBackedTexture texture = new NativeImageBackedTexture(image); + + Identifier textureId = Identifier.of(MOD_ID, "ranks/" + rank); + + MinecraftClient.getInstance().execute(() -> { + MinecraftClient.getInstance().getTextureManager().registerTexture(textureId, texture); + }); + + CACHE.put(rank, textureId); + return textureId; + } + } catch (Exception e) { + Client.logger.error("Failed to get rank texture for UUID: {}", uuid, e); + return null; + } + }); + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/utils/texture/ModIconComponent.java b/src/main/java/de/winniepat/citrus/utils/texture/ModIconComponent.java new file mode 100644 index 0000000..089fe39 --- /dev/null +++ b/src/main/java/de/winniepat/citrus/utils/texture/ModIconComponent.java @@ -0,0 +1,76 @@ +package de.winniepat.citrus.utils.texture; + +import de.winniepat.citrus.Client; +import de.winniepat.citrus.utils.SecurityUtils; +import io.wispforest.owo.ui.base.BaseComponent; +import io.wispforest.owo.ui.core.*; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.texture.*; +import net.minecraft.util.*; + +import java.io.InputStream; +import java.net.URI; +import java.net.http.*; +import java.util.concurrent.CompletableFuture; + +public class ModIconComponent extends BaseComponent { + + private static final String ICON_URL = Client.API_URL + "/client/logo.png"; + public static Identifier LOADED_TEXTURE = null; + private static boolean loadingStarted = false; + + public ModIconComponent() { + this.sizing(Sizing.fixed(20), Sizing.fixed(20)); + startLoading(); + } + + private static void startLoading() { + if (loadingStarted || LOADED_TEXTURE != null) return; + loadingStarted = true; + + CompletableFuture.runAsync(() -> { + try { + HttpClient client = HttpClient.newHttpClient(); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(ICON_URL)) + .header("x-daily-key", SecurityUtils.getDailyKey()) + .GET() + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofInputStream()); + + if (response.statusCode() == 200) { + try (InputStream is = response.body()) { + NativeImage image = NativeImage.read(is); + NativeImageBackedTexture texture = new NativeImageBackedTexture(image); + + MinecraftClient.getInstance().execute(() -> { + LOADED_TEXTURE = MinecraftClient.getInstance() + .getTextureManager() + .registerDynamicTexture("citrus_logo", texture); + }); + } + } + } catch (Exception e) { + Client.logger.error("Failed to load icon from CDN", e); + loadingStarted = false; + } + }, Util.getMainWorkerExecutor()); + } + + @Override + public void draw(OwoUIDrawContext context, int mouseX, int mouseY, float partialTick, float delta) { + if (LOADED_TEXTURE == null) { + return; + } + + context.drawTexture( + LOADED_TEXTURE, + x(), y(), + 20, 20, + 0, 0, + 20, 20, + 20, 20 + ); + } +} \ No newline at end of file diff --git a/src/main/java/de/winniepat/citrus/utils/toasts/CustomToast.java b/src/main/java/de/winniepat/citrus/utils/toasts/CustomToast.java new file mode 100644 index 0000000..a18aa5f --- /dev/null +++ b/src/main/java/de/winniepat/citrus/utils/toasts/CustomToast.java @@ -0,0 +1,59 @@ +package de.winniepat.citrus.utils.toasts; + +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.font.TextRenderer; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.toast.Toast; +import net.minecraft.client.toast.ToastManager; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; + +public class CustomToast implements Toast { + private final Text title; + private final Text description; + private final int titleColor; + private final int descColor; + private final int width; + + private static final Identifier TEXTURE = Identifier.of("minecraft", "toast/advancement"); + + public CustomToast(TextRenderer renderer, Text title, int titleColor, Text description, int descColor) { + this.title = title; + this.titleColor = titleColor; + this.description = description; + this.descColor = descColor; + + int titleWidth = renderer.getWidth(title); + int descWidth = renderer.getWidth(description); + + this.width = Math.max(40, Math.max(titleWidth, descWidth) + 16); + } + + @Override + public int getWidth() { + return this.width; + } + + @Override + public Visibility draw(DrawContext context, ToastManager manager, long startTime) { + context.drawGuiTexture(TEXTURE, 0, 0, this.getWidth(), 32); + + TextRenderer renderer = manager.getClient().textRenderer; + + context.drawText(renderer, title, 8, 7, titleColor, false); + context.drawText(renderer, description, 8, 18, descColor, false); + + return startTime >= 5000L ? Visibility.HIDE : Visibility.SHOW; + } + + public static void sendCustomToast(String title, int titleCol, String desc, int descCol) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null) { + client.getToastManager().add(new CustomToast( + client.textRenderer, + Text.literal(title), titleCol, + Text.literal(desc), descCol + )); + } + } +} diff --git a/src/main/resources/assets/citrus/animations/angel_wings.animation.json b/src/main/resources/assets/citrus/animations/angel_wings.animation.json new file mode 100644 index 0000000..2514df2 --- /dev/null +++ b/src/main/resources/assets/citrus/animations/angel_wings.animation.json @@ -0,0 +1,32 @@ +{ + "format_version": "1.8.0", + "animations": { + "idle": { + "loop": true, + "animation_length": 125.1667, + "bones": { + "L5": { + "rotation": { + "vector": [0, "-40 - ((1 - 0.15) * math.sin(query.anim_time*100 - 50) * 5 + 0.2 * math.sin(query.anim_time*100 - 50 - 0.25) * 25)", 0] + } + }, + "L4": { + "rotation": { + "vector": [0, "-((1 - 0.15) * math.sin(query.anim_time*100 - 100) * 20 + 0.2 * math.sin(query.anim_time*100 - 100 - 0.25) * 1)", 0] + } + }, + "R5": { + "rotation": { + "vector": [0, " 40 + ((1 - 0.15) * math.sin(query.anim_time*100 - 50) * 5 + 0.2 * math.sin(query.anim_time*100 - 50 - 0.25) * 1)", 0] + } + }, + "R4": { + "rotation": { + "vector": [0, "((1 - 0.15) * math.sin(query.anim_time*100 - 100) * 20 + 0.2 * math.sin(query.anim_time*100 - 100 - 0.25) * 35)", 0] + } + } + } + } + }, + "geckolib_format_version": 2 +} \ No newline at end of file diff --git a/src/main/resources/assets/citrus/animations/halo/angel_halo.animation.json b/src/main/resources/assets/citrus/animations/halo/angel_halo.animation.json new file mode 100644 index 0000000..f6b155e --- /dev/null +++ b/src/main/resources/assets/citrus/animations/halo/angel_halo.animation.json @@ -0,0 +1,23 @@ +{ + "format_version": "1.8.0", + "animations": { + "nrc.idle": { + "loop": true, + "bones": { + "halo": { + "rotation": [0, "math.cos(query.anim_time * 10) * 150", 0], + "position": [0, "math.cos(query.anim_time * 100) * 0.3", 0] + }, + "sparkle": { + "position": [0, -4.5, 0], + "scale": 1.15 + } + } + } + }, + "geckolib_format_version": 2, + "geometry.animations": { + "texturewidth": 64, + "textureheight": 64 + } +} \ No newline at end of file diff --git a/src/main/resources/assets/citrus/animations/hat/test_hat.animation.json b/src/main/resources/assets/citrus/animations/hat/test_hat.animation.json new file mode 100644 index 0000000..742bd89 --- /dev/null +++ b/src/main/resources/assets/citrus/animations/hat/test_hat.animation.json @@ -0,0 +1,16 @@ +{ + "format_version": "1.8.0", + "animations": { + "animation.hat.idle": { + "loop": true, + "bones": { + "bandana": { + "scale": { + "vector": [1.05, 1.05, 1.05] + } + } + } + } + }, + "geckolib_format_version": 2 +} diff --git a/src/main/resources/assets/citrus/animations/wings.animation.json b/src/main/resources/assets/citrus/animations/wings.animation.json new file mode 100644 index 0000000..4785831 --- /dev/null +++ b/src/main/resources/assets/citrus/animations/wings.animation.json @@ -0,0 +1,32 @@ +{ + "format_version": "1.8.0", + "animations": { + "animation.wings.idle": { + "loop": true, + "animation_length": 125.1667, + "bones": { + "L2": { + "rotation": { + "vector": [0, "-40 - ((1 - 0.15) * math.sin(query.anim_time*100 - 50) * 5 + 0.2 * math.sin(query.anim_time*100 - 50 - 0.25) * 25)", 0] + } + }, + "L3": { + "rotation": { + "vector": [0, "-((1 - 0.15) * math.sin(query.anim_time*100 - 100) * 20 + 0.2 * math.sin(query.anim_time*100 - 100 - 0.25) * 1)", 0] + } + }, + "R2": { + "rotation": { + "vector": [0, " 40 + ((1 - 0.15) * math.sin(query.anim_time*100 - 50) * 5 + 0.2 * math.sin(query.anim_time*100 - 50 - 0.25) * 1)", 0] + } + }, + "R3": { + "rotation": { + "vector": [0, "((1 - 0.15) * math.sin(query.anim_time*100 - 100) * 20 + 0.2 * math.sin(query.anim_time*100 - 100 - 0.25) * 35)", 0] + } + } + } + } + }, + "geckolib_format_version": 2 +} \ No newline at end of file diff --git a/src/main/resources/assets/citrus/animations/wings2.animation.json b/src/main/resources/assets/citrus/animations/wings2.animation.json new file mode 100644 index 0000000..4785831 --- /dev/null +++ b/src/main/resources/assets/citrus/animations/wings2.animation.json @@ -0,0 +1,32 @@ +{ + "format_version": "1.8.0", + "animations": { + "animation.wings.idle": { + "loop": true, + "animation_length": 125.1667, + "bones": { + "L2": { + "rotation": { + "vector": [0, "-40 - ((1 - 0.15) * math.sin(query.anim_time*100 - 50) * 5 + 0.2 * math.sin(query.anim_time*100 - 50 - 0.25) * 25)", 0] + } + }, + "L3": { + "rotation": { + "vector": [0, "-((1 - 0.15) * math.sin(query.anim_time*100 - 100) * 20 + 0.2 * math.sin(query.anim_time*100 - 100 - 0.25) * 1)", 0] + } + }, + "R2": { + "rotation": { + "vector": [0, " 40 + ((1 - 0.15) * math.sin(query.anim_time*100 - 50) * 5 + 0.2 * math.sin(query.anim_time*100 - 50 - 0.25) * 1)", 0] + } + }, + "R3": { + "rotation": { + "vector": [0, "((1 - 0.15) * math.sin(query.anim_time*100 - 100) * 20 + 0.2 * math.sin(query.anim_time*100 - 100 - 0.25) * 35)", 0] + } + } + } + } + }, + "geckolib_format_version": 2 +} \ No newline at end of file diff --git a/src/main/resources/assets/citrus/font/icon_font.json b/src/main/resources/assets/citrus/font/icon_font.json new file mode 100644 index 0000000..56cdf1d --- /dev/null +++ b/src/main/resources/assets/citrus/font/icon_font.json @@ -0,0 +1,46 @@ +{ + "providers": [ + { + "type": "bitmap", + "file": "citrus:ranks/owner.png", + "ascent": 7, + "height": 8, + "chars": ["\uE001"] + }, + { + "type": "bitmap", + "file": "citrus:ranks/admin.png", + "ascent": 7, + "height": 8, + "chars": ["\uE002"] + }, + { + "type": "bitmap", + "file": "citrus:ranks/vip.png", + "ascent": 7, + "height": 8, + "chars": ["\uE003"] + }, + { + "type": "bitmap", + "file": "citrus:ranks/dev.png", + "ascent": 7, + "height": 8, + "chars": ["\uE004"] + }, + { + "type": "bitmap", + "file": "citrus:ranks/bughunter.png", + "ascent": 7, + "height": 8, + "chars": ["\uE005"] + }, + { + "type": "bitmap", + "file": "citrus:ranks/default.png", + "ascent": 7, + "height": 8, + "chars": ["\uE099"] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/assets/citrus/geo/angel_wings.geo.json b/src/main/resources/assets/citrus/geo/angel_wings.geo.json new file mode 100644 index 0000000..31d6ecc --- /dev/null +++ b/src/main/resources/assets/citrus/geo/angel_wings.geo.json @@ -0,0 +1,184 @@ +{ + "format_version": "1.12.0", + "minecraft:geometry": [ + { + "description": { + "identifier": "geometry.unknown", + "texture_width": 100, + "texture_height": 100, + "visible_bounds_width": 3, + "visible_bounds_height": 3.5, + "visible_bounds_offset": [0, 1.25, 0] + }, + "bones": [ + { + "name": "bb_main", + "pivot": [0, 0, 0] + }, + { + "name": "bipedHead", + "pivot": [0, 24.99, 1.12857] + }, + { + "name": "armorHead", + "parent": "bipedHead", + "pivot": [0, 24.99, 1.12857] + }, + { + "name": "bipedBody", + "pivot": [0, 24.99, 1.12857] + }, + { + "name": "armorBody", + "parent": "bipedBody", + "pivot": [0, 24.99, 1.12857], + "cubes": [ + { + "origin": [-4, 12, -2], + "size": [8, 12, 4.1], + "uv": { + "south": {"uv": [85.15625, 83.59375], "uv_size": [10.15625, 14.0625]} + } + } + ] + }, + { + "name": "dark_wings", + "parent": "armorBody", + "pivot": [-4, 22, 4.3] + }, + { + "name": "right", + "parent": "dark_wings", + "pivot": [1.3, 23, 1.9] + }, + { + "name": "L2", + "parent": "right", + "pivot": [1.3, 23, 1.9], + "cubes": [ + { + "origin": [0, 15, 3.2], + "size": [0, 14, 3.1], + "pivot": [0, 26, 1.85], + "rotation": [0, 90, 0], + "uv": { + "north": {"uv": [0, 85.9375], "uv_size": [17.1875, 13.28125]}, + "east": {"uv": [100, 85.9375], "uv_size": [-9.375, 13.28125]}, + "south": {"uv": [0, 85.9375], "uv_size": [17.1875, 13.28125]}, + "west": {"uv": [54.9375, 21.78125], "uv_size": [-7.25, 28.125]}, + "up": {"uv": [0, 89.92188], "uv_size": [17.1875, -3.98437]}, + "down": {"uv": [0, 85.9375], "uv_size": [17.1875, 3.98438]} + } + } + ] + }, + { + "name": "L3", + "parent": "L2", + "pivot": [4.45, 23, 1.86029], + "cubes": [ + { + "origin": [-2, 15, 8.2], + "size": [0, 14, 10.1], + "pivot": [-2, 26, 1.85], + "rotation": [0, 90, 0], + "uv": { + "north": {"uv": [0, 85.9375], "uv_size": [17.1875, 13.28125]}, + "east": {"uv": [82.8125, 86.71875], "uv_size": [12.03125, 13.28125]}, + "south": {"uv": [0, 85.9375], "uv_size": [17.1875, 13.28125]}, + "west": {"uv": [47.725, 21.78125], "uv_size": [-19.7875, 28.125]}, + "up": {"uv": [0, 99.21875], "uv_size": [17.1875, -9.29687]}, + "down": {"uv": [0, 89.92188], "uv_size": [17.1875, 9.29688]} + } + } + ] + }, + { + "name": "left", + "parent": "dark_wings", + "pivot": [-1.3, 23, 1.8] + }, + { + "name": "R2", + "parent": "left", + "pivot": [-1.3, 23, 1.8], + "cubes": [ + { + "origin": [0, 15, -2.6], + "size": [0, 14, 3.1], + "pivot": [0, 26, 1.85], + "rotation": [0, 90, 0], + "uv": { + "north": {"uv": [17.1875, 85.9375], "uv_size": [-17.1875, 13.28125]}, + "east": {"uv": [100, 86.71875], "uv_size": [-9.375, 13.28125]}, + "south": {"uv": [17.1875, 85.9375], "uv_size": [-17.1875, 13.28125]}, + "west": {"uv": [47.6875, 21.78125], "uv_size": [7.25, 28.125]}, + "up": {"uv": [0, 85.9375], "uv_size": [17.1875, 3.98438]}, + "down": {"uv": [0, 89.92188], "uv_size": [17.1875, -3.98437]} + } + } + ] + }, + { + "name": "R3", + "parent": "R2", + "pivot": [-4.45, 23, 1.86029], + "cubes": [ + { + "origin": [2, 15, -14.6], + "size": [0, 14, 10.1], + "pivot": [2, 26, 1.85], + "rotation": [0, 90, 0], + "uv": { + "north": {"uv": [17.1875, 85.9375], "uv_size": [-17.1875, 13.28125]}, + "east": {"uv": [94.84375, 86.71875], "uv_size": [-12.03125, 13.28125]}, + "south": {"uv": [17.1875, 85.9375], "uv_size": [-17.1875, 13.28125]}, + "west": {"uv": [27.9375, 21.78125], "uv_size": [19.7875, 28.125]}, + "up": {"uv": [0, 89.92188], "uv_size": [17.1875, 9.29688]}, + "down": {"uv": [0, 99.21875], "uv_size": [17.1875, -9.29687]} + } + } + ] + }, + { + "name": "bipedRightArm", + "pivot": [3.28, 23.35, 1.12857] + }, + { + "name": "armorRightArm", + "parent": "bipedRightArm", + "pivot": [3.28, 23.35, 1.12857] + }, + { + "name": "bipedLeftLeg", + "pivot": [-1.64, 15.15, 1.12857] + }, + { + "name": "armorLeftLeg", + "parent": "bipedLeftLeg", + "pivot": [-1.64, 15.15, 1.12857] + }, + { + "name": "armorLeftBoot", + "parent": "bipedLeftLeg", + "pivot": [-1.64, 15.15, 1.12857] + }, + { + "name": "bipedRightLeg", + "pivot": [1.64, 15.15, 1.12857] + }, + { + "name": "armorRightLeg", + "parent": "bipedRightLeg", + "pivot": [1.64, 15.15, 1.12857] + }, + { + "name": "armorRightBoot", + "parent": "bipedRightLeg", + "pivot": [1.64, 15.15, 1.12857] + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/assets/citrus/geo/halo/angel_halo.geo.json b/src/main/resources/assets/citrus/geo/halo/angel_halo.geo.json new file mode 100644 index 0000000..d711ec7 --- /dev/null +++ b/src/main/resources/assets/citrus/geo/halo/angel_halo.geo.json @@ -0,0 +1,189 @@ +{ + "format_version": "1.12.0", + "minecraft:geometry": [ + { + "description": { + "identifier": "geometry.unknown", + "texture_width": 64, + "texture_height": 64, + "visible_bounds_width": 2, + "visible_bounds_height": 3.5, + "visible_bounds_offset": [0, 1.25, 0] + }, + "bones": [ + { + "name": "bipedHead", + "pivot": [0, 24, 0] + }, + { + "name": "armorHead", + "parent": "bipedHead", + "pivot": [0, 24, 0] + }, + { + "name": "sparkle", + "parent": "armorHead", + "pivot": [-0.00129, -9.24, 0.03425], + "cubes": [ + { + "origin": [-6.73398, 30.99279, 0.28539], + "size": [13.44642, 3.41442, 0.00002], + "inflate": -0.0001, + "pivot": [-0.01077, 32.7, 0.2854], + "rotation": [0, 60, 0], + "uv": { + "north": {"uv": [30, 6], "uv_size": [23, 19]}, + "east": {"uv": [0, 55], "uv_size": [30, 6]}, + "south": {"uv": [0, 58], "uv_size": [30, 6]}, + "west": {"uv": [24, 5], "uv_size": [4, 1]}, + "up": {"uv": [25, 2], "uv_size": [1, 4]}, + "down": {"uv": [26, 7], "uv_size": [1, -4]} + } + }, + { + "origin": [-6.73398, 30.99279, 0.28539], + "size": [13.44642, 3.41442, 0.00002], + "inflate": -0.0001, + "pivot": [-0.01077, 32.7, 0.2854], + "rotation": [0, -60, 0], + "uv": { + "north": {"uv": [32, 23], "uv_size": [1, 1]}, + "east": {"uv": [0, 55], "uv_size": [30, 6]}, + "south": {"uv": [0, 58], "uv_size": [30, 6]}, + "west": {"uv": [24, 5], "uv_size": [4, 1]}, + "up": {"uv": [25, 2], "uv_size": [1, 4]}, + "down": {"uv": [26, 7], "uv_size": [1, -4]} + } + }, + { + "origin": [-6.73398, 30.99279, 0.28539], + "size": [13.44642, 3.41442, 0.00002], + "inflate": -0.0001, + "pivot": [-0.01077, 32.7, 0.2854], + "rotation": [0, 30, 0], + "uv": { + "north": {"uv": [28, 11], "uv_size": [17, 12]}, + "east": {"uv": [0, 55], "uv_size": [30, 6]}, + "south": {"uv": [0, 58], "uv_size": [30, 6]}, + "west": {"uv": [24, 5], "uv_size": [4, 1]}, + "up": {"uv": [25, 2], "uv_size": [1, 4]}, + "down": {"uv": [26, 7], "uv_size": [1, -4]} + } + }, + { + "origin": [-6.73398, 30.99279, 0.28539], + "size": [13.44642, 3.41442, 0.00002], + "inflate": -0.0001, + "pivot": [-0.01077, 32.7, 0.2854], + "rotation": [0, 30, -180], + "uv": { + "north": {"uv": [32, 23], "uv_size": [1, 1]}, + "east": {"uv": [0, 55], "uv_size": [30, 6]}, + "south": {"uv": [30, 64], "uv_size": [-30, -6]}, + "west": {"uv": [24, 5], "uv_size": [4, 1]}, + "up": {"uv": [25, 2], "uv_size": [1, 4]}, + "down": {"uv": [26, 7], "uv_size": [1, -4]} + } + }, + { + "origin": [-6.73398, 30.99279, 0.28539], + "size": [13.44642, 3.41442, 0.00002], + "inflate": -0.0001, + "pivot": [-0.01077, 32.7, 0.2854], + "rotation": [0, 45, 0], + "uv": { + "north": {"uv": [32, 23], "uv_size": [1, 1]}, + "east": {"uv": [0, 55], "uv_size": [30, 6]}, + "south": {"uv": [0, 58], "uv_size": [30, 6]}, + "west": {"uv": [24, 5], "uv_size": [4, 1]}, + "up": {"uv": [25, 2], "uv_size": [1, 4]}, + "down": {"uv": [26, 7], "uv_size": [1, -4]} + } + }, + { + "origin": [-6.73398, 30.99279, 0.28539], + "size": [13.44642, 3.41442, 0.00002], + "inflate": -0.0001, + "pivot": [-0.01077, 32.7, 0.2854], + "rotation": [0, -45, 0], + "uv": { + "north": {"uv": [32, 23], "uv_size": [1, 1]}, + "east": {"uv": [0, 55], "uv_size": [30, 6]}, + "south": {"uv": [0, 58], "uv_size": [30, 6]}, + "west": {"uv": [24, 5], "uv_size": [4, 1]}, + "up": {"uv": [25, 2], "uv_size": [1, 4]}, + "down": {"uv": [26, 7], "uv_size": [1, -4]} + } + } + ] + }, + { + "name": "halo", + "parent": "sparkle", + "pivot": [0, 2.5, 0], + "cubes": [ + {"origin": [-2.5, 32.5, -3.5], "size": [5, 1, 1], "uv": [0, 6]}, + {"origin": [-2.5, 32.5, 2.5], "size": [5, 1, 1], "uv": [0, 6]}, + {"origin": [-0.5, 32.5, 1.5], "size": [5, 1, 1], "pivot": [0.5, 32.5, -1.5], "rotation": [0, -90, 0], "uv": [0, 6]}, + {"origin": [0.5, 32.5, -0.5], "size": [5, 1, 1], "pivot": [3, 33, 0], "rotation": [0, -90, 0], "uv": [0, 6]} + ] + }, + { + "name": "bipedBody", + "pivot": [0, 24, 0] + }, + { + "name": "armorBody", + "parent": "bipedBody", + "pivot": [0, 24, 0] + }, + { + "name": "bipedLeftArm", + "pivot": [-4, 22, 0] + }, + { + "name": "armorLeftArm", + "parent": "bipedLeftArm", + "pivot": [-4, 22, 0] + }, + { + "name": "bipedRightArm", + "pivot": [4, 22, 0] + }, + { + "name": "armorRightArm", + "parent": "bipedRightArm", + "pivot": [4, 22, 0] + }, + { + "name": "bipedLeftLeg", + "pivot": [-2, 12, 0] + }, + { + "name": "armorLeftLeg", + "parent": "bipedLeftLeg", + "pivot": [-2, 12, 0] + }, + { + "name": "armorLeftBoot", + "parent": "bipedLeftLeg", + "pivot": [-2, 12, 0] + }, + { + "name": "bipedRightLeg", + "pivot": [2, 12, 0] + }, + { + "name": "armorRightLeg", + "parent": "bipedRightLeg", + "pivot": [2, 12, 0] + }, + { + "name": "armorRightBoot", + "parent": "bipedRightLeg", + "pivot": [2, 12, 0] + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/assets/citrus/geo/hat/test_hat.geo.json b/src/main/resources/assets/citrus/geo/hat/test_hat.geo.json new file mode 100644 index 0000000..a985d12 --- /dev/null +++ b/src/main/resources/assets/citrus/geo/hat/test_hat.geo.json @@ -0,0 +1,218 @@ +{ + "format_version": "1.12.0", + "minecraft:geometry": [ + { + "description": { + "identifier": "geometry.unknown", + "texture_width": 768, + "texture_height": 768, + "visible_bounds_width": 2, + "visible_bounds_height": 3.5, + "visible_bounds_offset": [0, 1.25, 0] + }, + "bones": [ + { + "name": "bipedHead", + "pivot": [0, 24, 0] + }, + { + "name": "armorHead", + "parent": "bipedHead", + "pivot": [0, 24, 0] + }, + { + "name": "bandana", + "parent": "armorHead", + "pivot": [-0.05, 29.00016, 0.97874], + "rotation": [-7.5, 0, 0], + "cubes": [ + { + "origin": [-1.25, 27.71352, 4.37616], + "size": [2.2, 2.2, 0.825], + "pivot": [-0.07971, 28.0568, 3.054], + "rotation": [-10, 0, 0], + "uv": { + "north": {"uv": [4, 9.66667], "uv_size": [4, 9.5]}, + "east": {"uv": [96, 48], "uv_size": [16, 48]}, + "south": {"uv": [48, 48], "uv_size": [48, 48]}, + "west": {"uv": [32, 48], "uv_size": [16, 48]}, + "up": {"uv": [96, 48], "uv_size": [-48, -16]}, + "down": {"uv": [96, 112], "uv_size": [-48, -16]} + } + }, + { + "origin": [-3.65, 28, 3.5], + "size": [8.5, 2.25, 1.45], + "uv": { + "north": {"uv": [256, 0], "uv_size": [256, 48]}, + "east": {"uv": [15, 20], "uv_size": [1, 2]}, + "south": {"uv": [256, 144], "uv_size": [227, 48]}, + "west": {"uv": [483, 96], "uv_size": [29, 48]}, + "up": {"uv": [224, 256], "uv_size": [-224, -32]}, + "down": {"uv": [32, 224], "uv_size": [224, 32]} + } + }, + { + "origin": [-11.4, 28, -3.5], + "size": [8.5, 2.25, 1.45], + "pivot": [-3.9, 28.25, -3.5], + "rotation": [0, 180, 0], + "uv": { + "north": {"uv": [256, 0], "uv_size": [256, 48]}, + "east": {"uv": [15, 20], "uv_size": [1, 2]}, + "south": {"uv": [256, 0], "uv_size": [227, 48]}, + "west": {"uv": [482, 48], "uv_size": [30, 47]}, + "up": {"uv": [32, 0], "uv_size": [224, 32]}, + "down": {"uv": [224, 32], "uv_size": [-224, -32]} + } + }, + { + "origin": [-4.15, 28, -12.45], + "size": [1.25, 2.25, 8.45], + "pivot": [-3.9, 28.25, -3.75], + "rotation": [0, 180, 0], + "uv": { + "north": {"uv": [483, 144], "uv_size": [29, 48]}, + "east": {"uv": [256, 48], "uv_size": [256, 48]}, + "south": {"uv": [256, 48], "uv_size": [231, 48]}, + "west": {"uv": [256, 48], "uv_size": [226, 47]}, + "up": {"uv": [224, 32], "uv_size": [32, 224]}, + "down": {"uv": [32, 256], "uv_size": [-32, -224]} + } + }, + { + "origin": [3.6, 28, -4.95], + "size": [1.25, 2.25, 8.45], + "uv": { + "north": {"uv": [483, 0], "uv_size": [29, 48]}, + "east": {"uv": [256, 96], "uv_size": [256, 48]}, + "south": {"uv": [256, 48], "uv_size": [231, 48]}, + "west": {"uv": [257, 96], "uv_size": [226, 48]}, + "up": {"uv": [32, 224], "uv_size": [-32, -224]}, + "down": {"uv": [224, 0], "uv_size": [32, 224]} + } + } + ] + }, + { + "name": "bandana_ribbon", + "parent": "bandana", + "pivot": [-0.15, 28.63698, 2.80196], + "cubes": [ + { + "origin": [1.12389, 27.61925, 4.2772], + "size": [2.36, 2.065, 0.295], + "pivot": [-0.0746, 27.88698, 2.80196], + "rotation": [-1.88319, -18.14976, 6.79956], + "uv": { + "north": {"uv": [16, 272], "uv_size": [144, 48]}, + "east": {"uv": [0, 272], "uv_size": [16, 48]}, + "south": {"uv": [464, 272], "uv_size": [144, 48]}, + "west": {"uv": [160, 272], "uv_size": [16, 48]}, + "up": {"uv": [304, 272], "uv_size": [-144, -16]}, + "down": {"uv": [592, 272], "uv_size": [-144, -16]} + } + }, + { + "origin": [3.37774, 28.06097, 3.3012], + "size": [2.36, 2.065, 0.295], + "pivot": [0.03114, 27.30877, 2.79839], + "rotation": [-6.70517, -34.2775, 16.56847], + "uv": { + "north": {"uv": [160, 272], "uv_size": [144, 48]}, + "east": {"uv": [144, 272], "uv_size": [16, 48]}, + "south": {"uv": [320, 272], "uv_size": [144, 48]}, + "west": {"uv": [304, 272], "uv_size": [16, 48]}, + "up": {"uv": [160, 272], "uv_size": [-144, -16]}, + "down": {"uv": [448, 272], "uv_size": [-144, -16]} + } + }, + { + "origin": [-6.03774, 28.06097, 3.3012], + "size": [2.36, 2.065, 0.295], + "pivot": [-0.33114, 27.30877, 2.79839], + "rotation": [-6.70517, 34.2775, -16.56847], + "uv": { + "north": {"uv": [304, 272], "uv_size": [-144, 48]}, + "east": {"uv": [320, 272], "uv_size": [-16, 48]}, + "south": {"uv": [464, 272], "uv_size": [-144, 48]}, + "west": {"uv": [160, 272], "uv_size": [-16, 48]}, + "up": {"uv": [16, 272], "uv_size": [144, -16]}, + "down": {"uv": [304, 272], "uv_size": [144, -16]} + } + }, + { + "origin": [-3.78389, 27.61925, 4.2772], + "size": [2.36, 2.065, 0.295], + "pivot": [-0.2254, 27.88698, 2.80196], + "rotation": [-1.88319, 18.14976, -6.79956], + "uv": { + "north": {"uv": [160, 272], "uv_size": [-144, 48]}, + "east": {"uv": [176, 272], "uv_size": [-16, 48]}, + "south": {"uv": [608, 272], "uv_size": [-144, 48]}, + "west": {"uv": [16, 272], "uv_size": [-16, 48]}, + "up": {"uv": [160, 272], "uv_size": [144, -16]}, + "down": {"uv": [448, 272], "uv_size": [144, -16]} + } + } + ] + }, + { + "name": "bipedBody", + "pivot": [0, 24, 0] + }, + { + "name": "armorBody", + "parent": "bipedBody", + "pivot": [-0.05, 24, 0] + }, + { + "name": "bipedRightArm", + "pivot": [-4, 22, 0] + }, + { + "name": "armorRightArm", + "parent": "bipedRightArm", + "pivot": [-4, 22, 0] + }, + { + "name": "bipedLeftArm", + "pivot": [4, 22, 0] + }, + { + "name": "armorLeftArm", + "parent": "bipedLeftArm", + "pivot": [4, 22, 0] + }, + { + "name": "bipedLeftLeg", + "pivot": [2, 12, 0] + }, + { + "name": "armorLeftLeg", + "parent": "bipedLeftLeg", + "pivot": [2, 12, 0] + }, + { + "name": "armorLeftBoot", + "parent": "bipedLeftLeg", + "pivot": [2, 12, 0] + }, + { + "name": "bipedRightLeg", + "pivot": [-2, 12, 0] + }, + { + "name": "armorRightLeg", + "parent": "bipedRightLeg", + "pivot": [-2, 12, 0] + }, + { + "name": "armorRightBoot", + "parent": "bipedRightLeg", + "pivot": [-2, 12, 0] + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/assets/citrus/geo/wings.geo.json b/src/main/resources/assets/citrus/geo/wings.geo.json new file mode 100644 index 0000000..08a8566 --- /dev/null +++ b/src/main/resources/assets/citrus/geo/wings.geo.json @@ -0,0 +1,184 @@ +{ + "format_version": "1.12.0", + "minecraft:geometry": [ + { + "description": { + "identifier": "geometry.unknown", + "texture_width": 64, + "texture_height": 64, + "visible_bounds_width": 8, + "visible_bounds_height": 5.5, + "visible_bounds_offset": [0, 2.25, 0] + }, + "bones": [ + { + "name": "bb_main", + "pivot": [0, 0, 0] + }, + { + "name": "bipedHead", + "pivot": [0, 24.99, 1.12857] + }, + { + "name": "armorHead", + "parent": "bipedHead", + "pivot": [0, 24.99, 1.12857] + }, + { + "name": "bipedBody", + "pivot": [0, 24.99, 1.12857] + }, + { + "name": "armorBody", + "parent": "bipedBody", + "pivot": [0, 24.99, 1.12857], + "cubes": [ + { + "origin": [-4, 12, -2], + "size": [8, 12, 4.1], + "uv": { + "south": {"uv": [54.5, 53.5], "uv_size": [6.5, 9]} + } + } + ] + }, + { + "name": "dark_wings", + "parent": "armorBody", + "pivot": [-4, 22, 4.3] + }, + { + "name": "right", + "parent": "dark_wings", + "pivot": [1.3, 23, 1.9] + }, + { + "name": "L2", + "parent": "right", + "pivot": [1.3, 23, 1.9], + "cubes": [ + { + "origin": [0, 15, 3.2], + "size": [0, 14, 3.1], + "pivot": [0, 26, 1.85], + "rotation": [0, 90, 0], + "uv": { + "north": {"uv": [0, 55], "uv_size": [11, 8.5]}, + "east": {"uv": [64, 55], "uv_size": [-6, 8.5]}, + "south": {"uv": [0, 55], "uv_size": [11, 8.5]}, + "west": {"uv": [35.16, 13.94], "uv_size": [-4.64, 18]}, + "up": {"uv": [0, 57.55], "uv_size": [11, -2.55]}, + "down": {"uv": [0, 55], "uv_size": [11, 2.55]} + } + } + ] + }, + { + "name": "L3", + "parent": "L2", + "pivot": [4.45, 23, 1.86029], + "cubes": [ + { + "origin": [-2, 15, 8.2], + "size": [0, 14, 10.1], + "pivot": [-2, 26, 1.85], + "rotation": [0, 90, 0], + "uv": { + "north": {"uv": [0, 55], "uv_size": [11, 8.5]}, + "east": {"uv": [53, 55.5], "uv_size": [7.7, 8.5]}, + "south": {"uv": [0, 55], "uv_size": [11, 8.5]}, + "west": {"uv": [30.544, 13.94], "uv_size": [-12.664, 18]}, + "up": {"uv": [0, 63.5], "uv_size": [11, -5.95]}, + "down": {"uv": [0, 57.55], "uv_size": [11, 5.95]} + } + } + ] + }, + { + "name": "left", + "parent": "dark_wings", + "pivot": [-1.3, 23, 1.8] + }, + { + "name": "R2", + "parent": "left", + "pivot": [-1.3, 23, 1.8], + "cubes": [ + { + "origin": [0, 15, -2.6], + "size": [0, 14, 3.1], + "pivot": [0, 26, 1.85], + "rotation": [0, 90, 0], + "uv": { + "north": {"uv": [11, 55], "uv_size": [-11, 8.5]}, + "east": {"uv": [64, 55.5], "uv_size": [-6, 8.5]}, + "south": {"uv": [11, 55], "uv_size": [-11, 8.5]}, + "west": {"uv": [30.52, 13.94], "uv_size": [4.64, 18]}, + "up": {"uv": [0, 55], "uv_size": [11, 2.55]}, + "down": {"uv": [0, 57.55], "uv_size": [11, -2.55]} + } + } + ] + }, + { + "name": "R3", + "parent": "R2", + "pivot": [-4.45, 23, 1.86029], + "cubes": [ + { + "origin": [2, 15, -14.6], + "size": [0, 14, 10.1], + "pivot": [2, 26, 1.85], + "rotation": [0, 90, 0], + "uv": { + "north": {"uv": [11, 55], "uv_size": [-11, 8.5]}, + "east": {"uv": [60.7, 55.5], "uv_size": [-7.7, 8.5]}, + "south": {"uv": [11, 55], "uv_size": [-11, 8.5]}, + "west": {"uv": [17.88, 13.94], "uv_size": [12.664, 18]}, + "up": {"uv": [0, 57.55], "uv_size": [11, 5.95]}, + "down": {"uv": [0, 63.5], "uv_size": [11, -5.95]} + } + } + ] + }, + { + "name": "bipedRightArm", + "pivot": [3.28, 23.35, 1.12857] + }, + { + "name": "armorRightArm", + "parent": "bipedRightArm", + "pivot": [3.28, 23.35, 1.12857] + }, + { + "name": "bipedLeftLeg", + "pivot": [-1.64, 15.15, 1.12857] + }, + { + "name": "armorLeftLeg", + "parent": "bipedLeftLeg", + "pivot": [-1.64, 15.15, 1.12857] + }, + { + "name": "armorLeftBoot", + "parent": "bipedLeftLeg", + "pivot": [-1.64, 15.15, 1.12857] + }, + { + "name": "bipedRightLeg", + "pivot": [1.64, 15.15, 1.12857] + }, + { + "name": "armorRightLeg", + "parent": "bipedRightLeg", + "pivot": [1.64, 15.15, 1.12857] + }, + { + "name": "armorRightBoot", + "parent": "bipedRightLeg", + "pivot": [1.64, 15.15, 1.12857] + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/assets/citrus/geo/wings2.geo.json b/src/main/resources/assets/citrus/geo/wings2.geo.json new file mode 100644 index 0000000..08a8566 --- /dev/null +++ b/src/main/resources/assets/citrus/geo/wings2.geo.json @@ -0,0 +1,184 @@ +{ + "format_version": "1.12.0", + "minecraft:geometry": [ + { + "description": { + "identifier": "geometry.unknown", + "texture_width": 64, + "texture_height": 64, + "visible_bounds_width": 8, + "visible_bounds_height": 5.5, + "visible_bounds_offset": [0, 2.25, 0] + }, + "bones": [ + { + "name": "bb_main", + "pivot": [0, 0, 0] + }, + { + "name": "bipedHead", + "pivot": [0, 24.99, 1.12857] + }, + { + "name": "armorHead", + "parent": "bipedHead", + "pivot": [0, 24.99, 1.12857] + }, + { + "name": "bipedBody", + "pivot": [0, 24.99, 1.12857] + }, + { + "name": "armorBody", + "parent": "bipedBody", + "pivot": [0, 24.99, 1.12857], + "cubes": [ + { + "origin": [-4, 12, -2], + "size": [8, 12, 4.1], + "uv": { + "south": {"uv": [54.5, 53.5], "uv_size": [6.5, 9]} + } + } + ] + }, + { + "name": "dark_wings", + "parent": "armorBody", + "pivot": [-4, 22, 4.3] + }, + { + "name": "right", + "parent": "dark_wings", + "pivot": [1.3, 23, 1.9] + }, + { + "name": "L2", + "parent": "right", + "pivot": [1.3, 23, 1.9], + "cubes": [ + { + "origin": [0, 15, 3.2], + "size": [0, 14, 3.1], + "pivot": [0, 26, 1.85], + "rotation": [0, 90, 0], + "uv": { + "north": {"uv": [0, 55], "uv_size": [11, 8.5]}, + "east": {"uv": [64, 55], "uv_size": [-6, 8.5]}, + "south": {"uv": [0, 55], "uv_size": [11, 8.5]}, + "west": {"uv": [35.16, 13.94], "uv_size": [-4.64, 18]}, + "up": {"uv": [0, 57.55], "uv_size": [11, -2.55]}, + "down": {"uv": [0, 55], "uv_size": [11, 2.55]} + } + } + ] + }, + { + "name": "L3", + "parent": "L2", + "pivot": [4.45, 23, 1.86029], + "cubes": [ + { + "origin": [-2, 15, 8.2], + "size": [0, 14, 10.1], + "pivot": [-2, 26, 1.85], + "rotation": [0, 90, 0], + "uv": { + "north": {"uv": [0, 55], "uv_size": [11, 8.5]}, + "east": {"uv": [53, 55.5], "uv_size": [7.7, 8.5]}, + "south": {"uv": [0, 55], "uv_size": [11, 8.5]}, + "west": {"uv": [30.544, 13.94], "uv_size": [-12.664, 18]}, + "up": {"uv": [0, 63.5], "uv_size": [11, -5.95]}, + "down": {"uv": [0, 57.55], "uv_size": [11, 5.95]} + } + } + ] + }, + { + "name": "left", + "parent": "dark_wings", + "pivot": [-1.3, 23, 1.8] + }, + { + "name": "R2", + "parent": "left", + "pivot": [-1.3, 23, 1.8], + "cubes": [ + { + "origin": [0, 15, -2.6], + "size": [0, 14, 3.1], + "pivot": [0, 26, 1.85], + "rotation": [0, 90, 0], + "uv": { + "north": {"uv": [11, 55], "uv_size": [-11, 8.5]}, + "east": {"uv": [64, 55.5], "uv_size": [-6, 8.5]}, + "south": {"uv": [11, 55], "uv_size": [-11, 8.5]}, + "west": {"uv": [30.52, 13.94], "uv_size": [4.64, 18]}, + "up": {"uv": [0, 55], "uv_size": [11, 2.55]}, + "down": {"uv": [0, 57.55], "uv_size": [11, -2.55]} + } + } + ] + }, + { + "name": "R3", + "parent": "R2", + "pivot": [-4.45, 23, 1.86029], + "cubes": [ + { + "origin": [2, 15, -14.6], + "size": [0, 14, 10.1], + "pivot": [2, 26, 1.85], + "rotation": [0, 90, 0], + "uv": { + "north": {"uv": [11, 55], "uv_size": [-11, 8.5]}, + "east": {"uv": [60.7, 55.5], "uv_size": [-7.7, 8.5]}, + "south": {"uv": [11, 55], "uv_size": [-11, 8.5]}, + "west": {"uv": [17.88, 13.94], "uv_size": [12.664, 18]}, + "up": {"uv": [0, 57.55], "uv_size": [11, 5.95]}, + "down": {"uv": [0, 63.5], "uv_size": [11, -5.95]} + } + } + ] + }, + { + "name": "bipedRightArm", + "pivot": [3.28, 23.35, 1.12857] + }, + { + "name": "armorRightArm", + "parent": "bipedRightArm", + "pivot": [3.28, 23.35, 1.12857] + }, + { + "name": "bipedLeftLeg", + "pivot": [-1.64, 15.15, 1.12857] + }, + { + "name": "armorLeftLeg", + "parent": "bipedLeftLeg", + "pivot": [-1.64, 15.15, 1.12857] + }, + { + "name": "armorLeftBoot", + "parent": "bipedLeftLeg", + "pivot": [-1.64, 15.15, 1.12857] + }, + { + "name": "bipedRightLeg", + "pivot": [1.64, 15.15, 1.12857] + }, + { + "name": "armorRightLeg", + "parent": "bipedRightLeg", + "pivot": [1.64, 15.15, 1.12857] + }, + { + "name": "armorRightBoot", + "parent": "bipedRightLeg", + "pivot": [1.64, 15.15, 1.12857] + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/assets/citrus/icon.png b/src/main/resources/assets/citrus/icon.png new file mode 100644 index 0000000..7b89a39 Binary files /dev/null and b/src/main/resources/assets/citrus/icon.png differ diff --git a/src/main/resources/assets/citrus/lang/en_us.json b/src/main/resources/assets/citrus/lang/en_us.json new file mode 100644 index 0000000..c0b664b --- /dev/null +++ b/src/main/resources/assets/citrus/lang/en_us.json @@ -0,0 +1,12 @@ +{ + "key.citrus.open_cape_screen": "Open Cape Selection", + "category.citrus.general": "Citrus", + "screen.citrus.cape_selection.title": "Cape Selection", + "key.citrus.overlay_editor": "Overlay Editor", + "key.citrus.refresh_client": "Refresh Client", + "key.citrus.toggle_fullbright": "Toggle Fullbright", + "key.citrus.zoom": "Zoom", + "key.citrus.module_toggle": "Modules", + "key.citrus.open_friends_screen": "Open Friend GUI", + "key.citrus.open_cosmetics_screen": "Open Cosmetics" +} \ No newline at end of file diff --git a/src/main/resources/assets/citrus/textures/capes/xmas.png b/src/main/resources/assets/citrus/textures/capes/xmas.png new file mode 100644 index 0000000..a351e09 Binary files /dev/null and b/src/main/resources/assets/citrus/textures/capes/xmas.png differ diff --git a/src/main/resources/assets/citrus/textures/cosmetics/angel_wings.png b/src/main/resources/assets/citrus/textures/cosmetics/angel_wings.png new file mode 100644 index 0000000..b790080 Binary files /dev/null and b/src/main/resources/assets/citrus/textures/cosmetics/angel_wings.png differ diff --git a/src/main/resources/assets/citrus/textures/cosmetics/halo/angel_halo.png b/src/main/resources/assets/citrus/textures/cosmetics/halo/angel_halo.png new file mode 100644 index 0000000..5b807f3 Binary files /dev/null and b/src/main/resources/assets/citrus/textures/cosmetics/halo/angel_halo.png differ diff --git a/src/main/resources/assets/citrus/textures/cosmetics/hat/test_hat.png b/src/main/resources/assets/citrus/textures/cosmetics/hat/test_hat.png new file mode 100644 index 0000000..7dd696e Binary files /dev/null and b/src/main/resources/assets/citrus/textures/cosmetics/hat/test_hat.png differ diff --git a/src/main/resources/assets/citrus/textures/cosmetics/wings.png b/src/main/resources/assets/citrus/textures/cosmetics/wings.png new file mode 100644 index 0000000..69b26e3 Binary files /dev/null and b/src/main/resources/assets/citrus/textures/cosmetics/wings.png differ diff --git a/src/main/resources/assets/citrus/textures/cosmetics/wings2.png b/src/main/resources/assets/citrus/textures/cosmetics/wings2.png new file mode 100644 index 0000000..b2f7639 Binary files /dev/null and b/src/main/resources/assets/citrus/textures/cosmetics/wings2.png differ diff --git a/src/main/resources/assets/citrus/textures/modules/fps.png b/src/main/resources/assets/citrus/textures/modules/fps.png new file mode 100644 index 0000000..ef399db Binary files /dev/null and b/src/main/resources/assets/citrus/textures/modules/fps.png differ diff --git a/src/main/resources/assets/citrus/textures/ranks/admin.png b/src/main/resources/assets/citrus/textures/ranks/admin.png new file mode 100644 index 0000000..42f4651 Binary files /dev/null and b/src/main/resources/assets/citrus/textures/ranks/admin.png differ diff --git a/src/main/resources/assets/citrus/textures/ranks/bughunter.png b/src/main/resources/assets/citrus/textures/ranks/bughunter.png new file mode 100644 index 0000000..44819dd Binary files /dev/null and b/src/main/resources/assets/citrus/textures/ranks/bughunter.png differ diff --git a/src/main/resources/assets/citrus/textures/ranks/default.png b/src/main/resources/assets/citrus/textures/ranks/default.png new file mode 100644 index 0000000..7b89a39 Binary files /dev/null and b/src/main/resources/assets/citrus/textures/ranks/default.png differ diff --git a/src/main/resources/assets/citrus/textures/ranks/dev.png b/src/main/resources/assets/citrus/textures/ranks/dev.png new file mode 100644 index 0000000..9e181f5 Binary files /dev/null and b/src/main/resources/assets/citrus/textures/ranks/dev.png differ diff --git a/src/main/resources/assets/citrus/textures/ranks/owner.png b/src/main/resources/assets/citrus/textures/ranks/owner.png new file mode 100644 index 0000000..c17da3e Binary files /dev/null and b/src/main/resources/assets/citrus/textures/ranks/owner.png differ diff --git a/src/main/resources/assets/citrus/textures/ranks/vip.png b/src/main/resources/assets/citrus/textures/ranks/vip.png new file mode 100644 index 0000000..fc7af67 Binary files /dev/null and b/src/main/resources/assets/citrus/textures/ranks/vip.png differ diff --git a/src/main/resources/assets/citrus/textures/startscreen/audi.png b/src/main/resources/assets/citrus/textures/startscreen/audi.png new file mode 100644 index 0000000..a367b9d Binary files /dev/null and b/src/main/resources/assets/citrus/textures/startscreen/audi.png differ diff --git a/src/main/resources/assets/citrus/textures/startscreen/bg.png b/src/main/resources/assets/citrus/textures/startscreen/bg.png new file mode 100644 index 0000000..cbc2478 Binary files /dev/null and b/src/main/resources/assets/citrus/textures/startscreen/bg.png differ diff --git a/src/main/resources/assets/citrus/textures/startscreen/discord_icon.png b/src/main/resources/assets/citrus/textures/startscreen/discord_icon.png new file mode 100644 index 0000000..0f3b2c7 Binary files /dev/null and b/src/main/resources/assets/citrus/textures/startscreen/discord_icon.png differ diff --git a/src/main/resources/assets/citrus/textures/startscreen/door_icon.png b/src/main/resources/assets/citrus/textures/startscreen/door_icon.png new file mode 100644 index 0000000..e9a26f0 Binary files /dev/null and b/src/main/resources/assets/citrus/textures/startscreen/door_icon.png differ diff --git a/src/main/resources/assets/citrus/textures/startscreen/logo.png b/src/main/resources/assets/citrus/textures/startscreen/logo.png new file mode 100644 index 0000000..7b89a39 Binary files /dev/null and b/src/main/resources/assets/citrus/textures/startscreen/logo.png differ diff --git a/src/main/resources/assets/citrus/textures/startscreen/multiplayer_icon.png b/src/main/resources/assets/citrus/textures/startscreen/multiplayer_icon.png new file mode 100644 index 0000000..6832ee3 Binary files /dev/null and b/src/main/resources/assets/citrus/textures/startscreen/multiplayer_icon.png differ diff --git a/src/main/resources/assets/citrus/textures/startscreen/settings_icon.png b/src/main/resources/assets/citrus/textures/startscreen/settings_icon.png new file mode 100644 index 0000000..e98b5ed Binary files /dev/null and b/src/main/resources/assets/citrus/textures/startscreen/settings_icon.png differ diff --git a/src/main/resources/assets/citrus/textures/startscreen/singleplayer_icon.png b/src/main/resources/assets/citrus/textures/startscreen/singleplayer_icon.png new file mode 100644 index 0000000..9f94f7f Binary files /dev/null and b/src/main/resources/assets/citrus/textures/startscreen/singleplayer_icon.png differ diff --git a/src/main/resources/assets/citrus/textures/startscreen/wallpaper.png b/src/main/resources/assets/citrus/textures/startscreen/wallpaper.png new file mode 100644 index 0000000..e193720 Binary files /dev/null and b/src/main/resources/assets/citrus/textures/startscreen/wallpaper.png differ diff --git a/src/main/resources/citrus.mixins.json b/src/main/resources/citrus.mixins.json new file mode 100644 index 0000000..b2a98b8 --- /dev/null +++ b/src/main/resources/citrus.mixins.json @@ -0,0 +1,27 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "de.winniepat.citrus.mixins", + "compatibilityLevel": "JAVA_21", + "client": [ + "AbstractClientPlayerEntityMixin", + "CapeTransparencyMixin", + "CreateWorldScreenMixin", + "GameRendererMixin", + "GammaBypassMixin", + "MouseMixin", + "MultiplayerScreenMixin", + "OptionsScreenMixin", + "PlayerListEntryMixin", + "PlayerNametagMixin", + "ScreenRenderMixin", + "ShowOwnNametagMixin", + "SingleplayerScreenMixin", + "TabListMixin", + "TitleScreenMixin", + "WindowTitleMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} \ No newline at end of file diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..c726413 --- /dev/null +++ b/src/main/resources/fabric.mod.json @@ -0,0 +1,55 @@ +{ + "schemaVersion": 1, + "id": "citrus", + "version": "0.1", + + "name": "Citrus", + "description": "Minecraft Client developed by WinniePatGG and others", + "authors": [ + { + "name": "WinniePatGG" + }, + { + "name": "FullRiskk" + }, + { + "name": "AAirCrafter" + } + ], + "contributors": [ + { + "name": "hykaika" + } + ], + "contact": { + "homepage": "https://citrus-client.de", + "sources": "https://github.com/Citrus-Client/Client", + "issues": "https://github.com/Citrus-Client/issues", + "discord": "https://discord.gg/GTK3kS5An8" + }, + + "license": "MIT", + "icon": "assets/citrus/icon.png", + + "environment": "client", + "entrypoints": { + "client": ["de.winniepat.citrus.ClientEntrypoint"], + "main": ["de.winniepat.citrus.ClientEntrypoint"], + "modmenu": ["de.winniepat.citrus.utils.ModMenuIntegration"] + }, + + "mixins": [ + "citrus.mixins.json" + ], + "custom": { + "mixin.refmap": "citrus.refmap.json" + }, + + "depends": { + "fabricloader": ">=0.16.14", + "fabric": "*", + "minecraft": ">=1.21 <1.22", + "forgeconfigapiport": ">=21.0.5", + "geckolib": "*" + } +}