diff --git a/README.md b/README.md index 917347a..509af6c 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,10 @@ without having to install any local client mod. It is designed with performance and compatibility in mind, so even the most low-specs servers should be able to run it.

- Placing and removing image - Works with animated images too! - Sample Screenshot - Sample Screenshot + Placing and removing image + Works with animated images too! + Placing and removing image item + Optimized to work in low-spec servers

## Installation @@ -32,7 +32,7 @@ Here are the Minecraft distributions where Yamipa should be able to run: |------------------:|:-----------:|:------:|:-------:| | 1.16.x | ✅ | ✅ | ✅ | | 1.17 & 1.17.1 | ✅ | ✅ | ✅ | -| 1.18 | ✅ | ✅ | ✅ | +| 1.18 & 1.18.1 | ✅ | ✅ | ✅ | ## Configuration Yamipa is ready-to-go right out of the box. By default, it creates the following files and directories under the @@ -62,8 +62,9 @@ This plugin adds the following commands: - `/image clear []`: Remove all placed images in a radius of `r` blocks around an origin. - `/image describe`: Show detailed information about a placed image. - `/image download `: Download an image from a URL and place it in the images directory. +- `/image give [] []`: Give image items that can be placed later to a player. - `/image list []`: List all available files in the images directory. -- `/image place []`: Place an image of size `width`x`height` blocks. +- `/image place [] []`: Place an image of size `w`x`h` blocks. - `/image remove`: Remove a placed image from the world without deleting the image file. - `/image top`: List players with the most placed images. @@ -73,10 +74,16 @@ This plugin adds the following commands: `/image` - Download an image from a URL and save it with another name\ `/image download "https://www.example.com/a/b/c/1234.jpg" imagename.jpg` +- Give 10 image items to "TestPlayer" for the "test.jpg" image (3x5 blocks)\ + `/image give TestPlayer test.jpg 10 3 5` +- Give 10 image items to "TestPlayer" that will not drop an image item when removed\ + `/image give TestPlayer test.jpg 10 3 5 -DROP` - Start the dialog to place an image with a width of 3 blocks and auto height\ `/image place imagename.jpg 3` - Start the dialog to place a 3-blocks wide and 2-blocks high image\ `/image place imagename.jpg 3 2` +- Start the dialog to place an image that glows in the dark\ + `/image place imagename.jpg 3 2 +GLOW` - Start the dialog to remove a placed image while keeping the original file\ `/image remove` - Remove all placed images in a radius of 5 blocks around the spawn\ @@ -94,6 +101,7 @@ Yamipa defines the following permissions, each one corresponding to the command - `yamipa.clear` - `yamipa.describe` - `yamipa.download` +- `yamipa.give` - `yamipa.list` - `yamipa.place` - `yamipa.remove` @@ -104,6 +112,21 @@ such as [LuckPerms](https://luckperms.net/) or [GroupManager](https://elgarl.git Both these plugins have been tested to work with Yamipa, although any similar one should work just fine. +## Flags +Images from this plugin have a set of boolean attributes called "flags" that modify its behavior. Possible values are: + +- `ANIM` (animatable): Whether an image should be animated or not, useful when you don't want a GIF image to play. +- `REMO` (removable): Whether an image can be removed by any player by left-clicking it. +- `DROP` (droppable): Whether an image drops an image item when is removed by any player. +- `GLOW` (glowing): Whether an image glows in the dark (only works on Minecraft 1.17 and above). + +By default, images placed with the "/image place" command only have the `ANIM` flag. +Similarly, image items issued with the "/image give" command have `ANIM`, `REMO` and `DROP` flags. + +Default flags can be modified through the "" argument. +To add a flag to the default ones use "+{FLAG_NAME}" (e.g. `+GLOW`), and to remove it use "-{FLAG_NAME}" (e.g. `-ANIM`). +You can modify multiple flags separating them with commas (e.g. `+GLOW,-ANIM`). + ## How does it work? As you may have already guessed, Minecraft does not support the placing of image files. Yamipa bypasses this limitation by using two built-in features (**item frames and maps**) to render custom images. diff --git a/pom.xml b/pom.xml index 95463cd..2adeb67 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.josemmo.bukkit.plugin YamipaPlugin - 1.2.0 + 1.2.1 8 @@ -33,7 +33,7 @@ org.spigotmc spigot-api - 1.18-R0.1-SNAPSHOT + 1.18.1-R0.1-SNAPSHOT provided diff --git a/screenshots/demo-item.gif b/screenshots/demo-item.gif new file mode 100644 index 0000000..c39f638 Binary files /dev/null and b/screenshots/demo-item.gif differ diff --git a/screenshots/sample-1.jpg b/screenshots/sample-1.jpg deleted file mode 100644 index 36496de..0000000 Binary files a/screenshots/sample-1.jpg and /dev/null differ diff --git a/screenshots/sample-2.jpg b/screenshots/sample.jpg similarity index 100% rename from screenshots/sample-2.jpg rename to screenshots/sample.jpg diff --git a/src/main/java/io/josemmo/bukkit/plugin/YamipaPlugin.java b/src/main/java/io/josemmo/bukkit/plugin/YamipaPlugin.java index e88a848..2746aa7 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/YamipaPlugin.java +++ b/src/main/java/io/josemmo/bukkit/plugin/YamipaPlugin.java @@ -3,6 +3,7 @@ import io.josemmo.bukkit.plugin.commands.ImageCommandBridge; import io.josemmo.bukkit.plugin.renderer.FakeImage; import io.josemmo.bukkit.plugin.renderer.ImageRenderer; +import io.josemmo.bukkit.plugin.renderer.ItemService; import io.josemmo.bukkit.plugin.storage.ImageStorage; import org.bstats.bukkit.Metrics; import org.bstats.charts.SimplePie; @@ -23,6 +24,7 @@ public class YamipaPlugin extends JavaPlugin { private boolean verbose; private ImageStorage storage; private ImageRenderer renderer; + private ItemService itemService; private ScheduledExecutorService scheduler; /** @@ -110,6 +112,10 @@ public void onEnable() { renderer = new ImageRenderer(basePath.resolve(dataPath).toString()); renderer.start(); + // Create image item service + itemService = new ItemService(); + itemService.start(); + // Create thread pool scheduler = Executors.newScheduledThreadPool(6); @@ -133,8 +139,10 @@ public void onDisable() { // Stop plugin components storage.stop(); renderer.stop(); + itemService.stop(); storage = null; renderer = null; + itemService = null; // Stop internal scheduler scheduler.shutdownNow(); diff --git a/src/main/java/io/josemmo/bukkit/plugin/commands/ImageCommand.java b/src/main/java/io/josemmo/bukkit/plugin/commands/ImageCommand.java index 8c58329..99c0458 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/commands/ImageCommand.java +++ b/src/main/java/io/josemmo/bukkit/plugin/commands/ImageCommand.java @@ -3,12 +3,15 @@ import io.josemmo.bukkit.plugin.YamipaPlugin; import io.josemmo.bukkit.plugin.renderer.FakeImage; import io.josemmo.bukkit.plugin.renderer.ImageRenderer; +import io.josemmo.bukkit.plugin.renderer.ItemService; import io.josemmo.bukkit.plugin.storage.ImageFile; import io.josemmo.bukkit.plugin.utils.SelectBlockTask; import io.josemmo.bukkit.plugin.utils.ActionBar; import org.bukkit.*; +import org.bukkit.block.BlockFace; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; import org.bukkit.plugin.PluginDescriptionFile; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -21,10 +24,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.Map; -import java.util.Set; -import java.util.UUID; +import java.util.*; public class ImageCommand { public static final int ITEMS_PER_PAGE = 9; @@ -42,11 +42,14 @@ public static void showHelp(@NotNull CommandSender s, @NotNull String commandNam if (s.hasPermission("yamipa.download")) { s.sendMessage(ChatColor.AQUA + cmd + " download " + ChatColor.RESET + " - Download image"); } + if (s.hasPermission("yamipa.give")) { + s.sendMessage(ChatColor.AQUA + cmd + " give

