diff --git a/src/main/java/net/earthcomputer/clientcommands/ClientCommands.java b/src/main/java/net/earthcomputer/clientcommands/ClientCommands.java index fba30693..6ace2550 100644 --- a/src/main/java/net/earthcomputer/clientcommands/ClientCommands.java +++ b/src/main/java/net/earthcomputer/clientcommands/ClientCommands.java @@ -19,6 +19,8 @@ import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback; import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.fabricmc.fabric.api.client.rendering.v1.HudRenderCallback; +import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderEvents; import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry; import net.fabricmc.loader.api.FabricLoader; import net.minecraft.Util; @@ -91,6 +93,8 @@ public void onInitializeClient() { FishingCracker.registerEvents(); PlayerRandCracker.registerEvents(); ServerBrandManager.registerEvents(); + HudRenderCallback.EVENT.register(WaypointCommand::renderWaypointLabels); + WorldRenderEvents.AFTER_ENTITIES.register(WaypointCommand::renderWaypointBoxes); } private static Set getCommands(CommandDispatcher dispatcher) { @@ -173,6 +177,7 @@ public static void registerCommands(CommandDispatcher UsageTreeCommand.register(dispatcher); UuidCommand.register(dispatcher); VarCommand.register(dispatcher); + WaypointCommand.register(dispatcher); WeatherCommand.register(dispatcher); WhisperEncryptedCommand.register(dispatcher); WikiCommand.register(dispatcher); diff --git a/src/main/java/net/earthcomputer/clientcommands/command/WaypointCommand.java b/src/main/java/net/earthcomputer/clientcommands/command/WaypointCommand.java new file mode 100644 index 00000000..3f01ea17 --- /dev/null +++ b/src/main/java/net/earthcomputer/clientcommands/command/WaypointCommand.java @@ -0,0 +1,393 @@ +package net.earthcomputer.clientcommands.command; + +import com.mojang.blaze3d.platform.Window; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.brigadier.Command; +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.exceptions.DynamicCommandExceptionType; +import com.mojang.brigadier.exceptions.SimpleCommandExceptionType; +import com.mojang.logging.LogUtils; +import com.mojang.serialization.Dynamic; +import net.earthcomputer.clientcommands.ClientCommands; +import net.earthcomputer.clientcommands.render.RenderQueue; +import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource; +import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderContext; +import net.minecraft.SharedConstants; +import net.minecraft.Util; +import net.minecraft.client.Camera; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.multiplayer.ClientChunkCache; +import net.minecraft.client.renderer.GameRenderer; +import net.minecraft.client.renderer.LightTexture; +import net.minecraft.client.renderer.ShapeRenderer; +import net.minecraft.commands.SharedSuggestionProvider; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtIo; +import net.minecraft.nbt.NbtOps; +import net.minecraft.nbt.NbtUtils; +import net.minecraft.nbt.Tag; +import net.minecraft.network.chat.CommonComponents; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; +import org.apache.commons.lang3.tuple.Pair; +import org.joml.Vector2d; +import org.slf4j.Logger; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.PriorityQueue; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static com.mojang.brigadier.arguments.BoolArgumentType.*; +import static com.mojang.brigadier.arguments.StringArgumentType.*; +import static dev.xpple.clientarguments.arguments.CBlockPosArgument.*; +import static dev.xpple.clientarguments.arguments.CDimensionArgument.*; +import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.*; + +public class WaypointCommand { + + private static final Map>>> waypoints = new HashMap<>(); + + private static final Logger LOGGER = LogUtils.getLogger(); + + private static final SimpleCommandExceptionType SAVE_FAILED_EXCEPTION = new SimpleCommandExceptionType(Component.translatable("commands.cwaypoint.saveFailed")); + private static final DynamicCommandExceptionType ALREADY_EXISTS_EXCEPTION = new DynamicCommandExceptionType(name -> Component.translatable("commands.cwaypoint.alreadyExists", name)); + private static final DynamicCommandExceptionType NOT_FOUND_EXCEPTION = new DynamicCommandExceptionType(name -> Component.translatable("commands.cwaypoint.notFound", name)); + + static { + try { + loadFile(); + } catch (Exception e) { + LOGGER.error("Could not load waypoints file, hence /cwaypoint will not work!", e); + } + } + + public static void register(CommandDispatcher dispatcher) { + dispatcher.register(literal("cwaypoint") + .then(literal("add") + .then(argument("name", word()) + .then(argument("pos", blockPos()) + .executes(ctx -> add(ctx.getSource(), getString(ctx, "name"), getBlockPos(ctx, "pos"))) + .then(argument("dimension", dimension()) + .executes(ctx -> add(ctx.getSource(), getString(ctx, "name"), getBlockPos(ctx, "pos"), getDimension(ctx, "dimension"))))))) + .then(literal("remove") + .then(argument("name", word()) + .suggests((ctx, builder) -> { + Map>> worldWaypoints = waypoints.get(getWorldIdentifier(ctx.getSource().getClient())); + return SharedSuggestionProvider.suggest(worldWaypoints != null ? worldWaypoints.keySet() : Collections.emptySet(), builder); + }) + .executes(ctx -> remove(ctx.getSource(), getString(ctx, "name"))))) + .then(literal("edit") + .then(argument("name", word()) + .suggests((ctx, builder) -> { + Map>> worldWaypoints = waypoints.get(getWorldIdentifier(ctx.getSource().getClient())); + return SharedSuggestionProvider.suggest(worldWaypoints != null ? worldWaypoints.keySet() : Collections.emptySet(), builder); + }) + .then(argument("pos", blockPos()) + .executes(ctx -> edit(ctx.getSource(), getString(ctx, "name"), getBlockPos(ctx, "pos"))) + .then(argument("dimension", dimension()) + .executes(ctx -> edit(ctx.getSource(), getString(ctx, "name"), getBlockPos(ctx, "pos"), getDimension(ctx, "dimension"))))))) + .then(literal("list") + .executes(ctx -> list(ctx.getSource())) + .then(argument("current", bool()) + .executes(ctx -> list(ctx.getSource(), getBool(ctx, "current")))))); + } + + public static String getWorldIdentifier(Minecraft minecraft) { + String worldIdentifier; + if (minecraft.hasSingleplayerServer()) { + // the level id remains the same even after the level is renamed + worldIdentifier = minecraft.getSingleplayerServer().storageSource.getLevelId(); + } else { + worldIdentifier = minecraft.getConnection().getConnection().getRemoteAddress().toString(); + } + return worldIdentifier; + } + + private static int add(FabricClientCommandSource source, String name, BlockPos pos) throws CommandSyntaxException { + return add(source, name, pos, source.getWorld().dimension()); + } + + private static int add(FabricClientCommandSource source, String name, BlockPos pos, ResourceKey dimension) throws CommandSyntaxException { + String worldIdentifier = getWorldIdentifier(source.getClient()); + + Map>> worldWaypoints = waypoints.computeIfAbsent(worldIdentifier, key -> new HashMap<>()); + + if (worldWaypoints.putIfAbsent(name, Pair.of(pos, dimension)) != null) { + throw ALREADY_EXISTS_EXCEPTION.create(name); + } + + saveFile(); + source.sendFeedback(Component.translatable("commands.cwaypoint.add.success", name, pos.toShortString(), dimension.location())); + return Command.SINGLE_SUCCESS; + } + + private static int remove(FabricClientCommandSource source, String name) throws CommandSyntaxException { + String worldIdentifier = getWorldIdentifier(source.getClient()); + + Map>> worldWaypoints = waypoints.get(worldIdentifier); + + if (worldWaypoints == null) { + throw NOT_FOUND_EXCEPTION.create(name); + } + + if (worldWaypoints.remove(name) == null) { + throw NOT_FOUND_EXCEPTION.create(name); + } + + saveFile(); + source.sendFeedback(Component.translatable("commands.cwaypoint.remove.success", name)); + return Command.SINGLE_SUCCESS; + } + + private static int edit(FabricClientCommandSource source, String name, BlockPos pos) throws CommandSyntaxException { + return edit(source, name, pos, source.getWorld().dimension()); + } + + private static int edit(FabricClientCommandSource source, String name, BlockPos pos, ResourceKey dimension) throws CommandSyntaxException { + String worldIdentifier = getWorldIdentifier(source.getClient()); + + Map>> worldWaypoints = waypoints.get(worldIdentifier); + + if (worldWaypoints == null) { + throw NOT_FOUND_EXCEPTION.create(name); + } + + if (worldWaypoints.computeIfPresent(name, (key, value) -> Pair.of(pos, dimension)) == null) { + throw NOT_FOUND_EXCEPTION.create(name); + } + + saveFile(); + source.sendFeedback(Component.translatable("commands.cwaypoint.edit.success", name, pos.toShortString(), dimension.location())); + return Command.SINGLE_SUCCESS; + } + + private static int list(FabricClientCommandSource source) { + return list(source, false); + } + + private static int list(FabricClientCommandSource source, boolean current) { + if (current) { + String worldIdentifier = getWorldIdentifier(source.getClient()); + + Map>> worldWaypoints = waypoints.get(worldIdentifier); + + if (worldWaypoints == null || worldWaypoints.isEmpty()) { + source.sendFeedback(Component.translatable("commands.cwaypoint.list.empty")); + return Command.SINGLE_SUCCESS; + } + + worldWaypoints.forEach((name, waypoint) -> source.sendFeedback(Component.translatable("commands.cwaypoint.list", name, waypoint.getLeft().toShortString(), waypoint.getRight().location()))); + return Command.SINGLE_SUCCESS; + } + + if (waypoints.isEmpty()) { + source.sendFeedback(Component.translatable("commands.cwaypoint.list.empty")); + return Command.SINGLE_SUCCESS; + } + + waypoints.forEach((worldIdentifier, worldWaypoints) -> { + if (worldWaypoints.isEmpty()) { + return; + } + + source.sendFeedback(Component.literal(worldIdentifier).append(":")); + worldWaypoints.forEach((name, waypoint) -> source.sendFeedback(Component.translatable("commands.cwaypoint.list", name, waypoint.getLeft().toShortString(), waypoint.getRight().location()))); + }); + return Command.SINGLE_SUCCESS; + } + + private static void saveFile() throws CommandSyntaxException { + try { + CompoundTag rootTag = new CompoundTag(); + rootTag.putInt("DataVersion", SharedConstants.getCurrentVersion().getDataVersion().getVersion()); + CompoundTag compoundTag = new CompoundTag(); + waypoints.forEach((worldIdentifier, worldWaypoints) -> compoundTag.put(worldIdentifier, worldWaypoints.entrySet().stream() + .collect(CompoundTag::new, (result, entry) -> { + CompoundTag waypoint = new CompoundTag(); + Tag pos = NbtUtils.writeBlockPos(entry.getValue().getLeft()); + waypoint.put("pos", pos); + String dimension = entry.getValue().getRight().location().toString(); + waypoint.putString("Dimension", dimension); + result.put(entry.getKey(), waypoint); + }, CompoundTag::merge))); + rootTag.put("Waypoints", compoundTag); + Path newFile = Files.createTempFile(ClientCommands.configDir, "waypoints", ".dat"); + NbtIo.write(rootTag, newFile); + Path backupFile = ClientCommands.configDir.resolve("waypoints.dat_old"); + Path currentFile = ClientCommands.configDir.resolve("waypoints.dat"); + Util.safeReplaceFile(currentFile, newFile, backupFile); + } catch (IOException e) { + throw SAVE_FAILED_EXCEPTION.create(); + } + } + + private static void loadFile() throws IOException { + waypoints.clear(); + CompoundTag rootTag = NbtIo.read(ClientCommands.configDir.resolve("waypoints.dat")); + if (rootTag == null) { + return; + } + // TODO: update-sensitive: apply custom data fixes when it becomes necessary + CompoundTag compoundTag = rootTag.getCompound("Waypoints"); + compoundTag.getAllKeys().forEach(worldIdentifier -> { + CompoundTag worldWaypoints = compoundTag.getCompound(worldIdentifier); + waypoints.put(worldIdentifier, worldWaypoints.getAllKeys().stream() + .collect(Collectors.toMap(Function.identity(), name -> { + CompoundTag waypoint = worldWaypoints.getCompound(name); + BlockPos pos = NbtUtils.readBlockPos(waypoint, "pos").orElseThrow(); + ResourceKey dimension = Level.RESOURCE_KEY_CODEC.parse(new Dynamic<>(NbtOps.INSTANCE, waypoint.get("Dimension"))).resultOrPartial(LOGGER::error).orElseThrow(); + return Pair.of(pos, dimension); + }))); + }); + } + + public static void renderWaypointLabels(GuiGraphics guiGraphics, DeltaTracker deltaTracker) { + String worldIdentifier = getWorldIdentifier(Minecraft.getInstance()); + Map>> waypoints = WaypointCommand.waypoints.get(worldIdentifier); + if (waypoints == null) { + return; + } + + Minecraft minecraft = Minecraft.getInstance(); + GameRenderer gameRenderer = minecraft.gameRenderer; + Camera camera = gameRenderer.getMainCamera(); + Entity cameraEntity = camera.getEntity(); + float partialTicks = deltaTracker.getGameTimeDeltaPartialTick(true); + double verticalFovRad = Math.toRadians(gameRenderer.getFov(camera, partialTicks, false)); + Window window = minecraft.getWindow(); + double aspectRatio = (double) window.getGuiScaledWidth() / window.getGuiScaledHeight(); + double horizontalFovRad = 2 * Math.atan(Math.tan(verticalFovRad / 2) * aspectRatio); + + Vec3 viewVector3 = cameraEntity.getViewVector(1.0f); + Vector2d viewVector = new Vector2d(viewVector3.x, viewVector3.z); + Vector2d position = new Vector2d(cameraEntity.getEyePosition().x, cameraEntity.getEyePosition().z); + + PriorityQueue> xPositionsBuilder = new PriorityQueue<>(Comparator.comparingInt(Pair::getRight)); + waypoints.forEach((waypointName, waypoint) -> { + if (!waypoint.getRight().location().equals(minecraft.level.dimension().location())) { + return; + } + + double distanceSquared = waypoint.getLeft().distToCenterSqr(cameraEntity.position()); + long distance = Math.round(Math.sqrt(distanceSquared)); + + MutableComponent waypointComponent = Component.literal(waypointName).append(CommonComponents.space()).append(Long.toString(distance)); + + Vector2d waypointLocation = new Vector2d(waypoint.getLeft().getX(), waypoint.getLeft().getZ()); + double angleRad = viewVector.angle(waypointLocation.sub(position, new Vector2d())); + boolean right = angleRad > 0; + angleRad = Math.abs(angleRad); + + int x; + if (angleRad > horizontalFovRad / 2) { + int width = minecraft.font.width(waypointComponent); + x = right ? guiGraphics.guiWidth() - width / 2 : width / 2; + } else { + // V is the view vector + // A is the leftmost visible direction + // B is the rightmost visible direction + // M is the intersection of the waypoint ray with AB + double mv = Math.tan(angleRad) * GameRenderer.PROJECTION_Z_NEAR; + double av = Math.tan(horizontalFovRad / 2) * GameRenderer.PROJECTION_Z_NEAR; + double ab = 2 * av; + double am = right ? mv + av : ab - (mv + av); + double perc = am / ab; + x = (int) (perc * guiGraphics.guiWidth()); + } + xPositionsBuilder.offer(Pair.of(waypointComponent, x)); + }); + + List> xPositions = new ArrayList<>(); + int waypointAmount = xPositionsBuilder.size(); + for (int i = 0; i < waypointAmount; i++) { + xPositions.add(xPositionsBuilder.poll()); + } + + int yOffset = 1; + Map>> positions = new HashMap<>(); + positions.put(yOffset, xPositions); + + while (true) { + List> pairs = positions.get(yOffset); + if (pairs == null) { + break; + } + int i = 0; + while (i < pairs.size() - 1) { + Pair leftPair = pairs.get(i); + Pair rightPair = pairs.get(i + 1); + Integer leftX = leftPair.getRight(); + Integer rightX = rightPair.getRight(); + int leftWidth = minecraft.font.width(leftPair.getLeft()); + int rightWidth = minecraft.font.width(rightPair.getLeft()); + if (leftWidth / 2 + rightWidth / 2 > rightX - leftX) { + List> nextLevel = positions.computeIfAbsent(yOffset + minecraft.font.lineHeight, k -> new ArrayList<>()); + Pair removed = pairs.remove(i + 1); + nextLevel.add(removed); + } else { + i++; + } + } + yOffset += minecraft.font.lineHeight; + } + + positions.forEach((y, w) -> w.forEach(waypoint -> guiGraphics.drawCenteredString(minecraft.font, waypoint.getLeft(), waypoint.getRight(), y, 0xFFFFFF))); + } + + public static void renderWaypointBoxes(WorldRenderContext context) { + String worldIdentifier = getWorldIdentifier(Minecraft.getInstance()); + Map>> waypoints = WaypointCommand.waypoints.get(worldIdentifier); + if (waypoints == null) { + return; + } + + ClientChunkCache chunkSource = context.world().getChunkSource(); + waypoints.forEach((waypointName, waypoint) -> { + BlockPos waypointLocation = waypoint.getLeft(); + if (!chunkSource.hasChunk(waypointLocation.getX() >> 4, waypointLocation.getZ() >> 4)) { + return; + } + + Vec3 cameraPosition = context.camera().getPosition(); + float distance = (float) waypointLocation.distToCenterSqr(cameraPosition); + distance = (float) Math.sqrt(distance) / 6; + + PoseStack stack = context.matrixStack(); + stack.pushPose(); + stack.translate(cameraPosition.scale(-1)); + + AABB box = new AABB(waypointLocation); + ShapeRenderer.renderLineBox(stack, context.consumers().getBuffer(RenderQueue.NO_DEPTH_LAYER), box, 1, 1, 1, 1); + + stack.translate(waypointLocation.getCenter().add(new Vec3(0, 1, 0))); + stack.mulPose(context.camera().rotation()); + stack.scale(0.025f * distance, -0.025f * distance, 0.025f * distance); + + Font font = Minecraft.getInstance().font; + int width = font.width(waypointName) / 2; + int backgroundColour = (int) (Minecraft.getInstance().options.getBackgroundOpacity(0.25f) * 255.0f) << 24; + font.drawInBatch(waypointName, -width, 0, 0xFFFFFF, false, stack.last().pose(), context.consumers(), Font.DisplayMode.SEE_THROUGH, backgroundColour, LightTexture.FULL_SKY); + + stack.popPose(); + }); + } +} diff --git a/src/main/java/net/earthcomputer/clientcommands/render/RenderQueue.java b/src/main/java/net/earthcomputer/clientcommands/render/RenderQueue.java index 489b3acc..f03a3e3a 100644 --- a/src/main/java/net/earthcomputer/clientcommands/render/RenderQueue.java +++ b/src/main/java/net/earthcomputer/clientcommands/render/RenderQueue.java @@ -113,7 +113,7 @@ private record AddQueueEntry(Layer layer, Object key, Shape shape, int life) {} private record RemoveQueueEntry(Layer layer, Object key) {} - private static final RenderType NO_DEPTH_LAYER = RenderType.create("clientcommands_no_depth", DefaultVertexFormat.POSITION_COLOR_NORMAL, VertexFormat.Mode.LINES, 256, true, true, RenderType.CompositeState.builder() + public static final RenderType NO_DEPTH_LAYER = RenderType.create("clientcommands_no_depth", DefaultVertexFormat.POSITION_COLOR_NORMAL, VertexFormat.Mode.LINES, 256, true, true, RenderType.CompositeState.builder() .setShaderState(RenderType.RENDERTYPE_LINES_SHADER) .setWriteMaskState(RenderType.COLOR_WRITE) .setCullState(RenderType.NO_CULL) diff --git a/src/main/resources/assets/clientcommands/lang/en_us.json b/src/main/resources/assets/clientcommands/lang/en_us.json index a0f6884b..6e1d6af7 100644 --- a/src/main/resources/assets/clientcommands/lang/en_us.json +++ b/src/main/resources/assets/clientcommands/lang/en_us.json @@ -284,6 +284,15 @@ "commands.cvar.remove.success": "Successfully removed variable \"%s\"", "commands.cvar.saveFile.failed": "Could not save variables file", + "commands.cwaypoint.add.success": "Waypoint \"%s\" at %s in %s successfully added", + "commands.cwaypoint.alreadyExists": "A waypoint with the name \"%s\" already exists", + "commands.cwaypoint.edit.success": "Waypoint \"%s\" has successfully been changed to %s in %s", + "commands.cwaypoint.list": "- \"%s\" at %s in %s", + "commands.cwaypoint.list.empty": "No available waypoints", + "commands.cwaypoint.notFound": "No waypoint with the name \"%s\" could be found", + "commands.cwaypoint.remove.success": "Waypoint \"%s\" successfully removed", + "commands.cwaypoint.saveFailed": "Could not save waypoints file", + "commands.cwe.playerNotFound": "Player not found", "commands.cweather.reset": "Stopped overriding weather", diff --git a/src/main/resources/clientcommands.aw b/src/main/resources/clientcommands.aw index 7e78137d..1abc9abb 100644 --- a/src/main/resources/clientcommands.aw +++ b/src/main/resources/clientcommands.aw @@ -36,6 +36,10 @@ accessible field net/minecraft/network/codec/IdDispatchCodec toId Lit/unimi/dsi/ # cpermissionlevel accessible method net/minecraft/client/player/LocalPlayer getPermissionLevel ()I +# cwaypoint +accessible field net/minecraft/server/MinecraftServer storageSource Lnet/minecraft/world/level/storage/LevelStorageSource$LevelStorageAccess; +accessible method net/minecraft/client/renderer/GameRenderer getFov (Lnet/minecraft/client/Camera;FZ)F + # Game Options accessible field net/minecraft/client/OptionInstance value Ljava/lang/Object;