aboutsummaryrefslogtreecommitdiff
path: root/src/main/java/de/hysky/skyblocker/utils
diff options
context:
space:
mode:
authorYasin <a.piri@hotmail.de>2023-10-09 12:58:02 +0200
committerYasin <a.piri@hotmail.de>2023-10-09 12:58:02 +0200
commitbd3f0329d0e391bd84b5f9e3ff207d9dd9815853 (patch)
tree2fd1d1ef625f57acc2e4916c967d8d2393844798 /src/main/java/de/hysky/skyblocker/utils
parent2315b90da8117f28f66348927afdb621ee4fc815 (diff)
downloadSkyblocker-bd3f0329d0e391bd84b5f9e3ff207d9dd9815853.tar.gz
Skyblocker-bd3f0329d0e391bd84b5f9e3ff207d9dd9815853.tar.bz2
Skyblocker-bd3f0329d0e391bd84b5f9e3ff207d9dd9815853.zip
new pr because fixing merge conflict would take too long
Diffstat (limited to 'src/main/java/de/hysky/skyblocker/utils')
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/Boxes.java50
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/Constants.java8
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/Http.java89
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/ItemUtils.java111
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/NEURepo.java101
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/PosUtils.java14
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/SlayerUtils.java54
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/Utils.java370
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/chat/ChatFilterResult.java18
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/chat/ChatMessageListener.java89
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/chat/ChatPatternListener.java30
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/discord/DiscordRPCManager.java122
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/render/FrustumUtils.java21
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/render/RenderHelper.java247
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/render/culling/OcclusionCulling.java47
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/render/culling/WorldProvider.java28
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/render/culling/package-info.java4
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/render/gui/ColorHighlight.java24
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/render/gui/ContainerSolver.java44
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/render/gui/ContainerSolverManager.java125
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/render/title/Title.java53
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/render/title/TitleContainer.java175
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/render/title/TitleContainerConfigScreen.java170
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/scheduler/MessageScheduler.java66
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/scheduler/Scheduler.java140
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/tictactoe/TicTacToeUtils.java104
26 files changed, 2304 insertions, 0 deletions
diff --git a/src/main/java/de/hysky/skyblocker/utils/Boxes.java b/src/main/java/de/hysky/skyblocker/utils/Boxes.java
new file mode 100644
index 00000000..cd944a46
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/utils/Boxes.java
@@ -0,0 +1,50 @@
+package de.hysky.skyblocker.utils;
+
+import net.minecraft.util.math.Box;
+import net.minecraft.util.math.Direction.Axis;
+import net.minecraft.util.math.Vec3d;
+
+public class Boxes {
+ /** Returns the vector of the min pos of this box. **/
+ public static Vec3d getMinVec(Box box) {
+ return new Vec3d(box.minX, box.minY, box.minZ);
+ }
+
+ /** Returns the vector of the max pos of this box. **/
+ public static Vec3d getMaxVec(Box box) {
+ return new Vec3d(box.maxX, box.maxY, box.maxZ);
+ }
+
+ /** Returns the vector of the side lengths of this box. **/
+ public static Vec3d getLengthVec(Box box) {
+ return new Vec3d(box.getLengthX(), box.getLengthY(), box.getLengthZ());
+ }
+
+ /** Offsets this box so that minX, minY and minZ are all zero. **/
+ public static Box moveToZero(Box box) {
+ return box.offset(getMinVec(box).negate());
+ }
+
+ /** Returns the distance between to oppisite corners of the box. **/
+ public static double getCornerLength(Box box) {
+ return getMinVec(box).distanceTo(getMaxVec(box));
+ }
+
+ /** Returns the length of an axis in the box. **/
+ public static double getAxisLength(Box box, Axis axis) {
+ return box.getMax(axis) - box.getMin(axis);
+ }
+
+ /** Returns a box with each axis multiplied by the amount specified. **/
+ public static Box multiply(Box box, double amount) {
+ return multiply(box, amount, amount, amount);
+ }
+
+ /** Returns a box with each axis multiplied by the amount specified. **/
+ public static Box multiply(Box box, double x, double y, double z) {
+ return box.expand(
+ getAxisLength(box, Axis.X) * (x - 1) / 2d,
+ getAxisLength(box, Axis.Y) * (y - 1) / 2d,
+ getAxisLength(box, Axis.Z) * (z - 1) / 2d);
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/utils/Constants.java b/src/main/java/de/hysky/skyblocker/utils/Constants.java
new file mode 100644
index 00000000..fbeb448c
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/utils/Constants.java
@@ -0,0 +1,8 @@
+package de.hysky.skyblocker.utils;
+
+/**
+ * Holds generic static constants
+ */
+public interface Constants {
+ String LEVEL_EMBLEMS = "\u2E15\u273F\u2741\u2E19\u03B1\u270E\u2615\u2616\u2663\u213B\u2694\u27B6\u26A1\u2604\u269A\u2693\u2620\u269B\u2666\u2660\u2764\u2727\u238A\u1360\u262C\u269D\u29C9\uA214\u32D6\u2E0E\u26A0\uA541\u3020\u30C4\u2948\u2622\u2623\u273E\u269C\u0BD0\u0A6D\u2742\u16C3\u3023\u10F6\u0444\u266A\u266B\u04C3\u26C1\u26C3\u16DD\uA03E\u1C6A\u03A3\u09EB\u2603\u2654\u26C2\u12DE";
+}
diff --git a/src/main/java/de/hysky/skyblocker/utils/Http.java b/src/main/java/de/hysky/skyblocker/utils/Http.java
new file mode 100644
index 00000000..ee500b5a
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/utils/Http.java
@@ -0,0 +1,89 @@
+package de.hysky.skyblocker.utils;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpClient.Version;
+import java.net.http.HttpHeaders;
+import java.net.http.HttpRequest;
+import java.net.http.HttpRequest.BodyPublishers;
+import java.net.http.HttpResponse;
+import java.net.http.HttpResponse.BodyHandlers;
+import java.time.Duration;
+import java.util.zip.GZIPInputStream;
+import java.util.zip.InflaterInputStream;
+
+import de.hysky.skyblocker.SkyblockerMod;
+import net.minecraft.SharedConstants;
+
+/**
+ * @implNote All http requests are sent using HTTP 2
+ */
+public class Http {
+ private static final String USER_AGENT = "Skyblocker/" + SkyblockerMod.VERSION + " (" + SharedConstants.getGameVersion().getName() + ")";
+ private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(10))
+ .build();
+
+ public static String sendGetRequest(String url) throws IOException, InterruptedException {
+ HttpRequest request = HttpRequest.newBuilder()
+ .GET()
+ .header("Accept", "application/json")
+ .header("Accept-Encoding", "gzip, deflate")
+ .header("User-Agent", USER_AGENT)
+ .version(Version.HTTP_2)
+ .uri(URI.create(url))
+ .build();
+
+ HttpResponse<InputStream> response = HTTP_CLIENT.send(request, BodyHandlers.ofInputStream());
+ InputStream decodedInputStream = getDecodedInputStream(response);
+ String body = new String(decodedInputStream.readAllBytes());
+
+ return body;
+ }
+
+ public static HttpHeaders sendHeadRequest(String url) throws IOException, InterruptedException {
+ HttpRequest request = HttpRequest.newBuilder()
+ .method("HEAD", BodyPublishers.noBody())
+ .header("User-Agent", USER_AGENT)
+ .version(Version.HTTP_2)
+ .uri(URI.create(url))
+ .build();
+
+ HttpResponse<Void> response = HTTP_CLIENT.send(request, BodyHandlers.discarding());
+ return response.headers();
+ }
+
+ private static InputStream getDecodedInputStream(HttpResponse<InputStream> response) {
+ String encoding = getContentEncoding(response);
+
+ try {
+ switch (encoding) {
+ case "":
+ return response.body();
+ case "gzip":
+ return new GZIPInputStream(response.body());
+ case "deflate":
+ return new InflaterInputStream(response.body());
+ default:
+ throw new UnsupportedOperationException("The server sent content in an unexpected encoding: " + encoding);
+ }
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ private static String getContentEncoding(HttpResponse<InputStream> response) {
+ return response.headers().firstValue("Content-Encoding").orElse("");
+ }
+
+ public static String getEtag(HttpHeaders headers) {
+ return headers.firstValue("Etag").orElse("");
+ }
+
+ public static String getLastModified(HttpHeaders headers) {
+ return headers.firstValue("Last-Modified").orElse("");
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/utils/ItemUtils.java b/src/main/java/de/hysky/skyblocker/utils/ItemUtils.java
new file mode 100644
index 00000000..6ae1b4d0
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/utils/ItemUtils.java
@@ -0,0 +1,111 @@
+package de.hysky.skyblocker.utils;
+
+import com.mojang.brigadier.exceptions.CommandSyntaxException;
+import de.hysky.skyblocker.config.SkyblockerConfigManager;
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.item.TooltipContext;
+import net.minecraft.item.ItemStack;
+import net.minecraft.nbt.NbtCompound;
+import net.minecraft.nbt.StringNbtReader;
+import net.minecraft.text.Text;
+import net.minecraft.util.Formatting;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.regex.Pattern;
+
+public class ItemUtils {
+ private final static Pattern WHITESPACES = Pattern.compile("^\\s*$");
+
+ public static List<Text> getTooltip(ItemStack item) {
+ MinecraftClient client = MinecraftClient.getInstance();
+ return client.player == null || item == null ? Collections.emptyList() : item.getTooltip(client.player, TooltipContext.Default.BASIC);
+ }
+
+ public static List<String> getTooltipStrings(ItemStack item) {
+ List<Text> lines = getTooltip(item);
+ List<String> list = new ArrayList<>();
+
+ for (Text line : lines) {
+ String string = line.getString();
+ if (!WHITESPACES.matcher(string).matches())
+ list.add(string);
+ }
+
+ return list;
+ }
+
+ @Nullable
+ public static Durability getDurability(ItemStack stack) {
+ if (!Utils.isOnSkyblock() || !SkyblockerConfigManager.get().locations.dwarvenMines.enableDrillFuel || stack.isEmpty()) {
+ return null;
+ }
+
+ NbtCompound tag = stack.getNbt();
+ if (tag == null || !tag.contains("ExtraAttributes")) {
+ return null;
+ }
+
+ NbtCompound extraAttributes = tag.getCompound("ExtraAttributes");
+ if (!extraAttributes.contains("drill_fuel") && !extraAttributes.getString("id").equals("PICKONIMBUS")) {
+ return null;
+ }
+
+ int current = 0;
+ int max = 0;
+ String clearFormatting;
+
+ for (String line : ItemUtils.getTooltipStrings(stack)) {
+ clearFormatting = Formatting.strip(line);
+ if (line.contains("Fuel: ")) {
+ if (clearFormatting != null) {
+ String clear = Pattern.compile("[^0-9 /]").matcher(clearFormatting).replaceAll("").trim();
+ String[] split = clear.split("/");
+ current = Integer.parseInt(split[0]);
+ max = Integer.parseInt(split[1]) * 1000;
+ return new Durability(current, max);
+ }
+ } else if (line.contains("uses.")) {
+ if (clearFormatting != null) {
+ int startIndex = clearFormatting.lastIndexOf("after") + 6;
+ int endIndex = clearFormatting.indexOf("uses", startIndex);
+ if (startIndex >= 0 && endIndex > startIndex) {
+ String usesString = clearFormatting.substring(startIndex, endIndex).trim();
+ current = Integer.parseInt(usesString);
+ max = 5000;
+ }
+ return new Durability(current, max);
+ }
+ }
+ }
+
+ return null;
+ }
+
+ public static ItemStack getSkyblockerStack() {
+ try {
+ return ItemStack.fromNbt(StringNbtReader.parse("{id:\"minecraft:player_head\",Count:1,tag:{SkullOwner:{Id:[I;-300151517,-631415889,-1193921967,-1821784279],Properties:{textures:[{Value:\"e3RleHR1cmVzOntTS0lOOnt1cmw6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZDdjYzY2ODc0MjNkMDU3MGQ1NTZhYzUzZTA2NzZjYjU2M2JiZGQ5NzE3Y2Q4MjY5YmRlYmVkNmY2ZDRlN2JmOCJ9fX0=\"}]}}}}"));
+ } catch (CommandSyntaxException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public static String getItemId(ItemStack itemStack) {
+ if (itemStack == null) return null;
+
+ NbtCompound nbt = itemStack.getNbt();
+ if (nbt != null && nbt.contains("ExtraAttributes")) {
+ NbtCompound extraAttributes = nbt.getCompound("ExtraAttributes");
+ if (extraAttributes.contains("id")) {
+ return extraAttributes.getString("id");
+ }
+ }
+
+ return null;
+ }
+
+ public record Durability(int current, int max) {
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/utils/NEURepo.java b/src/main/java/de/hysky/skyblocker/utils/NEURepo.java
new file mode 100644
index 00000000..9bc6b245
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/utils/NEURepo.java
@@ -0,0 +1,101 @@
+package de.hysky.skyblocker.utils;
+
+import de.hysky.skyblocker.SkyblockerMod;
+import de.hysky.skyblocker.skyblock.itemlist.ItemRegistry;
+import net.fabricmc.fabric.api.client.command.v2.ClientCommandManager;
+import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback;
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.text.Text;
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.api.errors.TransportException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * Initializes the NEU repo, which contains item metadata and fairy souls location data. Clones the repo if it does not exist and checks for updates. Use {@link #runAsyncAfterLoad(Runnable)} to run code after the repo is initialized.
+ */
+public class NEURepo {
+ private static final Logger LOGGER = LoggerFactory.getLogger(NEURepo.class);
+ public static final String REMOTE_REPO_URL = "https://github.com/NotEnoughUpdates/NotEnoughUpdates-REPO.git";
+ public static final Path LOCAL_REPO_DIR = SkyblockerMod.CONFIG_DIR.resolve("item-repo");
+ private static final CompletableFuture<Void> REPO_INITIALIZED = initRepository();
+
+ /**
+ * Adds command to update repository manually from ingame.
+ * <p></p>
+ * TODO A button could be added to the settings menu that will trigger this command.
+ */
+ public static void init() {
+ ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) ->
+ dispatcher.register(ClientCommandManager.literal(SkyblockerMod.NAMESPACE)
+ .then(ClientCommandManager.literal("updaterepository").executes(context -> {
+ deleteAndDownloadRepository();
+ return 1;
+ }))));
+ }
+
+ private static CompletableFuture<Void> initRepository() {
+ return CompletableFuture.runAsync(() -> {
+ try {
+ if (Files.isDirectory(NEURepo.LOCAL_REPO_DIR)) {
+ try (Git localRepo = Git.open(NEURepo.LOCAL_REPO_DIR.toFile())) {
+ localRepo.pull().setRebase(true).call();
+ LOGGER.info("[Skyblocker] NEU Repository Updated");
+ }
+ } else {
+ Git.cloneRepository().setURI(REMOTE_REPO_URL).setDirectory(NEURepo.LOCAL_REPO_DIR.toFile()).setBranchesToClone(List.of("refs/heads/master")).setBranch("refs/heads/master").call().close();
+ LOGGER.info("[Skyblocker] NEU Repository Downloaded");
+ }
+ } catch (TransportException e){
+ LOGGER.error("[Skyblocker] Transport operation failed. Most likely unable to connect to the remote NEU repo on github", e);
+ } catch (RepositoryNotFoundException e) {
+ LOGGER.warn("[Skyblocker] Local NEU Repository not found or corrupted, downloading new one", e);
+ deleteAndDownloadRepository();
+ } catch (Exception e) {
+ LOGGER.error("[Skyblocker] Encountered unknown exception while initializing NEU Repository", e);
+ }
+ });
+ }
+
+ private static void deleteAndDownloadRepository() {
+ CompletableFuture.runAsync(() -> {
+ try {
+ ItemRegistry.filesImported = false;
+ File dir = NEURepo.LOCAL_REPO_DIR.toFile();
+ recursiveDelete(dir);
+ } catch (Exception ex) {
+ if (MinecraftClient.getInstance().player != null)
+ MinecraftClient.getInstance().player.sendMessage(Text.translatable("skyblocker.updaterepository.failed"), false);
+ return;
+ }
+ initRepository();
+ });
+ }
+
+ @SuppressWarnings("ResultOfMethodCallIgnored")
+ private static void recursiveDelete(File dir) {
+ File[] children;
+ if (dir.isDirectory() && !Files.isSymbolicLink(dir.toPath()) && (children = dir.listFiles()) != null) {
+ for (File child : children) {
+ recursiveDelete(child);
+ }
+ }
+ dir.delete();
+ }
+
+ /**
+ * Runs the given runnable after the NEU repo is initialized.
+ * @param runnable the runnable to run
+ * @return a completable future of the given runnable
+ */
+ public static CompletableFuture<Void> runAsyncAfterLoad(Runnable runnable) {
+ return REPO_INITIALIZED.thenRunAsync(runnable);
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/utils/PosUtils.java b/src/main/java/de/hysky/skyblocker/utils/PosUtils.java
new file mode 100644
index 00000000..6a34b060
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/utils/PosUtils.java
@@ -0,0 +1,14 @@
+package de.hysky.skyblocker.utils;
+
+import net.minecraft.util.math.BlockPos;
+
+public final class PosUtils {
+ public static BlockPos parsePosString(String posData) {
+ String[] posArray = posData.split(",");
+ return new BlockPos(Integer.parseInt(posArray[0]), Integer.parseInt(posArray[1]), Integer.parseInt(posArray[2]));
+ }
+
+ public static String getPosString(BlockPos blockPos) {
+ return blockPos.getX() + "," + blockPos.getY() + "," + blockPos.getZ();
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/utils/SlayerUtils.java b/src/main/java/de/hysky/skyblocker/utils/SlayerUtils.java
new file mode 100644
index 00000000..0a42c6ae
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/utils/SlayerUtils.java
@@ -0,0 +1,54 @@
+package de.hysky.skyblocker.utils;
+
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.entity.Entity;
+import net.minecraft.entity.decoration.ArmorStandEntity;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.List;
+
+//TODO Slayer Packet system that can provide information about the current slayer boss, abstract so that different bosses can have different info
+public class SlayerUtils {
+ private static final Logger LOGGER = LoggerFactory.getLogger(SlayerUtils.class);
+
+ //TODO: Cache this, probably included in Packet system
+ public static List<Entity> getEntityArmorStands(Entity entity) {
+ return entity.getEntityWorld().getOtherEntities(entity, entity.getBoundingBox().expand(1F, 2.5F, 1F), x -> x instanceof ArmorStandEntity && x.hasCustomName());
+ }
+
+ //Eventually this should be modified so that if you hit a slayer boss all slayer features will work on that boss.
+ public static Entity getSlayerEntity() {
+ if (MinecraftClient.getInstance().world != null) {
+ for (Entity entity : MinecraftClient.getInstance().world.getEntities()) {
+ //Check if entity is Bloodfiend
+ if (entity.hasCustomName() && entity.getCustomName().getString().contains("Bloodfiend")) {
+ //Grab the players username
+ String username = MinecraftClient.getInstance().getSession().getUsername();
+ //Check all armor stands around the boss
+ for (Entity armorStand : getEntityArmorStands(entity)) {
+ //Check if the display name contains the players username
+ if (armorStand.getDisplayName().getString().contains(username)) {
+ return entity;
+ }
+ }
+ }
+ }
+ }
+ return null;
+ }
+
+ public static boolean isInSlayer() {
+ try {
+ for (int i = 0; i < Utils.STRING_SCOREBOARD.size(); i++) {
+ String line = Utils.STRING_SCOREBOARD.get(i);
+
+ if (line.contains("Slay the boss!")) return true;
+ }
+ } catch (NullPointerException e) {
+ LOGGER.error("[Skyblocker] Error while checking if player is in slayer", e);
+ }
+
+ return false;
+ }
+} \ No newline at end of file
diff --git a/src/main/java/de/hysky/skyblocker/utils/Utils.java b/src/main/java/de/hysky/skyblocker/utils/Utils.java
new file mode 100644
index 00000000..f046bffb
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/utils/Utils.java
@@ -0,0 +1,370 @@
+package de.hysky.skyblocker.utils;
+
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+
+import de.hysky.skyblocker.events.SkyblockEvents;
+import it.unimi.dsi.fastutil.objects.ObjectArrayList;
+import de.hysky.skyblocker.skyblock.item.PriceInfoTooltip;
+import de.hysky.skyblocker.skyblock.rift.TheRift;
+import de.hysky.skyblocker.utils.scheduler.MessageScheduler;
+import net.fabricmc.fabric.api.client.item.v1.ItemTooltipCallback;
+import net.fabricmc.fabric.api.client.message.v1.ClientReceiveMessageEvents;
+import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
+import net.fabricmc.fabric.api.networking.v1.PacketSender;
+import net.fabricmc.loader.api.FabricLoader;
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.network.ClientPlayNetworkHandler;
+import net.minecraft.client.network.ClientPlayerEntity;
+import net.minecraft.client.network.PlayerListEntry;
+import net.minecraft.scoreboard.Scoreboard;
+import net.minecraft.scoreboard.ScoreboardDisplaySlot;
+import net.minecraft.scoreboard.ScoreboardObjective;
+import net.minecraft.scoreboard.ScoreboardPlayerScore;
+import net.minecraft.scoreboard.Team;
+import net.minecraft.text.Text;
+import net.minecraft.util.Formatting;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Utility variables and methods for retrieving Skyblock related information.
+ */
+public class Utils {
+ private static final Logger LOGGER = LoggerFactory.getLogger(Utils.class);
+ private static final String ALTERNATE_HYPIXEL_ADDRESS = System.getProperty("skyblocker.alternateHypixelAddress", "");
+ private static final String PROFILE_PREFIX = "Profile: ";
+ private static boolean isOnHypixel = false;
+ private static boolean isOnSkyblock = false;
+ private static boolean isInDungeons = false;
+ private static boolean isInjected = false;
+ /**
+ * The following fields store data returned from /locraw: {@link #profile}, {@link #server}, {@link #gameType}, {@link #locationRaw}, and {@link #map}.
+ */
+ @SuppressWarnings("JavadocDeclaration")
+ private static String profile = "";
+ private static String server = "";
+ private static String gameType = "";
+ private static String locationRaw = "";
+ private static String map = "";
+ private static long clientWorldJoinTime = 0;
+ private static boolean sentLocRaw = false;
+ private static boolean canSendLocRaw = false;
+
+ /**
+ * @implNote The parent text will always be empty, the actual text content is inside the text's siblings.
+ */
+ public static final ObjectArrayList<Text> TEXT_SCOREBOARD = new ObjectArrayList<>();
+ public static final ObjectArrayList<String> STRING_SCOREBOARD = new ObjectArrayList<>();
+
+ public static boolean isOnHypixel() {
+ return isOnHypixel;
+ }
+
+ public static boolean isOnSkyblock() {
+ return isOnSkyblock;
+ }
+
+ public static boolean isInDungeons() {
+ return isInDungeons;
+ }
+
+ public static boolean isInTheRift() {
+ return getLocationRaw().equals(TheRift.LOCATION);
+ }
+
+ public static boolean isInjected() {
+ return isInjected;
+ }
+
+ /**
+ * @return the profile parsed from the player list.
+ */
+ public static String getProfile() {
+ return profile;
+ }
+
+ /**
+ * @return the server parsed from /locraw.
+ */
+ public static String getServer() {
+ return server;
+ }
+
+ /**
+ * @return the game type parsed from /locraw.
+ */
+ public static String getGameType() {
+ return gameType;
+ }
+
+ /**
+ * @return the location raw parsed from /locraw.
+ */
+ public static String getLocationRaw() {
+ return locationRaw;
+ }
+
+ /**
+ * @return the map parsed from /locraw.
+ */
+ public static String getMap() {
+ return map;
+ }
+
+ public static void init() {
+ ClientPlayConnectionEvents.JOIN.register(Utils::onClientWorldJoin);
+ ClientReceiveMessageEvents.ALLOW_GAME.register(Utils::onChatMessage);
+ ClientReceiveMessageEvents.GAME_CANCELED.register(Utils::onChatMessage); // Somehow this works even though onChatMessage returns a boolean
+ }
+
+ /**
+ * Updates all the fields stored in this class from the sidebar, player list, and /locraw.
+ */
+ public static void update() {
+ MinecraftClient client = MinecraftClient.getInstance();
+ updateScoreboard(client);
+ updatePlayerPresenceFromScoreboard(client);
+ updateFromPlayerList(client);
+ updateLocRaw();
+ }
+
+ /**
+ * Updates {@link #isOnSkyblock}, {@link #isInDungeons}, and {@link #isInjected} from the scoreboard.
+ */
+ public static void updatePlayerPresenceFromScoreboard(MinecraftClient client) {
+ List<String> sidebar = STRING_SCOREBOARD;
+
+ FabricLoader fabricLoader = FabricLoader.getInstance();
+ if ((client.world == null || client.isInSingleplayer() || sidebar == null || sidebar.isEmpty())) {
+ if (fabricLoader.isDevelopmentEnvironment()) {
+ sidebar = Collections.emptyList();
+ } else {
+ isOnSkyblock = false;
+ isInDungeons = false;
+ return;
+ }
+ }
+
+ if (sidebar.isEmpty() && !fabricLoader.isDevelopmentEnvironment()) return;
+ String string = sidebar.toString();
+
+ if (fabricLoader.isDevelopmentEnvironment() || isConnectedToHypixel(client)) {
+ if (!isOnHypixel) {
+ isOnHypixel = true;
+ }
+ if (fabricLoader.isDevelopmentEnvironment() || sidebar.get(0).contains("SKYBLOCK") || sidebar.get(0).contains("SKIBLOCK")) {
+ if (!isOnSkyblock) {
+ if (!isInjected) {
+ isInjected = true;
+ ItemTooltipCallback.EVENT.register(PriceInfoTooltip::onInjectTooltip);
+ }
+ isOnSkyblock = true;
+ SkyblockEvents.JOIN.invoker().onSkyblockJoin();
+ }
+ } else {
+ onLeaveSkyblock();
+ }
+ isInDungeons = fabricLoader.isDevelopmentEnvironment() || isOnSkyblock && string.contains("The Catacombs");
+ } else if (isOnHypixel) {
+ isOnHypixel = false;
+ onLeaveSkyblock();
+ }
+ }
+
+ private static boolean isConnectedToHypixel(MinecraftClient client) {
+ String serverAddress = (client.getCurrentServerEntry() != null) ? client.getCurrentServerEntry().address.toLowerCase() : "";
+ String serverBrand = (client.player != null && client.player.networkHandler != null && client.player.networkHandler.getBrand() != null) ? client.player.networkHandler.getBrand() : "";
+
+ return serverAddress.equalsIgnoreCase(ALTERNATE_HYPIXEL_ADDRESS) || serverAddress.contains("hypixel.net") || serverAddress.contains("hypixel.io") || serverBrand.contains("Hypixel BungeeCord");
+ }
+
+ private static void onLeaveSkyblock() {
+ if (isOnSkyblock) {
+ isOnSkyblock = false;
+ isInDungeons = false;
+ SkyblockEvents.LEAVE.invoker().onSkyblockLeave();
+ }
+ }
+
+ public static String getLocation() {
+ String location = null;
+ List<String> sidebarLines = STRING_SCOREBOARD;
+ try {
+ if (sidebarLines != null) {
+ for (String sidebarLine : sidebarLines) {
+ if (sidebarLine.contains("⏣")) location = sidebarLine;
+ if (sidebarLine.contains("ф")) location = sidebarLine; //Rift
+ }
+ if (location == null) location = "Unknown";
+ location = location.strip();
+ }
+ } catch (IndexOutOfBoundsException e) {
+ LOGGER.error("[Skyblocker] Failed to get location from sidebar", e);
+ }
+ return location;
+ }
+
+ public static double getPurse() {
+ String purseString = null;
+ double purse = 0;
+
+ List<String> sidebarLines = STRING_SCOREBOARD;
+ try {
+
+ if (sidebarLines != null) {
+ for (String sidebarLine : sidebarLines) {
+ if (sidebarLine.contains("Piggy:")) purseString = sidebarLine;
+ if (sidebarLine.contains("Purse:")) purseString = sidebarLine;
+ }
+ }
+ if (purseString != null) purse = Double.parseDouble(purseString.replaceAll("[^0-9.]", "").strip());
+ else purse = 0;
+
+ } catch (IndexOutOfBoundsException e) {
+ LOGGER.error("[Skyblocker] Failed to get purse from sidebar", e);
+ }
+ return purse;
+ }
+
+ public static int getBits() {
+ int bits = 0;
+ String bitsString = null;
+ List<String> sidebarLines = STRING_SCOREBOARD;
+ try {
+ if (sidebarLines != null) {
+ for (String sidebarLine : sidebarLines) {
+ if (sidebarLine.contains("Bits")) bitsString = sidebarLine;
+ }
+ }
+ if (bitsString != null) {
+ bits = Integer.parseInt(bitsString.replaceAll("[^0-9.]", "").strip());
+ }
+ } catch (IndexOutOfBoundsException e) {
+ LOGGER.error("[Skyblocker] Failed to get bits from sidebar", e);
+ }
+ return bits;
+ }
+
+ private static void updateScoreboard(MinecraftClient client) {
+ try {
+ TEXT_SCOREBOARD.clear();
+ STRING_SCOREBOARD.clear();
+
+ ClientPlayerEntity player = client.player;
+ if (player == null) return;
+
+ Scoreboard scoreboard = player.getScoreboard();
+ ScoreboardObjective objective = scoreboard.getObjectiveForSlot(ScoreboardDisplaySlot.FROM_ID.apply(1));
+ ObjectArrayList<Text> textLines = new ObjectArrayList<>();
+ ObjectArrayList<String> stringLines = new ObjectArrayList<>();
+
+ for (ScoreboardPlayerScore score : scoreboard.getAllPlayerScores(objective)) {
+ Team team = scoreboard.getPlayerTeam(score.getPlayerName());
+
+ if (team != null) {
+ Text textLine = Text.empty().append(team.getPrefix().copy()).append(team.getSuffix().copy());
+ String strLine = team.getPrefix().getString() + team.getSuffix().getString();
+
+ if (!strLine.trim().isEmpty()) {
+ String formatted = Formatting.strip(strLine);
+
+ textLines.add(textLine);
+ stringLines.add(formatted);
+ }
+ }
+ }
+
+ if (objective != null) {
+ stringLines.add(objective.getDisplayName().getString());
+ textLines.add(Text.empty().append(objective.getDisplayName().copy()));
+
+ Collections.reverse(stringLines);
+ Collections.reverse(textLines);
+ }
+
+ TEXT_SCOREBOARD.addAll(textLines);
+ STRING_SCOREBOARD.addAll(stringLines);
+ } catch (NullPointerException e) {
+ //Do nothing
+ }
+ }
+
+ private static void updateFromPlayerList(MinecraftClient client) {
+ if (client.getNetworkHandler() == null) {
+ return;
+ }
+ for (PlayerListEntry playerListEntry : client.getNetworkHandler().getPlayerList()) {
+ if (playerListEntry.getDisplayName() == null) {
+ continue;
+ }
+ String name = playerListEntry.getDisplayName().getString();
+ if (name.startsWith(PROFILE_PREFIX)) {
+ profile = name.substring(PROFILE_PREFIX.length());
+ }
+ }
+ }
+
+ public static void onClientWorldJoin(ClientPlayNetworkHandler handler, PacketSender sender, MinecraftClient client) {
+ clientWorldJoinTime = System.currentTimeMillis();
+ resetLocRawInfo();
+ }
+
+ /**
+ * Sends /locraw to the server if the player is on skyblock and on a new island.
+ */
+ private static void updateLocRaw() {
+ if (isOnSkyblock) {
+ long currentTime = System.currentTimeMillis();
+ if (!sentLocRaw && canSendLocRaw && currentTime > clientWorldJoinTime + 1000) {
+ MessageScheduler.INSTANCE.sendMessageAfterCooldown("/locraw");
+ sentLocRaw = true;
+ canSendLocRaw = false;
+ }
+ } else {
+ resetLocRawInfo();
+ }
+ }
+
+ /**
+ * Parses the /locraw reply from the server
+ *
+ * @return not display the message in chat is the command is sent by the mod
+ */
+ public static boolean onChatMessage(Text text, boolean overlay) {
+ String message = text.getString();
+ if (message.startsWith("{\"server\":") && message.endsWith("}")) {
+ JsonObject locRaw = JsonParser.parseString(message).getAsJsonObject();
+ if (locRaw.has("server")) {
+ server = locRaw.get("server").getAsString();
+ if (locRaw.has("gameType")) {
+ gameType = locRaw.get("gameType").getAsString();
+ }
+ if (locRaw.has("mode")) {
+ locationRaw = locRaw.get("mode").getAsString();
+ }
+ if (locRaw.has("map")) {
+ map = locRaw.get("map").getAsString();
+ }
+
+ boolean shouldFilter = !sentLocRaw;
+ sentLocRaw = false;
+
+ return shouldFilter;
+ }
+ }
+ return true;
+ }
+
+ private static void resetLocRawInfo() {
+ sentLocRaw = false;
+ canSendLocRaw = true;
+ server = "";
+ gameType = "";
+ locationRaw = "";
+ map = "";
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/utils/chat/ChatFilterResult.java b/src/main/java/de/hysky/skyblocker/utils/chat/ChatFilterResult.java
new file mode 100644
index 00000000..5a94682a
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/utils/chat/ChatFilterResult.java
@@ -0,0 +1,18 @@
+package de.hysky.skyblocker.utils.chat;
+
+import net.minecraft.client.resource.language.I18n;
+public enum ChatFilterResult {
+ // Skip this one / no action
+ PASS,
+ // Filter
+ FILTER,
+ // Move to action bar
+ ACTION_BAR;
+ // Skip remaining checks, don't filter
+ // null
+
+ @Override
+ public String toString() {
+ return I18n.translate("text.autoconfig.skyblocker.option.messages.chatFilterResult." + name());
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/utils/chat/ChatMessageListener.java b/src/main/java/de/hysky/skyblocker/utils/chat/ChatMessageListener.java
new file mode 100644
index 00000000..7892445e
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/utils/chat/ChatMessageListener.java
@@ -0,0 +1,89 @@
+package de.hysky.skyblocker.utils.chat;
+
+import de.hysky.skyblocker.skyblock.filters.*;
+import de.hysky.skyblocker.utils.Utils;
+import de.hysky.skyblocker.skyblock.barn.HungryHiker;
+import de.hysky.skyblocker.skyblock.barn.TreasureHunter;
+import de.hysky.skyblocker.skyblock.dungeon.Reparty;
+import de.hysky.skyblocker.skyblock.dungeon.ThreeWeirdos;
+import de.hysky.skyblocker.skyblock.dungeon.Trivia;
+import de.hysky.skyblocker.skyblock.dwarven.Fetchur;
+import de.hysky.skyblocker.skyblock.dwarven.Puzzler;
+import de.hysky.skyblocker.skyblock.filters.*;
+import net.fabricmc.fabric.api.client.message.v1.ClientReceiveMessageEvents;
+import net.fabricmc.fabric.api.event.Event;
+import net.fabricmc.fabric.api.event.EventFactory;
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.network.ClientPlayerEntity;
+import net.minecraft.text.Text;
+
+@FunctionalInterface
+public interface ChatMessageListener {
+ /**
+ * An event called when a game message is received. Register your listeners in {@link ChatMessageListener#init()}.
+ */
+ Event<ChatMessageListener> EVENT = EventFactory.createArrayBacked(ChatMessageListener.class,
+ (listeners) -> (message, asString) -> {
+ for (ChatMessageListener listener : listeners) {
+ ChatFilterResult result = listener.onMessage(message, asString);
+ if (result != ChatFilterResult.PASS) return result;
+ }
+ return ChatFilterResult.PASS;
+ });
+
+ /**
+ * Registers {@link ChatMessageListener}s to {@link ChatMessageListener#EVENT} and registers {@link ChatMessageListener#EVENT} to {@link ClientReceiveMessageEvents#ALLOW_GAME}
+ */
+ static void init() {
+ ChatMessageListener[] listeners = new ChatMessageListener[]{
+ // Features
+ new Fetchur(),
+ new Puzzler(),
+ new Reparty(),
+ new ThreeWeirdos(),
+ new Trivia(),
+ new TreasureHunter(),
+ new HungryHiker(),
+ // Filters
+ new AbilityFilter(),
+ new AdFilter(),
+ new AoteFilter(),
+ new ComboFilter(),
+ new HealFilter(),
+ new ImplosionFilter(),
+ new MoltenWaveFilter(),
+ new TeleportPadFilter(),
+ new AutopetFilter(),
+ new ShowOffFilter()
+ };
+ // Register all listeners to EVENT
+ for (ChatMessageListener listener : listeners) {
+ EVENT.register(listener);
+ }
+ // Register EVENT to ClientReceiveMessageEvents.ALLOW_GAME from fabric api
+ ClientReceiveMessageEvents.ALLOW_GAME.register((message, overlay) -> {
+ if (!Utils.isOnSkyblock()) {
+ return true;
+ }
+ ChatFilterResult result = EVENT.invoker().onMessage(message, message.getString());
+ switch (result) {
+ case ACTION_BAR -> {
+ if (overlay) {
+ return true;
+ }
+ ClientPlayerEntity player = MinecraftClient.getInstance().player;
+ if (player != null) {
+ player.sendMessage(message, true);
+ return false;
+ }
+ }
+ case FILTER -> {
+ return false;
+ }
+ }
+ return true;
+ });
+ }
+
+ ChatFilterResult onMessage(Text message, String asString);
+}
diff --git a/src/main/java/de/hysky/skyblocker/utils/chat/ChatPatternListener.java b/src/main/java/de/hysky/skyblocker/utils/chat/ChatPatternListener.java
new file mode 100644
index 00000000..708af280
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/utils/chat/ChatPatternListener.java
@@ -0,0 +1,30 @@
+package de.hysky.skyblocker.utils.chat;
+
+import net.minecraft.text.Text;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public abstract class ChatPatternListener implements ChatMessageListener {
+ protected static final String NUMBER = "-?[0-9]{1,3}(?>,[0-9]{3})*(?:\\.[1-9])?";
+ public final Pattern pattern;
+
+ public ChatPatternListener(String pattern) {
+ this.pattern = Pattern.compile(pattern);
+ }
+
+ @Override
+ public final ChatFilterResult onMessage(Text message, String asString) {
+ ChatFilterResult state = state();
+ if (state == ChatFilterResult.PASS) return ChatFilterResult.PASS;
+ Matcher m = pattern.matcher(asString);
+ if (m.matches() && onMatch(message, m)) {
+ return state;
+ }
+ return ChatFilterResult.PASS;
+ }
+
+ protected abstract ChatFilterResult state();
+
+ protected abstract boolean onMatch(Text message, Matcher matcher);
+}
diff --git a/src/main/java/de/hysky/skyblocker/utils/discord/DiscordRPCManager.java b/src/main/java/de/hysky/skyblocker/utils/discord/DiscordRPCManager.java
new file mode 100644
index 00000000..f0589a0b
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/utils/discord/DiscordRPCManager.java
@@ -0,0 +1,122 @@
+package de.hysky.skyblocker.utils.discord;
+
+
+import de.hysky.skyblocker.config.SkyblockerConfig;
+import de.hysky.skyblocker.config.SkyblockerConfigManager;
+import de.hysky.skyblocker.events.SkyblockEvents;
+import de.hysky.skyblocker.utils.Utils;
+import meteordevelopment.discordipc.DiscordIPC;
+import meteordevelopment.discordipc.RichPresence;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.text.DecimalFormat;
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * Manages the discord rich presence. Automatically connects to discord and displays a customizable activity when playing Skyblock.
+ */
+public class DiscordRPCManager {
+ public static final DecimalFormat DECIMAL_FORMAT = new DecimalFormat("###,###.##");
+ public static final Logger LOGGER = LoggerFactory.getLogger("Skyblocker Discord RPC");
+ /**
+ * The update task used to avoid multiple update tasks running simultaneously.
+ */
+ public static CompletableFuture<Void> updateTask;
+ public static long startTimeStamp;
+ public static int cycleCount;
+
+ public static void init() {
+ SkyblockEvents.LEAVE.register(DiscordRPCManager::initAndUpdatePresence);
+ SkyblockEvents.JOIN.register(() -> {
+ startTimeStamp = System.currentTimeMillis();
+ initAndUpdatePresence(true);
+ });
+ }
+
+ /**
+ * Checks the {@link SkyblockerConfig.RichPresence#customMessage custom message}, updates {@link #cycleCount} if enabled, and updates rich presence.
+ */
+ public static void updateDataAndPresence() {
+ // If the custom message is empty, discord will keep the last message, this is can serve as a default if the user doesn't want a custom message
+ if (SkyblockerConfigManager.get().richPresence.customMessage.isEmpty()) {
+ SkyblockerConfigManager.get().richPresence.customMessage = "Playing Skyblock";
+ SkyblockerConfigManager.save();
+ }
+ if (SkyblockerConfigManager.get().richPresence.cycleMode) cycleCount = (cycleCount + 1) % 3;
+ initAndUpdatePresence();
+ }
+
+ /**
+ * @see #initAndUpdatePresence(boolean)
+ */
+ private static void initAndUpdatePresence() {
+ initAndUpdatePresence(false);
+ }
+
+ /**
+ * Updates discord presence asynchronously.
+ * <p>
+ * When the {@link #updateTask previous update} does not exist or {@link CompletableFuture#isDone() has completed}:
+ * <p>
+ * Connects to discord if {@link SkyblockerConfig.RichPresence#enableRichPresence rich presence is enabled},
+ * the player {@link Utils#isOnSkyblock() is on Skyblock}, and {@link DiscordIPC#isConnected() discord is not already connected}.
+ * Updates the presence if {@link SkyblockerConfig.RichPresence#enableRichPresence rich presence is enabled}
+ * and the player {@link Utils#isOnSkyblock() is on Skyblock}.
+ * Stops the connection if {@link SkyblockerConfig.RichPresence#enableRichPresence rich presence is disabled}
+ * or the player {@link Utils#isOnSkyblock() is not on Skyblock} and {@link DiscordIPC#isConnected() discord is connected}.
+ * Saves the update task in {@link #updateTask}
+ *
+ * @param initialization whether this is the first time the presence is being updates. If {@code true}, a message will be logged
+ * if {@link SkyblockerConfig.RichPresence#enableRichPresence rich presence is disabled}.
+ */
+ private static void initAndUpdatePresence(boolean initialization) {
+ if (updateTask == null || updateTask.isDone()) {
+ updateTask = CompletableFuture.runAsync(() -> {
+ if (SkyblockerConfigManager.get().richPresence.enableRichPresence && Utils.isOnSkyblock()) {
+ if (!DiscordIPC.isConnected()) {
+ if (DiscordIPC.start(934607927837356052L, null)) {
+ LOGGER.info("Discord RPC started successfully");
+ } else {
+ LOGGER.error("Discord RPC failed to start");
+ return;
+ }
+ }
+ DiscordIPC.setActivity(buildPresence());
+ } else if (DiscordIPC.isConnected()) {
+ DiscordIPC.stop();
+ LOGGER.info("Discord RPC stopped");
+ } else if (initialization) {
+ LOGGER.info("Discord RPC is currently disabled");
+ }
+ });
+ }
+ }
+
+ public static RichPresence buildPresence() {
+ RichPresence presence = new RichPresence();
+ presence.setLargeImage("skyblocker-default", null);
+ presence.setStart(startTimeStamp);
+ presence.setDetails(SkyblockerConfigManager.get().richPresence.customMessage);
+ presence.setState(getInfo());
+ return presence;
+ }
+
+ public static String getInfo() {
+ String info = null;
+ if (!SkyblockerConfigManager.get().richPresence.cycleMode) {
+ switch (SkyblockerConfigManager.get().richPresence.info) {
+ case BITS -> info = "Bits: " + DECIMAL_FORMAT.format(Utils.getBits());
+ case PURSE -> info = "Purse: " + DECIMAL_FORMAT.format(Utils.getPurse());
+ case LOCATION -> info = Utils.getLocation();
+ }
+ } else if (SkyblockerConfigManager.get().richPresence.cycleMode) {
+ switch (cycleCount) {
+ case 0 -> info = "Bits: " + DECIMAL_FORMAT.format(Utils.getBits());
+ case 1 -> info = "Purse: " + DECIMAL_FORMAT.format(Utils.getPurse());
+ case 2 -> info = Utils.getLocation();
+ }
+ }
+ return info;
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/utils/render/FrustumUtils.java b/src/main/java/de/hysky/skyblocker/utils/render/FrustumUtils.java
new file mode 100644
index 00000000..3fe79e43
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/utils/render/FrustumUtils.java
@@ -0,0 +1,21 @@
+package de.hysky.skyblocker.utils.render;
+
+import de.hysky.skyblocker.mixin.accessor.FrustumInvoker;
+import de.hysky.skyblocker.mixin.accessor.WorldRendererAccessor;
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.render.Frustum;
+import net.minecraft.util.math.Box;
+
+public class FrustumUtils {
+ public static Frustum getFrustum() {
+ return ((WorldRendererAccessor) MinecraftClient.getInstance().worldRenderer).getFrustum();
+ }
+
+ public static boolean isVisible(Box box) {
+ return getFrustum().isVisible(box);
+ }
+
+ public static boolean isVisible(double minX, double minY, double minZ, double maxX, double maxY, double maxZ) {
+ return ((FrustumInvoker) getFrustum()).invokeIsVisible(minX, minY, minZ, maxX, maxY, maxZ);
+ }
+} \ No newline at end of file
diff --git a/src/main/java/de/hysky/skyblocker/utils/render/RenderHelper.java b/src/main/java/de/hysky/skyblocker/utils/render/RenderHelper.java
new file mode 100644
index 00000000..4630149c
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/utils/render/RenderHelper.java
@@ -0,0 +1,247 @@
+package de.hysky.skyblocker.utils.render;
+
+import com.mojang.blaze3d.platform.GlStateManager.DstFactor;
+import com.mojang.blaze3d.platform.GlStateManager.SrcFactor;
+import com.mojang.blaze3d.systems.RenderSystem;
+import de.hysky.skyblocker.mixin.accessor.BeaconBlockEntityRendererInvoker;
+import me.x150.renderer.render.Renderer3d;
+import de.hysky.skyblocker.utils.render.culling.OcclusionCulling;
+import de.hysky.skyblocker.utils.render.title.Title;
+import de.hysky.skyblocker.utils.render.title.TitleContainer;
+import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderContext;
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.font.TextRenderer;
+import net.minecraft.client.render.*;
+import net.minecraft.client.render.VertexFormat.DrawMode;
+import net.minecraft.client.util.math.MatrixStack;
+import net.minecraft.sound.SoundEvents;
+import net.minecraft.text.OrderedText;
+import net.minecraft.text.Text;
+import net.minecraft.util.math.BlockPos;
+import net.minecraft.util.math.Box;
+import net.minecraft.util.math.Vec3d;
+import org.joml.Matrix3f;
+import org.joml.Matrix4f;
+import org.joml.Vector3f;
+import org.lwjgl.opengl.GL11;
+
+import java.awt.*;
+
+public class RenderHelper {
+ private static final Vec3d ONE = new Vec3d(1, 1, 1);
+ private static final int MAX_OVERWORLD_BUILD_HEIGHT = 319;
+ private static final MinecraftClient client = MinecraftClient.getInstance();
+
+ public static void renderFilledThroughWallsWithBeaconBeam(WorldRenderContext context, BlockPos pos, float[] colorComponents, float alpha) {
+ renderFilledThroughWalls(context, pos, colorComponents, alpha);
+ renderBeaconBeam(context, pos, colorComponents);
+ }
+
+ public static void renderFilledThroughWalls(WorldRenderContext context, BlockPos pos, float[] colorComponents, float alpha) {
+ if (FrustumUtils.isVisible(pos.getX(), pos.getY(), pos.getZ(), pos.getX() + 1, pos.getY() + 1, pos.getZ() + 1)) {
+ Renderer3d.renderThroughWalls();
+ renderFilled(context, pos, colorComponents, alpha);
+ Renderer3d.stopRenderThroughWalls();
+ }
+ }
+
+ public static void renderFilledIfVisible(WorldRenderContext context, BlockPos pos, float[] colorComponents, float alpha) {
+ if (OcclusionCulling.isVisible(pos.getX(), pos.getY(), pos.getZ(), pos.getX() + 1, pos.getY() + 1, pos.getZ() + 1)) {
+ renderFilled(context, pos, colorComponents, alpha);
+ }
+ }
+
+ private static void renderFilled(WorldRenderContext context, BlockPos pos, float[] colorComponents, float alpha) {
+ Renderer3d.renderFilled(context.matrixStack(), new Color(colorComponents[0], colorComponents[1], colorComponents[2], alpha), Vec3d.of(pos), ONE);
+ }
+
+ private static void renderBeaconBeam(WorldRenderContext context, BlockPos pos, float[] colorComponents) {
+ if (FrustumUtils.isVisible(pos.getX(), pos.getY(), pos.getZ(), pos.getX() + 1, MAX_OVERWORLD_BUILD_HEIGHT, pos.getZ() + 1)) {
+ MatrixStack matrices = context.matrixStack();
+ Vec3d camera = context.camera().getPos();
+
+ matrices.push();
+ matrices.translate(pos.getX() - camera.getX(), pos.getY() - camera.getY(), pos.getZ() - camera.getZ());
+
+ Tessellator tessellator = RenderSystem.renderThreadTesselator();
+ BufferBuilder buffer = tessellator.getBuffer();
+ VertexConsumerProvider.Immediate consumer = VertexConsumerProvider.immediate(buffer);
+
+ BeaconBlockEntityRendererInvoker.renderBeam(matrices, consumer, context.tickDelta(), context.world().getTime(), 0, MAX_OVERWORLD_BUILD_HEIGHT, colorComponents);
+
+ consumer.draw();
+ matrices.pop();
+ }
+ }
+
+ /**
+ * Renders the outline of a box with the specified color components and line width.
+ * This does not use renderer since renderer draws outline using debug lines with a fixed width.
+ */
+ public static void renderOutline(WorldRenderContext context, Box box, float[] colorComponents, float lineWidth) {
+ if (FrustumUtils.isVisible(box)) {
+ MatrixStack matrices = context.matrixStack();
+ Vec3d camera = context.camera().getPos();
+ Tessellator tessellator = RenderSystem.renderThreadTesselator();
+ BufferBuilder buffer = tessellator.getBuffer();
+
+ RenderSystem.setShader(GameRenderer::getRenderTypeLinesProgram);
+ RenderSystem.setShaderColor(1f, 1f, 1f, 1f);
+ RenderSystem.lineWidth(lineWidth);
+ RenderSystem.disableCull();
+ RenderSystem.enableDepthTest();
+
+ matrices.push();
+ matrices.translate(-camera.getX(), -camera.getY(), -camera.getZ());
+
+ buffer.begin(DrawMode.LINES, VertexFormats.LINES);
+ WorldRenderer.drawBox(matrices, buffer, box, colorComponents[0], colorComponents[1], colorComponents[2], 1f);
+ tessellator.draw();
+
+ matrices.pop();
+ RenderSystem.lineWidth(1f);
+ RenderSystem.enableCull();
+ RenderSystem.disableDepthTest();
+ }
+ }
+
+ /**
+ * Draws lines from point to point.<br><br>
+ * <p>
+ * Tip: To draw lines from the center of a block, offset the X, Y and Z each by 0.5
+ *
+ * @param context The WorldRenderContext which supplies the matrices and tick delta
+ * @param points The points from which to draw lines between
+ * @param colorComponents An array of R, G and B color components
+ * @param alpha The alpha of the lines
+ * @param lineWidth The width of the lines
+ */
+ public static void renderLinesFromPoints(WorldRenderContext context, Vec3d[] points, float[] colorComponents, float alpha, float lineWidth) {
+ Vec3d camera = context.camera().getPos();
+ MatrixStack matrices = context.matrixStack();
+
+ matrices.push();
+ matrices.translate(-camera.x, -camera.y, -camera.z);
+
+ Tessellator tessellator = RenderSystem.renderThreadTesselator();
+ BufferBuilder buffer = tessellator.getBuffer();
+ Matrix4f projectionMatrix = matrices.peek().getPositionMatrix();
+ Matrix3f normalMatrix = matrices.peek().getNormalMatrix();
+
+ GL11.glEnable(GL11.GL_LINE_SMOOTH);
+ GL11.glHint(GL11.GL_LINE_SMOOTH_HINT, GL11.GL_NICEST);
+
+ RenderSystem.setShader(GameRenderer::getRenderTypeLinesProgram);
+ RenderSystem.setShaderColor(1f, 1f, 1f, 1f);
+ RenderSystem.lineWidth(lineWidth);
+ RenderSystem.enableBlend();
+ RenderSystem.blendFunc(SrcFactor.SRC_ALPHA, DstFactor.ONE_MINUS_SRC_ALPHA);
+ RenderSystem.disableCull();
+ RenderSystem.enableDepthTest();
+
+ buffer.begin(DrawMode.LINE_STRIP, VertexFormats.LINES);
+
+ for (int i = 0; i < points.length; i++) {
+ Vec3d point = points[i];
+ Vec3d nextPoint = (i + 1 == points.length) ? points[i - 1] : points[i + 1];
+ Vector3f normalVec = new Vector3f((float) nextPoint.getX(), (float) nextPoint.getY(), (float) nextPoint.getZ()).sub((float) point.getX(), (float) point.getY(), (float) point.getZ()).normalize();
+
+ buffer
+ .vertex(projectionMatrix, (float) point.getX(), (float) point.getY(), (float) point.getZ())
+ .color(colorComponents[0], colorComponents[1], colorComponents[2], alpha)
+ .normal(normalMatrix, normalVec.x, normalVec.y, normalVec.z)
+ .next();
+ }
+
+ tessellator.draw();
+
+ matrices.pop();
+ GL11.glDisable(GL11.GL_LINE_SMOOTH);
+ RenderSystem.lineWidth(1f);
+ RenderSystem.disableBlend();
+ RenderSystem.defaultBlendFunc();
+ RenderSystem.enableCull();
+ RenderSystem.disableDepthTest();
+ }
+
+ public static void renderText(WorldRenderContext context, Text text, Vec3d pos, boolean seeThrough) {
+ renderText(context, text, pos, 1, seeThrough);
+ }
+
+ public static void renderText(WorldRenderContext context, Text text, Vec3d pos, float scale, boolean seeThrough) {
+ renderText(context, text, pos, scale, 0, seeThrough);
+ }
+
+ public static void renderText(WorldRenderContext context, Text text, Vec3d pos, float scale, float yOffset, boolean seeThrough) {
+ renderText(context, text.asOrderedText(), pos, scale, yOffset, seeThrough);
+ }
+
+ /**
+ * Renders text in the world space.
+ *
+ * @param seeThrough Whether the text should be able to be seen through walls or not.
+ */
+ public static void renderText(WorldRenderContext context, OrderedText text, Vec3d pos, float scale, float yOffset, boolean seeThrough) {
+ MatrixStack matrices = context.matrixStack();
+ Vec3d camera = context.camera().getPos();
+ TextRenderer textRenderer = client.textRenderer;
+
+ scale *= 0.025f;
+
+ matrices.push();
+ matrices.translate(pos.getX() - camera.getX(), pos.getY() - camera.getY(), pos.getZ() - camera.getZ());
+ matrices.peek().getPositionMatrix().mul(RenderSystem.getModelViewMatrix());
+ matrices.multiply(context.camera().getRotation());
+ matrices.scale(-scale, -scale, scale);
+
+ Matrix4f positionMatrix = matrices.peek().getPositionMatrix();
+ float xOffset = -textRenderer.getWidth(text) / 2f;
+
+ Tessellator tessellator = RenderSystem.renderThreadTesselator();
+ BufferBuilder buffer = tessellator.getBuffer();
+ VertexConsumerProvider.Immediate consumers = VertexConsumerProvider.immediate(buffer);
+
+ RenderSystem.depthFunc(seeThrough ? GL11.GL_ALWAYS : GL11.GL_LEQUAL);
+
+ textRenderer.draw(text, xOffset, yOffset, 0xFFFFFFFF, false, positionMatrix, consumers, TextRenderer.TextLayerType.SEE_THROUGH, 0, LightmapTextureManager.MAX_LIGHT_COORDINATE);
+ consumers.draw();
+
+ RenderSystem.depthFunc(GL11.GL_LEQUAL);
+ matrices.pop();
+ }
+
+ /**
+ * Adds the title to {@link TitleContainer} and {@link #playNotificationSound() plays the notification sound} if the title is not in the {@link TitleContainer} already.
+ * No checking needs to be done on whether the title is in the {@link TitleContainer} already by the caller.
+ *
+ * @param title the title
+ */
+ public static void displayInTitleContainerAndPlaySound(Title title) {
+ if (TitleContainer.addTitle(title)) {
+ playNotificationSound();
+ }
+ }
+
+ /**
+ * Adds the title to {@link TitleContainer} for a set number of ticks and {@link #playNotificationSound() plays the notification sound} if the title is not in the {@link TitleContainer} already.
+ * No checking needs to be done on whether the title is in the {@link TitleContainer} already by the caller.
+ *
+ * @param title the title
+ * @param ticks the number of ticks the title will remain
+ */
+ public static void displayInTitleContainerAndPlaySound(Title title, int ticks) {
+ if (TitleContainer.addTitle(title, ticks)) {
+ playNotificationSound();
+ }
+ }
+
+ private static void playNotificationSound() {
+ if (MinecraftClient.getInstance().player != null) {
+ MinecraftClient.getInstance().player.playSound(SoundEvents.ENTITY_EXPERIENCE_ORB_PICKUP, 100f, 0.1f);
+ }
+ }
+
+ public static boolean pointIsInArea(double x, double y, double x1, double y1, double x2, double y2) {
+ return x >= x1 && x <= x2 && y >= y1 && y <= y2;
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/utils/render/culling/OcclusionCulling.java b/src/main/java/de/hysky/skyblocker/utils/render/culling/OcclusionCulling.java
new file mode 100644
index 00000000..5f8d1592
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/utils/render/culling/OcclusionCulling.java
@@ -0,0 +1,47 @@
+package de.hysky.skyblocker.utils.render.culling;
+
+import com.logisticscraft.occlusionculling.OcclusionCullingInstance;
+import com.logisticscraft.occlusionculling.cache.ArrayOcclusionCache;
+import com.logisticscraft.occlusionculling.util.Vec3d;
+import de.hysky.skyblocker.utils.render.FrustumUtils;
+import net.minecraft.client.MinecraftClient;
+
+public class OcclusionCulling {
+ private static final int TRACING_DISTANCE = 128;
+ private static final MinecraftClient CLIENT = MinecraftClient.getInstance();
+ private static OcclusionCullingInstance instance = null;
+
+ // Reused objects to reduce allocation overhead
+ private static final Vec3d cameraPos = new Vec3d(0, 0, 0);
+ private static final Vec3d min = new Vec3d(0, 0, 0);
+ private static final Vec3d max = new Vec3d(0, 0, 0);
+
+ /**
+ * Initializes the occlusion culling instance
+ */
+ public static void init() {
+ instance = new OcclusionCullingInstance(TRACING_DISTANCE, new WorldProvider(), new ArrayOcclusionCache(TRACING_DISTANCE), 2);
+ }
+
+ private static void updateCameraPos() {
+ var camera = CLIENT.gameRenderer.getCamera().getPos();
+ cameraPos.set(camera.x, camera.y, camera.z);
+ }
+
+ /**
+ * This first checks checks if the bounding box is within the camera's FOV, if
+ * it is then it checks for whether it's occluded or not.
+ *
+ * @return A boolean representing whether the bounding box is fully visible or
+ * not.
+ */
+ public static boolean isVisible(double x1, double y1, double z1, double x2, double y2, double z2) {
+ if (!FrustumUtils.isVisible(x1, y1, z1, x2, y2, z2)) return false;
+
+ updateCameraPos();
+ min.set(x1, y1, z1);
+ max.set(x2, y2, z2);
+
+ return instance.isAABBVisible(min, max, cameraPos);
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/utils/render/culling/WorldProvider.java b/src/main/java/de/hysky/skyblocker/utils/render/culling/WorldProvider.java
new file mode 100644
index 00000000..7ee0f0ed
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/utils/render/culling/WorldProvider.java
@@ -0,0 +1,28 @@
+package de.hysky.skyblocker.utils.render.culling;
+
+import com.logisticscraft.occlusionculling.DataProvider;
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.world.ClientWorld;
+import net.minecraft.util.math.BlockPos;
+
+public class WorldProvider implements DataProvider {
+ private final static MinecraftClient CLIENT = MinecraftClient.getInstance();
+ private ClientWorld world = null;
+
+ @Override
+ public boolean prepareChunk(int chunkX, int chunkZ) {
+ this.world = CLIENT.world;
+ return this.world != null;
+ }
+
+ @Override
+ public boolean isOpaqueFullCube(int x, int y, int z) {
+ BlockPos pos = new BlockPos(x, y, z);
+ return this.world.getBlockState(pos).isOpaqueFullCube(this.world, pos);
+ }
+
+ @Override
+ public void cleanup() {
+ this.world = null;
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/utils/render/culling/package-info.java b/src/main/java/de/hysky/skyblocker/utils/render/culling/package-info.java
new file mode 100644
index 00000000..1d5cdf98
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/utils/render/culling/package-info.java
@@ -0,0 +1,4 @@
+/**
+ * Package dedicated to occlusion culling utilities
+ */
+package de.hysky.skyblocker.utils.render.culling; \ No newline at end of file
diff --git a/src/main/java/de/hysky/skyblocker/utils/render/gui/ColorHighlight.java b/src/main/java/de/hysky/skyblocker/utils/render/gui/ColorHighlight.java
new file mode 100644
index 00000000..5451e1a8
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/utils/render/gui/ColorHighlight.java
@@ -0,0 +1,24 @@
+package de.hysky.skyblocker.utils.render.gui;
+
+public record ColorHighlight(int slot, int color) {
+ private static final int RED_HIGHLIGHT = 64 << 24 | 255 << 16;
+ private static final int YELLOW_HIGHLIGHT = 128 << 24 | 255 << 16 | 255 << 8;
+ private static final int GREEN_HIGHLIGHT = 128 << 24 | 64 << 16 | 196 << 8 | 64;
+ private static final int GRAY_HIGHLIGHT = 128 << 24 | 64 << 16 | 64 << 8 | 64;
+
+ public static ColorHighlight red(int slot) {
+ return new ColorHighlight(slot, RED_HIGHLIGHT);
+ }
+
+ public static ColorHighlight yellow(int slot) {
+ return new ColorHighlight(slot, YELLOW_HIGHLIGHT);
+ }
+
+ public static ColorHighlight green(int slot) {
+ return new ColorHighlight(slot, GREEN_HIGHLIGHT);
+ }
+
+ public static ColorHighlight gray(int slot) {
+ return new ColorHighlight(slot, GRAY_HIGHLIGHT);
+ }
+} \ No newline at end of file
diff --git a/src/main/java/de/hysky/skyblocker/utils/render/gui/ContainerSolver.java b/src/main/java/de/hysky/skyblocker/utils/render/gui/ContainerSolver.java
new file mode 100644
index 00000000..cf67e84c
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/utils/render/gui/ContainerSolver.java
@@ -0,0 +1,44 @@
+package de.hysky.skyblocker.utils.render.gui;
+
+import net.minecraft.client.gui.screen.ingame.GenericContainerScreen;
+import net.minecraft.item.ItemStack;
+
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+/**
+ * Abstract class for gui solvers. Extend this class to add a new gui solver, like terminal solvers or experiment solvers.
+ */
+public abstract class ContainerSolver {
+ private final Pattern containerName;
+
+ protected ContainerSolver(String containerName) {
+ this.containerName = Pattern.compile(containerName);
+ }
+
+ protected abstract boolean isEnabled();
+
+ public Pattern getName() {
+ return containerName;
+ }
+
+ protected void start(GenericContainerScreen screen) {
+ }
+
+ protected void reset() {
+ }
+
+ protected abstract List<ColorHighlight> getColors(String[] groups, Map<Integer, ItemStack> slots);
+
+ protected void trimEdges(Map<Integer, ItemStack> slots, int rows) {
+ for (int i = 0; i < rows; i++) {
+ slots.remove(9 * i);
+ slots.remove(9 * i + 8);
+ }
+ for (int i = 1; i < 8; i++) {
+ slots.remove(i);
+ slots.remove((rows - 1) * 9 + i);
+ }
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/utils/render/gui/ContainerSolverManager.java b/src/main/java/de/hysky/skyblocker/utils/render/gui/ContainerSolverManager.java
new file mode 100644
index 00000000..60e2b4e4
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/utils/render/gui/ContainerSolverManager.java
@@ -0,0 +1,125 @@
+package de.hysky.skyblocker.utils.render.gui;
+
+import com.mojang.blaze3d.systems.RenderSystem;
+import de.hysky.skyblocker.mixin.accessor.HandledScreenAccessor;
+import de.hysky.skyblocker.skyblock.dungeon.CroesusHelper;
+import de.hysky.skyblocker.skyblock.dungeon.terminal.ColorTerminal;
+import de.hysky.skyblocker.skyblock.dungeon.terminal.OrderTerminal;
+import de.hysky.skyblocker.skyblock.dungeon.terminal.StartsWithTerminal;
+import de.hysky.skyblocker.skyblock.experiment.ChronomatronSolver;
+import de.hysky.skyblocker.skyblock.experiment.SuperpairsSolver;
+import de.hysky.skyblocker.skyblock.experiment.UltrasequencerSolver;
+import de.hysky.skyblocker.utils.Utils;
+import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents;
+import net.minecraft.client.gui.DrawContext;
+import net.minecraft.client.gui.screen.ingame.GenericContainerScreen;
+import net.minecraft.client.util.math.MatrixStack;
+import net.minecraft.item.ItemStack;
+import net.minecraft.screen.slot.Slot;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Manager class for {@link ContainerSolver}s like terminal solvers and experiment solvers. To add a new gui solver, extend {@link ContainerSolver} and register it in {@link #ContainerSolverManager()}.
+ */
+public class ContainerSolverManager {
+ private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("");
+ private final ContainerSolver[] solvers;
+ private ContainerSolver currentSolver = null;
+ private String[] groups;
+ private List<ColorHighlight> highlights;
+
+ public ContainerSolverManager() {
+ solvers = new ContainerSolver[]{
+ new ColorTerminal(),
+ new OrderTerminal(),
+ new StartsWithTerminal(),
+ new CroesusHelper(),
+ new ChronomatronSolver(),
+ new SuperpairsSolver(),
+ new UltrasequencerSolver()
+ };
+ }
+
+ public ContainerSolver getCurrentSolver() {
+ return currentSolver;
+ }
+
+ public void init() {
+ ScreenEvents.BEFORE_INIT.register((client, screen, scaledWidth, scaledHeight) -> {
+ if (Utils.isOnSkyblock() && screen instanceof GenericContainerScreen genericContainerScreen) {
+ ScreenEvents.afterRender(screen).register((screen1, context, mouseX, mouseY, delta) -> {
+ MatrixStack matrices = context.getMatrices();
+ matrices.push();
+ matrices.translate(((HandledScreenAccessor) genericContainerScreen).getX(), ((HandledScreenAccessor) genericContainerScreen).getY(), 300);
+ onDraw(context, genericContainerScreen.getScreenHandler().slots.subList(0, genericContainerScreen.getScreenHandler().getRows() * 9));
+ matrices.pop();
+ });
+ ScreenEvents.remove(screen).register(screen1 -> clearScreen());
+ onSetScreen(genericContainerScreen);
+ } else {
+ clearScreen();
+ }
+ });
+ }
+
+ public void onSetScreen(@NotNull GenericContainerScreen screen) {
+ String screenName = screen.getTitle().getString();
+ Matcher matcher = PLACEHOLDER_PATTERN.matcher(screenName);
+ for (ContainerSolver solver : solvers) {
+ if (solver.isEnabled()) {
+ matcher.usePattern(solver.getName());
+ matcher.reset();
+ if (matcher.matches()) {
+ currentSolver = solver;
+ groups = new String[matcher.groupCount()];
+ for (int i = 0; i < groups.length; i++) {
+ groups[i] = matcher.group(i + 1);
+ }
+ currentSolver.start(screen);
+ return;
+ }
+ }
+ }
+ clearScreen();
+ }
+
+ public void clearScreen() {
+ if (currentSolver != null) {
+ currentSolver.reset();
+ currentSolver = null;
+ }
+ }
+
+ public void markDirty() {
+ highlights = null;
+ }
+
+ public void onDraw(DrawContext context, List<Slot> slots) {
+ if (currentSolver == null)
+ return;
+ if (highlights == null)
+ highlights = currentSolver.getColors(groups, slotMap(slots));
+ RenderSystem.enableDepthTest();
+ RenderSystem.colorMask(true, true, true, false);
+ for (ColorHighlight highlight : highlights) {
+ Slot slot = slots.get(highlight.slot());
+ int color = highlight.color();
+ context.fillGradient(slot.x, slot.y, slot.x + 16, slot.y + 16, color, color);
+ }
+ RenderSystem.colorMask(true, true, true, true);
+ }
+
+ private Map<Integer, ItemStack> slotMap(List<Slot> slots) {
+ Map<Integer, ItemStack> slotMap = new TreeMap<>();
+ for (int i = 0; i < slots.size(); i++) {
+ slotMap.put(i, slots.get(i).getStack());
+ }
+ return slotMap;
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/utils/render/title/Title.java b/src/main/java/de/hysky/skyblocker/utils/render/title/Title.java
new file mode 100644
index 00000000..1e167afa
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/utils/render/title/Title.java
@@ -0,0 +1,53 @@
+package de.hysky.skyblocker.utils.render.title;
+
+import net.minecraft.text.MutableText;
+import net.minecraft.text.Text;
+import net.minecraft.util.Formatting;
+
+/**
+ * Represents a title used for {@link TitleContainer}.
+ *
+ * @see TitleContainer
+ */
+public class Title {
+ private MutableText text;
+ protected float x = -1;
+ protected float y = -1;
+
+ /**
+ * Constructs a new title with the given translation key and formatting to be applied.
+ *
+ * @param textKey the translation key
+ * @param formatting the formatting to be applied to the text
+ */
+ public Title(String textKey, Formatting formatting) {
+ this(Text.translatable(textKey).formatted(formatting));
+ }
+
+ /**
+ * Constructs a new title with the given {@link MutableText}.
+ * Use {@link Text#literal(String)} or {@link Text#translatable(String)} to create a {@link MutableText}
+ *
+ * @param text the mutable text
+ */
+ public Title(MutableText text) {
+ this.text = text;
+ }
+
+ public MutableText getText() {
+ return text;
+ }
+
+ public void setText(MutableText text) {
+ this.text = text;
+ }
+
+ protected boolean isDefaultPos() {
+ return x == -1 && y == -1;
+ }
+
+ protected void resetPos() {
+ this.x = -1;
+ this.y = -1;
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/utils/render/title/TitleContainer.java b/src/main/java/de/hysky/skyblocker/utils/render/title/TitleContainer.java
new file mode 100644
index 00000000..487e3d8b
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/utils/render/title/TitleContainer.java
@@ -0,0 +1,175 @@
+package de.hysky.skyblocker.utils.render.title;
+
+import de.hysky.skyblocker.config.SkyblockerConfig;
+import de.hysky.skyblocker.config.SkyblockerConfigManager;
+import de.hysky.skyblocker.utils.scheduler.Scheduler;
+import net.fabricmc.fabric.api.client.command.v2.ClientCommandManager;
+import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback;
+import net.fabricmc.fabric.api.client.rendering.v1.HudRenderCallback;
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.font.TextRenderer;
+import net.minecraft.client.gui.DrawContext;
+import net.minecraft.util.math.MathHelper;
+
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+public class TitleContainer {
+ /**
+ * The set of titles which will be rendered.
+ *
+ * @see #containsTitle(Title)
+ * @see #addTitle(Title)
+ * @see #addTitle(Title, int)
+ * @see #removeTitle(Title)
+ */
+ private static final Set<Title> titles = new LinkedHashSet<>();
+
+ public static void init() {
+ HudRenderCallback.EVENT.register(TitleContainer::render);
+ ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> dispatcher.register(ClientCommandManager.literal("skyblocker")
+ .then(ClientCommandManager.literal("hud")
+ .then(ClientCommandManager.literal("titleContainer")
+ .executes(Scheduler.queueOpenScreenCommand(TitleContainerConfigScreen::new))))));
+ }
+
+ /**
+ * Returns {@code true} if the title is currently shown.
+ *
+ * @param title the title to check
+ * @return whether the title in currently shown
+ */
+ public static boolean containsTitle(Title title) {
+ return titles.contains(title);
+ }
+
+ /**
+ * Adds a title to be shown
+ *
+ * @param title the title to be shown
+ * @return whether the title is already currently being shown
+ */
+ public static boolean addTitle(Title title) {
+ if (titles.add(title)) {
+ title.resetPos();
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Adds a title to be shown for a set number of ticks
+ *
+ * @param title the title to be shown
+ * @param ticks the number of ticks to show the title
+ * @return whether the title is already currently being shown
+ */
+ public static boolean addTitle(Title title, int ticks) {
+ if (addTitle(title)) {
+ Scheduler.INSTANCE.schedule(() -> TitleContainer.removeTitle(title), ticks);
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Stops showing a title
+ *
+ * @param title the title to stop showing
+ */
+ public static void removeTitle(Title title) {
+ titles.remove(title);
+ }
+
+ private static void render(DrawContext context, float tickDelta) {
+ render(context, titles, SkyblockerConfigManager.get().general.titleContainer.x, SkyblockerConfigManager.get().general.titleContainer.y, tickDelta);
+ }
+
+ protected static void render(DrawContext context, Set<Title> titles, int xPos, int yPos, float tickDelta) {
+ var client = MinecraftClient.getInstance();
+ TextRenderer textRenderer = client.textRenderer;
+
+ // Calculate Scale to use
+ float scale = 3F * (SkyblockerConfigManager.get().general.titleContainer.titleContainerScale / 100F);
+
+ // Grab direction and alignment values
+ SkyblockerConfig.Direction direction = SkyblockerConfigManager.get().general.titleContainer.direction;
+ SkyblockerConfig.Alignment alignment = SkyblockerConfigManager.get().general.titleContainer.alignment;
+ // x/y refer to the starting position for the text
+ // y always starts at yPos
+ float x = 0;
+ float y = yPos;
+
+ //Calculate the width of combined text
+ float width = 0;
+ for (Title title : titles) {
+ width += textRenderer.getWidth(title.getText()) * scale + 10;
+ }
+
+ if (alignment == SkyblockerConfig.Alignment.MIDDLE) {
+ if (direction == SkyblockerConfig.Direction.HORIZONTAL) {
+ //If middle aligned horizontally, start the xPosition at half of the width to the left.
+ x = xPos - (width / 2);
+ } else {
+ //If middle aligned vertically, start at xPos, we will shift each text to the left later
+ x = xPos;
+ }
+ }
+ if (alignment == SkyblockerConfig.Alignment.LEFT || alignment == SkyblockerConfig.Alignment.RIGHT) {
+ //If left or right aligned, start at xPos, we will shift each text later
+ x = xPos;
+ }
+
+ for (Title title : titles) {
+
+ //Calculate which x the text should use
+ float xToUse;
+ if (direction == SkyblockerConfig.Direction.HORIZONTAL) {
+ xToUse = alignment == SkyblockerConfig.Alignment.RIGHT ?
+ x - (textRenderer.getWidth(title.getText()) * scale) : //if right aligned we need the text position to be aligned on the right side.
+ x;
+ } else {
+ xToUse = alignment == SkyblockerConfig.Alignment.MIDDLE ?
+ x - (textRenderer.getWidth(title.getText()) * scale) / 2 : //if middle aligned we need the text position to be aligned in the middle.
+ alignment == SkyblockerConfig.Alignment.RIGHT ?
+ x - (textRenderer.getWidth(title.getText()) * scale) : //if right aligned we need the text position to be aligned on the right side.
+ x;
+ }
+
+ //Start displaying the title at the correct position, not at the default position
+ if (title.isDefaultPos()) {
+ title.x = xToUse;
+ title.y = y;
+ }
+
+ //Lerp the texts x and y variables
+ title.x = MathHelper.lerp(tickDelta * 0.5F, title.x, xToUse);
+ title.y = MathHelper.lerp(tickDelta * 0.5F, title.y, y);
+
+ //Translate the matrix to the texts position and scale
+ context.getMatrices().push();
+ context.getMatrices().translate(title.x, title.y, 200);
+ context.getMatrices().scale(scale, scale, scale);
+
+ //Draw text
+ context.drawTextWithShadow(textRenderer, title.getText(), 0, 0, 0xFFFFFF);
+ context.getMatrices().pop();
+
+ //Calculate the x and y positions for the next title
+ if (direction == SkyblockerConfig.Direction.HORIZONTAL) {
+ if (alignment == SkyblockerConfig.Alignment.MIDDLE || alignment == SkyblockerConfig.Alignment.LEFT) {
+ //Move to the right if middle or left aligned
+ x += textRenderer.getWidth(title.getText()) * scale + 10;
+ }
+
+ if (alignment == SkyblockerConfig.Alignment.RIGHT) {
+ //Move to the left if right aligned
+ x -= textRenderer.getWidth(title.getText()) * scale + 10;
+ }
+ } else {
+ //Y always moves by the same amount if vertical
+ y += textRenderer.fontHeight * scale + 10;
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/src/main/java/de/hysky/skyblocker/utils/render/title/TitleContainerConfigScreen.java b/src/main/java/de/hysky/skyblocker/utils/render/title/TitleContainerConfigScreen.java
new file mode 100644
index 00000000..5a42eeb4
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/utils/render/title/TitleContainerConfigScreen.java
@@ -0,0 +1,170 @@
+package de.hysky.skyblocker.utils.render.title;
+
+import de.hysky.skyblocker.config.SkyblockerConfig;
+import de.hysky.skyblocker.config.SkyblockerConfigManager;
+import de.hysky.skyblocker.utils.render.RenderHelper;
+import net.minecraft.client.gui.DrawContext;
+import net.minecraft.client.gui.screen.Screen;
+import net.minecraft.client.util.math.Vector2f;
+import net.minecraft.text.Text;
+import net.minecraft.util.Formatting;
+import net.minecraft.util.Pair;
+import org.lwjgl.glfw.GLFW;
+
+import java.awt.*;
+import java.util.Set;
+
+public class TitleContainerConfigScreen extends Screen {
+ private final Title example1 = new Title(Text.literal("Test1").formatted(Formatting.RED));
+ private final Title example2 = new Title(Text.literal("Test23").formatted(Formatting.AQUA));
+ private final Title example3 = new Title(Text.literal("Testing1234").formatted(Formatting.DARK_GREEN));
+ private float hudX = SkyblockerConfigManager.get().general.titleContainer.x;
+ private float hudY = SkyblockerConfigManager.get().general.titleContainer.y;
+ private final Screen parent;
+
+ protected TitleContainerConfigScreen() {
+ this(null);
+ }
+
+ public TitleContainerConfigScreen(Screen parent) {
+ super(Text.of("Title Container HUD Config"));
+ this.parent = parent;
+ }
+
+ @Override
+ public void render(DrawContext context, int mouseX, int mouseY, float delta) {
+ super.render(context, mouseX, mouseY, delta);
+ renderBackground(context, mouseX, mouseY, delta);
+ TitleContainer.render(context, Set.of(example1, example2, example3), (int) hudX, (int) hudY, delta);
+ SkyblockerConfig.Direction direction = SkyblockerConfigManager.get().general.titleContainer.direction;
+ SkyblockerConfig.Alignment alignment = SkyblockerConfigManager.get().general.titleContainer.alignment;
+ context.drawCenteredTextWithShadow(textRenderer, "Press Q/E to change Alignment: " + alignment, width / 2, textRenderer.fontHeight * 2, Color.WHITE.getRGB());
+ context.drawCenteredTextWithShadow(textRenderer, "Press R to change Direction: " + direction, width / 2, textRenderer.fontHeight * 3 + 5, Color.WHITE.getRGB());
+ context.drawCenteredTextWithShadow(textRenderer, "Press +/- to change Scale", width / 2, textRenderer.fontHeight * 4 + 10, Color.WHITE.getRGB());
+ context.drawCenteredTextWithShadow(textRenderer, "Right Click To Reset Position", width / 2, textRenderer.fontHeight * 5 + 15, Color.GRAY.getRGB());
+
+ Pair<Vector2f, Vector2f> boundingBox = getSelectionBoundingBox();
+ int x1 = (int) boundingBox.getLeft().getX();
+ int y1 = (int) boundingBox.getLeft().getY();
+ int x2 = (int) boundingBox.getRight().getX();
+ int y2 = (int) boundingBox.getRight().getY();
+
+ context.drawHorizontalLine(x1, x2, y1, Color.RED.getRGB());
+ context.drawHorizontalLine(x1, x2, y2, Color.RED.getRGB());
+ context.drawVerticalLine(x1, y1, y2, Color.RED.getRGB());
+ context.drawVerticalLine(x2, y1, y2, Color.RED.getRGB());
+ }
+
+ private Pair<Vector2f, Vector2f> getSelectionBoundingBox() {
+ SkyblockerConfig.Alignment alignment = SkyblockerConfigManager.get().general.titleContainer.alignment;
+
+ float midWidth = getSelectionWidth() / 2F;
+ float x1 = 0;
+ float x2 = 0;
+ float y1 = hudY;
+ float y2 = hudY + getSelectionHeight();
+ switch (alignment) {
+ case RIGHT -> {
+ x1 = hudX - midWidth * 2;
+ x2 = hudX;
+ }
+ case MIDDLE -> {
+ x1 = hudX - midWidth;
+ x2 = hudX + midWidth;
+ }
+ case LEFT -> {
+ x1 = hudX;
+ x2 = hudX + midWidth * 2;
+ }
+ }
+ return new Pair<>(new Vector2f(x1, y1), new Vector2f(x2, y2));
+ }
+
+ private float getSelectionHeight() {
+ float scale = (3F * (SkyblockerConfigManager.get().general.titleContainer.titleContainerScale / 100F));
+ return SkyblockerConfigManager.get().general.titleContainer.direction == SkyblockerConfig.Direction.HORIZONTAL ?
+ (textRenderer.fontHeight * scale) :
+ (textRenderer.fontHeight + 10F) * 3F * scale;
+ }
+
+ private float getSelectionWidth() {
+ float scale = (3F * (SkyblockerConfigManager.get().general.titleContainer.titleContainerScale / 100F));
+ return SkyblockerConfigManager.get().general.titleContainer.direction == SkyblockerConfig.Direction.HORIZONTAL ?
+ (textRenderer.getWidth("Test1") + 10 + textRenderer.getWidth("Test23") + 10 + textRenderer.getWidth("Testing1234")) * scale :
+ textRenderer.getWidth("Testing1234") * scale;
+ }
+
+ @Override
+ public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
+ float midWidth = getSelectionWidth() / 2;
+ float midHeight = getSelectionHeight() / 2;
+ var alignment = SkyblockerConfigManager.get().general.titleContainer.alignment;
+
+ Pair<Vector2f, Vector2f> boundingBox = getSelectionBoundingBox();
+ float x1 = boundingBox.getLeft().getX();
+ float y1 = boundingBox.getLeft().getY();
+ float x2 = boundingBox.getRight().getX();
+ float y2 = boundingBox.getRight().getY();
+
+ if (RenderHelper.pointIsInArea(mouseX, mouseY, x1, y1, x2, y2) && button == 0) {
+ hudX = switch (alignment) {
+ case LEFT -> (int) mouseX - midWidth;
+ case MIDDLE -> (int) mouseX;
+ case RIGHT -> (int) mouseX + midWidth;
+ };
+ hudY = (int) (mouseY - midHeight);
+ }
+ return super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY);
+ }
+
+ @Override
+ public boolean mouseClicked(double mouseX, double mouseY, int button) {
+ if (button == 1) {
+ hudX = (float) this.width / 2;
+ hudY = this.height * 0.6F;
+ }
+ return super.mouseClicked(mouseX, mouseY, button);
+ }
+
+ @Override
+ public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
+ if (keyCode == GLFW.GLFW_KEY_Q) {
+ SkyblockerConfig.Alignment current = SkyblockerConfigManager.get().general.titleContainer.alignment;
+ SkyblockerConfigManager.get().general.titleContainer.alignment = switch (current) {
+ case LEFT -> SkyblockerConfig.Alignment.MIDDLE;
+ case MIDDLE -> SkyblockerConfig.Alignment.RIGHT;
+ case RIGHT -> SkyblockerConfig.Alignment.LEFT;
+ };
+ }
+ if (keyCode == GLFW.GLFW_KEY_E) {
+ SkyblockerConfig.Alignment current = SkyblockerConfigManager.get().general.titleContainer.alignment;
+ SkyblockerConfigManager.get().general.titleContainer.alignment = switch (current) {
+ case LEFT -> SkyblockerConfig.Alignment.RIGHT;
+ case MIDDLE -> SkyblockerConfig.Alignment.LEFT;
+ case RIGHT -> SkyblockerConfig.Alignment.MIDDLE;
+ };
+ }
+ if (keyCode == GLFW.GLFW_KEY_R) {
+ SkyblockerConfig.Direction current = SkyblockerConfigManager.get().general.titleContainer.direction;
+ SkyblockerConfigManager.get().general.titleContainer.direction = switch (current) {
+ case HORIZONTAL -> SkyblockerConfig.Direction.VERTICAL;
+ case VERTICAL -> SkyblockerConfig.Direction.HORIZONTAL;
+ };
+ }
+ if (keyCode == GLFW.GLFW_KEY_EQUAL) {
+ SkyblockerConfigManager.get().general.titleContainer.titleContainerScale += 10;
+ }
+ if (keyCode == GLFW.GLFW_KEY_MINUS) {
+ SkyblockerConfigManager.get().general.titleContainer.titleContainerScale -= 10;
+ }
+ return super.keyPressed(keyCode, scanCode, modifiers);
+ }
+
+ @Override
+ public void close() {
+ SkyblockerConfigManager.get().general.titleContainer.x = (int) hudX;
+ SkyblockerConfigManager.get().general.titleContainer.y = (int) hudY;
+ SkyblockerConfigManager.save();
+ this.client.setScreen(parent);
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/utils/scheduler/MessageScheduler.java b/src/main/java/de/hysky/skyblocker/utils/scheduler/MessageScheduler.java
new file mode 100644
index 00000000..15636965
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/utils/scheduler/MessageScheduler.java
@@ -0,0 +1,66 @@
+package de.hysky.skyblocker.utils.scheduler;
+
+import net.minecraft.client.MinecraftClient;
+
+/**
+ * A scheduler for sending chat messages or commands. Use the instance in {@link #INSTANCE}. Do not instantiate this class.
+ */
+public class MessageScheduler extends Scheduler {
+ /**
+ * The minimum delay that the server will accept between chat messages.
+ */
+ private static final int MIN_DELAY = 200;
+ public static final MessageScheduler INSTANCE = new MessageScheduler();
+ /**
+ * The timestamp of the last message send,
+ */
+ private long lastMessage = 0;
+
+ protected MessageScheduler() {
+ }
+
+ /**
+ * Sends a chat message or command after the minimum cooldown. Prefer this method to send messages or commands to the server.
+ *
+ * @param message the message to send
+ */
+ public void sendMessageAfterCooldown(String message) {
+ if (lastMessage + MIN_DELAY < System.currentTimeMillis()) {
+ sendMessage(message);
+ lastMessage = System.currentTimeMillis();
+ } else {
+ queueMessage(message, 0);
+ }
+ }
+
+ private void sendMessage(String message) {
+ if (MinecraftClient.getInstance().player != null) {
+ if (message.startsWith("/")) {
+ MinecraftClient.getInstance().player.networkHandler.sendCommand(message.substring(1));
+ } else {
+ MinecraftClient.getInstance().inGameHud.getChatHud().addToMessageHistory(message);
+ MinecraftClient.getInstance().player.networkHandler.sendChatMessage(message);
+ }
+ }
+ }
+
+ /**
+ * Queues a chat message or command to send in {@code delay} ticks. Use this method to send messages or commands a set time in the future. The minimum cooldown is still respected.
+ *
+ * @param message the message to send
+ * @param delay the delay before sending the message in ticks
+ */
+ public void queueMessage(String message, int delay) {
+ schedule(() -> sendMessage(message), delay);
+ }
+
+ @Override
+ protected boolean runTask(Runnable task) {
+ if (lastMessage + MIN_DELAY < System.currentTimeMillis()) {
+ task.run();
+ lastMessage = System.currentTimeMillis();
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/utils/scheduler/Scheduler.java b/src/main/java/de/hysky/skyblocker/utils/scheduler/Scheduler.java
new file mode 100644
index 00000000..0f44cf93
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/utils/scheduler/Scheduler.java
@@ -0,0 +1,140 @@
+package de.hysky.skyblocker.utils.scheduler;
+
+import com.mojang.brigadier.Command;
+import it.unimi.dsi.fastutil.ints.AbstractInt2ObjectMap;
+import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
+import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource;
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.gui.screen.Screen;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Supplier;
+
+/**
+ * A scheduler for running tasks at a later time. Tasks will be run synchronously on the main client thread. Use the instance stored in {@link #INSTANCE}. Do not instantiate this class.
+ */
+public class Scheduler {
+ private static final Logger LOGGER = LoggerFactory.getLogger(Scheduler.class);
+ public static final Scheduler INSTANCE = new Scheduler();
+ private int currentTick = 0;
+ private final AbstractInt2ObjectMap<List<ScheduledTask>> tasks = new Int2ObjectOpenHashMap<>();
+
+ protected Scheduler() {
+ }
+
+ /**
+ * Schedules a task to run after a delay.
+ *
+ * @param task the task to run
+ * @param delay the delay in ticks
+ */
+ public void schedule(Runnable task, int delay) {
+ if (delay >= 0) {
+ addTask(new ScheduledTask(task), currentTick + delay);
+ } else {
+ LOGGER.warn("Scheduled a task with negative delay");
+ }
+ }
+
+ /**
+ * Schedules a task to run every period ticks.
+ *
+ * @param task the task to run
+ * @param period the period in ticks
+ */
+ public void scheduleCyclic(Runnable task, int period) {
+ if (period > 0) {
+ addTask(new CyclicTask(task, period), currentTick);
+ } else {
+ LOGGER.error("Attempted to schedule a cyclic task with period lower than 1");
+ }
+ }
+
+ public static Command<FabricClientCommandSource> queueOpenScreenCommand(Supplier<Screen> screenSupplier) {
+ return context -> INSTANCE.queueOpenScreen(screenSupplier);
+ }
+
+ /**
+ * Schedules a screen to open in the next tick. Used in commands to avoid screen immediately closing after the command is executed.
+ *
+ * @param screenSupplier the supplier of the screen to open
+ * @see #queueOpenScreenCommand(Supplier)
+ */
+ public int queueOpenScreen(Supplier<Screen> screenSupplier) {
+ MinecraftClient.getInstance().send(() -> MinecraftClient.getInstance().setScreen(screenSupplier.get()));
+ return Command.SINGLE_SUCCESS;
+ }
+
+ public void tick() {
+ if (tasks.containsKey(currentTick)) {
+ List<ScheduledTask> currentTickTasks = tasks.get(currentTick);
+ //noinspection ForLoopReplaceableByForEach (or else we get a ConcurrentModificationException)
+ for (int i = 0; i < currentTickTasks.size(); i++) {
+ ScheduledTask task = currentTickTasks.get(i);
+ if (!runTask(task)) {
+ tasks.computeIfAbsent(currentTick + 1, key -> new ArrayList<>()).add(task);
+ }
+ }
+ tasks.remove(currentTick);
+ }
+ currentTick += 1;
+ }
+
+ /**
+ * Runs the task if able.
+ *
+ * @param task the task to run
+ * @return {@code true} if the task is run, and {@link false} if task is not run.
+ */
+ protected boolean runTask(Runnable task) {
+ task.run();
+ return true;
+ }
+
+ private void addTask(ScheduledTask scheduledTask, int schedule) {
+ if (tasks.containsKey(schedule)) {
+ tasks.get(schedule).add(scheduledTask);
+ } else {
+ List<ScheduledTask> list = new ArrayList<>();
+ list.add(scheduledTask);
+ tasks.put(schedule, list);
+ }
+ }
+
+ /**
+ * A task that runs every period ticks. More specifically, this task reschedules itself to run again after period ticks every time it runs.
+ */
+ protected class CyclicTask extends ScheduledTask {
+ private final int period;
+
+ CyclicTask(Runnable inner, int period) {
+ super(inner);
+ this.period = period;
+ }
+
+ @Override
+ public void run() {
+ super.run();
+ addTask(this, currentTick + period);
+ }
+ }
+
+ /**
+ * A task that runs at a specific tick, relative to {@link #currentTick}.
+ */
+ protected static class ScheduledTask implements Runnable {
+ private final Runnable inner;
+
+ public ScheduledTask(Runnable inner) {
+ this.inner = inner;
+ }
+
+ @Override
+ public void run() {
+ inner.run();
+ }
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/utils/tictactoe/TicTacToeUtils.java b/src/main/java/de/hysky/skyblocker/utils/tictactoe/TicTacToeUtils.java
new file mode 100644
index 00000000..908ba46d
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/utils/tictactoe/TicTacToeUtils.java
@@ -0,0 +1,104 @@
+package de.hysky.skyblocker.utils.tictactoe;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+public class TicTacToeUtils {
+
+ public static int getBestMove(char[][] board) {
+ HashMap<Integer, Integer> moves = new HashMap<>();
+ for (int row = 0; row < board.length; row++) {
+ for (int col = 0; col < board[row].length; col++) {
+ if (board[row][col] != '\0') continue;
+ board[row][col] = 'O';
+ int score = alphabeta(board, Integer.MIN_VALUE, Integer.MAX_VALUE, false, 0);
+ board[row][col] = '\0';
+ moves.put(row * 3 + col + 1, score);
+ }
+ }
+ return Collections.max(moves.entrySet(), Map.Entry.comparingByValue()).getKey();
+ }
+
+ public static boolean hasMovesLeft(char[][] board) {
+ for (char[] rows : board) {
+ for (char col : rows) {
+ if (col == '\0') return true;
+ }
+ }
+ return false;
+ }
+
+ public static int getBoardRanking(char[][] board) {
+ for (int row = 0; row < 3; row++) {
+ if (board[row][0] == board[row][1] && board[row][0] == board[row][2]) {
+ if (board[row][0] == 'X') {
+ return -10;
+ } else if (board[row][0] == 'O') {
+ return 10;
+ }
+ }
+ }
+
+ for (int col = 0; col < 3; col++) {
+ if (board[0][col] == board[1][col] && board[0][col] == board[2][col]) {
+ if (board[0][col] == 'X') {
+ return -10;
+ } else if (board[0][col] == 'O') {
+ return 10;
+ }
+ }
+ }
+
+ if (board[0][0] == board[1][1] && board[0][0] == board[2][2]) {
+ if (board[0][0] == 'X') {
+ return -10;
+ } else if (board[0][0] == 'O') {
+ return 10;
+ }
+ } else if (board[0][2] == board[1][1] && board[0][2] == board[2][0]) {
+ if (board[0][2] == 'X') {
+ return -10;
+ } else if (board[0][2] == 'O') {
+ return 10;
+ }
+ }
+
+ return 0;
+ }
+ public static int alphabeta(char[][] board, int alpha, int beta, boolean max, int depth) {
+ int score = getBoardRanking(board);
+ if (score == 10 || score == -10) return score;
+ if (!hasMovesLeft(board)) return 0;
+
+ if (max) {
+ int bestScore = Integer.MIN_VALUE;
+ for (int row = 0; row < 3; row++) {
+ for (int col = 0; col < 3; col++) {
+ if (board[row][col] == '\0') {
+ board[row][col] = 'O';
+ bestScore = Math.max(bestScore, alphabeta(board, alpha, beta, false, depth + 1));
+ board[row][col] = '\0';
+ alpha = Math.max(alpha, bestScore);
+ if (beta <= alpha) break; // Pruning
+ }
+ }
+ }
+ return bestScore - depth;
+ } else {
+ int bestScore = Integer.MAX_VALUE;
+ for (int row = 0; row < 3; row++) {
+ for (int col = 0; col < 3; col++) {
+ if (board[row][col] == '\0') {
+ board[row][col] = 'X';
+ bestScore = Math.min(bestScore, alphabeta(board, alpha, beta, true, depth + 1));
+ board[row][col] = '\0';
+ beta = Math.min(beta, bestScore);
+ if (beta <= alpha) break; // Pruning
+ }
+ }
+ }
+ return bestScore + depth;
+ }
+ }
+} \ No newline at end of file