<#> [] []" + ChatColor.RESET + " - Give image items"); + } if (s.hasPermission("yamipa.list")) { s.sendMessage(ChatColor.AQUA + cmd + " list []" + ChatColor.RESET + " - List all images"); } if (s.hasPermission("yamipa.place")) { - s.sendMessage(ChatColor.AQUA + cmd + " place []" + ChatColor.RESET + " - Place image"); + s.sendMessage(ChatColor.AQUA + cmd + " place [] []" + ChatColor.RESET + " - Place image"); } if (s.hasPermission("yamipa.remove")) { s.sendMessage(ChatColor.AQUA + cmd + " remove" + ChatColor.RESET + " - Remove a single placed image"); @@ -120,48 +123,58 @@ public static void placeImage( @NotNull Player player, @NotNull ImageFile image, int width, - int height + int height, + int flags ) { - YamipaPlugin plugin = YamipaPlugin.getInstance(); - // Get image size in blocks Dimension sizeInPixels = image.getSize(); if (sizeInPixels == null) { player.sendMessage(ChatColor.RED + "The requested file is not a valid image"); return; } - if (height == 0) { - float imageRatio = (float) sizeInPixels.height / sizeInPixels.width; - height = Math.round(width * imageRatio); - height = Math.min(height, FakeImage.MAX_DIMENSION); - } - final int finalHeight = height; + final int finalHeight = (height == 0) ? FakeImage.getProportionalHeight(sizeInPixels, width) : height; // Ask player where to place image SelectBlockTask task = new SelectBlockTask(player); task.onSuccess((location, face) -> { - FakeImage existingImage = plugin.getRenderer().getImage(location, face); - if (existingImage != null) { - ActionBar.send(player, ChatColor.RED + "There's already an image there!"); - return; - } - - // Create new fake image instance - Rotation rotation = FakeImage.getRotationFromPlayerEyesight(face, player.getEyeLocation()); - FakeImage fakeImage = new FakeImage(image.getName(), location, face, rotation, - width, finalHeight, new Date(), player); - - // Show loading status to player - ActionBar loadingActionBar = ActionBar.repeat(player, ChatColor.AQUA + "Loading image..."); - fakeImage.setOnLoadedListener(loadingActionBar::clear); - - // Add fake image to renderer - plugin.getRenderer().addImage(fakeImage); + placeImage(player, image, width, finalHeight, flags, location, face); }); task.onFailure(() -> ActionBar.send(player, ChatColor.RED + "Image placing canceled")); task.run("Right click a block to continue"); } + public static boolean placeImage( + @NotNull Player player, + @NotNull ImageFile image, + int width, + int height, + int flags, + @NotNull Location location, + @NotNull BlockFace face + ) { + YamipaPlugin plugin = YamipaPlugin.getInstance(); + + // Prevent two images occupying the same space + FakeImage existingImage = plugin.getRenderer().getImage(location, face); + if (existingImage != null) { + ActionBar.send(player, ChatColor.RED + "There's already an image there!"); + return false; + } + + // Create new fake image instance + Rotation rotation = FakeImage.getRotationFromPlayerEyesight(face, player.getEyeLocation()); + FakeImage fakeImage = new FakeImage(image.getName(), location, face, rotation, + width, height, new Date(), player, flags); + + // Show loading status to player + ActionBar loadingActionBar = ActionBar.repeat(player, ChatColor.AQUA + "Loading image..."); + fakeImage.setOnLoadedListener(loadingActionBar::clear); + + // Add fake image to renderer + plugin.getRenderer().addImage(fakeImage); + return true; + } + public static void removeImage(@NotNull Player player) { ImageRenderer renderer = YamipaPlugin.getInstance().getRenderer(); @@ -221,19 +234,10 @@ public static void describeImage(@NotNull Player player) { return; } - // Send placed image information to player - String dateStr = (image.getPlacedAt() == null) ? - ChatColor.GRAY + "Some point in time" : - new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z").format(image.getPlacedAt()); - String playerStr; - if (image.getPlacedBy().getUniqueId().equals(FakeImage.UNKNOWN_PLAYER_ID)) { - playerStr = ChatColor.GRAY + "Someone"; - } else if (image.getPlacedBy().getName() == null) { - playerStr = ChatColor.DARK_AQUA + image.getPlacedBy().getUniqueId().toString(); - } else { - playerStr = image.getPlacedBy().getName(); - } + // Separate previous messages player.sendMessage(""); + + // Basic information player.sendMessage(ChatColor.GOLD + "Filename: " + ChatColor.RESET + image.getFilename()); player.sendMessage(ChatColor.GOLD + "World: " + ChatColor.RESET + image.getLocation().getChunk().getWorld().getName()); @@ -245,11 +249,47 @@ public static void describeImage(@NotNull Player player) { player.sendMessage(ChatColor.GOLD + "Rotation: " + ChatColor.RESET + image.getRotation()); player.sendMessage(ChatColor.GOLD + "Dimensions: " + ChatColor.RESET + image.getWidth() + "x" + image.getHeight() + " blocks"); + + // Speed int delay = image.getDelay() * 50; - String delayStr = (delay > 0) ? delay + " ms per step" : ChatColor.GRAY + "Not animatable"; + String delayStr = (delay > 0) ? delay + " ms per step" : ChatColor.GRAY + "N/A"; player.sendMessage(ChatColor.GOLD + "Speed: " + ChatColor.RESET + delayStr); + + // Placed At + String dateStr = (image.getPlacedAt() == null) ? + ChatColor.GRAY + "Some point in time" : + new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z").format(image.getPlacedAt()); player.sendMessage(ChatColor.GOLD + "Placed At: " + ChatColor.RESET + dateStr); + + // Placed By + String playerStr; + if (image.getPlacedBy().getUniqueId().equals(FakeImage.UNKNOWN_PLAYER_ID)) { + playerStr = ChatColor.GRAY + "Someone"; + } else if (image.getPlacedBy().getName() == null) { + playerStr = ChatColor.DARK_AQUA + image.getPlacedBy().getUniqueId().toString(); + } else { + playerStr = image.getPlacedBy().getName(); + } player.sendMessage(ChatColor.GOLD + "Placed By: " + ChatColor.RESET + playerStr); + + // Flags + String flagsStr = ""; + if (image.hasFlag(FakeImage.FLAG_ANIMATABLE)) { + flagsStr += ChatColor.AQUA + "ANIM "; + } + if (image.hasFlag(FakeImage.FLAG_REMOVABLE)) { + flagsStr += ChatColor.RED + "REMO "; + } + if (image.hasFlag(FakeImage.FLAG_DROPPABLE)) { + flagsStr += ChatColor.LIGHT_PURPLE + "DROP "; + } + if (image.hasFlag(FakeImage.FLAG_GLOWING)) { + flagsStr += ChatColor.GREEN + "GLOW "; + } + if (flagsStr.isEmpty()) { + flagsStr = ChatColor.GRAY + "N/A"; + } + player.sendMessage(ChatColor.GOLD + "Flags: " + ChatColor.RESET + flagsStr); }); task.onFailure(() -> ActionBar.send(player, ChatColor.RED + "Image describing canceled")); task.run("Right click the image to describe"); @@ -297,4 +337,35 @@ public static void showTopPlayers(@NotNull CommandSender sender) { ++printedLines; } } + + public static void giveImageItems( + @NotNull CommandSender sender, + @NotNull Player player, + @NotNull ImageFile image, + int amount, + int width, + int height, + int flags + ) { + // Get image size in blocks + Dimension sizeInPixels = image.getSize(); + if (sizeInPixels == null) { + sender.sendMessage(ChatColor.RED + "The requested file is not a valid image"); + return; + } + if (height == 0) { + height = FakeImage.getProportionalHeight(sizeInPixels, width); + } + + // Create item stack + ItemStack itemStack = ItemService.getImageItem(image, amount, width, height, flags); + + // Add item stack to player's inventory + player.getInventory().addItem(itemStack); + sender.sendMessage( + ChatColor.ITALIC + "Added " + amount + " " + + (amount == 1 ? "image item" : "image items") + + " to " + player.getName() + "'s inventory" + ); + } } diff --git a/src/main/java/io/josemmo/bukkit/plugin/commands/ImageCommandBridge.java b/src/main/java/io/josemmo/bukkit/plugin/commands/ImageCommandBridge.java index bdc674b..c139bb7 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/commands/ImageCommandBridge.java +++ b/src/main/java/io/josemmo/bukkit/plugin/commands/ImageCommandBridge.java @@ -63,6 +63,7 @@ public static void register(@NotNull YamipaPlugin plugin) { sender.hasPermission("yamipa.clear") || sender.hasPermission("yamipa.describe") || sender.hasPermission("yamipa.download") || + sender.hasPermission("yamipa.give") || sender.hasPermission("yamipa.list") || sender.hasPermission("yamipa.place") || sender.hasPermission("yamipa.remove") || @@ -110,6 +111,41 @@ public static void register(@NotNull YamipaPlugin plugin) { ImageCommand.downloadImage(sender, (String) args[1], (String) args[2]); }); + // Give subcommand + root.addSubcommand("give") + .withPermission("yamipa.give") + .withArgument(new OnlinePlayerArgument("player")) + .withArgument(new ImageFileArgument("filename")) + .withArgument(new IntegerArgument("amount", 1, 64)) + .withArgument(new IntegerArgument("width", 1, FakeImage.MAX_DIMENSION)) + .withArgument(new IntegerArgument("height", 1, FakeImage.MAX_DIMENSION)) + .withArgument(new ImageFlagsArgument("flags", FakeImage.DEFAULT_GIVE_FLAGS)) + .executes((sender, args) -> { + ImageCommand.giveImageItems(sender, (Player) args[1], (ImageFile) args[2], (int) args[3], + (int) args[4], (int) args[5], (int) args[6]); + }); + root.addSubcommand("give") + .withPermission("yamipa.give") + .withArgument(new OnlinePlayerArgument("player")) + .withArgument(new ImageFileArgument("filename")) + .withArgument(new IntegerArgument("amount", 1, 64)) + .withArgument(new IntegerArgument("width", 1, FakeImage.MAX_DIMENSION)) + .withArgument(new IntegerArgument("height", 1, FakeImage.MAX_DIMENSION)) + .executes((sender, args) -> { + ImageCommand.giveImageItems(sender, (Player) args[1], (ImageFile) args[2], (int) args[3], + (int) args[4], (int) args[5], FakeImage.DEFAULT_GIVE_FLAGS); + }); + root.addSubcommand("give") + .withPermission("yamipa.give") + .withArgument(new OnlinePlayerArgument("player")) + .withArgument(new ImageFileArgument("filename")) + .withArgument(new IntegerArgument("amount", 1, 64)) + .withArgument(new IntegerArgument("width", 1, FakeImage.MAX_DIMENSION)) + .executes((sender, args) -> { + ImageCommand.giveImageItems(sender, (Player) args[1], (ImageFile) args[2], (int) args[3], + (int) args[4], 0, FakeImage.DEFAULT_GIVE_FLAGS); + }); + // List subcommand root.addSubcommand("list") .withPermission("yamipa.list") @@ -125,20 +161,31 @@ public static void register(@NotNull YamipaPlugin plugin) { }); // Place subcommand + root.addSubcommand("place") + .withPermission("yamipa.place") + .withArgument(new ImageFileArgument("filename")) + .withArgument(new IntegerArgument("width", 1, FakeImage.MAX_DIMENSION)) + .withArgument(new IntegerArgument("height", 1, FakeImage.MAX_DIMENSION)) + .withArgument(new ImageFlagsArgument("flags", FakeImage.DEFAULT_PLACE_FLAGS)) + .executesPlayer((player, args) -> { + ImageCommand.placeImage(player, (ImageFile) args[1], (int) args[2], (int) args[3], (int) args[4]); + }); root.addSubcommand("place") .withPermission("yamipa.place") .withArgument(new ImageFileArgument("filename")) .withArgument(new IntegerArgument("width", 1, FakeImage.MAX_DIMENSION)) .withArgument(new IntegerArgument("height", 1, FakeImage.MAX_DIMENSION)) .executesPlayer((player, args) -> { - ImageCommand.placeImage(player, (ImageFile) args[1], (int) args[2], (int) args[3]); + ImageCommand.placeImage(player, (ImageFile) args[1], (int) args[2], (int) args[3], + FakeImage.DEFAULT_PLACE_FLAGS); }); root.addSubcommand("place") .withPermission("yamipa.place") .withArgument(new ImageFileArgument("filename")) .withArgument(new IntegerArgument("width", 1, FakeImage.MAX_DIMENSION)) .executesPlayer((player, args) -> { - ImageCommand.placeImage(player, (ImageFile) args[1], (int) args[2], 0); + ImageCommand.placeImage(player, (ImageFile) args[1], (int) args[2], 0, + FakeImage.DEFAULT_PLACE_FLAGS); }); // Remove subcommand diff --git a/src/main/java/io/josemmo/bukkit/plugin/commands/arguments/ImageFlagsArgument.java b/src/main/java/io/josemmo/bukkit/plugin/commands/arguments/ImageFlagsArgument.java new file mode 100644 index 0000000..cfe4576 --- /dev/null +++ b/src/main/java/io/josemmo/bukkit/plugin/commands/arguments/ImageFlagsArgument.java @@ -0,0 +1,99 @@ +package io.josemmo.bukkit.plugin.commands.arguments; + +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.builder.RequiredArgumentBuilder; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.suggestion.Suggestions; +import com.mojang.brigadier.suggestion.SuggestionsBuilder; +import io.josemmo.bukkit.plugin.renderer.FakeImage; +import org.bukkit.command.CommandSender; +import org.jetbrains.annotations.NotNull; +import java.util.Arrays; +import java.util.Collections; +import java.util.concurrent.CompletableFuture; + +public class ImageFlagsArgument extends Argument { + private final int defaultFlags; + + /** + * Image File Argument constructor + * @param name Argument name + * @param defaultFlags Default flags + */ + public ImageFlagsArgument(@NotNull String name, int defaultFlags) { + super(name); + this.defaultFlags = defaultFlags; + } + + @Override + public @NotNull RequiredArgumentBuilder build() { + return RequiredArgumentBuilder.argument(name, StringArgumentType.greedyString()).suggests(this::getSuggestions); + } + + private @NotNull CompletableFuture getSuggestions( + @NotNull CommandContext ctx, + @NotNull SuggestionsBuilder builder + ) { + String input = builder.getRemaining().replaceAll("[^A-Z+\\-,]", ""); + int lastIndex = Collections.max( + Arrays.asList(input.lastIndexOf(","), input.lastIndexOf("+"), input.lastIndexOf("-")) + ); + input = input.substring(0, lastIndex+1); + + // Add suggestions + String[] values = new String[] {"ANIM", "REMO", "DROP", "GLOW"}; + for (String value : values) { + builder.suggest(input + value); + } + + return builder.buildFuture(); + } + + @Override + public @NotNull Object parse(@NotNull CommandSender sender, @NotNull Object rawValue) throws CommandSyntaxException { + String value = (String) rawValue; + int parsedValue = defaultFlags; + + // Load flags from parts + String[] parts = value.split(","); + for (String part : parts) { + // Parse modifier + String modifier = ""; + if (part.startsWith("+") || part.startsWith("-")) { + modifier = part.substring(0, 1); + part = part.substring(1); + } + + // Parse flag name + int flag; + switch (part) { + case "ANIM": + flag = FakeImage.FLAG_ANIMATABLE; + break; + case "REMO": + flag = FakeImage.FLAG_REMOVABLE; + break; + case "DROP": + flag = FakeImage.FLAG_DROPPABLE; + break; + case "GLOW": + flag = FakeImage.FLAG_GLOWING; + break; + default: + throw newException("Unrecognized flag \"" + part + "\""); + } + + // Apply flag to value + if (modifier.equals("+")) { + parsedValue |= flag; + } else if (modifier.equals("-")) { + parsedValue &= ~flag; + } else { + parsedValue = flag; + } + } + + return parsedValue; + } +} diff --git a/src/main/java/io/josemmo/bukkit/plugin/commands/arguments/OnlinePlayerArgument.java b/src/main/java/io/josemmo/bukkit/plugin/commands/arguments/OnlinePlayerArgument.java new file mode 100644 index 0000000..51350f2 --- /dev/null +++ b/src/main/java/io/josemmo/bukkit/plugin/commands/arguments/OnlinePlayerArgument.java @@ -0,0 +1,55 @@ +package io.josemmo.bukkit.plugin.commands.arguments; + +import com.mojang.brigadier.builder.RequiredArgumentBuilder; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.suggestion.Suggestions; +import com.mojang.brigadier.suggestion.SuggestionsBuilder; +import org.bukkit.Bukkit; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +public class OnlinePlayerArgument extends StringArgument { + /** + * Online player argument constructor + * @param name Argument name + */ + public OnlinePlayerArgument(@NotNull String name) { + super(name); + } + + @Override + public @NotNull RequiredArgumentBuilder build() { + return super.build().suggests(this::getSuggestions); + } + + @Override + public @NotNull Object parse(@NotNull CommandSender sender, @NotNull Object rawValue) throws CommandSyntaxException { + Player player = getAllowedValues().get((String) rawValue); + if (player == null) { + throw newException("Expected online player (name or UUID)"); + } + return player; + } + + private @NotNull CompletableFuture getSuggestions( + @NotNull CommandContext ctx, + @NotNull SuggestionsBuilder builder + ) { + getAllowedValues().keySet().forEach(builder::suggest); + return builder.buildFuture(); + } + + private @NotNull Map getAllowedValues() { + Map values = new HashMap<>(); + for (Player player : Bukkit.getOnlinePlayers()) { + values.put(player.getUniqueId().toString(), player); + values.put(player.getName(), player); + } + return values; + } +} diff --git a/src/main/java/io/josemmo/bukkit/plugin/renderer/FakeImage.java b/src/main/java/io/josemmo/bukkit/plugin/renderer/FakeImage.java index 91510d1..9750bf2 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/renderer/FakeImage.java +++ b/src/main/java/io/josemmo/bukkit/plugin/renderer/FakeImage.java @@ -10,6 +10,7 @@ import org.bukkit.util.Vector; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.awt.*; import java.util.*; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; @@ -21,6 +22,16 @@ public class FakeImage extends FakeEntity { public static final int MIN_DELAY = 1; // Minimum step delay in 50ms intervals (50ms / 50ms) public static final int MAX_DELAY = 50; // Maximum step delay in 50ms intervals (5000ms / 50ms) public static final UUID UNKNOWN_PLAYER_ID = new UUID(0, 0); + + // Flags + public static final int FLAG_ANIMATABLE = 1; // Whether image is allowed to animate multiple steps + public static final int FLAG_REMOVABLE = 2; // Whether image can be removed by a player using the interact button + public static final int FLAG_DROPPABLE = 4; // Whether image will drop an image item when removed by a player + public static final int FLAG_GLOWING = 8; // Whether image glows in the dark + public static final int DEFAULT_PLACE_FLAGS = FLAG_ANIMATABLE; + public static final int DEFAULT_GIVE_FLAGS = FLAG_ANIMATABLE | FLAG_REMOVABLE | FLAG_DROPPABLE; + + // Instance properties private static boolean animateImages = false; private final String filename; private final Location location; @@ -30,6 +41,7 @@ public class FakeImage extends FakeEntity { private final int height; private final Date placedAt; private final OfflinePlayer placedBy; + private final int flags; private final BiFunction getLocationVector; private Runnable onLoadedListener = null; @@ -44,7 +56,7 @@ public class FakeImage extends FakeEntity { private int currentStep = -1; // Current animation step /** - * Enable image animation + * Enable plugin-wide image animation support */ public static void enableAnimation() { animateImages = true; @@ -84,16 +96,30 @@ public static boolean isAnimationEnabled() { } } + /** + * Get proportional height + * @param sizeInPixels Image file dimension in pixels + * @param width Desired width in blocks + * @return Height in blocks (capped at FakeImage.MAX_DIMENSION) + */ + public static int getProportionalHeight(@NotNull Dimension sizeInPixels, int width) { + float imageRatio = (float) sizeInPixels.height / sizeInPixels.width; + int height = Math.round(width * imageRatio); + height = Math.min(height, MAX_DIMENSION); + return height; + } + /** * Class constructor - * @param filename Image filename - * @param location Top-left corner where image will be placed - * @param face Block face - * @param rotation Image rotation - * @param width Width in blocks - * @param height Height in blocks - * @param placedAt Placed at - * @param placedBy Placed by + * @param filename Image filename + * @param location Top-left corner where image will be placed + * @param face Block face + * @param rotation Image rotation + * @param width Width in blocks + * @param height Height in blocks + * @param placedAt Placed at + * @param placedBy Placed by + * @param flags Flags */ public FakeImage( @NotNull String filename, @@ -103,7 +129,8 @@ public FakeImage( int width, int height, @Nullable Date placedAt, - @NotNull OfflinePlayer placedBy + @NotNull OfflinePlayer placedBy, + int flags ) { this.filename = filename; this.location = location; @@ -113,6 +140,7 @@ public FakeImage( this.height = height; this.placedAt = placedAt; this.placedBy = placedBy; + this.flags = flags; // Define function for retrieving item frame positional vector from pair if (face == BlockFace.SOUTH) { @@ -220,6 +248,23 @@ public int getHeight() { return placedBy; } + /** + * Get flags + * @return Flags + */ + public int getFlags() { + return flags; + } + + /** + * Has flag + * @param flag Flag to check + * @return Whether instance has given flag or not + */ + public boolean hasFlag(int flag) { + return ((flags & flag) == flag); + } + /** * Get image delay * @return Image delay in 50ms intervals @@ -304,10 +349,11 @@ private void load() { // Generate frames frames = new FakeItemFrame[width*height]; + boolean glowing = hasFlag(FLAG_GLOWING); for (int col=0; col 1) { + if (animateImages && hasFlag(FLAG_ANIMATABLE) && numOfSteps > 1) { animatingPlayers.add(player); if (task == null) { task = plugin.getScheduler().scheduleAtFixedRate( diff --git a/src/main/java/io/josemmo/bukkit/plugin/renderer/FakeItemFrame.java b/src/main/java/io/josemmo/bukkit/plugin/renderer/FakeItemFrame.java index 611d5d4..dc92415 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/renderer/FakeItemFrame.java +++ b/src/main/java/io/josemmo/bukkit/plugin/renderer/FakeItemFrame.java @@ -6,6 +6,7 @@ import io.josemmo.bukkit.plugin.packets.DestroyEntityPacket; import io.josemmo.bukkit.plugin.packets.EntityMetadataPacket; import io.josemmo.bukkit.plugin.packets.SpawnEntityPacket; +import io.josemmo.bukkit.plugin.utils.Internals; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.Rotation; @@ -19,11 +20,13 @@ public class FakeItemFrame extends FakeEntity { public static final int MIN_FRAME_ID = Integer.MAX_VALUE / 4; public static final int MAX_FRAME_ID = Integer.MAX_VALUE; + private static final boolean SUPPORTS_GLOWING = Internals.MINECRAFT_VERSION >= 17; private static final AtomicInteger lastFrameId = new AtomicInteger(MAX_FRAME_ID); private final int id; private final Location location; private final BlockFace face; private final Rotation rotation; + private final boolean glowing; private final FakeMap[] maps; /** @@ -44,13 +47,21 @@ private static int getNextId() { * @param location Frame location * @param face Block face * @param rotation Frame rotation + * @param glowing Whether is glowing or regular frame * @param maps Fake maps to animate */ - public FakeItemFrame(@NotNull Location location, @NotNull BlockFace face, @NotNull Rotation rotation, @NotNull FakeMap[] maps) { + public FakeItemFrame( + @NotNull Location location, + @NotNull BlockFace face, + @NotNull Rotation rotation, + boolean glowing, + @NotNull FakeMap[] maps + ) { this.id = getNextId(); this.location = location; this.face = face; this.rotation = rotation; + this.glowing = glowing; this.maps = maps; plugin.fine("Created FakeItemFrame#" + this.id + " using " + this.maps.length + " FakeMap(s)"); } @@ -100,11 +111,12 @@ public void spawn(@NotNull Player player) { // Create item frame entity SpawnEntityPacket framePacket = new SpawnEntityPacket(); framePacket.setId(id) - .setEntityType(EntityType.ITEM_FRAME) + .setEntityType((glowing && SUPPORTS_GLOWING) ? EntityType.GLOW_ITEM_FRAME : EntityType.ITEM_FRAME) .setPosition(x, y, z) .setRotation(pitch, yaw) .setData(orientation); tryToSendPacket(player, framePacket); + plugin.fine("Spawned FakeItemFrame#" + this.id + " for Player#" + player.getName()); // Send pixels for all linked maps for (FakeMap map : maps) { @@ -144,5 +156,6 @@ public void destroy(@NotNull Player player) { DestroyEntityPacket destroyPacket = new DestroyEntityPacket(); destroyPacket.setId(id); tryToSendPacket(player, destroyPacket); + plugin.fine("Destroyed FakeItemFrame#" + this.id + " for Player#" + player.getName()); } } diff --git a/src/main/java/io/josemmo/bukkit/plugin/renderer/ImageRenderer.java b/src/main/java/io/josemmo/bukkit/plugin/renderer/ImageRenderer.java index d7c6764..7e8329f 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/renderer/ImageRenderer.java +++ b/src/main/java/io/josemmo/bukkit/plugin/renderer/ImageRenderer.java @@ -106,7 +106,12 @@ private void loadConfig() { UUID.fromString(row[10]) : FakeImage.UNKNOWN_PLAYER_ID; OfflinePlayer placedBy = Bukkit.getOfflinePlayer(placedById); - addImage(new FakeImage(filename, location, face, rotation, width, height, placedAt, placedBy), true); + int flags = (row.length > 11) ? + Math.max(Integer.parseInt(row[11]), 0) : + FakeImage.DEFAULT_PLACE_FLAGS; + FakeImage fakeImage = new FakeImage(filename, location, face, rotation, width, height, + placedAt, placedBy, flags); + addImage(fakeImage, true); } catch (Exception e) { plugin.log(Level.SEVERE, "Invalid fake image properties: " + String.join(";", row), e); } @@ -144,7 +149,8 @@ private void saveConfig() { fakeImage.getWidth() + "", fakeImage.getHeight() + "", (fakeImage.getPlacedAt() == null) ? "" : (fakeImage.getPlacedAt().getTime() / 1000) + "", - placedById.equals(FakeImage.UNKNOWN_PLAYER_ID) ? "" : placedById.toString() + placedById.equals(FakeImage.UNKNOWN_PLAYER_ID) ? "" : placedById.toString(), + fakeImage.getFlags() + "" }; config.addRow(row); } @@ -164,7 +170,10 @@ private void saveConfig() { * @param isInit TRUE if called during renderer startup, FALSE otherwise */ public void addImage(@NotNull FakeImage image, boolean isInit) { - for (WorldAreaId worldAreaId : image.getWorldAreaIds()) { + WorldAreaId[] imageWorldAreaIds = image.getWorldAreaIds(); + + // Add image to world area(s) + for (WorldAreaId worldAreaId : imageWorldAreaIds) { WorldArea worldArea = worldAreas.computeIfAbsent(worldAreaId, __ -> { plugin.fine("Created WorldArea#(" + worldAreaId + ")"); return new WorldArea(worldAreaId); @@ -180,6 +189,11 @@ public void addImage(@NotNull FakeImage image, boolean isInit) { // Increment count of placed images by player UUID placedById = image.getPlacedBy().getUniqueId(); imagesCountByPlayer.compute(placedById, (__, prev) -> (prev == null) ? 1 : prev+1); + + // Spawn image in players nearby + for (Player player : getPlayersInNeighborhood(imageWorldAreaIds)) { + image.spawn(player); + } } /** @@ -242,7 +256,16 @@ public void addImage(@NotNull FakeImage image) { * @param image Fake image instance */ public void removeImage(@NotNull FakeImage image) { - for (WorldAreaId worldAreaId : image.getWorldAreaIds()) { + WorldAreaId[] imageWorldAreaIds = image.getWorldAreaIds(); + + // Destroy image from players nearby + for (Player player : getPlayersInNeighborhood(imageWorldAreaIds)) { + image.destroy(player); + } + image.invalidate(); + + // Remove image from world area(s) + for (WorldAreaId worldAreaId : imageWorldAreaIds) { WorldArea worldArea = worldAreas.get(worldAreaId); worldArea.removeImage(image); if (!worldArea.hasImages()) { @@ -250,7 +273,6 @@ public void removeImage(@NotNull FakeImage image) { worldAreas.remove(worldAreaId); } } - image.invalidate(); // Set configuration changed flag hasConfigChanged.set(true); @@ -308,6 +330,28 @@ public int size() { return instances; } + /** + * Get players in neighborhood + * @param ids World area IDs to compute neighborhood from + * @return Players inside those world areas + */ + private @NotNull Set getPlayersInNeighborhood(@NotNull WorldAreaId[] ids) { + Set neighborhood = new HashSet<>(); + for (WorldAreaId worldAreaId : ids) { + Collections.addAll(neighborhood, worldAreaId.getNeighborhood()); + } + + Set players = new HashSet<>(); + for (Map.Entry entry : playersLocation.entrySet()) { + if (neighborhood.contains(entry.getValue())) { + Player player = Bukkit.getPlayer(entry.getKey()); + players.add(player); + } + } + + return players; + } + /** * On player location change * @param player Player instance diff --git a/src/main/java/io/josemmo/bukkit/plugin/renderer/ItemService.java b/src/main/java/io/josemmo/bukkit/plugin/renderer/ItemService.java new file mode 100644 index 0000000..5cc706b --- /dev/null +++ b/src/main/java/io/josemmo/bukkit/plugin/renderer/ItemService.java @@ -0,0 +1,174 @@ +package io.josemmo.bukkit.plugin.renderer; + +import io.josemmo.bukkit.plugin.YamipaPlugin; +import io.josemmo.bukkit.plugin.commands.ImageCommand; +import io.josemmo.bukkit.plugin.storage.ImageFile; +import io.josemmo.bukkit.plugin.utils.InteractWithEntityListener; +import org.bukkit.*; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; +import org.bukkit.event.hanging.HangingPlaceEvent; +import org.bukkit.event.inventory.PrepareItemCraftEvent; +import org.bukkit.inventory.CraftingInventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import java.util.Collections; +import java.util.Objects; + +public class ItemService extends InteractWithEntityListener implements Listener { + private static final YamipaPlugin plugin = YamipaPlugin.getInstance(); + private static final NamespacedKey NSK_FILENAME = new NamespacedKey(plugin, "filename"); + private static final NamespacedKey NSK_WIDTH = new NamespacedKey(plugin, "width"); + private static final NamespacedKey NSK_HEIGHT = new NamespacedKey(plugin, "height"); + private static final NamespacedKey NSK_FLAGS = new NamespacedKey(plugin, "flags"); + + /** + * Get image item + * @param image Image file + * @param amount Stack amount + * @param width Image width in blocks + * @param height Image height in blocks + * @param flags Image flags + * @return Image item + */ + public static @NotNull ItemStack getImageItem(@NotNull ImageFile image, int amount, int width, int height, int flags) { + ItemStack itemStack = new ItemStack(Material.ITEM_FRAME, amount); + ItemMeta itemMeta = Objects.requireNonNull(itemStack.getItemMeta()); + + // Set metadata + PersistentDataContainer itemData = itemMeta.getPersistentDataContainer(); + itemMeta.setDisplayName(image.getName() + ChatColor.AQUA + " (" + width + "x" + height + ")"); + itemMeta.setLore(Collections.singletonList("Yamipa image")); + itemData.set(NSK_FILENAME, PersistentDataType.STRING, image.getName()); + itemData.set(NSK_WIDTH, PersistentDataType.INTEGER, width); + itemData.set(NSK_HEIGHT, PersistentDataType.INTEGER, height); + itemData.set(NSK_FLAGS, PersistentDataType.INTEGER, flags); + itemStack.setItemMeta(itemMeta); + + return itemStack; + } + + /** + * Start service + */ + public void start() { + register(); + plugin.getServer().getPluginManager().registerEvents(this, plugin); + } + + /** + * Stop service + */ + public void stop() { + unregister(); + HandlerList.unregisterAll(this); + } + + @EventHandler(priority = EventPriority.LOWEST) + public void onCraftItem(@NotNull PrepareItemCraftEvent event) { + CraftingInventory inventory = event.getInventory(); + for (@Nullable ItemStack item : inventory.getMatrix()) { + if (item == null) continue; + + // Get metadata from item + ItemMeta itemMeta = item.getItemMeta(); + if (itemMeta == null) continue; + + // Prevent crafting recipes with image items + if (itemMeta.getPersistentDataContainer().has(NSK_FILENAME, PersistentDataType.STRING)) { + inventory.setResult(new ItemStack(Material.AIR)); + break; + } + } + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOWEST) + public void onPlaceItem(@NotNull HangingPlaceEvent event) { + Player player = event.getPlayer(); + ItemStack item = event.getItemStack(); + if (player == null || item == null) return; + + // Get metadata from item + ItemMeta itemMeta = item.getItemMeta(); + if (itemMeta == null) return; + PersistentDataContainer itemData = itemMeta.getPersistentDataContainer(); + String filename = itemData.get(NSK_FILENAME, PersistentDataType.STRING); + if (filename == null) return; + Integer width = itemData.get(NSK_WIDTH, PersistentDataType.INTEGER); + Integer height = itemData.get(NSK_HEIGHT, PersistentDataType.INTEGER); + Integer flags = itemData.get(NSK_FLAGS, PersistentDataType.INTEGER); + if (width == null || height == null || flags == null) { + plugin.warning(player + " tried to place corrupted image item (missing width/height/flags properties)"); + return; + } + + // Validate filename + ImageFile image = YamipaPlugin.getInstance().getStorage().get(filename); + if (image == null) { + plugin.warning(player + " tried to place corrupted image item (\"" + filename + "\" no longer exists)"); + player.sendMessage(ChatColor.RED + "Image file \"" + filename + "\" no longer exists"); + return; + } + + // Prevent item frame placing + event.setCancelled(true); + + // Try to place image in world + Location location = event.getBlock().getLocation(); + boolean success = ImageCommand.placeImage(player, image, width, height, flags, location, event.getBlockFace()); + if (!success) return; + + // Decrement item from player's inventory + if (player.getGameMode() == GameMode.CREATIVE) return; + PlayerInventory inventory = player.getInventory(); + int itemIndex = inventory.first(item); + int amount = item.getAmount(); + if (amount > 1) { + item.setAmount(amount - 1); + inventory.setItem(itemIndex, item); + } else { + inventory.setItem(itemIndex, new ItemStack(Material.AIR)); + } + } + + @Override + public boolean onAttack(@NotNull Player player, @NotNull Block block, @NotNull BlockFace face) { + ImageRenderer renderer = plugin.getRenderer(); + Location location = block.getLocation(); + + // Has the player clicked a removable placed image? + FakeImage image = renderer.getImage(location, face); + if (image == null || !image.hasFlag(FakeImage.FLAG_REMOVABLE)) return true; + + // Remove image from renderer + renderer.removeImage(image); + + // Drop image item + if (player.getGameMode() == GameMode.SURVIVAL && image.hasFlag(FakeImage.FLAG_DROPPABLE)) { + ImageFile imageFile = Objects.requireNonNull(image.getFile()); + ItemStack imageItem = getImageItem(imageFile, 1, image.getWidth(), image.getHeight(), image.getFlags()); + Location dropLocation = location.clone().add(0.5, -0.5, 0.5).add(face.getDirection()); + Bukkit.getScheduler().scheduleSyncDelayedTask(plugin, () -> { + block.getWorld().dropItem(dropLocation, imageItem); + }); + } + + return false; + } + + @Override + public boolean onInteract(@NotNull Player player, @NotNull Block block, @NotNull BlockFace face) { + // Intentionally left blank + return true; + } +} diff --git a/src/main/java/io/josemmo/bukkit/plugin/renderer/WorldArea.java b/src/main/java/io/josemmo/bukkit/plugin/renderer/WorldArea.java index fcaf561..d68288f 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/renderer/WorldArea.java +++ b/src/main/java/io/josemmo/bukkit/plugin/renderer/WorldArea.java @@ -61,9 +61,6 @@ public boolean hasImages() { */ public void addImage(@NotNull FakeImage image) { fakeImages.add(image); - for (Player player : players) { - image.spawn(player); - } } /** @@ -72,9 +69,6 @@ public void addImage(@NotNull FakeImage image) { */ public void removeImage(@NotNull FakeImage image) { fakeImages.remove(image); - for (Player player : players) { - image.destroy(player); - } } /** diff --git a/src/main/java/io/josemmo/bukkit/plugin/utils/InteractWithEntityListener.java b/src/main/java/io/josemmo/bukkit/plugin/utils/InteractWithEntityListener.java new file mode 100644 index 0000000..8a288cc --- /dev/null +++ b/src/main/java/io/josemmo/bukkit/plugin/utils/InteractWithEntityListener.java @@ -0,0 +1,120 @@ +package io.josemmo.bukkit.plugin.utils; + +import com.comphenix.protocol.PacketType; +import com.comphenix.protocol.ProtocolLibrary; +import com.comphenix.protocol.events.ListenerPriority; +import com.comphenix.protocol.events.ListeningWhitelist; +import com.comphenix.protocol.events.PacketEvent; +import com.comphenix.protocol.events.PacketListener; +import com.comphenix.protocol.wrappers.EnumWrappers; +import io.josemmo.bukkit.plugin.YamipaPlugin; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import org.jetbrains.annotations.NotNull; +import java.util.List; + +public abstract class InteractWithEntityListener implements PacketListener { + public static final int MAX_BLOCK_DISTANCE = 5; // Server should only accept entities within a 4-block radius + + /** + * On player attack listener + * @param player Initiating player + * @param block Target block + * @param face Target block face + * @return Whether to allow original event (true) or not (false) + */ + public abstract boolean onAttack(@NotNull Player player, @NotNull Block block, @NotNull BlockFace face); + + /** + * On player interact listener + * @param player Initiating player + * @param block Target block + * @param face Target block face + * @return Whether to allow original event (true) or not (false) + */ + public abstract boolean onInteract(@NotNull Player player, @NotNull Block block, @NotNull BlockFace face); + + /** + * Get listener priority + * @return Listener priority + */ + public @NotNull ListenerPriority getPriority() { + return ListenerPriority.LOWEST; + } + + /** + * Register listener + */ + public void register() { + ProtocolLibrary.getProtocolManager().addPacketListener(this); + } + + /** + * Unregister listener + */ + public void unregister() { + ProtocolLibrary.getProtocolManager().removePacketListener(this); + } + + @Override + public final void onPacketReceiving(@NotNull PacketEvent event) { + Player player = event.getPlayer(); + + // Discard out-of-range packets + List lastTwoTargetBlocks = player.getLastTwoTargetBlocks(null, MAX_BLOCK_DISTANCE); + if (lastTwoTargetBlocks.size() != 2) return; + Block targetBlock = lastTwoTargetBlocks.get(1); + if (!targetBlock.getType().isOccluding()) return; + + // Get target block face + Block adjacentBlock = lastTwoTargetBlocks.get(0); + BlockFace targetBlockFace = targetBlock.getFace(adjacentBlock); + if (targetBlockFace == null) return; + + // Get action + EnumWrappers.EntityUseAction action; + if (Internals.MINECRAFT_VERSION < 17) { + action = event.getPacket().getEntityUseActions().read(0); + } else { + action = event.getPacket().getEnumEntityUseActions().read(0).getAction(); + } + + // Notify handler + boolean allowEvent = true; + if (action == EnumWrappers.EntityUseAction.ATTACK) { + allowEvent = onAttack(player, targetBlock, targetBlockFace); + } else if (action == EnumWrappers.EntityUseAction.INTERACT_AT) { + allowEvent = onInteract(player, targetBlock, targetBlockFace); + } + + // Cancel event (if needed) + if (!allowEvent) { + event.setCancelled(true); + } + } + + @Override + public final void onPacketSending(PacketEvent event) { + // Intentionally left blank + } + + @Override + public final ListeningWhitelist getReceivingWhitelist() { + return ListeningWhitelist.newBuilder() + .priority(getPriority()) + .types(PacketType.Play.Client.USE_ENTITY) + .build(); + } + + @Override + public final ListeningWhitelist getSendingWhitelist() { + return ListeningWhitelist.EMPTY_WHITELIST; + } + + @Override + public final Plugin getPlugin() { + return YamipaPlugin.getInstance(); + } +} diff --git a/src/main/java/io/josemmo/bukkit/plugin/utils/SelectBlockTask.java b/src/main/java/io/josemmo/bukkit/plugin/utils/SelectBlockTask.java index 53cfba1..e5ff860 100644 --- a/src/main/java/io/josemmo/bukkit/plugin/utils/SelectBlockTask.java +++ b/src/main/java/io/josemmo/bukkit/plugin/utils/SelectBlockTask.java @@ -1,11 +1,6 @@ package io.josemmo.bukkit.plugin.utils; -import com.comphenix.protocol.PacketType; -import com.comphenix.protocol.ProtocolLibrary; -import com.comphenix.protocol.events.ListeningWhitelist; -import com.comphenix.protocol.events.PacketEvent; -import com.comphenix.protocol.events.PacketListener; -import com.comphenix.protocol.wrappers.EnumWrappers; +import com.comphenix.protocol.events.ListenerPriority; import io.josemmo.bukkit.plugin.YamipaPlugin; import org.bukkit.ChatColor; import org.bukkit.Location; @@ -17,13 +12,13 @@ import org.bukkit.event.HandlerList; import org.bukkit.event.Listener; import org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerAnimationEvent; +import org.bukkit.event.player.PlayerAnimationType; import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.event.player.PlayerQuitEvent; -import org.bukkit.plugin.Plugin; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.HashMap; -import java.util.List; import java.util.Map; import java.util.UUID; import java.util.function.BiConsumer; @@ -31,7 +26,7 @@ public class SelectBlockTask { private static final YamipaPlugin plugin = YamipaPlugin.getInstance(); private static final Map instances = new HashMap<>(); - private static PlayerInteractionListener listener = null; + private static SelectBlockTaskListener listener = null; private final Player player; private BiConsumer success; private Runnable failure; @@ -76,9 +71,8 @@ public void run(@NotNull String helpMessage) { // Create listener singleton if needed if (listener == null) { - listener = new PlayerInteractionListener(); - plugin.getServer().getPluginManager().registerEvents(listener, plugin); - ProtocolLibrary.getProtocolManager().addPacketListener(listener); + listener = new SelectBlockTaskListener(); + listener.register(); plugin.fine("Created PlayerInteractionListener singleton"); } @@ -100,49 +94,66 @@ public void cancel() { // Destroy listener singleton if no more active tasks if (instances.isEmpty()) { - HandlerList.unregisterAll(listener); - ProtocolLibrary.getProtocolManager().removePacketListener(listener); + listener.unregister(); listener = null; - plugin.fine("Destroyed PlayerInteractionListener singleton"); + plugin.fine("Destroyed SelectBlockTaskListener singleton"); } } /** * Internal listener for handling player events */ - private static class PlayerInteractionListener implements Listener, PacketListener { - @EventHandler - public void onPlayerInteraction(PlayerInteractEvent event) { + private static class SelectBlockTaskListener extends InteractWithEntityListener implements Listener { + @Override + public void register() { + super.register(); + plugin.getServer().getPluginManager().registerEvents(this, plugin); + } + + @Override + public void unregister() { + super.unregister(); + HandlerList.unregisterAll(this); + } + + @Override + public @NotNull ListenerPriority getPriority() { + return ListenerPriority.LOW; + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOW) + public void onBlockInteraction(@NotNull PlayerInteractEvent event) { Action action = event.getAction(); + Player player = event.getPlayer(); Block block = event.getClickedBlock(); + if (block == null) return; + BlockFace face = event.getBlockFace(); - // Get task responsible for handling this event - UUID uuid = event.getPlayer().getUniqueId(); - SelectBlockTask task = instances.get(uuid); - if (task == null) return; - - // Player canceled the task + // Handle failure event if (action == Action.LEFT_CLICK_AIR || action == Action.LEFT_CLICK_BLOCK) { event.setCancelled(true); - task.cancel(); - if (task.failure != null) { - task.failure.run(); - } + handle(player, null, null); return; } - // Player selected a block - if (action == Action.RIGHT_CLICK_BLOCK && block != null) { + // Handle success event + if (action == Action.RIGHT_CLICK_BLOCK) { event.setCancelled(true); - task.cancel(); - if (task.success != null) { - task.success.accept(block.getLocation(), event.getBlockFace()); - } + handle(player, block, face); + } + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOWEST) + public void onArmSwing(@NotNull PlayerAnimationEvent event) { + if (event.getAnimationType() != PlayerAnimationType.ARM_SWING) { + // Sanity check, vanilla Minecraft does not have any other player animation type + return; } + handle(event.getPlayer(), null, null); } @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR) - public void onPlayerQuit(PlayerQuitEvent event) { + public void onPlayerQuit(@NotNull PlayerQuitEvent event) { UUID uuid = event.getPlayer().getUniqueId(); SelectBlockTask task = instances.get(uuid); if (task != null) { @@ -151,68 +162,38 @@ public void onPlayerQuit(PlayerQuitEvent event) { } @Override - public void onPacketReceiving(PacketEvent event) { - Player player = event.getPlayer(); + public boolean onAttack(@NotNull Player player, @NotNull Block block, @NotNull BlockFace face) { + handle(player, null, null); + return false; + } + + @Override + public boolean onInteract(@NotNull Player player, @NotNull Block block, @NotNull BlockFace face) { + handle(player, block, face); + return false; + } + private void handle(@NotNull Player player, @Nullable Block block, @Nullable BlockFace face) { // Get task responsible for handling this event UUID uuid = player.getUniqueId(); SelectBlockTask task = instances.get(uuid); if (task == null) return; - // Get action - EnumWrappers.EntityUseAction action; - if (Internals.MINECRAFT_VERSION < 17) { - action = event.getPacket().getEntityUseActions().read(0); - } else { - action = event.getPacket().getEnumEntityUseActions().read(0).getAction(); - } + // Cancel task + task.cancel(); - // Player left clicked an entity - if (action == EnumWrappers.EntityUseAction.ATTACK) { - event.setCancelled(true); - task.cancel(); + // Notify failure listener + if (block == null || face == null) { if (task.failure != null) { task.failure.run(); } return; } - // Player right clicked an entity - if (action == EnumWrappers.EntityUseAction.INTERACT_AT) { - int maxDistance = 5; // Server should only accept entities within a 4-block radius - List lastTwoTargetBlocks = player.getLastTwoTargetBlocks(null, maxDistance); - if (lastTwoTargetBlocks.size() != 2) return; - Block targetBlock = lastTwoTargetBlocks.get(1); - Block adjacentBlock = lastTwoTargetBlocks.get(0); - if (!targetBlock.getType().isOccluding()) return; - - BlockFace targetBlockFace = targetBlock.getFace(adjacentBlock); - event.setCancelled(true); - task.cancel(); - if (task.success != null) { - task.success.accept(targetBlock.getLocation(), targetBlockFace); - } + // Notify success listener + if (task.success != null) { + task.success.accept(block.getLocation(), face); } } - - @Override - public void onPacketSending(PacketEvent event) { - // Intentionally left blank - } - - @Override - public ListeningWhitelist getReceivingWhitelist() { - return ListeningWhitelist.newBuilder().types(PacketType.Play.Client.USE_ENTITY).build(); - } - - @Override - public ListeningWhitelist getSendingWhitelist() { - return ListeningWhitelist.EMPTY_WHITELIST; - } - - @Override - public Plugin getPlugin() { - return plugin; - } } } diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index b2d7c35..475c224 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -3,7 +3,7 @@ version: ${project.version} main: io.josemmo.bukkit.plugin.YamipaPlugin api-version: 1.13 depend: [ProtocolLib] -softdepend: [Multiverse-Core] +softdepend: [Multiverse-Core, Hyperverse] permissions: yamipa.*: @@ -12,6 +12,7 @@ permissions: yamipa.clear: true yamipa.describe: true yamipa.download: true + yamipa.give: true yamipa.list: true yamipa.place: true yamipa.remove: true @@ -22,6 +23,8 @@ permissions: default: op yamipa.download: default: op + yamipa.give: + default: op yamipa.list: default: op yamipa.place: