diff options
author | Kevin <92656833+kevinthegreat1@users.noreply.github.com> | 2023-08-31 20:03:17 -0400 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-08-31 20:03:17 -0400 |
commit | 1370097922f19815e14bdfd8c9e606cde2bc8f39 (patch) | |
tree | f8bf7c0f14fdaf84c6cdb6253aa63839cd2847ef /src/main/java/me/xmrvizzy | |
parent | 68eb2556c4809860a73b7bb62cc5f2c84d58c3ae (diff) | |
parent | 761952420f1cf9896bd69a534a338d64658bab59 (diff) | |
download | Skyblocker-1370097922f19815e14bdfd8c9e606cde2bc8f39.tar.gz Skyblocker-1370097922f19815e14bdfd8c9e606cde2bc8f39.tar.bz2 Skyblocker-1370097922f19815e14bdfd8c9e606cde2bc8f39.zip |
Merge pull request #246 from kevinthegreat1/dungeon-secrets
Dungeon Secrets
Diffstat (limited to 'src/main/java/me/xmrvizzy')
10 files changed, 1473 insertions, 111 deletions
diff --git a/src/main/java/me/xmrvizzy/skyblocker/SkyblockerMod.java b/src/main/java/me/xmrvizzy/skyblocker/SkyblockerMod.java index c9705a3b..f15427c1 100644 --- a/src/main/java/me/xmrvizzy/skyblocker/SkyblockerMod.java +++ b/src/main/java/me/xmrvizzy/skyblocker/SkyblockerMod.java @@ -11,12 +11,9 @@ import me.xmrvizzy.skyblocker.skyblock.dungeon.DungeonBlaze; import me.xmrvizzy.skyblocker.skyblock.dungeon.DungeonMap; import me.xmrvizzy.skyblocker.skyblock.dungeon.LividColor; import me.xmrvizzy.skyblocker.skyblock.dungeon.TicTacToe; +import me.xmrvizzy.skyblocker.skyblock.dungeon.secrets.DungeonSecrets; import me.xmrvizzy.skyblocker.skyblock.dwarven.DwarvenHud; -import me.xmrvizzy.skyblocker.skyblock.item.CustomArmorDyeColors; -import me.xmrvizzy.skyblocker.skyblock.item.CustomArmorTrims; -import me.xmrvizzy.skyblocker.skyblock.item.CustomItemNames; -import me.xmrvizzy.skyblocker.skyblock.item.PriceInfoTooltip; -import me.xmrvizzy.skyblocker.skyblock.item.WikiLookup; +import me.xmrvizzy.skyblocker.skyblock.item.*; import me.xmrvizzy.skyblocker.skyblock.itemlist.ItemRegistry; import me.xmrvizzy.skyblocker.skyblock.quicknav.QuickNav; import me.xmrvizzy.skyblocker.skyblock.rift.TheRift; @@ -24,7 +21,10 @@ import me.xmrvizzy.skyblocker.skyblock.shortcut.Shortcuts; import me.xmrvizzy.skyblocker.skyblock.tabhud.TabHud; import me.xmrvizzy.skyblocker.skyblock.tabhud.screenbuilder.ScreenMaster; import me.xmrvizzy.skyblocker.skyblock.tabhud.util.PlayerListMgr; -import me.xmrvizzy.skyblocker.utils.*; +import me.xmrvizzy.skyblocker.utils.MessageScheduler; +import me.xmrvizzy.skyblocker.utils.NEURepo; +import me.xmrvizzy.skyblocker.utils.Scheduler; +import me.xmrvizzy.skyblocker.utils.Utils; import me.xmrvizzy.skyblocker.utils.culling.OcclusionCulling; import me.xmrvizzy.skyblocker.utils.title.TitleContainer; import net.fabricmc.api.ClientModInitializer; @@ -89,6 +89,7 @@ public class SkyblockerMod implements ClientModInitializer { FishingHelper.init(); TabHud.init(); DungeonMap.init(); + DungeonSecrets.init(); TheRift.init(); TitleContainer.init(); ScreenMaster.init(); @@ -99,6 +100,7 @@ public class SkyblockerMod implements ClientModInitializer { CustomArmorTrims.init(); TicTacToe.init(); containerSolverManager.init(); + statusBarTracker.init(); scheduler.scheduleCyclic(Utils::update, 20); scheduler.scheduleCyclic(DiscordRPCManager::updateDataAndPresence, 100); scheduler.scheduleCyclic(DungeonBlaze::update, 4); diff --git a/src/main/java/me/xmrvizzy/skyblocker/config/SkyblockerConfig.java b/src/main/java/me/xmrvizzy/skyblocker/config/SkyblockerConfig.java index e6961819..6ddbdf65 100644 --- a/src/main/java/me/xmrvizzy/skyblocker/config/SkyblockerConfig.java +++ b/src/main/java/me/xmrvizzy/skyblocker/config/SkyblockerConfig.java @@ -407,6 +407,8 @@ public class SkyblockerConfig implements ConfigData { } public static class Dungeons { + @ConfigEntry.Gui.CollapsibleObject + public SecretWaypoints secretWaypoints = new SecretWaypoints(); @ConfigEntry.Gui.Tooltip() public boolean croesusHelper = true; public boolean enableMap = true; @@ -427,6 +429,24 @@ public class SkyblockerConfig implements ConfigData { public Terminals terminals = new Terminals(); } + public static class SecretWaypoints { + + public boolean enableSecretWaypoints = true; + @ConfigEntry.Gui.Tooltip() + public boolean noInitSecretWaypoints = false; + public boolean enableEntranceWaypoints = true; + public boolean enableSuperboomWaypoints = true; + public boolean enableChestWaypoints = true; + public boolean enableItemWaypoints = true; + public boolean enableBatWaypoints = true; + public boolean enableWitherWaypoints = true; + public boolean enableLeverWaypoints = true; + public boolean enableFairySoulWaypoints = true; + public boolean enableStonkWaypoints = true; + @ConfigEntry.Gui.Tooltip() + public boolean enableDefaultWaypoints = true; + } + public static class LividColor { @ConfigEntry.Gui.Tooltip() public boolean enableLividColor = true; diff --git a/src/main/java/me/xmrvizzy/skyblocker/mixin/ClientPlayNetworkHandlerMixin.java b/src/main/java/me/xmrvizzy/skyblocker/mixin/ClientPlayNetworkHandlerMixin.java index 2d109524..f52e2f7f 100644 --- a/src/main/java/me/xmrvizzy/skyblocker/mixin/ClientPlayNetworkHandlerMixin.java +++ b/src/main/java/me/xmrvizzy/skyblocker/mixin/ClientPlayNetworkHandlerMixin.java @@ -1,23 +1,40 @@ package me.xmrvizzy.skyblocker.mixin; import com.llamalad7.mixinextras.injector.WrapWithCondition; - +import com.llamalad7.mixinextras.sugar.Local; import dev.cbyrne.betterinject.annotations.Inject; import me.xmrvizzy.skyblocker.skyblock.FishingHelper; +import me.xmrvizzy.skyblocker.skyblock.dungeon.secrets.DungeonSecrets; import me.xmrvizzy.skyblocker.utils.Utils; +import net.minecraft.client.MinecraftClient; import net.minecraft.client.network.ClientPlayNetworkHandler; +import net.minecraft.entity.ItemEntity; +import net.minecraft.entity.LivingEntity; import net.minecraft.network.packet.s2c.play.PlaySoundS2CPacket; import org.slf4j.Logger; +import org.spongepowered.asm.mixin.Final; import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.ModifyVariable; @Mixin(ClientPlayNetworkHandler.class) public abstract class ClientPlayNetworkHandlerMixin { + @Shadow + @Final + private MinecraftClient client; + @Inject(method = "onPlaySound", at = @At("RETURN")) private void skyblocker$onPlaySound(PlaySoundS2CPacket packet) { FishingHelper.onSound(packet); } + @ModifyVariable(method = "onItemPickupAnimation", at = @At(value = "STORE", ordinal = 0)) + private ItemEntity skyblocker$onItemPickup(ItemEntity itemEntity, @Local LivingEntity collector) { + DungeonSecrets.onItemPickup(itemEntity, collector, collector == client.player); + return itemEntity; + } + @WrapWithCondition(method = "onEntityPassengersSet", at = @At(value = "INVOKE", target = "Lorg/slf4j/Logger;warn(Ljava/lang/String;)V", remap = false)) private boolean skyblocker$cancelEntityPassengersWarning(Logger instance, String msg) { return !Utils.isOnHypixel(); diff --git a/src/main/java/me/xmrvizzy/skyblocker/mixin/InGameHudMixin.java b/src/main/java/me/xmrvizzy/skyblocker/mixin/InGameHudMixin.java index 4cda73aa..bc3df266 100644 --- a/src/main/java/me/xmrvizzy/skyblocker/mixin/InGameHudMixin.java +++ b/src/main/java/me/xmrvizzy/skyblocker/mixin/InGameHudMixin.java @@ -5,14 +5,12 @@ import me.xmrvizzy.skyblocker.SkyblockerMod; import me.xmrvizzy.skyblocker.config.SkyblockerConfig; import me.xmrvizzy.skyblocker.skyblock.FancyStatusBars; import me.xmrvizzy.skyblocker.skyblock.HotbarSlotLock; -import me.xmrvizzy.skyblocker.skyblock.StatusBarTracker; import me.xmrvizzy.skyblocker.skyblock.dungeon.DungeonMap; import me.xmrvizzy.skyblocker.utils.Utils; import net.fabricmc.api.EnvType; import net.fabricmc.api.Environment; import net.minecraft.client.gui.DrawContext; import net.minecraft.client.gui.hud.InGameHud; -import net.minecraft.text.Text; import net.minecraft.util.Identifier; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Shadow; @@ -27,8 +25,6 @@ public abstract class InGameHudMixin { @Unique private static final Identifier SLOT_LOCK = new Identifier(SkyblockerMod.NAMESPACE, "textures/gui/slot_lock.png"); @Unique - private final StatusBarTracker statusBarTracker = SkyblockerMod.getInstance().statusBarTracker; - @Unique private final FancyStatusBars statusBars = new FancyStatusBars(); @Shadow @@ -36,22 +32,6 @@ public abstract class InGameHudMixin { @Shadow private int scaledWidth; - @Shadow - public abstract void setOverlayMessage(Text message, boolean tinted); - - @Inject(method = "setOverlayMessage(Lnet/minecraft/text/Text;Z)V", at = @At("HEAD"), cancellable = true) - private void skyblocker$onSetOverlayMessage(Text message, boolean tinted, CallbackInfo ci) { - if (!Utils.isOnSkyblock() || !SkyblockerConfig.get().general.bars.enableBars || Utils.isInTheRift()) - return; - String msg = message.getString(); - String res = statusBarTracker.update(msg, SkyblockerConfig.get().messages.hideMana); - if (!msg.equals(res)) { - if (res != null) - setOverlayMessage(Text.of(res), tinted); - ci.cancel(); - } - } - @Inject(method = "renderHotbar", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/gui/hud/InGameHud;renderHotbarItem(Lnet/minecraft/client/gui/DrawContext;IIFLnet/minecraft/entity/player/PlayerEntity;Lnet/minecraft/item/ItemStack;I)V", ordinal = 0)) public void skyblocker$renderHotbarItemLock(float tickDelta, DrawContext context, CallbackInfo ci, @Local(ordinal = 4, name = "m") int index, @Local(ordinal = 5, name = "n") int x, @Local(ordinal = 6, name = "o") int y) { if (Utils.isOnSkyblock() && HotbarSlotLock.isLocked(index)) { diff --git a/src/main/java/me/xmrvizzy/skyblocker/skyblock/StatusBarTracker.java b/src/main/java/me/xmrvizzy/skyblocker/skyblock/StatusBarTracker.java index 96165ce8..aeee9978 100644 --- a/src/main/java/me/xmrvizzy/skyblocker/skyblock/StatusBarTracker.java +++ b/src/main/java/me/xmrvizzy/skyblocker/skyblock/StatusBarTracker.java @@ -1,7 +1,11 @@ package me.xmrvizzy.skyblocker.skyblock; +import me.xmrvizzy.skyblocker.config.SkyblockerConfig; +import me.xmrvizzy.skyblocker.utils.Utils; +import net.fabricmc.fabric.api.client.message.v1.ClientReceiveMessageEvents; import net.minecraft.client.MinecraftClient; import net.minecraft.client.network.ClientPlayerEntity; +import net.minecraft.text.Text; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -16,6 +20,10 @@ public class StatusBarTracker { private Resource mana = new Resource(100, 100, 0); private int defense = 0; + public void init() { + ClientReceiveMessageEvents.MODIFY_GAME.register(this::onOverlayMessage); + } + public Resource getHealth() { return this.health; } @@ -57,6 +65,13 @@ public class StatusBarTracker { return str; } + private Text onOverlayMessage(Text text, boolean overlay) { + if (!overlay || !Utils.isOnSkyblock() || !SkyblockerConfig.get().general.bars.enableBars || Utils.isInTheRift()) { + return text; + } + return Text.of(update(text.getString(), SkyblockerConfig.get().messages.hideMana)); + } + public String update(String actionBar, boolean filterManaUse) { var sb = new StringBuilder(); Matcher matcher = STATUS_HEALTH.matcher(actionBar); @@ -89,5 +104,6 @@ public class StatusBarTracker { return res.isEmpty() ? null : res; } - public record Resource(int value, int max, int overflow) {} + public record Resource(int value, int max, int overflow) { + } } diff --git a/src/main/java/me/xmrvizzy/skyblocker/skyblock/dungeon/secrets/DungeonMapUtils.java b/src/main/java/me/xmrvizzy/skyblocker/skyblock/dungeon/secrets/DungeonMapUtils.java new file mode 100644 index 00000000..b56b2827 --- /dev/null +++ b/src/main/java/me/xmrvizzy/skyblocker/skyblock/dungeon/secrets/DungeonMapUtils.java @@ -0,0 +1,275 @@ +package me.xmrvizzy.skyblocker.skyblock.dungeon.secrets; + +import com.google.gson.JsonObject; +import it.unimi.dsi.fastutil.ints.IntSortedSet; +import it.unimi.dsi.fastutil.objects.ObjectIntPair; +import net.minecraft.block.MapColor; +import net.minecraft.item.map.MapIcon; +import net.minecraft.item.map.MapState; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.MathHelper; +import net.minecraft.util.math.Vec3d; +import net.minecraft.util.math.Vec3i; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.joml.RoundingMode; +import org.joml.Vector2i; +import org.joml.Vector2ic; + +import java.util.*; + +public class DungeonMapUtils { + public static final byte BLACK_COLOR = MapColor.BLACK.getRenderColorByte(MapColor.Brightness.LOWEST); + public static final byte WHITE_COLOR = MapColor.WHITE.getRenderColorByte(MapColor.Brightness.HIGH); + + public static byte getColor(MapState map, @Nullable Vector2ic pos) { + return pos == null ? -1 : getColor(map, pos.x(), pos.y()); + } + + public static byte getColor(MapState map, int x, int z) { + if (x < 0 || z < 0 || x >= 128 || z >= 128) { + return -1; + } + return map.colors[x + (z << 7)]; + } + + public static boolean isEntranceColor(MapState map, int x, int z) { + return getColor(map, x, z) == Room.Type.ENTRANCE.color; + } + + public static boolean isEntranceColor(MapState map, @Nullable Vector2ic pos) { + return getColor(map, pos) == Room.Type.ENTRANCE.color; + } + + @Nullable + private static Vector2i getMapPlayerPos(MapState map) { + for (MapIcon icon : map.getIcons()) { + if (icon.getType() == MapIcon.Type.FRAME) { + return new Vector2i((icon.getX() >> 1) + 64, (icon.getZ() >> 1) + 64); + } + } + return null; + } + + @Nullable + public static ObjectIntPair<Vector2ic> getMapEntrancePosAndRoomSize(@NotNull MapState map) { + Vector2ic mapPos = getMapPlayerPos(map); + if (mapPos == null) { + return null; + } + Queue<Vector2ic> posToCheck = new ArrayDeque<>(); + Set<Vector2ic> checked = new HashSet<>(); + posToCheck.add(mapPos); + checked.add(mapPos); + while ((mapPos = posToCheck.poll()) != null) { + if (isEntranceColor(map, mapPos)) { + ObjectIntPair<Vector2ic> mapEntranceAndRoomSizePos = getMapEntrancePosAndRoomSizeAt(map, mapPos); + if (mapEntranceAndRoomSizePos.rightInt() > 0) { + return mapEntranceAndRoomSizePos; + } + } + Vector2ic pos = new Vector2i(mapPos).sub(10, 0); + if (checked.add(pos)) { + posToCheck.add(pos); + } + pos = new Vector2i(mapPos).sub(0, 10); + if (checked.add(pos)) { + posToCheck.add(pos); + } + pos = new Vector2i(mapPos).add(10, 0); + if (checked.add(pos)) { + posToCheck.add(pos); + } + pos = new Vector2i(mapPos).add(0, 10); + if (checked.add(pos)) { + posToCheck.add(pos); + } + } + return null; + } + + private static ObjectIntPair<Vector2ic> getMapEntrancePosAndRoomSizeAt(MapState map, Vector2ic mapPosImmutable) { + Vector2i mapPos = new Vector2i(mapPosImmutable); + // noinspection StatementWithEmptyBody + while (isEntranceColor(map, mapPos.sub(1, 0))) { + } + mapPos.add(1, 0); + //noinspection StatementWithEmptyBody + while (isEntranceColor(map, mapPos.sub(0, 1))) { + } + return ObjectIntPair.of(mapPos.add(0, 1), getMapRoomSize(map, mapPos)); + } + + public static int getMapRoomSize(MapState map, Vector2ic mapEntrancePos) { + int i = -1; + //noinspection StatementWithEmptyBody + while (isEntranceColor(map, mapEntrancePos.x() + ++i, mapEntrancePos.y())) { + } + return i > 5 ? i : 0; + } + + /** + * Gets the map position of the top left corner of the room the player is in. + * + * @param map the map + * @param mapEntrancePos the map position of the top left corner of the entrance + * @param mapRoomSize the size of a room on the map + * @return the map position of the top left corner of the room the player is in + * @implNote {@code mapPos} is shifted by 2 so room borders are evenly split. + * {@code mapPos} is then shifted by {@code offset} to align the top left most room at (0, 0) + * so subtracting the modulo will give the top left corner of the room shifted by {@code offset}. + * Finally, {@code mapPos} is shifted back by {@code offset} to its intended position. + */ + @Nullable + public static Vector2ic getMapRoomPos(MapState map, Vector2ic mapEntrancePos, int mapRoomSize) { + int mapRoomSizeWithGap = mapRoomSize + 4; + Vector2i mapPos = getMapPlayerPos(map); + if (mapPos == null) { + return null; + } + Vector2ic offset = new Vector2i(mapEntrancePos.x() % mapRoomSizeWithGap, mapEntrancePos.y() % mapRoomSizeWithGap); + return mapPos.add(2, 2).sub(offset).sub(mapPos.x() % mapRoomSizeWithGap, mapPos.y() % mapRoomSizeWithGap).add(offset); + } + + /** + * Gets the map position of the top left corner of the room corresponding to the physical position of the northwest corner of a room. + * + * @param physicalEntrancePos the physical position of the northwest corner of the entrance room + * @param mapEntrancePos the map position of the top left corner of the entrance room + * @param mapRoomSize the size of a room on the map + * @param physicalPos the physical position of the northwest corner of the room + * @return the map position of the top left corner of the room corresponding to the physical position of the northwest corner of a room + */ + public static Vector2ic getMapPosFromPhysical(Vector2ic physicalEntrancePos, Vector2ic mapEntrancePos, int mapRoomSize, Vector2ic physicalPos) { + return new Vector2i(physicalPos).sub(physicalEntrancePos).div(32).mul(mapRoomSize + 4).add(mapEntrancePos); + } + + /** + * @see #getPhysicalRoomPos(double, double) + */ + @NotNull + public static Vector2ic getPhysicalRoomPos(@NotNull Vec3d pos) { + return getPhysicalRoomPos(pos.getX(), pos.getZ()); + } + + /** + * @see #getPhysicalRoomPos(double, double) + */ + @NotNull + public static Vector2ic getPhysicalRoomPos(@NotNull Vec3i pos) { + return getPhysicalRoomPos(pos.getX(), pos.getZ()); + } + + /** + * Gets the physical position of the northwest corner of the room the given coordinate is in. Hypixel Skyblock Dungeons are aligned to a 32 by 32 blocks grid, allowing corners to be calculated through math. + * + * @param x the x position of the coordinate to calculate + * @param z the z position of the coordinate to calculate + * @return the physical position of the northwest corner of the room the player is in + * @implNote {@code physicalPos} is shifted by 0.5 so room borders are evenly split. + * {@code physicalPos} is further shifted by 8 because Hypixel offset dungeons by 8 blocks in Skyblock 0.12.3. + * Subtracting the modulo gives the northwest corner of the room shifted by 8. Finally, {@code physicalPos} is shifted back by 8 to its intended position. + */ + @NotNull + public static Vector2ic getPhysicalRoomPos(double x, double z) { + Vector2i physicalPos = new Vector2i(x + 8.5, z + 8.5, RoundingMode.TRUNCATE); + return physicalPos.sub(MathHelper.floorMod(physicalPos.x(), 32), MathHelper.floorMod(physicalPos.y(), 32)).sub(8, 8); + } + + public static Vector2ic[] getPhysicalPosFromMap(Vector2ic mapEntrancePos, int mapRoomSize, Vector2ic physicalEntrancePos, Vector2ic... mapPositions) { + for (int i = 0; i < mapPositions.length; i++) { + mapPositions[i] = getPhysicalPosFromMap(mapEntrancePos, mapRoomSize, physicalEntrancePos, mapPositions[i]); + } + return mapPositions; + } + + /** + * Gets the physical position of the northwest corner of the room corresponding to the map position of the top left corner of a room. + * + * @param mapEntrancePos the map position of the top left corner of the entrance room + * @param mapRoomSize the size of a room on the map + * @param physicalEntrancePos the physical position of the northwest corner of the entrance room + * @param mapPos the map position of the top left corner of the room + * @return the physical position of the northwest corner of the room corresponding to the map position of the top left corner of a room + */ + public static Vector2ic getPhysicalPosFromMap(Vector2ic mapEntrancePos, int mapRoomSize, Vector2ic physicalEntrancePos, Vector2ic mapPos) { + return new Vector2i(mapPos).sub(mapEntrancePos).div(mapRoomSize + 4).mul(32).add(physicalEntrancePos); + } + + public static Vector2ic getPhysicalCornerPos(Room.Direction direction, IntSortedSet segmentsX, IntSortedSet segmentsY) { + return switch (direction) { + case NW -> new Vector2i(segmentsX.firstInt(), segmentsY.firstInt()); + case NE -> new Vector2i(segmentsX.lastInt() + 30, segmentsY.firstInt()); + case SW -> new Vector2i(segmentsX.firstInt(), segmentsY.lastInt() + 30); + case SE -> new Vector2i(segmentsX.lastInt() + 30, segmentsY.lastInt() + 30); + }; + } + + public static BlockPos actualToRelative(Room.Direction direction, Vector2ic physicalCornerPos, BlockPos pos) { + return switch (direction) { + case NW -> new BlockPos(pos.getX() - physicalCornerPos.x(), pos.getY(), pos.getZ() - physicalCornerPos.y()); + case NE -> new BlockPos(pos.getZ() - physicalCornerPos.y(), pos.getY(), -pos.getX() + physicalCornerPos.x()); + case SW -> new BlockPos(-pos.getZ() + physicalCornerPos.y(), pos.getY(), pos.getX() - physicalCornerPos.x()); + case SE -> new BlockPos(-pos.getX() + physicalCornerPos.x(), pos.getY(), -pos.getZ() + physicalCornerPos.y()); + }; + } + + public static BlockPos relativeToActual(Room.Direction direction, Vector2ic physicalCornerPos, JsonObject posJson) { + return relativeToActual(direction, physicalCornerPos, new BlockPos(posJson.get("x").getAsInt(), posJson.get("y").getAsInt(), posJson.get("z").getAsInt())); + } + + public static BlockPos relativeToActual(Room.Direction direction, Vector2ic physicalCornerPos, BlockPos pos) { + return switch (direction) { + case NW -> new BlockPos(pos.getX() + physicalCornerPos.x(), pos.getY(), pos.getZ() + physicalCornerPos.y()); + case NE -> new BlockPos(-pos.getZ() + physicalCornerPos.x(), pos.getY(), pos.getX() + physicalCornerPos.y()); + case SW -> new BlockPos(pos.getZ() + physicalCornerPos.x(), pos.getY(), -pos.getX() + physicalCornerPos.y()); + case SE -> new BlockPos(-pos.getX() + physicalCornerPos.x(), pos.getY(), -pos.getZ() + physicalCornerPos.y()); + }; + } + + public static Room.Type getRoomType(MapState map, Vector2ic mapPos) { + return switch (getColor(map, mapPos)) { + case 30 -> Room.Type.ENTRANCE; + case 63 -> Room.Type.ROOM; + case 66 -> Room.Type.PUZZLE; + case 62 -> Room.Type.TRAP; + case 74 -> Room.Type.MINIBOSS; + case 82 -> Room.Type.FAIRY; + case 18 -> Room.Type.BLOOD; + case 85 -> Room.Type.UNKNOWN; + default -> null; + }; + } + + public static Vector2ic[] getRoomSegments(MapState map, Vector2ic mapPos, int mapRoomSize, byte color) { + Set<Vector2ic> segments = new HashSet<>(); + Queue<Vector2ic> queue = new ArrayDeque<>(); + segments.add(mapPos); + queue.add(mapPos); + while (!queue.isEmpty()) { + Vector2ic curMapPos = queue.poll(); + Vector2i newMapPos = new Vector2i(); + if (getColor(map, newMapPos.set(curMapPos).sub(1, 0)) == color && !segments.contains(newMapPos.sub(mapRoomSize + 3, 0))) { + segments.add(newMapPos); + queue.add(newMapPos); + newMapPos = new Vector2i(); + } + if (getColor(map, newMapPos.set(curMapPos).sub(0, 1)) == color && !segments.contains(newMapPos.sub(0, mapRoomSize + 3))) { + segments.add(newMapPos); + queue.add(newMapPos); + newMapPos = new Vector2i(); + } + if (getColor(map, newMapPos.set(curMapPos).add(mapRoomSize, 0)) == color && !segments.contains(newMapPos.add(4, 0))) { + segments.add(newMapPos); + queue.add(newMapPos); + newMapPos = new Vector2i(); + } + if (getColor(map, newMapPos.set(curMapPos).add(0, mapRoomSize)) == color && !segments.contains(newMapPos.add(0, 4))) { + segments.add(newMapPos); + queue.add(newMapPos); + } + } + DungeonSecrets.LOGGER.debug("[Skyblocker] Found dungeon room segments: {}", Arrays.toString(segments.toArray())); + return segments.toArray(Vector2ic[]::new); + } +} diff --git a/src/main/java/me/xmrvizzy/skyblocker/skyblock/dungeon/secrets/DungeonSecrets.java b/src/main/java/me/xmrvizzy/skyblocker/skyblock/dungeon/secrets/DungeonSecrets.java new file mode 100644 index 00000000..c916a5e4 --- /dev/null +++ b/src/main/java/me/xmrvizzy/skyblocker/skyblock/dungeon/secrets/DungeonSecrets.java @@ -0,0 +1,427 @@ +package me.xmrvizzy.skyblocker.skyblock.dungeon.secrets; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.mojang.brigadier.Command; +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.builder.ArgumentBuilder; +import com.mojang.brigadier.builder.RequiredArgumentBuilder; +import it.unimi.dsi.fastutil.objects.Object2ByteMap; +import it.unimi.dsi.fastutil.objects.Object2ByteOpenHashMap; +import it.unimi.dsi.fastutil.objects.ObjectIntPair; +import me.xmrvizzy.skyblocker.SkyblockerMod; +import me.xmrvizzy.skyblocker.config.SkyblockerConfig; +import me.xmrvizzy.skyblocker.utils.Utils; +import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback; +import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource; +import net.fabricmc.fabric.api.client.message.v1.ClientReceiveMessageEvents; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; +import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderContext; +import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderEvents; +import net.fabricmc.fabric.api.event.player.UseBlockCallback; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.network.ClientPlayerEntity; +import net.minecraft.entity.ItemEntity; +import net.minecraft.entity.LivingEntity; +import net.minecraft.item.FilledMapItem; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.minecraft.item.map.MapState; +import net.minecraft.resource.Resource; +import net.minecraft.text.Text; +import net.minecraft.util.ActionResult; +import net.minecraft.util.Identifier; +import net.minecraft.util.hit.BlockHitResult; +import net.minecraft.util.math.Vec3d; +import net.minecraft.world.World; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.joml.Vector2ic; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.zip.InflaterInputStream; + +import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.argument; +import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.literal; + +public class DungeonSecrets { + protected static final Logger LOGGER = LoggerFactory.getLogger(DungeonSecrets.class); + private static final String DUNGEONS_PATH = "dungeons"; + /** + * Maps the block identifier string to a custom numeric block id used in dungeon rooms data. + * + * @implNote Not using {@link net.minecraft.registry.Registry#getId(Object) Registry#getId(Block)} and {@link net.minecraft.block.Blocks Blocks} since this is also used by {@link me.xmrvizzy.skyblocker.skyblock.dungeon.secrets.DungeonRoomsDFU DungeonRoomsDFU}, which runs outside of Minecraft. + */ + @SuppressWarnings("JavadocReference") + protected static final Object2ByteMap<String> NUMERIC_ID = new Object2ByteOpenHashMap<>(Map.ofEntries( + Map.entry("minecraft:stone", (byte) 1), + Map.entry("minecraft:diorite", (byte) 2), + Map.entry("minecraft:polished_diorite", (byte) 3), + Map.entry("minecraft:andesite", (byte) 4), + Map.entry("minecraft:polished_andesite", (byte) 5), + Map.entry("minecraft:grass_block", (byte) 6), + Map.entry("minecraft:dirt", (byte) 7), + Map.entry("minecraft:coarse_dirt", (byte) 8), + Map.entry("minecraft:cobblestone", (byte) 9), + Map.entry("minecraft:bedrock", (byte) 10), + Map.entry("minecraft:oak_leaves", (byte) 11), + Map.entry("minecraft:gray_wool", (byte) 12), + Map.entry("minecraft:double_stone_slab", (byte) 13), + Map.entry("minecraft:mossy_cobblestone", (byte) 14), + Map.entry("minecraft:clay", (byte) 15), + Map.entry("minecraft:stone_bricks", (byte) 16), + Map.entry("minecraft:mossy_stone_bricks", (byte) 17), + Map.entry("minecraft:chiseled_stone_bricks", (byte) 18), + Map.entry("minecraft:gray_terracotta", (byte) 19), + Map.entry("minecraft:cyan_terracotta", (byte) 20), + Map.entry("minecraft:black_terracotta", (byte) 21) + )); + /** + * Block data for dungeon rooms. See {@link me.xmrvizzy.skyblocker.skyblock.dungeon.secrets.DungeonRoomsDFU DungeonRoomsDFU} for format details and how it's generated. + * All access to this map must check {@link #isRoomsLoaded()} to prevent concurrent modification. + */ + @SuppressWarnings("JavadocReference") + protected static final HashMap<String, Map<String, Map<String, int[]>>> ROOMS_DATA = new HashMap<>(); + @NotNull + private static final Map<Vector2ic, Room> rooms = new HashMap<>(); + private static final Map<String, JsonElement> roomsJson = new HashMap<>(); + private static final Map<String, JsonElement> waypointsJson = new HashMap<>(); + @Nullable + private static CompletableFuture<Void> roomsLoaded; + /** + * The map position of the top left corner of the entrance room. + */ + @Nullable + private static Vector2ic mapEntrancePos; + /** + * The size of a room on the map. + */ + private static int mapRoomSize; + /** + * The physical position of the northwest corner of the entrance room. + */ + @Nullable + private static Vector2ic physicalEntrancePos; + private static Room currentRoom; + + public static boolean isRoomsLoaded() { + return roomsLoaded != null && roomsLoaded.isDone(); + } + + @SuppressWarnings("unused") + public static JsonObject getRoomMetadata(String room) { + return roomsJson.get(room).getAsJsonObject(); + } + + public static JsonArray getRoomWaypoints(String room) { + return waypointsJson.get(room).getAsJsonArray(); + } + + /** + * Loads the dungeon secrets asynchronously from {@code /assets/skyblocker/dungeons}. + * Use {@link #isRoomsLoaded()} to check for completion of loading. + */ + public static void init() { + if (SkyblockerConfig.get().locations.dungeons.secretWaypoints.noInitSecretWaypoints) { + return; + } + // Execute with MinecraftClient as executor since we need to wait for MinecraftClient#resourceManager to be set + CompletableFuture.runAsync(DungeonSecrets::load, MinecraftClient.getInstance()).exceptionally(e -> { + LOGGER.error("[Skyblocker] Failed to load dungeon secrets", e); + return null; + }); + SkyblockerMod.getInstance().scheduler.scheduleCyclic(DungeonSecrets::update, 10); + WorldRenderEvents.AFTER_TRANSLUCENT.register(DungeonSecrets::render); + ClientReceiveMessageEvents.GAME.register(DungeonSecrets::onChatMessage); + ClientReceiveMessageEvents.GAME_CANCELED.register(DungeonSecrets::onChatMessage); + UseBlockCallback.EVENT.register((player, world, hand, hitResult) -> onUseBlock(world, hitResult)); + ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> dispatcher.register(literal(SkyblockerMod.NAMESPACE).then(literal("dungeons").then(literal("secrets") + .then(literal("markAsFound").then(markSecretsCommand(true))) + .then(literal("markAsMissing").then(markSecretsCommand(false))))))); + ClientPlayConnectionEvents.JOIN.register(((handler, sender, client) -> reset())); + } + + private static void load() { + long startTime = System.currentTimeMillis(); + List<CompletableFuture<Void>> dungeonFutures = new ArrayList<>(); + for (Map.Entry<Identifier, Resource> resourceEntry : MinecraftClient.getInstance().getResourceManager().findResources(DUNGEONS_PATH, id -> id.getPath().endsWith(".skeleton")).entrySet()) { + String[] path = resourceEntry.getKey().getPath().split("/"); + if (path.length != 4) { + LOGGER.error("[Skyblocker] Failed to load dungeon secrets, invalid resource identifier {}", resourceEntry.getKey()); + break; + } + String dungeon = path[1]; + String roomShape = path[2]; + String room = path[3].substring(0, path[3].length() - ".skeleton".length()); + ROOMS_DATA.computeIfAbsent(dungeon, dungeonKey -> new HashMap<>()); + ROOMS_DATA.get(dungeon).computeIfAbsent(roomShape, roomShapeKey -> new HashMap<>()); + dungeonFutures.add(CompletableFuture.supplyAsync(() -> readRoom(resourceEntry.getValue())).thenAcceptAsync(rooms -> { + Map<String, int[]> roomsMap = ROOMS_DATA.get(dungeon).get(roomShape); + synchronized (roomsMap) { + roomsMap.put(room, rooms); + } + LOGGER.debug("[Skyblocker] Loaded dungeon secrets dungeon {} room shape {} room {}", dungeon, roomShape, room); + }).exceptionally(e -> { + LOGGER.error("[Skyblocker] Failed to load dungeon secrets dungeon {} room shape {} room {}", dungeon, roomShape, room, e); + return null; + })); + } + dungeonFutures.add(CompletableFuture.runAsync(() -> { + try (BufferedReader roomsReader = MinecraftClient.getInstance().getResourceManager().openAsReader(new Identifier(SkyblockerMod.NAMESPACE, "dungeons/dungeonrooms.json")); BufferedReader waypointsReader = MinecraftClient.getInstance().getResourceManager().openAsReader(new Identifier(SkyblockerMod.NAMESPACE, "dungeons/secretlocations.json"))) { + SkyblockerMod.GSON.fromJson(roomsReader, JsonObject.class).asMap().forEach((room, jsonElement) -> roomsJson.put(room.toLowerCase(), jsonElement)); + SkyblockerMod.GSON.fromJson(waypointsReader, JsonObject.class).asMap().forEach((room, jsonElement) -> waypointsJson.put(room.toLowerCase(), jsonElement)); + LOGGER.debug("[Skyblocker] Loaded dungeon secrets json"); + } catch (Exception e) { + LOGGER.error("[Skyblocker] Failed to load dungeon secrets json", e); + } + })); + roomsLoaded = CompletableFuture.allOf(dungeonFutures.toArray(CompletableFuture[]::new)).thenRun(() -> LOGGER.info("[Skyblocker] Loaded dungeon secrets for {} dungeon(s), {} room shapes, and {} rooms total in {} ms", ROOMS_DATA.size(), ROOMS_DATA.values().stream().mapToInt(Map::size).sum(), ROOMS_DATA.values().stream().map(Map::values).flatMap(Collection::stream).mapToInt(Map::size).sum(), System.currentTimeMillis() - startTime)).exceptionally(e -> { + LOGGER.error("[Skyblocker] Failed to load dungeon secrets", e); + return null; + }); + LOGGER.info("[Skyblocker] Started loading dungeon secrets in (blocked main thread for) {} ms", System.currentTimeMillis() - startTime); + } + + private static int[] readRoom(Resource resource) throws RuntimeException { + try (ObjectInputStream in = new ObjectInputStream(new InflaterInputStream(resource.getInputStream()))) { + return (int[]) in.readObject(); + } catch (IOException | ClassNotFoundException e) { + throw new RuntimeException(e); + } + } + + private static ArgumentBuilder<FabricClientCommandSource, RequiredArgumentBuilder<FabricClientCommandSource, Integer>> markSecretsCommand(boolean found) { + return argument("secret", IntegerArgumentType.integer()).executes(context -> { + int secretIndex = IntegerArgumentType.getInteger(context, "secret"); + if (markSecrets(secretIndex, found)) { + context.getSource().sendFeedback(Text.translatable(found ? "skyblocker.dungeons.secrets.markSecretFound" : "skyblocker.dungeons.secrets.markSecretMissing", secretIndex)); + } else { + context.getSource().sendError(Text.translatable(found ? "skyblocker.dungeons.secrets.markSecretFoundUnable" : "skyblocker.dungeons.secrets.markSecretMissingUnable", secretIndex)); + } + return Command.SINGLE_SUCCESS; + }); + } + + /** + * Updates the dungeon. The general idea is similar to the Dungeon Rooms Mod. + * <p></p> + * When entering a new dungeon, this method: + * <ul> + * <li> Gets the physical northwest corner position of the entrance room and saves it in {@link #physicalEntrancePos}. </li> + * <li> Do nothing until the dungeon map exists. </li> + * <li> Gets the upper left corner of entrance room on the map and saves it in {@link #mapEntrancePos}. </li> + * <li> Gets the size of a room on the map in pixels and saves it in {@link #mapRoomSize}. </li> + * <li> Creates a new {@link Room} with {@link Room.Type} {@link Room.Type.ENTRANCE ENTRANCE} and sets {@link #currentRoom}. </li> + * </ul> + * When processing an existing dungeon, this method: + * <ul> + * <li> Calculates the physical northwest corner and upper left corner on the map of the room the player is currently in. </li> + * <li> Gets the room type based on the map color. </li> + * <li> If the room has not been created (when the physical northwest corner is not in {@link #rooms}):</li> + * <ul> + * <li> If the room type is {@link Room.Type.ROOM}, gets the northwest corner of all connected room segments with {@link DungeonMapUtils#getRoomSegments(MapState, Vector2ic, int, byte)}. (For example, a 1x2 room has two room segments.) </li> + * <li> Create a new room. </li> + * </ul> + * <li> Sets {@link #currentRoom} to the current room, either created from the previous step or from {@link #rooms}. </li> + * <li> Calls {@link Room#update()} on {@link #currentRoom}. </li> + * </ul> + */ + @SuppressWarnings("JavadocReference") + private static void update() { + if (!SkyblockerConfig.get().locations.dungeons.secretWaypoints.enableSecretWaypoints) { + return; + } + if (!Utils.isInDungeons()) { + if (mapEntrancePos != null) { + reset(); + } + return; + } + MinecraftClient client = MinecraftClient.getInstance(); + ClientPlayerEntity player = client.player; + if (player == null || client.world == null) { + return; + } + if (physicalEntrancePos == null) { + Vec3d playerPos = player.getPos(); + physicalEntrancePos = DungeonMapUtils.getPhysicalRoomPos(playerPos); + currentRoom = newRoom(Room.Type.ENTRANCE, physicalEntrancePos); + } + ItemStack stack = player.getInventory().main.get(8); + if (!stack.isOf(Items.FILLED_MAP)) { + return; + } + MapState map = FilledMapItem.getMapState(FilledMapItem.getMapId(stack), client.world); + if (map == null) { + return; + } + if (mapEntrancePos == null || mapRoomSize == 0) { + ObjectIntPair<Vector2ic> mapEntrancePosAndSize = DungeonMapUtils.getMapEntrancePosAndRoomSize(map); + if (mapEntrancePosAndSize == null) { + return; + } + mapEntrancePos = mapEntrancePosAndSize.left(); + mapRoomSize = mapEntrancePosAndSize.rightInt(); + LOGGER.info("[Skyblocker] Started dungeon with map room size {}, map entrance pos {}, player pos {}, and physical entrance pos {}", mapRoomSize, mapEntrancePos, client.player.getPos(), physicalEntrancePos); + } + + Vector2ic physicalPos = DungeonMapUtils.getPhysicalRoomPos(client.player.getPos()); + Vector2ic mapPos = DungeonMapUtils.getMapPosFromPhysical(physicalEntrancePos, mapEntrancePos, mapRoomSize, physicalPos); + Room room = rooms.get(physicalPos); + if (room == null) { + Room.Type type = DungeonMapUtils.getRoomType(map, mapPos); + if (type == null || type == Room.Type.UNKNOWN) { + return; + } + switch (type) { + case ENTRANCE, PUZZLE, TRAP, MINIBOSS, FAIRY, BLOOD -> room = newRoom(type, physicalPos); + case ROOM -> room = newRoom(type, DungeonMapUtils.getPhysicalPosFromMap(mapEntrancePos, mapRoomSize, physicalEntrancePos, DungeonMapUtils.getRoomSegments(map, mapPos, mapRoomSize, type.color))); + } + } + if (room != null && currentRoom != room) { + currentRoom = room; + } + currentRoom.update(); + } + + /** + * Creates a new room with the given type and physical positions, + * adds the room to {@link #rooms}, and sets {@link #currentRoom} to the new room. + * + * @param type the type of room to create + * @param physicalPositions the physical positions of the room + */ + @Nullable + private static Room newRoom(Room.Type type, Vector2ic... physicalPositions) { + try { + Room newRoom = new Room(type, physicalPositions); + for (Vector2ic physicalPos : physicalPositions) { + rooms.put(physicalPos, newRoom); + } + return newRoom; + } catch (IllegalArgumentException e) { + LOGGER.error("[Skyblocker] Failed to create room", e); + } + return null; + } + + /** + * Renders the secret waypoints in {@link #currentRoom} if {@link #isCurrentRoomMatched()}. + */ + private static void render(WorldRenderContext context) { + if (isCurrentRoomMatched()) { + currentRoom.render(context); + } + } + + /** + * Calls {@link Room#onChatMessage(String)} on {@link #currentRoom} if the message is an overlay message and {@link #isCurrentRoomMatched()}. + * Used to detect when all secrets in a room are found. + */ + private static void onChatMessage(Text text, boolean overlay) { + if (overlay && isCurrentRoomMatched()) { + currentRoom.onChatMessage(text.getString()); + } + } + + /** + * Calls {@link Room#onUseBlock(World, BlockHitResult)} on {@link #currentRoom} if {@link #isCurrentRoomMatched()}. + * Used to detect finding {@link SecretWaypoint.Category.CHEST} and {@link SecretWaypoint.Category.WITHER} secrets. + * + * @return {@link ActionResult#PASS} + */ + @SuppressWarnings("JavadocReference") + private static ActionResult onUseBlock(World world, BlockHitResult hitResult) { + if (isCurrentRoomMatched()) { + currentRoom.onUseBlock(world, hitResult); + } + return ActionResult.PASS; + } + + /** + * Calls {@link Room#onItemPickup(ItemEntity, LivingEntity)} on the room the {@code collector} is in if that room {@link #isRoomMatched(Room)}. + * Used to detect finding {@link SecretWaypoint.Category.ITEM} and {@link SecretWaypoint.Category.BAT} secrets. + * If the collector is the player, {@link #currentRoom} is used as an optimization. + */ + @SuppressWarnings("JavadocReference") + public static void onItemPickup(ItemEntity itemEntity, LivingEntity collector, boolean isPlayer) { + if (isPlayer) { + if (isCurrentRoomMatched()) { + currentRoom.onItemPickup(itemEntity, collector); + } + } else { + Room room = getRoomAtPhysical(collector.getPos()); + if (isRoomMatched(room)) { + room.onItemPickup(itemEntity, collector); + } + } + } + + public static boolean markSecrets(int secretIndex, boolean found) { + if (isCurrentRoomMatched()) { + return currentRoom.markSecrets(secretIndex, found); + } + return false; + } + + /** + * Gets the room at the given physical position. + * + * @param pos the physical position + * @return the room at the given physical position, or null if there is no room at the given physical position + * @see #rooms + * @see DungeonMapUtils#getPhysicalRoomPos(Vec3d) + */ + @Nullable + private static Room getRoomAtPhysical(Vec3d pos) { + return rooms.get(DungeonMapUtils.getPhysicalRoomPos(pos)); + } + + /** + * Calls {@link #isRoomMatched(Room)} on {@link #currentRoom}. + * + * @return {@code true} if {@link #currentRoom} is not null and {@link #isRoomMatched(Room)} + */ + private static boolean isCurrentRoomMatched() { + return isRoomMatched(currentRoom); + } + + /** + * Calls {@link #shouldProcess()} and {@link Room#isMatched()} on the given room. + * + * @param room the room to check + * @return {@code true} if {@link #shouldProcess()}, the given room is not null, and {@link Room#isMatched()} on the given room + */ + @Contract("null -> false") + private static boolean isRoomMatched(@Nullable Room room) { + return shouldProcess() && room != null && room.isMatched(); + } + + /** + * Checks if the player is in a dungeon and {@link me.xmrvizzy.skyblocker.config.SkyblockerConfig.Dungeons#secretWaypoints Secret Waypoints} is enabled. + * + * @return whether dungeon secrets should be processed + */ + private static boolean shouldProcess() { + return SkyblockerConfig.get().locations.dungeons.secretWaypoints.enableSecretWaypoints && Utils.isInDungeons(); + } + + /** + * Resets fields when leaving a dungeon. + */ + private static void reset() { + mapEntrancePos = null; + mapRoomSize = 0; + physicalEntrancePos = null; + rooms.clear(); + currentRoom = null; + } +} diff --git a/src/main/java/me/xmrvizzy/skyblocker/skyblock/dungeon/secrets/Room.java b/src/main/java/me/xmrvizzy/skyblocker/skyblock/dungeon/secrets/Room.java new file mode 100644 index 00000000..fc62150c --- /dev/null +++ b/src/main/java/me/xmrvizzy/skyblocker/skyblock/dungeon/secrets/Room.java @@ -0,0 +1,461 @@ +package me.xmrvizzy.skyblocker.skyblock.dungeon.secrets; + +import com.google.common.collect.HashBasedTable; +import com.google.common.collect.ImmutableTable; +import com.google.common.collect.Table; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import it.unimi.dsi.fastutil.ints.IntRBTreeSet; +import it.unimi.dsi.fastutil.ints.IntSortedSet; +import it.unimi.dsi.fastutil.ints.IntSortedSets; +import me.xmrvizzy.skyblocker.SkyblockerMod; +import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderContext; +import net.fabricmc.fabric.api.util.TriState; +import net.minecraft.block.BlockState; +import net.minecraft.block.Blocks; +import net.minecraft.block.MapColor; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.network.ClientPlayerEntity; +import net.minecraft.client.world.ClientWorld; +import net.minecraft.entity.ItemEntity; +import net.minecraft.entity.LivingEntity; +import net.minecraft.registry.Registries; +import net.minecraft.util.hit.BlockHitResult; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.MathHelper; +import net.minecraft.world.World; +import org.apache.commons.lang3.tuple.MutableTriple; +import org.apache.commons.lang3.tuple.Triple; +import org.jetbrains.annotations.NotNull; +import org.joml.Vector2i; +import org.joml.Vector2ic; + +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class Room { + private static final Pattern SECRETS = Pattern.compile("ยง7(\\d{1,2})/(\\d{1,2}) Secrets"); + @NotNull + private final Type type; + @NotNull + private final Set<Vector2ic> segments; + /** + * The shape of the room. See {@link #getShape(IntSortedSet, IntSortedSet)}. + */ + @NotNull + private final Shape shape; + /** + * The room data containing all rooms for a specific dungeon and {@link #shape}. + */ + private Map<String, int[]> roomsData; + /** + * Contains all possible dungeon rooms for this room. The list is gradually shrunk by checking blocks until only one room is left. + */ + private List<MutableTriple<Direction, Vector2ic, List<String>>> possibleRooms; + /** + * Contains all blocks that have been checked to prevent checking the same block multiple times. + */ + private Set<BlockPos> checkedBlocks = new HashSet<>(); + /** + * The task that is used to check blocks. This is used to ensure only one such task can run at a time. + */ + private CompletableFuture<Void> findRoom; + private int doubleCheckBlocks; + /** + * Represents the matching state of the room with the following possible values: + * <li>{@link TriState#DEFAULT} means that the room has not been checked, is being processed, or does not {@link Type#needsScanning() need to be processed}. + * <li>{@link TriState#FALSE} means that the room has been checked and there is no match. + * <li>{@link TriState#TRUE} means that the room has been checked and there is a match. + */ + private TriState matched = TriState.DEFAULT; + private Table<Integer, BlockPos, SecretWaypoint> secretWaypoints; + + public Room(@NotNull Type type, @NotNull Vector2ic... physicalPositions) { + this.type = type; + segments = Set.of(physicalPositions); + IntSortedSet segmentsX = IntSortedSets.unmodifiable(new IntRBTreeSet(segments.stream().mapToInt(Vector2ic::x).toArray())); + IntSortedSet segmentsY = IntSortedSets.unmodifiable(new IntRBTreeSet(segments.stream().mapToInt(Vector2ic::y).toArray())); + shape = getShape(segmentsX, segmentsY); + roomsData = DungeonSecrets.ROOMS_DATA.getOrDefault("catacombs", Collections.emptyMap()).getOrDefault(shape.shape.toLowerCase(), Collections.emptyMap()); + possibleRooms = getPossibleRooms(segmentsX, segmentsY); + } + + @NotNull + public Type getType() { + return type; + } + + public boolean isMatched() { + return matched == TriState.TRUE; + } + + @Override + public String toString() { + return "Room{type=" + type + ", shape=" + shape + ", matched=" + matched + ", segments=" + Arrays.toString(segments.toArray()) + "}"; + } + + @NotNull + private Shape getShape(IntSortedSet segmentsX, IntSortedSet segmentsY) { + return switch (segments.size()) { + case 1 -> Shape.ONE_BY_ONE; + case 2 -> Shape.ONE_BY_TWO; + case 3 -> segmentsX.size() == 2 && segmentsY.size() == 2 ? Shape.L_SHAPE : Shape.ONE_BY_THREE; + case 4 -> segmentsX.size() == 2 && segmentsY.size() == 2 ? Shape.TWO_BY_TWO : Shape.ONE_BY_FOUR; + default -> throw new IllegalArgumentException("There are no matching room shapes with this set of physical positions: " + Arrays.toString(segments.toArray())); + }; + } + + private List<MutableTriple<Direction, Vector2ic, List<String>>> getPossibleRooms(IntSortedSet segmentsX, IntSortedSet segmentsY) { + List<String> possibleDirectionRooms = new ArrayList<>(roomsData.keySet()); + List<MutableTriple<Direction, Vector2ic, List<String>>> possibleRooms = new ArrayList<>(); + for (Direction direction : getPossibleDirections(segmentsX, segmentsY)) { + possibleRooms.add(MutableTriple.of(direction, DungeonMapUtils.getPhysicalCornerPos(direction, segmentsX, segmentsY), possibleDirectionRooms)); + } + return possibleRooms; + } + + @NotNull + private Direction[] getPossibleDirections(IntSortedSet segmentsX, IntSortedSet segmentsY) { + return switch (shape) { + case ONE_BY_ONE, TWO_BY_TWO -> Direction.values(); + case ONE_BY_TWO, ONE_BY_THREE, ONE_BY_FOUR -> { + if (segmentsX.size() > 1 && segmentsY.size() == 1) { + yield new Direction[]{Direction.NW, Direction.SE}; + } else if (segmentsX.size() == 1 && segmentsY.size() > 1) { + yield new Direction[]{Direction.NE, Direction.SW}; + } + throw new IllegalArgumentException("Shape " + shape.shape + " does not match segments: " + Arrays.toString(segments.toArray())); + } + case L_SHAPE -> { + if (!segments.contains(new Vector2i(segmentsX.firstInt(), segmentsY.firstInt()))) { + yield new Direction[]{Direction.SW}; + } else if (!segments.contains(new Vector2i(segmentsX.firstInt(), segmentsY.lastInt()))) { + yield new Direction[]{Direction.SE}; + } else if (!segments.contains(new Vector2i(segmentsX.lastInt(), segmentsY.firstInt()))) { + yield new Direction[]{Direction.NW}; + } else if (!segments.contains(new Vector2i(segmentsX.lastInt(), segmentsY.lastInt()))) { + yield new Direction[]{Direction.NE}; + } + throw new IllegalArgumentException("Shape " + shape.shape + " does not match segments: " + Arrays.toString(segments.toArray())); + } + }; + } + + /** + * Updates the room. + * <p></p> + * This method returns immediately if any of the following conditions are met: + * <ul> + * <li> The room does not need to be scanned and matched. (When the room is not of type {@link Type.ROOM}, {@link Type.PUZZLE}, or {@link Type.TRAP}. See {@link Type#needsScanning()}) </li> + * <li> The room has been matched or failed to match and is on cooldown. See {@link #matched}. </li> + * <li> {@link #findRoom The previous update} has not completed. </li> + * </ul> + * Then this method tries to match this room through: + * <ul> + * <li> Iterate over a 11 by 11 by 11 box around the player. </li> + * <li> Check it the block is part of this room and not part of a doorway. See {@link #segments} and {@link #notInDoorway(BlockPos)}. </li> + * <li> Checks if the position has been checked and adds it to {@link #checkedBlocks}. </li> + * <li> Calls {@link #checkBlock(ClientWorld, BlockPos)} </li> + * </ul> + */ + @SuppressWarnings("JavadocReference") + protected void update() { + // Logical AND has higher precedence than logical OR + if (!type.needsScanning() || matched != TriState.DEFAULT || !DungeonSecrets.isRoomsLoaded() || findRoom != null && !findRoom.isDone()) { + return; + } + MinecraftClient client = MinecraftClient.getInstance(); + ClientPlayerEntity player = client.player; + ClientWorld world = client.world; + if (player == null || world == null) { + return; + } + findRoom = CompletableFuture.runAsync(() -> { + for (BlockPos pos : BlockPos.iterate(player.getBlockPos().add(-5, -5, -5), player.getBlockPos().add(5, 5, 5))) { + if (segments.contains(DungeonMapUtils.getPhysicalRoomPos(pos)) && notInDoorway(pos) && checkedBlocks.add(pos) && checkBlock(world, pos)) { + break; + } + } + }); + } + + private static boolean notInDoorway(BlockPos pos) { + if (pos.getY() < 66 || pos.getY() > 73) { + return true; + } + int x = MathHelper.floorMod(pos.getX() - 8, 32); + int z = MathHelper.floorMod(pos.getZ() - 8, 32); + return (x < 13 || x > 17 || z > 2 && z < 28) && (z < 13 || z > 17 || x > 2 && x < 28); + } + + /** + * Filters out dungeon rooms which does not contain the block at the given position. + * <p></p> + * This method: + * <ul> + * <li> Checks if the block type is included in the dungeon rooms data. See {@link DungeonSecrets#NUMERIC_ID}. </li> + * <li> For each possible direction: </li> + * <ul> + * <li> Rotate and convert the position to a relative position. See {@link DungeonMapUtils#actualToRelative(Direction, Vector2ic, BlockPos)}. </li> + * <li> Encode the block based on the relative position and the custom numeric block id. See {@link #posIdToInt(BlockPos, byte)}. </li> + * <li> For each possible room in the current direction: </li> + * <ul> + * <li> Check if {@link #roomsData} contains the encoded block. </li> + * <li> If so, add the room to the new list of possible rooms for this direction. </li> + * </ul> + * <li> Replace the old possible room list for the current direction with the new one. </li> + * </ul> + * <li> If there are no matching rooms left: </li> + * <ul> + * <li> Terminate matching by setting {@link #matched} to {@link TriState#FALSE}. </li> + * <li> Schedule another matching attempt in 50 ticks (2.5 seconds). </li> + * <li> Reset {@link #possibleRooms} and {@link #checkedBlocks} with {@link #reset()}. </li> + * <li> Return {@code true} </li> + * </ul> + * <li> If there are exactly one room matching: </li> + * <ul> + * <li> Call {@link #roomMatched(String, Direction, Vector2ic)}. </li> + * <li> Discard the no longer needed fields to save memory. </li> + * <li> Return {@code true} </li> + * </ul> + * <li> Return {@code false} </li> + * </ul> + * + * @param world the world to get the block from + * @param pos the position of the block to check + * @return whether room matching should end. Either a match is found or there are no valid rooms left + */ + private boolean checkBlock(ClientWorld world, BlockPos pos) { + byte id = DungeonSecrets.NUMERIC_ID.getByte(Registries.BLOCK.getId(world.getBlockState(pos).getBlock()).toString()); + if (id == 0) { + return false; + } + for (MutableTriple<Direction, Vector2ic, List<String>> directionRooms : possibleRooms) { + int block = posIdToInt(DungeonMapUtils.actualToRelative(directionRooms.getLeft(), directionRooms.getMiddle(), pos), id); + List<String> possibleDirectionRooms = new ArrayList<>(); + for (String room : directionRooms.getRight()) { + if (Arrays.binarySearch(roomsData.get(room), block) >= 0) { + possibleDirectionRooms.add(room); + } + } + directionRooms.setRight(possibleDirectionRooms); + } + + int matchingRoomsSize = possibleRooms.stream().map(Triple::getRight).mapToInt(Collection::size).sum(); + if (matchingRoomsSize == 0) { + // If no rooms match, reset the fields and scan again after 50 ticks. + matched = TriState.FALSE; + DungeonSecrets.LOGGER.warn("[Skyblocker] No dungeon room matches after checking {} block(s)", checkedBlocks.size()); + SkyblockerMod.getInstance().scheduler.schedule(() -> matched = TriState.DEFAULT, 50); + reset(); + return true; + } else if (matchingRoomsSize == 1 && ++doubleCheckBlocks >= 10) { + // If one room matches, load the secrets for that room and discard the no longer needed fields. + for (Triple<Direction, Vector2ic, List<String>> directionRooms : possibleRooms) { + if (directionRooms.getRight().size() == 1) { + roomMatched(directionRooms.getRight().get(0), directionRooms.getLeft(), directionRooms.getMiddle()); + discard(); + return true; + } + } + return false; // This should never happen, we just checked that there is one possible room, and the return true in the loop should activate + } else { + DungeonSecrets.LOGGER.debug("[Skyblocker] {} room(s) remaining after checking {} block(s)", matchingRoomsSize, checkedBlocks.size()); + return false; + } + } + + /** + * Encodes a {@link BlockPos} and the custom numeric block id into an integer. + * + * @param pos the position of the block + * @param id the custom numeric block id + * @return the encoded integer + */ + private int posIdToInt(BlockPos pos, byte id) { + return pos.getX() << 24 | pos.getY() << 16 | pos.getZ() << 8 | id; + } + + /** + * Loads the secret waypoints for the room from {@link DungeonSecrets#waypointsJson} once it has been matched + * and sets {@link #matched} to {@link TriState#TRUE}. + * + * @param directionRooms the direction, position, and name of the room + */ + @SuppressWarnings("JavadocReference") + private void roomMatched(String name, Direction direction, Vector2ic physicalCornerPos) { + Table<Integer, BlockPos, SecretWaypoint> secretWaypointsMutable = HashBasedTable.create(); + for (JsonElement waypointElement : DungeonSecrets.getRoomWaypoints(name)) { + JsonObject waypoint = waypointElement.getAsJsonObject(); + String secretName = waypoint.get("secretName").getAsString(); + int secretIndex = Integer.parseInt(secretName.substring(0, Character.isDigit(secretName.charAt(1)) ? 2 : 1)); + BlockPos pos = DungeonMapUtils.relativeToActual(direction, physicalCornerPos, waypoint); + secretWaypointsMutable.put(secretIndex, pos, new SecretWaypoint(secretIndex, waypoint, secretName, pos)); + } + secretWaypoints = ImmutableTable.copyOf(secretWaypointsMutable); + matched = TriState.TRUE; + DungeonSecrets.LOGGER.info("[Skyblocker] Room {} matched after checking {} block(s)", name, checkedBlocks.size()); + } + + /** + * Resets fields for another round of matching after room matching fails. + */ + private void reset() { + IntSortedSet segmentsX = IntSortedSets.unmodifiable(new IntRBTreeSet(segments.stream().mapToInt(Vector2ic::x).toArray())); + IntSortedSet segmentsY = IntSortedSets.unmodifiable(new IntRBTreeSet(segments.stream().mapToInt(Vector2ic::y).toArray())); + possibleRooms = getPossibleRooms(segmentsX, segmentsY); + checkedBlocks = new HashSet<>(); + doubleCheckBlocks = 0; + } + + /** + * Discards fields after room matching completes when a room is found. + * These fields are no longer needed and are discarded to save memory. + */ + private void discard() { + roomsData = null; + possibleRooms = null; + checkedBlocks = null; + doubleCheckBlocks = 0; + } + + /** + * Calls {@link SecretWaypoint#render(WorldRenderContext)} on {@link #secretWaypoints all secret waypoints}. + */ + protected void render(WorldRenderContext context) { + for (SecretWaypoint secretWaypoint : secretWaypoints.values()) { + if (secretWaypoint.shouldRender()) { + secretWaypoint.render(context); + } + } + } + + /** + * Sets all secrets as found if {@link #isAllSecretsFound(String)}. + */ + protected void onChatMessage(String message) { + if (isAllSecretsFound(message)) { + secretWaypoints.values().forEach(SecretWaypoint::setFound); + } + } + + /** + * Checks if the number of found secrets is equals or greater than the total number of secrets in the room. + * + * @param message the message to check in + * @return whether the number of found secrets is equals or greater than the total number of secrets in the room + */ + protected static boolean isAllSecretsFound(String message) { + Matcher matcher = SECRETS.matcher(message); + if (matcher.find()) { + return Integer.parseInt(matcher.group(1)) >= Integer.parseInt(matcher.group(2)); + } + return false; + } + + /** + * Marks the secret at the interaction position as found when the player interacts with a chest or a player head, + * if there is a secret at the interaction position. + * + * @param world the world to get the block from + * @param hitResult the block being interacted with + * @see #onSecretFound(SecretWaypoint, String, Object...) + */ + protected void onUseBlock(World world, BlockHitResult hitResult) { + BlockState state = world.getBlockState(hitResult.getBlockPos()); + if (state.isOf(Blocks.CHEST) || state.isOf(Blocks.PLAYER_HEAD) || state.isOf(Blocks.PLAYER_WALL_HEAD)) { + secretWaypoints.column(hitResult.getBlockPos()).values().stream().filter(SecretWaypoint::needsInteraction).findAny() + .ifPresent(secretWaypoint -> onSecretFound(secretWaypoint, "[Skyblocker] Detected {} interaction, setting secret #{} as found", secretWaypoint.category, secretWaypoint.secretIndex)); + } else if (state.isOf(Blocks.LEVER)) { + secretWaypoints.column(hitResult.getBlockPos()).values().stream().filter(SecretWaypoint::isLever).forEach(SecretWaypoint::setFound); + } + } + + /** + * Marks the closest secret no greater than 6 blocks away as found when the player picks up a secret item. + * + * @param itemEntity the item entity being picked up + * @param collector the collector of the item + * @see #onSecretFound(SecretWaypoint, String, Object...) + */ + protected void onItemPickup(ItemEntity itemEntity, LivingEntity collector) { + if (SecretWaypoint.SECRET_ITEMS.stream().noneMatch(itemEntity.getStack().getName().getString()::contains)) { + return; + } + secretWaypoints.values().stream().filter(SecretWaypoint::needsItemPickup).min(Comparator.comparingDouble(SecretWaypoint.getSquaredDistanceToFunction(collector))).filter(SecretWaypoint.getRangePredicate(collector)) + .ifPresent(secretWaypoint -> onSecretFound(secretWaypoint, "[Skyblocker] Detected {} picked up a {} from a {} secret, setting secret #{} as found", collector.getName().getString(), itemEntity.getName().getString(), secretWaypoint.category, secretWaypoint.secretIndex)); + } + + /** + * Marks all secret waypoints with the same index as the given {@link SecretWaypoint} as found. + * + * @param secretWaypoint the secret waypoint to read the index from. + * @param msg the message to log + * @param args the args for the {@link org.slf4j.Logger#info(String, Object...) Logger#info(String, Object...)} call + */ + private void onSecretFound(SecretWaypoint secretWaypoint, String msg, Object... args) { + secretWaypoints.row(secretWaypoint.secretIndex).values().forEach(SecretWaypoint::setFound); + DungeonSecrets.LOGGER.info(msg, args); + } + + protected boolean markSecrets(int secretIndex, boolean found) { + Map<BlockPos, SecretWaypoint> secret = secretWaypoints.row(secretIndex); + if (secret.isEmpty()) { + return false; + } else { + secret.values().forEach(found ? SecretWaypoint::setFound : SecretWaypoint::setMissing); + return true; + } + } + + public enum Type { + ENTRANCE(MapColor.DARK_GREEN.getRenderColorByte(MapColor.Brightness.HIGH)), + ROOM(MapColor.ORANGE.getRenderColorByte(MapColor.Brightness.LOWEST)), + PUZZLE(MapColor.MAGENTA.getRenderColorByte(MapColor.Brightness.HIGH)), + TRAP(MapColor.ORANGE.getRenderColorByte(MapColor.Brightness.HIGH)), + MINIBOSS(MapColor.YELLOW.getRenderColorByte(MapColor.Brightness.HIGH)), + FAIRY(MapColor.PINK.getRenderColorByte(MapColor.Brightness.HIGH)), + BLOOD(MapColor.BRIGHT_RED.getRenderColorByte(MapColor.Brightness.HIGH)), + UNKNOWN(MapColor.GRAY.getRenderColorByte(MapColor.Brightness.NORMAL)); + final byte color; + + Type(byte color) { + this.color = color; + } + + /** + * @return whether this room type has secrets and needs to be scanned and matched. + */ + private boolean needsScanning() { + return switch (this) { + case ROOM, PUZZLE, TRAP -> true; + default -> false; + }; + } + } + + private enum Shape { + ONE_BY_ONE("1x1"), + ONE_BY_TWO("1x2"), + ONE_BY_THREE("1x3"), + ONE_BY_FOUR("1x4"), + L_SHAPE("L-shape"), + TWO_BY_TWO("2x2"); + final String shape; + + Shape(String shape) { + this.shape = shape; + } + + @Override + public String toString() { + return shape; + } + } + + public enum Direction { + NW, NE, SW, SE + } +} diff --git a/src/main/java/me/xmrvizzy/skyblocker/skyblock/dungeon/secrets/SecretWaypoint.java b/src/main/java/me/xmrvizzy/skyblocker/skyblock/dungeon/secrets/SecretWaypoint.java new file mode 100644 index 00000000..c2658fd3 --- /dev/null +++ b/src/main/java/me/xmrvizzy/skyblocker/skyblock/dungeon/secrets/SecretWaypoint.java @@ -0,0 +1,132 @@ +package me.xmrvizzy.skyblocker.skyblock.dungeon.secrets; + +import com.google.gson.JsonObject; +import me.xmrvizzy.skyblocker.config.SkyblockerConfig; +import me.xmrvizzy.skyblocker.utils.RenderHelper; +import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderContext; +import net.minecraft.client.MinecraftClient; +import net.minecraft.entity.Entity; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Vec3d; + +import java.util.List; +import java.util.function.Predicate; +import java.util.function.ToDoubleFunction; + +public class SecretWaypoint { + static final List<String> SECRET_ITEMS = List.of("Decoy", "Defuse Kit", "Dungeon Chest Key", "Healing VIII", "Inflatable Jerry", "Spirit Leap", "Training Weights", "Trap", "Treasure Talisman"); + final int secretIndex; + final Category category; + private final Text name; + private final BlockPos pos; + private final Vec3d centerPos; + private boolean missing; + + SecretWaypoint(int secretIndex, JsonObject waypoint, String name, BlockPos pos) { + this.secretIndex = secretIndex; + this.category = Category.get(waypoint); + this.name = Text.of(name); + this.pos = pos; + this.centerPos = pos.toCenterPos(); + this.missing = true; + } + + static ToDoubleFunction<SecretWaypoint> getSquaredDistanceToFunction(Entity entity) { + return secretWaypoint -> entity.squaredDistanceTo(secretWaypoint.centerPos); + } + + static Predicate<SecretWaypoint> getRangePredicate(Entity entity) { + return secretWaypoint -> entity.squaredDistanceTo(secretWaypoint.centerPos) <= 36D; + } + + boolean shouldRender() { + return category.isEnabled() && missing; + } + + boolean needsInteraction() { + return category.needsInteraction(); + } + + boolean isLever() { + return category.isLever(); + } + + boolean needsItemPickup() { + return category.needsItemPickup(); + } + + void setFound() { + this.missing = false; + } + + void setMissing() { + this.missing = true; + } + + /** + * Renders the secret waypoint, including a filled cube, a beacon beam, the name, and the distance from the player. + */ + void render(WorldRenderContext context) { + RenderHelper.renderFilledThroughWallsWithBeaconBeam(context, pos, category.colorComponents, 0.5F); + Vec3d posUp = centerPos.add(0, 1, 0); + RenderHelper.renderText(context, name, posUp, true); + double distance = context.camera().getPos().distanceTo(centerPos); + RenderHelper.renderText(context, Text.literal(Math.round(distance) + "m").formatted(Formatting.YELLOW), posUp, 1, MinecraftClient.getInstance().textRenderer.fontHeight + 1, true); + } + + enum Category { + ENTRANCE(secretWaypoints -> secretWaypoints.enableEntranceWaypoints, 0, 255, 0), + SUPERBOOM(secretWaypoints -> secretWaypoints.enableSuperboomWaypoints, 255, 0, 0), + CHEST(secretWaypoints -> secretWaypoints.enableChestWaypoints, 2, 213, 250), + ITEM(secretWaypoints -> secretWaypoints.enableItemWaypoints, 2, 64, 250), + BAT(secretWaypoints -> secretWaypoints.enableBatWaypoints, 142, 66, 0), + WITHER(secretWaypoints -> secretWaypoints.enableWitherWaypoints, 30, 30, 30), + LEVER(secretWaypoints -> secretWaypoints.enableLeverWaypoints, 250, 217, 2), + FAIRYSOUL(secretWaypoints -> secretWaypoints.enableFairySoulWaypoints, 255, 85, 255), + STONK(secretWaypoints -> secretWaypoints.enableStonkWaypoints, 146, 52, 235), + DEFAULT(secretWaypoints -> secretWaypoints.enableDefaultWaypoints, 190, 255, 252); + private final Predicate<SkyblockerConfig.SecretWaypoints> enabledPredicate; + private final float[] colorComponents; + + Category(Predicate<SkyblockerConfig.SecretWaypoints> enabledPredicate, int... intColorComponents) { + this.enabledPredicate = enabledPredicate; + colorComponents = new float[intColorComponents.length]; + for (int i = 0; i < intColorComponents.length; i++) { + colorComponents[i] = intColorComponents[i] / 255F; + } + } + + private static Category get(JsonObject categoryJson) { + return switch (categoryJson.get("category").getAsString()) { + case "entrance" -> Category.ENTRANCE; + case "superboom" -> Category.SUPERBOOM; + case "chest" -> Category.CHEST; + case "item" -> Category.ITEM; + case "bat" -> Category.BAT; + case "wither" -> Category.WITHER; + case "lever" -> Category.LEVER; + case "fairysoul" -> Category.FAIRYSOUL; + case "stonk" -> Category.STONK; + default -> Category.DEFAULT; + }; + } + + boolean needsInteraction() { + return this == CHEST || this == WITHER; + } + + boolean isLever() { + return this == LEVER; + } + + boolean needsItemPickup() { + return this == ITEM || this == BAT; + } + + boolean isEnabled() { + return enabledPredicate.test(SkyblockerConfig.get().locations.dungeons.secretWaypoints); + } + } +} diff --git a/src/main/java/me/xmrvizzy/skyblocker/utils/RenderHelper.java b/src/main/java/me/xmrvizzy/skyblocker/utils/RenderHelper.java index 8f0f7860..a38f1ab8 100644 --- a/src/main/java/me/xmrvizzy/skyblocker/utils/RenderHelper.java +++ b/src/main/java/me/xmrvizzy/skyblocker/utils/RenderHelper.java @@ -1,5 +1,8 @@ package me.xmrvizzy.skyblocker.utils; +import com.mojang.blaze3d.platform.GlStateManager.DstFactor; +import com.mojang.blaze3d.platform.GlStateManager.SrcFactor; +import com.mojang.blaze3d.systems.RenderSystem; import me.x150.renderer.render.Renderer3d; import me.xmrvizzy.skyblocker.mixin.accessor.BeaconBlockEntityRendererInvoker; import me.xmrvizzy.skyblocker.utils.culling.OcclusionCulling; @@ -7,34 +10,27 @@ import me.xmrvizzy.skyblocker.utils.title.Title; import me.xmrvizzy.skyblocker.utils.title.TitleContainer; import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderContext; import net.minecraft.client.MinecraftClient; -import net.minecraft.client.render.BufferBuilder; -import net.minecraft.client.render.Camera; -import net.minecraft.client.render.GameRenderer; -import net.minecraft.client.render.Tessellator; -import net.minecraft.client.render.VertexConsumerProvider; -import net.minecraft.client.render.VertexFormats; +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.SoundEvent; +import net.minecraft.text.OrderedText; import net.minecraft.text.Text; -import net.minecraft.util.Formatting; import net.minecraft.util.Identifier; import net.minecraft.util.math.BlockPos; import net.minecraft.util.math.Vec3d; -import java.awt.*; - import org.joml.Matrix3f; import org.joml.Matrix4f; import org.joml.Vector3f; import org.lwjgl.opengl.GL11; -import com.mojang.blaze3d.platform.GlStateManager.DstFactor; -import com.mojang.blaze3d.platform.GlStateManager.SrcFactor; -import com.mojang.blaze3d.systems.RenderSystem; +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); @@ -44,8 +40,8 @@ public class RenderHelper { 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(); + renderFilled(context, pos, colorComponents, alpha); + Renderer3d.stopRenderThroughWalls(); } } @@ -63,84 +59,120 @@ public class RenderHelper { 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.x, pos.getY() - camera.y, pos.getZ() - camera.z); - + 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(); } } - - /** - * Draws lines from point to point.<br><br> - * - * 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 displayTitleAndPlaySound(int stayTicks, int fadeOutTicks, String titleKey, Formatting formatting) { - MinecraftClient.getInstance().inGameHud.setTitleTicks(0, stayTicks, fadeOutTicks); - MinecraftClient.getInstance().inGameHud.setTitle(Text.translatable(titleKey).formatted(formatting)); - playNotificationSound(); + + 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); + } + + /** + * 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(); + } + + /** + * 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(); } /** |