first commit

This commit is contained in:
Patrick
2026-05-01 18:54:57 +02:00
commit 3820e45f0a
127 changed files with 14283 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
.gradle
.idea
build
run
backend
+21
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
# Mal schauen bin lwk zu faul ne neue zu machen
+122
View File
@@ -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
}
}
}
+19
View File
@@ -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
Binary file not shown.
+7
View File
@@ -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
Vendored
+252
View File
@@ -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" "$@"
Vendored
+94
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
maven { url = "https://maven.fabricmc.net/" }
}
}
rootProject.name = "Citrus"
@@ -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;
}
}
@@ -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;
}
}
@@ -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() {}
}
@@ -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<VerificationResult> 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;
}
}
@@ -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();
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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<FabricClientCommandSource> citrusCommand =
LiteralArgumentBuilder.<FabricClientCommandSource>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 <cape_name> §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 <wing_type> §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 <name> §7- Open a client screen"));
return 1;
});
citrusCommand.then(LiteralArgumentBuilder.<FabricClientCommandSource>literal("screen")
.then(RequiredArgumentBuilder
.<FabricClientCommandSource, String>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.<FabricClientCommandSource>literal("cape")
.then(LiteralArgumentBuilder.<FabricClientCommandSource>literal("set")
.then(RequiredArgumentBuilder
.<FabricClientCommandSource, String>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.<FabricClientCommandSource>literal("reload")
.executes(context -> {
context.getSource().sendFeedback(Text.literal("§aReloading capes..."));
CapeHandler.reloadCapes();
return 1;
})
)
);
citrusCommand.then(LiteralArgumentBuilder.<FabricClientCommandSource>literal("capesync")
.then(LiteralArgumentBuilder.<FabricClientCommandSource>literal("clearcache")
.executes(context -> {
CapeSyncManager.clearAllCache();
context.getSource().sendFeedback(Text.literal("§aCache cleared."));
return 1;
})
)
.then(LiteralArgumentBuilder.<FabricClientCommandSource>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<UUID> 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.<FabricClientCommandSource>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;
}
}
@@ -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);
}
}
@@ -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;
})
)
));
}
}
@@ -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<UUID, PlayerCosmeticData> 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<String, JsonElement> 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<UUID, PlayerCosmeticData> 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);
}
}
@@ -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<UUID, PlayerCosmeticData> 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());
}
}
@@ -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());
}
}
@@ -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<UUID, SyncedCosmeticData> SYNCED_COSMETIC_CACHE = new ConcurrentHashMap<>();
private static final Map<UUID, Long> LAST_SYNC_TIME = new ConcurrentHashMap<>();
private static final Map<UUID, SyncedCosmeticData> PENDING_UPDATES = new ConcurrentHashMap<>();
private static final Set<UUID> 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<SyncedCosmeticData> 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<UUID> uuids) {
if (uuids.isEmpty()) return;
List<UUID> 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<UUID, SyncedCosmeticData> 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";
}
}
@@ -0,0 +1,7 @@
package de.winniepat.citrus.cosmetics;
public enum CosmeticType {
WINGS,
HAT,
HALO
}
@@ -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<UUID, Long> 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<UUID> 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<Map.Entry<UUID, Long>> iterator = LAST_SEEN_PLAYERS.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<UUID, Long> 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();
}
}
}
@@ -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<String, CapeEntry> CAPE_CACHE = new ConcurrentHashMap<>();
private static final Map<UUID, String> PLAYER_CAPES = new ConcurrentHashMap<>();
private static final Set<String> LOADING_CAPES = ConcurrentHashMap.newKeySet();
private static final Set<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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));
}
}
@@ -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<UUID, String> SYNCED_CAPE_CACHE = new ConcurrentHashMap<>();
private static final Map<UUID, Long> LAST_SYNC_TIME = new ConcurrentHashMap<>();
private static final Map<UUID, String> PENDING_UPDATES = new ConcurrentHashMap<>();
private static final Set<UUID> 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<String> 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<Boolean> 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<Boolean> 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<UUID> uuids) {
if (uuids.isEmpty()) return;
List<UUID> 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<String> 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<UUID, String> 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<UUID, String> 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<UUID> 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;
}
}
@@ -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<UUID, Long> 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<UUID> 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<Map.Entry<UUID, Long>> iterator = LAST_SEEN_PLAYERS.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<UUID, Long> 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();
}
}
}
@@ -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;
}
}
@@ -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<AbstractClientPlayerEntity, PlayerEntityModel<AbstractClientPlayerEntity>> {
private final GeoObjectRenderer<CosmeticHalo> haloRenderer;
private static final WeakHashMap<PlayerEntity, CosmeticHalo> HALO_CACHE = new WeakHashMap<>();
public HaloFeatureRenderer(FeatureRendererContext<AbstractClientPlayerEntity, PlayerEntityModel<AbstractClientPlayerEntity>> 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);
}
}
@@ -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<CosmeticHalo> {
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;
}
}
@@ -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();
}
}
@@ -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;
}
}
@@ -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<AbstractClientPlayerEntity, PlayerEntityModel<AbstractClientPlayerEntity>> {
private final GeoObjectRenderer<CosmeticHat> hatRenderer;
private static final WeakHashMap<PlayerEntity, CosmeticHat> HAT_CACHE = new WeakHashMap<>();
public HatFeatureRenderer(FeatureRendererContext<AbstractClientPlayerEntity, PlayerEntityModel<AbstractClientPlayerEntity>> 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);
}
}
@@ -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<CosmeticHat> {
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;
}
}
@@ -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();
}
}
@@ -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<UUID, WingData> 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<String, JsonElement> 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<UUID, WingData> 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);
}
}
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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<AbstractClientPlayerEntity, PlayerEntityModel<AbstractClientPlayerEntity>> {
private final GeoObjectRenderer<CosmeticWings> wingRenderer;
private static final WeakHashMap<PlayerEntity, CosmeticWings> WING_CACHE = new WeakHashMap<>();
public WingsFeatureRenderer(FeatureRendererContext<AbstractClientPlayerEntity, PlayerEntityModel<AbstractClientPlayerEntity>> 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);
}
}
@@ -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<CosmeticWings> {
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;
}
}
@@ -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<UUID, Long> 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<UUID> 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<Map.Entry<UUID, Long>> iterator = LAST_SEEN_PLAYERS.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<UUID, Long> 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();
}
}
}
@@ -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<UUID, SyncedWingData> SYNCED_WING_CACHE = new ConcurrentHashMap<>();
private static final Map<UUID, Long> LAST_SYNC_TIME = new ConcurrentHashMap<>();
private static final Map<UUID, SyncedWingData> PENDING_UPDATES = new ConcurrentHashMap<>();
private static final Set<UUID> 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<SyncedWingData> 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<Boolean> 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<UUID> uuids) {
if (uuids.isEmpty()) return;
List<UUID> 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<String> 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<UUID> 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<UUID, SyncedWingData> 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<Boolean> 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";
}
}
@@ -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);
}
}
}
@@ -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<StatusEffectInstance> 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();
}
}
@@ -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<ItemStack> 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<StatusEffectInstance> 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;
}
}
}
}
@@ -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<Boolean> 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);
});
}
}
@@ -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<FlowLayout> {
private final Map<String, ButtonComponent> capeButtons = new HashMap<>();
private LabelComponent statusLabel;
private LabelComponent currentCapeLabel;
private LabelComponent capeCountLabel;
private FlowLayout capeGrid;
private float updateTimer = 0;
@Override
protected @NotNull OwoUIAdapter<FlowLayout> 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);
}
}
@@ -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<String> 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);
}
}
@@ -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<View> 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<Friend> 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<FriendRequest> 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;
}
}
@@ -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<String, ModuleCard> mModuleCards = new LinkedHashMap<>();
private final List<View> 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<String, ModuleCard> 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<String, ModuleCard> 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<String, ModuleCard> 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);
}
}
}
@@ -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<View> 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<String> 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));
});
}
}
}
@@ -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<FlowLayout> {
@Override
protected @NotNull OwoUIAdapter<FlowLayout> 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);
}
}
@@ -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<View> 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;
}
}
@@ -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
}
}
@@ -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<UUID> 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<UUID> 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<UUID> 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<UUID> filterOnlineUsers(List<UUID> playerUUIDs) {
if (playerUUIDs.isEmpty()) return new ArrayList<>();
CompletableFuture<List<UUID>> 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<String> 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<UUID> 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<UUID> getCitrusUsersInWorld() {
MinecraftClient client = MinecraftClient.getInstance();
if (client.world == null) return new ArrayList<>();
List<UUID> 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";
}
}
@@ -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));
}
}
@@ -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<Friend> FRIENDS = Collections.synchronizedList(new ArrayList<>());
private static final List<FriendRequest> 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<Void> 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<Void> 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<Void> 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<Void> 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<String> 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<String> 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<String> 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<String> 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<Friend> getFriends() {
if (System.currentTimeMillis() - lastRefresh > REFRESH_INTERVAL) {
refresh();
}
synchronized (FRIENDS) {
return new ArrayList<>(FRIENDS);
}
}
public static List<FriendRequest> 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);
}
}
}
@@ -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);
}
}
@@ -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<UUID, PlayerProfile> PROFILE_CACHE = new ConcurrentHashMap<>();
private static final long CACHE_EXPIRY = 300000;
private static final Map<UUID, Long> 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<PlayerProfile> 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<PlayerProfile> getOwnProfile() {
UUID uuid = getCurrentUuid();
if (uuid == null) return CompletableFuture.completedFuture(null);
return getProfile(uuid);
}
public static CompletableFuture<String> 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<List<SearchResult>> searchProfiles(String query) {
return CompletableFuture.supplyAsync(() -> {
List<SearchResult> 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;
}
}
@@ -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<UUID, String> 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<String> 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());
});
}
}
@@ -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<String, Identifier> WALLPAPER_CACHE = new ConcurrentHashMap<>();
private static final Map<String, byte[]> WALLPAPER_DATA_CACHE = new ConcurrentHashMap<>();
private static final Set<String> LOADING_WALLPAPERS = ConcurrentHashMap.newKeySet();
private static final Set<String> FAILED_WALLPAPERS = ConcurrentHashMap.newKeySet();
private static final List<String> AVAILABLE_WALLPAPERS = new CopyOnWriteArrayList<>();
private static boolean wallpapersListFetched = false;
private static boolean isInitialized = false;
private static final List<Runnable> 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<String> 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<String> 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<byte[]> 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<Identifier> 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);
}
}
@@ -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));
}
}
@@ -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);
}
}
}
@@ -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);
}
}
@@ -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
);
}
}
@@ -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<Double> info) {
if (ZoomManager.isZooming()) {
double originalFov = info.getReturnValue();
info.setReturnValue(originalFov / ZoomManager.getZoomMultiplier());
}
}
}
@@ -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<Optional<?>> cir) {
cir.setReturnValue(Optional.of(value));
}
}
@@ -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;
}
}
@@ -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
);
}
}
@@ -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();
}
}
@@ -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();
}
@@ -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;
}
}
@@ -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
);
}
}
@@ -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;
}
}
@@ -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
);
}
}
@@ -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<Text> 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));
}
}
}
@@ -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();
}
}
@@ -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<String> cir) {
cir.setReturnValue(Client.clientInfo);
}
}
@@ -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
);
}
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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;
}
}
@@ -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<String, Identifier> CACHE = new ConcurrentHashMap<>();
public static CompletableFuture<Identifier> 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;
}
});
}
}
@@ -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<InputStream> 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
);
}
}
@@ -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
));
}
}
}
@@ -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
}
@@ -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
}
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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"]
}
]
}
@@ -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]
}
]
}
]
}
@@ -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]
}
]
}
]
}
@@ -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]
}
]
}
]
}
@@ -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]
}
]
}
]
}

Some files were not shown because too many files have changed in this diff Show More