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
@@ -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]
}
]
}
]
}
@@ -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]
}
]
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

@@ -0,0 +1,12 @@
{
"key.citrus.open_cape_screen": "Open Cape Selection",
"category.citrus.general": "Citrus",
"screen.citrus.cape_selection.title": "Cape Selection",
"key.citrus.overlay_editor": "Overlay Editor",
"key.citrus.refresh_client": "Refresh Client",
"key.citrus.toggle_fullbright": "Toggle Fullbright",
"key.citrus.zoom": "Zoom",
"key.citrus.module_toggle": "Modules",
"key.citrus.open_friends_screen": "Open Friend GUI",
"key.citrus.open_cosmetics_screen": "Open Cosmetics"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 833 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 686 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

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